use serde::Serialize;
use crate::cli::runner::RunnerFormat;
use crate::contracts::Runner;
use crate::runner::default_model_for_runner;
use crate::runner::{BuiltInRunnerPlugin, RunnerPlugin};
use super::detection::check_runner_binary;
#[derive(Debug, Clone, Serialize)]
pub struct RunnerCapabilityReport {
pub runner: String,
pub name: String,
pub supports_session_resume: bool,
pub requires_managed_session_id: bool,
pub features: RunnerFeatures,
pub allowed_models: Option<Vec<String>>,
pub default_model: String,
pub binary: BinaryInfo,
}
#[derive(Debug, Clone, Serialize)]
pub struct RunnerFeatures {
pub reasoning_effort: bool,
pub sandbox: SandboxSupport,
pub plan_mode: bool,
pub verbose: bool,
pub approval_modes: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SandboxSupport {
pub supported: bool,
pub modes: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BinaryInfo {
pub installed: bool,
pub version: Option<String>,
pub error: Option<String>,
}
pub fn get_runner_capabilities(runner: &Runner, bin_name: &str) -> RunnerCapabilityReport {
let plugin = runner_to_plugin(runner);
let metadata = plugin.metadata();
let binary_status = check_runner_binary(bin_name);
let binary_info = BinaryInfo {
installed: binary_status.installed,
version: binary_status.version,
error: binary_status.error,
};
let features = get_runner_features(runner);
let allowed_models = get_allowed_models(runner);
let default_model = default_model_for_runner(runner);
RunnerCapabilityReport {
runner: runner.id().to_string(),
name: metadata.name,
supports_session_resume: metadata.supports_resume,
requires_managed_session_id: plugin.requires_managed_session_id(),
features,
allowed_models,
default_model: default_model.as_str().to_string(),
binary: binary_info,
}
}
fn runner_to_plugin(runner: &Runner) -> BuiltInRunnerPlugin {
match runner {
Runner::Codex => BuiltInRunnerPlugin::Codex,
Runner::Opencode => BuiltInRunnerPlugin::Opencode,
Runner::Gemini => BuiltInRunnerPlugin::Gemini,
Runner::Claude => BuiltInRunnerPlugin::Claude,
Runner::Kimi => BuiltInRunnerPlugin::Kimi,
Runner::Pi => BuiltInRunnerPlugin::Pi,
Runner::Cursor => BuiltInRunnerPlugin::Cursor,
Runner::Plugin(_) => BuiltInRunnerPlugin::Claude, }
}
pub(crate) fn get_runner_features(runner: &Runner) -> RunnerFeatures {
match runner {
Runner::Codex => RunnerFeatures {
reasoning_effort: true,
sandbox: SandboxSupport {
supported: true,
modes: vec!["default".into(), "enabled".into(), "disabled".into()],
},
plan_mode: false,
verbose: false,
approval_modes: vec!["config_file".into()], },
Runner::Claude => RunnerFeatures {
reasoning_effort: false,
sandbox: SandboxSupport {
supported: false,
modes: vec![],
},
plan_mode: false,
verbose: true,
approval_modes: vec!["accept_edits".into(), "bypass_permissions".into()],
},
Runner::Gemini => RunnerFeatures {
reasoning_effort: false,
sandbox: SandboxSupport {
supported: true,
modes: vec!["default".into(), "enabled".into()],
},
plan_mode: false,
verbose: false,
approval_modes: vec!["yolo".into(), "auto_edit".into()],
},
Runner::Cursor => RunnerFeatures {
reasoning_effort: false,
sandbox: SandboxSupport {
supported: true,
modes: vec!["enabled".into(), "disabled".into()],
},
plan_mode: true,
verbose: false,
approval_modes: vec!["force".into()],
},
Runner::Opencode => RunnerFeatures {
reasoning_effort: false,
sandbox: SandboxSupport {
supported: false,
modes: vec![],
},
plan_mode: false,
verbose: false,
approval_modes: vec![],
},
Runner::Kimi => RunnerFeatures {
reasoning_effort: false,
sandbox: SandboxSupport {
supported: false,
modes: vec![],
},
plan_mode: false,
verbose: false,
approval_modes: vec!["yolo".into()],
},
Runner::Pi => RunnerFeatures {
reasoning_effort: false,
sandbox: SandboxSupport {
supported: true,
modes: vec!["default".into(), "enabled".into()],
},
plan_mode: false,
verbose: false,
approval_modes: vec!["print".into()],
},
Runner::Plugin(_) => RunnerFeatures {
reasoning_effort: false,
sandbox: SandboxSupport {
supported: false,
modes: vec![],
},
plan_mode: false,
verbose: false,
approval_modes: vec![],
},
}
}
fn get_allowed_models(runner: &Runner) -> Option<Vec<String>> {
match runner {
Runner::Codex => Some(vec![
"gpt-5.4".into(),
"gpt-5.3-codex".into(),
"gpt-5.3-codex-spark".into(),
"gpt-5.3".into(),
]),
_ => None, }
}
pub fn handle_capabilities(runner_str: &str, format: RunnerFormat) -> anyhow::Result<()> {
let runner: Runner = runner_str
.parse()
.map_err(|_| anyhow::anyhow!("unknown runner: {}", runner_str))?;
let bin_name = get_bin_name(&runner);
let report = get_runner_capabilities(&runner, &bin_name);
match format {
RunnerFormat::Text => print_capabilities_text(&report),
RunnerFormat::Json => println!("{}", serde_json::to_string_pretty(&report)?),
}
Ok(())
}
fn get_bin_name(runner: &Runner) -> String {
match runner {
Runner::Codex => "codex".into(),
Runner::Opencode => "opencode".into(),
Runner::Gemini => "gemini".into(),
Runner::Claude => "claude".into(),
Runner::Cursor => "agent".into(), Runner::Kimi => "kimi".into(),
Runner::Pi => "pi".into(),
Runner::Plugin(id) => id.clone(),
}
}
fn print_capabilities_text(report: &RunnerCapabilityReport) {
println!("Runner: {} ({})", report.name, report.runner);
println!();
println!("Binary:");
if report.binary.installed {
println!(" Status: installed");
if let Some(ref v) = report.binary.version {
println!(" Version: {}", v);
}
} else {
println!(" Status: NOT INSTALLED");
if let Some(ref e) = report.binary.error {
println!(" Error: {}", e);
}
}
println!();
println!("Models:");
println!(" Default: {}", report.default_model);
if let Some(ref models) = report.allowed_models {
println!(" Allowed: {}", models.join(", "));
} else {
println!(" Allowed: (any model ID)");
}
println!();
println!("Features:");
println!(
" Session resume: {}",
if report.supports_session_resume {
"yes"
} else {
"no"
}
);
if report.requires_managed_session_id {
println!(" Managed session ID: required (Ralph supplies session ID)");
}
println!(
" Reasoning effort: {}",
if report.features.reasoning_effort {
"yes"
} else {
"no"
}
);
println!(
" Plan mode: {}",
if report.features.plan_mode {
"yes"
} else {
"no"
}
);
println!(
" Verbose output: {}",
if report.features.verbose { "yes" } else { "no" }
);
if report.features.sandbox.supported {
println!(
" Sandbox: {} (supported)",
report.features.sandbox.modes.join(", ")
);
} else {
println!(" Sandbox: not supported");
}
if !report.features.approval_modes.is_empty() {
println!(
" Approval modes: {}",
report.features.approval_modes.join(", ")
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn codex_has_reasoning_effort_support() {
let features = get_runner_features(&Runner::Codex);
assert!(features.reasoning_effort);
assert!(!features.plan_mode);
}
#[test]
fn cursor_has_plan_mode_support() {
let features = get_runner_features(&Runner::Cursor);
assert!(features.plan_mode);
assert!(!features.reasoning_effort);
}
#[test]
fn codex_has_restricted_models() {
let report = get_runner_capabilities(&Runner::Codex, "codex");
assert!(report.allowed_models.is_some());
let models = report.allowed_models.unwrap();
assert!(models.contains(&"gpt-5.4".to_string()));
assert!(models.contains(&"gpt-5.3-codex".to_string()));
assert!(!models.contains(&"sonnet".to_string()));
}
#[test]
fn claude_allows_arbitrary_models() {
let report = get_runner_capabilities(&Runner::Claude, "claude");
assert!(report.allowed_models.is_none());
}
#[test]
fn kimi_requires_managed_session_id() {
let report = get_runner_capabilities(&Runner::Kimi, "kimi");
assert!(report.requires_managed_session_id);
}
}