@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/mover.mjs
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mover.mjs — Safely move Claude Code customizations between scopes.
|
|
3
|
+
*
|
|
4
|
+
* Rules:
|
|
5
|
+
* - memory → memory only
|
|
6
|
+
* - skill → skill only
|
|
7
|
+
* - mcp → mcp only
|
|
8
|
+
* - config, plugin, plan → locked, cannot move
|
|
9
|
+
*
|
|
10
|
+
* Pure data module. No HTTP, no UI.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { rename, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
14
|
+
import { join, dirname, basename } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { existsSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
const HOME = homedir();
|
|
19
|
+
const CLAUDE_DIR = join(HOME, ".claude");
|
|
20
|
+
|
|
21
|
+
// ── Resolve scope to real filesystem path ────────────────────────────
|
|
22
|
+
|
|
23
|
+
function resolveMemoryDir(scopeId) {
|
|
24
|
+
if (scopeId === "global") return join(CLAUDE_DIR, "memory");
|
|
25
|
+
return join(CLAUDE_DIR, "projects", scopeId, "memory");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function resolveSkillDir(scopeId, scopes) {
|
|
29
|
+
if (scopeId === "global") return join(CLAUDE_DIR, "skills");
|
|
30
|
+
const scope = scopes.find(s => s.id === scopeId);
|
|
31
|
+
if (!scope || !scope.repoDir) return null;
|
|
32
|
+
return join(scope.repoDir, ".claude", "skills");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function resolveMcpJson(scopeId, scopes) {
|
|
36
|
+
if (scopeId === "global") return join(CLAUDE_DIR, ".mcp.json");
|
|
37
|
+
const scope = scopes.find(s => s.id === scopeId);
|
|
38
|
+
if (!scope || !scope.repoDir) return null;
|
|
39
|
+
return join(scope.repoDir, ".mcp.json");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Validate move ────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
function validateMove(item, toScopeId) {
|
|
45
|
+
// Locked items cannot move
|
|
46
|
+
if (item.locked) {
|
|
47
|
+
return { ok: false, error: `${item.name} is locked and cannot be moved` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Same scope = no-op
|
|
51
|
+
if (item.scopeId === toScopeId) {
|
|
52
|
+
return { ok: false, error: "Item is already in this scope" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Only memory, skill, mcp can move
|
|
56
|
+
const movableCategories = ["memory", "skill", "mcp"];
|
|
57
|
+
if (!movableCategories.includes(item.category)) {
|
|
58
|
+
return { ok: false, error: `${item.category} items cannot be moved` };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { ok: true };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Move memory file ─────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
async function moveMemory(item, toScopeId) {
|
|
67
|
+
const fromDir = dirname(item.path);
|
|
68
|
+
const toDir = resolveMemoryDir(toScopeId);
|
|
69
|
+
const toPath = join(toDir, item.fileName);
|
|
70
|
+
|
|
71
|
+
if (existsSync(toPath)) {
|
|
72
|
+
return { ok: false, error: `File already exists at destination: ${item.fileName}` };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await mkdir(toDir, { recursive: true });
|
|
76
|
+
await rename(item.path, toPath);
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
from: item.path,
|
|
81
|
+
to: toPath,
|
|
82
|
+
message: `Moved "${item.name}" from ${item.scopeId} to ${toScopeId}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Move skill directory ─────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
async function moveSkill(item, toScopeId, scopes) {
|
|
89
|
+
const toSkillsRoot = resolveSkillDir(toScopeId, scopes);
|
|
90
|
+
if (!toSkillsRoot) {
|
|
91
|
+
return { ok: false, error: `Cannot resolve skill directory for scope: ${toScopeId}` };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const toPath = join(toSkillsRoot, item.fileName);
|
|
95
|
+
|
|
96
|
+
if (existsSync(toPath)) {
|
|
97
|
+
return { ok: false, error: `Skill directory already exists at destination: ${item.fileName}` };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await mkdir(toSkillsRoot, { recursive: true });
|
|
101
|
+
await rename(item.path, toPath);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
from: item.path,
|
|
106
|
+
to: toPath,
|
|
107
|
+
message: `Moved skill "${item.name}" from ${item.scopeId} to ${toScopeId}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Move MCP server entry ────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
async function moveMcp(item, toScopeId, scopes) {
|
|
114
|
+
const fromMcpJson = item.path;
|
|
115
|
+
const toMcpJson = resolveMcpJson(toScopeId, scopes);
|
|
116
|
+
|
|
117
|
+
if (!toMcpJson) {
|
|
118
|
+
return { ok: false, error: `Cannot resolve .mcp.json for scope: ${toScopeId}` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Read source .mcp.json
|
|
122
|
+
let fromContent;
|
|
123
|
+
try {
|
|
124
|
+
fromContent = JSON.parse(await readFile(fromMcpJson, "utf-8"));
|
|
125
|
+
} catch {
|
|
126
|
+
return { ok: false, error: `Cannot read source .mcp.json: ${fromMcpJson}` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const serverConfig = fromContent.mcpServers?.[item.name];
|
|
130
|
+
if (!serverConfig) {
|
|
131
|
+
return { ok: false, error: `Server "${item.name}" not found in ${fromMcpJson}` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Read or create destination .mcp.json
|
|
135
|
+
let toContent = { mcpServers: {} };
|
|
136
|
+
try {
|
|
137
|
+
toContent = JSON.parse(await readFile(toMcpJson, "utf-8"));
|
|
138
|
+
if (!toContent.mcpServers) toContent.mcpServers = {};
|
|
139
|
+
} catch {
|
|
140
|
+
// File doesn't exist or is invalid, start fresh
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (toContent.mcpServers[item.name]) {
|
|
144
|
+
return { ok: false, error: `Server "${item.name}" already exists in destination` };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Add to destination
|
|
148
|
+
toContent.mcpServers[item.name] = serverConfig;
|
|
149
|
+
|
|
150
|
+
// Remove from source
|
|
151
|
+
delete fromContent.mcpServers[item.name];
|
|
152
|
+
|
|
153
|
+
// Write both files
|
|
154
|
+
await mkdir(dirname(toMcpJson), { recursive: true });
|
|
155
|
+
await writeFile(toMcpJson, JSON.stringify(toContent, null, 2) + "\n");
|
|
156
|
+
await writeFile(fromMcpJson, JSON.stringify(fromContent, null, 2) + "\n");
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
ok: true,
|
|
160
|
+
from: fromMcpJson,
|
|
161
|
+
to: toMcpJson,
|
|
162
|
+
message: `Moved MCP server "${item.name}" from ${item.scopeId} to ${toScopeId}`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Main move function ───────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Move an item to a different scope.
|
|
170
|
+
*
|
|
171
|
+
* @param {object} item - Item object from scanner
|
|
172
|
+
* @param {string} toScopeId - Target scope ID
|
|
173
|
+
* @param {object[]} scopes - All scopes from scanner
|
|
174
|
+
* @returns {{ ok: boolean, error?: string, from?: string, to?: string, message?: string }}
|
|
175
|
+
*/
|
|
176
|
+
export async function moveItem(item, toScopeId, scopes) {
|
|
177
|
+
const validation = validateMove(item, toScopeId);
|
|
178
|
+
if (!validation.ok) return validation;
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
switch (item.category) {
|
|
182
|
+
case "memory":
|
|
183
|
+
return await moveMemory(item, toScopeId);
|
|
184
|
+
case "skill":
|
|
185
|
+
return await moveSkill(item, toScopeId, scopes);
|
|
186
|
+
case "mcp":
|
|
187
|
+
return await moveMcp(item, toScopeId, scopes);
|
|
188
|
+
default:
|
|
189
|
+
return { ok: false, error: `Unknown category: ${item.category}` };
|
|
190
|
+
}
|
|
191
|
+
} catch (err) {
|
|
192
|
+
return { ok: false, error: `Move failed: ${err.message}` };
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Get valid destination scopes for an item.
|
|
198
|
+
* Returns only scopes where this category of item can live.
|
|
199
|
+
*/
|
|
200
|
+
export function getValidDestinations(item, scopes) {
|
|
201
|
+
if (item.locked) return [];
|
|
202
|
+
|
|
203
|
+
return scopes
|
|
204
|
+
.filter(s => s.id !== item.scopeId)
|
|
205
|
+
.filter(s => {
|
|
206
|
+
switch (item.category) {
|
|
207
|
+
case "memory":
|
|
208
|
+
return true; // memories can go to any scope
|
|
209
|
+
case "skill":
|
|
210
|
+
// skills can go to global or any scope with a repoDir
|
|
211
|
+
return s.id === "global" || s.repoDir;
|
|
212
|
+
case "mcp":
|
|
213
|
+
return true; // mcp can go to any scope
|
|
214
|
+
default:
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|