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/src/scan.mjs ADDED
@@ -0,0 +1,281 @@
1
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { agentItems, memoryItems } from "./files.mjs";
4
+ import { folderHash } from "./hash.mjs";
5
+ import { mcpItems } from "./mcp.mjs";
6
+ import { sessionFolderName } from "./places.mjs";
7
+
8
+ // What the site accepts, checked here too, so a sync never fails on one odd folder: a name an
9
+ // install prompt can print as it is, a path inside a repository, and an https address.
10
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
11
+ const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
12
+ const SEGMENT_RE = /^[A-Za-z0-9._-]+$/;
13
+ const GITHUB_NAME_RE = /^[A-Za-z0-9_.-]{1,100}$/;
14
+ // An address an install prompt can print as it is.
15
+ const SAFE_URL_RE = /^https:\/\/[A-Za-z0-9._~:/%+-]+$/;
16
+ const SOURCE_MAX = 200;
17
+ const PATH_MAX = 200;
18
+
19
+ /** Folders under a skills directory that Claude Code does not load as skills. */
20
+ const SKIPPED_FOLDERS = new Set(["learned", "synced"]);
21
+
22
+ /**
23
+ * Parsed JSON, or null when the file does not exist. A file that exists but cannot be read stops
24
+ * the sync: reported as empty, it would remove what the last sync reported.
25
+ */
26
+ export async function readJson(path) {
27
+ let text;
28
+ try {
29
+ text = await readFile(path, "utf8");
30
+ } catch (error) {
31
+ if (error?.code === "ENOENT") return null;
32
+ throw new Error(`Could not read ${path}: ${error?.code ?? error}.`);
33
+ }
34
+ try {
35
+ return JSON.parse(text);
36
+ } catch {
37
+ throw new Error(`${path} is not valid JSON.`);
38
+ }
39
+ }
40
+
41
+ const byName = (a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
42
+
43
+ /**
44
+ * The skills in a skills directory as Claude Code loads them: each folder directly inside it, or a
45
+ * link to one, with a SKILL.md of its own. Hidden folders, `learned`, `synced` and `*-workspace`
46
+ * are skipped, and so is a folder whose skills sit deeper inside it.
47
+ */
48
+ export async function skillFolders(dir) {
49
+ let entries;
50
+ try {
51
+ entries = await readdir(dir, { withFileTypes: true });
52
+ } catch (error) {
53
+ if (error?.code === "ENOENT") return [];
54
+ throw new Error(`Could not read ${dir}: ${error?.code ?? error}.`);
55
+ }
56
+ const folders = [];
57
+ for (const entry of entries) {
58
+ const { name } = entry;
59
+ if (name.startsWith(".") || SKIPPED_FOLDERS.has(name) || name.endsWith("-workspace")) continue;
60
+ const path = join(dir, name);
61
+ const [folder, skillMd] = await Promise.all([stat(path).catch(() => null), stat(join(path, "SKILL.md")).catch(() => null)]);
62
+ if (folder?.isDirectory() && skillMd?.isFile()) folders.push({ name, path });
63
+ }
64
+ return folders.sort(byName);
65
+ }
66
+
67
+ const githubRepository = (owner, repo) =>
68
+ GITHUB_NAME_RE.test(owner) && GITHUB_NAME_RE.test(repo) && ![owner, repo].includes(".") && ![owner, repo].includes("..")
69
+ ? `https://github.com/${owner.toLowerCase()}/${repo.toLowerCase()}`
70
+ : null;
71
+
72
+ /** The GitHub repository of a skills-lock.json entry, as https://github.com/<owner>/<repo>. */
73
+ function lockSource(entry) {
74
+ if (entry?.sourceType !== "github" || typeof entry.source !== "string") return null;
75
+ const [owner, repo, ...rest] = entry.source.replace(/\.git$/, "").split("/");
76
+ return rest.length || !owner || !repo ? null : githubRepository(owner, repo);
77
+ }
78
+
79
+ /** The folder a lock entry's SKILL.md sits in, relative to its repository; null at the root or when odd. */
80
+ function lockFolder(entry) {
81
+ if (typeof entry?.skillPath !== "string") return null;
82
+ const folder = entry.skillPath.replaceAll("\\", "/").replace(/(^|\/)SKILL\.md$/, "");
83
+ const segments = folder.split("/");
84
+ return folder && folder.length <= PATH_MAX && segments.every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..") ? folder : null;
85
+ }
86
+
87
+ /**
88
+ * An https address with no credentials, query or fragment, which an install prompt can print as it
89
+ * is, as the site keeps it; null for a path on disk or anything else.
90
+ */
91
+ function httpsSource(value) {
92
+ if (typeof value !== "string") return null;
93
+ let url;
94
+ try {
95
+ url = new URL(value);
96
+ } catch {
97
+ return null;
98
+ }
99
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) return null;
100
+ if (url.hostname === "github.com" || url.hostname === "www.github.com") {
101
+ const [owner, repo, ...rest] = url.pathname.split("/").filter(Boolean);
102
+ return rest.length || !owner || !repo ? null : githubRepository(owner, repo.replace(/\.git$/, ""));
103
+ }
104
+ return url.href.length <= SOURCE_MAX && SAFE_URL_RE.test(url.href) ? url.href : null;
105
+ }
106
+
107
+ /** Where a marketplace in known_marketplaces.json comes from; a marketplace of local folders has no address to send. */
108
+ function marketplaceSource(source) {
109
+ if (source?.source === "github" && typeof source.repo === "string") {
110
+ const [owner, repo, ...rest] = source.repo.split("/");
111
+ return rest.length || !owner || !repo ? null : githubRepository(owner, repo.replace(/\.git$/, ""));
112
+ }
113
+ if (source?.source === "git" || source?.source === "url") return httpsSource(source.url);
114
+ return null;
115
+ }
116
+
117
+ /** The folder name `npx skills` installs a skill under: its sanitizeName, read in the skills CLI 1.7.2 source. */
118
+ const installFolderName = (name) =>
119
+ name
120
+ .toLowerCase()
121
+ .replace(/[^a-z0-9._]+/g, "-")
122
+ .replace(/^[.-]+|[.-]+$/g, "")
123
+ .slice(0, 255) || "unnamed-skill";
124
+
125
+ /** A lock's entries by the folder each skill is installed in: a lock names a skill as its SKILL.md does, such as react:components. */
126
+ function lockEntries(lock) {
127
+ const entries = new Map();
128
+ for (const [name, entry] of Object.entries(lock?.skills ?? {})) if (!entries.has(installFolderName(name))) entries.set(installFolderName(name), entry);
129
+ for (const [name, entry] of Object.entries(lock?.skills ?? {})) entries.set(name, entry);
130
+ return entries;
131
+ }
132
+
133
+ /**
134
+ * A skill folder as the sync sends it. A folder `npx skills` installed carries what its lock
135
+ * records: the repository, the folder in it and its hash; any other is hashed here.
136
+ */
137
+ async function skillItem(folder, locked, lockHashKind) {
138
+ const entry = locked.get(folder.name);
139
+ const source = lockSource(entry);
140
+ const lockHash = lockHashKind === "git-tree" ? entry?.skillFolderHash : entry?.computedHash;
141
+ if (source && typeof lockHash === "string" && /^[0-9a-f]+$/.test(lockHash) && lockHash.length === (lockHashKind === "git-tree" ? 40 : 64)) {
142
+ return { kind: "skill", name: folder.name, source, skillPath: lockFolder(entry), hash: lockHash, hashKind: lockHashKind, version: null };
143
+ }
144
+ // A file that cannot be read, or vanished meanwhile, costs only this folder its hash.
145
+ const hash = await folderHash(folder.path).catch(() => null);
146
+ return { kind: "skill", name: folder.name, source, skillPath: source ? lockFolder(entry) : null, hash, hashKind: hash ? "sha256" : null, version: null };
147
+ }
148
+
149
+ async function skillItems(dir, lock, lockHashKind, skipped) {
150
+ const items = [];
151
+ const locked = lockEntries(lock);
152
+ for (const folder of await skillFolders(dir)) {
153
+ if (!NAME_RE.test(folder.name)) {
154
+ skipped.push(folder.name);
155
+ continue;
156
+ }
157
+ items.push(await skillItem(folder, locked, lockHashKind));
158
+ }
159
+ return items;
160
+ }
161
+
162
+ const real = (path) => realpath(path).catch(() => path);
163
+
164
+ async function inFolders(path, folders) {
165
+ const target = await real(path);
166
+ for (const folder of folders) if ((await real(folder)) === target) return true;
167
+ return false;
168
+ }
169
+
170
+ /**
171
+ * The plugins Claude Code lists as installed, by `<plugin>@<marketplace>`: with no project, those
172
+ * installed for the user; with one, those installed in that project, from its root or a folder in
173
+ * it. A plugin whose installed copy is gone from disk is not counted.
174
+ */
175
+ async function pluginItems(claudeDir, projectFolders, skipped) {
176
+ const installed = await readJson(join(claudeDir, "plugins", "installed_plugins.json"));
177
+ const marketplaces = (await readJson(join(claudeDir, "plugins", "known_marketplaces.json"))) ?? {};
178
+ const items = [];
179
+ for (const [key, installs] of Object.entries(installed?.plugins ?? {})) {
180
+ if (!Array.isArray(installs)) continue;
181
+ let install = null;
182
+ for (const candidate of installs) {
183
+ const here = projectFolders
184
+ ? (candidate?.scope === "project" || candidate?.scope === "local") && typeof candidate.projectPath === "string" && (await inFolders(candidate.projectPath, projectFolders))
185
+ : candidate?.scope === "user";
186
+ if (here && typeof candidate.installPath === "string" && (await stat(candidate.installPath).catch(() => null))?.isDirectory()) {
187
+ install = candidate;
188
+ break;
189
+ }
190
+ }
191
+ if (!install) continue;
192
+ if (!NAME_RE.test(key) || !key.includes("@")) {
193
+ skipped.push(key);
194
+ continue;
195
+ }
196
+ const version = typeof install.version === "string" && VERSION_RE.test(install.version) ? install.version : null;
197
+ const marketplace = key.slice(key.lastIndexOf("@") + 1);
198
+ items.push({
199
+ kind: "plugin",
200
+ name: key,
201
+ source: marketplaceSource(marketplaces[marketplace]?.source),
202
+ skillPath: null,
203
+ hash: null,
204
+ hashKind: version ? "version" : null,
205
+ version,
206
+ });
207
+ }
208
+ return items.sort(byName);
209
+ }
210
+
211
+ /**
212
+ * Where `npx skills` keeps its global lock: under XDG_STATE_HOME when that is set, else in
213
+ * ~/.agents, as the skills CLI 1.7.2 source chooses.
214
+ */
215
+ export const globalLockPath = (home, env = process.env) =>
216
+ env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, "skills", ".skill-lock.json") : join(home, ".agents", ".skill-lock.json");
217
+
218
+ /** Items with one of each name per kind: a place's folders can name the same server or file twice. */
219
+ function distinct(items) {
220
+ const seen = new Set();
221
+ return items.filter((item) => {
222
+ const key = `${item.kind}/${item.name}`;
223
+ if (seen.has(key)) return false;
224
+ seen.add(key);
225
+ return true;
226
+ });
227
+ }
228
+
229
+ /**
230
+ * What every project on this computer loads: ~/.claude/skills, with the skills CLI's lock, the
231
+ * user's plugins and MCP servers, ~/.claude/CLAUDE.md and its rules, and its agents.
232
+ */
233
+ export async function scanGlobal({ home, claudeDir, claudeJson }) {
234
+ const skipped = [];
235
+ const lock = await readJson(globalLockPath(home));
236
+ const items = [
237
+ ...(await skillItems(join(claudeDir, "skills"), lock, "git-tree", skipped)),
238
+ ...(await pluginItems(claudeDir, null, skipped)),
239
+ ...mcpItems(claudeJson?.mcpServers, skipped),
240
+ ...(await memoryItems(
241
+ [
242
+ { name: "CLAUDE.md", file: join(claudeDir, "CLAUDE.md") },
243
+ { name: "rules", dir: join(claudeDir, "rules"), deep: true },
244
+ ],
245
+ skipped,
246
+ )),
247
+ ...(await agentItems(join(claudeDir, "agents"), skipped)),
248
+ ];
249
+ return { items: distinct(items), skipped };
250
+ }
251
+
252
+ /**
253
+ * What one project has: its .claude/skills, with its skills-lock.json, the plugins installed
254
+ * there, its MCP servers (.mcp.json, and those Claude Code keeps for a folder in it), its
255
+ * CLAUDE.md, AGENTS.md and rules, the session memory of each folder Claude Code ran in, and its
256
+ * agents.
257
+ */
258
+ export async function scanProject({ root, folders = [root], claudeDir, claudeJson }) {
259
+ const skipped = [];
260
+ const lock = await readJson(join(root, "skills-lock.json"));
261
+ const mcpJson = await readJson(join(root, ".mcp.json"));
262
+ const items = [
263
+ ...(await skillItems(join(root, ".claude", "skills"), lock, "sha256", skipped)),
264
+ ...(await pluginItems(claudeDir, [root, ...folders], skipped)),
265
+ ...mcpItems(mcpJson?.mcpServers, skipped),
266
+ ...folders.flatMap((folder) => mcpItems(claudeJson?.projects?.[folder]?.mcpServers, skipped)),
267
+ ...(await memoryItems(
268
+ [
269
+ { name: "CLAUDE.md", file: join(root, "CLAUDE.md") },
270
+ { name: "AGENTS.md", file: join(root, "AGENTS.md") },
271
+ { name: "rules", dir: join(root, ".claude", "rules"), deep: true },
272
+ // Claude Code's docs put a repository's memory under its root's name, and a session's files
273
+ // under its folder's: both are read.
274
+ ...[...new Set([root, ...folders])].map((folder) => ({ name: "memory", dir: join(claudeDir, "projects", sessionFolderName(folder), "memory"), deep: false })),
275
+ ],
276
+ skipped,
277
+ )),
278
+ ...(await agentItems(join(root, ".claude", "agents"), skipped)),
279
+ ];
280
+ return { items: distinct(items), skipped };
281
+ }
package/src/skill.mjs ADDED
@@ -0,0 +1,21 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ const SKILL = new URL("../skill/SKILL.md", import.meta.url);
5
+
6
+ /** Where Claude Code's skillcellar skill goes: its own folder in the global skills folder. */
7
+ export const skillFolder = (claudeDir) => join(claudeDir, "skills", "skillcellar");
8
+
9
+ /** Whether the skill in Claude Code's folder is this version's ("current"), another ("other"), or not there ("missing"). */
10
+ export async function skillState(claudeDir) {
11
+ const [wanted, installed] = await Promise.all([readFile(SKILL, "utf8"), readFile(join(skillFolder(claudeDir), "SKILL.md"), "utf8").catch(() => null)]);
12
+ return installed === null ? "missing" : installed === wanted ? "current" : "other";
13
+ }
14
+
15
+ /** Writes this version's skill into Claude Code's skills folder, replacing an older copy. */
16
+ export async function installSkill(claudeDir) {
17
+ const folder = skillFolder(claudeDir);
18
+ await mkdir(folder, { recursive: true });
19
+ await writeFile(join(folder, "SKILL.md"), await readFile(SKILL, "utf8"));
20
+ return folder;
21
+ }