@intutic/sync-daemon 2.1.0 → 2.2.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 (179) hide show
  1. package/dist/agentReporter.d.ts +1 -0
  2. package/dist/agentReporter.d.ts.map +1 -1
  3. package/dist/configReader.d.ts +44 -22
  4. package/dist/configReader.d.ts.map +1 -1
  5. package/dist/configReader.js +70 -44
  6. package/dist/configReader.js.map +1 -1
  7. package/dist/configWriter.d.ts +29 -41
  8. package/dist/configWriter.d.ts.map +1 -1
  9. package/dist/configWriter.js +26 -637
  10. package/dist/configWriter.js.map +1 -1
  11. package/dist/disconnect/harnesses.d.ts +31 -0
  12. package/dist/disconnect/harnesses.d.ts.map +1 -0
  13. package/dist/disconnect/harnesses.js +728 -0
  14. package/dist/disconnect/harnesses.js.map +1 -0
  15. package/dist/disconnect/index.d.ts +25 -0
  16. package/dist/disconnect/index.d.ts.map +1 -0
  17. package/dist/disconnect/index.js +82 -0
  18. package/dist/disconnect/index.js.map +1 -0
  19. package/dist/disconnect/mcp.d.ts +60 -0
  20. package/dist/disconnect/mcp.d.ts.map +1 -0
  21. package/dist/disconnect/mcp.js +283 -0
  22. package/dist/disconnect/mcp.js.map +1 -0
  23. package/dist/disconnect/originals.d.ts +88 -0
  24. package/dist/disconnect/originals.d.ts.map +1 -0
  25. package/dist/disconnect/originals.js +257 -0
  26. package/dist/disconnect/originals.js.map +1 -0
  27. package/dist/disconnect/plan.d.ts +175 -0
  28. package/dist/disconnect/plan.d.ts.map +1 -0
  29. package/dist/disconnect/plan.js +580 -0
  30. package/dist/disconnect/plan.js.map +1 -0
  31. package/dist/disconnect/recognise.d.ts +43 -0
  32. package/dist/disconnect/recognise.d.ts.map +1 -0
  33. package/dist/disconnect/recognise.js +98 -0
  34. package/dist/disconnect/recognise.js.map +1 -0
  35. package/dist/disconnect/tomlLines.d.ts +39 -0
  36. package/dist/disconnect/tomlLines.d.ts.map +1 -0
  37. package/dist/disconnect/tomlLines.js +105 -0
  38. package/dist/disconnect/tomlLines.js.map +1 -0
  39. package/dist/harness/aiderConfigMerger.d.ts +1 -0
  40. package/dist/harness/aiderConfigMerger.d.ts.map +1 -1
  41. package/dist/harness/aiderConfigMerger.js +4 -1
  42. package/dist/harness/aiderConfigMerger.js.map +1 -1
  43. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  44. package/dist/harness/antigravityHooks.js +2 -0
  45. package/dist/harness/antigravityHooks.js.map +1 -1
  46. package/dist/harness/claudeCodeHooks.d.ts +1 -2
  47. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  48. package/dist/harness/claudeCodeHooks.js +19 -6
  49. package/dist/harness/claudeCodeHooks.js.map +1 -1
  50. package/dist/harness/claudeDesktopHooks.d.ts +3 -0
  51. package/dist/harness/claudeDesktopHooks.d.ts.map +1 -1
  52. package/dist/harness/claudeDesktopHooks.js +3 -1
  53. package/dist/harness/claudeDesktopHooks.js.map +1 -1
  54. package/dist/harness/claudeProjectApproval.d.ts +53 -0
  55. package/dist/harness/claudeProjectApproval.d.ts.map +1 -0
  56. package/dist/harness/claudeProjectApproval.js +159 -0
  57. package/dist/harness/claudeProjectApproval.js.map +1 -0
  58. package/dist/harness/clineHooks.d.ts.map +1 -1
  59. package/dist/harness/clineHooks.js +3 -0
  60. package/dist/harness/clineHooks.js.map +1 -1
  61. package/dist/harness/codexConfigMerger.d.ts.map +1 -1
  62. package/dist/harness/codexConfigMerger.js +4 -0
  63. package/dist/harness/codexConfigMerger.js.map +1 -1
  64. package/dist/harness/codexHooks.d.ts.map +1 -1
  65. package/dist/harness/codexHooks.js +3 -0
  66. package/dist/harness/codexHooks.js.map +1 -1
  67. package/dist/harness/continueConfigMerger.d.ts.map +1 -1
  68. package/dist/harness/continueConfigMerger.js +4 -0
  69. package/dist/harness/continueConfigMerger.js.map +1 -1
  70. package/dist/harness/continueHooks.d.ts.map +1 -1
  71. package/dist/harness/continueHooks.js +3 -0
  72. package/dist/harness/continueHooks.js.map +1 -1
  73. package/dist/harness/cursorHooks.d.ts.map +1 -1
  74. package/dist/harness/cursorHooks.js +3 -0
  75. package/dist/harness/cursorHooks.js.map +1 -1
  76. package/dist/harness/dshHooks.d.ts.map +1 -1
  77. package/dist/harness/dshHooks.js +7 -0
  78. package/dist/harness/dshHooks.js.map +1 -1
  79. package/dist/harness/gateBody.d.ts +11 -2
  80. package/dist/harness/gateBody.d.ts.map +1 -1
  81. package/dist/harness/gateBody.js +33 -6
  82. package/dist/harness/gateBody.js.map +1 -1
  83. package/dist/harness/githubCopilotHooks.d.ts.map +1 -1
  84. package/dist/harness/githubCopilotHooks.js +4 -9
  85. package/dist/harness/githubCopilotHooks.js.map +1 -1
  86. package/dist/harness/gooseHooks.d.ts +15 -0
  87. package/dist/harness/gooseHooks.d.ts.map +1 -1
  88. package/dist/harness/gooseHooks.js +55 -43
  89. package/dist/harness/gooseHooks.js.map +1 -1
  90. package/dist/harness/grokHooks.d.ts.map +1 -1
  91. package/dist/harness/grokHooks.js +9 -12
  92. package/dist/harness/grokHooks.js.map +1 -1
  93. package/dist/harness/hermesHooks.d.ts.map +1 -1
  94. package/dist/harness/hermesHooks.js +7 -2
  95. package/dist/harness/hermesHooks.js.map +1 -1
  96. package/dist/harness/holdRedaction.d.ts +4 -1
  97. package/dist/harness/holdRedaction.d.ts.map +1 -1
  98. package/dist/harness/holdRedaction.js +32 -8
  99. package/dist/harness/holdRedaction.js.map +1 -1
  100. package/dist/harness/jetbrainsXmlConfig.d.ts +20 -0
  101. package/dist/harness/jetbrainsXmlConfig.d.ts.map +1 -1
  102. package/dist/harness/jetbrainsXmlConfig.js +39 -18
  103. package/dist/harness/jetbrainsXmlConfig.js.map +1 -1
  104. package/dist/harness/mcpAutoWrite.d.ts +39 -17
  105. package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
  106. package/dist/harness/mcpAutoWrite.js +189 -71
  107. package/dist/harness/mcpAutoWrite.js.map +1 -1
  108. package/dist/harness/museHooks.d.ts.map +1 -1
  109. package/dist/harness/museHooks.js +6 -2
  110. package/dist/harness/museHooks.js.map +1 -1
  111. package/dist/harness/openCodeHooks.d.ts.map +1 -1
  112. package/dist/harness/openCodeHooks.js +2 -5
  113. package/dist/harness/openCodeHooks.js.map +1 -1
  114. package/dist/harness/openWebuiHooks.d.ts.map +1 -1
  115. package/dist/harness/openWebuiHooks.js +6 -2
  116. package/dist/harness/openWebuiHooks.js.map +1 -1
  117. package/dist/harness/openclawHooks.d.ts +5 -0
  118. package/dist/harness/openclawHooks.d.ts.map +1 -1
  119. package/dist/harness/openclawHooks.js +3 -1
  120. package/dist/harness/openclawHooks.js.map +1 -1
  121. package/dist/harness/openhandsHooks.d.ts +13 -1
  122. package/dist/harness/openhandsHooks.d.ts.map +1 -1
  123. package/dist/harness/openhandsHooks.js +106 -56
  124. package/dist/harness/openhandsHooks.js.map +1 -1
  125. package/dist/harness/piHooks.d.ts.map +1 -1
  126. package/dist/harness/piHooks.js +3 -0
  127. package/dist/harness/piHooks.js.map +1 -1
  128. package/dist/harness/rooCodeHooks.d.ts.map +1 -1
  129. package/dist/harness/rooCodeHooks.js +5 -10
  130. package/dist/harness/rooCodeHooks.js.map +1 -1
  131. package/dist/harness/windsurfHooks.d.ts +18 -2
  132. package/dist/harness/windsurfHooks.d.ts.map +1 -1
  133. package/dist/harness/windsurfHooks.js +37 -7
  134. package/dist/harness/windsurfHooks.js.map +1 -1
  135. package/dist/harness/windsurfJetBrainsProxy.d.ts +1 -1
  136. package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -1
  137. package/dist/harness/windsurfJetBrainsProxy.js +5 -1
  138. package/dist/harness/windsurfJetBrainsProxy.js.map +1 -1
  139. package/dist/index.d.ts +9 -12
  140. package/dist/index.d.ts.map +1 -1
  141. package/dist/index.js +8 -10
  142. package/dist/index.js.map +1 -1
  143. package/dist/lib/decisionsDigest.d.ts +1 -1
  144. package/dist/lib/decisionsDigest.js +1 -1
  145. package/dist/lib/policySnapshot.d.ts +18 -0
  146. package/dist/lib/policySnapshot.d.ts.map +1 -1
  147. package/dist/lib/policySnapshot.js +142 -7
  148. package/dist/lib/policySnapshot.js.map +1 -1
  149. package/dist/sessionReporter.d.ts +2 -2
  150. package/dist/sessionReporter.js +2 -2
  151. package/dist/syncCycle.d.ts +142 -0
  152. package/dist/syncCycle.d.ts.map +1 -0
  153. package/dist/syncCycle.js +315 -0
  154. package/dist/syncCycle.js.map +1 -0
  155. package/dist/trajectoryMonitor.d.ts +1 -1
  156. package/dist/trajectoryMonitor.d.ts.map +1 -1
  157. package/dist/trajectoryMonitor.js +1 -1
  158. package/dist/trajectoryMonitor.js.map +1 -1
  159. package/dist/watcher/settingsGuard.d.ts +4 -1
  160. package/dist/watcher/settingsGuard.d.ts.map +1 -1
  161. package/dist/watcher/settingsGuard.js +28 -3
  162. package/dist/watcher/settingsGuard.js.map +1 -1
  163. package/package.json +5 -5
  164. package/dist/hashReporter.d.ts +0 -33
  165. package/dist/hashReporter.d.ts.map +0 -1
  166. package/dist/hashReporter.js +0 -69
  167. package/dist/hashReporter.js.map +0 -1
  168. package/dist/integrityStore.d.ts +0 -35
  169. package/dist/integrityStore.d.ts.map +0 -1
  170. package/dist/integrityStore.js +0 -71
  171. package/dist/integrityStore.js.map +0 -1
  172. package/dist/lib/gitWorktrees.d.ts +0 -140
  173. package/dist/lib/gitWorktrees.d.ts.map +0 -1
  174. package/dist/lib/gitWorktrees.js +0 -191
  175. package/dist/lib/gitWorktrees.js.map +0 -1
  176. package/dist/syncLoop.d.ts +0 -172
  177. package/dist/syncLoop.d.ts.map +0 -1
  178. package/dist/syncLoop.js +0 -878
  179. package/dist/syncLoop.js.map +0 -1
@@ -4,7 +4,10 @@
4
4
  *
5
5
  * Injects and proxy-wraps MCP server entries in:
6
6
  * - Claude Code: ~/.claude.json (user-scope `mcpServers`, and the
7
- * local-scope `projects[<workspaceRoot>].mcpServers`)
7
+ * local-scope `projects[<workspaceRoot>].mcpServers`,
8
+ * where the approved servers of the project's
9
+ * `.mcp.json` also get wrapped shadows — the shared
10
+ * `.mcp.json` itself is only read)
8
11
  * - Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json
9
12
  * - Cursor (global): ~/Library/Application Support/Cursor/User/globalSettings.json
10
13
  * - Cursor (project): <workspaceRoot>/.cursor/mcp.json
@@ -22,8 +25,8 @@
22
25
  * That is 14 config paths across 11 `HarnessType` values (Cursor, Grok
23
26
  * Build and OpenCode each own two paths — global/project for Cursor and
24
27
  * OpenCode, user/project for Grok Build). `discoverMcpServers` below reads
25
- * all fourteen read-only, for reporting; the injectors above are the only
26
- * thing that writes.
28
+ * all fourteen read-only, for reporting, plus Claude Code's project
29
+ * `.mcp.json`; the injectors above are the only thing that writes.
27
30
  *
28
31
  * # Grok Build's compat-path overlap — dedup, not a bug
29
32
  *
@@ -69,15 +72,13 @@
69
72
  * comment used to describe as "left untouched": the entry is rewritten to
70
73
  * spawn the SAME proxy binary in bridge mode (`--remote-url`/
71
74
  * `--remote-transport`, headers riding in `INTUTIC_REMOTE_HEADERS` env, not
72
- * argv — `ps` visibility). `__intutic_original` preserves the entry's
73
- * pre-wrap `url`/`type`/`headers` so `classifyEntry` can report the TRUE
74
- * transport of what got wrapped, and so a wrapped entry could be unwrapped
75
- * later. See TECH_DEBT.md TD-354 for the historical decline this phase
76
- * supersedes.
75
+ * argv — `ps` visibility). `__intutic_original` keeps the whole pre-wrap
76
+ * entry, stdio or remote, so `classifyEntry` can report the TRUE transport
77
+ * of what got wrapped and `intutic disconnect` can put the entry back
78
+ * exactly.
77
79
  *
78
- * Continuous invariant, not one-shot: `injectMcpServer` is called once from
79
- * `intutic connect` (tools/cli) AND once per sync-loop iteration
80
- * (services/sync-daemon/src/syncLoop.ts), so a server a user adds to a harness
80
+ * Continuous invariant, not one-shot: `intutic connect` (tools/cli) calls
81
+ * `injectMcpServer` on every sync cycle, so a server a user adds to a harness
81
82
  * config after their first `connect` still gets wrapped on the next sync
82
83
  * cycle rather than staying invisible to governance forever. Running this
83
84
  * every ~30s only works because `writeJsonFile` below is write-if-changed —
@@ -96,6 +97,8 @@ import { createRequire } from 'node:module';
96
97
  import { isDeepStrictEqual } from 'node:util';
97
98
  import { createLogger } from '@intutic/logger';
98
99
  import { readJsonObjectForMerge } from './jsonMergeTarget.js';
100
+ import { projectServerApproval } from './claudeProjectApproval.js';
101
+ import { keepOriginal } from '../disconnect/originals.js';
99
102
  import { parseDocument, isMap } from 'yaml';
100
103
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
101
104
  const log = createLogger('sync-mcp-autowrite');
@@ -139,13 +142,14 @@ export function resolveProxyBin(workspaceRoot) {
139
142
  // Shared by both the injectors (which write) and discoverMcpServers (which
140
143
  // only reads), so the two can never independently drift about where a
141
144
  // harness keeps its config.
142
- /** Claude Code keeps user- and local-scope MCP servers in `~/.claude.json`
143
- * (project-scope servers live in the repo's `.mcp.json`, which is shared
144
- * with the team and is not rewritten here). */
145
+ /** Claude Code keeps user- and local-scope MCP servers in `~/.claude.json`.
146
+ * Project-scope servers live in the repo's `.mcp.json`, which is shared with
147
+ * the team and never rewritten here; they are governed through local-scope
148
+ * shadows instead (see {@link shadowProjectServers}). */
145
149
  function claudeCodeConfigPath() {
146
150
  return node_path.join(node_os.homedir(), '.claude.json');
147
151
  }
148
- function claudeDesktopConfigPath() {
152
+ export function claudeDesktopConfigPath() {
149
153
  const home = node_os.homedir();
150
154
  if (process.platform === 'darwin') {
151
155
  return node_path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
@@ -155,7 +159,7 @@ function claudeDesktopConfigPath() {
155
159
  }
156
160
  return node_path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
157
161
  }
158
- function cursorGlobalConfigPath() {
162
+ export function cursorGlobalConfigPath() {
159
163
  const home = node_os.homedir();
160
164
  if (process.platform === 'darwin') {
161
165
  return node_path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalSettings.json');
@@ -171,22 +175,22 @@ function cursorProjectConfigPath(workspaceRoot) {
171
175
  function clineConfigPath(workspaceRoot) {
172
176
  return node_path.join(workspaceRoot, '.cline', 'mcp.json');
173
177
  }
174
- function windsurfConfigPath() {
178
+ export function windsurfConfigPath() {
175
179
  return node_path.join(node_os.homedir(), '.codeium', 'windsurf', 'mcp_config.json');
176
180
  }
177
- function continueConfigPath() {
181
+ export function continueConfigPath() {
178
182
  return node_path.join(node_os.homedir(), '.continue', 'config.json');
179
183
  }
180
- function gooseConfigPath() {
184
+ export function gooseConfigPath() {
181
185
  return node_path.join(node_os.homedir(), '.config', 'goose', 'config.yaml');
182
186
  }
183
187
  function openHandsConfigPath(workspaceRoot) {
184
188
  return node_path.join(workspaceRoot, '.openhands', 'mcp.json');
185
189
  }
186
- function museConfigPath() {
190
+ export function museConfigPath() {
187
191
  return node_path.join(node_os.homedir(), '.config', 'muse', 'settings.json');
188
192
  }
189
- function grokUserConfigPath() {
193
+ export function grokUserConfigPath() {
190
194
  return node_path.join(node_os.homedir(), '.grok', 'config.toml');
191
195
  }
192
196
  function grokProjectConfigPath(workspaceRoot) {
@@ -195,7 +199,7 @@ function grokProjectConfigPath(workspaceRoot) {
195
199
  /** OpenCode's global config: `$XDG_CONFIG_HOME/opencode/opencode.json`, which
196
200
  * is `~/.config/opencode/opencode.json` unless XDG_CONFIG_HOME is set (OpenCode
197
201
  * resolves it through xdg-basedir). Only the `.json` spelling — see `injectOpenCode`. */
198
- function openCodeGlobalConfigPath() {
202
+ export function openCodeGlobalConfigPath() {
199
203
  const base = process.env['XDG_CONFIG_HOME'] || node_path.join(node_os.homedir(), '.config');
200
204
  return node_path.join(base, 'opencode', 'opencode.json');
201
205
  }
@@ -227,6 +231,7 @@ function wrapWithProxy(entry, workspaceId, workspaceRoot, serverName) {
227
231
  if (entry.__intutic_wrapped)
228
232
  return entry;
229
233
  const proxyBin = resolveProxyBin(workspaceRoot);
234
+ const original = structuredClone(entry);
230
235
  if (typeof entry.command === 'string') {
231
236
  const originalArgs = entry.args ?? [];
232
237
  return {
@@ -244,16 +249,12 @@ function wrapWithProxy(entry, workspaceId, workspaceRoot, serverName) {
244
249
  INTUTIC_WORKSPACE_ID: workspaceId,
245
250
  },
246
251
  __intutic_wrapped: true,
252
+ __intutic_original: original,
247
253
  };
248
254
  }
249
255
  if (typeof entry.url === 'string') {
250
256
  const remoteTransport = entry.type?.toLowerCase() === 'sse' ? 'sse' : 'http';
251
257
  const hasHeaders = entry.headers !== undefined && Object.keys(entry.headers).length > 0;
252
- const original = { url: entry.url };
253
- if (entry.type !== undefined)
254
- original.type = entry.type;
255
- if (entry.headers !== undefined)
256
- original.headers = entry.headers;
257
258
  const env = {
258
259
  ...(entry.env ?? {}),
259
260
  INTUTIC_WORKSPACE_ID: workspaceId,
@@ -339,8 +340,8 @@ async function readJsonForWrite(filePath) {
339
340
  /**
340
341
  * Write JSON to disk, but only if the content actually changed.
341
342
  *
342
- * `injectMcpServer` now runs every sync-loop iteration (~every 30s, see
343
- * syncLoop.ts) instead of only once at `connect` time — re-running the same
343
+ * `injectMcpServer` runs every sync cycle (~every 30s, from `intutic
344
+ * connect`) instead of only once at startup — re-running the same
344
345
  * wrap against an already-wrapped, unchanged config must not touch the file,
345
346
  * or every cycle would churn the file's mtime and fire a spurious inotify /
346
347
  * FSEvents event for every harness config on every developer machine.
@@ -349,8 +350,12 @@ async function readJsonForWrite(filePath) {
349
350
  * serialized string — a naive string compare would report a false "changed"
350
351
  * on nothing more than JSON.stringify key-ordering differences between two
351
352
  * semantically identical objects.
353
+ *
354
+ * With `workspaceRoot`, the file's original is kept for `intutic disconnect`
355
+ * before the first write. `~/.claude.json` goes without: it is Claude Code's
356
+ * live state, never restored whole.
352
357
  */
353
- async function writeJsonFile(filePath, data) {
358
+ async function writeJsonFile(filePath, data, workspaceRoot) {
354
359
  let existing;
355
360
  let hasExisting;
356
361
  try {
@@ -363,6 +368,8 @@ async function writeJsonFile(filePath, data) {
363
368
  if (hasExisting && isDeepStrictEqual(existing, data)) {
364
369
  return;
365
370
  }
371
+ if (workspaceRoot !== undefined)
372
+ await keepOriginal(filePath, workspaceRoot);
366
373
  await node_fs.mkdir(node_path.dirname(filePath), { recursive: true });
367
374
  await node_fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
368
375
  }
@@ -378,14 +385,97 @@ async function injectClaudeCode(workspaceId, workspaceRoot) {
378
385
  const current = await readJsonForWrite(configPath);
379
386
  // User scope: available in every project.
380
387
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
381
- // Local scope: servers added with `claude mcp add` in this project.
388
+ // Local scope: servers added with `claude mcp add` in this project, then
389
+ // the shadows that govern its approved `.mcp.json` servers. A project Claude
390
+ // Code has never opened has no entry, and nothing approved to shadow.
382
391
  const project = current.projects?.[workspaceRoot];
383
- if (project?.mcpServers && Object.keys(project.mcpServers).length > 0) {
384
- project.mcpServers = wrapAllServers(project.mcpServers, workspaceId, workspaceRoot);
392
+ if (project) {
393
+ const local = wrapAllServers(project.mcpServers ?? {}, workspaceId, workspaceRoot);
394
+ const withShadows = shadowProjectServers(local, await readProjectScopeServers(workspaceRoot), await projectServerApproval(workspaceRoot, current), workspaceId, workspaceRoot);
395
+ if (project.mcpServers !== undefined || Object.keys(withShadows).length > 0)
396
+ project.mcpServers = withShadows;
385
397
  }
386
398
  await writeJsonFile(configPath, current);
387
399
  log.info({ action: 'claude_code_mcp_injected' }, 'Claude Code ~/.claude.json MCP servers updated');
388
400
  }
401
+ /** Where Claude Code keeps a project's shared, committed servers. */
402
+ function claudeCodeProjectMcpPath(workspaceRoot) {
403
+ return node_path.join(workspaceRoot, '.mcp.json');
404
+ }
405
+ /** The project's `.mcp.json` servers; empty when the file is missing or not a JSON object. */
406
+ async function readProjectScopeServers(workspaceRoot) {
407
+ const parsed = await readJsonFile(claudeCodeProjectMcpPath(workspaceRoot), {});
408
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
409
+ return {};
410
+ const servers = parsed.mcpServers;
411
+ return servers !== null && typeof servers === 'object' && !Array.isArray(servers) ? servers : {};
412
+ }
413
+ /**
414
+ * Why an approved project server cannot get a governed local-scope copy, or
415
+ * null when it can. Exported for discovery's reporting through
416
+ * {@link discoverMcpServers}.
417
+ *
418
+ * Expansion itself is not a reason: Claude Code expands `${VAR}` the same way
419
+ * at local scope as in `.mcp.json`, in `command`, `args`, `env`, `url` and
420
+ * `headers` (verified with Claude Code 2.1.233 — see the MCP governance
421
+ * guide), so a copy keeps references verbatim and writes no resolved value.
422
+ * A remote server is the exception. Its copy is a stdio entry that hands the
423
+ * url to the proxy as an argument and the headers through the environment,
424
+ * where Claude Code expands every variable; in a remote server's own `url`
425
+ * and `headers` it reads credential variables (its own API keys, cloud and
426
+ * proxy credentials) as empty, by a list it does not publish in full. A copy
427
+ * could therefore send a credential the original never would.
428
+ */
429
+ function shadowBlocker(entry) {
430
+ if (entry === null || typeof entry !== 'object')
431
+ return 'an entry the proxy cannot front';
432
+ if (entry.__intutic_wrapped)
433
+ return 'the entry in .mcp.json is already wrapped';
434
+ if (typeof entry.command === 'string')
435
+ return null;
436
+ if (typeof entry.url === 'string') {
437
+ const refs = [entry.url, ...Object.values(entry.headers ?? {})].some((v) => typeof v === 'string' && v.includes('${'));
438
+ return refs
439
+ ? 'its url or headers reference environment variables, which Claude Code expands with a credential filter a wrapped copy cannot keep'
440
+ : null;
441
+ }
442
+ return 'an entry the proxy cannot front';
443
+ }
444
+ /**
445
+ * Governs a project's `.mcp.json` servers without touching the file.
446
+ *
447
+ * `.mcp.json` is committed and shared with the team: wrapping it in place
448
+ * would write this machine's proxy path and workspace id into everyone's
449
+ * checkout. Claude Code gives a local-scope server precedence over a
450
+ * project-scope one of the same name, so each approved project server gets a
451
+ * wrapped copy at local scope, in this project's `~/.claude.json` entry,
452
+ * marked `__intutic_shadow_of: 'project'`. The rules:
453
+ *
454
+ * - A server the user added at local scope keeps its name; it is wrapped by
455
+ * the local-scope pass already and never replaced by a shadow.
456
+ * - A shadow follows its source: rewritten when the project entry changes,
457
+ * removed when the project entry, or its approval, goes away. Deleting the
458
+ * marked entries returns the project to exactly what `.mcp.json` says.
459
+ * - An entry whose shape the proxy cannot wrap is left to Claude Code as is.
460
+ * - Re-running on unchanged input produces the same map, so the
461
+ * write-if-changed in `writeJsonFile` writes nothing.
462
+ */
463
+ function shadowProjectServers(local, projectServers, approval, workspaceId, workspaceRoot) {
464
+ const result = {};
465
+ for (const [name, entry] of Object.entries(local)) {
466
+ if (entry.__intutic_shadow_of === 'project')
467
+ continue; // rebuilt below from the current source
468
+ result[name] = entry;
469
+ }
470
+ for (const [name, entry] of Object.entries(projectServers)) {
471
+ if (name === 'intutic' || name in result || !approval.approved(name))
472
+ continue;
473
+ if (shadowBlocker(entry) !== null)
474
+ continue;
475
+ result[name] = { ...wrapWithProxy(entry, workspaceId, workspaceRoot, name), __intutic_shadow_of: 'project' };
476
+ }
477
+ return result;
478
+ }
389
479
  // ─── Target: Claude Desktop ───────────────────────────────────────────────────
390
480
  async function injectClaudeDesktop(workspaceId, workspaceRoot) {
391
481
  const configPath = claudeDesktopConfigPath();
@@ -399,7 +489,7 @@ async function injectClaudeDesktop(workspaceId, workspaceRoot) {
399
489
  }
400
490
  const current = await readJsonForWrite(configPath);
401
491
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
402
- await writeJsonFile(configPath, current);
492
+ await writeJsonFile(configPath, current, workspaceRoot);
403
493
  log.info({ action: 'claude_desktop_mcp_injected' }, 'Claude Desktop config updated');
404
494
  }
405
495
  // ─── Target: Cursor (global + project) ───────────────────────────────────────
@@ -410,7 +500,7 @@ async function injectCursor(workspaceId, workspaceRoot) {
410
500
  await node_fs.access(node_path.dirname(globalPath));
411
501
  const current = await readJsonForWrite(globalPath);
412
502
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
413
- await writeJsonFile(globalPath, current);
503
+ await writeJsonFile(globalPath, current, workspaceRoot);
414
504
  log.info({ action: 'cursor_global_mcp_injected' }, 'Cursor globalSettings.json updated');
415
505
  }
416
506
  catch {
@@ -421,7 +511,7 @@ async function injectCursor(workspaceId, workspaceRoot) {
421
511
  try {
422
512
  const current = await readJsonForWrite(projectPath);
423
513
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
424
- await writeJsonFile(projectPath, current);
514
+ await writeJsonFile(projectPath, current, workspaceRoot);
425
515
  log.info({ action: 'cursor_project_mcp_injected' }, 'Cursor .cursor/mcp.json updated');
426
516
  }
427
517
  catch (err) {
@@ -434,7 +524,7 @@ async function injectCline(workspaceId, workspaceRoot) {
434
524
  try {
435
525
  const current = await readJsonForWrite(configPath);
436
526
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
437
- await writeJsonFile(configPath, current);
527
+ await writeJsonFile(configPath, current, workspaceRoot);
438
528
  log.info({ action: 'cline_mcp_injected' }, 'Cline .cline/mcp.json updated');
439
529
  }
440
530
  catch (err) {
@@ -448,7 +538,7 @@ async function injectWindsurf(workspaceId, workspaceRoot) {
448
538
  await node_fs.access(node_path.dirname(configPath));
449
539
  const current = await readJsonForWrite(configPath);
450
540
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
451
- await writeJsonFile(configPath, current);
541
+ await writeJsonFile(configPath, current, workspaceRoot);
452
542
  log.info({ action: 'windsurf_mcp_injected' }, 'Windsurf mcp_config.json updated');
453
543
  }
454
544
  catch {
@@ -469,14 +559,16 @@ async function injectContinue(workspaceId, workspaceRoot) {
469
559
  // Wrap existing non-intutic servers — routed through the same
470
560
  // wrapWithProxy used by every other harness, so the stdio-only guard and
471
561
  // --server-name threading stay in one place instead of two.
562
+ // The whole entry goes in, so the wrap's `__intutic_original` holds every
563
+ // key `intutic disconnect` has to put back.
472
564
  current.mcpServers = current.mcpServers.map((s) => {
473
- const wrapped = wrapWithProxy({ command: s.command, args: s.args, env: s.env, url: s.url, type: s.type, __intutic_wrapped: s.__intutic_wrapped }, workspaceId, workspaceRoot, s.name);
474
- return { name: s.name, ...wrapped };
565
+ const { name, ...entry } = s;
566
+ return { name, ...wrapWithProxy(entry, workspaceId, workspaceRoot, name) };
475
567
  });
476
568
  // Add Intutic MCP server
477
569
  const entry = buildIntuticMcpEntry(workspaceRoot);
478
570
  current.mcpServers.unshift({ name: 'intutic', ...entry });
479
- await writeJsonFile(configPath, current);
571
+ await writeJsonFile(configPath, current, workspaceRoot);
480
572
  log.info({ action: 'continue_mcp_injected' }, 'Continue ~/.continue/config.json updated');
481
573
  }
482
574
  catch {
@@ -512,6 +604,7 @@ async function injectGooseAppendOnly(configPath, existingYaml, workspaceId, prox
512
604
  // (see writeJsonFile's doc comment for why: this runs every sync cycle now).
513
605
  return;
514
606
  }
607
+ await keepOriginal(configPath, node_os.homedir());
515
608
  await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
516
609
  await node_fs.writeFile(configPath, text, 'utf-8');
517
610
  log.info({ action: 'goose_mcp_injected', mode: 'append_only_fallback' }, 'Goose config.yaml updated (append-only fallback)');
@@ -603,6 +696,7 @@ async function injectGoose(workspaceId, workspaceRoot) {
603
696
  // (see writeJsonFile's doc comment for why: this runs every sync cycle now).
604
697
  return;
605
698
  }
699
+ await keepOriginal(configPath, workspaceRoot);
606
700
  await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
607
701
  await node_fs.writeFile(configPath, doc.toString(), 'utf-8');
608
702
  log.info({ action: 'goose_mcp_injected', mode: 'yaml' }, 'Goose config.yaml updated (structural YAML edit)');
@@ -613,7 +707,7 @@ async function injectOpenHands(workspaceId, workspaceRoot) {
613
707
  try {
614
708
  const current = await readJsonForWrite(configPath);
615
709
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
616
- await writeJsonFile(configPath, current);
710
+ await writeJsonFile(configPath, current, workspaceRoot);
617
711
  log.info({ action: 'openhands_mcp_injected' }, 'OpenHands .openhands/mcp.json updated');
618
712
  }
619
713
  catch (err) {
@@ -644,7 +738,7 @@ async function injectMuse(workspaceId, workspaceRoot) {
644
738
  current.mcp_servers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcp_servers ?? {}) }, workspaceId, workspaceRoot);
645
739
  if (current.schema_version === undefined)
646
740
  current.schema_version = 1;
647
- await writeJsonFile(configPath, current);
741
+ await writeJsonFile(configPath, current, workspaceRoot);
648
742
  log.info({ action: 'muse_mcp_injected' }, 'Muse Code ~/.config/muse/settings.json mcp_servers updated');
649
743
  }
650
744
  /**
@@ -657,7 +751,7 @@ async function injectMuse(workspaceId, workspaceRoot) {
657
751
  * honest limit `injectGooseAppendOnly`/`discoverGooseLineScanFallback`
658
752
  * document for their own fallback.
659
753
  */
660
- async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin) {
754
+ async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin, workspaceRoot) {
661
755
  let text = existingToml;
662
756
  if (!text.includes('[mcp_servers.intutic]') && !text.includes('[mcp_servers."intutic"]')) {
663
757
  const block = [
@@ -669,6 +763,7 @@ async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId,
669
763
  }
670
764
  if (text === existingToml)
671
765
  return;
766
+ await keepOriginal(configPath, workspaceRoot);
672
767
  await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
673
768
  await node_fs.writeFile(configPath, text, 'utf-8');
674
769
  log.info({ action: 'grok_mcp_injected', mode: 'append_only_fallback' }, 'Grok config.toml updated (append-only fallback)');
@@ -711,7 +806,7 @@ async function injectGrokConfig(configPath, workspaceId, workspaceRoot) {
711
806
  }
712
807
  catch (err) {
713
808
  log.warn({ action: 'grok_toml_unparseable', path: configPath, err: err.message }, 'Grok config.toml did not parse as TOML — falling back to append-only text injection');
714
- await injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin);
809
+ await injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin, workspaceRoot);
715
810
  return;
716
811
  }
717
812
  const servers = doc.mcp_servers && typeof doc.mcp_servers === 'object' ? doc.mcp_servers : {};
@@ -736,6 +831,7 @@ async function injectGrokConfig(configPath, workspaceId, workspaceRoot) {
736
831
  return;
737
832
  }
738
833
  doc.mcp_servers = servers;
834
+ await keepOriginal(configPath, workspaceRoot);
739
835
  await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
740
836
  const tmp = configPath + '.intutic-tmp';
741
837
  await node_fs.writeFile(tmp, stringifyToml(doc), 'utf-8');
@@ -747,7 +843,7 @@ async function injectGrok(workspaceId, workspaceRoot) {
747
843
  await injectGrokConfig(grokUserConfigPath(), workspaceId, workspaceRoot);
748
844
  }
749
845
  /** The proxy binary path `resolveProxyBin` returns, in either of its two forms. */
750
- const PROXY_BIN_PATTERN = /(?:^|[\\/])(?:@intutic[\\/]mcp-governance-proxy|packages[\\/]mcp-proxy)[\\/]dist[\\/]index\.js$/;
846
+ export const PROXY_BIN_PATTERN = /(?:^|[\\/])(?:@intutic[\\/]mcp-governance-proxy|packages[\\/]mcp-proxy)[\\/]dist[\\/]index\.js$/;
751
847
  /**
752
848
  * Whether an OpenCode entry is already fronted by the governance proxy, read
753
849
  * from the command SHAPE — `["node", <proxy bin>, "--workspace-id", …]` —
@@ -858,6 +954,8 @@ async function injectOpenCodeConfig(configPath, workspaceId, workspaceRoot) {
858
954
  return;
859
955
  config['mcp'] = next;
860
956
  const indent = raw.match(/^([ \t]+)\S/m)?.[1] ?? 2;
957
+ // OpenCode entries cannot carry a marker, so disconnect needs the file as it was.
958
+ await keepOriginal(configPath, workspaceRoot);
861
959
  const tmp = configPath + '.intutic-tmp';
862
960
  await node_fs.writeFile(tmp, JSON.stringify(config, null, indent) + '\n', 'utf-8');
863
961
  await node_fs.rename(tmp, configPath);
@@ -910,15 +1008,31 @@ async function discoverJsonObjectHarness(harness, filePath) {
910
1008
  }
911
1009
  return out;
912
1010
  }
913
- /** Claude Code's `~/.claude.json`: the user-scope `mcpServers` map plus this
914
- * project's local-scope one under `projects[<workspaceRoot>]`. */
1011
+ /** Claude Code's servers as Claude Code resolves them: user scope
1012
+ * (`~/.claude.json`), project scope (the repo's `.mcp.json`) and local scope
1013
+ * (this project's entry in `~/.claude.json`), each narrower scope winning a
1014
+ * name — so a project server governed by a local-scope shadow reports as
1015
+ * wrapped. */
915
1016
  async function discoverClaudeCode(workspaceRoot) {
916
1017
  const filePath = claudeCodeConfigPath();
917
- if (!existsSync(filePath))
918
- return [];
919
- const current = await readJsonFile(filePath, {});
920
- const servers = { ...(current.projects?.[workspaceRoot]?.mcpServers ?? {}), ...(current.mcpServers ?? {}) };
921
- return Object.entries(servers).map(([name, entry]) => ({ server: name, harness: 'claude-code', ...classifyEntry(entry) }));
1018
+ const current = existsSync(filePath) ? await readJsonFile(filePath, {}) : {};
1019
+ const projectServers = await readProjectScopeServers(workspaceRoot);
1020
+ const local = current.projects?.[workspaceRoot]?.mcpServers ?? {};
1021
+ const servers = { ...(current.mcpServers ?? {}), ...projectServers, ...local };
1022
+ const approval = Object.keys(projectServers).length > 0 ? await projectServerApproval(workspaceRoot, current) : null;
1023
+ return Object.entries(servers).map(([name, entry]) => {
1024
+ const found = { server: name, harness: 'claude-code', ...classifyEntry(entry) };
1025
+ // A project server Claude Code will start, with no copy of ours in front of it.
1026
+ if (approval && name in projectServers && !(name in local) && approval.approved(name)) {
1027
+ const reason = shadowBlocker(projectServers[name]);
1028
+ if (reason)
1029
+ found.ungovernedReason = `Claude Code project server not governed: ${reason}`;
1030
+ }
1031
+ if (approval?.blockedReason && name in projectServers && !(name in local)) {
1032
+ found.ungovernedReason = `Claude Code project server not governed: ${approval.blockedReason}`;
1033
+ }
1034
+ return found;
1035
+ });
922
1036
  }
923
1037
  /** Muse Code's `~/.config/muse/settings.json` keeps servers under `mcp_servers`,
924
1038
  * not `mcpServers` — otherwise the same JSON-map shape `discoverJsonObjectHarness`
@@ -1151,32 +1265,36 @@ export async function discoverMcpServers(workspaceRoot) {
1151
1265
  *
1152
1266
  * Non-fatal: a failure in one harness does not prevent other harnesses from being updated.
1153
1267
  *
1154
- * Called from two places by design: once from `intutic connect` (tools/cli)
1155
- * for immediate effect, and once per sync-loop iteration
1156
- * (services/sync-daemon/src/syncLoop.ts) so it is a continuous invariant
1157
- * rather than a one-shot — a server a user adds after their first `connect`
1268
+ * Called by `intutic connect` (tools/cli) on every sync cycle, so it is a
1269
+ * continuous invariant rather than a one-shot — a server a user adds after their first `connect`
1158
1270
  * still gets wrapped on the next cycle. Safe to call every cycle because
1159
1271
  * `writeJsonFile` is write-if-changed: an already-wrapped, unchanged config
1160
1272
  * writes zero bytes.
1161
1273
  *
1162
1274
  * @param workspaceId - The workspace ID for policy lookups and event attribution.
1163
1275
  * @param workspaceRoot - Absolute path to the project workspace root.
1276
+ * @param options.skip - Harness ids to leave alone: the ones `intutic
1277
+ * disconnect --harness` took out.
1164
1278
  */
1165
- export async function injectMcpServer(workspaceRoot, workspaceId = 'unknown') {
1279
+ export async function injectMcpServer(workspaceRoot, workspaceId = 'unknown', options = {}) {
1166
1280
  log.info({ action: 'mcp_inject_start', workspaceRoot, workspaceId }, 'Starting MCP server injection');
1167
- await Promise.allSettled([
1168
- injectClaudeCode(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'claude-code' }, 'MCP injection failed')),
1169
- injectClaudeDesktop(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'claude-desktop' }, 'MCP injection failed')),
1170
- injectCursor(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'cursor' }, 'MCP injection failed')),
1171
- injectCline(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'cline' }, 'MCP injection failed')),
1172
- injectWindsurf(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'windsurf' }, 'MCP injection failed')),
1173
- injectContinue(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'continue' }, 'MCP injection failed')),
1174
- injectGoose(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'goose' }, 'MCP injection failed')),
1175
- injectOpenHands(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'openhands' }, 'MCP injection failed')),
1176
- injectMuse(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'muse-code' }, 'MCP injection failed')),
1177
- injectGrok(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'grok' }, 'MCP injection failed')),
1178
- injectOpenCode(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'opencode' }, 'MCP injection failed')),
1179
- ]);
1281
+ const targets = [
1282
+ ['claude-code', injectClaudeCode],
1283
+ ['claude-desktop', injectClaudeDesktop],
1284
+ ['cursor', injectCursor],
1285
+ ['cline', injectCline],
1286
+ ['windsurf', injectWindsurf],
1287
+ ['continue', injectContinue],
1288
+ ['goose', injectGoose],
1289
+ ['openhands', injectOpenHands],
1290
+ ['muse-code', injectMuse],
1291
+ ['grok', injectGrok],
1292
+ ['opencode', injectOpenCode],
1293
+ ];
1294
+ const skip = new Set(options.skip ?? []);
1295
+ await Promise.allSettled(targets
1296
+ .filter(([target]) => !skip.has(target))
1297
+ .map(([target, inject]) => inject(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target }, 'MCP injection failed'))));
1180
1298
  log.info({ action: 'mcp_inject_complete', workspaceRoot }, 'MCP server injection complete');
1181
1299
  }
1182
1300
  //# sourceMappingURL=mcpAutoWrite.js.map