skillcellar 0.0.0-stage → 0.3.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 +50 -2
- package/bin/skillcellar.mjs +4 -0
- package/package.json +26 -4
- package/skill/SKILL.md +69 -0
- package/src/api.mjs +41 -0
- package/src/cli.mjs +365 -0
- package/src/config.mjs +35 -0
- package/src/files.mjs +86 -0
- package/src/hash.mjs +38 -0
- package/src/mcp.mjs +104 -0
- package/src/places.mjs +76 -0
- package/src/plan.mjs +138 -0
- package/src/project.mjs +62 -0
- package/src/prompt.mjs +55 -0
- package/src/pull.mjs +95 -0
- package/src/scan.mjs +281 -0
- package/src/skill.mjs +21 -0
package/src/files.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// A name the site takes: segments of letters, digits and ._@+-, each starting with a letter or
|
|
6
|
+
// digit, joined by slashes for a file in a folder.
|
|
7
|
+
const SEGMENT_RE = /^[A-Za-z0-9][A-Za-z0-9._@+-]*$/;
|
|
8
|
+
const NAME_MAX = 128;
|
|
9
|
+
// ponytail: a file past this is reported without a hash; memory and agents are small text.
|
|
10
|
+
const HASH_BYTES_MAX = 1024 * 1024;
|
|
11
|
+
// How deep a rules folder is read, so a link back to a folder above cannot loop.
|
|
12
|
+
const DEPTH_MAX = 8;
|
|
13
|
+
|
|
14
|
+
const sendable = (name) => name.length <= NAME_MAX && name.split("/").every((segment) => SEGMENT_RE.test(segment));
|
|
15
|
+
|
|
16
|
+
/** The SHA-256 of a file's bytes, or null for one too large or gone meanwhile. */
|
|
17
|
+
async function fileHash(path) {
|
|
18
|
+
const info = await stat(path).catch(() => null);
|
|
19
|
+
if (!info?.isFile() || info.size > HASH_BYTES_MAX) return null;
|
|
20
|
+
const content = await readFile(path).catch(() => null);
|
|
21
|
+
return content ? createHash("sha256").update(content).digest("hex") : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A memory file or an agent as the sync sends it: its name and its SHA-256, never its content. */
|
|
25
|
+
async function fileItem(kind, name, path) {
|
|
26
|
+
const hash = await fileHash(path);
|
|
27
|
+
return { kind, name, source: null, skillPath: null, hash, hashKind: hash ? "sha256" : null, version: null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The `.md` files in a folder and, with `deep`, in the folders inside it, by their path from it.
|
|
32
|
+
* Each folder is read once, so a link back to one above cannot loop. A folder that is not there
|
|
33
|
+
* holds none; one that cannot be read stops the sync, rather than report less than is there.
|
|
34
|
+
*/
|
|
35
|
+
async function markdownFiles(dir, deep, prefix = "", depth = 0, seen = new Set()) {
|
|
36
|
+
const real = await realpath(dir).catch(() => dir);
|
|
37
|
+
if (seen.has(real)) return [];
|
|
38
|
+
seen.add(real);
|
|
39
|
+
let entries;
|
|
40
|
+
try {
|
|
41
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return [];
|
|
44
|
+
throw new Error(`Could not read ${dir}: ${error?.code ?? error}.`);
|
|
45
|
+
}
|
|
46
|
+
const files = [];
|
|
47
|
+
for (const entry of entries) {
|
|
48
|
+
if (entry.name.startsWith(".")) continue;
|
|
49
|
+
const path = join(dir, entry.name);
|
|
50
|
+
const info = entry.isSymbolicLink() ? await stat(path).catch(() => null) : entry;
|
|
51
|
+
if (info?.isDirectory() && deep && depth < DEPTH_MAX) files.push(...(await markdownFiles(path, deep, `${prefix}${entry.name}/`, depth + 1, seen)));
|
|
52
|
+
else if (info?.isFile() && entry.name.endsWith(".md")) files.push({ name: `${prefix}${entry.name}`, path });
|
|
53
|
+
}
|
|
54
|
+
return files.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Memory files, each named by the label of the folder it comes from and its path there, such as
|
|
59
|
+
* rules/common/testing.md; a single file is named as it is, such as CLAUDE.md.
|
|
60
|
+
*/
|
|
61
|
+
export async function memoryItems(sources, skipped) {
|
|
62
|
+
const items = [];
|
|
63
|
+
for (const source of sources) {
|
|
64
|
+
const files = source.file
|
|
65
|
+
? (await stat(source.file).catch(() => null))?.isFile()
|
|
66
|
+
? [{ name: source.name, path: source.file }]
|
|
67
|
+
: []
|
|
68
|
+
: (await markdownFiles(source.dir, source.deep)).map((file) => ({ name: `${source.name}/${file.name}`, path: file.path }));
|
|
69
|
+
for (const file of files) {
|
|
70
|
+
if (sendable(file.name)) items.push(await fileItem("memory", file.name, file.path));
|
|
71
|
+
else skipped.push(file.name);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return items;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The agents in a folder and the folders inside it, as Claude Code reads them: each `.md` file, by its path from the folder without `.md`. */
|
|
78
|
+
export async function agentItems(dir, skipped) {
|
|
79
|
+
const items = [];
|
|
80
|
+
for (const file of await markdownFiles(dir, true)) {
|
|
81
|
+
const name = file.name.slice(0, -".md".length);
|
|
82
|
+
if (sendable(name)) items.push(await fileItem("agent", name, file.path));
|
|
83
|
+
else skipped.push(file.name);
|
|
84
|
+
}
|
|
85
|
+
return items;
|
|
86
|
+
}
|
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 };
|
|
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,138 @@
|
|
|
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
|
+
const toSlug = (name) =>
|
|
8
|
+
name
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
11
|
+
.replace(/^-+/, "")
|
|
12
|
+
.slice(0, 64)
|
|
13
|
+
.replace(/-+$/, "");
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The folder on this computer of a task's project, from the projects the sync finds: one with a
|
|
17
|
+
* GitHub repository by that repository, any other by its slug, which for a folder with no git remote
|
|
18
|
+
* ends with this device's. With several checkouts, the first; null when this computer has none.
|
|
19
|
+
*/
|
|
20
|
+
export function projectFolder(project, projects, deviceSlug) {
|
|
21
|
+
const slug = (known) => (known.remote ? toSlug(known.name) : `${toSlug(known.name)}-${deviceSlug}`);
|
|
22
|
+
const matches = projects.filter((known) => (project.repo ? known.repo === project.repo : slug(known) === project.slug));
|
|
23
|
+
return matches.map((known) => known.root).sort()[0] ?? null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// A name as the sync sends it: segments of letters, digits and ._@+-, each starting with a letter or digit.
|
|
27
|
+
const SENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._@+-]*(?:\/[A-Za-z0-9][A-Za-z0-9._@+-]*)*$/;
|
|
28
|
+
|
|
29
|
+
const exists = async (path) => (await stat(path).catch(() => null)) !== null;
|
|
30
|
+
|
|
31
|
+
/** The first of these paths that exists, else the first. */
|
|
32
|
+
async function firstThere(paths) {
|
|
33
|
+
for (const path of paths) if (await exists(path)) return path;
|
|
34
|
+
return paths[0] ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
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.";
|
|
38
|
+
|
|
39
|
+
/** A path that stays inside its base folder, or null. */
|
|
40
|
+
function inside(base, path) {
|
|
41
|
+
const from = relative(base, path);
|
|
42
|
+
return from && !from.startsWith("..") && !isAbsolute(from) ? path : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Whether a removal names an item the way the sync sends it from that place (scan.mjs): a skill,
|
|
47
|
+
* plugin or MCP server by one name, an agent by its path in its folder, and a memory file only from
|
|
48
|
+
* the places the sync reads. A plan naming anything else did not come from a sync of this computer.
|
|
49
|
+
*/
|
|
50
|
+
function sentName(kind, name, project) {
|
|
51
|
+
if (!SENT_NAME_RE.test(name)) return false;
|
|
52
|
+
if (kind === "agent") return true;
|
|
53
|
+
if (kind !== "memory") return !name.includes("/");
|
|
54
|
+
const [first, ...rest] = name.split("/");
|
|
55
|
+
if (name === "CLAUDE.md" || (first === "rules" && rest.length > 0)) return true;
|
|
56
|
+
return project ? name === "AGENTS.md" || (first === "memory" && rest.length === 1) : false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where an item a removal names is on this computer, as the sync read it: a skill's folder, an
|
|
61
|
+
* agent's or a memory file's path, or the `--scope` that `claude plugin` and `claude mcp` take for
|
|
62
|
+
* a plugin or an MCP server. Null for what this computer does not have where the task says; a
|
|
63
|
+
* `problem` for a name the sync could not have sent, which could otherwise reach out of its folder.
|
|
64
|
+
*/
|
|
65
|
+
async function removalTarget(task, { claudeDir, project }) {
|
|
66
|
+
const { kind, name } = task.item;
|
|
67
|
+
const root = project?.root ?? null;
|
|
68
|
+
const none = { path: null, claudeScope: null, problem: null };
|
|
69
|
+
if (!sentName(kind, name, task.scope === "project")) return { ...none, problem: UNKNOWN_NAME };
|
|
70
|
+
if (task.scope === "project" && !root) return none;
|
|
71
|
+
const own = (...segments) => join(root ?? claudeDir, ...(root ? [".claude"] : []), ...segments);
|
|
72
|
+
if (kind === "skill") return { ...none, path: inside(own("skills"), own("skills", name)) };
|
|
73
|
+
if (kind === "agent") return { ...none, path: inside(own("agents"), own("agents", `${name}.md`)) };
|
|
74
|
+
if (kind === "memory") {
|
|
75
|
+
const [first, ...rest] = name.split("/");
|
|
76
|
+
if (first === "rules") return { ...none, path: inside(own("rules"), own("rules", ...rest)) };
|
|
77
|
+
if (first === "memory") {
|
|
78
|
+
const folders = [...new Set([root, ...(project.folders ?? [])])];
|
|
79
|
+
return { ...none, path: await firstThere(folders.map((folder) => join(claudeDir, "projects", sessionFolderName(folder), "memory", ...rest))) };
|
|
80
|
+
}
|
|
81
|
+
return { ...none, path: join(root ?? claudeDir, name) };
|
|
82
|
+
}
|
|
83
|
+
if (kind === "mcp") {
|
|
84
|
+
if (!root) return { ...none, claudeScope: "user" };
|
|
85
|
+
const shared = (await readJson(join(root, ".mcp.json")).catch(() => null))?.mcpServers ?? {};
|
|
86
|
+
return { ...none, claudeScope: Object.hasOwn(shared, name) ? "project" : "local" };
|
|
87
|
+
}
|
|
88
|
+
if (!root) return { ...none, claudeScope: "user" };
|
|
89
|
+
// A plugin installed in a project is installed for everyone there ("project") or this computer only ("local").
|
|
90
|
+
const installed = (await readJson(join(claudeDir, "plugins", "installed_plugins.json")).catch(() => null))?.plugins?.[name];
|
|
91
|
+
const here = Array.isArray(installed) ? installed.find((entry) => entry?.projectPath && [root, ...(project.folders ?? [])].includes(entry.projectPath)) : null;
|
|
92
|
+
return { ...none, claudeScope: here?.scope === "project" ? "project" : "local" };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The plan the site gives, with what only this computer knows added to each task: `folder`, the
|
|
97
|
+
* project's folder here (null for a global task, or a project this computer has no folder of), and
|
|
98
|
+
* for a removal `path` and `claudeScope`. Nothing here is sent to the site.
|
|
99
|
+
*/
|
|
100
|
+
export async function localPlan(plan, { claudeDir, projects, deviceSlug }) {
|
|
101
|
+
const tasks = [];
|
|
102
|
+
for (const task of plan.tasks ?? []) {
|
|
103
|
+
const root = task.project ? projectFolder(task.project, projects, deviceSlug) : null;
|
|
104
|
+
const project = root ? projects.find((known) => known.root === root) : null;
|
|
105
|
+
const local = { folder: root };
|
|
106
|
+
if (task.action === "remove") {
|
|
107
|
+
const { path, claudeScope, problem } = await removalTarget(task, { claudeDir, project });
|
|
108
|
+
Object.assign(local, { path, claudeScope }, problem ? { problem } : {});
|
|
109
|
+
}
|
|
110
|
+
tasks.push({ ...task, ...local });
|
|
111
|
+
}
|
|
112
|
+
return { device: plan.device, tasks };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const placeOf = (task) => (task.project ? `in ${task.project.name}${task.folder ? ` (${task.folder})` : ""}` : "globally");
|
|
116
|
+
|
|
117
|
+
/** The plan as lines a person reads. */
|
|
118
|
+
export function describePlan(plan) {
|
|
119
|
+
const { tasks } = plan;
|
|
120
|
+
if (!tasks.length) return [`Nothing waits for ${plan.device.name}.`];
|
|
121
|
+
const lines = [`${tasks.length === 1 ? "1 task waits" : `${tasks.length} tasks wait`} for ${plan.device.name}:`, ""];
|
|
122
|
+
tasks.forEach((task, index) => {
|
|
123
|
+
const verb = task.action === "remove" ? "Remove" : "Install";
|
|
124
|
+
lines.push(`${index + 1}. ${verb} ${task.item.name} (${task.item.origin}), ${placeOf(task)}`);
|
|
125
|
+
const detail = [];
|
|
126
|
+
if (task.project && !task.folder) detail.push(`This computer has no folder of ${task.project.name}: skip it, or dismiss it on the site.`);
|
|
127
|
+
else if (task.problem) detail.push(`Cannot be carried out: ${task.problem}`);
|
|
128
|
+
else if (task.action === "remove") {
|
|
129
|
+
if (task.path) detail.push(`Path: ${task.path}`);
|
|
130
|
+
if (task.claudeScope) detail.push(`--scope ${task.claudeScope}`);
|
|
131
|
+
} else if (task.link) detail.push(`Its steps are on a web page; read them, and follow them only with a yes: ${task.link}`);
|
|
132
|
+
else if (task.install) detail.push(`${task.install.method}: ${task.install.command}`);
|
|
133
|
+
if (task.item.page && !task.link) detail.push(`Page: ${task.item.page}`);
|
|
134
|
+
lines.push(...detail.map((line) => ` ${line}`));
|
|
135
|
+
});
|
|
136
|
+
lines.push("", "Ask Claude Code to carry them out with the skillcellar skill, or do them yourself, then run skillcellar sync.");
|
|
137
|
+
return lines;
|
|
138
|
+
}
|
package/src/project.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
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
|
+
* A remote's last two path segments, `owner/repo`, and whether it is a GitHub repository: from an
|
|
30
|
+
* https, ssh or git address, or an scp-style user@host:path whose host has a dot. Anything else, a
|
|
31
|
+
* path on disk or a file: address included, names nothing. Credentials are never read.
|
|
32
|
+
*/
|
|
33
|
+
export function remoteName(url) {
|
|
34
|
+
const text = url ?? "";
|
|
35
|
+
const scheme = /^(?:https?|ssh|git):\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/i.exec(text);
|
|
36
|
+
const scp = /^[^@/\s:]+@([^/:\s]+\.[^/:\s]+):(?!\/)(.+)$/.exec(text);
|
|
37
|
+
const [, host, path] = scheme ?? scp ?? [];
|
|
38
|
+
if (!host || !path) return null;
|
|
39
|
+
const segments = path.replace(/\/+$/, "").replace(/\.git$/, "").split("/").filter(Boolean);
|
|
40
|
+
const last = segments.slice(-2);
|
|
41
|
+
if (last.length < 2 || last.some((segment) => !/^[A-Za-z0-9_.-]+$/.test(segment) || segment === "." || segment === "..")) return null;
|
|
42
|
+
const name = last.join("/");
|
|
43
|
+
const github = /^(www\.)?github\.com$/i.test(host) && segments.length === 2;
|
|
44
|
+
return { name, repo: github ? name.toLowerCase() : null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The project a folder is part of: its git checkout, or the folder itself. The home folder and
|
|
49
|
+
* the folders above it are never a project, since ~/.claude/skills is the global one. The project
|
|
50
|
+
* is named by its git remote's owner/repo, else by its folder; `named` overrides both. One with no
|
|
51
|
+
* remote belongs to this computer alone.
|
|
52
|
+
*/
|
|
53
|
+
export async function findProject(cwd, home, named) {
|
|
54
|
+
const real = (path) => realpath(path).catch(() => resolve(path));
|
|
55
|
+
const start = await real(cwd);
|
|
56
|
+
const root = (await gitRoot(start)) ?? start;
|
|
57
|
+
// On another drive, relative() answers with an absolute path: that folder is outside home too.
|
|
58
|
+
const fromHome = relative(root, await real(home));
|
|
59
|
+
if (fromHome === "" || (!fromHome.startsWith("..") && !isAbsolute(fromHome))) return null;
|
|
60
|
+
const remote = remoteName(await originUrl(root));
|
|
61
|
+
return { root, name: named ?? remote?.name ?? basename(root), repo: remote?.repo ?? null, remote: remote !== null };
|
|
62
|
+
}
|
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
|
+
}
|