From 071045f5564785f5534855aedfe97e52ce1f83d6 Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Tue, 31 Mar 2026 19:27:31 +0000 Subject: [PATCH] feat(cli): add permissions clear and cost commands Expand the shared slash registry and REPL dispatcher with real session-management commands so the CLI feels closer to Claude Code during interactive use. /permissions now reports or switches the active permission mode, /clear rebuilds a fresh local session without restarting the process, and /cost reports cumulative token usage honestly from the runtime tracker. The implementation keeps command parsing centralized in the commands crate and preserves the existing prompt-mode path while rebuilding runtime state safely when commands change session configuration. Constraint: Commands must be genuinely useful local behavior rather than placeholders Constraint: Preserve REPL continuity when changing permissions or clearing session state Rejected: Store permission-mode changes only in environment variables | would not update the live runtime for the current session Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep future stateful slash commands rebuilding from current session + system prompt instead of mutating hidden runtime internals Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace Not-tested: manual live API session exercising permission changes mid-conversation --- rust/crates/commands/src/lib.rs | 51 +++++++++- rust/crates/rusty-claude-cli/src/main.rs | 123 +++++++++++++++++++++-- 2 files changed, 162 insertions(+), 12 deletions(-) diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 57f5826..6ca2cdf 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -58,6 +58,21 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[ summary: "Show or switch the active model", argument_hint: Some("[model]"), }, + SlashCommandSpec { + name: "permissions", + summary: "Show or switch the active permission mode", + argument_hint: Some("[read-only|workspace-write|danger-full-access]"), + }, + SlashCommandSpec { + name: "clear", + summary: "Start a fresh local session", + argument_hint: None, + }, + SlashCommandSpec { + name: "cost", + summary: "Show cumulative token usage for this session", + argument_hint: None, + }, ]; #[derive(Debug, Clone, PartialEq, Eq)] @@ -66,6 +81,9 @@ pub enum SlashCommand { Status, Compact, Model { model: Option }, + Permissions { mode: Option }, + Clear, + Cost, Unknown(String), } @@ -86,6 +104,11 @@ impl SlashCommand { "model" => Self::Model { model: parts.next().map(ToOwned::to_owned), }, + "permissions" => Self::Permissions { + mode: parts.next().map(ToOwned::to_owned), + }, + "clear" => Self::Clear, + "cost" => Self::Cost, other => Self::Unknown(other.to_string()), }) } @@ -141,7 +164,12 @@ pub fn handle_slash_command( message: render_slash_command_help(), session: session.clone(), }), - SlashCommand::Status | SlashCommand::Model { .. } | SlashCommand::Unknown(_) => None, + SlashCommand::Status + | SlashCommand::Model { .. } + | SlashCommand::Permissions { .. } + | SlashCommand::Clear + | SlashCommand::Cost + | SlashCommand::Unknown(_) => None, } } @@ -166,6 +194,14 @@ mod tests { 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)); + assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost)); } #[test] @@ -175,7 +211,10 @@ mod tests { assert!(help.contains("/status")); assert!(help.contains("/compact")); assert!(help.contains("/model [model]")); - assert_eq!(slash_command_specs().len(), 4); + assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); + assert!(help.contains("/clear")); + assert!(help.contains("/cost")); + assert_eq!(slash_command_specs().len(), 7); } #[test] @@ -225,5 +264,13 @@ mod tests { 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("/cost", &session, CompactionConfig::default()).is_none()); } } diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 2a08694..b703a22 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -323,6 +323,9 @@ impl LiveCli { SlashCommand::Status => self.print_status(), SlashCommand::Compact => self.compact()?, SlashCommand::Model { model } => self.set_model(model)?, + SlashCommand::Permissions { mode } => self.set_permissions(mode)?, + SlashCommand::Clear => self.clear_session()?, + SlashCommand::Cost => self.print_cost(), SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"), } Ok(()) @@ -363,14 +366,68 @@ impl LiveCli { Ok(()) } + fn set_permissions(&mut self, mode: Option) -> Result<(), Box> { + let Some(mode) = mode else { + println!("Current permission mode: {}", permission_mode_label()); + return Ok(()); + }; + + let normalized = normalize_permission_mode(&mode).ok_or_else(|| { + format!( + "Unsupported permission mode '{mode}'. Use read-only, workspace-write, or danger-full-access." + ) + })?; + + if normalized == permission_mode_label() { + println!("Permission mode already set to {normalized}."); + return Ok(()); + } + + let session = self.runtime.session().clone(); + self.runtime = build_runtime_with_permission_mode( + session, + self.model.clone(), + self.system_prompt.clone(), + true, + normalized, + )?; + println!("Switched permission mode to {normalized}."); + Ok(()) + } + + fn clear_session(&mut self) -> Result<(), Box> { + self.runtime = build_runtime_with_permission_mode( + Session::new(), + self.model.clone(), + self.system_prompt.clone(), + true, + permission_mode_label(), + )?; + println!("Cleared local session history."); + Ok(()) + } + + fn print_cost(&self) { + let cumulative = self.runtime.usage().cumulative_usage(); + println!( + "cost: input_tokens={} output_tokens={} cache_creation_tokens={} cache_read_tokens={} total_tokens={}", + cumulative.input_tokens, + cumulative.output_tokens, + cumulative.cache_creation_input_tokens, + cumulative.cache_read_input_tokens, + cumulative.total_tokens(), + ); + } + fn compact(&mut self) -> Result<(), Box> { let result = self.runtime.compact(CompactionConfig::default()); let removed = result.removed_message_count; - self.runtime = build_runtime( + self.runtime = build_runtime_with_permission_mode( result.compacted_session, self.model.clone(), self.system_prompt.clone(), true, + permission_mode_label(), )?; println!("Compacted {removed} messages."); Ok(()) @@ -403,9 +460,19 @@ fn format_status_line( ) } +fn normalize_permission_mode(mode: &str) -> Option<&'static str> { + match mode.trim() { + "read-only" => Some("read-only"), + "workspace-write" => Some("workspace-write"), + "danger-full-access" => Some("danger-full-access"), + _ => None, + } +} + fn permission_mode_label() -> &'static str { match env::var("RUSTY_CLAUDE_PERMISSION_MODE") { Ok(value) if value == "read-only" => "read-only", + Ok(value) if value == "danger-full-access" => "danger-full-access", _ => "workspace-write", } } @@ -425,12 +492,29 @@ fn build_runtime( system_prompt: Vec, enable_tools: bool, ) -> Result, Box> +{ + build_runtime_with_permission_mode( + session, + model, + system_prompt, + enable_tools, + permission_mode_label(), + ) +} + +fn build_runtime_with_permission_mode( + session: Session, + model: String, + system_prompt: Vec, + enable_tools: bool, + permission_mode: &str, +) -> Result, Box> { Ok(ConversationRuntime::new( session, AnthropicRuntimeClient::new(model, enable_tools)?, CliToolExecutor::new(), - permission_policy_from_env(), + permission_policy(permission_mode), system_prompt, )) } @@ -644,15 +728,14 @@ impl ToolExecutor for CliToolExecutor { } } -fn permission_policy_from_env() -> PermissionPolicy { - let mode = - env::var("RUSTY_CLAUDE_PERMISSION_MODE").unwrap_or_else(|_| "workspace-write".to_string()); - match mode.as_str() { - "read-only" => PermissionPolicy::new(PermissionMode::Deny) +fn permission_policy(mode: &str) -> PermissionPolicy { + if normalize_permission_mode(mode) == Some("read-only") { + PermissionPolicy::new(PermissionMode::Deny) .with_tool_mode("read_file", PermissionMode::Allow) .with_tool_mode("glob_search", PermissionMode::Allow) - .with_tool_mode("grep_search", PermissionMode::Allow), - _ => PermissionPolicy::new(PermissionMode::Allow), + .with_tool_mode("grep_search", PermissionMode::Allow) + } else { + PermissionPolicy::new(PermissionMode::Allow) } } @@ -713,7 +796,10 @@ fn print_help() { #[cfg(test)] mod tests { - use super::{format_status_line, parse_args, render_repl_help, CliAction, DEFAULT_MODEL}; + use super::{ + format_status_line, normalize_permission_mode, parse_args, render_repl_help, CliAction, + DEFAULT_MODEL, + }; use runtime::{ContentBlock, ConversationMessage, MessageRole}; use std::path::PathBuf; @@ -783,6 +869,9 @@ mod tests { assert!(help.contains("/help")); assert!(help.contains("/status")); assert!(help.contains("/model [model]")); + assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]")); + assert!(help.contains("/clear")); + assert!(help.contains("/cost")); assert!(help.contains("/exit")); } @@ -814,6 +903,20 @@ mod tests { assert!(status.contains("cumulative_total_tokens=31")); } + #[test] + fn normalizes_supported_permission_modes() { + assert_eq!(normalize_permission_mode("read-only"), Some("read-only")); + assert_eq!( + normalize_permission_mode("workspace-write"), + Some("workspace-write") + ); + assert_eq!( + normalize_permission_mode("danger-full-access"), + Some("danger-full-access") + ); + assert_eq!(normalize_permission_mode("unknown"), None); + } + #[test] fn converts_tool_roundtrip_messages() { let messages = vec![