@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
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const path = require("node:path");
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
mergeJson,
|
|
11
|
+
mergeToml,
|
|
12
|
+
renderSnippet,
|
|
13
|
+
mergeConfig,
|
|
14
|
+
installSkill,
|
|
15
|
+
upsertGuardedSection,
|
|
16
|
+
renderSkill,
|
|
17
|
+
loadSkillBody,
|
|
18
|
+
hasEditorCli,
|
|
19
|
+
isAlreadyConfiguredViaCli,
|
|
20
|
+
installViaCli,
|
|
21
|
+
EDITORS,
|
|
22
|
+
} = require("./mcp");
|
|
23
|
+
|
|
24
|
+
function withTempDir(fn) {
|
|
25
|
+
return async () => {
|
|
26
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "deepsql-mcp-skill-test-"));
|
|
27
|
+
try {
|
|
28
|
+
await fn(dir);
|
|
29
|
+
} finally {
|
|
30
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ─── snippet rendering ──────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
test("renderSnippet emits valid JSON for the JSON editors", () => {
|
|
38
|
+
const text = renderSnippet("json", "mcpServers");
|
|
39
|
+
const parsed = JSON.parse(text);
|
|
40
|
+
assert.deepEqual(parsed, {
|
|
41
|
+
mcpServers: { deepsql: { command: "deepsql", args: ["mcp"] } },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("renderSnippet emits a parseable [mcp_servers.deepsql] section for codex", () => {
|
|
46
|
+
const text = renderSnippet("toml", "mcp_servers");
|
|
47
|
+
assert.match(text, /^\[mcp_servers\.deepsql\]/m);
|
|
48
|
+
assert.match(text, /command = "deepsql"/);
|
|
49
|
+
assert.match(text, /args = \["mcp"\]/);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ─── JSON merge ─────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
test("mergeJson creates the file when nothing exists", () => {
|
|
55
|
+
const r = mergeJson(null, "mcpServers", false);
|
|
56
|
+
const parsed = JSON.parse(r.text);
|
|
57
|
+
assert.equal(parsed.mcpServers.deepsql.command, "deepsql");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("mergeJson preserves siblings under mcpServers", () => {
|
|
61
|
+
const original = JSON.stringify({
|
|
62
|
+
mcpServers: {
|
|
63
|
+
"other-tool": { command: "node", args: ["server.js"] },
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
const r = mergeJson(original, "mcpServers", false);
|
|
67
|
+
const parsed = JSON.parse(r.text);
|
|
68
|
+
assert.equal(parsed.mcpServers["other-tool"].command, "node");
|
|
69
|
+
assert.equal(parsed.mcpServers.deepsql.command, "deepsql");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("mergeJson preserves top-level siblings (e.g. permissions, env)", () => {
|
|
73
|
+
const original = JSON.stringify({ env: { FOO: "bar" }, permissions: ["x"] });
|
|
74
|
+
const r = mergeJson(original, "mcpServers", false);
|
|
75
|
+
const parsed = JSON.parse(r.text);
|
|
76
|
+
assert.deepEqual(parsed.env, { FOO: "bar" });
|
|
77
|
+
assert.deepEqual(parsed.permissions, ["x"]);
|
|
78
|
+
assert.ok(parsed.mcpServers.deepsql);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("mergeJson skips when an existing deepsql entry exists and --force is off", () => {
|
|
82
|
+
const original = JSON.stringify({
|
|
83
|
+
mcpServers: { deepsql: { command: "stale", args: [] } },
|
|
84
|
+
});
|
|
85
|
+
const r = mergeJson(original, "mcpServers", false);
|
|
86
|
+
assert.equal(r.skipped, true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("mergeJson overwrites with --force", () => {
|
|
90
|
+
const original = JSON.stringify({
|
|
91
|
+
mcpServers: { deepsql: { command: "stale", args: [] } },
|
|
92
|
+
});
|
|
93
|
+
const r = mergeJson(original, "mcpServers", true);
|
|
94
|
+
const parsed = JSON.parse(r.text);
|
|
95
|
+
assert.equal(parsed.mcpServers.deepsql.command, "deepsql");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("mergeJson refuses to silently overwrite malformed JSON", () => {
|
|
99
|
+
assert.throws(() => mergeJson("{this is not json", "mcpServers", false), /malformed JSON|config/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("mergeJson rejects a top-level array", () => {
|
|
103
|
+
assert.throws(() => mergeJson("[1,2,3]", "mcpServers", false), /must be a JSON object/);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ─── TOML merge ─────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
test("mergeToml appends when no section exists", () => {
|
|
109
|
+
const r = mergeToml("", "mcp_servers", false);
|
|
110
|
+
assert.match(r.text, /^\[mcp_servers\.deepsql\]/m);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("mergeToml preserves unrelated sections above and below", () => {
|
|
114
|
+
const original = [
|
|
115
|
+
"[runtime]",
|
|
116
|
+
"verbose = true",
|
|
117
|
+
"",
|
|
118
|
+
"[mcp_servers.other]",
|
|
119
|
+
'command = "elsewhere"',
|
|
120
|
+
"",
|
|
121
|
+
].join("\n");
|
|
122
|
+
const r = mergeToml(original, "mcp_servers", false);
|
|
123
|
+
assert.match(r.text, /\[runtime\]/);
|
|
124
|
+
assert.match(r.text, /verbose = true/);
|
|
125
|
+
assert.match(r.text, /\[mcp_servers\.other\]/);
|
|
126
|
+
assert.match(r.text, /\[mcp_servers\.deepsql\]/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("mergeToml skips an existing deepsql section without --force", () => {
|
|
130
|
+
const original = '[mcp_servers.deepsql]\ncommand = "stale"\nargs = []\n';
|
|
131
|
+
const r = mergeToml(original, "mcp_servers", false);
|
|
132
|
+
assert.equal(r.skipped, true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("mergeToml replaces an existing deepsql section with --force, leaving neighbors alone", () => {
|
|
136
|
+
const original = [
|
|
137
|
+
"[mcp_servers.deepsql]",
|
|
138
|
+
'command = "stale"',
|
|
139
|
+
"args = []",
|
|
140
|
+
"",
|
|
141
|
+
"[mcp_servers.other]",
|
|
142
|
+
'command = "elsewhere"',
|
|
143
|
+
"",
|
|
144
|
+
].join("\n");
|
|
145
|
+
const r = mergeToml(original, "mcp_servers", true);
|
|
146
|
+
assert.match(r.text, /command = "deepsql"/);
|
|
147
|
+
assert.equal(/command = "stale"/.test(r.text), false);
|
|
148
|
+
assert.match(r.text, /\[mcp_servers\.other\]/, "neighbor section must survive");
|
|
149
|
+
assert.match(r.text, /command = "elsewhere"/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ─── filesystem integration ────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
test("mergeConfig writes the JSON file (no original, no backup)", withTempDir((dir) => {
|
|
155
|
+
const configPath = path.join(dir, "mcp.json");
|
|
156
|
+
const result = mergeConfig({
|
|
157
|
+
format: "json", key: "mcpServers", configPath, force: false,
|
|
158
|
+
});
|
|
159
|
+
assert.equal(result.written, true);
|
|
160
|
+
assert.equal(result.backupPath, null);
|
|
161
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
162
|
+
assert.equal(parsed.mcpServers.deepsql.command, "deepsql");
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
test("mergeConfig backs up the existing file when it actually changes", withTempDir((dir) => {
|
|
166
|
+
const configPath = path.join(dir, "mcp.json");
|
|
167
|
+
fs.writeFileSync(configPath, JSON.stringify({ mcpServers: { other: { command: "x" } } }));
|
|
168
|
+
const result = mergeConfig({
|
|
169
|
+
format: "json", key: "mcpServers", configPath, force: false,
|
|
170
|
+
});
|
|
171
|
+
assert.equal(result.written, true);
|
|
172
|
+
assert.ok(result.backupPath, "backup path must be set when content changed");
|
|
173
|
+
assert.ok(fs.existsSync(result.backupPath));
|
|
174
|
+
const restored = JSON.parse(fs.readFileSync(result.backupPath, "utf8"));
|
|
175
|
+
assert.equal(restored.mcpServers.other.command, "x", "backup must contain pre-merge content");
|
|
176
|
+
}));
|
|
177
|
+
|
|
178
|
+
test("mergeConfig returns skipped=true when deepsql already there and --force off", withTempDir((dir) => {
|
|
179
|
+
const configPath = path.join(dir, "mcp.json");
|
|
180
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
181
|
+
mcpServers: { deepsql: { command: "deepsql", args: ["mcp"] } },
|
|
182
|
+
}));
|
|
183
|
+
const result = mergeConfig({
|
|
184
|
+
format: "json", key: "mcpServers", configPath, force: false,
|
|
185
|
+
});
|
|
186
|
+
assert.equal(result.skipped, true);
|
|
187
|
+
}));
|
|
188
|
+
|
|
189
|
+
test("mergeConfig writes TOML to ~/.codex/config.toml layout", withTempDir((dir) => {
|
|
190
|
+
const configPath = path.join(dir, "config.toml");
|
|
191
|
+
fs.writeFileSync(configPath, "[runtime]\nverbose = true\n");
|
|
192
|
+
const result = mergeConfig({
|
|
193
|
+
format: "toml", key: "mcp_servers", configPath, force: false,
|
|
194
|
+
});
|
|
195
|
+
assert.equal(result.written, true);
|
|
196
|
+
const text = fs.readFileSync(configPath, "utf8");
|
|
197
|
+
assert.match(text, /\[runtime\]/);
|
|
198
|
+
assert.match(text, /\[mcp_servers\.deepsql\]/);
|
|
199
|
+
}));
|
|
200
|
+
|
|
201
|
+
test("mergeConfig creates parent directories that don't exist yet", withTempDir((dir) => {
|
|
202
|
+
const configPath = path.join(dir, "deep", "nested", "mcp.json");
|
|
203
|
+
const result = mergeConfig({
|
|
204
|
+
format: "json", key: "mcpServers", configPath, force: false,
|
|
205
|
+
});
|
|
206
|
+
assert.equal(result.written, true);
|
|
207
|
+
assert.ok(fs.existsSync(configPath));
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
// ─── editor catalog ────────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
test("EDITORS catalog exposes the four supported targets with sensible defaults", () => {
|
|
213
|
+
for (const editor of ["claude-code", "claude-desktop", "cursor", "codex"]) {
|
|
214
|
+
const e = EDITORS[editor];
|
|
215
|
+
assert.ok(e, `missing editor: ${editor}`);
|
|
216
|
+
assert.match(e.format, /^(json|toml)$/);
|
|
217
|
+
assert.ok(typeof e.path === "function");
|
|
218
|
+
const p = e.path();
|
|
219
|
+
assert.ok(p && typeof p === "string" && p.length > 0);
|
|
220
|
+
}
|
|
221
|
+
assert.equal(EDITORS.codex.format, "toml");
|
|
222
|
+
for (const editor of ["claude-code", "claude-desktop", "cursor"]) {
|
|
223
|
+
assert.equal(EDITORS[editor].format, "json");
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("claude-code falls back to ~/.claude.json (not ~/.claude/settings.json) for user-scope MCP", () => {
|
|
228
|
+
// ~/.claude/settings.json is for permissions/hooks/model settings;
|
|
229
|
+
// Claude Code reads MCP from ~/.claude.json at the top level. Older
|
|
230
|
+
// installer versions had this wrong, which surfaced as
|
|
231
|
+
// "deepsql_* tools aren't loaded in this session" reports from users.
|
|
232
|
+
const p = EDITORS["claude-code"].path();
|
|
233
|
+
assert.ok(p.endsWith(".claude.json"), `unexpected claude-code fallback path: ${p}`);
|
|
234
|
+
assert.equal(/\.claude\/settings\.json$/.test(p), false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("claude-code prefers CLI delegation (cli descriptor present)", () => {
|
|
238
|
+
const cli = EDITORS["claude-code"].cli;
|
|
239
|
+
assert.ok(cli, "claude-code must carry a CLI delegation descriptor");
|
|
240
|
+
assert.equal(cli.binary, "claude");
|
|
241
|
+
assert.deepEqual(cli.detect(), ["mcp", "list"]);
|
|
242
|
+
// `claude mcp add --scope user deepsql deepsql mcp`
|
|
243
|
+
assert.deepEqual(cli.add(), ["mcp", "add", "--scope", "user", "deepsql", "deepsql", "mcp"]);
|
|
244
|
+
assert.deepEqual(cli.remove(), ["mcp", "remove", "deepsql", "--scope", "user"]);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("cursor and codex do not carry a CLI delegation descriptor (no equivalent tooling)", () => {
|
|
248
|
+
assert.equal(EDITORS["cursor"].cli, undefined);
|
|
249
|
+
assert.equal(EDITORS["codex"].cli, undefined);
|
|
250
|
+
assert.equal(EDITORS["claude-desktop"].cli, undefined);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
// ─── CLI delegation helpers ────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
function fakeSpawn(table) {
|
|
256
|
+
// table is an array of { match: { args: [...] }, result: { status, stdout?, stderr? } }
|
|
257
|
+
// First match wins; unmatched calls fail loudly so tests catch silent fallthroughs.
|
|
258
|
+
return (binary, args) => {
|
|
259
|
+
for (const entry of table) {
|
|
260
|
+
if (!entry.match) return entry.result;
|
|
261
|
+
const a = entry.match.args;
|
|
262
|
+
if (a.length === args.length && a.every((v, i) => v === args[i])) {
|
|
263
|
+
return entry.result;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
throw new Error(`fakeSpawn: no match for ${binary} ${args.join(" ")}`);
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
test("hasEditorCli returns true when the detect command exits 0", () => {
|
|
271
|
+
const spawnFn = fakeSpawn([{ match: { args: ["mcp", "list"] }, result: { status: 0, stdout: "" } }]);
|
|
272
|
+
assert.equal(hasEditorCli(EDITORS["claude-code"].cli, { spawnFn }), true);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("hasEditorCli returns false when the binary errors / isn't found", () => {
|
|
276
|
+
const spawnFn = () => { throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }); };
|
|
277
|
+
assert.equal(hasEditorCli(EDITORS["claude-code"].cli, { spawnFn }), false);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("hasEditorCli returns false when the binary exits non-zero", () => {
|
|
281
|
+
const spawnFn = fakeSpawn([{ match: { args: ["mcp", "list"] }, result: { status: 1 } }]);
|
|
282
|
+
assert.equal(hasEditorCli(EDITORS["claude-code"].cli, { spawnFn }), false);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("isAlreadyConfiguredViaCli matches 'deepsql:' line in claude mcp list output", () => {
|
|
286
|
+
const stdout = [
|
|
287
|
+
"claude.ai Gmail: https://gmailmcp.googleapis.com/mcp/v1 - ✓ Connected",
|
|
288
|
+
"claude.ai Slack: https://mcp.slack.com/mcp - ✓ Connected",
|
|
289
|
+
"deepsql: deepsql mcp - ✓ Connected",
|
|
290
|
+
"",
|
|
291
|
+
].join("\n");
|
|
292
|
+
const spawnFn = fakeSpawn([{ match: { args: ["mcp", "list"] }, result: { status: 0, stdout } }]);
|
|
293
|
+
assert.equal(isAlreadyConfiguredViaCli(EDITORS["claude-code"].cli, { spawnFn }), true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("isAlreadyConfiguredViaCli also matches a failed-to-connect deepsql entry (the bug we hit)", () => {
|
|
297
|
+
// The user's actual diagnostic showed this exact form. The detector
|
|
298
|
+
// must still classify it as "already configured" so --force can clean
|
|
299
|
+
// it up before re-adding.
|
|
300
|
+
const stdout = "deepsql: node /old/stale/path/server.js - ✗ Failed to connect\n";
|
|
301
|
+
const spawnFn = fakeSpawn([{ match: { args: ["mcp", "list"] }, result: { status: 0, stdout } }]);
|
|
302
|
+
assert.equal(isAlreadyConfiguredViaCli(EDITORS["claude-code"].cli, { spawnFn }), true);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("isAlreadyConfiguredViaCli returns false when deepsql isn't in the list", () => {
|
|
306
|
+
const stdout = "claude.ai Gmail: ... - ✓ Connected\n";
|
|
307
|
+
const spawnFn = fakeSpawn([{ match: { args: ["mcp", "list"] }, result: { status: 0, stdout } }]);
|
|
308
|
+
assert.equal(isAlreadyConfiguredViaCli(EDITORS["claude-code"].cli, { spawnFn }), false);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("installViaCli runs `claude mcp add --scope user` when not already present", () => {
|
|
312
|
+
const calls = [];
|
|
313
|
+
const spawnFn = (binary, args) => {
|
|
314
|
+
calls.push({ binary, args });
|
|
315
|
+
if (args[0] === "mcp" && args[1] === "list") return { status: 0, stdout: "" };
|
|
316
|
+
if (args[0] === "mcp" && args[1] === "add") return { status: 0, stdout: "Added MCP server deepsql" };
|
|
317
|
+
throw new Error(`unexpected: ${args.join(" ")}`);
|
|
318
|
+
};
|
|
319
|
+
const result = installViaCli({ cli: EDITORS["claude-code"].cli, force: false }, { spawnFn });
|
|
320
|
+
assert.equal(result.written, true);
|
|
321
|
+
assert.equal(result.viaCli, "claude");
|
|
322
|
+
// First call: list (the isAlreadyConfigured check). Second: add.
|
|
323
|
+
assert.equal(calls.length, 2);
|
|
324
|
+
assert.deepEqual(calls[1].args, ["mcp", "add", "--scope", "user", "deepsql", "deepsql", "mcp"]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
test("installViaCli skips when deepsql is already configured and --force is off", () => {
|
|
328
|
+
const calls = [];
|
|
329
|
+
const spawnFn = (binary, args) => {
|
|
330
|
+
calls.push(args);
|
|
331
|
+
return { status: 0, stdout: "deepsql: deepsql mcp - ✓ Connected\n" };
|
|
332
|
+
};
|
|
333
|
+
const result = installViaCli({ cli: EDITORS["claude-code"].cli, force: false }, { spawnFn });
|
|
334
|
+
assert.equal(result.skipped, true);
|
|
335
|
+
// We should not have attempted `mcp add` after detecting the entry.
|
|
336
|
+
assert.equal(calls.some((a) => a[0] === "mcp" && a[1] === "add"), false);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test("installViaCli with --force removes the existing entry then re-adds", () => {
|
|
340
|
+
const calls = [];
|
|
341
|
+
const spawnFn = (binary, args) => {
|
|
342
|
+
calls.push(args);
|
|
343
|
+
if (args[0] === "mcp" && args[1] === "list") return { status: 0, stdout: "deepsql: foo - ✗ Failed\n" };
|
|
344
|
+
if (args[0] === "mcp" && args[1] === "remove") return { status: 0 };
|
|
345
|
+
if (args[0] === "mcp" && args[1] === "add") return { status: 0 };
|
|
346
|
+
throw new Error(`unexpected: ${args.join(" ")}`);
|
|
347
|
+
};
|
|
348
|
+
installViaCli({ cli: EDITORS["claude-code"].cli, force: true }, { spawnFn });
|
|
349
|
+
// Sequence: list (skip check) → list (force check) → remove → add.
|
|
350
|
+
const ops = calls.map((a) => `${a[0]} ${a[1]}`);
|
|
351
|
+
assert.ok(ops.includes("mcp remove"), `expected mcp remove, got: ${ops.join(", ")}`);
|
|
352
|
+
assert.ok(ops.includes("mcp add"));
|
|
353
|
+
// Remove must come before add.
|
|
354
|
+
assert.ok(ops.indexOf("mcp remove") < ops.indexOf("mcp add"));
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("installViaCli throws a clean error when `claude mcp add` exits non-zero (surfaces stderr)", () => {
|
|
358
|
+
const spawnFn = (binary, args) => {
|
|
359
|
+
if (args[1] === "list") return { status: 0, stdout: "" };
|
|
360
|
+
if (args[1] === "add") return { status: 1, stderr: "permission denied: ~/.claude.json" };
|
|
361
|
+
throw new Error("unexpected");
|
|
362
|
+
};
|
|
363
|
+
assert.throws(
|
|
364
|
+
() => installViaCli({ cli: EDITORS["claude-code"].cli, force: false }, { spawnFn }),
|
|
365
|
+
/exited with status 1.*permission denied/,
|
|
366
|
+
);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ─── skill body + per-editor metadata ──────────────────────────────────────
|
|
370
|
+
|
|
371
|
+
test("loadSkillBody returns the bundled SKILL_BODY.md content", () => {
|
|
372
|
+
const body = loadSkillBody();
|
|
373
|
+
assert.ok(body.length > 500, "skill body should not be empty");
|
|
374
|
+
assert.match(body, /DBA consult/i, "body should mention the DBA consult framing");
|
|
375
|
+
assert.match(body, /get_brain_context/, "body should reference the brain-context tool call");
|
|
376
|
+
assert.match(body, /confirmMutation/, "body should reference the mutation confirm flow");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("each supported editor either has a skill descriptor or explicitly opts out", () => {
|
|
380
|
+
for (const [name, editor] of Object.entries(EDITORS)) {
|
|
381
|
+
if (editor.skill === null) {
|
|
382
|
+
// Explicit opt-out (claude-desktop today).
|
|
383
|
+
assert.equal(name, "claude-desktop", `unexpected null skill on ${name}`);
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
assert.ok(editor.skill, `${name} needs a skill descriptor or explicit null`);
|
|
387
|
+
assert.match(editor.skill.kind, /^(file|agents-append)$/);
|
|
388
|
+
assert.ok(typeof editor.skill.path === "function");
|
|
389
|
+
assert.ok(typeof editor.skill.frontmatter === "function");
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("renderSkill prepends Claude-Code-style YAML frontmatter naming the skill", () => {
|
|
394
|
+
const text = renderSkill(EDITORS["claude-code"].skill);
|
|
395
|
+
assert.match(text, /^---\nname: deepsql\n/);
|
|
396
|
+
assert.match(text, /description:.*DBA consult/i);
|
|
397
|
+
assert.match(text, /\n---\n\n#/, "frontmatter must be terminated before the body");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("renderSkill prepends Cursor-style frontmatter with globs and alwaysApply=false", () => {
|
|
401
|
+
const text = renderSkill(EDITORS["cursor"].skill);
|
|
402
|
+
assert.match(text, /^---\n/);
|
|
403
|
+
assert.match(text, /alwaysApply: false/);
|
|
404
|
+
assert.match(text, /globs:/);
|
|
405
|
+
assert.match(text, /migrations/);
|
|
406
|
+
assert.match(text, /\.prisma/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("renderSkill for codex emits the bare body (the AGENTS.md wrapper adds guards)", () => {
|
|
410
|
+
const text = renderSkill(EDITORS["codex"].skill);
|
|
411
|
+
// No frontmatter for codex — AGENTS.md is plain markdown, the
|
|
412
|
+
// upsertGuardedSection wrapper adds the BEGIN/END markers.
|
|
413
|
+
assert.equal(/^---/.test(text), false);
|
|
414
|
+
assert.match(text, /^# DeepSQL/);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// ─── installSkill (standalone-file flavor: claude-code, cursor) ────────────
|
|
418
|
+
|
|
419
|
+
test("installSkill writes Claude Code SKILL.md at the configured path", withTempDir(async (dir) => {
|
|
420
|
+
const skillPath = path.join(dir, "claude", "skills", "deepsql", "SKILL.md");
|
|
421
|
+
const skill = {
|
|
422
|
+
kind: "file",
|
|
423
|
+
path: () => skillPath,
|
|
424
|
+
frontmatter: EDITORS["claude-code"].skill.frontmatter,
|
|
425
|
+
};
|
|
426
|
+
const r = installSkill({ skill, force: false });
|
|
427
|
+
assert.equal(r.written, true);
|
|
428
|
+
assert.equal(r.backupPath, null, "no backup when file didn't exist before");
|
|
429
|
+
const written = fs.readFileSync(skillPath, "utf8");
|
|
430
|
+
assert.match(written, /^---\nname: deepsql\n/);
|
|
431
|
+
assert.match(written, /DBA consult/);
|
|
432
|
+
}));
|
|
433
|
+
|
|
434
|
+
test("installSkill is idempotent — second call without --force is a no-op", withTempDir(async (dir) => {
|
|
435
|
+
const skillPath = path.join(dir, "SKILL.md");
|
|
436
|
+
const skill = {
|
|
437
|
+
kind: "file",
|
|
438
|
+
path: () => skillPath,
|
|
439
|
+
frontmatter: EDITORS["claude-code"].skill.frontmatter,
|
|
440
|
+
};
|
|
441
|
+
const r1 = installSkill({ skill, force: false });
|
|
442
|
+
assert.equal(r1.written, true);
|
|
443
|
+
const r2 = installSkill({ skill, force: false });
|
|
444
|
+
assert.equal(r2.skipped, true, "should not rewrite identical content");
|
|
445
|
+
}));
|
|
446
|
+
|
|
447
|
+
test("installSkill backs up a divergent skill file before overwriting with --force", withTempDir(async (dir) => {
|
|
448
|
+
const skillPath = path.join(dir, "SKILL.md");
|
|
449
|
+
fs.writeFileSync(skillPath, "stale content the user wrote by hand\n");
|
|
450
|
+
const skill = {
|
|
451
|
+
kind: "file",
|
|
452
|
+
path: () => skillPath,
|
|
453
|
+
frontmatter: EDITORS["claude-code"].skill.frontmatter,
|
|
454
|
+
};
|
|
455
|
+
const r = installSkill({ skill, force: true });
|
|
456
|
+
assert.equal(r.written, true);
|
|
457
|
+
assert.ok(r.backupPath, "must back up the file when content differs");
|
|
458
|
+
const backup = fs.readFileSync(r.backupPath, "utf8");
|
|
459
|
+
assert.match(backup, /stale content/);
|
|
460
|
+
}));
|
|
461
|
+
|
|
462
|
+
test("installSkill writes Cursor .mdc with globs", withTempDir(async (dir) => {
|
|
463
|
+
const rulePath = path.join(dir, "rules", "deepsql.mdc");
|
|
464
|
+
const skill = {
|
|
465
|
+
kind: "file",
|
|
466
|
+
path: () => rulePath,
|
|
467
|
+
frontmatter: EDITORS["cursor"].skill.frontmatter,
|
|
468
|
+
};
|
|
469
|
+
installSkill({ skill, force: false });
|
|
470
|
+
const written = fs.readFileSync(rulePath, "utf8");
|
|
471
|
+
assert.match(written, /alwaysApply: false/);
|
|
472
|
+
assert.match(written, /migrations/);
|
|
473
|
+
}));
|
|
474
|
+
|
|
475
|
+
// ─── upsertGuardedSection (codex AGENTS.md flavor) ─────────────────────────
|
|
476
|
+
|
|
477
|
+
test("upsertGuardedSection appends a guarded section to a new AGENTS.md", withTempDir(async (dir) => {
|
|
478
|
+
const file = path.join(dir, "AGENTS.md");
|
|
479
|
+
const r = upsertGuardedSection({ filePath: file, content: "hello world", force: false });
|
|
480
|
+
assert.equal(r.written, true);
|
|
481
|
+
const text = fs.readFileSync(file, "utf8");
|
|
482
|
+
assert.match(text, /<!-- BEGIN DEEPSQL DBA CONSULT SKILL -->/);
|
|
483
|
+
assert.match(text, /<!-- END DEEPSQL DBA CONSULT SKILL -->/);
|
|
484
|
+
assert.match(text, /hello world/);
|
|
485
|
+
}));
|
|
486
|
+
|
|
487
|
+
test("upsertGuardedSection preserves the user's existing AGENTS.md content", withTempDir(async (dir) => {
|
|
488
|
+
const file = path.join(dir, "AGENTS.md");
|
|
489
|
+
fs.writeFileSync(file, "# My agent instructions\n\nDon't bug me on weekends.\n");
|
|
490
|
+
upsertGuardedSection({ filePath: file, content: "deepsql skill content", force: false });
|
|
491
|
+
const text = fs.readFileSync(file, "utf8");
|
|
492
|
+
assert.match(text, /# My agent instructions/, "user's pre-existing content must be preserved");
|
|
493
|
+
assert.match(text, /Don't bug me on weekends/);
|
|
494
|
+
assert.match(text, /deepsql skill content/);
|
|
495
|
+
}));
|
|
496
|
+
|
|
497
|
+
test("upsertGuardedSection replaces only the guarded section on re-install", withTempDir(async (dir) => {
|
|
498
|
+
const file = path.join(dir, "AGENTS.md");
|
|
499
|
+
fs.writeFileSync(file, "# Top of file\n\nUser stuff.\n");
|
|
500
|
+
upsertGuardedSection({ filePath: file, content: "v1 content", force: false });
|
|
501
|
+
upsertGuardedSection({ filePath: file, content: "v2 content", force: true });
|
|
502
|
+
const text = fs.readFileSync(file, "utf8");
|
|
503
|
+
assert.equal(/v1 content/.test(text), false, "old guarded content must be replaced");
|
|
504
|
+
assert.match(text, /v2 content/);
|
|
505
|
+
assert.match(text, /# Top of file/, "user content above the guarded section stays");
|
|
506
|
+
assert.match(text, /User stuff\./);
|
|
507
|
+
}));
|
|
508
|
+
|
|
509
|
+
test("upsertGuardedSection re-installs the same content as a no-op (skipped)", withTempDir(async (dir) => {
|
|
510
|
+
const file = path.join(dir, "AGENTS.md");
|
|
511
|
+
upsertGuardedSection({ filePath: file, content: "stable content", force: false });
|
|
512
|
+
const r = upsertGuardedSection({ filePath: file, content: "stable content", force: false });
|
|
513
|
+
assert.equal(r.skipped, true);
|
|
514
|
+
}));
|
|
515
|
+
|
|
516
|
+
test("upsertGuardedSection refuses to write if a BEGIN marker exists without a matching END", withTempDir(async (dir) => {
|
|
517
|
+
const file = path.join(dir, "AGENTS.md");
|
|
518
|
+
fs.writeFileSync(file, "# Header\n\n<!-- BEGIN DEEPSQL DBA CONSULT SKILL -->\nhalf-edited\n");
|
|
519
|
+
assert.throws(
|
|
520
|
+
() => upsertGuardedSection({ filePath: file, content: "anything", force: false }),
|
|
521
|
+
/half-edited state/,
|
|
522
|
+
);
|
|
523
|
+
}));
|
|
524
|
+
|
|
525
|
+
// ─── per-editor skill_off opt-out via skill === null ───────────────────────
|
|
526
|
+
|
|
527
|
+
test("claude-desktop intentionally has no skill descriptor (no skills surface)", () => {
|
|
528
|
+
assert.equal(EDITORS["claude-desktop"].skill, null);
|
|
529
|
+
});
|
package/src/commands/query.js
CHANGED
|
@@ -1,36 +1,118 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* `deepsql query` — execute a SQL statement against a connection.
|
|
5
|
+
*
|
|
6
|
+
* In 0.13.0 this stopped being a read-only-only command. It now hits the same
|
|
7
|
+
* canonical Editor endpoint (`/api/connections/{id}/query`) that the web UI
|
|
8
|
+
* uses, so policy decisions are uniform across all DeepSQL surfaces:
|
|
9
|
+
*
|
|
10
|
+
* - Developer + SELECT/WITH/SHOW/EXPLAIN → runs immediately
|
|
11
|
+
* - Developer + DML/DDL → backend returns 403 with a
|
|
12
|
+
* clear EDITOR_MUTATION_FORBIDDEN
|
|
13
|
+
* - Admin + DML/DDL (no --write) → server returns
|
|
14
|
+
* requiresConfirmation; we print
|
|
15
|
+
* the warnings, prompt y/N, and
|
|
16
|
+
* re-send with confirmMutation=true
|
|
17
|
+
* - Admin + DML/DDL + --write → confirmation flag is set
|
|
18
|
+
* upfront, no prompt; useful in
|
|
19
|
+
* scripts / CI
|
|
20
|
+
*
|
|
21
|
+
* `EXPLAIN` and `EXPLAIN ANALYZE` are just SQL — no special flag needed.
|
|
22
|
+
* For the AI-enriched plan analysis, use `deepsql analyze "<sql>"`.
|
|
23
|
+
*
|
|
24
|
+
* The old phase-1 read-only parser has been removed; the backend is the
|
|
25
|
+
* single source of truth on policy now. This also closes the
|
|
26
|
+
* "client says no but server would have said yes" mismatches we kept
|
|
27
|
+
* hitting in CI.
|
|
28
|
+
*/
|
|
29
|
+
|
|
3
30
|
const fs = require("node:fs");
|
|
4
31
|
const { request } = require("../api/client");
|
|
5
|
-
const { validateReadOnlySql } = require("../../deepsql-phase1-lib");
|
|
6
32
|
const { resolveSession } = require("./_session");
|
|
7
33
|
const { resolveConnectionId } = require("./_connections");
|
|
34
|
+
const ui = require("../ui/prompts");
|
|
8
35
|
|
|
9
|
-
async function run(opts, { stdout = process.stdout } = {}) {
|
|
36
|
+
async function run(opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
10
37
|
const sql = readSqlInput(opts);
|
|
11
|
-
const validation = validateReadOnlySql(sql, { allowExplain: true });
|
|
12
|
-
if (!validation.ok) throw new Error(validation.reason);
|
|
13
38
|
|
|
14
39
|
const session = resolveSession(opts);
|
|
15
40
|
const connectionId = await resolveConnectionId(session, opts.connection);
|
|
16
41
|
const limit = clampInt(opts.limit, 1, 1000, 100);
|
|
17
|
-
const
|
|
42
|
+
const timeoutSeconds = opts.timeoutSeconds == null ? null : clampInt(opts.timeoutSeconds, 1, 60, null);
|
|
43
|
+
|
|
44
|
+
const response = await runOnce(session, connectionId, {
|
|
45
|
+
query: sql,
|
|
46
|
+
limit,
|
|
47
|
+
timeoutSeconds,
|
|
48
|
+
mutationConfirmed: !!opts.write,
|
|
49
|
+
});
|
|
18
50
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
51
|
+
// Two-step mutation flow: server returns 200 with requiresConfirmation=true
|
|
52
|
+
// when the statement is a mutation and confirmMutation wasn't set. We show
|
|
53
|
+
// the warning list, ask the human, then resubmit.
|
|
54
|
+
if (response && response.success === false && response.requiresConfirmation) {
|
|
55
|
+
if (opts.write) {
|
|
56
|
+
// --write was passed but server still wants confirmation. That means
|
|
57
|
+
// the request lost the flag somewhere — surface it.
|
|
58
|
+
throw new Error(
|
|
59
|
+
`Server still asked for confirmation despite --write. Message: ${response.message}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
const accepted = await promptForConfirmation(response, { stderr });
|
|
63
|
+
if (!accepted) {
|
|
64
|
+
stderr.write("Aborted.\n");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const confirmed = await runOnce(session, connectionId, {
|
|
68
|
+
query: sql,
|
|
25
69
|
limit,
|
|
26
|
-
timeoutSeconds
|
|
70
|
+
timeoutSeconds,
|
|
71
|
+
mutationConfirmed: true,
|
|
72
|
+
});
|
|
73
|
+
printOutcome(confirmed, opts, { stdout, stderr });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
printOutcome(response, opts, { stdout, stderr });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function runOnce(session, connectionId, body) {
|
|
81
|
+
return request(
|
|
82
|
+
session.baseUrl,
|
|
83
|
+
`/connections/${encodeURIComponent(connectionId)}/query`,
|
|
84
|
+
{
|
|
85
|
+
method: "POST",
|
|
86
|
+
token: session.token,
|
|
87
|
+
json: body,
|
|
27
88
|
},
|
|
28
|
-
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function promptForConfirmation(response, { stderr = process.stderr }) {
|
|
93
|
+
stderr.write(`\n⚠ ${response.message || "This statement will modify the database."}\n`);
|
|
94
|
+
if (response.queryType) stderr.write(` statement: ${response.queryType}\n`);
|
|
95
|
+
const warnings = Array.isArray(response.warnings) ? response.warnings : [];
|
|
96
|
+
for (const w of warnings) {
|
|
97
|
+
stderr.write(` • ${w}\n`);
|
|
98
|
+
}
|
|
99
|
+
stderr.write("\n");
|
|
100
|
+
return ui.confirm({ message: "Execute the statement?", default: false });
|
|
101
|
+
}
|
|
29
102
|
|
|
103
|
+
function printOutcome(response, opts, { stdout = process.stdout, stderr = process.stderr } = {}) {
|
|
30
104
|
if (opts.json) {
|
|
31
105
|
stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
32
106
|
return;
|
|
33
107
|
}
|
|
108
|
+
if (response && response.success === false) {
|
|
109
|
+
// Block or failure that didn't go via the throw path. Print message
|
|
110
|
+
// + error code so a script can grep.
|
|
111
|
+
const code = response.errorCode ? ` [${response.errorCode}]` : "";
|
|
112
|
+
stderr.write(`${response.message || "Query failed."}${code}\n`);
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
34
116
|
printRows(stdout, response);
|
|
35
117
|
}
|
|
36
118
|
|