skillcellar 0.3.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ import { lstat, readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { field } from "./check.mjs";
4
+ import { installPulled, PULL_REF_RE, readPulled } from "./pull.mjs";
5
+
6
+ // The playbook skill's folder, as the site names it: playbook-<slug>, up to a skill name's 64 characters.
7
+ const SKILL_NAME_RE = /^playbook-[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
+ const SKILL_BYTES_MAX = 1024 * 1024;
9
+ const STATUSES = new Set(["installed", "outdated", "missing"]);
10
+ const KINDS = { catalog: "catalog", skill: "this site", github: "GitHub", web: "web page" };
11
+
12
+ const plural = (n, word) => `${n} ${n === 1 ? word : `${word}s`}`;
13
+ const label = (skill) => `${field(skill.name, 80)} (${KINDS[skill.kind] ?? "skill"})`;
14
+ const placeOf = (skill) => (skill.where === "project" ? "in the project" : "globally");
15
+
16
+ /** The site's answer, checked before anything is written or printed: the playbook skill's name and text, and its skills. */
17
+ export function readPlaybook(answer) {
18
+ const skill = answer?.skill;
19
+ const skills = Array.isArray(answer?.skills) ? answer.skills : null;
20
+ const valid =
21
+ typeof skill?.name === "string" &&
22
+ skill.name.length <= 64 &&
23
+ SKILL_NAME_RE.test(skill.name) &&
24
+ typeof skill.content === "string" &&
25
+ Buffer.byteLength(skill.content) <= SKILL_BYTES_MAX &&
26
+ (answer.installPrompt === null || typeof answer.installPrompt === "string") &&
27
+ skills?.every((entry) => entry && STATUSES.has(entry.status) && (entry.pull === null || (typeof entry.pull === "string" && PULL_REF_RE.test(entry.pull))));
28
+ if (!valid) throw new Error("The site's answer is not a playbook this command can apply.");
29
+ return answer;
30
+ }
31
+
32
+ /** For someone else's playbook, the line that says nobody has reviewed it; null for one's own. */
33
+ export function cautionLine(answer) {
34
+ if (answer.playbook?.own) return null;
35
+ return `${field(answer.playbook?.name, 80)} is published by ${field(answer.playbook?.author, 40)}, not curated: nobody at the site has reviewed it. Read each step before you let Claude Code run it.`;
36
+ }
37
+
38
+ /**
39
+ * Applies a playbook into the skills folder `skills`: writes the playbook skill, and pulls the
40
+ * skills of the site it uses that the computer lacks, each checked as `pull` checks it and written
41
+ * into a hidden folder first, then moved into place. A folder already there is kept unless `force`.
42
+ * Nothing it writes leaves `skills`. With `dryRun`, it lists the files it would write and writes
43
+ * none; otherwise `confirm`, when given, sees each file first and can refuse them all. Answers the
44
+ * lines to print and the pulls and writes that failed.
45
+ */
46
+ export async function applyPlaybook({ answer, skills, force, dryRun, fetchPull, confirm = null }) {
47
+ const lines = [];
48
+ const writes = [];
49
+ const kept = [];
50
+ const failed = [];
51
+
52
+ const folder = join(skills, answer.skill.name);
53
+ const there = await lstat(folder).catch(() => null);
54
+ // Up to date is the folder apply writes, as status hashes it: that SKILL.md alone, read only as a
55
+ // file of a skill's size, since a checkout can put a link to a device or a pipe there.
56
+ const only = there?.isDirectory() ? (await readdir(folder).catch(() => [])).join("/") === "SKILL.md" : false;
57
+ const file = only ? await lstat(join(folder, "SKILL.md")).catch(() => null) : null;
58
+ const current = file?.isFile() && file.size <= SKILL_BYTES_MAX ? await readFile(join(folder, "SKILL.md"), "utf8").catch(() => null) : null;
59
+ if (current === answer.skill.content) lines.push(`The playbook skill in ${folder} is up to date.`);
60
+ else if (there && !force) kept.push(`${folder}, which differs from the playbook on the site; run playbook apply again with --force to replace it`);
61
+ else {
62
+ const files = [{ segments: ["SKILL.md"], content: Buffer.from(answer.skill.content), executable: false }];
63
+ writes.push({ ref: null, pulled: { slug: answer.skill.name, version: null, files }, replace: !!there });
64
+ }
65
+
66
+ // Each folder is written once: a pulled skill never takes the playbook skill's place, or another's.
67
+ const folders = new Set([answer.skill.name]);
68
+ for (const skill of answer.skills) {
69
+ if (skill.status !== "missing" || !skill.pull || skill.forSale) continue;
70
+ let pulled;
71
+ try {
72
+ pulled = readPulled(await fetchPull(skill.pull));
73
+ } catch (error) {
74
+ failed.push(`${skill.pull}: ${field(error.message, 300)}`);
75
+ continue;
76
+ }
77
+ if (folders.has(pulled.slug)) {
78
+ failed.push(`${skill.pull}: its folder, ${pulled.slug}, is another of this playbook's.`);
79
+ continue;
80
+ }
81
+ folders.add(pulled.slug);
82
+ const target = join(skills, pulled.slug);
83
+ const exists = await lstat(target).catch(() => null);
84
+ if (exists && !force) kept.push(`${target}, which is there already; run playbook apply again with --force to replace it`);
85
+ else writes.push({ ref: skill.pull, pulled, replace: !!exists });
86
+ }
87
+
88
+ const files = writes.flatMap(({ pulled }) => pulled.files.map((entry) => ({ path: join(skills, pulled.slug, ...entry.segments), executable: entry.executable })));
89
+ if (dryRun) {
90
+ for (const { pulled, replace } of writes) if (replace) lines.push(`Would replace ${join(skills, pulled.slug)} and everything in it.`);
91
+ lines.push(files.length ? `Would write ${plural(files.length, "file")}:` : "Would write nothing.", ...files.map(({ path }) => ` ${path}`));
92
+ } else if (files.length && confirm && !(await confirm(files))) {
93
+ return { lines: ["Nothing written."], failed };
94
+ } else {
95
+ for (const { ref, pulled, replace } of writes) {
96
+ try {
97
+ const target = await installPulled(skills, pulled, replace);
98
+ lines.push(ref ? `Pulled ${ref}, version ${field(pulled.version, 20)}, into ${target}.` : `Wrote the playbook skill into ${target}.`);
99
+ } catch (error) {
100
+ failed.push(`${ref ?? "the playbook skill"}: ${field(error.message, 300)}`);
101
+ }
102
+ }
103
+ }
104
+ for (const note of kept) lines.push(`Kept ${note}.`);
105
+
106
+ const here = answer.skills.filter((skill) => skill.status !== "missing");
107
+ if (here.length) lines.push(`Already on this computer: ${here.map((skill) => `${field(skill.name, 80)}${skill.where === "global" ? " (globally)" : ""}`).join(", ")}.`);
108
+ for (const skill of answer.skills.filter((entry) => entry.status === "missing" && entry.kind === "skill" && !entry.pull)) {
109
+ lines.push(
110
+ skill.forSale
111
+ ? `Requires purchase: ${field(skill.name, 80)}. Buy it at ${field(skill.page, 200) || "its page"}, then run playbook apply again.`
112
+ : `Not pulled: ${field(skill.name, 80)}, which the site names no way to pull. ${field(skill.install, 300)}.`,
113
+ );
114
+ }
115
+ const elsewhere = answer.skills.filter((skill) => skill.status === "missing" && skill.kind !== "skill").length;
116
+ if (answer.installPrompt && elsewhere) {
117
+ lines.push(
118
+ "",
119
+ `${elsewhere === 1 ? "1 of its skills comes" : `${elsewhere} of its skills come`} from elsewhere, and this command never runs their installers. Paste this prompt into Claude Code to install ${elsewhere === 1 ? "it" : "them"}:`,
120
+ "",
121
+ answer.installPrompt,
122
+ );
123
+ }
124
+ return { lines, failed };
125
+ }
126
+
127
+ /** The site's check of a playbook against this computer, as lines a person reads. `project` names the project the command ran in. */
128
+ export function describeStatus(answer, project) {
129
+ const skills = answer.skills;
130
+ const ref = field(answer.playbook?.ref, 120);
131
+ const outdated = skills.filter((skill) => skill.status === "outdated");
132
+ const missing = skills.filter((skill) => skill.status === "missing");
133
+ const current = skills.filter((skill) => skill.status === "installed");
134
+ const where = project ? `in ${field(project, 80)} and globally` : "globally";
135
+ const lines = [
136
+ `${field(answer.playbook?.name, 80)} (${ref}) on this computer, ${where}: ${skills.length - missing.length} of ${plural(skills.length, "skill")} installed${outdated.length ? `, ${outdated.length} of them outdated` : ""}.`,
137
+ ];
138
+ const state = answer.skill?.state;
139
+ lines.push(
140
+ state === "current"
141
+ ? "The playbook skill is installed."
142
+ : state === "other"
143
+ ? `The playbook skill here is another copy, older or edited: skillcellar playbook apply ${ref} --force replaces it.`
144
+ : `The playbook skill is not installed: skillcellar playbook apply ${ref} writes it.`,
145
+ );
146
+ if (current.length) lines.push("", "Installed:", ...current.map((skill) => `- ${label(skill)}, ${placeOf(skill)}`));
147
+ if (outdated.length) {
148
+ lines.push("", "Outdated:", ...outdated.map((skill) => `- ${label(skill)}, ${placeOf(skill)}${skill.update ? `: ${field(skill.update, 300)}` : ""}`));
149
+ }
150
+ if (missing.length) {
151
+ lines.push(
152
+ "",
153
+ "Missing:",
154
+ ...missing.map((skill) =>
155
+ skill.forSale
156
+ ? `- ${label(skill)}: requires purchase at ${field(skill.page, 200) || "its page"}`
157
+ : skill.pull
158
+ ? `- ${label(skill)}: skillcellar playbook apply ${ref} pulls it`
159
+ : `- ${label(skill)}: ${field(skill.install, 300)}`,
160
+ ),
161
+ );
162
+ }
163
+ return lines;
164
+ }
package/src/project.mjs CHANGED
@@ -25,6 +25,21 @@ function originUrl(root) {
25
25
  });
26
26
  }
27
27
 
28
+ /**
29
+ * The files a checkout's git tracks, by their path from its root with forward slashes; an empty set
30
+ * when git is missing or the folder is no checkout. A clone brings these, so a backup leaves them out.
31
+ */
32
+ export function trackedFiles(root) {
33
+ return new Promise((done) => {
34
+ execFile(
35
+ "git",
36
+ ["-C", root, "ls-files", "-z"],
37
+ { timeout: 10_000, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, NoDefaultCurrentDirectoryInExePath: "1" } },
38
+ (error, stdout) => done(new Set(error ? [] : stdout.split("\0").filter(Boolean))),
39
+ );
40
+ });
41
+ }
42
+
28
43
  /**
29
44
  * A remote's last two path segments, `owner/repo`, and whether it is a GitHub repository: from an
30
45
  * https, ssh or git address, or an scp-style user@host:path whose host has a dot. Anything else, a
@@ -0,0 +1,352 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, mkdir, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, join, relative } from "node:path";
4
+ import { ensureMirror, git, gitBuffer, parseBackupRepo, refusePublic } from "./backup.mjs";
5
+ import { sessionFolderName } from "./places.mjs";
6
+ import { sentName, toSlug } from "./plan.mjs";
7
+ import { ask, interactive } from "./prompt.mjs";
8
+ import { checkProjectSkills, installPulled } from "./pull.mjs";
9
+ import { findSecret, oddReferencesIn, placeholdersIn } from "./secrets.mjs";
10
+
11
+ // The folder Claude Code's own skillcellar skill lives in: nothing restored may take its place.
12
+ const RESERVED_SLUG = "skillcellar";
13
+ // What a restore reads from the backup at most, as a backup's caps.
14
+ const FILES_MAX = 20_000;
15
+ const BYTES_MAX = 256 * 1024 * 1024;
16
+ // The install commands a copy may hand over, in the shapes the site's plan gives them.
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
+ 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
+
20
+ /** A path that stays inside its base folder, or null. */
21
+ function inside(base, ...segments) {
22
+ const path = join(base, ...segments);
23
+ const from = relative(base, path);
24
+ return from && !from.startsWith("..") && !isAbsolute(from) ? path : null;
25
+ }
26
+
27
+ /**
28
+ * The backup's files at one commit, by path: the mode and the blob of each entry. Read from the
29
+ * mirror's objects, never a checkout, so a link in the repository is listed and never followed.
30
+ */
31
+ async function treeAt(mirror, commit) {
32
+ const listing = await git(["--git-dir", mirror, "ls-tree", "-r", "-z", commit, "--", "devices"]);
33
+ const entries = new Map();
34
+ for (const line of listing.split("\0").filter(Boolean)) {
35
+ const match = /^(\d{6}) (\w+) ([0-9a-f]{40})\t(.+)$/s.exec(line);
36
+ if (match) entries.set(match[4], { mode: match[1], type: match[2], sha: match[3] });
37
+ }
38
+ return entries;
39
+ }
40
+
41
+ const regular = (entry) => entry?.type === "blob" && (entry.mode === "100644" || entry.mode === "100755");
42
+
43
+ /** A blob's bytes, counted against the caps. */
44
+ async function blobOf(mirror, entry, budget) {
45
+ const content = await gitBuffer(["--git-dir", mirror, "cat-file", "blob", entry.sha]);
46
+ budget.files += 1;
47
+ budget.bytes += content.length;
48
+ if (budget.files > FILES_MAX || budget.bytes > BYTES_MAX) throw new Error(`The backup holds more than ${FILES_MAX.toLocaleString("en")} files or ${BYTES_MAX / 1024 / 1024} MB: nothing more is restored.`);
49
+ return content;
50
+ }
51
+
52
+ const readJsonBlob = async (mirror, entry, budget) => {
53
+ if (!regular(entry)) return null;
54
+ try {
55
+ return JSON.parse((await blobOf(mirror, entry, budget)).toString("utf8"));
56
+ } catch {
57
+ return null;
58
+ }
59
+ };
60
+
61
+ /** The folder of a device in the backup: the one whose manifest names its id, else the one of its slug. */
62
+ async function deviceFolder(mirror, tree, device, budget) {
63
+ for (const path of tree.keys()) {
64
+ const match = /^devices\/([^/]+)\/manifest\.json$/.exec(path);
65
+ if (!match) continue;
66
+ const manifest = await readJsonBlob(mirror, tree.get(path), budget);
67
+ if (manifest?.device?.id === device.id) return { folder: `devices/${match[1]}`, manifest };
68
+ }
69
+ const manifest = await readJsonBlob(mirror, tree.get(`devices/${device.slug}/manifest.json`), budget);
70
+ return manifest ? { folder: `devices/${device.slug}`, manifest } : null;
71
+ }
72
+
73
+ /** The backup folder of a task's place: global, or the project's by its repository, its slug or its name. */
74
+ function placeFolder(found, task) {
75
+ if (!task.project) return `${found.folder}/global`;
76
+ const places = Array.isArray(found.manifest?.places) ? found.manifest.places : [];
77
+ const place =
78
+ (task.project.repo && places.find((entry) => entry.repo === task.project.repo)) ??
79
+ places.find((entry) => entry.folder === `projects/${task.project.slug}`) ??
80
+ places.find((entry) => typeof entry.name === "string" && toSlug(entry.name) === task.project.slug);
81
+ if (!place || typeof place.folder !== "string" || !/^projects\/[a-z0-9-]+$/.test(place.folder)) return null;
82
+ return `${found.folder}/${place.folder}`;
83
+ }
84
+
85
+ const sha256 = (content) => createHash("sha256").update(content).digest("hex");
86
+
87
+ /** Writes one file into place; "exists" when one is there already and `force` is not set, "link" when the target is a link. */
88
+ async function placeFile(content, target, force) {
89
+ const there = await lstat(target).catch(() => null);
90
+ if (there?.isSymbolicLink()) return "link";
91
+ if (there && !force) return "exists";
92
+ await mkdir(dirname(target), { recursive: true });
93
+ await writeFile(target, content);
94
+ return "copied";
95
+ }
96
+
97
+ const ENVIRONMENTS = [
98
+ ["package.json", "npm install"],
99
+ ["requirements.txt", "pip install -r requirements.txt"],
100
+ ["pyproject.toml", "pip install . (or uv sync)"],
101
+ ["playwright.config.ts", "npx playwright install"],
102
+ ["playwright.config.js", "npx playwright install"],
103
+ ];
104
+
105
+ /** The environments a restored skill folder names, each with the command that rebuilds it. */
106
+ async function environmentsOf(folder) {
107
+ const hints = [];
108
+ for (const [file, command] of ENVIRONMENTS) if ((await stat(join(folder, file)).catch(() => null))?.isFile()) hints.push(`${file} (${command})`);
109
+ return hints;
110
+ }
111
+
112
+ /** Refuses a project whose `.claude` or the folder written into is a link: a cloned repository can carry one that points anywhere. */
113
+ async function checkProjectFolder(root, sub) {
114
+ await checkProjectSkills(root);
115
+ const path = join(root, ".claude", sub);
116
+ if ((await lstat(path).catch(() => null))?.isSymbolicLink()) throw new Error(`${path} is a link, so a file could land outside the project.`);
117
+ }
118
+
119
+ /**
120
+ * 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.
124
+ */
125
+ async function restoreItem(item, { mirror, tree, budget, place, scope, root, claudeDir, force, dryRun, allowed }) {
126
+ // A file that looks like it holds a secret is copied only as the source's user allowed it, byte for byte.
127
+ const secretIn = (path, content) => {
128
+ const found = findSecret(content.toString("utf8"));
129
+ return found && allowed.get(path) !== sha256(content) ? found : null;
130
+ };
131
+ const { kind, name } = item;
132
+ if (item.tracked) return { outcome: "repository" };
133
+ if (kind === "plugin" || (kind === "skill" && item.install)) {
134
+ const command = item.install?.command;
135
+ if (typeof command !== "string") return { outcome: "missing" };
136
+ return (kind === "plugin" ? PLUGIN_RE : NPX_SKILLS_RE).test(command) ? { outcome: "command", command } : { outcome: "odd-command", command };
137
+ }
138
+ if (!["skill", "mcp", "memory", "agent"].includes(kind)) return { outcome: "unknown-kind" };
139
+ if (!sentName(kind, name, scope === "project")) return { outcome: "unsendable" };
140
+ const own = (...segments) => join(root ?? claudeDir, ...(root ? [".claude"] : []), ...segments);
141
+ if (kind === "mcp") {
142
+ const local = await readJsonBlob(mirror, tree.get(`${place}/mcp.local.json`), budget);
143
+ const shared = await readJsonBlob(mirror, tree.get(`${place}/mcp.json`), budget);
144
+ const holds = (json) => json?.mcpServers && typeof json.mcpServers === "object" && Object.hasOwn(json.mcpServers, name);
145
+ const fromLocal = holds(local);
146
+ const server = fromLocal ? local.mcpServers[name] : holds(shared) ? shared.mcpServers[name] : null;
147
+ if (!server || typeof server !== "object") return { outcome: "missing" };
148
+ const json = JSON.stringify(server);
149
+ const claudeScope = scope === "global" ? "user" : fromLocal ? "local" : "project";
150
+ const review = [...oddReferencesIn(json), ...("headersHelper" in server ? ["headersHelper, a command that fetches a credential"] : [])];
151
+ return { outcome: "command", command: `claude mcp add-json ${name} '${json.replaceAll("'", "'\\''")}' --scope ${claudeScope}`, placeholders: placeholdersIn(json), review };
152
+ }
153
+ if (kind === "skill") {
154
+ if (name === RESERVED_SLUG) return { outcome: "reserved" };
155
+ const prefix = `${place}/skills/${name}/`;
156
+ const files = [];
157
+ for (const [path, entry] of tree) {
158
+ if (!path.startsWith(prefix)) continue;
159
+ if (!regular(entry)) return { outcome: "link", path };
160
+ const content = await blobOf(mirror, entry, budget);
161
+ const found = secretIn(path, content);
162
+ if (found) return { outcome: "secret", path, line: found.line, kind: found.kind };
163
+ files.push({ segments: path.slice(prefix.length).split("/"), content, executable: entry.mode === "100755" });
164
+ }
165
+ if (!files.length) return { outcome: "missing" };
166
+ if (root) await checkProjectFolder(root, "skills");
167
+ const skills = own("skills");
168
+ const target = join(skills, name);
169
+ if (dryRun) return { outcome: (await lstat(target).catch(() => null)) && !force ? "exists" : "copied", target };
170
+ try {
171
+ await installPulled(skills, { slug: name, files }, force);
172
+ } catch (error) {
173
+ if (/is there already/.test(error.message)) return { outcome: "exists", target };
174
+ throw error;
175
+ }
176
+ return { outcome: "copied", target, environments: await environmentsOf(target) };
177
+ }
178
+ let path;
179
+ let target;
180
+ if (kind === "agent") {
181
+ path = `${place}/agents/${name}.md`;
182
+ if (root) await checkProjectFolder(root, "agents");
183
+ target = inside(own("agents"), `${name}.md`);
184
+ } else {
185
+ const [first, ...rest] = name.split("/");
186
+ path = `${place}/${name}`;
187
+ if (first === "rules") {
188
+ if (root) await checkProjectFolder(root, "rules");
189
+ target = inside(own("rules"), ...rest);
190
+ } else if (first === "memory" && root) target = inside(join(claudeDir, "projects", sessionFolderName(root), "memory"), ...rest);
191
+ else target = join(root ?? claudeDir, name);
192
+ }
193
+ const entry = tree.get(path);
194
+ if (!entry) return { outcome: "missing" };
195
+ if (!regular(entry)) return { outcome: "link", path };
196
+ if (!target) return { outcome: "unsendable" };
197
+ const content = await blobOf(mirror, entry, budget);
198
+ // The site holds the file's hash as the source reported it: a file that differs is not the source's.
199
+ if (item.hash && sha256(content) !== item.hash) return { outcome: "differs" };
200
+ const found = secretIn(path, content);
201
+ if (found) return { outcome: "secret", path, line: found.line, kind: found.kind };
202
+ if (dryRun) return { outcome: (await lstat(target).catch(() => null)) && !force ? "exists" : "copied", target };
203
+ return { outcome: await placeFile(content, target, force), target };
204
+ }
205
+
206
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
207
+
208
+ /**
209
+ * Whether this computer may take the backup from the repository the site names: it is the one this
210
+ * computer backs up to, the user named it with --repo, or the user says yes at the terminal. The
211
+ * site's word alone is not enough: a key that could write it could name another repository.
212
+ */
213
+ async function trustedRepo(repo, source, config, flags) {
214
+ if (config?.backup?.repo === repo.name) return true;
215
+ if (typeof flags.repo === "string") return parseBackupRepo(flags.repo)?.name === repo.name;
216
+ if (!interactive()) return false;
217
+ return /^y(es)?$/i.test(await ask(`The site names ${source.device.name}'s backup as ${repo.name}. Clone it and restore from it? [y/N] `));
218
+ }
219
+
220
+ /** What one restored item came to, as a line. */
221
+ function describe(result, label, source, dryRun) {
222
+ switch (result.outcome) {
223
+ case "copied":
224
+ return `${dryRun ? "would copy" : "copied"} ${label} to ${result.target}`;
225
+ case "exists":
226
+ return `kept ${label}: ${result.target} is there already (run again with --force to replace it)`;
227
+ case "odd-command":
228
+ 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
+ case "repository":
230
+ return `${label}: tracked by the project's git; pull the repository to bring it`;
231
+ case "unsendable":
232
+ return `${label}: not a name a sync sends from this place; dismiss the task on the site`;
233
+ case "unknown-kind":
234
+ return `${label}: not a kind this tool restores`;
235
+ case "reserved":
236
+ return `${label}: the skillcellar skill itself; run skillcellar skill to update it`;
237
+ case "link":
238
+ return result.path ? `${label}: ${result.path} is a link in the backup, which is not restored` : `kept ${label}: ${result.target} is a link`;
239
+ case "secret":
240
+ return `${label}: ${result.path}:${result.line} in the backup looks like ${result.kind}; not restored`;
241
+ case "differs":
242
+ return `${label}: the backup's copy differs from what ${source.device.name} reported; run skillcellar backup there again`;
243
+ default:
244
+ return `${label}: not in the backup`;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Carries out the copy tasks of the plan whose source has a backup: refuses a public repository and
250
+ * one this computer was not told to trust, fetches the backup into a bare mirror with the user's own
251
+ * git, reads the commit the site recorded, writes skills, memory files and agents into place, and
252
+ * lists the commands left to run.
253
+ */
254
+ export async function runRestore({ plan, claudeDir, config, flags, version, print, warn }) {
255
+ const force = flags.force === true;
256
+ const dryRun = flags["dry-run"] === true;
257
+ const from = typeof flags.from === "string" ? flags.from.trim().toLowerCase() : null;
258
+ const wanted = (plan.tasks ?? []).filter(
259
+ (task) => task.action === "copy" && task.copy && (!from || task.copy.from.device.slug === from || task.copy.from.device.name.toLowerCase() === from),
260
+ );
261
+ if (!wanted.length) {
262
+ print(from ? `No copy task from ${flags.from} waits for ${plan.device.name}.` : `No copy task waits for ${plan.device.name}.`);
263
+ return;
264
+ }
265
+ const bySource = new Map();
266
+ for (const task of wanted) bySource.set(task.copy.from.device.id, [...(bySource.get(task.copy.from.device.id) ?? []), task]);
267
+
268
+ const commands = [];
269
+ const environments = [];
270
+ // Two tasks can name one item in one place, such as a copy of it and a copy of everything there: it is brought over once.
271
+ const done = new Set();
272
+ let copied = 0;
273
+ for (const tasks of bySource.values()) {
274
+ const source = tasks[0].copy.from;
275
+ if (!source.backup) {
276
+ warn(`No backup of ${source.device.name} yet: run skillcellar backup there, then skillcellar restore here again. Its ${plural(tasks.length, "copy task")} wait.`);
277
+ continue;
278
+ }
279
+ const repo = parseBackupRepo(source.backup.repo, flags.ssh === true || config?.backup?.ssh === true);
280
+ if (!repo) {
281
+ warn(`The site names ${source.device.name}'s backup as ${source.backup.repo}, which is not a GitHub repository. Its tasks wait.`);
282
+ continue;
283
+ }
284
+ if (!(await trustedRepo(repo, source, config, flags))) {
285
+ warn(`Not restored: the site names ${source.device.name}'s backup as ${repo.name}, which this computer does not back up to. Check it is yours, then run skillcellar restore --repo ${repo.name}.`);
286
+ continue;
287
+ }
288
+ await refusePublic(repo, version);
289
+ const mirror = await ensureMirror(repo);
290
+ const commit = source.backup.commit;
291
+ const known = await git(["--git-dir", mirror, "cat-file", "-e", `${commit}^{commit}`]).then(() => true, () => false);
292
+ if (!known) {
293
+ warn(`The backup's commit ${commit.slice(0, 7)} is not in ${repo.name}: run skillcellar backup on ${source.device.name} again, then restore here. Its tasks wait.`);
294
+ continue;
295
+ }
296
+ const budget = { files: 0, bytes: 0 };
297
+ const tree = await treeAt(mirror, commit);
298
+ const found = await deviceFolder(mirror, tree, source.device, budget);
299
+ if (!found) {
300
+ warn(`${repo.name} at ${commit.slice(0, 7)} holds no backup of ${source.device.name}. Run skillcellar backup there, then restore here again.`);
301
+ continue;
302
+ }
303
+ const listed = Array.isArray(found.manifest?.allowed) ? found.manifest.allowed : [];
304
+ const allowed = new Map(listed.filter((entry) => typeof entry?.path === "string" && typeof entry.sha256 === "string").map((entry) => [`${found.folder}/${entry.path}`, entry.sha256]));
305
+ print(`${dryRun ? "Would restore" : "Restoring"} from ${source.device.name}'s backup, ${repo.name} at ${commit.slice(0, 7)}:`);
306
+ for (const task of tasks) {
307
+ const where = task.project ? `in ${task.project.name}` : "globally";
308
+ if (task.problem) {
309
+ print(` ${where}: ${task.problem} Dismiss the task on the site.`);
310
+ continue;
311
+ }
312
+ if (task.project && !task.folder) {
313
+ print(` ${where}: this computer has no folder of ${task.project.name}; clone it, then run skillcellar restore from inside it.`);
314
+ continue;
315
+ }
316
+ const place = placeFolder(found, task);
317
+ if (!place) {
318
+ print(` ${where}: the backup holds nothing for this place.`);
319
+ continue;
320
+ }
321
+ if (!task.copy.items.length) print(` ${where}: nothing left to copy; the next sync closes the task.`);
322
+ for (const item of task.copy.items) {
323
+ const label = `${item.kind} ${item.name}`;
324
+ const key = `${place}/${label}`;
325
+ if (done.has(key)) {
326
+ print(` ${label}: brought over above`);
327
+ continue;
328
+ }
329
+ done.add(key);
330
+ const result = await restoreItem(item, { mirror, tree, budget, place, scope: task.scope, root: task.folder, claudeDir, force, dryRun, allowed });
331
+ if (result.outcome === "command") {
332
+ commands.push({ task: where, label, ...result });
333
+ continue;
334
+ }
335
+ if (result.outcome === "copied") {
336
+ copied += 1;
337
+ for (const hint of result.environments ?? []) environments.push(`${item.name} has ${hint}`);
338
+ }
339
+ print(` ${describe(result, label, source, dryRun)}`);
340
+ }
341
+ }
342
+ }
343
+ if (commands.length) {
344
+ print("To run, each in the folder of its task (a command with a ${NAME} placeholder, or one marked for review, is for the user to run):");
345
+ for (const entry of commands) {
346
+ const notes = [...(entry.placeholders?.length ? [`needs ${entry.placeholders.join(", ")}`] : []), ...(entry.review?.length ? [`review: ${entry.review.join(", ")}`] : [])];
347
+ print(` ${entry.task}: ${entry.command}${notes.length ? ` (${notes.join("; ")})` : ""}`);
348
+ }
349
+ }
350
+ if (environments.length) print(`Rebuild: ${environments.join("; ")}.`);
351
+ if (!dryRun) print(`${copied ? `Copied ${plural(copied, "item")}. ` : ""}Run skillcellar sync to close the tasks that are done.`);
352
+ }
package/src/scan.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readdir, readFile, realpath, stat } from "node:fs/promises";
2
- import { join } from "node:path";
2
+ import { join, relative } from "node:path";
3
3
  import { agentItems, memoryItems } from "./files.mjs";
4
4
  import { folderHash } from "./hash.mjs";
5
5
  import { mcpItems } from "./mcp.mjs";
@@ -76,12 +76,17 @@ function lockSource(entry) {
76
76
  return rest.length || !owner || !repo ? null : githubRepository(owner, repo);
77
77
  }
78
78
 
79
- /** The folder a lock entry's SKILL.md sits in, relative to its repository; null at the root or when odd. */
80
- function lockFolder(entry) {
79
+ /**
80
+ * Where a lock entry's SKILL.md sits in its repository: `{ folder }`, null for the repository's
81
+ * root; or null for an entry whose folder the sync cannot send as it is (odd characters, over 200
82
+ * characters, or no path at all), which must not pass for the root.
83
+ */
84
+ export function lockFolder(entry) {
81
85
  if (typeof entry?.skillPath !== "string") return null;
82
86
  const folder = entry.skillPath.replaceAll("\\", "/").replace(/(^|\/)SKILL\.md$/, "");
87
+ if (!folder) return { folder: null };
83
88
  const segments = folder.split("/");
84
- return folder && folder.length <= PATH_MAX && segments.every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..") ? folder : null;
89
+ return folder.length <= PATH_MAX && segments.every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..") ? { folder } : null;
85
90
  }
86
91
 
87
92
  /**
@@ -132,21 +137,27 @@ function lockEntries(lock) {
132
137
 
133
138
  /**
134
139
  * A skill folder as the sync sends it. A folder `npx skills` installed carries what its lock
135
- * records: the repository, the folder in it and its hash; any other is hashed here.
140
+ * records: the repository, the folder in it and its hash; any other is hashed here, as is one
141
+ * whose lock names a folder the sync cannot send, since a tree hash without its folder would be
142
+ * read as the root's.
136
143
  */
137
- async function skillItem(folder, locked, lockHashKind) {
144
+ async function skillItem(folder, locked, lockHashKind, tracked) {
138
145
  const entry = locked.get(folder.name);
139
146
  const source = lockSource(entry);
147
+ const located = lockFolder(entry);
140
148
  const lockHash = lockHashKind === "git-tree" ? entry?.skillFolderHash : entry?.computedHash;
141
- if (source && typeof lockHash === "string" && /^[0-9a-f]+$/.test(lockHash) && lockHash.length === (lockHashKind === "git-tree" ? 40 : 64)) {
142
- return { kind: "skill", name: folder.name, source, skillPath: lockFolder(entry), hash: lockHash, hashKind: lockHashKind, version: null };
149
+ if (source && located && typeof lockHash === "string" && /^[0-9a-f]+$/.test(lockHash) && lockHash.length === (lockHashKind === "git-tree" ? 40 : 64)) {
150
+ return { kind: "skill", name: folder.name, source, skillPath: located.folder, hash: lockHash, hashKind: lockHashKind, version: null, tracked };
143
151
  }
144
152
  // A file that cannot be read, or vanished meanwhile, costs only this folder its hash.
145
153
  const hash = await folderHash(folder.path).catch(() => null);
146
- return { kind: "skill", name: folder.name, source, skillPath: source ? lockFolder(entry) : null, hash, hashKind: hash ? "sha256" : null, version: null };
154
+ return { kind: "skill", name: folder.name, source, skillPath: source ? (located?.folder ?? null) : null, hash, hashKind: hash ? "sha256" : null, version: null, tracked };
147
155
  }
148
156
 
149
- async function skillItems(dir, lock, lockHashKind, skipped) {
157
+ /** Whether a project's git tracks a file, by its path from the project's root. */
158
+ export const isTracked = (tracked, root, path) => tracked.has(relative(root, path).split("\\").join("/"));
159
+
160
+ async function skillItems(dir, lock, lockHashKind, skipped, trackedIn = null) {
150
161
  const items = [];
151
162
  const locked = lockEntries(lock);
152
163
  for (const folder of await skillFolders(dir)) {
@@ -154,7 +165,9 @@ async function skillItems(dir, lock, lockHashKind, skipped) {
154
165
  skipped.push(folder.name);
155
166
  continue;
156
167
  }
157
- items.push(await skillItem(folder, locked, lockHashKind));
168
+ // A skill is the project's own when its SKILL.md is.
169
+ const tracked = trackedIn ? isTracked(trackedIn.tracked, trackedIn.root, join(folder.path, "SKILL.md")) : false;
170
+ items.push(await skillItem(folder, locked, lockHashKind, tracked));
158
171
  }
159
172
  return items;
160
173
  }
@@ -203,6 +216,7 @@ async function pluginItems(claudeDir, projectFolders, skipped) {
203
216
  hash: null,
204
217
  hashKind: version ? "version" : null,
205
218
  version,
219
+ tracked: false,
206
220
  });
207
221
  }
208
222
  return items.sort(byName);
@@ -255,14 +269,15 @@ export async function scanGlobal({ home, claudeDir, claudeJson }) {
255
269
  * CLAUDE.md, AGENTS.md and rules, the session memory of each folder Claude Code ran in, and its
256
270
  * agents.
257
271
  */
258
- export async function scanProject({ root, folders = [root], claudeDir, claudeJson }) {
272
+ export async function scanProject({ root, folders = [root], claudeDir, claudeJson, tracked = new Set() }) {
259
273
  const skipped = [];
260
274
  const lock = await readJson(join(root, "skills-lock.json"));
261
275
  const mcpJson = await readJson(join(root, ".mcp.json"));
276
+ const own = { tracked, root };
262
277
  const items = [
263
- ...(await skillItems(join(root, ".claude", "skills"), lock, "sha256", skipped)),
278
+ ...(await skillItems(join(root, ".claude", "skills"), lock, "sha256", skipped, own)),
264
279
  ...(await pluginItems(claudeDir, [root, ...folders], skipped)),
265
- ...mcpItems(mcpJson?.mcpServers, skipped),
280
+ ...mcpItems(mcpJson?.mcpServers, skipped).map((item) => ({ ...item, tracked: tracked.has(".mcp.json") })),
266
281
  ...folders.flatMap((folder) => mcpItems(claudeJson?.projects?.[folder]?.mcpServers, skipped)),
267
282
  ...(await memoryItems(
268
283
  [
@@ -274,8 +289,9 @@ export async function scanProject({ root, folders = [root], claudeDir, claudeJso
274
289
  ...[...new Set([root, ...folders])].map((folder) => ({ name: "memory", dir: join(claudeDir, "projects", sessionFolderName(folder), "memory"), deep: false })),
275
290
  ],
276
291
  skipped,
292
+ own,
277
293
  )),
278
- ...(await agentItems(join(root, ".claude", "agents"), skipped)),
294
+ ...(await agentItems(join(root, ".claude", "agents"), skipped, own)),
279
295
  ];
280
296
  return { items: distinct(items), skipped };
281
297
  }