@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.
Files changed (51) hide show
  1. package/README.md +505 -0
  2. package/assets/hooks-handlers/lib/capture.js +863 -0
  3. package/assets/hooks-handlers/lib/checkpoint-timer.js +72 -0
  4. package/assets/hooks-handlers/pre-compact.sh +3 -0
  5. package/assets/hooks-handlers/session-end.sh +27 -0
  6. package/assets/hooks-handlers/session-start.sh +24 -0
  7. package/assets/hooks-handlers/test_single_conversation.sh +169 -0
  8. package/assets/skills/meko-mcp-tools/SKILL.md +192 -0
  9. package/assets/skills/meko-mcp-tools/references/_sections.md +31 -0
  10. package/assets/skills/meko-mcp-tools/references/tools-agent-id-conventions.md +39 -0
  11. package/assets/skills/meko-mcp-tools/references/tools-conversation-dedup.md +49 -0
  12. package/assets/skills/meko-mcp-tools/references/tools-cookbook.md +373 -0
  13. package/assets/skills/meko-mcp-tools/references/tools-datapack-workflow.md +56 -0
  14. package/assets/skills/meko-mcp-tools/references/tools-db-query-patterns.md +38 -0
  15. package/assets/skills/meko-mcp-tools/references/tools-known-limitations.md +61 -0
  16. package/assets/skills/meko-mcp-tools/references/tools-memory-vs-conversation.md +128 -0
  17. package/assets/skills/meko-mcp-tools/references/tools-overview.md +97 -0
  18. package/assets/skills/meko-mcp-tools/references/tools-rag-workflow.md +63 -0
  19. package/assets/skills/meko-mcp-tools/references/tools-scope-permissions.md +79 -0
  20. package/assets/skills/meko-mcp-tools/references/tools-troubleshooting.md +143 -0
  21. package/assets/skills/meko-mcp-tools/references/tools-user-id-conventions.md +93 -0
  22. package/assets/skills/meko-mcp-tools/references/tools-write-guardrails.md +30 -0
  23. package/bin/create-meko-setup.mjs +499 -0
  24. package/lib/claude-code.mjs +14 -0
  25. package/lib/clients/claude-code.mjs +265 -0
  26. package/lib/clients/claude-desktop.mjs +256 -0
  27. package/lib/clients/common.mjs +107 -0
  28. package/lib/clients/cursor.mjs +332 -0
  29. package/lib/clients/index.mjs +14 -0
  30. package/lib/config.mjs +369 -0
  31. package/lib/detect.mjs +125 -0
  32. package/lib/installer.mjs +255 -0
  33. package/lib/logger.mjs +97 -0
  34. package/lib/mcp-transport.mjs +204 -0
  35. package/lib/migrate/adapters/claude-code.mjs +165 -0
  36. package/lib/migrate/adapters/cursor.mjs +221 -0
  37. package/lib/migrate/adapters/memory-md.mjs +230 -0
  38. package/lib/migrate/claude-parser.mjs +306 -0
  39. package/lib/migrate/daemon.mjs +67 -0
  40. package/lib/migrate/id.mjs +83 -0
  41. package/lib/migrate/lockfile.mjs +121 -0
  42. package/lib/migrate/logger.mjs +79 -0
  43. package/lib/migrate/mcp-client.mjs +224 -0
  44. package/lib/migrate/orchestrator.mjs +443 -0
  45. package/lib/migrate/progress.mjs +76 -0
  46. package/lib/migrate/state.mjs +177 -0
  47. package/lib/migrate.mjs +328 -0
  48. package/lib/prompt.mjs +212 -0
  49. package/lib/validate.mjs +259 -0
  50. package/package.json +49 -0
  51. 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
+ }