@sanqianx/project-knowledge 4.7.0 → 4.8.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 (192) hide show
  1. package/.agents/plugins/marketplace.json +17 -17
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/CHANGELOG.md +817 -799
  4. package/README.md +48 -36
  5. package/_modules/knowledge-engine/README.md +88 -0
  6. package/_modules/knowledge-engine/package.json +38 -0
  7. package/_modules/knowledge-engine/src/analysis-contract.js +25 -0
  8. package/_modules/knowledge-engine/src/analysis-worker.js +54 -0
  9. package/_modules/knowledge-engine/src/bin.js +52 -0
  10. package/_modules/knowledge-engine/src/bridge-client.js +24 -0
  11. package/_modules/knowledge-engine/src/coordinator.js +255 -0
  12. package/_modules/knowledge-engine/src/headless-analyzer.js +75 -0
  13. package/_modules/knowledge-engine/src/memories.js +85 -0
  14. package/_modules/knowledge-engine/src/migration.js +88 -0
  15. package/_modules/knowledge-engine/src/retrieval.js +116 -0
  16. package/_modules/knowledge-engine/src/runtime/README.md +7 -0
  17. package/_modules/knowledge-engine/src/runtime/atomic-file.js +204 -0
  18. package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -0
  19. package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +249 -0
  20. package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -0
  21. package/_modules/knowledge-engine/src/runtime/commit-prompt.js +202 -0
  22. package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +432 -0
  23. package/_modules/knowledge-engine/src/runtime/content-hash.js +4 -0
  24. package/_modules/knowledge-engine/src/runtime/contracts.js +279 -0
  25. package/_modules/knowledge-engine/src/runtime/conversation-exclusions.js +204 -0
  26. package/_modules/knowledge-engine/src/runtime/conversation-store.js +387 -0
  27. package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -0
  28. package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -0
  29. package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +444 -0
  30. package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +402 -0
  31. package/_modules/knowledge-engine/src/runtime/layout.js +44 -0
  32. package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +230 -0
  33. package/_modules/knowledge-engine/src/runtime/scanner.js +298 -0
  34. package/_modules/knowledge-engine/src/server.js +155 -0
  35. package/_modules/knowledge-engine/src/store.js +239 -0
  36. package/_modules/knowledge-engine/ui/app.css +172 -0
  37. package/_modules/knowledge-engine/ui/app.js +297 -0
  38. package/_modules/knowledge-engine/ui/index.html +95 -0
  39. package/_site/README.md +30 -30
  40. package/_site/_test/ai-profile-resolver-test.js +137 -137
  41. package/_site/_test/automation-queue-test.js +14 -14
  42. package/_site/_test/automation-ui-test.js +40 -40
  43. package/_site/_test/background-task-registry-test.js +43 -43
  44. package/_site/_test/baseline-schema-test.js +91 -91
  45. package/_site/_test/chat-claudecodeui-match-test.js +92 -92
  46. package/_site/_test/claude-executable-discovery-test.js +62 -62
  47. package/_site/_test/claude-workbench-test.js +170 -170
  48. package/_site/_test/codex-conversation-projection-test.js +2 -1
  49. package/_site/_test/commit-conversation-binding-test.js +95 -95
  50. package/_site/_test/commit-evidence-test.js +110 -110
  51. package/_site/_test/conversation-api-test.js +109 -109
  52. package/_site/_test/conversation-store-test.js +136 -136
  53. package/_site/_test/data-dir-migration-test.js +57 -57
  54. package/_site/_test/desktop-browser-compat-test.js +28 -28
  55. package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
  56. package/_site/_test/explicit-commit-processor-test.js +37 -37
  57. package/_site/_test/fixtures/make-git-repos.js +12 -1
  58. package/_site/_test/folder-picker-output-test.js +19 -19
  59. package/_site/_test/full-integration-e2e-test.js +165 -165
  60. package/_site/_test/git-validation-test.js +2 -2
  61. package/_site/_test/hook-runtime-endpoint-test.js +88 -88
  62. package/_site/_test/hook-status-repair-api-test.js +152 -152
  63. package/_site/_test/hook-trigger-test.js +85 -85
  64. package/_site/_test/import-preflight-api-test.js +234 -233
  65. package/_site/_test/index-writer-concurrency-test.js +135 -135
  66. package/_site/_test/integration-adapters-test.js +171 -171
  67. package/_site/_test/integration-surface-coverage-test.js +164 -164
  68. package/_site/_test/knowledge-language-control-test.js +170 -170
  69. package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
  70. package/_site/_test/knowledge-query-test.js +54 -54
  71. package/_site/_test/knowledge-retrieval-service-test.js +77 -77
  72. package/_site/_test/knowledge-storage-startup-test.js +52 -52
  73. package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
  74. package/_site/_test/legacy-forward-compat-test.js +257 -257
  75. package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -363
  76. package/_site/_test/logging-api-test.js +89 -89
  77. package/_site/_test/logging-sse-no-gap-test.js +88 -88
  78. package/_site/_test/logging-ui-test.js +106 -104
  79. package/_site/_test/markdown-delta-overlay-test.js +85 -85
  80. package/_site/_test/markdown-maintenance-api-test.js +75 -75
  81. package/_site/_test/mcp-server-test.js +149 -149
  82. package/_site/_test/module-artifact-integrity-test.js +27 -0
  83. package/_site/_test/module-boundary-test.js +30 -0
  84. package/_site/_test/module-bridge-eventbridge-test.js +7 -0
  85. package/_site/_test/module-model-configuration-test.js +43 -0
  86. package/_site/_test/module-process-lifecycle-test.js +24 -0
  87. package/_site/_test/module-stream-proxy-test.js +34 -0
  88. package/_site/_test/non-release-ci-test.js +34 -34
  89. package/_site/_test/offline-boundary-isolation-test.js +32 -32
  90. package/_site/_test/p0-data-migration-characterization-test.js +37 -37
  91. package/_site/_test/p0-e2e-gate-test.js +347 -347
  92. package/_site/_test/packaged-ui-smoke-test.js +11 -9
  93. package/_site/_test/path-consistency-test.js +151 -151
  94. package/_site/_test/pending-sweep-test.js +9 -9
  95. package/_site/_test/post-commit-automation-test.js +87 -87
  96. package/_site/_test/product-diagnostics-ui-test.js +75 -0
  97. package/_site/_test/product-import-ui-test.js +92 -0
  98. package/_site/_test/project-delete-recovery-test.js +64 -64
  99. package/_site/_test/project-goal-editor-test.js +144 -144
  100. package/_site/_test/project-layout-v2-migration-test.js +127 -127
  101. package/_site/_test/project-lifecycle-transaction-test.js +106 -106
  102. package/_site/_test/prompt-settings-test.js +115 -115
  103. package/_site/_test/protected-architecture-gate-test.js +122 -122
  104. package/_site/_test/refactor-characterization-test.js +36 -36
  105. package/_site/_test/release-version-sync-test.js +1 -1
  106. package/_site/_test/requirement-recorder-test.js +173 -173
  107. package/_site/_test/run-all-tests.js +159 -156
  108. package/_site/_test/server-runtime-migration-safety-test.js +27 -27
  109. package/_site/_test/shared-contracts-test.js +58 -58
  110. package/_site/_test/startup-analysis-disabled-test.js +10 -10
  111. package/_site/_test/storage-foundation-test.js +78 -78
  112. package/_site/_test/structured-logger-test.js +101 -101
  113. package/_site/_test/tracking-start-test.js +108 -108
  114. package/_site/_test/v4122-upgrade-data-contract-test.js +56 -56
  115. package/_site/_test/workbench-permission-test.js +117 -117
  116. package/_site/_test/workspace-ui-contract-test.js +28 -43
  117. package/_site/lib/ai-profile-resolver.js +78 -78
  118. package/_site/lib/ai-workspace.js +101 -101
  119. package/_site/lib/automation-config.js +140 -140
  120. package/_site/lib/bridge-adapter.js +1 -0
  121. package/_site/lib/bridge-consumer-service.js +402 -402
  122. package/_site/lib/claude-cli-runner.js +1510 -1510
  123. package/_site/lib/commit-conversation-binder.js +234 -234
  124. package/_site/lib/commit-processing-ledger.js +27 -27
  125. package/_site/lib/commit-prompt.js +202 -202
  126. package/_site/lib/commit-reconciler.js +406 -406
  127. package/_site/lib/contracts.js +284 -279
  128. package/_site/lib/conversation-query-service.js +144 -144
  129. package/_site/lib/conversation-store.js +385 -385
  130. package/_site/lib/data-dir.js +74 -74
  131. package/_site/lib/data-state-classifier.js +40 -40
  132. package/_site/lib/engine-cutover.js +97 -0
  133. package/_site/lib/evidence-bundle.js +228 -228
  134. package/_site/lib/folder-picker-output.js +21 -21
  135. package/_site/lib/git-runner.js +62 -62
  136. package/_site/lib/github-team-store.js +1172 -1172
  137. package/_site/lib/hook-manager.js +203 -202
  138. package/_site/lib/index-service.js +173 -173
  139. package/_site/lib/integration-installer.js +907 -907
  140. package/_site/lib/kb-framework.js +176 -176
  141. package/_site/lib/kb-validator.js +124 -124
  142. package/_site/lib/knowledge-promotion.js +435 -435
  143. package/_site/lib/knowledge-retrieval-service.js +401 -401
  144. package/_site/lib/knowledge-tool-runtime.js +359 -359
  145. package/_site/lib/legacy-data-manifest.js +26 -26
  146. package/_site/lib/llm-client.js +161 -161
  147. package/_site/lib/markdown-knowledge-indexer.js +230 -230
  148. package/_site/lib/migration-service.js +442 -442
  149. package/_site/lib/module-bridge.js +198 -33
  150. package/_site/lib/post-commit-automation.js +87 -87
  151. package/_site/lib/project-lifecycle-service.js +519 -497
  152. package/_site/lib/project-store.js +268 -262
  153. package/_site/lib/requirement-recorder.js +278 -278
  154. package/_site/lib/runtime-endpoint.js +155 -155
  155. package/_site/lib/scanner.js +298 -298
  156. package/_site/lib/server-app.js +1590 -1520
  157. package/_site/lib/settings-store.js +133 -113
  158. package/_site/lib/storage-layout.js +162 -162
  159. package/_site/lib/structured-logger.js +574 -574
  160. package/_site/scripts/folder-picker.ps1 +156 -156
  161. package/_site/scripts/hook-trigger.js +157 -156
  162. package/_site/scripts/install-module-candidates.js +69 -0
  163. package/_site/scripts/pack-module-candidates.js +72 -0
  164. package/_site/scripts/sync-release-version.js +2 -2
  165. package/_site/scripts/vendor-modules.js +47 -0
  166. package/_site/scripts/verify-module-runtime.js +45 -0
  167. package/_site/scripts/verify-product-runtime.js +34 -0
  168. package/bin/project-knowledge-mcp.js +194 -194
  169. package/docs/README.zh-CN.md +34 -25
  170. package/docs/project-registry-schema.md +22 -22
  171. package/docs/testing-strategy.md +33 -33
  172. package/module-runtime-manifest.json +220 -0
  173. package/package.json +13 -9
  174. package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
  175. package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
  176. package/plugins/project-knowledge/.mcp.json +1 -1
  177. package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
  178. package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
  179. package/templates/project-readme.md +34 -34
  180. package/ui/favicon.svg +38 -38
  181. package/ui/index.html +51 -149
  182. package/ui/product.css +9 -0
  183. package/ui/product.js +386 -0
  184. package/vendor-manifest.json +46 -0
  185. package/_site/_test/import-ui-flow-test.js +0 -171
  186. package/_site/_test/project-control-panel-task14-test.js +0 -63
  187. package/_site/_test/task15-20-ui-flow-test.js +0 -148
  188. package/_site/_test/ui-i18n-toggle-test.js +0 -114
  189. package/_site/_test/ui-smoke-test.js +0 -73
  190. package/ui/app.css +0 -58
  191. package/ui/app.js +0 -607
  192. package/ui/i18n.js +0 -146
@@ -1,907 +1,907 @@
1
- const fs = require('fs');
2
- const os = require('os');
3
- const path = require('path');
4
- const crypto = require('crypto');
5
- const { spawnSync } = require('child_process');
6
- const crossSpawn = require('cross-spawn');
7
- const { applyEdits, modify, parse } = require('jsonc-parser');
8
- const packageInfo = require('../../package.json');
9
-
10
- // Fail-open module resolution: a missing/unloadable Bridge package must
11
- // degrade Development Capture status, never crash server startup.
12
- let bridgeModule = null;
13
- try {
14
- bridgeModule = require('@sanqianx/ai-coding-event-bridge-console/src/bridge');
15
- } catch (_) {
16
- bridgeModule = null;
17
- }
18
-
19
- const INTEGRATION_NAME = 'project-knowledge';
20
- const MARKETPLACE_NAME = 'project-knowledge';
21
- const DEFAULT_MARKETPLACE_SOURCE = 'SanQianX/project-knowledge-base';
22
- const SUPPORTED_CLIENTS = ['claude', 'opencode', 'codex'];
23
- const DEFAULT_BRIDGE_CONSUMER = 'project-knowledge';
24
- const CLIENT_LABELS = {
25
- claude: 'Claude Code',
26
- opencode: 'OpenCode',
27
- codex: 'Codex',
28
- };
29
- const CLIENT_METHOD_SUFFIX = {
30
- claude: 'Claude',
31
- opencode: 'OpenCode',
32
- codex: 'Codex',
33
- };
34
-
35
- function normalizeClient(value) {
36
- const normalized = String(value || '').trim().toLowerCase();
37
- if (normalized === 'claude-code' || normalized === 'claudecode') return 'claude';
38
- if (normalized === 'codex-cli') return 'codex';
39
- if (SUPPORTED_CLIENTS.includes(normalized)) return normalized;
40
- throw new Error(`unsupported client: ${value}`);
41
- }
42
-
43
- function defaultCommandResolver(name, env = process.env) {
44
- const lookup = process.platform === 'win32' ? 'where.exe' : 'which';
45
- const result = spawnSync(lookup, [name], {
46
- encoding: 'utf8',
47
- windowsHide: true,
48
- env,
49
- });
50
- if (result.status !== 0) return '';
51
- const candidates = String(result.stdout || '')
52
- .split(/\r?\n/)
53
- .map(item => item.trim())
54
- .filter(Boolean);
55
- if (process.platform === 'win32') {
56
- return candidates.find(item => /\.(cmd|exe)$/i.test(item)) || candidates[0] || '';
57
- }
58
- return candidates[0] || '';
59
- }
60
-
61
- function defaultRunner(command, args, options = {}) {
62
- return crossSpawn.sync(command, args, {
63
- encoding: 'utf8',
64
- windowsHide: true,
65
- env: options.env || process.env,
66
- cwd: options.cwd,
67
- timeout: options.timeout || 120_000,
68
- });
69
- }
70
-
71
- function parseJson(text, fallback) {
72
- try {
73
- return JSON.parse(String(text || ''));
74
- } catch {
75
- return fallback;
76
- }
77
- }
78
-
79
- function writeFileAtomic(filePath, content) {
80
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
81
- const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
82
- fs.writeFileSync(temporary, content, 'utf8');
83
- fs.renameSync(temporary, filePath);
84
- }
85
-
86
- function codexNotifyLine(source) {
87
- const lines = String(source || '').split(/\r?\n/);
88
- const index = lines.findIndex(line => /^\s*notify\s*=/.test(line));
89
- if (index < 0) return null;
90
- const value = lines[index].replace(/^\s*notify\s*=\s*/, '');
91
- try {
92
- const args = JSON.parse(value);
93
- return Array.isArray(args) && args.every(item => typeof item === 'string') ? { lines, index, args } : null;
94
- } catch {
95
- // Older Bridge releases wrote Windows paths into TOML double-quoted
96
- // arrays without JSON escaping their backslashes. Codex accepts those
97
- // entries; keep them readable so an upgrade can repair them safely.
98
- if (!/^\s*\[.*\]\s*$/.test(value)) return null;
99
- const args = [];
100
- const matcher = /"((?:\\.|[^"\\])*)"/g;
101
- let lastIndex = 0;
102
- let match;
103
- while ((match = matcher.exec(value))) {
104
- if (!/^[\s,\[]*$/.test(value.slice(lastIndex, match.index))) return null;
105
- args.push(match[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'));
106
- lastIndex = matcher.lastIndex;
107
- }
108
- if (!args.length || !/^[\s,\]]*$/.test(value.slice(lastIndex))) return null;
109
- return { lines, index, args };
110
- }
111
- }
112
-
113
- const NESTED_NOTIFY_MARKERS = new Set(['--previous-notify', '--next-base64', '--bridge-base64']);
114
-
115
- function decodeNestedNotify(marker, value) {
116
- if (!NESTED_NOTIFY_MARKERS.has(marker) || typeof value !== 'string' || value.length > 1024 * 1024) return null;
117
- const candidates = marker === '--previous-notify'
118
- ? [value, Buffer.from(value, 'base64').toString('utf8')]
119
- : [Buffer.from(value, 'base64').toString('utf8')];
120
- for (const candidate of candidates) {
121
- try {
122
- const parsed = JSON.parse(candidate);
123
- if (Array.isArray(parsed) && parsed.every(item => typeof item === 'string')) return parsed;
124
- } catch {
125
- // Try the next supported representation.
126
- }
127
- }
128
- return null;
129
- }
130
-
131
- function notifyTreeSome(args, predicate, depth = 0) {
132
- if (!Array.isArray(args) || depth > 8) return false;
133
- if (predicate(args)) return true;
134
- for (let index = 0; index < args.length - 1; index += 1) {
135
- const nested = decodeNestedNotify(args[index], args[index + 1]);
136
- if (nested && notifyTreeSome(nested, predicate, depth + 1)) return true;
137
- }
138
- return false;
139
- }
140
-
141
- function isDevTaskRadarNotify(args) {
142
- return notifyTreeSome(args, isDirectDevTaskRadarNotify);
143
- }
144
-
145
- function isDirectDevTaskRadarNotify(args) {
146
- return Array.isArray(args) && args.some(arg => /devtask-radar[\\/]connectors[\\/]codex[\\/]notify-hook\.js$/i.test(arg));
147
- }
148
-
149
- function bridgeNextNotifyArgs(bridgeHomeDir) {
150
- return ['node', path.join(bridgeHomeDir, 'bin', 'bridge-hook.cjs')];
151
- }
152
-
153
- function codexNotifyFanoutPath(rootDir) {
154
- return path.join(rootDir, '_site', 'scripts', 'codex-notify-fanout.cjs');
155
- }
156
-
157
- function isProjectKnowledgeFanout(args, rootDir, bridgeHomeDir) {
158
- return notifyTreeSome(args, candidate => {
159
- if (candidate[0] !== 'node' || candidate[1] !== codexNotifyFanoutPath(rootDir)) return false;
160
- const bridgeIndex = candidate.indexOf('--bridge-base64');
161
- if (bridgeIndex < 0 || !candidate[bridgeIndex + 1]) return false;
162
- try {
163
- const bridge = JSON.parse(Buffer.from(candidate[bridgeIndex + 1], 'base64').toString('utf8'));
164
- return Array.isArray(bridge) && bridge.join('\n') === bridgeNextNotifyArgs(bridgeHomeDir).join('\n');
165
- } catch {
166
- return false;
167
- }
168
- });
169
- }
170
-
171
- function isBridgeNextNotify(args, bridgeHomeDir) {
172
- const expected = bridgeNextNotifyArgs(bridgeHomeDir).join('\n');
173
- return notifyTreeSome(args, candidate => candidate.join('\n') === expected);
174
- }
175
-
176
- function isBridgeOnlyNotify(args, bridgeHomeDir) {
177
- return Array.isArray(args) && args.join('\n') === bridgeNextNotifyArgs(bridgeHomeDir).join('\n');
178
- }
179
-
180
- function chainBridgeAfterDevTaskRadar(configFile, bridgeHomeDir) {
181
- if (!configFile || !fs.existsSync(configFile)) return { chained: false, reason: 'config-not-found' };
182
- const source = fs.readFileSync(configFile, 'utf8');
183
- const parsed = codexNotifyLine(source);
184
- if (!parsed || !isDirectDevTaskRadarNotify(parsed.args)) return { chained: false, reason: 'unsupported-third-party-notify' };
185
- if (isBridgeNextNotify(parsed.args, bridgeHomeDir)) return { chained: true, changed: false };
186
- if (parsed.args.includes('--next-base64')) return { chained: false, reason: 'third-party-next-notify-present' };
187
- const next = Buffer.from(JSON.stringify(bridgeNextNotifyArgs(bridgeHomeDir))).toString('base64');
188
- const args = [...parsed.args, '--next-base64', next];
189
- const indentation = (parsed.lines[parsed.index].match(/^\s*/) || [''])[0];
190
- parsed.lines[parsed.index] = `${indentation}notify = ${JSON.stringify(args)}`;
191
- const eol = source.includes('\r\n') ? '\r\n' : '\n';
192
- writeFileAtomic(configFile, parsed.lines.join(eol));
193
- return { chained: true, changed: true };
194
- }
195
-
196
- function wrapDevTaskRadarNotify(configFile, rootDir, bridgeHomeDir) {
197
- if (!configFile || !fs.existsSync(configFile)) return { chained: false, reason: 'config-not-found' };
198
- const source = fs.readFileSync(configFile, 'utf8');
199
- const parsed = codexNotifyLine(source);
200
- if (!parsed) return { chained: false, reason: 'unsupported-third-party-notify' };
201
- if (isProjectKnowledgeFanout(parsed.args, rootDir, bridgeHomeDir)) return { chained: true, changed: false };
202
- if (!JSON.stringify(parsed.args).includes('devtask-radar')) return { chained: false, reason: 'unsupported-third-party-notify' };
203
- const next = Buffer.from(JSON.stringify(parsed.args)).toString('base64');
204
- const bridge = Buffer.from(JSON.stringify(bridgeNextNotifyArgs(bridgeHomeDir))).toString('base64');
205
- const fanout = ['node', codexNotifyFanoutPath(rootDir), '--next-base64', next, '--bridge-base64', bridge];
206
- const indentation = (parsed.lines[parsed.index].match(/^\s*/) || [''])[0];
207
- parsed.lines[parsed.index] = `${indentation}notify = ${JSON.stringify(fanout)}`;
208
- const eol = source.includes('\r\n') ? '\r\n' : '\n';
209
- writeFileAtomic(configFile, parsed.lines.join(eol));
210
- return { chained: true, changed: true };
211
- }
212
-
213
- function wrapBridgeOnlyNotify(configFile, rootDir, bridgeHomeDir) {
214
- if (!configFile || !fs.existsSync(configFile)) return { chained: false, reason: 'config-not-found' };
215
- const source = fs.readFileSync(configFile, 'utf8');
216
- const parsed = codexNotifyLine(source);
217
- if (!parsed || !isBridgeOnlyNotify(parsed.args, bridgeHomeDir)) return { chained: false, reason: 'not-bridge-only-notify' };
218
- const bridge = Buffer.from(JSON.stringify(bridgeNextNotifyArgs(bridgeHomeDir))).toString('base64');
219
- const fanout = ['node', codexNotifyFanoutPath(rootDir), '--bridge-base64', bridge];
220
- const indentation = (parsed.lines[parsed.index].match(/^\s*/) || [''])[0];
221
- parsed.lines[parsed.index] = `${indentation}notify = ${JSON.stringify(fanout)}`;
222
- const eol = source.includes('\r\n') ? '\r\n' : '\n';
223
- writeFileAtomic(configFile, parsed.lines.join(eol));
224
- return { chained: true, changed: true };
225
- }
226
-
227
- function updateJsoncValue(filePath, propertyPath, value) {
228
- let source = fs.existsSync(filePath)
229
- ? fs.readFileSync(filePath, 'utf8')
230
- : '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
231
- const formattingOptions = { insertSpaces: true, tabSize: 2, eol: source.includes('\r\n') ? '\r\n' : '\n' };
232
- const edits = modify(source, propertyPath, value, { formattingOptions });
233
- source = applyEdits(source, edits);
234
- writeFileAtomic(filePath, source);
235
- }
236
-
237
- function openCodeConfigDir(homeDir = os.homedir(), env = process.env) {
238
- if (env.OPENCODE_CONFIG_DIR) return path.resolve(env.OPENCODE_CONFIG_DIR);
239
- if (env.XDG_CONFIG_HOME) return path.join(path.resolve(env.XDG_CONFIG_HOME), 'opencode');
240
- return path.join(homeDir, '.config', 'opencode');
241
- }
242
-
243
- function findOpenCodeConfig(configDir) {
244
- const jsonc = path.join(configDir, 'opencode.jsonc');
245
- const json = path.join(configDir, 'opencode.json');
246
- if (fs.existsSync(jsonc)) return jsonc;
247
- if (fs.existsSync(json)) return json;
248
- return jsonc;
249
- }
250
-
251
- function readOpenCodeState(configDir) {
252
- const configPath = findOpenCodeConfig(configDir);
253
- let config = {};
254
- if (fs.existsSync(configPath)) {
255
- config = parse(fs.readFileSync(configPath, 'utf8')) || {};
256
- }
257
- const legacy = config?.mcp?.[INTEGRATION_NAME];
258
- const v2 = config?.mcp?.servers?.[INTEGRATION_NAME];
259
- const skillPath = path.join(configDir, 'skills', INTEGRATION_NAME, 'SKILL.md');
260
- const instructionPath = path.join(configDir, 'instructions', `${INTEGRATION_NAME}.md`);
261
- const configuredInstructions = Array.isArray(config?.instructions) ? config.instructions : [];
262
- return {
263
- configPath,
264
- mcpInstalled: Boolean(legacy || v2),
265
- skillInstalled: fs.existsSync(skillPath),
266
- instructionInstalled: fs.existsSync(instructionPath)
267
- && configuredInstructions.some(item => path.resolve(String(item)) === path.resolve(instructionPath)),
268
- skillPath,
269
- instructionPath,
270
- };
271
- }
272
-
273
- function ensureOpenCodeInstructionConfig(configDir, instructionPath) {
274
- const configPath = findOpenCodeConfig(configDir);
275
- const config = fs.existsSync(configPath) ? (parse(fs.readFileSync(configPath, 'utf8')) || {}) : {};
276
- const instructions = Array.isArray(config.instructions) ? [...config.instructions] : [];
277
- if (!instructions.some(item => path.resolve(String(item)) === path.resolve(instructionPath))) {
278
- instructions.push(instructionPath);
279
- updateJsoncValue(configPath, ['instructions'], instructions);
280
- }
281
- return configPath;
282
- }
283
-
284
- function removeOpenCodeInstructionConfig(configDir, instructionPath) {
285
- const configPath = findOpenCodeConfig(configDir);
286
- if (!fs.existsSync(configPath)) return false;
287
- const config = parse(fs.readFileSync(configPath, 'utf8')) || {};
288
- if (!Array.isArray(config.instructions)) return false;
289
- const instructions = config.instructions.filter(item => path.resolve(String(item)) !== path.resolve(instructionPath));
290
- if (instructions.length === config.instructions.length) return false;
291
- updateJsoncValue(configPath, ['instructions'], instructions.length ? instructions : undefined);
292
- return true;
293
- }
294
-
295
- function removeOpenCodeMcpConfig(configDir) {
296
- const configPath = findOpenCodeConfig(configDir);
297
- if (!fs.existsSync(configPath)) return false;
298
- let source = fs.readFileSync(configPath, 'utf8');
299
- const config = parse(source) || {};
300
- const paths = [];
301
- if (config?.mcp?.[INTEGRATION_NAME]) paths.push(['mcp', INTEGRATION_NAME]);
302
- if (config?.mcp?.servers?.[INTEGRATION_NAME]) paths.push(['mcp', 'servers', INTEGRATION_NAME]);
303
- if (!paths.length) return false;
304
- const formattingOptions = { insertSpaces: true, tabSize: 2, eol: source.includes('\r\n') ? '\r\n' : '\n' };
305
- for (const propertyPath of paths) {
306
- const edits = modify(source, propertyPath, undefined, { formattingOptions });
307
- source = applyEdits(source, edits);
308
- }
309
- writeFileAtomic(configPath, source);
310
- return true;
311
- }
312
-
313
- class IntegrationManager {
314
- constructor(options = {}) {
315
- this.rootDir = options.rootDir || path.resolve(__dirname, '..', '..');
316
- this.homeDir = options.homeDir || os.homedir();
317
- this.env = options.env || process.env;
318
- this.version = options.version || packageInfo.version;
319
- this.packageSpec = options.packageSpec || `project-knowledge@${this.version}`;
320
- this.marketplaceSource = options.marketplaceSource || DEFAULT_MARKETPLACE_SOURCE;
321
- this.runner = options.runner || defaultRunner;
322
- this.commandResolver = options.commandResolver || defaultCommandResolver;
323
- this.dryRun = Boolean(options.dryRun);
324
- this.logger = options.logger || null;
325
- this.planned = [];
326
- this.bridge = options.bridgeModule || bridgeModule;
327
- this.bridgeConsumerName = options.bridgeConsumerName || DEFAULT_BRIDGE_CONSUMER;
328
- this.bridgeHomeDir = options.bridgeHomeDir
329
- || this.env.AI_CODING_EVENT_BRIDGE_HOME
330
- || path.join(this.homeDir, '.ai-coding-event-bridge');
331
- this.claudeSettingsFile = options.claudeSettingsFile || '';
332
- this.codexConfigFile = options.codexConfigFile || '';
333
- this.openCodePluginsDir = options.openCodePluginsDir || '';
334
- }
335
-
336
- _bridgeFacade() {
337
- return this.bridge.createBridge({ homeDir: this.bridgeHomeDir });
338
- }
339
-
340
- _captureInstallerOptions() {
341
- return {
342
- homeDir: this.bridgeHomeDir,
343
- settingsFile: this.claudeSettingsFile || undefined,
344
- configFile: this.codexConfigFile || undefined,
345
- pluginsDir: this.openCodePluginsDir || undefined,
346
- };
347
- }
348
-
349
- // ---- Bridge host consumer (host-level, never owned by one connector) ----
350
-
351
- async registerBridgeConsumer(meta = {}) {
352
- if (this.dryRun) {
353
- this.planned.push({ command: 'bridge', args: ['registerConsumer', this.bridgeConsumerName] });
354
- return { state: 'planned', consumerName: this.bridgeConsumerName };
355
- }
356
- const facade = this._bridgeFacade();
357
- const consumer = await facade.registerConsumer(this.bridgeConsumerName, meta);
358
- return { state: 'registered', consumerName: this.bridgeConsumerName, consumer };
359
- }
360
-
361
- async unregisterBridgeConsumer() {
362
- if (this.dryRun) {
363
- this.planned.push({ command: 'bridge', args: ['unregisterConsumer', this.bridgeConsumerName] });
364
- return { state: 'planned', consumerName: this.bridgeConsumerName };
365
- }
366
- const facade = this._bridgeFacade();
367
- await facade.unregisterConsumer(this.bridgeConsumerName);
368
- return { state: 'unregistered', consumerName: this.bridgeConsumerName };
369
- }
370
-
371
- async bridgeStatus() {
372
- const facade = this._bridgeFacade();
373
- const [consumer, health] = await Promise.all([facade.getConsumer(this.bridgeConsumerName), facade.getHealth()]);
374
- return {
375
- consumerRegistered: Boolean(consumer),
376
- consumerName: this.bridgeConsumerName,
377
- bridgeHealthy: true,
378
- health,
379
- };
380
- }
381
-
382
- // ---- Development Capture connectors (Bridge hooks / notify / plugin) ----
383
-
384
- async installCaptureClaude() {
385
- if (this.dryRun) {
386
- this.planned.push({ command: 'bridge', args: ['installClaudeCodeHook', this.bridgeHomeDir] });
387
- return { state: 'planned', installed: false, conflict: false, detail: 'managed Bridge hooks (dry-run)' };
388
- }
389
- const result = await this.bridge.installers.claudeCode.installClaudeCodeHook(this._captureInstallerOptions());
390
- return { state: 'installed', installed: true, conflict: false, detail: `managed hooks: ${result.added.join(', ') || 'already present'}` };
391
- }
392
-
393
- async statusCaptureClaude() {
394
- const status = await this.bridge.installers.claudeCode.statusClaudeCodeHook(this._captureInstallerOptions());
395
- return {
396
- state: status.installed ? 'installed' : 'not-installed',
397
- installed: status.installed,
398
- conflict: false,
399
- detail: status.installed ? `managed events: ${status.managedEvents.join(', ')}` : 'managed hooks not installed',
400
- };
401
- }
402
-
403
- async uninstallCaptureClaude(options = {}) {
404
- await this.bridge.installers.claudeCode.uninstallClaudeCodeHook({
405
- ...this._captureInstallerOptions(),
406
- unregisterConsumer: options.unregisterConsumer === true,
407
- consumerName: this.bridgeConsumerName,
408
- });
409
- return { state: 'removed', detail: options.unregisterConsumer === true ? 'managed hooks removed; host consumer unregistered' : 'managed hooks removed; host consumer kept' };
410
- }
411
-
412
- async installCaptureCodex() {
413
- if (this.dryRun) {
414
- this.planned.push({ command: 'bridge', args: ['installCodexNotify', this.bridgeHomeDir] });
415
- return { state: 'planned', installed: false, conflict: false, detail: 'managed Codex notify (dry-run)' };
416
- }
417
- const result = await this.bridge.installers.codex.installCodexNotify(this._captureInstallerOptions());
418
- if (result.conflict) {
419
- const configFile = this.codexConfigFile || path.join(this.homeDir, '.codex', 'config.toml');
420
- const chained = chainBridgeAfterDevTaskRadar(configFile, this.bridgeHomeDir);
421
- if (chained.chained) {
422
- return { state: 'installed', installed: true, conflict: false, detail: 'Bridge notify chained after the compatible DevTask Radar notifier' };
423
- }
424
- const wrapped = wrapDevTaskRadarNotify(configFile, this.rootDir, this.bridgeHomeDir);
425
- if (wrapped.chained) {
426
- return { state: 'installed', installed: true, conflict: false, detail: 'Bridge notify fan-out preserves the existing DevTask Radar notifier' };
427
- }
428
- return { state: 'conflict', installed: false, conflict: true, detail: 'third-party notify present; not overwritten' };
429
- }
430
- const configFile = this.codexConfigFile || path.join(this.homeDir, '.codex', 'config.toml');
431
- const wrapped = wrapBridgeOnlyNotify(configFile, this.rootDir, this.bridgeHomeDir);
432
- if (!wrapped.chained) {
433
- return { state: 'failed', installed: false, conflict: false, detail: 'managed notify could not be configured for Codex session capture' };
434
- }
435
- return { state: 'installed', installed: true, conflict: false, detail: 'managed Bridge notify fan-out installed' };
436
- }
437
-
438
- async statusCaptureCodex() {
439
- const status = await this.bridge.installers.codex.statusCodexNotify(this._captureInstallerOptions());
440
- if (status.thirdParty) {
441
- const configFile = this.codexConfigFile || path.join(this.homeDir, '.codex', 'config.toml');
442
- const source = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf8') : '';
443
- const parsed = codexNotifyLine(source);
444
- if (parsed && isProjectKnowledgeFanout(parsed.args, this.rootDir, this.bridgeHomeDir)) {
445
- return { state: 'installed', installed: true, conflict: false, detail: 'Bridge notify fan-out preserves the existing DevTask Radar notifier' };
446
- }
447
- if (parsed && isDevTaskRadarNotify(parsed.args) && isBridgeNextNotify(parsed.args, this.bridgeHomeDir)) {
448
- return { state: 'installed', installed: true, conflict: false, detail: 'Bridge notify chained after the compatible DevTask Radar notifier' };
449
- }
450
- }
451
- return {
452
- state: status.installed ? 'installed' : status.thirdParty ? 'conflict' : 'not-installed',
453
- installed: Boolean(status.installed),
454
- conflict: Boolean(status.thirdParty),
455
- detail: status.installed ? 'managed notify installed' : status.thirdParty ? 'third-party notify present' : 'notify not installed',
456
- };
457
- }
458
-
459
- async uninstallCaptureCodex(options = {}) {
460
- await this.bridge.installers.codex.uninstallCodexNotify({
461
- ...this._captureInstallerOptions(),
462
- unregisterConsumer: options.unregisterConsumer === true,
463
- consumerName: this.bridgeConsumerName,
464
- });
465
- return { state: 'removed', detail: 'managed notify removed' };
466
- }
467
-
468
- async installCaptureOpenCode() {
469
- if (this.dryRun) {
470
- this.planned.push({ command: 'bridge', args: ['installOpenCodePlugin', this.bridgeHomeDir] });
471
- return { state: 'planned', installed: false, conflict: false, detail: 'managed OpenCode plugin (dry-run)' };
472
- }
473
- await this.bridge.installers.openCode.installOpenCodePlugin(this._captureInstallerOptions());
474
- return { state: 'installed', installed: true, conflict: false, detail: 'managed plugin installed' };
475
- }
476
-
477
- async statusCaptureOpenCode() {
478
- const status = await this.bridge.installers.openCode.statusOpenCodePlugin(this._captureInstallerOptions());
479
- return {
480
- state: status.installed ? 'installed' : 'not-installed',
481
- installed: Boolean(status.installed),
482
- conflict: false,
483
- detail: status.installed ? 'managed plugin installed' : `plugin not installed (${status.thirdPartyFiles} third-party files preserved)`,
484
- };
485
- }
486
-
487
- async uninstallCaptureOpenCode(options = {}) {
488
- await this.bridge.installers.openCode.uninstallOpenCodePlugin({
489
- ...this._captureInstallerOptions(),
490
- unregisterConsumer: options.unregisterConsumer === true,
491
- consumerName: this.bridgeConsumerName,
492
- });
493
- return { state: 'removed', detail: 'managed plugin removed' };
494
- }
495
-
496
- log(level, event, message, input = {}) {
497
- if (!this.logger || typeof this.logger[level] !== 'function') return;
498
- Promise.resolve(this.logger[level](event, message, { component: 'integration-manager', ...input })).catch(() => {
499
- try { process.stderr.write(`[integration-manager logger fallback] ${event}\n`); } catch {
500
- // stderr is the final non-recursive observer fallback.
501
- }
502
- });
503
- }
504
-
505
- executable(client) {
506
- const name = client === 'claude' ? 'claude' : client;
507
- if (this.dryRun) return process.platform === 'win32' ? `${name}.cmd` : name;
508
- return this.commandResolver(name, this.env);
509
- }
510
-
511
- detectClients() {
512
- return SUPPORTED_CLIENTS.filter(client => Boolean(this.executable(client)));
513
- }
514
-
515
- execute(client, args, options = {}) {
516
- const startedAt = Date.now();
517
- const command = this.executable(client);
518
- this.planned.push({ command: command || client, args: [...args] });
519
- const argsHash = `sha256:${crypto.createHash('sha256').update(JSON.stringify(args), 'utf8').digest('hex')}`;
520
- this.log('info', 'integration.command_started', 'Integration CLI command started.', { context: { client, argumentCount: args.length, argsHash, dryRun: this.dryRun } });
521
- if (this.dryRun) {
522
- this.log('info', 'integration.command_completed', 'Integration CLI command completed.', { durationMs: Date.now() - startedAt, context: { client, status: 0, argsHash, dryRun: true } });
523
- return { status: 0, stdout: '', stderr: '' };
524
- }
525
- if (!command) {
526
- this.log('error', 'integration.command_failed', 'Integration CLI command failed.', { durationMs: Date.now() - startedAt, error: Object.assign(new Error('Integration CLI is unavailable.'), { code: 'INTEGRATION_CLI_UNAVAILABLE' }), context: { client, argsHash } });
527
- throw new Error(`${CLIENT_LABELS[client]} CLI is not installed or not on PATH`);
528
- }
529
- const result = this.runner(command, args, {
530
- env: this.env,
531
- cwd: this.rootDir,
532
- timeout: options.timeout,
533
- });
534
- if (result.error) {
535
- this.log('error', 'integration.command_failed', 'Integration CLI command failed.', { durationMs: Date.now() - startedAt, error: Object.assign(new Error('Integration CLI command failed.'), { code: result.error.code || 'INTEGRATION_COMMAND_FAILED' }), context: { client, argsHash } });
536
- throw result.error;
537
- }
538
- if (result.status !== 0 && !options.allowFailure) {
539
- const detail = String(result.stderr || result.stdout || '').trim();
540
- this.log('error', 'integration.command_failed', 'Integration CLI command failed.', { durationMs: Date.now() - startedAt, error: Object.assign(new Error('Integration CLI command failed.'), { code: 'INTEGRATION_COMMAND_FAILED' }), context: { client, status: result.status, argsHash, outputHash: `sha256:${crypto.createHash('sha256').update(detail, 'utf8').digest('hex')}` } });
541
- throw new Error(detail || `${CLIENT_LABELS[client]} command failed with exit code ${result.status}`);
542
- }
543
- this.log('info', 'integration.command_completed', 'Integration CLI command completed.', { durationMs: Date.now() - startedAt, context: { client, status: result.status, argsHash } });
544
- return result;
545
- }
546
-
547
- claudePlugin() {
548
- const result = this.execute('claude', ['plugin', 'list', '--json'], { allowFailure: true });
549
- const plugins = parseJson(result.stdout, []);
550
- return Array.isArray(plugins)
551
- ? plugins.find(item => item.id === `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`)
552
- : undefined;
553
- }
554
-
555
- claudeMarketplaceExists() {
556
- const result = this.execute('claude', ['plugin', 'marketplace', 'list', '--json'], { allowFailure: true });
557
- const marketplaces = parseJson(result.stdout, []);
558
- return Array.isArray(marketplaces) && marketplaces.some(item => item.name === MARKETPLACE_NAME);
559
- }
560
-
561
- codexPlugin() {
562
- const result = this.execute('codex', ['plugin', 'list', '--json'], { allowFailure: true });
563
- const data = parseJson(result.stdout, {});
564
- return Array.isArray(data?.installed)
565
- ? data.installed.find(item => item.pluginId === `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`)
566
- : undefined;
567
- }
568
-
569
- codexMarketplaceExists() {
570
- const result = this.execute('codex', ['plugin', 'marketplace', 'list'], { allowFailure: true });
571
- return String(result.stdout || '').split(/\r?\n/).some(line => line.trim().startsWith(`${MARKETPLACE_NAME} `));
572
- }
573
-
574
- ensureClaudeMarketplace() {
575
- if (!this.dryRun && this.claudeMarketplaceExists()) return;
576
- this.execute('claude', ['plugin', 'marketplace', 'add', this.marketplaceSource]);
577
- }
578
-
579
- ensureCodexMarketplace() {
580
- if (!this.dryRun && this.codexMarketplaceExists()) return;
581
- this.execute('codex', ['plugin', 'marketplace', 'add', this.marketplaceSource]);
582
- }
583
-
584
- installClaude(options = {}) {
585
- this.ensureClaudeMarketplace();
586
- if (!this.dryRun && this.claudePlugin()) {
587
- return { state: 'installed', detail: 'plugin already installed' };
588
- }
589
- this.execute('claude', [
590
- 'plugin',
591
- 'install',
592
- `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`,
593
- '--scope',
594
- options.scope || 'user',
595
- ]);
596
- return { state: 'installed', detail: 'plugin, MCP, and Skill installed' };
597
- }
598
-
599
- updateClaude(options = {}) {
600
- this.ensureClaudeMarketplace();
601
- this.execute('claude', ['plugin', 'marketplace', 'update', MARKETPLACE_NAME]);
602
- if (!this.dryRun && !this.claudePlugin()) return this.installClaude(options);
603
- this.execute('claude', [
604
- 'plugin',
605
- 'update',
606
- `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`,
607
- '--scope',
608
- options.scope || 'user',
609
- ]);
610
- return { state: 'updated', detail: 'plugin marketplace and installed plugin updated' };
611
- }
612
-
613
- uninstallClaude(options = {}) {
614
- if (this.dryRun || this.claudePlugin()) {
615
- this.execute('claude', [
616
- 'plugin',
617
- 'uninstall',
618
- `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`,
619
- '--scope',
620
- options.scope || 'user',
621
- ]);
622
- }
623
- if (this.dryRun || this.claudeMarketplaceExists()) {
624
- this.execute('claude', ['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
625
- }
626
- return { state: 'removed', detail: 'plugin and marketplace registration removed' };
627
- }
628
-
629
- statusClaude() {
630
- const command = this.executable('claude');
631
- if (!command) return { state: 'unavailable', installed: false, detail: 'Claude Code CLI not found' };
632
- const plugin = this.claudePlugin();
633
- return {
634
- state: plugin ? 'installed' : 'not-installed',
635
- installed: Boolean(plugin),
636
- version: plugin?.version,
637
- detail: plugin ? `plugin enabled: ${plugin.enabled !== false}` : 'plugin not installed',
638
- };
639
- }
640
-
641
- installCodex() {
642
- this.ensureCodexMarketplace();
643
- if (!this.dryRun && this.codexPlugin()) {
644
- return { state: 'installed', detail: 'plugin already installed' };
645
- }
646
- this.execute('codex', ['plugin', 'add', `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`]);
647
- return { state: 'installed', detail: 'plugin, MCP, and Skill installed' };
648
- }
649
-
650
- updateCodex() {
651
- this.ensureCodexMarketplace();
652
- this.execute('codex', ['plugin', 'marketplace', 'upgrade', MARKETPLACE_NAME]);
653
- if (this.dryRun || this.codexPlugin()) {
654
- this.execute('codex', ['plugin', 'remove', `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`]);
655
- }
656
- this.execute('codex', ['plugin', 'add', `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`]);
657
- return { state: 'updated', detail: 'marketplace refreshed and plugin reinstalled' };
658
- }
659
-
660
- uninstallCodex() {
661
- if (this.dryRun || this.codexPlugin()) {
662
- this.execute('codex', ['plugin', 'remove', `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`]);
663
- }
664
- if (this.dryRun || this.codexMarketplaceExists()) {
665
- this.execute('codex', ['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
666
- }
667
- return { state: 'removed', detail: 'plugin and marketplace registration removed' };
668
- }
669
-
670
- statusCodex() {
671
- const command = this.executable('codex');
672
- if (!command) return { state: 'unavailable', installed: false, detail: 'Codex CLI not found' };
673
- const plugin = this.codexPlugin();
674
- return {
675
- state: plugin ? 'installed' : 'not-installed',
676
- installed: Boolean(plugin),
677
- version: plugin?.version,
678
- detail: plugin ? `plugin enabled: ${plugin.enabled !== false}` : 'plugin not installed',
679
- };
680
- }
681
-
682
- openCodeSkillSource() {
683
- return path.join(
684
- this.rootDir,
685
- 'plugins',
686
- INTEGRATION_NAME,
687
- 'skills',
688
- INTEGRATION_NAME,
689
- );
690
- }
691
-
692
- openCodeInstructionSource() {
693
- return path.join(this.rootDir, 'plugins', INTEGRATION_NAME, 'opencode', `${INTEGRATION_NAME}.md`);
694
- }
695
-
696
- openCodeConfigDir() {
697
- return openCodeConfigDir(this.homeDir, this.env);
698
- }
699
-
700
- installOpenCode() {
701
- const source = this.openCodeSkillSource();
702
- const instructionSource = this.openCodeInstructionSource();
703
- const configDir = this.openCodeConfigDir();
704
- const destination = path.join(this.openCodeConfigDir(), 'skills', INTEGRATION_NAME);
705
- const instructionDestination = path.join(configDir, 'instructions', `${INTEGRATION_NAME}.md`);
706
- if (!this.dryRun) {
707
- if (!fs.existsSync(path.join(source, 'SKILL.md'))) throw new Error(`bundled Skill not found: ${source}`);
708
- if (!fs.existsSync(instructionSource)) throw new Error(`bundled OpenCode instructions not found: ${instructionSource}`);
709
- fs.mkdirSync(destination, { recursive: true });
710
- fs.cpSync(source, destination, { recursive: true, force: true });
711
- fs.mkdirSync(path.dirname(instructionDestination), { recursive: true });
712
- fs.copyFileSync(instructionSource, instructionDestination);
713
- }
714
- this.planned.push({ copy: source, destination });
715
- this.planned.push({ copy: instructionSource, destination: instructionDestination });
716
- this.execute('opencode', [
717
- 'mcp',
718
- 'add',
719
- INTEGRATION_NAME,
720
- '--',
721
- 'npx',
722
- '-y',
723
- '--package',
724
- this.packageSpec,
725
- 'project-knowledge-mcp',
726
- ]);
727
- if (!this.dryRun) ensureOpenCodeInstructionConfig(configDir, instructionDestination);
728
- return { state: 'installed', detail: 'MCP, global Skill, and Instructions installed' };
729
- }
730
-
731
- updateOpenCode() {
732
- const result = this.installOpenCode();
733
- return { ...result, state: 'updated', detail: 'MCP configuration, global Skill, and Instructions updated' };
734
- }
735
-
736
- uninstallOpenCode() {
737
- const configDir = this.openCodeConfigDir();
738
- const skillDir = path.join(configDir, 'skills', INTEGRATION_NAME);
739
- const instructionPath = path.join(configDir, 'instructions', `${INTEGRATION_NAME}.md`);
740
- if (!this.dryRun) {
741
- removeOpenCodeMcpConfig(configDir);
742
- removeOpenCodeInstructionConfig(configDir, instructionPath);
743
- if (fs.existsSync(skillDir)) fs.rmSync(skillDir, { recursive: true, force: true });
744
- if (fs.existsSync(instructionPath)) fs.rmSync(instructionPath, { force: true });
745
- }
746
- this.planned.push({
747
- removeConfig: findOpenCodeConfig(configDir),
748
- removeDirectory: skillDir,
749
- removeFile: instructionPath,
750
- });
751
- return { state: 'removed', detail: 'MCP configuration, global Skill, and Instructions removed' };
752
- }
753
-
754
- statusOpenCode() {
755
- const available = Boolean(this.executable('opencode'));
756
- const state = readOpenCodeState(this.openCodeConfigDir());
757
- const installed = state.mcpInstalled && state.skillInstalled && state.instructionInstalled;
758
- let detail = 'MCP, Skill, and Instructions not installed';
759
- if (installed) detail = 'MCP, global Skill, and Instructions installed';
760
- else if (state.mcpInstalled && state.skillInstalled) detail = 'MCP and Skill installed; Instructions missing';
761
- else if (state.mcpInstalled) detail = 'MCP installed; Skill missing';
762
- else if (state.skillInstalled) detail = 'Skill installed; MCP missing';
763
- if (!available) detail = `OpenCode CLI not found; ${detail}`;
764
- return {
765
- state: installed ? 'installed' : available ? 'not-installed' : 'unavailable',
766
- installed,
767
- detail,
768
- configPath: state.configPath,
769
- skillPath: state.skillPath,
770
- instructionPath: state.instructionPath,
771
- };
772
- }
773
-
774
- async operate(operation, clients, options = {}) {
775
- const results = [];
776
- for (const rawClient of clients) {
777
- const client = normalizeClient(rawClient);
778
- try {
779
- const suffix = CLIENT_METHOD_SUFFIX[client];
780
- let result;
781
- if (operation === 'status') result = this[`status${suffix}`]();
782
- else if (operation === 'install') result = this[`install${suffix}`](options);
783
- else if (operation === 'update') result = this[`update${suffix}`](options);
784
- else if (operation === 'uninstall') result = this[`uninstall${suffix}`](options);
785
- else throw new Error(`unsupported operation: ${operation}`);
786
- if (this.dryRun && operation !== 'status') {
787
- result = { ...result, state: 'planned', detail: `${result.detail} (dry-run)` };
788
- }
789
- results.push({ client, label: CLIENT_LABELS[client], ok: true, ...result });
790
- } catch (error) {
791
- results.push({
792
- client,
793
- label: CLIENT_LABELS[client],
794
- ok: false,
795
- state: 'failed',
796
- detail: error.message,
797
- });
798
- }
799
- }
800
- return results;
801
- }
802
-
803
- async operateCapture(operation, clients, options = {}) {
804
- const results = [];
805
- for (const rawClient of clients) {
806
- const client = normalizeClient(rawClient);
807
- const suffix = CLIENT_METHOD_SUFFIX[client];
808
- try {
809
- const result = await this[`${operation}Capture${suffix}`](options);
810
- results.push({ client, label: CLIENT_LABELS[client], ok: true, ...result });
811
- } catch (error) {
812
- results.push({ client, label: CLIENT_LABELS[client], ok: false, state: 'failed', detail: error.message });
813
- }
814
- }
815
- return results;
816
- }
817
-
818
- // One-click Integration Setup: register the host Bridge consumer once,
819
- // then install BOTH capabilities per client. Knowledge Integration and
820
- // Development Capture are reported separately; one failing component never
821
- // rolls back or blocks the others.
822
- async installAll(options = {}) {
823
- const clients = options.clients && options.clients.length ? options.clients.map(normalizeClient) : this.detectClients();
824
- const summary = { clients: [], bridge: null };
825
- try {
826
- summary.bridge = { ok: true, ...(await this.registerBridgeConsumer(options.bridgeConsumerMeta || {})) };
827
- } catch (error) {
828
- summary.bridge = { ok: false, state: 'failed', detail: error.message };
829
- }
830
- for (const client of clients) {
831
- const suffix = CLIENT_METHOD_SUFFIX[client];
832
- const entry = { client, label: CLIENT_LABELS[client] };
833
- if (options.captureOnly !== true) {
834
- try {
835
- entry.knowledgeIntegration = { ok: true, ...(await this[`install${suffix}`](options)) };
836
- } catch (error) {
837
- entry.knowledgeIntegration = { ok: false, state: 'failed', detail: error.message };
838
- }
839
- }
840
- if (options.knowledgeOnly !== true) {
841
- try {
842
- entry.developmentCapture = { ok: true, ...(await this[`installCapture${suffix}`]()) };
843
- } catch (error) {
844
- entry.developmentCapture = { ok: false, state: 'failed', detail: error.message };
845
- }
846
- }
847
- summary.clients.push(entry);
848
- }
849
- return summary;
850
- }
851
-
852
- async statusAll(options = {}) {
853
- const clients = options.clients && options.clients.length ? options.clients.map(normalizeClient) : this.detectClients();
854
- const summary = { clients: [], bridge: null };
855
- try {
856
- summary.bridge = { ok: true, ...(await this.bridgeStatus()) };
857
- } catch (error) {
858
- summary.bridge = { ok: false, state: 'failed', bridgeHealthy: false, detail: error.message };
859
- }
860
- for (const client of clients) {
861
- const suffix = CLIENT_METHOD_SUFFIX[client];
862
- const entry = { client, label: CLIENT_LABELS[client], available: Boolean(this.executable(client)) };
863
- try {
864
- entry.knowledgeIntegration = { ok: true, ...(await this[`status${suffix}`]()) };
865
- } catch (error) {
866
- entry.knowledgeIntegration = { ok: false, state: 'failed', detail: error.message };
867
- }
868
- try {
869
- entry.developmentCapture = { ok: true, ...(await this[`statusCapture${suffix}`]()) };
870
- } catch (error) {
871
- entry.developmentCapture = { ok: false, state: 'failed', detail: error.message };
872
- }
873
- summary.clients.push(entry);
874
- }
875
- return summary;
876
- }
877
-
878
- // Global disable: remove every connector first, then (and only then)
879
- // unregister the host Bridge consumer (GATE INSTALL-003 semantics).
880
- async disableAllCapture(options = {}) {
881
- const clients = options.clients && options.clients.length ? options.clients.map(normalizeClient) : SUPPORTED_CLIENTS;
882
- const results = await this.operateCapture('uninstall', clients, { unregisterConsumer: false });
883
- let consumer = null;
884
- try {
885
- consumer = { ok: true, ...(await this.unregisterBridgeConsumer()) };
886
- } catch (error) {
887
- consumer = { ok: false, state: 'failed', detail: error.message };
888
- }
889
- return { clients: results, consumer };
890
- }
891
- }
892
-
893
- module.exports = {
894
- CLIENT_LABELS,
895
- DEFAULT_MARKETPLACE_SOURCE,
896
- INTEGRATION_NAME,
897
- IntegrationManager,
898
- MARKETPLACE_NAME,
899
- SUPPORTED_CLIENTS,
900
- findOpenCodeConfig,
901
- ensureOpenCodeInstructionConfig,
902
- normalizeClient,
903
- openCodeConfigDir,
904
- readOpenCodeState,
905
- removeOpenCodeMcpConfig,
906
- removeOpenCodeInstructionConfig,
907
- };
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const { spawnSync } = require('child_process');
6
+ const crossSpawn = require('cross-spawn');
7
+ const { applyEdits, modify, parse } = require('jsonc-parser');
8
+ const packageInfo = require('../../package.json');
9
+
10
+ // Fail-open module resolution: a missing/unloadable Bridge package must
11
+ // degrade Development Capture status, never crash server startup.
12
+ let bridgeModule = null;
13
+ try {
14
+ bridgeModule = require('@sanqianx/ai-coding-event-bridge-console/src/bridge');
15
+ } catch (_) {
16
+ bridgeModule = null;
17
+ }
18
+
19
+ const INTEGRATION_NAME = 'project-knowledge';
20
+ const MARKETPLACE_NAME = 'project-knowledge';
21
+ const DEFAULT_MARKETPLACE_SOURCE = 'SanQianX/project-knowledge-base';
22
+ const SUPPORTED_CLIENTS = ['claude', 'opencode', 'codex'];
23
+ const DEFAULT_BRIDGE_CONSUMER = 'project-knowledge';
24
+ const CLIENT_LABELS = {
25
+ claude: 'Claude Code',
26
+ opencode: 'OpenCode',
27
+ codex: 'Codex',
28
+ };
29
+ const CLIENT_METHOD_SUFFIX = {
30
+ claude: 'Claude',
31
+ opencode: 'OpenCode',
32
+ codex: 'Codex',
33
+ };
34
+
35
+ function normalizeClient(value) {
36
+ const normalized = String(value || '').trim().toLowerCase();
37
+ if (normalized === 'claude-code' || normalized === 'claudecode') return 'claude';
38
+ if (normalized === 'codex-cli') return 'codex';
39
+ if (SUPPORTED_CLIENTS.includes(normalized)) return normalized;
40
+ throw new Error(`unsupported client: ${value}`);
41
+ }
42
+
43
+ function defaultCommandResolver(name, env = process.env) {
44
+ const lookup = process.platform === 'win32' ? 'where.exe' : 'which';
45
+ const result = spawnSync(lookup, [name], {
46
+ encoding: 'utf8',
47
+ windowsHide: true,
48
+ env,
49
+ });
50
+ if (result.status !== 0) return '';
51
+ const candidates = String(result.stdout || '')
52
+ .split(/\r?\n/)
53
+ .map(item => item.trim())
54
+ .filter(Boolean);
55
+ if (process.platform === 'win32') {
56
+ return candidates.find(item => /\.(cmd|exe)$/i.test(item)) || candidates[0] || '';
57
+ }
58
+ return candidates[0] || '';
59
+ }
60
+
61
+ function defaultRunner(command, args, options = {}) {
62
+ return crossSpawn.sync(command, args, {
63
+ encoding: 'utf8',
64
+ windowsHide: true,
65
+ env: options.env || process.env,
66
+ cwd: options.cwd,
67
+ timeout: options.timeout || 120_000,
68
+ });
69
+ }
70
+
71
+ function parseJson(text, fallback) {
72
+ try {
73
+ return JSON.parse(String(text || ''));
74
+ } catch {
75
+ return fallback;
76
+ }
77
+ }
78
+
79
+ function writeFileAtomic(filePath, content) {
80
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
81
+ const temporary = `${filePath}.${process.pid}.${Date.now()}.tmp`;
82
+ fs.writeFileSync(temporary, content, 'utf8');
83
+ fs.renameSync(temporary, filePath);
84
+ }
85
+
86
+ function codexNotifyLine(source) {
87
+ const lines = String(source || '').split(/\r?\n/);
88
+ const index = lines.findIndex(line => /^\s*notify\s*=/.test(line));
89
+ if (index < 0) return null;
90
+ const value = lines[index].replace(/^\s*notify\s*=\s*/, '');
91
+ try {
92
+ const args = JSON.parse(value);
93
+ return Array.isArray(args) && args.every(item => typeof item === 'string') ? { lines, index, args } : null;
94
+ } catch {
95
+ // Older Bridge releases wrote Windows paths into TOML double-quoted
96
+ // arrays without JSON escaping their backslashes. Codex accepts those
97
+ // entries; keep them readable so an upgrade can repair them safely.
98
+ if (!/^\s*\[.*\]\s*$/.test(value)) return null;
99
+ const args = [];
100
+ const matcher = /"((?:\\.|[^"\\])*)"/g;
101
+ let lastIndex = 0;
102
+ let match;
103
+ while ((match = matcher.exec(value))) {
104
+ if (!/^[\s,\[]*$/.test(value.slice(lastIndex, match.index))) return null;
105
+ args.push(match[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\'));
106
+ lastIndex = matcher.lastIndex;
107
+ }
108
+ if (!args.length || !/^[\s,\]]*$/.test(value.slice(lastIndex))) return null;
109
+ return { lines, index, args };
110
+ }
111
+ }
112
+
113
+ const NESTED_NOTIFY_MARKERS = new Set(['--previous-notify', '--next-base64', '--bridge-base64']);
114
+
115
+ function decodeNestedNotify(marker, value) {
116
+ if (!NESTED_NOTIFY_MARKERS.has(marker) || typeof value !== 'string' || value.length > 1024 * 1024) return null;
117
+ const candidates = marker === '--previous-notify'
118
+ ? [value, Buffer.from(value, 'base64').toString('utf8')]
119
+ : [Buffer.from(value, 'base64').toString('utf8')];
120
+ for (const candidate of candidates) {
121
+ try {
122
+ const parsed = JSON.parse(candidate);
123
+ if (Array.isArray(parsed) && parsed.every(item => typeof item === 'string')) return parsed;
124
+ } catch {
125
+ // Try the next supported representation.
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function notifyTreeSome(args, predicate, depth = 0) {
132
+ if (!Array.isArray(args) || depth > 8) return false;
133
+ if (predicate(args)) return true;
134
+ for (let index = 0; index < args.length - 1; index += 1) {
135
+ const nested = decodeNestedNotify(args[index], args[index + 1]);
136
+ if (nested && notifyTreeSome(nested, predicate, depth + 1)) return true;
137
+ }
138
+ return false;
139
+ }
140
+
141
+ function isDevTaskRadarNotify(args) {
142
+ return notifyTreeSome(args, isDirectDevTaskRadarNotify);
143
+ }
144
+
145
+ function isDirectDevTaskRadarNotify(args) {
146
+ return Array.isArray(args) && args.some(arg => /devtask-radar[\\/]connectors[\\/]codex[\\/]notify-hook\.js$/i.test(arg));
147
+ }
148
+
149
+ function bridgeNextNotifyArgs(bridgeHomeDir) {
150
+ return ['node', path.join(bridgeHomeDir, 'bin', 'bridge-hook.cjs')];
151
+ }
152
+
153
+ function codexNotifyFanoutPath(rootDir) {
154
+ return path.join(rootDir, '_site', 'scripts', 'codex-notify-fanout.cjs');
155
+ }
156
+
157
+ function isProjectKnowledgeFanout(args, rootDir, bridgeHomeDir) {
158
+ return notifyTreeSome(args, candidate => {
159
+ if (candidate[0] !== 'node' || candidate[1] !== codexNotifyFanoutPath(rootDir)) return false;
160
+ const bridgeIndex = candidate.indexOf('--bridge-base64');
161
+ if (bridgeIndex < 0 || !candidate[bridgeIndex + 1]) return false;
162
+ try {
163
+ const bridge = JSON.parse(Buffer.from(candidate[bridgeIndex + 1], 'base64').toString('utf8'));
164
+ return Array.isArray(bridge) && bridge.join('\n') === bridgeNextNotifyArgs(bridgeHomeDir).join('\n');
165
+ } catch {
166
+ return false;
167
+ }
168
+ });
169
+ }
170
+
171
+ function isBridgeNextNotify(args, bridgeHomeDir) {
172
+ const expected = bridgeNextNotifyArgs(bridgeHomeDir).join('\n');
173
+ return notifyTreeSome(args, candidate => candidate.join('\n') === expected);
174
+ }
175
+
176
+ function isBridgeOnlyNotify(args, bridgeHomeDir) {
177
+ return Array.isArray(args) && args.join('\n') === bridgeNextNotifyArgs(bridgeHomeDir).join('\n');
178
+ }
179
+
180
+ function chainBridgeAfterDevTaskRadar(configFile, bridgeHomeDir) {
181
+ if (!configFile || !fs.existsSync(configFile)) return { chained: false, reason: 'config-not-found' };
182
+ const source = fs.readFileSync(configFile, 'utf8');
183
+ const parsed = codexNotifyLine(source);
184
+ if (!parsed || !isDirectDevTaskRadarNotify(parsed.args)) return { chained: false, reason: 'unsupported-third-party-notify' };
185
+ if (isBridgeNextNotify(parsed.args, bridgeHomeDir)) return { chained: true, changed: false };
186
+ if (parsed.args.includes('--next-base64')) return { chained: false, reason: 'third-party-next-notify-present' };
187
+ const next = Buffer.from(JSON.stringify(bridgeNextNotifyArgs(bridgeHomeDir))).toString('base64');
188
+ const args = [...parsed.args, '--next-base64', next];
189
+ const indentation = (parsed.lines[parsed.index].match(/^\s*/) || [''])[0];
190
+ parsed.lines[parsed.index] = `${indentation}notify = ${JSON.stringify(args)}`;
191
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
192
+ writeFileAtomic(configFile, parsed.lines.join(eol));
193
+ return { chained: true, changed: true };
194
+ }
195
+
196
+ function wrapDevTaskRadarNotify(configFile, rootDir, bridgeHomeDir) {
197
+ if (!configFile || !fs.existsSync(configFile)) return { chained: false, reason: 'config-not-found' };
198
+ const source = fs.readFileSync(configFile, 'utf8');
199
+ const parsed = codexNotifyLine(source);
200
+ if (!parsed) return { chained: false, reason: 'unsupported-third-party-notify' };
201
+ if (isProjectKnowledgeFanout(parsed.args, rootDir, bridgeHomeDir)) return { chained: true, changed: false };
202
+ if (!JSON.stringify(parsed.args).includes('devtask-radar')) return { chained: false, reason: 'unsupported-third-party-notify' };
203
+ const next = Buffer.from(JSON.stringify(parsed.args)).toString('base64');
204
+ const bridge = Buffer.from(JSON.stringify(bridgeNextNotifyArgs(bridgeHomeDir))).toString('base64');
205
+ const fanout = ['node', codexNotifyFanoutPath(rootDir), '--next-base64', next, '--bridge-base64', bridge];
206
+ const indentation = (parsed.lines[parsed.index].match(/^\s*/) || [''])[0];
207
+ parsed.lines[parsed.index] = `${indentation}notify = ${JSON.stringify(fanout)}`;
208
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
209
+ writeFileAtomic(configFile, parsed.lines.join(eol));
210
+ return { chained: true, changed: true };
211
+ }
212
+
213
+ function wrapBridgeOnlyNotify(configFile, rootDir, bridgeHomeDir) {
214
+ if (!configFile || !fs.existsSync(configFile)) return { chained: false, reason: 'config-not-found' };
215
+ const source = fs.readFileSync(configFile, 'utf8');
216
+ const parsed = codexNotifyLine(source);
217
+ if (!parsed || !isBridgeOnlyNotify(parsed.args, bridgeHomeDir)) return { chained: false, reason: 'not-bridge-only-notify' };
218
+ const bridge = Buffer.from(JSON.stringify(bridgeNextNotifyArgs(bridgeHomeDir))).toString('base64');
219
+ const fanout = ['node', codexNotifyFanoutPath(rootDir), '--bridge-base64', bridge];
220
+ const indentation = (parsed.lines[parsed.index].match(/^\s*/) || [''])[0];
221
+ parsed.lines[parsed.index] = `${indentation}notify = ${JSON.stringify(fanout)}`;
222
+ const eol = source.includes('\r\n') ? '\r\n' : '\n';
223
+ writeFileAtomic(configFile, parsed.lines.join(eol));
224
+ return { chained: true, changed: true };
225
+ }
226
+
227
+ function updateJsoncValue(filePath, propertyPath, value) {
228
+ let source = fs.existsSync(filePath)
229
+ ? fs.readFileSync(filePath, 'utf8')
230
+ : '{\n "$schema": "https://opencode.ai/config.json"\n}\n';
231
+ const formattingOptions = { insertSpaces: true, tabSize: 2, eol: source.includes('\r\n') ? '\r\n' : '\n' };
232
+ const edits = modify(source, propertyPath, value, { formattingOptions });
233
+ source = applyEdits(source, edits);
234
+ writeFileAtomic(filePath, source);
235
+ }
236
+
237
+ function openCodeConfigDir(homeDir = os.homedir(), env = process.env) {
238
+ if (env.OPENCODE_CONFIG_DIR) return path.resolve(env.OPENCODE_CONFIG_DIR);
239
+ if (env.XDG_CONFIG_HOME) return path.join(path.resolve(env.XDG_CONFIG_HOME), 'opencode');
240
+ return path.join(homeDir, '.config', 'opencode');
241
+ }
242
+
243
+ function findOpenCodeConfig(configDir) {
244
+ const jsonc = path.join(configDir, 'opencode.jsonc');
245
+ const json = path.join(configDir, 'opencode.json');
246
+ if (fs.existsSync(jsonc)) return jsonc;
247
+ if (fs.existsSync(json)) return json;
248
+ return jsonc;
249
+ }
250
+
251
+ function readOpenCodeState(configDir) {
252
+ const configPath = findOpenCodeConfig(configDir);
253
+ let config = {};
254
+ if (fs.existsSync(configPath)) {
255
+ config = parse(fs.readFileSync(configPath, 'utf8')) || {};
256
+ }
257
+ const legacy = config?.mcp?.[INTEGRATION_NAME];
258
+ const v2 = config?.mcp?.servers?.[INTEGRATION_NAME];
259
+ const skillPath = path.join(configDir, 'skills', INTEGRATION_NAME, 'SKILL.md');
260
+ const instructionPath = path.join(configDir, 'instructions', `${INTEGRATION_NAME}.md`);
261
+ const configuredInstructions = Array.isArray(config?.instructions) ? config.instructions : [];
262
+ return {
263
+ configPath,
264
+ mcpInstalled: Boolean(legacy || v2),
265
+ skillInstalled: fs.existsSync(skillPath),
266
+ instructionInstalled: fs.existsSync(instructionPath)
267
+ && configuredInstructions.some(item => path.resolve(String(item)) === path.resolve(instructionPath)),
268
+ skillPath,
269
+ instructionPath,
270
+ };
271
+ }
272
+
273
+ function ensureOpenCodeInstructionConfig(configDir, instructionPath) {
274
+ const configPath = findOpenCodeConfig(configDir);
275
+ const config = fs.existsSync(configPath) ? (parse(fs.readFileSync(configPath, 'utf8')) || {}) : {};
276
+ const instructions = Array.isArray(config.instructions) ? [...config.instructions] : [];
277
+ if (!instructions.some(item => path.resolve(String(item)) === path.resolve(instructionPath))) {
278
+ instructions.push(instructionPath);
279
+ updateJsoncValue(configPath, ['instructions'], instructions);
280
+ }
281
+ return configPath;
282
+ }
283
+
284
+ function removeOpenCodeInstructionConfig(configDir, instructionPath) {
285
+ const configPath = findOpenCodeConfig(configDir);
286
+ if (!fs.existsSync(configPath)) return false;
287
+ const config = parse(fs.readFileSync(configPath, 'utf8')) || {};
288
+ if (!Array.isArray(config.instructions)) return false;
289
+ const instructions = config.instructions.filter(item => path.resolve(String(item)) !== path.resolve(instructionPath));
290
+ if (instructions.length === config.instructions.length) return false;
291
+ updateJsoncValue(configPath, ['instructions'], instructions.length ? instructions : undefined);
292
+ return true;
293
+ }
294
+
295
+ function removeOpenCodeMcpConfig(configDir) {
296
+ const configPath = findOpenCodeConfig(configDir);
297
+ if (!fs.existsSync(configPath)) return false;
298
+ let source = fs.readFileSync(configPath, 'utf8');
299
+ const config = parse(source) || {};
300
+ const paths = [];
301
+ if (config?.mcp?.[INTEGRATION_NAME]) paths.push(['mcp', INTEGRATION_NAME]);
302
+ if (config?.mcp?.servers?.[INTEGRATION_NAME]) paths.push(['mcp', 'servers', INTEGRATION_NAME]);
303
+ if (!paths.length) return false;
304
+ const formattingOptions = { insertSpaces: true, tabSize: 2, eol: source.includes('\r\n') ? '\r\n' : '\n' };
305
+ for (const propertyPath of paths) {
306
+ const edits = modify(source, propertyPath, undefined, { formattingOptions });
307
+ source = applyEdits(source, edits);
308
+ }
309
+ writeFileAtomic(configPath, source);
310
+ return true;
311
+ }
312
+
313
+ class IntegrationManager {
314
+ constructor(options = {}) {
315
+ this.rootDir = options.rootDir || path.resolve(__dirname, '..', '..');
316
+ this.homeDir = options.homeDir || os.homedir();
317
+ this.env = options.env || process.env;
318
+ this.version = options.version || packageInfo.version;
319
+ this.packageSpec = options.packageSpec || `${packageInfo.name}@${this.version}`;
320
+ this.marketplaceSource = options.marketplaceSource || DEFAULT_MARKETPLACE_SOURCE;
321
+ this.runner = options.runner || defaultRunner;
322
+ this.commandResolver = options.commandResolver || defaultCommandResolver;
323
+ this.dryRun = Boolean(options.dryRun);
324
+ this.logger = options.logger || null;
325
+ this.planned = [];
326
+ this.bridge = options.bridgeModule || bridgeModule;
327
+ this.bridgeConsumerName = options.bridgeConsumerName || DEFAULT_BRIDGE_CONSUMER;
328
+ this.bridgeHomeDir = options.bridgeHomeDir
329
+ || this.env.AI_CODING_EVENT_BRIDGE_HOME
330
+ || path.join(this.homeDir, '.ai-coding-event-bridge');
331
+ this.claudeSettingsFile = options.claudeSettingsFile || '';
332
+ this.codexConfigFile = options.codexConfigFile || '';
333
+ this.openCodePluginsDir = options.openCodePluginsDir || '';
334
+ }
335
+
336
+ _bridgeFacade() {
337
+ return this.bridge.createBridge({ homeDir: this.bridgeHomeDir });
338
+ }
339
+
340
+ _captureInstallerOptions() {
341
+ return {
342
+ homeDir: this.bridgeHomeDir,
343
+ settingsFile: this.claudeSettingsFile || undefined,
344
+ configFile: this.codexConfigFile || undefined,
345
+ pluginsDir: this.openCodePluginsDir || undefined,
346
+ };
347
+ }
348
+
349
+ // ---- Bridge host consumer (host-level, never owned by one connector) ----
350
+
351
+ async registerBridgeConsumer(meta = {}) {
352
+ if (this.dryRun) {
353
+ this.planned.push({ command: 'bridge', args: ['registerConsumer', this.bridgeConsumerName] });
354
+ return { state: 'planned', consumerName: this.bridgeConsumerName };
355
+ }
356
+ const facade = this._bridgeFacade();
357
+ const consumer = await facade.registerConsumer(this.bridgeConsumerName, meta);
358
+ return { state: 'registered', consumerName: this.bridgeConsumerName, consumer };
359
+ }
360
+
361
+ async unregisterBridgeConsumer() {
362
+ if (this.dryRun) {
363
+ this.planned.push({ command: 'bridge', args: ['unregisterConsumer', this.bridgeConsumerName] });
364
+ return { state: 'planned', consumerName: this.bridgeConsumerName };
365
+ }
366
+ const facade = this._bridgeFacade();
367
+ await facade.unregisterConsumer(this.bridgeConsumerName);
368
+ return { state: 'unregistered', consumerName: this.bridgeConsumerName };
369
+ }
370
+
371
+ async bridgeStatus() {
372
+ const facade = this._bridgeFacade();
373
+ const [consumer, health] = await Promise.all([facade.getConsumer(this.bridgeConsumerName), facade.getHealth()]);
374
+ return {
375
+ consumerRegistered: Boolean(consumer),
376
+ consumerName: this.bridgeConsumerName,
377
+ bridgeHealthy: true,
378
+ health,
379
+ };
380
+ }
381
+
382
+ // ---- Development Capture connectors (Bridge hooks / notify / plugin) ----
383
+
384
+ async installCaptureClaude() {
385
+ if (this.dryRun) {
386
+ this.planned.push({ command: 'bridge', args: ['installClaudeCodeHook', this.bridgeHomeDir] });
387
+ return { state: 'planned', installed: false, conflict: false, detail: 'managed Bridge hooks (dry-run)' };
388
+ }
389
+ const result = await this.bridge.installers.claudeCode.installClaudeCodeHook(this._captureInstallerOptions());
390
+ return { state: 'installed', installed: true, conflict: false, detail: `managed hooks: ${result.added.join(', ') || 'already present'}` };
391
+ }
392
+
393
+ async statusCaptureClaude() {
394
+ const status = await this.bridge.installers.claudeCode.statusClaudeCodeHook(this._captureInstallerOptions());
395
+ return {
396
+ state: status.installed ? 'installed' : 'not-installed',
397
+ installed: status.installed,
398
+ conflict: false,
399
+ detail: status.installed ? `managed events: ${status.managedEvents.join(', ')}` : 'managed hooks not installed',
400
+ };
401
+ }
402
+
403
+ async uninstallCaptureClaude(options = {}) {
404
+ await this.bridge.installers.claudeCode.uninstallClaudeCodeHook({
405
+ ...this._captureInstallerOptions(),
406
+ unregisterConsumer: options.unregisterConsumer === true,
407
+ consumerName: this.bridgeConsumerName,
408
+ });
409
+ return { state: 'removed', detail: options.unregisterConsumer === true ? 'managed hooks removed; host consumer unregistered' : 'managed hooks removed; host consumer kept' };
410
+ }
411
+
412
+ async installCaptureCodex() {
413
+ if (this.dryRun) {
414
+ this.planned.push({ command: 'bridge', args: ['installCodexNotify', this.bridgeHomeDir] });
415
+ return { state: 'planned', installed: false, conflict: false, detail: 'managed Codex notify (dry-run)' };
416
+ }
417
+ const result = await this.bridge.installers.codex.installCodexNotify(this._captureInstallerOptions());
418
+ if (result.conflict) {
419
+ const configFile = this.codexConfigFile || path.join(this.homeDir, '.codex', 'config.toml');
420
+ const chained = chainBridgeAfterDevTaskRadar(configFile, this.bridgeHomeDir);
421
+ if (chained.chained) {
422
+ return { state: 'installed', installed: true, conflict: false, detail: 'Bridge notify chained after the compatible DevTask Radar notifier' };
423
+ }
424
+ const wrapped = wrapDevTaskRadarNotify(configFile, this.rootDir, this.bridgeHomeDir);
425
+ if (wrapped.chained) {
426
+ return { state: 'installed', installed: true, conflict: false, detail: 'Bridge notify fan-out preserves the existing DevTask Radar notifier' };
427
+ }
428
+ return { state: 'conflict', installed: false, conflict: true, detail: 'third-party notify present; not overwritten' };
429
+ }
430
+ const configFile = this.codexConfigFile || path.join(this.homeDir, '.codex', 'config.toml');
431
+ const wrapped = wrapBridgeOnlyNotify(configFile, this.rootDir, this.bridgeHomeDir);
432
+ if (!wrapped.chained) {
433
+ return { state: 'failed', installed: false, conflict: false, detail: 'managed notify could not be configured for Codex session capture' };
434
+ }
435
+ return { state: 'installed', installed: true, conflict: false, detail: 'managed Bridge notify fan-out installed' };
436
+ }
437
+
438
+ async statusCaptureCodex() {
439
+ const status = await this.bridge.installers.codex.statusCodexNotify(this._captureInstallerOptions());
440
+ if (status.thirdParty) {
441
+ const configFile = this.codexConfigFile || path.join(this.homeDir, '.codex', 'config.toml');
442
+ const source = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf8') : '';
443
+ const parsed = codexNotifyLine(source);
444
+ if (parsed && isProjectKnowledgeFanout(parsed.args, this.rootDir, this.bridgeHomeDir)) {
445
+ return { state: 'installed', installed: true, conflict: false, detail: 'Bridge notify fan-out preserves the existing DevTask Radar notifier' };
446
+ }
447
+ if (parsed && isDevTaskRadarNotify(parsed.args) && isBridgeNextNotify(parsed.args, this.bridgeHomeDir)) {
448
+ return { state: 'installed', installed: true, conflict: false, detail: 'Bridge notify chained after the compatible DevTask Radar notifier' };
449
+ }
450
+ }
451
+ return {
452
+ state: status.installed ? 'installed' : status.thirdParty ? 'conflict' : 'not-installed',
453
+ installed: Boolean(status.installed),
454
+ conflict: Boolean(status.thirdParty),
455
+ detail: status.installed ? 'managed notify installed' : status.thirdParty ? 'third-party notify present' : 'notify not installed',
456
+ };
457
+ }
458
+
459
+ async uninstallCaptureCodex(options = {}) {
460
+ await this.bridge.installers.codex.uninstallCodexNotify({
461
+ ...this._captureInstallerOptions(),
462
+ unregisterConsumer: options.unregisterConsumer === true,
463
+ consumerName: this.bridgeConsumerName,
464
+ });
465
+ return { state: 'removed', detail: 'managed notify removed' };
466
+ }
467
+
468
+ async installCaptureOpenCode() {
469
+ if (this.dryRun) {
470
+ this.planned.push({ command: 'bridge', args: ['installOpenCodePlugin', this.bridgeHomeDir] });
471
+ return { state: 'planned', installed: false, conflict: false, detail: 'managed OpenCode plugin (dry-run)' };
472
+ }
473
+ await this.bridge.installers.openCode.installOpenCodePlugin(this._captureInstallerOptions());
474
+ return { state: 'installed', installed: true, conflict: false, detail: 'managed plugin installed' };
475
+ }
476
+
477
+ async statusCaptureOpenCode() {
478
+ const status = await this.bridge.installers.openCode.statusOpenCodePlugin(this._captureInstallerOptions());
479
+ return {
480
+ state: status.installed ? 'installed' : 'not-installed',
481
+ installed: Boolean(status.installed),
482
+ conflict: false,
483
+ detail: status.installed ? 'managed plugin installed' : `plugin not installed (${status.thirdPartyFiles} third-party files preserved)`,
484
+ };
485
+ }
486
+
487
+ async uninstallCaptureOpenCode(options = {}) {
488
+ await this.bridge.installers.openCode.uninstallOpenCodePlugin({
489
+ ...this._captureInstallerOptions(),
490
+ unregisterConsumer: options.unregisterConsumer === true,
491
+ consumerName: this.bridgeConsumerName,
492
+ });
493
+ return { state: 'removed', detail: 'managed plugin removed' };
494
+ }
495
+
496
+ log(level, event, message, input = {}) {
497
+ if (!this.logger || typeof this.logger[level] !== 'function') return;
498
+ Promise.resolve(this.logger[level](event, message, { component: 'integration-manager', ...input })).catch(() => {
499
+ try { process.stderr.write(`[integration-manager logger fallback] ${event}\n`); } catch {
500
+ // stderr is the final non-recursive observer fallback.
501
+ }
502
+ });
503
+ }
504
+
505
+ executable(client) {
506
+ const name = client === 'claude' ? 'claude' : client;
507
+ if (this.dryRun) return process.platform === 'win32' ? `${name}.cmd` : name;
508
+ return this.commandResolver(name, this.env);
509
+ }
510
+
511
+ detectClients() {
512
+ return SUPPORTED_CLIENTS.filter(client => Boolean(this.executable(client)));
513
+ }
514
+
515
+ execute(client, args, options = {}) {
516
+ const startedAt = Date.now();
517
+ const command = this.executable(client);
518
+ this.planned.push({ command: command || client, args: [...args] });
519
+ const argsHash = `sha256:${crypto.createHash('sha256').update(JSON.stringify(args), 'utf8').digest('hex')}`;
520
+ this.log('info', 'integration.command_started', 'Integration CLI command started.', { context: { client, argumentCount: args.length, argsHash, dryRun: this.dryRun } });
521
+ if (this.dryRun) {
522
+ this.log('info', 'integration.command_completed', 'Integration CLI command completed.', { durationMs: Date.now() - startedAt, context: { client, status: 0, argsHash, dryRun: true } });
523
+ return { status: 0, stdout: '', stderr: '' };
524
+ }
525
+ if (!command) {
526
+ this.log('error', 'integration.command_failed', 'Integration CLI command failed.', { durationMs: Date.now() - startedAt, error: Object.assign(new Error('Integration CLI is unavailable.'), { code: 'INTEGRATION_CLI_UNAVAILABLE' }), context: { client, argsHash } });
527
+ throw new Error(`${CLIENT_LABELS[client]} CLI is not installed or not on PATH`);
528
+ }
529
+ const result = this.runner(command, args, {
530
+ env: this.env,
531
+ cwd: this.rootDir,
532
+ timeout: options.timeout,
533
+ });
534
+ if (result.error) {
535
+ this.log('error', 'integration.command_failed', 'Integration CLI command failed.', { durationMs: Date.now() - startedAt, error: Object.assign(new Error('Integration CLI command failed.'), { code: result.error.code || 'INTEGRATION_COMMAND_FAILED' }), context: { client, argsHash } });
536
+ throw result.error;
537
+ }
538
+ if (result.status !== 0 && !options.allowFailure) {
539
+ const detail = String(result.stderr || result.stdout || '').trim();
540
+ this.log('error', 'integration.command_failed', 'Integration CLI command failed.', { durationMs: Date.now() - startedAt, error: Object.assign(new Error('Integration CLI command failed.'), { code: 'INTEGRATION_COMMAND_FAILED' }), context: { client, status: result.status, argsHash, outputHash: `sha256:${crypto.createHash('sha256').update(detail, 'utf8').digest('hex')}` } });
541
+ throw new Error(detail || `${CLIENT_LABELS[client]} command failed with exit code ${result.status}`);
542
+ }
543
+ this.log('info', 'integration.command_completed', 'Integration CLI command completed.', { durationMs: Date.now() - startedAt, context: { client, status: result.status, argsHash } });
544
+ return result;
545
+ }
546
+
547
+ claudePlugin() {
548
+ const result = this.execute('claude', ['plugin', 'list', '--json'], { allowFailure: true });
549
+ const plugins = parseJson(result.stdout, []);
550
+ return Array.isArray(plugins)
551
+ ? plugins.find(item => item.id === `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`)
552
+ : undefined;
553
+ }
554
+
555
+ claudeMarketplaceExists() {
556
+ const result = this.execute('claude', ['plugin', 'marketplace', 'list', '--json'], { allowFailure: true });
557
+ const marketplaces = parseJson(result.stdout, []);
558
+ return Array.isArray(marketplaces) && marketplaces.some(item => item.name === MARKETPLACE_NAME);
559
+ }
560
+
561
+ codexPlugin() {
562
+ const result = this.execute('codex', ['plugin', 'list', '--json'], { allowFailure: true });
563
+ const data = parseJson(result.stdout, {});
564
+ return Array.isArray(data?.installed)
565
+ ? data.installed.find(item => item.pluginId === `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`)
566
+ : undefined;
567
+ }
568
+
569
+ codexMarketplaceExists() {
570
+ const result = this.execute('codex', ['plugin', 'marketplace', 'list'], { allowFailure: true });
571
+ return String(result.stdout || '').split(/\r?\n/).some(line => line.trim().startsWith(`${MARKETPLACE_NAME} `));
572
+ }
573
+
574
+ ensureClaudeMarketplace() {
575
+ if (!this.dryRun && this.claudeMarketplaceExists()) return;
576
+ this.execute('claude', ['plugin', 'marketplace', 'add', this.marketplaceSource]);
577
+ }
578
+
579
+ ensureCodexMarketplace() {
580
+ if (!this.dryRun && this.codexMarketplaceExists()) return;
581
+ this.execute('codex', ['plugin', 'marketplace', 'add', this.marketplaceSource]);
582
+ }
583
+
584
+ installClaude(options = {}) {
585
+ this.ensureClaudeMarketplace();
586
+ if (!this.dryRun && this.claudePlugin()) {
587
+ return { state: 'installed', detail: 'plugin already installed' };
588
+ }
589
+ this.execute('claude', [
590
+ 'plugin',
591
+ 'install',
592
+ `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`,
593
+ '--scope',
594
+ options.scope || 'user',
595
+ ]);
596
+ return { state: 'installed', detail: 'plugin, MCP, and Skill installed' };
597
+ }
598
+
599
+ updateClaude(options = {}) {
600
+ this.ensureClaudeMarketplace();
601
+ this.execute('claude', ['plugin', 'marketplace', 'update', MARKETPLACE_NAME]);
602
+ if (!this.dryRun && !this.claudePlugin()) return this.installClaude(options);
603
+ this.execute('claude', [
604
+ 'plugin',
605
+ 'update',
606
+ `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`,
607
+ '--scope',
608
+ options.scope || 'user',
609
+ ]);
610
+ return { state: 'updated', detail: 'plugin marketplace and installed plugin updated' };
611
+ }
612
+
613
+ uninstallClaude(options = {}) {
614
+ if (this.dryRun || this.claudePlugin()) {
615
+ this.execute('claude', [
616
+ 'plugin',
617
+ 'uninstall',
618
+ `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`,
619
+ '--scope',
620
+ options.scope || 'user',
621
+ ]);
622
+ }
623
+ if (this.dryRun || this.claudeMarketplaceExists()) {
624
+ this.execute('claude', ['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
625
+ }
626
+ return { state: 'removed', detail: 'plugin and marketplace registration removed' };
627
+ }
628
+
629
+ statusClaude() {
630
+ const command = this.executable('claude');
631
+ if (!command) return { state: 'unavailable', installed: false, detail: 'Claude Code CLI not found' };
632
+ const plugin = this.claudePlugin();
633
+ return {
634
+ state: plugin ? 'installed' : 'not-installed',
635
+ installed: Boolean(plugin),
636
+ version: plugin?.version,
637
+ detail: plugin ? `plugin enabled: ${plugin.enabled !== false}` : 'plugin not installed',
638
+ };
639
+ }
640
+
641
+ installCodex() {
642
+ this.ensureCodexMarketplace();
643
+ if (!this.dryRun && this.codexPlugin()) {
644
+ return { state: 'installed', detail: 'plugin already installed' };
645
+ }
646
+ this.execute('codex', ['plugin', 'add', `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`]);
647
+ return { state: 'installed', detail: 'plugin, MCP, and Skill installed' };
648
+ }
649
+
650
+ updateCodex() {
651
+ this.ensureCodexMarketplace();
652
+ this.execute('codex', ['plugin', 'marketplace', 'upgrade', MARKETPLACE_NAME]);
653
+ if (this.dryRun || this.codexPlugin()) {
654
+ this.execute('codex', ['plugin', 'remove', `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`]);
655
+ }
656
+ this.execute('codex', ['plugin', 'add', `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`]);
657
+ return { state: 'updated', detail: 'marketplace refreshed and plugin reinstalled' };
658
+ }
659
+
660
+ uninstallCodex() {
661
+ if (this.dryRun || this.codexPlugin()) {
662
+ this.execute('codex', ['plugin', 'remove', `${INTEGRATION_NAME}@${MARKETPLACE_NAME}`]);
663
+ }
664
+ if (this.dryRun || this.codexMarketplaceExists()) {
665
+ this.execute('codex', ['plugin', 'marketplace', 'remove', MARKETPLACE_NAME]);
666
+ }
667
+ return { state: 'removed', detail: 'plugin and marketplace registration removed' };
668
+ }
669
+
670
+ statusCodex() {
671
+ const command = this.executable('codex');
672
+ if (!command) return { state: 'unavailable', installed: false, detail: 'Codex CLI not found' };
673
+ const plugin = this.codexPlugin();
674
+ return {
675
+ state: plugin ? 'installed' : 'not-installed',
676
+ installed: Boolean(plugin),
677
+ version: plugin?.version,
678
+ detail: plugin ? `plugin enabled: ${plugin.enabled !== false}` : 'plugin not installed',
679
+ };
680
+ }
681
+
682
+ openCodeSkillSource() {
683
+ return path.join(
684
+ this.rootDir,
685
+ 'plugins',
686
+ INTEGRATION_NAME,
687
+ 'skills',
688
+ INTEGRATION_NAME,
689
+ );
690
+ }
691
+
692
+ openCodeInstructionSource() {
693
+ return path.join(this.rootDir, 'plugins', INTEGRATION_NAME, 'opencode', `${INTEGRATION_NAME}.md`);
694
+ }
695
+
696
+ openCodeConfigDir() {
697
+ return openCodeConfigDir(this.homeDir, this.env);
698
+ }
699
+
700
+ installOpenCode() {
701
+ const source = this.openCodeSkillSource();
702
+ const instructionSource = this.openCodeInstructionSource();
703
+ const configDir = this.openCodeConfigDir();
704
+ const destination = path.join(this.openCodeConfigDir(), 'skills', INTEGRATION_NAME);
705
+ const instructionDestination = path.join(configDir, 'instructions', `${INTEGRATION_NAME}.md`);
706
+ if (!this.dryRun) {
707
+ if (!fs.existsSync(path.join(source, 'SKILL.md'))) throw new Error(`bundled Skill not found: ${source}`);
708
+ if (!fs.existsSync(instructionSource)) throw new Error(`bundled OpenCode instructions not found: ${instructionSource}`);
709
+ fs.mkdirSync(destination, { recursive: true });
710
+ fs.cpSync(source, destination, { recursive: true, force: true });
711
+ fs.mkdirSync(path.dirname(instructionDestination), { recursive: true });
712
+ fs.copyFileSync(instructionSource, instructionDestination);
713
+ }
714
+ this.planned.push({ copy: source, destination });
715
+ this.planned.push({ copy: instructionSource, destination: instructionDestination });
716
+ this.execute('opencode', [
717
+ 'mcp',
718
+ 'add',
719
+ INTEGRATION_NAME,
720
+ '--',
721
+ 'npx',
722
+ '-y',
723
+ '--package',
724
+ this.packageSpec,
725
+ 'project-knowledge-mcp',
726
+ ]);
727
+ if (!this.dryRun) ensureOpenCodeInstructionConfig(configDir, instructionDestination);
728
+ return { state: 'installed', detail: 'MCP, global Skill, and Instructions installed' };
729
+ }
730
+
731
+ updateOpenCode() {
732
+ const result = this.installOpenCode();
733
+ return { ...result, state: 'updated', detail: 'MCP configuration, global Skill, and Instructions updated' };
734
+ }
735
+
736
+ uninstallOpenCode() {
737
+ const configDir = this.openCodeConfigDir();
738
+ const skillDir = path.join(configDir, 'skills', INTEGRATION_NAME);
739
+ const instructionPath = path.join(configDir, 'instructions', `${INTEGRATION_NAME}.md`);
740
+ if (!this.dryRun) {
741
+ removeOpenCodeMcpConfig(configDir);
742
+ removeOpenCodeInstructionConfig(configDir, instructionPath);
743
+ if (fs.existsSync(skillDir)) fs.rmSync(skillDir, { recursive: true, force: true });
744
+ if (fs.existsSync(instructionPath)) fs.rmSync(instructionPath, { force: true });
745
+ }
746
+ this.planned.push({
747
+ removeConfig: findOpenCodeConfig(configDir),
748
+ removeDirectory: skillDir,
749
+ removeFile: instructionPath,
750
+ });
751
+ return { state: 'removed', detail: 'MCP configuration, global Skill, and Instructions removed' };
752
+ }
753
+
754
+ statusOpenCode() {
755
+ const available = Boolean(this.executable('opencode'));
756
+ const state = readOpenCodeState(this.openCodeConfigDir());
757
+ const installed = state.mcpInstalled && state.skillInstalled && state.instructionInstalled;
758
+ let detail = 'MCP, Skill, and Instructions not installed';
759
+ if (installed) detail = 'MCP, global Skill, and Instructions installed';
760
+ else if (state.mcpInstalled && state.skillInstalled) detail = 'MCP and Skill installed; Instructions missing';
761
+ else if (state.mcpInstalled) detail = 'MCP installed; Skill missing';
762
+ else if (state.skillInstalled) detail = 'Skill installed; MCP missing';
763
+ if (!available) detail = `OpenCode CLI not found; ${detail}`;
764
+ return {
765
+ state: installed ? 'installed' : available ? 'not-installed' : 'unavailable',
766
+ installed,
767
+ detail,
768
+ configPath: state.configPath,
769
+ skillPath: state.skillPath,
770
+ instructionPath: state.instructionPath,
771
+ };
772
+ }
773
+
774
+ async operate(operation, clients, options = {}) {
775
+ const results = [];
776
+ for (const rawClient of clients) {
777
+ const client = normalizeClient(rawClient);
778
+ try {
779
+ const suffix = CLIENT_METHOD_SUFFIX[client];
780
+ let result;
781
+ if (operation === 'status') result = this[`status${suffix}`]();
782
+ else if (operation === 'install') result = this[`install${suffix}`](options);
783
+ else if (operation === 'update') result = this[`update${suffix}`](options);
784
+ else if (operation === 'uninstall') result = this[`uninstall${suffix}`](options);
785
+ else throw new Error(`unsupported operation: ${operation}`);
786
+ if (this.dryRun && operation !== 'status') {
787
+ result = { ...result, state: 'planned', detail: `${result.detail} (dry-run)` };
788
+ }
789
+ results.push({ client, label: CLIENT_LABELS[client], ok: true, ...result });
790
+ } catch (error) {
791
+ results.push({
792
+ client,
793
+ label: CLIENT_LABELS[client],
794
+ ok: false,
795
+ state: 'failed',
796
+ detail: error.message,
797
+ });
798
+ }
799
+ }
800
+ return results;
801
+ }
802
+
803
+ async operateCapture(operation, clients, options = {}) {
804
+ const results = [];
805
+ for (const rawClient of clients) {
806
+ const client = normalizeClient(rawClient);
807
+ const suffix = CLIENT_METHOD_SUFFIX[client];
808
+ try {
809
+ const result = await this[`${operation}Capture${suffix}`](options);
810
+ results.push({ client, label: CLIENT_LABELS[client], ok: true, ...result });
811
+ } catch (error) {
812
+ results.push({ client, label: CLIENT_LABELS[client], ok: false, state: 'failed', detail: error.message });
813
+ }
814
+ }
815
+ return results;
816
+ }
817
+
818
+ // One-click Integration Setup: register the host Bridge consumer once,
819
+ // then install BOTH capabilities per client. Knowledge Integration and
820
+ // Development Capture are reported separately; one failing component never
821
+ // rolls back or blocks the others.
822
+ async installAll(options = {}) {
823
+ const clients = options.clients && options.clients.length ? options.clients.map(normalizeClient) : this.detectClients();
824
+ const summary = { clients: [], bridge: null };
825
+ try {
826
+ summary.bridge = { ok: true, ...(await this.registerBridgeConsumer(options.bridgeConsumerMeta || {})) };
827
+ } catch (error) {
828
+ summary.bridge = { ok: false, state: 'failed', detail: error.message };
829
+ }
830
+ for (const client of clients) {
831
+ const suffix = CLIENT_METHOD_SUFFIX[client];
832
+ const entry = { client, label: CLIENT_LABELS[client] };
833
+ if (options.captureOnly !== true) {
834
+ try {
835
+ entry.knowledgeIntegration = { ok: true, ...(await this[`install${suffix}`](options)) };
836
+ } catch (error) {
837
+ entry.knowledgeIntegration = { ok: false, state: 'failed', detail: error.message };
838
+ }
839
+ }
840
+ if (options.knowledgeOnly !== true) {
841
+ try {
842
+ entry.developmentCapture = { ok: true, ...(await this[`installCapture${suffix}`]()) };
843
+ } catch (error) {
844
+ entry.developmentCapture = { ok: false, state: 'failed', detail: error.message };
845
+ }
846
+ }
847
+ summary.clients.push(entry);
848
+ }
849
+ return summary;
850
+ }
851
+
852
+ async statusAll(options = {}) {
853
+ const clients = options.clients && options.clients.length ? options.clients.map(normalizeClient) : this.detectClients();
854
+ const summary = { clients: [], bridge: null };
855
+ try {
856
+ summary.bridge = { ok: true, ...(await this.bridgeStatus()) };
857
+ } catch (error) {
858
+ summary.bridge = { ok: false, state: 'failed', bridgeHealthy: false, detail: error.message };
859
+ }
860
+ for (const client of clients) {
861
+ const suffix = CLIENT_METHOD_SUFFIX[client];
862
+ const entry = { client, label: CLIENT_LABELS[client], available: Boolean(this.executable(client)) };
863
+ try {
864
+ entry.knowledgeIntegration = { ok: true, ...(await this[`status${suffix}`]()) };
865
+ } catch (error) {
866
+ entry.knowledgeIntegration = { ok: false, state: 'failed', detail: error.message };
867
+ }
868
+ try {
869
+ entry.developmentCapture = { ok: true, ...(await this[`statusCapture${suffix}`]()) };
870
+ } catch (error) {
871
+ entry.developmentCapture = { ok: false, state: 'failed', detail: error.message };
872
+ }
873
+ summary.clients.push(entry);
874
+ }
875
+ return summary;
876
+ }
877
+
878
+ // Global disable: remove every connector first, then (and only then)
879
+ // unregister the host Bridge consumer (GATE INSTALL-003 semantics).
880
+ async disableAllCapture(options = {}) {
881
+ const clients = options.clients && options.clients.length ? options.clients.map(normalizeClient) : SUPPORTED_CLIENTS;
882
+ const results = await this.operateCapture('uninstall', clients, { unregisterConsumer: false });
883
+ let consumer = null;
884
+ try {
885
+ consumer = { ok: true, ...(await this.unregisterBridgeConsumer()) };
886
+ } catch (error) {
887
+ consumer = { ok: false, state: 'failed', detail: error.message };
888
+ }
889
+ return { clients: results, consumer };
890
+ }
891
+ }
892
+
893
+ module.exports = {
894
+ CLIENT_LABELS,
895
+ DEFAULT_MARKETPLACE_SOURCE,
896
+ INTEGRATION_NAME,
897
+ IntegrationManager,
898
+ MARKETPLACE_NAME,
899
+ SUPPORTED_CLIENTS,
900
+ findOpenCodeConfig,
901
+ ensureOpenCodeInstructionConfig,
902
+ normalizeClient,
903
+ openCodeConfigDir,
904
+ readOpenCodeState,
905
+ removeOpenCodeMcpConfig,
906
+ removeOpenCodeInstructionConfig,
907
+ };