skillcellar 0.6.1 → 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 +5 -3
- package/package.json +1 -1
- package/skill/SKILL.md +2 -2
- package/src/backup.mjs +23 -6
- package/src/cli.mjs +25 -12
- package/src/places.mjs +9 -7
- package/src/plan.mjs +3 -3
- package/src/restore.mjs +160 -44
- package/src/scan.mjs +4 -3
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,6 +29,8 @@ Both read this computer as `sync` does and send that list as `sync` sends it (na
|
|
|
29
29
|
|
|
30
30
|
## Backups
|
|
31
31
|
|
|
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
|
+
|
|
32
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.
|
|
@@ -36,13 +38,13 @@ Both read this computer as `sync` does and send that list as `sync` sends it (na
|
|
|
36
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 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
|
|
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
|
|
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.
|
|
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
|
@@ -444,6 +444,9 @@ export async function collectBackup({ device, claudeDir, claudeJson, projects, t
|
|
|
444
444
|
// How many findings a stopped backup lists.
|
|
445
445
|
const FINDINGS_MAX = 20;
|
|
446
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
|
+
|
|
447
450
|
/**
|
|
448
451
|
* The files of a backup that hold what looks like a secret, each with the line, up to FINDINGS_MAX,
|
|
449
452
|
* and those that pass despite one, each with the hash of what passed: a file the user allows by its
|
|
@@ -457,8 +460,8 @@ export function findSecretsIn(files, allowed = [], remembered = []) {
|
|
|
457
460
|
if (!found) continue;
|
|
458
461
|
const sha256 = createHash("sha256").update(file.content).digest("hex");
|
|
459
462
|
// A merged file, such as the mcp.json of several checkouts, is allowed by any file it came from.
|
|
460
|
-
const names = file.sources ?? [file.source];
|
|
461
|
-
if (allowed.some((path) => names.some((name) => name === path || name.endsWith(`/${path}`))) || remembered.some((entry) => entry?.file === file.source && entry.sha256 === sha256)) {
|
|
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)) {
|
|
462
465
|
passed.push({ file: file.source, path: file.path, sha256, line: found.line, kind: found.kind });
|
|
463
466
|
continue;
|
|
464
467
|
}
|
|
@@ -501,6 +504,14 @@ export async function currentBranch(clone) {
|
|
|
501
504
|
return head.trim() ? ref.trim() || null : null;
|
|
502
505
|
}
|
|
503
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
|
+
|
|
504
515
|
/**
|
|
505
516
|
* The clone of the backup repository, made if missing, else brought to the remote's state: the
|
|
506
517
|
* clone is the tool's own scratch area, so what a failed run left in its tree is dropped, a commit a
|
|
@@ -509,13 +520,16 @@ export async function currentBranch(clone) {
|
|
|
509
520
|
export async function ensureClone(repo) {
|
|
510
521
|
const clone = clonePath(repo.owner, repo.repo);
|
|
511
522
|
const there = await stat(join(clone, ".git")).catch(() => null);
|
|
523
|
+
const reach = (error) => {
|
|
524
|
+
throw reachError(error, repo);
|
|
525
|
+
};
|
|
512
526
|
if (!there?.isDirectory()) {
|
|
513
527
|
await mkdir(dirname(clone), { recursive: true, mode: 0o700 });
|
|
514
|
-
await git(["clone", "--quiet", repo.url, clone], { timeout: 600_000 });
|
|
528
|
+
await git(["clone", "--quiet", repo.url, clone], { timeout: 600_000 }).catch(reach);
|
|
515
529
|
return clone;
|
|
516
530
|
}
|
|
517
531
|
await git(["remote", "set-url", "origin", repo.url], { cwd: clone });
|
|
518
|
-
await git(["fetch", "--quiet", "origin"], { cwd: clone, timeout: 600_000 });
|
|
532
|
+
await git(["fetch", "--quiet", "origin"], { cwd: clone, timeout: 600_000 }).catch(reach);
|
|
519
533
|
const branch = await currentBranch(clone);
|
|
520
534
|
if (branch) {
|
|
521
535
|
await git(["reset", "--hard", "--quiet"], { cwd: clone });
|
|
@@ -533,13 +547,16 @@ export async function ensureClone(repo) {
|
|
|
533
547
|
export async function ensureMirror(repo) {
|
|
534
548
|
const mirror = `${clonePath(repo.owner, repo.repo)}.git`;
|
|
535
549
|
const there = await stat(join(mirror, "HEAD")).catch(() => null);
|
|
550
|
+
const reach = (error) => {
|
|
551
|
+
throw reachError(error, repo);
|
|
552
|
+
};
|
|
536
553
|
if (!there?.isFile()) {
|
|
537
554
|
await mkdir(dirname(mirror), { recursive: true, mode: 0o700 });
|
|
538
|
-
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);
|
|
539
556
|
return mirror;
|
|
540
557
|
}
|
|
541
558
|
await git(["--git-dir", mirror, "remote", "set-url", "origin", repo.url]);
|
|
542
|
-
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);
|
|
543
560
|
return mirror;
|
|
544
561
|
}
|
|
545
562
|
|
package/src/cli.mjs
CHANGED
|
@@ -70,11 +70,17 @@ Usage:
|
|
|
70
70
|
that looks like a secret and refuses a public repository. --repo is remembered; --ssh clones
|
|
71
71
|
and pushes over SSH; --allow <file> pushes a file you have read despite a finding in it,
|
|
72
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] [--
|
|
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.
|
|
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
|
-
|
|
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);
|
|
@@ -223,7 +230,7 @@ async function sync(flags, print, warn) {
|
|
|
223
230
|
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
224
231
|
const cwd = process.cwd();
|
|
225
232
|
const whole = flags.project === undefined;
|
|
226
|
-
let projects = await findProjects({ home, claudeDir, claudeJson, cwd });
|
|
233
|
+
let projects = await findProjects({ home, claudeDir, claudeJson, cwd, restored: config?.folders });
|
|
227
234
|
if (!whole) {
|
|
228
235
|
const here = await findProject(cwd, home);
|
|
229
236
|
if (!here) throw new UsageError("Run sync --project inside the project's folder, not in your home folder.");
|
|
@@ -300,7 +307,7 @@ async function plan(flags, print) {
|
|
|
300
307
|
const answer = await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION);
|
|
301
308
|
const home = homedir();
|
|
302
309
|
const claudeDir = claudeFolder(home);
|
|
303
|
-
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) : [];
|
|
304
311
|
// The device's slug as the site has it now: a rename changes the slugs of its folders' projects.
|
|
305
312
|
const local = await localPlan(answer, { claudeDir, projects, deviceSlug: answer.device?.slug ?? config.device.slug });
|
|
306
313
|
if (flags.json) print(JSON.stringify(local, null, 2));
|
|
@@ -352,7 +359,7 @@ async function playbook(flags, action, ref, print, warn) {
|
|
|
352
359
|
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
353
360
|
const global = await scanGlobal({ home, claudeDir, claudeJson });
|
|
354
361
|
// 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;
|
|
362
|
+
const known = here ? (await findProjects({ home, claudeDir, claudeJson, cwd: process.cwd(), restored: config.folders })).find((entry) => entry.root === here.root) : null;
|
|
356
363
|
const folders = here ? [...new Set([here.root, ...(known?.folders ?? []), process.cwd()])] : [];
|
|
357
364
|
const project = here ? await scanProject({ root: here.root, folders, claudeDir, claudeJson, tracked: await trackedFiles(here.root) }) : null;
|
|
358
365
|
const body = { ref, global: global.items, project: project?.items ?? null, updates: action === "status" };
|
|
@@ -384,8 +391,8 @@ async function playbook(flags, action, ref, print, warn) {
|
|
|
384
391
|
}
|
|
385
392
|
|
|
386
393
|
/** 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() });
|
|
394
|
+
async function knownProjects(home, claudeDir, config) {
|
|
395
|
+
return findProjects({ home, claudeDir, claudeJson: (await readJson(claudeJsonPath(home))) ?? {}, cwd: process.cwd(), restored: config?.folders });
|
|
389
396
|
}
|
|
390
397
|
|
|
391
398
|
/**
|
|
@@ -410,7 +417,7 @@ async function backup(flags, print, warn) {
|
|
|
410
417
|
? ((await call(site, config.key, "GET", `/api/v1/plan?device=${encodeURIComponent(config.device.id)}`, undefined, VERSION)).device ?? config.device)
|
|
411
418
|
: { id: null, slug: "this-computer", name: "this computer" };
|
|
412
419
|
const claudeJson = (await readJson(claudeJsonPath(home))) ?? {};
|
|
413
|
-
const projects = await knownProjects(home, claudeDir);
|
|
420
|
+
const projects = await knownProjects(home, claudeDir, config);
|
|
414
421
|
const allowed = (Array.isArray(flags.allow) ? flags.allow : []).map((path) => path.trim()).filter(Boolean);
|
|
415
422
|
// The repository is remembered as soon as a push has gone through, whatever the site answers after,
|
|
416
423
|
// with the files the user allowed despite a finding, each by the hash of what was allowed.
|
|
@@ -433,9 +440,13 @@ async function restore(flags, print, warn) {
|
|
|
433
440
|
const home = homedir();
|
|
434
441
|
const claudeDir = claudeFolder(home);
|
|
435
442
|
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) : [];
|
|
443
|
+
const projects = (answer.tasks ?? []).some((task) => task.project) ? await knownProjects(home, claudeDir, config) : [];
|
|
437
444
|
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 });
|
|
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] });
|
|
439
450
|
}
|
|
440
451
|
|
|
441
452
|
/** Installs or updates the skillcellar skill without asking: the command itself is the yes. */
|
|
@@ -482,6 +493,8 @@ export async function main(argv, toOut = console.log, toErr = console.error) {
|
|
|
482
493
|
repo: { type: "string" },
|
|
483
494
|
ssh: { type: "boolean" },
|
|
484
495
|
from: { type: "string" },
|
|
496
|
+
"take-backup": { type: "boolean" },
|
|
497
|
+
"from-backup": { type: "string", multiple: true },
|
|
485
498
|
allow: { type: "string", multiple: true },
|
|
486
499
|
help: { type: "boolean", short: "h" },
|
|
487
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,
|
|
40
|
-
*
|
|
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
|
|
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
|
@@ -140,8 +140,8 @@ export function describePlan(plan) {
|
|
|
140
140
|
if (task.project && !task.folder) {
|
|
141
141
|
detail.push(
|
|
142
142
|
task.action === "copy"
|
|
143
|
-
? `
|
|
144
|
-
: `
|
|
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.`,
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
else if (task.problem) detail.push(`Cannot be carried out: ${task.problem}`);
|
|
@@ -156,7 +156,7 @@ export function describePlan(plan) {
|
|
|
156
156
|
else if (task.install) detail.push(`${task.install.method}: ${task.install.command}`);
|
|
157
157
|
if (task.item?.page && !task.link) detail.push(`Page: ${task.item.page}`);
|
|
158
158
|
lines.push(...detail.map((line) => ` ${line}`));
|
|
159
|
-
if (task.action === "copy" && task.copy
|
|
159
|
+
if (task.action === "copy" && task.copy) lines.push(...copyLines(task.copy));
|
|
160
160
|
});
|
|
161
161
|
lines.push("", "Ask Claude Code to carry them out with the skillcellar skill, or do them yourself, then run skillcellar sync.");
|
|
162
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 (
|
|
159
|
+
for (const [file, command] of ENVIRONMENTS) if (await isFile(join(folder, file))) hints.push(`${file} (${command})`);
|
|
109
160
|
return hints;
|
|
110
161
|
}
|
|
111
162
|
|
|
@@ -116,13 +167,34 @@ async function checkProjectFolder(root, sub) {
|
|
|
116
167
|
if ((await lstat(path).catch(() => null))?.isSymbolicLink()) throw new Error(`${path} is a link, so a file could land outside the project.`);
|
|
117
168
|
}
|
|
118
169
|
|
|
170
|
+
/** The name a SKILL.md's frontmatter gives, when a command can print it as it is; null otherwise. */
|
|
171
|
+
async function skillMdName(mirror, entry, budget) {
|
|
172
|
+
if (!regular(entry)) return null;
|
|
173
|
+
const text = (await blobOf(mirror, entry, budget)).toString("utf8");
|
|
174
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1] ?? "";
|
|
175
|
+
const name = /^name:[ \t]*["']?([^"'\r\n]*?)["']?[ \t]*$/m.exec(front)?.[1];
|
|
176
|
+
return name && SKILL_NAME_RE.test(name) ? name : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The `npx skills add` command of a skill from a repository, under the name its SKILL.md gives in the
|
|
181
|
+
* backup: the skills CLI matches on that name, which can differ from the folder's (react:components
|
|
182
|
+
* installs as react-components). Globally it adds -g, without which the skills CLI installs into the
|
|
183
|
+
* folder it runs in, and -y.
|
|
184
|
+
*/
|
|
185
|
+
async function skillCommand(command, name, { mirror, tree, budget, place, scope }) {
|
|
186
|
+
const named = await skillMdName(mirror, tree.get(`${place}/skills/${name}/SKILL.md`), budget);
|
|
187
|
+
const renamed = named && / --skill \S+$/.test(command) ? command.replace(/ --skill \S+$/, () => ` --skill ${named}`) : command;
|
|
188
|
+
return scope === "global" ? `${renamed} -g -y` : renamed;
|
|
189
|
+
}
|
|
190
|
+
|
|
119
191
|
/**
|
|
120
192
|
* One item of a copy, in one place: files from the backup are written into Claude Code's folders,
|
|
121
|
-
* once they match what the source reported
|
|
122
|
-
* command installs, a plugin, a skill from a repository or an
|
|
123
|
-
* command for Claude Code or the user to run. Nothing is run here.
|
|
193
|
+
* once they match what the source reported (or the user takes the backup's copy) and hold nothing
|
|
194
|
+
* that looks like a secret, and what a command installs, a plugin, a skill from a repository or an
|
|
195
|
+
* MCP server, is answered as that command for Claude Code or the user to run. Nothing is run here.
|
|
124
196
|
*/
|
|
125
|
-
async function restoreItem(item, { mirror, tree, budget, place, scope, root, claudeDir, force, dryRun, allowed }) {
|
|
197
|
+
async function restoreItem(item, { mirror, tree, budget, place, scope, root, claudeDir, force, dryRun, allowed, takeBackup, fromBackup }) {
|
|
126
198
|
// A file that looks like it holds a secret is copied only as the source's user allowed it, byte for byte.
|
|
127
199
|
const secretIn = (path, content) => {
|
|
128
200
|
const found = findSecret(content.toString("utf8"));
|
|
@@ -130,14 +202,15 @@ async function restoreItem(item, { mirror, tree, budget, place, scope, root, cla
|
|
|
130
202
|
};
|
|
131
203
|
const { kind, name } = item;
|
|
132
204
|
if (item.tracked) return { outcome: "repository" };
|
|
133
|
-
if (kind === "plugin" || (kind === "skill" && item.install)) {
|
|
205
|
+
if (kind === "plugin" || (kind === "skill" && item.install && !fromBackup.has(name))) {
|
|
134
206
|
const command = item.install?.command;
|
|
135
207
|
if (typeof command !== "string") return { outcome: "missing" };
|
|
136
|
-
|
|
208
|
+
if (!(kind === "plugin" ? PLUGIN_RE : NPX_SKILLS_RE).test(command)) return { outcome: "odd-command", command };
|
|
209
|
+
if (kind === "plugin") return { outcome: "command", command, plugin: true };
|
|
210
|
+
return { outcome: "command", command: await skillCommand(command, name, { mirror, tree, budget, place, scope }), inBackup: tree.has(`${place}/skills/${name}/SKILL.md`) };
|
|
137
211
|
}
|
|
138
212
|
if (!["skill", "mcp", "memory", "agent"].includes(kind)) return { outcome: "unknown-kind" };
|
|
139
213
|
if (!sentName(kind, name, scope === "project")) return { outcome: "unsendable" };
|
|
140
|
-
const own = (...segments) => join(root ?? claudeDir, ...(root ? [".claude"] : []), ...segments);
|
|
141
214
|
if (kind === "mcp") {
|
|
142
215
|
const local = await readJsonBlob(mirror, tree.get(`${place}/mcp.local.json`), budget);
|
|
143
216
|
const shared = await readJsonBlob(mirror, tree.get(`${place}/mcp.json`), budget);
|
|
@@ -164,43 +237,41 @@ async function restoreItem(item, { mirror, tree, budget, place, scope, root, cla
|
|
|
164
237
|
}
|
|
165
238
|
if (!files.length) return { outcome: "missing" };
|
|
166
239
|
if (root) await checkProjectFolder(root, "skills");
|
|
167
|
-
const skills =
|
|
168
|
-
const target =
|
|
169
|
-
if (
|
|
240
|
+
const skills = join(root ?? claudeDir, ...(root ? [".claude"] : []), "skills");
|
|
241
|
+
const target = targetOf(item, { root, claudeDir });
|
|
242
|
+
if ((await lstat(target).catch(() => null)) && !force) {
|
|
243
|
+
// A folder without its SKILL.md is not a skill Claude Code loads, so it is not "there already".
|
|
244
|
+
if (!(await isFile(join(target, "SKILL.md")))) return { outcome: "incomplete", target };
|
|
245
|
+
return { outcome: "exists", target, same: await sameFiles(target, files) };
|
|
246
|
+
}
|
|
247
|
+
if (dryRun) return { outcome: "copied", target };
|
|
170
248
|
try {
|
|
171
249
|
await installPulled(skills, { slug: name, files }, force);
|
|
172
250
|
} catch (error) {
|
|
173
|
-
if (/is there already/.test(error.message)) return { outcome: "exists", target };
|
|
251
|
+
if (/is there already/.test(error.message)) return { outcome: "exists", target, same: false };
|
|
174
252
|
throw error;
|
|
175
253
|
}
|
|
254
|
+
if (!(await isFile(join(target, "SKILL.md")))) return { outcome: "vanished", target };
|
|
176
255
|
return { outcome: "copied", target, environments: await environmentsOf(target) };
|
|
177
256
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (root) await checkProjectFolder(root, "agents");
|
|
183
|
-
target = inside(own("agents"), `${name}.md`);
|
|
184
|
-
} else {
|
|
185
|
-
const [first, ...rest] = name.split("/");
|
|
186
|
-
path = `${place}/${name}`;
|
|
187
|
-
if (first === "rules") {
|
|
188
|
-
if (root) await checkProjectFolder(root, "rules");
|
|
189
|
-
target = inside(own("rules"), ...rest);
|
|
190
|
-
} else if (first === "memory" && root) target = inside(join(claudeDir, "projects", sessionFolderName(root), "memory"), ...rest);
|
|
191
|
-
else target = join(root ?? claudeDir, name);
|
|
192
|
-
}
|
|
257
|
+
if (kind === "agent" && root) await checkProjectFolder(root, "agents");
|
|
258
|
+
if (kind === "memory" && name.startsWith("rules/") && root) await checkProjectFolder(root, "rules");
|
|
259
|
+
const path = kind === "agent" ? `${place}/agents/${name}.md` : `${place}/${name}`;
|
|
260
|
+
const target = targetOf(item, { root, claudeDir });
|
|
193
261
|
const entry = tree.get(path);
|
|
194
262
|
if (!entry) return { outcome: "missing" };
|
|
195
263
|
if (!regular(entry)) return { outcome: "link", path };
|
|
196
264
|
if (!target) return { outcome: "unsendable" };
|
|
197
265
|
const content = await blobOf(mirror, entry, budget);
|
|
198
|
-
// The site holds the file's hash as the source reported it: a
|
|
199
|
-
|
|
266
|
+
// The site holds the file's hash as the source reported it: a copy that differs is taken only on the user's word.
|
|
267
|
+
const differs = Boolean(item.hash) && sha256(content) !== item.hash;
|
|
268
|
+
if (differs && !takeBackup) return { outcome: "differs" };
|
|
200
269
|
const found = secretIn(path, content);
|
|
201
270
|
if (found) return { outcome: "secret", path, line: found.line, kind: found.kind };
|
|
202
|
-
|
|
203
|
-
return { outcome:
|
|
271
|
+
const there = await lstat(target).catch(() => null);
|
|
272
|
+
if (there && !force && !there.isSymbolicLink()) return { outcome: "exists", target, same: (await readFile(target).catch(() => null))?.equals(content) === true };
|
|
273
|
+
if (dryRun) return { outcome: there?.isSymbolicLink() ? "link" : "copied", target, differs };
|
|
274
|
+
return { outcome: await placeFile(content, target, force), target, differs };
|
|
204
275
|
}
|
|
205
276
|
|
|
206
277
|
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
@@ -221,9 +292,13 @@ async function trustedRepo(repo, source, config, flags) {
|
|
|
221
292
|
function describe(result, label, source, dryRun) {
|
|
222
293
|
switch (result.outcome) {
|
|
223
294
|
case "copied":
|
|
224
|
-
return `${dryRun ? "would copy" : "copied"} ${label} to ${result.target}`;
|
|
295
|
+
return `${dryRun ? "would copy" : "copied"} ${label} to ${result.target}${result.differs ? ` (the backup's copy, which is not the one ${source.device.name} last reported)` : ""}`;
|
|
225
296
|
case "exists":
|
|
226
|
-
return `kept ${label}: ${result.target} is there already (run again with --force to replace it)`;
|
|
297
|
+
return `kept ${label}: ${result.target} is there already and differs from the backup's copy (run again with --force to replace it)`;
|
|
298
|
+
case "incomplete":
|
|
299
|
+
return `${label}: ${result.target} has no SKILL.md, so Claude Code does not load it: ${ANTIVIRUS}. Run again with --force to copy it again`;
|
|
300
|
+
case "vanished":
|
|
301
|
+
return `copied ${label} to ${result.target}, but its SKILL.md is gone already: ${ANTIVIRUS}`;
|
|
227
302
|
case "odd-command":
|
|
228
303
|
return `${label}: its install command is not one this tool hands over (${result.command.slice(0, 80)}); install it from the site's page`;
|
|
229
304
|
case "repository":
|
|
@@ -239,7 +314,7 @@ function describe(result, label, source, dryRun) {
|
|
|
239
314
|
case "secret":
|
|
240
315
|
return `${label}: ${result.path}:${result.line} in the backup looks like ${result.kind}; not restored`;
|
|
241
316
|
case "differs":
|
|
242
|
-
return `${label}: the backup's copy
|
|
317
|
+
return `${label}: the backup's copy is not the one ${source.device.name} last reported (it changed after that sync, or in the backup since). If the backup is yours as you left it, run again with --take-backup to copy it`;
|
|
243
318
|
default:
|
|
244
319
|
return `${label}: not in the backup`;
|
|
245
320
|
}
|
|
@@ -249,18 +324,21 @@ function describe(result, label, source, dryRun) {
|
|
|
249
324
|
* Carries out the copy tasks of the plan whose source has a backup: refuses a public repository and
|
|
250
325
|
* one this computer was not told to trust, fetches the backup into a bare mirror with the user's own
|
|
251
326
|
* git, reads the commit the site recorded, writes skills, memory files and agents into place, and
|
|
252
|
-
* lists the commands left to run.
|
|
327
|
+
* lists the commands left to run. Answers the project folders it restored, whatever was missing there, so a sync reads them and closes their tasks.
|
|
253
328
|
*/
|
|
254
329
|
export async function runRestore({ plan, claudeDir, config, flags, version, print, warn }) {
|
|
255
330
|
const force = flags.force === true;
|
|
256
331
|
const dryRun = flags["dry-run"] === true;
|
|
332
|
+
const takeBackup = flags["take-backup"] === true;
|
|
333
|
+
const fromBackup = new Set(Array.isArray(flags["from-backup"]) ? flags["from-backup"] : []);
|
|
257
334
|
const from = typeof flags.from === "string" ? flags.from.trim().toLowerCase() : null;
|
|
335
|
+
const folders = new Set();
|
|
258
336
|
const wanted = (plan.tasks ?? []).filter(
|
|
259
337
|
(task) => task.action === "copy" && task.copy && (!from || task.copy.from.device.slug === from || task.copy.from.device.name.toLowerCase() === from),
|
|
260
338
|
);
|
|
261
339
|
if (!wanted.length) {
|
|
262
340
|
print(from ? `No copy task from ${flags.from} waits for ${plan.device.name}.` : `No copy task waits for ${plan.device.name}.`);
|
|
263
|
-
return;
|
|
341
|
+
return { folders };
|
|
264
342
|
}
|
|
265
343
|
const bySource = new Map();
|
|
266
344
|
for (const task of wanted) bySource.set(task.copy.from.device.id, [...(bySource.get(task.copy.from.device.id) ?? []), task]);
|
|
@@ -303,6 +381,13 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
|
|
|
303
381
|
const listed = Array.isArray(found.manifest?.allowed) ? found.manifest.allowed : [];
|
|
304
382
|
const allowed = new Map(listed.filter((entry) => typeof entry?.path === "string" && typeof entry.sha256 === "string").map((entry) => [`${found.folder}/${entry.path}`, entry.sha256]));
|
|
305
383
|
print(`${dryRun ? "Would restore" : "Restoring"} from ${source.device.name}'s backup, ${repo.name} at ${commit.slice(0, 7)}:`);
|
|
384
|
+
// What any task names in a place, so the backup's other items there are told apart from them.
|
|
385
|
+
const reported = new Set(
|
|
386
|
+
tasks.flatMap((task) => {
|
|
387
|
+
const place = placeFolder(found, task);
|
|
388
|
+
return place ? task.copy.items.map((item) => `${place}/${item.kind}/${item.name}`) : [];
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
306
391
|
for (const task of tasks) {
|
|
307
392
|
const where = task.project ? `in ${task.project.name}` : "globally";
|
|
308
393
|
if (task.problem) {
|
|
@@ -310,7 +395,7 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
|
|
|
310
395
|
continue;
|
|
311
396
|
}
|
|
312
397
|
if (task.project && !task.folder) {
|
|
313
|
-
print(` ${where}:
|
|
398
|
+
print(` ${where}: no folder of ${task.project.name} that Claude Code knows on this computer. If you cloned it, run skillcellar restore inside the clone.`);
|
|
314
399
|
continue;
|
|
315
400
|
}
|
|
316
401
|
const place = placeFolder(found, task);
|
|
@@ -318,26 +403,50 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
|
|
|
318
403
|
print(` ${where}: the backup holds nothing for this place.`);
|
|
319
404
|
continue;
|
|
320
405
|
}
|
|
321
|
-
if (
|
|
322
|
-
|
|
406
|
+
if (task.folder && !dryRun) folders.add(task.folder);
|
|
407
|
+
const context = { mirror, tree, budget, place, scope: task.scope, root: task.folder, claudeDir, force, dryRun, allowed, takeBackup, fromBackup };
|
|
408
|
+
let inPlace = 0;
|
|
409
|
+
const bring = async (item, note = "") => {
|
|
323
410
|
const label = `${item.kind} ${item.name}`;
|
|
324
411
|
const key = `${place}/${label}`;
|
|
325
412
|
if (done.has(key)) {
|
|
326
413
|
print(` ${label}: brought over above`);
|
|
327
|
-
|
|
414
|
+
return;
|
|
328
415
|
}
|
|
329
416
|
done.add(key);
|
|
330
|
-
const result = await restoreItem(item,
|
|
417
|
+
const result = await restoreItem(item, context);
|
|
331
418
|
if (result.outcome === "command") {
|
|
332
419
|
commands.push({ task: where, label, ...result });
|
|
333
|
-
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (result.outcome === "exists" && result.same) {
|
|
423
|
+
inPlace += 1;
|
|
424
|
+
return;
|
|
334
425
|
}
|
|
335
426
|
if (result.outcome === "copied") {
|
|
336
427
|
copied += 1;
|
|
337
428
|
for (const hint of result.environments ?? []) environments.push(`${item.name} has ${hint}`);
|
|
338
429
|
}
|
|
339
|
-
print(` ${describe(result, label, source, dryRun)}`);
|
|
430
|
+
print(` ${describe(result, label, source, dryRun)}${note}`);
|
|
431
|
+
};
|
|
432
|
+
if (!task.copy.items.length) print(` ${where}: nothing left to copy; the next sync closes the task.`);
|
|
433
|
+
for (const item of task.copy.items) await bring(item);
|
|
434
|
+
// A copy of everything also finds what the backup holds that the source's last sync did not report.
|
|
435
|
+
if (!task.item) {
|
|
436
|
+
const unlisted = [];
|
|
437
|
+
for (const item of backupItems(tree, place, task.scope === "project")) {
|
|
438
|
+
if (reported.has(`${place}/${item.kind}/${item.name}`)) continue;
|
|
439
|
+
const target = targetOf(item, { root: task.folder, claudeDir });
|
|
440
|
+
if (target && !(await presentAt(target, item.kind))) unlisted.push(item);
|
|
441
|
+
}
|
|
442
|
+
if (unlisted.length && !takeBackup) {
|
|
443
|
+
print(
|
|
444
|
+
` ${where}: ${plural(unlisted.length, "item")} in the backup that ${source.device.name}'s last sync did not report (added after that sync, or removed before it): ${unlisted.map((item) => `${item.kind} ${item.name}`).join(", ")}. Run again with --take-backup to copy ${unlisted.length === 1 ? "it" : "them"}.`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
if (takeBackup) for (const item of unlisted) await bring(item, ` (in the backup, not in ${source.device.name}'s last sync)`);
|
|
340
448
|
}
|
|
449
|
+
if (inPlace) print(` ${where}: ${plural(inPlace, "item")} already in place, the same as in the backup.`);
|
|
341
450
|
}
|
|
342
451
|
}
|
|
343
452
|
if (commands.length) {
|
|
@@ -346,7 +455,14 @@ export async function runRestore({ plan, claudeDir, config, flags, version, prin
|
|
|
346
455
|
const notes = [...(entry.placeholders?.length ? [`needs ${entry.placeholders.join(", ")}`] : []), ...(entry.review?.length ? [`review: ${entry.review.join(", ")}`] : [])];
|
|
347
456
|
print(` ${entry.task}: ${entry.command}${notes.length ? ` (${notes.join("; ")})` : ""}`);
|
|
348
457
|
}
|
|
458
|
+
if (commands.some((entry) => entry.inBackup)) {
|
|
459
|
+
print("If npx skills add finds no matching skill, the skill has left its repository: run skillcellar restore --from-backup <name> to copy the backup's copy instead.");
|
|
460
|
+
}
|
|
461
|
+
if (commands.some((entry) => entry.plugin)) {
|
|
462
|
+
print("If /plugin install says the plugin is not in its marketplace, the marketplace was renamed after the old computer added it: install the plugin under the name /plugin marketplace list shows for it.");
|
|
463
|
+
}
|
|
349
464
|
}
|
|
350
465
|
if (environments.length) print(`Rebuild: ${environments.join("; ")}.`);
|
|
351
466
|
if (!dryRun) print(`${copied ? `Copied ${plural(copied, "item")}. ` : ""}Run skillcellar sync to close the tasks that are done.`);
|
|
467
|
+
return { folders };
|
|
352
468
|
}
|
package/src/scan.mjs
CHANGED
|
@@ -42,8 +42,9 @@ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
44
|
* The skills in a skills directory as Claude Code loads them: each folder directly inside it, or a
|
|
45
|
-
* link to one, with a SKILL.md of its own. Hidden folders, `learned
|
|
46
|
-
*
|
|
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)
|
|
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 });
|