Skip to main content

ralph/commands/runner/
capabilities.rs

1//! Runner capabilities reporting.
2//!
3//! Responsibilities:
4//! - Aggregate capability data from multiple sources.
5//! - Format output as text or JSON.
6//!
7//! Not handled here:
8//! - Binary detection (see detection.rs).
9//! - CLI argument parsing (see cli/runner.rs).
10
11use serde::Serialize;
12
13use crate::cli::runner::RunnerFormat;
14use crate::contracts::Runner;
15use crate::runner::default_model_for_runner;
16use crate::runner::{BuiltInRunnerPlugin, RunnerPlugin};
17
18use super::detection::check_runner_binary;
19
20/// Complete capability report for a runner.
21#[derive(Debug, Clone, Serialize)]
22pub struct RunnerCapabilityReport {
23    /// Runner identifier.
24    pub runner: String,
25    /// Human-readable runner name.
26    pub name: String,
27    /// Whether session resumption is supported.
28    pub supports_session_resume: bool,
29    /// Whether Ralph must manage session IDs (e.g., Kimi).
30    pub requires_managed_session_id: bool,
31    /// Supported features.
32    pub features: RunnerFeatures,
33    /// Allowed models (None = arbitrary models allowed).
34    pub allowed_models: Option<Vec<String>>,
35    /// Default model for this runner.
36    pub default_model: String,
37    /// Binary status.
38    pub binary: BinaryInfo,
39}
40
41#[derive(Debug, Clone, Serialize)]
42pub struct RunnerFeatures {
43    /// Reasoning effort control (Codex only).
44    pub reasoning_effort: bool,
45    /// Sandbox mode control.
46    pub sandbox: SandboxSupport,
47    /// Plan mode support (Cursor only).
48    pub plan_mode: bool,
49    /// Verbose output control.
50    pub verbose: bool,
51    /// Approval mode control.
52    pub approval_modes: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize)]
56pub struct SandboxSupport {
57    pub supported: bool,
58    pub modes: Vec<String>,
59}
60
61#[derive(Debug, Clone, Serialize)]
62pub struct BinaryInfo {
63    pub installed: bool,
64    pub version: Option<String>,
65    pub error: Option<String>,
66}
67
68/// Get capabilities for a specific runner.
69pub fn get_runner_capabilities(runner: &Runner, bin_name: &str) -> RunnerCapabilityReport {
70    let plugin = runner_to_plugin(runner);
71    let metadata = plugin.metadata();
72
73    // Check binary status
74    let binary_status = check_runner_binary(bin_name);
75    let binary_info = BinaryInfo {
76        installed: binary_status.installed,
77        version: binary_status.version,
78        error: binary_status.error,
79    };
80
81    // Get features based on runner type
82    let features = get_runner_features(runner);
83
84    // Get allowed models
85    let allowed_models = get_allowed_models(runner);
86
87    // Get default model
88    let default_model = default_model_for_runner(runner);
89
90    RunnerCapabilityReport {
91        runner: runner.id().to_string(),
92        name: metadata.name,
93        supports_session_resume: metadata.supports_resume,
94        requires_managed_session_id: plugin.requires_managed_session_id(),
95        features,
96        allowed_models,
97        default_model: default_model.as_str().to_string(),
98        binary: binary_info,
99    }
100}
101
102fn runner_to_plugin(runner: &Runner) -> BuiltInRunnerPlugin {
103    match runner {
104        Runner::Codex => BuiltInRunnerPlugin::Codex,
105        Runner::Opencode => BuiltInRunnerPlugin::Opencode,
106        Runner::Gemini => BuiltInRunnerPlugin::Gemini,
107        Runner::Claude => BuiltInRunnerPlugin::Claude,
108        Runner::Kimi => BuiltInRunnerPlugin::Kimi,
109        Runner::Pi => BuiltInRunnerPlugin::Pi,
110        Runner::Cursor => BuiltInRunnerPlugin::Cursor,
111        Runner::Plugin(_) => BuiltInRunnerPlugin::Claude, // Fallback
112    }
113}
114
115pub(crate) fn get_runner_features(runner: &Runner) -> RunnerFeatures {
116    match runner {
117        Runner::Codex => RunnerFeatures {
118            reasoning_effort: true,
119            sandbox: SandboxSupport {
120                supported: true,
121                modes: vec!["default".into(), "enabled".into(), "disabled".into()],
122            },
123            plan_mode: false,
124            verbose: false,
125            approval_modes: vec!["config_file".into()], // Codex uses ~/.codex/config.json
126        },
127        Runner::Claude => RunnerFeatures {
128            reasoning_effort: false,
129            sandbox: SandboxSupport {
130                supported: false,
131                modes: vec![],
132            },
133            plan_mode: false,
134            verbose: true,
135            approval_modes: vec!["accept_edits".into(), "bypass_permissions".into()],
136        },
137        Runner::Gemini => RunnerFeatures {
138            reasoning_effort: false,
139            sandbox: SandboxSupport {
140                supported: true,
141                modes: vec!["default".into(), "enabled".into()],
142            },
143            plan_mode: false,
144            verbose: false,
145            approval_modes: vec!["yolo".into(), "auto_edit".into()],
146        },
147        Runner::Cursor => RunnerFeatures {
148            reasoning_effort: false,
149            sandbox: SandboxSupport {
150                supported: true,
151                modes: vec!["enabled".into(), "disabled".into()],
152            },
153            plan_mode: true,
154            verbose: false,
155            approval_modes: vec!["force".into()],
156        },
157        Runner::Opencode => RunnerFeatures {
158            reasoning_effort: false,
159            sandbox: SandboxSupport {
160                supported: false,
161                modes: vec![],
162            },
163            plan_mode: false,
164            verbose: false,
165            approval_modes: vec![],
166        },
167        Runner::Kimi => RunnerFeatures {
168            reasoning_effort: false,
169            sandbox: SandboxSupport {
170                supported: false,
171                modes: vec![],
172            },
173            plan_mode: false,
174            verbose: false,
175            approval_modes: vec!["yolo".into()],
176        },
177        Runner::Pi => RunnerFeatures {
178            reasoning_effort: false,
179            sandbox: SandboxSupport {
180                supported: true,
181                modes: vec!["default".into(), "enabled".into()],
182            },
183            plan_mode: false,
184            verbose: false,
185            approval_modes: vec!["print".into()],
186        },
187        Runner::Plugin(_) => RunnerFeatures {
188            reasoning_effort: false,
189            sandbox: SandboxSupport {
190                supported: false,
191                modes: vec![],
192            },
193            plan_mode: false,
194            verbose: false,
195            approval_modes: vec![],
196        },
197    }
198}
199
200fn get_allowed_models(runner: &Runner) -> Option<Vec<String>> {
201    match runner {
202        Runner::Codex => Some(vec![
203            "gpt-5.4".into(),
204            "gpt-5.3-codex".into(),
205            "gpt-5.3-codex-spark".into(),
206            "gpt-5.3".into(),
207            "gpt-5.2-codex".into(),
208            "gpt-5.2".into(),
209        ]),
210        _ => None, // All other runners support arbitrary models
211    }
212}
213
214/// Handle the `ralph runner capabilities` command.
215pub fn handle_capabilities(runner_str: &str, format: RunnerFormat) -> anyhow::Result<()> {
216    let runner: Runner = runner_str
217        .parse()
218        .map_err(|_| anyhow::anyhow!("unknown runner: {}", runner_str))?;
219
220    // Get binary name from config (use defaults for now)
221    let bin_name = get_bin_name(&runner);
222
223    let report = get_runner_capabilities(&runner, &bin_name);
224
225    match format {
226        RunnerFormat::Text => print_capabilities_text(&report),
227        RunnerFormat::Json => println!("{}", serde_json::to_string_pretty(&report)?),
228    }
229
230    Ok(())
231}
232
233fn get_bin_name(runner: &Runner) -> String {
234    match runner {
235        Runner::Codex => "codex".into(),
236        Runner::Opencode => "opencode".into(),
237        Runner::Gemini => "gemini".into(),
238        Runner::Claude => "claude".into(),
239        Runner::Cursor => "agent".into(), // Cursor uses 'agent' binary
240        Runner::Kimi => "kimi".into(),
241        Runner::Pi => "pi".into(),
242        Runner::Plugin(id) => id.clone(),
243    }
244}
245
246fn print_capabilities_text(report: &RunnerCapabilityReport) {
247    println!("Runner: {} ({})", report.name, report.runner);
248    println!();
249
250    // Binary status
251    println!("Binary:");
252    if report.binary.installed {
253        println!("  Status: installed");
254        if let Some(ref v) = report.binary.version {
255            println!("  Version: {}", v);
256        }
257    } else {
258        println!("  Status: NOT INSTALLED");
259        if let Some(ref e) = report.binary.error {
260            println!("  Error: {}", e);
261        }
262    }
263    println!();
264
265    // Models
266    println!("Models:");
267    println!("  Default: {}", report.default_model);
268    if let Some(ref models) = report.allowed_models {
269        println!("  Allowed: {}", models.join(", "));
270    } else {
271        println!("  Allowed: (any model ID)");
272    }
273    println!();
274
275    // Features
276    println!("Features:");
277    println!(
278        "  Session resume: {}",
279        if report.supports_session_resume {
280            "yes"
281        } else {
282            "no"
283        }
284    );
285    if report.requires_managed_session_id {
286        println!("  Managed session ID: required (Ralph supplies session ID)");
287    }
288    println!(
289        "  Reasoning effort: {}",
290        if report.features.reasoning_effort {
291            "yes"
292        } else {
293            "no"
294        }
295    );
296    println!(
297        "  Plan mode: {}",
298        if report.features.plan_mode {
299            "yes"
300        } else {
301            "no"
302        }
303    );
304    println!(
305        "  Verbose output: {}",
306        if report.features.verbose { "yes" } else { "no" }
307    );
308
309    // Sandbox
310    if report.features.sandbox.supported {
311        println!(
312            "  Sandbox: {} (supported)",
313            report.features.sandbox.modes.join(", ")
314        );
315    } else {
316        println!("  Sandbox: not supported");
317    }
318
319    // Approval modes
320    if !report.features.approval_modes.is_empty() {
321        println!(
322            "  Approval modes: {}",
323            report.features.approval_modes.join(", ")
324        );
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn codex_has_reasoning_effort_support() {
334        let features = get_runner_features(&Runner::Codex);
335        assert!(features.reasoning_effort);
336        assert!(!features.plan_mode);
337    }
338
339    #[test]
340    fn cursor_has_plan_mode_support() {
341        let features = get_runner_features(&Runner::Cursor);
342        assert!(features.plan_mode);
343        assert!(!features.reasoning_effort);
344    }
345
346    #[test]
347    fn codex_has_restricted_models() {
348        let report = get_runner_capabilities(&Runner::Codex, "codex");
349        assert!(report.allowed_models.is_some());
350        let models = report.allowed_models.unwrap();
351        assert!(models.contains(&"gpt-5.4".to_string()));
352        assert!(models.contains(&"gpt-5.3-codex".to_string()));
353        assert!(!models.contains(&"sonnet".to_string()));
354    }
355
356    #[test]
357    fn claude_allows_arbitrary_models() {
358        let report = get_runner_capabilities(&Runner::Claude, "claude");
359        assert!(report.allowed_models.is_none());
360    }
361
362    #[test]
363    fn kimi_requires_managed_session_id() {
364        let report = get_runner_capabilities(&Runner::Kimi, "kimi");
365        assert!(report.requires_managed_session_id);
366    }
367}