@dealdeploy/skl 0.4.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/add-tui.test.ts +338 -0
- package/add-tui.ts +269 -0
- package/add.ts +81 -328
- package/index.ts +112 -976
- package/lib.test.ts +470 -40
- package/lib.ts +240 -37
- package/package.json +1 -1
- package/skills-lock.json +10 -0
- package/tui.test.ts +565 -0
- package/tui.ts +612 -0
- package/update.ts +90 -90
package/lib.test.ts
CHANGED
|
@@ -1,70 +1,500 @@
|
|
|
1
|
-
import { test, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import { mkdirSync, rmSync, existsSync,
|
|
1
|
+
import { test, expect, describe, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, writeFileSync, existsSync, readdirSync, cpSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { mkdtempSync } from "fs";
|
|
5
5
|
import { tmpdir } from "os";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
readLock,
|
|
9
|
+
writeLock,
|
|
10
|
+
addToLock,
|
|
11
|
+
removeFromLock,
|
|
12
|
+
getCatalogSkills,
|
|
13
|
+
getLockEntry,
|
|
14
|
+
catalogDir,
|
|
15
|
+
lockPath,
|
|
16
|
+
findSkillEntries,
|
|
17
|
+
parseRepoArg,
|
|
18
|
+
buildAddArgs,
|
|
19
|
+
parseSkillsListOutput,
|
|
20
|
+
planUpdates,
|
|
21
|
+
groupByRepo,
|
|
22
|
+
type LockFile,
|
|
23
|
+
type LockEntry,
|
|
24
|
+
type TreeEntry,
|
|
25
|
+
} from "./lib.ts";
|
|
8
26
|
|
|
9
27
|
let tmp: string;
|
|
10
|
-
let
|
|
11
|
-
let localDir: string;
|
|
28
|
+
let origHome: string;
|
|
12
29
|
|
|
13
30
|
beforeEach(() => {
|
|
14
31
|
tmp = mkdtempSync(join(tmpdir(), "skl-test-"));
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
mkdirSync(library);
|
|
18
|
-
mkdirSync(localDir);
|
|
32
|
+
origHome = process.env.HOME!;
|
|
33
|
+
process.env.HOME = tmp;
|
|
19
34
|
});
|
|
20
35
|
|
|
21
36
|
afterEach(() => {
|
|
37
|
+
process.env.HOME = origHome;
|
|
22
38
|
rmSync(tmp, { recursive: true, force: true });
|
|
23
39
|
});
|
|
24
40
|
|
|
25
|
-
|
|
26
|
-
mkdirSync(join(localDir, "my-skill"));
|
|
27
|
-
mkdirSync(join(library, "other-skill"));
|
|
41
|
+
// ── Lock file ───────────────────────────────────────────────────────
|
|
28
42
|
|
|
29
|
-
|
|
43
|
+
describe("lock file", () => {
|
|
44
|
+
test("readLock returns empty structure when file doesn't exist", () => {
|
|
45
|
+
const lock = readLock();
|
|
46
|
+
expect(lock).toEqual({ version: 1, skills: {} });
|
|
47
|
+
});
|
|
30
48
|
|
|
31
|
-
|
|
49
|
+
test("writeLock and readLock round-trip", () => {
|
|
50
|
+
const lock: LockFile = {
|
|
51
|
+
version: 1,
|
|
52
|
+
skills: {
|
|
53
|
+
"test-skill": {
|
|
54
|
+
source: "owner/repo",
|
|
55
|
+
sourceUrl: "https://github.com/owner/repo",
|
|
56
|
+
skillPath: "skills/test-skill",
|
|
57
|
+
treeSHA: "abc123",
|
|
58
|
+
addedAt: "2026-03-12T00:00:00.000Z",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
writeLock(lock);
|
|
63
|
+
const read = readLock();
|
|
64
|
+
expect(read).toEqual(lock);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("writeLock creates .skl directory if missing", () => {
|
|
68
|
+
expect(existsSync(join(tmp, ".skl"))).toBe(false);
|
|
69
|
+
writeLock({ version: 1, skills: {} });
|
|
70
|
+
expect(existsSync(join(tmp, ".skl"))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("readLock returns empty on corrupted JSON", () => {
|
|
74
|
+
mkdirSync(join(tmp, ".skl"), { recursive: true });
|
|
75
|
+
writeFileSync(lockPath(), "not json");
|
|
76
|
+
expect(readLock()).toEqual({ version: 1, skills: {} });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("readLock returns empty on wrong version", () => {
|
|
80
|
+
mkdirSync(join(tmp, ".skl"), { recursive: true });
|
|
81
|
+
writeFileSync(lockPath(), JSON.stringify({ version: 99, skills: {} }));
|
|
82
|
+
expect(readLock()).toEqual({ version: 1, skills: {} });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("addToLock adds a new entry", () => {
|
|
86
|
+
writeLock({ version: 1, skills: {} });
|
|
87
|
+
const entry: LockEntry = {
|
|
88
|
+
source: "owner/repo",
|
|
89
|
+
sourceUrl: "https://github.com/owner/repo",
|
|
90
|
+
skillPath: "skills/my-skill",
|
|
91
|
+
treeSHA: "def456",
|
|
92
|
+
addedAt: "2026-03-12T00:00:00.000Z",
|
|
93
|
+
};
|
|
94
|
+
addToLock("my-skill", entry);
|
|
95
|
+
const lock = readLock();
|
|
96
|
+
expect(lock.skills["my-skill"]).toEqual(entry);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("addToLock overwrites existing entry", () => {
|
|
100
|
+
const entry1: LockEntry = {
|
|
101
|
+
source: "a/b", sourceUrl: "https://github.com/a/b",
|
|
102
|
+
skillPath: "skills/x", treeSHA: "old", addedAt: "2026-01-01",
|
|
103
|
+
};
|
|
104
|
+
const entry2: LockEntry = {
|
|
105
|
+
source: "a/b", sourceUrl: "https://github.com/a/b",
|
|
106
|
+
skillPath: "skills/x", treeSHA: "new", addedAt: "2026-01-02",
|
|
107
|
+
};
|
|
108
|
+
addToLock("x", entry1);
|
|
109
|
+
addToLock("x", entry2);
|
|
110
|
+
expect(getLockEntry("x")!.treeSHA).toBe("new");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("removeFromLock removes an entry", () => {
|
|
114
|
+
writeLock({
|
|
115
|
+
version: 1,
|
|
116
|
+
skills: {
|
|
117
|
+
"keep-me": {
|
|
118
|
+
source: "a/b", sourceUrl: "https://github.com/a/b",
|
|
119
|
+
skillPath: "skills/keep-me", treeSHA: "111", addedAt: "2026-03-12",
|
|
120
|
+
},
|
|
121
|
+
"remove-me": {
|
|
122
|
+
source: "a/b", sourceUrl: "https://github.com/a/b",
|
|
123
|
+
skillPath: "skills/remove-me", treeSHA: "222", addedAt: "2026-03-12",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
removeFromLock("remove-me");
|
|
128
|
+
const lock = readLock();
|
|
129
|
+
expect(lock.skills["keep-me"]).toBeDefined();
|
|
130
|
+
expect(lock.skills["remove-me"]).toBeUndefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("removeFromLock is no-op for missing entry", () => {
|
|
134
|
+
writeLock({ version: 1, skills: {} });
|
|
135
|
+
removeFromLock("nonexistent");
|
|
136
|
+
expect(readLock()).toEqual({ version: 1, skills: {} });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("getLockEntry returns entry or null", () => {
|
|
140
|
+
writeLock({
|
|
141
|
+
version: 1,
|
|
142
|
+
skills: {
|
|
143
|
+
"exists": {
|
|
144
|
+
source: "a/b", sourceUrl: "https://github.com/a/b",
|
|
145
|
+
skillPath: "skills/exists", treeSHA: "333", addedAt: "2026-03-12",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
expect(getLockEntry("exists")).not.toBeNull();
|
|
150
|
+
expect(getLockEntry("exists")!.treeSHA).toBe("333");
|
|
151
|
+
expect(getLockEntry("nope")).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── Catalog ─────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
describe("catalog", () => {
|
|
158
|
+
test("getCatalogSkills finds directories with SKILL.md", () => {
|
|
159
|
+
const catalog = catalogDir();
|
|
160
|
+
mkdirSync(catalog, { recursive: true });
|
|
161
|
+
|
|
162
|
+
mkdirSync(join(catalog, "good-skill"));
|
|
163
|
+
writeFileSync(join(catalog, "good-skill", "SKILL.md"), "# Good");
|
|
164
|
+
|
|
165
|
+
mkdirSync(join(catalog, "no-manifest"));
|
|
166
|
+
writeFileSync(join(catalog, "just-a-file"), "nope");
|
|
167
|
+
|
|
168
|
+
mkdirSync(join(catalog, "another-skill"));
|
|
169
|
+
writeFileSync(join(catalog, "another-skill", "SKILL.md"), "# Another");
|
|
170
|
+
|
|
171
|
+
const skills = getCatalogSkills();
|
|
172
|
+
expect(skills).toEqual(["another-skill", "good-skill"]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("getCatalogSkills returns empty array when catalog doesn't exist", () => {
|
|
176
|
+
expect(getCatalogSkills()).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("getCatalogSkills returns sorted list", () => {
|
|
180
|
+
const catalog = catalogDir();
|
|
181
|
+
mkdirSync(catalog, { recursive: true });
|
|
182
|
+
for (const name of ["zebra", "apple", "mango"]) {
|
|
183
|
+
mkdirSync(join(catalog, name));
|
|
184
|
+
writeFileSync(join(catalog, name, "SKILL.md"), "# Skill");
|
|
185
|
+
}
|
|
186
|
+
expect(getCatalogSkills()).toEqual(["apple", "mango", "zebra"]);
|
|
187
|
+
});
|
|
32
188
|
});
|
|
33
189
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
190
|
+
// ── findSkillEntries ────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
describe("findSkillEntries", () => {
|
|
193
|
+
test("finds flat skills (name/SKILL.md)", () => {
|
|
194
|
+
const tree: TreeEntry[] = [
|
|
195
|
+
{ path: "commit", type: "tree", sha: "aaa" },
|
|
196
|
+
{ path: "commit/SKILL.md", type: "blob", sha: "bbb" },
|
|
197
|
+
{ path: "commit/prompt.md", type: "blob", sha: "ccc" },
|
|
198
|
+
{ path: "opentui", type: "tree", sha: "ddd" },
|
|
199
|
+
{ path: "opentui/SKILL.md", type: "blob", sha: "eee" },
|
|
200
|
+
];
|
|
201
|
+
const entries = findSkillEntries(tree);
|
|
202
|
+
expect(entries).toEqual([
|
|
203
|
+
{ prefix: "commit", name: "commit", treeSHA: "aaa" },
|
|
204
|
+
{ prefix: "opentui", name: "opentui", treeSHA: "ddd" },
|
|
205
|
+
]);
|
|
206
|
+
});
|
|
39
207
|
|
|
40
|
-
|
|
208
|
+
test("finds nested skills (dir/name/SKILL.md)", () => {
|
|
209
|
+
const tree: TreeEntry[] = [
|
|
210
|
+
{ path: "skills", type: "tree", sha: "000" },
|
|
211
|
+
{ path: "skills/commit", type: "tree", sha: "aaa" },
|
|
212
|
+
{ path: "skills/commit/SKILL.md", type: "blob", sha: "bbb" },
|
|
213
|
+
{ path: "skills/opentui", type: "tree", sha: "ddd" },
|
|
214
|
+
{ path: "skills/opentui/SKILL.md", type: "blob", sha: "eee" },
|
|
215
|
+
];
|
|
216
|
+
const entries = findSkillEntries(tree);
|
|
217
|
+
expect(entries).toEqual([
|
|
218
|
+
{ prefix: "skills/commit", name: "commit", treeSHA: "aaa" },
|
|
219
|
+
{ prefix: "skills/opentui", name: "opentui", treeSHA: "ddd" },
|
|
220
|
+
]);
|
|
221
|
+
});
|
|
41
222
|
|
|
42
|
-
|
|
43
|
-
|
|
223
|
+
test("returns empty for tree with no SKILL.md", () => {
|
|
224
|
+
const tree: TreeEntry[] = [
|
|
225
|
+
{ path: "README.md", type: "blob", sha: "aaa" },
|
|
226
|
+
{ path: "src", type: "tree", sha: "bbb" },
|
|
227
|
+
];
|
|
228
|
+
expect(findSkillEntries(tree)).toEqual([]);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("sorts by name", () => {
|
|
232
|
+
const tree: TreeEntry[] = [
|
|
233
|
+
{ path: "zeta", type: "tree", sha: "z" },
|
|
234
|
+
{ path: "zeta/SKILL.md", type: "blob", sha: "z1" },
|
|
235
|
+
{ path: "alpha", type: "tree", sha: "a" },
|
|
236
|
+
{ path: "alpha/SKILL.md", type: "blob", sha: "a1" },
|
|
237
|
+
];
|
|
238
|
+
const entries = findSkillEntries(tree);
|
|
239
|
+
expect(entries[0]!.name).toBe("alpha");
|
|
240
|
+
expect(entries[1]!.name).toBe("zeta");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("handles missing tree entry for directory (empty SHA)", () => {
|
|
244
|
+
const tree: TreeEntry[] = [
|
|
245
|
+
{ path: "orphan/SKILL.md", type: "blob", sha: "ooo" },
|
|
246
|
+
// No tree entry for "orphan"
|
|
247
|
+
];
|
|
248
|
+
const entries = findSkillEntries(tree);
|
|
249
|
+
expect(entries).toEqual([
|
|
250
|
+
{ prefix: "orphan", name: "orphan", treeSHA: "" },
|
|
251
|
+
]);
|
|
252
|
+
});
|
|
44
253
|
});
|
|
45
254
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
255
|
+
// ── parseRepoArg ────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
describe("parseRepoArg", () => {
|
|
258
|
+
test("accepts owner/repo format", () => {
|
|
259
|
+
expect(parseRepoArg("vercel-labs/agent-skills")).toBe("vercel-labs/agent-skills");
|
|
260
|
+
});
|
|
50
261
|
|
|
51
|
-
|
|
52
|
-
|
|
262
|
+
test("extracts from GitHub URL", () => {
|
|
263
|
+
expect(parseRepoArg("https://github.com/vercel-labs/agent-skills")).toBe("vercel-labs/agent-skills");
|
|
264
|
+
});
|
|
53
265
|
|
|
54
|
-
|
|
55
|
-
|
|
266
|
+
test("strips .git suffix from URL", () => {
|
|
267
|
+
expect(parseRepoArg("https://github.com/owner/repo.git")).toBe("owner/repo");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("extracts from URL with trailing path", () => {
|
|
271
|
+
expect(parseRepoArg("https://github.com/owner/repo/tree/main")).toBe("owner/repo");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("returns null for empty string", () => {
|
|
275
|
+
expect(parseRepoArg("")).toBeNull();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test("returns null for invalid format", () => {
|
|
279
|
+
expect(parseRepoArg("just-a-name")).toBeNull();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("returns null for triple-segment path", () => {
|
|
283
|
+
expect(parseRepoArg("a/b/c")).toBeNull();
|
|
284
|
+
});
|
|
56
285
|
});
|
|
57
286
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
287
|
+
// ── buildAddArgs ────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
describe("buildAddArgs", () => {
|
|
290
|
+
test("uses lock entry sourceUrl for remote skill", () => {
|
|
291
|
+
const entry: LockEntry = {
|
|
292
|
+
source: "owner/repo",
|
|
293
|
+
sourceUrl: "https://github.com/owner/repo",
|
|
294
|
+
skillPath: "skills/myskill",
|
|
295
|
+
treeSHA: "abc",
|
|
296
|
+
addedAt: "2026-01-01",
|
|
297
|
+
};
|
|
298
|
+
addToLock("myskill", entry);
|
|
299
|
+
|
|
300
|
+
const args = buildAddArgs("/catalog", "myskill", false);
|
|
301
|
+
expect(args).toEqual(["add", "https://github.com/owner/repo", "--skill", "myskill", "-y"]);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("adds -g flag for global remote skill", () => {
|
|
305
|
+
const entry: LockEntry = {
|
|
306
|
+
source: "owner/repo",
|
|
307
|
+
sourceUrl: "https://github.com/owner/repo",
|
|
308
|
+
skillPath: "skills/myskill",
|
|
309
|
+
treeSHA: "abc",
|
|
310
|
+
addedAt: "2026-01-01",
|
|
311
|
+
};
|
|
312
|
+
addToLock("myskill", entry);
|
|
313
|
+
|
|
314
|
+
const args = buildAddArgs("/catalog", "myskill", true);
|
|
315
|
+
expect(args).toContain("-g");
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("uses catalog path for hand-authored skill", () => {
|
|
319
|
+
// No lock entry for this skill
|
|
320
|
+
const args = buildAddArgs("/my/catalog", "custom-skill", false);
|
|
321
|
+
expect(args).toEqual(["add", "/my/catalog/custom-skill", "-y"]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test("adds -g flag for global hand-authored skill", () => {
|
|
325
|
+
const args = buildAddArgs("/my/catalog", "custom-skill", true);
|
|
326
|
+
expect(args).toEqual(["add", "/my/catalog/custom-skill", "-y", "-g"]);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ── parseSkillsListOutput ───────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
describe("parseSkillsListOutput", () => {
|
|
333
|
+
test("parses valid JSON array", () => {
|
|
334
|
+
const json = JSON.stringify([
|
|
335
|
+
{ name: "opentui", path: "/some/path", scope: "project", agents: ["Claude Code"] },
|
|
336
|
+
{ name: "commit", path: "/other/path", scope: "global", agents: ["Cursor"] },
|
|
337
|
+
]);
|
|
338
|
+
expect(parseSkillsListOutput(json)).toEqual(["opentui", "commit"]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("returns empty for empty array", () => {
|
|
342
|
+
expect(parseSkillsListOutput("[]")).toEqual([]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("returns empty for invalid JSON", () => {
|
|
346
|
+
expect(parseSkillsListOutput("not json")).toEqual([]);
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("returns empty for non-array JSON", () => {
|
|
350
|
+
expect(parseSkillsListOutput('{"foo": "bar"}')).toEqual([]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("returns empty for empty string", () => {
|
|
354
|
+
expect(parseSkillsListOutput("")).toEqual([]);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// ── planUpdates ─────────────────────────────────────────────────────
|
|
359
|
+
|
|
360
|
+
describe("planUpdates", () => {
|
|
361
|
+
const remoteTree: TreeEntry[] = [
|
|
362
|
+
{ path: "skills", type: "tree", sha: "root" },
|
|
363
|
+
{ path: "skills/alpha", type: "tree", sha: "new-alpha-sha" },
|
|
364
|
+
{ path: "skills/alpha/SKILL.md", type: "blob", sha: "f1" },
|
|
365
|
+
{ path: "skills/beta", type: "tree", sha: "same-beta-sha" },
|
|
366
|
+
{ path: "skills/beta/SKILL.md", type: "blob", sha: "f2" },
|
|
367
|
+
];
|
|
368
|
+
|
|
369
|
+
test("detects skills that need updating (SHA changed)", () => {
|
|
370
|
+
const skills = [
|
|
371
|
+
{ name: "alpha", skillPath: "skills/alpha", treeSHA: "old-alpha-sha" },
|
|
372
|
+
];
|
|
373
|
+
const plan = planUpdates(skills, remoteTree);
|
|
374
|
+
expect(plan.updated).toEqual([{
|
|
375
|
+
name: "alpha",
|
|
376
|
+
skillPath: "skills/alpha",
|
|
377
|
+
oldSHA: "old-alpha-sha",
|
|
378
|
+
newSHA: "new-alpha-sha",
|
|
379
|
+
}]);
|
|
380
|
+
expect(plan.upToDate).toEqual([]);
|
|
381
|
+
expect(plan.notFound).toEqual([]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("detects skills that are up to date (SHA matches)", () => {
|
|
385
|
+
const skills = [
|
|
386
|
+
{ name: "beta", skillPath: "skills/beta", treeSHA: "same-beta-sha" },
|
|
387
|
+
];
|
|
388
|
+
const plan = planUpdates(skills, remoteTree);
|
|
389
|
+
expect(plan.updated).toEqual([]);
|
|
390
|
+
expect(plan.upToDate).toEqual(["beta"]);
|
|
391
|
+
expect(plan.notFound).toEqual([]);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
test("detects skills not found in remote tree", () => {
|
|
395
|
+
const skills = [
|
|
396
|
+
{ name: "gamma", skillPath: "skills/gamma", treeSHA: "whatever" },
|
|
397
|
+
];
|
|
398
|
+
const plan = planUpdates(skills, remoteTree);
|
|
399
|
+
expect(plan.updated).toEqual([]);
|
|
400
|
+
expect(plan.upToDate).toEqual([]);
|
|
401
|
+
expect(plan.notFound).toEqual(["gamma"]);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("handles mix of updated, up-to-date, and missing", () => {
|
|
405
|
+
const skills = [
|
|
406
|
+
{ name: "alpha", skillPath: "skills/alpha", treeSHA: "old-alpha-sha" },
|
|
407
|
+
{ name: "beta", skillPath: "skills/beta", treeSHA: "same-beta-sha" },
|
|
408
|
+
{ name: "gamma", skillPath: "skills/gamma", treeSHA: "missing" },
|
|
409
|
+
];
|
|
410
|
+
const plan = planUpdates(skills, remoteTree);
|
|
411
|
+
expect(plan.updated.length).toBe(1);
|
|
412
|
+
expect(plan.upToDate.length).toBe(1);
|
|
413
|
+
expect(plan.notFound.length).toBe(1);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("empty skills list returns empty plan", () => {
|
|
417
|
+
const plan = planUpdates([], remoteTree);
|
|
418
|
+
expect(plan.updated).toEqual([]);
|
|
419
|
+
expect(plan.upToDate).toEqual([]);
|
|
420
|
+
expect(plan.notFound).toEqual([]);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ── groupByRepo ─────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
describe("groupByRepo", () => {
|
|
427
|
+
test("groups skills by source repo", () => {
|
|
428
|
+
const lock: LockFile = {
|
|
429
|
+
version: 1,
|
|
430
|
+
skills: {
|
|
431
|
+
"skill-a": {
|
|
432
|
+
source: "owner/repo1", sourceUrl: "https://github.com/owner/repo1",
|
|
433
|
+
skillPath: "skills/skill-a", treeSHA: "aaa", addedAt: "2026-01-01",
|
|
434
|
+
},
|
|
435
|
+
"skill-b": {
|
|
436
|
+
source: "owner/repo1", sourceUrl: "https://github.com/owner/repo1",
|
|
437
|
+
skillPath: "skills/skill-b", treeSHA: "bbb", addedAt: "2026-01-01",
|
|
438
|
+
},
|
|
439
|
+
"skill-c": {
|
|
440
|
+
source: "other/repo2", sourceUrl: "https://github.com/other/repo2",
|
|
441
|
+
skillPath: "skills/skill-c", treeSHA: "ccc", addedAt: "2026-01-01",
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
const byRepo = groupByRepo(lock);
|
|
446
|
+
expect(byRepo.size).toBe(2);
|
|
447
|
+
expect(byRepo.get("owner/repo1")!.length).toBe(2);
|
|
448
|
+
expect(byRepo.get("other/repo2")!.length).toBe(1);
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test("skips skills without source or treeSHA", () => {
|
|
452
|
+
const lock: LockFile = {
|
|
453
|
+
version: 1,
|
|
454
|
+
skills: {
|
|
455
|
+
"has-source": {
|
|
456
|
+
source: "a/b", sourceUrl: "https://github.com/a/b",
|
|
457
|
+
skillPath: "skills/x", treeSHA: "aaa", addedAt: "2026-01-01",
|
|
458
|
+
},
|
|
459
|
+
"no-source": {
|
|
460
|
+
source: "", sourceUrl: "",
|
|
461
|
+
skillPath: "skills/y", treeSHA: "bbb", addedAt: "2026-01-01",
|
|
462
|
+
},
|
|
463
|
+
"no-sha": {
|
|
464
|
+
source: "a/b", sourceUrl: "https://github.com/a/b",
|
|
465
|
+
skillPath: "skills/z", treeSHA: "", addedAt: "2026-01-01",
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
const byRepo = groupByRepo(lock);
|
|
470
|
+
expect(byRepo.size).toBe(1);
|
|
471
|
+
expect(byRepo.get("a/b")!.length).toBe(1);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("empty lock file returns empty map", () => {
|
|
475
|
+
const byRepo = groupByRepo({ version: 1, skills: {} });
|
|
476
|
+
expect(byRepo.size).toBe(0);
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// ── Migration (integration test) ────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
describe("migration", () => {
|
|
483
|
+
test("copies skills from old library to catalog", () => {
|
|
484
|
+
const oldLib = join(tmp, "dotfiles/skills");
|
|
485
|
+
mkdirSync(join(oldLib, "skill-one"), { recursive: true });
|
|
486
|
+
writeFileSync(join(oldLib, "skill-one", "SKILL.md"), "# One");
|
|
487
|
+
mkdirSync(join(oldLib, "skill-two"), { recursive: true });
|
|
488
|
+
writeFileSync(join(oldLib, "skill-two", "SKILL.md"), "# Two");
|
|
489
|
+
|
|
490
|
+
const catalog = catalogDir();
|
|
491
|
+
mkdirSync(catalog, { recursive: true });
|
|
66
492
|
|
|
67
|
-
|
|
493
|
+
// Simulate migration
|
|
494
|
+
for (const name of readdirSync(oldLib)) {
|
|
495
|
+
cpSync(join(oldLib, name), join(catalog, name), { recursive: true });
|
|
496
|
+
}
|
|
68
497
|
|
|
69
|
-
|
|
498
|
+
expect(getCatalogSkills()).toEqual(["skill-one", "skill-two"]);
|
|
499
|
+
});
|
|
70
500
|
});
|