skillpack 0.12.1

Generate, verify, and maintain AI agent guidance (skills, plugins, AGENTS.md) for Claude Code, Cursor, Codex, Copilot, and 10+ AI coding ecosystems — one command turns any CLI or library into an agent-discoverable skill pack.
Documentation
//! The `skillpack verify` subcommand: load the generated distribution files
//! and run discovery + invocation checks against them.
//!
//! Design §5.2. `verify` works even on hand-written plugin files (not just
//! `init` output) — see §4.2 — so the loader is tolerant of missing pieces and
//! each check degrades gracefully.

pub mod discovery;
pub mod fix;
pub mod invocation;
pub mod result;
pub mod schema;

use anyhow::Result;

use self::invocation::InvocationInput;
use self::result::CheckResult;

// Re-export the pieces the rest of the crate touches.
pub use self::result::VerifyReport;

/// Where the invocation stage should look for skill text. Passed in so the
/// dispatcher owns the single `find_skill_file` call.
///
/// `root` is where the skill/manifest files live (the project root for the
/// `verify` subcommand, the temp dir for `init`'s pre-commit gate).
/// `spawn_root` is the real project root the CLI spawns from — it must be
/// separate from `root` so the pre-commit gate can spawn the real CLI in its
/// source tree while still verifying the rendered files (design §5.3 + §6.3).
#[derive(Debug, Clone)]
pub struct VerifyInput {
    pub root: std::path::PathBuf,
    /// The real project root the documented CLI runs in. For the `verify`
    /// subcommand this equals `root`; for `init`'s pre-commit gate it's the
    /// project root while `root` is the temp dir holding the rendered files.
    pub spawn_root: std::path::PathBuf,
    pub cli_command: Option<Vec<String>>,
    /// The repo URL `git remote get-url origin` produced at introspection
    /// (cached on `ProjectProfile.repo_url`, threaded here so `discovery`'s
    /// URL-drift check stays free of a subprocess spawn — see module doc).
    /// `None` when no git origin is configured.
    pub repo_url: Option<String>,
    /// The kebab-coerced project name (`coerce_kebab(&profile.name)`,
    /// already computed for rendering). Threaded so `discovery`'s
    /// `discovery.skill.name_drift` check can compare the SKILL.md `name:`
    /// frontmatter against the canonical value the template renders — without
    /// `discovery` itself calling `coerce_kebab` or building a `ProjectProfile`.
    /// `None` only when introspection couldn't derive a name at all.
    pub profile_name: Option<String>,
    /// Print every subprocess spawn to stderr (design §8.2 --debug).
    pub debug: bool,
    /// Stdin bytes to feed the CLI during `verify` spawns. For interactive
    /// CLIs that block on stdin. `None` uses `/dev/null` (default).
    pub verify_stdin: Option<String>,
}

/// Run the full verify suite against `root`, returning the aggregate report.
pub fn run(input: &VerifyInput) -> Result<VerifyReport> {
    let root = &input.root;
    let mut report = VerifyReport::default();

    // Discovery checks (pure, file reads only + the threaded repo_url +
    // profile_name for the plugin.json / SKILL.md drift sub-checks).
    for check in discovery::run(root, &input.repo_url, &input.profile_name)? {
        report.push(check);
    }

    // Invocation checks run against EVERY skill that documents a CLI, so a
    // multi-skill pack can't hide drift in a secondary skill. The primary
    // (first) CLI spawns from the introspected `cli_command` (which may be a
    // resolved absolute path); each secondary skill derives its program from
    // its own documented invocation and must be on PATH to be spawnable.
    let skill_files = discovery::find_skill_files(root);
    let mut spawned_primary = false;
    for skill_path in &skill_files {
        let skill_md = match std::fs::read_to_string(skill_path) {
            Ok(s) => s,
            Err(e) => {
                // Path exists (find_skill_files returned it) — read failure is
                // non-missing (permissions, non-UTF8, EBUSY). Discovery's
                // `check_one_skill_md` would abort verify on the same file;
                // surface a WARN here so the maintainer sees the read failure.
                report.push(CheckResult::warn(
                    "invocation.read_failed",
                    "skills a verify can spawn should be readable",
                    format!("{}: read failed ({}); invocation drift check skipped for this skill", discovery::rel_unix(root, skill_path), e),
                    "To fix: check file permissions, ensure UTF-8 encoding (no Latin-1), and re-run.",
                ));
                continue;
            }
        };
        // A pure-library skill (or one with no documented CLI) still goes
        // through `invocation::run`, which emits its "Skipped: pure-library
        // project" result — silently `continue`-ing here would drop that
        // signal from the report.
        let is_cli = invocation::extract_documented_invocation(&skill_md).is_some();
        let cmd = if !is_cli {
            None
        } else if !spawned_primary {
            spawned_primary = true;
            input.cli_command.clone()
        } else {
            // Secondary skill: derive the command from its own invocation. If
            // the binary is not on PATH (expected on many machines, since
            // introspection only resolved the primary), warn and skip rather
            // than false-fail a legitimately multi-CLI pack.
            match invocation::command_from_documented(&skill_md) {
                Some(c) if crate::introspect::which_on_path(&c[0]).is_some() => Some(c),
                Some(c) => {
                    report.push(CheckResult::warn(
                        "invocation.secondary_not_runnable",
                        "every documented CLI can be spawned for drift checks",
                        format!("secondary skill documents CLI `{}`, which is not on PATH; its drift checks were skipped", c[0]),
                        "To fix: install/build the secondary CLI so it is on PATH, then re-run verify.",
                    ));
                    continue;
                }
                None => {
                    report.push(CheckResult::warn(
                        "invocation.secondary_unparseable",
                        "every documented CLI can be spawned for drift checks",
                        format!("could not derive a command from {}'s documented invocation; its drift checks were skipped", discovery::rel_unix(root, skill_path)),
                        "To fix: document the CLI with a plain command line in the `## Invocation` section.",
                    ));
                    continue;
                }
            }
        };
        let inv = InvocationInput::new(
            root,
            &input.spawn_root,
            &skill_md,
            cmd.as_deref(),
            input.debug,
            input.verify_stdin.as_deref(),
        );
        invocation::run(&inv, &mut report)?;
    }

    Ok(report)
}

/// How `verify` presents its results (Improvement B). The human format is the
/// default; `json` is for CI gating / scripting and uses the machine-readable
/// `check_id`s already on each [`CheckResult`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub enum OutputFormat {
    Human,
    Json,
    Sarif,
}

/// Pretty-print a report as the human-facing output (design §5.2 step 4).
/// Returns a single string the CLI writes to stdout.
pub fn render(report: &VerifyReport) -> String {
    use self::result::Severity;
    let mut out = String::new();
    let (pass, warn, fail, _skip) = report.counts();
    for r in &report.results {
        let glyph = match r.severity {
            Severity::Pass => "",
            Severity::Warn => "!",
            Severity::Error => "",
            Severity::Skipped => "·",
        };
        out.push_str(&format!(
            "{} {}: {}\n",
            glyph,
            r.severity.as_str(),
            r.check_name
        ));
        if !r.message.is_empty() {
            out.push_str(&format!("    {}\n", r.message));
        }
        if let Some(s) = &r.suggestion {
            out.push_str(&format!("    {s}\n"));
        }
    }

    out.push_str(&format!(
        "\n{pass} passed, {warn} warning(s), {fail} failed, discoverability score {}/100",
        report.discoverability_score()
    ));
    out.push_str(if fail > 0 {
        ": verify FAILED\n"
    } else {
        ": verify OK\n"
    });
    out
}

/// Render the report as a stable JSON object for CI / scripting. Shape:
/// `{ "ok": bool, "discoverability_score": u8, "counts": {pass,warn,fail,skip},
/// "results": [ {check_id, check_name, severity, message, suggestion?,
/// location?} ... ] }`. The score weights Pass=1.0, Warn=0.5, Error=0;
/// Skipped excluded from the denominator.
pub fn render_json(report: &VerifyReport) -> String {
    let (pass, warn, fail, skip) = report.counts();
    let results: Vec<_> = report
        .results
        .iter()
        .map(|r| {
            let mut o = serde_json::json!({
                "check_id": r.check_id,
                "check_name": r.check_name,
                "severity": r.severity.as_str(),
                "message": r.message,
            });
            if let Some(s) = &r.suggestion {
                o["suggestion"] = serde_json::Value::String(s.clone());
            }
            if let Some((file, line)) = &r.location {
                let mut loc = serde_json::Map::new();
                loc.insert("file".to_string(), serde_json::Value::String(file.clone()));
                if let Some(n) = line {
                    loc.insert("line".to_string(), serde_json::Value::from(*n));
                }
                o["location"] = serde_json::Value::Object(loc);
            }
            o
        })
        .collect();
    let body = serde_json::json!({
        "ok": !report.has_critical_failure(),
        "discoverability_score": report.discoverability_score(),
        "counts": {
            "pass": pass,
            "warn": warn,
            "fail": fail,
            "skip": skip,
        },
        "results": results,
    });
    serde_json::to_string_pretty(&body).expect("verify report serializes to JSON")
}

/// Render the report as SARIF 2.1.0 for GitHub Code Scanning upload-sarif.
/// Only `Warn` and `Error` results are emitted (SARIF reports failures, not
/// passes). Each result maps `ruleId` → `check_id`, `level` → `"warning"` or
/// `"error"`, `message` → CheckResult message, `locations` → file + line
/// when available. Pass/Skipped results are omitted.
pub fn render_sarif(report: &VerifyReport) -> String {
    use self::result::Severity;

    let results: Vec<_> = report
        .results
        .iter()
        .filter(|r| matches!(r.severity, Severity::Warn | Severity::Error))
        .map(|r| {
            let level = match r.severity {
                Severity::Warn => "warning",
                Severity::Error => "error",
                _ => "none",
            };
            let mut result = serde_json::json!({
                "ruleId": r.check_id,
                "level": level,
                "message": { "text": r.message },
            });

            // suggestion → rule metadata, appended to the message.
            if let Some(s) = &r.suggestion {
                result["message"]["text"] =
                    serde_json::Value::String(format!("{}\nSuggestion: {s}", r.message));
            }

            if let Some((file, line)) = &r.location {
                let mut region = serde_json::Map::new();
                if let Some(n) = line {
                    region.insert("startLine".to_string(), serde_json::Value::from(*n));
                }
                let mut phys_loc = serde_json::json!({
                    "artifactLocation": { "uri": file }
                });
                if !region.is_empty() {
                    phys_loc["region"] = serde_json::Value::Object(region);
                }
                result["locations"] = serde_json::json!([phys_loc]);
            }

            result
        })
        .collect();

    let body = serde_json::json!({
        "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
        "version": "2.1.0",
        "runs": [{
            "tool": {
                "driver": {
                    "name": "skillpack",
                    "informationUri": "https://github.com/nordicnode/skillpack"
                }
            },
            "results": results
        }]
    });

    serde_json::to_string_pretty(&body).expect("verify report serializes to SARIF JSON")
}