mirror of
https://github.com/lWolvesl/claw-code.git
synced 2026-04-02 07:41:52 +08:00
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:
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
Reference in New Issue
Block a user