From 76ad0a8ee9e5e6303da229671382e606721f26da Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Wed, 1 Apr 2026 20:36:06 +0900 Subject: [PATCH] feat: slash commands, skills discovery, and config inspection --- rust/crates/commands/Cargo.toml | 14 + rust/crates/commands/src/lib.rs | 1808 +++++++++++++++++++++++++++++++ 2 files changed, 1822 insertions(+) create mode 100644 rust/crates/commands/Cargo.toml create mode 100644 rust/crates/commands/src/lib.rs diff --git a/rust/crates/commands/Cargo.toml b/rust/crates/commands/Cargo.toml new file mode 100644 index 0000000..2263f7a --- /dev/null +++ b/rust/crates/commands/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "commands" +version.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true + +[lints] +workspace = true + +[dependencies] +plugins = { path = "../plugins" } +runtime = { path = "../runtime" } +serde_json.workspace = true diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs new file mode 100644 index 0000000..6e01f71 --- /dev/null +++ b/rust/crates/commands/src/lib.rs @@ -0,0 +1,1808 @@ +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 Claw config files or merged sections", + argument_hint: Some("[env|hooks|model|plugins]"), + resume_supported: true, + }, + SlashCommandSpec { + name: "memory", + aliases: &[], + summary: "Inspect loaded Claw instruction memory files", + argument_hint: None, + resume_supported: true, + }, + SlashCommandSpec { + name: "init", + aliases: &[], + summary: "Create a starter CLAW.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, + ProjectClaw, + UserCodexHome, + UserCodex, + UserClaw, +} + +impl DefinitionSource { + fn label(self) -> &'static str { + match self { + Self::ProjectCodex => "Project (.codex)", + Self::ProjectClaw => "Project (.claw)", + Self::UserCodexHome => "User ($CODEX_HOME)", + Self::UserCodex => "User (~/.codex)", + Self::UserClaw => "User (~/.claw)", + } + } +} + +#[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::ProjectClaw, + ancestor.join(".claw").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::UserClaw, + home.join(".claw").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::ProjectClaw, + ancestor.join(".claw").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::ProjectClaw, + ancestor.join(".claw").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::UserClaw, + home.join(".claw").join("skills"), + SkillOrigin::SkillsDir, + ); + push_unique_skill_root( + &mut roots, + DefinitionSource::UserClaw, + home.join(".claw").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::ProjectClaw, + DefinitionSource::UserCodexHome, + DefinitionSource::UserCodex, + DefinitionSource::UserClaw, + ] { + 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::ProjectClaw, + DefinitionSource::UserCodexHome, + DefinitionSource::UserCodex, + DefinitionSource::UserClaw, + ] { + 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, .claw/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, .claw/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(".claw-plugin")).expect("manifest dir"); + fs::write( + root.join(".claw-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(".claw-plugin")).expect("manifest dir"); + fs::write( + root.join(".claw-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 opus"), + Some(SlashCommand::Model { + model: Some("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 sonnet", &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(".claw").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::ProjectClaw, + 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 (.claw):")); + 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); + } +}