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.
- package/README.md +83 -20
- package/package.json +8 -2
- package/src/adapters/inference/agent.ts +23 -16
- package/src/cli.ts +39 -0
- package/src/commands/add.ts +624 -128
- package/src/commands/adopted.test.ts +144 -0
- package/src/commands/agents-config.test.ts +126 -0
- package/src/commands/agents.test.ts +96 -0
- package/src/commands/agents.ts +243 -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 +136 -19
- package/src/commands/migrate.test.ts +157 -0
- package/src/commands/migrate.ts +260 -0
- package/src/commands/mode-surfacing.test.ts +110 -0
- package/src/commands/outdated.test.ts +55 -0
- package/src/commands/outdated.ts +166 -18
- package/src/commands/projects.test.ts +85 -0
- package/src/commands/projects.ts +80 -0
- 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 +130 -11
- 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/track.test.ts +170 -0
- package/src/commands/track.ts +340 -0
- package/src/commands/unretire.ts +33 -0
- package/src/commands/untag.ts +73 -0
- package/src/commands/untrack.ts +44 -0
- package/src/commands/update.test.ts +71 -0
- package/src/commands/update.ts +157 -15
- package/src/commands/use.test.ts +122 -0
- package/src/commands/use.ts +46 -23
- package/src/commands/where.ts +232 -0
- package/src/config.test.ts +198 -0
- package/src/config.ts +232 -10
- package/src/core/agents.test.ts +319 -0
- package/src/core/agents.ts +438 -0
- package/src/core/bundle.ts +12 -15
- package/src/core/core.test.ts +21 -8
- 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 +371 -75
- 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 +155 -15
- package/src/core/overlay.ts +0 -63
package/src/commands/show.ts
CHANGED
|
@@ -1,28 +1,126 @@
|
|
|
1
|
-
// `skl show <name>` — print
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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);
|
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,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
|
+
});
|