skillcellar 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +74 -2
- package/bin/skillcellar.mjs +4 -0
- package/package.json +26 -4
- package/skill/SKILL.md +78 -0
- package/src/api.mjs +41 -0
- package/src/backup.mjs +557 -0
- package/src/check.mjs +59 -0
- package/src/cli.mjs +525 -0
- package/src/config.mjs +35 -0
- package/src/files.mjs +92 -0
- package/src/hash.mjs +38 -0
- package/src/mcp.mjs +104 -0
- package/src/places.mjs +76 -0
- package/src/plan.mjs +160 -0
- package/src/playbook.mjs +164 -0
- package/src/project.mjs +77 -0
- package/src/prompt.mjs +55 -0
- package/src/pull.mjs +95 -0
- package/src/restore.mjs +345 -0
- package/src/scan.mjs +297 -0
- package/src/secrets.mjs +65 -0
- package/src/skill.mjs +21 -0
package/src/prompt.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
|
|
3
|
+
const CTRL_C = String.fromCharCode(3);
|
|
4
|
+
const BACKSPACE = [String.fromCharCode(8), String.fromCharCode(127)];
|
|
5
|
+
const ESCAPE_SEQUENCE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*[~A-Za-z]`, "g");
|
|
6
|
+
|
|
7
|
+
export const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
8
|
+
|
|
9
|
+
/** One answer typed at the terminal. */
|
|
10
|
+
export async function ask(question) {
|
|
11
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
12
|
+
try {
|
|
13
|
+
return (await rl.question(question)).trim();
|
|
14
|
+
} finally {
|
|
15
|
+
rl.close();
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A secret typed or pasted at the terminal, never echoed. */
|
|
20
|
+
export function askSecret(question) {
|
|
21
|
+
const { stdin, stdout } = process;
|
|
22
|
+
stdout.write(question);
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
let value = "";
|
|
25
|
+
const finish = (error) => {
|
|
26
|
+
stdin.off("data", onData);
|
|
27
|
+
stdin.setRawMode(false);
|
|
28
|
+
stdin.pause();
|
|
29
|
+
stdout.write("\n");
|
|
30
|
+
if (error) reject(error);
|
|
31
|
+
else resolve(value.replace(ESCAPE_SEQUENCE, "").trim());
|
|
32
|
+
};
|
|
33
|
+
const onData = (chunk) => {
|
|
34
|
+
for (const char of chunk) {
|
|
35
|
+
if (char === "\r" || char === "\n") return finish();
|
|
36
|
+
if (char === CTRL_C) return finish(new Error("Cancelled."));
|
|
37
|
+
value = BACKSPACE.includes(char) ? value.slice(0, -1) : value + char;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
stdin.setRawMode(true);
|
|
41
|
+
stdin.setEncoding("utf8");
|
|
42
|
+
stdin.on("data", onData);
|
|
43
|
+
stdin.resume();
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The first line piped into the command, for a key passed without a terminal. */
|
|
48
|
+
export async function firstLine() {
|
|
49
|
+
let text = "";
|
|
50
|
+
for await (const chunk of process.stdin) {
|
|
51
|
+
text += chunk;
|
|
52
|
+
if (text.includes("\n")) break;
|
|
53
|
+
}
|
|
54
|
+
return text.split("\n")[0].trim();
|
|
55
|
+
}
|
package/src/pull.mjs
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { chmod, lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
// What `skillcellar pull` names: <username>/<name>, or <name> for one of the user's own.
|
|
5
|
+
export const PULL_REF_RE = /^(?:[a-z0-9]+(?:-[a-z0-9]+)*\/)?[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
6
|
+
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
+
// ponytail: the site keeps 200 files and 1 MB a version; these only stop an answer that is not one.
|
|
8
|
+
const FILES_MAX = 1000;
|
|
9
|
+
const BYTES_MAX = 16 * 1024 * 1024;
|
|
10
|
+
const PATH_MAX = 255;
|
|
11
|
+
// The site's own rules for a file's path (src/lib/skills/files.ts), checked again here, so that an
|
|
12
|
+
// answer the site would never give cannot write one: characters no file name holds on one of Linux,
|
|
13
|
+
// macOS or Windows or that hide a name, Windows device names and trimmed names, and what npx skills
|
|
14
|
+
// leaves out, such as a .git folder whose config would run a command.
|
|
15
|
+
const UNSAFE_CHARACTER_RE = /[\\:*?"<>|\u2028\u2029\p{Cc}\p{Cf}\p{Default_Ignorable_Code_Point}]/u;
|
|
16
|
+
const WINDOWS_NAME_RE = /^(?:con|prn|aux|nul|com[0-9]|lpt[0-9])(?:\..*)?$|[. ]$/i;
|
|
17
|
+
const SKIPPED_FOLDERS = new Set([".git", "node_modules", "__pycache__", "__pypackages__"]);
|
|
18
|
+
const SKIPPED_FILES = new Set(["metadata.json", ".DS_Store", "Thumbs.db", "desktop.ini"]);
|
|
19
|
+
// The folder Claude Code's own skillcellar skill lives in: nothing pulled may take its place.
|
|
20
|
+
const RESERVED_SLUG = "skillcellar";
|
|
21
|
+
|
|
22
|
+
/** A file's path inside the skill's folder, as relative segments the site would store; null otherwise. */
|
|
23
|
+
function safePath(path) {
|
|
24
|
+
if (typeof path !== "string" || !path || path.length > PATH_MAX || path.startsWith("/")) return null;
|
|
25
|
+
if (UNSAFE_CHARACTER_RE.test(path) || !path.isWellFormed() || path !== path.normalize("NFC")) return null;
|
|
26
|
+
const segments = path.split("/");
|
|
27
|
+
if (segments.some((segment) => !segment || segment === "." || segment === ".." || WINDOWS_NAME_RE.test(segment))) return null;
|
|
28
|
+
if (SKIPPED_FILES.has(segments[segments.length - 1]) || segments.slice(0, -1).some((segment) => SKIPPED_FOLDERS.has(segment))) return null;
|
|
29
|
+
return segments;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The skill the site answered with, checked before anything is written: its name and files, decoded. */
|
|
33
|
+
export function readPulled(answer) {
|
|
34
|
+
const slug = answer?.skill?.slug;
|
|
35
|
+
if (typeof slug !== "string" || !SLUG_RE.test(slug) || slug === RESERVED_SLUG || !Array.isArray(answer.files) || answer.files.length > FILES_MAX) {
|
|
36
|
+
throw new Error("The site's answer is not a skill this command can install.");
|
|
37
|
+
}
|
|
38
|
+
let bytes = 0;
|
|
39
|
+
const files = answer.files.map((file) => {
|
|
40
|
+
const segments = safePath(file?.path);
|
|
41
|
+
if (!segments || typeof file.content !== "string") throw new Error(`The site's answer holds a file this command will not write: ${String(file?.path).slice(0, 80)}.`);
|
|
42
|
+
const content = Buffer.from(file.content, "base64");
|
|
43
|
+
bytes += content.length;
|
|
44
|
+
return { segments, content, executable: file.executable === true };
|
|
45
|
+
});
|
|
46
|
+
if (bytes > BYTES_MAX || !files.some((file) => file.segments.join("/") === "SKILL.md")) throw new Error("The site's answer is not a skill this command can install.");
|
|
47
|
+
return { slug, version: answer.skill.version, files };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Refuses a project whose `.claude` or `.claude/skills` is a link: a cloned repository can carry one
|
|
52
|
+
* that points anywhere, and the pull would write there.
|
|
53
|
+
*/
|
|
54
|
+
export async function checkProjectSkills(root) {
|
|
55
|
+
for (const path of [join(root, ".claude"), join(root, ".claude", "skills")]) {
|
|
56
|
+
if ((await lstat(path).catch(() => null))?.isSymbolicLink()) throw new Error(`${path} is a link, so the skill could land outside the project. Pull it globally instead.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Writes a pulled skill into `<skills>/<slug>`: into a new hidden folder beside it first, which the
|
|
62
|
+
* sync skips, then moved into place, so a failure leaves no half-written skill. A folder or link
|
|
63
|
+
* already there is replaced only with `force`, and put back when the swap fails.
|
|
64
|
+
*/
|
|
65
|
+
export async function installPulled(skills, pulled, force) {
|
|
66
|
+
const target = join(skills, pulled.slug);
|
|
67
|
+
const there = await lstat(target).catch(() => null);
|
|
68
|
+
if (there && !force) throw new Error(`${target} is there already. Run pull again with --force to replace it.`);
|
|
69
|
+
const fresh = join(skills, `.${pulled.slug}.skillcellar-${process.pid}`);
|
|
70
|
+
const old = join(skills, `.${pulled.slug}.replaced-${process.pid}`);
|
|
71
|
+
await rm(fresh, { recursive: true, force: true });
|
|
72
|
+
await mkdir(fresh, { recursive: true });
|
|
73
|
+
let moved = false;
|
|
74
|
+
try {
|
|
75
|
+
for (const file of pulled.files) {
|
|
76
|
+
const path = join(fresh, ...file.segments);
|
|
77
|
+
// The segments were checked; this is the last guard before a write.
|
|
78
|
+
if (relative(fresh, path).startsWith("..")) throw new Error("The site's answer names a file outside the skill's folder.");
|
|
79
|
+
await mkdir(dirname(path), { recursive: true });
|
|
80
|
+
await writeFile(path, file.content, { flag: "wx" });
|
|
81
|
+
await chmod(path, file.executable ? 0o755 : 0o644);
|
|
82
|
+
}
|
|
83
|
+
if (there) {
|
|
84
|
+
await rename(target, old);
|
|
85
|
+
moved = true;
|
|
86
|
+
}
|
|
87
|
+
await rename(fresh, target);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
await rm(fresh, { recursive: true, force: true });
|
|
90
|
+
if (moved && !(await lstat(target).catch(() => null))) await rename(old, target).catch(() => {});
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (moved) await rm(old, { recursive: true, force: true });
|
|
94
|
+
return target;
|
|
95
|
+
}
|
package/src/restore.mjs
ADDED
|
@@ -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
|
+
}
|