Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
315 changes: 293 additions & 22 deletions cli.js

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion cli/session-convert-args.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ function resolveOutputPath(outputPath, defaultFileName) {
}

function parseArgs(args = []) {
const options = { from: '', to: '', sessionId: '', filePath: '', output: '', maxMessages: undefined };
const options = { from: '', to: '', sessionId: '', filePath: '', output: '', outputDir: 'native', maxMessages: undefined };
const errors = [];
for (let i = 0; i < args.length; i += 1) {
const arg = String(args[i] || '');
Expand All @@ -41,6 +41,8 @@ function parseArgs(args = []) {
if (arg.startsWith('--file=')) { options.filePath = arg.slice(7); continue; }
if (arg === '--output') { options.output = next; i += 1; continue; }
if (arg.startsWith('--output=')) { options.output = arg.slice(9); continue; }
if (arg === '--output-dir') { options.outputDir = next; i += 1; continue; }
if (arg.startsWith('--output-dir=')) { options.outputDir = arg.slice(13); continue; }
if (arg === '--max-messages') { options.maxMessages = next; i += 1; continue; }
if (arg.startsWith('--max-messages=')) { options.maxMessages = arg.slice(15); continue; }
errors.push(`未知参数: ${arg}`);
Expand All @@ -50,6 +52,8 @@ function parseArgs(args = []) {
if (options.from !== 'codex' && options.from !== 'claude') errors.push('参数 --from 仅支持 codex 或 claude');
if (options.to !== 'codex' && options.to !== 'claude') errors.push('参数 --to 仅支持 codex 或 claude');
if (options.from && options.to && options.from === options.to) errors.push('--from 与 --to 不能相同');
options.outputDir = String(options.outputDir || 'native').trim().toLowerCase();
if (options.outputDir !== 'native' && options.outputDir !== 'derived') errors.push('参数 --output-dir 仅支持 native 或 derived');
if (!options.from) errors.push('缺少 --from');
if (!options.to) errors.push('缺少 --to');
if (!options.sessionId && !options.filePath) errors.push('必须指定 --session-id 或 --file');
Expand Down
115 changes: 111 additions & 4 deletions cli/session-convert.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,80 @@ const { readSessionMessages, buildTargetRecords } = require('./session-convert-i

function printUsage() {
console.log('\n用法:');
console.log(' codexmate convert-session --from <codex|claude> --to <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--max-messages <N|all|Infinity>]');
console.log(' codexmate convert-session --from <codex|claude> --to <codex|claude> (--session-id <ID>|--file <PATH>) [--output <PATH>] [--output-dir <native|derived>] [--max-messages <N|all|Infinity>]');
}


function resolveExistingDir(candidates, fallback) {
for (const candidate of candidates) {
if (!candidate) continue;
try {
if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) return candidate;
} catch (_) {}
}
return fallback;
}

function resolveCodexSessionsDir() {
const home = process.env.HOME || process.env.USERPROFILE || '';
const candidates = [];
if (process.env.CODEX_HOME) candidates.push(path.join(process.env.CODEX_HOME, 'sessions'));
if (process.env.XDG_CONFIG_HOME) candidates.push(path.join(process.env.XDG_CONFIG_HOME, 'codex', 'sessions'));
if (home) {
candidates.push(path.join(home, '.config', 'codex', 'sessions'));
candidates.push(path.join(home, '.codex', 'sessions'));
}
return resolveExistingDir(candidates, candidates[candidates.length - 1] || path.resolve('.codex/sessions'));
}

function resolveClaudeProjectsDir() {
const home = process.env.HOME || process.env.USERPROFILE || '';
const candidates = [];
const claudeHome = process.env.CLAUDE_HOME || process.env.CLAUDE_CONFIG_DIR || '';
if (claudeHome) candidates.push(path.join(claudeHome, 'projects'));
if (process.env.XDG_CONFIG_HOME) candidates.push(path.join(process.env.XDG_CONFIG_HOME, 'claude', 'projects'));
if (home) {
candidates.push(path.join(home, '.config', 'claude', 'projects'));
candidates.push(path.join(home, '.claude', 'projects'));
}
return resolveExistingDir(candidates, candidates[candidates.length - 1] || path.resolve('.claude/projects'));
}

function sanitizeClaudeProjectName(cwd) {
const value = typeof cwd === 'string' && cwd.trim() ? path.resolve(cwd.trim()) : 'codexmate-derived';
return value.replace(/[^a-zA-Z0-9._-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'codexmate-derived';
}

function buildSourceKey(from, sessionId, filePath) {
const seed = `${from}|${sessionId || ''}|${filePath || ''}`;
let hash = 0;
for (let i = 0; i < seed.length; i += 1) hash = ((hash << 5) - hash + seed.charCodeAt(i)) | 0;
return String(Math.abs(hash)).padStart(8, '0').slice(0, 8);
}

function resolveDefaultOutputPath(opt, sessionId, cwd) {
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_') || 'session';
if (opt.output) return resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`);
if (opt.outputDir === 'derived') {
const home = process.env.HOME || process.env.USERPROFILE || '';
const root = path.join(home || process.cwd(), '.codexmate', 'sessions', 'derived', opt.to, opt.from, buildSourceKey(opt.from, sessionId, opt.filePath));
return path.join(root, `${safeSessionId}.jsonl`);
}
if (opt.to === 'codex') return path.join(resolveCodexSessionsDir(), `${safeSessionId}.jsonl`);
return path.join(resolveClaudeProjectsDir(), sanitizeClaudeProjectName(cwd), `${safeSessionId}.jsonl`);
}

function getDerivedSessionMetaPath(filePath) {
if (!filePath) return '';
return filePath.toLowerCase().endsWith('.jsonl')
? filePath.slice(0, -6) + '.meta.json'
: `${filePath}.meta.json`;
}

function writeJsonAtomic(filePath, value) {
const tmpPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
fs.writeFileSync(tmpPath, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf-8', flag: 'wx' });
fs.renameSync(tmpPath, filePath);
}

async function cmdConvertSession(args = [], deps = {}) {
Expand All @@ -28,12 +101,46 @@ async function cmdConvertSession(args = [], deps = {}) {
}
const extracted = await readSessionMessages(filePath, opt.from, opt.maxMessages);
const sessionId = extracted.sessionId || opt.sessionId || path.basename(filePath, '.jsonl');
const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9_-]/g, '_');
const records = buildTargetRecords(opt.to, { sessionId, cwd: extracted.cwd || '', messages: extracted.messages });
const jsonl = `${records.map(r => JSON.stringify(r)).join('\n')}\n`;
const outputPath = resolveOutputPath(opt.output, `${opt.to}-session-${safeSessionId}.jsonl`);
const outputPath = resolveDefaultOutputPath(opt, sessionId, extracted.cwd || '');
const metaPath = getDerivedSessionMetaPath(outputPath);
ensureDir(path.dirname(outputPath));
fs.writeFileSync(outputPath, jsonl, 'utf-8');
try {
if (fs.existsSync(metaPath)) {
const error = new Error(`target session metadata already exists: ${metaPath}`);
error.code = 'EEXIST';
throw error;
}
fs.writeFileSync(outputPath, jsonl, { encoding: 'utf-8', flag: 'wx' });
writeJsonAtomic(metaPath, {
version: 1,
createdAt: new Date().toISOString(),
source: {
type: opt.from,
sessionId,
filePath
},
target: {
type: opt.to,
sessionId,
filePath: outputPath
},
options: {
maxMessages: opt.maxMessages,
outputDir: opt.outputDir
}
});
} catch (error) {
if (error && error.code === 'EEXIST') {
console.error('转换失败: target session already exists:', outputPath);
process.exit(1);
}
try {
if (fs.existsSync(outputPath) && !fs.existsSync(metaPath)) fs.unlinkSync(outputPath);
} catch (_) {}
throw error;
}
console.log('\n✓ 会话已转换:', outputPath);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (extracted.truncated) console.log('! 已截断: 可使用 --max-messages=all');
console.log();
Expand Down
56 changes: 41 additions & 15 deletions tests/e2e/test-session-convert-derived.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,12 @@ async function convertAndAssertListed(api, tmpHome, source, target, params = {},
const outPath = res.session.filePath;
assert(fs.existsSync(outPath), `derived ${target} session file missing`);
assert(fs.existsSync(derivedMetaPath(outPath)), `derived ${target} meta missing`);
if (target === 'codex') {
if (params.outputDir === 'derived') {
assert(
outPath.startsWith(path.join(tmpHome, '.codexmate', 'sessions', 'derived', target) + path.sep),
`derived ${target} session path should stay inside ~/.codexmate when outputDir=derived`
);
} else if (target === 'codex') {
assert(isCodexSessionPath(tmpHome, outPath), 'derived codex session path should stay inside ~/.codex or ~/.config/codex');
} else if (target === 'claude') {
assert(isClaudeProjectPath(tmpHome, outPath), 'derived claude session path should stay inside ~/.claude/projects or ~/.config/claude/projects');
Expand Down Expand Up @@ -97,32 +102,53 @@ module.exports = async function testSessionConvertDerived(ctx) {
});

const detailClaude = await api('session-detail', { source: 'claude', filePath: derivedClaudePath, maxMessages: 50 });
assert(detailClaude.derived === true, 'native-written converted claude session should stay marked derived');
assert(detailClaude.nativeAvailable === true, 'native-written converted claude session should report nativeAvailable');
assert(detailClaude.nativePath === derivedClaudePath, 'native-written converted claude nativePath should equal filePath');
assert(detailClaude.nativeImportAvailable === false, 'native-written converted claude should not show import action');
assert(Array.isArray(detailClaude.messages), 'session-detail(derived claude) missing messages');
assert(detailClaude.messages.length === 2, 'session-detail(derived claude) should keep exact short length');
assert(detailClaude.messages[0].text === 'hello', 'session-detail(derived claude) user text mismatch');
assert(detailClaude.messages[1].text === 'world', 'session-detail(derived claude) assistant text mismatch');

const { outPath: derivedCodexPath } = await convertAndAssertListed(api, tmpHome, 'claude', 'codex', {
filePath: derivedClaudePath,
maxMessages: 'all'
});

const detailCodex = await api('session-detail', { source: 'codex', filePath: derivedCodexPath, maxMessages: 50 });
assert(Array.isArray(detailCodex.messages), 'session-detail(derived codex) missing messages');
assert(detailCodex.messages.length === 2, 'session-detail(derived codex) should keep exact short length');
assert(detailCodex.messages[0].text === 'hello', 'session-detail(derived codex) user text mismatch');
assert(detailCodex.messages[1].text === 'world', 'session-detail(derived codex) assistant text mismatch');

const { outPath: derivedClaudePath2 } = await convertAndAssertListed(api, tmpHome, 'codex', 'claude', {
const duplicateNative = await api('convert-session', {
source: 'codex',
target: 'claude',
sessionId,
maxMessages: 'all'
});
assert(derivedClaudePath2 && derivedClaudePath2 !== derivedClaudePath, 'second derived session should create a distinct file');
assert(fs.existsSync(derivedClaudePath2), 'second derived claude session file missing');
assert(duplicateNative.error, 'second native conversion should abort on target sessionId conflict');

const afterHash = sha256File(sessionPath);
assert(afterHash === beforeHash, 'source codex session should remain unchanged after conversions');

{
const { res: legacyRes, outPath: legacyDerivedCodexPath } = await convertAndAssertListed(api, tmpHome, 'claude', 'codex', {
filePath: derivedClaudePath,
maxMessages: 'all',
outputDir: 'derived'
}, { assertListed: false });
assert(legacyRes.session.nativeAvailable === false, 'legacy derived codex conversion should report native unavailable');
assert(legacyRes.session.nativeImportAvailable === true, 'legacy derived codex conversion should allow native import');
const detailCodex = await api('session-detail', { source: 'codex', filePath: legacyDerivedCodexPath, maxMessages: 50 });
assert(detailCodex.derived === true, 'legacy derived codex session should stay marked derived');
assert(detailCodex.nativeAvailable === false, 'legacy derived codex detail should report native unavailable');
assert(detailCodex.nativeImportAvailable === true, 'legacy derived codex detail should allow import');
assert(Array.isArray(detailCodex.messages), 'session-detail(derived codex) missing messages');
assert(detailCodex.messages.length === 2, 'session-detail(derived codex) should keep exact short length');
assert(detailCodex.messages[0].text === 'hello', 'session-detail(derived codex) user text mismatch');
assert(detailCodex.messages[1].text === 'world', 'session-detail(derived codex) assistant text mismatch');
const imported = await api('import-derived-session', { source: 'codex', filePath: legacyDerivedCodexPath });
assert(!imported.error, `import-derived-session failed: ${imported.error || ''}`);
assert(imported.nativeAvailable === true, 'import-derived-session should report native available');
assert(imported.filePath && imported.filePath !== legacyDerivedCodexPath, 'import-derived-session should copy to native path');
assert(fs.existsSync(imported.filePath), 'imported native codex session file missing');
const importedDetail = await api('session-detail', { source: 'codex', filePath: imported.filePath, maxMessages: 50 });
assert(importedDetail.nativeAvailable === true, 'imported native codex detail should report native available');
const conflict = await api('import-derived-session', { source: 'codex', filePath: legacyDerivedCodexPath });
assert(conflict.conflict === true, 'second import without overwrite should report conflict');
}

if (daudeSessionPath) {
const beforeDaudeHash = sha256File(daudeSessionPath);
const { outPath: daudeDerivedClaudePath } = await convertAndAssertListed(api, tmpHome, 'codex', 'claude', {
Expand Down
4 changes: 3 additions & 1 deletion tests/unit/compact-layout-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,9 @@ test('styles keep desktop layout wide and session history readable on large scre
assert.match(styles, /\.session-layout\s*\{[\s\S]*grid-template-columns:\s*minmax\(260px,\s*360px\)\s*minmax\(0,\s*1fr\);/);
assert.match(styles, /\.session-preview-scroll\s*\{[\s\S]*padding-right:\s*52px;/);
assert.match(styles, /\.session-timeline\s*\{[\s\S]*right:\s*4px;[\s\S]*width:\s*44px;/);
assert.match(styles, /\.session-item\s*\{[\s\S]*min-height:\s*80px;/);
assert.match(styles, /\.session-item\s*\{[\s\S]*min-height:\s*84px;/);
assert.match(styles, /\.session-item\s*\{[\s\S]*contain-intrinsic-size:\s*84px;/);
assert.match(styles, /@media\s*\(max-width:\s*720px\)\s*\{[\s\S]*\.session-item\s*\{[\s\S]*min-height:\s*79px;[\s\S]*height:\s*79px;[\s\S]*contain-intrinsic-size:\s*79px;/);

const html = readBundledWebUiHtml();
assert.match(html, /class="brand-logo"\s+src="\/res\/logo-pack\.webp"/);
Expand Down
16 changes: 12 additions & 4 deletions tests/unit/session-convert.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,12 @@ test('convert-session converts codex jsonl to claude jsonl', async () => {
);

const files = listFiles(outDir);
assert.deepStrictEqual(files, ['claude-session-sess-1.jsonl']);
const content = fs.readFileSync(path.join(outDir, files[0]), 'utf-8').trim();
assert.deepStrictEqual(files, ['claude-session-sess-1.jsonl', 'claude-session-sess-1.meta.json']);
const meta = JSON.parse(fs.readFileSync(path.join(outDir, 'claude-session-sess-1.meta.json'), 'utf-8'));
assert.strictEqual(meta.source.type, 'codex');
assert.strictEqual(meta.target.type, 'claude');
assert.strictEqual(meta.target.sessionId, 'sess-1');
const content = fs.readFileSync(path.join(outDir, 'claude-session-sess-1.jsonl'), 'utf-8').trim();
const records = content.split('\n').map((line) => JSON.parse(line));
assert.strictEqual(records.length, 2);
assert.strictEqual(records[0].type, 'user');
Expand Down Expand Up @@ -63,8 +67,12 @@ test('convert-session converts claude jsonl to codex jsonl', async () => {
);

const files = listFiles(outDir);
assert.deepStrictEqual(files, ['codex-session-sess-2.jsonl']);
const content = fs.readFileSync(path.join(outDir, files[0]), 'utf-8').trim();
assert.deepStrictEqual(files, ['codex-session-sess-2.jsonl', 'codex-session-sess-2.meta.json']);
const meta = JSON.parse(fs.readFileSync(path.join(outDir, 'codex-session-sess-2.meta.json'), 'utf-8'));
assert.strictEqual(meta.source.type, 'claude');
assert.strictEqual(meta.target.type, 'codex');
assert.strictEqual(meta.target.sessionId, 'sess-2');
const content = fs.readFileSync(path.join(outDir, 'codex-session-sess-2.jsonl'), 'utf-8').trim();
const records = content.split('\n').map((line) => JSON.parse(line));
assert.strictEqual(records[0].type, 'session_meta');
assert.strictEqual(records[0].payload.id, 'sess-2');
Expand Down
9 changes: 9 additions & 0 deletions tests/unit/session-header-actions-layout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ test('sessions header actions keep buttons inline (contract)', () => {
'panel-sessions should mark header actions with sessions-header-actions'
);

assert(
!html.includes(':disabled="true"'),
'session panel actions must not contain permanently disabled buttons'
);
assert(
html.includes('isSessionConvertAvailable(activeSession)'),
'convert session button should bind to real availability instead of a hardcoded disabled state'
);

const css = readText('web-ui/styles/controls-forms.css');
assert(
/\.selector-header\s*\{[\s\S]*?flex-wrap:\s*nowrap\s*;[\s\S]*?\}/m.test(css),
Expand Down
Loading
Loading