@mcpware/claude-code-organizer 0.1.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/.claude-plugin/plugin.json +21 -0
- package/LICENSE +21 -0
- package/PLAN.md +243 -0
- package/README.md +123 -0
- package/SHIP.md +96 -0
- package/bin/cli.mjs +23 -0
- package/mockup.html +419 -0
- package/package.json +39 -0
- package/src/mover.mjs +218 -0
- package/src/scanner.mjs +513 -0
- package/src/server.mjs +147 -0
- package/src/ui/app.js +580 -0
- package/src/ui/index.html +88 -0
- package/src/ui/style.css +203 -0
package/src/scanner.mjs
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scanner.mjs — Scan all Claude Code customizations.
|
|
3
|
+
* Returns a structured object describing every memory, skill, MCP server,
|
|
4
|
+
* config file, hook, plugin, plan, command, and agent — grouped by scope.
|
|
5
|
+
*
|
|
6
|
+
* Pure data module. No HTTP, no UI, no side effects.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readdir, stat, readFile, access } from "node:fs/promises";
|
|
10
|
+
import { join, relative, basename, extname } from "node:path";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
|
|
13
|
+
const HOME = homedir();
|
|
14
|
+
const CLAUDE_DIR = join(HOME, ".claude");
|
|
15
|
+
|
|
16
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
async function exists(p) {
|
|
19
|
+
try { await access(p); return true; } catch { return false; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function safeReadFile(p) {
|
|
23
|
+
try { return await readFile(p, "utf-8"); } catch { return null; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function safeStat(p) {
|
|
27
|
+
try { return await stat(p); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function formatSize(bytes) {
|
|
31
|
+
if (!bytes) return "0B";
|
|
32
|
+
if (bytes < 1024) return bytes + "B";
|
|
33
|
+
return (bytes / 1024).toFixed(1) + "K";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseFrontmatter(content) {
|
|
37
|
+
if (!content) return {};
|
|
38
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
39
|
+
if (!match) return {};
|
|
40
|
+
const fm = {};
|
|
41
|
+
for (const line of match[1].split("\n")) {
|
|
42
|
+
const m = line.match(/^(\w+):\s*(.+)/);
|
|
43
|
+
if (m) fm[m[1]] = m[2].trim();
|
|
44
|
+
}
|
|
45
|
+
return fm;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Path decoding ────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve an encoded project dir name back to a real filesystem path.
|
|
52
|
+
* E.g. "-home-nicole-AlltrueAi-ai-security-control-plane" → "/home/nicole/AlltrueAi/ai-security-control-plane"
|
|
53
|
+
*
|
|
54
|
+
* Strategy: starting from root, greedily match the longest existing directory
|
|
55
|
+
* at each level by consuming segments from the encoded name.
|
|
56
|
+
*/
|
|
57
|
+
async function resolveEncodedProjectPath(encoded) {
|
|
58
|
+
// Remove leading dash, split by dash
|
|
59
|
+
const segments = encoded.replace(/^-/, "").split("-");
|
|
60
|
+
let currentPath = "/";
|
|
61
|
+
let i = 0;
|
|
62
|
+
|
|
63
|
+
while (i < segments.length) {
|
|
64
|
+
// Try longest match first: join remaining segments and check if directory exists
|
|
65
|
+
let matched = false;
|
|
66
|
+
for (let end = segments.length; end > i; end--) {
|
|
67
|
+
const candidate = segments.slice(i, end).join("-");
|
|
68
|
+
const testPath = join(currentPath, candidate);
|
|
69
|
+
if (await exists(testPath)) {
|
|
70
|
+
const s = await safeStat(testPath);
|
|
71
|
+
if (s && s.isDirectory()) {
|
|
72
|
+
currentPath = testPath;
|
|
73
|
+
i = end;
|
|
74
|
+
matched = true;
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!matched) {
|
|
80
|
+
// Try single segment
|
|
81
|
+
currentPath = join(currentPath, segments[i]);
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Verify the resolved path exists
|
|
87
|
+
if (await exists(currentPath)) return currentPath;
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Scope discovery ──────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Discover all scopes by scanning ~/.claude/projects/ and known repo dirs.
|
|
95
|
+
* Returns an array of scope objects with parent-child relationships.
|
|
96
|
+
*/
|
|
97
|
+
async function discoverScopes() {
|
|
98
|
+
const scopes = [];
|
|
99
|
+
|
|
100
|
+
// Global scope
|
|
101
|
+
scopes.push({
|
|
102
|
+
id: "global",
|
|
103
|
+
name: "Global",
|
|
104
|
+
type: "global",
|
|
105
|
+
tag: "applies everywhere",
|
|
106
|
+
parentId: null,
|
|
107
|
+
claudeProjectDir: null, // global uses ~/.claude/ directly
|
|
108
|
+
repoDir: null,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Scan ~/.claude/projects/ for project scopes
|
|
112
|
+
const projectsDir = join(CLAUDE_DIR, "projects");
|
|
113
|
+
if (!(await exists(projectsDir))) return scopes;
|
|
114
|
+
|
|
115
|
+
const projectDirs = await readdir(projectsDir, { withFileTypes: true });
|
|
116
|
+
const projectEntries = [];
|
|
117
|
+
|
|
118
|
+
for (const d of projectDirs) {
|
|
119
|
+
if (!d.isDirectory()) continue;
|
|
120
|
+
|
|
121
|
+
// Decode encoded path: try to find the real directory on disk.
|
|
122
|
+
// The encoding replaces / with - and prepends -.
|
|
123
|
+
// E.g. -home-nicole-AlltrueAi-ai-security-control-plane → /home/nicole/AlltrueAi/ai-security-control-plane
|
|
124
|
+
// Since directory names can contain dashes, we resolve by checking which real path exists.
|
|
125
|
+
const realPath = await resolveEncodedProjectPath(d.name);
|
|
126
|
+
if (!realPath) continue;
|
|
127
|
+
|
|
128
|
+
const shortName = basename(realPath);
|
|
129
|
+
const hasMemory = await exists(join(projectsDir, d.name, "memory"));
|
|
130
|
+
|
|
131
|
+
if (hasMemory) {
|
|
132
|
+
projectEntries.push({
|
|
133
|
+
encodedName: d.name,
|
|
134
|
+
realPath,
|
|
135
|
+
shortName,
|
|
136
|
+
claudeProjectDir: join(projectsDir, d.name),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Sort by path depth (shorter = parent) then alphabetically
|
|
142
|
+
projectEntries.sort((a, b) => {
|
|
143
|
+
const da = a.realPath.split("/").length;
|
|
144
|
+
const db = b.realPath.split("/").length;
|
|
145
|
+
if (da !== db) return da - db;
|
|
146
|
+
return a.realPath.localeCompare(b.realPath);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Build parent-child relationships based on path nesting
|
|
150
|
+
for (const entry of projectEntries) {
|
|
151
|
+
// Find parent: the deepest existing scope whose realPath is a prefix of this one
|
|
152
|
+
let parentId = "global";
|
|
153
|
+
for (const existing of scopes) {
|
|
154
|
+
if (existing.repoDir && entry.realPath.startsWith(existing.repoDir + "/")) {
|
|
155
|
+
parentId = existing.id;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Determine type: if parent is global → workspace/project, if parent is another project → sub-project
|
|
160
|
+
const isWorkspace = parentId === "global" && projectEntries.some(
|
|
161
|
+
e => e !== entry && e.realPath.startsWith(entry.realPath + "/")
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
scopes.push({
|
|
165
|
+
id: entry.encodedName,
|
|
166
|
+
name: entry.shortName,
|
|
167
|
+
type: isWorkspace ? "workspace" : "project",
|
|
168
|
+
tag: isWorkspace ? "workspace" : "project",
|
|
169
|
+
parentId,
|
|
170
|
+
claudeProjectDir: entry.claudeProjectDir,
|
|
171
|
+
repoDir: entry.realPath,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return scopes;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Item scanners ────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
async function scanMemories(scope) {
|
|
181
|
+
const items = [];
|
|
182
|
+
const memDir = scope.id === "global"
|
|
183
|
+
? join(CLAUDE_DIR, "memory")
|
|
184
|
+
: join(scope.claudeProjectDir, "memory");
|
|
185
|
+
|
|
186
|
+
if (!(await exists(memDir))) return items;
|
|
187
|
+
|
|
188
|
+
const files = await readdir(memDir);
|
|
189
|
+
for (const f of files) {
|
|
190
|
+
if (!f.endsWith(".md") || f === "MEMORY.md") continue;
|
|
191
|
+
const fullPath = join(memDir, f);
|
|
192
|
+
const s = await safeStat(fullPath);
|
|
193
|
+
const content = await safeReadFile(fullPath);
|
|
194
|
+
const fm = parseFrontmatter(content);
|
|
195
|
+
|
|
196
|
+
items.push({
|
|
197
|
+
category: "memory",
|
|
198
|
+
scopeId: scope.id,
|
|
199
|
+
name: fm.name || f.replace(".md", ""),
|
|
200
|
+
fileName: f,
|
|
201
|
+
description: fm.description || "",
|
|
202
|
+
subType: fm.type || "memory", // feedback, user, project, reference
|
|
203
|
+
size: s ? formatSize(s.size) : "0B",
|
|
204
|
+
sizeBytes: s ? s.size : 0,
|
|
205
|
+
mtime: s ? s.mtime.toISOString().slice(0, 10) : "",
|
|
206
|
+
path: fullPath,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return items;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function scanSkills(scope) {
|
|
214
|
+
const items = [];
|
|
215
|
+
let skillDirs = [];
|
|
216
|
+
|
|
217
|
+
if (scope.id === "global") {
|
|
218
|
+
// Global skills: ~/.claude/skills/
|
|
219
|
+
const dir = join(CLAUDE_DIR, "skills");
|
|
220
|
+
if (await exists(dir)) skillDirs.push(dir);
|
|
221
|
+
} else if (scope.repoDir) {
|
|
222
|
+
// Per-repo skills: repo/.claude/skills/
|
|
223
|
+
const dir = join(scope.repoDir, ".claude", "skills");
|
|
224
|
+
if (await exists(dir)) skillDirs.push(dir);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
for (const skillsRoot of skillDirs) {
|
|
228
|
+
const entries = await readdir(skillsRoot, { withFileTypes: true });
|
|
229
|
+
for (const entry of entries) {
|
|
230
|
+
if (!entry.isDirectory()) continue;
|
|
231
|
+
// Skip "private" directory (usually copies of global skills)
|
|
232
|
+
if (entry.name === "private") continue;
|
|
233
|
+
|
|
234
|
+
const skillDir = join(skillsRoot, entry.name);
|
|
235
|
+
const skillMd = join(skillDir, "SKILL.md");
|
|
236
|
+
if (!(await exists(skillMd))) continue;
|
|
237
|
+
|
|
238
|
+
const s = await safeStat(skillMd);
|
|
239
|
+
const content = await safeReadFile(skillMd);
|
|
240
|
+
|
|
241
|
+
// Extract description: first meaningful paragraph line after the heading
|
|
242
|
+
let description = "";
|
|
243
|
+
if (content) {
|
|
244
|
+
const lines = content.split("\n");
|
|
245
|
+
let pastHeading = false;
|
|
246
|
+
for (const line of lines) {
|
|
247
|
+
const trimmed = line.trim();
|
|
248
|
+
if (trimmed.startsWith("# ")) { pastHeading = true; continue; }
|
|
249
|
+
if (!pastHeading) continue;
|
|
250
|
+
// Skip empty lines, frontmatter-like lines, code blocks, list items
|
|
251
|
+
if (!trimmed) continue;
|
|
252
|
+
if (trimmed.startsWith("```") || trimmed.startsWith("-") || trimmed.startsWith("|")) continue;
|
|
253
|
+
if (trimmed.match(/^\w+:\s/)) continue; // skip "name: foo" style lines
|
|
254
|
+
if (trimmed.startsWith("##")) continue;
|
|
255
|
+
description = trimmed.slice(0, 120);
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Count files in skill directory
|
|
261
|
+
const allFiles = await readdir(skillDir, { withFileTypes: true });
|
|
262
|
+
const fileCount = allFiles.filter(f => f.isFile()).length;
|
|
263
|
+
|
|
264
|
+
// Total size of skill directory
|
|
265
|
+
let totalSize = 0;
|
|
266
|
+
for (const f of allFiles.filter(f => f.isFile())) {
|
|
267
|
+
const fs = await safeStat(join(skillDir, f.name));
|
|
268
|
+
if (fs) totalSize += fs.size;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
items.push({
|
|
272
|
+
category: "skill",
|
|
273
|
+
scopeId: scope.id,
|
|
274
|
+
name: entry.name,
|
|
275
|
+
fileName: entry.name, // directory name
|
|
276
|
+
description,
|
|
277
|
+
subType: "skill",
|
|
278
|
+
size: formatSize(totalSize),
|
|
279
|
+
sizeBytes: totalSize,
|
|
280
|
+
fileCount,
|
|
281
|
+
mtime: s ? s.mtime.toISOString().slice(0, 10) : "",
|
|
282
|
+
path: skillDir,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return items;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async function scanMcpServers(scope) {
|
|
291
|
+
const items = [];
|
|
292
|
+
let mcpPaths = [];
|
|
293
|
+
|
|
294
|
+
if (scope.id === "global") {
|
|
295
|
+
mcpPaths.push({ path: join(CLAUDE_DIR, ".mcp.json"), label: "global" });
|
|
296
|
+
} else if (scope.repoDir) {
|
|
297
|
+
// Check both repo/.mcp.json and parent workspace .mcp.json
|
|
298
|
+
const repoMcp = join(scope.repoDir, ".mcp.json");
|
|
299
|
+
if (await exists(repoMcp)) {
|
|
300
|
+
mcpPaths.push({ path: repoMcp, label: scope.type });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const { path: mcpPath, label } of mcpPaths) {
|
|
305
|
+
const content = await safeReadFile(mcpPath);
|
|
306
|
+
if (!content) continue;
|
|
307
|
+
try {
|
|
308
|
+
const config = JSON.parse(content);
|
|
309
|
+
const servers = config.mcpServers || {};
|
|
310
|
+
for (const [name, serverConfig] of Object.entries(servers)) {
|
|
311
|
+
const cmd = serverConfig.command || "";
|
|
312
|
+
const args = serverConfig.args || [];
|
|
313
|
+
const desc = [cmd, ...args].filter(Boolean).join(" ").slice(0, 100);
|
|
314
|
+
|
|
315
|
+
items.push({
|
|
316
|
+
category: "mcp",
|
|
317
|
+
scopeId: scope.id,
|
|
318
|
+
name,
|
|
319
|
+
fileName: basename(mcpPath),
|
|
320
|
+
description: desc,
|
|
321
|
+
subType: "mcp",
|
|
322
|
+
size: "",
|
|
323
|
+
sizeBytes: 0,
|
|
324
|
+
mtime: "",
|
|
325
|
+
path: mcpPath,
|
|
326
|
+
mcpConfig: serverConfig,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
} catch {}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return items;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function scanConfigs(scope) {
|
|
336
|
+
const items = [];
|
|
337
|
+
if (scope.id !== "global") return items;
|
|
338
|
+
|
|
339
|
+
const configs = [
|
|
340
|
+
{ name: "CLAUDE.md", path: join(CLAUDE_DIR, "CLAUDE.md"), desc: "Global instructions" },
|
|
341
|
+
{ name: "settings.json", path: join(CLAUDE_DIR, "settings.json"), desc: "Global settings" },
|
|
342
|
+
{ name: "settings.local.json", path: join(CLAUDE_DIR, "settings.local.json"), desc: "Local settings override" },
|
|
343
|
+
];
|
|
344
|
+
|
|
345
|
+
for (const cfg of configs) {
|
|
346
|
+
if (!(await exists(cfg.path))) continue;
|
|
347
|
+
const s = await safeStat(cfg.path);
|
|
348
|
+
items.push({
|
|
349
|
+
category: "config",
|
|
350
|
+
scopeId: scope.id,
|
|
351
|
+
name: cfg.name,
|
|
352
|
+
fileName: cfg.name,
|
|
353
|
+
description: cfg.desc,
|
|
354
|
+
subType: "config",
|
|
355
|
+
size: s ? formatSize(s.size) : "0B",
|
|
356
|
+
sizeBytes: s ? s.size : 0,
|
|
357
|
+
mtime: s ? s.mtime.toISOString().slice(0, 10) : "",
|
|
358
|
+
path: cfg.path,
|
|
359
|
+
locked: true,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return items;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function scanHooks(scope) {
|
|
367
|
+
const items = [];
|
|
368
|
+
if (scope.id !== "global") return items; // hooks in global settings for now
|
|
369
|
+
|
|
370
|
+
const content = await safeReadFile(join(CLAUDE_DIR, "settings.json"));
|
|
371
|
+
if (!content) return items;
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
const settings = JSON.parse(content);
|
|
375
|
+
const hooks = settings.hooks || {};
|
|
376
|
+
for (const [event, hookArray] of Object.entries(hooks)) {
|
|
377
|
+
for (const hookGroup of hookArray) {
|
|
378
|
+
const cmds = hookGroup.hooks || [];
|
|
379
|
+
for (const cmd of cmds) {
|
|
380
|
+
items.push({
|
|
381
|
+
category: "hook",
|
|
382
|
+
scopeId: scope.id,
|
|
383
|
+
name: event,
|
|
384
|
+
fileName: "settings.json",
|
|
385
|
+
description: cmd.command || cmd.prompt || "",
|
|
386
|
+
subType: cmd.type || "command",
|
|
387
|
+
size: "",
|
|
388
|
+
sizeBytes: 0,
|
|
389
|
+
mtime: "",
|
|
390
|
+
path: join(CLAUDE_DIR, "settings.json"),
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
} catch {}
|
|
396
|
+
|
|
397
|
+
return items;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function scanPlugins() {
|
|
401
|
+
const items = [];
|
|
402
|
+
const cacheDir = join(CLAUDE_DIR, "plugins", "cache");
|
|
403
|
+
if (!(await exists(cacheDir))) return items;
|
|
404
|
+
|
|
405
|
+
try {
|
|
406
|
+
const orgs = await readdir(cacheDir, { withFileTypes: true });
|
|
407
|
+
for (const org of orgs) {
|
|
408
|
+
if (!org.isDirectory()) continue;
|
|
409
|
+
// Skip temp directories
|
|
410
|
+
if (org.name.startsWith("temp_")) continue;
|
|
411
|
+
const plugins = await readdir(join(cacheDir, org.name), { withFileTypes: true });
|
|
412
|
+
for (const plugin of plugins) {
|
|
413
|
+
if (!plugin.isDirectory()) continue;
|
|
414
|
+
// Skip hidden dirs and version dirs (we want plugin name level)
|
|
415
|
+
if (plugin.name.startsWith(".")) continue;
|
|
416
|
+
items.push({
|
|
417
|
+
category: "plugin",
|
|
418
|
+
scopeId: "global",
|
|
419
|
+
name: `${plugin.name}`,
|
|
420
|
+
fileName: `${org.name}/${plugin.name}`,
|
|
421
|
+
description: `${org.name}/${plugin.name}`,
|
|
422
|
+
subType: "plugin",
|
|
423
|
+
size: "",
|
|
424
|
+
sizeBytes: 0,
|
|
425
|
+
mtime: "",
|
|
426
|
+
path: join(cacheDir, org.name, plugin.name),
|
|
427
|
+
locked: true,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} catch {}
|
|
432
|
+
|
|
433
|
+
return items;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function scanPlans() {
|
|
437
|
+
const items = [];
|
|
438
|
+
const plansDir = join(CLAUDE_DIR, "plans");
|
|
439
|
+
if (!(await exists(plansDir))) return items;
|
|
440
|
+
|
|
441
|
+
const files = await readdir(plansDir);
|
|
442
|
+
for (const f of files) {
|
|
443
|
+
if (!f.endsWith(".md")) continue;
|
|
444
|
+
const fullPath = join(plansDir, f);
|
|
445
|
+
const s = await safeStat(fullPath);
|
|
446
|
+
const content = await safeReadFile(fullPath);
|
|
447
|
+
|
|
448
|
+
// Extract first heading as description
|
|
449
|
+
let desc = "";
|
|
450
|
+
if (content) {
|
|
451
|
+
const headingMatch = content.match(/^#\s+(.+)/m);
|
|
452
|
+
if (headingMatch) desc = headingMatch[1].slice(0, 100);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
items.push({
|
|
456
|
+
category: "plan",
|
|
457
|
+
scopeId: "global",
|
|
458
|
+
name: f.replace(".md", ""),
|
|
459
|
+
fileName: f,
|
|
460
|
+
description: desc,
|
|
461
|
+
subType: "plan",
|
|
462
|
+
size: s ? formatSize(s.size) : "0B",
|
|
463
|
+
sizeBytes: s ? s.size : 0,
|
|
464
|
+
mtime: s ? s.mtime.toISOString().slice(0, 10) : "",
|
|
465
|
+
path: fullPath,
|
|
466
|
+
locked: true, // plans are ephemeral, don't move
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return items;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── Main scan function ───────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Scan everything. Returns:
|
|
477
|
+
* {
|
|
478
|
+
* scopes: [ { id, name, type, tag, parentId, ... } ],
|
|
479
|
+
* items: [ { category, scopeId, name, description, subType, size, path, ... } ],
|
|
480
|
+
* counts: { memory: N, skill: N, mcp: N, config: N, hook: N, plugin: N, plan: N, total: N }
|
|
481
|
+
* }
|
|
482
|
+
*/
|
|
483
|
+
export async function scan() {
|
|
484
|
+
const scopes = await discoverScopes();
|
|
485
|
+
const allItems = [];
|
|
486
|
+
|
|
487
|
+
// Scan per-scope items
|
|
488
|
+
for (const scope of scopes) {
|
|
489
|
+
const [memories, skills, mcpServers, configs, hooks] = await Promise.all([
|
|
490
|
+
scanMemories(scope),
|
|
491
|
+
scanSkills(scope),
|
|
492
|
+
scanMcpServers(scope),
|
|
493
|
+
scanConfigs(scope),
|
|
494
|
+
scanHooks(scope),
|
|
495
|
+
]);
|
|
496
|
+
allItems.push(...memories, ...skills, ...mcpServers, ...configs, ...hooks);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Scan global-only items
|
|
500
|
+
const [plugins, plans] = await Promise.all([
|
|
501
|
+
scanPlugins(),
|
|
502
|
+
scanPlans(),
|
|
503
|
+
]);
|
|
504
|
+
allItems.push(...plugins, ...plans);
|
|
505
|
+
|
|
506
|
+
// Build counts
|
|
507
|
+
const counts = { total: allItems.length };
|
|
508
|
+
for (const item of allItems) {
|
|
509
|
+
counts[item.category] = (counts[item.category] || 0) + 1;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return { scopes, items: allItems, counts };
|
|
513
|
+
}
|
package/src/server.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server.mjs — HTTP server for Claude Inventory Manager.
|
|
3
|
+
* Routes only. All logic is in scanner.mjs and mover.mjs.
|
|
4
|
+
* All UI is in src/ui/ (html, css, js).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { join, extname } from "node:path";
|
|
10
|
+
import { scan } from "./scanner.mjs";
|
|
11
|
+
import { moveItem, getValidDestinations } from "./mover.mjs";
|
|
12
|
+
|
|
13
|
+
const UI_DIR = join(import.meta.dirname, "ui");
|
|
14
|
+
|
|
15
|
+
const MIME = {
|
|
16
|
+
".html": "text/html; charset=utf-8",
|
|
17
|
+
".css": "text/css; charset=utf-8",
|
|
18
|
+
".js": "application/javascript; charset=utf-8",
|
|
19
|
+
".json": "application/json; charset=utf-8",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// ── Cached scan data (refresh on each request to /api/scan) ──────────
|
|
23
|
+
let cachedData = null;
|
|
24
|
+
|
|
25
|
+
async function freshScan() {
|
|
26
|
+
cachedData = await scan();
|
|
27
|
+
return cachedData;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── Request helpers ──────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function json(res, data, status = 200) {
|
|
33
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
34
|
+
res.end(JSON.stringify(data));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function readBody(req) {
|
|
38
|
+
let body = "";
|
|
39
|
+
for await (const chunk of req) body += chunk;
|
|
40
|
+
return JSON.parse(body);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function serveFile(res, filePath) {
|
|
44
|
+
try {
|
|
45
|
+
const content = await readFile(filePath);
|
|
46
|
+
const mime = MIME[extname(filePath)] || "application/octet-stream";
|
|
47
|
+
res.writeHead(200, { "Content-Type": mime });
|
|
48
|
+
res.end(content);
|
|
49
|
+
} catch {
|
|
50
|
+
res.writeHead(404);
|
|
51
|
+
res.end("Not found");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Routes ───────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
async function handleRequest(req, res) {
|
|
58
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
59
|
+
const path = url.pathname;
|
|
60
|
+
|
|
61
|
+
// ── API routes ──
|
|
62
|
+
|
|
63
|
+
// GET /api/scan — full scan of all customizations
|
|
64
|
+
if (path === "/api/scan" && req.method === "GET") {
|
|
65
|
+
const data = await freshScan();
|
|
66
|
+
return json(res, data);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// POST /api/move — move an item to a different scope
|
|
70
|
+
if (path === "/api/move" && req.method === "POST") {
|
|
71
|
+
const { itemPath, toScopeId } = await readBody(req);
|
|
72
|
+
|
|
73
|
+
if (!cachedData) await freshScan();
|
|
74
|
+
|
|
75
|
+
// Find the item by path
|
|
76
|
+
const item = cachedData.items.find(i => i.path === itemPath && !i.locked);
|
|
77
|
+
if (!item) return json(res, { ok: false, error: "Item not found or locked" }, 400);
|
|
78
|
+
|
|
79
|
+
const result = await moveItem(item, toScopeId, cachedData.scopes);
|
|
80
|
+
|
|
81
|
+
// Refresh cache after move
|
|
82
|
+
if (result.ok) await freshScan();
|
|
83
|
+
|
|
84
|
+
return json(res, result, result.ok ? 200 : 400);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// GET /api/destinations?path=...&category=... — valid move destinations
|
|
88
|
+
if (path === "/api/destinations" && req.method === "GET") {
|
|
89
|
+
if (!cachedData) await freshScan();
|
|
90
|
+
const itemPath = url.searchParams.get("path");
|
|
91
|
+
const item = cachedData.items.find(i => i.path === itemPath);
|
|
92
|
+
if (!item) return json(res, { ok: false, error: "Item not found" }, 400);
|
|
93
|
+
|
|
94
|
+
const destinations = getValidDestinations(item, cachedData.scopes);
|
|
95
|
+
return json(res, { ok: true, destinations, currentScopeId: item.scopeId });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// GET /api/file-content?path=... — read file content for detail panel
|
|
99
|
+
if (path === "/api/file-content" && req.method === "GET") {
|
|
100
|
+
const filePath = url.searchParams.get("path");
|
|
101
|
+
if (!filePath || !filePath.startsWith("/")) {
|
|
102
|
+
return json(res, { ok: false, error: "Invalid path" }, 400);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const content = await readFile(filePath, "utf-8");
|
|
106
|
+
return json(res, { ok: true, content });
|
|
107
|
+
} catch {
|
|
108
|
+
return json(res, { ok: false, error: "Cannot read file" }, 400);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ── Static UI files ──
|
|
113
|
+
|
|
114
|
+
if (path === "/" || path === "/index.html") {
|
|
115
|
+
return serveFile(res, join(UI_DIR, "index.html"));
|
|
116
|
+
}
|
|
117
|
+
if (path === "/style.css") {
|
|
118
|
+
return serveFile(res, join(UI_DIR, "style.css"));
|
|
119
|
+
}
|
|
120
|
+
if (path === "/app.js") {
|
|
121
|
+
return serveFile(res, join(UI_DIR, "app.js"));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── 404 ──
|
|
125
|
+
res.writeHead(404);
|
|
126
|
+
res.end("Not found");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Start server ─────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export function startServer(port = 3847) {
|
|
132
|
+
const server = createServer(async (req, res) => {
|
|
133
|
+
try {
|
|
134
|
+
await handleRequest(req, res);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
console.error("Error:", err.message);
|
|
137
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
138
|
+
res.end(JSON.stringify({ ok: false, error: err.message }));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
server.listen(port, () => {
|
|
143
|
+
console.log(`Claude Inventory running at http://localhost:${port}`);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return server;
|
|
147
|
+
}
|