| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016 |
- use plugins::{PluginError, PluginManager, PluginSummary};
- use runtime::{compact_session, CompactionConfig, Session};
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct CommandManifestEntry {
- pub name: String,
- pub source: CommandSource,
- }
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub enum CommandSource {
- Builtin,
- InternalOnly,
- FeatureGated,
- }
- #[derive(Debug, Clone, Default, PartialEq, Eq)]
- pub struct CommandRegistry {
- entries: Vec<CommandManifestEntry>,
- }
- impl CommandRegistry {
- #[must_use]
- pub fn new(entries: Vec<CommandManifestEntry>) -> Self {
- Self { entries }
- }
- #[must_use]
- pub fn entries(&self) -> &[CommandManifestEntry] {
- &self.entries
- }
- }
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub struct SlashCommandSpec {
- pub name: &'static str,
- pub summary: &'static str,
- pub argument_hint: Option<&'static str>,
- pub resume_supported: bool,
- }
- const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
- SlashCommandSpec {
- name: "help",
- summary: "Show available slash commands",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "status",
- summary: "Show current session status",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "compact",
- summary: "Compact local session history",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "model",
- summary: "Show or switch the active model",
- argument_hint: Some("[model]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "permissions",
- summary: "Show or switch the active permission mode",
- argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "clear",
- summary: "Start a fresh local session",
- argument_hint: Some("[--confirm]"),
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "cost",
- summary: "Show cumulative token usage for this session",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "resume",
- summary: "Load a saved session into the REPL",
- argument_hint: Some("<session-path>"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "config",
- summary: "Inspect Claude config files or merged sections",
- argument_hint: Some("[env|hooks|model|plugins]"),
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "memory",
- summary: "Inspect loaded Claude instruction memory files",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "init",
- summary: "Create a starter CLAUDE.md for this repo",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "diff",
- summary: "Show git diff for current workspace changes",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "version",
- summary: "Show CLI version and build information",
- argument_hint: None,
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "bughunter",
- summary: "Inspect the codebase for likely bugs",
- argument_hint: Some("[scope]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "commit",
- summary: "Generate a commit message and create a git commit",
- argument_hint: None,
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "pr",
- summary: "Draft or create a pull request from the conversation",
- argument_hint: Some("[context]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "issue",
- summary: "Draft or create a GitHub issue from the conversation",
- argument_hint: Some("[context]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "ultraplan",
- summary: "Run a deep planning prompt with multi-step reasoning",
- argument_hint: Some("[task]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "teleport",
- summary: "Jump to a file or symbol by searching the workspace",
- argument_hint: Some("<symbol-or-path>"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "debug-tool-call",
- summary: "Replay the last tool call with debug details",
- argument_hint: None,
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "export",
- summary: "Export the current conversation to a file",
- argument_hint: Some("[file]"),
- resume_supported: true,
- },
- SlashCommandSpec {
- name: "session",
- summary: "List or switch managed local sessions",
- argument_hint: Some("[list|switch <session-id>]"),
- resume_supported: false,
- },
- SlashCommandSpec {
- name: "plugins",
- summary: "List or manage plugins",
- argument_hint: Some(
- "[list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
- ),
- resume_supported: false,
- },
- ];
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub enum SlashCommand {
- Help,
- Status,
- Compact,
- Bughunter {
- scope: Option<String>,
- },
- Commit,
- Pr {
- context: Option<String>,
- },
- Issue {
- context: Option<String>,
- },
- Ultraplan {
- task: Option<String>,
- },
- Teleport {
- target: Option<String>,
- },
- DebugToolCall,
- Model {
- model: Option<String>,
- },
- Permissions {
- mode: Option<String>,
- },
- Clear {
- confirm: bool,
- },
- Cost,
- Resume {
- session_path: Option<String>,
- },
- Config {
- section: Option<String>,
- },
- Memory,
- Init,
- Diff,
- Version,
- Export {
- path: Option<String>,
- },
- Session {
- action: Option<String>,
- target: Option<String>,
- },
- Plugins {
- action: Option<String>,
- target: Option<String>,
- },
- Unknown(String),
- }
- impl SlashCommand {
- #[must_use]
- pub fn parse(input: &str) -> Option<Self> {
- let trimmed = input.trim();
- if !trimmed.starts_with('/') {
- return None;
- }
- let mut parts = trimmed.trim_start_matches('/').split_whitespace();
- let command = parts.next().unwrap_or_default();
- Some(match command {
- "help" => Self::Help,
- "status" => Self::Status,
- "compact" => Self::Compact,
- "bughunter" => Self::Bughunter {
- scope: remainder_after_command(trimmed, command),
- },
- "commit" => Self::Commit,
- "pr" => Self::Pr {
- context: remainder_after_command(trimmed, command),
- },
- "issue" => Self::Issue {
- context: remainder_after_command(trimmed, command),
- },
- "ultraplan" => Self::Ultraplan {
- task: remainder_after_command(trimmed, command),
- },
- "teleport" => Self::Teleport {
- target: remainder_after_command(trimmed, command),
- },
- "debug-tool-call" => Self::DebugToolCall,
- "model" => Self::Model {
- model: parts.next().map(ToOwned::to_owned),
- },
- "permissions" => Self::Permissions {
- mode: parts.next().map(ToOwned::to_owned),
- },
- "clear" => Self::Clear {
- confirm: parts.next() == Some("--confirm"),
- },
- "cost" => Self::Cost,
- "resume" => Self::Resume {
- session_path: parts.next().map(ToOwned::to_owned),
- },
- "config" => Self::Config {
- section: parts.next().map(ToOwned::to_owned),
- },
- "memory" => Self::Memory,
- "init" => Self::Init,
- "diff" => Self::Diff,
- "version" => Self::Version,
- "export" => Self::Export {
- path: parts.next().map(ToOwned::to_owned),
- },
- "session" => Self::Session {
- action: parts.next().map(ToOwned::to_owned),
- target: parts.next().map(ToOwned::to_owned),
- },
- "plugins" => Self::Plugins {
- action: parts.next().map(ToOwned::to_owned),
- target: {
- let remainder = parts.collect::<Vec<_>>().join(" ");
- (!remainder.is_empty()).then_some(remainder)
- },
- },
- other => Self::Unknown(other.to_string()),
- })
- }
- }
- fn remainder_after_command(input: &str, command: &str) -> Option<String> {
- input
- .trim()
- .strip_prefix(&format!("/{command}"))
- .map(str::trim)
- .filter(|value| !value.is_empty())
- .map(ToOwned::to_owned)
- }
- #[must_use]
- pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
- SLASH_COMMAND_SPECS
- }
- #[must_use]
- pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
- slash_command_specs()
- .iter()
- .filter(|spec| spec.resume_supported)
- .collect()
- }
- #[must_use]
- pub fn render_slash_command_help() -> String {
- let mut lines = vec![
- "Slash commands".to_string(),
- " [resume] means the command also works with --resume SESSION.json".to_string(),
- ];
- for spec in slash_command_specs() {
- let name = match spec.argument_hint {
- Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
- None => format!("/{}", spec.name),
- };
- let resume = if spec.resume_supported {
- " [resume]"
- } else {
- ""
- };
- lines.push(format!(" {name:<20} {}{}", spec.summary, resume));
- }
- lines.join("\n")
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct SlashCommandResult {
- pub message: String,
- pub session: Session,
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct PluginsCommandResult {
- pub message: String,
- pub reload_runtime: bool,
- }
- #[allow(clippy::too_many_lines)]
- pub fn handle_plugins_slash_command(
- action: Option<&str>,
- target: Option<&str>,
- manager: &mut PluginManager,
- ) -> Result<PluginsCommandResult, PluginError> {
- match action {
- None | Some("list") => Ok(PluginsCommandResult {
- message: render_plugins_report(&manager.list_installed_plugins()?),
- reload_runtime: false,
- }),
- Some("install") => {
- let Some(target) = target else {
- return Ok(PluginsCommandResult {
- message: "Usage: /plugins install <path>".to_string(),
- reload_runtime: false,
- });
- };
- let install = manager.install(target)?;
- let plugin = manager
- .list_installed_plugins()?
- .into_iter()
- .find(|plugin| plugin.metadata.id == install.plugin_id);
- Ok(PluginsCommandResult {
- message: render_plugin_install_report(&install.plugin_id, plugin.as_ref()),
- reload_runtime: true,
- })
- }
- Some("enable") => {
- let Some(target) = target else {
- return Ok(PluginsCommandResult {
- message: "Usage: /plugins enable <name>".to_string(),
- reload_runtime: false,
- });
- };
- let plugin = resolve_plugin_target(manager, target)?;
- manager.enable(&plugin.metadata.id)?;
- Ok(PluginsCommandResult {
- message: format!(
- "Plugins\n Result enabled {}\n Name {}\n Version {}\n Status enabled",
- plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
- ),
- reload_runtime: true,
- })
- }
- Some("disable") => {
- let Some(target) = target else {
- return Ok(PluginsCommandResult {
- message: "Usage: /plugins disable <name>".to_string(),
- reload_runtime: false,
- });
- };
- let plugin = resolve_plugin_target(manager, target)?;
- manager.disable(&plugin.metadata.id)?;
- Ok(PluginsCommandResult {
- message: format!(
- "Plugins\n Result disabled {}\n Name {}\n Version {}\n Status disabled",
- plugin.metadata.id, plugin.metadata.name, plugin.metadata.version
- ),
- reload_runtime: true,
- })
- }
- Some("uninstall") => {
- let Some(target) = target else {
- return Ok(PluginsCommandResult {
- message: "Usage: /plugins uninstall <plugin-id>".to_string(),
- reload_runtime: false,
- });
- };
- manager.uninstall(target)?;
- Ok(PluginsCommandResult {
- message: format!("Plugins\n Result uninstalled {target}"),
- reload_runtime: true,
- })
- }
- Some("update") => {
- let Some(target) = target else {
- return Ok(PluginsCommandResult {
- message: "Usage: /plugins update <plugin-id>".to_string(),
- reload_runtime: false,
- });
- };
- let update = manager.update(target)?;
- let plugin = manager
- .list_installed_plugins()?
- .into_iter()
- .find(|plugin| plugin.metadata.id == update.plugin_id);
- Ok(PluginsCommandResult {
- message: format!(
- "Plugins\n Result updated {}\n Name {}\n Old version {}\n New version {}\n Status {}",
- update.plugin_id,
- plugin
- .as_ref()
- .map_or_else(|| update.plugin_id.clone(), |plugin| plugin.metadata.name.clone()),
- update.old_version,
- update.new_version,
- plugin
- .as_ref()
- .map_or("unknown", |plugin| if plugin.enabled { "enabled" } else { "disabled" }),
- ),
- reload_runtime: true,
- })
- }
- Some(other) => Ok(PluginsCommandResult {
- message: format!(
- "Unknown /plugins action '{other}'. Use list, install, enable, disable, uninstall, or update."
- ),
- reload_runtime: false,
- }),
- }
- }
- #[must_use]
- pub fn render_plugins_report(plugins: &[PluginSummary]) -> String {
- let mut lines = vec!["Plugins".to_string()];
- if plugins.is_empty() {
- lines.push(" No plugins installed.".to_string());
- return lines.join("\n");
- }
- for plugin in plugins {
- let enabled = if plugin.enabled {
- "enabled"
- } else {
- "disabled"
- };
- lines.push(format!(
- " {name:<20} v{version:<10} {enabled}",
- name = plugin.metadata.name,
- version = plugin.metadata.version,
- ));
- }
- lines.join("\n")
- }
- fn render_plugin_install_report(plugin_id: &str, plugin: Option<&PluginSummary>) -> String {
- let name = plugin.map_or(plugin_id, |plugin| plugin.metadata.name.as_str());
- let version = plugin.map_or("unknown", |plugin| plugin.metadata.version.as_str());
- let enabled = plugin.is_some_and(|plugin| plugin.enabled);
- format!(
- "Plugins\n Result installed {plugin_id}\n Name {name}\n Version {version}\n Status {}",
- if enabled { "enabled" } else { "disabled" }
- )
- }
- fn resolve_plugin_target(
- manager: &PluginManager,
- target: &str,
- ) -> Result<PluginSummary, PluginError> {
- let mut matches = manager
- .list_installed_plugins()?
- .into_iter()
- .filter(|plugin| plugin.metadata.id == target || plugin.metadata.name == target)
- .collect::<Vec<_>>();
- match matches.len() {
- 1 => Ok(matches.remove(0)),
- 0 => Err(PluginError::NotFound(format!(
- "plugin `{target}` is not installed or discoverable"
- ))),
- _ => Err(PluginError::InvalidManifest(format!(
- "plugin name `{target}` is ambiguous; use the full plugin id"
- ))),
- }
- }
- #[must_use]
- pub fn handle_slash_command(
- input: &str,
- session: &Session,
- compaction: CompactionConfig,
- ) -> Option<SlashCommandResult> {
- match SlashCommand::parse(input)? {
- SlashCommand::Compact => {
- let result = compact_session(session, compaction);
- let message = if result.removed_message_count == 0 {
- "Compaction skipped: session is below the compaction threshold.".to_string()
- } else {
- format!(
- "Compacted {} messages into a resumable system summary.",
- result.removed_message_count
- )
- };
- Some(SlashCommandResult {
- message,
- session: result.compacted_session,
- })
- }
- SlashCommand::Help => Some(SlashCommandResult {
- message: render_slash_command_help(),
- session: session.clone(),
- }),
- SlashCommand::Status
- | SlashCommand::Bughunter { .. }
- | SlashCommand::Commit
- | SlashCommand::Pr { .. }
- | SlashCommand::Issue { .. }
- | SlashCommand::Ultraplan { .. }
- | SlashCommand::Teleport { .. }
- | SlashCommand::DebugToolCall
- | SlashCommand::Model { .. }
- | SlashCommand::Permissions { .. }
- | SlashCommand::Clear { .. }
- | SlashCommand::Cost
- | SlashCommand::Resume { .. }
- | SlashCommand::Config { .. }
- | SlashCommand::Memory
- | SlashCommand::Init
- | SlashCommand::Diff
- | SlashCommand::Version
- | SlashCommand::Export { .. }
- | SlashCommand::Session { .. }
- | SlashCommand::Plugins { .. }
- | SlashCommand::Unknown(_) => None,
- }
- }
- #[cfg(test)]
- mod tests {
- use super::{
- handle_plugins_slash_command, handle_slash_command, render_plugins_report,
- render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
- SlashCommand,
- };
- use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
- use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
- use std::fs;
- use std::path::{Path, PathBuf};
- use std::time::{SystemTime, UNIX_EPOCH};
- fn temp_dir(label: &str) -> PathBuf {
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("time should be after epoch")
- .as_nanos();
- std::env::temp_dir().join(format!("commands-plugin-{label}-{nanos}"))
- }
- fn write_external_plugin(root: &Path, name: &str, version: &str) {
- fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
- fs::write(
- root.join(".claude-plugin").join("plugin.json"),
- format!(
- "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"commands plugin\"\n}}"
- ),
- )
- .expect("write manifest");
- }
- fn write_bundled_plugin(root: &Path, name: &str, version: &str, default_enabled: bool) {
- fs::create_dir_all(root.join(".claude-plugin")).expect("manifest dir");
- fs::write(
- root.join(".claude-plugin").join("plugin.json"),
- format!(
- "{{\n \"name\": \"{name}\",\n \"version\": \"{version}\",\n \"description\": \"bundled commands plugin\",\n \"defaultEnabled\": {}\n}}",
- if default_enabled { "true" } else { "false" }
- ),
- )
- .expect("write bundled manifest");
- }
- #[test]
- fn parses_supported_slash_commands() {
- assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
- assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
- assert_eq!(
- SlashCommand::parse("/bughunter runtime"),
- Some(SlashCommand::Bughunter {
- scope: Some("runtime".to_string())
- })
- );
- assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
- assert_eq!(
- SlashCommand::parse("/pr ready for review"),
- Some(SlashCommand::Pr {
- context: Some("ready for review".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/issue flaky test"),
- Some(SlashCommand::Issue {
- context: Some("flaky test".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/ultraplan ship both features"),
- Some(SlashCommand::Ultraplan {
- task: Some("ship both features".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/teleport conversation.rs"),
- Some(SlashCommand::Teleport {
- target: Some("conversation.rs".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/debug-tool-call"),
- Some(SlashCommand::DebugToolCall)
- );
- assert_eq!(
- SlashCommand::parse("/model claude-opus"),
- Some(SlashCommand::Model {
- model: Some("claude-opus".to_string()),
- })
- );
- assert_eq!(
- SlashCommand::parse("/model"),
- Some(SlashCommand::Model { model: None })
- );
- assert_eq!(
- SlashCommand::parse("/permissions read-only"),
- Some(SlashCommand::Permissions {
- mode: Some("read-only".to_string()),
- })
- );
- assert_eq!(
- SlashCommand::parse("/clear"),
- Some(SlashCommand::Clear { confirm: false })
- );
- assert_eq!(
- SlashCommand::parse("/clear --confirm"),
- Some(SlashCommand::Clear { confirm: true })
- );
- assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
- assert_eq!(
- SlashCommand::parse("/resume session.json"),
- Some(SlashCommand::Resume {
- session_path: Some("session.json".to_string()),
- })
- );
- assert_eq!(
- SlashCommand::parse("/config"),
- Some(SlashCommand::Config { section: None })
- );
- assert_eq!(
- SlashCommand::parse("/config env"),
- Some(SlashCommand::Config {
- section: Some("env".to_string())
- })
- );
- assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
- assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
- assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
- assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
- assert_eq!(
- SlashCommand::parse("/export notes.txt"),
- Some(SlashCommand::Export {
- path: Some("notes.txt".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/session switch abc123"),
- Some(SlashCommand::Session {
- action: Some("switch".to_string()),
- target: Some("abc123".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/plugins install demo"),
- Some(SlashCommand::Plugins {
- action: Some("install".to_string()),
- target: Some("demo".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/plugins list"),
- Some(SlashCommand::Plugins {
- action: Some("list".to_string()),
- target: None
- })
- );
- assert_eq!(
- SlashCommand::parse("/plugins enable demo"),
- Some(SlashCommand::Plugins {
- action: Some("enable".to_string()),
- target: Some("demo".to_string())
- })
- );
- assert_eq!(
- SlashCommand::parse("/plugins disable demo"),
- Some(SlashCommand::Plugins {
- action: Some("disable".to_string()),
- target: Some("demo".to_string())
- })
- );
- }
- #[test]
- fn renders_help_from_shared_specs() {
- let help = render_slash_command_help();
- assert!(help.contains("works with --resume SESSION.json"));
- assert!(help.contains("/help"));
- assert!(help.contains("/status"));
- assert!(help.contains("/compact"));
- assert!(help.contains("/bughunter [scope]"));
- assert!(help.contains("/commit"));
- assert!(help.contains("/pr [context]"));
- assert!(help.contains("/issue [context]"));
- assert!(help.contains("/ultraplan [task]"));
- assert!(help.contains("/teleport <symbol-or-path>"));
- assert!(help.contains("/debug-tool-call"));
- assert!(help.contains("/model [model]"));
- assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
- assert!(help.contains("/clear [--confirm]"));
- assert!(help.contains("/cost"));
- assert!(help.contains("/resume <session-path>"));
- assert!(help.contains("/config [env|hooks|model|plugins]"));
- assert!(help.contains("/memory"));
- assert!(help.contains("/init"));
- assert!(help.contains("/diff"));
- assert!(help.contains("/version"));
- assert!(help.contains("/export [file]"));
- assert!(help.contains("/session [list|switch <session-id>]"));
- assert!(help.contains(
- "/plugins [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]"
- ));
- assert_eq!(slash_command_specs().len(), 23);
- assert_eq!(resume_supported_slash_commands().len(), 11);
- }
- #[test]
- fn compacts_sessions_via_slash_command() {
- let session = Session {
- version: 1,
- messages: vec![
- ConversationMessage::user_text("a ".repeat(200)),
- ConversationMessage::assistant(vec![ContentBlock::Text {
- text: "b ".repeat(200),
- }]),
- ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
- ConversationMessage::assistant(vec![ContentBlock::Text {
- text: "recent".to_string(),
- }]),
- ],
- };
- let result = handle_slash_command(
- "/compact",
- &session,
- CompactionConfig {
- preserve_recent_messages: 2,
- max_estimated_tokens: 1,
- },
- )
- .expect("slash command should be handled");
- assert!(result.message.contains("Compacted 2 messages"));
- assert_eq!(result.session.messages[0].role, MessageRole::System);
- }
- #[test]
- fn help_command_is_non_mutating() {
- let session = Session::new();
- let result = handle_slash_command("/help", &session, CompactionConfig::default())
- .expect("help command should be handled");
- assert_eq!(result.session, session);
- assert!(result.message.contains("Slash commands"));
- }
- #[test]
- fn ignores_unknown_or_runtime_bound_slash_commands() {
- let session = Session::new();
- assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
- assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
- assert!(
- handle_slash_command("/bughunter", &session, CompactionConfig::default()).is_none()
- );
- assert!(handle_slash_command("/commit", &session, CompactionConfig::default()).is_none());
- assert!(handle_slash_command("/pr", &session, CompactionConfig::default()).is_none());
- assert!(handle_slash_command("/issue", &session, CompactionConfig::default()).is_none());
- assert!(
- handle_slash_command("/ultraplan", &session, CompactionConfig::default()).is_none()
- );
- assert!(
- handle_slash_command("/teleport foo", &session, CompactionConfig::default()).is_none()
- );
- assert!(
- handle_slash_command("/debug-tool-call", &session, CompactionConfig::default())
- .is_none()
- );
- assert!(
- handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
- );
- assert!(handle_slash_command(
- "/permissions read-only",
- &session,
- CompactionConfig::default()
- )
- .is_none());
- assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
- assert!(
- handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
- .is_none()
- );
- assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
- assert!(handle_slash_command(
- "/resume session.json",
- &session,
- CompactionConfig::default()
- )
- .is_none());
- assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
- assert!(
- handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
- );
- assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
- assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
- assert!(
- handle_slash_command("/export note.txt", &session, CompactionConfig::default())
- .is_none()
- );
- assert!(
- handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
- );
- assert!(
- handle_slash_command("/plugins list", &session, CompactionConfig::default()).is_none()
- );
- }
- #[test]
- fn renders_plugins_report_with_name_version_and_status() {
- let rendered = render_plugins_report(&[
- PluginSummary {
- metadata: PluginMetadata {
- id: "demo@external".to_string(),
- name: "demo".to_string(),
- version: "1.2.3".to_string(),
- description: "demo plugin".to_string(),
- kind: PluginKind::External,
- source: "demo".to_string(),
- default_enabled: false,
- root: None,
- },
- enabled: true,
- },
- PluginSummary {
- metadata: PluginMetadata {
- id: "sample@external".to_string(),
- name: "sample".to_string(),
- version: "0.9.0".to_string(),
- description: "sample plugin".to_string(),
- kind: PluginKind::External,
- source: "sample".to_string(),
- default_enabled: false,
- root: None,
- },
- enabled: false,
- },
- ]);
- assert!(rendered.contains("demo"));
- assert!(rendered.contains("v1.2.3"));
- assert!(rendered.contains("enabled"));
- assert!(rendered.contains("sample"));
- assert!(rendered.contains("v0.9.0"));
- assert!(rendered.contains("disabled"));
- }
- #[test]
- fn installs_plugin_from_path_and_lists_it() {
- let config_home = temp_dir("home");
- let source_root = temp_dir("source");
- write_external_plugin(&source_root, "demo", "1.0.0");
- let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
- let install = handle_plugins_slash_command(
- Some("install"),
- Some(source_root.to_str().expect("utf8 path")),
- &mut manager,
- )
- .expect("install command should succeed");
- assert!(install.reload_runtime);
- assert!(install.message.contains("installed demo@external"));
- assert!(install.message.contains("Name demo"));
- assert!(install.message.contains("Version 1.0.0"));
- assert!(install.message.contains("Status enabled"));
- let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
- .expect("list command should succeed");
- assert!(!list.reload_runtime);
- assert!(list.message.contains("demo"));
- assert!(list.message.contains("v1.0.0"));
- assert!(list.message.contains("enabled"));
- let _ = fs::remove_dir_all(config_home);
- let _ = fs::remove_dir_all(source_root);
- }
- #[test]
- fn enables_and_disables_plugin_by_name() {
- let config_home = temp_dir("toggle-home");
- let source_root = temp_dir("toggle-source");
- write_external_plugin(&source_root, "demo", "1.0.0");
- let mut manager = PluginManager::new(PluginManagerConfig::new(&config_home));
- handle_plugins_slash_command(
- Some("install"),
- Some(source_root.to_str().expect("utf8 path")),
- &mut manager,
- )
- .expect("install command should succeed");
- let disable = handle_plugins_slash_command(Some("disable"), Some("demo"), &mut manager)
- .expect("disable command should succeed");
- assert!(disable.reload_runtime);
- assert!(disable.message.contains("disabled demo@external"));
- assert!(disable.message.contains("Name demo"));
- assert!(disable.message.contains("Status disabled"));
- let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
- .expect("list command should succeed");
- assert!(list.message.contains("demo"));
- assert!(list.message.contains("disabled"));
- let enable = handle_plugins_slash_command(Some("enable"), Some("demo"), &mut manager)
- .expect("enable command should succeed");
- assert!(enable.reload_runtime);
- assert!(enable.message.contains("enabled demo@external"));
- assert!(enable.message.contains("Name demo"));
- assert!(enable.message.contains("Status enabled"));
- let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
- .expect("list command should succeed");
- assert!(list.message.contains("demo"));
- assert!(list.message.contains("enabled"));
- let _ = fs::remove_dir_all(config_home);
- let _ = fs::remove_dir_all(source_root);
- }
- #[test]
- fn lists_auto_installed_bundled_plugins_with_status() {
- let config_home = temp_dir("bundled-home");
- let bundled_root = temp_dir("bundled-root");
- let bundled_plugin = bundled_root.join("starter");
- write_bundled_plugin(&bundled_plugin, "starter", "0.1.0", false);
- let mut config = PluginManagerConfig::new(&config_home);
- config.bundled_root = Some(bundled_root.clone());
- let mut manager = PluginManager::new(config);
- let list = handle_plugins_slash_command(Some("list"), None, &mut manager)
- .expect("list command should succeed");
- assert!(!list.reload_runtime);
- assert!(list.message.contains("starter"));
- assert!(list.message.contains("v0.1.0"));
- assert!(list.message.contains("disabled"));
- let _ = fs::remove_dir_all(config_home);
- let _ = fs::remove_dir_all(bundled_root);
- }
- }
|