cloudflare-mcp-smart-proxy 1.2.0 → 1.3.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.
@@ -0,0 +1,284 @@
1
+ import os from 'os';
2
+ import path from 'path';
3
+ import { CloudClient } from './cloud-client.js';
4
+ import { DeviceIdentity } from './device-identity.js';
5
+ import { discoverProjectProbe } from './project-probe-discovery.js';
6
+ import { detectIde, applyBrainSnapshot } from './ide-configurator.js';
7
+
8
+ function normalizeString(value, fallback = '') {
9
+ const normalized = typeof value === 'string' ? value.trim() : '';
10
+ return normalized || fallback;
11
+ }
12
+
13
+ function defaultConnectorId() {
14
+ return `connector.${os.hostname().replace(/[^a-zA-Z0-9_.-]/g, '_')}.local`;
15
+ }
16
+
17
+ function defaultWorkspaceId(workspaceRoot) {
18
+ return `workspace.${path.basename(workspaceRoot || process.cwd()).replace(/[^a-zA-Z0-9_.-]/g, '_')}`;
19
+ }
20
+
21
+ function buildIdempotencyKey(...parts) {
22
+ return parts
23
+ .map((entry) => normalizeString(entry))
24
+ .filter(Boolean)
25
+ .join(':');
26
+ }
27
+
28
+ export class ConnectorBridge {
29
+ constructor({
30
+ cloudUrl,
31
+ cloudApiKey,
32
+ workspaceRoot,
33
+ clientProfileId,
34
+ connectorId,
35
+ connectorType,
36
+ workspaceId,
37
+ deviceIdentity = null,
38
+ cloudClient = null
39
+ }) {
40
+ this.workspaceRoot = workspaceRoot || process.cwd();
41
+ this.clientProfileId = normalizeString(clientProfileId);
42
+ this.connectorId = normalizeString(connectorId, defaultConnectorId());
43
+ this.connectorType = normalizeString(connectorType, 'smart_proxy');
44
+ this.workspaceId = normalizeString(workspaceId, defaultWorkspaceId(this.workspaceRoot));
45
+ this.deviceIdentity = deviceIdentity || new DeviceIdentity();
46
+ this.cloudClient = cloudClient || new CloudClient({
47
+ cloudUrl,
48
+ cloudApiKey,
49
+ deviceIdentity: this.deviceIdentity
50
+ });
51
+ this.state = {
52
+ initializedAt: Date.now(),
53
+ lastWorkspaceGrant: null,
54
+ lastInstallPlan: null,
55
+ lastStatusReport: null,
56
+ lastProjectProbe: null,
57
+ lastGeneratedContextPack: null,
58
+ lastSyncAt: null,
59
+ lastProjectProbeReportAt: null,
60
+ lastError: null
61
+ };
62
+ }
63
+
64
+ isConfigured() {
65
+ return Boolean(this.clientProfileId);
66
+ }
67
+
68
+ getBridgeStatus() {
69
+ return {
70
+ objectType: 'connector_bridge_status',
71
+ configured: this.isConfigured(),
72
+ clientProfileId: this.clientProfileId || null,
73
+ connectorId: this.connectorId,
74
+ connectorType: this.connectorType,
75
+ workspaceId: this.workspaceId,
76
+ workspaceRoot: this.workspaceRoot,
77
+ state: this.state
78
+ };
79
+ }
80
+
81
+ async initialize({
82
+ autoSyncProfile = true,
83
+ autoApplyBrain = true,
84
+ autoReportProjectProbe = false,
85
+ autoGenerateContextPack = false
86
+ } = {}) {
87
+ if (!this.isConfigured()) {
88
+ return this.getBridgeStatus();
89
+ }
90
+
91
+ try {
92
+ if (typeof this.cloudClient?.registerDevice === 'function') {
93
+ await this.ensureDeviceRegistration();
94
+ }
95
+ const canApplyBrain = autoApplyBrain && typeof this.cloudClient?.fetchBrainSnapshot === 'function';
96
+ if (canApplyBrain) {
97
+ try {
98
+ await this.applyBrain();
99
+ } catch (error) {
100
+ if (!autoSyncProfile) {
101
+ throw error;
102
+ }
103
+ await this.syncProfile();
104
+ }
105
+ } else if (autoSyncProfile) {
106
+ await this.syncProfile();
107
+ }
108
+ if (autoReportProjectProbe) {
109
+ await this.reportProjectProbe({ autoGenerateContextPack });
110
+ }
111
+ } catch (error) {
112
+ this.state.lastError = error.message;
113
+ }
114
+ return this.getBridgeStatus();
115
+ }
116
+
117
+ async ensureDeviceRegistration() {
118
+ const identity = await this.deviceIdentity.loadOrCreate();
119
+ const response = await this.cloudClient.registerDevice({
120
+ deviceId: identity.deviceId,
121
+ publicKeyJwk: identity.publicKeyJwk,
122
+ clientProfileId: this.clientProfileId || null,
123
+ connectorId: this.connectorId,
124
+ workspaceId: this.workspaceId,
125
+ label: `${this.connectorType}:${os.hostname()}`
126
+ });
127
+ this.state.deviceBinding = response?.deviceBinding || null;
128
+ this.state.device = response?.device || null;
129
+ return response;
130
+ }
131
+
132
+ /**
133
+ * Fetch the brain snapshot from the cloud and apply it to the local IDE.
134
+ * This is the core of the "cerebral cortex" flow:
135
+ * admin configures skills/rules/backends → connector detects IDE → applies natively.
136
+ */
137
+ async applyBrain() {
138
+ if (!this.isConfigured()) {
139
+ throw new Error('client profile id is required for brain apply');
140
+ }
141
+
142
+ const snapshotResponse = await this.cloudClient.fetchBrainSnapshot({
143
+ clientProfileId: this.clientProfileId,
144
+ workspaceId: this.workspaceId
145
+ });
146
+ const snapshot = snapshotResponse?.brainSnapshot;
147
+ if (!snapshot) {
148
+ throw new Error('brain snapshot returned empty response');
149
+ }
150
+
151
+ this.state.lastBrainSnapshot = snapshot;
152
+ this.state.lastBrainAppliedAt = Date.now();
153
+
154
+ const ide = detectIde(this.workspaceRoot);
155
+ const applyResult = await applyBrainSnapshot(snapshot, this.workspaceRoot, ide);
156
+
157
+ this.state.lastBrainApplyResult = applyResult;
158
+ this.state.lastSyncAt = Date.now();
159
+ this.state.lastError = null;
160
+
161
+ return {
162
+ success: true,
163
+ ide,
164
+ applied: applyResult.applied,
165
+ meta: snapshot.meta
166
+ };
167
+ }
168
+
169
+ async syncProfile() {
170
+ if (!this.isConfigured()) {
171
+ throw new Error('client profile id is required for bridge sync');
172
+ }
173
+
174
+ const workspaceGrantResponse = await this.cloudClient.fetchWorkspaceGrant({
175
+ clientProfileId: this.clientProfileId,
176
+ workspaceId: this.workspaceId,
177
+ connectorId: this.connectorId
178
+ });
179
+ this.state.lastWorkspaceGrant = workspaceGrantResponse?.workspaceGrant || null;
180
+
181
+ const installPlanResponse = await this.cloudClient.fetchInstallPlan({
182
+ clientProfileId: this.clientProfileId,
183
+ workspaceId: this.workspaceId
184
+ });
185
+ this.state.lastInstallPlan = installPlanResponse?.installPlan || null;
186
+
187
+ const statusReportsResponse = await this.cloudClient.listConnectorStatusReports({
188
+ clientProfileId: this.clientProfileId,
189
+ workspaceId: this.workspaceId,
190
+ connectorId: this.connectorId,
191
+ limit: 5
192
+ });
193
+ this.state.lastStatusReport = Array.isArray(statusReportsResponse?.connectorStatusReports)
194
+ ? (statusReportsResponse.connectorStatusReports[0] || null)
195
+ : null;
196
+ this.state.lastSyncAt = Date.now();
197
+
198
+ this.state.lastError = null;
199
+ return {
200
+ success: true,
201
+ workspaceGrant: this.state.lastWorkspaceGrant,
202
+ installPlan: this.state.lastInstallPlan,
203
+ lastStatusReport: this.state.lastStatusReport
204
+ };
205
+ }
206
+
207
+ async reportStatus({
208
+ status = 'succeeded',
209
+ summary = {},
210
+ operationResults = [],
211
+ issues = []
212
+ } = {}) {
213
+ if (!this.isConfigured()) {
214
+ throw new Error('client profile id is required for status reporting');
215
+ }
216
+
217
+ const report = {
218
+ idempotencyKey: buildIdempotencyKey(
219
+ 'status_report',
220
+ this.clientProfileId,
221
+ this.workspaceId,
222
+ this.connectorId,
223
+ this.state.lastInstallPlan?.id || 'no-plan',
224
+ status
225
+ ),
226
+ connectorId: this.connectorId,
227
+ connectorType: this.connectorType,
228
+ workspaceId: this.workspaceId,
229
+ installPlanId: this.state.lastInstallPlan?.id || null,
230
+ status,
231
+ summary,
232
+ operationResults,
233
+ issues
234
+ };
235
+
236
+ const response = await this.cloudClient.createConnectorStatusReport({
237
+ clientProfileId: this.clientProfileId,
238
+ report
239
+ });
240
+ this.state.lastStatusReport = response?.connectorStatusReport || null;
241
+ this.state.lastError = null;
242
+ return response;
243
+ }
244
+
245
+ async discoverProjectProbe() {
246
+ const probe = await discoverProjectProbe({
247
+ workspaceRoot: this.workspaceRoot,
248
+ workspaceId: this.workspaceId,
249
+ clientProfileId: this.clientProfileId || null,
250
+ connectorId: this.connectorId,
251
+ connectorType: this.connectorType
252
+ });
253
+ this.state.lastProjectProbe = probe;
254
+ return probe;
255
+ }
256
+
257
+ async reportProjectProbe({ autoGenerateContextPack = false } = {}) {
258
+ if (!this.isConfigured()) {
259
+ throw new Error('client profile id is required for project probe reporting');
260
+ }
261
+
262
+ const probe = await this.discoverProjectProbe();
263
+ const probeWithIdempotency = {
264
+ ...probe,
265
+ idempotencyKey: buildIdempotencyKey(
266
+ 'project_probe',
267
+ this.clientProfileId,
268
+ this.workspaceId,
269
+ this.connectorId,
270
+ probe.id || probe.repoName || 'workspace'
271
+ )
272
+ };
273
+ const response = await this.cloudClient.createProjectProbe({
274
+ clientProfileId: this.clientProfileId,
275
+ probe: probeWithIdempotency,
276
+ autoGenerateContextPack
277
+ });
278
+ this.state.lastProjectProbe = response?.projectProbe || probe;
279
+ this.state.lastGeneratedContextPack = response?.generatedContextPack || null;
280
+ this.state.lastProjectProbeReportAt = Date.now();
281
+ this.state.lastError = null;
282
+ return response;
283
+ }
284
+ }
@@ -0,0 +1,87 @@
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { webcrypto } from 'crypto';
5
+
6
+ const subtle = webcrypto.subtle;
7
+ const DEVICE_SIGNATURE_VERSION = 'device_sig_v1';
8
+
9
+ function bytesToBase64(bytes) {
10
+ return Buffer.from(bytes).toString('base64');
11
+ }
12
+
13
+ async function sha256Hex(value) {
14
+ const digest = await subtle.digest('SHA-256', new TextEncoder().encode(String(value || '')));
15
+ return Buffer.from(digest).toString('hex');
16
+ }
17
+
18
+ function defaultIdentityPath() {
19
+ return path.join(os.homedir(), '.cloudmcp', 'device-identity.json');
20
+ }
21
+
22
+ function ensureParent(filePath) {
23
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
24
+ }
25
+
26
+ export class DeviceIdentity {
27
+ constructor({ identityPath = defaultIdentityPath() } = {}) {
28
+ this.identityPath = identityPath;
29
+ this.record = null;
30
+ this.privateKey = null;
31
+ }
32
+
33
+ async loadOrCreate() {
34
+ if (this.record && this.privateKey) return this.record;
35
+ if (fs.existsSync(this.identityPath)) {
36
+ this.record = JSON.parse(fs.readFileSync(this.identityPath, 'utf8'));
37
+ } else {
38
+ const keyPair = await subtle.generateKey(
39
+ { name: 'ECDSA', namedCurve: 'P-256' },
40
+ true,
41
+ ['sign', 'verify']
42
+ );
43
+ this.record = {
44
+ deviceId: `dev_${crypto.randomUUID()}`,
45
+ publicKeyJwk: await subtle.exportKey('jwk', keyPair.publicKey),
46
+ privateKeyJwk: await subtle.exportKey('jwk', keyPair.privateKey),
47
+ createdAt: Date.now()
48
+ };
49
+ ensureParent(this.identityPath);
50
+ fs.writeFileSync(this.identityPath, `${JSON.stringify(this.record, null, 2)}\n`, { mode: 0o600 });
51
+ }
52
+ this.privateKey = await subtle.importKey(
53
+ 'jwk',
54
+ this.record.privateKeyJwk,
55
+ { name: 'ECDSA', namedCurve: 'P-256' },
56
+ false,
57
+ ['sign']
58
+ );
59
+ return this.record;
60
+ }
61
+
62
+ async buildSignedHeaders({ method, pathname, body = '' }) {
63
+ const record = await this.loadOrCreate();
64
+ const timestamp = String(Date.now());
65
+ const nonce = crypto.randomUUID();
66
+ const bodyHash = await sha256Hex(body);
67
+ const canonical = [
68
+ DEVICE_SIGNATURE_VERSION,
69
+ String(method || 'GET').toUpperCase(),
70
+ pathname || '/',
71
+ bodyHash,
72
+ timestamp,
73
+ nonce
74
+ ].join('\n');
75
+ const signature = await subtle.sign(
76
+ { name: 'ECDSA', hash: 'SHA-256' },
77
+ this.privateKey,
78
+ new TextEncoder().encode(canonical)
79
+ );
80
+ return {
81
+ 'X-CloudMCP-Device-ID': record.deviceId,
82
+ 'X-CloudMCP-Device-Timestamp': timestamp,
83
+ 'X-CloudMCP-Device-Nonce': nonce,
84
+ 'X-CloudMCP-Device-Signature': bytesToBase64(signature)
85
+ };
86
+ }
87
+ }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * ide-configurator.js
3
+ *
4
+ * Detects which IDE the connector is running inside, then applies
5
+ * a brain snapshot natively (rules files, MCP server config, context files).
6
+ *
7
+ * Supported IDEs:
8
+ * codex — Codex CLI (~/.codex/config.toml)
9
+ * cursor — Cursor editor (.cursor/rules + ~/.cursor/mcp.json)
10
+ * claude_code — Claude Code (.mcp.json / ~/.claude.json)
11
+ * claude — Claude Desktop legacy (~/Library/…/claude_desktop_config.json)
12
+ * vscode — VS Code Copilot (.github/copilot-instructions.md + .vscode/mcp.json)
13
+ * unknown — fallback: only write project-local files if possible
14
+ */
15
+
16
+ import fs from 'fs';
17
+ import os from 'os';
18
+ import path from 'path';
19
+ import { mergeCodexConfig, mergeClaudeCodeConfig } from './reference-connectors.js';
20
+
21
+ // ── IDE detection ─────────────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Returns the IDE identifier string.
25
+ * Priority: explicit env override → process/env signals → filesystem signals.
26
+ */
27
+ export function detectIde(workspaceRoot) {
28
+ // 1. Explicit override from env (useful in non-standard setups)
29
+ const override = (process.env.CLOUDMCP_IDE || '').trim().toLowerCase();
30
+ if (['codex', 'cursor', 'claude_code', 'claude', 'vscode'].includes(override)) {
31
+ return override;
32
+ }
33
+
34
+ // 2. Codex-specific env vars should win over the VSCode host shell it often runs inside
35
+ if (_isCodex()) return 'codex';
36
+
37
+ // 3. Claude Code project markers
38
+ if (_isClaudeCode(workspaceRoot)) return 'claude_code';
39
+
40
+ // 4. VSCode family: reliable env var
41
+ if (process.env.VSCODE_PID || process.env.VSCODE_IPC_HOOK || process.env.TERM_PROGRAM === 'vscode') {
42
+ // Distinguish Cursor (a VSCode fork) from vanilla VSCode
43
+ if (_isCursor()) return 'cursor';
44
+ return 'vscode';
45
+ }
46
+
47
+ // 5. Cursor-specific env vars (set by Cursor launcher)
48
+ if (_isCursor()) return 'cursor';
49
+
50
+ // 6. Claude Desktop legacy — checks parent process name or known env var
51
+ if (process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DESKTOP_APP) {
52
+ return 'claude';
53
+ }
54
+
55
+ // 7. Filesystem heuristic: if workspace has a .cursor directory, assume Cursor
56
+ if (workspaceRoot && fs.existsSync(path.join(workspaceRoot, '.cursor'))) {
57
+ return 'cursor';
58
+ }
59
+
60
+ // 8. Fallback
61
+ return 'unknown';
62
+ }
63
+
64
+ function _isCodex() {
65
+ return !!(
66
+ process.env.CODEX_CI ||
67
+ process.env.CODEX_THREAD_ID ||
68
+ process.env.CODEX_HOME ||
69
+ (process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || '').toLowerCase().includes('codex')
70
+ );
71
+ }
72
+
73
+ function _isCursor() {
74
+ return !!(
75
+ process.env.CURSOR_TRACE ||
76
+ process.env.CURSOR_CHANNEL ||
77
+ process.env.CURSOR_SESSION_ID ||
78
+ // Cursor sets GIT_ASKPASS to a cursor-specific binary
79
+ (process.env.GIT_ASKPASS || '').toLowerCase().includes('cursor')
80
+ );
81
+ }
82
+
83
+ function _isClaudeCode(workspaceRoot) {
84
+ return !!(
85
+ process.env.CLAUDE_PROJECT_DIR ||
86
+ process.env.CLAUDE_CODE_ENTRYPOINT ||
87
+ (workspaceRoot && fs.existsSync(path.join(workspaceRoot, '.claude')))
88
+ );
89
+ }
90
+
91
+ // ── Brain snapshot application ────────────────────────────────────────────────
92
+
93
+ /**
94
+ * Apply a brain snapshot to the detected IDE.
95
+ * Returns a summary of what was written.
96
+ */
97
+ export async function applyBrainSnapshot(snapshot, workspaceRoot, ide = null) {
98
+ const detectedIde = ide || detectIde(workspaceRoot);
99
+ const results = [];
100
+
101
+ switch (detectedIde) {
102
+ case 'codex':
103
+ results.push(..._applyCodex(snapshot, workspaceRoot));
104
+ break;
105
+ case 'cursor':
106
+ results.push(..._applyCursor(snapshot, workspaceRoot));
107
+ break;
108
+ case 'claude_code':
109
+ results.push(..._applyClaudeCode(snapshot, workspaceRoot));
110
+ break;
111
+ case 'claude':
112
+ results.push(..._applyClaude(snapshot, workspaceRoot));
113
+ break;
114
+ case 'vscode':
115
+ results.push(..._applyVSCode(snapshot, workspaceRoot));
116
+ break;
117
+ default:
118
+ // Unknown IDE: write project-local files only (no global config touched)
119
+ results.push(..._applyLocal(snapshot, workspaceRoot));
120
+ break;
121
+ }
122
+
123
+ return { ide: detectedIde, applied: results };
124
+ }
125
+
126
+ // ── Codex ─────────────────────────────────────────────────────────────────────
127
+
128
+ function _applyCodex(snapshot, workspaceRoot) {
129
+ const results = [];
130
+ const mcpServers = snapshot.mcpServers || {};
131
+
132
+ if (Object.keys(mcpServers).length > 0) {
133
+ const configDir = path.join(os.homedir(), '.codex');
134
+ const configFile = path.join(configDir, 'config.toml');
135
+ _ensureDir(configFile);
136
+ let content = fs.existsSync(configFile) ? fs.readFileSync(configFile, 'utf8') : '';
137
+ for (const [serverName, serverDefinition] of Object.entries(mcpServers)) {
138
+ content = mergeCodexConfig(content, serverName, {
139
+ ...serverDefinition,
140
+ enabled: serverDefinition?.enabled !== false
141
+ });
142
+ }
143
+ fs.writeFileSync(configFile, content, 'utf8');
144
+ results.push({ action: 'merged_mcp', file: configFile, count: Object.keys(mcpServers).length });
145
+ }
146
+
147
+ results.push(..._writeContextEntries(snapshot.contextEntries, workspaceRoot, '.cloudmcp/codex'));
148
+ return results;
149
+ }
150
+
151
+ // ── Cursor ────────────────────────────────────────────────────────────────────
152
+
153
+ function _applyCursor(snapshot, workspaceRoot) {
154
+ const results = [];
155
+
156
+ // 1. System prompt → .cursor/rules/cloudmcp.md (project-local, alwaysApply)
157
+ if (snapshot.systemPrompt) {
158
+ const rulesDir = path.join(workspaceRoot, '.cursor', 'rules');
159
+ _ensureDir(rulesDir);
160
+ const rulesFile = path.join(rulesDir, 'cloudmcp.md');
161
+ const content = [
162
+ '---',
163
+ 'description: CloudMCP brain — auto-applied by connector, do not edit manually',
164
+ 'alwaysApply: true',
165
+ '---',
166
+ '',
167
+ snapshot.systemPrompt
168
+ ].join('\n');
169
+ fs.writeFileSync(rulesFile, content, 'utf8');
170
+ results.push({ action: 'wrote', file: rulesFile });
171
+ }
172
+
173
+ // 2. Additional MCP backends → merge into ~/.cursor/mcp.json
174
+ // (the proxy itself is already registered; this adds extra backends from the profile)
175
+ const mcpServers = snapshot.mcpServers || {};
176
+ if (Object.keys(mcpServers).length > 0) {
177
+ const cursorConfigDir = path.join(os.homedir(), '.cursor');
178
+ _ensureDir(cursorConfigDir);
179
+ const mcpFile = path.join(cursorConfigDir, 'mcp.json');
180
+ const existing = _readJson(mcpFile, { mcpServers: {} });
181
+ existing.mcpServers = { ...(existing.mcpServers || {}), ...mcpServers };
182
+ fs.writeFileSync(mcpFile, JSON.stringify(existing, null, 2), 'utf8');
183
+ results.push({ action: 'merged_mcp', file: mcpFile, count: Object.keys(mcpServers).length });
184
+ }
185
+
186
+ // 3. Context entries → .cursor/context/cloudmcp-context.md
187
+ results.push(..._writeContextEntries(snapshot.contextEntries, workspaceRoot, '.cursor/context'));
188
+
189
+ return results;
190
+ }
191
+
192
+ // ── Claude Code ───────────────────────────────────────────────────────────────
193
+
194
+ function _applyClaudeCode(snapshot, workspaceRoot) {
195
+ const results = [];
196
+ const mcpServers = snapshot.mcpServers || {};
197
+
198
+ if (Object.keys(mcpServers).length > 0) {
199
+ const configFile = path.join(workspaceRoot, '.mcp.json');
200
+ const existing = _readJson(configFile, {});
201
+ let nextConfig = existing;
202
+ for (const [serverName, serverDefinition] of Object.entries(mcpServers)) {
203
+ nextConfig = mergeClaudeCodeConfig(nextConfig, serverName, serverDefinition);
204
+ }
205
+ _ensureDir(configFile);
206
+ fs.writeFileSync(configFile, JSON.stringify(nextConfig, null, 2), 'utf8');
207
+ results.push({ action: 'merged_mcp', file: configFile, count: Object.keys(mcpServers).length });
208
+ }
209
+
210
+ results.push(..._writeContextEntries(snapshot.contextEntries, workspaceRoot, '.cloudmcp/claude-code'));
211
+ return results;
212
+ }
213
+
214
+ // ── Claude Desktop ────────────────────────────────────────────────────────────
215
+
216
+ function _applyClaude(snapshot, workspaceRoot) {
217
+ const results = [];
218
+
219
+ // Claude Desktop MCP config location
220
+ let configDir;
221
+ if (process.platform === 'darwin') {
222
+ configDir = path.join(os.homedir(), 'Library', 'Application Support', 'Claude');
223
+ } else if (process.platform === 'win32') {
224
+ configDir = path.join(process.env.APPDATA || os.homedir(), 'Claude');
225
+ } else {
226
+ configDir = path.join(os.homedir(), '.config', 'Claude');
227
+ }
228
+ _ensureDir(configDir);
229
+
230
+ // Merge MCP servers
231
+ const mcpServers = snapshot.mcpServers || {};
232
+ if (Object.keys(mcpServers).length > 0) {
233
+ const configFile = path.join(configDir, 'claude_desktop_config.json');
234
+ const existing = _readJson(configFile, { mcpServers: {} });
235
+ existing.mcpServers = { ...(existing.mcpServers || {}), ...mcpServers };
236
+ fs.writeFileSync(configFile, JSON.stringify(existing, null, 2), 'utf8');
237
+ results.push({ action: 'merged_mcp', file: configFile, count: Object.keys(mcpServers).length });
238
+ }
239
+
240
+ // Claude Desktop doesn't have a rules file; write context as project-local markdown
241
+ results.push(..._writeContextEntries(snapshot.contextEntries, workspaceRoot, '.cloudmcp'));
242
+
243
+ return results;
244
+ }
245
+
246
+ // ── VS Code Copilot ───────────────────────────────────────────────────────────
247
+
248
+ function _applyVSCode(snapshot, workspaceRoot) {
249
+ const results = [];
250
+
251
+ // 1. System prompt → .github/copilot-instructions.md
252
+ if (snapshot.systemPrompt) {
253
+ const ghDir = path.join(workspaceRoot, '.github');
254
+ _ensureDir(ghDir);
255
+ const instructionsFile = path.join(ghDir, 'copilot-instructions.md');
256
+ fs.writeFileSync(instructionsFile, snapshot.systemPrompt, 'utf8');
257
+ results.push({ action: 'wrote', file: instructionsFile });
258
+ }
259
+
260
+ // 2. MCP servers → .vscode/mcp.json
261
+ const mcpServers = snapshot.mcpServers || {};
262
+ if (Object.keys(mcpServers).length > 0) {
263
+ const vscodeDir = path.join(workspaceRoot, '.vscode');
264
+ _ensureDir(vscodeDir);
265
+ const mcpFile = path.join(vscodeDir, 'mcp.json');
266
+ const existing = _readJson(mcpFile, { servers: {} });
267
+ // VSCode uses "servers" not "mcpServers"
268
+ existing.servers = { ...(existing.servers || {}), ...mcpServers };
269
+ fs.writeFileSync(mcpFile, JSON.stringify(existing, null, 2), 'utf8');
270
+ results.push({ action: 'merged_mcp', file: mcpFile, count: Object.keys(mcpServers).length });
271
+ }
272
+
273
+ // 3. Context entries
274
+ results.push(..._writeContextEntries(snapshot.contextEntries, workspaceRoot, '.vscode/cloudmcp'));
275
+
276
+ return results;
277
+ }
278
+
279
+ // ── Fallback (unknown IDE) ────────────────────────────────────────────────────
280
+
281
+ function _applyLocal(snapshot, workspaceRoot) {
282
+ return _writeContextEntries(snapshot.contextEntries, workspaceRoot, '.cloudmcp');
283
+ }
284
+
285
+ // ── Shared helpers ────────────────────────────────────────────────────────────
286
+
287
+ function _writeContextEntries(entries, workspaceRoot, subDir) {
288
+ if (!Array.isArray(entries) || entries.length === 0) return [];
289
+ const results = [];
290
+ const dir = path.join(workspaceRoot, subDir);
291
+ _ensureDir(dir);
292
+ const outFile = path.join(dir, 'context.md');
293
+ const lines = ['# CloudMCP Project Context', ''];
294
+ for (const entry of entries) {
295
+ if (!entry?.content) continue;
296
+ lines.push(`## ${entry.title || entry.entryId || 'Context'}`, '', entry.content, '');
297
+ }
298
+ fs.writeFileSync(outFile, lines.join('\n'), 'utf8');
299
+ results.push({ action: 'wrote', file: outFile, count: entries.length });
300
+ return results;
301
+ }
302
+
303
+ function _ensureDir(dir) {
304
+ const targetDir = path.extname(dir) ? path.dirname(dir) : dir;
305
+ if (!fs.existsSync(targetDir)) {
306
+ fs.mkdirSync(targetDir, { recursive: true });
307
+ }
308
+ }
309
+
310
+ function _readJson(filePath, fallback) {
311
+ try {
312
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
313
+ } catch {
314
+ return fallback;
315
+ }
316
+ }