frontier-mcp 0.6.0 → 1.0.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.
Files changed (79) hide show
  1. package/README.md +33 -0
  2. package/dist/domain.d.ts +62 -29
  3. package/dist/domain.d.ts.map +1 -1
  4. package/dist/domain.js +9 -0
  5. package/dist/domain.js.map +1 -1
  6. package/dist/frontier.js +1 -1
  7. package/dist/frontier.js.map +1 -1
  8. package/dist/server.d.ts.map +1 -1
  9. package/dist/server.js +14 -8
  10. package/dist/server.js.map +1 -1
  11. package/dist/storage/driver.d.ts +36 -6
  12. package/dist/storage/driver.d.ts.map +1 -1
  13. package/dist/storage/driver.js +53 -5
  14. package/dist/storage/driver.js.map +1 -1
  15. package/dist/storage/markdown/create.d.ts +0 -2
  16. package/dist/storage/markdown/create.d.ts.map +1 -1
  17. package/dist/storage/markdown/create.js +0 -10
  18. package/dist/storage/markdown/create.js.map +1 -1
  19. package/dist/storage/markdown/driver.d.ts.map +1 -1
  20. package/dist/storage/markdown/driver.js +36 -18
  21. package/dist/storage/markdown/driver.js.map +1 -1
  22. package/dist/storage/markdown/frontmatter.js +1 -1
  23. package/dist/storage/markdown/header-doc.d.ts.map +1 -1
  24. package/dist/storage/markdown/header-doc.js +13 -11
  25. package/dist/storage/markdown/header-doc.js.map +1 -1
  26. package/dist/storage/markdown/legacy.d.ts +8 -4
  27. package/dist/storage/markdown/legacy.d.ts.map +1 -1
  28. package/dist/storage/markdown/legacy.js +20 -15
  29. package/dist/storage/markdown/legacy.js.map +1 -1
  30. package/dist/storage/markdown/migrate.d.ts +11 -3
  31. package/dist/storage/markdown/migrate.d.ts.map +1 -1
  32. package/dist/storage/markdown/migrate.js +69 -80
  33. package/dist/storage/markdown/migrate.js.map +1 -1
  34. package/dist/storage/markdown/schema.d.ts +9 -0
  35. package/dist/storage/markdown/schema.d.ts.map +1 -0
  36. package/dist/storage/markdown/schema.js +21 -0
  37. package/dist/storage/markdown/schema.js.map +1 -0
  38. package/dist/storage/markdown/serialize.d.ts +1 -1
  39. package/dist/storage/markdown/serialize.d.ts.map +1 -1
  40. package/dist/storage/markdown/serialize.js +110 -24
  41. package/dist/storage/markdown/serialize.js.map +1 -1
  42. package/dist/storage/markdown/ticket.d.ts +29 -6
  43. package/dist/storage/markdown/ticket.d.ts.map +1 -1
  44. package/dist/storage/markdown/ticket.js +100 -33
  45. package/dist/storage/markdown/ticket.js.map +1 -1
  46. package/dist/storage/markdown/watcher.d.ts +1 -1
  47. package/dist/storage/markdown/watcher.d.ts.map +1 -1
  48. package/dist/storage/markdown/watcher.js +91 -11
  49. package/dist/storage/markdown/watcher.js.map +1 -1
  50. package/dist/tools/create-tickets.js +1 -1
  51. package/dist/tools/create-tickets.js.map +1 -1
  52. package/dist/tools/edit-map.d.ts.map +1 -1
  53. package/dist/tools/edit-map.js +6 -2
  54. package/dist/tools/edit-map.js.map +1 -1
  55. package/dist/tools/get-board.d.ts.map +1 -1
  56. package/dist/tools/get-board.js +34 -7
  57. package/dist/tools/get-board.js.map +1 -1
  58. package/dist/tools/get-tickets.d.ts.map +1 -1
  59. package/dist/tools/get-tickets.js +16 -9
  60. package/dist/tools/get-tickets.js.map +1 -1
  61. package/dist/tools/list-efforts.d.ts +7 -0
  62. package/dist/tools/list-efforts.d.ts.map +1 -1
  63. package/dist/tools/list-efforts.js +15 -0
  64. package/dist/tools/list-efforts.js.map +1 -1
  65. package/dist/tools/migrate-effort.d.ts +6 -1
  66. package/dist/tools/migrate-effort.d.ts.map +1 -1
  67. package/dist/tools/migrate-effort.js +59 -20
  68. package/dist/tools/migrate-effort.js.map +1 -1
  69. package/dist/tools/update-ticket.d.ts +2 -0
  70. package/dist/tools/update-ticket.d.ts.map +1 -1
  71. package/dist/tools/update-ticket.js +11 -4
  72. package/dist/tools/update-ticket.js.map +1 -1
  73. package/dist/workspace.d.ts +11 -0
  74. package/dist/workspace.d.ts.map +1 -1
  75. package/dist/workspace.js +17 -5
  76. package/dist/workspace.js.map +1 -1
  77. package/docs/agents/issue-tracker.md +15 -9
  78. package/package.json +11 -11
  79. package/scripts/frontier-setup.cjs +451 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frontier-mcp",
3
- "version": "0.6.0",
3
+ "version": "1.0.0",
4
4
  "description": "An MCP server exposing the markdown issue tracker under a repo's .scratch/ as a queryable graph",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,19 +25,19 @@
25
25
  }
26
26
  },
27
27
  "dependencies": {
28
- "@modelcontextprotocol/server": "^2.0.0",
29
- "yaml": "^2.9.0",
30
- "zod": "^4.4.3"
28
+ "@modelcontextprotocol/server": "^2.4.0",
29
+ "yaml": "^2.9.1",
30
+ "zod": "^4.6.7"
31
31
  },
32
32
  "devDependencies": {
33
- "@modelcontextprotocol/client": "^2.0.0",
34
- "@release-it/conventional-changelog": "^12.0.0",
35
- "@types/node": "^24.10.1",
36
- "oxfmt": "^0.62.0",
37
- "oxlint": "^1.77.0",
38
- "release-it": "^21.0.1",
33
+ "@modelcontextprotocol/client": "^2.4.0",
34
+ "@release-it/conventional-changelog": "^12.0.2",
35
+ "@types/node": "^24.19.2",
36
+ "oxfmt": "^0.78.0",
37
+ "oxlint": "^1.93.0",
38
+ "release-it": "^21.1.2",
39
39
  "typescript": "^7.0.2",
40
- "vitest": "^4.1.10"
40
+ "vitest": "^5.1.0"
41
41
  },
42
42
  "engines": {
43
43
  "node": "^20.20.2 || ^22.17.1 || ^24.15.0"
@@ -3,7 +3,7 @@
3
3
 
4
4
  // This file deliberately has no dependencies and uses syntax Node 16 accepts.
5
5
  // It must choose a runtime before FrontierMCP itself is installed or imported.
6
- // FrontierMCP setup bootstrap revision 2.
6
+ // FrontierMCP setup bootstrap revision 3.
7
7
 
8
8
  const { spawn } = require('node:child_process');
9
9
  const { createHash } = require('node:crypto');
@@ -11,6 +11,8 @@ const {
11
11
  access,
12
12
  chmod,
13
13
  copyFile,
14
+ lstat,
15
+ readlink,
14
16
  mkdtemp,
15
17
  mkdir,
16
18
  open,
@@ -40,6 +42,7 @@ const TOOL_NAMES = [
40
42
  'update_ticket',
41
43
  ];
42
44
  const REQUEST_TIMEOUT_MS = 15_000;
45
+ const CLIENTS = ['cursor', 'claude-code', 'manual'];
43
46
 
44
47
  if (require.main === module) {
45
48
  main(process.argv.slice(2)).catch(error => {
@@ -54,6 +57,12 @@ async function main(args) {
54
57
  printUsage();
55
58
  return;
56
59
  }
60
+ if (options.doctor) {
61
+ const report = await doctor(options.project ?? process.cwd());
62
+ process.stdout.write(report.text);
63
+ if (report.failed) process.exitCode = 1;
64
+ return;
65
+ }
57
66
 
58
67
  const release = await readRelease(options.version);
59
68
  const runtime = await selectRuntime(options.node, release.engines.node);
@@ -70,11 +79,17 @@ async function main(args) {
70
79
  await verifyLaunch(launch);
71
80
 
72
81
  const target =
73
- options.client === 'cursor' ? cursorConfigPath() : 'your MCP client user configuration';
82
+ options.client === 'cursor'
83
+ ? cursorConfigPath()
84
+ : options.client === 'claude-code'
85
+ ? claudeConfigPath()
86
+ : 'your MCP client user configuration';
74
87
  const preview =
75
88
  options.client === 'cursor'
76
89
  ? await prepareConfig(target, launch)
77
- : { desired: { command: launch.command, args: launch.args } };
90
+ : options.client === 'claude-code'
91
+ ? await prepareClaudeCode(target, launch)
92
+ : { desired: { command: launch.command, args: launch.args } };
78
93
  printPreview({
79
94
  runtime,
80
95
  release,
@@ -86,6 +101,18 @@ async function main(args) {
86
101
  });
87
102
 
88
103
  if (!options.apply || options.client === 'manual') return;
104
+ if (options.client === 'claude-code') {
105
+ const claude =
106
+ options.claude ??
107
+ (await firstAccessible(commandCandidatesOnPath('claude', process.env, process.platform)));
108
+ if (claude === undefined)
109
+ throw new Error('Claude Code CLI `claude` was not found on PATH. Pass --claude PATH.');
110
+ await applyClaudeCode(claude, target, preview, options.replace);
111
+ process.stdout.write(
112
+ 'Applied Claude Code user scope with `claude mcp add-json --scope user`. Restart Claude Code sessions to use it.\n',
113
+ );
114
+ return;
115
+ }
89
116
  await applyConfig(target, preview, options.replace);
90
117
  process.stdout.write(
91
118
  `Applied Cursor user configuration at ${target}. Restart Cursor to use it.\n`,
@@ -96,9 +123,12 @@ function parseArguments(args) {
96
123
  const options = {
97
124
  apply: false,
98
125
  bun: undefined,
126
+ claude: undefined,
99
127
  client: 'cursor',
100
128
  deno: undefined,
129
+ doctor: false,
101
130
  help: false,
131
+ project: undefined,
102
132
  node: undefined,
103
133
  replace: false,
104
134
  };
@@ -123,6 +153,14 @@ function parseArguments(args) {
123
153
  } else if (argument === '--deno' && value !== undefined) {
124
154
  options.deno = value;
125
155
  index += 1;
156
+ } else if (argument === '--doctor') {
157
+ options.doctor = true;
158
+ } else if (argument === '--project' && value !== undefined) {
159
+ options.project = value;
160
+ index += 1;
161
+ } else if (argument === '--claude' && value !== undefined) {
162
+ options.claude = value;
163
+ index += 1;
126
164
  } else if (argument === '--client' && value !== undefined) {
127
165
  options.client = value;
128
166
  index += 1;
@@ -132,30 +170,36 @@ function parseArguments(args) {
132
170
  }
133
171
  if (
134
172
  !options.help &&
173
+ !options.doctor &&
135
174
  (options.version === undefined || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(options.version))
136
175
  ) {
137
176
  throw new Error('Pass an exact released version, for example --version 0.4.0.');
138
177
  }
139
- if (options.client !== 'cursor' && options.client !== 'manual') {
140
- throw new Error('--client must be cursor or manual.');
178
+ if (!CLIENTS.includes(options.client)) {
179
+ throw new Error(`--client must be ${CLIENTS.join(', ')}.`);
141
180
  }
142
181
  if (options.client === 'manual' && options.apply) {
143
- throw new Error('--apply is available only for Cursor user configuration.');
182
+ throw new Error('--apply is available only for Cursor or Claude Code user configuration.');
144
183
  }
145
184
  return options;
146
185
  }
147
186
 
148
187
  function printUsage() {
149
- process.stdout.write(`FrontierMCP setup bootstrap revision 2
188
+ process.stdout.write(`FrontierMCP setup bootstrap revision 3
150
189
 
151
190
  Usage: node scripts/frontier-setup.cjs --version X.Y.Z [options]
191
+ node scripts/frontier-setup.cjs --doctor [--project DIR]
152
192
 
153
193
  Options:
154
194
  --node PATH Use this Node executable instead of the current one.
155
195
  --bun PATH Save this Bun candidate for verified project launches.
156
196
  --deno PATH Save this Deno candidate for verified project launches.
157
- --client NAME cursor (default) writes Cursor user scope; manual prints an entry.
158
- --apply Write the previewed Cursor entry.
197
+ --client NAME cursor (default) writes Cursor user scope; claude-code registers
198
+ Claude Code user scope through its CLI; manual prints an entry.
199
+ --claude PATH Claude Code CLI used by --client claude-code --apply.
200
+ --apply Write the previewed Cursor or Claude Code entry.
201
+ --doctor Report which client configs declare frontier and which are read.
202
+ --project DIR Repository the doctor inspects (default: current directory).
159
203
  --replace Replace an existing different frontier entry, with a backup.
160
204
  `);
161
205
  }
@@ -1410,17 +1454,22 @@ function printPreview({ runtime, release, installation, target, preview, client,
1410
1454
  process.stdout.write(
1411
1455
  `Installation: ${installation.directory}${installation.reused ? ' (reused after verification)' : ''}\n`,
1412
1456
  );
1413
- process.stdout.write(
1414
- `${client === 'cursor' ? 'Cursor user config' : 'Manual client entry'}: ${target}\n`,
1415
- );
1416
- process.stdout.write(
1417
- `${JSON.stringify({ mcpServers: { [SERVER]: preview.desired } }, null, 2)}\n`,
1418
- );
1419
- if (client === 'cursor' && !preview.identical && preview.existing !== undefined) {
1457
+ process.stdout.write(`${CLIENT_LABELS[client]}: ${target}\n`);
1458
+ if (client === 'claude-code') {
1459
+ if (process.platform === 'win32') process.stdout.write('In PowerShell:\n');
1460
+ process.stdout.write(`${manualClaudeCommand(preview.desired, process.platform)}\n`);
1461
+ } else {
1462
+ process.stdout.write(
1463
+ `${JSON.stringify({ mcpServers: { [SERVER]: preview.desired } }, null, 2)}\n`,
1464
+ );
1465
+ }
1466
+ if (client !== 'manual' && !preview.identical && preview.existing !== undefined) {
1420
1467
  process.stdout.write('An existing frontier entry needs --replace before --apply.\n');
1421
1468
  }
1422
- if (client === 'cursor' && !apply)
1423
- process.stdout.write('Preview only. Add --apply to write this Cursor entry.\n');
1469
+ if (client !== 'manual' && !apply)
1470
+ process.stdout.write(
1471
+ `Preview only. Add --apply to write this ${client === 'cursor' ? 'Cursor' : 'Claude Code'} entry.\n`,
1472
+ );
1424
1473
  }
1425
1474
 
1426
1475
  async function applyConfig(target, preview, replace, afterPreparation) {
@@ -1508,12 +1557,396 @@ function run(command, args, options = {}) {
1508
1557
  });
1509
1558
  }
1510
1559
 
1560
+ const CLIENT_LABELS = {
1561
+ cursor: 'Cursor user config',
1562
+ 'claude-code': 'Claude Code user config',
1563
+ manual: 'Manual client entry',
1564
+ };
1565
+
1566
+ function claudeConfigPath() {
1567
+ return path.join(homeDirectory(), '.claude.json');
1568
+ }
1569
+
1570
+ // Claude Code keeps user and local scope in ~/.claude.json and rewrites that
1571
+ // file itself while running, so setup only reads it and leaves the write to
1572
+ // the client's own CLI. The fingerprint covers the frontier entry alone.
1573
+ async function prepareClaudeCode(target, launch) {
1574
+ const config = await readClaudeConfig(target);
1575
+ const existing = isObject(config.mcpServers) ? config.mcpServers[SERVER] : undefined;
1576
+ const desired = { type: 'stdio', command: launch.command, args: launch.args };
1577
+ const identical =
1578
+ existing !== undefined &&
1579
+ existing.command === desired.command &&
1580
+ JSON.stringify(existing.args ?? []) === JSON.stringify(desired.args) &&
1581
+ (existing.type === undefined || existing.type === 'stdio');
1582
+ return { desired, existing, fingerprint: JSON.stringify(existing), identical };
1583
+ }
1584
+
1585
+ async function readClaudeConfig(target) {
1586
+ let source;
1587
+ try {
1588
+ source = await readFile(target, 'utf8');
1589
+ } catch (error) {
1590
+ if (error.code === 'ENOENT') return {};
1591
+ throw error;
1592
+ }
1593
+ let config;
1594
+ try {
1595
+ config = JSON.parse(source);
1596
+ } catch (error) {
1597
+ throw new Error(
1598
+ `Refusing to register into invalid Claude Code JSON at ${target}: ${error.message}`,
1599
+ {
1600
+ cause: error,
1601
+ },
1602
+ );
1603
+ }
1604
+ if (!isObject(config))
1605
+ throw new Error(`Refusing to register into invalid Claude Code configuration at ${target}.`);
1606
+ return config;
1607
+ }
1608
+
1609
+ async function applyClaudeCode(claude, target, preview, replace, platform = process.platform) {
1610
+ if (preview.identical) return;
1611
+ if (preview.existing !== undefined && !replace) {
1612
+ throw new Error(
1613
+ 'Claude Code already has a different user-scope frontier entry. Review the preview and rerun with --replace.',
1614
+ );
1615
+ }
1616
+ assertSpawnableClaude(claude, preview.desired, platform);
1617
+ const current = await readClaudeConfig(target);
1618
+ const now = isObject(current.mcpServers) ? current.mcpServers[SERVER] : undefined;
1619
+ if (JSON.stringify(now) !== preview.fingerprint) {
1620
+ throw new Error(
1621
+ `Claude Code frontier entry changed since preview; refusing to overwrite ${target}.`,
1622
+ );
1623
+ }
1624
+ let backup;
1625
+ try {
1626
+ backup = `${target}.frontier-backup-${new Date().toISOString().replace(/[:.]/g, '-')}`;
1627
+ await copyFile(target, backup);
1628
+ process.stdout.write(`Backup: ${backup}\n`);
1629
+ } catch (error) {
1630
+ if (error.code !== 'ENOENT') throw error;
1631
+ backup = undefined;
1632
+ }
1633
+ const options = { label: 'claude mcp', timeout: 60_000, env: process.env };
1634
+ const addJson = entry => ['mcp', 'add-json', '--scope', 'user', SERVER, JSON.stringify(entry)];
1635
+ if (preview.existing === undefined) {
1636
+ await run(claude, addJson(preview.desired), options);
1637
+ return;
1638
+ }
1639
+ await run(claude, ['mcp', 'remove', '--scope', 'user', SERVER], options);
1640
+ try {
1641
+ await run(claude, addJson(preview.desired), options);
1642
+ } catch (error) {
1643
+ let restored;
1644
+ try {
1645
+ await run(claude, addJson(preview.existing), options);
1646
+ restored = 'the previous entry was re-registered';
1647
+ } catch (restoreError) {
1648
+ restored = `re-registering the previous entry also failed (${restoreError.message})`;
1649
+ }
1650
+ throw new Error(
1651
+ `${error.message}\nThe previous user-scope frontier entry had already been removed with \`claude mcp remove\`; ${restored}. ` +
1652
+ (backup === undefined ? 'No backup was made.' : `Backup of ${target}: ${backup}`),
1653
+ { cause: error },
1654
+ );
1655
+ }
1656
+ }
1657
+
1658
+ // On Windows, PATH lookup can resolve the npm `claude.cmd` shim. Spawning a
1659
+ // batch file without a shell fails with EINVAL on current Node, and through
1660
+ // cmd.exe the JSON argument is re-parsed, so refuse rather than use a shell.
1661
+ function assertSpawnableClaude(claude, desired, platform) {
1662
+ if (platform !== 'win32' || !/\.(cmd|bat)$/i.test(claude)) return;
1663
+ throw new Error(
1664
+ `Claude Code CLI resolved to the batch shim ${claude}, which setup cannot pass a JSON argument through safely. ` +
1665
+ 'Rerun with --claude pointing at claude.exe, or register the entry yourself in PowerShell:\n' +
1666
+ manualClaudeCommand(desired, platform),
1667
+ );
1668
+ }
1669
+
1670
+ // Windows PowerShell 5.1 passes embedded double quotes to native programs
1671
+ // unescaped, so a JSON argument arrives mangled; the Windows form carries no
1672
+ // JSON and single-quotes every word PowerShell-style.
1673
+ function manualClaudeCommand(desired, platform) {
1674
+ if (platform === 'win32') {
1675
+ const words = [desired.command, ...(desired.args || [])].map(
1676
+ // PowerShell also ends a single-quoted string at U+2018..U+201B.
1677
+ word => `'${String(word).replace(/['\u2018\u2019\u201A\u201B]/g, '$&$&')}'`,
1678
+ );
1679
+ return `claude mcp add --scope user ${SERVER} -- ${words.join(' ')}`;
1680
+ }
1681
+ const json = `'${JSON.stringify(desired).replace(/'/g, "'\\''")}'`;
1682
+ return `claude mcp add-json --scope user ${SERVER} ${json}`;
1683
+ }
1684
+
1685
+ // Which file each client reads for MCP servers. Claude Code and Cursor
1686
+ // locations are the ones those clients document; .agents/mcp.json is read by
1687
+ // neither and reaches them only through a symlink at one of their paths.
1688
+ // Codex is reported but never judged: only its user scope is verified.
1689
+ const VERIFIED_CLIENTS = new Set(['Claude Code', 'Cursor']);
1690
+
1691
+ function clientConfigLocations(root, home) {
1692
+ return [
1693
+ {
1694
+ client: 'Claude Code',
1695
+ command: 'claude',
1696
+ scope: 'project',
1697
+ repo: true,
1698
+ file: path.join(root, '.mcp.json'),
1699
+ },
1700
+ {
1701
+ client: 'Claude Code',
1702
+ command: 'claude',
1703
+ scope: 'user',
1704
+ file: path.join(home, '.claude.json'),
1705
+ claude: 'user',
1706
+ },
1707
+ {
1708
+ client: 'Claude Code',
1709
+ command: 'claude',
1710
+ scope: 'local',
1711
+ file: path.join(home, '.claude.json'),
1712
+ claude: 'local',
1713
+ },
1714
+ {
1715
+ client: 'Cursor',
1716
+ command: 'cursor',
1717
+ scope: 'project',
1718
+ repo: true,
1719
+ file: path.join(root, '.cursor', 'mcp.json'),
1720
+ },
1721
+ {
1722
+ client: 'Cursor',
1723
+ command: 'cursor',
1724
+ scope: 'user',
1725
+ file: path.join(home, '.cursor', 'mcp.json'),
1726
+ },
1727
+ {
1728
+ client: 'Codex',
1729
+ command: 'codex',
1730
+ scope: 'user',
1731
+ file: path.join(home, '.codex', 'config.toml'),
1732
+ toml: true,
1733
+ },
1734
+ ];
1735
+ }
1736
+
1737
+ const UNREAD_CANDIDATES = [path.join('.agents', 'mcp.json')];
1738
+ const FAILED_STATES = new Set([
1739
+ 'broken symlink',
1740
+ 'unreadable',
1741
+ 'invalid JSON',
1742
+ 'not a JSON object',
1743
+ 'frontier entry has no command',
1744
+ ]);
1745
+
1746
+ async function doctor(start, environment = process.env, platform = process.platform) {
1747
+ const root = await doctorRoot(path.resolve(start));
1748
+ const home = homeDirectory(environment);
1749
+ const commands = ['claude', 'cursor', 'codex'];
1750
+ const found = await Promise.all(
1751
+ commands.map(command =>
1752
+ firstAccessible(commandCandidatesOnPath(command, environment, platform)),
1753
+ ),
1754
+ );
1755
+ const installed = Object.fromEntries(
1756
+ commands.map((command, index) => [command, found[index] !== undefined]),
1757
+ );
1758
+ const locations = clientConfigLocations(root, home);
1759
+ const results = await Promise.all(
1760
+ locations.map(async location => ({ location, ...(await inspectConfig(location, root)) })),
1761
+ );
1762
+ const lines = [`Repository: ${root}`];
1763
+ const readTargets = new Map();
1764
+ const reachable = { 'Claude Code': [], Cursor: [], Codex: [] };
1765
+ let repoDeclares = false;
1766
+ const readFailures = { 'Claude Code': new Map(), Cursor: new Map(), Codex: new Map() };
1767
+ for (const result of results) {
1768
+ const { location } = result;
1769
+ if (result.real !== undefined && !readTargets.has(result.real))
1770
+ readTargets.set(result.real, location.file);
1771
+ const via = result.target !== undefined ? ` (-> ${result.target})` : '';
1772
+ lines.push(
1773
+ ` ${location.client} ${location.scope}: ${location.file}${via} — ${describe(result)}; read by ${location.client} (${installed[location.command] ? 'installed' : 'not on PATH'})`,
1774
+ );
1775
+ if (result.declared) reachable[location.client].push(location.scope);
1776
+ if (location.repo && (result.declared || result.others.length > 0)) repoDeclares = true;
1777
+ if (FAILED_STATES.has(result.state) && VERIFIED_CLIENTS.has(location.client))
1778
+ readFailures[location.client].set(location.file, result.state);
1779
+ }
1780
+ lines.push(
1781
+ ` Codex project scope: not verified; this check does not know whether Codex reads ${path.join('.agents', 'mcp.json')} or ${path.join('.codex', 'config.toml')} in a repository.`,
1782
+ );
1783
+ const candidates = await Promise.all(
1784
+ UNREAD_CANDIDATES.map(async relative => {
1785
+ const file = path.join(root, relative);
1786
+ return { file, result: await inspectConfig({ file }, root) };
1787
+ }),
1788
+ );
1789
+ for (const { file, result } of candidates) {
1790
+ if (result.state === 'absent') continue;
1791
+ if (result.declared || result.others.length > 0) repoDeclares = true;
1792
+ const via = result.target !== undefined ? ` (-> ${result.target})` : '';
1793
+ let reach = '';
1794
+ if (result.declared) {
1795
+ const reader = result.real === undefined ? undefined : readTargets.get(result.real);
1796
+ reach =
1797
+ reader === undefined
1798
+ ? '; Claude Code and Cursor do not read this path (Codex: not verified)'
1799
+ : `; reached through the symlink at ${reader}`;
1800
+ }
1801
+ lines.push(` ${file}${via} — ${describe(result)}${reach}`);
1802
+ }
1803
+ const failing = [];
1804
+ lines.push('Summary:');
1805
+ for (const client of ['Claude Code', 'Cursor', 'Codex']) {
1806
+ const command = locations.find(location => location.client === client).command;
1807
+ const state = installed[command] ? 'installed' : 'not on PATH';
1808
+ const scopes = reachable[client];
1809
+ const note = client === 'Codex' ? ' (user scope only; project scope not verified)' : '';
1810
+ lines.push(
1811
+ scopes.length > 0
1812
+ ? ` ${client} (${state}): frontier declared in ${scopes.join(', ')} scope${note}`
1813
+ : ` ${client} (${state}): no file checked here declares frontier${note}`,
1814
+ );
1815
+ if (
1816
+ VERIFIED_CLIENTS.has(client) &&
1817
+ installed[command] &&
1818
+ scopes.length === 0 &&
1819
+ (repoDeclares || readFailures[client].size > 0)
1820
+ )
1821
+ failing.push(client);
1822
+ }
1823
+ if (reachable['Claude Code'].includes('project'))
1824
+ lines.push(
1825
+ ' Claude Code asks once per project before starting a .mcp.json server; check `claude mcp list` for Pending approval.',
1826
+ );
1827
+ const failed = failing.length > 0;
1828
+ for (const client of failing)
1829
+ for (const [file, state] of readFailures[client])
1830
+ lines.push(
1831
+ state === 'frontier entry has no command'
1832
+ ? ` ${file}: frontier entry has no command; ${client} reads it.`
1833
+ : ` ${file} cannot be ${state === 'invalid JSON' || state === 'not a JSON object' ? 'parsed' : 'read'}; ${client} reads it.`,
1834
+ );
1835
+ if (failed && repoDeclares)
1836
+ lines.push(
1837
+ ` This repository expects frontier, but ${failing.join(' and ')} would start without it. Declare it in a file listed above or symlink one to the repository's entry.`,
1838
+ );
1839
+ return { text: `${lines.join('\n')}\n`, reachable, failing, failed, installed };
1840
+ }
1841
+
1842
+ function describe(result) {
1843
+ const parts = [];
1844
+ if (result.state === 'declares') parts.push('declares frontier');
1845
+ else if (result.state === 'present') parts.push('no frontier entry');
1846
+ else
1847
+ parts.push(result.detail === undefined ? result.state : `${result.state} (${result.detail})`);
1848
+ for (const key of result.others)
1849
+ parts.push(`runs frontier-mcp under key "${key}", not "frontier"`);
1850
+ return parts.join('; ');
1851
+ }
1852
+
1853
+ async function doctorRoot(start, current = start) {
1854
+ const markers = await Promise.all(
1855
+ ['.scratch', '.git'].map(async marker => {
1856
+ try {
1857
+ const info = await stat(path.join(current, marker));
1858
+ return marker === '.git' || info.isDirectory();
1859
+ } catch {
1860
+ return false;
1861
+ }
1862
+ }),
1863
+ );
1864
+ if (markers.some(Boolean)) return current;
1865
+ const parent = path.dirname(current);
1866
+ return parent === current ? start : doctorRoot(start, parent);
1867
+ }
1868
+
1869
+ async function realpathOrUndefined(file) {
1870
+ try {
1871
+ return await realpath(file);
1872
+ } catch {
1873
+ return undefined;
1874
+ }
1875
+ }
1876
+
1877
+ async function inspectConfig(location, root) {
1878
+ const base = { declared: false, others: [] };
1879
+ let target;
1880
+ try {
1881
+ if ((await lstat(location.file)).isSymbolicLink()) target = await readlink(location.file);
1882
+ } catch (error) {
1883
+ if (error.code === 'ENOENT') return { ...base, state: 'absent' };
1884
+ return { ...base, state: 'unreadable', detail: error.code };
1885
+ }
1886
+ const real = await realpathOrUndefined(location.file);
1887
+ const linked = { ...base, target, real };
1888
+ if (target !== undefined && real === undefined) return { ...linked, state: 'broken symlink' };
1889
+ let source;
1890
+ try {
1891
+ source = await readFile(location.file, 'utf8');
1892
+ } catch (error) {
1893
+ return { ...linked, state: 'unreadable', detail: error.code };
1894
+ }
1895
+ if (location.toml) {
1896
+ const declared = /^\s*\[\s*mcp_servers\s*\.\s*(?:frontier|"frontier"|'frontier')\s*\]/m.test(
1897
+ source,
1898
+ );
1899
+ return { ...linked, state: declared ? 'declares' : 'present', declared };
1900
+ }
1901
+ let config;
1902
+ try {
1903
+ config = JSON.parse(source);
1904
+ } catch {
1905
+ return { ...linked, state: 'invalid JSON' };
1906
+ }
1907
+ if (!isObject(config)) return { ...linked, state: 'not a JSON object' };
1908
+ let servers = config.mcpServers;
1909
+ if (location.claude === 'local') {
1910
+ // Claude Code keys local scope by project path; accept either spelling.
1911
+ const projects = isObject(config.projects) ? config.projects : {};
1912
+ const realRoot = await realpathOrUndefined(root);
1913
+ const project = projects[root] ?? (realRoot === undefined ? undefined : projects[realRoot]);
1914
+ servers = isObject(project) ? project.mcpServers : undefined;
1915
+ }
1916
+ if (!isObject(servers)) return { ...linked, state: 'present' };
1917
+ const entry = servers[SERVER];
1918
+ const declared =
1919
+ isObject(entry) && (typeof entry.command === 'string' || typeof entry.url === 'string');
1920
+ const others = Object.keys(servers).filter(
1921
+ key => key !== SERVER && runsFrontierMcp(servers[key]),
1922
+ );
1923
+ const state = declared
1924
+ ? 'declares'
1925
+ : entry === undefined
1926
+ ? 'present'
1927
+ : 'frontier entry has no command';
1928
+ return { ...linked, state, declared, others };
1929
+ }
1930
+
1931
+ function runsFrontierMcp(entry) {
1932
+ if (!isObject(entry)) return false;
1933
+ const words = [entry.command, ...(Array.isArray(entry.args) ? entry.args : [])];
1934
+ return words.some(
1935
+ word => typeof word === 'string' && /(^|[\\/@\s])frontier-mcp(@|$|[\\/\s])/.test(word),
1936
+ );
1937
+ }
1938
+
1511
1939
  // The check script imports these private helpers to force a fingerprint race
1512
1940
  // and a broken protocol without adding command-line switches for test cases.
1513
1941
  module.exports = {
1514
1942
  testing: {
1943
+ applyClaudeCode,
1944
+ assertSpawnableClaude,
1515
1945
  applyConfig,
1516
1946
  cursorConfigPath,
1947
+ doctor,
1948
+ manualClaudeCommand,
1949
+ prepareClaudeCode,
1517
1950
  discoverManagedRuntimes,
1518
1951
  durableLaunch,
1519
1952
  prepareConfig,