Let /skills invocations reach the prompt skill path

The CLI still treated every /skills payload other than list/install/help as local usage text, so skills that appeared in /skills could not actually be invoked. This restores prompt dispatch for /skills <skill> [args], keeps list/install on the local path, and shares skill resolution with the Skill tool so project-local and legacy /commands entries resolve consistently.

Constraint: --resume local slash execution still only supports local commands without provider turns
Rejected: Implement full resumed prompt-turn execution for /skills | larger behavior change outside this bugfix
Rejected: Keep separate skill lookups in tools and commands | drift already caused listing/invocation mismatches
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep /skills discovery, CLI prompt dispatch, and Tool Skill resolution on the same registry semantics
Tested: cargo fmt --all; cargo clippy -p commands -p tools -p rusty-claude-cli --all-targets -- -D warnings; cargo test --workspace -- --nocapture
Not-tested: Live provider-backed /skills invocation against external skill packs in an interactive REPL
This commit is contained in:
Yeachan-Heo
2026-04-06 05:43:27 +00:00
parent 84a0973f6c
commit 58e4afeda6
5 changed files with 288 additions and 93 deletions

1
rust/Cargo.lock generated
View File

@@ -1579,6 +1579,7 @@ name = "tools"
version = "0.1.0"
dependencies = [
"api",
"commands",
"plugins",
"reqwest",
"runtime",

View File

@@ -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 <path>|help]"),
summary: "List, install, or invoke available skills",
argument_hint: Some("[list|install <path>|help|<skill> [args]]"),
resume_supported: true,
},
SlashCommandSpec {
@@ -1686,13 +1692,7 @@ fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandP
}
}
Err(command_error(
&format!(
"Unexpected arguments for /skills: {args}. Use /skills, /skills list, /skills install <path>, or /skills help."
),
"skills",
"/skills [list|install <path>|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<PathBuf> {
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 <path>|help]".to_string(),
" Direct CLI claw skills [list|install <path>|help]".to_string(),
" Usage /skills [list|install <path>|help|<skill> [args]]".to_string(),
" Direct CLI claw skills [list|install <path>|help|<skill> [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 <path>|help]",
"direct_cli": "claw skills [list|install <path>|help]",
"slash_command": "/skills [list|install <path>|help|<skill> [args]]",
"direct_cli": "claw skills [list|install <path>|help|<skill> [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 <path>, or /skills help."
));
assert!(skills_error.contains(" Usage /skills [list|install <path>|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 <path>|help]"));
assert!(help.contains("/skills [list|install <path>|help|<skill> [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 <path>|help]"
"claw skills [list|install <path>|help|<skill> [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 <path>|help]"));
assert!(skills_help
.contains("Usage /skills [list|install <path>|help|<skill> [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 <path>|help]"));
assert!(skills_install_help
.contains("Usage /skills [list|install <path>|help|<skill> [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 <path>|help]"));
assert!(skills_unknown_help
.contains("Usage /skills [list|install <path>|help|<skill> [args]]"));
assert!(skills_unknown_help.contains("Unexpected show"));
let _ = fs::remove_dir_all(cwd);

View File

@@ -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<CliAction, String> {
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<CliAction, String> {
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<String> {
fn parse_direct_slash_cli_action(
rest: &[String],
model: String,
output_format: CliOutputFormat,
allowed_tools: Option<AllowedToolSet>,
permission_mode: PermissionMode,
) -> Result<CliAction, String> {
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 <skill>` 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 => {
@@ -7123,6 +7166,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"),
@@ -7264,6 +7322,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(),

View File

@@ -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"] }

View File

@@ -2973,53 +2973,8 @@ fn todo_store_path() -> Result<std::path::PathBuf, String> {
}
fn resolve_skill_path(skill: &str) -> Result<std::path::PathBuf, String> {
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(