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
31 changes: 29 additions & 2 deletions 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`, `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

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

Expand Down
60 changes: 60 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* diff <a> <b> structural comparison of two SKILL.md files
* tree <dir> preview the file inventory pack would produce
* cat <skill> print the bundled SKILL.md of a .skill archive
* ls list installed skills in ~/.claude/skills/
*/
import { cac } from "cac";
import kleur from "kleur";
Expand All @@ -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";
Expand Down Expand Up @@ -616,6 +618,64 @@ cli
}
});

cli
.command("ls", "List installed skills (default: ~/.claude/skills)")
.option("--from <dir>", "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();
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
184 changes: 184 additions & 0 deletions src/ls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
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<string> {
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();
// 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);
});
});
Loading
Loading