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,427 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Codex Plugin Bridge
5
+ *
6
+ * 读取 ~/.codex/plugins/claude-bridge/manifest.json,
7
+ * 将 Codex 事件映射为 Claude hooks 事件并执行 command hook。
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+ const { spawnSync } = require('child_process');
14
+
15
+ const BRIDGE_MANIFEST_RELATIVE_PATH = path.join('plugins', 'claude-bridge', 'manifest.json');
16
+
17
+ const CODEX_EVENT_MAP = {
18
+ exec_approval_request: ['PermissionRequest'],
19
+ apply_patch_approval_request: ['PermissionRequest'],
20
+ request_user_input: ['PermissionRequest'],
21
+ task_started: ['TaskStarted'],
22
+ session_configured: ['TaskStarted'],
23
+ task_complete: ['TaskComplete'],
24
+ error: ['ToolError'],
25
+ warning: ['ToolError'],
26
+ turn_aborted: ['ToolError'],
27
+ stream_error: ['ToolError'],
28
+ mcp_startup_complete: ['MCPStartupComplete']
29
+ };
30
+
31
+ function parseArgs(argv) {
32
+ const options = {
33
+ codexHome: null,
34
+ projectRoot: null,
35
+ since: null,
36
+ watch: false,
37
+ pollMs: 600,
38
+ emitStop: false,
39
+ quiet: true
40
+ };
41
+
42
+ for (const arg of argv) {
43
+ if (arg === '--watch') {
44
+ options.watch = true;
45
+ continue;
46
+ }
47
+
48
+ if (arg === '--emit-stop') {
49
+ options.emitStop = true;
50
+ continue;
51
+ }
52
+
53
+ if (arg === '--verbose') {
54
+ options.quiet = false;
55
+ continue;
56
+ }
57
+
58
+ if (arg.startsWith('--codex-home=')) {
59
+ options.codexHome = path.resolve(arg.slice('--codex-home='.length));
60
+ continue;
61
+ }
62
+
63
+ if (arg.startsWith('--project-root=')) {
64
+ options.projectRoot = path.resolve(arg.slice('--project-root='.length));
65
+ continue;
66
+ }
67
+
68
+ if (arg.startsWith('--since=')) {
69
+ const raw = Number(arg.slice('--since='.length));
70
+ if (Number.isFinite(raw) && raw > 0) {
71
+ options.since = raw;
72
+ }
73
+ continue;
74
+ }
75
+
76
+ if (arg.startsWith('--poll-ms=')) {
77
+ const raw = Number(arg.slice('--poll-ms='.length));
78
+ if (Number.isFinite(raw) && raw >= 200) {
79
+ options.pollMs = raw;
80
+ }
81
+ continue;
82
+ }
83
+
84
+ throw new Error(`未知参数: ${arg}`);
85
+ }
86
+
87
+ return options;
88
+ }
89
+
90
+ function resolveCodexHome(overrideValue) {
91
+ if (overrideValue) {
92
+ return overrideValue;
93
+ }
94
+
95
+ if (process.env.CODEX_HOME && process.env.CODEX_HOME.trim()) {
96
+ return path.resolve(process.env.CODEX_HOME);
97
+ }
98
+
99
+ return path.join(os.homedir(), '.codex');
100
+ }
101
+
102
+ function readJsonIfExists(filePath) {
103
+ if (!fs.existsSync(filePath)) {
104
+ return null;
105
+ }
106
+
107
+ const raw = fs.readFileSync(filePath, 'utf8');
108
+ if (!raw.trim()) {
109
+ return null;
110
+ }
111
+
112
+ try {
113
+ return JSON.parse(raw);
114
+ } catch (error) {
115
+ throw new Error(`${filePath} JSON 解析失败: ${error.message}`);
116
+ }
117
+ }
118
+
119
+ function readManifest(codexHome) {
120
+ const manifestPath = path.join(codexHome, BRIDGE_MANIFEST_RELATIVE_PATH);
121
+ const manifest = readJsonIfExists(manifestPath);
122
+ if (!manifest || typeof manifest !== 'object') {
123
+ return {
124
+ manifestPath,
125
+ plugins: [],
126
+ topHooks: []
127
+ };
128
+ }
129
+
130
+ return {
131
+ manifestPath,
132
+ plugins: Array.isArray(manifest.plugins) ? manifest.plugins : [],
133
+ topHooks: Array.isArray(manifest.topHooks) ? manifest.topHooks : []
134
+ };
135
+ }
136
+
137
+ function collectSessionFiles(sessionsRoot, sinceEpochSec) {
138
+ if (!fs.existsSync(sessionsRoot)) {
139
+ return [];
140
+ }
141
+
142
+ const files = [];
143
+ const stack = [sessionsRoot];
144
+
145
+ while (stack.length > 0) {
146
+ const current = stack.pop();
147
+ const entries = fs.readdirSync(current, { withFileTypes: true });
148
+
149
+ for (const entry of entries) {
150
+ const fullPath = path.join(current, entry.name);
151
+ if (entry.isDirectory()) {
152
+ stack.push(fullPath);
153
+ continue;
154
+ }
155
+
156
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) {
157
+ continue;
158
+ }
159
+
160
+ if (sinceEpochSec) {
161
+ const stat = fs.statSync(fullPath);
162
+ const modifiedSec = Math.floor(stat.mtimeMs / 1000);
163
+ if (modifiedSec < sinceEpochSec) {
164
+ continue;
165
+ }
166
+ }
167
+
168
+ files.push(fullPath);
169
+ }
170
+ }
171
+
172
+ return files.sort((a, b) => a.localeCompare(b));
173
+ }
174
+
175
+ function parseCodexEvent(line) {
176
+ let parsed;
177
+ try {
178
+ parsed = JSON.parse(line);
179
+ } catch (_) {
180
+ return null;
181
+ }
182
+
183
+ if (!parsed || typeof parsed !== 'object') {
184
+ return null;
185
+ }
186
+
187
+ if (parsed.type !== 'event_msg') {
188
+ return null;
189
+ }
190
+
191
+ const payload = parsed.payload;
192
+ if (!payload || typeof payload !== 'object' || typeof payload.type !== 'string') {
193
+ return null;
194
+ }
195
+
196
+ return {
197
+ rawType: payload.type,
198
+ payload
199
+ };
200
+ }
201
+
202
+ function mapEventNames(rawType) {
203
+ const mapped = CODEX_EVENT_MAP[rawType] || [];
204
+ return [...mapped, rawType];
205
+ }
206
+
207
+ function buildMatcherText(eventRecord) {
208
+ const payload = eventRecord.payload || {};
209
+
210
+ const parts = [eventRecord.rawType];
211
+ if (Array.isArray(payload.command)) {
212
+ parts.push(payload.command.join(' '));
213
+ }
214
+
215
+ if (typeof payload.reason === 'string') {
216
+ parts.push(payload.reason);
217
+ }
218
+
219
+ if (typeof payload.call_id === 'string') {
220
+ parts.push(payload.call_id);
221
+ }
222
+
223
+ return parts.join(' ');
224
+ }
225
+
226
+ function matchesRule(matcher, text) {
227
+ if (!matcher) {
228
+ return true;
229
+ }
230
+
231
+ try {
232
+ return new RegExp(matcher).test(text);
233
+ } catch (_) {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ function safeStringValue(value) {
239
+ if (value == null) {
240
+ return '';
241
+ }
242
+
243
+ if (typeof value === 'string') {
244
+ return value;
245
+ }
246
+
247
+ if (typeof value === 'number' || typeof value === 'boolean') {
248
+ return String(value);
249
+ }
250
+
251
+ return JSON.stringify(value);
252
+ }
253
+
254
+ function runHookCommand({ command, timeoutSec, contextEnv, quiet }) {
255
+ const result = spawnSync('bash', ['-lc', command], {
256
+ env: {
257
+ ...process.env,
258
+ ...contextEnv
259
+ },
260
+ stdio: quiet ? 'ignore' : 'inherit',
261
+ timeout: Math.max(1, timeoutSec) * 1000
262
+ });
263
+
264
+ return result.status === 0;
265
+ }
266
+
267
+ function getAllHookSources(manifest) {
268
+ return [...manifest.plugins, ...manifest.topHooks];
269
+ }
270
+
271
+ function executeEvent(manifest, eventRecord, projectRoot, quiet) {
272
+ const names = mapEventNames(eventRecord.rawType);
273
+ const matcherText = buildMatcherText(eventRecord);
274
+ const sources = getAllHookSources(manifest);
275
+
276
+ for (const source of sources) {
277
+ const events = Array.isArray(source.events) ? source.events : [];
278
+
279
+ for (const eventDef of events) {
280
+ if (!eventDef || typeof eventDef.eventName !== 'string') {
281
+ continue;
282
+ }
283
+
284
+ if (!names.includes(eventDef.eventName)) {
285
+ continue;
286
+ }
287
+
288
+ if (!matchesRule(eventDef.matcher, matcherText)) {
289
+ continue;
290
+ }
291
+
292
+ const commands = Array.isArray(eventDef.commands) ? eventDef.commands : [];
293
+ for (const commandDef of commands) {
294
+ if (!commandDef || typeof commandDef.command !== 'string' || !commandDef.command.trim()) {
295
+ continue;
296
+ }
297
+
298
+ const timeout = Number.isFinite(commandDef.timeout) ? Number(commandDef.timeout) : 10;
299
+ runHookCommand({
300
+ command: commandDef.command,
301
+ timeoutSec: timeout,
302
+ quiet,
303
+ contextEnv: {
304
+ CLAUDE_PLUGIN_ROOT: source.rootPath || '',
305
+ CLAUDE_PROJECT_ROOT: projectRoot || '',
306
+ CRS_EVENT_TYPE: names[0],
307
+ CRS_EVENT_RAW_TYPE: eventRecord.rawType,
308
+ CRS_EVENT_MATCHER_TEXT: matcherText,
309
+ CRS_EVENT_REASON: safeStringValue(eventRecord.payload.reason),
310
+ CRS_CALL_ID: safeStringValue(eventRecord.payload.call_id)
311
+ }
312
+ });
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ function processSessionFile(filePath, state, manifest, projectRoot, quiet) {
319
+ let currentOffset = state.offsets.get(filePath) || 0;
320
+ let remainder = state.remainders.get(filePath) || '';
321
+
322
+ const stat = fs.statSync(filePath);
323
+ if (stat.size < currentOffset) {
324
+ currentOffset = 0;
325
+ remainder = '';
326
+ }
327
+
328
+ if (stat.size === currentOffset) {
329
+ return;
330
+ }
331
+
332
+ const fd = fs.openSync(filePath, 'r');
333
+ const chunkSize = stat.size - currentOffset;
334
+ const buffer = Buffer.alloc(chunkSize);
335
+ fs.readSync(fd, buffer, 0, chunkSize, currentOffset);
336
+ fs.closeSync(fd);
337
+
338
+ const text = `${remainder}${buffer.toString('utf8')}`;
339
+ const lines = text.split('\n');
340
+ remainder = lines.pop() || '';
341
+
342
+ for (const line of lines) {
343
+ if (!line.trim()) {
344
+ continue;
345
+ }
346
+
347
+ const eventRecord = parseCodexEvent(line);
348
+ if (!eventRecord) {
349
+ continue;
350
+ }
351
+
352
+ executeEvent(manifest, eventRecord, projectRoot, quiet);
353
+ }
354
+
355
+ state.offsets.set(filePath, stat.size);
356
+ state.remainders.set(filePath, remainder);
357
+ }
358
+
359
+ function runOnce(options) {
360
+ const codexHome = resolveCodexHome(options.codexHome);
361
+ const manifest = readManifest(codexHome);
362
+ const sessionsRoot = path.join(codexHome, 'sessions');
363
+ const files = collectSessionFiles(sessionsRoot, options.since);
364
+
365
+ const state = {
366
+ offsets: new Map(),
367
+ remainders: new Map()
368
+ };
369
+
370
+ for (const filePath of files) {
371
+ processSessionFile(filePath, state, manifest, options.projectRoot, options.quiet);
372
+ }
373
+
374
+ if (options.emitStop) {
375
+ executeEvent(manifest, { rawType: 'Stop', payload: {} }, options.projectRoot, options.quiet);
376
+ }
377
+ }
378
+
379
+ function sleep(ms) {
380
+ return new Promise(resolve => setTimeout(resolve, ms));
381
+ }
382
+
383
+ async function watch(options) {
384
+ const codexHome = resolveCodexHome(options.codexHome);
385
+ const manifest = readManifest(codexHome);
386
+ const sessionsRoot = path.join(codexHome, 'sessions');
387
+
388
+ const state = {
389
+ offsets: new Map(),
390
+ remainders: new Map()
391
+ };
392
+
393
+ let stopping = false;
394
+ const requestStop = () => {
395
+ stopping = true;
396
+ };
397
+
398
+ process.on('SIGINT', requestStop);
399
+ process.on('SIGTERM', requestStop);
400
+
401
+ while (!stopping) {
402
+ const files = collectSessionFiles(sessionsRoot, options.since);
403
+ for (const filePath of files) {
404
+ if (stopping) {
405
+ break;
406
+ }
407
+ processSessionFile(filePath, state, manifest, options.projectRoot, options.quiet);
408
+ }
409
+
410
+ await sleep(options.pollMs);
411
+ }
412
+ }
413
+
414
+ async function main() {
415
+ const options = parseArgs(process.argv.slice(2));
416
+ if (options.watch) {
417
+ await watch(options);
418
+ return;
419
+ }
420
+
421
+ runOnce(options);
422
+ }
423
+
424
+ main().catch(error => {
425
+ console.error(`codex-plugin-bridge 运行失败: ${error.message}`);
426
+ process.exit(1);
427
+ });
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const mode = process.argv[2] || 'install';
8
+ const zshrcArg = process.argv[3];
9
+
10
+ if (!['install', 'remove'].includes(mode)) {
11
+ console.error('Usage: node scripts/install-codex-zsh-hook.js [install|remove] [zshrc_path]');
12
+ process.exit(1);
13
+ }
14
+
15
+ function resolveCodexHome() {
16
+ if (process.env.CODEX_HOME && process.env.CODEX_HOME.trim()) {
17
+ return path.resolve(process.env.CODEX_HOME);
18
+ }
19
+
20
+ return path.join(os.homedir(), '.codex');
21
+ }
22
+
23
+ function expandPath(inputPath) {
24
+ if (!inputPath) {
25
+ return path.join(os.homedir(), '.zshrc');
26
+ }
27
+
28
+ if (inputPath === '~') {
29
+ return os.homedir();
30
+ }
31
+
32
+ if (inputPath.startsWith('~/')) {
33
+ return path.join(os.homedir(), inputPath.slice(2));
34
+ }
35
+
36
+ return path.resolve(inputPath);
37
+ }
38
+
39
+ function readText(filePath) {
40
+ if (!fs.existsSync(filePath)) {
41
+ return '';
42
+ }
43
+
44
+ return fs.readFileSync(filePath, 'utf8');
45
+ }
46
+
47
+ function writeText(filePath, text) {
48
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
49
+ fs.writeFileSync(filePath, text, 'utf8');
50
+ }
51
+
52
+ function findManagedBlock(text, startMarker, endMarker) {
53
+ const start = text.indexOf(startMarker);
54
+ if (start === -1) {
55
+ return null;
56
+ }
57
+
58
+ const endStart = text.indexOf(endMarker, start);
59
+ if (endStart === -1) {
60
+ return null;
61
+ }
62
+
63
+ let end = endStart + endMarker.length;
64
+ if (text[end] === '\n') {
65
+ end += 1;
66
+ }
67
+
68
+ return { start, end };
69
+ }
70
+
71
+ function copyScript(sourcePath, targetPath) {
72
+ if (!fs.existsSync(sourcePath)) {
73
+ throw new Error(`未找到脚本: ${sourcePath}`);
74
+ }
75
+
76
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
77
+ fs.copyFileSync(sourcePath, targetPath);
78
+ fs.chmodSync(targetPath, 0o755);
79
+ }
80
+
81
+ function installHelpers(codexHome) {
82
+ const syncSourcePath = path.join(__dirname, 'claude-runtime-sync.js');
83
+ const baseSyncSourcePath = path.join(__dirname, 'sync-claude-all-to-codex.js');
84
+ const bridgeSourcePath = path.join(__dirname, 'codex-plugin-bridge.js');
85
+
86
+ const syncTargetPath = path.join(codexHome, 'scripts', 'sync-claude-to-codex.js');
87
+ const baseSyncTargetPath = path.join(codexHome, 'scripts', 'sync-claude-all-to-codex.js');
88
+ const bridgeTargetPath = path.join(codexHome, 'scripts', 'codex-plugin-bridge.js');
89
+
90
+ copyScript(syncSourcePath, syncTargetPath);
91
+ copyScript(baseSyncSourcePath, baseSyncTargetPath);
92
+ copyScript(bridgeSourcePath, bridgeTargetPath);
93
+
94
+ return {
95
+ syncTargetPath,
96
+ baseSyncTargetPath,
97
+ bridgeTargetPath
98
+ };
99
+ }
100
+
101
+ const codexHome = resolveCodexHome();
102
+ const zshrcPath = expandPath(zshrcArg);
103
+ const startMarker = '# >>> codex-claude-sync >>>';
104
+ const endMarker = '# <<< codex-claude-sync <<<';
105
+
106
+ const block = [
107
+ '# >>> codex-claude-sync >>>',
108
+ '# Auto-sync ~/.claude (+ project .claude) into Codex before launching Codex.',
109
+ '# Set CODEX_SYNC_DISABLE=1 to temporarily skip sync.',
110
+ '# Set CODEX_PLUGIN_BRIDGE_DISABLE=1 to temporarily disable plugin hooks bridge.',
111
+ 'codex() {',
112
+ ' local first_arg="${1-}"',
113
+ ' local codex_home',
114
+ ' local sync_script',
115
+ ' local bridge_script',
116
+ ' local bridge_pid=""',
117
+ ' local bridge_since=""',
118
+ '',
119
+ ' case "$first_arg" in',
120
+ ' -h|--help|-V|--version|completion|login|logout|features)',
121
+ ' command codex "$@"',
122
+ ' return $?',
123
+ ' ;;',
124
+ ' esac',
125
+ '',
126
+ ' codex_home="${CODEX_HOME:-$HOME/.codex}"',
127
+ ' sync_script="$codex_home/scripts/sync-claude-to-codex.js"',
128
+ ' bridge_script="$codex_home/scripts/codex-plugin-bridge.js"',
129
+ '',
130
+ ' if [[ "${CODEX_SYNC_DISABLE:-0}" != "1" ]]; then',
131
+ ' if command -v node >/dev/null 2>&1 && [[ -f "$sync_script" ]]; then',
132
+ ' node "$sync_script" --check >/dev/null 2>&1 || node "$sync_script" >/dev/null 2>&1',
133
+ ' fi',
134
+ ' fi',
135
+ '',
136
+ ' if [[ "${CODEX_PLUGIN_BRIDGE_DISABLE:-0}" != "1" ]]; then',
137
+ ' if command -v node >/dev/null 2>&1 && [[ -f "$bridge_script" ]]; then',
138
+ ' bridge_since="$(date +%s)"',
139
+ ' node "$bridge_script" --watch --since="$bridge_since" --codex-home="$codex_home" --project-root="$PWD" >/dev/null 2>&1 &',
140
+ ' bridge_pid="$!"',
141
+ ' fi',
142
+ ' fi',
143
+ '',
144
+ ' command codex "$@"',
145
+ ' local codex_rc=$?',
146
+ '',
147
+ ' if [[ -n "$bridge_pid" ]]; then',
148
+ ' node "$bridge_script" --emit-stop --codex-home="$codex_home" --project-root="$PWD" >/dev/null 2>&1 || true',
149
+ ' kill "$bridge_pid" >/dev/null 2>&1 || true',
150
+ ' wait "$bridge_pid" >/dev/null 2>&1 || true',
151
+ ' fi',
152
+ '',
153
+ ' return $codex_rc',
154
+ '}',
155
+ '# <<< codex-claude-sync <<<'
156
+ ].join('\n');
157
+
158
+ const currentText = readText(zshrcPath);
159
+
160
+ if (mode === 'install') {
161
+ const helperPaths = installHelpers(codexHome);
162
+ const existing = findManagedBlock(currentText, startMarker, endMarker);
163
+ let newText;
164
+
165
+ if (existing) {
166
+ const before = currentText.slice(0, existing.start).replace(/\s*$/, '');
167
+ const after = currentText.slice(existing.end).replace(/^\s*/, '');
168
+ const parts = [before, block, after].filter(Boolean);
169
+ newText = `${parts.join('\n\n')}\n`;
170
+ } else if (currentText.trim()) {
171
+ newText = `${currentText.replace(/\s*$/, '')}\n\n${block}\n`;
172
+ } else {
173
+ newText = `${block}\n`;
174
+ }
175
+
176
+ writeText(zshrcPath, newText);
177
+ console.log(`Installed Codex sync helper to ${helperPaths.syncTargetPath}`);
178
+ console.log(`Installed Codex base sync helper to ${helperPaths.baseSyncTargetPath}`);
179
+ console.log(`Installed Codex plugin bridge helper to ${helperPaths.bridgeTargetPath}`);
180
+ console.log(`Installed Codex sync hook in ${zshrcPath}`);
181
+ process.exit(0);
182
+ }
183
+
184
+ const existing = findManagedBlock(currentText, startMarker, endMarker);
185
+ if (!existing) {
186
+ console.log(`No hook block found in ${zshrcPath}`);
187
+ process.exit(0);
188
+ }
189
+
190
+ const before = currentText.slice(0, existing.start).replace(/\s*$/, '');
191
+ const after = currentText.slice(existing.end).replace(/^\s*/, '');
192
+ const parts = [before, after].filter(Boolean);
193
+ const newText = parts.length > 0 ? `${parts.join('\n\n')}\n` : '';
194
+
195
+ writeText(zshrcPath, newText);
196
+ console.log(`Removed Codex sync hook from ${zshrcPath}`);