@mylesiyabor/claudex 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.
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # claudex
2
+
3
+ Ink TUI that lets you chat through either native `claude` or native `codex`, switch the active backend mid-thread, and import Claude JSONL sessions into Codex sessions.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ cd /Users/myles/Development/Repos/claudex
9
+ npm install
10
+ npm link
11
+ ```
12
+
13
+ ## Start
14
+
15
+ ```bash
16
+ claudex
17
+ claudex --cli claude
18
+ claudex resume --cli claude 22ef532e-636f-4666-bf17-ccd272f8877d
19
+ claudex resume --cli codex ca36d796-b8cb-46c9-b0d1-6de1d8480dba
20
+ ```
21
+
22
+ If you pass a Claude session ID to `claudex resume --cli codex <session>`, it will import that Claude transcript into a new Codex rollout file and then open the Codex side.
23
+
24
+ ## In-App Commands
25
+
26
+ ```text
27
+ /use claude
28
+ /use codex
29
+ /resume --cli claude <session-id>
30
+ /resume --cli codex <session-id-or-claude-jsonl-path>
31
+ /ids
32
+ /clear
33
+ /help
34
+ /exit
35
+ ```
36
+
37
+ ## One-Shot Import
38
+
39
+ ```bash
40
+ claudex import-claude 22ef532e-636f-4666-bf17-ccd272f8877d
41
+ ```
42
+
43
+ This prints the generated Codex session ID and writes a rollout file under `~/.codex/sessions/YYYY/MM/DD/`.
package/bin/claudex.js ADDED
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+
3
+ import React from 'react';
4
+ import { render } from 'ink';
5
+ import { ClauDexApp } from '../lib/ui.js';
6
+ import { hasCodexSession, importClaudeSessionToCodex } from '../lib/session-store.js';
7
+
8
+ const [, , command, ...args] = process.argv;
9
+
10
+ async function main() {
11
+ if (command === 'import-claude') {
12
+ const session = args[0] || valueAfter(args, '--session');
13
+ if (!session) {
14
+ console.error('Usage: claudex import-claude <claude-session-id-or-jsonl-path>');
15
+ process.exit(1);
16
+ }
17
+
18
+ const result = await importClaudeSessionToCodex({ session, cwd: process.cwd() });
19
+ console.log(result.sessionId);
20
+ console.error(`Codex session file: ${result.sessionPath}`);
21
+ console.error(`Thread: ${result.threadName}`);
22
+ console.error(`Imported ${result.userMessages} user messages and ${result.assistantMessages} assistant messages`);
23
+ process.exit(0);
24
+ }
25
+
26
+ const startup = await buildStartupState(command, args);
27
+ render(React.createElement(ClauDexApp, startup));
28
+ }
29
+
30
+ async function buildStartupState(commandName, values) {
31
+ if (commandName === 'resume') {
32
+ const agent = valueAfter(values, '--cli');
33
+ const session = firstPositional(values, new Set(['--cli']));
34
+
35
+ if (!agent || !session || !['claude', 'codex'].includes(agent)) {
36
+ console.error('Usage: claudex resume --cli <claude|codex> <session-id-or-claude-jsonl-path>');
37
+ process.exit(1);
38
+ }
39
+
40
+ if (agent === 'claude') {
41
+ return {
42
+ initialAgent: 'claude',
43
+ initialSessions: { claude: session, codex: null },
44
+ initialNotice: `Resuming Claude session ${session}`,
45
+ };
46
+ }
47
+
48
+ if (hasCodexSession(session)) {
49
+ return {
50
+ initialAgent: 'codex',
51
+ initialSessions: { claude: null, codex: session },
52
+ initialNotice: `Resuming Codex session ${session}`,
53
+ };
54
+ }
55
+
56
+ const imported = await importClaudeSessionToCodex({ session, cwd: process.cwd() });
57
+ return {
58
+ initialAgent: 'codex',
59
+ initialSessions: { claude: session, codex: imported.sessionId },
60
+ initialNotice: `Imported Claude ${session} -> Codex ${imported.sessionId}`,
61
+ };
62
+ }
63
+
64
+ const agent = valueAfter([commandName, ...values].filter(Boolean), '--cli') || 'codex';
65
+ return {
66
+ initialAgent: ['claude', 'codex'].includes(agent) ? agent : 'codex',
67
+ initialSessions: { claude: null, codex: null },
68
+ initialNotice: 'Type /help for commands, /use claude or /use codex to swap backends.',
69
+ };
70
+ }
71
+
72
+ function valueAfter(values, flag) {
73
+ const index = values.indexOf(flag);
74
+ return index >= 0 ? values[index + 1] : undefined;
75
+ }
76
+
77
+ function firstPositional(values, flagsWithValues = new Set()) {
78
+ for (let i = 0; i < values.length; i += 1) {
79
+ const value = values[i];
80
+ if (flagsWithValues.has(value)) {
81
+ i += 1;
82
+ continue;
83
+ }
84
+ if (!value.startsWith('-')) return value;
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ main().catch((error) => {
90
+ console.error(error?.stack || error?.message || String(error));
91
+ process.exit(1);
92
+ });
@@ -0,0 +1,261 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ export function startClaudePrompt({ prompt, sessionId, cwd, onActivity }) {
5
+ const nextSessionId = sessionId || randomUUID();
6
+ const args = sessionId
7
+ ? ['-p', '--output-format', 'json', '--resume', sessionId, prompt]
8
+ : ['-p', '--output-format', 'json', '--session-id', nextSessionId, prompt];
9
+
10
+ return {
11
+ ...runProcess('claude', args, cwd, {
12
+ onStdoutLine: () => onActivity?.('claude response received'),
13
+ onStart: () => onActivity?.('claude thinking'),
14
+ onSpawn: () => onActivity?.('claude process started'),
15
+ parseResult: ({ stdout, stderr, code, interrupted }) => {
16
+ const parsed = parseJsonMaybe(stdout);
17
+ if (interrupted) {
18
+ return {
19
+ sessionId: parsed?.session_id || parsed?.sessionId || nextSessionId,
20
+ text: 'Interrupted.',
21
+ interrupted: true,
22
+ };
23
+ }
24
+ if (parsed?.is_error) {
25
+ throw new Error(parsed.result || parsed.error || 'Claude returned an error');
26
+ }
27
+ if (code !== 0) {
28
+ throw new Error((stderr || renderClaudeResponse(parsed, stdout) || `claude exited with ${code}`).trim());
29
+ }
30
+ return {
31
+ sessionId: parsed?.session_id || parsed?.sessionId || nextSessionId,
32
+ text: renderClaudeResponse(parsed, stdout),
33
+ interrupted: false,
34
+ };
35
+ },
36
+ }),
37
+ };
38
+ }
39
+
40
+ export function startCodexPrompt({ prompt, sessionId, cwd, onActivity }) {
41
+ const args = [
42
+ 'exec',
43
+ '-C',
44
+ cwd,
45
+ '--json',
46
+ '--dangerously-bypass-approvals-and-sandbox',
47
+ ];
48
+ if (!isGitRepo(cwd)) {
49
+ args.push('--skip-git-repo-check');
50
+ }
51
+ if (sessionId) {
52
+ args.push('resume', sessionId, prompt);
53
+ } else {
54
+ args.push(prompt);
55
+ }
56
+
57
+ return {
58
+ ...runProcess('codex', args, cwd, {
59
+ onStart: () => onActivity?.('codex thinking'),
60
+ onSpawn: () => onActivity?.('codex process started'),
61
+ onStdoutLine: (line) => emitCodexActivity(line, onActivity),
62
+ parseResult: ({ stdout, stderr, code, interrupted }) => {
63
+ const parsed = parseCodexJsonl(stdout);
64
+ if (interrupted) {
65
+ return {
66
+ sessionId: parsed.sessionId || sessionId,
67
+ text: `${parsed.text}\n\nInterrupted.`.trim(),
68
+ interrupted: true,
69
+ };
70
+ }
71
+ if (code !== 0) {
72
+ throw new Error((stderr || parsed.text || stdout || `codex exited with ${code}`).trim());
73
+ }
74
+ return {
75
+ sessionId: parsed.sessionId || sessionId,
76
+ text: parsed.text || '(no output)',
77
+ interrupted: false,
78
+ };
79
+ },
80
+ }),
81
+ };
82
+ }
83
+
84
+ export async function sendClaudePrompt(options) {
85
+ const run = startClaudePrompt(options);
86
+ return run.promise;
87
+ }
88
+
89
+ export async function sendCodexPrompt(options) {
90
+ const run = startCodexPrompt(options);
91
+ return run.promise;
92
+ }
93
+
94
+ function isGitRepo(cwd) {
95
+ const result = spawnSync('git', ['-C', cwd, 'rev-parse', '--is-inside-work-tree'], {
96
+ env: process.env,
97
+ stdio: 'ignore',
98
+ });
99
+ return result.status === 0;
100
+ }
101
+
102
+ function runProcess(command, args, cwd, handlers = {}) {
103
+ let child = null;
104
+ let interrupted = false;
105
+ let stdoutBuffer = '';
106
+
107
+ handlers.onStart?.();
108
+
109
+ const promise = new Promise((resolve, reject) => {
110
+ const child = spawn(command, args, {
111
+ cwd,
112
+ env: process.env,
113
+ stdio: ['ignore', 'pipe', 'pipe'],
114
+ });
115
+
116
+ let stdout = '';
117
+ let stderr = '';
118
+ handlers.onSpawn?.(child.pid);
119
+
120
+ child.stdout.on('data', (chunk) => {
121
+ const text = chunk.toString('utf8');
122
+ stdout += text;
123
+ stdoutBuffer += text;
124
+ const lines = stdoutBuffer.split('\n');
125
+ stdoutBuffer = lines.pop() || '';
126
+ for (const line of lines) {
127
+ if (line.trim()) handlers.onStdoutLine?.(line);
128
+ }
129
+ });
130
+ child.stderr.on('data', (chunk) => {
131
+ stderr += chunk.toString('utf8');
132
+ });
133
+ child.on('close', (code) => {
134
+ try {
135
+ resolve(handlers.parseResult
136
+ ? handlers.parseResult({ code, stdout, stderr, interrupted })
137
+ : { code, stdout, stderr, interrupted });
138
+ } catch (error) {
139
+ reject(error);
140
+ }
141
+ });
142
+ });
143
+
144
+ return {
145
+ promise,
146
+ interrupt: () => {
147
+ interrupted = true;
148
+ if (child && !child.killed) child.kill('SIGINT');
149
+ },
150
+ };
151
+ }
152
+
153
+ function parseJsonMaybe(text) {
154
+ try {
155
+ return JSON.parse(text);
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ function renderClaudeResponse(parsed, fallbackText) {
162
+ if (!parsed) return (fallbackText || '').trim() || '(no output)';
163
+ if (typeof parsed.result === 'string') return parsed.result;
164
+ if (typeof parsed.output === 'string') return parsed.output;
165
+ if (typeof parsed.content === 'string') return parsed.content;
166
+ return JSON.stringify(parsed, null, 2);
167
+ }
168
+
169
+ function parseCodexJsonl(output) {
170
+ let sessionId = null;
171
+ const assistantChunks = [];
172
+ const toolChunks = [];
173
+
174
+ for (const line of output.split('\n')) {
175
+ const raw = line.trim();
176
+ if (!raw) continue;
177
+
178
+ let record;
179
+ try {
180
+ record = JSON.parse(raw);
181
+ } catch {
182
+ continue;
183
+ }
184
+
185
+ if (record.type === 'thread.started' && record.thread_id) {
186
+ sessionId = record.thread_id;
187
+ continue;
188
+ }
189
+
190
+ if (record.type === 'session_meta' && record.payload?.id) {
191
+ sessionId = record.payload.id;
192
+ continue;
193
+ }
194
+
195
+ if (record.type === 'item.completed' && record.item) {
196
+ const item = record.item;
197
+ if (item.type === 'agent_message' && typeof item.text === 'string' && item.text.trim()) {
198
+ assistantChunks.push(item.text.trim());
199
+ } else if (item.type === 'tool_call') {
200
+ toolChunks.push(formatCodexToolCall(item));
201
+ } else if (item.type === 'tool_call_output' && typeof item.output === 'string') {
202
+ toolChunks.push(`tool output\n${item.output.trim()}`);
203
+ }
204
+ continue;
205
+ }
206
+
207
+ if (record.type === 'response_item' && record.payload?.type === 'message' && record.payload.role === 'assistant') {
208
+ for (const item of record.payload.content || []) {
209
+ if (typeof item?.text === 'string' && item.text.trim()) {
210
+ assistantChunks.push(item.text.trim());
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ return {
217
+ sessionId,
218
+ text: [...toolChunks, ...assistantChunks].join('\n\n').trim(),
219
+ };
220
+ }
221
+
222
+ function formatCodexToolCall(item) {
223
+ const name = item.name || item.tool_name || 'tool';
224
+ const args = item.arguments || item.args || item.input;
225
+ if (!args) return `tool ${name}`;
226
+ const renderedArgs = typeof args === 'string' ? args : JSON.stringify(args);
227
+ return `tool ${name}\n${renderedArgs}`;
228
+ }
229
+
230
+ function emitCodexActivity(line, onActivity) {
231
+ if (!onActivity) return;
232
+ let record;
233
+ try {
234
+ record = JSON.parse(line);
235
+ } catch {
236
+ return;
237
+ }
238
+
239
+ if (record.type === 'turn.started') {
240
+ onActivity('codex turn started');
241
+ return;
242
+ }
243
+
244
+ if (record.type === 'item.started' && record.item?.type) {
245
+ if (record.item.type === 'tool_call') {
246
+ onActivity(`tool ${record.item.name || record.item.tool_name || 'running'}`);
247
+ } else if (record.item.type === 'agent_message') {
248
+ onActivity('codex composing');
249
+ }
250
+ return;
251
+ }
252
+
253
+ if (record.type === 'item.completed' && record.item?.type === 'tool_call') {
254
+ onActivity(`tool ${record.item.name || record.item.tool_name || 'done'}`);
255
+ return;
256
+ }
257
+
258
+ if (record.type === 'turn.completed') {
259
+ onActivity('codex turn complete');
260
+ }
261
+ }
@@ -0,0 +1,358 @@
1
+ import { appendFileSync, createReadStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
5
+ import readline from 'node:readline';
6
+
7
+ const CODEX_HOME = join(homedir(), '.codex');
8
+ const CLAUDE_PROJECTS = join(homedir(), '.claude', 'projects');
9
+ const MAX_TEXT_CHARS = 20000;
10
+ const SECRET_PATTERNS = [
11
+ /\bgithub_pat_[A-Za-z0-9_]+\b/g,
12
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g,
13
+ /\bsk-or-v1-[A-Za-z0-9_-]{20,}\b/g,
14
+ /\bsk-[A-Za-z0-9_-]{20,}\b/g,
15
+ /\bgsk_[A-Za-z0-9]{20,}\b/g,
16
+ /\b[A-Fa-f0-9]{48,}\b/g,
17
+ ];
18
+
19
+ export function hasCodexSession(sessionId) {
20
+ const indexPath = join(CODEX_HOME, 'session_index.jsonl');
21
+ if (existsSync(indexPath)) {
22
+ const lines = readFileSync(indexPath, 'utf8').split('\n');
23
+ if (lines.some((line) => line.includes(`"id":"${sessionId}"`))) return true;
24
+ }
25
+
26
+ return findCodexSessionPath(sessionId, join(CODEX_HOME, 'sessions')) != null;
27
+ }
28
+
29
+ export function listResumeSessions(limit = 200) {
30
+ return [...listCodexSessions(limit), ...listClaudeSessions(limit)]
31
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
32
+ .slice(0, limit);
33
+ }
34
+
35
+ export async function importClaudeSessionToCodex({ session, cwd = process.cwd() }) {
36
+ if (!session) throw new Error('Missing Claude session ID or JSONL path');
37
+
38
+ const sourcePath = resolveClaudeSessionPath(session);
39
+ const sessionId = randomUUID();
40
+ const records = [];
41
+ let firstTimestamp = null;
42
+ let lastTimestamp = null;
43
+ let importedCwd = null;
44
+ let threadName = `Imported Claude session ${basename(sourcePath, '.jsonl')}`;
45
+ let userMessages = 0;
46
+ let assistantMessages = 0;
47
+
48
+ const rl = readline.createInterface({
49
+ input: createReadStream(sourcePath, { encoding: 'utf8' }),
50
+ crlfDelay: Infinity,
51
+ });
52
+
53
+ for await (const line of rl) {
54
+ const raw = line.trim();
55
+ if (!raw) continue;
56
+
57
+ let entry;
58
+ try {
59
+ entry = JSON.parse(raw);
60
+ } catch {
61
+ continue;
62
+ }
63
+
64
+ importedCwd ??= entry.cwd || null;
65
+ firstTimestamp ??= entry.timestamp || null;
66
+ lastTimestamp = entry.timestamp || lastTimestamp;
67
+
68
+ if (entry.type === 'user') {
69
+ const text = renderClaudeUser(entry.message?.content);
70
+ if (!text) continue;
71
+ if (threadName.startsWith('Imported Claude session ')) {
72
+ threadName = text.replace(/\s+/g, ' ').slice(0, 80) || threadName;
73
+ }
74
+ records.push(makeMessageRecord(entry.timestamp, 'user', 'input_text', text));
75
+ userMessages += 1;
76
+ }
77
+
78
+ if (entry.type === 'assistant') {
79
+ const text = renderClaudeAssistant(entry);
80
+ if (!text) continue;
81
+ records.push(makeMessageRecord(entry.timestamp, 'assistant', 'output_text', text));
82
+ assistantMessages += 1;
83
+ }
84
+ }
85
+
86
+ const createdAt = firstTimestamp || new Date().toISOString();
87
+ const sessionPath = writeCodexSession({
88
+ sessionId,
89
+ createdAt,
90
+ cwd: importedCwd || cwd,
91
+ records,
92
+ });
93
+
94
+ appendSessionIndex({
95
+ sessionId,
96
+ threadName,
97
+ updatedAt: lastTimestamp || createdAt,
98
+ });
99
+
100
+ return {
101
+ sessionId,
102
+ sessionPath,
103
+ threadName,
104
+ sourcePath,
105
+ userMessages,
106
+ assistantMessages,
107
+ };
108
+ }
109
+
110
+ function resolveClaudeSessionPath(sessionRef) {
111
+ if (sessionRef.endsWith('.jsonl')) {
112
+ const explicitPath = isAbsolute(sessionRef) ? sessionRef : resolve(process.cwd(), sessionRef);
113
+ if (!existsSync(explicitPath)) throw new Error(`Claude session file not found: ${explicitPath}`);
114
+ return explicitPath;
115
+ }
116
+
117
+ const target = `${sessionRef}.jsonl`;
118
+ const stack = [CLAUDE_PROJECTS];
119
+ while (stack.length > 0) {
120
+ const dir = stack.pop();
121
+ let entries;
122
+ try {
123
+ entries = readdirSync(dir, { withFileTypes: true });
124
+ } catch {
125
+ continue;
126
+ }
127
+
128
+ for (const entry of entries) {
129
+ const fullPath = join(dir, entry.name);
130
+ if (entry.isFile() && entry.name === target) return fullPath;
131
+ if (entry.isDirectory()) stack.push(fullPath);
132
+ }
133
+ }
134
+
135
+ throw new Error(`Claude session ${sessionRef} not found under ${CLAUDE_PROJECTS}`);
136
+ }
137
+
138
+ function listCodexSessions(limit) {
139
+ const indexPath = join(CODEX_HOME, 'session_index.jsonl');
140
+ if (!existsSync(indexPath)) return [];
141
+
142
+ return readFileSync(indexPath, 'utf8')
143
+ .split('\n')
144
+ .filter(Boolean)
145
+ .slice(-limit)
146
+ .map((line) => {
147
+ try {
148
+ const entry = JSON.parse(line);
149
+ return {
150
+ cli: 'codex',
151
+ sessionId: entry.id,
152
+ title: entry.thread_name || '(untitled)',
153
+ updatedAt: entry.updated_at || '1970-01-01T00:00:00.000Z',
154
+ };
155
+ } catch {
156
+ return null;
157
+ }
158
+ })
159
+ .filter(Boolean);
160
+ }
161
+
162
+ function listClaudeSessions(limit) {
163
+ const stack = [CLAUDE_PROJECTS];
164
+ const sessions = [];
165
+
166
+ while (stack.length > 0) {
167
+ const dir = stack.pop();
168
+ let entries;
169
+ try {
170
+ entries = readdirSync(dir, { withFileTypes: true });
171
+ } catch {
172
+ continue;
173
+ }
174
+
175
+ for (const entry of entries) {
176
+ const fullPath = join(dir, entry.name);
177
+ if (entry.isDirectory()) {
178
+ stack.push(fullPath);
179
+ continue;
180
+ }
181
+ if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
182
+ const sessionId = entry.name.replace(/\.jsonl$/, '');
183
+ const { title, updatedAt } = readClaudeSessionSummary(fullPath);
184
+ sessions.push({
185
+ cli: 'claude',
186
+ sessionId,
187
+ title,
188
+ updatedAt: updatedAt || statSync(fullPath).mtime.toISOString(),
189
+ path: fullPath,
190
+ });
191
+ }
192
+ }
193
+
194
+ return sessions
195
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
196
+ .slice(0, limit);
197
+ }
198
+
199
+ function readClaudeSessionSummary(path) {
200
+ let title = '(untitled)';
201
+ let updatedAt = null;
202
+
203
+ try {
204
+ const lines = readFileSync(path, 'utf8').split('\n');
205
+ for (const line of lines) {
206
+ if (!line.trim()) continue;
207
+ const entry = JSON.parse(line);
208
+ updatedAt = entry.timestamp || updatedAt;
209
+ if (title === '(untitled)' && entry.type === 'user') {
210
+ const text = renderClaudeUser(entry.message?.content).replace(/\s+/g, ' ').trim();
211
+ if (text) title = text.slice(0, 100);
212
+ }
213
+ }
214
+ } catch {
215
+ return { title, updatedAt };
216
+ }
217
+
218
+ return { title, updatedAt };
219
+ }
220
+
221
+ function findCodexSessionPath(sessionId, root) {
222
+ const stack = [root];
223
+ while (stack.length > 0) {
224
+ const dir = stack.pop();
225
+ let entries;
226
+ try {
227
+ entries = readdirSync(dir, { withFileTypes: true });
228
+ } catch {
229
+ continue;
230
+ }
231
+
232
+ for (const entry of entries) {
233
+ const fullPath = join(dir, entry.name);
234
+ if (entry.isFile() && entry.name.endsWith(`${sessionId}.jsonl`)) return fullPath;
235
+ if (entry.isDirectory()) stack.push(fullPath);
236
+ }
237
+ }
238
+ return null;
239
+ }
240
+
241
+ function writeCodexSession({ sessionId, createdAt, cwd, records }) {
242
+ const date = new Date(createdAt);
243
+ const stamp = createdAt.replace(/:/g, '-').replace(/\.\d{3}Z$/, '');
244
+ const sessionDir = join(
245
+ CODEX_HOME,
246
+ 'sessions',
247
+ String(date.getUTCFullYear()),
248
+ String(date.getUTCMonth() + 1).padStart(2, '0'),
249
+ String(date.getUTCDate()).padStart(2, '0'),
250
+ );
251
+
252
+ mkdirSync(sessionDir, { recursive: true });
253
+ const sessionPath = join(sessionDir, `rollout-${stamp}-${sessionId}.jsonl`);
254
+ const lines = [
255
+ JSON.stringify({
256
+ timestamp: createdAt,
257
+ type: 'session_meta',
258
+ payload: {
259
+ id: sessionId,
260
+ timestamp: createdAt,
261
+ cwd,
262
+ originator: 'claudex',
263
+ cli_version: 'imported',
264
+ source: 'cli',
265
+ },
266
+ }),
267
+ JSON.stringify({
268
+ timestamp: createdAt,
269
+ type: 'event_msg',
270
+ payload: {
271
+ type: 'task_started',
272
+ turn_id: randomUUID(),
273
+ collaboration_mode_kind: 'default',
274
+ },
275
+ }),
276
+ ...records.map((record) => JSON.stringify(record)),
277
+ ];
278
+ writeFileSync(sessionPath, `${lines.join('\n')}\n`, 'utf8');
279
+ return sessionPath;
280
+ }
281
+
282
+ function appendSessionIndex({ sessionId, threadName, updatedAt }) {
283
+ const indexPath = join(CODEX_HOME, 'session_index.jsonl');
284
+ mkdirSync(dirname(indexPath), { recursive: true });
285
+ appendFileSync(
286
+ indexPath,
287
+ `${JSON.stringify({ id: sessionId, thread_name: threadName, updated_at: updatedAt })}\n`,
288
+ 'utf8',
289
+ );
290
+ }
291
+
292
+ function makeMessageRecord(timestamp, role, type, text) {
293
+ return {
294
+ timestamp: timestamp || new Date().toISOString(),
295
+ type: 'response_item',
296
+ payload: {
297
+ type: 'message',
298
+ role,
299
+ content: [{ type, text: truncate(redact(text)) }],
300
+ },
301
+ };
302
+ }
303
+
304
+ function renderClaudeUser(content) {
305
+ if (typeof content === 'string') return content;
306
+ if (!Array.isArray(content)) return '';
307
+ return content
308
+ .map((item) => {
309
+ if (item?.type === 'text') return item.text || '';
310
+ if (item?.type === 'tool_result') return `[Claude tool result ${item.tool_use_id || 'unknown'}]\n${renderToolResult(item.content)}`;
311
+ return '';
312
+ })
313
+ .filter(Boolean)
314
+ .join('\n\n');
315
+ }
316
+
317
+ function renderClaudeAssistant(entry) {
318
+ if (entry.error && entry.isApiErrorMessage) {
319
+ return `[Claude API error: ${entry.error}]\n${renderToolResult(entry.message?.content)}`.trim();
320
+ }
321
+ if (!Array.isArray(entry.message?.content)) return '';
322
+ return entry.message.content
323
+ .map((item) => {
324
+ if (item?.type === 'text') return item.text || '';
325
+ if (item?.type === 'tool_use') {
326
+ return `[Claude tool use: ${item.name || 'tool'} ${item.id || ''}]\n${JSON.stringify(item.input || {}, null, 2)}`;
327
+ }
328
+ return '';
329
+ })
330
+ .filter(Boolean)
331
+ .join('\n\n');
332
+ }
333
+
334
+ function renderToolResult(content) {
335
+ if (typeof content === 'string') return content;
336
+ if (Array.isArray(content)) {
337
+ return content
338
+ .map((item) => (typeof item === 'string' ? item : item?.text || JSON.stringify(item)))
339
+ .filter(Boolean)
340
+ .join('\n');
341
+ }
342
+ if (content == null) return '';
343
+ return JSON.stringify(content, null, 2);
344
+ }
345
+
346
+ function redact(text) {
347
+ let value = String(text || '');
348
+ for (const pattern of SECRET_PATTERNS) {
349
+ value = value.replace(pattern, '[REDACTED_SECRET]');
350
+ }
351
+ return value;
352
+ }
353
+
354
+ function truncate(text) {
355
+ const value = String(text || '');
356
+ if (value.length <= MAX_TEXT_CHARS) return value;
357
+ return `${value.slice(0, MAX_TEXT_CHARS)}\n\n[truncated ${value.length - MAX_TEXT_CHARS} chars during Claude -> Codex import]`;
358
+ }
package/lib/ui.js ADDED
@@ -0,0 +1,382 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { Box, Text, useApp, useInput, useStdout } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { MessageComponent } from 'terminal-chat-ui';
5
+ import { sendClaudePrompt, sendCodexPrompt } from './native-agents.js';
6
+ import { hasCodexSession, importClaudeSessionToCodex, listResumeSessions } from './session-store.js';
7
+
8
+ const HELP_LINES = [
9
+ '/use claude | /use codex',
10
+ '/resume --cli <claude|codex> <session-id-or-claude-jsonl-path>',
11
+ '/ids',
12
+ '/menu',
13
+ '/clear',
14
+ '/help',
15
+ '/exit',
16
+ ];
17
+
18
+ export function ClauDexApp({ initialAgent = 'codex', initialSessions, initialNotice }) {
19
+ const { exit } = useApp();
20
+ const { stdout } = useStdout();
21
+ const [agent, setAgent] = useState(initialAgent);
22
+ const [sessions, setSessions] = useState(initialSessions || { claude: null, codex: null });
23
+ const [messages, setMessages] = useState(() => [
24
+ messageOf('system', initialNotice || 'Type /help for commands.'),
25
+ ]);
26
+ const [busy, setBusy] = useState(false);
27
+ const [menu, setMenu] = useState({ open: false, selected: 0, items: [] });
28
+
29
+ const transcriptHeight = Math.max(8, (stdout?.rows || 24) - 7);
30
+ const accent = agent === 'claude' ? 'magenta' : 'cyan';
31
+ const status = useMemo(() => {
32
+ const claudeId = sessions.claude ? sessions.claude.slice(0, 8) : 'none';
33
+ const codexId = sessions.codex ? sessions.codex.slice(0, 8) : 'none';
34
+ return `active=${agent} claude=${claudeId} codex=${codexId}`;
35
+ }, [agent, sessions]);
36
+
37
+ async function submit(value) {
38
+ const text = value.trim();
39
+ if (!text || busy) return;
40
+
41
+ setMessages((current) => [...current, messageOf('user', `[to ${agent}] ${text}`)]);
42
+
43
+ if (text.startsWith('/')) {
44
+ await runCommand(text, {
45
+ sessions,
46
+ setAgent,
47
+ setSessions,
48
+ setMessages,
49
+ setMenu,
50
+ exit,
51
+ });
52
+ return;
53
+ }
54
+
55
+ setBusy(true);
56
+ try {
57
+ if (agent === 'claude') {
58
+ const result = await sendClaudePrompt({
59
+ prompt: text,
60
+ sessionId: sessions.claude,
61
+ cwd: process.cwd(),
62
+ });
63
+ setSessions((current) => ({ ...current, claude: result.sessionId }));
64
+ setMessages((current) => [...current, messageOf('assistant', `[claude]\n${result.text}`)]);
65
+ } else {
66
+ const result = await sendCodexPrompt({
67
+ prompt: text,
68
+ sessionId: sessions.codex,
69
+ cwd: process.cwd(),
70
+ });
71
+ setSessions((current) => ({ ...current, codex: result.sessionId }));
72
+ setMessages((current) => [...current, messageOf('assistant', `[codex]\n${result.text}`)]);
73
+ }
74
+ } catch (error) {
75
+ setMessages((current) => [...current, messageOf('error', error?.message || String(error))]);
76
+ } finally {
77
+ setBusy(false);
78
+ }
79
+ }
80
+
81
+ return React.createElement(
82
+ Box,
83
+ { flexDirection: 'column', minHeight: stdout?.rows || 24 },
84
+ React.createElement(
85
+ Box,
86
+ {
87
+ borderStyle: 'round',
88
+ borderColor: accent,
89
+ paddingX: 1,
90
+ justifyContent: 'space-between',
91
+ },
92
+ React.createElement(Text, { color: accent, bold: true }, 'ClauDex'),
93
+ React.createElement(Text, { dimColor: true }, status),
94
+ React.createElement(Text, { color: busy ? 'yellow' : 'green' }, busy ? 'running' : 'ready'),
95
+ ),
96
+ React.createElement(
97
+ Box,
98
+ {
99
+ flexDirection: 'column',
100
+ borderStyle: 'single',
101
+ borderColor: 'gray',
102
+ paddingX: 1,
103
+ height: transcriptHeight,
104
+ overflow: 'hidden',
105
+ },
106
+ messages.slice(-120).map((message, index) =>
107
+ React.createElement(MessageComponent, {
108
+ key: `${message.timestamp.getTime()}-${index}`,
109
+ message,
110
+ variant: 'chat',
111
+ toolDisplayMode: 'minimal',
112
+ showTimestamp: false,
113
+ prefixOverrides: {
114
+ user: 'you ',
115
+ assistant: 'agent ',
116
+ system: 'system ',
117
+ error: 'error ',
118
+ },
119
+ }),
120
+ ),
121
+ busy
122
+ ? React.createElement(
123
+ Box,
124
+ { marginBottom: 1 },
125
+ React.createElement(Text, { color: accent }, 'agent '),
126
+ React.createElement(Text, { color: accent },
127
+ React.createElement(Spinner, { type: 'dots' }),
128
+ ' thinking...',
129
+ ),
130
+ )
131
+ : null,
132
+ menu.open
133
+ ? React.createElement(SessionMenu, {
134
+ menu,
135
+ setMenu,
136
+ setAgent,
137
+ setSessions,
138
+ setMessages,
139
+ })
140
+ : null,
141
+ ),
142
+ React.createElement(
143
+ Box,
144
+ {
145
+ borderStyle: 'round',
146
+ borderColor: accent,
147
+ paddingX: 1,
148
+ },
149
+ React.createElement(Text, { color: accent, bold: true }, `${agent} › `),
150
+ React.createElement(Composer, {
151
+ placeholder: busy ? 'waiting for response...' : 'message or /help',
152
+ onSubmit: submit,
153
+ disabled: busy || menu.open,
154
+ }),
155
+ ),
156
+ );
157
+ }
158
+
159
+ async function runCommand(text, ctx) {
160
+ const [command, ...args] = text.split(/\s+/);
161
+
162
+ if (command === '/exit' || command === '/quit') {
163
+ ctx.exit();
164
+ return;
165
+ }
166
+
167
+ if (command === '/help') {
168
+ ctx.setMessages((current) => [...current, messageOf('system', `Commands:\n${HELP_LINES.join('\n')}`)]);
169
+ return;
170
+ }
171
+
172
+ if (command === '/clear') {
173
+ ctx.setMessages([messageOf('system', 'Cleared transcript.')]);
174
+ return;
175
+ }
176
+
177
+ if (command === '/ids') {
178
+ ctx.setMessages((current) => [
179
+ ...current,
180
+ messageOf('system', `claude=${ctx.sessions.claude || 'none'}\ncodex=${ctx.sessions.codex || 'none'}`),
181
+ ]);
182
+ return;
183
+ }
184
+
185
+ if (command === '/menu') {
186
+ ctx.setMenu({
187
+ open: true,
188
+ selected: 0,
189
+ items: listResumeSessions(200),
190
+ });
191
+ ctx.setMessages((current) => [...current, messageOf('system', 'Opened resume menu. ↑/↓ navigate, Enter resume, Esc close.')]);
192
+ return;
193
+ }
194
+
195
+ if (command === '/use') {
196
+ const next = args[0];
197
+ if (!['claude', 'codex'].includes(next)) {
198
+ ctx.setMessages((current) => [...current, messageOf('system', 'Usage: /use claude or /use codex')]);
199
+ return;
200
+ }
201
+
202
+ if (next === 'codex' && !ctx.sessions.codex && ctx.sessions.claude) {
203
+ ctx.setMessages((current) => [
204
+ ...current,
205
+ messageOf('system', `Importing Claude session ${ctx.sessions.claude} into Codex...`),
206
+ ]);
207
+ const imported = await importClaudeSessionToCodex({
208
+ session: ctx.sessions.claude,
209
+ cwd: process.cwd(),
210
+ });
211
+ ctx.setSessions((current) => ({ ...current, codex: imported.sessionId }));
212
+ ctx.setMessages((current) => [
213
+ ...current,
214
+ messageOf('system', `Imported Claude ${ctx.sessions.claude} -> Codex ${imported.sessionId}`),
215
+ ]);
216
+ }
217
+
218
+ ctx.setAgent(next);
219
+ ctx.setMessages((current) => [...current, messageOf('system', `Switched active backend to ${next}`)]);
220
+ return;
221
+ }
222
+
223
+ if (command === '/resume') {
224
+ await resumeBackend(args, ctx);
225
+ return;
226
+ }
227
+
228
+ ctx.setMessages((current) => [...current, messageOf('system', `Unknown command: ${command}. Type /help.`)]);
229
+ }
230
+
231
+ async function resumeBackend(args, ctx) {
232
+ const cliIndex = args.indexOf('--cli');
233
+ const target = cliIndex >= 0 ? args[cliIndex + 1] : null;
234
+ const session = args.find((value, index) => index !== cliIndex && index !== cliIndex + 1 && !value.startsWith('-'));
235
+
236
+ if (!['claude', 'codex'].includes(target) || !session) {
237
+ ctx.setMessages((current) => [
238
+ ...current,
239
+ messageOf('system', 'Usage: /resume --cli <claude|codex> <session-id-or-claude-jsonl-path>'),
240
+ ]);
241
+ return;
242
+ }
243
+
244
+ if (target === 'claude') {
245
+ ctx.setSessions((current) => ({ ...current, claude: session }));
246
+ ctx.setAgent('claude');
247
+ ctx.setMessages((current) => [...current, messageOf('system', `Resuming Claude session ${session}`)]);
248
+ return;
249
+ }
250
+
251
+ if (hasCodexSession(session)) {
252
+ ctx.setSessions((current) => ({ ...current, codex: session }));
253
+ ctx.setAgent('codex');
254
+ ctx.setMessages((current) => [...current, messageOf('system', `Resuming Codex session ${session}`)]);
255
+ return;
256
+ }
257
+
258
+ ctx.setMessages((current) => [
259
+ ...current,
260
+ messageOf('system', `Importing Claude session ${session} into Codex...`),
261
+ ]);
262
+ const imported = await importClaudeSessionToCodex({ session, cwd: process.cwd() });
263
+ ctx.setSessions((current) => ({
264
+ ...current,
265
+ claude: current.claude || session,
266
+ codex: imported.sessionId,
267
+ }));
268
+ ctx.setAgent('codex');
269
+ ctx.setMessages((current) => [
270
+ ...current,
271
+ messageOf('system', `Imported Claude ${session} -> Codex ${imported.sessionId}`),
272
+ ]);
273
+ }
274
+
275
+ function messageOf(role, content) {
276
+ return {
277
+ role,
278
+ content,
279
+ timestamp: new Date(),
280
+ status: 'complete',
281
+ };
282
+ }
283
+
284
+ function Composer({ placeholder, onSubmit, disabled }) {
285
+ const [value, setValue] = useState('');
286
+
287
+ useInput((input, key) => {
288
+ if (disabled) return;
289
+ if (key.return) {
290
+ if (value.trim()) {
291
+ onSubmit(value);
292
+ setValue('');
293
+ }
294
+ return;
295
+ }
296
+ if (key.backspace || key.delete) {
297
+ setValue((current) => current.slice(0, -1));
298
+ return;
299
+ }
300
+ if (input && !key.ctrl && !key.meta) {
301
+ setValue((current) => current + input);
302
+ }
303
+ }, { isActive: !disabled });
304
+
305
+ const color = value.startsWith('/') ? 'yellow' : 'white';
306
+
307
+ return React.createElement(
308
+ Box,
309
+ { flexDirection: 'column', flexGrow: 1 },
310
+ React.createElement(
311
+ Text,
312
+ { color: value ? color : 'gray' },
313
+ value || placeholder,
314
+ !disabled && React.createElement(Text, { backgroundColor: 'gray' }, ' '),
315
+ ),
316
+ !disabled
317
+ ? React.createElement(Box, { marginTop: 1 }, React.createElement(Text, { color: 'gray', dimColor: true }, 'Press Enter to send'))
318
+ : null,
319
+ );
320
+ }
321
+
322
+ function SessionMenu({ menu, setMenu, setAgent, setSessions, setMessages }) {
323
+ useInput((input, key) => {
324
+ if (key.escape || input === 'q') {
325
+ setMenu((current) => ({ ...current, open: false }));
326
+ return;
327
+ }
328
+
329
+ if (key.upArrow) {
330
+ setMenu((current) => ({
331
+ ...current,
332
+ selected: Math.max(0, current.selected - 1),
333
+ }));
334
+ return;
335
+ }
336
+
337
+ if (key.downArrow) {
338
+ setMenu((current) => ({
339
+ ...current,
340
+ selected: Math.min(current.items.length - 1, current.selected + 1),
341
+ }));
342
+ return;
343
+ }
344
+
345
+ if (key.return) {
346
+ const item = menu.items[menu.selected];
347
+ if (!item) return;
348
+ setSessions((current) => ({ ...current, [item.cli]: item.sessionId }));
349
+ setAgent(item.cli);
350
+ setMenu((current) => ({ ...current, open: false }));
351
+ setMessages((current) => [
352
+ ...current,
353
+ messageOf('system', `Resuming ${item.cli} session ${item.sessionId}`),
354
+ ]);
355
+ }
356
+ });
357
+
358
+ return React.createElement(
359
+ Box,
360
+ {
361
+ flexDirection: 'column',
362
+ borderStyle: 'round',
363
+ borderColor: 'yellow',
364
+ paddingX: 1,
365
+ marginTop: 1,
366
+ },
367
+ React.createElement(Text, { color: 'yellow', bold: true }, 'Resume Menu'),
368
+ menu.items.slice(Math.max(0, menu.selected - 6), menu.selected + 7).map((item) => {
369
+ const isSelected = item === menu.items[menu.selected];
370
+ return React.createElement(
371
+ Text,
372
+ {
373
+ key: `${item.cli}-${item.sessionId}`,
374
+ color: isSelected ? 'black' : item.cli === 'claude' ? 'magenta' : 'cyan',
375
+ backgroundColor: isSelected ? 'yellow' : undefined,
376
+ },
377
+ `${isSelected ? '›' : ' '} ${item.cli.padEnd(6)} ${item.sessionId.slice(0, 8)} ${item.updatedAt.slice(0, 16)} ${item.title}`,
378
+ );
379
+ }),
380
+ React.createElement(Text, { dimColor: true }, 'Enter resume Esc close'),
381
+ );
382
+ }
package/package.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@mylesiyabor/claudex",
3
+ "version": "0.1.0",
4
+ "description": "Ink TUI for chatting with Claude Code and Codex CLI from one switchable interface.",
5
+ "type": "module",
6
+ "bin": {
7
+ "claudex": "./bin/claudex.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=20.0.0"
11
+ },
12
+ "dependencies": {
13
+ "ink": "^4.4.1",
14
+ "ink-spinner": "^5.0.0",
15
+ "react": "^18.2.0",
16
+ "terminal-chat-ui": "^1.0.3"
17
+ }
18
+ }