skillshelf 0.2.0 → 0.3.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 (57) hide show
  1. package/README.md +57 -19
  2. package/package.json +8 -2
  3. package/src/adapters/inference/agent.ts +23 -16
  4. package/src/cli.ts +31 -0
  5. package/src/commands/add.ts +624 -128
  6. package/src/commands/agents.ts +120 -0
  7. package/src/commands/drop.ts +21 -13
  8. package/src/commands/import.ts +44 -28
  9. package/src/commands/infer.ts +6 -6
  10. package/src/commands/link.test.ts +160 -0
  11. package/src/commands/link.ts +317 -0
  12. package/src/commands/ls.ts +118 -18
  13. package/src/commands/mode-surfacing.test.ts +110 -0
  14. package/src/commands/outdated.test.ts +55 -0
  15. package/src/commands/outdated.ts +138 -18
  16. package/src/commands/refresh.ts +133 -0
  17. package/src/commands/remediation.test.ts +149 -0
  18. package/src/commands/rename.test.ts +121 -0
  19. package/src/commands/rename.ts +64 -0
  20. package/src/commands/retag.ts +58 -0
  21. package/src/commands/retire.ts +39 -0
  22. package/src/commands/rm.test.ts +133 -0
  23. package/src/commands/rm.ts +107 -0
  24. package/src/commands/roots.ts +41 -0
  25. package/src/commands/scan.ts +122 -30
  26. package/src/commands/show.ts +4 -1
  27. package/src/commands/status.ts +43 -8
  28. package/src/commands/tag.test.ts +109 -0
  29. package/src/commands/tag.ts +68 -0
  30. package/src/commands/unretire.ts +33 -0
  31. package/src/commands/untag.ts +73 -0
  32. package/src/commands/update.test.ts +71 -0
  33. package/src/commands/update.ts +65 -15
  34. package/src/commands/use.test.ts +92 -0
  35. package/src/commands/use.ts +46 -23
  36. package/src/commands/where.ts +232 -0
  37. package/src/config.test.ts +69 -0
  38. package/src/config.ts +79 -10
  39. package/src/core/agents.test.ts +232 -0
  40. package/src/core/agents.ts +363 -0
  41. package/src/core/bundle.ts +12 -15
  42. package/src/core/core.test.ts +14 -1
  43. package/src/core/crawl.ts +22 -5
  44. package/src/core/dedupe.ts +36 -0
  45. package/src/core/deployments.test.ts +147 -0
  46. package/src/core/deployments.ts +208 -0
  47. package/src/core/fetch.ts +344 -70
  48. package/src/core/indexgen.ts +2 -0
  49. package/src/core/library.test.ts +41 -0
  50. package/src/core/library.ts +61 -16
  51. package/src/core/lifecycle.ts +252 -0
  52. package/src/core/surfaces.ts +46 -0
  53. package/src/core/taxonomy.test.ts +159 -0
  54. package/src/core/taxonomy.ts +190 -0
  55. package/src/lib/fs.ts +2 -2
  56. package/src/types.ts +85 -15
  57. package/src/core/overlay.ts +0 -63
@@ -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,33 @@
1
+ // `skl unretire <name>` — restore a retired skill from <library>/_retired/<name>/
2
+ // back to the active library. The inverse of `skl retire`.
3
+ //
4
+ // skl unretire <name> [--json]
5
+
6
+ import type { Ctx } from "../types.ts";
7
+ import { unretireSkill, reindexLibrary } from "../core/lifecycle.ts";
8
+
9
+ export const meta = {
10
+ name: "unretire",
11
+ summary: "Restore a retired skill back to the active library",
12
+ usage: "skl unretire <name> [--json]",
13
+ } as const;
14
+
15
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
16
+ const json = argv.includes("--json");
17
+ const name = argv.find((a) => !a.startsWith("--"));
18
+ if (!name) {
19
+ ctx.error("skl unretire: a <name> is required");
20
+ ctx.error(`usage: ${meta.usage}`);
21
+ return 1;
22
+ }
23
+ try {
24
+ const dest = await unretireSkill(ctx.libraryPath, name);
25
+ await reindexLibrary(ctx.libraryPath);
26
+ if (json) ctx.json({ ok: true, name, restoredTo: dest });
27
+ else ctx.log(`unretired ${name} (active again)`);
28
+ return 0;
29
+ } catch (err) {
30
+ ctx.error(`skl unretire: ${err instanceof Error ? err.message : String(err)}`);
31
+ return 1;
32
+ }
33
+ }
@@ -0,0 +1,73 @@
1
+ // `skl untag <name> <domain>` — remove ONE domain tag from a skill in the central
2
+ // taxonomy.json (ADR-0002). The inverse of `skl tag`. Errors (does not silently
3
+ // no-op) when the domain isn't a taxonomy tag — a typo'd untag should be visible —
4
+ // and distinguishes a frontmatter-declared domain (which lives in the skill body,
5
+ // not the taxonomy, and can't be removed here).
6
+ //
7
+ // skl untag <name> <domain> [--json]
8
+
9
+ import type { Ctx } from "../types.ts";
10
+ import { findByName } from "../core/library.ts";
11
+ import { readTaxonomy, domainsForName, removeDomainForName } from "../core/taxonomy.ts";
12
+ import { reindexLibrary } from "../core/lifecycle.ts";
13
+
14
+ export const meta = {
15
+ name: "untag",
16
+ summary: "Remove a domain tag from a skill in the central taxonomy",
17
+ usage: "skl untag <name> <domain> [--json]",
18
+ } as const;
19
+
20
+ export async function run(argv: string[], ctx: Ctx): Promise<number> {
21
+ const json = argv.includes("--json");
22
+ const positional = argv.filter((a) => !a.startsWith("--"));
23
+ const unknownFlag = argv.find((a) => a.startsWith("--") && a !== "--json");
24
+ if (unknownFlag) {
25
+ ctx.error(`skl untag: unknown argument: ${unknownFlag}`);
26
+ ctx.error(`usage: ${meta.usage}`);
27
+ return 1;
28
+ }
29
+ const [name, domain] = positional;
30
+ if (!name || !domain) {
31
+ ctx.error("skl untag: a <name> and a <domain> are required");
32
+ ctx.error(`usage: ${meta.usage}`);
33
+ return 1;
34
+ }
35
+
36
+ try {
37
+ const skills = await ctx.loadLibrary();
38
+ const skill = findByName(skills, name);
39
+ if (!skill) {
40
+ ctx.error(`skl untag: '${name}' is not in the library`);
41
+ return 1;
42
+ }
43
+
44
+ const removed = await removeDomainForName(ctx.libraryPath, name, domain);
45
+ if (!removed) {
46
+ // Distinguish "never had it" from "it's declared in SKILL.md frontmatter".
47
+ const tax = await readTaxonomy(ctx.libraryPath);
48
+ const inTaxonomy = domainsForName(tax, name).includes(domain);
49
+ if (!inTaxonomy && skill.domains.includes(domain)) {
50
+ ctx.error(
51
+ `skl untag: '${domain}' is declared in ${name}'s SKILL.md frontmatter, not the taxonomy — edit the skill body to remove it`,
52
+ );
53
+ } else {
54
+ ctx.error(`skl untag: '${name}' is not tagged '${domain}'`);
55
+ }
56
+ return 1;
57
+ }
58
+
59
+ await reindexLibrary(ctx.libraryPath);
60
+ const tax = await readTaxonomy(ctx.libraryPath);
61
+ const resulting = domainsForName(tax, name);
62
+ if (json) {
63
+ ctx.json({ ok: true, name, removed: domain, domains: resulting });
64
+ } else {
65
+ ctx.log(`untagged ${name} -= ${domain}`);
66
+ ctx.log(` taxonomy domains: [${resulting.join(", ")}]`);
67
+ }
68
+ return 0;
69
+ } catch (err) {
70
+ ctx.error(`skl untag: ${err instanceof Error ? err.message : String(err)}`);
71
+ return 1;
72
+ }
73
+ }
@@ -0,0 +1,71 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, readFile, symlink, rm, realpath } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { run } from "./update.ts";
6
+ import type { Ctx } from "../types.ts";
7
+
8
+ function makeCtx(libraryPath: string) {
9
+ const logs: string[] = [];
10
+ const errors: string[] = [];
11
+ const json: unknown[] = [];
12
+ const ctx = {
13
+ config: { libraryPath },
14
+ libraryPath,
15
+ log: (...a: unknown[]) => logs.push(a.join(" ")),
16
+ error: (...a: unknown[]) => errors.push(a.join(" ")),
17
+ json: (v: unknown) => json.push(v),
18
+ } as unknown as Ctx;
19
+ return { ctx, logs, errors, json };
20
+ }
21
+
22
+ describe("skl update — LINKED entries are skipped (ADR-0004 safety)", () => {
23
+ let tmp: string;
24
+ let library: string;
25
+ let devRepo: string;
26
+
27
+ beforeEach(async () => {
28
+ tmp = await realpath(await mkdtemp(join(tmpdir(), "skl-update-")));
29
+ library = join(tmp, "library");
30
+ devRepo = join(tmp, "dev", "devskill");
31
+ await mkdir(library, { recursive: true });
32
+ await mkdir(devRepo, { recursive: true });
33
+ // The dev repo's canonical body — must NOT be clobbered by update.
34
+ await writeFile(join(devRepo, "SKILL.md"), "---\nname: devskill\n---\n\nDEV REPO BODY v1\n");
35
+ // library/devskill is a LINKED entry (symlink to the dev repo).
36
+ await symlink(devRepo, join(library, "devskill"));
37
+ // A STALE lockfile entry — as if devskill had once been a github import.
38
+ const lock = {
39
+ version: 1,
40
+ entries: {
41
+ devskill: {
42
+ name: "devskill",
43
+ source: "github:owner/repo",
44
+ ref: "0000000000000000000000000000000000000000",
45
+ channel: "github",
46
+ installedAt: "2020-01-01T00:00:00.000Z",
47
+ localEdits: false,
48
+ },
49
+ },
50
+ };
51
+ await writeFile(join(library, "shelf.lock.json"), JSON.stringify(lock, null, 2));
52
+ });
53
+ afterEach(async () => {
54
+ await rm(tmp, { recursive: true, force: true });
55
+ });
56
+
57
+ test("does not pull upstream into a LINKED dev repo", async () => {
58
+ const { ctx, json } = makeCtx(library);
59
+ const code = await run(["devskill", "--json"], ctx);
60
+
61
+ expect(code).toBe(0); // skipped is not an error
62
+ const report = json[0] as { results: Array<{ name: string; outcome: string; note: string }> };
63
+ const row = report.results.find((r) => r.name === "devskill")!;
64
+ expect(row.outcome).toBe("skipped");
65
+ expect(row.note).toContain("LINKED");
66
+
67
+ // The dev repo body is untouched — update never followed the symlink.
68
+ const body = await readFile(join(devRepo, "SKILL.md"), "utf8");
69
+ expect(body).toContain("DEV REPO BODY v1");
70
+ });
71
+ });
@@ -1,7 +1,9 @@
1
1
  // skl update [name] — re-pull the upstream SKILL.md body for tracked skills.
2
2
  //
3
3
  // Invariants (the whole point of this command):
4
- // - The overlay (<name>.shelf.json) is NEVER touched: taxonomy/bundles survive.
4
+ // - Domain tags are NEVER touched: they live in the central <library>/taxonomy.json
5
+ // (ADR-0002), which is separate from skill bodies, so re-pulling SKILL.md leaves
6
+ // every skill's domains intact. (There is no longer a per-skill overlay file.)
5
7
  // - Only the upstream body (SKILL.md + bundled reference files) is replaced.
6
8
  // - If the LOCAL body diverged from the previously-installed upstream (the user
7
9
  // hand-edited it), DO NOT clobber. Show a diff and skip, unless --force.
@@ -11,7 +13,7 @@
11
13
  // to preview without writing; --force to overwrite diverged local edits.
12
14
 
13
15
  import { join, basename } from "node:path";
14
- import { existsSync } from "node:fs";
16
+ import { existsSync, type Dirent } from "node:fs";
15
17
  import { cp, rm, readdir } from "node:fs/promises";
16
18
  import type { Ctx, LockEntry } from "../types.ts";
17
19
  import { readLockfile, recordEntry } from "../core/provenance.ts";
@@ -24,11 +26,11 @@ import {
24
26
  } from "../core/fetch.ts";
25
27
  import { hashContent } from "../core/crawl.ts";
26
28
  import { parseFrontmatter } from "../lib/frontmatter.ts";
27
- import { loadLibrary, findByName } from "../core/library.ts";
29
+ import { loadLibrary, findByName, entryMode } from "../core/library.ts";
28
30
 
29
31
  export const meta = {
30
32
  name: "update",
31
- summary: "Re-pull upstream body, preserve overlay, diff if local body diverged",
33
+ summary: "Re-pull upstream body, preserve domain tags, diff if local body diverged",
32
34
  usage: "skl update [name] [--force] [--dry-run] [--json]",
33
35
  } as const;
34
36
 
@@ -50,11 +52,18 @@ function bodyOf(text: string): string {
50
52
  return parseFrontmatter(text).body;
51
53
  }
52
54
 
53
- /** Replace SKILL.md + bundled reference files from upstream; preserve overlay/lock. */
54
- async function applyUpstream(destDir: string, upstreamDir: string, name: string): Promise<void> {
55
- const PRESERVE = new Set([`${name}.shelf.json`, "shelf.lock.json"]);
56
- // Remove existing upstream-managed files (everything except overlay/lock/.git).
57
- let entries: Awaited<ReturnType<typeof readdir>> = [];
55
+ /**
56
+ * Replace SKILL.md + bundled reference files from upstream within a single skill
57
+ * dir. Domain tags and provenance are NOT stored inside the skill dir — the central
58
+ * taxonomy.json and shelf.lock.json both live at the LIBRARY ROOT (ADR-0002), never
59
+ * inside `destDir` so the only thing to protect here is the skill's own `.git`.
60
+ * We still keep `shelf.lock.json`/`taxonomy.json` in the preserve set defensively,
61
+ * so a stray copy inside a skill dir is never deleted by this cleanup.
62
+ */
63
+ async function applyUpstream(destDir: string, upstreamDir: string, _name: string): Promise<void> {
64
+ const PRESERVE = new Set(["shelf.lock.json", "taxonomy.json"]);
65
+ // Remove existing upstream-managed files (everything except lock/taxonomy/.git).
66
+ let entries: Dirent[] = [];
58
67
  try {
59
68
  entries = await readdir(destDir, { withFileTypes: true });
60
69
  } catch {
@@ -158,7 +167,8 @@ async function updateOne(
158
167
  };
159
168
  }
160
169
 
161
- // Apply: replace body + ref files, preserve overlay/lock.
170
+ // Apply: replace body + ref files; domain tags + lock live at the library
171
+ // root (taxonomy.json / shelf.lock.json), untouched by this skill-dir cleanup.
162
172
  await applyUpstream(destDir, fetched.skillDir, entry.name);
163
173
 
164
174
  // Update lockfile ref + record the new installed body hash + clear localEdits
@@ -179,7 +189,7 @@ async function updateOne(
179
189
  fromRef: entry.ref,
180
190
  toRef: fetched.ref,
181
191
  outcome: "updated",
182
- note: opts.force && localDiverged ? "overwrote diverged local body" : "upstream body re-pulled; overlay preserved",
192
+ note: opts.force && localDiverged ? "overwrote diverged local body" : "upstream body re-pulled; domain tags preserved",
183
193
  };
184
194
  } catch (err) {
185
195
  return {
@@ -208,23 +218,63 @@ export async function run(argv: string[], ctx: Ctx): Promise<number> {
208
218
  if (nameArg) entries = entries.filter((e) => e.name === nameArg);
209
219
  entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
210
220
 
211
- if (entries.length === 0) {
221
+ // Resolve on-disk dirs via the library so renames/domain folders are honored;
222
+ // also lets us surface LINKED skills that have NO lock entry (the normal `skl
223
+ // link --from` case) as positive 'skipped (linked)' evidence rather than silence.
224
+ const library = await loadLibrary(ctx.config.libraryPath);
225
+ const lockNames = new Set(entries.map((e) => e.name));
226
+ const linkedNoLock = library.filter(
227
+ (s) =>
228
+ !lockNames.has(s.name) &&
229
+ (!nameArg || s.name === nameArg) &&
230
+ entryMode(ctx.config.libraryPath, s.name) === "linked",
231
+ );
232
+
233
+ if (entries.length === 0 && linkedNoLock.length === 0) {
212
234
  if (json) ctx.json({ ok: true, updated: 0, diverged: 0, results: [] });
213
235
  else if (nameArg) ctx.error(`no tracked skill named "${nameArg}"`);
214
236
  else ctx.log("no tracked third-party skills (lockfile is empty)");
215
237
  return nameArg && !json ? 1 : 0;
216
238
  }
217
239
 
218
- // Resolve on-disk dirs via the library so renames/domain folders are honored.
219
- const library = await loadLibrary(ctx.config.libraryPath);
220
-
221
240
  const results: Result[] = [];
222
241
  for (const entry of entries) {
242
+ // LINKED entries (library/<name> is a symlink to an external dev repo) own their
243
+ // own versioning via their own git. Re-pulling upstream would follow the symlink
244
+ // and clobber the dev repo, so skip them outright (ADR-0004). A stale lock entry
245
+ // can exist if an OWNED import was later converted with `skl link --from --force`.
246
+ if (entryMode(ctx.config.libraryPath, entry.name) === "linked") {
247
+ results.push({
248
+ name: entry.name,
249
+ source: entry.source,
250
+ channel: entry.channel,
251
+ fromRef: entry.ref,
252
+ toRef: null,
253
+ outcome: "skipped",
254
+ note: "LINKED to a dev repo — its own git owns versioning; not pulling upstream",
255
+ });
256
+ continue;
257
+ }
223
258
  const skill = findByName(library, entry.name);
224
259
  const destDir = skill?.path ?? join(ctx.config.libraryPath, entry.name);
225
260
  results.push(await updateOne(ctx, entry, destDir, { force, dryRun }));
226
261
  }
227
262
 
263
+ // LINKED skills with no lock entry: report them as explicitly skipped so the
264
+ // never-clobber-a-dev-repo guarantee is visible, not silent.
265
+ for (const s of linkedNoLock) {
266
+ results.push({
267
+ name: s.name,
268
+ source: "(dev repo)",
269
+ channel: "local",
270
+ fromRef: "-",
271
+ toRef: null,
272
+ outcome: "skipped",
273
+ note: "LINKED to a dev repo — its own git owns versioning; not pulling upstream",
274
+ });
275
+ }
276
+ results.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
277
+
228
278
  const updated = results.filter((r) => r.outcome === "updated").length;
229
279
  const diverged = results.filter((r) => r.outcome === "diverged").length;
230
280
  const errored = results.filter((r) => r.outcome === "error").length;