@intutic/sync-daemon 2.0.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 (206) hide show
  1. package/dist/agentReporter.d.ts +5 -12
  2. package/dist/agentReporter.d.ts.map +1 -1
  3. package/dist/agentReporter.js +25 -4
  4. package/dist/agentReporter.js.map +1 -1
  5. package/dist/configReader.d.ts +44 -22
  6. package/dist/configReader.d.ts.map +1 -1
  7. package/dist/configReader.js +70 -44
  8. package/dist/configReader.js.map +1 -1
  9. package/dist/configWriter.d.ts +29 -41
  10. package/dist/configWriter.d.ts.map +1 -1
  11. package/dist/configWriter.js +28 -625
  12. package/dist/configWriter.js.map +1 -1
  13. package/dist/disconnect/harnesses.d.ts +31 -0
  14. package/dist/disconnect/harnesses.d.ts.map +1 -0
  15. package/dist/disconnect/harnesses.js +728 -0
  16. package/dist/disconnect/harnesses.js.map +1 -0
  17. package/dist/disconnect/index.d.ts +25 -0
  18. package/dist/disconnect/index.d.ts.map +1 -0
  19. package/dist/disconnect/index.js +82 -0
  20. package/dist/disconnect/index.js.map +1 -0
  21. package/dist/disconnect/mcp.d.ts +60 -0
  22. package/dist/disconnect/mcp.d.ts.map +1 -0
  23. package/dist/disconnect/mcp.js +283 -0
  24. package/dist/disconnect/mcp.js.map +1 -0
  25. package/dist/disconnect/originals.d.ts +88 -0
  26. package/dist/disconnect/originals.d.ts.map +1 -0
  27. package/dist/disconnect/originals.js +257 -0
  28. package/dist/disconnect/originals.js.map +1 -0
  29. package/dist/disconnect/plan.d.ts +175 -0
  30. package/dist/disconnect/plan.d.ts.map +1 -0
  31. package/dist/disconnect/plan.js +580 -0
  32. package/dist/disconnect/plan.js.map +1 -0
  33. package/dist/disconnect/recognise.d.ts +43 -0
  34. package/dist/disconnect/recognise.d.ts.map +1 -0
  35. package/dist/disconnect/recognise.js +98 -0
  36. package/dist/disconnect/recognise.js.map +1 -0
  37. package/dist/disconnect/tomlLines.d.ts +39 -0
  38. package/dist/disconnect/tomlLines.d.ts.map +1 -0
  39. package/dist/disconnect/tomlLines.js +105 -0
  40. package/dist/disconnect/tomlLines.js.map +1 -0
  41. package/dist/harness/aiderConfigMerger.d.ts +42 -5
  42. package/dist/harness/aiderConfigMerger.d.ts.map +1 -1
  43. package/dist/harness/aiderConfigMerger.js +113 -86
  44. package/dist/harness/aiderConfigMerger.js.map +1 -1
  45. package/dist/harness/antigravityHooks.d.ts +13 -8
  46. package/dist/harness/antigravityHooks.d.ts.map +1 -1
  47. package/dist/harness/antigravityHooks.js +53 -39
  48. package/dist/harness/antigravityHooks.js.map +1 -1
  49. package/dist/harness/claudeCodeHooks.d.ts +1 -2
  50. package/dist/harness/claudeCodeHooks.d.ts.map +1 -1
  51. package/dist/harness/claudeCodeHooks.js +61 -34
  52. package/dist/harness/claudeCodeHooks.js.map +1 -1
  53. package/dist/harness/claudeDesktopHooks.d.ts +3 -0
  54. package/dist/harness/claudeDesktopHooks.d.ts.map +1 -1
  55. package/dist/harness/claudeDesktopHooks.js +3 -1
  56. package/dist/harness/claudeDesktopHooks.js.map +1 -1
  57. package/dist/harness/claudeProjectApproval.d.ts +53 -0
  58. package/dist/harness/claudeProjectApproval.d.ts.map +1 -0
  59. package/dist/harness/claudeProjectApproval.js +159 -0
  60. package/dist/harness/claudeProjectApproval.js.map +1 -0
  61. package/dist/harness/clineHooks.d.ts +39 -9
  62. package/dist/harness/clineHooks.d.ts.map +1 -1
  63. package/dist/harness/clineHooks.js +142 -60
  64. package/dist/harness/clineHooks.js.map +1 -1
  65. package/dist/harness/codexConfigMerger.d.ts +38 -0
  66. package/dist/harness/codexConfigMerger.d.ts.map +1 -0
  67. package/dist/harness/codexConfigMerger.js +105 -0
  68. package/dist/harness/codexConfigMerger.js.map +1 -0
  69. package/dist/harness/codexHooks.d.ts +4 -2
  70. package/dist/harness/codexHooks.d.ts.map +1 -1
  71. package/dist/harness/codexHooks.js +14 -11
  72. package/dist/harness/codexHooks.js.map +1 -1
  73. package/dist/harness/continueConfigMerger.d.ts +46 -0
  74. package/dist/harness/continueConfigMerger.d.ts.map +1 -0
  75. package/dist/harness/continueConfigMerger.js +94 -0
  76. package/dist/harness/continueConfigMerger.js.map +1 -0
  77. package/dist/harness/continueHooks.d.ts +15 -25
  78. package/dist/harness/continueHooks.d.ts.map +1 -1
  79. package/dist/harness/continueHooks.js +25 -119
  80. package/dist/harness/continueHooks.js.map +1 -1
  81. package/dist/harness/cursorHooks.d.ts +33 -16
  82. package/dist/harness/cursorHooks.d.ts.map +1 -1
  83. package/dist/harness/cursorHooks.js +69 -25
  84. package/dist/harness/cursorHooks.js.map +1 -1
  85. package/dist/harness/dshHooks.d.ts +59 -52
  86. package/dist/harness/dshHooks.d.ts.map +1 -1
  87. package/dist/harness/dshHooks.js +152 -147
  88. package/dist/harness/dshHooks.js.map +1 -1
  89. package/dist/harness/gateBody.d.ts +11 -2
  90. package/dist/harness/gateBody.d.ts.map +1 -1
  91. package/dist/harness/gateBody.js +33 -6
  92. package/dist/harness/gateBody.js.map +1 -1
  93. package/dist/harness/gateKind.d.ts.map +1 -1
  94. package/dist/harness/gateKind.js +1 -0
  95. package/dist/harness/gateKind.js.map +1 -1
  96. package/dist/harness/githubCopilotHooks.d.ts.map +1 -1
  97. package/dist/harness/githubCopilotHooks.js +5 -10
  98. package/dist/harness/githubCopilotHooks.js.map +1 -1
  99. package/dist/harness/gooseHooks.d.ts +15 -0
  100. package/dist/harness/gooseHooks.d.ts.map +1 -1
  101. package/dist/harness/gooseHooks.js +55 -43
  102. package/dist/harness/gooseHooks.js.map +1 -1
  103. package/dist/harness/grokHooks.d.ts.map +1 -1
  104. package/dist/harness/grokHooks.js +14 -12
  105. package/dist/harness/grokHooks.js.map +1 -1
  106. package/dist/harness/hermesHooks.d.ts +9 -0
  107. package/dist/harness/hermesHooks.d.ts.map +1 -1
  108. package/dist/harness/hermesHooks.js +39 -33
  109. package/dist/harness/hermesHooks.js.map +1 -1
  110. package/dist/harness/holdRedaction.d.ts +4 -1
  111. package/dist/harness/holdRedaction.d.ts.map +1 -1
  112. package/dist/harness/holdRedaction.js +32 -8
  113. package/dist/harness/holdRedaction.js.map +1 -1
  114. package/dist/harness/jetbrainsXmlConfig.d.ts +20 -0
  115. package/dist/harness/jetbrainsXmlConfig.d.ts.map +1 -1
  116. package/dist/harness/jetbrainsXmlConfig.js +39 -18
  117. package/dist/harness/jetbrainsXmlConfig.js.map +1 -1
  118. package/dist/harness/jsonMergeTarget.d.ts +23 -0
  119. package/dist/harness/jsonMergeTarget.d.ts.map +1 -0
  120. package/dist/harness/jsonMergeTarget.js +50 -0
  121. package/dist/harness/jsonMergeTarget.js.map +1 -0
  122. package/dist/harness/mcpAutoWrite.d.ts +64 -20
  123. package/dist/harness/mcpAutoWrite.d.ts.map +1 -1
  124. package/dist/harness/mcpAutoWrite.js +429 -85
  125. package/dist/harness/mcpAutoWrite.js.map +1 -1
  126. package/dist/harness/museHooks.d.ts.map +1 -1
  127. package/dist/harness/museHooks.js +6 -2
  128. package/dist/harness/museHooks.js.map +1 -1
  129. package/dist/harness/n8nHooks.d.ts.map +1 -1
  130. package/dist/harness/n8nHooks.js +6 -7
  131. package/dist/harness/n8nHooks.js.map +1 -1
  132. package/dist/harness/openCodeHooks.d.ts +37 -6
  133. package/dist/harness/openCodeHooks.d.ts.map +1 -1
  134. package/dist/harness/openCodeHooks.js +103 -13
  135. package/dist/harness/openCodeHooks.js.map +1 -1
  136. package/dist/harness/openWebuiHooks.d.ts.map +1 -1
  137. package/dist/harness/openWebuiHooks.js +10 -5
  138. package/dist/harness/openWebuiHooks.js.map +1 -1
  139. package/dist/harness/openclawHooks.d.ts +5 -0
  140. package/dist/harness/openclawHooks.d.ts.map +1 -1
  141. package/dist/harness/openclawHooks.js +3 -1
  142. package/dist/harness/openclawHooks.js.map +1 -1
  143. package/dist/harness/openhandsHooks.d.ts +26 -1
  144. package/dist/harness/openhandsHooks.d.ts.map +1 -1
  145. package/dist/harness/openhandsHooks.js +160 -40
  146. package/dist/harness/openhandsHooks.js.map +1 -1
  147. package/dist/harness/piHooks.d.ts +17 -0
  148. package/dist/harness/piHooks.d.ts.map +1 -1
  149. package/dist/harness/piHooks.js +31 -29
  150. package/dist/harness/piHooks.js.map +1 -1
  151. package/dist/harness/protectedPaths.d.ts +64 -6
  152. package/dist/harness/protectedPaths.d.ts.map +1 -1
  153. package/dist/harness/protectedPaths.js +161 -11
  154. package/dist/harness/protectedPaths.js.map +1 -1
  155. package/dist/harness/rooCodeHooks.d.ts.map +1 -1
  156. package/dist/harness/rooCodeHooks.js +5 -10
  157. package/dist/harness/rooCodeHooks.js.map +1 -1
  158. package/dist/harness/windsurfHooks.d.ts +21 -3
  159. package/dist/harness/windsurfHooks.d.ts.map +1 -1
  160. package/dist/harness/windsurfHooks.js +96 -44
  161. package/dist/harness/windsurfHooks.js.map +1 -1
  162. package/dist/harness/windsurfJetBrainsProxy.d.ts +10 -7
  163. package/dist/harness/windsurfJetBrainsProxy.d.ts.map +1 -1
  164. package/dist/harness/windsurfJetBrainsProxy.js +22 -7
  165. package/dist/harness/windsurfJetBrainsProxy.js.map +1 -1
  166. package/dist/index.d.ts +12 -12
  167. package/dist/index.d.ts.map +1 -1
  168. package/dist/index.js +11 -10
  169. package/dist/index.js.map +1 -1
  170. package/dist/lib/decisionsDigest.d.ts +1 -1
  171. package/dist/lib/decisionsDigest.js +1 -1
  172. package/dist/lib/policySnapshot.d.ts +37 -0
  173. package/dist/lib/policySnapshot.d.ts.map +1 -1
  174. package/dist/lib/policySnapshot.js +170 -8
  175. package/dist/lib/policySnapshot.js.map +1 -1
  176. package/dist/sessionReporter.d.ts +2 -2
  177. package/dist/sessionReporter.js +2 -2
  178. package/dist/syncCycle.d.ts +142 -0
  179. package/dist/syncCycle.d.ts.map +1 -0
  180. package/dist/syncCycle.js +315 -0
  181. package/dist/syncCycle.js.map +1 -0
  182. package/dist/trajectoryMonitor.d.ts +1 -1
  183. package/dist/trajectoryMonitor.d.ts.map +1 -1
  184. package/dist/trajectoryMonitor.js +1 -1
  185. package/dist/trajectoryMonitor.js.map +1 -1
  186. package/dist/watcher/settingsGuard.d.ts +4 -1
  187. package/dist/watcher/settingsGuard.d.ts.map +1 -1
  188. package/dist/watcher/settingsGuard.js +42 -18
  189. package/dist/watcher/settingsGuard.js.map +1 -1
  190. package/package.json +6 -6
  191. package/dist/hashReporter.d.ts +0 -33
  192. package/dist/hashReporter.d.ts.map +0 -1
  193. package/dist/hashReporter.js +0 -69
  194. package/dist/hashReporter.js.map +0 -1
  195. package/dist/integrityStore.d.ts +0 -35
  196. package/dist/integrityStore.d.ts.map +0 -1
  197. package/dist/integrityStore.js +0 -71
  198. package/dist/integrityStore.js.map +0 -1
  199. package/dist/lib/gitWorktrees.d.ts +0 -140
  200. package/dist/lib/gitWorktrees.d.ts.map +0 -1
  201. package/dist/lib/gitWorktrees.js +0 -191
  202. package/dist/lib/gitWorktrees.js.map +0 -1
  203. package/dist/syncLoop.d.ts +0 -172
  204. package/dist/syncLoop.d.ts.map +0 -1
  205. package/dist/syncLoop.js +0 -878
  206. package/dist/syncLoop.js.map +0 -1
@@ -3,7 +3,11 @@
3
3
  * MCP servers with the @intutic/mcp-governance-proxy.
4
4
  *
5
5
  * Injects and proxy-wraps MCP server entries in:
6
- * - Claude Code: ~/.claude/mcp.json
6
+ * - Claude Code: ~/.claude.json (user-scope `mcpServers`, and the
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)
7
11
  * - Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json
8
12
  * - Cursor (global): ~/Library/Application Support/Cursor/User/globalSettings.json
9
13
  * - Cursor (project): <workspaceRoot>/.cursor/mcp.json
@@ -15,11 +19,14 @@
15
19
  * - Muse Code: ~/.config/muse/settings.json (`mcp_servers` section)
16
20
  * - Grok Build: ~/.grok/config.toml + <workspaceRoot>/.grok/config.toml
17
21
  * ([mcp_servers.*] tables)
22
+ * - OpenCode: ~/.config/opencode/opencode.json + <workspaceRoot>/opencode.json
23
+ * (`mcp` block — TD-487; see `injectOpenCode`)
18
24
  *
19
- * That is 12 config paths across 10 `HarnessType` values (Cursor and Grok
20
- * Build each own two paths — global/project for Cursor, user/project for
21
- * Grok Build). `discoverMcpServers` below reads all twelve read-only, for
22
- * reporting; the injectors above are the only thing that writes.
25
+ * That is 14 config paths across 11 `HarnessType` values (Cursor, Grok
26
+ * Build and OpenCode each own two paths — global/project for Cursor and
27
+ * OpenCode, user/project for Grok Build). `discoverMcpServers` below reads
28
+ * all fourteen read-only, for reporting, plus Claude Code's project
29
+ * `.mcp.json`; the injectors above are the only thing that writes.
23
30
  *
24
31
  * # Grok Build's compat-path overlap — dedup, not a bug
25
32
  *
@@ -65,15 +72,13 @@
65
72
  * comment used to describe as "left untouched": the entry is rewritten to
66
73
  * spawn the SAME proxy binary in bridge mode (`--remote-url`/
67
74
  * `--remote-transport`, headers riding in `INTUTIC_REMOTE_HEADERS` env, not
68
- * argv — `ps` visibility). `__intutic_original` preserves the entry's
69
- * pre-wrap `url`/`type`/`headers` so `classifyEntry` can report the TRUE
70
- * transport of what got wrapped, and so a wrapped entry could be unwrapped
71
- * later. See TECH_DEBT.md TD-354 for the historical decline this phase
72
- * supersedes.
73
- *
74
- * Continuous invariant, not one-shot: `injectMcpServer` is called once from
75
- * `intutic connect` (tools/cli) AND once per sync-loop iteration
76
- * (services/sync-daemon/src/syncLoop.ts), so a server a user adds to a harness
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.
79
+ *
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
77
82
  * config after their first `connect` still gets wrapped on the next sync
78
83
  * cycle rather than staying invisible to governance forever. Running this
79
84
  * every ~30s only works because `writeJsonFile` below is write-if-changed —
@@ -88,41 +93,63 @@ import * as node_fs from 'node:fs/promises';
88
93
  import * as node_path from 'node:path';
89
94
  import * as node_os from 'node:os';
90
95
  import { existsSync } from 'node:fs';
96
+ import { createRequire } from 'node:module';
91
97
  import { isDeepStrictEqual } from 'node:util';
92
98
  import { createLogger } from '@intutic/logger';
99
+ import { readJsonObjectForMerge } from './jsonMergeTarget.js';
100
+ import { projectServerApproval } from './claudeProjectApproval.js';
101
+ import { keepOriginal } from '../disconnect/originals.js';
93
102
  import { parseDocument, isMap } from 'yaml';
94
103
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
95
104
  const log = createLogger('sync-mcp-autowrite');
96
105
  // ─── Proxy Binary Resolution ─────────────────────────────────────────────────
106
+ /** Resolves packages relative to this module — i.e. from wherever the
107
+ * daemon itself is installed, not from the user's project. */
108
+ const requireFromDaemon = createRequire(import.meta.url);
97
109
  /**
98
- * Resolve the path to the @intutic/mcp-governance-proxy binary.
110
+ * Resolve the path to the @intutic/mcp-governance-proxy entry point.
99
111
  *
100
112
  * Resolution order:
101
- * 1. `<workspaceRoot>/node_modules/@intutic/mcp-governance-proxy/dist/index.js`
102
- * — created by pnpm after `pnpm install` (post-install production path)
103
- * 2. `<workspaceRoot>/packages/mcp-proxy/dist/index.js`
104
- * — direct monorepo source path (dev without pnpm install, or if symlink is missing)
113
+ * 1. The copy installed next to this daemon — a dependency of
114
+ * `@intutic/sync-daemon`, so a global `npm install -g @intutic/cli` has it
115
+ * in the CLI's own node_modules. This is the normal case: the user's
116
+ * project has no reason to depend on the proxy.
117
+ * 2. `<workspaceRoot>/node_modules/@intutic/mcp-governance-proxy/dist/index.js`
118
+ * and `<workspaceRoot>/packages/mcp-proxy/dist/index.js` — for a run from
119
+ * a bundled binary, whose bundled modules are not on disk for `node` to
120
+ * load (their paths start with `/snapshot/`), and for a source checkout
121
+ * that has not built the proxy yet.
105
122
  *
106
- * Using synchronous existsSync is intentional — this runs at daemon init time
107
- * (not in a hot path), and avoids async complexity in callers.
123
+ * Synchronous on purpose — this runs while building config entries, not in a
124
+ * hot path.
108
125
  */
109
- function resolveProxyBin(workspaceRoot) {
126
+ export function resolveProxyBin(workspaceRoot) {
127
+ try {
128
+ const installed = requireFromDaemon.resolve('@intutic/mcp-governance-proxy');
129
+ if (!installed.startsWith('/snapshot/') && existsSync(installed))
130
+ return installed;
131
+ }
132
+ catch {
133
+ // Not installed alongside the daemon — fall through to the workspace.
134
+ }
110
135
  const nmPath = node_path.join(workspaceRoot, 'node_modules', '@intutic', 'mcp-governance-proxy', 'dist', 'index.js');
111
136
  if (existsSync(nmPath))
112
137
  return nmPath;
113
- // Fallback: direct monorepo package path for dev environments
114
- const pkgPath = node_path.join(workspaceRoot, 'packages', 'mcp-proxy', 'dist', 'index.js');
115
- return pkgPath;
138
+ return node_path.join(workspaceRoot, 'packages', 'mcp-proxy', 'dist', 'index.js');
116
139
  }
117
140
  // ─── Config Path Resolution ───────────────────────────────────────────────────
118
141
  //
119
142
  // Shared by both the injectors (which write) and discoverMcpServers (which
120
143
  // only reads), so the two can never independently drift about where a
121
144
  // harness keeps its config.
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}). */
122
149
  function claudeCodeConfigPath() {
123
- return node_path.join(node_os.homedir(), '.claude', 'mcp.json');
150
+ return node_path.join(node_os.homedir(), '.claude.json');
124
151
  }
125
- function claudeDesktopConfigPath() {
152
+ export function claudeDesktopConfigPath() {
126
153
  const home = node_os.homedir();
127
154
  if (process.platform === 'darwin') {
128
155
  return node_path.join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
@@ -132,7 +159,7 @@ function claudeDesktopConfigPath() {
132
159
  }
133
160
  return node_path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
134
161
  }
135
- function cursorGlobalConfigPath() {
162
+ export function cursorGlobalConfigPath() {
136
163
  const home = node_os.homedir();
137
164
  if (process.platform === 'darwin') {
138
165
  return node_path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalSettings.json');
@@ -148,27 +175,37 @@ function cursorProjectConfigPath(workspaceRoot) {
148
175
  function clineConfigPath(workspaceRoot) {
149
176
  return node_path.join(workspaceRoot, '.cline', 'mcp.json');
150
177
  }
151
- function windsurfConfigPath() {
178
+ export function windsurfConfigPath() {
152
179
  return node_path.join(node_os.homedir(), '.codeium', 'windsurf', 'mcp_config.json');
153
180
  }
154
- function continueConfigPath() {
181
+ export function continueConfigPath() {
155
182
  return node_path.join(node_os.homedir(), '.continue', 'config.json');
156
183
  }
157
- function gooseConfigPath() {
184
+ export function gooseConfigPath() {
158
185
  return node_path.join(node_os.homedir(), '.config', 'goose', 'config.yaml');
159
186
  }
160
187
  function openHandsConfigPath(workspaceRoot) {
161
188
  return node_path.join(workspaceRoot, '.openhands', 'mcp.json');
162
189
  }
163
- function museConfigPath() {
190
+ export function museConfigPath() {
164
191
  return node_path.join(node_os.homedir(), '.config', 'muse', 'settings.json');
165
192
  }
166
- function grokUserConfigPath() {
193
+ export function grokUserConfigPath() {
167
194
  return node_path.join(node_os.homedir(), '.grok', 'config.toml');
168
195
  }
169
196
  function grokProjectConfigPath(workspaceRoot) {
170
197
  return node_path.join(workspaceRoot, '.grok', 'config.toml');
171
198
  }
199
+ /** OpenCode's global config: `$XDG_CONFIG_HOME/opencode/opencode.json`, which
200
+ * is `~/.config/opencode/opencode.json` unless XDG_CONFIG_HOME is set (OpenCode
201
+ * resolves it through xdg-basedir). Only the `.json` spelling — see `injectOpenCode`. */
202
+ export function openCodeGlobalConfigPath() {
203
+ const base = process.env['XDG_CONFIG_HOME'] || node_path.join(node_os.homedir(), '.config');
204
+ return node_path.join(base, 'opencode', 'opencode.json');
205
+ }
206
+ function openCodeProjectConfigPath(workspaceRoot) {
207
+ return node_path.join(workspaceRoot, 'opencode.json');
208
+ }
172
209
  // ─── Proxy Wrapping ───────────────────────────────────────────────────────────
173
210
  /**
174
211
  * Wrap a single MCP server entry with the governance proxy.
@@ -194,6 +231,7 @@ function wrapWithProxy(entry, workspaceId, workspaceRoot, serverName) {
194
231
  if (entry.__intutic_wrapped)
195
232
  return entry;
196
233
  const proxyBin = resolveProxyBin(workspaceRoot);
234
+ const original = structuredClone(entry);
197
235
  if (typeof entry.command === 'string') {
198
236
  const originalArgs = entry.args ?? [];
199
237
  return {
@@ -211,16 +249,12 @@ function wrapWithProxy(entry, workspaceId, workspaceRoot, serverName) {
211
249
  INTUTIC_WORKSPACE_ID: workspaceId,
212
250
  },
213
251
  __intutic_wrapped: true,
252
+ __intutic_original: original,
214
253
  };
215
254
  }
216
255
  if (typeof entry.url === 'string') {
217
256
  const remoteTransport = entry.type?.toLowerCase() === 'sse' ? 'sse' : 'http';
218
257
  const hasHeaders = entry.headers !== undefined && Object.keys(entry.headers).length > 0;
219
- const original = { url: entry.url };
220
- if (entry.type !== undefined)
221
- original.type = entry.type;
222
- if (entry.headers !== undefined)
223
- original.headers = entry.headers;
224
258
  const env = {
225
259
  ...(entry.env ?? {}),
226
260
  INTUTIC_WORKSPACE_ID: workspaceId,
@@ -291,11 +325,23 @@ async function readJsonFile(filePath, fallback) {
291
325
  return fallback;
292
326
  }
293
327
  }
328
+ /**
329
+ * Read a harness config this module is about to write back. A missing file is
330
+ * an empty object; a file that is present but not a plain JSON object throws,
331
+ * so the caller's error handling skips the write instead of replacing the
332
+ * user's file with only the MCP entries (see jsonMergeTarget.ts).
333
+ */
334
+ async function readJsonForWrite(filePath) {
335
+ const current = await readJsonObjectForMerge(filePath);
336
+ if (current === null)
337
+ throw new Error(`${filePath} is not a plain JSON object — left untouched`);
338
+ return current;
339
+ }
294
340
  /**
295
341
  * Write JSON to disk, but only if the content actually changed.
296
342
  *
297
- * `injectMcpServer` now runs every sync-loop iteration (~every 30s, see
298
- * 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
299
345
  * wrap against an already-wrapped, unchanged config must not touch the file,
300
346
  * or every cycle would churn the file's mtime and fire a spurious inotify /
301
347
  * FSEvents event for every harness config on every developer machine.
@@ -304,8 +350,12 @@ async function readJsonFile(filePath, fallback) {
304
350
  * serialized string — a naive string compare would report a false "changed"
305
351
  * on nothing more than JSON.stringify key-ordering differences between two
306
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.
307
357
  */
308
- async function writeJsonFile(filePath, data) {
358
+ async function writeJsonFile(filePath, data, workspaceRoot) {
309
359
  let existing;
310
360
  let hasExisting;
311
361
  try {
@@ -318,16 +368,113 @@ async function writeJsonFile(filePath, data) {
318
368
  if (hasExisting && isDeepStrictEqual(existing, data)) {
319
369
  return;
320
370
  }
371
+ if (workspaceRoot !== undefined)
372
+ await keepOriginal(filePath, workspaceRoot);
321
373
  await node_fs.mkdir(node_path.dirname(filePath), { recursive: true });
322
374
  await node_fs.writeFile(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
323
375
  }
324
- // ─── Target: Claude Code ─────────────────────────────────────────────────────
325
376
  async function injectClaudeCode(workspaceId, workspaceRoot) {
326
377
  const configPath = claudeCodeConfigPath();
327
- const current = await readJsonFile(configPath, {});
378
+ // ~/.claude.json is Claude Code's own state file; it exists once Claude
379
+ // Code has run. Never created here, and merged key by key: everything but
380
+ // the two `mcpServers` maps below is Claude Code's.
381
+ if (!existsSync(configPath)) {
382
+ log.debug({ action: 'claude_code_skip' }, 'Claude Code not set up (no ~/.claude.json) — skipping');
383
+ return;
384
+ }
385
+ const current = await readJsonForWrite(configPath);
386
+ // User scope: available in every project.
328
387
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
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.
391
+ const project = current.projects?.[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;
397
+ }
329
398
  await writeJsonFile(configPath, current);
330
- log.info({ action: 'claude_code_mcp_injected' }, 'Claude Code ~/.claude/mcp.json updated');
399
+ log.info({ action: 'claude_code_mcp_injected' }, 'Claude Code ~/.claude.json MCP servers updated');
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;
331
478
  }
332
479
  // ─── Target: Claude Desktop ───────────────────────────────────────────────────
333
480
  async function injectClaudeDesktop(workspaceId, workspaceRoot) {
@@ -340,9 +487,9 @@ async function injectClaudeDesktop(workspaceId, workspaceRoot) {
340
487
  log.debug({ action: 'claude_desktop_skip' }, 'Claude Desktop not installed — skipping');
341
488
  return;
342
489
  }
343
- const current = await readJsonFile(configPath, {});
490
+ const current = await readJsonForWrite(configPath);
344
491
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
345
- await writeJsonFile(configPath, current);
492
+ await writeJsonFile(configPath, current, workspaceRoot);
346
493
  log.info({ action: 'claude_desktop_mcp_injected' }, 'Claude Desktop config updated');
347
494
  }
348
495
  // ─── Target: Cursor (global + project) ───────────────────────────────────────
@@ -351,9 +498,9 @@ async function injectCursor(workspaceId, workspaceRoot) {
351
498
  const globalPath = cursorGlobalConfigPath();
352
499
  try {
353
500
  await node_fs.access(node_path.dirname(globalPath));
354
- const current = await readJsonFile(globalPath, {});
501
+ const current = await readJsonForWrite(globalPath);
355
502
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
356
- await writeJsonFile(globalPath, current);
503
+ await writeJsonFile(globalPath, current, workspaceRoot);
357
504
  log.info({ action: 'cursor_global_mcp_injected' }, 'Cursor globalSettings.json updated');
358
505
  }
359
506
  catch {
@@ -362,9 +509,9 @@ async function injectCursor(workspaceId, workspaceRoot) {
362
509
  // Project-level .cursor/mcp.json
363
510
  const projectPath = cursorProjectConfigPath(workspaceRoot);
364
511
  try {
365
- const current = await readJsonFile(projectPath, {});
512
+ const current = await readJsonForWrite(projectPath);
366
513
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
367
- await writeJsonFile(projectPath, current);
514
+ await writeJsonFile(projectPath, current, workspaceRoot);
368
515
  log.info({ action: 'cursor_project_mcp_injected' }, 'Cursor .cursor/mcp.json updated');
369
516
  }
370
517
  catch (err) {
@@ -375,9 +522,9 @@ async function injectCursor(workspaceId, workspaceRoot) {
375
522
  async function injectCline(workspaceId, workspaceRoot) {
376
523
  const configPath = clineConfigPath(workspaceRoot);
377
524
  try {
378
- const current = await readJsonFile(configPath, {});
525
+ const current = await readJsonForWrite(configPath);
379
526
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
380
- await writeJsonFile(configPath, current);
527
+ await writeJsonFile(configPath, current, workspaceRoot);
381
528
  log.info({ action: 'cline_mcp_injected' }, 'Cline .cline/mcp.json updated');
382
529
  }
383
530
  catch (err) {
@@ -389,9 +536,9 @@ async function injectWindsurf(workspaceId, workspaceRoot) {
389
536
  const configPath = windsurfConfigPath();
390
537
  try {
391
538
  await node_fs.access(node_path.dirname(configPath));
392
- const current = await readJsonFile(configPath, {});
539
+ const current = await readJsonForWrite(configPath);
393
540
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
394
- await writeJsonFile(configPath, current);
541
+ await writeJsonFile(configPath, current, workspaceRoot);
395
542
  log.info({ action: 'windsurf_mcp_injected' }, 'Windsurf mcp_config.json updated');
396
543
  }
397
544
  catch {
@@ -402,7 +549,7 @@ async function injectContinue(workspaceId, workspaceRoot) {
402
549
  const configPath = continueConfigPath();
403
550
  try {
404
551
  await node_fs.access(node_path.dirname(configPath));
405
- const current = await readJsonFile(configPath, {});
552
+ const current = await readJsonForWrite(configPath);
406
553
  // Continue uses an array format for mcpServers
407
554
  if (!Array.isArray(current.mcpServers)) {
408
555
  current.mcpServers = [];
@@ -412,14 +559,16 @@ async function injectContinue(workspaceId, workspaceRoot) {
412
559
  // Wrap existing non-intutic servers — routed through the same
413
560
  // wrapWithProxy used by every other harness, so the stdio-only guard and
414
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.
415
564
  current.mcpServers = current.mcpServers.map((s) => {
416
- 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);
417
- return { name: s.name, ...wrapped };
565
+ const { name, ...entry } = s;
566
+ return { name, ...wrapWithProxy(entry, workspaceId, workspaceRoot, name) };
418
567
  });
419
568
  // Add Intutic MCP server
420
569
  const entry = buildIntuticMcpEntry(workspaceRoot);
421
570
  current.mcpServers.unshift({ name: 'intutic', ...entry });
422
- await writeJsonFile(configPath, current);
571
+ await writeJsonFile(configPath, current, workspaceRoot);
423
572
  log.info({ action: 'continue_mcp_injected' }, 'Continue ~/.continue/config.json updated');
424
573
  }
425
574
  catch {
@@ -455,6 +604,7 @@ async function injectGooseAppendOnly(configPath, existingYaml, workspaceId, prox
455
604
  // (see writeJsonFile's doc comment for why: this runs every sync cycle now).
456
605
  return;
457
606
  }
607
+ await keepOriginal(configPath, node_os.homedir());
458
608
  await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
459
609
  await node_fs.writeFile(configPath, text, 'utf-8');
460
610
  log.info({ action: 'goose_mcp_injected', mode: 'append_only_fallback' }, 'Goose config.yaml updated (append-only fallback)');
@@ -471,7 +621,7 @@ async function injectGooseAppendOnly(configPath, existingYaml, workspaceId, prox
471
621
  * stdio entries wrapped with `--`, remote (`url`-keyed) entries wrapped with
472
622
  * `--remote-url`/`--remote-transport` — so a remote MCP server declared in
473
623
  * Goose's config gets the identical bridge coverage a remote entry in
474
- * `~/.claude/mcp.json` would get, not a second, divergent convention.
624
+ * `~/.claude.json` would get, not a second, divergent convention.
475
625
  *
476
626
  * Falls back to `injectGooseAppendOnly` (append-only text injection, the
477
627
  * pre-existing behaviour) when the file does not parse as YAML at all —
@@ -546,6 +696,7 @@ async function injectGoose(workspaceId, workspaceRoot) {
546
696
  // (see writeJsonFile's doc comment for why: this runs every sync cycle now).
547
697
  return;
548
698
  }
699
+ await keepOriginal(configPath, workspaceRoot);
549
700
  await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
550
701
  await node_fs.writeFile(configPath, doc.toString(), 'utf-8');
551
702
  log.info({ action: 'goose_mcp_injected', mode: 'yaml' }, 'Goose config.yaml updated (structural YAML edit)');
@@ -554,9 +705,9 @@ async function injectGoose(workspaceId, workspaceRoot) {
554
705
  async function injectOpenHands(workspaceId, workspaceRoot) {
555
706
  const configPath = openHandsConfigPath(workspaceRoot);
556
707
  try {
557
- const current = await readJsonFile(configPath, {});
708
+ const current = await readJsonForWrite(configPath);
558
709
  current.mcpServers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcpServers ?? {}) }, workspaceId, workspaceRoot);
559
- await writeJsonFile(configPath, current);
710
+ await writeJsonFile(configPath, current, workspaceRoot);
560
711
  log.info({ action: 'openhands_mcp_injected' }, 'OpenHands .openhands/mcp.json updated');
561
712
  }
562
713
  catch (err) {
@@ -583,11 +734,11 @@ async function injectMuse(workspaceId, workspaceRoot) {
583
734
  log.debug({ action: 'muse_skip' }, 'Muse Code not installed — skipping');
584
735
  return;
585
736
  }
586
- const current = await readJsonFile(configPath, {});
737
+ const current = await readJsonForWrite(configPath);
587
738
  current.mcp_servers = wrapAllServers({ intutic: buildIntuticMcpEntry(workspaceRoot), ...(current.mcp_servers ?? {}) }, workspaceId, workspaceRoot);
588
739
  if (current.schema_version === undefined)
589
740
  current.schema_version = 1;
590
- await writeJsonFile(configPath, current);
741
+ await writeJsonFile(configPath, current, workspaceRoot);
591
742
  log.info({ action: 'muse_mcp_injected' }, 'Muse Code ~/.config/muse/settings.json mcp_servers updated');
592
743
  }
593
744
  /**
@@ -600,7 +751,7 @@ async function injectMuse(workspaceId, workspaceRoot) {
600
751
  * honest limit `injectGooseAppendOnly`/`discoverGooseLineScanFallback`
601
752
  * document for their own fallback.
602
753
  */
603
- async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin) {
754
+ async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin, workspaceRoot) {
604
755
  let text = existingToml;
605
756
  if (!text.includes('[mcp_servers.intutic]') && !text.includes('[mcp_servers."intutic"]')) {
606
757
  const block = [
@@ -612,6 +763,7 @@ async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId,
612
763
  }
613
764
  if (text === existingToml)
614
765
  return;
766
+ await keepOriginal(configPath, workspaceRoot);
615
767
  await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
616
768
  await node_fs.writeFile(configPath, text, 'utf-8');
617
769
  log.info({ action: 'grok_mcp_injected', mode: 'append_only_fallback' }, 'Grok config.toml updated (append-only fallback)');
@@ -622,7 +774,7 @@ async function injectGrokConfigAppendOnly(configPath, existingToml, workspaceId,
622
774
  * SAME `wrapWithProxy` convention every other harness uses (stdio entries
623
775
  * wrapped with `--`, remote/`url`-keyed entries wrapped with
624
776
  * `--remote-url`/`--remote-transport`) so a remote MCP server declared for
625
- * Grok Build gets identical bridge coverage to one in `~/.claude/mcp.json`,
777
+ * Grok Build gets identical bridge coverage to one in `~/.claude.json`,
626
778
  * not a second, divergent convention.
627
779
  *
628
780
  * Unlike `injectGoose`'s `Document#setIn` (which preserves comments/
@@ -654,7 +806,7 @@ async function injectGrokConfig(configPath, workspaceId, workspaceRoot) {
654
806
  }
655
807
  catch (err) {
656
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');
657
- await injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin);
809
+ await injectGrokConfigAppendOnly(configPath, existingToml, workspaceId, proxyBin, workspaceRoot);
658
810
  return;
659
811
  }
660
812
  const servers = doc.mcp_servers && typeof doc.mcp_servers === 'object' ? doc.mcp_servers : {};
@@ -679,6 +831,7 @@ async function injectGrokConfig(configPath, workspaceId, workspaceRoot) {
679
831
  return;
680
832
  }
681
833
  doc.mcp_servers = servers;
834
+ await keepOriginal(configPath, workspaceRoot);
682
835
  await node_fs.mkdir(node_path.dirname(configPath), { recursive: true });
683
836
  const tmp = configPath + '.intutic-tmp';
684
837
  await node_fs.writeFile(tmp, stringifyToml(doc), 'utf-8');
@@ -689,6 +842,129 @@ async function injectGrok(workspaceId, workspaceRoot) {
689
842
  await injectGrokConfig(grokProjectConfigPath(workspaceRoot), workspaceId, workspaceRoot);
690
843
  await injectGrokConfig(grokUserConfigPath(), workspaceId, workspaceRoot);
691
844
  }
845
+ /** The proxy binary path `resolveProxyBin` returns, in either of its two forms. */
846
+ export const PROXY_BIN_PATTERN = /(?:^|[\\/])(?:@intutic[\\/]mcp-governance-proxy|packages[\\/]mcp-proxy)[\\/]dist[\\/]index\.js$/;
847
+ /**
848
+ * Whether an OpenCode entry is already fronted by the governance proxy, read
849
+ * from the command SHAPE — `["node", <proxy bin>, "--workspace-id", …]` —
850
+ * because an OpenCode entry cannot carry the `__intutic_wrapped` marker every
851
+ * other harness uses: OpenCode's config schema has no such key, and a key the
852
+ * schema does not declare is at best dropped and at worst refused.
853
+ */
854
+ function isOpenCodeWrapped(entry) {
855
+ const c = entry.command;
856
+ return (Array.isArray(c) &&
857
+ c[0] === 'node' &&
858
+ typeof c[1] === 'string' &&
859
+ PROXY_BIN_PATTERN.test(c[1]) &&
860
+ c.includes('--workspace-id'));
861
+ }
862
+ /**
863
+ * Wrap one OpenCode server. Translates the entry into the `command`/`args`/
864
+ * `env` (or `url`/`headers`) shape `wrapWithProxy` takes, so the proxy argv —
865
+ * `--workspace-id`, `--server-name`, then `--` and the real command, or
866
+ * `--remote-url`/`--remote-transport` for a remote server — is built in one
867
+ * place for every harness, then translates the result back into a `local`
868
+ * entry. Keys OpenCode defines for a local server (`cwd`, `enabled`,
869
+ * `timeout`, and anything unrecognised) are carried over unchanged.
870
+ *
871
+ * Left alone: an already-wrapped entry, an entry of neither type, a local
872
+ * entry with no command, and a remote entry with an `oauth` object. The
873
+ * proxy's remote bridge forwards static headers only, so wrapping a server
874
+ * that relies on OpenCode's own OAuth flow would silently stop it from
875
+ * authenticating; it stays ungoverned by the MCP proxy instead (the plugin
876
+ * gate still sees its calls).
877
+ */
878
+ function wrapOpenCodeEntry(entry, workspaceId, workspaceRoot, serverName) {
879
+ if (isOpenCodeWrapped(entry))
880
+ return entry;
881
+ const { type, command, environment, url, headers, oauth, ...rest } = entry;
882
+ let wrapped;
883
+ if (type === 'local' && Array.isArray(command) && command.length > 0 && command.every((a) => typeof a === 'string')) {
884
+ const [cmd, ...args] = command;
885
+ wrapped = wrapWithProxy({ command: cmd, args, env: environment }, workspaceId, workspaceRoot, serverName);
886
+ }
887
+ else if (type === 'remote' && typeof url === 'string' && (oauth === undefined || oauth === false)) {
888
+ // OpenCode tries streamable HTTP first, then SSE; the bridge's `http` is
889
+ // the streamable transport. `type` is OpenCode's discriminator, not a
890
+ // transport hint, so it is not passed through.
891
+ wrapped = wrapWithProxy({ url, headers }, workspaceId, workspaceRoot, serverName);
892
+ }
893
+ else {
894
+ return entry;
895
+ }
896
+ // `rest` holds keys valid on a local entry (cwd/enabled/timeout/…); a
897
+ // remote entry's url/headers/oauth are now carried by the argv and env.
898
+ return {
899
+ type: 'local',
900
+ command: [wrapped.command, ...(wrapped.args ?? [])],
901
+ environment: wrapped.env,
902
+ ...rest,
903
+ };
904
+ }
905
+ /**
906
+ * Merge the governance proxy into ONE OpenCode config file's `mcp` block.
907
+ *
908
+ * Narrow by design, like Muse's `settings.json`: `opencode.json` is the
909
+ * user's file (providers, permissions, agents, themes), so only `mcp` is
910
+ * rewritten and every other key is written back as it was parsed, in its
911
+ * original order, with the file's own indentation. The file is never created
912
+ * — a missing `opencode.json` means this scope has no OpenCode config to
913
+ * govern. OpenCode reads `opencode.json` as JSONC; a file that carries
914
+ * comments or trailing commas does not parse as JSON and is skipped
915
+ * (`opencode_config_unparseable`), never rewritten, since a rewrite would
916
+ * strip the comments. `opencode.jsonc` is not touched for the same reason.
917
+ * Write-if-changed and atomic, like every target here.
918
+ */
919
+ async function injectOpenCodeConfig(configPath, workspaceId, workspaceRoot) {
920
+ let raw;
921
+ try {
922
+ raw = await node_fs.readFile(configPath, 'utf-8');
923
+ }
924
+ catch {
925
+ return;
926
+ }
927
+ let doc;
928
+ try {
929
+ doc = JSON.parse(raw);
930
+ }
931
+ catch (err) {
932
+ log.warn({ action: 'opencode_config_unparseable', path: configPath, err: err.message }, 'opencode.json is not plain JSON (comments?) — MCP servers in it are not proxy-wrapped');
933
+ return;
934
+ }
935
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc))
936
+ return;
937
+ const config = doc;
938
+ const existing = config['mcp'] && typeof config['mcp'] === 'object' && !Array.isArray(config['mcp'])
939
+ ? config['mcp']
940
+ : {};
941
+ const intutic = buildIntuticMcpEntry(workspaceRoot);
942
+ const servers = {
943
+ intutic: { type: 'local', command: [intutic.command, ...(intutic.args ?? [])], environment: intutic.env },
944
+ ...existing,
945
+ };
946
+ const next = {};
947
+ for (const [name, entry] of Object.entries(servers)) {
948
+ next[name] =
949
+ name === 'intutic' || !entry || typeof entry !== 'object'
950
+ ? entry
951
+ : wrapOpenCodeEntry(entry, workspaceId, workspaceRoot, name);
952
+ }
953
+ if (isDeepStrictEqual(next, config['mcp']))
954
+ return;
955
+ config['mcp'] = next;
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);
959
+ const tmp = configPath + '.intutic-tmp';
960
+ await node_fs.writeFile(tmp, JSON.stringify(config, null, indent) + '\n', 'utf-8');
961
+ await node_fs.rename(tmp, configPath);
962
+ log.info({ action: 'opencode_mcp_injected', path: configPath }, 'OpenCode opencode.json mcp block updated');
963
+ }
964
+ async function injectOpenCode(workspaceId, workspaceRoot) {
965
+ await injectOpenCodeConfig(openCodeProjectConfigPath(workspaceRoot), workspaceId, workspaceRoot);
966
+ await injectOpenCodeConfig(openCodeGlobalConfigPath(), workspaceId, workspaceRoot);
967
+ }
692
968
  // ─── Discovery (read-only — writes nothing) ───────────────────────────────────
693
969
  /** Classify a raw server entry's transport + wrapped status, tolerant of any shape. */
694
970
  function classifyEntry(entry) {
@@ -732,6 +1008,32 @@ async function discoverJsonObjectHarness(harness, filePath) {
732
1008
  }
733
1009
  return out;
734
1010
  }
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. */
1016
+ async function discoverClaudeCode(workspaceRoot) {
1017
+ const filePath = claudeCodeConfigPath();
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
+ });
1036
+ }
735
1037
  /** Muse Code's `~/.config/muse/settings.json` keeps servers under `mcp_servers`,
736
1038
  * not `mcpServers` — otherwise the same JSON-map shape `discoverJsonObjectHarness`
737
1039
  * reads, so this is that function with one key renamed rather than a new format. */
@@ -885,9 +1187,45 @@ async function discoverGrok(workspaceRoot) {
885
1187
  ]);
886
1188
  return [...project, ...user];
887
1189
  }
1190
+ /** Classify an OpenCode `mcp` entry. A wrapped entry is a `local` one whose
1191
+ * argv names the proxy; its true transport is read back from that argv
1192
+ * (`--remote-url` / `--remote-transport`), since OpenCode entries cannot
1193
+ * carry `__intutic_original`. */
1194
+ function classifyOpenCodeEntry(entry) {
1195
+ if (!entry || typeof entry !== 'object')
1196
+ return { transport: 'unknown', wrapped: false };
1197
+ const e = entry;
1198
+ const wrapped = isOpenCodeWrapped(e);
1199
+ if (wrapped) {
1200
+ const argv = e.command;
1201
+ if (!argv.includes('--remote-url'))
1202
+ return { transport: 'stdio', wrapped };
1203
+ const t = argv[argv.indexOf('--remote-transport') + 1];
1204
+ return { transport: t === 'sse' ? 'sse' : 'http', wrapped };
1205
+ }
1206
+ if (e.type === 'local' && Array.isArray(e.command))
1207
+ return { transport: 'stdio', wrapped };
1208
+ if (e.type === 'remote' && typeof e.url === 'string')
1209
+ return { transport: 'http', wrapped };
1210
+ return { transport: 'unknown', wrapped };
1211
+ }
1212
+ async function discoverOpenCodeConfig(configPath) {
1213
+ if (!existsSync(configPath))
1214
+ return [];
1215
+ const current = await readJsonFile(configPath, {});
1216
+ const mcp = current.mcp && typeof current.mcp === 'object' ? current.mcp : {};
1217
+ return Object.entries(mcp).map(([name, entry]) => ({ server: name, harness: 'opencode', ...classifyOpenCodeEntry(entry) }));
1218
+ }
1219
+ async function discoverOpenCode(workspaceRoot) {
1220
+ const [project, global] = await Promise.all([
1221
+ discoverOpenCodeConfig(openCodeProjectConfigPath(workspaceRoot)),
1222
+ discoverOpenCodeConfig(openCodeGlobalConfigPath()),
1223
+ ]);
1224
+ return [...project, ...global];
1225
+ }
888
1226
  /**
889
1227
  * Discover every MCP server declared in any harness config this daemon knows
890
- * how to parse — the same 12 config paths / 10 harnesses `injectMcpServer`
1228
+ * how to parse — the same 14 config paths / 11 harnesses `injectMcpServer`
891
1229
  * wraps — without writing anything. Used for reporting (agentReporter's
892
1230
  * `mcp_tools` facet) so visibility does not silently lag behind whatever
893
1231
  * `injectMcpServer` was last run against.
@@ -906,7 +1244,7 @@ async function discoverGrok(workspaceRoot) {
906
1244
  */
907
1245
  export async function discoverMcpServers(workspaceRoot) {
908
1246
  const results = await Promise.all([
909
- discoverJsonObjectHarness('claude-code', claudeCodeConfigPath()),
1247
+ discoverClaudeCode(workspaceRoot),
910
1248
  discoverJsonObjectHarness('claude-desktop', claudeDesktopConfigPath()),
911
1249
  discoverJsonObjectHarness('cursor', cursorGlobalConfigPath()),
912
1250
  discoverJsonObjectHarness('cursor', cursorProjectConfigPath(workspaceRoot)),
@@ -917,6 +1255,7 @@ export async function discoverMcpServers(workspaceRoot) {
917
1255
  discoverGoose(),
918
1256
  discoverMuse(),
919
1257
  discoverGrok(workspaceRoot),
1258
+ discoverOpenCode(workspaceRoot),
920
1259
  ]);
921
1260
  return results.flat().filter((s) => s.server !== 'intutic');
922
1261
  }
@@ -926,31 +1265,36 @@ export async function discoverMcpServers(workspaceRoot) {
926
1265
  *
927
1266
  * Non-fatal: a failure in one harness does not prevent other harnesses from being updated.
928
1267
  *
929
- * Called from two places by design: once from `intutic connect` (tools/cli)
930
- * for immediate effect, and once per sync-loop iteration
931
- * (services/sync-daemon/src/syncLoop.ts) so it is a continuous invariant
932
- * 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`
933
1270
  * still gets wrapped on the next cycle. Safe to call every cycle because
934
1271
  * `writeJsonFile` is write-if-changed: an already-wrapped, unchanged config
935
1272
  * writes zero bytes.
936
1273
  *
937
1274
  * @param workspaceId - The workspace ID for policy lookups and event attribution.
938
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.
939
1278
  */
940
- export async function injectMcpServer(workspaceRoot, workspaceId = 'unknown') {
1279
+ export async function injectMcpServer(workspaceRoot, workspaceId = 'unknown', options = {}) {
941
1280
  log.info({ action: 'mcp_inject_start', workspaceRoot, workspaceId }, 'Starting MCP server injection');
942
- await Promise.allSettled([
943
- injectClaudeCode(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'claude-code' }, 'MCP injection failed')),
944
- injectClaudeDesktop(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'claude-desktop' }, 'MCP injection failed')),
945
- injectCursor(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'cursor' }, 'MCP injection failed')),
946
- injectCline(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'cline' }, 'MCP injection failed')),
947
- injectWindsurf(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'windsurf' }, 'MCP injection failed')),
948
- injectContinue(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'continue' }, 'MCP injection failed')),
949
- injectGoose(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'goose' }, 'MCP injection failed')),
950
- injectOpenHands(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'openhands' }, 'MCP injection failed')),
951
- injectMuse(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'muse-code' }, 'MCP injection failed')),
952
- injectGrok(workspaceId, workspaceRoot).catch((err) => log.error({ err: err.message, target: 'grok' }, 'MCP injection failed')),
953
- ]);
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'))));
954
1298
  log.info({ action: 'mcp_inject_complete', workspaceRoot }, 'MCP server injection complete');
955
1299
  }
956
1300
  //# sourceMappingURL=mcpAutoWrite.js.map