supercode-cli 0.4.13

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-32 ("first-run onboarding flow").
//!
//! `maybe_onboard`'s interactive body (`crates/cli/src/main.rs`) is
//! deliberately gated on BOTH stdin and stderr being real terminals
//! (`should_onboard`, unit-tested directly in `main.rs`'s
//! `onboarding_gate_tests`), and `std::process::Command`-spawned children
//! never get a real pty without one, so this file cannot drive the
//! interactive prompts themselves — that's covered by a manual pty
//! transcript (see the UX-32 build report) plus the in-crate unit tests.
//! What IS provable against the real, unmodified binary without a pty:
//! the two machine-safety guarantees (never blocks/never onboards off a
//! tty) and the "must not corrupt an existing config" guarantee — all
//! three below spawn the actual built `supercode` binary, mirroring
//! `mcp_import_cli.rs`/`quiet_cli.rs`'s idiom.

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

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-ux32-{tag}-{}-{nanos}",
        std::process::id()
    ));
    std::fs::create_dir_all(&dir).unwrap();
    dir
}

/// Run `supercode` with an isolated `SUPERCODE_HOME`/`HOME`, no provider
/// env vars, and `extra_args` appended. stdin/stdout/stderr are all real
/// OS pipes (never a tty) — exactly what a script/CI runner gets, which is
/// the condition under test.
fn run_non_tty(supercode_home: &Path, home: &Path, extra_args: &[&str]) -> Output {
    Command::new(bin())
        .env("SUPERCODE_HOME", supercode_home)
        .env("HOME", home)
        .env_remove("OPENROUTER_API_KEY")
        .env_remove("OPENAI_API_KEY")
        .env_remove("ANTHROPIC_API_KEY")
        .args(extra_args)
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .expect("failed to spawn the supercode binary")
}

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

/// UX-32 dev/03 + the "must never hang a script" bar: a fresh install (no
/// `config.toml`, no key anywhere) invoked the way a script/CI would —
/// `run` with piped/closed stdin — must NOT show the onboarding banner,
/// must NOT block (this test itself would hang if it did — `Output`
/// doesn't return until the child exits), and must fail with EXACTLY the
/// pre-UX-32 `require_api_key` message. Also: a scripted invocation must
/// not spend the "first run" — no `config.toml` gets written just because
/// a machine, not a human, hit this path first.
#[test]
fn non_tty_fresh_run_never_onboards_and_keeps_the_original_no_key_error() {
    let sc_home = fresh_dir("nontty-sc");
    let home = fresh_dir("nontty-home");

    let out = run_non_tty(&sc_home, &home, &["run", "hello"]);
    assert!(
        !out.status.success(),
        "a genuinely key-less run must still fail: stderr={}",
        stderr(&out)
    );
    let se = stderr(&out);
    assert!(
        se.contains("no API key found"),
        "must keep the pre-UX-32 require_api_key message verbatim, got: {se}"
    );
    assert!(
        !se.to_lowercase().contains("welcome to supercode"),
        "a non-tty run must never show the onboarding banner: {se}"
    );
    assert!(
        !sc_home.join("config.toml").exists(),
        "a scripted/non-tty invocation must not spend the first run — \
         config.toml must stay absent so a later interactive run still onboards"
    );
}

/// Same guarantee, explicitly with `--output-format json` and `--yes` —
/// the exact machine-mode combination the AC calls out ("a fresh `run
/// --output-format json` with no key still errors cleanly as today, no
/// wizard"). `--yes` only affects answers WITHIN an already-interactive
/// onboarding session; it must never be read as "pretend this is a tty".
#[test]
fn non_tty_json_output_with_yes_flag_still_never_onboards() {
    let sc_home = fresh_dir("nontty-json-sc");
    let home = fresh_dir("nontty-json-home");

    let out = run_non_tty(
        &sc_home,
        &home,
        &["--yes", "run", "--output-format", "json", "hello"],
    );
    assert!(!out.status.success());
    let se = stderr(&out);
    assert!(
        se.contains("no API key found"),
        "machine mode must be byte-identical to pre-UX-32 behavior: {se}"
    );
    assert!(
        !se.to_lowercase().contains("welcome to supercode"),
        "--yes must not be treated as an interactivity override: {se}"
    );
}

/// UX-32's "must NOT corrupt existing config" guarantee, plus the marker
/// gate's effect proven end-to-end against the real binary: seed a
/// `config.toml` with real custom values (as if onboarding — or plain
/// `supercode login --model ...` — already ran once), then invoke `run`
/// again. The file must come back byte-for-byte unchanged (never
/// re-onboarded, never silently rewritten/reformatted) and the ordinary
/// (non-onboarding) error path proceeds exactly as it does for any other
/// already-configured install.
#[test]
fn existing_config_is_never_touched_by_a_later_invocation() {
    let sc_home = fresh_dir("existing-sc");
    let home = fresh_dir("existing-home");
    std::fs::create_dir_all(&sc_home).unwrap();
    let config_text =
        "model = \"anthropic/claude-opus-4-8\"\nbase_url = \"https://openrouter.ai/api/v1\"\n";
    std::fs::write(sc_home.join("config.toml"), config_text).unwrap();

    let out = run_non_tty(&sc_home, &home, &["run", "hello"]);
    assert!(!out.status.success());
    let se = stderr(&out);
    assert!(
        se.contains("no API key found"),
        "an already-configured install with no key must still fail the same way: {se}"
    );
    assert!(
        !se.to_lowercase().contains("welcome to supercode"),
        "onboarding must never fire once config.toml exists: {se}"
    );

    let after = std::fs::read_to_string(sc_home.join("config.toml")).unwrap();
    assert_eq!(
        after, config_text,
        "an existing config.toml must be byte-for-byte untouched"
    );
}

/// Run `supercode <args>` under `script(1)` so stdin AND stderr are BOTH
/// real ptys — the interactive shape `should_onboard` gates on — with
/// nothing on the other end to type: `script`'s own stdin is `/dev/null`,
/// mirroring `notify_cli.rs::run_under_pty` (see that file's doc comment
/// for exactly how `script` wires a driverless pty up; this box's `script`
/// is util-linux 2.38.1). Bounded by `deadline` itself, independent of any
/// outer test-runner timeout: if `maybe_onboard`'s "never hang on stdin
/// that can't answer" guarantee regresses, THIS call kills the child and
/// fails with a clear message instead of hanging the whole `cargo test`
/// run the way the UX-32/UX-22 union integration bug once did (see
/// `main.rs`'s `prompt_yes_no` doc comment for the two failure shapes it
/// now handles).
fn run_under_driverless_pty(home: &Path, args: &[&str], deadline: Duration) -> (bool, String) {
    run_under_driverless_pty_with_env(home, args, &[], deadline)
}

/// Same as [`run_under_driverless_pty`], but with extra environment
/// variables layered on top of the same isolated/scrubbed base env (e.g. an
/// `OPENROUTER_API_KEY` so a `--yes` onboarding run takes the "reuse an
/// existing key" branch instead of `login`'s own separate stdin read).
fn run_under_driverless_pty_with_env(
    home: &Path,
    args: &[&str],
    envs: &[(&str, &str)],
    deadline: Duration,
) -> (bool, String) {
    let wrapper = home.join("run.sh");
    let mut body = format!("#!/bin/sh\nexec {:?}", bin());
    for a in args {
        body.push_str(&format!(" {a:?}"));
    }
    body.push('\n');
    std::fs::write(&wrapper, body).expect("write wrapper");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
    }

    let mut cmd = Command::new("script");
    #[cfg(target_os = "macos")]
    cmd.args(["-qe", "/dev/null", &wrapper.display().to_string()]);
    #[cfg(not(target_os = "macos"))]
    cmd.args(["-qec", &wrapper.display().to_string(), "/dev/null"]);
    cmd.env("SUPERCODE_HOME", home)
        // MCP auto-discovery consults Claude/Codex files below HOME after
        // onboarding completes. Without isolating those roots, a --yes test
        // can import and spawn the developer's real MCP servers, leaking
        // host config and hanging this unrelated driverless-pty assertion.
        .env("HOME", home)
        .env("XDG_CONFIG_HOME", home.join("config"))
        .env("XDG_DATA_HOME", home.join("data"))
        .env_remove("OPENROUTER_API_KEY")
        .env_remove("OPENAI_API_KEY")
        .env_remove("ANTHROPIC_API_KEY")
        .stdin(Stdio::null())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    for (k, v) in envs {
        cmd.env(k, v);
    }
    let mut child = cmd.spawn().expect("failed to spawn script(1)");

    let deadline_at = std::time::Instant::now() + deadline;
    loop {
        if child
            .try_wait()
            .expect("try_wait should not error")
            .is_some()
        {
            break;
        }
        if std::time::Instant::now() >= deadline_at {
            let _ = child.kill();
            let _ = child.wait();
            panic!(
                "supercode did not exit within {deadline:?} under a driverless pty — the \
                 \"onboarding never hangs when stdin can't answer\" guarantee regressed \
                 (see main.rs's prompt_yes_no + notify_cli.rs's module doc)"
            );
        }
        std::thread::sleep(Duration::from_millis(20));
    }

    let out = child
        .wait_with_output()
        .expect("collect output from an already-exited child");
    let combined = format!(
        "{}{}",
        String::from_utf8_lossy(&out.stdout),
        String::from_utf8_lossy(&out.stderr)
    );
    (out.status.success(), combined)
}

/// The UX-32/UX-22 union integration bug, reproduced and pinned: a genuine
/// first run (no `config.toml`) under a pty that LOOKS fully interactive
/// (`should_onboard`'s stdin+stderr tty gate passes — this is exactly what
/// `notify_cli.rs::run_under_pty` sets up for its own, unrelated UX-22
/// assertions) but has nothing on the other end typing anything. Before the
/// fix, `prompt_yes_no`'s `Ok(0)` (genuine EOF) read result was treated as
/// an implicit YES, driving onboarding into `pick_onboarding_model`'s
/// raw-terminal picker, which then blocked forever reading keystrokes that
/// would never come — this exact shape hung `cargo test -p supercode-cli
/// --test notify_cli` indefinitely. `run_under_driverless_pty`'s own
/// deadline turns a regression into a fast, clear test failure instead of
/// a hung suite.
///
/// A `--base-url` doesn't even need to point at a real server: the
/// onboarding banner fires (tty-shaped stdin), but with no genuine input to
/// answer "yes" to, it must degrade to the SAME decline path a human typing
/// "n" gets — banner shown, "skipped" printed, a default `config.toml`
/// marker written so it won't re-ask — and the run then falls through to
/// the ordinary, pre-UX-32 `require_api_key` failure, since no provider key
/// is present either.
#[test]
fn first_run_under_a_driverless_pty_declines_gracefully_and_never_hangs() {
    let home = fresh_dir("driverless-pty");

    let (ok, transcript) = run_under_driverless_pty(
        &home,
        &["--base-url", "http://127.0.0.1:1", "run", "hello"],
        Duration::from_secs(30),
    );

    assert!(
        !ok,
        "a genuinely key-less run must still fail even after declining onboarding: {transcript}"
    );
    assert!(
        transcript.to_lowercase().contains("welcome to supercode"),
        "the banner must still fire for a tty-shaped stdin — this proves the fix didn't just \
         suppress onboarding outright, only its hang on undriven input: {transcript}"
    );
    assert!(
        transcript.contains("skipped"),
        "with no genuine input to answer 'yes' to, onboarding must degrade to the same \
         decline path a human typing 'n' gets, not silently proceed: {transcript}"
    );
    assert!(
        transcript.contains("no API key found"),
        "after declining, the run must fall through to the ordinary require_api_key failure: \
         {transcript}"
    );
    assert!(
        home.join("config.toml").exists(),
        "the decline marker must be written so this pty doesn't get re-asked on a future run"
    );
}

/// The REOPEN this ticket fixes: `--yes` on a fresh install under a
/// driverless pty used to hang FOREVER, unlike the plain (no `--yes`)
/// case above. `maybe_onboard`'s `cli.yes || prompt_yes_no(...)` gates
/// short-circuit on `cli.yes` — bypassing BOTH Fix-1's `Ok(0)` → decline
/// AND `stdin_ready_within`'s poll-bounded wait — and proceeded straight
/// into `pick_onboarding_model` → `picker::pick` (`picker.rs`), an
/// unbounded raw-mode `read_one` loop whose old EOF arm was `else {
/// continue }`: on this same already-closed pty every read returns EOF
/// (0 bytes) immediately, so that loop spun forever, never blocking on
/// I/O, never returning. `--yes` means "accept defaults without
/// prompting" — it must never draw an interactive picker at all. Fixed
/// two ways (see `main.rs`'s `pick_onboarding_model` and `picker.rs`'s
/// `pick`): the primary fix short-circuits `pick_onboarding_model` to
/// `uc::DEFAULT_MODEL` outright under `cli.yes`, and a defensive
/// hardening makes `picker::pick` itself treat read EOF as Cancel for
/// every OTHER caller (e.g. the resume picker) that can still reach it.
///
/// `OPENROUTER_API_KEY` is set so `maybe_onboard` takes the "reuse an
/// existing key" branch (also `cli.yes`-gated, also short-circuited) —
/// this isolates the assertion to the picker hang the ticket is about,
/// not `login`'s own separate (canonical-mode, already EOF-safe, and
/// explicitly out of scope here) hidden-line read.
///
/// Fails-before proof: reverting either `pick_onboarding_model`'s
/// `cli.yes` short-circuit or `picker::pick`'s EOF→Cancel arm reproduces
/// the pre-fix hang, and `run_under_driverless_pty_with_env`'s own
/// deadline turns that into a fast, clear panic instead of wedging this
/// whole test binary — exactly the shape the plain (no `--yes`) test
/// above already guards, extended to the `--yes` path this ticket adds.
#[test]
fn yes_flag_first_run_under_a_driverless_pty_completes_and_takes_the_default_model() {
    let home = fresh_dir("yes-driverless-pty");

    let (_ok, transcript) = run_under_driverless_pty_with_env(
        &home,
        &["--yes", "--base-url", "http://127.0.0.1:1", "run", "hello"],
        &[("OPENROUTER_API_KEY", "x")],
        Duration::from_secs(30),
    );

    // The headline guarantee: this returned at all within the deadline
    // (a hang panics inside the helper above, failing this test with a
    // clear message rather than wedging `cargo test` the way the
    // pre-fix bug did) — everything below further pins WHAT it did.
    assert!(
        transcript.to_lowercase().contains("welcome to supercode"),
        "the banner must still fire for a tty-shaped stdin: {transcript}"
    );
    assert!(
        !transcript.contains("pick a default model"),
        "--yes must never draw the interactive model picker at all (not even one that then \
         gets auto-cancelled) — this is the picker's own header text: {transcript}"
    );
    assert!(
        !transcript.contains("↑/↓ or j/k to move"),
        "--yes must never draw the picker's key-hint chrome: {transcript}"
    );
    assert!(
        !transcript.contains("cancelled — using the default model"),
        "--yes takes the default model directly (pick_onboarding_model's own cli.yes \
         short-circuit), not via a picker call that happens to get cancelled: {transcript}"
    );

    let config_text = std::fs::read_to_string(home.join("config.toml"))
        .expect("--yes onboarding must write config.toml (the reuse branch saves it directly)");
    assert!(
        config_text.contains("anthropic/claude-opus-4-8"),
        "--yes must land on uc::DEFAULT_MODEL when no model was explicitly chosen: {config_text}"
    );
    assert!(
        home.join("credentials.toml").exists(),
        "the reuse branch must have saved the OPENROUTER_API_KEY env key: {transcript}"
    );
}