lib.rs 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016
  1. use plugins::{PluginError, PluginManager, PluginSummary};
  2. use runtime::{compact_session, CompactionConfig, Session};
  3. #[derive(Debug, Clone, PartialEq, Eq)]
  4. pub struct CommandManifestEntry {
  5. pub name: String,
  6. pub source: CommandSource,
  7. }
  8. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  9. pub enum CommandSource {
  10. Builtin,
  11. InternalOnly,
  12. FeatureGated,
  13. }
  14. #[derive(Debug, Clone, Default, PartialEq, Eq)]
  15. pub struct CommandRegistry {
  16. entries: Vec<CommandManifestEntry>,
  17. }
  18. impl CommandRegistry {
  19. #[must_use]
  20. pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
  21. Self { entries }
  22. }
  23. #[must_use]
  24. pub fn entries(&self) -> &[CommandManifestEntry] {
  25. &self.entries
  26. }
  27. }
  28. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  29. pub struct SlashCommandSpec {
  30. pub name: &'static str,
  31. pub summary: &'static str,
  32. pub argument_hint: Option<&'static str>,
  33. pub resume_supported: bool,
  34. }
  35. const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
  36. SlashCommandSpec {
  37. name: "help",
  38. summary: "Show available slash commands",
  39. argument_hint: None,
  40. resume_supported: true,
  41. },
  42. SlashCommandSpec {
  43. name: "status",
  44. summary: "Show current session status",
  45. argument_hint: None,
  46. resume_supported: true,
  47. },
  48. SlashCommandSpec {
  49. name: "compact",
  50. summary: "Compact local session history",
  51. argument_hint: None,
  52. resume_supported: true,
  53. },
  54. SlashCommandSpec {
  55. name: "model",
  56. summary: "Show or switch the active model",
  57. argument_hint: Some("[model]"),
  58. resume_supported: false,
  59. },
  60. SlashCommandSpec {
  61. name: "permissions",
  62. summary: "Show or switch the active permission mode",
  63. argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
  64. resume_supported: false,
  65. },
  66. SlashCommandSpec {
  67. name: "clear",
  68. summary: "Start a fresh local session",
  69. argument_hint: Some("[--confirm]"),
  70. resume_supported: true,
  71. },
  72. SlashCommandSpec {
  73. name: "cost",
  74. summary: "Show cumulative token usage for this session",
  75. argument_hint: None,
  76. resume_supported: true,
  77. },
  78. SlashCommandSpec {
  79. name: "resume",
  80. summary: "Load a saved session into the REPL",
  81. argument_hint: Some("<session-path>"),
  82. resume_supported: false,
  83. },
  84. SlashCommandSpec {
  85. name: "config",
  86. summary: "Inspect Claude config files or merged sections",
  87. argument_hint: Some("[env|hooks|model|plugins]"),
  88. resume_supported: true,
  89. },
  90. SlashCommandSpec {
  91. name: "memory",
  92. summary: "Inspect loaded Claude instruction memory files",
  93. argument_hint: None,
  94. resume_supported: true,
  95. },
  96. SlashCommandSpec {
  97. name: "init",
  98. summary: "Create a starter CLAUDE.md for this repo",
  99. argument_hint: None,
  100. resume_supported: true,
  101. },
  102. SlashCommandSpec {
  103. name: "diff",
  104. summary: "Show git diff for current workspace changes",
  105. argument_hint: None,
  106. resume_supported: true,
  107. },
  108. SlashCommandSpec {
  109. name: "version",
  110. summary: "Show CLI version and build information",
  111. argument_hint: None,
  112. resume_supported: true,
  113. },
  114. SlashCommandSpec {
  115. name: "bughunter",
  116. summary: "Inspect the codebase for likely bugs",
  117. argument_hint: Some("[scope]"),
  118. resume_supported: false,
  119. },
  120. SlashCommandSpec {
  121. name: "commit",
  122. summary: "Generate a commit message and create a git commit",
  123. argument_hint: None,
  124. resume_supported: false,
  125. },
  126. SlashCommandSpec {
  127. name: "pr",
  128. summary: "Draft or create a pull request from the conversation",
  129. argument_hint: Some("[context]"),
  130. resume_supported: false,
  131. },
  132. SlashCommandSpec {
  133. name: "issue",
  134. summary: "Draft or create a GitHub issue from the conversation",
  135. argument_hint: Some("[context]"),
  136. resume_supported: false,
  137. },
  138. SlashCommandSpec {
  139. name: "ultraplan",
  140. summary: "Run a deep planning prompt with multi-step reasoning",
  141. argument_hint: Some("[task]"),
  142. resume_supported: false,
  143. },
  144. SlashCommandSpec {
  145. name: "teleport",
  146. summary: "Jump to a file or symbol by searching the workspace",
  147. argument_hint: Some("<symbol-or-path>"),
  148. resume_supported: false,
  149. },
  150. SlashCommandSpec {
  151. name: "debug-tool-call",
  152. summary: "Replay the last tool call with debug details",
  153. argument_hint: None,
  154. resume_supported: false,
  155. },
  156. SlashCommandSpec {
  157. name: "export",
  158. summary: "Export the current conversation to a file",
  159. argument_hint: Some("[file]"),
  160. resume_supported: true,
  161. },
  162. SlashCommandSpec {
  163. name: "session",
  164. summary: "List or switch managed local sessions",
  165. argument_hint: Some("[list|switch <session-id>]"),
  166. resume_supported: false,
  167. },
  168. SlashCommandSpec {
  169. name: "plugins",
  170. summary: "List or manage plugins",
  171. argument_hint: Some(
  172. "[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
  173. ),
  174. resume_supported: false,
  175. },
  176. ];
  177. #[derive(Debug, Clone, PartialEq, Eq)]
  178. pub enum SlashCommand {
  179. Help,
  180. Status,
  181. Compact,
  182. Bughunter {
  183. scope: Option<String>,
  184. },
  185. Commit,
  186. Pr {
  187. context: Option<String>,
  188. },
  189. Issue {
  190. context: Option<String>,
  191. },
  192. Ultraplan {
  193. task: Option<String>,
  194. },
  195. Teleport {
  196. target: Option<String>,
  197. },
  198. DebugToolCall,
  199. Model {
  200. model: Option<String>,
  201. },
  202. Permissions {
  203. mode: Option<String>,
  204. },
  205. Clear {
  206. confirm: bool,
  207. },
  208. Cost,
  209. Resume {
  210. session_path: Option<String>,
  211. },
  212. Config {
  213. section: Option<String>,
  214. },
  215. Memory,
  216. Init,
  217. Diff,
  218. Version,
  219. Export {
  220. path: Option<String>,
  221. },
  222. Session {
  223. action: Option<String>,
  224. target: Option<String>,
  225. },
  226. Plugins {
  227. action: Option<String>,
  228. target: Option<String>,
  229. },
  230. Unknown(String),
  231. }
  232. impl SlashCommand {
  233. #[must_use]
  234. pub fn parse(input: &str) -> Option<Self> {
  235. let trimmed = input.trim();
  236. if !trimmed.starts_with('/') {
  237. return None;
  238. }
  239. let mut parts = trimmed.trim_start_matches('/').split_whitespace();
  240. let command = parts.next().unwrap_or_default();
  241. Some(match command {
  242. "help" => Self::Help,
  243. "status" => Self::Status,
  244. "compact" => Self::Compact,
  245. "bughunter" => Self::Bughunter {
  246. scope: remainder_after_command(trimmed, command),
  247. },
  248. "commit" => Self::Commit,
  249. "pr" => Self::Pr {
  250. context: remainder_after_command(trimmed, command),
  251. },
  252. "issue" => Self::Issue {
  253. context: remainder_after_command(trimmed, command),
  254. },
  255. "ultraplan" => Self::Ultraplan {
  256. task: remainder_after_command(trimmed, command),
  257. },
  258. "teleport" => Self::Teleport {
  259. target: remainder_after_command(trimmed, command),
  260. },
  261. "debug-tool-call" => Self::DebugToolCall,
  262. "model" => Self::Model {
  263. model: parts.next().map(ToOwned::to_owned),
  264. },
  265. "permissions" => Self::Permissions {
  266. mode: parts.next().map(ToOwned::to_owned),
  267. },
  268. "clear" => Self::Clear {
  269. confirm: parts.next() == Some("--confirm"),
  270. },
  271. "cost" => Self::Cost,
  272. "resume" => Self::Resume {
  273. session_path: parts.next().map(ToOwned::to_owned),
  274. },
  275. "config" => Self::Config {
  276. section: parts.next().map(ToOwned::to_owned),
  277. },
  278. "memory" => Self::Memory,
  279. "init" => Self::Init,
  280. "diff" => Self::Diff,
  281. "version" => Self::Version,
  282. "export" => Self::Export {
  283. path: parts.next().map(ToOwned::to_owned),
  284. },
  285. "session" => Self::Session {
  286. action: parts.next().map(ToOwned::to_owned),
  287. target: parts.next().map(ToOwned::to_owned),
  288. },
  289. "plugins" => Self::Plugins {
  290. action: parts.next().map(ToOwned::to_owned),
  291. target: {
  292. let remainder = parts.collect::<Vec<_>>().join(" ");
  293. (!remainder.is_empty()).then_some(remainder)
  294. },
  295. },
  296. other => Self::Unknown(other.to_string()),
  297. })
  298. }
  299. }
  300. fn remainder_after_command(input: &str, command: &str) -> Option<String> {
  301. input
  302. .trim()
  303. .strip_prefix(&format!("/{command}"))
  304. .map(str::trim)
  305. .filter(|value| !value.is_empty())
  306. .map(ToOwned::to_owned)
  307. }
  308. #[must_use]
  309. pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
  310. SLASH_COMMAND_SPECS
  311. }
  312. #[must_use]
  313. pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
  314. slash_command_specs()
  315. .iter()
  316. .filter(|spec| spec.resume_supported)
  317. .collect()
  318. }
  319. #[must_use]
  320. pub fn render_slash_command_help() -> String {
  321. let mut lines = vec![
  322. "Slash commands".to_string(),
  323. " [resume] means the command also works with --resume SESSION.json".to_string(),
  324. ];
  325. for spec in slash_command_specs() {
  326. let name = match spec.argument_hint {
  327. Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
  328. None => format!("/{}", spec.name),
  329. };
  330. let resume = if spec.resume_supported {
  331. " [resume]"
  332. } else {
  333. ""
  334. };
  335. lines.push(format!(" {name:<20} {}{}", spec.summary, resume));
  336. }
  337. lines.join("\n")
  338. }
  339. #[derive(Debug, Clone, PartialEq, Eq)]
  340. pub struct SlashCommandResult {
  341. pub message: String,
  342. pub session: Session,
  343. }
  344. #[derive(Debug, Clone, PartialEq, Eq)]
  345. pub struct PluginsCommandResult {
  346. pub message: String,
  347. pub reload_runtime: bool,
  348. }
  349. #[allow(clippy::too_many_lines)]
  350. pub fn handle_plugins_slash_command(
  351. action: Option<&str>,
  352. target: Option<&str>,
  353. manager: &mut PluginManager,
  354. ) -> Result<PluginsCommandResult, PluginError> {
  355. match action {
  356. None | Some("list") => Ok(PluginsCommandResult {
  357. message: render_plugins_report(&manager.list_installed_plugins()?),
  358. reload_runtime: false,
  359. }),
  360. Some("install") => {
  361. let Some(target) = target else {
  362. return Ok(PluginsCommandResult {
  363. message: "Usage: /plugins install <path>".to_string(),
  364. reload_runtime: false,
  365. });
  366. };
  367. let install = manager.install(target)?;
  368. let plugin = manager
  369. .list_installed_plugins()?
  370. .into_iter()
  371. .find(|plugin| plugin.metadata.id == install.plugin_id);
  372. Ok(PluginsCommandResult {
  373. message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
  374. reload_runtime: true,
  375. })
  376. }
  377. Some("enable") => {
  378. let Some(target) = target else {
  379. return Ok(PluginsCommandResult {
  380. message: "Usage: /plugins enable <name>".to_string(),
  381. reload_runtime: false,
  382. });
  383. };
  384. let plugin = resolve_plugin_target(manager, target)?;
  385. manager.enable(&plugin.metadata.id)?;
  386. Ok(PluginsCommandResult {
  387. message: format!(
  388. "Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled",
  389. plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
  390. ),
  391. reload_runtime: true,
  392. })
  393. }
  394. Some("disable") => {
  395. let Some(target) = target else {
  396. return Ok(PluginsCommandResult {
  397. message: "Usage: /plugins disable <name>".to_string(),
  398. reload_runtime: false,
  399. });
  400. };
  401. let plugin = resolve_plugin_target(manager, target)?;
  402. manager.disable(&plugin.metadata.id)?;
  403. Ok(PluginsCommandResult {
  404. message: format!(
  405. "Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled",
  406. plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
  407. ),
  408. reload_runtime: true,
  409. })
  410. }
  411. Some("uninstall") => {
  412. let Some(target) = target else {
  413. return Ok(PluginsCommandResult {
  414. message: "Usage: /plugins uninstall <plugin-id>".to_string(),
  415. reload_runtime: false,
  416. });
  417. };
  418. manager.uninstall(target)?;
  419. Ok(PluginsCommandResult {
  420. message: format!("Plugins\n Result uninstalled {target}"),
  421. reload_runtime: true,
  422. })
  423. }
  424. Some("update") => {
  425. let Some(target) = target else {
  426. return Ok(PluginsCommandResult {
  427. message: "Usage: /plugins update <plugin-id>".to_string(),
  428. reload_runtime: false,
  429. });
  430. };
  431. let update = manager.update(target)?;
  432. let plugin = manager
  433. .list_installed_plugins()?
  434. .into_iter()
  435. .find(|plugin| plugin.metadata.id == update.plugin_id);
  436. Ok(PluginsCommandResult {
  437. message: format!(
  438. "Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
  439. update.plugin_id,
  440. plugin
  441. .as_ref()
  442. .map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
  443. update.old_version,
  444. update.new_version,
  445. plugin
  446. .as_ref()
  447. .map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
  448. ),
  449. reload_runtime: true,
  450. })
  451. }
  452. Some(other) => Ok(PluginsCommandResult {
  453. message: format!(
  454. "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
  455. ),
  456. reload_runtime: false,
  457. }),
  458. }
  459. }
  460. #[must_use]
  461. pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
  462. let mut lines = vec!["Plugins".to_string()];
  463. if plugins.is_empty() {
  464. lines.push(" No plugins installed.".to_string());
  465. return lines.join("\n");
  466. }
  467. for plugin in plugins {
  468. let enabled = if plugin.enabled {
  469. "enabled"
  470. } else {
  471. "disabled"
  472. };
  473. lines.push(format!(
  474. " {name:<20} v{version:<10} {enabled}",
  475. name = plugin.metadata.name,
  476. version = plugin.metadata.version,
  477. ));
  478. }
  479. lines.join("\n")
  480. }
  481. fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
  482. let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
  483. let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
  484. let enabled = plugin.is_some_and(|plugin| plugin.enabled);
  485. format!(
  486. "Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
  487. if enabled { "enabled" } else { "disabled" }
  488. )
  489. }
  490. fn resolve_plugin_target(
  491. manager: &PluginManager,
  492. target: &str,
  493. ) -> Result<PluginSummary, PluginError> {
  494. let mut matches = manager
  495. .list_installed_plugins()?
  496. .into_iter()
  497. .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
  498. .collect::<Vec<_>>();
  499. match matches.len() {
  500. 1 => Ok(matches.remove(0)),
  501. 0 => Err(PluginError::NotFound(format!(
  502. "plugin `{target}` is not installed or discoverable"
  503. ))),
  504. _ => Err(PluginError::InvalidManifest(format!(
  505. "plugin name `{target}` is ambiguous; use the full plugin id"
  506. ))),
  507. }
  508. }
  509. #[must_use]
  510. pub fn handle_slash_command(
  511. input: &str,
  512. session: &Session,
  513. compaction: CompactionConfig,
  514. ) -> Option<SlashCommandResult> {
  515. match SlashCommand::parse(input)? {
  516. SlashCommand::Compact => {
  517. let result = compact_session(session, compaction);
  518. let message = if result.removed_message_count == 0 {
  519. "Compaction skipped: session is below the compaction threshold.".to_string()
  520. } else {
  521. format!(
  522. "Compacted {} messages into a resumable system summary.",
  523. result.removed_message_count
  524. )
  525. };
  526. Some(SlashCommandResult {
  527. message,
  528. session: result.compacted_session,
  529. })
  530. }
  531. SlashCommand::Help => Some(SlashCommandResult {
  532. message: render_slash_command_help(),
  533. session: session.clone(),
  534. }),
  535. SlashCommand::Status
  536. | SlashCommand::Bughunter { .. }
  537. | SlashCommand::Commit
  538. | SlashCommand::Pr { .. }
  539. | SlashCommand::Issue { .. }
  540. | SlashCommand::Ultraplan { .. }
  541. | SlashCommand::Teleport { .. }
  542. | SlashCommand::DebugToolCall
  543. | SlashCommand::Model { .. }
  544. | SlashCommand::Permissions { .. }
  545. | SlashCommand::Clear { .. }
  546. | SlashCommand::Cost
  547. | SlashCommand::Resume { .. }
  548. | SlashCommand::Config { .. }
  549. | SlashCommand::Memory
  550. | SlashCommand::Init
  551. | SlashCommand::Diff
  552. | SlashCommand::Version
  553. | SlashCommand::Export { .. }
  554. | SlashCommand::Session { .. }
  555. | SlashCommand::Plugins { .. }
  556. | SlashCommand::Unknown(_) => None,
  557. }
  558. }
  559. #[cfg(test)]
  560. mod tests {
  561. use super::{
  562. handle_plugins_slash_command, handle_slash_command, render_plugins_report,
  563. render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
  564. SlashCommand,
  565. };
  566. use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
  567. use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
  568. use std::fs;
  569. use std::path::{Path, PathBuf};
  570. use std::time::{SystemTime, UNIX_EPOCH};
  571. fn temp_dir(label: &str) -> PathBuf {
  572. let nanos = SystemTime::now()
  573. .duration_since(UNIX_EPOCH)
  574. .expect("time should be after epoch")
  575. .as_nanos();
  576. std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
  577. }
  578. fn write_external_plugin(root: &Path, name: &str, version: &str) {
  579. fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
  580. fs::write(
  581. root.join(".claude-plugin").join("plugin.json"),
  582. format!(
  583. "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
  584. ),
  585. )
  586. .expect("write manifest");
  587. }
  588. fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
  589. fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
  590. fs::write(
  591. root.join(".claude-plugin").join("plugin.json"),
  592. format!(
  593. "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}",
  594. if default_enabled { "true" } else { "false" }
  595. ),
  596. )
  597. .expect("write bundled manifest");
  598. }
  599. #[test]
  600. fn parses_supported_slash_commands() {
  601. assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
  602. assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
  603. assert_eq!(
  604. SlashCommand::parse("/bughunter runtime"),
  605. Some(SlashCommand::Bughunter {
  606. scope: Some("runtime".to_string())
  607. })
  608. );
  609. assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
  610. assert_eq!(
  611. SlashCommand::parse("/pr ready for review"),
  612. Some(SlashCommand::Pr {
  613. context: Some("ready for review".to_string())
  614. })
  615. );
  616. assert_eq!(
  617. SlashCommand::parse("/issue flaky test"),
  618. Some(SlashCommand::Issue {
  619. context: Some("flaky test".to_string())
  620. })
  621. );
  622. assert_eq!(
  623. SlashCommand::parse("/ultraplan ship both features"),
  624. Some(SlashCommand::Ultraplan {
  625. task: Some("ship both features".to_string())
  626. })
  627. );
  628. assert_eq!(
  629. SlashCommand::parse("/teleport conversation.rs"),
  630. Some(SlashCommand::Teleport {
  631. target: Some("conversation.rs".to_string())
  632. })
  633. );
  634. assert_eq!(
  635. SlashCommand::parse("/debug-tool-call"),
  636. Some(SlashCommand::DebugToolCall)
  637. );
  638. assert_eq!(
  639. SlashCommand::parse("/model claude-opus"),
  640. Some(SlashCommand::Model {
  641. model: Some("claude-opus".to_string()),
  642. })
  643. );
  644. assert_eq!(
  645. SlashCommand::parse("/model"),
  646. Some(SlashCommand::Model { model: None })
  647. );
  648. assert_eq!(
  649. SlashCommand::parse("/permissions read-only"),
  650. Some(SlashCommand::Permissions {
  651. mode: Some("read-only".to_string()),
  652. })
  653. );
  654. assert_eq!(
  655. SlashCommand::parse("/clear"),
  656. Some(SlashCommand::Clear { confirm: false })
  657. );
  658. assert_eq!(
  659. SlashCommand::parse("/clear --confirm"),
  660. Some(SlashCommand::Clear { confirm: true })
  661. );
  662. assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
  663. assert_eq!(
  664. SlashCommand::parse("/resume session.json"),
  665. Some(SlashCommand::Resume {
  666. session_path: Some("session.json".to_string()),
  667. })
  668. );
  669. assert_eq!(
  670. SlashCommand::parse("/config"),
  671. Some(SlashCommand::Config { section: None })
  672. );
  673. assert_eq!(
  674. SlashCommand::parse("/config env"),
  675. Some(SlashCommand::Config {
  676. section: Some("env".to_string())
  677. })
  678. );
  679. assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
  680. assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
  681. assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
  682. assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
  683. assert_eq!(
  684. SlashCommand::parse("/export notes.txt"),
  685. Some(SlashCommand::Export {
  686. path: Some("notes.txt".to_string())
  687. })
  688. );
  689. assert_eq!(
  690. SlashCommand::parse("/session switch abc123"),
  691. Some(SlashCommand::Session {
  692. action: Some("switch".to_string()),
  693. target: Some("abc123".to_string())
  694. })
  695. );
  696. assert_eq!(
  697. SlashCommand::parse("/plugins install demo"),
  698. Some(SlashCommand::Plugins {
  699. action: Some("install".to_string()),
  700. target: Some("demo".to_string())
  701. })
  702. );
  703. assert_eq!(
  704. SlashCommand::parse("/plugins list"),
  705. Some(SlashCommand::Plugins {
  706. action: Some("list".to_string()),
  707. target: None
  708. })
  709. );
  710. assert_eq!(
  711. SlashCommand::parse("/plugins enable demo"),
  712. Some(SlashCommand::Plugins {
  713. action: Some("enable".to_string()),
  714. target: Some("demo".to_string())
  715. })
  716. );
  717. assert_eq!(
  718. SlashCommand::parse("/plugins disable demo"),
  719. Some(SlashCommand::Plugins {
  720. action: Some("disable".to_string()),
  721. target: Some("demo".to_string())
  722. })
  723. );
  724. }
  725. #[test]
  726. fn renders_help_from_shared_specs() {
  727. let help = render_slash_command_help();
  728. assert!(help.contains("works with --resume SESSION.json"));
  729. assert!(help.contains("/help"));
  730. assert!(help.contains("/status"));
  731. assert!(help.contains("/compact"));
  732. assert!(help.contains("/bughunter [scope]"));
  733. assert!(help.contains("/commit"));
  734. assert!(help.contains("/pr [context]"));
  735. assert!(help.contains("/issue [context]"));
  736. assert!(help.contains("/ultraplan [task]"));
  737. assert!(help.contains("/teleport <symbol-or-path>"));
  738. assert!(help.contains("/debug-tool-call"));
  739. assert!(help.contains("/model [model]"));
  740. assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
  741. assert!(help.contains("/clear [--confirm]"));
  742. assert!(help.contains("/cost"));
  743. assert!(help.contains("/resume <session-path>"));
  744. assert!(help.contains("/config [env|hooks|model|plugins]"));
  745. assert!(help.contains("/memory"));
  746. assert!(help.contains("/init"));
  747. assert!(help.contains("/diff"));
  748. assert!(help.contains("/version"));
  749. assert!(help.contains("/export [file]"));
  750. assert!(help.contains("/session [list|switch <session-id>]"));
  751. assert!(help.contains(
  752. "/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
  753. ));
  754. assert_eq!(slash_command_specs().len(), 23);
  755. assert_eq!(resume_supported_slash_commands().len(), 11);
  756. }
  757. #[test]
  758. fn compacts_sessions_via_slash_command() {
  759. let session = Session {
  760. version: 1,
  761. messages: vec![
  762. ConversationMessage::user_text("a ".repeat(200)),
  763. ConversationMessage::assistant(vec![ContentBlock::Text {
  764. text: "b ".repeat(200),
  765. }]),
  766. ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
  767. ConversationMessage::assistant(vec![ContentBlock::Text {
  768. text: "recent".to_string(),
  769. }]),
  770. ],
  771. };
  772. let result = handle_slash_command(
  773. "/compact",
  774. &session,
  775. CompactionConfig {
  776. preserve_recent_messages: 2,
  777. max_estimated_tokens: 1,
  778. },
  779. )
  780. .expect("slash command should be handled");
  781. assert!(result.message.contains("Compacted 2 messages"));
  782. assert_eq!(result.session.messages[0].role, MessageRole::System);
  783. }
  784. #[test]
  785. fn help_command_is_non_mutating() {
  786. let session = Session::new();
  787. let result = handle_slash_command("/help", &session, CompactionConfig::default())
  788. .expect("help command should be handled");
  789. assert_eq!(result.session, session);
  790. assert!(result.message.contains("Slash commands"));
  791. }
  792. #[test]
  793. fn ignores_unknown_or_runtime_bound_slash_commands() {
  794. let session = Session::new();
  795. assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
  796. assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
  797. assert!(
  798. handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
  799. );
  800. assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
  801. assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
  802. assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
  803. assert!(
  804. handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
  805. );
  806. assert!(
  807. handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
  808. );
  809. assert!(
  810. handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
  811. .is_none()
  812. );
  813. assert!(
  814. handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
  815. );
  816. assert!(handle_slash_command(
  817. "/permissions read-only",
  818. &session,
  819. CompactionConfig::default()
  820. )
  821. .is_none());
  822. assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
  823. assert!(
  824. handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
  825. .is_none()
  826. );
  827. assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
  828. assert!(handle_slash_command(
  829. "/resume session.json",
  830. &session,
  831. CompactionConfig::default()
  832. )
  833. .is_none());
  834. assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
  835. assert!(
  836. handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
  837. );
  838. assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
  839. assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
  840. assert!(
  841. handle_slash_command("/export note.txt", &session, CompactionConfig::default())
  842. .is_none()
  843. );
  844. assert!(
  845. handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
  846. );
  847. assert!(
  848. handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
  849. );
  850. }
  851. #[test]
  852. fn renders_plugins_report_with_name_version_and_status() {
  853. let rendered = render_plugins_report(&[
  854. PluginSummary {
  855. metadata: PluginMetadata {
  856. id: "demo@external".to_string(),
  857. name: "demo".to_string(),
  858. version: "1.2.3".to_string(),
  859. description: "demo plugin".to_string(),
  860. kind: PluginKind::External,
  861. source: "demo".to_string(),
  862. default_enabled: false,
  863. root: None,
  864. },
  865. enabled: true,
  866. },
  867. PluginSummary {
  868. metadata: PluginMetadata {
  869. id: "sample@external".to_string(),
  870. name: "sample".to_string(),
  871. version: "0.9.0".to_string(),
  872. description: "sample plugin".to_string(),
  873. kind: PluginKind::External,
  874. source: "sample".to_string(),
  875. default_enabled: false,
  876. root: None,
  877. },
  878. enabled: false,
  879. },
  880. ]);
  881. assert!(rendered.contains("demo"));
  882. assert!(rendered.contains("v1.2.3"));
  883. assert!(rendered.contains("enabled"));
  884. assert!(rendered.contains("sample"));
  885. assert!(rendered.contains("v0.9.0"));
  886. assert!(rendered.contains("disabled"));
  887. }
  888. #[test]
  889. fn installs_plugin_from_path_and_lists_it() {
  890. let config_home = temp_dir("home");
  891. let source_root = temp_dir("source");
  892. write_external_plugin(&source_root, "demo", "1.0.0");
  893. let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
  894. let install = handle_plugins_slash_command(
  895. Some("install"),
  896. Some(source_root.to_str().expect("utf8 path")),
  897. &mut manager,
  898. )
  899. .expect("install command should succeed");
  900. assert!(install.reload_runtime);
  901. assert!(install.message.contains("installed demo@external"));
  902. assert!(install.message.contains("Name demo"));
  903. assert!(install.message.contains("Version 1.0.0"));
  904. assert!(install.message.contains("Status enabled"));
  905. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  906. .expect("list command should succeed");
  907. assert!(!list.reload_runtime);
  908. assert!(list.message.contains("demo"));
  909. assert!(list.message.contains("v1.0.0"));
  910. assert!(list.message.contains("enabled"));
  911. let _ = fs::remove_dir_all(config_home);
  912. let _ = fs::remove_dir_all(source_root);
  913. }
  914. #[test]
  915. fn enables_and_disables_plugin_by_name() {
  916. let config_home = temp_dir("toggle-home");
  917. let source_root = temp_dir("toggle-source");
  918. write_external_plugin(&source_root, "demo", "1.0.0");
  919. let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
  920. handle_plugins_slash_command(
  921. Some("install"),
  922. Some(source_root.to_str().expect("utf8 path")),
  923. &mut manager,
  924. )
  925. .expect("install command should succeed");
  926. let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
  927. .expect("disable command should succeed");
  928. assert!(disable.reload_runtime);
  929. assert!(disable.message.contains("disabled demo@external"));
  930. assert!(disable.message.contains("Name demo"));
  931. assert!(disable.message.contains("Status disabled"));
  932. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  933. .expect("list command should succeed");
  934. assert!(list.message.contains("demo"));
  935. assert!(list.message.contains("disabled"));
  936. let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
  937. .expect("enable command should succeed");
  938. assert!(enable.reload_runtime);
  939. assert!(enable.message.contains("enabled demo@external"));
  940. assert!(enable.message.contains("Name demo"));
  941. assert!(enable.message.contains("Status enabled"));
  942. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  943. .expect("list command should succeed");
  944. assert!(list.message.contains("demo"));
  945. assert!(list.message.contains("enabled"));
  946. let _ = fs::remove_dir_all(config_home);
  947. let _ = fs::remove_dir_all(source_root);
  948. }
  949. #[test]
  950. fn lists_auto_installed_bundled_plugins_with_status() {
  951. let config_home = temp_dir("bundled-home");
  952. let bundled_root = temp_dir("bundled-root");
  953. let bundled_plugin = bundled_root.join("starter");
  954. write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
  955. let mut config = PluginManagerConfig::new(&config_home);
  956. config.bundled_root = Some(bundled_root.clone());
  957. let mut manager = PluginManager::new(config);
  958. let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
  959. .expect("list command should succeed");
  960. assert!(!list.reload_runtime);
  961. assert!(list.message.contains("starter"));
  962. assert!(list.message.contains("v0.1.0"));
  963. assert!(list.message.contains("disabled"));
  964. let _ = fs::remove_dir_all(config_home);
  965. let _ = fs::remove_dir_all(bundled_root);
  966. }
  967. }