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 = &[
328 ("bash", Tools(&["bash"])),
329 ("ls", Tools(&["list"])),
330 ("work", Absent),
331 ("read", Tools(&["read"])),
332 ("write", Tools(&["edit"])),
333 ("edit", Tools(&["edit"])),
334 ("notebook", Absent),
335 ("agent", Tools(&["task"])),
336 ("skill", Tools(&["skill"])),
337 ("workflow", Absent),
338 ("glob", Tools(&["glob"])),
339 ("grep", Tools(&["grep"])),
340 ("web", Tools(&["websearch", "webfetch"])),
341 ("web_search", Tools(&["websearch"])),
342 ("web_fetch", Tools(&["webfetch"])),
343 ("ask_user", Tools(&["question"])),
344 ("task", Tools(&["todowrite"])),
345 ("plan_mode", Tools(&["plan_enter", "plan_exit"])),
346 ("cron", Absent),
347 ("notifications", Absent),
348 ("worktree", Absent),
349 ("lsp", Tools(&["lsp"])),
350 ("monitor", Absent),
351 ("send_user_file", Absent),
352 ("schedule_wakeup", Absent),
353 ("remote_trigger", Absent),
354 ("tool_search", Absent),
355 (
356 "image",
357 NoSwitch("opencode views images through read; no switch"),
358 ),
359];
360
361pub fn opencode_v2_action(v1: &str) -> &str {
365 match v1 {
366 "bash" => "shell",
367 "write" | "patch" => "edit",
368 "task" => "subagent",
369 other => other,
370 }
371}
372
373const PI: Table = &[
383 ("bash", Tools(&["bash"])),
384 ("ls", Tools(&["ls"])),
385 ("work", Absent),
386 ("read", Tools(&["read"])),
387 ("write", Tools(&["write"])),
388 ("edit", Tools(&["edit", "write"])),
389 ("notebook", Absent),
390 ("agent", Absent),
391 (
392 "skill",
393 NoSwitch("pi loads skills by reading files; no switch"),
394 ),
395 ("workflow", Absent),
396 ("glob", Tools(&["find"])),
397 ("grep", Tools(&["grep"])),
398 ("web", Absent),
399 ("web_search", Absent),
400 ("web_fetch", Absent),
401 ("ask_user", Absent),
402 ("task", Absent),
403 ("plan_mode", Absent),
404 ("cron", Absent),
405 ("notifications", Absent),
406 ("worktree", Absent),
407 ("lsp", Absent),
408 ("monitor", Absent),
409 ("send_user_file", Absent),
410 ("schedule_wakeup", Absent),
411 ("remote_trigger", Absent),
412 ("tool_search", Absent),
413 ("image", NoSwitch("pi views images through read; no switch")),
414];
415
416const CURSOR: Table = &[
421 ("bash", Tools(&["Shell"])),
422 ("ls", Absent),
423 ("work", Absent),
424 ("read", Tools(&["Read"])),
425 ("write", Tools(&["Write"])),
426 ("edit", Tools(&["StrReplace"])),
427 ("notebook", Tools(&["EditNotebook"])),
428 ("agent", Tools(&["Task"])),
429 ("skill", Tools(&["Skill"])),
430 ("glob", Tools(&["Glob"])),
431 ("grep", Tools(&["Grep"])),
432 ("web", Tools(&["WebSearch", "WebFetch"])),
433 ("web_search", Tools(&["WebSearch"])),
434 ("web_fetch", Tools(&["WebFetch"])),
435 ("ask_user", Tools(&["AskQuestion"])),
436 ("task", Tools(&["TodoWrite"])),
437 ("plan_mode", Tools(&["SwitchMode"])),
438];
439
440const FLOW: Table = &[
445 ("read", Tools(&["read"])),
446 ("write", Tools(&["write"])),
447 ("edit", Tools(&["write"])),
448 ("grep", Tools(&["grep"])),
449 ("glob", Tools(&["glob"])),
450 ("ls", Tools(&["ls"])),
451 ("skill", Tools(&["skill"])),
452 ("ask_user", Tools(&["ask"])),
453 (
454 "agent",
455 Tools(&[
456 "spawn",
457 "thread_message",
458 "thread_report",
459 "thread_ls",
460 "thread_history",
461 ]),
462 ),
463 ("work", Tools(&["work"])),
464];
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 fn real_tools(harness: HarnessId) -> &'static [&'static str] {
473 match harness {
474 HarnessId::Claude => &[
477 "Agent",
478 "Artifact",
479 "AskUserQuestion",
480 "Bash",
481 "CronCreate",
482 "CronDelete",
483 "CronList",
484 "DesignSync",
485 "Edit",
486 "EndConversation",
487 "EnterPlanMode",
488 "EnterWorktree",
489 "ExitPlanMode",
490 "ExitWorktree",
491 "Glob",
492 "Grep",
493 "ListAgents",
494 "ListMcpResourcesTool",
495 "LSP",
496 "Monitor",
497 "NotebookEdit",
498 "PowerShell",
499 "PushNotification",
500 "Read",
501 "ReadMcpResourceTool",
502 "RemoteTrigger",
503 "ReportFindings",
504 "ScheduleWakeup",
505 "SendFeedback",
506 "SendMessage",
507 "SendUserFile",
508 "ShareOnboardingGuide",
509 "Skill",
510 "SubagentHandback",
511 "TaskCreate",
512 "TaskGet",
513 "TaskList",
514 "TaskOutput",
515 "TaskStop",
516 "TaskUpdate",
517 "TodoWrite",
518 "ToolSearch",
519 "WaitForMcpServers",
520 "WebFetch",
521 "WebSearch",
522 "Workflow",
523 "Write",
524 ],
525 HarnessId::Codex => &[
529 "apply_patch",
530 "close_agent",
531 "exec_command",
532 "followup_task",
533 "image_generation",
534 "interrupt_agent",
535 "js_repl",
536 "list_agents",
537 "list_mcp_resource_templates",
538 "list_mcp_resources",
539 "read_mcp_resource",
540 "request_permissions",
541 "request_user_input",
542 "resume_agent",
543 "send_input",
544 "send_message",
545 "spawn_agent",
546 "tool_search",
547 "update_plan",
548 "view_image",
549 "wait_agent",
550 "web_search",
551 "write_stdin",
552 ],
553 HarnessId::OpenCode => &[
556 "bash",
557 "edit",
558 "glob",
559 "grep",
560 "list",
561 "lsp",
562 "plan_enter",
563 "plan_exit",
564 "question",
565 "read",
566 "skill",
567 "task",
568 "todowrite",
569 "webfetch",
570 "websearch",
571 ],
572 HarnessId::Pi => &[
574 "bash",
575 "edit",
576 "find",
577 "grep",
578 "ls",
579 "powershell",
580 "read",
581 "write",
582 ],
583 HarnessId::Cursor => &[
585 "AskQuestion",
586 "EditNotebook",
587 "Glob",
588 "Grep",
589 "Read",
590 "Shell",
591 "Skill",
592 "StrReplace",
593 "SwitchMode",
594 "Task",
595 "TodoWrite",
596 "WebFetch",
597 "WebSearch",
598 "Write",
599 ],
600 }
601 }
602
603 #[test]
604 fn every_native_name_and_native_tool_is_a_real_tool_of_its_harness() {
605 for &harness in crate::harness::ALL {
606 let real = real_tools(harness);
607 for (capability, native) in table(harness) {
608 let lists: &[&[&str]] = match native {
609 Tools(names) => &[names],
610 ScopedTools {
611 unscoped, scoped, ..
612 } => &[unscoped, scoped],
613 Absent | NoSwitch(_) => &[],
614 };
615 for names in lists {
616 assert!(
617 !names.is_empty(),
618 "{harness:?} `{capability}` names no tool"
619 );
620 for name in *names {
621 assert!(
622 real.contains(name),
623 "{harness:?} `{capability}` maps to `{name}`, which is not in its real tool list"
624 );
625 }
626 }
627 assert!(
628 native.native_tools().iter().all(|name| real.contains(name)),
629 "{harness:?} `{capability}` has a native tool outside its real tool list"
630 );
631 }
632 }
633 }
634
635 #[test]
636 fn claude_scoped_file_rules_separate_permission_names_from_native_tools() {
637 for (capability, unscoped, scoped) in [
638 ("write", &["Write"][..], &["Edit"][..]),
639 (
640 "edit",
641 &["Edit", "Write", "NotebookEdit"][..],
642 &["Edit"][..],
643 ),
644 ("notebook", &["NotebookEdit"][..], &["Edit"][..]),
645 ("glob", &["Glob"][..], &["Read"][..]),
646 ("grep", &["Grep"][..], &["Read"][..]),
647 ] {
648 let Some(ScopedTools {
649 unscoped: actual_unscoped,
650 scoped: actual_scoped,
651 note,
652 }) = lookup(capability, HarnessId::Claude)
653 else {
654 panic!("Claude `{capability}` must distinguish bare and scoped rules");
655 };
656 assert_eq!(actual_unscoped, unscoped, "{capability} bare rule");
657 assert_eq!(actual_scoped, scoped, "{capability} scoped rule");
658 assert_eq!(
659 lookup(capability, HarnessId::Claude)
660 .expect("row")
661 .native_tools(),
662 unscoped,
663 "{capability} availability tools"
664 );
665 assert!(
666 !note.is_empty(),
667 "{capability} must explain the wider category"
668 );
669 }
670
671 assert_eq!(
672 lookup("web_fetch", HarnessId::Claude),
673 Some(Tools(&["WebFetch"]))
674 );
675 }
676
677 #[test]
678 fn every_row_is_a_canonical_capability_named_once() {
679 for &harness in crate::harness::ALL {
680 let mut seen = std::collections::HashSet::new();
681 for (capability, _) in table(harness) {
682 assert!(
683 crate::tool_names::is_canonical_capability(capability),
684 "{harness:?} has a row for `{capability}`, which is not a canonical capability"
685 );
686 assert!(
687 seen.insert(*capability),
688 "{harness:?} repeats `{capability}`"
689 );
690 }
691 }
692 }
693
694 #[test]
695 fn maintained_harnesses_answer_for_every_canonical_capability() {
696 for harness in [
699 HarnessId::Claude,
700 HarnessId::Codex,
701 HarnessId::OpenCode,
702 HarnessId::Pi,
703 ] {
704 for capability in crate::tool_names::canonical_capabilities() {
705 assert!(
706 lookup(capability, harness).is_some(),
707 "{harness:?} has no row for `{capability}`"
708 );
709 }
710 }
711 }
712}