use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Adapter {
pub name: String,
pub bin: String,
pub install: String,
#[serde(default)]
pub creds: Vec<String>,
#[serde(default)]
pub token: Vec<String>,
#[serde(default)]
pub login: Option<String>,
#[serde(default)]
pub tools: BTreeMap<crate::hook::Tool, String>,
#[serde(default)]
pub capabilities: BTreeMap<Capability, Binding>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Capability {
Rules,
Skills,
Mcp,
Commands,
Subagents,
Hooks,
}
impl Capability {
pub fn source(&self) -> &'static str {
match self {
Self::Rules => "rules",
Self::Skills => "skills",
Self::Mcp => "mcp.json",
Self::Commands => "commands",
Self::Subagents => "subagents",
Self::Hooks => "hooks",
}
}
pub const ALL: [Capability; 6] = [
Self::Rules,
Self::Skills,
Self::Mcp,
Self::Commands,
Self::Subagents,
Self::Hooks,
];
}
impl std::fmt::Display for Capability {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Rules => "rules",
Self::Skills => "skills",
Self::Mcp => "mcp",
Self::Commands => "commands",
Self::Subagents => "subagents",
Self::Hooks => "hooks",
})
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Binding {
pub path: String,
#[serde(default)]
pub also: Vec<String>,
pub render: Render,
#[serde(default)]
pub import: Option<String>,
#[serde(default)]
pub events: BTreeMap<crate::hook::Event, String>,
#[serde(default)]
pub fields: BTreeMap<crate::hook::Field, String>,
#[serde(default)]
pub inject: Option<Template>,
#[serde(default)]
pub refuse: Option<Template>,
}
impl Binding {
pub fn protocol(
&self,
action: &crate::hook::Action,
) -> std::result::Result<Option<&Template>, &'static str> {
match action {
crate::hook::Action::Run(_) => Ok(None),
crate::hook::Action::Inject { .. } => {
self.inject.as_ref().map(Some).ok_or("way to inject text")
}
crate::hook::Action::Refuse { .. } => {
self.refuse.as_ref().map(Some).ok_or("way to refuse a call")
}
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Template {
pub template: String,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum Render {
Dir,
Concat,
McpJson,
CodexToml,
OpencodeJson,
ClaudeSettings,
OpencodePlugin,
}
impl Adapter {
pub fn load(path: &Path) -> Result<Self> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("reading adapter {}", path.display()))?;
toml::from_str(&raw).with_context(|| format!("parsing adapter {}", path.display()))
}
pub fn load_dir(dir: &Path) -> Result<Vec<Self>> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for entry in entries {
let path = entry?.path();
if path.extension().is_some_and(|e| e == "toml") {
out.push(Self::load(&path)?);
}
}
out.sort_by(|a, b| a.name.cmp(&b.name));
Ok(out)
}
pub fn find(dir: &Path, name: &str) -> Result<Self> {
let path = dir.join(format!("{name}.toml"));
if !path.exists() {
let known: Vec<_> = Self::load_dir(dir)?.into_iter().map(|a| a.name).collect();
anyhow::bail!(
"unknown harness `{name}`\nknown: {}\nadd one by dropping {}",
if known.is_empty() {
"(none)".into()
} else {
known.join(", ")
},
path.display()
);
}
let adapter = Self::load(&path)?;
if adapter.capabilities.is_empty() {
anyhow::bail!(
"adapter {} declares no capabilities — it would launch a harness \
that can see none of your profile",
path.display()
);
}
adapter.check_hook_maps(&path)?;
Ok(adapter)
}
pub fn supports(&self, cap: Capability) -> Option<&Binding> {
self.capabilities.get(&cap)
}
fn check_hook_maps(&self, path: &Path) -> Result<()> {
let hooks = self.capabilities.contains_key(&Capability::Hooks);
if hooks && self.tools.is_empty() {
anyhow::bail!(
"adapter {}: `hooks` is declared with no `[tools]`, so every hook that \
names a tool would be dropped and reported as unsupported. Map at \
least one of edit, read, shell, search.",
path.display()
);
}
if !hooks && !self.tools.is_empty() {
anyhow::bail!(
"adapter {}: `[tools]` is read by nobody here — only `hooks` uses it, \
and this adapter declares none.",
path.display()
);
}
for (cap, binding) in &self.capabilities {
let declares = !binding.events.is_empty()
|| !binding.fields.is_empty()
|| binding.inject.is_some()
|| binding.refuse.is_some();
match cap {
Capability::Hooks if binding.events.is_empty() => anyhow::bail!(
"adapter {}: `hooks` declares no `events`, so it can express no \
moment and every hook would be dropped. Map at least one of \
session-start, turn-end, before-tool, after-tool — or omit the \
`hooks` capability, which is how a harness says it has none.",
path.display()
),
Capability::Hooks => {}
_ if declares => anyhow::bail!(
"adapter {}: `{cap}` declares hook maps (`events`, `fields` \
or `inject`), which are read only under `hooks`. Nothing \
would use them.",
path.display()
),
_ => {}
}
}
Ok(())
}
}
pub fn expand(template: &str, home: &str) -> PathBuf {
PathBuf::from(template.replace("$HOME", home))
}
pub fn expand_host(template: &str, home: &Path, repo: &Path) -> PathBuf {
PathBuf::from(
template
.replace("$HOME", &home.to_string_lossy())
.replace("$REPO", &repo.to_string_lossy()),
)
}
#[cfg(test)]
mod tests {
use super::*;
const REAL: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/adapters");
#[test]
fn each_action_asks_for_the_protocol_it_means() {
let advise_only: Binding = toml::from_str(
"path = \"/x\"\nrender = \"claude-settings\"\n[inject]\ntemplate = \"say {{text}}\"\n",
)
.unwrap();
let block_only: Binding = toml::from_str(
"path = \"/x\"\nrender = \"claude-settings\"\n[refuse]\ntemplate = \"deny {{text}}\"\n",
)
.unwrap();
let inject = crate::hook::Action::Inject {
capture: None,
text: "t".into(),
};
let refuse = crate::hook::Action::Refuse { text: "t".into() };
let run = crate::hook::Action::Run("x".into());
assert_eq!(
advise_only.protocol(&inject).unwrap().unwrap().template,
"say {{text}}"
);
assert_eq!(
advise_only.protocol(&refuse).err(),
Some("way to refuse a call"),
"a harness that can only advise must not block with the nudge protocol"
);
assert_eq!(
block_only.protocol(&refuse).unwrap().unwrap().template,
"deny {{text}}"
);
assert_eq!(
block_only.protocol(&inject).err(),
Some("way to inject text"),
"and must not promote a nudge to a wall"
);
assert!(advise_only.protocol(&run).unwrap().is_none());
}
#[test]
fn shipped_adapters_parse() {
let all = Adapter::load_dir(Path::new(REAL)).unwrap();
assert_eq!(
all.iter().map(|a| a.name.as_str()).collect::<Vec<_>>(),
["claude", "opencode"]
);
}
#[test]
fn capability_support_matches_reality() {
let claude = Adapter::find(Path::new(REAL), "claude").unwrap();
for cap in Capability::ALL {
assert!(
claude.supports(cap).is_some(),
"claude should support {cap}"
);
}
let oc = Adapter::find(Path::new(REAL), "opencode").unwrap();
for cap in Capability::ALL {
assert!(oc.supports(cap).is_some(), "opencode should support {cap}");
}
assert!(
oc.supports(Capability::Subagents).is_some(),
"opencode has agent files; dropping them costs a feature the user had"
);
}
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, body).unwrap();
p
}
#[test]
fn stale_flat_format_is_rejected_loudly() {
let d = tempfile::tempdir().unwrap();
write(
d.path(),
"old.toml",
r#"
name = "old"
bin = "old"
install = "x"
rules = { path = "/work/OLD.md" }
"#,
);
let err = Adapter::find(d.path(), "old").unwrap_err();
assert!(
format!("{err:#}").contains("rules"),
"must name the stray key: {err:#}"
);
}
#[test]
fn opencode_reads_where_its_documentation_says() {
let oc = Adapter::find(Path::new(REAL), "opencode").unwrap();
let path = |c: Capability| oc.supports(c).map(|b| b.path.as_str());
assert_eq!(
path(Capability::Skills),
Some("$HOME/.config/opencode/skills")
);
assert_eq!(
path(Capability::Commands),
Some("$HOME/.config/opencode/commands"),
"plural — omh spelled this `command` and opencode documents `commands`, \
so every custom command was mounted where nothing reads"
);
assert_eq!(
path(Capability::Subagents),
Some("$HOME/.config/opencode/agents")
);
}
#[test]
fn the_tool_vocabulary_is_declared_once_for_the_harness() {
let claude = Adapter::find(Path::new(REAL), "claude").unwrap();
assert_eq!(claude.tools[&crate::hook::Tool::Shell], "Bash");
assert_eq!(
claude.tools[&crate::hook::Tool::Edit],
"Edit|Write|MultiEdit"
);
}
#[test]
fn a_harness_with_hooks_must_spell_the_tools() {
let d = tempfile::tempdir().unwrap();
write(
d.path(),
"wordless.toml",
r#"
name = "wordless"
bin = "wordless"
install = "x"
[capabilities.hooks]
path = "$HOME/settings.json"
render = "claude-settings"
[capabilities.hooks.events]
before-tool = "PreToolUse"
"#,
);
let err = format!("{:#}", Adapter::find(d.path(), "wordless").unwrap_err());
assert!(err.contains("tools"), "must name what is missing: {err}");
}
#[test]
fn a_harness_without_hooks_has_no_use_for_a_tool_vocabulary() {
let d = tempfile::tempdir().unwrap();
write(
d.path(),
"odd.toml",
r#"
name = "odd"
bin = "odd"
install = "x"
[tools]
shell = "Bash"
[capabilities.rules]
path = "/work/AGENTS.md"
render = "concat"
"#,
);
let err = format!("{:#}", Adapter::find(d.path(), "odd").unwrap_err());
assert!(err.contains("tools"), "read by nobody, and said so: {err}");
}
#[test]
fn a_tool_map_inside_a_capability_is_refused() {
let d = tempfile::tempdir().unwrap();
write(
d.path(),
"old.toml",
r#"
name = "old"
bin = "old"
install = "x"
[capabilities.hooks]
path = "$HOME/settings.json"
render = "claude-settings"
[capabilities.hooks.events]
turn-end = "Stop"
[capabilities.hooks.tools]
shell = "Bash"
"#,
);
let err = format!("{:#}", Adapter::find(d.path(), "old").unwrap_err());
assert!(err.contains("tools"), "must name the key: {err}");
}
#[test]
fn hook_maps_only_appear_on_the_hooks_capability() {
let d = tempfile::tempdir().unwrap();
write(
d.path(),
"odd.toml",
r#"
name = "odd"
bin = "odd"
install = "x"
[capabilities.rules]
path = "/work/AGENTS.md"
render = "concat"
[capabilities.rules.events]
turn-end = "Stop"
"#,
);
let err = Adapter::find(d.path(), "odd").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("rules"), "must name the capability: {msg}");
assert!(msg.contains("hooks"), "and where it belongs: {msg}");
}
#[test]
fn every_hook_map_is_refused_outside_the_hooks_capability() {
for map in [
"[capabilities.rules.events]\nturn-end = \"Stop\"",
"[capabilities.rules.fields]\ntool-file = \".tool_input.file_path\"",
"[capabilities.rules.inject]\ntemplate = \"echo {{text}}\"",
] {
let d = tempfile::tempdir().unwrap();
write(
d.path(),
"odd.toml",
&format!(
"name = \"odd\"\nbin = \"odd\"\ninstall = \"x\"\n\
[capabilities.rules]\npath = \"/work/AGENTS.md\"\n\
render = \"concat\"\n{map}\n"
),
);
let err = format!("{:#}", Adapter::find(d.path(), "odd").unwrap_err());
assert!(err.contains("rules"), "{map} must be refused: {err}");
}
}
#[test]
fn a_hooks_binding_that_names_no_moment_is_refused() {
let d = tempfile::tempdir().unwrap();
write(
d.path(),
"mute.toml",
r#"
name = "mute"
bin = "mute"
install = "x"
[tools]
shell = "Bash"
[capabilities.hooks]
path = "$HOME/settings.json"
render = "claude-settings"
"#,
);
let err = Adapter::find(d.path(), "mute").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("events"), "must name what is missing: {msg}");
}
#[test]
fn zero_capability_adapter_is_rejected() {
let d = tempfile::tempdir().unwrap();
write(
d.path(),
"empty.toml",
r#"name="e"
bin="e"
install="x""#,
);
let err = Adapter::find(d.path(), "empty").unwrap_err();
assert!(err.to_string().contains("no capabilities"), "got: {err}");
}
#[test]
fn unknown_harness_lists_known_ones() {
let err = Adapter::find(Path::new(REAL), "nope").unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("claude") && msg.contains("opencode"),
"got: {msg}"
);
}
#[test]
fn every_adapter_with_credentials_says_how_to_log_in() {
for a in Adapter::load_dir(Path::new(REAL)).unwrap() {
if !a.creds.is_empty() {
assert!(a.login.is_some(), "{} does not say how to log in", a.name);
}
}
}
#[test]
fn expand_substitutes_guest_home() {
assert_eq!(
expand("$HOME/.claude/skills", "/home/agent"),
Path::new("/home/agent/.claude/skills")
);
assert_eq!(
expand("/work/CLAUDE.md", "/home/agent"),
Path::new("/work/CLAUDE.md")
);
}
#[test]
fn import_paths_expand_against_the_host_not_the_container() {
let home = Path::new("/Users/me");
let repo = Path::new("/Users/me/code/proj");
assert_eq!(
expand_host("$HOME/.config/opencode/opencode.json", home, repo),
Path::new("/Users/me/.config/opencode/opencode.json")
);
assert_eq!(
expand_host("$REPO/.mcp.json", home, repo),
repo.join(".mcp.json")
);
assert_eq!(
expand_host("/absolute/path", home, repo),
Path::new("/absolute/path")
);
}
#[test]
fn guest_and_host_expansion_disagree_on_purpose() {
let home = Path::new("/Users/me");
let repo = Path::new("/repo");
assert_ne!(
expand_host("$HOME/.claude", home, repo),
expand("$HOME/.claude", "/home/agent")
);
}
#[test]
fn shipped_adapters_declare_where_to_import_mcp_from() {
for name in ["claude", "opencode"] {
let a = Adapter::find(Path::new(REAL), name).unwrap();
assert!(
a.supports(Capability::Mcp).unwrap().import.is_some(),
"{name} must say where `omh mcp import` should look"
);
}
}
}