Skip to main content

wt/agent/
spec.rs

1//! The known code-agent CLIs and their per-agent invocation differences,
2//! encoded as a static table so adding an agent is a single data literal
3//! (issue #11). All per-agent-variable logic — argv construction and version
4//! and result parsing — lives here as pure functions, directly unit-testable
5//! without spawning a process.
6
7use serde::Serialize;
8
9use crate::agent::model::{AgentModel, Effort};
10use crate::agent::types::{AgentRun, AgentVersion, ClaudeResult};
11use crate::error::Result;
12
13/// A code-agent CLI that `wt` knows how to detect and drive.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "lowercase")]
16pub enum AgentKind {
17    /// Anthropic's Claude Code (`claude`).
18    Claude,
19}
20
21impl AgentKind {
22    /// Every known agent kind, in display order.
23    pub fn all() -> &'static [AgentKind] {
24        &[AgentKind::Claude]
25    }
26
27    /// The static [`AgentSpec`] for this kind. An exhaustive `match` (rather
28    /// than a fallible table lookup) keeps this panic-free.
29    pub fn spec(self) -> &'static AgentSpec {
30        match self {
31            AgentKind::Claude => &AGENTS[0],
32        }
33    }
34
35    /// The stable lowercase identifier (the binary name, e.g. `"claude"`).
36    pub fn as_str(self) -> &'static str {
37        self.spec().binary
38    }
39
40    /// Parses a lowercase kind identifier, returning `None` if unknown.
41    pub fn parse(s: &str) -> Option<AgentKind> {
42        AgentKind::all().iter().copied().find(|k| k.as_str() == s)
43    }
44}
45
46/// How an agent's JSON output mode frames its result. New formats (e.g. a
47/// JSON-lines event stream) are added here as more agents are supported.
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum ResultFormat {
50    /// A single JSON object on stdout (e.g. `claude -p --output-format json`).
51    SingleObject,
52}
53
54/// Everything `wt` needs to detect and drive one agent CLI.
55#[derive(Debug, Clone, Copy)]
56pub struct AgentSpec {
57    /// The agent kind this spec describes.
58    pub kind: AgentKind,
59    /// The binary name as found on `PATH` (e.g. `"claude"`).
60    pub binary: &'static str,
61    /// Arguments that print the version (e.g. `["--version"]`).
62    pub version_args: &'static [&'static str],
63    /// Fixed leading arguments for a non-interactive run, before the prompt and
64    /// the JSON flag (e.g. `["-p"]`).
65    pub run_args: &'static [&'static str],
66    /// Whether the prompt is passed as a positional argument after `run_args`.
67    pub prompt_positional: bool,
68    /// Arguments that select JSON output (e.g. `["--output-format", "json"]`).
69    pub json_args: &'static [&'static str],
70    /// The flag that selects a model (e.g. `"--model"`); empty if the agent has
71    /// no model selector, in which case the model is not passed.
72    pub model_flag: &'static str,
73    /// How to parse stdout in JSON mode.
74    pub result_format: ResultFormat,
75}
76
77/// The known agents. Add a new agent by appending one literal here.
78pub static AGENTS: &[AgentSpec] = &[AgentSpec {
79    kind: AgentKind::Claude,
80    binary: "claude",
81    version_args: &["--version"],
82    run_args: &["-p"],
83    prompt_positional: true,
84    json_args: &["--output-format", "json"],
85    model_flag: "--model",
86    result_format: ResultFormat::SingleObject,
87}];
88
89/// Builds the version-probe argv for `spec`.
90pub fn version_argv(spec: &AgentSpec) -> Vec<String> {
91    spec.version_args.iter().map(|s| s.to_string()).collect()
92}
93
94/// Builds the full non-interactive, JSON-mode argv for `spec`, `prompt`, and
95/// `model`: `run_args`, then the prompt (when positional), then `json_args`,
96/// then the model selector (`model_flag` + the model id) when the agent has
97/// one. The prompt is a single argv element — never shell-interpolated — so it
98/// needs no quoting and cannot inject extra arguments.
99pub fn prompt_argv(spec: &AgentSpec, prompt: &str, model: AgentModel) -> Vec<String> {
100    let mut argv: Vec<String> = spec.run_args.iter().map(|s| s.to_string()).collect();
101    if spec.prompt_positional {
102        argv.push(prompt.to_string());
103    }
104    argv.extend(spec.json_args.iter().map(|s| s.to_string()));
105    if !spec.model_flag.is_empty() {
106        argv.push(spec.model_flag.to_string());
107        argv.push(model.id().to_string());
108    }
109    argv
110}
111
112/// Applies an [`Effort`] level to a prompt by prepending its directive (a
113/// blank line separates it from the body); the baseline (medium) returns the
114/// prompt unchanged. This is how `wt` conveys effort to agents without a native
115/// effort flag — it never fails and is a no-op for unsupported levels.
116pub fn apply_effort(effort: Effort, prompt: &str) -> String {
117    match effort.directive() {
118        Some(directive) => format!("{directive}\n\n{prompt}"),
119        None => prompt.to_string(),
120    }
121}
122
123/// Extracts a best-effort version from `--version` output: the first
124/// `MAJOR.MINOR[.PATCH]`-shaped token on the first line. No semver crate is
125/// used (matching repo convention); the trimmed raw line is preserved too.
126pub fn parse_version(raw_stdout: &str) -> AgentVersion {
127    let raw = raw_stdout.lines().next().unwrap_or("").trim().to_string();
128    AgentVersion {
129        version: extract_version(&raw),
130        raw,
131    }
132}
133
134/// Finds the first `\d+\.\d+(\.\d+)*`-shaped run in `text` (at least
135/// `MAJOR.MINOR`), trimming any trailing dot.
136fn extract_version(text: &str) -> Option<String> {
137    let bytes = text.as_bytes();
138    let mut i = 0;
139    while i < bytes.len() {
140        if !bytes[i].is_ascii_digit() {
141            i += 1;
142            continue;
143        }
144        let start = i;
145        while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b'.') {
146            i += 1;
147        }
148        let token = text[start..i].trim_end_matches('.');
149        if token.split('.').count() >= 2 && token.split('.').all(|part| !part.is_empty()) {
150            return Some(token.to_string());
151        }
152    }
153    None
154}
155
156/// Parses JSON-mode stdout into a normalized [`AgentRun`] for `kind`, per
157/// `format`. Malformed JSON maps to [`crate::error::Error::Json`].
158pub fn parse_result(kind: AgentKind, format: ResultFormat, stdout: &str) -> Result<AgentRun> {
159    match format {
160        ResultFormat::SingleObject => {
161            let raw: serde_json::Value = serde_json::from_str(stdout)?;
162            let parsed: ClaudeResult = serde_json::from_value(raw.clone())?;
163            Ok(AgentRun {
164                kind,
165                is_error: parsed.is_error,
166                result: parsed.result,
167                raw,
168            })
169        }
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn every_kind_has_a_matching_spec() {
179        for &kind in AgentKind::all() {
180            assert_eq!(kind.spec().kind, kind);
181        }
182    }
183
184    #[test]
185    fn kind_parse_roundtrips_and_rejects_unknown() {
186        for &kind in AgentKind::all() {
187            assert_eq!(AgentKind::parse(kind.as_str()), Some(kind));
188        }
189        assert_eq!(AgentKind::parse("nope"), None);
190    }
191
192    #[test]
193    fn kind_serializes_lowercase() {
194        assert_eq!(
195            serde_json::to_string(&AgentKind::Claude).unwrap(),
196            "\"claude\""
197        );
198    }
199
200    #[test]
201    fn version_argv_is_version_args() {
202        assert_eq!(
203            version_argv(AgentKind::Claude.spec()),
204            vec!["--version".to_string()]
205        );
206    }
207
208    #[test]
209    fn prompt_argv_orders_run_then_prompt_then_json_then_model() {
210        let argv = prompt_argv(AgentKind::Claude.spec(), "do a thing", AgentModel::Sonnet);
211        assert_eq!(
212            argv,
213            vec![
214                "-p".to_string(),
215                "do a thing".to_string(),
216                "--output-format".to_string(),
217                "json".to_string(),
218                "--model".to_string(),
219                "sonnet".to_string(),
220            ]
221        );
222        // A prompt with spaces and quotes stays a single argv element.
223        let tricky = prompt_argv(
224            AgentKind::Claude.spec(),
225            "a \"quoted\" $arg; rm -rf",
226            AgentModel::Opus,
227        );
228        assert_eq!(tricky[1], "a \"quoted\" $arg; rm -rf");
229        // The selected model is passed verbatim via `--model`.
230        assert_eq!(tricky[tricky.len() - 2], "--model");
231        assert_eq!(tricky[tricky.len() - 1], "opus");
232    }
233
234    #[test]
235    fn apply_effort_prefixes_directive_except_baseline() {
236        // Medium is the baseline: prompt unchanged.
237        assert_eq!(apply_effort(Effort::Medium, "draft this"), "draft this");
238        // Low/High prepend their directive plus a blank line.
239        let high = apply_effort(Effort::High, "draft this");
240        assert!(high.ends_with("\n\ndraft this"));
241        assert!(high.starts_with(Effort::High.directive().unwrap()));
242        let low = apply_effort(Effort::Low, "draft this");
243        assert!(low.starts_with(Effort::Low.directive().unwrap()));
244    }
245
246    #[test]
247    fn parse_version_extracts_semver() {
248        assert_eq!(
249            parse_version("1.2.3 (Claude Code)").version,
250            Some("1.2.3".to_string())
251        );
252        assert_eq!(parse_version("claude 0.4").version, Some("0.4".to_string()));
253        assert_eq!(
254            parse_version("v2.10.0\nextra line").version,
255            Some("2.10.0".to_string())
256        );
257        // A trailing dot is trimmed; a lone integer is not a version.
258        assert_eq!(parse_version("1.2.").version, Some("1.2".to_string()));
259        assert_eq!(parse_version("build 12").version, None);
260        let none = parse_version("weird-output");
261        assert_eq!(none.version, None);
262        assert_eq!(none.raw, "weird-output");
263    }
264
265    #[test]
266    fn parse_result_single_object_ok() {
267        let run = parse_result(
268            AgentKind::Claude,
269            ResultFormat::SingleObject,
270            r#"{"is_error": false, "result": "done", "extra": 1}"#,
271        )
272        .unwrap();
273        assert!(!run.is_error);
274        assert_eq!(run.result, "done");
275        assert_eq!(run.kind, AgentKind::Claude);
276        // Unmodeled fields are preserved in `raw`.
277        assert_eq!(run.raw.get("extra").and_then(|v| v.as_i64()), Some(1));
278    }
279
280    #[test]
281    fn parse_result_single_object_error_flag() {
282        let run = parse_result(
283            AgentKind::Claude,
284            ResultFormat::SingleObject,
285            r#"{"is_error": true, "result": "boom"}"#,
286        )
287        .unwrap();
288        assert!(run.is_error);
289        assert_eq!(run.result, "boom");
290    }
291
292    #[test]
293    fn parse_result_rejects_malformed_json() {
294        let err =
295            parse_result(AgentKind::Claude, ResultFormat::SingleObject, "not json").unwrap_err();
296        assert!(matches!(err, crate::error::Error::Json(_)));
297    }
298}