use plugins::{PluginError, PluginKind, 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 summary: &'static str, pub argument_hint: Option<&'static str>, pub resume_supported: bool, } const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ SlashCommandSpec { name: "help", summary: "Show available slash commands", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "status", summary: "Show current session status", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "compact", summary: "Compact local session history", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "model", summary: "Show or switch the active model", argument_hint: Some("[model]"), resume_supported: false, }, SlashCommandSpec { name: "permissions", summary: "Show or switch the active permission mode", argument_hint: Some("[read-only|workspace-write|danger-full-access]"), resume_supported: false, }, SlashCommandSpec { name: "clear", summary: "Start a fresh local session", argument_hint: Some("[--confirm]"), resume_supported: true, }, SlashCommandSpec { name: "cost", summary: "Show cumulative token usage for this session", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "resume", summary: "Load a saved session into the REPL", argument_hint: Some(""), resume_supported: false, }, SlashCommandSpec { name: "config", summary: "Inspect Claude config files or merged sections", argument_hint: Some("[env|hooks|model|plugins]"), resume_supported: true, }, SlashCommandSpec { name: "memory", summary: "Inspect loaded Claude instruction memory files", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "init", summary: "Create a starter CLAUDE.md for this repo", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "diff", summary: "Show git diff for current workspace changes", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "version", summary: "Show CLI version and build information", argument_hint: None, resume_supported: true, }, SlashCommandSpec { name: "export", summary: "Export the current conversation to a file", argument_hint: Some("[file]"), resume_supported: true, }, SlashCommandSpec { name: "session", summary: "List or switch managed local sessions", argument_hint: Some("[list|switch ]"), resume_supported: false, }, SlashCommandSpec { name: "plugins", summary: "List or manage plugins", argument_hint: Some( "[list|install |enable |disable |uninstall |update ]", ), resume_supported: false, }, ]; #[derive(Debug, Clone, PartialEq, Eq)] pub enum SlashCommand { Help, Status, Compact, 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, }, 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, "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), }, "plugins" => Self::Plugins { action: parts.next().map(ToOwned::to_owned), target: { let remainder = parts.collect::>().join(" "); (!remainder.is_empty()).then_some(remainder) }, }, other => Self::Unknown(other.to_string()), }) } } #[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 resume = if spec.resume_supported { " [resume]" } else { "" }; lines.push(format!(" {name:<20} {}{}", spec.summary, resume)); } 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, } 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, }), } } #[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 kind = match plugin.metadata.kind { PluginKind::Builtin => "builtin", PluginKind::Bundled => "bundled", PluginKind::External => "external", }; let enabled = if plugin.enabled { "enabled" } else { "disabled" }; lines.push(format!( " {id:<24} {kind:<8} {enabled:<8} v{version}", id = plugin.metadata.id, 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" ))), } } #[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::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::Unknown(_) => None, } } #[cfg(test)] mod tests { use super::{ handle_plugins_slash_command, handle_slash_command, render_plugins_report, render_slash_command_help, resume_supported_slash_commands, slash_command_specs, 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"); } #[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("/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 }) ); } #[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("/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( "/plugins [list|install |enable |disable |uninstall |update ]" )); assert_eq!(slash_command_specs().len(), 16); assert_eq!(resume_supported_slash_commands().len(), 11); } #[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("/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@external")); assert!(rendered.contains("v1.2.3")); assert!(rendered.contains("enabled")); assert!(rendered.contains("sample@external")); assert!(rendered.contains("v0.9.0")); assert!(rendered.contains("disabled")); } #[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@external")); 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@external")); 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@external")); 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@bundled")); assert!(list.message.contains("bundled")); assert!(list.message.contains("disabled")); let _ = fs::remove_dir_all(config_home); let _ = fs::remove_dir_all(bundled_root); } }