skillcellar 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/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,345 @@
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 }) {
126
+ const { kind, name } = item;
127
+ if (item.tracked) return { outcome: "repository" };
128
+ if (kind === "plugin" || (kind === "skill" && item.install)) {
129
+ const command = item.install?.command;
130
+ if (typeof command !== "string") return { outcome: "missing" };
131
+ return (kind === "plugin" ? PLUGIN_RE : NPX_SKILLS_RE).test(command) ? { outcome: "command", command } : { outcome: "odd-command", command };
132
+ }
133
+ if (!["skill", "mcp", "memory", "agent"].includes(kind)) return { outcome: "unknown-kind" };
134
+ if (!sentName(kind, name, scope === "project")) return { outcome: "unsendable" };
135
+ const own = (...segments) => join(root ?? claudeDir, ...(root ? [".claude"] : []), ...segments);
136
+ if (kind === "mcp") {
137
+ const local = await readJsonBlob(mirror, tree.get(`${place}/mcp.local.json`), budget);
138
+ const shared = await readJsonBlob(mirror, tree.get(`${place}/mcp.json`), budget);
139
+ const holds = (json) => json?.mcpServers && typeof json.mcpServers === "object" && Object.hasOwn(json.mcpServers, name);
140
+ const fromLocal = holds(local);
141
+ const server = fromLocal ? local.mcpServers[name] : holds(shared) ? shared.mcpServers[name] : null;
142
+ if (!server || typeof server !== "object") return { outcome: "missing" };
143
+ const json = JSON.stringify(server);
144
+ const claudeScope = scope === "global" ? "user" : fromLocal ? "local" : "project";
145
+ const review = [...oddReferencesIn(json), ...("headersHelper" in server ? ["headersHelper, a command that fetches a credential"] : [])];
146
+ return { outcome: "command", command: `claude mcp add-json ${name} '${json.replaceAll("'", "'\\''")}' --scope ${claudeScope}`, placeholders: placeholdersIn(json), review };
147
+ }
148
+ if (kind === "skill") {
149
+ if (name === RESERVED_SLUG) return { outcome: "reserved" };
150
+ const prefix = `${place}/skills/${name}/`;
151
+ const files = [];
152
+ for (const [path, entry] of tree) {
153
+ if (!path.startsWith(prefix)) continue;
154
+ if (!regular(entry)) return { outcome: "link", path };
155
+ const content = await blobOf(mirror, entry, budget);
156
+ const found = findSecret(content.toString("utf8"));
157
+ if (found) return { outcome: "secret", path, line: found.line, kind: found.kind };
158
+ files.push({ segments: path.slice(prefix.length).split("/"), content, executable: entry.mode === "100755" });
159
+ }
160
+ if (!files.length) return { outcome: "missing" };
161
+ if (root) await checkProjectFolder(root, "skills");
162
+ const skills = own("skills");
163
+ const target = join(skills, name);
164
+ if (dryRun) return { outcome: (await lstat(target).catch(() => null)) && !force ? "exists" : "copied", target };
165
+ try {
166
+ await installPulled(skills, { slug: name, files }, force);
167
+ } catch (error) {
168
+ if (/is there already/.test(error.message)) return { outcome: "exists", target };
169
+ throw error;
170
+ }
171
+ return { outcome: "copied", target, environments: await environmentsOf(target) };
172
+ }
173
+ let path;
174
+ let target;
175
+ if (kind === "agent") {
176
+ path = `${place}/agents/${name}.md`;
177
+ if (root) await checkProjectFolder(root, "agents");
178
+ target = inside(own("agents"), `${name}.md`);
179
+ } else {
180
+ const [first, ...rest] = name.split("/");
181
+ path = `${place}/${name}`;
182
+ if (first === "rules") {
183
+ if (root) await checkProjectFolder(root, "rules");
184
+ target = inside(own("rules"), ...rest);
185
+ } else if (first === "memory" && root) target = inside(join(claudeDir, "projects", sessionFolderName(root), "memory"), ...rest);
186
+ else target = join(root ?? claudeDir, name);
187
+ }
188
+ const entry = tree.get(path);
189
+ if (!entry) return { outcome: "missing" };
190
+ if (!regular(entry)) return { outcome: "link", path };
191
+ if (!target) return { outcome: "unsendable" };
192
+ const content = await blobOf(mirror, entry, budget);
193
+ // The site holds the file's hash as the source reported it: a file that differs is not the source's.
194
+ if (item.hash && sha256(content) !== item.hash) return { outcome: "differs" };
195
+ const found = findSecret(content.toString("utf8"));
196
+ if (found) return { outcome: "secret", path, line: found.line, kind: found.kind };
197
+ if (dryRun) return { outcome: (await lstat(target).catch(() => null)) && !force ? "exists" : "copied", target };
198
+ return { outcome: await placeFile(content, target, force), target };
199
+ }
200
+
201
+ const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
202
+
203
+ /**
204
+ * Whether this computer may take the backup from the repository the site names: it is the one this
205
+ * computer backs up to, the user named it with --repo, or the user says yes at the terminal. The
206
+ * site's word alone is not enough: a key that could write it could name another repository.
207
+ */
208
+ async function trustedRepo(repo, source, config, flags) {
209
+ if (config?.backup?.repo === repo.name) return true;
210
+ if (typeof flags.repo === "string") return parseBackupRepo(flags.repo)?.name === repo.name;
211
+ if (!interactive()) return false;
212
+ 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] `));
213
+ }
214
+
215
+ /** What one restored item came to, as a line. */
216
+ function describe(result, label, source, dryRun) {
217
+ switch (result.outcome) {
218
+ case "copied":
219
+ return `${dryRun ? "would copy" : "copied"} ${label} to ${result.target}`;
220
+ case "exists":
221
+ return `kept ${label}: ${result.target} is there already (run again with --force to replace it)`;
222
+ case "odd-command":
223
+ return `${label}: its install command is not one this tool hands over (${result.command.slice(0, 80)}); install it from the site's page`;
224
+ case "repository":
225
+ return `${label}: tracked by the project's git; pull the repository to bring it`;
226
+ case "unsendable":
227
+ return `${label}: not a name a sync sends from this place; dismiss the task on the site`;
228
+ case "unknown-kind":
229
+ return `${label}: not a kind this tool restores`;
230
+ case "reserved":
231
+ return `${label}: the skillcellar skill itself; run skillcellar skill to update it`;
232
+ case "link":
233
+ return result.path ? `${label}: ${result.path} is a link in the backup, which is not restored` : `kept ${label}: ${result.target} is a link`;
234
+ case "secret":
235
+ return `${label}: ${result.path}:${result.line} in the backup looks like ${result.kind}; not restored`;
236
+ case "differs":
237
+ return `${label}: the backup's copy differs from what ${source.device.name} reported; run skillcellar backup there again`;
238
+ default:
239
+ return `${label}: not in the backup`;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Carries out the copy tasks of the plan whose source has a backup: refuses a public repository and
245
+ * one this computer was not told to trust, fetches the backup into a bare mirror with the user's own
246
+ * git, reads the commit the site recorded, writes skills, memory files and agents into place, and
247
+ * lists the commands left to run.
248
+ */
249
+ export async function runRestore({ plan, claudeDir, config, flags, version, print, warn }) {
250
+ const force = flags.force === true;
251
+ const dryRun = flags["dry-run"] === true;
252
+ const from = typeof flags.from === "string" ? flags.from.trim().toLowerCase() : null;
253
+ const wanted = (plan.tasks ?? []).filter(
254
+ (task) => task.action === "copy" && task.copy && (!from || task.copy.from.device.slug === from || task.copy.from.device.name.toLowerCase() === from),
255
+ );
256
+ if (!wanted.length) {
257
+ print(from ? `No copy task from ${flags.from} waits for ${plan.device.name}.` : `No copy task waits for ${plan.device.name}.`);
258
+ return;
259
+ }
260
+ const bySource = new Map();
261
+ for (const task of wanted) bySource.set(task.copy.from.device.id, [...(bySource.get(task.copy.from.device.id) ?? []), task]);
262
+
263
+ const commands = [];
264
+ const environments = [];
265
+ // 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.
266
+ const done = new Set();
267
+ let copied = 0;
268
+ for (const tasks of bySource.values()) {
269
+ const source = tasks[0].copy.from;
270
+ if (!source.backup) {
271
+ warn(`No backup of ${source.device.name} yet: run skillcellar backup there, then skillcellar restore here again. Its ${plural(tasks.length, "copy task")} wait.`);
272
+ continue;
273
+ }
274
+ const repo = parseBackupRepo(source.backup.repo, flags.ssh === true || config?.backup?.ssh === true);
275
+ if (!repo) {
276
+ warn(`The site names ${source.device.name}'s backup as ${source.backup.repo}, which is not a GitHub repository. Its tasks wait.`);
277
+ continue;
278
+ }
279
+ if (!(await trustedRepo(repo, source, config, flags))) {
280
+ 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}.`);
281
+ continue;
282
+ }
283
+ await refusePublic(repo, version);
284
+ const mirror = await ensureMirror(repo);
285
+ const commit = source.backup.commit;
286
+ const known = await git(["--git-dir", mirror, "cat-file", "-e", `${commit}^{commit}`]).then(() => true, () => false);
287
+ if (!known) {
288
+ 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.`);
289
+ continue;
290
+ }
291
+ const budget = { files: 0, bytes: 0 };
292
+ const tree = await treeAt(mirror, commit);
293
+ const found = await deviceFolder(mirror, tree, source.device, budget);
294
+ if (!found) {
295
+ warn(`${repo.name} at ${commit.slice(0, 7)} holds no backup of ${source.device.name}. Run skillcellar backup there, then restore here again.`);
296
+ continue;
297
+ }
298
+ print(`${dryRun ? "Would restore" : "Restoring"} from ${source.device.name}'s backup, ${repo.name} at ${commit.slice(0, 7)}:`);
299
+ for (const task of tasks) {
300
+ const where = task.project ? `in ${task.project.name}` : "globally";
301
+ if (task.problem) {
302
+ print(` ${where}: ${task.problem} Dismiss the task on the site.`);
303
+ continue;
304
+ }
305
+ if (task.project && !task.folder) {
306
+ print(` ${where}: this computer has no folder of ${task.project.name}; clone it, then run skillcellar restore from inside it.`);
307
+ continue;
308
+ }
309
+ const place = placeFolder(found, task);
310
+ if (!place) {
311
+ print(` ${where}: the backup holds nothing for this place.`);
312
+ continue;
313
+ }
314
+ if (!task.copy.items.length) print(` ${where}: nothing left to copy; the next sync closes the task.`);
315
+ for (const item of task.copy.items) {
316
+ const label = `${item.kind} ${item.name}`;
317
+ const key = `${place}/${label}`;
318
+ if (done.has(key)) {
319
+ print(` ${label}: brought over above`);
320
+ continue;
321
+ }
322
+ done.add(key);
323
+ const result = await restoreItem(item, { mirror, tree, budget, place, scope: task.scope, root: task.folder, claudeDir, force, dryRun });
324
+ if (result.outcome === "command") {
325
+ commands.push({ task: where, label, ...result });
326
+ continue;
327
+ }
328
+ if (result.outcome === "copied") {
329
+ copied += 1;
330
+ for (const hint of result.environments ?? []) environments.push(`${item.name} has ${hint}`);
331
+ }
332
+ print(` ${describe(result, label, source, dryRun)}`);
333
+ }
334
+ }
335
+ }
336
+ if (commands.length) {
337
+ 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):");
338
+ for (const entry of commands) {
339
+ const notes = [...(entry.placeholders?.length ? [`needs ${entry.placeholders.join(", ")}`] : []), ...(entry.review?.length ? [`review: ${entry.review.join(", ")}`] : [])];
340
+ print(` ${entry.task}: ${entry.command}${notes.length ? ` (${notes.join("; ")})` : ""}`);
341
+ }
342
+ }
343
+ if (environments.length) print(`Rebuild: ${environments.join("; ")}.`);
344
+ if (!dryRun) print(`${copied ? `Copied ${plural(copied, "item")}. ` : ""}Run skillcellar sync to close the tasks that are done.`);
345
+ }
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
  }
@@ -0,0 +1,65 @@
1
+ // What stops a backup: a line that looks like a key, a token or a password. Each pattern is bounded,
2
+ // so a line of any length is read in one pass, and a placeholder such as ${NAME}, $NAME, <your key>
3
+ // or your_api_key is not a secret. A finding is a reason to look, not proof: the backup stops either
4
+ // way, and a key of a shape no pattern knows is not found.
5
+
6
+ // A key stands alone: nothing alphanumeric right before or after it, so STRIPE_API_KEY= and "apiKey"
7
+ // count, which a word boundary would miss between _ and a letter.
8
+ const EDGE = String.raw`(?<![A-Za-z0-9])`;
9
+ const END = String.raw`(?![A-Za-z0-9])`;
10
+ // A value that is not a placeholder, a variable, a template, an example or a reference into config or code.
11
+ const NOT_PLACEHOLDER = String.raw`(?!\$\{|\$[A-Za-z_]|<|\{\{|%|your[_-]|xxx|example|placeholder|change[_-]?me|redacted|none\b|null\b|true\b|false\b|\*{3}|process\.|os\.|env\.|config\.|settings\.|this\.|self\.)`;
12
+
13
+ const PATTERNS = [
14
+ ["a private key", /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/],
15
+ ["an AWS access key", new RegExp(String.raw`${EDGE}AKIA[0-9A-Z]{16}${END}`)],
16
+ ["a GitHub token", new RegExp(String.raw`${EDGE}(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})${END}`)],
17
+ ["an OpenAI or Anthropic key", new RegExp(String.raw`${EDGE}sk-(?:proj-|ant-|svcacct-)?[A-Za-z0-9_-]{20,255}(?![A-Za-z0-9_-])`)],
18
+ ["a Slack token", new RegExp(String.raw`${EDGE}xox[abeprs]-[A-Za-z0-9-]{10,255}(?![A-Za-z0-9-])`)],
19
+ ["a Stripe key", new RegExp(String.raw`${EDGE}(?:[sr]k_(?:live|test)_[A-Za-z0-9]{16,255}|whsec_[A-Za-z0-9]{16,255})${END}`)],
20
+ ["a Google API key", new RegExp(String.raw`${EDGE}AIza[0-9A-Za-z_-]{35}(?![A-Za-z0-9_-])`)],
21
+ ["a SkillCellar API key", new RegExp(String.raw`${EDGE}skc_[A-Za-z0-9]{64}${END}`)],
22
+ ["an npm token", new RegExp(String.raw`${EDGE}npm_[A-Za-z0-9]{36}${END}`)],
23
+ ["a Neon API key", new RegExp(String.raw`${EDGE}napi_[a-z0-9]{40,255}${END}`)],
24
+ ["a Resend, Hugging Face or Groq key", new RegExp(String.raw`${EDGE}(?:re_[A-Za-z0-9_]{20,255}|hf_[A-Za-z0-9]{20,255}|gsk_[A-Za-z0-9]{20,255})${END}`)],
25
+ ["a JSON Web Token", new RegExp(String.raw`${EDGE}eyJ[A-Za-z0-9_-]{10,2000}\.[A-Za-z0-9_-]{10,2000}\.[A-Za-z0-9_-]{10,2000}(?![A-Za-z0-9_-])`)],
26
+ // user:password@host in an address of any scheme, unless the password is a placeholder.
27
+ ["a password in an address", /[a-z][a-z0-9+.-]{0,30}:\/\/[^\s/@:'"]{1,100}:(?!\$\{)[^\s/@'"]{1,200}@/i],
28
+ ["a bearer or basic credential", new RegExp(String.raw`\b(?:Bearer|Basic)\s{1,5}(?!\$\{|\$[A-Za-z_]|<)[A-Za-z0-9_\-./+=]{16,2000}(?![A-Za-z0-9_\-./+=])`)],
29
+ // A key named as one, assigned a value with a digit in it, as random keys have; the name may be quoted, as in JSON.
30
+ [
31
+ "a key or password in an assignment",
32
+ new RegExp(
33
+ String.raw`${EDGE}(?:api[_-]?key|apikey|secret[_-]?(?:access[_-]?)?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|password|passwd|secret|token)${END}["'\x60]?\s{0,5}[:=]\s{0,5}["'\x60]?${NOT_PLACEHOLDER}(?=[A-Za-z0-9_\-./+=]{0,254}[0-9])[A-Za-z0-9_\-./+=]{8,255}`,
34
+ "i",
35
+ ),
36
+ ],
37
+ ];
38
+
39
+ /**
40
+ * The first line of a text that looks like a secret, as `{ line, kind }` (the line counted from
41
+ * 1), or null. Lines are read one by one, so the file's size sets the cost, never a pattern.
42
+ */
43
+ export function findSecret(text) {
44
+ const lines = text.split("\n");
45
+ for (let index = 0; index < lines.length; index += 1) {
46
+ const line = lines[index];
47
+ for (const [kind, pattern] of PATTERNS) if (pattern.test(line)) return { line: index + 1, kind };
48
+ }
49
+ return null;
50
+ }
51
+
52
+ /** The placeholders a sanitized MCP config holds, such as ${CONTEXT7_KEY}, in order and once each. */
53
+ export function placeholdersIn(text) {
54
+ return [...new Set(Array.from(text.matchAll(/\$\{([A-Z0-9_]{1,80})\}/g), (match) => match[0]))];
55
+ }
56
+
57
+ /**
58
+ * References in an MCP config that Claude Code would expand from the environment and that are not
59
+ * plain placeholders: `${VAR:-default}`, `$VAR`, or a placeholder of another shape. Each is a reason
60
+ * to read the server's config before adding it, since it reaches into the user's environment.
61
+ */
62
+ export function oddReferencesIn(text) {
63
+ const all = Array.from(text.matchAll(/\$\{[^}]{0,200}\}|\$[A-Za-z_][A-Za-z0-9_]{0,80}/g), (match) => match[0]);
64
+ return [...new Set(all.filter((reference) => !/^\$\{[A-Z0-9_]{1,80}\}$/.test(reference)))];
65
+ }