use std::collections::BTreeMap;
use serde::Serialize;
use crate::HarnessId;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Native {
Tools(&'static [&'static str]),
ScopedTools {
unscoped: &'static [&'static str],
scoped: &'static [&'static str],
note: &'static str,
},
Absent,
NoSwitch(&'static str),
}
impl Native {
pub fn native_tools(self) -> &'static [&'static str] {
match self {
Self::Tools(names)
| Self::ScopedTools {
unscoped: names, ..
} => names,
Self::Absent | Self::NoSwitch(_) => &[],
}
}
}
use Native::{Absent, NoSwitch, ScopedTools, Tools};
type Table = &'static [(&'static str, Native)];
pub fn lookup(capability: &str, harness: HarnessId) -> Option<Native> {
table(harness)
.iter()
.find(|(name, _)| *name == capability)
.map(|(_, native)| *native)
}
#[derive(Debug, Clone, Serialize)]
pub struct Vocabulary {
pub capabilities: Vec<crate::tool_names::CapabilityEntry>,
pub natives: BTreeMap<&'static str, BTreeMap<&'static str, NativeEntry>>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum NativeEntry {
Tools {
tools: &'static [&'static str],
},
ScopedTools {
unscoped: &'static [&'static str],
scoped: &'static [&'static str],
note: &'static str,
},
Absent,
NoSwitch {
note: &'static str,
},
}
impl From<Native> for NativeEntry {
fn from(native: Native) -> Self {
match native {
Tools(tools) => Self::Tools { tools },
ScopedTools {
unscoped,
scoped,
note,
} => Self::ScopedTools {
unscoped,
scoped,
note,
},
Absent => Self::Absent,
NoSwitch(note) => Self::NoSwitch { note },
}
}
}
pub fn vocabulary() -> Vocabulary {
let rows = |table: Table| {
table
.iter()
.map(|(name, native)| (*name, NativeEntry::from(*native)))
.collect()
};
let mut natives: BTreeMap<_, _> = crate::harness::ALL
.iter()
.map(|host| (host.as_str(), rows(table(*host))))
.collect();
natives.insert("meridian-flow", rows(FLOW));
Vocabulary {
capabilities: crate::tool_names::capabilities(),
natives,
}
}
fn table(harness: HarnessId) -> Table {
match harness {
HarnessId::Claude => CLAUDE,
HarnessId::Codex => CODEX,
HarnessId::OpenCode => OPENCODE,
HarnessId::Pi => PI,
HarnessId::Cursor => CURSOR,
}
}
const CLAUDE: Table = &[
("bash", Tools(&["Bash"])),
("read", Tools(&["Read"])),
("ls", Absent),
("work", Absent),
(
"write",
ScopedTools {
unscoped: &["Write"],
scoped: &["Edit"],
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",
},
),
(
"edit",
ScopedTools {
unscoped: &["Edit", "Write", "NotebookEdit"],
scoped: &["Edit"],
note: "Claude uses Edit(path) to scope every built-in file-editing tool",
},
),
(
"notebook",
ScopedTools {
unscoped: &["NotebookEdit"],
scoped: &["Edit"],
note: "Claude scopes NotebookEdit through Edit(path), which also covers other built-in file edits at that path",
},
),
("agent", Tools(&["Agent", "SendMessage", "ListAgents"])),
("skill", Tools(&["Skill"])),
("workflow", Tools(&["Workflow"])),
(
"glob",
ScopedTools {
unscoped: &["Glob"],
scoped: &["Read"],
note: "Claude scopes Glob paths through Read(path), which also covers other built-in file readers at that path",
},
),
(
"grep",
ScopedTools {
unscoped: &["Grep"],
scoped: &["Read"],
note: "Claude scopes Grep paths through Read(path), which also covers other built-in file readers at that path",
},
),
("web", Tools(&["WebSearch", "WebFetch"])),
("web_search", Tools(&["WebSearch"])),
("web_fetch", Tools(&["WebFetch"])),
("ask_user", Tools(&["AskUserQuestion"])),
(
"task",
Tools(&[
"TaskCreate",
"TaskGet",
"TaskList",
"TaskOutput",
"TaskUpdate",
"TaskStop",
"TodoWrite",
]),
),
("cron", Tools(&["CronCreate", "CronDelete", "CronList"])),
("notifications", Tools(&["PushNotification"])),
("plan_mode", Tools(&["EnterPlanMode", "ExitPlanMode"])),
("worktree", Tools(&["EnterWorktree", "ExitWorktree"])),
("lsp", Tools(&["LSP"])),
("monitor", Tools(&["Monitor"])),
("send_user_file", Tools(&["SendUserFile"])),
("schedule_wakeup", Tools(&["ScheduleWakeup"])),
("remote_trigger", Tools(&["RemoteTrigger"])),
("tool_search", Tools(&["ToolSearch"])),
(
"image",
NoSwitch("claude views images through Read; no switch"),
),
];
const CODEX: Table = &[
("bash", Tools(&["exec_command"])),
("ls", Absent),
("work", Absent),
(
"read",
NoSwitch("codex reads files through its shell; no switch"),
),
("write", Tools(&["apply_patch"])),
("edit", Tools(&["apply_patch"])),
("notebook", Absent),
(
"agent",
Tools(&[
"spawn_agent",
"send_input",
"resume_agent",
"wait_agent",
"close_agent",
"send_message",
"followup_task",
"interrupt_agent",
"list_agents",
]),
),
(
"skill",
NoSwitch("codex loads skills by reading files; no switch"),
),
("workflow", Absent),
(
"glob",
NoSwitch("codex finds files through its shell; no switch"),
),
(
"grep",
NoSwitch("codex searches files through its shell; no switch"),
),
("web", Tools(&["web_search"])),
("web_search", Tools(&["web_search"])),
(
"web_fetch",
NoSwitch("codex opens pages through hosted web_search; no separate switch"),
),
("ask_user", Tools(&["request_user_input"])),
("task", Tools(&["update_plan"])),
("cron", Absent),
("notifications", Absent),
("plan_mode", Absent),
("worktree", Absent),
("lsp", Absent),
("monitor", Absent),
("send_user_file", Absent),
("schedule_wakeup", Absent),
("remote_trigger", Absent),
("tool_search", Tools(&["tool_search"])),
("image", Tools(&["view_image"])),
];
const OPENCODE: Table = &[
("bash", Tools(&["bash"])),
("ls", Tools(&["list"])),
("work", Absent),
("read", Tools(&["read"])),
("write", Tools(&["edit"])),
("edit", Tools(&["edit"])),
("notebook", Absent),
("agent", Tools(&["task"])),
("skill", Tools(&["skill"])),
("workflow", Absent),
("glob", Tools(&["glob"])),
("grep", Tools(&["grep"])),
("web", Tools(&["websearch", "webfetch"])),
("web_search", Tools(&["websearch"])),
("web_fetch", Tools(&["webfetch"])),
("ask_user", Tools(&["question"])),
("task", Tools(&["todowrite"])),
("plan_mode", Tools(&["plan_enter", "plan_exit"])),
("cron", Absent),
("notifications", Absent),
("worktree", Absent),
("lsp", Tools(&["lsp"])),
("monitor", Absent),
("send_user_file", Absent),
("schedule_wakeup", Absent),
("remote_trigger", Absent),
("tool_search", Absent),
(
"image",
NoSwitch("opencode views images through read; no switch"),
),
];
const PI: Table = &[
("bash", Tools(&["bash"])),
("ls", Tools(&["ls"])),
("work", Absent),
("read", Tools(&["read"])),
("write", Tools(&["write"])),
("edit", Tools(&["edit", "write"])),
("notebook", Absent),
("agent", Absent),
(
"skill",
NoSwitch("pi loads skills by reading files; no switch"),
),
("workflow", Absent),
("glob", Tools(&["find"])),
("grep", Tools(&["grep"])),
("web", Absent),
("web_search", Absent),
("web_fetch", Absent),
("ask_user", Absent),
("task", Absent),
("plan_mode", Absent),
("cron", Absent),
("notifications", Absent),
("worktree", Absent),
("lsp", Absent),
("monitor", Absent),
("send_user_file", Absent),
("schedule_wakeup", Absent),
("remote_trigger", Absent),
("tool_search", Absent),
("image", NoSwitch("pi views images through read; no switch")),
];
const CURSOR: Table = &[
("bash", Tools(&["Shell"])),
("ls", Absent),
("work", Absent),
("read", Tools(&["Read"])),
("write", Tools(&["Write"])),
("edit", Tools(&["StrReplace"])),
("notebook", Tools(&["EditNotebook"])),
("agent", Tools(&["Task"])),
("skill", Tools(&["Skill"])),
("glob", Tools(&["Glob"])),
("grep", Tools(&["Grep"])),
("web", Tools(&["WebSearch", "WebFetch"])),
("web_search", Tools(&["WebSearch"])),
("web_fetch", Tools(&["WebFetch"])),
("ask_user", Tools(&["AskQuestion"])),
("task", Tools(&["TodoWrite"])),
("plan_mode", Tools(&["SwitchMode"])),
];
const FLOW: Table = &[
("read", Tools(&["read"])),
("write", Tools(&["write"])),
("edit", Tools(&["write"])),
("grep", Tools(&["grep"])),
("glob", Tools(&["glob"])),
("ls", Tools(&["ls"])),
("skill", Tools(&["skill"])),
("ask_user", Tools(&["ask"])),
(
"agent",
Tools(&[
"spawn",
"thread_message",
"thread_report",
"thread_ls",
"thread_history",
]),
),
("work", Tools(&["work"])),
];
#[cfg(test)]
mod tests {
use super::*;
fn real_tools(harness: HarnessId) -> &'static [&'static str] {
match harness {
HarnessId::Claude => &[
"Agent",
"Artifact",
"AskUserQuestion",
"Bash",
"CronCreate",
"CronDelete",
"CronList",
"DesignSync",
"Edit",
"EndConversation",
"EnterPlanMode",
"EnterWorktree",
"ExitPlanMode",
"ExitWorktree",
"Glob",
"Grep",
"ListAgents",
"ListMcpResourcesTool",
"LSP",
"Monitor",
"NotebookEdit",
"PowerShell",
"PushNotification",
"Read",
"ReadMcpResourceTool",
"RemoteTrigger",
"ReportFindings",
"ScheduleWakeup",
"SendFeedback",
"SendMessage",
"SendUserFile",
"ShareOnboardingGuide",
"Skill",
"SubagentHandback",
"TaskCreate",
"TaskGet",
"TaskList",
"TaskOutput",
"TaskStop",
"TaskUpdate",
"TodoWrite",
"ToolSearch",
"WaitForMcpServers",
"WebFetch",
"WebSearch",
"Workflow",
"Write",
],
HarnessId::Codex => &[
"apply_patch",
"close_agent",
"exec_command",
"followup_task",
"image_generation",
"interrupt_agent",
"js_repl",
"list_agents",
"list_mcp_resource_templates",
"list_mcp_resources",
"read_mcp_resource",
"request_permissions",
"request_user_input",
"resume_agent",
"send_input",
"send_message",
"spawn_agent",
"tool_search",
"update_plan",
"view_image",
"wait_agent",
"web_search",
"write_stdin",
],
HarnessId::OpenCode => &[
"bash",
"edit",
"glob",
"grep",
"list",
"lsp",
"plan_enter",
"plan_exit",
"question",
"read",
"skill",
"task",
"todowrite",
"webfetch",
"websearch",
],
HarnessId::Pi => &[
"bash",
"edit",
"find",
"grep",
"ls",
"powershell",
"read",
"write",
],
HarnessId::Cursor => &[
"AskQuestion",
"EditNotebook",
"Glob",
"Grep",
"Read",
"Shell",
"Skill",
"StrReplace",
"SwitchMode",
"Task",
"TodoWrite",
"WebFetch",
"WebSearch",
"Write",
],
}
}
#[test]
fn every_native_name_and_native_tool_is_a_real_tool_of_its_harness() {
for &harness in crate::harness::ALL {
let real = real_tools(harness);
for (capability, native) in table(harness) {
let lists: &[&[&str]] = match native {
Tools(names) => &[names],
ScopedTools {
unscoped, scoped, ..
} => &[unscoped, scoped],
Absent | NoSwitch(_) => &[],
};
for names in lists {
assert!(
!names.is_empty(),
"{harness:?} `{capability}` names no tool"
);
for name in *names {
assert!(
real.contains(name),
"{harness:?} `{capability}` maps to `{name}`, which is not in its real tool list"
);
}
}
assert!(
native.native_tools().iter().all(|name| real.contains(name)),
"{harness:?} `{capability}` has a native tool outside its real tool list"
);
}
}
}
#[test]
fn claude_scoped_file_rules_separate_permission_names_from_native_tools() {
for (capability, unscoped, scoped) in [
("write", &["Write"][..], &["Edit"][..]),
(
"edit",
&["Edit", "Write", "NotebookEdit"][..],
&["Edit"][..],
),
("notebook", &["NotebookEdit"][..], &["Edit"][..]),
("glob", &["Glob"][..], &["Read"][..]),
("grep", &["Grep"][..], &["Read"][..]),
] {
let Some(ScopedTools {
unscoped: actual_unscoped,
scoped: actual_scoped,
note,
}) = lookup(capability, HarnessId::Claude)
else {
panic!("Claude `{capability}` must distinguish bare and scoped rules");
};
assert_eq!(actual_unscoped, unscoped, "{capability} bare rule");
assert_eq!(actual_scoped, scoped, "{capability} scoped rule");
assert_eq!(
lookup(capability, HarnessId::Claude)
.expect("row")
.native_tools(),
unscoped,
"{capability} availability tools"
);
assert!(
!note.is_empty(),
"{capability} must explain the wider category"
);
}
assert_eq!(
lookup("web_fetch", HarnessId::Claude),
Some(Tools(&["WebFetch"]))
);
}
#[test]
fn every_row_is_a_canonical_capability_named_once() {
for &harness in crate::harness::ALL {
let mut seen = std::collections::HashSet::new();
for (capability, _) in table(harness) {
assert!(
crate::tool_names::is_canonical_capability(capability),
"{harness:?} has a row for `{capability}`, which is not a canonical capability"
);
assert!(
seen.insert(*capability),
"{harness:?} repeats `{capability}`"
);
}
}
}
#[test]
fn maintained_harnesses_answer_for_every_canonical_capability() {
for harness in [
HarnessId::Claude,
HarnessId::Codex,
HarnessId::OpenCode,
HarnessId::Pi,
] {
for capability in crate::tool_names::canonical_capabilities() {
assert!(
lookup(capability, harness).is_some(),
"{harness:?} has no row for `{capability}`"
);
}
}
}
}