claude-runtime-sync 0.1.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,997 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * 同步 ~/.claude 与当前项目 .claude 的能力到 Codex。
5
+ *
6
+ * 目标:让 .claude 成为唯一真源。
7
+ * - Skills: ~/.claude/skills + <repo>/.claude/skills
8
+ * - MCP: ~/.claude/mcp.json(或 ~/.claude/.mcp.json) + <repo>/.mcp.json
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const crypto = require('crypto');
15
+ const { execSync } = require('child_process');
16
+
17
+ const MANAGED_BLOCK_START = '# >>> claude-codex-sync:managed-mcp:start >>>';
18
+ const MANAGED_BLOCK_END = '# <<< claude-codex-sync:managed-mcp:end <<<';
19
+
20
+ const DEFAULT_SYNC_OPTIONS = {
21
+ ignoreSkills: [],
22
+ ignorePlugins: [],
23
+ ignoreMcpServers: [],
24
+ skillNameMap: {},
25
+ pluginNameMap: {},
26
+ mcpNameMap: {}
27
+ };
28
+
29
+ function parseArgs(argv) {
30
+ const options = {
31
+ check: false,
32
+ codexHome: null,
33
+ claudeHome: null,
34
+ projectRoot: null,
35
+ noHome: false,
36
+ noProject: false
37
+ };
38
+
39
+ for (const arg of argv) {
40
+ if (arg === '--check') {
41
+ options.check = true;
42
+ continue;
43
+ }
44
+
45
+ if (arg === '--no-home') {
46
+ options.noHome = true;
47
+ continue;
48
+ }
49
+
50
+ if (arg === '--no-project') {
51
+ options.noProject = true;
52
+ continue;
53
+ }
54
+
55
+ if (arg.startsWith('--codex-home=')) {
56
+ options.codexHome = path.resolve(arg.slice('--codex-home='.length));
57
+ continue;
58
+ }
59
+
60
+ if (arg.startsWith('--claude-home=')) {
61
+ options.claudeHome = path.resolve(arg.slice('--claude-home='.length));
62
+ continue;
63
+ }
64
+
65
+ if (arg.startsWith('--project-root=')) {
66
+ options.projectRoot = path.resolve(arg.slice('--project-root='.length));
67
+ continue;
68
+ }
69
+
70
+ throw new Error(`未知参数: ${arg}`);
71
+ }
72
+
73
+ return options;
74
+ }
75
+
76
+ function readJsonIfExists(filePath) {
77
+ if (!fs.existsSync(filePath)) {
78
+ return null;
79
+ }
80
+
81
+ const raw = fs.readFileSync(filePath, 'utf8');
82
+ if (!raw.trim()) {
83
+ return null;
84
+ }
85
+
86
+ try {
87
+ return JSON.parse(raw);
88
+ } catch (error) {
89
+ throw new Error(`${filePath} JSON 解析失败: ${error.message}`);
90
+ }
91
+ }
92
+
93
+ function resolveCodexHome(overrideValue) {
94
+ if (overrideValue) {
95
+ return overrideValue;
96
+ }
97
+
98
+ if (process.env.CODEX_HOME && process.env.CODEX_HOME.trim()) {
99
+ return path.resolve(process.env.CODEX_HOME);
100
+ }
101
+
102
+ return path.join(os.homedir(), '.codex');
103
+ }
104
+
105
+ function resolveClaudeHome(overrideValue) {
106
+ if (overrideValue) {
107
+ return overrideValue;
108
+ }
109
+
110
+ return path.join(os.homedir(), '.claude');
111
+ }
112
+
113
+ function detectProjectRoot(explicitRoot) {
114
+ if (explicitRoot) {
115
+ return explicitRoot;
116
+ }
117
+
118
+ try {
119
+ const output = execSync('git rev-parse --show-toplevel', {
120
+ cwd: process.cwd(),
121
+ stdio: ['ignore', 'pipe', 'ignore']
122
+ });
123
+ return output.toString('utf8').trim();
124
+ } catch (_) {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function normalizeSyncOptions(userOptions, configPath) {
130
+ const merged = {
131
+ ...DEFAULT_SYNC_OPTIONS,
132
+ ...(userOptions || {})
133
+ };
134
+
135
+ for (const key of ['ignoreSkills', 'ignorePlugins', 'ignoreMcpServers']) {
136
+ if (!Array.isArray(merged[key])) {
137
+ throw new Error(`${configPath} 中 ${key} 必须是数组`);
138
+ }
139
+ }
140
+
141
+ for (const key of ['skillNameMap', 'pluginNameMap', 'mcpNameMap']) {
142
+ const value = merged[key];
143
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
144
+ throw new Error(`${configPath} 中 ${key} 必须是对象`);
145
+ }
146
+ }
147
+
148
+ return merged;
149
+ }
150
+
151
+ function loadSyncOptions(configPath) {
152
+ const userOptions = readJsonIfExists(configPath);
153
+ if (!userOptions) {
154
+ return { ...DEFAULT_SYNC_OPTIONS };
155
+ }
156
+
157
+ return normalizeSyncOptions(userOptions, configPath);
158
+ }
159
+
160
+ function listDirectories(dirPath) {
161
+ if (!fs.existsSync(dirPath)) {
162
+ return [];
163
+ }
164
+
165
+ return fs
166
+ .readdirSync(dirPath, { withFileTypes: true })
167
+ .filter(entry => entry.isDirectory())
168
+ .map(entry => entry.name)
169
+ .sort((a, b) => a.localeCompare(b));
170
+ }
171
+
172
+ function listFilesRecursive(rootDir, currentDir = rootDir) {
173
+ if (!fs.existsSync(currentDir)) {
174
+ return [];
175
+ }
176
+
177
+ const files = [];
178
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
179
+
180
+ for (const entry of entries) {
181
+ const absolutePath = path.join(currentDir, entry.name);
182
+ const relativePath = path.relative(rootDir, absolutePath);
183
+
184
+ if (entry.isDirectory()) {
185
+ files.push(...listFilesRecursive(rootDir, absolutePath));
186
+ continue;
187
+ }
188
+
189
+ if (!entry.isFile()) {
190
+ continue;
191
+ }
192
+
193
+ if (entry.name === '.DS_Store') {
194
+ continue;
195
+ }
196
+
197
+ files.push(relativePath);
198
+ }
199
+
200
+ return files.sort((a, b) => a.localeCompare(b));
201
+ }
202
+
203
+ function digestDirectory(dirPath) {
204
+ if (!fs.existsSync(dirPath)) {
205
+ return null;
206
+ }
207
+
208
+ const hash = crypto.createHash('sha256');
209
+ const files = listFilesRecursive(dirPath);
210
+
211
+ for (const relativePath of files) {
212
+ const absolutePath = path.join(dirPath, relativePath);
213
+ hash.update(relativePath);
214
+ hash.update('\0');
215
+ hash.update(fs.readFileSync(absolutePath));
216
+ hash.update('\0');
217
+ }
218
+
219
+ return hash.digest('hex');
220
+ }
221
+
222
+ function sanitizePathSegment(value, fallback) {
223
+ const cleaned = value.replace(/[\\/]/g, '-').trim();
224
+ return cleaned || fallback;
225
+ }
226
+
227
+ function resolveUniqueName(baseName, usedNames) {
228
+ if (!usedNames.has(baseName)) {
229
+ usedNames.add(baseName);
230
+ return baseName;
231
+ }
232
+
233
+ let index = 2;
234
+ while (usedNames.has(`${baseName}-${index}`)) {
235
+ index += 1;
236
+ }
237
+
238
+ const finalName = `${baseName}-${index}`;
239
+ usedNames.add(finalName);
240
+ return finalName;
241
+ }
242
+
243
+ function buildSkillMappings(sourceSkillsDir, options) {
244
+ const ignoreSet = new Set(options.ignoreSkills);
245
+ const sourceDirs = listDirectories(sourceSkillsDir);
246
+ const warnings = [];
247
+ const mappings = [];
248
+ const usedTargets = new Set();
249
+
250
+ for (const sourceName of sourceDirs) {
251
+ if (ignoreSet.has(sourceName)) {
252
+ continue;
253
+ }
254
+
255
+ const sourcePath = path.join(sourceSkillsDir, sourceName);
256
+ const skillFilePath = path.join(sourcePath, 'SKILL.md');
257
+
258
+ if (!fs.existsSync(skillFilePath)) {
259
+ warnings.push(`技能目录 ${sourceName} 缺少 SKILL.md,已跳过`);
260
+ continue;
261
+ }
262
+
263
+ const mappedName = options.skillNameMap[sourceName] || sourceName;
264
+ const normalizedTarget = sanitizePathSegment(mappedName, sourceName);
265
+ const targetName = resolveUniqueName(normalizedTarget, usedTargets);
266
+
267
+ mappings.push({
268
+ sourceName,
269
+ targetName,
270
+ sourcePath
271
+ });
272
+ }
273
+
274
+ return { mappings, warnings };
275
+ }
276
+
277
+ function syncSkills({ sourceSkillsDir, targetSkillsRoot, options, check }) {
278
+ const { mappings, warnings } = buildSkillMappings(sourceSkillsDir, options);
279
+ const targetNames = new Set(mappings.map(item => item.targetName));
280
+
281
+ const existingTargetDirs = listDirectories(targetSkillsRoot);
282
+ const added = [];
283
+ const updated = [];
284
+ const removed = [];
285
+
286
+ for (const mapping of mappings) {
287
+ const targetPath = path.join(targetSkillsRoot, mapping.targetName);
288
+
289
+ if (!fs.existsSync(targetPath)) {
290
+ added.push(mapping.targetName);
291
+ if (!check) {
292
+ fs.mkdirSync(targetSkillsRoot, { recursive: true });
293
+ fs.cpSync(mapping.sourcePath, targetPath, { recursive: true, force: true });
294
+ }
295
+ continue;
296
+ }
297
+
298
+ const sourceDigest = digestDirectory(mapping.sourcePath);
299
+ const targetDigest = digestDirectory(targetPath);
300
+
301
+ if (sourceDigest !== targetDigest) {
302
+ updated.push(mapping.targetName);
303
+ if (!check) {
304
+ fs.rmSync(targetPath, { recursive: true, force: true });
305
+ fs.cpSync(mapping.sourcePath, targetPath, { recursive: true, force: true });
306
+ }
307
+ }
308
+ }
309
+
310
+ for (const existingDir of existingTargetDirs) {
311
+ if (targetNames.has(existingDir)) {
312
+ continue;
313
+ }
314
+
315
+ removed.push(existingDir);
316
+ if (!check) {
317
+ fs.rmSync(path.join(targetSkillsRoot, existingDir), { recursive: true, force: true });
318
+ }
319
+ }
320
+
321
+ const changed = added.length > 0 || updated.length > 0 || removed.length > 0;
322
+
323
+ return {
324
+ changed,
325
+ sourceCount: mappings.length,
326
+ targetRoot: targetSkillsRoot,
327
+ added: added.sort((a, b) => a.localeCompare(b)),
328
+ updated: updated.sort((a, b) => a.localeCompare(b)),
329
+ removed: removed.sort((a, b) => a.localeCompare(b)),
330
+ warnings
331
+ };
332
+ }
333
+
334
+ function normalizeMcpKey(name) {
335
+ const normalized = name
336
+ .toLowerCase()
337
+ .replace(/[^a-z0-9]+/g, '-')
338
+ .replace(/^-+|-+$/g, '');
339
+
340
+ if (normalized) {
341
+ return normalized;
342
+ }
343
+
344
+ const hash = crypto.createHash('sha1').update(name).digest('hex').slice(0, 8);
345
+ return `mcp-${hash}`;
346
+ }
347
+
348
+ function coerceArrayOfScalars(value, defaultValue) {
349
+ if (!Array.isArray(value)) {
350
+ return defaultValue;
351
+ }
352
+
353
+ return value.filter(item => ['string', 'number', 'boolean'].includes(typeof item));
354
+ }
355
+
356
+ function coerceObjectOfScalars(value, defaultValue) {
357
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
358
+ return defaultValue;
359
+ }
360
+
361
+ const result = {};
362
+ for (const [key, item] of Object.entries(value)) {
363
+ if (['string', 'number', 'boolean'].includes(typeof item)) {
364
+ result[key] = item;
365
+ }
366
+ }
367
+
368
+ return result;
369
+ }
370
+
371
+ function pickMcpServerConfig(rawServerConfig, sourceName, warnings) {
372
+ const config = {};
373
+
374
+ if (typeof rawServerConfig.command === 'string' && rawServerConfig.command.trim()) {
375
+ config.command = rawServerConfig.command;
376
+ }
377
+
378
+ const args = coerceArrayOfScalars(rawServerConfig.args, null);
379
+ if (args && args.length > 0) {
380
+ config.args = args;
381
+ }
382
+
383
+ if (typeof rawServerConfig.url === 'string' && rawServerConfig.url.trim()) {
384
+ config.url = rawServerConfig.url;
385
+ }
386
+
387
+ if (typeof rawServerConfig.cwd === 'string' && rawServerConfig.cwd.trim()) {
388
+ config.cwd = rawServerConfig.cwd;
389
+ }
390
+
391
+ const env = coerceObjectOfScalars(rawServerConfig.env, null);
392
+ if (env && Object.keys(env).length > 0) {
393
+ config.env = env;
394
+ }
395
+
396
+ if (rawServerConfig.transport) {
397
+ warnings.push(`MCP ${sourceName} 的 transport=${rawServerConfig.transport} 未同步到 Codex(已忽略)`);
398
+ }
399
+
400
+ if (rawServerConfig.headers) {
401
+ warnings.push(`MCP ${sourceName} 的 headers 字段未同步到 Codex(已忽略)`);
402
+ }
403
+
404
+ if (!config.command && !config.url) {
405
+ warnings.push(`MCP ${sourceName} 缺少 command 或 url,已跳过`);
406
+ return null;
407
+ }
408
+
409
+ return config;
410
+ }
411
+
412
+ function tomlKey(key) {
413
+ if (/^[A-Za-z0-9_-]+$/.test(key)) {
414
+ return key;
415
+ }
416
+
417
+ return JSON.stringify(key);
418
+ }
419
+
420
+ function tomlString(value) {
421
+ return JSON.stringify(value);
422
+ }
423
+
424
+ function tomlValue(value) {
425
+ if (typeof value === 'string') {
426
+ return tomlString(value);
427
+ }
428
+
429
+ if (typeof value === 'number' || typeof value === 'boolean') {
430
+ return String(value);
431
+ }
432
+
433
+ if (Array.isArray(value)) {
434
+ return `[${value.map(item => tomlValue(item)).join(', ')}]`;
435
+ }
436
+
437
+ if (value && typeof value === 'object') {
438
+ const pairs = Object.keys(value)
439
+ .sort((a, b) => a.localeCompare(b))
440
+ .map(key => `${tomlKey(key)} = ${tomlValue(value[key])}`);
441
+ return `{ ${pairs.join(', ')} }`;
442
+ }
443
+
444
+ throw new Error(`不支持的 TOML 值类型: ${typeof value}`);
445
+ }
446
+
447
+ function buildManagedMcpBlock(servers) {
448
+ const lines = [];
449
+ const entryMap = {};
450
+
451
+ lines.push(MANAGED_BLOCK_START);
452
+ lines.push('# Auto-generated by scripts/sync-claude-all-to-codex.js');
453
+
454
+ const serverNames = Object.keys(servers).sort((a, b) => a.localeCompare(b));
455
+ if (serverNames.length === 0) {
456
+ lines.push('# No managed MCP servers found from claude sources');
457
+ }
458
+
459
+ for (const serverName of serverNames) {
460
+ const server = servers[serverName];
461
+ const entryLines = [`[mcp_servers.${serverName}]`];
462
+
463
+ for (const key of Object.keys(server).sort((a, b) => a.localeCompare(b))) {
464
+ entryLines.push(`${tomlKey(key)} = ${tomlValue(server[key])}`);
465
+ }
466
+
467
+ entryMap[serverName] = `${entryLines.join('\n')}\n`;
468
+
469
+ lines.push('');
470
+ lines.push(...entryLines);
471
+ }
472
+
473
+ lines.push(MANAGED_BLOCK_END);
474
+ return {
475
+ blockText: `${lines.join('\n')}\n`,
476
+ entryMap
477
+ };
478
+ }
479
+
480
+ function extractManagedBlock(configText) {
481
+ const start = configText.indexOf(MANAGED_BLOCK_START);
482
+ if (start === -1) {
483
+ return null;
484
+ }
485
+
486
+ const end = configText.indexOf(MANAGED_BLOCK_END, start);
487
+ if (end === -1) {
488
+ return null;
489
+ }
490
+
491
+ let blockEnd = end + MANAGED_BLOCK_END.length;
492
+ if (configText[blockEnd] === '\n') {
493
+ blockEnd += 1;
494
+ }
495
+
496
+ return {
497
+ start,
498
+ end: blockEnd,
499
+ blockText: configText.slice(start, blockEnd)
500
+ };
501
+ }
502
+
503
+ function parseMcpEntryMapFromBlock(blockText) {
504
+ const map = {};
505
+ const lines = blockText.split('\n');
506
+
507
+ let currentName = null;
508
+ let buffer = [];
509
+
510
+ function flush() {
511
+ if (!currentName) {
512
+ return;
513
+ }
514
+
515
+ map[currentName] = `${buffer.join('\n').trim()}\n`;
516
+ }
517
+
518
+ for (const line of lines) {
519
+ const tableMatch = line.match(/^\[mcp_servers\.([A-Za-z0-9_-]+)]\s*$/);
520
+ if (tableMatch) {
521
+ flush();
522
+ currentName = tableMatch[1];
523
+ buffer = [line];
524
+ continue;
525
+ }
526
+
527
+ if (currentName) {
528
+ if (line.startsWith('# <<<')) {
529
+ flush();
530
+ currentName = null;
531
+ buffer = [];
532
+ } else {
533
+ buffer.push(line);
534
+ }
535
+ }
536
+ }
537
+
538
+ flush();
539
+ return map;
540
+ }
541
+
542
+ function applyManagedBlock(configText, blockText) {
543
+ const existing = extractManagedBlock(configText);
544
+
545
+ if (!existing) {
546
+ if (!configText.trim()) {
547
+ return blockText;
548
+ }
549
+
550
+ const separator = configText.endsWith('\n') ? '\n' : '\n\n';
551
+ return `${configText}${separator}${blockText}`;
552
+ }
553
+
554
+ return `${configText.slice(0, existing.start)}${blockText}${configText.slice(existing.end)}`;
555
+ }
556
+
557
+ function collectServersFromFile({
558
+ filePath,
559
+ sourceLabel,
560
+ options,
561
+ mergedServers,
562
+ warnings,
563
+ sourceTrace
564
+ }) {
565
+ if (!filePath || !fs.existsSync(filePath)) {
566
+ return false;
567
+ }
568
+
569
+ const mcpJson = readJsonIfExists(filePath);
570
+ const servers = mcpJson && mcpJson.mcpServers;
571
+
572
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers)) {
573
+ warnings.push(`${sourceLabel} 的 mcp 配置缺少 mcpServers 对象:${filePath}`);
574
+ return false;
575
+ }
576
+
577
+ const ignoreSet = new Set(options.ignoreMcpServers);
578
+
579
+ for (const sourceName of Object.keys(servers).sort((a, b) => a.localeCompare(b))) {
580
+ if (ignoreSet.has(sourceName)) {
581
+ continue;
582
+ }
583
+
584
+ const rawServer = servers[sourceName];
585
+ if (!rawServer || typeof rawServer !== 'object' || Array.isArray(rawServer)) {
586
+ warnings.push(`${sourceLabel} 的 MCP ${sourceName} 结构不合法,已跳过`);
587
+ continue;
588
+ }
589
+
590
+ const mappedName = options.mcpNameMap[sourceName] || sourceName;
591
+ const normalizedName = normalizeMcpKey(mappedName);
592
+ const config = pickMcpServerConfig(rawServer, `${sourceLabel}/${sourceName}`, warnings);
593
+ if (!config) {
594
+ continue;
595
+ }
596
+
597
+ if (mergedServers[normalizedName]) {
598
+ warnings.push(
599
+ `MCP ${normalizedName} 由 ${sourceLabel} 覆盖 ${sourceTrace[normalizedName]}(后者优先)`
600
+ );
601
+ }
602
+
603
+ mergedServers[normalizedName] = config;
604
+ sourceTrace[normalizedName] = sourceLabel;
605
+ }
606
+
607
+ return true;
608
+ }
609
+
610
+ function chooseHomeMcpPath(claudeHome) {
611
+ const candidates = [
612
+ path.join(claudeHome, 'mcp.json'),
613
+ path.join(claudeHome, '.mcp.json')
614
+ ];
615
+
616
+ return candidates.find(candidate => fs.existsSync(candidate)) || null;
617
+ }
618
+
619
+ function resolveHomeMcpTargetPath(claudeHome) {
620
+ return chooseHomeMcpPath(claudeHome) || path.join(claudeHome, 'mcp.json');
621
+ }
622
+
623
+ function stableStringify(value) {
624
+ if (Array.isArray(value)) {
625
+ return `[${value.map(item => stableStringify(item)).join(',')}]`;
626
+ }
627
+
628
+ if (value && typeof value === 'object') {
629
+ const keys = Object.keys(value).sort((a, b) => a.localeCompare(b));
630
+ return `{${keys.map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
631
+ }
632
+
633
+ return JSON.stringify(value);
634
+ }
635
+
636
+ function mirrorProjectMcpIntoHome({ claudeHome, projectRoot, check }) {
637
+ const projectMcpPath = path.join(projectRoot, '.mcp.json');
638
+ if (!fs.existsSync(projectMcpPath)) {
639
+ return {
640
+ enabled: false,
641
+ changed: false,
642
+ sourcePath: projectMcpPath,
643
+ targetPath: null,
644
+ addedOrUpdated: [],
645
+ removed: [],
646
+ warnings: ['项目 .mcp.json 不存在,跳过 project -> ~/.claude mcp 镜像']
647
+ };
648
+ }
649
+
650
+ const projectJson = readJsonIfExists(projectMcpPath);
651
+ const projectServers = projectJson && projectJson.mcpServers;
652
+ if (!projectServers || typeof projectServers !== 'object' || Array.isArray(projectServers)) {
653
+ throw new Error(`${projectMcpPath} 缺少 mcpServers 对象`);
654
+ }
655
+
656
+ const targetPath = resolveHomeMcpTargetPath(claudeHome);
657
+ const targetJson = readJsonIfExists(targetPath) || { mcpServers: {} };
658
+ const targetServers = targetJson.mcpServers;
659
+ if (!targetServers || typeof targetServers !== 'object' || Array.isArray(targetServers)) {
660
+ throw new Error(`${targetPath} 缺少 mcpServers 对象`);
661
+ }
662
+
663
+ const statePath = path.join(claudeHome, '.codex-project-mcp-sync-state.json');
664
+ const stateJson = readJsonIfExists(statePath) || { projects: {} };
665
+ const projectsState = stateJson.projects && typeof stateJson.projects === 'object' && !Array.isArray(stateJson.projects)
666
+ ? stateJson.projects
667
+ : {};
668
+
669
+ const projectKey = path.resolve(projectRoot);
670
+ const previousNames = Array.isArray(projectsState[projectKey]) ? projectsState[projectKey] : [];
671
+ const nextNames = Object.keys(projectServers).sort((a, b) => a.localeCompare(b));
672
+
673
+ const mergedServers = { ...targetServers };
674
+ const removed = [];
675
+
676
+ for (const oldName of previousNames) {
677
+ if (!Object.prototype.hasOwnProperty.call(projectServers, oldName) && Object.prototype.hasOwnProperty.call(mergedServers, oldName)) {
678
+ delete mergedServers[oldName];
679
+ removed.push(oldName);
680
+ }
681
+ }
682
+
683
+ for (const [name, serverConfig] of Object.entries(projectServers)) {
684
+ mergedServers[name] = serverConfig;
685
+ }
686
+
687
+ const oldPayload = { mcpServers: targetServers };
688
+ const nextPayload = { mcpServers: mergedServers };
689
+ const payloadChanged = stableStringify(oldPayload) !== stableStringify(nextPayload);
690
+
691
+ const nextState = {
692
+ projects: {
693
+ ...projectsState,
694
+ [projectKey]: nextNames
695
+ }
696
+ };
697
+ const stateChanged = stableStringify({ projects: projectsState }) !== stableStringify(nextState);
698
+ const changed = payloadChanged || stateChanged;
699
+
700
+ if (changed && !check) {
701
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
702
+ fs.writeFileSync(targetPath, `${JSON.stringify(nextPayload, null, 2)}
703
+ `, 'utf8');
704
+
705
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
706
+ fs.writeFileSync(statePath, `${JSON.stringify(nextState, null, 2)}
707
+ `, 'utf8');
708
+ }
709
+
710
+ return {
711
+ enabled: true,
712
+ changed,
713
+ sourcePath: projectMcpPath,
714
+ targetPath,
715
+ addedOrUpdated: nextNames,
716
+ removed: removed.sort((a, b) => a.localeCompare(b)),
717
+ warnings: []
718
+ };
719
+ }
720
+
721
+ function syncMergedMcp({
722
+ codexHome,
723
+ check,
724
+ home,
725
+ project
726
+ }) {
727
+ const warnings = [];
728
+ const mergedServers = {};
729
+ const sourceTrace = {};
730
+ const sourceFiles = [];
731
+
732
+ if (home && home.enabled) {
733
+ if (collectServersFromFile({
734
+ filePath: home.mcpPath,
735
+ sourceLabel: 'home',
736
+ options: home.options,
737
+ mergedServers,
738
+ warnings,
739
+ sourceTrace
740
+ })) {
741
+ sourceFiles.push(home.mcpPath);
742
+ } else if (home.mcpPath) {
743
+ sourceFiles.push(home.mcpPath);
744
+ } else {
745
+ warnings.push('未找到 ~/.claude/mcp.json(或 ~/.claude/.mcp.json),已跳过 home MCP');
746
+ }
747
+ }
748
+
749
+ if (project && project.enabled) {
750
+ if (collectServersFromFile({
751
+ filePath: project.mcpPath,
752
+ sourceLabel: 'project',
753
+ options: project.options,
754
+ mergedServers,
755
+ warnings,
756
+ sourceTrace
757
+ })) {
758
+ sourceFiles.push(project.mcpPath);
759
+ } else if (project.mcpPath) {
760
+ sourceFiles.push(project.mcpPath);
761
+ }
762
+ }
763
+
764
+ if (sourceFiles.length === 0) {
765
+ return {
766
+ changed: false,
767
+ skipped: true,
768
+ configPath: path.join(codexHome, 'config.toml'),
769
+ sourcePaths: [],
770
+ added: [],
771
+ updated: [],
772
+ removed: [],
773
+ managedCount: 0,
774
+ warnings
775
+ };
776
+ }
777
+
778
+ const { blockText, entryMap: newEntryMap } = buildManagedMcpBlock(mergedServers);
779
+ const configPath = path.join(codexHome, 'config.toml');
780
+ const oldConfigText = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : '';
781
+ const oldManaged = extractManagedBlock(oldConfigText);
782
+ const oldEntryMap = oldManaged ? parseMcpEntryMapFromBlock(oldManaged.blockText) : {};
783
+
784
+ const newConfigText = applyManagedBlock(oldConfigText, blockText);
785
+ const changed = newConfigText !== oldConfigText;
786
+
787
+ if (changed && !check) {
788
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
789
+ fs.writeFileSync(configPath, newConfigText, 'utf8');
790
+ }
791
+
792
+ const oldNames = new Set(Object.keys(oldEntryMap));
793
+ const newNames = new Set(Object.keys(newEntryMap));
794
+
795
+ const added = [...newNames].filter(name => !oldNames.has(name)).sort((a, b) => a.localeCompare(b));
796
+ const removed = [...oldNames].filter(name => !newNames.has(name)).sort((a, b) => a.localeCompare(b));
797
+ const updated = [...newNames]
798
+ .filter(name => oldNames.has(name) && oldEntryMap[name] !== newEntryMap[name])
799
+ .sort((a, b) => a.localeCompare(b));
800
+
801
+ return {
802
+ changed,
803
+ skipped: false,
804
+ configPath,
805
+ sourcePaths: sourceFiles,
806
+ added,
807
+ updated,
808
+ removed,
809
+ managedCount: Object.keys(newEntryMap).length,
810
+ warnings
811
+ };
812
+ }
813
+
814
+ function syncSources({ projectRoot, claudeHome, codexHome, check, includeHome, includeProject }) {
815
+ const homeOptions = loadSyncOptions(path.join(claudeHome, '.codex-sync.json'));
816
+ const projectOptions = projectRoot
817
+ ? loadSyncOptions(path.join(projectRoot, '.claude-codex-sync.json'))
818
+ : { ...DEFAULT_SYNC_OPTIONS };
819
+
820
+ const skillsReports = [];
821
+ const warnings = [];
822
+
823
+ if (includeHome) {
824
+ const homeSkills = syncSkills({
825
+ sourceSkillsDir: path.join(claudeHome, 'skills'),
826
+ targetSkillsRoot: path.join(codexHome, 'skills', 'claude-home'),
827
+ options: homeOptions,
828
+ check
829
+ });
830
+
831
+ skillsReports.push({ source: 'home', ...homeSkills });
832
+ warnings.push(...homeSkills.warnings);
833
+ }
834
+
835
+ if (includeProject && projectRoot) {
836
+ const projectSkills = syncSkills({
837
+ sourceSkillsDir: path.join(projectRoot, '.claude', 'skills'),
838
+ targetSkillsRoot: path.join(codexHome, 'skills', 'project', path.basename(projectRoot)),
839
+ options: projectOptions,
840
+ check
841
+ });
842
+
843
+ skillsReports.push({ source: 'project', ...projectSkills });
844
+ warnings.push(...projectSkills.warnings);
845
+ }
846
+
847
+ let projectMcpMirror = {
848
+ enabled: false,
849
+ changed: false,
850
+ sourcePath: projectRoot ? path.join(projectRoot, '.mcp.json') : null,
851
+ targetPath: null,
852
+ addedOrUpdated: [],
853
+ removed: [],
854
+ warnings: []
855
+ };
856
+
857
+ if (includeHome && includeProject && projectRoot) {
858
+ projectMcpMirror = mirrorProjectMcpIntoHome({
859
+ claudeHome,
860
+ projectRoot,
861
+ check
862
+ });
863
+ warnings.push(...projectMcpMirror.warnings);
864
+ }
865
+
866
+ const mcp = syncMergedMcp({
867
+ codexHome,
868
+ check,
869
+ home: {
870
+ enabled: includeHome,
871
+ mcpPath: includeHome ? resolveHomeMcpTargetPath(claudeHome) : null,
872
+ options: homeOptions
873
+ },
874
+ project: {
875
+ enabled: includeProject && Boolean(projectRoot) && !projectMcpMirror.enabled,
876
+ mcpPath: projectRoot ? path.join(projectRoot, '.mcp.json') : null,
877
+ options: projectOptions
878
+ }
879
+ });
880
+
881
+ warnings.push(...mcp.warnings);
882
+
883
+ const skillsChanged = skillsReports.some(item => item.changed);
884
+
885
+ return {
886
+ changed: skillsChanged || mcp.changed || projectMcpMirror.changed,
887
+ check,
888
+ claudeHome,
889
+ codexHome,
890
+ projectRoot,
891
+ includeHome,
892
+ includeProject,
893
+ skillsReports,
894
+ mcp,
895
+ projectMcpMirror,
896
+ warnings
897
+ };
898
+ }
899
+
900
+ function printReport(report) {
901
+ const modeText = report.check ? '检查模式(不写入)' : '同步模式(会写入)';
902
+ console.log(`\n🔄 Claude(全局+项目) → Codex 同步:${modeText}`);
903
+ console.log(`🏠 Claude Home: ${report.claudeHome}`);
904
+ console.log(`🏠 Codex Home: ${report.codexHome}`);
905
+ console.log(`📁 Project Root: ${report.projectRoot || '无(仅全局)'}`);
906
+
907
+ console.log('\n🧩 Skills:');
908
+ if (report.skillsReports.length === 0) {
909
+ console.log('- 本次未启用 skills 同步');
910
+ }
911
+
912
+ for (const item of report.skillsReports) {
913
+ console.log(`- [${item.source}] 来源数量: ${item.sourceCount}`);
914
+ console.log(` 目标目录: ${item.targetRoot}`);
915
+ console.log(` 新增: ${item.added.length > 0 ? item.added.join(', ') : '无'}`);
916
+ console.log(` 更新: ${item.updated.length > 0 ? item.updated.join(', ') : '无'}`);
917
+ console.log(` 删除: ${item.removed.length > 0 ? item.removed.join(', ') : '无'}`);
918
+ }
919
+
920
+ if (report.projectMcpMirror && report.projectMcpMirror.enabled) {
921
+ console.log('\n↔ Home MCP Mirror:');
922
+ console.log(`- 来源配置: ${report.projectMcpMirror.sourcePath}`);
923
+ console.log(`- 目标配置: ${report.projectMcpMirror.targetPath}`);
924
+ console.log(`- 同步(新增或更新): ${report.projectMcpMirror.addedOrUpdated.length > 0 ? report.projectMcpMirror.addedOrUpdated.join(', ') : '无'}`);
925
+ console.log(`- 移除(已不在项目中): ${report.projectMcpMirror.removed.length > 0 ? report.projectMcpMirror.removed.join(', ') : '无'}`);
926
+ }
927
+
928
+ console.log('\n🔌 MCP:');
929
+ if (report.mcp.skipped) {
930
+ console.log('- 未发现可用 mcp 源文件,本次跳过 mcp 同步');
931
+ } else {
932
+ console.log(`- 来源配置: ${report.mcp.sourcePaths.join(' | ')}`);
933
+ console.log(`- 目标配置: ${report.mcp.configPath}`);
934
+ console.log(`- 托管 server 数量: ${report.mcp.managedCount}`);
935
+ console.log(`- 新增: ${report.mcp.added.length > 0 ? report.mcp.added.join(', ') : '无'}`);
936
+ console.log(`- 更新: ${report.mcp.updated.length > 0 ? report.mcp.updated.join(', ') : '无'}`);
937
+ console.log(`- 删除: ${report.mcp.removed.length > 0 ? report.mcp.removed.join(', ') : '无'}`);
938
+ }
939
+
940
+ const warningSet = [...new Set(report.warnings)];
941
+ if (warningSet.length > 0) {
942
+ console.log('\n⚠️ Warnings:');
943
+ for (const warning of warningSet) {
944
+ console.log(`- ${warning}`);
945
+ }
946
+ }
947
+
948
+ if (report.changed) {
949
+ console.log(report.check ? '\n❗ 检测到差异,需要执行同步。' : '\n✅ 同步完成,已写入变更。');
950
+ } else {
951
+ console.log('\n✅ 已是最新,无需同步。');
952
+ }
953
+ }
954
+
955
+ function main() {
956
+ const cliOptions = parseArgs(process.argv.slice(2));
957
+ const codexHome = resolveCodexHome(cliOptions.codexHome);
958
+ const claudeHome = resolveClaudeHome(cliOptions.claudeHome);
959
+ const projectRoot = detectProjectRoot(cliOptions.projectRoot);
960
+
961
+ const includeHome = !cliOptions.noHome;
962
+ const includeProject = !cliOptions.noProject && Boolean(projectRoot);
963
+
964
+ const report = syncSources({
965
+ projectRoot,
966
+ claudeHome,
967
+ codexHome,
968
+ check: cliOptions.check,
969
+ includeHome,
970
+ includeProject
971
+ });
972
+
973
+ printReport(report);
974
+
975
+ if (cliOptions.check && report.changed) {
976
+ process.exitCode = 1;
977
+ }
978
+ }
979
+
980
+ if (require.main === module) {
981
+ try {
982
+ main();
983
+ } catch (error) {
984
+ console.error(`\n❌ 同步失败: ${error.message}`);
985
+ process.exit(1);
986
+ }
987
+ }
988
+
989
+ module.exports = {
990
+ MANAGED_BLOCK_START,
991
+ MANAGED_BLOCK_END,
992
+ applyManagedBlock,
993
+ buildManagedMcpBlock,
994
+ parseMcpEntryMapFromBlock,
995
+ syncSkills,
996
+ syncSources
997
+ };