skillcellar 0.3.0 → 0.6.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/cli.mjs CHANGED
@@ -3,12 +3,16 @@ import { homedir, hostname } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { parseArgs } from "node:util";
5
5
  import { ApiError, call } from "./api.mjs";
6
+ import { parseBackupRepo, runBackup } from "./backup.mjs";
7
+ import { describeCheck } from "./check.mjs";
6
8
  import { configPath, readConfig, writeConfig } from "./config.mjs";
7
9
  import { findProjects } from "./places.mjs";
8
10
  import { describePlan, localPlan } from "./plan.mjs";
9
- import { findProject } from "./project.mjs";
11
+ import { applyPlaybook, cautionLine, describeStatus, readPlaybook } from "./playbook.mjs";
12
+ import { findProject, trackedFiles } from "./project.mjs";
10
13
  import { ask, askSecret, firstLine, interactive } from "./prompt.mjs";
11
14
  import { checkProjectSkills, installPulled, PULL_REF_RE, readPulled } from "./pull.mjs";
15
+ import { runRestore } from "./restore.mjs";
12
16
  import { readJson, scanGlobal, scanProject } from "./scan.mjs";
13
17
  import { installSkill, skillFolder, skillState } from "./skill.mjs";
14
18
 
@@ -35,12 +39,42 @@ Usage:
35
39
  skillcellar plan [--json]
36
40
  List the tasks waiting for this computer, each with how to carry it out and, for a project,
37
41
  its folder here.
42
+ skillcellar check [--json]
43
+ List what this computer has that its source has moved past, as the site last synced it: a
44
+ skill npx skills installed globally whose folder changed in its repository, a plugin whose
45
+ marketplace declares another version, a skill from the site that is an older version of
46
+ it; each with the command that updates it, then what the site cannot check.
38
47
  skillcellar pull <username>/<name> [--scope global|project] [--force]
39
48
  Install a skill from the site: one you wrote (<name> alone will do), one you bought, or a free
40
49
  Community skill, into ~/.claude/skills/<name>, or with --scope project into the skills folder
41
50
  of the project you are in. --force replaces a folder of that name.
51
+ skillcellar playbook apply <username>/<name> [--global] [--dry-run] [--force] [--yes]
52
+ Apply a playbook: one of yours (<name> alone will do) or one published in Community. Writes
53
+ its skill into the project's .claude/skills/playbook-<name>, or ~/.claude/skills with --global,
54
+ pulls the skills it uses from the site that this computer lacks, and prints an install prompt
55
+ for the ones from elsewhere, whose installers it never runs. It writes nothing outside those
56
+ skills folders, and keeps a folder that is there already unless you add --force. Someone
57
+ else's playbook writes only after a yes at the terminal, or with --yes.
58
+ --dry-run list the files it would write, and write nothing
59
+ --yes write someone else's playbook without asking, once you have read what it writes
60
+ skillcellar playbook status <username>/<name> [--global] [--json]
61
+ List which of a playbook's skills this computer has, in the project you are in and globally
62
+ (only globally with --global): installed, outdated with the command that updates it, or
63
+ missing with how to install it. It reads this computer as sync does, and the site keeps none of it.
42
64
  skillcellar skill
43
65
  Install or update the skillcellar skill, which tells Claude Code how to carry out your tasks.
66
+ skillcellar backup [--repo <owner>/<repo>] [--ssh] [--allow <file>] [--dry-run]
67
+ Push this computer's memory files, rules, agents, MCP config (every env and header value,
68
+ address credential and key-like argument replaced by a \${NAME} placeholder) and skills'
69
+ source files to a private GitHub repository you own, with your own git. It stops on anything
70
+ that looks like a secret and refuses a public repository. --repo is remembered; --ssh clones
71
+ and pushes over SSH; --allow <file> pushes a file you have read despite a finding in it;
72
+ --dry-run lists what would go.
73
+ skillcellar restore [--from <device>] [--repo <owner>/<repo>] [--ssh] [--force] [--dry-run]
74
+ Carry out the copy tasks waiting for this computer: fetch the other computer's backup, write
75
+ its skills, memory files and agents into place, and list the commands left to run. A backup
76
+ in a repository this computer does not back up to is taken only with --repo, or a yes at the
77
+ terminal. --force replaces a folder or file that is there already.
44
78
 
45
79
  login reads the key from SKILLCELLAR_API_KEY when it is set, else from the terminal or the first
46
80
  line piped in. The key and the device are kept in ${configPath()}, written with mode 0600.`;
@@ -91,6 +125,13 @@ async function chooseDevice(site, key, devices, print) {
91
125
  /** Claude Code's own folder: CLAUDE_CONFIG_DIR when it is set, else ~/.claude. */
92
126
  const claudeFolder = (home) => process.env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude");
93
127
 
128
+ /** Stops a command that reads this computer when Claude Code's folder is not there: read from the wrong one, it would find nothing. */
129
+ async function checkClaudeFolder(claudeDir) {
130
+ if (!(await stat(claudeDir).catch(() => null))?.isDirectory()) {
131
+ throw new Error(`There is no Claude Code folder at ${claudeDir}. Set CLAUDE_CONFIG_DIR to the one Claude Code uses.`);
132
+ }
133
+ }
134
+
94
135
  /** The saved login and its site, for a command that calls the site. */
95
136
  async function signedIn() {
96
137
  const config = await readConfig();
@@ -118,7 +159,10 @@ async function login(flags, print) {
118
159
  if (!/^[A-Za-z0-9_]{1,200}$/.test(key)) throw new Error(`That is not an API key. Create one at ${site}/dashboard/keys.`);
119
160
  const library = await call(site, key, "GET", "/api/v1/library", undefined, VERSION);
120
161
  const device = flags.device !== undefined ? await namedDevice(site, key, flags.device) : await chooseDevice(site, key, library.devices ?? [], print);
121
- await writeConfig({ site, key, device: { id: device.id, slug: device.slug, name: device.name } });
162
+ // A backup repository remembered for this site stays remembered across sign-ins.
163
+ const earlier = await readConfig().catch(() => null);
164
+ const backup = earlier?.site === site && earlier.backup ? { backup: earlier.backup } : {};
165
+ await writeConfig({ site, key, device: { id: device.id, slug: device.slug, name: device.name }, ...backup });
122
166
  const who = library.account?.email ? ` as ${library.account.email}` : "";
123
167
  print(`Signed in to ${new URL(site).host}${who}. This computer syncs as ${device.name}.`);
124
168
  await offerSkill(print);
@@ -163,7 +207,8 @@ const identity = (project) => project.repo ?? project.name.toLowerCase();
163
207
 
164
208
  /** The tasks a sync closed, in one line. */
165
209
  function closedLine(closed) {
166
- const done = closed.map((task) => `${task.action === "remove" ? "removed" : "installed"} ${task.name} ${task.project ? `in ${task.project}` : "globally"}`);
210
+ const verb = (task) => (task.action === "remove" ? "removed" : task.action === "copy" ? "copied" : "installed");
211
+ const done = closed.map((task) => `${verb(task)} ${task.name ?? "everything"} ${task.project ? `in ${task.project}` : "globally"}`);
167
212
  return `Closed ${plural(closed.length, "task")}: ${done.join("; ")}.`;
168
213
  }
169
214
 
@@ -176,9 +221,7 @@ async function sync(flags, print, warn) {
176
221
  const site = config ? siteOrigin(config.site) : null;
177
222
  if (config && !site) throw new Error(`The saved site, ${config.site}, is not an https address. Run skillcellar login again.`);
178
223
  // Read from the wrong folder, a sync would report nothing and remove what the last one reported.
179
- if (!(await stat(claudeDir).catch(() => null))?.isDirectory()) {
180
- throw new Error(`There is no Claude Code folder at ${claudeDir}. Set CLAUDE_CONFIG_DIR to the one Claude Code uses.`);
181
- }
224
+ await checkClaudeFolder(claudeDir);
182
225
 
183
226
  const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
184
227
  const cwd = process.cwd();
@@ -205,7 +248,7 @@ async function sync(flags, print, warn) {
205
248
  }
206
249
  let scanned;
207
250
  try {
208
- scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson });
251
+ scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson, tracked: await trackedFiles(project.root) });
209
252
  } catch (error) {
210
253
  // One project's broken file stops only that project in a sync of the whole computer.
211
254
  if (!whole) throw error;
@@ -260,15 +303,21 @@ async function plan(flags, print) {
260
303
  const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
261
304
  const home = homedir();
262
305
  const claudeDir = claudeFolder(home);
263
- const projects = (answer.tasks ?? []).some((task) => task.project)
264
- ? await findProjects({ home, claudeDir, claudeJson: (await readJson(claudeJsonPath(home))) ?? {}, cwd: process.cwd() })
265
- : [];
306
+ const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
266
307
  // The device's slug as the site has it now: a rename changes the slugs of its folders' projects.
267
308
  const local = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
268
309
  if (flags.json) print(JSON.stringify(local, null, 2));
269
310
  else for (const line of describePlan(local)) print(line);
270
311
  }
271
312
 
313
+ /** Prints which of this computer's skills and plugins their sources have moved past, as the site checked them. */
314
+ async function check(flags, print) {
315
+ const { config, site } = await signedIn();
316
+ const answer = await call(site, config.key, "GET", `/api/v1/check?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
317
+ if (flags.json) print(JSON.stringify(answer, null, 2));
318
+ else for (const line of describeCheck(answer)) print(line);
319
+ }
320
+
272
321
  /** Installs a skill from the site into the global skills folder, or the project's. */
273
322
  async function pull(flags, ref, print) {
274
323
  if (!ref || !PULL_REF_RE.test(ref)) throw new UsageError("Name the skill as <username>/<name>, or <name> for one of your own.");
@@ -287,6 +336,105 @@ async function pull(flags, ref, print) {
287
336
  print(`Pulled ${ref}, version ${pulled.version}, into ${await installPulled(skills, pulled, flags.force === true)}.`);
288
337
  }
289
338
 
339
+ /**
340
+ * Applies a playbook to the project the command runs in, or globally, or prints where this
341
+ * computer stands with it. Either way the site checks it against what this computer has now,
342
+ * scanned as a sync scans it, and keeps none of it.
343
+ */
344
+ async function playbook(flags, action, ref, print, warn) {
345
+ if (action !== "apply" && action !== "status") throw new UsageError("Run playbook apply <username>/<name>, or playbook status <username>/<name>.");
346
+ if (!ref || !PULL_REF_RE.test(ref)) throw new UsageError("Name the playbook as <username>/<name>, or <name> for one of your own.");
347
+ const { config, site } = await signedIn();
348
+ const home = homedir();
349
+ const claudeDir = claudeFolder(home);
350
+ await checkClaudeFolder(claudeDir);
351
+ const here = flags.global === true ? null : await findProject(process.cwd(), home);
352
+ if (action === "apply" && flags.global !== true && !here) throw new UsageError("Run playbook apply inside the project's folder, or add --global to apply it in ~/.claude/skills.");
353
+ if (action === "apply" && here) await checkProjectSkills(here.root);
354
+
355
+ const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
356
+ const global = await scanGlobal({ home, claudeDir, claudeJson });
357
+ // The project as a sync reads it: every folder in it Claude Code ran in, as well as this one.
358
+ const known = here ? (await findProjects({ home, claudeDir, claudeJson, cwd: process.cwd() })).find((entry) => entry.root === here.root) : null;
359
+ const folders = here ? [...new Set([here.root, ...(known?.folders ?? []), process.cwd()])] : [];
360
+ const project = here ? await scanProject({ root: here.root, folders, claudeDir, claudeJson, tracked: await trackedFiles(here.root) }) : null;
361
+ const body = { ref, global: global.items, project: project?.items ?? null, updates: action === "status" };
362
+ const answer = readPlaybook(await call(site, config.key, "POST", "/api/v1/playbook", body, VERSION));
363
+
364
+ if (action === "status") {
365
+ if (flags.json) print(JSON.stringify(answer, null, 2));
366
+ else for (const line of describeStatus(answer, here?.name ?? null)) print(line);
367
+ return;
368
+ }
369
+ const caution = cautionLine(answer);
370
+ if (caution) warn(caution);
371
+ const skills = here ? join(here.root, ".claude", "skills") : join(claudeDir, "skills");
372
+ const fetchPull = (pullRef) => call(site, config.key, "GET", `/api/v1/pull?ref=${encodeURIComponent(pullRef)}`, undefined, VERSION);
373
+ // Someone else's playbook writes what its author chose, scripts included: only after a yes.
374
+ const confirm = answer.playbook?.own
375
+ ? null
376
+ : async (files) => {
377
+ if (flags.yes === true) return true;
378
+ if (!interactive()) throw new UsageError("This playbook is someone else's: read what --dry-run lists, then run apply again with --yes.");
379
+ print(`It would write ${files.length === 1 ? "1 file" : `${files.length} files`}:`);
380
+ for (const { path, executable } of files) print(` ${path}${executable ? " (a program it can run)" : ""}`);
381
+ return /^y(es)?$/i.test(await ask("Write them? [y/N] "));
382
+ };
383
+ const { lines, failed } = await applyPlaybook({ answer, skills, force: flags.force === true, dryRun: flags["dry-run"] === true, fetchPull, confirm });
384
+ for (const line of lines) print(line);
385
+ for (const note of failed) warn(`Not written: ${note}`);
386
+ if (failed.length) throw new Error(`${failed.length === 1 ? "1 skill was" : `${failed.length} skills were`} not written; run playbook apply again once the reason above is fixed.`);
387
+ }
388
+
389
+ /** This computer's projects, as the sync finds them. */
390
+ async function knownProjects(home, claudeDir) {
391
+ return findProjects({ home, claudeDir, claudeJson: (await readJson(claudeJsonPath(home))) ?? {}, cwd: process.cwd() });
392
+ }
393
+
394
+ /**
395
+ * Backs up this computer to a private GitHub repository the user owns, with their own git, and
396
+ * reports the commit to the site. The repository is remembered from one run to the next.
397
+ */
398
+ async function backup(flags, print, warn) {
399
+ const home = homedir();
400
+ const claudeDir = claudeFolder(home);
401
+ const dryRun = flags["dry-run"] === true;
402
+ const config = await readConfig();
403
+ if (!config && !dryRun) throw new Error("Run skillcellar login first.");
404
+ const site = config ? siteOrigin(config.site) : null;
405
+ if (config && !site) throw new Error(`The saved site, ${config.site}, is not an https address. Run skillcellar login again.`);
406
+ await checkClaudeFolder(claudeDir);
407
+ const repoValue = flags.repo ?? config?.backup?.repo;
408
+ if (repoValue === undefined) throw new UsageError("Name the private repository the backup goes to with --repo <owner>/<repo>, once; it is remembered.");
409
+ const repo = parseBackupRepo(repoValue, flags.ssh === true || (flags.repo === undefined && config?.backup?.ssh === true));
410
+ if (!repo) throw new UsageError("--repo takes a GitHub repository as <owner>/<repo>, its https address, or its git@github.com address.");
411
+ // The device's slug as the site has it now: a rename changes the folder the backup goes under.
412
+ const device = config
413
+ ? ((await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION)).device ?? config.device)
414
+ : { id: null, slug: "this-computer", name: "this computer" };
415
+ const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
416
+ const projects = await knownProjects(home, claudeDir);
417
+ const allowed = (Array.isArray(flags.allow) ? flags.allow : []).map((path) => path.trim()).filter(Boolean);
418
+ // The repository is remembered as soon as a push has gone through, whatever the site answers after.
419
+ const remember = () => (config ? writeConfig({ ...config, device: { id: device.id, slug: device.slug, name: device.name }, backup: { repo: repo.name, ssh: repo.ssh } }) : Promise.resolve());
420
+ const commit = await runBackup({ repo, device, claudeDir, claudeJson, projects, trackedOf: trackedFiles, version: VERSION, dryRun, allowed, print, onPushed: remember });
421
+ if (dryRun || !commit) return;
422
+ const recorded = await call(site, config.key, "POST", "/api/v1/backup", { device: device.id, repo: repo.name, commit }, VERSION);
423
+ const slug = recorded.device?.slug ?? device.slug;
424
+ if (slug !== device.slug) warn(`The site now calls this computer ${recorded.device.name}; the next backup goes under devices/${slug}.`);
425
+ }
426
+
427
+ /** Carries out this computer's copy tasks from the other computers' backups. */
428
+ async function restore(flags, print, warn) {
429
+ const { config, site } = await signedIn();
430
+ const home = homedir();
431
+ const claudeDir = claudeFolder(home);
432
+ const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
433
+ const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
434
+ const plan = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
435
+ await runRestore({ plan, claudeDir, config, flags, version: VERSION, print, warn });
436
+ }
437
+
290
438
  /** Installs or updates the skillcellar skill without asking: the command itself is the yes. */
291
439
  async function skill(print) {
292
440
  const claudeDir = claudeFolder(homedir());
@@ -296,14 +444,16 @@ async function skill(print) {
296
444
  }
297
445
 
298
446
  /**
299
- * Text as the terminal gets it: without control characters, which a site's answer or a folder's
300
- * name could use to move the cursor or recolour what follows. Line breaks and tabs stay.
447
+ * Text as the terminal gets it: without control or format characters, which a site's answer, a
448
+ * folder's name or a backup's file could use to move the cursor, recolour or reorder what follows.
449
+ * Line breaks and tabs stay.
301
450
  */
451
+ const FORMAT_RE = /\p{Cf}|[\u2028\u2029]/u;
302
452
  const plain = (text) =>
303
453
  Array.from(String(text))
304
454
  .filter((char) => {
305
455
  const code = char.codePointAt(0);
306
- return code === 9 || code === 10 || (code >= 32 && code !== 127 && (code < 128 || code >= 160));
456
+ return code === 9 || code === 10 || (code >= 32 && code !== 127 && (code < 128 || code >= 160) && !FORMAT_RE.test(char));
307
457
  })
308
458
  .join("");
309
459
 
@@ -324,6 +474,12 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
324
474
  json: { type: "boolean" },
325
475
  scope: { type: "string" },
326
476
  force: { type: "boolean" },
477
+ global: { type: "boolean" },
478
+ yes: { type: "boolean" },
479
+ repo: { type: "string" },
480
+ ssh: { type: "boolean" },
481
+ from: { type: "string" },
482
+ allow: { type: "string", multiple: true },
327
483
  help: { type: "boolean", short: "h" },
328
484
  version: { type: "boolean", short: "v" },
329
485
  },
@@ -343,14 +499,18 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
343
499
  return flags.help ? 0 : 2;
344
500
  }
345
501
  try {
346
- // Only pull takes an argument: the skill it installs.
347
- const [ref, ...rest] = command === "pull" ? extra : [undefined, ...extra];
502
+ // Only pull takes an argument, the skill it installs, and playbook two: what to do, and with which playbook.
503
+ const [action, ref, ...rest] = command === "playbook" ? extra : command === "pull" ? [undefined, ...extra] : [undefined, undefined, ...extra];
348
504
  if (rest.length) throw new UsageError(`Unexpected ${rest.join(" ")}.`);
349
505
  if (command === "login") await login(flags, print);
350
506
  else if (command === "sync") await sync(flags, print, warn);
351
507
  else if (command === "plan") await plan(flags, print);
508
+ else if (command === "check") await check(flags, print);
352
509
  else if (command === "pull") await pull(flags, ref, print);
510
+ else if (command === "playbook") await playbook(flags, action, ref, print, warn);
353
511
  else if (command === "skill") await skill(print);
512
+ else if (command === "backup") await backup(flags, print, warn);
513
+ else if (command === "restore") await restore(flags, print, warn);
354
514
  else throw new UsageError(`Unknown command ${command}.`);
355
515
  return 0;
356
516
  } catch (error) {
package/src/config.mjs CHANGED
@@ -14,7 +14,7 @@ export function configPath(env = process.env) {
14
14
  return join(base, "skillcellar", "config.json");
15
15
  }
16
16
 
17
- /** The saved login, or null before the first one. */
17
+ /** The saved login, or null before the first one. A saved backup repository rides along when it is there. */
18
18
  export async function readConfig(path = configPath()) {
19
19
  const config = await readJson(path).catch((error) => {
20
20
  throw new Error(`${error.message} Run skillcellar login to save it again.`);
package/src/files.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readdir, readFile, realpath, stat } from "node:fs/promises";
3
- import { join } from "node:path";
3
+ import { join, relative } from "node:path";
4
4
 
5
5
  // A name the site takes: segments of letters, digits and ._@+-, each starting with a letter or
6
6
  // digit, joined by slashes for a file in a folder.
@@ -22,11 +22,14 @@ async function fileHash(path) {
22
22
  }
23
23
 
24
24
  /** A memory file or an agent as the sync sends it: its name and its SHA-256, never its content. */
25
- async function fileItem(kind, name, path) {
25
+ async function fileItem(kind, name, path, tracked) {
26
26
  const hash = await fileHash(path);
27
- return { kind, name, source: null, skillPath: null, hash, hashKind: hash ? "sha256" : null, version: null };
27
+ return { kind, name, source: null, skillPath: null, hash, hashKind: hash ? "sha256" : null, version: null, tracked };
28
28
  }
29
29
 
30
+ /** Whether a project's git tracks the file: `own` holds the project's root and its tracked paths, or is null outside a project. */
31
+ const trackedBy = (own, path) => (own ? own.tracked.has(relative(own.root, path).split("\\").join("/")) : false);
32
+
30
33
  /**
31
34
  * The `.md` files in a folder and, with `deep`, in the folders inside it, by their path from it.
32
35
  * Each folder is read once, so a link back to one above cannot loop. A folder that is not there
@@ -58,7 +61,7 @@ async function markdownFiles(dir, deep, prefix = "", depth = 0, seen = new Set()
58
61
  * Memory files, each named by the label of the folder it comes from and its path there, such as
59
62
  * rules/common/testing.md; a single file is named as it is, such as CLAUDE.md.
60
63
  */
61
- export async function memoryItems(sources, skipped) {
64
+ export async function memoryItems(sources, skipped, own = null) {
62
65
  const items = [];
63
66
  for (const source of sources) {
64
67
  const files = source.file
@@ -67,7 +70,7 @@ export async function memoryItems(sources, skipped) {
67
70
  : []
68
71
  : (await markdownFiles(source.dir, source.deep)).map((file) => ({ name: `${source.name}/${file.name}`, path: file.path }));
69
72
  for (const file of files) {
70
- if (sendable(file.name)) items.push(await fileItem("memory", file.name, file.path));
73
+ if (sendable(file.name)) items.push(await fileItem("memory", file.name, file.path, trackedBy(own, file.path)));
71
74
  else skipped.push(file.name);
72
75
  }
73
76
  }
@@ -75,12 +78,15 @@ export async function memoryItems(sources, skipped) {
75
78
  }
76
79
 
77
80
  /** The agents in a folder and the folders inside it, as Claude Code reads them: each `.md` file, by its path from the folder without `.md`. */
78
- export async function agentItems(dir, skipped) {
81
+ export async function agentItems(dir, skipped, own = null) {
79
82
  const items = [];
80
83
  for (const file of await markdownFiles(dir, true)) {
81
84
  const name = file.name.slice(0, -".md".length);
82
- if (sendable(name)) items.push(await fileItem("agent", name, file.path));
85
+ if (sendable(name)) items.push(await fileItem("agent", name, file.path, trackedBy(own, file.path)));
83
86
  else skipped.push(file.name);
84
87
  }
85
88
  return items;
86
89
  }
90
+
91
+ /** The `.md` files of a folder, for a backup: the same files a sync names, with their paths. */
92
+ export { markdownFiles };
package/src/mcp.mjs CHANGED
@@ -89,7 +89,7 @@ export function mcpItem(name, server) {
89
89
  } else {
90
90
  return null;
91
91
  }
92
- return { kind: "mcp", name, source: null, skillPath: null, hash: null, hashKind: null, version: null, transport, target };
92
+ return { kind: "mcp", name, source: null, skillPath: null, hash: null, hashKind: null, version: null, transport, target, tracked: false };
93
93
  }
94
94
 
95
95
  /** The MCP servers of one `mcpServers` object, by name; the names it cannot send go to `skipped`. */
package/src/plan.mjs CHANGED
@@ -4,7 +4,7 @@ import { sessionFolderName } from "./places.mjs";
4
4
  import { readJson } from "./scan.mjs";
5
5
 
6
6
  /** A name's slug as the site makes it: lowercase letters and digits with single hyphens between. */
7
- const toSlug = (name) =>
7
+ export const toSlug = (name) =>
8
8
  name
9
9
  .toLowerCase()
10
10
  .replace(/[^a-z0-9]+/g, "-")
@@ -12,14 +12,16 @@ const toSlug = (name) =>
12
12
  .slice(0, 64)
13
13
  .replace(/-+$/, "");
14
14
 
15
+ /** A project's slug as the site makes it: its name's, and for a folder with no git remote that followed by the device's. */
16
+ export const projectSlug = (project, deviceSlug) => (project.remote ? toSlug(project.name) : `${toSlug(project.name)}-${deviceSlug}`);
17
+
15
18
  /**
16
19
  * The folder on this computer of a task's project, from the projects the sync finds: one with a
17
20
  * GitHub repository by that repository, any other by its slug, which for a folder with no git remote
18
21
  * ends with this device's. With several checkouts, the first; null when this computer has none.
19
22
  */
20
23
  export function projectFolder(project, projects, deviceSlug) {
21
- const slug = (known) => (known.remote ? toSlug(known.name) : `${toSlug(known.name)}-${deviceSlug}`);
22
- const matches = projects.filter((known) => (project.repo ? known.repo === project.repo : slug(known) === project.slug));
24
+ const matches = projects.filter((known) => (project.repo ? known.repo === project.repo : projectSlug(known, deviceSlug) === project.slug));
23
25
  return matches.map((known) => known.root).sort()[0] ?? null;
24
26
  }
25
27
 
@@ -43,11 +45,11 @@ function inside(base, path) {
43
45
  }
44
46
 
45
47
  /**
46
- * Whether a removal names an item the way the sync sends it from that place (scan.mjs): a skill,
47
- * plugin or MCP server by one name, an agent by its path in its folder, and a memory file only from
48
- * the places the sync reads. A plan naming anything else did not come from a sync of this computer.
48
+ * Whether a removal or a copy names an item the way the sync sends it from that place (scan.mjs): a
49
+ * skill, plugin or MCP server by one name, an agent by its path in its folder, and a memory file only
50
+ * from the places the sync reads. A plan naming anything else did not come from a sync of this computer.
49
51
  */
50
- function sentName(kind, name, project) {
52
+ export function sentName(kind, name, project) {
51
53
  if (!SENT_NAME_RE.test(name)) return false;
52
54
  if (kind === "agent") return true;
53
55
  if (kind !== "memory") return !name.includes("/");
@@ -114,24 +116,44 @@ export async function localPlan(plan, { claudeDir, projects, deviceSlug }) {
114
116
 
115
117
  const placeOf = (task) => (task.project ? `in ${task.project.name}${task.folder ? ` (${task.folder})` : ""}` : "globally");
116
118
 
119
+ /** One line per item a copy still needs: from the backup's files, a command, or the repository a clone brings it with. */
120
+ function copyLines(copy) {
121
+ return copy.items.map((item) => {
122
+ const how = item.tracked ? "tracked by the project's git: pull the repository" : item.install ? item.install.command : item.kind === "mcp" ? "from the backup's MCP config (claude mcp add-json)" : "from the backup";
123
+ return ` - ${item.kind} ${item.name}: ${how}`;
124
+ });
125
+ }
126
+
117
127
  /** The plan as lines a person reads. */
118
128
  export function describePlan(plan) {
119
129
  const { tasks } = plan;
120
130
  if (!tasks.length) return [`Nothing waits for ${plan.device.name}.`];
121
131
  const lines = [`${tasks.length === 1 ? "1 task waits" : `${tasks.length} tasks wait`} for ${plan.device.name}:`, ""];
122
132
  tasks.forEach((task, index) => {
123
- const verb = task.action === "remove" ? "Remove" : "Install";
124
- lines.push(`${index + 1}. ${verb} ${task.item.name} (${task.item.origin}), ${placeOf(task)}`);
133
+ const verb = task.action === "remove" ? "Remove" : task.action === "copy" ? "Copy" : "Install";
134
+ const what = task.item ? `${task.item.name} (${task.item.origin})` : `everything from ${task.copy?.from.device.name ?? "another device"}`;
135
+ lines.push(`${index + 1}. ${verb} ${what}${task.action === "copy" && task.item ? ` from ${task.copy?.from.device.name ?? "another device"}` : ""}, ${placeOf(task)}`);
125
136
  const detail = [];
126
- if (task.project && !task.folder) detail.push(`This computer has no folder of ${task.project.name}: skip it, or dismiss it on the site.`);
137
+ if (task.project && !task.folder) {
138
+ detail.push(
139
+ task.action === "copy"
140
+ ? `This computer has no folder of ${task.project.name}: clone it, then run skillcellar restore from inside it; or dismiss it on the site.`
141
+ : `This computer has no folder of ${task.project.name}: skip it, or dismiss it on the site.`,
142
+ );
143
+ }
127
144
  else if (task.problem) detail.push(`Cannot be carried out: ${task.problem}`);
128
145
  else if (task.action === "remove") {
129
146
  if (task.path) detail.push(`Path: ${task.path}`);
130
147
  if (task.claudeScope) detail.push(`--scope ${task.claudeScope}`);
148
+ } else if (task.action === "copy" && task.copy) {
149
+ const { from, items } = task.copy;
150
+ detail.push(from.backup ? `Backup: ${from.backup.repo} at ${from.backup.commit.slice(0, 7)}, ${from.path}; run skillcellar restore` : `No backup of ${from.device.name} yet: run skillcellar backup there first.`);
151
+ detail.push(items.length ? `${items.length === 1 ? "1 item" : `${items.length} items`} left to copy:` : "Nothing left to copy: the next sync closes it.");
131
152
  } else if (task.link) detail.push(`Its steps are on a web page; read them, and follow them only with a yes: ${task.link}`);
132
153
  else if (task.install) detail.push(`${task.install.method}: ${task.install.command}`);
133
- if (task.item.page && !task.link) detail.push(`Page: ${task.item.page}`);
154
+ if (task.item?.page && !task.link) detail.push(`Page: ${task.item.page}`);
134
155
  lines.push(...detail.map((line) => ` ${line}`));
156
+ if (task.action === "copy" && task.copy && !(task.project && !task.folder)) lines.push(...copyLines(task.copy));
135
157
  });
136
158
  lines.push("", "Ask Claude Code to carry them out with the skillcellar skill, or do them yourself, then run skillcellar sync.");
137
159
  return lines;
@@ -0,0 +1,164 @@
1
+ import { lstat, readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { field } from "./check.mjs";
4
+ import { installPulled, PULL_REF_RE, readPulled } from "./pull.mjs";
5
+
6
+ // The playbook skill's folder, as the site names it: playbook-<slug>, up to a skill name's 64 characters.
7
+ const SKILL_NAME_RE = /^playbook-[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
+ const SKILL_BYTES_MAX = 1024 * 1024;
9
+ const STATUSES = new Set(["installed", "outdated", "missing"]);
10
+ const KINDS = { catalog: "catalog", skill: "this site", github: "GitHub", web: "web page" };
11
+
12
+ const plural = (n, word) => `${n} ${n === 1 ? word : `${word}s`}`;
13
+ const label = (skill) => `${field(skill.name, 80)} (${KINDS[skill.kind] ?? "skill"})`;
14
+ const placeOf = (skill) => (skill.where === "project" ? "in the project" : "globally");
15
+
16
+ /** The site's answer, checked before anything is written or printed: the playbook skill's name and text, and its skills. */
17
+ export function readPlaybook(answer) {
18
+ const skill = answer?.skill;
19
+ const skills = Array.isArray(answer?.skills) ? answer.skills : null;
20
+ const valid =
21
+ typeof skill?.name === "string" &&
22
+ skill.name.length <= 64 &&
23
+ SKILL_NAME_RE.test(skill.name) &&
24
+ typeof skill.content === "string" &&
25
+ Buffer.byteLength(skill.content) <= SKILL_BYTES_MAX &&
26
+ (answer.installPrompt === null || typeof answer.installPrompt === "string") &&
27
+ skills?.every((entry) => entry && STATUSES.has(entry.status) && (entry.pull === null || (typeof entry.pull === "string" && PULL_REF_RE.test(entry.pull))));
28
+ if (!valid) throw new Error("The site's answer is not a playbook this command can apply.");
29
+ return answer;
30
+ }
31
+
32
+ /** For someone else's playbook, the line that says nobody has reviewed it; null for one's own. */
33
+ export function cautionLine(answer) {
34
+ if (answer.playbook?.own) return null;
35
+ return `${field(answer.playbook?.name, 80)} is published by ${field(answer.playbook?.author, 40)}, not curated: nobody at the site has reviewed it. Read each step before you let Claude Code run it.`;
36
+ }
37
+
38
+ /**
39
+ * Applies a playbook into the skills folder `skills`: writes the playbook skill, and pulls the
40
+ * skills of the site it uses that the computer lacks, each checked as `pull` checks it and written
41
+ * into a hidden folder first, then moved into place. A folder already there is kept unless `force`.
42
+ * Nothing it writes leaves `skills`. With `dryRun`, it lists the files it would write and writes
43
+ * none; otherwise `confirm`, when given, sees each file first and can refuse them all. Answers the
44
+ * lines to print and the pulls and writes that failed.
45
+ */
46
+ export async function applyPlaybook({ answer, skills, force, dryRun, fetchPull, confirm = null }) {
47
+ const lines = [];
48
+ const writes = [];
49
+ const kept = [];
50
+ const failed = [];
51
+
52
+ const folder = join(skills, answer.skill.name);
53
+ const there = await lstat(folder).catch(() => null);
54
+ // Up to date is the folder apply writes, as status hashes it: that SKILL.md alone, read only as a
55
+ // file of a skill's size, since a checkout can put a link to a device or a pipe there.
56
+ const only = there?.isDirectory() ? (await readdir(folder).catch(() => [])).join("/") === "SKILL.md" : false;
57
+ const file = only ? await lstat(join(folder, "SKILL.md")).catch(() => null) : null;
58
+ const current = file?.isFile() && file.size <= SKILL_BYTES_MAX ? await readFile(join(folder, "SKILL.md"), "utf8").catch(() => null) : null;
59
+ if (current === answer.skill.content) lines.push(`The playbook skill in ${folder} is up to date.`);
60
+ else if (there && !force) kept.push(`${folder}, which differs from the playbook on the site; run playbook apply again with --force to replace it`);
61
+ else {
62
+ const files = [{ segments: ["SKILL.md"], content: Buffer.from(answer.skill.content), executable: false }];
63
+ writes.push({ ref: null, pulled: { slug: answer.skill.name, version: null, files }, replace: !!there });
64
+ }
65
+
66
+ // Each folder is written once: a pulled skill never takes the playbook skill's place, or another's.
67
+ const folders = new Set([answer.skill.name]);
68
+ for (const skill of answer.skills) {
69
+ if (skill.status !== "missing" || !skill.pull || skill.forSale) continue;
70
+ let pulled;
71
+ try {
72
+ pulled = readPulled(await fetchPull(skill.pull));
73
+ } catch (error) {
74
+ failed.push(`${skill.pull}: ${field(error.message, 300)}`);
75
+ continue;
76
+ }
77
+ if (folders.has(pulled.slug)) {
78
+ failed.push(`${skill.pull}: its folder, ${pulled.slug}, is another of this playbook's.`);
79
+ continue;
80
+ }
81
+ folders.add(pulled.slug);
82
+ const target = join(skills, pulled.slug);
83
+ const exists = await lstat(target).catch(() => null);
84
+ if (exists && !force) kept.push(`${target}, which is there already; run playbook apply again with --force to replace it`);
85
+ else writes.push({ ref: skill.pull, pulled, replace: !!exists });
86
+ }
87
+
88
+ const files = writes.flatMap(({ pulled }) => pulled.files.map((entry) => ({ path: join(skills, pulled.slug, ...entry.segments), executable: entry.executable })));
89
+ if (dryRun) {
90
+ for (const { pulled, replace } of writes) if (replace) lines.push(`Would replace ${join(skills, pulled.slug)} and everything in it.`);
91
+ lines.push(files.length ? `Would write ${plural(files.length, "file")}:` : "Would write nothing.", ...files.map(({ path }) => ` ${path}`));
92
+ } else if (files.length && confirm && !(await confirm(files))) {
93
+ return { lines: ["Nothing written."], failed };
94
+ } else {
95
+ for (const { ref, pulled, replace } of writes) {
96
+ try {
97
+ const target = await installPulled(skills, pulled, replace);
98
+ lines.push(ref ? `Pulled ${ref}, version ${field(pulled.version, 20)}, into ${target}.` : `Wrote the playbook skill into ${target}.`);
99
+ } catch (error) {
100
+ failed.push(`${ref ?? "the playbook skill"}: ${field(error.message, 300)}`);
101
+ }
102
+ }
103
+ }
104
+ for (const note of kept) lines.push(`Kept ${note}.`);
105
+
106
+ const here = answer.skills.filter((skill) => skill.status !== "missing");
107
+ if (here.length) lines.push(`Already on this computer: ${here.map((skill) => `${field(skill.name, 80)}${skill.where === "global" ? " (globally)" : ""}`).join(", ")}.`);
108
+ for (const skill of answer.skills.filter((entry) => entry.status === "missing" && entry.kind === "skill" && !entry.pull)) {
109
+ lines.push(
110
+ skill.forSale
111
+ ? `Requires purchase: ${field(skill.name, 80)}. Buy it at ${field(skill.page, 200) || "its page"}, then run playbook apply again.`
112
+ : `Not pulled: ${field(skill.name, 80)}, which the site names no way to pull. ${field(skill.install, 300)}.`,
113
+ );
114
+ }
115
+ const elsewhere = answer.skills.filter((skill) => skill.status === "missing" && skill.kind !== "skill").length;
116
+ if (answer.installPrompt && elsewhere) {
117
+ lines.push(
118
+ "",
119
+ `${elsewhere === 1 ? "1 of its skills comes" : `${elsewhere} of its skills come`} from elsewhere, and this command never runs their installers. Paste this prompt into Claude Code to install ${elsewhere === 1 ? "it" : "them"}:`,
120
+ "",
121
+ answer.installPrompt,
122
+ );
123
+ }
124
+ return { lines, failed };
125
+ }
126
+
127
+ /** The site's check of a playbook against this computer, as lines a person reads. `project` names the project the command ran in. */
128
+ export function describeStatus(answer, project) {
129
+ const skills = answer.skills;
130
+ const ref = field(answer.playbook?.ref, 120);
131
+ const outdated = skills.filter((skill) => skill.status === "outdated");
132
+ const missing = skills.filter((skill) => skill.status === "missing");
133
+ const current = skills.filter((skill) => skill.status === "installed");
134
+ const where = project ? `in ${field(project, 80)} and globally` : "globally";
135
+ const lines = [
136
+ `${field(answer.playbook?.name, 80)} (${ref}) on this computer, ${where}: ${skills.length - missing.length} of ${plural(skills.length, "skill")} installed${outdated.length ? `, ${outdated.length} of them outdated` : ""}.`,
137
+ ];
138
+ const state = answer.skill?.state;
139
+ lines.push(
140
+ state === "current"
141
+ ? "The playbook skill is installed."
142
+ : state === "other"
143
+ ? `The playbook skill here is another copy, older or edited: skillcellar playbook apply ${ref} --force replaces it.`
144
+ : `The playbook skill is not installed: skillcellar playbook apply ${ref} writes it.`,
145
+ );
146
+ if (current.length) lines.push("", "Installed:", ...current.map((skill) => `- ${label(skill)}, ${placeOf(skill)}`));
147
+ if (outdated.length) {
148
+ lines.push("", "Outdated:", ...outdated.map((skill) => `- ${label(skill)}, ${placeOf(skill)}${skill.update ? `: ${field(skill.update, 300)}` : ""}`));
149
+ }
150
+ if (missing.length) {
151
+ lines.push(
152
+ "",
153
+ "Missing:",
154
+ ...missing.map((skill) =>
155
+ skill.forSale
156
+ ? `- ${label(skill)}: requires purchase at ${field(skill.page, 200) || "its page"}`
157
+ : skill.pull
158
+ ? `- ${label(skill)}: skillcellar playbook apply ${ref} pulls it`
159
+ : `- ${label(skill)}: ${field(skill.install, 300)}`,
160
+ ),
161
+ );
162
+ }
163
+ return lines;
164
+ }