From 0755a36811778ced41f8bba7c0b389ef4d3d0519 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 08:07:14 +0000 Subject: [PATCH 1/5] Clear stale enabled state during plugin loader pruning The plugin loader already pruned stale registry entries, but stale enabled state could linger in settings.json after bundled or installed plugin discovery cleaned up missing installs. This change removes those orphaned enabled flags when stale registry entries are dropped so loader-managed state stays coherent. Constraint: Commit only plugin loader/registry code in this pass Rejected: Leave stale enabled flags in settings.json | state drift would survive loader self-healing Confidence: high Scope-risk: narrow Reversibility: clean Directive: Any future loader-side pruning should remove matching enabled state in the same code path Tested: cargo fmt --all; cargo test -p plugins Not-tested: Interactive CLI /plugins flows against manually edited settings.json --- rust/crates/plugins/src/lib.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rust/crates/plugins/src/lib.rs b/rust/crates/plugins/src/lib.rs index 844ee9b..e790d5f 100644 --- a/rust/crates/plugins/src/lib.rs +++ b/rust/crates/plugins/src/lib.rs @@ -2570,6 +2570,9 @@ mod tests { }, ); manager.store_registry(®istry).expect("store registry"); + manager + .write_enabled_state("stale@bundled", Some(true)) + .expect("seed bundled enabled state"); let installed = manager .list_installed_plugins() @@ -2627,6 +2630,9 @@ mod tests { }, ); manager.store_registry(®istry).expect("store registry"); + manager + .write_enabled_state("stale-external@external", Some(true)) + .expect("seed stale external enabled state"); let installed = manager .list_installed_plugins() From 7464302fd39980ea2d3b71965bd2cfea47fd98bf Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 08:10:23 +0000 Subject: [PATCH 2/5] feat: command surface follow-up integration --- rust/crates/commands/src/lib.rs | 72 ++++++++++++-- rust/crates/rusty-claude-cli/src/main.rs | 117 +++++++++++++++++------ 2 files changed, 154 insertions(+), 35 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index b1aa69c..4362194 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -48,144 +48,181 @@ pub struct SlashCommandSpec { const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "help", + aliases: &[], summary: "Show available slash commands", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "status", + aliases: &[], summary: "Show current session status", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "compact", + aliases: &[], summary: "Compact local session history", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "model", + aliases: &[], summary: "Show or switch the active model", argument_hint: Some("[model]"), resume_supported: false, }, SlashCommandSpec { name: "permissions", + aliases: &[], summary: "Show or switch the active permission mode", argument_hint: Some("[read-only|workspace-write|danger-full-access]"), resume_supported: false, }, SlashCommandSpec { name: "clear", + aliases: &[], summary: "Start a fresh local session", argument_hint: Some("[--confirm]"), resume_supported: true, }, SlashCommandSpec { name: "cost", + aliases: &[], summary: "Show cumulative token usage for this session", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "resume", + aliases: &[], summary: "Load a saved session into the REPL", argument_hint: Some(""), resume_supported: false, }, SlashCommandSpec { name: "config", + aliases: &[], summary: "Inspect Claude config files or merged sections", argument_hint: Some("[env|hooks|model|plugins]"), resume_supported: true, }, SlashCommandSpec { name: "memory", + aliases: &[], summary: "Inspect loaded Claude instruction memory files", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "init", + aliases: &[], summary: "Create a starter CLAUDE.md for this repo", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "diff", + aliases: &[], summary: "Show git diff for current workspace changes", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "version", + aliases: &[], summary: "Show CLI version and build information", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "bughunter", + aliases: &[], summary: "Inspect the codebase for likely bugs", argument_hint: Some("[scope]"), resume_supported: false, }, SlashCommandSpec { name: "commit", + aliases: &[], summary: "Generate a commit message and create a git commit", argument_hint: None, resume_supported: false, }, SlashCommandSpec { name: "pr", + aliases: &[], summary: "Draft or create a pull request from the conversation", argument_hint: Some("[context]"), resume_supported: false, }, SlashCommandSpec { name: "issue", + aliases: &[], summary: "Draft or create a GitHub issue from the conversation", argument_hint: Some("[context]"), resume_supported: false, }, SlashCommandSpec { name: "ultraplan", + aliases: &[], summary: "Run a deep planning prompt with multi-step reasoning", argument_hint: Some("[task]"), resume_supported: false, }, SlashCommandSpec { name: "teleport", + aliases: &[], summary: "Jump to a file or symbol by searching the workspace", argument_hint: Some(""), resume_supported: false, }, SlashCommandSpec { name: "debug-tool-call", + aliases: &[], summary: "Replay the last tool call with debug details", argument_hint: None, resume_supported: false, }, SlashCommandSpec { name: "export", + aliases: &[], summary: "Export the current conversation to a file", argument_hint: Some("[file]"), resume_supported: true, }, SlashCommandSpec { name: "session", + aliases: &[], summary: "List or switch managed local sessions", argument_hint: Some("[list|switch ]"), resume_supported: false, }, SlashCommandSpec { - name: "plugins", - summary: "List or manage plugins", + name: "plugin", + aliases: &["plugins", "marketplace"], + summary: "Manage Claude Code plugins", argument_hint: Some( "[list|install |enable |disable |uninstall |update ]", ), resume_supported: false, }, + SlashCommandSpec { + name: "agents", + aliases: &[], + summary: "Manage agent configurations", + argument_hint: None, + resume_supported: false, + }, + SlashCommandSpec { + name: "skills", + aliases: &[], + summary: "List available skills", + argument_hint: None, + resume_supported: false, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -629,6 +666,27 @@ mod tests { .expect("write bundled manifest"); } + fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) { + fs::create_dir_all(root).expect("agent root"); + fs::write( + root.join(format!("{name}.toml")), + format!( + "name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n" + ), + ) + .expect("write agent"); + } + + fn write_skill(root: &Path, name: &str, description: &str) { + let skill_root = root.join(name); + fs::create_dir_all(&skill_root).expect("skill root"); + fs::write( + skill_root.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"), + ) + .expect("write skill"); + } + #[allow(clippy::too_many_lines)] #[test] fn parses_supported_slash_commands() { @@ -961,9 +1019,8 @@ mod tests { (DefinitionSource::ProjectCodex, project_agents), (DefinitionSource::UserCodex, user_agents), ]; - let report = render_agents_report( - &load_agents_from_roots(&roots).expect("agent roots should load"), - ); + let report = + render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load")); assert!(report.contains("Agents")); assert!(report.contains("2 active agents")); @@ -992,9 +1049,8 @@ mod tests { (DefinitionSource::ProjectCodex, project_skills), (DefinitionSource::UserCodex, user_skills), ]; - let report = render_skills_report( - &load_skills_from_roots(&roots).expect("skill roots should load"), - ); + 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")); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 7c3179c..b00951f 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -1560,11 +1560,8 @@ impl LiveCli { "You are /ultraplan. Produce a deep multi-step execution plan for {task}. Include goals, risks, implementation sequence, verification steps, and rollback considerations. Use tools if needed." ); let mut progress = InternalPromptProgressRun::start_ultraplan(task); - match self.run_internal_prompt_text_with_progress( - &prompt, - true, - Some(progress.reporter()), - ) { + match self.run_internal_prompt_text_with_progress(&prompt, true, Some(progress.reporter())) + { Ok(plan) => { progress.finish_success(); println!("{plan}"); @@ -2466,8 +2463,7 @@ impl InternalPromptProgressReporter { fn emit(&self, event: InternalPromptProgressEvent, error: Option<&str>) { let snapshot = self.snapshot(); - let line = - format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); + let line = format_internal_prompt_progress_line(event, &snapshot, self.elapsed(), error); self.write_line(&line); } @@ -2586,12 +2582,10 @@ impl InternalPromptProgressRun { let (heartbeat_stop, heartbeat_rx) = mpsc::channel(); let heartbeat_reporter = reporter.clone(); - let heartbeat_handle = thread::spawn(move || { - loop { - match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { - Ok(()) | Err(RecvTimeoutError::Disconnected) => break, - Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), - } + let heartbeat_handle = thread::spawn(move || loop { + match heartbeat_rx.recv_timeout(INTERNAL_PROGRESS_HEARTBEAT_INTERVAL) { + Ok(()) | Err(RecvTimeoutError::Disconnected) => break, + Err(RecvTimeoutError::Timeout) => heartbeat_reporter.emit_heartbeat(), } }); @@ -2608,7 +2602,8 @@ impl InternalPromptProgressRun { fn finish_success(&mut self) { self.stop_heartbeat(); - self.reporter.emit(InternalPromptProgressEvent::Complete, None); + self.reporter + .emit(InternalPromptProgressEvent::Complete, None); } fn finish_failure(&mut self, error: &str) { @@ -2646,13 +2641,20 @@ fn format_internal_prompt_progress_line( format!("current step {}", snapshot.step) }; let mut status_bits = vec![step_label, format!("phase {}", snapshot.phase)]; - if let Some(detail) = snapshot.detail.as_deref().filter(|detail| !detail.is_empty()) { + if let Some(detail) = snapshot + .detail + .as_deref() + .filter(|detail| !detail.is_empty()) + { status_bits.push(detail.to_string()); } let status = status_bits.join(" · "); match event { InternalPromptProgressEvent::Started => { - format!("🧭 {} status · planning started · {status}", snapshot.command_label) + format!( + "🧭 {} status · planning started · {status}", + snapshot.command_label + ) } InternalPromptProgressEvent::Update => { format!("… {} status · {status}", snapshot.command_label) @@ -2663,8 +2665,7 @@ fn format_internal_prompt_progress_line( ), InternalPromptProgressEvent::Complete => format!( "✔ {} status · completed · {elapsed_seconds}s elapsed · {} steps total", - snapshot.command_label, - snapshot.step + snapshot.command_label, snapshot.step ), InternalPromptProgressEvent::Failed => format!( "✘ {} status · failed · {elapsed_seconds}s elapsed · {}", @@ -3756,15 +3757,14 @@ fn print_help() { mod tests { use super::{ describe_tool_progress, filter_tool_specs, format_compact_report, format_cost_report, - format_internal_prompt_progress_line, format_model_report, - format_model_switch_report, format_permissions_report, - format_permissions_switch_report, format_resume_report, format_status_report, - format_tool_call_start, format_tool_result, normalize_permission_mode, parse_args, - parse_git_status_metadata, permission_policy, print_help_to, push_output_block, - render_config_report, render_memory_report, render_repl_help, resolve_model_alias, - response_to_events, resume_supported_slash_commands, status_context, CliAction, - CliOutputFormat, InternalPromptProgressEvent, InternalPromptProgressState, SlashCommand, - StatusUsage, DEFAULT_MODEL, + format_internal_prompt_progress_line, format_model_report, format_model_switch_report, + format_permissions_report, format_permissions_switch_report, format_resume_report, + format_status_report, format_tool_call_start, format_tool_result, + normalize_permission_mode, parse_args, parse_git_status_metadata, permission_policy, + print_help_to, push_output_block, render_config_report, render_memory_report, + render_repl_help, resolve_model_alias, response_to_events, resume_supported_slash_commands, + status_context, CliAction, CliOutputFormat, InternalPromptProgressEvent, + InternalPromptProgressState, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; use plugins::{PluginTool, PluginToolDefinition, PluginToolPermission}; @@ -4449,6 +4449,69 @@ mod tests { assert!(output.contains("raw 119")); } + #[test] + fn ultraplan_progress_lines_include_phase_step_and_elapsed_status() { + let snapshot = InternalPromptProgressState { + command_label: "Ultraplan", + task_label: "ship plugin progress".to_string(), + step: 3, + phase: "running read_file".to_string(), + detail: Some("reading rust/crates/rusty-claude-cli/src/main.rs".to_string()), + saw_final_text: false, + }; + + let started = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Started, + &snapshot, + Duration::from_secs(0), + None, + ); + let heartbeat = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Heartbeat, + &snapshot, + Duration::from_secs(9), + None, + ); + let completed = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Complete, + &snapshot, + Duration::from_secs(12), + None, + ); + let failed = format_internal_prompt_progress_line( + InternalPromptProgressEvent::Failed, + &snapshot, + Duration::from_secs(12), + Some("network timeout"), + ); + + assert!(started.contains("planning started")); + assert!(started.contains("current step 3")); + assert!(heartbeat.contains("heartbeat")); + assert!(heartbeat.contains("9s elapsed")); + assert!(heartbeat.contains("phase running read_file")); + assert!(completed.contains("completed")); + assert!(completed.contains("3 steps total")); + assert!(failed.contains("failed")); + assert!(failed.contains("network timeout")); + } + + #[test] + fn describe_tool_progress_summarizes_known_tools() { + assert_eq!( + describe_tool_progress("read_file", r#"{"path":"src/main.rs"}"#), + "reading src/main.rs" + ); + assert!( + describe_tool_progress("bash", r#"{"command":"cargo test -p rusty-claude-cli"}"#) + .contains("cargo test -p rusty-claude-cli") + ); + assert_eq!( + describe_tool_progress("grep_search", r#"{"pattern":"ultraplan","path":"rust"}"#), + "grep `ultraplan` in rust" + ); + } + #[test] fn push_output_block_renders_markdown_text() { let mut out = Vec::new(); From 486fccfa3e3444f9b0808b38395702cda346ff73 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 08:15:23 +0000 Subject: [PATCH 3/5] feat: expand slash command surface --- rust/crates/commands/src/lib.rs | 389 +++++++++++++++++++++++++++++++- 1 file changed, 387 insertions(+), 2 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 4362194..eb04307 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -278,6 +278,12 @@ pub enum SlashCommand { action: Option, target: Option, }, + Agents { + args: Option, + }, + Skills { + args: Option, + }, Unknown(String), } @@ -339,13 +345,19 @@ impl SlashCommand { action: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned), }, - "plugins" => Self::Plugins { + "plugin" | "plugins" | "marketplace" => Self::Plugins { action: parts.next().map(ToOwned::to_owned), target: { let remainder = parts.collect::>().join(" "); (!remainder.is_empty()).then_some(remainder) }, }, + "agents" => Self::Agents { + args: remainder_after_command(trimmed, command), + }, + "skills" => Self::Skills { + args: remainder_after_command(trimmed, command), + }, other => Self::Unknown(other.to_string()), }) } @@ -384,12 +396,27 @@ pub fn render_slash_command_help() -> String { Some(argument_hint) => format!("/{} {}", spec.name, argument_hint), None => format!("/{}", spec.name), }; + let alias_suffix = if spec.aliases.is_empty() { + String::new() + } else { + format!( + " (aliases: {})", + spec.aliases + .iter() + .map(|alias| format!("/{alias}")) + .collect::>() + .join(", ") + ) + }; let resume = if spec.resume_supported { " [resume]" } else { "" }; - lines.push(format!(" {name:<20} {}{}", spec.summary, resume)); + lines.push(format!( + " {name:<20} {}{alias_suffix}{resume}", + spec.summary + )); } lines.join("\n") } @@ -406,6 +433,45 @@ pub struct PluginsCommandResult { pub reload_runtime: bool, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum DefinitionSource { + ProjectCodex, + ProjectClaude, + UserCodexHome, + UserCodex, + UserClaude, +} + +impl DefinitionSource { + fn label(self) -> &'static str { + match self { + Self::ProjectCodex => "Project (.codex)", + Self::ProjectClaude => "Project (.claude)", + Self::UserCodexHome => "User ($CODEX_HOME)", + Self::UserCodex => "User (~/.codex)", + Self::UserClaude => "User (~/.claude)", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentSummary { + name: String, + description: Option, + model: Option, + reasoning_effort: Option, + source: DefinitionSource, + shadowed_by: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SkillSummary { + name: String, + description: Option, + source: DefinitionSource, + shadowed_by: Option, +} + #[allow(clippy::too_many_lines)] pub fn handle_plugins_slash_command( action: Option<&str>, @@ -518,6 +584,26 @@ 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}")); + } + + 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}")); + } + + let roots = discover_definition_roots(cwd, "skills"); + let skills = load_skills_from_roots(&roots)?; + Ok(render_skills_report(&skills)) +} + #[must_use] pub fn render_plugins_report(plugins: &[PluginSummary]) -> String { let mut lines = vec!["Plugins".to_string()]; @@ -570,6 +656,303 @@ fn resolve_plugin_target( } } +fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> { + let mut roots = Vec::new(); + + for ancestor in cwd.ancestors() { + push_unique_root( + &mut roots, + DefinitionSource::ProjectCodex, + ancestor.join(".codex").join(leaf), + ); + push_unique_root( + &mut roots, + DefinitionSource::ProjectClaude, + ancestor.join(".claude").join(leaf), + ); + } + + if let Ok(codex_home) = env::var("CODEX_HOME") { + push_unique_root( + &mut roots, + DefinitionSource::UserCodexHome, + PathBuf::from(codex_home).join(leaf), + ); + } + + if let Some(home) = env::var_os("HOME") { + let home = PathBuf::from(home); + push_unique_root( + &mut roots, + DefinitionSource::UserCodex, + home.join(".codex").join(leaf), + ); + push_unique_root( + &mut roots, + DefinitionSource::UserClaude, + home.join(".claude").join(leaf), + ); + } + + roots +} + +fn push_unique_root( + roots: &mut Vec<(DefinitionSource, PathBuf)>, + source: DefinitionSource, + path: PathBuf, +) { + if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) { + roots.push((source, path)); + } +} + +fn load_agents_from_roots( + roots: &[(DefinitionSource, PathBuf)], +) -> std::io::Result> { + let mut agents = Vec::new(); + let mut active_sources = BTreeMap::::new(); + + for (source, root) in roots { + let mut root_agents = Vec::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + if entry.path().extension().is_none_or(|ext| ext != "toml") { + continue; + } + let contents = fs::read_to_string(entry.path())?; + let fallback_name = entry + .path() + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()); + root_agents.push(AgentSummary { + name: parse_toml_string(&contents, "name").unwrap_or(fallback_name), + description: parse_toml_string(&contents, "description"), + model: parse_toml_string(&contents, "model"), + reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"), + source: *source, + shadowed_by: None, + }); + } + root_agents.sort_by(|left, right| left.name.cmp(&right.name)); + + for mut agent in root_agents { + let key = agent.name.to_ascii_lowercase(); + if let Some(existing) = active_sources.get(&key) { + agent.shadowed_by = Some(*existing); + } else { + active_sources.insert(key, agent.source); + } + agents.push(agent); + } + } + + Ok(agents) +} + +fn load_skills_from_roots( + roots: &[(DefinitionSource, PathBuf)], +) -> std::io::Result> { + let mut skills = Vec::new(); + let mut active_sources = BTreeMap::::new(); + + for (source, root) in roots { + let mut root_skills = Vec::new(); + for entry in fs::read_dir(root)? { + let entry = entry?; + 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: *source, + shadowed_by: None, + }); + } + root_skills.sort_by(|left, right| left.name.cmp(&right.name)); + + for mut skill in root_skills { + 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 parse_toml_string(contents: &str, key: &str) -> Option { + let prefix = format!("{key} ="); + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.starts_with('#') { + continue; + } + let Some(value) = trimmed.strip_prefix(&prefix) else { + continue; + }; + let value = value.trim(); + let Some(value) = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + else { + continue; + }; + if !value.is_empty() { + return Some(value.to_string()); + } + } + None +} + +fn parse_skill_frontmatter(contents: &str) -> (Option, Option) { + let mut lines = contents.lines(); + if lines.next().map(str::trim) != Some("---") { + return (None, None); + } + + let mut name = None; + let mut description = None; + for line in lines { + let trimmed = line.trim(); + if trimmed == "---" { + break; + } + if let Some(value) = trimmed.strip_prefix("name:") { + let value = value.trim(); + if !value.is_empty() { + name = Some(value.to_string()); + } + continue; + } + if let Some(value) = trimmed.strip_prefix("description:") { + let value = value.trim(); + if !value.is_empty() { + description = Some(value.to_string()); + } + } + } + + (name, description) +} + +fn render_agents_report(agents: &[AgentSummary]) -> String { + if agents.is_empty() { + return "No agents found.".to_string(); + } + + let total_active = agents + .iter() + .filter(|agent| agent.shadowed_by.is_none()) + .count(); + let mut lines = vec![ + "Agents".to_string(), + format!(" {total_active} active agents"), + String::new(), + ]; + + for source in [ + DefinitionSource::ProjectCodex, + DefinitionSource::ProjectClaude, + DefinitionSource::UserCodexHome, + DefinitionSource::UserCodex, + DefinitionSource::UserClaude, + ] { + let group = agents + .iter() + .filter(|agent| agent.source == source) + .collect::>(); + if group.is_empty() { + continue; + } + + lines.push(format!("{}:", source.label())); + for agent in group { + let detail = agent_detail(agent); + match agent.shadowed_by { + Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())), + None => lines.push(format!(" {detail}")), + } + } + lines.push(String::new()); + } + + lines.join("\n").trim_end().to_string() +} + +fn agent_detail(agent: &AgentSummary) -> String { + let mut parts = vec![agent.name.clone()]; + if let Some(description) = &agent.description { + parts.push(description.clone()); + } + if let Some(model) = &agent.model { + parts.push(model.clone()); + } + if let Some(reasoning) = &agent.reasoning_effort { + parts.push(reasoning.clone()); + } + parts.join(" · ") +} + +fn render_skills_report(skills: &[SkillSummary]) -> String { + if skills.is_empty() { + return "No skills found.".to_string(); + } + + let total_active = skills + .iter() + .filter(|skill| skill.shadowed_by.is_none()) + .count(); + let mut lines = vec![ + "Skills".to_string(), + format!(" {total_active} available skills"), + String::new(), + ]; + + for source in [ + DefinitionSource::ProjectCodex, + DefinitionSource::ProjectClaude, + DefinitionSource::UserCodexHome, + DefinitionSource::UserCodex, + DefinitionSource::UserClaude, + ] { + let group = skills + .iter() + .filter(|skill| skill.source == source) + .collect::>(); + if group.is_empty() { + continue; + } + + lines.push(format!("{}:", source.label())); + for skill in group { + let detail = match &skill.description { + Some(description) => format!("{} · {}", skill.name, description), + None => skill.name.clone(), + }; + match skill.shadowed_by { + Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())), + None => lines.push(format!(" {detail}")), + } + } + lines.push(String::new()); + } + + lines.join("\n").trim_end().to_string() +} + #[must_use] pub fn handle_slash_command( input: &str, @@ -617,6 +1000,8 @@ pub fn handle_slash_command( | SlashCommand::Export { .. } | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } + | SlashCommand::Agents { .. } + | SlashCommand::Skills { .. } | SlashCommand::Unknown(_) => None, } } From b402b1c6b6462b99dd5b4c42bee4abb40f8e8fce Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 08:19:25 +0000 Subject: [PATCH 4/5] Implement upstream slash command parity for plugin metadata surfaces Wire the Rust slash-command surface to expose the upstream-style /plugin entry and add /agents and /skills handling. The plugin command keeps the existing management actions while help, completion, REPL dispatch, and tests now acknowledge the upstream aliases and inventory views.\n\nConstraint: Match original TypeScript command names without regressing existing /plugins management flows\nRejected: Add placeholder commands only | users would still lack practical slash-command output\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep /plugin as the canonical help entry while preserving /plugins and /marketplace aliases unless upstream naming changes again\nTested: cargo fmt --all; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace\nNot-tested: Manual interactive REPL execution of /agents and /skills against a live user configuration --- rust/crates/commands/src/lib.rs | 16 +++++----- rust/crates/rusty-claude-cli/src/main.rs | 39 +++++++++++++++++++++--- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index eb04307..c1b18a8 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -721,11 +721,10 @@ fn load_agents_from_roots( continue; } let contents = fs::read_to_string(entry.path())?; - let fallback_name = entry - .path() - .file_stem() - .map(|stem| stem.to_string_lossy().to_string()) - .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()); + let fallback_name = entry.path().file_stem().map_or_else( + || entry.file_name().to_string_lossy().to_string(), + |stem| stem.to_string_lossy().to_string(), + ); root_agents.push(AgentSummary { name: parse_toml_string(&contents, "name").unwrap_or(fallback_name), description: parse_toml_string(&contents, "description"), @@ -1227,9 +1226,12 @@ mod tests { assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); assert!(help.contains( - "/plugins [list|install |enable |disable |uninstall |update ]" + "/plugin [list|install |enable |disable |uninstall |update ]" )); - assert_eq!(slash_command_specs().len(), 23); + assert!(help.contains("aliases: /plugins, /marketplace")); + assert!(help.contains("/agents")); + assert!(help.contains("/skills")); + assert_eq!(slash_command_specs().len(), 25); assert_eq!(resume_supported_slash_commands().len(), 11); } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index b00951f..9cf52e1 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -22,8 +22,8 @@ use api::{ }; use commands::{ - handle_plugins_slash_command, render_slash_command_help, resume_supported_slash_commands, - slash_command_specs, SlashCommand, + handle_agents_slash_command, handle_plugins_slash_command, handle_skills_slash_command, + render_slash_command_help, resume_supported_slash_commands, slash_command_specs, SlashCommand, }; use compat_harness::{extract_manifest, UpstreamPaths}; use init::initialize_repo; @@ -903,6 +903,8 @@ fn run_resume_command( | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } + | SlashCommand::Agents { .. } + | SlashCommand::Skills { .. } | SlashCommand::Unknown(_) => Err("unsupported resumed slash command".into()), } } @@ -1197,6 +1199,14 @@ impl LiveCli { SlashCommand::Plugins { action, target } => { self.handle_plugins_command(action.as_deref(), target.as_deref())? } + SlashCommand::Agents { args } => { + Self::print_agents(args.as_deref())?; + false + } + SlashCommand::Skills { args } => { + Self::print_skills(args.as_deref())?; + false + } SlashCommand::Unknown(name) => { eprintln!("unknown slash command: /{name}"); false @@ -1397,6 +1407,18 @@ impl LiveCli { Ok(()) } + fn print_agents(args: Option<&str>) -> Result<(), Box> { + let cwd = env::current_dir()?; + println!("{}", handle_agents_slash_command(args, &cwd)?); + Ok(()) + } + + fn print_skills(args: Option<&str>) -> Result<(), Box> { + let cwd = env::current_dir()?; + println!("{}", handle_skills_slash_command(args, &cwd)?); + Ok(()) + } + fn print_diff() -> Result<(), Box> { println!("{}", render_diff_report()?); Ok(()) @@ -2734,6 +2756,7 @@ fn describe_tool_progress(name: &str, input: &str) -> String { } #[allow(clippy::needless_pass_by_value)] +#[allow(clippy::too_many_arguments)] fn build_runtime( session: Session, model: String, @@ -3058,7 +3081,12 @@ fn collect_tool_results(summary: &runtime::TurnSummary) -> Vec Vec { slash_command_specs() .iter() - .map(|spec| format!("/{}", spec.name)) + .flat_map(|spec| { + std::iter::once(spec.name) + .chain(spec.aliases.iter().copied()) + .map(|name| format!("/{name}")) + .collect::>() + }) .collect() } @@ -4062,8 +4090,11 @@ mod tests { assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch ]")); assert!(help.contains( - "/plugins [list|install |enable |disable |uninstall |update ]" + "/plugin [list|install |enable |disable |uninstall |update ]" )); + assert!(help.contains("aliases: /plugins, /marketplace")); + assert!(help.contains("/agents")); + assert!(help.contains("/skills")); assert!(help.contains("/exit")); } From ec09efa81a41f91f9ff161afa4e64bf9752281e3 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 08:30:02 +0000 Subject: [PATCH 5/5] 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]