@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/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
+ }