Skip to content

Commit 43bbf3c

Browse files
committed
feat(cli): add session convert command
1 parent 8ea6a74 commit 43bbf3c

5 files changed

Lines changed: 269 additions & 0 deletions

File tree

‎cli.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ const {
115115
const {
116116
createZipCommandController
117117
} = require('./cli/zip-commands');
118+
const { cmdConvertSession } = require('./cli/session-convert');
118119
const {
119120
getCodexSkillsDir,
120121
getClaudeSkillsDir,
@@ -14614,6 +14615,7 @@ function printMainHelp() {
1461414615
console.log(' codexmate qwen [参数...] 等同于 qwen --yolo');
1461514616
console.log(' codexmate mcp [serve] [--transport stdio] [--allow-write|--read-only]');
1461614617
console.log(' codexmate export-session --source <codex|claude|gemini|codebuddy> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
14618+
console.log(' codexmate convert-session --from <codex|claude> --to <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
1461714619
console.log(' codexmate zip <路径> [--max:级别] 压缩(系统 zip 优先,其次 zip-lib)');
1461814620
console.log(' codexmate unzip <zip文件> [输出目录] 解压(zip-lib)');
1461914621
console.log(' codexmate unzip-ext <zip目录> [输出目录] [--ext:后缀[,后缀...]] [--no-recursive] 批量提取 ZIP 指定后缀文件(默认递归)');
@@ -14712,6 +14714,7 @@ async function main() {
1471214714
}
1471314715
case 'mcp': await cmdMcp(args.slice(1)); break;
1471414716
case 'export-session': await cmdExportSession(args.slice(1)); break;
14717+
case 'convert-session': await cmdConvertSession(args.slice(1), { resolveSessionFilePath }); break;
1471514718
case 'zip': {
1471614719
const { targetPath, options } = parseZipCommandArgs(args.slice(1));
1471714720
await cmdZip(targetPath, options);

‎cli/session-convert-args.js‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const { parseMaxMessagesValue } = require('../lib/cli-session-utils');
5+
6+
function ensureDir(dirPath) {
7+
if (!dirPath) return;
8+
if (fs.existsSync(dirPath)) return;
9+
fs.mkdirSync(dirPath, { recursive: true });
10+
}
11+
12+
function resolveOutputPath(outputPath, defaultFileName) {
13+
const fallback = path.resolve(process.cwd(), defaultFileName);
14+
if (typeof outputPath !== 'string' || !outputPath.trim()) return fallback;
15+
const trimmed = outputPath.trim();
16+
const resolved = path.resolve(trimmed);
17+
if (/[\\\/]$/.test(trimmed)) {
18+
ensureDir(resolved);
19+
return path.join(resolved, defaultFileName);
20+
}
21+
if (fs.existsSync(resolved)) {
22+
try { if (fs.statSync(resolved).isDirectory()) return path.join(resolved, defaultFileName); } catch (_) {}
23+
}
24+
return resolved;
25+
}
26+
27+
function parseArgs(args = []) {
28+
const options = { from: '', to: '', sessionId: '', filePath: '', output: '', maxMessages: undefined };
29+
const errors = [];
30+
for (let i = 0; i < args.length; i += 1) {
31+
const arg = String(args[i] || '');
32+
const next = args[i + 1] || '';
33+
if (!arg) continue;
34+
if (arg === '--from') { options.from = next; i += 1; continue; }
35+
if (arg.startsWith('--from=')) { options.from = arg.slice(7); continue; }
36+
if (arg === '--to') { options.to = next; i += 1; continue; }
37+
if (arg.startsWith('--to=')) { options.to = arg.slice(5); continue; }
38+
if (arg === '--session-id') { options.sessionId = next; i += 1; continue; }
39+
if (arg.startsWith('--session-id=')) { options.sessionId = arg.slice(13); continue; }
40+
if (arg === '--file') { options.filePath = next; i += 1; continue; }
41+
if (arg.startsWith('--file=')) { options.filePath = arg.slice(7); continue; }
42+
if (arg === '--output') { options.output = next; i += 1; continue; }
43+
if (arg.startsWith('--output=')) { options.output = arg.slice(9); continue; }
44+
if (arg === '--max-messages') { options.maxMessages = next; i += 1; continue; }
45+
if (arg.startsWith('--max-messages=')) { options.maxMessages = arg.slice(15); continue; }
46+
errors.push(`未知参数: ${arg}`);
47+
}
48+
options.from = String(options.from || '').trim().toLowerCase();
49+
options.to = String(options.to || '').trim().toLowerCase();
50+
if (options.from !== 'codex' && options.from !== 'claude') errors.push('参数 --from 仅支持 codex 或 claude');
51+
if (options.to !== 'codex' && options.to !== 'claude') errors.push('参数 --to 仅支持 codex 或 claude');
52+
if (options.from && options.to && options.from === options.to) errors.push('--from 与 --to 不能相同');
53+
if (!options.from) errors.push('缺少 --from');
54+
if (!options.to) errors.push('缺少 --to');
55+
if (!options.sessionId && !options.filePath) errors.push('必须指定 --session-id 或 --file');
56+
if (options.maxMessages !== undefined) {
57+
const parsed = parseMaxMessagesValue(options.maxMessages);
58+
if (parsed === null) errors.push('参数 --max-messages 无效');
59+
else options.maxMessages = parsed === Infinity ? Infinity : Math.max(1, Math.floor(parsed));
60+
}
61+
return { options, error: errors.length ? errors.join(';') : '' };
62+
}
63+
64+
module.exports = { ensureDir, resolveOutputPath, parseArgs };
65+

‎cli/session-convert-io.js‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
const fs = require('fs');
2+
const readline = require('readline');
3+
4+
const {
5+
toIsoTime,
6+
extractMessageText,
7+
normalizeRole,
8+
resolveMaxMessagesValue
9+
} = require('../lib/cli-session-utils');
10+
11+
const { removeLeadingSystemMessage } = require('../lib/cli-sessions');
12+
13+
async function readSessionMessages(filePath, source, maxMessages) {
14+
const limit = resolveMaxMessagesValue(maxMessages, 200);
15+
const state = { sessionId: '', cwd: '', updatedAt: '', messages: [], truncated: false };
16+
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
17+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
18+
for await (const line of rl) {
19+
const trimmed = String(line || '').trim();
20+
if (!trimmed) continue;
21+
let record;
22+
try { record = JSON.parse(trimmed); } catch (_) { continue; }
23+
const timestamp = toIsoTime(record.timestamp, '');
24+
if (timestamp) state.updatedAt = timestamp;
25+
if (source === 'codex' && record.type === 'session_meta' && record.payload) {
26+
if (!state.sessionId && record.payload.id) state.sessionId = String(record.payload.id || '');
27+
if (!state.cwd && record.payload.cwd) state.cwd = String(record.payload.cwd || '');
28+
continue;
29+
}
30+
if (source === 'claude') {
31+
if (!state.sessionId && record.sessionId) state.sessionId = String(record.sessionId || '');
32+
if (!state.cwd && record.cwd) state.cwd = String(record.cwd || '');
33+
}
34+
let role = '';
35+
let text = '';
36+
if (source === 'codex' && record.type === 'response_item' && record.payload && record.payload.type === 'message') {
37+
role = normalizeRole(record.payload.role);
38+
text = extractMessageText(record.payload.content);
39+
} else if (source === 'claude') {
40+
role = normalizeRole(record.type);
41+
text = extractMessageText(record.message ? record.message.content : '');
42+
}
43+
if (!role || !text) continue;
44+
state.messages.push({ role, text, timestamp });
45+
if (limit !== Infinity && state.messages.length > limit) {
46+
state.messages.shift();
47+
state.truncated = true;
48+
}
49+
}
50+
state.messages = removeLeadingSystemMessage(state.messages);
51+
return state;
52+
}
53+
54+
function buildTargetRecords(target, payload) {
55+
const now = Date.now();
56+
const sessionId = String(payload.sessionId || '').trim();
57+
const cwd = String(payload.cwd || '').trim();
58+
const messages = Array.isArray(payload.messages) ? payload.messages : [];
59+
if (target === 'codex') {
60+
const records = [{ type: 'session_meta', timestamp: new Date(now).toISOString(), payload: { id: sessionId, cwd } }];
61+
for (let i = 0; i < messages.length; i += 1) {
62+
const m = messages[i] || {};
63+
const role = normalizeRole(m.role);
64+
const text = typeof m.text === 'string' ? m.text : '';
65+
if (!role || !text) continue;
66+
records.push({ type: 'response_item', timestamp: m.timestamp || new Date(now + i).toISOString(), payload: { type: 'message', role, content: text } });
67+
}
68+
return records;
69+
}
70+
const records = [];
71+
for (let i = 0; i < messages.length; i += 1) {
72+
const m = messages[i] || {};
73+
const role = normalizeRole(m.role);
74+
const text = typeof m.text === 'string' ? m.text : '';
75+
if (!role || !text) continue;
76+
records.push({ type: role, timestamp: m.timestamp || new Date(now + i).toISOString(), sessionId, cwd, message: { content: text } });
77+
}
78+
return records;
79+
}
80+
81+
module.exports = { readSessionMessages, buildTargetRecords };
82+

‎cli/session-convert.js‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
const { parseArgs, ensureDir, resolveOutputPath } = require('./session-convert-args');
5+
const { readSessionMessages, buildTargetRecords } = require('./session-convert-io');
6+
7+
function printUsage() {
8+
console.log('\n用法:');
9+
console.log(' codexmate convert-session --from <codex|claude> --to <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
10+
}
11+
12+
async function cmdConvertSession(args = [], deps = {}) {
13+
const parsed = parseArgs(args);
14+
if (parsed.error) {
15+
console.error('错误:', parsed.error);
16+
printUsage();
17+
process.exit(1);
18+
}
19+
if (!deps || typeof deps.resolveSessionFilePath !== 'function') {
20+
console.error('错误: convert-session missing resolver');
21+
process.exit(1);
22+
}
23+
const opt = parsed.options;
24+
const filePath = deps.resolveSessionFilePath(opt.from, opt.filePath, opt.sessionId);
25+
if (!filePath) {
26+
console.error('转换失败: Session file not found');
27+
process.exit(1);
28+
}
29+
const extracted = await readSessionMessages(filePath, opt.from, opt.maxMessages);
30+
const sessionId = extracted.sessionId || opt.sessionId || path.basename(filePath, '.jsonl');
31+
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
32+
const records = buildTargetRecords(opt.to, { sessionId, cwd: extracted.cwd || '', messages: extracted.messages });
33+
const jsonl = `${records.map(r => JSON.stringify(r)).join('\n')}\n`;
34+
const outputPath = resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`);
35+
ensureDir(path.dirname(outputPath));
36+
fs.writeFileSync(outputPath, jsonl, 'utf-8');
37+
console.log('\n✓ 会话已转换:', outputPath);
38+
if (extracted.truncated) console.log('! 已截断: 可使用 --max-messages=all');
39+
console.log();
40+
}
41+
42+
module.exports = { cmdConvertSession };
43+
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import assert from 'assert';
2+
import fs from 'fs';
3+
import os from 'os';
4+
import path from 'path';
5+
import { createRequire } from 'module';
6+
7+
const require = createRequire(import.meta.url);
8+
const { cmdConvertSession } = require('../../cli/session-convert');
9+
10+
function writeJsonl(filePath, records) {
11+
const lines = records.map((r) => JSON.stringify(r));
12+
fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf-8');
13+
}
14+
15+
function listFiles(dirPath) {
16+
return fs.readdirSync(dirPath).filter(Boolean).sort();
17+
}
18+
19+
test('convert-session converts codex jsonl to claude jsonl', async () => {
20+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-convert-'));
21+
const inputPath = path.join(tmpDir, 'input.jsonl');
22+
const outDir = path.join(tmpDir, 'out');
23+
fs.mkdirSync(outDir);
24+
25+
writeJsonl(inputPath, [
26+
{ type: 'session_meta', timestamp: '2026-04-29T00:00:00.000Z', payload: { id: 'sess-1', cwd: '/repo' } },
27+
{ type: 'response_item', timestamp: '2026-04-29T00:00:01.000Z', payload: { type: 'message', role: 'user', content: 'hi' } },
28+
{ type: 'response_item', timestamp: '2026-04-29T00:00:02.000Z', payload: { type: 'message', role: 'assistant', content: 'hello' } }
29+
]);
30+
31+
await cmdConvertSession(
32+
['--from', 'codex', '--to', 'claude', '--file', inputPath, '--output', `${outDir}/`],
33+
{ resolveSessionFilePath: () => inputPath }
34+
);
35+
36+
const files = listFiles(outDir);
37+
assert.deepStrictEqual(files, ['claude-session-sess-1.jsonl']);
38+
const content = fs.readFileSync(path.join(outDir, files[0]), 'utf-8').trim();
39+
const records = content.split('\n').map((line) => JSON.parse(line));
40+
assert.strictEqual(records.length, 2);
41+
assert.strictEqual(records[0].type, 'user');
42+
assert.strictEqual(records[0].sessionId, 'sess-1');
43+
assert.strictEqual(records[0].cwd, '/repo');
44+
assert.strictEqual(records[0].message.content, 'hi');
45+
assert.strictEqual(records[1].type, 'assistant');
46+
assert.strictEqual(records[1].message.content, 'hello');
47+
});
48+
49+
test('convert-session converts claude jsonl to codex jsonl', async () => {
50+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codexmate-convert-'));
51+
const inputPath = path.join(tmpDir, 'input.jsonl');
52+
const outDir = path.join(tmpDir, 'out');
53+
fs.mkdirSync(outDir);
54+
55+
writeJsonl(inputPath, [
56+
{ type: 'user', timestamp: '2026-04-29T00:00:01.000Z', sessionId: 'sess-2', cwd: '/repo', message: { content: 'hi' } },
57+
{ type: 'assistant', timestamp: '2026-04-29T00:00:02.000Z', sessionId: 'sess-2', cwd: '/repo', message: { content: 'hello' } }
58+
]);
59+
60+
await cmdConvertSession(
61+
['--from', 'claude', '--to', 'codex', '--file', inputPath, '--output', `${outDir}/`],
62+
{ resolveSessionFilePath: () => inputPath }
63+
);
64+
65+
const files = listFiles(outDir);
66+
assert.deepStrictEqual(files, ['codex-session-sess-2.jsonl']);
67+
const content = fs.readFileSync(path.join(outDir, files[0]), 'utf-8').trim();
68+
const records = content.split('\n').map((line) => JSON.parse(line));
69+
assert.strictEqual(records[0].type, 'session_meta');
70+
assert.strictEqual(records[0].payload.id, 'sess-2');
71+
assert.strictEqual(records[0].payload.cwd, '/repo');
72+
assert.strictEqual(records[1].type, 'response_item');
73+
assert.strictEqual(records[1].payload.role, 'user');
74+
assert.strictEqual(records[2].payload.role, 'assistant');
75+
});
76+

0 commit comments

Comments
 (0)