lib.rs 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547
  1. use std::collections::BTreeMap;
  2. use std::env;
  3. use std::fs;
  4. use std::path::{Path, PathBuf};
  5. use plugins::{PluginError, PluginManager, PluginSummary};
  6. use runtime::{compact_session, CompactionConfig, Session};
  7. #[derive(Debug, Clone, PartialEq, Eq)]
  8. pub struct CommandManifestEntry {
  9. pub name: String,
  10. pub source: CommandSource,
  11. }
  12. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  13. pub enum CommandSource {
  14. Builtin,
  15. InternalOnly,
  16. FeatureGated,
  17. }
  18. #[derive(Debug, Clone, Default, PartialEq, Eq)]
  19. pub struct CommandRegistry {
  20. entries: Vec<CommandManifestEntry>,
  21. }
  22. impl CommandRegistry {
  23. #[must_use]
  24. pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
  25. Self { entries }
  26. }
  27. #[must_use]
  28. pub fn entries(&self) -> &[CommandManifestEntry] {
  29. &self.entries
  30. }
  31. }
  32. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  33. pub struct SlashCommandSpec {
  34. pub name: &'static str,
  35. pub aliases: &'static [&'static str],
  36. pub summary: &'static str,
  37. pub argument_hint: Option<&'static str>,
  38. pub resume_supported: bool,
  39. }
  40. const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
  41. SlashCommandSpec {
  42. name: "help",
  43. aliases: &[],
  44. summary: "Show available slash commands",
  45. argument_hint: None,
  46. resume_supported: true,
  47. },
  48. SlashCommandSpec {
  49. name: "status",
  50. aliases: &[],
  51. summary: "Show current session status",
  52. argument_hint: None,
  53. resume_supported: true,
  54. },
  55. SlashCommandSpec {
  56. name: "compact",
  57. aliases: &[],
  58. summary: "Compact local session history",
  59. argument_hint: None,
  60. resume_supported: true,
  61. },
  62. SlashCommandSpec {
  63. name: "model",
  64. aliases: &[],
  65. summary: "Show or switch the active model",
  66. argument_hint: Some("[model]"),
  67. resume_supported: false,
  68. },
  69. SlashCommandSpec {
  70. name: "permissions",
  71. aliases: &[],
  72. summary: "Show or switch the active permission mode",
  73. argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
  74. resume_supported: false,
  75. },
  76. SlashCommandSpec {
  77. name: "clear",
  78. aliases: &[],
  79. summary: "Start a fresh local session",
  80. argument_hint: Some("[--confirm]"),
  81. resume_supported: true,
  82. },
  83. SlashCommandSpec {
  84. name: "cost",
  85. aliases: &[],
  86. summary: "Show cumulative token usage for this session",
  87. argument_hint: None,
  88. resume_supported: true,
  89. },
  90. SlashCommandSpec {
  91. name: "resume",
  92. aliases: &[],
  93. summary: "Load a saved session into the REPL",
  94. argument_hint: Some("<session-path>"),
  95. resume_supported: false,
  96. },
  97. SlashCommandSpec {
  98. name: "config",
  99. aliases: &[],
  100. summary: "Inspect Claude config files or merged sections",
  101. argument_hint: Some("[env|hooks|model|plugins]"),
  102. resume_supported: true,
  103. },
  104. SlashCommandSpec {
  105. name: "memory",
  106. aliases: &[],
  107. summary: "Inspect loaded Claude instruction memory files",
  108. argument_hint: None,
  109. resume_supported: true,
  110. },
  111. SlashCommandSpec {
  112. name: "init",
  113. aliases: &[],
  114. summary: "Create a starter CLAUDE.md for this repo",
  115. argument_hint: None,
  116. resume_supported: true,
  117. },
  118. SlashCommandSpec {
  119. name: "diff",
  120. aliases: &[],
  121. summary: "Show git diff for current workspace changes",
  122. argument_hint: None,
  123. resume_supported: true,
  124. },
  125. SlashCommandSpec {
  126. name: "version",
  127. aliases: &[],
  128. summary: "Show CLI version and build information",
  129. argument_hint: None,
  130. resume_supported: true,
  131. },
  132. SlashCommandSpec {
  133. name: "bughunter",
  134. aliases: &[],
  135. summary: "Inspect the codebase for likely bugs",
  136. argument_hint: Some("[scope]"),
  137. resume_supported: false,
  138. },
  139. SlashCommandSpec {
  140. name: "commit",
  141. aliases: &[],
  142. summary: "Generate a commit message and create a git commit",
  143. argument_hint: None,
  144. resume_supported: false,
  145. },
  146. SlashCommandSpec {
  147. name: "pr",
  148. aliases: &[],
  149. summary: "Draft or create a pull request from the conversation",
  150. argument_hint: Some("[context]"),
  151. resume_supported: false,
  152. },
  153. SlashCommandSpec {
  154. name: "issue",
  155. aliases: &[],
  156. summary: "Draft or create a GitHub issue from the conversation",
  157. argument_hint: Some("[context]"),
  158. resume_supported: false,
  159. },
  160. SlashCommandSpec {
  161. name: "ultraplan",
  162. aliases: &[],
  163. summary: "Run a deep planning prompt with multi-step reasoning",
  164. argument_hint: Some("[task]"),
  165. resume_supported: false,
  166. },
  167. SlashCommandSpec {
  168. name: "teleport",
  169. aliases: &[],
  170. summary: "Jump to a file or symbol by searching the workspace",
  171. argument_hint: Some("<symbol-or-path>"),
  172. resume_supported: false,
  173. },
  174. SlashCommandSpec {
  175. name: "debug-tool-call",
  176. aliases: &[],
  177. summary: "Replay the last tool call with debug details",
  178. argument_hint: None,
  179. resume_supported: false,
  180. },
  181. SlashCommandSpec {
  182. name: "export",
  183. aliases: &[],
  184. summary: "Export the current conversation to a file",
  185. argument_hint: Some("[file]"),
  186. resume_supported: true,
  187. },
  188. SlashCommandSpec {
  189. name: "session",
  190. aliases: &[],
  191. summary: "List or switch managed local sessions",
  192. argument_hint: Some("[list|switch <session-id>]"),
  193. resume_supported: false,
  194. },
  195. SlashCommandSpec {
  196. name: "plugin",
  197. aliases: &["plugins", "marketplace"],
  198. summary: "Manage Claude Code plugins",
  199. argument_hint: Some(
  200. "[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
  201. ),
  202. resume_supported: false,
  203. },
  204. SlashCommandSpec {
  205. name: "agents",
  206. aliases: &[],
  207. summary: "Manage agent configurations",
  208. argument_hint: None,
  209. resume_supported: false,
  210. },
  211. SlashCommandSpec {
  212. name: "skills",
  213. aliases: &[],
  214. summary: "List available skills",
  215. argument_hint: None,
  216. resume_supported: false,
  217. },
  218. ];
  219. #[derive(Debug, Clone, PartialEq, Eq)]
  220. pub enum SlashCommand {
  221. Help,
  222. Status,
  223. Compact,
  224. Bughunter {
  225. scope: Option<String>,
  226. },
  227. Commit,
  228. Pr {
  229. context: Option<String>,
  230. },
  231. Issue {
  232. context: Option<String>,
  233. },
  234. Ultraplan {
  235. task: Option<String>,
  236. },
  237. Teleport {
  238. target: Option<String>,
  239. },
  240. DebugToolCall,
  241. Model {
  242. model: Option<String>,
  243. },
  244. Permissions {
  245. mode: Option<String>,
  246. },
  247. Clear {
  248. confirm: bool,
  249. },
  250. Cost,
  251. Resume {
  252. session_path: Option<String>,
  253. },
  254. Config {
  255. section: Option<String>,
  256. },
  257. Memory,
  258. Init,
  259. Diff,
  260. Version,
  261. Export {
  262. path: Option<String>,
  263. },
  264. Session {
  265. action: Option<String>,
  266. target: Option<String>,
  267. },
  268. Plugins {
  269. action: Option<String>,
  270. target: Option<String>,
  271. },
  272. Agents {
  273. args: Option<String>,
  274. },
  275. Skills {
  276. args: Option<String>,
  277. },
  278. Unknown(String),
  279. }
  280. impl SlashCommand {
  281. #[must_use]
  282. pub fn parse(input: &str) -> Option<Self> {
  283. let trimmed = input.trim();
  284. if !trimmed.starts_with('/') {
  285. return None;
  286. }
  287. let mut parts = trimmed.trim_start_matches('/').split_whitespace();
  288. let command = parts.next().unwrap_or_default();
  289. Some(match command {
  290. "help" => Self::Help,
  291. "status" => Self::Status,
  292. "compact" => Self::Compact,
  293. "bughunter" => Self::Bughunter {
  294. scope: remainder_after_command(trimmed, command),
  295. },
  296. "commit" => Self::Commit,
  297. "pr" => Self::Pr {
  298. context: remainder_after_command(trimmed, command),
  299. },
  300. "issue" => Self::Issue {
  301. context: remainder_after_command(trimmed, command),
  302. },
  303. "ultraplan" => Self::Ultraplan {
  304. task: remainder_after_command(trimmed, command),
  305. },
  306. "teleport" => Self::Teleport {
  307. target: remainder_after_command(trimmed, command),
  308. },
  309. "debug-tool-call" => Self::DebugToolCall,
  310. "model" => Self::Model {
  311. model: parts.next().map(ToOwned::to_owned),
  312. },
  313. "permissions" => Self::Permissions {
  314. mode: parts.next().map(ToOwned::to_owned),
  315. },
  316. "clear" => Self::Clear {
  317. confirm: parts.next() == Some("--confirm"),
  318. },
  319. "cost" => Self::Cost,
  320. "resume" => Self::Resume {
  321. session_path: parts.next().map(ToOwned::to_owned),
  322. },
  323. "config" => Self::Config {
  324. section: parts.next().map(ToOwned::to_owned),
  325. },
  326. "memory" => Self::Memory,
  327. "init" => Self::Init,
  328. "diff" => Self::Diff,
  329. "version" => Self::Version,
  330. "export" => Self::Export {
  331. path: parts.next().map(ToOwned::to_owned),
  332. },
  333. "session" => Self::Session {
  334. action: parts.next().map(ToOwned::to_owned),
  335. target: parts.next().map(ToOwned::to_owned),
  336. },
  337. "plugin" | "plugins" | "marketplace" => Self::Plugins {
  338. action: parts.next().map(ToOwned::to_owned),
  339. target: {
  340. let remainder = parts.collect::<Vec<_>>().join(" ");
  341. (!remainder.is_empty()).then_some(remainder)
  342. },
  343. },
  344. "agents" => Self::Agents {
  345. args: remainder_after_command(trimmed, command),
  346. },
  347. "skills" => Self::Skills {
  348. args: remainder_after_command(trimmed, command),
  349. },
  350. other => Self::Unknown(other.to_string()),
  351. })
  352. }
  353. }
  354. fn remainder_after_command(input: &str, command: &str) -> Option<String> {
  355. input
  356. .trim()
  357. .strip_prefix(&format!("/{command}"))
  358. .map(str::trim)
  359. .filter(|value| !value.is_empty())
  360. .map(ToOwned::to_owned)
  361. }
  362. #[must_use]
  363. pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
  364. SLASH_COMMAND_SPECS
  365. }
  366. #[must_use]
  367. pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
  368. slash_command_specs()
  369. .iter()
  370. .filter(|spec| spec.resume_supported)
  371. .collect()
  372. }
  373. #[must_use]
  374. pub fn render_slash_command_help() -> String {
  375. let mut lines = vec![
  376. "Slash commands".to_string(),
  377. " [resume] means the command also works with --resume SESSION.json".to_string(),
  378. ];
  379. for spec in slash_command_specs() {
  380. let name = match spec.argument_hint {
  381. Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
  382. None => format!("/{}", spec.name),
  383. };
  384. let alias_suffix = if spec.aliases.is_empty() {
  385. String::new()
  386. } else {
  387. format!(
  388. " (aliases: {})",
  389. spec.aliases
  390. .iter()
  391. .map(|alias| format!("/{alias}"))
  392. .collect::<Vec<_>>()
  393. .join(", ")
  394. )
  395. };
  396. let resume = if spec.resume_supported {
  397. " [resume]"
  398. } else {
  399. ""
  400. };
  401. lines.push(format!(
  402. " {name:<20} {}{alias_suffix}{resume}",
  403. spec.summary
  404. ));
  405. }
  406. lines.join("\n")
  407. }
  408. #[derive(Debug, Clone, PartialEq, Eq)]
  409. pub struct SlashCommandResult {
  410. pub message: String,
  411. pub session: Session,
  412. }
  413. #[derive(Debug, Clone, PartialEq, Eq)]
  414. pub struct PluginsCommandResult {
  415. pub message: String,
  416. pub reload_runtime: bool,
  417. }
  418. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
  419. enum DefinitionSource {
  420. ProjectCodex,
  421. ProjectClaude,
  422. UserCodexHome,
  423. UserCodex,
  424. UserClaude,
  425. }
  426. impl DefinitionSource {
  427. fn label(self) -> &'static str {
  428. match self {
  429. Self::ProjectCodex => "Project (.codex)",
  430. Self::ProjectClaude => "Project (.claude)",
  431. Self::UserCodexHome => "User ($CODEX_HOME)",
  432. Self::UserCodex => "User (~/.codex)",
  433. Self::UserClaude => "User (~/.claude)",
  434. }
  435. }
  436. }
  437. #[derive(Debug, Clone, PartialEq, Eq)]
  438. struct AgentSummary {
  439. name: String,
  440. description: Option<String>,
  441. model: Option<String>,
  442. reasoning_effort: Option<String>,
  443. source: DefinitionSource,
  444. shadowed_by: Option<DefinitionSource>,
  445. }
  446. #[derive(Debug, Clone, PartialEq, Eq)]
  447. struct SkillSummary {
  448. name: String,
  449. description: Option<String>,
  450. source: DefinitionSource,
  451. shadowed_by: Option<DefinitionSource>,
  452. }
  453. #[allow(clippy::too_many_lines)]
  454. pub fn handle_plugins_slash_command(
  455. action: Option<&str>,
  456. target: Option<&str>,
  457. manager: &mut PluginManager,
  458. ) -> Result<PluginsCommandResult, PluginError> {
  459. match action {
  460. None | Some("list") => Ok(PluginsCommandResult {
  461. message: render_plugins_report(&manager.list_installed_plugins()?),
  462. reload_runtime: false,
  463. }),
  464. Some("install") => {
  465. let Some(target) = target else {
  466. return Ok(PluginsCommandResult {
  467. message: "Usage: /plugins install <path>".to_string(),
  468. reload_runtime: false,
  469. });
  470. };
  471. let install = manager.install(target)?;
  472. let plugin = manager
  473. .list_installed_plugins()?
  474. .into_iter()
  475. .find(|plugin| plugin.metadata.id == install.plugin_id);
  476. Ok(PluginsCommandResult {
  477. message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
  478. reload_runtime: true,
  479. })
  480. }
  481. Some("enable") => {
  482. let Some(target) = target else {
  483. return Ok(PluginsCommandResult {
  484. message: "Usage: /plugins enable <name>".to_string(),
  485. reload_runtime: false,
  486. });
  487. };
  488. let plugin = resolve_plugin_target(manager, target)?;
  489. manager.enable(&plugin.metadata.id)?;
  490. Ok(PluginsCommandResult {
  491. message: format!(
  492. "Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled",
  493. plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
  494. ),
  495. reload_runtime: true,
  496. })
  497. }
  498. Some("disable") => {
  499. let Some(target) = target else {
  500. return Ok(PluginsCommandResult {
  501. message: "Usage: /plugins disable <name>".to_string(),
  502. reload_runtime: false,
  503. });
  504. };
  505. let plugin = resolve_plugin_target(manager, target)?;
  506. manager.disable(&plugin.metadata.id)?;
  507. Ok(PluginsCommandResult {
  508. message: format!(
  509. "Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled",
  510. plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
  511. ),
  512. reload_runtime: true,
  513. })
  514. }
  515. Some("uninstall") => {
  516. let Some(target) = target else {
  517. return Ok(PluginsCommandResult {
  518. message: "Usage: /plugins uninstall <plugin-id>".to_string(),
  519. reload_runtime: false,
  520. });
  521. };
  522. manager.uninstall(target)?;
  523. Ok(PluginsCommandResult {
  524. message: format!("Plugins\n Result uninstalled {target}"),
  525. reload_runtime: true,
  526. })
  527. }
  528. Some("update") => {
  529. let Some(target) = target else {
  530. return Ok(PluginsCommandResult {
  531. message: "Usage: /plugins update <plugin-id>".to_string(),
  532. reload_runtime: false,
  533. });
  534. };
  535. let update = manager.update(target)?;
  536. let plugin = manager
  537. .list_installed_plugins()?
  538. .into_iter()
  539. .find(|plugin| plugin.metadata.id == update.plugin_id);
  540. Ok(PluginsCommandResult {
  541. message: format!(
  542. "Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
  543. update.plugin_id,
  544. plugin
  545. .as_ref()
  546. .map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
  547. update.old_version,
  548. update.new_version,
  549. plugin
  550. .as_ref()
  551. .map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
  552. ),
  553. reload_runtime: true,
  554. })
  555. }
  556. Some(other) => Ok(PluginsCommandResult {
  557. message: format!(
  558. "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
  559. ),
  560. reload_runtime: false,
  561. }),
  562. }
  563. }
  564. pub fn handle_agents_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
  565. if let Some(args) = args.filter(|value| !value.trim().is_empty()) {
  566. return Ok(format!("Usage: /agents\nUnexpected arguments: {args}"));
  567. }
  568. let roots = discover_definition_roots(cwd, "agents");
  569. let agents = load_agents_from_roots(&roots)?;
  570. Ok(render_agents_report(&agents))
  571. }
  572. pub fn handle_skills_slash_command(args: Option<&str>, cwd: &Path) -> std::io::Result<String> {
  573. if let Some(args) = args.filter(|value| !value.trim().is_empty()) {
  574. return Ok(format!("Usage: /skills\nUnexpected arguments: {args}"));
  575. }
  576. let roots = discover_definition_roots(cwd, "skills");
  577. let skills = load_skills_from_roots(&roots)?;
  578. Ok(render_skills_report(&skills))
  579. }
  580. #[must_use]
  581. pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
  582. let mut lines = vec!["Plugins".to_string()];
  583. if plugins.is_empty() {
  584. lines.push(" No plugins installed.".to_string());
  585. return lines.join("\n");
  586. }
  587. for plugin in plugins {
  588. let enabled = if plugin.enabled {
  589. "enabled"
  590. } else {
  591. "disabled"
  592. };
  593. lines.push(format!(
  594. " {name:<20} v{version:<10} {enabled}",
  595. name = plugin.metadata.name,
  596. version = plugin.metadata.version,
  597. ));
  598. }
  599. lines.join("\n")
  600. }
  601. fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
  602. let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
  603. let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
  604. let enabled = plugin.is_some_and(|plugin| plugin.enabled);
  605. format!(
  606. "Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
  607. if enabled { "enabled" } else { "disabled" }
  608. )
  609. }
  610. fn resolve_plugin_target(
  611. manager: &PluginManager,
  612. target: &str,
  613. ) -> Result<PluginSummary, PluginError> {
  614. let mut matches = manager
  615. .list_installed_plugins()?
  616. .into_iter()
  617. .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
  618. .collect::<Vec<_>>();
  619. match matches.len() {
  620. 1 => Ok(matches.remove(0)),
  621. 0 => Err(PluginError::NotFound(format!(
  622. "plugin `{target}` is not installed or discoverable"
  623. ))),
  624. _ => Err(PluginError::InvalidManifest(format!(
  625. "plugin name `{target}` is ambiguous; use the full plugin id"
  626. ))),
  627. }
  628. }
  629. fn discover_definition_roots(cwd: &Path, leaf: &str) -> Vec<(DefinitionSource, PathBuf)> {
  630. let mut roots = Vec::new();
  631. for ancestor in cwd.ancestors() {
  632. push_unique_root(
  633. &mut roots,
  634. DefinitionSource::ProjectCodex,
  635. ancestor.join(".codex").join(leaf),
  636. );
  637. push_unique_root(
  638. &mut roots,
  639. DefinitionSource::ProjectClaude,
  640. ancestor.join(".claude").join(leaf),
  641. );
  642. }
  643. if let Ok(codex_home) = env::var("CODEX_HOME") {
  644. push_unique_root(
  645. &mut roots,
  646. DefinitionSource::UserCodexHome,
  647. PathBuf::from(codex_home).join(leaf),
  648. );
  649. }
  650. if let Some(home) = env::var_os("HOME") {
  651. let home = PathBuf::from(home);
  652. push_unique_root(
  653. &mut roots,
  654. DefinitionSource::UserCodex,
  655. home.join(".codex").join(leaf),
  656. );
  657. push_unique_root(
  658. &mut roots,
  659. DefinitionSource::UserClaude,
  660. home.join(".claude").join(leaf),
  661. );
  662. }
  663. roots
  664. }
  665. fn push_unique_root(
  666. roots: &mut Vec<(DefinitionSource, PathBuf)>,
  667. source: DefinitionSource,
  668. path: PathBuf,
  669. ) {
  670. if path.is_dir() && !roots.iter().any(|(_, existing)| existing == &path) {
  671. roots.push((source, path));
  672. }
  673. }
  674. fn load_agents_from_roots(
  675. roots: &[(DefinitionSource, PathBuf)],
  676. ) -> std::io::Result<Vec<AgentSummary>> {
  677. let mut agents = Vec::new();
  678. let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
  679. for (source, root) in roots {
  680. let mut root_agents = Vec::new();
  681. for entry in fs::read_dir(root)? {
  682. let entry = entry?;
  683. if entry.path().extension().is_none_or(|ext| ext != "toml") {
  684. continue;
  685. }
  686. let contents = fs::read_to_string(entry.path())?;
  687. let fallback_name = entry.path().file_stem().map_or_else(
  688. || entry.file_name().to_string_lossy().to_string(),
  689. |stem| stem.to_string_lossy().to_string(),
  690. );
  691. root_agents.push(AgentSummary {
  692. name: parse_toml_string(&contents, "name").unwrap_or(fallback_name),
  693. description: parse_toml_string(&contents, "description"),
  694. model: parse_toml_string(&contents, "model"),
  695. reasoning_effort: parse_toml_string(&contents, "model_reasoning_effort"),
  696. source: *source,
  697. shadowed_by: None,
  698. });
  699. }
  700. root_agents.sort_by(|left, right| left.name.cmp(&right.name));
  701. for mut agent in root_agents {
  702. let key = agent.name.to_ascii_lowercase();
  703. if let Some(existing) = active_sources.get(&key) {
  704. agent.shadowed_by = Some(*existing);
  705. } else {
  706. active_sources.insert(key, agent.source);
  707. }
  708. agents.push(agent);
  709. }
  710. }
  711. Ok(agents)
  712. }
  713. fn load_skills_from_roots(
  714. roots: &[(DefinitionSource, PathBuf)],
  715. ) -> std::io::Result<Vec<SkillSummary>> {
  716. let mut skills = Vec::new();
  717. let mut active_sources = BTreeMap::<String, DefinitionSource>::new();
  718. for (source, root) in roots {
  719. let mut root_skills = Vec::new();
  720. for entry in fs::read_dir(root)? {
  721. let entry = entry?;
  722. if !entry.path().is_dir() {
  723. continue;
  724. }
  725. let skill_path = entry.path().join("SKILL.md");
  726. if !skill_path.is_file() {
  727. continue;
  728. }
  729. let contents = fs::read_to_string(skill_path)?;
  730. let (name, description) = parse_skill_frontmatter(&contents);
  731. root_skills.push(SkillSummary {
  732. name: name.unwrap_or_else(|| entry.file_name().to_string_lossy().to_string()),
  733. description,
  734. source: *source,
  735. shadowed_by: None,
  736. });
  737. }
  738. root_skills.sort_by(|left, right| left.name.cmp(&right.name));
  739. for mut skill in root_skills {
  740. let key = skill.name.to_ascii_lowercase();
  741. if let Some(existing) = active_sources.get(&key) {
  742. skill.shadowed_by = Some(*existing);
  743. } else {
  744. active_sources.insert(key, skill.source);
  745. }
  746. skills.push(skill);
  747. }
  748. }
  749. Ok(skills)
  750. }
  751. fn parse_toml_string(contents: &str, key: &str) -> Option<String> {
  752. let prefix = format!("{key} =");
  753. for line in contents.lines() {
  754. let trimmed = line.trim();
  755. if trimmed.starts_with('#') {
  756. continue;
  757. }
  758. let Some(value) = trimmed.strip_prefix(&prefix) else {
  759. continue;
  760. };
  761. let value = value.trim();
  762. let Some(value) = value
  763. .strip_prefix('"')
  764. .and_then(|value| value.strip_suffix('"'))
  765. else {
  766. continue;
  767. };
  768. if !value.is_empty() {
  769. return Some(value.to_string());
  770. }
  771. }
  772. None
  773. }
  774. fn parse_skill_frontmatter(contents: &str) -> (Option<String>, Option<String>) {
  775. let mut lines = contents.lines();
  776. if lines.next().map(str::trim) != Some("---") {
  777. return (None, None);
  778. }
  779. let mut name = None;
  780. let mut description = None;
  781. for line in lines {
  782. let trimmed = line.trim();
  783. if trimmed == "---" {
  784. break;
  785. }
  786. if let Some(value) = trimmed.strip_prefix("name:") {
  787. let value = value.trim();
  788. if !value.is_empty() {
  789. name = Some(value.to_string());
  790. }
  791. continue;
  792. }
  793. if let Some(value) = trimmed.strip_prefix("description:") {
  794. let value = value.trim();
  795. if !value.is_empty() {
  796. description = Some(value.to_string());
  797. }
  798. }
  799. }
  800. (name, description)
  801. }
  802. fn render_agents_report(agents: &[AgentSummary]) -> String {
  803. if agents.is_empty() {
  804. return "No agents found.".to_string();
  805. }
  806. let total_active = agents
  807. .iter()
  808. .filter(|agent| agent.shadowed_by.is_none())
  809. .count();
  810. let mut lines = vec![
  811. "Agents".to_string(),
  812. format!(" {total_active} active agents"),
  813. String::new(),
  814. ];
  815. for source in [
  816. DefinitionSource::ProjectCodex,
  817. DefinitionSource::ProjectClaude,
  818. DefinitionSource::UserCodexHome,
  819. DefinitionSource::UserCodex,
  820. DefinitionSource::UserClaude,
  821. ] {
  822. let group = agents
  823. .iter()
  824. .filter(|agent| agent.source == source)
  825. .collect::<Vec<_>>();
  826. if group.is_empty() {
  827. continue;
  828. }
  829. lines.push(format!("{}:", source.label()));
  830. for agent in group {
  831. let detail = agent_detail(agent);
  832. match agent.shadowed_by {
  833. Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
  834. None => lines.push(format!(" {detail}")),
  835. }
  836. }
  837. lines.push(String::new());
  838. }
  839. lines.join("\n").trim_end().to_string()
  840. }
  841. fn agent_detail(agent: &AgentSummary) -> String {
  842. let mut parts = vec![agent.name.clone()];
  843. if let Some(description) = &agent.description {
  844. parts.push(description.clone());
  845. }
  846. if let Some(model) = &agent.model {
  847. parts.push(model.clone());
  848. }
  849. if let Some(reasoning) = &agent.reasoning_effort {
  850. parts.push(reasoning.clone());
  851. }
  852. parts.join(" · ")
  853. }
  854. fn render_skills_report(skills: &[SkillSummary]) -> String {
  855. if skills.is_empty() {
  856. return "No skills found.".to_string();
  857. }
  858. let total_active = skills
  859. .iter()
  860. .filter(|skill| skill.shadowed_by.is_none())
  861. .count();
  862. let mut lines = vec![
  863. "Skills".to_string(),
  864. format!(" {total_active} available skills"),
  865. String::new(),
  866. ];
  867. for source in [
  868. DefinitionSource::ProjectCodex,
  869. DefinitionSource::ProjectClaude,
  870. DefinitionSource::UserCodexHome,
  871. DefinitionSource::UserCodex,
  872. DefinitionSource::UserClaude,
  873. ] {
  874. let group = skills
  875. .iter()
  876. .filter(|skill| skill.source == source)
  877. .collect::<Vec<_>>();
  878. if group.is_empty() {
  879. continue;
  880. }
  881. lines.push(format!("{}:", source.label()));
  882. for skill in group {
  883. let detail = match &skill.description {
  884. Some(description) => format!("{} · {}", skill.name, description),
  885. None => skill.name.clone(),
  886. };
  887. match skill.shadowed_by {
  888. Some(winner) => lines.push(format!(" (shadowed by {}) {detail}", winner.label())),
  889. None => lines.push(format!(" {detail}")),
  890. }
  891. }
  892. lines.push(String::new());
  893. }
  894. lines.join("\n").trim_end().to_string()
  895. }
  896. #[must_use]
  897. pub fn handle_slash_command(
  898. input: &str,
  899. session: &Session,
  900. compaction: CompactionConfig,
  901. ) -> Option<SlashCommandResult> {
  902. match SlashCommand::parse(input)? {
  903. SlashCommand::Compact => {
  904. let result = compact_session(session, compaction);
  905. let message = if result.removed_message_count == 0 {
  906. "Compaction skipped: session is below the compaction threshold.".to_string()
  907. } else {
  908. format!(
  909. "Compacted {} messages into a resumable system summary.",
  910. result.removed_message_count
  911. )
  912. };
  913. Some(SlashCommandResult {
  914. message,
  915. session: result.compacted_session,
  916. })
  917. }
  918. SlashCommand::Help => Some(SlashCommandResult {
  919. message: render_slash_command_help(),
  920. session: session.clone(),
  921. }),
  922. SlashCommand::Status
  923. | SlashCommand::Bughunter { .. }
  924. | SlashCommand::Commit
  925. | SlashCommand::Pr { .. }
  926. | SlashCommand::Issue { .. }
  927. | SlashCommand::Ultraplan { .. }
  928. | SlashCommand::Teleport { .. }
  929. | SlashCommand::DebugToolCall
  930. | SlashCommand::Model { .. }
  931. | SlashCommand::Permissions { .. }
  932. | SlashCommand::Clear { .. }
  933. | SlashCommand::Cost
  934. | SlashCommand::Resume { .. }
  935. | SlashCommand::Config { .. }
  936. | SlashCommand::Memory
  937. | SlashCommand::Init
  938. | SlashCommand::Diff
  939. | SlashCommand::Version
  940. | SlashCommand::Export { .. }
  941. | SlashCommand::Session { .. }
  942. | SlashCommand::Plugins { .. }
  943. | SlashCommand::Agents { .. }
  944. | SlashCommand::Skills { .. }
  945. | SlashCommand::Unknown(_) => None,
  946. }
  947. }
  948. #[cfg(test)]
  949. mod tests {
  950. use super::{
  951. handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
  952. load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
  953. render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
  954. DefinitionSource, SlashCommand,
  955. };
  956. use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
  957. use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
  958. use std::fs;
  959. use std::path::{Path, PathBuf};
  960. use std::time::{SystemTime, UNIX_EPOCH};
  961. fn temp_dir(label: &str) -> PathBuf {
  962. let nanos = SystemTime::now()
  963. .duration_since(UNIX_EPOCH)
  964. .expect("time should be after epoch")
  965. .as_nanos();
  966. std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
  967. }
  968. fn write_external_plugin(root: &Path, name: &str, version: &str) {
  969. fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
  970. fs::write(
  971. root.join(".claude-plugin").join("plugin.json"),
  972. format!(
  973. "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
  974. ),
  975. )
  976. .expect("write manifest");
  977. }
  978. fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
  979. fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
  980. fs::write(
  981. root.join(".claude-plugin").join("plugin.json"),
  982. format!(
  983. "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}",
  984. if default_enabled { "true" } else { "false" }
  985. ),
  986. )
  987. .expect("write bundled manifest");
  988. }
  989. fn write_agent(root: &Path, name: &str, description: &str, model: &str, reasoning: &str) {
  990. fs::create_dir_all(root).expect("agent root");
  991. fs::write(
  992. root.join(format!("{name}.toml")),
  993. format!(
  994. "name = \"{name}\"\ndescription = \"{description}\"\nmodel = \"{model}\"\nmodel_reasoning_effort = \"{reasoning}\"\n"
  995. ),
  996. )
  997. .expect("write agent");
  998. }
  999. fn write_skill(root: &Path, name: &str, description: &str) {
  1000. let skill_root = root.join(name);
  1001. fs::create_dir_all(&skill_root).expect("skill root");
  1002. fs::write(
  1003. skill_root.join("SKILL.md"),
  1004. format!("---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n"),
  1005. )
  1006. .expect("write skill");
  1007. }
  1008. #[allow(clippy::too_many_lines)]
  1009. #[test]
  1010. fn parses_supported_slash_commands() {
  1011. assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
  1012. assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
  1013. assert_eq!(
  1014. SlashCommand::parse("/bughunter runtime"),
  1015. Some(SlashCommand::Bughunter {
  1016. scope: Some("runtime".to_string())
  1017. })
  1018. );
  1019. assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
  1020. assert_eq!(
  1021. SlashCommand::parse("/pr ready for review"),
  1022. Some(SlashCommand::Pr {
  1023. context: Some("ready for review".to_string())
  1024. })
  1025. );
  1026. assert_eq!(
  1027. SlashCommand::parse("/issue flaky test"),
  1028. Some(SlashCommand::Issue {
  1029. context: Some("flaky test".to_string())
  1030. })
  1031. );
  1032. assert_eq!(
  1033. SlashCommand::parse("/ultraplan ship both features"),
  1034. Some(SlashCommand::Ultraplan {
  1035. task: Some("ship both features".to_string())
  1036. })
  1037. );
  1038. assert_eq!(
  1039. SlashCommand::parse("/teleport conversation.rs"),
  1040. Some(SlashCommand::Teleport {
  1041. target: Some("conversation.rs".to_string())
  1042. })
  1043. );
  1044. assert_eq!(
  1045. SlashCommand::parse("/debug-tool-call"),
  1046. Some(SlashCommand::DebugToolCall)
  1047. );
  1048. assert_eq!(
  1049. SlashCommand::parse("/model claude-opus"),
  1050. Some(SlashCommand::Model {
  1051. model: Some("claude-opus".to_string()),
  1052. })
  1053. );
  1054. assert_eq!(
  1055. SlashCommand::parse("/model"),
  1056. Some(SlashCommand::Model { model: None })
  1057. );
  1058. assert_eq!(
  1059. SlashCommand::parse("/permissions read-only"),
  1060. Some(SlashCommand::Permissions {
  1061. mode: Some("read-only".to_string()),
  1062. })
  1063. );
  1064. assert_eq!(
  1065. SlashCommand::parse("/clear"),
  1066. Some(SlashCommand::Clear { confirm: false })
  1067. );
  1068. assert_eq!(
  1069. SlashCommand::parse("/clear --confirm"),
  1070. Some(SlashCommand::Clear { confirm: true })
  1071. );
  1072. assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
  1073. assert_eq!(
  1074. SlashCommand::parse("/resume session.json"),
  1075. Some(SlashCommand::Resume {
  1076. session_path: Some("session.json".to_string()),
  1077. })
  1078. );
  1079. assert_eq!(
  1080. SlashCommand::parse("/config"),
  1081. Some(SlashCommand::Config { section: None })
  1082. );
  1083. assert_eq!(
  1084. SlashCommand::parse("/config env"),
  1085. Some(SlashCommand::Config {
  1086. section: Some("env".to_string())
  1087. })
  1088. );
  1089. assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
  1090. assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
  1091. assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
  1092. assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
  1093. assert_eq!(
  1094. SlashCommand::parse("/export notes.txt"),
  1095. Some(SlashCommand::Export {
  1096. path: Some("notes.txt".to_string())
  1097. })
  1098. );
  1099. assert_eq!(
  1100. SlashCommand::parse("/session switch abc123"),
  1101. Some(SlashCommand::Session {
  1102. action: Some("switch".to_string()),
  1103. target: Some("abc123".to_string())
  1104. })
  1105. );
  1106. assert_eq!(
  1107. SlashCommand::parse("/plugins install demo"),
  1108. Some(SlashCommand::Plugins {
  1109. action: Some("install".to_string()),
  1110. target: Some("demo".to_string())
  1111. })
  1112. );
  1113. assert_eq!(
  1114. SlashCommand::parse("/plugins list"),
  1115. Some(SlashCommand::Plugins {
  1116. action: Some("list".to_string()),
  1117. target: None
  1118. })
  1119. );
  1120. assert_eq!(
  1121. SlashCommand::parse("/plugins enable demo"),
  1122. Some(SlashCommand::Plugins {
  1123. action: Some("enable".to_string()),
  1124. target: Some("demo".to_string())
  1125. })
  1126. );
  1127. assert_eq!(
  1128. SlashCommand::parse("/plugins disable demo"),
  1129. Some(SlashCommand::Plugins {
  1130. action: Some("disable".to_string()),
  1131. target: Some("demo".to_string())
  1132. })
  1133. );
  1134. }
  1135. #[test]
  1136. fn renders_help_from_shared_specs() {
  1137. let help = render_slash_command_help();
  1138. assert!(help.contains("works with --resume SESSION.json"));
  1139. assert!(help.contains("/help"));
  1140. assert!(help.contains("/status"));
  1141. assert!(help.contains("/compact"));
  1142. assert!(help.contains("/bughunter [scope]"));
  1143. assert!(help.contains("/commit"));
  1144. assert!(help.contains("/pr [context]"));
  1145. assert!(help.contains("/issue [context]"));
  1146. assert!(help.contains("/ultraplan [task]"));
  1147. assert!(help.contains("/teleport <symbol-or-path>"));
  1148. assert!(help.contains("/debug-tool-call"));
  1149. assert!(help.contains("/model [model]"));
  1150. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  1151. assert!(help.contains("/clear [--confirm]"));
  1152. assert!(help.contains("/cost"));
  1153. assert!(help.contains("/resume <session-path>"));
  1154. assert!(help.contains("/config [env|hooks|model|plugins]"));
  1155. assert!(help.contains("/memory"));
  1156. assert!(help.contains("/init"));
  1157. assert!(help.contains("/diff"));
  1158. assert!(help.contains("/version"));
  1159. assert!(help.contains("/export [file]"));
  1160. assert!(help.contains("/session [list|switch <session-id>]"));
  1161. assert!(help.contains(
  1162. "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
  1163. ));
  1164. assert!(help.contains("aliases: /plugins, /marketplace"));
  1165. assert!(help.contains("/agents"));
  1166. assert!(help.contains("/skills"));
  1167. assert_eq!(slash_command_specs().len(), 25);
  1168. assert_eq!(resume_supported_slash_commands().len(), 11);
  1169. }
  1170. #[test]
  1171. fn compacts_sessions_via_slash_command() {
  1172. let session = Session {
  1173. version: 1,
  1174. messages: vec![
  1175. ConversationMessage::user_text("a ".repeat(200)),
  1176. ConversationMessage::assistant(vec![ContentBlock::Text {
  1177. text: "b ".repeat(200),
  1178. }]),
  1179. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  1180. ConversationMessage::assistant(vec![ContentBlock::Text {
  1181. text: "recent".to_string(),
  1182. }]),
  1183. ],
  1184. };
  1185. let result = handle_slash_command(
  1186. "/compact",
  1187. &session,
  1188. CompactionConfig {
  1189. preserve_recent_messages: 2,
  1190. max_estimated_tokens: 1,
  1191. },
  1192. )
  1193. .expect("slash command should be handled");
  1194. assert!(result.message.contains("Compacted 2 messages"));
  1195. assert_eq!(result.session.messages[0].role, MessageRole::System);
  1196. }
  1197. #[test]
  1198. fn help_command_is_non_mutating() {
  1199. let session = Session::new();
  1200. let result = handle_slash_command("/help", &session, CompactionConfig::default())
  1201. .expect("help command should be handled");
  1202. assert_eq!(result.session, session);
  1203. assert!(result.message.contains("Slash commands"));
  1204. }
  1205. #[test]
  1206. fn ignores_unknown_or_runtime_bound_slash_commands() {
  1207. let session = Session::new();
  1208. assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
  1209. assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
  1210. assert!(
  1211. handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
  1212. );
  1213. assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
  1214. assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
  1215. assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
  1216. assert!(
  1217. handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
  1218. );
  1219. assert!(
  1220. handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
  1221. );
  1222. assert!(
  1223. handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
  1224. .is_none()
  1225. );
  1226. assert!(
  1227. handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
  1228. );
  1229. assert!(handle_slash_command(
  1230. "/permissions read-only",
  1231. &session,
  1232. CompactionConfig::default()
  1233. )
  1234. .is_none());
  1235. assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
  1236. assert!(
  1237. handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
  1238. .is_none()
  1239. );
  1240. assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
  1241. assert!(handle_slash_command(
  1242. "/resume session.json",
  1243. &session,
  1244. CompactionConfig::default()
  1245. )
  1246. .is_none());
  1247. assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
  1248. assert!(
  1249. handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
  1250. );
  1251. assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
  1252. assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
  1253. assert!(
  1254. handle_slash_command("/export note.txt", &session, CompactionConfig::default())
  1255. .is_none()
  1256. );
  1257. assert!(
  1258. handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
  1259. );
  1260. assert!(
  1261. handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
  1262. );
  1263. }
  1264. #[test]
  1265. fn renders_plugins_report_with_name_version_and_status() {
  1266. let rendered = render_plugins_report(&[
  1267. PluginSummary {
  1268. metadata: PluginMetadata {
  1269. id: "demo@external".to_string(),
  1270. name: "demo".to_string(),
  1271. version: "1.2.3".to_string(),
  1272. description: "demo plugin".to_string(),
  1273. kind: PluginKind::External,
  1274. source: "demo".to_string(),
  1275. default_enabled: false,
  1276. root: None,
  1277. },
  1278. enabled: true,
  1279. },
  1280. PluginSummary {
  1281. metadata: PluginMetadata {
  1282. id: "sample@external".to_string(),
  1283. name: "sample".to_string(),
  1284. version: "0.9.0".to_string(),
  1285. description: "sample plugin".to_string(),
  1286. kind: PluginKind::External,
  1287. source: "sample".to_string(),
  1288. default_enabled: false,
  1289. root: None,
  1290. },
  1291. enabled: false,
  1292. },
  1293. ]);
  1294. assert!(rendered.contains("demo"));
  1295. assert!(rendered.contains("v1.2.3"));
  1296. assert!(rendered.contains("enabled"));
  1297. assert!(rendered.contains("sample"));
  1298. assert!(rendered.contains("v0.9.0"));
  1299. assert!(rendered.contains("disabled"));
  1300. }
  1301. #[test]
  1302. fn lists_agents_from_project_and_user_roots() {
  1303. let workspace = temp_dir("agents-workspace");
  1304. let project_agents = workspace.join(".codex").join("agents");
  1305. let user_home = temp_dir("agents-home");
  1306. let user_agents = user_home.join(".codex").join("agents");
  1307. write_agent(
  1308. &project_agents,
  1309. "planner",
  1310. "Project planner",
  1311. "gpt-5.4",
  1312. "medium",
  1313. );
  1314. write_agent(
  1315. &user_agents,
  1316. "planner",
  1317. "User planner",
  1318. "gpt-5.4-mini",
  1319. "high",
  1320. );
  1321. write_agent(
  1322. &user_agents,
  1323. "verifier",
  1324. "Verification agent",
  1325. "gpt-5.4-mini",
  1326. "high",
  1327. );
  1328. let roots = vec![
  1329. (DefinitionSource::ProjectCodex, project_agents),
  1330. (DefinitionSource::UserCodex, user_agents),
  1331. ];
  1332. let report =
  1333. render_agents_report(&load_agents_from_roots(&roots).expect("agent roots should load"));
  1334. assert!(report.contains("Agents"));
  1335. assert!(report.contains("2 active agents"));
  1336. assert!(report.contains("Project (.codex):"));
  1337. assert!(report.contains("planner · Project planner · gpt-5.4 · medium"));
  1338. assert!(report.contains("User (~/.codex):"));
  1339. assert!(report.contains("(shadowed by Project (.codex)) planner · User planner"));
  1340. assert!(report.contains("verifier · Verification agent · gpt-5.4-mini · high"));
  1341. let _ = fs::remove_dir_all(workspace);
  1342. let _ = fs::remove_dir_all(user_home);
  1343. }
  1344. #[test]
  1345. fn lists_skills_from_project_and_user_roots() {
  1346. let workspace = temp_dir("skills-workspace");
  1347. let project_skills = workspace.join(".codex").join("skills");
  1348. let user_home = temp_dir("skills-home");
  1349. let user_skills = user_home.join(".codex").join("skills");
  1350. write_skill(&project_skills, "plan", "Project planning guidance");
  1351. write_skill(&user_skills, "plan", "User planning guidance");
  1352. write_skill(&user_skills, "help", "Help guidance");
  1353. let roots = vec![
  1354. (DefinitionSource::ProjectCodex, project_skills),
  1355. (DefinitionSource::UserCodex, user_skills),
  1356. ];
  1357. let report =
  1358. render_skills_report(&load_skills_from_roots(&roots).expect("skill roots should load"));
  1359. assert!(report.contains("Skills"));
  1360. assert!(report.contains("2 available skills"));
  1361. assert!(report.contains("Project (.codex):"));
  1362. assert!(report.contains("plan · Project planning guidance"));
  1363. assert!(report.contains("User (~/.codex):"));
  1364. assert!(report.contains("(shadowed by Project (.codex)) plan · User planning guidance"));
  1365. assert!(report.contains("help · Help guidance"));
  1366. let _ = fs::remove_dir_all(workspace);
  1367. let _ = fs::remove_dir_all(user_home);
  1368. }
  1369. #[test]
  1370. fn installs_plugin_from_path_and_lists_it() {
  1371. let config_home = temp_dir("home");
  1372. let source_root = temp_dir("source");
  1373. write_external_plugin(&source_root, "demo", "1.0.0");
  1374. let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
  1375. let install = handle_plugins_slash_command(
  1376. Some("install"),
  1377. Some(source_root.to_str().expect("utf8 path")),
  1378. &mut manager,
  1379. )
  1380. .expect("install command should succeed");
  1381. assert!(install.reload_runtime);
  1382. assert!(install.message.contains("installed demo@external"));
  1383. assert!(install.message.contains("Name demo"));
  1384. assert!(install.message.contains("Version 1.0.0"));
  1385. assert!(install.message.contains("Status enabled"));
  1386. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  1387. .expect("list command should succeed");
  1388. assert!(!list.reload_runtime);
  1389. assert!(list.message.contains("demo"));
  1390. assert!(list.message.contains("v1.0.0"));
  1391. assert!(list.message.contains("enabled"));
  1392. let _ = fs::remove_dir_all(config_home);
  1393. let _ = fs::remove_dir_all(source_root);
  1394. }
  1395. #[test]
  1396. fn enables_and_disables_plugin_by_name() {
  1397. let config_home = temp_dir("toggle-home");
  1398. let source_root = temp_dir("toggle-source");
  1399. write_external_plugin(&source_root, "demo", "1.0.0");
  1400. let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
  1401. handle_plugins_slash_command(
  1402. Some("install"),
  1403. Some(source_root.to_str().expect("utf8 path")),
  1404. &mut manager,
  1405. )
  1406. .expect("install command should succeed");
  1407. let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
  1408. .expect("disable command should succeed");
  1409. assert!(disable.reload_runtime);
  1410. assert!(disable.message.contains("disabled demo@external"));
  1411. assert!(disable.message.contains("Name demo"));
  1412. assert!(disable.message.contains("Status disabled"));
  1413. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  1414. .expect("list command should succeed");
  1415. assert!(list.message.contains("demo"));
  1416. assert!(list.message.contains("disabled"));
  1417. let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
  1418. .expect("enable command should succeed");
  1419. assert!(enable.reload_runtime);
  1420. assert!(enable.message.contains("enabled demo@external"));
  1421. assert!(enable.message.contains("Name demo"));
  1422. assert!(enable.message.contains("Status enabled"));
  1423. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  1424. .expect("list command should succeed");
  1425. assert!(list.message.contains("demo"));
  1426. assert!(list.message.contains("enabled"));
  1427. let _ = fs::remove_dir_all(config_home);
  1428. let _ = fs::remove_dir_all(source_root);
  1429. }
  1430. #[test]
  1431. fn lists_auto_installed_bundled_plugins_with_status() {
  1432. let config_home = temp_dir("bundled-home");
  1433. let bundled_root = temp_dir("bundled-root");
  1434. let bundled_plugin = bundled_root.join("starter");
  1435. write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
  1436. let mut config = PluginManagerConfig::new(&config_home);
  1437. config.bundled_root = Some(bundled_root.clone());
  1438. let mut manager = PluginManager::new(config);
  1439. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  1440. .expect("list command should succeed");
  1441. assert!(!list.reload_runtime);
  1442. assert!(list.message.contains("starter"));
  1443. assert!(list.message.contains("v0.1.0"));
  1444. assert!(list.message.contains("disabled"));
  1445. let _ = fs::remove_dir_all(config_home);
  1446. let _ = fs::remove_dir_all(bundled_root);
  1447. }
  1448. }