conpub 0.1.3

Agent-first CLI for publishing local knowledge files to Confluence Cloud
use agent_plugin_installer::{
    AgentRuntime, AgentSelector, BatchResult, DoctorOutcome, FailurePolicy, InstallRequest,
    PluginRef, UninstallRequest, UpdateRequest, doctor_many, install_many, uninstall_many,
    update_many,
};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};

const PLUGIN: PluginRef<'static> = PluginRef {
    selector: "conpub@conpub",
    name: "conpub",
};
const MARKETPLACE: &str = "conpub";

pub(crate) fn doctor_agents(selector: AgentSelector) -> Vec<DoctorOutcome> {
    doctor_many(selector)
}

pub(crate) fn install_agents(selector: AgentSelector, checkout: &Path) -> BatchResult {
    install_many(
        selector,
        |_| InstallRequest::local(checkout, PLUGIN),
        FailurePolicy::StopOnFailure,
    )
}

pub(crate) fn update_agents(selector: AgentSelector) -> BatchResult {
    update_many(
        selector,
        |_| UpdateRequest::new(PLUGIN).with_marketplace_name(MARKETPLACE),
        FailurePolicy::StopOnFailure,
    )
}

pub(crate) fn uninstall_agents(selector: AgentSelector) -> BatchResult {
    uninstall_many(
        selector,
        |_| UninstallRequest::new(PLUGIN),
        FailurePolicy::StopOnFailure,
    )
}

pub(crate) fn validate_agent_package(runtime: AgentRuntime, checkout: &Path) -> Result<(), String> {
    if !checkout.is_dir() {
        return Err(format!(
            "plugin package for {} is not a directory: {}",
            runtime.id(),
            checkout.display()
        ));
    }

    let [marketplace_path, manifest_path, skill_path] = package_requirements(runtime, checkout);
    let marketplace = read_json(&marketplace_path)?;
    let manifest = read_json(&manifest_path)?;
    validate_marketplace(runtime, &marketplace, &marketplace_path)?;
    validate_manifest(&manifest, &manifest_path)?;
    validate_skill(&skill_path)
}

fn package_requirements(runtime: AgentRuntime, checkout: &Path) -> [PathBuf; 3] {
    let marketplace = match runtime {
        AgentRuntime::Codex => ".agents/plugins/marketplace.json",
        AgentRuntime::Claude => ".claude-plugin/marketplace.json",
    };
    let manifest = match runtime {
        AgentRuntime::Codex => "plugins/conpub/.codex-plugin/plugin.json",
        AgentRuntime::Claude => "plugins/conpub/.claude-plugin/plugin.json",
    };
    [
        checkout.join(marketplace),
        checkout.join(manifest),
        checkout.join("plugins/conpub/skills/conpub/SKILL.md"),
    ]
}

fn read_json(path: &Path) -> Result<Value, String> {
    let text = fs::read_to_string(path)
        .map_err(|err| format!("failed to read plugin file {}: {err}", path.display()))?;
    serde_json::from_str(&text)
        .map_err(|err| format!("invalid JSON in plugin file {}: {err}", path.display()))
}

fn validate_marketplace(
    runtime: AgentRuntime,
    marketplace: &Value,
    path: &Path,
) -> Result<(), String> {
    if marketplace["name"] != "conpub" {
        return Err(format!(
            "plugin marketplace {} must be named conpub",
            path.display()
        ));
    }
    let entry = marketplace["plugins"]
        .as_array()
        .and_then(|plugins| plugins.iter().find(|plugin| plugin["name"] == "conpub"))
        .ok_or_else(|| format!("plugin marketplace {} does not list conpub", path.display()))?;

    match runtime {
        AgentRuntime::Codex
            if entry["source"]
                != serde_json::json!({"source": "local", "path": "./plugins/conpub"}) =>
        {
            Err(format!(
                "Codex marketplace {} must source ./plugins/conpub",
                path.display()
            ))
        }
        AgentRuntime::Claude if entry["source"] != "./plugins/conpub" => Err(format!(
            "Claude marketplace {} must source ./plugins/conpub",
            path.display()
        )),
        AgentRuntime::Claude if entry["version"] != env!("CARGO_PKG_VERSION") => Err(format!(
            "Claude marketplace {} version must match conpub {}",
            path.display(),
            env!("CARGO_PKG_VERSION")
        )),
        _ => Ok(()),
    }
}

fn validate_manifest(manifest: &Value, path: &Path) -> Result<(), String> {
    if manifest["name"] != "conpub" {
        return Err(format!(
            "plugin manifest {} must be named conpub",
            path.display()
        ));
    }
    if manifest["version"] != env!("CARGO_PKG_VERSION") {
        return Err(format!(
            "plugin manifest {} version must match conpub {}",
            path.display(),
            env!("CARGO_PKG_VERSION")
        ));
    }
    Ok(())
}

fn validate_skill(path: &Path) -> Result<(), String> {
    let text = fs::read_to_string(path)
        .map_err(|err| format!("failed to read conpub skill {}: {err}", path.display()))?;
    if text.lines().take(4).any(|line| line == "name: conpub") {
        Ok(())
    } else {
        Err(format!(
            "conpub skill {} has invalid frontmatter",
            path.display()
        ))
    }
}