pub mod model;
pub mod spec;
pub mod types;
use std::path::Path;
use std::process::Command;
use crate::error::{Error, Result};
pub use model::{AgentModel, AgentOptions, Effort};
pub use spec::{AGENTS, AgentKind, AgentSpec, ResultFormat};
pub use types::{AgentRun, AgentVersion, DetectedAgent};
pub trait AgentClient {
fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>>;
fn run(
&self,
kind: AgentKind,
prompt: &str,
dir: &Path,
opts: &AgentOptions,
) -> Result<AgentRun>;
fn detect_all(&self) -> Vec<DetectedAgent> {
AgentKind::all()
.iter()
.filter_map(|&kind| self.detect(kind).ok().flatten())
.collect()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct RealAgent;
impl AgentClient for RealAgent {
fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>> {
detect_with(kind.spec().binary, kind, kind.spec())
}
fn run(
&self,
kind: AgentKind,
prompt: &str,
dir: &Path,
opts: &AgentOptions,
) -> Result<AgentRun> {
run_with(kind.spec().binary, kind, kind.spec(), prompt, dir, opts)
}
}
fn detect_with(binary: &str, kind: AgentKind, spec: &AgentSpec) -> Result<Option<DetectedAgent>> {
match run_agent(binary, None, &spec::version_argv(spec)) {
Ok(stdout) => Ok(Some(DetectedAgent {
kind,
binary: binary.to_string(),
version: spec::parse_version(&stdout),
})),
Err(Error::AgentUnavailable(_)) => Ok(None),
Err(e) => Err(e),
}
}
fn run_with(
binary: &str,
kind: AgentKind,
spec: &AgentSpec,
prompt: &str,
dir: &Path,
opts: &AgentOptions,
) -> Result<AgentRun> {
let prompt = spec::apply_effort(opts.effort, prompt);
let argv = spec::prompt_argv(spec, &prompt, opts.model);
let stdout = run_agent(binary, Some(dir), &argv)?;
spec::parse_result(kind, spec.result_format, &stdout)
}
fn run_agent(binary: &str, dir: Option<&Path>, args: &[String]) -> Result<String> {
let mut cmd = Command::new(binary);
if let Some(dir) = dir {
cmd.current_dir(dir);
}
cmd.args(args);
let output = match cmd.output() {
Ok(output) => output,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(Error::AgentUnavailable(format!(
"{binary} is not installed or not on PATH"
)));
}
Err(e) => {
return Err(Error::AgentUnavailable(format!(
"failed to run {binary}: {e}"
)));
}
};
if output.status.success() {
return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
}
Err(Error::Subprocess {
program: binary.to_string(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
const MISSING: &str = "wt-nonexistent-agent-binary-xyzzy";
enum Behavior {
Found,
Missing,
Failing,
}
struct Fake(Behavior);
impl AgentClient for Fake {
fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>> {
match self.0 {
Behavior::Found => Ok(Some(DetectedAgent {
kind,
binary: kind.as_str().to_string(),
version: AgentVersion {
version: None,
raw: String::new(),
},
})),
Behavior::Missing => Ok(None),
Behavior::Failing => Err(Error::operation("boom")),
}
}
fn run(
&self,
kind: AgentKind,
prompt: &str,
_dir: &Path,
_opts: &AgentOptions,
) -> Result<AgentRun> {
Ok(AgentRun {
kind,
is_error: false,
result: prompt.to_string(),
raw: serde_json::Value::Null,
})
}
}
#[test]
fn detect_all_keeps_found_drops_missing_and_failing() {
assert_eq!(
Fake(Behavior::Found).detect_all().len(),
AgentKind::all().len()
);
assert!(Fake(Behavior::Missing).detect_all().is_empty());
assert!(Fake(Behavior::Failing).detect_all().is_empty());
}
#[test]
fn fake_run_returns_normalized_result() {
let dir = tempfile::tempdir().unwrap();
let run = Fake(Behavior::Found)
.run(
AgentKind::Claude,
"hi",
dir.path(),
&AgentOptions::default(),
)
.unwrap();
assert_eq!(run.result, "hi");
assert!(!run.is_error);
}
#[test]
fn run_agent_maps_missing_binary_to_unavailable() {
let err = run_agent(MISSING, None, &["--version".to_string()]).unwrap_err();
assert!(matches!(err, Error::AgentUnavailable(_)));
}
#[test]
fn detect_with_returns_none_for_missing_binary() {
let result = detect_with(MISSING, AgentKind::Claude, AgentKind::Claude.spec()).unwrap();
assert!(result.is_none());
}
#[test]
fn real_agent_detect_claude_does_not_error() {
assert!(RealAgent.detect(AgentKind::Claude).is_ok());
}
#[cfg(unix)]
mod unix {
use super::*;
const SH_VERSION: AgentSpec = AgentSpec {
kind: AgentKind::Claude,
binary: "sh",
version_args: &["-c", "echo '9.9.9 (test agent)'"],
run_args: &["-c", "printf '{\"is_error\":false,\"result\":\"ok\"}'"],
prompt_positional: true,
json_args: &[],
model_flag: "",
result_format: ResultFormat::SingleObject,
};
const SH_FAIL: AgentSpec = AgentSpec {
kind: AgentKind::Claude,
binary: "sh",
version_args: &["-c", "exit 1"],
run_args: &["-c", "true"],
prompt_positional: true,
json_args: &[],
model_flag: "",
result_format: ResultFormat::SingleObject,
};
#[test]
fn run_agent_returns_stdout_on_success() {
let out =
run_agent("sh", None, &["-c".to_string(), "printf hello".to_string()]).unwrap();
assert_eq!(out, "hello");
}
#[test]
fn run_agent_maps_nonzero_exit_to_subprocess() {
let err = run_agent("sh", None, &["-c".to_string(), "exit 3".to_string()]).unwrap_err();
match err {
Error::Subprocess { program, .. } => assert_eq!(program, "sh"),
other => panic!("expected subprocess error, got {other:?}"),
}
}
#[test]
fn detect_with_parses_version_from_real_process() {
let detected = detect_with("sh", AgentKind::Claude, &SH_VERSION)
.unwrap()
.unwrap();
assert_eq!(detected.binary, "sh");
assert_eq!(detected.version.version, Some("9.9.9".to_string()));
}
#[test]
fn detect_with_propagates_non_unavailable_errors() {
let err = detect_with("sh", AgentKind::Claude, &SH_FAIL).unwrap_err();
assert!(matches!(err, Error::Subprocess { .. }));
}
#[test]
fn run_with_invokes_and_parses_result() {
let dir = tempfile::tempdir().unwrap();
let run = run_with(
"sh",
AgentKind::Claude,
&SH_VERSION,
"my prompt",
dir.path(),
&AgentOptions::default(),
)
.unwrap();
assert!(!run.is_error);
assert_eq!(run.result, "ok");
}
}
}