skillcellar 0.6.1 → 0.8.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/README.md +20 -5
- package/package.json +1 -1
- package/skill/SKILL.md +2 -2
- package/src/backup.mjs +23 -6
- package/src/cli.mjs +116 -18
- package/src/manifests.mjs +290 -0
- package/src/places.mjs +9 -7
- package/src/plan.mjs +3 -3
- package/src/restore.mjs +160 -44
- package/src/scan.mjs +4 -3
- package/src/tech.mjs +242 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// Readers for the manifests that list a project's packages. Each takes the file's text and answers
|
|
2
|
+
// the names it lists, never throwing: a file it cannot read lists nothing. The names stay on this
|
|
3
|
+
// computer; only the ids of the technologies they match are sent.
|
|
4
|
+
|
|
5
|
+
const NAME_MAX = 214;
|
|
6
|
+
// Longer lines are skipped by the line readers: no dependency is written on one, and a pattern run on
|
|
7
|
+
// a long hostile line could take minutes.
|
|
8
|
+
const LINE_MAX = 1000;
|
|
9
|
+
const lines = (text) => text.split(/\r?\n/).filter((line) => line.length <= LINE_MAX);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The registry whose manifest a file is, by its name and its folder's, or null. Lockfiles are never
|
|
13
|
+
* read. pip's requirements go by many names: requirements.txt, dev-requirements.txt, requirements/base.txt.
|
|
14
|
+
*/
|
|
15
|
+
export function manifestEcosystem(file, folder = "") {
|
|
16
|
+
if (file === "package.json") return "npm";
|
|
17
|
+
if (file === "composer.json") return "composer";
|
|
18
|
+
if (file === "pyproject.toml" || file === "Pipfile" || /^(?:[\w.-]+[-_.])?requirements[\w.-]*\.txt$/.test(file) || (folder === "requirements" && file.endsWith(".txt"))) return "pypi";
|
|
19
|
+
if (file === "go.mod") return "go";
|
|
20
|
+
if (file === "Cargo.toml") return "cargo";
|
|
21
|
+
if (file === "Gemfile") return "gem";
|
|
22
|
+
if (file === "pubspec.yaml") return "pub";
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A package name as its registry compares it: PyPI by PEP 503, crates with - for _, and npm, Composer and gems in lowercase. */
|
|
27
|
+
export function normalizeName(ecosystem, name) {
|
|
28
|
+
if (ecosystem === "pypi") return name.toLowerCase().replace(/[-_.]+/g, "-");
|
|
29
|
+
if (ecosystem === "cargo") return name.toLowerCase().replace(/_/g, "-");
|
|
30
|
+
if (ecosystem === "npm" || ecosystem === "composer" || ecosystem === "gem") return name.toLowerCase();
|
|
31
|
+
return name;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The package names a manifest lists, in its order, as written. */
|
|
35
|
+
export function dependencyNames(ecosystem, file, text) {
|
|
36
|
+
try {
|
|
37
|
+
const body = text.replace(/^/, "");
|
|
38
|
+
const names =
|
|
39
|
+
ecosystem === "npm"
|
|
40
|
+
? npmNames(body)
|
|
41
|
+
: ecosystem === "composer"
|
|
42
|
+
? composerNames(body)
|
|
43
|
+
: ecosystem === "pypi"
|
|
44
|
+
? file === "pyproject.toml"
|
|
45
|
+
? pyprojectNames(body)
|
|
46
|
+
: file === "Pipfile"
|
|
47
|
+
? pipfileNames(body)
|
|
48
|
+
: requirementsNames(body)
|
|
49
|
+
: ecosystem === "go"
|
|
50
|
+
? goNames(body)
|
|
51
|
+
: ecosystem === "cargo"
|
|
52
|
+
? cargoNames(body)
|
|
53
|
+
: ecosystem === "gem"
|
|
54
|
+
? gemNames(body)
|
|
55
|
+
: ecosystem === "pub"
|
|
56
|
+
? pubNames(body)
|
|
57
|
+
: [];
|
|
58
|
+
return names.filter((name) => typeof name === "string" && name !== "" && name.length <= NAME_MAX && !/\s/.test(name));
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isTable = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
65
|
+
|
|
66
|
+
function npmNames(text) {
|
|
67
|
+
const json = JSON.parse(text);
|
|
68
|
+
if (!isTable(json)) return [];
|
|
69
|
+
const names = [];
|
|
70
|
+
for (const list of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
|
|
71
|
+
if (!isTable(json[list])) continue;
|
|
72
|
+
for (const [name, spec] of Object.entries(json[list])) {
|
|
73
|
+
names.push(name);
|
|
74
|
+
// "x": "npm:real@1" installs real under the name x.
|
|
75
|
+
if (typeof spec === "string" && spec.startsWith("npm:")) {
|
|
76
|
+
const alias = spec.slice(4);
|
|
77
|
+
const at = alias.indexOf("@", alias.startsWith("@") ? 1 : 0);
|
|
78
|
+
names.push(at === -1 ? alias : alias.slice(0, at));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return names;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function composerNames(text) {
|
|
86
|
+
const json = JSON.parse(text);
|
|
87
|
+
if (!isTable(json)) return [];
|
|
88
|
+
const platform = (name) => name === "php" || name.startsWith("ext-") || name.startsWith("lib-") || name.startsWith("composer-");
|
|
89
|
+
return ["require", "require-dev"].flatMap((list) => (isTable(json[list]) ? Object.keys(json[list]).filter((name) => !platform(name.toLowerCase())) : []));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A requirement's name, as PEP 508 writes one first, or null for anything else: an option, a path or a URL. */
|
|
93
|
+
function requirementName(requirement) {
|
|
94
|
+
const match = /^([A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?)(?=$|[\s[;=<>!~@,(])/.exec(requirement.trim());
|
|
95
|
+
return match ? match[1] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function requirementsNames(text) {
|
|
99
|
+
const names = [];
|
|
100
|
+
for (const line of lines(text.replace(/\\\r?\n/g, " "))) {
|
|
101
|
+
const requirement = line.replace(/(^|\s)#.*$/, "").trim();
|
|
102
|
+
if (!requirement || requirement.startsWith("-")) continue;
|
|
103
|
+
const name = requirementName(requirement);
|
|
104
|
+
if (name) names.push(name);
|
|
105
|
+
}
|
|
106
|
+
return names;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A line of TOML up to a # outside a string. */
|
|
110
|
+
function withoutComment(line) {
|
|
111
|
+
let quote = null;
|
|
112
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
113
|
+
const char = line[i];
|
|
114
|
+
if (quote) {
|
|
115
|
+
if (char === "\\" && quote === '"') i += 1;
|
|
116
|
+
else if (char === quote) quote = null;
|
|
117
|
+
} else if (char === '"' || char === "'") quote = char;
|
|
118
|
+
else if (char === "#") return line.slice(0, i);
|
|
119
|
+
}
|
|
120
|
+
return line;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** How deep in brackets and braces, outside strings, a line of a TOML value leaves it, from `depth`. */
|
|
124
|
+
function depthAfter(line, depth) {
|
|
125
|
+
let quote = null;
|
|
126
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
127
|
+
const char = line[i];
|
|
128
|
+
if (quote) {
|
|
129
|
+
if (char === "\\" && quote === '"') i += 1;
|
|
130
|
+
else if (char === quote) quote = null;
|
|
131
|
+
} else if (char === '"' || char === "'") quote = char;
|
|
132
|
+
else if (char === "[" || char === "{") depth += 1;
|
|
133
|
+
else if (char === "]" || char === "}") depth -= 1;
|
|
134
|
+
}
|
|
135
|
+
return depth;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The strings in a TOML value, leaving out those inside an inline table such as { include-group = "x" }. */
|
|
139
|
+
function valueStrings(value) {
|
|
140
|
+
const strings = [];
|
|
141
|
+
let depth = 0;
|
|
142
|
+
let quote = null;
|
|
143
|
+
let start = 0;
|
|
144
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
145
|
+
const char = value[i];
|
|
146
|
+
if (quote) {
|
|
147
|
+
if (char === "\\" && quote === '"') i += 1;
|
|
148
|
+
else if (char === quote) {
|
|
149
|
+
if (depth === 0) strings.push(value.slice(start, i));
|
|
150
|
+
quote = null;
|
|
151
|
+
}
|
|
152
|
+
} else if (char === '"' || char === "'") {
|
|
153
|
+
quote = char;
|
|
154
|
+
start = i + 1;
|
|
155
|
+
} else if (char === "{") depth += 1;
|
|
156
|
+
else if (char === "}") depth -= 1;
|
|
157
|
+
}
|
|
158
|
+
return strings;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** A TOML key's name: a quoted one as it is, a dotted one by its first part. */
|
|
162
|
+
const keyName = (key) => (/^["']/.test(key) ? key.slice(1, -1) : key.split(".")[0].trim());
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The tables and key = value lines of a TOML file, as far as the readers here need: each table's
|
|
166
|
+
* header (key null), then each of its keys with the value's text, an array or inline table spread
|
|
167
|
+
* over several lines joined.
|
|
168
|
+
*/
|
|
169
|
+
function* tomlEntries(text) {
|
|
170
|
+
const rows = lines(text);
|
|
171
|
+
let section = "";
|
|
172
|
+
for (let i = 0; i < rows.length; i += 1) {
|
|
173
|
+
const line = withoutComment(rows[i]).trim();
|
|
174
|
+
if (!line) continue;
|
|
175
|
+
if (line.startsWith("[")) {
|
|
176
|
+
const header = /^\[\[?([^\]]+)\]\]?$/.exec(line);
|
|
177
|
+
section = header ? header[1].trim() : "";
|
|
178
|
+
if (header) yield { section, key: null, value: "" };
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const at = line.indexOf("=");
|
|
182
|
+
if (at < 1) continue;
|
|
183
|
+
let value = line.slice(at + 1).trim();
|
|
184
|
+
// Counted line by line, so a value left open to the end of the file costs one pass over it.
|
|
185
|
+
let depth = /^[[{]/.test(value) ? depthAfter(value, 0) : 0;
|
|
186
|
+
while (depth > 0 && i + 1 < rows.length) {
|
|
187
|
+
i += 1;
|
|
188
|
+
const next = withoutComment(rows[i]);
|
|
189
|
+
value += `\n${next}`;
|
|
190
|
+
depth = depthAfter(next, depth);
|
|
191
|
+
}
|
|
192
|
+
yield { section, key: line.slice(0, at).trim(), value };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const POETRY_RE = /^tool\.poetry\.(?:dependencies|dev-dependencies|group\.[^.]+\.dependencies)$/;
|
|
197
|
+
|
|
198
|
+
function pyprojectNames(text) {
|
|
199
|
+
const names = [];
|
|
200
|
+
for (const { section, key, value } of tomlEntries(text)) {
|
|
201
|
+
if (key === null) continue;
|
|
202
|
+
const lists = (section === "project" && keyName(key) === "dependencies") || section === "project.optional-dependencies" || section === "dependency-groups" || (section === "tool.uv" && keyName(key) === "dev-dependencies");
|
|
203
|
+
if (lists) {
|
|
204
|
+
// One push per name: spreading a long list into push() passes the engine's limit on arguments.
|
|
205
|
+
for (const requirement of valueStrings(value)) {
|
|
206
|
+
const name = requirementName(requirement);
|
|
207
|
+
if (name) names.push(name);
|
|
208
|
+
}
|
|
209
|
+
} else if (POETRY_RE.test(section) && keyName(key).toLowerCase() !== "python") names.push(keyName(key));
|
|
210
|
+
}
|
|
211
|
+
return names;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pipfileNames(text) {
|
|
215
|
+
return [...tomlEntries(text)].flatMap(({ section, key }) => (key !== null && (section === "packages" || section === "dev-packages") ? [keyName(key)] : []));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const CARGO_TABLE_RE = /^(?:target\..+\.)?(?:dependencies|dev-dependencies|build-dependencies)$|^workspace\.dependencies$/;
|
|
219
|
+
const CARGO_NAMED_RE = /^(?:(?:target\..+\.)?(?:dependencies|dev-dependencies|build-dependencies)|workspace\.dependencies)\.([^.]+)$/;
|
|
220
|
+
|
|
221
|
+
function cargoNames(text) {
|
|
222
|
+
const names = [];
|
|
223
|
+
const quoted = (value) => /^["']([^"']+)["']$/.exec(value.trim())?.[1];
|
|
224
|
+
for (const { section, key, value } of tomlEntries(text)) {
|
|
225
|
+
if (CARGO_TABLE_RE.test(section)) {
|
|
226
|
+
if (key === null) continue;
|
|
227
|
+
names.push(keyName(key));
|
|
228
|
+
// db = { package = "rusqlite" } installs rusqlite under the name db.
|
|
229
|
+
const renamed = value.startsWith("{") ? /\bpackage\s*=\s*["']([^"']+)["']/.exec(value)?.[1] : undefined;
|
|
230
|
+
if (renamed) names.push(renamed);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const named = CARGO_NAMED_RE.exec(section);
|
|
234
|
+
if (!named) continue;
|
|
235
|
+
if (key === null) names.push(keyName(named[1]));
|
|
236
|
+
else if (keyName(key) === "package" && quoted(value)) names.push(quoted(value));
|
|
237
|
+
}
|
|
238
|
+
return names;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function goNames(text) {
|
|
242
|
+
const names = [];
|
|
243
|
+
let block = false;
|
|
244
|
+
for (const raw of lines(text)) {
|
|
245
|
+
const indirect = /\/\/\s*indirect\b/.test(raw);
|
|
246
|
+
const line = raw.replace(/\/\/.*$/, "").trim();
|
|
247
|
+
if (!line) continue;
|
|
248
|
+
let path = null;
|
|
249
|
+
if (block) {
|
|
250
|
+
if (line === ")") {
|
|
251
|
+
block = false;
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
path = line.split(/\s+/)[0];
|
|
255
|
+
} else if (/^require\s*\($/.test(line)) {
|
|
256
|
+
block = true;
|
|
257
|
+
continue;
|
|
258
|
+
} else {
|
|
259
|
+
path = /^require\s+(\S+)/.exec(line)?.[1] ?? null;
|
|
260
|
+
}
|
|
261
|
+
// A module's major version past 1 ends its path: github.com/jackc/pgx/v5.
|
|
262
|
+
if (path && !indirect) names.push(path.replace(/^"|"$/g, "").replace(/\/v\d+$/, ""));
|
|
263
|
+
}
|
|
264
|
+
return names;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function gemNames(text) {
|
|
268
|
+
return lines(text).flatMap((line) => /^gem[\s(]+["']([^"']+)["']/.exec(line.trimStart())?.[1] ?? []);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function pubNames(text) {
|
|
272
|
+
const names = [];
|
|
273
|
+
let section = false;
|
|
274
|
+
let indent = null;
|
|
275
|
+
for (const raw of lines(text)) {
|
|
276
|
+
const trimmed = raw.trim();
|
|
277
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
278
|
+
const lead = raw.length - raw.trimStart().length;
|
|
279
|
+
if (lead === 0) {
|
|
280
|
+
section = /^(?:dependencies|dev_dependencies)\s*:/.test(raw);
|
|
281
|
+
indent = null;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (!section) continue;
|
|
285
|
+
indent ??= lead;
|
|
286
|
+
const name = lead === indent ? /^([A-Za-z0-9_]+)\s*:/.exec(trimmed)?.[1] : undefined;
|
|
287
|
+
if (name) names.push(name);
|
|
288
|
+
}
|
|
289
|
+
return names;
|
|
290
|
+
}
|
package/src/places.mjs
CHANGED
|
@@ -36,11 +36,12 @@ async function sessionFolder(dir) {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* Every folder Claude Code knows on this computer: the projects `~/.claude.json` lists,
|
|
40
|
-
*
|
|
39
|
+
* Every folder Claude Code knows on this computer: the projects `~/.claude.json` lists, the folder
|
|
40
|
+
* each of `~/.claude/projects/*` ran in, and those a restore wrote into, which Claude Code may never
|
|
41
|
+
* have been opened in. Folders that are gone are skipped.
|
|
41
42
|
*/
|
|
42
|
-
async function knownFolders(claudeDir, claudeJson) {
|
|
43
|
-
const folders = new Set(Object.keys(claudeJson?.projects ?? {}));
|
|
43
|
+
async function knownFolders(claudeDir, claudeJson, restored) {
|
|
44
|
+
const folders = new Set([...Object.keys(claudeJson?.projects ?? {}), ...restored.filter((folder) => typeof folder === "string" && isAbsolute(folder))]);
|
|
44
45
|
const sessions = join(claudeDir, "projects");
|
|
45
46
|
const names = await readdir(sessions).catch(() => []);
|
|
46
47
|
const listed = new Set([...folders].map(sessionFolderName));
|
|
@@ -59,12 +60,13 @@ const real = (path) => realpath(path).catch(() => path);
|
|
|
59
60
|
/**
|
|
60
61
|
* The projects on this computer, one per checkout or folder, each with the folders Claude Code ran
|
|
61
62
|
* in inside it: their local MCP servers and session memory belong to it. `cwd` is added when it is
|
|
62
|
-
* in a project
|
|
63
|
+
* in a project, and so is each folder in `restored`, the config's list of folders a restore wrote
|
|
64
|
+
* into. The home folder, those above it and Claude Code's own folder are never one.
|
|
63
65
|
*/
|
|
64
|
-
export async function findProjects({ home, claudeDir, claudeJson, cwd }) {
|
|
66
|
+
export async function findProjects({ home, claudeDir, claudeJson, cwd, restored = [] }) {
|
|
65
67
|
const projects = new Map();
|
|
66
68
|
const own = await real(claudeDir);
|
|
67
|
-
for (const folder of [...(await knownFolders(claudeDir, claudeJson)), cwd]) {
|
|
69
|
+
for (const folder of [...(await knownFolders(claudeDir, claudeJson, Array.isArray(restored) ? restored : [])), cwd]) {
|
|
68
70
|
const project = await findProject(folder, home);
|
|
69
71
|
const inside = project && relative(own, project.root);
|
|
70
72
|
if (!project || inside === "" || (inside && !inside.startsWith("..") && !isAbsolute(inside))) continue;
|
package/src/plan.mjs
CHANGED
|
@@ -140,8 +140,8 @@ export function describePlan(plan) {
|
|
|
140
140
|
if (task.project && !task.folder) {
|
|
141
141
|
detail.push(
|
|
142
142
|
task.action === "copy"
|
|
143
|
-
? `
|
|
144
|
-
: `
|
|
143
|
+
? `No folder of ${task.project.name} that Claude Code knows on this computer. If you cloned it, run skillcellar restore inside the clone; or dismiss the task on the site.`
|
|
144
|
+
: `No folder of ${task.project.name} that Claude Code knows on this computer: skip it, or dismiss it on the site.`,
|
|
145
145
|
);
|
|
146
146
|
}
|
|
147
147
|
else if (task.problem) detail.push(`Cannot be carried out: ${task.problem}`);
|
|
@@ -156,7 +156,7 @@ export function describePlan(plan) {
|
|
|
156
156
|
else if (task.install) detail.push(`${task.install.method}: ${task.install.command}`);
|
|
157
157
|
if (task.item?.page && !task.link) detail.push(`Page: ${task.item.page}`);
|
|
158
158
|
lines.push(...detail.map((line) => ` ${line}`));
|
|
159
|
-
if (task.action === "copy" && task.copy
|
|
159
|
+
if (task.action === "copy" && task.copy) lines.push(...copyLines(task.copy));
|
|
160
160
|
});
|
|
161
161
|
lines.push("", "Ask Claude Code to carry them out with the skillcellar skill, or do them yourself, then run skillcellar sync.");
|
|
162
162
|
return lines;
|