Files
claw-code/rust/crates/commands/src/lib.rs
Yeachan-Heo 1f53d961ff Route nested CLI help requests to usage instead of operand fallthrough
The direct CLI wrappers for agents, skills, and mcp treated nested help flags as ordinary operands. That made commands like `claw mcp show --help` report a missing server and `claw skills install --help` fall into filesystem install logic instead of surfacing usage.

This change normalizes help-path arguments before dispatch so nested help stays on the help path. The regression tests cover both handler-level behavior and end-to-end CLI output for nested help and unknown subcommands with trailing help flags.

Constraint: Keep the fix scoped to direct CLI slash-command wrappers without changing unrelated parser behavior
Rejected: Rework top-level argument parsing for all subcommands | broader risk than needed for the regression
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If more nested subcommands are added, extend the help-path normalization table before relying on raw operand dispatch
Tested: cargo build -p commands -p rusty-claude-cli
Tested: cargo test -p commands -p rusty-claude-cli
Not-tested: cargo clippy -p commands -p rusty-claude-cli --all-targets --no-deps -- -D warnings (pre-existing warnings in untouched files block clean run)
2026-04-05 16:38:43 +00:00

4317 lines
140 KiB
Rust

use std::collections::BTreeMap;
use std::env;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use plugins::{PluginError, PluginManager, PluginSummary};
use runtime::{
compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
ScopedMcpServerConfig, 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<CommandManifestEntry>,
}
impl CommandRegistry {
#[must_use]
pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
Self { entries }
}
#[must_use]
pub fn entries(&self) -> &[CommandManifestEntry] {
&self.entries
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SlashCommandSpec {
pub name: &'static str,
pub aliases: &'static [&'static str],
pub summary: &'static str,
pub argument_hint: Option<&'static str>,
pub resume_supported: bool,
}
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
SlashCommandSpec {
name: "help",
aliases: &[],
summary: "Show available slash commands",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "status",
aliases: &[],
summary: "Show current session status",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "sandbox",
aliases: &[],
summary: "Show sandbox isolation status",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "compact",
aliases: &[],
summary: "Compact local session history",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "model",
aliases: &[],
summary: "Show or switch the active model",
argument_hint: Some("[model]"),
resume_supported: false,
},
SlashCommandSpec {
name: "permissions",
aliases: &[],
summary: "Show or switch the active permission mode",
argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
resume_supported: false,
},
SlashCommandSpec {
name: "clear",
aliases: &[],
summary: "Start a fresh local session",
argument_hint: Some("[--confirm]"),
resume_supported: true,
},
SlashCommandSpec {
name: "cost",
aliases: &[],
summary: "Show cumulative token usage for this session",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "resume",
aliases: &[],
summary: "Load a saved session into the REPL",
argument_hint: Some("<session-path>"),
resume_supported: false,
},
SlashCommandSpec {
name: "config",
aliases: &[],
summary: "Inspect Claude config files or merged sections",
argument_hint: Some("[env|hooks|model|plugins]"),
resume_supported: true,
},
SlashCommandSpec {
name: "mcp",
aliases: &[],
summary: "Inspect configured MCP servers",
argument_hint: Some("[list|show <server>|help]"),
resume_supported: true,
},
SlashCommandSpec {
name: "memory",
aliases: &[],
summary: "Inspect loaded Claude instruction memory files",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "init",
aliases: &[],
summary: "Create a starter CLAUDE.md for this repo",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "diff",
aliases: &[],
summary: "Show git diff for current workspace changes",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "version",
aliases: &[],
summary: "Show CLI version and build information",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "bughunter",
aliases: &[],
summary: "Inspect the codebase for likely bugs",
argument_hint: Some("[scope]"),
resume_supported: false,
},
SlashCommandSpec {
name: "commit",
aliases: &[],
summary: "Generate a commit message and create a git commit",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "pr",
aliases: &[],
summary: "Draft or create a pull request from the conversation",
argument_hint: Some("[context]"),
resume_supported: false,
},
SlashCommandSpec {
name: "issue",
aliases: &[],
summary: "Draft or create a GitHub issue from the conversation",
argument_hint: Some("[context]"),
resume_supported: false,
},
SlashCommandSpec {
name: "ultraplan",
aliases: &[],
summary: "Run a deep planning prompt with multi-step reasoning",
argument_hint: Some("[task]"),
resume_supported: false,
},
SlashCommandSpec {
name: "teleport",
aliases: &[],
summary: "Jump to a file or symbol by searching the workspace",
argument_hint: Some("<symbol-or-path>"),
resume_supported: false,
},
SlashCommandSpec {
name: "debug-tool-call",
aliases: &[],
summary: "Replay the last tool call with debug details",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "export",
aliases: &[],
summary: "Export the current conversation to a file",
argument_hint: Some("[file]"),
resume_supported: true,
},
SlashCommandSpec {
name: "session",
aliases: &[],
summary: "List, switch, or fork managed local sessions",
argument_hint: Some("[list|switch <session-id>|fork [branch-name]]"),
resume_supported: false,
},
SlashCommandSpec {
name: "plugin",
aliases: &["plugins", "marketplace"],
summary: "Manage Claw Code plugins",
argument_hint: Some(
"[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
),
resume_supported: false,
},
SlashCommandSpec {
name: "agents",
aliases: &[],
summary: "List configured agents",
argument_hint: Some("[list|help]"),
resume_supported: true,
},
SlashCommandSpec {
name: "skills",
aliases: &[],
summary: "List or install available skills",
argument_hint: Some("[list|install <path>|help]"),
resume_supported: true,
},
SlashCommandSpec {
name: "doctor",
aliases: &[],
summary: "Diagnose setup issues and environment health",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "login",
aliases: &[],
summary: "Log in to the service",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "logout",
aliases: &[],
summary: "Log out of the current session",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "plan",
aliases: &[],
summary: "Toggle or inspect planning mode",
argument_hint: Some("[on|off]"),
resume_supported: true,
},
SlashCommandSpec {
name: "review",
aliases: &[],
summary: "Run a code review on current changes",
argument_hint: Some("[scope]"),
resume_supported: false,
},
SlashCommandSpec {
name: "tasks",
aliases: &[],
summary: "List and manage background tasks",
argument_hint: Some("[list|get <id>|stop <id>]"),
resume_supported: true,
},
SlashCommandSpec {
name: "theme",
aliases: &[],
summary: "Switch the terminal color theme",
argument_hint: Some("[theme-name]"),
resume_supported: true,
},
SlashCommandSpec {
name: "vim",
aliases: &[],
summary: "Toggle vim keybinding mode",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "voice",
aliases: &[],
summary: "Toggle voice input mode",
argument_hint: Some("[on|off]"),
resume_supported: false,
},
SlashCommandSpec {
name: "upgrade",
aliases: &[],
summary: "Check for and install CLI updates",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "usage",
aliases: &[],
summary: "Show detailed API usage statistics",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "stats",
aliases: &[],
summary: "Show workspace and session statistics",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "rename",
aliases: &[],
summary: "Rename the current session",
argument_hint: Some("<name>"),
resume_supported: false,
},
SlashCommandSpec {
name: "copy",
aliases: &[],
summary: "Copy conversation or output to clipboard",
argument_hint: Some("[last|all]"),
resume_supported: true,
},
SlashCommandSpec {
name: "share",
aliases: &[],
summary: "Share the current conversation",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "feedback",
aliases: &[],
summary: "Submit feedback about the current session",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "hooks",
aliases: &[],
summary: "List and manage lifecycle hooks",
argument_hint: Some("[list|run <hook>]"),
resume_supported: true,
},
SlashCommandSpec {
name: "files",
aliases: &[],
summary: "List files in the current context window",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "context",
aliases: &[],
summary: "Inspect or manage the conversation context",
argument_hint: Some("[show|clear]"),
resume_supported: true,
},
SlashCommandSpec {
name: "color",
aliases: &[],
summary: "Configure terminal color settings",
argument_hint: Some("[scheme]"),
resume_supported: true,
},
SlashCommandSpec {
name: "effort",
aliases: &[],
summary: "Set the effort level for responses",
argument_hint: Some("[low|medium|high]"),
resume_supported: true,
},
SlashCommandSpec {
name: "fast",
aliases: &[],
summary: "Toggle fast/concise response mode",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "exit",
aliases: &[],
summary: "Exit the REPL session",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "branch",
aliases: &[],
summary: "Create or switch git branches",
argument_hint: Some("[name]"),
resume_supported: false,
},
SlashCommandSpec {
name: "rewind",
aliases: &[],
summary: "Rewind the conversation to a previous state",
argument_hint: Some("[steps]"),
resume_supported: false,
},
SlashCommandSpec {
name: "summary",
aliases: &[],
summary: "Generate a summary of the conversation",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "desktop",
aliases: &[],
summary: "Open or manage the desktop app integration",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "ide",
aliases: &[],
summary: "Open or configure IDE integration",
argument_hint: Some("[vscode|cursor]"),
resume_supported: false,
},
SlashCommandSpec {
name: "tag",
aliases: &[],
summary: "Tag the current conversation point",
argument_hint: Some("[label]"),
resume_supported: true,
},
SlashCommandSpec {
name: "brief",
aliases: &[],
summary: "Toggle brief output mode",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "advisor",
aliases: &[],
summary: "Toggle advisor mode for guidance-only responses",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "stickers",
aliases: &[],
summary: "Browse and manage sticker packs",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "insights",
aliases: &[],
summary: "Show AI-generated insights about the session",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "thinkback",
aliases: &[],
summary: "Replay the thinking process of the last response",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "release-notes",
aliases: &[],
summary: "Generate release notes from recent changes",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "security-review",
aliases: &[],
summary: "Run a security review on the codebase",
argument_hint: Some("[scope]"),
resume_supported: false,
},
SlashCommandSpec {
name: "keybindings",
aliases: &[],
summary: "Show or configure keyboard shortcuts",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "privacy-settings",
aliases: &[],
summary: "View or modify privacy settings",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "output-style",
aliases: &[],
summary: "Switch output formatting style",
argument_hint: Some("[style]"),
resume_supported: true,
},
SlashCommandSpec {
name: "add-dir",
aliases: &[],
summary: "Add an additional directory to the context",
argument_hint: Some("<path>"),
resume_supported: false,
},
SlashCommandSpec {
name: "allowed-tools",
aliases: &[],
summary: "Show or modify the allowed tools list",
argument_hint: Some("[add|remove|list] [tool]"),
resume_supported: true,
},
SlashCommandSpec {
name: "api-key",
aliases: &[],
summary: "Show or set the Anthropic API key",
argument_hint: Some("[key]"),
resume_supported: false,
},
SlashCommandSpec {
name: "approve",
aliases: &["yes", "y"],
summary: "Approve a pending tool execution",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "deny",
aliases: &["no", "n"],
summary: "Deny a pending tool execution",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "undo",
aliases: &[],
summary: "Undo the last file write or edit",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "stop",
aliases: &[],
summary: "Stop the current generation",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "retry",
aliases: &[],
summary: "Retry the last failed message",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "paste",
aliases: &[],
summary: "Paste clipboard content as input",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "screenshot",
aliases: &[],
summary: "Take a screenshot and add to conversation",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "image",
aliases: &[],
summary: "Add an image file to the conversation",
argument_hint: Some("<path>"),
resume_supported: false,
},
SlashCommandSpec {
name: "terminal-setup",
aliases: &[],
summary: "Configure terminal integration settings",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "search",
aliases: &[],
summary: "Search files in the workspace",
argument_hint: Some("<query>"),
resume_supported: false,
},
SlashCommandSpec {
name: "listen",
aliases: &[],
summary: "Listen for voice input",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "speak",
aliases: &[],
summary: "Read the last response aloud",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "language",
aliases: &[],
summary: "Set the interface language",
argument_hint: Some("[language]"),
resume_supported: true,
},
SlashCommandSpec {
name: "profile",
aliases: &[],
summary: "Show or switch user profile",
argument_hint: Some("[name]"),
resume_supported: false,
},
SlashCommandSpec {
name: "max-tokens",
aliases: &[],
summary: "Show or set the max output tokens",
argument_hint: Some("[count]"),
resume_supported: true,
},
SlashCommandSpec {
name: "temperature",
aliases: &[],
summary: "Show or set the sampling temperature",
argument_hint: Some("[value]"),
resume_supported: true,
},
SlashCommandSpec {
name: "system-prompt",
aliases: &[],
summary: "Show the active system prompt",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "tool-details",
aliases: &[],
summary: "Show detailed info about a specific tool",
argument_hint: Some("<tool-name>"),
resume_supported: true,
},
SlashCommandSpec {
name: "format",
aliases: &[],
summary: "Format the last response in a different style",
argument_hint: Some("[markdown|plain|json]"),
resume_supported: false,
},
SlashCommandSpec {
name: "pin",
aliases: &[],
summary: "Pin a message to persist across compaction",
argument_hint: Some("[message-index]"),
resume_supported: false,
},
SlashCommandSpec {
name: "unpin",
aliases: &[],
summary: "Unpin a previously pinned message",
argument_hint: Some("[message-index]"),
resume_supported: false,
},
SlashCommandSpec {
name: "bookmarks",
aliases: &[],
summary: "List or manage conversation bookmarks",
argument_hint: Some("[add|remove|list]"),
resume_supported: true,
},
SlashCommandSpec {
name: "workspace",
aliases: &["cwd"],
summary: "Show or change the working directory",
argument_hint: Some("[path]"),
resume_supported: true,
},
SlashCommandSpec {
name: "history",
aliases: &[],
summary: "Show conversation history summary",
argument_hint: Some("[count]"),
resume_supported: true,
},
SlashCommandSpec {
name: "tokens",
aliases: &[],
summary: "Show token count for the current conversation",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "cache",
aliases: &[],
summary: "Show prompt cache statistics",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "providers",
aliases: &[],
summary: "List available model providers",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "notifications",
aliases: &[],
summary: "Show or configure notification settings",
argument_hint: Some("[on|off|status]"),
resume_supported: true,
},
SlashCommandSpec {
name: "changelog",
aliases: &[],
summary: "Show recent changes to the codebase",
argument_hint: Some("[count]"),
resume_supported: true,
},
SlashCommandSpec {
name: "test",
aliases: &[],
summary: "Run tests for the current project",
argument_hint: Some("[filter]"),
resume_supported: false,
},
SlashCommandSpec {
name: "lint",
aliases: &[],
summary: "Run linting for the current project",
argument_hint: Some("[filter]"),
resume_supported: false,
},
SlashCommandSpec {
name: "build",
aliases: &[],
summary: "Build the current project",
argument_hint: Some("[target]"),
resume_supported: false,
},
SlashCommandSpec {
name: "run",
aliases: &[],
summary: "Run a command in the project context",
argument_hint: Some("<command>"),
resume_supported: false,
},
SlashCommandSpec {
name: "git",
aliases: &[],
summary: "Run a git command in the workspace",
argument_hint: Some("<subcommand>"),
resume_supported: false,
},
SlashCommandSpec {
name: "stash",
aliases: &[],
summary: "Stash or unstash workspace changes",
argument_hint: Some("[pop|list|apply]"),
resume_supported: false,
},
SlashCommandSpec {
name: "blame",
aliases: &[],
summary: "Show git blame for a file",
argument_hint: Some("<file> [line]"),
resume_supported: true,
},
SlashCommandSpec {
name: "log",
aliases: &[],
summary: "Show git log for the workspace",
argument_hint: Some("[count]"),
resume_supported: true,
},
SlashCommandSpec {
name: "cron",
aliases: &[],
summary: "Manage scheduled tasks",
argument_hint: Some("[list|add|remove]"),
resume_supported: true,
},
SlashCommandSpec {
name: "team",
aliases: &[],
summary: "Manage agent teams",
argument_hint: Some("[list|create|delete]"),
resume_supported: true,
},
SlashCommandSpec {
name: "benchmark",
aliases: &[],
summary: "Run performance benchmarks",
argument_hint: Some("[suite]"),
resume_supported: false,
},
SlashCommandSpec {
name: "migrate",
aliases: &[],
summary: "Run pending data migrations",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "reset",
aliases: &[],
summary: "Reset configuration to defaults",
argument_hint: Some("[section]"),
resume_supported: false,
},
SlashCommandSpec {
name: "telemetry",
aliases: &[],
summary: "Show or configure telemetry settings",
argument_hint: Some("[on|off|status]"),
resume_supported: true,
},
SlashCommandSpec {
name: "env",
aliases: &[],
summary: "Show environment variables visible to tools",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "project",
aliases: &[],
summary: "Show project detection info",
argument_hint: None,
resume_supported: true,
},
SlashCommandSpec {
name: "templates",
aliases: &[],
summary: "List or apply prompt templates",
argument_hint: Some("[list|apply <name>]"),
resume_supported: false,
},
SlashCommandSpec {
name: "explain",
aliases: &[],
summary: "Explain a file or code snippet",
argument_hint: Some("<path> [line-range]"),
resume_supported: false,
},
SlashCommandSpec {
name: "refactor",
aliases: &[],
summary: "Suggest refactoring for a file or function",
argument_hint: Some("<path> [scope]"),
resume_supported: false,
},
SlashCommandSpec {
name: "docs",
aliases: &[],
summary: "Generate or show documentation",
argument_hint: Some("[path]"),
resume_supported: false,
},
SlashCommandSpec {
name: "fix",
aliases: &[],
summary: "Fix errors in a file or project",
argument_hint: Some("[path]"),
resume_supported: false,
},
SlashCommandSpec {
name: "perf",
aliases: &[],
summary: "Analyze performance of a function or file",
argument_hint: Some("<path>"),
resume_supported: false,
},
SlashCommandSpec {
name: "chat",
aliases: &[],
summary: "Switch to free-form chat mode",
argument_hint: None,
resume_supported: false,
},
SlashCommandSpec {
name: "focus",
aliases: &[],
summary: "Focus context on specific files or directories",
argument_hint: Some("<path> [path...]"),
resume_supported: false,
},
SlashCommandSpec {
name: "unfocus",
aliases: &[],
summary: "Remove focus from files or directories",
argument_hint: Some("[path...]"),
resume_supported: false,
},
SlashCommandSpec {
name: "web",
aliases: &[],
summary: "Fetch and summarize a web page",
argument_hint: Some("<url>"),
resume_supported: false,
},
SlashCommandSpec {
name: "map",
aliases: &[],
summary: "Show a visual map of the codebase structure",
argument_hint: Some("[depth]"),
resume_supported: true,
},
SlashCommandSpec {
name: "symbols",
aliases: &[],
summary: "List symbols (functions, classes, etc.) in a file",
argument_hint: Some("<path>"),
resume_supported: true,
},
SlashCommandSpec {
name: "references",
aliases: &[],
summary: "Find all references to a symbol",
argument_hint: Some("<symbol>"),
resume_supported: false,
},
SlashCommandSpec {
name: "definition",
aliases: &[],
summary: "Go to the definition of a symbol",
argument_hint: Some("<symbol>"),
resume_supported: false,
},
SlashCommandSpec {
name: "hover",
aliases: &[],
summary: "Show hover information for a symbol",
argument_hint: Some("<symbol>"),
resume_supported: true,
},
SlashCommandSpec {
name: "diagnostics",
aliases: &[],
summary: "Show LSP diagnostics for a file",
argument_hint: Some("[path]"),
resume_supported: true,
},
SlashCommandSpec {
name: "autofix",
aliases: &[],
summary: "Auto-fix all fixable diagnostics",
argument_hint: Some("[path]"),
resume_supported: false,
},
SlashCommandSpec {
name: "multi",
aliases: &[],
summary: "Execute multiple slash commands in sequence",
argument_hint: Some("<commands>"),
resume_supported: false,
},
SlashCommandSpec {
name: "macro",
aliases: &[],
summary: "Record or replay command macros",
argument_hint: Some("[record|stop|play <name>]"),
resume_supported: false,
},
SlashCommandSpec {
name: "alias",
aliases: &[],
summary: "Create a command alias",
argument_hint: Some("<name> <command>"),
resume_supported: true,
},
SlashCommandSpec {
name: "parallel",
aliases: &[],
summary: "Run commands in parallel subagents",
argument_hint: Some("<count> <prompt>"),
resume_supported: false,
},
SlashCommandSpec {
name: "agent",
aliases: &[],
summary: "Manage sub-agents and spawned sessions",
argument_hint: Some("[list|spawn|kill]"),
resume_supported: true,
},
SlashCommandSpec {
name: "subagent",
aliases: &[],
summary: "Control active subagent execution",
argument_hint: Some("[list|steer <target> <msg>|kill <id>]"),
resume_supported: true,
},
SlashCommandSpec {
name: "reasoning",
aliases: &[],
summary: "Toggle extended reasoning mode",
argument_hint: Some("[on|off|stream]"),
resume_supported: true,
},
SlashCommandSpec {
name: "budget",
aliases: &[],
summary: "Show or set token budget limits",
argument_hint: Some("[show|set <limit>]"),
resume_supported: true,
},
SlashCommandSpec {
name: "rate-limit",
aliases: &[],
summary: "Configure API rate limiting",
argument_hint: Some("[status|set <rpm>]"),
resume_supported: true,
},
SlashCommandSpec {
name: "metrics",
aliases: &[],
summary: "Show performance and usage metrics",
argument_hint: None,
resume_supported: true,
},
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SlashCommand {
Help,
Status,
Sandbox,
Compact,
Bughunter {
scope: Option<String>,
},
Commit,
Pr {
context: Option<String>,
},
Issue {
context: Option<String>,
},
Ultraplan {
task: Option<String>,
},
Teleport {
target: Option<String>,
},
DebugToolCall,
Model {
model: Option<String>,
},
Permissions {
mode: Option<String>,
},
Clear {
confirm: bool,
},
Cost,
Resume {
session_path: Option<String>,
},
Config {
section: Option<String>,
},
Mcp {
action: Option<String>,
target: Option<String>,
},
Memory,
Init,
Diff,
Version,
Export {
path: Option<String>,
},
Session {
action: Option<String>,
target: Option<String>,
},
Plugins {
action: Option<String>,
target: Option<String>,
},
Agents {
args: Option<String>,
},
Skills {
args: Option<String>,
},
Doctor,
Login,
Logout,
Vim,
Upgrade,
Stats,
Share,
Feedback,
Files,
Fast,
Exit,
Summary,
Desktop,
Brief,
Advisor,
Stickers,
Insights,
Thinkback,
ReleaseNotes,
SecurityReview,
Keybindings,
PrivacySettings,
Plan {
mode: Option<String>,
},
Review {
scope: Option<String>,
},
Tasks {
args: Option<String>,
},
Theme {
name: Option<String>,
},
Voice {
mode: Option<String>,
},
Usage {
scope: Option<String>,
},
Rename {
name: Option<String>,
},
Copy {
target: Option<String>,
},
Hooks {
args: Option<String>,
},
Context {
action: Option<String>,
},
Color {
scheme: Option<String>,
},
Effort {
level: Option<String>,
},
Branch {
name: Option<String>,
},
Rewind {
steps: Option<String>,
},
Ide {
target: Option<String>,
},
Tag {
label: Option<String>,
},
OutputStyle {
style: Option<String>,
},
AddDir {
path: Option<String>,
},
Unknown(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlashCommandParseError {
message: String,
}
impl SlashCommandParseError {
fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl fmt::Display for SlashCommandParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for SlashCommandParseError {}
impl SlashCommand {
pub fn parse(input: &str) -> Result<Option<Self>, SlashCommandParseError> {
validate_slash_command_input(input)
}
}
#[allow(clippy::too_many_lines)]
pub fn validate_slash_command_input(
input: &str,
) -> Result<Option<SlashCommand>, SlashCommandParseError> {
let trimmed = input.trim();
if !trimmed.starts_with('/') {
return Ok(None);
}
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
let command = parts.next().unwrap_or_default();
if command.is_empty() {
return Err(SlashCommandParseError::new(
"Slash command name is missing. Use /help to list available slash commands.",
));
}
let args = parts.collect::<Vec<_>>();
let remainder = remainder_after_command(trimmed, command);
Ok(Some(match command {
"help" => {
validate_no_args(command, &args)?;
SlashCommand::Help
}
"status" => {
validate_no_args(command, &args)?;
SlashCommand::Status
}
"sandbox" => {
validate_no_args(command, &args)?;
SlashCommand::Sandbox
}
"compact" => {
validate_no_args(command, &args)?;
SlashCommand::Compact
}
"bughunter" => SlashCommand::Bughunter { scope: remainder },
"commit" => {
validate_no_args(command, &args)?;
SlashCommand::Commit
}
"pr" => SlashCommand::Pr { context: remainder },
"issue" => SlashCommand::Issue { context: remainder },
"ultraplan" => SlashCommand::Ultraplan { task: remainder },
"teleport" => SlashCommand::Teleport {
target: Some(require_remainder(command, remainder, "<symbol-or-path>")?),
},
"debug-tool-call" => {
validate_no_args(command, &args)?;
SlashCommand::DebugToolCall
}
"model" => SlashCommand::Model {
model: optional_single_arg(command, &args, "[model]")?,
},
"permissions" => SlashCommand::Permissions {
mode: parse_permissions_mode(&args)?,
},
"clear" => SlashCommand::Clear {
confirm: parse_clear_args(&args)?,
},
"cost" => {
validate_no_args(command, &args)?;
SlashCommand::Cost
}
"resume" => SlashCommand::Resume {
session_path: Some(require_remainder(command, remainder, "<session-path>")?),
},
"config" => SlashCommand::Config {
section: parse_config_section(&args)?,
},
"mcp" => parse_mcp_command(&args)?,
"memory" => {
validate_no_args(command, &args)?;
SlashCommand::Memory
}
"init" => {
validate_no_args(command, &args)?;
SlashCommand::Init
}
"diff" => {
validate_no_args(command, &args)?;
SlashCommand::Diff
}
"version" => {
validate_no_args(command, &args)?;
SlashCommand::Version
}
"export" => SlashCommand::Export { path: remainder },
"session" => parse_session_command(&args)?,
"plugin" | "plugins" | "marketplace" => parse_plugin_command(&args)?,
"agents" => SlashCommand::Agents {
args: parse_list_or_help_args(command, remainder)?,
},
"skills" => SlashCommand::Skills {
args: parse_skills_args(remainder.as_deref())?,
},
"doctor" => {
validate_no_args(command, &args)?;
SlashCommand::Doctor
}
"login" => {
validate_no_args(command, &args)?;
SlashCommand::Login
}
"logout" => {
validate_no_args(command, &args)?;
SlashCommand::Logout
}
"vim" => {
validate_no_args(command, &args)?;
SlashCommand::Vim
}
"upgrade" => {
validate_no_args(command, &args)?;
SlashCommand::Upgrade
}
"stats" => {
validate_no_args(command, &args)?;
SlashCommand::Stats
}
"share" => {
validate_no_args(command, &args)?;
SlashCommand::Share
}
"feedback" => {
validate_no_args(command, &args)?;
SlashCommand::Feedback
}
"files" => {
validate_no_args(command, &args)?;
SlashCommand::Files
}
"fast" => {
validate_no_args(command, &args)?;
SlashCommand::Fast
}
"exit" => {
validate_no_args(command, &args)?;
SlashCommand::Exit
}
"summary" => {
validate_no_args(command, &args)?;
SlashCommand::Summary
}
"desktop" => {
validate_no_args(command, &args)?;
SlashCommand::Desktop
}
"brief" => {
validate_no_args(command, &args)?;
SlashCommand::Brief
}
"advisor" => {
validate_no_args(command, &args)?;
SlashCommand::Advisor
}
"stickers" => {
validate_no_args(command, &args)?;
SlashCommand::Stickers
}
"insights" => {
validate_no_args(command, &args)?;
SlashCommand::Insights
}
"thinkback" => {
validate_no_args(command, &args)?;
SlashCommand::Thinkback
}
"release-notes" => {
validate_no_args(command, &args)?;
SlashCommand::ReleaseNotes
}
"security-review" => {
validate_no_args(command, &args)?;
SlashCommand::SecurityReview
}
"keybindings" => {
validate_no_args(command, &args)?;
SlashCommand::Keybindings
}
"privacy-settings" => {
validate_no_args(command, &args)?;
SlashCommand::PrivacySettings
}
"plan" => SlashCommand::Plan { mode: remainder },
"review" => SlashCommand::Review { scope: remainder },
"tasks" => SlashCommand::Tasks { args: remainder },
"theme" => SlashCommand::Theme { name: remainder },
"voice" => SlashCommand::Voice { mode: remainder },
"usage" => SlashCommand::Usage { scope: remainder },
"rename" => SlashCommand::Rename { name: remainder },
"copy" => SlashCommand::Copy { target: remainder },
"hooks" => SlashCommand::Hooks { args: remainder },
"context" => SlashCommand::Context { action: remainder },
"color" => SlashCommand::Color { scheme: remainder },
"effort" => SlashCommand::Effort { level: remainder },
"branch" => SlashCommand::Branch { name: remainder },
"rewind" => SlashCommand::Rewind { steps: remainder },
"ide" => SlashCommand::Ide { target: remainder },
"tag" => SlashCommand::Tag { label: remainder },
"output-style" => SlashCommand::OutputStyle { style: remainder },
"add-dir" => SlashCommand::AddDir { path: remainder },
other => SlashCommand::Unknown(other.to_string()),
}))
}
fn validate_no_args(command: &str, args: &[&str]) -> Result<(), SlashCommandParseError> {
if args.is_empty() {
return Ok(());
}
Err(command_error(
&format!("Unexpected arguments for /{command}."),
command,
&format!("/{command}"),
))
}
fn optional_single_arg(
command: &str,
args: &[&str],
argument_hint: &str,
) -> Result<Option<String>, SlashCommandParseError> {
match args {
[] => Ok(None),
[value] => Ok(Some((*value).to_string())),
_ => Err(usage_error(command, argument_hint)),
}
}
fn require_remainder(
command: &str,
remainder: Option<String>,
argument_hint: &str,
) -> Result<String, SlashCommandParseError> {
remainder.ok_or_else(|| usage_error(command, argument_hint))
}
fn parse_permissions_mode(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
let mode = optional_single_arg(
"permissions",
args,
"[read-only|workspace-write|danger-full-access]",
)?;
if let Some(mode) = mode {
if matches!(
mode.as_str(),
"read-only" | "workspace-write" | "danger-full-access"
) {
return Ok(Some(mode));
}
return Err(command_error(
&format!(
"Unsupported /permissions mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
),
"permissions",
"/permissions [read-only|workspace-write|danger-full-access]",
));
}
Ok(None)
}
fn parse_clear_args(args: &[&str]) -> Result<bool, SlashCommandParseError> {
match args {
[] => Ok(false),
["--confirm"] => Ok(true),
[unexpected] => Err(command_error(
&format!("Unsupported /clear argument '{unexpected}'. Use /clear or /clear --confirm."),
"clear",
"/clear [--confirm]",
)),
_ => Err(usage_error("clear", "[--confirm]")),
}
}
fn parse_config_section(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
let section = optional_single_arg("config", args, "[env|hooks|model|plugins]")?;
if let Some(section) = section {
if matches!(section.as_str(), "env" | "hooks" | "model" | "plugins") {
return Ok(Some(section));
}
return Err(command_error(
&format!("Unsupported /config section '{section}'. Use env, hooks, model, or plugins."),
"config",
"/config [env|hooks|model|plugins]",
));
}
Ok(None)
}
fn parse_session_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
match args {
[] => Ok(SlashCommand::Session {
action: None,
target: None,
}),
["list"] => Ok(SlashCommand::Session {
action: Some("list".to_string()),
target: None,
}),
["list", ..] => Err(usage_error("session", "[list|switch <session-id>|fork [branch-name]]")),
["switch"] => Err(usage_error("session switch", "<session-id>")),
["switch", target] => Ok(SlashCommand::Session {
action: Some("switch".to_string()),
target: Some((*target).to_string()),
}),
["switch", ..] => Err(command_error(
"Unexpected arguments for /session switch.",
"session",
"/session switch <session-id>",
)),
["fork"] => Ok(SlashCommand::Session {
action: Some("fork".to_string()),
target: None,
}),
["fork", target] => Ok(SlashCommand::Session {
action: Some("fork".to_string()),
target: Some((*target).to_string()),
}),
["fork", ..] => Err(command_error(
"Unexpected arguments for /session fork.",
"session",
"/session fork [branch-name]",
)),
[action, ..] => Err(command_error(
&format!(
"Unknown /session action '{action}'. Use list, switch <session-id>, or fork [branch-name]."
),
"session",
"/session [list|switch <session-id>|fork [branch-name]]",
)),
}
}
fn parse_mcp_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
match args {
[] => Ok(SlashCommand::Mcp {
action: None,
target: None,
}),
["list"] => Ok(SlashCommand::Mcp {
action: Some("list".to_string()),
target: None,
}),
["list", ..] => Err(usage_error("mcp list", "")),
["show"] => Err(usage_error("mcp show", "<server>")),
["show", target] => Ok(SlashCommand::Mcp {
action: Some("show".to_string()),
target: Some((*target).to_string()),
}),
["show", ..] => Err(command_error(
"Unexpected arguments for /mcp show.",
"mcp",
"/mcp show <server>",
)),
["help" | "-h" | "--help"] => Ok(SlashCommand::Mcp {
action: Some("help".to_string()),
target: None,
}),
[action, ..] => Err(command_error(
&format!("Unknown /mcp action '{action}'. Use list, show <server>, or help."),
"mcp",
"/mcp [list|show <server>|help]",
)),
}
}
fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
match args {
[] => Ok(SlashCommand::Plugins {
action: None,
target: None,
}),
["list"] => Ok(SlashCommand::Plugins {
action: Some("list".to_string()),
target: None,
}),
["list", ..] => Err(usage_error("plugin list", "")),
["install"] => Err(usage_error("plugin install", "<path>")),
["install", target @ ..] => Ok(SlashCommand::Plugins {
action: Some("install".to_string()),
target: Some(target.join(" ")),
}),
["enable"] => Err(usage_error("plugin enable", "<name>")),
["enable", target] => Ok(SlashCommand::Plugins {
action: Some("enable".to_string()),
target: Some((*target).to_string()),
}),
["enable", ..] => Err(command_error(
"Unexpected arguments for /plugin enable.",
"plugin",
"/plugin enable <name>",
)),
["disable"] => Err(usage_error("plugin disable", "<name>")),
["disable", target] => Ok(SlashCommand::Plugins {
action: Some("disable".to_string()),
target: Some((*target).to_string()),
}),
["disable", ..] => Err(command_error(
"Unexpected arguments for /plugin disable.",
"plugin",
"/plugin disable <name>",
)),
["uninstall"] => Err(usage_error("plugin uninstall", "<id>")),
["uninstall", target] => Ok(SlashCommand::Plugins {
action: Some("uninstall".to_string()),
target: Some((*target).to_string()),
}),
["uninstall", ..] => Err(command_error(
"Unexpected arguments for /plugin uninstall.",
"plugin",
"/plugin uninstall <id>",
)),
["update"] => Err(usage_error("plugin update", "<id>")),
["update", target] => Ok(SlashCommand::Plugins {
action: Some("update".to_string()),
target: Some((*target).to_string()),
}),
["update", ..] => Err(command_error(
"Unexpected arguments for /plugin update.",
"plugin",
"/plugin update <id>",
)),
[action, ..] => Err(command_error(
&format!(
"Unknown /plugin action '{action}'. Use list, install <path>, enable <name>, disable <name>, uninstall <id>, or update <id>."
),
"plugin",
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
)),
}
}
fn parse_list_or_help_args(
command: &str,
args: Option<String>,
) -> Result<Option<String>, SlashCommandParseError> {
match normalize_optional_args(args.as_deref()) {
None | Some("list" | "help" | "-h" | "--help") => Ok(args),
Some(unexpected) => Err(command_error(
&format!(
"Unexpected arguments for /{command}: {unexpected}. Use /{command}, /{command} list, or /{command} help."
),
command,
&format!("/{command} [list|help]"),
)),
}
}
fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandParseError> {
let Some(args) = normalize_optional_args(args) else {
return Ok(None);
};
if matches!(args, "list" | "help" | "-h" | "--help") {
return Ok(Some(args.to_string()));
}
if args == "install" {
return Err(command_error(
"Usage: /skills install <path>",
"skills",
"/skills install <path>",
));
}
if let Some(target) = args.strip_prefix("install").map(str::trim) {
if !target.is_empty() {
return Ok(Some(format!("install {target}")));
}
}
Err(command_error(
&format!(
"Unexpected arguments for /skills: {args}. Use /skills, /skills list, /skills install <path>, or /skills help."
),
"skills",
"/skills [list|install <path>|help]",
))
}
fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
let usage = format!("/{command} {argument_hint}");
let usage = usage.trim_end().to_string();
command_error(
&format!("Usage: {usage}"),
command_root_name(command),
&usage,
)
}
fn command_error(message: &str, command: &str, usage: &str) -> SlashCommandParseError {
let detail = render_slash_command_help_detail(command)
.map(|detail| format!("\n\n{detail}"))
.unwrap_or_default();
SlashCommandParseError::new(format!("{message}\n Usage {usage}{detail}"))
}
fn remainder_after_command(input: &str, command: &str) -> Option<String> {
input
.trim()
.strip_prefix(&format!("/{command}"))
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn find_slash_command_spec(name: &str) -> Option<&'static SlashCommandSpec> {
slash_command_specs().iter().find(|spec| {
spec.name.eq_ignore_ascii_case(name)
|| spec
.aliases
.iter()
.any(|alias| alias.eq_ignore_ascii_case(name))
})
}
fn command_root_name(command: &str) -> &str {
command.split_whitespace().next().unwrap_or(command)
}
fn slash_command_usage(spec: &SlashCommandSpec) -> String {
match spec.argument_hint {
Some(argument_hint) => format!("/{} {argument_hint}", spec.name),
None => format!("/{}", spec.name),
}
}
fn slash_command_detail_lines(spec: &SlashCommandSpec) -> Vec<String> {
let mut lines = vec![format!("/{}", spec.name)];
lines.push(format!(" Summary {}", spec.summary));
lines.push(format!(" Usage {}", slash_command_usage(spec)));
lines.push(format!(
" Category {}",
slash_command_category(spec.name)
));
if !spec.aliases.is_empty() {
lines.push(format!(
" Aliases {}",
spec.aliases
.iter()
.map(|alias| format!("/{alias}"))
.collect::<Vec<_>>()
.join(", ")
));
}
if spec.resume_supported {
lines.push(" Resume Supported with --resume SESSION.jsonl".to_string());
}
lines
}
#[must_use]
pub fn render_slash_command_help_detail(name: &str) -> Option<String> {
find_slash_command_spec(name).map(|spec| slash_command_detail_lines(spec).join("\n"))
}
#[must_use]
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
SLASH_COMMAND_SPECS
}
#[must_use]
pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
slash_command_specs()
.iter()
.filter(|spec| spec.resume_supported)
.collect()
}
fn slash_command_category(name: &str) -> &'static str {
match name {
"help" | "status" | "sandbox" | "model" | "permissions" | "cost" | "resume" | "session"
| "version" | "login" | "logout" | "usage" | "stats" | "rename" | "privacy-settings" => {
"Session & visibility"
}
"compact" | "clear" | "config" | "memory" | "init" | "diff" | "commit" | "pr" | "issue"
| "export" | "plugin" | "branch" | "add-dir" | "files" | "hooks" | "release-notes" => {
"Workspace & git"
}
"agents" | "skills" | "teleport" | "debug-tool-call" | "mcp" | "context" | "tasks"
| "doctor" | "ide" | "desktop" => "Discovery & debugging",
"bughunter" | "ultraplan" | "review" | "security-review" | "advisor" | "insights" => {
"Analysis & automation"
}
"theme" | "vim" | "voice" | "color" | "effort" | "fast" | "brief" | "output-style"
| "keybindings" | "stickers" => "Appearance & input",
"copy" | "share" | "feedback" | "summary" | "tag" | "thinkback" | "plan" | "exit"
| "upgrade" | "rewind" => "Communication & control",
_ => "Other",
}
}
fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String {
let name = slash_command_usage(spec);
let alias_suffix = if spec.aliases.is_empty() {
String::new()
} else {
format!(
" (aliases: {})",
spec.aliases
.iter()
.map(|alias| format!("/{alias}"))
.collect::<Vec<_>>()
.join(", ")
)
};
let resume = if spec.resume_supported {
" [resume]"
} else {
""
};
format!(" {name:<66} {}{alias_suffix}{resume}", spec.summary)
}
fn levenshtein_distance(left: &str, right: &str) -> usize {
if left == right {
return 0;
}
if left.is_empty() {
return right.chars().count();
}
if right.is_empty() {
return left.chars().count();
}
let right_chars = right.chars().collect::<Vec<_>>();
let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
let mut current = vec![0; right_chars.len() + 1];
for (left_index, left_char) in left.chars().enumerate() {
current[0] = left_index + 1;
for (right_index, right_char) in right_chars.iter().enumerate() {
let substitution_cost = usize::from(left_char != *right_char);
current[right_index + 1] = (current[right_index] + 1)
.min(previous[right_index + 1] + 1)
.min(previous[right_index] + substitution_cost);
}
previous.clone_from(&current);
}
previous[right_chars.len()]
}
#[must_use]
pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec<String> {
let query = input.trim().trim_start_matches('/').to_ascii_lowercase();
if query.is_empty() || limit == 0 {
return Vec::new();
}
let mut suggestions = slash_command_specs()
.iter()
.filter_map(|spec| {
let best = std::iter::once(spec.name)
.chain(spec.aliases.iter().copied())
.map(str::to_ascii_lowercase)
.map(|candidate| {
let prefix_rank =
if candidate.starts_with(&query) || query.starts_with(&candidate) {
0
} else if candidate.contains(&query) || query.contains(&candidate) {
1
} else {
2
};
let distance = levenshtein_distance(&candidate, &query);
(prefix_rank, distance)
})
.min();
best.and_then(|(prefix_rank, distance)| {
if prefix_rank <= 1 || distance <= 2 {
Some((prefix_rank, distance, spec.name.len(), spec.name))
} else {
None
}
})
})
.collect::<Vec<_>>();
suggestions.sort_unstable();
suggestions
.into_iter()
.map(|(_, _, _, name)| format!("/{name}"))
.take(limit)
.collect()
}
#[must_use]
pub fn render_slash_command_help() -> String {
let mut lines = vec![
"Slash commands".to_string(),
" Start here /status, /diff, /agents, /skills, /commit".to_string(),
" [resume] also works with --resume SESSION.jsonl".to_string(),
String::new(),
];
let categories = [
"Session & visibility",
"Workspace & git",
"Discovery & debugging",
"Analysis & automation",
];
for category in categories {
lines.push(category.to_string());
for spec in slash_command_specs()
.iter()
.filter(|spec| slash_command_category(spec.name) == category)
{
lines.push(format_slash_command_help_line(spec));
}
lines.push(String::new());
}
lines
.into_iter()
.rev()
.skip_while(String::is_empty)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SlashCommandResult {
pub message: String,
pub session: Session,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginsCommandResult {
pub message: String,
pub reload_runtime: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum DefinitionSource {
ProjectCodex,
ProjectClaude,
UserCodexHome,
UserCodex,
UserClaude,
}
impl DefinitionSource {
fn label(self) -> &'static str {
match self {
Self::ProjectCodex => "Project (.codex)",
Self::ProjectClaude => "Project (.claude)",
Self::UserCodexHome => "User ($CODEX_HOME)",
Self::UserCodex => "User (~/.codex)",
Self::UserClaude => "User (~/.claude)",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct AgentSummary {
name: String,
description: Option<String>,
model: Option<String>,
reasoning_effort: Option<String>,
source: DefinitionSource,
shadowed_by: Option<DefinitionSource>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SkillSummary {
name: String,
description: Option<String>,
source: DefinitionSource,
shadowed_by: Option<DefinitionSource>,
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,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InstalledSkill {
invocation_name: String,
display_name: Option<String>,
source: PathBuf,
registry_root: PathBuf,
installed_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SkillInstallSource {
Directory { root: PathBuf, prompt_path: PathBuf },
MarkdownFile { path: PathBuf },
}
#[allow(clippy::too_many_lines)]
pub fn handle_plugins_slash_command(
action: Option<&str>,
target: Option<&str>,
manager: &mut PluginManager,
) -> Result<PluginsCommandResult, PluginError> {
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 <path>".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 <name>".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 <name>".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 <plugin-id>".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 <plugin-id>".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<String> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_agents_usage(None),
_ => render_agents_usage(Some(&help_path.join(" "))),
});
}
}
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(args) if is_help_arg(args) => Ok(render_agents_usage(None)),
Some(args) => Ok(render_agents_usage(Some(args))),
}
}
pub fn handle_mcp_slash_command(
args: Option<&str>,
cwd: &Path,
) -> Result<String, runtime::ConfigError> {
let loader = ConfigLoader::default_for(cwd);
render_mcp_report_for(&loader, cwd, args)
}
pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_skills_usage(None),
["install", ..] => render_skills_usage(Some("install")),
_ => render_skills_usage(Some(&help_path.join(" "))),
});
}
}
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("install") => Ok(render_skills_usage(Some("install"))),
Some(args) if args.starts_with("install ") => {
let target = args["install ".len()..].trim();
if target.is_empty() {
return Ok(render_skills_usage(Some("install")));
}
let install = install_skill(target, cwd)?;
Ok(render_skill_install_report(&install))
}
Some(args) if is_help_arg(args) => Ok(render_skills_usage(None)),
Some(args) => Ok(render_skills_usage(Some(args))),
}
}
fn render_mcp_report_for(
loader: &ConfigLoader,
cwd: &Path,
args: Option<&str>,
) -> Result<String, runtime::ConfigError> {
if let Some(args) = normalize_optional_args(args) {
if let Some(help_path) = help_path_from_args(args) {
return Ok(match help_path.as_slice() {
[] => render_mcp_usage(None),
["show", ..] => render_mcp_usage(Some("show")),
_ => render_mcp_usage(Some(&help_path.join(" "))),
});
}
}
match normalize_optional_args(args) {
None | Some("list") => {
let runtime_config = loader.load()?;
Ok(render_mcp_summary_report(
cwd,
runtime_config.mcp().servers(),
))
}
Some(args) if is_help_arg(args) => Ok(render_mcp_usage(None)),
Some("show") => Ok(render_mcp_usage(Some("show"))),
Some(args) if args.split_whitespace().next() == Some("show") => {
let mut parts = args.split_whitespace();
let _ = parts.next();
let Some(server_name) = parts.next() else {
return Ok(render_mcp_usage(Some("show")));
};
if parts.next().is_some() {
return Ok(render_mcp_usage(Some(args)));
}
let runtime_config = loader.load()?;
Ok(render_mcp_server_report(
cwd,
server_name,
runtime_config.mcp().get(server_name),
))
}
Some(args) => Ok(render_mcp_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<PluginSummary, PluginError> {
let mut matches = manager
.list_installed_plugins()?
.into_iter()
.filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
.collect::<Vec<_>>();
match matches.len() {
1 => Ok(matches.remove(0)),
0 => Err(PluginError::NotFound(format!(
"plugin `{target}` is not installed or discoverable"
))),
_ => Err(PluginError::InvalidManifest(format!(
"plugin name `{target}` is ambiguous; use the full plugin id"
))),
}
}
fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
let mut roots = Vec::new();
for ancestor in cwd.ancestors() {
push_unique_root(
&mut roots,
DefinitionSource::ProjectCodex,
ancestor.join(".codex").join(leaf),
);
push_unique_root(
&mut roots,
DefinitionSource::ProjectClaude,
ancestor.join(".claude").join(leaf),
);
}
if let Ok(codex_home) = env::var("CODEX_HOME") {
push_unique_root(
&mut roots,
DefinitionSource::UserCodexHome,
PathBuf::from(codex_home).join(leaf),
);
}
if let Some(home) = env::var_os("HOME") {
let home = PathBuf::from(home);
push_unique_root(
&mut roots,
DefinitionSource::UserCodex,
home.join(".codex").join(leaf),
);
push_unique_root(
&mut roots,
DefinitionSource::UserClaude,
home.join(".claude").join(leaf),
);
}
roots
}
fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
let mut roots = Vec::new();
for ancestor in cwd.ancestors() {
push_unique_skill_root(
&mut roots,
DefinitionSource::ProjectCodex,
ancestor.join(".codex").join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::ProjectClaude,
ancestor.join(".claude").join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::ProjectCodex,
ancestor.join(".codex").join("commands"),
SkillOrigin::LegacyCommandsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::ProjectClaude,
ancestor.join(".claude").join("commands"),
SkillOrigin::LegacyCommandsDir,
);
}
if let Ok(codex_home) = env::var("CODEX_HOME") {
let codex_home = PathBuf::from(codex_home);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserCodexHome,
codex_home.join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserCodexHome,
codex_home.join("commands"),
SkillOrigin::LegacyCommandsDir,
);
}
if let Some(home) = env::var_os("HOME") {
let home = PathBuf::from(home);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserCodex,
home.join(".codex").join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserCodex,
home.join(".codex").join("commands"),
SkillOrigin::LegacyCommandsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserClaude,
home.join(".claude").join("skills"),
SkillOrigin::SkillsDir,
);
push_unique_skill_root(
&mut roots,
DefinitionSource::UserClaude,
home.join(".claude").join("commands"),
SkillOrigin::LegacyCommandsDir,
);
}
roots
}
fn install_skill(source: &str, cwd: &Path) -> std::io::Result<InstalledSkill> {
let registry_root = default_skill_install_root()?;
install_skill_into(source, cwd, &registry_root)
}
fn install_skill_into(
source: &str,
cwd: &Path,
registry_root: &Path,
) -> std::io::Result<InstalledSkill> {
let source = resolve_skill_install_source(source, cwd)?;
let prompt_path = source.prompt_path();
let contents = fs::read_to_string(prompt_path)?;
let display_name = parse_skill_frontmatter(&contents).0;
let invocation_name = derive_skill_install_name(&source, display_name.as_deref())?;
let installed_path = registry_root.join(&invocation_name);
if installed_path.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!(
"skill '{invocation_name}' is already installed at {}",
installed_path.display()
),
));
}
fs::create_dir_all(&installed_path)?;
let install_result = match &source {
SkillInstallSource::Directory { root, .. } => {
copy_directory_contents(root, &installed_path)
}
SkillInstallSource::MarkdownFile { path } => {
fs::copy(path, installed_path.join("SKILL.md")).map(|_| ())
}
};
if let Err(error) = install_result {
let _ = fs::remove_dir_all(&installed_path);
return Err(error);
}
Ok(InstalledSkill {
invocation_name,
display_name,
source: source.report_path().to_path_buf(),
registry_root: registry_root.to_path_buf(),
installed_path,
})
}
fn default_skill_install_root() -> std::io::Result<PathBuf> {
if let Ok(codex_home) = env::var("CODEX_HOME") {
return Ok(PathBuf::from(codex_home).join("skills"));
}
if let Some(home) = env::var_os("HOME") {
return Ok(PathBuf::from(home).join(".codex").join("skills"));
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"unable to resolve a skills install root; set CODEX_HOME or HOME",
))
}
fn resolve_skill_install_source(source: &str, cwd: &Path) -> std::io::Result<SkillInstallSource> {
let candidate = PathBuf::from(source);
let source = if candidate.is_absolute() {
candidate
} else {
cwd.join(candidate)
};
let source = fs::canonicalize(&source)?;
if source.is_dir() {
let prompt_path = source.join("SKILL.md");
if !prompt_path.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"skill directory '{}' must contain SKILL.md",
source.display()
),
));
}
return Ok(SkillInstallSource::Directory {
root: source,
prompt_path,
});
}
if source
.extension()
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
{
return Ok(SkillInstallSource::MarkdownFile { path: source });
}
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"skill source '{}' must be a directory with SKILL.md or a markdown file",
source.display()
),
))
}
fn derive_skill_install_name(
source: &SkillInstallSource,
declared_name: Option<&str>,
) -> std::io::Result<String> {
for candidate in [declared_name, source.fallback_name().as_deref()] {
if let Some(candidate) = candidate.and_then(sanitize_skill_invocation_name) {
return Ok(candidate);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"unable to derive an installable invocation name from '{}'",
source.report_path().display()
),
))
}
fn sanitize_skill_invocation_name(candidate: &str) -> Option<String> {
let trimmed = candidate
.trim()
.trim_start_matches('/')
.trim_start_matches('$');
if trimmed.is_empty() {
return None;
}
let mut sanitized = String::new();
let mut last_was_separator = false;
for ch in trimmed.chars() {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
sanitized.push(ch.to_ascii_lowercase());
last_was_separator = false;
} else if (ch.is_whitespace() || matches!(ch, '/' | '\\'))
&& !last_was_separator
&& !sanitized.is_empty()
{
sanitized.push('-');
last_was_separator = true;
}
}
let sanitized = sanitized
.trim_matches(|ch| matches!(ch, '-' | '_' | '.'))
.to_string();
(!sanitized.is_empty()).then_some(sanitized)
}
fn copy_directory_contents(source: &Path, destination: &Path) -> std::io::Result<()> {
for entry in fs::read_dir(source)? {
let entry = entry?;
let entry_type = entry.file_type()?;
let destination_path = destination.join(entry.file_name());
if entry_type.is_dir() {
fs::create_dir_all(&destination_path)?;
copy_directory_contents(&entry.path(), &destination_path)?;
} else {
fs::copy(entry.path(), destination_path)?;
}
}
Ok(())
}
impl SkillInstallSource {
fn prompt_path(&self) -> &Path {
match self {
Self::Directory { prompt_path, .. } => prompt_path,
Self::MarkdownFile { path } => path,
}
}
fn fallback_name(&self) -> Option<String> {
match self {
Self::Directory { root, .. } => root
.file_name()
.map(|name| name.to_string_lossy().to_string()),
Self::MarkdownFile { path } => path
.file_stem()
.map(|name| name.to_string_lossy().to_string()),
}
}
fn report_path(&self) -> &Path {
match self {
Self::Directory { root, .. } => root,
Self::MarkdownFile { path } => path,
}
}
}
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<SkillRoot>,
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<Vec<AgentSummary>> {
let mut agents = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::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<Vec<SkillSummary>> {
let mut skills = Vec::new();
let mut active_sources = BTreeMap::<String, DefinitionSource>::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<String> {
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<String>, Option<String>) {
let mut lines = contents.lines();
if lines.next().map(str::trim) != Some("---") {
return (None, None);
}
let mut name = None;
let mut description = None;
for line in lines {
let trimmed = line.trim();
if trimmed == "---" {
break;
}
if let Some(value) = trimmed.strip_prefix("name:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
name = Some(value);
}
continue;
}
if let Some(value) = trimmed.strip_prefix("description:") {
let value = unquote_frontmatter_value(value.trim());
if !value.is_empty() {
description = Some(value);
}
}
}
(name, description)
}
fn unquote_frontmatter_value(value: &str) -> String {
value
.strip_prefix('"')
.and_then(|trimmed| trimmed.strip_suffix('"'))
.or_else(|| {
value
.strip_prefix('\'')
.and_then(|trimmed| trimmed.strip_suffix('\''))
})
.unwrap_or(value)
.trim()
.to_string()
}
fn render_agents_report(agents: &[AgentSummary]) -> String {
if agents.is_empty() {
return "No agents found.".to_string();
}
let total_active = agents
.iter()
.filter(|agent| agent.shadowed_by.is_none())
.count();
let mut lines = vec![
"Agents".to_string(),
format!(" {total_active} active agents"),
String::new(),
];
for source in [
DefinitionSource::ProjectCodex,
DefinitionSource::ProjectClaude,
DefinitionSource::UserCodexHome,
DefinitionSource::UserCodex,
DefinitionSource::UserClaude,
] {
let group = agents
.iter()
.filter(|agent| agent.source == source)
.collect::<Vec<_>>();
if group.is_empty() {
continue;
}
lines.push(format!("{}:", source.label()));
for agent in group {
let detail = agent_detail(agent);
match agent.shadowed_by {
Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
None => lines.push(format!(" {detail}")),
}
}
lines.push(String::new());
}
lines.join("\n").trim_end().to_string()
}
fn agent_detail(agent: &AgentSummary) -> String {
let mut parts = vec![agent.name.clone()];
if let Some(description) = &agent.description {
parts.push(description.clone());
}
if let Some(model) = &agent.model {
parts.push(model.clone());
}
if let Some(reasoning) = &agent.reasoning_effort {
parts.push(reasoning.clone());
}
parts.join(" · ")
}
fn render_skills_report(skills: &[SkillSummary]) -> String {
if skills.is_empty() {
return "No skills found.".to_string();
}
let total_active = skills
.iter()
.filter(|skill| skill.shadowed_by.is_none())
.count();
let mut lines = vec![
"Skills".to_string(),
format!(" {total_active} available skills"),
String::new(),
];
for source in [
DefinitionSource::ProjectCodex,
DefinitionSource::ProjectClaude,
DefinitionSource::UserCodexHome,
DefinitionSource::UserCodex,
DefinitionSource::UserClaude,
] {
let group = skills
.iter()
.filter(|skill| skill.source == source)
.collect::<Vec<_>>();
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 render_skill_install_report(skill: &InstalledSkill) -> String {
let mut lines = vec![
"Skills".to_string(),
format!(" Result installed {}", skill.invocation_name),
format!(" Invoke as ${}", skill.invocation_name),
];
if let Some(display_name) = &skill.display_name {
lines.push(format!(" Display name {display_name}"));
}
lines.push(format!(" Source {}", skill.source.display()));
lines.push(format!(
" Registry {}",
skill.registry_root.display()
));
lines.push(format!(
" Installed path {}",
skill.installed_path.display()
));
lines.join("\n")
}
fn render_mcp_summary_report(
cwd: &Path,
servers: &BTreeMap<String, ScopedMcpServerConfig>,
) -> String {
let mut lines = vec![
"MCP".to_string(),
format!(" Working directory {}", cwd.display()),
format!(" Configured servers {}", servers.len()),
];
if servers.is_empty() {
lines.push(" No MCP servers configured.".to_string());
return lines.join("\n");
}
lines.push(String::new());
for (name, server) in servers {
lines.push(format!(
" {name:<16} {transport:<13} {scope:<7} {summary}",
transport = mcp_transport_label(&server.config),
scope = config_source_label(server.scope),
summary = mcp_server_summary(&server.config)
));
}
lines.join("\n")
}
fn render_mcp_server_report(
cwd: &Path,
server_name: &str,
server: Option<&ScopedMcpServerConfig>,
) -> String {
let Some(server) = server else {
return format!(
"MCP\n Working directory {}\n Result server `{server_name}` is not configured",
cwd.display()
);
};
let mut lines = vec![
"MCP".to_string(),
format!(" Working directory {}", cwd.display()),
format!(" Name {server_name}"),
format!(" Scope {}", config_source_label(server.scope)),
format!(
" Transport {}",
mcp_transport_label(&server.config)
),
];
match &server.config {
McpServerConfig::Stdio(config) => {
lines.push(format!(" Command {}", config.command));
lines.push(format!(
" Args {}",
format_optional_list(&config.args)
));
lines.push(format!(
" Env keys {}",
format_optional_keys(config.env.keys().cloned().collect())
));
lines.push(format!(
" Tool timeout {}",
config
.tool_call_timeout_ms
.map_or_else(|| "<default>".to_string(), |value| format!("{value} ms"))
));
}
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
lines.push(format!(" URL {}", config.url));
lines.push(format!(
" Header keys {}",
format_optional_keys(config.headers.keys().cloned().collect())
));
lines.push(format!(
" Header helper {}",
config.headers_helper.as_deref().unwrap_or("<none>")
));
lines.push(format!(
" OAuth {}",
format_mcp_oauth(config.oauth.as_ref())
));
}
McpServerConfig::Ws(config) => {
lines.push(format!(" URL {}", config.url));
lines.push(format!(
" Header keys {}",
format_optional_keys(config.headers.keys().cloned().collect())
));
lines.push(format!(
" Header helper {}",
config.headers_helper.as_deref().unwrap_or("<none>")
));
}
McpServerConfig::Sdk(config) => {
lines.push(format!(" SDK name {}", config.name));
}
McpServerConfig::ManagedProxy(config) => {
lines.push(format!(" URL {}", config.url));
lines.push(format!(" Proxy id {}", config.id));
}
}
lines.join("\n")
}
fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
args.map(str::trim).filter(|value| !value.is_empty())
}
fn is_help_arg(arg: &str) -> bool {
matches!(arg, "help" | "-h" | "--help")
}
fn help_path_from_args(args: &str) -> Option<Vec<&str>> {
let parts = args.split_whitespace().collect::<Vec<_>>();
let help_index = parts.iter().position(|part| is_help_arg(part))?;
Some(parts[..help_index].to_vec())
}
fn render_agents_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Agents".to_string(),
" Usage /agents [list|help]".to_string(),
" Direct CLI claw agents".to_string(),
" Sources .codex/agents, .claude/agents, $CODEX_HOME/agents".to_string(),
];
if let Some(args) = unexpected {
lines.push(format!(" Unexpected {args}"));
}
lines.join("\n")
}
fn render_skills_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"Skills".to_string(),
" Usage /skills [list|install <path>|help]".to_string(),
" Direct CLI claw skills [list|install <path>|help]".to_string(),
" Install root $CODEX_HOME/skills or ~/.codex/skills".to_string(),
" Sources .codex/skills, .claude/skills, legacy /commands".to_string(),
];
if let Some(args) = unexpected {
lines.push(format!(" Unexpected {args}"));
}
lines.join("\n")
}
fn render_mcp_usage(unexpected: Option<&str>) -> String {
let mut lines = vec![
"MCP".to_string(),
" Usage /mcp [list|show <server>|help]".to_string(),
" Direct CLI claw mcp [list|show <server>|help]".to_string(),
" Sources .claw/settings.json, .claw/settings.local.json".to_string(),
];
if let Some(args) = unexpected {
lines.push(format!(" Unexpected {args}"));
}
lines.join("\n")
}
fn config_source_label(source: ConfigSource) -> &'static str {
match source {
ConfigSource::User => "user",
ConfigSource::Project => "project",
ConfigSource::Local => "local",
}
}
fn mcp_transport_label(config: &McpServerConfig) -> &'static str {
match config {
McpServerConfig::Stdio(_) => "stdio",
McpServerConfig::Sse(_) => "sse",
McpServerConfig::Http(_) => "http",
McpServerConfig::Ws(_) => "ws",
McpServerConfig::Sdk(_) => "sdk",
McpServerConfig::ManagedProxy(_) => "managed-proxy",
}
}
fn mcp_server_summary(config: &McpServerConfig) -> String {
match config {
McpServerConfig::Stdio(config) => {
if config.args.is_empty() {
config.command.clone()
} else {
format!("{} {}", config.command, config.args.join(" "))
}
}
McpServerConfig::Sse(config) | McpServerConfig::Http(config) => config.url.clone(),
McpServerConfig::Ws(config) => config.url.clone(),
McpServerConfig::Sdk(config) => config.name.clone(),
McpServerConfig::ManagedProxy(config) => format!("{} ({})", config.id, config.url),
}
}
fn format_optional_list(values: &[String]) -> String {
if values.is_empty() {
"<none>".to_string()
} else {
values.join(" ")
}
}
fn format_optional_keys(mut keys: Vec<String>) -> String {
if keys.is_empty() {
return "<none>".to_string();
}
keys.sort();
keys.join(", ")
}
fn format_mcp_oauth(oauth: Option<&McpOAuthConfig>) -> String {
let Some(oauth) = oauth else {
return "<none>".to_string();
};
let mut parts = Vec::new();
if let Some(client_id) = &oauth.client_id {
parts.push(format!("client_id={client_id}"));
}
if let Some(port) = oauth.callback_port {
parts.push(format!("callback_port={port}"));
}
if let Some(url) = &oauth.auth_server_metadata_url {
parts.push(format!("metadata_url={url}"));
}
if let Some(xaa) = oauth.xaa {
parts.push(format!("xaa={xaa}"));
}
if parts.is_empty() {
"enabled".to_string()
} else {
parts.join(", ")
}
}
#[must_use]
pub fn handle_slash_command(
input: &str,
session: &Session,
compaction: CompactionConfig,
) -> Option<SlashCommandResult> {
let command = match SlashCommand::parse(input) {
Ok(Some(command)) => command,
Ok(None) => return None,
Err(error) => {
return Some(SlashCommandResult {
message: error.to_string(),
session: session.clone(),
});
}
};
match command {
SlashCommand::Compact => {
let result = compact_session(session, compaction);
let message = if result.removed_message_count == 0 {
"Compaction skipped: session is below the compaction threshold.".to_string()
} else {
format!(
"Compacted {} messages into a resumable system summary.",
result.removed_message_count
)
};
Some(SlashCommandResult {
message,
session: result.compacted_session,
})
}
SlashCommand::Help => Some(SlashCommandResult {
message: render_slash_command_help(),
session: session.clone(),
}),
SlashCommand::Status
| SlashCommand::Bughunter { .. }
| SlashCommand::Commit
| SlashCommand::Pr { .. }
| SlashCommand::Issue { .. }
| SlashCommand::Ultraplan { .. }
| SlashCommand::Teleport { .. }
| SlashCommand::DebugToolCall
| SlashCommand::Sandbox
| SlashCommand::Model { .. }
| SlashCommand::Permissions { .. }
| SlashCommand::Clear { .. }
| SlashCommand::Cost
| SlashCommand::Resume { .. }
| SlashCommand::Config { .. }
| SlashCommand::Mcp { .. }
| SlashCommand::Memory
| SlashCommand::Init
| SlashCommand::Diff
| SlashCommand::Version
| SlashCommand::Export { .. }
| SlashCommand::Session { .. }
| SlashCommand::Plugins { .. }
| SlashCommand::Agents { .. }
| SlashCommand::Skills { .. }
| SlashCommand::Doctor
| SlashCommand::Login
| SlashCommand::Logout
| SlashCommand::Vim
| SlashCommand::Upgrade
| SlashCommand::Stats
| SlashCommand::Share
| SlashCommand::Feedback
| SlashCommand::Files
| SlashCommand::Fast
| SlashCommand::Exit
| SlashCommand::Summary
| SlashCommand::Desktop
| SlashCommand::Brief
| SlashCommand::Advisor
| SlashCommand::Stickers
| SlashCommand::Insights
| SlashCommand::Thinkback
| SlashCommand::ReleaseNotes
| SlashCommand::SecurityReview
| SlashCommand::Keybindings
| SlashCommand::PrivacySettings
| SlashCommand::Plan { .. }
| SlashCommand::Review { .. }
| SlashCommand::Tasks { .. }
| SlashCommand::Theme { .. }
| SlashCommand::Voice { .. }
| SlashCommand::Usage { .. }
| SlashCommand::Rename { .. }
| SlashCommand::Copy { .. }
| SlashCommand::Hooks { .. }
| SlashCommand::Context { .. }
| SlashCommand::Color { .. }
| SlashCommand::Effort { .. }
| SlashCommand::Branch { .. }
| SlashCommand::Rewind { .. }
| SlashCommand::Ide { .. }
| SlashCommand::Tag { .. }
| SlashCommand::OutputStyle { .. }
| SlashCommand::AddDir { .. }
| SlashCommand::Unknown(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::{
handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
render_slash_command_help, render_slash_command_help_detail,
resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
};
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
use runtime::{
CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos();
std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
}
fn write_external_plugin(root: &Path, name: &str, version: &str) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
),
)
.expect("write manifest");
}
fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
fs::write(
root.join(".claude-plugin").join("plugin.json"),
format!(
"{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}",
if default_enabled { "true" } else { "false" }
),
)
.expect("write bundled manifest");
}
fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
fs::create_dir_all(root).expect("agent root");
fs::write(
root.join(format!("{name}.toml")),
format!(
"name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
),
)
.expect("write agent");
}
fn write_skill(root: &Path, name: &str, description: &str) {
let skill_root = root.join(name);
fs::create_dir_all(&skill_root).expect("skill root");
fs::write(
skill_root.join("SKILL.md"),
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
)
.expect("write skill");
}
fn write_legacy_command(root: &Path, name: &str, description: &str) {
fs::create_dir_all(root).expect("commands root");
fs::write(
root.join(format!("{name}.md")),
format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
)
.expect("write command");
}
fn parse_error_message(input: &str) -> String {
SlashCommand::parse(input)
.expect_err("slash command should be rejected")
.to_string()
}
#[allow(clippy::too_many_lines)]
#[test]
fn parses_supported_slash_commands() {
assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
assert_eq!(
SlashCommand::parse(" /status "),
Ok(Some(SlashCommand::Status))
);
assert_eq!(
SlashCommand::parse("/sandbox"),
Ok(Some(SlashCommand::Sandbox))
);
assert_eq!(
SlashCommand::parse("/bughunter runtime"),
Ok(Some(SlashCommand::Bughunter {
scope: Some("runtime".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/commit"),
Ok(Some(SlashCommand::Commit))
);
assert_eq!(
SlashCommand::parse("/pr ready for review"),
Ok(Some(SlashCommand::Pr {
context: Some("ready for review".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/issue flaky test"),
Ok(Some(SlashCommand::Issue {
context: Some("flaky test".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/ultraplan ship both features"),
Ok(Some(SlashCommand::Ultraplan {
task: Some("ship both features".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/teleport conversation.rs"),
Ok(Some(SlashCommand::Teleport {
target: Some("conversation.rs".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/debug-tool-call"),
Ok(Some(SlashCommand::DebugToolCall))
);
assert_eq!(
SlashCommand::parse("/bughunter runtime"),
Ok(Some(SlashCommand::Bughunter {
scope: Some("runtime".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/commit"),
Ok(Some(SlashCommand::Commit))
);
assert_eq!(
SlashCommand::parse("/pr ready for review"),
Ok(Some(SlashCommand::Pr {
context: Some("ready for review".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/issue flaky test"),
Ok(Some(SlashCommand::Issue {
context: Some("flaky test".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/ultraplan ship both features"),
Ok(Some(SlashCommand::Ultraplan {
task: Some("ship both features".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/teleport conversation.rs"),
Ok(Some(SlashCommand::Teleport {
target: Some("conversation.rs".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/debug-tool-call"),
Ok(Some(SlashCommand::DebugToolCall))
);
assert_eq!(
SlashCommand::parse("/model claude-opus"),
Ok(Some(SlashCommand::Model {
model: Some("claude-opus".to_string()),
}))
);
assert_eq!(
SlashCommand::parse("/model"),
Ok(Some(SlashCommand::Model { model: None }))
);
assert_eq!(
SlashCommand::parse("/permissions read-only"),
Ok(Some(SlashCommand::Permissions {
mode: Some("read-only".to_string()),
}))
);
assert_eq!(
SlashCommand::parse("/clear"),
Ok(Some(SlashCommand::Clear { confirm: false }))
);
assert_eq!(
SlashCommand::parse("/clear --confirm"),
Ok(Some(SlashCommand::Clear { confirm: true }))
);
assert_eq!(SlashCommand::parse("/cost"), Ok(Some(SlashCommand::Cost)));
assert_eq!(
SlashCommand::parse("/resume session.json"),
Ok(Some(SlashCommand::Resume {
session_path: Some("session.json".to_string()),
}))
);
assert_eq!(
SlashCommand::parse("/config"),
Ok(Some(SlashCommand::Config { section: None }))
);
assert_eq!(
SlashCommand::parse("/config env"),
Ok(Some(SlashCommand::Config {
section: Some("env".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/mcp"),
Ok(Some(SlashCommand::Mcp {
action: None,
target: None
}))
);
assert_eq!(
SlashCommand::parse("/mcp show remote"),
Ok(Some(SlashCommand::Mcp {
action: Some("show".to_string()),
target: Some("remote".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/memory"),
Ok(Some(SlashCommand::Memory))
);
assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init)));
assert_eq!(SlashCommand::parse("/diff"), Ok(Some(SlashCommand::Diff)));
assert_eq!(
SlashCommand::parse("/version"),
Ok(Some(SlashCommand::Version))
);
assert_eq!(
SlashCommand::parse("/export notes.txt"),
Ok(Some(SlashCommand::Export {
path: Some("notes.txt".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/session switch abc123"),
Ok(Some(SlashCommand::Session {
action: Some("switch".to_string()),
target: Some("abc123".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/plugins install demo"),
Ok(Some(SlashCommand::Plugins {
action: Some("install".to_string()),
target: Some("demo".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/plugins list"),
Ok(Some(SlashCommand::Plugins {
action: Some("list".to_string()),
target: None
}))
);
assert_eq!(
SlashCommand::parse("/plugins enable demo"),
Ok(Some(SlashCommand::Plugins {
action: Some("enable".to_string()),
target: Some("demo".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/skills install ./fixtures/help-skill"),
Ok(Some(SlashCommand::Skills {
args: Some("install ./fixtures/help-skill".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/plugins disable demo"),
Ok(Some(SlashCommand::Plugins {
action: Some("disable".to_string()),
target: Some("demo".to_string())
}))
);
assert_eq!(
SlashCommand::parse("/session fork incident-review"),
Ok(Some(SlashCommand::Session {
action: Some("fork".to_string()),
target: Some("incident-review".to_string())
}))
);
}
#[test]
fn rejects_unexpected_arguments_for_no_arg_commands() {
// given
let input = "/compact now";
// when
let error = parse_error_message(input);
// then
assert!(error.contains("Unexpected arguments for /compact."));
assert!(error.contains(" Usage /compact"));
assert!(error.contains(" Summary Compact local session history"));
}
#[test]
fn rejects_invalid_argument_values() {
// given
let input = "/permissions admin";
// when
let error = parse_error_message(input);
// then
assert!(error.contains(
"Unsupported /permissions mode 'admin'. Use read-only, workspace-write, or danger-full-access."
));
assert!(error.contains(
" Usage /permissions [read-only|workspace-write|danger-full-access]"
));
}
#[test]
fn rejects_missing_required_arguments() {
// given
let input = "/teleport";
// when
let error = parse_error_message(input);
// then
assert!(error.contains("Usage: /teleport <symbol-or-path>"));
assert!(error.contains(" Category Discovery & debugging"));
}
#[test]
fn rejects_invalid_session_and_plugin_shapes() {
// given
let session_input = "/session switch";
let plugin_input = "/plugins list extra";
// when
let session_error = parse_error_message(session_input);
let plugin_error = parse_error_message(plugin_input);
// then
assert!(session_error.contains("Usage: /session switch <session-id>"));
assert!(session_error.contains("/session"));
assert!(plugin_error.contains("Usage: /plugin list"));
assert!(plugin_error.contains("Aliases /plugins, /marketplace"));
}
#[test]
fn rejects_invalid_agents_and_skills_arguments() {
// given
let agents_input = "/agents show planner";
let skills_input = "/skills show help";
// when
let agents_error = parse_error_message(agents_input);
let skills_error = parse_error_message(skills_input);
// then
assert!(agents_error.contains(
"Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
));
assert!(agents_error.contains(" Usage /agents [list|help]"));
assert!(skills_error.contains(
"Unexpected arguments for /skills: show help. Use /skills, /skills list, /skills install <path>, or /skills help."
));
assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
}
#[test]
fn rejects_invalid_mcp_arguments() {
let show_error = parse_error_message("/mcp show alpha beta");
assert!(show_error.contains("Unexpected arguments for /mcp show."));
assert!(show_error.contains(" Usage /mcp show <server>"));
let action_error = parse_error_message("/mcp inspect alpha");
assert!(action_error
.contains("Unknown /mcp action 'inspect'. Use list, show <server>, or help."));
assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
}
#[test]
fn renders_help_from_shared_specs() {
let help = render_slash_command_help();
assert!(help.contains("Start here /status, /diff, /agents, /skills, /commit"));
assert!(help.contains("[resume] also works with --resume SESSION.jsonl"));
assert!(help.contains("Session & visibility"));
assert!(help.contains("Workspace & git"));
assert!(help.contains("Discovery & debugging"));
assert!(help.contains("Analysis & automation"));
assert!(help.contains("/help"));
assert!(help.contains("/status"));
assert!(help.contains("/sandbox"));
assert!(help.contains("/compact"));
assert!(help.contains("/bughunter [scope]"));
assert!(help.contains("/commit"));
assert!(help.contains("/pr [context]"));
assert!(help.contains("/issue [context]"));
assert!(help.contains("/ultraplan [task]"));
assert!(help.contains("/teleport <symbol-or-path>"));
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 <session-path>"));
assert!(help.contains("/config [env|hooks|model|plugins]"));
assert!(help.contains("/mcp [list|show <server>|help]"));
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 <session-id>|fork [branch-name]]"));
assert!(help.contains("/sandbox"));
assert!(help.contains(
"/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
));
assert!(help.contains("aliases: /plugins, /marketplace"));
assert!(help.contains("/agents [list|help]"));
assert!(help.contains("/skills [list|install <path>|help]"));
assert_eq!(slash_command_specs().len(), 141);
assert!(resume_supported_slash_commands().len() >= 39);
}
#[test]
fn renders_per_command_help_detail() {
// given
let command = "plugins";
// when
let help = render_slash_command_help_detail(command).expect("detail help should exist");
// then
assert!(help.contains("/plugin"));
assert!(help.contains("Summary Manage Claw Code plugins"));
assert!(help.contains("Aliases /plugins, /marketplace"));
assert!(help.contains("Category Workspace & git"));
}
#[test]
fn renders_per_command_help_detail_for_mcp() {
let help = render_slash_command_help_detail("mcp").expect("detail help should exist");
assert!(help.contains("/mcp"));
assert!(help.contains("Summary Inspect configured MCP servers"));
assert!(help.contains("Category Discovery & debugging"));
assert!(help.contains("Resume Supported with --resume SESSION.jsonl"));
}
#[test]
fn validate_slash_command_input_rejects_extra_single_value_arguments() {
// given
let session_input = "/session switch current next";
let plugin_input = "/plugin enable demo extra";
// when
let session_error = validate_slash_command_input(session_input)
.expect_err("session input should be rejected")
.to_string();
let plugin_error = validate_slash_command_input(plugin_input)
.expect_err("plugin input should be rejected")
.to_string();
// then
assert!(session_error.contains("Unexpected arguments for /session switch."));
assert!(session_error.contains(" Usage /session switch <session-id>"));
assert!(plugin_error.contains("Unexpected arguments for /plugin enable."));
assert!(plugin_error.contains(" Usage /plugin enable <name>"));
}
#[test]
fn suggests_closest_slash_commands_for_typos_and_aliases() {
let suggestions = suggest_slash_commands("stats", 3);
assert!(suggestions.contains(&"/stats".to_string()));
assert!(suggestions.contains(&"/status".to_string()));
assert!(suggestions.len() <= 3);
let plugin_suggestions = suggest_slash_commands("/plugns", 3);
assert!(plugin_suggestions.contains(&"/plugin".to_string()));
assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
}
#[test]
fn compacts_sessions_via_slash_command() {
let mut session = Session::new();
session.messages = vec![
ConversationMessage::user_text("a ".repeat(200)),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "b ".repeat(200),
}]),
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
ConversationMessage::assistant(vec![ContentBlock::Text {
text: "recent".to_string(),
}]),
];
let result = handle_slash_command(
"/compact",
&session,
CompactionConfig {
preserve_recent_messages: 2,
max_estimated_tokens: 1,
},
)
.expect("slash command should be handled");
assert!(result.message.contains("Compacted 2 messages"));
assert_eq!(result.session.messages[0].role, MessageRole::System);
}
#[test]
fn help_command_is_non_mutating() {
let session = Session::new();
let result = handle_slash_command("/help", &session, CompactionConfig::default())
.expect("help command should be handled");
assert_eq!(result.session, session);
assert!(result.message.contains("Slash commands"));
}
#[test]
fn ignores_unknown_or_runtime_bound_slash_commands() {
let session = Session::new();
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
);
assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
);
assert!(
handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
);
assert!(
handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
.is_none()
);
assert!(
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
);
assert!(handle_slash_command(
"/permissions read-only",
&session,
CompactionConfig::default()
)
.is_none());
assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
.is_none()
);
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command(
"/resume session.json",
&session,
CompactionConfig::default()
)
.is_none());
assert!(handle_slash_command(
"/resume session.jsonl",
&session,
CompactionConfig::default()
)
.is_none());
assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
);
assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
assert!(
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
.is_none()
);
assert!(
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
);
assert!(
handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
);
}
#[test]
fn renders_plugins_report_with_name_version_and_status() {
let rendered = render_plugins_report(&[
PluginSummary {
metadata: PluginMetadata {
id: "demo@external".to_string(),
name: "demo".to_string(),
version: "1.2.3".to_string(),
description: "demo plugin".to_string(),
kind: PluginKind::External,
source: "demo".to_string(),
default_enabled: false,
root: None,
},
enabled: true,
},
PluginSummary {
metadata: PluginMetadata {
id: "sample@external".to_string(),
name: "sample".to_string(),
version: "0.9.0".to_string(),
description: "sample plugin".to_string(),
kind: PluginKind::External,
source: "sample".to_string(),
default_enabled: false,
root: None,
},
enabled: false,
},
]);
assert!(rendered.contains("demo"));
assert!(rendered.contains("v1.2.3"));
assert!(rendered.contains("enabled"));
assert!(rendered.contains("sample"));
assert!(rendered.contains("v0.9.0"));
assert!(rendered.contains("disabled"));
}
#[test]
fn lists_agents_from_project_and_user_roots() {
let workspace = temp_dir("agents-workspace");
let project_agents = workspace.join(".codex").join("agents");
let user_home = temp_dir("agents-home");
let user_agents = user_home.join(".codex").join("agents");
write_agent(
&project_agents,
"planner",
"Project planner",
"gpt-5.4",
"medium",
);
write_agent(
&user_agents,
"planner",
"User planner",
"gpt-5.4-mini",
"high",
);
write_agent(
&user_agents,
"verifier",
"Verification agent",
"gpt-5.4-mini",
"high",
);
let roots = vec![
(DefinitionSource::ProjectCodex, project_agents),
(DefinitionSource::UserCodex, user_agents),
];
let report =
render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load"));
assert!(report.contains("Agents"));
assert!(report.contains("2 active agents"));
assert!(report.contains("Project (.codex):"));
assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
assert!(report.contains("User (~/.codex):"));
assert!(report.contains("(shadowed by Project (.codex)) planner · User planner"));
assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn lists_skills_from_project_and_user_roots() {
let workspace = temp_dir("skills-workspace");
let project_skills = workspace.join(".codex").join("skills");
let project_commands = workspace.join(".claude").join("commands");
let user_home = temp_dir("skills-home");
let user_skills = user_home.join(".codex").join("skills");
write_skill(&project_skills, "plan", "Project planning guidance");
write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance");
write_skill(&user_skills, "plan", "User planning guidance");
write_skill(&user_skills, "help", "Help guidance");
let roots = vec![
SkillRoot {
source: DefinitionSource::ProjectCodex,
path: project_skills,
origin: SkillOrigin::SkillsDir,
},
SkillRoot {
source: DefinitionSource::ProjectClaude,
path: project_commands,
origin: SkillOrigin::LegacyCommandsDir,
},
SkillRoot {
source: DefinitionSource::UserCodex,
path: user_skills,
origin: SkillOrigin::SkillsDir,
},
];
let report =
render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
assert!(report.contains("Skills"));
assert!(report.contains("3 available skills"));
assert!(report.contains("Project (.codex):"));
assert!(report.contains("plan · Project planning guidance"));
assert!(report.contains("Project (.claude):"));
assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
assert!(report.contains("User (~/.codex):"));
assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
assert!(report.contains("help · Help guidance"));
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(user_home);
}
#[test]
fn agents_and_skills_usage_support_help_and_unexpected_args() {
let cwd = temp_dir("slash-usage");
let agents_help =
super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
assert!(agents_help.contains("Usage /agents [list|help]"));
assert!(agents_help.contains("Direct CLI claw agents"));
let agents_unexpected =
super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
assert!(agents_unexpected.contains("Unexpected show planner"));
let skills_help =
super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
assert!(skills_help.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_help.contains("Install root $CODEX_HOME/skills or ~/.codex/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"));
let skills_install_help = super::handle_skills_slash_command(Some("install --help"), &cwd)
.expect("nested skills help");
assert!(skills_install_help.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_install_help.contains("Unexpected install"));
let skills_unknown_help =
super::handle_skills_slash_command(Some("show --help"), &cwd).expect("skills help");
assert!(skills_unknown_help.contains("Usage /skills [list|install <path>|help]"));
assert!(skills_unknown_help.contains("Unexpected show"));
let _ = fs::remove_dir_all(cwd);
}
#[test]
fn mcp_usage_supports_help_and_unexpected_args() {
let cwd = temp_dir("mcp-usage");
let help = super::handle_mcp_slash_command(Some("help"), &cwd).expect("mcp help");
assert!(help.contains("Usage /mcp [list|show <server>|help]"));
assert!(help.contains("Direct CLI claw mcp [list|show <server>|help]"));
let unexpected =
super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
assert!(unexpected.contains("Unexpected show alpha beta"));
let nested_help =
super::handle_mcp_slash_command(Some("show --help"), &cwd).expect("mcp help");
assert!(nested_help.contains("Usage /mcp [list|show <server>|help]"));
assert!(nested_help.contains("Unexpected show"));
let unknown_help =
super::handle_mcp_slash_command(Some("inspect --help"), &cwd).expect("mcp usage");
assert!(unknown_help.contains("Usage /mcp [list|show <server>|help]"));
assert!(unknown_help.contains("Unexpected inspect"));
let _ = fs::remove_dir_all(cwd);
}
#[test]
fn renders_mcp_reports_from_loaded_config() {
let workspace = temp_dir("mcp-config-workspace");
let config_home = temp_dir("mcp-config-home");
fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir");
fs::create_dir_all(&config_home).expect("config home");
fs::write(
workspace.join(".claw").join("settings.json"),
r#"{
"mcpServers": {
"alpha": {
"command": "uvx",
"args": ["alpha-server"],
"env": {"ALPHA_TOKEN": "secret"},
"toolCallTimeoutMs": 1200
},
"remote": {
"type": "http",
"url": "https://remote.example/mcp",
"headers": {"Authorization": "Bearer secret"},
"headersHelper": "./bin/headers",
"oauth": {
"clientId": "remote-client",
"callbackPort": 7878
}
}
}
}"#,
)
.expect("write settings");
fs::write(
workspace.join(".claw").join("settings.local.json"),
r#"{
"mcpServers": {
"remote": {
"type": "ws",
"url": "wss://remote.example/mcp"
}
}
}"#,
)
.expect("write local settings");
let loader = ConfigLoader::new(&workspace, &config_home);
let list = super::render_mcp_report_for(&loader, &workspace, None)
.expect("mcp list report should render");
assert!(list.contains("Configured servers 2"));
assert!(list.contains("alpha"));
assert!(list.contains("stdio"));
assert!(list.contains("project"));
assert!(list.contains("uvx alpha-server"));
assert!(list.contains("remote"));
assert!(list.contains("ws"));
assert!(list.contains("local"));
assert!(list.contains("wss://remote.example/mcp"));
let show = super::render_mcp_report_for(&loader, &workspace, Some("show alpha"))
.expect("mcp show report should render");
assert!(show.contains("Name alpha"));
assert!(show.contains("Command uvx"));
assert!(show.contains("Args alpha-server"));
assert!(show.contains("Env keys ALPHA_TOKEN"));
assert!(show.contains("Tool timeout 1200 ms"));
let remote = super::render_mcp_report_for(&loader, &workspace, Some("show remote"))
.expect("mcp show remote report should render");
assert!(remote.contains("Transport ws"));
assert!(remote.contains("URL wss://remote.example/mcp"));
let missing = super::render_mcp_report_for(&loader, &workspace, Some("show missing"))
.expect("missing report should render");
assert!(missing.contains("server `missing` is not configured"));
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(config_home);
}
#[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_skill_into_user_registry_and_preserves_nested_files() {
let workspace = temp_dir("skills-install-workspace");
let source_root = workspace.join("source").join("help");
let install_root = temp_dir("skills-install-root");
write_skill(
source_root.parent().expect("parent"),
"help",
"Helpful skill",
);
let script_dir = source_root.join("scripts");
fs::create_dir_all(&script_dir).expect("script dir");
fs::write(script_dir.join("run.sh"), "#!/bin/sh\necho help\n").expect("write script");
let installed = super::install_skill_into(
source_root.to_str().expect("utf8 skill path"),
&workspace,
&install_root,
)
.expect("skill should install");
assert_eq!(installed.invocation_name, "help");
assert_eq!(installed.display_name.as_deref(), Some("help"));
assert!(installed.installed_path.ends_with(Path::new("help")));
assert!(installed.installed_path.join("SKILL.md").is_file());
assert!(installed
.installed_path
.join("scripts")
.join("run.sh")
.is_file());
let report = super::render_skill_install_report(&installed);
assert!(report.contains("Result installed help"));
assert!(report.contains("Invoke as $help"));
assert!(report.contains(&install_root.display().to_string()));
let roots = vec![SkillRoot {
source: DefinitionSource::UserCodexHome,
path: install_root.clone(),
origin: SkillOrigin::SkillsDir,
}];
let listed = render_skills_report(
&load_skills_from_roots(&roots).expect("installed skills should load"),
);
assert!(listed.contains("User ($CODEX_HOME):"));
assert!(listed.contains("help · Helpful skill"));
let _ = fs::remove_dir_all(workspace);
let _ = fs::remove_dir_all(install_root);
}
#[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);
}
}