use std::{
io::{self, Write},
path::{Path, PathBuf},
process::Command,
time::Duration,
};
use agent_harness_adapter_core::{
Harness, fs,
hook::{
Answer, Event, HooksFile,
file::{DEFAULT_TIMEOUT, Entry},
wire,
},
manifest::project_dirs,
};
use regex::Regex;
use crate::bridge::{self, Ran};
const ME: &str = "agent-harness-adapter";
const DISPATCHING: &str = "AGENT_HARNESS_ADAPTER_DISPATCHING";
pub fn dispatch(
hooks: &str,
h: &dyn Harness,
event: Event,
text: &str,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> io::Result<u8> {
let input = bridge::parse(h, event, text);
let cwd = input
.cwd
.as_deref()
.map(PathBuf::from)
.filter(|d| d.is_dir())
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
let allow = |why: String| Answer::Allow {
stderr: Some(format!("{ME}: {why}\n")),
};
if std::env::var_os(DISPATCHING).is_some() {
let a = allow(format!("{hooks} is not dispatched from inside a dispatch"));
return bridge::answer(h, event, ME, a, stdout, stderr);
}
let Some(file) = locate(hooks, &cwd, fs::home_dir().as_deref()) else {
let a = allow(format!(
"no hooks file {hooks} at or above {}",
cwd.display()
));
return bridge::answer(h, event, ME, a, stdout, stderr);
};
let parsed = match HooksFile::read(&file) {
Ok(Some(f)) => f,
Ok(None) => HooksFile::default(),
Err(e) => return bridge::answer(h, event, ME, allow(e.to_string()), stdout, stderr),
};
let parser = agent_harness_adapter_core::harness::find(&input.harness);
let named_by = parser.as_deref().unwrap_or(h);
let tool = input.tool.as_ref().map(|t| named_by.claude_tool(t).name);
let mut entries = Vec::new();
for group in parsed.groups(event) {
match matches(group.matcher.as_deref(), tool.as_deref()) {
Ok(true) => entries.extend(group.hooks.iter().filter(|e| e.dispatched())),
Ok(false) => {}
Err(e) => writeln!(
stderr,
"{ME}: matcher `{}` is not a regular expression: {e}",
group.matcher.as_deref().unwrap_or_default()
)?,
}
}
let project = project_dir(hooks, &file, &cwd);
let json = bridge::stdin_text(h, &input);
let run = |e: &Entry| run_entry(e, &json, &cwd, &project);
if h.id() == "claude" && entries.len() == 1 {
return match run(entries[0]) {
Ok(ran) => bridge::pass_through(&ran, stdout, stderr),
Err(why) => {
writeln!(stderr, "{ME}: `{}`: {why}", command(entries[0]))?;
Ok(0)
}
};
}
let answer = combine(event, h.id(), &entries, run, stderr)?;
if h.id() == "claude" {
if let Answer::Allow { stderr: Some(text) } = &answer {
stderr.write_all(text.as_bytes())?;
}
let json = wire::answer_json(event, &answer).unwrap_or_else(|_| serde_json::json!({}));
writeln!(stdout, "{json}")?;
stdout.flush()?;
return Ok(0);
}
bridge::answer(h, event, ME, answer, stdout, stderr)
}
fn combine(
event: Event,
harness: &str,
entries: &[&Entry],
mut run: impl FnMut(&Entry) -> Result<Ran, String>,
stderr: &mut impl Write,
) -> io::Result<Answer> {
let mut contexts = Vec::new();
let mut notes = String::new();
for e in entries {
let ran = match run(e) {
Ok(ran) => ran,
Err(why) => {
notes.push_str(&format!("{ME}: `{}`: {why}\n", command(e)));
continue;
}
};
let reply = bridge::read(event, command(e), &ran);
for d in &reply.dropped {
writeln!(stderr, "{}: {d} is not supported by {harness}", command(e))?;
}
match reply.answer {
a @ (Answer::Deny { .. } | Answer::Continue { .. }) => {
stderr.write_all(notes.as_bytes())?;
return Ok(a);
}
Answer::Context { text } => contexts.push(text),
Answer::Allow { stderr: Some(text) } => notes.push_str(&text),
_ => {}
}
}
Ok(if contexts.is_empty() {
Answer::Allow {
stderr: (!notes.is_empty()).then_some(notes),
}
} else {
stderr.write_all(notes.as_bytes())?;
Answer::Context {
text: contexts.join("\n\n"),
}
})
}
fn matches(matcher: Option<&str>, tool: Option<&str>) -> Result<bool, regex::Error> {
let (Some(m), Some(tool)) = (matcher.filter(|m| !m.is_empty() && *m != "*"), tool) else {
return Ok(true);
};
Ok(Regex::new(&format!("^(?:{m})$"))?.is_match(tool))
}
fn locate(hooks: &str, cwd: &Path, home: Option<&Path>) -> Option<PathBuf> {
if let Some(rest) = hooks.strip_prefix("~/") {
return Some(home?.join(rest)).filter(|p| p.is_file());
}
let path = Path::new(hooks);
if path.is_absolute() {
return Some(path.to_path_buf()).filter(|p| p.is_file());
}
let first = path
.components()
.next()?
.as_os_str()
.to_string_lossy()
.into_owned();
project_dirs(cwd, home, &first)
.into_iter()
.map(|d| d.join(path))
.find(|p| p.is_file())
}
fn project_dir(hooks: &str, file: &Path, cwd: &Path) -> PathBuf {
if hooks.starts_with("~/") || Path::new(hooks).is_absolute() {
return cwd.to_path_buf();
}
let depth = Path::new(hooks).components().count();
file.ancestors()
.nth(depth)
.map_or_else(|| cwd.to_path_buf(), Path::to_path_buf)
}
fn run_entry(e: &Entry, input: &str, cwd: &Path, project: &Path) -> Result<Ran, String> {
let mut cmd = if cfg!(windows) {
let mut c = Command::new("cmd");
c.arg("/C");
c
} else {
let mut c = Command::new("sh");
c.arg("-c");
c
};
cmd.arg(command(e))
.current_dir(cwd)
.env("CLAUDE_PROJECT_DIR", project)
.env(DISPATCHING, "1");
let timeout = Duration::from_secs(e.timeout.unwrap_or(DEFAULT_TIMEOUT));
bridge::run_child(&mut cmd, input, Some(timeout))
}
fn command(e: &Entry) -> &str {
e.command.as_deref().unwrap_or_default()
}
#[cfg(test)]
mod tests;