supercode-cli 0.4.16

supercode — a lightweight, fully-customizable AI coding agent CLI in Rust. Any model via OpenRouter; natively continues Claude Code and Codex sessions.
//! ORCH-11 dev/01 acceptance at the CLI: `supercode skills list` reads the
//! skill packages each harness has installed, from that harness's own roots,
//! and `--json` emits the RPC rows verbatim.
//!
//! Fully offline. Hermes and OpenClaw are read from the committed fixture
//! homes the way a real install reaches them (`HERMES_HOME`,
//! `OPENCLAW_STATE_DIR`); Claude Code is read from a temp home
//! (`CLAUDE_CONFIG_DIR`) plus a temp working tree, so the user and project
//! scopes are both exercised without touching any real harness home.

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

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

fn fixtures() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../harness/tests/fixtures")
}

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

fn write_skill(root: &Path, name: &str, body: &str) {
    let dir = root.join(name);
    std::fs::create_dir_all(&dir).unwrap();
    std::fs::write(dir.join("SKILL.md"), body).unwrap();
}

/// Every harness home is pinned at an absent path unless the caller names it,
/// so a listing can never fall through to this machine's real installs.
fn run(cwd: &Path, envs: &[(&str, PathBuf)], args: &[&str]) -> Output {
    let home = scratch("home");
    let mut command = Command::new(bin());
    command
        .current_dir(cwd)
        .env("SUPERCODE_HOME", home.join("sessions"))
        .env("HOME", &home)
        .env("CLAUDE_CONFIG_DIR", home.join("absent-claude"))
        .env("CODEX_HOME", home.join("absent-codex"))
        .env("OPENCODE_CONFIG_DIR", home.join("absent-opencode"))
        .env("PI_CODING_AGENT_DIR", home.join("absent-pi"))
        .env("HERMES_HOME", home.join("absent-hermes"))
        .env("OPENCLAW_STATE_DIR", home.join("absent-openclaw"))
        .env("NO_COLOR", "1")
        .env_remove("OPENROUTER_API_KEY");
    for (key, value) in envs {
        command.env(key, value);
    }
    let output = command
        .args(args)
        .stdin(std::process::Stdio::null())
        .output()
        .expect("supercode binary runs");
    let _ = std::fs::remove_dir_all(&home);
    output
}

fn stdout(cwd: &Path, envs: &[(&str, PathBuf)], args: &[&str]) -> String {
    let output = run(cwd, envs, args);
    assert!(
        output.status.success(),
        "`supercode {}` failed: {}",
        args.join(" "),
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout).unwrap()
}

fn rows(cwd: &Path, envs: &[(&str, PathBuf)], args: &[&str]) -> Vec<serde_json::Value> {
    serde_json::from_str(&stdout(cwd, envs, args)).expect("skills list --json is a JSON array")
}

#[test]
fn skills_list_json_reads_the_hermes_and_openclaw_fixture_homes() {
    let fixtures = fixtures();
    let cwd = scratch("cwd");
    let rows = rows(
        &cwd,
        &[
            ("HERMES_HOME", fixtures.join("hermes_home")),
            ("OPENCLAW_STATE_DIR", fixtures.join("openclaw_home")),
        ],
        &["skills", "list", "--json"],
    );

    let arxiv = rows
        .iter()
        .find(|row| row["name"] == "arxiv-search")
        .unwrap_or_else(|| panic!("no arxiv-search row in {rows:#?}"));
    assert_eq!(arxiv["harness"], "hermes");
    assert_eq!(arxiv["scope"], "user");
    assert_eq!(arxiv["version"], "1.4.0");
    assert!(arxiv["description"]
        .as_str()
        .unwrap()
        .contains("Search arXiv"));

    // A skill directory with no SKILL.md still lists, by directory name.
    let bare = rows
        .iter()
        .find(|row| row["name"] == "bare-skill")
        .unwrap_or_else(|| panic!("no bare-skill row in {rows:#?}"));
    assert_eq!(bare["harness"], "hermes");
    assert_eq!(bare["enabled"], serde_json::Value::Null);

    let demo = rows
        .iter()
        .find(|row| row["name"] == "clawhub-demo")
        .unwrap_or_else(|| panic!("no clawhub-demo row in {rows:#?}"));
    assert_eq!(demo["harness"], "openclaw");
    assert_eq!(demo["scope"], "managed");
    assert_eq!(demo["enabled"], false);

    let _ = std::fs::remove_dir_all(&cwd);
}

#[test]
fn skills_list_table_names_harness_name_scope_and_location() {
    let fixtures = fixtures();
    let cwd = scratch("cwd");
    let table = stdout(
        &cwd,
        &[("HERMES_HOME", fixtures.join("hermes_home"))],
        &["skills", "list", "--harness", "hermes"],
    );
    let row = table
        .lines()
        .find(|line| line.contains("arxiv-search"))
        .unwrap_or_else(|| panic!("no arxiv-search row in:\n{table}"));
    assert!(row.contains("hermes"), "{row}");
    assert!(row.contains("user"), "{row}");
    assert!(row.contains("hermes_home/skills/research/arxiv"), "{row}");
    let _ = std::fs::remove_dir_all(&cwd);
}

/// Claude Code from a temp home: the personal root and the project root under
/// the working tree, each in its own scope.
#[test]
fn skills_list_reads_a_claude_code_temp_home_and_project_root() {
    let claude_home = scratch("claude");
    write_skill(
        &claude_home.join("skills"),
        "note-taker",
        "---\nname: note-taker\ndescription: Take notes.\nversion: 2.0.0\n---\n\nbody\n",
    );
    let project = scratch("project");
    // Bound the ancestor walk at this working tree, the way a repo does.
    std::fs::create_dir_all(project.join(".git")).unwrap();
    write_skill(
        &project.join(".claude").join("skills"),
        "repo-runbook",
        "---\nname: repo-runbook\ndescription: Runbook for this repo.\n---\n\nbody\n",
    );

    let listed = rows(
        &project,
        &[("CLAUDE_CONFIG_DIR", claude_home.clone())],
        &["skills", "list", "--harness", "claude-code", "--json"],
    );
    let personal = listed
        .iter()
        .find(|row| row["name"] == "note-taker")
        .unwrap_or_else(|| panic!("no note-taker row in {listed:#?}"));
    assert_eq!(personal["harness"], "claude-code");
    assert_eq!(personal["scope"], "user");
    assert_eq!(personal["version"], "2.0.0");

    let repo = listed
        .iter()
        .find(|row| row["name"] == "repo-runbook")
        .unwrap_or_else(|| panic!("no repo-runbook row in {listed:#?}"));
    assert_eq!(repo["scope"], "project");
    assert_eq!(repo["version"], serde_json::Value::Null);

    // `--scope` selects against the same rows.
    let project_only = rows(
        &project,
        &[("CLAUDE_CONFIG_DIR", claude_home.clone())],
        &[
            "skills",
            "list",
            "--harness",
            "claude-code",
            "--scope",
            "project",
            "--json",
        ],
    );
    assert!(
        project_only.iter().all(|row| row["scope"] == "project"),
        "{project_only:#?}"
    );
    assert!(project_only.iter().any(|row| row["name"] == "repo-runbook"));

    let _ = std::fs::remove_dir_all(&claude_home);
    let _ = std::fs::remove_dir_all(&project);
}

/// A harness supercode has no skills root for is refused by name — the
/// uniform-verb contract, never a silent empty listing.
#[test]
fn skills_list_refuses_an_unknown_harness() {
    let cwd = scratch("cwd");
    let output = run(&cwd, &[], &["skills", "list", "--harness", "notaharness"]);
    assert!(!output.status.success());
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(stderr.contains("notaharness"), "{stderr}");
    assert!(stderr.contains("unsupported action"), "{stderr}");
    let _ = std::fs::remove_dir_all(&cwd);
}