use std::ffi::OsString;
use std::path::{Path, PathBuf};
use crate::hook;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("could not write the agent's settings: {0}")]
Settings(#[from] hook::Error),
#[error("no harness called {0}; known: {known}", known = known_names())]
Unknown(String),
#[error("{0} is not a command marver can read: {1}")]
BadSpec(String, String),
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Report {
ClaudeHooks,
CodexNotify,
Silent,
}
impl Report {
pub fn describe(self) -> &'static str {
match self {
Self::ClaudeHooks => "reports finishing, blocking, and why",
Self::CodexNotify => "reports finishing a turn; never blocks",
Self::Silent => "reports nothing — f marks a task finished",
}
}
pub fn finishes_by_itself(self) -> bool {
!matches!(self, Self::Silent)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Harness {
pub name: String,
pub program: String,
pub args: Vec<String>,
pub report: Report,
}
pub struct Known {
pub name: &'static str,
pub program: &'static str,
pub report: Report,
}
pub const KNOWN: &[Known] = &[
Known {
name: "claude",
program: "claude",
report: Report::ClaudeHooks,
},
Known {
name: "codex",
program: "codex",
report: Report::CodexNotify,
},
];
fn known_names() -> String {
KNOWN
.iter()
.map(|known| known.name)
.collect::<Vec<_>>()
.join(", ")
}
impl Harness {
fn known(name: &str) -> Option<Self> {
KNOWN
.iter()
.find(|known| known.name == name)
.map(|known| Self {
name: known.name.into(),
program: known.program.into(),
args: Vec::new(),
report: known.report,
})
}
pub fn claude() -> Self {
Self::known("claude").expect("claude is in KNOWN")
}
pub fn codex() -> Self {
Self::known("codex").expect("codex is in KNOWN")
}
pub fn silent(name: &str, program: &str, args: Vec<String>) -> Self {
Self {
name: name.into(),
program: program.into(),
args,
report: Report::Silent,
}
}
pub fn spec(&self) -> String {
match self.report {
Report::Silent => {
let mut words = vec![self.program.clone()];
words.extend(self.args.iter().cloned());
format!("{}:{}", self.name, shell_words::join(words))
}
_ => self.name.clone(),
}
}
pub fn parse(spec: &str) -> Result<Self> {
if let Some((name, command)) = spec.split_once(':') {
let words = shell_words::split(command)
.map_err(|err| Error::BadSpec(spec.into(), err.to_string()))?;
let Some((program, args)) = words.split_first() else {
return Err(Error::Unknown(spec.into()));
};
return Ok(Self::silent(name, program, args.to_vec()));
}
Self::known(spec).ok_or_else(|| Error::Unknown(spec.into()))
}
pub fn prepare(
&self,
dir: &Path,
task_id: i64,
marver_bin: &Path,
socket: &Path,
prompt: &str,
) -> Result<Start> {
let mut argv: Vec<OsString> = vec![self.program.clone().into()];
argv.extend(self.args.iter().map(OsString::from));
let mut settings = None;
match self.report {
Report::ClaudeHooks => {
let path = hook::write_settings(dir, task_id, marver_bin, socket)?;
argv.push("--settings".into());
argv.push(path.as_os_str().to_owned());
settings = Some(path);
}
Report::CodexNotify => {
argv.push("--config".into());
argv.push(
format!(
"notify={}",
toml_argv(&[
marver_bin.to_string_lossy().as_ref(),
"hook",
"--task",
&task_id.to_string(),
"--socket",
socket.to_string_lossy().as_ref(),
"--from",
"codex",
])
)
.into(),
);
}
Report::Silent => {}
}
argv.push(prompt.into());
Ok(Start { argv, settings })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Start {
pub argv: Vec<OsString>,
pub settings: Option<PathBuf>,
}
fn toml_argv(words: &[&str]) -> String {
let escaped: Vec<String> = words
.iter()
.map(|word| format!("\"{}\"", word.replace('\\', "\\\\").replace('"', "\\\"")))
.collect();
format!("[{}]", escaped.join(","))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn paths() -> (PathBuf, PathBuf) {
("/usr/local/bin/marver".into(), "/tmp/marverd.sock".into())
}
fn strings(argv: &[OsString]) -> Vec<String> {
argv.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect()
}
#[test]
fn claude_is_given_a_settings_file_of_hooks() {
let tmp = TempDir::new().unwrap();
let (bin, socket) = paths();
let start = Harness::claude()
.prepare(tmp.path(), 7, &bin, &socket, "fix it")
.unwrap();
let argv = strings(&start.argv);
assert_eq!(argv[0], "claude");
assert_eq!(argv[1], "--settings");
assert_eq!(argv.last().unwrap(), "fix it", "the prompt goes last");
let written = std::fs::read_to_string(start.settings.unwrap()).unwrap();
assert!(written.contains("\"Stop\""), "{written}");
assert!(written.contains("--task"), "{written}");
}
#[test]
fn codex_is_given_a_notify_override_rather_than_a_config_file() {
let tmp = TempDir::new().unwrap();
let (bin, socket) = paths();
let start = Harness::codex()
.prepare(tmp.path(), 7, &bin, &socket, "fix it")
.unwrap();
let argv = strings(&start.argv);
assert_eq!(argv[0], "codex");
assert_eq!(argv[1], "--config");
assert_eq!(
argv[2],
"notify=[\"/usr/local/bin/marver\",\"hook\",\"--task\",\"7\",\"--socket\",\"/tmp/marverd.sock\",\"--from\",\"codex\"]"
);
assert_eq!(argv.last().unwrap(), "fix it");
assert_eq!(start.settings, None, "codex needs no file");
assert!(
std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
"and none was written"
);
}
#[test]
fn a_quote_in_a_path_cannot_end_the_toml_array_early() {
let tmp = TempDir::new().unwrap();
let start = Harness::codex()
.prepare(
tmp.path(),
7,
Path::new("/tmp/od\"d/marver"),
Path::new("/tmp/s.sock"),
"go",
)
.unwrap();
let argv = strings(&start.argv);
assert!(argv[2].contains(r#"\"d/marver"#), "{}", argv[2]);
assert!(argv[2].ends_with("\"codex\"]"), "{}", argv[2]);
}
#[test]
fn a_silent_harness_is_started_and_then_only_watched() {
let tmp = TempDir::new().unwrap();
let (bin, socket) = paths();
let harness = Harness::silent("opencode", "opencode", vec!["run".into()]);
let start = harness
.prepare(tmp.path(), 7, &bin, &socket, "fix it")
.unwrap();
assert_eq!(strings(&start.argv), ["opencode", "run", "fix it"]);
assert_eq!(start.settings, None);
assert!(!harness.report.finishes_by_itself());
assert!(harness.report.describe().contains("f marks"));
}
#[test]
fn an_unknown_harness_can_be_described_on_the_spot() {
let harness = Harness::parse("opencode:opencode --agent build").unwrap();
assert_eq!(harness.name, "opencode");
assert_eq!(harness.program, "opencode");
assert_eq!(harness.args, ["--agent", "build"]);
assert_eq!(harness.report, Report::Silent);
}
#[test]
fn a_harness_round_trips_through_the_flag_that_named_it() {
for spec in ["claude", "codex", "opencode:opencode --agent build"] {
let harness = Harness::parse(spec).unwrap();
assert_eq!(harness.spec(), spec);
assert_eq!(Harness::parse(&harness.spec()).unwrap(), harness);
}
}
#[test]
fn an_argument_with_a_space_in_it_stays_one_argument() {
let harness = Harness::silent(
"oc",
"opencode",
vec!["--agent".into(), "build fast".into(), "it's odd".into()],
);
let back = Harness::parse(&harness.spec()).unwrap();
assert_eq!(back, harness, "spec was {:?}", harness.spec());
}
#[test]
fn a_spec_with_an_unbalanced_quote_is_refused_rather_than_guessed_at() {
let err = Harness::parse("oc:opencode --agent \"build").unwrap_err();
assert!(matches!(err, Error::BadSpec(..)), "{err}");
}
#[test]
fn the_ones_it_knows_are_named_and_the_rest_are_refused() {
assert_eq!(
Harness::parse("claude").unwrap().report,
Report::ClaudeHooks
);
assert_eq!(Harness::parse("codex").unwrap().report, Report::CodexNotify);
let err = Harness::parse("aider").unwrap_err();
for known in KNOWN {
assert!(
err.to_string().contains(known.name),
"{err} should name {}",
known.name
);
}
}
#[test]
fn only_the_one_with_an_event_for_it_claims_to_report_blocking() {
assert!(Report::ClaudeHooks.describe().contains("blocking"));
assert!(Report::CodexNotify.describe().contains("never blocks"));
assert!(Report::CodexNotify.finishes_by_itself());
}
}