use std::collections::BTreeMap; use std::env; use std::fmt; use std::fs; use std::path::{Path, PathBuf}; use plugins::{PluginError, PluginManager, PluginSummary}; use runtime::{compact_session, CompactionConfig, Session}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommandManifestEntry { pub name: String, pub source: CommandSource, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CommandSource { Builtin, InternalOnly, FeatureGated, } #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct CommandRegistry { entries: Vec, } impl CommandRegistry { #[must_use] pub fn new(entries: Vec) -> Self { Self { entries } } #[must_use] pub fn entries(&self) -> &[CommandManifestEntry] { &self.entries } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SlashCommandSpec { pub name: &'static str, pub aliases: &'static [&'static str], pub summary: &'static str, pub argument_hint: Option<&'static str>, pub resume_supported: bool, } 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: "sandbox", aliases: &[], summary: "Show sandbox isolation 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, switch, or fork managed local sessions", argument_hint: Some("[list|switch |fork [branch-name]]"), resume_supported: false, }, SlashCommandSpec { name: "plugin", aliases: &["plugins", "marketplace"], summary: "Manage Claw Code plugins", argument_hint: Some( "[list|install |enable |disable |uninstall |update ]", ), resume_supported: false, }, SlashCommandSpec { name: "agents", aliases: &[], summary: "List configured agents", argument_hint: Some("[list|help]"), resume_supported: true, }, SlashCommandSpec { name: "skills", aliases: &[], summary: "List available skills", argument_hint: Some("[list|help]"), resume_supported: true, }, ]; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SlashCommand { Help, Status, Sandbox, Compact, Bughunter { scope: Option, }, Commit, Pr { context: Option, }, Issue { context: Option, }, Ultraplan { task: Option, }, Teleport { target: Option, }, DebugToolCall, Model { model: Option, }, Permissions { mode: Option, }, Clear { confirm: bool, }, Cost, Resume { session_path: Option, }, Config { section: Option, }, Memory, Init, Diff, Version, Export { path: Option, }, Session { action: Option, target: Option, }, Plugins { action: Option, target: Option, }, Agents { args: Option, }, Skills { args: Option, }, Unknown(String), } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SlashCommandParseError { message: String, } impl SlashCommandParseError { fn new(message: impl Into) -> Self { Self { message: message.into(), } } } impl fmt::Display for SlashCommandParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.message) } } impl std::error::Error for SlashCommandParseError {} impl SlashCommand { pub fn parse(input: &str) -> Result, SlashCommandParseError> { validate_slash_command_input(input) } } pub fn validate_slash_command_input( input: &str, ) -> Result, SlashCommandParseError> { let trimmed = input.trim(); if !trimmed.starts_with('/') { return Ok(None); } let mut parts = trimmed.trim_start_matches('/').split_whitespace(); let command = parts.next().unwrap_or_default(); if command.is_empty() { return Err(SlashCommandParseError::new( "Slash command name is missing. Use /help to list available slash commands.", )); } let args = parts.collect::>(); let remainder = remainder_after_command(trimmed, command); Ok(Some(match command { "help" => { validate_no_args(command, &args)?; SlashCommand::Help } "status" => { validate_no_args(command, &args)?; SlashCommand::Status } "sandbox" => { validate_no_args(command, &args)?; SlashCommand::Sandbox } "compact" => { validate_no_args(command, &args)?; SlashCommand::Compact } "bughunter" => SlashCommand::Bughunter { scope: remainder }, "commit" => { validate_no_args(command, &args)?; SlashCommand::Commit } "pr" => SlashCommand::Pr { context: remainder }, "issue" => SlashCommand::Issue { context: remainder }, "ultraplan" => SlashCommand::Ultraplan { task: remainder }, "teleport" => SlashCommand::Teleport { target: Some(require_remainder(command, remainder, "")?), }, "debug-tool-call" => { validate_no_args(command, &args)?; SlashCommand::DebugToolCall } "model" => SlashCommand::Model { model: optional_single_arg(command, &args, "[model]")?, }, "permissions" => SlashCommand::Permissions { mode: parse_permissions_mode(&args)?, }, "clear" => SlashCommand::Clear { confirm: parse_clear_args(&args)?, }, "cost" => { validate_no_args(command, &args)?; SlashCommand::Cost } "resume" => SlashCommand::Resume { session_path: Some(require_remainder(command, remainder, "")?), }, "config" => SlashCommand::Config { section: parse_config_section(&args)?, }, "memory" => { validate_no_args(command, &args)?; SlashCommand::Memory } "init" => { validate_no_args(command, &args)?; SlashCommand::Init } "diff" => { validate_no_args(command, &args)?; SlashCommand::Diff } "version" => { validate_no_args(command, &args)?; SlashCommand::Version } "export" => SlashCommand::Export { path: remainder }, "session" => parse_session_command(&args)?, "plugin" | "plugins" | "marketplace" => parse_plugin_command(&args)?, "agents" => SlashCommand::Agents { args: parse_list_or_help_args(command, remainder)?, }, "skills" => SlashCommand::Skills { args: parse_list_or_help_args(command, remainder)?, }, other => SlashCommand::Unknown(other.to_string()), })) } fn validate_no_args(command: &str, args: &[&str]) -> Result<(), SlashCommandParseError> { if args.is_empty() { return Ok(()); } Err(command_error( &format!("Unexpected arguments for /{command}."), command, &format!("/{command}"), )) } fn optional_single_arg( command: &str, args: &[&str], argument_hint: &str, ) -> Result, SlashCommandParseError> { match args { [] => Ok(None), [value] => Ok(Some((*value).to_string())), _ => Err(usage_error(command, argument_hint)), } } fn require_remainder( command: &str, remainder: Option, argument_hint: &str, ) -> Result { remainder.ok_or_else(|| usage_error(command, argument_hint)) } fn parse_permissions_mode(args: &[&str]) -> Result, SlashCommandParseError> { let mode = optional_single_arg( "permissions", args, "[read-only|workspace-write|danger-full-access]", )?; if let Some(mode) = mode { if matches!( mode.as_str(), "read-only" | "workspace-write" | "danger-full-access" ) { return Ok(Some(mode)); } return Err(command_error( &format!( "Unsupported /permissions mode '{mode}'. Use read-only, workspace-write, or danger-full-access." ), "permissions", "/permissions [read-only|workspace-write|danger-full-access]", )); } Ok(None) } fn parse_clear_args(args: &[&str]) -> Result { match args { [] => Ok(false), ["--confirm"] => Ok(true), [unexpected] => Err(command_error( &format!("Unsupported /clear argument '{unexpected}'. Use /clear or /clear --confirm."), "clear", "/clear [--confirm]", )), _ => Err(usage_error("clear", "[--confirm]")), } } fn parse_config_section(args: &[&str]) -> Result, SlashCommandParseError> { let section = optional_single_arg("config", args, "[env|hooks|model|plugins]")?; if let Some(section) = section { if matches!(section.as_str(), "env" | "hooks" | "model" | "plugins") { return Ok(Some(section)); } return Err(command_error( &format!("Unsupported /config section '{section}'. Use env, hooks, model, or plugins."), "config", "/config [env|hooks|model|plugins]", )); } Ok(None) } fn parse_session_command(args: &[&str]) -> Result { match args { [] => Ok(SlashCommand::Session { action: None, target: None, }), ["list"] => Ok(SlashCommand::Session { action: Some("list".to_string()), target: None, }), ["list", ..] => Err(usage_error("session", "[list|switch |fork [branch-name]]")), ["switch"] => Err(usage_error("session switch", "")), ["switch", target] => Ok(SlashCommand::Session { action: Some("switch".to_string()), target: Some((*target).to_string()), }), ["switch", ..] => Err(command_error( "Unexpected arguments for /session switch.", "session", "/session switch ", )), ["fork"] => Ok(SlashCommand::Session { action: Some("fork".to_string()), target: None, }), ["fork", target] => Ok(SlashCommand::Session { action: Some("fork".to_string()), target: Some((*target).to_string()), }), ["fork", ..] => Err(command_error( "Unexpected arguments for /session fork.", "session", "/session fork [branch-name]", )), [action, ..] => Err(command_error( &format!( "Unknown /session action '{action}'. Use list, switch , or fork [branch-name]." ), "session", "/session [list|switch |fork [branch-name]]", )), } } fn parse_plugin_command(args: &[&str]) -> Result { match args { [] => Ok(SlashCommand::Plugins { action: None, target: None, }), ["list"] => Ok(SlashCommand::Plugins { action: Some("list".to_string()), target: None, }), ["list", ..] => Err(usage_error("plugin list", "")), ["install"] => Err(usage_error("plugin install", "")), ["install", target @ ..] => Ok(SlashCommand::Plugins { action: Some("install".to_string()), target: Some(target.join(" ")), }), ["enable"] => Err(usage_error("plugin enable", "")), ["enable", target] => Ok(SlashCommand::Plugins { action: Some("enable".to_string()), target: Some((*target).to_string()), }), ["enable", ..] => Err(command_error( "Unexpected arguments for /plugin enable.", "plugin", "/plugin enable ", )), ["disable"] => Err(usage_error("plugin disable", "")), ["disable", target] => Ok(SlashCommand::Plugins { action: Some("disable".to_string()), target: Some((*target).to_string()), }), ["disable", ..] => Err(command_error( "Unexpected arguments for /plugin disable.", "plugin", "/plugin disable ", )), ["uninstall"] => Err(usage_error("plugin uninstall", "")), ["uninstall", target] => Ok(SlashCommand::Plugins { action: Some("uninstall".to_string()), target: Some((*target).to_string()), }), ["uninstall", ..] => Err(command_error( "Unexpected arguments for /plugin uninstall.", "plugin", "/plugin uninstall ", )), ["update"] => Err(usage_error("plugin update", "")), ["update", target] => Ok(SlashCommand::Plugins { action: Some("update".to_string()), target: Some((*target).to_string()), }), ["update", ..] => Err(command_error( "Unexpected arguments for /plugin update.", "plugin", "/plugin update ", )), [action, ..] => Err(command_error( &format!( "Unknown /plugin action '{action}'. Use list, install , enable , disable , uninstall , or update ." ), "plugin", "/plugin [list|install |enable |disable |uninstall |update ]", )), } } fn parse_list_or_help_args( command: &str, args: Option, ) -> Result, SlashCommandParseError> { match normalize_optional_args(args.as_deref()) { None | Some("list" | "help" | "-h" | "--help") => Ok(args), Some(unexpected) => Err(command_error( &format!( "Unexpected arguments for /{command}: {unexpected}. Use /{command}, /{command} list, or /{command} help." ), command, &format!("/{command} [list|help]"), )), } } fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError { let usage = format!("/{command} {argument_hint}"); let usage = usage.trim_end().to_string(); command_error( &format!("Usage: {usage}"), command_root_name(command), &usage, ) } fn command_error(message: &str, command: &str, usage: &str) -> SlashCommandParseError { let detail = render_slash_command_help_detail(command) .map(|detail| format!("\n\n{detail}")) .unwrap_or_default(); SlashCommandParseError::new(format!("{message}\n Usage {usage}{detail}")) } fn remainder_after_command(input: &str, command: &str) -> Option { input .trim() .strip_prefix(&format!("/{command}")) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } fn find_slash_command_spec(name: &str) -> Option<&'static SlashCommandSpec> { slash_command_specs().iter().find(|spec| { spec.name.eq_ignore_ascii_case(name) || spec .aliases .iter() .any(|alias| alias.eq_ignore_ascii_case(name)) }) } fn command_root_name(command: &str) -> &str { command.split_whitespace().next().unwrap_or(command) } fn slash_command_usage(spec: &SlashCommandSpec) -> String { match spec.argument_hint { Some(argument_hint) => format!("/{} {argument_hint}", spec.name), None => format!("/{}", spec.name), } } fn slash_command_detail_lines(spec: &SlashCommandSpec) -> Vec { let mut lines = vec![format!("/{}", spec.name)]; lines.push(format!(" Summary {}", spec.summary)); lines.push(format!(" Usage {}", slash_command_usage(spec))); lines.push(format!( " Category {}", slash_command_category(spec.name) )); if !spec.aliases.is_empty() { lines.push(format!( " Aliases {}", spec.aliases .iter() .map(|alias| format!("/{alias}")) .collect::>() .join(", ") )); } if spec.resume_supported { lines.push(" Resume Supported with --resume SESSION.jsonl".to_string()); } lines } #[must_use] pub fn render_slash_command_help_detail(name: &str) -> Option { find_slash_command_spec(name).map(|spec| slash_command_detail_lines(spec).join("\n")) } #[must_use] pub fn slash_command_specs() -> &'static [SlashCommandSpec] { SLASH_COMMAND_SPECS } #[must_use] pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> { slash_command_specs() .iter() .filter(|spec| spec.resume_supported) .collect() } fn slash_command_category(name: &str) -> &'static str { match name { "help" | "status" | "sandbox" | "model" | "permissions" | "cost" | "resume" | "session" | "version" => "Session & visibility", "compact" | "clear" | "config" | "memory" | "init" | "diff" | "commit" | "pr" | "issue" | "export" | "plugin" => "Workspace & git", "agents" | "skills" | "teleport" | "debug-tool-call" => "Discovery & debugging", "bughunter" | "ultraplan" => "Analysis & automation", _ => "Other", } } fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String { let name = slash_command_usage(spec); 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 { "" }; format!(" {name:<66} {}{alias_suffix}{resume}", spec.summary) } fn levenshtein_distance(left: &str, right: &str) -> usize { if left == right { return 0; } if left.is_empty() { return right.chars().count(); } if right.is_empty() { return left.chars().count(); } let right_chars = right.chars().collect::>(); let mut previous = (0..=right_chars.len()).collect::>(); let mut current = vec![0; right_chars.len() + 1]; for (left_index, left_char) in left.chars().enumerate() { current[0] = left_index + 1; for (right_index, right_char) in right_chars.iter().enumerate() { let substitution_cost = usize::from(left_char != *right_char); current[right_index + 1] = (current[right_index] + 1) .min(previous[right_index + 1] + 1) .min(previous[right_index] + substitution_cost); } previous.clone_from(¤t); } previous[right_chars.len()] } #[must_use] pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec { let query = input.trim().trim_start_matches('/').to_ascii_lowercase(); if query.is_empty() || limit == 0 { return Vec::new(); } let mut suggestions = slash_command_specs() .iter() .filter_map(|spec| { let best = std::iter::once(spec.name) .chain(spec.aliases.iter().copied()) .map(str::to_ascii_lowercase) .map(|candidate| { let prefix_rank = if candidate.starts_with(&query) || query.starts_with(&candidate) { 0 } else if candidate.contains(&query) || query.contains(&candidate) { 1 } else { 2 }; let distance = levenshtein_distance(&candidate, &query); (prefix_rank, distance) }) .min(); best.and_then(|(prefix_rank, distance)| { if prefix_rank <= 1 || distance <= 2 { Some((prefix_rank, distance, spec.name.len(), spec.name)) } else { None } }) }) .collect::>(); suggestions.sort_unstable(); suggestions .into_iter() .map(|(_, _, _, name)| format!("/{name}")) .take(limit) .collect() } #[must_use] pub fn render_slash_command_help() -> String { let mut lines = vec![ "Slash commands".to_string(), " Start here /status, /diff, /agents, /skills, /commit".to_string(), " [resume] also works with --resume SESSION.jsonl".to_string(), String::new(), ]; let categories = [ "Session & visibility", "Workspace & git", "Discovery & debugging", "Analysis & automation", ]; for category in categories { lines.push(category.to_string()); for spec in slash_command_specs() .iter() .filter(|spec| slash_command_category(spec.name) == category) { lines.push(format_slash_command_help_line(spec)); } lines.push(String::new()); } lines .into_iter() .rev() .skip_while(String::is_empty) .collect::>() .into_iter() .rev() .collect::>() .join("\n") } #[derive(Debug, Clone, PartialEq, Eq)] pub struct SlashCommandResult { pub message: String, pub session: Session, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginsCommandResult { pub message: String, 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, 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)] pub fn handle_plugins_slash_command( action: Option<&str>, target: Option<&str>, manager: &mut PluginManager, ) -> Result { match action { None | Some("list") => Ok(PluginsCommandResult { message: render_plugins_report(&manager.list_installed_plugins()?), reload_runtime: false, }), Some("install") => { let Some(target) = target else { return Ok(PluginsCommandResult { message: "Usage: /plugins install ".to_string(), reload_runtime: false, }); }; let install = manager.install(target)?; let plugin = manager .list_installed_plugins()? .into_iter() .find(|plugin| plugin.metadata.id == install.plugin_id); Ok(PluginsCommandResult { message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()), reload_runtime: true, }) } Some("enable") => { let Some(target) = target else { return Ok(PluginsCommandResult { message: "Usage: /plugins enable ".to_string(), reload_runtime: false, }); }; let plugin = resolve_plugin_target(manager, target)?; manager.enable(&plugin.metadata.id)?; Ok(PluginsCommandResult { message: format!( "Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled", plugin.metadata.id, plugin.metadata.name, plugin.metadata.version ), reload_runtime: true, }) } Some("disable") => { let Some(target) = target else { return Ok(PluginsCommandResult { message: "Usage: /plugins disable ".to_string(), reload_runtime: false, }); }; let plugin = resolve_plugin_target(manager, target)?; manager.disable(&plugin.metadata.id)?; Ok(PluginsCommandResult { message: format!( "Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled", plugin.metadata.id, plugin.metadata.name, plugin.metadata.version ), reload_runtime: true, }) } Some("uninstall") => { let Some(target) = target else { return Ok(PluginsCommandResult { message: "Usage: /plugins uninstall ".to_string(), reload_runtime: false, }); }; manager.uninstall(target)?; Ok(PluginsCommandResult { message: format!("Plugins\n Result uninstalled {target}"), reload_runtime: true, }) } Some("update") => { let Some(target) = target else { return Ok(PluginsCommandResult { message: "Usage: /plugins update ".to_string(), reload_runtime: false, }); }; let update = manager.update(target)?; let plugin = manager .list_installed_plugins()? .into_iter() .find(|plugin| plugin.metadata.id == update.plugin_id); Ok(PluginsCommandResult { message: format!( "Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}", update.plugin_id, plugin .as_ref() .map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()), update.old_version, update.new_version, plugin .as_ref() .map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }), ), reload_runtime: true, }) } Some(other) => Ok(PluginsCommandResult { message: format!( "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update." ), reload_runtime: false, }), } } pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result { 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))), } } 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 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))), } } #[must_use] pub fn render_plugins_report(plugins: &[PluginSummary]) -> String { let mut lines = vec!["Plugins".to_string()]; if plugins.is_empty() { lines.push(" No plugins installed.".to_string()); return lines.join("\n"); } for plugin in plugins { let enabled = if plugin.enabled { "enabled" } else { "disabled" }; lines.push(format!( " {name:<20} v{version:<10} {enabled}", name = plugin.metadata.name, version = plugin.metadata.version, )); } lines.join("\n") } fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String { let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str()); let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str()); let enabled = plugin.is_some_and(|plugin| plugin.enabled); format!( "Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}", if enabled { "enabled" } else { "disabled" } ) } fn resolve_plugin_target( manager: &PluginManager, target: &str, ) -> Result { let mut matches = manager .list_installed_plugins()? .into_iter() .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target) .collect::>(); match matches.len() { 1 => Ok(matches.remove(0)), 0 => Err(PluginError::NotFound(format!( "plugin `{target}` is not installed or discoverable" ))), _ => Err(PluginError::InvalidManifest(format!( "plugin name `{target}` is ambiguous; use the full plugin id" ))), } } 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 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, path: PathBuf, ) { if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) { roots.push((source, path)); } } 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> { 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_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"), 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: &[SkillRoot]) -> std::io::Result> { let mut skills = Vec::new(); let mut active_sources = BTreeMap::::new(); for root in roots { let mut root_skills = Vec::new(); for entry in fs::read_dir(&root.path)? { let entry = entry?; match root.origin { SkillOrigin::SkillsDir => { if !entry.path().is_dir() { continue; } let skill_path = entry.path().join("SKILL.md"); if !skill_path.is_file() { continue; } let contents = fs::read_to_string(skill_path)?; let (name, 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, }); } } } 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 = unquote_frontmatter_value(value.trim()); if !value.is_empty() { name = Some(value); } continue; } if let Some(value) = trimmed.strip_prefix("description:") { let value = unquote_frontmatter_value(value.trim()); if !value.is_empty() { description = Some(value); } } } (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(); } 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 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}")), } } lines.push(String::new()); } 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 [list|help]".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 [list|help]".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, session: &Session, compaction: CompactionConfig, ) -> Option { let command = match SlashCommand::parse(input) { Ok(Some(command)) => command, Ok(None) => return None, Err(error) => { return Some(SlashCommandResult { message: error.to_string(), session: session.clone(), }); } }; match command { SlashCommand::Compact => { let result = compact_session(session, compaction); let message = if result.removed_message_count == 0 { "Compaction skipped: session is below the compaction threshold.".to_string() } else { format!( "Compacted {} messages into a resumable system summary.", result.removed_message_count ) }; Some(SlashCommandResult { message, session: result.compacted_session, }) } SlashCommand::Help => Some(SlashCommandResult { message: render_slash_command_help(), session: session.clone(), }), SlashCommand::Status | SlashCommand::Bughunter { .. } | SlashCommand::Commit | SlashCommand::Pr { .. } | SlashCommand::Issue { .. } | SlashCommand::Ultraplan { .. } | SlashCommand::Teleport { .. } | SlashCommand::DebugToolCall | SlashCommand::Sandbox | SlashCommand::Model { .. } | SlashCommand::Permissions { .. } | SlashCommand::Clear { .. } | SlashCommand::Cost | SlashCommand::Resume { .. } | SlashCommand::Config { .. } | SlashCommand::Memory | SlashCommand::Init | SlashCommand::Diff | SlashCommand::Version | SlashCommand::Export { .. } | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } | SlashCommand::Agents { .. } | SlashCommand::Skills { .. } | SlashCommand::Unknown(_) => None, } } #[cfg(test)] mod tests { use super::{ 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, render_slash_command_help_detail, resume_supported_slash_commands, slash_command_specs, suggest_slash_commands, validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; fn temp_dir(label: &str) -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("time should be after epoch") .as_nanos(); std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}")) } fn write_external_plugin(root: &Path, name: &str, version: &str) { fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); fs::write( root.join(".claude-plugin").join("plugin.json"), format!( "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}" ), ) .expect("write manifest"); } fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) { fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir"); fs::write( root.join(".claude-plugin").join("plugin.json"), format!( "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}", if default_enabled { "true" } else { "false" } ), ) .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"); } 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"); } fn parse_error_message(input: &str) -> String { SlashCommand::parse(input) .expect_err("slash command should be rejected") .to_string() } #[allow(clippy::too_many_lines)] #[test] fn parses_supported_slash_commands() { assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help))); assert_eq!( SlashCommand::parse(" /status "), Ok(Some(SlashCommand::Status)) ); assert_eq!( SlashCommand::parse("/sandbox"), Ok(Some(SlashCommand::Sandbox)) ); assert_eq!( SlashCommand::parse("/bughunter runtime"), Ok(Some(SlashCommand::Bughunter { scope: Some("runtime".to_string()) })) ); assert_eq!( SlashCommand::parse("/commit"), Ok(Some(SlashCommand::Commit)) ); assert_eq!( SlashCommand::parse("/pr ready for review"), Ok(Some(SlashCommand::Pr { context: Some("ready for review".to_string()) })) ); assert_eq!( SlashCommand::parse("/issue flaky test"), Ok(Some(SlashCommand::Issue { context: Some("flaky test".to_string()) })) ); assert_eq!( SlashCommand::parse("/ultraplan ship both features"), Ok(Some(SlashCommand::Ultraplan { task: Some("ship both features".to_string()) })) ); assert_eq!( SlashCommand::parse("/teleport conversation.rs"), Ok(Some(SlashCommand::Teleport { target: Some("conversation.rs".to_string()) })) ); assert_eq!( SlashCommand::parse("/debug-tool-call"), Ok(Some(SlashCommand::DebugToolCall)) ); assert_eq!( SlashCommand::parse("/bughunter runtime"), Ok(Some(SlashCommand::Bughunter { scope: Some("runtime".to_string()) })) ); assert_eq!( SlashCommand::parse("/commit"), Ok(Some(SlashCommand::Commit)) ); assert_eq!( SlashCommand::parse("/pr ready for review"), Ok(Some(SlashCommand::Pr { context: Some("ready for review".to_string()) })) ); assert_eq!( SlashCommand::parse("/issue flaky test"), Ok(Some(SlashCommand::Issue { context: Some("flaky test".to_string()) })) ); assert_eq!( SlashCommand::parse("/ultraplan ship both features"), Ok(Some(SlashCommand::Ultraplan { task: Some("ship both features".to_string()) })) ); assert_eq!( SlashCommand::parse("/teleport conversation.rs"), Ok(Some(SlashCommand::Teleport { target: Some("conversation.rs".to_string()) })) ); assert_eq!( SlashCommand::parse("/debug-tool-call"), Ok(Some(SlashCommand::DebugToolCall)) ); assert_eq!( SlashCommand::parse("/model claude-opus"), Ok(Some(SlashCommand::Model { model: Some("claude-opus".to_string()), })) ); assert_eq!( SlashCommand::parse("/model"), Ok(Some(SlashCommand::Model { model: None })) ); assert_eq!( SlashCommand::parse("/permissions read-only"), Ok(Some(SlashCommand::Permissions { mode: Some("read-only".to_string()), })) ); assert_eq!( SlashCommand::parse("/clear"), Ok(Some(SlashCommand::Clear { confirm: false })) ); assert_eq!( SlashCommand::parse("/clear --confirm"), Ok(Some(SlashCommand::Clear { confirm: true })) ); assert_eq!(SlashCommand::parse("/cost"), Ok(Some(SlashCommand::Cost))); assert_eq!( SlashCommand::parse("/resume session.json"), Ok(Some(SlashCommand::Resume { session_path: Some("session.json".to_string()), })) ); assert_eq!( SlashCommand::parse("/config"), Ok(Some(SlashCommand::Config { section: None })) ); assert_eq!( SlashCommand::parse("/config env"), Ok(Some(SlashCommand::Config { section: Some("env".to_string()) })) ); assert_eq!( SlashCommand::parse("/memory"), Ok(Some(SlashCommand::Memory)) ); assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init))); assert_eq!(SlashCommand::parse("/diff"), Ok(Some(SlashCommand::Diff))); assert_eq!( SlashCommand::parse("/version"), Ok(Some(SlashCommand::Version)) ); assert_eq!( SlashCommand::parse("/export notes.txt"), Ok(Some(SlashCommand::Export { path: Some("notes.txt".to_string()) })) ); assert_eq!( SlashCommand::parse("/session switch abc123"), Ok(Some(SlashCommand::Session { action: Some("switch".to_string()), target: Some("abc123".to_string()) })) ); assert_eq!( SlashCommand::parse("/plugins install demo"), Ok(Some(SlashCommand::Plugins { action: Some("install".to_string()), target: Some("demo".to_string()) })) ); assert_eq!( SlashCommand::parse("/plugins list"), Ok(Some(SlashCommand::Plugins { action: Some("list".to_string()), target: None })) ); assert_eq!( SlashCommand::parse("/plugins enable demo"), Ok(Some(SlashCommand::Plugins { action: Some("enable".to_string()), target: Some("demo".to_string()) })) ); assert_eq!( SlashCommand::parse("/plugins disable demo"), Ok(Some(SlashCommand::Plugins { action: Some("disable".to_string()), target: Some("demo".to_string()) })) ); assert_eq!( SlashCommand::parse("/session fork incident-review"), Ok(Some(SlashCommand::Session { action: Some("fork".to_string()), target: Some("incident-review".to_string()) })) ); } #[test] fn rejects_unexpected_arguments_for_no_arg_commands() { // given let input = "/compact now"; // when let error = parse_error_message(input); // then assert!(error.contains("Unexpected arguments for /compact.")); assert!(error.contains(" Usage /compact")); assert!(error.contains(" Summary Compact local session history")); } #[test] fn rejects_invalid_argument_values() { // given let input = "/permissions admin"; // when let error = parse_error_message(input); // then assert!(error.contains( "Unsupported /permissions mode 'admin'. Use read-only, workspace-write, or danger-full-access." )); assert!(error.contains( " Usage /permissions [read-only|workspace-write|danger-full-access]" )); } #[test] fn rejects_missing_required_arguments() { // given let input = "/teleport"; // when let error = parse_error_message(input); // then assert!(error.contains("Usage: /teleport ")); assert!(error.contains(" Category Discovery & debugging")); } #[test] fn rejects_invalid_session_and_plugin_shapes() { // given let session_input = "/session switch"; let plugin_input = "/plugins list extra"; // when let session_error = parse_error_message(session_input); let plugin_error = parse_error_message(plugin_input); // then assert!(session_error.contains("Usage: /session switch ")); assert!(session_error.contains("/session")); assert!(plugin_error.contains("Usage: /plugin list")); assert!(plugin_error.contains("Aliases /plugins, /marketplace")); } #[test] fn rejects_invalid_agents_and_skills_arguments() { // given let agents_input = "/agents show planner"; let skills_input = "/skills show help"; // when let agents_error = parse_error_message(agents_input); let skills_error = parse_error_message(skills_input); // then assert!(agents_error.contains( "Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help." )); assert!(agents_error.contains(" Usage /agents [list|help]")); assert!(skills_error.contains( "Unexpected arguments for /skills: show help. Use /skills, /skills list, or /skills help." )); assert!(skills_error.contains(" Usage /skills [list|help]")); } #[test] fn renders_help_from_shared_specs() { let help = render_slash_command_help(); assert!(help.contains("Start here /status, /diff, /agents, /skills, /commit")); assert!(help.contains("[resume] also works with --resume SESSION.jsonl")); assert!(help.contains("Session & visibility")); assert!(help.contains("Workspace & git")); assert!(help.contains("Discovery & debugging")); assert!(help.contains("Analysis & automation")); assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/sandbox")); assert!(help.contains("/compact")); assert!(help.contains("/bughunter [scope]")); assert!(help.contains("/commit")); assert!(help.contains("/pr [context]")); assert!(help.contains("/issue [context]")); assert!(help.contains("/ultraplan [task]")); assert!(help.contains("/teleport ")); assert!(help.contains("/debug-tool-call")); assert!(help.contains("/model [model]")); assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); assert!(help.contains("/clear [--confirm]")); assert!(help.contains("/cost")); assert!(help.contains("/resume ")); assert!(help.contains("/config [env|hooks|model|plugins]")); assert!(help.contains("/memory")); assert!(help.contains("/init")); assert!(help.contains("/diff")); assert!(help.contains("/version")); assert!(help.contains("/export [file]")); assert!(help.contains("/session [list|switch |fork [branch-name]]")); assert!(help.contains("/sandbox")); assert!(help.contains( "/plugin [list|install |enable |disable |uninstall |update ]" )); assert!(help.contains("aliases: /plugins, /marketplace")); assert!(help.contains("/agents [list|help]")); assert!(help.contains("/skills [list|help]")); assert_eq!(slash_command_specs().len(), 26); assert_eq!(resume_supported_slash_commands().len(), 14); } #[test] fn renders_per_command_help_detail() { // given let command = "plugins"; // when let help = render_slash_command_help_detail(command).expect("detail help should exist"); // then assert!(help.contains("/plugin")); assert!(help.contains("Summary Manage Claw Code plugins")); assert!(help.contains("Aliases /plugins, /marketplace")); assert!(help.contains("Category Workspace & git")); } #[test] fn validate_slash_command_input_rejects_extra_single_value_arguments() { // given let session_input = "/session switch current next"; let plugin_input = "/plugin enable demo extra"; // when let session_error = validate_slash_command_input(session_input) .expect_err("session input should be rejected") .to_string(); let plugin_error = validate_slash_command_input(plugin_input) .expect_err("plugin input should be rejected") .to_string(); // then assert!(session_error.contains("Unexpected arguments for /session switch.")); assert!(session_error.contains(" Usage /session switch ")); assert!(plugin_error.contains("Unexpected arguments for /plugin enable.")); assert!(plugin_error.contains(" Usage /plugin enable ")); } #[test] fn suggests_closest_slash_commands_for_typos_and_aliases() { assert_eq!(suggest_slash_commands("stats", 3), vec!["/status"]); assert_eq!(suggest_slash_commands("/plugns", 3), vec!["/plugin"]); assert_eq!(suggest_slash_commands("zzz", 3), Vec::::new()); } #[test] fn compacts_sessions_via_slash_command() { let mut session = Session::new(); session.messages = vec![ ConversationMessage::user_text("a ".repeat(200)), ConversationMessage::assistant(vec![ContentBlock::Text { text: "b ".repeat(200), }]), ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false), ConversationMessage::assistant(vec![ContentBlock::Text { text: "recent".to_string(), }]), ]; let result = handle_slash_command( "/compact", &session, CompactionConfig { preserve_recent_messages: 2, max_estimated_tokens: 1, }, ) .expect("slash command should be handled"); assert!(result.message.contains("Compacted 2 messages")); assert_eq!(result.session.messages[0].role, MessageRole::System); } #[test] fn help_command_is_non_mutating() { let session = Session::new(); let result = handle_slash_command("/help", &session, CompactionConfig::default()) .expect("help command should be handled"); assert_eq!(result.session, session); assert!(result.message.contains("Slash commands")); } #[test] fn ignores_unknown_or_runtime_bound_slash_commands() { let session = Session::new(); assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none()); assert!( handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none() ); assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none()); assert!( handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none() ); assert!( handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none() ); assert!( handle_slash_command("/debug-tool-call", &session, CompactionConfig::default()) .is_none() ); assert!( handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none() ); assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none()); assert!( handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none() ); assert!( handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none() ); assert!( handle_slash_command("/debug-tool-call", &session, CompactionConfig::default()) .is_none() ); assert!( handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none() ); assert!(handle_slash_command( "/permissions read-only", &session, CompactionConfig::default() ) .is_none()); assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none()); assert!( handle_slash_command("/clear --confirm", &session, CompactionConfig::default()) .is_none() ); assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command( "/resume session.json", &session, CompactionConfig::default() ) .is_none()); assert!(handle_slash_command( "/resume session.jsonl", &session, CompactionConfig::default() ) .is_none()); assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none()); assert!( handle_slash_command("/config env", &session, CompactionConfig::default()).is_none() ); assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none()); assert!( handle_slash_command("/export note.txt", &session, CompactionConfig::default()) .is_none() ); assert!( handle_slash_command("/session list", &session, CompactionConfig::default()).is_none() ); assert!( handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none() ); } #[test] fn renders_plugins_report_with_name_version_and_status() { let rendered = render_plugins_report(&[ PluginSummary { metadata: PluginMetadata { id: "demo@external".to_string(), name: "demo".to_string(), version: "1.2.3".to_string(), description: "demo plugin".to_string(), kind: PluginKind::External, source: "demo".to_string(), default_enabled: false, root: None, }, enabled: true, }, PluginSummary { metadata: PluginMetadata { id: "sample@external".to_string(), name: "sample".to_string(), version: "0.9.0".to_string(), description: "sample plugin".to_string(), kind: PluginKind::External, source: "sample".to_string(), default_enabled: false, root: None, }, enabled: false, }, ]); assert!(rendered.contains("demo")); assert!(rendered.contains("v1.2.3")); assert!(rendered.contains("enabled")); assert!(rendered.contains("sample")); assert!(rendered.contains("v0.9.0")); assert!(rendered.contains("disabled")); } #[test] fn lists_agents_from_project_and_user_roots() { let workspace = temp_dir("agents-workspace"); let project_agents = workspace.join(".codex").join("agents"); let user_home = temp_dir("agents-home"); let user_agents = user_home.join(".codex").join("agents"); write_agent( &project_agents, "planner", "Project planner", "gpt-5.4", "medium", ); write_agent( &user_agents, "planner", "User planner", "gpt-5.4-mini", "high", ); write_agent( &user_agents, "verifier", "Verification agent", "gpt-5.4-mini", "high", ); let roots = vec![ (DefinitionSource::ProjectCodex, project_agents), (DefinitionSource::UserCodex, user_agents), ]; 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")); assert!(report.contains("Project (.codex):")); assert!(report.contains("planner · Project planner · gpt-5.4 · medium")); assert!(report.contains("User (~/.codex):")); assert!(report.contains("(shadowed by Project (.codex)) planner · User planner")); assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high")); let _ = fs::remove_dir_all(workspace); let _ = fs::remove_dir_all(user_home); } #[test] 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![ 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("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")); let _ = fs::remove_dir_all(workspace); 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 [list|help]")); 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 [list|help]")); 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"); let source_root = temp_dir("source"); write_external_plugin(&source_root, "demo", "1.0.0"); let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); let install = handle_plugins_slash_command( Some("install"), Some(source_root.to_str().expect("utf8 path")), &mut manager, ) .expect("install command should succeed"); assert!(install.reload_runtime); assert!(install.message.contains("installed demo@external")); assert!(install.message.contains("Name demo")); assert!(install.message.contains("Version 1.0.0")); assert!(install.message.contains("Status enabled")); let list = handle_plugins_slash_command(Some("list"), None, &mut manager) .expect("list command should succeed"); assert!(!list.reload_runtime); assert!(list.message.contains("demo")); assert!(list.message.contains("v1.0.0")); assert!(list.message.contains("enabled")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(source_root); } #[test] fn enables_and_disables_plugin_by_name() { let config_home = temp_dir("toggle-home"); let source_root = temp_dir("toggle-source"); write_external_plugin(&source_root, "demo", "1.0.0"); let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home)); handle_plugins_slash_command( Some("install"), Some(source_root.to_str().expect("utf8 path")), &mut manager, ) .expect("install command should succeed"); let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager) .expect("disable command should succeed"); assert!(disable.reload_runtime); assert!(disable.message.contains("disabled demo@external")); assert!(disable.message.contains("Name demo")); assert!(disable.message.contains("Status disabled")); let list = handle_plugins_slash_command(Some("list"), None, &mut manager) .expect("list command should succeed"); assert!(list.message.contains("demo")); assert!(list.message.contains("disabled")); let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager) .expect("enable command should succeed"); assert!(enable.reload_runtime); assert!(enable.message.contains("enabled demo@external")); assert!(enable.message.contains("Name demo")); assert!(enable.message.contains("Status enabled")); let list = handle_plugins_slash_command(Some("list"), None, &mut manager) .expect("list command should succeed"); assert!(list.message.contains("demo")); assert!(list.message.contains("enabled")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(source_root); } #[test] fn lists_auto_installed_bundled_plugins_with_status() { let config_home = temp_dir("bundled-home"); let bundled_root = temp_dir("bundled-root"); let bundled_plugin = bundled_root.join("starter"); write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false); let mut config = PluginManagerConfig::new(&config_home); config.bundled_root = Some(bundled_root.clone()); let mut manager = PluginManager::new(config); let list = handle_plugins_slash_command(Some("list"), None, &mut manager) .expect("list command should succeed"); assert!(!list.reload_runtime); assert!(list.message.contains("starter")); assert!(list.message.contains("v0.1.0")); assert!(list.message.contains("disabled")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); } }