diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index a02322d..73175ab 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -2293,10 +2293,53 @@ pub fn classify_skills_slash_command(args: Option<&str>) -> SkillSlashDispatch { Some(args) if args == "install" || args.starts_with("install ") => { SkillSlashDispatch::Local } - Some(args) => SkillSlashDispatch::Invoke(format!("${args}")), + Some(args) => SkillSlashDispatch::Invoke(format!("${}", args.trim_start_matches('/'))), } } +/// Resolve a skill invocation by validating the skill exists on disk before +/// returning the dispatch. When the skill is not found, returns `Err` with a +/// human-readable message that lists nearby skill names. +pub fn resolve_skill_invocation( + cwd: &Path, + args: Option<&str>, +) -> Result { + let dispatch = classify_skills_slash_command(args); + if let SkillSlashDispatch::Invoke(ref prompt) = dispatch { + // Extract the skill name from the "$skill [args]" prompt. + let skill_token = prompt + .trim_start_matches('$') + .split_whitespace() + .next() + .unwrap_or_default(); + if !skill_token.is_empty() { + if let Err(error) = resolve_skill_path(cwd, skill_token) { + let mut message = + format!("Unknown skill: {skill_token} ({error})"); + let roots = discover_skill_roots(cwd); + if let Ok(available) = load_skills_from_roots(&roots) { + let names: Vec = available + .iter() + .filter(|s| s.shadowed_by.is_none()) + .map(|s| s.name.clone()) + .collect(); + if !names.is_empty() { + message.push_str(&format!( + "\n Available skills: {}", + names.join(", ") + )); + } + } + message.push_str( + "\n Usage: /skills [list|install |help| [args]]", + ); + return Err(message); + } + } + } + Ok(dispatch) +} + 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() { @@ -4301,6 +4344,10 @@ mod tests { classify_skills_slash_command(Some("help overview")), SkillSlashDispatch::Invoke("$help overview".to_string()) ); + assert_eq!( + classify_skills_slash_command(Some("/test")), + SkillSlashDispatch::Invoke("$test".to_string()) + ); assert_eq!( classify_skills_slash_command(Some("install ./skill-pack")), SkillSlashDispatch::Local diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 9a012d9..cce09c8 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -7778,6 +7778,17 @@ mod tests { output_format: CliOutputFormat::Text, } ); + assert_eq!( + parse_args(&["/skills".to_string(), "/test".to_string()]) + .expect("/skills /test should normalize to a single skill prompt prefix"), + CliAction::Prompt { + prompt: "$test".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: crate::default_permission_mode(), + } + ); let error = parse_args(&["/status".to_string()]) .expect_err("/status should remain REPL-only when invoked directly"); assert!(error.contains("interactive-only"));