Vectrum
Sign In
738 lines · 27.9 KB · Rust
//! Every agent the app can run and what it offers, in one catalog the
//! pickers and settings read. Each configured provider instance gets a
//! background health check: Claude's models, account, slash commands and
//! usage limits come from a short-lived `claude` asked over its control
//! channel, Pi's models from asking `pi` for what its providers offer. Also
//! the user's favorite models.

use std::{
    io::{BufRead, BufReader, Write},
    path::Path,
    process::Stdio,
    sync::mpsc,
    time::{Duration, Instant, SystemTime},
};

use gpui_kit::*;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};

use crate::claude::{Effort, base_command};
use crate::settings::{self, ProviderInstance};

/// The kind of agent behind a thread.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Provider {
    #[default]
    Claude,
    Pi,
}

impl Provider {
    pub const ALL: [Provider; 2] = [Provider::Claude, Provider::Pi];

    pub fn label(self) -> &'static str {
        match self {
            Provider::Claude => "Claude",
            Provider::Pi => "Pi",
        }
    }

    pub fn key(self) -> &'static str {
        match self {
            Provider::Claude => "claude",
            Provider::Pi => "pi",
        }
    }

    pub fn from_key(key: &str) -> Option<Self> {
        Self::ALL.into_iter().find(|provider| provider.key() == key)
    }
}

#[derive(Clone, Debug, PartialEq)]
pub struct ModelEntry {
    pub provider: Provider,
    /// The provider instance offering it (`claude`, `claude-2`, `pi`).
    pub instance: String,
    /// What the agent is launched with: a Claude alias or model id, or Pi's
    /// `provider/model`.
    pub id: String,
    /// The concrete model, e.g. `claude-opus-5-5` behind the `opus` alias.
    pub resolved: String,
    pub name: String,
    pub detail: String,
    /// Older Claude models, listed under "Legacy models".
    pub legacy: bool,
    /// Reasoning levels the model accepts.
    pub efforts: Vec<Effort>,
    pub fast_mode: bool,
    pub auto_mode: bool,
    pub context_window: Option<u64>,
}

impl ModelEntry {
    /// The key favorites and defaults are stored under.
    pub fn key(&self) -> String {
        format!("{}:{}", self.instance, self.id)
    }
}

/// A `/` command or skill an agent offers.
#[derive(Clone, Debug, PartialEq)]
pub struct SlashCommand {
    pub name: String,
    pub description: String,
    pub argument_hint: String,
    /// Installed skills, as opposed to the agent's own commands.
    pub skill: bool,
}

#[derive(Clone, Debug, PartialEq)]
pub enum ProbeStatus {
    /// Turned off in the settings.
    Disabled,
    Checking,
    Ready,
    /// The binary isn't installed (or isn't on a path we look at).
    Missing,
    Failed(String),
}

/// What the last health check found out about one provider instance.
#[derive(Clone, Debug)]
pub struct InstanceState {
    pub config: ProviderInstance,
    pub status: ProbeStatus,
    /// A check is running; the fields keep the previous one's findings.
    pub checking: bool,
    pub models: Vec<ModelEntry>,
    /// Claude's `default` entry, which the picker shows as the model it resolves to.
    pub default: Option<ModelEntry>,
    /// The signed-in account (Claude's email).
    pub account: Option<String>,
    /// The subscription or login (`Claude Team`).
    pub plan: Option<String>,
    pub version: Option<String>,
    /// The newest published version, once an update check ran.
    pub latest: Option<String>,
    /// Claude's `get_usage` reply: plan limits and how much of them is used.
    pub usage: Option<Value>,
    pub commands: Vec<SlashCommand>,
    pub checked: Option<SystemTime>,
}

impl InstanceState {
    fn new(config: ProviderInstance) -> Self {
        Self {
            status: if config.enabled { ProbeStatus::Checking } else { ProbeStatus::Disabled },
            config,
            checking: false,
            models: Vec::new(),
            default: None,
            account: None,
            plan: None,
            version: None,
            latest: None,
            usage: None,
            commands: Vec::new(),
            checked: None,
        }
    }

    pub fn id(&self) -> &str {
        &self.config.id
    }

    pub fn label(&self) -> String {
        self.config.label()
    }

    pub fn kind(&self) -> Provider {
        self.config.kind
    }

    /// Whether a newer version than the installed one is out.
    pub fn update_available(&self) -> bool {
        match (&self.version, &self.latest) {
            (Some(version), Some(latest)) => version_newer(latest, version),
            _ => false,
        }
    }
}

pub struct Catalog {
    pub instances: Vec<InstanceState>,
    favorites: Vec<String>,
}

impl Global for Catalog {}

/// What one health check of an instance found.
#[derive(Default)]
struct Probe {
    models: Vec<ModelEntry>,
    default: Option<ModelEntry>,
    account: Option<String>,
    plan: Option<String>,
    version: Option<String>,
    usage: Option<Value>,
    commands: Vec<SlashCommand>,
}

impl Catalog {
    pub fn init(cx: &mut App) {
        let saved = settings::load();
        cx.set_global(Catalog {
            instances: saved.providers.into_iter().map(InstanceState::new).collect(),
            favorites: saved.favorites,
        });
        Self::refresh_all(cx);
    }

    pub fn global(cx: &App) -> &Catalog {
        cx.global::<Catalog>()
    }

    /// Picks up added, removed and edited instances from the settings, and
    /// checks the ones that changed.
    pub fn sync_instances(cx: &mut App) {
        let configs = settings::instances();
        let catalog = cx.global_mut::<Catalog>();
        let mut changed = Vec::new();
        let mut instances = Vec::new();
        for config in configs {
            match catalog.instances.iter().position(|state| state.config.id == config.id) {
                Some(ix) => {
                    let mut state = catalog.instances[ix].clone();
                    if state.config != config {
                        let enabled = config.enabled;
                        state.config = config;
                        if !enabled {
                            state.status = ProbeStatus::Disabled;
                        }
                        changed.push(state.config.id.clone());
                    }
                    instances.push(state);
                }
                None => {
                    changed.push(config.id.clone());
                    instances.push(InstanceState::new(config));
                }
            }
        }
        catalog.instances = instances;
        for id in changed {
            Self::refresh(&id, cx);
        }
        cx.refresh_windows();
    }

    /// Checks every enabled instance again.
    pub fn refresh_all(cx: &mut App) {
        let ids: Vec<String> = Self::global(cx).instances.iter().map(|state| state.config.id.clone()).collect();
        for id in ids {
            Self::refresh(&id, cx);
        }
    }

    /// Checks one instance in the background: its version, account, models,
    /// commands and limits.
    pub fn refresh(id: &str, cx: &mut App) {
        let update_checks = settings::get(|settings| settings.update_checks);
        let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == id) else {
            return;
        };
        if !state.config.enabled {
            state.status = ProbeStatus::Disabled;
            cx.refresh_windows();
            return;
        }
        if state.checking {
            return;
        }
        state.checking = true;
        if state.checked.is_none() {
            state.status = ProbeStatus::Checking;
        }
        let config = state.config.clone();
        cx.refresh_windows();
        let id = id.to_string();
        let probe = cx.background_executor().spawn(async move {
            let probe = match config.kind {
                Provider::Claude => probe_claude(&config),
                Provider::Pi => probe_pi(&config),
            };
            let latest = if update_checks { latest_version(config.kind) } else { None };
            (probe, latest)
        });
        cx.spawn(async move |cx| {
            let (result, latest) = probe.await;
            cx.update(|cx| {
                let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == id)
                else {
                    return;
                };
                state.checking = false;
                state.checked = Some(SystemTime::now());
                if latest.is_some() {
                    state.latest = latest;
                }
                match result {
                    Ok(probe) => {
                        state.status = ProbeStatus::Ready;
                        if !probe.models.is_empty() || state.config.kind == Provider::Pi {
                            state.models = probe.models;
                            state.default = probe.default;
                        }
                        state.account = probe.account.or(state.account.take());
                        state.plan = probe.plan.or(state.plan.take());
                        state.version = probe.version.or(state.version.take());
                        if probe.usage.is_some() {
                            state.usage = probe.usage;
                        }
                        if !probe.commands.is_empty() {
                            state.commands = probe.commands;
                        }
                    }
                    Err(status) => {
                        if state.config.enabled {
                            state.status = status;
                        }
                        if state.config.kind == Provider::Pi {
                            state.models.clear();
                        }
                    }
                }
                cx.refresh_windows();
            })
        })
        .detach();
    }

    pub fn instance(&self, id: &str) -> Option<&InstanceState> {
        self.instances.iter().find(|state| state.config.id == id)
    }

    /// The enabled instances, in the order the settings list them.
    pub fn enabled(&self) -> impl Iterator<Item = &InstanceState> {
        self.instances.iter().filter(|state| state.config.enabled)
    }

    pub fn label(&self, id: &str) -> String {
        self.instance(id).map(InstanceState::label).unwrap_or_else(|| id.to_string())
    }

    /// The first enabled instance of a kind.
    pub fn first_of(&self, kind: Provider) -> Option<&InstanceState> {
        self.enabled().find(|state| state.config.kind == kind)
    }

    /// Takes Claude's model list, account and commands from a session's
    /// `initialize` reply.
    pub fn set_claude_info(instance: &str, info: &Value, cx: &mut App) {
        let probe = claude_probe_from(instance, info);
        let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == instance)
        else {
            return;
        };
        let mut changed = false;
        if probe.account.is_some() && state.account != probe.account {
            state.account = probe.account;
            state.plan = probe.plan;
            changed = true;
        }
        if !probe.models.is_empty() && state.models != probe.models {
            state.models = probe.models;
            state.default = probe.default;
            changed = true;
        }
        if !probe.commands.is_empty() && state.commands != probe.commands {
            state.commands = probe.commands;
            changed = true;
        }
        if state.status != ProbeStatus::Ready {
            state.status = ProbeStatus::Ready;
            changed = true;
        }
        if changed {
            cx.refresh_windows();
        }
    }

    pub fn models(&self, instance: &str) -> &[ModelEntry] {
        self.instance(instance).map(|state| state.models.as_slice()).unwrap_or_default()
    }

    /// Every enabled instance's models.
    pub fn all_models(&self) -> impl Iterator<Item = &ModelEntry> {
        self.enabled().flat_map(|state| &state.models)
    }

    /// The entry for a thread's model; `None` means the provider's default.
    pub fn find(&self, instance: &str, id: Option<&str>) -> Option<&ModelEntry> {
        let state = self.instance(instance)?;
        match id {
            None => state.default.as_ref(),
            Some(id) => state.models.iter().find(|model| model.id == id),
        }
    }

    /// The entry stored under `key` (see [`ModelEntry::key`]).
    pub fn find_key(&self, key: &str) -> Option<&ModelEntry> {
        self.instances.iter().flat_map(|state| &state.models).find(|model| model.key() == key)
    }

    pub fn commands(&self, instance: &str) -> &[SlashCommand] {
        self.instance(instance).map(|state| state.commands.as_slice()).unwrap_or_default()
    }

    pub fn is_favorite(&self, model: &ModelEntry) -> bool {
        self.favorites.contains(&model.key())
    }

    pub fn favorites(&self) -> Vec<&ModelEntry> {
        self.favorites.iter().filter_map(|key| self.all_models().find(|model| &model.key() == key)).collect()
    }

    pub fn toggle_favorite(model: &ModelEntry, cx: &mut App) {
        let key = model.key();
        let catalog = cx.global_mut::<Catalog>();
        match catalog.favorites.iter().position(|favorite| *favorite == key) {
            Some(ix) => {
                catalog.favorites.remove(ix);
            }
            None => catalog.favorites.push(key),
        }
        let favorites = catalog.favorites.clone();
        settings::update(|settings| settings.favorites = favorites);
        cx.refresh_windows();
    }
}

/// Splits a model key into its instance and model id. Keys from before
/// instances existed name the kind, which is also its first instance's id.
pub fn split_key(key: &str) -> Option<(&str, &str)> {
    key.split_once(':')
}

/// Parses Claude's `initialize` reply: the models (the default entry apart),
/// the account and the commands.
fn claude_probe_from(instance: &str, info: &Value) -> Probe {
    let mut default = None;
    let mut models = Vec::new();
    for model in info.get("models").and_then(Value::as_array).into_iter().flatten() {
        let Some(id) = model.get("value").and_then(Value::as_str) else { continue };
        let text = |key: &str| model.get(key).and_then(Value::as_str).unwrap_or_default().to_string();
        let flag = |key: &str| model.get(key).and_then(Value::as_bool).unwrap_or(false);
        let display = text("displayName");
        let entry = ModelEntry {
            provider: Provider::Claude,
            instance: instance.to_string(),
            id: id.to_string(),
            resolved: Some(text("resolvedModel")).filter(|model| !model.is_empty()).unwrap_or_else(|| id.into()),
            name: if display.starts_with("Claude") { display.clone() } else { format!("Claude {display}") },
            detail: text("description"),
            // Aliases (`opus`, `sonnet`) track the current models; full ids are older ones.
            legacy: id.starts_with("claude-"),
            efforts: model
                .get("supportedEffortLevels")
                .and_then(Value::as_array)
                .map(|levels| levels.iter().filter_map(Value::as_str).filter_map(Effort::from_cli).collect())
                .unwrap_or_default(),
            fast_mode: flag("supportsFastMode"),
            auto_mode: flag("supportsAutoMode"),
            context_window: None,
        };
        if id == "default" {
            default = Some(entry);
        } else {
            models.push(entry);
        }
    }
    // Present the default as the model it resolves to.
    if let Some(default) = default.as_mut()
        && let Some(current) = models.iter().find(|model| !model.legacy && model.resolved == default.resolved)
    {
        default.name = current.name.clone();
    }
    let commands = info
        .get("commands")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|command| {
            let name = command.get("name")?.as_str()?;
            // Internal commands start with underscores.
            (!name.starts_with('_')).then(|| SlashCommand {
                name: name.to_string(),
                description: command.get("description").and_then(Value::as_str).unwrap_or_default().to_string(),
                argument_hint: command.get("argumentHint").and_then(Value::as_str).unwrap_or_default().to_string(),
                // Claude's own commands are marked builtin; skills from
                // plugins and skill folders aren't.
                skill: command.get("builtin").and_then(Value::as_bool) != Some(true),
            })
        })
        .collect();
    Probe {
        models,
        default,
        account: info.pointer("/account/email").and_then(Value::as_str).map(str::to_string),
        plan: info
            .pointer("/account/subscriptionType")
            .and_then(Value::as_str)
            .map(|plan| if plan.to_lowercase().contains("subscription") { plan.to_string() } else { format!("{plan} Subscription") }),
        version: info.get("claude_code_version").and_then(Value::as_str).map(str::to_string),
        usage: None,
        commands,
    }
}

/// Starts `claude` just long enough to ask what it offers and how much of
/// the plan's limits is used. Nothing is sent to a model.
fn probe_claude(config: &ProviderInstance) -> Result<Probe, ProbeStatus> {
    let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string());
    let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?;
    command
        .args(["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"])
        .arg("--no-session-persistence")
        .current_dir(std::env::temp_dir())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    let mut child = command.spawn().map_err(|err| failed(&err))?;
    let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?;
    let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?;
    let stderr = child.stderr.take().ok_or_else(|| failed(&"no stderr"))?;
    let request = |id: &str, subtype: &str| {
        json!({"type": "control_request", "request_id": id, "request": {"subtype": subtype}}).to_string()
    };
    writeln!(stdin, "{}", request("probe-init", "initialize")).map_err(|err| failed(&err))?;
    writeln!(stdin, "{}", request("probe-usage", "get_usage")).map_err(|err| failed(&err))?;

    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        for line in BufReader::new(stdout).lines().map_while(Result::ok) {
            if let Ok(value) = serde_json::from_str::<Value>(&line)
                && value.get("type").and_then(Value::as_str) == Some("control_response")
                && tx.send(value["response"].clone()).is_err()
            {
                return;
            }
        }
    });
    let stderr_tail = std::thread::spawn(move || {
        BufReader::new(stderr).lines().map_while(Result::ok).filter(|line| !line.trim().is_empty()).last()
    });
    let deadline = Instant::now() + Duration::from_secs(25);
    let (mut init, mut usage) = (None, None);
    while init.is_none() || usage.is_none() {
        let Some(left) = deadline.checked_duration_since(Instant::now()) else { break };
        let Ok(response) = rx.recv_timeout(left) else { break };
        let ok = response.get("subtype").and_then(Value::as_str) == Some("success");
        match response.get("request_id").and_then(Value::as_str) {
            Some("probe-init") => init = Some(ok.then(|| response["response"].clone())),
            Some("probe-usage") => usage = Some(ok.then(|| response["response"].clone())),
            _ => {}
        }
    }
    drop(stdin);
    let _ = child.kill();
    let _ = child.wait();
    let Some(Some(info)) = init else {
        let detail = stderr_tail.join().ok().flatten().unwrap_or_else(|| "Claude Code didn't answer".into());
        return Err(failed(&detail));
    };
    let mut probe = claude_probe_from(&config.id, &info);
    probe.usage = usage.flatten().filter(|usage| usage.get("rate_limits_available").and_then(Value::as_bool) != Some(false));
    Ok(probe)
}

/// Starts `pi` just long enough to list the models its providers offer, and
/// asks its version.
fn probe_pi(config: &ProviderInstance) -> Result<Probe, ProbeStatus> {
    let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string());
    let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?;
    command
        .args(["--mode", "rpc", "--no-session"])
        .current_dir(std::env::temp_dir())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::null());
    let mut child = command.spawn().map_err(|err| failed(&err))?;
    let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?;
    let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?;
    let request = json!({"id": "models", "type": "get_available_models"});
    writeln!(stdin, "{request}").map_err(|err| failed(&err))?;

    let (tx, rx) = mpsc::channel();
    std::thread::spawn(move || {
        for line in BufReader::new(stdout).lines().map_while(Result::ok) {
            if let Ok(value) = serde_json::from_str::<Value>(&line)
                && value.get("id").and_then(Value::as_str) == Some("models")
            {
                let _ = tx.send(value);
                return;
            }
        }
    });
    let reply = rx.recv_timeout(Duration::from_secs(30));
    drop(stdin);
    let _ = child.kill();
    let _ = child.wait();
    let reply = reply.map_err(|_| failed(&"pi didn't list its models"))?;
    if reply.get("success").and_then(Value::as_bool) != Some(true) {
        let error = reply.get("error").and_then(Value::as_str).unwrap_or("unknown error");
        return Err(failed(&error));
    }
    let models = reply
        .pointer("/data/models")
        .and_then(Value::as_array)
        .into_iter()
        .flatten()
        .filter_map(|model| pi_model_from(&config.id, model))
        .collect();
    let version = base_command(config).ok().and_then(|(_, mut command)| {
        let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?;
        first_version(&String::from_utf8_lossy(&output.stdout))
    });
    Ok(Probe { models, account: None, plan: None, version, ..Probe::default() })
}

pub fn pi_model_from(instance: &str, model: &Value) -> Option<ModelEntry> {
    let id = model.get("id")?.as_str()?;
    let provider = model.get("provider")?.as_str()?;
    let reasoning = model.get("reasoning").and_then(Value::as_bool).unwrap_or(false);
    Some(ModelEntry {
        provider: Provider::Pi,
        instance: instance.to_string(),
        id: format!("{provider}/{id}"),
        resolved: id.to_string(),
        name: model.get("name").and_then(Value::as_str).unwrap_or(id).to_string(),
        detail: provider_label(provider),
        legacy: false,
        efforts: if reasoning {
            vec![Effort::Off, Effort::Minimal, Effort::Low, Effort::Medium, Effort::High]
        } else {
            vec![Effort::Off]
        },
        fast_mode: false,
        auto_mode: false,
        context_window: model.get("contextWindow").and_then(Value::as_u64),
    })
}

/// The newest published version of an agent, from the npm registry.
fn latest_version(kind: Provider) -> Option<String> {
    let package = match kind {
        Provider::Claude => "@anthropic-ai/claude-code",
        Provider::Pi => "@earendil-works/pi-coding-agent",
    };
    let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?;
    serde_json::from_str::<Value>(&body).ok()?.get("version")?.as_str().map(str::to_string)
}

/// The command that updates an agent in place, and how to describe it.
pub fn update_command(config: &ProviderInstance) -> Option<(std::process::Command, &'static str)> {
    let (binary, mut command) = base_command(config).ok()?;
    match config.kind {
        Provider::Claude => {
            command.arg("update");
            Some((command, "claude update"))
        }
        Provider::Pi => {
            // Pi is an npm package; update it with the npm next to it.
            let npm = binary.parent().map(|dir| dir.join("npm")).filter(|npm| npm.exists());
            let mut command = std::process::Command::new(npm.as_deref().unwrap_or(Path::new("npm")));
            command.args(["install", "-g", "@earendil-works/pi-coding-agent@latest"]);
            Some((command, "npm install -g @earendil-works/pi-coding-agent"))
        }
    }
}

/// The first `1.2.3`-looking word in `text`.
fn first_version(text: &str) -> Option<String> {
    text.split_whitespace()
        .map(|word| word.trim_start_matches('v'))
        .find(|word| word.split('.').count() >= 2 && word.split('.').all(|part| part.parse::<u64>().is_ok()))
        .map(str::to_string)
}

/// Whether version `a` is newer than `b`, comparing numerically.
fn version_newer(a: &str, b: &str) -> bool {
    let parse = |version: &str| -> Vec<u64> {
        version.trim_start_matches('v').split(['.', '-', '+']).map_while(|part| part.parse().ok()).collect()
    };
    parse(a) > parse(b)
}

/// `openai-codex` → `OpenAI Codex`.
fn provider_label(provider: &str) -> String {
    provider
        .split(['-', '_'])
        .map(|word| match word {
            "openai" => "OpenAI".to_string(),
            "openrouter" => "OpenRouter".to_string(),
            "xai" => "xAI".to_string(),
            "ai" => "AI".to_string(),
            word => {
                let mut chars = word.chars();
                chars.next().map(|first| first.to_uppercase().chain(chars).collect()).unwrap_or_default()
            }
        })
        .collect::<Vec<_>>()
        .join(" ")
}

#[cfg(test)]
mod tests {
    use super::{claude_probe_from, first_version, provider_label, version_newer};
    use serde_json::json;

    #[::core::prelude::v1::test]
    fn provider_labels() {
        assert_eq!(provider_label("openai-codex"), "OpenAI Codex");
        assert_eq!(provider_label("anthropic"), "Anthropic");
        assert_eq!(provider_label("openrouter"), "OpenRouter");
        assert_eq!(provider_label("google_vertex"), "Google Vertex");
    }

    #[::core::prelude::v1::test]
    fn claude_default_takes_the_current_name() {
        let info = json!({
            "models": [
                {"value": "default", "resolvedModel": "claude-opus-5-5", "displayName": "Default (recommended)"},
                {"value": "opus", "resolvedModel": "claude-opus-5-5", "displayName": "Opus 5.5",
                 "supportedEffortLevels": ["low", "high"], "supportsFastMode": true},
                {"value": "claude-haiku-4-5", "displayName": "Haiku 4.5"},
            ],
            "account": {"email": "me@example.com", "subscriptionType": "Claude Team"},
            "commands": [
                {"name": "compact", "description": "Compact", "argumentHint": "", "builtin": true},
                {"name": "pdf", "description": "PDF skill", "argumentHint": ""},
                {"name": "__internal", "description": "", "argumentHint": "", "builtin": true},
            ],
            "claude_code_version": "2.1.296",
        });
        let probe = claude_probe_from("claude", &info);
        assert_eq!(probe.default.unwrap().name, "Claude Opus 5.5");
        assert_eq!(probe.models.len(), 2);
        assert!(probe.models[1].legacy);
        assert_eq!(probe.models[0].efforts.len(), 2);
        assert_eq!(probe.models[0].key(), "claude:opus");
        assert_eq!(probe.plan.as_deref(), Some("Claude Team Subscription"));
        assert_eq!(probe.commands.len(), 2);
        assert!(!probe.commands[0].skill && probe.commands[1].skill);
        assert_eq!(probe.version.as_deref(), Some("2.1.296"));
    }

    #[::core::prelude::v1::test]
    fn compares_versions() {
        assert!(version_newer("2.1.300", "2.1.296"));
        assert!(!version_newer("2.1.296", "2.1.296"));
        assert!(version_newer("1.10.0", "1.9.9"));
        assert_eq!(first_version("pi v1.1.0\n").as_deref(), Some("1.1.0"));
        assert_eq!(first_version("2.1.296 (Claude Code)").as_deref(), Some("2.1.296"));
    }
}