Ver Fonte

feat(cli): align slash help/status/model handling

Centralize slash command parsing in the commands crate so the REPL can share help metadata and grow toward Claw Code parity without duplicating handlers. This adds shared /help and /model parsing, routes REPL dispatch through the shared parser, and upgrades /status to report model and token totals.

To satisfy the required verification gate, this also fixes existing workspace clippy and test blockers in runtime, tools, api, and compat-harness that were unrelated to the new command behavior but prevented fmt/clippy/test from passing cleanly.

Constraint: Preserve existing prompt-mode and REPL behavior while adding real slash commands

Constraint: cargo fmt, clippy, and workspace tests must pass before shipping command-surface work

Rejected: Keep command handling only in main.rs | would deepen duplication with commands crate and resume path

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Extend new slash commands through the shared commands crate first so REPL and resume entrypoints stay consistent

Tested: cargo fmt; cargo clippy --workspace --all-targets -- -D warnings; cargo test --workspace

Not-tested: live Anthropic network execution beyond existing mocked/integration coverage
Yeachan-Heo há 2 meses atrás
pai
commit
df767a54c8

+ 18 - 3
rust/crates/api/src/client.rs

@@ -158,7 +158,10 @@ impl AnthropicClient {
             .header("anthropic-version", ANTHROPIC_VERSION)
             .header("content-type", "application/json");
 
-        let auth_header = self.auth_token.as_ref().map(|_| "Bearer [REDACTED]").unwrap_or("<absent>");
+        let auth_header = self
+            .auth_token
+            .as_ref()
+            .map_or("<absent>", |_| "Bearer [REDACTED]");
         eprintln!("[anthropic-client] headers x-api-key=[REDACTED] authorization={auth_header} anthropic-version={ANTHROPIC_VERSION} content-type=application/json");
 
         if let Some(auth_token) = &self.auth_token {
@@ -192,8 +195,7 @@ fn read_api_key() -> Result<String, ApiError> {
         Ok(_) => Err(ApiError::MissingApiKey),
         Err(std::env::VarError::NotPresent) => match std::env::var("ANTHROPIC_AUTH_TOKEN") {
             Ok(api_key) if !api_key.is_empty() => Ok(api_key),
-            Ok(_) => Err(ApiError::MissingApiKey),
-            Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey),
+            Ok(_) | Err(std::env::VarError::NotPresent) => Err(ApiError::MissingApiKey),
             Err(error) => Err(ApiError::from(error)),
         },
         Err(error) => Err(ApiError::from(error)),
@@ -303,12 +305,22 @@ struct AnthropicErrorBody {
 #[cfg(test)]
 mod tests {
     use super::{ALT_REQUEST_ID_HEADER, REQUEST_ID_HEADER};
+    use std::sync::{Mutex, OnceLock};
     use std::time::Duration;
 
     use crate::types::{ContentBlockDelta, MessageRequest};
 
+    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
+        static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+        ENV_LOCK
+            .get_or_init(|| Mutex::new(()))
+            .lock()
+            .expect("env lock should not be poisoned")
+    }
+
     #[test]
     fn read_api_key_requires_presence() {
+        let _guard = env_lock();
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
         std::env::remove_var("ANTHROPIC_API_KEY");
         let error = super::read_api_key().expect_err("missing key should error");
@@ -317,6 +329,7 @@ mod tests {
 
     #[test]
     fn read_api_key_requires_non_empty_value() {
+        let _guard = env_lock();
         std::env::set_var("ANTHROPIC_AUTH_TOKEN", "");
         std::env::remove_var("ANTHROPIC_API_KEY");
         let error = super::read_api_key().expect_err("empty key should error");
@@ -325,6 +338,7 @@ mod tests {
 
     #[test]
     fn read_api_key_prefers_api_key_env() {
+        let _guard = env_lock();
         std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
         std::env::set_var("ANTHROPIC_API_KEY", "legacy-key");
         assert_eq!(
@@ -337,6 +351,7 @@ mod tests {
 
     #[test]
     fn read_auth_token_reads_auth_token_env() {
+        let _guard = env_lock();
         std::env::set_var("ANTHROPIC_AUTH_TOKEN", "auth-token");
         assert_eq!(super::read_auth_token().as_deref(), Some("auth-token"));
         std::env::remove_var("ANTHROPIC_AUTH_TOKEN");

+ 129 - 10
rust/crates/commands/src/lib.rs

@@ -30,6 +30,85 @@ impl CommandRegistry {
     }
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct SlashCommandSpec {
+    pub name: &'static str,
+    pub summary: &'static str,
+    pub argument_hint: Option<&'static str>,
+}
+
+const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
+    SlashCommandSpec {
+        name: "help",
+        summary: "Show available slash commands",
+        argument_hint: None,
+    },
+    SlashCommandSpec {
+        name: "status",
+        summary: "Show current session status",
+        argument_hint: None,
+    },
+    SlashCommandSpec {
+        name: "compact",
+        summary: "Compact local session history",
+        argument_hint: None,
+    },
+    SlashCommandSpec {
+        name: "model",
+        summary: "Show or switch the active model",
+        argument_hint: Some("[model]"),
+    },
+];
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum SlashCommand {
+    Help,
+    Status,
+    Compact,
+    Model { model: Option<String> },
+    Unknown(String),
+}
+
+impl SlashCommand {
+    #[must_use]
+    pub fn parse(input: &str) -> Option<Self> {
+        let trimmed = input.trim();
+        if !trimmed.starts_with('/') {
+            return None;
+        }
+
+        let mut parts = trimmed.trim_start_matches('/').split_whitespace();
+        let command = parts.next().unwrap_or_default();
+        Some(match command {
+            "help" => Self::Help,
+            "status" => Self::Status,
+            "compact" => Self::Compact,
+            "model" => Self::Model {
+                model: parts.next().map(ToOwned::to_owned),
+            },
+            other => Self::Unknown(other.to_string()),
+        })
+    }
+}
+
+#[must_use]
+pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
+    SLASH_COMMAND_SPECS
+}
+
+#[must_use]
+pub fn render_slash_command_help() -> String {
+    let mut lines = vec!["Available commands:".to_string()];
+    for spec in slash_command_specs() {
+        let name = match spec.argument_hint {
+            Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
+            None => format!("/{}", spec.name),
+        };
+        lines.push(format!("  {name:<20} {}", spec.summary));
+    }
+    lines.join("\n")
+}
+
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct SlashCommandResult {
     pub message: String,
@@ -42,13 +121,8 @@ pub fn handle_slash_command(
     session: &Session,
     compaction: CompactionConfig,
 ) -> Option<SlashCommandResult> {
-    let trimmed = input.trim();
-    if !trimmed.starts_with('/') {
-        return None;
-    }
-
-    match trimmed.split_whitespace().next() {
-        Some("/compact") => {
+    match SlashCommand::parse(input)? {
+        SlashCommand::Compact => {
             let result = compact_session(session, compaction);
             let message = if result.removed_message_count == 0 {
                 "Compaction skipped: session is below the compaction threshold.".to_string()
@@ -63,15 +137,47 @@ pub fn handle_slash_command(
                 session: result.compacted_session,
             })
         }
-        _ => None,
+        SlashCommand::Help => Some(SlashCommandResult {
+            message: render_slash_command_help(),
+            session: session.clone(),
+        }),
+        SlashCommand::Status | SlashCommand::Model { .. } | SlashCommand::Unknown(_) => None,
     }
 }
 
 #[cfg(test)]
 mod tests {
-    use super::handle_slash_command;
+    use super::{
+        handle_slash_command, render_slash_command_help, slash_command_specs, SlashCommand,
+    };
     use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
 
+    #[test]
+    fn parses_supported_slash_commands() {
+        assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
+        assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
+        assert_eq!(
+            SlashCommand::parse("/model claude-opus"),
+            Some(SlashCommand::Model {
+                model: Some("claude-opus".to_string()),
+            })
+        );
+        assert_eq!(
+            SlashCommand::parse("/model"),
+            Some(SlashCommand::Model { model: None })
+        );
+    }
+
+    #[test]
+    fn renders_help_from_shared_specs() {
+        let help = render_slash_command_help();
+        assert!(help.contains("/help"));
+        assert!(help.contains("/status"));
+        assert!(help.contains("/compact"));
+        assert!(help.contains("/model [model]"));
+        assert_eq!(slash_command_specs().len(), 4);
+    }
+
     #[test]
     fn compacts_sessions_via_slash_command() {
         let session = Session {
@@ -103,8 +209,21 @@ mod tests {
     }
 
     #[test]
-    fn ignores_unknown_slash_commands() {
+    fn help_command_is_non_mutating() {
+        let session = Session::new();
+        let result = handle_slash_command("/help", &session, CompactionConfig::default())
+            .expect("help command should be handled");
+        assert_eq!(result.session, session);
+        assert!(result.message.contains("Available commands:"));
+    }
+
+    #[test]
+    fn ignores_unknown_or_runtime_bound_slash_commands() {
         let session = Session::new();
         assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
+        assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
+        assert!(
+            handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
+        );
     }
 }

+ 38 - 1
rust/crates/compat-harness/src/lib.rs

@@ -24,9 +24,10 @@ impl UpstreamPaths {
             .as_ref()
             .canonicalize()
             .unwrap_or_else(|_| workspace_dir.as_ref().to_path_buf());
-        let repo_root = workspace_dir
+        let primary_repo_root = workspace_dir
             .parent()
             .map_or_else(|| PathBuf::from(".."), Path::to_path_buf);
+        let repo_root = resolve_upstream_repo_root(&primary_repo_root);
         Self { repo_root }
     }
 
@@ -53,6 +54,42 @@ pub struct ExtractedManifest {
     pub bootstrap: BootstrapPlan,
 }
 
+fn resolve_upstream_repo_root(primary_repo_root: &Path) -> PathBuf {
+    let candidates = upstream_repo_candidates(primary_repo_root);
+    candidates
+        .into_iter()
+        .find(|candidate| candidate.join("src/commands.ts").is_file())
+        .unwrap_or_else(|| primary_repo_root.to_path_buf())
+}
+
+fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec<PathBuf> {
+    let mut candidates = vec![primary_repo_root.to_path_buf()];
+
+    if let Some(explicit) = std::env::var_os("CLAUDE_CODE_UPSTREAM") {
+        candidates.push(PathBuf::from(explicit));
+    }
+
+    for ancestor in primary_repo_root.ancestors().take(4) {
+        candidates.push(ancestor.join("claude-code"));
+        candidates.push(ancestor.join("clawd-code"));
+    }
+
+    candidates.push(
+        primary_repo_root
+            .join("reference-source")
+            .join("claude-code"),
+    );
+    candidates.push(primary_repo_root.join("vendor").join("claude-code"));
+
+    let mut deduped = Vec::new();
+    for candidate in candidates {
+        if !deduped.iter().any(|seen: &PathBuf| seen == &candidate) {
+            deduped.push(candidate);
+        }
+    }
+    deduped
+}
+
 pub fn extract_manifest(paths: &UpstreamPaths) -> std::io::Result<ExtractedManifest> {
     let commands_source = fs::read_to_string(paths.commands_path())?;
     let tools_source = fs::read_to_string(paths.tools_path())?;

+ 12 - 13
rust/crates/runtime/src/file_ops.rs

@@ -138,9 +138,9 @@ pub fn read_file(
     let content = fs::read_to_string(&absolute_path)?;
     let lines: Vec<&str> = content.lines().collect();
     let start_index = offset.unwrap_or(0).min(lines.len());
-    let end_index = limit
-        .map(|limit| start_index.saturating_add(limit).min(lines.len()))
-        .unwrap_or(lines.len());
+    let end_index = limit.map_or(lines.len(), |limit| {
+        start_index.saturating_add(limit).min(lines.len())
+    });
     let selected = lines[start_index..end_index].join("\n");
 
     Ok(ReadFileOutput {
@@ -285,7 +285,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
         .output_mode
         .clone()
         .unwrap_or_else(|| String::from("files_with_matches"));
-    let context = input.context.or(input.context_short).unwrap_or(0);
+    let context_window = input.context.or(input.context_short).unwrap_or(0);
 
     let mut filenames = Vec::new();
     let mut content_lines = Vec::new();
@@ -296,12 +296,12 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
             continue;
         }
 
-        let Ok(content) = fs::read_to_string(&file_path) else {
+        let Ok(file_content) = fs::read_to_string(&file_path) else {
             continue;
         };
 
         if output_mode == "count" {
-            let count = regex.find_iter(&content).count();
+            let count = regex.find_iter(&file_content).count();
             if count > 0 {
                 filenames.push(file_path.to_string_lossy().into_owned());
                 total_matches += count;
@@ -309,7 +309,7 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
             continue;
         }
 
-        let lines: Vec<&str> = content.lines().collect();
+        let lines: Vec<&str> = file_content.lines().collect();
         let mut matched_lines = Vec::new();
         for (index, line) in lines.iter().enumerate() {
             if regex.is_match(line) {
@@ -325,15 +325,15 @@ pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
         filenames.push(file_path.to_string_lossy().into_owned());
         if output_mode == "content" {
             for index in matched_lines {
-                let start = index.saturating_sub(input.before.unwrap_or(context));
-                let end = (index + input.after.unwrap_or(context) + 1).min(lines.len());
-                for current in start..end {
+                let start = index.saturating_sub(input.before.unwrap_or(context_window));
+                let end = (index + input.after.unwrap_or(context_window) + 1).min(lines.len());
+                for (current, line_content) in lines.iter().enumerate().take(end).skip(start) {
                     let prefix = if input.line_numbers.unwrap_or(true) {
                         format!("{}:{}:", file_path.to_string_lossy(), current + 1)
                     } else {
                         format!("{}:", file_path.to_string_lossy())
                     };
-                    content_lines.push(format!("{prefix}{}", lines[current]));
+                    content_lines.push(format!("{prefix}{line_content}"));
                 }
             }
         }
@@ -376,8 +376,7 @@ fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
 
     let mut files = Vec::new();
     for entry in WalkDir::new(base_path) {
-        let entry =
-            entry.map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))?;
+        let entry = entry.map_err(|error| io::Error::other(error.to_string()))?;
         if entry.file_type().is_file() {
             files.push(entry.path().to_path_buf());
         }

+ 127 - 23
rust/crates/rusty-claude-cli/src/main.rs

@@ -11,7 +11,7 @@ use api::{
     ToolResultContentBlock,
 };
 
-use commands::handle_slash_command;
+use commands::{handle_slash_command, render_slash_command_help, SlashCommand};
 use compat_harness::{extract_manifest, UpstreamPaths};
 use render::{Spinner, TerminalRenderer};
 use runtime::{
@@ -82,7 +82,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
                 let value = args
                     .get(index + 1)
                     .ok_or_else(|| "missing value for --model".to_string())?;
-                model = value.clone();
+                model.clone_from(value);
                 index += 2;
             }
             flag if flag.starts_with("--model=") => {
@@ -249,19 +249,14 @@ fn run_repl(model: String) -> Result<(), Box<dyn std::error::Error>> {
         if trimmed.is_empty() {
             continue;
         }
-        match trimmed {
-            "/exit" | "/quit" => break,
-            "/help" => {
-                println!("Available commands:");
-                println!("  /help    Show help");
-                println!("  /status  Show session status");
-                println!("  /compact Compact session history");
-                println!("  /exit    Quit the REPL");
-            }
-            "/status" => cli.print_status(),
-            "/compact" => cli.compact()?,
-            _ => cli.run_turn(trimmed)?,
+        if matches!(trimmed, "/exit" | "/quit") {
+            break;
+        }
+        if let Some(command) = SlashCommand::parse(trimmed) {
+            cli.handle_repl_command(command)?;
+            continue;
         }
+        cli.run_turn(trimmed)?;
     }
 
     Ok(())
@@ -319,17 +314,55 @@ impl LiveCli {
         }
     }
 
+    fn handle_repl_command(
+        &mut self,
+        command: SlashCommand,
+    ) -> Result<(), Box<dyn std::error::Error>> {
+        match command {
+            SlashCommand::Help => println!("{}", render_repl_help()),
+            SlashCommand::Status => self.print_status(),
+            SlashCommand::Compact => self.compact()?,
+            SlashCommand::Model { model } => self.set_model(model)?,
+            SlashCommand::Unknown(name) => eprintln!("unknown slash command: /{name}"),
+        }
+        Ok(())
+    }
+
     fn print_status(&self) {
-        let usage = self.runtime.usage().cumulative_usage();
+        let cumulative = self.runtime.usage().cumulative_usage();
+        let latest = self.runtime.usage().current_turn_usage();
         println!(
-            "status: messages={} turns={} input_tokens={} output_tokens={}",
-            self.runtime.session().messages.len(),
-            self.runtime.usage().turns(),
-            usage.input_tokens,
-            usage.output_tokens
+            "{}",
+            format_status_line(
+                &self.model,
+                self.runtime.session().messages.len(),
+                self.runtime.usage().turns(),
+                latest,
+                cumulative,
+                self.runtime.estimated_tokens(),
+                permission_mode_label(),
+            )
         );
     }
 
+    fn set_model(&mut self, model: Option<String>) -> Result<(), Box<dyn std::error::Error>> {
+        let Some(model) = model else {
+            println!("Current model: {}", self.model);
+            return Ok(());
+        };
+
+        if model == self.model {
+            println!("Model already set to {model}.");
+            return Ok(());
+        }
+
+        let session = self.runtime.session().clone();
+        self.runtime = build_runtime(session, model.clone(), self.system_prompt.clone(), true)?;
+        self.model.clone_from(&model);
+        println!("Switched model to {model}.");
+        Ok(())
+    }
+
     fn compact(&mut self) -> Result<(), Box<dyn std::error::Error>> {
         let result = self.runtime.compact(CompactionConfig::default());
         let removed = result.removed_message_count;
@@ -344,6 +377,39 @@ impl LiveCli {
     }
 }
 
+fn render_repl_help() -> String {
+    format!(
+        "{}
+  /exit                Quit the REPL",
+        render_slash_command_help()
+    )
+}
+
+fn format_status_line(
+    model: &str,
+    message_count: usize,
+    turns: u32,
+    latest: TokenUsage,
+    cumulative: TokenUsage,
+    estimated_tokens: usize,
+    permission_mode: &str,
+) -> String {
+    format!(
+        "status: model={model} permission_mode={permission_mode} messages={message_count} turns={turns} estimated_tokens={estimated_tokens} latest_tokens={} cumulative_input_tokens={} cumulative_output_tokens={} cumulative_total_tokens={}",
+        latest.total_tokens(),
+        cumulative.input_tokens,
+        cumulative.output_tokens,
+        cumulative.total_tokens(),
+    )
+}
+
+fn permission_mode_label() -> &'static str {
+    match env::var("RUSTY_CLAUDE_PERMISSION_MODE") {
+        Ok(value) if value == "read-only" => "read-only",
+        _ => "workspace-write",
+    }
+}
+
 fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
     Ok(load_system_prompt(
         env::current_dir()?,
@@ -388,6 +454,7 @@ impl AnthropicRuntimeClient {
 }
 
 impl ApiClient for AnthropicRuntimeClient {
+    #[allow(clippy::too_many_lines)]
     fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
         let message_request = MessageRequest {
             model: self.model.clone(),
@@ -442,7 +509,7 @@ impl ApiClient for AnthropicRuntimeClient {
                         ContentBlockDelta::TextDelta { text } => {
                             if !text.is_empty() {
                                 write!(stdout, "{text}")
-                                    .and_then(|_| stdout.flush())
+                                    .and_then(|()| stdout.flush())
                                     .map_err(|error| RuntimeError::new(error.to_string()))?;
                                 events.push(AssistantEvent::TextDelta(text));
                             }
@@ -512,7 +579,7 @@ fn push_output_block(
         OutputContentBlock::Text { text } => {
             if !text.is_empty() {
                 write!(out, "{text}")
-                    .and_then(|_| out.flush())
+                    .and_then(|()| out.flush())
                     .map_err(|error| RuntimeError::new(error.to_string()))?;
                 events.push(AssistantEvent::TextDelta(text));
             }
@@ -646,7 +713,7 @@ fn print_help() {
 
 #[cfg(test)]
 mod tests {
-    use super::{parse_args, CliAction, DEFAULT_MODEL};
+    use super::{format_status_line, parse_args, render_repl_help, CliAction, DEFAULT_MODEL};
     use runtime::{ContentBlock, ConversationMessage, MessageRole};
     use std::path::PathBuf;
 
@@ -710,6 +777,43 @@ mod tests {
         );
     }
 
+    #[test]
+    fn repl_help_includes_shared_commands_and_exit() {
+        let help = render_repl_help();
+        assert!(help.contains("/help"));
+        assert!(help.contains("/status"));
+        assert!(help.contains("/model [model]"));
+        assert!(help.contains("/exit"));
+    }
+
+    #[test]
+    fn status_line_reports_model_and_token_totals() {
+        let status = format_status_line(
+            "claude-sonnet",
+            7,
+            3,
+            runtime::TokenUsage {
+                input_tokens: 5,
+                output_tokens: 4,
+                cache_creation_input_tokens: 1,
+                cache_read_input_tokens: 0,
+            },
+            runtime::TokenUsage {
+                input_tokens: 20,
+                output_tokens: 8,
+                cache_creation_input_tokens: 2,
+                cache_read_input_tokens: 1,
+            },
+            128,
+            "workspace-write",
+        );
+        assert!(status.contains("model=claude-sonnet"));
+        assert!(status.contains("permission_mode=workspace-write"));
+        assert!(status.contains("messages=7"));
+        assert!(status.contains("latest_tokens=10"));
+        assert!(status.contains("cumulative_total_tokens=31"));
+    }
+
     #[test]
     fn converts_tool_roundtrip_messages() {
         let messages = vec![

+ 26 - 16
rust/crates/tools/src/lib.rs

@@ -146,11 +146,17 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
 pub fn execute_tool(name: &str, input: &Value) -> Result<String, String> {
     match name {
         "bash" => from_value::<BashCommandInput>(input).and_then(run_bash),
-        "read_file" => from_value::<ReadFileInput>(input).and_then(run_read_file),
-        "write_file" => from_value::<WriteFileInput>(input).and_then(run_write_file),
-        "edit_file" => from_value::<EditFileInput>(input).and_then(run_edit_file),
-        "glob_search" => from_value::<GlobSearchInputValue>(input).and_then(run_glob_search),
-        "grep_search" => from_value::<GrepSearchInput>(input).and_then(run_grep_search),
+        "read_file" => from_value::<ReadFileInput>(input).and_then(|input| run_read_file(&input)),
+        "write_file" => {
+            from_value::<WriteFileInput>(input).and_then(|input| run_write_file(&input))
+        }
+        "edit_file" => from_value::<EditFileInput>(input).and_then(|input| run_edit_file(&input)),
+        "glob_search" => {
+            from_value::<GlobSearchInputValue>(input).and_then(|input| run_glob_search(&input))
+        }
+        "grep_search" => {
+            from_value::<GrepSearchInput>(input).and_then(|input| run_grep_search(&input))
+        }
         _ => Err(format!("unsupported tool: {name}")),
     }
 }
@@ -164,15 +170,17 @@ fn run_bash(input: BashCommandInput) -> Result<String, String> {
         .map_err(|error| error.to_string())
 }
 
-fn run_read_file(input: ReadFileInput) -> Result<String, String> {
-    to_pretty_json(read_file(&input.path, input.offset, input.limit).map_err(io_to_string)?)
+fn run_read_file(input: &ReadFileInput) -> Result<String, String> {
+    to_pretty_json(
+        read_file(&input.path, input.offset, input.limit).map_err(|error| io_to_string(&error))?,
+    )
 }
 
-fn run_write_file(input: WriteFileInput) -> Result<String, String> {
-    to_pretty_json(write_file(&input.path, &input.content).map_err(io_to_string)?)
+fn run_write_file(input: &WriteFileInput) -> Result<String, String> {
+    to_pretty_json(write_file(&input.path, &input.content).map_err(|error| io_to_string(&error))?)
 }
 
-fn run_edit_file(input: EditFileInput) -> Result<String, String> {
+fn run_edit_file(input: &EditFileInput) -> Result<String, String> {
     to_pretty_json(
         edit_file(
             &input.path,
@@ -180,23 +188,25 @@ fn run_edit_file(input: EditFileInput) -> Result<String, String> {
             &input.new_string,
             input.replace_all.unwrap_or(false),
         )
-        .map_err(io_to_string)?,
+        .map_err(|error| io_to_string(&error))?,
     )
 }
 
-fn run_glob_search(input: GlobSearchInputValue) -> Result<String, String> {
-    to_pretty_json(glob_search(&input.pattern, input.path.as_deref()).map_err(io_to_string)?)
+fn run_glob_search(input: &GlobSearchInputValue) -> Result<String, String> {
+    to_pretty_json(
+        glob_search(&input.pattern, input.path.as_deref()).map_err(|error| io_to_string(&error))?,
+    )
 }
 
-fn run_grep_search(input: GrepSearchInput) -> Result<String, String> {
-    to_pretty_json(grep_search(&input).map_err(io_to_string)?)
+fn run_grep_search(input: &GrepSearchInput) -> Result<String, String> {
+    to_pretty_json(grep_search(input).map_err(|error| io_to_string(&error))?)
 }
 
 fn to_pretty_json<T: serde::Serialize>(value: T) -> Result<String, String> {
     serde_json::to_string_pretty(&value).map_err(|error| error.to_string())
 }
 
-fn io_to_string(error: std::io::Error) -> String {
+fn io_to_string(error: &std::io::Error) -> String {
     error.to_string()
 }