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-18 ("Login: hidden key input +
//! post-save validation ping"):
//!
//! - dev/01/02: the interactive prompt no longer echoes the key in plaintext
//!   (the old "input hidden is not supported, paste carefully" banner is
//!   gone) and `--api-key-stdin` stores a piped key without a prompt.
//! - dev/03: after saving, `login` performs a reachability check against
//!   `{base_url}/models` and prints a clear reachable/unreachable line,
//!   without failing the command either way.
//! - dev/04: the non-interactive `--api-key` path still works, skips the
//!   prompt, and the key lands in `credentials.toml`.
//!
//! Spawns the real, built `supercode` binary against a real local HTTP
//! stub (mirroring `quiet_cli.rs`'s / `mcp_import_cli.rs`'s idiom) — the
//! CLI, credential-saving, and reachability-probe code are the genuine,
//! unmodified binary, not a reimplementation.

use std::io::{Read, Write};
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};

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

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

/// A one-shot local HTTP server answering `GET /models` with either a 200
/// (reachable) or a 401 (rejected key) so the reachability-ping branch can
/// be exercised deterministically, with no real network access.
fn spawn_models_stub(
    status_line: &'static str,
) -> (std::net::SocketAddr, std::thread::JoinHandle<()>) {
    let listener = TcpListener::bind("127.0.0.1:0").expect("bind stub listener");
    let addr = listener.local_addr().unwrap();
    let handle = std::thread::spawn(move || {
        let (mut sock, _) = listener.accept().expect("accept one connection");
        let mut buf = [0u8; 4096];
        let _ = sock.read(&mut buf); // drain the request; path/headers unused.

        let body = "{\"data\":[]}";
        let resp = format!(
            "HTTP/1.1 {status_line}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
            body.len(),
            body
        );
        sock.write_all(resp.as_bytes())
            .expect("write stub response");
        sock.flush().ok();
    });
    (addr, handle)
}

fn run(home: &Path, stdin_data: Option<&str>, args: &[&str]) -> Output {
    let mut cmd = Command::new(bin());
    cmd.env("SUPERCODE_HOME", home)
        .env_remove("OPENROUTER_API_KEY")
        .env_remove("OPENAI_API_KEY")
        .env_remove("ANTHROPIC_API_KEY")
        .env_remove("NO_COLOR")
        .args(args)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());
    if stdin_data.is_some() {
        cmd.stdin(Stdio::piped());
    } else {
        cmd.stdin(Stdio::null());
    }
    let mut child = cmd.spawn().expect("failed to spawn the supercode binary");
    if let Some(data) = stdin_data {
        child
            .stdin
            .take()
            .expect("stdin was piped")
            .write_all(data.as_bytes())
            .expect("write to child stdin");
    }
    child.wait_with_output().expect("child process failed")
}

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

/// The exact banner text `login` used to print before UX-18 — asserting its
/// absence is the load-bearing check that the key is no longer flagged as
/// unmaskable/echoed.
const OLD_ECHO_WARNING: &str = "input hidden is not supported";

#[test]
fn non_interactive_api_key_flag_skips_prompt_saves_key_and_pings_reachability() {
    let (addr, server) = spawn_models_stub("200 OK");
    let home = fresh_home("flag-reachable");

    let out = run(
        &home,
        None,
        &[
            "login",
            "--api-key",
            "sk-flag-test",
            "--base-url",
            &format!("http://{addr}"),
        ],
    );

    assert!(
        out.status.success(),
        "login --api-key failed: status={:?} stderr={}",
        out.status,
        stderr(&out)
    );
    let creds = std::fs::read_to_string(home.join("credentials.toml")).expect("credentials.toml");
    assert!(
        creds.contains("sk-flag-test"),
        "expected the key to be persisted, got: {creds}"
    );

    let err = stderr(&out);
    assert!(
        !err.contains(OLD_ECHO_WARNING),
        "the old plaintext-echo warning must be gone, got: {err}"
    );
    assert!(
        err.contains("reachable (200"),
        "expected a reachable(200) ping line, got: {err}"
    );

    server.join().expect("stub server thread panicked");
}

#[test]
fn unreachable_or_rejected_key_still_saves_and_reports_the_failure_without_erroring() {
    let (addr, server) = spawn_models_stub("401 Unauthorized");
    let home = fresh_home("flag-rejected");

    let out = run(
        &home,
        None,
        &[
            "login",
            "--api-key",
            "sk-bad-key",
            "--base-url",
            &format!("http://{addr}"),
        ],
    );

    assert!(
        out.status.success(),
        "login must still succeed (save is independent of the ping): status={:?} stderr={}",
        out.status,
        stderr(&out)
    );
    let creds = std::fs::read_to_string(home.join("credentials.toml")).expect("credentials.toml");
    assert!(
        creds.contains("sk-bad-key"),
        "the key must be saved even when the provider rejects it"
    );

    let err = stderr(&out);
    assert!(
        err.contains("HTTP 401") && err.contains("key saved"),
        "expected an HTTP 401 rejection line with a save acknowledgement, got: {err}"
    );

    server.join().expect("stub server thread panicked");
}

#[test]
fn api_key_stdin_reads_the_piped_key_without_a_prompt_banner() {
    let (addr, server) = spawn_models_stub("200 OK");
    let home = fresh_home("stdin");

    let out = run(
        &home,
        Some("sk-from-stdin\n"),
        &[
            "login",
            "--api-key-stdin",
            "--base-url",
            &format!("http://{addr}"),
        ],
    );

    assert!(
        out.status.success(),
        "login --api-key-stdin failed: status={:?} stderr={}",
        out.status,
        stderr(&out)
    );
    let creds = std::fs::read_to_string(home.join("credentials.toml")).expect("credentials.toml");
    assert!(
        creds.contains("sk-from-stdin"),
        "expected the piped key to be persisted, got: {creds}"
    );

    let err = stderr(&out);
    assert!(
        !err.contains("API key (OpenRouter"),
        "no interactive prompt banner should appear for --api-key-stdin, got: {err}"
    );

    server.join().expect("stub server thread panicked");
}

#[test]
fn api_key_and_api_key_stdin_are_mutually_exclusive() {
    let home = fresh_home("conflict");
    let out = run(&home, None, &["login", "--api-key", "x", "--api-key-stdin"]);
    assert!(
        !out.status.success(),
        "clap should reject combining --api-key and --api-key-stdin"
    );
    let err = stderr(&out);
    assert!(
        err.contains("cannot be used with"),
        "expected clap's conflicts_with message, got: {err}"
    );
}