skillcellar 0.6.1 → 0.8.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 +20 -5
- package/package.json +1 -1
- package/skill/SKILL.md +2 -2
- package/src/backup.mjs +23 -6
- package/src/cli.mjs +116 -18
- package/src/manifests.mjs +290 -0
- 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/src/tech.mjs +242 -0
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 });
|
package/src/tech.mjs
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { lstat, open, readdir, realpath } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, sep } from "node:path";
|
|
3
|
+
import { dependencyNames, manifestEcosystem, normalizeName } from "./manifests.mjs";
|
|
4
|
+
|
|
5
|
+
// Finds a project's technologies on this computer. The rules come from the site's public list at
|
|
6
|
+
// /tech.json; they are names and paths compared by prefix and suffix, never a pattern the code
|
|
7
|
+
// compiles, and only the ids they match leave the computer.
|
|
8
|
+
|
|
9
|
+
export const RULES_BYTES_MAX = 512 * 1024;
|
|
10
|
+
const RULES_VERSION = 1;
|
|
11
|
+
const ECOSYSTEMS = ["npm", "pypi", "go", "cargo", "gem", "composer", "pub"];
|
|
12
|
+
const ID_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
13
|
+
const ID_MAX = 32;
|
|
14
|
+
// The site's SYNC_TECH_MAX: the most one project reports.
|
|
15
|
+
const PER_PROJECT_MAX = 32;
|
|
16
|
+
const RULES_MAX = 500;
|
|
17
|
+
const PATTERNS_MAX = 200;
|
|
18
|
+
const PATTERN_MAX = 214;
|
|
19
|
+
const SEGMENTS_MAX = 3;
|
|
20
|
+
// A path counts up to this many segments deep: the manifests and markers that say what a project is sit near its root.
|
|
21
|
+
const PATH_SEGMENTS_MAX = 4;
|
|
22
|
+
const MANIFESTS_MAX = 40;
|
|
23
|
+
const MANIFEST_BYTES_MAX = 1024 * 1024;
|
|
24
|
+
const PROJECT_MS_MAX = 2000;
|
|
25
|
+
// Never counted, whatever the list says.
|
|
26
|
+
const ALWAYS_IGNORED = ["node_modules", ".git"];
|
|
27
|
+
|
|
28
|
+
/** Whether a rule's name or path is one this code compares safely: one `*` at most, no `..`, no backslash, no control character. */
|
|
29
|
+
const usable = (value) => typeof value === "string" && value !== "" && value.length <= PATTERN_MAX && value.split("*").length <= 2 && !value.includes("..") && !/[\\\s\p{Cc}]/u.test(value);
|
|
30
|
+
|
|
31
|
+
/** A package name or a prefix of one ending in `*`, normalised as its registry compares names. */
|
|
32
|
+
function namePattern(ecosystem, name) {
|
|
33
|
+
if (!usable(name)) return null;
|
|
34
|
+
const star = name.endsWith("*");
|
|
35
|
+
if (name.includes("*") && !star) return null;
|
|
36
|
+
const prefix = normalizeName(ecosystem, star ? name.slice(0, -1) : name);
|
|
37
|
+
return prefix ? { prefix, star } : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A path of 1 to 3 segments, tied to the project's root when it starts with `/`, a segment holding at most one `*`. */
|
|
41
|
+
function filePattern(path) {
|
|
42
|
+
if (!usable(path)) return null;
|
|
43
|
+
const anchored = path.startsWith("/");
|
|
44
|
+
const segments = (anchored ? path.slice(1) : path).split("/");
|
|
45
|
+
if (segments.length > SEGMENTS_MAX || segments.some((segment) => segment === "" || segment === ".")) return null;
|
|
46
|
+
return {
|
|
47
|
+
anchored,
|
|
48
|
+
segments: segments.map((segment) => {
|
|
49
|
+
const at = segment.indexOf("*");
|
|
50
|
+
return at === -1 ? { literal: segment } : { prefix: segment.slice(0, at), suffix: segment.slice(at + 1) };
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The list the site serves, as rules this code can use, or null for a list of another version.
|
|
57
|
+
* It comes from the network, so every field is checked, and a rule it cannot read is left out.
|
|
58
|
+
*/
|
|
59
|
+
export function readRules(json) {
|
|
60
|
+
if (json?.version !== RULES_VERSION || !Array.isArray(json.tech)) return null;
|
|
61
|
+
const listed = Array.isArray(json.ignore) ? json.ignore.filter((name) => typeof name === "string" && name !== "" && name.length <= 64 && !name.includes("/")) : [];
|
|
62
|
+
const ignore = new Set([...ALWAYS_IGNORED, ...listed.slice(0, PATTERNS_MAX)]);
|
|
63
|
+
const tech = [];
|
|
64
|
+
const seen = new Set();
|
|
65
|
+
for (const entry of json.tech.slice(0, RULES_MAX)) {
|
|
66
|
+
if (typeof entry?.id !== "string" || entry.id.length > ID_MAX || !ID_RE.test(entry.id) || seen.has(entry.id)) continue;
|
|
67
|
+
const deps = {};
|
|
68
|
+
for (const ecosystem of ECOSYSTEMS) {
|
|
69
|
+
const names = Array.isArray(entry.deps?.[ecosystem]) ? entry.deps[ecosystem].slice(0, PATTERNS_MAX) : [];
|
|
70
|
+
const patterns = names.flatMap((name) => namePattern(ecosystem, name) ?? []);
|
|
71
|
+
if (patterns.length) deps[ecosystem] = patterns;
|
|
72
|
+
}
|
|
73
|
+
const files = (Array.isArray(entry.files) ? entry.files.slice(0, PATTERNS_MAX) : []).flatMap((path) => filePattern(path) ?? []);
|
|
74
|
+
if (!files.length && !Object.keys(deps).length) continue;
|
|
75
|
+
seen.add(entry.id);
|
|
76
|
+
tech.push({ id: entry.id, deps, files });
|
|
77
|
+
}
|
|
78
|
+
return { ignore, tech };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The site's list of technologies, downloaded without the key and never through a redirect, or
|
|
83
|
+
* null when it cannot be had: missing, moved, too large, not JSON, too slow or of another version.
|
|
84
|
+
*/
|
|
85
|
+
export async function loadRules(site, version, { timeout = 10_000 } = {}) {
|
|
86
|
+
try {
|
|
87
|
+
const response = await fetch(new URL("/tech.json", site), {
|
|
88
|
+
headers: { accept: "application/json", "user-agent": `skillcellar/${version}` },
|
|
89
|
+
redirect: "error",
|
|
90
|
+
signal: AbortSignal.timeout(timeout),
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok || !response.body) return null;
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let size = 0;
|
|
95
|
+
for await (const chunk of response.body) {
|
|
96
|
+
size += chunk.length;
|
|
97
|
+
if (size > RULES_BYTES_MAX) return null;
|
|
98
|
+
chunks.push(chunk);
|
|
99
|
+
}
|
|
100
|
+
return readRules(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether the site serves /tech.json at all, whatever the list's version: it then takes `tech: false`,
|
|
108
|
+
* which needs no rules. A site without it, from before 0.8.0's server, would refuse the key.
|
|
109
|
+
*/
|
|
110
|
+
export async function techServed(site, version, { timeout = 10_000 } = {}) {
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetch(new URL("/tech.json", site), {
|
|
113
|
+
headers: { accept: "application/json", "user-agent": `skillcellar/${version}` },
|
|
114
|
+
redirect: "error",
|
|
115
|
+
signal: AbortSignal.timeout(timeout),
|
|
116
|
+
});
|
|
117
|
+
await response.body?.cancel();
|
|
118
|
+
return response.ok;
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether this computer reports technologies: what the config says, or the answer to the one
|
|
126
|
+
* question, asked only where it can be. `asked` says the answer is new, for the config to keep.
|
|
127
|
+
*/
|
|
128
|
+
export async function techChoice(saved, canAsk, askYes) {
|
|
129
|
+
if (saved === "on" || saved === "off" || saved === "clearing") return { tech: saved, asked: false };
|
|
130
|
+
if (!canAsk) return { tech: null, asked: false };
|
|
131
|
+
return { tech: (await askYes()) ? "on" : "off", asked: true };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A folder or file name a pattern's segment matches; a name starting with a dot only where the rule names the dot. */
|
|
135
|
+
function segmentMatches(pattern, segment) {
|
|
136
|
+
const named = pattern.literal ?? pattern.prefix;
|
|
137
|
+
if (segment.startsWith(".") && !named.startsWith(".")) return false;
|
|
138
|
+
if (pattern.literal !== undefined) return segment === pattern.literal;
|
|
139
|
+
return segment.length >= pattern.prefix.length + pattern.suffix.length && segment.startsWith(pattern.prefix) && segment.endsWith(pattern.suffix);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Whether a path, as its segments, is one a file rule names: the whole path for a rule tied to the root, else its end, with no dot folder before it. */
|
|
143
|
+
function pathMatches(pattern, path) {
|
|
144
|
+
const count = pattern.segments.length;
|
|
145
|
+
if (pattern.anchored ? path.length !== count : path.length < count) return false;
|
|
146
|
+
const tail = path.slice(-count);
|
|
147
|
+
if (!pattern.anchored && path.slice(0, -count).some((segment) => segment.startsWith("."))) return false;
|
|
148
|
+
return pattern.segments.every((segment, index) => segmentMatches(segment, tail[index]));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const nameMatches = (pattern, name) => (pattern.star ? name.startsWith(pattern.prefix) : name === pattern.prefix);
|
|
152
|
+
|
|
153
|
+
/** The project's files as segments, from the list git tracks, or the root's own files when it lists none. */
|
|
154
|
+
async function projectPaths(root, tracked, ignore) {
|
|
155
|
+
let listed = tracked?.size ? [...tracked] : [];
|
|
156
|
+
if (!listed.length) {
|
|
157
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
158
|
+
listed = entries.filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
159
|
+
}
|
|
160
|
+
return listed.map((path) => path.split("/")).filter((path) => path.length <= PATH_SEGMENTS_MAX && path.every((segment) => segment !== "" && !ignore.has(segment)));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Whether a path, its links resolved, is the project's folder or inside it: a linked parent folder can point anywhere. */
|
|
164
|
+
async function inside(base, path) {
|
|
165
|
+
const real = await realpath(path).catch(() => null);
|
|
166
|
+
return real !== null && (real === base || real.startsWith(base + sep));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A file or folder at the root, which git may not track, such as .neon; a link is not followed. */
|
|
170
|
+
async function isThere(base, path) {
|
|
171
|
+
const found = await lstat(path).catch(() => null);
|
|
172
|
+
return Boolean(found && (found.isFile() || found.isDirectory()) && (await inside(base, dirname(path))));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A manifest's text, or null when it is missing, a link or under one, not a plain file, or over 1 MiB. */
|
|
176
|
+
async function readManifest(base, path) {
|
|
177
|
+
const found = await lstat(path).catch(() => null);
|
|
178
|
+
if (!found?.isFile() || found.size > MANIFEST_BYTES_MAX || !(await inside(base, path))) return null;
|
|
179
|
+
let handle;
|
|
180
|
+
try {
|
|
181
|
+
handle = await open(path, "r");
|
|
182
|
+
const opened = await handle.stat();
|
|
183
|
+
if (!opened.isFile() || opened.size > MANIFEST_BYTES_MAX) return null;
|
|
184
|
+
return await handle.readFile("utf8");
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
} finally {
|
|
188
|
+
await handle?.close().catch(() => {});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The ids of the technologies a project uses, as the rules find them in its files and manifests,
|
|
194
|
+
* in the rules' order, at most 32. Nothing it reads leaves this function but those ids, and it
|
|
195
|
+
* never throws. Null when it could not tell, because a read failed or took past its time: the
|
|
196
|
+
* sync then sends nothing for the project, and the site keeps what it had, rather than fewer ids.
|
|
197
|
+
*/
|
|
198
|
+
export async function detectTech(rules, root, tracked) {
|
|
199
|
+
try {
|
|
200
|
+
const started = Date.now();
|
|
201
|
+
const base = await realpath(root);
|
|
202
|
+
const paths = await projectPaths(root, tracked, rules.ignore);
|
|
203
|
+
const found = new Set();
|
|
204
|
+
for (const rule of rules.tech) {
|
|
205
|
+
if (rule.files.some((pattern) => paths.some((path) => pathMatches(pattern, path)))) found.add(rule.id);
|
|
206
|
+
}
|
|
207
|
+
// A file the rule ties to the root by its whole name is looked for on disk too: .neon and .vercel are rarely tracked.
|
|
208
|
+
for (const rule of rules.tech) {
|
|
209
|
+
if (found.has(rule.id)) continue;
|
|
210
|
+
for (const pattern of rule.files) {
|
|
211
|
+
if (pattern.anchored && pattern.segments.every((segment) => segment.literal !== undefined) && (await isThere(base, join(root, ...pattern.segments.map((segment) => segment.literal))))) {
|
|
212
|
+
found.add(rule.id);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const wanted = new Set(rules.tech.flatMap((rule) => Object.keys(rule.deps)));
|
|
219
|
+
// A manifest in a dot folder belongs to a tool, such as a skill in .claude or a dev container, not the project.
|
|
220
|
+
const ecosystemOf = (path) => (path.slice(0, -1).some((segment) => segment.startsWith(".")) ? null : manifestEcosystem(path.at(-1), path.at(-2)));
|
|
221
|
+
const manifests = paths
|
|
222
|
+
.filter((path) => wanted.has(ecosystemOf(path)))
|
|
223
|
+
.sort((a, b) => a.length - b.length || (a.join("/") < b.join("/") ? -1 : 1))
|
|
224
|
+
.slice(0, MANIFESTS_MAX);
|
|
225
|
+
const names = new Map(ECOSYSTEMS.map((ecosystem) => [ecosystem, new Set()]));
|
|
226
|
+
for (const path of manifests) {
|
|
227
|
+
if (Date.now() - started > PROJECT_MS_MAX) return null;
|
|
228
|
+
const ecosystem = ecosystemOf(path);
|
|
229
|
+
const text = await readManifest(base, join(root, ...path));
|
|
230
|
+
if (text === null) continue;
|
|
231
|
+
for (const name of dependencyNames(ecosystem, path.at(-1), text)) names.get(ecosystem).add(normalizeName(ecosystem, name));
|
|
232
|
+
}
|
|
233
|
+
for (const rule of rules.tech) {
|
|
234
|
+
if (found.has(rule.id)) continue;
|
|
235
|
+
const uses = Object.entries(rule.deps).some(([ecosystem, patterns]) => [...names.get(ecosystem)].some((name) => patterns.some((pattern) => nameMatches(pattern, name))));
|
|
236
|
+
if (uses) found.add(rule.id);
|
|
237
|
+
}
|
|
238
|
+
return rules.tech.filter((rule) => found.has(rule.id)).map((rule) => rule.id).slice(0, PER_PROJECT_MAX);
|
|
239
|
+
} catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|