skiller 0.2.0

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

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

use crate::catalog::{CatalogIndex, load_global_config, sync_registered_catalogs};
use crate::installer::{InstallScope, install_with_catalogs};
use crate::model::{InstalledState, ProjectConfig, SelectionMode, SkillSelection, validate_schema};
use crate::paths::{
    global_config_path, global_skills_root, global_state_path, read_json_or_default,
    write_global_config, write_json_atomic,
};

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ConfigRow {
    pub(crate) key: String,
    pub(crate) catalog: String,
    pub(crate) scope: String,
    pub(crate) scope_order: i32,
    pub(crate) name: String,
    pub(crate) installed_name: String,
    pub(crate) description: String,
    pub(crate) global: bool,
    pub(crate) selected: Option<SelectionMode>,
    pub(crate) gitignore: bool,
    pub(crate) installed: bool,
    pub(crate) required_by: Vec<String>,
}

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct PrintedConfig<'a> {
    scope: &'a str,
    config_path: String,
    skills: &'a [ConfigRow],
}

pub fn configure(scope: InstallScope, print_only: bool, assignments: &[String]) -> Result<()> {
    let mut global_config = load_global_config()?;
    if global_config.catalogs.is_empty() {
        anyhow::bail!("no catalogs configured; run `skiller add-catalog <alias> <source>`");
    }
    let catalogs = sync_registered_catalogs(&global_config)?;
    let (config_path, state_path, target_root, mut manifest) = match &scope {
        InstallScope::Project(project_root) => {
            let config_path = project_root.join("skiller.config.json");
            let manifest: ProjectConfig = read_json_or_default(&config_path)?;
            (
                config_path,
                project_root.join(".skiller/installed.json"),
                project_root.join(".agents/skills"),
                manifest,
            )
        }
        InstallScope::Global => (
            global_config_path()?,
            global_state_path()?,
            global_skills_root()?,
            ProjectConfig {
                version: global_config.version,
                skills: global_config.skills.clone(),
            },
        ),
    };
    validate_schema(manifest.version, "skill config")?;
    let state: InstalledState = read_json_or_default(&state_path)?;
    validate_schema(state.version, "installed state")?;
    let rows = config_rows(
        &catalogs,
        &state,
        &target_root,
        &manifest,
        scope.is_global(),
    );

    if print_only {
        let output = PrintedConfig {
            scope: if scope.is_global() {
                "global"
            } else {
                "project"
            },
            config_path: config_path.display().to_string(),
            skills: &rows,
        };
        println!("{}", serde_json::to_string_pretty(&output)?);
        return Ok(());
    }
    if !assignments.is_empty() {
        apply_assignments(&mut manifest, &rows, assignments)?;
        save_manifest(&scope, &config_path, &manifest, &mut global_config)?;
        println!("saved {}", config_path.display());
        return Ok(());
    }

    match crate::config_tui::run(&rows, &mut manifest, scope.is_global())? {
        crate::config_tui::ConfigTuiResult::Cancel => Ok(()),
        crate::config_tui::ConfigTuiResult::Save => {
            save_manifest(&scope, &config_path, &manifest, &mut global_config)?;
            println!("saved {}", config_path.display());
            maybe_install(scope, &manifest, &catalogs)
        }
    }
}

fn save_manifest(
    scope: &InstallScope,
    path: &Path,
    manifest: &ProjectConfig,
    global_config: &mut crate::model::GlobalConfig,
) -> Result<()> {
    match scope {
        InstallScope::Project(_) => write_json_atomic(path, manifest),
        InstallScope::Global => {
            global_config.skills = manifest.skills.clone();
            write_global_config(global_config)
        }
    }
}

fn config_rows(
    catalogs: &BTreeMap<String, CatalogIndex>,
    state: &InstalledState,
    target_root: &Path,
    manifest: &ProjectConfig,
    global_scope: bool,
) -> Vec<ConfigRow> {
    let mut rows: Vec<_> = catalogs
        .values()
        .flat_map(|catalog| {
            catalog
                .skills
                .values()
                .filter(move |skill| skill.global == global_scope)
                .map(|skill| {
                    let key = format!("{}/{}", catalog.alias, skill.name);
                    let scope = skill.scope.clone().unwrap_or_else(|| "other".to_owned());
                    let scope_order = catalog
                        .metadata
                        .scopes
                        .get(&scope)
                        .map_or(i32::MAX, |metadata| metadata.order);
                    let installed_name = if global_scope {
                        skill.name.clone()
                    } else {
                        skill.installed_name.clone()
                    };
                    let selection = manifest.skills.get(&key);
                    let mut required_by: Vec<_> = catalog
                        .skills
                        .values()
                        .filter(|candidate| candidate.requires.contains(&skill.name))
                        .map(|candidate| candidate.name.clone())
                        .collect();
                    required_by.sort();
                    ConfigRow {
                        key: key.clone(),
                        catalog: catalog.alias.clone(),
                        scope,
                        scope_order,
                        name: skill.name.clone(),
                        installed: state.skills.get(&key).is_some_and(|installed| {
                            target_root
                                .join(&installed.installed_name)
                                .join("SKILL.md")
                                .is_file()
                        }),
                        installed_name,
                        description: skill.description.clone(),
                        global: skill.global,
                        selected: selection.map(SkillSelection::mode),
                        gitignore: selection.is_some_and(SkillSelection::gitignore),
                        required_by,
                    }
                })
        })
        .collect();
    rows.sort_by(|left, right| {
        left.catalog
            .cmp(&right.catalog)
            .then(left.scope_order.cmp(&right.scope_order))
            .then(left.scope.cmp(&right.scope))
            .then(left.name.cmp(&right.name))
    });
    rows
}

fn apply_assignments(
    manifest: &mut ProjectConfig,
    rows: &[ConfigRow],
    assignments: &[String],
) -> Result<()> {
    let available: BTreeSet<_> = rows.iter().map(|row| row.key.as_str()).collect();
    let mut seen = BTreeSet::new();
    for assignment in assignments {
        let (key, mode) = assignment.split_once('=').with_context(|| {
            format!("invalid selection {assignment:?}; expected catalog/name=enable|manual|off")
        })?;
        if !available.contains(key) {
            anyhow::bail!("skill is unavailable in this configuration: {key}");
        }
        if !seen.insert(key) {
            anyhow::bail!("duplicate skill selection: {key}");
        }
        match mode {
            "enable" => {
                manifest.skills.insert(
                    key.to_owned(),
                    SkillSelection::from_parts(SelectionMode::Enable, false),
                );
            }
            "manual" => {
                manifest.skills.insert(
                    key.to_owned(),
                    SkillSelection::from_parts(SelectionMode::Manual, false),
                );
            }
            "off" => {
                manifest.skills.remove(key);
            }
            _ => anyhow::bail!("invalid mode for {key}: {mode}; expected enable, manual, or off"),
        }
    }
    Ok(())
}

pub(crate) fn cycle_selection(manifest: &mut ProjectConfig, key: &str) {
    let next = match manifest.skills.get(key).map(SkillSelection::mode) {
        None => Some(SelectionMode::Enable),
        Some(SelectionMode::Enable) => Some(SelectionMode::Manual),
        Some(SelectionMode::Manual) => None,
    };
    let ignored = manifest
        .skills
        .get(key)
        .is_some_and(SkillSelection::gitignore);
    if let Some(mode) = next {
        manifest
            .skills
            .insert(key.to_owned(), SkillSelection::from_parts(mode, ignored));
    } else {
        manifest.skills.remove(key);
    }
}

pub(crate) fn toggle_gitignore(manifest: &mut ProjectConfig, key: &str) {
    let Some(selection) = manifest.skills.get(key) else {
        println!("select the skill before changing its Git ignore state");
        return;
    };
    let mode = selection.mode();
    let ignored = !selection.gitignore();
    manifest
        .skills
        .insert(key.to_owned(), SkillSelection::from_parts(mode, ignored));
}

fn maybe_install(
    scope: InstallScope,
    manifest: &ProjectConfig,
    catalogs: &BTreeMap<String, CatalogIndex>,
) -> Result<()> {
    let command = if scope.is_global() {
        "skiller install -g"
    } else {
        "skiller install"
    };
    let answer = prompt(&format!("Run `{command}` now? [y/N]: "))?;
    if answer == "y" || answer == "yes" {
        install_with_catalogs(scope, manifest, catalogs, false)?;
    }
    Ok(())
}

fn prompt(message: &str) -> Result<String> {
    print!("{message}");
    io::stdout().flush()?;
    let mut input = String::new();
    io::stdin()
        .read_line(&mut input)
        .context("reading interactive input")?;
    Ok(input.trim().to_ascii_lowercase())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::catalog::CatalogSkill;
    use crate::model::{CatalogMetadata, SCHEMA_VERSION};
    use std::path::PathBuf;

    #[test]
    fn selection_cycles_without_persisting_off() {
        let mut manifest = ProjectConfig {
            version: SCHEMA_VERSION,
            skills: BTreeMap::new(),
        };
        cycle_selection(&mut manifest, "pyg/develop");
        assert_eq!(manifest.skills["pyg/develop"].mode(), SelectionMode::Enable);
        cycle_selection(&mut manifest, "pyg/develop");
        assert_eq!(manifest.skills["pyg/develop"].mode(), SelectionMode::Manual);
        cycle_selection(&mut manifest, "pyg/develop");
        assert!(!manifest.skills.contains_key("pyg/develop"));
    }

    #[test]
    fn assignments_apply_modes_and_reject_unknown_or_duplicate_skills() {
        let rows = vec![ConfigRow {
            key: "pyg/develop".to_owned(),
            catalog: "pyg".to_owned(),
            scope: "engineering".to_owned(),
            scope_order: 0,
            name: "develop".to_owned(),
            installed_name: "develop".to_owned(),
            description: "Develop".to_owned(),
            global: true,
            selected: None,
            gitignore: false,
            installed: false,
            required_by: Vec::new(),
        }];
        let mut manifest = ProjectConfig::default();
        apply_assignments(&mut manifest, &rows, &["pyg/develop=enable".to_owned()]).unwrap();
        assert_eq!(manifest.skills["pyg/develop"].mode(), SelectionMode::Enable);
        apply_assignments(&mut manifest, &rows, &["pyg/develop=manual".to_owned()]).unwrap();
        assert_eq!(manifest.skills["pyg/develop"].mode(), SelectionMode::Manual);
        apply_assignments(&mut manifest, &rows, &["pyg/develop=off".to_owned()]).unwrap();
        assert!(!manifest.skills.contains_key("pyg/develop"));
        assert!(
            apply_assignments(&mut manifest, &rows, &["pyg/missing=enable".to_owned()]).is_err()
        );
        assert!(
            apply_assignments(
                &mut manifest,
                &rows,
                &[
                    "pyg/develop=enable".to_owned(),
                    "pyg/develop=manual".to_owned()
                ]
            )
            .is_err()
        );
    }

    #[test]
    fn rows_partition_global_and_project_skills() {
        let catalog = CatalogIndex {
            alias: "pyg".to_owned(),
            source: "test".to_owned(),
            root: PathBuf::from("."),
            metadata: CatalogMetadata::default(),
            skills: BTreeMap::from([
                (
                    "global".to_owned(),
                    CatalogSkill {
                        name: "global".to_owned(),
                        description: "Global".to_owned(),
                        scope: None,
                        installed_name: "global-scope".to_owned(),
                        global: true,
                        requires: Vec::new(),
                    },
                ),
                (
                    "project".to_owned(),
                    CatalogSkill {
                        name: "project".to_owned(),
                        description: "Project".to_owned(),
                        scope: None,
                        installed_name: "project-scope".to_owned(),
                        global: false,
                        requires: Vec::new(),
                    },
                ),
            ]),
        };
        let catalogs = BTreeMap::from([("pyg".to_owned(), catalog)]);
        let manifest = ProjectConfig::default();
        let state = InstalledState::default();
        let global = config_rows(&catalogs, &state, Path::new("."), &manifest, true);
        let project = config_rows(&catalogs, &state, Path::new("."), &manifest, false);
        assert_eq!(global[0].name, "global");
        assert_eq!(global[0].installed_name, "global");
        assert_eq!(project[0].name, "project");
        assert_eq!(project[0].installed_name, "project-scope");
    }
}