| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553 |
- 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, 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: "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 available skills",
- argument_hint: Some("[list|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>,
- },
- 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 {
- #[must_use]
- pub fn parse(input: &str) -> Option<Self> {
- let trimmed = input.trim();
- if !trimmed.starts_with('/') {
- return None;
- }
- let mut parts = trimmed.trim_start_matches('/').split_whitespace();
- let command = parts.next().unwrap_or_default();
- Some(match command {
- "help" => Self::Help,
- "status" => Self::Status,
- "sandbox" => Self::Sandbox,
- "compact" => Self::Compact,
- "bughunter" => Self::Bughunter {
- scope: remainder_after_command(trimmed, command),
- },
- "commit" => Self::Commit,
- "pr" => Self::Pr {
- context: remainder_after_command(trimmed, command),
- },
- "issue" => Self::Issue {
- context: remainder_after_command(trimmed, command),
- },
- "ultraplan" => Self::Ultraplan {
- task: remainder_after_command(trimmed, command),
- },
- "teleport" => Self::Teleport {
- target: remainder_after_command(trimmed, command),
- },
- "debug-tool-call" => Self::DebugToolCall,
- "model" => Self::Model {
- model: parts.next().map(ToOwned::to_owned),
- },
- "permissions" => Self::Permissions {
- mode: parts.next().map(ToOwned::to_owned),
- },
- "clear" => Self::Clear {
- confirm: parts.next() == Some("--confirm"),
- },
- "cost" => Self::Cost,
- "resume" => Self::Resume {
- session_path: parts.next().map(ToOwned::to_owned),
- },
- "config" => Self::Config {
- section: parts.next().map(ToOwned::to_owned),
- },
- "memory" => Self::Memory,
- "init" => Self::Init,
- "diff" => Self::Diff,
- "version" => Self::Version,
- "export" => Self::Export {
- path: parts.next().map(ToOwned::to_owned),
- },
- "session" => Self::Session {
- action: parts.next().map(ToOwned::to_owned),
- target: parts.next().map(ToOwned::to_owned),
- },
- "plugin" | "plugins" | "marketplace" => Self::Plugins {
- action: parts.next().map(ToOwned::to_owned),
- target: {
- let remainder = parts.collect::<Vec<_>>().join(" ");
- (!remainder.is_empty()).then_some(remainder)
- },
- },
- "agents" => Self::Agents {
- args: remainder_after_command(trimmed, command),
- },
- "skills" => Self::Skills {
- args: remainder_after_command(trimmed, command),
- },
- other => Self::Unknown(other.to_string()),
- })
- }
- }
- 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)?,
- },
- "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_list_or_help_args(command, 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_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 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" => "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(¤t);
- }
- 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,
- }
- #[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_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("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
- Some(args) => Ok(render_skills_usage(Some(args))),
- }
- }
- #[must_use]
- pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
- let mut lines = vec!["Plugins".to_string()];
- if plugins.is_empty() {
- lines.push(" No plugins installed.".to_string());
- return lines.join("\n");
- }
- for plugin in plugins {
- let enabled = if plugin.enabled {
- "enabled"
- } else {
- "disabled"
- };
- lines.push(format!(
- " {name:<20} v{version:<10} {enabled}",
- name = plugin.metadata.name,
- version = plugin.metadata.version,
- ));
- }
- lines.join("\n")
- }
- fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
- let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
- let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
- let enabled = plugin.is_some_and(|plugin| plugin.enabled);
- format!(
- "Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
- if enabled { "enabled" } else { "disabled" }
- )
- }
- fn resolve_plugin_target(
- manager: &PluginManager,
- target: &str,
- ) -> Result<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 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 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|help]".to_string(),
- " Direct CLI claw 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")
- }
- #[must_use]
- pub fn handle_slash_command(
- input: &str,
- session: &Session,
- compaction: CompactionConfig,
- ) -> Option<SlashCommandResult> {
- let command = match validate_slash_command_input(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::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, 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 {
- validate_slash_command_input(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"), Some(SlashCommand::Help));
- assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
- assert_eq!(SlashCommand::parse("/sandbox"), Some(SlashCommand::Sandbox));
- assert_eq!(
- SlashCommand::parse("/bughunter runtime"),
- Some(SlashCommand::Bughunter {
- scope: Some("runtime".to_string())
- })
- );
- assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
- assert_eq!(
- SlashCommand::parse("/pr ready for review"),
- Some(SlashCommand::Pr {
- context: Some("ready for review".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/issue flaky test"),
- Some(SlashCommand::Issue {
- context: Some("flaky test".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/ultraplan ship both features"),
- Some(SlashCommand::Ultraplan {
- task: Some("ship both features".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/teleport conversation.rs"),
- Some(SlashCommand::Teleport {
- target: Some("conversation.rs".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/debug-tool-call"),
- Some(SlashCommand::DebugToolCall)
- );
- assert_eq!(
- SlashCommand::parse("/bughunter runtime"),
- Some(SlashCommand::Bughunter {
- scope: Some("runtime".to_string())
- })
- );
- assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
- assert_eq!(
- SlashCommand::parse("/pr ready for review"),
- Some(SlashCommand::Pr {
- context: Some("ready for review".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/issue flaky test"),
- Some(SlashCommand::Issue {
- context: Some("flaky test".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/ultraplan ship both features"),
- Some(SlashCommand::Ultraplan {
- task: Some("ship both features".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/teleport conversation.rs"),
- Some(SlashCommand::Teleport {
- target: Some("conversation.rs".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/debug-tool-call"),
- Some(SlashCommand::DebugToolCall)
- );
- assert_eq!(
- SlashCommand::parse("/model claude-opus"),
- Some(SlashCommand::Model {
- model: Some("claude-opus".to_string()),
- })
- );
- assert_eq!(
- SlashCommand::parse("/model"),
- Some(SlashCommand::Model { model: None })
- );
- assert_eq!(
- SlashCommand::parse("/permissions read-only"),
- Some(SlashCommand::Permissions {
- mode: Some("read-only".to_string()),
- })
- );
- assert_eq!(
- SlashCommand::parse("/clear"),
- Some(SlashCommand::Clear { confirm: false })
- );
- assert_eq!(
- SlashCommand::parse("/clear --confirm"),
- Some(SlashCommand::Clear { confirm: true })
- );
- assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
- assert_eq!(
- SlashCommand::parse("/resume session.json"),
- Some(SlashCommand::Resume {
- session_path: Some("session.json".to_string()),
- })
- );
- assert_eq!(
- SlashCommand::parse("/config"),
- Some(SlashCommand::Config { section: None })
- );
- assert_eq!(
- SlashCommand::parse("/config env"),
- Some(SlashCommand::Config {
- section: Some("env".to_string())
- })
- );
- assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
- assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
- assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
- assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
- assert_eq!(
- SlashCommand::parse("/export notes.txt"),
- Some(SlashCommand::Export {
- path: Some("notes.txt".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/session switch abc123"),
- Some(SlashCommand::Session {
- action: Some("switch".to_string()),
- target: Some("abc123".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/plugins install demo"),
- Some(SlashCommand::Plugins {
- action: Some("install".to_string()),
- target: Some("demo".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/plugins list"),
- Some(SlashCommand::Plugins {
- action: Some("list".to_string()),
- target: None
- })
- );
- assert_eq!(
- SlashCommand::parse("/plugins enable demo"),
- Some(SlashCommand::Plugins {
- action: Some("enable".to_string()),
- target: Some("demo".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/plugins disable demo"),
- Some(SlashCommand::Plugins {
- action: Some("disable".to_string()),
- target: Some("demo".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/session fork incident-review"),
- 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, or /skills help."
- ));
- assert!(skills_error.contains(" Usage /skills [list|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("/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|help]"));
- assert_eq!(slash_command_specs().len(), 26);
- assert_eq!(resume_supported_slash_commands().len(), 14);
- }
- #[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_agents_and_skills_help_with_list_and_help_usage() {
- // given
- let agents = render_slash_command_help_detail("agents").expect("agents help should exist");
- let skills = render_slash_command_help_detail("skills").expect("skills help should exist");
- // when
- // then
- assert!(agents.contains("Usage /agents [list|help]"));
- assert!(skills.contains("Usage /skills [list|help]"));
- }
- #[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("/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|help]"));
- assert!(skills_help.contains("legacy /commands"));
- let skills_unexpected =
- super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
- assert!(skills_unexpected.contains("Unexpected show help"));
- let _ = fs::remove_dir_all(cwd);
- }
- #[test]
- fn parses_quoted_skill_frontmatter_values() {
- let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
- let (name, description) = super::parse_skill_frontmatter(contents);
- assert_eq!(name.as_deref(), Some("hud"));
- assert_eq!(description.as_deref(), Some("Quoted description"));
- }
- #[test]
- fn installs_plugin_from_path_and_lists_it() {
- let config_home = temp_dir("home");
- let source_root = temp_dir("source");
- write_external_plugin(&source_root, "demo", "1.0.0");
- let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
- let install = handle_plugins_slash_command(
- Some("install"),
- Some(source_root.to_str().expect("utf8 path")),
- &mut manager,
- )
- .expect("install command should succeed");
- assert!(install.reload_runtime);
- assert!(install.message.contains("installed demo@external"));
- assert!(install.message.contains("Name demo"));
- assert!(install.message.contains("Version 1.0.0"));
- assert!(install.message.contains("Status enabled"));
- let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
- .expect("list command should succeed");
- assert!(!list.reload_runtime);
- assert!(list.message.contains("demo"));
- assert!(list.message.contains("v1.0.0"));
- assert!(list.message.contains("enabled"));
- let _ = fs::remove_dir_all(config_home);
- let _ = fs::remove_dir_all(source_root);
- }
- #[test]
- fn enables_and_disables_plugin_by_name() {
- let config_home = temp_dir("toggle-home");
- let source_root = temp_dir("toggle-source");
- write_external_plugin(&source_root, "demo", "1.0.0");
- let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
- handle_plugins_slash_command(
- Some("install"),
- Some(source_root.to_str().expect("utf8 path")),
- &mut manager,
- )
- .expect("install command should succeed");
- let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
- .expect("disable command should succeed");
- assert!(disable.reload_runtime);
- assert!(disable.message.contains("disabled demo@external"));
- assert!(disable.message.contains("Name demo"));
- assert!(disable.message.contains("Status disabled"));
- let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
- .expect("list command should succeed");
- assert!(list.message.contains("demo"));
- assert!(list.message.contains("disabled"));
- let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
- .expect("enable command should succeed");
- assert!(enable.reload_runtime);
- assert!(enable.message.contains("enabled demo@external"));
- assert!(enable.message.contains("Name demo"));
- assert!(enable.message.contains("Status enabled"));
- let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
- .expect("list command should succeed");
- assert!(list.message.contains("demo"));
- assert!(list.message.contains("enabled"));
- let _ = fs::remove_dir_all(config_home);
- let _ = fs::remove_dir_all(source_root);
- }
- #[test]
- fn lists_auto_installed_bundled_plugins_with_status() {
- let config_home = temp_dir("bundled-home");
- let bundled_root = temp_dir("bundled-root");
- let bundled_plugin = bundled_root.join("starter");
- write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
- let mut config = PluginManagerConfig::new(&config_home);
- config.bundled_root = Some(bundled_root.clone());
- let mut manager = PluginManager::new(config);
- let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
- .expect("list command should succeed");
- assert!(!list.reload_runtime);
- assert!(list.message.contains("starter"));
- assert!(list.message.contains("v0.1.0"));
- assert!(list.message.contains("disabled"));
- let _ = fs::remove_dir_all(config_home);
- let _ = fs::remove_dir_all(bundled_root);
- }
- }
|