use crate::adapter::{Binding, Capability, Render};
use crate::hook::{self, Outcome};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Default)]
pub struct Document {
pub body: String,
pub dropped: Vec<hook::Dropped>,
}
impl From<String> for Document {
fn from(body: String) -> Self {
Self {
body,
dropped: Vec::new(),
}
}
}
pub fn document(
cap: Capability,
binding: &Binding,
sources: &[PathBuf],
own: &crate::base::Own,
repo: &crate::settings::RepoPolicy,
tools: &BTreeMap<hook::Tool, String>,
resolves: &BTreeMap<String, bool>,
) -> Result<Document> {
match binding.render {
Render::McpJson | Render::CodexToml | Render::OpencodeJson => {
let mut servers = merge_servers(sources)?;
for name in repo.mcp_env.keys() {
if !servers.contains_key(name) {
anyhow::bail!(
"[mcp.{name}.env] overrides a server that is not in your \
catalogue — nothing would read it. `omh config mcp ls` \
lists what is there."
);
}
}
servers.retain(|name, _| !repo.disabled_servers.contains(name));
servers.retain(|name, _| repo.selection.allows(Capability::Mcp, name));
for (name, env) in &repo.mcp_env {
if let Some(server) = servers.get_mut(name) {
server.env.extend(env.clone());
}
}
Ok(mcp(binding.render, &servers)?.into())
}
Render::ClaudeSettings => {
let mut hooks = merge_hooks(sources, own, repo)?;
let mut dropped = suppressed_by_probe(&mut hooks, resolves);
let (rendered, unspellable) = translate(&hooks, binding, tools)?;
dropped.extend(unspellable);
Ok(Document {
body: claude_settings(&rendered)?,
dropped,
})
}
Render::OpencodePlugin => {
let mut hooks = merge_hooks(sources, own, repo)?;
let mut dropped = suppressed_by_probe(&mut hooks, resolves);
let mut doc = opencode_plugin(&hooks, binding, tools)?;
dropped.extend(std::mem::take(&mut doc.dropped));
doc.dropped = dropped;
Ok(doc)
}
Render::Dir | Render::Concat => {
anyhow::bail!(
"{cap}: `{:?}` is staged by the launcher, not rendered",
binding.render
)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Server {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
}
#[derive(Deserialize)]
struct CanonicalMcp {
#[serde(rename = "mcpServers", default)]
servers: BTreeMap<String, Server>,
}
pub fn parse_layers(files: &[PathBuf]) -> Result<BTreeMap<String, Server>> {
merge_servers(files)
}
fn merge_servers(files: &[PathBuf]) -> Result<BTreeMap<String, Server>> {
let mut out = BTreeMap::new();
for f in files {
let parsed: CanonicalMcp = read_json(f)?;
out.extend(parsed.servers);
}
Ok(out)
}
fn mcp(render: Render, servers: &BTreeMap<String, Server>) -> Result<String> {
match render {
Render::McpJson => pretty(serde_json::json!({ "mcpServers": servers })),
Render::OpencodeJson => {
let mcp: BTreeMap<_, _> = servers
.iter()
.map(|(name, s)| {
let mut command = vec![s.command.clone()];
command.extend(s.args.iter().cloned());
(
name.clone(),
serde_json::json!({
"type": "local",
"command": command,
"environment": s.env,
"enabled": true,
}),
)
})
.collect();
pretty(serde_json::json!({
"$schema": "https://opencode.ai/config.json",
"mcp": mcp
}))
}
Render::CodexToml => {
let mut out = String::new();
for (name, s) in servers {
out.push_str(&format!("[mcp_servers.{name}]\n"));
out.push_str(&format!("command = {}\n", toml_str(&s.command)));
let args: Vec<String> = s.args.iter().map(|a| toml_str(a)).collect();
out.push_str(&format!("args = [{}]\n", args.join(", ")));
if !s.env.is_empty() {
out.push_str(&format!("\n[mcp_servers.{name}.env]\n"));
for (k, v) in &s.env {
out.push_str(&format!("{k} = {}\n", toml_str(v)));
}
}
out.push('\n');
}
Ok(out)
}
_ => unreachable!("caller matched on MCP renders"),
}
}
pub fn parse(format: Render, raw: &str) -> Result<BTreeMap<String, Server>> {
match format {
Render::McpJson => {
let doc: serde_json::Value = serde_json::from_str(raw).context("parsing MCP JSON")?;
if doc.get("mcpServers").is_none() && doc.get("projects").is_some() {
anyhow::bail!(
"this config nests servers under `projects` — importing all of \
them would pull in servers from unrelated repos. Point --file \
at a project-scoped .mcp.json instead."
);
}
let doc: CanonicalMcp = serde_json::from_value(doc).context("reading mcpServers")?;
Ok(doc.servers)
}
Render::CodexToml => {
#[derive(Deserialize)]
struct Doc {
#[serde(default, rename = "mcp_servers")]
servers: BTreeMap<String, Server>,
}
let doc: Doc = toml::from_str(raw).context("parsing codex config.toml")?;
Ok(doc.servers)
}
Render::OpencodeJson => {
#[derive(Deserialize)]
struct Doc {
#[serde(default)]
mcp: BTreeMap<String, Entry>,
}
#[derive(Deserialize)]
struct Entry {
#[serde(default)]
command: Vec<String>,
#[serde(default)]
environment: BTreeMap<String, String>,
}
let doc: Doc = serde_json::from_str(raw).context("parsing opencode.json")?;
doc.mcp
.into_iter()
.map(|(name, e)| {
let (command, args) = e
.command
.split_first()
.with_context(|| format!("server `{name}` has an empty command"))?;
Ok((
name,
Server {
command: command.clone(),
args: args.to_vec(),
env: e.environment,
},
))
})
.collect()
}
other => anyhow::bail!("`{other:?}` is not an MCP format and cannot be imported"),
}
}
pub fn declared_stacks(dirs: &[PathBuf]) -> Result<BTreeMap<String, Option<String>>> {
let mut out = BTreeMap::new();
for dir in dirs {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())),
};
for entry in entries {
let path = entry
.with_context(|| format!("reading {}", dir.display()))?
.path();
if !path.extension().is_some_and(|e| e == "json") {
continue;
}
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let stack = std::fs::read_to_string(&path)
.ok()
.and_then(|raw| hook::Hook::parse(&raw, &path.display().to_string()).ok())
.and_then(|h| h.stack);
out.insert(name, stack);
}
}
Ok(out)
}
pub fn held_back(
dirs: &[PathBuf],
own: &crate::base::Own,
repo: &crate::settings::RepoPolicy,
resolves: &BTreeMap<String, bool>,
) -> Result<Vec<hook::Dropped>> {
let mut hooks = merge_hooks(dirs, own, repo)?;
Ok(suppressed_by_probe(&mut hooks, resolves))
}
fn suppressed_by_probe(
hooks: &mut BTreeMap<String, hook::Hook>,
resolves: &BTreeMap<String, bool>,
) -> Vec<hook::Dropped> {
let mut dropped = Vec::new();
hooks.retain(|name, hook| {
let blocked = hook.runs().into_iter().find_map(|cmd| {
let p = crate::detect::program(cmd)?;
(resolves.get(p) == Some(&false)).then(|| p.to_string())
});
match blocked {
Some(program) => {
dropped.push(hook::Dropped {
name: name.clone(),
wanted: format!("`{program}` — not installed in this repo's sandbox"),
});
false
}
None => true,
}
});
dropped
}
fn translate(
hooks: &BTreeMap<String, hook::Hook>,
binding: &Binding,
tools: &BTreeMap<hook::Tool, String>,
) -> Result<(BTreeMap<String, hook::Rendered>, Vec<hook::Dropped>)> {
let mut rendered = BTreeMap::new();
let mut dropped = Vec::new();
for (name, h) in hooks {
match hook::render(name, h, binding, tools)? {
Outcome::Rendered(r) => {
rendered.insert(name.clone(), r);
}
Outcome::Dropped(d) => dropped.push(d),
}
}
Ok((rendered, dropped))
}
pub fn merge_hooks(
dirs: &[PathBuf],
own: &crate::base::Own,
repo: &crate::settings::RepoPolicy,
) -> Result<BTreeMap<String, hook::Hook>> {
let mut out = BTreeMap::new();
for dir in dirs {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
Err(e) => return Err(e).with_context(|| format!("reading {}", dir.display())),
};
for entry in entries {
let path = entry
.with_context(|| format!("reading {}", dir.display()))?
.path();
if path.extension().is_some_and(|e| e == "json") {
let name = path
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
if own.reserved.contains(&name) {
anyhow::bail!(
"{}: `{name}` is a name omh ships, so this file answers to \
nothing — it is not read, and it does not override omh's. \
Rename it, or switch the feature off with `[omh]` in \
.omh/settings.toml if what you want is omh's gone.",
path.display()
);
}
if !repo.selection.allows(Capability::Hooks, &name) {
continue;
}
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
out.insert(name, hook::Hook::parse(&raw, &path.display().to_string())?);
}
}
}
for own_hook in &own.hooks {
out.insert(own_hook.name.to_string(), own_hook.hook.clone());
}
Ok(out)
}
pub fn hook_programs(
dirs: &[PathBuf],
own: &crate::base::Own,
repo: &crate::settings::RepoPolicy,
) -> Result<BTreeSet<String>> {
Ok(merge_hooks(dirs, own, repo)?
.values()
.flat_map(hook::Hook::runs)
.filter_map(crate::detect::program)
.map(str::to_string)
.collect())
}
const BEFORE_TOOL: &str = "tool.execute.before";
const AFTER_TOOL: &str = "tool.execute.after";
const BUS: &str = "event";
#[derive(Clone, Copy)]
enum Slot<'a> {
Call { hook: &'a str, args: &'static str },
Bus { ty: &'a str },
}
impl<'a> Slot<'a> {
fn of(event: &'a str) -> Self {
match event {
BEFORE_TOOL => Slot::Call {
hook: BEFORE_TOOL,
args: "output",
},
AFTER_TOOL => Slot::Call {
hook: AFTER_TOOL,
args: "input",
},
ty => Slot::Bus { ty },
}
}
fn handler(&self) -> &'a str {
match self {
Slot::Call { hook, .. } => hook,
Slot::Bus { .. } => BUS,
}
}
fn args(&self) -> &'static str {
match self {
Slot::Call { .. } => "(input, output)",
Slot::Bus { .. } => "(input)",
}
}
}
fn opencode_plugin(
hooks: &BTreeMap<String, hook::Hook>,
binding: &Binding,
tools: &BTreeMap<hook::Tool, String>,
) -> Result<Document> {
let mut dropped = Vec::new();
let mut bodies: BTreeMap<&str, Vec<String>> = BTreeMap::new();
for (name, hook) in hooks {
let wired = match hook::wire(name, hook, binding, tools) {
Ok(wired) => wired,
Err(d) => {
dropped.push(d);
continue;
}
};
let give_up = |wanted: &str| hook::Dropped {
name: name.clone(),
wanted: wanted.to_string(),
};
let slot = Slot::of(wired.event);
if let Slot::Bus { .. } = slot {
let needs = match &hook.action {
_ if !wired.fields.is_empty() => Some("payload field"),
_ if !wired.tools.is_empty() => Some("way to narrow to a tool"),
hook::Action::Inject { .. } => Some("way to inject text"),
hook::Action::Refuse { .. } => Some("way to refuse a call"),
hook::Action::Run(_) => None,
};
if let Some(needs) = needs {
dropped.push(give_up(&format!("{needs} at `{}`", hook.on)));
continue;
}
}
if matches!(hook.action, hook::Action::Inject { .. })
&& matches!(
slot,
Slot::Call {
hook: BEFORE_TOOL,
..
}
)
{
dropped.push(give_up("way to inject text before a tool runs"));
continue;
}
let protocol = match binding.protocol(&hook.action) {
Ok(p) => p,
Err(wanted) => {
dropped.push(give_up(wanted));
continue;
}
};
bodies
.entry(slot.handler())
.or_default()
.push(one_hook(name, hook, &wired, slot, protocol));
}
let mut out = String::from(PLUGIN_PREAMBLE);
for (handler, blocks) in &bodies {
let args = Slot::of(handler).args();
out.push_str(&format!(" {handler:?}: async {args} => {{\n"));
for block in blocks {
out.push_str(block);
}
out.push_str(" },\n");
}
out.push_str("}))\n");
Ok(Document { body: out, dropped })
}
fn one_hook(
name: &str,
hook: &hook::Hook,
wired: &hook::Wired<'_>,
slot: Slot<'_>,
protocol: Option<&crate::adapter::Template>,
) -> String {
let mut b = format!(" // {name}\n await (async () => {{\n");
if let Slot::Bus { ty } = slot {
b.push_str(&format!(
" if (input?.event?.type !== {ty:?}) return\n"
));
}
if !wired.tools.is_empty() {
let names = wired
.tools
.iter()
.map(|t| format!("{t:?}"))
.collect::<Vec<_>>()
.join(", ");
b.push_str(&format!(
" if (![{names}].includes(input.tool)) return\n"
));
}
b.push_str(" const env = {}\n");
if let Slot::Call { args, .. } = slot {
for (field, at) in &wired.fields {
b.push_str(&format!(
" env[{:?}] = String({args}?.args?.{at} ?? \"\")\n",
field.var()
));
}
}
if let hook::Action::Inject {
capture: Some(capture),
..
} = &hook.action
{
b.push_str(&format!(
" const cap = sh({}, env)\n if (!cap.ran || cap.code !== 0) warn({}, \"capture\", cap)\n env[{:?}] = cap.out\n",
js(capture),
js(name),
hook::CAPTURE_VAR,
));
}
if let Some(when) = &hook.when {
b.push_str(&format!(
" const p = sh({}, env)\n if (!p.ran || p.err) warn({}, \"its `when`\", p)\n if (p.code !== 0) return\n",
js(when),
js(name),
));
}
match &hook.action {
hook::Action::Run(run) => b.push_str(&format!(
" const r = sh({}, env)\n if (!r.ran || r.code !== 0) warn({}, \"its `run`\", r)\n",
js(run),
js(name),
)),
hook::Action::Inject { text, .. } | hook::Action::Refuse { text } => {
let template = protocol.map(|p| p.template.as_str()).unwrap_or_default();
b.push_str(&format!(
" {}\n",
template
.replace(
"{{text}}",
&format!(
"t({}, {}, {}, env)",
js(name),
js(text),
js(&hook::interpolating(text))
),
)
.replace("{{event}}", wired.event)
));
}
}
b.push_str(" })()\n");
b
}
fn js(s: &str) -> String {
serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into())
}
const PLUGIN_PREAMBLE: &str = r#"// Generated by omh. Edits are overwritten at launch.
import { spawnSync } from "node:child_process"
// `ran` is false when the shell could not start or was killed by a signal —
// `spawnSync` reports both as `status: null`, which is indistinguishable from
// the `1` a predicate returns when it deliberately declines. Collapsing them
// meant a guard that could not be evaluated let the call through in silence.
const sh = (script, env) => {
const r = spawnSync("sh", ["-c", script], {
env: { ...process.env, ...env },
encoding: "utf8",
})
return {
ran: r.status !== null && r.status !== undefined,
code: r.status ?? 1,
out: (r.stdout ?? "").trim(),
err: (r.stderr ?? "").trim() || String(r.error?.message ?? ""),
}
}
// A hook still degrades to a no-op rather than to an error — the base set's own
// rule — but it says so. Silence is what a predicate means; it is not what a
// broken predicate means.
//
// A predicate is warned about when it wrote to stderr, not merely when it
// returned non-zero: `case … esac` declining is the normal path and says
// nothing, while a missing binary, a syntax error or a permission problem all
// leave a message. Exit status alone cannot tell those apart — a deliberate
// `false` and a `command not found` are both just non-zero.
const warn = (hook, phase, r) =>
console.error(`omh: hook ${hook}: ${phase} did not run${r.err ? " — " + r.err : ""}`)
// A hook's text is written once, in omh's words, and may name the payload
// fields bound above it. Expanding it through the same shell keeps one meaning
// for `$OMH_TOOL_FILE` whether the harness takes configuration or code — but a
// blocked call with a blank reason is the worst of both states, so a failed
// expansion falls back to the text as written.
const t = (hook, raw, word, env) => {
const r = sh("printf '%s' " + word, env)
if (!r.ran || r.code !== 0) {
warn(hook, "expanding its text", r)
return raw
}
return r.out
}
export default (async () => ({
"#;
fn claude_settings(hooks: &BTreeMap<String, hook::Rendered>) -> Result<String> {
let mut by_event: BTreeMap<&str, Vec<serde_json::Value>> = BTreeMap::new();
for h in hooks.values() {
by_event
.entry(&h.event)
.or_default()
.push(serde_json::json!({
"matcher": h.matcher,
"hooks": [{ "type": "command", "command": h.command }],
}));
}
pretty(serde_json::json!({
"hooks": by_event,
"enableAllProjectMcpServers": true,
}))
}
pub fn parse_hooks(
raw: &str,
vocab: &hook::Vocabulary,
) -> Result<(BTreeMap<String, hook::Hook>, Vec<hook::Dropped>)> {
#[derive(Deserialize)]
struct Document {
#[serde(default)]
hooks: BTreeMap<String, Vec<Entry>>,
}
#[derive(Deserialize)]
struct Entry {
#[serde(default)]
matcher: String,
#[serde(default)]
hooks: Vec<serde_json::Value>,
}
let doc: Document = serde_json::from_str(raw).context("parsing this harness's hooks")?;
let mut out: BTreeMap<String, hook::Hook> = BTreeMap::new();
let mut residue = Vec::new();
let mut note = |what: &str, wanted: String| {
residue.push(hook::Dropped {
name: what.to_string(),
wanted,
})
};
for (theirs, entries) in &doc.hooks {
for (i, entry) in entries.iter().enumerate() {
let at = format!("{theirs}[{i}]");
let Some(on) = vocab.event(theirs) else {
note(&at, format!("`{theirs}` is a moment omh has no word for"));
continue;
};
let tools = match read_matcher(&entry.matcher, on, vocab) {
Ok(tools) => tools,
Err(why) => {
note(&at, why);
continue;
}
};
for handler in &entry.hooks {
match read_handler(handler) {
Ok(command) => {
let name = name_for(&out, on, &command);
out.insert(
name,
hook::Hook {
on,
stack: None,
tools: tools.clone(),
when: None,
action: hook::Action::Run(command),
},
);
}
Err(why) => note(&at, why),
}
}
}
}
Ok((out, residue))
}
fn read_matcher(
matcher: &str,
on: hook::Event,
vocab: &hook::Vocabulary,
) -> std::result::Result<Vec<hook::Tool>, String> {
if matcher.trim().is_empty() {
return Ok(Vec::new());
}
if on == hook::Event::SessionStart {
return Err(format!(
"`{matcher}` narrows a session start, which is not a tool — omh has \
no word for that axis"
));
}
let mut rest = matcher.trim();
let mut tools = Vec::new();
while !rest.is_empty() {
let matched = vocab
.spellings()
.filter(|(word, _)| {
rest.strip_prefix(*word)
.is_some_and(|after| after.is_empty() || after.starts_with('|'))
})
.max_by_key(|(word, _)| word.len());
let Some((word, tool)) = matched else {
return Err(format!(
"`{matcher}` is not a list of tools omh knows — it does not \
continue with one at `{rest}`"
));
};
tools.push(tool);
rest = rest[word.len()..].trim_start_matches('|');
}
Ok(tools)
}
fn read_handler(handler: &serde_json::Value) -> std::result::Result<String, String> {
let Some(object) = handler.as_object() else {
return Err("a handler that is not an object".to_string());
};
for key in object.keys() {
if key != "type" && key != "command" {
return Err(format!(
"a handler with `{key}`, which omh cannot express — importing \
the command without it would change what the hook does"
));
}
}
match object.get("type").and_then(serde_json::Value::as_str) {
Some("command") => {}
Some(other) => return Err(format!("a `{other}` handler, which is not a command")),
None => return Err("a handler with no `type`".to_string()),
}
match object.get("command").and_then(serde_json::Value::as_str) {
Some(c) if !c.trim().is_empty() => Ok(c.to_string()),
_ => Err("a command handler with no command".to_string()),
}
}
fn name_for(taken: &BTreeMap<String, hook::Hook>, on: hook::Event, command: &str) -> String {
let base = match crate::detect::program(command) {
Some(p) => format!("{on}-{}", p.trim_start_matches("./").replace('/', "-")),
None => format!("{on}-imported"),
};
if !taken.contains_key(&base) {
return base;
}
(2..)
.map(|n| format!("{base}-{n}"))
.find(|n| !taken.contains_key(n))
.expect("an unbounded range contains a free name")
}
fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<T> {
let raw =
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
serde_json::from_str(&raw).with_context(|| format!("parsing {}", path.display()))
}
fn pretty(v: serde_json::Value) -> Result<String> {
Ok(serde_json::to_string_pretty(&v)?)
}
fn toml_str(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn foreign() -> (Binding, BTreeMap<hook::Tool, String>) {
let binding: Binding = toml::from_str(
"path = \"x\"\nrender = \"claude-settings\"\n\n\
[events]\nturn-end = \"AfterTurn\"\nafter-tool = \"ToolFinished\"\n\
session-start = \"Boot\"\n",
)
.unwrap();
let tools = BTreeMap::from([
(hook::Tool::Edit, "Modify".to_string()),
(hook::Tool::Read, "Fetch".to_string()),
]);
(binding, tools)
}
#[test]
fn hooks_survive_a_round_trip_through_the_harness_document() {
let (binding, tools) = foreign();
let mine = hooks_named(&[
("tests", r#"{"on":"turn-end","run":"cargo test"}"#),
(
"fmt",
r#"{"on":"after-tool","tools":["edit"],"run":"cargo fmt"}"#,
),
]);
let (rendered, dropped) = translate(&mine, &binding, &tools).unwrap();
assert!(dropped.is_empty(), "the fixture must render: {dropped:?}");
let document = claude_settings(&rendered).unwrap();
let vocab = hook::Vocabulary::of(&binding, &tools).unwrap();
let (back, residue) = parse_hooks(&document, &vocab).unwrap();
assert!(residue.is_empty(), "nothing should be residue: {residue:?}");
let recovered: BTreeSet<(hook::Event, Vec<hook::Tool>, String)> = back
.values()
.map(|h| (h.on, h.tools.clone(), h.does().to_string()))
.collect();
assert_eq!(
recovered,
mine.values()
.map(|h| (h.on, h.tools.clone(), h.does().to_string()))
.collect(),
"a hook rendered into a harness's file must come back as itself"
);
}
#[test]
fn a_handler_omh_cannot_express_whole_is_not_imported_in_part() {
let (binding, tools) = foreign();
let vocab = hook::Vocabulary::of(&binding, &tools).unwrap();
for (why, handler) in [
(
"a permission gate omh would drop",
r#"{"type":"command","command":"fmt","if":"tool.name == 'Bash'"}"#,
),
(
"arguments that change what runs",
r#"{"type":"command","command":"fmt","args":["--check"]}"#,
),
(
"a type that is not a command",
r#"{"type":"builtin","command":"fmt"}"#,
),
(
"a key omh has never heard of",
r#"{"type":"command","command":"fmt","onFailure":"block"}"#,
),
] {
let doc =
format!(r#"{{"hooks":{{"AfterTurn":[{{"matcher":"","hooks":[{handler}]}}]}}}}"#);
let (imported, residue) = parse_hooks(&doc, &vocab).unwrap();
assert!(imported.is_empty(), "{why}: imported anyway: {imported:?}");
assert_eq!(residue.len(), 1, "{why}: not reported: {residue:?}");
}
}
#[test]
fn a_matcher_that_is_not_a_list_of_tools_is_residue() {
let (binding, tools) = foreign();
let vocab = hook::Vocabulary::of(&binding, &tools).unwrap();
for (why, event, matcher) in [
(
"a regex is wider than the tools it mentions",
"ToolFinished",
"Modify.*",
),
("a tool this harness never declared", "ToolFinished", "Bash"),
] {
let doc = format!(
r#"{{"hooks":{{"{event}":[{{"matcher":"{matcher}","hooks":[{{"type":"command","command":"x"}}]}}]}}}}"#
);
let (imported, residue) = parse_hooks(&doc, &vocab).unwrap();
assert!(imported.is_empty(), "{why}: {imported:?}");
assert_eq!(residue.len(), 1, "{why}: not reported: {residue:?}");
}
let doc = r#"{"hooks":{"AfterTurn":[{"matcher":"","hooks":[{"type":"command","command":"x"}]}]}}"#;
let (imported, residue) = parse_hooks(doc, &vocab).unwrap();
assert_eq!(imported.len(), 1, "got: {residue:?}");
assert!(imported.values().next().unwrap().tools.is_empty());
}
#[test]
fn a_tool_spelled_as_an_alternation_is_one_tool() {
let adapter = crate::adapter::Adapter::find(Path::new(ADAPTERS), "claude").unwrap();
let binding = adapter.supports(Capability::Hooks).unwrap();
let vocab = hook::Vocabulary::of(binding, &adapter.tools).unwrap();
let doc = |matcher: &str| {
format!(
r#"{{"hooks":{{"PostToolUse":[{{"matcher":"{matcher}","hooks":[{{"type":"command","command":"fmt"}}]}}]}}}}"#
)
};
let (imported, residue) = parse_hooks(&doc("Edit|Write|MultiEdit"), &vocab).unwrap();
assert_eq!(
imported.len(),
1,
"one tool, however it is spelled: {residue:?}"
);
assert_eq!(
imported.values().next().unwrap().tools,
vec![hook::Tool::Edit]
);
let (imported, residue) = parse_hooks(&doc("Edit|Write|MultiEdit|Read"), &vocab).unwrap();
assert_eq!(imported.len(), 1, "got: {residue:?}");
assert_eq!(
imported.values().next().unwrap().tools,
vec![hook::Tool::Edit, hook::Tool::Read]
);
let (imported, residue) = parse_hooks(&doc("Edit|Write"), &vocab).unwrap();
assert!(
imported.is_empty(),
"importing this as `edit` would widen a hook that deliberately \
excluded MultiEdit: {imported:?}"
);
assert_eq!(residue.len(), 1, "and it is reported: {residue:?}");
}
#[test]
fn a_session_start_matcher_is_never_read_as_a_tool() {
let (binding, _) = foreign();
let colliding = BTreeMap::from([(hook::Tool::Edit, "startup".to_string())]);
let vocab = hook::Vocabulary::of(&binding, &colliding).unwrap();
let doc = r#"{"hooks":{"Boot":[{"matcher":"startup","hooks":[{"type":"command","command":"x"}]}]}}"#;
let (imported, residue) = parse_hooks(doc, &vocab).unwrap();
assert!(
imported.is_empty(),
"a session-start matcher became a tool narrowing: {imported:?}"
);
assert_eq!(residue.len(), 1, "and is reported: {residue:?}");
}
#[test]
fn a_moment_omh_has_no_word_for_is_reported_not_fatal() {
let (binding, tools) = foreign();
let vocab = hook::Vocabulary::of(&binding, &tools).unwrap();
let doc = r#"{"hooks":{
"AfterTurn":[{"matcher":"","hooks":[{"type":"command","command":"keep me"}]}],
"PreCompact":[{"matcher":"","hooks":[{"type":"command","command":"drop me"}]}]}}"#;
let (imported, residue) = parse_hooks(doc, &vocab).unwrap();
assert_eq!(imported.len(), 1, "the rest still come across");
assert_eq!(residue.len(), 1);
assert!(
residue[0].wanted.contains("PreCompact"),
"and the residue names what it was: {residue:?}"
);
}
fn hooks_named(pairs: &[(&str, &str)]) -> BTreeMap<String, hook::Hook> {
pairs
.iter()
.map(|(name, body)| {
(
name.to_string(),
hook::Hook::parse(body, name).expect("fixture must be a valid hook"),
)
})
.collect()
}
#[test]
fn a_hook_needing_a_program_the_image_lacks_is_dropped_by_name() {
let mut hooks = hooks_named(&[
("rust-test", r#"{"on":"turn-end","run":"cargo test"}"#),
("greet", r#"{"on":"turn-end","run":"echo hi"}"#),
]);
let measured = BTreeMap::from([("cargo".to_string(), false)]);
let dropped = suppressed_by_probe(&mut hooks, &measured);
assert_eq!(dropped.len(), 1, "one hook is held back: {dropped:?}");
assert_eq!(dropped[0].name, "rust-test");
assert!(
dropped[0].wanted.contains("cargo"),
"and it names the program: {:?}",
dropped[0]
);
assert!(
!hooks.contains_key("rust-test"),
"a suppressed hook must not reach the harness"
);
assert!(
hooks.contains_key("greet"),
"and nothing else is disturbed: {:?}",
hooks.keys().collect::<Vec<_>>()
);
}
#[test]
fn only_a_measured_absence_suppresses_and_an_unmeasured_program_never_does() {
let present = BTreeMap::from([("cargo".to_string(), true)]);
let mut hooks = hooks_named(&[("rust-test", r#"{"on":"turn-end","run":"cargo test"}"#)]);
assert!(
suppressed_by_probe(&mut hooks, &present).is_empty(),
"the probe found it — that is the answer that keeps a hook working"
);
assert!(hooks.contains_key("rust-test"));
let mut hooks = hooks_named(&[("rust-test", r#"{"on":"turn-end","run":"cargo test"}"#)]);
assert!(
suppressed_by_probe(&mut hooks, &BTreeMap::new()).is_empty(),
"silence is cannot-tell, and cannot-tell is never a licence to act"
);
assert!(hooks.contains_key("rust-test"));
}
#[test]
fn a_hand_written_hook_obeys_the_same_answer() {
let mut hooks = hooks_named(&[
(
"mine",
r#"{"on":"after-tool","tools":["edit"],"run":"cargo clippy"}"#,
),
(
"ask",
r#"{"on":"turn-end","capture":"cargo metadata","inject":"$OMH_CAPTURE"}"#,
),
(
"refuse",
r#"{"on":"before-tool","tools":["edit"],"refuse":"no"}"#,
),
]);
let measured = BTreeMap::from([("cargo".to_string(), false)]);
let dropped = suppressed_by_probe(&mut hooks, &measured);
let names: BTreeSet<&str> = dropped.iter().map(|d| d.name.as_str()).collect();
assert_eq!(
names,
BTreeSet::from(["mine", "ask"]),
"both shell out to cargo: {dropped:?}"
);
assert!(
hooks.contains_key("refuse"),
"a refusal runs nothing and cannot be blocked by a missing program"
);
}
fn file(dir: &Path, name: &str, body: &str) -> PathBuf {
let p = dir.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
write!(std::fs::File::create(&p).unwrap(), "{body}").unwrap();
p
}
fn servers(json: &[(&str, &str)]) -> (tempfile::TempDir, Vec<PathBuf>) {
let dir = tempfile::tempdir().unwrap();
let paths = json
.iter()
.enumerate()
.map(|(i, (_, body))| file(dir.path(), &format!("l{i}.json"), body))
.collect();
(dir, paths)
}
const L1: &str = r#"{"mcpServers":{"a":{"command":"a-cmd","args":["--x"]}}}"#;
const L2: &str = r#"{"mcpServers":{"b":{"command":"b-cmd","env":{"K":"v"}}}}"#;
const L2_SHADOW: &str = r#"{"mcpServers":{"a":{"command":"overridden"}}}"#;
#[test]
fn mcp_merges_across_layers() {
let (_d, files) = servers(&[("", L1), ("", L2)]);
let merged = merge_servers(&files).unwrap();
assert_eq!(merged.len(), 2);
assert_eq!(merged["a"].command, "a-cmd");
assert_eq!(merged["b"].env["K"], "v");
}
#[test]
fn later_layers_shadow_earlier_ones() {
let (_d, files) = servers(&[("", L1), ("", L2_SHADOW)]);
let merged = merge_servers(&files).unwrap();
assert_eq!(
merged["a"].command, "overridden",
"layer 3 must win over layer 1"
);
}
#[test]
fn each_format_emits_its_harness_shape() {
let (_d, files) = servers(&[("", L1)]);
let m = merge_servers(&files).unwrap();
let claude = mcp(Render::McpJson, &m).unwrap();
let v: serde_json::Value = serde_json::from_str(&claude).unwrap();
assert_eq!(v["mcpServers"]["a"]["command"], "a-cmd");
let oc = mcp(Render::OpencodeJson, &m).unwrap();
let v: serde_json::Value = serde_json::from_str(&oc).unwrap();
assert_eq!(v["mcp"]["a"]["type"], "local");
assert_eq!(v["mcp"]["a"]["command"][0], "a-cmd");
assert_eq!(v["mcp"]["a"]["command"][1], "--x");
let codex = mcp(Render::CodexToml, &m).unwrap();
assert!(codex.contains("[mcp_servers.a]"), "got: {codex}");
assert!(codex.contains(r#"command = "a-cmd""#), "got: {codex}");
let reparsed: toml::Table = codex.parse().expect("codex output must be valid TOML");
assert_eq!(
reparsed["mcp_servers"]["a"]["args"][0].as_str(),
Some("--x")
);
}
#[test]
fn codex_toml_escapes_quotes() {
let mut m = BTreeMap::new();
m.insert(
"q".to_string(),
Server {
command: r#"say "hi""#.into(),
args: vec![],
env: BTreeMap::new(),
},
);
let out = mcp(Render::CodexToml, &m).unwrap();
out.parse::<toml::Table>()
.expect("must stay valid TOML when values contain quotes");
}
use crate::adapter::Adapter;
const ADAPTERS: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/adapters");
fn claude_hooks() -> crate::adapter::Adapter {
crate::adapter::Adapter::find(Path::new(ADAPTERS), "claude").unwrap()
}
fn hooks_binding(a: &crate::adapter::Adapter) -> &Binding {
a.supports(Capability::Hooks).expect("claude has hooks")
}
#[test]
fn hooks_group_by_event() {
let dir = tempfile::tempdir().unwrap();
file(dir.path(), "h/a.json", r#"{"on":"turn-end","run":"one"}"#);
file(dir.path(), "h/b.json", r#"{"on":"turn-end","run":"two"}"#);
file(
dir.path(),
"h/c.json",
r#"{"on":"after-tool","tools":["edit"],"run":"three"}"#,
);
let adapter = claude_hooks();
let out = document(
Capability::Hooks,
hooks_binding(&adapter),
&[dir.path().join("h")],
&Default::default(),
&Default::default(),
&adapter.tools,
&Default::default(),
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&out.body).unwrap();
assert_eq!(v["hooks"]["Stop"].as_array().unwrap().len(), 2);
assert_eq!(
v["hooks"]["PostToolUse"][0]["matcher"],
"Edit|Write|MultiEdit"
);
assert_eq!(v["hooks"]["PostToolUse"][0]["hooks"][0]["type"], "command");
}
#[test]
fn the_settings_document_approves_the_mcp_document_omh_mounts() {
let dir = tempfile::tempdir().unwrap();
file(
dir.path(),
"h/a.json",
r#"{"on":"turn-end","run":"cargo test"}"#,
);
let adapter = claude_hooks();
let out = document(
Capability::Hooks,
hooks_binding(&adapter),
&[dir.path().join("h")],
&Default::default(),
&Default::default(),
&adapter.tools,
&Default::default(),
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&out.body).unwrap();
assert_eq!(
v["enableAllProjectMcpServers"], true,
"the mounted document would be listed and never loaded: {}",
out.body
);
assert!(
v["hooks"]["Stop"].is_array(),
"and the hooks this file exists for still ship: {}",
out.body
);
}
#[test]
fn a_hook_written_in_omhs_words_reaches_the_harness() {
let dir = tempfile::tempdir().unwrap();
file(
dir.path(),
"h/rust-test.json",
r#"{"on":"turn-end","run":"cargo test"}"#,
);
let adapter = claude_hooks();
let out = document(
Capability::Hooks,
hooks_binding(&adapter),
&[dir.path().join("h")],
&Default::default(),
&Default::default(),
&adapter.tools,
&Default::default(),
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&out.body).unwrap();
assert_eq!(v["hooks"]["Stop"][0]["hooks"][0]["command"], "cargo test");
}
#[test]
fn what_init_reports_held_back_is_what_a_launch_holds_back() {
let dir = tempfile::tempdir().unwrap();
file(
dir.path(),
"h/rust-test.json",
r#"{"on":"turn-end","run":"cargo test"}"#,
);
file(
dir.path(),
"h/lint.json",
r#"{"on":"turn-end","run":"shellcheck ./x.sh"}"#,
);
file(
dir.path(),
"h/greet.json",
r#"{"on":"turn-end","run":"echo hi"}"#,
);
let dirs = [dir.path().join("h")];
let measured = BTreeMap::from([
("cargo".to_string(), false),
("shellcheck".to_string(), false),
("echo".to_string(), true),
]);
let reported: BTreeSet<String> =
held_back(&dirs, &Default::default(), &Default::default(), &measured)
.unwrap()
.into_iter()
.map(|d| d.name)
.collect();
let adapter = claude_hooks();
let shipped: BTreeSet<String> = document(
Capability::Hooks,
hooks_binding(&adapter),
&dirs,
&Default::default(),
&Default::default(),
&adapter.tools,
&measured,
)
.unwrap()
.dropped
.into_iter()
.map(|d| d.name)
.collect();
assert_eq!(reported, shipped, "the report and the session disagree");
assert_eq!(
reported,
BTreeSet::from(["rust-test".to_string(), "lint".to_string()]),
"and both must be the measured absences, not everything or nothing"
);
}
#[test]
fn a_measured_absence_holds_on_every_render_path() {
let dir = tempfile::tempdir().unwrap();
file(
dir.path(),
"h/rust-test.json",
r#"{"on":"turn-end","run":"cargo test"}"#,
);
file(
dir.path(),
"h/greet.json",
r#"{"on":"turn-end","run":"echo hi"}"#,
);
let measured = BTreeMap::from([("cargo".to_string(), false)]);
let claude = claude_hooks();
let paths: [(&Binding, &BTreeMap<hook::Tool, String>); 2] = [
(hooks_binding(&claude), &claude.tools),
(opencode_hooks(), &opencode().tools),
];
for (binding, tools) in paths {
let out = document(
Capability::Hooks,
binding,
&[dir.path().join("h")],
&Default::default(),
&Default::default(),
tools,
&measured,
)
.unwrap();
assert!(
!out.body.contains("cargo test"),
"{:?}: a hook the sandbox cannot run reached the harness:\n{}",
binding.render,
out.body
);
assert!(
out.dropped.iter().any(|d| d.name == "rust-test"),
"{:?}: and it must be named, never silently absent: {:?}",
binding.render,
out.dropped
);
assert!(
out.body.contains("echo hi"),
"{:?}: nothing else is disturbed:\n{}",
binding.render,
out.body
);
}
}
fn opencode() -> &'static Adapter {
static CELL: std::sync::OnceLock<Adapter> = std::sync::OnceLock::new();
CELL.get_or_init(|| Adapter::find(Path::new(ADAPTERS), "opencode").unwrap())
}
fn opencode_hooks() -> &'static Binding {
opencode()
.supports(Capability::Hooks)
.expect("opencode has hooks")
}
fn plugin(hooks: &[(&str, &str)]) -> Document {
let dir = tempfile::tempdir().unwrap();
for (name, body) in hooks {
file(dir.path(), &format!("h/{name}.json"), body);
}
document(
Capability::Hooks,
opencode_hooks(),
&[dir.path().join("h")],
&Default::default(),
&Default::default(),
&opencode().tools,
&Default::default(),
)
.unwrap()
}
#[test]
#[ignore]
fn a_plugin_is_a_module_opencode_can_load() {
let body = plugin(&[
("fmt", r#"{"on":"turn-end","run":"cargo fmt"}"#),
(
"awkward",
r#"{"on":"before-tool","tools":["shell"],"when":"awk '{print $1}' </dev/null; case \"$OMH_TOOL_COMMAND\" in *\"}\"*) ;; *) false ;; esac","refuse":"no \" quote, back\\slash, `tick`, 100%"}"#,
),
])
.body;
assert!(
body.contains("export default"),
"opencode imports the default export: {body}"
);
let dir = tempfile::tempdir().unwrap();
let module = dir.path().join("omh.mjs");
std::fs::write(&module, &body).unwrap();
let out = std::process::Command::new("node")
.args(["--check", module.to_str().unwrap()])
.output()
.expect("node is required to check the program omh generates");
assert!(
out.status.success(),
"the generated module does not parse:\n{}\n{body}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn a_before_tool_refusal_becomes_a_throw() {
let body = plugin(&[(
"git-unavailable",
r#"{"on":"before-tool","tools":["shell"],"refuse":"git does not work here"}"#,
)])
.body;
assert!(body.contains("tool.execute.before"), "got: {body}");
assert!(body.contains("throw new Error"), "got: {body}");
assert!(body.contains("git does not work here"), "got: {body}");
}
#[test]
#[ignore]
fn two_hooks_on_one_moment_do_not_cancel_each_other() {
let body = plugin(&[
(
"a-read",
r#"{"on":"before-tool","tools":["read"],"refuse":"read refused"}"#,
),
(
"b-shell",
r#"{"on":"before-tool","tools":["shell"],"refuse":"shell refused"}"#,
),
])
.body;
let dir = tempfile::tempdir().unwrap();
let module = dir.path().join("omh.mjs");
std::fs::write(&module, &body).unwrap();
let driver = dir.path().join("run.mjs");
std::fs::write(
&driver,
format!(
r#"import plugin from "file://{}"
const hooks = await plugin({{}})
try {{
await hooks["tool.execute.before"]({{ tool: "bash" }}, {{ args: {{ command: "git status" }} }})
console.log("NOTHING")
}} catch (e) {{ console.log(e.message) }}
"#,
module.display()
),
)
.unwrap();
let out = std::process::Command::new("node")
.arg(&driver)
.output()
.expect("node is required to check the program omh generates");
let said = String::from_utf8_lossy(&out.stdout);
assert!(
said.contains("shell refused"),
"the second hook has to run: {said}{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
#[ignore]
fn show_the_plugin() {
let doc = plugin(&[
(
"git-unavailable",
r#"{"on":"before-tool","tools":["shell"],"when":"case \"$OMH_TOOL_COMMAND\" in git*) ;; *) false ;; esac","refuse":"git does not work here"}"#,
),
(
"graph-refresh",
r#"{"on":"turn-end","run":"index --repo /work || true"}"#,
),
(
"note",
r#"{"on":"after-tool","tools":["read"],"inject":"about $OMH_TOOL_FILE"}"#,
),
]);
println!("{}", doc.body);
println!(
"dropped: {:?}",
doc.dropped
.iter()
.map(|d| d.to_string())
.collect::<Vec<_>>()
);
}
fn drive(body: &str, slot: &str, input: &str, output: &str) -> String {
let dir = tempfile::tempdir().unwrap();
let module = dir.path().join("omh.mjs");
std::fs::write(&module, body).unwrap();
let driver = dir.path().join("run.mjs");
std::fs::write(
&driver,
format!(
r#"import plugin from "file://{}"
const hooks = await plugin({{}})
const input = {input}, output = {output}
try {{
await hooks[{slot:?}]?.(input, output)
console.log(JSON.stringify(output))
}} catch (e) {{ console.log("THREW: " + e.message) }}
"#,
module.display()
),
)
.unwrap();
let out = std::process::Command::new("node")
.arg(&driver)
.output()
.expect("node is required: a probe that skips is a probe that passes");
assert!(
out.status.success(),
"node failed: {}",
String::from_utf8_lossy(&out.stderr)
);
format!(
"{}{}",
String::from_utf8_lossy(&out.stdout).trim(),
String::from_utf8_lossy(&out.stderr).trim()
)
}
#[test]
#[ignore]
fn a_hook_that_could_not_run_says_so() {
let doc = plugin(&[
(
"broken-predicate",
r#"{"on":"before-tool","tools":["shell"],"when":"omh-no-such-binary","refuse":"blocked"}"#,
),
(
"broken-run",
r#"{"on":"before-tool","tools":["shell"],"run":"omh-no-such-binary"}"#,
),
]);
let said = drive(
&doc.body,
"tool.execute.before",
r#"{ tool: "bash", sessionID: "s", callID: "c" }"#,
r#"{ args: { command: "ls" } }"#,
);
assert!(
!said.contains("THREW"),
"a hook that cannot run degrades to a no-op: {said}"
);
for name in ["broken-predicate", "broken-run"] {
assert!(said.contains(name), "{name} failed in silence: {said}");
}
}
#[test]
#[ignore]
fn a_refusal_always_carries_a_reason() {
let doc = plugin(&[(
"git-unavailable",
r#"{"on":"before-tool","tools":["shell"],"refuse":"git does not work here"}"#,
)]);
let broken = doc
.body
.replace(r#"spawnSync("sh""#, r#"spawnSync("omh-no-such-shell""#);
let said = drive(
&broken,
"tool.execute.before",
r#"{ tool: "bash", sessionID: "s", callID: "c" }"#,
r#"{ args: { command: "git status" } }"#,
);
assert!(
said.contains("git does not work here"),
"the reason has to survive a failed expansion: {said}"
);
}
#[test]
#[ignore]
fn an_after_tool_hook_reads_the_arguments_where_this_moment_keeps_them() {
let body = plugin(&[(
"note",
r#"{"on":"after-tool","tools":["read"],"when":"[ -n \"$OMH_TOOL_FILE\" ]","inject":"about $OMH_TOOL_FILE"}"#,
)])
.body;
let said = drive(
&body,
"tool.execute.after",
r#"{ tool: "read", sessionID: "s", callID: "c", args: { filePath: "/work/note.txt" } }"#,
r#"{ title: "note.txt", output: "blue", metadata: {} }"#,
);
assert!(
said.contains("/work/note.txt"),
"the field has to reach the hook: {said}"
);
}
#[test]
#[ignore]
fn the_shipped_refusal_blocks_a_git_call() {
let doc = plugin(&[(
"git-unavailable",
r#"{"on":"before-tool","tools":["shell"],"when":"case \"$OMH_TOOL_COMMAND\" in git*) ;; *) false ;; esac","refuse":"git does not work here"}"#,
)]);
assert!(doc.dropped.is_empty(), "{:?}", doc.dropped);
let blocked = drive(
&doc.body,
"tool.execute.before",
r#"{ tool: "bash", sessionID: "s", callID: "c" }"#,
r#"{ args: { command: "git status" } }"#,
);
assert_eq!(
blocked, "THREW: git does not work here",
"the call has to be blocked, with the reason"
);
let allowed = drive(
&doc.body,
"tool.execute.before",
r#"{ tool: "bash", sessionID: "s", callID: "c" }"#,
r#"{ args: { command: "ls" } }"#,
);
assert!(
!allowed.contains("THREW"),
"a nudge is not a wall: {allowed}"
);
}
#[test]
#[ignore]
fn a_shipped_inject_appends_to_the_result_rather_than_replacing_it() {
let doc = plugin(&[(
"note",
r#"{"on":"after-tool","tools":["read"],"inject":"consider the graph"}"#,
)]);
let said = drive(
&doc.body,
"tool.execute.after",
r#"{ tool: "read", sessionID: "s", callID: "c", args: { filePath: "/work/f" } }"#,
r#"{ title: "f", output: "the original bytes", metadata: {} }"#,
);
assert!(said.contains("the original bytes"), "kept: {said}");
assert!(said.contains("consider the graph"), "and added: {said}");
}
#[test]
fn a_hook_needing_a_call_is_dropped_at_a_moment_that_has_none() {
for (name, body) in [
(
"reads-a-field",
r#"{"on":"turn-end","when":"[ -n \"$OMH_TOOL_FILE\" ]","run":"reindex"}"#,
),
(
"narrows-to-a-tool",
r#"{"on":"turn-end","tools":["shell"],"run":"x"}"#,
),
(
"injects",
r#"{"on":"turn-end","inject":"remember to test"}"#,
),
] {
let doc = plugin(&[(name, body)]);
let named: Vec<&str> = doc.dropped.iter().map(|d| d.name.as_str()).collect();
assert_eq!(named, vec![name], "{name} must be named, not emitted");
assert!(
!doc.body.contains("output"),
"{name} leaked an out-of-scope reference: {}",
doc.body
);
}
}
#[test]
#[ignore]
fn a_bus_moment_still_runs_a_command() {
let body = plugin(&[("refresh", r#"{"on":"turn-end","run":"true"}"#)]).body;
let said = drive(
&body,
"event",
r#"{ event: { type: "session.idle" } }"#,
"undefined",
);
assert!(!said.contains("THREW"), "got: {said}");
}
#[test]
fn a_before_tool_inject_is_dropped_by_name() {
let doc = plugin(&[(
"graph-first",
r#"{"on":"before-tool","tools":["read"],"inject":"use the graph"}"#,
)]);
let names: Vec<&str> = doc.dropped.iter().map(|d| d.name.as_str()).collect();
assert_eq!(names, vec!["graph-first"]);
assert!(
doc.dropped[0].wanted.contains("inject"),
"say what it wanted: {}",
doc.dropped[0].wanted
);
assert!(
!doc.body.contains("use the graph"),
"and it must not have leaked in as a throw: {}",
doc.body
);
}
#[test]
fn an_after_tool_inject_mutates_the_tool_result() {
let body = plugin(&[(
"note",
r#"{"on":"after-tool","tools":["read"],"inject":"and consider the graph"}"#,
)])
.body;
assert!(body.contains("tool.execute.after"), "got: {body}");
assert!(body.contains("output.output"), "got: {body}");
assert!(body.contains("and consider the graph"), "got: {body}");
}
#[test]
fn a_tool_scoped_hook_tests_the_harnesss_own_tool_name() {
let body = plugin(&[(
"git-unavailable",
r#"{"on":"before-tool","tools":["shell"],"refuse":"no git"}"#,
)])
.body;
assert!(
body.contains(r#"["bash"].includes(input.tool)"#),
"opencode calls it bash, not shell: {body}"
);
}
#[test]
fn the_payload_field_is_read_where_this_moment_keeps_it() {
for (on, from) in [("before-tool", "output"), ("after-tool", "input")] {
let body = plugin(&[(
"big",
&format!(
r#"{{"on":"{on}","tools":["read"],"when":"[ -f \"$OMH_TOOL_FILE\" ]","run":"x"}}"#
),
)])
.body;
assert!(
body.contains(&format!("{from}?.args?.filePath")),
"{on} keeps its arguments on `{from}`: {body}"
);
assert!(
!body.contains("jq"),
"jq is Claude's payload, not this one: {body}"
);
}
}
#[test]
fn a_repo_overrides_a_servers_env_without_redeclaring_it() {
let dir = tempfile::tempdir().unwrap();
let mcp = file(
dir.path(),
"mcp.json",
r#"{"mcpServers":{"linear":{"command":"npx","args":["-y","mcp-remote"],
"env":{"LINEAR_API_KEY":"","REGION":"eu"}}}}"#,
);
let repo = crate::settings::RepoPolicy {
mcp_env: BTreeMap::from([(
"linear".to_string(),
BTreeMap::from([("LINEAR_API_KEY".to_string(), "secret".to_string())]),
)]),
..Default::default()
};
let adapter = claude_hooks();
let out = document(
Capability::Mcp,
adapter.supports(Capability::Mcp).unwrap(),
&[mcp],
&Default::default(),
&repo,
&adapter.tools,
&Default::default(),
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&out.body).unwrap();
let server = &v["mcpServers"]["linear"];
assert_eq!(server["env"]["LINEAR_API_KEY"], "secret");
assert_eq!(
server["env"]["REGION"], "eu",
"an override, not a replacement"
);
assert_eq!(server["command"], "npx", "the server itself is untouched");
}
#[test]
fn an_override_for_a_server_that_is_not_installed_is_an_error() {
let dir = tempfile::tempdir().unwrap();
let mcp = file(dir.path(), "mcp.json", r#"{"mcpServers":{}}"#);
let repo = crate::settings::RepoPolicy {
mcp_env: BTreeMap::from([("linear".to_string(), BTreeMap::new())]),
..Default::default()
};
let adapter = claude_hooks();
let err = document(
Capability::Mcp,
adapter.supports(Capability::Mcp).unwrap(),
&[mcp],
&Default::default(),
&repo,
&adapter.tools,
&Default::default(),
)
.unwrap_err();
assert!(format!("{err:#}").contains("linear"), "got: {err:#}");
}
#[test]
fn a_hook_answering_to_a_manifest_name_is_an_error_naming_both() {
let dir = tempfile::tempdir().unwrap();
file(
dir.path(),
"h/graph-refresh.json",
r#"{"on":"turn-end","run":"my own indexer"}"#,
);
let own = crate::base::Own {
reserved: ["graph-refresh".to_string()].into(),
..Default::default()
};
let err = merge_hooks(&[dir.path().join("h")], &own, &Default::default())
.expect_err("a manifest name is not something a file may claim");
let msg = format!("{err:#}");
assert!(msg.contains("graph-refresh.json"), "name the file: {msg}");
assert!(
msg.contains("codegraph") || msg.contains("omh"),
"and whose name it is: {msg}"
);
let mut repo = crate::settings::RepoPolicy::default();
repo.selection
.apply(
&BTreeMap::from([("hooks".to_string(), Vec::new())]),
Path::new("settings.toml"),
)
.unwrap();
assert!(
merge_hooks(&[dir.path().join("h")], &own, &repo).is_err(),
"an unselected hook file still may not claim a name omh ships"
);
}
#[test]
fn every_program_a_shipped_hook_would_run_is_asked_about() {
let dir = tempfile::tempdir().unwrap();
let hooks = dir.path().join("h");
file(
&hooks,
"lint.json",
r#"{"on":"turn-end","run":"shellcheck ./x.sh"}"#,
);
file(
&hooks,
"no.json",
r#"{"on":"before-tool","tools":["edit"],"refuse":"no"}"#,
);
file(
&hooks,
"clever.json",
r#"{"on":"turn-end","run":"$(which cargo) test"}"#,
);
let own = crate::base::Own {
hooks: vec![crate::base::Hook {
name: "graph-refresh",
hook: hook::Hook::parse(r#"{"on":"turn-end","run":"omh-graph index"}"#, "own")
.unwrap(),
}],
..Default::default()
};
let asked = hook_programs(std::slice::from_ref(&hooks), &own, &Default::default()).unwrap();
assert!(
asked.contains("shellcheck"),
"a hand-written hook's program is in no stack's `needs`: {asked:?}"
);
assert!(
asked.contains("omh-graph"),
"omh's own hooks run programs too: {asked:?}"
);
assert_eq!(
asked,
BTreeSet::from(["shellcheck".to_string(), "omh-graph".to_string()]),
"a refusal runs nothing, and a command omh cannot read is not a gap \
it may invent: {asked:?}"
);
}
#[cfg(unix)]
#[test]
fn an_unreadable_hooks_directory_is_an_error_not_an_empty_one() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
file(dir.path(), "h/a.json", r#"{"on":"turn-end","run":"one"}"#);
let hooks = dir.path().join("h");
std::fs::set_permissions(&hooks, std::fs::Permissions::from_mode(0o000)).unwrap();
let err = merge_hooks(
std::slice::from_ref(&hooks),
&Default::default(),
&Default::default(),
)
.expect_err("an unreadable layer must be reported, not skipped");
std::fs::set_permissions(&hooks, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(err.to_string().contains("h"), "must name the path: {err}");
}
#[test]
fn staged_renders_are_not_documents() {
let adapter = claude_hooks();
let skills = adapter.supports(Capability::Skills).unwrap();
let err = document(
Capability::Skills,
skills,
&[],
&Default::default(),
&Default::default(),
&adapter.tools,
&Default::default(),
)
.unwrap_err();
assert!(err.to_string().contains("staged by the launcher"));
}
#[test]
fn malformed_json_names_the_file() {
let dir = tempfile::tempdir().unwrap();
let bad = file(dir.path(), "broken.json", "{ not json");
let err = merge_servers(&[bad]).unwrap_err();
assert!(err.to_string().contains("broken.json"), "got: {err}");
}
fn canonical() -> BTreeMap<String, Server> {
let mut m = BTreeMap::new();
m.insert(
"plain".to_string(),
Server {
command: "plain-cmd".into(),
args: vec![],
env: BTreeMap::new(),
},
);
m.insert(
"rich".to_string(),
Server {
command: "rich-cmd".into(),
args: vec!["--root".into(), "/work".into()],
env: BTreeMap::from([("TOKEN".to_string(), "abc".to_string())]),
},
);
m
}
#[test]
fn every_mcp_format_round_trips() {
let original = canonical();
for format in [Render::McpJson, Render::CodexToml, Render::OpencodeJson] {
let rendered = mcp(format, &original).unwrap();
let back = parse(format, &rendered)
.unwrap_or_else(|e| panic!("{format:?} failed to parse its own output: {e:#}"));
assert_eq!(back, original, "{format:?} lost data on round-trip");
}
}
#[test]
fn opencode_command_array_splits_back_into_command_and_args() {
let raw = r#"{"mcp":{"g":{"type":"local","command":["cmd","--a","--b"]}}}"#;
let back = parse(Render::OpencodeJson, raw).unwrap();
assert_eq!(back["g"].command, "cmd");
assert_eq!(back["g"].args, ["--a", "--b"]);
}
#[test]
fn parses_a_hand_written_mcp_json() {
let raw = r#"{"mcpServers":{"g":{"command":"c","args":["x"],"env":{"K":"v"}}}}"#;
let back = parse(Render::McpJson, raw).unwrap();
assert_eq!(back["g"].args, ["x"]);
assert_eq!(back["g"].env["K"], "v");
}
#[test]
fn parses_a_hand_written_codex_toml() {
let raw =
"[mcp_servers.g]\ncommand = \"c\"\nargs = [\"x\"]\n\n[mcp_servers.g.env]\nK = \"v\"\n";
let back = parse(Render::CodexToml, raw).unwrap();
assert_eq!(back["g"].command, "c");
assert_eq!(back["g"].env["K"], "v");
}
#[test]
fn empty_config_parses_to_no_servers() {
assert!(parse(Render::McpJson, "{}").unwrap().is_empty());
assert!(parse(Render::CodexToml, "").unwrap().is_empty());
assert!(parse(Render::OpencodeJson, "{}").unwrap().is_empty());
}
#[test]
fn project_nested_claude_config_is_refused_with_guidance() {
let raw = r#"{"projects":{"/some/repo":{"mcpServers":{"g":{"command":"c"}}}}}"#;
let err = parse(Render::McpJson, raw).unwrap_err();
assert!(format!("{err:#}").contains("projects"), "got: {err:#}");
}
#[test]
fn malformed_input_is_an_error_not_an_empty_import() {
assert!(parse(Render::McpJson, "{ not json").is_err());
assert!(parse(Render::CodexToml, "[[[").is_err());
}
#[test]
fn non_mcp_formats_cannot_be_parsed_as_servers() {
assert!(parse(Render::Dir, "").is_err());
assert!(parse(Render::ClaudeSettings, "{}").is_err());
}
#[test]
fn two_hooks_that_would_share_a_name_are_both_imported() {
let (binding, tools) = foreign();
let vocab = hook::Vocabulary::of(&binding, &tools).unwrap();
let doc = "{\"hooks\":{\"AfterTurn\":[\
{\"matcher\":\"\",\"hooks\":[{\"type\":\"command\",\"command\":\"prettier a\"}]},\
{\"matcher\":\"\",\"hooks\":[{\"type\":\"command\",\"command\":\"prettier b\"}]}]}}";
let (imported, residue) = parse_hooks(doc, &vocab).unwrap();
assert_eq!(
imported.len(),
2,
"one was overwritten by the other: {imported:?} {residue:?}"
);
let commands: BTreeSet<&str> = imported.values().map(|h| h.does()).collect();
assert_eq!(commands.len(), 2, "and they are the two that were written");
}
#[test]
fn a_hook_named_after_a_path_is_still_one_filename() {
let (binding, tools) = foreign();
let vocab = hook::Vocabulary::of(&binding, &tools).unwrap();
let doc = "{\"hooks\":{\"AfterTurn\":[{\"matcher\":\"\",\"hooks\":[\
{\"type\":\"command\",\"command\":\"./bin/fmt --check\"}]}]}}";
let (imported, _) = parse_hooks(doc, &vocab).unwrap();
let name = imported.keys().next().expect("must import").clone();
assert!(
!name.contains('/') && !name.contains('.'),
"a hook name is a filename, not a path: {name}"
);
}
#[test]
fn a_handler_with_a_blank_command_is_residue() {
let (binding, tools) = foreign();
let vocab = hook::Vocabulary::of(&binding, &tools).unwrap();
let doc = "{\"hooks\":{\"AfterTurn\":[{\"matcher\":\"\",\"hooks\":[\
{\"type\":\"command\",\"command\":\" \"}]}]}}";
let (imported, residue) = parse_hooks(doc, &vocab).unwrap();
assert!(imported.is_empty(), "imported anyway: {imported:?}");
assert_eq!(residue.len(), 1, "and it is reported: {residue:?}");
}
#[test]
fn a_session_start_hook_with_no_matcher_is_imported() {
let (binding, tools) = foreign();
let vocab = hook::Vocabulary::of(&binding, &tools).unwrap();
let doc = "{\"hooks\":{\"Boot\":[{\"matcher\":\"\",\"hooks\":[\
{\"type\":\"command\",\"command\":\"echo hi\"}]}]}}";
let (imported, residue) = parse_hooks(doc, &vocab).unwrap();
assert_eq!(
imported.len(),
1,
"an unnarrowed session start: {residue:?}"
);
assert_eq!(
imported.values().next().unwrap().on,
hook::Event::SessionStart
);
}
#[test]
fn the_longest_spelling_wins_so_a_prefix_cannot_strand_the_rest() {
let (binding, _) = foreign();
let overlapping = BTreeMap::from([
(hook::Tool::Edit, "Edit".to_string()),
(hook::Tool::Read, "Edit|Write".to_string()),
]);
let vocab = hook::Vocabulary::of(&binding, &overlapping).unwrap();
let doc = "{\"hooks\":{\"ToolFinished\":[{\"matcher\":\"Edit|Write\",\"hooks\":[\
{\"type\":\"command\",\"command\":\"x\"}]}]}}";
let (imported, residue) = parse_hooks(doc, &vocab).unwrap();
assert_eq!(imported.len(), 1, "got: {residue:?}");
assert_eq!(
imported.values().next().unwrap().tools,
vec![hook::Tool::Read],
"`Edit|Write` is one spelling, not `Edit` followed by something"
);
}
#[test]
fn a_spelling_matches_a_whole_word_not_a_prefix() {
let (binding, tools) = foreign();
let vocab = hook::Vocabulary::of(&binding, &tools).unwrap();
let doc = "{\"hooks\":{\"ToolFinished\":[{\"matcher\":\"ModifyFetch\",\"hooks\":[\
{\"type\":\"command\",\"command\":\"x\"}]}]}}";
let (imported, residue) = parse_hooks(doc, &vocab).unwrap();
assert!(imported.is_empty(), "read as two tools: {imported:?}");
assert_eq!(residue.len(), 1, "and reported: {residue:?}");
}
fn hook_dirs(layers: &[&[(&str, &str)]]) -> (tempfile::TempDir, Vec<PathBuf>) {
let d = tempfile::tempdir().unwrap();
let mut dirs = Vec::new();
for (i, files) in layers.iter().enumerate() {
let dir = d.path().join(format!("layer{i}"));
std::fs::create_dir_all(&dir).unwrap();
for (name, body) in *files {
std::fs::write(dir.join(name), body).unwrap();
}
dirs.push(dir);
}
(d, dirs)
}
#[test]
fn a_repo_hook_shadowing_a_catalogue_name_decides_its_ecosystem() {
let (_d, dirs) = hook_dirs(&[
&[(
"test.json",
"{\"on\":\"turn-end\",\"stack\":\"rust\",\"run\":\"cargo test\"}",
)],
&[(
"test.json",
"{\"on\":\"turn-end\",\"stack\":\"node\",\"run\":\"npm run test\"}",
)],
]);
assert_eq!(
declared_stacks(&dirs).unwrap().get("test"),
Some(&Some("node".to_string())),
"the layer that decides what runs decides what it belongs to"
);
}
#[test]
fn a_hook_file_that_will_not_parse_is_still_offered() {
let (_d, dirs) = hook_dirs(&[&[
("broken.json", "{ this is not json"),
("mistyped.json", "{\"on\":\"whenever\",\"run\":\"x\"}"),
]]);
let got = declared_stacks(&dirs).unwrap();
assert_eq!(
got.get("broken"),
Some(&None),
"a file omh cannot read belongs everywhere: {got:?}"
);
assert_eq!(got.get("mistyped"), Some(&None), "got: {got:?}");
}
#[test]
fn a_replaced_files_backup_is_not_a_hook_name() {
let (_d, dirs) = hook_dirs(&[&[
(
"rust-test.json",
"{\"on\":\"turn-end\",\"stack\":\"rust\",\"run\":\"cargo test\"}",
),
(
"rust-test.json.yours",
"{\"on\":\"turn-end\",\"run\":\"cargo t\"}",
),
]]);
let got = declared_stacks(&dirs).unwrap();
assert_eq!(
got.keys().collect::<Vec<_>>(),
vec!["rust-test"],
"only a file omh reads back is a name: {got:?}"
);
}
}