diff --git a/CHANGELOG.md b/CHANGELOG.md index b68a82f..ea25d15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) ## [Unreleased] ### Added +- `skillforge inspect `: one-shot diagnostic report that rolls validation, linting, frontmatter parsing, body stats (lines / words / characters / `## headings` extracted in document order, code-fence aware), and (for directory inputs) the attached-file inventory into one structured `InspectResult`. Reuses `pack`'s exclusion rules so the file list matches exactly what `pack` would bundle — no duplicated walker. Human-readable mode prints a tidy multi-section report with a frontmatter table, right-aligned body-stat numerals, and grouped validation / lint / attached-file lists; `--json` emits the full result for CI. Exits 0 when validation passes and there are no lint errors, 1 otherwise. Composed entirely on top of the existing `validateSkill` / `lintSkill` APIs. - `skillforge format `: reformat a SKILL.md to canonical shape — frontmatter keys reordered into the schema's declared order (`name`, `description`, `version`, `tags`, `author`, `homepage`) with passthrough fields alphabetized at the end, stringy primitives like `tags: "[]"` coerced back to YAML, trailing whitespace stripped per line, runs of 3+ blank lines collapsed to 2, exactly one trailing newline. The body envelope is normalized but prose is not reflowed and fenced code blocks (` ``` `) are preserved verbatim. The formatted output is validated against the schema before writing; an already-canonical file is a no-op. `--dry-run` returns the diff without writing; `--write=false` prints to stdout; `--check` exits 1 if anything would change (CI mode). Idempotent — running twice produces byte-identical output the second time. Style fixes that pair with `lint`'s style checks. - `skillforge update `: bump the `version:` field of a SKILL.md in one shot — `--bump ` or `--new-version ` (mutually exclusive, exactly one required). Pre-release tags (`-beta`, `-rc.1`, …) are dropped on any bump, matching `npm version`. A missing `version:` field is treated as the schema default of `0.0.1`. Validates the proposed frontmatter against the schema before touching disk and uses a line-surgical write so the body bytes and other YAML formatting are preserved byte-for-byte. `--dry-run` reports the new version without writing. - `skillforge lint `: warnings-first style/quality linter for `SKILL.md` files. A stricter peer of `validate` that surfaces nine smells `validate` deliberately ignores — short or noun-phrase `description`, descriptions missing trigger language, empty `tags`, stale `version: 0.0.1` files (older than 7 days), missing `## When to use` / `## Examples` headings, `TODO` markers (error), `you should` / `always` second-person phrasing, and trailing whitespace. Exit 0 if only warnings, 1 on errors, 2 with `--strict`. `--json` emits machine-readable issues. Each rule is a tiny pure function so adding rules is a one-liner. diff --git a/README.md b/README.md index 386867e..137048a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Scaffold, validate, and lint `SKILL.md` files for the agent ecosystem. --- -> **Status — v0.0.2, early days.** `init`, `validate`, `lint`, `pack`, `install`, `update`, and `format` work today. Registry, publish, and eval flows land in v0.1. +> **Status — v0.0.2, early days.** `init`, `validate`, `lint`, `pack`, `install`, `update`, `format`, and `inspect` work today. Registry, publish, and eval flows land in v0.1. ## Install @@ -126,6 +126,46 @@ skillforge format ./code-review/SKILL.md --write=false Fenced code blocks (` ``` `) are preserved verbatim — `format` is gentle on the body, never reflowing prose. The output is always validated against the schema before writing; an already-canonical file is a no-op (`exit 0`, no write). Format is idempotent: running twice produces byte-identical output the second time. +### `skillforge inspect ` + +One-shot diagnostic report — rolls `validate` + `lint` + a frontmatter summary + body stats + (for directory inputs) the attached-file inventory into one structured view. The "give me everything you know about this skill" command: + +```bash +skillforge inspect ./code-review +# code-review /…/SKILL.md ISSUES +# +# FRONTMATTER +# name code-review +# description Use this when the user asks for a code review on a diff… +# version 0.1.0 +# tags code, review +# +# BODY +# 42 lines +# 210 words +# 1832 characters +# 4 sections +# headings: +# · What this skill does +# · When to use +# · Examples +# +# VALIDATION +# ✓ no issues +# +# LINT +# · warning …/SKILL.md: tags-empty: tags array is empty… +# +# ATTACHED FILES +# · SKILL.md +# · templates/letter.md +# +# SUMMARY +# ✗ validation: 0 lint: 1 +``` + +Pass `--json` for machine-readable output (CI-friendly — every field is JSON-stable). Exit `0` if validation passes and there are no lint errors, `1` otherwise. Reuses `pack`'s exclusion rules so the attached-file list matches exactly what `pack` would bundle. + ## Schema ```yaml diff --git a/src/cli.ts b/src/cli.ts index c1736bb..cdf70cc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,11 +9,13 @@ * install download a remote .skill into ~/.claude/skills/ * update bump the version field of a SKILL.md * format reformat a SKILL.md to canonical shape + * inspect one-shot report: validation + lint + frontmatter + body */ import { cac } from "cac"; import kleur from "kleur"; import { formatSkill } from "./format.js"; import { initSkill } from "./init.js"; +import { type InspectResult, inspectSkill } from "./inspect.js"; import { installSkill } from "./install.js"; import { computeExitCode, lintSkill } from "./lint.js"; import { packSkill } from "./pack.js"; @@ -227,6 +229,123 @@ cli } }); +cli + .command( + "inspect ", + "One-shot report: validation + lint + frontmatter summary + body stats", + ) + .option("--json", "Emit the full result as JSON (CI-friendly)") + .action(async (path: string, opts) => { + try { + const result = await inspectSkill({ path, json: !!opts.json }); + if (opts.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.exit(result.summary.ok ? 0 : 1); + } + printInspectReport(result); + process.exit(result.summary.ok ? 0 : 1); + } catch (err) { + process.stderr.write(`${kleur.red("error:")} ${(err as Error).message}\n`); + process.exit(1); + } + }); + +/** + * Human-readable `inspect` report. Tidy multi-section layout: a header, an + * aligned frontmatter table, body stats with tabular numerals, then any + * validation issues, lint issues, and the attached-file inventory. Each + * heading is sentence-case in tracked uppercase — quiet structural signal, + * not editorial shouting. + */ +function printInspectReport(r: InspectResult): void { + const out = process.stdout; + const status = r.summary.ok ? kleur.green("OK") : kleur.red("ISSUES"); + const heading = (label: string) => out.write(`\n${kleur.bold(kleur.dim(label.toUpperCase()))}\n`); + + out.write(`${kleur.bold(r.name ?? "")} ${kleur.dim(r.path)} ${status}\n`); + + heading("Frontmatter"); + if (r.frontmatter) { + const rows: Array<[string, string]> = [ + ["name", r.frontmatter.name], + ["description", truncate(r.frontmatter.description.replace(/\s+/g, " "), 64)], + ["version", r.frontmatter.version], + ["tags", r.frontmatter.tags.length ? r.frontmatter.tags.join(", ") : kleur.dim("∅")], + ]; + if (r.frontmatter.author) rows.push(["author", r.frontmatter.author]); + if (r.frontmatter.homepage) rows.push(["homepage", r.frontmatter.homepage]); + const keyWidth = Math.max(...rows.map((row) => row[0].length)); + for (const [k, v] of rows) { + out.write(` ${kleur.dim(k.padEnd(keyWidth))} ${v}\n`); + } + } else { + out.write(` ${kleur.dim("(unparseable — see validation issues below)")}\n`); + } + + heading("Body"); + // tabular-nums analogue: right-align the numbers in a fixed column so the + // eye can compare them at a glance. + const stats: Array<[string, number]> = [ + ["lines", r.body.lines], + ["words", r.body.words], + ["characters", r.body.characters], + ["sections", r.body.sections.length], + ]; + const valWidth = Math.max(...stats.map(([, n]) => String(n).length)); + for (const [label, n] of stats) { + out.write(` ${String(n).padStart(valWidth)} ${kleur.dim(label)}\n`); + } + if (r.body.sections.length > 0) { + out.write(` ${kleur.dim("headings:")}\n`); + for (const section of r.body.sections) { + out.write(` ${kleur.dim("·")} ${section}\n`); + } + } + + heading("Validation"); + if (r.validation.ok) { + out.write(` ${kleur.green("✓")} no issues\n`); + } else { + for (const issue of r.validation.issues) { + out.write(` ${kleur.red("·")} ${issue}\n`); + } + } + + heading("Lint"); + if (r.lint.issues.length === 0) { + out.write(` ${kleur.green("✓")} no issues\n`); + } else { + for (const issue of r.lint.issues) { + const color = issue.severity === "error" ? kleur.red : kleur.yellow; + const loc = issue.line ? `${r.path}:${issue.line}` : r.path; + out.write( + ` ${color("·")} ${color(issue.severity)} ${loc}: ${issue.rule}: ${issue.message}\n`, + ); + } + } + + if (r.attachedFiles) { + heading("Attached files"); + if (r.attachedFiles.length === 0) { + out.write(` ${kleur.dim("(none)")}\n`); + } else { + for (const f of r.attachedFiles) { + out.write(` ${kleur.dim("·")} ${f}\n`); + } + } + } + + heading("Summary"); + out.write( + ` ${r.summary.ok ? kleur.green("✓") : kleur.red("✗")} validation: ${r.summary.validationIssues} lint: ${r.summary.lintIssues}\n`, + ); +} + +function truncate(s: string, max: number): string { + if (s.length <= max) return s; + return `${s.slice(0, max - 1)}…`; +} + cli.help(); cli.version(VERSION); cli.parse(); diff --git a/src/index.ts b/src/index.ts index 9474d78..f96f8fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,13 @@ /** Public programmatic API. */ export { type FormatOptions, type FormatResult, formatSkill } from "./format.js"; export { initSkill, type InitOptions } from "./init.js"; +export { + type InspectBodyStats, + type InspectOptions, + type InspectResult, + type InspectSummary, + inspectSkill, +} from "./inspect.js"; export { computeExitCode, type Issue, diff --git a/src/inspect.test.ts b/src/inspect.test.ts new file mode 100644 index 0000000..364f8e8 --- /dev/null +++ b/src/inspect.test.ts @@ -0,0 +1,253 @@ +import { realpathSync } from "node:fs"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; +import { inspectSkill } from "./inspect.js"; + +let workDir: string; + +beforeEach(async () => { + // realpath because macOS `tmpdir()` returns `/tmp` while resolved paths + // come back as `/private/tmp`. Same trick as the sibling test files. + workDir = realpathSync(await mkdtemp(join(tmpdir(), "skillforge-inspect-"))); +}); + +const VALID_DESC = "Use this when the user asks for the demo skill described in this file."; + +const HEALTHY_BODY = `# my-skill + +## What this skill does + +Real prose. Real prose. Real prose. Real prose. + +## When to use + +When the agent wants to demonstrate something. + +## Examples + +\`\`\` +example +\`\`\` +`; + +function buildSkillSource( + opts: { + name?: string; + description?: string; + version?: string; + tags?: string; + body?: string; + } = {}, +): string { + const name = opts.name ?? "my-skill"; + const description = opts.description ?? VALID_DESC; + const version = opts.version ?? "0.1.0"; + const tags = opts.tags ?? "[a, b]"; + const body = opts.body ?? HEALTHY_BODY; + return [ + "---", + `name: ${name}`, + `description: ${description}`, + `version: ${version}`, + `tags: ${tags}`, + "---", + "", + body, + ].join("\n"); +} + +async function writeSkill(content: string, name = "SKILL.md"): Promise { + const file = join(workDir, name); + await writeFile(file, content); + return file; +} + +describe("inspectSkill — happy path", () => { + it("returns frontmatter, body stats, and a clean summary for a healthy SKILL.md", async () => { + const file = await writeSkill(buildSkillSource()); + const r = await inspectSkill({ path: file }); + + expect(r.name).toBe("my-skill"); + expect(r.frontmatter?.name).toBe("my-skill"); + expect(r.frontmatter?.version).toBe("0.1.0"); + expect(r.frontmatter?.tags).toEqual(["a", "b"]); + expect(r.validation.ok).toBe(true); + // Lint may flag style warnings (e.g. abandoned-default-version) — what we + // care about is that there are no *errors* on a healthy file, since that + // drives `summary.ok`. + expect(r.lint.issues.filter((i) => i.severity === "error")).toHaveLength(0); + expect(r.summary.ok).toBe(true); + expect(r.summary.validationIssues).toBe(0); + expect(r.attachedFiles).toBeUndefined(); // file input → no inventory + }); +}); + +describe("inspectSkill — surfaces validation failures", () => { + it("flags an invalid frontmatter (description too short) via the validation block", async () => { + const broken = buildSkillSource({ description: "too short" }); + const file = await writeSkill(broken); + const r = await inspectSkill({ path: file }); + expect(r.validation.ok).toBe(false); + expect(r.validation.issues.length).toBeGreaterThan(0); + expect(r.validation.issues.join(" ")).toMatch(/description/i); + expect(r.summary.ok).toBe(false); + expect(r.summary.validationIssues).toBeGreaterThan(0); + // Frontmatter on the result is undefined when the schema parse fails — + // that's the contract callers depend on. + expect(r.frontmatter).toBeUndefined(); + }); +}); + +describe("inspectSkill — surfaces lint issues", () => { + it("flags a TODO marker (lint error) and reports summary.ok=false", async () => { + const body = `# my-skill + +## What this skill does + +Real prose. Real prose. Real prose. Real prose. + +## When to use + +When the agent wants to demonstrate something. + +## Examples + +TODO finish writing this example. +`; + const file = await writeSkill(buildSkillSource({ body })); + const r = await inspectSkill({ path: file }); + // Validation also surfaces TODO as a body-level warning (in `issues`), + // so we narrow to the lint side for this assertion. + const lintErrors = r.lint.issues.filter((i) => i.severity === "error"); + expect(lintErrors.some((i) => i.rule === "todo-marker")).toBe(true); + expect(r.summary.ok).toBe(false); + expect(r.summary.lintIssues).toBeGreaterThan(0); + }); +}); + +describe("inspectSkill — directory mode", () => { + it("lists every non-excluded attached file in a skill directory", async () => { + const skillDir = join(workDir, "my-skill"); + await mkdir(skillDir, { recursive: true }); + await writeFile(join(skillDir, "SKILL.md"), buildSkillSource()); + await mkdir(join(skillDir, "templates"), { recursive: true }); + await writeFile(join(skillDir, "templates", "letter.md"), "Dear ..."); + await writeFile(join(skillDir, "tool.py"), "print('hi')"); + // These three must be excluded by the same rules as `pack`: + await writeFile(join(skillDir, ".DS_Store"), "trash"); + await writeFile(join(skillDir, "debug.log"), "noise"); + await mkdir(join(skillDir, ".git"), { recursive: true }); + await writeFile(join(skillDir, ".git", "HEAD"), "ref: refs/heads/main"); + + const r = await inspectSkill({ path: skillDir }); + + expect(r.attachedFiles).toBeDefined(); + expect(r.attachedFiles).toContain("SKILL.md"); + expect(r.attachedFiles).toContain("templates/letter.md"); + expect(r.attachedFiles).toContain("tool.py"); + expect(r.attachedFiles).not.toContain(".DS_Store"); + expect(r.attachedFiles).not.toContain("debug.log"); + expect(r.attachedFiles?.some((p) => p.startsWith(".git"))).toBe(false); + }); +}); + +describe("inspectSkill — body parsing", () => { + it("extracts every `## heading` (level-2 only) in document order", async () => { + const body = `# Title (h1 ignored) + +## Alpha + +prose + +### sub (h3 ignored) + +## Bravo + +more prose + +## Charlie +`; + const file = await writeSkill(buildSkillSource({ body })); + const r = await inspectSkill({ path: file }); + expect(r.body.sections).toEqual(["Alpha", "Bravo", "Charlie"]); + }); + + it("ignores `##` sequences that appear inside fenced code blocks", async () => { + const body = `# Title + +## Real Section + +\`\`\`md +## Not A Section — this is inside a fence +\`\`\` + +## Another Real Section +`; + const file = await writeSkill(buildSkillSource({ body })); + const r = await inspectSkill({ path: file }); + expect(r.body.sections).toEqual(["Real Section", "Another Real Section"]); + }); + + it("counts words correctly on a known-size body", async () => { + // Exactly 12 words across two lines. + const body = `one two three four five six + +seven eight nine ten eleven twelve +`; + const file = await writeSkill(buildSkillSource({ body })); + const r = await inspectSkill({ path: file }); + // Word count is exact — gray-matter's leading-newline quirk doesn't add + // extra tokens through the whitespace splitter. + expect(r.body.words).toBe(12); + // gray-matter prefixes a leading "\n" to its `content`, so character / + // line counts will be exactly one greater than the raw body. We assert + // the offset rather than equality to pin the contract. + expect(r.body.characters).toBe(body.length + 1); + expect(r.body.lines).toBe(body.split("\n").length + 1); + }); +}); + +describe("inspectSkill — error paths", () => { + it("throws a helpful error when the path does not exist", async () => { + await expect(inspectSkill({ path: join(workDir, "missing.md") })).rejects.toThrow( + /does not exist/, + ); + }); + + it("throws when a directory input has no SKILL.md", async () => { + const dir = join(workDir, "empty"); + await mkdir(dir, { recursive: true }); + await expect(inspectSkill({ path: dir })).rejects.toThrow(/SKILL\.md/); + }); +}); + +describe("inspectSkill — JSON shape stability", () => { + it("produces a result that serializes cleanly to JSON with all top-level keys", async () => { + const file = await writeSkill(buildSkillSource()); + const r = await inspectSkill({ path: file, json: true }); + const json = JSON.stringify(r); + // Round-trip — no Date / Map / undefined-only landmines. + const parsed = JSON.parse(json) as Record; + // Top-level contract. `attachedFiles` is intentionally omitted for file + // inputs and JSON.stringify drops `undefined` — assert via the source + // object instead. + expect(Object.keys(parsed).sort()).toEqual( + ["body", "frontmatter", "lint", "name", "path", "summary", "validation"].sort(), + ); + const summary = parsed.summary as Record; + expect(summary).toMatchObject({ + ok: expect.any(Boolean), + validationIssues: expect.any(Number), + lintIssues: expect.any(Number), + }); + const body = parsed.body as Record; + expect(body).toMatchObject({ + lines: expect.any(Number), + characters: expect.any(Number), + words: expect.any(Number), + sections: expect.any(Array), + }); + }); +}); diff --git a/src/inspect.ts b/src/inspect.ts new file mode 100644 index 0000000..9ce2082 --- /dev/null +++ b/src/inspect.ts @@ -0,0 +1,204 @@ +/** + * `skillforge inspect ` — one-shot diagnostic report for a SKILL.md. + * + * Eighth piece of the authoring workflow after `init`, `validate`, `lint`, + * `update`, `format`, `pack`, `install`. Where the others *do* one thing, + * `inspect` *reads* — it rolls validation, linting, frontmatter parsing, + * body stats, and (for directory inputs) the file inventory into one + * structured report. Useful for "what is this skill, really?" inspection + * and for CI summaries via `--json`. + * + * No side effects: this command never writes to disk. Composed entirely + * out of the existing public APIs (`validateSkill`, `lintSkill`, + * `shouldExcludeEntry`) — no duplicated logic. + */ +import { existsSync } from "node:fs"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { join, relative, resolve, sep } from "node:path"; +import matter from "gray-matter"; +import { type LintResult, lintSkill } from "./lint.js"; +import { shouldExcludeEntry } from "./pack.js"; +import { type SkillFrontmatter, SkillFrontmatterSchema } from "./schema.js"; +import { type ValidateResult, validateSkill } from "./validate.js"; + +export interface InspectOptions { + /** Path to a SKILL.md file or a directory containing one. */ + path: string; + /** + * When true, the CLI emits the result as JSON instead of human-readable + * text. The library returns the same `InspectResult` either way — the + * option is on the result for stable CLI rendering. + */ + json?: boolean; +} + +export interface InspectBodyStats { + /** Number of `\n`-separated lines in the body. */ + lines: number; + /** Number of UTF-16 code units (matches `String.length`). */ + characters: number; + /** Whitespace-split word count. */ + words: number; + /** Names of every `## heading` (level-2 only) found in the body, in order. */ + sections: string[]; +} + +export interface InspectSummary { + /** True iff validation passed AND lint produced no `error`-severity issues. */ + ok: boolean; + validationIssues: number; + lintIssues: number; +} + +export interface InspectResult { + /** Path of the SKILL.md file inspected (absolute or as the user passed it). */ + path: string; + /** + * Skill name pulled from frontmatter, if the file parsed at all. May be + * `undefined` for unparseable inputs — the rest of the report will still + * surface the validation failure. + */ + name?: string; + /** Validated frontmatter (zod-parsed). `undefined` if validation failed. */ + frontmatter?: SkillFrontmatter; + /** Body stats — always present, computed even when validation fails. */ + body: InspectBodyStats; + /** Full validation result, delegated to `validateSkill`. */ + validation: ValidateResult; + /** Full lint result, delegated to `lintSkill`. */ + lint: LintResult; + /** + * If the input path resolved to a directory, every non-excluded file + * (POSIX-relative to the directory). `undefined` when the input was a + * single SKILL.md file. Uses the same exclusion rules as `pack`. + */ + attachedFiles?: string[]; + summary: InspectSummary; +} + +/** + * Resolve the input path to a `{ skillFile, rootDir }` pair. + * - File: `rootDir` is undefined; `attachedFiles` is not produced. + * - Directory: must contain a SKILL.md; `rootDir` is the directory. + */ +async function resolveInput(inputPath: string): Promise<{ skillFile: string; rootDir?: string }> { + if (!existsSync(inputPath)) { + throw new Error(`inspect: ${inputPath} does not exist`); + } + const st = await stat(inputPath); + if (st.isDirectory()) { + const candidate = join(inputPath, "SKILL.md"); + if (!existsSync(candidate)) { + throw new Error(`inspect: ${inputPath} does not contain a SKILL.md`); + } + return { skillFile: candidate, rootDir: inputPath }; + } + return { skillFile: inputPath }; +} + +/** + * Walk `rootDir` and return every non-excluded file as a POSIX-style path + * relative to `rootDir`. Reuses the same exclusion rule as `packSkill` so + * `inspect` and `pack` agree on which files belong to the skill. + */ +async function listAttachedFiles(rootDir: string): Promise { + const results: string[] = []; + await walk(rootDir, rootDir, results); + results.sort(); + return results; +} + +async function walk(rootDir: string, currentDir: string, out: string[]): Promise { + const entries = await readdir(currentDir, { withFileTypes: true }); + for (const entry of entries) { + if (shouldExcludeEntry(entry.name)) continue; + const abs = join(currentDir, entry.name); + if (entry.isDirectory()) { + await walk(rootDir, abs, out); + continue; + } + if (!entry.isFile()) continue; // skip symlinks / sockets / fifos + out.push(relative(rootDir, abs).split(sep).join("/")); + } +} + +/** + * Pull the names of every level-2 (`## heading`) section from the body, in + * document order. We deliberately ignore `#` (title) and `### …` (subsection) + * because skill bodies use `##` for the major narrative sections (`## When + * to use`, `## Examples`, …) and those are what users want to scan. + */ +function extractSections(body: string): string[] { + const sections: string[] = []; + const lines = body.split("\n"); + let inFence = false; + const fenceRe = /^\s{0,3}(```+|~~~+)/; + for (const line of lines) { + if (fenceRe.test(line)) { + inFence = !inFence; + continue; + } + if (inFence) continue; + const m = line.match(/^##\s+(.+?)\s*$/); + if (m) sections.push(m[1]); + } + return sections; +} + +function bodyStats(body: string): InspectBodyStats { + return { + lines: body.split("\n").length, + characters: body.length, + // Whitespace split, drop empty tokens — matches the intuitive "how many + // words is this" count. + words: body.split(/\s+/).filter((w) => w.length > 0).length, + sections: extractSections(body), + }; +} + +export async function inspectSkill(opts: InspectOptions): Promise { + const { skillFile, rootDir } = await resolveInput(opts.path); + + // Read once, derive everything else. We re-read inside validateSkill / + // lintSkill — a small cost for a clean delegation. The alternative + // (passing pre-parsed state around) would couple us to those modules' + // internals. + const raw = await readFile(skillFile, "utf8"); + const parsed = matter(raw); + + const [validation, lint] = await Promise.all([validateSkill(skillFile), lintSkill(skillFile)]); + + // The schema parse here is just to pull `name` and the typed frontmatter + // for the report. Validation issues are already captured in `validation`. + const schemaResult = SkillFrontmatterSchema.safeParse(parsed.data); + const frontmatter = schemaResult.success ? schemaResult.data : undefined; + const name = frontmatter?.name; + + const body = bodyStats(parsed.content); + + let attachedFiles: string[] | undefined; + if (rootDir) { + attachedFiles = await listAttachedFiles(rootDir); + } + + const lintErrorCount = lint.issues.filter((i) => i.severity === "error").length; + const summary: InspectSummary = { + ok: validation.ok && lintErrorCount === 0, + validationIssues: validation.issues.length, + lintIssues: lint.issues.length, + }; + + // `path` is intentionally the resolved SKILL.md — that's what every other + // sub-result already references, and it's the file the user actually + // wants to know about. + return { + path: resolve(skillFile), + name, + frontmatter, + body, + validation, + lint, + attachedFiles, + summary, + }; +} diff --git a/src/pack.ts b/src/pack.ts index 2f7a068..291170e 100644 --- a/src/pack.ts +++ b/src/pack.ts @@ -45,6 +45,22 @@ export interface PackResult { const DEFAULT_EXCLUDES = new Set([".git", "node_modules", ".DS_Store"]); +/** + * The exclusion rule used by `packSkill` when walking a skill directory. + * Exported so siblings (e.g. `inspect`) can show users the same file inventory + * the packer would produce, without duplicating the rule. + * + * Returns `true` for entries that should be skipped: `.git`, `node_modules`, + * `.DS_Store`, anything ending in `.log`, and any hidden dotfile. + */ +export function shouldExcludeEntry(name: string): boolean { + if (DEFAULT_EXCLUDES.has(name)) return true; + if (name.endsWith(".log")) return true; + // Hidden files at any depth — keep VCS / editor cruft out of bundles. + if (name.startsWith(".")) return true; + return false; +} + /** * Walks `srcDir`, zips every non-excluded file into a `.skill` archive, and * writes it to disk. Returns the on-disk path and an inventory. @@ -113,9 +129,5 @@ async function walk( } function shouldExclude(name: string): boolean { - if (DEFAULT_EXCLUDES.has(name)) return true; - if (name.endsWith(".log")) return true; - // Hidden files at any depth — keep VCS / editor cruft out of bundles. - if (name.startsWith(".")) return true; - return false; + return shouldExcludeEntry(name); }