Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
## [Unreleased]

### Added
- `skillforge diff <a> <b>`: structural comparison of two SKILL.md files. Reports per-field frontmatter changes (added / removed / changed with before/after), `##` and `###` heading-level changes (added / removed / reordered with `from`/`to` positions, computed over the intersection so a single insert doesn't cascade as N moves), and a coarse body line-count delta. Refuses to diff either file if its frontmatter fails schema validation — fix the file first. Human-readable mode prints a tidy multi-section report with kleur colour (green adds, red removes, yellow changes); `--json` emits the full `DiffResult` for CI. Exits 0 when structurally identical, 1 when they differ (matches `mcp-devtools diff`), 2 on validation/IO error. Side-effect free.
- `skillforge inspect <path>`: 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 <path>`: 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 <path>`: bump the `version:` field of a SKILL.md in one shot — `--bump <patch|minor|major>` or `--new-version <semver>` (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.
Expand Down
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `format`, and `inspect` work today. Registry, publish, and eval flows land in v0.1.
> **Status — v0.0.2, early days.** `init`, `validate`, `lint`, `pack`, `install`, `update`, `format`, `inspect`, and `diff` work today. Registry, publish, and eval flows land in v0.1.

## Install

Expand Down Expand Up @@ -166,6 +166,28 @@ skillforge inspect ./code-review

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.

### `skillforge diff <a> <b>`

Structural comparison of two `SKILL.md` files. A plain `diff` is noisy — a reordered heading or a one-word frontmatter change drowns in re-wrapped paragraphs. `skillforge diff` pulls the structural signal out: which frontmatter fields changed, which `##` / `###` sections were added, removed, or reordered, and a coarse body line-count delta.

```bash
skillforge diff ./code-review-v1/SKILL.md ./code-review-v2/SKILL.md
# diff …v1/SKILL.md → …v2/SKILL.md
# frontmatter: 1 headings: 2 body lines: +6 -3
#
# FRONTMATTER
# ~ version: 0.1.0 → 0.2.0
#
# HEADINGS
# + Examples
# ~ When to use it (position 1 → 2)
#
# BODY
# +6 -3 lines (coarse)
```

Pass `--json` for machine-readable output. Exit `0` if the files are structurally identical, `1` if they differ (mirrors `mcp-devtools diff`), `2` on validation or IO error. Both files must validate against the schema — broken frontmatter is refused so the structural view stays trustworthy.

## Schema

```yaml
Expand Down
108 changes: 108 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
* update <path> bump the version field of a SKILL.md
* format <path> reformat a SKILL.md to canonical shape
* inspect <path> one-shot report: validation + lint + frontmatter + body
* diff <a> <b> structural comparison of two SKILL.md files
*/
import { cac } from "cac";
import kleur from "kleur";
import { type DiffResult, diffSkills } from "./diff.js";
import { formatSkill } from "./format.js";
import { initSkill } from "./init.js";
import { type InspectResult, inspectSkill } from "./inspect.js";
Expand Down Expand Up @@ -346,6 +348,112 @@ function truncate(s: string, max: number): string {
return `${s.slice(0, max - 1)}…`;
}

cli
.command("diff <a> <b>", "Structural diff of two SKILL.md files")
.option("--json", "Emit the full diff result as JSON (machine-readable)")
.action(async (a: string, b: string, opts) => {
try {
const result = await diffSkills(a, b);
if (opts.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
process.exit(result.identical ? 0 : 1);
}
printDiffReport(result);
process.exit(result.identical ? 0 : 1);
} catch (err) {
process.stderr.write(`${kleur.red("error:")} ${(err as Error).message}\n`);
// Exit 2 for validation/IO failure, distinct from the "files differ"
// exit 1 — mirrors `mcp-devtools diff` convention so CI scripts can
// tell "noisy" from "broken".
process.exit(2);
}
});

/**
* Human-readable `diff` report. A short summary header, then one section
* per kind of change (frontmatter, headings, body). Prose-oriented:
* paragraphs where the comparison is best read as a sentence, lists only
* where the items are genuinely parallel. Additions in green, removals in
* red, changes in yellow.
*/
function printDiffReport(r: DiffResult): void {
const out = process.stdout;
const heading = (label: string) => out.write(`\n${kleur.bold(kleur.dim(label.toUpperCase()))}\n`);

if (r.identical) {
out.write(
`${kleur.green("✓")} ${r.pathA} ${kleur.dim("≡")} ${r.pathB} ${kleur.dim("— structurally identical")}\n`,
);
return;
}

// Summary line — counts at a glance, full breakdown follows.
const fm = r.frontmatter;
const fmCount = Object.keys(fm.added).length + Object.keys(fm.removed).length + fm.changed.length;
const hd = r.bodyHeadings;
const hdCount = hd.added.length + hd.removed.length + hd.reordered.length;
out.write(
`${kleur.bold("diff")} ${kleur.dim(r.pathA)} ${kleur.dim("→")} ${kleur.dim(r.pathB)}\n`,
);
out.write(
`${kleur.dim(" frontmatter:")} ${fmCount} ${kleur.dim("headings:")} ${hdCount} ${kleur.dim("body lines:")} ${kleur.green(`+${r.bodyLinesDelta.added}`)} ${kleur.red(`-${r.bodyLinesDelta.removed}`)}\n`,
);

if (fmCount > 0) {
heading("Frontmatter");
for (const [k, v] of Object.entries(fm.added)) {
out.write(` ${kleur.green("+")} ${k}: ${formatValue(v)}\n`);
}
for (const [k, v] of Object.entries(fm.removed)) {
out.write(` ${kleur.red("-")} ${k}: ${formatValue(v)}\n`);
}
for (const c of fm.changed) {
out.write(
` ${kleur.yellow("~")} ${c.key}: ${kleur.red(formatValue(c.before))} ${kleur.dim("→")} ${kleur.green(formatValue(c.after))}\n`,
);
}
}

if (hdCount > 0) {
heading("Headings");
for (const h of hd.added) {
out.write(` ${kleur.green("+")} ${h}\n`);
}
for (const h of hd.removed) {
out.write(` ${kleur.red("-")} ${h}\n`);
}
for (const m of hd.reordered) {
out.write(
` ${kleur.yellow("~")} ${m.heading} ${kleur.dim(`(position ${m.from} → ${m.to})`)}\n`,
);
}
}

if (r.bodyLinesDelta.added > 0 || r.bodyLinesDelta.removed > 0) {
heading("Body");
out.write(
` ${kleur.green(`+${r.bodyLinesDelta.added}`)} ${kleur.red(`-${r.bodyLinesDelta.removed}`)} ${kleur.dim("lines (coarse)")}\n`,
);
}
}

/**
* Render a frontmatter value as a short inline string. Arrays show as
* `[a, b]`; objects as JSON; long strings get truncated with an ellipsis
* so the diff stays scannable even with multi-line `description` fields.
*/
function formatValue(v: unknown): string {
if (v === undefined) return kleur.dim("∅");
if (v === null) return "null";
if (Array.isArray(v)) return `[${v.map((x) => formatValue(x)).join(", ")}]`;
if (typeof v === "string") {
const single = v.replace(/\s+/g, " ");
return single.length > 64 ? `${single.slice(0, 63)}…` : single;
}
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}

cli.help();
cli.version(VERSION);
cli.parse();
Loading
Loading