Skip to main content

arcan_commands/
lib.rs

1//! Slash command system for the Arcan agent runtime.
2//!
3//! Provides a [`CommandRegistry`] that dispatches `/`-prefixed user input to
4//! built-in commands (`/help`, `/clear`, `/cost`, `/quit`, `/diff`).
5
6mod clear;
7mod commit;
8mod compact;
9mod config_cmd;
10mod consolidate;
11mod context;
12mod cost;
13mod diff;
14mod help;
15mod history;
16mod memory;
17mod model;
18mod quit;
19mod reasoning;
20mod search;
21mod skill;
22mod status;
23mod undo;
24
25use std::collections::{BTreeMap, HashSet};
26use std::path::PathBuf;
27
28/// Result of executing a slash command.
29#[derive(Debug)]
30pub enum CommandResult {
31    /// Text output to display to the user.
32    Output(String),
33    /// Clear the conversation history and start a new session.
34    ClearSession,
35    /// Compact conversation history to reduce token usage.
36    CompactRequested,
37    /// Run memory consolidation (decay, extract patterns, prune).
38    ConsolidateRequested,
39    /// Exit the REPL.
40    Quit,
41    /// An error occurred during command execution.
42    Error(String),
43}
44
45/// Mutable context passed to every command invocation.
46#[derive(Debug, Default)]
47pub struct CommandContext {
48    /// Accumulated cost in USD for this session.
49    pub session_cost_usd: f64,
50    /// Input tokens consumed this session.
51    pub session_input_tokens: u64,
52    /// Output tokens consumed this session.
53    pub session_output_tokens: u64,
54    /// Number of user turns in this session.
55    pub session_turns: u32,
56    /// Workspace root directory.
57    pub workspace: PathBuf,
58    /// Pre-rendered help text (set by the registry).
59    pub help_text: String,
60    /// Tools the user has permanently approved for this session (via "always" response).
61    pub session_approved_tools: HashSet<String>,
62    /// Permission mode: "default" (prompt), "yes" (auto-approve all), "plan" (deny all writes).
63    pub permission_mode: PermissionMode,
64    /// Directory for persistent agent memory files (`.arcan/memory/`).
65    pub memory_dir: PathBuf,
66    /// Provider name (e.g. "anthropic", "openai", "mock").
67    pub provider_name: String,
68    /// Current model name (e.g. "claude-sonnet-4-20250514").
69    pub model_name: String,
70    /// Model override requested via `/model` command (applied on next turn).
71    pub model_override: Option<String>,
72    /// Data directory for persistent storage (`.arcan/`).
73    pub data_dir: PathBuf,
74    /// Number of messages in the conversation history.
75    pub message_count: usize,
76    /// Number of tool calls executed this session.
77    pub tool_call_count: usize,
78    /// Number of registered tools.
79    pub tools_count: usize,
80    /// Number of registered hooks.
81    pub hooks_count: usize,
82    /// Names of discovered skills.
83    pub skill_names: Vec<String>,
84    /// Latest Nous evaluation scores: `(evaluator_name, score_value)`.
85    pub nous_scores: Vec<(String, f64)>,
86    /// Session budget in USD (set via `--budget`). `None` means unlimited.
87    pub budget_usd: Option<f64>,
88    /// Autonomic economic mode label (e.g. "Sovereign", "Conserving").
89    /// Populated when `ARCAN_AUTONOMIC_URL` is configured.
90    pub economic_mode: Option<String>,
91    /// Workspace journal status string (e.g. path or "unavailable").
92    /// Set when a shared Lance workspace journal is opened.
93    pub workspace_journal_status: Option<String>,
94    /// Estimated tokens for project instructions (CLAUDE.md, AGENTS.md, docs/).
95    pub project_instructions_tokens: usize,
96    /// Estimated tokens for git context section.
97    pub git_context_tokens: usize,
98    /// Estimated tokens for MEMORY.md index.
99    pub memory_index_tokens: usize,
100    /// Estimated tokens for shared workspace journal context.
101    pub workspace_context_tokens: usize,
102    /// Estimated tokens for skills catalog.
103    pub skills_catalog_tokens: usize,
104    /// Whether to display reasoning/thinking tokens in the output.
105    pub show_reasoning: bool,
106    /// Current Autonomic context ruling (e.g. "Breathe — pressure 42%, quality 0.85, ...").
107    pub context_ruling: Option<String>,
108    /// Context window size in tokens (from provider).
109    pub context_window: Option<usize>,
110    /// Identity tier label (e.g. "pro", "free", "anonymous").
111    pub identity_tier: Option<String>,
112    /// Identity subject (e.g. "user@example.com").
113    pub identity_subject: Option<String>,
114}
115
116/// Permission mode governing tool approval in the shell.
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum PermissionMode {
119    /// Prompt the user for non-read-only tools.
120    #[default]
121    Default,
122    /// Auto-approve all tools (--yes flag).
123    Yes,
124    /// Plan mode: deny all write/destructive tools.
125    Plan,
126}
127
128/// Well-known read-only tool names that never require permission prompts.
129const READ_ONLY_TOOLS: &[&str] = &[
130    "glob",
131    "grep",
132    "file_read",
133    "list_dir",
134    "read_file",
135    "list_directory",
136    "memory_read",
137    "memory_search",
138    "memory_browse",
139    "memory_recent",
140    "memory_similar",
141    "read_memory",
142];
143
144/// Determine whether a tool requires user permission before execution.
145///
146/// Returns `true` if the tool should be auto-approved (no prompt needed),
147/// `false` if the user must be prompted.
148pub fn is_tool_auto_approved(
149    tool_name: &str,
150    permission_mode: PermissionMode,
151    session_approved: &HashSet<String>,
152    is_read_only_annotation: bool,
153) -> bool {
154    // --yes mode: everything is auto-approved
155    if permission_mode == PermissionMode::Yes {
156        return true;
157    }
158
159    // Tools with read_only annotation or in the well-known list
160    if is_read_only_annotation || READ_ONLY_TOOLS.contains(&tool_name) {
161        return true;
162    }
163
164    // User previously chose "always" for this tool
165    if session_approved.contains(tool_name) {
166        return true;
167    }
168
169    false
170}
171
172/// Prompt the user for permission to execute a tool.
173///
174/// Returns the user's choice: `'y'` (once), `'n'` (deny), or `'a'` (always).
175/// On EOF or invalid input, defaults to `'n'`.
176#[allow(clippy::print_stderr)]
177pub fn prompt_tool_permission(tool_name: &str) -> char {
178    use std::io::Write;
179
180    eprint!("[y/n/a] Allow {tool_name}? ");
181    std::io::stderr().flush().ok();
182
183    let mut response = String::new();
184    match std::io::stdin().read_line(&mut response) {
185        Ok(0) => 'n', // EOF
186        Ok(_) => match response.trim().to_lowercase().as_str() {
187            "y" | "yes" => 'y',
188            "a" | "always" => 'a',
189            _ => 'n',
190        },
191        Err(_) => 'n',
192    }
193}
194
195/// Trait implemented by each slash command.
196pub trait Command: Send + Sync {
197    /// Primary name (without the leading `/`).
198    fn name(&self) -> &str;
199
200    /// Alternative names that also dispatch to this command.
201    fn aliases(&self) -> &[&str];
202
203    /// One-line description shown in `/help`.
204    fn description(&self) -> &str;
205
206    /// Execute the command with the given arguments and mutable context.
207    fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult;
208}
209
210/// Registry of slash commands with dispatch by name or alias.
211pub struct CommandRegistry {
212    /// Canonical name -> command implementation.
213    commands: BTreeMap<String, Box<dyn Command>>,
214    /// Alias -> canonical name.
215    aliases: BTreeMap<String, String>,
216    /// Cached help text.
217    help_text: String,
218}
219
220impl CommandRegistry {
221    /// Create an empty registry.
222    pub fn new() -> Self {
223        Self {
224            commands: BTreeMap::new(),
225            aliases: BTreeMap::new(),
226            help_text: String::new(),
227        }
228    }
229
230    /// Create a registry with all built-in commands pre-registered.
231    pub fn with_builtins() -> Self {
232        let mut registry = Self::new();
233        registry.register(Box::new(help::HelpCommand));
234        registry.register(Box::new(clear::ClearCommand));
235        registry.register(Box::new(compact::CompactCommand));
236        registry.register(Box::new(cost::CostCommand));
237        registry.register(Box::new(quit::QuitCommand));
238        registry.register(Box::new(diff::DiffCommand));
239        registry.register(Box::new(memory::MemoryCommand));
240        registry.register(Box::new(status::StatusCommand));
241        registry.register(Box::new(model::ModelCommand));
242        registry.register(Box::new(commit::CommitCommand));
243        registry.register(Box::new(config_cmd::ConfigCommand));
244        registry.register(Box::new(undo::UndoCommand));
245        registry.register(Box::new(history::HistoryCommand));
246        registry.register(Box::new(skill::SkillCommand));
247        registry.register(Box::new(context::ContextCommand));
248        registry.register(Box::new(consolidate::ConsolidateCommand));
249        registry.register(Box::new(search::SearchCommand));
250        registry.register(Box::new(reasoning::ReasoningCommand));
251        registry.rebuild_help_text();
252        registry
253    }
254
255    /// Check if a name (or alias) is registered as a built-in command.
256    pub fn has_command(&self, name: &str) -> bool {
257        let name = name.strip_prefix('/').unwrap_or(name);
258        self.commands.contains_key(name) || self.aliases.contains_key(name)
259    }
260
261    /// Register a command. Overwrites any existing command with the same name.
262    pub fn register(&mut self, cmd: Box<dyn Command>) {
263        let name = cmd.name().to_string();
264        for alias in cmd.aliases() {
265            self.aliases.insert((*alias).to_string(), name.clone());
266        }
267        self.commands.insert(name, cmd);
268        self.rebuild_help_text();
269    }
270
271    /// Dispatch a `/`-prefixed input string. Returns `None` if the name is unknown.
272    pub fn execute(&self, input: &str, ctx: &mut CommandContext) -> Option<CommandResult> {
273        let input = input.strip_prefix('/').unwrap_or(input);
274        let (name, args) = match input.split_once(char::is_whitespace) {
275            Some((n, a)) => (n, a.trim()),
276            None => (input.trim(), ""),
277        };
278
279        // Inject help text so /help can display it.
280        ctx.help_text.clone_from(&self.help_text);
281
282        let canonical = self.aliases.get(name).map(String::as_str).unwrap_or(name);
283
284        self.commands
285            .get(canonical)
286            .map(|cmd| cmd.execute(args, ctx))
287    }
288
289    /// Get the rendered help text for all registered commands.
290    pub fn help_text(&self) -> &str {
291        &self.help_text
292    }
293
294    /// Return commands matching a prefix (for slash-command hints).
295    ///
296    /// Given `"/"` returns all commands. Given `"/co"` returns `/compact`, `/commit`,
297    /// `/config`, `/consolidate`, `/context`, `/cost`. Matches against names and aliases.
298    pub fn matching_commands(&self, prefix: &str) -> Vec<(String, String)> {
299        let prefix = prefix.strip_prefix('/').unwrap_or(prefix);
300        let mut matches: Vec<(String, String)> = Vec::new();
301
302        for cmd in self.commands.values() {
303            if cmd.name().starts_with(prefix) {
304                matches.push((format!("/{}", cmd.name()), cmd.description().to_string()));
305            }
306            for alias in cmd.aliases() {
307                if alias.starts_with(prefix) && !cmd.name().starts_with(prefix) {
308                    matches.push((format!("/{alias}"), cmd.description().to_string()));
309                }
310            }
311        }
312
313        matches.sort_by(|a, b| a.0.cmp(&b.0));
314        matches
315    }
316
317    fn rebuild_help_text(&mut self) {
318        let mut lines = vec!["Available commands:".to_string()];
319        for cmd in self.commands.values() {
320            let aliases = cmd.aliases();
321            let alias_str = if aliases.is_empty() {
322                String::new()
323            } else {
324                let formatted: Vec<String> = aliases.iter().map(|a| format!("/{a}")).collect();
325                format!(" ({})", formatted.join(", "))
326            };
327            lines.push(format!(
328                "  /{}{} — {}",
329                cmd.name(),
330                alias_str,
331                cmd.description()
332            ));
333        }
334        self.help_text = lines.join("\n");
335    }
336}
337
338impl Default for CommandRegistry {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347
348    #[test]
349    fn registry_dispatches_by_name() {
350        let registry = CommandRegistry::with_builtins();
351        let mut ctx = CommandContext::default();
352        let result = registry.execute("/help", &mut ctx);
353        assert!(result.is_some());
354        assert!(matches!(result.unwrap(), CommandResult::Output(_)));
355    }
356
357    #[test]
358    fn registry_dispatches_by_alias() {
359        let registry = CommandRegistry::with_builtins();
360        let mut ctx = CommandContext::default();
361
362        // /q is an alias for /quit
363        let result = registry.execute("/q", &mut ctx);
364        assert!(matches!(result.unwrap(), CommandResult::Quit));
365
366        // /exit is an alias for /quit
367        let result = registry.execute("/exit", &mut ctx);
368        assert!(matches!(result.unwrap(), CommandResult::Quit));
369    }
370
371    #[test]
372    fn registry_returns_none_for_unknown() {
373        let registry = CommandRegistry::with_builtins();
374        let mut ctx = CommandContext::default();
375        assert!(registry.execute("/nonexistent", &mut ctx).is_none());
376    }
377
378    #[test]
379    fn help_text_lists_all_commands() {
380        let registry = CommandRegistry::with_builtins();
381        let text = registry.help_text();
382        assert!(text.contains("/help"));
383        assert!(text.contains("/clear"));
384        assert!(text.contains("/compact"));
385        assert!(text.contains("/cost"));
386        assert!(text.contains("/quit"));
387        assert!(text.contains("/diff"));
388        assert!(text.contains("/status"));
389        assert!(text.contains("/model"));
390        assert!(text.contains("/commit"));
391        assert!(text.contains("/config"));
392        assert!(text.contains("/undo"));
393        assert!(text.contains("/history"));
394        assert!(text.contains("/skill"));
395    }
396
397    #[test]
398    fn new_command_aliases_dispatch() {
399        let registry = CommandRegistry::with_builtins();
400        let mut ctx = CommandContext::default();
401
402        // /info -> /status
403        let result = registry.execute("/info", &mut ctx);
404        assert!(result.is_some());
405        assert!(matches!(result.unwrap(), CommandResult::Output(_)));
406
407        // /git-status -> /commit
408        let result = registry.execute("/git-status", &mut ctx);
409        assert!(result.is_some());
410
411        // /settings -> /config
412        let result = registry.execute("/settings", &mut ctx);
413        assert!(result.is_some());
414
415        // /messages -> /history
416        let result = registry.execute("/messages", &mut ctx);
417        assert!(result.is_some());
418
419        // /skills -> /skill
420        let result = registry.execute("/skills", &mut ctx);
421        assert!(result.is_some());
422    }
423
424    #[test]
425    fn has_command_checks_names_and_aliases() {
426        let registry = CommandRegistry::with_builtins();
427        assert!(registry.has_command("help"));
428        assert!(registry.has_command("/help"));
429        assert!(registry.has_command("status"));
430        assert!(registry.has_command("/info"));
431        assert!(registry.has_command("skill"));
432        assert!(registry.has_command("/skills"));
433        assert!(!registry.has_command("nonexistent"));
434    }
435
436    #[test]
437    fn command_context_new_fields_default() {
438        let ctx = CommandContext::default();
439        assert!(ctx.provider_name.is_empty());
440        assert!(ctx.model_name.is_empty());
441        assert!(ctx.model_override.is_none());
442        assert_eq!(ctx.message_count, 0);
443        assert_eq!(ctx.tool_call_count, 0);
444        assert_eq!(ctx.tools_count, 0);
445        assert_eq!(ctx.hooks_count, 0);
446        assert!(ctx.skill_names.is_empty());
447        assert!(ctx.nous_scores.is_empty());
448        assert!(ctx.budget_usd.is_none());
449        assert!(ctx.economic_mode.is_none());
450    }
451
452    #[test]
453    fn cost_alias_usage() {
454        let registry = CommandRegistry::with_builtins();
455        let mut ctx = CommandContext {
456            session_turns: 3,
457            session_input_tokens: 100,
458            session_output_tokens: 50,
459            ..Default::default()
460        };
461        let result = registry.execute("/usage", &mut ctx);
462        assert!(result.is_some());
463        match result.unwrap() {
464            CommandResult::Output(text) => {
465                assert!(text.contains("Turns:  3"));
466                assert!(text.contains("Tokens: 150"));
467            }
468            other => panic!("expected Output, got {other:?}"),
469        }
470    }
471
472    #[test]
473    fn slash_prefix_is_optional() {
474        let registry = CommandRegistry::with_builtins();
475        let mut ctx = CommandContext::default();
476        // Without leading /
477        let result = registry.execute("help", &mut ctx);
478        assert!(matches!(result.unwrap(), CommandResult::Output(_)));
479    }
480
481    #[test]
482    fn args_are_passed_through() {
483        let registry = CommandRegistry::with_builtins();
484        let mut ctx = CommandContext::default();
485        // /help with trailing args — should still work
486        let result = registry.execute("/help some args", &mut ctx);
487        assert!(matches!(result.unwrap(), CommandResult::Output(_)));
488    }
489
490    // ── Permission logic tests ──
491
492    #[test]
493    fn read_only_tools_auto_approved() {
494        let empty = HashSet::new();
495        assert!(is_tool_auto_approved(
496            "glob",
497            PermissionMode::Default,
498            &empty,
499            false
500        ));
501        assert!(is_tool_auto_approved(
502            "grep",
503            PermissionMode::Default,
504            &empty,
505            false
506        ));
507        assert!(is_tool_auto_approved(
508            "file_read",
509            PermissionMode::Default,
510            &empty,
511            false
512        ));
513        assert!(is_tool_auto_approved(
514            "list_dir",
515            PermissionMode::Default,
516            &empty,
517            false
518        ));
519        assert!(is_tool_auto_approved(
520            "read_file",
521            PermissionMode::Default,
522            &empty,
523            false
524        ));
525    }
526
527    #[test]
528    fn read_only_annotation_auto_approved() {
529        let empty = HashSet::new();
530        // Even an unknown tool with read_only annotation should be auto-approved
531        assert!(is_tool_auto_approved(
532            "custom_reader",
533            PermissionMode::Default,
534            &empty,
535            true
536        ));
537    }
538
539    #[test]
540    fn yes_mode_auto_approves_all() {
541        let empty = HashSet::new();
542        assert!(is_tool_auto_approved(
543            "bash",
544            PermissionMode::Yes,
545            &empty,
546            false
547        ));
548        assert!(is_tool_auto_approved(
549            "write_file",
550            PermissionMode::Yes,
551            &empty,
552            false
553        ));
554        assert!(is_tool_auto_approved(
555            "edit_file",
556            PermissionMode::Yes,
557            &empty,
558            false
559        ));
560    }
561
562    #[test]
563    fn session_memory_works_after_always() {
564        let mut approved = HashSet::new();
565        // bash is not auto-approved by default
566        assert!(!is_tool_auto_approved(
567            "bash",
568            PermissionMode::Default,
569            &approved,
570            false
571        ));
572
573        // After adding to session_approved, it should be auto-approved
574        approved.insert("bash".to_string());
575        assert!(is_tool_auto_approved(
576            "bash",
577            PermissionMode::Default,
578            &approved,
579            false
580        ));
581    }
582
583    #[test]
584    fn non_read_only_tools_require_permission() {
585        let empty = HashSet::new();
586        assert!(!is_tool_auto_approved(
587            "bash",
588            PermissionMode::Default,
589            &empty,
590            false
591        ));
592        assert!(!is_tool_auto_approved(
593            "write_file",
594            PermissionMode::Default,
595            &empty,
596            false
597        ));
598        assert!(!is_tool_auto_approved(
599            "edit_file",
600            PermissionMode::Default,
601            &empty,
602            false
603        ));
604    }
605
606    #[test]
607    fn plan_mode_still_requires_permission_for_writes() {
608        let empty = HashSet::new();
609        // Plan mode does NOT auto-approve write tools
610        assert!(!is_tool_auto_approved(
611            "bash",
612            PermissionMode::Plan,
613            &empty,
614            false
615        ));
616        // But read-only tools are still auto-approved
617        assert!(is_tool_auto_approved(
618            "glob",
619            PermissionMode::Plan,
620            &empty,
621            false
622        ));
623    }
624
625    #[test]
626    fn permission_mode_default_trait() {
627        assert_eq!(PermissionMode::default(), PermissionMode::Default);
628    }
629
630    #[test]
631    fn command_context_default_has_empty_approved_tools() {
632        let ctx = CommandContext::default();
633        assert!(ctx.session_approved_tools.is_empty());
634        assert_eq!(ctx.permission_mode, PermissionMode::Default);
635    }
636}