Explorar o código

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
Yeachan-Heo hai 2 meses
pai
achega
071045f556
Modificáronse 2 ficheiros con 162 adicións e 12 borrados
  1. 49 2
      rust/crates/commands/src/lib.rs
  2. 113 10
      rust/crates/rusty-claude-cli/src/main.rs

+ 49 - 2
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<String> },
+    Permissions { mode: Option<String> },
+    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());
     }
 }

+ 113 - 10
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<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>> {
         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<String>,
     enable_tools: bool,
 ) -> 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(
         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![