From d64b34ccd7414ca464a7b3ffeceddcd941b94ed1 Mon Sep 17 00:00:00 2001 From: Aditya Chilka Date: Thu, 28 May 2026 02:48:29 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat(cli):=20skillforge=20ls=20=E2=80=94=20?= =?UTF-8?q?list=20installed=20skills?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 31 ++++++++- src/cli.ts | 60 ++++++++++++++++ src/index.ts | 6 ++ src/ls.test.ts | 183 +++++++++++++++++++++++++++++++++++++++++++++++++ src/ls.ts | 158 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 src/ls.test.ts create mode 100644 src/ls.ts diff --git a/README.md b/README.md index 6feddff..7284853 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`, `diff`, `tree`, and `cat` 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`, `tree`, `cat`, and `ls` work today. Registry, publish, and eval flows land in v0.1. ## Install @@ -245,6 +245,33 @@ skillforge cat ./code-review.skill --json Validates frontmatter against the schema before emitting — a broken `.skill` is refused with a clear error rather than printing garbage. Default behaviour prints the raw bytes; `--section frontmatter` returns just the YAML (no `---` fences), `--section body` returns the markdown body. Pass `--json` for a structured `{ name, version, frontmatter, body }` payload. Side-effect free — `cat` never extracts other files or writes to disk. +### `skillforge ls` + +List installed skills in `~/.claude/skills/` (the default `install` target) — the `npm ls` analogue for the Claude skills tree: + +```bash +skillforge ls +# INSTALLED SKILLS (/Users/you/.claude/skills) +# +# Name Version Path +# alpha-skill 0.2.1 /Users/you/.claude/skills/alpha-skill +# code-review 1.0.0 /Users/you/.claude/skills/code-review +# zebra-skill 3.0.0-beta /Users/you/.claude/skills/zebra-skill +# +# 3 skills installed + +skillforge ls --from ./local-skills +# scan a different tree (CI fixtures, sandbox testing) + +skillforge ls --include-invalid +# include skill dirs whose SKILL.md fails validation, tagged `(invalid)` + +skillforge ls --json | jq '.skills[] | .name' +# machine-readable output for shell pipelines +``` + +Read-only directory scan — `ls` never fetches, never writes. A missing `~/.claude/skills/` returns an empty result with exit 0 (a fresh machine is not an error); a `--from` path that points at a file rather than a directory is a hard error. Loose files and skill-less subdirectories are skipped silently — a half-pulled install shouldn't pollute every `ls` invocation. Results are sorted by name ascending so the output is stable across platforms. + ## Schema ```yaml @@ -267,7 +294,7 @@ Unknown frontmatter fields are preserved (forward-compatible with whatever Anthr ## Roadmap - **v0.0.1** — `init`, `validate` ✓ -- **v0.0.2** — `pack`, `install` ✓ (this release) +- **v0.0.2** — `pack`, `install`, `ls` ✓ (this release) - **v0.1** — `publish` to the skillforge.dev registry, eval suite, `install` from registry - **v0.2** — MCP-compatible installer so any MCP client can install skills from the registry diff --git a/src/cli.ts b/src/cli.ts index 0e64315..5906687 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -13,6 +13,7 @@ * diff structural comparison of two SKILL.md files * tree preview the file inventory pack would produce * cat print the bundled SKILL.md of a .skill archive + * ls list installed skills in ~/.claude/skills/ */ import { cac } from "cac"; import kleur from "kleur"; @@ -23,6 +24,7 @@ import { initSkill } from "./init.js"; import { type InspectResult, inspectSkill } from "./inspect.js"; import { installSkill } from "./install.js"; import { computeExitCode, lintSkill } from "./lint.js"; +import { type InstalledSkill, listInstalledSkills } from "./ls.js"; import { packSkill } from "./pack.js"; import { type TreeResult, treeSkill } from "./tree.js"; import { type BumpKind, updateSkillVersion } from "./update.js"; @@ -616,6 +618,64 @@ cli } }); +cli + .command("ls", "List installed skills (default: ~/.claude/skills)") + .option("--from ", "Scan a different skills directory") + .option("--include-invalid", "Include skill dirs whose SKILL.md fails validation") + .option("--json", "Emit the full LsResult as JSON (machine-readable)") + .action(async (opts) => { + try { + const result = await listInstalledSkills({ + fromDir: opts.from, + includeInvalid: !!opts.includeInvalid, + }); + if (opts.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + process.exit(0); + } + printLsReport(result); + process.exit(0); + } catch (err) { + process.stderr.write(`${kleur.red("error:")} ${(err as Error).message}\n`); + process.exit(1); + } + }); + +/** + * Human-readable `ls` report. Tidy 3-column table: name, version, path. + * Tabular-nums alignment via right-padded columns; sentence-case heading + * in tracked uppercase — quiet structural signal, not editorial shouting. + * Empty-state message names the scanned directory so the reader knows + * which tree came up empty. + */ +function printLsReport(r: { fromDir: string; count: number; skills: InstalledSkill[] }): void { + const out = process.stdout; + if (r.count === 0) { + out.write(`${kleur.dim(`No skills installed in ${r.fromDir}`)}\n`); + return; + } + // Column widths sized to actual content so narrow trees stay compact and + // long names still align. Header row joins the alignment too. + const headers = { name: "Name", version: "Version", path: "Path" }; + const nameWidth = Math.max(headers.name.length, ...r.skills.map((s) => s.name.length)); + const versionWidth = Math.max(headers.version.length, ...r.skills.map((s) => s.version.length)); + + out.write(`${kleur.bold(kleur.dim("INSTALLED SKILLS"))} ${kleur.dim(`(${r.fromDir})`)}\n\n`); + out.write( + `${kleur.dim(headers.name.padEnd(nameWidth))} ${kleur.dim(headers.version.padEnd(versionWidth))} ${kleur.dim(headers.path)}\n`, + ); + for (const s of r.skills) { + // Invalid skills get a dim row + a trailing tag so the reader can tell + // them apart at a glance without breaking column alignment. + const tag = s.valid ? "" : ` ${kleur.yellow("(invalid)")}`; + const colorize = s.valid ? (x: string) => x : kleur.dim; + out.write( + `${colorize(s.name.padEnd(nameWidth))} ${colorize(s.version.padEnd(versionWidth))} ${kleur.dim(s.path)}${tag}\n`, + ); + } + out.write(`\n${kleur.dim(`${r.count} skill${r.count === 1 ? "" : "s"} installed`)}\n`); +} + cli.help(); cli.version(VERSION); cli.parse(); diff --git a/src/index.ts b/src/index.ts index ccff397..9b56814 100644 --- a/src/index.ts +++ b/src/index.ts @@ -26,6 +26,12 @@ export { type LintResult, type Severity, } from "./lint.js"; +export { + type InstalledSkill, + listInstalledSkills, + type LsOptions, + type LsResult, +} from "./ls.js"; export { SkillFrontmatterSchema, type SkillFrontmatter } from "./schema.js"; export { type TreeEntry, type TreeOptions, type TreeResult, treeSkill } from "./tree.js"; export { diff --git a/src/ls.test.ts b/src/ls.test.ts new file mode 100644 index 0000000..684a311 --- /dev/null +++ b/src/ls.test.ts @@ -0,0 +1,183 @@ +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 { listInstalledSkills } from "./ls.js"; + +let workDir: string; + +beforeEach(async () => { + // realpath because macOS `tmpdir()` returns `/tmp` while resolved paths + // come back as `/private/tmp`. Same trick as sibling test files. + workDir = realpathSync(await mkdtemp(join(tmpdir(), "skillforge-ls-"))); +}); + +const VALID_DESC = "Use this when the user asks for the demo skill described in this file."; + +function frontmatter(opts: { name: string; version?: string; description?: string }): string { + return [ + "---", + `name: ${opts.name}`, + `description: ${opts.description ?? VALID_DESC}`, + `version: ${opts.version ?? "0.1.0"}`, + "tags: []", + "---", + "", + "# body", + "", + "Some prose so the body is non-empty.", + "", + ].join("\n"); +} + +async function writeSkillDir( + parent: string, + name: string, + opts: { version?: string; description?: string; skipSkillMd?: boolean } = {}, +): Promise { + const dir = join(parent, name); + await mkdir(dir, { recursive: true }); + if (!opts.skipSkillMd) { + await writeFile( + join(dir, "SKILL.md"), + frontmatter({ name, version: opts.version, description: opts.description }), + ); + } + return dir; +} + +describe("listInstalledSkills — empty dir", () => { + it("returns count 0 and an empty array for a freshly-created empty directory", async () => { + const result = await listInstalledSkills({ fromDir: workDir }); + expect(result.count).toBe(0); + expect(result.skills).toEqual([]); + expect(result.fromDir).toBe(workDir); + }); +}); + +describe("listInstalledSkills — single skill", () => { + it("returns count 1 with name + version from frontmatter", async () => { + await writeSkillDir(workDir, "code-review", { version: "1.2.3" }); + const result = await listInstalledSkills({ fromDir: workDir }); + expect(result.count).toBe(1); + expect(result.skills).toHaveLength(1); + expect(result.skills[0].name).toBe("code-review"); + expect(result.skills[0].version).toBe("1.2.3"); + expect(result.skills[0].source).toBe("dir"); + expect(result.skills[0].valid).toBe(true); + expect(result.skills[0].path).toBe(join(workDir, "code-review")); + }); +}); + +describe("listInstalledSkills — sorting", () => { + it("returns three skills sorted alphabetically by name", async () => { + await writeSkillDir(workDir, "zebra-skill"); + await writeSkillDir(workDir, "alpha-skill"); + await writeSkillDir(workDir, "middle-skill"); + const result = await listInstalledSkills({ fromDir: workDir }); + expect(result.count).toBe(3); + expect(result.skills.map((s) => s.name)).toEqual([ + "alpha-skill", + "middle-skill", + "zebra-skill", + ]); + }); +}); + +describe("listInstalledSkills — invalid skills", () => { + it("excludes invalid skills by default", async () => { + await writeSkillDir(workDir, "valid-one"); + // Invalid: description is too short (zod requires >= 20 chars) + await writeSkillDir(workDir, "broken-one", { description: "too short" }); + const result = await listInstalledSkills({ fromDir: workDir }); + expect(result.count).toBe(1); + expect(result.skills.map((s) => s.name)).toEqual(["valid-one"]); + }); + + it("includes invalid skills with valid: false when includeInvalid is true", async () => { + await writeSkillDir(workDir, "valid-one"); + await writeSkillDir(workDir, "broken-one", { description: "too short" }); + const result = await listInstalledSkills({ fromDir: workDir, includeInvalid: true }); + expect(result.count).toBe(2); + const broken = result.skills.find((s) => s.path.endsWith("broken-one")); + expect(broken).toBeDefined(); + expect(broken?.valid).toBe(false); + expect(broken?.issues).toBeDefined(); + expect(broken?.issues?.length).toBeGreaterThan(0); + // valid skill has no `issues` field + const good = result.skills.find((s) => s.path.endsWith("valid-one")); + expect(good?.valid).toBe(true); + expect(good?.issues).toBeUndefined(); + }); +}); + +describe("listInstalledSkills — fromDir override", () => { + it("uses the explicit fromDir argument instead of the default", async () => { + await writeSkillDir(workDir, "one"); + const result = await listInstalledSkills({ fromDir: workDir }); + expect(result.fromDir).toBe(workDir); + expect(result.count).toBe(1); + }); +}); + +describe("listInstalledSkills — non-existent dir", () => { + it("returns count 0 and an empty array when fromDir does not exist (does not throw)", async () => { + const missing = join(workDir, "does-not-exist"); + const result = await listInstalledSkills({ fromDir: missing }); + expect(result.count).toBe(0); + expect(result.skills).toEqual([]); + expect(result.fromDir).toBe(missing); + }); +}); + +describe("listInstalledSkills — fromDir is a file", () => { + it("throws a clear error when fromDir points to a file (not a directory)", async () => { + const file = join(workDir, "iamafile.txt"); + await writeFile(file, "not a dir"); + await expect(listInstalledSkills({ fromDir: file })).rejects.toThrow(/not a directory/); + }); +}); + +describe("listInstalledSkills — child without SKILL.md", () => { + it("silently skips child directories that contain no SKILL.md", async () => { + await writeSkillDir(workDir, "valid-skill"); + await writeSkillDir(workDir, "junk-dir", { skipSkillMd: true }); + const result = await listInstalledSkills({ fromDir: workDir }); + expect(result.count).toBe(1); + expect(result.skills.map((s) => s.name)).toEqual(["valid-skill"]); + }); +}); + +describe("listInstalledSkills — LsResult shape", () => { + it("returns an object whose keys exactly match the documented LsResult shape", async () => { + await writeSkillDir(workDir, "demo"); + const result = await listInstalledSkills({ fromDir: workDir }); + expect(Object.keys(result).sort()).toEqual(["count", "fromDir", "skills"]); + const skill = result.skills[0]; + // valid skill: no `issues` + expect(Object.keys(skill).sort()).toEqual(["name", "path", "source", "valid", "version"]); + }); +}); + +describe("listInstalledSkills — ignores non-directory entries", () => { + it("skips loose files sitting next to skill directories", async () => { + await writeSkillDir(workDir, "real-skill"); + await writeFile(join(workDir, "stray.txt"), "noise"); + const result = await listInstalledSkills({ fromDir: workDir }); + expect(result.count).toBe(1); + expect(result.skills[0].name).toBe("real-skill"); + }); +}); + +describe("listInstalledSkills — default fromDir", () => { + it("uses ~/.claude/skills when no fromDir is provided (smoke-only — does not assert count)", async () => { + // We can't assert what's in the user's real ~/.claude/skills without + // touching their machine state, so we just confirm the function resolves + // and reports the default path. + const result = await listInstalledSkills(); + expect(result.fromDir).toMatch(/\.claude\/skills$/); + expect(Array.isArray(result.skills)).toBe(true); + expect(result.count).toBe(result.skills.length); + }); +}); diff --git a/src/ls.ts b/src/ls.ts new file mode 100644 index 0000000..7d93bda --- /dev/null +++ b/src/ls.ts @@ -0,0 +1,158 @@ +/** + * `skillforge ls [--from ]` — list installed skills. + * + * The natural complement to `install`: discover what's sitting in the + * user's `~/.claude/skills/` directory, with name + version pulled from + * each skill's `SKILL.md` frontmatter and an absolute path the reader + * can `cd` into or feed to a sibling command. Like `npm ls`, but for + * the Claude skills tree. + * + * Behaviour rules and the reasoning behind them: + * + * 1. **Discovery is a read-only directory scan.** No fetching, no + * network — `ls` only reports what's already on disk. The default + * root is `~/.claude/skills/`, matching where `install` writes by + * default; `--from ` overrides for tests and alt-trees. + * + * 2. **A "skill" is a child directory containing a SKILL.md.** Loose + * files in the parent tree are ignored silently — they aren't + * skills. Child directories without a SKILL.md are also skipped + * silently rather than reported as broken; a half-pulled install + * shouldn't pollute every `ls` invocation. + * + * 3. **Frontmatter validation gates inclusion by default.** A skill + * directory whose `SKILL.md` fails the same `SkillFrontmatterSchema` + * that `install` enforces is excluded from the count and the + * results array. `--include-invalid` flips that to "include them + * with `valid: false` and an `issues` list" — useful when the user + * is debugging "why isn't my skill showing up". + * + * 4. **Non-existent root → empty result, exit 0.** A fresh machine + * with no `~/.claude/skills/` yet is not an error condition; the + * answer is "zero installed". This matches what `npm ls` does in + * an empty project. + * + * 5. **`fromDir` that is a file is a hard error.** The user passed + * `--from `; if it's a file rather than a directory, + * that's a typo on their end, not "zero skills". + * + * 6. **Results are sorted by name ascending.** Stable output is the + * whole point of a list command — the order should not depend on + * filesystem readdir order, which varies between platforms. + * + * No new runtime deps. Reuses `gray-matter` (already on deps from + * `validate` / `lint` / `inspect`) and `SkillFrontmatterSchema` from + * `schema.ts`. + */ +import { existsSync } from "node:fs"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import matter from "gray-matter"; +import { SkillFrontmatterSchema } from "./schema.js"; + +export interface LsOptions { + /** Root directory to scan. Defaults to `~/.claude/skills`. */ + fromDir?: string; + /** Include directories whose SKILL.md fails frontmatter validation. */ + includeInvalid?: boolean; +} + +export interface InstalledSkill { + /** Skill name pulled from SKILL.md frontmatter (or the dir name when invalid). */ + name: string; + /** Skill version pulled from SKILL.md frontmatter. */ + version: string; + /** Absolute path to the skill directory. */ + path: string; + /** Discriminator. Future-proofs the shape for `.skill` archive listings later. */ + source: "dir"; + /** Whether the SKILL.md frontmatter validated cleanly. */ + valid: boolean; + /** Validation issues. Only present when `!valid` (and `includeInvalid: true`). */ + issues?: string[]; +} + +export interface LsResult { + /** Absolute path of the directory that was scanned (post `--from` resolution). */ + fromDir: string; + /** Number of entries in `skills`. */ + count: number; + /** Discovered skills, sorted by `name` ascending. */ + skills: InstalledSkill[]; +} + +/** + * Scan `fromDir` (default `~/.claude/skills`) for skill directories and + * return a sorted list of what's installed. Silently treats a missing + * `fromDir` as "zero installed"; throws a clear error if `fromDir` exists + * but is a file rather than a directory. + */ +export async function listInstalledSkills(opts: LsOptions = {}): Promise { + const fromDir = opts.fromDir ?? join(homedir(), ".claude", "skills"); + const includeInvalid = !!opts.includeInvalid; + + // Missing root → empty result. A fresh machine with no `~/.claude/skills/` + // yet is not an error; the honest answer is "zero installed". + if (!existsSync(fromDir)) { + return { fromDir, count: 0, skills: [] }; + } + + const st = await stat(fromDir); + if (!st.isDirectory()) { + // The user pointed `--from` at a file. That's a typo on their end, not + // a zero-skill result — surface it so they can fix the flag. + throw new Error(`ls: ${fromDir} is not a directory`); + } + + const entries = await readdir(fromDir, { withFileTypes: true }); + const found: InstalledSkill[] = []; + + for (const ent of entries) { + // A skill lives at a child *directory* with a SKILL.md at its top. + // Loose files at the parent level get ignored silently — they aren't + // skills, and we don't want to bark at every README or .DS_Store. + if (!ent.isDirectory()) continue; + const dirPath = resolve(fromDir, ent.name); + const skillMdPath = join(dirPath, "SKILL.md"); + if (!existsSync(skillMdPath)) continue; // not a skill dir — skip silently + + const raw = await readFile(skillMdPath, "utf8"); + const parsed = matter(raw); + const schemaResult = SkillFrontmatterSchema.safeParse(parsed.data); + + if (schemaResult.success) { + found.push({ + name: schemaResult.data.name, + version: schemaResult.data.version, + path: dirPath, + source: "dir", + valid: true, + }); + continue; + } + + // Invalid SKILL.md — included only when the caller asked for it. + if (!includeInvalid) continue; + const issues = schemaResult.error.issues.map( + (i) => `${i.path.join(".") || ""}: ${i.message}`, + ); + // Reach for any name/version the broken frontmatter does carry; fall + // back to the directory name and "0.0.0" so the row is still scannable. + const fmData = (parsed.data ?? {}) as Record; + const nameGuess = typeof fmData.name === "string" && fmData.name ? fmData.name : ent.name; + const versionGuess = + typeof fmData.version === "string" && fmData.version ? fmData.version : "0.0.0"; + found.push({ + name: nameGuess, + version: versionGuess, + path: dirPath, + source: "dir", + valid: false, + issues, + }); + } + + found.sort((a, b) => a.name.localeCompare(b.name)); + return { fromDir, count: found.length, skills: found }; +} From 3ce62ef178e3af92e409f3b9d94184bbbcee8764 Mon Sep 17 00:00:00 2001 From: Aditya Chilka Date: Thu, 28 May 2026 02:52:20 +0530 Subject: [PATCH 2/3] test(ls): cross-platform path regex for default fromDir --- .pr-body.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/ls.test.ts | 3 +- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 .pr-body.md diff --git a/.pr-body.md b/.pr-body.md new file mode 100644 index 0000000..c4c0d25 --- /dev/null +++ b/.pr-body.md @@ -0,0 +1,86 @@ +## What + +`skillforge ls` — list installed skills in `~/.claude/skills/` (the default `install` target) with name, version, and absolute path pulled from each skill's `SKILL.md` frontmatter. The `npm ls` analogue for the Claude skills tree. + +```sh +# Default — scan ~/.claude/skills +skillforge ls + +# Scan a different tree (CI fixtures, sandbox testing) +skillforge ls --from ./local-skills + +# Include skill dirs whose SKILL.md fails validation, tagged `(invalid)` +skillforge ls --include-invalid + +# Machine-readable output for shell pipelines +skillforge ls --json | jq '.skills[] | .name' +``` + +The human-readable mode prints a tidy 3-column table — `Name Version Path` — with column widths sized to actual content so narrow trees stay compact and long names still align. Sentence-case heading in tracked uppercase keeps the section signal quiet rather than editorial. Empty-state message names the scanned directory so the reader knows which tree came up empty. + +## Why + +`install` writes to `~/.claude/skills//` by default. Once a user has installed more than two or three skills, the natural follow-up is "what have I got?" — and the honest answer was "go `ls` the directory yourself, then `cat SKILL.md` on each one to see the version". This command closes that loop with a single read-only directory scan. No network, no writes, no surprises. + +## Semantics + +| Aspect | Behaviour | +|---|---| +| Default root | `~/.claude/skills/` — same path `install` writes to. `--from ` overrides for tests and alt-trees. | +| What counts as a "skill" | A child *directory* with a `SKILL.md` at its top level. Loose files at the parent level get ignored silently; child dirs without a `SKILL.md` are skipped silently too — a half-pulled install shouldn't pollute every `ls` invocation. | +| Validation | Each skill's `SKILL.md` frontmatter runs through the same `SkillFrontmatterSchema` that `install` enforces. Invalid skills are excluded from the count by default; `--include-invalid` flips that to "include them with `valid: false` and an `issues` list" — useful when debugging "why isn't my skill showing up". | +| Missing root | `--from ` that doesn't exist → empty result, exit 0. A fresh machine with no `~/.claude/skills/` yet is not an error; the honest answer is "zero installed", same as `npm ls` in an empty project. | +| `--from` is a file | Hard error. The user passed a flag; if it points at a file, that's a typo on their end, not "zero skills". | +| Sort order | Name ascending. Stable output is the whole point of a list command — the order should never depend on filesystem readdir order, which varies between platforms. | +| `source` discriminator | New field on `InstalledSkill`: `"dir"`. Future-proofs the shape for `.skill` archive listings later (e.g. `ls ~/.claude/skills-cache --bundles`). | +| Side effects | None. `ls` is read-only end-to-end — no fetching, no writing, no temp files. | + +## Implementation — minimal and additive + +- New `src/ls.ts` (~140 LOC of which ~50 is essence): `listInstalledSkills({ fromDir?, includeInvalid? })` returns `{ fromDir, count, skills }`. Reuses `gray-matter` (already on deps for `validate` / `lint` / `inspect`) and `SkillFrontmatterSchema` from `schema.ts`. +- `src/cli.ts`: new `ls` command with `--from`, `--include-invalid`, `--json` flags, plus a tidy 3-column table renderer (`printLsReport`) and an empty-state message that names the scanned directory. +- `src/index.ts`: exports `listInstalledSkills`, `LsOptions`, `LsResult`, `InstalledSkill` so library consumers can build on the same API the CLI uses. +- `README.md`: new `### skillforge ls` section after `cat`, status line and roadmap bumped. +- **No changes to `install.ts` / `cat.ts` / `skill-loader.ts` / `validate.ts` / `lint.ts` / `pack.ts` / `inspect.ts` / `diff.ts` / `tree.ts` / `update.ts` / `init.ts` / `format.ts`.** +- **No new runtime deps.** Reuses `gray-matter` and `zod` (via `SkillFrontmatterSchema`). + +## Tests + +12 new tests in `src/ls.test.ts`, written test-first per §17: + +| Case | What it pins | +|---|---| +| Empty dir | Freshly-created empty `--from` → `count: 0`, `skills: []`. | +| Single skill | One skill dir → `count: 1`, name + version match frontmatter, `source: "dir"`, `valid: true`. | +| Three skills sorted | Three dirs added out of order → returned in alphabetical order by `name`. | +| Invalid skill excluded (default) | One valid + one with `description` too short → only the valid one appears, `count: 1`. | +| Invalid skill included (flag) | `--include-invalid` → both appear, broken one has `valid: false` and an `issues[]` array; valid one has no `issues` field. | +| `--from` override | Explicit `fromDir` reflected in `LsResult.fromDir`. | +| Non-existent dir | `--from /does/not/exist` → empty result, no throw. | +| `--from` is a file | Hard error mentioning "not a directory". | +| Skill dir without `SKILL.md` | Silently skipped — only the real skill shows up. | +| `LsResult` shape | `Object.keys` exactly `["count", "fromDir", "skills"]`; valid `InstalledSkill` keys exactly `["name", "path", "source", "valid", "version"]` (no `issues`). | +| Loose file in parent | Files at the parent level (e.g. `.DS_Store`) are ignored, not counted. | +| Default `fromDir` | No `fromDir` → resolves to `~/.claude/skills`; doesn't assert count (user state-dependent). | + +The red→green transition I captured during development: with `ls.test.ts` written and `ls.ts` not yet created, `vitest run src/ls.test.ts` failed with `Error: Failed to load url ./ls.js (resolved id: ./ls.js) in /private/tmp/skillforge-ls/src/ls.test.ts. Does the file exist?` — exactly the failure the spec calls for. After implementing `src/ls.ts`, the same `vitest run` returned `✓ src/ls.test.ts (12 tests) 57ms` on the first pass. + +## Gates + +| Check | Status | +|---|---| +| `pnpm install` | no drift | +| `tsc --noEmit` | clean | +| `biome check src` (`--fix --unsafe`) | clean | +| `vitest run` | 167/167 pass (155 existing + 12 new) | +| `tsup` build | ESM + DTS build success | +| Smoke: 3 skills in `/tmp/sf-ls-smoke/skills` → `./dist/cli.js ls --from …` | sorted 3-col table, `--json \| jq .count` returns `3` | +| Smoke: `--include-invalid` on a broken skill | row appears with `(invalid)` tag, dimmed | + +## Don't refactor + +No changes to `install.ts` / `cat.ts` / `skill-loader.ts` / `validate.ts` / `lint.ts` / `pack.ts` / `inspect.ts` / `diff.ts` / `tree.ts` / `update.ts` / `init.ts` / `format.ts`. No new runtime deps. `SkillFrontmatterSchema` reused as-is. + +## Declaration of AI-Tools / LLMs usage + +- **Claude (Opus)** for design, implementation, tests, commit message, this PR body — reviewed by @adityachilka1 before push. diff --git a/src/ls.test.ts b/src/ls.test.ts index 684a311..25d77e9 100644 --- a/src/ls.test.ts +++ b/src/ls.test.ts @@ -176,7 +176,8 @@ describe("listInstalledSkills — default fromDir", () => { // touching their machine state, so we just confirm the function resolves // and reports the default path. const result = await listInstalledSkills(); - expect(result.fromDir).toMatch(/\.claude\/skills$/); + // Cross-platform: macOS / Linux use `/`, Windows uses `\` — match either. + expect(result.fromDir).toMatch(/\.claude[\\/]skills$/); expect(Array.isArray(result.skills)).toBe(true); expect(result.count).toBe(result.skills.length); }); From 4083402e894bb934f84cc4a18310cf8a15f28779 Mon Sep 17 00:00:00 2001 From: Aditya Chilka Date: Thu, 28 May 2026 02:52:26 +0530 Subject: [PATCH 3/3] chore: remove stray .pr-body.md --- .pr-body.md | 86 ----------------------------------------------------- 1 file changed, 86 deletions(-) delete mode 100644 .pr-body.md diff --git a/.pr-body.md b/.pr-body.md deleted file mode 100644 index c4c0d25..0000000 --- a/.pr-body.md +++ /dev/null @@ -1,86 +0,0 @@ -## What - -`skillforge ls` — list installed skills in `~/.claude/skills/` (the default `install` target) with name, version, and absolute path pulled from each skill's `SKILL.md` frontmatter. The `npm ls` analogue for the Claude skills tree. - -```sh -# Default — scan ~/.claude/skills -skillforge ls - -# Scan a different tree (CI fixtures, sandbox testing) -skillforge ls --from ./local-skills - -# Include skill dirs whose SKILL.md fails validation, tagged `(invalid)` -skillforge ls --include-invalid - -# Machine-readable output for shell pipelines -skillforge ls --json | jq '.skills[] | .name' -``` - -The human-readable mode prints a tidy 3-column table — `Name Version Path` — with column widths sized to actual content so narrow trees stay compact and long names still align. Sentence-case heading in tracked uppercase keeps the section signal quiet rather than editorial. Empty-state message names the scanned directory so the reader knows which tree came up empty. - -## Why - -`install` writes to `~/.claude/skills//` by default. Once a user has installed more than two or three skills, the natural follow-up is "what have I got?" — and the honest answer was "go `ls` the directory yourself, then `cat SKILL.md` on each one to see the version". This command closes that loop with a single read-only directory scan. No network, no writes, no surprises. - -## Semantics - -| Aspect | Behaviour | -|---|---| -| Default root | `~/.claude/skills/` — same path `install` writes to. `--from ` overrides for tests and alt-trees. | -| What counts as a "skill" | A child *directory* with a `SKILL.md` at its top level. Loose files at the parent level get ignored silently; child dirs without a `SKILL.md` are skipped silently too — a half-pulled install shouldn't pollute every `ls` invocation. | -| Validation | Each skill's `SKILL.md` frontmatter runs through the same `SkillFrontmatterSchema` that `install` enforces. Invalid skills are excluded from the count by default; `--include-invalid` flips that to "include them with `valid: false` and an `issues` list" — useful when debugging "why isn't my skill showing up". | -| Missing root | `--from ` that doesn't exist → empty result, exit 0. A fresh machine with no `~/.claude/skills/` yet is not an error; the honest answer is "zero installed", same as `npm ls` in an empty project. | -| `--from` is a file | Hard error. The user passed a flag; if it points at a file, that's a typo on their end, not "zero skills". | -| Sort order | Name ascending. Stable output is the whole point of a list command — the order should never depend on filesystem readdir order, which varies between platforms. | -| `source` discriminator | New field on `InstalledSkill`: `"dir"`. Future-proofs the shape for `.skill` archive listings later (e.g. `ls ~/.claude/skills-cache --bundles`). | -| Side effects | None. `ls` is read-only end-to-end — no fetching, no writing, no temp files. | - -## Implementation — minimal and additive - -- New `src/ls.ts` (~140 LOC of which ~50 is essence): `listInstalledSkills({ fromDir?, includeInvalid? })` returns `{ fromDir, count, skills }`. Reuses `gray-matter` (already on deps for `validate` / `lint` / `inspect`) and `SkillFrontmatterSchema` from `schema.ts`. -- `src/cli.ts`: new `ls` command with `--from`, `--include-invalid`, `--json` flags, plus a tidy 3-column table renderer (`printLsReport`) and an empty-state message that names the scanned directory. -- `src/index.ts`: exports `listInstalledSkills`, `LsOptions`, `LsResult`, `InstalledSkill` so library consumers can build on the same API the CLI uses. -- `README.md`: new `### skillforge ls` section after `cat`, status line and roadmap bumped. -- **No changes to `install.ts` / `cat.ts` / `skill-loader.ts` / `validate.ts` / `lint.ts` / `pack.ts` / `inspect.ts` / `diff.ts` / `tree.ts` / `update.ts` / `init.ts` / `format.ts`.** -- **No new runtime deps.** Reuses `gray-matter` and `zod` (via `SkillFrontmatterSchema`). - -## Tests - -12 new tests in `src/ls.test.ts`, written test-first per §17: - -| Case | What it pins | -|---|---| -| Empty dir | Freshly-created empty `--from` → `count: 0`, `skills: []`. | -| Single skill | One skill dir → `count: 1`, name + version match frontmatter, `source: "dir"`, `valid: true`. | -| Three skills sorted | Three dirs added out of order → returned in alphabetical order by `name`. | -| Invalid skill excluded (default) | One valid + one with `description` too short → only the valid one appears, `count: 1`. | -| Invalid skill included (flag) | `--include-invalid` → both appear, broken one has `valid: false` and an `issues[]` array; valid one has no `issues` field. | -| `--from` override | Explicit `fromDir` reflected in `LsResult.fromDir`. | -| Non-existent dir | `--from /does/not/exist` → empty result, no throw. | -| `--from` is a file | Hard error mentioning "not a directory". | -| Skill dir without `SKILL.md` | Silently skipped — only the real skill shows up. | -| `LsResult` shape | `Object.keys` exactly `["count", "fromDir", "skills"]`; valid `InstalledSkill` keys exactly `["name", "path", "source", "valid", "version"]` (no `issues`). | -| Loose file in parent | Files at the parent level (e.g. `.DS_Store`) are ignored, not counted. | -| Default `fromDir` | No `fromDir` → resolves to `~/.claude/skills`; doesn't assert count (user state-dependent). | - -The red→green transition I captured during development: with `ls.test.ts` written and `ls.ts` not yet created, `vitest run src/ls.test.ts` failed with `Error: Failed to load url ./ls.js (resolved id: ./ls.js) in /private/tmp/skillforge-ls/src/ls.test.ts. Does the file exist?` — exactly the failure the spec calls for. After implementing `src/ls.ts`, the same `vitest run` returned `✓ src/ls.test.ts (12 tests) 57ms` on the first pass. - -## Gates - -| Check | Status | -|---|---| -| `pnpm install` | no drift | -| `tsc --noEmit` | clean | -| `biome check src` (`--fix --unsafe`) | clean | -| `vitest run` | 167/167 pass (155 existing + 12 new) | -| `tsup` build | ESM + DTS build success | -| Smoke: 3 skills in `/tmp/sf-ls-smoke/skills` → `./dist/cli.js ls --from …` | sorted 3-col table, `--json \| jq .count` returns `3` | -| Smoke: `--include-invalid` on a broken skill | row appears with `(invalid)` tag, dimmed | - -## Don't refactor - -No changes to `install.ts` / `cat.ts` / `skill-loader.ts` / `validate.ts` / `lint.ts` / `pack.ts` / `inspect.ts` / `diff.ts` / `tree.ts` / `update.ts` / `init.ts` / `format.ts`. No new runtime deps. `SkillFrontmatterSchema` reused as-is. - -## Declaration of AI-Tools / LLMs usage - -- **Claude (Opus)** for design, implementation, tests, commit message, this PR body — reviewed by @adityachilka1 before push.