genexus-mcp 2.8.2 → 2.8.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/cli/lib/config.js CHANGED
@@ -237,16 +237,132 @@ function directoryLooksLikeKnowledgeBase(dir) {
237
237
  }
238
238
  }
239
239
 
240
+ // Strip // and /* */ comments and trailing commas while respecting string
241
+ // literals, so we can parse JSONC configs (VS Code's mcp.json/settings.json and
242
+ // OpenCode's opencode.jsonc are JSONC). Comments are NOT preserved on rewrite.
243
+ //
244
+ // Trailing-comma removal is done INSIDE the scanner (a comma is deferred and only
245
+ // emitted once we know the next significant char isn't a closing brace/bracket),
246
+ // not by a post-hoc regex — a regex over the whole text would also strip commas
247
+ // that live inside string values (e.g. "see foo, ]" -> "see foo ]"). Only `"`
248
+ // opens a string: JSON/JSONC has no single-quoted strings.
249
+ function stripJsonComments(text) {
250
+ let out = '';
251
+ let inString = false;
252
+ let inLine = false;
253
+ let inBlock = false;
254
+ let pendingComma = false;
255
+ // Resolve a deferred comma: keep it unless the next significant char closes a
256
+ // container (then it was a trailing comma and gets dropped).
257
+ const flushComma = (nextSignificant) => {
258
+ if (pendingComma) {
259
+ if (nextSignificant !== '}' && nextSignificant !== ']') out += ',';
260
+ pendingComma = false;
261
+ }
262
+ };
263
+ for (let i = 0; i < text.length; i += 1) {
264
+ const ch = text[i];
265
+ const next = text[i + 1];
266
+ if (inLine) {
267
+ if (ch === '\n') inLine = false;
268
+ continue;
269
+ }
270
+ if (inBlock) {
271
+ if (ch === '*' && next === '/') { inBlock = false; i += 1; }
272
+ continue;
273
+ }
274
+ if (inString) {
275
+ out += ch;
276
+ if (ch === '\\') { out += next; i += 1; continue; }
277
+ if (ch === '"') inString = false;
278
+ continue;
279
+ }
280
+ // Outside any string/comment.
281
+ if (ch === '/' && next === '/') { inLine = true; i += 1; continue; }
282
+ if (ch === '/' && next === '*') { inBlock = true; i += 1; continue; }
283
+ if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
284
+ // Whitespace between a deferred comma and the next token is collapsed
285
+ // (JSON.parse ignores it); otherwise emit it verbatim.
286
+ if (!pendingComma) out += ch;
287
+ continue;
288
+ }
289
+ if (ch === ',') {
290
+ flushComma(','); // a prior comma followed by another comma is kept as-is
291
+ pendingComma = true; // defer this one until we see what follows
292
+ continue;
293
+ }
294
+ flushComma(ch);
295
+ out += ch;
296
+ if (ch === '"') inString = true;
297
+ }
298
+ flushComma('');
299
+ return out;
300
+ }
301
+
240
302
  function readJsonFileSafe(filePath) {
241
303
  try {
242
304
  const raw = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
243
305
  if (!raw.trim()) return {};
244
- return JSON.parse(raw);
306
+ try {
307
+ return JSON.parse(raw);
308
+ } catch {
309
+ // Fall back to a JSONC-tolerant parse before giving up, so a commented
310
+ // VS Code / OpenCode config isn't treated as corrupt.
311
+ return JSON.parse(stripJsonComments(raw));
312
+ }
245
313
  } catch {
246
314
  return null;
247
315
  }
248
316
  }
249
317
 
318
+ // Atomic write: stage to a temp file then rename over the target, so a crash
319
+ // mid-write can never leave a client's config truncated.
320
+ function writeFileAtomic(filePath, content) {
321
+ const tmp = `${filePath}.tmp-${process.pid}`;
322
+ fs.writeFileSync(tmp, content);
323
+ try {
324
+ fs.renameSync(tmp, filePath);
325
+ } catch (err) {
326
+ try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
327
+ throw err;
328
+ }
329
+ }
330
+
331
+ // Back up a client config once per process run before the first mutation, so the
332
+ // user has a restore point (the old build-from-source install.ps1 did this; the
333
+ // CLI now owns it). Best-effort \u2014 a failed backup never blocks the write.
334
+ const _backedUpThisRun = new Set();
335
+ function backupClientConfigOnce(filePath) {
336
+ if (!fs.existsSync(filePath)) return null;
337
+ // Case-fold the dedupe key only on Windows; lowercasing on a case-sensitive
338
+ // filesystem could merge two genuinely distinct paths.
339
+ const resolved = path.resolve(filePath);
340
+ const key = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
341
+ if (_backedUpThisRun.has(key)) return null;
342
+ try {
343
+ const d = new Date();
344
+ const stamp = d.toISOString().replace(/[-:T]/g, '').slice(0, 14);
345
+ const bak = `${filePath}.${stamp}.bak`;
346
+ fs.copyFileSync(filePath, bak);
347
+ _backedUpThisRun.add(key);
348
+ return bak;
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+
354
+ // Write JSON to a client config: back up, serialize, write atomically.
355
+ function writeClientJson(filePath, obj) {
356
+ backupClientConfigOnce(filePath);
357
+ writeFileAtomic(filePath, JSON.stringify(obj, null, 2));
358
+ }
359
+
360
+ // Write raw text to a client config (e.g. Codex TOML): back up + write atomically.
361
+ function writeClientText(filePath, content) {
362
+ backupClientConfigOnce(filePath);
363
+ writeFileAtomic(filePath, content);
364
+ }
365
+
250
366
  function resolveConfigPathNoMutate(cwd) {
251
367
  const cwdConfigPath = path.join(cwd, 'config.json');
252
368
  if (process.env.GX_CONFIG_PATH && fs.existsSync(process.env.GX_CONFIG_PATH)) {
@@ -298,67 +414,241 @@ function getLauncher() {
298
414
  : { command: process.platform === 'win32' ? 'npx.cmd' : 'npx', args: ['-y', 'genexus-mcp@latest'] };
299
415
  }
300
416
 
417
+ // Antigravity (Google's agentic IDE) ships its MCP config under ~/.gemini.
418
+ // The newer unified location (shared across Antigravity CLI / IDE / SDK) is
419
+ // ~/.gemini/config/mcp_config.json; the older IDE-specific one is
420
+ // ~/.gemini/antigravity/mcp_config.json. We write to the unified path when its
421
+ // parent dir already exists, else fall back to the IDE-specific path.
422
+ function resolveAntigravityConfigPath(home) {
423
+ // Only target the unified location when its file already exists (the user has
424
+ // adopted it); otherwise write the IDE-specific path, which is the location
425
+ // Antigravity reliably reads and was confirmed working in the field.
426
+ const unified = path.join(home, '.gemini', 'config', 'mcp_config.json');
427
+ if (fs.existsSync(unified)) return unified;
428
+ return path.join(home, '.gemini', 'antigravity', 'mcp_config.json');
429
+ }
430
+
431
+ // OpenCode CLI accepts either opencode.jsonc or opencode.json. Prefer an
432
+ // existing .jsonc so we don't strand the user's commented config, else .json.
433
+ function resolveOpenCodeConfigPath(xdgConfig) {
434
+ const jsonc = path.join(xdgConfig, 'opencode', 'opencode.jsonc');
435
+ if (fs.existsSync(jsonc)) return jsonc;
436
+ return path.join(xdgConfig, 'opencode', 'opencode.json');
437
+ }
438
+
439
+ // VS Code stores its user profile (and native MCP mcp.json) in a per-platform
440
+ // location. `variant` is 'Code' (stable) or 'Code - Insiders'.
441
+ function vscodeUserDir(variant, { appData, macAppSupport, xdgConfig }) {
442
+ if (process.platform === 'win32') return path.join(appData, variant, 'User');
443
+ if (process.platform === 'darwin') return path.join(macAppSupport, variant, 'User');
444
+ return path.join(xdgConfig, variant, 'User');
445
+ }
446
+
301
447
  function getClientConfigTargets() {
302
448
  const home = os.homedir();
303
449
  const xdgConfig = process.env.XDG_CONFIG_HOME || path.join(home, '.config');
450
+ const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
451
+ const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
452
+ const macAppSupport = path.join(home, 'Library', 'Application Support');
453
+ const vscodeStableUser = vscodeUserDir('Code', { appData, macAppSupport, xdgConfig });
454
+ const vscodeInsidersUser = vscodeUserDir('Code - Insiders', { appData, macAppSupport, xdgConfig });
455
+
456
+ // `installMarkers` prove the AGENT is installed, independent of whether our
457
+ // MCP config file exists yet. This is the fix for the field report where the
458
+ // wizard showed Antigravity as "not detected": Antigravity does not create
459
+ // ~/.gemini/antigravity/mcp_config.json until the user adds an MCP server, so
460
+ // detecting by config-file presence alone was chicken-and-egg.
304
461
  return [
305
462
  {
306
463
  id: 'claude-desktop-win',
307
464
  name: 'Claude Desktop (Windows)',
308
465
  format: 'mcpServers',
309
466
  path: path.join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json'),
310
- platforms: ['win32']
467
+ platforms: ['win32'],
468
+ installMarkers: [
469
+ path.join(localAppData, 'AnthropicClaude'),
470
+ path.join(appData, 'Claude')
471
+ ]
311
472
  },
312
473
  {
313
474
  id: 'claude-desktop-mac',
314
475
  name: 'Claude Desktop (macOS)',
315
476
  format: 'mcpServers',
316
477
  path: path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
317
- platforms: ['darwin']
478
+ platforms: ['darwin'],
479
+ installMarkers: [
480
+ path.join(macAppSupport, 'Claude'),
481
+ '/Applications/Claude.app'
482
+ ]
318
483
  },
319
484
  {
320
485
  id: 'antigravity',
321
486
  name: 'Antigravity',
322
487
  format: 'mcpServers',
323
- path: path.join(home, '.gemini', 'antigravity', 'mcp_config.json')
488
+ path: resolveAntigravityConfigPath(home),
489
+ // Unambiguous Antigravity markers only. ~/.gemini/config is NOT a
490
+ // marker — gemini-cli can create ~/.gemini, and we'd false-positive;
491
+ // it's still used as the write path (resolveAntigravityConfigPath)
492
+ // once a real Antigravity install is confirmed by these markers.
493
+ installMarkers: [
494
+ path.join(localAppData, 'Programs', 'Antigravity'),
495
+ path.join(appData, 'Antigravity'),
496
+ path.join(home, '.antigravity'),
497
+ path.join(home, '.gemini', 'antigravity')
498
+ ]
324
499
  },
325
500
  {
326
501
  id: 'claude-code',
327
502
  name: 'Claude Code',
328
503
  format: 'mcpServers',
329
- path: path.join(home, '.claude.json')
504
+ path: path.join(home, '.claude.json'),
505
+ installMarkers: [
506
+ path.join(home, '.claude.json'),
507
+ path.join(home, '.claude')
508
+ ]
330
509
  },
331
510
  {
332
511
  id: 'gemini-cli',
333
512
  name: 'Gemini CLI',
334
513
  format: 'mcpServers',
335
- path: path.join(home, '.gemini', 'settings.json')
514
+ path: path.join(home, '.gemini', 'settings.json'),
515
+ installMarkers: [
516
+ path.join(home, '.gemini', 'settings.json')
517
+ ]
336
518
  },
337
519
  {
338
520
  id: 'cursor',
339
521
  name: 'Cursor',
340
522
  format: 'mcpServers',
341
- path: path.join(home, '.cursor', 'mcp.json')
523
+ path: path.join(home, '.cursor', 'mcp.json'),
524
+ installMarkers: [
525
+ path.join(home, '.cursor'),
526
+ path.join(localAppData, 'Programs', 'cursor'),
527
+ '/Applications/Cursor.app'
528
+ ]
342
529
  },
343
530
  {
344
531
  id: 'opencode',
345
- name: 'OpenCode',
532
+ name: 'OpenCode (CLI)',
346
533
  format: 'opencode',
347
- path: path.join(xdgConfig, 'opencode', 'opencode.json')
534
+ path: resolveOpenCodeConfigPath(xdgConfig),
535
+ installMarkers: [
536
+ path.join(xdgConfig, 'opencode'),
537
+ path.join(home, '.local', 'share', 'opencode')
538
+ ]
348
539
  },
349
540
  {
350
541
  id: 'codex-cli',
351
542
  name: 'Codex CLI',
352
543
  format: 'codex-toml',
353
- path: path.join(home, '.codex', 'config.toml')
544
+ path: path.join(home, '.codex', 'config.toml'),
545
+ installMarkers: [
546
+ path.join(home, '.codex')
547
+ ]
548
+ },
549
+ {
550
+ id: 'opencode-desktop',
551
+ name: 'OpenCode Desktop',
552
+ // Detect-only: the Desktop app's MCP config schema differs from the CLI
553
+ // and isn't auto-written yet. We report it so the user knows it's there
554
+ // and how to wire it up, but never mutate its config blindly.
555
+ format: 'manual',
556
+ writeSupported: false,
557
+ manualNote: 'OpenCode Desktop: add the genexus MCP server from the app\'s settings (automatic registration not supported yet).',
558
+ path: path.join(appData, 'ai.opencode.desktop', 'mcp.json'),
559
+ installMarkers: [
560
+ path.join(localAppData, 'Programs', '@opencode-aidesktop'),
561
+ path.join(appData, 'ai.opencode.desktop'),
562
+ '/Applications/OpenCode.app'
563
+ ]
564
+ },
565
+ {
566
+ id: 'vscode',
567
+ name: 'VS Code',
568
+ format: 'vscode-servers',
569
+ path: path.join(vscodeStableUser, 'mcp.json'),
570
+ installMarkers: [vscodeStableUser]
571
+ },
572
+ {
573
+ id: 'vscode-insiders',
574
+ name: 'VS Code Insiders',
575
+ format: 'vscode-servers',
576
+ path: path.join(vscodeInsidersUser, 'mcp.json'),
577
+ installMarkers: [vscodeInsidersUser]
354
578
  }
355
579
  ];
356
580
  }
357
581
 
582
+ // Decide whether an agent is installed (independent of whether OUR config file
583
+ // exists). Returns the installed flag plus diagnostics so the wizard can show
584
+ // the user exactly where it looked when an agent is reported "not detected".
585
+ function detectClientInstalled(client) {
586
+ const markers = Array.isArray(client.installMarkers) ? client.installMarkers : [];
587
+ const hasConfig = fs.existsSync(client.path);
588
+ let markerHit = null;
589
+ for (const m of markers) {
590
+ if (fs.existsSync(m)) {
591
+ markerHit = m;
592
+ break;
593
+ }
594
+ }
595
+ return {
596
+ installed: hasConfig || markerHit !== null,
597
+ hasConfig,
598
+ markerHit,
599
+ markersChecked: markers
600
+ };
601
+ }
602
+
358
603
  function listSupportedClientIds() {
359
604
  return getClientConfigTargets().map((c) => c.id);
360
605
  }
361
606
 
607
+ // Judge whether a registered launcher command is healthy. npx/node/genexus-mcp
608
+ // shims resolve at runtime so we can't fault them; any other launcher referenced
609
+ // by an explicit path (a separator in the command) that no longer exists on disk
610
+ // is the classic "Failed to connect / still on old version" cause after an
611
+ // install dir moved or was cleaned — covers .exe, .bat, .cmd, .sh, extensionless.
612
+ function clientCommandHealth(entry) {
613
+ if (!entry || !entry.command) return { stale: false, reason: null };
614
+ const cmd = String(entry.command);
615
+ if (/(^|[\\/])(npx|npx\.cmd|node|node\.exe|genexus-mcp|genexus-mcp\.cmd)$/i.test(cmd)) {
616
+ return { stale: false, reason: null };
617
+ }
618
+ if (/[\\/]/.test(cmd) && !fs.existsSync(cmd)) {
619
+ return { stale: true, reason: 'configured launcher does not exist on disk' };
620
+ }
621
+ return { stale: false, reason: null };
622
+ }
623
+
624
+ // Read-only report of every supported agent on this platform: is it installed,
625
+ // is genexus registered, where, what launcher command it points at, and whether
626
+ // that command is stale. Backs the `genexus-mcp clients` command.
627
+ function clientsStatus(opts = {}) {
628
+ const targets = filterClientTargets(getClientConfigTargets(), {
629
+ ids: opts.ids,
630
+ platform: process.platform
631
+ });
632
+ return targets.map((client) => {
633
+ const det = detectClientInstalled(client);
634
+ const entry = readClientCommandEntry(client);
635
+ const health = clientCommandHealth(entry);
636
+ return {
637
+ id: client.id,
638
+ name: client.name,
639
+ installed: det.installed,
640
+ registered: entry !== null,
641
+ writeSupported: client.writeSupported !== false,
642
+ configPath: client.path,
643
+ command: entry && entry.command ? entry.command : null,
644
+ commandStale: health.stale,
645
+ commandStaleReason: health.reason,
646
+ detectedAt: det.markerHit || (det.hasConfig ? client.path : null),
647
+ note: client.writeSupported === false ? (client.manualNote || null) : null
648
+ };
649
+ });
650
+ }
651
+
362
652
  function filterClientTargets(targets, opts = {}) {
363
653
  const { ids, onlyExisting, platform } = opts;
364
654
  let out = targets;
@@ -396,13 +686,29 @@ function patchClientConfig(targetConfigPath, opts = {}) {
396
686
  const skipped = [];
397
687
 
398
688
  for (const client of candidates) {
399
- if (onlyExisting && !fs.existsSync(client.path)) {
689
+ // Detect-only agents (e.g. OpenCode Desktop) can't be auto-written; surface
690
+ // the manual step instead of pretending we registered them.
691
+ if (client.writeSupported === false) {
692
+ if (detectClientInstalled(client).installed) {
693
+ skipped.push({ client: client.name, reason: client.manualNote || 'manual setup required' });
694
+ }
695
+ continue;
696
+ }
697
+ // "Installed" keys off install markers (the agent itself is present), not
698
+ // just our config file — otherwise agents that don't pre-create their MCP
699
+ // config (e.g. Antigravity) are wrongly skipped as "not installed".
700
+ if (onlyExisting && !detectClientInstalled(client).installed) {
400
701
  skipped.push({ client: client.name, reason: 'not installed' });
401
702
  continue;
402
703
  }
403
704
  try {
404
705
  fs.mkdirSync(path.dirname(client.path), { recursive: true });
405
706
  applyClientEntry(client, launcher, targetConfigPath);
707
+ // Read-back: confirm the entry is actually present and the file still
708
+ // parses, so a silently-corrupted write is reported as a failure.
709
+ if (!readClientCommandEntry(client)) {
710
+ throw new Error('post-write verification failed (genexus entry not found after write)');
711
+ }
406
712
  patched.push(client.name);
407
713
  } catch (err) {
408
714
  failed.push({ client: client.name, reason: err && err.message ? err.message : 'Unknown error' });
@@ -423,6 +729,11 @@ function unpatchClientConfig(opts = {}) {
423
729
  const failed = [];
424
730
 
425
731
  for (const client of targets) {
732
+ // Detect-only agents were never written by us — nothing to remove.
733
+ if (client.writeSupported === false) {
734
+ skipped.push({ client: client.name, reason: 'manual setup (not managed by genexus-mcp)' });
735
+ continue;
736
+ }
426
737
  try {
427
738
  const wasRemoved = removeClientEntry(client);
428
739
  if (wasRemoved) removed.push(client.name);
@@ -443,6 +754,8 @@ function applyClientEntry(client, launcher, targetConfigPath) {
443
754
  return applyOpenCodeJson(client.path, launcher, targetConfigPath);
444
755
  case 'codex-toml':
445
756
  return applyCodexToml(client.path, launcher, targetConfigPath);
757
+ case 'vscode-servers':
758
+ return applyVsCodeServersJson(client.path, launcher, targetConfigPath);
446
759
  default:
447
760
  throw new Error(`Unknown client format: ${client.format}`);
448
761
  }
@@ -456,6 +769,8 @@ function removeClientEntry(client) {
456
769
  return removeOpenCodeJson(client.path);
457
770
  case 'codex-toml':
458
771
  return removeCodexToml(client.path);
772
+ case 'vscode-servers':
773
+ return removeVsCodeServersJson(client.path);
459
774
  default:
460
775
  throw new Error(`Unknown client format: ${client.format}`);
461
776
  }
@@ -467,16 +782,63 @@ function applyMcpServersJson(filePath, launcher, targetConfigPath) {
467
782
  const cfgObj = parsed || {};
468
783
  cfgObj.mcpServers = cfgObj.mcpServers || {};
469
784
  cfgObj.mcpServers.genexus = { ...launcher, env: { GX_CONFIG_PATH: targetConfigPath } };
470
- fs.writeFileSync(filePath, JSON.stringify(cfgObj, null, 2));
785
+ // Drop the legacy `genexus18` key from older build-from-source installs so the
786
+ // user isn't left with two duplicate servers (and colliding tool names).
787
+ if (cfgObj.mcpServers.genexus18) delete cfgObj.mcpServers.genexus18;
788
+ writeClientJson(filePath, cfgObj);
471
789
  }
472
790
 
473
791
  function removeMcpServersJson(filePath) {
474
792
  const parsed = readJsonFileSafe(filePath);
475
793
  if (parsed === null) throw new Error('Invalid JSON');
476
794
  const cfgObj = parsed || {};
477
- if (!cfgObj.mcpServers || !cfgObj.mcpServers.genexus) return false;
478
- delete cfgObj.mcpServers.genexus;
479
- fs.writeFileSync(filePath, JSON.stringify(cfgObj, null, 2));
795
+ if (!cfgObj.mcpServers) return false;
796
+ // Remove the current key plus the legacy `genexus18` key written by older
797
+ // versions of the build-from-source install.ps1, so uninstall fully cleans up
798
+ // regardless of which installer wrote the entry.
799
+ let removedAny = false;
800
+ for (const key of ['genexus', 'genexus18']) {
801
+ if (cfgObj.mcpServers[key]) {
802
+ delete cfgObj.mcpServers[key];
803
+ removedAny = true;
804
+ }
805
+ }
806
+ if (!removedAny) return false;
807
+ writeClientJson(filePath, cfgObj);
808
+ return true;
809
+ }
810
+
811
+ // VS Code native MCP lives in User\mcp.json and uses a top-level `servers` map
812
+ // with `type: "stdio"` (distinct from the `mcpServers` shape Claude/Cursor use).
813
+ function applyVsCodeServersJson(filePath, launcher, targetConfigPath) {
814
+ const parsed = fs.existsSync(filePath) ? readJsonFileSafe(filePath) : {};
815
+ if (parsed === null) throw new Error('Invalid JSON');
816
+ const cfgObj = parsed || {};
817
+ cfgObj.servers = cfgObj.servers || {};
818
+ cfgObj.servers.genexus = {
819
+ type: 'stdio',
820
+ ...launcher,
821
+ env: { GX_CONFIG_PATH: targetConfigPath }
822
+ };
823
+ // Drop the legacy `genexus18` key written by older build-from-source installs.
824
+ if (cfgObj.servers.genexus18) delete cfgObj.servers.genexus18;
825
+ writeClientJson(filePath, cfgObj);
826
+ }
827
+
828
+ function removeVsCodeServersJson(filePath) {
829
+ const parsed = readJsonFileSafe(filePath);
830
+ if (parsed === null) throw new Error('Invalid JSON');
831
+ const cfgObj = parsed || {};
832
+ if (!cfgObj.servers) return false;
833
+ let removedAny = false;
834
+ for (const key of ['genexus', 'genexus18']) {
835
+ if (cfgObj.servers[key]) {
836
+ delete cfgObj.servers[key];
837
+ removedAny = true;
838
+ }
839
+ }
840
+ if (!removedAny) return false;
841
+ writeClientJson(filePath, cfgObj);
480
842
  return true;
481
843
  }
482
844
 
@@ -485,6 +847,9 @@ function applyOpenCodeJson(filePath, launcher, targetConfigPath) {
485
847
  const parsed = fs.existsSync(filePath) ? readJsonFileSafe(filePath) : {};
486
848
  if (parsed === null) throw new Error('Invalid JSON');
487
849
  const cfgObj = parsed || {};
850
+ // OpenCode configs carry a top-level $schema for editor validation; set it when
851
+ // absent (new file or a config that never had one) without clobbering a custom one.
852
+ if (!cfgObj.$schema) cfgObj.$schema = 'https://opencode.ai/config.json';
488
853
  cfgObj.mcp = cfgObj.mcp || {};
489
854
  cfgObj.mcp.genexus = {
490
855
  type: 'local',
@@ -492,7 +857,8 @@ function applyOpenCodeJson(filePath, launcher, targetConfigPath) {
492
857
  environment: { GX_CONFIG_PATH: targetConfigPath },
493
858
  enabled: true
494
859
  };
495
- fs.writeFileSync(filePath, JSON.stringify(cfgObj, null, 2));
860
+ if (cfgObj.mcp.genexus18) delete cfgObj.mcp.genexus18;
861
+ writeClientJson(filePath, cfgObj);
496
862
  }
497
863
 
498
864
  function removeOpenCodeJson(filePath) {
@@ -501,7 +867,7 @@ function removeOpenCodeJson(filePath) {
501
867
  const cfgObj = parsed || {};
502
868
  if (!cfgObj.mcp || !cfgObj.mcp.genexus) return false;
503
869
  delete cfgObj.mcp.genexus;
504
- fs.writeFileSync(filePath, JSON.stringify(cfgObj, null, 2));
870
+ writeClientJson(filePath, cfgObj);
505
871
  return true;
506
872
  }
507
873
 
@@ -524,7 +890,7 @@ function applyCodexToml(filePath, launcher, targetConfigPath) {
524
890
  lines.push('[mcp_servers.genexus.env]');
525
891
  lines.push(`GX_CONFIG_PATH = ${tomlString(targetConfigPath)}`);
526
892
  lines.push('');
527
- fs.writeFileSync(filePath, stripped + lines.join('\n'));
893
+ writeClientText(filePath, stripped + lines.join('\n'));
528
894
  }
529
895
 
530
896
  function removeCodexToml(filePath) {
@@ -532,7 +898,7 @@ function removeCodexToml(filePath) {
532
898
  const existing = fs.readFileSync(filePath, 'utf8');
533
899
  const stripped = stripCodexGenexusBlocks(existing);
534
900
  if (stripped === existing) return false;
535
- fs.writeFileSync(filePath, stripped);
901
+ writeClientText(filePath, stripped);
536
902
  return true;
537
903
  }
538
904
 
@@ -588,6 +954,7 @@ function normalizeExePath(p) {
588
954
  }
589
955
 
590
956
  function readClientCommandEntry(client) {
957
+ if (client.writeSupported === false) return null;
591
958
  if (!fs.existsSync(client.path)) return null;
592
959
  try {
593
960
  if (client.format === 'mcpServers') {
@@ -604,6 +971,13 @@ function readClientCommandEntry(client) {
604
971
  if (!entry || !Array.isArray(entry.command) || entry.command.length === 0) return null;
605
972
  return { command: entry.command[0], args: entry.command.slice(1) };
606
973
  }
974
+ if (client.format === 'vscode-servers') {
975
+ const parsed = readJsonFileSafe(client.path);
976
+ if (!parsed || typeof parsed !== 'object') return null;
977
+ const entry = parsed.servers && parsed.servers.genexus;
978
+ if (!entry) return null;
979
+ return { command: entry.command || null, args: Array.isArray(entry.args) ? entry.args : [] };
980
+ }
607
981
  if (client.format === 'codex-toml') {
608
982
  const raw = fs.readFileSync(client.path, 'utf8');
609
983
  // Minimal extraction: find [mcp_servers.genexus] block and pull command = "..."
@@ -786,6 +1160,8 @@ module.exports = {
786
1160
  patchClientConfig,
787
1161
  unpatchClientConfig,
788
1162
  getClientConfigTargets,
1163
+ detectClientInstalled,
1164
+ clientsStatus,
789
1165
  listSupportedClientIds,
790
1166
  filterClientTargets,
791
1167
  getLocalAppDataCacheDir,