spiracha 1.0.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,272 @@
1
+ import path from 'node:path';
2
+ import { type CodexCliOptions, DEFAULT_DB_PATH, DEFAULT_INPUT_DIR, DEFAULT_OUTPUT_DIR } from './codex-exporter-types';
3
+ import { CliUsageError, type ExportFormat, expandHome, getPortablePathBasename } from './shared';
4
+
5
+ export const parseCodexCliArgs = (argv: string[]): CodexCliOptions => {
6
+ let dbPath = DEFAULT_DB_PATH;
7
+ let inputDir = DEFAULT_INPUT_DIR;
8
+ let outputDir: string | null = null;
9
+ let cwdFilter: string | null = null;
10
+ let projectFilter: string | null = null;
11
+ let threadIds: string[] = [];
12
+ let outputProvided = false;
13
+ let optimized = false;
14
+ let includeTools = false;
15
+ let outputFormat: ExportFormat = 'md';
16
+ let flat = false;
17
+
18
+ for (let index = 0; index < argv.length; index += 1) {
19
+ const nextIndex = applyCodexCliArg(argv, index, {
20
+ cwdFilter,
21
+ dbPath,
22
+ flat,
23
+ includeTools,
24
+ inputDir,
25
+ optimized,
26
+ outputDir,
27
+ outputFormat,
28
+ outputProvided,
29
+ projectFilter,
30
+ threadIds,
31
+ });
32
+
33
+ ({
34
+ cwdFilter,
35
+ dbPath,
36
+ flat,
37
+ includeTools,
38
+ inputDir,
39
+ optimized,
40
+ outputDir,
41
+ outputFormat,
42
+ outputProvided,
43
+ projectFilter,
44
+ threadIds,
45
+ } = nextIndex.state);
46
+ index = nextIndex.index;
47
+ }
48
+
49
+ if (!outputProvided) {
50
+ outputDir = resolveDefaultOutputDir(cwdFilter);
51
+ }
52
+
53
+ return {
54
+ cwdFilter,
55
+ dbPath,
56
+ flat,
57
+ includeTools,
58
+ inputDir,
59
+ optimized,
60
+ outputDir: outputDir ?? DEFAULT_OUTPUT_DIR,
61
+ outputFormat,
62
+ projectFilter,
63
+ threadIds: [...new Set(threadIds)],
64
+ };
65
+ };
66
+
67
+ type CodexCliState = {
68
+ cwdFilter: string | null;
69
+ dbPath: string;
70
+ flat: boolean;
71
+ includeTools: boolean;
72
+ inputDir: string;
73
+ optimized: boolean;
74
+ outputDir: string | null;
75
+ outputFormat: ExportFormat;
76
+ outputProvided: boolean;
77
+ projectFilter: string | null;
78
+ threadIds: string[];
79
+ };
80
+
81
+ type CodexCliNext = {
82
+ index: number;
83
+ state: CodexCliState;
84
+ };
85
+
86
+ const applyCodexCliArg = (argv: string[], index: number, state: CodexCliState): CodexCliNext => {
87
+ const arg = argv[index];
88
+
89
+ if (arg === '--db') {
90
+ return {
91
+ index: index + 1,
92
+ state: {
93
+ ...state,
94
+ dbPath: expandHome(requireValue(argv[index + 1], '--db')),
95
+ },
96
+ };
97
+ }
98
+
99
+ if (arg === '--input' || arg === '-i') {
100
+ return {
101
+ index: index + 1,
102
+ state: {
103
+ ...state,
104
+ inputDir: expandHome(requireValue(argv[index + 1], arg)),
105
+ },
106
+ };
107
+ }
108
+
109
+ if (arg === '--output' || arg === '-o') {
110
+ return {
111
+ index: index + 1,
112
+ state: {
113
+ ...state,
114
+ outputDir: expandHome(requireValue(argv[index + 1], arg)),
115
+ outputProvided: true,
116
+ },
117
+ };
118
+ }
119
+
120
+ if (arg === '--cwd') {
121
+ return {
122
+ index: index + 1,
123
+ state: {
124
+ ...state,
125
+ cwdFilter: expandHome(requireValue(argv[index + 1], '--cwd')),
126
+ },
127
+ };
128
+ }
129
+
130
+ if (arg === '--project') {
131
+ return {
132
+ index: index + 1,
133
+ state: {
134
+ ...state,
135
+ projectFilter: requireValue(argv[index + 1], '--project').trim(),
136
+ },
137
+ };
138
+ }
139
+
140
+ if (arg === '--optimized') {
141
+ return {
142
+ index,
143
+ state: {
144
+ ...state,
145
+ optimized: true,
146
+ },
147
+ };
148
+ }
149
+
150
+ if (arg === '--tools') {
151
+ return {
152
+ index,
153
+ state: {
154
+ ...state,
155
+ includeTools: true,
156
+ },
157
+ };
158
+ }
159
+
160
+ if (arg === '--flat') {
161
+ return {
162
+ index,
163
+ state: {
164
+ ...state,
165
+ flat: true,
166
+ },
167
+ };
168
+ }
169
+
170
+ if (arg.startsWith('--output-format=')) {
171
+ return {
172
+ index,
173
+ state: {
174
+ ...state,
175
+ outputFormat: parseExportFormat(arg.slice('--output-format='.length)),
176
+ },
177
+ };
178
+ }
179
+
180
+ if (arg === '--output-format') {
181
+ return {
182
+ index: index + 1,
183
+ state: {
184
+ ...state,
185
+ outputFormat: parseExportFormat(requireValue(argv[index + 1], '--output-format')),
186
+ },
187
+ };
188
+ }
189
+
190
+ if (!arg.startsWith('-')) {
191
+ const threadId = parseThreadSelectionArg(arg);
192
+ if (!threadId) {
193
+ throw new CliUsageError(
194
+ `Unsupported positional argument: ${arg}\nExpected a Codex thread deeplink like codex://threads/<thread-id>`,
195
+ );
196
+ }
197
+
198
+ return {
199
+ index,
200
+ state: {
201
+ ...state,
202
+ threadIds: [...state.threadIds, threadId],
203
+ },
204
+ };
205
+ }
206
+
207
+ throw new CliUsageError(`Unknown argument: ${arg}`);
208
+ };
209
+
210
+ export const getCodexHelpText = (): string => {
211
+ return [
212
+ 'Export Codex session JSONL files to Markdown or TXT.',
213
+ 'Run with no arguments to enter interactive mode.',
214
+ '',
215
+ 'Usage:',
216
+ ' codex-chats',
217
+ ' codex-chats --interactive',
218
+ ' codex-chats [--db FILE] [--input DIR] [--output DIR] [--cwd DIR] [--project NAME] [--optimized] [--tools] [--flat] [--output-format md|txt] [codex://threads/<thread-id> ...]',
219
+ '',
220
+ 'Options:',
221
+ ` --db Thread database path (default: ${DEFAULT_DB_PATH})`,
222
+ ` --input, -i Source sessions directory (default: ${DEFAULT_INPUT_DIR})`,
223
+ ' --output, -o Export directory (default: ./<cwd-basename> when --cwd is set, otherwise ./exports)',
224
+ ' --cwd Only export chats whose cwd matches this exact path',
225
+ ' --project Only export chats whose cwd basename matches this project name',
226
+ ' codex://threads/<id>',
227
+ ' Only export the exact threads referenced by these Codex deeplinks',
228
+ ' --optimized Suppress metadata and apply token-saving text cleanup',
229
+ ' --tools Include tool-call logs such as exec_command invocations',
230
+ ' --flat Write all exports into one folder instead of nested subfolders',
231
+ ' --output-format Output file format: md or txt (default: md)',
232
+ ' --interactive Start the interactive prompt flow',
233
+ ' --help, -h Show this help text',
234
+ ].join('\n');
235
+ };
236
+
237
+ export const parseThreadSelectionArg = (value: string): string | null => {
238
+ const trimmed = value.trim();
239
+ if (!trimmed) {
240
+ return null;
241
+ }
242
+
243
+ const match = /^codex:\/\/threads\/([^/?#]+)$/u.exec(trimmed);
244
+ return match?.[1] ?? null;
245
+ };
246
+
247
+ export const resolveDefaultOutputDir = (cwdFilter: string | null): string => {
248
+ if (cwdFilter) {
249
+ const basename = getPortablePathBasename(cwdFilter);
250
+ if (basename) {
251
+ return path.join(process.cwd(), basename);
252
+ }
253
+ }
254
+
255
+ return DEFAULT_OUTPUT_DIR;
256
+ };
257
+
258
+ const requireValue = (value: string | undefined, flag: string): string => {
259
+ if (!value || value.startsWith('--')) {
260
+ throw new CliUsageError(`Missing value for ${flag}`);
261
+ }
262
+
263
+ return value;
264
+ };
265
+
266
+ const parseExportFormat = (value: string): ExportFormat => {
267
+ if (value === 'md' || value === 'txt') {
268
+ return value;
269
+ }
270
+
271
+ throw new CliUsageError(`Unsupported output format: ${value}`);
272
+ };
@@ -0,0 +1,320 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import {
5
+ type CodexCliOptions,
6
+ DEFAULT_CODEX_DIR,
7
+ type ExportTarget,
8
+ type SpawnEdgeRow,
9
+ type ThreadData,
10
+ type ThreadRelations,
11
+ type ThreadRow,
12
+ } from './codex-exporter-types';
13
+ import { getPortablePathBasename } from './shared';
14
+
15
+ export const loadThreadData = (dbPath: string, options: CodexCliOptions): ThreadData => {
16
+ const threadsById = new Map<string, ThreadRow>();
17
+ const parentByChildId = new Map<string, SpawnEdgeRow>();
18
+ const childEdgesByParentId = new Map<string, SpawnEdgeRow[]>();
19
+
20
+ let db: Database | null = null;
21
+
22
+ try {
23
+ db = new Database(dbPath, { readonly: true });
24
+
25
+ const threadQuery = buildThreadQuery(options);
26
+ const threadRows = db.query(threadQuery.sql).all(...threadQuery.params) as ThreadRow[];
27
+
28
+ for (const row of threadRows) {
29
+ threadsById.set(row.id, row);
30
+ }
31
+
32
+ const edgeQuery = buildSpawnEdgeQuery([...threadsById.keys()], options);
33
+ const edgeRows = db.query(edgeQuery.sql).all(...edgeQuery.params) as SpawnEdgeRow[];
34
+
35
+ for (const row of edgeRows) {
36
+ parentByChildId.set(row.child_thread_id, row);
37
+
38
+ const existing = childEdgesByParentId.get(row.parent_thread_id) ?? [];
39
+ existing.push(row);
40
+ childEdgesByParentId.set(row.parent_thread_id, existing);
41
+ }
42
+ } catch (error) {
43
+ const message = error instanceof Error ? error.message : String(error);
44
+ throw new Error(`Failed to read thread database at ${dbPath}: ${message}`);
45
+ } finally {
46
+ db?.close();
47
+ }
48
+
49
+ return {
50
+ childEdgesByParentId,
51
+ parentByChildId,
52
+ threadsById,
53
+ };
54
+ };
55
+
56
+ export const buildThreadQuery = (options: CodexCliOptions) => {
57
+ const clauses: string[] = [];
58
+ const params: string[] = [];
59
+
60
+ if (options.threadIds.length > 0) {
61
+ clauses.push(`id IN (${options.threadIds.map(() => '?').join(', ')})`);
62
+ params.push(...options.threadIds);
63
+ }
64
+
65
+ if (options.cwdFilter) {
66
+ clauses.push('cwd = ?');
67
+ params.push(options.cwdFilter);
68
+ }
69
+
70
+ if (options.projectFilter) {
71
+ clauses.push("(cwd = ? OR cwd LIKE ? ESCAPE '\\' OR cwd LIKE ? ESCAPE '\\')");
72
+ const projectPattern = escapeSqlLike(options.projectFilter);
73
+ params.push(options.projectFilter, `%/${projectPattern}`, `%\\${projectPattern}`);
74
+ }
75
+
76
+ return {
77
+ params,
78
+ sql: clauses.length > 0 ? `SELECT * FROM threads WHERE ${clauses.join(' AND ')}` : 'SELECT * FROM threads',
79
+ };
80
+ };
81
+
82
+ export const buildSpawnEdgeQuery = (threadIds: string[], options: CodexCliOptions) => {
83
+ const hasScopedFilters =
84
+ options.threadIds.length > 0 || options.cwdFilter !== null || options.projectFilter !== null;
85
+
86
+ if (!hasScopedFilters || threadIds.length === 0) {
87
+ return {
88
+ params: [] as string[],
89
+ sql: 'SELECT * FROM thread_spawn_edges',
90
+ };
91
+ }
92
+
93
+ const placeholders = threadIds.map(() => '?').join(', ');
94
+ return {
95
+ params: [...threadIds, ...threadIds],
96
+ sql: `SELECT * FROM thread_spawn_edges WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
97
+ };
98
+ };
99
+
100
+ export const findJsonlFiles = async (rootDir: string): Promise<string[]> => {
101
+ const entries = await readdir(rootDir, { withFileTypes: true });
102
+ const files: string[] = [];
103
+
104
+ for (const entry of entries) {
105
+ const fullPath = path.join(rootDir, entry.name);
106
+
107
+ if (entry.isDirectory()) {
108
+ files.push(...(await findJsonlFiles(fullPath)));
109
+ continue;
110
+ }
111
+
112
+ if (entry.isFile() && fullPath.endsWith('.jsonl')) {
113
+ files.push(fullPath);
114
+ }
115
+ }
116
+
117
+ files.sort();
118
+ return files;
119
+ };
120
+
121
+ export const shouldScanFallbackSessionFiles = (options: CodexCliOptions) => {
122
+ return !options.cwdFilter && !options.projectFilter && options.threadIds.length === 0;
123
+ };
124
+
125
+ export const buildExportTargets = (
126
+ threadData: ThreadData,
127
+ sessionFiles: string[],
128
+ options: CodexCliOptions,
129
+ ): ExportTarget[] => {
130
+ const targets: ExportTarget[] = [];
131
+ const seenSessionFiles = new Set<string>();
132
+ const threadOrder = new Map(options.threadIds.map((threadId, index) => [threadId, index] as const));
133
+
134
+ for (const thread of threadData.threadsById.values()) {
135
+ if (!matchesFilters(thread.cwd, options)) {
136
+ continue;
137
+ }
138
+
139
+ const sessionFile = path.resolve(thread.rollout_path);
140
+ seenSessionFiles.add(sessionFile);
141
+
142
+ targets.push({
143
+ fallbackReason: null,
144
+ outputRelativePath: toOutputRelativePath(sessionFile, options, thread.cwd),
145
+ relations: getRelations(thread.id, threadData),
146
+ sessionFile,
147
+ thread,
148
+ });
149
+ }
150
+
151
+ for (const sessionFile of sessionFiles) {
152
+ const normalized = path.resolve(sessionFile);
153
+ if (seenSessionFiles.has(normalized)) {
154
+ continue;
155
+ }
156
+
157
+ targets.push({
158
+ fallbackReason: 'missing_thread_row',
159
+ outputRelativePath: toOutputRelativePath(normalized, options),
160
+ relations: {
161
+ childEdges: [],
162
+ parentThreadId: null,
163
+ },
164
+ sessionFile: normalized,
165
+ thread: null,
166
+ });
167
+ }
168
+
169
+ if (options.threadIds.length > 0) {
170
+ targets.sort((left, right) => {
171
+ const leftOrder = left.thread
172
+ ? (threadOrder.get(left.thread.id) ?? Number.MAX_SAFE_INTEGER)
173
+ : Number.MAX_SAFE_INTEGER;
174
+ const rightOrder = right.thread
175
+ ? (threadOrder.get(right.thread.id) ?? Number.MAX_SAFE_INTEGER)
176
+ : Number.MAX_SAFE_INTEGER;
177
+
178
+ if (leftOrder !== rightOrder) {
179
+ return leftOrder - rightOrder;
180
+ }
181
+
182
+ return left.outputRelativePath.localeCompare(right.outputRelativePath);
183
+ });
184
+ } else {
185
+ targets.sort((left, right) => left.outputRelativePath.localeCompare(right.outputRelativePath));
186
+ }
187
+
188
+ return options.flat ? ensureUniqueFlatOutputPaths(targets) : targets;
189
+ };
190
+
191
+ export const matchesFilters = (
192
+ value: string | null | undefined,
193
+ options: Pick<CodexCliOptions, 'cwdFilter' | 'projectFilter'>,
194
+ ): boolean => {
195
+ return matchesCwdFilter(value, options.cwdFilter) && matchesProjectFilter(value, options.projectFilter);
196
+ };
197
+
198
+ export const toOutputRelativePath = (
199
+ sessionFile: string,
200
+ options: CodexCliOptions,
201
+ projectCwd?: string | null,
202
+ ): string => {
203
+ const normalized = path.resolve(sessionFile);
204
+ const inputRoot = path.resolve(options.inputDir);
205
+ const codexRoot = path.resolve(DEFAULT_CODEX_DIR);
206
+ const extension = options.outputFormat === 'txt' ? '.txt' : '.md';
207
+ const flatName = toFlatFileName(normalized, extension, projectCwd);
208
+
209
+ if (options.flat) {
210
+ return flatName;
211
+ }
212
+
213
+ if (normalized.startsWith(`${inputRoot}${path.sep}`)) {
214
+ return path.relative(inputRoot, normalized).replace(/\.jsonl$/i, extension);
215
+ }
216
+
217
+ if (normalized.startsWith(`${codexRoot}${path.sep}`)) {
218
+ return path.relative(codexRoot, normalized).replace(/\.jsonl$/i, extension);
219
+ }
220
+
221
+ return path.basename(normalized).replace(/\.jsonl$/i, extension);
222
+ };
223
+
224
+ export const toCodexRelativePath = (targetPath: string): string => {
225
+ const codexRoot = path.resolve(DEFAULT_CODEX_DIR);
226
+ const normalized = path.resolve(targetPath);
227
+
228
+ if (normalized.startsWith(`${codexRoot}${path.sep}`)) {
229
+ return path.relative(codexRoot, normalized);
230
+ }
231
+
232
+ return normalized;
233
+ };
234
+
235
+ const escapeSqlLike = (value: string) => {
236
+ return value.replace(/([\\%_])/g, '\\$1');
237
+ };
238
+
239
+ const getRelations = (threadId: string, threadData: ThreadData): ThreadRelations => {
240
+ const parentEdge = threadData.parentByChildId.get(threadId) ?? null;
241
+ const childEdges = [...(threadData.childEdgesByParentId.get(threadId) ?? [])].sort((left, right) =>
242
+ left.child_thread_id.localeCompare(right.child_thread_id),
243
+ );
244
+
245
+ return {
246
+ childEdges,
247
+ parentThreadId: parentEdge?.parent_thread_id ?? null,
248
+ };
249
+ };
250
+
251
+ const matchesCwdFilter = (value: string | null | undefined, cwdFilter: string | null): boolean => {
252
+ if (!cwdFilter) {
253
+ return true;
254
+ }
255
+
256
+ return value === cwdFilter;
257
+ };
258
+
259
+ const matchesProjectFilter = (value: string | null | undefined, projectFilter: string | null): boolean => {
260
+ if (!projectFilter) {
261
+ return true;
262
+ }
263
+
264
+ if (!value) {
265
+ return false;
266
+ }
267
+
268
+ return getPortablePathBasename(value) === projectFilter;
269
+ };
270
+
271
+ const toFlatFileName = (sessionFile: string, extension: string, projectCwd?: string | null): string => {
272
+ const normalized = path.resolve(sessionFile);
273
+ const codexRoot = path.resolve(DEFAULT_CODEX_DIR);
274
+ const relative = normalized.startsWith(`${codexRoot}${path.sep}`)
275
+ ? path.relative(codexRoot, normalized)
276
+ : path.basename(normalized);
277
+
278
+ const flattened = relative.replace(/[\\/]/g, '__').replace(/\.jsonl$/i, extension);
279
+
280
+ if (!projectCwd) {
281
+ return flattened;
282
+ }
283
+
284
+ const portableProjectName = getPortablePathBasename(projectCwd);
285
+ if (!portableProjectName) {
286
+ return flattened;
287
+ }
288
+
289
+ return `${portableProjectName}${extension}`;
290
+ };
291
+
292
+ const ensureUniqueFlatOutputPaths = (targets: ExportTarget[]): ExportTarget[] => {
293
+ const counts = new Map<string, number>();
294
+ for (const target of targets) {
295
+ counts.set(target.outputRelativePath, (counts.get(target.outputRelativePath) ?? 0) + 1);
296
+ }
297
+
298
+ return targets.map((target) => {
299
+ if ((counts.get(target.outputRelativePath) ?? 0) < 2) {
300
+ return target;
301
+ }
302
+
303
+ const suffix = getFlatCollisionSuffix(target);
304
+ const extension = path.extname(target.outputRelativePath);
305
+ const basename = extension ? target.outputRelativePath.slice(0, -extension.length) : target.outputRelativePath;
306
+
307
+ return {
308
+ ...target,
309
+ outputRelativePath: `${basename}__${suffix}${extension}`,
310
+ };
311
+ });
312
+ };
313
+
314
+ const getFlatCollisionSuffix = (target: ExportTarget): string => {
315
+ if (target.thread?.id) {
316
+ return target.thread.id.slice(0, 8);
317
+ }
318
+
319
+ return path.basename(target.sessionFile, '.jsonl').slice(-8);
320
+ };