Files
claw-code/rust/crates/commands/src/lib.rs

3238 lines
108 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,
},
];
#[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>,
},
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)
}
}
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())?,
},
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" => "Session & visibility",
"compact" | "clear" | "config" | "memory" | "init" | "diff" | "commit" | "pr" | "issue"
| "export" | "plugin" => "Workspace & git",
"agents" | "skills" | "teleport" | "debug-tool-call" | "mcp" => "Discovery & debugging",
"bughunter" | "ultraplan" => "Analysis & automation",
_ => "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> {
match normalize_optional_args(args) {
None | Some("list") => {
let roots = discover_definition_roots(cwd, "agents");
let agents = load_agents_from_roots(&roots)?;
Ok(render_agents_report(&agents))
}
Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)),
Some(args) => Ok(render_agents_usage(Some(args))),
}
}
pub fn handle_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> {
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("-h" | "--help" | "help") => 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> {
match normalize_optional_args(args) {
None | Some("list") => {
let runtime_config = loader.load()?;
Ok(render_mcp_summary_report(
cwd,
runtime_config.mcp().servers(),
))
}
Some("-h" | "--help" | "help") => 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 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::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(), 27);
assert_eq!(resume_supported_slash_commands().len(), 15);
}
#[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() {
assert_eq!(suggest_slash_commands("stats", 3), vec!["/status"]);
assert_eq!(suggest_slash_commands("/plugns", 3), vec!["/plugin"]);
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("/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 help"));
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 _ = 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);
}
}