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