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.
@@ -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, and the
40
- * folder each of `~/.claude/projects/*` ran in. Folders that are gone are skipped.
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. The home folder, those above it and Claude Code's own folder are never one.
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
- ? `This computer has no folder of ${task.project.name}: clone it, then run skillcellar restore from inside it; or dismiss it on the site.`
144
- : `This computer has no folder of ${task.project.name}: skip it, or dismiss it on the site.`,
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 && !(task.project && !task.folder)) lines.push(...copyLines(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;