supercode-cli 0.4.6

supercode — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! CLI-level acceptance test for UX-27 ("★ first-run MCP config import from
//! Claude Code / Codex"): spawns the real built `supercode` binary against
//! a fixture `$HOME` (containing a Claude Code `~/.claude.json` and a Codex
//! `~/.codex/config.toml`) and a fresh, isolated `$SUPERCODE_HOME`, and
//! drives `supercode mcp import` / `supercode mcp list` exactly as a user
//! would. No API key / network needed — `mcp import`/`mcp list` are both
//! fully offline. Follows the `dedup_cli.rs`/`reductions_cli.rs` idiom.

use std::path::{Path, PathBuf};
use std::process::{Command, Output};

fn bin() -> PathBuf {
    PathBuf::from(env!("CARGO_BIN_EXE_supercode"))
}

fn fresh_dir(tag: &str) -> PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let dir = std::env::temp_dir().join(format!(
        "supercode-ux27-{tag}-{}-{nanos}",
        std::process::id()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// Run `supercode` with `HOME` pointed at a fixture directory (sibling
/// harness configs) and `SUPERCODE_HOME` pointed at supercode's own
/// (initially empty) config dir — mirroring a real machine where both
/// happen to be the same `$HOME` in practice, but kept separate here so the
/// test can seed/inspect each independently.
fn run(supercode_home: &Path, fake_home: &Path, args: &[&str]) -> Output {
    Command::new(bin())
        .env("SUPERCODE_HOME", supercode_home)
        .env("HOME", fake_home)
        .env_remove("OPENROUTER_API_KEY")
        .env_remove("OPENAI_API_KEY")
        .env_remove("ANTHROPIC_API_KEY")
        .args(args)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .output()
        .expect("failed to spawn the supercode binary")
}

fn stdout(out: &Output) -> String {
    String::from_utf8_lossy(&out.stdout).into_owned()
}
fn stderr(out: &Output) -> String {
    String::from_utf8_lossy(&out.stderr).into_owned()
}

/// A real (redacted) `~/.claude.json` shape: top-level `mcpServers` with one
/// stdio server (with `env`, now imported faithfully — the MCP-env
/// follow-up this file's tests were updated for) and one `http` server
/// (which supercode still can't represent at all) side by side — exactly
/// what a real Claude Code install looks like.
const CLAUDE_JSON: &str = r#"
{
  "numStartups": 4,
  "mcpServers": {
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"],
      "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "shh" }
    },
    "hosted-docs": {
      "type": "http",
      "url": "https://example.com/mcp"
    }
  },
  "projects": {}
}
"#;

/// Documented Codex `~/.codex/config.toml` shape: `[mcp_servers.<name>]`
/// tables.
const CODEX_TOML: &str = "\
[mcp_servers.filesystem]
command = \"mcp-server-filesystem\"
args = [\"/tmp\"]
";

fn seed_fixture_home() -> PathBuf {
    let home = fresh_dir("fakehome");
    std::fs::write(home.join(".claude.json"), CLAUDE_JSON).unwrap();
    std::fs::create_dir_all(home.join(".codex")).unwrap();
    std::fs::write(home.join(".codex").join("config.toml"), CODEX_TOML).unwrap();
    home
}

#[test]
fn import_maps_claude_and_codex_stdio_servers_and_reports_the_http_skip() {
    let sc_home = fresh_dir("sc1");
    let home = seed_fixture_home();

    let out = run(&sc_home, &home, &["mcp", "import"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    let so = stdout(&out);
    let se = stderr(&out);

    assert!(so.contains("github"), "stdout: {so}");
    assert!(so.contains("filesystem"), "stdout: {so}");
    assert!(so.contains("imported 2 server(s)"), "stdout: {so}");
    // env vars ARE now representable in supercode's { command, args, env }
    // schema (MCP-env follow-up) — imported, not reported as dropped.
    assert!(
        !so.contains("env var"),
        "env is imported now — no drop note expected: {so}"
    );
    // http transport must be recognized and skipped with a reason, never
    // imported.
    assert!(se.contains("hosted-docs"), "stderr: {se}");
    assert!(se.contains("http"), "stderr: {se}");

    // `mcp list` now shows both imported servers, correctly mapped —
    // including the `github` server's imported env var name.
    let out = run(&sc_home, &home, &["mcp", "list"]);
    assert!(out.status.success());
    let so = stdout(&out);
    assert!(so.contains("github"), "stdout: {so}");
    assert!(
        so.contains("npx") && so.contains("@modelcontextprotocol/server-github"),
        "stdout: {so}"
    );
    assert!(
        so.contains("GITHUB_PERSONAL_ACCESS_TOKEN"),
        "mcp list must surface the imported env var name: {so}"
    );
    assert!(so.contains("filesystem"), "stdout: {so}");
    assert!(so.contains("mcp-server-filesystem"), "stdout: {so}");
    // The http entry was never imported.
    assert!(!so.contains("hosted-docs"), "stdout: {so}");

    // The env var's VALUE round-trips byte-for-byte into mcp.json (the
    // actual import target, not just what `mcp list` chooses to print).
    let reg_path = sc_home.join("mcp.json");
    let text = std::fs::read_to_string(&reg_path).unwrap();
    let v: serde_json::Value = serde_json::from_str(&text).unwrap();
    assert_eq!(
        v["mcpServers"]["github"]["env"]["GITHUB_PERSONAL_ACCESS_TOKEN"], "shh",
        "mcp.json: {text}"
    );
    // The no-env `filesystem` server must NOT have grown a spurious `env`
    // key.
    assert!(
        v["mcpServers"]["filesystem"].get("env").is_none(),
        "a no-env import must not fabricate an env key: {text}"
    );
}

#[test]
fn import_is_idempotent_second_run_reports_no_new_servers() {
    let sc_home = fresh_dir("sc2");
    let home = seed_fixture_home();

    let first = run(&sc_home, &home, &["mcp", "import"]);
    assert!(first.status.success());
    assert!(stdout(&first).contains("imported 2 server(s)"));

    let second = run(&sc_home, &home, &["mcp", "import"]);
    assert!(second.status.success(), "stderr: {}", stderr(&second));
    let so = stdout(&second);
    assert!(
        so.contains("nothing new to import"),
        "second run must be a no-op, got: {so}"
    );

    // The registry file itself has exactly the 2 servers, not duplicated.
    let reg_path = sc_home.join("mcp.json");
    let text = std::fs::read_to_string(&reg_path).unwrap();
    let v: serde_json::Value = serde_json::from_str(&text).unwrap();
    let servers = v["mcpServers"].as_object().unwrap();
    assert_eq!(servers.len(), 2, "registry: {text}");
}

#[test]
fn import_never_clobbers_a_pre_existing_supercode_entry() {
    let sc_home = fresh_dir("sc3");
    let home = seed_fixture_home();

    // The user already has their OWN `github` server configured in
    // supercode, pointing at a different command than Claude Code's.
    let out = run(
        &sc_home,
        &home,
        &["mcp", "add", "github", "my-own-github-server"],
    );
    assert!(out.status.success(), "stderr: {}", stderr(&out));

    let out = run(&sc_home, &home, &["mcp", "import"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    let so = stdout(&out);
    let se = stderr(&out);
    // `filesystem` (no local conflict) is imported...
    assert!(so.contains("filesystem"), "stdout: {so}");
    // ...but `github` is reported as already-present and left alone.
    assert!(
        se.contains("github") && se.contains("already exists"),
        "stderr: {se}"
    );

    let out = run(&sc_home, &home, &["mcp", "list"]);
    let so = stdout(&out);
    assert!(
        so.contains("my-own-github-server"),
        "the user's own github server must survive import untouched: {so}"
    );
}

#[test]
fn dry_run_previews_without_writing_anything() {
    let sc_home = fresh_dir("sc4");
    let home = seed_fixture_home();

    let out = run(&sc_home, &home, &["mcp", "import", "--dry-run"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    assert!(stdout(&out).contains("dry-run: would import"));

    // Nothing was written — no mcp.json at all yet.
    assert!(!sc_home.join("mcp.json").exists());

    let out = run(&sc_home, &home, &["mcp", "list"]);
    assert!(
        stdout(&out).contains("no MCP servers registered"),
        "dry-run must not have imported anything: {}",
        stdout(&out)
    );
}

/// Backward-compat bar for the MCP-env follow-up: a pre-existing `mcp.json`
/// written before `env` support existed (no `env` key anywhere) must
/// survive real CLI operations — including one that re-saves the whole
/// registry (`mcp add`, adding an unrelated server) — without an `env` key
/// being fabricated onto the pre-existing entry.
#[test]
fn legacy_no_env_mcp_json_survives_a_resave_unchanged() {
    let sc_home = fresh_dir("sc6");
    let home = seed_fixture_home();
    std::fs::create_dir_all(&sc_home).unwrap();
    let legacy =
        r#"{"mcpServers":{"legacy-server":{"command":"my-legacy-cmd","args":["--flag"]}}}"#;
    std::fs::write(sc_home.join("mcp.json"), legacy).unwrap();

    // `mcp add` re-saves the ENTIRE registry (legacy entry + the new one).
    let out = run(&sc_home, &home, &["mcp", "add", "new-server", "some-cmd"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));

    let text = std::fs::read_to_string(sc_home.join("mcp.json")).unwrap();
    let v: serde_json::Value = serde_json::from_str(&text).unwrap();
    assert_eq!(v["mcpServers"]["legacy-server"]["command"], "my-legacy-cmd");
    assert!(
        v["mcpServers"]["legacy-server"].get("env").is_none(),
        "a legacy no-env entry must not grow a spurious `env` key on resave: {text}"
    );
    assert!(
        v["mcpServers"]["new-server"].get("env").is_none(),
        "`mcp add` without --env must not fabricate an env key either: {text}"
    );
}

/// `mcp add --env KEY=VALUE` stores the env, round-trips through
/// `mcp.json`, and shows up in `mcp list`.
#[test]
fn mcp_add_with_env_flag_stores_and_lists_the_env() {
    let sc_home = fresh_dir("sc7");
    let home = seed_fixture_home();

    let out = run(
        &sc_home,
        &home,
        &[
            "mcp",
            "add",
            "with-env",
            "--env",
            "API_TOKEN=abc123",
            "some-cmd",
        ],
    );
    assert!(out.status.success(), "stderr: {}", stderr(&out));

    let text = std::fs::read_to_string(sc_home.join("mcp.json")).unwrap();
    let v: serde_json::Value = serde_json::from_str(&text).unwrap();
    assert_eq!(
        v["mcpServers"]["with-env"]["env"]["API_TOKEN"], "abc123",
        "text: {text}"
    );

    let out = run(&sc_home, &home, &["mcp", "list"]);
    let so = stdout(&out);
    assert!(
        so.contains("API_TOKEN"),
        "mcp list must show the env var name: {so}"
    );
}

#[test]
fn from_claude_only_ignores_codex_servers() {
    let sc_home = fresh_dir("sc5");
    let home = seed_fixture_home();

    let out = run(&sc_home, &home, &["mcp", "import", "--from", "claude"]);
    assert!(out.status.success(), "stderr: {}", stderr(&out));
    let so = stdout(&out);
    assert!(so.contains("github"), "stdout: {so}");
    assert!(
        !so.contains("filesystem"),
        "--from claude must not pull in the codex server: {so}"
    );
}