supercode-cli 0.5.84

Volter Harness — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! UX-27: first-run / on-demand import of MCP server definitions from
//! sibling coding-agent harnesses (Claude Code, Codex) into supercode's own
//! MCP registry (`userconfig::McpServers`).
//!
//! Zero-config borrowing of a sibling harness's MCP config is exactly the
//! "interop glue tool" promise supercode is built on — see AGENTS.md. This
//! module only *reads* sibling config and produces a plan; callers
//! (`main.rs`'s `mcp import` subcommand and the first-run offer in
//! `attach_mcp`) decide whether/how to apply it via [`apply`], which never
//! overwrites an existing supercode entry.
//!
//! Source shapes (verified against a real `~/.claude.json` on a dev box —
//! see the UX-27 build report for the inspection transcript):
//! - **Claude Code** `~/.claude.json`: top-level `mcpServers` (global) *and*
//!   per-project `projects."<abs-path>".mcpServers`, plus the standalone
//!   `~/.claude/mcp.json` and `~/.mcp.json` files — all the same
//!   `{ "<name>": { "command", "args", "env"? } }` (or `{"type": "http"|"sse",
//!   "url", ...}`) shape Claude Code's own `--mcp-config` files use.
//! - **Codex** `~/.codex/config.toml`: `[mcp_servers.<name>]` tables with
//!   `command`, `args`, `env` (documented shape — no Codex config was
//!   present on the build box to inspect directly; see the build report).
//!
//! supercode's own MCP schema ([`crate::userconfig::McpServerDef`]) is
//! `{ command, args, env? }` — `env` is imported faithfully (MCP-env
//! follow-up to this ticket; see the UX-27 tracker's "Deferred/out of
//! scope" note, now resolved). `url`/http/sse is still unsupported: those
//! servers are recognized and skipped with a reason, never silently
//! dropped or fabricated as stdio.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use serde_json::Value as JsonValue;

use crate::userconfig::McpServerDef;

/// Which sibling harness a candidate/skip came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
    Claude,
    Codex,
}

impl Source {
    pub fn label(self) -> &'static str {
        match self {
            Source::Claude => "claude",
            Source::Codex => "codex",
        }
    }
}

/// A stdio server discovered in a sibling harness's config, ready to import
/// as-is into supercode's `{ command, args }` schema.
#[derive(Debug, Clone)]
pub struct Candidate {
    pub name: String,
    pub def: McpServerDef,
    pub source: Source,
    /// Where in the source config this came from (e.g. a project path), for
    /// a legible import summary. Purely informational.
    pub origin: String,
    /// Non-fatal fidelity notes for this entry. `env` vars are now imported
    /// faithfully (no longer a fidelity loss), so this is currently unused
    /// by either source scanner, but stays as the general escape hatch for
    /// any future partial-fidelity import — both callers print these
    /// alongside the candidate: `mcp_import_cmd`'s `for note in &c.notes`
    /// loop, and the first-run offer's `first_run_candidate_lines` (see
    /// `main.rs`) — before the import (or the first-run confirmation
    /// prompt) happens.
    pub notes: Vec<String>,
}

/// A source entry that was recognized but NOT imported (unsupported
/// transport, or unparseable), with a human reason. Always surfaced — never
/// silently dropped.
#[derive(Debug, Clone)]
pub struct SkippedEntry {
    pub name: String,
    pub source: Source,
    pub reason: String,
}

/// The result of scanning sibling-harness configs: what CAN be imported,
/// and what was recognized but can't be (with why).
#[derive(Debug, Default, Clone)]
pub struct ScanReport {
    pub candidates: Vec<Candidate>,
    pub skipped: Vec<SkippedEntry>,
}

/// What actually happened when a [`ScanReport`] was applied to an existing
/// registry via [`apply`].
#[derive(Debug, Default, Clone)]
pub struct ApplyOutcome {
    /// Names newly written into the registry.
    pub imported: Vec<String>,
    /// Names that already existed in the registry — left untouched
    /// (never-clobber guarantee).
    pub already_present: Vec<String>,
}

enum ParsedEntry {
    // Boxed: `McpServerDef` grew past clippy's large-enum-variant threshold
    // once P5-2 added the remote-transport/OAuth fields (§2 module 15) —
    // `Unsupported`'s `String` is comparatively tiny, so without boxing
    // every `ParsedEntry` (including every `Unsupported` one) would pay
    // `McpServerDef`'s full size.
    Stdio(Box<McpServerDef>, Vec<String>),
    Unsupported(String),
}

/// `$HOME` for the CURRENT process (not supercode's own `SUPERCODE_HOME` —
/// deliberately independent, so a test/CI harness can point supercode's own
/// config at one temp dir while pointing sibling-harness discovery at
/// another, real or fixture, `$HOME`).
pub fn home_dir() -> Option<PathBuf> {
    supercode_interchange::user_home()
        .map(std::path::PathBuf::into_os_string)
        .filter(|h| !h.is_empty())
        .map(PathBuf::from)
}

/// Scan both Claude Code and Codex sources under `home` and classify every
/// entry found into candidates/skips (dedup by name: first source to define
/// a name wins; a later duplicate — same or different harness — is
/// recorded as skipped rather than silently overwriting the plan).
pub fn scan_all(home: &Path) -> ScanReport {
    let mut entries = raw_claude_entries(home);
    entries.extend(raw_codex_entries(home));
    classify(entries)
}

fn classify(entries: Vec<(String, Source, String, ParsedEntry)>) -> ScanReport {
    let mut report = ScanReport::default();
    let mut seen: BTreeMap<String, Source> = BTreeMap::new();
    for (name, source, origin, parsed) in entries {
        if let Some(prev) = seen.get(&name) {
            report.skipped.push(SkippedEntry {
                name,
                source,
                reason: format!("duplicate server name (already found via {})", prev.label()),
            });
            continue;
        }
        match parsed {
            ParsedEntry::Stdio(def, notes) => {
                seen.insert(name.clone(), source);
                report.candidates.push(Candidate {
                    name,
                    def: *def,
                    source,
                    origin,
                    notes,
                });
            }
            ParsedEntry::Unsupported(reason) => {
                // Don't mark unsupported entries as "seen" — if a LATER
                // source defines the same name as an importable stdio
                // server, it should still be picked up.
                report.skipped.push(SkippedEntry {
                    name,
                    source,
                    reason,
                });
            }
        }
    }
    report
}

/// Scan only Claude Code sources.
pub fn scan_claude(home: &Path) -> ScanReport {
    classify(raw_claude_entries(home))
}

/// Scan only Codex sources.
pub fn scan_codex(home: &Path) -> ScanReport {
    classify(raw_codex_entries(home))
}

fn raw_claude_entries(home: &Path) -> Vec<(String, Source, String, ParsedEntry)> {
    let mut out = Vec::new();

    // `~/.claude.json`: top-level `mcpServers` (global) + per-project
    // `projects.<abs-path>.mcpServers`.
    if let Some(root) = read_json(&home.join(".claude.json")) {
        if let Some(obj) = root.get("mcpServers").and_then(JsonValue::as_object) {
            let mut names: Vec<&String> = obj.keys().collect();
            names.sort();
            for name in names {
                out.push(claude_entry(name, &obj[name], "~/.claude.json".to_string()));
            }
        }
        if let Some(projects) = root.get("projects").and_then(JsonValue::as_object) {
            let mut paths: Vec<&String> = projects.keys().collect();
            paths.sort();
            for path in paths {
                if let Some(obj) = projects[path]
                    .get("mcpServers")
                    .and_then(JsonValue::as_object)
                {
                    let mut names: Vec<&String> = obj.keys().collect();
                    names.sort();
                    for name in names {
                        out.push(claude_entry(
                            name,
                            &obj[name],
                            format!("~/.claude.json (project {path})"),
                        ));
                    }
                }
            }
        }
    }

    // Standalone flat `{ "mcpServers": {...} }` files sharing the same
    // per-server shape.
    for (rel, label) in [
        (
            PathBuf::from(".claude").join("mcp.json"),
            "~/.claude/mcp.json",
        ),
        (PathBuf::from(".mcp.json"), "~/.mcp.json"),
    ] {
        if let Some(root) = read_json(&home.join(&rel)) {
            if let Some(obj) = root.get("mcpServers").and_then(JsonValue::as_object) {
                let mut names: Vec<&String> = obj.keys().collect();
                names.sort();
                for name in names {
                    out.push(claude_entry(name, &obj[name], label.to_string()));
                }
            }
        }
    }

    out
}

fn claude_entry(
    name: &str,
    v: &JsonValue,
    origin: String,
) -> (String, Source, String, ParsedEntry) {
    let ty = v.get("type").and_then(JsonValue::as_str).unwrap_or("stdio");
    let parsed = if ty != "stdio" || v.get("url").is_some() {
        ParsedEntry::Unsupported(format!(
            "`{ty}` transport (url-based) not supported — Volter Harness's MCP config is stdio-only"
        ))
    } else {
        match v.get("command").and_then(JsonValue::as_str) {
            None => ParsedEntry::Unsupported("no `command` field".to_string()),
            Some(command) => {
                let args = v
                    .get("args")
                    .and_then(JsonValue::as_array)
                    .map(|a| {
                        a.iter()
                            .filter_map(JsonValue::as_str)
                            .map(String::from)
                            .collect()
                    })
                    .unwrap_or_default();
                let env = v
                    .get("env")
                    .and_then(JsonValue::as_object)
                    .map(|o| {
                        o.iter()
                            .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
                            .collect::<BTreeMap<String, String>>()
                    })
                    .filter(|m| !m.is_empty());
                ParsedEntry::Stdio(
                    Box::new(McpServerDef {
                        command: Some(command.to_string()),
                        args,
                        env,
                        ..Default::default()
                    }),
                    Vec::new(),
                )
            }
        }
    };
    (name.to_string(), Source::Claude, origin, parsed)
}

fn raw_codex_entries(home: &Path) -> Vec<(String, Source, String, ParsedEntry)> {
    let mut out = Vec::new();
    let path = home.join(".codex").join("config.toml");
    let Ok(text) = std::fs::read_to_string(&path) else {
        return out;
    };
    let Ok(root) = text.parse::<toml::Value>() else {
        return out;
    };
    if let Some(table) = root.get("mcp_servers").and_then(toml::Value::as_table) {
        let mut names: Vec<&String> = table.keys().collect();
        names.sort();
        for name in names {
            out.push(codex_entry(
                name,
                &table[name],
                "~/.codex/config.toml".to_string(),
            ));
        }
    }
    out
}

fn codex_entry(
    name: &str,
    v: &toml::Value,
    origin: String,
) -> (String, Source, String, ParsedEntry) {
    let parsed = if v.get("url").is_some() {
        ParsedEntry::Unsupported(
            "url-based transport not supported — Volter Harness's MCP config is stdio-only"
                .to_string(),
        )
    } else {
        match v.get("command").and_then(toml::Value::as_str) {
            None => ParsedEntry::Unsupported("no `command` field".to_string()),
            Some(command) => {
                let args = v
                    .get("args")
                    .and_then(toml::Value::as_array)
                    .map(|a| {
                        a.iter()
                            .filter_map(toml::Value::as_str)
                            .map(String::from)
                            .collect()
                    })
                    .unwrap_or_default();
                let env = v
                    .get("env")
                    .and_then(toml::Value::as_table)
                    .map(|t| {
                        t.iter()
                            .filter_map(|(k, val)| val.as_str().map(|s| (k.clone(), s.to_string())))
                            .collect::<BTreeMap<String, String>>()
                    })
                    .filter(|m| !m.is_empty());
                ParsedEntry::Stdio(
                    Box::new(McpServerDef {
                        command: Some(command.to_string()),
                        args,
                        env,
                        ..Default::default()
                    }),
                    Vec::new(),
                )
            }
        }
    };
    (name.to_string(), Source::Codex, origin, parsed)
}

fn read_json(path: &Path) -> Option<JsonValue> {
    let text = std::fs::read_to_string(path).ok()?;
    serde_json::from_str(&text).ok()
}

/// Apply a scan's candidates to an existing server map, IN PLACE. Never
/// overwrites an existing entry — a name already present is reported as
/// `already_present`, not touched. Re-applying the same report twice is a
/// no-op the second time (idempotent).
pub fn apply(report: &ScanReport, servers: &mut BTreeMap<String, McpServerDef>) -> ApplyOutcome {
    let mut outcome = ApplyOutcome::default();
    for c in &report.candidates {
        if servers.contains_key(&c.name) {
            outcome.already_present.push(c.name.clone());
        } else {
            servers.insert(c.name.clone(), c.def.clone());
            outcome.imported.push(c.name.clone());
        }
    }
    outcome
}