mod clear;
mod commit;
mod compact;
mod config_cmd;
mod consolidate;
mod context;
mod cost;
mod diff;
mod help;
mod history;
mod memory;
mod model;
mod quit;
mod reasoning;
mod search;
mod skill;
mod status;
mod undo;
use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf;
#[derive(Debug)]
pub enum CommandResult {
Output(String),
ClearSession,
CompactRequested,
ConsolidateRequested,
Quit,
Error(String),
}
#[derive(Debug, Default)]
pub struct CommandContext {
pub session_cost_usd: f64,
pub session_input_tokens: u64,
pub session_output_tokens: u64,
pub session_turns: u32,
pub workspace: PathBuf,
pub help_text: String,
pub session_approved_tools: HashSet<String>,
pub permission_mode: PermissionMode,
pub memory_dir: PathBuf,
pub provider_name: String,
pub model_name: String,
pub model_override: Option<String>,
pub data_dir: PathBuf,
pub message_count: usize,
pub tool_call_count: usize,
pub tools_count: usize,
pub hooks_count: usize,
pub skill_names: Vec<String>,
pub nous_scores: Vec<(String, f64)>,
pub budget_usd: Option<f64>,
pub economic_mode: Option<String>,
pub workspace_journal_status: Option<String>,
pub project_instructions_tokens: usize,
pub git_context_tokens: usize,
pub memory_index_tokens: usize,
pub workspace_context_tokens: usize,
pub skills_catalog_tokens: usize,
pub show_reasoning: bool,
pub context_ruling: Option<String>,
pub context_window: Option<usize>,
pub identity_tier: Option<String>,
pub identity_subject: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PermissionMode {
#[default]
Default,
Yes,
Plan,
}
const READ_ONLY_TOOLS: &[&str] = &[
"glob",
"grep",
"file_read",
"list_dir",
"read_file",
"list_directory",
"memory_read",
"memory_search",
"memory_browse",
"memory_recent",
"memory_similar",
"read_memory",
];
pub fn is_tool_auto_approved(
tool_name: &str,
permission_mode: PermissionMode,
session_approved: &HashSet<String>,
is_read_only_annotation: bool,
) -> bool {
if permission_mode == PermissionMode::Yes {
return true;
}
if is_read_only_annotation || READ_ONLY_TOOLS.contains(&tool_name) {
return true;
}
if session_approved.contains(tool_name) {
return true;
}
false
}
#[allow(clippy::print_stderr)]
pub fn prompt_tool_permission(tool_name: &str) -> char {
use std::io::Write;
eprint!("[y/n/a] Allow {tool_name}? ");
std::io::stderr().flush().ok();
let mut response = String::new();
match std::io::stdin().read_line(&mut response) {
Ok(0) => 'n', Ok(_) => match response.trim().to_lowercase().as_str() {
"y" | "yes" => 'y',
"a" | "always" => 'a',
_ => 'n',
},
Err(_) => 'n',
}
}
pub trait Command: Send + Sync {
fn name(&self) -> &str;
fn aliases(&self) -> &[&str];
fn description(&self) -> &str;
fn execute(&self, args: &str, ctx: &mut CommandContext) -> CommandResult;
}
pub struct CommandRegistry {
commands: BTreeMap<String, Box<dyn Command>>,
aliases: BTreeMap<String, String>,
help_text: String,
}
impl CommandRegistry {
pub fn new() -> Self {
Self {
commands: BTreeMap::new(),
aliases: BTreeMap::new(),
help_text: String::new(),
}
}
pub fn with_builtins() -> Self {
let mut registry = Self::new();
registry.register(Box::new(help::HelpCommand));
registry.register(Box::new(clear::ClearCommand));
registry.register(Box::new(compact::CompactCommand));
registry.register(Box::new(cost::CostCommand));
registry.register(Box::new(quit::QuitCommand));
registry.register(Box::new(diff::DiffCommand));
registry.register(Box::new(memory::MemoryCommand));
registry.register(Box::new(status::StatusCommand));
registry.register(Box::new(model::ModelCommand));
registry.register(Box::new(commit::CommitCommand));
registry.register(Box::new(config_cmd::ConfigCommand));
registry.register(Box::new(undo::UndoCommand));
registry.register(Box::new(history::HistoryCommand));
registry.register(Box::new(skill::SkillCommand));
registry.register(Box::new(context::ContextCommand));
registry.register(Box::new(consolidate::ConsolidateCommand));
registry.register(Box::new(search::SearchCommand));
registry.register(Box::new(reasoning::ReasoningCommand));
registry.rebuild_help_text();
registry
}
pub fn has_command(&self, name: &str) -> bool {
let name = name.strip_prefix('/').unwrap_or(name);
self.commands.contains_key(name) || self.aliases.contains_key(name)
}
pub fn register(&mut self, cmd: Box<dyn Command>) {
let name = cmd.name().to_string();
for alias in cmd.aliases() {
self.aliases.insert((*alias).to_string(), name.clone());
}
self.commands.insert(name, cmd);
self.rebuild_help_text();
}
pub fn execute(&self, input: &str, ctx: &mut CommandContext) -> Option<CommandResult> {
let input = input.strip_prefix('/').unwrap_or(input);
let (name, args) = match input.split_once(char::is_whitespace) {
Some((n, a)) => (n, a.trim()),
None => (input.trim(), ""),
};
ctx.help_text.clone_from(&self.help_text);
let canonical = self.aliases.get(name).map(String::as_str).unwrap_or(name);
self.commands
.get(canonical)
.map(|cmd| cmd.execute(args, ctx))
}
pub fn help_text(&self) -> &str {
&self.help_text
}
pub fn matching_commands(&self, prefix: &str) -> Vec<(String, String)> {
let prefix = prefix.strip_prefix('/').unwrap_or(prefix);
let mut matches: Vec<(String, String)> = Vec::new();
for cmd in self.commands.values() {
if cmd.name().starts_with(prefix) {
matches.push((format!("/{}", cmd.name()), cmd.description().to_string()));
}
for alias in cmd.aliases() {
if alias.starts_with(prefix) && !cmd.name().starts_with(prefix) {
matches.push((format!("/{alias}"), cmd.description().to_string()));
}
}
}
matches.sort_by(|a, b| a.0.cmp(&b.0));
matches
}
fn rebuild_help_text(&mut self) {
let mut lines = vec!["Available commands:".to_string()];
for cmd in self.commands.values() {
let aliases = cmd.aliases();
let alias_str = if aliases.is_empty() {
String::new()
} else {
let formatted: Vec<String> = aliases.iter().map(|a| format!("/{a}")).collect();
format!(" ({})", formatted.join(", "))
};
lines.push(format!(
" /{}{} — {}",
cmd.name(),
alias_str,
cmd.description()
));
}
self.help_text = lines.join("\n");
}
}
impl Default for CommandRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registry_dispatches_by_name() {
let registry = CommandRegistry::with_builtins();
let mut ctx = CommandContext::default();
let result = registry.execute("/help", &mut ctx);
assert!(result.is_some());
assert!(matches!(result.unwrap(), CommandResult::Output(_)));
}
#[test]
fn registry_dispatches_by_alias() {
let registry = CommandRegistry::with_builtins();
let mut ctx = CommandContext::default();
let result = registry.execute("/q", &mut ctx);
assert!(matches!(result.unwrap(), CommandResult::Quit));
let result = registry.execute("/exit", &mut ctx);
assert!(matches!(result.unwrap(), CommandResult::Quit));
}
#[test]
fn registry_returns_none_for_unknown() {
let registry = CommandRegistry::with_builtins();
let mut ctx = CommandContext::default();
assert!(registry.execute("/nonexistent", &mut ctx).is_none());
}
#[test]
fn help_text_lists_all_commands() {
let registry = CommandRegistry::with_builtins();
let text = registry.help_text();
assert!(text.contains("/help"));
assert!(text.contains("/clear"));
assert!(text.contains("/compact"));
assert!(text.contains("/cost"));
assert!(text.contains("/quit"));
assert!(text.contains("/diff"));
assert!(text.contains("/status"));
assert!(text.contains("/model"));
assert!(text.contains("/commit"));
assert!(text.contains("/config"));
assert!(text.contains("/undo"));
assert!(text.contains("/history"));
assert!(text.contains("/skill"));
}
#[test]
fn new_command_aliases_dispatch() {
let registry = CommandRegistry::with_builtins();
let mut ctx = CommandContext::default();
let result = registry.execute("/info", &mut ctx);
assert!(result.is_some());
assert!(matches!(result.unwrap(), CommandResult::Output(_)));
let result = registry.execute("/git-status", &mut ctx);
assert!(result.is_some());
let result = registry.execute("/settings", &mut ctx);
assert!(result.is_some());
let result = registry.execute("/messages", &mut ctx);
assert!(result.is_some());
let result = registry.execute("/skills", &mut ctx);
assert!(result.is_some());
}
#[test]
fn has_command_checks_names_and_aliases() {
let registry = CommandRegistry::with_builtins();
assert!(registry.has_command("help"));
assert!(registry.has_command("/help"));
assert!(registry.has_command("status"));
assert!(registry.has_command("/info"));
assert!(registry.has_command("skill"));
assert!(registry.has_command("/skills"));
assert!(!registry.has_command("nonexistent"));
}
#[test]
fn command_context_new_fields_default() {
let ctx = CommandContext::default();
assert!(ctx.provider_name.is_empty());
assert!(ctx.model_name.is_empty());
assert!(ctx.model_override.is_none());
assert_eq!(ctx.message_count, 0);
assert_eq!(ctx.tool_call_count, 0);
assert_eq!(ctx.tools_count, 0);
assert_eq!(ctx.hooks_count, 0);
assert!(ctx.skill_names.is_empty());
assert!(ctx.nous_scores.is_empty());
assert!(ctx.budget_usd.is_none());
assert!(ctx.economic_mode.is_none());
}
#[test]
fn cost_alias_usage() {
let registry = CommandRegistry::with_builtins();
let mut ctx = CommandContext {
session_turns: 3,
session_input_tokens: 100,
session_output_tokens: 50,
..Default::default()
};
let result = registry.execute("/usage", &mut ctx);
assert!(result.is_some());
match result.unwrap() {
CommandResult::Output(text) => {
assert!(text.contains("Turns: 3"));
assert!(text.contains("Tokens: 150"));
}
other => panic!("expected Output, got {other:?}"),
}
}
#[test]
fn slash_prefix_is_optional() {
let registry = CommandRegistry::with_builtins();
let mut ctx = CommandContext::default();
let result = registry.execute("help", &mut ctx);
assert!(matches!(result.unwrap(), CommandResult::Output(_)));
}
#[test]
fn args_are_passed_through() {
let registry = CommandRegistry::with_builtins();
let mut ctx = CommandContext::default();
let result = registry.execute("/help some args", &mut ctx);
assert!(matches!(result.unwrap(), CommandResult::Output(_)));
}
#[test]
fn read_only_tools_auto_approved() {
let empty = HashSet::new();
assert!(is_tool_auto_approved(
"glob",
PermissionMode::Default,
&empty,
false
));
assert!(is_tool_auto_approved(
"grep",
PermissionMode::Default,
&empty,
false
));
assert!(is_tool_auto_approved(
"file_read",
PermissionMode::Default,
&empty,
false
));
assert!(is_tool_auto_approved(
"list_dir",
PermissionMode::Default,
&empty,
false
));
assert!(is_tool_auto_approved(
"read_file",
PermissionMode::Default,
&empty,
false
));
}
#[test]
fn read_only_annotation_auto_approved() {
let empty = HashSet::new();
assert!(is_tool_auto_approved(
"custom_reader",
PermissionMode::Default,
&empty,
true
));
}
#[test]
fn yes_mode_auto_approves_all() {
let empty = HashSet::new();
assert!(is_tool_auto_approved(
"bash",
PermissionMode::Yes,
&empty,
false
));
assert!(is_tool_auto_approved(
"write_file",
PermissionMode::Yes,
&empty,
false
));
assert!(is_tool_auto_approved(
"edit_file",
PermissionMode::Yes,
&empty,
false
));
}
#[test]
fn session_memory_works_after_always() {
let mut approved = HashSet::new();
assert!(!is_tool_auto_approved(
"bash",
PermissionMode::Default,
&approved,
false
));
approved.insert("bash".to_string());
assert!(is_tool_auto_approved(
"bash",
PermissionMode::Default,
&approved,
false
));
}
#[test]
fn non_read_only_tools_require_permission() {
let empty = HashSet::new();
assert!(!is_tool_auto_approved(
"bash",
PermissionMode::Default,
&empty,
false
));
assert!(!is_tool_auto_approved(
"write_file",
PermissionMode::Default,
&empty,
false
));
assert!(!is_tool_auto_approved(
"edit_file",
PermissionMode::Default,
&empty,
false
));
}
#[test]
fn plan_mode_still_requires_permission_for_writes() {
let empty = HashSet::new();
assert!(!is_tool_auto_approved(
"bash",
PermissionMode::Plan,
&empty,
false
));
assert!(is_tool_auto_approved(
"glob",
PermissionMode::Plan,
&empty,
false
));
}
#[test]
fn permission_mode_default_trait() {
assert_eq!(PermissionMode::default(), PermissionMode::Default);
}
#[test]
fn command_context_default_has_empty_approved_tools() {
let ctx = CommandContext::default();
assert!(ctx.session_approved_tools.is_empty());
assert_eq!(ctx.permission_mode, PermissionMode::Default);
}
}