lib.rs 86 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553
  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::{compact_session, CompactionConfig, Session};
  8. #[derive(Debug, Clone, PartialEq, Eq)]
  9. pub struct CommandManifestEntry {
  10. pub name: String,
  11. pub source: CommandSource,
  12. }
  13. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  14. pub enum CommandSource {
  15. Builtin,
  16. InternalOnly,
  17. FeatureGated,
  18. }
  19. #[derive(Debug, Clone, Default, PartialEq, Eq)]
  20. pub struct CommandRegistry {
  21. entries: Vec<CommandManifestEntry>,
  22. }
  23. impl CommandRegistry {
  24. #[must_use]
  25. pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
  26. Self { entries }
  27. }
  28. #[must_use]
  29. pub fn entries(&self) -> &[CommandManifestEntry] {
  30. &self.entries
  31. }
  32. }
  33. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  34. pub struct SlashCommandSpec {
  35. pub name: &'static str,
  36. pub aliases: &'static [&'static str],
  37. pub summary: &'static str,
  38. pub argument_hint: Option<&'static str>,
  39. pub resume_supported: bool,
  40. }
  41. const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
  42. SlashCommandSpec {
  43. name: "help",
  44. aliases: &[],
  45. summary: "Show available slash commands",
  46. argument_hint: None,
  47. resume_supported: true,
  48. },
  49. SlashCommandSpec {
  50. name: "status",
  51. aliases: &[],
  52. summary: "Show current session status",
  53. argument_hint: None,
  54. resume_supported: true,
  55. },
  56. SlashCommandSpec {
  57. name: "sandbox",
  58. aliases: &[],
  59. summary: "Show sandbox isolation status",
  60. argument_hint: None,
  61. resume_supported: true,
  62. },
  63. SlashCommandSpec {
  64. name: "compact",
  65. aliases: &[],
  66. summary: "Compact local session history",
  67. argument_hint: None,
  68. resume_supported: true,
  69. },
  70. SlashCommandSpec {
  71. name: "model",
  72. aliases: &[],
  73. summary: "Show or switch the active model",
  74. argument_hint: Some("[model]"),
  75. resume_supported: false,
  76. },
  77. SlashCommandSpec {
  78. name: "permissions",
  79. aliases: &[],
  80. summary: "Show or switch the active permission mode",
  81. argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
  82. resume_supported: false,
  83. },
  84. SlashCommandSpec {
  85. name: "clear",
  86. aliases: &[],
  87. summary: "Start a fresh local session",
  88. argument_hint: Some("[--confirm]"),
  89. resume_supported: true,
  90. },
  91. SlashCommandSpec {
  92. name: "cost",
  93. aliases: &[],
  94. summary: "Show cumulative token usage for this session",
  95. argument_hint: None,
  96. resume_supported: true,
  97. },
  98. SlashCommandSpec {
  99. name: "resume",
  100. aliases: &[],
  101. summary: "Load a saved session into the REPL",
  102. argument_hint: Some("<session-path>"),
  103. resume_supported: false,
  104. },
  105. SlashCommandSpec {
  106. name: "config",
  107. aliases: &[],
  108. summary: "Inspect Claude config files or merged sections",
  109. argument_hint: Some("[env|hooks|model|plugins]"),
  110. resume_supported: true,
  111. },
  112. SlashCommandSpec {
  113. name: "memory",
  114. aliases: &[],
  115. summary: "Inspect loaded Claude instruction memory files",
  116. argument_hint: None,
  117. resume_supported: true,
  118. },
  119. SlashCommandSpec {
  120. name: "init",
  121. aliases: &[],
  122. summary: "Create a starter CLAUDE.md for this repo",
  123. argument_hint: None,
  124. resume_supported: true,
  125. },
  126. SlashCommandSpec {
  127. name: "diff",
  128. aliases: &[],
  129. summary: "Show git diff for current workspace changes",
  130. argument_hint: None,
  131. resume_supported: true,
  132. },
  133. SlashCommandSpec {
  134. name: "version",
  135. aliases: &[],
  136. summary: "Show CLI version and build information",
  137. argument_hint: None,
  138. resume_supported: true,
  139. },
  140. SlashCommandSpec {
  141. name: "bughunter",
  142. aliases: &[],
  143. summary: "Inspect the codebase for likely bugs",
  144. argument_hint: Some("[scope]"),
  145. resume_supported: false,
  146. },
  147. SlashCommandSpec {
  148. name: "commit",
  149. aliases: &[],
  150. summary: "Generate a commit message and create a git commit",
  151. argument_hint: None,
  152. resume_supported: false,
  153. },
  154. SlashCommandSpec {
  155. name: "pr",
  156. aliases: &[],
  157. summary: "Draft or create a pull request from the conversation",
  158. argument_hint: Some("[context]"),
  159. resume_supported: false,
  160. },
  161. SlashCommandSpec {
  162. name: "issue",
  163. aliases: &[],
  164. summary: "Draft or create a GitHub issue from the conversation",
  165. argument_hint: Some("[context]"),
  166. resume_supported: false,
  167. },
  168. SlashCommandSpec {
  169. name: "ultraplan",
  170. aliases: &[],
  171. summary: "Run a deep planning prompt with multi-step reasoning",
  172. argument_hint: Some("[task]"),
  173. resume_supported: false,
  174. },
  175. SlashCommandSpec {
  176. name: "teleport",
  177. aliases: &[],
  178. summary: "Jump to a file or symbol by searching the workspace",
  179. argument_hint: Some("<symbol-or-path>"),
  180. resume_supported: false,
  181. },
  182. SlashCommandSpec {
  183. name: "debug-tool-call",
  184. aliases: &[],
  185. summary: "Replay the last tool call with debug details",
  186. argument_hint: None,
  187. resume_supported: false,
  188. },
  189. SlashCommandSpec {
  190. name: "export",
  191. aliases: &[],
  192. summary: "Export the current conversation to a file",
  193. argument_hint: Some("[file]"),
  194. resume_supported: true,
  195. },
  196. SlashCommandSpec {
  197. name: "session",
  198. aliases: &[],
  199. summary: "List, switch, or fork managed local sessions",
  200. argument_hint: Some("[list|switch <session-id>|fork [branch-name]]"),
  201. resume_supported: false,
  202. },
  203. SlashCommandSpec {
  204. name: "plugin",
  205. aliases: &["plugins", "marketplace"],
  206. summary: "Manage Claw Code plugins",
  207. argument_hint: Some(
  208. "[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
  209. ),
  210. resume_supported: false,
  211. },
  212. SlashCommandSpec {
  213. name: "agents",
  214. aliases: &[],
  215. summary: "List configured agents",
  216. argument_hint: Some("[list|help]"),
  217. resume_supported: true,
  218. },
  219. SlashCommandSpec {
  220. name: "skills",
  221. aliases: &[],
  222. summary: "List available skills",
  223. argument_hint: Some("[list|help]"),
  224. resume_supported: true,
  225. },
  226. ];
  227. #[derive(Debug, Clone, PartialEq, Eq)]
  228. pub enum SlashCommand {
  229. Help,
  230. Status,
  231. Sandbox,
  232. Compact,
  233. Bughunter {
  234. scope: Option<String>,
  235. },
  236. Commit,
  237. Pr {
  238. context: Option<String>,
  239. },
  240. Issue {
  241. context: Option<String>,
  242. },
  243. Ultraplan {
  244. task: Option<String>,
  245. },
  246. Teleport {
  247. target: Option<String>,
  248. },
  249. DebugToolCall,
  250. Model {
  251. model: Option<String>,
  252. },
  253. Permissions {
  254. mode: Option<String>,
  255. },
  256. Clear {
  257. confirm: bool,
  258. },
  259. Cost,
  260. Resume {
  261. session_path: Option<String>,
  262. },
  263. Config {
  264. section: Option<String>,
  265. },
  266. Memory,
  267. Init,
  268. Diff,
  269. Version,
  270. Export {
  271. path: Option<String>,
  272. },
  273. Session {
  274. action: Option<String>,
  275. target: Option<String>,
  276. },
  277. Plugins {
  278. action: Option<String>,
  279. target: Option<String>,
  280. },
  281. Agents {
  282. args: Option<String>,
  283. },
  284. Skills {
  285. args: Option<String>,
  286. },
  287. Unknown(String),
  288. }
  289. #[derive(Debug, Clone, PartialEq, Eq)]
  290. pub struct SlashCommandParseError {
  291. message: String,
  292. }
  293. impl SlashCommandParseError {
  294. fn new(message: impl Into<String>) -> Self {
  295. Self {
  296. message: message.into(),
  297. }
  298. }
  299. }
  300. impl fmt::Display for SlashCommandParseError {
  301. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
  302. f.write_str(&self.message)
  303. }
  304. }
  305. impl std::error::Error for SlashCommandParseError {}
  306. impl SlashCommand {
  307. #[must_use]
  308. pub fn parse(input: &str) -> Option<Self> {
  309. let trimmed = input.trim();
  310. if !trimmed.starts_with('/') {
  311. return None;
  312. }
  313. let mut parts = trimmed.trim_start_matches('/').split_whitespace();
  314. let command = parts.next().unwrap_or_default();
  315. Some(match command {
  316. "help" => Self::Help,
  317. "status" => Self::Status,
  318. "sandbox" => Self::Sandbox,
  319. "compact" => Self::Compact,
  320. "bughunter" => Self::Bughunter {
  321. scope: remainder_after_command(trimmed, command),
  322. },
  323. "commit" => Self::Commit,
  324. "pr" => Self::Pr {
  325. context: remainder_after_command(trimmed, command),
  326. },
  327. "issue" => Self::Issue {
  328. context: remainder_after_command(trimmed, command),
  329. },
  330. "ultraplan" => Self::Ultraplan {
  331. task: remainder_after_command(trimmed, command),
  332. },
  333. "teleport" => Self::Teleport {
  334. target: remainder_after_command(trimmed, command),
  335. },
  336. "debug-tool-call" => Self::DebugToolCall,
  337. "model" => Self::Model {
  338. model: parts.next().map(ToOwned::to_owned),
  339. },
  340. "permissions" => Self::Permissions {
  341. mode: parts.next().map(ToOwned::to_owned),
  342. },
  343. "clear" => Self::Clear {
  344. confirm: parts.next() == Some("--confirm"),
  345. },
  346. "cost" => Self::Cost,
  347. "resume" => Self::Resume {
  348. session_path: parts.next().map(ToOwned::to_owned),
  349. },
  350. "config" => Self::Config {
  351. section: parts.next().map(ToOwned::to_owned),
  352. },
  353. "memory" => Self::Memory,
  354. "init" => Self::Init,
  355. "diff" => Self::Diff,
  356. "version" => Self::Version,
  357. "export" => Self::Export {
  358. path: parts.next().map(ToOwned::to_owned),
  359. },
  360. "session" => Self::Session {
  361. action: parts.next().map(ToOwned::to_owned),
  362. target: parts.next().map(ToOwned::to_owned),
  363. },
  364. "plugin" | "plugins" | "marketplace" => Self::Plugins {
  365. action: parts.next().map(ToOwned::to_owned),
  366. target: {
  367. let remainder = parts.collect::<Vec<_>>().join(" ");
  368. (!remainder.is_empty()).then_some(remainder)
  369. },
  370. },
  371. "agents" => Self::Agents {
  372. args: remainder_after_command(trimmed, command),
  373. },
  374. "skills" => Self::Skills {
  375. args: remainder_after_command(trimmed, command),
  376. },
  377. other => Self::Unknown(other.to_string()),
  378. })
  379. }
  380. }
  381. pub fn validate_slash_command_input(
  382. input: &str,
  383. ) -> Result<Option<SlashCommand>, SlashCommandParseError> {
  384. let trimmed = input.trim();
  385. if !trimmed.starts_with('/') {
  386. return Ok(None);
  387. }
  388. let mut parts = trimmed.trim_start_matches('/').split_whitespace();
  389. let command = parts.next().unwrap_or_default();
  390. if command.is_empty() {
  391. return Err(SlashCommandParseError::new(
  392. "Slash command name is missing. Use /help to list available slash commands.",
  393. ));
  394. }
  395. let args = parts.collect::<Vec<_>>();
  396. let remainder = remainder_after_command(trimmed, command);
  397. Ok(Some(match command {
  398. "help" => {
  399. validate_no_args(command, &args)?;
  400. SlashCommand::Help
  401. }
  402. "status" => {
  403. validate_no_args(command, &args)?;
  404. SlashCommand::Status
  405. }
  406. "sandbox" => {
  407. validate_no_args(command, &args)?;
  408. SlashCommand::Sandbox
  409. }
  410. "compact" => {
  411. validate_no_args(command, &args)?;
  412. SlashCommand::Compact
  413. }
  414. "bughunter" => SlashCommand::Bughunter { scope: remainder },
  415. "commit" => {
  416. validate_no_args(command, &args)?;
  417. SlashCommand::Commit
  418. }
  419. "pr" => SlashCommand::Pr { context: remainder },
  420. "issue" => SlashCommand::Issue { context: remainder },
  421. "ultraplan" => SlashCommand::Ultraplan { task: remainder },
  422. "teleport" => SlashCommand::Teleport {
  423. target: Some(require_remainder(command, remainder, "<symbol-or-path>")?),
  424. },
  425. "debug-tool-call" => {
  426. validate_no_args(command, &args)?;
  427. SlashCommand::DebugToolCall
  428. }
  429. "model" => SlashCommand::Model {
  430. model: optional_single_arg(command, &args, "[model]")?,
  431. },
  432. "permissions" => SlashCommand::Permissions {
  433. mode: parse_permissions_mode(&args)?,
  434. },
  435. "clear" => SlashCommand::Clear {
  436. confirm: parse_clear_args(&args)?,
  437. },
  438. "cost" => {
  439. validate_no_args(command, &args)?;
  440. SlashCommand::Cost
  441. }
  442. "resume" => SlashCommand::Resume {
  443. session_path: Some(require_remainder(command, remainder, "<session-path>")?),
  444. },
  445. "config" => SlashCommand::Config {
  446. section: parse_config_section(&args)?,
  447. },
  448. "memory" => {
  449. validate_no_args(command, &args)?;
  450. SlashCommand::Memory
  451. }
  452. "init" => {
  453. validate_no_args(command, &args)?;
  454. SlashCommand::Init
  455. }
  456. "diff" => {
  457. validate_no_args(command, &args)?;
  458. SlashCommand::Diff
  459. }
  460. "version" => {
  461. validate_no_args(command, &args)?;
  462. SlashCommand::Version
  463. }
  464. "export" => SlashCommand::Export { path: remainder },
  465. "session" => parse_session_command(&args)?,
  466. "plugin" | "plugins" | "marketplace" => parse_plugin_command(&args)?,
  467. "agents" => SlashCommand::Agents {
  468. args: parse_list_or_help_args(command, remainder)?,
  469. },
  470. "skills" => SlashCommand::Skills {
  471. args: parse_list_or_help_args(command, remainder)?,
  472. },
  473. other => SlashCommand::Unknown(other.to_string()),
  474. }))
  475. }
  476. fn validate_no_args(command: &str, args: &[&str]) -> Result<(), SlashCommandParseError> {
  477. if args.is_empty() {
  478. return Ok(());
  479. }
  480. Err(command_error(
  481. &format!("Unexpected arguments for /{command}."),
  482. command,
  483. &format!("/{command}"),
  484. ))
  485. }
  486. fn optional_single_arg(
  487. command: &str,
  488. args: &[&str],
  489. argument_hint: &str,
  490. ) -> Result<Option<String>, SlashCommandParseError> {
  491. match args {
  492. [] => Ok(None),
  493. [value] => Ok(Some((*value).to_string())),
  494. _ => Err(usage_error(command, argument_hint)),
  495. }
  496. }
  497. fn require_remainder(
  498. command: &str,
  499. remainder: Option<String>,
  500. argument_hint: &str,
  501. ) -> Result<String, SlashCommandParseError> {
  502. remainder.ok_or_else(|| usage_error(command, argument_hint))
  503. }
  504. fn parse_permissions_mode(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
  505. let mode = optional_single_arg(
  506. "permissions",
  507. args,
  508. "[read-only|workspace-write|danger-full-access]",
  509. )?;
  510. if let Some(mode) = mode {
  511. if matches!(
  512. mode.as_str(),
  513. "read-only" | "workspace-write" | "danger-full-access"
  514. ) {
  515. return Ok(Some(mode));
  516. }
  517. return Err(command_error(
  518. &format!(
  519. "Unsupported /permissions mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
  520. ),
  521. "permissions",
  522. "/permissions [read-only|workspace-write|danger-full-access]",
  523. ));
  524. }
  525. Ok(None)
  526. }
  527. fn parse_clear_args(args: &[&str]) -> Result<bool, SlashCommandParseError> {
  528. match args {
  529. [] => Ok(false),
  530. ["--confirm"] => Ok(true),
  531. [unexpected] => Err(command_error(
  532. &format!("Unsupported /clear argument '{unexpected}'. Use /clear or /clear --confirm."),
  533. "clear",
  534. "/clear [--confirm]",
  535. )),
  536. _ => Err(usage_error("clear", "[--confirm]")),
  537. }
  538. }
  539. fn parse_config_section(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
  540. let section = optional_single_arg("config", args, "[env|hooks|model|plugins]")?;
  541. if let Some(section) = section {
  542. if matches!(section.as_str(), "env" | "hooks" | "model" | "plugins") {
  543. return Ok(Some(section));
  544. }
  545. return Err(command_error(
  546. &format!("Unsupported /config section '{section}'. Use env, hooks, model, or plugins."),
  547. "config",
  548. "/config [env|hooks|model|plugins]",
  549. ));
  550. }
  551. Ok(None)
  552. }
  553. fn parse_session_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
  554. match args {
  555. [] => Ok(SlashCommand::Session {
  556. action: None,
  557. target: None,
  558. }),
  559. ["list"] => Ok(SlashCommand::Session {
  560. action: Some("list".to_string()),
  561. target: None,
  562. }),
  563. ["list", ..] => Err(usage_error("session", "[list|switch <session-id>|fork [branch-name]]")),
  564. ["switch"] => Err(usage_error("session switch", "<session-id>")),
  565. ["switch", target] => Ok(SlashCommand::Session {
  566. action: Some("switch".to_string()),
  567. target: Some((*target).to_string()),
  568. }),
  569. ["switch", ..] => Err(command_error(
  570. "Unexpected arguments for /session switch.",
  571. "session",
  572. "/session switch <session-id>",
  573. )),
  574. ["fork"] => Ok(SlashCommand::Session {
  575. action: Some("fork".to_string()),
  576. target: None,
  577. }),
  578. ["fork", target] => Ok(SlashCommand::Session {
  579. action: Some("fork".to_string()),
  580. target: Some((*target).to_string()),
  581. }),
  582. ["fork", ..] => Err(command_error(
  583. "Unexpected arguments for /session fork.",
  584. "session",
  585. "/session fork [branch-name]",
  586. )),
  587. [action, ..] => Err(command_error(
  588. &format!(
  589. "Unknown /session action '{action}'. Use list, switch <session-id>, or fork [branch-name]."
  590. ),
  591. "session",
  592. "/session [list|switch <session-id>|fork [branch-name]]",
  593. )),
  594. }
  595. }
  596. fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
  597. match args {
  598. [] => Ok(SlashCommand::Plugins {
  599. action: None,
  600. target: None,
  601. }),
  602. ["list"] => Ok(SlashCommand::Plugins {
  603. action: Some("list".to_string()),
  604. target: None,
  605. }),
  606. ["list", ..] => Err(usage_error("plugin list", "")),
  607. ["install"] => Err(usage_error("plugin install", "<path>")),
  608. ["install", target @ ..] => Ok(SlashCommand::Plugins {
  609. action: Some("install".to_string()),
  610. target: Some(target.join(" ")),
  611. }),
  612. ["enable"] => Err(usage_error("plugin enable", "<name>")),
  613. ["enable", target] => Ok(SlashCommand::Plugins {
  614. action: Some("enable".to_string()),
  615. target: Some((*target).to_string()),
  616. }),
  617. ["enable", ..] => Err(command_error(
  618. "Unexpected arguments for /plugin enable.",
  619. "plugin",
  620. "/plugin enable <name>",
  621. )),
  622. ["disable"] => Err(usage_error("plugin disable", "<name>")),
  623. ["disable", target] => Ok(SlashCommand::Plugins {
  624. action: Some("disable".to_string()),
  625. target: Some((*target).to_string()),
  626. }),
  627. ["disable", ..] => Err(command_error(
  628. "Unexpected arguments for /plugin disable.",
  629. "plugin",
  630. "/plugin disable <name>",
  631. )),
  632. ["uninstall"] => Err(usage_error("plugin uninstall", "<id>")),
  633. ["uninstall", target] => Ok(SlashCommand::Plugins {
  634. action: Some("uninstall".to_string()),
  635. target: Some((*target).to_string()),
  636. }),
  637. ["uninstall", ..] => Err(command_error(
  638. "Unexpected arguments for /plugin uninstall.",
  639. "plugin",
  640. "/plugin uninstall <id>",
  641. )),
  642. ["update"] => Err(usage_error("plugin update", "<id>")),
  643. ["update", target] => Ok(SlashCommand::Plugins {
  644. action: Some("update".to_string()),
  645. target: Some((*target).to_string()),
  646. }),
  647. ["update", ..] => Err(command_error(
  648. "Unexpected arguments for /plugin update.",
  649. "plugin",
  650. "/plugin update <id>",
  651. )),
  652. [action, ..] => Err(command_error(
  653. &format!(
  654. "Unknown /plugin action '{action}'. Use list, install <path>, enable <name>, disable <name>, uninstall <id>, or update <id>."
  655. ),
  656. "plugin",
  657. "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
  658. )),
  659. }
  660. }
  661. fn parse_list_or_help_args(
  662. command: &str,
  663. args: Option<String>,
  664. ) -> Result<Option<String>, SlashCommandParseError> {
  665. match normalize_optional_args(args.as_deref()) {
  666. None | Some("list" | "help" | "-h" | "--help") => Ok(args),
  667. Some(unexpected) => Err(command_error(
  668. &format!(
  669. "Unexpected arguments for /{command}: {unexpected}. Use /{command}, /{command} list, or /{command} help."
  670. ),
  671. command,
  672. &format!("/{command} [list|help]"),
  673. )),
  674. }
  675. }
  676. fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
  677. let usage = format!("/{command} {argument_hint}");
  678. let usage = usage.trim_end().to_string();
  679. command_error(
  680. &format!("Usage: {usage}"),
  681. command_root_name(command),
  682. &usage,
  683. )
  684. }
  685. fn command_error(message: &str, command: &str, usage: &str) -> SlashCommandParseError {
  686. let detail = render_slash_command_help_detail(command)
  687. .map(|detail| format!("\n\n{detail}"))
  688. .unwrap_or_default();
  689. SlashCommandParseError::new(format!("{message}\n Usage {usage}{detail}"))
  690. }
  691. fn remainder_after_command(input: &str, command: &str) -> Option<String> {
  692. input
  693. .trim()
  694. .strip_prefix(&format!("/{command}"))
  695. .map(str::trim)
  696. .filter(|value| !value.is_empty())
  697. .map(ToOwned::to_owned)
  698. }
  699. fn find_slash_command_spec(name: &str) -> Option<&'static SlashCommandSpec> {
  700. slash_command_specs().iter().find(|spec| {
  701. spec.name.eq_ignore_ascii_case(name)
  702. || spec
  703. .aliases
  704. .iter()
  705. .any(|alias| alias.eq_ignore_ascii_case(name))
  706. })
  707. }
  708. fn command_root_name(command: &str) -> &str {
  709. command.split_whitespace().next().unwrap_or(command)
  710. }
  711. fn slash_command_usage(spec: &SlashCommandSpec) -> String {
  712. match spec.argument_hint {
  713. Some(argument_hint) => format!("/{} {argument_hint}", spec.name),
  714. None => format!("/{}", spec.name),
  715. }
  716. }
  717. fn slash_command_detail_lines(spec: &SlashCommandSpec) -> Vec<String> {
  718. let mut lines = vec![format!("/{}", spec.name)];
  719. lines.push(format!(" Summary {}", spec.summary));
  720. lines.push(format!(" Usage {}", slash_command_usage(spec)));
  721. lines.push(format!(
  722. " Category {}",
  723. slash_command_category(spec.name)
  724. ));
  725. if !spec.aliases.is_empty() {
  726. lines.push(format!(
  727. " Aliases {}",
  728. spec.aliases
  729. .iter()
  730. .map(|alias| format!("/{alias}"))
  731. .collect::<Vec<_>>()
  732. .join(", ")
  733. ));
  734. }
  735. if spec.resume_supported {
  736. lines.push(" Resume Supported with --resume SESSION.jsonl".to_string());
  737. }
  738. lines
  739. }
  740. #[must_use]
  741. pub fn render_slash_command_help_detail(name: &str) -> Option<String> {
  742. find_slash_command_spec(name).map(|spec| slash_command_detail_lines(spec).join("\n"))
  743. }
  744. #[must_use]
  745. pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
  746. SLASH_COMMAND_SPECS
  747. }
  748. #[must_use]
  749. pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
  750. slash_command_specs()
  751. .iter()
  752. .filter(|spec| spec.resume_supported)
  753. .collect()
  754. }
  755. fn slash_command_category(name: &str) -> &'static str {
  756. match name {
  757. "help" | "status" | "sandbox" | "model" | "permissions" | "cost" | "resume" | "session"
  758. | "version" => "Session & visibility",
  759. "compact" | "clear" | "config" | "memory" | "init" | "diff" | "commit" | "pr" | "issue"
  760. | "export" | "plugin" => "Workspace & git",
  761. "agents" | "skills" | "teleport" | "debug-tool-call" => "Discovery & debugging",
  762. "bughunter" | "ultraplan" => "Analysis & automation",
  763. _ => "Other",
  764. }
  765. }
  766. fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String {
  767. let name = slash_command_usage(spec);
  768. let alias_suffix = if spec.aliases.is_empty() {
  769. String::new()
  770. } else {
  771. format!(
  772. " (aliases: {})",
  773. spec.aliases
  774. .iter()
  775. .map(|alias| format!("/{alias}"))
  776. .collect::<Vec<_>>()
  777. .join(", ")
  778. )
  779. };
  780. let resume = if spec.resume_supported {
  781. " [resume]"
  782. } else {
  783. ""
  784. };
  785. format!(" {name:<66} {}{alias_suffix}{resume}", spec.summary)
  786. }
  787. fn levenshtein_distance(left: &str, right: &str) -> usize {
  788. if left == right {
  789. return 0;
  790. }
  791. if left.is_empty() {
  792. return right.chars().count();
  793. }
  794. if right.is_empty() {
  795. return left.chars().count();
  796. }
  797. let right_chars = right.chars().collect::<Vec<_>>();
  798. let mut previous = (0..=right_chars.len()).collect::<Vec<_>>();
  799. let mut current = vec![0; right_chars.len() + 1];
  800. for (left_index, left_char) in left.chars().enumerate() {
  801. current[0] = left_index + 1;
  802. for (right_index, right_char) in right_chars.iter().enumerate() {
  803. let substitution_cost = usize::from(left_char != *right_char);
  804. current[right_index + 1] = (current[right_index] + 1)
  805. .min(previous[right_index + 1] + 1)
  806. .min(previous[right_index] + substitution_cost);
  807. }
  808. previous.clone_from(&current);
  809. }
  810. previous[right_chars.len()]
  811. }
  812. #[must_use]
  813. pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec<String> {
  814. let query = input.trim().trim_start_matches('/').to_ascii_lowercase();
  815. if query.is_empty() || limit == 0 {
  816. return Vec::new();
  817. }
  818. let mut suggestions = slash_command_specs()
  819. .iter()
  820. .filter_map(|spec| {
  821. let best = std::iter::once(spec.name)
  822. .chain(spec.aliases.iter().copied())
  823. .map(str::to_ascii_lowercase)
  824. .map(|candidate| {
  825. let prefix_rank =
  826. if candidate.starts_with(&query) || query.starts_with(&candidate) {
  827. 0
  828. } else if candidate.contains(&query) || query.contains(&candidate) {
  829. 1
  830. } else {
  831. 2
  832. };
  833. let distance = levenshtein_distance(&candidate, &query);
  834. (prefix_rank, distance)
  835. })
  836. .min();
  837. best.and_then(|(prefix_rank, distance)| {
  838. if prefix_rank <= 1 || distance <= 2 {
  839. Some((prefix_rank, distance, spec.name.len(), spec.name))
  840. } else {
  841. None
  842. }
  843. })
  844. })
  845. .collect::<Vec<_>>();
  846. suggestions.sort_unstable();
  847. suggestions
  848. .into_iter()
  849. .map(|(_, _, _, name)| format!("/{name}"))
  850. .take(limit)
  851. .collect()
  852. }
  853. #[must_use]
  854. pub fn render_slash_command_help() -> String {
  855. let mut lines = vec![
  856. "Slash commands".to_string(),
  857. " Start here /status, /diff, /agents, /skills, /commit".to_string(),
  858. " [resume] also works with --resume SESSION.jsonl".to_string(),
  859. String::new(),
  860. ];
  861. let categories = [
  862. "Session & visibility",
  863. "Workspace & git",
  864. "Discovery & debugging",
  865. "Analysis & automation",
  866. ];
  867. for category in categories {
  868. lines.push(category.to_string());
  869. for spec in slash_command_specs()
  870. .iter()
  871. .filter(|spec| slash_command_category(spec.name) == category)
  872. {
  873. lines.push(format_slash_command_help_line(spec));
  874. }
  875. lines.push(String::new());
  876. }
  877. lines
  878. .into_iter()
  879. .rev()
  880. .skip_while(String::is_empty)
  881. .collect::<Vec<_>>()
  882. .into_iter()
  883. .rev()
  884. .collect::<Vec<_>>()
  885. .join("\n")
  886. }
  887. #[derive(Debug, Clone, PartialEq, Eq)]
  888. pub struct SlashCommandResult {
  889. pub message: String,
  890. pub session: Session,
  891. }
  892. #[derive(Debug, Clone, PartialEq, Eq)]
  893. pub struct PluginsCommandResult {
  894. pub message: String,
  895. pub reload_runtime: bool,
  896. }
  897. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
  898. enum DefinitionSource {
  899. ProjectCodex,
  900. ProjectClaude,
  901. UserCodexHome,
  902. UserCodex,
  903. UserClaude,
  904. }
  905. impl DefinitionSource {
  906. fn label(self) -> &'static str {
  907. match self {
  908. Self::ProjectCodex => "Project (.codex)",
  909. Self::ProjectClaude => "Project (.claude)",
  910. Self::UserCodexHome => "User ($CODEX_HOME)",
  911. Self::UserCodex => "User (~/.codex)",
  912. Self::UserClaude => "User (~/.claude)",
  913. }
  914. }
  915. }
  916. #[derive(Debug, Clone, PartialEq, Eq)]
  917. struct AgentSummary {
  918. name: String,
  919. description: Option<String>,
  920. model: Option<String>,
  921. reasoning_effort: Option<String>,
  922. source: DefinitionSource,
  923. shadowed_by: Option<DefinitionSource>,
  924. }
  925. #[derive(Debug, Clone, PartialEq, Eq)]
  926. struct SkillSummary {
  927. name: String,
  928. description: Option<String>,
  929. source: DefinitionSource,
  930. shadowed_by: Option<DefinitionSource>,
  931. origin: SkillOrigin,
  932. }
  933. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  934. enum SkillOrigin {
  935. SkillsDir,
  936. LegacyCommandsDir,
  937. }
  938. impl SkillOrigin {
  939. fn detail_label(self) -> Option<&'static str> {
  940. match self {
  941. Self::SkillsDir => None,
  942. Self::LegacyCommandsDir => Some("legacy /commands"),
  943. }
  944. }
  945. }
  946. #[derive(Debug, Clone, PartialEq, Eq)]
  947. struct SkillRoot {
  948. source: DefinitionSource,
  949. path: PathBuf,
  950. origin: SkillOrigin,
  951. }
  952. #[allow(clippy::too_many_lines)]
  953. pub fn handle_plugins_slash_command(
  954. action: Option<&str>,
  955. target: Option<&str>,
  956. manager: &mut PluginManager,
  957. ) -> Result<PluginsCommandResult, PluginError> {
  958. match action {
  959. None | Some("list") => Ok(PluginsCommandResult {
  960. message: render_plugins_report(&manager.list_installed_plugins()?),
  961. reload_runtime: false,
  962. }),
  963. Some("install") => {
  964. let Some(target) = target else {
  965. return Ok(PluginsCommandResult {
  966. message: "Usage: /plugins install <path>".to_string(),
  967. reload_runtime: false,
  968. });
  969. };
  970. let install = manager.install(target)?;
  971. let plugin = manager
  972. .list_installed_plugins()?
  973. .into_iter()
  974. .find(|plugin| plugin.metadata.id == install.plugin_id);
  975. Ok(PluginsCommandResult {
  976. message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
  977. reload_runtime: true,
  978. })
  979. }
  980. Some("enable") => {
  981. let Some(target) = target else {
  982. return Ok(PluginsCommandResult {
  983. message: "Usage: /plugins enable <name>".to_string(),
  984. reload_runtime: false,
  985. });
  986. };
  987. let plugin = resolve_plugin_target(manager, target)?;
  988. manager.enable(&plugin.metadata.id)?;
  989. Ok(PluginsCommandResult {
  990. message: format!(
  991. "Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled",
  992. plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
  993. ),
  994. reload_runtime: true,
  995. })
  996. }
  997. Some("disable") => {
  998. let Some(target) = target else {
  999. return Ok(PluginsCommandResult {
  1000. message: "Usage: /plugins disable <name>".to_string(),
  1001. reload_runtime: false,
  1002. });
  1003. };
  1004. let plugin = resolve_plugin_target(manager, target)?;
  1005. manager.disable(&plugin.metadata.id)?;
  1006. Ok(PluginsCommandResult {
  1007. message: format!(
  1008. "Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled",
  1009. plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
  1010. ),
  1011. reload_runtime: true,
  1012. })
  1013. }
  1014. Some("uninstall") => {
  1015. let Some(target) = target else {
  1016. return Ok(PluginsCommandResult {
  1017. message: "Usage: /plugins uninstall <plugin-id>".to_string(),
  1018. reload_runtime: false,
  1019. });
  1020. };
  1021. manager.uninstall(target)?;
  1022. Ok(PluginsCommandResult {
  1023. message: format!("Plugins\n Result uninstalled {target}"),
  1024. reload_runtime: true,
  1025. })
  1026. }
  1027. Some("update") => {
  1028. let Some(target) = target else {
  1029. return Ok(PluginsCommandResult {
  1030. message: "Usage: /plugins update <plugin-id>".to_string(),
  1031. reload_runtime: false,
  1032. });
  1033. };
  1034. let update = manager.update(target)?;
  1035. let plugin = manager
  1036. .list_installed_plugins()?
  1037. .into_iter()
  1038. .find(|plugin| plugin.metadata.id == update.plugin_id);
  1039. Ok(PluginsCommandResult {
  1040. message: format!(
  1041. "Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
  1042. update.plugin_id,
  1043. plugin
  1044. .as_ref()
  1045. .map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
  1046. update.old_version,
  1047. update.new_version,
  1048. plugin
  1049. .as_ref()
  1050. .map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
  1051. ),
  1052. reload_runtime: true,
  1053. })
  1054. }
  1055. Some(other) => Ok(PluginsCommandResult {
  1056. message: format!(
  1057. "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
  1058. ),
  1059. reload_runtime: false,
  1060. }),
  1061. }
  1062. }
  1063. pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
  1064. match normalize_optional_args(args) {
  1065. None | Some("list") => {
  1066. let roots = discover_definition_roots(cwd, "agents");
  1067. let agents = load_agents_from_roots(&roots)?;
  1068. Ok(render_agents_report(&agents))
  1069. }
  1070. Some("-h" | "--help" | "help") => Ok(render_agents_usage(None)),
  1071. Some(args) => Ok(render_agents_usage(Some(args))),
  1072. }
  1073. }
  1074. pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
  1075. match normalize_optional_args(args) {
  1076. None | Some("list") => {
  1077. let roots = discover_skill_roots(cwd);
  1078. let skills = load_skills_from_roots(&roots)?;
  1079. Ok(render_skills_report(&skills))
  1080. }
  1081. Some("-h" | "--help" | "help") => Ok(render_skills_usage(None)),
  1082. Some(args) => Ok(render_skills_usage(Some(args))),
  1083. }
  1084. }
  1085. #[must_use]
  1086. pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
  1087. let mut lines = vec!["Plugins".to_string()];
  1088. if plugins.is_empty() {
  1089. lines.push(" No plugins installed.".to_string());
  1090. return lines.join("\n");
  1091. }
  1092. for plugin in plugins {
  1093. let enabled = if plugin.enabled {
  1094. "enabled"
  1095. } else {
  1096. "disabled"
  1097. };
  1098. lines.push(format!(
  1099. " {name:<20} v{version:<10} {enabled}",
  1100. name = plugin.metadata.name,
  1101. version = plugin.metadata.version,
  1102. ));
  1103. }
  1104. lines.join("\n")
  1105. }
  1106. fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
  1107. let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
  1108. let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
  1109. let enabled = plugin.is_some_and(|plugin| plugin.enabled);
  1110. format!(
  1111. "Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
  1112. if enabled { "enabled" } else { "disabled" }
  1113. )
  1114. }
  1115. fn resolve_plugin_target(
  1116. manager: &PluginManager,
  1117. target: &str,
  1118. ) -> Result<PluginSummary, PluginError> {
  1119. let mut matches = manager
  1120. .list_installed_plugins()?
  1121. .into_iter()
  1122. .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
  1123. .collect::<Vec<_>>();
  1124. match matches.len() {
  1125. 1 => Ok(matches.remove(0)),
  1126. 0 => Err(PluginError::NotFound(format!(
  1127. "plugin `{target}` is not installed or discoverable"
  1128. ))),
  1129. _ => Err(PluginError::InvalidManifest(format!(
  1130. "plugin name `{target}` is ambiguous; use the full plugin id"
  1131. ))),
  1132. }
  1133. }
  1134. fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
  1135. let mut roots = Vec::new();
  1136. for ancestor in cwd.ancestors() {
  1137. push_unique_root(
  1138. &mut roots,
  1139. DefinitionSource::ProjectCodex,
  1140. ancestor.join(".codex").join(leaf),
  1141. );
  1142. push_unique_root(
  1143. &mut roots,
  1144. DefinitionSource::ProjectClaude,
  1145. ancestor.join(".claude").join(leaf),
  1146. );
  1147. }
  1148. if let Ok(codex_home) = env::var("CODEX_HOME") {
  1149. push_unique_root(
  1150. &mut roots,
  1151. DefinitionSource::UserCodexHome,
  1152. PathBuf::from(codex_home).join(leaf),
  1153. );
  1154. }
  1155. if let Some(home) = env::var_os("HOME") {
  1156. let home = PathBuf::from(home);
  1157. push_unique_root(
  1158. &mut roots,
  1159. DefinitionSource::UserCodex,
  1160. home.join(".codex").join(leaf),
  1161. );
  1162. push_unique_root(
  1163. &mut roots,
  1164. DefinitionSource::UserClaude,
  1165. home.join(".claude").join(leaf),
  1166. );
  1167. }
  1168. roots
  1169. }
  1170. fn discover_skill_roots(cwd: &Path) -> Vec<SkillRoot> {
  1171. let mut roots = Vec::new();
  1172. for ancestor in cwd.ancestors() {
  1173. push_unique_skill_root(
  1174. &mut roots,
  1175. DefinitionSource::ProjectCodex,
  1176. ancestor.join(".codex").join("skills"),
  1177. SkillOrigin::SkillsDir,
  1178. );
  1179. push_unique_skill_root(
  1180. &mut roots,
  1181. DefinitionSource::ProjectClaude,
  1182. ancestor.join(".claude").join("skills"),
  1183. SkillOrigin::SkillsDir,
  1184. );
  1185. push_unique_skill_root(
  1186. &mut roots,
  1187. DefinitionSource::ProjectCodex,
  1188. ancestor.join(".codex").join("commands"),
  1189. SkillOrigin::LegacyCommandsDir,
  1190. );
  1191. push_unique_skill_root(
  1192. &mut roots,
  1193. DefinitionSource::ProjectClaude,
  1194. ancestor.join(".claude").join("commands"),
  1195. SkillOrigin::LegacyCommandsDir,
  1196. );
  1197. }
  1198. if let Ok(codex_home) = env::var("CODEX_HOME") {
  1199. let codex_home = PathBuf::from(codex_home);
  1200. push_unique_skill_root(
  1201. &mut roots,
  1202. DefinitionSource::UserCodexHome,
  1203. codex_home.join("skills"),
  1204. SkillOrigin::SkillsDir,
  1205. );
  1206. push_unique_skill_root(
  1207. &mut roots,
  1208. DefinitionSource::UserCodexHome,
  1209. codex_home.join("commands"),
  1210. SkillOrigin::LegacyCommandsDir,
  1211. );
  1212. }
  1213. if let Some(home) = env::var_os("HOME") {
  1214. let home = PathBuf::from(home);
  1215. push_unique_skill_root(
  1216. &mut roots,
  1217. DefinitionSource::UserCodex,
  1218. home.join(".codex").join("skills"),
  1219. SkillOrigin::SkillsDir,
  1220. );
  1221. push_unique_skill_root(
  1222. &mut roots,
  1223. DefinitionSource::UserCodex,
  1224. home.join(".codex").join("commands"),
  1225. SkillOrigin::LegacyCommandsDir,
  1226. );
  1227. push_unique_skill_root(
  1228. &mut roots,
  1229. DefinitionSource::UserClaude,
  1230. home.join(".claude").join("skills"),
  1231. SkillOrigin::SkillsDir,
  1232. );
  1233. push_unique_skill_root(
  1234. &mut roots,
  1235. DefinitionSource::UserClaude,
  1236. home.join(".claude").join("commands"),
  1237. SkillOrigin::LegacyCommandsDir,
  1238. );
  1239. }
  1240. roots
  1241. }
  1242. fn push_unique_root(
  1243. roots: &mut Vec<(DefinitionSource, PathBuf)>,
  1244. source: DefinitionSource,
  1245. path: PathBuf,
  1246. ) {
  1247. if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) {
  1248. roots.push((source, path));
  1249. }
  1250. }
  1251. fn push_unique_skill_root(
  1252. roots: &mut Vec<SkillRoot>,
  1253. source: DefinitionSource,
  1254. path: PathBuf,
  1255. origin: SkillOrigin,
  1256. ) {
  1257. if path.is_dir() && !roots.iter().any(|existing| existing.path == path) {
  1258. roots.push(SkillRoot {
  1259. source,
  1260. path,
  1261. origin,
  1262. });
  1263. }
  1264. }
  1265. fn load_agents_from_roots(
  1266. roots: &[(DefinitionSource, PathBuf)],
  1267. ) -> std::io::Result<Vec<AgentSummary>> {
  1268. let mut agents = Vec::new();
  1269. let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
  1270. for (source, root) in roots {
  1271. let mut root_agents = Vec::new();
  1272. for entry in fs::read_dir(root)? {
  1273. let entry = entry?;
  1274. if entry.path().extension().is_none_or(|ext| ext != "toml") {
  1275. continue;
  1276. }
  1277. let contents = fs::read_to_string(entry.path())?;
  1278. let fallback_name = entry.path().file_stem().map_or_else(
  1279. || entry.file_name().to_string_lossy().to_string(),
  1280. |stem| stem.to_string_lossy().to_string(),
  1281. );
  1282. root_agents.push(AgentSummary {
  1283. name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
  1284. description: parse_toml_string(&contents, "description"),
  1285. model: parse_toml_string(&contents, "model"),
  1286. reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
  1287. source: *source,
  1288. shadowed_by: None,
  1289. });
  1290. }
  1291. root_agents.sort_by(|left, right| left.name.cmp(&right.name));
  1292. for mut agent in root_agents {
  1293. let key = agent.name.to_ascii_lowercase();
  1294. if let Some(existing) = active_sources.get(&key) {
  1295. agent.shadowed_by = Some(*existing);
  1296. } else {
  1297. active_sources.insert(key, agent.source);
  1298. }
  1299. agents.push(agent);
  1300. }
  1301. }
  1302. Ok(agents)
  1303. }
  1304. fn load_skills_from_roots(roots: &[SkillRoot]) -> std::io::Result<Vec<SkillSummary>> {
  1305. let mut skills = Vec::new();
  1306. let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
  1307. for root in roots {
  1308. let mut root_skills = Vec::new();
  1309. for entry in fs::read_dir(&root.path)? {
  1310. let entry = entry?;
  1311. match root.origin {
  1312. SkillOrigin::SkillsDir => {
  1313. if !entry.path().is_dir() {
  1314. continue;
  1315. }
  1316. let skill_path = entry.path().join("SKILL.md");
  1317. if !skill_path.is_file() {
  1318. continue;
  1319. }
  1320. let contents = fs::read_to_string(skill_path)?;
  1321. let (name, description) = parse_skill_frontmatter(&contents);
  1322. root_skills.push(SkillSummary {
  1323. name: name
  1324. .unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
  1325. description,
  1326. source: root.source,
  1327. shadowed_by: None,
  1328. origin: root.origin,
  1329. });
  1330. }
  1331. SkillOrigin::LegacyCommandsDir => {
  1332. let path = entry.path();
  1333. let markdown_path = if path.is_dir() {
  1334. let skill_path = path.join("SKILL.md");
  1335. if !skill_path.is_file() {
  1336. continue;
  1337. }
  1338. skill_path
  1339. } else if path
  1340. .extension()
  1341. .is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("md"))
  1342. {
  1343. path
  1344. } else {
  1345. continue;
  1346. };
  1347. let contents = fs::read_to_string(&markdown_path)?;
  1348. let fallback_name = markdown_path.file_stem().map_or_else(
  1349. || entry.file_name().to_string_lossy().to_string(),
  1350. |stem| stem.to_string_lossy().to_string(),
  1351. );
  1352. let (name, description) = parse_skill_frontmatter(&contents);
  1353. root_skills.push(SkillSummary {
  1354. name: name.unwrap_or(fallback_name),
  1355. description,
  1356. source: root.source,
  1357. shadowed_by: None,
  1358. origin: root.origin,
  1359. });
  1360. }
  1361. }
  1362. }
  1363. root_skills.sort_by(|left, right| left.name.cmp(&right.name));
  1364. for mut skill in root_skills {
  1365. let key = skill.name.to_ascii_lowercase();
  1366. if let Some(existing) = active_sources.get(&key) {
  1367. skill.shadowed_by = Some(*existing);
  1368. } else {
  1369. active_sources.insert(key, skill.source);
  1370. }
  1371. skills.push(skill);
  1372. }
  1373. }
  1374. Ok(skills)
  1375. }
  1376. fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
  1377. let prefix = format!("{key} =");
  1378. for line in contents.lines() {
  1379. let trimmed = line.trim();
  1380. if trimmed.starts_with('#') {
  1381. continue;
  1382. }
  1383. let Some(value) = trimmed.strip_prefix(&prefix) else {
  1384. continue;
  1385. };
  1386. let value = value.trim();
  1387. let Some(value) = value
  1388. .strip_prefix('"')
  1389. .and_then(|value| value.strip_suffix('"'))
  1390. else {
  1391. continue;
  1392. };
  1393. if !value.is_empty() {
  1394. return Some(value.to_string());
  1395. }
  1396. }
  1397. None
  1398. }
  1399. fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
  1400. let mut lines = contents.lines();
  1401. if lines.next().map(str::trim) != Some("---") {
  1402. return (None, None);
  1403. }
  1404. let mut name = None;
  1405. let mut description = None;
  1406. for line in lines {
  1407. let trimmed = line.trim();
  1408. if trimmed == "---" {
  1409. break;
  1410. }
  1411. if let Some(value) = trimmed.strip_prefix("name:") {
  1412. let value = unquote_frontmatter_value(value.trim());
  1413. if !value.is_empty() {
  1414. name = Some(value);
  1415. }
  1416. continue;
  1417. }
  1418. if let Some(value) = trimmed.strip_prefix("description:") {
  1419. let value = unquote_frontmatter_value(value.trim());
  1420. if !value.is_empty() {
  1421. description = Some(value);
  1422. }
  1423. }
  1424. }
  1425. (name, description)
  1426. }
  1427. fn unquote_frontmatter_value(value: &str) -> String {
  1428. value
  1429. .strip_prefix('"')
  1430. .and_then(|trimmed| trimmed.strip_suffix('"'))
  1431. .or_else(|| {
  1432. value
  1433. .strip_prefix('\'')
  1434. .and_then(|trimmed| trimmed.strip_suffix('\''))
  1435. })
  1436. .unwrap_or(value)
  1437. .trim()
  1438. .to_string()
  1439. }
  1440. fn render_agents_report(agents: &[AgentSummary]) -> String {
  1441. if agents.is_empty() {
  1442. return "No agents found.".to_string();
  1443. }
  1444. let total_active = agents
  1445. .iter()
  1446. .filter(|agent| agent.shadowed_by.is_none())
  1447. .count();
  1448. let mut lines = vec![
  1449. "Agents".to_string(),
  1450. format!(" {total_active} active agents"),
  1451. String::new(),
  1452. ];
  1453. for source in [
  1454. DefinitionSource::ProjectCodex,
  1455. DefinitionSource::ProjectClaude,
  1456. DefinitionSource::UserCodexHome,
  1457. DefinitionSource::UserCodex,
  1458. DefinitionSource::UserClaude,
  1459. ] {
  1460. let group = agents
  1461. .iter()
  1462. .filter(|agent| agent.source == source)
  1463. .collect::<Vec<_>>();
  1464. if group.is_empty() {
  1465. continue;
  1466. }
  1467. lines.push(format!("{}:", source.label()));
  1468. for agent in group {
  1469. let detail = agent_detail(agent);
  1470. match agent.shadowed_by {
  1471. Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
  1472. None => lines.push(format!(" {detail}")),
  1473. }
  1474. }
  1475. lines.push(String::new());
  1476. }
  1477. lines.join("\n").trim_end().to_string()
  1478. }
  1479. fn agent_detail(agent: &AgentSummary) -> String {
  1480. let mut parts = vec![agent.name.clone()];
  1481. if let Some(description) = &agent.description {
  1482. parts.push(description.clone());
  1483. }
  1484. if let Some(model) = &agent.model {
  1485. parts.push(model.clone());
  1486. }
  1487. if let Some(reasoning) = &agent.reasoning_effort {
  1488. parts.push(reasoning.clone());
  1489. }
  1490. parts.join(" · ")
  1491. }
  1492. fn render_skills_report(skills: &[SkillSummary]) -> String {
  1493. if skills.is_empty() {
  1494. return "No skills found.".to_string();
  1495. }
  1496. let total_active = skills
  1497. .iter()
  1498. .filter(|skill| skill.shadowed_by.is_none())
  1499. .count();
  1500. let mut lines = vec![
  1501. "Skills".to_string(),
  1502. format!(" {total_active} available skills"),
  1503. String::new(),
  1504. ];
  1505. for source in [
  1506. DefinitionSource::ProjectCodex,
  1507. DefinitionSource::ProjectClaude,
  1508. DefinitionSource::UserCodexHome,
  1509. DefinitionSource::UserCodex,
  1510. DefinitionSource::UserClaude,
  1511. ] {
  1512. let group = skills
  1513. .iter()
  1514. .filter(|skill| skill.source == source)
  1515. .collect::<Vec<_>>();
  1516. if group.is_empty() {
  1517. continue;
  1518. }
  1519. lines.push(format!("{}:", source.label()));
  1520. for skill in group {
  1521. let mut parts = vec![skill.name.clone()];
  1522. if let Some(description) = &skill.description {
  1523. parts.push(description.clone());
  1524. }
  1525. if let Some(detail) = skill.origin.detail_label() {
  1526. parts.push(detail.to_string());
  1527. }
  1528. let detail = parts.join(" · ");
  1529. match skill.shadowed_by {
  1530. Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
  1531. None => lines.push(format!(" {detail}")),
  1532. }
  1533. }
  1534. lines.push(String::new());
  1535. }
  1536. lines.join("\n").trim_end().to_string()
  1537. }
  1538. fn normalize_optional_args(args: Option<&str>) -> Option<&str> {
  1539. args.map(str::trim).filter(|value| !value.is_empty())
  1540. }
  1541. fn render_agents_usage(unexpected: Option<&str>) -> String {
  1542. let mut lines = vec![
  1543. "Agents".to_string(),
  1544. " Usage /agents [list|help]".to_string(),
  1545. " Direct CLI claw agents".to_string(),
  1546. " Sources .codex/agents, .claude/agents, $CODEX_HOME/agents".to_string(),
  1547. ];
  1548. if let Some(args) = unexpected {
  1549. lines.push(format!(" Unexpected {args}"));
  1550. }
  1551. lines.join("\n")
  1552. }
  1553. fn render_skills_usage(unexpected: Option<&str>) -> String {
  1554. let mut lines = vec![
  1555. "Skills".to_string(),
  1556. " Usage /skills [list|help]".to_string(),
  1557. " Direct CLI claw skills".to_string(),
  1558. " Sources .codex/skills, .claude/skills, legacy /commands".to_string(),
  1559. ];
  1560. if let Some(args) = unexpected {
  1561. lines.push(format!(" Unexpected {args}"));
  1562. }
  1563. lines.join("\n")
  1564. }
  1565. #[must_use]
  1566. pub fn handle_slash_command(
  1567. input: &str,
  1568. session: &Session,
  1569. compaction: CompactionConfig,
  1570. ) -> Option<SlashCommandResult> {
  1571. let command = match validate_slash_command_input(input) {
  1572. Ok(Some(command)) => command,
  1573. Ok(None) => return None,
  1574. Err(error) => {
  1575. return Some(SlashCommandResult {
  1576. message: error.to_string(),
  1577. session: session.clone(),
  1578. });
  1579. }
  1580. };
  1581. match command {
  1582. SlashCommand::Compact => {
  1583. let result = compact_session(session, compaction);
  1584. let message = if result.removed_message_count == 0 {
  1585. "Compaction skipped: session is below the compaction threshold.".to_string()
  1586. } else {
  1587. format!(
  1588. "Compacted {} messages into a resumable system summary.",
  1589. result.removed_message_count
  1590. )
  1591. };
  1592. Some(SlashCommandResult {
  1593. message,
  1594. session: result.compacted_session,
  1595. })
  1596. }
  1597. SlashCommand::Help => Some(SlashCommandResult {
  1598. message: render_slash_command_help(),
  1599. session: session.clone(),
  1600. }),
  1601. SlashCommand::Status
  1602. | SlashCommand::Bughunter { .. }
  1603. | SlashCommand::Commit
  1604. | SlashCommand::Pr { .. }
  1605. | SlashCommand::Issue { .. }
  1606. | SlashCommand::Ultraplan { .. }
  1607. | SlashCommand::Teleport { .. }
  1608. | SlashCommand::DebugToolCall
  1609. | SlashCommand::Sandbox
  1610. | SlashCommand::Model { .. }
  1611. | SlashCommand::Permissions { .. }
  1612. | SlashCommand::Clear { .. }
  1613. | SlashCommand::Cost
  1614. | SlashCommand::Resume { .. }
  1615. | SlashCommand::Config { .. }
  1616. | SlashCommand::Memory
  1617. | SlashCommand::Init
  1618. | SlashCommand::Diff
  1619. | SlashCommand::Version
  1620. | SlashCommand::Export { .. }
  1621. | SlashCommand::Session { .. }
  1622. | SlashCommand::Plugins { .. }
  1623. | SlashCommand::Agents { .. }
  1624. | SlashCommand::Skills { .. }
  1625. | SlashCommand::Unknown(_) => None,
  1626. }
  1627. }
  1628. #[cfg(test)]
  1629. mod tests {
  1630. use super::{
  1631. handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
  1632. load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
  1633. render_slash_command_help, render_slash_command_help_detail,
  1634. resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
  1635. validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
  1636. };
  1637. use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
  1638. use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
  1639. use std::fs;
  1640. use std::path::{Path, PathBuf};
  1641. use std::time::{SystemTime, UNIX_EPOCH};
  1642. fn temp_dir(label: &str) -> PathBuf {
  1643. let nanos = SystemTime::now()
  1644. .duration_since(UNIX_EPOCH)
  1645. .expect("time should be after epoch")
  1646. .as_nanos();
  1647. std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
  1648. }
  1649. fn write_external_plugin(root: &Path, name: &str, version: &str) {
  1650. fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
  1651. fs::write(
  1652. root.join(".claude-plugin").join("plugin.json"),
  1653. format!(
  1654. "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
  1655. ),
  1656. )
  1657. .expect("write manifest");
  1658. }
  1659. fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
  1660. fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
  1661. fs::write(
  1662. root.join(".claude-plugin").join("plugin.json"),
  1663. format!(
  1664. "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}",
  1665. if default_enabled { "true" } else { "false" }
  1666. ),
  1667. )
  1668. .expect("write bundled manifest");
  1669. }
  1670. fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
  1671. fs::create_dir_all(root).expect("agent root");
  1672. fs::write(
  1673. root.join(format!("{name}.toml")),
  1674. format!(
  1675. "name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
  1676. ),
  1677. )
  1678. .expect("write agent");
  1679. }
  1680. fn write_skill(root: &Path, name: &str, description: &str) {
  1681. let skill_root = root.join(name);
  1682. fs::create_dir_all(&skill_root).expect("skill root");
  1683. fs::write(
  1684. skill_root.join("SKILL.md"),
  1685. format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
  1686. )
  1687. .expect("write skill");
  1688. }
  1689. fn write_legacy_command(root: &Path, name: &str, description: &str) {
  1690. fs::create_dir_all(root).expect("commands root");
  1691. fs::write(
  1692. root.join(format!("{name}.md")),
  1693. format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
  1694. )
  1695. .expect("write command");
  1696. }
  1697. fn parse_error_message(input: &str) -> String {
  1698. validate_slash_command_input(input)
  1699. .expect_err("slash command should be rejected")
  1700. .to_string()
  1701. }
  1702. #[allow(clippy::too_many_lines)]
  1703. #[test]
  1704. fn parses_supported_slash_commands() {
  1705. assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
  1706. assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
  1707. assert_eq!(SlashCommand::parse("/sandbox"), Some(SlashCommand::Sandbox));
  1708. assert_eq!(
  1709. SlashCommand::parse("/bughunter runtime"),
  1710. Some(SlashCommand::Bughunter {
  1711. scope: Some("runtime".to_string())
  1712. })
  1713. );
  1714. assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
  1715. assert_eq!(
  1716. SlashCommand::parse("/pr ready for review"),
  1717. Some(SlashCommand::Pr {
  1718. context: Some("ready for review".to_string())
  1719. })
  1720. );
  1721. assert_eq!(
  1722. SlashCommand::parse("/issue flaky test"),
  1723. Some(SlashCommand::Issue {
  1724. context: Some("flaky test".to_string())
  1725. })
  1726. );
  1727. assert_eq!(
  1728. SlashCommand::parse("/ultraplan ship both features"),
  1729. Some(SlashCommand::Ultraplan {
  1730. task: Some("ship both features".to_string())
  1731. })
  1732. );
  1733. assert_eq!(
  1734. SlashCommand::parse("/teleport conversation.rs"),
  1735. Some(SlashCommand::Teleport {
  1736. target: Some("conversation.rs".to_string())
  1737. })
  1738. );
  1739. assert_eq!(
  1740. SlashCommand::parse("/debug-tool-call"),
  1741. Some(SlashCommand::DebugToolCall)
  1742. );
  1743. assert_eq!(
  1744. SlashCommand::parse("/bughunter runtime"),
  1745. Some(SlashCommand::Bughunter {
  1746. scope: Some("runtime".to_string())
  1747. })
  1748. );
  1749. assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
  1750. assert_eq!(
  1751. SlashCommand::parse("/pr ready for review"),
  1752. Some(SlashCommand::Pr {
  1753. context: Some("ready for review".to_string())
  1754. })
  1755. );
  1756. assert_eq!(
  1757. SlashCommand::parse("/issue flaky test"),
  1758. Some(SlashCommand::Issue {
  1759. context: Some("flaky test".to_string())
  1760. })
  1761. );
  1762. assert_eq!(
  1763. SlashCommand::parse("/ultraplan ship both features"),
  1764. Some(SlashCommand::Ultraplan {
  1765. task: Some("ship both features".to_string())
  1766. })
  1767. );
  1768. assert_eq!(
  1769. SlashCommand::parse("/teleport conversation.rs"),
  1770. Some(SlashCommand::Teleport {
  1771. target: Some("conversation.rs".to_string())
  1772. })
  1773. );
  1774. assert_eq!(
  1775. SlashCommand::parse("/debug-tool-call"),
  1776. Some(SlashCommand::DebugToolCall)
  1777. );
  1778. assert_eq!(
  1779. SlashCommand::parse("/model claude-opus"),
  1780. Some(SlashCommand::Model {
  1781. model: Some("claude-opus".to_string()),
  1782. })
  1783. );
  1784. assert_eq!(
  1785. SlashCommand::parse("/model"),
  1786. Some(SlashCommand::Model { model: None })
  1787. );
  1788. assert_eq!(
  1789. SlashCommand::parse("/permissions read-only"),
  1790. Some(SlashCommand::Permissions {
  1791. mode: Some("read-only".to_string()),
  1792. })
  1793. );
  1794. assert_eq!(
  1795. SlashCommand::parse("/clear"),
  1796. Some(SlashCommand::Clear { confirm: false })
  1797. );
  1798. assert_eq!(
  1799. SlashCommand::parse("/clear --confirm"),
  1800. Some(SlashCommand::Clear { confirm: true })
  1801. );
  1802. assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
  1803. assert_eq!(
  1804. SlashCommand::parse("/resume session.json"),
  1805. Some(SlashCommand::Resume {
  1806. session_path: Some("session.json".to_string()),
  1807. })
  1808. );
  1809. assert_eq!(
  1810. SlashCommand::parse("/config"),
  1811. Some(SlashCommand::Config { section: None })
  1812. );
  1813. assert_eq!(
  1814. SlashCommand::parse("/config env"),
  1815. Some(SlashCommand::Config {
  1816. section: Some("env".to_string())
  1817. })
  1818. );
  1819. assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
  1820. assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
  1821. assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
  1822. assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
  1823. assert_eq!(
  1824. SlashCommand::parse("/export notes.txt"),
  1825. Some(SlashCommand::Export {
  1826. path: Some("notes.txt".to_string())
  1827. })
  1828. );
  1829. assert_eq!(
  1830. SlashCommand::parse("/session switch abc123"),
  1831. Some(SlashCommand::Session {
  1832. action: Some("switch".to_string()),
  1833. target: Some("abc123".to_string())
  1834. })
  1835. );
  1836. assert_eq!(
  1837. SlashCommand::parse("/plugins install demo"),
  1838. Some(SlashCommand::Plugins {
  1839. action: Some("install".to_string()),
  1840. target: Some("demo".to_string())
  1841. })
  1842. );
  1843. assert_eq!(
  1844. SlashCommand::parse("/plugins list"),
  1845. Some(SlashCommand::Plugins {
  1846. action: Some("list".to_string()),
  1847. target: None
  1848. })
  1849. );
  1850. assert_eq!(
  1851. SlashCommand::parse("/plugins enable demo"),
  1852. Some(SlashCommand::Plugins {
  1853. action: Some("enable".to_string()),
  1854. target: Some("demo".to_string())
  1855. })
  1856. );
  1857. assert_eq!(
  1858. SlashCommand::parse("/plugins disable demo"),
  1859. Some(SlashCommand::Plugins {
  1860. action: Some("disable".to_string()),
  1861. target: Some("demo".to_string())
  1862. })
  1863. );
  1864. assert_eq!(
  1865. SlashCommand::parse("/session fork incident-review"),
  1866. Some(SlashCommand::Session {
  1867. action: Some("fork".to_string()),
  1868. target: Some("incident-review".to_string())
  1869. })
  1870. );
  1871. }
  1872. #[test]
  1873. fn rejects_unexpected_arguments_for_no_arg_commands() {
  1874. // given
  1875. let input = "/compact now";
  1876. // when
  1877. let error = parse_error_message(input);
  1878. // then
  1879. assert!(error.contains("Unexpected arguments for /compact."));
  1880. assert!(error.contains(" Usage /compact"));
  1881. assert!(error.contains(" Summary Compact local session history"));
  1882. }
  1883. #[test]
  1884. fn rejects_invalid_argument_values() {
  1885. // given
  1886. let input = "/permissions admin";
  1887. // when
  1888. let error = parse_error_message(input);
  1889. // then
  1890. assert!(error.contains(
  1891. "Unsupported /permissions mode 'admin'. Use read-only, workspace-write, or danger-full-access."
  1892. ));
  1893. assert!(error.contains(
  1894. " Usage /permissions [read-only|workspace-write|danger-full-access]"
  1895. ));
  1896. }
  1897. #[test]
  1898. fn rejects_missing_required_arguments() {
  1899. // given
  1900. let input = "/teleport";
  1901. // when
  1902. let error = parse_error_message(input);
  1903. // then
  1904. assert!(error.contains("Usage: /teleport <symbol-or-path>"));
  1905. assert!(error.contains(" Category Discovery & debugging"));
  1906. }
  1907. #[test]
  1908. fn rejects_invalid_session_and_plugin_shapes() {
  1909. // given
  1910. let session_input = "/session switch";
  1911. let plugin_input = "/plugins list extra";
  1912. // when
  1913. let session_error = parse_error_message(session_input);
  1914. let plugin_error = parse_error_message(plugin_input);
  1915. // then
  1916. assert!(session_error.contains("Usage: /session switch <session-id>"));
  1917. assert!(session_error.contains("/session"));
  1918. assert!(plugin_error.contains("Usage: /plugin list"));
  1919. assert!(plugin_error.contains("Aliases /plugins, /marketplace"));
  1920. }
  1921. #[test]
  1922. fn rejects_invalid_agents_and_skills_arguments() {
  1923. // given
  1924. let agents_input = "/agents show planner";
  1925. let skills_input = "/skills show help";
  1926. // when
  1927. let agents_error = parse_error_message(agents_input);
  1928. let skills_error = parse_error_message(skills_input);
  1929. // then
  1930. assert!(agents_error.contains(
  1931. "Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
  1932. ));
  1933. assert!(agents_error.contains(" Usage /agents [list|help]"));
  1934. assert!(skills_error.contains(
  1935. "Unexpected arguments for /skills: show help. Use /skills, /skills list, or /skills help."
  1936. ));
  1937. assert!(skills_error.contains(" Usage /skills [list|help]"));
  1938. }
  1939. #[test]
  1940. fn renders_help_from_shared_specs() {
  1941. let help = render_slash_command_help();
  1942. assert!(help.contains("Start here /status, /diff, /agents, /skills, /commit"));
  1943. assert!(help.contains("[resume] also works with --resume SESSION.jsonl"));
  1944. assert!(help.contains("Session & visibility"));
  1945. assert!(help.contains("Workspace & git"));
  1946. assert!(help.contains("Discovery & debugging"));
  1947. assert!(help.contains("Analysis & automation"));
  1948. assert!(help.contains("/help"));
  1949. assert!(help.contains("/status"));
  1950. assert!(help.contains("/sandbox"));
  1951. assert!(help.contains("/compact"));
  1952. assert!(help.contains("/bughunter [scope]"));
  1953. assert!(help.contains("/commit"));
  1954. assert!(help.contains("/pr [context]"));
  1955. assert!(help.contains("/issue [context]"));
  1956. assert!(help.contains("/ultraplan [task]"));
  1957. assert!(help.contains("/teleport <symbol-or-path>"));
  1958. assert!(help.contains("/debug-tool-call"));
  1959. assert!(help.contains("/model [model]"));
  1960. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  1961. assert!(help.contains("/clear [--confirm]"));
  1962. assert!(help.contains("/cost"));
  1963. assert!(help.contains("/resume <session-path>"));
  1964. assert!(help.contains("/config [env|hooks|model|plugins]"));
  1965. assert!(help.contains("/memory"));
  1966. assert!(help.contains("/init"));
  1967. assert!(help.contains("/diff"));
  1968. assert!(help.contains("/version"));
  1969. assert!(help.contains("/export [file]"));
  1970. assert!(help.contains("/session [list|switch <session-id>|fork [branch-name]]"));
  1971. assert!(help.contains("/sandbox"));
  1972. assert!(help.contains(
  1973. "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
  1974. ));
  1975. assert!(help.contains("aliases: /plugins, /marketplace"));
  1976. assert!(help.contains("/agents [list|help]"));
  1977. assert!(help.contains("/skills [list|help]"));
  1978. assert_eq!(slash_command_specs().len(), 26);
  1979. assert_eq!(resume_supported_slash_commands().len(), 14);
  1980. }
  1981. #[test]
  1982. fn renders_per_command_help_detail() {
  1983. // given
  1984. let command = "plugins";
  1985. // when
  1986. let help = render_slash_command_help_detail(command).expect("detail help should exist");
  1987. // then
  1988. assert!(help.contains("/plugin"));
  1989. assert!(help.contains("Summary Manage Claw Code plugins"));
  1990. assert!(help.contains("Aliases /plugins, /marketplace"));
  1991. assert!(help.contains("Category Workspace & git"));
  1992. }
  1993. #[test]
  1994. fn renders_agents_and_skills_help_with_list_and_help_usage() {
  1995. // given
  1996. let agents = render_slash_command_help_detail("agents").expect("agents help should exist");
  1997. let skills = render_slash_command_help_detail("skills").expect("skills help should exist");
  1998. // when
  1999. // then
  2000. assert!(agents.contains("Usage /agents [list|help]"));
  2001. assert!(skills.contains("Usage /skills [list|help]"));
  2002. }
  2003. #[test]
  2004. fn validate_slash_command_input_rejects_extra_single_value_arguments() {
  2005. // given
  2006. let session_input = "/session switch current next";
  2007. let plugin_input = "/plugin enable demo extra";
  2008. // when
  2009. let session_error = validate_slash_command_input(session_input)
  2010. .expect_err("session input should be rejected")
  2011. .to_string();
  2012. let plugin_error = validate_slash_command_input(plugin_input)
  2013. .expect_err("plugin input should be rejected")
  2014. .to_string();
  2015. // then
  2016. assert!(session_error.contains("Unexpected arguments for /session switch."));
  2017. assert!(session_error.contains(" Usage /session switch <session-id>"));
  2018. assert!(plugin_error.contains("Unexpected arguments for /plugin enable."));
  2019. assert!(plugin_error.contains(" Usage /plugin enable <name>"));
  2020. }
  2021. #[test]
  2022. fn suggests_closest_slash_commands_for_typos_and_aliases() {
  2023. assert_eq!(suggest_slash_commands("stats", 3), vec!["/status"]);
  2024. assert_eq!(suggest_slash_commands("/plugns", 3), vec!["/plugin"]);
  2025. assert_eq!(suggest_slash_commands("zzz", 3), Vec::<String>::new());
  2026. }
  2027. #[test]
  2028. fn compacts_sessions_via_slash_command() {
  2029. let mut session = Session::new();
  2030. session.messages = vec![
  2031. ConversationMessage::user_text("a ".repeat(200)),
  2032. ConversationMessage::assistant(vec![ContentBlock::Text {
  2033. text: "b ".repeat(200),
  2034. }]),
  2035. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  2036. ConversationMessage::assistant(vec![ContentBlock::Text {
  2037. text: "recent".to_string(),
  2038. }]),
  2039. ];
  2040. let result = handle_slash_command(
  2041. "/compact",
  2042. &session,
  2043. CompactionConfig {
  2044. preserve_recent_messages: 2,
  2045. max_estimated_tokens: 1,
  2046. },
  2047. )
  2048. .expect("slash command should be handled");
  2049. assert!(result.message.contains("Compacted 2 messages"));
  2050. assert_eq!(result.session.messages[0].role, MessageRole::System);
  2051. }
  2052. #[test]
  2053. fn help_command_is_non_mutating() {
  2054. let session = Session::new();
  2055. let result = handle_slash_command("/help", &session, CompactionConfig::default())
  2056. .expect("help command should be handled");
  2057. assert_eq!(result.session, session);
  2058. assert!(result.message.contains("Slash commands"));
  2059. }
  2060. #[test]
  2061. fn ignores_unknown_or_runtime_bound_slash_commands() {
  2062. let session = Session::new();
  2063. assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
  2064. assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
  2065. assert!(handle_slash_command("/sandbox", &session, CompactionConfig::default()).is_none());
  2066. assert!(
  2067. handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
  2068. );
  2069. assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
  2070. assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
  2071. assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
  2072. assert!(
  2073. handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
  2074. );
  2075. assert!(
  2076. handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
  2077. );
  2078. assert!(
  2079. handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
  2080. .is_none()
  2081. );
  2082. assert!(
  2083. handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
  2084. );
  2085. assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
  2086. assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
  2087. assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
  2088. assert!(
  2089. handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
  2090. );
  2091. assert!(
  2092. handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
  2093. );
  2094. assert!(
  2095. handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
  2096. .is_none()
  2097. );
  2098. assert!(
  2099. handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
  2100. );
  2101. assert!(handle_slash_command(
  2102. "/permissions read-only",
  2103. &session,
  2104. CompactionConfig::default()
  2105. )
  2106. .is_none());
  2107. assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
  2108. assert!(
  2109. handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
  2110. .is_none()
  2111. );
  2112. assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
  2113. assert!(handle_slash_command(
  2114. "/resume session.json",
  2115. &session,
  2116. CompactionConfig::default()
  2117. )
  2118. .is_none());
  2119. assert!(handle_slash_command(
  2120. "/resume session.jsonl",
  2121. &session,
  2122. CompactionConfig::default()
  2123. )
  2124. .is_none());
  2125. assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
  2126. assert!(
  2127. handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
  2128. );
  2129. assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
  2130. assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
  2131. assert!(
  2132. handle_slash_command("/export note.txt", &session, CompactionConfig::default())
  2133. .is_none()
  2134. );
  2135. assert!(
  2136. handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
  2137. );
  2138. assert!(
  2139. handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
  2140. );
  2141. }
  2142. #[test]
  2143. fn renders_plugins_report_with_name_version_and_status() {
  2144. let rendered = render_plugins_report(&[
  2145. PluginSummary {
  2146. metadata: PluginMetadata {
  2147. id: "demo@external".to_string(),
  2148. name: "demo".to_string(),
  2149. version: "1.2.3".to_string(),
  2150. description: "demo plugin".to_string(),
  2151. kind: PluginKind::External,
  2152. source: "demo".to_string(),
  2153. default_enabled: false,
  2154. root: None,
  2155. },
  2156. enabled: true,
  2157. },
  2158. PluginSummary {
  2159. metadata: PluginMetadata {
  2160. id: "sample@external".to_string(),
  2161. name: "sample".to_string(),
  2162. version: "0.9.0".to_string(),
  2163. description: "sample plugin".to_string(),
  2164. kind: PluginKind::External,
  2165. source: "sample".to_string(),
  2166. default_enabled: false,
  2167. root: None,
  2168. },
  2169. enabled: false,
  2170. },
  2171. ]);
  2172. assert!(rendered.contains("demo"));
  2173. assert!(rendered.contains("v1.2.3"));
  2174. assert!(rendered.contains("enabled"));
  2175. assert!(rendered.contains("sample"));
  2176. assert!(rendered.contains("v0.9.0"));
  2177. assert!(rendered.contains("disabled"));
  2178. }
  2179. #[test]
  2180. fn lists_agents_from_project_and_user_roots() {
  2181. let workspace = temp_dir("agents-workspace");
  2182. let project_agents = workspace.join(".codex").join("agents");
  2183. let user_home = temp_dir("agents-home");
  2184. let user_agents = user_home.join(".codex").join("agents");
  2185. write_agent(
  2186. &project_agents,
  2187. "planner",
  2188. "Project planner",
  2189. "gpt-5.4",
  2190. "medium",
  2191. );
  2192. write_agent(
  2193. &user_agents,
  2194. "planner",
  2195. "User planner",
  2196. "gpt-5.4-mini",
  2197. "high",
  2198. );
  2199. write_agent(
  2200. &user_agents,
  2201. "verifier",
  2202. "Verification agent",
  2203. "gpt-5.4-mini",
  2204. "high",
  2205. );
  2206. let roots = vec![
  2207. (DefinitionSource::ProjectCodex, project_agents),
  2208. (DefinitionSource::UserCodex, user_agents),
  2209. ];
  2210. let report =
  2211. render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load"));
  2212. assert!(report.contains("Agents"));
  2213. assert!(report.contains("2 active agents"));
  2214. assert!(report.contains("Project (.codex):"));
  2215. assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
  2216. assert!(report.contains("User (~/.codex):"));
  2217. assert!(report.contains("(shadowed by Project (.codex)) planner · User planner"));
  2218. assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
  2219. let _ = fs::remove_dir_all(workspace);
  2220. let _ = fs::remove_dir_all(user_home);
  2221. }
  2222. #[test]
  2223. fn lists_skills_from_project_and_user_roots() {
  2224. let workspace = temp_dir("skills-workspace");
  2225. let project_skills = workspace.join(".codex").join("skills");
  2226. let project_commands = workspace.join(".claude").join("commands");
  2227. let user_home = temp_dir("skills-home");
  2228. let user_skills = user_home.join(".codex").join("skills");
  2229. write_skill(&project_skills, "plan", "Project planning guidance");
  2230. write_legacy_command(&project_commands, "deploy", "Legacy deployment guidance");
  2231. write_skill(&user_skills, "plan", "User planning guidance");
  2232. write_skill(&user_skills, "help", "Help guidance");
  2233. let roots = vec![
  2234. SkillRoot {
  2235. source: DefinitionSource::ProjectCodex,
  2236. path: project_skills,
  2237. origin: SkillOrigin::SkillsDir,
  2238. },
  2239. SkillRoot {
  2240. source: DefinitionSource::ProjectClaude,
  2241. path: project_commands,
  2242. origin: SkillOrigin::LegacyCommandsDir,
  2243. },
  2244. SkillRoot {
  2245. source: DefinitionSource::UserCodex,
  2246. path: user_skills,
  2247. origin: SkillOrigin::SkillsDir,
  2248. },
  2249. ];
  2250. let report =
  2251. render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
  2252. assert!(report.contains("Skills"));
  2253. assert!(report.contains("3 available skills"));
  2254. assert!(report.contains("Project (.codex):"));
  2255. assert!(report.contains("plan · Project planning guidance"));
  2256. assert!(report.contains("Project (.claude):"));
  2257. assert!(report.contains("deploy · Legacy deployment guidance · legacy /commands"));
  2258. assert!(report.contains("User (~/.codex):"));
  2259. assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
  2260. assert!(report.contains("help · Help guidance"));
  2261. let _ = fs::remove_dir_all(workspace);
  2262. let _ = fs::remove_dir_all(user_home);
  2263. }
  2264. #[test]
  2265. fn agents_and_skills_usage_support_help_and_unexpected_args() {
  2266. let cwd = temp_dir("slash-usage");
  2267. let agents_help =
  2268. super::handle_agents_slash_command(Some("help"), &cwd).expect("agents help");
  2269. assert!(agents_help.contains("Usage /agents [list|help]"));
  2270. assert!(agents_help.contains("Direct CLI claw agents"));
  2271. let agents_unexpected =
  2272. super::handle_agents_slash_command(Some("show planner"), &cwd).expect("agents usage");
  2273. assert!(agents_unexpected.contains("Unexpected show planner"));
  2274. let skills_help =
  2275. super::handle_skills_slash_command(Some("--help"), &cwd).expect("skills help");
  2276. assert!(skills_help.contains("Usage /skills [list|help]"));
  2277. assert!(skills_help.contains("legacy /commands"));
  2278. let skills_unexpected =
  2279. super::handle_skills_slash_command(Some("show help"), &cwd).expect("skills usage");
  2280. assert!(skills_unexpected.contains("Unexpected show help"));
  2281. let _ = fs::remove_dir_all(cwd);
  2282. }
  2283. #[test]
  2284. fn parses_quoted_skill_frontmatter_values() {
  2285. let contents = "---\nname: \"hud\"\ndescription: 'Quoted description'\n---\n";
  2286. let (name, description) = super::parse_skill_frontmatter(contents);
  2287. assert_eq!(name.as_deref(), Some("hud"));
  2288. assert_eq!(description.as_deref(), Some("Quoted description"));
  2289. }
  2290. #[test]
  2291. fn installs_plugin_from_path_and_lists_it() {
  2292. let config_home = temp_dir("home");
  2293. let source_root = temp_dir("source");
  2294. write_external_plugin(&source_root, "demo", "1.0.0");
  2295. let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
  2296. let install = handle_plugins_slash_command(
  2297. Some("install"),
  2298. Some(source_root.to_str().expect("utf8 path")),
  2299. &mut manager,
  2300. )
  2301. .expect("install command should succeed");
  2302. assert!(install.reload_runtime);
  2303. assert!(install.message.contains("installed demo@external"));
  2304. assert!(install.message.contains("Name demo"));
  2305. assert!(install.message.contains("Version 1.0.0"));
  2306. assert!(install.message.contains("Status enabled"));
  2307. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  2308. .expect("list command should succeed");
  2309. assert!(!list.reload_runtime);
  2310. assert!(list.message.contains("demo"));
  2311. assert!(list.message.contains("v1.0.0"));
  2312. assert!(list.message.contains("enabled"));
  2313. let _ = fs::remove_dir_all(config_home);
  2314. let _ = fs::remove_dir_all(source_root);
  2315. }
  2316. #[test]
  2317. fn enables_and_disables_plugin_by_name() {
  2318. let config_home = temp_dir("toggle-home");
  2319. let source_root = temp_dir("toggle-source");
  2320. write_external_plugin(&source_root, "demo", "1.0.0");
  2321. let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
  2322. handle_plugins_slash_command(
  2323. Some("install"),
  2324. Some(source_root.to_str().expect("utf8 path")),
  2325. &mut manager,
  2326. )
  2327. .expect("install command should succeed");
  2328. let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
  2329. .expect("disable command should succeed");
  2330. assert!(disable.reload_runtime);
  2331. assert!(disable.message.contains("disabled demo@external"));
  2332. assert!(disable.message.contains("Name demo"));
  2333. assert!(disable.message.contains("Status disabled"));
  2334. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  2335. .expect("list command should succeed");
  2336. assert!(list.message.contains("demo"));
  2337. assert!(list.message.contains("disabled"));
  2338. let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
  2339. .expect("enable command should succeed");
  2340. assert!(enable.reload_runtime);
  2341. assert!(enable.message.contains("enabled demo@external"));
  2342. assert!(enable.message.contains("Name demo"));
  2343. assert!(enable.message.contains("Status enabled"));
  2344. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  2345. .expect("list command should succeed");
  2346. assert!(list.message.contains("demo"));
  2347. assert!(list.message.contains("enabled"));
  2348. let _ = fs::remove_dir_all(config_home);
  2349. let _ = fs::remove_dir_all(source_root);
  2350. }
  2351. #[test]
  2352. fn lists_auto_installed_bundled_plugins_with_status() {
  2353. let config_home = temp_dir("bundled-home");
  2354. let bundled_root = temp_dir("bundled-root");
  2355. let bundled_plugin = bundled_root.join("starter");
  2356. write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
  2357. let mut config = PluginManagerConfig::new(&config_home);
  2358. config.bundled_root = Some(bundled_root.clone());
  2359. let mut manager = PluginManager::new(config);
  2360. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  2361. .expect("list command should succeed");
  2362. assert!(!list.reload_runtime);
  2363. assert!(list.message.contains("starter"));
  2364. assert!(list.message.contains("v0.1.0"));
  2365. assert!(list.message.contains("disabled"));
  2366. let _ = fs::remove_dir_all(config_home);
  2367. let _ = fs::remove_dir_all(bundled_root);
  2368. }
  2369. }