1pub mod model;
11pub mod spec;
12pub mod types;
13
14use std::path::Path;
15use std::process::Command;
16
17use crate::error::{Error, Result};
18pub use model::{AgentModel, AgentOptions, Effort};
19pub use spec::{AGENTS, AgentKind, AgentSpec, ResultFormat};
20pub use types::{AgentRun, AgentVersion, DetectedAgent};
21
22pub trait AgentClient {
24 fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>>;
27
28 fn run(
32 &self,
33 kind: AgentKind,
34 prompt: &str,
35 dir: &Path,
36 opts: &AgentOptions,
37 ) -> Result<AgentRun>;
38
39 fn detect_all(&self) -> Vec<DetectedAgent> {
42 AgentKind::all()
43 .iter()
44 .filter_map(|&kind| self.detect(kind).ok().flatten())
45 .collect()
46 }
47}
48
49#[derive(Debug, Clone, Copy, Default)]
51pub struct RealAgent;
52
53impl AgentClient for RealAgent {
54 fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>> {
55 detect_with(kind.spec().binary, kind, kind.spec())
56 }
57
58 fn run(
59 &self,
60 kind: AgentKind,
61 prompt: &str,
62 dir: &Path,
63 opts: &AgentOptions,
64 ) -> Result<AgentRun> {
65 run_with(kind.spec().binary, kind, kind.spec(), prompt, dir, opts)
66 }
67}
68
69fn detect_with(binary: &str, kind: AgentKind, spec: &AgentSpec) -> Result<Option<DetectedAgent>> {
73 match run_agent(binary, None, &spec::version_argv(spec)) {
74 Ok(stdout) => Ok(Some(DetectedAgent {
75 kind,
76 binary: binary.to_string(),
77 version: spec::parse_version(&stdout),
78 })),
79 Err(Error::AgentUnavailable(_)) => Ok(None),
80 Err(e) => Err(e),
81 }
82}
83
84fn run_with(
87 binary: &str,
88 kind: AgentKind,
89 spec: &AgentSpec,
90 prompt: &str,
91 dir: &Path,
92 opts: &AgentOptions,
93) -> Result<AgentRun> {
94 let prompt = spec::apply_effort(opts.effort, prompt);
95 let argv = spec::prompt_argv(spec, &prompt, opts.model);
96 let stdout = run_agent(binary, Some(dir), &argv)?;
97 spec::parse_result(kind, spec.result_format, &stdout)
98}
99
100fn run_agent(binary: &str, dir: Option<&Path>, args: &[String]) -> Result<String> {
104 let mut cmd = Command::new(binary);
105 if let Some(dir) = dir {
106 cmd.current_dir(dir);
107 }
108 cmd.args(args);
109 let output = match cmd.output() {
110 Ok(output) => output,
111 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
112 return Err(Error::AgentUnavailable(format!(
113 "{binary} is not installed or not on PATH"
114 )));
115 }
116 Err(e) => {
117 return Err(Error::AgentUnavailable(format!(
118 "failed to run {binary}: {e}"
119 )));
120 }
121 };
122 if output.status.success() {
123 return Ok(String::from_utf8_lossy(&output.stdout).into_owned());
124 }
125 Err(Error::Subprocess {
126 program: binary.to_string(),
127 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
128 })
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 const MISSING: &str = "wt-nonexistent-agent-binary-xyzzy";
137
138 enum Behavior {
140 Found,
141 Missing,
142 Failing,
143 }
144
145 struct Fake(Behavior);
146
147 impl AgentClient for Fake {
148 fn detect(&self, kind: AgentKind) -> Result<Option<DetectedAgent>> {
149 match self.0 {
150 Behavior::Found => Ok(Some(DetectedAgent {
151 kind,
152 binary: kind.as_str().to_string(),
153 version: AgentVersion {
154 version: None,
155 raw: String::new(),
156 },
157 })),
158 Behavior::Missing => Ok(None),
159 Behavior::Failing => Err(Error::operation("boom")),
160 }
161 }
162
163 fn run(
164 &self,
165 kind: AgentKind,
166 prompt: &str,
167 _dir: &Path,
168 _opts: &AgentOptions,
169 ) -> Result<AgentRun> {
170 Ok(AgentRun {
171 kind,
172 is_error: false,
173 result: prompt.to_string(),
174 raw: serde_json::Value::Null,
175 })
176 }
177 }
178
179 #[test]
180 fn detect_all_keeps_found_drops_missing_and_failing() {
181 assert_eq!(
182 Fake(Behavior::Found).detect_all().len(),
183 AgentKind::all().len()
184 );
185 assert!(Fake(Behavior::Missing).detect_all().is_empty());
186 assert!(Fake(Behavior::Failing).detect_all().is_empty());
189 }
190
191 #[test]
192 fn fake_run_returns_normalized_result() {
193 let dir = tempfile::tempdir().unwrap();
194 let run = Fake(Behavior::Found)
195 .run(
196 AgentKind::Claude,
197 "hi",
198 dir.path(),
199 &AgentOptions::default(),
200 )
201 .unwrap();
202 assert_eq!(run.result, "hi");
203 assert!(!run.is_error);
204 }
205
206 #[test]
207 fn run_agent_maps_missing_binary_to_unavailable() {
208 let err = run_agent(MISSING, None, &["--version".to_string()]).unwrap_err();
209 assert!(matches!(err, Error::AgentUnavailable(_)));
210 }
211
212 #[test]
213 fn detect_with_returns_none_for_missing_binary() {
214 let result = detect_with(MISSING, AgentKind::Claude, AgentKind::Claude.spec()).unwrap();
215 assert!(result.is_none());
216 }
217
218 #[test]
219 fn real_agent_detect_claude_does_not_error() {
220 assert!(RealAgent.detect(AgentKind::Claude).is_ok());
223 }
224
225 #[cfg(unix)]
228 mod unix {
229 use super::*;
230
231 const SH_VERSION: AgentSpec = AgentSpec {
233 kind: AgentKind::Claude,
234 binary: "sh",
235 version_args: &["-c", "echo '9.9.9 (test agent)'"],
236 run_args: &["-c", "printf '{\"is_error\":false,\"result\":\"ok\"}'"],
237 prompt_positional: true,
238 json_args: &[],
239 model_flag: "",
240 result_format: ResultFormat::SingleObject,
241 };
242
243 const SH_FAIL: AgentSpec = AgentSpec {
245 kind: AgentKind::Claude,
246 binary: "sh",
247 version_args: &["-c", "exit 1"],
248 run_args: &["-c", "true"],
249 prompt_positional: true,
250 json_args: &[],
251 model_flag: "",
252 result_format: ResultFormat::SingleObject,
253 };
254
255 #[test]
256 fn run_agent_returns_stdout_on_success() {
257 let out =
258 run_agent("sh", None, &["-c".to_string(), "printf hello".to_string()]).unwrap();
259 assert_eq!(out, "hello");
260 }
261
262 #[test]
263 fn run_agent_maps_nonzero_exit_to_subprocess() {
264 let err = run_agent("sh", None, &["-c".to_string(), "exit 3".to_string()]).unwrap_err();
265 match err {
266 Error::Subprocess { program, .. } => assert_eq!(program, "sh"),
267 other => panic!("expected subprocess error, got {other:?}"),
268 }
269 }
270
271 #[test]
272 fn detect_with_parses_version_from_real_process() {
273 let detected = detect_with("sh", AgentKind::Claude, &SH_VERSION)
274 .unwrap()
275 .unwrap();
276 assert_eq!(detected.binary, "sh");
277 assert_eq!(detected.version.version, Some("9.9.9".to_string()));
278 }
279
280 #[test]
281 fn detect_with_propagates_non_unavailable_errors() {
282 let err = detect_with("sh", AgentKind::Claude, &SH_FAIL).unwrap_err();
283 assert!(matches!(err, Error::Subprocess { .. }));
284 }
285
286 #[test]
287 fn run_with_invokes_and_parses_result() {
288 let dir = tempfile::tempdir().unwrap();
289 let run = run_with(
290 "sh",
291 AgentKind::Claude,
292 &SH_VERSION,
293 "my prompt",
294 dir.path(),
295 &AgentOptions::default(),
296 )
297 .unwrap();
298 assert!(!run.is_error);
299 assert_eq!(run.result, "ok");
300 }
301 }
302}