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