skillshelf 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,163 @@
1
+ // `skl new` — scaffold a new skill directory + SKILL.md into the library.
2
+ //
3
+ // skl new <name> [--domain <d>] [--desc "..."] [--force] [--json]
4
+ //
5
+ // Writes <library>/[<domain>/]<name>/SKILL.md with frontmatter (name,
6
+ // description, domains). Refuses to clobber an existing SKILL.md unless --force.
7
+
8
+ import { join } from "node:path";
9
+ import { existsSync } from "node:fs";
10
+ import type { Ctx } from "../types.ts";
11
+ import { serializeFrontmatter } from "../lib/frontmatter.ts";
12
+ import { ensureDir } from "../lib/fs.ts";
13
+
14
+ export const meta = {
15
+ name: "new",
16
+ summary: "Scaffold a new skill dir + SKILL.md into the library",
17
+ usage: 'skl new <name> [--domain <d>] [--desc "..."] [--force] [--json]',
18
+ } as const;
19
+
20
+ interface Args {
21
+ name: string | null;
22
+ domain: string | null;
23
+ desc: string | null;
24
+ force: boolean;
25
+ json: boolean;
26
+ }
27
+
28
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
29
+
30
+ function parseArgs(argv: string[]): { args: Args } | { error: string } {
31
+ const args: Args = { name: null, domain: null, desc: null, force: false, json: false };
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const a = argv[i]!;
34
+ if (a === "--domain") {
35
+ const v = argv[++i];
36
+ if (!v) return { error: "--domain requires a value" };
37
+ args.domain = v;
38
+ } else if (a.startsWith("--domain=")) {
39
+ args.domain = a.slice("--domain=".length);
40
+ } else if (a === "--desc" || a === "--description") {
41
+ const v = argv[++i];
42
+ if (v === undefined) return { error: "--desc requires a value" };
43
+ args.desc = v;
44
+ } else if (a.startsWith("--desc=")) {
45
+ args.desc = a.slice("--desc=".length);
46
+ } else if (a.startsWith("--description=")) {
47
+ args.desc = a.slice("--description=".length);
48
+ } else if (a === "--force") {
49
+ args.force = true;
50
+ } else if (a === "--json") {
51
+ args.json = true;
52
+ } else if (a.startsWith("--")) {
53
+ return { error: `unknown argument: ${a}` };
54
+ } else if (args.name === null) {
55
+ args.name = a;
56
+ } else {
57
+ return { error: `unexpected argument: ${a}` };
58
+ }
59
+ }
60
+ return { args };
61
+ }
62
+
63
+ function defaultBody(name: string, desc: string): string {
64
+ const title = name
65
+ .split("-")
66
+ .map((w) => (w ? w[0]!.toUpperCase() + w.slice(1) : w))
67
+ .join(" ");
68
+ return [
69
+ `# ${title}`,
70
+ "",
71
+ desc ? desc : "One-line statement of what this skill does and when to use it.",
72
+ "",
73
+ "## When to use",
74
+ "",
75
+ "- Describe the trigger conditions.",
76
+ "",
77
+ "## Steps",
78
+ "",
79
+ "1. First step.",
80
+ "2. Second step.",
81
+ "",
82
+ ].join("\n");
83
+ }
84
+
85
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
86
+ const parsed = parseArgs(argv);
87
+ if ("error" in parsed) {
88
+ ctx.error(`skl new: ${parsed.error}`);
89
+ ctx.error(`usage: ${meta.usage}`);
90
+ return 1;
91
+ }
92
+ const args = parsed.args;
93
+
94
+ if (!args.name || args.name.trim() === "") {
95
+ ctx.error("skl new: a <name> is required");
96
+ ctx.error(`usage: ${meta.usage}`);
97
+ return 1;
98
+ }
99
+ const name = args.name.trim();
100
+ if (!SLUG_RE.test(name)) {
101
+ ctx.error(
102
+ `skl new: invalid skill name "${name}" — use lowercase letters, digits, and hyphens (e.g. my-skill)`,
103
+ );
104
+ return 1;
105
+ }
106
+ const domain = args.domain?.trim() || null;
107
+ if (domain && !SLUG_RE.test(domain)) {
108
+ ctx.error(
109
+ `skl new: invalid domain "${domain}" — use lowercase letters, digits, and hyphens`,
110
+ );
111
+ return 1;
112
+ }
113
+ const desc = (args.desc ?? "").trim();
114
+
115
+ try {
116
+ const libraryPath = ctx.config.libraryPath;
117
+ const skillDir = domain
118
+ ? join(libraryPath, domain, name)
119
+ : join(libraryPath, name);
120
+ const bodyPath = join(skillDir, "SKILL.md");
121
+
122
+ if (existsSync(bodyPath) && !args.force) {
123
+ ctx.error(
124
+ `skl new: SKILL.md already exists at ${bodyPath} — pass --force to overwrite`,
125
+ );
126
+ return 1;
127
+ }
128
+
129
+ await ensureDir(skillDir);
130
+
131
+ const frontmatterData: Record<string, unknown> = {
132
+ name,
133
+ description: desc || `TODO: describe ${name}.`,
134
+ };
135
+ if (domain) {
136
+ frontmatterData.primaryDomain = domain;
137
+ frontmatterData.domains = [domain];
138
+ }
139
+
140
+ const content = serializeFrontmatter(frontmatterData, defaultBody(name, desc));
141
+ await Bun.write(bodyPath, content);
142
+
143
+ if (args.json) {
144
+ ctx.json({
145
+ ok: true,
146
+ name,
147
+ domain,
148
+ path: skillDir,
149
+ bodyPath,
150
+ created: true,
151
+ });
152
+ } else {
153
+ ctx.log(`Created skill "${name}"`);
154
+ ctx.log(` dir: ${skillDir}`);
155
+ ctx.log(` file: ${bodyPath}`);
156
+ ctx.log("Edit SKILL.md, then run `skl infer` to tag it and `skl index` to list it.");
157
+ }
158
+ return 0;
159
+ } catch (e) {
160
+ ctx.error(`skl new: ${e instanceof Error ? e.message : String(e)}`);
161
+ return 1;
162
+ }
163
+ }
@@ -0,0 +1,113 @@
1
+ // skl outdated — per locked skill, check the upstream latest commit/ref and
2
+ // mark which installed skills are stale (upstream moved past the installed ref).
3
+ //
4
+ // github channel: `gh api` / `git ls-remote` (via core/fetch).
5
+ // vercel-registry channel: `skills info` (degrades gracefully if absent).
6
+ //
7
+ // Read command: supports --json.
8
+
9
+ import type { Ctx, LockEntry } from "../types.ts";
10
+ import { readLockfile } from "../core/provenance.ts";
11
+ import { parseStoredSource, latestRef } from "../core/fetch.ts";
12
+
13
+ export const meta = {
14
+ name: "outdated",
15
+ summary: "Check upstream ref per tracked skill and mark stale ones",
16
+ usage: "skl outdated [name] [--json]",
17
+ } as const;
18
+
19
+ type Status = "stale" | "current" | "unknown";
20
+
21
+ interface Row {
22
+ name: string;
23
+ channel: string;
24
+ source: string;
25
+ installedRef: string;
26
+ latestRef: string | null;
27
+ status: Status;
28
+ note: string;
29
+ }
30
+
31
+ function shortRef(ref: string): string {
32
+ return /^[0-9a-f]{7,40}$/i.test(ref) ? ref.slice(0, 10) : ref;
33
+ }
34
+
35
+ async function checkEntry(entry: LockEntry): Promise<Row> {
36
+ const parsed = parseStoredSource(entry.source);
37
+ const res = await latestRef(parsed);
38
+ if (!res.ok) {
39
+ return {
40
+ name: entry.name,
41
+ channel: entry.channel,
42
+ source: entry.source,
43
+ installedRef: entry.ref,
44
+ latestRef: null,
45
+ status: "unknown",
46
+ note: res.error,
47
+ };
48
+ }
49
+ const latest = res.ref;
50
+ const same = latest === entry.ref;
51
+ return {
52
+ name: entry.name,
53
+ channel: entry.channel,
54
+ source: entry.source,
55
+ installedRef: entry.ref,
56
+ latestRef: latest,
57
+ status: same ? "current" : "stale",
58
+ note: entry.localEdits ? "has local edits" : "",
59
+ };
60
+ }
61
+
62
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
63
+ const json = argv.includes("--json");
64
+ const nameArg = argv.find((a) => !a.startsWith("-")) ?? null;
65
+
66
+ try {
67
+ const lock = await readLockfile(ctx.config.libraryPath);
68
+ let entries = Object.values(lock.entries);
69
+ if (nameArg) entries = entries.filter((e) => e.name === nameArg);
70
+ entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
71
+
72
+ if (entries.length === 0) {
73
+ if (json) ctx.json({ ok: true, checked: 0, stale: 0, rows: [] });
74
+ else if (nameArg) ctx.log(`no tracked skill named "${nameArg}"`);
75
+ else ctx.log("no tracked third-party skills (lockfile is empty)");
76
+ return 0;
77
+ }
78
+
79
+ const rows = await Promise.all(entries.map((e) => checkEntry(e)));
80
+ const stale = rows.filter((r) => r.status === "stale");
81
+
82
+ if (json) {
83
+ ctx.json({
84
+ ok: true,
85
+ checked: rows.length,
86
+ stale: stale.length,
87
+ rows,
88
+ });
89
+ } else {
90
+ for (const r of rows) {
91
+ const mark = r.status === "stale" ? "STALE " : r.status === "current" ? "current" : "unknown";
92
+ const refInfo =
93
+ r.status === "stale"
94
+ ? `${shortRef(r.installedRef)} -> ${shortRef(r.latestRef ?? "")}`
95
+ : r.status === "current"
96
+ ? shortRef(r.installedRef)
97
+ : `${shortRef(r.installedRef)} (${r.note})`;
98
+ const extra = r.note && r.status !== "unknown" ? ` [${r.note}]` : "";
99
+ ctx.log(`${mark} ${r.name.padEnd(28)} ${r.channel.padEnd(15)} ${refInfo}${extra}`);
100
+ }
101
+ ctx.log("");
102
+ ctx.log(`${rows.length} tracked, ${stale.length} stale.`);
103
+ if (stale.length > 0) {
104
+ ctx.log(`run \`skl update [name]\` to re-pull (overlays are preserved).`);
105
+ }
106
+ }
107
+ // Non-zero exit when stale skills exist, so agents/CI can branch on it.
108
+ return stale.length > 0 ? 2 : 0;
109
+ } catch (err) {
110
+ ctx.error("outdated: failed:", err instanceof Error ? err.message : String(err));
111
+ return 1;
112
+ }
113
+ }
@@ -0,0 +1,61 @@
1
+ // `skl search <kw>` — fuzzy match over skill name + description (+ domains)
2
+ // across the whole library. Kills "forgot it exists".
3
+
4
+ import type { Ctx, Skill } from "../types.ts";
5
+ import { searchSkills } from "../core/library.ts";
6
+
7
+ export const meta = {
8
+ name: "search",
9
+ summary: "Fuzzy over name+desc+domains across the library",
10
+ usage: "skl search <kw...> [--json]",
11
+ } as const;
12
+
13
+ function oneLine(desc: string, max = 100): string {
14
+ const flat = desc.replace(/\s+/g, " ").trim();
15
+ return flat.length <= max ? flat : flat.slice(0, max - 1).trimEnd() + "…";
16
+ }
17
+
18
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
19
+ try {
20
+ const json = argv.includes("--json");
21
+ const terms = argv.filter((a) => a !== "--json");
22
+ const query = terms.join(" ").trim();
23
+
24
+ if (query === "") {
25
+ ctx.error("usage: " + meta.usage);
26
+ return 1;
27
+ }
28
+
29
+ const skills = await ctx.loadLibrary();
30
+ const matches = searchSkills(skills, query);
31
+
32
+ if (json) {
33
+ ctx.json(
34
+ matches.map((s: Skill) => ({
35
+ name: s.name,
36
+ description: s.description,
37
+ primaryDomain: s.primaryDomain,
38
+ domains: s.domains,
39
+ path: s.path,
40
+ retired: s.retired,
41
+ })),
42
+ );
43
+ return 0;
44
+ }
45
+
46
+ if (matches.length === 0) {
47
+ ctx.log(`No skills match "${query}".`);
48
+ return 0;
49
+ }
50
+
51
+ for (const s of matches) {
52
+ const tag = s.retired ? " (retired)" : "";
53
+ const dom = s.domains.length ? ` [${s.domains.join(", ")}]` : "";
54
+ ctx.log(`${s.name}${dom}${tag} — ${oneLine(s.description)}`);
55
+ }
56
+ return 0;
57
+ } catch (err) {
58
+ ctx.error(`search failed: ${(err as Error).message}`);
59
+ return 1;
60
+ }
61
+ }
@@ -0,0 +1,70 @@
1
+ // `skl show <name>` — print ONLY the SKILL.md instruction layer (the body
2
+ // after frontmatter) and list bundled reference-file paths. Reference file
3
+ // CONTENTS are never printed; the agent Reads them on demand. Manual
4
+ // progressive disclosure: cheap by default, deep on demand.
5
+
6
+ import type { Ctx, Skill } from "../types.ts";
7
+ import { findByName } from "../core/library.ts";
8
+ import { parseFrontmatter } from "../lib/frontmatter.ts";
9
+
10
+ export const meta = {
11
+ name: "show",
12
+ summary: "Print SKILL.md body; list reference-file paths (not contents)",
13
+ usage: "skl show <name> [--json]",
14
+ } as const;
15
+
16
+ async function bodyOf(skill: Skill): Promise<string> {
17
+ const raw = await Bun.file(skill.bodyPath).text();
18
+ const { body, hasFrontmatter } = parseFrontmatter(raw);
19
+ return hasFrontmatter ? body : raw;
20
+ }
21
+
22
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
23
+ try {
24
+ const json = argv.includes("--json");
25
+ const positional = argv.filter((a) => !a.startsWith("--"));
26
+ const name = positional[0];
27
+
28
+ if (!name) {
29
+ ctx.error("usage: " + meta.usage);
30
+ return 1;
31
+ }
32
+
33
+ const skills = await ctx.loadLibrary();
34
+ const skill = findByName(skills, name);
35
+ if (!skill) {
36
+ ctx.error(`No skill named "${name}". Try: skl search ${name}`);
37
+ return 1;
38
+ }
39
+
40
+ const body = await bodyOf(skill);
41
+
42
+ if (json) {
43
+ ctx.json({
44
+ name: skill.name,
45
+ description: skill.description,
46
+ primaryDomain: skill.primaryDomain,
47
+ domains: skill.domains,
48
+ path: skill.path,
49
+ bodyPath: skill.bodyPath,
50
+ body,
51
+ refFiles: skill.refFiles,
52
+ retired: skill.retired,
53
+ source: skill.source,
54
+ });
55
+ return 0;
56
+ }
57
+
58
+ ctx.log(body.replace(/\n+$/, ""));
59
+
60
+ if (skill.refFiles.length) {
61
+ ctx.log("");
62
+ ctx.log(`# Reference files (${skill.refFiles.length}) — Read on demand:`);
63
+ for (const f of skill.refFiles) ctx.log(f);
64
+ }
65
+ return 0;
66
+ } catch (err) {
67
+ ctx.error(`show failed: ${(err as Error).message}`);
68
+ return 1;
69
+ }
70
+ }
@@ -0,0 +1,117 @@
1
+ // `skl status` — which library skills are currently symlinked into the
2
+ // project you're in (./.claude/skills/). Groups the linked skills by the
3
+ // bundles (domain tags) they belong to, so you can see what `skl use` pinned.
4
+
5
+ import { join } from "node:path";
6
+ import type { Ctx, Skill } from "../types.ts";
7
+ import {
8
+ pathExists,
9
+ isSymlink,
10
+ realpathOrSelf,
11
+ listDirNames,
12
+ } from "../lib/fs.ts";
13
+
14
+ export const meta = {
15
+ name: "status",
16
+ summary: "Which library skills are linked into ./.claude/skills",
17
+ usage: "skl status [--json]",
18
+ } as const;
19
+
20
+ interface LinkedEntry {
21
+ link: string; // entry name under .claude/skills
22
+ linkPath: string; // abs path of the symlink
23
+ target: string; // realpath the symlink resolves to
24
+ skill: Skill | null; // matching library skill, if the target is one of ours
25
+ }
26
+
27
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
28
+ try {
29
+ const json = argv.includes("--json");
30
+ const cwd = process.cwd();
31
+ const skillsDir = join(cwd, ".claude", "skills");
32
+
33
+ const skills = await ctx.loadLibrary();
34
+ // index library skills by their realpath for matching
35
+ const byReal = new Map<string, Skill>();
36
+ for (const s of skills) byReal.set(realpathOrSelf(s.path), s);
37
+
38
+ const linked: LinkedEntry[] = [];
39
+ if (pathExists(skillsDir)) {
40
+ const names = await listDirNames(skillsDir);
41
+ for (const name of names) {
42
+ const linkPath = join(skillsDir, name);
43
+ if (!isSymlink(linkPath)) continue; // only count managed symlinks
44
+ const target = realpathOrSelf(linkPath);
45
+ linked.push({
46
+ link: name,
47
+ linkPath,
48
+ target,
49
+ skill: byReal.get(target) ?? null,
50
+ });
51
+ }
52
+ }
53
+ linked.sort((a, b) => (a.link < b.link ? -1 : a.link > b.link ? 1 : 0));
54
+
55
+ // group resolved skills by bundle (domain tag) for the human summary
56
+ const bundles = new Map<string, string[]>();
57
+ for (const e of linked) {
58
+ if (!e.skill) continue;
59
+ for (const d of e.skill.domains) {
60
+ const arr = bundles.get(d);
61
+ if (arr) arr.push(e.skill.name);
62
+ else bundles.set(d, [e.skill.name]);
63
+ }
64
+ }
65
+
66
+ if (json) {
67
+ ctx.json({
68
+ projectRoot: cwd,
69
+ skillsDir,
70
+ skillsDirExists: pathExists(skillsDir),
71
+ linkedCount: linked.length,
72
+ bundles: [...bundles.keys()].sort().map((name) => ({
73
+ name,
74
+ skills: bundles.get(name)!.slice().sort(),
75
+ })),
76
+ linked: linked.map((e) => ({
77
+ link: e.link,
78
+ target: e.target,
79
+ skill: e.skill ? e.skill.name : null,
80
+ inLibrary: e.skill != null,
81
+ domains: e.skill ? e.skill.domains : [],
82
+ })),
83
+ });
84
+ return 0;
85
+ }
86
+
87
+ if (linked.length === 0) {
88
+ ctx.log(`No skills linked into ${skillsDir}`);
89
+ return 0;
90
+ }
91
+
92
+ ctx.log(`Linked into ${skillsDir} (${linked.length}):`);
93
+ for (const e of linked) {
94
+ if (e.skill) {
95
+ const dom = e.skill.domains.length
96
+ ? ` [${e.skill.domains.join(", ")}]`
97
+ : "";
98
+ ctx.log(` ${e.link}${dom} -> ${e.skill.name}`);
99
+ } else {
100
+ ctx.log(` ${e.link} -> ${e.target} (not a library skill)`);
101
+ }
102
+ }
103
+
104
+ if (bundles.size) {
105
+ ctx.log("");
106
+ ctx.log("Bundles present:");
107
+ for (const name of [...bundles.keys()].sort()) {
108
+ const members = bundles.get(name)!.slice().sort();
109
+ ctx.log(` ${name} (${members.length}): ${members.join(", ")}`);
110
+ }
111
+ }
112
+ return 0;
113
+ } catch (err) {
114
+ ctx.error(`status failed: ${(err as Error).message}`);
115
+ return 1;
116
+ }
117
+ }