use std::collections::BTreeMap; use std::env; use std::fs; use std::io; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{SystemTime, UNIX_EPOCH}; 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: "branch", aliases: &[], summary: "List, create, or switch git branches", argument_hint: Some("[list|create |switch ]"), resume_supported: false, }, SlashCommandSpec { name: "worktree", aliases: &[], summary: "List, add, remove, or prune git worktrees", argument_hint: Some("[list|add [branch]|remove |prune]"), 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: "commit-push-pr", aliases: &[], summary: "Commit workspace changes, push the branch, and open a PR", argument_hint: Some("[context]"), 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, Branch { action: Option, target: Option, }, Bughunter { scope: Option, }, Worktree { action: Option, path: Option, branch: Option, }, Commit, CommitPushPr { context: Option, }, 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, "branch" => Self::Branch { action: parts.next().map(ToOwned::to_owned), target: parts.next().map(ToOwned::to_owned), }, "bughunter" => Self::Bughunter { scope: remainder_after_command(trimmed, command), }, "worktree" => Self::Worktree { action: parts.next().map(ToOwned::to_owned), path: parts.next().map(ToOwned::to_owned), branch: parts.next().map(ToOwned::to_owned), }, "commit" => Self::Commit, "commit-push-pr" => Self::CommitPushPr { context: remainder_after_command(trimmed, command), }, "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))), } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CommitPushPrRequest { pub commit_message: Option, pub pr_title: String, pub pr_body: String, pub branch_name_hint: String, } pub fn handle_branch_slash_command( action: Option<&str>, target: Option<&str>, cwd: &Path, ) -> io::Result { match normalize_optional_args(action) { None | Some("list") => { let branches = git_stdout(cwd, &["branch", "--list", "--verbose"])?; let trimmed = branches.trim(); Ok(if trimmed.is_empty() { "Branch\n Result no branches found".to_string() } else { format!("Branch\n Result listed\n\n{}", trimmed) }) } Some("create") => { let Some(target) = target.filter(|value| !value.trim().is_empty()) else { return Ok("Usage: /branch create ".to_string()); }; git_status_ok(cwd, &["switch", "-c", target])?; Ok(format!( "Branch\n Result created and switched\n Branch {target}" )) } Some("switch") => { let Some(target) = target.filter(|value| !value.trim().is_empty()) else { return Ok("Usage: /branch switch ".to_string()); }; git_status_ok(cwd, &["switch", target])?; Ok(format!( "Branch\n Result switched\n Branch {target}" )) } Some(other) => Ok(format!( "Unknown /branch action '{other}'. Use /branch list, /branch create , or /branch switch ." )), } } pub fn handle_worktree_slash_command( action: Option<&str>, path: Option<&str>, branch: Option<&str>, cwd: &Path, ) -> io::Result { match normalize_optional_args(action) { None | Some("list") => { let worktrees = git_stdout(cwd, &["worktree", "list"])?; let trimmed = worktrees.trim(); Ok(if trimmed.is_empty() { "Worktree\n Result no worktrees found".to_string() } else { format!("Worktree\n Result listed\n\n{}", trimmed) }) } Some("add") => { let Some(path) = path.filter(|value| !value.trim().is_empty()) else { return Ok("Usage: /worktree add [branch]".to_string()); }; if let Some(branch) = branch.filter(|value| !value.trim().is_empty()) { if branch_exists(cwd, branch) { git_status_ok(cwd, &["worktree", "add", path, branch])?; } else { git_status_ok(cwd, &["worktree", "add", path, "-b", branch])?; } Ok(format!( "Worktree\n Result added\n Path {path}\n Branch {branch}" )) } else { git_status_ok(cwd, &["worktree", "add", path])?; Ok(format!( "Worktree\n Result added\n Path {path}" )) } } Some("remove") => { let Some(path) = path.filter(|value| !value.trim().is_empty()) else { return Ok("Usage: /worktree remove ".to_string()); }; git_status_ok(cwd, &["worktree", "remove", path])?; Ok(format!( "Worktree\n Result removed\n Path {path}" )) } Some("prune") => { git_status_ok(cwd, &["worktree", "prune"])?; Ok("Worktree\n Result pruned".to_string()) } Some(other) => Ok(format!( "Unknown /worktree action '{other}'. Use /worktree list, /worktree add [branch], /worktree remove , or /worktree prune." )), } } pub fn handle_commit_slash_command(message: &str, cwd: &Path) -> io::Result { let status = git_stdout(cwd, &["status", "--short"])?; if status.trim().is_empty() { return Ok( "Commit\n Result skipped\n Reason no workspace changes" .to_string(), ); } let message = message.trim(); if message.is_empty() { return Err(io::Error::other("generated commit message was empty")); } git_status_ok(cwd, &["add", "-A"])?; let path = write_temp_text_file("claw-commit-message", "txt", message)?; let path_string = path.to_string_lossy().into_owned(); git_status_ok(cwd, &["commit", "--file", path_string.as_str()])?; Ok(format!( "Commit\n Result created\n Message file {}\n\n{}", path.display(), message )) } pub fn handle_commit_push_pr_slash_command( request: &CommitPushPrRequest, cwd: &Path, ) -> io::Result { if !command_exists("gh") { return Err(io::Error::other("gh CLI is required for /commit-push-pr")); } let default_branch = detect_default_branch(cwd)?; let mut branch = current_branch(cwd)?; let mut created_branch = false; if branch == default_branch { let hint = if request.branch_name_hint.trim().is_empty() { request.pr_title.as_str() } else { request.branch_name_hint.as_str() }; let next_branch = build_branch_name(hint); git_status_ok(cwd, &["switch", "-c", next_branch.as_str()])?; branch = next_branch; created_branch = true; } let workspace_has_changes = !git_stdout(cwd, &["status", "--short"])?.trim().is_empty(); let commit_report = if workspace_has_changes { let Some(message) = request.commit_message.as_deref() else { return Err(io::Error::other( "commit message is required when workspace changes are present", )); }; Some(handle_commit_slash_command(message, cwd)?) } else { None }; let branch_diff = git_stdout( cwd, &["diff", "--stat", &format!("{default_branch}...HEAD")], )?; if branch_diff.trim().is_empty() { return Ok( "Commit/Push/PR\n Result skipped\n Reason no branch changes to push or open as a pull request" .to_string(), ); } git_status_ok(cwd, &["push", "--set-upstream", "origin", branch.as_str()])?; let body_path = write_temp_text_file("claw-pr-body", "md", request.pr_body.trim())?; let body_path_string = body_path.to_string_lossy().into_owned(); let create = Command::new("gh") .args([ "pr", "create", "--title", request.pr_title.as_str(), "--body-file", body_path_string.as_str(), "--base", default_branch.as_str(), ]) .current_dir(cwd) .output()?; let (result, url) = if create.status.success() { ( "created", parse_pr_url(&String::from_utf8_lossy(&create.stdout)) .unwrap_or_else(|| "".to_string()), ) } else { let view = Command::new("gh") .args(["pr", "view", "--json", "url"]) .current_dir(cwd) .output()?; if !view.status.success() { return Err(io::Error::other(command_failure( "gh", &["pr", "create"], &create, ))); } ( "existing", parse_pr_json_url(&String::from_utf8_lossy(&view.stdout)) .unwrap_or_else(|| "".to_string()), ) }; let mut lines = vec![ "Commit/Push/PR".to_string(), format!(" Result {result}"), format!(" Branch {branch}"), format!(" Base {default_branch}"), format!(" Body file {}", body_path.display()), format!(" URL {url}"), ]; if created_branch { lines.insert(2, " Branch action created and switched".to_string()); } if let Some(report) = commit_report { lines.push(String::new()); lines.push(report); } Ok(lines.join("\n")) } pub fn detect_default_branch(cwd: &Path) -> io::Result { if let Ok(reference) = git_stdout(cwd, &["symbolic-ref", "refs/remotes/origin/HEAD"]) { if let Some(branch) = reference .trim() .rsplit('/') .next() .filter(|value| !value.is_empty()) { return Ok(branch.to_string()); } } for branch in ["main", "master"] { if branch_exists(cwd, branch) { return Ok(branch.to_string()); } } current_branch(cwd) } fn git_stdout(cwd: &Path, args: &[&str]) -> io::Result { run_command_stdout("git", args, cwd) } fn git_status_ok(cwd: &Path, args: &[&str]) -> io::Result<()> { run_command_success("git", args, cwd) } fn run_command_stdout(program: &str, args: &[&str], cwd: &Path) -> io::Result { let output = Command::new(program).args(args).current_dir(cwd).output()?; if !output.status.success() { return Err(io::Error::other(command_failure(program, args, &output))); } String::from_utf8(output.stdout) .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) } fn run_command_success(program: &str, args: &[&str], cwd: &Path) -> io::Result<()> { let output = Command::new(program).args(args).current_dir(cwd).output()?; if !output.status.success() { return Err(io::Error::other(command_failure(program, args, &output))); } Ok(()) } fn command_failure(program: &str, args: &[&str], output: &std::process::Output) -> String { let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let detail = if stderr.is_empty() { stdout } else { stderr }; if detail.is_empty() { format!("{program} {} failed", args.join(" ")) } else { format!("{program} {} failed: {detail}", args.join(" ")) } } fn branch_exists(cwd: &Path, branch: &str) -> bool { Command::new("git") .args([ "show-ref", "--verify", "--quiet", &format!("refs/heads/{branch}"), ]) .current_dir(cwd) .output() .map(|output| output.status.success()) .unwrap_or(false) } fn current_branch(cwd: &Path) -> io::Result { let branch = git_stdout(cwd, &["branch", "--show-current"])?; let branch = branch.trim(); if branch.is_empty() { Err(io::Error::other("unable to determine current git branch")) } else { Ok(branch.to_string()) } } fn command_exists(name: &str) -> bool { Command::new(name) .arg("--version") .output() .map(|output| output.status.success()) .unwrap_or(false) } fn write_temp_text_file(prefix: &str, extension: &str, contents: &str) -> io::Result { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) .unwrap_or_default(); let path = env::temp_dir().join(format!("{prefix}-{nanos}.{extension}")); fs::write(&path, contents)?; Ok(path) } fn build_branch_name(hint: &str) -> String { let slug = slugify(hint); let owner = env::var("SAFEUSER") .ok() .filter(|value| !value.trim().is_empty()) .or_else(|| { env::var("USER") .ok() .filter(|value| !value.trim().is_empty()) }); match owner { Some(owner) => format!("{owner}/{slug}"), None => slug, } } fn slugify(value: &str) -> String { let mut slug = String::new(); let mut last_was_dash = false; for ch in value.chars() { if ch.is_ascii_alphanumeric() { slug.push(ch.to_ascii_lowercase()); last_was_dash = false; } else if !last_was_dash { slug.push('-'); last_was_dash = true; } } let slug = slug.trim_matches('-').to_string(); if slug.is_empty() { "change".to_string() } else { slug } } fn parse_pr_url(stdout: &str) -> Option { stdout .lines() .map(str::trim) .find(|line| line.starts_with("http://") || line.starts_with("https://")) .map(ToOwned::to_owned) } fn parse_pr_json_url(stdout: &str) -> Option { serde_json::from_str::(stdout) .ok()? .get("url")? .as_str() .map(ToOwned::to_owned) } #[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::Branch { .. } | SlashCommand::Bughunter { .. } | SlashCommand::Worktree { .. } | SlashCommand::Commit | SlashCommand::CommitPushPr { .. } | 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_branch_slash_command, handle_commit_push_pr_slash_command, handle_commit_slash_command, handle_plugins_slash_command, handle_slash_command, handle_worktree_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, CommitPushPrRequest, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand, }; use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary}; use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session}; use std::env; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::{Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; 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 env_lock() -> std::sync::MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() .expect("env lock") } fn run_command(cwd: &Path, program: &str, args: &[&str]) -> String { let output = Command::new(program) .args(args) .current_dir(cwd) .output() .expect("command should run"); assert!( output.status.success(), "{} {} failed: {}", program, args.join(" "), String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).expect("stdout should be utf8") } fn init_git_repo(label: &str) -> PathBuf { let root = temp_dir(label); fs::create_dir_all(&root).expect("repo root"); let init = Command::new("git") .args(["init", "-b", "main"]) .current_dir(&root) .output() .expect("git init should run"); if !init.status.success() { let fallback = Command::new("git") .arg("init") .current_dir(&root) .output() .expect("fallback git init should run"); assert!( fallback.status.success(), "fallback git init should succeed" ); let rename = Command::new("git") .args(["branch", "-m", "main"]) .current_dir(&root) .output() .expect("git branch -m should run"); assert!(rename.status.success(), "git branch -m main should succeed"); } run_command(&root, "git", &["config", "user.name", "Claw Tests"]); run_command(&root, "git", &["config", "user.email", "claw@example.com"]); fs::write(root.join("README.md"), "seed\n").expect("seed file"); run_command(&root, "git", &["add", "README.md"]); run_command(&root, "git", &["commit", "-m", "chore: seed repo"]); root } fn init_bare_repo(label: &str) -> PathBuf { let root = temp_dir(label); let output = Command::new("git") .args(["init", "--bare"]) .arg(&root) .output() .expect("bare repo should initialize"); assert!(output.status.success(), "git init --bare should succeed"); root } #[cfg(unix)] fn write_fake_gh(bin_dir: &Path, log_path: &Path, url: &str) { fs::create_dir_all(bin_dir).expect("bin dir"); let script = format!( "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then\n echo 'gh 1.0.0'\n exit 0\nfi\nprintf '%s\\n' \"$*\" >> \"{}\"\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"create\" ]; then\n echo '{}'\n exit 0\nfi\nif [ \"$1\" = \"pr\" ] && [ \"$2\" = \"view\" ]; then\n echo '{{\"url\":\"{}\"}}'\n exit 0\nfi\nexit 0\n", log_path.display(), url, url, ); let path = bin_dir.join("gh"); fs::write(&path, script).expect("gh stub"); let mut permissions = fs::metadata(&path).expect("metadata").permissions(); permissions.set_mode(0o755); fs::set_permissions(&path, permissions).expect("chmod"); } 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("/branch create feature/demo"), Some(SlashCommand::Branch { action: Some("create".to_string()), target: Some("feature/demo".to_string()), }) ); assert_eq!( SlashCommand::parse("/worktree add ../demo wt-demo"), Some(SlashCommand::Worktree { action: Some("add".to_string()), path: Some("../demo".to_string()), branch: Some("wt-demo".to_string()), }) ); assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit)); assert_eq!( SlashCommand::parse("/commit-push-pr ready for review"), Some(SlashCommand::CommitPushPr { context: Some("ready for review".to_string()) }) ); 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("/branch [list|create |switch ]")); assert!(help.contains("/worktree [list|add [branch]|remove |prune]")); assert!(help.contains("/commit")); assert!(help.contains("/commit-push-pr [context]")); 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(), 28); 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("/branch list", &session, CompactionConfig::default()).is_none() ); assert!( handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none() ); assert!( handle_slash_command("/worktree list", &session, CompactionConfig::default()).is_none() ); assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none()); assert!(handle_slash_command( "/commit-push-pr review notes", &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); } #[test] fn branch_and_worktree_commands_manage_git_state() { // given let repo = init_git_repo("branch-worktree"); let worktree_path = repo .parent() .expect("repo should have parent") .join("branch-worktree-linked"); // when let branch_list = handle_branch_slash_command(Some("list"), None, &repo).expect("branch list succeeds"); let created = handle_branch_slash_command(Some("create"), Some("feature/demo"), &repo) .expect("branch create succeeds"); let switched = handle_branch_slash_command(Some("switch"), Some("main"), &repo) .expect("branch switch succeeds"); let added = handle_worktree_slash_command( Some("add"), Some(worktree_path.to_str().expect("utf8 path")), Some("wt-demo"), &repo, ) .expect("worktree add succeeds"); let listed_worktrees = handle_worktree_slash_command(Some("list"), None, None, &repo).expect("list succeeds"); let removed = handle_worktree_slash_command( Some("remove"), Some(worktree_path.to_str().expect("utf8 path")), None, &repo, ) .expect("remove succeeds"); // then assert!(branch_list.contains("main")); assert!(created.contains("feature/demo")); assert!(switched.contains("main")); assert!(added.contains("wt-demo")); assert!(listed_worktrees.contains(worktree_path.to_str().expect("utf8 path"))); assert!(removed.contains("Result removed")); let _ = fs::remove_dir_all(repo); let _ = fs::remove_dir_all(worktree_path); } #[test] fn commit_command_stages_and_commits_changes() { // given let repo = init_git_repo("commit-command"); fs::write(repo.join("notes.txt"), "hello\n").expect("write notes"); // when let report = handle_commit_slash_command("feat: add notes", &repo).expect("commit succeeds"); let status = run_command(&repo, "git", &["status", "--short"]); let message = run_command(&repo, "git", &["log", "-1", "--pretty=%B"]); // then assert!(report.contains("Result created")); assert!(status.trim().is_empty()); assert_eq!(message.trim(), "feat: add notes"); let _ = fs::remove_dir_all(repo); } #[cfg(unix)] #[test] fn commit_push_pr_command_commits_pushes_and_creates_pr() { // given let _guard = env_lock(); let repo = init_git_repo("commit-push-pr"); let remote = init_bare_repo("commit-push-pr-remote"); run_command( &repo, "git", &[ "remote", "add", "origin", remote.to_str().expect("utf8 remote"), ], ); run_command(&repo, "git", &["push", "-u", "origin", "main"]); fs::write(repo.join("feature.txt"), "feature\n").expect("write feature file"); let fake_bin = temp_dir("fake-gh-bin"); let gh_log = fake_bin.join("gh.log"); write_fake_gh(&fake_bin, &gh_log, "https://example.com/pr/123"); let previous_path = env::var_os("PATH"); let mut new_path = fake_bin.display().to_string(); if let Some(path) = &previous_path { new_path.push(':'); new_path.push_str(&path.to_string_lossy()); } env::set_var("PATH", &new_path); let previous_safeuser = env::var_os("SAFEUSER"); env::set_var("SAFEUSER", "tester"); let request = CommitPushPrRequest { commit_message: Some("feat: add feature file".to_string()), pr_title: "Add feature file".to_string(), pr_body: "## Summary\n- add feature file".to_string(), branch_name_hint: "Add feature file".to_string(), }; // when let report = handle_commit_push_pr_slash_command(&request, &repo).expect("commit-push-pr succeeds"); let branch = run_command(&repo, "git", &["branch", "--show-current"]); let message = run_command(&repo, "git", &["log", "-1", "--pretty=%B"]); let gh_invocations = fs::read_to_string(&gh_log).expect("gh log should exist"); // then assert!(report.contains("Result created")); assert!(report.contains("URL https://example.com/pr/123")); assert_eq!(branch.trim(), "tester/add-feature-file"); assert_eq!(message.trim(), "feat: add feature file"); assert!(gh_invocations.contains("pr create")); assert!(gh_invocations.contains("--base main")); if let Some(path) = previous_path { env::set_var("PATH", path); } else { env::remove_var("PATH"); } if let Some(safeuser) = previous_safeuser { env::set_var("SAFEUSER", safeuser); } else { env::remove_var("SAFEUSER"); } let _ = fs::remove_dir_all(repo); let _ = fs::remove_dir_all(remote); let _ = fs::remove_dir_all(fake_bin); } }