skillshelf 0.2.0 → 0.4.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.
Files changed (67) hide show
  1. package/README.md +83 -20
  2. package/package.json +8 -2
  3. package/src/adapters/inference/agent.ts +23 -16
  4. package/src/cli.ts +39 -0
  5. package/src/commands/add.ts +624 -128
  6. package/src/commands/adopted.test.ts +144 -0
  7. package/src/commands/agents-config.test.ts +126 -0
  8. package/src/commands/agents.test.ts +96 -0
  9. package/src/commands/agents.ts +243 -0
  10. package/src/commands/drop.ts +21 -13
  11. package/src/commands/import.ts +44 -28
  12. package/src/commands/infer.ts +6 -6
  13. package/src/commands/link.test.ts +160 -0
  14. package/src/commands/link.ts +317 -0
  15. package/src/commands/ls.ts +136 -19
  16. package/src/commands/migrate.test.ts +157 -0
  17. package/src/commands/migrate.ts +260 -0
  18. package/src/commands/mode-surfacing.test.ts +110 -0
  19. package/src/commands/outdated.test.ts +55 -0
  20. package/src/commands/outdated.ts +166 -18
  21. package/src/commands/projects.test.ts +85 -0
  22. package/src/commands/projects.ts +80 -0
  23. package/src/commands/refresh.ts +133 -0
  24. package/src/commands/remediation.test.ts +149 -0
  25. package/src/commands/rename.test.ts +121 -0
  26. package/src/commands/rename.ts +64 -0
  27. package/src/commands/retag.ts +58 -0
  28. package/src/commands/retire.ts +39 -0
  29. package/src/commands/rm.test.ts +133 -0
  30. package/src/commands/rm.ts +107 -0
  31. package/src/commands/roots.ts +41 -0
  32. package/src/commands/scan.ts +122 -30
  33. package/src/commands/show.ts +130 -11
  34. package/src/commands/status.ts +43 -8
  35. package/src/commands/tag.test.ts +109 -0
  36. package/src/commands/tag.ts +68 -0
  37. package/src/commands/track.test.ts +170 -0
  38. package/src/commands/track.ts +340 -0
  39. package/src/commands/unretire.ts +33 -0
  40. package/src/commands/untag.ts +73 -0
  41. package/src/commands/untrack.ts +44 -0
  42. package/src/commands/update.test.ts +71 -0
  43. package/src/commands/update.ts +157 -15
  44. package/src/commands/use.test.ts +122 -0
  45. package/src/commands/use.ts +46 -23
  46. package/src/commands/where.ts +232 -0
  47. package/src/config.test.ts +198 -0
  48. package/src/config.ts +232 -10
  49. package/src/core/agents.test.ts +319 -0
  50. package/src/core/agents.ts +438 -0
  51. package/src/core/bundle.ts +12 -15
  52. package/src/core/core.test.ts +21 -8
  53. package/src/core/crawl.ts +22 -5
  54. package/src/core/dedupe.ts +36 -0
  55. package/src/core/deployments.test.ts +147 -0
  56. package/src/core/deployments.ts +208 -0
  57. package/src/core/fetch.ts +371 -75
  58. package/src/core/indexgen.ts +2 -0
  59. package/src/core/library.test.ts +41 -0
  60. package/src/core/library.ts +61 -16
  61. package/src/core/lifecycle.ts +252 -0
  62. package/src/core/surfaces.ts +46 -0
  63. package/src/core/taxonomy.test.ts +159 -0
  64. package/src/core/taxonomy.ts +190 -0
  65. package/src/lib/fs.ts +2 -2
  66. package/src/types.ts +155 -15
  67. package/src/core/overlay.ts +0 -63
@@ -1,28 +1,126 @@
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.
1
+ // `skl show <name>` — print the SKILL.md instruction layer (the body after
2
+ // frontmatter) and list the bundled reference files. By default only PATHS are
3
+ // listed; the agent Reads them on demand (manual progressive disclosure: cheap
4
+ // by default, deep on demand).
5
+ //
6
+ // `--file <relpath>` opens one bundled file: its CONTENTS are printed (or
7
+ // carried in `--json` `body`). The path is resolved INSIDE the skill dir — any
8
+ // attempt to escape it (`..`, absolute) is refused. This is what powers the
9
+ // drawer's file navigator: one deterministic verb per file, code or prose.
5
10
 
11
+ import { join, resolve, sep } from "node:path";
12
+ import { readdir } from "node:fs/promises";
13
+ import { existsSync, statSync, realpathSync } from "node:fs";
14
+ import type { Dirent } from "node:fs";
6
15
  import type { Ctx, Skill } from "../types.ts";
7
- import { findByName } from "../core/library.ts";
16
+ import { findByName, entryModeInfo } from "../core/library.ts";
8
17
  import { parseFrontmatter } from "../lib/frontmatter.ts";
9
18
 
10
19
  export const meta = {
11
20
  name: "show",
12
- summary: "Print SKILL.md body; list reference-file paths (not contents)",
13
- usage: "skl show <name> [--json]",
21
+ summary: "Print SKILL.md body; list (or open with --file) reference files",
22
+ usage: "skl show <name> [--file <relpath>] [--json]",
14
23
  } as const;
15
24
 
25
+ const SKILL_FILE = "SKILL.md";
26
+ const SKIP_DIRS = new Set([".git", "node_modules"]);
27
+ // Refuse to dump anything that smells binary or is too big to be a readable
28
+ // reference. Keeps `--file` honest: it serves text, not blobs.
29
+ const MAX_FILE_BYTES = 2_000_000;
30
+
16
31
  async function bodyOf(skill: Skill): Promise<string> {
17
32
  const raw = await Bun.file(skill.bodyPath).text();
18
33
  const { body, hasFrontmatter } = parseFrontmatter(raw);
19
34
  return hasFrontmatter ? body : raw;
20
35
  }
21
36
 
37
+ /**
38
+ * Recursively enumerate every bundled file AND directory under the skill dir
39
+ * (absolute paths), excluding SKILL.md, the lock/sidecar, and VCS/noise dirs.
40
+ * Directory entries are emitted too so the UI can render a tree. Sorted so a
41
+ * directory always precedes its children (lexical: "ref" < "ref/x").
42
+ */
43
+ async function walkRefFiles(
44
+ skillDir: string,
45
+ skillName: string,
46
+ ): Promise<string[]> {
47
+ const out: string[] = [];
48
+ async function walk(dir: string): Promise<void> {
49
+ let entries: Dirent[];
50
+ try {
51
+ entries = await readdir(dir, { withFileTypes: true });
52
+ } catch {
53
+ return;
54
+ }
55
+ for (const e of entries) {
56
+ if (e.name === ".DS_Store") continue;
57
+ if (dir === skillDir) {
58
+ if (e.name === SKILL_FILE) continue;
59
+ if (e.name === `${skillName}.shelf.json`) continue;
60
+ if (e.name === "shelf.lock.json") continue;
61
+ }
62
+ const abs = join(dir, e.name);
63
+ if (e.isDirectory()) {
64
+ if (SKIP_DIRS.has(e.name)) continue;
65
+ out.push(abs);
66
+ await walk(abs);
67
+ } else {
68
+ out.push(abs);
69
+ }
70
+ }
71
+ }
72
+ await walk(skillDir);
73
+ return out.sort();
74
+ }
75
+
76
+ /**
77
+ * Resolve a user-supplied `--file` path INSIDE the skill dir. Returns the
78
+ * absolute path, or null if it escapes the dir / does not exist / is not a
79
+ * regular file / is too large.
80
+ */
81
+ function resolveBundledFile(skillDir: string, rel: string): string | null {
82
+ const root = resolve(skillDir);
83
+ const abs = resolve(root, rel);
84
+ if (abs !== root && !abs.startsWith(root + sep)) return null; // lexical escape
85
+ if (!existsSync(abs)) return null;
86
+ const st = statSync(abs);
87
+ if (!st.isFile()) return null;
88
+ if (st.size > MAX_FILE_BYTES) return null;
89
+ // Symlink guard: a bundled file can be a symlink (third-party skills are
90
+ // untrusted) and the lexical check above is fooled by it (`statSync` follows
91
+ // links). Re-check the REAL paths — realpath BOTH, since the skill dir itself
92
+ // can sit under a symlinked component (e.g. macOS /var -> /private/var).
93
+ let realRoot: string;
94
+ let realAbs: string;
95
+ try {
96
+ realRoot = realpathSync(root);
97
+ realAbs = realpathSync(abs);
98
+ } catch {
99
+ return null;
100
+ }
101
+ if (realAbs !== realRoot && !realAbs.startsWith(realRoot + sep)) return null;
102
+ return abs;
103
+ }
104
+
22
105
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
23
106
  try {
24
107
  const json = argv.includes("--json");
25
- const positional = argv.filter((a) => !a.startsWith("--"));
108
+ let fileArg: string | undefined;
109
+ const positional: string[] = [];
110
+ for (let i = 0; i < argv.length; i++) {
111
+ const a = argv[i]!;
112
+ if (a === "--json") continue;
113
+ if (a === "--file") {
114
+ fileArg = argv[++i];
115
+ continue;
116
+ }
117
+ if (a.startsWith("--file=")) {
118
+ fileArg = a.slice("--file=".length);
119
+ continue;
120
+ }
121
+ if (a.startsWith("--")) continue;
122
+ positional.push(a);
123
+ }
26
124
  const name = positional[0];
27
125
 
28
126
  if (!name) {
@@ -37,9 +135,25 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
37
135
  return 1;
38
136
  }
39
137
 
40
- const body = await bodyOf(skill);
138
+ // Which file are we serving? SKILL.md (default) gets frontmatter stripped;
139
+ // any other bundled file is served verbatim.
140
+ const wantsFile = fileArg && fileArg !== SKILL_FILE && fileArg !== ".";
141
+ let body: string;
142
+ let file = SKILL_FILE;
143
+ if (wantsFile) {
144
+ const abs = resolveBundledFile(skill.path, fileArg!);
145
+ if (!abs) {
146
+ ctx.error(`No readable file "${fileArg}" in skill "${name}".`);
147
+ return 1;
148
+ }
149
+ body = await Bun.file(abs).text();
150
+ file = fileArg!;
151
+ } else {
152
+ body = await bodyOf(skill);
153
+ }
41
154
 
42
155
  if (json) {
156
+ const { mode, linkTarget } = entryModeInfo(ctx.libraryPath, skill.name);
43
157
  ctx.json({
44
158
  name: skill.name,
45
159
  description: skill.description,
@@ -47,17 +161,22 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
47
161
  domains: skill.domains,
48
162
  path: skill.path,
49
163
  bodyPath: skill.bodyPath,
164
+ file,
50
165
  body,
51
- refFiles: skill.refFiles,
166
+ refFiles: await walkRefFiles(skill.path, skill.name),
52
167
  retired: skill.retired,
53
168
  source: skill.source,
169
+ mode,
170
+ linkTarget,
54
171
  });
55
172
  return 0;
56
173
  }
57
174
 
58
175
  ctx.log(body.replace(/\n+$/, ""));
59
176
 
60
- if (skill.refFiles.length) {
177
+ // The reference-file index is part of the SKILL.md view only; when a
178
+ // specific file is opened its contents are the whole output.
179
+ if (!wantsFile && skill.refFiles.length) {
61
180
  ctx.log("");
62
181
  ctx.log(`# Reference files (${skill.refFiles.length}) — Read on demand:`);
63
182
  for (const f of skill.refFiles) ctx.log(f);
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { join } from "node:path";
6
6
  import type { Ctx, Skill } from "../types.ts";
7
+ import { resolveReadTarget } from "../core/agents.ts";
7
8
  import {
8
9
  pathExists,
9
10
  isSymlink,
@@ -13,8 +14,8 @@ import {
13
14
 
14
15
  export const meta = {
15
16
  name: "status",
16
- summary: "Which library skills are linked into ./.claude/skills",
17
- usage: "skl status [--json]",
17
+ summary: "Which library skills are linked into an agent's project skills dir (default: ./.claude/skills)",
18
+ usage: "skl status [--agent <id>] [--project <dir>] [--json]",
18
19
  } as const;
19
20
 
20
21
  interface LinkedEntry {
@@ -26,21 +27,41 @@ interface LinkedEntry {
26
27
 
27
28
  export async function run(argv: string[], ctx: Ctx): Promise<number> {
28
29
  try {
29
- const json = argv.includes("--json");
30
- const cwd = process.cwd();
31
- const skillsDir = join(cwd, ".claude", "skills");
30
+ const rt = resolveReadTarget(argv);
31
+ if ("error" in rt) {
32
+ ctx.error(`skl status: ${rt.error}`);
33
+ ctx.error("usage: " + meta.usage);
34
+ return 1;
35
+ }
36
+ const json = rt.rest.includes("--json");
37
+ // --project <dir> / --agent <id> let status inspect the SAME dir `skl use
38
+ // --project … --agent …` wrote to; default stays the cwd project's .claude/skills.
39
+ const baseDir = rt.projectDir ?? process.cwd();
40
+ const agentId = rt.agentId ?? "claude";
41
+ const cwd = baseDir;
42
+ const skillsDir = join(baseDir, `.${agentId}`, "skills");
32
43
 
33
44
  const skills = await ctx.loadLibrary();
34
45
  // index library skills by their realpath for matching
35
46
  const byReal = new Map<string, Skill>();
36
47
  for (const s of skills) byReal.set(realpathOrSelf(s.path), s);
37
48
 
49
+ // index library skills by NAME too, to flag a real project copy that shadows a
50
+ // library skill (a drift-prone unmanaged copy — a symlink can't drift, a copy can).
51
+ const byName = new Map<string, Skill>(skills.map((s) => [s.name, s]));
52
+
38
53
  const linked: LinkedEntry[] = [];
54
+ const unmanaged: Array<{ name: string; inLibrary: boolean }> = [];
39
55
  if (pathExists(skillsDir)) {
40
56
  const names = await listDirNames(skillsDir);
41
57
  for (const name of names) {
42
58
  const linkPath = join(skillsDir, name);
43
- if (!isSymlink(linkPath)) continue; // only count managed symlinks
59
+ if (!isSymlink(linkPath)) {
60
+ // a real (non-symlink) skill dir sitting in the project — unmanaged; if it
61
+ // shadows a library skill it is a drift-prone copy, not a clean deployment.
62
+ unmanaged.push({ name, inLibrary: byName.has(name) });
63
+ continue;
64
+ }
44
65
  const target = realpathOrSelf(linkPath);
45
66
  linked.push({
46
67
  link: name,
@@ -51,6 +72,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
51
72
  }
52
73
  }
53
74
  linked.sort((a, b) => (a.link < b.link ? -1 : a.link > b.link ? 1 : 0));
75
+ unmanaged.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
54
76
 
55
77
  // group resolved skills by bundle (domain tag) for the human summary
56
78
  const bundles = new Map<string, string[]>();
@@ -69,6 +91,7 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
69
91
  skillsDir,
70
92
  skillsDirExists: pathExists(skillsDir),
71
93
  linkedCount: linked.length,
94
+ unmanaged,
72
95
  bundles: [...bundles.keys()].sort().map((name) => ({
73
96
  name,
74
97
  skills: bundles.get(name)!.slice().sort(),
@@ -78,13 +101,16 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
78
101
  target: e.target,
79
102
  skill: e.skill ? e.skill.name : null,
80
103
  inLibrary: e.skill != null,
104
+ // aliased: the link resolves to a library skill under a DIFFERENT name
105
+ // (a name-keyed blind spot — see `skl where --problems`).
106
+ aliased: e.skill != null && e.skill.name !== e.link,
81
107
  domains: e.skill ? e.skill.domains : [],
82
108
  })),
83
109
  });
84
110
  return 0;
85
111
  }
86
112
 
87
- if (linked.length === 0) {
113
+ if (linked.length === 0 && unmanaged.length === 0) {
88
114
  ctx.log(`No skills linked into ${skillsDir}`);
89
115
  return 0;
90
116
  }
@@ -95,7 +121,8 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
95
121
  const dom = e.skill.domains.length
96
122
  ? ` [${e.skill.domains.join(", ")}]`
97
123
  : "";
98
- ctx.log(` ${e.link}${dom} -> ${e.skill.name}`);
124
+ const alias = e.skill.name !== e.link ? " ⚠ aliased (link name ≠ skill)" : "";
125
+ ctx.log(` ${e.link}${dom} -> ${e.skill.name}${alias}`);
99
126
  } else {
100
127
  ctx.log(` ${e.link} -> ${e.target} (not a library skill)`);
101
128
  }
@@ -109,6 +136,14 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
109
136
  ctx.log(` ${name} (${members.length}): ${members.join(", ")}`);
110
137
  }
111
138
  }
139
+
140
+ if (unmanaged.length) {
141
+ ctx.log("");
142
+ ctx.log(`⚠ Unmanaged real copies (${unmanaged.length}) — not symlinks, can drift:`);
143
+ for (const u of unmanaged) {
144
+ ctx.log(` ${u.name}${u.inLibrary ? " (shadows a library skill — `skl where --fix` to dedupe)" : ""}`);
145
+ }
146
+ }
112
147
  return 0;
113
148
  } catch (err) {
114
149
  ctx.error(`status failed: ${(err as Error).message}`);
@@ -0,0 +1,109 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, rm, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { run as tagRun } from "./tag.ts";
6
+ import { run as untagRun } from "./untag.ts";
7
+ import { run as retagRun } from "./retag.ts";
8
+ import { loadLibrary } from "../core/library.ts";
9
+ import { readTaxonomy } from "../core/taxonomy.ts";
10
+ import type { Ctx } from "../types.ts";
11
+
12
+ function makeCtx(libraryPath: string) {
13
+ const json: unknown[] = [];
14
+ const errors: string[] = [];
15
+ const ctx = {
16
+ config: { libraryPath },
17
+ libraryPath,
18
+ loadLibrary: () => loadLibrary(libraryPath),
19
+ log: () => {},
20
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
21
+ json: (v: unknown) => json.push(v),
22
+ } as unknown as Ctx;
23
+ return { ctx, json, errors };
24
+ }
25
+
26
+ async function writeSkill(library: string, name: string, frontmatterDomains?: string[]) {
27
+ const dir = join(library, name);
28
+ await mkdir(dir, { recursive: true });
29
+ const dom = frontmatterDomains ? `domains: [${frontmatterDomains.join(", ")}]\n` : "";
30
+ await writeFile(join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${name}\n${dom}---\n\nbody\n`);
31
+ }
32
+
33
+ describe("skl tag/untag/retag — surgical taxonomy edits (friction #4)", () => {
34
+ let tmp: string;
35
+ let library: string;
36
+
37
+ beforeEach(async () => {
38
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-tag-")));
39
+ library = join(tmp, "library");
40
+ await writeSkill(library, "alpha", ["bio"]); // bio is a FRONTMATTER domain
41
+ await writeSkill(library, "beta");
42
+ });
43
+ afterEach(async () => {
44
+ await rm(tmp, { recursive: true, force: true });
45
+ });
46
+
47
+ test("tag adds new domains and reports already-present ones", async () => {
48
+ const { ctx, json } = makeCtx(library);
49
+ await tagRun(["alpha", "coding", "nlp", "--json"], ctx);
50
+ expect(json[0]).toMatchObject({ added: ["coding", "nlp"], already: [] });
51
+
52
+ const { ctx: c2, json: j2 } = makeCtx(library);
53
+ await tagRun(["alpha", "coding", "--json"], c2);
54
+ expect(j2[0]).toMatchObject({ added: [], already: ["coding"] });
55
+ });
56
+
57
+ test("tag refuses an unknown skill", async () => {
58
+ const { ctx, errors } = makeCtx(library);
59
+ const code = await tagRun(["ghost", "x"], ctx);
60
+ expect(code).toBe(1);
61
+ expect(errors.join("\n")).toContain("not in the library");
62
+ });
63
+
64
+ test("untag removes a taxonomy domain", async () => {
65
+ const { ctx } = makeCtx(library);
66
+ await tagRun(["beta", "coding", "nlp"], ctx);
67
+ const { ctx: c2, json } = makeCtx(library);
68
+ const code = await untagRun(["beta", "coding", "--json"], c2);
69
+ expect(code).toBe(0);
70
+ expect(json[0]).toMatchObject({ removed: "coding", domains: ["nlp"] });
71
+ });
72
+
73
+ test("untag a frontmatter domain explains it can't be removed from the taxonomy", async () => {
74
+ const { ctx, errors } = makeCtx(library);
75
+ const code = await untagRun(["alpha", "bio"], ctx);
76
+ expect(code).toBe(1);
77
+ expect(errors.join("\n")).toContain("frontmatter");
78
+ });
79
+
80
+ test("untag a never-present domain errors (no silent no-op)", async () => {
81
+ const { ctx, errors } = makeCtx(library);
82
+ const code = await untagRun(["beta", "zzz"], ctx);
83
+ expect(code).toBe(1);
84
+ expect(errors.join("\n")).toContain("not tagged");
85
+ });
86
+
87
+ test("retag renames a domain across the whole taxonomy deterministically", async () => {
88
+ const { ctx } = makeCtx(library);
89
+ await tagRun(["alpha", "ml"], ctx);
90
+ await tagRun(["beta", "ml"], makeCtx(library).ctx);
91
+
92
+ const { ctx: c3, json } = makeCtx(library);
93
+ const code = await retagRun(["ml", "machine-learning", "--json"], c3);
94
+ expect(code).toBe(0);
95
+ expect((json[0] as { changed: string[] }).changed.sort()).toEqual(["alpha", "beta"]);
96
+
97
+ const tax = await readTaxonomy(library);
98
+ expect(tax.skills.alpha).toContain("machine-learning");
99
+ expect(tax.skills.beta).toContain("machine-learning");
100
+ expect(tax.skills.alpha).not.toContain("ml");
101
+ });
102
+
103
+ test("retag a domain no skill carries is a clean no-op (changed:[])", async () => {
104
+ const { ctx, json } = makeCtx(library);
105
+ const code = await retagRun(["nonexistent", "whatever", "--json"], ctx);
106
+ expect(code).toBe(0);
107
+ expect((json[0] as { changed: string[] }).changed).toEqual([]);
108
+ });
109
+ });
@@ -0,0 +1,68 @@
1
+ // `skl tag <name> <domain>...` — add one or more domain tags to a skill, surgically
2
+ // and deterministically, in the central taxonomy.json (ADR-0002). The only other
3
+ // taxonomy writer is the non-deterministic AI `infer` pass; this is the precise,
4
+ // no-LLM edit for "give this one skill this one tag" that previously forced a
5
+ // hand-edit of taxonomy.json (or a silently-failing frontmatter sed).
6
+ //
7
+ // skl tag <name> <domain> [<domain>...] [--json]
8
+
9
+ import type { Ctx } from "../types.ts";
10
+ import { findByName } from "../core/library.ts";
11
+ import { addDomainsForName } from "../core/taxonomy.ts";
12
+ import { reindexLibrary } from "../core/lifecycle.ts";
13
+
14
+ export const meta = {
15
+ name: "tag",
16
+ summary: "Add domain tag(s) to a skill in the central taxonomy",
17
+ usage: "skl tag <name> <domain> [<domain>...] [--json]",
18
+ } as const;
19
+
20
+ const SLUG_RE = /^[a-z0-9][a-z0-9-]*$/;
21
+
22
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
23
+ const json = argv.includes("--json");
24
+ const positional = argv.filter((a) => !a.startsWith("--"));
25
+ const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--json");
26
+ if (unknownFlag) {
27
+ ctx.error(`skl tag: unknown argument: ${unknownFlag}`);
28
+ ctx.error(`usage: ${meta.usage}`);
29
+ return 1;
30
+ }
31
+ const [name, ...domains] = positional;
32
+ if (!name || domains.length === 0) {
33
+ ctx.error("skl tag: a <name> and at least one <domain> are required");
34
+ ctx.error(`usage: ${meta.usage}`);
35
+ return 1;
36
+ }
37
+ const bad = domains.find((d) => !SLUG_RE.test(d));
38
+ if (bad) {
39
+ ctx.error(`skl tag: invalid domain "${bad}" — use lowercase letters, digits, and hyphens`);
40
+ return 1;
41
+ }
42
+
43
+ try {
44
+ const skills = await ctx.loadLibrary();
45
+ if (!findByName(skills, name)) {
46
+ ctx.error(`skl tag: '${name}' is not in the library`);
47
+ return 1;
48
+ }
49
+ const { added, already, domains: resulting } = await addDomainsForName(
50
+ ctx.libraryPath,
51
+ name,
52
+ domains,
53
+ );
54
+ if (added.length > 0) await reindexLibrary(ctx.libraryPath);
55
+
56
+ if (json) {
57
+ ctx.json({ ok: true, name, added, already, domains: resulting });
58
+ } else {
59
+ if (added.length > 0) ctx.log(`tagged ${name} += [${added.join(", ")}]`);
60
+ if (already.length > 0) ctx.log(` (already had: ${already.join(", ")})`);
61
+ ctx.log(` domains: [${resulting.join(", ")}]`);
62
+ }
63
+ return 0;
64
+ } catch (err) {
65
+ ctx.error(`skl tag: ${err instanceof Error ? err.message : String(err)}`);
66
+ return 1;
67
+ }
68
+ }
@@ -0,0 +1,170 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, symlink, rm, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createHash } from "node:crypto";
6
+ import { parseFrontmatter } from "../lib/frontmatter.ts";
7
+ import { run as trackRun } from "./track.ts";
8
+ import { run as untrackRun } from "./untrack.ts";
9
+ import { readLockfile } from "../core/provenance.ts";
10
+ import type { Ctx } from "../types.ts";
11
+
12
+ function makeCtx(libraryPath: string) {
13
+ const logs: string[] = [];
14
+ const errors: string[] = [];
15
+ const json: unknown[] = [];
16
+ const ctx = {
17
+ config: { libraryPath },
18
+ libraryPath,
19
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
20
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
21
+ json: (v: unknown) => json.push(v),
22
+ } as unknown as Ctx;
23
+ return { ctx, logs, errors, json };
24
+ }
25
+
26
+ function bodyHash(body: string): string {
27
+ return createHash("sha256").update(body, "utf8").digest("hex");
28
+ }
29
+
30
+ describe("skl track — adopt provenance offline", () => {
31
+ let tmp: string;
32
+ let library: string;
33
+
34
+ beforeEach(async () => {
35
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-track-")));
36
+ library = join(tmp, "library");
37
+ await mkdir(join(library, "foo"), { recursive: true });
38
+ await writeFile(join(library, "foo", "SKILL.md"), "---\nname: foo\ndescription: a foo\n---\n\nBODY OF FOO\n");
39
+ });
40
+ afterEach(async () => {
41
+ await rm(tmp, { recursive: true, force: true });
42
+ });
43
+
44
+ test("attaches an adopted lock entry with the local body hash and empty ref", async () => {
45
+ const { ctx, json } = makeCtx(library);
46
+ const code = await trackRun(["foo", "--source", "github:owner/repo", "--json"], ctx);
47
+ expect(code).toBe(0);
48
+
49
+ const lock = await readLockfile(library);
50
+ const e = lock.entries.foo!;
51
+ expect(e.source).toBe("github:owner/repo");
52
+ expect(e.channel).toBe("github");
53
+ expect(e.ref).toBe("");
54
+ expect(e.adopted).toBe(true);
55
+ expect(e.localEdits).toBe(false);
56
+ const expectedBody = parseFrontmatter("---\nname: foo\ndescription: a foo\n---\n\nBODY OF FOO\n").body;
57
+ expect(e.installedHash).toBe(bodyHash(expectedBody));
58
+
59
+ const summary = json[0] as { adopted: boolean; ref: string };
60
+ expect(summary.adopted).toBe(true);
61
+ });
62
+
63
+ test("stores a github subpath in the add.ts @-convention and round-trips", async () => {
64
+ const { ctx } = makeCtx(library);
65
+ const code = await trackRun(["foo", "--source", "github:owner/repo@skills/foo"], ctx);
66
+ expect(code).toBe(0);
67
+ const lock = await readLockfile(library);
68
+ expect(lock.entries.foo!.source).toBe("github:owner/repo@skills/foo");
69
+ });
70
+
71
+ test("--ref asserts the exact commit and clears adopted", async () => {
72
+ const { ctx } = makeCtx(library);
73
+ const code = await trackRun(["foo", "--source", "github:owner/repo", "--ref", "deadbeef"], ctx);
74
+ expect(code).toBe(0);
75
+ const e = (await readLockfile(library)).entries.foo!;
76
+ expect(e.ref).toBe("deadbeef");
77
+ expect(e.adopted).toBe(false);
78
+ });
79
+
80
+ test("refuses a skill not in the library, pointing at import/add", async () => {
81
+ const { ctx, errors } = makeCtx(library);
82
+ const code = await trackRun(["nope", "--source", "github:owner/repo"], ctx);
83
+ expect(code).toBe(1);
84
+ expect(errors.join("\n")).toContain("not in the library");
85
+ expect((await readLockfile(library)).entries.nope).toBeUndefined();
86
+ });
87
+
88
+ test("refuses a LINKED entry (dev repo owns versioning, ADR-0004)", async () => {
89
+ // Make a LINKED skill: library/bar -> external dev repo.
90
+ const dev = join(tmp, "dev", "bar");
91
+ await mkdir(dev, { recursive: true });
92
+ await writeFile(join(dev, "SKILL.md"), "---\nname: bar\ndescription: d\n---\n\nbody\n");
93
+ await symlink(dev, join(library, "bar"));
94
+
95
+ const { ctx, errors } = makeCtx(library);
96
+ const code = await trackRun(["bar", "--source", "github:owner/repo"], ctx);
97
+ expect(code).toBe(1);
98
+ expect(errors.join("\n")).toContain("LINKED");
99
+ expect((await readLockfile(library)).entries.bar).toBeUndefined();
100
+ });
101
+
102
+ test("refuses an ALIASED LINKED entry where the symlink slug != frontmatter name", async () => {
103
+ // Regression for the linked-guard bypass: a dev repo linked as library/aka-dir but
104
+ // whose SKILL.md frontmatter name is `aka-name`. findByName matches the frontmatter
105
+ // name, but linked-ness must be resolved by the on-disk slug (basename of the path),
106
+ // or a LINKED skill slips the guard and `update` later clobbers the dev repo (ADR-0004).
107
+ const dev = join(tmp, "dev", "aka-dir");
108
+ await mkdir(dev, { recursive: true });
109
+ await writeFile(join(dev, "SKILL.md"), "---\nname: aka-name\ndescription: d\n---\n\nbody\n");
110
+ await symlink(dev, join(library, "aka-dir"));
111
+
112
+ const { ctx, errors } = makeCtx(library);
113
+ const code = await trackRun(["aka-name", "--source", "github:owner/repo"], ctx);
114
+ expect(code).toBe(1);
115
+ expect(errors.join("\n")).toContain("LINKED");
116
+ expect((await readLockfile(library)).entries["aka-name"]).toBeUndefined();
117
+ expect((await readLockfile(library)).entries["aka-dir"]).toBeUndefined();
118
+ });
119
+
120
+ test("refuses an existing lock entry without --force, allows with --force", async () => {
121
+ const { ctx } = makeCtx(library);
122
+ await trackRun(["foo", "--source", "github:owner/repo"], ctx);
123
+
124
+ const second = makeCtx(library);
125
+ const code = await trackRun(["foo", "--source", "github:other/repo"], second.ctx);
126
+ expect(code).toBe(1);
127
+ expect(second.errors.join("\n")).toContain("already");
128
+ // unchanged
129
+ expect((await readLockfile(library)).entries.foo!.source).toBe("github:owner/repo");
130
+
131
+ const third = makeCtx(library);
132
+ const fcode = await trackRun(["foo", "--source", "github:other/repo", "--force"], third.ctx);
133
+ expect(fcode).toBe(0);
134
+ expect((await readLockfile(library)).entries.foo!.source).toBe("github:other/repo");
135
+ });
136
+ });
137
+
138
+ describe("skl untrack — inverse of track, idempotent", () => {
139
+ let tmp: string;
140
+ let library: string;
141
+
142
+ beforeEach(async () => {
143
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-untrack-")));
144
+ library = join(tmp, "library");
145
+ await mkdir(join(library, "foo"), { recursive: true });
146
+ await writeFile(join(library, "foo", "SKILL.md"), "---\nname: foo\ndescription: d\n---\n\nbody\n");
147
+ });
148
+ afterEach(async () => {
149
+ await rm(tmp, { recursive: true, force: true });
150
+ });
151
+
152
+ test("removes an existing entry", async () => {
153
+ const a = makeCtx(library);
154
+ await trackRun(["foo", "--source", "github:owner/repo"], a.ctx);
155
+ expect((await readLockfile(library)).entries.foo).toBeDefined();
156
+
157
+ const b = makeCtx(library);
158
+ const code = await untrackRun(["foo", "--json"], b.ctx);
159
+ expect(code).toBe(0);
160
+ expect((b.json[0] as { removed: boolean }).removed).toBe(true);
161
+ expect((await readLockfile(library)).entries.foo).toBeUndefined();
162
+ });
163
+
164
+ test("is a no-op (exit 0) when absent", async () => {
165
+ const { ctx, json } = makeCtx(library);
166
+ const code = await untrackRun(["ghost", "--json"], ctx);
167
+ expect(code).toBe(0);
168
+ expect((json[0] as { removed: boolean }).removed).toBe(false);
169
+ });
170
+ });