use std::collections::BTreeMap; use std::env; 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: "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: "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: None, resume_supported: true, }, SlashCommandSpec { name: "skills", aliases: &[], summary: "List available skills", argument_hint: None, resume_supported: true, }, ]; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SlashCommand { Help, Status, 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), } impl SlashCommand { #[must_use] pub fn parse(input: &str) -> Option { let trimmed = input.trim(); if !trimmed.starts_with('/') { return None; } let mut parts = trimmed.trim_start_matches('/').split_whitespace(); let command = parts.next().unwrap_or_default(); Some(match command { "help" => Self::Help, "status" => Self::Status, "compact" => Self::Compact, "bughunter" => Self::Bughunter { scope: remainder_after_command(trimmed, command), }, "commit" => Self::Commit, "pr" => Self::Pr { context: remainder_after_command(trimmed, command), }, "issue" => Self::Issue { context: remainder_after_command(trimmed, command), }, "ultraplan" => Self::Ultraplan { task: remainder_after_command(trimmed, command), }, "teleport" => Self::Teleport { target: remainder_after_command(trimmed, command), }, "debug-tool-call" => Self::DebugToolCall, "model" => Self::Model { model: parts.next().map(ToOwned::to_owned), }, "permissions" => Self::Permissions { mode: parts.next().map(ToOwned::to_owned), }, "clear" => Self::Clear { confirm: parts.next() == Some("--confirm"), }, "cost" => Self::Cost, "resume" => Self::Resume { session_path: parts.next().map(ToOwned::to_owned), }, "config" => Self::Config { section: parts.next().map(ToOwned::to_owned), }, "memory" => Self::Memory, "init" => Self::Init, "diff" => Self::Diff, "version" => Self::Version, "export" => Self::Export { path: parts.next().map(ToOwned::to_owned), }, "session" => Self::Session { action: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned), }, "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()), }) } } 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) } #[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() } #[must_use] pub fn render_slash_command_help() -> String { let mut lines = vec![ "Slash commands".to_string(), " [resume] means the command also works with --resume SESSION.json".to_string(), ]; for spec in slash_command_specs() { let name = match spec.argument_hint { 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} {}{alias_suffix}{resume}", spec.summary )); } lines.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".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, session: &Session, compaction: CompactionConfig, ) -> Option { match SlashCommand::parse(input)? { 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::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, resume_supported_slash_commands, slash_command_specs, 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"); } #[allow(clippy::too_many_lines)] #[test] fn parses_supported_slash_commands() { assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help)); assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status)); assert_eq!( SlashCommand::parse("/bughunter runtime"), Some(SlashCommand::Bughunter { scope: Some("runtime".to_string()) }) ); assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit)); assert_eq!( SlashCommand::parse("/pr ready for review"), Some(SlashCommand::Pr { context: Some("ready for review".to_string()) }) ); assert_eq!( SlashCommand::parse("/issue flaky test"), Some(SlashCommand::Issue { context: Some("flaky test".to_string()) }) ); assert_eq!( SlashCommand::parse("/ultraplan ship both features"), Some(SlashCommand::Ultraplan { task: Some("ship both features".to_string()) }) ); assert_eq!( SlashCommand::parse("/teleport conversation.rs"), Some(SlashCommand::Teleport { target: Some("conversation.rs".to_string()) }) ); assert_eq!( SlashCommand::parse("/debug-tool-call"), Some(SlashCommand::DebugToolCall) ); assert_eq!( SlashCommand::parse("/model claude-opus"), Some(SlashCommand::Model { model: Some("claude-opus".to_string()), }) ); assert_eq!( SlashCommand::parse("/model"), Some(SlashCommand::Model { model: None }) ); assert_eq!( SlashCommand::parse("/permissions read-only"), Some(SlashCommand::Permissions { mode: Some("read-only".to_string()), }) ); assert_eq!( SlashCommand::parse("/clear"), Some(SlashCommand::Clear { confirm: false }) ); assert_eq!( SlashCommand::parse("/clear --confirm"), Some(SlashCommand::Clear { confirm: true }) ); assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost)); assert_eq!( SlashCommand::parse("/resume session.json"), Some(SlashCommand::Resume { session_path: Some("session.json".to_string()), }) ); assert_eq!( SlashCommand::parse("/config"), Some(SlashCommand::Config { section: None }) ); assert_eq!( SlashCommand::parse("/config env"), Some(SlashCommand::Config { section: Some("env".to_string()) }) ); assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory)); assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init)); assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff)); assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version)); assert_eq!( SlashCommand::parse("/export notes.txt"), Some(SlashCommand::Export { path: Some("notes.txt".to_string()) }) ); assert_eq!( SlashCommand::parse("/session switch abc123"), Some(SlashCommand::Session { action: Some("switch".to_string()), target: Some("abc123".to_string()) }) ); assert_eq!( SlashCommand::parse("/plugins install demo"), Some(SlashCommand::Plugins { action: Some("install".to_string()), target: Some("demo".to_string()) }) ); assert_eq!( SlashCommand::parse("/plugins list"), Some(SlashCommand::Plugins { action: Some("list".to_string()), target: None }) ); assert_eq!( SlashCommand::parse("/plugins enable demo"), Some(SlashCommand::Plugins { action: Some("enable".to_string()), target: Some("demo".to_string()) }) ); assert_eq!( SlashCommand::parse("/plugins disable demo"), Some(SlashCommand::Plugins { action: Some("disable".to_string()), target: Some("demo".to_string()) }) ); } #[test] fn renders_help_from_shared_specs() { let help = render_slash_command_help(); assert!(help.contains("works with --resume SESSION.json")); assert!(help.contains("/help")); assert!(help.contains("/status")); 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 ]")); assert!(help.contains( "/plugin [list|install |enable |disable |uninstall |update ]" )); 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(), 13); } #[test] fn compacts_sessions_via_slash_command() { let session = Session { version: 1, 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("/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("/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")); 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"); 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); } }