skiller 0.7.3

Declarative project and global skill management over the Vercel Skills CLI
use std::collections::{BTreeMap, BTreeSet};
use std::io::{self, IsTerminal, Write};

use anyhow::{Context, Result, bail};
use serde::Serialize;

use crate::catalog::{
    load_global_config, sync_registered_authoring_catalogs, sync_registered_catalogs_noninteractive,
};
use crate::installer::{InstallScope, install_paths, install_with_catalogs, resolve_manifest};
use crate::model::{InstalledState, ProjectConfig};
use crate::paths::{read_json, read_json_or_default};

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct CatalogVersion {
    source: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    r#ref: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    revision: Option<String>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct SkillUpdate {
    key: String,
    status: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    installed_digest: Option<String>,
    available_digest: String,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct UpdateReport {
    scope: &'static str,
    catalogs: BTreeMap<String, CatalogVersion>,
    updates: Vec<SkillUpdate>,
    local_drafts: Vec<SkillUpdate>,
}

pub fn run(scope: InstallScope, check: bool, json: bool, yes: bool) -> Result<()> {
    let global = load_global_config()?;
    let catalogs = sync_registered_catalogs_noninteractive(&global)?;
    let manifest = match &scope {
        InstallScope::Project(root) => read_json(&root.join("skiller.config.json"))
            .context("run `skiller config` before checking updates")?,
        InstallScope::Global => ProjectConfig {
            version: global.version,
            skills: global.skills.clone(),
            agents: global.agents.clone(),
        },
    };
    let resolved = resolve_manifest(&manifest, &catalogs, scope.is_global())?;
    let paths = install_paths(&scope)?;
    let installed: InstalledState = read_json_or_default(&paths.state_path)?;
    let mut used_catalogs = BTreeSet::new();
    let mut updates = Vec::new();
    for skill in &resolved {
        used_catalogs.insert(skill.key.split_once('/').expect("resolved key has alias").0);
        if let Some(update) = skill_update(skill, installed.skills.get(&skill.key)) {
            updates.push(update);
        }
    }
    let authoring_catalogs = sync_registered_authoring_catalogs(&global, &catalogs)?;
    let authoring = resolve_manifest(&manifest, &authoring_catalogs, scope.is_global())?;
    let canonical_by_key: BTreeMap<_, _> = resolved
        .iter()
        .map(|skill| (skill.key.as_str(), skill.digest.as_str()))
        .collect();
    let local_drafts = authoring
        .iter()
        .filter(|skill| {
            global
                .catalogs
                .get(skill.key.split_once('/').expect("resolved key has alias").0)
                .and_then(|registration| registration.authoring_root.as_ref())
                .is_some()
                && canonical_by_key.get(skill.key.as_str()).copied() != Some(skill.digest.as_str())
        })
        .filter_map(|skill| skill_update(skill, installed.skills.get(&skill.key)))
        .map(|mut update| {
            update.status = "local-draft";
            update
        })
        .collect();
    let catalog_versions = used_catalogs
        .into_iter()
        .filter_map(|alias| {
            let catalog = catalogs.get(alias)?;
            let registration = global.catalogs.get(alias)?;
            Some((
                alias.to_owned(),
                CatalogVersion {
                    source: registration.source.clone(),
                    r#ref: registration.r#ref.clone(),
                    revision: catalog.revision.clone(),
                },
            ))
        })
        .collect();
    let report = UpdateReport {
        scope: if scope.is_global() {
            "global"
        } else {
            "project"
        },
        catalogs: catalog_versions,
        updates,
        local_drafts,
    };
    if json {
        println!("{}", serde_json::to_string(&report)?);
    } else if report.updates.is_empty() && report.local_drafts.is_empty() {
        println!("{} skills are current", report.scope);
    } else {
        if !report.updates.is_empty() {
            println!(
                "{} published skill update{} available:",
                report.updates.len(),
                if report.updates.len() == 1 { "" } else { "s" }
            );
            for update in &report.updates {
                println!("- {} ({})", update.key, update.status);
            }
        }
        if !report.local_drafts.is_empty() {
            println!(
                "{} unpublished authoring change{} detected:",
                report.local_drafts.len(),
                if report.local_drafts.len() == 1 {
                    ""
                } else {
                    "s"
                }
            );
            for update in &report.local_drafts {
                println!("- {}", update.key);
            }
        }
    }
    if check || report.updates.is_empty() {
        return Ok(());
    }
    if !yes && !confirm(report.updates.len())? {
        println!("update cancelled");
        return Ok(());
    }
    drop(resolved);
    install_with_catalogs(scope, &manifest, &catalogs)
}

fn skill_update(
    skill: &crate::installer::ResolvedSkill<'_>,
    current: Option<&crate::model::InstalledSkill>,
) -> Option<SkillUpdate> {
    let status = match current {
        None => Some("not-installed"),
        Some(current)
            if current.installed_name != skill.installed_name
                || current.mode != skill.mode
                || current.gitignore != skill.gitignore =>
        {
            Some("reconcile")
        }
        Some(current) if current.digest.as_deref() != Some(&skill.digest) => {
            Some(if current.digest.is_some() {
                "updated"
            } else {
                "version-untracked"
            })
        }
        Some(_) => None,
    }?;
    Some(SkillUpdate {
        key: skill.key.clone(),
        status,
        installed_digest: current.and_then(|value| value.digest.clone()),
        available_digest: skill.digest.clone(),
    })
}

fn confirm(count: usize) -> Result<bool> {
    if !io::stdin().is_terminal() {
        bail!("noninteractive update requires --yes");
    }
    print!(
        "Install and verify {count} update{}? [y/N] ",
        if count == 1 { "" } else { "s" }
    );
    io::stdout().flush()?;
    let mut answer = String::new();
    io::stdin().read_line(&mut answer)?;
    Ok(matches!(answer.trim(), "y" | "Y" | "yes" | "YES"))
}