From ec09efa81a41f91f9ff161afa4e64bf9752281e3 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 08:30:02 +0000 Subject: [PATCH] Make agents and skills commands usable beyond placeholder parsing Wire /agents and /skills through the Rust command stack so they can run as direct CLI subcommands, direct slash invocations, and resume-safe slash commands. The handlers now provide structured usage output, skills discovery also covers legacy /commands markdown entries, and the reporting/tests line up more closely with the original TypeScript behavior where feasible. Constraint: The Rust port does not yet have the original TypeScript TUI menus or plugin/MCP skill registry, so text reports approximate those views Rejected: Rebuild the original interactive React menus in Rust now | too large for the current CLI parity slice Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep /skills discovery and the Skill tool aligned if command/skill registry parity expands later Tested: cargo test --workspace Tested: cargo clippy --workspace --all-targets -- -D warnings Tested: cargo run -q -p rusty-claude-cli -- agents --help Tested: cargo run -q -p rusty-claude-cli -- /agents Not-tested: Live Anthropic-backed REPL execution of /agents or /skills --- rust/crates/commands/src/lib.rs | 355 ++++++++++++++++++++--- rust/crates/rusty-claude-cli/src/main.rs | 103 ++++++- 2 files changed, 406 insertions(+), 52 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index c1b18a8..3fe75ec 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -212,16 +212,16 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "agents", aliases: &[], - summary: "Manage agent configurations", + summary: "List configured agents", argument_hint: None, - resume_supported: false, + resume_supported: true, }, SlashCommandSpec { name: "skills", aliases: &[], summary: "List available skills", argument_hint: None, - resume_supported: false, + resume_supported: true, }, ]; @@ -470,6 +470,29 @@ struct SkillSummary { description: Option, source: DefinitionSource, shadowed_by: Option, + origin: SkillOrigin, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SkillOrigin { + SkillsDir, + LegacyCommandsDir, +} + +impl SkillOrigin { + fn detail_label(self) -> Option<&'static str> { + match self { + Self::SkillsDir => None, + Self::LegacyCommandsDir => Some("legacy /commands"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SkillRoot { + source: DefinitionSource, + path: PathBuf, + origin: SkillOrigin, } #[allow(clippy::too_many_lines)] @@ -585,23 +608,27 @@ pub fn handle_plugins_slash_command( } pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result { - if let Some(args) = args.filter(|value| !value.trim().is_empty()) { - return Ok(format!("Usage: /agents\nUnexpected arguments: {args}")); + match normalize_optional_args(args) { + None | Some("list") => { + let roots = discover_definition_roots(cwd, "agents"); + let agents = load_agents_from_roots(&roots)?; + Ok(render_agents_report(&agents)) + } + Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)), + Some(args) => Ok(render_agents_usage(Some(args))), } - - let roots = discover_definition_roots(cwd, "agents"); - let agents = load_agents_from_roots(&roots)?; - Ok(render_agents_report(&agents)) } pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result { - if let Some(args) = args.filter(|value| !value.trim().is_empty()) { - return Ok(format!("Usage: /skills\nUnexpected arguments: {args}")); + match normalize_optional_args(args) { + None | Some("list") => { + let roots = discover_skill_roots(cwd); + let skills = load_skills_from_roots(&roots)?; + Ok(render_skills_report(&skills)) + } + Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)), + Some(args) => Ok(render_skills_usage(Some(args))), } - - let roots = discover_definition_roots(cwd, "skills"); - let skills = load_skills_from_roots(&roots)?; - Ok(render_skills_report(&skills)) } #[must_use] @@ -697,6 +724,83 @@ fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, P roots } +fn discover_skill_roots(cwd: &Path) -> Vec { + let mut roots = Vec::new(); + + for ancestor in cwd.ancestors() { + push_unique_skill_root( + &mut roots, + DefinitionSource::ProjectCodex, + ancestor.join(".codex").join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::ProjectClaude, + ancestor.join(".claude").join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::ProjectCodex, + ancestor.join(".codex").join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::ProjectClaude, + ancestor.join(".claude").join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + } + + if let Ok(codex_home) = env::var("CODEX_HOME") { + let codex_home = PathBuf::from(codex_home); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserCodexHome, + codex_home.join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserCodexHome, + codex_home.join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + } + + if let Some(home) = env::var_os("HOME") { + let home = PathBuf::from(home); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserCodex, + home.join(".codex").join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserCodex, + home.join(".codex").join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserClaude, + home.join(".claude").join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserClaude, + home.join(".claude").join("commands"), + SkillOrigin::LegacyCommandsDir, + ); + } + + roots +} + fn push_unique_root( roots: &mut Vec<(DefinitionSource, PathBuf)>, source: DefinitionSource, @@ -707,6 +811,21 @@ fn push_unique_root( } } +fn push_unique_skill_root( + roots: &mut Vec, + source: DefinitionSource, + path: PathBuf, + origin: SkillOrigin, +) { + if path.is_dir() && !roots.iter().any(|existing| existing.path == path) { + roots.push(SkillRoot { + source, + path, + origin, + }); + } +} + fn load_agents_from_roots( roots: &[(DefinitionSource, PathBuf)], ) -> std::io::Result> { @@ -750,31 +869,66 @@ fn load_agents_from_roots( Ok(agents) } -fn load_skills_from_roots( - roots: &[(DefinitionSource, PathBuf)], -) -> std::io::Result> { +fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result> { let mut skills = Vec::new(); let mut active_sources = BTreeMap::::new(); - for (source, root) in roots { + for root in roots { let mut root_skills = Vec::new(); - for entry in fs::read_dir(root)? { + for entry in fs::read_dir(&root.path)? { let entry = entry?; - if !entry.path().is_dir() { - continue; + 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, description) = parse_skill_frontmatter(&contents); + root_skills.push(SkillSummary { + name: name + .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()), + description, + source: root.source, + shadowed_by: None, + origin: root.origin, + }); + } + 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, description) = parse_skill_frontmatter(&contents); + root_skills.push(SkillSummary { + name: name.unwrap_or(fallback_name), + description, + source: root.source, + shadowed_by: None, + origin: root.origin, + }); + } } - let skill_path = entry.path().join("SKILL.md"); - if !skill_path.is_file() { - continue; - } - let contents = fs::read_to_string(skill_path)?; - let (name, description) = parse_skill_frontmatter(&contents); - root_skills.push(SkillSummary { - name: name.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()), - description, - source: *source, - shadowed_by: None, - }); } root_skills.sort_by(|left, right| left.name.cmp(&right.name)); @@ -830,16 +984,16 @@ fn parse_skill_frontmatter(contents: &str) -> (Option, Option) { break; } if let Some(value) = trimmed.strip_prefix("name:") { - let value = value.trim(); + let value = unquote_frontmatter_value(value.trim()); if !value.is_empty() { - name = Some(value.to_string()); + name = Some(value); } continue; } if let Some(value) = trimmed.strip_prefix("description:") { - let value = value.trim(); + let value = unquote_frontmatter_value(value.trim()); if !value.is_empty() { - description = Some(value.to_string()); + description = Some(value); } } } @@ -847,6 +1001,20 @@ fn parse_skill_frontmatter(contents: &str) -> (Option, Option) { (name, description) } +fn unquote_frontmatter_value(value: &str) -> String { + value + .strip_prefix('"') + .and_then(|trimmed| trimmed.strip_suffix('"')) + .or_else(|| { + value + .strip_prefix('\'') + .and_then(|trimmed| trimmed.strip_suffix('\'')) + }) + .unwrap_or(value) + .trim() + .to_string() +} + fn render_agents_report(agents: &[AgentSummary]) -> String { if agents.is_empty() { return "No agents found.".to_string(); @@ -937,10 +1105,14 @@ fn render_skills_report(skills: &[SkillSummary]) -> String { lines.push(format!("{}:", source.label())); for skill in group { - let detail = match &skill.description { - Some(description) => format!("{} · {}", skill.name, description), - None => skill.name.clone(), - }; + let mut parts = vec![skill.name.clone()]; + if let Some(description) = &skill.description { + parts.push(description.clone()); + } + if let Some(detail) = skill.origin.detail_label() { + parts.push(detail.to_string()); + } + let detail = parts.join(" · "); match skill.shadowed_by { Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())), None => lines.push(format!(" {detail}")), @@ -952,6 +1124,36 @@ fn render_skills_report(skills: &[SkillSummary]) -> String { lines.join("\n").trim_end().to_string() } +fn normalize_optional_args(args: Option<&str>) -> Option<&str> { + args.map(str::trim).filter(|value| !value.is_empty()) +} + +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, .claude/agents, $CODEX_HOME/agents".to_string(), + ]; + if let Some(args) = unexpected { + lines.push(format!(" Unexpected {args}")); + } + lines.join("\n") +} + +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, .claude/skills, legacy /commands".to_string(), + ]; + if let Some(args) = unexpected { + lines.push(format!(" Unexpected {args}")); + } + lines.join("\n") +} + #[must_use] pub fn handle_slash_command( input: &str, @@ -1011,7 +1213,7 @@ mod tests { handle_plugins_slash_command, handle_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, - DefinitionSource, SlashCommand, + DefinitionSource, SkillOrigin, SkillRoot, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; @@ -1071,6 +1273,15 @@ mod tests { .expect("write skill"); } + fn write_legacy_command(root: &Path, name: &str, description: &str) { + fs::create_dir_all(root).expect("commands root"); + fs::write( + root.join(format!("{name}.md")), + format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"), + ) + .expect("write command"); + } + #[allow(clippy::too_many_lines)] #[test] fn parses_supported_slash_commands() { @@ -1232,7 +1443,7 @@ mod tests { assert!(help.contains("/agents")); assert!(help.contains("/skills")); assert_eq!(slash_command_specs().len(), 25); - assert_eq!(resume_supported_slash_commands().len(), 11); + assert_eq!(resume_supported_slash_commands().len(), 13); } #[test] @@ -1425,24 +1636,41 @@ mod tests { fn lists_skills_from_project_and_user_roots() { let workspace = temp_dir("skills-workspace"); let project_skills = workspace.join(".codex").join("skills"); + let project_commands = workspace.join(".claude").join("commands"); let user_home = temp_dir("skills-home"); let user_skills = user_home.join(".codex").join("skills"); write_skill(&project_skills, "plan", "Project planning guidance"); + write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance"); write_skill(&user_skills, "plan", "User planning guidance"); write_skill(&user_skills, "help", "Help guidance"); let roots = vec![ - (DefinitionSource::ProjectCodex, project_skills), - (DefinitionSource::UserCodex, user_skills), + SkillRoot { + source: DefinitionSource::ProjectCodex, + path: project_skills, + origin: SkillOrigin::SkillsDir, + }, + SkillRoot { + source: DefinitionSource::ProjectClaude, + path: project_commands, + origin: SkillOrigin::LegacyCommandsDir, + }, + SkillRoot { + source: DefinitionSource::UserCodex, + path: user_skills, + origin: SkillOrigin::SkillsDir, + }, ]; let report = render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load")); assert!(report.contains("Skills")); - assert!(report.contains("2 available skills")); + assert!(report.contains("3 available skills")); assert!(report.contains("Project (.codex):")); assert!(report.contains("plan · Project planning guidance")); + assert!(report.contains("Project (.claude):")); + assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands")); assert!(report.contains("User (~/.codex):")); assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance")); assert!(report.contains("help · Help guidance")); @@ -1451,6 +1679,39 @@ mod tests { let _ = fs::remove_dir_all(user_home); } + #[test] + fn agents_and_skills_usage_support_help_and_unexpected_args() { + let cwd = temp_dir("slash-usage"); + + let agents_help = + super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help"); + assert!(agents_help.contains("Usage /agents")); + assert!(agents_help.contains("Direct CLI claw agents")); + + let agents_unexpected = + super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage"); + assert!(agents_unexpected.contains("Unexpected show planner")); + + let skills_help = + super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help"); + assert!(skills_help.contains("Usage /skills")); + assert!(skills_help.contains("legacy /commands")); + + let skills_unexpected = + super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage"); + assert!(skills_unexpected.contains("Unexpected show help")); + + let _ = fs::remove_dir_all(cwd); + } + + #[test] + fn parses_quoted_skill_frontmatter_values() { + let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n"; + let (name, description) = super::parse_skill_frontmatter(contents); + assert_eq!(name.as_deref(), Some("hud")); + assert_eq!(description.as_deref(), Some("Quoted description")); + } + #[test] fn installs_plugin_from_path_and_lists_it() { let config_home = temp_dir("home"); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 9cf52e1..3fbe74c 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -73,6 +73,8 @@ fn run() -> Result<(), Box> { match parse_args(&args)? { CliAction::DumpManifests => dump_manifests(), CliAction::BootstrapPlan => print_bootstrap_plan(), + CliAction::Agents { args } => LiveCli::print_agents(args.as_deref())?, + CliAction::Skills { args } => LiveCli::print_skills(args.as_deref())?, CliAction::PrintSystemPrompt { cwd, date } => print_system_prompt(cwd, date), CliAction::Version => print_version(), CliAction::ResumeSession { @@ -104,6 +106,12 @@ fn run() -> Result<(), Box> { enum CliAction { DumpManifests, BootstrapPlan, + Agents { + args: Option, + }, + Skills { + args: Option, + }, PrintSystemPrompt { cwd: PathBuf, date: String, @@ -267,6 +275,12 @@ 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..]), + }), "system-prompt" => parse_system_prompt_args(&rest[1..]), "login" => Ok(CliAction::Login), "logout" => Ok(CliAction::Logout), @@ -284,14 +298,37 @@ fn parse_args(args: &[String]) -> Result { permission_mode, }) } - other if !other.starts_with('/') => Ok(CliAction::Prompt { + other if other.starts_with('/') => parse_direct_slash_cli_action(&rest), + _other => Ok(CliAction::Prompt { prompt: rest.join(" "), model, output_format, allowed_tools, permission_mode, }), - other => Err(format!("unknown subcommand: {other}")), + } +} + +fn join_optional_args(args: &[String]) -> Option { + let joined = args.join(" "); + let trimmed = joined.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) +} + +fn parse_direct_slash_cli_action(rest: &[String]) -> 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(command) => Err(format!( + "unsupported direct slash command outside the REPL: {command_name}", + command_name = match command { + SlashCommand::Unknown(name) => format!("/{name}"), + _ => rest[0].clone(), + } + )), + None => Err(format!("unknown subcommand: {}", rest[0])), } } @@ -891,6 +928,20 @@ fn run_resume_command( )), }) } + SlashCommand::Agents { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), + }) + } + SlashCommand::Skills { args } => { + let cwd = env::current_dir()?; + Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), + }) + } SlashCommand::Bughunter { .. } | SlashCommand::Commit | SlashCommand::Pr { .. } @@ -903,8 +954,6 @@ fn run_resume_command( | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } - | SlashCommand::Agents { .. } - | SlashCommand::Skills { .. } | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -3718,6 +3767,8 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { )?; writeln!(out, " claw dump-manifests")?; writeln!(out, " claw bootstrap-plan")?; + writeln!(out, " claw agents")?; + writeln!(out, " claw skills")?; writeln!(out, " claw system-prompt [--cwd PATH] [--date YYYY-MM-DD]")?; writeln!(out, " claw login")?; writeln!(out, " claw logout")?; @@ -3772,6 +3823,8 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " claw --resume session.json /status /diff /export notes.txt" )?; + writeln!(out, " claw agents")?; + writeln!(out, " claw /skills")?; writeln!(out, " claw login")?; writeln!(out, " claw init")?; Ok(()) @@ -3992,6 +4045,43 @@ mod tests { parse_args(&["init".to_string()]).expect("init should parse"), CliAction::Init ); + assert_eq!( + parse_args(&["agents".to_string()]).expect("agents should parse"), + CliAction::Agents { args: None } + ); + assert_eq!( + parse_args(&["skills".to_string()]).expect("skills should parse"), + CliAction::Skills { args: None } + ); + assert_eq!( + parse_args(&["agents".to_string(), "--help".to_string()]) + .expect("agents help should parse"), + CliAction::Agents { + args: Some("--help".to_string()) + } + ); + } + + #[test] + fn parses_direct_agents_and_skills_slash_commands() { + assert_eq!( + parse_args(&["/agents".to_string()]).expect("/agents should parse"), + CliAction::Agents { args: None } + ); + assert_eq!( + parse_args(&["/skills".to_string()]).expect("/skills should parse"), + 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()) + } + ); + let error = parse_args(&["/status".to_string()]) + .expect_err("/status should remain REPL-only when invoked directly"); + assert!(error.contains("unsupported direct slash command")); } #[test] @@ -4108,7 +4198,7 @@ mod tests { names, vec![ "help", "status", "compact", "clear", "cost", "config", "memory", "init", "diff", - "version", "export", + "version", "export", "agents", "skills", ] ); } @@ -4175,6 +4265,9 @@ mod tests { print_help_to(&mut help).expect("help should render"); let help = String::from_utf8(help).expect("help should be utf8"); assert!(help.contains("claw init")); + assert!(help.contains("claw agents")); + assert!(help.contains("claw skills")); + assert!(help.contains("claw /skills")); } #[test]