use std::{
ffi::{OsStr, OsString},
io::{self, Read, Write},
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
};
use agent_harness_adapter_core::{
Harness,
cli::EXIT_ERRORS,
harness,
hook::{Answer, Event, HookInput, ToolKind, emit, wire},
};
use clap::Args;
use serde_json::Value;
#[derive(Debug, Args)]
pub struct HookArgs {
#[arg(long, value_name = "NAME")]
pub tool: Option<String>,
#[arg(long, value_name = "KIND", value_parser = parse_kind)]
pub tools: Option<ToolKind>,
pub harness: String,
pub event: String,
#[arg(last = true, required = true, value_name = "COMMAND")]
pub command: Vec<OsString>,
}
pub fn run(args: &HookArgs) -> io::Result<u8> {
let mut input = String::new();
let _ = io::stdin().read_to_string(&mut input);
bridge(
args,
&input,
&mut io::stdout().lock(),
&mut io::stderr().lock(),
)
}
fn bridge(
args: &HookArgs,
text: &str,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> io::Result<u8> {
let Some(h) = harness::find(&args.harness) else {
writeln!(stderr, "error: no harness named `{}`", args.harness)?;
return Ok(EXIT_ERRORS);
};
let event: Event = match args.event.parse() {
Ok(e) => e,
Err(e) => {
writeln!(stderr, "error: {e}")?;
return Ok(EXIT_ERRORS);
}
};
let input = parse(h.as_ref(), event, text);
let tool = args.tool.clone().unwrap_or_else(|| name(&args.command[0]));
let answer = if other_kind(args.tools, &input) {
Answer::Allow { stderr: None }
} else {
ask(&args.command, &input).unwrap_or_else(|reason| Answer::Allow {
stderr: Some(format!("{tool}: {reason}\n")),
})
};
emit(
h.as_ref(),
event,
Ok::<_, std::convert::Infallible>(answer),
stdout,
stderr,
)
}
fn other_kind(tools: Option<ToolKind>, input: &HookInput) -> bool {
matches!((tools, &input.tool), (Some(k), Some(t)) if t.kind != k)
}
fn parse_kind(s: &str) -> Result<ToolKind, String> {
serde_json::from_value(Value::String(s.into()))
.map_err(|_| format!("no tool kind `{s}`: shell, read, write, mcp or other"))
}
fn parse(h: &dyn Harness, event: Event, text: &str) -> HookInput {
HookInput::parse(h, event, text).unwrap_or_else(|_| HookInput::new(h.id(), event, Value::Null))
}
fn ask(command: &[OsString], input: &HookInput) -> Result<Answer, String> {
let program = &command[0];
let mut child = Command::new(resolve(program))
.args(&command[1..])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::inherit())
.spawn()
.map_err(|e| format!("could not run `{}`: {e}", program.to_string_lossy()))?;
let mut json = wire::input_json(input).to_string();
json.push('\n');
let mut stdin = child.stdin.take().expect("piped");
let writer = thread::spawn(move || {
let _ = stdin.write_all(json.as_bytes());
});
let out = child
.wait_with_output()
.map_err(|e| format!("could not run `{}`: {e}", program.to_string_lossy()))?;
let _ = writer.join();
if !out.status.success() {
return Err(match out.status.code() {
Some(code) => format!("exited with {code}"),
None => "was killed".into(),
});
}
let text = String::from_utf8_lossy(&out.stdout);
wire::parse_answer(&text).map_err(|e| e.to_string())
}
fn name(program: &OsString) -> String {
Path::new(program)
.file_stem()
.map_or_else(|| program.to_string_lossy(), |s| s.to_string_lossy())
.into_owned()
}
fn resolve(program: &OsString) -> PathBuf {
let exts = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
let path = std::env::var_os("PATH").unwrap_or_default();
resolve_in(program, cfg!(windows), &path, &exts)
}
fn resolve_in(program: &OsString, windows: bool, path: &OsStr, exts: &str) -> PathBuf {
let given = PathBuf::from(program);
if !windows || given.extension().is_some() || given.components().count() > 1 {
return given;
}
let base = given.display().to_string();
std::env::split_paths(path)
.flat_map(|dir| {
exts.split(';')
.filter(|e| !e.is_empty())
.map(|e| dir.join(format!("{base}{e}")))
.collect::<Vec<_>>()
})
.find(|p| p.is_file())
.unwrap_or(given)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names_and_programs() {
assert_eq!(name(&"/usr/bin/mytool".into()), "mytool");
assert_eq!(name(&"mytool.cmd".into()), "mytool");
if !cfg!(windows) {
assert_eq!(resolve(&"mytool".into()), PathBuf::from("mytool"));
}
}
#[test]
fn windows_finds_a_bare_name_with_its_extension_on_the_path() {
let dir = std::env::temp_dir().join(format!("aha-resolve-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("mytool.CMD"), "").unwrap();
let path = std::env::join_paths([dir.join("none"), dir.clone()]).unwrap();
let exts = ".EXE;.CMD;";
let found = |p: &str| resolve_in(&p.into(), true, &path, exts);
assert_eq!(found("mytool"), dir.join("mytool.CMD"));
assert_eq!(found("mytool.exe"), PathBuf::from("mytool.exe"));
assert_eq!(found("bin/mytool"), PathBuf::from("bin/mytool"));
assert_eq!(found("other"), PathBuf::from("other"));
assert_eq!(
resolve_in(&"mytool".into(), false, &path, exts),
PathBuf::from("mytool")
);
std::fs::remove_dir_all(&dir).unwrap();
}
}