| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237 |
- 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(¤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,
- }
- #[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, ®istry_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);
- }
- }
|