skillcellar 0.6.0 → 0.7.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/README.md CHANGED
@@ -4,7 +4,7 @@ Keeps your [SkillCellar](https://skillcellar.com) library's list of what a compu
4
4
 
5
5
  1. Create an API key at https://skillcellar.com/dashboard/keys, one per computer.
6
6
  2. `npx skillcellar login`: paste the key, then choose which of your devices this computer is, or name a new one. `--device <name>` chooses without asking. It then offers to install the skillcellar skill for Claude Code, in `~/.claude/skills/skillcellar`.
7
- 3. `npx skillcellar sync`: reports the whole computer in one request: what it has globally, then every project Claude Code knows. Each sync replaces what the last one reported from this computer, so a project you deleted, or one Claude Code no longer lists, leaves this computer's list on the site, and the site once no computer has anything in it; marks you once made by hand on the site stay. `--project <name>`, run inside a project's folder, reports only that project (every checkout of it Claude Code knows) and global, under that name, and leaves the others as they are. `--dry-run` prints what would be sent and sends nothing. The site then closes the tasks it finds done, and the sync lists them.
7
+ 3. `npx skillcellar sync`: reports the whole computer in one request: what it has globally, then every project Claude Code knows, and every clone a restore wrote into. Each sync replaces what the last one reported from this computer, so a project you deleted, or one Claude Code no longer lists, leaves this computer's list on the site, and the site once no computer has anything in it; marks you once made by hand on the site stay. `--project <name>`, run inside a project's folder, reports only that project (every checkout of it Claude Code knows) and global, under that name, and leaves the others as they are. `--dry-run` prints what would be sent and sends nothing. The site then closes the tasks it finds done, and the sync lists them.
8
8
 
9
9
  ## Tasks
10
10
 
@@ -29,20 +29,22 @@ Both read this computer as `sync` does and send that list as `sync` sends it (na
29
29
 
30
30
  ## Backups
31
31
 
32
- `skillcellar backup --repo <owner>/<repo>` pushes this computer's Claude Code setup to a private GitHub repository you own, with your own git and its credentials, under `devices/<device>/`: `global/` holds your skills' source files, `CLAUDE.md`, `rules/`, `agents/` and `mcp.json`; `projects/<project>/` holds the same for each project, with its `AGENTS.md`, the session memory Claude Code keeps for it under `memory/`, and its MCP servers in `mcp.json` (the project's `.mcp.json`) and `mcp.local.json` (those Claude Code keeps for its folders). A `manifest.json` names the device and its projects. The repository is remembered, so `skillcellar backup` alone works from then on; `--ssh` clones and pushes over SSH, and `--dry-run` lists what would go without cloning, committing or pushing.
32
+ To move to a new computer, or back onto one you reinstall, follow the step-by-step guide at https://skillcellar.com/guides/new-computer.
33
+
34
+ `skillcellar backup --repo <owner>/<repo>` pushes this computer's Claude Code setup to a private GitHub repository you own, with your own git and its credentials, under `devices/<device>/`: `global/` holds your skills' source files, `CLAUDE.md`, `rules/`, `agents/` and `mcp.json`; `projects/<project>/` holds the same for each project, with its `AGENTS.md`, the session memory Claude Code keeps for it under `memory/`, and its MCP servers in `mcp.json` (the project's `.mcp.json`) and `mcp.local.json` (those Claude Code keeps for its folders). A project checked out more than once on the computer is one project, as on the site: every checkout goes in its one folder, and where two hold a file or a skill of the same name, the one the sync sends first goes and the other is listed as left out. MCP servers of the same name are kept once the same way, but separately in `mcp.json` and in `mcp.local.json`, and the one left out is not listed. A `manifest.json` names the device and its projects. The repository is remembered, so `skillcellar backup` alone works from then on; `--ssh` clones and pushes over SSH, and `--dry-run` lists what would go without cloning, committing or pushing.
33
35
 
34
36
  - In the MCP config every `env` and header value, an address's credentials and its query's values, the parts of its path that are not short words, and an argument that looks like a key are replaced by a placeholder, named after its key where one exists (`${CONTEXT7_KEY}`), else `${ARG_n}` or `${PATH_n}`. A key of a shape the check does not know, in another field, or given as a bare argument shorter than 16 characters, stays: read the backup once before you rely on it.
35
37
  - Source files are text of a known kind (Markdown, code, JSON, YAML and the like) under 1 MB. Folders of installed packages, caches and build output (`node_modules`, `vendor`, `.venv`, `venv`, `env`, `__pycache__`, `.git`, `.cache`, `.next`, `.turbo`, `.nuxt`, `.svelte-kit`, `dist`, `build`, `target`, `coverage`, `.tox`, `.mypy_cache`, `.pytest_cache`, `.ruff_cache`, `.gradle` and `ms-playwright`) are left out at any depth, as are links inside a skill folder, and the command lists the folders and files it left out for those reasons, a file over 1 MB and a folder it could not read among them. Files a project's own git tracks are left out without a line: a clone brings them.
36
- - Before anything is pushed, every file is read for what looks like a key, a token, a password or a private key: known shapes (GitHub, OpenAI, AWS, Stripe, Slack, Google, npm, Neon, Resend, Hugging Face, Groq, JSON Web Tokens, private key blocks), a password in an address, a bearer or basic credential, and a name such as `API_KEY`, `password` or `token` assigned a value with digits in it. Anything found stops the backup with each file and line (the first 20), and nothing is pushed. A file you have read and know holds no secret goes through with `--allow <path>` for that file. Then the repository is checked without a token: one that answers anyone is public, and is refused. The clone lives beside the config, in `skillcellar/backups/<owner>/<repo>`, readable by you only; each backup brings the clone to the remote's state, commits and pushes, and a commit a failed push left behind goes with the next run.
38
+ - Before anything is pushed, every file is read for what looks like a key, a token, a password or a private key: known shapes (GitHub, OpenAI, AWS, Stripe, Slack, Google, npm, Neon, Resend, Hugging Face, Groq, JSON Web Tokens, private key blocks), a password in an address, a bearer or basic credential, and a name such as `API_KEY`, `password` or `token` assigned a value with digits in it; neither of the last two counts when the value is elided (`abc123...`) or holds an example word (`dummy`, `fake`, `sample`, `example`, `placeholder`, `redacted` or `changeme`) in any case with no letter next to it; an assignment whose value starts with `example`, `placeholder`, `redacted` or `changeme` is passed whatever follows. An assignment does not count either when its value is unquoted and is a dotted name directly followed by `(`, such as `base64.b64encode(`. Anything found stops the backup with each file and line (the first 20), and nothing is pushed. A file you have read and know holds no secret goes through with `--allow <path>` for that file, and each run lists the files it lets through this way. A backup that pushes remembers each with the hash of its contents, so later runs pass it without `--allow` until it changes, and names it in `manifest.json`, so a restore copies it as it was allowed. Then the repository is checked without a token: one that answers anyone is public, and is refused. The clone lives beside the config, in `skillcellar/backups/<owner>/<repo>`, readable by you only; each backup brings the clone to the remote's state, commits and pushes, and a commit a failed push left behind goes with the next run.
37
39
  - The site learns only the repository's name, the commit and the time, which the device's page shows. It holds no token for the repository and never reads the backup.
38
40
 
39
- `skillcellar restore` carries out the copy tasks waiting for this computer ("Copy to…" on the compare page, or "Copy everything to…" on a device's page). The site names the other computer's backup repository and commit, and a name is not proof: the restore takes a repository this computer does not itself back up to only with `--repo <owner>/<repo>`, or a yes at the terminal, and refuses a public one. It fetches the backup into a bare mirror beside the config (`skillcellar/backups/<owner>/<repo>.git`, never checked out, so a link in the repository is never followed), reads the commit the site recorded, and writes its skills, memory files and agents into place: a memory file or agent only when its hash is the one the other computer reported, where it reported one, nothing that holds what looks like a secret, nothing over the skillcellar skill, nothing over a file that is a link, and nothing into a project's `.claude`, `.claude/skills`, `.claude/agents` or `.claude/rules` when that folder is a link; what is there already stays unless you pass `--force`, which replaces every item the tasks name. It prints the commands left: a plugin's install, `npx skills add` for a skill from a repository, and `claude mcp add-json` for each MCP server with the backup's config, where each `${NAME}` is yours to fill in, and a `review` note marks a `${VAR:-default}` or `$VAR` reference or a `headersHelper` to read before adding. It also names the `package.json`, `requirements.txt`, `pyproject.toml` or Playwright config a restored skill carries, so you can rebuild its environment. `--from <device>` takes one computer's tasks; `--ssh` fetches over SSH; `--dry-run` fetches the backup but writes nothing into place. The printed commands are quoted for a POSIX shell; on Windows, paste them into Git Bash or WSL. Then `skillcellar sync` closes each task this computer now satisfies.
41
+ `skillcellar restore` carries out the copy tasks waiting for this computer ("Copy to…" on the compare page, or "Copy everything to…" on a device's page). The site names the other computer's backup repository and commit, and a name is not proof: the restore takes a repository this computer does not itself back up to only with `--repo <owner>/<repo>`, or a yes at the terminal, and refuses a public one. It fetches the backup into a bare mirror beside the config (`skillcellar/backups/<owner>/<repo>.git`, never checked out, so a link in the repository is never followed), reads the commit the site recorded, and writes its skills, memory files and agents into place: a memory file or agent only when its hash is the one the other computer reported, where it reported one, unless you pass `--take-backup`, which copies the backup's copy and says so; nothing that holds what looks like a secret unless the backup's `manifest.json` names the file as allowed with those very contents, nothing over the skillcellar skill, nothing over a file that is a link, and nothing into a project's `.claude`, `.claude/skills`, `.claude/agents` or `.claude/rules` when that folder is a link. What is there already stays unless you pass `--force`, which replaces every item the tasks name: one the same as the backup's copy is only counted, one that differs is named, and a skill folder without its SKILL.md, which an antivirus can leave behind, is named as no skill rather than kept. A copy of everything in a place also lists what the backup holds there that the other computer's last sync did not report, such as a memory file written after that sync; `--take-backup` copies those too. It prints the commands left: a plugin's install, `npx skills add` for a skill from a repository, under the name the skill's SKILL.md gives in the backup and with `-g -y` for a global task, and `claude mcp add-json` for each MCP server with the backup's config, where each `${NAME}` is yours to fill in, and a `review` note marks a `${VAR:-default}` or `$VAR` reference or a `headersHelper` to read before adding. `--from-backup <skill>` copies a skill's files from the backup instead of printing its command, for a skill that has left its repository; a plugin whose marketplace renamed itself since installs under the name `/plugin marketplace list` shows. It also names the `package.json`, `requirements.txt`, `pyproject.toml` or Playwright config a restored skill carries, so you can rebuild its environment. `--from <device>` takes one computer's tasks; `--ssh` fetches over SSH, and a fetch over HTTPS that git has no credentials for says to add it; `--dry-run` fetches the backup but writes nothing into place. The printed commands are quoted for a POSIX shell; on Windows, paste them into Git Bash or WSL. Each project folder it restores is remembered in the config, so `sync`, `plan` and `backup` read it from then on, as if Claude Code had been opened there. Then `skillcellar sync` closes each task this computer now satisfies.
40
42
 
41
43
  ## What it reads
42
44
 
43
45
  Globally, in `~/.claude`:
44
46
 
45
- - `skills`: each folder with a SKILL.md, skipping `learned`, `synced`, hidden and `*-workspace` folders, and skills nested deeper; and the lock where `npx skills` records each skill's repository, folder and hash: `$XDG_STATE_HOME/skills/.skill-lock.json` when that variable is set, else `~/.agents/.skill-lock.json`.
47
+ - `skills`: each folder with a SKILL.md of its own, whatever its name, skipping `learned`, `synced` and hidden folders, and skills nested deeper, such as in skill-creator's `*-workspace` folders; and the lock where `npx skills` records each skill's repository, folder and hash: `$XDG_STATE_HOME/skills/.skill-lock.json` when that variable is set, else `~/.agents/.skill-lock.json`.
46
48
  - `plugins/installed_plugins.json` and `known_marketplaces.json`: the plugins Claude Code lists as installed, and their marketplaces.
47
49
  - `CLAUDE.md`, `rules/**/*.md` and `agents/**/*.md`.
48
50
  - `~/.claude.json`: your MCP servers (`mcpServers`), and the projects Claude Code knows (`projects`), with their own MCP servers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillcellar",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Sync what Claude Code has on this computer (skills, plugins, MCP servers, memory and agents) to your SkillCellar library, by name and hash, list the tasks you queue there for Claude Code to carry out, check which skills and plugins are outdated, and apply playbooks.",
5
5
  "keywords": [
6
6
  "claude-code",
package/skill/SKILL.md CHANGED
@@ -61,9 +61,9 @@ A removal deletes something from this computer. Show the user exactly what goes,
61
61
 
62
62
  ## 4. Copy
63
63
 
64
- A copy brings what another of the user's computers has, in the same place, from that computer's backup, a private GitHub repository the user owns. The site only names that repository, and a name is not proof: show the user `copy.from.device.name` and `copy.from.backup.repo`, and run the restore only once they confirm the repository is theirs, as `npx skillcellar restore --repo <owner>/<repo>` with the name they confirmed (`--from <device>` takes one computer's tasks; on a computer that backs up to the same repository, `npx skillcellar restore` alone works). For a project task, run it from inside the project's folder on this computer, cloned first if it is not there yet. It refuses a public repository, fetches the backup into the tool's own folder with the user's own git, reads the commit the site recorded, writes skills, memory files and agents into place, keeps a folder or file that is there already, and prints the rest as commands. If it says a folder is there already, ask the user whether to replace it, and run it again with `--force` only on a yes: `--force` replaces every item it names, so tell the user which. If it says there is no backup yet, or that the commit is missing, tell the user to run `npx skillcellar backup` on the other computer, and go on with the next task.
64
+ A copy brings what another of the user's computers has, in the same place, from that computer's backup, a private GitHub repository the user owns. The site only names that repository, and a name is not proof: show the user `copy.from.device.name` and `copy.from.backup.repo`, and run the restore only once they confirm the repository is theirs, as `npx skillcellar restore --repo <owner>/<repo>` with the name they confirmed (`--from <device>` takes one computer's tasks; on a computer that backs up to the same repository, `npx skillcellar restore` alone works). For a project task, run it from inside the project's folder on this computer, cloned first if it is not there yet. It refuses a public repository, fetches the backup into the tool's own folder with the user's own git, reads the commit the site recorded, writes skills, memory files and agents into place, keeps a folder or file that is there already, and prints the rest as commands. If it says a folder is there already, ask the user whether to replace it, and run it again with `--force` only on a yes: `--force` replaces every item it names, so tell the user which. If it says a skill's folder has no SKILL.md, an antivirus may have removed it: tell the user, and copy it again with `--force` only on their yes. If it says the backup's copy of a file is not the one the other computer last reported, or lists what the backup holds that its last sync did not report, show the user and run it again with `--take-backup` only on their yes. If it says there is no backup yet, or that the commit is missing, tell the user to run `npx skillcellar backup` on the other computer, and go on with the next task.
65
65
 
66
- The commands it prints run each inside the folder of its task. A plugin's or a repository skill's install command is plain, as section 2 says. A `claude mcp add-json <name> '<json>' --scope <scope>` command is never plain: it adds a server that starts with every Claude Code session, with the backup's config, so show the user its full JSON (the command, its arguments, the address, the headers) and run it only on their yes to that server. A `${NAME}` in that config marks a value the backup left out, such as a key or an environment variable, and a command marked `review` holds a `${VAR:-default}` or `$VAR` reference that reaches into the user's environment, or a `headersHelper` command: both go to the user, as section 2 says, and you never fill a placeholder in. An item marked `tracked` comes with the project's repository: pull it, and copy nothing.
66
+ The commands it prints run each inside the folder of its task. A plugin's or a repository skill's install command is plain, as section 2 says; a global `npx skills add` from `restore` carries `-g -y` already, so add only `--agent claude-code`. If it finds no matching skill, the skill has left its repository: ask the user, and on a yes run the restore again with `--from-backup <name>`, which copies the backup's copy instead. If `/plugin install` says the plugin is not in its marketplace, run `/plugin marketplace list` and install it under the name shown there for that marketplace. A `claude mcp add-json <name> '<json>' --scope <scope>` command is never plain: it adds a server that starts with every Claude Code session, with the backup's config, so show the user its full JSON (the command, its arguments, the address, the headers) and run it only on their yes to that server. A `${NAME}` in that config marks a value the backup left out, such as a key or an environment variable, and a command marked `review` holds a `${VAR:-default}` or `$VAR` reference that reaches into the user's environment, or a `headersHelper` command: both go to the user, as section 2 says, and you never fill a placeholder in. An item marked `tracked` comes with the project's repository: pull it, and copy nothing.
67
67
 
68
68
  A restored skill may need its environment: `restore` names each `package.json` (`npm install`), `requirements.txt` (`pip install -r requirements.txt`), `pyproject.toml` or Playwright config it found. Each of those is a package manager's install, so show it to the user and run it only after their yes to that command, inside the skill's folder.
69
69
 
package/src/backup.mjs CHANGED
@@ -1,10 +1,11 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
2
3
  import { lstat, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
4
  import { dirname, extname, join, relative } from "node:path";
4
5
  import { configPath } from "./config.mjs";
5
6
  import { markdownFiles } from "./files.mjs";
6
7
  import { sessionFolderName } from "./places.mjs";
7
- import { projectSlug } from "./plan.mjs";
8
+ import { projectIdentity, projectSlug } from "./plan.mjs";
8
9
  import { skillFolders } from "./scan.mjs";
9
10
  import { findSecret } from "./secrets.mjs";
10
11
 
@@ -287,6 +288,7 @@ const sanitizeServers = (servers) => Object.fromEntries(Object.entries(servers ?
287
288
  // ── What a device backs up ──────────────────────────────────────────────────────────────────
288
289
 
289
290
  const text = (path, source, content) => ({ path, source, content: Buffer.from(content), executable: false });
291
+ const manifestFile = (base, manifest) => text(`${base}/manifest.json`, "manifest", `${JSON.stringify(manifest, null, 2)}\n`);
290
292
 
291
293
  /** The `.md` files of a folder, for the backup, as `<into>/<path>`; a folder's absence is nothing to back up, one that cannot be read is named. */
292
294
  async function markdownInto(dir, into, own, skipped) {
@@ -362,71 +364,111 @@ export async function collectBackup({ device, claudeDir, claudeJson, projects, t
362
364
  if (Object.keys(globalServers).length) files.push(text(`${base}/global/mcp.json`, "~/.claude.json (mcpServers)", `${JSON.stringify({ mcpServers: globalServers }, null, 2)}\n`));
363
365
 
364
366
  const places = [{ folder: "global", files: files.length }];
365
- const projectFolders = new Set();
367
+ // Every checkout of one project, such as two clones of a repository, goes in the project's one
368
+ // folder, as the site merges them into one project. Another project whose name makes the same
369
+ // folder name is left out.
370
+ const groups = new Map();
366
371
  for (const project of projects) {
367
372
  const slug = projectSlug(project, device.slug);
368
373
  if (!slug) continue;
369
- if (projectFolders.has(slug)) {
370
- skipped.push({ path: project.root, reason: `a folder of the same name, ${slug}, is backed up already` });
371
- continue;
372
- }
373
- projectFolders.add(slug);
374
+ const group = groups.get(slug);
375
+ if (!group) groups.set(slug, [project]);
376
+ else if (projectIdentity(group[0]) === projectIdentity(project)) group.push(project);
377
+ else skipped.push({ path: project.root, reason: `a folder of the same name, ${slug}, is backed up already` });
378
+ }
379
+ for (const [slug, group] of groups) {
380
+ // In the order the sync sends them, which the site merges them in: by name, then as found.
381
+ const checkouts = [...group].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
374
382
  const into = `${base}/projects/${slug}`;
375
- const own = { root: project.root, tracked: await trackedOf(project.root) };
376
383
  const before = files.length;
377
- files.push(...(await skillsInto(join(project.root, ".claude", "skills"), `${into}/skills`, own, skipped, budget)));
378
- files.push(...(await oneFile(join(project.root, "CLAUDE.md"), `${into}/CLAUDE.md`, own, skipped)));
379
- files.push(...(await oneFile(join(project.root, "AGENTS.md"), `${into}/AGENTS.md`, own, skipped)));
380
- files.push(...(await markdownInto(join(project.root, ".claude", "rules"), `${into}/rules`, own, skipped)));
381
- files.push(...(await markdownInto(join(project.root, ".claude", "agents"), `${into}/agents`, own, skipped)));
382
- const seen = new Set();
383
- for (const folder of new Set([project.root, ...(project.folders ?? [])])) {
384
- for (const file of await markdownInto(join(claudeDir, "projects", sessionFolderName(folder), "memory"), `${into}/memory`, null, skipped)) {
385
- if (seen.has(file.path)) continue;
386
- seen.add(file.path);
387
- files.push(file);
384
+ // The first checkout's item of a name wins, as on the site: a file by its path, a skill by its whole folder.
385
+ const item = (path) => (path.startsWith(`${into}/skills/`) ? path.split("/", into.split("/").length + 2).join("/") : path);
386
+ const taken = new Set();
387
+ const shared = new Map();
388
+ const local = new Map();
389
+ const sharedSources = [];
390
+ for (const project of checkouts) {
391
+ const own = { root: project.root, tracked: await trackedOf(project.root) };
392
+ const found = [
393
+ ...(await skillsInto(join(project.root, ".claude", "skills"), `${into}/skills`, own, skipped, budget)),
394
+ ...(await oneFile(join(project.root, "CLAUDE.md"), `${into}/CLAUDE.md`, own, skipped)),
395
+ ...(await oneFile(join(project.root, "AGENTS.md"), `${into}/AGENTS.md`, own, skipped)),
396
+ ...(await markdownInto(join(project.root, ".claude", "rules"), `${into}/rules`, own, skipped)),
397
+ ...(await markdownInto(join(project.root, ".claude", "agents"), `${into}/agents`, own, skipped)),
398
+ ];
399
+ const seen = new Set();
400
+ for (const folder of new Set([project.root, ...(project.folders ?? [])])) {
401
+ for (const file of await markdownInto(join(claudeDir, "projects", sessionFolderName(folder), "memory"), `${into}/memory`, null, skipped)) {
402
+ if (seen.has(file.path)) continue;
403
+ seen.add(file.path);
404
+ found.push(file);
405
+ }
388
406
  }
389
- }
390
- if (!own.tracked.has(".mcp.json")) {
391
- const mcpJson = await readFile(join(project.root, ".mcp.json"), "utf8").catch(() => null);
392
- let servers = null;
393
- try {
394
- servers = mcpJson === null ? null : JSON.parse(mcpJson)?.mcpServers;
395
- } catch {
396
- skipped.push({ path: join(project.root, ".mcp.json"), reason: "not valid JSON" });
407
+ const lost = new Map();
408
+ for (const file of found) {
409
+ const key = item(file.path);
410
+ if (!taken.has(key)) files.push(file);
411
+ else if (!lost.has(key)) lost.set(key, key.startsWith(`${into}/skills/`) ? join(project.root, ".claude", "skills", key.slice(`${into}/skills/`.length)) : file.source);
397
412
  }
398
- if (servers && typeof servers === "object" && Object.keys(servers).length) {
399
- files.push(text(`${into}/mcp.json`, join(project.root, ".mcp.json"), `${JSON.stringify({ mcpServers: sanitizeServers(servers) }, null, 2)}\n`));
413
+ for (const path of lost.values()) skipped.push({ path, reason: `another checkout of ${project.name} has one of the same name, backed up instead, as the site keeps one` });
414
+ for (const file of found) taken.add(item(file.path));
415
+ if (!own.tracked.has(".mcp.json")) {
416
+ const mcpJson = await readFile(join(project.root, ".mcp.json"), "utf8").catch(() => null);
417
+ let servers = null;
418
+ try {
419
+ servers = mcpJson === null ? null : JSON.parse(mcpJson)?.mcpServers;
420
+ } catch {
421
+ skipped.push({ path: join(project.root, ".mcp.json"), reason: "not valid JSON" });
422
+ }
423
+ if (servers && typeof servers === "object" && Object.keys(servers).length) {
424
+ sharedSources.push(join(project.root, ".mcp.json"));
425
+ for (const [name, server] of Object.entries(sanitizeServers(servers))) if (!shared.has(name)) shared.set(name, server);
426
+ }
400
427
  }
428
+ const ownLocal = {};
429
+ for (const folder of [project.root, ...(project.folders ?? [])]) Object.assign(ownLocal, sanitizeServers(claudeJson?.projects?.[folder]?.mcpServers));
430
+ for (const [name, server] of Object.entries(ownLocal)) if (!local.has(name)) local.set(name, server);
401
431
  }
402
- const local = {};
403
- for (const folder of [project.root, ...(project.folders ?? [])]) Object.assign(local, sanitizeServers(claudeJson?.projects?.[folder]?.mcpServers));
404
- if (Object.keys(local).length) files.push(text(`${into}/mcp.local.json`, `~/.claude.json (projects, mcpServers)`, `${JSON.stringify({ mcpServers: local }, null, 2)}\n`));
432
+ if (shared.size) files.push({ ...text(`${into}/mcp.json`, sharedSources.join(", "), `${JSON.stringify({ mcpServers: Object.fromEntries(shared) }, null, 2)}\n`), sources: sharedSources });
433
+ if (local.size) files.push(text(`${into}/mcp.local.json`, `~/.claude.json (projects, mcpServers)`, `${JSON.stringify({ mcpServers: Object.fromEntries(local) }, null, 2)}\n`));
434
+ const [project] = checkouts;
405
435
  places.push({ folder: `projects/${slug}`, name: project.name, repo: project.repo ?? null, remote: Boolean(project.remote), files: files.length - before });
406
436
  }
407
437
 
408
438
  // No time in the manifest: the commit carries it, and an unchanged computer then commits nothing.
409
439
  const manifest = { skillcellar: version, device: { id: device.id, slug: device.slug, name: device.name }, places };
410
- files.push(text(`${base}/manifest.json`, "manifest", `${JSON.stringify(manifest, null, 2)}\n`));
440
+ files.push(manifestFile(base, manifest));
411
441
  return { files, skipped, manifest };
412
442
  }
413
443
 
414
444
  // How many findings a stopped backup lists.
415
445
  const FINDINGS_MAX = 20;
416
446
 
447
+ /** A path with `/` between its folders, as on Windows `--allow` may name a file either way. */
448
+ const slashed = (path) => path.replaceAll("\\", "/");
449
+
417
450
  /**
418
- * The files of a backup that hold what looks like a secret, each with the line, up to FINDINGS_MAX;
419
- * a file the user allowed by its path, or the end of it, is not read.
451
+ * The files of a backup that hold what looks like a secret, each with the line, up to FINDINGS_MAX,
452
+ * and those that pass despite one, each with the hash of what passed: a file the user allows by its
453
+ * path, whole or from a folder's start, and one an earlier backup let through holding these bytes.
420
454
  */
421
- export function findSecretsIn(files, allowed = []) {
455
+ export function findSecretsIn(files, allowed = [], remembered = []) {
422
456
  const findings = [];
457
+ const passed = [];
423
458
  for (const file of files) {
424
- if (allowed.some((path) => file.source === path || file.source.endsWith(`/${path}`))) continue;
425
459
  const found = findSecret(file.content.toString("utf8"));
426
- if (found) findings.push({ ...found, file: file.source });
460
+ if (!found) continue;
461
+ const sha256 = createHash("sha256").update(file.content).digest("hex");
462
+ // A merged file, such as the mcp.json of several checkouts, is allowed by any file it came from.
463
+ const names = (file.sources ?? [file.source]).map(slashed);
464
+ if (allowed.map(slashed).some((path) => names.some((name) => name === path || name.endsWith(`/${path}`))) || remembered.some((entry) => entry?.file === file.source && entry.sha256 === sha256)) {
465
+ passed.push({ file: file.source, path: file.path, sha256, line: found.line, kind: found.kind });
466
+ continue;
467
+ }
468
+ findings.push({ ...found, file: file.source });
427
469
  if (findings.length >= FINDINGS_MAX) break;
428
470
  }
429
- return findings;
471
+ return { findings, passed };
430
472
  }
431
473
 
432
474
  /** Writes the backup's files into the clone, replacing the device's folder and any older folder of the same device under another slug. */
@@ -462,6 +504,14 @@ export async function currentBranch(clone) {
462
504
  return head.trim() ? ref.trim() || null : null;
463
505
  }
464
506
 
507
+ // What git says when it has no credentials for a GitHub repository over HTTPS.
508
+ const HTTPS_AUTH_RE = /could not read Username|Authentication failed|terminal prompts disabled|Invalid username or password|repository '[^']*' not found/i;
509
+
510
+ /** Git's failure to reach the backup repository, naming the way out when HTTPS had no credentials: SSH. */
511
+ export function reachError(error, repo) {
512
+ return !repo.ssh && HTTPS_AUTH_RE.test(error.message) ? new Error(`${error.message}. If your GitHub access is an SSH key, run it again with --ssh.`) : error;
513
+ }
514
+
465
515
  /**
466
516
  * The clone of the backup repository, made if missing, else brought to the remote's state: the
467
517
  * clone is the tool's own scratch area, so what a failed run left in its tree is dropped, a commit a
@@ -470,13 +520,16 @@ export async function currentBranch(clone) {
470
520
  export async function ensureClone(repo) {
471
521
  const clone = clonePath(repo.owner, repo.repo);
472
522
  const there = await stat(join(clone, ".git")).catch(() => null);
523
+ const reach = (error) => {
524
+ throw reachError(error, repo);
525
+ };
473
526
  if (!there?.isDirectory()) {
474
527
  await mkdir(dirname(clone), { recursive: true, mode: 0o700 });
475
- await git(["clone", "--quiet", repo.url, clone], { timeout: 600_000 });
528
+ await git(["clone", "--quiet", repo.url, clone], { timeout: 600_000 }).catch(reach);
476
529
  return clone;
477
530
  }
478
531
  await git(["remote", "set-url", "origin", repo.url], { cwd: clone });
479
- await git(["fetch", "--quiet", "origin"], { cwd: clone, timeout: 600_000 });
532
+ await git(["fetch", "--quiet", "origin"], { cwd: clone, timeout: 600_000 }).catch(reach);
480
533
  const branch = await currentBranch(clone);
481
534
  if (branch) {
482
535
  await git(["reset", "--hard", "--quiet"], { cwd: clone });
@@ -494,13 +547,16 @@ export async function ensureClone(repo) {
494
547
  export async function ensureMirror(repo) {
495
548
  const mirror = `${clonePath(repo.owner, repo.repo)}.git`;
496
549
  const there = await stat(join(mirror, "HEAD")).catch(() => null);
550
+ const reach = (error) => {
551
+ throw reachError(error, repo);
552
+ };
497
553
  if (!there?.isFile()) {
498
554
  await mkdir(dirname(mirror), { recursive: true, mode: 0o700 });
499
- await git(["clone", "--quiet", "--bare", repo.url, mirror], { timeout: 600_000 });
555
+ await git(["clone", "--quiet", "--bare", repo.url, mirror], { timeout: 600_000 }).catch(reach);
500
556
  return mirror;
501
557
  }
502
558
  await git(["--git-dir", mirror, "remote", "set-url", "origin", repo.url]);
503
- await git(["--git-dir", mirror, "fetch", "--quiet", "origin", "+refs/heads/*:refs/heads/*"], { timeout: 600_000 });
559
+ await git(["--git-dir", mirror, "fetch", "--quiet", "origin", "+refs/heads/*:refs/heads/*"], { timeout: 600_000 }).catch(reach);
504
560
  return mirror;
505
561
  }
506
562
 
@@ -514,14 +570,27 @@ export function describeSkipped(skipped) {
514
570
 
515
571
  const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
516
572
 
573
+ /** The files let through despite a finding, as lines a person reads, so an allowance is never silent. */
574
+ function describePassed(passed) {
575
+ if (!passed.length) return [];
576
+ return ["Let through as you allowed them:", ...passed.map(({ file, line, kind }) => ` ${file}:${line} looks like ${kind}`)];
577
+ }
578
+
517
579
  /**
518
580
  * Backs up the device: collects its files, stops on anything that looks like a secret, refuses a
519
581
  * public repository, then commits and pushes with the user's own git. Answers what to print and the
520
582
  * commit to report, or null when nothing changed.
521
583
  */
522
- export async function runBackup({ repo, device, claudeDir, claudeJson, projects, trackedOf, version, dryRun, allowed = [], print, onPushed = async () => {} }) {
523
- const { files, skipped, manifest } = await collectBackup({ device, claudeDir, claudeJson, projects, trackedOf, version });
524
- const findings = findSecretsIn(files, allowed);
584
+ export async function runBackup({ repo, device, claudeDir, claudeJson, projects, trackedOf, version, dryRun, allowed = [], remembered = [], print, onPushed = async () => {} }) {
585
+ const collected = await collectBackup({ device, claudeDir, claudeJson, projects, trackedOf, version });
586
+ const { skipped } = collected;
587
+ const { findings, passed } = findSecretsIn(collected.files, allowed, remembered);
588
+ // What passed despite a finding is named in the manifest with the hash of what passed, so a
589
+ // restore copies that and reads any other copy of the file again.
590
+ const base = `devices/${device.slug}`;
591
+ const allowedFiles = passed.map(({ path, sha256 }) => ({ path: path.slice(base.length + 1), sha256 })).sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
592
+ const manifest = allowedFiles.length ? { ...collected.manifest, allowed: allowedFiles } : collected.manifest;
593
+ const files = allowedFiles.length ? collected.files.map((file) => (file.path === `${base}/manifest.json` ? manifestFile(base, manifest) : file)) : collected.files;
525
594
  if (findings.length) {
526
595
  const lines = findings.map((found) => ` ${found.file}:${found.line} looks like ${found.kind}`);
527
596
  throw new Error(
@@ -532,7 +601,7 @@ export async function runBackup({ repo, device, claudeDir, claudeJson, projects,
532
601
  const summary = `${plural(files.length, "file")} (${size(bytes)}): ${manifest.places.map((place) => `${place.folder} ${place.files}`).join(", ")}`;
533
602
  if (dryRun) {
534
603
  print(`Would back up ${device.name} to ${repo.name}, ${summary}.`);
535
- for (const line of describeSkipped(skipped)) print(line);
604
+ for (const line of [...describeSkipped(skipped), ...describePassed(passed)]) print(line);
536
605
  return null;
537
606
  }
538
607
  await refusePublic(repo, version);
@@ -548,10 +617,10 @@ export async function runBackup({ repo, device, claudeDir, claudeJson, projects,
548
617
  if (await currentBranch(clone)) {
549
618
  await git(["push", "--quiet", "-u", "origin", "HEAD"], { cwd: clone, timeout: 600_000 });
550
619
  commit = (await git(["rev-parse", "HEAD"], { cwd: clone })).trim();
551
- await onPushed(commit);
620
+ await onPushed(commit, passed);
552
621
  }
553
622
  if (changed) print(`Backed up ${device.name} to ${repo.name} at ${commit.slice(0, 7)}, ${summary}.`);
554
623
  else print(`Nothing changed since the last backup of ${device.name} in ${repo.name}${commit ? ` (${commit.slice(0, 7)})` : ""}.`);
555
- for (const line of describeSkipped(skipped)) print(line);
624
+ for (const line of [...describeSkipped(skipped), ...describePassed(passed)]) print(line);
556
625
  return commit;
557
626
  }
package/src/cli.mjs CHANGED
@@ -7,7 +7,7 @@ import { parseBackupRepo, runBackup } from "./backup.mjs";
7
7
  import { describeCheck } from "./check.mjs";
8
8
  import { configPath, readConfig, writeConfig } from "./config.mjs";
9
9
  import { findProjects } from "./places.mjs";
10
- import { describePlan, localPlan } from "./plan.mjs";
10
+ import { describePlan, localPlan, projectIdentity } from "./plan.mjs";
11
11
  import { applyPlaybook, cautionLine, describeStatus, readPlaybook } from "./playbook.mjs";
12
12
  import { findProject, trackedFiles } from "./project.mjs";
13
13
  import { ask, askSecret, firstLine, interactive } from "./prompt.mjs";
@@ -68,13 +68,19 @@ Usage:
68
68
  address credential and key-like argument replaced by a \${NAME} placeholder) and skills'
69
69
  source files to a private GitHub repository you own, with your own git. It stops on anything
70
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]
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] [--take-backup]
74
+ [--from-backup <skill>] [--dry-run]
74
75
  Carry out the copy tasks waiting for this computer: fetch the other computer's backup, write
75
76
  its skills, memory files and agents into place, and list the commands left to run. A backup
76
77
  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.
78
+ terminal. Each project folder it restores is remembered, so sync reports it from then on.
79
+ --force replace a folder or file that is there already
80
+ --take-backup copy a file whose backup copy is not the one the other computer last
81
+ reported, and what the backup holds that its last sync did not report
82
+ --from-backup <skill> copy this skill from the backup instead of printing its install
83
+ command, for one that has left its repository
78
84
 
79
85
  login reads the key from SKILLCELLAR_API_KEY when it is set, else from the terminal or the first
80
86
  line piped in. The key and the device are kept in ${configPath()}, written with mode 0600.`;
@@ -159,10 +165,11 @@ async function login(flags, print) {
159
165
  if (!/^[A-Za-z0-9_]{1,200}$/.test(key)) throw new Error(`That is not an API key. Create one at ${site}/dashboard/keys.`);
160
166
  const library = await call(site, key, "GET", "/api/v1/library", undefined, VERSION);
161
167
  const device = flags.device !== undefined ? await namedDevice(site, key, flags.device) : await chooseDevice(site, key, library.devices ?? [], print);
162
- // A backup repository remembered for this site stays remembered across sign-ins.
168
+ // A backup repository remembered for this site stays remembered across sign-ins, and the folders a restore wrote into always.
163
169
  const earlier = await readConfig().catch(() => null);
164
170
  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 });
171
+ const folders = Array.isArray(earlier?.folders) ? { folders: earlier.folders } : {};
172
+ await writeConfig({ site, key, device: { id: device.id, slug: device.slug, name: device.name }, ...backup, ...folders });
166
173
  const who = library.account?.email ? ` as ${library.account.email}` : "";
167
174
  print(`Signed in to ${new URL(site).host}${who}. This computer syncs as ${device.name}.`);
168
175
  await offerSkill(print);
@@ -202,9 +209,6 @@ function summary(payload, result, deviceName, whole) {
202
209
  */
203
210
  const claudeJsonPath = (home) => (process.env.CLAUDE_CONFIG_DIR?.trim() ? join(process.env.CLAUDE_CONFIG_DIR.trim(), ".claude.json") : join(home, ".claude.json"));
204
211
 
205
- /** What makes checkouts one project on the site: their GitHub repository, else their name. */
206
- const identity = (project) => project.repo ?? project.name.toLowerCase();
207
-
208
212
  /** The tasks a sync closed, in one line. */
209
213
  function closedLine(closed) {
210
214
  const verb = (task) => (task.action === "remove" ? "removed" : task.action === "copy" ? "copied" : "installed");
@@ -226,13 +230,13 @@ async function sync(flags, print, warn) {
226
230
  const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
227
231
  const cwd = process.cwd();
228
232
  const whole = flags.project === undefined;
229
- let projects = await findProjects({ home, claudeDir, claudeJson, cwd });
233
+ let projects = await findProjects({ home, claudeDir, claudeJson, cwd, restored: config?.folders });
230
234
  if (!whole) {
231
235
  const here = await findProject(cwd, home);
232
236
  if (!here) throw new UsageError("Run sync --project inside the project's folder, not in your home folder.");
233
237
  if (!/[A-Za-z0-9]/.test(flags.project)) throw new UsageError("--project needs a letter a–z or a digit 0–9.");
234
238
  // Every checkout of the same repository is one project on the site, so all of them go together.
235
- const same = projects.filter((known) => known.root === here.root || (here.remote && known.remote && identity(known) === identity(here)));
239
+ const same = projects.filter((known) => known.root === here.root || (here.remote && known.remote && projectIdentity(known) === projectIdentity(here)));
236
240
  projects = (same.length ? same : [{ ...here, folders: new Set([cwd]) }]).map((known) => ({ ...known, name: flags.project }));
237
241
  }
238
242
 
@@ -252,7 +256,7 @@ async function sync(flags, print, warn) {
252
256
  } catch (error) {
253
257
  // One project's broken file stops only that project in a sync of the whole computer.
254
258
  if (!whole) throw error;
255
- unread.add(identity(project));
259
+ unread.add(projectIdentity(project));
256
260
  warn(`Not reported: ${project.name}, because ${error.message} The site keeps what it had there until a sync can read it.`);
257
261
  continue;
258
262
  }
@@ -266,7 +270,7 @@ async function sync(flags, print, warn) {
266
270
  // one as it was and closes no task there; an empty place is then sent, to empty it. Another
267
271
  // checkout of the unread project is left out too, or it would replace the project on its own.
268
272
  const complete = whole && !unread.size;
269
- if (!complete) places = [...places, ...empty].filter((place) => !unread.has(identity(place.project)));
273
+ if (!complete) places = [...places, ...empty].filter((place) => !unread.has(projectIdentity(place.project)));
270
274
  if (places.length > PROJECTS_PER_SYNC) {
271
275
  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>.`);
272
276
  }
@@ -303,7 +307,7 @@ async function plan(flags, print) {
303
307
  const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
304
308
  const home = homedir();
305
309
  const claudeDir = claudeFolder(home);
306
- const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir) : [];
310
+ const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir, config) : [];
307
311
  // The device's slug as the site has it now: a rename changes the slugs of its folders' projects.
308
312
  const local = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
309
313
  if (flags.json) print(JSON.stringify(local, null, 2));
@@ -355,7 +359,7 @@ async function playbook(flags, action, ref, print, warn) {
355
359
  const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
356
360
  const global = await scanGlobal({ home, claudeDir, claudeJson });
357
361
  // 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;
362
+ const known = here ? (await findProjects({ home, claudeDir, claudeJson, cwd: process.cwd(), restored: config.folders })).find((entry) => entry.root === here.root) : null;
359
363
  const folders = here ? [...new Set([here.root, ...(known?.folders ?? []), process.cwd()])] : [];
360
364
  const project = here ? await scanProject({ root: here.root, folders, claudeDir, claudeJson, tracked: await trackedFiles(here.root) }) : null;
361
365
  const body = { ref, global: global.items, project: project?.items ?? null, updates: action === "status" };
@@ -387,8 +391,8 @@ async function playbook(flags, action, ref, print, warn) {
387
391
  }
388
392
 
389
393
  /** 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() });
394
+ async function knownProjects(home, claudeDir, config) {
395
+ return findProjects({ home, claudeDir, claudeJson: (await readJson(claudeJsonPath(home))) ?? {}, cwd: process.cwd(), restored: config?.folders });
392
396
  }
393
397
 
394
398
  /**
@@ -413,11 +417,17 @@ async function backup(flags, print, warn) {
413
417
  ? ((await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION)).device ?? config.device)
414
418
  : { id: null, slug: "this-computer", name: "this computer" };
415
419
  const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
416
- const projects = await knownProjects(home, claudeDir);
420
+ const projects = await knownProjects(home, claudeDir, config);
417
421
  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 });
422
+ // The repository is remembered as soon as a push has gone through, whatever the site answers after,
423
+ // with the files the user allowed despite a finding, each by the hash of what was allowed.
424
+ const remember = (commit, passed = []) => {
425
+ if (!config) return Promise.resolve();
426
+ const kept = passed.length ? { allowed: passed.map(({ file, sha256 }) => ({ file, sha256 })) } : {};
427
+ return writeConfig({ ...config, device: { id: device.id, slug: device.slug, name: device.name }, backup: { repo: repo.name, ssh: repo.ssh, ...kept } });
428
+ };
429
+ const remembered = Array.isArray(config?.backup?.allowed) ? config.backup.allowed : [];
430
+ const commit = await runBackup({ repo, device, claudeDir, claudeJson, projects, trackedOf: trackedFiles, version: VERSION, dryRun, allowed, remembered, print, onPushed: remember });
421
431
  if (dryRun || !commit) return;
422
432
  const recorded = await call(site, config.key, "POST", "/api/v1/backup", { device: device.id, repo: repo.name, commit }, VERSION);
423
433
  const slug = recorded.device?.slug ?? device.slug;
@@ -430,9 +440,13 @@ async function restore(flags, print, warn) {
430
440
  const home = homedir();
431
441
  const claudeDir = claudeFolder(home);
432
442
  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) : [];
443
+ const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir, config) : [];
434
444
  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 });
445
+ const { folders } = await runRestore({ plan, claudeDir, config, flags, version: VERSION, print, warn });
446
+ // A clone Claude Code has not been opened in yet is otherwise one no sync reads, and its copy task would never close.
447
+ const remembered = Array.isArray(config.folders) ? config.folders : [];
448
+ const added = [...folders].filter((folder) => !remembered.includes(folder));
449
+ if (added.length) await writeConfig({ ...config, folders: [...remembered, ...added] });
436
450
  }
437
451
 
438
452
  /** Installs or updates the skillcellar skill without asking: the command itself is the yes. */
@@ -479,6 +493,8 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
479
493
  repo: { type: "string" },
480
494
  ssh: { type: "boolean" },
481
495
  from: { type: "string" },
496
+ "take-backup": { type: "boolean" },
497
+ "from-backup": { type: "string", multiple: true },
482
498
  allow: { type: "string", multiple: true },
483
499
  help: { type: "boolean", short: "h" },
484
500
  version: { type: "boolean", short: "v" },
package/src/places.mjs CHANGED
@@ -36,11 +36,12 @@ async function sessionFolder(dir) {
36
36
  }
37
37
 
38
38
  /**
39
- * Every folder Claude Code knows on this computer: the projects `~/.claude.json` lists, and the
40
- * folder each of `~/.claude/projects/*` ran in. Folders that are gone are skipped.
39
+ * Every folder Claude Code knows on this computer: the projects `~/.claude.json` lists, the folder
40
+ * each of `~/.claude/projects/*` ran in, and those a restore wrote into, which Claude Code may never
41
+ * have been opened in. Folders that are gone are skipped.
41
42
  */
42
- async function knownFolders(claudeDir, claudeJson) {
43
- const folders = new Set(Object.keys(claudeJson?.projects ?? {}));
43
+ async function knownFolders(claudeDir, claudeJson, restored) {
44
+ const folders = new Set([...Object.keys(claudeJson?.projects ?? {}), ...restored.filter((folder) => typeof folder === "string" && isAbsolute(folder))]);
44
45
  const sessions = join(claudeDir, "projects");
45
46
  const names = await readdir(sessions).catch(() => []);
46
47
  const listed = new Set([...folders].map(sessionFolderName));
@@ -59,12 +60,13 @@ const real = (path) => realpath(path).catch(() => path);
59
60
  /**
60
61
  * The projects on this computer, one per checkout or folder, each with the folders Claude Code ran
61
62
  * in inside it: their local MCP servers and session memory belong to it. `cwd` is added when it is
62
- * in a project. The home folder, those above it and Claude Code's own folder are never one.
63
+ * in a project, and so is each folder in `restored`, the config's list of folders a restore wrote
64
+ * into. The home folder, those above it and Claude Code's own folder are never one.
63
65
  */
64
- export async function findProjects({ home, claudeDir, claudeJson, cwd }) {
66
+ export async function findProjects({ home, claudeDir, claudeJson, cwd, restored = [] }) {
65
67
  const projects = new Map();
66
68
  const own = await real(claudeDir);
67
- for (const folder of [...(await knownFolders(claudeDir, claudeJson)), cwd]) {
69
+ for (const folder of [...(await knownFolders(claudeDir, claudeJson, Array.isArray(restored) ? restored : [])), cwd]) {
68
70
  const project = await findProject(folder, home);
69
71
  const inside = project && relative(own, project.root);
70
72
  if (!project || inside === "" || (inside && !inside.startsWith("..") && !isAbsolute(inside))) continue;
package/src/plan.mjs CHANGED
@@ -15,6 +15,9 @@ export const toSlug = (name) =>
15
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
16
  export const projectSlug = (project, deviceSlug) => (project.remote ? toSlug(project.name) : `${toSlug(project.name)}-${deviceSlug}`);
17
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
+
18
21
  /**
19
22
  * The folder on this computer of a task's project, from the projects the sync finds: one with a
20
23
  * GitHub repository by that repository, any other by its slug, which for a folder with no git remote
@@ -137,8 +140,8 @@ export function describePlan(plan) {
137
140
  if (task.project && !task.folder) {
138
141
  detail.push(
139
142
  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.`,
143
+ ? `No folder of ${task.project.name} that Claude Code knows on this computer. If you cloned it, run skillcellar restore inside the clone; or dismiss the task on the site.`
144
+ : `No folder of ${task.project.name} that Claude Code knows on this computer: skip it, or dismiss it on the site.`,
142
145
  );
143
146
  }
144
147
  else if (task.problem) detail.push(`Cannot be carried out: ${task.problem}`);
@@ -153,7 +156,7 @@ export function describePlan(plan) {
153
156
  else if (task.install) detail.push(`${task.install.method}: ${task.install.command}`);
154
157
  if (task.item?.page && !task.link) detail.push(`Page: ${task.item.page}`);
155
158
  lines.push(...detail.map((line) => ` ${line}`));
156
- if (task.action === "copy" && task.copy && !(task.project && !task.folder)) lines.push(...copyLines(task.copy));
159
+ if (task.action === "copy" && task.copy) lines.push(...copyLines(task.copy));
157
160
  });
158
161
  lines.push("", "Ask Claude Code to carry them out with the skillcellar skill, or do them yourself, then run skillcellar sync.");
159
162
  return lines;
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,23 +167,50 @@ 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 }) {
197
+ async function restoreItem(item, { mirror, tree, budget, place, scope, root, claudeDir, force, dryRun, allowed, takeBackup, fromBackup }) {
198
+ // A file that looks like it holds a secret is copied only as the source's user allowed it, byte for byte.
199
+ const secretIn = (path, content) => {
200
+ const found = findSecret(content.toString("utf8"));
201
+ return found && allowed.get(path) !== sha256(content) ? found : null;
202
+ };
126
203
  const { kind, name } = item;
127
204
  if (item.tracked) return { outcome: "repository" };
128
- if (kind === "plugin" || (kind === "skill" && item.install)) {
205
+ if (kind === "plugin" || (kind === "skill" && item.install && !fromBackup.has(name))) {
129
206
  const command = item.install?.command;
130
207
  if (typeof command !== "string") return { outcome: "missing" };
131
- 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`) };
132
211
  }
133
212
  if (!["skill", "mcp", "memory", "agent"].includes(kind)) return { outcome: "unknown-kind" };
134
213
  if (!sentName(kind, name, scope === "project")) return { outcome: "unsendable" };
135
- const own = (...segments) => join(root ?? claudeDir, ...(root ? [".claude"] : []), ...segments);
136
214
  if (kind === "mcp") {
137
215
  const local = await readJsonBlob(mirror, tree.get(`${place}/mcp.local.json`), budget);
138
216
  const shared = await readJsonBlob(mirror, tree.get(`${place}/mcp.json`), budget);
@@ -153,49 +231,47 @@ async function restoreItem(item, { mirror, tree, budget, place, scope, root, cla
153
231
  if (!path.startsWith(prefix)) continue;
154
232
  if (!regular(entry)) return { outcome: "link", path };
155
233
  const content = await blobOf(mirror, entry, budget);
156
- const found = findSecret(content.toString("utf8"));
234
+ const found = secretIn(path, content);
157
235
  if (found) return { outcome: "secret", path, line: found.line, kind: found.kind };
158
236
  files.push({ segments: path.slice(prefix.length).split("/"), content, executable: entry.mode === "100755" });
159
237
  }
160
238
  if (!files.length) return { outcome: "missing" };
161
239
  if (root) await checkProjectFolder(root, "skills");
162
- const skills = own("skills");
163
- const target = join(skills, name);
164
- 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 };
165
248
  try {
166
249
  await installPulled(skills, { slug: name, files }, force);
167
250
  } catch (error) {
168
- 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 };
169
252
  throw error;
170
253
  }
254
+ if (!(await isFile(join(target, "SKILL.md")))) return { outcome: "vanished", target };
171
255
  return { outcome: "copied", target, environments: await environmentsOf(target) };
172
256
  }
173
- let path;
174
- let target;
175
- if (kind === "agent") {
176
- path = `${place}/agents/${name}.md`;
177
- if (root) await checkProjectFolder(root, "agents");
178
- target = inside(own("agents"), `${name}.md`);
179
- } else {
180
- const [first, ...rest] = name.split("/");
181
- path = `${place}/${name}`;
182
- if (first === "rules") {
183
- if (root) await checkProjectFolder(root, "rules");
184
- target = inside(own("rules"), ...rest);
185
- } else if (first === "memory" && root) target = inside(join(claudeDir, "projects", sessionFolderName(root), "memory"), ...rest);
186
- else target = join(root ?? claudeDir, name);
187
- }
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 });
188
261
  const entry = tree.get(path);
189
262
  if (!entry) return { outcome: "missing" };
190
263
  if (!regular(entry)) return { outcome: "link", path };
191
264
  if (!target) return { outcome: "unsendable" };
192
265
  const content = await blobOf(mirror, entry, budget);
193
- // The site holds the file's hash as the source reported it: a file that differs is not the source's.
194
- if (item.hash && sha256(content) !== item.hash) return { outcome: "differs" };
195
- const found = findSecret(content.toString("utf8"));
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" };
269
+ const found = secretIn(path, content);
196
270
  if (found) return { outcome: "secret", path, line: found.line, kind: found.kind };
197
- if (dryRun) return { outcome: (await lstat(target).catch(() => null)) && !force ? "exists" : "copied", target };
198
- 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 };
199
275
  }
200
276
 
201
277
  const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
@@ -216,9 +292,13 @@ async function trustedRepo(repo, source, config, flags) {
216
292
  function describe(result, label, source, dryRun) {
217
293
  switch (result.outcome) {
218
294
  case "copied":
219
- 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)` : ""}`;
220
296
  case "exists":
221
- 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}`;
222
302
  case "odd-command":
223
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`;
224
304
  case "repository":
@@ -234,7 +314,7 @@ function describe(result, label, source, dryRun) {
234
314
  case "secret":
235
315
  return `${label}: ${result.path}:${result.line} in the backup looks like ${result.kind}; not restored`;
236
316
  case "differs":
237
- 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`;
238
318
  default:
239
319
  return `${label}: not in the backup`;
240
320
  }
@@ -244,18 +324,21 @@ function describe(result, label, source, dryRun) {
244
324
  * Carries out the copy tasks of the plan whose source has a backup: refuses a public repository and
245
325
  * one this computer was not told to trust, fetches the backup into a bare mirror with the user's own
246
326
  * git, reads the commit the site recorded, writes skills, memory files and agents into place, and
247
- * 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.
248
328
  */
249
329
  export async function runRestore({ plan, claudeDir, config, flags, version, print, warn }) {
250
330
  const force = flags.force === true;
251
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"] : []);
252
334
  const from = typeof flags.from === "string" ? flags.from.trim().toLowerCase() : null;
335
+ const folders = new Set();
253
336
  const wanted = (plan.tasks ?? []).filter(
254
337
  (task) => task.action === "copy" && task.copy && (!from || task.copy.from.device.slug === from || task.copy.from.device.name.toLowerCase() === from),
255
338
  );
256
339
  if (!wanted.length) {
257
340
  print(from ? `No copy task from ${flags.from} waits for ${plan.device.name}.` : `No copy task waits for ${plan.device.name}.`);
258
- return;
341
+ return { folders };
259
342
  }
260
343
  const bySource = new Map();
261
344
  for (const task of wanted) bySource.set(task.copy.from.device.id, [...(bySource.get(task.copy.from.device.id) ?? []), task]);
@@ -295,7 +378,16 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
295
378
  warn(`${repo.name} at ${commit.slice(0, 7)} holds no backup of ${source.device.name}. Run skillcellar backup there, then restore here again.`);
296
379
  continue;
297
380
  }
381
+ const listed = Array.isArray(found.manifest?.allowed) ? found.manifest.allowed : [];
382
+ const allowed = new Map(listed.filter((entry) => typeof entry?.path === "string" && typeof entry.sha256 === "string").map((entry) => [`${found.folder}/${entry.path}`, entry.sha256]));
298
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
+ );
299
391
  for (const task of tasks) {
300
392
  const where = task.project ? `in ${task.project.name}` : "globally";
301
393
  if (task.problem) {
@@ -303,7 +395,7 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
303
395
  continue;
304
396
  }
305
397
  if (task.project && !task.folder) {
306
- 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.`);
307
399
  continue;
308
400
  }
309
401
  const place = placeFolder(found, task);
@@ -311,26 +403,50 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
311
403
  print(` ${where}: the backup holds nothing for this place.`);
312
404
  continue;
313
405
  }
314
- if (!task.copy.items.length) print(` ${where}: nothing left to copy; the next sync closes the task.`);
315
- 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 = "") => {
316
410
  const label = `${item.kind} ${item.name}`;
317
411
  const key = `${place}/${label}`;
318
412
  if (done.has(key)) {
319
413
  print(` ${label}: brought over above`);
320
- continue;
414
+ return;
321
415
  }
322
416
  done.add(key);
323
- const result = await restoreItem(item, { mirror, tree, budget, place, scope: task.scope, root: task.folder, claudeDir, force, dryRun });
417
+ const result = await restoreItem(item, context);
324
418
  if (result.outcome === "command") {
325
419
  commands.push({ task: where, label, ...result });
326
- continue;
420
+ return;
421
+ }
422
+ if (result.outcome === "exists" && result.same) {
423
+ inPlace += 1;
424
+ return;
327
425
  }
328
426
  if (result.outcome === "copied") {
329
427
  copied += 1;
330
428
  for (const hint of result.environments ?? []) environments.push(`${item.name} has ${hint}`);
331
429
  }
332
- 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)`);
333
448
  }
449
+ if (inPlace) print(` ${where}: ${plural(inPlace, "item")} already in place, the same as in the backup.`);
334
450
  }
335
451
  }
336
452
  if (commands.length) {
@@ -339,7 +455,14 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
339
455
  const notes = [...(entry.placeholders?.length ? [`needs ${entry.placeholders.join(", ")}`] : []), ...(entry.review?.length ? [`review: ${entry.review.join(", ")}`] : [])];
340
456
  print(` ${entry.task}: ${entry.command}${notes.length ? ` (${notes.join("; ")})` : ""}`);
341
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
+ }
342
464
  }
343
465
  if (environments.length) print(`Rebuild: ${environments.join("; ")}.`);
344
466
  if (!dryRun) print(`${copied ? `Copied ${plural(copied, "item")}. ` : ""}Run skillcellar sync to close the tasks that are done.`);
467
+ return { folders };
345
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/secrets.mjs CHANGED
@@ -9,6 +9,16 @@ const EDGE = String.raw`(?<![A-Za-z0-9])`;
9
9
  const END = String.raw`(?![A-Za-z0-9])`;
10
10
  // A value that is not a placeholder, a variable, a template, an example or a reference into config or code.
11
11
  const NOT_PLACEHOLDER = String.raw`(?!\$\{|\$[A-Za-z_]|<|\{\{|%|your[_-]|xxx|example|placeholder|change[_-]?me|redacted|none\b|null\b|true\b|false\b|\*{3}|process\.|os\.|env\.|config\.|settings\.|this\.|self\.)`;
12
+ // The characters an assigned or bearer value is read with.
13
+ const VALUE = String.raw`[A-Za-z0-9_\-./+=]`;
14
+ // A word in either case, for a pattern that minds case.
15
+ const anyCase = (word) => word.replace(/[a-z]/g, (letter) => `[${letter}${letter.toUpperCase()}]`);
16
+ const EXAMPLE_WORDS = ["dummy", "fake", "sample", "example", "placeholder", "redacted", "change[_-]?me"].map(anyCase).join("|");
17
+ // A value as documentation writes one, not a key: elided (abc123... or abc123…), or holding a word,
18
+ // not part of a longer one, that calls it an example. Real keys are neither.
19
+ const NOT_EXAMPLE = String.raw`(?!${VALUE}{0,255}?(?:\.\.\.|(?<![A-Za-z])(?:${EXAMPLE_WORDS})(?![A-Za-z])))(?!${VALUE}{1,255}…)`;
20
+ // Code such as base64.b64encode( is no value; a quoted value is one, whatever it holds.
21
+ const NOT_CALL = String.raw`(?:(?<=["'\x60])|(?![A-Za-z_]\w{0,60}(?:\.[A-Za-z_]\w{0,60}){1,5}\())`;
12
22
 
13
23
  const PATTERNS = [
14
24
  ["a private key", /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/],
@@ -25,12 +35,12 @@ const PATTERNS = [
25
35
  ["a JSON Web Token", new RegExp(String.raw`${EDGE}eyJ[A-Za-z0-9_-]{10,2000}\.[A-Za-z0-9_-]{10,2000}\.[A-Za-z0-9_-]{10,2000}(?![A-Za-z0-9_-])`)],
26
36
  // user:password@host in an address of any scheme, unless the password is a placeholder.
27
37
  ["a password in an address", /[a-z][a-z0-9+.-]{0,30}:\/\/[^\s/@:'"]{1,100}:(?!\$\{)[^\s/@'"]{1,200}@/i],
28
- ["a bearer or basic credential", new RegExp(String.raw`\b(?:Bearer|Basic)\s{1,5}(?!\$\{|\$[A-Za-z_]|<)[A-Za-z0-9_\-./+=]{16,2000}(?![A-Za-z0-9_\-./+=])`)],
38
+ ["a bearer or basic credential", new RegExp(String.raw`\b(?:Bearer|Basic)\s{1,5}(?!\$\{|\$[A-Za-z_]|<)${NOT_EXAMPLE}[A-Za-z0-9_\-./+=]{16,2000}(?![A-Za-z0-9_\-./+=])`)],
29
39
  // A key named as one, assigned a value with a digit in it, as random keys have; the name may be quoted, as in JSON.
30
40
  [
31
41
  "a key or password in an assignment",
32
42
  new RegExp(
33
- String.raw`${EDGE}(?:api[_-]?key|apikey|secret[_-]?(?:access[_-]?)?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|password|passwd|secret|token)${END}["'\x60]?\s{0,5}[:=]\s{0,5}["'\x60]?${NOT_PLACEHOLDER}(?=[A-Za-z0-9_\-./+=]{0,254}[0-9])[A-Za-z0-9_\-./+=]{8,255}`,
43
+ String.raw`${EDGE}(?:api[_-]?key|apikey|secret[_-]?(?:access[_-]?)?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|password|passwd|secret|token)${END}["'\x60]?\s{0,5}[:=]\s{0,5}["'\x60]?${NOT_PLACEHOLDER}${NOT_EXAMPLE}${NOT_CALL}(?=[A-Za-z0-9_\-./+=]{0,254}[0-9])[A-Za-z0-9_\-./+=]{8,255}`,
34
44
  "i",
35
45
  ),
36
46
  ],