diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index f038a00..a02322d 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -243,7 +243,7 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ }, SlashCommandSpec { name: "skills", - aliases: &[], + aliases: &["skill"], summary: "List, install, or invoke available skills", argument_hint: Some("[list|install |help| [args]]"), resume_supported: true, @@ -1312,7 +1312,7 @@ pub fn validate_slash_command_input( "agents" => SlashCommand::Agents { args: parse_list_or_help_args(command, remainder)?, }, - "skills" => SlashCommand::Skills { + "skills" | "skill" => SlashCommand::Skills { args: parse_skills_args(remainder.as_deref())?, }, "doctor" => { @@ -1975,9 +1975,9 @@ enum DefinitionScope { impl DefinitionScope { fn label(self) -> &'static str { match self { - Self::Project => "Project (.claw)", - Self::UserConfigHome => "User ($CLAW_CONFIG_HOME)", - Self::UserHome => "User (~/.claw)", + Self::Project => "Project roots", + Self::UserConfigHome => "User config roots", + Self::UserHome => "User home roots", } } } @@ -2548,6 +2548,14 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P ); } + if let Ok(claude_config_dir) = env::var("CLAUDE_CONFIG_DIR") { + push_unique_root( + &mut roots, + DefinitionSource::UserClaude, + PathBuf::from(claude_config_dir).join(leaf), + ); + } + if let Some(home) = env::var_os("HOME") { let home = PathBuf::from(home); push_unique_root( @@ -2581,6 +2589,18 @@ fn discover_skill_roots(cwd: &Path) -> Vec { ancestor.join(".claw").join("skills"), SkillOrigin::SkillsDir, ); + push_unique_skill_root( + &mut roots, + DefinitionSource::ProjectClaw, + ancestor.join(".omc").join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::ProjectClaw, + ancestor.join(".agents").join("skills"), + SkillOrigin::SkillsDir, + ); push_unique_skill_root( &mut roots, DefinitionSource::ProjectCodex, @@ -2653,6 +2673,12 @@ fn discover_skill_roots(cwd: &Path) -> Vec { home.join(".claw").join("skills"), SkillOrigin::SkillsDir, ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserClaw, + home.join(".omc").join("skills"), + SkillOrigin::SkillsDir, + ); push_unique_skill_root( &mut roots, DefinitionSource::UserClaw, @@ -2677,6 +2703,12 @@ fn discover_skill_roots(cwd: &Path) -> Vec { home.join(".claude").join("skills"), SkillOrigin::SkillsDir, ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserClaude, + home.join(".claude").join("skills").join("omc-learned"), + SkillOrigin::SkillsDir, + ); push_unique_skill_root( &mut roots, DefinitionSource::UserClaude, @@ -2685,6 +2717,29 @@ fn discover_skill_roots(cwd: &Path) -> Vec { ); } + if let Ok(claude_config_dir) = env::var("CLAUDE_CONFIG_DIR") { + let claude_config_dir = PathBuf::from(claude_config_dir); + let skills_dir = claude_config_dir.join("skills"); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserClaude, + skills_dir.clone(), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserClaude, + skills_dir.join("omc-learned"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserClaude, + claude_config_dir.join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + } + roots } @@ -3467,10 +3522,11 @@ fn render_skills_usage(unexpected: Option<&str>) -> String { let mut lines = vec![ "Skills".to_string(), " Usage /skills [list|install |help| [args]]".to_string(), + " Alias /skill".to_string(), " Direct CLI claw skills [list|install |help| [args]]".to_string(), " Invoke /skills help overview -> $help overview".to_string(), " Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills".to_string(), - " Sources .claw/skills, ~/.claw/skills, legacy /commands".to_string(), + " Sources .claw/skills, .omc/skills, .agents/skills, .codex/skills, .claude/skills, ~/.claw/skills, ~/.omc/skills, ~/.claude/skills/omc-learned, ~/.codex/skills, ~/.claude/skills, legacy /commands".to_string(), ]; if let Some(args) = unexpected { lines.push(format!(" Unexpected {args}")); @@ -3484,10 +3540,24 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value { "action": "help", "usage": { "slash_command": "/skills [list|install |help| [args]]", + "aliases": ["/skill"], "direct_cli": "claw skills [list|install |help| [args]]", "invoke": "/skills help overview -> $help overview", "install_root": "$CLAW_CONFIG_HOME/skills or ~/.claw/skills", - "sources": [".claw/skills", "legacy /commands", "legacy fallback dirs still load automatically"], + "sources": [ + ".claw/skills", + ".omc/skills", + ".agents/skills", + ".codex/skills", + ".claude/skills", + "~/.claw/skills", + "~/.omc/skills", + "~/.claude/skills/omc-learned", + "~/.codex/skills", + "~/.claude/skills", + "legacy /commands", + "legacy fallback dirs still load automatically" + ], }, "unexpected": unexpected, }) @@ -3849,8 +3919,10 @@ mod tests { use runtime::{ CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session, }; + use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; + use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_dir(label: &str) -> PathBuf { @@ -3861,6 +3933,18 @@ mod tests { std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}")) } + fn env_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + } + + fn restore_env_var(key: &str, original: Option) { + match original { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + fn write_external_plugin(root: &Path, name: &str, version: &str) { fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); fs::write( @@ -4275,6 +4359,7 @@ mod tests { assert!(help.contains("aliases: /plugins, /marketplace")); assert!(help.contains("/agents [list|help]")); assert!(help.contains("/skills [list|install |help| [args]]")); + assert!(help.contains("aliases: /skill")); assert_eq!(slash_command_specs().len(), 141); assert!(resume_supported_slash_commands().len() >= 39); } @@ -4517,10 +4602,10 @@ mod tests { assert!(report.contains("Agents")); assert!(report.contains("2 active agents")); - assert!(report.contains("Project (.claw):")); + assert!(report.contains("Project roots:")); assert!(report.contains("planner · Project planner · gpt-5.4 · medium")); - assert!(report.contains("User (~/.claw):")); - assert!(report.contains("(shadowed by Project (.claw)) planner · User planner")); + assert!(report.contains("User home roots:")); + assert!(report.contains("(shadowed by Project roots) planner · User planner")); assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high")); let _ = fs::remove_dir_all(workspace); @@ -4628,11 +4713,11 @@ mod tests { assert!(report.contains("Skills")); assert!(report.contains("3 available skills")); - assert!(report.contains("Project (.claw):")); + assert!(report.contains("Project roots:")); assert!(report.contains("plan · Project planning guidance")); assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands")); - assert!(report.contains("User (~/.claw):")); - assert!(report.contains("(shadowed by Project (.claw)) plan · User planning guidance")); + assert!(report.contains("User home roots:")); + assert!(report.contains("(shadowed by Project roots) plan · User planning guidance")); assert!(report.contains("help · Help guidance")); let _ = fs::remove_dir_all(workspace); @@ -4704,6 +4789,7 @@ mod tests { let help = handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help"); assert_eq!(help["kind"], "skills"); assert_eq!(help["action"], "help"); + assert_eq!(help["usage"]["aliases"][0], "/skill"); assert_eq!( help["usage"]["direct_cli"], "claw skills [list|install |help| [args]]" @@ -4732,8 +4818,12 @@ mod tests { super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help"); assert!(skills_help .contains("Usage /skills [list|install |help| [args]]")); + assert!(skills_help.contains("Alias /skill")); assert!(skills_help.contains("Invoke /skills help overview -> $help overview")); assert!(skills_help.contains("Install root $CLAW_CONFIG_HOME/skills or ~/.claw/skills")); + assert!(skills_help.contains(".omc/skills")); + assert!(skills_help.contains(".agents/skills")); + assert!(skills_help.contains("~/.claude/skills/omc-learned")); assert!(skills_help.contains("legacy /commands")); let skills_unexpected = @@ -4744,6 +4834,7 @@ mod tests { .expect("nested skills help"); assert!(skills_install_help .contains("Usage /skills [list|install |help| [args]]")); + assert!(skills_install_help.contains("Alias /skill")); assert!(skills_install_help.contains("Unexpected install")); let skills_unknown_help = @@ -4752,9 +4843,87 @@ mod tests { .contains("Usage /skills [list|install |help| [args]]")); assert!(skills_unknown_help.contains("Unexpected show")); + let skills_help_json = + super::handle_skills_slash_command_json(Some("help"), &cwd).expect("skills help json"); + let sources = skills_help_json["usage"]["sources"] + .as_array() + .expect("skills help sources"); + assert_eq!(skills_help_json["usage"]["aliases"][0], "/skill"); + assert!(sources.iter().any(|value| value == ".omc/skills")); + assert!(sources.iter().any(|value| value == ".agents/skills")); + assert!(sources.iter().any(|value| value == "~/.omc/skills")); + assert!(sources + .iter() + .any(|value| value == "~/.claude/skills/omc-learned")); + let _ = fs::remove_dir_all(cwd); } + #[test] + fn discovers_omc_skills_from_project_and_user_compatibility_roots() { + let _guard = env_lock().lock().expect("env lock"); + let workspace = temp_dir("skills-omc-workspace"); + let user_home = temp_dir("skills-omc-home"); + let claude_config_dir = temp_dir("skills-omc-claude-config"); + let project_omc_skills = workspace.join(".omc").join("skills"); + let project_agents_skills = workspace.join(".agents").join("skills"); + let user_omc_skills = user_home.join(".omc").join("skills"); + let claude_config_skills = claude_config_dir.join("skills"); + let claude_config_commands = claude_config_dir.join("commands"); + let learned_skills = claude_config_dir.join("skills").join("omc-learned"); + let original_home = std::env::var_os("HOME"); + let original_claude_config_dir = std::env::var_os("CLAUDE_CONFIG_DIR"); + + write_skill(&project_omc_skills, "hud", "OMC HUD guidance"); + write_skill( + &project_agents_skills, + "trace", + "Compatibility skill guidance", + ); + write_skill(&user_omc_skills, "cancel", "OMC cancel guidance"); + write_skill( + &claude_config_skills, + "statusline", + "Claude config skill guidance", + ); + write_legacy_command( + &claude_config_commands, + "doctor-check", + "Claude config command guidance", + ); + write_skill(&learned_skills, "learned", "Learned skill guidance"); + std::env::set_var("HOME", &user_home); + std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir); + + let report = super::handle_skills_slash_command(None, &workspace).expect("skills list"); + assert!(report.contains("available skills")); + assert!(report.contains("hud · OMC HUD guidance")); + assert!(report.contains("trace · Compatibility skill guidance")); + assert!(report.contains("cancel · OMC cancel guidance")); + assert!(report.contains("statusline · Claude config skill guidance")); + assert!(report.contains("doctor-check · Claude config command guidance · legacy /commands")); + assert!(report.contains("learned · Learned skill guidance")); + + let help = + super::handle_skills_slash_command_json(Some("help"), &workspace).expect("skills help"); + let sources = help["usage"]["sources"] + .as_array() + .expect("skills help sources"); + assert_eq!(help["usage"]["aliases"][0], "/skill"); + assert!(sources.iter().any(|value| value == ".omc/skills")); + assert!(sources.iter().any(|value| value == ".agents/skills")); + assert!(sources.iter().any(|value| value == "~/.omc/skills")); + assert!(sources + .iter() + .any(|value| value == "~/.claude/skills/omc-learned")); + + restore_env_var("HOME", original_home); + restore_env_var("CLAUDE_CONFIG_DIR", original_claude_config_dir); + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(user_home); + let _ = fs::remove_dir_all(claude_config_dir); + } + #[test] fn mcp_usage_supports_help_and_unexpected_args() { let cwd = temp_dir("mcp-usage"); @@ -4991,7 +5160,7 @@ mod tests { let listed = render_skills_report( &load_skills_from_roots(&roots).expect("installed skills should load"), ); - assert!(listed.contains("User ($CLAW_CONFIG_HOME):")); + assert!(listed.contains("User config roots:")); assert!(listed.contains("help · Helpful skill")); let _ = fs::remove_dir_all(workspace); diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 3070d17..052d1ba 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -920,6 +920,9 @@ pub enum PluginManifestValidationError { tool_name: String, permission: String, }, + UnsupportedManifestContract { + detail: String, + }, } impl Display for PluginManifestValidationError { @@ -965,6 +968,7 @@ impl Display for PluginManifestValidationError { f, "plugin tool `{tool_name}` requiredPermission `{permission}` must be read-only, workspace-write, or danger-full-access" ), + Self::UnsupportedManifestContract { detail } => f.write_str(detail), } } } @@ -1594,10 +1598,73 @@ fn load_manifest_from_path( manifest_path.display() )) })?; - let raw_manifest: RawPluginManifest = serde_json::from_str(&contents)?; + let raw_json: Value = serde_json::from_str(&contents)?; + let compatibility_errors = detect_claude_code_manifest_contract_gaps(&raw_json); + if !compatibility_errors.is_empty() { + return Err(PluginError::ManifestValidation(compatibility_errors)); + } + let raw_manifest: RawPluginManifest = serde_json::from_value(raw_json)?; build_plugin_manifest(root, raw_manifest) } +fn detect_claude_code_manifest_contract_gaps( + raw_manifest: &Value, +) -> Vec { + let Some(root) = raw_manifest.as_object() else { + return Vec::new(); + }; + + let mut errors = Vec::new(); + + for (field, detail) in [ + ( + "skills", + "plugin manifest field `skills` uses the Claude Code plugin contract; `claw` does not load plugin-managed skills and instead discovers skills from local roots such as `.claw/skills`, `.omc/skills`, `.agents/skills`, `~/.omc/skills`, and `~/.claude/skills/omc-learned`.", + ), + ( + "mcpServers", + "plugin manifest field `mcpServers` uses the Claude Code plugin contract; `claw` does not import MCP servers from plugin manifests.", + ), + ( + "agents", + "plugin manifest field `agents` uses the Claude Code plugin contract; `claw` does not load plugin-managed agent markdown catalogs from plugin manifests.", + ), + ] { + if root.contains_key(field) { + errors.push(PluginManifestValidationError::UnsupportedManifestContract { + detail: detail.to_string(), + }); + } + } + + if root + .get("commands") + .and_then(Value::as_array) + .is_some_and(|commands| commands.iter().any(Value::is_string)) + { + errors.push(PluginManifestValidationError::UnsupportedManifestContract { + detail: "plugin manifest field `commands` uses Claude Code-style directory globs; `claw` slash dispatch is still built-in and does not load plugin slash command markdown files.".to_string(), + }); + } + + if let Some(hooks) = root.get("hooks").and_then(Value::as_object) { + for hook_name in hooks.keys() { + if !matches!( + hook_name.as_str(), + "PreToolUse" | "PostToolUse" | "PostToolUseFailure" + ) { + errors.push(PluginManifestValidationError::UnsupportedManifestContract { + detail: format!( + "plugin hook `{hook_name}` uses the Claude Code lifecycle contract; `claw` plugins currently support only PreToolUse, PostToolUse, and PostToolUseFailure." + ), + }); + } + } + } + + errors +} + fn plugin_manifest_path(root: &Path) -> Result { let direct_path = root.join(MANIFEST_FILE_NAME); if direct_path.exists() { @@ -2517,6 +2584,37 @@ mod tests { let _ = fs::remove_dir_all(root); } + #[test] + fn load_plugin_from_directory_rejects_claude_code_manifest_contracts_with_guidance() { + let root = temp_dir("manifest-claude-code-contract"); + write_file( + root.join(MANIFEST_FILE_NAME).as_path(), + r#"{ + "name": "oh-my-claudecode", + "version": "4.10.2", + "description": "Claude Code plugin manifest", + "hooks": { + "SessionStart": ["scripts/session-start.mjs"] + }, + "agents": ["agents/*.md"], + "commands": ["commands/**/*.md"], + "skills": "./skills/", + "mcpServers": "./.mcp.json" +}"#, + ); + + let error = load_plugin_from_directory(&root) + .expect_err("Claude Code plugin manifest should fail with guidance"); + let rendered = error.to_string(); + assert!(rendered.contains("field `skills` uses the Claude Code plugin contract")); + assert!(rendered.contains("field `mcpServers` uses the Claude Code plugin contract")); + assert!(rendered.contains("field `agents` uses the Claude Code plugin contract")); + assert!(rendered.contains("field `commands` uses Claude Code-style directory globs")); + assert!(rendered.contains("hook `SessionStart` uses the Claude Code lifecycle contract")); + + let _ = fs::remove_dir_all(root); + } + #[test] fn load_plugin_from_directory_rejects_missing_tool_or_command_paths() { let root = temp_dir("manifest-paths"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 693e944..fd99f4d 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -128,6 +128,11 @@ fn run() -> Result<(), Box> { args, output_format, } => LiveCli::print_skills(args.as_deref(), output_format)?, + CliAction::Plugins { + action, + target, + output_format, + } => LiveCli::print_plugins(action.as_deref(), target.as_deref(), output_format)?, CliAction::PrintSystemPrompt { cwd, date, @@ -188,6 +193,11 @@ enum CliAction { args: Option, output_format: CliOutputFormat, }, + Plugins { + action: Option, + target: Option, + output_format: CliOutputFormat, + }, PrintSystemPrompt { cwd: PathBuf, date: String, @@ -619,6 +629,10 @@ fn format_unknown_direct_slash_command(name: &str) -> String { message.push('\n'); message.push_str(&suggestions); } + if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { + message.push('\n'); + message.push_str(note); + } message.push_str("\nRun `claw --help` for CLI usage, or start `claw` and use /help."); message } @@ -630,10 +644,21 @@ fn format_unknown_slash_command(name: &str) -> String { message.push('\n'); message.push_str(&suggestions); } + if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { + message.push('\n'); + message.push_str(note); + } message.push_str("\n Help /help lists available slash commands"); message } +fn omc_compatibility_note_for_unknown_slash_command(name: &str) -> Option<&'static str> { + name.starts_with("oh-my-claudecode:") + .then_some( + "Compatibility note: `/oh-my-claudecode:*` is a Claude Code/OMC plugin command. `claw` does not yet load plugin slash commands, Claude statusline stdin, or OMC session hooks.", + ) +} + fn render_suggestion_line(label: &str, suggestions: &[String]) -> Option { (!suggestions.is_empty()).then(|| format!(" {label:<16} {}", suggestions.join(", "),)) } @@ -1885,14 +1910,18 @@ impl GitWorkspaceSummary { #[cfg(test)] fn format_unknown_slash_command_message(name: &str) -> String { let suggestions = suggest_slash_commands(name); - if suggestions.is_empty() { - format!("unknown slash command: /{name}. Use /help to list available commands.") - } else { - format!( - "unknown slash command: /{name}. Did you mean {}? Use /help to list available commands.", - suggestions.join(", ") - ) + let mut message = format!("unknown slash command: /{name}."); + if !suggestions.is_empty() { + message.push_str(" Did you mean "); + message.push_str(&suggestions.join(", ")); + message.push('?'); } + if let Some(note) = omc_compatibility_note_for_unknown_slash_command(name) { + message.push(' '); + message.push_str(note); + } + message.push_str(" Use /help to list available commands."); + message } fn format_model_report(model: &str, message_count: usize, turns: u32) -> String { @@ -3569,6 +3598,32 @@ impl LiveCli { Ok(()) } + fn print_plugins( + action: Option<&str>, + target: Option<&str>, + output_format: CliOutputFormat, + ) -> Result<(), Box> { + let cwd = env::current_dir()?; + let loader = ConfigLoader::default_for(&cwd); + let runtime_config = loader.load()?; + let mut manager = build_plugin_manager(&cwd, &loader, &runtime_config); + let result = handle_plugins_slash_command(action, target, &mut manager)?; + match output_format { + CliOutputFormat::Text => println!("{}", result.message), + CliOutputFormat::Json => println!( + "{}", + serde_json::to_string_pretty(&json!({ + "kind": "plugin", + "action": action.unwrap_or("list"), + "target": target, + "message": result.message, + "reload_runtime": result.reload_runtime, + }))? + ), + } + Ok(()) + } + fn print_diff() -> Result<(), Box> { println!("{}", render_diff_report()?); Ok(()) @@ -7449,6 +7504,13 @@ mod tests { output_format: CliOutputFormat::Text, } ); + assert_eq!( + parse_args(&["/skill".to_string()]).expect("/skill should parse"), + CliAction::Skills { + args: None, + output_format: CliOutputFormat::Text, + } + ); assert_eq!( parse_args(&["/skills".to_string(), "help".to_string()]) .expect("/skills help should parse"), @@ -7457,6 +7519,14 @@ mod tests { output_format: CliOutputFormat::Text, } ); + assert_eq!( + parse_args(&["/skill".to_string(), "list".to_string()]) + .expect("/skill list should parse"), + CliAction::Skills { + args: Some("list".to_string()), + output_format: CliOutputFormat::Text, + } + ); assert_eq!( parse_args(&[ "/skills".to_string(), @@ -7515,6 +7585,16 @@ mod tests { assert!(report.contains("Use /help")); } + #[test] + fn formats_namespaced_omc_slash_command_with_contract_guidance() { + let report = format_unknown_slash_command_message("oh-my-claudecode:hud"); + assert!(report.contains("unknown slash command: /oh-my-claudecode:hud")); + assert!(report.contains("Claude Code/OMC plugin command")); + assert!(report.contains("plugin slash commands")); + assert!(report.contains("statusline")); + assert!(report.contains("session hooks")); + } + #[test] fn parses_resume_flag_with_slash_command() { let args = vec![ @@ -8312,6 +8392,14 @@ UU conflicted.rs", assert!(message.contains("/help")); } + #[test] + fn unknown_omc_slash_command_guidance_explains_runtime_gap() { + let message = format_unknown_slash_command("oh-my-claudecode:hud"); + assert!(message.contains("Unknown slash command: /oh-my-claudecode:hud")); + assert!(message.contains("Claude Code/OMC plugin command")); + assert!(message.contains("does not yet load plugin slash commands")); + } + #[test] fn resume_usage_mentions_latest_shortcut() { let usage = render_resume_usage(); diff --git a/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs b/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs index 8988291..21a93e2 100644 --- a/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs +++ b/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs @@ -104,6 +104,31 @@ fn slash_command_names_match_known_commands_and_suggest_nearby_unknown_ones() { fs::remove_dir_all(temp_dir).expect("cleanup temp dir"); } +#[test] +fn omc_namespaced_slash_commands_surface_a_targeted_compatibility_hint() { + let temp_dir = unique_temp_dir("slash-dispatch-omc"); + fs::create_dir_all(&temp_dir).expect("temp dir should exist"); + + let output = Command::new(env!("CARGO_BIN_EXE_claw")) + .current_dir(&temp_dir) + .arg("/oh-my-claudecode:hud") + .output() + .expect("claw should launch"); + + assert!( + !output.status.success(), + "stdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let stderr = String::from_utf8(output.stderr).expect("stderr should be utf8"); + assert!(stderr.contains("unknown slash command outside the REPL: /oh-my-claudecode:hud")); + assert!(stderr.contains("Claude Code/OMC plugin command")); + assert!(stderr.contains("does not yet load plugin slash commands")); + + fs::remove_dir_all(temp_dir).expect("cleanup temp dir"); +} + #[test] fn config_command_loads_defaults_from_standard_config_locations() { // given diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index ac24fde..d80e995 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -2977,6 +2977,244 @@ fn resolve_skill_path(skill: &str) -> Result { commands::resolve_skill_path(&cwd, skill).map_err(|error| error.to_string()) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SkillLookupOrigin { + SkillsDir, + LegacyCommandsDir, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SkillLookupRoot { + path: std::path::PathBuf, + origin: SkillLookupOrigin, +} + +fn skill_lookup_roots() -> Vec { + let mut roots = Vec::new(); + + if let Ok(cwd) = std::env::current_dir() { + push_project_skill_lookup_roots(&mut roots, &cwd); + } + + if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") { + push_prefixed_skill_lookup_roots(&mut roots, std::path::Path::new(&claw_config_home)); + } + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + push_prefixed_skill_lookup_roots(&mut roots, std::path::Path::new(&codex_home)); + } + if let Ok(home) = std::env::var("HOME") { + push_home_skill_lookup_roots(&mut roots, std::path::Path::new(&home)); + } + if let Ok(claude_config_dir) = std::env::var("CLAUDE_CONFIG_DIR") { + let claude_config_dir = std::path::PathBuf::from(claude_config_dir); + push_skill_lookup_root( + &mut roots, + claude_config_dir.join("skills"), + SkillLookupOrigin::SkillsDir, + ); + push_skill_lookup_root( + &mut roots, + claude_config_dir.join("skills").join("omc-learned"), + SkillLookupOrigin::SkillsDir, + ); + push_skill_lookup_root( + &mut roots, + claude_config_dir.join("commands"), + SkillLookupOrigin::LegacyCommandsDir, + ); + } + push_skill_lookup_root( + &mut roots, + std::path::PathBuf::from("/home/bellman/.claw/skills"), + SkillLookupOrigin::SkillsDir, + ); + push_skill_lookup_root( + &mut roots, + std::path::PathBuf::from("/home/bellman/.codex/skills"), + SkillLookupOrigin::SkillsDir, + ); + + roots +} + +fn push_project_skill_lookup_roots(roots: &mut Vec, cwd: &std::path::Path) { + for ancestor in cwd.ancestors() { + push_prefixed_skill_lookup_roots(roots, &ancestor.join(".omc")); + push_prefixed_skill_lookup_roots(roots, &ancestor.join(".agents")); + push_prefixed_skill_lookup_roots(roots, &ancestor.join(".claw")); + push_prefixed_skill_lookup_roots(roots, &ancestor.join(".codex")); + push_prefixed_skill_lookup_roots(roots, &ancestor.join(".claude")); + } +} + +fn push_home_skill_lookup_roots(roots: &mut Vec, home: &std::path::Path) { + push_prefixed_skill_lookup_roots(roots, &home.join(".omc")); + push_prefixed_skill_lookup_roots(roots, &home.join(".claw")); + push_prefixed_skill_lookup_roots(roots, &home.join(".codex")); + push_prefixed_skill_lookup_roots(roots, &home.join(".claude")); + push_skill_lookup_root( + roots, + home.join(".agents").join("skills"), + SkillLookupOrigin::SkillsDir, + ); + push_skill_lookup_root( + roots, + home.join(".config").join("opencode").join("skills"), + SkillLookupOrigin::SkillsDir, + ); + push_skill_lookup_root( + roots, + home.join(".claude").join("skills").join("omc-learned"), + SkillLookupOrigin::SkillsDir, + ); +} + +fn push_prefixed_skill_lookup_roots(roots: &mut Vec, prefix: &std::path::Path) { + push_skill_lookup_root(roots, prefix.join("skills"), SkillLookupOrigin::SkillsDir); + push_skill_lookup_root( + roots, + prefix.join("commands"), + SkillLookupOrigin::LegacyCommandsDir, + ); +} + +fn push_skill_lookup_root( + roots: &mut Vec, + path: std::path::PathBuf, + origin: SkillLookupOrigin, +) { + if path.is_dir() && !roots.iter().any(|existing| existing.path == path) { + roots.push(SkillLookupRoot { path, origin }); + } +} + +fn resolve_skill_path_in_root( + root: &SkillLookupRoot, + requested: &str, +) -> Option { + match root.origin { + SkillLookupOrigin::SkillsDir => resolve_skill_path_in_skills_dir(&root.path, requested), + SkillLookupOrigin::LegacyCommandsDir => { + resolve_skill_path_in_legacy_commands_dir(&root.path, requested) + } + } +} + +fn resolve_skill_path_in_skills_dir( + root: &std::path::Path, + requested: &str, +) -> Option { + let direct = root.join(requested).join("SKILL.md"); + if direct.is_file() { + return Some(direct); + } + + let entries = std::fs::read_dir(root).ok()?; + for entry in entries.flatten() { + if !entry.path().is_dir() { + continue; + } + let skill_path = entry.path().join("SKILL.md"); + if !skill_path.is_file() { + continue; + } + if entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(requested) + || skill_frontmatter_name_matches(&skill_path, requested) + { + return Some(skill_path); + } + } + + None +} + +fn resolve_skill_path_in_legacy_commands_dir( + root: &std::path::Path, + requested: &str, +) -> Option { + let direct_dir = root.join(requested).join("SKILL.md"); + if direct_dir.is_file() { + return Some(direct_dir); + } + + let direct_markdown = root.join(format!("{requested}.md")); + if direct_markdown.is_file() { + return Some(direct_markdown); + } + + let entries = std::fs::read_dir(root).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + let candidate_path = if path.is_dir() { + let skill_path = path.join("SKILL.md"); + if !skill_path.is_file() { + continue; + } + skill_path + } else if path + .extension() + .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md")) + { + path + } else { + continue; + }; + + let matches_entry_name = candidate_path + .file_stem() + .is_some_and(|stem| stem.to_string_lossy().eq_ignore_ascii_case(requested)) + || entry + .file_name() + .to_string_lossy() + .trim_end_matches(".md") + .eq_ignore_ascii_case(requested); + if matches_entry_name || skill_frontmatter_name_matches(&candidate_path, requested) { + return Some(candidate_path); + } + } + + None +} + +fn skill_frontmatter_name_matches(path: &std::path::Path, requested: &str) -> bool { + std::fs::read_to_string(path) + .ok() + .and_then(|contents| parse_skill_name(&contents)) + .is_some_and(|name| name.eq_ignore_ascii_case(requested)) +} + +fn parse_skill_name(contents: &str) -> Option { + parse_skill_frontmatter_value(contents, "name") +} + +fn parse_skill_frontmatter_value(contents: &str, key: &str) -> Option { + let mut lines = contents.lines(); + if lines.next().map(str::trim) != Some("---") { + return None; + } + + for line in lines { + let trimmed = line.trim(); + if trimmed == "---" { + break; + } + if let Some(value) = trimmed.strip_prefix(&format!("{key}:")) { + let value = value + .trim() + .trim_matches(|ch| matches!(ch, '"' | '\'')) + .trim(); + if !value.is_empty() { + return Some(value.to_string()); + } + } + } + + None +} + const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6"; const DEFAULT_AGENT_SYSTEM_DATE: &str = "2026-03-31"; const DEFAULT_AGENT_MAX_ITERATIONS: usize = 32; @@ -5796,6 +6034,305 @@ mod tests { fs::remove_dir_all(root).expect("temp project should clean up"); } + #[test] + fn skill_loads_project_local_claude_skill_prompt() { + let _guard = env_lock().lock().expect("env lock should acquire"); + let root = temp_path("project-skills"); + let home = root.join("home"); + let workspace = root.join("workspace"); + let nested = workspace.join("nested"); + let skill_dir = workspace.join(".claude").join("skills").join("trace"); + fs::create_dir_all(&skill_dir).expect("skill dir should exist"); + fs::create_dir_all(&nested).expect("nested cwd should exist"); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: trace\ndescription: Project-local trace helper\n---\n# trace\n", + ) + .expect("skill file should exist"); + + let original_home = std::env::var("HOME").ok(); + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_codex_home = std::env::var("CODEX_HOME").ok(); + let original_dir = std::env::current_dir().expect("cwd"); + std::env::set_var("HOME", &home); + std::env::remove_var("CLAW_CONFIG_HOME"); + std::env::remove_var("CODEX_HOME"); + std::env::set_current_dir(&nested).expect("set cwd"); + + let result = execute_tool("Skill", &json!({ "skill": "trace" })) + .expect("project-local skill should resolve"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert!(output["path"] + .as_str() + .expect("path") + .ends_with(".claude/skills/trace/SKILL.md")); + assert_eq!(output["description"], "Project-local trace helper"); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_codex_home { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + fs::remove_dir_all(root).expect("temp tree should clean up"); + } + + #[test] + fn skill_loads_project_local_omc_and_agents_skill_prompts() { + let _guard = env_lock().lock().expect("env lock should acquire"); + let root = temp_path("project-omc-skills"); + let home = root.join("home"); + let workspace = root.join("workspace"); + let nested = workspace.join("nested"); + let omc_skill_dir = workspace.join(".omc").join("skills").join("hud"); + let agents_skill_dir = workspace.join(".agents").join("skills").join("trace"); + fs::create_dir_all(&omc_skill_dir).expect("omc skill dir should exist"); + fs::create_dir_all(&agents_skill_dir).expect("agents skill dir should exist"); + fs::create_dir_all(&nested).expect("nested cwd should exist"); + fs::write( + omc_skill_dir.join("SKILL.md"), + "---\nname: hud\ndescription: Project-local OMC HUD helper\n---\n# hud\n", + ) + .expect("omc skill file should exist"); + fs::write( + agents_skill_dir.join("SKILL.md"), + "---\nname: trace\ndescription: Project-local agents compatibility helper\n---\n# trace\n", + ) + .expect("agents skill file should exist"); + + let original_home = std::env::var("HOME").ok(); + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_codex_home = std::env::var("CODEX_HOME").ok(); + let original_dir = std::env::current_dir().expect("cwd"); + std::env::set_var("HOME", &home); + std::env::remove_var("CLAW_CONFIG_HOME"); + std::env::remove_var("CODEX_HOME"); + std::env::set_current_dir(&nested).expect("set cwd"); + + let omc_result = + execute_tool("Skill", &json!({ "skill": "hud" })).expect("omc skill should resolve"); + let agents_result = execute_tool("Skill", &json!({ "skill": "trace" })) + .expect("agents skill should resolve"); + + let omc_output: serde_json::Value = serde_json::from_str(&omc_result).expect("valid json"); + let agents_output: serde_json::Value = + serde_json::from_str(&agents_result).expect("valid json"); + assert!(omc_output["path"] + .as_str() + .expect("path") + .ends_with(".omc/skills/hud/SKILL.md")); + assert_eq!(omc_output["description"], "Project-local OMC HUD helper"); + assert!(agents_output["path"] + .as_str() + .expect("path") + .ends_with(".agents/skills/trace/SKILL.md")); + assert_eq!( + agents_output["description"], + "Project-local agents compatibility helper" + ); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_codex_home { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + fs::remove_dir_all(root).expect("temp tree should clean up"); + } + + #[test] + fn skill_loads_learned_skill_from_claude_config_dir() { + let _guard = env_lock().lock().expect("env lock should acquire"); + let root = temp_path("claude-config-learned-skill"); + let home = root.join("home"); + let claude_config_dir = root.join("claude-config"); + let learned_skill_dir = claude_config_dir + .join("skills") + .join("omc-learned") + .join("learned"); + fs::create_dir_all(&learned_skill_dir).expect("learned skill dir should exist"); + fs::write( + learned_skill_dir.join("SKILL.md"), + "---\nname: learned\ndescription: Learned OMC skill\n---\n# learned\n", + ) + .expect("learned skill file should exist"); + + let original_home = std::env::var("HOME").ok(); + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_codex_home = std::env::var("CODEX_HOME").ok(); + let original_claude_config_dir = std::env::var("CLAUDE_CONFIG_DIR").ok(); + std::env::set_var("HOME", &home); + std::env::remove_var("CLAW_CONFIG_HOME"); + std::env::remove_var("CODEX_HOME"); + std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir); + + let result = execute_tool("Skill", &json!({ "skill": "learned" })) + .expect("learned skill should resolve"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert!(output["path"] + .as_str() + .expect("path") + .ends_with("skills/omc-learned/learned/SKILL.md")); + assert_eq!(output["description"], "Learned OMC skill"); + + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_codex_home { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + match original_claude_config_dir { + Some(value) => std::env::set_var("CLAUDE_CONFIG_DIR", value), + None => std::env::remove_var("CLAUDE_CONFIG_DIR"), + } + fs::remove_dir_all(root).expect("temp tree should clean up"); + } + + #[test] + fn skill_loads_direct_skill_and_legacy_command_from_claude_config_dir() { + let _guard = env_lock().lock().expect("env lock should acquire"); + let root = temp_path("claude-config-direct-skill"); + let home = root.join("home"); + let claude_config_dir = root.join("claude-config"); + let skill_dir = claude_config_dir.join("skills").join("statusline"); + let command_dir = claude_config_dir.join("commands"); + fs::create_dir_all(&skill_dir).expect("direct skill dir should exist"); + fs::create_dir_all(&command_dir).expect("command dir should exist"); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: statusline\ndescription: Claude config skill\n---\n# statusline\n", + ) + .expect("direct skill file should exist"); + fs::write( + command_dir.join("doctor-check.md"), + "---\nname: doctor-check\ndescription: Claude config command\n---\n# doctor-check\n", + ) + .expect("direct command file should exist"); + + let original_home = std::env::var("HOME").ok(); + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_codex_home = std::env::var("CODEX_HOME").ok(); + let original_claude_config_dir = std::env::var("CLAUDE_CONFIG_DIR").ok(); + std::env::set_var("HOME", &home); + std::env::remove_var("CLAW_CONFIG_HOME"); + std::env::remove_var("CODEX_HOME"); + std::env::set_var("CLAUDE_CONFIG_DIR", &claude_config_dir); + + let direct_skill = + execute_tool("Skill", &json!({ "skill": "statusline" })).expect("direct skill"); + let direct_skill_output: serde_json::Value = + serde_json::from_str(&direct_skill).expect("valid skill json"); + assert!(direct_skill_output["path"] + .as_str() + .expect("path") + .ends_with("skills/statusline/SKILL.md")); + assert_eq!(direct_skill_output["description"], "Claude config skill"); + + let legacy_command = + execute_tool("Skill", &json!({ "skill": "doctor-check" })).expect("direct command"); + let legacy_command_output: serde_json::Value = + serde_json::from_str(&legacy_command).expect("valid command json"); + assert!(legacy_command_output["path"] + .as_str() + .expect("path") + .ends_with("commands/doctor-check.md")); + assert_eq!( + legacy_command_output["description"], + "Claude config command" + ); + + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_codex_home { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + match original_claude_config_dir { + Some(value) => std::env::set_var("CLAUDE_CONFIG_DIR", value), + None => std::env::remove_var("CLAUDE_CONFIG_DIR"), + } + fs::remove_dir_all(root).expect("temp tree should clean up"); + } + + #[test] + fn skill_loads_project_local_legacy_command_markdown() { + let _guard = env_lock().lock().expect("env lock should acquire"); + let root = temp_path("project-legacy-command"); + let home = root.join("home"); + let workspace = root.join("workspace"); + let nested = workspace.join("nested"); + let command_dir = workspace.join(".claude").join("commands"); + fs::create_dir_all(&command_dir).expect("legacy command dir should exist"); + fs::create_dir_all(&nested).expect("nested cwd should exist"); + fs::write( + command_dir.join("team.md"), + "---\nname: team\ndescription: Legacy team workflow\n---\n# team\n", + ) + .expect("legacy command file should exist"); + + let original_home = std::env::var("HOME").ok(); + let original_config_home = std::env::var("CLAW_CONFIG_HOME").ok(); + let original_codex_home = std::env::var("CODEX_HOME").ok(); + let original_dir = std::env::current_dir().expect("cwd"); + std::env::set_var("HOME", &home); + std::env::remove_var("CLAW_CONFIG_HOME"); + std::env::remove_var("CODEX_HOME"); + std::env::set_current_dir(&nested).expect("set cwd"); + + let result = execute_tool("Skill", &json!({ "skill": "team" })) + .expect("legacy command markdown should resolve"); + + let output: serde_json::Value = serde_json::from_str(&result).expect("valid json"); + assert!(output["path"] + .as_str() + .expect("path") + .ends_with(".claude/commands/team.md")); + assert_eq!(output["description"], "Legacy team workflow"); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + match original_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match original_config_home { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } + match original_codex_home { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + fs::remove_dir_all(root).expect("temp tree should clean up"); + } + #[test] fn tool_search_supports_keyword_and_select_queries() { let keyword = execute_tool(