1use std::collections::BTreeMap;
18
19use serde::Serialize;
20
21use crate::HarnessId;
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
25pub enum Native {
26 Tools(&'static [&'static str]),
28 ScopedTools {
32 unscoped: &'static [&'static str],
33 scoped: &'static [&'static str],
34 note: &'static str,
35 },
36 Absent,
38 NoSwitch(&'static str),
42}
43
44impl Native {
45 pub fn native_tools(self) -> &'static [&'static str] {
48 match self {
49 Self::Tools(names)
50 | Self::ScopedTools {
51 unscoped: names, ..
52 } => names,
53 Self::Absent | Self::NoSwitch(_) => &[],
54 }
55 }
56}
57
58use Native::{Absent, NoSwitch, ScopedTools, Tools};
59
60type Table = &'static [(&'static str, Native)];
61
62pub fn lookup(capability: &str, harness: HarnessId) -> Option<Native> {
64 table(harness)
65 .iter()
66 .find(|(name, _)| *name == capability)
67 .map(|(_, native)| *native)
68}
69
70#[derive(Debug, Clone, Serialize)]
73pub struct Vocabulary {
74 pub capabilities: Vec<crate::tool_names::CapabilityEntry>,
75 pub natives: BTreeMap<&'static str, BTreeMap<&'static str, NativeEntry>>,
76}
77
78#[derive(Debug, Clone, Serialize)]
80#[serde(tag = "kind", rename_all = "kebab-case")]
81pub enum NativeEntry {
82 Tools {
83 tools: &'static [&'static str],
84 },
85 ScopedTools {
86 unscoped: &'static [&'static str],
87 scoped: &'static [&'static str],
88 note: &'static str,
89 },
90 Absent,
91 NoSwitch {
92 note: &'static str,
93 },
94}
95
96impl From<Native> for NativeEntry {
97 fn from(native: Native) -> Self {
98 match native {
99 Tools(tools) => Self::Tools { tools },
100 ScopedTools {
101 unscoped,
102 scoped,
103 note,
104 } => Self::ScopedTools {
105 unscoped,
106 scoped,
107 note,
108 },
109 Absent => Self::Absent,
110 NoSwitch(note) => Self::NoSwitch { note },
111 }
112 }
113}
114
115pub fn vocabulary() -> Vocabulary {
117 let rows = |table: Table| {
118 table
119 .iter()
120 .map(|(name, native)| (*name, NativeEntry::from(*native)))
121 .collect()
122 };
123 let mut natives: BTreeMap<_, _> = crate::harness::ALL
124 .iter()
125 .map(|host| (host.as_str(), rows(table(*host))))
126 .collect();
127 natives.insert("meridian-flow", rows(FLOW));
128 Vocabulary {
129 capabilities: crate::tool_names::capabilities(),
130 natives,
131 }
132}
133
134fn table(harness: HarnessId) -> Table {
135 match harness {
136 HarnessId::Claude => CLAUDE,
137 HarnessId::Codex => CODEX,
138 HarnessId::OpenCode => OPENCODE,
139 HarnessId::Pi => PI,
140 HarnessId::Cursor => CURSOR,
141 }
142}
143
144const CLAUDE: Table = &[
158 ("bash", Tools(&["Bash"])),
159 ("read", Tools(&["Read"])),
160 ("ls", Absent),
162 ("work", Absent),
163 (
164 "write",
165 ScopedTools {
166 unscoped: &["Write"],
167 scoped: &["Edit"],
168 note: "Claude scopes Write through Edit(path): an allow adds no practical write power because Write can replace the whole file, while a deny also refuses other built-in file edits at that path",
169 },
170 ),
171 (
172 "edit",
173 ScopedTools {
174 unscoped: &["Edit", "Write", "NotebookEdit"],
175 scoped: &["Edit"],
176 note: "Claude uses Edit(path) to scope every built-in file-editing tool",
177 },
178 ),
179 (
180 "notebook",
181 ScopedTools {
182 unscoped: &["NotebookEdit"],
183 scoped: &["Edit"],
184 note: "Claude scopes NotebookEdit through Edit(path), which also covers other built-in file edits at that path",
185 },
186 ),
187 ("agent", Tools(&["Agent", "SendMessage", "ListAgents"])),
188 ("skill", Tools(&["Skill"])),
189 ("workflow", Tools(&["Workflow"])),
190 (
191 "glob",
192 ScopedTools {
193 unscoped: &["Glob"],
194 scoped: &["Read"],
195 note: "Claude scopes Glob paths through Read(path), which also covers other built-in file readers at that path",
196 },
197 ),
198 (
199 "grep",
200 ScopedTools {
201 unscoped: &["Grep"],
202 scoped: &["Read"],
203 note: "Claude scopes Grep paths through Read(path), which also covers other built-in file readers at that path",
204 },
205 ),
206 ("web", Tools(&["WebSearch", "WebFetch"])),
207 ("web_search", Tools(&["WebSearch"])),
208 ("web_fetch", Tools(&["WebFetch"])),
209 ("ask_user", Tools(&["AskUserQuestion"])),
210 (
215 "task",
216 Tools(&[
217 "TaskCreate",
218 "TaskGet",
219 "TaskList",
220 "TaskOutput",
221 "TaskUpdate",
222 "TaskStop",
223 "TodoWrite",
224 ]),
225 ),
226 ("cron", Tools(&["CronCreate", "CronDelete", "CronList"])),
227 ("notifications", Tools(&["PushNotification"])),
228 ("plan_mode", Tools(&["EnterPlanMode", "ExitPlanMode"])),
229 ("worktree", Tools(&["EnterWorktree", "ExitWorktree"])),
230 ("lsp", Tools(&["LSP"])),
231 ("monitor", Tools(&["Monitor"])),
232 ("send_user_file", Tools(&["SendUserFile"])),
233 ("schedule_wakeup", Tools(&["ScheduleWakeup"])),
234 ("remote_trigger", Tools(&["RemoteTrigger"])),
235 ("tool_search", Tools(&["ToolSearch"])),
236 (
237 "image",
238 NoSwitch("claude views images through Read; no switch"),
239 ),
240];
241
242const CODEX: Table = &[
259 ("bash", Tools(&["exec_command"])),
260 ("ls", Absent),
261 ("work", Absent),
262 (
263 "read",
264 NoSwitch("codex reads files through its shell; no switch"),
265 ),
266 ("write", Tools(&["apply_patch"])),
267 ("edit", Tools(&["apply_patch"])),
268 ("notebook", Absent),
269 (
270 "agent",
271 Tools(&[
272 "spawn_agent",
273 "send_input",
274 "resume_agent",
275 "wait_agent",
276 "close_agent",
277 "send_message",
278 "followup_task",
279 "interrupt_agent",
280 "list_agents",
281 ]),
282 ),
283 (
284 "skill",
285 NoSwitch("codex loads skills by reading files; no switch"),
286 ),
287 ("workflow", Absent),
288 (
289 "glob",
290 NoSwitch("codex finds files through its shell; no switch"),
291 ),
292 (
293 "grep",
294 NoSwitch("codex searches files through its shell; no switch"),
295 ),
296 ("web", Tools(&["web_search"])),
297 ("web_search", Tools(&["web_search"])),
298 (
299 "web_fetch",
300 NoSwitch("codex opens pages through hosted web_search; no separate switch"),
301 ),
302 ("ask_user", Tools(&["request_user_input"])),
303 ("task", Tools(&["update_plan"])),
304 ("cron", Absent),
305 ("notifications", Absent),
306 ("plan_mode", Absent),
307 ("worktree", Absent),
308 ("lsp", Absent),
309 ("monitor", Absent),
310 ("send_user_file", Absent),
311 ("schedule_wakeup", Absent),
312 ("remote_trigger", Absent),
313 ("tool_search", Tools(&["tool_search"])),
314 ("image", Tools(&["view_image"])),
315];
316
317const OPENCODE: Table = &[
327 ("bash", Tools(&["bash"])),
328 ("ls", Tools(&["list"])),
329 ("work", Absent),
330 ("read", Tools(&["read"])),
331 ("write", Tools(&["edit"])),
332 ("edit", Tools(&["edit"])),
333 ("notebook", Absent),
334 ("agent", Tools(&["task"])),
335 ("skill", Tools(&["skill"])),
336 ("workflow", Absent),
337 ("glob", Tools(&["glob"])),
338 ("grep", Tools(&["grep"])),
339 ("web", Tools(&["websearch", "webfetch"])),
340 ("web_search", Tools(&["websearch"])),
341 ("web_fetch", Tools(&["webfetch"])),
342 ("ask_user", Tools(&["question"])),
343 ("task", Tools(&["todowrite"])),
344 ("plan_mode", Tools(&["plan_enter", "plan_exit"])),
345 ("cron", Absent),
346 ("notifications", Absent),
347 ("worktree", Absent),
348 ("lsp", Tools(&["lsp"])),
349 ("monitor", Absent),
350 ("send_user_file", Absent),
351 ("schedule_wakeup", Absent),
352 ("remote_trigger", Absent),
353 ("tool_search", Absent),
354 (
355 "image",
356 NoSwitch("opencode views images through read; no switch"),
357 ),
358];
359
360const PI: Table = &[
370 ("bash", Tools(&["bash"])),
371 ("ls", Tools(&["ls"])),
372 ("work", Absent),
373 ("read", Tools(&["read"])),
374 ("write", Tools(&["write"])),
375 ("edit", Tools(&["edit", "write"])),
376 ("notebook", Absent),
377 ("agent", Absent),
378 (
379 "skill",
380 NoSwitch("pi loads skills by reading files; no switch"),
381 ),
382 ("workflow", Absent),
383 ("glob", Tools(&["find"])),
384 ("grep", Tools(&["grep"])),
385 ("web", Absent),
386 ("web_search", Absent),
387 ("web_fetch", Absent),
388 ("ask_user", Absent),
389 ("task", Absent),
390 ("plan_mode", Absent),
391 ("cron", Absent),
392 ("notifications", Absent),
393 ("worktree", Absent),
394 ("lsp", Absent),
395 ("monitor", Absent),
396 ("send_user_file", Absent),
397 ("schedule_wakeup", Absent),
398 ("remote_trigger", Absent),
399 ("tool_search", Absent),
400 ("image", NoSwitch("pi views images through read; no switch")),
401];
402
403const CURSOR: Table = &[
408 ("bash", Tools(&["Shell"])),
409 ("ls", Absent),
410 ("work", Absent),
411 ("read", Tools(&["Read"])),
412 ("write", Tools(&["Write"])),
413 ("edit", Tools(&["StrReplace"])),
414 ("notebook", Tools(&["EditNotebook"])),
415 ("agent", Tools(&["Task"])),
416 ("skill", Tools(&["Skill"])),
417 ("glob", Tools(&["Glob"])),
418 ("grep", Tools(&["Grep"])),
419 ("web", Tools(&["WebSearch", "WebFetch"])),
420 ("web_search", Tools(&["WebSearch"])),
421 ("web_fetch", Tools(&["WebFetch"])),
422 ("ask_user", Tools(&["AskQuestion"])),
423 ("task", Tools(&["TodoWrite"])),
424 ("plan_mode", Tools(&["SwitchMode"])),
425];
426
427const FLOW: Table = &[
432 ("read", Tools(&["read"])),
433 ("write", Tools(&["write"])),
434 ("edit", Tools(&["write"])),
435 ("grep", Tools(&["grep"])),
436 ("glob", Tools(&["glob"])),
437 ("ls", Tools(&["ls"])),
438 ("skill", Tools(&["skill"])),
439 ("ask_user", Tools(&["ask"])),
440 (
441 "agent",
442 Tools(&[
443 "spawn",
444 "thread_message",
445 "thread_report",
446 "thread_ls",
447 "thread_history",
448 ]),
449 ),
450 ("work", Tools(&["work"])),
451];
452
453#[cfg(test)]
454mod tests {
455 use super::*;
456
457 fn real_tools(harness: HarnessId) -> &'static [&'static str] {
460 match harness {
461 HarnessId::Claude => &[
464 "Agent",
465 "Artifact",
466 "AskUserQuestion",
467 "Bash",
468 "CronCreate",
469 "CronDelete",
470 "CronList",
471 "DesignSync",
472 "Edit",
473 "EndConversation",
474 "EnterPlanMode",
475 "EnterWorktree",
476 "ExitPlanMode",
477 "ExitWorktree",
478 "Glob",
479 "Grep",
480 "ListAgents",
481 "ListMcpResourcesTool",
482 "LSP",
483 "Monitor",
484 "NotebookEdit",
485 "PowerShell",
486 "PushNotification",
487 "Read",
488 "ReadMcpResourceTool",
489 "RemoteTrigger",
490 "ReportFindings",
491 "ScheduleWakeup",
492 "SendFeedback",
493 "SendMessage",
494 "SendUserFile",
495 "ShareOnboardingGuide",
496 "Skill",
497 "SubagentHandback",
498 "TaskCreate",
499 "TaskGet",
500 "TaskList",
501 "TaskOutput",
502 "TaskStop",
503 "TaskUpdate",
504 "TodoWrite",
505 "ToolSearch",
506 "WaitForMcpServers",
507 "WebFetch",
508 "WebSearch",
509 "Workflow",
510 "Write",
511 ],
512 HarnessId::Codex => &[
516 "apply_patch",
517 "close_agent",
518 "exec_command",
519 "followup_task",
520 "image_generation",
521 "interrupt_agent",
522 "js_repl",
523 "list_agents",
524 "list_mcp_resource_templates",
525 "list_mcp_resources",
526 "read_mcp_resource",
527 "request_permissions",
528 "request_user_input",
529 "resume_agent",
530 "send_input",
531 "send_message",
532 "spawn_agent",
533 "tool_search",
534 "update_plan",
535 "view_image",
536 "wait_agent",
537 "web_search",
538 "write_stdin",
539 ],
540 HarnessId::OpenCode => &[
543 "bash",
544 "edit",
545 "glob",
546 "grep",
547 "list",
548 "lsp",
549 "plan_enter",
550 "plan_exit",
551 "question",
552 "read",
553 "skill",
554 "task",
555 "todowrite",
556 "webfetch",
557 "websearch",
558 ],
559 HarnessId::Pi => &[
561 "bash",
562 "edit",
563 "find",
564 "grep",
565 "ls",
566 "powershell",
567 "read",
568 "write",
569 ],
570 HarnessId::Cursor => &[
572 "AskQuestion",
573 "EditNotebook",
574 "Glob",
575 "Grep",
576 "Read",
577 "Shell",
578 "Skill",
579 "StrReplace",
580 "SwitchMode",
581 "Task",
582 "TodoWrite",
583 "WebFetch",
584 "WebSearch",
585 "Write",
586 ],
587 }
588 }
589
590 #[test]
591 fn every_native_name_and_native_tool_is_a_real_tool_of_its_harness() {
592 for &harness in crate::harness::ALL {
593 let real = real_tools(harness);
594 for (capability, native) in table(harness) {
595 let lists: &[&[&str]] = match native {
596 Tools(names) => &[names],
597 ScopedTools {
598 unscoped, scoped, ..
599 } => &[unscoped, scoped],
600 Absent | NoSwitch(_) => &[],
601 };
602 for names in lists {
603 assert!(
604 !names.is_empty(),
605 "{harness:?} `{capability}` names no tool"
606 );
607 for name in *names {
608 assert!(
609 real.contains(name),
610 "{harness:?} `{capability}` maps to `{name}`, which is not in its real tool list"
611 );
612 }
613 }
614 assert!(
615 native.native_tools().iter().all(|name| real.contains(name)),
616 "{harness:?} `{capability}` has a native tool outside its real tool list"
617 );
618 }
619 }
620 }
621
622 #[test]
623 fn claude_scoped_file_rules_separate_permission_names_from_native_tools() {
624 for (capability, unscoped, scoped) in [
625 ("write", &["Write"][..], &["Edit"][..]),
626 (
627 "edit",
628 &["Edit", "Write", "NotebookEdit"][..],
629 &["Edit"][..],
630 ),
631 ("notebook", &["NotebookEdit"][..], &["Edit"][..]),
632 ("glob", &["Glob"][..], &["Read"][..]),
633 ("grep", &["Grep"][..], &["Read"][..]),
634 ] {
635 let Some(ScopedTools {
636 unscoped: actual_unscoped,
637 scoped: actual_scoped,
638 note,
639 }) = lookup(capability, HarnessId::Claude)
640 else {
641 panic!("Claude `{capability}` must distinguish bare and scoped rules");
642 };
643 assert_eq!(actual_unscoped, unscoped, "{capability} bare rule");
644 assert_eq!(actual_scoped, scoped, "{capability} scoped rule");
645 assert_eq!(
646 lookup(capability, HarnessId::Claude)
647 .expect("row")
648 .native_tools(),
649 unscoped,
650 "{capability} availability tools"
651 );
652 assert!(
653 !note.is_empty(),
654 "{capability} must explain the wider category"
655 );
656 }
657
658 assert_eq!(
659 lookup("web_fetch", HarnessId::Claude),
660 Some(Tools(&["WebFetch"]))
661 );
662 }
663
664 #[test]
665 fn every_row_is_a_canonical_capability_named_once() {
666 for &harness in crate::harness::ALL {
667 let mut seen = std::collections::HashSet::new();
668 for (capability, _) in table(harness) {
669 assert!(
670 crate::tool_names::is_canonical_capability(capability),
671 "{harness:?} has a row for `{capability}`, which is not a canonical capability"
672 );
673 assert!(
674 seen.insert(*capability),
675 "{harness:?} repeats `{capability}`"
676 );
677 }
678 }
679 }
680
681 #[test]
682 fn maintained_harnesses_answer_for_every_canonical_capability() {
683 for harness in [
686 HarnessId::Claude,
687 HarnessId::Codex,
688 HarnessId::OpenCode,
689 HarnessId::Pi,
690 ] {
691 for capability in crate::tool_names::canonical_capabilities() {
692 assert!(
693 lookup(capability, harness).is_some(),
694 "{harness:?} has no row for `{capability}`"
695 );
696 }
697 }
698 }
699}