@deepsql/mcp 0.13.0 → 0.14.0
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 +15 -6
- package/CLAUDE.md +20 -6
- 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 +225 -2
- package/deepsql-phase1-server.js +1 -1
- package/package.json +1 -1
- package/skills/SKILL_BODY.md +169 -33
- package/src/cli.js +34 -0
- package/src/commands/index-recommendations.js +426 -0
- package/src/commands/index-recommendations.test.js +323 -0
- package/src/commands/login.js +46 -7
- package/src/commands/login.test.js +111 -0
- package/src/commands/mcp.js +162 -10
- package/src/commands/mcp.test.js +145 -0
package/src/commands/mcp.js
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
const fs = require("node:fs");
|
|
26
26
|
const os = require("node:os");
|
|
27
27
|
const path = require("node:path");
|
|
28
|
-
const { spawn } = require("node:child_process");
|
|
28
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
29
29
|
|
|
30
30
|
const { resolveSession } = require("./_session");
|
|
31
31
|
|
|
@@ -35,8 +35,28 @@ const SKILL_FOOTER_MARKER = "<!-- END DEEPSQL DBA CONSULT SKILL -->";
|
|
|
35
35
|
const EDITORS = {
|
|
36
36
|
"claude-code": {
|
|
37
37
|
format: "json",
|
|
38
|
-
|
|
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"),
|
|
39
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
|
+
},
|
|
40
60
|
skill: {
|
|
41
61
|
kind: "file",
|
|
42
62
|
path: () => path.join(os.homedir(), ".claude", "skills", "deepsql", "SKILL.md"),
|
|
@@ -168,20 +188,44 @@ async function runConfig(opts, { stdout = process.stdout, stderr = process.stder
|
|
|
168
188
|
return;
|
|
169
189
|
}
|
|
170
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.
|
|
171
195
|
const configPath = opts.path || target.path();
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
+
}
|
|
178
222
|
|
|
179
223
|
if (configResult.skipped) {
|
|
180
224
|
stderr.write(
|
|
181
|
-
`DeepSQL
|
|
225
|
+
`DeepSQL MCP entry already present (${writtenVia}). Re-run with --force to overwrite, or --print to see the snippet.\n`,
|
|
182
226
|
);
|
|
183
227
|
} else {
|
|
184
|
-
stdout.write(`Installed DeepSQL MCP entry
|
|
228
|
+
stdout.write(`Installed DeepSQL MCP entry: ${writtenVia}.\n`);
|
|
185
229
|
if (configResult.backupPath) {
|
|
186
230
|
stdout.write(` backup: ${configResult.backupPath}\n`);
|
|
187
231
|
}
|
|
@@ -374,6 +418,111 @@ function mergeConfig({ format, key, configPath, force }) {
|
|
|
374
418
|
return { written: true, backupPath };
|
|
375
419
|
}
|
|
376
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
|
+
|
|
377
526
|
function mergeJson(originalText, key, force) {
|
|
378
527
|
let parsed = {};
|
|
379
528
|
if (originalText && originalText.trim()) {
|
|
@@ -459,5 +608,8 @@ module.exports = {
|
|
|
459
608
|
upsertGuardedSection,
|
|
460
609
|
renderSkill,
|
|
461
610
|
loadSkillBody,
|
|
611
|
+
hasEditorCli,
|
|
612
|
+
isAlreadyConfiguredViaCli,
|
|
613
|
+
installViaCli,
|
|
462
614
|
EDITORS,
|
|
463
615
|
};
|
package/src/commands/mcp.test.js
CHANGED
|
@@ -15,6 +15,9 @@ const {
|
|
|
15
15
|
upsertGuardedSection,
|
|
16
16
|
renderSkill,
|
|
17
17
|
loadSkillBody,
|
|
18
|
+
hasEditorCli,
|
|
19
|
+
isAlreadyConfiguredViaCli,
|
|
20
|
+
installViaCli,
|
|
18
21
|
EDITORS,
|
|
19
22
|
} = require("./mcp");
|
|
20
23
|
|
|
@@ -221,6 +224,148 @@ test("EDITORS catalog exposes the four supported targets with sensible defaults"
|
|
|
221
224
|
}
|
|
222
225
|
});
|
|
223
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
|
+
|
|
224
369
|
// ─── skill body + per-editor metadata ──────────────────────────────────────
|
|
225
370
|
|
|
226
371
|
test("loadSkillBody returns the bundled SKILL_BODY.md content", () => {
|