@debugai/mcp 2.0.0 → 2.1.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/README.md +114 -112
- package/dist/auth.d.ts +40 -0
- package/dist/auth.js +115 -0
- package/dist/backend.d.ts +9 -0
- package/dist/cli/clients.d.ts +36 -0
- package/dist/cli/clients.js +143 -0
- package/dist/cli/commands.d.ts +7 -0
- package/dist/cli/commands.js +360 -0
- package/dist/cli/install.d.ts +21 -0
- package/dist/cli/install.js +150 -0
- package/dist/cli/jsonc.d.ts +12 -0
- package/dist/cli/jsonc.js +115 -0
- package/dist/cli/ui.d.ts +19 -0
- package/dist/cli/ui.js +56 -0
- package/dist/config.d.ts +10 -0
- package/dist/config.js +54 -2
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +5 -0
- package/dist/deviceLink.d.ts +51 -0
- package/dist/deviceLink.js +125 -0
- package/dist/index.js +67 -23
- package/dist/server.js +29 -1
- package/dist/tools/authGate.d.ts +10 -0
- package/dist/tools/authGate.js +29 -0
- package/dist/tools/debugError.js +5 -1
- package/dist/tools/reportOutcome.js +5 -1
- package/package.json +2 -2
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// The subcommands. Every one of these exists to delete a step a human used
|
|
2
|
+
// to do by hand:
|
|
3
|
+
//
|
|
4
|
+
// setup login + install, the only command the README leads with
|
|
5
|
+
// login device link — replaces "copy your key out of the dashboard"
|
|
6
|
+
// install writes client configs — replaces "paste this JSON blob"
|
|
7
|
+
// doctor one command that answers "why isn't it working"
|
|
8
|
+
// status what account/key is active right now
|
|
9
|
+
// logout removes the stored key
|
|
10
|
+
// uninstall removes the server entry from client configs
|
|
11
|
+
//
|
|
12
|
+
// Each returns a process exit code. Nothing here ever writes to stdout while
|
|
13
|
+
// the MCP transport is live — subcommands exit before a server is created.
|
|
14
|
+
import { statSync } from 'node:fs';
|
|
15
|
+
import { hostname, platform } from 'node:os';
|
|
16
|
+
import { clearStoredKey, configPath, loadFileConfig, maskKey, resolveSettings, writeFileConfig, } from '../config.js';
|
|
17
|
+
import { DeviceLinkError, startDeviceLink, waitForDeviceLink } from '../deviceLink.js';
|
|
18
|
+
import { DEFAULT_API_BASE } from '../constants.js';
|
|
19
|
+
import { detectedClients, findClient, isDetected, knownClients, } from './clients.js';
|
|
20
|
+
import { applyToClient, isInstalled } from './install.js';
|
|
21
|
+
import { FAIL, INFO, OK, WARN, bold, codeBox, dim, heading, openBrowser, say, yellow } from './ui.js';
|
|
22
|
+
// ── shared helpers ───────────────────────────────────────────────────────────
|
|
23
|
+
function flag(argv, name) {
|
|
24
|
+
return argv.includes(`--${name}`);
|
|
25
|
+
}
|
|
26
|
+
function flagValue(argv, name) {
|
|
27
|
+
const eq = argv.find((a) => a.startsWith(`--${name}=`));
|
|
28
|
+
if (eq)
|
|
29
|
+
return eq.slice(name.length + 3);
|
|
30
|
+
const idx = argv.indexOf(`--${name}`);
|
|
31
|
+
if (idx >= 0 && argv[idx + 1] && !argv[idx + 1].startsWith('-'))
|
|
32
|
+
return argv[idx + 1];
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
/** Confirms a key actually works against the live API. Null = could not verify. */
|
|
36
|
+
async function verifyKey(apiBase, apiKey) {
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(), 12_000);
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${apiBase}/user/me`, {
|
|
41
|
+
headers: { 'x-api-key': apiKey },
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok)
|
|
45
|
+
return null;
|
|
46
|
+
const body = await res.json();
|
|
47
|
+
const user = body?.user ?? body ?? {};
|
|
48
|
+
return {
|
|
49
|
+
email: user.email,
|
|
50
|
+
tier: user.tier,
|
|
51
|
+
usedToday: user.usage_today ?? user.used_today ?? user.daily_usage,
|
|
52
|
+
dailyLimit: user.daily_limit ?? user.limit,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function clientLabel() {
|
|
63
|
+
return `${hostname()} (${platform()})`;
|
|
64
|
+
}
|
|
65
|
+
function describeResult(r) {
|
|
66
|
+
const name = bold(r.client.label);
|
|
67
|
+
switch (r.action) {
|
|
68
|
+
case 'wrote':
|
|
69
|
+
case 'updated':
|
|
70
|
+
say(` ${OK()} ${name} — ${r.action === 'wrote' ? 'added to' : 'updated in'} ${dim(r.path ?? '')}`);
|
|
71
|
+
if (r.backupPath)
|
|
72
|
+
say(` ${dim(`backup: ${r.backupPath}`)}`);
|
|
73
|
+
if (r.warning)
|
|
74
|
+
say(` ${WARN()} ${yellow(r.warning)}`);
|
|
75
|
+
say(` ${dim(`next: ${r.client.afterInstall}`)}`);
|
|
76
|
+
break;
|
|
77
|
+
case 'unchanged':
|
|
78
|
+
say(` ${OK()} ${name} — already configured, nothing to change`);
|
|
79
|
+
break;
|
|
80
|
+
case 'removed':
|
|
81
|
+
say(` ${OK()} ${name} — entry removed from ${dim(r.path ?? '')}`);
|
|
82
|
+
if (r.backupPath)
|
|
83
|
+
say(` ${dim(`backup: ${r.backupPath}`)}`);
|
|
84
|
+
break;
|
|
85
|
+
case 'skipped':
|
|
86
|
+
say(` ${INFO()} ${name} — skipped${r.warning ? `: ${r.warning}` : ''}`);
|
|
87
|
+
break;
|
|
88
|
+
case 'failed':
|
|
89
|
+
say(` ${FAIL()} ${name} — ${r.error ?? 'failed'}`);
|
|
90
|
+
say(` ${dim(`file: ${r.path ?? '(unknown)'}`)}`);
|
|
91
|
+
if (r.preview) {
|
|
92
|
+
say(` ${dim('merge this in by hand:')}`);
|
|
93
|
+
r.preview.split('\n').forEach((l) => say(` ${dim(l)}`));
|
|
94
|
+
}
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// ── login ────────────────────────────────────────────────────────────────────
|
|
99
|
+
export async function cmdLogin(argv, env = process.env) {
|
|
100
|
+
const { apiBase } = resolveSettings(DEFAULT_API_BASE, env, () => { });
|
|
101
|
+
const manualKey = flagValue(argv, 'key');
|
|
102
|
+
// Escape hatch for CI, air-gapped machines, and anyone who would rather
|
|
103
|
+
// paste. Same storage path, so everything downstream behaves identically.
|
|
104
|
+
if (manualKey) {
|
|
105
|
+
if (!manualKey.startsWith('dbg_')) {
|
|
106
|
+
say(`${FAIL()} That does not look like a DebugAI key. They start with ${bold('dbg_')}.`);
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
const path = writeFileConfig({ apiKey: manualKey }, env);
|
|
110
|
+
const info = await verifyKey(apiBase, manualKey);
|
|
111
|
+
say(`${OK()} Key stored in ${path}${info?.email ? ` for ${bold(info.email)}` : ''}.`);
|
|
112
|
+
if (!info)
|
|
113
|
+
say(`${WARN()} ${yellow('Could not verify it against the API just now. Run "debugai-mcp doctor" later.')}`);
|
|
114
|
+
return 0;
|
|
115
|
+
}
|
|
116
|
+
const existing = loadFileConfig(env, () => { }).apiKey;
|
|
117
|
+
if (existing && !flag(argv, 'force')) {
|
|
118
|
+
const info = await verifyKey(apiBase, existing);
|
|
119
|
+
if (info) {
|
|
120
|
+
say(`${OK()} Already signed in as ${bold(info.email ?? 'this account')} (${info.tier ?? 'free'} tier).`);
|
|
121
|
+
say(` ${dim(`Key ${maskKey(existing)} in ${configPath(env)}. Re-link with "debugai-mcp login --force".`)}`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
say(`${WARN()} A stored key exists but the API rejected it. Re-linking.`);
|
|
125
|
+
}
|
|
126
|
+
let start;
|
|
127
|
+
try {
|
|
128
|
+
start = await startDeviceLink({ apiBase, clientLabel: clientLabel() });
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
const detail = err instanceof DeviceLinkError ? err.message : String(err);
|
|
132
|
+
say(`${FAIL()} Could not start the sign-in link: ${detail}`);
|
|
133
|
+
say(` ${dim('Fallback: grab a key at https://debugai.io/dashboard and run')}`);
|
|
134
|
+
say(` ${dim('debugai-mcp login --key dbg_your_key')}`);
|
|
135
|
+
return 1;
|
|
136
|
+
}
|
|
137
|
+
say();
|
|
138
|
+
say(bold('Sign in to DebugAI'));
|
|
139
|
+
say(codeBox(start.userCode));
|
|
140
|
+
say(` Confirm that code at ${bold(start.verificationUriComplete)}`);
|
|
141
|
+
const opened = openBrowser(start.verificationUriComplete);
|
|
142
|
+
say(opened
|
|
143
|
+
? dim(' (opening your browser. free account, 10 debugs/day, no card)')
|
|
144
|
+
: dim(' (open that link on any device. free account, 10 debugs/day, no card)'));
|
|
145
|
+
say();
|
|
146
|
+
say(dim(` Waiting… the code expires in ${Math.round(start.expiresIn / 60)} minutes. Ctrl-C to cancel.`));
|
|
147
|
+
const result = await waitForDeviceLink(start, { apiBase });
|
|
148
|
+
if (result.status === 'linked') {
|
|
149
|
+
const path = writeFileConfig({ apiKey: result.apiKey }, env);
|
|
150
|
+
say();
|
|
151
|
+
say(`${OK()} Signed in${result.email ? ` as ${bold(result.email)}` : ''}${result.tier ? ` (${result.tier} tier)` : ''}.`);
|
|
152
|
+
say(` ${dim(`Key saved to ${path}. Every MCP client on this machine reads it.`)}`);
|
|
153
|
+
say();
|
|
154
|
+
say(` Next: ${bold('npx -y @debugai/mcp install')} ${dim('(wires up the MCP clients you have)')}`);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
say();
|
|
158
|
+
if (result.status === 'denied')
|
|
159
|
+
say(`${FAIL()} Sign-in was declined in the browser.`);
|
|
160
|
+
else if (result.status === 'expired')
|
|
161
|
+
say(`${FAIL()} The code expired. Run "debugai-mcp login" again.`);
|
|
162
|
+
else
|
|
163
|
+
say(`${FAIL()} Sign-in did not complete.`);
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
// ── logout / status ──────────────────────────────────────────────────────────
|
|
167
|
+
export function cmdLogout(_argv, env = process.env) {
|
|
168
|
+
const removed = clearStoredKey(env);
|
|
169
|
+
say(removed
|
|
170
|
+
? `${OK()} Stored key removed from ${configPath(env)}.`
|
|
171
|
+
: `${INFO()} No stored key to remove (${configPath(env)}).`);
|
|
172
|
+
if ((env.DEBUGAI_API_KEY ?? '').trim()) {
|
|
173
|
+
say(`${WARN()} ${yellow('DEBUGAI_API_KEY is still set in this environment and takes priority over the file.')}`);
|
|
174
|
+
}
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
export async function cmdStatus(_argv, env = process.env) {
|
|
178
|
+
const { apiKey, apiBase, keySource } = resolveSettings(DEFAULT_API_BASE, env, () => { });
|
|
179
|
+
say(`${bold('DebugAI MCP status')}`);
|
|
180
|
+
say(` API base ${apiBase}`);
|
|
181
|
+
say(` Key ${apiKey ? `${maskKey(apiKey)} (from ${keySource === 'env' ? 'DEBUGAI_API_KEY' : configPath(env)})` : dim('none')}`);
|
|
182
|
+
if (!apiKey) {
|
|
183
|
+
say();
|
|
184
|
+
say(` Run ${bold('npx -y @debugai/mcp login')} to sign in.`);
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
const info = await verifyKey(apiBase, apiKey);
|
|
188
|
+
if (!info) {
|
|
189
|
+
say(` Account ${FAIL()} key rejected or API unreachable`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
say(` Account ${info.email ?? '(unknown)'} · ${info.tier ?? 'free'} tier`);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
195
|
+
// ── install / uninstall ──────────────────────────────────────────────────────
|
|
196
|
+
function selectClients(argv, env) {
|
|
197
|
+
const raw = flagValue(argv, 'client');
|
|
198
|
+
if (raw) {
|
|
199
|
+
const ids = raw.split(',').map((s) => s.trim()).filter(Boolean);
|
|
200
|
+
const clients = [];
|
|
201
|
+
for (const id of ids) {
|
|
202
|
+
const found = findClient(id, env);
|
|
203
|
+
if (found)
|
|
204
|
+
clients.push(found);
|
|
205
|
+
else
|
|
206
|
+
say(`${WARN()} Unknown client "${id}". Run "debugai-mcp install --list" to see the names.`);
|
|
207
|
+
}
|
|
208
|
+
return { clients, explicit: true };
|
|
209
|
+
}
|
|
210
|
+
if (flag(argv, 'all'))
|
|
211
|
+
return { clients: knownClients(env).filter((c) => !c.optIn), explicit: false };
|
|
212
|
+
return { clients: detectedClients(env).filter((c) => !c.optIn), explicit: false };
|
|
213
|
+
}
|
|
214
|
+
function listClients(env) {
|
|
215
|
+
heading('Known MCP clients');
|
|
216
|
+
for (const c of knownClients(env)) {
|
|
217
|
+
const mark = isDetected(c) ? OK() : INFO();
|
|
218
|
+
const state = isDetected(c) ? (isInstalled(c) ? 'detected · debugai configured' : 'detected') : 'not found';
|
|
219
|
+
say(` ${mark} ${bold(c.id.padEnd(15))} ${c.label.padEnd(22)} ${dim(state)}`);
|
|
220
|
+
say(` ${dim(c.configPath ?? 'no config path on this OS')}`);
|
|
221
|
+
if (c.note)
|
|
222
|
+
say(` ${dim(c.note)}`);
|
|
223
|
+
}
|
|
224
|
+
say();
|
|
225
|
+
say(dim(' Install one explicitly: debugai-mcp install --client=cursor'));
|
|
226
|
+
}
|
|
227
|
+
export function cmdInstall(argv, env = process.env) {
|
|
228
|
+
if (flag(argv, 'list')) {
|
|
229
|
+
listClients(env);
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
const dryRun = flag(argv, 'dry-run') || flag(argv, 'print');
|
|
233
|
+
const remove = flag(argv, 'remove');
|
|
234
|
+
const { clients, explicit } = selectClients(argv, env);
|
|
235
|
+
if (!clients.length) {
|
|
236
|
+
say(`${WARN()} No MCP clients detected on this machine.`);
|
|
237
|
+
say();
|
|
238
|
+
listClients(env);
|
|
239
|
+
return 1;
|
|
240
|
+
}
|
|
241
|
+
heading(remove ? 'Removing DebugAI from MCP clients' : 'Installing DebugAI into MCP clients');
|
|
242
|
+
const results = clients.map((c) => applyToClient(c, { dryRun, remove }));
|
|
243
|
+
results.forEach(describeResult);
|
|
244
|
+
if (dryRun) {
|
|
245
|
+
for (const r of results.filter((x) => x.preview)) {
|
|
246
|
+
heading(`${r.client.label} — ${r.path}`);
|
|
247
|
+
say(r.preview.trimEnd());
|
|
248
|
+
}
|
|
249
|
+
say();
|
|
250
|
+
say(dim(' Dry run — nothing was written.'));
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
const failed = results.filter((r) => r.action === 'failed').length;
|
|
254
|
+
const changed = results.filter((r) => r.action === 'wrote' || r.action === 'updated' || r.action === 'removed').length;
|
|
255
|
+
say();
|
|
256
|
+
if (!remove) {
|
|
257
|
+
const { apiKey } = resolveSettings(DEFAULT_API_BASE, env, () => { });
|
|
258
|
+
if (!apiKey) {
|
|
259
|
+
say(`${WARN()} ${yellow('No API key stored yet — run')} ${bold('npx -y @debugai/mcp login')} ${yellow('to finish.')}`);
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
say(`${OK()} Key already stored — ${changed ? 'restart the clients above and you are done.' : 'nothing left to do.'}`);
|
|
263
|
+
}
|
|
264
|
+
if (!explicit)
|
|
265
|
+
say(dim(' Missing a client? "debugai-mcp install --list" shows every name.'));
|
|
266
|
+
}
|
|
267
|
+
return failed ? 1 : 0;
|
|
268
|
+
}
|
|
269
|
+
export function cmdUninstall(argv, env = process.env) {
|
|
270
|
+
return cmdInstall([...argv, '--remove', '--all'], env);
|
|
271
|
+
}
|
|
272
|
+
// ── doctor ───────────────────────────────────────────────────────────────────
|
|
273
|
+
export async function cmdDoctor(_argv, env = process.env) {
|
|
274
|
+
let hardFailures = 0;
|
|
275
|
+
const fail = (msg, hint) => {
|
|
276
|
+
hardFailures++;
|
|
277
|
+
say(` ${FAIL()} ${msg}`);
|
|
278
|
+
if (hint)
|
|
279
|
+
say(` ${dim(hint)}`);
|
|
280
|
+
};
|
|
281
|
+
const pass = (msg, detail) => {
|
|
282
|
+
say(` ${OK()} ${msg}`);
|
|
283
|
+
if (detail)
|
|
284
|
+
say(` ${dim(detail)}`);
|
|
285
|
+
};
|
|
286
|
+
heading('Runtime');
|
|
287
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
288
|
+
if (major >= 18)
|
|
289
|
+
pass(`Node ${process.versions.node}`);
|
|
290
|
+
else
|
|
291
|
+
fail(`Node ${process.versions.node} is too old`, 'DebugAI MCP needs Node 18 or newer (it uses global fetch).');
|
|
292
|
+
heading('Account');
|
|
293
|
+
const { apiKey, apiBase, keySource } = resolveSettings(DEFAULT_API_BASE, env, () => { });
|
|
294
|
+
if (!apiKey) {
|
|
295
|
+
fail('No API key found', 'Run: npx -y @debugai/mcp login');
|
|
296
|
+
}
|
|
297
|
+
else if (!apiKey.startsWith('dbg_')) {
|
|
298
|
+
fail(`Key does not look like a DebugAI key (${maskKey(apiKey)})`, 'DebugAI keys start with dbg_.');
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
pass(`Key ${maskKey(apiKey)}`, `source: ${keySource === 'env' ? 'DEBUGAI_API_KEY env var' : configPath(env)}`);
|
|
302
|
+
}
|
|
303
|
+
if (keySource === 'file') {
|
|
304
|
+
try {
|
|
305
|
+
const mode = statSync(configPath(env)).mode & 0o777;
|
|
306
|
+
if (platform() !== 'win32' && (mode & 0o077) !== 0) {
|
|
307
|
+
say(` ${WARN()} ${yellow(`Config file is readable by other users (mode ${mode.toString(8)})`)}`);
|
|
308
|
+
say(` ${dim(`fix: chmod 600 ${configPath(env)}`)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
catch { /* file vanished between calls — the key check above already covered it */ }
|
|
312
|
+
}
|
|
313
|
+
heading('API');
|
|
314
|
+
if (!apiKey) {
|
|
315
|
+
say(` ${INFO()} Skipped — no key to test with.`);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
const info = await verifyKey(apiBase, apiKey);
|
|
319
|
+
if (info) {
|
|
320
|
+
pass(`${apiBase} reachable`, `${info.email ?? 'account'} · ${info.tier ?? 'free'} tier`);
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
fail(`Could not authenticate against ${apiBase}`, 'Either the key was rotated (run: debugai-mcp login --force) or the API is unreachable from here.');
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
heading('MCP clients');
|
|
327
|
+
const detected = detectedClients(env);
|
|
328
|
+
if (!detected.length) {
|
|
329
|
+
say(` ${INFO()} None detected. "debugai-mcp install --list" shows every supported client.`);
|
|
330
|
+
}
|
|
331
|
+
for (const c of detected) {
|
|
332
|
+
if (isInstalled(c))
|
|
333
|
+
pass(`${c.label} — debugai configured`, c.configPath ?? undefined);
|
|
334
|
+
else
|
|
335
|
+
say(` ${WARN()} ${yellow(`${c.label} — installed but DebugAI is not in its config`)}\n ${dim(`fix: debugai-mcp install --client=${c.id}`)}`);
|
|
336
|
+
}
|
|
337
|
+
say();
|
|
338
|
+
if (hardFailures) {
|
|
339
|
+
say(`${FAIL()} ${hardFailures} problem${hardFailures === 1 ? '' : 's'} to fix.`);
|
|
340
|
+
return 1;
|
|
341
|
+
}
|
|
342
|
+
say(`${OK()} Everything checks out.`);
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
// ── setup (the headline command) ─────────────────────────────────────────────
|
|
346
|
+
export async function cmdSetup(argv, env = process.env) {
|
|
347
|
+
const { apiKey } = resolveSettings(DEFAULT_API_BASE, env, () => { });
|
|
348
|
+
if (!apiKey || flag(argv, 'force')) {
|
|
349
|
+
const code = await cmdLogin(argv, env);
|
|
350
|
+
if (code !== 0)
|
|
351
|
+
return code;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
say(`${OK()} Already signed in — skipping login (use --force to re-link).`);
|
|
355
|
+
}
|
|
356
|
+
const installCode = cmdInstall(argv.filter((a) => a !== '--force'), env);
|
|
357
|
+
if (installCode !== 0)
|
|
358
|
+
return installCode;
|
|
359
|
+
return cmdDoctor([], env);
|
|
360
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type McpClient } from './clients.js';
|
|
2
|
+
export type InstallAction = 'wrote' | 'updated' | 'unchanged' | 'removed' | 'skipped' | 'failed';
|
|
3
|
+
export interface InstallResult {
|
|
4
|
+
client: McpClient;
|
|
5
|
+
action: InstallAction;
|
|
6
|
+
path: string | null;
|
|
7
|
+
backupPath?: string;
|
|
8
|
+
/** Non-fatal thing the user must know (dropped comments, unsupported OS). */
|
|
9
|
+
warning?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
/** The exact JSON we would write — used by --dry-run and by failure output. */
|
|
12
|
+
preview?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface InstallOptions {
|
|
15
|
+
dryRun?: boolean;
|
|
16
|
+
/** Remove the debugai entry instead of adding it. */
|
|
17
|
+
remove?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare function applyToClient(client: McpClient, opts?: InstallOptions): InstallResult;
|
|
20
|
+
/** True when the client's config already points at this server. */
|
|
21
|
+
export declare function isInstalled(client: McpClient): boolean;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Writes the DebugAI server into MCP client config files.
|
|
2
|
+
//
|
|
3
|
+
// Rules this code refuses to break, because it is editing files a user's
|
|
4
|
+
// whole editor setup depends on:
|
|
5
|
+
// - never overwrite the file wholesale — parse, touch only our own key,
|
|
6
|
+
// write everything else back untouched
|
|
7
|
+
// - back up before the first modification, always, with the path printed
|
|
8
|
+
// - write to a temp file in the same directory and rename over the target,
|
|
9
|
+
// so a crash mid-write cannot leave a truncated config behind
|
|
10
|
+
// - a file we cannot parse is left ALONE and reported, never "fixed"
|
|
11
|
+
// - running twice is a no-op ("unchanged"), never a duplicate entry
|
|
12
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname } from 'node:path';
|
|
14
|
+
import { parseJsonc } from './jsonc.js';
|
|
15
|
+
import { SERVER_NAME, serverEntry, serversKey } from './clients.js';
|
|
16
|
+
function backupPath(path) {
|
|
17
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
18
|
+
return `${path}.debugai-backup-${stamp}`;
|
|
19
|
+
}
|
|
20
|
+
function writeAtomic(path, contents) {
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
const tmp = `${path}.tmp-${process.pid}`;
|
|
23
|
+
writeFileSync(tmp, contents, 'utf8');
|
|
24
|
+
try {
|
|
25
|
+
renameSync(tmp, path);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
try {
|
|
29
|
+
unlinkSync(tmp);
|
|
30
|
+
}
|
|
31
|
+
catch { /* ignore */ }
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function sameEntry(a, b) {
|
|
36
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
37
|
+
}
|
|
38
|
+
export function applyToClient(client, opts = {}) {
|
|
39
|
+
const path = client.configPath;
|
|
40
|
+
if (!path) {
|
|
41
|
+
return {
|
|
42
|
+
client,
|
|
43
|
+
action: 'skipped',
|
|
44
|
+
path: null,
|
|
45
|
+
warning: `${client.label} has no known config location on this operating system.`,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const key = serversKey(client.shape);
|
|
49
|
+
const desired = serverEntry(client);
|
|
50
|
+
let root = {};
|
|
51
|
+
let hadComments = false;
|
|
52
|
+
const existed = existsSync(path);
|
|
53
|
+
if (existed) {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = readFileSync(path, 'utf8');
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { client, action: 'failed', path, error: `could not read: ${err.message}` };
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const parsed = parseJsonc(raw);
|
|
63
|
+
hadComments = parsed.hadComments;
|
|
64
|
+
if (typeof parsed.value !== 'object' || parsed.value === null || Array.isArray(parsed.value)) {
|
|
65
|
+
throw new Error('top level is not a JSON object');
|
|
66
|
+
}
|
|
67
|
+
root = parsed.value;
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
// Do NOT rewrite a file we failed to understand — that is how people
|
|
71
|
+
// lose their editor settings. Report it and hand back the snippet.
|
|
72
|
+
return {
|
|
73
|
+
client,
|
|
74
|
+
action: 'failed',
|
|
75
|
+
path,
|
|
76
|
+
error: `could not parse (${err.message}) — left untouched`,
|
|
77
|
+
preview: JSON.stringify({ [key]: { [SERVER_NAME]: desired } }, null, 2),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else if (opts.remove) {
|
|
82
|
+
return { client, action: 'unchanged', path };
|
|
83
|
+
}
|
|
84
|
+
const existingServers = root[key];
|
|
85
|
+
const servers = typeof existingServers === 'object' && existingServers !== null && !Array.isArray(existingServers)
|
|
86
|
+
? { ...existingServers }
|
|
87
|
+
: {};
|
|
88
|
+
if (opts.remove) {
|
|
89
|
+
if (!(SERVER_NAME in servers))
|
|
90
|
+
return { client, action: 'unchanged', path };
|
|
91
|
+
delete servers[SERVER_NAME];
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
if (sameEntry(servers[SERVER_NAME], desired))
|
|
95
|
+
return { client, action: 'unchanged', path };
|
|
96
|
+
servers[SERVER_NAME] = desired;
|
|
97
|
+
}
|
|
98
|
+
const nextRoot = { ...root, [key]: servers };
|
|
99
|
+
const contents = `${JSON.stringify(nextRoot, null, 2)}\n`;
|
|
100
|
+
const warning = hadComments && !opts.dryRun
|
|
101
|
+
? 'this file had comments; JSON does not keep them, so they were dropped (the backup above still has them)'
|
|
102
|
+
: undefined;
|
|
103
|
+
if (opts.dryRun) {
|
|
104
|
+
return {
|
|
105
|
+
client,
|
|
106
|
+
action: opts.remove ? 'removed' : existed ? 'updated' : 'wrote',
|
|
107
|
+
path,
|
|
108
|
+
preview: contents,
|
|
109
|
+
warning: hadComments ? 'this file has comments; installing would drop them (a backup is written first)' : undefined,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
let backup;
|
|
113
|
+
try {
|
|
114
|
+
if (existed) {
|
|
115
|
+
backup = backupPath(path);
|
|
116
|
+
copyFileSync(path, backup);
|
|
117
|
+
}
|
|
118
|
+
writeAtomic(path, contents);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return {
|
|
122
|
+
client,
|
|
123
|
+
action: 'failed',
|
|
124
|
+
path,
|
|
125
|
+
backupPath: backup,
|
|
126
|
+
error: err.message,
|
|
127
|
+
preview: JSON.stringify({ [key]: { [SERVER_NAME]: desired } }, null, 2),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
client,
|
|
132
|
+
action: opts.remove ? 'removed' : existed ? 'updated' : 'wrote',
|
|
133
|
+
path,
|
|
134
|
+
backupPath: backup,
|
|
135
|
+
warning,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
/** True when the client's config already points at this server. */
|
|
139
|
+
export function isInstalled(client) {
|
|
140
|
+
if (!client.configPath || !existsSync(client.configPath))
|
|
141
|
+
return false;
|
|
142
|
+
try {
|
|
143
|
+
const parsed = parseJsonc(readFileSync(client.configPath, 'utf8'));
|
|
144
|
+
const servers = parsed.value?.[serversKey(client.shape)];
|
|
145
|
+
return Boolean(servers && typeof servers === 'object' && SERVER_NAME in servers);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ParsedJsonc {
|
|
2
|
+
value: unknown;
|
|
3
|
+
hadComments: boolean;
|
|
4
|
+
}
|
|
5
|
+
export declare function stripJsonComments(text: string): {
|
|
6
|
+
out: string;
|
|
7
|
+
hadComments: boolean;
|
|
8
|
+
};
|
|
9
|
+
/** Removes trailing commas before } or ] — legal in JSONC, fatal to JSON.parse. */
|
|
10
|
+
export declare function stripTrailingCommas(text: string): string;
|
|
11
|
+
/** Parses JSON or JSONC. Throws the underlying SyntaxError on real malformed input. */
|
|
12
|
+
export declare function parseJsonc(text: string): ParsedJsonc;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Minimal JSONC tolerance for editor config files.
|
|
2
|
+
//
|
|
3
|
+
// Zed's settings.json and VS Code's mcp.json ship WITH comments and often
|
|
4
|
+
// carry trailing commas. `JSON.parse` throws on both, and a naive regex
|
|
5
|
+
// stripper corrupts any string containing "//" — e.g. every URL in the file.
|
|
6
|
+
// So this walks the text character by character with a string-state machine.
|
|
7
|
+
//
|
|
8
|
+
// Round-tripping comments is out of scope: we detect them (`hadComments`) so
|
|
9
|
+
// the caller can warn the user and back the file up before rewriting.
|
|
10
|
+
export function stripJsonComments(text) {
|
|
11
|
+
let out = '';
|
|
12
|
+
let hadComments = false;
|
|
13
|
+
let i = 0;
|
|
14
|
+
let inString = false;
|
|
15
|
+
let inLineComment = false;
|
|
16
|
+
let inBlockComment = false;
|
|
17
|
+
while (i < text.length) {
|
|
18
|
+
const ch = text[i];
|
|
19
|
+
const next = text[i + 1];
|
|
20
|
+
if (inLineComment) {
|
|
21
|
+
if (ch === '\n') {
|
|
22
|
+
inLineComment = false;
|
|
23
|
+
out += ch;
|
|
24
|
+
}
|
|
25
|
+
i++;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (inBlockComment) {
|
|
29
|
+
if (ch === '*' && next === '/') {
|
|
30
|
+
inBlockComment = false;
|
|
31
|
+
i += 2;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (ch === '\n')
|
|
35
|
+
out += ch; // keep line numbers honest for error messages
|
|
36
|
+
i++;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (inString) {
|
|
40
|
+
out += ch;
|
|
41
|
+
if (ch === '\\') {
|
|
42
|
+
out += next ?? '';
|
|
43
|
+
i += 2;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '"')
|
|
47
|
+
inString = false;
|
|
48
|
+
i++;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (ch === '"') {
|
|
52
|
+
inString = true;
|
|
53
|
+
out += ch;
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (ch === '/' && next === '/') {
|
|
58
|
+
inLineComment = true;
|
|
59
|
+
hadComments = true;
|
|
60
|
+
i += 2;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (ch === '/' && next === '*') {
|
|
64
|
+
inBlockComment = true;
|
|
65
|
+
hadComments = true;
|
|
66
|
+
i += 2;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
out += ch;
|
|
70
|
+
i++;
|
|
71
|
+
}
|
|
72
|
+
return { out, hadComments };
|
|
73
|
+
}
|
|
74
|
+
/** Removes trailing commas before } or ] — legal in JSONC, fatal to JSON.parse. */
|
|
75
|
+
export function stripTrailingCommas(text) {
|
|
76
|
+
let out = '';
|
|
77
|
+
let inString = false;
|
|
78
|
+
for (let i = 0; i < text.length; i++) {
|
|
79
|
+
const ch = text[i];
|
|
80
|
+
if (inString) {
|
|
81
|
+
out += ch;
|
|
82
|
+
if (ch === '\\') {
|
|
83
|
+
out += text[i + 1] ?? '';
|
|
84
|
+
i++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (ch === '"')
|
|
88
|
+
inString = false;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (ch === '"') {
|
|
92
|
+
inString = true;
|
|
93
|
+
out += ch;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (ch === ',') {
|
|
97
|
+
// Look ahead past whitespace for a closer.
|
|
98
|
+
let j = i + 1;
|
|
99
|
+
while (j < text.length && /\s/.test(text[j]))
|
|
100
|
+
j++;
|
|
101
|
+
if (text[j] === '}' || text[j] === ']')
|
|
102
|
+
continue; // drop this comma
|
|
103
|
+
}
|
|
104
|
+
out += ch;
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/** Parses JSON or JSONC. Throws the underlying SyntaxError on real malformed input. */
|
|
109
|
+
export function parseJsonc(text) {
|
|
110
|
+
const trimmed = text.trim();
|
|
111
|
+
if (trimmed === '')
|
|
112
|
+
return { value: {}, hadComments: false };
|
|
113
|
+
const { out, hadComments } = stripJsonComments(text);
|
|
114
|
+
return { value: JSON.parse(stripTrailingCommas(out)), hadComments };
|
|
115
|
+
}
|
package/dist/cli/ui.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const bold: (s: string) => string;
|
|
2
|
+
export declare const dim: (s: string) => string;
|
|
3
|
+
export declare const red: (s: string) => string;
|
|
4
|
+
export declare const green: (s: string) => string;
|
|
5
|
+
export declare const yellow: (s: string) => string;
|
|
6
|
+
export declare const OK: () => string;
|
|
7
|
+
export declare const FAIL: () => string;
|
|
8
|
+
export declare const WARN: () => string;
|
|
9
|
+
export declare const INFO: () => string;
|
|
10
|
+
export declare function say(line?: string): void;
|
|
11
|
+
export declare function heading(text: string): void;
|
|
12
|
+
/**
|
|
13
|
+
* Opens a URL in the user's browser, best effort. Returns false when there is
|
|
14
|
+
* clearly no browser to open (headless Linux, CI) so the caller prints the URL
|
|
15
|
+
* instead of pretending something happened.
|
|
16
|
+
*/
|
|
17
|
+
export declare function openBrowser(url: string): boolean;
|
|
18
|
+
/** Big enough to read across a room, small enough to fit a narrow terminal. */
|
|
19
|
+
export declare function codeBox(code: string): string;
|