@deepsql/mcp 0.11.0 → 0.13.4

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.
@@ -1,16 +1,145 @@
1
1
  "use strict";
2
2
 
3
+ /**
4
+ * `deepsql mcp` — two modes:
5
+ *
6
+ * 1. Bare invocation (`deepsql mcp`) runs the stdio MCP server using the
7
+ * saved auth token. Editors point at this command so the token never
8
+ * has to be embedded in their config files.
9
+ *
10
+ * 2. `deepsql mcp config --install --for <editor>` writes a DeepSQL entry
11
+ * into the editor's MCP config file (JSON or TOML, with a `.bak.<ts>`
12
+ * backup of the previous contents) AND installs a "DBA consult" skill
13
+ * so the agent auto-triggers on database work without having to be
14
+ * told. `--print` emits the snippet only. `--force` overwrites an
15
+ * existing entry without complaint. `--no-skill` skips the skill
16
+ * install (config only).
17
+ *
18
+ * Supported editors and skill install paths:
19
+ * claude-code → ~/.claude/settings.json + ~/.claude/skills/deepsql/SKILL.md
20
+ * claude-desktop → ~/Library/.../Claude/... + (no skill — Desktop has no skills surface)
21
+ * cursor → ~/.cursor/mcp.json + ~/.cursor/rules/deepsql.mdc
22
+ * codex → ~/.codex/config.toml + ~/.codex/AGENTS.md (user-global, append-merge)
23
+ */
24
+
25
+ const fs = require("node:fs");
26
+ const os = require("node:os");
3
27
  const path = require("node:path");
4
- const { spawn } = require("node:child_process");
28
+ const { spawn, spawnSync } = require("node:child_process");
5
29
 
6
30
  const { resolveSession } = require("./_session");
7
31
 
8
- /**
9
- * Launch the existing Phase 1 stdio MCP server with the saved auth token
10
- * injected via env vars. The server is unchanged — this command is a thin
11
- * shim that means editor configs no longer need to embed a raw token.
12
- */
13
- async function run(opts) {
32
+ const SKILL_HEADER_MARKER = "<!-- BEGIN DEEPSQL DBA CONSULT SKILL -->";
33
+ const SKILL_FOOTER_MARKER = "<!-- END DEEPSQL DBA CONSULT SKILL -->";
34
+
35
+ const EDITORS = {
36
+ "claude-code": {
37
+ format: "json",
38
+ // Claude Code's user-scope MCP server list lives at the top of
39
+ // ~/.claude.json under `mcpServers`. The older ~/.claude/settings.json
40
+ // path that this installer used to target is for permissions/hooks/
41
+ // model settings — Claude Code never reads MCP entries from there.
42
+ //
43
+ // Preferred install path: shell out to `claude mcp add --scope user`
44
+ // (the official CLI handles future storage changes for us). If
45
+ // `claude` isn't on PATH (e.g. CI, SSH boxes), we fall back to
46
+ // writing ~/.claude.json directly via the standard JSON merge.
47
+ path: () => path.join(os.homedir(), ".claude.json"),
48
+ key: "mcpServers",
49
+ cli: {
50
+ binary: "claude",
51
+ // `claude mcp list` is the smoke test: exit 0 means we have a
52
+ // working Claude Code CLI we can delegate to. We resolve the
53
+ // command/args via builder fns so SERVER_ENTRY_NAME isn't
54
+ // forward-referenced.
55
+ detect: () => ["mcp", "list"],
56
+ // `claude mcp add --scope user <name> <cmd> [args…]`
57
+ add: () => ["mcp", "add", "--scope", "user", SERVER_ENTRY_NAME, SERVER_ENTRY.command, ...SERVER_ENTRY.args],
58
+ remove: () => ["mcp", "remove", SERVER_ENTRY_NAME, "--scope", "user"],
59
+ },
60
+ skill: {
61
+ kind: "file",
62
+ path: () => path.join(os.homedir(), ".claude", "skills", "deepsql", "SKILL.md"),
63
+ frontmatter: () => [
64
+ "---",
65
+ "name: deepsql",
66
+ "description: Use DeepSQL MCP tools whenever the user is doing database work — adding tables, writing migrations, designing schema, modeling new entities, or running SQL queries. Encodes the \"DBA consult\" pattern: get brain context, schema, business rules, and anti-patterns BEFORE generating any DDL or non-trivial SQL. Triggers on phrases like \"add a table\", \"create a column\", \"write a migration\", \"schema change\", \"design a model\", \"query the database\", \"SQL\", \"ORM model\", \"foreign key\", \"index\".",
67
+ "---",
68
+ "",
69
+ ].join("\n"),
70
+ },
71
+ },
72
+ "claude-desktop": {
73
+ format: "json",
74
+ path: () => claudeDesktopPath(),
75
+ key: "mcpServers",
76
+ skill: null, // Claude Desktop has no skills surface today
77
+ },
78
+ "cursor": {
79
+ format: "json",
80
+ path: () => path.join(os.homedir(), ".cursor", "mcp.json"),
81
+ key: "mcpServers",
82
+ skill: {
83
+ kind: "file",
84
+ path: () => path.join(os.homedir(), ".cursor", "rules", "deepsql.mdc"),
85
+ frontmatter: () => [
86
+ "---",
87
+ "description: DeepSQL DBA consult — call DeepSQL's MCP tools BEFORE generating any DDL, migration, or non-trivial SQL. Get brain context, schema, business rules, and anti-patterns first; then narrate findings to the user before proposing schema.",
88
+ "alwaysApply: false",
89
+ "globs:",
90
+ " - \"**/*.sql\"",
91
+ " - \"**/migrations/**\"",
92
+ " - \"**/schema/**\"",
93
+ " - \"**/models/**\"",
94
+ " - \"**/*.prisma\"",
95
+ " - \"**/entities/**\"",
96
+ "---",
97
+ "",
98
+ ].join("\n"),
99
+ },
100
+ },
101
+ "codex": {
102
+ format: "toml",
103
+ path: () => path.join(os.homedir(), ".codex", "config.toml"),
104
+ key: "mcp_servers",
105
+ skill: {
106
+ kind: "agents-append", // Codex reads AGENTS.md; we append a guarded section
107
+ path: () => path.join(os.homedir(), ".codex", "AGENTS.md"),
108
+ frontmatter: () => "",
109
+ },
110
+ },
111
+ };
112
+
113
+ const SERVER_ENTRY_NAME = "deepsql";
114
+ const SERVER_ENTRY = { command: "deepsql", args: ["mcp"] };
115
+
116
+ /** Read the shared skill body from disk. Bundled in the npm tarball. */
117
+ function loadSkillBody() {
118
+ const bodyPath = path.resolve(__dirname, "..", "..", "skills", "SKILL_BODY.md");
119
+ try {
120
+ return fs.readFileSync(bodyPath, "utf8");
121
+ } catch (err) {
122
+ throw new Error(
123
+ `Could not read the bundled skill body at ${bodyPath}: ${err.message}. `
124
+ + "Is this an incomplete install of @deepsql/mcp?",
125
+ );
126
+ }
127
+ }
128
+
129
+ async function run(opts, io = {}) {
130
+ const sub = opts.positional[0];
131
+ if (!sub) return runServer(opts, io);
132
+ if (sub === "config") {
133
+ return runConfig({ ...opts, positional: opts.positional.slice(1) }, io);
134
+ }
135
+ throw new Error(
136
+ `Unknown mcp subcommand: ${sub}. Try \`deepsql mcp\` (run server) or \`deepsql mcp config --install --for <editor>\`.`,
137
+ );
138
+ }
139
+
140
+ // ─── server (bare `deepsql mcp`) ────────────────────────────────────────────
141
+
142
+ function runServer(opts) {
14
143
  const session = resolveSession(opts);
15
144
  const serverPath = path.resolve(__dirname, "..", "..", "deepsql-phase1-server.js");
16
145
  const env = {
@@ -28,4 +157,459 @@ async function run(opts) {
28
157
  });
29
158
  }
30
159
 
31
- module.exports = { run };
160
+ // ─── `deepsql mcp config` ───────────────────────────────────────────────────
161
+
162
+ async function runConfig(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
163
+ const editor = opts.for;
164
+ if (!editor) {
165
+ throw new Error(
166
+ `Pass --for <editor>. Supported: ${Object.keys(EDITORS).join(", ")}.`,
167
+ );
168
+ }
169
+ const target = EDITORS[editor];
170
+ if (!target) {
171
+ throw new Error(
172
+ `Unknown editor "${editor}". Supported: ${Object.keys(EDITORS).join(", ")}.`,
173
+ );
174
+ }
175
+ if (!opts.install && !opts.print) {
176
+ throw new Error("Pass --install (write the config) or --print (emit the snippet only).");
177
+ }
178
+
179
+ if (opts.print) {
180
+ stdout.write(`# MCP server config snippet\n`);
181
+ stdout.write(`${renderSnippet(target.format, target.key)}\n\n`);
182
+ if (target.skill && !opts.noSkill) {
183
+ stdout.write(`# Skill — installed to ${target.skill.path()}\n`);
184
+ stdout.write(`${renderSkill(target.skill)}\n`);
185
+ } else if (!target.skill) {
186
+ stdout.write(`# (no skill — ${editor} has no skills surface)\n`);
187
+ }
188
+ return;
189
+ }
190
+
191
+ // Editor-CLI delegation: if the editor ships its own MCP CLI (Claude
192
+ // Code's `claude mcp add`) AND it's on PATH, use it. Falls back to
193
+ // direct file write if the CLI isn't available — useful for CI and
194
+ // SSH boxes that have @deepsql/mcp installed but no editor.
195
+ const configPath = opts.path || target.path();
196
+ let configResult;
197
+ let writtenVia = configPath;
198
+ const usingCli = target.cli && !opts.path && hasEditorCli(target.cli);
199
+ if (usingCli) {
200
+ try {
201
+ configResult = installViaCli({ cli: target.cli, force: !!opts.force });
202
+ writtenVia = `\`${target.cli.binary} mcp add --scope user\` (user-scope in ~/.claude.json)`;
203
+ } catch (err) {
204
+ stderr.write(
205
+ `\`${target.cli.binary} mcp add\` failed: ${err.message}\nFalling back to direct file write at ${configPath}.\n`,
206
+ );
207
+ configResult = mergeConfig({
208
+ format: target.format,
209
+ key: target.key,
210
+ configPath,
211
+ force: !!opts.force,
212
+ });
213
+ }
214
+ } else {
215
+ configResult = mergeConfig({
216
+ format: target.format,
217
+ key: target.key,
218
+ configPath,
219
+ force: !!opts.force,
220
+ });
221
+ }
222
+
223
+ if (configResult.skipped) {
224
+ stderr.write(
225
+ `DeepSQL MCP entry already present (${writtenVia}). Re-run with --force to overwrite, or --print to see the snippet.\n`,
226
+ );
227
+ } else {
228
+ stdout.write(`Installed DeepSQL MCP entry: ${writtenVia}.\n`);
229
+ if (configResult.backupPath) {
230
+ stdout.write(` backup: ${configResult.backupPath}\n`);
231
+ }
232
+ }
233
+
234
+ // Skill install: default-on. Users can opt out with --no-skill if they
235
+ // only want the MCP server config wired up.
236
+ if (target.skill && !opts.noSkill) {
237
+ const skillResult = installSkill({
238
+ skill: target.skill,
239
+ force: !!opts.force,
240
+ });
241
+ if (skillResult.skipped) {
242
+ stderr.write(
243
+ `DeepSQL skill already installed at ${target.skill.path()}. Re-run with --force to overwrite.\n`,
244
+ );
245
+ } else {
246
+ stdout.write(`Installed DeepSQL DBA-consult skill at ${target.skill.path()}.\n`);
247
+ if (skillResult.backupPath) {
248
+ stdout.write(` backup: ${skillResult.backupPath}\n`);
249
+ }
250
+ }
251
+ } else if (!target.skill && !opts.noSkill) {
252
+ stderr.write(
253
+ `Note: ${editor} has no skills surface, so no skill was installed. The MCP server config still works.\n`,
254
+ );
255
+ }
256
+
257
+ stdout.write("Restart the editor for the changes to take effect.\n");
258
+ }
259
+
260
+ // ─── skill install ──────────────────────────────────────────────────────────
261
+
262
+ /**
263
+ * Render the skill content (frontmatter + shared body). Used by --print and
264
+ * by installSkill().
265
+ */
266
+ function renderSkill(skill) {
267
+ const body = loadSkillBody();
268
+ return `${skill.frontmatter()}${body}`;
269
+ }
270
+
271
+ /**
272
+ * Install the skill file for an editor.
273
+ *
274
+ * Two flavors:
275
+ *
276
+ * - `kind: "file"` (Claude Code, Cursor)
277
+ * Writes a standalone skill file at `skill.path()`. If a file is
278
+ * already there and the content differs, back up before overwriting.
279
+ * If a file is there and matches, skip silently.
280
+ *
281
+ * - `kind: "agents-append"` (Codex CLI's AGENTS.md)
282
+ * Appends our skill content to the user's AGENTS.md as a guarded
283
+ * section (between `<!-- BEGIN DEEPSQL ... -->` markers) so the user's
284
+ * own instructions are preserved. Re-running replaces only the guarded
285
+ * section.
286
+ *
287
+ * Returns:
288
+ * { written: true, backupPath?: "...bak.<ts>" } on success
289
+ * { skipped: true } on no-op (content matches)
290
+ */
291
+ function installSkill({ skill, force }) {
292
+ const skillPath = skill.path();
293
+ ensureParentDir(skillPath);
294
+ const desiredContent = renderSkill(skill);
295
+
296
+ if (skill.kind === "agents-append") {
297
+ return upsertGuardedSection({
298
+ filePath: skillPath,
299
+ content: desiredContent,
300
+ force,
301
+ });
302
+ }
303
+
304
+ // Standalone-file flavor.
305
+ const exists = fs.existsSync(skillPath);
306
+ const original = exists ? fs.readFileSync(skillPath, "utf8") : null;
307
+ if (exists && original === desiredContent && !force) {
308
+ return { skipped: true };
309
+ }
310
+ let backupPath = null;
311
+ if (exists && original !== desiredContent) {
312
+ backupPath = `${skillPath}.bak.${Date.now()}`;
313
+ fs.writeFileSync(backupPath, original, { mode: 0o600 });
314
+ }
315
+ fs.writeFileSync(skillPath, desiredContent, { mode: 0o600 });
316
+ return { written: true, backupPath };
317
+ }
318
+
319
+ /**
320
+ * Append-or-replace a guarded section in a Markdown file. Preserves the
321
+ * user's content around the section. Re-running rewrites only the section
322
+ * between our markers.
323
+ */
324
+ function upsertGuardedSection({ filePath, content, force }) {
325
+ const exists = fs.existsSync(filePath);
326
+ const original = exists ? fs.readFileSync(filePath, "utf8") : "";
327
+
328
+ const guarded = `${SKILL_HEADER_MARKER}\n${content}\n${SKILL_FOOTER_MARKER}`;
329
+ const startIdx = original.indexOf(SKILL_HEADER_MARKER);
330
+
331
+ let next;
332
+ if (startIdx === -1) {
333
+ // First install: append (with a blank line before if there's existing
334
+ // content).
335
+ const trimmed = original.replace(/\s+$/, "");
336
+ next = trimmed ? `${trimmed}\n\n${guarded}\n` : `${guarded}\n`;
337
+ } else {
338
+ const endIdx = original.indexOf(SKILL_FOOTER_MARKER, startIdx);
339
+ if (endIdx === -1) {
340
+ throw new Error(
341
+ `Found ${SKILL_HEADER_MARKER} in ${filePath} but no matching footer. `
342
+ + "The file is in a half-edited state; fix it by hand and re-run.",
343
+ );
344
+ }
345
+ const before = original.slice(0, startIdx).replace(/\s+$/, "");
346
+ const after = original.slice(endIdx + SKILL_FOOTER_MARKER.length).replace(/^\s+/, "");
347
+ if (!force) {
348
+ // Compare the existing section to the new one.
349
+ const existingSection = original.slice(startIdx, endIdx + SKILL_FOOTER_MARKER.length);
350
+ if (existingSection === guarded) {
351
+ return { skipped: true };
352
+ }
353
+ }
354
+ next = [before, guarded, after].filter(Boolean).join("\n\n").replace(/\s+$/, "") + "\n";
355
+ }
356
+
357
+ let backupPath = null;
358
+ if (exists && original !== next) {
359
+ backupPath = `${filePath}.bak.${Date.now()}`;
360
+ fs.writeFileSync(backupPath, original, { mode: 0o600 });
361
+ }
362
+ fs.writeFileSync(filePath, next, { mode: 0o600 });
363
+ return { written: true, backupPath };
364
+ }
365
+
366
+ // ─── snippet rendering ──────────────────────────────────────────────────────
367
+
368
+ function renderSnippet(format, key) {
369
+ if (format === "toml") {
370
+ return [
371
+ `[${key}.${SERVER_ENTRY_NAME}]`,
372
+ `command = "${SERVER_ENTRY.command}"`,
373
+ `args = ${JSON.stringify(SERVER_ENTRY.args)}`,
374
+ ].join("\n");
375
+ }
376
+ return JSON.stringify(
377
+ { [key]: { [SERVER_ENTRY_NAME]: SERVER_ENTRY } },
378
+ null,
379
+ 2,
380
+ );
381
+ }
382
+
383
+ // ─── merge logic ────────────────────────────────────────────────────────────
384
+
385
+ /**
386
+ * Merge a DeepSQL entry into the editor's config file in-place. JSON files
387
+ * are parsed, mutated, and re-serialized. TOML files use a minimal "find or
388
+ * append" pass on the `[<key>.deepsql]` section header — we don't ship a
389
+ * TOML parser dep just for two lines of config.
390
+ *
391
+ * Returns:
392
+ * { written: true, backupPath: "...bak.<ts>" } on success
393
+ * { skipped: true } if an entry already exists and --force was not set
394
+ */
395
+ function mergeConfig({ format, key, configPath, force }) {
396
+ ensureParentDir(configPath);
397
+
398
+ const exists = fs.existsSync(configPath);
399
+ const original = exists ? fs.readFileSync(configPath, "utf8") : null;
400
+
401
+ let next;
402
+ if (format === "json") {
403
+ next = mergeJson(original, key, force);
404
+ } else if (format === "toml") {
405
+ next = mergeToml(original, key, force);
406
+ } else {
407
+ throw new Error(`Unsupported config format: ${format}`);
408
+ }
409
+
410
+ if (next.skipped) return { skipped: true };
411
+
412
+ let backupPath = null;
413
+ if (exists && original !== next.text) {
414
+ backupPath = `${configPath}.bak.${Date.now()}`;
415
+ fs.writeFileSync(backupPath, original, { mode: 0o600 });
416
+ }
417
+ fs.writeFileSync(configPath, next.text, { mode: 0o600 });
418
+ return { written: true, backupPath };
419
+ }
420
+
421
+ // ─── Editor-CLI delegation (Claude Code) ───────────────────────────────────
422
+ //
423
+ // For editors that ship their own MCP-config CLI (currently just Claude
424
+ // Code via `claude mcp add`), we prefer delegating instead of writing
425
+ // config files ourselves. Three reasons:
426
+ //
427
+ // 1. The on-disk format is documented as private and has changed once
428
+ // already (settings.json → .claude.json at the top level under
429
+ // mcpServers, vs. local-scope's keyed-by-project layout).
430
+ // 2. The CLI handles scopes (user / project / local) correctly. Writing
431
+ // to top-level mcpServers in ~/.claude.json mostly works for user
432
+ // scope but it's brittle; the CLI is the contract.
433
+ // 3. If Anthropic changes the layout again, the CLI keeps working
434
+ // without a deepsql release.
435
+ //
436
+ // We only delegate when `claude mcp list` actually exits 0 — that's our
437
+ // proof of life. Otherwise we fall back to direct JSON write.
438
+
439
+ /**
440
+ * Return true if `claude mcp list` works on this machine. Tested by
441
+ * spawning `claude` with `mcp list` and a 5-second timeout — fast enough
442
+ * to keep the installer snappy, slow enough that a normal cold start
443
+ * (loading config, listing servers) reliably completes.
444
+ *
445
+ * `spawnFn` is overridable for tests.
446
+ */
447
+ function hasEditorCli({ binary, detect }, { spawnFn = spawnSync } = {}) {
448
+ if (!binary) return false;
449
+ try {
450
+ const result = spawnFn(binary, detect(), {
451
+ stdio: ["ignore", "pipe", "pipe"],
452
+ timeout: 5000,
453
+ });
454
+ return result && result.status === 0;
455
+ } catch {
456
+ return false;
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Run `claude mcp list` and check whether `deepsql` is already in the
462
+ * output. Used to decide between "no-op", "remove + add" (force), and
463
+ * "add" paths.
464
+ */
465
+ function isAlreadyConfiguredViaCli({ binary, detect }, { spawnFn = spawnSync } = {}) {
466
+ try {
467
+ const result = spawnFn(binary, detect(), {
468
+ stdio: ["ignore", "pipe", "pipe"],
469
+ timeout: 5000,
470
+ encoding: "utf8",
471
+ });
472
+ if (!result || result.status !== 0) return false;
473
+ const stdout = String(result.stdout || "");
474
+ // `claude mcp list` lines look like:
475
+ // deepsql: deepsql mcp - ✓ Connected
476
+ // deepsql: /path/... - ✗ Failed to connect
477
+ // We match the entry name at the start of a line, followed by `:`,
478
+ // which is stable across both states.
479
+ return new RegExp(`(?:^|\\n)\\s*${SERVER_ENTRY_NAME}\\s*:`).test(stdout);
480
+ } catch {
481
+ return false;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Install the DeepSQL MCP entry by delegating to the editor's CLI.
487
+ *
488
+ * Returns:
489
+ * { written: true, viaCli: "<binary>" } on success
490
+ * { skipped: true } when already present and !force
491
+ *
492
+ * On --force: remove first (best-effort; ignore "not found" exits), then
493
+ * add. The CLI handles backup/atomicity for us.
494
+ */
495
+ function installViaCli({ cli, force }, { spawnFn = spawnSync } = {}) {
496
+ if (isAlreadyConfiguredViaCli(cli, { spawnFn }) && !force) {
497
+ return { skipped: true };
498
+ }
499
+
500
+ if (force && isAlreadyConfiguredViaCli(cli, { spawnFn })) {
501
+ // Best-effort remove — if the entry was in a different scope, the
502
+ // remove may fail, but that's the user's problem to disambiguate.
503
+ spawnFn(cli.binary, cli.remove(), {
504
+ stdio: ["ignore", "pipe", "pipe"],
505
+ timeout: 5000,
506
+ });
507
+ }
508
+
509
+ const addResult = spawnFn(cli.binary, cli.add(), {
510
+ stdio: ["ignore", "pipe", "pipe"],
511
+ timeout: 10000,
512
+ encoding: "utf8",
513
+ });
514
+ if (!addResult || addResult.status !== 0) {
515
+ const stderr = String(addResult && addResult.stderr ? addResult.stderr : "").trim();
516
+ throw new Error(
517
+ `\`${cli.binary} ${cli.add().join(" ")}\` exited with status ${addResult ? addResult.status : "?"}.${
518
+ stderr ? ` stderr: ${stderr}` : ""
519
+ }`,
520
+ );
521
+ }
522
+
523
+ return { written: true, viaCli: cli.binary };
524
+ }
525
+
526
+ function mergeJson(originalText, key, force) {
527
+ let parsed = {};
528
+ if (originalText && originalText.trim()) {
529
+ try {
530
+ parsed = JSON.parse(originalText);
531
+ } catch (err) {
532
+ throw new Error(
533
+ `Refusing to overwrite ${err.message ? "malformed JSON" : "config"}. Fix the file by hand first or pass --path <other>.`,
534
+ );
535
+ }
536
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
537
+ throw new Error("Top-level value must be a JSON object.");
538
+ }
539
+ }
540
+ if (!parsed[key] || typeof parsed[key] !== "object" || Array.isArray(parsed[key])) {
541
+ parsed[key] = {};
542
+ }
543
+ if (parsed[key][SERVER_ENTRY_NAME] && !force) {
544
+ return { skipped: true };
545
+ }
546
+ parsed[key][SERVER_ENTRY_NAME] = SERVER_ENTRY;
547
+ return { text: `${JSON.stringify(parsed, null, 2)}\n` };
548
+ }
549
+
550
+ function mergeToml(originalText, key, force) {
551
+ const text = originalText || "";
552
+ const header = `[${key}.${SERVER_ENTRY_NAME}]`;
553
+ const block = renderSnippet("toml", key);
554
+
555
+ // Section already present?
556
+ const lines = text.split(/\r?\n/);
557
+ const startIdx = lines.findIndex((l) => l.trim() === header);
558
+ if (startIdx >= 0) {
559
+ if (!force) return { skipped: true };
560
+ // Replace [header ... up-to-next-header).
561
+ let endIdx = lines.length;
562
+ for (let i = startIdx + 1; i < lines.length; i++) {
563
+ if (/^\s*\[/.test(lines[i])) {
564
+ endIdx = i;
565
+ break;
566
+ }
567
+ }
568
+ const before = lines.slice(0, startIdx).join("\n").replace(/\s+$/, "");
569
+ const after = lines.slice(endIdx).join("\n").replace(/^\s+/, "");
570
+ const joined = [before, block, after].filter(Boolean).join("\n\n");
571
+ return { text: `${joined}\n` };
572
+ }
573
+
574
+ // Append. Keep a blank line between any existing content and our block.
575
+ const trimmed = text.replace(/\s+$/, "");
576
+ const joined = trimmed ? `${trimmed}\n\n${block}\n` : `${block}\n`;
577
+ return { text: joined };
578
+ }
579
+
580
+ function ensureParentDir(filePath) {
581
+ const dir = path.dirname(filePath);
582
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
583
+ }
584
+
585
+ function claudeDesktopPath() {
586
+ // macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
587
+ // Windows: %APPDATA%/Claude/claude_desktop_config.json
588
+ // Linux: ~/.config/Claude/claude_desktop_config.json (best-effort; Claude
589
+ // Desktop isn't officially shipped on Linux yet but the pattern
590
+ // matches the XDG default).
591
+ if (process.platform === "darwin") {
592
+ return path.join(os.homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
593
+ }
594
+ if (process.platform === "win32" && process.env.APPDATA) {
595
+ return path.join(process.env.APPDATA, "Claude", "claude_desktop_config.json");
596
+ }
597
+ return path.join(os.homedir(), ".config", "Claude", "claude_desktop_config.json");
598
+ }
599
+
600
+ module.exports = {
601
+ run,
602
+ // Exported for tests — small surface, all pure-ish.
603
+ mergeJson,
604
+ mergeToml,
605
+ renderSnippet,
606
+ mergeConfig,
607
+ installSkill,
608
+ upsertGuardedSection,
609
+ renderSkill,
610
+ loadSkillBody,
611
+ hasEditorCli,
612
+ isAlreadyConfiguredViaCli,
613
+ installViaCli,
614
+ EDITORS,
615
+ };