@extuitive/skill 0.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.
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Getting skill directories into a host, and taking them back out.
3
+ *
4
+ * Two shapes of host, kept apart by `skillDelivery`. Most scan a directory, so installing is
5
+ * copying. Claude Desktop takes an upload instead — its Chat-tab skills are account-bound
6
+ * rather than files on this machine — so installing there means building a `.zip` and
7
+ * handing over the path.
8
+ *
9
+ * The only genuinely delicate part is overwriting. This writes into directories people also
10
+ * author skills in by hand, so a reinstall that silently replaced an edited SKILL.md would
11
+ * destroy work with no way back. Every overwrite moves the existing copy aside to a
12
+ * timestamped sibling first, and the report says where it went.
13
+ */
14
+ import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
15
+ import { existsSync } from "node:fs";
16
+ import { dirname, join, relative } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import { SKILL_NAMES } from "./constants.mjs";
20
+ import { previousSkillsRoots, resolveSkillsRoot, stateRoot } from "./hosts.mjs";
21
+ import { createZip, zipHoldsEntries } from "./zip.mjs";
22
+
23
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
24
+
25
+ /** The `skills/` directory inside this package, wherever npx unpacked it. */
26
+ export function bundledSkillsDir() {
27
+ return join(MODULE_DIR, "..", "skills");
28
+ }
29
+
30
+ /**
31
+ * Backups live outside every skills root, deliberately.
32
+ *
33
+ * Both hosts scan their skills directory for `SKILL.md`, and Codex scans it recursively. A
34
+ * backup kept as a sibling would therefore be discovered as a second, older copy of the skill
35
+ * it was meant to preserve — the exact duplicate-skill problem that makes one shadow the
36
+ * other. Keeping them here means a backup is recoverable without ever being loadable.
37
+ */
38
+ export function backupsRoot() {
39
+ return join(stateRoot(), "backups");
40
+ }
41
+
42
+ function timestamp() {
43
+ return new Date().toISOString().replace(/[:.]/g, "-");
44
+ }
45
+
46
+ /** Every file under a directory, relative and sorted, so two trees can be compared. */
47
+ async function listFiles(root) {
48
+ const found = [];
49
+ const walk = async (dir) => {
50
+ const entries = await readdir(dir, { withFileTypes: true });
51
+ for (const entry of entries) {
52
+ const full = join(dir, entry.name);
53
+ if (entry.isDirectory() === true) {
54
+ await walk(full);
55
+ } else if (entry.isFile() === true) {
56
+ found.push(relative(root, full));
57
+ }
58
+ }
59
+ };
60
+ await walk(root);
61
+ return found.sort();
62
+ }
63
+
64
+ /**
65
+ * Whether an installed skill is already exactly what we would write.
66
+ *
67
+ * Reinstalling is common — it is how someone upgrades — and backing up an identical copy
68
+ * every time would leave a pile of directories that differ from each other in nothing. Skill
69
+ * files are a few kilobytes each, so comparing contents outright is cheaper than the cleanup
70
+ * it avoids.
71
+ */
72
+ async function treesMatch(left, right) {
73
+ let leftFiles;
74
+ let rightFiles;
75
+ try {
76
+ leftFiles = await listFiles(left);
77
+ rightFiles = await listFiles(right);
78
+ } catch {
79
+ return false;
80
+ }
81
+
82
+ if (leftFiles.join("\n") !== rightFiles.join("\n")) {
83
+ return false;
84
+ }
85
+
86
+ for (const file of leftFiles) {
87
+ const [a, b] = await Promise.all([
88
+ readFile(join(left, file)),
89
+ readFile(join(right, file)),
90
+ ]);
91
+ if (a.equals(b) === false) {
92
+ return false;
93
+ }
94
+ }
95
+ return true;
96
+ }
97
+
98
+ /**
99
+ * The `name` a host will use for this skill.
100
+ *
101
+ * Read rather than assumed because a mismatch between frontmatter `name` and directory name
102
+ * is the failure that makes a skill vanish without an error on both hosts. Install checks it
103
+ * so the problem surfaces here instead of as "the skill isn't there" days later.
104
+ */
105
+ async function readSkillName(skillDir) {
106
+ const skillFile = join(skillDir, "SKILL.md");
107
+ let contents;
108
+ try {
109
+ contents = await readFile(skillFile, "utf8");
110
+ } catch {
111
+ return null;
112
+ }
113
+
114
+ const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(contents);
115
+ if (frontmatter === null) {
116
+ return null;
117
+ }
118
+ const nameLine = /^name:\s*(.+)$/m.exec(frontmatter[1]);
119
+ if (nameLine === null) {
120
+ return null;
121
+ }
122
+ return nameLine[1].trim().replace(/^["']|["']$/g, "");
123
+ }
124
+
125
+ /** Every bundled skill, verified to be loadable before anything is copied anywhere. */
126
+ export async function readBundledSkills() {
127
+ const root = bundledSkillsDir();
128
+ const entries = await readdir(root, { withFileTypes: true });
129
+ const skills = [];
130
+
131
+ for (const entry of entries) {
132
+ if (entry.isDirectory() === false) {
133
+ continue;
134
+ }
135
+ const source = join(root, entry.name);
136
+ const declaredName = await readSkillName(source);
137
+
138
+ if (declaredName === null) {
139
+ throw new Error(
140
+ `Bundled skill "${entry.name}" has no readable name in its SKILL.md frontmatter.`,
141
+ );
142
+ }
143
+ if (declaredName !== entry.name) {
144
+ throw new Error(
145
+ `Bundled skill "${entry.name}" declares name "${declaredName}". Both hosts key a skill on its directory name, so these must match.`,
146
+ );
147
+ }
148
+ skills.push({ name: entry.name, source });
149
+ }
150
+
151
+ const found = skills.map((skill) => skill.name).sort();
152
+ const expected = [...SKILL_NAMES].sort();
153
+ if (found.join(",") !== expected.join(",")) {
154
+ throw new Error(
155
+ `Bundled skills are ${found.join(", ")} but expected ${expected.join(", ")}.`,
156
+ );
157
+ }
158
+
159
+ return skills;
160
+ }
161
+
162
+ /**
163
+ * Copy every skill into one host, and move any copy out of where we used to put it.
164
+ *
165
+ * Idempotent in the sense that matters: running it twice leaves the same result, and the
166
+ * second run reports `replaced` rather than pretending nothing was there.
167
+ *
168
+ * Migration is the second half and runs only after the first has succeeded. The order is the
169
+ * safety: the new copy is written and verified against the bundled source *before* the old
170
+ * one is touched, so a failure part-way leaves two working copies rather than none. An old
171
+ * copy that differs from what we ship is moved to the backups directory, not deleted — it
172
+ * may have been edited by hand — and one that matches is simply removed. Codex scans both
173
+ * roots, so leaving the old copy would show the skill twice in the picker.
174
+ */
175
+ export async function installSkills(host, options = {}) {
176
+ const { scope = "user", dir = null, cwd = process.cwd(), dryRun = false } = options;
177
+ const destinationRoot = resolveSkillsRoot(host, { scope, dir, cwd });
178
+ const previousRoots = previousSkillsRoots(host, { scope, dir });
179
+ const skills = await readBundledSkills();
180
+ const results = [];
181
+
182
+ if (dryRun === false) {
183
+ await mkdir(destinationRoot, { recursive: true });
184
+ }
185
+
186
+ const runTimestamp = timestamp();
187
+
188
+ for (const skill of skills) {
189
+ const destination = join(destinationRoot, skill.name);
190
+ const existed = existsSync(destination);
191
+ let backup = null;
192
+ let action;
193
+
194
+ if (existed === true && (await treesMatch(destination, skill.source)) === true) {
195
+ action = "unchanged";
196
+ } else {
197
+ if (existed === true && dryRun === false) {
198
+ backup = join(backupsRoot(), runTimestamp, skill.name);
199
+ await mkdir(dirname(backup), { recursive: true });
200
+ await rename(destination, backup);
201
+ }
202
+ if (dryRun === false) {
203
+ await cp(skill.source, destination, { recursive: true });
204
+ }
205
+ action = existed === true ? "replaced" : "created";
206
+ }
207
+
208
+ // Verified against the source rather than trusted: `cp` reporting success and the tree
209
+ // being what we meant to write are different facts, and the old copy is only removed on
210
+ // the strength of the second.
211
+ const verified = dryRun === true || (await treesMatch(destination, skill.source)) === true;
212
+ const migrated = [];
213
+
214
+ for (const root of previousRoots) {
215
+ const previous = join(root, skill.name);
216
+ if (existsSync(join(previous, "SKILL.md")) === false) {
217
+ continue;
218
+ }
219
+ if (verified === false) {
220
+ migrated.push({ from: previous, action: "left", backup: null });
221
+ continue;
222
+ }
223
+
224
+ const identical = await treesMatch(previous, skill.source);
225
+ let previousBackup = null;
226
+
227
+ if (dryRun === false) {
228
+ if (identical === true) {
229
+ await rm(previous, { recursive: true, force: true });
230
+ } else {
231
+ previousBackup = join(backupsRoot(), runTimestamp, "previous-location", skill.name);
232
+ await mkdir(dirname(previousBackup), { recursive: true });
233
+ await rename(previous, previousBackup);
234
+ }
235
+ }
236
+ migrated.push({
237
+ from: previous,
238
+ action: identical === true ? "removed" : "backed_up",
239
+ backup: previousBackup,
240
+ });
241
+ }
242
+
243
+ results.push({
244
+ name: skill.name,
245
+ destination,
246
+ skillFile: join(destination, "SKILL.md"),
247
+ action,
248
+ backup,
249
+ verified,
250
+ migrated,
251
+ });
252
+ }
253
+
254
+ return { host: host.id, destinationRoot, skills: results, dryRun };
255
+ }
256
+
257
+ /**
258
+ * A fixed timestamp for every file in a bundle, so that rebuilding unchanged files produces
259
+ * an unchanged archive.
260
+ *
261
+ * Not what the freshness check relies on — that is `zipHoldsEntries`, which reads the CRCs
262
+ * the archive already stores and so is immune to both the clock and the zlib underneath.
263
+ * This is here so the artifact itself is reproducible: two builds of the same skill are the
264
+ * same file, which is worth having for anyone diffing or caching one. 1980 is the ZIP epoch,
265
+ * the one date the format stores exactly.
266
+ */
267
+ const BUNDLE_EPOCH = new Date(Date.UTC(1980, 0, 1));
268
+
269
+ /** Every file under a skill, as ZIP entries rooted at a folder named for the skill. */
270
+ async function bundleEntries(source, name) {
271
+ const files = await listFiles(source);
272
+ const directories = new Set();
273
+
274
+ for (const file of files) {
275
+ const parts = file.split(/[/\\]/).slice(0, -1);
276
+ for (let depth = 1; depth <= parts.length; depth += 1) {
277
+ directories.add(`${name}/${parts.slice(0, depth).join("/")}`);
278
+ }
279
+ }
280
+
281
+ const entries = [{ path: name, directory: true }];
282
+ for (const directory of [...directories].sort()) {
283
+ entries.push({ path: directory, directory: true });
284
+ }
285
+ for (const file of files) {
286
+ entries.push({
287
+ // Always forward slashes. The archive is read on whatever machine the account is
288
+ // signed in on, not this one, and a backslash from a Windows build unpacks as a single
289
+ // file with slashes in its name rather than as a folder.
290
+ path: `${name}/${file.split(/[/\\]/).join("/")}`,
291
+ data: await readFile(join(source, file)),
292
+ });
293
+ }
294
+ return entries;
295
+ }
296
+
297
+ /**
298
+ * Build the `.zip` a host asks people to upload.
299
+ *
300
+ * Reports `unchanged` when the archive it would write is byte-for-byte the one already
301
+ * there, for the same reason the copying path does: rebuilding is how someone upgrades, and
302
+ * an install that claims to have produced something new every time gives no way to tell a
303
+ * real upgrade from a no-op.
304
+ */
305
+ export async function buildSkillBundles(host, options = {}) {
306
+ const { dir = null, cwd = process.cwd(), dryRun = false } = options;
307
+ const destinationRoot = resolveSkillsRoot(host, { dir, cwd });
308
+ const skills = await readBundledSkills();
309
+ const results = [];
310
+
311
+ if (dryRun === false) {
312
+ await mkdir(destinationRoot, { recursive: true });
313
+ }
314
+
315
+ const runTimestamp = timestamp();
316
+ // Our own bundle directory holds build output and nothing else, so a file being replaced
317
+ // there is a bundle we wrote, regenerable from this same package. Anywhere else is a
318
+ // directory the person named, and a file we find there is not ours to overwrite without
319
+ // keeping a copy. Backing up either way would mean a new backup on every skill change,
320
+ // each one a stale copy of an artifact nobody wants back.
321
+ const ours = destinationRoot === host.userSkillsDir;
322
+
323
+ for (const skill of skills) {
324
+ const destination = join(destinationRoot, `${skill.name}.zip`);
325
+ const entries = await bundleEntries(skill.source, skill.name);
326
+
327
+ const existed = existsSync(destination);
328
+ let backup = null;
329
+
330
+ if (existed === true) {
331
+ const current = await readFile(destination).catch(() => null);
332
+ if (current !== null && zipHoldsEntries(current, entries) === true) {
333
+ results.push({ name: skill.name, destination, action: "unchanged", backup: null });
334
+ continue;
335
+ }
336
+ if (ours === false && dryRun === false) {
337
+ backup = join(backupsRoot(), runTimestamp, `${skill.name}.zip`);
338
+ await mkdir(dirname(backup), { recursive: true });
339
+ await rename(destination, backup);
340
+ }
341
+ }
342
+
343
+ const archive = createZip(entries, { modifiedAt: BUNDLE_EPOCH });
344
+ if (dryRun === false) {
345
+ await writeFile(destination, archive);
346
+ }
347
+
348
+ results.push({
349
+ name: skill.name,
350
+ destination,
351
+ action: existed === true ? "replaced" : "created",
352
+ backup,
353
+ bytes: archive.length,
354
+ });
355
+ }
356
+
357
+ return { host: host.id, destinationRoot, skills: results, dryRun };
358
+ }
359
+
360
+ /**
361
+ * Delete the bundles, which is the only part of a bundle install that is ours to undo.
362
+ *
363
+ * The uploaded copy lives in the person's Anthropic account and comes off through the same
364
+ * panel it went in by. Saying so is the caller's job; all this can do is stop leaving a
365
+ * stale archive around for someone to upload months later.
366
+ */
367
+ export async function removeSkillBundles(host, options = {}) {
368
+ const { dir = null, cwd = process.cwd(), dryRun = false } = options;
369
+ const destinationRoot = resolveSkillsRoot(host, { dir, cwd });
370
+ const results = [];
371
+
372
+ for (const name of SKILL_NAMES) {
373
+ const destination = join(destinationRoot, `${name}.zip`);
374
+ if (existsSync(destination) === false) {
375
+ results.push({ name, destination, action: "absent" });
376
+ continue;
377
+ }
378
+ if (dryRun === false) {
379
+ await rm(destination, { force: true });
380
+ }
381
+ results.push({ name, destination, action: "removed" });
382
+ }
383
+
384
+ return { host: host.id, destinationRoot, skills: results, dryRun };
385
+ }
386
+
387
+ /**
388
+ * Whether a bundle is built and current, which is as much as can be known from here.
389
+ *
390
+ * Deliberately not called "installed". Whether the archive was ever uploaded, and whether
391
+ * the account still has it, are facts on the other side of a browser session — so this
392
+ * reports the artifact and leaves the rest to be asked of the app.
393
+ */
394
+ export async function inspectSkillBundles(host, options = {}) {
395
+ const { dir = null, cwd = process.cwd() } = options;
396
+ const destinationRoot = resolveSkillsRoot(host, { dir, cwd });
397
+ const skills = await readBundledSkills();
398
+ const found = [];
399
+
400
+ for (const skill of skills) {
401
+ const destination = join(destinationRoot, `${skill.name}.zip`);
402
+ const current = existsSync(destination) === true ? await readFile(destination).catch(() => null) : null;
403
+
404
+ if (current === null) {
405
+ found.push({ name: skill.name, present: false, current: false, destination });
406
+ continue;
407
+ }
408
+
409
+ found.push({
410
+ name: skill.name,
411
+ present: true,
412
+ current: zipHoldsEntries(current, await bundleEntries(skill.source, skill.name)),
413
+ destination,
414
+ });
415
+ }
416
+
417
+ return { destinationRoot, skills: found };
418
+ }
419
+
420
+ /**
421
+ * Remove the skills this package installed.
422
+ *
423
+ * Backups are left alone on purpose. They exist because a previous run found something it
424
+ * did not put there, and deleting them during an uninstall would throw away the only copy of
425
+ * whatever that was.
426
+ */
427
+ export async function uninstallSkills(host, options = {}) {
428
+ const { scope = "user", dir = null, cwd = process.cwd(), dryRun = false } = options;
429
+ const destinationRoot = resolveSkillsRoot(host, { scope, dir, cwd });
430
+ const results = [];
431
+
432
+ for (const name of SKILL_NAMES) {
433
+ const destination = join(destinationRoot, name);
434
+ if (existsSync(destination) === false) {
435
+ results.push({ name, destination, action: "absent" });
436
+ continue;
437
+ }
438
+ if (dryRun === false) {
439
+ await rm(destination, { recursive: true, force: true });
440
+ }
441
+ results.push({ name, destination, action: "removed" });
442
+ }
443
+
444
+ // A copy left in the previous location would survive the removal above and keep loading
445
+ // — the skill would appear to come back. Only directories matching our own skill names
446
+ // are touched; anything else in there belongs to someone else.
447
+ const previous = await findPreviousCopies(host, { scope, dir });
448
+ for (const copy of previous) {
449
+ if (dryRun === false) {
450
+ await rm(copy.path, { recursive: true, force: true });
451
+ }
452
+ results.push({ name: copy.name, destination: copy.path, action: "removed", previousLocation: true });
453
+ }
454
+
455
+ return { host: host.id, destinationRoot, skills: results, dryRun };
456
+ }
457
+
458
+ /** Which of our skills are present in a host's skills root, for `doctor`. */
459
+ export async function inspectInstalledSkills(host, options = {}) {
460
+ const { scope = "user", dir = null, cwd = process.cwd() } = options;
461
+ const destinationRoot = resolveSkillsRoot(host, { scope, dir, cwd });
462
+ const found = [];
463
+
464
+ for (const name of SKILL_NAMES) {
465
+ const destination = join(destinationRoot, name);
466
+ const skillFile = join(destination, "SKILL.md");
467
+
468
+ if (existsSync(skillFile) === false) {
469
+ found.push({ name, present: false, destination, skillFile, nameMatches: false });
470
+ continue;
471
+ }
472
+
473
+ const declaredName = await readSkillName(destination);
474
+ found.push({
475
+ name,
476
+ present: true,
477
+ destination,
478
+ skillFile,
479
+ nameMatches: declaredName === name,
480
+ declaredName,
481
+ });
482
+ }
483
+
484
+ return {
485
+ destinationRoot,
486
+ skills: found,
487
+ previous: await findPreviousCopies(host, { scope, dir }),
488
+ };
489
+ }
490
+
491
+ /**
492
+ * Backup directories sitting inside a skills root, which earlier versions of this installer
493
+ * created as siblings of the skill they replaced.
494
+ *
495
+ * They are reported rather than deleted: the point of a backup is that someone may still
496
+ * want it. But left in place they are scanned like any other skill, so `doctor` needs to say
497
+ * so and point at the safe location.
498
+ */
499
+ export async function findShadowingBackups(host, options = {}) {
500
+ const { scope = "user", dir = null, cwd = process.cwd() } = options;
501
+ const root = resolveSkillsRoot(host, { scope, dir, cwd });
502
+
503
+ let entries;
504
+ try {
505
+ entries = await readdir(root, { withFileTypes: true });
506
+ } catch {
507
+ return [];
508
+ }
509
+
510
+ return entries
511
+ .filter(
512
+ (entry) =>
513
+ entry.isDirectory() === true &&
514
+ /\.backup-/.test(entry.name) === true &&
515
+ existsSync(join(root, entry.name, "SKILL.md")) === true,
516
+ )
517
+ .map((entry) => join(root, entry.name));
518
+ }
519
+
520
+ /**
521
+ * Our skills sitting where an earlier version of this installer put them.
522
+ *
523
+ * On Codex that is `~/.agents/skills`, which Codex still scans alongside the current default,
524
+ * so a copy there loads fine on its own — and shows up as a duplicate the moment a current
525
+ * install exists too. Install migrates these; doctor names them; uninstall removes them.
526
+ */
527
+ export async function findPreviousCopies(host, options = {}) {
528
+ const { scope = "user", dir = null } = options;
529
+ const found = [];
530
+
531
+ for (const root of previousSkillsRoots(host, { scope, dir })) {
532
+ for (const name of SKILL_NAMES) {
533
+ const candidate = join(root, name);
534
+ if (existsSync(join(candidate, "SKILL.md")) === true) {
535
+ const info = await stat(candidate);
536
+ found.push({ name, path: candidate, root, modifiedAt: info.mtime.toISOString() });
537
+ }
538
+ }
539
+ }
540
+ return found;
541
+ }