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)) => format!("`{params}`"),
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}