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.
- package/README.md +57 -19
- package/package.json +8 -2
- package/src/adapters/inference/agent.ts +23 -16
- package/src/cli.ts +31 -0
- package/src/commands/add.ts +624 -128
- package/src/commands/agents.ts +120 -0
- package/src/commands/drop.ts +21 -13
- package/src/commands/import.ts +44 -28
- package/src/commands/infer.ts +6 -6
- package/src/commands/link.test.ts +160 -0
- package/src/commands/link.ts +317 -0
- package/src/commands/ls.ts +118 -18
- package/src/commands/mode-surfacing.test.ts +110 -0
- package/src/commands/outdated.test.ts +55 -0
- package/src/commands/outdated.ts +138 -18
- package/src/commands/refresh.ts +133 -0
- package/src/commands/remediation.test.ts +149 -0
- package/src/commands/rename.test.ts +121 -0
- package/src/commands/rename.ts +64 -0
- package/src/commands/retag.ts +58 -0
- package/src/commands/retire.ts +39 -0
- package/src/commands/rm.test.ts +133 -0
- package/src/commands/rm.ts +107 -0
- package/src/commands/roots.ts +41 -0
- package/src/commands/scan.ts +122 -30
- package/src/commands/show.ts +4 -1
- package/src/commands/status.ts +43 -8
- package/src/commands/tag.test.ts +109 -0
- package/src/commands/tag.ts +68 -0
- package/src/commands/unretire.ts +33 -0
- package/src/commands/untag.ts +73 -0
- package/src/commands/update.test.ts +71 -0
- package/src/commands/update.ts +65 -15
- package/src/commands/use.test.ts +92 -0
- package/src/commands/use.ts +46 -23
- package/src/commands/where.ts +232 -0
- package/src/config.test.ts +69 -0
- package/src/config.ts +79 -10
- package/src/core/agents.test.ts +232 -0
- package/src/core/agents.ts +363 -0
- package/src/core/bundle.ts +12 -15
- package/src/core/core.test.ts +14 -1
- package/src/core/crawl.ts +22 -5
- package/src/core/dedupe.ts +36 -0
- package/src/core/deployments.test.ts +147 -0
- package/src/core/deployments.ts +208 -0
- package/src/core/fetch.ts +344 -70
- package/src/core/indexgen.ts +2 -0
- package/src/core/library.test.ts +41 -0
- package/src/core/library.ts +61 -16
- package/src/core/lifecycle.ts +252 -0
- package/src/core/surfaces.ts +46 -0
- package/src/core/taxonomy.test.ts +159 -0
- package/src/core/taxonomy.ts +190 -0
- package/src/lib/fs.ts +2 -2
- package/src/types.ts +85 -15
- package/src/core/overlay.ts +0 -63
package/src/commands/status.ts
CHANGED
|
@@ -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
|
|
30
|
-
|
|
31
|
-
|
|
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))
|
|
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
|
-
|
|
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
|
+
});
|
package/src/commands/update.ts
CHANGED
|
@@ -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
|
-
// -
|
|
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
|
|
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
|
-
/**
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
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;
|