use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AdapterId {
ClaudeCode,
Codex,
Gemini,
}
impl AdapterId {
pub fn as_str(self) -> &'static str {
match self {
AdapterId::ClaudeCode => "claude-code",
AdapterId::Codex => "codex",
AdapterId::Gemini => "gemini",
}
}
pub fn display_name(self) -> &'static str {
match self {
AdapterId::ClaudeCode => "Claude Code",
AdapterId::Codex => "Codex CLI",
AdapterId::Gemini => "Gemini CLI",
}
}
pub fn all() -> &'static [AdapterId] {
&[AdapterId::ClaudeCode, AdapterId::Codex, AdapterId::Gemini]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AuthKind {
Subscription,
ApiKey,
#[default]
Unknown,
Unauthenticated,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Capabilities {
#[serde(default)]
pub tool_use: bool,
#[serde(default)]
pub mcp: bool,
#[serde(default)]
pub hooks: bool,
#[serde(default)]
pub sessions: bool,
#[serde(default)]
pub streaming: bool,
#[serde(default)]
pub images: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum ExecutableStatus {
#[default]
Runnable,
Unusable {
reason: String,
checked_at: u64,
},
}
impl ExecutableStatus {
pub fn unusable_reason(&self) -> Option<&str> {
match self {
ExecutableStatus::Runnable => None,
ExecutableStatus::Unusable { reason, .. } => Some(reason.as_str()),
}
}
pub fn is_runnable(&self) -> bool {
matches!(self, ExecutableStatus::Runnable)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalAgentSpec {
pub id: String,
pub display_name: String,
pub binary_path: PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
#[serde(default)]
pub auth_kind: AuthKind,
pub capabilities: Capabilities,
#[serde(default)]
pub detected_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health: Option<crate::health::ExternalAgentHealth>,
#[serde(default)]
pub execution: ExecutableStatus,
}
impl ExternalAgentSpec {
pub fn unusable_reason(&self) -> Option<&str> {
if let Some(reason) = self.execution.unusable_reason() {
return Some(reason);
}
let health = self.health.as_ref()?;
if health.status != crate::health::HealthStatus::NotExecutable {
return None;
}
Some(
health
.reason
.as_deref()
.unwrap_or("binary is not executable"),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::health::{ExternalAgentHealth, HealthStatus};
fn spec_with(status: Option<HealthStatus>) -> ExternalAgentSpec {
ExternalAgentSpec {
id: "codex".to_string(),
display_name: "Codex CLI".to_string(),
binary_path: "/opt/homebrew/bin/codex".into(),
version: None,
auth_kind: AuthKind::Unknown,
capabilities: Capabilities::default(),
detected_at: 0,
health: status.map(|s| ExternalAgentHealth {
id: "codex".to_string(),
status: s,
details: serde_json::Value::Object(Default::default()),
reason: Some("killed by signal 9".to_string()),
checked_at: 0,
}),
execution: ExecutableStatus::Runnable,
}
}
#[test]
fn unusable_reason_only_fires_for_not_executable() {
assert_eq!(
spec_with(Some(HealthStatus::NotExecutable)).unusable_reason(),
Some("killed by signal 9")
);
for ok in [
HealthStatus::Ready,
HealthStatus::NotConfigured,
HealthStatus::Expired,
HealthStatus::NetworkError,
HealthStatus::Unknown,
] {
assert_eq!(spec_with(Some(ok)).unusable_reason(), None, "{ok:?}");
}
assert_eq!(spec_with(None).unusable_reason(), None);
}
}
#[cfg(test)]
mod execution_axis_tests {
use super::*;
use crate::health::{ExternalAgentHealth, HealthStatus};
fn base() -> ExternalAgentSpec {
ExternalAgentSpec {
id: "codex".to_string(),
display_name: "Codex CLI".to_string(),
binary_path: "/opt/homebrew/bin/codex".into(),
version: None,
auth_kind: AuthKind::default(),
capabilities: Capabilities::default(),
detected_at: 0,
health: None,
execution: ExecutableStatus::Runnable,
}
}
#[test]
fn a_health_refresh_cannot_overwrite_the_execution_verdict() {
let mut spec = base();
spec.execution = ExecutableStatus::Unusable {
reason: "quarantined at /opt/homebrew/bin/codex".into(),
checked_at: 42,
};
spec.health = Some(ExternalAgentHealth {
id: "codex".into(),
status: HealthStatus::Unknown,
details: serde_json::json!({}),
reason: None,
checked_at: 99,
});
assert_eq!(
spec.unusable_reason(),
Some("quarantined at /opt/homebrew/bin/codex"),
"a health probe must not be able to erase the execution verdict"
);
}
#[test]
fn a_runnable_binary_with_auth_problems_is_still_runnable() {
let mut spec = base();
for status in [
HealthStatus::Unknown,
HealthStatus::NotConfigured,
HealthStatus::Expired,
HealthStatus::Ready,
] {
spec.health = Some(ExternalAgentHealth {
id: "codex".into(),
status,
details: serde_json::json!({}),
reason: Some("some auth detail".into()),
checked_at: 1,
});
assert_eq!(
spec.unusable_reason(),
None,
"{status:?} is not an exec fault"
);
}
}
#[test]
fn a_pre_split_spec_is_still_understood() {
let json = serde_json::json!({
"id": "codex",
"display_name": "Codex CLI",
"binary_path": "/opt/homebrew/bin/codex",
"auth_kind": "unknown",
"capabilities": {},
"detected_at": 0,
"health": {
"id": "codex",
"status": "not_executable",
"details": {},
"reason": "quarantined at /opt/homebrew/bin/codex",
"checked_at": 7
}
});
let spec: ExternalAgentSpec =
serde_json::from_value(json).expect("pre-split spec must deserialize");
assert!(
spec.execution.is_runnable(),
"absent `execution` reads as Runnable"
);
assert_eq!(
spec.unusable_reason(),
Some("quarantined at /opt/homebrew/bin/codex"),
"the compatibility fallback must still honour the old shape"
);
}
#[test]
fn execution_defaults_to_runnable_and_adds_no_bytes_for_the_common_case() {
let spec = base();
let wire = serde_json::to_value(&spec).unwrap();
assert_eq!(wire["execution"]["state"], "runnable");
assert!(spec.execution.is_runnable());
}
#[test]
fn unusable_round_trips_on_the_wire() {
let mut spec = base();
spec.execution = ExecutableStatus::Unusable {
reason: "bad".into(),
checked_at: 5,
};
let wire = serde_json::to_value(&spec).unwrap();
assert_eq!(wire["execution"]["state"], "unusable");
assert_eq!(wire["execution"]["reason"], "bad");
let back: ExternalAgentSpec = serde_json::from_value(wire).unwrap();
assert_eq!(back.execution, spec.execution);
}
}