logwork-cli 1.2.0 → 1.3.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.
@@ -0,0 +1,930 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { constants as fsConstants, promises as fs } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { atomicWriteFile } from './atomic-file.mjs';
5
+ import { mcpArgs, pathApi, resolveLaunchCommand } from './launcher.mjs';
6
+ import {
7
+ MCP_SERVER_NAME,
8
+ formatClaudeMcpAddCommand,
9
+ formatCodexToml,
10
+ stdioMcpServersConfig,
11
+ vscodeMcpConfig
12
+ } from './manual-mcp.mjs';
13
+
14
+ // Installs or updates the `logwork` MCP server entry in the clients the user
15
+ // has. Every file edit reads, parses and merges, touches only the `logwork`
16
+ // entry (and removes a legacy `logwork-helper` entry), backs the file up next
17
+ // to itself first and writes atomically. Anything that does not parse cleanly
18
+ // is left alone and reported with a manual snippet.
19
+
20
+ export const LEGACY_SERVER_NAME = 'logwork-helper';
21
+ export const MCP_INSTALL_CLIENT_KEYS = ['claude', 'cursor', 'codex', 'vscode', 'antigravity'];
22
+ const SERVER_NAMES = [MCP_SERVER_NAME, LEGACY_SERVER_NAME];
23
+ const CLIENT_ALIASES = {
24
+ 'claude-code': 'claude',
25
+ copilot: 'vscode',
26
+ 'vs-code': 'vscode',
27
+ 'github-copilot': 'vscode',
28
+ 'google-antigravity': 'antigravity'
29
+ };
30
+
31
+ export function normalizeMcpInstallClient(value) {
32
+ const key = String(value || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
33
+ const resolved = CLIENT_ALIASES[key] || key;
34
+ return MCP_INSTALL_CLIENT_KEYS.includes(resolved) ? resolved : null;
35
+ }
36
+
37
+ // Builds the shared context. Every root (home, APPDATA), file operation and
38
+ // command runner can be replaced, so tests never touch the real machine.
39
+ export function createMcpInstallContext({
40
+ platform = process.platform,
41
+ userHome = homedir(),
42
+ env = process.env,
43
+ launch,
44
+ home,
45
+ fsApi = {},
46
+ findCommand,
47
+ runCommand = runCliCommand,
48
+ now = () => new Date()
49
+ } = {}) {
50
+ return {
51
+ platform,
52
+ userHome,
53
+ env,
54
+ launch: launch || resolveLaunchCommand({ platform, ...(home ? { home } : {}), env }),
55
+ fs: {
56
+ readFile: fs.readFile,
57
+ stat: fs.stat,
58
+ realpath: fs.realpath,
59
+ copyFile: fs.copyFile,
60
+ access: fs.access,
61
+ writeFile: atomicWriteFile,
62
+ ...fsApi
63
+ },
64
+ findCommand: findCommand || ((name) => whichCommand(name, { platform, env })),
65
+ runCommand,
66
+ now
67
+ };
68
+ }
69
+
70
+ export function mcpClientTargets({ platform, userHome, env = {} }) {
71
+ const path = pathApi(platform);
72
+ const claudeDir = env.CLAUDE_CONFIG_DIR || userHome;
73
+ return [
74
+ {
75
+ key: 'claude',
76
+ label: 'Claude Code',
77
+ kind: 'claude',
78
+ file: path.join(claudeDir, '.claude.json'),
79
+ rootKey: 'mcpServers',
80
+ entryType: 'stdio'
81
+ },
82
+ {
83
+ key: 'cursor',
84
+ label: 'Cursor',
85
+ kind: 'json',
86
+ file: path.join(userHome, '.cursor', 'mcp.json'),
87
+ detectPath: path.join(userHome, '.cursor'),
88
+ rootKey: 'mcpServers',
89
+ entryType: null
90
+ },
91
+ {
92
+ key: 'codex',
93
+ label: 'Codex',
94
+ kind: 'toml',
95
+ file: path.join(userHome, '.codex', 'config.toml'),
96
+ detectPath: path.join(userHome, '.codex')
97
+ },
98
+ {
99
+ key: 'vscode',
100
+ label: 'VS Code',
101
+ kind: 'json',
102
+ file: path.join(vscodeUserDir({ platform, userHome, env }), 'mcp.json'),
103
+ detectPath: vscodeUserDir({ platform, userHome, env }),
104
+ rootKey: 'servers',
105
+ entryType: 'stdio'
106
+ },
107
+ {
108
+ key: 'antigravity',
109
+ label: 'Google Antigravity',
110
+ kind: 'json',
111
+ file: path.join(userHome, '.gemini', 'antigravity', 'mcp_config.json'),
112
+ detectPath: path.join(userHome, '.gemini', 'antigravity'),
113
+ rootKey: 'mcpServers',
114
+ entryType: null
115
+ }
116
+ ];
117
+ }
118
+
119
+ function vscodeUserDir({ platform, userHome, env }) {
120
+ const path = pathApi(platform);
121
+ if (platform === 'darwin') {
122
+ return path.join(userHome, 'Library', 'Application Support', 'Code', 'User');
123
+ }
124
+ if (platform === 'win32') {
125
+ return path.join(env.APPDATA || path.join(userHome, 'AppData', 'Roaming'), 'Code', 'User');
126
+ }
127
+ return path.join(env.XDG_CONFIG_HOME || path.join(userHome, '.config'), 'Code', 'User');
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Status
132
+
133
+ // Inspects every client (or the selected ones). Read-only.
134
+ export async function inspectMcpClients(ctx, { clients = MCP_INSTALL_CLIENT_KEYS } = {}) {
135
+ const targets = mcpClientTargets(ctx).filter((client) => clients.includes(client.key));
136
+ const output = [];
137
+ for (const client of targets) {
138
+ output.push(await inspectMcpClient(client, ctx));
139
+ }
140
+ return output;
141
+ }
142
+
143
+ export async function inspectMcpClient(client, ctx) {
144
+ const base = {
145
+ key: client.key,
146
+ label: client.label,
147
+ kind: client.kind,
148
+ file: client.file,
149
+ rootKey: client.rootKey,
150
+ entryType: client.entryType,
151
+ detected: false,
152
+ status: 'not_detected',
153
+ reason: '',
154
+ legacy: false,
155
+ existing: null
156
+ };
157
+ if (client.kind === 'claude') {
158
+ base.cliPath = await ctx.findCommand('claude');
159
+ }
160
+ const fileExists = await pathExists(client.file, ctx);
161
+ if (client.kind === 'claude') {
162
+ base.detected = Boolean(base.cliPath) || fileExists;
163
+ } else {
164
+ base.detected = await pathExists(client.detectPath, ctx);
165
+ }
166
+ if (!base.detected) {
167
+ return base;
168
+ }
169
+
170
+ let text = null;
171
+ if (fileExists) {
172
+ try {
173
+ text = await ctx.fs.readFile(client.file, 'utf8');
174
+ } catch (error) {
175
+ return { ...base, status: 'unreadable', reason: `cannot read ${client.file}: ${error.message}` };
176
+ }
177
+ }
178
+
179
+ if (client.kind === 'toml') {
180
+ const parsed = parseCodexConfig(text || '');
181
+ if (!parsed.ok) {
182
+ return { ...base, status: 'unreadable', reason: parsed.error, text };
183
+ }
184
+ const existing = parsed.servers[MCP_SERVER_NAME];
185
+ return {
186
+ ...base,
187
+ ...await classifyEntry(existing ? { command: existing.command, args: existing.args, unparsed: existing.unparsed } : null, ctx),
188
+ legacy: Boolean(parsed.servers[LEGACY_SERVER_NAME]),
189
+ existing: existing || null,
190
+ text,
191
+ parsed
192
+ };
193
+ }
194
+
195
+ const json = parseJsonConfig(text, client.rootKey);
196
+ if (!json.ok) {
197
+ return { ...base, status: 'unreadable', reason: json.error, text };
198
+ }
199
+ const servers = json.value[client.rootKey] || {};
200
+ const existing = servers[MCP_SERVER_NAME] || null;
201
+ const result = {
202
+ ...base,
203
+ ...await classifyEntry(existing, ctx),
204
+ legacy: Object.hasOwn(servers, LEGACY_SERVER_NAME),
205
+ existing,
206
+ text,
207
+ json: json.value
208
+ };
209
+ if (client.kind === 'claude') {
210
+ result.localScopes = await inspectClaudeLocalScopes(json.value, ctx);
211
+ }
212
+ return result;
213
+ }
214
+
215
+ // Claude Code local-scope entries (`claude mcp add` without --scope, which the
216
+ // old docs suggested) live under projects[<dir>].mcpServers and win over the
217
+ // user scope, so a stale one would shadow the new entry.
218
+ async function inspectClaudeLocalScopes(json, ctx) {
219
+ const projects = json?.projects && typeof json.projects === 'object' ? json.projects : {};
220
+ const output = [];
221
+ for (const [project, value] of Object.entries(projects)) {
222
+ const servers = value?.mcpServers;
223
+ if (!servers || typeof servers !== 'object') {
224
+ continue;
225
+ }
226
+ for (const name of SERVER_NAMES) {
227
+ if (!Object.hasOwn(servers, name)) {
228
+ continue;
229
+ }
230
+ const check = name === LEGACY_SERVER_NAME
231
+ ? { status: 'stale', reason: 'old logwork-helper entry' }
232
+ : await classifyEntry(servers[name], ctx);
233
+ if (check.status !== 'current') {
234
+ output.push({ project, name, reason: check.reason });
235
+ }
236
+ }
237
+ }
238
+ return output;
239
+ }
240
+
241
+ async function classifyEntry(entry, ctx) {
242
+ if (!entry) {
243
+ return { status: 'missing', reason: '' };
244
+ }
245
+ if (entry.unparsed || typeof entry !== 'object') {
246
+ return { status: 'stale', reason: 'the entry could not be read' };
247
+ }
248
+ const command = String(entry.command || '');
249
+ const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
250
+ const expectedArgs = mcpArgs(ctx.launch);
251
+ if (samePath(command, ctx.launch.command, ctx.platform) && args.length === expectedArgs.length && args.every((arg, index) => samePath(arg, expectedArgs[index], ctx.platform))) {
252
+ return { status: 'current', reason: '' };
253
+ }
254
+ const commandLine = [command, ...args].join(' ');
255
+ if (/logwork-helper|mcp-server\.mjs/i.test(commandLine)) {
256
+ return { status: 'stale', reason: 'points at the old logwork-helper runtime' };
257
+ }
258
+ // Split on both separators: a config may hold a Windows path even when the
259
+ // check runs elsewhere, and vice versa.
260
+ if (/^node(\.exe)?$/i.test(String(command).split(/[\\/]/).pop()) || command === 'node') {
261
+ if (command !== 'node' && !await pathExists(command, ctx)) {
262
+ return { status: 'stale', reason: `runs Node at ${command}, which no longer exists` };
263
+ }
264
+ return {
265
+ status: 'stale',
266
+ reason: ctx.launch.kind === 'launcher'
267
+ ? `runs Node directly (${command}) instead of the logwork launcher`
268
+ : `runs a different Node (${command})`
269
+ };
270
+ }
271
+ return { status: 'stale', reason: `runs a different command (${command || 'none'})` };
272
+ }
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Install
276
+
277
+ // Works out what `logwork mcp install` would change. Read-only.
278
+ export async function planMcpInstall(ctx, { clients = MCP_INSTALL_CLIENT_KEYS, explicit = false } = {}) {
279
+ const inspected = await inspectMcpClients(ctx, { clients });
280
+ return inspected
281
+ .filter((client) => client.detected || explicit)
282
+ .map((client) => ({ ...client, action: planAction(client) }));
283
+ }
284
+
285
+ function planAction(client) {
286
+ if (!client.detected) {
287
+ return 'not_detected';
288
+ }
289
+ if (client.status === 'unreadable') {
290
+ return 'manual';
291
+ }
292
+ const stray = client.legacy || (client.localScopes || []).length > 0;
293
+ if (client.status === 'current' && !stray) {
294
+ return 'none';
295
+ }
296
+ return client.status === 'missing' ? 'add' : 'update';
297
+ }
298
+
299
+ // Applies one planned client change. Returns a result with status:
300
+ // added | updated | up_to_date | failed | not_detected.
301
+ export async function applyMcpClientPlan(plan, ctx) {
302
+ const result = { key: plan.key, label: plan.label, file: plan.file };
303
+ if (plan.action === 'not_detected') {
304
+ return { ...result, status: 'not_detected' };
305
+ }
306
+ if (plan.action === 'none') {
307
+ return { ...result, status: 'up_to_date' };
308
+ }
309
+ if (plan.action === 'manual') {
310
+ return { ...result, status: 'failed', reason: plan.reason, snippet: manualSnippet(plan.key, ctx) };
311
+ }
312
+ const changed = plan.action === 'add' ? 'added' : 'updated';
313
+ try {
314
+ if (plan.kind === 'claude' && plan.cliPath) {
315
+ await applyClaudeCli(plan, ctx);
316
+ return { ...result, status: changed, via: 'claude mcp' };
317
+ }
318
+ const content = renderUpdatedConfig(plan, ctx);
319
+ if (content === null) {
320
+ return { ...result, status: 'failed', reason: 'the existing config has an unusual layout', snippet: manualSnippet(plan.key, ctx) };
321
+ }
322
+ const backup = await writeConfigFile(plan.file, content, ctx, { existed: plan.text !== null && plan.text !== undefined });
323
+ return { ...result, status: changed, backup };
324
+ } catch (error) {
325
+ return { ...result, status: 'failed', reason: error.message, snippet: manualSnippet(plan.key, ctx) };
326
+ }
327
+ }
328
+
329
+ // The new file content for a JSON or TOML client, or null when it cannot be
330
+ // produced safely.
331
+ export function renderUpdatedConfig(plan, ctx) {
332
+ if (plan.kind === 'toml') {
333
+ return rewriteCodexConfig(plan.text || '', formatCodexToml(ctx.launch));
334
+ }
335
+ const json = plan.json ? structuredClone(plan.json) : {};
336
+ const rootKey = plan.kind === 'claude' ? 'mcpServers' : plan.rootKey;
337
+ if (json[rootKey] === undefined) {
338
+ json[rootKey] = {};
339
+ }
340
+ const servers = json[rootKey];
341
+ servers[MCP_SERVER_NAME] = buildJsonEntry(plan, ctx, servers[MCP_SERVER_NAME]);
342
+ delete servers[LEGACY_SERVER_NAME];
343
+ if (plan.kind === 'claude') {
344
+ for (const stray of plan.localScopes || []) {
345
+ delete json.projects?.[stray.project]?.mcpServers?.[stray.name];
346
+ }
347
+ }
348
+ return `${JSON.stringify(json, null, 2)}\n`;
349
+ }
350
+
351
+ function buildJsonEntry(plan, ctx, previous) {
352
+ const base = previous && typeof previous === 'object' && !Array.isArray(previous) ? previous : {};
353
+ const entry = { ...base };
354
+ const type = plan.kind === 'claude' ? 'stdio' : plan.entryType;
355
+ if (type) {
356
+ entry.type = type;
357
+ }
358
+ entry.command = ctx.launch.command;
359
+ entry.args = mcpArgs(ctx.launch);
360
+ if (plan.kind === 'claude' && entry.env === undefined) {
361
+ entry.env = {};
362
+ }
363
+ // Keep a stable key order for new entries: type, command, args, then the rest.
364
+ const ordered = {};
365
+ for (const key of ['type', 'command', 'args']) {
366
+ if (entry[key] !== undefined) {
367
+ ordered[key] = entry[key];
368
+ }
369
+ }
370
+ return { ...ordered, ...entry };
371
+ }
372
+
373
+ async function applyClaudeCli(plan, ctx) {
374
+ const run = (args, options = {}) => ctx.runCommand(plan.cliPath, args, { platform: ctx.platform, ...options });
375
+ // A missing entry makes `remove` fail; that is fine.
376
+ await run(['mcp', 'remove', '--scope', 'user', MCP_SERVER_NAME]).catch(() => null);
377
+ if (plan.legacy) {
378
+ await run(['mcp', 'remove', '--scope', 'user', LEGACY_SERVER_NAME]).catch(() => null);
379
+ }
380
+ for (const stray of plan.localScopes || []) {
381
+ if (await pathExists(stray.project, ctx)) {
382
+ await run(['mcp', 'remove', '--scope', 'local', stray.name], { cwd: stray.project }).catch(() => null);
383
+ }
384
+ }
385
+ const added = await run(claudeAddArgs(ctx.launch));
386
+ if (!added || added.code !== 0) {
387
+ throw new Error(`claude mcp add failed${added ? ` with exit code ${added.code}` : ''}: ${String(added?.stderr || added?.stdout || 'no output').trim().split(/\r?\n/)[0]}`);
388
+ }
389
+ }
390
+
391
+ export function claudeAddArgs(launch) {
392
+ return ['mcp', 'add', '--scope', 'user', MCP_SERVER_NAME, '--', launch.command, ...mcpArgs(launch)];
393
+ }
394
+
395
+ async function writeConfigFile(file, content, ctx, { existed }) {
396
+ let target = file;
397
+ let mode = 0o600;
398
+ let backup = null;
399
+ if (existed) {
400
+ // Write through a symlinked config (dotfile managers) instead of replacing the link.
401
+ target = await ctx.fs.realpath(file).catch(() => file);
402
+ const info = await ctx.fs.stat(target).catch(() => null);
403
+ if (info?.mode) {
404
+ mode = info.mode & 0o777;
405
+ }
406
+ backup = `${file}.logwork-backup-${formatBackupStamp(ctx.now())}`;
407
+ await ctx.fs.copyFile(target, backup);
408
+ }
409
+ await ctx.fs.writeFile(target, content, { mode });
410
+ return backup;
411
+ }
412
+
413
+ export function formatBackupStamp(date) {
414
+ const pad = (value) => String(value).padStart(2, '0');
415
+ return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
416
+ }
417
+
418
+ // Diff-like lines for --dry-run and the non-interactive setup summary.
419
+ export function describeMcpPlan(plan, ctx) {
420
+ const lines = [];
421
+ const target = plan.kind === 'claude' && plan.cliPath ? 'claude CLI' : plan.file;
422
+ const verb = {
423
+ add: 'would add the logwork entry',
424
+ update: 'would update the logwork entry',
425
+ none: 'already up to date',
426
+ manual: `could not update automatically (${plan.reason})`,
427
+ not_detected: 'not detected; skipped'
428
+ }[plan.action] || plan.action;
429
+ lines.push(`${plan.label} (${target}): ${verb}`);
430
+ if (plan.action !== 'add' && plan.action !== 'update') {
431
+ return lines;
432
+ }
433
+ if (plan.kind === 'claude' && plan.cliPath) {
434
+ lines.push(` $ claude mcp remove --scope user ${MCP_SERVER_NAME}`);
435
+ if (plan.legacy) {
436
+ lines.push(` $ claude mcp remove --scope user ${LEGACY_SERVER_NAME}`);
437
+ }
438
+ for (const stray of plan.localScopes || []) {
439
+ lines.push(` $ (cd ${stray.project}) claude mcp remove --scope local ${stray.name}`);
440
+ }
441
+ lines.push(` $ ${formatClaudeMcpAddCommand(ctx.launch, { platform: ctx.platform })}`);
442
+ return lines;
443
+ }
444
+ if (plan.status === 'stale' && plan.existing && !plan.existing.unparsed) {
445
+ lines.push(` - command: ${plan.existing.command}`);
446
+ lines.push(` - args: ${JSON.stringify(plan.existing.args || [])}`);
447
+ }
448
+ if (plan.status !== 'current') {
449
+ lines.push(` + command: ${ctx.launch.command}`);
450
+ lines.push(` + args: ${JSON.stringify(mcpArgs(ctx.launch))}`);
451
+ }
452
+ if (plan.legacy) {
453
+ lines.push(` - remove the "${LEGACY_SERVER_NAME}" entry`);
454
+ }
455
+ for (const stray of plan.localScopes || []) {
456
+ lines.push(` - remove the local-scope "${stray.name}" entry for ${stray.project}`);
457
+ }
458
+ if (plan.text !== null && plan.text !== undefined) {
459
+ lines.push(` backup: ${plan.file}.logwork-backup-<timestamp>`);
460
+ } else {
461
+ lines.push(' (new file)');
462
+ }
463
+ return lines;
464
+ }
465
+
466
+ export function manualSnippet(key, ctx) {
467
+ const launch = ctx.launch;
468
+ if (key === 'codex') {
469
+ return formatCodexToml(launch);
470
+ }
471
+ if (key === 'vscode') {
472
+ return JSON.stringify(vscodeMcpConfig(launch), null, 2);
473
+ }
474
+ if (key === 'claude') {
475
+ return formatClaudeMcpAddCommand(launch, { platform: ctx.platform });
476
+ }
477
+ return JSON.stringify(stdioMcpServersConfig(launch), null, 2);
478
+ }
479
+
480
+ // ---------------------------------------------------------------------------
481
+ // JSON
482
+
483
+ export function parseJsonConfig(text, rootKey) {
484
+ if (text === null || text === undefined || !String(text).trim()) {
485
+ return { ok: true, value: {} };
486
+ }
487
+ let value;
488
+ try {
489
+ value = JSON.parse(String(text).replace(/^\uFEFF/, ''));
490
+ } catch {
491
+ return { ok: false, error: 'the file is not plain JSON; it may contain comments or trailing commas' };
492
+ }
493
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
494
+ return { ok: false, error: 'the file is not a JSON object' };
495
+ }
496
+ const servers = value[rootKey];
497
+ if (servers !== undefined && (!servers || typeof servers !== 'object' || Array.isArray(servers))) {
498
+ return { ok: false, error: `"${rootKey}" is not an object` };
499
+ }
500
+ return { ok: true, value };
501
+ }
502
+
503
+ // ---------------------------------------------------------------------------
504
+ // TOML (Codex). Edited as text: no TOML dependency. Anything unusual is
505
+ // refused rather than guessed.
506
+
507
+ export function parseCodexConfig(text) {
508
+ const scan = scanToml(text);
509
+ if (!scan.ok) {
510
+ return scan;
511
+ }
512
+ const servers = {};
513
+ for (const name of SERVER_NAMES) {
514
+ const blocks = scan.blocks.filter((block) => isServerPath(block.path, name));
515
+ if (!blocks.length) {
516
+ continue;
517
+ }
518
+ if (blocks.some((block) => block.array)) {
519
+ return { ok: false, error: `[[mcp_servers.${name}]] array tables are not supported` };
520
+ }
521
+ const main = blocks.find((block) => block.path.length === 2);
522
+ const values = main ? readTomlKeyValues(scan.lines.slice(main.start + 1, main.end)) : {};
523
+ const command = typeof values.command === 'string' ? values.command : null;
524
+ const args = Array.isArray(values.args) && values.args.every((item) => typeof item === 'string') ? values.args : null;
525
+ servers[name] = {
526
+ command: command || '',
527
+ args: args || [],
528
+ unparsed: !main || command === null || (values.args !== undefined && args === null),
529
+ blocks
530
+ };
531
+ }
532
+ return { ok: true, lines: scan.lines, eol: scan.eol, blocks: scan.blocks, servers };
533
+ }
534
+
535
+ // Replaces every [mcp_servers.logwork] table (with its sub-tables such as
536
+ // [mcp_servers.logwork.env]) by `tableText` at the position of the first one,
537
+ // or appends it; removes every [mcp_servers.logwork-helper] table. Returns
538
+ // null when the file cannot be edited safely.
539
+ export function rewriteCodexConfig(text, tableText) {
540
+ const parsed = parseCodexConfig(text);
541
+ if (!parsed.ok) {
542
+ return null;
543
+ }
544
+ const { lines, eol } = parsed;
545
+ const remove = new Set();
546
+ let insertAt = -1;
547
+ for (const name of SERVER_NAMES) {
548
+ for (const block of parsed.servers[name]?.blocks || []) {
549
+ for (let index = block.start; index < block.end; index += 1) {
550
+ remove.add(index);
551
+ }
552
+ if (name === MCP_SERVER_NAME && (insertAt === -1 || block.start < insertAt)) {
553
+ insertAt = block.start;
554
+ }
555
+ }
556
+ }
557
+ const tableLines = String(tableText).split(/\r?\n/);
558
+ const output = [];
559
+ for (let index = 0; index < lines.length; index += 1) {
560
+ if (index === insertAt) {
561
+ output.push(...tableLines);
562
+ }
563
+ if (!remove.has(index)) {
564
+ output.push(lines[index]);
565
+ }
566
+ }
567
+ if (insertAt === -1) {
568
+ while (output.length && output[output.length - 1].trim() === '') {
569
+ output.pop();
570
+ }
571
+ if (output.length) {
572
+ output.push('');
573
+ }
574
+ output.push(...tableLines);
575
+ }
576
+ // Collapse the blank lines a removed table can leave behind.
577
+ const collapsed = output.filter((line, index) => !(line.trim() === '' && index > 0 && output[index - 1].trim() === ''));
578
+ while (collapsed.length && collapsed[collapsed.length - 1].trim() === '') {
579
+ collapsed.pop();
580
+ }
581
+ return `${collapsed.join(eol)}${eol}`;
582
+ }
583
+
584
+ function isServerPath(path, name) {
585
+ return path.length >= 2 && path[0] === 'mcp_servers' && path[1] === name;
586
+ }
587
+
588
+ function scanToml(text) {
589
+ const source = String(text || '').replace(/^\uFEFF/, '');
590
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
591
+ const lines = source.split(/\r?\n/);
592
+ if (lines.length && lines[lines.length - 1] === '') {
593
+ lines.pop();
594
+ }
595
+ const headers = [];
596
+ let multiline = null;
597
+ let table = [];
598
+ for (let index = 0; index < lines.length; index += 1) {
599
+ const line = lines[index];
600
+ if (multiline) {
601
+ if (countOccurrences(line, multiline) % 2 === 1) {
602
+ multiline = null;
603
+ }
604
+ continue;
605
+ }
606
+ const header = /^\s*(\[\[?)([^\]]*)(\]\]?)\s*(#.*)?$/.exec(line);
607
+ if (header && line.trim().startsWith('[') && (header[1].length === header[3].length)) {
608
+ const path = parseDottedKey(header[2]);
609
+ if (!path) {
610
+ if (/logwork/.test(header[2])) {
611
+ return { ok: false, error: `unusual table header: ${line.trim()}` };
612
+ }
613
+ table = [];
614
+ headers.push({ index, path: [], array: header[1] === '[[' });
615
+ continue;
616
+ }
617
+ headers.push({ index, path, array: header[1] === '[[' });
618
+ table = path;
619
+ continue;
620
+ }
621
+ if (/^\s*\[/.test(line) && /logwork/.test(line)) {
622
+ return { ok: false, error: `unusual table header: ${line.trim()}` };
623
+ }
624
+ for (const delimiter of ['"""', "'''"]) {
625
+ if (countOccurrences(line, delimiter) % 2 === 1) {
626
+ multiline = delimiter;
627
+ break;
628
+ }
629
+ }
630
+ const assignment = /^\s*([A-Za-z0-9_\-."' ]+?)\s*=/.exec(line);
631
+ if (!assignment) {
632
+ continue;
633
+ }
634
+ const keyPath = parseDottedKey(assignment[1]);
635
+ if (!keyPath) {
636
+ continue;
637
+ }
638
+ const full = [...table, ...keyPath];
639
+ if (full.length === 1 && full[0] === 'mcp_servers') {
640
+ return { ok: false, error: 'mcp_servers is defined as an inline table' };
641
+ }
642
+ for (const name of SERVER_NAMES) {
643
+ if (isServerPath(full, name) && !isServerPath(table, name)) {
644
+ return { ok: false, error: `mcp_servers.${name} is defined outside its own [mcp_servers.${name}] table` };
645
+ }
646
+ }
647
+ }
648
+ if (multiline) {
649
+ return { ok: false, error: 'unterminated multi-line string' };
650
+ }
651
+ const blocks = headers.map((header, position) => {
652
+ let end = position + 1 < headers.length ? headers[position + 1].index : lines.length;
653
+ // Comments and blank lines right before the next header belong to it.
654
+ while (end > header.index + 1 && /^\s*(#.*)?$/.test(lines[end - 1])) {
655
+ end -= 1;
656
+ }
657
+ return { start: header.index, end, path: header.path, array: header.array };
658
+ });
659
+ return { ok: true, lines, eol, blocks };
660
+ }
661
+
662
+ function countOccurrences(line, needle) {
663
+ let count = 0;
664
+ let from = 0;
665
+ while ((from = line.indexOf(needle, from)) !== -1) {
666
+ count += 1;
667
+ from += needle.length;
668
+ }
669
+ return count;
670
+ }
671
+
672
+ // `a.b."c d".'e'` -> ['a', 'b', 'c d', 'e']; null when it is not a plain key.
673
+ export function parseDottedKey(value) {
674
+ const source = String(value);
675
+ const segments = [];
676
+ let index = 0;
677
+ const skipSpace = () => {
678
+ while (index < source.length && /[ \t]/.test(source[index])) {
679
+ index += 1;
680
+ }
681
+ };
682
+ while (true) {
683
+ skipSpace();
684
+ if (source[index] === '"') {
685
+ const parsed = readBasicString(source, index);
686
+ if (!parsed) {
687
+ return null;
688
+ }
689
+ segments.push(parsed.value);
690
+ index = parsed.end;
691
+ } else if (source[index] === "'") {
692
+ const end = source.indexOf("'", index + 1);
693
+ if (end === -1) {
694
+ return null;
695
+ }
696
+ segments.push(source.slice(index + 1, end));
697
+ index = end + 1;
698
+ } else {
699
+ const match = /^[A-Za-z0-9_-]+/.exec(source.slice(index));
700
+ if (!match) {
701
+ return null;
702
+ }
703
+ segments.push(match[0]);
704
+ index += match[0].length;
705
+ }
706
+ skipSpace();
707
+ if (index >= source.length) {
708
+ return segments;
709
+ }
710
+ if (source[index] !== '.') {
711
+ return null;
712
+ }
713
+ index += 1;
714
+ }
715
+ }
716
+
717
+ function readTomlKeyValues(lines) {
718
+ const values = {};
719
+ for (let index = 0; index < lines.length; index += 1) {
720
+ const match = /^\s*([A-Za-z0-9_-]+)\s*=\s*(.*)$/.exec(lines[index]);
721
+ if (!match) {
722
+ continue;
723
+ }
724
+ let raw = match[2];
725
+ // A multi-line array: read until its closing bracket.
726
+ if (raw.trim().startsWith('[')) {
727
+ while (!arrayClosed(raw) && index + 1 < lines.length) {
728
+ index += 1;
729
+ raw += `\n${lines[index]}`;
730
+ }
731
+ }
732
+ const parsed = parseTomlValue(raw);
733
+ values[match[1]] = parsed === undefined ? Symbol.for('unparsed') : parsed;
734
+ }
735
+ return values;
736
+ }
737
+
738
+ function arrayClosed(raw) {
739
+ return parseTomlValue(raw) !== undefined;
740
+ }
741
+
742
+ // Strings and arrays of strings only; undefined for anything else.
743
+ export function parseTomlValue(raw) {
744
+ const source = String(raw);
745
+ let index = 0;
746
+ const skip = () => {
747
+ while (index < source.length) {
748
+ if (/\s|,/.test(source[index]) && source[index] !== undefined) {
749
+ index += 1;
750
+ } else if (source[index] === '#') {
751
+ while (index < source.length && source[index] !== '\n') {
752
+ index += 1;
753
+ }
754
+ } else {
755
+ break;
756
+ }
757
+ }
758
+ };
759
+ const readValue = () => {
760
+ if (source[index] === '"') {
761
+ if (source.startsWith('"""', index)) {
762
+ return undefined;
763
+ }
764
+ const parsed = readBasicString(source, index);
765
+ if (!parsed) {
766
+ return undefined;
767
+ }
768
+ index = parsed.end;
769
+ return parsed.value;
770
+ }
771
+ if (source[index] === "'") {
772
+ if (source.startsWith("'''", index)) {
773
+ return undefined;
774
+ }
775
+ const end = source.indexOf("'", index + 1);
776
+ if (end === -1) {
777
+ return undefined;
778
+ }
779
+ const value = source.slice(index + 1, end);
780
+ index = end + 1;
781
+ return value;
782
+ }
783
+ if (source[index] === '[') {
784
+ index += 1;
785
+ const items = [];
786
+ while (true) {
787
+ skip();
788
+ if (index >= source.length) {
789
+ return undefined;
790
+ }
791
+ if (source[index] === ']') {
792
+ index += 1;
793
+ return items;
794
+ }
795
+ const item = readValue();
796
+ if (item === undefined) {
797
+ return undefined;
798
+ }
799
+ items.push(item);
800
+ }
801
+ }
802
+ return undefined;
803
+ };
804
+ while (index < source.length && /[ \t]/.test(source[index])) {
805
+ index += 1;
806
+ }
807
+ const value = readValue();
808
+ if (value === undefined) {
809
+ return undefined;
810
+ }
811
+ const rest = source.slice(index).trim();
812
+ if (rest && !rest.startsWith('#')) {
813
+ return undefined;
814
+ }
815
+ return value;
816
+ }
817
+
818
+ function readBasicString(source, start) {
819
+ let value = '';
820
+ let index = start + 1;
821
+ while (index < source.length) {
822
+ const char = source[index];
823
+ if (char === '"') {
824
+ return { value, end: index + 1 };
825
+ }
826
+ if (char === '\n') {
827
+ return null;
828
+ }
829
+ if (char === '\\') {
830
+ const next = source[index + 1];
831
+ const simple = { b: '\b', t: '\t', n: '\n', f: '\f', r: '\r', '"': '"', '\\': '\\' }[next];
832
+ if (simple !== undefined) {
833
+ value += simple;
834
+ index += 2;
835
+ continue;
836
+ }
837
+ if (next === 'u' || next === 'U') {
838
+ const length = next === 'u' ? 4 : 8;
839
+ const hex = source.slice(index + 2, index + 2 + length);
840
+ if (!/^[0-9a-fA-F]+$/.test(hex) || hex.length !== length) {
841
+ return null;
842
+ }
843
+ value += String.fromCodePoint(Number.parseInt(hex, 16));
844
+ index += 2 + length;
845
+ continue;
846
+ }
847
+ return null;
848
+ }
849
+ value += char;
850
+ index += 1;
851
+ }
852
+ return null;
853
+ }
854
+
855
+ // ---------------------------------------------------------------------------
856
+ // Helpers
857
+
858
+ async function pathExists(path, ctx) {
859
+ if (!path) {
860
+ return false;
861
+ }
862
+ try {
863
+ await ctx.fs.access(path, fsConstants.F_OK);
864
+ return true;
865
+ } catch {
866
+ return false;
867
+ }
868
+ }
869
+
870
+ function samePath(left, right, platform) {
871
+ const a = String(left ?? '');
872
+ const b = String(right ?? '');
873
+ return platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
874
+ }
875
+
876
+ // Finds a command on PATH and returns its absolute path, or null.
877
+ export async function whichCommand(name, {
878
+ platform = process.platform,
879
+ env = process.env,
880
+ access = fs.access
881
+ } = {}) {
882
+ const path = pathApi(platform);
883
+ const pathValue = env.PATH || env.Path || '';
884
+ const entries = pathValue.split(platform === 'win32' ? ';' : ':').filter(Boolean);
885
+ const names = platform === 'win32' ? [`${name}.exe`, `${name}.cmd`, `${name}.bat`, name] : [name];
886
+ for (const entry of entries) {
887
+ for (const candidate of names) {
888
+ const full = path.join(entry, candidate);
889
+ try {
890
+ await access(full, platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK);
891
+ return full;
892
+ } catch {
893
+ // Keep searching PATH.
894
+ }
895
+ }
896
+ }
897
+ return null;
898
+ }
899
+
900
+ // Runs a CLI and collects its output. A Windows .cmd/.bat shim (npm installs)
901
+ // must go through cmd.exe; arguments are quoted for it.
902
+ export function runCliCommand(command, args, { platform = process.platform, cwd } = {}) {
903
+ let file = command;
904
+ let argv = args;
905
+ const options = { cwd, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true };
906
+ if (platform === 'win32' && /\.(cmd|bat)$/i.test(command)) {
907
+ file = 'cmd.exe';
908
+ argv = ['/d', '/s', '/c', `"${[command, ...args].map(quoteCmdArg).join(' ')}"`];
909
+ options.windowsVerbatimArguments = true;
910
+ }
911
+ return new Promise((resolveValue, reject) => {
912
+ const child = spawn(file, argv, options);
913
+ let stdout = '';
914
+ let stderr = '';
915
+ child.stdout.setEncoding('utf8');
916
+ child.stderr.setEncoding('utf8');
917
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
918
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
919
+ child.on('error', reject);
920
+ child.on('close', (code, signal) => resolveValue({ code: signal ? 1 : (code ?? 1), stdout, stderr }));
921
+ });
922
+ }
923
+
924
+ export function quoteCmdArg(value) {
925
+ const text = String(value);
926
+ if (/["%!^&|<>\r\n]/.test(text)) {
927
+ throw new Error(`Cannot pass ${JSON.stringify(text)} to cmd.exe safely.`);
928
+ }
929
+ return /[\s()]/.test(text) || text === '' ? `"${text}"` : text;
930
+ }