import { Command } from "commander"; import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import chalk from "chalk"; import { resolveInstallPath } from "../lib/install-path.js"; import { readManifest } from "../lib/manifest.js"; type VerifyStatus = "PASS" | "FAIL" | "UNVERIFIED" | "MISSING"; interface VerifyResult { slug: string; status: VerifyStatus; detail?: string; } /** * Extract the instructions body from an installed SKILL.md. * Returns the text after the second `---` frontmatter delimiter, trimmed. */ export function extractInstructionsBody(fileContent: string): string { // Split on `---` line boundaries — frontmatter is delimited by `---` on its own line const parts = fileContent.split(/^---\s*$/m); // parts[0] = before first ---, parts[1] = frontmatter, parts[2+] = body if (parts.length < 3) return fileContent.trim(); return parts.slice(2).join("---").trim(); } /** * Compute SHA-256 hex digest of skill instructions — mirrors API content-hash logic. */ export function computeContentHash(instructions: string): string { return createHash("sha256").update(instructions, "utf-8").digest("hex"); } function verifySkill( slug: string, platform: string, expectedHash?: string, ): VerifyResult { if (!expectedHash) { return { slug, status: "UNVERIFIED" }; } let target: { path: string }; try { target = resolveInstallPath(platform); } catch { return { slug, status: "MISSING", detail: `unknown platform: ${platform}` }; } const skillFile = join(target.path, slug, "SKILL.md"); if (!existsSync(skillFile)) { return { slug, status: "MISSING", detail: skillFile }; } const content = readFileSync(skillFile, "utf-8"); const body = extractInstructionsBody(content); const actualHash = computeContentHash(body); if (actualHash === expectedHash) { return { slug, status: "PASS" }; } return { slug, status: "FAIL", detail: `expected ${expectedHash.slice(0, 12)}… got ${actualHash.slice(0, 12)}…`, }; } export const verifyCommand = new Command("verify") .description("Verify installed skills match their recorded content hashes") .argument("[slug]", "Verify a single skill by slug (default: verify all)") .action((slug?: string) => { const manifest = readManifest(); const entries = Object.entries(manifest.skills); if (entries.length === 0) { console.log(chalk.yellow("No skills in .skills.json to verify.")); process.exit(0); } const targets = slug ? entries.filter(([s]) => s === slug) : entries; if (slug && targets.length === 0) { console.error(chalk.red(`✗ Skill "${slug}" not found in .skills.json`)); process.exit(1); } const results: VerifyResult[] = targets.map(([s, entry]) => verifySkill(s, entry.platform, entry.contentHash), ); let hasFail = false; for (const r of results) { switch (r.status) { case "PASS": console.log(chalk.green(`✓ PASS ${r.slug}`)); break; case "FAIL": console.log( chalk.red(`✗ FAIL ${r.slug}`) + chalk.dim(` (${r.detail})`), ); hasFail = true; break; case "UNVERIFIED": console.log( chalk.yellow(`⚠ UNVERIFIED ${r.slug}`) + chalk.dim(" (no hash recorded — reinstall to track)"), ); break; case "MISSING": console.log( chalk.red(`✗ MISSING ${r.slug}`) + chalk.dim(` (${r.detail})`), ); hasFail = true; break; } } const passCount = results.filter((r) => r.status === "PASS").length; const failCount = results.filter( (r) => r.status === "FAIL" || r.status === "MISSING", ).length; const unverifiedCount = results.filter( (r) => r.status === "UNVERIFIED", ).length; console.log(""); console.log( `${chalk.bold("Summary:")} ${passCount} passed, ${failCount} failed, ${unverifiedCount} unverified`, ); if (hasFail) { console.log( chalk.red( "Run `skills-hub update ` to reinstall a skill from the registry.", ), ); process.exit(1); } });