Skip to main content

atm_core/
harness_registry.rs

1//! Built-in coding-agent harness registry.
2//!
3//! The registry is intentionally data-first: spawning, process discovery,
4//! and future config overlays can all consume the same definitions instead
5//! of special-casing every CLI agent in each subsystem.
6
7use crate::Harness;
8
9/// How ATM should supply an initial prompt to a harness.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum PromptMode {
12    /// The harness accepts a prompt via a command-line flag.
13    Flag(&'static str),
14    /// The harness must be launched first, then text is injected with tmux
15    /// `send-keys`.
16    KeystrokeInjection,
17    /// ATM does not know how to pass an initial prompt for this harness yet.
18    Unsupported,
19}
20
21/// A declarative process-path matcher used by daemon discovery.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum ProcessMatcher {
24    /// Path or argv exactly equals this string.
25    Exact(&'static str),
26    /// Path or argv ends with this suffix.
27    Suffix(&'static str),
28    /// Path or argv contains this substring.
29    Contains(&'static str),
30}
31
32impl ProcessMatcher {
33    /// Returns true if `candidate` satisfies this matcher.
34    #[must_use]
35    pub fn matches(&self, candidate: &str) -> bool {
36        match self {
37            Self::Exact(expected) => candidate == *expected,
38            Self::Suffix(suffix) => candidate.ends_with(suffix),
39            Self::Contains(needle) => candidate.contains(needle),
40        }
41    }
42}
43
44/// Metadata for a CLI coding-agent harness.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub struct HarnessDefinition {
47    /// Stable CLI/config identifier (e.g. `claude`, `pi`, `codex`).
48    pub id: &'static str,
49    /// Alternate CLI/config identifiers accepted by lookup.
50    pub aliases: &'static [&'static str],
51    /// Human-readable display name.
52    pub display_name: &'static str,
53    /// Session harness tag used in ATM's domain model.
54    pub harness: Harness,
55    /// Binary to launch for `atm spawn`.
56    pub binary: &'static str,
57    /// Default arguments inserted immediately after the binary.
58    pub default_args: &'static [&'static str],
59    /// Flag used to set the model, when supported.
60    pub model_flag: Option<&'static str>,
61    /// How to pass an initial prompt, when/if spawn grows that option.
62    pub prompt_mode: PromptMode,
63    /// Arguments used for installation/version probing.
64    pub version_args: &'static [&'static str],
65    /// Process path/argv matchers used by discovery.
66    pub process_matchers: &'static [ProcessMatcher],
67    /// Whether this harness should be detected by daemon `/proc` discovery.
68    ///
69    /// Keep this false until a harness has an adapter/status source, otherwise
70    /// ATM may show unmanaged pending sessions with misleading badges.
71    pub discovery_enabled: bool,
72    /// Whether bare argv0 matches are allowed during cmdline scanning.
73    ///
74    /// This is safe for distinctive command names like `claude`, but unsafe for
75    /// short ambiguous names like `pi` (a random program can take `pi` as data).
76    /// Bare matches are never accepted from arbitrary positional arguments.
77    pub allow_bare_cmdline_match: bool,
78}
79
80const CLAUDE_MATCHERS: &[ProcessMatcher] = &[
81    ProcessMatcher::Exact("claude"),
82    ProcessMatcher::Suffix("/claude"),
83    ProcessMatcher::Contains("claude/versions/"),
84];
85
86const PI_MATCHERS: &[ProcessMatcher] = &[
87    ProcessMatcher::Suffix("/bin/pi"),
88    ProcessMatcher::Suffix("/pi"),
89    ProcessMatcher::Contains("pi-coding-agent"),
90];
91
92const CODEX_MATCHERS: &[ProcessMatcher] = &[
93    ProcessMatcher::Exact("codex"),
94    ProcessMatcher::Suffix("/codex"),
95    ProcessMatcher::Contains("codex-cli"),
96];
97
98const AMP_MATCHERS: &[ProcessMatcher] =
99    &[ProcessMatcher::Exact("amp"), ProcessMatcher::Suffix("/amp")];
100
101const QWEN_MATCHERS: &[ProcessMatcher] = &[
102    ProcessMatcher::Exact("qwen"),
103    ProcessMatcher::Suffix("/qwen"),
104    ProcessMatcher::Exact("qwen-code"),
105    ProcessMatcher::Suffix("/qwen-code"),
106];
107
108const GEMINI_MATCHERS: &[ProcessMatcher] = &[
109    ProcessMatcher::Exact("gemini"),
110    ProcessMatcher::Suffix("/gemini"),
111    ProcessMatcher::Contains("gemini-cli"),
112];
113
114/// Built-in harness definitions.
115pub const BUILTIN_HARNESSES: &[HarnessDefinition] = &[
116    HarnessDefinition {
117        id: "claude",
118        aliases: &["claude_code", "claude-code", "cc"],
119        display_name: "Claude Code",
120        harness: Harness::ClaudeCode,
121        binary: "claude",
122        default_args: &[],
123        model_flag: Some("--model"),
124        prompt_mode: PromptMode::KeystrokeInjection,
125        version_args: &["--version"],
126        process_matchers: CLAUDE_MATCHERS,
127        discovery_enabled: true,
128        allow_bare_cmdline_match: true,
129    },
130    HarnessDefinition {
131        id: "pi",
132        aliases: &[],
133        display_name: "pi",
134        harness: Harness::Pi,
135        binary: "pi",
136        default_args: &[],
137        model_flag: Some("--model"),
138        prompt_mode: PromptMode::KeystrokeInjection,
139        version_args: &["--version"],
140        process_matchers: PI_MATCHERS,
141        discovery_enabled: true,
142        allow_bare_cmdline_match: false,
143    },
144    HarnessDefinition {
145        id: "codex",
146        aliases: &["codex-cli"],
147        display_name: "Codex CLI",
148        harness: Harness::Codex,
149        binary: "codex",
150        default_args: &[],
151        model_flag: None,
152        prompt_mode: PromptMode::KeystrokeInjection,
153        version_args: &["--version"],
154        process_matchers: CODEX_MATCHERS,
155        discovery_enabled: false,
156        allow_bare_cmdline_match: true,
157    },
158    HarnessDefinition {
159        id: "amp",
160        aliases: &[],
161        display_name: "Amp",
162        harness: Harness::Amp,
163        binary: "amp",
164        default_args: &[],
165        model_flag: None,
166        prompt_mode: PromptMode::KeystrokeInjection,
167        version_args: &["--version"],
168        process_matchers: AMP_MATCHERS,
169        discovery_enabled: false,
170        allow_bare_cmdline_match: true,
171    },
172    HarnessDefinition {
173        id: "qwen",
174        aliases: &["qwen-code"],
175        display_name: "Qwen Code",
176        harness: Harness::Qwen,
177        binary: "qwen",
178        default_args: &[],
179        model_flag: None,
180        prompt_mode: PromptMode::KeystrokeInjection,
181        version_args: &["--version"],
182        process_matchers: QWEN_MATCHERS,
183        discovery_enabled: false,
184        allow_bare_cmdline_match: true,
185    },
186    HarnessDefinition {
187        id: "gemini",
188        aliases: &["gemini-cli"],
189        display_name: "Gemini CLI",
190        harness: Harness::Gemini,
191        binary: "gemini",
192        default_args: &[],
193        model_flag: None,
194        prompt_mode: PromptMode::KeystrokeInjection,
195        version_args: &["--version"],
196        process_matchers: GEMINI_MATCHERS,
197        discovery_enabled: false,
198        allow_bare_cmdline_match: true,
199    },
200];
201
202/// Returns the default harness definition used by `atm spawn`.
203#[must_use]
204pub fn default_harness_definition() -> &'static HarnessDefinition {
205    // Keep the historic default behavior: `atm spawn` launches Claude Code.
206    if let Some(definition) = find_harness_definition("claude") {
207        definition
208    } else {
209        // BUILTIN_HARNESSES is defined in this module with Claude first; this
210        // defensive fallback avoids panicking if that invariant changes.
211        BUILTIN_HARNESSES
212            .iter()
213            .next()
214            .unwrap_or(&FALLBACK_HARNESS_DEFINITION)
215    }
216}
217
218const FALLBACK_HARNESS_DEFINITION: HarnessDefinition = HarnessDefinition {
219    id: "unknown",
220    aliases: &[],
221    display_name: "unknown",
222    harness: Harness::Unknown,
223    binary: "claude",
224    default_args: &[],
225    model_flag: None,
226    prompt_mode: PromptMode::Unsupported,
227    version_args: &[],
228    process_matchers: &[],
229    discovery_enabled: false,
230    allow_bare_cmdline_match: false,
231};
232
233/// Finds a built-in harness definition by canonical id or alias.
234#[must_use]
235pub fn find_harness_definition(id: &str) -> Option<&'static HarnessDefinition> {
236    BUILTIN_HARNESSES
237        .iter()
238        .find(|definition| definition.id == id || definition.aliases.contains(&id))
239}
240
241/// Iterates over built-in harness definitions in discovery priority order.
242pub fn builtin_harnesses() -> impl Iterator<Item = &'static HarnessDefinition> {
243    BUILTIN_HARNESSES.iter()
244}
245
246/// Returns a comma-separated list of built-in harness ids for diagnostics.
247#[must_use]
248pub fn builtin_harness_ids_display() -> String {
249    BUILTIN_HARNESSES
250        .iter()
251        .map(|definition| definition.id)
252        .collect::<Vec<_>>()
253        .join(", ")
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn finds_default_claude_harness() {
262        let definition = default_harness_definition();
263        assert_eq!(definition.id, "claude");
264        assert_eq!(definition.harness, Harness::ClaudeCode);
265    }
266
267    #[test]
268    fn aliases_resolve_to_canonical_harnesses() {
269        assert_eq!(
270            find_harness_definition("claude_code").map(|d| d.id),
271            Some("claude")
272        );
273        assert_eq!(
274            find_harness_definition("qwen-code").map(|d| d.id),
275            Some("qwen")
276        );
277        assert_eq!(find_harness_definition("nope").map(|d| d.id), None);
278    }
279
280    #[test]
281    fn process_matchers_cover_expected_paths() {
282        let claude = find_harness_definition("claude").unwrap_or(default_harness_definition());
283        assert!(claude.process_matchers.iter().any(|m| m.matches("claude")));
284        assert!(claude
285            .process_matchers
286            .iter()
287            .any(|m| m.matches("/usr/local/bin/claude")));
288
289        let pi = find_harness_definition("pi").unwrap_or(default_harness_definition());
290        assert!(pi.process_matchers.iter().any(|m| m.matches("/usr/bin/pi")));
291        assert!(pi.discovery_enabled);
292        assert!(!pi.allow_bare_cmdline_match);
293    }
294}