From 685d5fef9f9a9efc5b83252e2ada28bcca4cf017 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 21:25:00 +0000 Subject: [PATCH] Restore slash skill invocation parity after the main merge The merged command surface still listed /skills but treated every positional argument as unexpected usage text, so slash-based skill invocation regressed. This wires /skills and /agents invocations back through the prompt path, shares skill resolution between the slash/discovery layer and the Skill tool, and teaches skill discovery to see enabled plugin roots plus namespaced plugin skills such as oh-my-claudecode:ralplan. Constraint: Keep documentation files untouched while restoring the runtime behavior Rejected: Add a separate skill-invoke tool name | existing Skill tool already covered the loading surface once resolution was fixed Rejected: Resolve plugin skills only inside the slash handler | would leave the Skill tool and direct invocation path inconsistent Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep slash discovery/help behavior and Skill-tool resolution on the same registry path so plugin and project skills do not drift again Tested: cargo check; cargo test; direct /skills help overview smoke run Not-tested: End-to-end live provider execution for a real installed oh-my-claudecode plugin beyond synthetic fixture coverage --- rust/Cargo.lock | 1 + rust/crates/claw-cli/src/main.rs | 134 +++++++-- rust/crates/commands/src/lib.rs | 489 +++++++++++++++++++++++++++---- rust/crates/tools/Cargo.toml | 1 + rust/crates/tools/src/lib.rs | 130 +++++--- 5 files changed, 642 insertions(+), 113 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index d94c57b..f507235 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1753,6 +1753,7 @@ name = "tools" version = "0.1.0" dependencies = [ "api", + "commands", "plugins", "reqwest", "runtime", diff --git a/rust/crates/claw-cli/src/main.rs b/rust/crates/claw-cli/src/main.rs index 2b7d6f1..e9b8776 100644 --- a/rust/crates/claw-cli/src/main.rs +++ b/rust/crates/claw-cli/src/main.rs @@ -22,9 +22,10 @@ use api::{ }; use commands::{ - handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command, - render_slash_command_help, resume_supported_slash_commands, slash_command_specs, - suggest_slash_commands, SlashCommand, + classify_agents_slash_command, classify_skills_slash_command, handle_agents_slash_command, + handle_plugins_slash_command, handle_skills_slash_command, render_slash_command_help, + resume_supported_slash_commands, slash_command_specs, suggest_slash_commands, + InvokeCommandAction, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; @@ -286,12 +287,30 @@ fn parse_args(args: &[String]) -> Result { match rest[0].as_str() { "dump-manifests" => Ok(CliAction::DumpManifests), "bootstrap-plan" => Ok(CliAction::BootstrapPlan), - "agents" => Ok(CliAction::Agents { - args: join_optional_args(&rest[1..]), - }), - "skills" => Ok(CliAction::Skills { - args: join_optional_args(&rest[1..]), - }), + "agents" => match classify_agents_slash_command(join_optional_args(&rest[1..]).as_deref()) { + InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + }), + _ => Ok(CliAction::Agents { + args: join_optional_args(&rest[1..]), + }), + }, + "skills" => match classify_skills_slash_command(join_optional_args(&rest[1..]).as_deref()) { + InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + }), + _ => Ok(CliAction::Skills { + args: join_optional_args(&rest[1..]), + }), + }, "system-prompt" => parse_system_prompt_args(&rest[1..]), "login" => Ok(CliAction::Login), "logout" => Ok(CliAction::Logout), @@ -309,7 +328,13 @@ fn parse_args(args: &[String]) -> Result { permission_mode, }) } - other if other.starts_with('/') => parse_direct_slash_cli_action(&rest), + 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, @@ -326,12 +351,40 @@ fn join_optional_args(args: &[String]) -> Option { (!trimmed.is_empty()).then(|| trimmed.to_string()) } -fn parse_direct_slash_cli_action(rest: &[String]) -> Result { +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) { Some(SlashCommand::Help) => Ok(CliAction::Help), - Some(SlashCommand::Agents { args }) => Ok(CliAction::Agents { args }), - Some(SlashCommand::Skills { args }) => Ok(CliAction::Skills { args }), + Some(SlashCommand::Agents { args }) => { + match classify_agents_slash_command(args.as_deref()) { + InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + }), + _ => Ok(CliAction::Agents { args }), + } + } + Some(SlashCommand::Skills { args }) => { + match classify_skills_slash_command(args.as_deref()) { + InvokeCommandAction::Invoke(prompt) => Ok(CliAction::Prompt { + prompt, + model, + output_format, + allowed_tools, + permission_mode, + }), + _ => Ok(CliAction::Skills { args }), + } + } Some(command) => Err(format_direct_slash_command_error( match &command { SlashCommand::Unknown(name) => format!("/{name}"), @@ -1321,11 +1374,17 @@ impl LiveCli { self.handle_plugins_command(action.as_deref(), target.as_deref())? } SlashCommand::Agents { args } => { - Self::print_agents(args.as_deref())?; + match classify_agents_slash_command(args.as_deref()) { + InvokeCommandAction::Invoke(prompt) => self.run_turn(&prompt)?, + _ => Self::print_agents(args.as_deref())?, + } false } SlashCommand::Skills { args } => { - Self::print_skills(args.as_deref())?; + match classify_skills_slash_command(args.as_deref()) { + InvokeCommandAction::Invoke(prompt) => self.run_turn(&prompt)?, + _ => Self::print_skills(args.as_deref())?, + } false } SlashCommand::Branch { .. } => { @@ -4332,6 +4391,17 @@ mod tests { args: Some("--help".to_string()) } ); + assert_eq!( + parse_args(&["skills".to_string(), "ralplan".to_string()]) + .expect("skills invoke should parse"), + CliAction::Prompt { + prompt: "$ralplan".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::DangerFullAccess, + } + ); } #[test] @@ -4345,10 +4415,36 @@ mod tests { CliAction::Skills { args: None } ); assert_eq!( - parse_args(&["/skills".to_string(), "help".to_string()]) - .expect("/skills help should parse"), - CliAction::Skills { - args: Some("help".to_string()) + 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: PermissionMode::DangerFullAccess, + } + ); + assert_eq!( + parse_args(&["/skills".to_string(), "oh-my-claudecode:ralplan".to_string()]) + .expect("/skills namespaced invoke should parse"), + CliAction::Prompt { + prompt: "$oh-my-claudecode:ralplan".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::DangerFullAccess, + } + ); + assert_eq!( + parse_args(&["/agents".to_string(), "planner".to_string()]) + .expect("/agents planner should invoke"), + CliAction::Prompt { + prompt: "/prompts:planner".to_string(), + model: DEFAULT_MODEL.to_string(), + output_format: CliOutputFormat::Text, + allowed_tools: None, + permission_mode: PermissionMode::DangerFullAccess, } ); let error = parse_args(&["/status".to_string()]) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index da7f1a4..ce88e88 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -6,8 +6,8 @@ use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; -use plugins::{PluginError, PluginManager, PluginSummary}; -use runtime::{compact_session, CompactionConfig, Session}; +use plugins::{PluginError, PluginManager, PluginManagerConfig, PluginSummary}; +use runtime::{compact_session, CompactionConfig, ConfigLoader, Session}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandManifestEntry { @@ -284,16 +284,16 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "agents", aliases: &[], - summary: "List configured agents", - argument_hint: None, + summary: "List or invoke configured agents", + argument_hint: Some("[list|--help|]"), resume_supported: true, category: SlashCommandCategory::Automation, }, SlashCommandSpec { name: "skills", aliases: &[], - summary: "List available skills", - argument_hint: None, + summary: "List or invoke available skills", + argument_hint: Some("[list|--help| [args]]"), resume_supported: true, category: SlashCommandCategory::Automation, }, @@ -631,6 +631,7 @@ enum DefinitionSource { UserCodexHome, UserCodex, UserClaw, + Plugin, } impl DefinitionSource { @@ -641,6 +642,7 @@ impl DefinitionSource { Self::UserCodexHome => "User ($CODEX_HOME)", Self::UserCodex => "User (~/.codex)", Self::UserClaw => "User (~/.claw)", + Self::Plugin => "Plugins", } } } @@ -684,6 +686,30 @@ struct SkillRoot { source: DefinitionSource, path: PathBuf, origin: SkillOrigin, + namespace: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DefinitionRoot { + source: DefinitionSource, + path: PathBuf, + namespace: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SkillEntry { + name: String, + path: PathBuf, + description: Option, + source: DefinitionSource, + origin: SkillOrigin, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InvokeCommandAction { + Browse, + Help, + Invoke(String), } #[allow(clippy::too_many_lines)] @@ -813,7 +839,7 @@ pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::R pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result { match normalize_optional_args(args) { None | Some("list") => { - let roots = discover_skill_roots(cwd); + let roots = discover_skill_roots(cwd)?; let skills = load_skills_from_roots(&roots)?; Ok(render_skills_report(&skills)) } @@ -1260,7 +1286,72 @@ fn resolve_plugin_target( } } -fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> { +pub fn classify_agents_slash_command(args: Option<&str>) -> InvokeCommandAction { + classify_invoke_command(args, "/prompts:") +} + +pub fn classify_skills_slash_command(args: Option<&str>) -> InvokeCommandAction { + classify_invoke_command(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(io::Error::new( + io::ErrorKind::InvalidInput, + "skill must not be empty", + )); + } + + let roots = discover_skill_roots(cwd)?; + let entries = load_skill_entries_from_roots(&roots)?; + + if let Some((namespace, name)) = requested.split_once(':') { + return entries + .into_iter() + .find(|entry| entry.name.eq_ignore_ascii_case(&format!("{namespace}:{name}"))) + .map(|entry| entry.path) + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, format!("unknown skill: {requested}"))); + } + + if let Some(entry) = entries + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case(requested)) + { + return Ok(entry.path.clone()); + } + + let plugin_matches = entries + .into_iter() + .filter(|entry| { + entry + .name + .split_once(':') + .is_some_and(|(_, name)| name.eq_ignore_ascii_case(requested)) + }) + .collect::>(); + + match plugin_matches.len() { + 0 => Err(io::Error::new( + io::ErrorKind::NotFound, + format!("unknown skill: {requested}"), + )), + 1 => Ok(plugin_matches[0].path.clone()), + _ => Err(io::Error::other(format!( + "skill `{requested}` is provided by multiple plugins; use :" + ))), + } +} + +fn classify_invoke_command(args: Option<&str>, prefix: &str) -> InvokeCommandAction { + match normalize_optional_args(args) { + None | Some("list") => InvokeCommandAction::Browse, + Some("-h" | "--help" | "help") => InvokeCommandAction::Help, + Some(args) => InvokeCommandAction::Invoke(format!("{prefix}{args}")), + } +} + +fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec { let mut roots = Vec::new(); for ancestor in cwd.ancestors() { @@ -1268,11 +1359,13 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P &mut roots, DefinitionSource::ProjectCodex, ancestor.join(".codex").join(leaf), + None, ); push_unique_root( &mut roots, DefinitionSource::ProjectClaw, ancestor.join(".claw").join(leaf), + None, ); } @@ -1281,6 +1374,7 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P &mut roots, DefinitionSource::UserCodexHome, PathBuf::from(codex_home).join(leaf), + None, ); } @@ -1290,18 +1384,22 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P &mut roots, DefinitionSource::UserCodex, home.join(".codex").join(leaf), + None, ); push_unique_root( &mut roots, DefinitionSource::UserClaw, home.join(".claw").join(leaf), + None, ); } + append_plugin_definition_roots(&mut roots, cwd, leaf); + roots } -fn discover_skill_roots(cwd: &Path) -> Vec { +fn discover_skill_roots(cwd: &Path) -> io::Result> { let mut roots = Vec::new(); for ancestor in cwd.ancestors() { @@ -1310,24 +1408,28 @@ fn discover_skill_roots(cwd: &Path) -> Vec { DefinitionSource::ProjectCodex, ancestor.join(".codex").join("skills"), SkillOrigin::SkillsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::ProjectClaw, ancestor.join(".claw").join("skills"), SkillOrigin::SkillsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::ProjectCodex, ancestor.join(".codex").join("commands"), SkillOrigin::LegacyCommandsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::ProjectClaw, ancestor.join(".claw").join("commands"), SkillOrigin::LegacyCommandsDir, + None, ); } @@ -1338,12 +1440,14 @@ fn discover_skill_roots(cwd: &Path) -> Vec { DefinitionSource::UserCodexHome, codex_home.join("skills"), SkillOrigin::SkillsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::UserCodexHome, codex_home.join("commands"), SkillOrigin::LegacyCommandsDir, + None, ); } @@ -1354,37 +1458,52 @@ fn discover_skill_roots(cwd: &Path) -> Vec { DefinitionSource::UserCodex, home.join(".codex").join("skills"), SkillOrigin::SkillsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::UserCodex, home.join(".codex").join("commands"), SkillOrigin::LegacyCommandsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::UserClaw, home.join(".claw").join("skills"), SkillOrigin::SkillsDir, + None, ); push_unique_skill_root( &mut roots, DefinitionSource::UserClaw, home.join(".claw").join("commands"), SkillOrigin::LegacyCommandsDir, + None, ); } - roots + append_plugin_skill_roots(&mut roots, cwd)?; + + Ok(roots) } fn push_unique_root( - roots: &mut Vec<(DefinitionSource, PathBuf)>, + roots: &mut Vec, source: DefinitionSource, path: PathBuf, + namespace: Option, ) { - if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) { - roots.push((source, path)); + if path.is_dir() + && !roots + .iter() + .any(|existing| existing.path == path && existing.namespace == namespace) + { + roots.push(DefinitionRoot { + source, + path, + namespace, + }); } } @@ -1393,25 +1512,29 @@ fn push_unique_skill_root( source: DefinitionSource, path: PathBuf, origin: SkillOrigin, + namespace: Option, ) { - if path.is_dir() && !roots.iter().any(|existing| existing.path == path) { + if path.is_dir() + && !roots + .iter() + .any(|existing| existing.path == path && existing.namespace == namespace) + { roots.push(SkillRoot { source, path, origin, + namespace, }); } } -fn load_agents_from_roots( - roots: &[(DefinitionSource, PathBuf)], -) -> std::io::Result> { +fn load_agents_from_roots(roots: &[DefinitionRoot]) -> std::io::Result> { let mut agents = Vec::new(); let mut active_sources = BTreeMap::::new(); - for (source, root) in roots { + for root in roots { let mut root_agents = Vec::new(); - for entry in fs::read_dir(root)? { + for entry in fs::read_dir(&root.path)? { let entry = entry?; if entry.path().extension().is_none_or(|ext| ext != "toml") { continue; @@ -1421,12 +1544,13 @@ fn load_agents_from_roots( || entry.file_name().to_string_lossy().to_string(), |stem| stem.to_string_lossy().to_string(), ); + let name = parse_toml_string(&contents, "name").unwrap_or(fallback_name); root_agents.push(AgentSummary { - name: parse_toml_string(&contents, "name").unwrap_or(fallback_name), + name: namespaced_definition_name(root.namespace.as_deref(), &name), description: parse_toml_string(&contents, "description"), model: parse_toml_string(&contents, "model"), reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"), - source: *source, + source: root.source, shadowed_by: None, }); } @@ -1447,9 +1571,33 @@ fn load_agents_from_roots( } fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result> { + let entries = load_skill_entries_from_roots(roots)?; let mut skills = Vec::new(); let mut active_sources = BTreeMap::::new(); + for entry in entries { + let mut skill = SkillSummary { + name: entry.name, + description: entry.description, + source: entry.source, + shadowed_by: None, + origin: entry.origin, + }; + let key = skill.name.to_ascii_lowercase(); + if let Some(existing) = active_sources.get(&key) { + skill.shadowed_by = Some(*existing); + } else { + active_sources.insert(key, skill.source); + } + skills.push(skill); + } + + Ok(skills) +} + +fn load_skill_entries_from_roots(roots: &[SkillRoot]) -> std::io::Result> { + let mut skills = Vec::new(); + for root in roots { let mut root_skills = Vec::new(); for entry in fs::read_dir(&root.path)? { @@ -1463,14 +1611,17 @@ fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result std::io::Result, name: &str) -> String { + namespace.map_or_else(|| name.to_string(), |namespace| format!("{namespace}:{name}")) +} + +fn append_plugin_definition_roots(roots: &mut Vec, cwd: &Path, leaf: &str) { + if let Ok(plugins) = discover_enabled_plugins(cwd) { + for plugin in plugins { + let Some(root) = plugin.metadata.root.as_ref() else { + continue; + }; + let namespace = Some(plugin_namespace(&plugin)); + push_unique_root( + roots, + DefinitionSource::Plugin, + root.join(".codex").join(leaf), + namespace.clone(), + ); + push_unique_root( + roots, + DefinitionSource::Plugin, + root.join(".claw").join(leaf), + namespace.clone(), + ); + push_unique_root( + roots, + DefinitionSource::Plugin, + root.join(leaf), + namespace, + ); + } + } +} + +fn append_plugin_skill_roots(roots: &mut Vec, cwd: &Path) -> io::Result<()> { + for plugin in discover_enabled_plugins(cwd)? { + let Some(root) = plugin.metadata.root.as_ref() else { + continue; + }; + let namespace = Some(plugin_namespace(&plugin)); + push_unique_skill_root( + roots, + DefinitionSource::Plugin, + root.join(".codex").join("skills"), + SkillOrigin::SkillsDir, + namespace.clone(), + ); + push_unique_skill_root( + roots, + DefinitionSource::Plugin, + root.join(".claw").join("skills"), + SkillOrigin::SkillsDir, + namespace.clone(), + ); + push_unique_skill_root( + roots, + DefinitionSource::Plugin, + root.join("skills"), + SkillOrigin::SkillsDir, + namespace.clone(), + ); + push_unique_skill_root( + roots, + DefinitionSource::Plugin, + root.join(".codex").join("commands"), + SkillOrigin::LegacyCommandsDir, + namespace.clone(), + ); + push_unique_skill_root( + roots, + DefinitionSource::Plugin, + root.join(".claw").join("commands"), + SkillOrigin::LegacyCommandsDir, + namespace.clone(), + ); + push_unique_skill_root( + roots, + DefinitionSource::Plugin, + root.join("commands"), + SkillOrigin::LegacyCommandsDir, + namespace, + ); + } + + Ok(()) +} + +fn discover_enabled_plugins(cwd: &Path) -> io::Result> { + let loader = ConfigLoader::default_for(cwd); + let runtime_config = loader.load().map_err(io::Error::other)?; + let plugin_settings = runtime_config.plugins(); + let mut plugin_config = PluginManagerConfig::new(loader.config_home().to_path_buf()); + plugin_config.enabled_plugins = plugin_settings.enabled_plugins().clone(); + plugin_config.external_dirs = plugin_settings + .external_directories() + .iter() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)) + .collect(); + plugin_config.install_root = plugin_settings + .install_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.registry_path = plugin_settings + .registry_path() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + plugin_config.bundled_root = plugin_settings + .bundled_root() + .map(|path| resolve_plugin_path(cwd, loader.config_home(), path)); + PluginManager::new(plugin_config) + .list_installed_plugins() + .map(|plugins| plugins.into_iter().filter(|plugin| plugin.enabled).collect()) + .map_err(io::Error::other) +} + +fn resolve_plugin_path(cwd: &Path, config_home: &Path, value: &str) -> PathBuf { + let path = PathBuf::from(value); + if path.is_absolute() { + path + } else if value.starts_with('.') { + cwd.join(path) + } else { + config_home.join(path) + } +} + +fn plugin_namespace(plugin: &PluginSummary) -> String { + plugin.metadata.name.clone() +} + fn parse_toml_string(contents: &str, key: &str) -> Option { let prefix = format!("{key} ="); for line in contents.lines() { @@ -1613,6 +1885,7 @@ fn render_agents_report(agents: &[AgentSummary]) -> String { DefinitionSource::UserCodexHome, DefinitionSource::UserCodex, DefinitionSource::UserClaw, + DefinitionSource::Plugin, ] { let group = agents .iter() @@ -1671,6 +1944,7 @@ fn render_skills_report(skills: &[SkillSummary]) -> String { DefinitionSource::UserCodexHome, DefinitionSource::UserCodex, DefinitionSource::UserClaw, + DefinitionSource::Plugin, ] { let group = skills .iter() @@ -1708,9 +1982,11 @@ fn normalize_optional_args(args: Option<&str>) -> Option<&str> { fn render_agents_usage(unexpected: Option<&str>) -> String { let mut lines = vec![ "Agents".to_string(), - " Usage /agents".to_string(), - " Direct CLI claw agents".to_string(), - " Sources .codex/agents, .claw/agents, $CODEX_HOME/agents".to_string(), + " Usage /agents [list|--help|]".to_string(), + " Direct CLI claw agents [list|--help|]".to_string(), + " Invoke /agents planner -> /prompts:planner".to_string(), + " Sources .codex/agents, .claw/agents, $CODEX_HOME/agents, enabled plugins" + .to_string(), ]; if let Some(args) = unexpected { lines.push(format!(" Unexpected {args}")); @@ -1721,9 +1997,12 @@ fn render_agents_usage(unexpected: Option<&str>) -> String { fn render_skills_usage(unexpected: Option<&str>) -> String { let mut lines = vec![ "Skills".to_string(), - " Usage /skills".to_string(), - " Direct CLI claw skills".to_string(), - " Sources .codex/skills, .claw/skills, legacy /commands".to_string(), + " Usage /skills [list|--help| [args]]".to_string(), + " Direct CLI claw skills [list|--help| [args]]".to_string(), + " Invoke /skills help overview -> $help overview".to_string(), + " Namespacing /skills plugin-name:skill".to_string(), + " Sources .codex/skills, .claw/skills, legacy /commands, enabled plugins" + .to_string(), ]; if let Some(args) = unexpected { lines.push(format!(" Unexpected {args}")); @@ -1790,13 +2069,15 @@ pub fn handle_slash_command( #[cfg(test)] mod tests { use super::{ + classify_agents_slash_command, classify_skills_slash_command, handle_branch_slash_command, handle_commit_push_pr_slash_command, - handle_commit_slash_command, handle_plugins_slash_command, handle_slash_command, - handle_worktree_slash_command, load_agents_from_roots, load_skills_from_roots, - render_agents_report, render_plugins_report, render_skills_report, - render_slash_command_help, resume_supported_slash_commands, slash_command_specs, - suggest_slash_commands, CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot, - SlashCommand, + handle_commit_slash_command, handle_plugins_slash_command, handle_skills_slash_command, + handle_slash_command, handle_worktree_slash_command, load_agents_from_roots, + load_skills_from_roots, render_agents_report, render_plugins_report, + render_skills_report, render_slash_command_help, resolve_skill_path, + resume_supported_slash_commands, slash_command_specs, suggest_slash_commands, + CommitPushPrRequest, DefinitionRoot, DefinitionSource, InvokeCommandAction, + SkillOrigin, SkillRoot, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; @@ -2336,8 +2617,16 @@ mod tests { ); let roots = vec![ - (DefinitionSource::ProjectCodex, project_agents), - (DefinitionSource::UserCodex, user_agents), + DefinitionRoot { + source: DefinitionSource::ProjectCodex, + path: project_agents, + namespace: None, + }, + DefinitionRoot { + source: DefinitionSource::UserCodex, + path: user_agents, + namespace: None, + }, ]; let report = render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load")); @@ -2372,16 +2661,19 @@ mod tests { source: DefinitionSource::ProjectCodex, path: project_skills, origin: SkillOrigin::SkillsDir, + namespace: None, }, SkillRoot { source: DefinitionSource::ProjectClaw, path: project_commands, origin: SkillOrigin::LegacyCommandsDir, + namespace: None, }, SkillRoot { source: DefinitionSource::UserCodex, path: user_skills, origin: SkillOrigin::SkillsDir, + namespace: None, }, ]; let report = @@ -2426,6 +2718,97 @@ mod tests { let _ = fs::remove_dir_all(cwd); } + #[test] + fn classifies_agents_and_skills_invocation_args() { + assert_eq!( + classify_agents_slash_command(None), + InvokeCommandAction::Browse + ); + assert_eq!( + classify_agents_slash_command(Some("--help")), + InvokeCommandAction::Help + ); + assert_eq!( + classify_agents_slash_command(Some("planner")), + InvokeCommandAction::Invoke("/prompts:planner".to_string()) + ); + assert_eq!( + classify_skills_slash_command(Some("help overview")), + InvokeCommandAction::Invoke("$help overview".to_string()) + ); + assert_eq!( + classify_skills_slash_command(Some("oh-my-claudecode:ralplan")), + InvokeCommandAction::Invoke("$oh-my-claudecode:ralplan".to_string()) + ); + } + + #[test] + fn resolves_project_and_plugin_scoped_skills() { + let _guard = env_lock(); + let workspace = temp_dir("skill-resolve-workspace"); + let home = temp_dir("skill-resolve-home"); + let plugin_root = home + .join(".claw") + .join("plugins") + .join("installed") + .join("oh-my-claudecode-external"); + + write_skill( + &workspace.join(".codex").join("skills"), + "ralplan", + "Project ralplan", + ); + fs::create_dir_all(plugin_root.join(".claw-plugin")).expect("plugin manifest dir"); + fs::write( + plugin_root.join(".claw-plugin").join("plugin.json"), + r#"{ + "name": "oh-my-claudecode", + "version": "1.0.0", + "description": "Plugin-scoped skills" +}"#, + ) + .expect("plugin manifest"); + write_skill(&plugin_root.join("skills"), "ralplan", "Plugin ralplan"); + fs::create_dir_all(home.join(".claw")).expect("config home"); + fs::write( + home.join(".claw").join("settings.json"), + r#"{ + "enabledPlugins": { + "oh-my-claudecode@external": true + } +}"#, + ) + .expect("settings"); + + let old_home = env::var_os("HOME"); + let old_codex_home = env::var_os("CODEX_HOME"); + env::set_var("HOME", &home); + env::remove_var("CODEX_HOME"); + + let local = resolve_skill_path(&workspace, "ralplan").expect("local skill should resolve"); + assert!(local.ends_with(".codex/skills/ralplan/SKILL.md")); + + let plugin = resolve_skill_path(&workspace, "oh-my-claudecode:ralplan") + .expect("plugin skill should resolve"); + assert!(plugin.ends_with("skills/ralplan/SKILL.md")); + + let skills_report = handle_skills_slash_command(None, &workspace).expect("skills report"); + assert!(skills_report.contains("Plugins:")); + assert!(skills_report.contains("oh-my-claudecode:ralplan ยท Plugin ralplan")); + + match old_home { + Some(value) => env::set_var("HOME", value), + None => env::remove_var("HOME"), + } + match old_codex_home { + Some(value) => env::set_var("CODEX_HOME", value), + None => env::remove_var("CODEX_HOME"), + } + + let _ = fs::remove_dir_all(workspace); + let _ = fs::remove_dir_all(home); + } + #[test] fn parses_quoted_skill_frontmatter_values() { let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n"; 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 4b42572..84bd7bc 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -8,6 +8,7 @@ use api::{ MessageRequest, MessageResponse, OutputContentBlock, ProviderClient, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; +use commands::resolve_skill_path as resolve_workspace_skill_path; use plugins::PluginTool; use reqwest::blocking::Client; use runtime::{ @@ -1455,47 +1456,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(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(".agents").join("skills")); - candidates.push(home.join(".config").join("opencode").join("skills")); - candidates.push(home.join(".codex").join("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())?; + resolve_workspace_skill_path(&cwd, skill).map_err(|error| error.to_string()) } const DEFAULT_AGENT_MODEL: &str = "claude-opus-4-6"; @@ -3488,6 +3450,92 @@ mod tests { .ends_with("/help/SKILL.md")); } + #[test] + fn skill_resolves_project_and_plugin_scoped_prompts() { + let _guard = env_lock() + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let workspace = temp_path("skill-workspace"); + let home = temp_path("skill-home"); + let plugin_root = home + .join(".claw") + .join("plugins") + .join("installed") + .join("oh-my-claudecode-external"); + let project_skill_root = workspace.join(".codex").join("skills").join("ralplan"); + std::fs::create_dir_all(&project_skill_root).expect("project skill dir"); + std::fs::write( + project_skill_root.join("SKILL.md"), + "---\nname: ralplan\ndescription: Project skill\n---\n", + ) + .expect("project skill"); + std::fs::create_dir_all(plugin_root.join(".claw-plugin")).expect("plugin manifest dir"); + std::fs::write( + plugin_root.join(".claw-plugin").join("plugin.json"), + r#"{ + "name": "oh-my-claudecode", + "version": "1.0.0", + "description": "Plugin skills" +}"#, + ) + .expect("plugin manifest"); + std::fs::create_dir_all(home.join(".claw")).expect("config home"); + std::fs::write( + home.join(".claw").join("settings.json"), + r#"{ + "enabledPlugins": { + "oh-my-claudecode@external": true + } +}"#, + ) + .expect("settings"); + let plugin_skill_root = plugin_root.join("skills").join("ralplan"); + std::fs::create_dir_all(&plugin_skill_root).expect("plugin skill dir"); + std::fs::write( + plugin_skill_root.join("SKILL.md"), + "---\nname: ralplan\ndescription: Plugin skill\n---\n", + ) + .expect("plugin skill"); + + let original_dir = std::env::current_dir().expect("cwd"); + let old_home = std::env::var_os("HOME"); + let old_codex_home = std::env::var_os("CODEX_HOME"); + std::env::set_current_dir(&workspace).expect("set cwd"); + std::env::set_var("HOME", &home); + std::env::remove_var("CODEX_HOME"); + + let project_result = execute_tool("Skill", &json!({ "skill": "ralplan" })) + .expect("project skill should resolve"); + let project_output: serde_json::Value = + serde_json::from_str(&project_result).expect("valid json"); + assert!(project_output["path"] + .as_str() + .expect("path") + .ends_with(".codex/skills/ralplan/SKILL.md")); + + let plugin_result = + execute_tool("Skill", &json!({ "skill": "$oh-my-claudecode:ralplan" })) + .expect("plugin skill should resolve"); + let plugin_output: serde_json::Value = + serde_json::from_str(&plugin_result).expect("valid json"); + assert!(plugin_output["path"] + .as_str() + .expect("path") + .ends_with("skills/ralplan/SKILL.md")); + + std::env::set_current_dir(&original_dir).expect("restore cwd"); + match old_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match old_codex_home { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + let _ = std::fs::remove_dir_all(workspace); + let _ = std::fs::remove_dir_all(home); + } + #[test] fn tool_search_supports_keyword_and_select_queries() { let keyword = execute_tool(