@@ -6,7 +6,80 @@ const { readSessionMessages, buildTargetRecords } = require('./session-convert-i
66
77function printUsage ( ) {
88 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>]' ) ;
9+ 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>]' ) ;
10+ }
11+
12+
13+ function resolveExistingDir ( candidates , fallback ) {
14+ for ( const candidate of candidates ) {
15+ if ( ! candidate ) continue ;
16+ try {
17+ if ( fs . existsSync ( candidate ) && fs . statSync ( candidate ) . isDirectory ( ) ) return candidate ;
18+ } catch ( _ ) { }
19+ }
20+ return fallback ;
21+ }
22+
23+ function resolveCodexSessionsDir ( ) {
24+ const home = process . env . HOME || process . env . USERPROFILE || '' ;
25+ const candidates = [ ] ;
26+ if ( process . env . CODEX_HOME ) candidates . push ( path . join ( process . env . CODEX_HOME , 'sessions' ) ) ;
27+ if ( process . env . XDG_CONFIG_HOME ) candidates . push ( path . join ( process . env . XDG_CONFIG_HOME , 'codex' , 'sessions' ) ) ;
28+ if ( home ) {
29+ candidates . push ( path . join ( home , '.config' , 'codex' , 'sessions' ) ) ;
30+ candidates . push ( path . join ( home , '.codex' , 'sessions' ) ) ;
31+ }
32+ return resolveExistingDir ( candidates , candidates [ candidates . length - 1 ] || path . resolve ( '.codex/sessions' ) ) ;
33+ }
34+
35+ function resolveClaudeProjectsDir ( ) {
36+ const home = process . env . HOME || process . env . USERPROFILE || '' ;
37+ const candidates = [ ] ;
38+ const claudeHome = process . env . CLAUDE_HOME || process . env . CLAUDE_CONFIG_DIR || '' ;
39+ if ( claudeHome ) candidates . push ( path . join ( claudeHome , 'projects' ) ) ;
40+ if ( process . env . XDG_CONFIG_HOME ) candidates . push ( path . join ( process . env . XDG_CONFIG_HOME , 'claude' , 'projects' ) ) ;
41+ if ( home ) {
42+ candidates . push ( path . join ( home , '.config' , 'claude' , 'projects' ) ) ;
43+ candidates . push ( path . join ( home , '.claude' , 'projects' ) ) ;
44+ }
45+ return resolveExistingDir ( candidates , candidates [ candidates . length - 1 ] || path . resolve ( '.claude/projects' ) ) ;
46+ }
47+
48+ function sanitizeClaudeProjectName ( cwd ) {
49+ const value = typeof cwd === 'string' && cwd . trim ( ) ? path . resolve ( cwd . trim ( ) ) : 'codexmate-derived' ;
50+ return value . replace ( / [ ^ a - z A - Z 0 - 9 . _ - ] / g, '-' ) . replace ( / - + / g, '-' ) . replace ( / ^ - | - $ / g, '' ) || 'codexmate-derived' ;
51+ }
52+
53+ function buildSourceKey ( from , sessionId , filePath ) {
54+ const seed = `${ from } |${ sessionId || '' } |${ filePath || '' } ` ;
55+ let hash = 0 ;
56+ for ( let i = 0 ; i < seed . length ; i += 1 ) hash = ( ( hash << 5 ) - hash + seed . charCodeAt ( i ) ) | 0 ;
57+ return String ( Math . abs ( hash ) ) . padStart ( 8 , '0' ) . slice ( 0 , 8 ) ;
58+ }
59+
60+ function resolveDefaultOutputPath ( opt , sessionId , cwd ) {
61+ const safeSessionId = String ( sessionId ) . replace ( / [ ^ a - z A - Z 0 - 9 _ - ] / g, '_' ) || 'session' ;
62+ if ( opt . output ) return resolveOutputPath ( opt . output , `${ opt . to } -session-${ safeSessionId } .jsonl` ) ;
63+ if ( opt . outputDir === 'derived' ) {
64+ const home = process . env . HOME || process . env . USERPROFILE || '' ;
65+ const root = path . join ( home || process . cwd ( ) , '.codexmate' , 'sessions' , 'derived' , opt . to , opt . from , buildSourceKey ( opt . from , sessionId , opt . filePath ) ) ;
66+ return path . join ( root , `${ safeSessionId } .jsonl` ) ;
67+ }
68+ if ( opt . to === 'codex' ) return path . join ( resolveCodexSessionsDir ( ) , `${ safeSessionId } .jsonl` ) ;
69+ return path . join ( resolveClaudeProjectsDir ( ) , sanitizeClaudeProjectName ( cwd ) , `${ safeSessionId } .jsonl` ) ;
70+ }
71+
72+ function getDerivedSessionMetaPath ( filePath ) {
73+ if ( ! filePath ) return '' ;
74+ return filePath . toLowerCase ( ) . endsWith ( '.jsonl' )
75+ ? filePath . slice ( 0 , - 6 ) + '.meta.json'
76+ : `${ filePath } .meta.json` ;
77+ }
78+
79+ function writeJsonAtomic ( filePath , value ) {
80+ const tmpPath = `${ filePath } .tmp-${ process . pid } -${ Date . now ( ) } ` ;
81+ fs . writeFileSync ( tmpPath , `${ JSON . stringify ( value , null , 2 ) } \n` , { encoding : 'utf-8' , flag : 'wx' } ) ;
82+ fs . renameSync ( tmpPath , filePath ) ;
1083}
1184
1285async function cmdConvertSession ( args = [ ] , deps = { } ) {
@@ -28,12 +101,46 @@ async function cmdConvertSession(args = [], deps = {}) {
28101 }
29102 const extracted = await readSessionMessages ( filePath , opt . from , opt . maxMessages ) ;
30103 const sessionId = extracted . sessionId || opt . sessionId || path . basename ( filePath , '.jsonl' ) ;
31- const safeSessionId = String ( sessionId ) . replace ( / [ ^ a - z A - Z 0 - 9 _ - ] / g, '_' ) ;
32104 const records = buildTargetRecords ( opt . to , { sessionId, cwd : extracted . cwd || '' , messages : extracted . messages } ) ;
33105 const jsonl = `${ records . map ( r => JSON . stringify ( r ) ) . join ( '\n' ) } \n` ;
34- const outputPath = resolveOutputPath ( opt . output , `${ opt . to } -session-${ safeSessionId } .jsonl` ) ;
106+ const outputPath = resolveDefaultOutputPath ( opt , sessionId , extracted . cwd || '' ) ;
107+ const metaPath = getDerivedSessionMetaPath ( outputPath ) ;
35108 ensureDir ( path . dirname ( outputPath ) ) ;
36- fs . writeFileSync ( outputPath , jsonl , 'utf-8' ) ;
109+ try {
110+ if ( fs . existsSync ( metaPath ) ) {
111+ const error = new Error ( `target session metadata already exists: ${ metaPath } ` ) ;
112+ error . code = 'EEXIST' ;
113+ throw error ;
114+ }
115+ fs . writeFileSync ( outputPath , jsonl , { encoding : 'utf-8' , flag : 'wx' } ) ;
116+ writeJsonAtomic ( metaPath , {
117+ version : 1 ,
118+ createdAt : new Date ( ) . toISOString ( ) ,
119+ source : {
120+ type : opt . from ,
121+ sessionId,
122+ filePath
123+ } ,
124+ target : {
125+ type : opt . to ,
126+ sessionId,
127+ filePath : outputPath
128+ } ,
129+ options : {
130+ maxMessages : opt . maxMessages ,
131+ outputDir : opt . outputDir
132+ }
133+ } ) ;
134+ } catch ( error ) {
135+ if ( error && error . code === 'EEXIST' ) {
136+ console . error ( '转换失败: target session already exists:' , outputPath ) ;
137+ process . exit ( 1 ) ;
138+ }
139+ try {
140+ if ( fs . existsSync ( outputPath ) && ! fs . existsSync ( metaPath ) ) fs . unlinkSync ( outputPath ) ;
141+ } catch ( _ ) { }
142+ throw error ;
143+ }
37144 console . log ( '\n✓ 会话已转换:' , outputPath ) ;
38145 if ( extracted . truncated ) console . log ( '! 已截断: 可使用 --max-messages=all' ) ;
39146 console . log ( ) ;
0 commit comments