@yugabytedb/meko-mcp 1.0.3
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 +505 -0
- package/assets/hooks-handlers/lib/capture.js +863 -0
- package/assets/hooks-handlers/lib/checkpoint-timer.js +72 -0
- package/assets/hooks-handlers/pre-compact.sh +3 -0
- package/assets/hooks-handlers/session-end.sh +27 -0
- package/assets/hooks-handlers/session-start.sh +24 -0
- package/assets/hooks-handlers/test_single_conversation.sh +169 -0
- package/assets/skills/meko-mcp-tools/SKILL.md +192 -0
- package/assets/skills/meko-mcp-tools/references/_sections.md +31 -0
- package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +39 -0
- package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +49 -0
- package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +373 -0
- package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +56 -0
- package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +38 -0
- package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +61 -0
- package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +128 -0
- package/assets/skills/meko-mcp-tools/references/tools-overview.md +97 -0
- package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +63 -0
- package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +79 -0
- package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +143 -0
- package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +93 -0
- package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +30 -0
- package/bin/create-meko-setup.mjs +499 -0
- package/lib/claude-code.mjs +14 -0
- package/lib/clients/claude-code.mjs +265 -0
- package/lib/clients/claude-desktop.mjs +256 -0
- package/lib/clients/common.mjs +107 -0
- package/lib/clients/cursor.mjs +332 -0
- package/lib/clients/index.mjs +14 -0
- package/lib/config.mjs +369 -0
- package/lib/detect.mjs +125 -0
- package/lib/installer.mjs +255 -0
- package/lib/logger.mjs +97 -0
- package/lib/mcp-transport.mjs +204 -0
- package/lib/migrate/adapters/claude-code.mjs +165 -0
- package/lib/migrate/adapters/cursor.mjs +221 -0
- package/lib/migrate/adapters/memory-md.mjs +230 -0
- package/lib/migrate/claude-parser.mjs +306 -0
- package/lib/migrate/daemon.mjs +67 -0
- package/lib/migrate/id.mjs +83 -0
- package/lib/migrate/lockfile.mjs +121 -0
- package/lib/migrate/logger.mjs +79 -0
- package/lib/migrate/mcp-client.mjs +224 -0
- package/lib/migrate/orchestrator.mjs +443 -0
- package/lib/migrate/progress.mjs +76 -0
- package/lib/migrate/state.mjs +177 -0
- package/lib/migrate.mjs +328 -0
- package/lib/prompt.mjs +212 -0
- package/lib/validate.mjs +259 -0
- package/package.json +49 -0
- package/scripts/bundle-assets.mjs +49 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
lstatSync,
|
|
4
|
+
readlinkSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
} from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
getClaudeCompatSettingsPath,
|
|
12
|
+
getCursorHooksPath,
|
|
13
|
+
getCursorMcpPath,
|
|
14
|
+
getCursorSkillsDir,
|
|
15
|
+
readJsonFile,
|
|
16
|
+
writeJsonFile,
|
|
17
|
+
} from "../config.mjs";
|
|
18
|
+
import {
|
|
19
|
+
resolveHooksRoot,
|
|
20
|
+
resolveSkillSource,
|
|
21
|
+
removePath,
|
|
22
|
+
symlinkOrCopy,
|
|
23
|
+
} from "./common.mjs";
|
|
24
|
+
|
|
25
|
+
const SKILLS = [{ name: "meko-mcp-tools" }];
|
|
26
|
+
|
|
27
|
+
const HOOKS = [
|
|
28
|
+
{ event: "sessionStart", script: "session-start.sh", timeout: 15000 },
|
|
29
|
+
{ event: "preCompact", script: "pre-compact.sh" },
|
|
30
|
+
{ event: "sessionEnd", script: "session-end.sh", timeout: 30000 },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @returns {object}
|
|
35
|
+
*/
|
|
36
|
+
export function createCursorAdapter(customPaths = {}) {
|
|
37
|
+
const paths = {
|
|
38
|
+
getMcpPath: customPaths.getMcpPath ?? getCursorMcpPath,
|
|
39
|
+
getHooksPath: customPaths.getHooksPath ?? getCursorHooksPath,
|
|
40
|
+
getSkillsDir: customPaths.getSkillsDir ?? getCursorSkillsDir,
|
|
41
|
+
getClaudeCompatSettingsPath:
|
|
42
|
+
customPaths.getClaudeCompatSettingsPath ?? getClaudeCompatSettingsPath,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
id: "cursor",
|
|
47
|
+
displayName: "Cursor",
|
|
48
|
+
settingsPath() {
|
|
49
|
+
return "";
|
|
50
|
+
},
|
|
51
|
+
async detectPrerequisites() {
|
|
52
|
+
// No CLI dependency for Cursor config-mode installs.
|
|
53
|
+
},
|
|
54
|
+
detectExisting(name, { scope, projectRoot }) {
|
|
55
|
+
const path = paths.getMcpPath(scope, projectRoot);
|
|
56
|
+
const cfg = readJsonFile(path);
|
|
57
|
+
const found = Boolean(cfg?.mcpServers?.[name]);
|
|
58
|
+
return { found, status: found ? "Configured" : "" };
|
|
59
|
+
},
|
|
60
|
+
removeRegistration(name, { scope, projectRoot }) {
|
|
61
|
+
const mcpPath = paths.getMcpPath(scope, projectRoot);
|
|
62
|
+
const cfg = readJsonFile(mcpPath);
|
|
63
|
+
if (!cfg?.mcpServers?.[name]) return;
|
|
64
|
+
delete cfg.mcpServers[name];
|
|
65
|
+
writeJsonFile(mcpPath, cfg);
|
|
66
|
+
},
|
|
67
|
+
registerServer({ name, url, apiKey, dryRun, log, scope, projectRoot }) {
|
|
68
|
+
const mcpPath = paths.getMcpPath(scope, projectRoot);
|
|
69
|
+
if (dryRun) {
|
|
70
|
+
log.dim(` Would update ${mcpPath}:`);
|
|
71
|
+
log.dim(` mcpServers.${name}.url = ${url}`);
|
|
72
|
+
if (apiKey) {
|
|
73
|
+
log.dim(" mcpServers.<name>.headers.API_KEY = <set>");
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cfg = readJsonFile(mcpPath);
|
|
79
|
+
if (!cfg.mcpServers || typeof cfg.mcpServers !== "object") {
|
|
80
|
+
cfg.mcpServers = {};
|
|
81
|
+
}
|
|
82
|
+
const server = { url };
|
|
83
|
+
if (apiKey) {
|
|
84
|
+
server.headers = { API_KEY: apiKey };
|
|
85
|
+
}
|
|
86
|
+
cfg.mcpServers[name] = server;
|
|
87
|
+
writeJsonFile(mcpPath, cfg);
|
|
88
|
+
},
|
|
89
|
+
setHookEnvironment({ url, apiKey, dryRun, log, scope, projectRoot, cursorHookMode }) {
|
|
90
|
+
const mode = cursorHookMode || "hybrid";
|
|
91
|
+
if (dryRun) {
|
|
92
|
+
if (mode === "native" || mode === "hybrid") {
|
|
93
|
+
log.dim(` Would configure Cursor hooks at ${paths.getHooksPath(scope, projectRoot)}`);
|
|
94
|
+
}
|
|
95
|
+
if (mode === "claude-compat" || mode === "hybrid") {
|
|
96
|
+
log.dim(` Would configure Claude-compatible hooks at ${paths.getClaudeCompatSettingsPath(scope, projectRoot)}`);
|
|
97
|
+
}
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (mode === "native" || mode === "hybrid") {
|
|
102
|
+
installNativeCursorHooks({ scope, projectRoot, url, apiKey, paths });
|
|
103
|
+
}
|
|
104
|
+
if (mode === "claude-compat" || mode === "hybrid") {
|
|
105
|
+
installClaudeCompatHooks({ scope, projectRoot, url, apiKey, paths });
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
installSkills({ dryRun, log, scope, projectRoot }) {
|
|
109
|
+
const skillDir = paths.getSkillsDir(scope, projectRoot);
|
|
110
|
+
if (dryRun) {
|
|
111
|
+
log.dim(` Would install skills into ${skillDir}`);
|
|
112
|
+
return { marketplace: "skipped", skills: "dry-run" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
mkdirSync(skillDir, { recursive: true });
|
|
116
|
+
for (const skill of SKILLS) {
|
|
117
|
+
const source = resolveSkillSource(skill.name);
|
|
118
|
+
const destination = join(skillDir, skill.name);
|
|
119
|
+
if (existsSync(destination)) continue;
|
|
120
|
+
const strategy = symlinkOrCopy(source, destination);
|
|
121
|
+
if (strategy === "copy") {
|
|
122
|
+
writeFileSync(join(destination, ".meko-installer"), "managed\n", "utf8");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { marketplace: "skipped", skills: "ok" };
|
|
127
|
+
},
|
|
128
|
+
uninstall({ name, scope, projectRoot, cursorHookMode }) {
|
|
129
|
+
const results = [];
|
|
130
|
+
try {
|
|
131
|
+
this.removeRegistration(name, { scope, projectRoot });
|
|
132
|
+
results.push({ key: "mcp", ok: true, message: `Cursor MCP server "${name}" removed.` });
|
|
133
|
+
} catch (err) {
|
|
134
|
+
results.push({ key: "mcp", ok: false, message: `Could not remove Cursor MCP server: ${err.message}` });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
uninstallCursorHooks({
|
|
139
|
+
scope,
|
|
140
|
+
projectRoot,
|
|
141
|
+
mode: cursorHookMode || "hybrid",
|
|
142
|
+
paths,
|
|
143
|
+
});
|
|
144
|
+
results.push({ key: "hooks", ok: true, message: "Cursor hook configuration cleaned up." });
|
|
145
|
+
} catch (err) {
|
|
146
|
+
results.push({ key: "hooks", ok: false, message: `Could not clean Cursor hooks: ${err.message}` });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
uninstallCursorSkills({ scope, projectRoot, paths });
|
|
151
|
+
results.push({ key: "skills", ok: true, message: "Cursor skills cleaned up." });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
results.push({ key: "skills", ok: false, message: `Could not remove Cursor skills: ${err.message}` });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return results;
|
|
157
|
+
},
|
|
158
|
+
async validateConfig({ serverName, scope, projectRoot }) {
|
|
159
|
+
const mcpPath = paths.getMcpPath(scope, projectRoot);
|
|
160
|
+
const cfg = readJsonFile(mcpPath);
|
|
161
|
+
if (cfg?.mcpServers?.[serverName]) {
|
|
162
|
+
return { ok: true, message: `Server "${serverName}" is registered in ${mcpPath}.` };
|
|
163
|
+
}
|
|
164
|
+
return { ok: false, message: `Server "${serverName}" not found in ${mcpPath}.` };
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// NOTE: URL and API key are NOT embedded in the command string.
|
|
170
|
+
// They live in the settings file's `env` block (see ensureEnvBlock below) so
|
|
171
|
+
// that changing env values — e.g., switching between local and cloud — takes
|
|
172
|
+
// effect without rewriting every hook entry. Shadowing env assignments in the
|
|
173
|
+
// command prefix (`VAR=... bash "..."`) silently override an inherited env,
|
|
174
|
+
// which is exactly the leak we're avoiding (see MEKO-20).
|
|
175
|
+
function buildScriptCommand({ scriptPath }) {
|
|
176
|
+
return `bash "${scriptPath}"`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function ensureEnvBlock(cfg, url, apiKey) {
|
|
180
|
+
if (!cfg.env || typeof cfg.env !== "object") cfg.env = {};
|
|
181
|
+
cfg.env.MEKO_MCP_URL = url;
|
|
182
|
+
if (apiKey) {
|
|
183
|
+
cfg.env.MEKO_API_KEY = apiKey;
|
|
184
|
+
} else {
|
|
185
|
+
delete cfg.env.MEKO_API_KEY;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function installNativeCursorHooks({ scope, projectRoot, url, apiKey, paths }) {
|
|
190
|
+
const hooksPath = paths.getHooksPath(scope, projectRoot);
|
|
191
|
+
const cfg = readJsonFile(hooksPath);
|
|
192
|
+
if (!cfg.version) cfg.version = 1;
|
|
193
|
+
if (!cfg.hooks || typeof cfg.hooks !== "object") {
|
|
194
|
+
cfg.hooks = {};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const handlersRoot = resolveHooksRoot();
|
|
198
|
+
for (const hook of HOOKS) {
|
|
199
|
+
const scriptPath = join(handlersRoot, hook.script);
|
|
200
|
+
const entry = { command: buildScriptCommand({ scriptPath }) };
|
|
201
|
+
if (hook.timeout) entry.timeout = hook.timeout;
|
|
202
|
+
upsertHookEntry(cfg.hooks, hook.event, entry);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
ensureEnvBlock(cfg, url, apiKey);
|
|
206
|
+
writeJsonFile(hooksPath, cfg);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function installClaudeCompatHooks({ scope, projectRoot, url, apiKey, paths }) {
|
|
210
|
+
const settingsPath = paths.getClaudeCompatSettingsPath(scope, projectRoot);
|
|
211
|
+
const cfg = readJsonFile(settingsPath);
|
|
212
|
+
if (!cfg.hooks || typeof cfg.hooks !== "object") {
|
|
213
|
+
cfg.hooks = {};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const handlersRoot = resolveHooksRoot();
|
|
217
|
+
const compatMap = {
|
|
218
|
+
SessionStart: { script: "session-start.sh", timeout: 15000 },
|
|
219
|
+
PreCompact: { script: "pre-compact.sh" },
|
|
220
|
+
SessionEnd: { script: "session-end.sh", timeout: 30000 },
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
for (const [event, data] of Object.entries(compatMap)) {
|
|
224
|
+
const scriptPath = join(handlersRoot, data.script);
|
|
225
|
+
const entry = {
|
|
226
|
+
type: "command",
|
|
227
|
+
command: buildScriptCommand({ scriptPath }),
|
|
228
|
+
};
|
|
229
|
+
if (data.timeout) entry.timeout = data.timeout;
|
|
230
|
+
upsertClaudeCompatHook(cfg.hooks, event, entry);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// On scope=user this file is shared with Claude Code (getClaudeCompatSettingsPath
|
|
234
|
+
// returns ~/.claude/settings.json), so writing the env block here also feeds the
|
|
235
|
+
// Claude Code adapter. See MEKO-20 for the leak this closes.
|
|
236
|
+
ensureEnvBlock(cfg, url, apiKey);
|
|
237
|
+
writeJsonFile(settingsPath, cfg);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function upsertHookEntry(hooksObj, event, entry) {
|
|
241
|
+
if (!Array.isArray(hooksObj[event])) hooksObj[event] = [];
|
|
242
|
+
const exists = hooksObj[event].some((item) => item.command === entry.command);
|
|
243
|
+
if (!exists) hooksObj[event].push(entry);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function upsertClaudeCompatHook(hooksObj, event, entry) {
|
|
247
|
+
if (!Array.isArray(hooksObj[event])) hooksObj[event] = [];
|
|
248
|
+
let bucket = hooksObj[event].find((item) => Array.isArray(item.hooks));
|
|
249
|
+
if (!bucket) {
|
|
250
|
+
bucket = { hooks: [] };
|
|
251
|
+
hooksObj[event].push(bucket);
|
|
252
|
+
}
|
|
253
|
+
const exists = bucket.hooks.some((hook) => hook.command === entry.command);
|
|
254
|
+
if (!exists) bucket.hooks.push(entry);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function uninstallCursorHooks({ scope, projectRoot, mode, paths }) {
|
|
258
|
+
const handlersRoot = resolveHooksRoot();
|
|
259
|
+
const scripts = ["session-start.sh", "pre-compact.sh", "session-end.sh"]
|
|
260
|
+
.map((name) => join(handlersRoot, name));
|
|
261
|
+
|
|
262
|
+
if (mode === "native" || mode === "hybrid") {
|
|
263
|
+
const hooksPath = paths.getHooksPath(scope, projectRoot);
|
|
264
|
+
const cfg = readJsonFile(hooksPath);
|
|
265
|
+
if (cfg?.hooks && typeof cfg.hooks === "object") {
|
|
266
|
+
for (const event of Object.keys(cfg.hooks)) {
|
|
267
|
+
if (!Array.isArray(cfg.hooks[event])) continue;
|
|
268
|
+
cfg.hooks[event] = cfg.hooks[event].filter((item) => {
|
|
269
|
+
if (!item?.command) return true;
|
|
270
|
+
return !scripts.some((script) => item.command.includes(script));
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
writeJsonFile(hooksPath, cfg);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (mode === "claude-compat" || mode === "hybrid") {
|
|
278
|
+
// NOTE: we deliberately do NOT strip env.MEKO_MCP_URL / env.MEKO_API_KEY here.
|
|
279
|
+
// On scope=user, this file is the same ~/.claude/settings.json the Claude Code
|
|
280
|
+
// adapter owns; the Claude Code uninstall (claude-code.mjs removeKeys call) is
|
|
281
|
+
// responsible for env cleanup. Stripping here would break a still-active
|
|
282
|
+
// Claude Code install. See MEKO-20.
|
|
283
|
+
const settingsPath = paths.getClaudeCompatSettingsPath(scope, projectRoot);
|
|
284
|
+
const cfg = readJsonFile(settingsPath);
|
|
285
|
+
if (cfg?.hooks && typeof cfg.hooks === "object") {
|
|
286
|
+
for (const event of Object.keys(cfg.hooks)) {
|
|
287
|
+
const arr = cfg.hooks[event];
|
|
288
|
+
if (!Array.isArray(arr)) continue;
|
|
289
|
+
cfg.hooks[event] = arr.map((group) => {
|
|
290
|
+
if (!Array.isArray(group?.hooks)) return group;
|
|
291
|
+
const filtered = group.hooks.filter((item) => {
|
|
292
|
+
if (!item?.command) return true;
|
|
293
|
+
return !scripts.some((script) => item.command.includes(script));
|
|
294
|
+
});
|
|
295
|
+
return { ...group, hooks: filtered };
|
|
296
|
+
}).filter((group) => {
|
|
297
|
+
if (!Array.isArray(group?.hooks)) return true;
|
|
298
|
+
return group.hooks.length > 0;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
writeJsonFile(settingsPath, cfg);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function uninstallCursorSkills({ scope, projectRoot, paths }) {
|
|
307
|
+
const skillsDir = paths.getSkillsDir(scope, projectRoot);
|
|
308
|
+
|
|
309
|
+
for (const skill of SKILLS) {
|
|
310
|
+
const dest = join(skillsDir, skill.name);
|
|
311
|
+
if (!existsSync(dest)) continue;
|
|
312
|
+
|
|
313
|
+
const managedCopyMarker = join(dest, ".meko-installer");
|
|
314
|
+
if (existsSync(managedCopyMarker)) {
|
|
315
|
+
removePath(dest);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
const stat = lstatSync(dest);
|
|
321
|
+
if (!stat.isSymbolicLink()) continue;
|
|
322
|
+
const linkTarget = readlinkSync(dest);
|
|
323
|
+
const resolvedTarget = resolve(dirname(dest), linkTarget);
|
|
324
|
+
const expectedTarget = resolveSkillSource(skill.name);
|
|
325
|
+
if (resolvedTarget === expectedTarget) {
|
|
326
|
+
removePath(dest);
|
|
327
|
+
}
|
|
328
|
+
} catch {
|
|
329
|
+
// no-op
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createClaudeCodeAdapter } from "./claude-code.mjs";
|
|
2
|
+
import { createClaudeDesktopAdapter } from "./claude-desktop.mjs";
|
|
3
|
+
import { createCursorAdapter } from "./cursor.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @returns {Record<string, object>}
|
|
7
|
+
*/
|
|
8
|
+
export function getClientAdapters() {
|
|
9
|
+
return {
|
|
10
|
+
"claude-code": createClaudeCodeAdapter(),
|
|
11
|
+
"claude-desktop": createClaudeDesktopAdapter(),
|
|
12
|
+
cursor: createCursorAdapter(),
|
|
13
|
+
};
|
|
14
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.mjs - Safe JSON file manipulation for installer-managed config.
|
|
3
|
+
*
|
|
4
|
+
* Provides generic JSON utilities plus client-specific path helpers used by
|
|
5
|
+
* Claude Code, Claude Desktop, and Cursor install flows.
|
|
6
|
+
*
|
|
7
|
+
* Zero external dependencies - Node.js built-ins only.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, copyFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Path helpers
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** @returns {string} Absolute path to ~/.claude/settings.json */
|
|
19
|
+
export function getClaudeCodeSettingsPath() {
|
|
20
|
+
return join(homedir(), ".claude", "settings.json");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Compatibility alias used by existing Claude Code paths.
|
|
25
|
+
* @returns {string}
|
|
26
|
+
*/
|
|
27
|
+
export function getSettingsPath() {
|
|
28
|
+
return getClaudeCodeSettingsPath();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Return the canonical user-level Claude Desktop config path for this OS.
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function getClaudeDesktopConfigPath() {
|
|
36
|
+
const home = homedir();
|
|
37
|
+
switch (process.platform) {
|
|
38
|
+
case "darwin":
|
|
39
|
+
return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
40
|
+
case "win32":
|
|
41
|
+
return join(home, "AppData", "Roaming", "Claude", "claude_desktop_config.json");
|
|
42
|
+
default:
|
|
43
|
+
return join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Return path to Cursor's MCP config file.
|
|
49
|
+
* @param {"user"|"project"} scope
|
|
50
|
+
* @param {string} projectRoot
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function getCursorMcpPath(scope, projectRoot) {
|
|
54
|
+
if (scope === "project") {
|
|
55
|
+
return join(projectRoot, ".cursor", "mcp.json");
|
|
56
|
+
}
|
|
57
|
+
return join(homedir(), ".cursor", "mcp.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Return path to Cursor hooks config file.
|
|
62
|
+
* @param {"user"|"project"} scope
|
|
63
|
+
* @param {string} projectRoot
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
export function getCursorHooksPath(scope, projectRoot) {
|
|
67
|
+
if (scope === "project") {
|
|
68
|
+
return join(projectRoot, ".cursor", "hooks.json");
|
|
69
|
+
}
|
|
70
|
+
return join(homedir(), ".cursor", "hooks.json");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Return path to Cursor skills directory.
|
|
75
|
+
* @param {"user"|"project"} scope
|
|
76
|
+
* @param {string} projectRoot
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
export function getCursorSkillsDir(scope, projectRoot) {
|
|
80
|
+
if (scope === "project") {
|
|
81
|
+
return join(projectRoot, ".cursor", "skills");
|
|
82
|
+
}
|
|
83
|
+
return join(homedir(), ".cursor", "skills");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Return path for Claude compatibility settings file.
|
|
88
|
+
* @param {"user"|"project"} scope
|
|
89
|
+
* @param {string} projectRoot
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function getClaudeCompatSettingsPath(scope, projectRoot) {
|
|
93
|
+
if (scope === "project") {
|
|
94
|
+
return join(projectRoot, ".claude", "settings.json");
|
|
95
|
+
}
|
|
96
|
+
return getClaudeCodeSettingsPath();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Read / Write primitives
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Read and parse a JSON file.
|
|
105
|
+
*
|
|
106
|
+
* - Returns `{}` when the file does not exist.
|
|
107
|
+
* - Returns `{}` when the file exists but is empty (0 bytes or whitespace).
|
|
108
|
+
* - Throws a descriptive error when the file contains invalid JSON.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} filePath - Absolute path to the JSON file.
|
|
111
|
+
* @returns {Record<string, unknown>} Parsed contents, or empty object.
|
|
112
|
+
*/
|
|
113
|
+
export function readJsonFile(filePath) {
|
|
114
|
+
let raw;
|
|
115
|
+
try {
|
|
116
|
+
raw = readFileSync(filePath, "utf8");
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err.code === "ENOENT") {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const trimmed = raw.trim();
|
|
125
|
+
if (trimmed.length === 0) {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
return JSON.parse(trimmed);
|
|
131
|
+
} catch (parseErr) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
`Invalid JSON in ${filePath}: ${parseErr.message}.\n` +
|
|
134
|
+
"Fix the file manually or delete it and re-run the installer.",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Write a JavaScript value as pretty-printed JSON to a file.
|
|
141
|
+
*
|
|
142
|
+
* Creates parent directories if they do not exist.
|
|
143
|
+
* Output uses 2-space indentation and a trailing newline.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} filePath - Absolute path to write.
|
|
146
|
+
* @param {unknown} data - Value to serialize.
|
|
147
|
+
*/
|
|
148
|
+
export function writeJsonFile(filePath, data) {
|
|
149
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
150
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Deep merge helper
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Recursively merge `source` into `target`, returning a new object.
|
|
159
|
+
*
|
|
160
|
+
* Rules:
|
|
161
|
+
* - Plain objects are merged key-by-key (additive).
|
|
162
|
+
* - Everything else (arrays, scalars, null) in `source` overwrites `target`.
|
|
163
|
+
*
|
|
164
|
+
* @param {Record<string, unknown>} target
|
|
165
|
+
* @param {Record<string, unknown>} source
|
|
166
|
+
* @returns {Record<string, unknown>} Merged result (new object, inputs unchanged).
|
|
167
|
+
*/
|
|
168
|
+
function deepMerge(target, source) {
|
|
169
|
+
const result = { ...target };
|
|
170
|
+
|
|
171
|
+
for (const key of Object.keys(source)) {
|
|
172
|
+
const srcVal = source[key];
|
|
173
|
+
const tgtVal = result[key];
|
|
174
|
+
|
|
175
|
+
if (isPlainObject(srcVal) && isPlainObject(tgtVal)) {
|
|
176
|
+
result[key] = deepMerge(tgtVal, srcVal);
|
|
177
|
+
} else {
|
|
178
|
+
result[key] = srcVal;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return result;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* @param {unknown} val
|
|
187
|
+
* @returns {boolean} True when val is a non-null, non-array plain object.
|
|
188
|
+
*/
|
|
189
|
+
function isPlainObject(val) {
|
|
190
|
+
return val !== null && typeof val === "object" && !Array.isArray(val);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
// High-level operations
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Deep-merge a patch into an existing JSON file.
|
|
199
|
+
*
|
|
200
|
+
* - Reads current contents (or `{}` if missing).
|
|
201
|
+
* - Creates a `.bak` backup of the original file before writing.
|
|
202
|
+
* - Performs an additive deep merge: existing keys are preserved unless
|
|
203
|
+
* explicitly overridden in `patch`.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} filePath - Absolute path to the JSON file.
|
|
206
|
+
* @param {Record<string, unknown>} patch - Object to merge in.
|
|
207
|
+
* @returns {Record<string, unknown>} The merged result that was written.
|
|
208
|
+
*/
|
|
209
|
+
export function mergeSettings(filePath, patch) {
|
|
210
|
+
const existing = readJsonFile(filePath);
|
|
211
|
+
|
|
212
|
+
// Back up the original file (only if it physically exists on disk).
|
|
213
|
+
try {
|
|
214
|
+
copyFileSync(filePath, filePath + ".bak");
|
|
215
|
+
} catch (err) {
|
|
216
|
+
// File does not exist yet - nothing to back up.
|
|
217
|
+
if (err.code !== "ENOENT") {
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const merged = deepMerge(existing, patch);
|
|
223
|
+
writeJsonFile(filePath, merged);
|
|
224
|
+
return merged;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Union a list of string entries into an array at a dot-separated key path.
|
|
229
|
+
*
|
|
230
|
+
* Use this when the patch is a list (e.g. `permissions.allow`) that must be
|
|
231
|
+
* merged additively rather than overwritten — plain `mergeSettings` replaces
|
|
232
|
+
* arrays wholesale, which would clobber user-added entries.
|
|
233
|
+
*
|
|
234
|
+
* - Creates intermediate objects if they don't exist.
|
|
235
|
+
* - Preserves existing entries; deduplicates by exact string match.
|
|
236
|
+
* - Creates a `.bak` backup before writing.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} filePath - Absolute path to the JSON file.
|
|
239
|
+
* @param {string} keyPath - Dot-separated path to the array (e.g. "permissions.allow").
|
|
240
|
+
* @param {string[]} entries - Entries to union into the array.
|
|
241
|
+
* @returns {Record<string, unknown>} The data after the union.
|
|
242
|
+
*/
|
|
243
|
+
export function mergeAllowList(filePath, keyPath, entries) {
|
|
244
|
+
const data = readJsonFile(filePath);
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
copyFileSync(filePath, filePath + ".bak");
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (err.code !== "ENOENT") {
|
|
250
|
+
throw err;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const segments = keyPath.split(".");
|
|
255
|
+
const leaf = segments.pop();
|
|
256
|
+
let cursor = data;
|
|
257
|
+
for (const seg of segments) {
|
|
258
|
+
if (!isPlainObject(cursor[seg])) {
|
|
259
|
+
cursor[seg] = {};
|
|
260
|
+
}
|
|
261
|
+
cursor = cursor[seg];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (!Array.isArray(cursor[leaf])) {
|
|
265
|
+
cursor[leaf] = [];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const existing = new Set(cursor[leaf]);
|
|
269
|
+
for (const entry of entries) {
|
|
270
|
+
if (!existing.has(entry)) {
|
|
271
|
+
cursor[leaf].push(entry);
|
|
272
|
+
existing.add(entry);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
writeJsonFile(filePath, data);
|
|
277
|
+
return data;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Remove specific entries from an array at a dot-separated key path.
|
|
282
|
+
*
|
|
283
|
+
* Inverse of `mergeAllowList` — used to clean up installer-managed entries
|
|
284
|
+
* on uninstall without touching user-added ones.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} filePath
|
|
287
|
+
* @param {string} keyPath
|
|
288
|
+
* @param {string[]} entries
|
|
289
|
+
* @returns {Record<string, unknown>}
|
|
290
|
+
*/
|
|
291
|
+
export function removeFromAllowList(filePath, keyPath, entries) {
|
|
292
|
+
const data = readJsonFile(filePath);
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
copyFileSync(filePath, filePath + ".bak");
|
|
296
|
+
} catch (err) {
|
|
297
|
+
if (err.code !== "ENOENT") {
|
|
298
|
+
throw err;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const segments = keyPath.split(".");
|
|
303
|
+
const leaf = segments.pop();
|
|
304
|
+
let cursor = data;
|
|
305
|
+
for (const seg of segments) {
|
|
306
|
+
if (!isPlainObject(cursor) || !isPlainObject(cursor[seg])) {
|
|
307
|
+
return data;
|
|
308
|
+
}
|
|
309
|
+
cursor = cursor[seg];
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (!Array.isArray(cursor[leaf])) {
|
|
313
|
+
return data;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const toRemove = new Set(entries);
|
|
317
|
+
cursor[leaf] = cursor[leaf].filter((entry) => !toRemove.has(entry));
|
|
318
|
+
|
|
319
|
+
writeJsonFile(filePath, data);
|
|
320
|
+
return data;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Remove specific nested keys from a JSON file.
|
|
325
|
+
*
|
|
326
|
+
* Keys are specified as dot-separated paths (e.g. `"env.MEKO_MCP_URL"`).
|
|
327
|
+
* Missing intermediate segments are silently skipped.
|
|
328
|
+
* Creates a `.bak` backup before writing.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} filePath - Absolute path to the JSON file.
|
|
331
|
+
* @param {string[]} keyPaths - Dot-separated key paths to remove.
|
|
332
|
+
* @returns {Record<string, unknown>} The data after removals.
|
|
333
|
+
*/
|
|
334
|
+
export function removeKeys(filePath, keyPaths) {
|
|
335
|
+
const data = readJsonFile(filePath);
|
|
336
|
+
|
|
337
|
+
// Back up original.
|
|
338
|
+
try {
|
|
339
|
+
copyFileSync(filePath, filePath + ".bak");
|
|
340
|
+
} catch (err) {
|
|
341
|
+
if (err.code !== "ENOENT") {
|
|
342
|
+
throw err;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
for (const keyPath of keyPaths) {
|
|
347
|
+
const segments = keyPath.split(".");
|
|
348
|
+
const leaf = segments.pop();
|
|
349
|
+
let cursor = data;
|
|
350
|
+
|
|
351
|
+
// Walk to the parent of the target key.
|
|
352
|
+
let reachable = true;
|
|
353
|
+
for (const seg of segments) {
|
|
354
|
+
if (isPlainObject(cursor) && Object.prototype.hasOwnProperty.call(cursor, seg)) {
|
|
355
|
+
cursor = cursor[seg];
|
|
356
|
+
} else {
|
|
357
|
+
reachable = false;
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (reachable && isPlainObject(cursor)) {
|
|
363
|
+
delete cursor[leaf];
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
writeJsonFile(filePath, data);
|
|
368
|
+
return data;
|
|
369
|
+
}
|