use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelInfo {
pub id: String,
pub display_name: String,
pub is_default: bool,
pub is_free_tier: bool,
pub context_window: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionModeInfo {
pub id: String,
pub display_name: String,
pub description: String,
pub is_default: bool,
}
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
pub struct CliFeatures {
pub thinking: bool,
pub effort_control: bool,
pub mcp: bool,
pub resume: bool,
pub continue_last: bool,
pub allowed_tools_filter: bool,
pub system_prompt_injection: bool,
pub max_turns: bool,
pub sandbox_mode: bool,
pub ide_context: bool,
pub plan_mode: bool,
pub speed_toggle: bool,
pub multi_provider: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliCapabilities {
pub tool_id: String,
pub display_name: String,
pub binary: String,
pub available_models: Vec<ModelInfo>,
pub permission_modes: Vec<PermissionModeInfo>,
pub features: CliFeatures,
}
impl CliCapabilities {
pub fn default_model(&self) -> Option<&ModelInfo> {
self.available_models
.iter()
.find(|m| m.is_default)
.or_else(|| self.available_models.first())
}
pub fn default_permission_mode(&self) -> Option<&PermissionModeInfo> {
self.permission_modes
.iter()
.find(|p| p.is_default)
.or_else(|| self.permission_modes.first())
}
}
fn model(
id: &str,
display_name: &str,
is_default: bool,
is_free_tier: bool,
context_window: Option<u64>,
) -> ModelInfo {
ModelInfo {
id: id.to_string(),
display_name: display_name.to_string(),
is_default,
is_free_tier,
context_window,
}
}
fn perm(id: &str, display_name: &str, description: &str, is_default: bool) -> PermissionModeInfo {
PermissionModeInfo {
id: id.to_string(),
display_name: display_name.to_string(),
description: description.to_string(),
is_default,
}
}
pub(crate) fn claude_capabilities() -> CliCapabilities {
CliCapabilities {
tool_id: "claude_code".to_string(),
display_name: "Claude Code".to_string(),
binary: "claude".to_string(),
available_models: vec![
model("claude-opus-4-6", "Claude Opus 4.6", false, false, Some(1_048_576)),
model("claude-sonnet-4-6", "Claude Sonnet 4.6", true, false, Some(1_048_576)),
model("claude-haiku-4-5", "Claude Haiku 4.5", false, false, Some(200_000)),
],
permission_modes: vec![
perm("default", "Default", "Standard interactive permissions; asks before file edits.", true),
perm("acceptEdits", "Accept Edits", "Auto-accepts file edits, asks for shell commands.", false),
perm("auto", "Auto", "Approves most operations automatically.", false),
perm("bypassPermissions", "Bypass Permissions", "Skips all permission checks (equivalent to --dangerously-skip-permissions).", false),
perm("plan", "Plan Mode", "Shows a plan and asks for approval before executing.", false),
],
features: CliFeatures {
thinking: true,
effort_control: true,
mcp: true,
resume: true,
continue_last: true,
allowed_tools_filter: true,
system_prompt_injection: true,
max_turns: true,
sandbox_mode: false,
ide_context: false,
plan_mode: true,
speed_toggle: false,
multi_provider: false,
},
}
}
pub(crate) fn codex_capabilities() -> CliCapabilities {
CliCapabilities {
tool_id: "codex".to_string(),
display_name: "Codex".to_string(),
binary: "codex".to_string(),
available_models: vec![
model("gpt-5.4", "GPT-5.4", true, false, Some(272_000)),
model("gpt-5.4-mini", "GPT-5.4 Mini", false, false, Some(272_000)),
model("gpt-5.3-codex", "GPT-5.3 Codex", false, false, Some(272_000)),
model("gpt-5.2", "GPT-5.2", false, false, Some(272_000)),
],
permission_modes: vec![
perm("read-only", "Read Only", "No workspace writes through the inline child.", true),
perm("workspace-write", "Workspace Write", "Allows writes inside the selected workspace.", false),
perm("danger-full-access", "Danger Full Access", "Disables the Codex sandbox for this child.", false),
],
features: CliFeatures {
thinking: false,
effort_control: false,
mcp: true,
resume: true,
continue_last: true,
allowed_tools_filter: false,
system_prompt_injection: false,
max_turns: false,
sandbox_mode: true,
ide_context: true,
plan_mode: true,
speed_toggle: true,
multi_provider: false,
},
}
}
pub(crate) fn kimi_capabilities() -> CliCapabilities {
CliCapabilities {
tool_id: "kimi".to_string(),
display_name: "Kimi Code".to_string(),
binary: "kimi".to_string(),
available_models: Vec::new(),
permission_modes: Vec::new(),
features: CliFeatures {
resume: true,
continue_last: true,
..CliFeatures::default()
},
}
}
pub(crate) fn grok_capabilities() -> CliCapabilities {
CliCapabilities {
tool_id: "grok".to_string(),
display_name: "Grok".to_string(),
binary: "grok".to_string(),
available_models: Vec::new(),
permission_modes: Vec::new(),
features: CliFeatures::default(),
}
}
fn read_codex_config_model() -> Option<String> {
let home = home_dir()?;
let path = home.join(".codex").join("config.toml");
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("model") && trimmed.contains('=') {
let val = trimmed.split('=').nth(1)?.trim().trim_matches('"');
if !val.is_empty() {
return Some(val.to_string());
}
}
}
None
}
fn update_default_model(models: &mut Vec<ModelInfo>, discovered_id: &str) {
for m in models.iter_mut() {
m.is_default = false;
}
if let Some(m) = models.iter_mut().find(|m| m.id == discovered_id) {
m.is_default = true;
} else {
models.insert(
0,
ModelInfo {
id: discovered_id.to_string(),
display_name: discovered_id.to_string(),
is_default: true,
is_free_tier: false,
context_window: None,
},
);
}
}
fn home_dir() -> Option<PathBuf> {
crate::utils::home_dir()
}
pub(crate) fn discover(
tool: crate::core::types::CliTool,
) -> CliCapabilities {
use crate::core::types::CliTool;
let mut caps = match tool {
CliTool::ClaudeCode => claude_capabilities(),
CliTool::Codex => codex_capabilities(),
CliTool::KimiCode => kimi_capabilities(),
CliTool::Grok => grok_capabilities(),
};
if let Some(cure_cache) = crate::cure::load_cure_cache() {
if let Some(cured_models) = cure_cache.tools.get(caps.tool_id.as_str()) {
apply_cure_context_windows(&mut caps.available_models, cured_models);
}
}
match tool {
CliTool::Codex => {
if let Some(model_id) = read_codex_config_model() {
update_default_model(&mut caps.available_models, &model_id);
}
}
CliTool::ClaudeCode | CliTool::KimiCode | CliTool::Grok => {}
}
caps
}
fn apply_cure_context_windows(
models: &mut Vec<ModelInfo>,
cured: &[crate::cure::CuredModel],
) {
use std::collections::HashMap;
let lookup: HashMap<&str, Option<u64>> = cured
.iter()
.map(|c| (c.id.as_str(), c.context_window))
.collect();
for m in models.iter_mut() {
if let Some(&ctx) = lookup.get(m.id.as_str()) {
if ctx.is_some() {
m.context_window = ctx;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::types::CliTool;
#[test]
fn each_tool_has_exactly_one_default_model() {
for tool in [CliTool::ClaudeCode, CliTool::Codex] {
let caps = tool.capabilities();
let default_count = caps.available_models.iter().filter(|m| m.is_default).count();
assert_eq!(
default_count,
1,
"{:?} must have exactly one default model, found {}",
tool,
default_count
);
}
}
#[test]
fn each_tool_with_modes_has_exactly_one_default_mode() {
for tool in [CliTool::ClaudeCode, CliTool::Codex] {
let caps = tool.capabilities();
let default_count = caps.permission_modes.iter().filter(|p| p.is_default).count();
assert_eq!(
default_count,
1,
"{:?} must have exactly one default permission mode, found {}",
tool,
default_count
);
}
assert!(CliTool::KimiCode.capabilities().permission_modes.is_empty());
assert!(CliTool::Grok.capabilities().permission_modes.is_empty());
}
#[test]
fn capabilities_returns_correct_tool_id() {
assert_eq!(CliTool::ClaudeCode.capabilities().tool_id, "claude_code");
assert_eq!(CliTool::Codex.capabilities().tool_id, "codex");
assert_eq!(CliTool::KimiCode.capabilities().tool_id, "kimi");
assert_eq!(CliTool::Grok.capabilities().tool_id, "grok");
}
#[test]
fn model_ids_are_nonempty() {
for tool in [CliTool::ClaudeCode, CliTool::Codex] {
let caps = tool.capabilities();
for m in &caps.available_models {
assert!(
!m.id.is_empty(),
"{:?} has a model with an empty id (display_name={})",
tool,
m.display_name
);
}
}
}
#[test]
fn claude_has_five_permission_modes() {
assert_eq!(CliTool::ClaudeCode.capabilities().permission_modes.len(), 5);
}
#[test]
fn codex_has_three_permission_modes() {
assert_eq!(CliTool::Codex.capabilities().permission_modes.len(), 3);
}
#[test]
fn claude_features_correct() {
let f = CliTool::ClaudeCode.capabilities().features;
assert!(f.thinking);
assert!(f.effort_control);
assert!(f.plan_mode);
assert!(f.mcp);
assert!(f.resume);
assert!(!f.multi_provider);
assert!(!f.sandbox_mode);
}
#[test]
fn codex_features_correct() {
let f = CliTool::Codex.capabilities().features;
assert!(f.ide_context);
assert!(f.plan_mode);
assert!(f.resume);
assert!(!f.thinking);
assert!(!f.multi_provider);
}
#[test]
fn kimi_features_correct() {
let f = CliTool::KimiCode.capabilities().features;
assert!(f.resume);
assert!(f.continue_last);
assert!(!f.thinking);
}
#[test]
fn grok_features_correct() {
let f = CliTool::Grok.capabilities().features;
assert!(!f.resume);
assert!(!f.continue_last);
assert!(!f.thinking);
}
#[test]
fn default_model_helper_works() {
for tool in [CliTool::ClaudeCode, CliTool::Codex] {
let caps = tool.capabilities();
let default_model = caps.default_model();
assert!(
default_model.is_some(),
"{:?} default_model() must return Some",
tool
);
assert!(
default_model.unwrap().is_default,
"{:?} default_model() must return a model with is_default=true",
tool
);
}
}
#[test]
fn update_default_model_marks_known_model() {
let mut models = vec![
ModelInfo { id: "a".to_string(), display_name: "A".to_string(), is_default: true, is_free_tier: false, context_window: None },
ModelInfo { id: "b".to_string(), display_name: "B".to_string(), is_default: false, is_free_tier: false, context_window: None },
];
update_default_model(&mut models, "b");
assert!(!models[0].is_default);
assert!(models[1].is_default);
}
#[test]
fn update_default_model_prepends_unknown_model() {
let mut models = vec![
ModelInfo { id: "a".to_string(), display_name: "A".to_string(), is_default: true, is_free_tier: false, context_window: None },
];
update_default_model(&mut models, "new-model");
assert_eq!(models.len(), 2);
assert_eq!(models[0].id, "new-model");
assert!(models[0].is_default);
assert!(!models[1].is_default);
}
#[test]
fn discover_returns_valid_capabilities() {
for tool in [CliTool::ClaudeCode, CliTool::Codex] {
let caps = tool.discover_capabilities();
let default_count = caps.available_models.iter().filter(|m| m.is_default).count();
assert_eq!(
default_count,
1,
"{:?} discover_capabilities must have exactly one default model, found {}",
tool,
default_count
);
assert_eq!(caps.tool_id, tool.capabilities().tool_id);
}
for tool in [CliTool::KimiCode, CliTool::Grok] {
let caps = tool.discover_capabilities();
assert_eq!(caps.tool_id, tool.capabilities().tool_id);
}
}
#[test]
fn codex_config_discovery_with_temp_file() {
let dir = std::env::temp_dir().join("gate4agent_test_codex");
std::fs::create_dir_all(&dir).unwrap();
let config_path = dir.join("config.toml");
std::fs::write(&config_path, "model = \"gpt-5-custom\"\n").unwrap();
let content = std::fs::read_to_string(&config_path).unwrap();
let mut found = None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("model") && trimmed.contains('=') {
if let Some(val) = trimmed.split('=').nth(1) {
let val = val.trim().trim_matches('"');
if !val.is_empty() {
found = Some(val.to_string());
}
}
}
}
assert_eq!(found.as_deref(), Some("gpt-5-custom"));
}
}