tools.rs

  1use std::path::PathBuf;
  2
  3use agentic_coding_protocol::{self as acp, PushToolCallParams, ToolCallLocation};
  4use itertools::Itertools;
  5use schemars::JsonSchema;
  6use serde::{Deserialize, Serialize};
  7use util::ResultExt;
  8
  9pub enum ClaudeTool {
 10    Task(Option<TaskToolParams>),
 11    NotebookRead(Option<NotebookReadToolParams>),
 12    NotebookEdit(Option<NotebookEditToolParams>),
 13    Edit(Option<EditToolParams>),
 14    MultiEdit(Option<MultiEditToolParams>),
 15    ReadFile(Option<ReadToolParams>),
 16    Write(Option<WriteToolParams>),
 17    Ls(Option<LsToolParams>),
 18    Glob(Option<GlobToolParams>),
 19    Grep(Option<GrepToolParams>),
 20    Terminal(Option<BashToolParams>),
 21    WebFetch(Option<WebFetchToolParams>),
 22    WebSearch(Option<WebSearchToolParams>),
 23    TodoWrite(Option<TodoWriteToolParams>),
 24    ExitPlanMode(Option<ExitPlanModeToolParams>),
 25    Other {
 26        name: String,
 27        input: serde_json::Value,
 28    },
 29}
 30
 31impl ClaudeTool {
 32    pub fn infer(tool_name: &str, input: serde_json::Value) -> Self {
 33        match tool_name {
 34            // Known tools
 35            "mcp__zed__Read" => Self::ReadFile(serde_json::from_value(input).log_err()),
 36            "mcp__zed__Edit" => Self::Edit(serde_json::from_value(input).log_err()),
 37            "MultiEdit" => Self::MultiEdit(serde_json::from_value(input).log_err()),
 38            "Write" => Self::Write(serde_json::from_value(input).log_err()),
 39            "LS" => Self::Ls(serde_json::from_value(input).log_err()),
 40            "Glob" => Self::Glob(serde_json::from_value(input).log_err()),
 41            "Grep" => Self::Grep(serde_json::from_value(input).log_err()),
 42            "Bash" => Self::Terminal(serde_json::from_value(input).log_err()),
 43            "WebFetch" => Self::WebFetch(serde_json::from_value(input).log_err()),
 44            "WebSearch" => Self::WebSearch(serde_json::from_value(input).log_err()),
 45            "TodoWrite" => Self::TodoWrite(serde_json::from_value(input).log_err()),
 46            "exit_plan_mode" => Self::ExitPlanMode(serde_json::from_value(input).log_err()),
 47            "Task" => Self::Task(serde_json::from_value(input).log_err()),
 48            "NotebookRead" => Self::NotebookRead(serde_json::from_value(input).log_err()),
 49            "NotebookEdit" => Self::NotebookEdit(serde_json::from_value(input).log_err()),
 50            // Inferred from name
 51            _ => {
 52                let tool_name = tool_name.to_lowercase();
 53
 54                if tool_name.contains("edit") || tool_name.contains("write") {
 55                    Self::Edit(None)
 56                } else if tool_name.contains("terminal") {
 57                    Self::Terminal(None)
 58                } else {
 59                    Self::Other {
 60                        name: tool_name.to_string(),
 61                        input,
 62                    }
 63                }
 64            }
 65        }
 66    }
 67
 68    pub fn label(&self) -> String {
 69        match &self {
 70            Self::Task(Some(params)) => params.description.clone(),
 71            Self::Task(None) => "Task".into(),
 72            Self::NotebookRead(Some(params)) => {
 73                format!("Read Notebook {}", params.notebook_path.display())
 74            }
 75            Self::NotebookRead(None) => "Read Notebook".into(),
 76            Self::NotebookEdit(Some(params)) => {
 77                format!("Edit Notebook {}", params.notebook_path.display())
 78            }
 79            Self::NotebookEdit(None) => "Edit Notebook".into(),
 80            Self::Terminal(Some(params)) => format!("`{}`", params.command),
 81            Self::Terminal(None) => "Terminal".into(),
 82            Self::ReadFile(_) => "Read File".into(),
 83            Self::Ls(Some(params)) => {
 84                format!("List Directory {}", params.path.display())
 85            }
 86            Self::Ls(None) => "List Directory".into(),
 87            Self::Edit(Some(params)) => {
 88                format!("Edit {}", params.abs_path.display())
 89            }
 90            Self::Edit(None) => "Edit".into(),
 91            Self::MultiEdit(Some(params)) => {
 92                format!("Multi Edit {}", params.file_path.display())
 93            }
 94            Self::MultiEdit(None) => "Multi Edit".into(),
 95            Self::Write(Some(params)) => {
 96                format!("Write {}", params.file_path.display())
 97            }
 98            Self::Write(None) => "Write".into(),
 99            Self::Glob(Some(params)) => {
100                format!("Glob {params}")
101            }
102            Self::Glob(None) => "Glob".into(),
103            Self::Grep(Some(params)) => params.to_string(),
104            Self::Grep(None) => "Grep".into(),
105            Self::WebFetch(Some(params)) => format!("Fetch {}", params.url),
106            Self::WebFetch(None) => "Fetch".into(),
107            Self::WebSearch(Some(params)) => format!("Web Search: {}", params),
108            Self::WebSearch(None) => "Web Search".into(),
109            Self::TodoWrite(Some(params)) => format!(
110                "Update TODOs: {}",
111                params.todos.iter().map(|todo| &todo.content).join(", ")
112            ),
113            Self::TodoWrite(None) => "Update TODOs".into(),
114            Self::ExitPlanMode(_) => "Exit Plan Mode".into(),
115            Self::Other { name, .. } => name.clone(),
116        }
117    }
118
119    pub fn content(&self) -> Option<acp::ToolCallContent> {
120        match &self {
121            ClaudeTool::Other { input, .. } => Some(acp::ToolCallContent::Markdown {
122                markdown: format!(
123                    "```json\n{}```",
124                    serde_json::to_string_pretty(&input).unwrap_or("{}".to_string())
125                ),
126            }),
127            _ => None,
128        }
129    }
130
131    pub fn icon(&self) -> acp::Icon {
132        match self {
133            Self::Task(_) => acp::Icon::Hammer,
134            Self::NotebookRead(_) => acp::Icon::FileSearch,
135            Self::NotebookEdit(_) => acp::Icon::Pencil,
136            Self::Edit(_) => acp::Icon::Pencil,
137            Self::MultiEdit(_) => acp::Icon::Pencil,
138            Self::Write(_) => acp::Icon::Pencil,
139            Self::ReadFile(_) => acp::Icon::FileSearch,
140            Self::Ls(_) => acp::Icon::Folder,
141            Self::Glob(_) => acp::Icon::FileSearch,
142            Self::Grep(_) => acp::Icon::Regex,
143            Self::Terminal(_) => acp::Icon::Terminal,
144            Self::WebSearch(_) => acp::Icon::Globe,
145            Self::WebFetch(_) => acp::Icon::Globe,
146            Self::TodoWrite(_) => acp::Icon::LightBulb,
147            Self::ExitPlanMode(_) => acp::Icon::Hammer,
148            Self::Other { .. } => acp::Icon::Hammer,
149        }
150    }
151
152    pub fn confirmation(&self, description: Option<String>) -> acp::ToolCallConfirmation {
153        match &self {
154            Self::Edit(_) | Self::Write(_) | Self::NotebookEdit(_) | Self::MultiEdit(_) => {
155                acp::ToolCallConfirmation::Edit { description }
156            }
157            Self::WebFetch(params) => acp::ToolCallConfirmation::Fetch {
158                urls: params
159                    .as_ref()
160                    .map(|p| vec![p.url.clone()])
161                    .unwrap_or_default(),
162                description,
163            },
164            Self::Terminal(Some(BashToolParams {
165                description,
166                command,
167                ..
168            })) => acp::ToolCallConfirmation::Execute {
169                command: command.clone(),
170                root_command: command.clone(),
171                description: description.clone(),
172            },
173            Self::ExitPlanMode(Some(params)) => acp::ToolCallConfirmation::Other {
174                description: if let Some(description) = description {
175                    format!("{description} {}", params.plan)
176                } else {
177                    params.plan.clone()
178                },
179            },
180            Self::Task(Some(params)) => acp::ToolCallConfirmation::Other {
181                description: if let Some(description) = description {
182                    format!("{description} {}", params.description)
183                } else {
184                    params.description.clone()
185                },
186            },
187            Self::Ls(Some(LsToolParams { path, .. }))
188            | Self::ReadFile(Some(ReadToolParams { abs_path: path, .. })) => {
189                let path = path.display();
190                acp::ToolCallConfirmation::Other {
191                    description: if let Some(description) = description {
192                        format!("{description} {path}")
193                    } else {
194                        path.to_string()
195                    },
196                }
197            }
198            Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
199                let path = notebook_path.display();
200                acp::ToolCallConfirmation::Other {
201                    description: if let Some(description) = description {
202                        format!("{description} {path}")
203                    } else {
204                        path.to_string()
205                    },
206                }
207            }
208            Self::Glob(Some(params)) => acp::ToolCallConfirmation::Other {
209                description: if let Some(description) = description {
210                    format!("{description} {params}")
211                } else {
212                    params.to_string()
213                },
214            },
215            Self::Grep(Some(params)) => acp::ToolCallConfirmation::Other {
216                description: if let Some(description) = description {
217                    format!("{description} {params}")
218                } else {
219                    params.to_string()
220                },
221            },
222            Self::WebSearch(Some(params)) => acp::ToolCallConfirmation::Other {
223                description: if let Some(description) = description {
224                    format!("{description} {params}")
225                } else {
226                    params.to_string()
227                },
228            },
229            Self::TodoWrite(Some(params)) => {
230                let params = params.todos.iter().map(|todo| &todo.content).join(", ");
231                acp::ToolCallConfirmation::Other {
232                    description: if let Some(description) = description {
233                        format!("{description} {params}")
234                    } else {
235                        params
236                    },
237                }
238            }
239            Self::Terminal(None)
240            | Self::Task(None)
241            | Self::NotebookRead(None)
242            | Self::ExitPlanMode(None)
243            | Self::Ls(None)
244            | Self::Glob(None)
245            | Self::Grep(None)
246            | Self::ReadFile(None)
247            | Self::WebSearch(None)
248            | Self::TodoWrite(None)
249            | Self::Other { .. } => acp::ToolCallConfirmation::Other {
250                description: description.unwrap_or("".to_string()),
251            },
252        }
253    }
254
255    pub fn locations(&self) -> Vec<acp::ToolCallLocation> {
256        match &self {
257            Self::Edit(Some(EditToolParams { abs_path, .. })) => vec![ToolCallLocation {
258                path: abs_path.clone(),
259                line: None,
260            }],
261            Self::MultiEdit(Some(MultiEditToolParams { file_path, .. })) => {
262                vec![ToolCallLocation {
263                    path: file_path.clone(),
264                    line: None,
265                }]
266            }
267            Self::Write(Some(WriteToolParams { file_path, .. })) => vec![ToolCallLocation {
268                path: file_path.clone(),
269                line: None,
270            }],
271            Self::ReadFile(Some(ReadToolParams {
272                abs_path, offset, ..
273            })) => vec![ToolCallLocation {
274                path: abs_path.clone(),
275                line: *offset,
276            }],
277            Self::NotebookRead(Some(NotebookReadToolParams { notebook_path, .. })) => {
278                vec![ToolCallLocation {
279                    path: notebook_path.clone(),
280                    line: None,
281                }]
282            }
283            Self::NotebookEdit(Some(NotebookEditToolParams { notebook_path, .. })) => {
284                vec![ToolCallLocation {
285                    path: notebook_path.clone(),
286                    line: None,
287                }]
288            }
289            Self::Glob(Some(GlobToolParams {
290                path: Some(path), ..
291            })) => vec![ToolCallLocation {
292                path: path.clone(),
293                line: None,
294            }],
295            Self::Ls(Some(LsToolParams { path, .. })) => vec![ToolCallLocation {
296                path: path.clone(),
297                line: None,
298            }],
299            Self::Grep(Some(GrepToolParams {
300                path: Some(path), ..
301            })) => vec![ToolCallLocation {
302                path: PathBuf::from(path),
303                line: None,
304            }],
305            Self::Task(_)
306            | Self::NotebookRead(None)
307            | Self::NotebookEdit(None)
308            | Self::Edit(None)
309            | Self::MultiEdit(None)
310            | Self::Write(None)
311            | Self::ReadFile(None)
312            | Self::Ls(None)
313            | Self::Glob(_)
314            | Self::Grep(_)
315            | Self::Terminal(_)
316            | Self::WebFetch(_)
317            | Self::WebSearch(_)
318            | Self::TodoWrite(_)
319            | Self::ExitPlanMode(_)
320            | Self::Other { .. } => vec![],
321        }
322    }
323
324    pub fn as_acp(&self) -> PushToolCallParams {
325        PushToolCallParams {
326            label: self.label(),
327            content: self.content(),
328            icon: self.icon(),
329            locations: self.locations(),
330        }
331    }
332}
333
334#[derive(Deserialize, JsonSchema, Debug)]
335pub struct EditToolParams {
336    /// The absolute path to the file to read.
337    pub abs_path: PathBuf,
338    /// The old text to replace (must be unique in the file)
339    pub old_text: String,
340    /// The new text.
341    pub new_text: String,
342}
343
344#[derive(Serialize)]
345#[serde(rename_all = "camelCase")]
346pub struct EditToolResponse;
347
348#[derive(Deserialize, JsonSchema, Debug)]
349pub struct ReadToolParams {
350    /// The absolute path to the file to read.
351    pub abs_path: PathBuf,
352    /// Which line to start reading from. Omit to start from the beginning.
353    #[serde(skip_serializing_if = "Option::is_none")]
354    pub offset: Option<u32>,
355    /// How many lines to read. Omit for the whole file.
356    #[serde(skip_serializing_if = "Option::is_none")]
357    pub limit: Option<u32>,
358}
359
360#[derive(Serialize)]
361#[serde(rename_all = "camelCase")]
362pub struct ReadToolResponse {
363    pub content: String,
364}
365
366#[derive(Deserialize, JsonSchema, Debug)]
367pub struct WriteToolParams {
368    /// Absolute path for new file
369    pub file_path: PathBuf,
370    /// File content
371    pub content: String,
372}
373
374#[derive(Deserialize, JsonSchema, Debug)]
375pub struct BashToolParams {
376    /// Shell command to execute
377    pub command: String,
378    /// 5-10 word description of what command does
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub description: Option<String>,
381    /// Timeout in ms (max 600000ms/10min, default 120000ms)
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub timeout: Option<u32>,
384}
385
386#[derive(Deserialize, JsonSchema, Debug)]
387pub struct GlobToolParams {
388    /// Glob pattern like **/*.js or src/**/*.ts
389    pub pattern: String,
390    /// Directory to search in (omit for current directory)
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub path: Option<PathBuf>,
393}
394
395impl std::fmt::Display for GlobToolParams {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        if let Some(path) = &self.path {
398            write!(f, "{}", path.display())?;
399        }
400        write!(f, "{}", self.pattern)
401    }
402}
403
404#[derive(Deserialize, JsonSchema, Debug)]
405pub struct LsToolParams {
406    /// Absolute path to directory
407    pub path: PathBuf,
408    /// Array of glob patterns to ignore
409    #[serde(default, skip_serializing_if = "Vec::is_empty")]
410    pub ignore: Vec<String>,
411}
412
413#[derive(Deserialize, JsonSchema, Debug)]
414pub struct GrepToolParams {
415    /// Regex pattern to search for
416    pub pattern: String,
417    /// File/directory to search (defaults to current directory)
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub path: Option<String>,
420    /// "content" (shows lines), "files_with_matches" (default), "count"
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub output_mode: Option<GrepOutputMode>,
423    /// Filter files with glob pattern like "*.js"
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub glob: Option<String>,
426    /// File type filter like "js", "py", "rust"
427    #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
428    pub file_type: Option<String>,
429    /// Case insensitive search
430    #[serde(rename = "-i", default, skip_serializing_if = "is_false")]
431    pub case_insensitive: bool,
432    /// Show line numbers (content mode only)
433    #[serde(rename = "-n", default, skip_serializing_if = "is_false")]
434    pub line_numbers: bool,
435    /// Lines after match (content mode only)
436    #[serde(rename = "-A", skip_serializing_if = "Option::is_none")]
437    pub after_context: Option<u32>,
438    /// Lines before match (content mode only)
439    #[serde(rename = "-B", skip_serializing_if = "Option::is_none")]
440    pub before_context: Option<u32>,
441    /// Lines before and after match (content mode only)
442    #[serde(rename = "-C", skip_serializing_if = "Option::is_none")]
443    pub context: Option<u32>,
444    /// Enable multiline/cross-line matching
445    #[serde(default, skip_serializing_if = "is_false")]
446    pub multiline: bool,
447    /// Limit output to first N results
448    #[serde(skip_serializing_if = "Option::is_none")]
449    pub head_limit: Option<u32>,
450}
451
452impl std::fmt::Display for GrepToolParams {
453    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
454        write!(f, "grep")?;
455
456        // Boolean flags
457        if self.case_insensitive {
458            write!(f, " -i")?;
459        }
460        if self.line_numbers {
461            write!(f, " -n")?;
462        }
463
464        // Context options
465        if let Some(after) = self.after_context {
466            write!(f, " -A {}", after)?;
467        }
468        if let Some(before) = self.before_context {
469            write!(f, " -B {}", before)?;
470        }
471        if let Some(context) = self.context {
472            write!(f, " -C {}", context)?;
473        }
474
475        // Output mode
476        if let Some(mode) = &self.output_mode {
477            match mode {
478                GrepOutputMode::FilesWithMatches => write!(f, " -l")?,
479                GrepOutputMode::Count => write!(f, " -c")?,
480                GrepOutputMode::Content => {} // Default mode
481            }
482        }
483
484        // Head limit
485        if let Some(limit) = self.head_limit {
486            write!(f, " | head -{}", limit)?;
487        }
488
489        // Glob pattern
490        if let Some(glob) = &self.glob {
491            write!(f, " --include=\"{}\"", glob)?;
492        }
493
494        // File type
495        if let Some(file_type) = &self.file_type {
496            write!(f, " --type={}", file_type)?;
497        }
498
499        // Multiline
500        if self.multiline {
501            write!(f, " -P")?; // Perl-compatible regex for multiline
502        }
503
504        // Pattern (escaped if contains special characters)
505        write!(f, " \"{}\"", self.pattern)?;
506
507        // Path
508        if let Some(path) = &self.path {
509            write!(f, " {}", path)?;
510        }
511
512        Ok(())
513    }
514}
515
516#[derive(Deserialize, Serialize, JsonSchema, Debug)]
517#[serde(rename_all = "snake_case")]
518pub enum TodoPriority {
519    High,
520    Medium,
521    Low,
522}
523
524#[derive(Deserialize, Serialize, JsonSchema, Debug)]
525#[serde(rename_all = "snake_case")]
526pub enum TodoStatus {
527    Pending,
528    InProgress,
529    Completed,
530}
531
532#[derive(Deserialize, Serialize, JsonSchema, Debug)]
533pub struct Todo {
534    /// Unique identifier
535    pub id: String,
536    /// Task description
537    pub content: String,
538    /// Priority level of the todo
539    pub priority: TodoPriority,
540    /// Current status of the todo
541    pub status: TodoStatus,
542}
543
544#[derive(Deserialize, JsonSchema, Debug)]
545pub struct TodoWriteToolParams {
546    pub todos: Vec<Todo>,
547}
548
549#[derive(Deserialize, JsonSchema, Debug)]
550pub struct ExitPlanModeToolParams {
551    /// Implementation plan in markdown format
552    pub plan: String,
553}
554
555#[derive(Deserialize, JsonSchema, Debug)]
556pub struct TaskToolParams {
557    /// Short 3-5 word description of task
558    pub description: String,
559    /// Detailed task for agent to perform
560    pub prompt: String,
561}
562
563#[derive(Deserialize, JsonSchema, Debug)]
564pub struct NotebookReadToolParams {
565    /// Absolute path to .ipynb file
566    pub notebook_path: PathBuf,
567    /// Specific cell ID to read
568    #[serde(skip_serializing_if = "Option::is_none")]
569    pub cell_id: Option<String>,
570}
571
572#[derive(Deserialize, Serialize, JsonSchema, Debug)]
573#[serde(rename_all = "snake_case")]
574pub enum CellType {
575    Code,
576    Markdown,
577}
578
579#[derive(Deserialize, Serialize, JsonSchema, Debug)]
580#[serde(rename_all = "snake_case")]
581pub enum EditMode {
582    Replace,
583    Insert,
584    Delete,
585}
586
587#[derive(Deserialize, JsonSchema, Debug)]
588pub struct NotebookEditToolParams {
589    /// Absolute path to .ipynb file
590    pub notebook_path: PathBuf,
591    /// New cell content
592    pub new_source: String,
593    /// Cell ID to edit
594    #[serde(skip_serializing_if = "Option::is_none")]
595    pub cell_id: Option<String>,
596    /// Type of cell (code or markdown)
597    #[serde(skip_serializing_if = "Option::is_none")]
598    pub cell_type: Option<CellType>,
599    /// Edit operation mode
600    #[serde(skip_serializing_if = "Option::is_none")]
601    pub edit_mode: Option<EditMode>,
602}
603
604#[derive(Deserialize, Serialize, JsonSchema, Debug)]
605pub struct MultiEditItem {
606    /// The text to search for and replace
607    pub old_string: String,
608    /// The replacement text
609    pub new_string: String,
610    /// Whether to replace all occurrences or just the first
611    #[serde(default, skip_serializing_if = "is_false")]
612    pub replace_all: bool,
613}
614
615#[derive(Deserialize, JsonSchema, Debug)]
616pub struct MultiEditToolParams {
617    /// Absolute path to file
618    pub file_path: PathBuf,
619    /// List of edits to apply
620    pub edits: Vec<MultiEditItem>,
621}
622
623fn is_false(v: &bool) -> bool {
624    !*v
625}
626
627#[derive(Deserialize, JsonSchema, Debug)]
628#[serde(rename_all = "snake_case")]
629pub enum GrepOutputMode {
630    Content,
631    FilesWithMatches,
632    Count,
633}
634
635#[derive(Deserialize, JsonSchema, Debug)]
636pub struct WebFetchToolParams {
637    /// Valid URL to fetch
638    #[serde(rename = "url")]
639    pub url: String,
640    /// What to extract from content
641    pub prompt: String,
642}
643
644#[derive(Deserialize, JsonSchema, Debug)]
645pub struct WebSearchToolParams {
646    /// Search query (min 2 chars)
647    pub query: String,
648    /// Only include these domains
649    #[serde(default, skip_serializing_if = "Vec::is_empty")]
650    pub allowed_domains: Vec<String>,
651    /// Exclude these domains
652    #[serde(default, skip_serializing_if = "Vec::is_empty")]
653    pub blocked_domains: Vec<String>,
654}
655
656impl std::fmt::Display for WebSearchToolParams {
657    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
658        write!(f, "\"{}\"", self.query)?;
659
660        if !self.allowed_domains.is_empty() {
661            write!(f, " (allowed: {})", self.allowed_domains.join(", "))?;
662        }
663
664        if !self.blocked_domains.is_empty() {
665            write!(f, " (blocked: {})", self.blocked_domains.join(", "))?;
666        }
667
668        Ok(())
669    }
670}