use crate::adapter::{Binding, Capability, Render};
use crate::hook::{self, Outcome};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
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>,
) -> 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 (rendered, dropped) = translate(&merge_hooks(sources, own, repo)?, binding, tools)?;
Ok(Document {
body: claude_settings(&rendered)?,
dropped,
})
}
Render::OpencodePlugin => {
opencode_plugin(&merge_hooks(sources, own, repo)?, binding, tools)
}
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"),
}
}
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))
}
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)
}
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 }))
}
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 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::Value = 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::Value>()
.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,
)
.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 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,
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&out.body).unwrap();
assert_eq!(v["hooks"]["Stop"][0]["hooks"][0]["command"], "cargo test");
}
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,
)
.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,
)
.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,
)
.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"
);
}
#[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,
)
.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());
}
}