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/src/hash.mjs ADDED
@@ -0,0 +1,38 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { join, relative } from "node:path";
4
+
5
+ // ponytail: a folder past this size is reported without a hash, so a stray virtualenv cannot stall
6
+ // a sync; read the files as a stream if large skills turn out to need one.
7
+ const HASH_BYTES_MAX = 20 * 1024 * 1024;
8
+
9
+ /**
10
+ * The hash `npx skills` writes to skills-lock.json as `computedHash`, and the site stores as a
11
+ * skill's content hash: SHA-256 over each file's path relative to the folder, then its bytes, the
12
+ * paths sorted by localeCompare, skipping `.git` and `node_modules` folders and anything that is not
13
+ * a regular file. Read from the skills CLI 1.7.2 source. Null for a folder past HASH_BYTES_MAX.
14
+ */
15
+ export async function folderHash(dir) {
16
+ const files = [];
17
+ const budget = { left: HASH_BYTES_MAX };
18
+ if (!(await collect(dir, dir, files, budget))) return null;
19
+ files.sort((a, b) => a.path.localeCompare(b.path));
20
+ const hash = createHash("sha256");
21
+ for (const file of files) hash.update(file.path).update(file.content);
22
+ return hash.digest("hex");
23
+ }
24
+
25
+ async function collect(base, current, files, budget) {
26
+ for (const entry of await readdir(current, { withFileTypes: true })) {
27
+ const path = join(current, entry.name);
28
+ if (entry.isDirectory()) {
29
+ if (entry.name === ".git" || entry.name === "node_modules") continue;
30
+ if (!(await collect(base, path, files, budget))) return false;
31
+ } else if (entry.isFile()) {
32
+ budget.left -= (await stat(path)).size;
33
+ if (budget.left < 0) return false;
34
+ files.push({ path: relative(base, path).split("\\").join("/"), content: await readFile(path) });
35
+ }
36
+ }
37
+ return true;
38
+ }
package/src/mcp.mjs ADDED
@@ -0,0 +1,104 @@
1
+ import { basename } from "node:path";
2
+
3
+ // What the site accepts, checked here too, so a sync never fails on one odd server: a name an
4
+ // install prompt can print as it is, a command's own name, a package as npm or PyPI write one, and
5
+ // an address whose path holds only short words, such as /v1/mcp.
6
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
7
+ const COMMAND_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
8
+ const PACKAGE_RE = /^@?[A-Za-z0-9][A-Za-z0-9._-]*(?:\/[A-Za-z0-9][A-Za-z0-9._-]*)?(?:@[A-Za-z0-9._+-]+|==?[A-Za-z0-9._+-]+)?$/;
9
+ const ENDPOINT_RE = /^https?:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?(?:\/(?:[a-z]{1,8}|v\d{1,3})?)*$/;
10
+ const WORD_RE = /^(?:[a-z]{1,8}|v\d{1,3})$/;
11
+ const TARGET_MAX = 200;
12
+
13
+ /**
14
+ * Runners that start a package: the flags of theirs known to take a value before it, and those
15
+ * known to stand alone. Any other flag could be followed by a key, so it ends the search.
16
+ */
17
+ const RUNNERS = new Map([
18
+ ["npx", { valued: ["-p", "--package", "-c", "--call"], alone: ["-y", "--yes", "-q", "--quiet", "-s", "--silent", "--no-install", "--prefer-offline", "--prefer-online"] }],
19
+ ["bunx", { valued: ["-p", "--package"], alone: ["-b", "--bun", "--silent", "--no-install"] }],
20
+ ["pnpm dlx", { valued: ["-p", "--package"], alone: ["-s", "--silent", "--shell-mode", "-c"] }],
21
+ ["pnpx", { valued: ["-p", "--package"], alone: ["-s", "--silent"] }],
22
+ ["uvx", { valued: ["--from", "--with", "--with-editable", "--with-requirements", "--python", "-p", "--index", "--index-url"], alone: ["-q", "--quiet", "--offline", "--isolated", "--no-cache", "-n", "--refresh"] }],
23
+ ]);
24
+
25
+ /**
26
+ * The runner and the package it starts: the first argument that is not a known flag or a known
27
+ * flag's value, as npm or PyPI write a package; null for anything else, such as a path, an address
28
+ * or a secret, and after any flag it does not know.
29
+ */
30
+ function runnerPackage(command, args) {
31
+ const runner = command === "pnpm" && args[0] === "dlx" ? "pnpm dlx" : command;
32
+ const flags = RUNNERS.get(runner);
33
+ if (!flags) return null;
34
+ const rest = runner === "pnpm dlx" ? args.slice(1) : args;
35
+ for (let index = 0; index < rest.length; index += 1) {
36
+ const arg = rest[index];
37
+ if (typeof arg !== "string") return null;
38
+ if (!arg.startsWith("-")) return PACKAGE_RE.test(arg) ? `${runner} ${arg}` : null;
39
+ if (flags.valued.includes(arg)) index += 1;
40
+ else if (!flags.alone.includes(arg) && !arg.includes("=")) return null;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * An address's origin and path, up to the first part of the path that is not a short word such as
47
+ * mcp, sse or v1, since some addresses carry a key there; never its credentials, query or fragment.
48
+ * Null for anything but http and https, and for a host the site would not take.
49
+ */
50
+ function endpoint(value) {
51
+ let url;
52
+ try {
53
+ url = new URL(value);
54
+ } catch {
55
+ return null;
56
+ }
57
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
58
+ const kept = [];
59
+ for (const segment of url.pathname.split("/").slice(1)) {
60
+ if (!WORD_RE.test(segment)) break;
61
+ kept.push(segment);
62
+ }
63
+ const target = `${url.origin}/${kept.join("/")}`;
64
+ return target.length <= TARGET_MAX && ENDPOINT_RE.test(target) ? target : null;
65
+ }
66
+
67
+ /**
68
+ * An MCP server as the sync sends it: its name, its transport and what it runs or answers on. For
69
+ * a local server, the command's own name and the package a runner such as npx starts; for a remote
70
+ * one, the address's origin and path. Never its environment, headers, other arguments or a query.
71
+ * Null when the name cannot be sent as it is, and for what Claude Code itself skips or this site
72
+ * does not keep: a server with no command or address, or a WebSocket or SDK one.
73
+ */
74
+ export function mcpItem(name, server) {
75
+ if (!NAME_RE.test(name) || typeof server !== "object" || server === null) return null;
76
+ // Claude Code reads a server without a type as stdio, and streamable-http as http.
77
+ const transport = server.type === "streamable-http" ? "http" : (server.type ?? "stdio");
78
+ let target = null;
79
+ if (transport === "stdio") {
80
+ if (typeof server.command !== "string") return null;
81
+ const command = basename(server.command.replaceAll("\\", "/")).replace(/\.(cmd|exe|bat)$/i, "");
82
+ if (COMMAND_RE.test(command)) {
83
+ const run = runnerPackage(command, Array.isArray(server.args) ? server.args : []);
84
+ target = run && run.length <= TARGET_MAX ? run : command;
85
+ }
86
+ } else if (transport === "http" || transport === "sse") {
87
+ if (typeof server.url !== "string") return null;
88
+ target = endpoint(server.url);
89
+ } else {
90
+ return null;
91
+ }
92
+ return { kind: "mcp", name, source: null, skillPath: null, hash: null, hashKind: null, version: null, transport, target, tracked: false };
93
+ }
94
+
95
+ /** The MCP servers of one `mcpServers` object, by name; the names it cannot send go to `skipped`. */
96
+ export function mcpItems(servers, skipped) {
97
+ const items = [];
98
+ for (const [name, server] of Object.entries(servers ?? {})) {
99
+ const item = mcpItem(name, server);
100
+ if (item) items.push(item);
101
+ else skipped.push(name);
102
+ }
103
+ return items;
104
+ }
package/src/places.mjs ADDED
@@ -0,0 +1,76 @@
1
+ import { open, readdir, realpath, stat } from "node:fs/promises";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+ import { findProject } from "./project.mjs";
4
+
5
+ // How much of a session's transcript is read for the folder it ran in, which its first lines name.
6
+ const TRANSCRIPT_HEAD_BYTES = 64 * 1024;
7
+ const CWD_RE = /"cwd":("(?:[^"\\]|\\.)*")/;
8
+
9
+ /** The folder name Claude Code keeps a project's sessions and memory under: its path, with each character but a letter or digit as `-`. */
10
+ export const sessionFolderName = (path) => path.replace(/[^A-Za-z0-9]/g, "-");
11
+
12
+ /** The folder a session of `~/.claude/projects/<name>` ran in, read from the start of its newest transcript; null without one. */
13
+ async function sessionFolder(dir) {
14
+ let names;
15
+ try {
16
+ names = (await readdir(dir)).filter((name) => name.endsWith(".jsonl"));
17
+ } catch {
18
+ return null;
19
+ }
20
+ const dated = await Promise.all(names.map(async (name) => ({ name, at: (await stat(join(dir, name)).catch(() => null))?.mtimeMs ?? 0 })));
21
+ for (const { name } of dated.sort((a, b) => b.at - a.at)) {
22
+ const file = await open(join(dir, name), "r").catch(() => null);
23
+ if (!file) continue;
24
+ try {
25
+ const buffer = Buffer.alloc(TRANSCRIPT_HEAD_BYTES);
26
+ const { bytesRead } = await file.read(buffer, 0, TRANSCRIPT_HEAD_BYTES, 0);
27
+ const match = CWD_RE.exec(buffer.subarray(0, bytesRead).toString("utf8"));
28
+ if (match) return JSON.parse(match[1]);
29
+ } catch {
30
+ // A transcript cut mid-escape names no folder; the next one may.
31
+ } finally {
32
+ await file.close();
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Every folder Claude Code knows on this computer: the projects `~/.claude.json` lists, and the
40
+ * folder each of `~/.claude/projects/*` ran in. Folders that are gone are skipped.
41
+ */
42
+ async function knownFolders(claudeDir, claudeJson) {
43
+ const folders = new Set(Object.keys(claudeJson?.projects ?? {}));
44
+ const sessions = join(claudeDir, "projects");
45
+ const names = await readdir(sessions).catch(() => []);
46
+ const listed = new Set([...folders].map(sessionFolderName));
47
+ for (const name of names) {
48
+ if (listed.has(name)) continue;
49
+ const folder = await sessionFolder(join(sessions, name));
50
+ if (typeof folder === "string") folders.add(folder);
51
+ }
52
+ const present = [];
53
+ for (const folder of folders) if ((await stat(folder).catch(() => null))?.isDirectory()) present.push(folder);
54
+ return present;
55
+ }
56
+
57
+ const real = (path) => realpath(path).catch(() => path);
58
+
59
+ /**
60
+ * The projects on this computer, one per checkout or folder, each with the folders Claude Code ran
61
+ * in inside it: their local MCP servers and session memory belong to it. `cwd` is added when it is
62
+ * in a project. The home folder, those above it and Claude Code's own folder are never one.
63
+ */
64
+ export async function findProjects({ home, claudeDir, claudeJson, cwd }) {
65
+ const projects = new Map();
66
+ const own = await real(claudeDir);
67
+ for (const folder of [...(await knownFolders(claudeDir, claudeJson)), cwd]) {
68
+ const project = await findProject(folder, home);
69
+ const inside = project && relative(own, project.root);
70
+ if (!project || inside === "" || (inside && !inside.startsWith("..") && !isAbsolute(inside))) continue;
71
+ const known = projects.get(project.root) ?? { ...project, folders: new Set() };
72
+ known.folders.add(folder);
73
+ projects.set(project.root, known);
74
+ }
75
+ return [...projects.values()];
76
+ }
package/src/plan.mjs ADDED
@@ -0,0 +1,160 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { isAbsolute, join, relative } from "node:path";
3
+ import { sessionFolderName } from "./places.mjs";
4
+ import { readJson } from "./scan.mjs";
5
+
6
+ /** A name's slug as the site makes it: lowercase letters and digits with single hyphens between. */
7
+ export const toSlug = (name) =>
8
+ name
9
+ .toLowerCase()
10
+ .replace(/[^a-z0-9]+/g, "-")
11
+ .replace(/^-+/, "")
12
+ .slice(0, 64)
13
+ .replace(/-+$/, "");
14
+
15
+ /** A project's slug as the site makes it: its name's, and for a folder with no git remote that followed by the device's. */
16
+ export const projectSlug = (project, deviceSlug) => (project.remote ? toSlug(project.name) : `${toSlug(project.name)}-${deviceSlug}`);
17
+
18
+ /**
19
+ * The folder on this computer of a task's project, from the projects the sync finds: one with a
20
+ * GitHub repository by that repository, any other by its slug, which for a folder with no git remote
21
+ * ends with this device's. With several checkouts, the first; null when this computer has none.
22
+ */
23
+ export function projectFolder(project, projects, deviceSlug) {
24
+ const matches = projects.filter((known) => (project.repo ? known.repo === project.repo : projectSlug(known, deviceSlug) === project.slug));
25
+ return matches.map((known) => known.root).sort()[0] ?? null;
26
+ }
27
+
28
+ // A name as the sync sends it: segments of letters, digits and ._@+-, each starting with a letter or digit.
29
+ const SENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._@+-]*(?:\/[A-Za-z0-9][A-Za-z0-9._@+-]*)*$/;
30
+
31
+ const exists = async (path) => (await stat(path).catch(() => null)) !== null;
32
+
33
+ /** The first of these paths that exists, else the first. */
34
+ async function firstThere(paths) {
35
+ for (const path of paths) if (await exists(path)) return path;
36
+ return paths[0] ?? null;
37
+ }
38
+
39
+ const UNKNOWN_NAME = "Its name is not one a sync sends from this place, so this computer cannot tell what it would remove. Dismiss it on the site.";
40
+
41
+ /** A path that stays inside its base folder, or null. */
42
+ function inside(base, path) {
43
+ const from = relative(base, path);
44
+ return from && !from.startsWith("..") && !isAbsolute(from) ? path : null;
45
+ }
46
+
47
+ /**
48
+ * Whether a removal or a copy names an item the way the sync sends it from that place (scan.mjs): a
49
+ * skill, plugin or MCP server by one name, an agent by its path in its folder, and a memory file only
50
+ * from the places the sync reads. A plan naming anything else did not come from a sync of this computer.
51
+ */
52
+ export function sentName(kind, name, project) {
53
+ if (!SENT_NAME_RE.test(name)) return false;
54
+ if (kind === "agent") return true;
55
+ if (kind !== "memory") return !name.includes("/");
56
+ const [first, ...rest] = name.split("/");
57
+ if (name === "CLAUDE.md" || (first === "rules" && rest.length > 0)) return true;
58
+ return project ? name === "AGENTS.md" || (first === "memory" && rest.length === 1) : false;
59
+ }
60
+
61
+ /**
62
+ * Where an item a removal names is on this computer, as the sync read it: a skill's folder, an
63
+ * agent's or a memory file's path, or the `--scope` that `claude plugin` and `claude mcp` take for
64
+ * a plugin or an MCP server. Null for what this computer does not have where the task says; a
65
+ * `problem` for a name the sync could not have sent, which could otherwise reach out of its folder.
66
+ */
67
+ async function removalTarget(task, { claudeDir, project }) {
68
+ const { kind, name } = task.item;
69
+ const root = project?.root ?? null;
70
+ const none = { path: null, claudeScope: null, problem: null };
71
+ if (!sentName(kind, name, task.scope === "project")) return { ...none, problem: UNKNOWN_NAME };
72
+ if (task.scope === "project" && !root) return none;
73
+ const own = (...segments) => join(root ?? claudeDir, ...(root ? [".claude"] : []), ...segments);
74
+ if (kind === "skill") return { ...none, path: inside(own("skills"), own("skills", name)) };
75
+ if (kind === "agent") return { ...none, path: inside(own("agents"), own("agents", `${name}.md`)) };
76
+ if (kind === "memory") {
77
+ const [first, ...rest] = name.split("/");
78
+ if (first === "rules") return { ...none, path: inside(own("rules"), own("rules", ...rest)) };
79
+ if (first === "memory") {
80
+ const folders = [...new Set([root, ...(project.folders ?? [])])];
81
+ return { ...none, path: await firstThere(folders.map((folder) => join(claudeDir, "projects", sessionFolderName(folder), "memory", ...rest))) };
82
+ }
83
+ return { ...none, path: join(root ?? claudeDir, name) };
84
+ }
85
+ if (kind === "mcp") {
86
+ if (!root) return { ...none, claudeScope: "user" };
87
+ const shared = (await readJson(join(root, ".mcp.json")).catch(() => null))?.mcpServers ?? {};
88
+ return { ...none, claudeScope: Object.hasOwn(shared, name) ? "project" : "local" };
89
+ }
90
+ if (!root) return { ...none, claudeScope: "user" };
91
+ // A plugin installed in a project is installed for everyone there ("project") or this computer only ("local").
92
+ const installed = (await readJson(join(claudeDir, "plugins", "installed_plugins.json")).catch(() => null))?.plugins?.[name];
93
+ const here = Array.isArray(installed) ? installed.find((entry) => entry?.projectPath && [root, ...(project.folders ?? [])].includes(entry.projectPath)) : null;
94
+ return { ...none, claudeScope: here?.scope === "project" ? "project" : "local" };
95
+ }
96
+
97
+ /**
98
+ * The plan the site gives, with what only this computer knows added to each task: `folder`, the
99
+ * project's folder here (null for a global task, or a project this computer has no folder of), and
100
+ * for a removal `path` and `claudeScope`. Nothing here is sent to the site.
101
+ */
102
+ export async function localPlan(plan, { claudeDir, projects, deviceSlug }) {
103
+ const tasks = [];
104
+ for (const task of plan.tasks ?? []) {
105
+ const root = task.project ? projectFolder(task.project, projects, deviceSlug) : null;
106
+ const project = root ? projects.find((known) => known.root === root) : null;
107
+ const local = { folder: root };
108
+ if (task.action === "remove") {
109
+ const { path, claudeScope, problem } = await removalTarget(task, { claudeDir, project });
110
+ Object.assign(local, { path, claudeScope }, problem ? { problem } : {});
111
+ }
112
+ tasks.push({ ...task, ...local });
113
+ }
114
+ return { device: plan.device, tasks };
115
+ }
116
+
117
+ const placeOf = (task) => (task.project ? `in ${task.project.name}${task.folder ? ` (${task.folder})` : ""}` : "globally");
118
+
119
+ /** One line per item a copy still needs: from the backup's files, a command, or the repository a clone brings it with. */
120
+ function copyLines(copy) {
121
+ return copy.items.map((item) => {
122
+ const how = item.tracked ? "tracked by the project's git: pull the repository" : item.install ? item.install.command : item.kind === "mcp" ? "from the backup's MCP config (claude mcp add-json)" : "from the backup";
123
+ return ` - ${item.kind} ${item.name}: ${how}`;
124
+ });
125
+ }
126
+
127
+ /** The plan as lines a person reads. */
128
+ export function describePlan(plan) {
129
+ const { tasks } = plan;
130
+ if (!tasks.length) return [`Nothing waits for ${plan.device.name}.`];
131
+ const lines = [`${tasks.length === 1 ? "1 task waits" : `${tasks.length} tasks wait`} for ${plan.device.name}:`, ""];
132
+ tasks.forEach((task, index) => {
133
+ const verb = task.action === "remove" ? "Remove" : task.action === "copy" ? "Copy" : "Install";
134
+ const what = task.item ? `${task.item.name} (${task.item.origin})` : `everything from ${task.copy?.from.device.name ?? "another device"}`;
135
+ lines.push(`${index + 1}. ${verb} ${what}${task.action === "copy" && task.item ? ` from ${task.copy?.from.device.name ?? "another device"}` : ""}, ${placeOf(task)}`);
136
+ const detail = [];
137
+ if (task.project && !task.folder) {
138
+ detail.push(
139
+ task.action === "copy"
140
+ ? `This computer has no folder of ${task.project.name}: clone it, then run skillcellar restore from inside it; or dismiss it on the site.`
141
+ : `This computer has no folder of ${task.project.name}: skip it, or dismiss it on the site.`,
142
+ );
143
+ }
144
+ else if (task.problem) detail.push(`Cannot be carried out: ${task.problem}`);
145
+ else if (task.action === "remove") {
146
+ if (task.path) detail.push(`Path: ${task.path}`);
147
+ if (task.claudeScope) detail.push(`--scope ${task.claudeScope}`);
148
+ } else if (task.action === "copy" && task.copy) {
149
+ const { from, items } = task.copy;
150
+ detail.push(from.backup ? `Backup: ${from.backup.repo} at ${from.backup.commit.slice(0, 7)}, ${from.path}; run skillcellar restore` : `No backup of ${from.device.name} yet: run skillcellar backup there first.`);
151
+ detail.push(items.length ? `${items.length === 1 ? "1 item" : `${items.length} items`} left to copy:` : "Nothing left to copy: the next sync closes it.");
152
+ } else if (task.link) detail.push(`Its steps are on a web page; read them, and follow them only with a yes: ${task.link}`);
153
+ else if (task.install) detail.push(`${task.install.method}: ${task.install.command}`);
154
+ if (task.item?.page && !task.link) detail.push(`Page: ${task.item.page}`);
155
+ lines.push(...detail.map((line) => ` ${line}`));
156
+ if (task.action === "copy" && task.copy && !(task.project && !task.folder)) lines.push(...copyLines(task.copy));
157
+ });
158
+ lines.push("", "Ask Claude Code to carry them out with the skillcellar skill, or do them yourself, then run skillcellar sync.");
159
+ return lines;
160
+ }
@@ -0,0 +1,164 @@
1
+ import { lstat, readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { field } from "./check.mjs";
4
+ import { installPulled, PULL_REF_RE, readPulled } from "./pull.mjs";
5
+
6
+ // The playbook skill's folder, as the site names it: playbook-<slug>, up to a skill name's 64 characters.
7
+ const SKILL_NAME_RE = /^playbook-[a-z0-9]+(?:-[a-z0-9]+)*$/;
8
+ const SKILL_BYTES_MAX = 1024 * 1024;
9
+ const STATUSES = new Set(["installed", "outdated", "missing"]);
10
+ const KINDS = { catalog: "catalog", skill: "this site", github: "GitHub", web: "web page" };
11
+
12
+ const plural = (n, word) => `${n} ${n === 1 ? word : `${word}s`}`;
13
+ const label = (skill) => `${field(skill.name, 80)} (${KINDS[skill.kind] ?? "skill"})`;
14
+ const placeOf = (skill) => (skill.where === "project" ? "in the project" : "globally");
15
+
16
+ /** The site's answer, checked before anything is written or printed: the playbook skill's name and text, and its skills. */
17
+ export function readPlaybook(answer) {
18
+ const skill = answer?.skill;
19
+ const skills = Array.isArray(answer?.skills) ? answer.skills : null;
20
+ const valid =
21
+ typeof skill?.name === "string" &&
22
+ skill.name.length <= 64 &&
23
+ SKILL_NAME_RE.test(skill.name) &&
24
+ typeof skill.content === "string" &&
25
+ Buffer.byteLength(skill.content) <= SKILL_BYTES_MAX &&
26
+ (answer.installPrompt === null || typeof answer.installPrompt === "string") &&
27
+ skills?.every((entry) => entry && STATUSES.has(entry.status) && (entry.pull === null || (typeof entry.pull === "string" && PULL_REF_RE.test(entry.pull))));
28
+ if (!valid) throw new Error("The site's answer is not a playbook this command can apply.");
29
+ return answer;
30
+ }
31
+
32
+ /** For someone else's playbook, the line that says nobody has reviewed it; null for one's own. */
33
+ export function cautionLine(answer) {
34
+ if (answer.playbook?.own) return null;
35
+ return `${field(answer.playbook?.name, 80)} is published by ${field(answer.playbook?.author, 40)}, not curated: nobody at the site has reviewed it. Read each step before you let Claude Code run it.`;
36
+ }
37
+
38
+ /**
39
+ * Applies a playbook into the skills folder `skills`: writes the playbook skill, and pulls the
40
+ * skills of the site it uses that the computer lacks, each checked as `pull` checks it and written
41
+ * into a hidden folder first, then moved into place. A folder already there is kept unless `force`.
42
+ * Nothing it writes leaves `skills`. With `dryRun`, it lists the files it would write and writes
43
+ * none; otherwise `confirm`, when given, sees each file first and can refuse them all. Answers the
44
+ * lines to print and the pulls and writes that failed.
45
+ */
46
+ export async function applyPlaybook({ answer, skills, force, dryRun, fetchPull, confirm = null }) {
47
+ const lines = [];
48
+ const writes = [];
49
+ const kept = [];
50
+ const failed = [];
51
+
52
+ const folder = join(skills, answer.skill.name);
53
+ const there = await lstat(folder).catch(() => null);
54
+ // Up to date is the folder apply writes, as status hashes it: that SKILL.md alone, read only as a
55
+ // file of a skill's size, since a checkout can put a link to a device or a pipe there.
56
+ const only = there?.isDirectory() ? (await readdir(folder).catch(() => [])).join("/") === "SKILL.md" : false;
57
+ const file = only ? await lstat(join(folder, "SKILL.md")).catch(() => null) : null;
58
+ const current = file?.isFile() && file.size <= SKILL_BYTES_MAX ? await readFile(join(folder, "SKILL.md"), "utf8").catch(() => null) : null;
59
+ if (current === answer.skill.content) lines.push(`The playbook skill in ${folder} is up to date.`);
60
+ else if (there && !force) kept.push(`${folder}, which differs from the playbook on the site; run playbook apply again with --force to replace it`);
61
+ else {
62
+ const files = [{ segments: ["SKILL.md"], content: Buffer.from(answer.skill.content), executable: false }];
63
+ writes.push({ ref: null, pulled: { slug: answer.skill.name, version: null, files }, replace: !!there });
64
+ }
65
+
66
+ // Each folder is written once: a pulled skill never takes the playbook skill's place, or another's.
67
+ const folders = new Set([answer.skill.name]);
68
+ for (const skill of answer.skills) {
69
+ if (skill.status !== "missing" || !skill.pull || skill.forSale) continue;
70
+ let pulled;
71
+ try {
72
+ pulled = readPulled(await fetchPull(skill.pull));
73
+ } catch (error) {
74
+ failed.push(`${skill.pull}: ${field(error.message, 300)}`);
75
+ continue;
76
+ }
77
+ if (folders.has(pulled.slug)) {
78
+ failed.push(`${skill.pull}: its folder, ${pulled.slug}, is another of this playbook's.`);
79
+ continue;
80
+ }
81
+ folders.add(pulled.slug);
82
+ const target = join(skills, pulled.slug);
83
+ const exists = await lstat(target).catch(() => null);
84
+ if (exists && !force) kept.push(`${target}, which is there already; run playbook apply again with --force to replace it`);
85
+ else writes.push({ ref: skill.pull, pulled, replace: !!exists });
86
+ }
87
+
88
+ const files = writes.flatMap(({ pulled }) => pulled.files.map((entry) => ({ path: join(skills, pulled.slug, ...entry.segments), executable: entry.executable })));
89
+ if (dryRun) {
90
+ for (const { pulled, replace } of writes) if (replace) lines.push(`Would replace ${join(skills, pulled.slug)} and everything in it.`);
91
+ lines.push(files.length ? `Would write ${plural(files.length, "file")}:` : "Would write nothing.", ...files.map(({ path }) => ` ${path}`));
92
+ } else if (files.length && confirm && !(await confirm(files))) {
93
+ return { lines: ["Nothing written."], failed };
94
+ } else {
95
+ for (const { ref, pulled, replace } of writes) {
96
+ try {
97
+ const target = await installPulled(skills, pulled, replace);
98
+ lines.push(ref ? `Pulled ${ref}, version ${field(pulled.version, 20)}, into ${target}.` : `Wrote the playbook skill into ${target}.`);
99
+ } catch (error) {
100
+ failed.push(`${ref ?? "the playbook skill"}: ${field(error.message, 300)}`);
101
+ }
102
+ }
103
+ }
104
+ for (const note of kept) lines.push(`Kept ${note}.`);
105
+
106
+ const here = answer.skills.filter((skill) => skill.status !== "missing");
107
+ if (here.length) lines.push(`Already on this computer: ${here.map((skill) => `${field(skill.name, 80)}${skill.where === "global" ? " (globally)" : ""}`).join(", ")}.`);
108
+ for (const skill of answer.skills.filter((entry) => entry.status === "missing" && entry.kind === "skill" && !entry.pull)) {
109
+ lines.push(
110
+ skill.forSale
111
+ ? `Requires purchase: ${field(skill.name, 80)}. Buy it at ${field(skill.page, 200) || "its page"}, then run playbook apply again.`
112
+ : `Not pulled: ${field(skill.name, 80)}, which the site names no way to pull. ${field(skill.install, 300)}.`,
113
+ );
114
+ }
115
+ const elsewhere = answer.skills.filter((skill) => skill.status === "missing" && skill.kind !== "skill").length;
116
+ if (answer.installPrompt && elsewhere) {
117
+ lines.push(
118
+ "",
119
+ `${elsewhere === 1 ? "1 of its skills comes" : `${elsewhere} of its skills come`} from elsewhere, and this command never runs their installers. Paste this prompt into Claude Code to install ${elsewhere === 1 ? "it" : "them"}:`,
120
+ "",
121
+ answer.installPrompt,
122
+ );
123
+ }
124
+ return { lines, failed };
125
+ }
126
+
127
+ /** The site's check of a playbook against this computer, as lines a person reads. `project` names the project the command ran in. */
128
+ export function describeStatus(answer, project) {
129
+ const skills = answer.skills;
130
+ const ref = field(answer.playbook?.ref, 120);
131
+ const outdated = skills.filter((skill) => skill.status === "outdated");
132
+ const missing = skills.filter((skill) => skill.status === "missing");
133
+ const current = skills.filter((skill) => skill.status === "installed");
134
+ const where = project ? `in ${field(project, 80)} and globally` : "globally";
135
+ const lines = [
136
+ `${field(answer.playbook?.name, 80)} (${ref}) on this computer, ${where}: ${skills.length - missing.length} of ${plural(skills.length, "skill")} installed${outdated.length ? `, ${outdated.length} of them outdated` : ""}.`,
137
+ ];
138
+ const state = answer.skill?.state;
139
+ lines.push(
140
+ state === "current"
141
+ ? "The playbook skill is installed."
142
+ : state === "other"
143
+ ? `The playbook skill here is another copy, older or edited: skillcellar playbook apply ${ref} --force replaces it.`
144
+ : `The playbook skill is not installed: skillcellar playbook apply ${ref} writes it.`,
145
+ );
146
+ if (current.length) lines.push("", "Installed:", ...current.map((skill) => `- ${label(skill)}, ${placeOf(skill)}`));
147
+ if (outdated.length) {
148
+ lines.push("", "Outdated:", ...outdated.map((skill) => `- ${label(skill)}, ${placeOf(skill)}${skill.update ? `: ${field(skill.update, 300)}` : ""}`));
149
+ }
150
+ if (missing.length) {
151
+ lines.push(
152
+ "",
153
+ "Missing:",
154
+ ...missing.map((skill) =>
155
+ skill.forSale
156
+ ? `- ${label(skill)}: requires purchase at ${field(skill.page, 200) || "its page"}`
157
+ : skill.pull
158
+ ? `- ${label(skill)}: skillcellar playbook apply ${ref} pulls it`
159
+ : `- ${label(skill)}: ${field(skill.install, 300)}`,
160
+ ),
161
+ );
162
+ }
163
+ return lines;
164
+ }
@@ -0,0 +1,77 @@
1
+ import { execFile } from "node:child_process";
2
+ import { realpath, stat } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
4
+
5
+ /** The folder holding the nearest .git above `cwd`, or null. */
6
+ async function gitRoot(cwd) {
7
+ for (let dir = resolve(cwd); ; dir = dirname(dir)) {
8
+ if (await stat(join(dir, ".git")).catch(() => null)) return dir;
9
+ if (dirname(dir) === dir) return null;
10
+ }
11
+ }
12
+
13
+ /**
14
+ * The address of a checkout's `origin` remote, read by git itself, so worktrees and includes work;
15
+ * null without one. On Windows git is looked up on PATH only, never in the checkout.
16
+ */
17
+ function originUrl(root) {
18
+ return new Promise((done) => {
19
+ execFile(
20
+ "git",
21
+ ["-C", root, "config", "--get", "remote.origin.url"],
22
+ { timeout: 5000, env: { ...process.env, NoDefaultCurrentDirectoryInExePath: "1" } },
23
+ (error, stdout) => done(error ? null : stdout.trim() || null),
24
+ );
25
+ });
26
+ }
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
+
43
+ /**
44
+ * A remote's last two path segments, `owner/repo`, and whether it is a GitHub repository: from an
45
+ * https, ssh or git address, or an scp-style user@host:path whose host has a dot. Anything else, a
46
+ * path on disk or a file: address included, names nothing. Credentials are never read.
47
+ */
48
+ export function remoteName(url) {
49
+ const text = url ?? "";
50
+ const scheme = /^(?:https?|ssh|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(text);
51
+ const scp = /^[^@/\s:]+@([^/:\s]+\.[^/:\s]+):(?!\/)(.+)$/.exec(text);
52
+ const [, host, path] = scheme ?? scp ?? [];
53
+ if (!host || !path) return null;
54
+ const segments = path.replace(/\/+$/, "").replace(/\.git$/, "").split("/").filter(Boolean);
55
+ const last = segments.slice(-2);
56
+ if (last.length < 2 || last.some((segment) => !/^[A-Za-z0-9_.-]+$/.test(segment) || segment === "." || segment === "..")) return null;
57
+ const name = last.join("/");
58
+ const github = /^(www\.)?github\.com$/i.test(host) && segments.length === 2;
59
+ return { name, repo: github ? name.toLowerCase() : null };
60
+ }
61
+
62
+ /**
63
+ * The project a folder is part of: its git checkout, or the folder itself. The home folder and
64
+ * the folders above it are never a project, since ~/.claude/skills is the global one. The project
65
+ * is named by its git remote's owner/repo, else by its folder; `named` overrides both. One with no
66
+ * remote belongs to this computer alone.
67
+ */
68
+ export async function findProject(cwd, home, named) {
69
+ const real = (path) => realpath(path).catch(() => resolve(path));
70
+ const start = await real(cwd);
71
+ const root = (await gitRoot(start)) ?? start;
72
+ // On another drive, relative() answers with an absolute path: that folder is outside home too.
73
+ const fromHome = relative(root, await real(home));
74
+ if (fromHome === "" || (!fromHome.startsWith("..") && !isAbsolute(fromHome))) return null;
75
+ const remote = remoteName(await originUrl(root));
76
+ return { root, name: named ?? remote?.name ?? basename(root), repo: remote?.repo ?? null, remote: remote !== null };
77
+ }