lib.rs 137 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206420742084209421042114212421342144215421642174218421942204221422242234224422542264227422842294230423142324233423442354236423742384239424042414242424342444245424642474248424942504251425242534254425542564257
  1. use std::collections::BTreeMap;
  2. use std::env;
  3. use std::fmt;
  4. use std::fs;
  5. use std::path::{Path, PathBuf};
  6. use plugins::{PluginError, PluginManager, PluginSummary};
  7. use runtime::{
  8. compact_session, CompactionConfig, ConfigLoader, ConfigSource, McpOAuthConfig, McpServerConfig,
  9. ScopedMcpServerConfig, Session,
  10. };
  11. #[derive(Debug, Clone, PartialEq, Eq)]
  12. pub struct CommandManifestEntry {
  13. pub name: String,
  14. pub source: CommandSource,
  15. }
  16. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  17. pub enum CommandSource {
  18. Builtin,
  19. InternalOnly,
  20. FeatureGated,
  21. }
  22. #[derive(Debug, Clone, Default, PartialEq, Eq)]
  23. pub struct CommandRegistry {
  24. entries: Vec<CommandManifestEntry>,
  25. }
  26. impl CommandRegistry {
  27. #[must_use]
  28. pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
  29. Self { entries }
  30. }
  31. #[must_use]
  32. pub fn entries(&self) -> &[CommandManifestEntry] {
  33. &self.entries
  34. }
  35. }
  36. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  37. pub struct SlashCommandSpec {
  38. pub name: &'static str,
  39. pub aliases: &'static [&'static str],
  40. pub summary: &'static str,
  41. pub argument_hint: Option<&'static str>,
  42. pub resume_supported: bool,
  43. }
  44. const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
  45. SlashCommandSpec {
  46. name: "help",
  47. aliases: &[],
  48. summary: "Show available slash commands",
  49. argument_hint: None,
  50. resume_supported: true,
  51. },
  52. SlashCommandSpec {
  53. name: "status",
  54. aliases: &[],
  55. summary: "Show current session status",
  56. argument_hint: None,
  57. resume_supported: true,
  58. },
  59. SlashCommandSpec {
  60. name: "sandbox",
  61. aliases: &[],
  62. summary: "Show sandbox isolation status",
  63. argument_hint: None,
  64. resume_supported: true,
  65. },
  66. SlashCommandSpec {
  67. name: "compact",
  68. aliases: &[],
  69. summary: "Compact local session history",
  70. argument_hint: None,
  71. resume_supported: true,
  72. },
  73. SlashCommandSpec {
  74. name: "model",
  75. aliases: &[],
  76. summary: "Show or switch the active model",
  77. argument_hint: Some("[model]"),
  78. resume_supported: false,
  79. },
  80. SlashCommandSpec {
  81. name: "permissions",
  82. aliases: &[],
  83. summary: "Show or switch the active permission mode",
  84. argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
  85. resume_supported: false,
  86. },
  87. SlashCommandSpec {
  88. name: "clear",
  89. aliases: &[],
  90. summary: "Start a fresh local session",
  91. argument_hint: Some("[--confirm]"),
  92. resume_supported: true,
  93. },
  94. SlashCommandSpec {
  95. name: "cost",
  96. aliases: &[],
  97. summary: "Show cumulative token usage for this session",
  98. argument_hint: None,
  99. resume_supported: true,
  100. },
  101. SlashCommandSpec {
  102. name: "resume",
  103. aliases: &[],
  104. summary: "Load a saved session into the REPL",
  105. argument_hint: Some("<session-path>"),
  106. resume_supported: false,
  107. },
  108. SlashCommandSpec {
  109. name: "config",
  110. aliases: &[],
  111. summary: "Inspect Claude config files or merged sections",
  112. argument_hint: Some("[env|hooks|model|plugins]"),
  113. resume_supported: true,
  114. },
  115. SlashCommandSpec {
  116. name: "mcp",
  117. aliases: &[],
  118. summary: "Inspect configured MCP servers",
  119. argument_hint: Some("[list|show <server>|help]"),
  120. resume_supported: true,
  121. },
  122. SlashCommandSpec {
  123. name: "memory",
  124. aliases: &[],
  125. summary: "Inspect loaded Claude instruction memory files",
  126. argument_hint: None,
  127. resume_supported: true,
  128. },
  129. SlashCommandSpec {
  130. name: "init",
  131. aliases: &[],
  132. summary: "Create a starter CLAUDE.md for this repo",
  133. argument_hint: None,
  134. resume_supported: true,
  135. },
  136. SlashCommandSpec {
  137. name: "diff",
  138. aliases: &[],
  139. summary: "Show git diff for current workspace changes",
  140. argument_hint: None,
  141. resume_supported: true,
  142. },
  143. SlashCommandSpec {
  144. name: "version",
  145. aliases: &[],
  146. summary: "Show CLI version and build information",
  147. argument_hint: None,
  148. resume_supported: true,
  149. },
  150. SlashCommandSpec {
  151. name: "bughunter",
  152. aliases: &[],
  153. summary: "Inspect the codebase for likely bugs",
  154. argument_hint: Some("[scope]"),
  155. resume_supported: false,
  156. },
  157. SlashCommandSpec {
  158. name: "commit",
  159. aliases: &[],
  160. summary: "Generate a commit message and create a git commit",
  161. argument_hint: None,
  162. resume_supported: false,
  163. },
  164. SlashCommandSpec {
  165. name: "pr",
  166. aliases: &[],
  167. summary: "Draft or create a pull request from the conversation",
  168. argument_hint: Some("[context]"),
  169. resume_supported: false,
  170. },
  171. SlashCommandSpec {
  172. name: "issue",
  173. aliases: &[],
  174. summary: "Draft or create a GitHub issue from the conversation",
  175. argument_hint: Some("[context]"),
  176. resume_supported: false,
  177. },
  178. SlashCommandSpec {
  179. name: "ultraplan",
  180. aliases: &[],
  181. summary: "Run a deep planning prompt with multi-step reasoning",
  182. argument_hint: Some("[task]"),
  183. resume_supported: false,
  184. },
  185. SlashCommandSpec {
  186. name: "teleport",
  187. aliases: &[],
  188. summary: "Jump to a file or symbol by searching the workspace",
  189. argument_hint: Some("<symbol-or-path>"),
  190. resume_supported: false,
  191. },
  192. SlashCommandSpec {
  193. name: "debug-tool-call",
  194. aliases: &[],
  195. summary: "Replay the last tool call with debug details",
  196. argument_hint: None,
  197. resume_supported: false,
  198. },
  199. SlashCommandSpec {
  200. name: "export",
  201. aliases: &[],
  202. summary: "Export the current conversation to a file",
  203. argument_hint: Some("[file]"),
  204. resume_supported: true,
  205. },
  206. SlashCommandSpec {
  207. name: "session",
  208. aliases: &[],
  209. summary: "List, switch, or fork managed local sessions",
  210. argument_hint: Some("[list|switch <session-id>|fork [branch-name]]"),
  211. resume_supported: false,
  212. },
  213. SlashCommandSpec {
  214. name: "plugin",
  215. aliases: &["plugins", "marketplace"],
  216. summary: "Manage Claw Code plugins",
  217. argument_hint: Some(
  218. "[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
  219. ),
  220. resume_supported: false,
  221. },
  222. SlashCommandSpec {
  223. name: "agents",
  224. aliases: &[],
  225. summary: "List configured agents",
  226. argument_hint: Some("[list|help]"),
  227. resume_supported: true,
  228. },
  229. SlashCommandSpec {
  230. name: "skills",
  231. aliases: &[],
  232. summary: "List or install available skills",
  233. argument_hint: Some("[list|install <path>|help]"),
  234. resume_supported: true,
  235. },
  236. SlashCommandSpec {
  237. name: "doctor",
  238. aliases: &[],
  239. summary: "Diagnose setup issues and environment health",
  240. argument_hint: None,
  241. resume_supported: true,
  242. },
  243. SlashCommandSpec {
  244. name: "login",
  245. aliases: &[],
  246. summary: "Log in to the service",
  247. argument_hint: None,
  248. resume_supported: false,
  249. },
  250. SlashCommandSpec {
  251. name: "logout",
  252. aliases: &[],
  253. summary: "Log out of the current session",
  254. argument_hint: None,
  255. resume_supported: false,
  256. },
  257. SlashCommandSpec {
  258. name: "plan",
  259. aliases: &[],
  260. summary: "Toggle or inspect planning mode",
  261. argument_hint: Some("[on|off]"),
  262. resume_supported: true,
  263. },
  264. SlashCommandSpec {
  265. name: "review",
  266. aliases: &[],
  267. summary: "Run a code review on current changes",
  268. argument_hint: Some("[scope]"),
  269. resume_supported: false,
  270. },
  271. SlashCommandSpec {
  272. name: "tasks",
  273. aliases: &[],
  274. summary: "List and manage background tasks",
  275. argument_hint: Some("[list|get <id>|stop <id>]"),
  276. resume_supported: true,
  277. },
  278. SlashCommandSpec {
  279. name: "theme",
  280. aliases: &[],
  281. summary: "Switch the terminal color theme",
  282. argument_hint: Some("[theme-name]"),
  283. resume_supported: true,
  284. },
  285. SlashCommandSpec {
  286. name: "vim",
  287. aliases: &[],
  288. summary: "Toggle vim keybinding mode",
  289. argument_hint: None,
  290. resume_supported: true,
  291. },
  292. SlashCommandSpec {
  293. name: "voice",
  294. aliases: &[],
  295. summary: "Toggle voice input mode",
  296. argument_hint: Some("[on|off]"),
  297. resume_supported: false,
  298. },
  299. SlashCommandSpec {
  300. name: "upgrade",
  301. aliases: &[],
  302. summary: "Check for and install CLI updates",
  303. argument_hint: None,
  304. resume_supported: false,
  305. },
  306. SlashCommandSpec {
  307. name: "usage",
  308. aliases: &[],
  309. summary: "Show detailed API usage statistics",
  310. argument_hint: None,
  311. resume_supported: true,
  312. },
  313. SlashCommandSpec {
  314. name: "stats",
  315. aliases: &[],
  316. summary: "Show workspace and session statistics",
  317. argument_hint: None,
  318. resume_supported: true,
  319. },
  320. SlashCommandSpec {
  321. name: "rename",
  322. aliases: &[],
  323. summary: "Rename the current session",
  324. argument_hint: Some("<name>"),
  325. resume_supported: false,
  326. },
  327. SlashCommandSpec {
  328. name: "copy",
  329. aliases: &[],
  330. summary: "Copy conversation or output to clipboard",
  331. argument_hint: Some("[last|all]"),
  332. resume_supported: true,
  333. },
  334. SlashCommandSpec {
  335. name: "share",
  336. aliases: &[],
  337. summary: "Share the current conversation",
  338. argument_hint: None,
  339. resume_supported: false,
  340. },
  341. SlashCommandSpec {
  342. name: "feedback",
  343. aliases: &[],
  344. summary: "Submit feedback about the current session",
  345. argument_hint: None,
  346. resume_supported: false,
  347. },
  348. SlashCommandSpec {
  349. name: "hooks",
  350. aliases: &[],
  351. summary: "List and manage lifecycle hooks",
  352. argument_hint: Some("[list|run <hook>]"),
  353. resume_supported: true,
  354. },
  355. SlashCommandSpec {
  356. name: "files",
  357. aliases: &[],
  358. summary: "List files in the current context window",
  359. argument_hint: None,
  360. resume_supported: true,
  361. },
  362. SlashCommandSpec {
  363. name: "context",
  364. aliases: &[],
  365. summary: "Inspect or manage the conversation context",
  366. argument_hint: Some("[show|clear]"),
  367. resume_supported: true,
  368. },
  369. SlashCommandSpec {
  370. name: "color",
  371. aliases: &[],
  372. summary: "Configure terminal color settings",
  373. argument_hint: Some("[scheme]"),
  374. resume_supported: true,
  375. },
  376. SlashCommandSpec {
  377. name: "effort",
  378. aliases: &[],
  379. summary: "Set the effort level for responses",
  380. argument_hint: Some("[low|medium|high]"),
  381. resume_supported: true,
  382. },
  383. SlashCommandSpec {
  384. name: "fast",
  385. aliases: &[],
  386. summary: "Toggle fast/concise response mode",
  387. argument_hint: None,
  388. resume_supported: true,
  389. },
  390. SlashCommandSpec {
  391. name: "exit",
  392. aliases: &[],
  393. summary: "Exit the REPL session",
  394. argument_hint: None,
  395. resume_supported: false,
  396. },
  397. SlashCommandSpec {
  398. name: "branch",
  399. aliases: &[],
  400. summary: "Create or switch git branches",
  401. argument_hint: Some("[name]"),
  402. resume_supported: false,
  403. },
  404. SlashCommandSpec {
  405. name: "rewind",
  406. aliases: &[],
  407. summary: "Rewind the conversation to a previous state",
  408. argument_hint: Some("[steps]"),
  409. resume_supported: false,
  410. },
  411. SlashCommandSpec {
  412. name: "summary",
  413. aliases: &[],
  414. summary: "Generate a summary of the conversation",
  415. argument_hint: None,
  416. resume_supported: true,
  417. },
  418. SlashCommandSpec {
  419. name: "desktop",
  420. aliases: &[],
  421. summary: "Open or manage the desktop app integration",
  422. argument_hint: None,
  423. resume_supported: false,
  424. },
  425. SlashCommandSpec {
  426. name: "ide",
  427. aliases: &[],
  428. summary: "Open or configure IDE integration",
  429. argument_hint: Some("[vscode|cursor]"),
  430. resume_supported: false,
  431. },
  432. SlashCommandSpec {
  433. name: "tag",
  434. aliases: &[],
  435. summary: "Tag the current conversation point",
  436. argument_hint: Some("[label]"),
  437. resume_supported: true,
  438. },
  439. SlashCommandSpec {
  440. name: "brief",
  441. aliases: &[],
  442. summary: "Toggle brief output mode",
  443. argument_hint: None,
  444. resume_supported: true,
  445. },
  446. SlashCommandSpec {
  447. name: "advisor",
  448. aliases: &[],
  449. summary: "Toggle advisor mode for guidance-only responses",
  450. argument_hint: None,
  451. resume_supported: true,
  452. },
  453. SlashCommandSpec {
  454. name: "stickers",
  455. aliases: &[],
  456. summary: "Browse and manage sticker packs",
  457. argument_hint: None,
  458. resume_supported: true,
  459. },
  460. SlashCommandSpec {
  461. name: "insights",
  462. aliases: &[],
  463. summary: "Show AI-generated insights about the session",
  464. argument_hint: None,
  465. resume_supported: true,
  466. },
  467. SlashCommandSpec {
  468. name: "thinkback",
  469. aliases: &[],
  470. summary: "Replay the thinking process of the last response",
  471. argument_hint: None,
  472. resume_supported: true,
  473. },
  474. SlashCommandSpec {
  475. name: "release-notes",
  476. aliases: &[],
  477. summary: "Generate release notes from recent changes",
  478. argument_hint: None,
  479. resume_supported: false,
  480. },
  481. SlashCommandSpec {
  482. name: "security-review",
  483. aliases: &[],
  484. summary: "Run a security review on the codebase",
  485. argument_hint: Some("[scope]"),
  486. resume_supported: false,
  487. },
  488. SlashCommandSpec {
  489. name: "keybindings",
  490. aliases: &[],
  491. summary: "Show or configure keyboard shortcuts",
  492. argument_hint: None,
  493. resume_supported: true,
  494. },
  495. SlashCommandSpec {
  496. name: "privacy-settings",
  497. aliases: &[],
  498. summary: "View or modify privacy settings",
  499. argument_hint: None,
  500. resume_supported: true,
  501. },
  502. SlashCommandSpec {
  503. name: "output-style",
  504. aliases: &[],
  505. summary: "Switch output formatting style",
  506. argument_hint: Some("[style]"),
  507. resume_supported: true,
  508. },
  509. SlashCommandSpec {
  510. name: "add-dir",
  511. aliases: &[],
  512. summary: "Add an additional directory to the context",
  513. argument_hint: Some("<path>"),
  514. resume_supported: false,
  515. },
  516. SlashCommandSpec {
  517. name: "allowed-tools",
  518. aliases: &[],
  519. summary: "Show or modify the allowed tools list",
  520. argument_hint: Some("[add|remove|list] [tool]"),
  521. resume_supported: true,
  522. },
  523. SlashCommandSpec {
  524. name: "api-key",
  525. aliases: &[],
  526. summary: "Show or set the Anthropic API key",
  527. argument_hint: Some("[key]"),
  528. resume_supported: false,
  529. },
  530. SlashCommandSpec {
  531. name: "approve",
  532. aliases: &["yes", "y"],
  533. summary: "Approve a pending tool execution",
  534. argument_hint: None,
  535. resume_supported: false,
  536. },
  537. SlashCommandSpec {
  538. name: "deny",
  539. aliases: &["no", "n"],
  540. summary: "Deny a pending tool execution",
  541. argument_hint: None,
  542. resume_supported: false,
  543. },
  544. SlashCommandSpec {
  545. name: "undo",
  546. aliases: &[],
  547. summary: "Undo the last file write or edit",
  548. argument_hint: None,
  549. resume_supported: false,
  550. },
  551. SlashCommandSpec {
  552. name: "stop",
  553. aliases: &[],
  554. summary: "Stop the current generation",
  555. argument_hint: None,
  556. resume_supported: false,
  557. },
  558. SlashCommandSpec {
  559. name: "retry",
  560. aliases: &[],
  561. summary: "Retry the last failed message",
  562. argument_hint: None,
  563. resume_supported: false,
  564. },
  565. SlashCommandSpec {
  566. name: "paste",
  567. aliases: &[],
  568. summary: "Paste clipboard content as input",
  569. argument_hint: None,
  570. resume_supported: false,
  571. },
  572. SlashCommandSpec {
  573. name: "screenshot",
  574. aliases: &[],
  575. summary: "Take a screenshot and add to conversation",
  576. argument_hint: None,
  577. resume_supported: false,
  578. },
  579. SlashCommandSpec {
  580. name: "image",
  581. aliases: &[],
  582. summary: "Add an image file to the conversation",
  583. argument_hint: Some("<path>"),
  584. resume_supported: false,
  585. },
  586. SlashCommandSpec {
  587. name: "terminal-setup",
  588. aliases: &[],
  589. summary: "Configure terminal integration settings",
  590. argument_hint: None,
  591. resume_supported: true,
  592. },
  593. SlashCommandSpec {
  594. name: "search",
  595. aliases: &[],
  596. summary: "Search files in the workspace",
  597. argument_hint: Some("<query>"),
  598. resume_supported: false,
  599. },
  600. SlashCommandSpec {
  601. name: "listen",
  602. aliases: &[],
  603. summary: "Listen for voice input",
  604. argument_hint: None,
  605. resume_supported: false,
  606. },
  607. SlashCommandSpec {
  608. name: "speak",
  609. aliases: &[],
  610. summary: "Read the last response aloud",
  611. argument_hint: None,
  612. resume_supported: false,
  613. },
  614. SlashCommandSpec {
  615. name: "language",
  616. aliases: &[],
  617. summary: "Set the interface language",
  618. argument_hint: Some("[language]"),
  619. resume_supported: true,
  620. },
  621. SlashCommandSpec {
  622. name: "profile",
  623. aliases: &[],
  624. summary: "Show or switch user profile",
  625. argument_hint: Some("[name]"),
  626. resume_supported: false,
  627. },
  628. SlashCommandSpec {
  629. name: "max-tokens",
  630. aliases: &[],
  631. summary: "Show or set the max output tokens",
  632. argument_hint: Some("[count]"),
  633. resume_supported: true,
  634. },
  635. SlashCommandSpec {
  636. name: "temperature",
  637. aliases: &[],
  638. summary: "Show or set the sampling temperature",
  639. argument_hint: Some("[value]"),
  640. resume_supported: true,
  641. },
  642. SlashCommandSpec {
  643. name: "system-prompt",
  644. aliases: &[],
  645. summary: "Show the active system prompt",
  646. argument_hint: None,
  647. resume_supported: true,
  648. },
  649. SlashCommandSpec {
  650. name: "tool-details",
  651. aliases: &[],
  652. summary: "Show detailed info about a specific tool",
  653. argument_hint: Some("<tool-name>"),
  654. resume_supported: true,
  655. },
  656. SlashCommandSpec {
  657. name: "format",
  658. aliases: &[],
  659. summary: "Format the last response in a different style",
  660. argument_hint: Some("[markdown|plain|json]"),
  661. resume_supported: false,
  662. },
  663. SlashCommandSpec {
  664. name: "pin",
  665. aliases: &[],
  666. summary: "Pin a message to persist across compaction",
  667. argument_hint: Some("[message-index]"),
  668. resume_supported: false,
  669. },
  670. SlashCommandSpec {
  671. name: "unpin",
  672. aliases: &[],
  673. summary: "Unpin a previously pinned message",
  674. argument_hint: Some("[message-index]"),
  675. resume_supported: false,
  676. },
  677. SlashCommandSpec {
  678. name: "bookmarks",
  679. aliases: &[],
  680. summary: "List or manage conversation bookmarks",
  681. argument_hint: Some("[add|remove|list]"),
  682. resume_supported: true,
  683. },
  684. SlashCommandSpec {
  685. name: "workspace",
  686. aliases: &["cwd"],
  687. summary: "Show or change the working directory",
  688. argument_hint: Some("[path]"),
  689. resume_supported: true,
  690. },
  691. SlashCommandSpec {
  692. name: "history",
  693. aliases: &[],
  694. summary: "Show conversation history summary",
  695. argument_hint: Some("[count]"),
  696. resume_supported: true,
  697. },
  698. SlashCommandSpec {
  699. name: "tokens",
  700. aliases: &[],
  701. summary: "Show token count for the current conversation",
  702. argument_hint: None,
  703. resume_supported: true,
  704. },
  705. SlashCommandSpec {
  706. name: "cache",
  707. aliases: &[],
  708. summary: "Show prompt cache statistics",
  709. argument_hint: None,
  710. resume_supported: true,
  711. },
  712. SlashCommandSpec {
  713. name: "providers",
  714. aliases: &[],
  715. summary: "List available model providers",
  716. argument_hint: None,
  717. resume_supported: true,
  718. },
  719. SlashCommandSpec {
  720. name: "notifications",
  721. aliases: &[],
  722. summary: "Show or configure notification settings",
  723. argument_hint: Some("[on|off|status]"),
  724. resume_supported: true,
  725. },
  726. SlashCommandSpec {
  727. name: "changelog",
  728. aliases: &[],
  729. summary: "Show recent changes to the codebase",
  730. argument_hint: Some("[count]"),
  731. resume_supported: true,
  732. },
  733. SlashCommandSpec {
  734. name: "test",
  735. aliases: &[],
  736. summary: "Run tests for the current project",
  737. argument_hint: Some("[filter]"),
  738. resume_supported: false,
  739. },
  740. SlashCommandSpec {
  741. name: "lint",
  742. aliases: &[],
  743. summary: "Run linting for the current project",
  744. argument_hint: Some("[filter]"),
  745. resume_supported: false,
  746. },
  747. SlashCommandSpec {
  748. name: "build",
  749. aliases: &[],
  750. summary: "Build the current project",
  751. argument_hint: Some("[target]"),
  752. resume_supported: false,
  753. },
  754. SlashCommandSpec {
  755. name: "run",
  756. aliases: &[],
  757. summary: "Run a command in the project context",
  758. argument_hint: Some("<command>"),
  759. resume_supported: false,
  760. },
  761. SlashCommandSpec {
  762. name: "git",
  763. aliases: &[],
  764. summary: "Run a git command in the workspace",
  765. argument_hint: Some("<subcommand>"),
  766. resume_supported: false,
  767. },
  768. SlashCommandSpec {
  769. name: "stash",
  770. aliases: &[],
  771. summary: "Stash or unstash workspace changes",
  772. argument_hint: Some("[pop|list|apply]"),
  773. resume_supported: false,
  774. },
  775. SlashCommandSpec {
  776. name: "blame",
  777. aliases: &[],
  778. summary: "Show git blame for a file",
  779. argument_hint: Some("<file> [line]"),
  780. resume_supported: true,
  781. },
  782. SlashCommandSpec {
  783. name: "log",
  784. aliases: &[],
  785. summary: "Show git log for the workspace",
  786. argument_hint: Some("[count]"),
  787. resume_supported: true,
  788. },
  789. SlashCommandSpec {
  790. name: "cron",
  791. aliases: &[],
  792. summary: "Manage scheduled tasks",
  793. argument_hint: Some("[list|add|remove]"),
  794. resume_supported: true,
  795. },
  796. SlashCommandSpec {
  797. name: "team",
  798. aliases: &[],
  799. summary: "Manage agent teams",
  800. argument_hint: Some("[list|create|delete]"),
  801. resume_supported: true,
  802. },
  803. SlashCommandSpec {
  804. name: "benchmark",
  805. aliases: &[],
  806. summary: "Run performance benchmarks",
  807. argument_hint: Some("[suite]"),
  808. resume_supported: false,
  809. },
  810. SlashCommandSpec {
  811. name: "migrate",
  812. aliases: &[],
  813. summary: "Run pending data migrations",
  814. argument_hint: None,
  815. resume_supported: false,
  816. },
  817. SlashCommandSpec {
  818. name: "reset",
  819. aliases: &[],
  820. summary: "Reset configuration to defaults",
  821. argument_hint: Some("[section]"),
  822. resume_supported: false,
  823. },
  824. SlashCommandSpec {
  825. name: "telemetry",
  826. aliases: &[],
  827. summary: "Show or configure telemetry settings",
  828. argument_hint: Some("[on|off|status]"),
  829. resume_supported: true,
  830. },
  831. SlashCommandSpec {
  832. name: "env",
  833. aliases: &[],
  834. summary: "Show environment variables visible to tools",
  835. argument_hint: None,
  836. resume_supported: true,
  837. },
  838. SlashCommandSpec {
  839. name: "project",
  840. aliases: &[],
  841. summary: "Show project detection info",
  842. argument_hint: None,
  843. resume_supported: true,
  844. },
  845. SlashCommandSpec {
  846. name: "templates",
  847. aliases: &[],
  848. summary: "List or apply prompt templates",
  849. argument_hint: Some("[list|apply <name>]"),
  850. resume_supported: false,
  851. },
  852. SlashCommandSpec {
  853. name: "explain",
  854. aliases: &[],
  855. summary: "Explain a file or code snippet",
  856. argument_hint: Some("<path> [line-range]"),
  857. resume_supported: false,
  858. },
  859. SlashCommandSpec {
  860. name: "refactor",
  861. aliases: &[],
  862. summary: "Suggest refactoring for a file or function",
  863. argument_hint: Some("<path> [scope]"),
  864. resume_supported: false,
  865. },
  866. SlashCommandSpec {
  867. name: "docs",
  868. aliases: &[],
  869. summary: "Generate or show documentation",
  870. argument_hint: Some("[path]"),
  871. resume_supported: false,
  872. },
  873. SlashCommandSpec {
  874. name: "fix",
  875. aliases: &[],
  876. summary: "Fix errors in a file or project",
  877. argument_hint: Some("[path]"),
  878. resume_supported: false,
  879. },
  880. SlashCommandSpec {
  881. name: "perf",
  882. aliases: &[],
  883. summary: "Analyze performance of a function or file",
  884. argument_hint: Some("<path>"),
  885. resume_supported: false,
  886. },
  887. SlashCommandSpec {
  888. name: "chat",
  889. aliases: &[],
  890. summary: "Switch to free-form chat mode",
  891. argument_hint: None,
  892. resume_supported: false,
  893. },
  894. SlashCommandSpec {
  895. name: "focus",
  896. aliases: &[],
  897. summary: "Focus context on specific files or directories",
  898. argument_hint: Some("<path> [path...]"),
  899. resume_supported: false,
  900. },
  901. SlashCommandSpec {
  902. name: "unfocus",
  903. aliases: &[],
  904. summary: "Remove focus from files or directories",
  905. argument_hint: Some("[path...]"),
  906. resume_supported: false,
  907. },
  908. SlashCommandSpec {
  909. name: "web",
  910. aliases: &[],
  911. summary: "Fetch and summarize a web page",
  912. argument_hint: Some("<url>"),
  913. resume_supported: false,
  914. },
  915. SlashCommandSpec {
  916. name: "map",
  917. aliases: &[],
  918. summary: "Show a visual map of the codebase structure",
  919. argument_hint: Some("[depth]"),
  920. resume_supported: true,
  921. },
  922. SlashCommandSpec {
  923. name: "symbols",
  924. aliases: &[],
  925. summary: "List symbols (functions, classes, etc.) in a file",
  926. argument_hint: Some("<path>"),
  927. resume_supported: true,
  928. },
  929. SlashCommandSpec {
  930. name: "references",
  931. aliases: &[],
  932. summary: "Find all references to a symbol",
  933. argument_hint: Some("<symbol>"),
  934. resume_supported: false,
  935. },
  936. SlashCommandSpec {
  937. name: "definition",
  938. aliases: &[],
  939. summary: "Go to the definition of a symbol",
  940. argument_hint: Some("<symbol>"),
  941. resume_supported: false,
  942. },
  943. SlashCommandSpec {
  944. name: "hover",
  945. aliases: &[],
  946. summary: "Show hover information for a symbol",
  947. argument_hint: Some("<symbol>"),
  948. resume_supported: true,
  949. },
  950. SlashCommandSpec {
  951. name: "diagnostics",
  952. aliases: &[],
  953. summary: "Show LSP diagnostics for a file",
  954. argument_hint: Some("[path]"),
  955. resume_supported: true,
  956. },
  957. SlashCommandSpec {
  958. name: "autofix",
  959. aliases: &[],
  960. summary: "Auto-fix all fixable diagnostics",
  961. argument_hint: Some("[path]"),
  962. resume_supported: false,
  963. },
  964. SlashCommandSpec {
  965. name: "multi",
  966. aliases: &[],
  967. summary: "Execute multiple slash commands in sequence",
  968. argument_hint: Some("<commands>"),
  969. resume_supported: false,
  970. },
  971. SlashCommandSpec {
  972. name: "macro",
  973. aliases: &[],
  974. summary: "Record or replay command macros",
  975. argument_hint: Some("[record|stop|play <name>]"),
  976. resume_supported: false,
  977. },
  978. SlashCommandSpec {
  979. name: "alias",
  980. aliases: &[],
  981. summary: "Create a command alias",
  982. argument_hint: Some("<name> <command>"),
  983. resume_supported: true,
  984. },
  985. SlashCommandSpec {
  986. name: "parallel",
  987. aliases: &[],
  988. summary: "Run commands in parallel subagents",
  989. argument_hint: Some("<count> <prompt>"),
  990. resume_supported: false,
  991. },
  992. SlashCommandSpec {
  993. name: "agent",
  994. aliases: &[],
  995. summary: "Manage sub-agents and spawned sessions",
  996. argument_hint: Some("[list|spawn|kill]"),
  997. resume_supported: true,
  998. },
  999. SlashCommandSpec {
  1000. name: "subagent",
  1001. aliases: &[],
  1002. summary: "Control active subagent execution",
  1003. argument_hint: Some("[list|steer <target> <msg>|kill <id>]"),
  1004. resume_supported: true,
  1005. },
  1006. SlashCommandSpec {
  1007. name: "reasoning",
  1008. aliases: &[],
  1009. summary: "Toggle extended reasoning mode",
  1010. argument_hint: Some("[on|off|stream]"),
  1011. resume_supported: true,
  1012. },
  1013. SlashCommandSpec {
  1014. name: "budget",
  1015. aliases: &[],
  1016. summary: "Show or set token budget limits",
  1017. argument_hint: Some("[show|set <limit>]"),
  1018. resume_supported: true,
  1019. },
  1020. SlashCommandSpec {
  1021. name: "rate-limit",
  1022. aliases: &[],
  1023. summary: "Configure API rate limiting",
  1024. argument_hint: Some("[status|set <rpm>]"),
  1025. resume_supported: true,
  1026. },
  1027. SlashCommandSpec {
  1028. name: "metrics",
  1029. aliases: &[],
  1030. summary: "Show performance and usage metrics",
  1031. argument_hint: None,
  1032. resume_supported: true,
  1033. },
  1034. ];
  1035. #[derive(Debug, Clone, PartialEq, Eq)]
  1036. pub enum SlashCommand {
  1037. Help,
  1038. Status,
  1039. Sandbox,
  1040. Compact,
  1041. Bughunter {
  1042. scope: Option<String>,
  1043. },
  1044. Commit,
  1045. Pr {
  1046. context: Option<String>,
  1047. },
  1048. Issue {
  1049. context: Option<String>,
  1050. },
  1051. Ultraplan {
  1052. task: Option<String>,
  1053. },
  1054. Teleport {
  1055. target: Option<String>,
  1056. },
  1057. DebugToolCall,
  1058. Model {
  1059. model: Option<String>,
  1060. },
  1061. Permissions {
  1062. mode: Option<String>,
  1063. },
  1064. Clear {
  1065. confirm: bool,
  1066. },
  1067. Cost,
  1068. Resume {
  1069. session_path: Option<String>,
  1070. },
  1071. Config {
  1072. section: Option<String>,
  1073. },
  1074. Mcp {
  1075. action: Option<String>,
  1076. target: Option<String>,
  1077. },
  1078. Memory,
  1079. Init,
  1080. Diff,
  1081. Version,
  1082. Export {
  1083. path: Option<String>,
  1084. },
  1085. Session {
  1086. action: Option<String>,
  1087. target: Option<String>,
  1088. },
  1089. Plugins {
  1090. action: Option<String>,
  1091. target: Option<String>,
  1092. },
  1093. Agents {
  1094. args: Option<String>,
  1095. },
  1096. Skills {
  1097. args: Option<String>,
  1098. },
  1099. Doctor,
  1100. Login,
  1101. Logout,
  1102. Vim,
  1103. Upgrade,
  1104. Stats,
  1105. Share,
  1106. Feedback,
  1107. Files,
  1108. Fast,
  1109. Exit,
  1110. Summary,
  1111. Desktop,
  1112. Brief,
  1113. Advisor,
  1114. Stickers,
  1115. Insights,
  1116. Thinkback,
  1117. ReleaseNotes,
  1118. SecurityReview,
  1119. Keybindings,
  1120. PrivacySettings,
  1121. Plan {
  1122. mode: Option<String>,
  1123. },
  1124. Review {
  1125. scope: Option<String>,
  1126. },
  1127. Tasks {
  1128. args: Option<String>,
  1129. },
  1130. Theme {
  1131. name: Option<String>,
  1132. },
  1133. Voice {
  1134. mode: Option<String>,
  1135. },
  1136. Usage {
  1137. scope: Option<String>,
  1138. },
  1139. Rename {
  1140. name: Option<String>,
  1141. },
  1142. Copy {
  1143. target: Option<String>,
  1144. },
  1145. Hooks {
  1146. args: Option<String>,
  1147. },
  1148. Context {
  1149. action: Option<String>,
  1150. },
  1151. Color {
  1152. scheme: Option<String>,
  1153. },
  1154. Effort {
  1155. level: Option<String>,
  1156. },
  1157. Branch {
  1158. name: Option<String>,
  1159. },
  1160. Rewind {
  1161. steps: Option<String>,
  1162. },
  1163. Ide {
  1164. target: Option<String>,
  1165. },
  1166. Tag {
  1167. label: Option<String>,
  1168. },
  1169. OutputStyle {
  1170. style: Option<String>,
  1171. },
  1172. AddDir {
  1173. path: Option<String>,
  1174. },
  1175. Unknown(String),
  1176. }
  1177. #[derive(Debug, Clone, PartialEq, Eq)]
  1178. pub struct SlashCommandParseError {
  1179. message: String,
  1180. }
  1181. impl SlashCommandParseError {
  1182. fn new(message: impl Into<String>) -> Self {
  1183. Self {
  1184. message: message.into(),
  1185. }
  1186. }
  1187. }
  1188. impl fmt::Display for SlashCommandParseError {
  1189. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  1190. f.write_str(&self.message)
  1191. }
  1192. }
  1193. impl std::error::Error for SlashCommandParseError {}
  1194. impl SlashCommand {
  1195. pub fn parse(input: &str) -> Result<Option<Self>, SlashCommandParseError> {
  1196. validate_slash_command_input(input)
  1197. }
  1198. }
  1199. #[allow(clippy::too_many_lines)]
  1200. pub fn validate_slash_command_input(
  1201. input: &str,
  1202. ) -> Result<Option<SlashCommand>, SlashCommandParseError> {
  1203. let trimmed = input.trim();
  1204. if !trimmed.starts_with('/') {
  1205. return Ok(None);
  1206. }
  1207. let mut parts = trimmed.trim_start_matches('/').split_whitespace();
  1208. let command = parts.next().unwrap_or_default();
  1209. if command.is_empty() {
  1210. return Err(SlashCommandParseError::new(
  1211. "Slash command name is missing. Use /help to list available slash commands.",
  1212. ));
  1213. }
  1214. let args = parts.collect::<Vec<_>>();
  1215. let remainder = remainder_after_command(trimmed, command);
  1216. Ok(Some(match command {
  1217. "help" => {
  1218. validate_no_args(command, &args)?;
  1219. SlashCommand::Help
  1220. }
  1221. "status" => {
  1222. validate_no_args(command, &args)?;
  1223. SlashCommand::Status
  1224. }
  1225. "sandbox" => {
  1226. validate_no_args(command, &args)?;
  1227. SlashCommand::Sandbox
  1228. }
  1229. "compact" => {
  1230. validate_no_args(command, &args)?;
  1231. SlashCommand::Compact
  1232. }
  1233. "bughunter" => SlashCommand::Bughunter { scope: remainder },
  1234. "commit" => {
  1235. validate_no_args(command, &args)?;
  1236. SlashCommand::Commit
  1237. }
  1238. "pr" => SlashCommand::Pr { context: remainder },
  1239. "issue" => SlashCommand::Issue { context: remainder },
  1240. "ultraplan" => SlashCommand::Ultraplan { task: remainder },
  1241. "teleport" => SlashCommand::Teleport {
  1242. target: Some(require_remainder(command, remainder, "<symbol-or-path>")?),
  1243. },
  1244. "debug-tool-call" => {
  1245. validate_no_args(command, &args)?;
  1246. SlashCommand::DebugToolCall
  1247. }
  1248. "model" => SlashCommand::Model {
  1249. model: optional_single_arg(command, &args, "[model]")?,
  1250. },
  1251. "permissions" => SlashCommand::Permissions {
  1252. mode: parse_permissions_mode(&args)?,
  1253. },
  1254. "clear" => SlashCommand::Clear {
  1255. confirm: parse_clear_args(&args)?,
  1256. },
  1257. "cost" => {
  1258. validate_no_args(command, &args)?;
  1259. SlashCommand::Cost
  1260. }
  1261. "resume" => SlashCommand::Resume {
  1262. session_path: Some(require_remainder(command, remainder, "<session-path>")?),
  1263. },
  1264. "config" => SlashCommand::Config {
  1265. section: parse_config_section(&args)?,
  1266. },
  1267. "mcp" => parse_mcp_command(&args)?,
  1268. "memory" => {
  1269. validate_no_args(command, &args)?;
  1270. SlashCommand::Memory
  1271. }
  1272. "init" => {
  1273. validate_no_args(command, &args)?;
  1274. SlashCommand::Init
  1275. }
  1276. "diff" => {
  1277. validate_no_args(command, &args)?;
  1278. SlashCommand::Diff
  1279. }
  1280. "version" => {
  1281. validate_no_args(command, &args)?;
  1282. SlashCommand::Version
  1283. }
  1284. "export" => SlashCommand::Export { path: remainder },
  1285. "session" => parse_session_command(&args)?,
  1286. "plugin" | "plugins" | "marketplace" => parse_plugin_command(&args)?,
  1287. "agents" => SlashCommand::Agents {
  1288. args: parse_list_or_help_args(command, remainder)?,
  1289. },
  1290. "skills" => SlashCommand::Skills {
  1291. args: parse_skills_args(remainder.as_deref())?,
  1292. },
  1293. "doctor" => {
  1294. validate_no_args(command, &args)?;
  1295. SlashCommand::Doctor
  1296. }
  1297. "login" => {
  1298. validate_no_args(command, &args)?;
  1299. SlashCommand::Login
  1300. }
  1301. "logout" => {
  1302. validate_no_args(command, &args)?;
  1303. SlashCommand::Logout
  1304. }
  1305. "vim" => {
  1306. validate_no_args(command, &args)?;
  1307. SlashCommand::Vim
  1308. }
  1309. "upgrade" => {
  1310. validate_no_args(command, &args)?;
  1311. SlashCommand::Upgrade
  1312. }
  1313. "stats" => {
  1314. validate_no_args(command, &args)?;
  1315. SlashCommand::Stats
  1316. }
  1317. "share" => {
  1318. validate_no_args(command, &args)?;
  1319. SlashCommand::Share
  1320. }
  1321. "feedback" => {
  1322. validate_no_args(command, &args)?;
  1323. SlashCommand::Feedback
  1324. }
  1325. "files" => {
  1326. validate_no_args(command, &args)?;
  1327. SlashCommand::Files
  1328. }
  1329. "fast" => {
  1330. validate_no_args(command, &args)?;
  1331. SlashCommand::Fast
  1332. }
  1333. "exit" => {
  1334. validate_no_args(command, &args)?;
  1335. SlashCommand::Exit
  1336. }
  1337. "summary" => {
  1338. validate_no_args(command, &args)?;
  1339. SlashCommand::Summary
  1340. }
  1341. "desktop" => {
  1342. validate_no_args(command, &args)?;
  1343. SlashCommand::Desktop
  1344. }
  1345. "brief" => {
  1346. validate_no_args(command, &args)?;
  1347. SlashCommand::Brief
  1348. }
  1349. "advisor" => {
  1350. validate_no_args(command, &args)?;
  1351. SlashCommand::Advisor
  1352. }
  1353. "stickers" => {
  1354. validate_no_args(command, &args)?;
  1355. SlashCommand::Stickers
  1356. }
  1357. "insights" => {
  1358. validate_no_args(command, &args)?;
  1359. SlashCommand::Insights
  1360. }
  1361. "thinkback" => {
  1362. validate_no_args(command, &args)?;
  1363. SlashCommand::Thinkback
  1364. }
  1365. "release-notes" => {
  1366. validate_no_args(command, &args)?;
  1367. SlashCommand::ReleaseNotes
  1368. }
  1369. "security-review" => {
  1370. validate_no_args(command, &args)?;
  1371. SlashCommand::SecurityReview
  1372. }
  1373. "keybindings" => {
  1374. validate_no_args(command, &args)?;
  1375. SlashCommand::Keybindings
  1376. }
  1377. "privacy-settings" => {
  1378. validate_no_args(command, &args)?;
  1379. SlashCommand::PrivacySettings
  1380. }
  1381. "plan" => SlashCommand::Plan { mode: remainder },
  1382. "review" => SlashCommand::Review { scope: remainder },
  1383. "tasks" => SlashCommand::Tasks { args: remainder },
  1384. "theme" => SlashCommand::Theme { name: remainder },
  1385. "voice" => SlashCommand::Voice { mode: remainder },
  1386. "usage" => SlashCommand::Usage { scope: remainder },
  1387. "rename" => SlashCommand::Rename { name: remainder },
  1388. "copy" => SlashCommand::Copy { target: remainder },
  1389. "hooks" => SlashCommand::Hooks { args: remainder },
  1390. "context" => SlashCommand::Context { action: remainder },
  1391. "color" => SlashCommand::Color { scheme: remainder },
  1392. "effort" => SlashCommand::Effort { level: remainder },
  1393. "branch" => SlashCommand::Branch { name: remainder },
  1394. "rewind" => SlashCommand::Rewind { steps: remainder },
  1395. "ide" => SlashCommand::Ide { target: remainder },
  1396. "tag" => SlashCommand::Tag { label: remainder },
  1397. "output-style" => SlashCommand::OutputStyle { style: remainder },
  1398. "add-dir" => SlashCommand::AddDir { path: remainder },
  1399. other => SlashCommand::Unknown(other.to_string()),
  1400. }))
  1401. }
  1402. fn validate_no_args(command: &str, args: &[&str]) -> Result<(), SlashCommandParseError> {
  1403. if args.is_empty() {
  1404. return Ok(());
  1405. }
  1406. Err(command_error(
  1407. &format!("Unexpected arguments for /{command}."),
  1408. command,
  1409. &format!("/{command}"),
  1410. ))
  1411. }
  1412. fn optional_single_arg(
  1413. command: &str,
  1414. args: &[&str],
  1415. argument_hint: &str,
  1416. ) -> Result<Option<String>, SlashCommandParseError> {
  1417. match args {
  1418. [] => Ok(None),
  1419. [value] => Ok(Some((*value).to_string())),
  1420. _ => Err(usage_error(command, argument_hint)),
  1421. }
  1422. }
  1423. fn require_remainder(
  1424. command: &str,
  1425. remainder: Option<String>,
  1426. argument_hint: &str,
  1427. ) -> Result<String, SlashCommandParseError> {
  1428. remainder.ok_or_else(|| usage_error(command, argument_hint))
  1429. }
  1430. fn parse_permissions_mode(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
  1431. let mode = optional_single_arg(
  1432. "permissions",
  1433. args,
  1434. "[read-only|workspace-write|danger-full-access]",
  1435. )?;
  1436. if let Some(mode) = mode {
  1437. if matches!(
  1438. mode.as_str(),
  1439. "read-only" | "workspace-write" | "danger-full-access"
  1440. ) {
  1441. return Ok(Some(mode));
  1442. }
  1443. return Err(command_error(
  1444. &format!(
  1445. "Unsupported /permissions mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
  1446. ),
  1447. "permissions",
  1448. "/permissions [read-only|workspace-write|danger-full-access]",
  1449. ));
  1450. }
  1451. Ok(None)
  1452. }
  1453. fn parse_clear_args(args: &[&str]) -> Result<bool, SlashCommandParseError> {
  1454. match args {
  1455. [] => Ok(false),
  1456. ["--confirm"] => Ok(true),
  1457. [unexpected] => Err(command_error(
  1458. &format!("Unsupported /clear argument '{unexpected}'. Use /clear or /clear --confirm."),
  1459. "clear",
  1460. "/clear [--confirm]",
  1461. )),
  1462. _ => Err(usage_error("clear", "[--confirm]")),
  1463. }
  1464. }
  1465. fn parse_config_section(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
  1466. let section = optional_single_arg("config", args, "[env|hooks|model|plugins]")?;
  1467. if let Some(section) = section {
  1468. if matches!(section.as_str(), "env" | "hooks" | "model" | "plugins") {
  1469. return Ok(Some(section));
  1470. }
  1471. return Err(command_error(
  1472. &format!("Unsupported /config section '{section}'. Use env, hooks, model, or plugins."),
  1473. "config",
  1474. "/config [env|hooks|model|plugins]",
  1475. ));
  1476. }
  1477. Ok(None)
  1478. }
  1479. fn parse_session_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
  1480. match args {
  1481. [] => Ok(SlashCommand::Session {
  1482. action: None,
  1483. target: None,
  1484. }),
  1485. ["list"] => Ok(SlashCommand::Session {
  1486. action: Some("list".to_string()),
  1487. target: None,
  1488. }),
  1489. ["list", ..] => Err(usage_error("session", "[list|switch <session-id>|fork [branch-name]]")),
  1490. ["switch"] => Err(usage_error("session switch", "<session-id>")),
  1491. ["switch", target] => Ok(SlashCommand::Session {
  1492. action: Some("switch".to_string()),
  1493. target: Some((*target).to_string()),
  1494. }),
  1495. ["switch", ..] => Err(command_error(
  1496. "Unexpected arguments for /session switch.",
  1497. "session",
  1498. "/session switch <session-id>",
  1499. )),
  1500. ["fork"] => Ok(SlashCommand::Session {
  1501. action: Some("fork".to_string()),
  1502. target: None,
  1503. }),
  1504. ["fork", target] => Ok(SlashCommand::Session {
  1505. action: Some("fork".to_string()),
  1506. target: Some((*target).to_string()),
  1507. }),
  1508. ["fork", ..] => Err(command_error(
  1509. "Unexpected arguments for /session fork.",
  1510. "session",
  1511. "/session fork [branch-name]",
  1512. )),
  1513. [action, ..] => Err(command_error(
  1514. &format!(
  1515. "Unknown /session action '{action}'. Use list, switch <session-id>, or fork [branch-name]."
  1516. ),
  1517. "session",
  1518. "/session [list|switch <session-id>|fork [branch-name]]",
  1519. )),
  1520. }
  1521. }
  1522. fn parse_mcp_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
  1523. match args {
  1524. [] => Ok(SlashCommand::Mcp {
  1525. action: None,
  1526. target: None,
  1527. }),
  1528. ["list"] => Ok(SlashCommand::Mcp {
  1529. action: Some("list".to_string()),
  1530. target: None,
  1531. }),
  1532. ["list", ..] => Err(usage_error("mcp list", "")),
  1533. ["show"] => Err(usage_error("mcp show", "<server>")),
  1534. ["show", target] => Ok(SlashCommand::Mcp {
  1535. action: Some("show".to_string()),
  1536. target: Some((*target).to_string()),
  1537. }),
  1538. ["show", ..] => Err(command_error(
  1539. "Unexpected arguments for /mcp show.",
  1540. "mcp",
  1541. "/mcp show <server>",
  1542. )),
  1543. ["help" | "-h" | "--help"] => Ok(SlashCommand::Mcp {
  1544. action: Some("help".to_string()),
  1545. target: None,
  1546. }),
  1547. [action, ..] => Err(command_error(
  1548. &format!("Unknown /mcp action '{action}'. Use list, show <server>, or help."),
  1549. "mcp",
  1550. "/mcp [list|show <server>|help]",
  1551. )),
  1552. }
  1553. }
  1554. fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
  1555. match args {
  1556. [] => Ok(SlashCommand::Plugins {
  1557. action: None,
  1558. target: None,
  1559. }),
  1560. ["list"] => Ok(SlashCommand::Plugins {
  1561. action: Some("list".to_string()),
  1562. target: None,
  1563. }),
  1564. ["list", ..] => Err(usage_error("plugin list", "")),
  1565. ["install"] => Err(usage_error("plugin install", "<path>")),
  1566. ["install", target @ ..] => Ok(SlashCommand::Plugins {
  1567. action: Some("install".to_string()),
  1568. target: Some(target.join(" ")),
  1569. }),
  1570. ["enable"] => Err(usage_error("plugin enable", "<name>")),
  1571. ["enable", target] => Ok(SlashCommand::Plugins {
  1572. action: Some("enable".to_string()),
  1573. target: Some((*target).to_string()),
  1574. }),
  1575. ["enable", ..] => Err(command_error(
  1576. "Unexpected arguments for /plugin enable.",
  1577. "plugin",
  1578. "/plugin enable <name>",
  1579. )),
  1580. ["disable"] => Err(usage_error("plugin disable", "<name>")),
  1581. ["disable", target] => Ok(SlashCommand::Plugins {
  1582. action: Some("disable".to_string()),
  1583. target: Some((*target).to_string()),
  1584. }),
  1585. ["disable", ..] => Err(command_error(
  1586. "Unexpected arguments for /plugin disable.",
  1587. "plugin",
  1588. "/plugin disable <name>",
  1589. )),
  1590. ["uninstall"] => Err(usage_error("plugin uninstall", "<id>")),
  1591. ["uninstall", target] => Ok(SlashCommand::Plugins {
  1592. action: Some("uninstall".to_string()),
  1593. target: Some((*target).to_string()),
  1594. }),
  1595. ["uninstall", ..] => Err(command_error(
  1596. "Unexpected arguments for /plugin uninstall.",
  1597. "plugin",
  1598. "/plugin uninstall <id>",
  1599. )),
  1600. ["update"] => Err(usage_error("plugin update", "<id>")),
  1601. ["update", target] => Ok(SlashCommand::Plugins {
  1602. action: Some("update".to_string()),
  1603. target: Some((*target).to_string()),
  1604. }),
  1605. ["update", ..] => Err(command_error(
  1606. "Unexpected arguments for /plugin update.",
  1607. "plugin",
  1608. "/plugin update <id>",
  1609. )),
  1610. [action, ..] => Err(command_error(
  1611. &format!(
  1612. "Unknown /plugin action '{action}'. Use list, install <path>, enable <name>, disable <name>, uninstall <id>, or update <id>."
  1613. ),
  1614. "plugin",
  1615. "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
  1616. )),
  1617. }
  1618. }
  1619. fn parse_list_or_help_args(
  1620. command: &str,
  1621. args: Option<String>,
  1622. ) -> Result<Option<String>, SlashCommandParseError> {
  1623. match normalize_optional_args(args.as_deref()) {
  1624. None | Some("list" | "help" | "-h" | "--help") => Ok(args),
  1625. Some(unexpected) => Err(command_error(
  1626. &format!(
  1627. "Unexpected arguments for /{command}: {unexpected}. Use /{command}, /{command} list, or /{command} help."
  1628. ),
  1629. command,
  1630. &format!("/{command} [list|help]"),
  1631. )),
  1632. }
  1633. }
  1634. fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandParseError> {
  1635. let Some(args) = normalize_optional_args(args) else {
  1636. return Ok(None);
  1637. };
  1638. if matches!(args, "list" | "help" | "-h" | "--help") {
  1639. return Ok(Some(args.to_string()));
  1640. }
  1641. if args == "install" {
  1642. return Err(command_error(
  1643. "Usage: /skills install <path>",
  1644. "skills",
  1645. "/skills install <path>",
  1646. ));
  1647. }
  1648. if let Some(target) = args.strip_prefix("install").map(str::trim) {
  1649. if !target.is_empty() {
  1650. return Ok(Some(format!("install {target}")));
  1651. }
  1652. }
  1653. Err(command_error(
  1654. &format!(
  1655. "Unexpected arguments for /skills: {args}. Use /skills, /skills list, /skills install <path>, or /skills help."
  1656. ),
  1657. "skills",
  1658. "/skills [list|install <path>|help]",
  1659. ))
  1660. }
  1661. fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
  1662. let usage = format!("/{command} {argument_hint}");
  1663. let usage = usage.trim_end().to_string();
  1664. command_error(
  1665. &format!("Usage: {usage}"),
  1666. command_root_name(command),
  1667. &usage,
  1668. )
  1669. }
  1670. fn command_error(message: &str, command: &str, usage: &str) -> SlashCommandParseError {
  1671. let detail = render_slash_command_help_detail(command)
  1672. .map(|detail| format!("\n\n{detail}"))
  1673. .unwrap_or_default();
  1674. SlashCommandParseError::new(format!("{message}\n Usage {usage}{detail}"))
  1675. }
  1676. fn remainder_after_command(input: &str, command: &str) -> Option<String> {
  1677. input
  1678. .trim()
  1679. .strip_prefix(&format!("/{command}"))
  1680. .map(str::trim)
  1681. .filter(|value| !value.is_empty())
  1682. .map(ToOwned::to_owned)
  1683. }
  1684. fn find_slash_command_spec(name: &str) -> Option<&'static SlashCommandSpec> {
  1685. slash_command_specs().iter().find(|spec| {
  1686. spec.name.eq_ignore_ascii_case(name)
  1687. || spec
  1688. .aliases
  1689. .iter()
  1690. .any(|alias| alias.eq_ignore_ascii_case(name))
  1691. })
  1692. }
  1693. fn command_root_name(command: &str) -> &str {
  1694. command.split_whitespace().next().unwrap_or(command)
  1695. }
  1696. fn slash_command_usage(spec: &SlashCommandSpec) -> String {
  1697. match spec.argument_hint {
  1698. Some(argument_hint) => format!("/{} {argument_hint}", spec.name),
  1699. None => format!("/{}", spec.name),
  1700. }
  1701. }
  1702. fn slash_command_detail_lines(spec: &SlashCommandSpec) -> Vec<String> {
  1703. let mut lines = vec![format!("/{}", spec.name)];
  1704. lines.push(format!(" Summary {}", spec.summary));
  1705. lines.push(format!(" Usage {}", slash_command_usage(spec)));
  1706. lines.push(format!(
  1707. " Category {}",
  1708. slash_command_category(spec.name)
  1709. ));
  1710. if !spec.aliases.is_empty() {
  1711. lines.push(format!(
  1712. " Aliases {}",
  1713. spec.aliases
  1714. .iter()
  1715. .map(|alias| format!("/{alias}"))
  1716. .collect::<Vec<_>>()
  1717. .join(", ")
  1718. ));
  1719. }
  1720. if spec.resume_supported {
  1721. lines.push(" Resume Supported with --resume SESSION.jsonl".to_string());
  1722. }
  1723. lines
  1724. }
  1725. #[must_use]
  1726. pub fn render_slash_command_help_detail(name: &str) -> Option<String> {
  1727. find_slash_command_spec(name).map(|spec| slash_command_detail_lines(spec).join("\n"))
  1728. }
  1729. #[must_use]
  1730. pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
  1731. SLASH_COMMAND_SPECS
  1732. }
  1733. #[must_use]
  1734. pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
  1735. slash_command_specs()
  1736. .iter()
  1737. .filter(|spec| spec.resume_supported)
  1738. .collect()
  1739. }
  1740. fn slash_command_category(name: &str) -> &'static str {
  1741. match name {
  1742. "help" | "status" | "sandbox" | "model" | "permissions" | "cost" | "resume" | "session"
  1743. | "version" | "login" | "logout" | "usage" | "stats" | "rename" | "privacy-settings" => {
  1744. "Session & visibility"
  1745. }
  1746. "compact" | "clear" | "config" | "memory" | "init" | "diff" | "commit" | "pr" | "issue"
  1747. | "export" | "plugin" | "branch" | "add-dir" | "files" | "hooks" | "release-notes" => {
  1748. "Workspace & git"
  1749. }
  1750. "agents" | "skills" | "teleport" | "debug-tool-call" | "mcp" | "context" | "tasks"
  1751. | "doctor" | "ide" | "desktop" => "Discovery & debugging",
  1752. "bughunter" | "ultraplan" | "review" | "security-review" | "advisor" | "insights" => {
  1753. "Analysis & automation"
  1754. }
  1755. "theme" | "vim" | "voice" | "color" | "effort" | "fast" | "brief" | "output-style"
  1756. | "keybindings" | "stickers" => "Appearance & input",
  1757. "copy" | "share" | "feedback" | "summary" | "tag" | "thinkback" | "plan" | "exit"
  1758. | "upgrade" | "rewind" => "Communication & control",
  1759. _ => "Other",
  1760. }
  1761. }
  1762. fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String {
  1763. let name = slash_command_usage(spec);
  1764. let alias_suffix = if spec.aliases.is_empty() {
  1765. String::new()
  1766. } else {
  1767. format!(
  1768. " (aliases: {})",
  1769. spec.aliases
  1770. .iter()
  1771. .map(|alias| format!("/{alias}"))
  1772. .collect::<Vec<_>>()
  1773. .join(", ")
  1774. )
  1775. };
  1776. let resume = if spec.resume_supported {
  1777. " [resume]"
  1778. } else {
  1779. ""
  1780. };
  1781. format!(" {name:<66} {}{alias_suffix}{resume}", spec.summary)
  1782. }
  1783. fn levenshtein_distance(left: &str, right: &str) -> usize {
  1784. if left == right {
  1785. return 0;
  1786. }
  1787. if left.is_empty() {
  1788. return right.chars().count();
  1789. }
  1790. if right.is_empty() {
  1791. return left.chars().count();
  1792. }
  1793. let right_chars = right.chars().collect::<Vec<_>>();
  1794. let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
  1795. let mut current = vec![0; right_chars.len() + 1];
  1796. for (left_index, left_char) in left.chars().enumerate() {
  1797. current[0] = left_index + 1;
  1798. for (right_index, right_char) in right_chars.iter().enumerate() {
  1799. let substitution_cost = usize::from(left_char != *right_char);
  1800. current[right_index + 1] = (current[right_index] + 1)
  1801. .min(previous[right_index + 1] + 1)
  1802. .min(previous[right_index] + substitution_cost);
  1803. }
  1804. previous.clone_from(&current);
  1805. }
  1806. previous[right_chars.len()]
  1807. }
  1808. #[must_use]
  1809. pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec<String> {
  1810. let query = input.trim().trim_start_matches('/').to_ascii_lowercase();
  1811. if query.is_empty() || limit == 0 {
  1812. return Vec::new();
  1813. }
  1814. let mut suggestions = slash_command_specs()
  1815. .iter()
  1816. .filter_map(|spec| {
  1817. let best = std::iter::once(spec.name)
  1818. .chain(spec.aliases.iter().copied())
  1819. .map(str::to_ascii_lowercase)
  1820. .map(|candidate| {
  1821. let prefix_rank =
  1822. if candidate.starts_with(&query) || query.starts_with(&candidate) {
  1823. 0
  1824. } else if candidate.contains(&query) || query.contains(&candidate) {
  1825. 1
  1826. } else {
  1827. 2
  1828. };
  1829. let distance = levenshtein_distance(&candidate, &query);
  1830. (prefix_rank, distance)
  1831. })
  1832. .min();
  1833. best.and_then(|(prefix_rank, distance)| {
  1834. if prefix_rank <= 1 || distance <= 2 {
  1835. Some((prefix_rank, distance, spec.name.len(), spec.name))
  1836. } else {
  1837. None
  1838. }
  1839. })
  1840. })
  1841. .collect::<Vec<_>>();
  1842. suggestions.sort_unstable();
  1843. suggestions
  1844. .into_iter()
  1845. .map(|(_, _, _, name)| format!("/{name}"))
  1846. .take(limit)
  1847. .collect()
  1848. }
  1849. #[must_use]
  1850. pub fn render_slash_command_help() -> String {
  1851. let mut lines = vec![
  1852. "Slash commands".to_string(),
  1853. " Start here /status, /diff, /agents, /skills, /commit".to_string(),
  1854. " [resume] also works with --resume SESSION.jsonl".to_string(),
  1855. String::new(),
  1856. ];
  1857. let categories = [
  1858. "Session & visibility",
  1859. "Workspace & git",
  1860. "Discovery & debugging",
  1861. "Analysis & automation",
  1862. ];
  1863. for category in categories {
  1864. lines.push(category.to_string());
  1865. for spec in slash_command_specs()
  1866. .iter()
  1867. .filter(|spec| slash_command_category(spec.name) == category)
  1868. {
  1869. lines.push(format_slash_command_help_line(spec));
  1870. }
  1871. lines.push(String::new());
  1872. }
  1873. lines
  1874. .into_iter()
  1875. .rev()
  1876. .skip_while(String::is_empty)
  1877. .collect::<Vec<_>>()
  1878. .into_iter()
  1879. .rev()
  1880. .collect::<Vec<_>>()
  1881. .join("\n")
  1882. }
  1883. #[derive(Debug, Clone, PartialEq, Eq)]
  1884. pub struct SlashCommandResult {
  1885. pub message: String,
  1886. pub session: Session,
  1887. }
  1888. #[derive(Debug, Clone, PartialEq, Eq)]
  1889. pub struct PluginsCommandResult {
  1890. pub message: String,
  1891. pub reload_runtime: bool,
  1892. }
  1893. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
  1894. enum DefinitionSource {
  1895. ProjectCodex,
  1896. ProjectClaude,
  1897. UserCodexHome,
  1898. UserCodex,
  1899. UserClaude,
  1900. }
  1901. impl DefinitionSource {
  1902. fn label(self) -> &'static str {
  1903. match self {
  1904. Self::ProjectCodex => "Project (.codex)",
  1905. Self::ProjectClaude => "Project (.claude)",
  1906. Self::UserCodexHome => "User ($CODEX_HOME)",
  1907. Self::UserCodex => "User (~/.codex)",
  1908. Self::UserClaude => "User (~/.claude)",
  1909. }
  1910. }
  1911. }
  1912. #[derive(Debug, Clone, PartialEq, Eq)]
  1913. struct AgentSummary {
  1914. name: String,
  1915. description: Option<String>,
  1916. model: Option<String>,
  1917. reasoning_effort: Option<String>,
  1918. source: DefinitionSource,
  1919. shadowed_by: Option<DefinitionSource>,
  1920. }
  1921. #[derive(Debug, Clone, PartialEq, Eq)]
  1922. struct SkillSummary {
  1923. name: String,
  1924. description: Option<String>,
  1925. source: DefinitionSource,
  1926. shadowed_by: Option<DefinitionSource>,
  1927. origin: SkillOrigin,
  1928. }
  1929. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  1930. enum SkillOrigin {
  1931. SkillsDir,
  1932. LegacyCommandsDir,
  1933. }
  1934. impl SkillOrigin {
  1935. fn detail_label(self) -> Option<&'static str> {
  1936. match self {
  1937. Self::SkillsDir => None,
  1938. Self::LegacyCommandsDir => Some("legacy /commands"),
  1939. }
  1940. }
  1941. }
  1942. #[derive(Debug, Clone, PartialEq, Eq)]
  1943. struct SkillRoot {
  1944. source: DefinitionSource,
  1945. path: PathBuf,
  1946. origin: SkillOrigin,
  1947. }
  1948. #[derive(Debug, Clone, PartialEq, Eq)]
  1949. struct InstalledSkill {
  1950. invocation_name: String,
  1951. display_name: Option<String>,
  1952. source: PathBuf,
  1953. registry_root: PathBuf,
  1954. installed_path: PathBuf,
  1955. }
  1956. #[derive(Debug, Clone, PartialEq, Eq)]
  1957. enum SkillInstallSource {
  1958. Directory { root: PathBuf, prompt_path: PathBuf },
  1959. MarkdownFile { path: PathBuf },
  1960. }
  1961. #[allow(clippy::too_many_lines)]
  1962. pub fn handle_plugins_slash_command(
  1963. action: Option<&str>,
  1964. target: Option<&str>,
  1965. manager: &mut PluginManager,
  1966. ) -> Result<PluginsCommandResult, PluginError> {
  1967. match action {
  1968. None | Some("list") => Ok(PluginsCommandResult {
  1969. message: render_plugins_report(&manager.list_installed_plugins()?),
  1970. reload_runtime: false,
  1971. }),
  1972. Some("install") => {
  1973. let Some(target) = target else {
  1974. return Ok(PluginsCommandResult {
  1975. message: "Usage: /plugins install <path>".to_string(),
  1976. reload_runtime: false,
  1977. });
  1978. };
  1979. let install = manager.install(target)?;
  1980. let plugin = manager
  1981. .list_installed_plugins()?
  1982. .into_iter()
  1983. .find(|plugin| plugin.metadata.id == install.plugin_id);
  1984. Ok(PluginsCommandResult {
  1985. message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
  1986. reload_runtime: true,
  1987. })
  1988. }
  1989. Some("enable") => {
  1990. let Some(target) = target else {
  1991. return Ok(PluginsCommandResult {
  1992. message: "Usage: /plugins enable <name>".to_string(),
  1993. reload_runtime: false,
  1994. });
  1995. };
  1996. let plugin = resolve_plugin_target(manager, target)?;
  1997. manager.enable(&plugin.metadata.id)?;
  1998. Ok(PluginsCommandResult {
  1999. message: format!(
  2000. "Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled",
  2001. plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
  2002. ),
  2003. reload_runtime: true,
  2004. })
  2005. }
  2006. Some("disable") => {
  2007. let Some(target) = target else {
  2008. return Ok(PluginsCommandResult {
  2009. message: "Usage: /plugins disable <name>".to_string(),
  2010. reload_runtime: false,
  2011. });
  2012. };
  2013. let plugin = resolve_plugin_target(manager, target)?;
  2014. manager.disable(&plugin.metadata.id)?;
  2015. Ok(PluginsCommandResult {
  2016. message: format!(
  2017. "Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled",
  2018. plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
  2019. ),
  2020. reload_runtime: true,
  2021. })
  2022. }
  2023. Some("uninstall") => {
  2024. let Some(target) = target else {
  2025. return Ok(PluginsCommandResult {
  2026. message: "Usage: /plugins uninstall <plugin-id>".to_string(),
  2027. reload_runtime: false,
  2028. });
  2029. };
  2030. manager.uninstall(target)?;
  2031. Ok(PluginsCommandResult {
  2032. message: format!("Plugins\n Result uninstalled {target}"),
  2033. reload_runtime: true,
  2034. })
  2035. }
  2036. Some("update") => {
  2037. let Some(target) = target else {
  2038. return Ok(PluginsCommandResult {
  2039. message: "Usage: /plugins update <plugin-id>".to_string(),
  2040. reload_runtime: false,
  2041. });
  2042. };
  2043. let update = manager.update(target)?;
  2044. let plugin = manager
  2045. .list_installed_plugins()?
  2046. .into_iter()
  2047. .find(|plugin| plugin.metadata.id == update.plugin_id);
  2048. Ok(PluginsCommandResult {
  2049. message: format!(
  2050. "Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
  2051. update.plugin_id,
  2052. plugin
  2053. .as_ref()
  2054. .map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
  2055. update.old_version,
  2056. update.new_version,
  2057. plugin
  2058. .as_ref()
  2059. .map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
  2060. ),
  2061. reload_runtime: true,
  2062. })
  2063. }
  2064. Some(other) => Ok(PluginsCommandResult {
  2065. message: format!(
  2066. "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
  2067. ),
  2068. reload_runtime: false,
  2069. }),
  2070. }
  2071. }
  2072. pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
  2073. match normalize_optional_args(args) {
  2074. None | Some("list") => {
  2075. let roots = discover_definition_roots(cwd, "agents");
  2076. let agents = load_agents_from_roots(&roots)?;
  2077. Ok(render_agents_report(&agents))
  2078. }
  2079. Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)),
  2080. Some(args) => Ok(render_agents_usage(Some(args))),
  2081. }
  2082. }
  2083. pub fn handle_mcp_slash_command(
  2084. args: Option<&str>,
  2085. cwd: &Path,
  2086. ) -> Result<String, runtime::ConfigError> {
  2087. let loader = ConfigLoader::default_for(cwd);
  2088. render_mcp_report_for(&loader, cwd, args)
  2089. }
  2090. pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
  2091. match normalize_optional_args(args) {
  2092. None | Some("list") => {
  2093. let roots = discover_skill_roots(cwd);
  2094. let skills = load_skills_from_roots(&roots)?;
  2095. Ok(render_skills_report(&skills))
  2096. }
  2097. Some("install") => Ok(render_skills_usage(Some("install"))),
  2098. Some(args) if args.starts_with("install ") => {
  2099. let target = args["install ".len()..].trim();
  2100. if target.is_empty() {
  2101. return Ok(render_skills_usage(Some("install")));
  2102. }
  2103. let install = install_skill(target, cwd)?;
  2104. Ok(render_skill_install_report(&install))
  2105. }
  2106. Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
  2107. Some(args) => Ok(render_skills_usage(Some(args))),
  2108. }
  2109. }
  2110. fn render_mcp_report_for(
  2111. loader: &ConfigLoader,
  2112. cwd: &Path,
  2113. args: Option<&str>,
  2114. ) -> Result<String, runtime::ConfigError> {
  2115. match normalize_optional_args(args) {
  2116. None | Some("list") => {
  2117. let runtime_config = loader.load()?;
  2118. Ok(render_mcp_summary_report(
  2119. cwd,
  2120. runtime_config.mcp().servers(),
  2121. ))
  2122. }
  2123. Some("-h" | "--help" | "help") => Ok(render_mcp_usage(None)),
  2124. Some("show") => Ok(render_mcp_usage(Some("show"))),
  2125. Some(args) if args.split_whitespace().next() == Some("show") => {
  2126. let mut parts = args.split_whitespace();
  2127. let _ = parts.next();
  2128. let Some(server_name) = parts.next() else {
  2129. return Ok(render_mcp_usage(Some("show")));
  2130. };
  2131. if parts.next().is_some() {
  2132. return Ok(render_mcp_usage(Some(args)));
  2133. }
  2134. let runtime_config = loader.load()?;
  2135. Ok(render_mcp_server_report(
  2136. cwd,
  2137. server_name,
  2138. runtime_config.mcp().get(server_name),
  2139. ))
  2140. }
  2141. Some(args) => Ok(render_mcp_usage(Some(args))),
  2142. }
  2143. }
  2144. #[must_use]
  2145. pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
  2146. let mut lines = vec!["Plugins".to_string()];
  2147. if plugins.is_empty() {
  2148. lines.push(" No plugins installed.".to_string());
  2149. return lines.join("\n");
  2150. }
  2151. for plugin in plugins {
  2152. let enabled = if plugin.enabled {
  2153. "enabled"
  2154. } else {
  2155. "disabled"
  2156. };
  2157. lines.push(format!(
  2158. " {name:<20} v{version:<10} {enabled}",
  2159. name = plugin.metadata.name,
  2160. version = plugin.metadata.version,
  2161. ));
  2162. }
  2163. lines.join("\n")
  2164. }
  2165. fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
  2166. let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
  2167. let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
  2168. let enabled = plugin.is_some_and(|plugin| plugin.enabled);
  2169. format!(
  2170. "Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
  2171. if enabled { "enabled" } else { "disabled" }
  2172. )
  2173. }
  2174. fn resolve_plugin_target(
  2175. manager: &PluginManager,
  2176. target: &str,
  2177. ) -> Result<PluginSummary, PluginError> {
  2178. let mut matches = manager
  2179. .list_installed_plugins()?
  2180. .into_iter()
  2181. .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
  2182. .collect::<Vec<_>>();
  2183. match matches.len() {
  2184. 1 => Ok(matches.remove(0)),
  2185. 0 => Err(PluginError::NotFound(format!(
  2186. "plugin `{target}` is not installed or discoverable"
  2187. ))),
  2188. _ => Err(PluginError::InvalidManifest(format!(
  2189. "plugin name `{target}` is ambiguous; use the full plugin id"
  2190. ))),
  2191. }
  2192. }
  2193. fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
  2194. let mut roots = Vec::new();
  2195. for ancestor in cwd.ancestors() {
  2196. push_unique_root(
  2197. &mut roots,
  2198. DefinitionSource::ProjectCodex,
  2199. ancestor.join(".codex").join(leaf),
  2200. );
  2201. push_unique_root(
  2202. &mut roots,
  2203. DefinitionSource::ProjectClaude,
  2204. ancestor.join(".claude").join(leaf),
  2205. );
  2206. }
  2207. if let Ok(codex_home) = env::var("CODEX_HOME") {
  2208. push_unique_root(
  2209. &mut roots,
  2210. DefinitionSource::UserCodexHome,
  2211. PathBuf::from(codex_home).join(leaf),
  2212. );
  2213. }
  2214. if let Some(home) = env::var_os("HOME") {
  2215. let home = PathBuf::from(home);
  2216. push_unique_root(
  2217. &mut roots,
  2218. DefinitionSource::UserCodex,
  2219. home.join(".codex").join(leaf),
  2220. );
  2221. push_unique_root(
  2222. &mut roots,
  2223. DefinitionSource::UserClaude,
  2224. home.join(".claude").join(leaf),
  2225. );
  2226. }
  2227. roots
  2228. }
  2229. fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
  2230. let mut roots = Vec::new();
  2231. for ancestor in cwd.ancestors() {
  2232. push_unique_skill_root(
  2233. &mut roots,
  2234. DefinitionSource::ProjectCodex,
  2235. ancestor.join(".codex").join("skills"),
  2236. SkillOrigin::SkillsDir,
  2237. );
  2238. push_unique_skill_root(
  2239. &mut roots,
  2240. DefinitionSource::ProjectClaude,
  2241. ancestor.join(".claude").join("skills"),
  2242. SkillOrigin::SkillsDir,
  2243. );
  2244. push_unique_skill_root(
  2245. &mut roots,
  2246. DefinitionSource::ProjectCodex,
  2247. ancestor.join(".codex").join("commands"),
  2248. SkillOrigin::LegacyCommandsDir,
  2249. );
  2250. push_unique_skill_root(
  2251. &mut roots,
  2252. DefinitionSource::ProjectClaude,
  2253. ancestor.join(".claude").join("commands"),
  2254. SkillOrigin::LegacyCommandsDir,
  2255. );
  2256. }
  2257. if let Ok(codex_home) = env::var("CODEX_HOME") {
  2258. let codex_home = PathBuf::from(codex_home);
  2259. push_unique_skill_root(
  2260. &mut roots,
  2261. DefinitionSource::UserCodexHome,
  2262. codex_home.join("skills"),
  2263. SkillOrigin::SkillsDir,
  2264. );
  2265. push_unique_skill_root(
  2266. &mut roots,
  2267. DefinitionSource::UserCodexHome,
  2268. codex_home.join("commands"),
  2269. SkillOrigin::LegacyCommandsDir,
  2270. );
  2271. }
  2272. if let Some(home) = env::var_os("HOME") {
  2273. let home = PathBuf::from(home);
  2274. push_unique_skill_root(
  2275. &mut roots,
  2276. DefinitionSource::UserCodex,
  2277. home.join(".codex").join("skills"),
  2278. SkillOrigin::SkillsDir,
  2279. );
  2280. push_unique_skill_root(
  2281. &mut roots,
  2282. DefinitionSource::UserCodex,
  2283. home.join(".codex").join("commands"),
  2284. SkillOrigin::LegacyCommandsDir,
  2285. );
  2286. push_unique_skill_root(
  2287. &mut roots,
  2288. DefinitionSource::UserClaude,
  2289. home.join(".claude").join("skills"),
  2290. SkillOrigin::SkillsDir,
  2291. );
  2292. push_unique_skill_root(
  2293. &mut roots,
  2294. DefinitionSource::UserClaude,
  2295. home.join(".claude").join("commands"),
  2296. SkillOrigin::LegacyCommandsDir,
  2297. );
  2298. }
  2299. roots
  2300. }
  2301. fn install_skill(source: &str, cwd: &Path) -> std::io::Result<InstalledSkill> {
  2302. let registry_root = default_skill_install_root()?;
  2303. install_skill_into(source, cwd, &registry_root)
  2304. }
  2305. fn install_skill_into(
  2306. source: &str,
  2307. cwd: &Path,
  2308. registry_root: &Path,
  2309. ) -> std::io::Result<InstalledSkill> {
  2310. let source = resolve_skill_install_source(source, cwd)?;
  2311. let prompt_path = source.prompt_path();
  2312. let contents = fs::read_to_string(prompt_path)?;
  2313. let display_name = parse_skill_frontmatter(&contents).0;
  2314. let invocation_name = derive_skill_install_name(&source, display_name.as_deref())?;
  2315. let installed_path = registry_root.join(&invocation_name);
  2316. if installed_path.exists() {
  2317. return Err(std::io::Error::new(
  2318. std::io::ErrorKind::AlreadyExists,
  2319. format!(
  2320. "skill '{invocation_name}' is already installed at {}",
  2321. installed_path.display()
  2322. ),
  2323. ));
  2324. }
  2325. fs::create_dir_all(&installed_path)?;
  2326. let install_result = match &source {
  2327. SkillInstallSource::Directory { root, .. } => {
  2328. copy_directory_contents(root, &installed_path)
  2329. }
  2330. SkillInstallSource::MarkdownFile { path } => {
  2331. fs::copy(path, installed_path.join("SKILL.md")).map(|_| ())
  2332. }
  2333. };
  2334. if let Err(error) = install_result {
  2335. let _ = fs::remove_dir_all(&installed_path);
  2336. return Err(error);
  2337. }
  2338. Ok(InstalledSkill {
  2339. invocation_name,
  2340. display_name,
  2341. source: source.report_path().to_path_buf(),
  2342. registry_root: registry_root.to_path_buf(),
  2343. installed_path,
  2344. })
  2345. }
  2346. fn default_skill_install_root() -> std::io::Result<PathBuf> {
  2347. if let Ok(codex_home) = env::var("CODEX_HOME") {
  2348. return Ok(PathBuf::from(codex_home).join("skills"));
  2349. }
  2350. if let Some(home) = env::var_os("HOME") {
  2351. return Ok(PathBuf::from(home).join(".codex").join("skills"));
  2352. }
  2353. Err(std::io::Error::new(
  2354. std::io::ErrorKind::NotFound,
  2355. "unable to resolve a skills install root; set CODEX_HOME or HOME",
  2356. ))
  2357. }
  2358. fn resolve_skill_install_source(source: &str, cwd: &Path) -> std::io::Result<SkillInstallSource> {
  2359. let candidate = PathBuf::from(source);
  2360. let source = if candidate.is_absolute() {
  2361. candidate
  2362. } else {
  2363. cwd.join(candidate)
  2364. };
  2365. let source = fs::canonicalize(&source)?;
  2366. if source.is_dir() {
  2367. let prompt_path = source.join("SKILL.md");
  2368. if !prompt_path.is_file() {
  2369. return Err(std::io::Error::new(
  2370. std::io::ErrorKind::InvalidInput,
  2371. format!(
  2372. "skill directory '{}' must contain SKILL.md",
  2373. source.display()
  2374. ),
  2375. ));
  2376. }
  2377. return Ok(SkillInstallSource::Directory {
  2378. root: source,
  2379. prompt_path,
  2380. });
  2381. }
  2382. if source
  2383. .extension()
  2384. .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
  2385. {
  2386. return Ok(SkillInstallSource::MarkdownFile { path: source });
  2387. }
  2388. Err(std::io::Error::new(
  2389. std::io::ErrorKind::InvalidInput,
  2390. format!(
  2391. "skill source '{}' must be a directory with SKILL.md or a markdown file",
  2392. source.display()
  2393. ),
  2394. ))
  2395. }
  2396. fn derive_skill_install_name(
  2397. source: &SkillInstallSource,
  2398. declared_name: Option<&str>,
  2399. ) -> std::io::Result<String> {
  2400. for candidate in [declared_name, source.fallback_name().as_deref()] {
  2401. if let Some(candidate) = candidate.and_then(sanitize_skill_invocation_name) {
  2402. return Ok(candidate);
  2403. }
  2404. }
  2405. Err(std::io::Error::new(
  2406. std::io::ErrorKind::InvalidInput,
  2407. format!(
  2408. "unable to derive an installable invocation name from '{}'",
  2409. source.report_path().display()
  2410. ),
  2411. ))
  2412. }
  2413. fn sanitize_skill_invocation_name(candidate: &str) -> Option<String> {
  2414. let trimmed = candidate
  2415. .trim()
  2416. .trim_start_matches('/')
  2417. .trim_start_matches('$');
  2418. if trimmed.is_empty() {
  2419. return None;
  2420. }
  2421. let mut sanitized = String::new();
  2422. let mut last_was_separator = false;
  2423. for ch in trimmed.chars() {
  2424. if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
  2425. sanitized.push(ch.to_ascii_lowercase());
  2426. last_was_separator = false;
  2427. } else if (ch.is_whitespace() || matches!(ch, '/' | '\\'))
  2428. && !last_was_separator
  2429. && !sanitized.is_empty()
  2430. {
  2431. sanitized.push('-');
  2432. last_was_separator = true;
  2433. }
  2434. }
  2435. let sanitized = sanitized
  2436. .trim_matches(|ch| matches!(ch, '-' | '_' | '.'))
  2437. .to_string();
  2438. (!sanitized.is_empty()).then_some(sanitized)
  2439. }
  2440. fn copy_directory_contents(source: &Path, destination: &Path) -> std::io::Result<()> {
  2441. for entry in fs::read_dir(source)? {
  2442. let entry = entry?;
  2443. let entry_type = entry.file_type()?;
  2444. let destination_path = destination.join(entry.file_name());
  2445. if entry_type.is_dir() {
  2446. fs::create_dir_all(&destination_path)?;
  2447. copy_directory_contents(&entry.path(), &destination_path)?;
  2448. } else {
  2449. fs::copy(entry.path(), destination_path)?;
  2450. }
  2451. }
  2452. Ok(())
  2453. }
  2454. impl SkillInstallSource {
  2455. fn prompt_path(&self) -> &Path {
  2456. match self {
  2457. Self::Directory { prompt_path, .. } => prompt_path,
  2458. Self::MarkdownFile { path } => path,
  2459. }
  2460. }
  2461. fn fallback_name(&self) -> Option<String> {
  2462. match self {
  2463. Self::Directory { root, .. } => root
  2464. .file_name()
  2465. .map(|name| name.to_string_lossy().to_string()),
  2466. Self::MarkdownFile { path } => path
  2467. .file_stem()
  2468. .map(|name| name.to_string_lossy().to_string()),
  2469. }
  2470. }
  2471. fn report_path(&self) -> &Path {
  2472. match self {
  2473. Self::Directory { root, .. } => root,
  2474. Self::MarkdownFile { path } => path,
  2475. }
  2476. }
  2477. }
  2478. fn push_unique_root(
  2479. roots: &mut Vec<(DefinitionSource, PathBuf)>,
  2480. source: DefinitionSource,
  2481. path: PathBuf,
  2482. ) {
  2483. if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) {
  2484. roots.push((source, path));
  2485. }
  2486. }
  2487. fn push_unique_skill_root(
  2488. roots: &mut Vec<SkillRoot>,
  2489. source: DefinitionSource,
  2490. path: PathBuf,
  2491. origin: SkillOrigin,
  2492. ) {
  2493. if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
  2494. roots.push(SkillRoot {
  2495. source,
  2496. path,
  2497. origin,
  2498. });
  2499. }
  2500. }
  2501. fn load_agents_from_roots(
  2502. roots: &[(DefinitionSource, PathBuf)],
  2503. ) -> std::io::Result<Vec<AgentSummary>> {
  2504. let mut agents = Vec::new();
  2505. let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
  2506. for (source, root) in roots {
  2507. let mut root_agents = Vec::new();
  2508. for entry in fs::read_dir(root)? {
  2509. let entry = entry?;
  2510. if entry.path().extension().is_none_or(|ext| ext != "toml") {
  2511. continue;
  2512. }
  2513. let contents = fs::read_to_string(entry.path())?;
  2514. let fallback_name = entry.path().file_stem().map_or_else(
  2515. || entry.file_name().to_string_lossy().to_string(),
  2516. |stem| stem.to_string_lossy().to_string(),
  2517. );
  2518. root_agents.push(AgentSummary {
  2519. name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
  2520. description: parse_toml_string(&contents, "description"),
  2521. model: parse_toml_string(&contents, "model"),
  2522. reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
  2523. source: *source,
  2524. shadowed_by: None,
  2525. });
  2526. }
  2527. root_agents.sort_by(|left, right| left.name.cmp(&right.name));
  2528. for mut agent in root_agents {
  2529. let key = agent.name.to_ascii_lowercase();
  2530. if let Some(existing) = active_sources.get(&key) {
  2531. agent.shadowed_by = Some(*existing);
  2532. } else {
  2533. active_sources.insert(key, agent.source);
  2534. }
  2535. agents.push(agent);
  2536. }
  2537. }
  2538. Ok(agents)
  2539. }
  2540. fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
  2541. let mut skills = Vec::new();
  2542. let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
  2543. for root in roots {
  2544. let mut root_skills = Vec::new();
  2545. for entry in fs::read_dir(&root.path)? {
  2546. let entry = entry?;
  2547. match root.origin {
  2548. SkillOrigin::SkillsDir => {
  2549. if !entry.path().is_dir() {
  2550. continue;
  2551. }
  2552. let skill_path = entry.path().join("SKILL.md");
  2553. if !skill_path.is_file() {
  2554. continue;
  2555. }
  2556. let contents = fs::read_to_string(skill_path)?;
  2557. let (name, description) = parse_skill_frontmatter(&contents);
  2558. root_skills.push(SkillSummary {
  2559. name: name
  2560. .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
  2561. description,
  2562. source: root.source,
  2563. shadowed_by: None,
  2564. origin: root.origin,
  2565. });
  2566. }
  2567. SkillOrigin::LegacyCommandsDir => {
  2568. let path = entry.path();
  2569. let markdown_path = if path.is_dir() {
  2570. let skill_path = path.join("SKILL.md");
  2571. if !skill_path.is_file() {
  2572. continue;
  2573. }
  2574. skill_path
  2575. } else if path
  2576. .extension()
  2577. .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
  2578. {
  2579. path
  2580. } else {
  2581. continue;
  2582. };
  2583. let contents = fs::read_to_string(&markdown_path)?;
  2584. let fallback_name = markdown_path.file_stem().map_or_else(
  2585. || entry.file_name().to_string_lossy().to_string(),
  2586. |stem| stem.to_string_lossy().to_string(),
  2587. );
  2588. let (name, description) = parse_skill_frontmatter(&contents);
  2589. root_skills.push(SkillSummary {
  2590. name: name.unwrap_or(fallback_name),
  2591. description,
  2592. source: root.source,
  2593. shadowed_by: None,
  2594. origin: root.origin,
  2595. });
  2596. }
  2597. }
  2598. }
  2599. root_skills.sort_by(|left, right| left.name.cmp(&right.name));
  2600. for mut skill in root_skills {
  2601. let key = skill.name.to_ascii_lowercase();
  2602. if let Some(existing) = active_sources.get(&key) {
  2603. skill.shadowed_by = Some(*existing);
  2604. } else {
  2605. active_sources.insert(key, skill.source);
  2606. }
  2607. skills.push(skill);
  2608. }
  2609. }
  2610. Ok(skills)
  2611. }
  2612. fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
  2613. let prefix = format!("{key} =");
  2614. for line in contents.lines() {
  2615. let trimmed = line.trim();
  2616. if trimmed.starts_with('#') {
  2617. continue;
  2618. }
  2619. let Some(value) = trimmed.strip_prefix(&prefix) else {
  2620. continue;
  2621. };
  2622. let value = value.trim();
  2623. let Some(value) = value
  2624. .strip_prefix('"')
  2625. .and_then(|value| value.strip_suffix('"'))
  2626. else {
  2627. continue;
  2628. };
  2629. if !value.is_empty() {
  2630. return Some(value.to_string());
  2631. }
  2632. }
  2633. None
  2634. }
  2635. fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
  2636. let mut lines = contents.lines();
  2637. if lines.next().map(str::trim) != Some("---") {
  2638. return (None, None);
  2639. }
  2640. let mut name = None;
  2641. let mut description = None;
  2642. for line in lines {
  2643. let trimmed = line.trim();
  2644. if trimmed == "---" {
  2645. break;
  2646. }
  2647. if let Some(value) = trimmed.strip_prefix("name:") {
  2648. let value = unquote_frontmatter_value(value.trim());
  2649. if !value.is_empty() {
  2650. name = Some(value);
  2651. }
  2652. continue;
  2653. }
  2654. if let Some(value) = trimmed.strip_prefix("description:") {
  2655. let value = unquote_frontmatter_value(value.trim());
  2656. if !value.is_empty() {
  2657. description = Some(value);
  2658. }
  2659. }
  2660. }
  2661. (name, description)
  2662. }
  2663. fn unquote_frontmatter_value(value: &str) -> String {
  2664. value
  2665. .strip_prefix('"')
  2666. .and_then(|trimmed| trimmed.strip_suffix('"'))
  2667. .or_else(|| {
  2668. value
  2669. .strip_prefix('\'')
  2670. .and_then(|trimmed| trimmed.strip_suffix('\''))
  2671. })
  2672. .unwrap_or(value)
  2673. .trim()
  2674. .to_string()
  2675. }
  2676. fn render_agents_report(agents: &[AgentSummary]) -> String {
  2677. if agents.is_empty() {
  2678. return "No agents found.".to_string();
  2679. }
  2680. let total_active = agents
  2681. .iter()
  2682. .filter(|agent| agent.shadowed_by.is_none())
  2683. .count();
  2684. let mut lines = vec![
  2685. "Agents".to_string(),
  2686. format!(" {total_active} active agents"),
  2687. String::new(),
  2688. ];
  2689. for source in [
  2690. DefinitionSource::ProjectCodex,
  2691. DefinitionSource::ProjectClaude,
  2692. DefinitionSource::UserCodexHome,
  2693. DefinitionSource::UserCodex,
  2694. DefinitionSource::UserClaude,
  2695. ] {
  2696. let group = agents
  2697. .iter()
  2698. .filter(|agent| agent.source == source)
  2699. .collect::<Vec<_>>();
  2700. if group.is_empty() {
  2701. continue;
  2702. }
  2703. lines.push(format!("{}:", source.label()));
  2704. for agent in group {
  2705. let detail = agent_detail(agent);
  2706. match agent.shadowed_by {
  2707. Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
  2708. None => lines.push(format!(" {detail}")),
  2709. }
  2710. }
  2711. lines.push(String::new());
  2712. }
  2713. lines.join("\n").trim_end().to_string()
  2714. }
  2715. fn agent_detail(agent: &AgentSummary) -> String {
  2716. let mut parts = vec![agent.name.clone()];
  2717. if let Some(description) = &agent.description {
  2718. parts.push(description.clone());
  2719. }
  2720. if let Some(model) = &agent.model {
  2721. parts.push(model.clone());
  2722. }
  2723. if let Some(reasoning) = &agent.reasoning_effort {
  2724. parts.push(reasoning.clone());
  2725. }
  2726. parts.join(" · ")
  2727. }
  2728. fn render_skills_report(skills: &[SkillSummary]) -> String {
  2729. if skills.is_empty() {
  2730. return "No skills found.".to_string();
  2731. }
  2732. let total_active = skills
  2733. .iter()
  2734. .filter(|skill| skill.shadowed_by.is_none())
  2735. .count();
  2736. let mut lines = vec![
  2737. "Skills".to_string(),
  2738. format!(" {total_active} available skills"),
  2739. String::new(),
  2740. ];
  2741. for source in [
  2742. DefinitionSource::ProjectCodex,
  2743. DefinitionSource::ProjectClaude,
  2744. DefinitionSource::UserCodexHome,
  2745. DefinitionSource::UserCodex,
  2746. DefinitionSource::UserClaude,
  2747. ] {
  2748. let group = skills
  2749. .iter()
  2750. .filter(|skill| skill.source == source)
  2751. .collect::<Vec<_>>();
  2752. if group.is_empty() {
  2753. continue;
  2754. }
  2755. lines.push(format!("{}:", source.label()));
  2756. for skill in group {
  2757. let mut parts = vec![skill.name.clone()];
  2758. if let Some(description) = &skill.description {
  2759. parts.push(description.clone());
  2760. }
  2761. if let Some(detail) = skill.origin.detail_label() {
  2762. parts.push(detail.to_string());
  2763. }
  2764. let detail = parts.join(" · ");
  2765. match skill.shadowed_by {
  2766. Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
  2767. None => lines.push(format!(" {detail}")),
  2768. }
  2769. }
  2770. lines.push(String::new());
  2771. }
  2772. lines.join("\n").trim_end().to_string()
  2773. }
  2774. fn render_skill_install_report(skill: &InstalledSkill) -> String {
  2775. let mut lines = vec![
  2776. "Skills".to_string(),
  2777. format!(" Result installed {}", skill.invocation_name),
  2778. format!(" Invoke as ${}", skill.invocation_name),
  2779. ];
  2780. if let Some(display_name) = &skill.display_name {
  2781. lines.push(format!(" Display name {display_name}"));
  2782. }
  2783. lines.push(format!(" Source {}", skill.source.display()));
  2784. lines.push(format!(
  2785. " Registry {}",
  2786. skill.registry_root.display()
  2787. ));
  2788. lines.push(format!(
  2789. " Installed path {}",
  2790. skill.installed_path.display()
  2791. ));
  2792. lines.join("\n")
  2793. }
  2794. fn render_mcp_summary_report(
  2795. cwd: &Path,
  2796. servers: &BTreeMap<String, ScopedMcpServerConfig>,
  2797. ) -> String {
  2798. let mut lines = vec![
  2799. "MCP".to_string(),
  2800. format!(" Working directory {}", cwd.display()),
  2801. format!(" Configured servers {}", servers.len()),
  2802. ];
  2803. if servers.is_empty() {
  2804. lines.push(" No MCP servers configured.".to_string());
  2805. return lines.join("\n");
  2806. }
  2807. lines.push(String::new());
  2808. for (name, server) in servers {
  2809. lines.push(format!(
  2810. " {name:<16} {transport:<13} {scope:<7} {summary}",
  2811. transport = mcp_transport_label(&server.config),
  2812. scope = config_source_label(server.scope),
  2813. summary = mcp_server_summary(&server.config)
  2814. ));
  2815. }
  2816. lines.join("\n")
  2817. }
  2818. fn render_mcp_server_report(
  2819. cwd: &Path,
  2820. server_name: &str,
  2821. server: Option<&ScopedMcpServerConfig>,
  2822. ) -> String {
  2823. let Some(server) = server else {
  2824. return format!(
  2825. "MCP\n Working directory {}\n Result server `{server_name}` is not configured",
  2826. cwd.display()
  2827. );
  2828. };
  2829. let mut lines = vec![
  2830. "MCP".to_string(),
  2831. format!(" Working directory {}", cwd.display()),
  2832. format!(" Name {server_name}"),
  2833. format!(" Scope {}", config_source_label(server.scope)),
  2834. format!(
  2835. " Transport {}",
  2836. mcp_transport_label(&server.config)
  2837. ),
  2838. ];
  2839. match &server.config {
  2840. McpServerConfig::Stdio(config) => {
  2841. lines.push(format!(" Command {}", config.command));
  2842. lines.push(format!(
  2843. " Args {}",
  2844. format_optional_list(&config.args)
  2845. ));
  2846. lines.push(format!(
  2847. " Env keys {}",
  2848. format_optional_keys(config.env.keys().cloned().collect())
  2849. ));
  2850. lines.push(format!(
  2851. " Tool timeout {}",
  2852. config
  2853. .tool_call_timeout_ms
  2854. .map_or_else(|| "<default>".to_string(), |value| format!("{value} ms"))
  2855. ));
  2856. }
  2857. McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
  2858. lines.push(format!(" URL {}", config.url));
  2859. lines.push(format!(
  2860. " Header keys {}",
  2861. format_optional_keys(config.headers.keys().cloned().collect())
  2862. ));
  2863. lines.push(format!(
  2864. " Header helper {}",
  2865. config.headers_helper.as_deref().unwrap_or("<none>")
  2866. ));
  2867. lines.push(format!(
  2868. " OAuth {}",
  2869. format_mcp_oauth(config.oauth.as_ref())
  2870. ));
  2871. }
  2872. McpServerConfig::Ws(config) => {
  2873. lines.push(format!(" URL {}", config.url));
  2874. lines.push(format!(
  2875. " Header keys {}",
  2876. format_optional_keys(config.headers.keys().cloned().collect())
  2877. ));
  2878. lines.push(format!(
  2879. " Header helper {}",
  2880. config.headers_helper.as_deref().unwrap_or("<none>")
  2881. ));
  2882. }
  2883. McpServerConfig::Sdk(config) => {
  2884. lines.push(format!(" SDK name {}", config.name));
  2885. }
  2886. McpServerConfig::ManagedProxy(config) => {
  2887. lines.push(format!(" URL {}", config.url));
  2888. lines.push(format!(" Proxy id {}", config.id));
  2889. }
  2890. }
  2891. lines.join("\n")
  2892. }
  2893. fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
  2894. args.map(str::trim).filter(|value| !value.is_empty())
  2895. }
  2896. fn render_agents_usage(unexpected: Option<&str>) -> String {
  2897. let mut lines = vec![
  2898. "Agents".to_string(),
  2899. " Usage /agents [list|help]".to_string(),
  2900. " Direct CLI claw agents".to_string(),
  2901. " Sources .codex/agents, .claude/agents, $CODEX_HOME/agents".to_string(),
  2902. ];
  2903. if let Some(args) = unexpected {
  2904. lines.push(format!(" Unexpected {args}"));
  2905. }
  2906. lines.join("\n")
  2907. }
  2908. fn render_skills_usage(unexpected: Option<&str>) -> String {
  2909. let mut lines = vec![
  2910. "Skills".to_string(),
  2911. " Usage /skills [list|install <path>|help]".to_string(),
  2912. " Direct CLI claw skills [list|install <path>|help]".to_string(),
  2913. " Install root $CODEX_HOME/skills or ~/.codex/skills".to_string(),
  2914. " Sources .codex/skills, .claude/skills, legacy /commands".to_string(),
  2915. ];
  2916. if let Some(args) = unexpected {
  2917. lines.push(format!(" Unexpected {args}"));
  2918. }
  2919. lines.join("\n")
  2920. }
  2921. fn render_mcp_usage(unexpected: Option<&str>) -> String {
  2922. let mut lines = vec![
  2923. "MCP".to_string(),
  2924. " Usage /mcp [list|show <server>|help]".to_string(),
  2925. " Direct CLI claw mcp [list|show <server>|help]".to_string(),
  2926. " Sources .claw/settings.json, .claw/settings.local.json".to_string(),
  2927. ];
  2928. if let Some(args) = unexpected {
  2929. lines.push(format!(" Unexpected {args}"));
  2930. }
  2931. lines.join("\n")
  2932. }
  2933. fn config_source_label(source: ConfigSource) -> &'static str {
  2934. match source {
  2935. ConfigSource::User => "user",
  2936. ConfigSource::Project => "project",
  2937. ConfigSource::Local => "local",
  2938. }
  2939. }
  2940. fn mcp_transport_label(config: &McpServerConfig) -> &'static str {
  2941. match config {
  2942. McpServerConfig::Stdio(_) => "stdio",
  2943. McpServerConfig::Sse(_) => "sse",
  2944. McpServerConfig::Http(_) => "http",
  2945. McpServerConfig::Ws(_) => "ws",
  2946. McpServerConfig::Sdk(_) => "sdk",
  2947. McpServerConfig::ManagedProxy(_) => "managed-proxy",
  2948. }
  2949. }
  2950. fn mcp_server_summary(config: &McpServerConfig) -> String {
  2951. match config {
  2952. McpServerConfig::Stdio(config) => {
  2953. if config.args.is_empty() {
  2954. config.command.clone()
  2955. } else {
  2956. format!("{} {}", config.command, config.args.join(" "))
  2957. }
  2958. }
  2959. McpServerConfig::Sse(config) | McpServerConfig::Http(config) => config.url.clone(),
  2960. McpServerConfig::Ws(config) => config.url.clone(),
  2961. McpServerConfig::Sdk(config) => config.name.clone(),
  2962. McpServerConfig::ManagedProxy(config) => format!("{} ({})", config.id, config.url),
  2963. }
  2964. }
  2965. fn format_optional_list(values: &[String]) -> String {
  2966. if values.is_empty() {
  2967. "<none>".to_string()
  2968. } else {
  2969. values.join(" ")
  2970. }
  2971. }
  2972. fn format_optional_keys(mut keys: Vec<String>) -> String {
  2973. if keys.is_empty() {
  2974. return "<none>".to_string();
  2975. }
  2976. keys.sort();
  2977. keys.join(", ")
  2978. }
  2979. fn format_mcp_oauth(oauth: Option<&McpOAuthConfig>) -> String {
  2980. let Some(oauth) = oauth else {
  2981. return "<none>".to_string();
  2982. };
  2983. let mut parts = Vec::new();
  2984. if let Some(client_id) = &oauth.client_id {
  2985. parts.push(format!("client_id={client_id}"));
  2986. }
  2987. if let Some(port) = oauth.callback_port {
  2988. parts.push(format!("callback_port={port}"));
  2989. }
  2990. if let Some(url) = &oauth.auth_server_metadata_url {
  2991. parts.push(format!("metadata_url={url}"));
  2992. }
  2993. if let Some(xaa) = oauth.xaa {
  2994. parts.push(format!("xaa={xaa}"));
  2995. }
  2996. if parts.is_empty() {
  2997. "enabled".to_string()
  2998. } else {
  2999. parts.join(", ")
  3000. }
  3001. }
  3002. #[must_use]
  3003. pub fn handle_slash_command(
  3004. input: &str,
  3005. session: &Session,
  3006. compaction: CompactionConfig,
  3007. ) -> Option<SlashCommandResult> {
  3008. let command = match SlashCommand::parse(input) {
  3009. Ok(Some(command)) => command,
  3010. Ok(None) => return None,
  3011. Err(error) => {
  3012. return Some(SlashCommandResult {
  3013. message: error.to_string(),
  3014. session: session.clone(),
  3015. });
  3016. }
  3017. };
  3018. match command {
  3019. SlashCommand::Compact => {
  3020. let result = compact_session(session, compaction);
  3021. let message = if result.removed_message_count == 0 {
  3022. "Compaction skipped: session is below the compaction threshold.".to_string()
  3023. } else {
  3024. format!(
  3025. "Compacted {} messages into a resumable system summary.",
  3026. result.removed_message_count
  3027. )
  3028. };
  3029. Some(SlashCommandResult {
  3030. message,
  3031. session: result.compacted_session,
  3032. })
  3033. }
  3034. SlashCommand::Help => Some(SlashCommandResult {
  3035. message: render_slash_command_help(),
  3036. session: session.clone(),
  3037. }),
  3038. SlashCommand::Status
  3039. | SlashCommand::Bughunter { .. }
  3040. | SlashCommand::Commit
  3041. | SlashCommand::Pr { .. }
  3042. | SlashCommand::Issue { .. }
  3043. | SlashCommand::Ultraplan { .. }
  3044. | SlashCommand::Teleport { .. }
  3045. | SlashCommand::DebugToolCall
  3046. | SlashCommand::Sandbox
  3047. | SlashCommand::Model { .. }
  3048. | SlashCommand::Permissions { .. }
  3049. | SlashCommand::Clear { .. }
  3050. | SlashCommand::Cost
  3051. | SlashCommand::Resume { .. }
  3052. | SlashCommand::Config { .. }
  3053. | SlashCommand::Mcp { .. }
  3054. | SlashCommand::Memory
  3055. | SlashCommand::Init
  3056. | SlashCommand::Diff
  3057. | SlashCommand::Version
  3058. | SlashCommand::Export { .. }
  3059. | SlashCommand::Session { .. }
  3060. | SlashCommand::Plugins { .. }
  3061. | SlashCommand::Agents { .. }
  3062. | SlashCommand::Skills { .. }
  3063. | SlashCommand::Doctor
  3064. | SlashCommand::Login
  3065. | SlashCommand::Logout
  3066. | SlashCommand::Vim
  3067. | SlashCommand::Upgrade
  3068. | SlashCommand::Stats
  3069. | SlashCommand::Share
  3070. | SlashCommand::Feedback
  3071. | SlashCommand::Files
  3072. | SlashCommand::Fast
  3073. | SlashCommand::Exit
  3074. | SlashCommand::Summary
  3075. | SlashCommand::Desktop
  3076. | SlashCommand::Brief
  3077. | SlashCommand::Advisor
  3078. | SlashCommand::Stickers
  3079. | SlashCommand::Insights
  3080. | SlashCommand::Thinkback
  3081. | SlashCommand::ReleaseNotes
  3082. | SlashCommand::SecurityReview
  3083. | SlashCommand::Keybindings
  3084. | SlashCommand::PrivacySettings
  3085. | SlashCommand::Plan { .. }
  3086. | SlashCommand::Review { .. }
  3087. | SlashCommand::Tasks { .. }
  3088. | SlashCommand::Theme { .. }
  3089. | SlashCommand::Voice { .. }
  3090. | SlashCommand::Usage { .. }
  3091. | SlashCommand::Rename { .. }
  3092. | SlashCommand::Copy { .. }
  3093. | SlashCommand::Hooks { .. }
  3094. | SlashCommand::Context { .. }
  3095. | SlashCommand::Color { .. }
  3096. | SlashCommand::Effort { .. }
  3097. | SlashCommand::Branch { .. }
  3098. | SlashCommand::Rewind { .. }
  3099. | SlashCommand::Ide { .. }
  3100. | SlashCommand::Tag { .. }
  3101. | SlashCommand::OutputStyle { .. }
  3102. | SlashCommand::AddDir { .. }
  3103. | SlashCommand::Unknown(_) => None,
  3104. }
  3105. }
  3106. #[cfg(test)]
  3107. mod tests {
  3108. use super::{
  3109. handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
  3110. load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
  3111. render_slash_command_help, render_slash_command_help_detail,
  3112. resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
  3113. validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
  3114. };
  3115. use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
  3116. use runtime::{
  3117. CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
  3118. };
  3119. use std::fs;
  3120. use std::path::{Path, PathBuf};
  3121. use std::time::{SystemTime, UNIX_EPOCH};
  3122. fn temp_dir(label: &str) -> PathBuf {
  3123. let nanos = SystemTime::now()
  3124. .duration_since(UNIX_EPOCH)
  3125. .expect("time should be after epoch")
  3126. .as_nanos();
  3127. std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
  3128. }
  3129. fn write_external_plugin(root: &Path, name: &str, version: &str) {
  3130. fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
  3131. fs::write(
  3132. root.join(".claude-plugin").join("plugin.json"),
  3133. format!(
  3134. "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
  3135. ),
  3136. )
  3137. .expect("write manifest");
  3138. }
  3139. fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
  3140. fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
  3141. fs::write(
  3142. root.join(".claude-plugin").join("plugin.json"),
  3143. format!(
  3144. "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}",
  3145. if default_enabled { "true" } else { "false" }
  3146. ),
  3147. )
  3148. .expect("write bundled manifest");
  3149. }
  3150. fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
  3151. fs::create_dir_all(root).expect("agent root");
  3152. fs::write(
  3153. root.join(format!("{name}.toml")),
  3154. format!(
  3155. "name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
  3156. ),
  3157. )
  3158. .expect("write agent");
  3159. }
  3160. fn write_skill(root: &Path, name: &str, description: &str) {
  3161. let skill_root = root.join(name);
  3162. fs::create_dir_all(&skill_root).expect("skill root");
  3163. fs::write(
  3164. skill_root.join("SKILL.md"),
  3165. format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
  3166. )
  3167. .expect("write skill");
  3168. }
  3169. fn write_legacy_command(root: &Path, name: &str, description: &str) {
  3170. fs::create_dir_all(root).expect("commands root");
  3171. fs::write(
  3172. root.join(format!("{name}.md")),
  3173. format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
  3174. )
  3175. .expect("write command");
  3176. }
  3177. fn parse_error_message(input: &str) -> String {
  3178. SlashCommand::parse(input)
  3179. .expect_err("slash command should be rejected")
  3180. .to_string()
  3181. }
  3182. #[allow(clippy::too_many_lines)]
  3183. #[test]
  3184. fn parses_supported_slash_commands() {
  3185. assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
  3186. assert_eq!(
  3187. SlashCommand::parse(" /status "),
  3188. Ok(Some(SlashCommand::Status))
  3189. );
  3190. assert_eq!(
  3191. SlashCommand::parse("/sandbox"),
  3192. Ok(Some(SlashCommand::Sandbox))
  3193. );
  3194. assert_eq!(
  3195. SlashCommand::parse("/bughunter runtime"),
  3196. Ok(Some(SlashCommand::Bughunter {
  3197. scope: Some("runtime".to_string())
  3198. }))
  3199. );
  3200. assert_eq!(
  3201. SlashCommand::parse("/commit"),
  3202. Ok(Some(SlashCommand::Commit))
  3203. );
  3204. assert_eq!(
  3205. SlashCommand::parse("/pr ready for review"),
  3206. Ok(Some(SlashCommand::Pr {
  3207. context: Some("ready for review".to_string())
  3208. }))
  3209. );
  3210. assert_eq!(
  3211. SlashCommand::parse("/issue flaky test"),
  3212. Ok(Some(SlashCommand::Issue {
  3213. context: Some("flaky test".to_string())
  3214. }))
  3215. );
  3216. assert_eq!(
  3217. SlashCommand::parse("/ultraplan ship both features"),
  3218. Ok(Some(SlashCommand::Ultraplan {
  3219. task: Some("ship both features".to_string())
  3220. }))
  3221. );
  3222. assert_eq!(
  3223. SlashCommand::parse("/teleport conversation.rs"),
  3224. Ok(Some(SlashCommand::Teleport {
  3225. target: Some("conversation.rs".to_string())
  3226. }))
  3227. );
  3228. assert_eq!(
  3229. SlashCommand::parse("/debug-tool-call"),
  3230. Ok(Some(SlashCommand::DebugToolCall))
  3231. );
  3232. assert_eq!(
  3233. SlashCommand::parse("/bughunter runtime"),
  3234. Ok(Some(SlashCommand::Bughunter {
  3235. scope: Some("runtime".to_string())
  3236. }))
  3237. );
  3238. assert_eq!(
  3239. SlashCommand::parse("/commit"),
  3240. Ok(Some(SlashCommand::Commit))
  3241. );
  3242. assert_eq!(
  3243. SlashCommand::parse("/pr ready for review"),
  3244. Ok(Some(SlashCommand::Pr {
  3245. context: Some("ready for review".to_string())
  3246. }))
  3247. );
  3248. assert_eq!(
  3249. SlashCommand::parse("/issue flaky test"),
  3250. Ok(Some(SlashCommand::Issue {
  3251. context: Some("flaky test".to_string())
  3252. }))
  3253. );
  3254. assert_eq!(
  3255. SlashCommand::parse("/ultraplan ship both features"),
  3256. Ok(Some(SlashCommand::Ultraplan {
  3257. task: Some("ship both features".to_string())
  3258. }))
  3259. );
  3260. assert_eq!(
  3261. SlashCommand::parse("/teleport conversation.rs"),
  3262. Ok(Some(SlashCommand::Teleport {
  3263. target: Some("conversation.rs".to_string())
  3264. }))
  3265. );
  3266. assert_eq!(
  3267. SlashCommand::parse("/debug-tool-call"),
  3268. Ok(Some(SlashCommand::DebugToolCall))
  3269. );
  3270. assert_eq!(
  3271. SlashCommand::parse("/model claude-opus"),
  3272. Ok(Some(SlashCommand::Model {
  3273. model: Some("claude-opus".to_string()),
  3274. }))
  3275. );
  3276. assert_eq!(
  3277. SlashCommand::parse("/model"),
  3278. Ok(Some(SlashCommand::Model { model: None }))
  3279. );
  3280. assert_eq!(
  3281. SlashCommand::parse("/permissions read-only"),
  3282. Ok(Some(SlashCommand::Permissions {
  3283. mode: Some("read-only".to_string()),
  3284. }))
  3285. );
  3286. assert_eq!(
  3287. SlashCommand::parse("/clear"),
  3288. Ok(Some(SlashCommand::Clear { confirm: false }))
  3289. );
  3290. assert_eq!(
  3291. SlashCommand::parse("/clear --confirm"),
  3292. Ok(Some(SlashCommand::Clear { confirm: true }))
  3293. );
  3294. assert_eq!(SlashCommand::parse("/cost"), Ok(Some(SlashCommand::Cost)));
  3295. assert_eq!(
  3296. SlashCommand::parse("/resume session.json"),
  3297. Ok(Some(SlashCommand::Resume {
  3298. session_path: Some("session.json".to_string()),
  3299. }))
  3300. );
  3301. assert_eq!(
  3302. SlashCommand::parse("/config"),
  3303. Ok(Some(SlashCommand::Config { section: None }))
  3304. );
  3305. assert_eq!(
  3306. SlashCommand::parse("/config env"),
  3307. Ok(Some(SlashCommand::Config {
  3308. section: Some("env".to_string())
  3309. }))
  3310. );
  3311. assert_eq!(
  3312. SlashCommand::parse("/mcp"),
  3313. Ok(Some(SlashCommand::Mcp {
  3314. action: None,
  3315. target: None
  3316. }))
  3317. );
  3318. assert_eq!(
  3319. SlashCommand::parse("/mcp show remote"),
  3320. Ok(Some(SlashCommand::Mcp {
  3321. action: Some("show".to_string()),
  3322. target: Some("remote".to_string())
  3323. }))
  3324. );
  3325. assert_eq!(
  3326. SlashCommand::parse("/memory"),
  3327. Ok(Some(SlashCommand::Memory))
  3328. );
  3329. assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init)));
  3330. assert_eq!(SlashCommand::parse("/diff"), Ok(Some(SlashCommand::Diff)));
  3331. assert_eq!(
  3332. SlashCommand::parse("/version"),
  3333. Ok(Some(SlashCommand::Version))
  3334. );
  3335. assert_eq!(
  3336. SlashCommand::parse("/export notes.txt"),
  3337. Ok(Some(SlashCommand::Export {
  3338. path: Some("notes.txt".to_string())
  3339. }))
  3340. );
  3341. assert_eq!(
  3342. SlashCommand::parse("/session switch abc123"),
  3343. Ok(Some(SlashCommand::Session {
  3344. action: Some("switch".to_string()),
  3345. target: Some("abc123".to_string())
  3346. }))
  3347. );
  3348. assert_eq!(
  3349. SlashCommand::parse("/plugins install demo"),
  3350. Ok(Some(SlashCommand::Plugins {
  3351. action: Some("install".to_string()),
  3352. target: Some("demo".to_string())
  3353. }))
  3354. );
  3355. assert_eq!(
  3356. SlashCommand::parse("/plugins list"),
  3357. Ok(Some(SlashCommand::Plugins {
  3358. action: Some("list".to_string()),
  3359. target: None
  3360. }))
  3361. );
  3362. assert_eq!(
  3363. SlashCommand::parse("/plugins enable demo"),
  3364. Ok(Some(SlashCommand::Plugins {
  3365. action: Some("enable".to_string()),
  3366. target: Some("demo".to_string())
  3367. }))
  3368. );
  3369. assert_eq!(
  3370. SlashCommand::parse("/skills install ./fixtures/help-skill"),
  3371. Ok(Some(SlashCommand::Skills {
  3372. args: Some("install ./fixtures/help-skill".to_string())
  3373. }))
  3374. );
  3375. assert_eq!(
  3376. SlashCommand::parse("/plugins disable demo"),
  3377. Ok(Some(SlashCommand::Plugins {
  3378. action: Some("disable".to_string()),
  3379. target: Some("demo".to_string())
  3380. }))
  3381. );
  3382. assert_eq!(
  3383. SlashCommand::parse("/session fork incident-review"),
  3384. Ok(Some(SlashCommand::Session {
  3385. action: Some("fork".to_string()),
  3386. target: Some("incident-review".to_string())
  3387. }))
  3388. );
  3389. }
  3390. #[test]
  3391. fn rejects_unexpected_arguments_for_no_arg_commands() {
  3392. // given
  3393. let input = "/compact now";
  3394. // when
  3395. let error = parse_error_message(input);
  3396. // then
  3397. assert!(error.contains("Unexpected arguments for /compact."));
  3398. assert!(error.contains(" Usage /compact"));
  3399. assert!(error.contains(" Summary Compact local session history"));
  3400. }
  3401. #[test]
  3402. fn rejects_invalid_argument_values() {
  3403. // given
  3404. let input = "/permissions admin";
  3405. // when
  3406. let error = parse_error_message(input);
  3407. // then
  3408. assert!(error.contains(
  3409. "Unsupported /permissions mode 'admin'. Use read-only, workspace-write, or danger-full-access."
  3410. ));
  3411. assert!(error.contains(
  3412. " Usage /permissions [read-only|workspace-write|danger-full-access]"
  3413. ));
  3414. }
  3415. #[test]
  3416. fn rejects_missing_required_arguments() {
  3417. // given
  3418. let input = "/teleport";
  3419. // when
  3420. let error = parse_error_message(input);
  3421. // then
  3422. assert!(error.contains("Usage: /teleport <symbol-or-path>"));
  3423. assert!(error.contains(" Category Discovery & debugging"));
  3424. }
  3425. #[test]
  3426. fn rejects_invalid_session_and_plugin_shapes() {
  3427. // given
  3428. let session_input = "/session switch";
  3429. let plugin_input = "/plugins list extra";
  3430. // when
  3431. let session_error = parse_error_message(session_input);
  3432. let plugin_error = parse_error_message(plugin_input);
  3433. // then
  3434. assert!(session_error.contains("Usage: /session switch <session-id>"));
  3435. assert!(session_error.contains("/session"));
  3436. assert!(plugin_error.contains("Usage: /plugin list"));
  3437. assert!(plugin_error.contains("Aliases /plugins, /marketplace"));
  3438. }
  3439. #[test]
  3440. fn rejects_invalid_agents_and_skills_arguments() {
  3441. // given
  3442. let agents_input = "/agents show planner";
  3443. let skills_input = "/skills show help";
  3444. // when
  3445. let agents_error = parse_error_message(agents_input);
  3446. let skills_error = parse_error_message(skills_input);
  3447. // then
  3448. assert!(agents_error.contains(
  3449. "Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
  3450. ));
  3451. assert!(agents_error.contains(" Usage /agents [list|help]"));
  3452. assert!(skills_error.contains(
  3453. "Unexpected arguments for /skills: show help. Use /skills, /skills list, /skills install <path>, or /skills help."
  3454. ));
  3455. assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
  3456. }
  3457. #[test]
  3458. fn rejects_invalid_mcp_arguments() {
  3459. let show_error = parse_error_message("/mcp show alpha beta");
  3460. assert!(show_error.contains("Unexpected arguments for /mcp show."));
  3461. assert!(show_error.contains(" Usage /mcp show <server>"));
  3462. let action_error = parse_error_message("/mcp inspect alpha");
  3463. assert!(action_error
  3464. .contains("Unknown /mcp action 'inspect'. Use list, show <server>, or help."));
  3465. assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
  3466. }
  3467. #[test]
  3468. fn renders_help_from_shared_specs() {
  3469. let help = render_slash_command_help();
  3470. assert!(help.contains("Start here /status, /diff, /agents, /skills, /commit"));
  3471. assert!(help.contains("[resume] also works with --resume SESSION.jsonl"));
  3472. assert!(help.contains("Session & visibility"));
  3473. assert!(help.contains("Workspace & git"));
  3474. assert!(help.contains("Discovery & debugging"));
  3475. assert!(help.contains("Analysis & automation"));
  3476. assert!(help.contains("/help"));
  3477. assert!(help.contains("/status"));
  3478. assert!(help.contains("/sandbox"));
  3479. assert!(help.contains("/compact"));
  3480. assert!(help.contains("/bughunter [scope]"));
  3481. assert!(help.contains("/commit"));
  3482. assert!(help.contains("/pr [context]"));
  3483. assert!(help.contains("/issue [context]"));
  3484. assert!(help.contains("/ultraplan [task]"));
  3485. assert!(help.contains("/teleport <symbol-or-path>"));
  3486. assert!(help.contains("/debug-tool-call"));
  3487. assert!(help.contains("/model [model]"));
  3488. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  3489. assert!(help.contains("/clear [--confirm]"));
  3490. assert!(help.contains("/cost"));
  3491. assert!(help.contains("/resume <session-path>"));
  3492. assert!(help.contains("/config [env|hooks|model|plugins]"));
  3493. assert!(help.contains("/mcp [list|show <server>|help]"));
  3494. assert!(help.contains("/memory"));
  3495. assert!(help.contains("/init"));
  3496. assert!(help.contains("/diff"));
  3497. assert!(help.contains("/version"));
  3498. assert!(help.contains("/export [file]"));
  3499. assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]]"));
  3500. assert!(help.contains("/sandbox"));
  3501. assert!(help.contains(
  3502. "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
  3503. ));
  3504. assert!(help.contains("aliases: /plugins, /marketplace"));
  3505. assert!(help.contains("/agents [list|help]"));
  3506. assert!(help.contains("/skills [list|install <path>|help]"));
  3507. assert_eq!(slash_command_specs().len(), 141);
  3508. assert!(resume_supported_slash_commands().len() >= 39);
  3509. }
  3510. #[test]
  3511. fn renders_per_command_help_detail() {
  3512. // given
  3513. let command = "plugins";
  3514. // when
  3515. let help = render_slash_command_help_detail(command).expect("detail help should exist");
  3516. // then
  3517. assert!(help.contains("/plugin"));
  3518. assert!(help.contains("Summary Manage Claw Code plugins"));
  3519. assert!(help.contains("Aliases /plugins, /marketplace"));
  3520. assert!(help.contains("Category Workspace & git"));
  3521. }
  3522. #[test]
  3523. fn renders_per_command_help_detail_for_mcp() {
  3524. let help = render_slash_command_help_detail("mcp").expect("detail help should exist");
  3525. assert!(help.contains("/mcp"));
  3526. assert!(help.contains("Summary Inspect configured MCP servers"));
  3527. assert!(help.contains("Category Discovery & debugging"));
  3528. assert!(help.contains("Resume Supported with --resume SESSION.jsonl"));
  3529. }
  3530. #[test]
  3531. fn validate_slash_command_input_rejects_extra_single_value_arguments() {
  3532. // given
  3533. let session_input = "/session switch current next";
  3534. let plugin_input = "/plugin enable demo extra";
  3535. // when
  3536. let session_error = validate_slash_command_input(session_input)
  3537. .expect_err("session input should be rejected")
  3538. .to_string();
  3539. let plugin_error = validate_slash_command_input(plugin_input)
  3540. .expect_err("plugin input should be rejected")
  3541. .to_string();
  3542. // then
  3543. assert!(session_error.contains("Unexpected arguments for /session switch."));
  3544. assert!(session_error.contains(" Usage /session switch <session-id>"));
  3545. assert!(plugin_error.contains("Unexpected arguments for /plugin enable."));
  3546. assert!(plugin_error.contains(" Usage /plugin enable <name>"));
  3547. }
  3548. #[test]
  3549. fn suggests_closest_slash_commands_for_typos_and_aliases() {
  3550. let suggestions = suggest_slash_commands("stats", 3);
  3551. assert!(suggestions.contains(&"/stats".to_string()));
  3552. assert!(suggestions.contains(&"/status".to_string()));
  3553. assert!(suggestions.len() <= 3);
  3554. let plugin_suggestions = suggest_slash_commands("/plugns", 3);
  3555. assert!(plugin_suggestions.contains(&"/plugin".to_string()));
  3556. assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
  3557. }
  3558. #[test]
  3559. fn compacts_sessions_via_slash_command() {
  3560. let mut session = Session::new();
  3561. session.messages = vec![
  3562. ConversationMessage::user_text("a ".repeat(200)),
  3563. ConversationMessage::assistant(vec![ContentBlock::Text {
  3564. text: "b ".repeat(200),
  3565. }]),
  3566. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  3567. ConversationMessage::assistant(vec![ContentBlock::Text {
  3568. text: "recent".to_string(),
  3569. }]),
  3570. ];
  3571. let result = handle_slash_command(
  3572. "/compact",
  3573. &session,
  3574. CompactionConfig {
  3575. preserve_recent_messages: 2,
  3576. max_estimated_tokens: 1,
  3577. },
  3578. )
  3579. .expect("slash command should be handled");
  3580. assert!(result.message.contains("Compacted 2 messages"));
  3581. assert_eq!(result.session.messages[0].role, MessageRole::System);
  3582. }
  3583. #[test]
  3584. fn help_command_is_non_mutating() {
  3585. let session = Session::new();
  3586. let result = handle_slash_command("/help", &session, CompactionConfig::default())
  3587. .expect("help command should be handled");
  3588. assert_eq!(result.session, session);
  3589. assert!(result.message.contains("Slash commands"));
  3590. }
  3591. #[test]
  3592. fn ignores_unknown_or_runtime_bound_slash_commands() {
  3593. let session = Session::new();
  3594. assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
  3595. assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
  3596. assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
  3597. assert!(
  3598. handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
  3599. );
  3600. assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
  3601. assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
  3602. assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
  3603. assert!(
  3604. handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
  3605. );
  3606. assert!(
  3607. handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
  3608. );
  3609. assert!(
  3610. handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
  3611. .is_none()
  3612. );
  3613. assert!(
  3614. handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
  3615. );
  3616. assert!(handle_slash_command(
  3617. "/permissions read-only",
  3618. &session,
  3619. CompactionConfig::default()
  3620. )
  3621. .is_none());
  3622. assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
  3623. assert!(
  3624. handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
  3625. .is_none()
  3626. );
  3627. assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
  3628. assert!(handle_slash_command(
  3629. "/resume session.json",
  3630. &session,
  3631. CompactionConfig::default()
  3632. )
  3633. .is_none());
  3634. assert!(handle_slash_command(
  3635. "/resume session.jsonl",
  3636. &session,
  3637. CompactionConfig::default()
  3638. )
  3639. .is_none());
  3640. assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
  3641. assert!(
  3642. handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
  3643. );
  3644. assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
  3645. assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
  3646. assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
  3647. assert!(
  3648. handle_slash_command("/export note.txt", &session, CompactionConfig::default())
  3649. .is_none()
  3650. );
  3651. assert!(
  3652. handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
  3653. );
  3654. assert!(
  3655. handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
  3656. );
  3657. }
  3658. #[test]
  3659. fn renders_plugins_report_with_name_version_and_status() {
  3660. let rendered = render_plugins_report(&[
  3661. PluginSummary {
  3662. metadata: PluginMetadata {
  3663. id: "demo@external".to_string(),
  3664. name: "demo".to_string(),
  3665. version: "1.2.3".to_string(),
  3666. description: "demo plugin".to_string(),
  3667. kind: PluginKind::External,
  3668. source: "demo".to_string(),
  3669. default_enabled: false,
  3670. root: None,
  3671. },
  3672. enabled: true,
  3673. },
  3674. PluginSummary {
  3675. metadata: PluginMetadata {
  3676. id: "sample@external".to_string(),
  3677. name: "sample".to_string(),
  3678. version: "0.9.0".to_string(),
  3679. description: "sample plugin".to_string(),
  3680. kind: PluginKind::External,
  3681. source: "sample".to_string(),
  3682. default_enabled: false,
  3683. root: None,
  3684. },
  3685. enabled: false,
  3686. },
  3687. ]);
  3688. assert!(rendered.contains("demo"));
  3689. assert!(rendered.contains("v1.2.3"));
  3690. assert!(rendered.contains("enabled"));
  3691. assert!(rendered.contains("sample"));
  3692. assert!(rendered.contains("v0.9.0"));
  3693. assert!(rendered.contains("disabled"));
  3694. }
  3695. #[test]
  3696. fn lists_agents_from_project_and_user_roots() {
  3697. let workspace = temp_dir("agents-workspace");
  3698. let project_agents = workspace.join(".codex").join("agents");
  3699. let user_home = temp_dir("agents-home");
  3700. let user_agents = user_home.join(".codex").join("agents");
  3701. write_agent(
  3702. &project_agents,
  3703. "planner",
  3704. "Project planner",
  3705. "gpt-5.4",
  3706. "medium",
  3707. );
  3708. write_agent(
  3709. &user_agents,
  3710. "planner",
  3711. "User planner",
  3712. "gpt-5.4-mini",
  3713. "high",
  3714. );
  3715. write_agent(
  3716. &user_agents,
  3717. "verifier",
  3718. "Verification agent",
  3719. "gpt-5.4-mini",
  3720. "high",
  3721. );
  3722. let roots = vec![
  3723. (DefinitionSource::ProjectCodex, project_agents),
  3724. (DefinitionSource::UserCodex, user_agents),
  3725. ];
  3726. let report =
  3727. render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load"));
  3728. assert!(report.contains("Agents"));
  3729. assert!(report.contains("2 active agents"));
  3730. assert!(report.contains("Project (.codex):"));
  3731. assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
  3732. assert!(report.contains("User (~/.codex):"));
  3733. assert!(report.contains("(shadowed by Project (.codex)) planner · User planner"));
  3734. assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
  3735. let _ = fs::remove_dir_all(workspace);
  3736. let _ = fs::remove_dir_all(user_home);
  3737. }
  3738. #[test]
  3739. fn lists_skills_from_project_and_user_roots() {
  3740. let workspace = temp_dir("skills-workspace");
  3741. let project_skills = workspace.join(".codex").join("skills");
  3742. let project_commands = workspace.join(".claude").join("commands");
  3743. let user_home = temp_dir("skills-home");
  3744. let user_skills = user_home.join(".codex").join("skills");
  3745. write_skill(&project_skills, "plan", "Project planning guidance");
  3746. write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance");
  3747. write_skill(&user_skills, "plan", "User planning guidance");
  3748. write_skill(&user_skills, "help", "Help guidance");
  3749. let roots = vec![
  3750. SkillRoot {
  3751. source: DefinitionSource::ProjectCodex,
  3752. path: project_skills,
  3753. origin: SkillOrigin::SkillsDir,
  3754. },
  3755. SkillRoot {
  3756. source: DefinitionSource::ProjectClaude,
  3757. path: project_commands,
  3758. origin: SkillOrigin::LegacyCommandsDir,
  3759. },
  3760. SkillRoot {
  3761. source: DefinitionSource::UserCodex,
  3762. path: user_skills,
  3763. origin: SkillOrigin::SkillsDir,
  3764. },
  3765. ];
  3766. let report =
  3767. render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
  3768. assert!(report.contains("Skills"));
  3769. assert!(report.contains("3 available skills"));
  3770. assert!(report.contains("Project (.codex):"));
  3771. assert!(report.contains("plan · Project planning guidance"));
  3772. assert!(report.contains("Project (.claude):"));
  3773. assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
  3774. assert!(report.contains("User (~/.codex):"));
  3775. assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
  3776. assert!(report.contains("help · Help guidance"));
  3777. let _ = fs::remove_dir_all(workspace);
  3778. let _ = fs::remove_dir_all(user_home);
  3779. }
  3780. #[test]
  3781. fn agents_and_skills_usage_support_help_and_unexpected_args() {
  3782. let cwd = temp_dir("slash-usage");
  3783. let agents_help =
  3784. super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
  3785. assert!(agents_help.contains("Usage /agents [list|help]"));
  3786. assert!(agents_help.contains("Direct CLI claw agents"));
  3787. let agents_unexpected =
  3788. super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
  3789. assert!(agents_unexpected.contains("Unexpected show planner"));
  3790. let skills_help =
  3791. super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
  3792. assert!(skills_help.contains("Usage /skills [list|install <path>|help]"));
  3793. assert!(skills_help.contains("Install root $CODEX_HOME/skills or ~/.codex/skills"));
  3794. assert!(skills_help.contains("legacy /commands"));
  3795. let skills_unexpected =
  3796. super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
  3797. assert!(skills_unexpected.contains("Unexpected show help"));
  3798. let _ = fs::remove_dir_all(cwd);
  3799. }
  3800. #[test]
  3801. fn mcp_usage_supports_help_and_unexpected_args() {
  3802. let cwd = temp_dir("mcp-usage");
  3803. let help = super::handle_mcp_slash_command(Some("help"), &cwd).expect("mcp help");
  3804. assert!(help.contains("Usage /mcp [list|show <server>|help]"));
  3805. assert!(help.contains("Direct CLI claw mcp [list|show <server>|help]"));
  3806. let unexpected =
  3807. super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
  3808. assert!(unexpected.contains("Unexpected show alpha beta"));
  3809. let _ = fs::remove_dir_all(cwd);
  3810. }
  3811. #[test]
  3812. fn renders_mcp_reports_from_loaded_config() {
  3813. let workspace = temp_dir("mcp-config-workspace");
  3814. let config_home = temp_dir("mcp-config-home");
  3815. fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir");
  3816. fs::create_dir_all(&config_home).expect("config home");
  3817. fs::write(
  3818. workspace.join(".claw").join("settings.json"),
  3819. r#"{
  3820. "mcpServers": {
  3821. "alpha": {
  3822. "command": "uvx",
  3823. "args": ["alpha-server"],
  3824. "env": {"ALPHA_TOKEN": "secret"},
  3825. "toolCallTimeoutMs": 1200
  3826. },
  3827. "remote": {
  3828. "type": "http",
  3829. "url": "https://remote.example/mcp",
  3830. "headers": {"Authorization": "Bearer secret"},
  3831. "headersHelper": "./bin/headers",
  3832. "oauth": {
  3833. "clientId": "remote-client",
  3834. "callbackPort": 7878
  3835. }
  3836. }
  3837. }
  3838. }"#,
  3839. )
  3840. .expect("write settings");
  3841. fs::write(
  3842. workspace.join(".claw").join("settings.local.json"),
  3843. r#"{
  3844. "mcpServers": {
  3845. "remote": {
  3846. "type": "ws",
  3847. "url": "wss://remote.example/mcp"
  3848. }
  3849. }
  3850. }"#,
  3851. )
  3852. .expect("write local settings");
  3853. let loader = ConfigLoader::new(&workspace, &config_home);
  3854. let list = super::render_mcp_report_for(&loader, &workspace, None)
  3855. .expect("mcp list report should render");
  3856. assert!(list.contains("Configured servers 2"));
  3857. assert!(list.contains("alpha"));
  3858. assert!(list.contains("stdio"));
  3859. assert!(list.contains("project"));
  3860. assert!(list.contains("uvx alpha-server"));
  3861. assert!(list.contains("remote"));
  3862. assert!(list.contains("ws"));
  3863. assert!(list.contains("local"));
  3864. assert!(list.contains("wss://remote.example/mcp"));
  3865. let show = super::render_mcp_report_for(&loader, &workspace, Some("show alpha"))
  3866. .expect("mcp show report should render");
  3867. assert!(show.contains("Name alpha"));
  3868. assert!(show.contains("Command uvx"));
  3869. assert!(show.contains("Args alpha-server"));
  3870. assert!(show.contains("Env keys ALPHA_TOKEN"));
  3871. assert!(show.contains("Tool timeout 1200 ms"));
  3872. let remote = super::render_mcp_report_for(&loader, &workspace, Some("show remote"))
  3873. .expect("mcp show remote report should render");
  3874. assert!(remote.contains("Transport ws"));
  3875. assert!(remote.contains("URL wss://remote.example/mcp"));
  3876. let missing = super::render_mcp_report_for(&loader, &workspace, Some("show missing"))
  3877. .expect("missing report should render");
  3878. assert!(missing.contains("server `missing` is not configured"));
  3879. let _ = fs::remove_dir_all(workspace);
  3880. let _ = fs::remove_dir_all(config_home);
  3881. }
  3882. #[test]
  3883. fn parses_quoted_skill_frontmatter_values() {
  3884. let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
  3885. let (name, description) = super::parse_skill_frontmatter(contents);
  3886. assert_eq!(name.as_deref(), Some("hud"));
  3887. assert_eq!(description.as_deref(), Some("Quoted description"));
  3888. }
  3889. #[test]
  3890. fn installs_skill_into_user_registry_and_preserves_nested_files() {
  3891. let workspace = temp_dir("skills-install-workspace");
  3892. let source_root = workspace.join("source").join("help");
  3893. let install_root = temp_dir("skills-install-root");
  3894. write_skill(
  3895. source_root.parent().expect("parent"),
  3896. "help",
  3897. "Helpful skill",
  3898. );
  3899. let script_dir = source_root.join("scripts");
  3900. fs::create_dir_all(&script_dir).expect("script dir");
  3901. fs::write(script_dir.join("run.sh"), "#!/bin/sh\necho help\n").expect("write script");
  3902. let installed = super::install_skill_into(
  3903. source_root.to_str().expect("utf8 skill path"),
  3904. &workspace,
  3905. &install_root,
  3906. )
  3907. .expect("skill should install");
  3908. assert_eq!(installed.invocation_name, "help");
  3909. assert_eq!(installed.display_name.as_deref(), Some("help"));
  3910. assert!(installed.installed_path.ends_with(Path::new("help")));
  3911. assert!(installed.installed_path.join("SKILL.md").is_file());
  3912. assert!(installed
  3913. .installed_path
  3914. .join("scripts")
  3915. .join("run.sh")
  3916. .is_file());
  3917. let report = super::render_skill_install_report(&installed);
  3918. assert!(report.contains("Result installed help"));
  3919. assert!(report.contains("Invoke as $help"));
  3920. assert!(report.contains(&install_root.display().to_string()));
  3921. let roots = vec![SkillRoot {
  3922. source: DefinitionSource::UserCodexHome,
  3923. path: install_root.clone(),
  3924. origin: SkillOrigin::SkillsDir,
  3925. }];
  3926. let listed = render_skills_report(
  3927. &load_skills_from_roots(&roots).expect("installed skills should load"),
  3928. );
  3929. assert!(listed.contains("User ($CODEX_HOME):"));
  3930. assert!(listed.contains("help · Helpful skill"));
  3931. let _ = fs::remove_dir_all(workspace);
  3932. let _ = fs::remove_dir_all(install_root);
  3933. }
  3934. #[test]
  3935. fn installs_plugin_from_path_and_lists_it() {
  3936. let config_home = temp_dir("home");
  3937. let source_root = temp_dir("source");
  3938. write_external_plugin(&source_root, "demo", "1.0.0");
  3939. let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
  3940. let install = handle_plugins_slash_command(
  3941. Some("install"),
  3942. Some(source_root.to_str().expect("utf8 path")),
  3943. &mut manager,
  3944. )
  3945. .expect("install command should succeed");
  3946. assert!(install.reload_runtime);
  3947. assert!(install.message.contains("installed demo@external"));
  3948. assert!(install.message.contains("Name demo"));
  3949. assert!(install.message.contains("Version 1.0.0"));
  3950. assert!(install.message.contains("Status enabled"));
  3951. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  3952. .expect("list command should succeed");
  3953. assert!(!list.reload_runtime);
  3954. assert!(list.message.contains("demo"));
  3955. assert!(list.message.contains("v1.0.0"));
  3956. assert!(list.message.contains("enabled"));
  3957. let _ = fs::remove_dir_all(config_home);
  3958. let _ = fs::remove_dir_all(source_root);
  3959. }
  3960. #[test]
  3961. fn enables_and_disables_plugin_by_name() {
  3962. let config_home = temp_dir("toggle-home");
  3963. let source_root = temp_dir("toggle-source");
  3964. write_external_plugin(&source_root, "demo", "1.0.0");
  3965. let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
  3966. handle_plugins_slash_command(
  3967. Some("install"),
  3968. Some(source_root.to_str().expect("utf8 path")),
  3969. &mut manager,
  3970. )
  3971. .expect("install command should succeed");
  3972. let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
  3973. .expect("disable command should succeed");
  3974. assert!(disable.reload_runtime);
  3975. assert!(disable.message.contains("disabled demo@external"));
  3976. assert!(disable.message.contains("Name demo"));
  3977. assert!(disable.message.contains("Status disabled"));
  3978. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  3979. .expect("list command should succeed");
  3980. assert!(list.message.contains("demo"));
  3981. assert!(list.message.contains("disabled"));
  3982. let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
  3983. .expect("enable command should succeed");
  3984. assert!(enable.reload_runtime);
  3985. assert!(enable.message.contains("enabled demo@external"));
  3986. assert!(enable.message.contains("Name demo"));
  3987. assert!(enable.message.contains("Status enabled"));
  3988. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  3989. .expect("list command should succeed");
  3990. assert!(list.message.contains("demo"));
  3991. assert!(list.message.contains("enabled"));
  3992. let _ = fs::remove_dir_all(config_home);
  3993. let _ = fs::remove_dir_all(source_root);
  3994. }
  3995. #[test]
  3996. fn lists_auto_installed_bundled_plugins_with_status() {
  3997. let config_home = temp_dir("bundled-home");
  3998. let bundled_root = temp_dir("bundled-root");
  3999. let bundled_plugin = bundled_root.join("starter");
  4000. write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
  4001. let mut config = PluginManagerConfig::new(&config_home);
  4002. config.bundled_root = Some(bundled_root.clone());
  4003. let mut manager = PluginManager::new(config);
  4004. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  4005. .expect("list command should succeed");
  4006. assert!(!list.reload_runtime);
  4007. assert!(list.message.contains("starter"));
  4008. assert!(list.message.contains("v0.1.0"));
  4009. assert!(list.message.contains("disabled"));
  4010. let _ = fs::remove_dir_all(config_home);
  4011. let _ = fs::remove_dir_all(bundled_root);
  4012. }
  4013. }