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 tree <dir>`: 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 <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.
Expand Down
19 changes: 18 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`, `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

Expand Down Expand Up @@ -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 <dir>`

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
Expand Down
102 changes: 102 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* 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
* tree <dir> preview the file inventory pack would produce
*/
import { cac } from "cac";
import kleur from "kleur";
Expand All @@ -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";

Expand Down Expand Up @@ -454,6 +456,106 @@ function formatValue(v: unknown): string {
return String(v);
}

cli
.command("tree <dir>", "Preview the file inventory that pack would produce")
.option("--json", "Emit the full result as JSON (machine-readable)")
.option("--sort <mode>", "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<string, string[]>();
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<string>();
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();
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
158 changes: 158 additions & 0 deletions src/tree.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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);
}
});
});
Loading
Loading