diff --git a/rust/Cargo.lock b/rust/Cargo.lock index acd9ebf..e6d0b7c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1579,6 +1579,7 @@ name = "tools" version = "0.1.0" dependencies = [ "api", + "commands", "plugins", "reqwest", "runtime", diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 60da84a..f038a00 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -50,6 +50,12 @@ pub struct SlashCommandSpec { pub resume_supported: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillSlashDispatch { + Local, + Invoke(String), +} + const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "help", @@ -238,8 +244,8 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "skills", aliases: &[], - summary: "List or install available skills", - argument_hint: Some("[list|install |help]"), + summary: "List, install, or invoke available skills", + argument_hint: Some("[list|install |help| [args]]"), resume_supported: true, }, SlashCommandSpec { @@ -1686,13 +1692,7 @@ fn parse_skills_args(args: Option<&str>) -> Result, SlashCommandP } } - Err(command_error( - &format!( - "Unexpected arguments for /skills: {args}. Use /skills, /skills list, /skills install , or /skills help." - ), - "skills", - "/skills [list|install |help]", - )) + Ok(Some(args.to_string())) } fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError { @@ -2286,6 +2286,89 @@ pub fn handle_skills_slash_command_json(args: Option<&str>, cwd: &Path) -> std:: } } +#[must_use] +pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch { + match normalize_optional_args(args) { + None | Some("list" | "help" | "-h" | "--help") => SkillSlashDispatch::Local, + Some(args) if args == "install" || args.starts_with("install ") => { + SkillSlashDispatch::Local + } + Some(args) => SkillSlashDispatch::Invoke(format!("${args}")), + } +} + +pub fn resolve_skill_path(cwd: &Path, skill: &str) -> std::io::Result { + let requested = skill.trim().trim_start_matches('/').trim_start_matches('$'); + if requested.is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "skill must not be empty", + )); + } + + let roots = discover_skill_roots(cwd); + for root in &roots { + let mut entries = Vec::new(); + for entry in fs::read_dir(&root.path)? { + let entry = entry?; + match root.origin { + SkillOrigin::SkillsDir => { + if !entry.path().is_dir() { + continue; + } + let skill_path = entry.path().join("SKILL.md"); + if !skill_path.is_file() { + continue; + } + let contents = fs::read_to_string(&skill_path)?; + let (name, _) = parse_skill_frontmatter(&contents); + entries.push(( + name.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()), + skill_path, + )); + } + SkillOrigin::LegacyCommandsDir => { + let path = entry.path(); + let markdown_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 contents = fs::read_to_string(&markdown_path)?; + let fallback_name = markdown_path.file_stem().map_or_else( + || entry.file_name().to_string_lossy().to_string(), + |stem| stem.to_string_lossy().to_string(), + ); + let (name, _) = parse_skill_frontmatter(&contents); + entries.push((name.unwrap_or(fallback_name), markdown_path)); + } + } + } + entries.sort_by(|left, right| left.0.cmp(&right.0)); + if let Some((_, path)) = entries + .into_iter() + .find(|(name, _)| name.eq_ignore_ascii_case(requested)) + { + return Ok(path); + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("unknown skill: {requested}"), + )) +} + fn render_mcp_report_for( loader: &ConfigLoader, cwd: &Path, @@ -3383,8 +3466,9 @@ fn render_agents_usage_json(unexpected: Option<&str>) -> Value { fn render_skills_usage(unexpected: Option<&str>) -> String { let mut lines = vec![ "Skills".to_string(), - " Usage /skills [list|install |help]".to_string(), - " Direct CLI claw skills [list|install |help]".to_string(), + " Usage /skills [list|install |help| [args]]".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(), ]; @@ -3399,8 +3483,9 @@ fn render_skills_usage_json(unexpected: Option<&str>) -> Value { "kind": "skills", "action": "help", "usage": { - "slash_command": "/skills [list|install |help]", - "direct_cli": "claw skills [list|install |help]", + "slash_command": "/skills [list|install |help| [args]]", + "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"], }, @@ -3751,13 +3836,14 @@ pub fn handle_slash_command( #[cfg(test)] mod tests { use super::{ - handle_agents_slash_command_json, handle_plugins_slash_command, - handle_skills_slash_command_json, handle_slash_command, load_agents_from_roots, - load_skills_from_roots, render_agents_report, render_agents_report_json, - render_mcp_report_json_for, render_plugins_report, render_skills_report, - render_slash_command_help, render_slash_command_help_detail, - resume_supported_slash_commands, slash_command_specs, suggest_slash_commands, - validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand, + classify_skills_slash_command, handle_agents_slash_command_json, + handle_plugins_slash_command, handle_skills_slash_command_json, handle_slash_command, + load_agents_from_roots, load_skills_from_roots, render_agents_report, + render_agents_report_json, render_mcp_report_json_for, render_plugins_report, + render_skills_report, render_slash_command_help, render_slash_command_help_detail, + resolve_skill_path, resume_supported_slash_commands, slash_command_specs, + suggest_slash_commands, validate_slash_command_input, DefinitionSource, SkillOrigin, + SkillRoot, SkillSlashDispatch, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{ @@ -4105,24 +4191,36 @@ mod tests { } #[test] - fn rejects_invalid_agents_and_skills_arguments() { + fn rejects_invalid_agents_arguments() { // given let agents_input = "/agents show planner"; - let skills_input = "/skills show help"; // when let agents_error = parse_error_message(agents_input); - let skills_error = parse_error_message(skills_input); // then assert!(agents_error.contains( "Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help." )); assert!(agents_error.contains(" Usage /agents [list|help]")); - assert!(skills_error.contains( - "Unexpected arguments for /skills: show help. Use /skills, /skills list, /skills install , or /skills help." - )); - assert!(skills_error.contains(" Usage /skills [list|install |help]")); + } + + #[test] + fn accepts_skills_invocation_arguments_for_prompt_dispatch() { + assert_eq!( + SlashCommand::parse("/skills help overview"), + Ok(Some(SlashCommand::Skills { + args: Some("help overview".to_string()), + })) + ); + assert_eq!( + classify_skills_slash_command(Some("help overview")), + SkillSlashDispatch::Invoke("$help overview".to_string()) + ); + assert_eq!( + classify_skills_slash_command(Some("install ./skill-pack")), + SkillSlashDispatch::Local + ); } #[test] @@ -4176,7 +4274,7 @@ mod tests { )); assert!(help.contains("aliases: /plugins, /marketplace")); assert!(help.contains("/agents [list|help]")); - assert!(help.contains("/skills [list|install |help]")); + assert!(help.contains("/skills [list|install |help| [args]]")); assert_eq!(slash_command_specs().len(), 141); assert!(resume_supported_slash_commands().len() >= 39); } @@ -4541,6 +4639,25 @@ mod tests { let _ = fs::remove_dir_all(user_home); } + #[test] + fn resolves_project_skills_and_legacy_commands_from_shared_registry() { + let workspace = temp_dir("resolve-project-skills"); + let project_skills = workspace.join(".claw").join("skills"); + let legacy_commands = workspace.join(".claw").join("commands"); + + write_skill(&project_skills, "plan", "Project planning guidance"); + write_legacy_command(&legacy_commands, "handoff", "Legacy handoff guidance"); + + assert_eq!( + resolve_skill_path(&workspace, "$plan").expect("project skill should resolve"), + project_skills.join("plan").join("SKILL.md") + ); + assert_eq!( + resolve_skill_path(&workspace, "/handoff").expect("legacy command should resolve"), + legacy_commands.join("handoff.md") + ); + } + #[test] fn renders_skills_reports_as_json() { let workspace = temp_dir("skills-json-workspace"); @@ -4589,7 +4706,7 @@ mod tests { assert_eq!(help["action"], "help"); assert_eq!( help["usage"]["direct_cli"], - "claw skills [list|install |help]" + "claw skills [list|install |help| [args]]" ); let _ = fs::remove_dir_all(workspace); @@ -4613,7 +4730,9 @@ mod tests { let skills_help = super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help"); - assert!(skills_help.contains("Usage /skills [list|install |help]")); + assert!(skills_help + .contains("Usage /skills [list|install |help| [args]]")); + 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("legacy /commands")); @@ -4623,12 +4742,14 @@ mod tests { let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd) .expect("nested skills help"); - assert!(skills_install_help.contains("Usage /skills [list|install |help]")); + assert!(skills_install_help + .contains("Usage /skills [list|install |help| [args]]")); assert!(skills_install_help.contains("Unexpected install")); let skills_unknown_help = super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help"); - assert!(skills_unknown_help.contains("Usage /skills [list|install |help]")); + assert!(skills_unknown_help + .contains("Usage /skills [list|install |help| [args]]")); assert!(skills_unknown_help.contains("Unexpected show")); let _ = fs::remove_dir_all(cwd); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 6c21a66..693e944 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -31,10 +31,11 @@ use api::{ }; use commands::{ - handle_agents_slash_command, handle_agents_slash_command_json, handle_mcp_slash_command, - handle_mcp_slash_command_json, handle_plugins_slash_command, handle_skills_slash_command, - handle_skills_slash_command_json, render_slash_command_help, resume_supported_slash_commands, - slash_command_specs, validate_slash_command_input, SlashCommand, + classify_skills_slash_command, handle_agents_slash_command, handle_agents_slash_command_json, + handle_mcp_slash_command, handle_mcp_slash_command_json, handle_plugins_slash_command, + handle_skills_slash_command, handle_skills_slash_command_json, render_slash_command_help, + resume_supported_slash_commands, slash_command_specs, validate_slash_command_input, + SkillSlashDispatch, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; @@ -419,10 +420,22 @@ fn parse_args(args: &[String]) -> Result { args: join_optional_args(&rest[1..]), output_format, }), - "skills" => Ok(CliAction::Skills { - args: join_optional_args(&rest[1..]), - output_format, - }), + "skills" => { + let args = join_optional_args(&rest[1..]); + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + }), + SkillSlashDispatch::Local => Ok(CliAction::Skills { + args, + output_format, + }), + } + } "system-prompt" => parse_system_prompt_args(&rest[1..], output_format), "login" => Ok(CliAction::Login { output_format }), "logout" => Ok(CliAction::Logout { output_format }), @@ -440,7 +453,13 @@ fn parse_args(args: &[String]) -> Result { permission_mode, }) } - other if other.starts_with('/') => parse_direct_slash_cli_action(&rest, output_format), + other if other.starts_with('/') => parse_direct_slash_cli_action( + &rest, + model, + output_format, + allowed_tools, + permission_mode, + ), _other => Ok(CliAction::Prompt { prompt: rest.join(" "), model, @@ -532,7 +551,10 @@ fn join_optional_args(args: &[String]) -> Option { fn parse_direct_slash_cli_action( rest: &[String], + model: String, output_format: CliOutputFormat, + allowed_tools: Option, + permission_mode: PermissionMode, ) -> Result { let raw = rest.join(" "); match SlashCommand::parse(&raw) { @@ -550,10 +572,21 @@ fn parse_direct_slash_cli_action( }, output_format, }), - Ok(Some(SlashCommand::Skills { args })) => Ok(CliAction::Skills { - args, - output_format, - }), + Ok(Some(SlashCommand::Skills { args })) => { + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + }), + SkillSlashDispatch::Local => Ok(CliAction::Skills { + args, + output_format, + }), + } + } Ok(Some(SlashCommand::Unknown(name))) => Err(format_unknown_direct_slash_command(&name)), Ok(Some(command)) => Err({ let _ = command; @@ -2281,6 +2314,11 @@ fn run_resume_command( }) } SlashCommand::Skills { args } => { + if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) { + return Err( + "resumed /skills invocations are interactive-only; start `claw` and run `/skills ` in the REPL".into(), + ); + } let cwd = env::current_dir()?; Ok(ResumeCommandOutcome { session: session.clone(), @@ -3203,7 +3241,12 @@ impl LiveCli { false } SlashCommand::Skills { args } => { - Self::print_skills(args.as_deref(), CliOutputFormat::Text)?; + match classify_skills_slash_command(args.as_deref()) { + SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?, + SkillSlashDispatch::Local => { + Self::print_skills(args.as_deref(), CliOutputFormat::Text)?; + } + } false } SlashCommand::Doctor => { @@ -7258,6 +7301,21 @@ mod tests { output_format: CliOutputFormat::Text, } ); + assert_eq!( + parse_args(&[ + "skills".to_string(), + "help".to_string(), + "overview".to_string() + ]) + .expect("skills help overview should invoke"), + CliAction::Prompt { + prompt: "$help overview".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: crate::default_permission_mode(), + } + ); assert_eq!( parse_args(&["agents".to_string(), "--help".to_string()]) .expect("agents help should parse"), @@ -7399,6 +7457,21 @@ mod tests { output_format: CliOutputFormat::Text, } ); + assert_eq!( + parse_args(&[ + "/skills".to_string(), + "help".to_string(), + "overview".to_string() + ]) + .expect("/skills help overview should invoke"), + CliAction::Prompt { + prompt: "$help overview".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: crate::default_permission_mode(), + } + ); assert_eq!( parse_args(&[ "/skills".to_string(), diff --git a/rust/crates/tools/Cargo.toml b/rust/crates/tools/Cargo.toml index 04d738b..fd66fd6 100644 --- a/rust/crates/tools/Cargo.toml +++ b/rust/crates/tools/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] api = { path = "../api" } +commands = { path = "../commands" } plugins = { path = "../plugins" } runtime = { path = "../runtime" } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"] } diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 31a162c..ac24fde 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -2973,53 +2973,8 @@ fn todo_store_path() -> Result { } fn resolve_skill_path(skill: &str) -> Result { - let requested = skill.trim().trim_start_matches('/').trim_start_matches('$'); - if requested.is_empty() { - return Err(String::from("skill must not be empty")); - } - - let mut candidates = Vec::new(); - if let Ok(claw_config_home) = std::env::var("CLAW_CONFIG_HOME") { - candidates.push(std::path::PathBuf::from(claw_config_home).join("skills")); - } - if let Ok(codex_home) = std::env::var("CODEX_HOME") { - candidates.push(std::path::PathBuf::from(codex_home).join("skills")); - } - if let Ok(home) = std::env::var("HOME") { - let home = std::path::PathBuf::from(home); - candidates.push(home.join(".claw").join("skills")); - candidates.push(home.join(".agents").join("skills")); - candidates.push(home.join(".config").join("opencode").join("skills")); - candidates.push(home.join(".codex").join("skills")); - candidates.push(home.join(".claude").join("skills")); - } - candidates.push(std::path::PathBuf::from("/home/bellman/.claw/skills")); - candidates.push(std::path::PathBuf::from("/home/bellman/.codex/skills")); - - for root in candidates { - let direct = root.join(requested).join("SKILL.md"); - if direct.exists() { - return Ok(direct); - } - - if let Ok(entries) = std::fs::read_dir(&root) { - for entry in entries.flatten() { - let path = entry.path().join("SKILL.md"); - if !path.exists() { - continue; - } - if entry - .file_name() - .to_string_lossy() - .eq_ignore_ascii_case(requested) - { - return Ok(path); - } - } - } - } - - Err(format!("unknown skill: {requested}")) + let cwd = std::env::current_dir().map_err(|error| error.to_string())?; + commands::resolve_skill_path(&cwd, skill).map_err(|error| error.to_string()) } const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6"; @@ -5797,6 +5752,50 @@ mod tests { fs::remove_dir_all(home).expect("temp home should clean up"); } + #[test] + fn skill_resolves_project_local_skills_and_legacy_commands() { + let _guard = env_lock().lock().expect("env lock should acquire"); + let root = temp_path("project-skills"); + let skill_dir = root.join(".claw").join("skills").join("plan"); + let command_dir = root.join(".claw").join("commands"); + fs::create_dir_all(&skill_dir).expect("skill dir should exist"); + fs::create_dir_all(&command_dir).expect("command dir should exist"); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: plan\ndescription: Project planning guidance\n---\n\n# plan\n", + ) + .expect("skill file should exist"); + fs::write( + command_dir.join("handoff.md"), + "---\nname: handoff\ndescription: Legacy handoff guidance\n---\n\n# handoff\n", + ) + .expect("command file should exist"); + + let original_dir = std::env::current_dir().expect("cwd"); + std::env::set_current_dir(&root).expect("set cwd"); + + let skill_result = execute_tool("Skill", &json!({ "skill": "$plan" })) + .expect("project-local skill should resolve"); + let skill_output: serde_json::Value = + serde_json::from_str(&skill_result).expect("valid json"); + assert!(skill_output["path"] + .as_str() + .expect("path") + .ends_with(".claw/skills/plan/SKILL.md")); + + let command_result = execute_tool("Skill", &json!({ "skill": "/handoff" })) + .expect("legacy command should resolve"); + let command_output: serde_json::Value = + serde_json::from_str(&command_result).expect("valid json"); + assert!(command_output["path"] + .as_str() + .expect("path") + .ends_with(".claw/commands/handoff.md")); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + fs::remove_dir_all(root).expect("temp project should clean up"); + } + #[test] fn tool_search_supports_keyword_and_select_queries() { let keyword = execute_tool(