@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.
- package/AGENT-SETUP.md +83 -31
- package/CLAUDE.md +382 -0
- package/README.md +109 -25
- package/claude_desktop_config.customer.example.json +3 -11
- package/codex_config.customer.example.toml +12 -8
- package/deepsql-phase1-lib.js +149 -27
- package/deepsql-phase1-server.js +1 -1
- package/package.json +3 -2
- package/skills/SKILL_BODY.md +125 -0
- package/src/api/client.js +35 -1
- package/src/cli.js +65 -20
- package/src/cli.test.js +1 -1
- package/src/commands/analyze.js +165 -0
- package/src/commands/analyze.test.js +180 -0
- package/src/commands/explain.js +18 -34
- package/src/commands/mcp.js +592 -8
- package/src/commands/mcp.test.js +529 -0
- package/src/commands/query.js +95 -13
- package/src/commands/query.test.js +214 -0
package/src/commands/mcp.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
+
};
|