skillcellar 0.6.1 → 0.8.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/src/restore.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { lstat, mkdir, stat, writeFile } from "node:fs/promises";
2
+ import { lstat, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { dirname, isAbsolute, join, relative } from "node:path";
4
4
  import { ensureMirror, git, gitBuffer, parseBackupRepo, refusePublic } from "./backup.mjs";
5
5
  import { sessionFolderName } from "./places.mjs";
@@ -16,6 +16,9 @@ const BYTES_MAX = 256 * 1024 * 1024;
16
16
  // The install commands a copy may hand over, in the shapes the site's plan gives them.
17
17
  const NPX_SKILLS_RE = /^npx skills add https:\/\/github\.com\/[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}(?: --skill [A-Za-z0-9][A-Za-z0-9._@+-]{0,127})?$/;
18
18
  const PLUGIN_RE = /^\/plugin (?:marketplace add [A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}, then \/plugin install |marketplace add https:\/\/[A-Za-z0-9._~:/%+-]+, then \/plugin install |install )[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
19
+ // A skill's name as its SKILL.md gives it, which `npx skills add --skill` matches on; printed in a command, so only these characters.
20
+ const SKILL_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:@+-]{0,127}$/;
21
+ const ANTIVIRUS = "an antivirus may have removed it (on Windows, see Windows Security, Protection history)";
19
22
 
20
23
  /** A path that stays inside its base folder, or null. */
21
24
  function inside(base, ...segments) {
@@ -24,6 +27,8 @@ function inside(base, ...segments) {
24
27
  return from && !from.startsWith("..") && !isAbsolute(from) ? path : null;
25
28
  }
26
29
 
30
+ const isFile = async (path) => (await stat(path).catch(() => null))?.isFile() === true;
31
+
27
32
  /**
28
33
  * The backup's files at one commit, by path: the mode and the blob of each entry. Read from the
29
34
  * mirror's objects, never a checkout, so a link in the repository is listed and never followed.
@@ -82,6 +87,43 @@ function placeFolder(found, task) {
82
87
  return `${found.folder}/${place.folder}`;
83
88
  }
84
89
 
90
+ /**
91
+ * What the backup holds in one place, as the items a sync reports: each skill folder, agent and
92
+ * memory file (CLAUDE.md, AGENTS.md, rules and session memory). MCP servers are left out: their
93
+ * config is one file per place, read only for a server the site names.
94
+ */
95
+ function backupItems(tree, place, project) {
96
+ const items = new Map();
97
+ for (const path of tree.keys()) {
98
+ if (!path.startsWith(`${place}/`)) continue;
99
+ const rest = path.slice(place.length + 1);
100
+ const [first, ...more] = rest.split("/");
101
+ let item = null;
102
+ if (first === "skills") item = more.length > 1 ? { kind: "skill", name: more[0] } : null;
103
+ else if (first === "agents") item = rest.endsWith(".md") ? { kind: "agent", name: more.join("/").slice(0, -3) } : null;
104
+ else if (first !== "mcp.json" && first !== "mcp.local.json") item = { kind: "memory", name: rest };
105
+ if (item && sentName(item.kind, item.name, project)) items.set(`${item.kind}/${item.name}`, item);
106
+ }
107
+ return [...items.values()];
108
+ }
109
+
110
+ /**
111
+ * Where a skill, agent or memory file goes on this computer: a skill's folder, or a file's path;
112
+ * null for a name that would leave its folder.
113
+ */
114
+ function targetOf({ kind, name }, { root, claudeDir }) {
115
+ const own = (...segments) => join(root ?? claudeDir, ...(root ? [".claude"] : []), ...segments);
116
+ if (kind === "skill") return join(own("skills"), name);
117
+ if (kind === "agent") return inside(own("agents"), `${name}.md`);
118
+ const [first, ...rest] = name.split("/");
119
+ if (first === "rules") return inside(own("rules"), ...rest);
120
+ if (first === "memory" && root) return inside(join(claudeDir, "projects", sessionFolderName(root), "memory"), ...rest);
121
+ return join(root ?? claudeDir, name);
122
+ }
123
+
124
+ /** Whether an item is on this computer already: a skill as a folder with its SKILL.md, a file as anything at its path. */
125
+ const presentAt = async (target, kind) => (kind === "skill" ? isFile(join(target, "SKILL.md")) : (await lstat(target).catch(() => null)) !== null);
126
+
85
127
  const sha256 = (content) => createHash("sha256").update(content).digest("hex");
86
128
 
87
129
  /** Writes one file into place; "exists" when one is there already and `force` is not set, "link" when the target is a link. */
@@ -94,6 +136,15 @@ async function placeFile(content, target, force) {
94
136
  return "copied";
95
137
  }
96
138
 
139
+ /** Whether every file of the backup's copy of a skill is in its folder here, byte for byte. */
140
+ async function sameFiles(target, files) {
141
+ for (const file of files) {
142
+ const there = await readFile(join(target, ...file.segments)).catch(() => null);
143
+ if (!there?.equals(file.content)) return false;
144
+ }
145
+ return true;
146
+ }
147
+
97
148
  const ENVIRONMENTS = [
98
149
  ["package.json", "npm install"],
99
150
  ["requirements.txt", "pip install -r requirements.txt"],
@@ -105,7 +156,7 @@ const ENVIRONMENTS = [
105
156
  /** The environments a restored skill folder names, each with the command that rebuilds it. */
106
157
  async function environmentsOf(folder) {
107
158
  const hints = [];
108
- for (const [file, command] of ENVIRONMENTS) if ((await stat(join(folder, file)).catch(() => null))?.isFile()) hints.push(`${file} (${command})`);
159
+ for (const [file, command] of ENVIRONMENTS) if (await isFile(join(folder, file))) hints.push(`${file} (${command})`);
109
160
  return hints;
110
161
  }
111
162
 
@@ -116,13 +167,34 @@ async function checkProjectFolder(root, sub) {
116
167
  if ((await lstat(path).catch(() => null))?.isSymbolicLink()) throw new Error(`${path} is a link, so a file could land outside the project.`);
117
168
  }
118
169
 
170
+ /** The name a SKILL.md's frontmatter gives, when a command can print it as it is; null otherwise. */
171
+ async function skillMdName(mirror, entry, budget) {
172
+ if (!regular(entry)) return null;
173
+ const text = (await blobOf(mirror, entry, budget)).toString("utf8");
174
+ const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? "";
175
+ const name = /^name:[ \t]*["']?([^"'\r\n]*?)["']?[ \t]*$/m.exec(front)?.[1];
176
+ return name && SKILL_NAME_RE.test(name) ? name : null;
177
+ }
178
+
179
+ /**
180
+ * The `npx skills add` command of a skill from a repository, under the name its SKILL.md gives in the
181
+ * backup: the skills CLI matches on that name, which can differ from the folder's (react:components
182
+ * installs as react-components). Globally it adds -g, without which the skills CLI installs into the
183
+ * folder it runs in, and -y.
184
+ */
185
+ async function skillCommand(command, name, { mirror, tree, budget, place, scope }) {
186
+ const named = await skillMdName(mirror, tree.get(`${place}/skills/${name}/SKILL.md`), budget);
187
+ const renamed = named && / --skill \S+$/.test(command) ? command.replace(/ --skill \S+$/, () => ` --skill ${named}`) : command;
188
+ return scope === "global" ? `${renamed} -g -y` : renamed;
189
+ }
190
+
119
191
  /**
120
192
  * One item of a copy, in one place: files from the backup are written into Claude Code's folders,
121
- * once they match what the source reported and hold nothing that looks like a secret, and what a
122
- * command installs, a plugin, a skill from a repository or an MCP server, is answered as that
123
- * command for Claude Code or the user to run. Nothing is run here.
193
+ * once they match what the source reported (or the user takes the backup's copy) and hold nothing
194
+ * that looks like a secret, and what a command installs, a plugin, a skill from a repository or an
195
+ * MCP server, is answered as that command for Claude Code or the user to run. Nothing is run here.
124
196
  */
125
- async function restoreItem(item, { mirror, tree, budget, place, scope, root, claudeDir, force, dryRun, allowed }) {
197
+ async function restoreItem(item, { mirror, tree, budget, place, scope, root, claudeDir, force, dryRun, allowed, takeBackup, fromBackup }) {
126
198
  // A file that looks like it holds a secret is copied only as the source's user allowed it, byte for byte.
127
199
  const secretIn = (path, content) => {
128
200
  const found = findSecret(content.toString("utf8"));
@@ -130,14 +202,15 @@ async function restoreItem(item, { mirror, tree, budget, place, scope, root, cla
130
202
  };
131
203
  const { kind, name } = item;
132
204
  if (item.tracked) return { outcome: "repository" };
133
- if (kind === "plugin" || (kind === "skill" && item.install)) {
205
+ if (kind === "plugin" || (kind === "skill" && item.install && !fromBackup.has(name))) {
134
206
  const command = item.install?.command;
135
207
  if (typeof command !== "string") return { outcome: "missing" };
136
- return (kind === "plugin" ? PLUGIN_RE : NPX_SKILLS_RE).test(command) ? { outcome: "command", command } : { outcome: "odd-command", command };
208
+ if (!(kind === "plugin" ? PLUGIN_RE : NPX_SKILLS_RE).test(command)) return { outcome: "odd-command", command };
209
+ if (kind === "plugin") return { outcome: "command", command, plugin: true };
210
+ return { outcome: "command", command: await skillCommand(command, name, { mirror, tree, budget, place, scope }), inBackup: tree.has(`${place}/skills/${name}/SKILL.md`) };
137
211
  }
138
212
  if (!["skill", "mcp", "memory", "agent"].includes(kind)) return { outcome: "unknown-kind" };
139
213
  if (!sentName(kind, name, scope === "project")) return { outcome: "unsendable" };
140
- const own = (...segments) => join(root ?? claudeDir, ...(root ? [".claude"] : []), ...segments);
141
214
  if (kind === "mcp") {
142
215
  const local = await readJsonBlob(mirror, tree.get(`${place}/mcp.local.json`), budget);
143
216
  const shared = await readJsonBlob(mirror, tree.get(`${place}/mcp.json`), budget);
@@ -164,43 +237,41 @@ async function restoreItem(item, { mirror, tree, budget, place, scope, root, cla
164
237
  }
165
238
  if (!files.length) return { outcome: "missing" };
166
239
  if (root) await checkProjectFolder(root, "skills");
167
- const skills = own("skills");
168
- const target = join(skills, name);
169
- if (dryRun) return { outcome: (await lstat(target).catch(() => null)) && !force ? "exists" : "copied", target };
240
+ const skills = join(root ?? claudeDir, ...(root ? [".claude"] : []), "skills");
241
+ const target = targetOf(item, { root, claudeDir });
242
+ if ((await lstat(target).catch(() => null)) && !force) {
243
+ // A folder without its SKILL.md is not a skill Claude Code loads, so it is not "there already".
244
+ if (!(await isFile(join(target, "SKILL.md")))) return { outcome: "incomplete", target };
245
+ return { outcome: "exists", target, same: await sameFiles(target, files) };
246
+ }
247
+ if (dryRun) return { outcome: "copied", target };
170
248
  try {
171
249
  await installPulled(skills, { slug: name, files }, force);
172
250
  } catch (error) {
173
- if (/is there already/.test(error.message)) return { outcome: "exists", target };
251
+ if (/is there already/.test(error.message)) return { outcome: "exists", target, same: false };
174
252
  throw error;
175
253
  }
254
+ if (!(await isFile(join(target, "SKILL.md")))) return { outcome: "vanished", target };
176
255
  return { outcome: "copied", target, environments: await environmentsOf(target) };
177
256
  }
178
- let path;
179
- let target;
180
- if (kind === "agent") {
181
- path = `${place}/agents/${name}.md`;
182
- if (root) await checkProjectFolder(root, "agents");
183
- target = inside(own("agents"), `${name}.md`);
184
- } else {
185
- const [first, ...rest] = name.split("/");
186
- path = `${place}/${name}`;
187
- if (first === "rules") {
188
- if (root) await checkProjectFolder(root, "rules");
189
- target = inside(own("rules"), ...rest);
190
- } else if (first === "memory" && root) target = inside(join(claudeDir, "projects", sessionFolderName(root), "memory"), ...rest);
191
- else target = join(root ?? claudeDir, name);
192
- }
257
+ if (kind === "agent" && root) await checkProjectFolder(root, "agents");
258
+ if (kind === "memory" && name.startsWith("rules/") && root) await checkProjectFolder(root, "rules");
259
+ const path = kind === "agent" ? `${place}/agents/${name}.md` : `${place}/${name}`;
260
+ const target = targetOf(item, { root, claudeDir });
193
261
  const entry = tree.get(path);
194
262
  if (!entry) return { outcome: "missing" };
195
263
  if (!regular(entry)) return { outcome: "link", path };
196
264
  if (!target) return { outcome: "unsendable" };
197
265
  const content = await blobOf(mirror, entry, budget);
198
- // The site holds the file's hash as the source reported it: a file that differs is not the source's.
199
- if (item.hash && sha256(content) !== item.hash) return { outcome: "differs" };
266
+ // The site holds the file's hash as the source reported it: a copy that differs is taken only on the user's word.
267
+ const differs = Boolean(item.hash) && sha256(content) !== item.hash;
268
+ if (differs && !takeBackup) return { outcome: "differs" };
200
269
  const found = secretIn(path, content);
201
270
  if (found) return { outcome: "secret", path, line: found.line, kind: found.kind };
202
- if (dryRun) return { outcome: (await lstat(target).catch(() => null)) && !force ? "exists" : "copied", target };
203
- return { outcome: await placeFile(content, target, force), target };
271
+ const there = await lstat(target).catch(() => null);
272
+ if (there && !force && !there.isSymbolicLink()) return { outcome: "exists", target, same: (await readFile(target).catch(() => null))?.equals(content) === true };
273
+ if (dryRun) return { outcome: there?.isSymbolicLink() ? "link" : "copied", target, differs };
274
+ return { outcome: await placeFile(content, target, force), target, differs };
204
275
  }
205
276
 
206
277
  const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
@@ -221,9 +292,13 @@ async function trustedRepo(repo, source, config, flags) {
221
292
  function describe(result, label, source, dryRun) {
222
293
  switch (result.outcome) {
223
294
  case "copied":
224
- return `${dryRun ? "would copy" : "copied"} ${label} to ${result.target}`;
295
+ return `${dryRun ? "would copy" : "copied"} ${label} to ${result.target}${result.differs ? ` (the backup's copy, which is not the one ${source.device.name} last reported)` : ""}`;
225
296
  case "exists":
226
- return `kept ${label}: ${result.target} is there already (run again with --force to replace it)`;
297
+ return `kept ${label}: ${result.target} is there already and differs from the backup's copy (run again with --force to replace it)`;
298
+ case "incomplete":
299
+ return `${label}: ${result.target} has no SKILL.md, so Claude Code does not load it: ${ANTIVIRUS}. Run again with --force to copy it again`;
300
+ case "vanished":
301
+ return `copied ${label} to ${result.target}, but its SKILL.md is gone already: ${ANTIVIRUS}`;
227
302
  case "odd-command":
228
303
  return `${label}: its install command is not one this tool hands over (${result.command.slice(0, 80)}); install it from the site's page`;
229
304
  case "repository":
@@ -239,7 +314,7 @@ function describe(result, label, source, dryRun) {
239
314
  case "secret":
240
315
  return `${label}: ${result.path}:${result.line} in the backup looks like ${result.kind}; not restored`;
241
316
  case "differs":
242
- return `${label}: the backup's copy differs from what ${source.device.name} reported; run skillcellar backup there again`;
317
+ return `${label}: the backup's copy is not the one ${source.device.name} last reported (it changed after that sync, or in the backup since). If the backup is yours as you left it, run again with --take-backup to copy it`;
243
318
  default:
244
319
  return `${label}: not in the backup`;
245
320
  }
@@ -249,18 +324,21 @@ function describe(result, label, source, dryRun) {
249
324
  * Carries out the copy tasks of the plan whose source has a backup: refuses a public repository and
250
325
  * one this computer was not told to trust, fetches the backup into a bare mirror with the user's own
251
326
  * git, reads the commit the site recorded, writes skills, memory files and agents into place, and
252
- * lists the commands left to run.
327
+ * lists the commands left to run. Answers the project folders it restored, whatever was missing there, so a sync reads them and closes their tasks.
253
328
  */
254
329
  export async function runRestore({ plan, claudeDir, config, flags, version, print, warn }) {
255
330
  const force = flags.force === true;
256
331
  const dryRun = flags["dry-run"] === true;
332
+ const takeBackup = flags["take-backup"] === true;
333
+ const fromBackup = new Set(Array.isArray(flags["from-backup"]) ? flags["from-backup"] : []);
257
334
  const from = typeof flags.from === "string" ? flags.from.trim().toLowerCase() : null;
335
+ const folders = new Set();
258
336
  const wanted = (plan.tasks ?? []).filter(
259
337
  (task) => task.action === "copy" && task.copy && (!from || task.copy.from.device.slug === from || task.copy.from.device.name.toLowerCase() === from),
260
338
  );
261
339
  if (!wanted.length) {
262
340
  print(from ? `No copy task from ${flags.from} waits for ${plan.device.name}.` : `No copy task waits for ${plan.device.name}.`);
263
- return;
341
+ return { folders };
264
342
  }
265
343
  const bySource = new Map();
266
344
  for (const task of wanted) bySource.set(task.copy.from.device.id, [...(bySource.get(task.copy.from.device.id) ?? []), task]);
@@ -303,6 +381,13 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
303
381
  const listed = Array.isArray(found.manifest?.allowed) ? found.manifest.allowed : [];
304
382
  const allowed = new Map(listed.filter((entry) => typeof entry?.path === "string" && typeof entry.sha256 === "string").map((entry) => [`${found.folder}/${entry.path}`, entry.sha256]));
305
383
  print(`${dryRun ? "Would restore" : "Restoring"} from ${source.device.name}'s backup, ${repo.name} at ${commit.slice(0, 7)}:`);
384
+ // What any task names in a place, so the backup's other items there are told apart from them.
385
+ const reported = new Set(
386
+ tasks.flatMap((task) => {
387
+ const place = placeFolder(found, task);
388
+ return place ? task.copy.items.map((item) => `${place}/${item.kind}/${item.name}`) : [];
389
+ }),
390
+ );
306
391
  for (const task of tasks) {
307
392
  const where = task.project ? `in ${task.project.name}` : "globally";
308
393
  if (task.problem) {
@@ -310,7 +395,7 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
310
395
  continue;
311
396
  }
312
397
  if (task.project && !task.folder) {
313
- print(` ${where}: this computer has no folder of ${task.project.name}; clone it, then run skillcellar restore from inside it.`);
398
+ print(` ${where}: no folder of ${task.project.name} that Claude Code knows on this computer. If you cloned it, run skillcellar restore inside the clone.`);
314
399
  continue;
315
400
  }
316
401
  const place = placeFolder(found, task);
@@ -318,26 +403,50 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
318
403
  print(` ${where}: the backup holds nothing for this place.`);
319
404
  continue;
320
405
  }
321
- if (!task.copy.items.length) print(` ${where}: nothing left to copy; the next sync closes the task.`);
322
- for (const item of task.copy.items) {
406
+ if (task.folder && !dryRun) folders.add(task.folder);
407
+ const context = { mirror, tree, budget, place, scope: task.scope, root: task.folder, claudeDir, force, dryRun, allowed, takeBackup, fromBackup };
408
+ let inPlace = 0;
409
+ const bring = async (item, note = "") => {
323
410
  const label = `${item.kind} ${item.name}`;
324
411
  const key = `${place}/${label}`;
325
412
  if (done.has(key)) {
326
413
  print(` ${label}: brought over above`);
327
- continue;
414
+ return;
328
415
  }
329
416
  done.add(key);
330
- const result = await restoreItem(item, { mirror, tree, budget, place, scope: task.scope, root: task.folder, claudeDir, force, dryRun, allowed });
417
+ const result = await restoreItem(item, context);
331
418
  if (result.outcome === "command") {
332
419
  commands.push({ task: where, label, ...result });
333
- continue;
420
+ return;
421
+ }
422
+ if (result.outcome === "exists" && result.same) {
423
+ inPlace += 1;
424
+ return;
334
425
  }
335
426
  if (result.outcome === "copied") {
336
427
  copied += 1;
337
428
  for (const hint of result.environments ?? []) environments.push(`${item.name} has ${hint}`);
338
429
  }
339
- print(` ${describe(result, label, source, dryRun)}`);
430
+ print(` ${describe(result, label, source, dryRun)}${note}`);
431
+ };
432
+ if (!task.copy.items.length) print(` ${where}: nothing left to copy; the next sync closes the task.`);
433
+ for (const item of task.copy.items) await bring(item);
434
+ // A copy of everything also finds what the backup holds that the source's last sync did not report.
435
+ if (!task.item) {
436
+ const unlisted = [];
437
+ for (const item of backupItems(tree, place, task.scope === "project")) {
438
+ if (reported.has(`${place}/${item.kind}/${item.name}`)) continue;
439
+ const target = targetOf(item, { root: task.folder, claudeDir });
440
+ if (target && !(await presentAt(target, item.kind))) unlisted.push(item);
441
+ }
442
+ if (unlisted.length && !takeBackup) {
443
+ print(
444
+ ` ${where}: ${plural(unlisted.length, "item")} in the backup that ${source.device.name}'s last sync did not report (added after that sync, or removed before it): ${unlisted.map((item) => `${item.kind} ${item.name}`).join(", ")}. Run again with --take-backup to copy ${unlisted.length === 1 ? "it" : "them"}.`,
445
+ );
446
+ }
447
+ if (takeBackup) for (const item of unlisted) await bring(item, ` (in the backup, not in ${source.device.name}'s last sync)`);
340
448
  }
449
+ if (inPlace) print(` ${where}: ${plural(inPlace, "item")} already in place, the same as in the backup.`);
341
450
  }
342
451
  }
343
452
  if (commands.length) {
@@ -346,7 +455,14 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
346
455
  const notes = [...(entry.placeholders?.length ? [`needs ${entry.placeholders.join(", ")}`] : []), ...(entry.review?.length ? [`review: ${entry.review.join(", ")}`] : [])];
347
456
  print(` ${entry.task}: ${entry.command}${notes.length ? ` (${notes.join("; ")})` : ""}`);
348
457
  }
458
+ if (commands.some((entry) => entry.inBackup)) {
459
+ print("If npx skills add finds no matching skill, the skill has left its repository: run skillcellar restore --from-backup <name> to copy the backup's copy instead.");
460
+ }
461
+ if (commands.some((entry) => entry.plugin)) {
462
+ print("If /plugin install says the plugin is not in its marketplace, the marketplace was renamed after the old computer added it: install the plugin under the name /plugin marketplace list shows for it.");
463
+ }
349
464
  }
350
465
  if (environments.length) print(`Rebuild: ${environments.join("; ")}.`);
351
466
  if (!dryRun) print(`${copied ? `Copied ${plural(copied, "item")}. ` : ""}Run skillcellar sync to close the tasks that are done.`);
467
+ return { folders };
352
468
  }
package/src/scan.mjs CHANGED
@@ -42,8 +42,9 @@ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
42
42
 
43
43
  /**
44
44
  * The skills in a skills directory as Claude Code loads them: each folder directly inside it, or a
45
- * link to one, with a SKILL.md of its own. Hidden folders, `learned`, `synced` and `*-workspace`
46
- * are skipped, and so is a folder whose skills sit deeper inside it.
45
+ * link to one, with a SKILL.md of its own. Hidden folders, `learned` and `synced` are skipped, and
46
+ * so is a folder whose skills sit deeper inside it, such as skill-creator's `*-workspace` folders;
47
+ * a folder with a SKILL.md of its own is a skill whatever its name.
47
48
  */
48
49
  export async function skillFolders(dir) {
49
50
  let entries;
@@ -56,7 +57,7 @@ export async function skillFolders(dir) {
56
57
  const folders = [];
57
58
  for (const entry of entries) {
58
59
  const { name } = entry;
59
- if (name.startsWith(".") || SKIPPED_FOLDERS.has(name) || name.endsWith("-workspace")) continue;
60
+ if (name.startsWith(".") || SKIPPED_FOLDERS.has(name)) continue;
60
61
  const path = join(dir, name);
61
62
  const [folder, skillMd] = await Promise.all([stat(path).catch(() => null), stat(join(path, "SKILL.md")).catch(() => null)]);
62
63
  if (folder?.isDirectory() && skillMd?.isFile()) folders.push({ name, path });
package/src/tech.mjs ADDED
@@ -0,0 +1,242 @@
1
+ import { lstat, open, readdir, realpath } from "node:fs/promises";
2
+ import { dirname, join, sep } from "node:path";
3
+ import { dependencyNames, manifestEcosystem, normalizeName } from "./manifests.mjs";
4
+
5
+ // Finds a project's technologies on this computer. The rules come from the site's public list at
6
+ // /tech.json; they are names and paths compared by prefix and suffix, never a pattern the code
7
+ // compiles, and only the ids they match leave the computer.
8
+
9
+ export const RULES_BYTES_MAX = 512 * 1024;
10
+ const RULES_VERSION = 1;
11
+ const ECOSYSTEMS = ["npm", "pypi", "go", "cargo", "gem", "composer", "pub"];
12
+ const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
13
+ const ID_MAX = 32;
14
+ // The site's SYNC_TECH_MAX: the most one project reports.
15
+ const PER_PROJECT_MAX = 32;
16
+ const RULES_MAX = 500;
17
+ const PATTERNS_MAX = 200;
18
+ const PATTERN_MAX = 214;
19
+ const SEGMENTS_MAX = 3;
20
+ // A path counts up to this many segments deep: the manifests and markers that say what a project is sit near its root.
21
+ const PATH_SEGMENTS_MAX = 4;
22
+ const MANIFESTS_MAX = 40;
23
+ const MANIFEST_BYTES_MAX = 1024 * 1024;
24
+ const PROJECT_MS_MAX = 2000;
25
+ // Never counted, whatever the list says.
26
+ const ALWAYS_IGNORED = ["node_modules", ".git"];
27
+
28
+ /** Whether a rule's name or path is one this code compares safely: one `*` at most, no `..`, no backslash, no control character. */
29
+ const usable = (value) => typeof value === "string" && value !== "" && value.length <= PATTERN_MAX && value.split("*").length <= 2 && !value.includes("..") && !/[\\\s\p{Cc}]/u.test(value);
30
+
31
+ /** A package name or a prefix of one ending in `*`, normalised as its registry compares names. */
32
+ function namePattern(ecosystem, name) {
33
+ if (!usable(name)) return null;
34
+ const star = name.endsWith("*");
35
+ if (name.includes("*") && !star) return null;
36
+ const prefix = normalizeName(ecosystem, star ? name.slice(0, -1) : name);
37
+ return prefix ? { prefix, star } : null;
38
+ }
39
+
40
+ /** A path of 1 to 3 segments, tied to the project's root when it starts with `/`, a segment holding at most one `*`. */
41
+ function filePattern(path) {
42
+ if (!usable(path)) return null;
43
+ const anchored = path.startsWith("/");
44
+ const segments = (anchored ? path.slice(1) : path).split("/");
45
+ if (segments.length > SEGMENTS_MAX || segments.some((segment) => segment === "" || segment === ".")) return null;
46
+ return {
47
+ anchored,
48
+ segments: segments.map((segment) => {
49
+ const at = segment.indexOf("*");
50
+ return at === -1 ? { literal: segment } : { prefix: segment.slice(0, at), suffix: segment.slice(at + 1) };
51
+ }),
52
+ };
53
+ }
54
+
55
+ /**
56
+ * The list the site serves, as rules this code can use, or null for a list of another version.
57
+ * It comes from the network, so every field is checked, and a rule it cannot read is left out.
58
+ */
59
+ export function readRules(json) {
60
+ if (json?.version !== RULES_VERSION || !Array.isArray(json.tech)) return null;
61
+ const listed = Array.isArray(json.ignore) ? json.ignore.filter((name) => typeof name === "string" && name !== "" && name.length <= 64 && !name.includes("/")) : [];
62
+ const ignore = new Set([...ALWAYS_IGNORED, ...listed.slice(0, PATTERNS_MAX)]);
63
+ const tech = [];
64
+ const seen = new Set();
65
+ for (const entry of json.tech.slice(0, RULES_MAX)) {
66
+ if (typeof entry?.id !== "string" || entry.id.length > ID_MAX || !ID_RE.test(entry.id) || seen.has(entry.id)) continue;
67
+ const deps = {};
68
+ for (const ecosystem of ECOSYSTEMS) {
69
+ const names = Array.isArray(entry.deps?.[ecosystem]) ? entry.deps[ecosystem].slice(0, PATTERNS_MAX) : [];
70
+ const patterns = names.flatMap((name) => namePattern(ecosystem, name) ?? []);
71
+ if (patterns.length) deps[ecosystem] = patterns;
72
+ }
73
+ const files = (Array.isArray(entry.files) ? entry.files.slice(0, PATTERNS_MAX) : []).flatMap((path) => filePattern(path) ?? []);
74
+ if (!files.length && !Object.keys(deps).length) continue;
75
+ seen.add(entry.id);
76
+ tech.push({ id: entry.id, deps, files });
77
+ }
78
+ return { ignore, tech };
79
+ }
80
+
81
+ /**
82
+ * The site's list of technologies, downloaded without the key and never through a redirect, or
83
+ * null when it cannot be had: missing, moved, too large, not JSON, too slow or of another version.
84
+ */
85
+ export async function loadRules(site, version, { timeout = 10_000 } = {}) {
86
+ try {
87
+ const response = await fetch(new URL("/tech.json", site), {
88
+ headers: { accept: "application/json", "user-agent": `skillcellar/${version}` },
89
+ redirect: "error",
90
+ signal: AbortSignal.timeout(timeout),
91
+ });
92
+ if (!response.ok || !response.body) return null;
93
+ const chunks = [];
94
+ let size = 0;
95
+ for await (const chunk of response.body) {
96
+ size += chunk.length;
97
+ if (size > RULES_BYTES_MAX) return null;
98
+ chunks.push(chunk);
99
+ }
100
+ return readRules(JSON.parse(Buffer.concat(chunks).toString("utf8")));
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Whether the site serves /tech.json at all, whatever the list's version: it then takes `tech: false`,
108
+ * which needs no rules. A site without it, from before 0.8.0's server, would refuse the key.
109
+ */
110
+ export async function techServed(site, version, { timeout = 10_000 } = {}) {
111
+ try {
112
+ const response = await fetch(new URL("/tech.json", site), {
113
+ headers: { accept: "application/json", "user-agent": `skillcellar/${version}` },
114
+ redirect: "error",
115
+ signal: AbortSignal.timeout(timeout),
116
+ });
117
+ await response.body?.cancel();
118
+ return response.ok;
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Whether this computer reports technologies: what the config says, or the answer to the one
126
+ * question, asked only where it can be. `asked` says the answer is new, for the config to keep.
127
+ */
128
+ export async function techChoice(saved, canAsk, askYes) {
129
+ if (saved === "on" || saved === "off" || saved === "clearing") return { tech: saved, asked: false };
130
+ if (!canAsk) return { tech: null, asked: false };
131
+ return { tech: (await askYes()) ? "on" : "off", asked: true };
132
+ }
133
+
134
+ /** A folder or file name a pattern's segment matches; a name starting with a dot only where the rule names the dot. */
135
+ function segmentMatches(pattern, segment) {
136
+ const named = pattern.literal ?? pattern.prefix;
137
+ if (segment.startsWith(".") && !named.startsWith(".")) return false;
138
+ if (pattern.literal !== undefined) return segment === pattern.literal;
139
+ return segment.length >= pattern.prefix.length + pattern.suffix.length && segment.startsWith(pattern.prefix) && segment.endsWith(pattern.suffix);
140
+ }
141
+
142
+ /** Whether a path, as its segments, is one a file rule names: the whole path for a rule tied to the root, else its end, with no dot folder before it. */
143
+ function pathMatches(pattern, path) {
144
+ const count = pattern.segments.length;
145
+ if (pattern.anchored ? path.length !== count : path.length < count) return false;
146
+ const tail = path.slice(-count);
147
+ if (!pattern.anchored && path.slice(0, -count).some((segment) => segment.startsWith("."))) return false;
148
+ return pattern.segments.every((segment, index) => segmentMatches(segment, tail[index]));
149
+ }
150
+
151
+ const nameMatches = (pattern, name) => (pattern.star ? name.startsWith(pattern.prefix) : name === pattern.prefix);
152
+
153
+ /** The project's files as segments, from the list git tracks, or the root's own files when it lists none. */
154
+ async function projectPaths(root, tracked, ignore) {
155
+ let listed = tracked?.size ? [...tracked] : [];
156
+ if (!listed.length) {
157
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
158
+ listed = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
159
+ }
160
+ return listed.map((path) => path.split("/")).filter((path) => path.length <= PATH_SEGMENTS_MAX && path.every((segment) => segment !== "" && !ignore.has(segment)));
161
+ }
162
+
163
+ /** Whether a path, its links resolved, is the project's folder or inside it: a linked parent folder can point anywhere. */
164
+ async function inside(base, path) {
165
+ const real = await realpath(path).catch(() => null);
166
+ return real !== null && (real === base || real.startsWith(base + sep));
167
+ }
168
+
169
+ /** A file or folder at the root, which git may not track, such as .neon; a link is not followed. */
170
+ async function isThere(base, path) {
171
+ const found = await lstat(path).catch(() => null);
172
+ return Boolean(found && (found.isFile() || found.isDirectory()) && (await inside(base, dirname(path))));
173
+ }
174
+
175
+ /** A manifest's text, or null when it is missing, a link or under one, not a plain file, or over 1 MiB. */
176
+ async function readManifest(base, path) {
177
+ const found = await lstat(path).catch(() => null);
178
+ if (!found?.isFile() || found.size > MANIFEST_BYTES_MAX || !(await inside(base, path))) return null;
179
+ let handle;
180
+ try {
181
+ handle = await open(path, "r");
182
+ const opened = await handle.stat();
183
+ if (!opened.isFile() || opened.size > MANIFEST_BYTES_MAX) return null;
184
+ return await handle.readFile("utf8");
185
+ } catch {
186
+ return null;
187
+ } finally {
188
+ await handle?.close().catch(() => {});
189
+ }
190
+ }
191
+
192
+ /**
193
+ * The ids of the technologies a project uses, as the rules find them in its files and manifests,
194
+ * in the rules' order, at most 32. Nothing it reads leaves this function but those ids, and it
195
+ * never throws. Null when it could not tell, because a read failed or took past its time: the
196
+ * sync then sends nothing for the project, and the site keeps what it had, rather than fewer ids.
197
+ */
198
+ export async function detectTech(rules, root, tracked) {
199
+ try {
200
+ const started = Date.now();
201
+ const base = await realpath(root);
202
+ const paths = await projectPaths(root, tracked, rules.ignore);
203
+ const found = new Set();
204
+ for (const rule of rules.tech) {
205
+ if (rule.files.some((pattern) => paths.some((path) => pathMatches(pattern, path)))) found.add(rule.id);
206
+ }
207
+ // A file the rule ties to the root by its whole name is looked for on disk too: .neon and .vercel are rarely tracked.
208
+ for (const rule of rules.tech) {
209
+ if (found.has(rule.id)) continue;
210
+ for (const pattern of rule.files) {
211
+ if (pattern.anchored && pattern.segments.every((segment) => segment.literal !== undefined) && (await isThere(base, join(root, ...pattern.segments.map((segment) => segment.literal))))) {
212
+ found.add(rule.id);
213
+ break;
214
+ }
215
+ }
216
+ }
217
+
218
+ const wanted = new Set(rules.tech.flatMap((rule) => Object.keys(rule.deps)));
219
+ // A manifest in a dot folder belongs to a tool, such as a skill in .claude or a dev container, not the project.
220
+ const ecosystemOf = (path) => (path.slice(0, -1).some((segment) => segment.startsWith(".")) ? null : manifestEcosystem(path.at(-1), path.at(-2)));
221
+ const manifests = paths
222
+ .filter((path) => wanted.has(ecosystemOf(path)))
223
+ .sort((a, b) => a.length - b.length || (a.join("/") < b.join("/") ? -1 : 1))
224
+ .slice(0, MANIFESTS_MAX);
225
+ const names = new Map(ECOSYSTEMS.map((ecosystem) => [ecosystem, new Set()]));
226
+ for (const path of manifests) {
227
+ if (Date.now() - started > PROJECT_MS_MAX) return null;
228
+ const ecosystem = ecosystemOf(path);
229
+ const text = await readManifest(base, join(root, ...path));
230
+ if (text === null) continue;
231
+ for (const name of dependencyNames(ecosystem, path.at(-1), text)) names.get(ecosystem).add(normalizeName(ecosystem, name));
232
+ }
233
+ for (const rule of rules.tech) {
234
+ if (found.has(rule.id)) continue;
235
+ const uses = Object.entries(rule.deps).some(([ecosystem, patterns]) => [...names.get(ecosystem)].some((name) => patterns.some((pattern) => nameMatches(pattern, name))));
236
+ if (uses) found.add(rule.id);
237
+ }
238
+ return rules.tech.filter((rule) => found.has(rule.id)).map((rule) => rule.id).slice(0, PER_PROJECT_MAX);
239
+ } catch {
240
+ return null;
241
+ }
242
+ }