codex-cli-captain 0.0.10

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

const SSL_MANIFEST_VERSION: &str = "ccc.skill_ssl_manifest.v1";
const SUPPORTED_SSL_MANIFEST_DOCUMENT_VERSION: &str = "0.1";

pub(crate) fn load_skill_ssl_manifest_for_agent(agent_name: &str) -> Value {
    let Some(path) = resolve_skill_ssl_manifest_path(agent_name) else {
        return missing_manifest(agent_name);
    };
    load_skill_ssl_manifest_from_path(agent_name, &path)
}

pub(crate) fn load_skill_ssl_manifest_from_path(agent_name: &str, path: &Path) -> Value {
    let raw = match fs::read_to_string(path) {
        Ok(raw) => raw,
        Err(error) => {
            return invalid_manifest(
                agent_name,
                Some(path),
                format!("Unable to read skill SSL manifest: {error}"),
            )
        }
    };
    let manifest: Value = match serde_json::from_str(&raw) {
        Ok(manifest) => manifest,
        Err(error) => {
            return invalid_manifest(
                agent_name,
                Some(path),
                format!("Unable to parse skill SSL manifest JSON: {error}"),
            )
        }
    };
    validate_skill_ssl_manifest(agent_name, Some(path), manifest)
}

fn validate_skill_ssl_manifest(agent_name: &str, path: Option<&Path>, manifest: Value) -> Value {
    let Some(version) = manifest
        .get("version")
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|value| !value.is_empty())
    else {
        return invalid_manifest(
            agent_name,
            path,
            "Skill SSL manifest is missing required string field: version".to_string(),
        );
    };
    if version != SUPPORTED_SSL_MANIFEST_DOCUMENT_VERSION {
        return classified_manifest(
            agent_name,
            path,
            "stale",
            format!(
                "Skill SSL manifest version {version} is not the supported version {SUPPORTED_SSL_MANIFEST_DOCUMENT_VERSION}.",
            ),
        );
    }

    let manifest_skill_id = manifest
        .get("skill_id")
        .and_then(Value::as_str)
        .map(str::trim)
        .unwrap_or_default();
    if manifest_skill_id != agent_name {
        return classified_manifest(
            agent_name,
            path,
            "drift_detected",
            format!(
                "Skill SSL manifest skill_id {manifest_skill_id:?} does not match requested agent {agent_name}.",
            ),
        );
    }

    let missing_sections = ["scheduling", "structural", "logical"]
        .into_iter()
        .filter(|section| !manifest.get(*section).is_some_and(Value::is_object))
        .map(str::to_string)
        .collect::<Vec<_>>();
    if !missing_sections.is_empty() {
        return invalid_manifest(
            agent_name,
            path,
            format!(
                "Skill SSL manifest is missing object section(s): {}",
                missing_sections.join(", ")
            ),
        );
    }

    let missing_required_fields = missing_required_manifest_fields(&manifest);
    if !missing_required_fields.is_empty() {
        return invalid_manifest(
            agent_name,
            path,
            format!(
                "Skill SSL manifest is missing required field(s): {}",
                missing_required_fields.join(", ")
            ),
        );
    }

    json!({
        "schema": SSL_MANIFEST_VERSION,
        "status": "available",
        "blocking": false,
        "source": "skill_ssl_manifest",
        "agent_name": agent_name,
        "path": path.map(|value| value.to_string_lossy().to_string()).unwrap_or_default(),
        "runtime_truth": false,
        "advisory_only": true,
        "manifest_version": version,
        "fallback": "SKILL.md + ccc-config.toml",
        "display_name": manifest.get("display_name").cloned().unwrap_or(Value::Null),
        "callsign": manifest.get("callsign").cloned().unwrap_or(Value::Null),
        "theme": manifest.get("theme").cloned().unwrap_or(Value::Null),
        "inspired_by": manifest.get("inspired_by").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
        "recommended_workflows": manifest.get("recommended_workflows").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
        "lsp_capabilities": manifest.get("lsp_capabilities").cloned().unwrap_or_else(|| Value::Array(Vec::new())),
        "scheduling": manifest.get("scheduling").cloned().unwrap_or(Value::Null),
        "structural": manifest.get("structural").cloned().unwrap_or(Value::Null),
        "logical": manifest.get("logical").cloned().unwrap_or(Value::Null),
    })
}

fn missing_required_manifest_fields(manifest: &Value) -> Vec<String> {
    let mut missing = Vec::new();
    require_string(manifest, "skill_id", &mut missing);
    require_string(manifest, "display_name", &mut missing);
    require_string(manifest, "callsign", &mut missing);
    require_string(manifest, "theme", &mut missing);
    require_array(manifest, "inspired_by", &mut missing);
    require_array(manifest, "recommended_workflows", &mut missing);
    require_array(manifest, "lsp_capabilities", &mut missing);
    require_string(manifest, "scheduling.role_family", &mut missing);
    require_string(manifest, "scheduling.display_agent_id", &mut missing);
    require_array(manifest, "scheduling.intent_signatures", &mut missing);
    require_array(manifest, "scheduling.expected_inputs", &mut missing);
    require_array(manifest, "scheduling.expected_outputs", &mut missing);
    require_bool(manifest, "scheduling.mutation_allowed", &mut missing);
    require_array(manifest, "structural.scenes", &mut missing);
    require_array(manifest, "logical.actions", &mut missing);
    require_bool(manifest, "logical.requires_operator_approval", &mut missing);
    require_bool(manifest, "logical.external_side_effects", &mut missing);
    missing
}

fn manifest_field<'a>(manifest: &'a Value, dotted_path: &str) -> Option<&'a Value> {
    dotted_path
        .split('.')
        .try_fold(manifest, |current, key| current.get(key))
}

fn require_string(manifest: &Value, dotted_path: &str, missing: &mut Vec<String>) {
    if !manifest_field(manifest, dotted_path).is_some_and(|value| {
        value
            .as_str()
            .map(str::trim)
            .is_some_and(|value| !value.is_empty())
    }) {
        missing.push(dotted_path.to_string());
    }
}

fn require_array(manifest: &Value, dotted_path: &str, missing: &mut Vec<String>) {
    if !manifest_field(manifest, dotted_path).is_some_and(Value::is_array) {
        missing.push(dotted_path.to_string());
    }
}

fn require_bool(manifest: &Value, dotted_path: &str, missing: &mut Vec<String>) {
    if !manifest_field(manifest, dotted_path).is_some_and(Value::is_boolean) {
        missing.push(dotted_path.to_string());
    }
}

fn resolve_skill_ssl_manifest_path(agent_name: &str) -> Option<PathBuf> {
    skill_ssl_manifest_directories()
        .into_iter()
        .map(|directory| directory.join(format!("{agent_name}.skill.ssl.json")))
        .find(|path| path.exists())
}

fn skill_ssl_manifest_directories() -> Vec<PathBuf> {
    let mut directories = Vec::new();
    if let Ok(directory) = env::var("CCC_SKILL_SSL_MANIFEST_DIR") {
        let trimmed = directory.trim();
        if !trimmed.is_empty() {
            directories.push(PathBuf::from(trimmed));
        }
    }

    // Keep the first slice non-invasive: manifests are optional sidecar files
    // near CCC source/package assets, not a replacement for SKILL.md or config.
    if let Ok(current_dir) = env::current_dir() {
        directories.push(current_dir.join("skills").join("ssl"));
        directories.push(
            current_dir
                .join("Codex-Cli-Captain")
                .join("skills")
                .join("ssl"),
        );
    }
    if let Ok(exe) = env::current_exe() {
        directories.extend(executable_ancestor_skill_ssl_directories(&exe));
    }
    directories.push(
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("assets")
            .join("skills")
            .join("ssl"),
    );

    directories
}

fn executable_ancestor_skill_ssl_directories(exe: &Path) -> Vec<PathBuf> {
    let normalized = fs::canonicalize(exe).unwrap_or_else(|_| exe.to_path_buf());
    normalized
        .ancestors()
        .take(6)
        .map(|ancestor| ancestor.join("skills").join("ssl"))
        .collect()
}

fn missing_manifest(agent_name: &str) -> Value {
    json!({
        "schema": SSL_MANIFEST_VERSION,
        "status": "missing",
        "blocking": false,
        "source": "skill_ssl_manifest",
        "agent_name": agent_name,
        "runtime_truth": false,
        "advisory_only": true,
        "fallback": "SKILL.md + ccc-config.toml",
        "reason": "Optional skill SSL manifest was not found; existing routing and risk rules remain authoritative.",
    })
}

fn invalid_manifest(agent_name: &str, path: Option<&Path>, reason: String) -> Value {
    classified_manifest(agent_name, path, "invalid", reason)
}

fn classified_manifest(
    agent_name: &str,
    path: Option<&Path>,
    status: &str,
    reason: String,
) -> Value {
    json!({
        "schema": SSL_MANIFEST_VERSION,
        "status": status,
        "blocking": false,
        "source": "skill_ssl_manifest",
        "agent_name": agent_name,
        "path": path.map(|value| value.to_string_lossy().to_string()).unwrap_or_default(),
        "runtime_truth": false,
        "advisory_only": true,
        "fallback": "SKILL.md + ccc-config.toml",
        "reason": reason,
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    #[cfg(unix)]
    use std::os::unix::fs::symlink;
    use std::time::{SystemTime, UNIX_EPOCH};

    #[cfg(unix)]
    #[test]
    fn executable_manifest_dirs_follow_public_symlink_to_packaged_root() {
        let suffix = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap_or_default()
            .as_nanos();
        let root = env::temp_dir().join(format!("ccc-skill-manifest-symlink-{suffix}"));
        let install_root = root.join("share").join("ccc").join("current");
        let real_bin_dir = install_root.join("bin");
        let public_bin_dir = root.join("bin");
        fs::create_dir_all(&real_bin_dir).expect("create real bin dir");
        fs::create_dir_all(&public_bin_dir).expect("create public bin dir");
        let real_exe = real_bin_dir.join("ccc");
        let public_exe = public_bin_dir.join("ccc");
        fs::write(&real_exe, "").expect("write fake real exe");
        symlink(&real_exe, &public_exe).expect("create public ccc symlink");

        let directories = executable_ancestor_skill_ssl_directories(&public_exe);
        let expected_manifest_dir = fs::canonicalize(&install_root)
            .expect("canonicalize install root")
            .join("skills")
            .join("ssl");

        assert!(
            directories.contains(&expected_manifest_dir),
            "symlinked ccc should search packaged install skills/ssl"
        );
        let _ = fs::remove_dir_all(&root);
    }
}