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/scan.mjs
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import { join, relative } 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
|
+
/**
|
|
80
|
+
* Where a lock entry's SKILL.md sits in its repository: `{ folder }`, null for the repository's
|
|
81
|
+
* root; or null for an entry whose folder the sync cannot send as it is (odd characters, over 200
|
|
82
|
+
* characters, or no path at all), which must not pass for the root.
|
|
83
|
+
*/
|
|
84
|
+
export function lockFolder(entry) {
|
|
85
|
+
if (typeof entry?.skillPath !== "string") return null;
|
|
86
|
+
const folder = entry.skillPath.replaceAll("\\", "/").replace(/(^|\/)SKILL\.md$/, "");
|
|
87
|
+
if (!folder) return { folder: null };
|
|
88
|
+
const segments = folder.split("/");
|
|
89
|
+
return folder.length <= PATH_MAX && segments.every((s) => SEGMENT_RE.test(s) && s !== "." && s !== "..") ? { folder } : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* An https address with no credentials, query or fragment, which an install prompt can print as it
|
|
94
|
+
* is, as the site keeps it; null for a path on disk or anything else.
|
|
95
|
+
*/
|
|
96
|
+
function httpsSource(value) {
|
|
97
|
+
if (typeof value !== "string") return null;
|
|
98
|
+
let url;
|
|
99
|
+
try {
|
|
100
|
+
url = new URL(value);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash) return null;
|
|
105
|
+
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
|
|
106
|
+
const [owner, repo, ...rest] = url.pathname.split("/").filter(Boolean);
|
|
107
|
+
return rest.length || !owner || !repo ? null : githubRepository(owner, repo.replace(/\.git$/, ""));
|
|
108
|
+
}
|
|
109
|
+
return url.href.length <= SOURCE_MAX && SAFE_URL_RE.test(url.href) ? url.href : null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Where a marketplace in known_marketplaces.json comes from; a marketplace of local folders has no address to send. */
|
|
113
|
+
function marketplaceSource(source) {
|
|
114
|
+
if (source?.source === "github" && typeof source.repo === "string") {
|
|
115
|
+
const [owner, repo, ...rest] = source.repo.split("/");
|
|
116
|
+
return rest.length || !owner || !repo ? null : githubRepository(owner, repo.replace(/\.git$/, ""));
|
|
117
|
+
}
|
|
118
|
+
if (source?.source === "git" || source?.source === "url") return httpsSource(source.url);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The folder name `npx skills` installs a skill under: its sanitizeName, read in the skills CLI 1.7.2 source. */
|
|
123
|
+
const installFolderName = (name) =>
|
|
124
|
+
name
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
.replace(/[^a-z0-9._]+/g, "-")
|
|
127
|
+
.replace(/^[.-]+|[.-]+$/g, "")
|
|
128
|
+
.slice(0, 255) || "unnamed-skill";
|
|
129
|
+
|
|
130
|
+
/** 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. */
|
|
131
|
+
function lockEntries(lock) {
|
|
132
|
+
const entries = new Map();
|
|
133
|
+
for (const [name, entry] of Object.entries(lock?.skills ?? {})) if (!entries.has(installFolderName(name))) entries.set(installFolderName(name), entry);
|
|
134
|
+
for (const [name, entry] of Object.entries(lock?.skills ?? {})) entries.set(name, entry);
|
|
135
|
+
return entries;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A skill folder as the sync sends it. A folder `npx skills` installed carries what its lock
|
|
140
|
+
* records: the repository, the folder in it and its hash; any other is hashed here, as is one
|
|
141
|
+
* whose lock names a folder the sync cannot send, since a tree hash without its folder would be
|
|
142
|
+
* read as the root's.
|
|
143
|
+
*/
|
|
144
|
+
async function skillItem(folder, locked, lockHashKind, tracked) {
|
|
145
|
+
const entry = locked.get(folder.name);
|
|
146
|
+
const source = lockSource(entry);
|
|
147
|
+
const located = lockFolder(entry);
|
|
148
|
+
const lockHash = lockHashKind === "git-tree" ? entry?.skillFolderHash : entry?.computedHash;
|
|
149
|
+
if (source && located && typeof lockHash === "string" && /^[0-9a-f]+$/.test(lockHash) && lockHash.length === (lockHashKind === "git-tree" ? 40 : 64)) {
|
|
150
|
+
return { kind: "skill", name: folder.name, source, skillPath: located.folder, hash: lockHash, hashKind: lockHashKind, version: null, tracked };
|
|
151
|
+
}
|
|
152
|
+
// A file that cannot be read, or vanished meanwhile, costs only this folder its hash.
|
|
153
|
+
const hash = await folderHash(folder.path).catch(() => null);
|
|
154
|
+
return { kind: "skill", name: folder.name, source, skillPath: source ? (located?.folder ?? null) : null, hash, hashKind: hash ? "sha256" : null, version: null, tracked };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Whether a project's git tracks a file, by its path from the project's root. */
|
|
158
|
+
export const isTracked = (tracked, root, path) => tracked.has(relative(root, path).split("\\").join("/"));
|
|
159
|
+
|
|
160
|
+
async function skillItems(dir, lock, lockHashKind, skipped, trackedIn = null) {
|
|
161
|
+
const items = [];
|
|
162
|
+
const locked = lockEntries(lock);
|
|
163
|
+
for (const folder of await skillFolders(dir)) {
|
|
164
|
+
if (!NAME_RE.test(folder.name)) {
|
|
165
|
+
skipped.push(folder.name);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// A skill is the project's own when its SKILL.md is.
|
|
169
|
+
const tracked = trackedIn ? isTracked(trackedIn.tracked, trackedIn.root, join(folder.path, "SKILL.md")) : false;
|
|
170
|
+
items.push(await skillItem(folder, locked, lockHashKind, tracked));
|
|
171
|
+
}
|
|
172
|
+
return items;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const real = (path) => realpath(path).catch(() => path);
|
|
176
|
+
|
|
177
|
+
async function inFolders(path, folders) {
|
|
178
|
+
const target = await real(path);
|
|
179
|
+
for (const folder of folders) if ((await real(folder)) === target) return true;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The plugins Claude Code lists as installed, by `<plugin>@<marketplace>`: with no project, those
|
|
185
|
+
* installed for the user; with one, those installed in that project, from its root or a folder in
|
|
186
|
+
* it. A plugin whose installed copy is gone from disk is not counted.
|
|
187
|
+
*/
|
|
188
|
+
async function pluginItems(claudeDir, projectFolders, skipped) {
|
|
189
|
+
const installed = await readJson(join(claudeDir, "plugins", "installed_plugins.json"));
|
|
190
|
+
const marketplaces = (await readJson(join(claudeDir, "plugins", "known_marketplaces.json"))) ?? {};
|
|
191
|
+
const items = [];
|
|
192
|
+
for (const [key, installs] of Object.entries(installed?.plugins ?? {})) {
|
|
193
|
+
if (!Array.isArray(installs)) continue;
|
|
194
|
+
let install = null;
|
|
195
|
+
for (const candidate of installs) {
|
|
196
|
+
const here = projectFolders
|
|
197
|
+
? (candidate?.scope === "project" || candidate?.scope === "local") && typeof candidate.projectPath === "string" && (await inFolders(candidate.projectPath, projectFolders))
|
|
198
|
+
: candidate?.scope === "user";
|
|
199
|
+
if (here && typeof candidate.installPath === "string" && (await stat(candidate.installPath).catch(() => null))?.isDirectory()) {
|
|
200
|
+
install = candidate;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (!install) continue;
|
|
205
|
+
if (!NAME_RE.test(key) || !key.includes("@")) {
|
|
206
|
+
skipped.push(key);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const version = typeof install.version === "string" && VERSION_RE.test(install.version) ? install.version : null;
|
|
210
|
+
const marketplace = key.slice(key.lastIndexOf("@") + 1);
|
|
211
|
+
items.push({
|
|
212
|
+
kind: "plugin",
|
|
213
|
+
name: key,
|
|
214
|
+
source: marketplaceSource(marketplaces[marketplace]?.source),
|
|
215
|
+
skillPath: null,
|
|
216
|
+
hash: null,
|
|
217
|
+
hashKind: version ? "version" : null,
|
|
218
|
+
version,
|
|
219
|
+
tracked: false,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return items.sort(byName);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Where `npx skills` keeps its global lock: under XDG_STATE_HOME when that is set, else in
|
|
227
|
+
* ~/.agents, as the skills CLI 1.7.2 source chooses.
|
|
228
|
+
*/
|
|
229
|
+
export const globalLockPath = (home, env = process.env) =>
|
|
230
|
+
env.XDG_STATE_HOME ? join(env.XDG_STATE_HOME, "skills", ".skill-lock.json") : join(home, ".agents", ".skill-lock.json");
|
|
231
|
+
|
|
232
|
+
/** Items with one of each name per kind: a place's folders can name the same server or file twice. */
|
|
233
|
+
function distinct(items) {
|
|
234
|
+
const seen = new Set();
|
|
235
|
+
return items.filter((item) => {
|
|
236
|
+
const key = `${item.kind}/${item.name}`;
|
|
237
|
+
if (seen.has(key)) return false;
|
|
238
|
+
seen.add(key);
|
|
239
|
+
return true;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* What every project on this computer loads: ~/.claude/skills, with the skills CLI's lock, the
|
|
245
|
+
* user's plugins and MCP servers, ~/.claude/CLAUDE.md and its rules, and its agents.
|
|
246
|
+
*/
|
|
247
|
+
export async function scanGlobal({ home, claudeDir, claudeJson }) {
|
|
248
|
+
const skipped = [];
|
|
249
|
+
const lock = await readJson(globalLockPath(home));
|
|
250
|
+
const items = [
|
|
251
|
+
...(await skillItems(join(claudeDir, "skills"), lock, "git-tree", skipped)),
|
|
252
|
+
...(await pluginItems(claudeDir, null, skipped)),
|
|
253
|
+
...mcpItems(claudeJson?.mcpServers, skipped),
|
|
254
|
+
...(await memoryItems(
|
|
255
|
+
[
|
|
256
|
+
{ name: "CLAUDE.md", file: join(claudeDir, "CLAUDE.md") },
|
|
257
|
+
{ name: "rules", dir: join(claudeDir, "rules"), deep: true },
|
|
258
|
+
],
|
|
259
|
+
skipped,
|
|
260
|
+
)),
|
|
261
|
+
...(await agentItems(join(claudeDir, "agents"), skipped)),
|
|
262
|
+
];
|
|
263
|
+
return { items: distinct(items), skipped };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* What one project has: its .claude/skills, with its skills-lock.json, the plugins installed
|
|
268
|
+
* there, its MCP servers (.mcp.json, and those Claude Code keeps for a folder in it), its
|
|
269
|
+
* CLAUDE.md, AGENTS.md and rules, the session memory of each folder Claude Code ran in, and its
|
|
270
|
+
* agents.
|
|
271
|
+
*/
|
|
272
|
+
export async function scanProject({ root, folders = [root], claudeDir, claudeJson, tracked = new Set() }) {
|
|
273
|
+
const skipped = [];
|
|
274
|
+
const lock = await readJson(join(root, "skills-lock.json"));
|
|
275
|
+
const mcpJson = await readJson(join(root, ".mcp.json"));
|
|
276
|
+
const own = { tracked, root };
|
|
277
|
+
const items = [
|
|
278
|
+
...(await skillItems(join(root, ".claude", "skills"), lock, "sha256", skipped, own)),
|
|
279
|
+
...(await pluginItems(claudeDir, [root, ...folders], skipped)),
|
|
280
|
+
...mcpItems(mcpJson?.mcpServers, skipped).map((item) => ({ ...item, tracked: tracked.has(".mcp.json") })),
|
|
281
|
+
...folders.flatMap((folder) => mcpItems(claudeJson?.projects?.[folder]?.mcpServers, skipped)),
|
|
282
|
+
...(await memoryItems(
|
|
283
|
+
[
|
|
284
|
+
{ name: "CLAUDE.md", file: join(root, "CLAUDE.md") },
|
|
285
|
+
{ name: "AGENTS.md", file: join(root, "AGENTS.md") },
|
|
286
|
+
{ name: "rules", dir: join(root, ".claude", "rules"), deep: true },
|
|
287
|
+
// Claude Code's docs put a repository's memory under its root's name, and a session's files
|
|
288
|
+
// under its folder's: both are read.
|
|
289
|
+
...[...new Set([root, ...folders])].map((folder) => ({ name: "memory", dir: join(claudeDir, "projects", sessionFolderName(folder), "memory"), deep: false })),
|
|
290
|
+
],
|
|
291
|
+
skipped,
|
|
292
|
+
own,
|
|
293
|
+
)),
|
|
294
|
+
...(await agentItems(join(root, ".claude", "agents"), skipped, own)),
|
|
295
|
+
];
|
|
296
|
+
return { items: distinct(items), skipped };
|
|
297
|
+
}
|
package/src/secrets.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// What stops a backup: a line that looks like a key, a token or a password. Each pattern is bounded,
|
|
2
|
+
// so a line of any length is read in one pass, and a placeholder such as ${NAME}, $NAME, <your key>
|
|
3
|
+
// or your_api_key is not a secret. A finding is a reason to look, not proof: the backup stops either
|
|
4
|
+
// way, and a key of a shape no pattern knows is not found.
|
|
5
|
+
|
|
6
|
+
// A key stands alone: nothing alphanumeric right before or after it, so STRIPE_API_KEY= and "apiKey"
|
|
7
|
+
// count, which a word boundary would miss between _ and a letter.
|
|
8
|
+
const EDGE = String.raw`(?<![A-Za-z0-9])`;
|
|
9
|
+
const END = String.raw`(?![A-Za-z0-9])`;
|
|
10
|
+
// A value that is not a placeholder, a variable, a template, an example or a reference into config or code.
|
|
11
|
+
const NOT_PLACEHOLDER = String.raw`(?!\$\{|\$[A-Za-z_]|<|\{\{|%|your[_-]|xxx|example|placeholder|change[_-]?me|redacted|none\b|null\b|true\b|false\b|\*{3}|process\.|os\.|env\.|config\.|settings\.|this\.|self\.)`;
|
|
12
|
+
|
|
13
|
+
const PATTERNS = [
|
|
14
|
+
["a private key", /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----/],
|
|
15
|
+
["an AWS access key", new RegExp(String.raw`${EDGE}AKIA[0-9A-Z]{16}${END}`)],
|
|
16
|
+
["a GitHub token", new RegExp(String.raw`${EDGE}(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})${END}`)],
|
|
17
|
+
["an OpenAI or Anthropic key", new RegExp(String.raw`${EDGE}sk-(?:proj-|ant-|svcacct-)?[A-Za-z0-9_-]{20,255}(?![A-Za-z0-9_-])`)],
|
|
18
|
+
["a Slack token", new RegExp(String.raw`${EDGE}xox[abeprs]-[A-Za-z0-9-]{10,255}(?![A-Za-z0-9-])`)],
|
|
19
|
+
["a Stripe key", new RegExp(String.raw`${EDGE}(?:[sr]k_(?:live|test)_[A-Za-z0-9]{16,255}|whsec_[A-Za-z0-9]{16,255})${END}`)],
|
|
20
|
+
["a Google API key", new RegExp(String.raw`${EDGE}AIza[0-9A-Za-z_-]{35}(?![A-Za-z0-9_-])`)],
|
|
21
|
+
["a SkillCellar API key", new RegExp(String.raw`${EDGE}skc_[A-Za-z0-9]{64}${END}`)],
|
|
22
|
+
["an npm token", new RegExp(String.raw`${EDGE}npm_[A-Za-z0-9]{36}${END}`)],
|
|
23
|
+
["a Neon API key", new RegExp(String.raw`${EDGE}napi_[a-z0-9]{40,255}${END}`)],
|
|
24
|
+
["a Resend, Hugging Face or Groq key", new RegExp(String.raw`${EDGE}(?:re_[A-Za-z0-9_]{20,255}|hf_[A-Za-z0-9]{20,255}|gsk_[A-Za-z0-9]{20,255})${END}`)],
|
|
25
|
+
["a JSON Web Token", new RegExp(String.raw`${EDGE}eyJ[A-Za-z0-9_-]{10,2000}\.[A-Za-z0-9_-]{10,2000}\.[A-Za-z0-9_-]{10,2000}(?![A-Za-z0-9_-])`)],
|
|
26
|
+
// user:password@host in an address of any scheme, unless the password is a placeholder.
|
|
27
|
+
["a password in an address", /[a-z][a-z0-9+.-]{0,30}:\/\/[^\s/@:'"]{1,100}:(?!\$\{)[^\s/@'"]{1,200}@/i],
|
|
28
|
+
["a bearer or basic credential", new RegExp(String.raw`\b(?:Bearer|Basic)\s{1,5}(?!\$\{|\$[A-Za-z_]|<)[A-Za-z0-9_\-./+=]{16,2000}(?![A-Za-z0-9_\-./+=])`)],
|
|
29
|
+
// A key named as one, assigned a value with a digit in it, as random keys have; the name may be quoted, as in JSON.
|
|
30
|
+
[
|
|
31
|
+
"a key or password in an assignment",
|
|
32
|
+
new RegExp(
|
|
33
|
+
String.raw`${EDGE}(?:api[_-]?key|apikey|secret[_-]?(?:access[_-]?)?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|client[_-]?secret|private[_-]?key|password|passwd|secret|token)${END}["'\x60]?\s{0,5}[:=]\s{0,5}["'\x60]?${NOT_PLACEHOLDER}(?=[A-Za-z0-9_\-./+=]{0,254}[0-9])[A-Za-z0-9_\-./+=]{8,255}`,
|
|
34
|
+
"i",
|
|
35
|
+
),
|
|
36
|
+
],
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The first line of a text that looks like a secret, as `{ line, kind }` (the line counted from
|
|
41
|
+
* 1), or null. Lines are read one by one, so the file's size sets the cost, never a pattern.
|
|
42
|
+
*/
|
|
43
|
+
export function findSecret(text) {
|
|
44
|
+
const lines = text.split("\n");
|
|
45
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
46
|
+
const line = lines[index];
|
|
47
|
+
for (const [kind, pattern] of PATTERNS) if (pattern.test(line)) return { line: index + 1, kind };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The placeholders a sanitized MCP config holds, such as ${CONTEXT7_KEY}, in order and once each. */
|
|
53
|
+
export function placeholdersIn(text) {
|
|
54
|
+
return [...new Set(Array.from(text.matchAll(/\$\{([A-Z0-9_]{1,80})\}/g), (match) => match[0]))];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* References in an MCP config that Claude Code would expand from the environment and that are not
|
|
59
|
+
* plain placeholders: `${VAR:-default}`, `$VAR`, or a placeholder of another shape. Each is a reason
|
|
60
|
+
* to read the server's config before adding it, since it reaches into the user's environment.
|
|
61
|
+
*/
|
|
62
|
+
export function oddReferencesIn(text) {
|
|
63
|
+
const all = Array.from(text.matchAll(/\$\{[^}]{0,200}\}|\$[A-Za-z_][A-Za-z0-9_]{0,80}/g), (match) => match[0]);
|
|
64
|
+
return [...new Set(all.filter((reference) => !/^\$\{[A-Z0-9_]{1,80}\}$/.test(reference)))];
|
|
65
|
+
}
|
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
|
+
}
|