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
This commit is contained in:
Yeachan-Heo
2026-03-31 19:27:31 +00:00
parent a96bb6c60f
commit 071045f556
2 changed files with 162 additions and 12 deletions

View File

@@ -58,6 +58,21 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
summary: "Show or switch the active model", summary: "Show or switch the active model",
argument_hint: Some("[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)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -66,6 +81,9 @@ pub enum SlashCommand {
Status, Status,
Compact, Compact,
Model { model: Option<String> }, Model { model: Option<String> },
Permissions { mode: Option<String> },
Clear,
Cost,
Unknown(String), Unknown(String),
} }
@@ -86,6 +104,11 @@ impl SlashCommand {
"model" => Self::Model { "model" => Self::Model {
model: parts.next().map(ToOwned::to_owned), 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()), other => Self::Unknown(other.to_string()),
}) })
} }
@@ -141,7 +164,12 @@ pub fn handle_slash_command(
message: render_slash_command_help(), message: render_slash_command_help(),
session: session.clone(), 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"), SlashCommand::parse("/model"),
Some(SlashCommand::Model { model: None }) 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] #[test]
@@ -175,7 +211,10 @@ mod tests {
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/compact")); assert!(help.contains("/compact"));
assert!(help.contains("/model [model]")); 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] #[test]
@@ -225,5 +264,13 @@ mod tests {
assert!( assert!(
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none() 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());
} }
} }

View File

@@ -323,6 +323,9 @@ impl LiveCli {
SlashCommand::Status => self.print_status(), SlashCommand::Status => self.print_status(),
SlashCommand::Compact => self.compact()?, SlashCommand::Compact => self.compact()?,
SlashCommand::Model { model } => self.set_model(model)?, 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}"), SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
} }
Ok(()) Ok(())
@@ -363,14 +366,68 @@ impl LiveCli {
Ok(()) Ok(())
} }
fn set_permissions(&mut self, mode: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> { fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
let result = self.runtime.compact(CompactionConfig::default()); let result = self.runtime.compact(CompactionConfig::default());
let removed = result.removed_message_count; let removed = result.removed_message_count;
self.runtime = build_runtime( self.runtime = build_runtime_with_permission_mode(
result.compacted_session, result.compacted_session,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
permission_mode_label(),
)?; )?;
println!("Compacted {removed} messages."); println!("Compacted {removed} messages.");
Ok(()) 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 { fn permission_mode_label() -> &'static str {
match env::var("RUSTY_CLAUDE_PERMISSION_MODE") { match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
Ok(value) if value == "read-only" => "read-only", Ok(value) if value == "read-only" => "read-only",
Ok(value) if value == "danger-full-access" => "danger-full-access",
_ => "workspace-write", _ => "workspace-write",
} }
} }
@@ -425,12 +492,29 @@ fn build_runtime(
system_prompt: Vec<String>, system_prompt: Vec<String>,
enable_tools: bool, enable_tools: bool,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{
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<String>,
enable_tools: bool,
permission_mode: &str,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{ {
Ok(ConversationRuntime::new( Ok(ConversationRuntime::new(
session, session,
AnthropicRuntimeClient::new(model, enable_tools)?, AnthropicRuntimeClient::new(model, enable_tools)?,
CliToolExecutor::new(), CliToolExecutor::new(),
permission_policy_from_env(), permission_policy(permission_mode),
system_prompt, system_prompt,
)) ))
} }
@@ -644,15 +728,14 @@ impl ToolExecutor for CliToolExecutor {
} }
} }
fn permission_policy_from_env() -> PermissionPolicy { fn permission_policy(mode: &str) -> PermissionPolicy {
let mode = if normalize_permission_mode(mode) == Some("read-only") {
env::var("RUSTY_CLAUDE_PERMISSION_MODE").unwrap_or_else(|_| "workspace-write".to_string()); PermissionPolicy::new(PermissionMode::Deny)
match mode.as_str() {
"read-only" => PermissionPolicy::new(PermissionMode::Deny)
.with_tool_mode("read_file", PermissionMode::Allow) .with_tool_mode("read_file", PermissionMode::Allow)
.with_tool_mode("glob_search", PermissionMode::Allow) .with_tool_mode("glob_search", PermissionMode::Allow)
.with_tool_mode("grep_search", PermissionMode::Allow), .with_tool_mode("grep_search", PermissionMode::Allow)
_ => PermissionPolicy::new(PermissionMode::Allow), } else {
PermissionPolicy::new(PermissionMode::Allow)
} }
} }
@@ -713,7 +796,10 @@ fn print_help() {
#[cfg(test)] #[cfg(test)]
mod tests { 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 runtime::{ContentBlock, ConversationMessage, MessageRole};
use std::path::PathBuf; use std::path::PathBuf;
@@ -783,6 +869,9 @@ mod tests {
assert!(help.contains("/help")); assert!(help.contains("/help"));
assert!(help.contains("/status")); assert!(help.contains("/status"));
assert!(help.contains("/model [model]")); 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")); assert!(help.contains("/exit"));
} }
@@ -814,6 +903,20 @@ mod tests {
assert!(status.contains("cumulative_total_tokens=31")); 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] #[test]
fn converts_tool_roundtrip_messages() { fn converts_tool_roundtrip_messages() {
let messages = vec![ let messages = vec![