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,924 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Claude Runtime Sync
5
+ *
6
+ * 让 ~/.claude 与 <repo>/.claude 作为唯一真实源,复用到 Codex:
7
+ * - skills + mcp(委托现有 sync-claude-all-to-codex)
8
+ * - plugins + hooks
9
+ * - CLAUDE.md -> agents.md / gemini.md
10
+ * - 生成插件桥接 manifest(供 codex-plugin-bridge 使用)
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const crypto = require('crypto');
17
+ const { execSync } = require('child_process');
18
+
19
+ const { syncSources: syncBaseSources } = require('./sync-claude-all-to-codex');
20
+
21
+ const BRIDGE_MANIFEST_RELATIVE_PATH = path.join('plugins', 'claude-bridge', 'manifest.json');
22
+
23
+ const DEFAULT_OPTIONS = {
24
+ ignorePlugins: [],
25
+ ignoreHookSources: [],
26
+ pluginNameMap: {}
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 resolveCodexHome(overrideValue) {
77
+ if (overrideValue) {
78
+ return overrideValue;
79
+ }
80
+
81
+ if (process.env.CODEX_HOME && process.env.CODEX_HOME.trim()) {
82
+ return path.resolve(process.env.CODEX_HOME);
83
+ }
84
+
85
+ return path.join(os.homedir(), '.codex');
86
+ }
87
+
88
+ function resolveClaudeHome(overrideValue) {
89
+ if (overrideValue) {
90
+ return overrideValue;
91
+ }
92
+
93
+ return path.join(os.homedir(), '.claude');
94
+ }
95
+
96
+ function detectProjectRoot(explicitRoot) {
97
+ if (explicitRoot) {
98
+ return explicitRoot;
99
+ }
100
+
101
+ try {
102
+ const output = execSync('git rev-parse --show-toplevel', {
103
+ cwd: process.cwd(),
104
+ stdio: ['ignore', 'pipe', 'ignore']
105
+ });
106
+ return output.toString('utf8').trim();
107
+ } catch (_) {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ function readJsonIfExists(filePath) {
113
+ if (!fs.existsSync(filePath)) {
114
+ return null;
115
+ }
116
+
117
+ const raw = fs.readFileSync(filePath, 'utf8');
118
+ if (!raw.trim()) {
119
+ return null;
120
+ }
121
+
122
+ try {
123
+ return JSON.parse(raw);
124
+ } catch (error) {
125
+ throw new Error(`${filePath} JSON 解析失败: ${error.message}`);
126
+ }
127
+ }
128
+
129
+ function normalizeOptions(userOptions, configPath) {
130
+ const merged = {
131
+ ...DEFAULT_OPTIONS,
132
+ ...(userOptions || {})
133
+ };
134
+
135
+ for (const key of ['ignorePlugins', 'ignoreHookSources']) {
136
+ if (!Array.isArray(merged[key])) {
137
+ throw new Error(`${configPath} 中 ${key} 必须是数组`);
138
+ }
139
+ }
140
+
141
+ if (!merged.pluginNameMap || typeof merged.pluginNameMap !== 'object' || Array.isArray(merged.pluginNameMap)) {
142
+ throw new Error(`${configPath} 中 pluginNameMap 必须是对象`);
143
+ }
144
+
145
+ return merged;
146
+ }
147
+
148
+ function loadSyncOptions(configPath) {
149
+ const userOptions = readJsonIfExists(configPath);
150
+ if (!userOptions) {
151
+ return { ...DEFAULT_OPTIONS };
152
+ }
153
+
154
+ return normalizeOptions(userOptions, configPath);
155
+ }
156
+
157
+ function listDirectories(dirPath) {
158
+ if (!fs.existsSync(dirPath)) {
159
+ return [];
160
+ }
161
+
162
+ return fs
163
+ .readdirSync(dirPath, { withFileTypes: true })
164
+ .filter(entry => entry.isDirectory())
165
+ .map(entry => entry.name)
166
+ .sort((a, b) => a.localeCompare(b));
167
+ }
168
+
169
+ function listFilesRecursive(rootDir, currentDir = rootDir) {
170
+ if (!fs.existsSync(currentDir)) {
171
+ return [];
172
+ }
173
+
174
+ const files = [];
175
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
176
+
177
+ for (const entry of entries) {
178
+ const absolutePath = path.join(currentDir, entry.name);
179
+ const relativePath = path.relative(rootDir, absolutePath);
180
+
181
+ if (entry.isDirectory()) {
182
+ files.push(...listFilesRecursive(rootDir, absolutePath));
183
+ continue;
184
+ }
185
+
186
+ if (!entry.isFile()) {
187
+ continue;
188
+ }
189
+
190
+ if (entry.name === '.DS_Store') {
191
+ continue;
192
+ }
193
+
194
+ files.push(relativePath);
195
+ }
196
+
197
+ return files.sort((a, b) => a.localeCompare(b));
198
+ }
199
+
200
+ function digestDirectory(dirPath) {
201
+ if (!fs.existsSync(dirPath)) {
202
+ return null;
203
+ }
204
+
205
+ const hash = crypto.createHash('sha256');
206
+ const files = listFilesRecursive(dirPath);
207
+
208
+ for (const relativePath of files) {
209
+ const absolutePath = path.join(dirPath, relativePath);
210
+ hash.update(relativePath);
211
+ hash.update('\0');
212
+ hash.update(fs.readFileSync(absolutePath));
213
+ hash.update('\0');
214
+ }
215
+
216
+ return hash.digest('hex');
217
+ }
218
+
219
+ function digestFile(filePath) {
220
+ if (!fs.existsSync(filePath)) {
221
+ return null;
222
+ }
223
+
224
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
225
+ }
226
+
227
+ function stableStringify(value) {
228
+ if (Array.isArray(value)) {
229
+ return `[${value.map(item => stableStringify(item)).join(',')}]`;
230
+ }
231
+
232
+ if (value && typeof value === 'object') {
233
+ const keys = Object.keys(value).sort((a, b) => a.localeCompare(b));
234
+ return `{${keys.map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
235
+ }
236
+
237
+ return JSON.stringify(value);
238
+ }
239
+
240
+ function sanitizePathSegment(value, fallback) {
241
+ const cleaned = String(value || '').replace(/[\\/]/g, '-').trim();
242
+ return cleaned || fallback;
243
+ }
244
+
245
+ function resolveUniqueName(baseName, usedNames) {
246
+ if (!usedNames.has(baseName)) {
247
+ usedNames.add(baseName);
248
+ return baseName;
249
+ }
250
+
251
+ let index = 2;
252
+ while (usedNames.has(`${baseName}-${index}`)) {
253
+ index += 1;
254
+ }
255
+
256
+ const finalName = `${baseName}-${index}`;
257
+ usedNames.add(finalName);
258
+ return finalName;
259
+ }
260
+
261
+ function buildPluginMappings(sourcePluginsDir, options) {
262
+ const ignoreSet = new Set(options.ignorePlugins || []);
263
+ const sourceDirs = listDirectories(sourcePluginsDir);
264
+ const warnings = [];
265
+ const mappings = [];
266
+ const usedTargets = new Set();
267
+
268
+ for (const sourceName of sourceDirs) {
269
+ if (ignoreSet.has(sourceName)) {
270
+ continue;
271
+ }
272
+
273
+ const sourcePath = path.join(sourcePluginsDir, sourceName);
274
+ const metaPath = path.join(sourcePath, '.claude-plugin', 'plugin.json');
275
+ if (!fs.existsSync(metaPath)) {
276
+ warnings.push(`插件目录 ${sourceName} 缺少 .claude-plugin/plugin.json,已跳过`);
277
+ continue;
278
+ }
279
+
280
+ let pluginName = sourceName;
281
+ try {
282
+ const meta = readJsonIfExists(metaPath);
283
+ if (meta && typeof meta.name === 'string' && meta.name.trim()) {
284
+ pluginName = meta.name.trim();
285
+ }
286
+ } catch (error) {
287
+ warnings.push(`插件目录 ${sourceName} 元数据解析失败,已跳过: ${error.message}`);
288
+ continue;
289
+ }
290
+
291
+ const mappedName = options.pluginNameMap[pluginName] || options.pluginNameMap[sourceName] || pluginName;
292
+ const normalizedTarget = sanitizePathSegment(mappedName, sourceName);
293
+ const targetName = resolveUniqueName(normalizedTarget, usedTargets);
294
+
295
+ mappings.push({
296
+ sourceName,
297
+ sourcePath,
298
+ pluginName,
299
+ targetName
300
+ });
301
+ }
302
+
303
+ return { mappings, warnings };
304
+ }
305
+
306
+ function syncPlugins({ sourcePluginsDir, targetPluginsRoot, sourceType, options, check }) {
307
+ const { mappings, warnings } = buildPluginMappings(sourcePluginsDir, options);
308
+ const targetNames = new Set(mappings.map(item => item.targetName));
309
+ const existingTargetDirs = listDirectories(targetPluginsRoot);
310
+
311
+ const added = [];
312
+ const updated = [];
313
+ const removed = [];
314
+ const entries = [];
315
+
316
+ for (const mapping of mappings) {
317
+ const targetPath = path.join(targetPluginsRoot, mapping.targetName);
318
+ entries.push({
319
+ sourceType,
320
+ pluginName: mapping.pluginName,
321
+ sourcePath: mapping.sourcePath,
322
+ targetPath
323
+ });
324
+
325
+ if (!fs.existsSync(targetPath)) {
326
+ added.push(mapping.targetName);
327
+ if (!check) {
328
+ fs.mkdirSync(targetPluginsRoot, { recursive: true });
329
+ fs.cpSync(mapping.sourcePath, targetPath, { recursive: true, force: true });
330
+ }
331
+ continue;
332
+ }
333
+
334
+ const sourceDigest = digestDirectory(mapping.sourcePath);
335
+ const targetDigest = digestDirectory(targetPath);
336
+ if (sourceDigest !== targetDigest) {
337
+ updated.push(mapping.targetName);
338
+ if (!check) {
339
+ fs.rmSync(targetPath, { recursive: true, force: true });
340
+ fs.cpSync(mapping.sourcePath, targetPath, { recursive: true, force: true });
341
+ }
342
+ }
343
+ }
344
+
345
+ for (const existingDir of existingTargetDirs) {
346
+ if (targetNames.has(existingDir)) {
347
+ continue;
348
+ }
349
+
350
+ removed.push(existingDir);
351
+ if (!check) {
352
+ fs.rmSync(path.join(targetPluginsRoot, existingDir), { recursive: true, force: true });
353
+ }
354
+ }
355
+
356
+ const changed = added.length > 0 || updated.length > 0 || removed.length > 0;
357
+ return {
358
+ changed,
359
+ sourceCount: mappings.length,
360
+ targetRoot: targetPluginsRoot,
361
+ added: added.sort((a, b) => a.localeCompare(b)),
362
+ updated: updated.sort((a, b) => a.localeCompare(b)),
363
+ removed: removed.sort((a, b) => a.localeCompare(b)),
364
+ warnings,
365
+ entries
366
+ };
367
+ }
368
+
369
+ function syncHooksDir({ sourceHooksDir, targetHooksRoot, sourceType, options, check }) {
370
+ const warnings = [];
371
+ if ((options.ignoreHookSources || []).includes(sourceType)) {
372
+ return {
373
+ enabled: false,
374
+ changed: false,
375
+ sourcePath: sourceHooksDir,
376
+ targetRoot: targetHooksRoot,
377
+ added: [],
378
+ updated: [],
379
+ removed: [],
380
+ warnings,
381
+ hookFilePath: null,
382
+ sourceType
383
+ };
384
+ }
385
+
386
+ const hookFileName = 'hooks.json';
387
+ const sourceHookPath = path.join(sourceHooksDir, hookFileName);
388
+ const targetHookPath = path.join(targetHooksRoot, hookFileName);
389
+ const targetExists = fs.existsSync(targetHookPath);
390
+ const sourceExists = fs.existsSync(sourceHookPath);
391
+
392
+ const added = [];
393
+ const updated = [];
394
+ const removed = [];
395
+
396
+ if (!sourceExists) {
397
+ if (targetExists) {
398
+ removed.push(hookFileName);
399
+ if (!check) {
400
+ fs.rmSync(targetHookPath, { force: true });
401
+ }
402
+ }
403
+
404
+ return {
405
+ enabled: false,
406
+ changed: removed.length > 0,
407
+ sourcePath: sourceHookPath,
408
+ targetRoot: targetHooksRoot,
409
+ added,
410
+ updated,
411
+ removed,
412
+ warnings,
413
+ hookFilePath: null,
414
+ sourceType
415
+ };
416
+ }
417
+
418
+ if (!targetExists) {
419
+ added.push(hookFileName);
420
+ if (!check) {
421
+ fs.mkdirSync(targetHooksRoot, { recursive: true });
422
+ fs.copyFileSync(sourceHookPath, targetHookPath);
423
+ }
424
+ } else {
425
+ const sourceDigest = digestFile(sourceHookPath);
426
+ const targetDigest = digestFile(targetHookPath);
427
+ if (sourceDigest !== targetDigest) {
428
+ updated.push(hookFileName);
429
+ if (!check) {
430
+ fs.mkdirSync(targetHooksRoot, { recursive: true });
431
+ fs.copyFileSync(sourceHookPath, targetHookPath);
432
+ }
433
+ }
434
+ }
435
+
436
+ return {
437
+ enabled: true,
438
+ changed: added.length > 0 || updated.length > 0 || removed.length > 0,
439
+ sourcePath: sourceHookPath,
440
+ targetRoot: targetHooksRoot,
441
+ added,
442
+ updated,
443
+ removed,
444
+ warnings,
445
+ hookFilePath: targetHookPath,
446
+ sourceType
447
+ };
448
+ }
449
+
450
+ function safeReadlinkTarget(linkPath) {
451
+ try {
452
+ const rawTarget = fs.readlinkSync(linkPath);
453
+ return path.resolve(path.dirname(linkPath), rawTarget);
454
+ } catch (_) {
455
+ return null;
456
+ }
457
+ }
458
+
459
+ function syncDocAliases({ projectRoot, check }) {
460
+ const sourceCandidates = ['CLAUDE.md', 'claude.md'];
461
+ const targetAliases = ['agents.md', 'gemini.md'];
462
+ const warnings = [];
463
+ const linked = [];
464
+ const copied = [];
465
+ const unchanged = [];
466
+
467
+ let sourcePath = null;
468
+ for (const candidate of sourceCandidates) {
469
+ const fullPath = path.join(projectRoot, candidate);
470
+ if (fs.existsSync(fullPath) && fs.statSync(fullPath).isFile()) {
471
+ sourcePath = fullPath;
472
+ break;
473
+ }
474
+ }
475
+
476
+ if (!sourcePath) {
477
+ return {
478
+ enabled: false,
479
+ changed: false,
480
+ sourcePath: null,
481
+ linked,
482
+ copied,
483
+ unchanged,
484
+ warnings: ['项目中未找到 CLAUDE.md/claude.md,跳过文档别名同步']
485
+ };
486
+ }
487
+
488
+ const sourceContent = fs.readFileSync(sourcePath, 'utf8');
489
+ const sourceBaseName = path.basename(sourcePath);
490
+
491
+ for (const aliasName of targetAliases) {
492
+ const aliasPath = path.join(projectRoot, aliasName);
493
+
494
+ if (fs.existsSync(aliasPath)) {
495
+ const stat = fs.lstatSync(aliasPath);
496
+ if (stat.isSymbolicLink()) {
497
+ const resolved = safeReadlinkTarget(aliasPath);
498
+ if (resolved === sourcePath) {
499
+ unchanged.push(aliasName);
500
+ continue;
501
+ }
502
+ } else if (stat.isFile()) {
503
+ const targetContent = fs.readFileSync(aliasPath, 'utf8');
504
+ if (targetContent === sourceContent) {
505
+ unchanged.push(aliasName);
506
+ continue;
507
+ }
508
+ }
509
+
510
+ if (!check) {
511
+ fs.rmSync(aliasPath, { force: true });
512
+ }
513
+ }
514
+
515
+ if (check) {
516
+ linked.push(aliasName);
517
+ continue;
518
+ }
519
+
520
+ try {
521
+ fs.symlinkSync(sourceBaseName, aliasPath);
522
+ linked.push(aliasName);
523
+ } catch (error) {
524
+ fs.copyFileSync(sourcePath, aliasPath);
525
+ copied.push(aliasName);
526
+ warnings.push(`创建软链接失败,已回退复制 ${aliasName}: ${error.message}`);
527
+ }
528
+ }
529
+
530
+ return {
531
+ enabled: true,
532
+ changed: linked.length > 0 || copied.length > 0,
533
+ sourcePath,
534
+ linked,
535
+ copied,
536
+ unchanged,
537
+ warnings
538
+ };
539
+ }
540
+
541
+ function parseHookDefinitions(hookConfigPath, warnings) {
542
+ const hookConfig = readJsonIfExists(hookConfigPath);
543
+ if (!hookConfig || typeof hookConfig !== 'object') {
544
+ return [];
545
+ }
546
+
547
+ const hooksObject = hookConfig.hooks;
548
+ if (!hooksObject || typeof hooksObject !== 'object' || Array.isArray(hooksObject)) {
549
+ warnings.push(`${hookConfigPath} 缺少 hooks 对象,已跳过`);
550
+ return [];
551
+ }
552
+
553
+ const eventSpecs = [];
554
+ for (const [eventName, eventRules] of Object.entries(hooksObject)) {
555
+ if (!Array.isArray(eventRules)) {
556
+ warnings.push(`${hookConfigPath} 的事件 ${eventName} 不是数组,已跳过`);
557
+ continue;
558
+ }
559
+
560
+ for (const eventRule of eventRules) {
561
+ const matcher = typeof eventRule.matcher === 'string' && eventRule.matcher.trim() ? eventRule.matcher.trim() : null;
562
+ const hooks = Array.isArray(eventRule.hooks) ? eventRule.hooks : [];
563
+ const commands = [];
564
+
565
+ for (const hook of hooks) {
566
+ if (!hook || hook.type !== 'command' || typeof hook.command !== 'string' || !hook.command.trim()) {
567
+ continue;
568
+ }
569
+
570
+ let timeout = 10;
571
+ if (typeof hook.timeout === 'number' && Number.isFinite(hook.timeout) && hook.timeout > 0) {
572
+ timeout = hook.timeout;
573
+ }
574
+
575
+ commands.push({
576
+ command: hook.command,
577
+ timeout
578
+ });
579
+ }
580
+
581
+ if (commands.length === 0) {
582
+ continue;
583
+ }
584
+
585
+ eventSpecs.push({
586
+ eventName,
587
+ matcher,
588
+ commands
589
+ });
590
+ }
591
+ }
592
+
593
+ return eventSpecs;
594
+ }
595
+
596
+ function buildBridgeManifest({ codexHome, check, pluginReports, hookReports, projectRoot }) {
597
+ const warnings = [];
598
+ const pluginByName = new Map();
599
+ const sourcePriority = { project: 2, home: 1 };
600
+
601
+ const allPluginEntries = pluginReports.flatMap(item => item.entries || []);
602
+ allPluginEntries.sort((a, b) => {
603
+ const pa = sourcePriority[a.sourceType] || 0;
604
+ const pb = sourcePriority[b.sourceType] || 0;
605
+ return pb - pa;
606
+ });
607
+
608
+ for (const entry of allPluginEntries) {
609
+ if (pluginByName.has(entry.pluginName)) {
610
+ continue;
611
+ }
612
+
613
+ const hookConfigPath = path.join(entry.targetPath, 'hooks', 'hooks.json');
614
+ if (!fs.existsSync(hookConfigPath)) {
615
+ continue;
616
+ }
617
+
618
+ const events = parseHookDefinitions(hookConfigPath, warnings);
619
+ if (events.length === 0) {
620
+ continue;
621
+ }
622
+
623
+ pluginByName.set(entry.pluginName, {
624
+ id: `${entry.sourceType}:${entry.pluginName}`,
625
+ sourceType: entry.sourceType,
626
+ name: entry.pluginName,
627
+ rootPath: entry.targetPath,
628
+ hookConfigPath,
629
+ events
630
+ });
631
+ }
632
+
633
+ const topHookSources = [];
634
+ for (const report of hookReports) {
635
+ if (!report.enabled || !report.hookFilePath || !fs.existsSync(report.hookFilePath)) {
636
+ continue;
637
+ }
638
+
639
+ const events = parseHookDefinitions(report.hookFilePath, warnings);
640
+ if (events.length === 0) {
641
+ continue;
642
+ }
643
+
644
+ topHookSources.push({
645
+ id: `${report.sourceType}:top-hooks`,
646
+ sourceType: report.sourceType,
647
+ name: `${report.sourceType}-hooks`,
648
+ rootPath: path.dirname(report.hookFilePath),
649
+ hookConfigPath: report.hookFilePath,
650
+ events
651
+ });
652
+ }
653
+
654
+ const contentPayload = {
655
+ version: 1,
656
+ projectRoot: projectRoot || null,
657
+ plugins: [...pluginByName.values()],
658
+ topHooks: topHookSources
659
+ };
660
+
661
+ const manifestPath = path.join(codexHome, BRIDGE_MANIFEST_RELATIVE_PATH);
662
+ const oldManifest = readJsonIfExists(manifestPath);
663
+ const oldContentPayload = oldManifest && typeof oldManifest === 'object'
664
+ ? {
665
+ version: oldManifest.version || 1,
666
+ projectRoot: Object.prototype.hasOwnProperty.call(oldManifest, 'projectRoot') ? oldManifest.projectRoot : null,
667
+ plugins: Array.isArray(oldManifest.plugins) ? oldManifest.plugins : [],
668
+ topHooks: Array.isArray(oldManifest.topHooks) ? oldManifest.topHooks : []
669
+ }
670
+ : null;
671
+
672
+ const changed = !oldContentPayload || stableStringify(oldContentPayload) !== stableStringify(contentPayload);
673
+
674
+ const manifest = {
675
+ ...contentPayload,
676
+ generatedAt: changed
677
+ ? new Date().toISOString()
678
+ : (oldManifest && oldManifest.generatedAt ? oldManifest.generatedAt : new Date().toISOString())
679
+ };
680
+
681
+ if (changed && !check) {
682
+ fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
683
+ fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
684
+ }
685
+
686
+ return {
687
+ changed,
688
+ manifestPath,
689
+ pluginCount: manifest.plugins.length,
690
+ topHookCount: manifest.topHooks.length,
691
+ warnings
692
+ };
693
+ }
694
+
695
+ function syncRuntimeSources({ projectRoot, claudeHome, codexHome, check, includeHome, includeProject }) {
696
+ const baseReport = syncBaseSources({
697
+ projectRoot,
698
+ claudeHome,
699
+ codexHome,
700
+ check,
701
+ includeHome,
702
+ includeProject
703
+ });
704
+
705
+ const homeOptions = loadSyncOptions(path.join(claudeHome, '.codex-sync.json'));
706
+ const projectOptions = projectRoot
707
+ ? loadSyncOptions(path.join(projectRoot, '.claude-codex-sync.json'))
708
+ : { ...DEFAULT_OPTIONS };
709
+
710
+ const pluginReports = [];
711
+ const hookReports = [];
712
+ const warnings = [...(baseReport.warnings || [])];
713
+
714
+ if (includeHome) {
715
+ const homePlugins = syncPlugins({
716
+ sourcePluginsDir: path.join(claudeHome, 'plugins'),
717
+ targetPluginsRoot: path.join(codexHome, 'plugins', 'claude-home'),
718
+ sourceType: 'home',
719
+ options: homeOptions,
720
+ check
721
+ });
722
+ pluginReports.push({ source: 'home', ...homePlugins });
723
+ warnings.push(...homePlugins.warnings);
724
+
725
+ const homeHooks = syncHooksDir({
726
+ sourceHooksDir: path.join(claudeHome, 'hooks'),
727
+ targetHooksRoot: path.join(codexHome, 'hooks', 'claude-home'),
728
+ sourceType: 'home',
729
+ options: homeOptions,
730
+ check
731
+ });
732
+ hookReports.push(homeHooks);
733
+ warnings.push(...homeHooks.warnings);
734
+ }
735
+
736
+ if (includeProject && projectRoot) {
737
+ const projectPlugins = syncPlugins({
738
+ sourcePluginsDir: path.join(projectRoot, '.claude', 'plugins'),
739
+ targetPluginsRoot: path.join(codexHome, 'plugins', 'project', path.basename(projectRoot)),
740
+ sourceType: 'project',
741
+ options: projectOptions,
742
+ check
743
+ });
744
+ pluginReports.push({ source: 'project', ...projectPlugins });
745
+ warnings.push(...projectPlugins.warnings);
746
+
747
+ const projectHooks = syncHooksDir({
748
+ sourceHooksDir: path.join(projectRoot, '.claude', 'hooks'),
749
+ targetHooksRoot: path.join(codexHome, 'hooks', 'project', path.basename(projectRoot)),
750
+ sourceType: 'project',
751
+ options: projectOptions,
752
+ check
753
+ });
754
+ hookReports.push(projectHooks);
755
+ warnings.push(...projectHooks.warnings);
756
+ }
757
+
758
+ const docAliases = projectRoot && includeProject
759
+ ? syncDocAliases({ projectRoot, check })
760
+ : {
761
+ enabled: false,
762
+ changed: false,
763
+ sourcePath: null,
764
+ linked: [],
765
+ copied: [],
766
+ unchanged: [],
767
+ warnings: []
768
+ };
769
+ warnings.push(...docAliases.warnings);
770
+
771
+ const bridgeManifest = buildBridgeManifest({
772
+ codexHome,
773
+ check,
774
+ pluginReports,
775
+ hookReports,
776
+ projectRoot
777
+ });
778
+ warnings.push(...bridgeManifest.warnings);
779
+
780
+ const pluginsChanged = pluginReports.some(item => item.changed);
781
+ const hooksChanged = hookReports.some(item => item.changed);
782
+
783
+ return {
784
+ changed: Boolean(baseReport.changed || pluginsChanged || hooksChanged || docAliases.changed || bridgeManifest.changed),
785
+ check,
786
+ claudeHome,
787
+ codexHome,
788
+ projectRoot,
789
+ includeHome,
790
+ includeProject,
791
+ baseReport,
792
+ pluginReports,
793
+ hookReports,
794
+ docAliases,
795
+ bridgeManifest,
796
+ warnings
797
+ };
798
+ }
799
+
800
+ function printReport(report) {
801
+ const modeText = report.check ? '检查模式(不写入)' : '同步模式(会写入)';
802
+ console.log(`\n🔄 Claude Runtime -> Codex:${modeText}`);
803
+ console.log(`🏠 Claude Home: ${report.claudeHome}`);
804
+ console.log(`🏠 Codex Home: ${report.codexHome}`);
805
+ console.log(`📁 Project Root: ${report.projectRoot || '无(仅全局)'}`);
806
+
807
+ const base = report.baseReport;
808
+ console.log('\n🧩 Skills:');
809
+ for (const item of base.skillsReports || []) {
810
+ console.log(`- [${item.source}] 来源数量: ${item.sourceCount}`);
811
+ console.log(` 目标目录: ${item.targetRoot}`);
812
+ console.log(` 新增: ${item.added.length > 0 ? item.added.join(', ') : '无'}`);
813
+ console.log(` 更新: ${item.updated.length > 0 ? item.updated.join(', ') : '无'}`);
814
+ console.log(` 删除: ${item.removed.length > 0 ? item.removed.join(', ') : '无'}`);
815
+ }
816
+
817
+ console.log('\n🔌 Plugins:');
818
+ if (report.pluginReports.length === 0) {
819
+ console.log('- 本次未启用 plugins 同步');
820
+ }
821
+ for (const item of report.pluginReports) {
822
+ console.log(`- [${item.source}] 来源数量: ${item.sourceCount}`);
823
+ console.log(` 目标目录: ${item.targetRoot}`);
824
+ console.log(` 新增: ${item.added.length > 0 ? item.added.join(', ') : '无'}`);
825
+ console.log(` 更新: ${item.updated.length > 0 ? item.updated.join(', ') : '无'}`);
826
+ console.log(` 删除: ${item.removed.length > 0 ? item.removed.join(', ') : '无'}`);
827
+ }
828
+
829
+ console.log('\n🪝 Hooks:');
830
+ if (report.hookReports.length === 0) {
831
+ console.log('- 本次未启用 hooks 同步');
832
+ }
833
+ for (const item of report.hookReports) {
834
+ console.log(`- [${item.sourceType}] 目标目录: ${item.targetRoot}`);
835
+ console.log(` 新增: ${item.added.length > 0 ? item.added.join(', ') : '无'}`);
836
+ console.log(` 更新: ${item.updated.length > 0 ? item.updated.join(', ') : '无'}`);
837
+ console.log(` 删除: ${item.removed.length > 0 ? item.removed.join(', ') : '无'}`);
838
+ }
839
+
840
+ console.log('\n📄 CLAUDE.md 别名:');
841
+ if (!report.docAliases.enabled) {
842
+ console.log('- 未启用或缺少 CLAUDE.md,已跳过');
843
+ } else {
844
+ console.log(`- 来源: ${report.docAliases.sourcePath}`);
845
+ console.log(`- 链接: ${report.docAliases.linked.length > 0 ? report.docAliases.linked.join(', ') : '无'}`);
846
+ console.log(`- 复制回退: ${report.docAliases.copied.length > 0 ? report.docAliases.copied.join(', ') : '无'}`);
847
+ console.log(`- 已一致: ${report.docAliases.unchanged.length > 0 ? report.docAliases.unchanged.join(', ') : '无'}`);
848
+ }
849
+
850
+ console.log('\n🔗 MCP:');
851
+ if (base.mcp && base.mcp.skipped) {
852
+ console.log('- 未发现可用 mcp 源文件,本次跳过 mcp 同步');
853
+ } else if (base.mcp) {
854
+ console.log(`- 来源配置: ${base.mcp.sourcePaths.join(' | ')}`);
855
+ console.log(`- 目标配置: ${base.mcp.configPath}`);
856
+ console.log(`- 托管 server 数量: ${base.mcp.managedCount}`);
857
+ console.log(`- 新增: ${base.mcp.added.length > 0 ? base.mcp.added.join(', ') : '无'}`);
858
+ console.log(`- 更新: ${base.mcp.updated.length > 0 ? base.mcp.updated.join(', ') : '无'}`);
859
+ console.log(`- 删除: ${base.mcp.removed.length > 0 ? base.mcp.removed.join(', ') : '无'}`);
860
+ }
861
+
862
+ console.log('\n🧠 Plugin Bridge Manifest:');
863
+ console.log(`- 文件: ${report.bridgeManifest.manifestPath}`);
864
+ console.log(`- 激活插件数: ${report.bridgeManifest.pluginCount}`);
865
+ console.log(`- 顶层 hooks 数: ${report.bridgeManifest.topHookCount}`);
866
+
867
+ const warningSet = [...new Set(report.warnings || [])];
868
+ if (warningSet.length > 0) {
869
+ console.log('\n⚠️ Warnings:');
870
+ for (const warning of warningSet) {
871
+ console.log(`- ${warning}`);
872
+ }
873
+ }
874
+
875
+ if (report.changed) {
876
+ console.log(report.check ? '\n❗ 检测到差异,需要执行同步。' : '\n✅ 同步完成,已写入变更。');
877
+ } else {
878
+ console.log('\n✅ 已是最新,无需同步。');
879
+ }
880
+ }
881
+
882
+ function main() {
883
+ const cliOptions = parseArgs(process.argv.slice(2));
884
+ const codexHome = resolveCodexHome(cliOptions.codexHome);
885
+ const claudeHome = resolveClaudeHome(cliOptions.claudeHome);
886
+ const projectRoot = detectProjectRoot(cliOptions.projectRoot);
887
+
888
+ const includeHome = !cliOptions.noHome;
889
+ const includeProject = !cliOptions.noProject && Boolean(projectRoot);
890
+
891
+ const report = syncRuntimeSources({
892
+ projectRoot,
893
+ claudeHome,
894
+ codexHome,
895
+ check: cliOptions.check,
896
+ includeHome,
897
+ includeProject
898
+ });
899
+
900
+ printReport(report);
901
+
902
+ if (cliOptions.check && report.changed) {
903
+ process.exitCode = 1;
904
+ }
905
+ }
906
+
907
+ if (require.main === module) {
908
+ try {
909
+ main();
910
+ } catch (error) {
911
+ console.error(`\n❌ 同步失败: ${error.message}`);
912
+ process.exit(1);
913
+ }
914
+ }
915
+
916
+ module.exports = {
917
+ BRIDGE_MANIFEST_RELATIVE_PATH,
918
+ buildBridgeManifest,
919
+ parseHookDefinitions,
920
+ syncDocAliases,
921
+ syncHooksDir,
922
+ syncPlugins,
923
+ syncRuntimeSources
924
+ };