skillnet 0.4.0

Reconcile and manage local AI skill mirrors; calibration data for the multi-phase-plan skill.
Documentation
mod config;
mod discover;
mod entry;
mod frontmatter;
mod render;
mod validate;

use anyhow::{bail, Result};

use crate::cli::SkillPath;
use crate::commands::Context;

use config::CatalogConfig;
use discover::load_entries;
use entry::SkillEntry;
use render::{
    render_catalog, render_conflicts, render_routing, write_generated_doc, write_project_indexes,
};
use validate::validate_entries;

pub(crate) use frontmatter::parse_frontmatter;

pub fn generate(ctx: &Context) -> Result<()> {
    ctx.ensure_destination_clean()?;
    let config = CatalogConfig::load(&ctx.catalog_config_path)?;
    let entries = load_entries(ctx, &config)?;
    let lint_errors = validate_entries(&entries, &config);
    if !lint_errors.is_empty() {
        bail!("catalog metadata is invalid:\n{}", lint_errors.join("\n"));
    }

    write_generated_doc(
        &ctx.mirror_root.join("CATALOG.md"),
        &render_catalog(&entries)?,
    )?;
    write_generated_doc(
        &ctx.mirror_root.join("ROUTING.md"),
        &render_routing(&entries)?,
    )?;
    write_generated_doc(
        &ctx.mirror_root.join("SKILL_CONFLICTS.md"),
        &render_conflicts(&entries),
    )?;
    write_project_indexes(ctx, &entries)?;
    println!("generated catalog for {} skills", entries.len());
    Ok(())
}

pub fn lint(ctx: &Context) -> Result<()> {
    let config = CatalogConfig::load(&ctx.catalog_config_path)?;
    let entries = load_entries(ctx, &config)?;
    let errors = validate_entries(&entries, &config);
    if errors.is_empty() {
        println!("catalog lint passed for {} skills", entries.len());
        Ok(())
    } else {
        bail!("catalog lint failed:\n{}", errors.join("\n"))
    }
}

pub(crate) fn entry_for(ctx: &Context, path: &SkillPath) -> Result<Option<SkillEntry>> {
    let config = CatalogConfig::load(&ctx.catalog_config_path)?;
    let entries = load_entries(ctx, &config)?;
    let qualified_name = format!("{}/{}", path.scope, path.skill);
    Ok(entries
        .into_iter()
        .find(|entry| entry.qualified_name == qualified_name))
}

pub fn search(ctx: &Context, query: &str) -> Result<()> {
    let config = CatalogConfig::load(&ctx.catalog_config_path)?;
    let query = query.to_lowercase();
    let entries = load_entries(ctx, &config)?;
    for entry in entries.iter().filter(|entry| entry.matches(&query)) {
        println!(
            "{}\t{}\t{}\t{}",
            entry.qualified_name,
            entry.category.as_deref().unwrap_or("uncategorized"),
            entry.status,
            entry.description
        );
    }
    Ok(())
}