lib.rs 109 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237
  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. ];
  237. #[derive(Debug, Clone, PartialEq, Eq)]
  238. pub enum SlashCommand {
  239. Help,
  240. Status,
  241. Sandbox,
  242. Compact,
  243. Bughunter {
  244. scope: Option<String>,
  245. },
  246. Commit,
  247. Pr {
  248. context: Option<String>,
  249. },
  250. Issue {
  251. context: Option<String>,
  252. },
  253. Ultraplan {
  254. task: Option<String>,
  255. },
  256. Teleport {
  257. target: Option<String>,
  258. },
  259. DebugToolCall,
  260. Model {
  261. model: Option<String>,
  262. },
  263. Permissions {
  264. mode: Option<String>,
  265. },
  266. Clear {
  267. confirm: bool,
  268. },
  269. Cost,
  270. Resume {
  271. session_path: Option<String>,
  272. },
  273. Config {
  274. section: Option<String>,
  275. },
  276. Mcp {
  277. action: Option<String>,
  278. target: Option<String>,
  279. },
  280. Memory,
  281. Init,
  282. Diff,
  283. Version,
  284. Export {
  285. path: Option<String>,
  286. },
  287. Session {
  288. action: Option<String>,
  289. target: Option<String>,
  290. },
  291. Plugins {
  292. action: Option<String>,
  293. target: Option<String>,
  294. },
  295. Agents {
  296. args: Option<String>,
  297. },
  298. Skills {
  299. args: Option<String>,
  300. },
  301. Unknown(String),
  302. }
  303. #[derive(Debug, Clone, PartialEq, Eq)]
  304. pub struct SlashCommandParseError {
  305. message: String,
  306. }
  307. impl SlashCommandParseError {
  308. fn new(message: impl Into<String>) -> Self {
  309. Self {
  310. message: message.into(),
  311. }
  312. }
  313. }
  314. impl fmt::Display for SlashCommandParseError {
  315. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  316. f.write_str(&self.message)
  317. }
  318. }
  319. impl std::error::Error for SlashCommandParseError {}
  320. impl SlashCommand {
  321. pub fn parse(input: &str) -> Result<Option<Self>, SlashCommandParseError> {
  322. validate_slash_command_input(input)
  323. }
  324. }
  325. pub fn validate_slash_command_input(
  326. input: &str,
  327. ) -> Result<Option<SlashCommand>, SlashCommandParseError> {
  328. let trimmed = input.trim();
  329. if !trimmed.starts_with('/') {
  330. return Ok(None);
  331. }
  332. let mut parts = trimmed.trim_start_matches('/').split_whitespace();
  333. let command = parts.next().unwrap_or_default();
  334. if command.is_empty() {
  335. return Err(SlashCommandParseError::new(
  336. "Slash command name is missing. Use /help to list available slash commands.",
  337. ));
  338. }
  339. let args = parts.collect::<Vec<_>>();
  340. let remainder = remainder_after_command(trimmed, command);
  341. Ok(Some(match command {
  342. "help" => {
  343. validate_no_args(command, &args)?;
  344. SlashCommand::Help
  345. }
  346. "status" => {
  347. validate_no_args(command, &args)?;
  348. SlashCommand::Status
  349. }
  350. "sandbox" => {
  351. validate_no_args(command, &args)?;
  352. SlashCommand::Sandbox
  353. }
  354. "compact" => {
  355. validate_no_args(command, &args)?;
  356. SlashCommand::Compact
  357. }
  358. "bughunter" => SlashCommand::Bughunter { scope: remainder },
  359. "commit" => {
  360. validate_no_args(command, &args)?;
  361. SlashCommand::Commit
  362. }
  363. "pr" => SlashCommand::Pr { context: remainder },
  364. "issue" => SlashCommand::Issue { context: remainder },
  365. "ultraplan" => SlashCommand::Ultraplan { task: remainder },
  366. "teleport" => SlashCommand::Teleport {
  367. target: Some(require_remainder(command, remainder, "<symbol-or-path>")?),
  368. },
  369. "debug-tool-call" => {
  370. validate_no_args(command, &args)?;
  371. SlashCommand::DebugToolCall
  372. }
  373. "model" => SlashCommand::Model {
  374. model: optional_single_arg(command, &args, "[model]")?,
  375. },
  376. "permissions" => SlashCommand::Permissions {
  377. mode: parse_permissions_mode(&args)?,
  378. },
  379. "clear" => SlashCommand::Clear {
  380. confirm: parse_clear_args(&args)?,
  381. },
  382. "cost" => {
  383. validate_no_args(command, &args)?;
  384. SlashCommand::Cost
  385. }
  386. "resume" => SlashCommand::Resume {
  387. session_path: Some(require_remainder(command, remainder, "<session-path>")?),
  388. },
  389. "config" => SlashCommand::Config {
  390. section: parse_config_section(&args)?,
  391. },
  392. "mcp" => parse_mcp_command(&args)?,
  393. "memory" => {
  394. validate_no_args(command, &args)?;
  395. SlashCommand::Memory
  396. }
  397. "init" => {
  398. validate_no_args(command, &args)?;
  399. SlashCommand::Init
  400. }
  401. "diff" => {
  402. validate_no_args(command, &args)?;
  403. SlashCommand::Diff
  404. }
  405. "version" => {
  406. validate_no_args(command, &args)?;
  407. SlashCommand::Version
  408. }
  409. "export" => SlashCommand::Export { path: remainder },
  410. "session" => parse_session_command(&args)?,
  411. "plugin" | "plugins" | "marketplace" => parse_plugin_command(&args)?,
  412. "agents" => SlashCommand::Agents {
  413. args: parse_list_or_help_args(command, remainder)?,
  414. },
  415. "skills" => SlashCommand::Skills {
  416. args: parse_skills_args(remainder.as_deref())?,
  417. },
  418. other => SlashCommand::Unknown(other.to_string()),
  419. }))
  420. }
  421. fn validate_no_args(command: &str, args: &[&str]) -> Result<(), SlashCommandParseError> {
  422. if args.is_empty() {
  423. return Ok(());
  424. }
  425. Err(command_error(
  426. &format!("Unexpected arguments for /{command}."),
  427. command,
  428. &format!("/{command}"),
  429. ))
  430. }
  431. fn optional_single_arg(
  432. command: &str,
  433. args: &[&str],
  434. argument_hint: &str,
  435. ) -> Result<Option<String>, SlashCommandParseError> {
  436. match args {
  437. [] => Ok(None),
  438. [value] => Ok(Some((*value).to_string())),
  439. _ => Err(usage_error(command, argument_hint)),
  440. }
  441. }
  442. fn require_remainder(
  443. command: &str,
  444. remainder: Option<String>,
  445. argument_hint: &str,
  446. ) -> Result<String, SlashCommandParseError> {
  447. remainder.ok_or_else(|| usage_error(command, argument_hint))
  448. }
  449. fn parse_permissions_mode(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
  450. let mode = optional_single_arg(
  451. "permissions",
  452. args,
  453. "[read-only|workspace-write|danger-full-access]",
  454. )?;
  455. if let Some(mode) = mode {
  456. if matches!(
  457. mode.as_str(),
  458. "read-only" | "workspace-write" | "danger-full-access"
  459. ) {
  460. return Ok(Some(mode));
  461. }
  462. return Err(command_error(
  463. &format!(
  464. "Unsupported /permissions mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
  465. ),
  466. "permissions",
  467. "/permissions [read-only|workspace-write|danger-full-access]",
  468. ));
  469. }
  470. Ok(None)
  471. }
  472. fn parse_clear_args(args: &[&str]) -> Result<bool, SlashCommandParseError> {
  473. match args {
  474. [] => Ok(false),
  475. ["--confirm"] => Ok(true),
  476. [unexpected] => Err(command_error(
  477. &format!("Unsupported /clear argument '{unexpected}'. Use /clear or /clear --confirm."),
  478. "clear",
  479. "/clear [--confirm]",
  480. )),
  481. _ => Err(usage_error("clear", "[--confirm]")),
  482. }
  483. }
  484. fn parse_config_section(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
  485. let section = optional_single_arg("config", args, "[env|hooks|model|plugins]")?;
  486. if let Some(section) = section {
  487. if matches!(section.as_str(), "env" | "hooks" | "model" | "plugins") {
  488. return Ok(Some(section));
  489. }
  490. return Err(command_error(
  491. &format!("Unsupported /config section '{section}'. Use env, hooks, model, or plugins."),
  492. "config",
  493. "/config [env|hooks|model|plugins]",
  494. ));
  495. }
  496. Ok(None)
  497. }
  498. fn parse_session_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
  499. match args {
  500. [] => Ok(SlashCommand::Session {
  501. action: None,
  502. target: None,
  503. }),
  504. ["list"] => Ok(SlashCommand::Session {
  505. action: Some("list".to_string()),
  506. target: None,
  507. }),
  508. ["list", ..] => Err(usage_error("session", "[list|switch <session-id>|fork [branch-name]]")),
  509. ["switch"] => Err(usage_error("session switch", "<session-id>")),
  510. ["switch", target] => Ok(SlashCommand::Session {
  511. action: Some("switch".to_string()),
  512. target: Some((*target).to_string()),
  513. }),
  514. ["switch", ..] => Err(command_error(
  515. "Unexpected arguments for /session switch.",
  516. "session",
  517. "/session switch <session-id>",
  518. )),
  519. ["fork"] => Ok(SlashCommand::Session {
  520. action: Some("fork".to_string()),
  521. target: None,
  522. }),
  523. ["fork", target] => Ok(SlashCommand::Session {
  524. action: Some("fork".to_string()),
  525. target: Some((*target).to_string()),
  526. }),
  527. ["fork", ..] => Err(command_error(
  528. "Unexpected arguments for /session fork.",
  529. "session",
  530. "/session fork [branch-name]",
  531. )),
  532. [action, ..] => Err(command_error(
  533. &format!(
  534. "Unknown /session action '{action}'. Use list, switch <session-id>, or fork [branch-name]."
  535. ),
  536. "session",
  537. "/session [list|switch <session-id>|fork [branch-name]]",
  538. )),
  539. }
  540. }
  541. fn parse_mcp_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
  542. match args {
  543. [] => Ok(SlashCommand::Mcp {
  544. action: None,
  545. target: None,
  546. }),
  547. ["list"] => Ok(SlashCommand::Mcp {
  548. action: Some("list".to_string()),
  549. target: None,
  550. }),
  551. ["list", ..] => Err(usage_error("mcp list", "")),
  552. ["show"] => Err(usage_error("mcp show", "<server>")),
  553. ["show", target] => Ok(SlashCommand::Mcp {
  554. action: Some("show".to_string()),
  555. target: Some((*target).to_string()),
  556. }),
  557. ["show", ..] => Err(command_error(
  558. "Unexpected arguments for /mcp show.",
  559. "mcp",
  560. "/mcp show <server>",
  561. )),
  562. ["help"] | ["-h"] | ["--help"] => Ok(SlashCommand::Mcp {
  563. action: Some("help".to_string()),
  564. target: None,
  565. }),
  566. [action, ..] => Err(command_error(
  567. &format!("Unknown /mcp action '{action}'. Use list, show <server>, or help."),
  568. "mcp",
  569. "/mcp [list|show <server>|help]",
  570. )),
  571. }
  572. }
  573. fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
  574. match args {
  575. [] => Ok(SlashCommand::Plugins {
  576. action: None,
  577. target: None,
  578. }),
  579. ["list"] => Ok(SlashCommand::Plugins {
  580. action: Some("list".to_string()),
  581. target: None,
  582. }),
  583. ["list", ..] => Err(usage_error("plugin list", "")),
  584. ["install"] => Err(usage_error("plugin install", "<path>")),
  585. ["install", target @ ..] => Ok(SlashCommand::Plugins {
  586. action: Some("install".to_string()),
  587. target: Some(target.join(" ")),
  588. }),
  589. ["enable"] => Err(usage_error("plugin enable", "<name>")),
  590. ["enable", target] => Ok(SlashCommand::Plugins {
  591. action: Some("enable".to_string()),
  592. target: Some((*target).to_string()),
  593. }),
  594. ["enable", ..] => Err(command_error(
  595. "Unexpected arguments for /plugin enable.",
  596. "plugin",
  597. "/plugin enable <name>",
  598. )),
  599. ["disable"] => Err(usage_error("plugin disable", "<name>")),
  600. ["disable", target] => Ok(SlashCommand::Plugins {
  601. action: Some("disable".to_string()),
  602. target: Some((*target).to_string()),
  603. }),
  604. ["disable", ..] => Err(command_error(
  605. "Unexpected arguments for /plugin disable.",
  606. "plugin",
  607. "/plugin disable <name>",
  608. )),
  609. ["uninstall"] => Err(usage_error("plugin uninstall", "<id>")),
  610. ["uninstall", target] => Ok(SlashCommand::Plugins {
  611. action: Some("uninstall".to_string()),
  612. target: Some((*target).to_string()),
  613. }),
  614. ["uninstall", ..] => Err(command_error(
  615. "Unexpected arguments for /plugin uninstall.",
  616. "plugin",
  617. "/plugin uninstall <id>",
  618. )),
  619. ["update"] => Err(usage_error("plugin update", "<id>")),
  620. ["update", target] => Ok(SlashCommand::Plugins {
  621. action: Some("update".to_string()),
  622. target: Some((*target).to_string()),
  623. }),
  624. ["update", ..] => Err(command_error(
  625. "Unexpected arguments for /plugin update.",
  626. "plugin",
  627. "/plugin update <id>",
  628. )),
  629. [action, ..] => Err(command_error(
  630. &format!(
  631. "Unknown /plugin action '{action}'. Use list, install <path>, enable <name>, disable <name>, uninstall <id>, or update <id>."
  632. ),
  633. "plugin",
  634. "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
  635. )),
  636. }
  637. }
  638. fn parse_list_or_help_args(
  639. command: &str,
  640. args: Option<String>,
  641. ) -> Result<Option<String>, SlashCommandParseError> {
  642. match normalize_optional_args(args.as_deref()) {
  643. None | Some("list" | "help" | "-h" | "--help") => Ok(args),
  644. Some(unexpected) => Err(command_error(
  645. &format!(
  646. "Unexpected arguments for /{command}: {unexpected}. Use /{command}, /{command} list, or /{command} help."
  647. ),
  648. command,
  649. &format!("/{command} [list|help]"),
  650. )),
  651. }
  652. }
  653. fn parse_skills_args(args: Option<&str>) -> Result<Option<String>, SlashCommandParseError> {
  654. let Some(args) = normalize_optional_args(args) else {
  655. return Ok(None);
  656. };
  657. if matches!(args, "list" | "help" | "-h" | "--help") {
  658. return Ok(Some(args.to_string()));
  659. }
  660. if args == "install" {
  661. return Err(command_error(
  662. "Usage: /skills install <path>",
  663. "skills",
  664. "/skills install <path>",
  665. ));
  666. }
  667. if let Some(target) = args.strip_prefix("install").map(str::trim) {
  668. if !target.is_empty() {
  669. return Ok(Some(format!("install {target}")));
  670. }
  671. }
  672. Err(command_error(
  673. &format!(
  674. "Unexpected arguments for /skills: {args}. Use /skills, /skills list, /skills install <path>, or /skills help."
  675. ),
  676. "skills",
  677. "/skills [list|install <path>|help]",
  678. ))
  679. }
  680. fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
  681. let usage = format!("/{command} {argument_hint}");
  682. let usage = usage.trim_end().to_string();
  683. command_error(
  684. &format!("Usage: {usage}"),
  685. command_root_name(command),
  686. &usage,
  687. )
  688. }
  689. fn command_error(message: &str, command: &str, usage: &str) -> SlashCommandParseError {
  690. let detail = render_slash_command_help_detail(command)
  691. .map(|detail| format!("\n\n{detail}"))
  692. .unwrap_or_default();
  693. SlashCommandParseError::new(format!("{message}\n Usage {usage}{detail}"))
  694. }
  695. fn remainder_after_command(input: &str, command: &str) -> Option<String> {
  696. input
  697. .trim()
  698. .strip_prefix(&format!("/{command}"))
  699. .map(str::trim)
  700. .filter(|value| !value.is_empty())
  701. .map(ToOwned::to_owned)
  702. }
  703. fn find_slash_command_spec(name: &str) -> Option<&'static SlashCommandSpec> {
  704. slash_command_specs().iter().find(|spec| {
  705. spec.name.eq_ignore_ascii_case(name)
  706. || spec
  707. .aliases
  708. .iter()
  709. .any(|alias| alias.eq_ignore_ascii_case(name))
  710. })
  711. }
  712. fn command_root_name(command: &str) -> &str {
  713. command.split_whitespace().next().unwrap_or(command)
  714. }
  715. fn slash_command_usage(spec: &SlashCommandSpec) -> String {
  716. match spec.argument_hint {
  717. Some(argument_hint) => format!("/{} {argument_hint}", spec.name),
  718. None => format!("/{}", spec.name),
  719. }
  720. }
  721. fn slash_command_detail_lines(spec: &SlashCommandSpec) -> Vec<String> {
  722. let mut lines = vec![format!("/{}", spec.name)];
  723. lines.push(format!(" Summary {}", spec.summary));
  724. lines.push(format!(" Usage {}", slash_command_usage(spec)));
  725. lines.push(format!(
  726. " Category {}",
  727. slash_command_category(spec.name)
  728. ));
  729. if !spec.aliases.is_empty() {
  730. lines.push(format!(
  731. " Aliases {}",
  732. spec.aliases
  733. .iter()
  734. .map(|alias| format!("/{alias}"))
  735. .collect::<Vec<_>>()
  736. .join(", ")
  737. ));
  738. }
  739. if spec.resume_supported {
  740. lines.push(" Resume Supported with --resume SESSION.jsonl".to_string());
  741. }
  742. lines
  743. }
  744. #[must_use]
  745. pub fn render_slash_command_help_detail(name: &str) -> Option<String> {
  746. find_slash_command_spec(name).map(|spec| slash_command_detail_lines(spec).join("\n"))
  747. }
  748. #[must_use]
  749. pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
  750. SLASH_COMMAND_SPECS
  751. }
  752. #[must_use]
  753. pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
  754. slash_command_specs()
  755. .iter()
  756. .filter(|spec| spec.resume_supported)
  757. .collect()
  758. }
  759. fn slash_command_category(name: &str) -> &'static str {
  760. match name {
  761. "help" | "status" | "sandbox" | "model" | "permissions" | "cost" | "resume" | "session"
  762. | "version" => "Session & visibility",
  763. "compact" | "clear" | "config" | "memory" | "init" | "diff" | "commit" | "pr" | "issue"
  764. | "export" | "plugin" => "Workspace & git",
  765. "agents" | "skills" | "teleport" | "debug-tool-call" | "mcp" => "Discovery & debugging",
  766. "bughunter" | "ultraplan" => "Analysis & automation",
  767. _ => "Other",
  768. }
  769. }
  770. fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String {
  771. let name = slash_command_usage(spec);
  772. let alias_suffix = if spec.aliases.is_empty() {
  773. String::new()
  774. } else {
  775. format!(
  776. " (aliases: {})",
  777. spec.aliases
  778. .iter()
  779. .map(|alias| format!("/{alias}"))
  780. .collect::<Vec<_>>()
  781. .join(", ")
  782. )
  783. };
  784. let resume = if spec.resume_supported {
  785. " [resume]"
  786. } else {
  787. ""
  788. };
  789. format!(" {name:<66} {}{alias_suffix}{resume}", spec.summary)
  790. }
  791. fn levenshtein_distance(left: &str, right: &str) -> usize {
  792. if left == right {
  793. return 0;
  794. }
  795. if left.is_empty() {
  796. return right.chars().count();
  797. }
  798. if right.is_empty() {
  799. return left.chars().count();
  800. }
  801. let right_chars = right.chars().collect::<Vec<_>>();
  802. let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
  803. let mut current = vec![0; right_chars.len() + 1];
  804. for (left_index, left_char) in left.chars().enumerate() {
  805. current[0] = left_index + 1;
  806. for (right_index, right_char) in right_chars.iter().enumerate() {
  807. let substitution_cost = usize::from(left_char != *right_char);
  808. current[right_index + 1] = (current[right_index] + 1)
  809. .min(previous[right_index + 1] + 1)
  810. .min(previous[right_index] + substitution_cost);
  811. }
  812. previous.clone_from(&current);
  813. }
  814. previous[right_chars.len()]
  815. }
  816. #[must_use]
  817. pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec<String> {
  818. let query = input.trim().trim_start_matches('/').to_ascii_lowercase();
  819. if query.is_empty() || limit == 0 {
  820. return Vec::new();
  821. }
  822. let mut suggestions = slash_command_specs()
  823. .iter()
  824. .filter_map(|spec| {
  825. let best = std::iter::once(spec.name)
  826. .chain(spec.aliases.iter().copied())
  827. .map(str::to_ascii_lowercase)
  828. .map(|candidate| {
  829. let prefix_rank =
  830. if candidate.starts_with(&query) || query.starts_with(&candidate) {
  831. 0
  832. } else if candidate.contains(&query) || query.contains(&candidate) {
  833. 1
  834. } else {
  835. 2
  836. };
  837. let distance = levenshtein_distance(&candidate, &query);
  838. (prefix_rank, distance)
  839. })
  840. .min();
  841. best.and_then(|(prefix_rank, distance)| {
  842. if prefix_rank <= 1 || distance <= 2 {
  843. Some((prefix_rank, distance, spec.name.len(), spec.name))
  844. } else {
  845. None
  846. }
  847. })
  848. })
  849. .collect::<Vec<_>>();
  850. suggestions.sort_unstable();
  851. suggestions
  852. .into_iter()
  853. .map(|(_, _, _, name)| format!("/{name}"))
  854. .take(limit)
  855. .collect()
  856. }
  857. #[must_use]
  858. pub fn render_slash_command_help() -> String {
  859. let mut lines = vec![
  860. "Slash commands".to_string(),
  861. " Start here /status, /diff, /agents, /skills, /commit".to_string(),
  862. " [resume] also works with --resume SESSION.jsonl".to_string(),
  863. String::new(),
  864. ];
  865. let categories = [
  866. "Session & visibility",
  867. "Workspace & git",
  868. "Discovery & debugging",
  869. "Analysis & automation",
  870. ];
  871. for category in categories {
  872. lines.push(category.to_string());
  873. for spec in slash_command_specs()
  874. .iter()
  875. .filter(|spec| slash_command_category(spec.name) == category)
  876. {
  877. lines.push(format_slash_command_help_line(spec));
  878. }
  879. lines.push(String::new());
  880. }
  881. lines
  882. .into_iter()
  883. .rev()
  884. .skip_while(String::is_empty)
  885. .collect::<Vec<_>>()
  886. .into_iter()
  887. .rev()
  888. .collect::<Vec<_>>()
  889. .join("\n")
  890. }
  891. #[derive(Debug, Clone, PartialEq, Eq)]
  892. pub struct SlashCommandResult {
  893. pub message: String,
  894. pub session: Session,
  895. }
  896. #[derive(Debug, Clone, PartialEq, Eq)]
  897. pub struct PluginsCommandResult {
  898. pub message: String,
  899. pub reload_runtime: bool,
  900. }
  901. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
  902. enum DefinitionSource {
  903. ProjectCodex,
  904. ProjectClaude,
  905. UserCodexHome,
  906. UserCodex,
  907. UserClaude,
  908. }
  909. impl DefinitionSource {
  910. fn label(self) -> &'static str {
  911. match self {
  912. Self::ProjectCodex => "Project (.codex)",
  913. Self::ProjectClaude => "Project (.claude)",
  914. Self::UserCodexHome => "User ($CODEX_HOME)",
  915. Self::UserCodex => "User (~/.codex)",
  916. Self::UserClaude => "User (~/.claude)",
  917. }
  918. }
  919. }
  920. #[derive(Debug, Clone, PartialEq, Eq)]
  921. struct AgentSummary {
  922. name: String,
  923. description: Option<String>,
  924. model: Option<String>,
  925. reasoning_effort: Option<String>,
  926. source: DefinitionSource,
  927. shadowed_by: Option<DefinitionSource>,
  928. }
  929. #[derive(Debug, Clone, PartialEq, Eq)]
  930. struct SkillSummary {
  931. name: String,
  932. description: Option<String>,
  933. source: DefinitionSource,
  934. shadowed_by: Option<DefinitionSource>,
  935. origin: SkillOrigin,
  936. }
  937. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  938. enum SkillOrigin {
  939. SkillsDir,
  940. LegacyCommandsDir,
  941. }
  942. impl SkillOrigin {
  943. fn detail_label(self) -> Option<&'static str> {
  944. match self {
  945. Self::SkillsDir => None,
  946. Self::LegacyCommandsDir => Some("legacy /commands"),
  947. }
  948. }
  949. }
  950. #[derive(Debug, Clone, PartialEq, Eq)]
  951. struct SkillRoot {
  952. source: DefinitionSource,
  953. path: PathBuf,
  954. origin: SkillOrigin,
  955. }
  956. #[derive(Debug, Clone, PartialEq, Eq)]
  957. struct InstalledSkill {
  958. invocation_name: String,
  959. display_name: Option<String>,
  960. source: PathBuf,
  961. registry_root: PathBuf,
  962. installed_path: PathBuf,
  963. }
  964. #[derive(Debug, Clone, PartialEq, Eq)]
  965. enum SkillInstallSource {
  966. Directory { root: PathBuf, prompt_path: PathBuf },
  967. MarkdownFile { path: PathBuf },
  968. }
  969. #[allow(clippy::too_many_lines)]
  970. pub fn handle_plugins_slash_command(
  971. action: Option<&str>,
  972. target: Option<&str>,
  973. manager: &mut PluginManager,
  974. ) -> Result<PluginsCommandResult, PluginError> {
  975. match action {
  976. None | Some("list") => Ok(PluginsCommandResult {
  977. message: render_plugins_report(&manager.list_installed_plugins()?),
  978. reload_runtime: false,
  979. }),
  980. Some("install") => {
  981. let Some(target) = target else {
  982. return Ok(PluginsCommandResult {
  983. message: "Usage: /plugins install <path>".to_string(),
  984. reload_runtime: false,
  985. });
  986. };
  987. let install = manager.install(target)?;
  988. let plugin = manager
  989. .list_installed_plugins()?
  990. .into_iter()
  991. .find(|plugin| plugin.metadata.id == install.plugin_id);
  992. Ok(PluginsCommandResult {
  993. message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
  994. reload_runtime: true,
  995. })
  996. }
  997. Some("enable") => {
  998. let Some(target) = target else {
  999. return Ok(PluginsCommandResult {
  1000. message: "Usage: /plugins enable <name>".to_string(),
  1001. reload_runtime: false,
  1002. });
  1003. };
  1004. let plugin = resolve_plugin_target(manager, target)?;
  1005. manager.enable(&plugin.metadata.id)?;
  1006. Ok(PluginsCommandResult {
  1007. message: format!(
  1008. "Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled",
  1009. plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
  1010. ),
  1011. reload_runtime: true,
  1012. })
  1013. }
  1014. Some("disable") => {
  1015. let Some(target) = target else {
  1016. return Ok(PluginsCommandResult {
  1017. message: "Usage: /plugins disable <name>".to_string(),
  1018. reload_runtime: false,
  1019. });
  1020. };
  1021. let plugin = resolve_plugin_target(manager, target)?;
  1022. manager.disable(&plugin.metadata.id)?;
  1023. Ok(PluginsCommandResult {
  1024. message: format!(
  1025. "Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled",
  1026. plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
  1027. ),
  1028. reload_runtime: true,
  1029. })
  1030. }
  1031. Some("uninstall") => {
  1032. let Some(target) = target else {
  1033. return Ok(PluginsCommandResult {
  1034. message: "Usage: /plugins uninstall <plugin-id>".to_string(),
  1035. reload_runtime: false,
  1036. });
  1037. };
  1038. manager.uninstall(target)?;
  1039. Ok(PluginsCommandResult {
  1040. message: format!("Plugins\n Result uninstalled {target}"),
  1041. reload_runtime: true,
  1042. })
  1043. }
  1044. Some("update") => {
  1045. let Some(target) = target else {
  1046. return Ok(PluginsCommandResult {
  1047. message: "Usage: /plugins update <plugin-id>".to_string(),
  1048. reload_runtime: false,
  1049. });
  1050. };
  1051. let update = manager.update(target)?;
  1052. let plugin = manager
  1053. .list_installed_plugins()?
  1054. .into_iter()
  1055. .find(|plugin| plugin.metadata.id == update.plugin_id);
  1056. Ok(PluginsCommandResult {
  1057. message: format!(
  1058. "Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
  1059. update.plugin_id,
  1060. plugin
  1061. .as_ref()
  1062. .map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
  1063. update.old_version,
  1064. update.new_version,
  1065. plugin
  1066. .as_ref()
  1067. .map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
  1068. ),
  1069. reload_runtime: true,
  1070. })
  1071. }
  1072. Some(other) => Ok(PluginsCommandResult {
  1073. message: format!(
  1074. "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
  1075. ),
  1076. reload_runtime: false,
  1077. }),
  1078. }
  1079. }
  1080. pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
  1081. match normalize_optional_args(args) {
  1082. None | Some("list") => {
  1083. let roots = discover_definition_roots(cwd, "agents");
  1084. let agents = load_agents_from_roots(&roots)?;
  1085. Ok(render_agents_report(&agents))
  1086. }
  1087. Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)),
  1088. Some(args) => Ok(render_agents_usage(Some(args))),
  1089. }
  1090. }
  1091. pub fn handle_mcp_slash_command(
  1092. args: Option<&str>,
  1093. cwd: &Path,
  1094. ) -> Result<String, runtime::ConfigError> {
  1095. let loader = ConfigLoader::default_for(cwd);
  1096. render_mcp_report_for(&loader, cwd, args)
  1097. }
  1098. pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
  1099. match normalize_optional_args(args) {
  1100. None | Some("list") => {
  1101. let roots = discover_skill_roots(cwd);
  1102. let skills = load_skills_from_roots(&roots)?;
  1103. Ok(render_skills_report(&skills))
  1104. }
  1105. Some("install") => Ok(render_skills_usage(Some("install"))),
  1106. Some(args) if args.starts_with("install ") => {
  1107. let target = args["install ".len()..].trim();
  1108. if target.is_empty() {
  1109. return Ok(render_skills_usage(Some("install")));
  1110. }
  1111. let install = install_skill(target, cwd)?;
  1112. Ok(render_skill_install_report(&install))
  1113. }
  1114. Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
  1115. Some(args) => Ok(render_skills_usage(Some(args))),
  1116. }
  1117. }
  1118. fn render_mcp_report_for(
  1119. loader: &ConfigLoader,
  1120. cwd: &Path,
  1121. args: Option<&str>,
  1122. ) -> Result<String, runtime::ConfigError> {
  1123. match normalize_optional_args(args) {
  1124. None | Some("list") => {
  1125. let runtime_config = loader.load()?;
  1126. Ok(render_mcp_summary_report(
  1127. cwd,
  1128. runtime_config.mcp().servers(),
  1129. ))
  1130. }
  1131. Some("-h" | "--help" | "help") => Ok(render_mcp_usage(None)),
  1132. Some("show") => Ok(render_mcp_usage(Some("show"))),
  1133. Some(args) if args.split_whitespace().next() == Some("show") => {
  1134. let mut parts = args.split_whitespace();
  1135. let _ = parts.next();
  1136. let Some(server_name) = parts.next() else {
  1137. return Ok(render_mcp_usage(Some("show")));
  1138. };
  1139. if parts.next().is_some() {
  1140. return Ok(render_mcp_usage(Some(args)));
  1141. }
  1142. let runtime_config = loader.load()?;
  1143. Ok(render_mcp_server_report(
  1144. cwd,
  1145. server_name,
  1146. runtime_config.mcp().get(server_name),
  1147. ))
  1148. }
  1149. Some(args) => Ok(render_mcp_usage(Some(args))),
  1150. }
  1151. }
  1152. #[must_use]
  1153. pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
  1154. let mut lines = vec!["Plugins".to_string()];
  1155. if plugins.is_empty() {
  1156. lines.push(" No plugins installed.".to_string());
  1157. return lines.join("\n");
  1158. }
  1159. for plugin in plugins {
  1160. let enabled = if plugin.enabled {
  1161. "enabled"
  1162. } else {
  1163. "disabled"
  1164. };
  1165. lines.push(format!(
  1166. " {name:<20} v{version:<10} {enabled}",
  1167. name = plugin.metadata.name,
  1168. version = plugin.metadata.version,
  1169. ));
  1170. }
  1171. lines.join("\n")
  1172. }
  1173. fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
  1174. let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
  1175. let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
  1176. let enabled = plugin.is_some_and(|plugin| plugin.enabled);
  1177. format!(
  1178. "Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
  1179. if enabled { "enabled" } else { "disabled" }
  1180. )
  1181. }
  1182. fn resolve_plugin_target(
  1183. manager: &PluginManager,
  1184. target: &str,
  1185. ) -> Result<PluginSummary, PluginError> {
  1186. let mut matches = manager
  1187. .list_installed_plugins()?
  1188. .into_iter()
  1189. .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
  1190. .collect::<Vec<_>>();
  1191. match matches.len() {
  1192. 1 => Ok(matches.remove(0)),
  1193. 0 => Err(PluginError::NotFound(format!(
  1194. "plugin `{target}` is not installed or discoverable"
  1195. ))),
  1196. _ => Err(PluginError::InvalidManifest(format!(
  1197. "plugin name `{target}` is ambiguous; use the full plugin id"
  1198. ))),
  1199. }
  1200. }
  1201. fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
  1202. let mut roots = Vec::new();
  1203. for ancestor in cwd.ancestors() {
  1204. push_unique_root(
  1205. &mut roots,
  1206. DefinitionSource::ProjectCodex,
  1207. ancestor.join(".codex").join(leaf),
  1208. );
  1209. push_unique_root(
  1210. &mut roots,
  1211. DefinitionSource::ProjectClaude,
  1212. ancestor.join(".claude").join(leaf),
  1213. );
  1214. }
  1215. if let Ok(codex_home) = env::var("CODEX_HOME") {
  1216. push_unique_root(
  1217. &mut roots,
  1218. DefinitionSource::UserCodexHome,
  1219. PathBuf::from(codex_home).join(leaf),
  1220. );
  1221. }
  1222. if let Some(home) = env::var_os("HOME") {
  1223. let home = PathBuf::from(home);
  1224. push_unique_root(
  1225. &mut roots,
  1226. DefinitionSource::UserCodex,
  1227. home.join(".codex").join(leaf),
  1228. );
  1229. push_unique_root(
  1230. &mut roots,
  1231. DefinitionSource::UserClaude,
  1232. home.join(".claude").join(leaf),
  1233. );
  1234. }
  1235. roots
  1236. }
  1237. fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
  1238. let mut roots = Vec::new();
  1239. for ancestor in cwd.ancestors() {
  1240. push_unique_skill_root(
  1241. &mut roots,
  1242. DefinitionSource::ProjectCodex,
  1243. ancestor.join(".codex").join("skills"),
  1244. SkillOrigin::SkillsDir,
  1245. );
  1246. push_unique_skill_root(
  1247. &mut roots,
  1248. DefinitionSource::ProjectClaude,
  1249. ancestor.join(".claude").join("skills"),
  1250. SkillOrigin::SkillsDir,
  1251. );
  1252. push_unique_skill_root(
  1253. &mut roots,
  1254. DefinitionSource::ProjectCodex,
  1255. ancestor.join(".codex").join("commands"),
  1256. SkillOrigin::LegacyCommandsDir,
  1257. );
  1258. push_unique_skill_root(
  1259. &mut roots,
  1260. DefinitionSource::ProjectClaude,
  1261. ancestor.join(".claude").join("commands"),
  1262. SkillOrigin::LegacyCommandsDir,
  1263. );
  1264. }
  1265. if let Ok(codex_home) = env::var("CODEX_HOME") {
  1266. let codex_home = PathBuf::from(codex_home);
  1267. push_unique_skill_root(
  1268. &mut roots,
  1269. DefinitionSource::UserCodexHome,
  1270. codex_home.join("skills"),
  1271. SkillOrigin::SkillsDir,
  1272. );
  1273. push_unique_skill_root(
  1274. &mut roots,
  1275. DefinitionSource::UserCodexHome,
  1276. codex_home.join("commands"),
  1277. SkillOrigin::LegacyCommandsDir,
  1278. );
  1279. }
  1280. if let Some(home) = env::var_os("HOME") {
  1281. let home = PathBuf::from(home);
  1282. push_unique_skill_root(
  1283. &mut roots,
  1284. DefinitionSource::UserCodex,
  1285. home.join(".codex").join("skills"),
  1286. SkillOrigin::SkillsDir,
  1287. );
  1288. push_unique_skill_root(
  1289. &mut roots,
  1290. DefinitionSource::UserCodex,
  1291. home.join(".codex").join("commands"),
  1292. SkillOrigin::LegacyCommandsDir,
  1293. );
  1294. push_unique_skill_root(
  1295. &mut roots,
  1296. DefinitionSource::UserClaude,
  1297. home.join(".claude").join("skills"),
  1298. SkillOrigin::SkillsDir,
  1299. );
  1300. push_unique_skill_root(
  1301. &mut roots,
  1302. DefinitionSource::UserClaude,
  1303. home.join(".claude").join("commands"),
  1304. SkillOrigin::LegacyCommandsDir,
  1305. );
  1306. }
  1307. roots
  1308. }
  1309. fn install_skill(source: &str, cwd: &Path) -> std::io::Result<InstalledSkill> {
  1310. let registry_root = default_skill_install_root()?;
  1311. install_skill_into(source, cwd, &registry_root)
  1312. }
  1313. fn install_skill_into(
  1314. source: &str,
  1315. cwd: &Path,
  1316. registry_root: &Path,
  1317. ) -> std::io::Result<InstalledSkill> {
  1318. let source = resolve_skill_install_source(source, cwd)?;
  1319. let prompt_path = source.prompt_path();
  1320. let contents = fs::read_to_string(prompt_path)?;
  1321. let display_name = parse_skill_frontmatter(&contents).0;
  1322. let invocation_name = derive_skill_install_name(&source, display_name.as_deref())?;
  1323. let installed_path = registry_root.join(&invocation_name);
  1324. if installed_path.exists() {
  1325. return Err(std::io::Error::new(
  1326. std::io::ErrorKind::AlreadyExists,
  1327. format!(
  1328. "skill '{invocation_name}' is already installed at {}",
  1329. installed_path.display()
  1330. ),
  1331. ));
  1332. }
  1333. fs::create_dir_all(&installed_path)?;
  1334. let install_result = match &source {
  1335. SkillInstallSource::Directory { root, .. } => {
  1336. copy_directory_contents(root, &installed_path)
  1337. }
  1338. SkillInstallSource::MarkdownFile { path } => {
  1339. fs::copy(path, installed_path.join("SKILL.md")).map(|_| ())
  1340. }
  1341. };
  1342. if let Err(error) = install_result {
  1343. let _ = fs::remove_dir_all(&installed_path);
  1344. return Err(error);
  1345. }
  1346. Ok(InstalledSkill {
  1347. invocation_name,
  1348. display_name,
  1349. source: source.report_path().to_path_buf(),
  1350. registry_root: registry_root.to_path_buf(),
  1351. installed_path,
  1352. })
  1353. }
  1354. fn default_skill_install_root() -> std::io::Result<PathBuf> {
  1355. if let Ok(codex_home) = env::var("CODEX_HOME") {
  1356. return Ok(PathBuf::from(codex_home).join("skills"));
  1357. }
  1358. if let Some(home) = env::var_os("HOME") {
  1359. return Ok(PathBuf::from(home).join(".codex").join("skills"));
  1360. }
  1361. Err(std::io::Error::new(
  1362. std::io::ErrorKind::NotFound,
  1363. "unable to resolve a skills install root; set CODEX_HOME or HOME",
  1364. ))
  1365. }
  1366. fn resolve_skill_install_source(source: &str, cwd: &Path) -> std::io::Result<SkillInstallSource> {
  1367. let candidate = PathBuf::from(source);
  1368. let source = if candidate.is_absolute() {
  1369. candidate
  1370. } else {
  1371. cwd.join(candidate)
  1372. };
  1373. let source = fs::canonicalize(&source)?;
  1374. if source.is_dir() {
  1375. let prompt_path = source.join("SKILL.md");
  1376. if !prompt_path.is_file() {
  1377. return Err(std::io::Error::new(
  1378. std::io::ErrorKind::InvalidInput,
  1379. format!(
  1380. "skill directory '{}' must contain SKILL.md",
  1381. source.display()
  1382. ),
  1383. ));
  1384. }
  1385. return Ok(SkillInstallSource::Directory {
  1386. root: source,
  1387. prompt_path,
  1388. });
  1389. }
  1390. if source
  1391. .extension()
  1392. .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
  1393. {
  1394. return Ok(SkillInstallSource::MarkdownFile { path: source });
  1395. }
  1396. Err(std::io::Error::new(
  1397. std::io::ErrorKind::InvalidInput,
  1398. format!(
  1399. "skill source '{}' must be a directory with SKILL.md or a markdown file",
  1400. source.display()
  1401. ),
  1402. ))
  1403. }
  1404. fn derive_skill_install_name(
  1405. source: &SkillInstallSource,
  1406. declared_name: Option<&str>,
  1407. ) -> std::io::Result<String> {
  1408. for candidate in [declared_name, source.fallback_name().as_deref()] {
  1409. if let Some(candidate) = candidate.and_then(sanitize_skill_invocation_name) {
  1410. return Ok(candidate);
  1411. }
  1412. }
  1413. Err(std::io::Error::new(
  1414. std::io::ErrorKind::InvalidInput,
  1415. format!(
  1416. "unable to derive an installable invocation name from '{}'",
  1417. source.report_path().display()
  1418. ),
  1419. ))
  1420. }
  1421. fn sanitize_skill_invocation_name(candidate: &str) -> Option<String> {
  1422. let trimmed = candidate
  1423. .trim()
  1424. .trim_start_matches('/')
  1425. .trim_start_matches('$');
  1426. if trimmed.is_empty() {
  1427. return None;
  1428. }
  1429. let mut sanitized = String::new();
  1430. let mut last_was_separator = false;
  1431. for ch in trimmed.chars() {
  1432. if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
  1433. sanitized.push(ch.to_ascii_lowercase());
  1434. last_was_separator = false;
  1435. } else if (ch.is_whitespace() || matches!(ch, '/' | '\\'))
  1436. && !last_was_separator
  1437. && !sanitized.is_empty()
  1438. {
  1439. sanitized.push('-');
  1440. last_was_separator = true;
  1441. }
  1442. }
  1443. let sanitized = sanitized
  1444. .trim_matches(|ch| matches!(ch, '-' | '_' | '.'))
  1445. .to_string();
  1446. (!sanitized.is_empty()).then_some(sanitized)
  1447. }
  1448. fn copy_directory_contents(source: &Path, destination: &Path) -> std::io::Result<()> {
  1449. for entry in fs::read_dir(source)? {
  1450. let entry = entry?;
  1451. let entry_type = entry.file_type()?;
  1452. let destination_path = destination.join(entry.file_name());
  1453. if entry_type.is_dir() {
  1454. fs::create_dir_all(&destination_path)?;
  1455. copy_directory_contents(&entry.path(), &destination_path)?;
  1456. } else {
  1457. fs::copy(entry.path(), destination_path)?;
  1458. }
  1459. }
  1460. Ok(())
  1461. }
  1462. impl SkillInstallSource {
  1463. fn prompt_path(&self) -> &Path {
  1464. match self {
  1465. Self::Directory { prompt_path, .. } => prompt_path,
  1466. Self::MarkdownFile { path } => path,
  1467. }
  1468. }
  1469. fn fallback_name(&self) -> Option<String> {
  1470. match self {
  1471. Self::Directory { root, .. } => root
  1472. .file_name()
  1473. .map(|name| name.to_string_lossy().to_string()),
  1474. Self::MarkdownFile { path } => path
  1475. .file_stem()
  1476. .map(|name| name.to_string_lossy().to_string()),
  1477. }
  1478. }
  1479. fn report_path(&self) -> &Path {
  1480. match self {
  1481. Self::Directory { root, .. } => root,
  1482. Self::MarkdownFile { path } => path,
  1483. }
  1484. }
  1485. }
  1486. fn push_unique_root(
  1487. roots: &mut Vec<(DefinitionSource, PathBuf)>,
  1488. source: DefinitionSource,
  1489. path: PathBuf,
  1490. ) {
  1491. if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) {
  1492. roots.push((source, path));
  1493. }
  1494. }
  1495. fn push_unique_skill_root(
  1496. roots: &mut Vec<SkillRoot>,
  1497. source: DefinitionSource,
  1498. path: PathBuf,
  1499. origin: SkillOrigin,
  1500. ) {
  1501. if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
  1502. roots.push(SkillRoot {
  1503. source,
  1504. path,
  1505. origin,
  1506. });
  1507. }
  1508. }
  1509. fn load_agents_from_roots(
  1510. roots: &[(DefinitionSource, PathBuf)],
  1511. ) -> std::io::Result<Vec<AgentSummary>> {
  1512. let mut agents = Vec::new();
  1513. let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
  1514. for (source, root) in roots {
  1515. let mut root_agents = Vec::new();
  1516. for entry in fs::read_dir(root)? {
  1517. let entry = entry?;
  1518. if entry.path().extension().is_none_or(|ext| ext != "toml") {
  1519. continue;
  1520. }
  1521. let contents = fs::read_to_string(entry.path())?;
  1522. let fallback_name = entry.path().file_stem().map_or_else(
  1523. || entry.file_name().to_string_lossy().to_string(),
  1524. |stem| stem.to_string_lossy().to_string(),
  1525. );
  1526. root_agents.push(AgentSummary {
  1527. name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
  1528. description: parse_toml_string(&contents, "description"),
  1529. model: parse_toml_string(&contents, "model"),
  1530. reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
  1531. source: *source,
  1532. shadowed_by: None,
  1533. });
  1534. }
  1535. root_agents.sort_by(|left, right| left.name.cmp(&right.name));
  1536. for mut agent in root_agents {
  1537. let key = agent.name.to_ascii_lowercase();
  1538. if let Some(existing) = active_sources.get(&key) {
  1539. agent.shadowed_by = Some(*existing);
  1540. } else {
  1541. active_sources.insert(key, agent.source);
  1542. }
  1543. agents.push(agent);
  1544. }
  1545. }
  1546. Ok(agents)
  1547. }
  1548. fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
  1549. let mut skills = Vec::new();
  1550. let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
  1551. for root in roots {
  1552. let mut root_skills = Vec::new();
  1553. for entry in fs::read_dir(&root.path)? {
  1554. let entry = entry?;
  1555. match root.origin {
  1556. SkillOrigin::SkillsDir => {
  1557. if !entry.path().is_dir() {
  1558. continue;
  1559. }
  1560. let skill_path = entry.path().join("SKILL.md");
  1561. if !skill_path.is_file() {
  1562. continue;
  1563. }
  1564. let contents = fs::read_to_string(skill_path)?;
  1565. let (name, description) = parse_skill_frontmatter(&contents);
  1566. root_skills.push(SkillSummary {
  1567. name: name
  1568. .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
  1569. description,
  1570. source: root.source,
  1571. shadowed_by: None,
  1572. origin: root.origin,
  1573. });
  1574. }
  1575. SkillOrigin::LegacyCommandsDir => {
  1576. let path = entry.path();
  1577. let markdown_path = if path.is_dir() {
  1578. let skill_path = path.join("SKILL.md");
  1579. if !skill_path.is_file() {
  1580. continue;
  1581. }
  1582. skill_path
  1583. } else if path
  1584. .extension()
  1585. .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
  1586. {
  1587. path
  1588. } else {
  1589. continue;
  1590. };
  1591. let contents = fs::read_to_string(&markdown_path)?;
  1592. let fallback_name = markdown_path.file_stem().map_or_else(
  1593. || entry.file_name().to_string_lossy().to_string(),
  1594. |stem| stem.to_string_lossy().to_string(),
  1595. );
  1596. let (name, description) = parse_skill_frontmatter(&contents);
  1597. root_skills.push(SkillSummary {
  1598. name: name.unwrap_or(fallback_name),
  1599. description,
  1600. source: root.source,
  1601. shadowed_by: None,
  1602. origin: root.origin,
  1603. });
  1604. }
  1605. }
  1606. }
  1607. root_skills.sort_by(|left, right| left.name.cmp(&right.name));
  1608. for mut skill in root_skills {
  1609. let key = skill.name.to_ascii_lowercase();
  1610. if let Some(existing) = active_sources.get(&key) {
  1611. skill.shadowed_by = Some(*existing);
  1612. } else {
  1613. active_sources.insert(key, skill.source);
  1614. }
  1615. skills.push(skill);
  1616. }
  1617. }
  1618. Ok(skills)
  1619. }
  1620. fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
  1621. let prefix = format!("{key} =");
  1622. for line in contents.lines() {
  1623. let trimmed = line.trim();
  1624. if trimmed.starts_with('#') {
  1625. continue;
  1626. }
  1627. let Some(value) = trimmed.strip_prefix(&prefix) else {
  1628. continue;
  1629. };
  1630. let value = value.trim();
  1631. let Some(value) = value
  1632. .strip_prefix('"')
  1633. .and_then(|value| value.strip_suffix('"'))
  1634. else {
  1635. continue;
  1636. };
  1637. if !value.is_empty() {
  1638. return Some(value.to_string());
  1639. }
  1640. }
  1641. None
  1642. }
  1643. fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
  1644. let mut lines = contents.lines();
  1645. if lines.next().map(str::trim) != Some("---") {
  1646. return (None, None);
  1647. }
  1648. let mut name = None;
  1649. let mut description = None;
  1650. for line in lines {
  1651. let trimmed = line.trim();
  1652. if trimmed == "---" {
  1653. break;
  1654. }
  1655. if let Some(value) = trimmed.strip_prefix("name:") {
  1656. let value = unquote_frontmatter_value(value.trim());
  1657. if !value.is_empty() {
  1658. name = Some(value);
  1659. }
  1660. continue;
  1661. }
  1662. if let Some(value) = trimmed.strip_prefix("description:") {
  1663. let value = unquote_frontmatter_value(value.trim());
  1664. if !value.is_empty() {
  1665. description = Some(value);
  1666. }
  1667. }
  1668. }
  1669. (name, description)
  1670. }
  1671. fn unquote_frontmatter_value(value: &str) -> String {
  1672. value
  1673. .strip_prefix('"')
  1674. .and_then(|trimmed| trimmed.strip_suffix('"'))
  1675. .or_else(|| {
  1676. value
  1677. .strip_prefix('\'')
  1678. .and_then(|trimmed| trimmed.strip_suffix('\''))
  1679. })
  1680. .unwrap_or(value)
  1681. .trim()
  1682. .to_string()
  1683. }
  1684. fn render_agents_report(agents: &[AgentSummary]) -> String {
  1685. if agents.is_empty() {
  1686. return "No agents found.".to_string();
  1687. }
  1688. let total_active = agents
  1689. .iter()
  1690. .filter(|agent| agent.shadowed_by.is_none())
  1691. .count();
  1692. let mut lines = vec![
  1693. "Agents".to_string(),
  1694. format!(" {total_active} active agents"),
  1695. String::new(),
  1696. ];
  1697. for source in [
  1698. DefinitionSource::ProjectCodex,
  1699. DefinitionSource::ProjectClaude,
  1700. DefinitionSource::UserCodexHome,
  1701. DefinitionSource::UserCodex,
  1702. DefinitionSource::UserClaude,
  1703. ] {
  1704. let group = agents
  1705. .iter()
  1706. .filter(|agent| agent.source == source)
  1707. .collect::<Vec<_>>();
  1708. if group.is_empty() {
  1709. continue;
  1710. }
  1711. lines.push(format!("{}:", source.label()));
  1712. for agent in group {
  1713. let detail = agent_detail(agent);
  1714. match agent.shadowed_by {
  1715. Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
  1716. None => lines.push(format!(" {detail}")),
  1717. }
  1718. }
  1719. lines.push(String::new());
  1720. }
  1721. lines.join("\n").trim_end().to_string()
  1722. }
  1723. fn agent_detail(agent: &AgentSummary) -> String {
  1724. let mut parts = vec![agent.name.clone()];
  1725. if let Some(description) = &agent.description {
  1726. parts.push(description.clone());
  1727. }
  1728. if let Some(model) = &agent.model {
  1729. parts.push(model.clone());
  1730. }
  1731. if let Some(reasoning) = &agent.reasoning_effort {
  1732. parts.push(reasoning.clone());
  1733. }
  1734. parts.join(" · ")
  1735. }
  1736. fn render_skills_report(skills: &[SkillSummary]) -> String {
  1737. if skills.is_empty() {
  1738. return "No skills found.".to_string();
  1739. }
  1740. let total_active = skills
  1741. .iter()
  1742. .filter(|skill| skill.shadowed_by.is_none())
  1743. .count();
  1744. let mut lines = vec![
  1745. "Skills".to_string(),
  1746. format!(" {total_active} available skills"),
  1747. String::new(),
  1748. ];
  1749. for source in [
  1750. DefinitionSource::ProjectCodex,
  1751. DefinitionSource::ProjectClaude,
  1752. DefinitionSource::UserCodexHome,
  1753. DefinitionSource::UserCodex,
  1754. DefinitionSource::UserClaude,
  1755. ] {
  1756. let group = skills
  1757. .iter()
  1758. .filter(|skill| skill.source == source)
  1759. .collect::<Vec<_>>();
  1760. if group.is_empty() {
  1761. continue;
  1762. }
  1763. lines.push(format!("{}:", source.label()));
  1764. for skill in group {
  1765. let mut parts = vec![skill.name.clone()];
  1766. if let Some(description) = &skill.description {
  1767. parts.push(description.clone());
  1768. }
  1769. if let Some(detail) = skill.origin.detail_label() {
  1770. parts.push(detail.to_string());
  1771. }
  1772. let detail = parts.join(" · ");
  1773. match skill.shadowed_by {
  1774. Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
  1775. None => lines.push(format!(" {detail}")),
  1776. }
  1777. }
  1778. lines.push(String::new());
  1779. }
  1780. lines.join("\n").trim_end().to_string()
  1781. }
  1782. fn render_skill_install_report(skill: &InstalledSkill) -> String {
  1783. let mut lines = vec![
  1784. "Skills".to_string(),
  1785. format!(" Result installed {}", skill.invocation_name),
  1786. format!(" Invoke as ${}", skill.invocation_name),
  1787. ];
  1788. if let Some(display_name) = &skill.display_name {
  1789. lines.push(format!(" Display name {display_name}"));
  1790. }
  1791. lines.push(format!(" Source {}", skill.source.display()));
  1792. lines.push(format!(
  1793. " Registry {}",
  1794. skill.registry_root.display()
  1795. ));
  1796. lines.push(format!(
  1797. " Installed path {}",
  1798. skill.installed_path.display()
  1799. ));
  1800. lines.join("\n")
  1801. }
  1802. fn render_mcp_summary_report(
  1803. cwd: &Path,
  1804. servers: &BTreeMap<String, ScopedMcpServerConfig>,
  1805. ) -> String {
  1806. let mut lines = vec![
  1807. "MCP".to_string(),
  1808. format!(" Working directory {}", cwd.display()),
  1809. format!(" Configured servers {}", servers.len()),
  1810. ];
  1811. if servers.is_empty() {
  1812. lines.push(" No MCP servers configured.".to_string());
  1813. return lines.join("\n");
  1814. }
  1815. lines.push(String::new());
  1816. for (name, server) in servers {
  1817. lines.push(format!(
  1818. " {name:<16} {transport:<13} {scope:<7} {summary}",
  1819. transport = mcp_transport_label(&server.config),
  1820. scope = config_source_label(server.scope),
  1821. summary = mcp_server_summary(&server.config)
  1822. ));
  1823. }
  1824. lines.join("\n")
  1825. }
  1826. fn render_mcp_server_report(
  1827. cwd: &Path,
  1828. server_name: &str,
  1829. server: Option<&ScopedMcpServerConfig>,
  1830. ) -> String {
  1831. let Some(server) = server else {
  1832. return format!(
  1833. "MCP\n Working directory {}\n Result server `{server_name}` is not configured",
  1834. cwd.display()
  1835. );
  1836. };
  1837. let mut lines = vec![
  1838. "MCP".to_string(),
  1839. format!(" Working directory {}", cwd.display()),
  1840. format!(" Name {server_name}"),
  1841. format!(" Scope {}", config_source_label(server.scope)),
  1842. format!(
  1843. " Transport {}",
  1844. mcp_transport_label(&server.config)
  1845. ),
  1846. ];
  1847. match &server.config {
  1848. McpServerConfig::Stdio(config) => {
  1849. lines.push(format!(" Command {}", config.command));
  1850. lines.push(format!(
  1851. " Args {}",
  1852. format_optional_list(&config.args)
  1853. ));
  1854. lines.push(format!(
  1855. " Env keys {}",
  1856. format_optional_keys(config.env.keys().cloned().collect())
  1857. ));
  1858. lines.push(format!(
  1859. " Tool timeout {}",
  1860. config
  1861. .tool_call_timeout_ms
  1862. .map_or_else(|| "<default>".to_string(), |value| format!("{value} ms"))
  1863. ));
  1864. }
  1865. McpServerConfig::Sse(config) | McpServerConfig::Http(config) => {
  1866. lines.push(format!(" URL {}", config.url));
  1867. lines.push(format!(
  1868. " Header keys {}",
  1869. format_optional_keys(config.headers.keys().cloned().collect())
  1870. ));
  1871. lines.push(format!(
  1872. " Header helper {}",
  1873. config.headers_helper.as_deref().unwrap_or("<none>")
  1874. ));
  1875. lines.push(format!(
  1876. " OAuth {}",
  1877. format_mcp_oauth(config.oauth.as_ref())
  1878. ));
  1879. }
  1880. McpServerConfig::Ws(config) => {
  1881. lines.push(format!(" URL {}", config.url));
  1882. lines.push(format!(
  1883. " Header keys {}",
  1884. format_optional_keys(config.headers.keys().cloned().collect())
  1885. ));
  1886. lines.push(format!(
  1887. " Header helper {}",
  1888. config.headers_helper.as_deref().unwrap_or("<none>")
  1889. ));
  1890. }
  1891. McpServerConfig::Sdk(config) => {
  1892. lines.push(format!(" SDK name {}", config.name));
  1893. }
  1894. McpServerConfig::ManagedProxy(config) => {
  1895. lines.push(format!(" URL {}", config.url));
  1896. lines.push(format!(" Proxy id {}", config.id));
  1897. }
  1898. }
  1899. lines.join("\n")
  1900. }
  1901. fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
  1902. args.map(str::trim).filter(|value| !value.is_empty())
  1903. }
  1904. fn render_agents_usage(unexpected: Option<&str>) -> String {
  1905. let mut lines = vec![
  1906. "Agents".to_string(),
  1907. " Usage /agents [list|help]".to_string(),
  1908. " Direct CLI claw agents".to_string(),
  1909. " Sources .codex/agents, .claude/agents, $CODEX_HOME/agents".to_string(),
  1910. ];
  1911. if let Some(args) = unexpected {
  1912. lines.push(format!(" Unexpected {args}"));
  1913. }
  1914. lines.join("\n")
  1915. }
  1916. fn render_skills_usage(unexpected: Option<&str>) -> String {
  1917. let mut lines = vec![
  1918. "Skills".to_string(),
  1919. " Usage /skills [list|install <path>|help]".to_string(),
  1920. " Direct CLI claw skills [list|install <path>|help]".to_string(),
  1921. " Install root $CODEX_HOME/skills or ~/.codex/skills".to_string(),
  1922. " Sources .codex/skills, .claude/skills, legacy /commands".to_string(),
  1923. ];
  1924. if let Some(args) = unexpected {
  1925. lines.push(format!(" Unexpected {args}"));
  1926. }
  1927. lines.join("\n")
  1928. }
  1929. fn render_mcp_usage(unexpected: Option<&str>) -> String {
  1930. let mut lines = vec![
  1931. "MCP".to_string(),
  1932. " Usage /mcp [list|show <server>|help]".to_string(),
  1933. " Direct CLI claw mcp [list|show <server>|help]".to_string(),
  1934. " Sources .claw/settings.json, .claw/settings.local.json".to_string(),
  1935. ];
  1936. if let Some(args) = unexpected {
  1937. lines.push(format!(" Unexpected {args}"));
  1938. }
  1939. lines.join("\n")
  1940. }
  1941. fn config_source_label(source: ConfigSource) -> &'static str {
  1942. match source {
  1943. ConfigSource::User => "user",
  1944. ConfigSource::Project => "project",
  1945. ConfigSource::Local => "local",
  1946. }
  1947. }
  1948. fn mcp_transport_label(config: &McpServerConfig) -> &'static str {
  1949. match config {
  1950. McpServerConfig::Stdio(_) => "stdio",
  1951. McpServerConfig::Sse(_) => "sse",
  1952. McpServerConfig::Http(_) => "http",
  1953. McpServerConfig::Ws(_) => "ws",
  1954. McpServerConfig::Sdk(_) => "sdk",
  1955. McpServerConfig::ManagedProxy(_) => "managed-proxy",
  1956. }
  1957. }
  1958. fn mcp_server_summary(config: &McpServerConfig) -> String {
  1959. match config {
  1960. McpServerConfig::Stdio(config) => {
  1961. if config.args.is_empty() {
  1962. config.command.clone()
  1963. } else {
  1964. format!("{} {}", config.command, config.args.join(" "))
  1965. }
  1966. }
  1967. McpServerConfig::Sse(config) | McpServerConfig::Http(config) => config.url.clone(),
  1968. McpServerConfig::Ws(config) => config.url.clone(),
  1969. McpServerConfig::Sdk(config) => config.name.clone(),
  1970. McpServerConfig::ManagedProxy(config) => format!("{} ({})", config.id, config.url),
  1971. }
  1972. }
  1973. fn format_optional_list(values: &[String]) -> String {
  1974. if values.is_empty() {
  1975. "<none>".to_string()
  1976. } else {
  1977. values.join(" ")
  1978. }
  1979. }
  1980. fn format_optional_keys(mut keys: Vec<String>) -> String {
  1981. if keys.is_empty() {
  1982. return "<none>".to_string();
  1983. }
  1984. keys.sort();
  1985. keys.join(", ")
  1986. }
  1987. fn format_mcp_oauth(oauth: Option<&McpOAuthConfig>) -> String {
  1988. let Some(oauth) = oauth else {
  1989. return "<none>".to_string();
  1990. };
  1991. let mut parts = Vec::new();
  1992. if let Some(client_id) = &oauth.client_id {
  1993. parts.push(format!("client_id={client_id}"));
  1994. }
  1995. if let Some(port) = oauth.callback_port {
  1996. parts.push(format!("callback_port={port}"));
  1997. }
  1998. if let Some(url) = &oauth.auth_server_metadata_url {
  1999. parts.push(format!("metadata_url={url}"));
  2000. }
  2001. if let Some(xaa) = oauth.xaa {
  2002. parts.push(format!("xaa={xaa}"));
  2003. }
  2004. if parts.is_empty() {
  2005. "enabled".to_string()
  2006. } else {
  2007. parts.join(", ")
  2008. }
  2009. }
  2010. #[must_use]
  2011. pub fn handle_slash_command(
  2012. input: &str,
  2013. session: &Session,
  2014. compaction: CompactionConfig,
  2015. ) -> Option<SlashCommandResult> {
  2016. let command = match SlashCommand::parse(input) {
  2017. Ok(Some(command)) => command,
  2018. Ok(None) => return None,
  2019. Err(error) => {
  2020. return Some(SlashCommandResult {
  2021. message: error.to_string(),
  2022. session: session.clone(),
  2023. });
  2024. }
  2025. };
  2026. match command {
  2027. SlashCommand::Compact => {
  2028. let result = compact_session(session, compaction);
  2029. let message = if result.removed_message_count == 0 {
  2030. "Compaction skipped: session is below the compaction threshold.".to_string()
  2031. } else {
  2032. format!(
  2033. "Compacted {} messages into a resumable system summary.",
  2034. result.removed_message_count
  2035. )
  2036. };
  2037. Some(SlashCommandResult {
  2038. message,
  2039. session: result.compacted_session,
  2040. })
  2041. }
  2042. SlashCommand::Help => Some(SlashCommandResult {
  2043. message: render_slash_command_help(),
  2044. session: session.clone(),
  2045. }),
  2046. SlashCommand::Status
  2047. | SlashCommand::Bughunter { .. }
  2048. | SlashCommand::Commit
  2049. | SlashCommand::Pr { .. }
  2050. | SlashCommand::Issue { .. }
  2051. | SlashCommand::Ultraplan { .. }
  2052. | SlashCommand::Teleport { .. }
  2053. | SlashCommand::DebugToolCall
  2054. | SlashCommand::Sandbox
  2055. | SlashCommand::Model { .. }
  2056. | SlashCommand::Permissions { .. }
  2057. | SlashCommand::Clear { .. }
  2058. | SlashCommand::Cost
  2059. | SlashCommand::Resume { .. }
  2060. | SlashCommand::Config { .. }
  2061. | SlashCommand::Mcp { .. }
  2062. | SlashCommand::Memory
  2063. | SlashCommand::Init
  2064. | SlashCommand::Diff
  2065. | SlashCommand::Version
  2066. | SlashCommand::Export { .. }
  2067. | SlashCommand::Session { .. }
  2068. | SlashCommand::Plugins { .. }
  2069. | SlashCommand::Agents { .. }
  2070. | SlashCommand::Skills { .. }
  2071. | SlashCommand::Unknown(_) => None,
  2072. }
  2073. }
  2074. #[cfg(test)]
  2075. mod tests {
  2076. use super::{
  2077. handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
  2078. load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
  2079. render_slash_command_help, render_slash_command_help_detail,
  2080. resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
  2081. validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
  2082. };
  2083. use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
  2084. use runtime::{
  2085. CompactionConfig, ConfigLoader, ContentBlock, ConversationMessage, MessageRole, Session,
  2086. };
  2087. use std::fs;
  2088. use std::path::{Path, PathBuf};
  2089. use std::time::{SystemTime, UNIX_EPOCH};
  2090. fn temp_dir(label: &str) -> PathBuf {
  2091. let nanos = SystemTime::now()
  2092. .duration_since(UNIX_EPOCH)
  2093. .expect("time should be after epoch")
  2094. .as_nanos();
  2095. std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
  2096. }
  2097. fn write_external_plugin(root: &Path, name: &str, version: &str) {
  2098. fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
  2099. fs::write(
  2100. root.join(".claude-plugin").join("plugin.json"),
  2101. format!(
  2102. "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
  2103. ),
  2104. )
  2105. .expect("write manifest");
  2106. }
  2107. fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
  2108. fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
  2109. fs::write(
  2110. root.join(".claude-plugin").join("plugin.json"),
  2111. format!(
  2112. "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}",
  2113. if default_enabled { "true" } else { "false" }
  2114. ),
  2115. )
  2116. .expect("write bundled manifest");
  2117. }
  2118. fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
  2119. fs::create_dir_all(root).expect("agent root");
  2120. fs::write(
  2121. root.join(format!("{name}.toml")),
  2122. format!(
  2123. "name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
  2124. ),
  2125. )
  2126. .expect("write agent");
  2127. }
  2128. fn write_skill(root: &Path, name: &str, description: &str) {
  2129. let skill_root = root.join(name);
  2130. fs::create_dir_all(&skill_root).expect("skill root");
  2131. fs::write(
  2132. skill_root.join("SKILL.md"),
  2133. format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
  2134. )
  2135. .expect("write skill");
  2136. }
  2137. fn write_legacy_command(root: &Path, name: &str, description: &str) {
  2138. fs::create_dir_all(root).expect("commands root");
  2139. fs::write(
  2140. root.join(format!("{name}.md")),
  2141. format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
  2142. )
  2143. .expect("write command");
  2144. }
  2145. fn parse_error_message(input: &str) -> String {
  2146. SlashCommand::parse(input)
  2147. .expect_err("slash command should be rejected")
  2148. .to_string()
  2149. }
  2150. #[allow(clippy::too_many_lines)]
  2151. #[test]
  2152. fn parses_supported_slash_commands() {
  2153. assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
  2154. assert_eq!(
  2155. SlashCommand::parse(" /status "),
  2156. Ok(Some(SlashCommand::Status))
  2157. );
  2158. assert_eq!(
  2159. SlashCommand::parse("/sandbox"),
  2160. Ok(Some(SlashCommand::Sandbox))
  2161. );
  2162. assert_eq!(
  2163. SlashCommand::parse("/bughunter runtime"),
  2164. Ok(Some(SlashCommand::Bughunter {
  2165. scope: Some("runtime".to_string())
  2166. }))
  2167. );
  2168. assert_eq!(
  2169. SlashCommand::parse("/commit"),
  2170. Ok(Some(SlashCommand::Commit))
  2171. );
  2172. assert_eq!(
  2173. SlashCommand::parse("/pr ready for review"),
  2174. Ok(Some(SlashCommand::Pr {
  2175. context: Some("ready for review".to_string())
  2176. }))
  2177. );
  2178. assert_eq!(
  2179. SlashCommand::parse("/issue flaky test"),
  2180. Ok(Some(SlashCommand::Issue {
  2181. context: Some("flaky test".to_string())
  2182. }))
  2183. );
  2184. assert_eq!(
  2185. SlashCommand::parse("/ultraplan ship both features"),
  2186. Ok(Some(SlashCommand::Ultraplan {
  2187. task: Some("ship both features".to_string())
  2188. }))
  2189. );
  2190. assert_eq!(
  2191. SlashCommand::parse("/teleport conversation.rs"),
  2192. Ok(Some(SlashCommand::Teleport {
  2193. target: Some("conversation.rs".to_string())
  2194. }))
  2195. );
  2196. assert_eq!(
  2197. SlashCommand::parse("/debug-tool-call"),
  2198. Ok(Some(SlashCommand::DebugToolCall))
  2199. );
  2200. assert_eq!(
  2201. SlashCommand::parse("/bughunter runtime"),
  2202. Ok(Some(SlashCommand::Bughunter {
  2203. scope: Some("runtime".to_string())
  2204. }))
  2205. );
  2206. assert_eq!(
  2207. SlashCommand::parse("/commit"),
  2208. Ok(Some(SlashCommand::Commit))
  2209. );
  2210. assert_eq!(
  2211. SlashCommand::parse("/pr ready for review"),
  2212. Ok(Some(SlashCommand::Pr {
  2213. context: Some("ready for review".to_string())
  2214. }))
  2215. );
  2216. assert_eq!(
  2217. SlashCommand::parse("/issue flaky test"),
  2218. Ok(Some(SlashCommand::Issue {
  2219. context: Some("flaky test".to_string())
  2220. }))
  2221. );
  2222. assert_eq!(
  2223. SlashCommand::parse("/ultraplan ship both features"),
  2224. Ok(Some(SlashCommand::Ultraplan {
  2225. task: Some("ship both features".to_string())
  2226. }))
  2227. );
  2228. assert_eq!(
  2229. SlashCommand::parse("/teleport conversation.rs"),
  2230. Ok(Some(SlashCommand::Teleport {
  2231. target: Some("conversation.rs".to_string())
  2232. }))
  2233. );
  2234. assert_eq!(
  2235. SlashCommand::parse("/debug-tool-call"),
  2236. Ok(Some(SlashCommand::DebugToolCall))
  2237. );
  2238. assert_eq!(
  2239. SlashCommand::parse("/model claude-opus"),
  2240. Ok(Some(SlashCommand::Model {
  2241. model: Some("claude-opus".to_string()),
  2242. }))
  2243. );
  2244. assert_eq!(
  2245. SlashCommand::parse("/model"),
  2246. Ok(Some(SlashCommand::Model { model: None }))
  2247. );
  2248. assert_eq!(
  2249. SlashCommand::parse("/permissions read-only"),
  2250. Ok(Some(SlashCommand::Permissions {
  2251. mode: Some("read-only".to_string()),
  2252. }))
  2253. );
  2254. assert_eq!(
  2255. SlashCommand::parse("/clear"),
  2256. Ok(Some(SlashCommand::Clear { confirm: false }))
  2257. );
  2258. assert_eq!(
  2259. SlashCommand::parse("/clear --confirm"),
  2260. Ok(Some(SlashCommand::Clear { confirm: true }))
  2261. );
  2262. assert_eq!(SlashCommand::parse("/cost"), Ok(Some(SlashCommand::Cost)));
  2263. assert_eq!(
  2264. SlashCommand::parse("/resume session.json"),
  2265. Ok(Some(SlashCommand::Resume {
  2266. session_path: Some("session.json".to_string()),
  2267. }))
  2268. );
  2269. assert_eq!(
  2270. SlashCommand::parse("/config"),
  2271. Ok(Some(SlashCommand::Config { section: None }))
  2272. );
  2273. assert_eq!(
  2274. SlashCommand::parse("/config env"),
  2275. Ok(Some(SlashCommand::Config {
  2276. section: Some("env".to_string())
  2277. }))
  2278. );
  2279. assert_eq!(
  2280. SlashCommand::parse("/mcp"),
  2281. Ok(Some(SlashCommand::Mcp {
  2282. action: None,
  2283. target: None
  2284. }))
  2285. );
  2286. assert_eq!(
  2287. SlashCommand::parse("/mcp show remote"),
  2288. Ok(Some(SlashCommand::Mcp {
  2289. action: Some("show".to_string()),
  2290. target: Some("remote".to_string())
  2291. }))
  2292. );
  2293. assert_eq!(
  2294. SlashCommand::parse("/memory"),
  2295. Ok(Some(SlashCommand::Memory))
  2296. );
  2297. assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init)));
  2298. assert_eq!(SlashCommand::parse("/diff"), Ok(Some(SlashCommand::Diff)));
  2299. assert_eq!(
  2300. SlashCommand::parse("/version"),
  2301. Ok(Some(SlashCommand::Version))
  2302. );
  2303. assert_eq!(
  2304. SlashCommand::parse("/export notes.txt"),
  2305. Ok(Some(SlashCommand::Export {
  2306. path: Some("notes.txt".to_string())
  2307. }))
  2308. );
  2309. assert_eq!(
  2310. SlashCommand::parse("/session switch abc123"),
  2311. Ok(Some(SlashCommand::Session {
  2312. action: Some("switch".to_string()),
  2313. target: Some("abc123".to_string())
  2314. }))
  2315. );
  2316. assert_eq!(
  2317. SlashCommand::parse("/plugins install demo"),
  2318. Ok(Some(SlashCommand::Plugins {
  2319. action: Some("install".to_string()),
  2320. target: Some("demo".to_string())
  2321. }))
  2322. );
  2323. assert_eq!(
  2324. SlashCommand::parse("/plugins list"),
  2325. Ok(Some(SlashCommand::Plugins {
  2326. action: Some("list".to_string()),
  2327. target: None
  2328. }))
  2329. );
  2330. assert_eq!(
  2331. SlashCommand::parse("/plugins enable demo"),
  2332. Ok(Some(SlashCommand::Plugins {
  2333. action: Some("enable".to_string()),
  2334. target: Some("demo".to_string())
  2335. }))
  2336. );
  2337. assert_eq!(
  2338. SlashCommand::parse("/skills install ./fixtures/help-skill"),
  2339. Ok(Some(SlashCommand::Skills {
  2340. args: Some("install ./fixtures/help-skill".to_string())
  2341. }))
  2342. );
  2343. assert_eq!(
  2344. SlashCommand::parse("/plugins disable demo"),
  2345. Ok(Some(SlashCommand::Plugins {
  2346. action: Some("disable".to_string()),
  2347. target: Some("demo".to_string())
  2348. }))
  2349. );
  2350. assert_eq!(
  2351. SlashCommand::parse("/session fork incident-review"),
  2352. Ok(Some(SlashCommand::Session {
  2353. action: Some("fork".to_string()),
  2354. target: Some("incident-review".to_string())
  2355. }))
  2356. );
  2357. }
  2358. #[test]
  2359. fn rejects_unexpected_arguments_for_no_arg_commands() {
  2360. // given
  2361. let input = "/compact now";
  2362. // when
  2363. let error = parse_error_message(input);
  2364. // then
  2365. assert!(error.contains("Unexpected arguments for /compact."));
  2366. assert!(error.contains(" Usage /compact"));
  2367. assert!(error.contains(" Summary Compact local session history"));
  2368. }
  2369. #[test]
  2370. fn rejects_invalid_argument_values() {
  2371. // given
  2372. let input = "/permissions admin";
  2373. // when
  2374. let error = parse_error_message(input);
  2375. // then
  2376. assert!(error.contains(
  2377. "Unsupported /permissions mode 'admin'. Use read-only, workspace-write, or danger-full-access."
  2378. ));
  2379. assert!(error.contains(
  2380. " Usage /permissions [read-only|workspace-write|danger-full-access]"
  2381. ));
  2382. }
  2383. #[test]
  2384. fn rejects_missing_required_arguments() {
  2385. // given
  2386. let input = "/teleport";
  2387. // when
  2388. let error = parse_error_message(input);
  2389. // then
  2390. assert!(error.contains("Usage: /teleport <symbol-or-path>"));
  2391. assert!(error.contains(" Category Discovery & debugging"));
  2392. }
  2393. #[test]
  2394. fn rejects_invalid_session_and_plugin_shapes() {
  2395. // given
  2396. let session_input = "/session switch";
  2397. let plugin_input = "/plugins list extra";
  2398. // when
  2399. let session_error = parse_error_message(session_input);
  2400. let plugin_error = parse_error_message(plugin_input);
  2401. // then
  2402. assert!(session_error.contains("Usage: /session switch <session-id>"));
  2403. assert!(session_error.contains("/session"));
  2404. assert!(plugin_error.contains("Usage: /plugin list"));
  2405. assert!(plugin_error.contains("Aliases /plugins, /marketplace"));
  2406. }
  2407. #[test]
  2408. fn rejects_invalid_agents_and_skills_arguments() {
  2409. // given
  2410. let agents_input = "/agents show planner";
  2411. let skills_input = "/skills show help";
  2412. // when
  2413. let agents_error = parse_error_message(agents_input);
  2414. let skills_error = parse_error_message(skills_input);
  2415. // then
  2416. assert!(agents_error.contains(
  2417. "Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
  2418. ));
  2419. assert!(agents_error.contains(" Usage /agents [list|help]"));
  2420. assert!(skills_error.contains(
  2421. "Unexpected arguments for /skills: show help. Use /skills, /skills list, /skills install <path>, or /skills help."
  2422. ));
  2423. assert!(skills_error.contains(" Usage /skills [list|install <path>|help]"));
  2424. }
  2425. #[test]
  2426. fn rejects_invalid_mcp_arguments() {
  2427. let show_error = parse_error_message("/mcp show alpha beta");
  2428. assert!(show_error.contains("Unexpected arguments for /mcp show."));
  2429. assert!(show_error.contains(" Usage /mcp show <server>"));
  2430. let action_error = parse_error_message("/mcp inspect alpha");
  2431. assert!(action_error
  2432. .contains("Unknown /mcp action 'inspect'. Use list, show <server>, or help."));
  2433. assert!(action_error.contains(" Usage /mcp [list|show <server>|help]"));
  2434. }
  2435. #[test]
  2436. fn renders_help_from_shared_specs() {
  2437. let help = render_slash_command_help();
  2438. assert!(help.contains("Start here /status, /diff, /agents, /skills, /commit"));
  2439. assert!(help.contains("[resume] also works with --resume SESSION.jsonl"));
  2440. assert!(help.contains("Session & visibility"));
  2441. assert!(help.contains("Workspace & git"));
  2442. assert!(help.contains("Discovery & debugging"));
  2443. assert!(help.contains("Analysis & automation"));
  2444. assert!(help.contains("/help"));
  2445. assert!(help.contains("/status"));
  2446. assert!(help.contains("/sandbox"));
  2447. assert!(help.contains("/compact"));
  2448. assert!(help.contains("/bughunter [scope]"));
  2449. assert!(help.contains("/commit"));
  2450. assert!(help.contains("/pr [context]"));
  2451. assert!(help.contains("/issue [context]"));
  2452. assert!(help.contains("/ultraplan [task]"));
  2453. assert!(help.contains("/teleport <symbol-or-path>"));
  2454. assert!(help.contains("/debug-tool-call"));
  2455. assert!(help.contains("/model [model]"));
  2456. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  2457. assert!(help.contains("/clear [--confirm]"));
  2458. assert!(help.contains("/cost"));
  2459. assert!(help.contains("/resume <session-path>"));
  2460. assert!(help.contains("/config [env|hooks|model|plugins]"));
  2461. assert!(help.contains("/mcp [list|show <server>|help]"));
  2462. assert!(help.contains("/memory"));
  2463. assert!(help.contains("/init"));
  2464. assert!(help.contains("/diff"));
  2465. assert!(help.contains("/version"));
  2466. assert!(help.contains("/export [file]"));
  2467. assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]]"));
  2468. assert!(help.contains("/sandbox"));
  2469. assert!(help.contains(
  2470. "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
  2471. ));
  2472. assert!(help.contains("aliases: /plugins, /marketplace"));
  2473. assert!(help.contains("/agents [list|help]"));
  2474. assert!(help.contains("/skills [list|install <path>|help]"));
  2475. assert_eq!(slash_command_specs().len(), 27);
  2476. assert_eq!(resume_supported_slash_commands().len(), 15);
  2477. }
  2478. #[test]
  2479. fn renders_per_command_help_detail() {
  2480. // given
  2481. let command = "plugins";
  2482. // when
  2483. let help = render_slash_command_help_detail(command).expect("detail help should exist");
  2484. // then
  2485. assert!(help.contains("/plugin"));
  2486. assert!(help.contains("Summary Manage Claw Code plugins"));
  2487. assert!(help.contains("Aliases /plugins, /marketplace"));
  2488. assert!(help.contains("Category Workspace & git"));
  2489. }
  2490. #[test]
  2491. fn renders_per_command_help_detail_for_mcp() {
  2492. let help = render_slash_command_help_detail("mcp").expect("detail help should exist");
  2493. assert!(help.contains("/mcp"));
  2494. assert!(help.contains("Summary Inspect configured MCP servers"));
  2495. assert!(help.contains("Category Discovery & debugging"));
  2496. assert!(help.contains("Resume Supported with --resume SESSION.jsonl"));
  2497. }
  2498. #[test]
  2499. fn validate_slash_command_input_rejects_extra_single_value_arguments() {
  2500. // given
  2501. let session_input = "/session switch current next";
  2502. let plugin_input = "/plugin enable demo extra";
  2503. // when
  2504. let session_error = validate_slash_command_input(session_input)
  2505. .expect_err("session input should be rejected")
  2506. .to_string();
  2507. let plugin_error = validate_slash_command_input(plugin_input)
  2508. .expect_err("plugin input should be rejected")
  2509. .to_string();
  2510. // then
  2511. assert!(session_error.contains("Unexpected arguments for /session switch."));
  2512. assert!(session_error.contains(" Usage /session switch <session-id>"));
  2513. assert!(plugin_error.contains("Unexpected arguments for /plugin enable."));
  2514. assert!(plugin_error.contains(" Usage /plugin enable <name>"));
  2515. }
  2516. #[test]
  2517. fn suggests_closest_slash_commands_for_typos_and_aliases() {
  2518. assert_eq!(suggest_slash_commands("stats", 3), vec!["/status"]);
  2519. assert_eq!(suggest_slash_commands("/plugns", 3), vec!["/plugin"]);
  2520. assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
  2521. }
  2522. #[test]
  2523. fn compacts_sessions_via_slash_command() {
  2524. let mut session = Session::new();
  2525. session.messages = vec![
  2526. ConversationMessage::user_text("a ".repeat(200)),
  2527. ConversationMessage::assistant(vec![ContentBlock::Text {
  2528. text: "b ".repeat(200),
  2529. }]),
  2530. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  2531. ConversationMessage::assistant(vec![ContentBlock::Text {
  2532. text: "recent".to_string(),
  2533. }]),
  2534. ];
  2535. let result = handle_slash_command(
  2536. "/compact",
  2537. &session,
  2538. CompactionConfig {
  2539. preserve_recent_messages: 2,
  2540. max_estimated_tokens: 1,
  2541. },
  2542. )
  2543. .expect("slash command should be handled");
  2544. assert!(result.message.contains("Compacted 2 messages"));
  2545. assert_eq!(result.session.messages[0].role, MessageRole::System);
  2546. }
  2547. #[test]
  2548. fn help_command_is_non_mutating() {
  2549. let session = Session::new();
  2550. let result = handle_slash_command("/help", &session, CompactionConfig::default())
  2551. .expect("help command should be handled");
  2552. assert_eq!(result.session, session);
  2553. assert!(result.message.contains("Slash commands"));
  2554. }
  2555. #[test]
  2556. fn ignores_unknown_or_runtime_bound_slash_commands() {
  2557. let session = Session::new();
  2558. assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
  2559. assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
  2560. assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
  2561. assert!(
  2562. handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
  2563. );
  2564. assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
  2565. assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
  2566. assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
  2567. assert!(
  2568. handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
  2569. );
  2570. assert!(
  2571. handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
  2572. );
  2573. assert!(
  2574. handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
  2575. .is_none()
  2576. );
  2577. assert!(
  2578. handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
  2579. );
  2580. assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
  2581. assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
  2582. assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
  2583. assert!(
  2584. handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
  2585. );
  2586. assert!(
  2587. handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
  2588. );
  2589. assert!(
  2590. handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
  2591. .is_none()
  2592. );
  2593. assert!(
  2594. handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
  2595. );
  2596. assert!(handle_slash_command(
  2597. "/permissions read-only",
  2598. &session,
  2599. CompactionConfig::default()
  2600. )
  2601. .is_none());
  2602. assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
  2603. assert!(
  2604. handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
  2605. .is_none()
  2606. );
  2607. assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
  2608. assert!(handle_slash_command(
  2609. "/resume session.json",
  2610. &session,
  2611. CompactionConfig::default()
  2612. )
  2613. .is_none());
  2614. assert!(handle_slash_command(
  2615. "/resume session.jsonl",
  2616. &session,
  2617. CompactionConfig::default()
  2618. )
  2619. .is_none());
  2620. assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
  2621. assert!(
  2622. handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
  2623. );
  2624. assert!(handle_slash_command("/mcp list", &session, CompactionConfig::default()).is_none());
  2625. assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
  2626. assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
  2627. assert!(
  2628. handle_slash_command("/export note.txt", &session, CompactionConfig::default())
  2629. .is_none()
  2630. );
  2631. assert!(
  2632. handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
  2633. );
  2634. assert!(
  2635. handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
  2636. );
  2637. }
  2638. #[test]
  2639. fn renders_plugins_report_with_name_version_and_status() {
  2640. let rendered = render_plugins_report(&[
  2641. PluginSummary {
  2642. metadata: PluginMetadata {
  2643. id: "demo@external".to_string(),
  2644. name: "demo".to_string(),
  2645. version: "1.2.3".to_string(),
  2646. description: "demo plugin".to_string(),
  2647. kind: PluginKind::External,
  2648. source: "demo".to_string(),
  2649. default_enabled: false,
  2650. root: None,
  2651. },
  2652. enabled: true,
  2653. },
  2654. PluginSummary {
  2655. metadata: PluginMetadata {
  2656. id: "sample@external".to_string(),
  2657. name: "sample".to_string(),
  2658. version: "0.9.0".to_string(),
  2659. description: "sample plugin".to_string(),
  2660. kind: PluginKind::External,
  2661. source: "sample".to_string(),
  2662. default_enabled: false,
  2663. root: None,
  2664. },
  2665. enabled: false,
  2666. },
  2667. ]);
  2668. assert!(rendered.contains("demo"));
  2669. assert!(rendered.contains("v1.2.3"));
  2670. assert!(rendered.contains("enabled"));
  2671. assert!(rendered.contains("sample"));
  2672. assert!(rendered.contains("v0.9.0"));
  2673. assert!(rendered.contains("disabled"));
  2674. }
  2675. #[test]
  2676. fn lists_agents_from_project_and_user_roots() {
  2677. let workspace = temp_dir("agents-workspace");
  2678. let project_agents = workspace.join(".codex").join("agents");
  2679. let user_home = temp_dir("agents-home");
  2680. let user_agents = user_home.join(".codex").join("agents");
  2681. write_agent(
  2682. &project_agents,
  2683. "planner",
  2684. "Project planner",
  2685. "gpt-5.4",
  2686. "medium",
  2687. );
  2688. write_agent(
  2689. &user_agents,
  2690. "planner",
  2691. "User planner",
  2692. "gpt-5.4-mini",
  2693. "high",
  2694. );
  2695. write_agent(
  2696. &user_agents,
  2697. "verifier",
  2698. "Verification agent",
  2699. "gpt-5.4-mini",
  2700. "high",
  2701. );
  2702. let roots = vec![
  2703. (DefinitionSource::ProjectCodex, project_agents),
  2704. (DefinitionSource::UserCodex, user_agents),
  2705. ];
  2706. let report =
  2707. render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load"));
  2708. assert!(report.contains("Agents"));
  2709. assert!(report.contains("2 active agents"));
  2710. assert!(report.contains("Project (.codex):"));
  2711. assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
  2712. assert!(report.contains("User (~/.codex):"));
  2713. assert!(report.contains("(shadowed by Project (.codex)) planner · User planner"));
  2714. assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
  2715. let _ = fs::remove_dir_all(workspace);
  2716. let _ = fs::remove_dir_all(user_home);
  2717. }
  2718. #[test]
  2719. fn lists_skills_from_project_and_user_roots() {
  2720. let workspace = temp_dir("skills-workspace");
  2721. let project_skills = workspace.join(".codex").join("skills");
  2722. let project_commands = workspace.join(".claude").join("commands");
  2723. let user_home = temp_dir("skills-home");
  2724. let user_skills = user_home.join(".codex").join("skills");
  2725. write_skill(&project_skills, "plan", "Project planning guidance");
  2726. write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance");
  2727. write_skill(&user_skills, "plan", "User planning guidance");
  2728. write_skill(&user_skills, "help", "Help guidance");
  2729. let roots = vec![
  2730. SkillRoot {
  2731. source: DefinitionSource::ProjectCodex,
  2732. path: project_skills,
  2733. origin: SkillOrigin::SkillsDir,
  2734. },
  2735. SkillRoot {
  2736. source: DefinitionSource::ProjectClaude,
  2737. path: project_commands,
  2738. origin: SkillOrigin::LegacyCommandsDir,
  2739. },
  2740. SkillRoot {
  2741. source: DefinitionSource::UserCodex,
  2742. path: user_skills,
  2743. origin: SkillOrigin::SkillsDir,
  2744. },
  2745. ];
  2746. let report =
  2747. render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
  2748. assert!(report.contains("Skills"));
  2749. assert!(report.contains("3 available skills"));
  2750. assert!(report.contains("Project (.codex):"));
  2751. assert!(report.contains("plan · Project planning guidance"));
  2752. assert!(report.contains("Project (.claude):"));
  2753. assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
  2754. assert!(report.contains("User (~/.codex):"));
  2755. assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
  2756. assert!(report.contains("help · Help guidance"));
  2757. let _ = fs::remove_dir_all(workspace);
  2758. let _ = fs::remove_dir_all(user_home);
  2759. }
  2760. #[test]
  2761. fn agents_and_skills_usage_support_help_and_unexpected_args() {
  2762. let cwd = temp_dir("slash-usage");
  2763. let agents_help =
  2764. super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
  2765. assert!(agents_help.contains("Usage /agents [list|help]"));
  2766. assert!(agents_help.contains("Direct CLI claw agents"));
  2767. let agents_unexpected =
  2768. super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
  2769. assert!(agents_unexpected.contains("Unexpected show planner"));
  2770. let skills_help =
  2771. super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
  2772. assert!(skills_help.contains("Usage /skills [list|install <path>|help]"));
  2773. assert!(skills_help.contains("Install root $CODEX_HOME/skills or ~/.codex/skills"));
  2774. assert!(skills_help.contains("legacy /commands"));
  2775. let skills_unexpected =
  2776. super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
  2777. assert!(skills_unexpected.contains("Unexpected show help"));
  2778. let _ = fs::remove_dir_all(cwd);
  2779. }
  2780. #[test]
  2781. fn mcp_usage_supports_help_and_unexpected_args() {
  2782. let cwd = temp_dir("mcp-usage");
  2783. let help = super::handle_mcp_slash_command(Some("help"), &cwd).expect("mcp help");
  2784. assert!(help.contains("Usage /mcp [list|show <server>|help]"));
  2785. assert!(help.contains("Direct CLI claw mcp [list|show <server>|help]"));
  2786. let unexpected =
  2787. super::handle_mcp_slash_command(Some("show alpha beta"), &cwd).expect("mcp usage");
  2788. assert!(unexpected.contains("Unexpected show alpha beta"));
  2789. let _ = fs::remove_dir_all(cwd);
  2790. }
  2791. #[test]
  2792. fn renders_mcp_reports_from_loaded_config() {
  2793. let workspace = temp_dir("mcp-config-workspace");
  2794. let config_home = temp_dir("mcp-config-home");
  2795. fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir");
  2796. fs::create_dir_all(&config_home).expect("config home");
  2797. fs::write(
  2798. workspace.join(".claw").join("settings.json"),
  2799. r#"{
  2800. "mcpServers": {
  2801. "alpha": {
  2802. "command": "uvx",
  2803. "args": ["alpha-server"],
  2804. "env": {"ALPHA_TOKEN": "secret"},
  2805. "toolCallTimeoutMs": 1200
  2806. },
  2807. "remote": {
  2808. "type": "http",
  2809. "url": "https://remote.example/mcp",
  2810. "headers": {"Authorization": "Bearer secret"},
  2811. "headersHelper": "./bin/headers",
  2812. "oauth": {
  2813. "clientId": "remote-client",
  2814. "callbackPort": 7878
  2815. }
  2816. }
  2817. }
  2818. }"#,
  2819. )
  2820. .expect("write settings");
  2821. fs::write(
  2822. workspace.join(".claw").join("settings.local.json"),
  2823. r#"{
  2824. "mcpServers": {
  2825. "remote": {
  2826. "type": "ws",
  2827. "url": "wss://remote.example/mcp"
  2828. }
  2829. }
  2830. }"#,
  2831. )
  2832. .expect("write local settings");
  2833. let loader = ConfigLoader::new(&workspace, &config_home);
  2834. let list = super::render_mcp_report_for(&loader, &workspace, None)
  2835. .expect("mcp list report should render");
  2836. assert!(list.contains("Configured servers 2"));
  2837. assert!(list.contains("alpha"));
  2838. assert!(list.contains("stdio"));
  2839. assert!(list.contains("project"));
  2840. assert!(list.contains("uvx alpha-server"));
  2841. assert!(list.contains("remote"));
  2842. assert!(list.contains("ws"));
  2843. assert!(list.contains("local"));
  2844. assert!(list.contains("wss://remote.example/mcp"));
  2845. let show = super::render_mcp_report_for(&loader, &workspace, Some("show alpha"))
  2846. .expect("mcp show report should render");
  2847. assert!(show.contains("Name alpha"));
  2848. assert!(show.contains("Command uvx"));
  2849. assert!(show.contains("Args alpha-server"));
  2850. assert!(show.contains("Env keys ALPHA_TOKEN"));
  2851. assert!(show.contains("Tool timeout 1200 ms"));
  2852. let remote = super::render_mcp_report_for(&loader, &workspace, Some("show remote"))
  2853. .expect("mcp show remote report should render");
  2854. assert!(remote.contains("Transport ws"));
  2855. assert!(remote.contains("URL wss://remote.example/mcp"));
  2856. let missing = super::render_mcp_report_for(&loader, &workspace, Some("show missing"))
  2857. .expect("missing report should render");
  2858. assert!(missing.contains("server `missing` is not configured"));
  2859. let _ = fs::remove_dir_all(workspace);
  2860. let _ = fs::remove_dir_all(config_home);
  2861. }
  2862. #[test]
  2863. fn parses_quoted_skill_frontmatter_values() {
  2864. let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
  2865. let (name, description) = super::parse_skill_frontmatter(contents);
  2866. assert_eq!(name.as_deref(), Some("hud"));
  2867. assert_eq!(description.as_deref(), Some("Quoted description"));
  2868. }
  2869. #[test]
  2870. fn installs_skill_into_user_registry_and_preserves_nested_files() {
  2871. let workspace = temp_dir("skills-install-workspace");
  2872. let source_root = workspace.join("source").join("help");
  2873. let install_root = temp_dir("skills-install-root");
  2874. write_skill(
  2875. source_root.parent().expect("parent"),
  2876. "help",
  2877. "Helpful skill",
  2878. );
  2879. let script_dir = source_root.join("scripts");
  2880. fs::create_dir_all(&script_dir).expect("script dir");
  2881. fs::write(script_dir.join("run.sh"), "#!/bin/sh\necho help\n").expect("write script");
  2882. let installed = super::install_skill_into(
  2883. source_root.to_str().expect("utf8 skill path"),
  2884. &workspace,
  2885. &install_root,
  2886. )
  2887. .expect("skill should install");
  2888. assert_eq!(installed.invocation_name, "help");
  2889. assert_eq!(installed.display_name.as_deref(), Some("help"));
  2890. assert!(installed.installed_path.ends_with(Path::new("help")));
  2891. assert!(installed.installed_path.join("SKILL.md").is_file());
  2892. assert!(installed
  2893. .installed_path
  2894. .join("scripts")
  2895. .join("run.sh")
  2896. .is_file());
  2897. let report = super::render_skill_install_report(&installed);
  2898. assert!(report.contains("Result installed help"));
  2899. assert!(report.contains("Invoke as $help"));
  2900. assert!(report.contains(&install_root.display().to_string()));
  2901. let roots = vec![SkillRoot {
  2902. source: DefinitionSource::UserCodexHome,
  2903. path: install_root.clone(),
  2904. origin: SkillOrigin::SkillsDir,
  2905. }];
  2906. let listed = render_skills_report(
  2907. &load_skills_from_roots(&roots).expect("installed skills should load"),
  2908. );
  2909. assert!(listed.contains("User ($CODEX_HOME):"));
  2910. assert!(listed.contains("help · Helpful skill"));
  2911. let _ = fs::remove_dir_all(workspace);
  2912. let _ = fs::remove_dir_all(install_root);
  2913. }
  2914. #[test]
  2915. fn installs_plugin_from_path_and_lists_it() {
  2916. let config_home = temp_dir("home");
  2917. let source_root = temp_dir("source");
  2918. write_external_plugin(&source_root, "demo", "1.0.0");
  2919. let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
  2920. let install = handle_plugins_slash_command(
  2921. Some("install"),
  2922. Some(source_root.to_str().expect("utf8 path")),
  2923. &mut manager,
  2924. )
  2925. .expect("install command should succeed");
  2926. assert!(install.reload_runtime);
  2927. assert!(install.message.contains("installed demo@external"));
  2928. assert!(install.message.contains("Name demo"));
  2929. assert!(install.message.contains("Version 1.0.0"));
  2930. assert!(install.message.contains("Status enabled"));
  2931. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  2932. .expect("list command should succeed");
  2933. assert!(!list.reload_runtime);
  2934. assert!(list.message.contains("demo"));
  2935. assert!(list.message.contains("v1.0.0"));
  2936. assert!(list.message.contains("enabled"));
  2937. let _ = fs::remove_dir_all(config_home);
  2938. let _ = fs::remove_dir_all(source_root);
  2939. }
  2940. #[test]
  2941. fn enables_and_disables_plugin_by_name() {
  2942. let config_home = temp_dir("toggle-home");
  2943. let source_root = temp_dir("toggle-source");
  2944. write_external_plugin(&source_root, "demo", "1.0.0");
  2945. let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
  2946. handle_plugins_slash_command(
  2947. Some("install"),
  2948. Some(source_root.to_str().expect("utf8 path")),
  2949. &mut manager,
  2950. )
  2951. .expect("install command should succeed");
  2952. let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
  2953. .expect("disable command should succeed");
  2954. assert!(disable.reload_runtime);
  2955. assert!(disable.message.contains("disabled demo@external"));
  2956. assert!(disable.message.contains("Name demo"));
  2957. assert!(disable.message.contains("Status disabled"));
  2958. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  2959. .expect("list command should succeed");
  2960. assert!(list.message.contains("demo"));
  2961. assert!(list.message.contains("disabled"));
  2962. let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
  2963. .expect("enable command should succeed");
  2964. assert!(enable.reload_runtime);
  2965. assert!(enable.message.contains("enabled demo@external"));
  2966. assert!(enable.message.contains("Name demo"));
  2967. assert!(enable.message.contains("Status enabled"));
  2968. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  2969. .expect("list command should succeed");
  2970. assert!(list.message.contains("demo"));
  2971. assert!(list.message.contains("enabled"));
  2972. let _ = fs::remove_dir_all(config_home);
  2973. let _ = fs::remove_dir_all(source_root);
  2974. }
  2975. #[test]
  2976. fn lists_auto_installed_bundled_plugins_with_status() {
  2977. let config_home = temp_dir("bundled-home");
  2978. let bundled_root = temp_dir("bundled-root");
  2979. let bundled_plugin = bundled_root.join("starter");
  2980. write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
  2981. let mut config = PluginManagerConfig::new(&config_home);
  2982. config.bundled_root = Some(bundled_root.clone());
  2983. let mut manager = PluginManager::new(config);
  2984. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  2985. .expect("list command should succeed");
  2986. assert!(!list.reload_runtime);
  2987. assert!(list.message.contains("starter"));
  2988. assert!(list.message.contains("v0.1.0"));
  2989. assert!(list.message.contains("disabled"));
  2990. let _ = fs::remove_dir_all(config_home);
  2991. let _ = fs::remove_dir_all(bundled_root);
  2992. }
  2993. }