@sanqianx/project-knowledge 4.6.1 → 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.
- package/.agents/plugins/marketplace.json +17 -17
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +817 -780
- package/README.md +48 -36
- package/_modules/knowledge-engine/README.md +88 -0
- package/_modules/knowledge-engine/package.json +38 -0
- package/_modules/knowledge-engine/src/analysis-contract.js +25 -0
- package/_modules/knowledge-engine/src/analysis-worker.js +54 -0
- package/_modules/knowledge-engine/src/bin.js +52 -0
- package/_modules/knowledge-engine/src/bridge-client.js +24 -0
- package/_modules/knowledge-engine/src/coordinator.js +255 -0
- package/_modules/knowledge-engine/src/headless-analyzer.js +75 -0
- package/_modules/knowledge-engine/src/memories.js +85 -0
- package/_modules/knowledge-engine/src/migration.js +88 -0
- package/_modules/knowledge-engine/src/retrieval.js +116 -0
- package/_modules/knowledge-engine/src/runtime/README.md +7 -0
- package/_modules/knowledge-engine/src/runtime/atomic-file.js +204 -0
- package/_modules/knowledge-engine/src/runtime/automation-config.js +140 -0
- package/_modules/knowledge-engine/src/runtime/commit-conversation-binder.js +249 -0
- package/_modules/knowledge-engine/src/runtime/commit-processing-ledger.js +26 -0
- package/_modules/knowledge-engine/src/runtime/commit-prompt.js +202 -0
- package/_modules/knowledge-engine/src/runtime/commit-reconciler.js +432 -0
- package/_modules/knowledge-engine/src/runtime/content-hash.js +4 -0
- package/_modules/knowledge-engine/src/runtime/contracts.js +279 -0
- package/_modules/knowledge-engine/src/runtime/conversation-exclusions.js +204 -0
- package/_modules/knowledge-engine/src/runtime/conversation-store.js +387 -0
- package/_modules/knowledge-engine/src/runtime/evidence-bundle.js +228 -0
- package/_modules/knowledge-engine/src/runtime/git-runner.js +62 -0
- package/_modules/knowledge-engine/src/runtime/knowledge-promotion.js +444 -0
- package/_modules/knowledge-engine/src/runtime/knowledge-retrieval-service.js +402 -0
- package/_modules/knowledge-engine/src/runtime/layout.js +44 -0
- package/_modules/knowledge-engine/src/runtime/markdown-knowledge-indexer.js +230 -0
- package/_modules/knowledge-engine/src/runtime/scanner.js +298 -0
- package/_modules/knowledge-engine/src/server.js +155 -0
- package/_modules/knowledge-engine/src/store.js +239 -0
- package/_modules/knowledge-engine/ui/app.css +172 -0
- package/_modules/knowledge-engine/ui/app.js +297 -0
- package/_modules/knowledge-engine/ui/index.html +95 -0
- package/_site/README.md +30 -30
- package/_site/_test/ai-profile-resolver-test.js +137 -137
- package/_site/_test/automation-queue-test.js +14 -14
- package/_site/_test/automation-ui-test.js +40 -40
- package/_site/_test/background-task-registry-test.js +43 -43
- package/_site/_test/baseline-schema-test.js +91 -91
- package/_site/_test/bridge-adapter-test.js +1 -1
- package/_site/_test/bridge-consumer-service-test.js +1 -1
- package/_site/_test/chat-claudecodeui-match-test.js +92 -92
- package/_site/_test/claude-executable-discovery-test.js +62 -62
- package/_site/_test/claude-workbench-test.js +170 -170
- package/_site/_test/client-capture-restart-e2e-test.js +4 -4
- package/_site/_test/codex-conversation-projection-test.js +2 -1
- package/_site/_test/commit-boundary-freeze-test.js +1 -1
- package/_site/_test/commit-conversation-binding-test.js +95 -95
- package/_site/_test/commit-evidence-test.js +110 -110
- package/_site/_test/conversation-api-test.js +109 -109
- package/_site/_test/conversation-store-test.js +136 -136
- package/_site/_test/cross-repo-isolation-e2e-test.js +2 -2
- package/_site/_test/data-dir-migration-test.js +57 -57
- package/_site/_test/desktop-browser-compat-test.js +28 -28
- package/_site/_test/desktop-hook-runtime-regression-test.js +204 -204
- package/_site/_test/explicit-commit-processor-test.js +37 -37
- package/_site/_test/fixtures/make-git-repos.js +12 -1
- package/_site/_test/folder-picker-output-test.js +19 -19
- package/_site/_test/full-integration-e2e-test.js +165 -165
- package/_site/_test/git-validation-test.js +2 -2
- package/_site/_test/hook-runtime-endpoint-test.js +88 -88
- package/_site/_test/hook-status-repair-api-test.js +152 -152
- package/_site/_test/hook-trigger-test.js +85 -85
- package/_site/_test/import-preflight-api-test.js +234 -233
- package/_site/_test/index-writer-concurrency-test.js +135 -135
- package/_site/_test/integration-adapters-test.js +171 -171
- package/_site/_test/integration-capture-setup-test.js +1 -1
- package/_site/_test/integration-surface-coverage-test.js +164 -164
- package/_site/_test/knowledge-language-control-test.js +170 -170
- package/_site/_test/knowledge-promotion-recovery-test.js +260 -260
- package/_site/_test/knowledge-query-test.js +54 -54
- package/_site/_test/knowledge-retrieval-service-test.js +77 -77
- package/_site/_test/knowledge-storage-startup-test.js +52 -52
- package/_site/_test/knowledge-store-logs-supervision-test.js +89 -89
- package/_site/_test/legacy-forward-compat-test.js +257 -257
- package/_site/_test/legacy-project-upgrade-e2e-test.js +363 -363
- package/_site/_test/logging-api-test.js +89 -89
- package/_site/_test/logging-sse-no-gap-test.js +88 -88
- package/_site/_test/logging-ui-test.js +106 -104
- package/_site/_test/markdown-delta-overlay-test.js +85 -85
- package/_site/_test/markdown-maintenance-api-test.js +75 -75
- package/_site/_test/mcp-server-test.js +149 -149
- package/_site/_test/module-artifact-integrity-test.js +27 -0
- package/_site/_test/module-boundary-test.js +30 -0
- package/_site/_test/module-bridge-eventbridge-test.js +7 -0
- package/_site/_test/module-model-configuration-test.js +43 -0
- package/_site/_test/module-process-lifecycle-test.js +24 -0
- package/_site/_test/module-stream-proxy-test.js +34 -0
- package/_site/_test/non-release-ci-test.js +34 -34
- package/_site/_test/offline-boundary-isolation-test.js +32 -32
- package/_site/_test/p0-data-migration-characterization-test.js +37 -37
- package/_site/_test/p0-e2e-gate-test.js +347 -347
- package/_site/_test/packaged-ui-smoke-test.js +19 -10
- package/_site/_test/path-consistency-test.js +151 -151
- package/_site/_test/pending-sweep-test.js +9 -9
- package/_site/_test/post-commit-automation-test.js +87 -87
- package/_site/_test/product-diagnostics-ui-test.js +75 -0
- package/_site/_test/product-import-ui-test.js +92 -0
- package/_site/_test/project-delete-recovery-test.js +64 -64
- package/_site/_test/project-goal-editor-test.js +144 -144
- package/_site/_test/project-layout-v2-migration-test.js +127 -127
- package/_site/_test/project-lifecycle-transaction-test.js +106 -106
- package/_site/_test/prompt-settings-test.js +115 -115
- package/_site/_test/protected-architecture-gate-test.js +122 -122
- package/_site/_test/refactor-characterization-test.js +36 -36
- package/_site/_test/release-version-sync-test.js +1 -1
- package/_site/_test/requirement-recorder-test.js +173 -173
- package/_site/_test/run-all-tests.js +159 -156
- package/_site/_test/server-runtime-migration-safety-test.js +27 -27
- package/_site/_test/shared-contracts-test.js +58 -58
- package/_site/_test/startup-analysis-disabled-test.js +10 -10
- package/_site/_test/storage-foundation-test.js +78 -78
- package/_site/_test/structured-logger-test.js +101 -101
- package/_site/_test/tracking-start-test.js +108 -108
- package/_site/_test/v4122-upgrade-data-contract-test.js +56 -56
- package/_site/_test/workbench-permission-test.js +117 -117
- package/_site/_test/workspace-ui-contract-test.js +28 -43
- package/_site/lib/ai-profile-resolver.js +78 -78
- package/_site/lib/ai-workspace.js +101 -101
- package/_site/lib/automation-config.js +140 -140
- package/_site/lib/bridge-adapter.js +5 -1
- package/_site/lib/bridge-consumer-service.js +402 -402
- package/_site/lib/claude-cli-runner.js +1510 -1510
- package/_site/lib/commit-conversation-binder.js +234 -234
- package/_site/lib/commit-processing-ledger.js +27 -27
- package/_site/lib/commit-prompt.js +202 -202
- package/_site/lib/commit-reconciler.js +406 -406
- package/_site/lib/contracts.js +284 -279
- package/_site/lib/conversation-query-service.js +144 -144
- package/_site/lib/conversation-store.js +385 -385
- package/_site/lib/data-dir.js +74 -74
- package/_site/lib/data-state-classifier.js +40 -40
- package/_site/lib/engine-cutover.js +97 -0
- package/_site/lib/evidence-bundle.js +228 -228
- package/_site/lib/folder-picker-output.js +21 -21
- package/_site/lib/git-runner.js +62 -62
- package/_site/lib/github-team-store.js +1172 -1172
- package/_site/lib/hook-manager.js +203 -202
- package/_site/lib/index-service.js +173 -173
- package/_site/lib/integration-installer.js +907 -907
- package/_site/lib/kb-framework.js +176 -176
- package/_site/lib/kb-validator.js +124 -124
- package/_site/lib/knowledge-promotion.js +435 -435
- package/_site/lib/knowledge-retrieval-service.js +401 -401
- package/_site/lib/knowledge-tool-runtime.js +359 -359
- package/_site/lib/legacy-data-manifest.js +26 -26
- package/_site/lib/llm-client.js +161 -161
- package/_site/lib/markdown-knowledge-indexer.js +230 -230
- package/_site/lib/migration-service.js +442 -442
- package/_site/lib/module-bridge.js +198 -33
- package/_site/lib/post-commit-automation.js +87 -87
- package/_site/lib/project-lifecycle-service.js +519 -497
- package/_site/lib/project-store.js +268 -262
- package/_site/lib/requirement-recorder.js +278 -278
- package/_site/lib/runtime-endpoint.js +155 -155
- package/_site/lib/scanner.js +298 -298
- package/_site/lib/server-app.js +1590 -1520
- package/_site/lib/settings-store.js +133 -113
- package/_site/lib/storage-layout.js +162 -162
- package/_site/lib/structured-logger.js +574 -574
- package/_site/scripts/folder-picker.ps1 +156 -156
- package/_site/scripts/hook-trigger.js +157 -156
- package/_site/scripts/install-module-candidates.js +69 -0
- package/_site/scripts/pack-module-candidates.js +72 -0
- package/_site/scripts/sync-release-version.js +2 -2
- package/_site/scripts/vendor-modules.js +47 -0
- package/_site/scripts/verify-module-runtime.js +45 -0
- package/_site/scripts/verify-product-runtime.js +34 -0
- package/bin/project-knowledge-mcp.js +194 -194
- package/docs/README.zh-CN.md +34 -25
- package/docs/project-registry-schema.md +22 -22
- package/docs/testing-strategy.md +33 -33
- package/module-runtime-manifest.json +220 -0
- package/package.json +13 -10
- package/plugins/project-knowledge/.claude-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.codex-plugin/plugin.json +1 -1
- package/plugins/project-knowledge/.mcp.json +1 -1
- package/plugins/project-knowledge/opencode/project-knowledge.md +3 -3
- package/plugins/project-knowledge/skills/project-knowledge/SKILL.md +28 -28
- package/templates/project-readme.md +34 -34
- package/ui/favicon.svg +38 -38
- package/ui/index.html +51 -149
- package/ui/product.css +9 -0
- package/ui/product.js +386 -0
- package/vendor-manifest.json +46 -0
- package/_site/_test/import-ui-flow-test.js +0 -171
- package/_site/_test/project-control-panel-task14-test.js +0 -63
- package/_site/_test/task15-20-ui-flow-test.js +0 -148
- package/_site/_test/ui-i18n-toggle-test.js +0 -114
- package/_site/_test/ui-smoke-test.js +0 -73
- package/ui/app.css +0 -58
- package/ui/app.js +0 -607
- 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');
|
|
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 ||
|
|
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
|
+
};
|