skillcellar 0.3.0 → 0.6.1

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/check.mjs ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * A field of the site's answer as one line of the terminal: whatever the site, or a marketplace
3
+ * through it, put there, it cannot forge a line of this output. Line breaks become spaces.
4
+ */
5
+ export const field = (value, max = 160) =>
6
+ String(value ?? "")
7
+ .replace(/\s+/g, " ")
8
+ .trim()
9
+ .slice(0, max);
10
+
11
+ const placeOf = (item) => (item.project ? `in ${field(item.project.name, 80) || "a project"}` : "globally");
12
+
13
+ /** A hash cut to what tells it apart in a line; a version as it is. */
14
+ const short = (value) => (typeof value === "string" && /^[0-9a-f]{40,64}$/.test(value) ? value.slice(0, 7) : field(value, 64));
15
+
16
+ /** What moved, in a few words: a folder's tree, a marketplace's version, or a version on the site. */
17
+ function moved(item) {
18
+ if (item.kind === "plugin") return `${short(item.installed)} here, ${short(item.latest)} in its marketplace`;
19
+ if (item.update?.method === "pull") return "an older version of the skill on the site";
20
+ return `${short(item.installed)} here, ${short(item.latest)} in ${field(item.source) || "its repository"}`;
21
+ }
22
+
23
+ /** When the site last heard from the computer, as the check reads it, to the minute. */
24
+ const syncedLine = (check) => {
25
+ const at = typeof check.syncedAt === "string" ? new Date(check.syncedAt) : null;
26
+ return at && !Number.isNaN(at.getTime()) ? `As of the last sync, ${at.toISOString().slice(0, 16).replace("T", " ")} UTC; run skillcellar sync first for the computer as it is now.` : null;
27
+ };
28
+
29
+ /** The site's check as lines a person reads: what is outdated, with its command, then what could not be checked. */
30
+ export function describeCheck(check) {
31
+ const items = Array.isArray(check.items) ? check.items : [];
32
+ const name = field(check.device?.name, 80) || "this computer";
33
+ if (!items.length) return [`Nothing on ${name} to check: no skill or plugin whose source the site can read.`];
34
+ const outdated = items.filter((item) => item?.status === "outdated");
35
+ const unknown = items.filter((item) => item?.status === "unknown");
36
+ const current = items.filter((item) => item?.status === "current").length;
37
+ const lines = [];
38
+ if (!outdated.length) lines.push(`Nothing outdated on ${name}: ${current} ${current === 1 ? "item is" : "items are"} current.`);
39
+ else {
40
+ lines.push(`${outdated.length} of ${items.length} checked ${items.length === 1 ? "item" : "items"} on ${name} ${outdated.length === 1 ? "is" : "are"} outdated:`, "");
41
+ outdated.forEach((item, index) => {
42
+ lines.push(`${index + 1}. ${field(item.kind, 20)} ${field(item.name)}, ${placeOf(item)}: ${moved(item)}`);
43
+ if (item.update?.command) {
44
+ const where = item.project ? `inside ${field(item.project.name, 80) || "the project"}` : "";
45
+ const warning = item.update.method === "pull" ? "replaces the folder with the site's latest version" : "";
46
+ const notes = [where, warning].filter(Boolean).join("; ");
47
+ lines.push(` ${field(item.update.command, 300)}${notes ? ` (${notes})` : ""}`);
48
+ }
49
+ });
50
+ lines.push("", `${current} ${current === 1 ? "item is" : "items are"} current.`);
51
+ }
52
+ if (unknown.length) {
53
+ lines.push("", `${unknown.length} could not be checked:`);
54
+ for (const item of unknown) lines.push(`- ${field(item.kind, 20)} ${field(item.name)}, ${placeOf(item)}: ${field(item.note) || "no way to compare it"}`);
55
+ }
56
+ const synced = syncedLine(check);
57
+ if (synced) lines.push("", synced);
58
+ return lines;
59
+ }
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
- import { describePlan, localPlan } from "./plan.mjs";
9
- import { findProject } from "./project.mjs";
10
+ import { describePlan, localPlan, projectIdentity } from "./plan.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
+ and once pushed is remembered while the file stays the same; --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);
@@ -158,12 +202,10 @@ function summary(payload, result, deviceName, whole) {
158
202
  */
159
203
  const claudeJsonPath = (home) => (process.env.CLAUDE_CONFIG_DIR?.trim() ? join(process.env.CLAUDE_CONFIG_DIR.trim(), ".claude.json") : join(home, ".claude.json"));
160
204
 
161
- /** What makes checkouts one project on the site: their GitHub repository, else their name. */
162
- const identity = (project) => project.repo ?? project.name.toLowerCase();
163
-
164
205
  /** The tasks a sync closed, in one line. */
165
206
  function closedLine(closed) {
166
- const done = closed.map((task) => `${task.action === "remove" ? "removed" : "installed"} ${task.name} ${task.project ? `in ${task.project}` : "globally"}`);
207
+ const verb = (task) => (task.action === "remove" ? "removed" : task.action === "copy" ? "copied" : "installed");
208
+ const done = closed.map((task) => `${verb(task)} ${task.name ?? "everything"} ${task.project ? `in ${task.project}` : "globally"}`);
167
209
  return `Closed ${plural(closed.length, "task")}: ${done.join("; ")}.`;
168
210
  }
169
211
 
@@ -176,9 +218,7 @@ async function sync(flags, print, warn) {
176
218
  const site = config ? siteOrigin(config.site) : null;
177
219
  if (config && !site) throw new Error(`The saved site, ${config.site}, is not an https address. Run skillcellar login again.`);
178
220
  // 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
- }
221
+ await checkClaudeFolder(claudeDir);
182
222
 
183
223
  const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
184
224
  const cwd = process.cwd();
@@ -189,7 +229,7 @@ async function sync(flags, print, warn) {
189
229
  if (!here) throw new UsageError("Run sync --project inside the project's folder, not in your home folder.");
190
230
  if (!/[A-Za-z0-9]/.test(flags.project)) throw new UsageError("--project needs a letter a–z or a digit 0–9.");
191
231
  // Every checkout of the same repository is one project on the site, so all of them go together.
192
- const same = projects.filter((known) => known.root === here.root || (here.remote && known.remote && identity(known) === identity(here)));
232
+ const same = projects.filter((known) => known.root === here.root || (here.remote && known.remote && projectIdentity(known) === projectIdentity(here)));
193
233
  projects = (same.length ? same : [{ ...here, folders: new Set([cwd]) }]).map((known) => ({ ...known, name: flags.project }));
194
234
  }
195
235
 
@@ -205,11 +245,11 @@ async function sync(flags, print, warn) {
205
245
  }
206
246
  let scanned;
207
247
  try {
208
- scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson });
248
+ scanned = await scanProject({ root: project.root, folders: [...project.folders], claudeDir, claudeJson, tracked: await trackedFiles(project.root) });
209
249
  } catch (error) {
210
250
  // One project's broken file stops only that project in a sync of the whole computer.
211
251
  if (!whole) throw error;
212
- unread.add(identity(project));
252
+ unread.add(projectIdentity(project));
213
253
  warn(`Not reported: ${project.name}, because ${error.message} The site keeps what it had there until a sync can read it.`);
214
254
  continue;
215
255
  }
@@ -223,7 +263,7 @@ async function sync(flags, print, warn) {
223
263
  // one as it was and closes no task there; an empty place is then sent, to empty it. Another
224
264
  // checkout of the unread project is left out too, or it would replace the project on its own.
225
265
  const complete = whole && !unread.size;
226
- if (!complete) places = [...places, ...empty].filter((place) => !unread.has(identity(place.project)));
266
+ if (!complete) places = [...places, ...empty].filter((place) => !unread.has(projectIdentity(place.project)));
227
267
  if (places.length > PROJECTS_PER_SYNC) {
228
268
  throw new Error(`This computer has ${places.length} projects to send, and one sync takes ${PROJECTS_PER_SYNC}. Sync each one you need from its folder with --project <name>.`);
229
269
  }
@@ -260,15 +300,21 @@ async function plan(flags, print) {
260
300
  const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
261
301
  const home = homedir();
262
302
  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
- : [];
303
+ const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
266
304
  // The device's slug as the site has it now: a rename changes the slugs of its folders' projects.
267
305
  const local = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
268
306
  if (flags.json) print(JSON.stringify(local, null, 2));
269
307
  else for (const line of describePlan(local)) print(line);
270
308
  }
271
309
 
310
+ /** Prints which of this computer's skills and plugins their sources have moved past, as the site checked them. */
311
+ async function check(flags, print) {
312
+ const { config, site } = await signedIn();
313
+ const answer = await call(site, config.key, "GET", `/api/v1/check?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
314
+ if (flags.json) print(JSON.stringify(answer, null, 2));
315
+ else for (const line of describeCheck(answer)) print(line);
316
+ }
317
+
272
318
  /** Installs a skill from the site into the global skills folder, or the project's. */
273
319
  async function pull(flags, ref, print) {
274
320
  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 +333,111 @@ async function pull(flags, ref, print) {
287
333
  print(`Pulled ${ref}, version ${pulled.version}, into ${await installPulled(skills, pulled, flags.force === true)}.`);
288
334
  }
289
335
 
336
+ /**
337
+ * Applies a playbook to the project the command runs in, or globally, or prints where this
338
+ * computer stands with it. Either way the site checks it against what this computer has now,
339
+ * scanned as a sync scans it, and keeps none of it.
340
+ */
341
+ async function playbook(flags, action, ref, print, warn) {
342
+ if (action !== "apply" && action !== "status") throw new UsageError("Run playbook apply <username>/<name>, or playbook status <username>/<name>.");
343
+ if (!ref || !PULL_REF_RE.test(ref)) throw new UsageError("Name the playbook as <username>/<name>, or <name> for one of your own.");
344
+ const { config, site } = await signedIn();
345
+ const home = homedir();
346
+ const claudeDir = claudeFolder(home);
347
+ await checkClaudeFolder(claudeDir);
348
+ const here = flags.global === true ? null : await findProject(process.cwd(), home);
349
+ 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.");
350
+ if (action === "apply" && here) await checkProjectSkills(here.root);
351
+
352
+ const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
353
+ const global = await scanGlobal({ home, claudeDir, claudeJson });
354
+ // The project as a sync reads it: every folder in it Claude Code ran in, as well as this one.
355
+ const known = here ? (await findProjects({ home, claudeDir, claudeJson, cwd: process.cwd() })).find((entry) => entry.root === here.root) : null;
356
+ const folders = here ? [...new Set([here.root, ...(known?.folders ?? []), process.cwd()])] : [];
357
+ const project = here ? await scanProject({ root: here.root, folders, claudeDir, claudeJson, tracked: await trackedFiles(here.root) }) : null;
358
+ const body = { ref, global: global.items, project: project?.items ?? null, updates: action === "status" };
359
+ const answer = readPlaybook(await call(site, config.key, "POST", "/api/v1/playbook", body, VERSION));
360
+
361
+ if (action === "status") {
362
+ if (flags.json) print(JSON.stringify(answer, null, 2));
363
+ else for (const line of describeStatus(answer, here?.name ?? null)) print(line);
364
+ return;
365
+ }
366
+ const caution = cautionLine(answer);
367
+ if (caution) warn(caution);
368
+ const skills = here ? join(here.root, ".claude", "skills") : join(claudeDir, "skills");
369
+ const fetchPull = (pullRef) => call(site, config.key, "GET", `/api/v1/pull?ref=${encodeURIComponent(pullRef)}`, undefined, VERSION);
370
+ // Someone else's playbook writes what its author chose, scripts included: only after a yes.
371
+ const confirm = answer.playbook?.own
372
+ ? null
373
+ : async (files) => {
374
+ if (flags.yes === true) return true;
375
+ if (!interactive()) throw new UsageError("This playbook is someone else's: read what --dry-run lists, then run apply again with --yes.");
376
+ print(`It would write ${files.length === 1 ? "1 file" : `${files.length} files`}:`);
377
+ for (const { path, executable } of files) print(` ${path}${executable ? " (a program it can run)" : ""}`);
378
+ return /^y(es)?$/i.test(await ask("Write them? [y/N] "));
379
+ };
380
+ const { lines, failed } = await applyPlaybook({ answer, skills, force: flags.force === true, dryRun: flags["dry-run"] === true, fetchPull, confirm });
381
+ for (const line of lines) print(line);
382
+ for (const note of failed) warn(`Not written: ${note}`);
383
+ 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.`);
384
+ }
385
+
386
+ /** This computer's projects, as the sync finds them. */
387
+ async function knownProjects(home, claudeDir) {
388
+ return findProjects({ home, claudeDir, claudeJson: (await readJson(claudeJsonPath(home))) ?? {}, cwd: process.cwd() });
389
+ }
390
+
391
+ /**
392
+ * Backs up this computer to a private GitHub repository the user owns, with their own git, and
393
+ * reports the commit to the site. The repository is remembered from one run to the next.
394
+ */
395
+ async function backup(flags, print, warn) {
396
+ const home = homedir();
397
+ const claudeDir = claudeFolder(home);
398
+ const dryRun = flags["dry-run"] === true;
399
+ const config = await readConfig();
400
+ if (!config && !dryRun) throw new Error("Run skillcellar login first.");
401
+ const site = config ? siteOrigin(config.site) : null;
402
+ if (config && !site) throw new Error(`The saved site, ${config.site}, is not an https address. Run skillcellar login again.`);
403
+ await checkClaudeFolder(claudeDir);
404
+ const repoValue = flags.repo ?? config?.backup?.repo;
405
+ if (repoValue === undefined) throw new UsageError("Name the private repository the backup goes to with --repo <owner>/<repo>, once; it is remembered.");
406
+ const repo = parseBackupRepo(repoValue, flags.ssh === true || (flags.repo === undefined && config?.backup?.ssh === true));
407
+ if (!repo) throw new UsageError("--repo takes a GitHub repository as <owner>/<repo>, its https address, or its git@github.com address.");
408
+ // The device's slug as the site has it now: a rename changes the folder the backup goes under.
409
+ const device = config
410
+ ? ((await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION)).device ?? config.device)
411
+ : { id: null, slug: "this-computer", name: "this computer" };
412
+ const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
413
+ const projects = await knownProjects(home, claudeDir);
414
+ const allowed = (Array.isArray(flags.allow) ? flags.allow : []).map((path) => path.trim()).filter(Boolean);
415
+ // The repository is remembered as soon as a push has gone through, whatever the site answers after,
416
+ // with the files the user allowed despite a finding, each by the hash of what was allowed.
417
+ const remember = (commit, passed = []) => {
418
+ if (!config) return Promise.resolve();
419
+ const kept = passed.length ? { allowed: passed.map(({ file, sha256 }) => ({ file, sha256 })) } : {};
420
+ return writeConfig({ ...config, device: { id: device.id, slug: device.slug, name: device.name }, backup: { repo: repo.name, ssh: repo.ssh, ...kept } });
421
+ };
422
+ const remembered = Array.isArray(config?.backup?.allowed) ? config.backup.allowed : [];
423
+ const commit = await runBackup({ repo, device, claudeDir, claudeJson, projects, trackedOf: trackedFiles, version: VERSION, dryRun, allowed, remembered, print, onPushed: remember });
424
+ if (dryRun || !commit) return;
425
+ const recorded = await call(site, config.key, "POST", "/api/v1/backup", { device: device.id, repo: repo.name, commit }, VERSION);
426
+ const slug = recorded.device?.slug ?? device.slug;
427
+ if (slug !== device.slug) warn(`The site now calls this computer ${recorded.device.name}; the next backup goes under devices/${slug}.`);
428
+ }
429
+
430
+ /** Carries out this computer's copy tasks from the other computers' backups. */
431
+ async function restore(flags, print, warn) {
432
+ const { config, site } = await signedIn();
433
+ const home = homedir();
434
+ const claudeDir = claudeFolder(home);
435
+ const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
436
+ const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
437
+ const plan = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
438
+ await runRestore({ plan, claudeDir, config, flags, version: VERSION, print, warn });
439
+ }
440
+
290
441
  /** Installs or updates the skillcellar skill without asking: the command itself is the yes. */
291
442
  async function skill(print) {
292
443
  const claudeDir = claudeFolder(homedir());
@@ -296,14 +447,16 @@ async function skill(print) {
296
447
  }
297
448
 
298
449
  /**
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.
450
+ * Text as the terminal gets it: without control or format characters, which a site's answer, a
451
+ * folder's name or a backup's file could use to move the cursor, recolour or reorder what follows.
452
+ * Line breaks and tabs stay.
301
453
  */
454
+ const FORMAT_RE = /\p{Cf}|[\u2028\u2029]/u;
302
455
  const plain = (text) =>
303
456
  Array.from(String(text))
304
457
  .filter((char) => {
305
458
  const code = char.codePointAt(0);
306
- return code === 9 || code === 10 || (code >= 32 && code !== 127 && (code < 128 || code >= 160));
459
+ return code === 9 || code === 10 || (code >= 32 && code !== 127 && (code < 128 || code >= 160) && !FORMAT_RE.test(char));
307
460
  })
308
461
  .join("");
309
462
 
@@ -324,6 +477,12 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
324
477
  json: { type: "boolean" },
325
478
  scope: { type: "string" },
326
479
  force: { type: "boolean" },
480
+ global: { type: "boolean" },
481
+ yes: { type: "boolean" },
482
+ repo: { type: "string" },
483
+ ssh: { type: "boolean" },
484
+ from: { type: "string" },
485
+ allow: { type: "string", multiple: true },
327
486
  help: { type: "boolean", short: "h" },
328
487
  version: { type: "boolean", short: "v" },
329
488
  },
@@ -343,14 +502,18 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
343
502
  return flags.help ? 0 : 2;
344
503
  }
345
504
  try {
346
- // Only pull takes an argument: the skill it installs.
347
- const [ref, ...rest] = command === "pull" ? extra : [undefined, ...extra];
505
+ // Only pull takes an argument, the skill it installs, and playbook two: what to do, and with which playbook.
506
+ const [action, ref, ...rest] = command === "playbook" ? extra : command === "pull" ? [undefined, ...extra] : [undefined, undefined, ...extra];
348
507
  if (rest.length) throw new UsageError(`Unexpected ${rest.join(" ")}.`);
349
508
  if (command === "login") await login(flags, print);
350
509
  else if (command === "sync") await sync(flags, print, warn);
351
510
  else if (command === "plan") await plan(flags, print);
511
+ else if (command === "check") await check(flags, print);
352
512
  else if (command === "pull") await pull(flags, ref, print);
513
+ else if (command === "playbook") await playbook(flags, action, ref, print, warn);
353
514
  else if (command === "skill") await skill(print);
515
+ else if (command === "backup") await backup(flags, print, warn);
516
+ else if (command === "restore") await restore(flags, print, warn);
354
517
  else throw new UsageError(`Unknown command ${command}.`);
355
518
  return 0;
356
519
  } 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,19 @@ 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
+
18
+ /** What makes checkouts one project on the site: their GitHub repository, else their name. */
19
+ export const projectIdentity = (project) => project.repo ?? project.name.toLowerCase();
20
+
15
21
  /**
16
22
  * The folder on this computer of a task's project, from the projects the sync finds: one with a
17
23
  * GitHub repository by that repository, any other by its slug, which for a folder with no git remote
18
24
  * ends with this device's. With several checkouts, the first; null when this computer has none.
19
25
  */
20
26
  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));
27
+ const matches = projects.filter((known) => (project.repo ? known.repo === project.repo : projectSlug(known, deviceSlug) === project.slug));
23
28
  return matches.map((known) => known.root).sort()[0] ?? null;
24
29
  }
25
30
 
@@ -43,11 +48,11 @@ function inside(base, path) {
43
48
  }
44
49
 
45
50
  /**
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.
51
+ * Whether a removal or a copy names an item the way the sync sends it from that place (scan.mjs): a
52
+ * skill, plugin or MCP server by one name, an agent by its path in its folder, and a memory file only
53
+ * from the places the sync reads. A plan naming anything else did not come from a sync of this computer.
49
54
  */
50
- function sentName(kind, name, project) {
55
+ export function sentName(kind, name, project) {
51
56
  if (!SENT_NAME_RE.test(name)) return false;
52
57
  if (kind === "agent") return true;
53
58
  if (kind !== "memory") return !name.includes("/");
@@ -114,24 +119,44 @@ export async function localPlan(plan, { claudeDir, projects, deviceSlug }) {
114
119
 
115
120
  const placeOf = (task) => (task.project ? `in ${task.project.name}${task.folder ? ` (${task.folder})` : ""}` : "globally");
116
121
 
122
+ /** One line per item a copy still needs: from the backup's files, a command, or the repository a clone brings it with. */
123
+ function copyLines(copy) {
124
+ return copy.items.map((item) => {
125
+ 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";
126
+ return ` - ${item.kind} ${item.name}: ${how}`;
127
+ });
128
+ }
129
+
117
130
  /** The plan as lines a person reads. */
118
131
  export function describePlan(plan) {
119
132
  const { tasks } = plan;
120
133
  if (!tasks.length) return [`Nothing waits for ${plan.device.name}.`];
121
134
  const lines = [`${tasks.length === 1 ? "1 task waits" : `${tasks.length} tasks wait`} for ${plan.device.name}:`, ""];
122
135
  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)}`);
136
+ const verb = task.action === "remove" ? "Remove" : task.action === "copy" ? "Copy" : "Install";
137
+ const what = task.item ? `${task.item.name} (${task.item.origin})` : `everything from ${task.copy?.from.device.name ?? "another device"}`;
138
+ lines.push(`${index + 1}. ${verb} ${what}${task.action === "copy" && task.item ? ` from ${task.copy?.from.device.name ?? "another device"}` : ""}, ${placeOf(task)}`);
125
139
  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.`);
140
+ if (task.project && !task.folder) {
141
+ detail.push(
142
+ task.action === "copy"
143
+ ? `This computer has no folder of ${task.project.name}: clone it, then run skillcellar restore from inside it; or dismiss it on the site.`
144
+ : `This computer has no folder of ${task.project.name}: skip it, or dismiss it on the site.`,
145
+ );
146
+ }
127
147
  else if (task.problem) detail.push(`Cannot be carried out: ${task.problem}`);
128
148
  else if (task.action === "remove") {
129
149
  if (task.path) detail.push(`Path: ${task.path}`);
130
150
  if (task.claudeScope) detail.push(`--scope ${task.claudeScope}`);
151
+ } else if (task.action === "copy" && task.copy) {
152
+ const { from, items } = task.copy;
153
+ 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.`);
154
+ 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
155
  } 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
156
  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}`);
157
+ if (task.item?.page && !task.link) detail.push(`Page: ${task.item.page}`);
134
158
  lines.push(...detail.map((line) => ` ${line}`));
159
+ if (task.action === "copy" && task.copy && !(task.project && !task.folder)) lines.push(...copyLines(task.copy));
135
160
  });
136
161
  lines.push("", "Ask Claude Code to carry them out with the skillcellar skill, or do them yourself, then run skillcellar sync.");
137
162
  return lines;