Skip to main content

mana_core/
agent_presets.rs

1//! Agent presets and detection for known coding-agent CLIs.
2//!
3//! Provides built-in presets (pi, claude, aider) with run/plan templates,
4//! and runtime detection of which agents are available on PATH.
5
6use std::process::Command;
7
8/// A known agent preset with command templates.
9#[derive(Debug, Clone, PartialEq)]
10pub struct AgentPreset {
11    /// Agent name (e.g. "pi", "claude", "aider").
12    pub name: &'static str,
13    /// Template for running/implementing a unit. Contains `{id}` placeholder.
14    pub run_template: &'static str,
15    /// Template for planning/splitting a unit. Contains `{id}` placeholder.
16    pub plan_template: &'static str,
17    /// Command to check the agent version (e.g. `pi --version`).
18    pub version_cmd: &'static str,
19}
20
21/// An agent detected on the current system.
22#[derive(Debug, Clone, PartialEq)]
23pub struct DetectedAgent {
24    /// Agent name.
25    pub name: String,
26    /// Absolute path to the binary (from `which`).
27    pub path: String,
28    /// Version string, if obtainable.
29    pub version: Option<String>,
30}
31
32// ---------------------------------------------------------------------------
33// Built-in presets
34// ---------------------------------------------------------------------------
35
36const PRESETS: &[AgentPreset] = &[
37    AgentPreset {
38        name: "pi",
39        run_template: "pi @.mana/{id}-*.md \"implement and mana close {id}\"",
40        plan_template: "pi @.mana/{id}-*.md \"plan into children with mana create --parent {id}\"",
41        version_cmd: "pi --version",
42    },
43    AgentPreset {
44        name: "claude",
45        run_template: "claude -p \"implement unit {id} and run mana close {id}\"",
46        plan_template: "claude -p \"unit {id} is too large, split with mana create --parent {id}\"",
47        version_cmd: "claude --version",
48    },
49    AgentPreset {
50        name: "aider",
51        run_template: "aider --message \"implement unit {id}, verify with mana close {id}\"",
52        plan_template: "aider --message \"plan unit {id} into children with mana create\"",
53        version_cmd: "aider --version",
54    },
55];
56
57/// Return all built-in agent presets.
58#[must_use]
59pub fn all_presets() -> &'static [AgentPreset] {
60    PRESETS
61}
62
63/// Look up a preset by name (case-insensitive).
64#[must_use]
65pub fn get_preset(name: &str) -> Option<&'static AgentPreset> {
66    let lower = name.to_ascii_lowercase();
67    PRESETS.iter().find(|p| p.name == lower)
68}
69
70/// Scan PATH for known agent CLIs and return those that are available.
71///
72/// For each preset, runs `which <name>` to find the binary and then
73/// attempts `<name> --version` to capture the version string.
74pub fn detect_agents() -> Vec<DetectedAgent> {
75    PRESETS
76        .iter()
77        .filter_map(|preset| {
78            let path = which_binary(preset.name)?;
79            let version = probe_version(preset.version_cmd);
80            Some(DetectedAgent {
81                name: preset.name.to_string(),
82                path,
83                version,
84            })
85        })
86        .collect()
87}
88
89impl AgentPreset {
90    /// Expand the run template, replacing `{id}` with the given unit id.
91    #[must_use]
92    pub fn run_cmd(&self, id: &str) -> String {
93        self.run_template.replace("{id}", id)
94    }
95
96    /// Expand the plan template, replacing `{id}` with the given unit id.
97    #[must_use]
98    pub fn plan_cmd(&self, id: &str) -> String {
99        self.plan_template.replace("{id}", id)
100    }
101}
102
103// ---------------------------------------------------------------------------
104// Helpers
105// ---------------------------------------------------------------------------
106
107/// Use `which` to resolve a binary name to an absolute path.
108fn which_binary(name: &str) -> Option<String> {
109    let output = Command::new("which").arg(name).output().ok()?;
110    if output.status.success() {
111        let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
112        if s.is_empty() {
113            None
114        } else {
115            Some(s)
116        }
117    } else {
118        None
119    }
120}
121
122/// Run a version command and capture its first line of output.
123fn probe_version(version_cmd: &str) -> Option<String> {
124    let parts: Vec<&str> = version_cmd.split_whitespace().collect();
125    let (bin, args) = parts.split_first()?;
126    let output = Command::new(bin).args(args).output().ok()?;
127    if output.status.success() {
128        let stdout = String::from_utf8_lossy(&output.stdout);
129        let first_line = stdout.lines().next()?.trim().to_string();
130        if first_line.is_empty() {
131            None
132        } else {
133            Some(first_line)
134        }
135    } else {
136        None
137    }
138}
139
140// ---------------------------------------------------------------------------
141// Tests
142// ---------------------------------------------------------------------------
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147
148    #[test]
149    fn all_presets_returns_at_least_three() {
150        let presets = all_presets();
151        assert!(
152            presets.len() >= 3,
153            "expected at least 3 presets, got {}",
154            presets.len()
155        );
156    }
157
158    #[test]
159    fn get_preset_pi_returns_correct_templates() {
160        let preset = get_preset("pi").expect("pi preset should exist");
161        assert_eq!(preset.name, "pi");
162        assert!(preset.run_template.contains("{id}"));
163        assert!(preset.plan_template.contains("{id}"));
164        assert_eq!(preset.version_cmd, "pi --version");
165    }
166
167    #[test]
168    fn get_preset_claude() {
169        let preset = get_preset("claude").expect("claude preset should exist");
170        assert_eq!(preset.name, "claude");
171        assert!(preset.run_template.contains("{id}"));
172        assert!(preset.plan_template.contains("{id}"));
173    }
174
175    #[test]
176    fn get_preset_aider() {
177        let preset = get_preset("aider").expect("aider preset should exist");
178        assert_eq!(preset.name, "aider");
179        assert!(preset.run_template.contains("{id}"));
180        assert!(preset.plan_template.contains("{id}"));
181    }
182
183    #[test]
184    fn get_preset_nonexistent_returns_none() {
185        assert!(get_preset("nonexistent").is_none());
186    }
187
188    #[test]
189    fn get_preset_is_case_insensitive() {
190        assert!(get_preset("Pi").is_some());
191        assert!(get_preset("CLAUDE").is_some());
192    }
193
194    #[test]
195    fn all_templates_contain_id_placeholder() {
196        for preset in all_presets() {
197            assert!(
198                preset.run_template.contains("{id}"),
199                "{} run_template missing {{id}}",
200                preset.name
201            );
202            assert!(
203                preset.plan_template.contains("{id}"),
204                "{} plan_template missing {{id}}",
205                preset.name
206            );
207        }
208    }
209
210    #[test]
211    fn run_cmd_expands_id() {
212        let preset = get_preset("pi").unwrap();
213        let cmd = preset.run_cmd("42");
214        assert!(cmd.contains("42"));
215        assert!(!cmd.contains("{id}"));
216    }
217
218    #[test]
219    fn plan_cmd_expands_id() {
220        let preset = get_preset("claude").unwrap();
221        let cmd = preset.plan_cmd("7.1");
222        assert!(cmd.contains("7.1"));
223        assert!(!cmd.contains("{id}"));
224    }
225
226    #[test]
227    fn detect_agents_returns_vec() {
228        // Smoke test — just ensure it doesn't panic. Actual results
229        // depend on what's installed on the machine.
230        let agents = detect_agents();
231        for agent in &agents {
232            assert!(!agent.name.is_empty());
233            assert!(!agent.path.is_empty());
234        }
235    }
236}