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        ]),
208        _ => None, // All other runners support arbitrary models
209    }
210}
211
212/// Handle the `ralph runner capabilities` command.
213pub fn handle_capabilities(runner_str: &str, format: RunnerFormat) -> anyhow::Result<()> {
214    let runner: Runner = runner_str
215        .parse()
216        .map_err(|_| anyhow::anyhow!("unknown runner: {}", runner_str))?;
217
218    // Get binary name from config (use defaults for now)
219    let bin_name = get_bin_name(&runner);
220
221    let report = get_runner_capabilities(&runner, &bin_name);
222
223    match format {
224        RunnerFormat::Text => print_capabilities_text(&report),
225        RunnerFormat::Json => println!("{}", serde_json::to_string_pretty(&report)?),
226    }
227
228    Ok(())
229}
230
231fn get_bin_name(runner: &Runner) -> String {
232    match runner {
233        Runner::Codex => "codex".into(),
234        Runner::Opencode => "opencode".into(),
235        Runner::Gemini => "gemini".into(),
236        Runner::Claude => "claude".into(),
237        Runner::Cursor => "agent".into(), // Cursor uses 'agent' binary
238        Runner::Kimi => "kimi".into(),
239        Runner::Pi => "pi".into(),
240        Runner::Plugin(id) => id.clone(),
241    }
242}
243
244fn print_capabilities_text(report: &RunnerCapabilityReport) {
245    println!("Runner: {} ({})", report.name, report.runner);
246    println!();
247
248    // Binary status
249    println!("Binary:");
250    if report.binary.installed {
251        println!("  Status: installed");
252        if let Some(ref v) = report.binary.version {
253            println!("  Version: {}", v);
254        }
255    } else {
256        println!("  Status: NOT INSTALLED");
257        if let Some(ref e) = report.binary.error {
258            println!("  Error: {}", e);
259        }
260    }
261    println!();
262
263    // Models
264    println!("Models:");
265    println!("  Default: {}", report.default_model);
266    if let Some(ref models) = report.allowed_models {
267        println!("  Allowed: {}", models.join(", "));
268    } else {
269        println!("  Allowed: (any model ID)");
270    }
271    println!();
272
273    // Features
274    println!("Features:");
275    println!(
276        "  Session resume: {}",
277        if report.supports_session_resume {
278            "yes"
279        } else {
280            "no"
281        }
282    );
283    if report.requires_managed_session_id {
284        println!("  Managed session ID: required (Ralph supplies session ID)");
285    }
286    println!(
287        "  Reasoning effort: {}",
288        if report.features.reasoning_effort {
289            "yes"
290        } else {
291            "no"
292        }
293    );
294    println!(
295        "  Plan mode: {}",
296        if report.features.plan_mode {
297            "yes"
298        } else {
299            "no"
300        }
301    );
302    println!(
303        "  Verbose output: {}",
304        if report.features.verbose { "yes" } else { "no" }
305    );
306
307    // Sandbox
308    if report.features.sandbox.supported {
309        println!(
310            "  Sandbox: {} (supported)",
311            report.features.sandbox.modes.join(", ")
312        );
313    } else {
314        println!("  Sandbox: not supported");
315    }
316
317    // Approval modes
318    if !report.features.approval_modes.is_empty() {
319        println!(
320            "  Approval modes: {}",
321            report.features.approval_modes.join(", ")
322        );
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn codex_has_reasoning_effort_support() {
332        let features = get_runner_features(&Runner::Codex);
333        assert!(features.reasoning_effort);
334        assert!(!features.plan_mode);
335    }
336
337    #[test]
338    fn cursor_has_plan_mode_support() {
339        let features = get_runner_features(&Runner::Cursor);
340        assert!(features.plan_mode);
341        assert!(!features.reasoning_effort);
342    }
343
344    #[test]
345    fn codex_has_restricted_models() {
346        let report = get_runner_capabilities(&Runner::Codex, "codex");
347        assert!(report.allowed_models.is_some());
348        let models = report.allowed_models.unwrap();
349        assert!(models.contains(&"gpt-5.4".to_string()));
350        assert!(models.contains(&"gpt-5.3-codex".to_string()));
351        assert!(!models.contains(&"sonnet".to_string()));
352    }
353
354    #[test]
355    fn claude_allows_arbitrary_models() {
356        let report = get_runner_capabilities(&Runner::Claude, "claude");
357        assert!(report.allowed_models.is_none());
358    }
359
360    #[test]
361    fn kimi_requires_managed_session_id() {
362        let report = get_runner_capabilities(&Runner::Kimi, "kimi");
363        assert!(report.requires_managed_session_id);
364    }
365}