diff --git a/CHANGELOG.md b/CHANGELOG.md index ed2294a..32cef9b 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 tree `: pre-pack file inventory preview. Walks the directory using the *same* exclusion rule as `pack` (`shouldExcludeEntry` reused from `pack.ts` — no duplicated walker, no chance of drift) and returns every non-excluded entry with relative POSIX path, byte size, and `isDir` flag. Optional `--sort path` (default, alphabetised tree order) or `--sort size` (files-only, descending — useful for spotting heavy fixtures). Human-readable mode prints a box-drawing tree (`├──` / `└──`, 2-space indent per level) with a right-aligned size column (bytes under 1 KB, KB with one decimal otherwise) and a `N files · X.Y KB` summary line. `--json` emits the full `TreeResult` for CI. Side-effect free — `tree` never writes. - `skillforge diff `: 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 `: 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. diff --git a/README.md b/README.md index 260b703..426873e 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`, `format`, `inspect`, and `diff` 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`, `diff`, and `tree` work today. Registry, publish, and eval flows land in v0.1. ## Install @@ -188,6 +188,23 @@ skillforge diff ./code-review-v1/SKILL.md ./code-review-v2/SKILL.md 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. +### `skillforge tree ` + +Preview what files `pack` would include in a `.skill` bundle without actually building the archive. Walks the directory with the *same* exclusion rules `pack` uses (`.git`, `node_modules`, hidden files, `*.log`) and prints a tidy box-drawing tree with per-file sizes: + +```bash +skillforge tree ./code-review +# /…/code-review +# ├── 312 B SKILL.md +# ├── templates/ +# │ └── 24 B letter.md +# └── 12 B tool.py +# +# 3 files · 348 B +``` + +Pass `--json` for machine-readable output. Pass `--sort size` to see files in descending byte-size order (useful for spotting heavy fixtures); `--sort path` is the default. Side-effect free — `tree` never writes. + ## Schema ```yaml diff --git a/src/cli.ts b/src/cli.ts index f1ab98f..8996b84 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ * format reformat a SKILL.md to canonical shape * inspect one-shot report: validation + lint + frontmatter + body * diff structural comparison of two SKILL.md files + * tree preview the file inventory pack would produce */ import { cac } from "cac"; import kleur from "kleur"; @@ -21,6 +22,7 @@ import { type InspectResult, inspectSkill } from "./inspect.js"; import { installSkill } from "./install.js"; import { computeExitCode, lintSkill } from "./lint.js"; import { packSkill } from "./pack.js"; +import { type TreeResult, treeSkill } from "./tree.js"; import { type BumpKind, updateSkillVersion } from "./update.js"; import { validateSkill } from "./validate.js"; @@ -454,6 +456,106 @@ function formatValue(v: unknown): string { return String(v); } +cli + .command("tree ", "Preview the file inventory that pack would produce") + .option("--json", "Emit the full result as JSON (machine-readable)") + .option("--sort ", "Sort entries by `path` (default) or `size`") + .action(async (dir: string, opts) => { + try { + const sort = opts.sort; + if (sort !== undefined && sort !== "path" && sort !== "size") { + throw new Error(`--sort must be one of path, size (got "${sort}")`); + } + const result = await treeSkill({ srcDir: dir, sort }); + if (opts.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.exit(0); + } + printTreeReport(result); + process.exit(0); + } catch (err) { + process.stderr.write(`${kleur.red("error:")} ${(err as Error).message}\n`); + process.exit(1); + } + }); + +/** + * Human-readable `tree` report. Box-drawing characters render the + * hierarchy; sizes are right-padded in a fixed column so the eye can + * scan them as a tabular-nums column. Sentence-case heading on the + * directory header, lowercase totals — quiet structural signal. + * + * The tree drawing uses a per-level "is-last-at-this-level" stack so + * the connector characters render correctly even for deep nesting. We + * derive the structure from the already-walked entries rather than + * re-walking the filesystem: path-sort returns entries in tree order + * already. + */ +function printTreeReport(r: TreeResult): void { + const out = process.stdout; + out.write(`${kleur.bold(r.srcDir)}\n`); + + // Group entries by parent path so we know which is last in each group — + // that determines whether to draw `├──` or `└──`. + const childrenByParent = new Map(); + for (const e of r.entries) { + const parent = e.path.includes("/") ? e.path.slice(0, e.path.lastIndexOf("/")) : ""; + const arr = childrenByParent.get(parent) ?? []; + arr.push(e.path); + childrenByParent.set(parent, arr); + } + const entryByPath = new Map(r.entries.map((e) => [e.path, e])); + + // Compute the max size-label width so right-aligned columns line up. + const labels = r.entries.filter((e) => !e.isDir).map((e) => formatSize(e.size)); + const sizeWidth = labels.length === 0 ? 0 : Math.max(...labels.map((l) => l.length)); + + // Walk recursively from the root using path-sort children. + const drawn = new Set(); + const drawNode = (path: string, prefix: string, isLast: boolean): void => { + const connector = isLast ? "└── " : "├── "; + const entry = entryByPath.get(path); + if (!entry) return; + const name = path.includes("/") ? path.slice(path.lastIndexOf("/") + 1) : path; + const sizeLabel = entry.isDir + ? " ".repeat(sizeWidth) + : formatSize(entry.size).padStart(sizeWidth, " "); + const display = entry.isDir ? kleur.cyan(`${name}/`) : name; + out.write(`${prefix}${connector}${kleur.dim(sizeLabel)} ${display}\n`); + drawn.add(path); + + if (entry.isDir) { + const kids = childrenByParent.get(path) ?? []; + for (let i = 0; i < kids.length; i += 1) { + const nextPrefix = prefix + (isLast ? " " : "│ "); + drawNode(kids[i], nextPrefix, i === kids.length - 1); + } + } + }; + + const roots = childrenByParent.get("") ?? []; + for (let i = 0; i < roots.length; i += 1) { + drawNode(roots[i], "", i === roots.length - 1); + } + + // Tabular-style totals — files count + total size in human units. Dot + // separator matches the rest of the CLI's "summary" lines. + const totalLabel = `${r.totalFiles} file${r.totalFiles === 1 ? "" : "s"} · ${formatSize( + r.totalBytes, + )}`; + out.write(`\n${kleur.dim(totalLabel)}\n`); +} + +/** + * Render a byte count as either bytes (< 1 KB) or KB with one decimal. + * Returned without a leading space so callers can right-pad to a fixed + * width for tabular-nums alignment. + */ +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + return `${(bytes / 1024).toFixed(1)} KB`; +} + cli.help(); cli.version(VERSION); cli.parse(); diff --git a/src/index.ts b/src/index.ts index c8c8295..f4180a6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,7 @@ export { type Severity, } from "./lint.js"; export { SkillFrontmatterSchema, type SkillFrontmatter } from "./schema.js"; +export { type TreeEntry, type TreeOptions, type TreeResult, treeSkill } from "./tree.js"; export { type BumpKind, bumpVersion, diff --git a/src/tree.test.ts b/src/tree.test.ts new file mode 100644 index 0000000..0ba442c --- /dev/null +++ b/src/tree.test.ts @@ -0,0 +1,158 @@ +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 { treeSkill } from "./tree.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-tree-"))); +}); + +const HEALTHY_SKILL_MD = `--- +name: my-skill +description: Use this when the user asks for the demo skill described in this file. +version: 0.1.0 +tags: [] +--- + +# my-skill + +body body body body body +`; + +async function writeSkill(dir: string): Promise { + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "SKILL.md"), HEALTHY_SKILL_MD); +} + +describe("treeSkill — happy path", () => { + it("walks a populated skill dir and matches pack's exclusion rules exactly", async () => { + const skillDir = join(workDir, "my-skill"); + await writeSkill(skillDir); + await mkdir(join(skillDir, "templates"), { recursive: true }); + await writeFile(join(skillDir, "templates", "letter.md"), "Dear ..."); + await writeFile(join(skillDir, "tool.py"), "print('hi')"); + // These four 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"); + await mkdir(join(skillDir, "node_modules"), { recursive: true }); + await writeFile(join(skillDir, "node_modules", "foo.js"), "module.exports = 1;"); + + const r = await treeSkill({ srcDir: skillDir }); + + const paths = r.entries.map((e) => e.path); + expect(paths).toContain("SKILL.md"); + expect(paths).toContain("templates"); + expect(paths).toContain("templates/letter.md"); + expect(paths).toContain("tool.py"); + // Same exclusions as pack — assert each individually for a useful diff. + expect(paths).not.toContain(".DS_Store"); + expect(paths).not.toContain("debug.log"); + expect(paths.some((p) => p.startsWith(".git"))).toBe(false); + expect(paths.some((p) => p.startsWith("node_modules"))).toBe(false); + // File count excludes directories. + expect(r.totalFiles).toBe(3); + }); +}); + +describe("treeSkill — minimal dir", () => { + it("returns exactly one entry for a dir containing only SKILL.md", async () => { + const skillDir = join(workDir, "tiny"); + await writeSkill(skillDir); + const r = await treeSkill({ srcDir: skillDir }); + expect(r.entries.map((e) => e.path)).toEqual(["SKILL.md"]); + expect(r.totalFiles).toBe(1); + expect(r.totalBytes).toBe(HEALTHY_SKILL_MD.length); + }); +}); + +describe("treeSkill — sort modes", () => { + it("default `path` sort returns entries in alphabetised tree order", async () => { + const skillDir = join(workDir, "sorted"); + await writeSkill(skillDir); + await writeFile(join(skillDir, "zeta.txt"), "z"); + await writeFile(join(skillDir, "alpha.txt"), "a"); + await mkdir(join(skillDir, "middle"), { recursive: true }); + await writeFile(join(skillDir, "middle", "nested.txt"), "n"); + + const r = await treeSkill({ srcDir: skillDir }); + const paths = r.entries.map((e) => e.path); + // alphabetised: SKILL.md, alpha.txt, middle, middle/nested.txt, zeta.txt + expect(paths).toEqual(["SKILL.md", "alpha.txt", "middle", "middle/nested.txt", "zeta.txt"]); + }); + + it("`size` sort returns files-only, descending by byte size", async () => { + const skillDir = join(workDir, "sized"); + await writeSkill(skillDir); + await writeFile(join(skillDir, "tiny.txt"), "x"); // 1 byte + await writeFile(join(skillDir, "huge.txt"), "x".repeat(500)); + await writeFile(join(skillDir, "mid.txt"), "x".repeat(100)); + await mkdir(join(skillDir, "subdir"), { recursive: true }); + await writeFile(join(skillDir, "subdir", "leaf.txt"), "x".repeat(50)); + + const r = await treeSkill({ srcDir: skillDir, sort: "size" }); + + // Directories absent under size-sort. + expect(r.entries.every((e) => !e.isDir)).toBe(true); + // Strictly descending by size. + const sizes = r.entries.map((e) => e.size); + for (let i = 1; i < sizes.length; i += 1) { + expect(sizes[i - 1]).toBeGreaterThanOrEqual(sizes[i]); + } + // First entry is `huge.txt` (500 bytes). + expect(r.entries[0].path).toBe("huge.txt"); + expect(r.entries[0].size).toBe(500); + }); +}); + +describe("treeSkill — totals are accurate", () => { + it("sums byte sizes of file entries only", async () => { + const skillDir = join(workDir, "bytes"); + await writeSkill(skillDir); + await writeFile(join(skillDir, "a.txt"), "ab"); // 2 bytes + await writeFile(join(skillDir, "b.txt"), "abcd"); // 4 bytes + + const r = await treeSkill({ srcDir: skillDir }); + expect(r.totalFiles).toBe(3); // SKILL.md + a.txt + b.txt + expect(r.totalBytes).toBe(HEALTHY_SKILL_MD.length + 2 + 4); + }); +}); + +describe("treeSkill — error paths", () => { + it("throws when the path does not exist", async () => { + await expect(treeSkill({ srcDir: join(workDir, "missing") })).rejects.toThrow(/does not exist/); + }); + + it("throws when the path is a file, not a directory", async () => { + const filePath = join(workDir, "not-a-dir"); + await writeFile(filePath, "hi"); + await expect(treeSkill({ srcDir: filePath })).rejects.toThrow(/not a directory/); + }); +}); + +describe("treeSkill — entry shape", () => { + it("returns POSIX-relative paths and correct isDir flags", async () => { + const skillDir = join(workDir, "shaped"); + await writeSkill(skillDir); + await mkdir(join(skillDir, "sub"), { recursive: true }); + await writeFile(join(skillDir, "sub", "leaf.md"), "leaf"); + + const r = await treeSkill({ srcDir: skillDir }); + const byPath = new Map(r.entries.map((e) => [e.path, e])); + expect(byPath.get("SKILL.md")?.isDir).toBe(false); + expect(byPath.get("sub")?.isDir).toBe(true); + expect(byPath.get("sub/leaf.md")?.isDir).toBe(false); + expect(byPath.get("sub/leaf.md")?.size).toBe(4); // "leaf" + // POSIX separator — never a backslash, even on hypothetical win32 runs. + for (const p of r.entries.map((e) => e.path)) { + expect(p.includes("\\")).toBe(false); + } + }); +}); diff --git a/src/tree.ts b/src/tree.ts new file mode 100644 index 0000000..2247dff --- /dev/null +++ b/src/tree.ts @@ -0,0 +1,121 @@ +/** + * `skillforge tree ` — preview the file inventory a `pack` would + * produce, without actually building the archive. + * + * Tenth piece of the authoring workflow after `init`, `validate`, `lint`, + * `update`, `format`, `pack`, `install`, `inspect`, `diff`. Where `pack` + * *builds* and `inspect` *reads* a single skill end-to-end, `tree` is a + * one-shot pre-flight: walk the directory using the same exclusion logic + * `pack` uses, return a tidy file listing with sizes. + * + * Reuses `shouldExcludeEntry` from `pack.ts` so the two stay byte-for-byte + * in agreement on which files belong in a skill — no duplicated walker. + * + * No side effects: this command never writes to disk. + */ +import { readdir, stat } from "node:fs/promises"; +import { join, relative, resolve, sep } from "node:path"; +import { shouldExcludeEntry } from "./pack.js"; + +export interface TreeOptions { + /** Directory to walk. Must exist and be a directory. */ + srcDir: string; + /** + * Sort order for the returned `entries`. `"path"` (default) walks the + * tree in directory-then-file order, alphabetised within each level — + * matches what a user would see if they ran `ls -R`. `"size"` sorts by + * descending byte size, useful for "what's making this skill heavy?" + * spot checks. Directory entries are excluded under `"size"` because + * their size is filesystem-defined and not a meaningful "weight". + */ + sort?: "path" | "size"; +} + +export interface TreeEntry { + /** POSIX-relative path from `srcDir`. Directories appear without a trailing slash. */ + path: string; + /** File size in bytes. Directories report the filesystem-reported size (informational only). */ + size: number; + /** True for directory entries, false for files. */ + isDir: boolean; +} + +export interface TreeResult { + /** Absolute, resolved srcDir. */ + srcDir: string; + /** Every non-excluded entry, in the requested sort order. */ + entries: TreeEntry[]; + /** Count of file entries (directories excluded). */ + totalFiles: number; + /** Sum of file sizes in bytes. */ + totalBytes: number; +} + +/** + * Walk `srcDir` and return every non-excluded entry. Uses the same + * exclusion rule as `packSkill` so authors can trust that what `tree` + * shows is what `pack` would bundle. + */ +export async function treeSkill(opts: TreeOptions): Promise { + const src = resolve(opts.srcDir); + const srcStat = await stat(src).catch(() => null); + if (!srcStat) { + throw new Error(`tree: ${opts.srcDir} does not exist`); + } + if (!srcStat.isDirectory()) { + throw new Error(`tree: ${opts.srcDir} is not a directory`); + } + + const entries: TreeEntry[] = []; + await walk(src, src, entries); + + const sort = opts.sort ?? "path"; + let sorted: TreeEntry[]; + if (sort === "size") { + // Directories drop out under size-sort — their "size" is filesystem + // bookkeeping, not skill content. Files only, descending. + sorted = entries.filter((e) => !e.isDir).sort((a, b) => b.size - a.size); + } else { + // Path-sort is the natural walk order — entries are already in tree + // order from the walk, and `walk` alphabetises each directory's + // children by raw byte order. Use the same byte-order comparator here + // so siblings stay grouped under their parent directory. (A locale + // comparator would intermix top-level entries with deep ones, e.g. + // `SKILL.md` after `middle/nested.txt`.) + sorted = entries.slice().sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + } + + let totalFiles = 0; + let totalBytes = 0; + for (const e of entries) { + if (!e.isDir) { + totalFiles += 1; + totalBytes += e.size; + } + } + + return { srcDir: src, entries: sorted, totalFiles, totalBytes }; +} + +async function walk(rootDir: string, currentDir: string, out: TreeEntry[]): Promise { + const dirEntries = await readdir(currentDir, { withFileTypes: true }); + // Alphabetise so two runs on the same tree produce the same output, and + // so the human-readable rendering reads naturally top-to-bottom. Raw + // byte order, not locale-aware — matches the path-sort comparator and + // keeps the result stable across machines with different locales. + dirEntries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const entry of dirEntries) { + if (shouldExcludeEntry(entry.name)) continue; + const abs = join(currentDir, entry.name); + const rel = relative(rootDir, abs).split(sep).join("/"); + if (entry.isDirectory()) { + const st = await stat(abs); + out.push({ path: rel, size: st.size, isDir: true }); + await walk(rootDir, abs, out); + continue; + } + if (!entry.isFile()) continue; // skip symlinks, sockets, FIFOs — same as pack + const st = await stat(abs); + out.push({ path: rel, size: st.size, isDir: false }); + } +}