codex-cli-captain 0.0.1

Codex-Cli-Captain runtime, installer, and MCP server for Codex CLI.
use serde_json::{Map, Value};
use std::fs;
use std::path::{Path, PathBuf};

fn repo_root() -> PathBuf {
    let source_root = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("ccc crate should live under rust/ccc-mcp")
        .to_path_buf();
    if source_root.join(".codex-plugin/plugin.json").exists() {
        return source_root;
    }

    Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/plugin")
}

fn source_repo_root() -> Option<PathBuf> {
    let source_root = Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("ccc crate should live under rust/ccc-mcp")
        .to_path_buf();
    source_root
        .join("skills/cap/SKILL.md")
        .exists()
        .then_some(source_root)
}

fn read_json(path: &Path) -> Value {
    let text = fs::read_to_string(path).unwrap_or_else(|error| {
        panic!("read {}: {error}", path.display());
    });
    serde_json::from_str(&text).unwrap_or_else(|error| {
        panic!("parse {}: {error}", path.display());
    })
}

fn assert_plugin_relative_path(value: &Value, field: &str) {
    let path = value
        .get(field)
        .and_then(Value::as_str)
        .unwrap_or_else(|| panic!("manifest should include {field}"));
    assert!(
        path.starts_with("./"),
        "manifest {field} should be plugin-root relative and ./-prefixed"
    );
}

fn is_server_config(value: &Value) -> bool {
    value
        .get("command")
        .and_then(Value::as_str)
        .is_some_and(|command| !command.is_empty())
}

fn mcp_server_map(value: &Value) -> &Map<String, Value> {
    if let Some(map) = value.get("mcpServers").and_then(Value::as_object) {
        return map;
    }

    if let Some(map) = value.get("mcp_servers").and_then(Value::as_object) {
        return map;
    }

    value
        .as_object()
        .filter(|map| !map.is_empty() && map.values().all(is_server_config))
        .expect(".mcp.json should be a direct server map or a wrapped mcpServers/mcp_servers map")
}

fn read_plugin_skill(root: &Path) -> String {
    let skill_path = root.join("skills/ccc/SKILL.md");
    fs::read_to_string(&skill_path).unwrap_or_else(|error| {
        panic!("read {}: {error}", skill_path.display());
    })
}

fn assert_skill_contains(skill: &str, expected: &str) {
    assert!(
        skill.contains(expected),
        "plugin skill should include workflow contract text: {expected}"
    );
}

#[test]
fn ccc_plugin_manifest_points_to_root_relative_package_assets() {
    let root = repo_root();
    let manifest_path = root.join(".codex-plugin/plugin.json");
    let manifest = read_json(&manifest_path);

    assert_eq!(manifest["name"], "ccc");
    assert_eq!(manifest["mcpServers"], "./.mcp.json");
    assert_eq!(manifest["skills"], "./skills/");
    assert_plugin_relative_path(&manifest, "mcpServers");
    assert_plugin_relative_path(&manifest, "skills");

    let codex_plugin_entries = fs::read_dir(root.join(".codex-plugin"))
        .expect("read .codex-plugin")
        .map(|entry| {
            entry
                .expect("read .codex-plugin entry")
                .file_name()
                .to_string_lossy()
                .into_owned()
        })
        .collect::<Vec<_>>();
    assert_eq!(codex_plugin_entries, vec!["plugin.json"]);
}

#[test]
fn ccc_plugin_mcp_launcher_starts_ccc_stdio_server() {
    let root = repo_root();
    let mcp = read_json(&root.join(".mcp.json"));
    let servers = mcp_server_map(&mcp);
    let ccc = servers
        .get("ccc")
        .expect("ccc MCP server should be declared");

    assert_eq!(ccc["command"], "ccc");
    assert_eq!(ccc["args"], serde_json::json!(["mcp"]));
}

#[test]
fn ccc_plugin_bundled_skill_preserves_cap_entrypoint() {
    let root = repo_root();
    let skill = read_plugin_skill(&root);

    assert!(skill.contains("name: ccc"));
    assert!(skill.contains("$cap"));
    assert!(skill.contains("public CCC entry point"));
    assert!(!skill.contains("name: cap"));
}

#[test]
fn ccc_plugin_bundled_skill_encodes_workflow_loop_contract() {
    let root = repo_root();
    let skill = read_plugin_skill(&root);

    assert_skill_contains(&skill, "start a CCC run");
    assert_skill_contains(&skill, "Create or refresh the CCC plan");
    assert_skill_contains(&skill, "bounded task cards");
    assert_skill_contains(&skill, "CCC role agents");
    assert_skill_contains(&skill, "wait for");
    assert_skill_contains(&skill, "fan-in");
    assert_skill_contains(&skill, "status or projection");
    assert_skill_contains(&skill, "review gate");
    assert_skill_contains(&skill, "bounded retry or replan");
    assert_skill_contains(&skill, "phase, role, and result updates");
    assert_skill_contains(&skill, "final summary");
}

#[test]
fn ccc_plugin_bundled_skill_keeps_plugin_invocation_secondary() {
    let root = repo_root();
    let skill = read_plugin_skill(&root);

    assert_skill_contains(&skill, "Keep plugin UI invocation secondary");
    assert_skill_contains(&skill, "Plugin UI controls may help discovery");
    assert_skill_contains(&skill, "not replacements for");
    assert_skill_contains(&skill, "the `$cap` entry point");
}

#[test]
fn crates_io_packaged_cap_skill_asset_matches_public_skill() {
    let crate_asset = fs::read_to_string(
        Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/skills/cap/SKILL.md"),
    )
    .expect("read crate cap skill asset");

    if let Some(root) = source_repo_root() {
        let public_skill =
            fs::read_to_string(root.join("skills/cap/SKILL.md")).expect("read public cap skill");
        assert_eq!(crate_asset, public_skill);
    } else {
        assert!(crate_asset.contains("name: cap"));
        assert!(crate_asset.contains("public CCC entry point"));
    }
}

#[test]
fn crates_io_packaged_ssl_manifest_assets_match_public_manifests() {
    let crate_asset_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/skills/ssl");
    let manifest_source_dir = source_repo_root()
        .map(|root| root.join("skills/ssl"))
        .unwrap_or_else(|| crate_asset_dir.clone());
    let mut manifest_names = fs::read_dir(&manifest_source_dir)
        .unwrap_or_else(|error| panic!("read {}: {error}", manifest_source_dir.display()))
        .map(|entry| {
            entry
                .expect("read public manifest entry")
                .file_name()
                .to_string_lossy()
                .into_owned()
        })
        .filter(|name| name.ends_with(".skill.ssl.json"))
        .collect::<Vec<_>>();
    manifest_names.sort();

    assert_eq!(manifest_names.len(), 8);
    for manifest_name in manifest_names {
        let public_manifest = fs::read_to_string(manifest_source_dir.join(&manifest_name))
            .expect("read public manifest");
        let crate_asset = fs::read_to_string(crate_asset_dir.join(&manifest_name))
            .expect("read crate manifest asset");

        assert_eq!(crate_asset, public_manifest, "{manifest_name}");
    }
}