|
@@ -1,5 +1,6 @@
|
|
|
use std::collections::BTreeMap;
|
|
use std::collections::BTreeMap;
|
|
|
use std::env;
|
|
use std::env;
|
|
|
|
|
+use std::fmt;
|
|
|
use std::fs;
|
|
use std::fs;
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
|
|
@@ -295,83 +296,360 @@ pub enum SlashCommand {
|
|
|
Unknown(String),
|
|
Unknown(String),
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
+pub struct SlashCommandParseError {
|
|
|
|
|
+ message: String,
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl SlashCommandParseError {
|
|
|
|
|
+ fn new(message: impl Into<String>) -> Self {
|
|
|
|
|
+ Self {
|
|
|
|
|
+ message: message.into(),
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl fmt::Display for SlashCommandParseError {
|
|
|
|
|
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
|
|
|
+ f.write_str(&self.message)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+impl std::error::Error for SlashCommandParseError {}
|
|
|
|
|
+
|
|
|
impl SlashCommand {
|
|
impl SlashCommand {
|
|
|
- #[must_use]
|
|
|
|
|
- pub fn parse(input: &str) -> Option<Self> {
|
|
|
|
|
- let trimmed = input.trim();
|
|
|
|
|
- if !trimmed.starts_with('/') {
|
|
|
|
|
- return None;
|
|
|
|
|
|
|
+ pub fn parse(input: &str) -> Result<Option<Self>, SlashCommandParseError> {
|
|
|
|
|
+ validate_slash_command_input(input)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+pub fn validate_slash_command_input(
|
|
|
|
|
+ input: &str,
|
|
|
|
|
+) -> Result<Option<SlashCommand>, SlashCommandParseError> {
|
|
|
|
|
+ let trimmed = input.trim();
|
|
|
|
|
+ if !trimmed.starts_with('/') {
|
|
|
|
|
+ return Ok(None);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let mut parts = trimmed.trim_start_matches('/').split_whitespace();
|
|
|
|
|
+ let command = parts.next().unwrap_or_default();
|
|
|
|
|
+ if command.is_empty() {
|
|
|
|
|
+ return Err(SlashCommandParseError::new(
|
|
|
|
|
+ "Slash command name is missing. Use /help to list available slash commands.",
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let args = parts.collect::<Vec<_>>();
|
|
|
|
|
+ let remainder = remainder_after_command(trimmed, command);
|
|
|
|
|
+
|
|
|
|
|
+ Ok(Some(match command {
|
|
|
|
|
+ "help" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::Help
|
|
|
|
|
+ }
|
|
|
|
|
+ "status" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::Status
|
|
|
|
|
+ }
|
|
|
|
|
+ "sandbox" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::Sandbox
|
|
|
|
|
+ }
|
|
|
|
|
+ "compact" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::Compact
|
|
|
}
|
|
}
|
|
|
|
|
+ "bughunter" => SlashCommand::Bughunter { scope: remainder },
|
|
|
|
|
+ "commit" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::Commit
|
|
|
|
|
+ }
|
|
|
|
|
+ "pr" => SlashCommand::Pr { context: remainder },
|
|
|
|
|
+ "issue" => SlashCommand::Issue { context: remainder },
|
|
|
|
|
+ "ultraplan" => SlashCommand::Ultraplan { task: remainder },
|
|
|
|
|
+ "teleport" => SlashCommand::Teleport {
|
|
|
|
|
+ target: Some(require_remainder(command, remainder, "<symbol-or-path>")?),
|
|
|
|
|
+ },
|
|
|
|
|
+ "debug-tool-call" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::DebugToolCall
|
|
|
|
|
+ }
|
|
|
|
|
+ "model" => SlashCommand::Model {
|
|
|
|
|
+ model: optional_single_arg(command, &args, "[model]")?,
|
|
|
|
|
+ },
|
|
|
|
|
+ "permissions" => SlashCommand::Permissions {
|
|
|
|
|
+ mode: parse_permissions_mode(&args)?,
|
|
|
|
|
+ },
|
|
|
|
|
+ "clear" => SlashCommand::Clear {
|
|
|
|
|
+ confirm: parse_clear_args(&args)?,
|
|
|
|
|
+ },
|
|
|
|
|
+ "cost" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::Cost
|
|
|
|
|
+ }
|
|
|
|
|
+ "resume" => SlashCommand::Resume {
|
|
|
|
|
+ session_path: Some(require_remainder(command, remainder, "<session-path>")?),
|
|
|
|
|
+ },
|
|
|
|
|
+ "config" => SlashCommand::Config {
|
|
|
|
|
+ section: parse_config_section(&args)?,
|
|
|
|
|
+ },
|
|
|
|
|
+ "memory" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::Memory
|
|
|
|
|
+ }
|
|
|
|
|
+ "init" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::Init
|
|
|
|
|
+ }
|
|
|
|
|
+ "diff" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::Diff
|
|
|
|
|
+ }
|
|
|
|
|
+ "version" => {
|
|
|
|
|
+ validate_no_args(command, &args)?;
|
|
|
|
|
+ SlashCommand::Version
|
|
|
|
|
+ }
|
|
|
|
|
+ "export" => SlashCommand::Export { path: remainder },
|
|
|
|
|
+ "session" => parse_session_command(&args)?,
|
|
|
|
|
+ "plugin" | "plugins" | "marketplace" => parse_plugin_command(&args)?,
|
|
|
|
|
+ "agents" => SlashCommand::Agents {
|
|
|
|
|
+ args: parse_list_or_help_args(command, remainder)?,
|
|
|
|
|
+ },
|
|
|
|
|
+ "skills" => SlashCommand::Skills {
|
|
|
|
|
+ args: parse_list_or_help_args(command, remainder)?,
|
|
|
|
|
+ },
|
|
|
|
|
+ other => SlashCommand::Unknown(other.to_string()),
|
|
|
|
|
+ }))
|
|
|
|
|
+}
|
|
|
|
|
+fn validate_no_args(command: &str, args: &[&str]) -> Result<(), SlashCommandParseError> {
|
|
|
|
|
+ if args.is_empty() {
|
|
|
|
|
+ return Ok(());
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- 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,
|
|
|
|
|
- "sandbox" => Self::Sandbox,
|
|
|
|
|
- "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),
|
|
|
|
|
- },
|
|
|
|
|
- "plugin" | "plugins" | "marketplace" => Self::Plugins {
|
|
|
|
|
- action: parts.next().map(ToOwned::to_owned),
|
|
|
|
|
- target: {
|
|
|
|
|
- let remainder = parts.collect::<Vec<_>>().join(" ");
|
|
|
|
|
- (!remainder.is_empty()).then_some(remainder)
|
|
|
|
|
- },
|
|
|
|
|
- },
|
|
|
|
|
- "agents" => Self::Agents {
|
|
|
|
|
- args: remainder_after_command(trimmed, command),
|
|
|
|
|
- },
|
|
|
|
|
- "skills" => Self::Skills {
|
|
|
|
|
- args: remainder_after_command(trimmed, command),
|
|
|
|
|
- },
|
|
|
|
|
- other => Self::Unknown(other.to_string()),
|
|
|
|
|
- })
|
|
|
|
|
|
|
+ Err(command_error(
|
|
|
|
|
+ &format!("Unexpected arguments for /{command}."),
|
|
|
|
|
+ command,
|
|
|
|
|
+ &format!("/{command}"),
|
|
|
|
|
+ ))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn optional_single_arg(
|
|
|
|
|
+ command: &str,
|
|
|
|
|
+ args: &[&str],
|
|
|
|
|
+ argument_hint: &str,
|
|
|
|
|
+) -> Result<Option<String>, SlashCommandParseError> {
|
|
|
|
|
+ match args {
|
|
|
|
|
+ [] => Ok(None),
|
|
|
|
|
+ [value] => Ok(Some((*value).to_string())),
|
|
|
|
|
+ _ => Err(usage_error(command, argument_hint)),
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn require_remainder(
|
|
|
|
|
+ command: &str,
|
|
|
|
|
+ remainder: Option<String>,
|
|
|
|
|
+ argument_hint: &str,
|
|
|
|
|
+) -> Result<String, SlashCommandParseError> {
|
|
|
|
|
+ remainder.ok_or_else(|| usage_error(command, argument_hint))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn parse_permissions_mode(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
|
|
|
|
|
+ let mode = optional_single_arg(
|
|
|
|
|
+ "permissions",
|
|
|
|
|
+ args,
|
|
|
|
|
+ "[read-only|workspace-write|danger-full-access]",
|
|
|
|
|
+ )?;
|
|
|
|
|
+ if let Some(mode) = mode {
|
|
|
|
|
+ if matches!(
|
|
|
|
|
+ mode.as_str(),
|
|
|
|
|
+ "read-only" | "workspace-write" | "danger-full-access"
|
|
|
|
|
+ ) {
|
|
|
|
|
+ return Ok(Some(mode));
|
|
|
|
|
+ }
|
|
|
|
|
+ return Err(command_error(
|
|
|
|
|
+ &format!(
|
|
|
|
|
+ "Unsupported /permissions mode '{mode}'. Use read-only, workspace-write, or danger-full-access."
|
|
|
|
|
+ ),
|
|
|
|
|
+ "permissions",
|
|
|
|
|
+ "/permissions [read-only|workspace-write|danger-full-access]",
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Ok(None)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn parse_clear_args(args: &[&str]) -> Result<bool, SlashCommandParseError> {
|
|
|
|
|
+ match args {
|
|
|
|
|
+ [] => Ok(false),
|
|
|
|
|
+ ["--confirm"] => Ok(true),
|
|
|
|
|
+ [unexpected] => Err(command_error(
|
|
|
|
|
+ &format!("Unsupported /clear argument '{unexpected}'. Use /clear or /clear --confirm."),
|
|
|
|
|
+ "clear",
|
|
|
|
|
+ "/clear [--confirm]",
|
|
|
|
|
+ )),
|
|
|
|
|
+ _ => Err(usage_error("clear", "[--confirm]")),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn parse_config_section(args: &[&str]) -> Result<Option<String>, SlashCommandParseError> {
|
|
|
|
|
+ let section = optional_single_arg("config", args, "[env|hooks|model|plugins]")?;
|
|
|
|
|
+ if let Some(section) = section {
|
|
|
|
|
+ if matches!(section.as_str(), "env" | "hooks" | "model" | "plugins") {
|
|
|
|
|
+ return Ok(Some(section));
|
|
|
|
|
+ }
|
|
|
|
|
+ return Err(command_error(
|
|
|
|
|
+ &format!("Unsupported /config section '{section}'. Use env, hooks, model, or plugins."),
|
|
|
|
|
+ "config",
|
|
|
|
|
+ "/config [env|hooks|model|plugins]",
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Ok(None)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn parse_session_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
|
|
|
|
|
+ match args {
|
|
|
|
|
+ [] => Ok(SlashCommand::Session {
|
|
|
|
|
+ action: None,
|
|
|
|
|
+ target: None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["list"] => Ok(SlashCommand::Session {
|
|
|
|
|
+ action: Some("list".to_string()),
|
|
|
|
|
+ target: None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["list", ..] => Err(usage_error("session", "[list|switch <session-id>|fork [branch-name]]")),
|
|
|
|
|
+ ["switch"] => Err(usage_error("session switch", "<session-id>")),
|
|
|
|
|
+ ["switch", target] => Ok(SlashCommand::Session {
|
|
|
|
|
+ action: Some("switch".to_string()),
|
|
|
|
|
+ target: Some((*target).to_string()),
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["switch", ..] => Err(command_error(
|
|
|
|
|
+ "Unexpected arguments for /session switch.",
|
|
|
|
|
+ "session",
|
|
|
|
|
+ "/session switch <session-id>",
|
|
|
|
|
+ )),
|
|
|
|
|
+ ["fork"] => Ok(SlashCommand::Session {
|
|
|
|
|
+ action: Some("fork".to_string()),
|
|
|
|
|
+ target: None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["fork", target] => Ok(SlashCommand::Session {
|
|
|
|
|
+ action: Some("fork".to_string()),
|
|
|
|
|
+ target: Some((*target).to_string()),
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["fork", ..] => Err(command_error(
|
|
|
|
|
+ "Unexpected arguments for /session fork.",
|
|
|
|
|
+ "session",
|
|
|
|
|
+ "/session fork [branch-name]",
|
|
|
|
|
+ )),
|
|
|
|
|
+ [action, ..] => Err(command_error(
|
|
|
|
|
+ &format!(
|
|
|
|
|
+ "Unknown /session action '{action}'. Use list, switch <session-id>, or fork [branch-name]."
|
|
|
|
|
+ ),
|
|
|
|
|
+ "session",
|
|
|
|
|
+ "/session [list|switch <session-id>|fork [branch-name]]",
|
|
|
|
|
+ )),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn parse_plugin_command(args: &[&str]) -> Result<SlashCommand, SlashCommandParseError> {
|
|
|
|
|
+ match args {
|
|
|
|
|
+ [] => Ok(SlashCommand::Plugins {
|
|
|
|
|
+ action: None,
|
|
|
|
|
+ target: None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["list"] => Ok(SlashCommand::Plugins {
|
|
|
|
|
+ action: Some("list".to_string()),
|
|
|
|
|
+ target: None,
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["list", ..] => Err(usage_error("plugin list", "")),
|
|
|
|
|
+ ["install"] => Err(usage_error("plugin install", "<path>")),
|
|
|
|
|
+ ["install", target @ ..] => Ok(SlashCommand::Plugins {
|
|
|
|
|
+ action: Some("install".to_string()),
|
|
|
|
|
+ target: Some(target.join(" ")),
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["enable"] => Err(usage_error("plugin enable", "<name>")),
|
|
|
|
|
+ ["enable", target] => Ok(SlashCommand::Plugins {
|
|
|
|
|
+ action: Some("enable".to_string()),
|
|
|
|
|
+ target: Some((*target).to_string()),
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["enable", ..] => Err(command_error(
|
|
|
|
|
+ "Unexpected arguments for /plugin enable.",
|
|
|
|
|
+ "plugin",
|
|
|
|
|
+ "/plugin enable <name>",
|
|
|
|
|
+ )),
|
|
|
|
|
+ ["disable"] => Err(usage_error("plugin disable", "<name>")),
|
|
|
|
|
+ ["disable", target] => Ok(SlashCommand::Plugins {
|
|
|
|
|
+ action: Some("disable".to_string()),
|
|
|
|
|
+ target: Some((*target).to_string()),
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["disable", ..] => Err(command_error(
|
|
|
|
|
+ "Unexpected arguments for /plugin disable.",
|
|
|
|
|
+ "plugin",
|
|
|
|
|
+ "/plugin disable <name>",
|
|
|
|
|
+ )),
|
|
|
|
|
+ ["uninstall"] => Err(usage_error("plugin uninstall", "<id>")),
|
|
|
|
|
+ ["uninstall", target] => Ok(SlashCommand::Plugins {
|
|
|
|
|
+ action: Some("uninstall".to_string()),
|
|
|
|
|
+ target: Some((*target).to_string()),
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["uninstall", ..] => Err(command_error(
|
|
|
|
|
+ "Unexpected arguments for /plugin uninstall.",
|
|
|
|
|
+ "plugin",
|
|
|
|
|
+ "/plugin uninstall <id>",
|
|
|
|
|
+ )),
|
|
|
|
|
+ ["update"] => Err(usage_error("plugin update", "<id>")),
|
|
|
|
|
+ ["update", target] => Ok(SlashCommand::Plugins {
|
|
|
|
|
+ action: Some("update".to_string()),
|
|
|
|
|
+ target: Some((*target).to_string()),
|
|
|
|
|
+ }),
|
|
|
|
|
+ ["update", ..] => Err(command_error(
|
|
|
|
|
+ "Unexpected arguments for /plugin update.",
|
|
|
|
|
+ "plugin",
|
|
|
|
|
+ "/plugin update <id>",
|
|
|
|
|
+ )),
|
|
|
|
|
+ [action, ..] => Err(command_error(
|
|
|
|
|
+ &format!(
|
|
|
|
|
+ "Unknown /plugin action '{action}'. Use list, install <path>, enable <name>, disable <name>, uninstall <id>, or update <id>."
|
|
|
|
|
+ ),
|
|
|
|
|
+ "plugin",
|
|
|
|
|
+ "/plugin [list|install <path>|enable <name>|disable <name>|uninstall <id>|update <id>]",
|
|
|
|
|
+ )),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn parse_list_or_help_args(
|
|
|
|
|
+ command: &str,
|
|
|
|
|
+ args: Option<String>,
|
|
|
|
|
+) -> Result<Option<String>, SlashCommandParseError> {
|
|
|
|
|
+ match normalize_optional_args(args.as_deref()) {
|
|
|
|
|
+ None | Some("list" | "help" | "-h" | "--help") => Ok(args),
|
|
|
|
|
+ Some(unexpected) => Err(command_error(
|
|
|
|
|
+ &format!(
|
|
|
|
|
+ "Unexpected arguments for /{command}: {unexpected}. Use /{command}, /{command} list, or /{command} help."
|
|
|
|
|
+ ),
|
|
|
|
|
+ command,
|
|
|
|
|
+ &format!("/{command} [list|help]"),
|
|
|
|
|
+ )),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn usage_error(command: &str, argument_hint: &str) -> SlashCommandParseError {
|
|
|
|
|
+ let usage = format!("/{command} {argument_hint}");
|
|
|
|
|
+ let usage = usage.trim_end().to_string();
|
|
|
|
|
+ command_error(
|
|
|
|
|
+ &format!("Usage: {usage}"),
|
|
|
|
|
+ command_root_name(command),
|
|
|
|
|
+ &usage,
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn command_error(message: &str, command: &str, usage: &str) -> SlashCommandParseError {
|
|
|
|
|
+ let detail = render_slash_command_help_detail(command)
|
|
|
|
|
+ .map(|detail| format!("\n\n{detail}"))
|
|
|
|
|
+ .unwrap_or_default();
|
|
|
|
|
+ SlashCommandParseError::new(format!("{message}\n Usage {usage}{detail}"))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
fn remainder_after_command(input: &str, command: &str) -> Option<String> {
|
|
fn remainder_after_command(input: &str, command: &str) -> Option<String> {
|
|
|
input
|
|
input
|
|
|
.trim()
|
|
.trim()
|
|
@@ -381,6 +659,56 @@ fn remainder_after_command(input: &str, command: &str) -> Option<String> {
|
|
|
.map(ToOwned::to_owned)
|
|
.map(ToOwned::to_owned)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+fn find_slash_command_spec(name: &str) -> Option<&'static SlashCommandSpec> {
|
|
|
|
|
+ slash_command_specs().iter().find(|spec| {
|
|
|
|
|
+ spec.name.eq_ignore_ascii_case(name)
|
|
|
|
|
+ || spec
|
|
|
|
|
+ .aliases
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .any(|alias| alias.eq_ignore_ascii_case(name))
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn command_root_name(command: &str) -> &str {
|
|
|
|
|
+ command.split_whitespace().next().unwrap_or(command)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn slash_command_usage(spec: &SlashCommandSpec) -> String {
|
|
|
|
|
+ match spec.argument_hint {
|
|
|
|
|
+ Some(argument_hint) => format!("/{} {argument_hint}", spec.name),
|
|
|
|
|
+ None => format!("/{}", spec.name),
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+fn slash_command_detail_lines(spec: &SlashCommandSpec) -> Vec<String> {
|
|
|
|
|
+ let mut lines = vec![format!("/{}", spec.name)];
|
|
|
|
|
+ lines.push(format!(" Summary {}", spec.summary));
|
|
|
|
|
+ lines.push(format!(" Usage {}", slash_command_usage(spec)));
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " Category {}",
|
|
|
|
|
+ slash_command_category(spec.name)
|
|
|
|
|
+ ));
|
|
|
|
|
+ if !spec.aliases.is_empty() {
|
|
|
|
|
+ lines.push(format!(
|
|
|
|
|
+ " Aliases {}",
|
|
|
|
|
+ spec.aliases
|
|
|
|
|
+ .iter()
|
|
|
|
|
+ .map(|alias| format!("/{alias}"))
|
|
|
|
|
+ .collect::<Vec<_>>()
|
|
|
|
|
+ .join(", ")
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+ if spec.resume_supported {
|
|
|
|
|
+ lines.push(" Resume Supported with --resume SESSION.jsonl".to_string());
|
|
|
|
|
+ }
|
|
|
|
|
+ lines
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+#[must_use]
|
|
|
|
|
+pub fn render_slash_command_help_detail(name: &str) -> Option<String> {
|
|
|
|
|
+ find_slash_command_spec(name).map(|spec| slash_command_detail_lines(spec).join("\n"))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
#[must_use]
|
|
#[must_use]
|
|
|
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
|
|
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
|
|
|
SLASH_COMMAND_SPECS
|
|
SLASH_COMMAND_SPECS
|
|
@@ -407,10 +735,7 @@ fn slash_command_category(name: &str) -> &'static str {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String {
|
|
fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String {
|
|
|
- let name = match spec.argument_hint {
|
|
|
|
|
- Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
|
|
|
|
- None => format!("/{}", spec.name),
|
|
|
|
|
- };
|
|
|
|
|
|
|
+ let name = slash_command_usage(spec);
|
|
|
let alias_suffix = if spec.aliases.is_empty() {
|
|
let alias_suffix = if spec.aliases.is_empty() {
|
|
|
String::new()
|
|
String::new()
|
|
|
} else {
|
|
} else {
|
|
@@ -428,7 +753,7 @@ fn format_slash_command_help_line(spec: &SlashCommandSpec) -> String {
|
|
|
} else {
|
|
} else {
|
|
|
""
|
|
""
|
|
|
};
|
|
};
|
|
|
- format!(" {name:<20} {}{alias_suffix}{resume}", spec.summary)
|
|
|
|
|
|
|
+ format!(" {name:<66} {}{alias_suffix}{resume}", spec.summary)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
fn levenshtein_distance(left: &str, right: &str) -> usize {
|
|
fn levenshtein_distance(left: &str, right: &str) -> usize {
|
|
@@ -509,8 +834,8 @@ pub fn suggest_slash_commands(input: &str, limit: usize) -> Vec<String> {
|
|
|
pub fn render_slash_command_help() -> String {
|
|
pub fn render_slash_command_help() -> String {
|
|
|
let mut lines = vec![
|
|
let mut lines = vec![
|
|
|
"Slash commands".to_string(),
|
|
"Slash commands".to_string(),
|
|
|
- " Start here /status, /diff, /agents, /skills, /commit".to_string(),
|
|
|
|
|
- " [resume] means the command also works with --resume SESSION.jsonl".to_string(),
|
|
|
|
|
|
|
+ " Start here /status, /diff, /agents, /skills, /commit".to_string(),
|
|
|
|
|
+ " [resume] also works with --resume SESSION.jsonl".to_string(),
|
|
|
String::new(),
|
|
String::new(),
|
|
|
];
|
|
];
|
|
|
|
|
|
|
@@ -1282,7 +1607,18 @@ pub fn handle_slash_command(
|
|
|
session: &Session,
|
|
session: &Session,
|
|
|
compaction: CompactionConfig,
|
|
compaction: CompactionConfig,
|
|
|
) -> Option<SlashCommandResult> {
|
|
) -> Option<SlashCommandResult> {
|
|
|
- match SlashCommand::parse(input)? {
|
|
|
|
|
|
|
+ let command = match SlashCommand::parse(input) {
|
|
|
|
|
+ Ok(Some(command)) => command,
|
|
|
|
|
+ Ok(None) => return None,
|
|
|
|
|
+ Err(error) => {
|
|
|
|
|
+ return Some(SlashCommandResult {
|
|
|
|
|
+ message: error.to_string(),
|
|
|
|
|
+ session: session.clone(),
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ match command {
|
|
|
SlashCommand::Compact => {
|
|
SlashCommand::Compact => {
|
|
|
let result = compact_session(session, compaction);
|
|
let result = compact_session(session, compaction);
|
|
|
let message = if result.removed_message_count == 0 {
|
|
let message = if result.removed_message_count == 0 {
|
|
@@ -1335,8 +1671,9 @@ mod tests {
|
|
|
use super::{
|
|
use super::{
|
|
|
handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
|
|
handle_plugins_slash_command, handle_slash_command, load_agents_from_roots,
|
|
|
load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
|
|
load_skills_from_roots, render_agents_report, render_plugins_report, render_skills_report,
|
|
|
- render_slash_command_help, resume_supported_slash_commands, slash_command_specs,
|
|
|
|
|
- suggest_slash_commands, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
|
|
|
|
|
|
+ render_slash_command_help, render_slash_command_help_detail,
|
|
|
|
|
+ resume_supported_slash_commands, slash_command_specs, suggest_slash_commands,
|
|
|
|
|
+ validate_slash_command_input, DefinitionSource, SkillOrigin, SkillRoot, SlashCommand,
|
|
|
};
|
|
};
|
|
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
|
use plugins::{PluginKind, PluginManager, PluginManagerConfig, PluginMetadata, PluginSummary};
|
|
|
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
|
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
|
@@ -1405,182 +1742,288 @@ mod tests {
|
|
|
.expect("write command");
|
|
.expect("write command");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ fn parse_error_message(input: &str) -> String {
|
|
|
|
|
+ SlashCommand::parse(input)
|
|
|
|
|
+ .expect_err("slash command should be rejected")
|
|
|
|
|
+ .to_string()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[allow(clippy::too_many_lines)]
|
|
#[allow(clippy::too_many_lines)]
|
|
|
#[test]
|
|
#[test]
|
|
|
fn parses_supported_slash_commands() {
|
|
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("/sandbox"), Some(SlashCommand::Sandbox));
|
|
|
|
|
|
|
+ assert_eq!(SlashCommand::parse("/help"), Ok(Some(SlashCommand::Help)));
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ SlashCommand::parse(" /status "),
|
|
|
|
|
+ Ok(Some(SlashCommand::Status))
|
|
|
|
|
+ );
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ SlashCommand::parse("/sandbox"),
|
|
|
|
|
+ Ok(Some(SlashCommand::Sandbox))
|
|
|
|
|
+ );
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/bughunter runtime"),
|
|
SlashCommand::parse("/bughunter runtime"),
|
|
|
- Some(SlashCommand::Bughunter {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Bughunter {
|
|
|
scope: Some("runtime".to_string())
|
|
scope: Some("runtime".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
|
|
+ );
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ SlashCommand::parse("/commit"),
|
|
|
|
|
+ Ok(Some(SlashCommand::Commit))
|
|
|
);
|
|
);
|
|
|
- assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
|
|
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/pr ready for review"),
|
|
SlashCommand::parse("/pr ready for review"),
|
|
|
- Some(SlashCommand::Pr {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Pr {
|
|
|
context: Some("ready for review".to_string())
|
|
context: Some("ready for review".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/issue flaky test"),
|
|
SlashCommand::parse("/issue flaky test"),
|
|
|
- Some(SlashCommand::Issue {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Issue {
|
|
|
context: Some("flaky test".to_string())
|
|
context: Some("flaky test".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/ultraplan ship both features"),
|
|
SlashCommand::parse("/ultraplan ship both features"),
|
|
|
- Some(SlashCommand::Ultraplan {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Ultraplan {
|
|
|
task: Some("ship both features".to_string())
|
|
task: Some("ship both features".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/teleport conversation.rs"),
|
|
SlashCommand::parse("/teleport conversation.rs"),
|
|
|
- Some(SlashCommand::Teleport {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Teleport {
|
|
|
target: Some("conversation.rs".to_string())
|
|
target: Some("conversation.rs".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/debug-tool-call"),
|
|
SlashCommand::parse("/debug-tool-call"),
|
|
|
- Some(SlashCommand::DebugToolCall)
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::DebugToolCall))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/bughunter runtime"),
|
|
SlashCommand::parse("/bughunter runtime"),
|
|
|
- Some(SlashCommand::Bughunter {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Bughunter {
|
|
|
scope: Some("runtime".to_string())
|
|
scope: Some("runtime".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
|
|
+ );
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ SlashCommand::parse("/commit"),
|
|
|
|
|
+ Ok(Some(SlashCommand::Commit))
|
|
|
);
|
|
);
|
|
|
- assert_eq!(SlashCommand::parse("/commit"), Some(SlashCommand::Commit));
|
|
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/pr ready for review"),
|
|
SlashCommand::parse("/pr ready for review"),
|
|
|
- Some(SlashCommand::Pr {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Pr {
|
|
|
context: Some("ready for review".to_string())
|
|
context: Some("ready for review".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/issue flaky test"),
|
|
SlashCommand::parse("/issue flaky test"),
|
|
|
- Some(SlashCommand::Issue {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Issue {
|
|
|
context: Some("flaky test".to_string())
|
|
context: Some("flaky test".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/ultraplan ship both features"),
|
|
SlashCommand::parse("/ultraplan ship both features"),
|
|
|
- Some(SlashCommand::Ultraplan {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Ultraplan {
|
|
|
task: Some("ship both features".to_string())
|
|
task: Some("ship both features".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/teleport conversation.rs"),
|
|
SlashCommand::parse("/teleport conversation.rs"),
|
|
|
- Some(SlashCommand::Teleport {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Teleport {
|
|
|
target: Some("conversation.rs".to_string())
|
|
target: Some("conversation.rs".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/debug-tool-call"),
|
|
SlashCommand::parse("/debug-tool-call"),
|
|
|
- Some(SlashCommand::DebugToolCall)
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::DebugToolCall))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/model claude-opus"),
|
|
SlashCommand::parse("/model claude-opus"),
|
|
|
- Some(SlashCommand::Model {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Model {
|
|
|
model: Some("claude-opus".to_string()),
|
|
model: Some("claude-opus".to_string()),
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/model"),
|
|
SlashCommand::parse("/model"),
|
|
|
- Some(SlashCommand::Model { model: None })
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Model { model: None }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/permissions read-only"),
|
|
SlashCommand::parse("/permissions read-only"),
|
|
|
- Some(SlashCommand::Permissions {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Permissions {
|
|
|
mode: Some("read-only".to_string()),
|
|
mode: Some("read-only".to_string()),
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/clear"),
|
|
SlashCommand::parse("/clear"),
|
|
|
- Some(SlashCommand::Clear { confirm: false })
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Clear { confirm: false }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/clear --confirm"),
|
|
SlashCommand::parse("/clear --confirm"),
|
|
|
- Some(SlashCommand::Clear { confirm: true })
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Clear { confirm: true }))
|
|
|
);
|
|
);
|
|
|
- assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
|
|
|
|
|
|
|
+ assert_eq!(SlashCommand::parse("/cost"), Ok(Some(SlashCommand::Cost)));
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/resume session.json"),
|
|
SlashCommand::parse("/resume session.json"),
|
|
|
- Some(SlashCommand::Resume {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Resume {
|
|
|
session_path: Some("session.json".to_string()),
|
|
session_path: Some("session.json".to_string()),
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/config"),
|
|
SlashCommand::parse("/config"),
|
|
|
- Some(SlashCommand::Config { section: None })
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Config { section: None }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/config env"),
|
|
SlashCommand::parse("/config env"),
|
|
|
- Some(SlashCommand::Config {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Config {
|
|
|
section: Some("env".to_string())
|
|
section: Some("env".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
|
|
+ );
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ SlashCommand::parse("/memory"),
|
|
|
|
|
+ Ok(Some(SlashCommand::Memory))
|
|
|
|
|
+ );
|
|
|
|
|
+ assert_eq!(SlashCommand::parse("/init"), Ok(Some(SlashCommand::Init)));
|
|
|
|
|
+ assert_eq!(SlashCommand::parse("/diff"), Ok(Some(SlashCommand::Diff)));
|
|
|
|
|
+ assert_eq!(
|
|
|
|
|
+ SlashCommand::parse("/version"),
|
|
|
|
|
+ Ok(Some(SlashCommand::Version))
|
|
|
);
|
|
);
|
|
|
- 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!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/export notes.txt"),
|
|
SlashCommand::parse("/export notes.txt"),
|
|
|
- Some(SlashCommand::Export {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Export {
|
|
|
path: Some("notes.txt".to_string())
|
|
path: Some("notes.txt".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/session switch abc123"),
|
|
SlashCommand::parse("/session switch abc123"),
|
|
|
- Some(SlashCommand::Session {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Session {
|
|
|
action: Some("switch".to_string()),
|
|
action: Some("switch".to_string()),
|
|
|
target: Some("abc123".to_string())
|
|
target: Some("abc123".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/plugins install demo"),
|
|
SlashCommand::parse("/plugins install demo"),
|
|
|
- Some(SlashCommand::Plugins {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Plugins {
|
|
|
action: Some("install".to_string()),
|
|
action: Some("install".to_string()),
|
|
|
target: Some("demo".to_string())
|
|
target: Some("demo".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/plugins list"),
|
|
SlashCommand::parse("/plugins list"),
|
|
|
- Some(SlashCommand::Plugins {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Plugins {
|
|
|
action: Some("list".to_string()),
|
|
action: Some("list".to_string()),
|
|
|
target: None
|
|
target: None
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/plugins enable demo"),
|
|
SlashCommand::parse("/plugins enable demo"),
|
|
|
- Some(SlashCommand::Plugins {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Plugins {
|
|
|
action: Some("enable".to_string()),
|
|
action: Some("enable".to_string()),
|
|
|
target: Some("demo".to_string())
|
|
target: Some("demo".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/plugins disable demo"),
|
|
SlashCommand::parse("/plugins disable demo"),
|
|
|
- Some(SlashCommand::Plugins {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Plugins {
|
|
|
action: Some("disable".to_string()),
|
|
action: Some("disable".to_string()),
|
|
|
target: Some("demo".to_string())
|
|
target: Some("demo".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
assert_eq!(
|
|
assert_eq!(
|
|
|
SlashCommand::parse("/session fork incident-review"),
|
|
SlashCommand::parse("/session fork incident-review"),
|
|
|
- Some(SlashCommand::Session {
|
|
|
|
|
|
|
+ Ok(Some(SlashCommand::Session {
|
|
|
action: Some("fork".to_string()),
|
|
action: Some("fork".to_string()),
|
|
|
target: Some("incident-review".to_string())
|
|
target: Some("incident-review".to_string())
|
|
|
- })
|
|
|
|
|
|
|
+ }))
|
|
|
);
|
|
);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn rejects_unexpected_arguments_for_no_arg_commands() {
|
|
|
|
|
+ // given
|
|
|
|
|
+ let input = "/compact now";
|
|
|
|
|
+
|
|
|
|
|
+ // when
|
|
|
|
|
+ let error = parse_error_message(input);
|
|
|
|
|
+
|
|
|
|
|
+ // then
|
|
|
|
|
+ assert!(error.contains("Unexpected arguments for /compact."));
|
|
|
|
|
+ assert!(error.contains(" Usage /compact"));
|
|
|
|
|
+ assert!(error.contains(" Summary Compact local session history"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn rejects_invalid_argument_values() {
|
|
|
|
|
+ // given
|
|
|
|
|
+ let input = "/permissions admin";
|
|
|
|
|
+
|
|
|
|
|
+ // when
|
|
|
|
|
+ let error = parse_error_message(input);
|
|
|
|
|
+
|
|
|
|
|
+ // then
|
|
|
|
|
+ assert!(error.contains(
|
|
|
|
|
+ "Unsupported /permissions mode 'admin'. Use read-only, workspace-write, or danger-full-access."
|
|
|
|
|
+ ));
|
|
|
|
|
+ assert!(error.contains(
|
|
|
|
|
+ " Usage /permissions [read-only|workspace-write|danger-full-access]"
|
|
|
|
|
+ ));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn rejects_missing_required_arguments() {
|
|
|
|
|
+ // given
|
|
|
|
|
+ let input = "/teleport";
|
|
|
|
|
+
|
|
|
|
|
+ // when
|
|
|
|
|
+ let error = parse_error_message(input);
|
|
|
|
|
+
|
|
|
|
|
+ // then
|
|
|
|
|
+ assert!(error.contains("Usage: /teleport <symbol-or-path>"));
|
|
|
|
|
+ assert!(error.contains(" Category Discovery & debugging"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn rejects_invalid_session_and_plugin_shapes() {
|
|
|
|
|
+ // given
|
|
|
|
|
+ let session_input = "/session switch";
|
|
|
|
|
+ let plugin_input = "/plugins list extra";
|
|
|
|
|
+
|
|
|
|
|
+ // when
|
|
|
|
|
+ let session_error = parse_error_message(session_input);
|
|
|
|
|
+ let plugin_error = parse_error_message(plugin_input);
|
|
|
|
|
+
|
|
|
|
|
+ // then
|
|
|
|
|
+ assert!(session_error.contains("Usage: /session switch <session-id>"));
|
|
|
|
|
+ assert!(session_error.contains("/session"));
|
|
|
|
|
+ assert!(plugin_error.contains("Usage: /plugin list"));
|
|
|
|
|
+ assert!(plugin_error.contains("Aliases /plugins, /marketplace"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn rejects_invalid_agents_and_skills_arguments() {
|
|
|
|
|
+ // given
|
|
|
|
|
+ let agents_input = "/agents show planner";
|
|
|
|
|
+ let skills_input = "/skills show help";
|
|
|
|
|
+
|
|
|
|
|
+ // when
|
|
|
|
|
+ let agents_error = parse_error_message(agents_input);
|
|
|
|
|
+ let skills_error = parse_error_message(skills_input);
|
|
|
|
|
+
|
|
|
|
|
+ // then
|
|
|
|
|
+ assert!(agents_error.contains(
|
|
|
|
|
+ "Unexpected arguments for /agents: show planner. Use /agents, /agents list, or /agents help."
|
|
|
|
|
+ ));
|
|
|
|
|
+ assert!(agents_error.contains(" Usage /agents [list|help]"));
|
|
|
|
|
+ assert!(skills_error.contains(
|
|
|
|
|
+ "Unexpected arguments for /skills: show help. Use /skills, /skills list, or /skills help."
|
|
|
|
|
+ ));
|
|
|
|
|
+ assert!(skills_error.contains(" Usage /skills [list|help]"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn renders_help_from_shared_specs() {
|
|
fn renders_help_from_shared_specs() {
|
|
|
let help = render_slash_command_help();
|
|
let help = render_slash_command_help();
|
|
|
- assert!(help.contains("Start here /status, /diff, /agents, /skills, /commit"));
|
|
|
|
|
- assert!(help.contains("works with --resume SESSION.jsonl"));
|
|
|
|
|
|
|
+ assert!(help.contains("Start here /status, /diff, /agents, /skills, /commit"));
|
|
|
|
|
+ assert!(help.contains("[resume] also works with --resume SESSION.jsonl"));
|
|
|
assert!(help.contains("Session & visibility"));
|
|
assert!(help.contains("Session & visibility"));
|
|
|
assert!(help.contains("Workspace & git"));
|
|
assert!(help.contains("Workspace & git"));
|
|
|
assert!(help.contains("Discovery & debugging"));
|
|
assert!(help.contains("Discovery & debugging"));
|
|
@@ -1619,6 +2062,42 @@ mod tests {
|
|
|
assert_eq!(resume_supported_slash_commands().len(), 14);
|
|
assert_eq!(resume_supported_slash_commands().len(), 14);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn renders_per_command_help_detail() {
|
|
|
|
|
+ // given
|
|
|
|
|
+ let command = "plugins";
|
|
|
|
|
+
|
|
|
|
|
+ // when
|
|
|
|
|
+ let help = render_slash_command_help_detail(command).expect("detail help should exist");
|
|
|
|
|
+
|
|
|
|
|
+ // then
|
|
|
|
|
+ assert!(help.contains("/plugin"));
|
|
|
|
|
+ assert!(help.contains("Summary Manage Claw Code plugins"));
|
|
|
|
|
+ assert!(help.contains("Aliases /plugins, /marketplace"));
|
|
|
|
|
+ assert!(help.contains("Category Workspace & git"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #[test]
|
|
|
|
|
+ fn validate_slash_command_input_rejects_extra_single_value_arguments() {
|
|
|
|
|
+ // given
|
|
|
|
|
+ let session_input = "/session switch current next";
|
|
|
|
|
+ let plugin_input = "/plugin enable demo extra";
|
|
|
|
|
+
|
|
|
|
|
+ // when
|
|
|
|
|
+ let session_error = validate_slash_command_input(session_input)
|
|
|
|
|
+ .expect_err("session input should be rejected")
|
|
|
|
|
+ .to_string();
|
|
|
|
|
+ let plugin_error = validate_slash_command_input(plugin_input)
|
|
|
|
|
+ .expect_err("plugin input should be rejected")
|
|
|
|
|
+ .to_string();
|
|
|
|
|
+
|
|
|
|
|
+ // then
|
|
|
|
|
+ assert!(session_error.contains("Unexpected arguments for /session switch."));
|
|
|
|
|
+ assert!(session_error.contains(" Usage /session switch <session-id>"));
|
|
|
|
|
+ assert!(plugin_error.contains("Unexpected arguments for /plugin enable."));
|
|
|
|
|
+ assert!(plugin_error.contains(" Usage /plugin enable <name>"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
#[test]
|
|
#[test]
|
|
|
fn suggests_closest_slash_commands_for_typos_and_aliases() {
|
|
fn suggests_closest_slash_commands_for_typos_and_aliases() {
|
|
|
assert_eq!(suggest_slash_commands("stats", 3), vec!["/status"]);
|
|
assert_eq!(suggest_slash_commands("stats", 3), vec!["/status"]);
|