1mod 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#[derive(Debug)]
30pub enum CommandResult {
31 Output(String),
33 ClearSession,
35 CompactRequested,
37 ConsolidateRequested,
39 Quit,
41 Error(String),
43}
44
45#[derive(Debug, Default)]
47pub struct CommandContext {
48 pub session_cost_usd: f64,
50 pub session_input_tokens: u64,
52 pub session_output_tokens: u64,
54 pub session_turns: u32,
56 pub workspace: PathBuf,
58 pub help_text: String,
60 pub session_approved_tools: HashSet<String>,
62 pub permission_mode: PermissionMode,
64 pub memory_dir: PathBuf,
66 pub provider_name: String,
68 pub model_name: String,
70 pub model_override: Option<String>,
72 pub data_dir: PathBuf,
74 pub message_count: usize,
76 pub tool_call_count: usize,
78 pub tools_count: usize,
80 pub hooks_count: usize,
82 pub skill_names: Vec<String>,
84 pub nous_scores: Vec<(String, f64)>,
86 pub budget_usd: Option<f64>,
88 pub economic_mode: Option<String>,
91 pub workspace_journal_status: Option<String>,
94 pub project_instructions_tokens: usize,
96 pub git_context_tokens: usize,
98 pub memory_index_tokens: usize,
100 pub workspace_context_tokens: usize,
102 pub skills_catalog_tokens: usize,
104 pub show_reasoning: bool,
106 pub context_ruling: Option<String>,
108 pub context_window: Option<usize>,
110 pub identity_tier: Option<String>,
112 pub identity_subject: Option<String>,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
118pub enum PermissionMode {
119 #[default]
121 Default,
122 Yes,
124 Plan,
126}
127
128const 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
144pub 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 if permission_mode == PermissionMode::Yes {
156 return true;
157 }
158
159 if is_read_only_annotation || READ_ONLY_TOOLS.contains(&tool_name) {
161 return true;
162 }
163
164 if session_approved.contains(tool_name) {
166 return true;
167 }
168
169 false
170}
171
172#[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', 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
195pub trait Command: Send + Sync {
197 fn name(&self) -> &str;
199
200 fn aliases(&self) -> &[&str];
202
203 fn description(&self) -> &str;
205
206 fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult;
208}
209
210pub struct CommandRegistry {
212 commands: BTreeMap<String, Box<dyn Command>>,
214 aliases: BTreeMap<String, String>,
216 help_text: String,
218}
219
220impl CommandRegistry {
221 pub fn new() -> Self {
223 Self {
224 commands: BTreeMap::new(),
225 aliases: BTreeMap::new(),
226 help_text: String::new(),
227 }
228 }
229
230 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 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 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 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 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 pub fn help_text(&self) -> &str {
291 &self.help_text
292 }
293
294 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 let result = registry.execute("/q", &mut ctx);
364 assert!(matches!(result.unwrap(), CommandResult::Quit));
365
366 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 let result = registry.execute("/info", &mut ctx);
404 assert!(result.is_some());
405 assert!(matches!(result.unwrap(), CommandResult::Output(_)));
406
407 let result = registry.execute("/git-status", &mut ctx);
409 assert!(result.is_some());
410
411 let result = registry.execute("/settings", &mut ctx);
413 assert!(result.is_some());
414
415 let result = registry.execute("/messages", &mut ctx);
417 assert!(result.is_some());
418
419 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 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 let result = registry.execute("/help some args", &mut ctx);
487 assert!(matches!(result.unwrap(), CommandResult::Output(_)));
488 }
489
490 #[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 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 assert!(!is_tool_auto_approved(
567 "bash",
568 PermissionMode::Default,
569 &approved,
570 false
571 ));
572
573 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 assert!(!is_tool_auto_approved(
611 "bash",
612 PermissionMode::Plan,
613 &empty,
614 false
615 ));
616 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}