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.
- package/AGENTS.md +128 -0
- package/LICENSE.md +7 -0
- package/README.md +182 -0
- package/package.json +70 -0
- package/src/export-chats.ts +134 -0
- package/src/export-claude.ts +36 -0
- package/src/lib/claude-exporter.ts +864 -0
- package/src/lib/codex-exporter-cli.ts +272 -0
- package/src/lib/codex-exporter-db.ts +320 -0
- package/src/lib/codex-exporter-transcript.ts +684 -0
- package/src/lib/codex-exporter-types.ts +110 -0
- package/src/lib/codex-exporter.ts +116 -0
- package/src/lib/interactive-cli.ts +448 -0
- package/src/lib/shared.ts +224 -0
- package/src/mcp-server.ts +136 -0
- package/src/spiracha.ts +72 -0
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
import { access, lstat, readdir } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
asBoolean,
|
|
5
|
+
asNumber,
|
|
6
|
+
asObject,
|
|
7
|
+
asString,
|
|
8
|
+
CliUsageError,
|
|
9
|
+
cleanExtractedText,
|
|
10
|
+
cleanInlineTitle,
|
|
11
|
+
type ExportFormat,
|
|
12
|
+
expandHome,
|
|
13
|
+
formatInlineLiteral,
|
|
14
|
+
type JsonValue,
|
|
15
|
+
type MetadataEntry,
|
|
16
|
+
readJsonlObjects,
|
|
17
|
+
renderCodeBlock,
|
|
18
|
+
renderDocumentTitle,
|
|
19
|
+
renderMetadataBlock,
|
|
20
|
+
renderSection,
|
|
21
|
+
writeExportFile,
|
|
22
|
+
} from './shared';
|
|
23
|
+
|
|
24
|
+
export type ClaudeCliOptions = {
|
|
25
|
+
inputPath: string;
|
|
26
|
+
outputPath: string | null;
|
|
27
|
+
outputFormat: ExportFormat;
|
|
28
|
+
includeTools: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ClaudeExportResult = {
|
|
32
|
+
outputPath: string;
|
|
33
|
+
sourcePath: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ClaudeExportMetadata = {
|
|
37
|
+
sessionId?: string;
|
|
38
|
+
cliSessionId?: string;
|
|
39
|
+
cwd?: string;
|
|
40
|
+
originCwd?: string;
|
|
41
|
+
createdAt?: number;
|
|
42
|
+
lastActivityAt?: number;
|
|
43
|
+
model?: string;
|
|
44
|
+
effort?: string;
|
|
45
|
+
isArchived?: boolean;
|
|
46
|
+
title?: string;
|
|
47
|
+
titleSource?: string;
|
|
48
|
+
permissionMode?: string;
|
|
49
|
+
prNumber?: number;
|
|
50
|
+
prUrl?: string;
|
|
51
|
+
prRepository?: string;
|
|
52
|
+
prState?: string;
|
|
53
|
+
completedTurns?: number;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type ClaudeSource = {
|
|
57
|
+
jsonlPath: string;
|
|
58
|
+
metadataPath: string | null;
|
|
59
|
+
metadata: ClaudeExportMetadata | null;
|
|
60
|
+
outputBaseName: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
type ClaudeSessionMeta = {
|
|
64
|
+
sessionId?: string;
|
|
65
|
+
cwd?: string;
|
|
66
|
+
entrypoint?: string;
|
|
67
|
+
version?: string;
|
|
68
|
+
gitBranch?: string;
|
|
69
|
+
model?: string;
|
|
70
|
+
firstTimestamp?: string;
|
|
71
|
+
lastTimestamp?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type BashToolCall = {
|
|
75
|
+
id: string;
|
|
76
|
+
command: string;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type ConvertState = {
|
|
80
|
+
bashCallsById: Map<string, BashToolCall>;
|
|
81
|
+
firstUserText: string | null;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const DEFAULT_OUTPUT_DIR = path.join(process.cwd(), 'exports', 'claude');
|
|
85
|
+
|
|
86
|
+
export const parseClaudeCliArgs = (argv: string[]): ClaudeCliOptions => {
|
|
87
|
+
let inputPath: string | null = null;
|
|
88
|
+
let outputPath: string | null = null;
|
|
89
|
+
let outputFormat: ExportFormat = 'md';
|
|
90
|
+
let includeTools = false;
|
|
91
|
+
|
|
92
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
93
|
+
const nextIndex = applyClaudeCliArg(argv, index, {
|
|
94
|
+
includeTools,
|
|
95
|
+
inputPath,
|
|
96
|
+
outputFormat,
|
|
97
|
+
outputPath,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
includeTools = nextIndex.state.includeTools;
|
|
101
|
+
inputPath = nextIndex.state.inputPath;
|
|
102
|
+
outputFormat = nextIndex.state.outputFormat;
|
|
103
|
+
outputPath = nextIndex.state.outputPath;
|
|
104
|
+
index = nextIndex.index;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!inputPath) {
|
|
108
|
+
throw new CliUsageError('A Claude export path is required.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
includeTools,
|
|
113
|
+
inputPath,
|
|
114
|
+
outputFormat,
|
|
115
|
+
outputPath,
|
|
116
|
+
};
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
type ClaudeCliState = {
|
|
120
|
+
inputPath: string | null;
|
|
121
|
+
outputPath: string | null;
|
|
122
|
+
outputFormat: ExportFormat;
|
|
123
|
+
includeTools: boolean;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
type ClaudeCliNext = {
|
|
127
|
+
index: number;
|
|
128
|
+
state: ClaudeCliState;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const applyClaudeCliArg = (argv: string[], index: number, state: ClaudeCliState): ClaudeCliNext => {
|
|
132
|
+
const arg = argv[index];
|
|
133
|
+
|
|
134
|
+
if (arg === '--input' || arg === '-i') {
|
|
135
|
+
return {
|
|
136
|
+
index: index + 1,
|
|
137
|
+
state: {
|
|
138
|
+
...state,
|
|
139
|
+
inputPath: expandHome(requireValue(argv[index + 1], arg)),
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (arg === '--output' || arg === '-o') {
|
|
145
|
+
return {
|
|
146
|
+
index: index + 1,
|
|
147
|
+
state: {
|
|
148
|
+
...state,
|
|
149
|
+
outputPath: expandHome(requireValue(argv[index + 1], arg)),
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (arg === '--tools') {
|
|
155
|
+
return {
|
|
156
|
+
index,
|
|
157
|
+
state: {
|
|
158
|
+
...state,
|
|
159
|
+
includeTools: true,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (arg.startsWith('--output-format=')) {
|
|
165
|
+
return {
|
|
166
|
+
index,
|
|
167
|
+
state: {
|
|
168
|
+
...state,
|
|
169
|
+
outputFormat: parseExportFormat(arg.slice('--output-format='.length)),
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (arg === '--output-format') {
|
|
175
|
+
return {
|
|
176
|
+
index: index + 1,
|
|
177
|
+
state: {
|
|
178
|
+
...state,
|
|
179
|
+
outputFormat: parseExportFormat(requireValue(argv[index + 1], '--output-format')),
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (!arg.startsWith('-') && !state.inputPath) {
|
|
185
|
+
return {
|
|
186
|
+
index,
|
|
187
|
+
state: {
|
|
188
|
+
...state,
|
|
189
|
+
inputPath: expandHome(arg),
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (!arg.startsWith('-') && !state.outputPath) {
|
|
195
|
+
return {
|
|
196
|
+
index,
|
|
197
|
+
state: {
|
|
198
|
+
...state,
|
|
199
|
+
outputPath: expandHome(arg),
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
throw new CliUsageError(`Unknown argument: ${arg}`);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
export const getClaudeHelpText = (): string => {
|
|
208
|
+
return [
|
|
209
|
+
'Export a Claude Code transcript JSONL to Markdown or TXT.',
|
|
210
|
+
'',
|
|
211
|
+
'Usage:',
|
|
212
|
+
' codex-chats-claude --input PATH [--output PATH] [--output-format md|txt] [--tools]',
|
|
213
|
+
' codex-chats-claude PATH [OUTPUT_PATH]',
|
|
214
|
+
'',
|
|
215
|
+
'Input:',
|
|
216
|
+
' PATH can be either the exported Claude .jsonl file or the export directory that contains metadata.json.',
|
|
217
|
+
'',
|
|
218
|
+
'Options:',
|
|
219
|
+
' --input, -i Claude transcript file or export directory',
|
|
220
|
+
' --output, -o Output file path, or output directory when no .md/.txt extension is supplied',
|
|
221
|
+
' --output-format Output file format: md or txt (default: md)',
|
|
222
|
+
' --tools Include Bash tool calls and their outputs',
|
|
223
|
+
' --help, -h Show this help text',
|
|
224
|
+
'',
|
|
225
|
+
'Default output:',
|
|
226
|
+
` ${DEFAULT_OUTPUT_DIR}/<session-id>.md`,
|
|
227
|
+
].join('\n');
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
export const runClaudeExport = async (options: ClaudeCliOptions): Promise<ClaudeExportResult> => {
|
|
231
|
+
const source = await resolveClaudeSource(options.inputPath);
|
|
232
|
+
const outputPath = await resolveOutputPath(source, options);
|
|
233
|
+
const content = await convertClaudeTranscript(source, outputPath, options);
|
|
234
|
+
|
|
235
|
+
if (!content) {
|
|
236
|
+
throw new Error(`No transcript content found in ${source.jsonlPath}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await writeExportFile(outputPath, content);
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
outputPath,
|
|
243
|
+
sourcePath: source.jsonlPath,
|
|
244
|
+
};
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const resolveClaudeSource = async (inputPath: string): Promise<ClaudeSource> => {
|
|
248
|
+
const resolvedInput = path.resolve(inputPath);
|
|
249
|
+
const stats = await lstat(resolvedInput).catch(() => null);
|
|
250
|
+
|
|
251
|
+
if (!stats) {
|
|
252
|
+
throw new Error(`Input path does not exist: ${resolvedInput}`);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (stats.isFile()) {
|
|
256
|
+
return await resolveClaudeFileSource(resolvedInput);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!stats.isDirectory()) {
|
|
260
|
+
throw new Error(`Unsupported input path: ${resolvedInput}`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return await resolveClaudeDirectorySource(resolvedInput);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const findMetadataPathForJsonl = async (jsonlPath: string): Promise<string | null> => {
|
|
267
|
+
const parentMetadataPath = path.join(path.dirname(jsonlPath), 'metadata.json');
|
|
268
|
+
return (await fileExists(parentMetadataPath)) ? parentMetadataPath : null;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const readClaudeMetadata = async (metadataPath: string | null): Promise<ClaudeExportMetadata | null> => {
|
|
272
|
+
if (!metadataPath) {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const raw = await Bun.file(metadataPath).text();
|
|
278
|
+
return parseClaudeMetadata(JSON.parse(raw) as Record<string, JsonValue>);
|
|
279
|
+
} catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
const resolveOutputPath = async (source: ClaudeSource, options: ClaudeCliOptions): Promise<string> => {
|
|
285
|
+
const fileName = `${source.outputBaseName}.${options.outputFormat}`;
|
|
286
|
+
|
|
287
|
+
if (!options.outputPath) {
|
|
288
|
+
return path.join(DEFAULT_OUTPUT_DIR, fileName);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const resolvedOutput = path.resolve(options.outputPath);
|
|
292
|
+
const extension = path.extname(resolvedOutput).toLowerCase();
|
|
293
|
+
|
|
294
|
+
if (extension === '.md' || extension === '.txt') {
|
|
295
|
+
return resolvedOutput;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const stats = await lstat(resolvedOutput).catch(() => null);
|
|
299
|
+
if (stats?.isDirectory()) {
|
|
300
|
+
return path.join(resolvedOutput, fileName);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return path.join(resolvedOutput, fileName);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const convertClaudeTranscript = async (
|
|
307
|
+
source: ClaudeSource,
|
|
308
|
+
outputPath: string,
|
|
309
|
+
options: ClaudeCliOptions,
|
|
310
|
+
): Promise<string | null> => {
|
|
311
|
+
const sessionMeta: ClaudeSessionMeta = {
|
|
312
|
+
cwd: source.metadata?.cwd ?? source.metadata?.originCwd,
|
|
313
|
+
model: source.metadata?.model,
|
|
314
|
+
sessionId: source.metadata?.cliSessionId ?? source.metadata?.sessionId,
|
|
315
|
+
};
|
|
316
|
+
const state: ConvertState = {
|
|
317
|
+
bashCallsById: new Map(),
|
|
318
|
+
firstUserText: null,
|
|
319
|
+
};
|
|
320
|
+
const sections: string[] = [];
|
|
321
|
+
|
|
322
|
+
try {
|
|
323
|
+
for await (const parsed of readJsonlObjects(source.jsonlPath)) {
|
|
324
|
+
captureClaudeSessionMeta(parsed, sessionMeta);
|
|
325
|
+
sections.push(...extractClaudeBlocks(parsed, options, state));
|
|
326
|
+
}
|
|
327
|
+
} catch (error) {
|
|
328
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
329
|
+
throw new Error(`Failed to read Claude transcript ${source.jsonlPath}: ${message}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (sections.length === 0) {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const title = getTitle(source, state, sessionMeta);
|
|
337
|
+
const metadata = buildMetadataEntries(source, outputPath, sessionMeta);
|
|
338
|
+
const parts = [
|
|
339
|
+
renderDocumentTitle(title, options.outputFormat),
|
|
340
|
+
'',
|
|
341
|
+
renderMetadataBlock(metadata, options.outputFormat),
|
|
342
|
+
...sections,
|
|
343
|
+
].filter(Boolean);
|
|
344
|
+
|
|
345
|
+
return parts.join('\n').trimEnd() + '\n';
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const resolveClaudeFileSource = async (resolvedInput: string): Promise<ClaudeSource> => {
|
|
349
|
+
if (!resolvedInput.endsWith('.jsonl')) {
|
|
350
|
+
throw new Error(`Expected a .jsonl file, got: ${resolvedInput}`);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const metadataPath = await findMetadataPathForJsonl(resolvedInput);
|
|
354
|
+
const metadata = await readClaudeMetadata(metadataPath);
|
|
355
|
+
|
|
356
|
+
return {
|
|
357
|
+
jsonlPath: resolvedInput,
|
|
358
|
+
metadata,
|
|
359
|
+
metadataPath,
|
|
360
|
+
outputBaseName: metadata?.cliSessionId ?? path.basename(resolvedInput, '.jsonl'),
|
|
361
|
+
};
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const resolveClaudeDirectorySource = async (resolvedInput: string): Promise<ClaudeSource> => {
|
|
365
|
+
const metadataPathCandidate = path.join(resolvedInput, 'metadata.json');
|
|
366
|
+
const metadataPath = (await fileExists(metadataPathCandidate)) ? metadataPathCandidate : null;
|
|
367
|
+
const metadata = await readClaudeMetadata(metadataPath);
|
|
368
|
+
const jsonlPath = await findClaudeJsonlPath(resolvedInput, metadata);
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
jsonlPath,
|
|
372
|
+
metadata,
|
|
373
|
+
metadataPath,
|
|
374
|
+
outputBaseName: metadata?.cliSessionId ?? path.basename(jsonlPath, '.jsonl'),
|
|
375
|
+
};
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const findClaudeJsonlPath = async (resolvedInput: string, metadata: ClaudeExportMetadata | null): Promise<string> => {
|
|
379
|
+
if (metadata?.cliSessionId) {
|
|
380
|
+
const metadataJsonlPath = path.join(resolvedInput, `${metadata.cliSessionId}.jsonl`);
|
|
381
|
+
if (await fileExists(metadataJsonlPath)) {
|
|
382
|
+
return metadataJsonlPath;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const files = (await readdir(resolvedInput, { withFileTypes: true }))
|
|
387
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
|
|
388
|
+
.map((entry) => path.join(resolvedInput, entry.name))
|
|
389
|
+
.sort();
|
|
390
|
+
|
|
391
|
+
if (files.length === 1) {
|
|
392
|
+
return files[0] ?? resolvedInput;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (files.length > 1) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`Multiple top-level .jsonl files found in ${resolvedInput}; pass the specific transcript file instead.`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
throw new Error(`No top-level Claude transcript .jsonl found in ${resolvedInput}`);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const parseClaudeMetadata = (parsed: Record<string, JsonValue>): ClaudeExportMetadata => ({
|
|
405
|
+
...buildClaudeMetadataIdentity(parsed),
|
|
406
|
+
...buildClaudeMetadataContext(parsed),
|
|
407
|
+
...buildClaudeMetadataActivity(parsed),
|
|
408
|
+
...buildClaudeMetadataProject(parsed),
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
const buildClaudeMetadataIdentity = (
|
|
412
|
+
parsed: Record<string, JsonValue>,
|
|
413
|
+
): Pick<ClaudeExportMetadata, 'cliSessionId' | 'sessionId'> => ({
|
|
414
|
+
cliSessionId: asString(parsed.cliSessionId) ?? undefined,
|
|
415
|
+
sessionId: asString(parsed.sessionId) ?? undefined,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const buildClaudeMetadataContext = (
|
|
419
|
+
parsed: Record<string, JsonValue>,
|
|
420
|
+
): Pick<
|
|
421
|
+
ClaudeExportMetadata,
|
|
422
|
+
'cwd' | 'originCwd' | 'model' | 'effort' | 'permissionMode' | 'title' | 'titleSource'
|
|
423
|
+
> => ({
|
|
424
|
+
cwd: asString(parsed.cwd) ?? undefined,
|
|
425
|
+
effort: asString(parsed.effort) ?? undefined,
|
|
426
|
+
model: asString(parsed.model) ?? undefined,
|
|
427
|
+
originCwd: asString(parsed.originCwd) ?? undefined,
|
|
428
|
+
permissionMode: asString(parsed.permissionMode) ?? undefined,
|
|
429
|
+
title: asString(parsed.title) ?? undefined,
|
|
430
|
+
titleSource: asString(parsed.titleSource) ?? undefined,
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
const buildClaudeMetadataActivity = (
|
|
434
|
+
parsed: Record<string, JsonValue>,
|
|
435
|
+
): Pick<ClaudeExportMetadata, 'completedTurns' | 'createdAt' | 'lastActivityAt' | 'isArchived'> => ({
|
|
436
|
+
completedTurns: asNumber(parsed.completedTurns) ?? undefined,
|
|
437
|
+
createdAt: asNumber(parsed.createdAt) ?? undefined,
|
|
438
|
+
isArchived: typeof parsed.isArchived === 'boolean' ? parsed.isArchived : undefined,
|
|
439
|
+
lastActivityAt: asNumber(parsed.lastActivityAt) ?? undefined,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const buildClaudeMetadataProject = (
|
|
443
|
+
parsed: Record<string, JsonValue>,
|
|
444
|
+
): Pick<ClaudeExportMetadata, 'prNumber' | 'prRepository' | 'prState' | 'prUrl'> => ({
|
|
445
|
+
prNumber: asNumber(parsed.prNumber) ?? undefined,
|
|
446
|
+
prRepository: asString(parsed.prRepository) ?? undefined,
|
|
447
|
+
prState: asString(parsed.prState) ?? undefined,
|
|
448
|
+
prUrl: asString(parsed.prUrl) ?? undefined,
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const captureClaudeSessionMeta = (parsed: Record<string, JsonValue>, meta: ClaudeSessionMeta) => {
|
|
452
|
+
meta.sessionId = asString(parsed.sessionId) ?? meta.sessionId;
|
|
453
|
+
meta.cwd = asString(parsed.cwd) ?? meta.cwd;
|
|
454
|
+
meta.entrypoint = asString(parsed.entrypoint) ?? meta.entrypoint;
|
|
455
|
+
meta.version = asString(parsed.version) ?? meta.version;
|
|
456
|
+
meta.gitBranch = asString(parsed.gitBranch) ?? meta.gitBranch;
|
|
457
|
+
|
|
458
|
+
const timestamp = asString(parsed.timestamp);
|
|
459
|
+
if (timestamp) {
|
|
460
|
+
meta.firstTimestamp = meta.firstTimestamp && meta.firstTimestamp < timestamp ? meta.firstTimestamp : timestamp;
|
|
461
|
+
meta.lastTimestamp = meta.lastTimestamp && meta.lastTimestamp > timestamp ? meta.lastTimestamp : timestamp;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const message = asObject(parsed.message);
|
|
465
|
+
meta.model = asString(message?.model ?? null) ?? meta.model;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const extractClaudeBlocks = (
|
|
469
|
+
parsed: Record<string, JsonValue>,
|
|
470
|
+
options: ClaudeCliOptions,
|
|
471
|
+
state: ConvertState,
|
|
472
|
+
): string[] => {
|
|
473
|
+
if (asBoolean(parsed.isCompactSummary)) {
|
|
474
|
+
return [];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const type = asString(parsed.type);
|
|
478
|
+
if (type === 'assistant') {
|
|
479
|
+
return extractAssistantBlocks(parsed, options, state);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (type === 'user') {
|
|
483
|
+
return extractUserBlocks(parsed, options, state);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
return [];
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const extractAssistantBlocks = (
|
|
490
|
+
parsed: Record<string, JsonValue>,
|
|
491
|
+
options: ClaudeCliOptions,
|
|
492
|
+
state: ConvertState,
|
|
493
|
+
): string[] => {
|
|
494
|
+
const message = asObject(parsed.message);
|
|
495
|
+
if (!message || asString(message.role) !== 'assistant') {
|
|
496
|
+
return [];
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
return extractBlocksFromContentSequence('assistant', message.content, options, state);
|
|
500
|
+
};
|
|
501
|
+
|
|
502
|
+
const extractUserBlocks = (
|
|
503
|
+
parsed: Record<string, JsonValue>,
|
|
504
|
+
options: ClaudeCliOptions,
|
|
505
|
+
state: ConvertState,
|
|
506
|
+
): string[] => {
|
|
507
|
+
const message = asObject(parsed.message);
|
|
508
|
+
if (!message || asString(message.role) !== 'user') {
|
|
509
|
+
return [];
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return extractBlocksFromContentSequence('user', message.content, options, state);
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
const extractBlocksFromContentSequence = (
|
|
516
|
+
role: 'user' | 'assistant',
|
|
517
|
+
content: JsonValue,
|
|
518
|
+
options: ClaudeCliOptions,
|
|
519
|
+
state: ConvertState,
|
|
520
|
+
): string[] => {
|
|
521
|
+
const items = Array.isArray(content) ? content : [content];
|
|
522
|
+
const blocks: string[] = [];
|
|
523
|
+
const textParts: string[] = [];
|
|
524
|
+
|
|
525
|
+
const flushText = () => {
|
|
526
|
+
const text = cleanExtractedText(textParts.join('\n\n')).trim();
|
|
527
|
+
textParts.length = 0;
|
|
528
|
+
|
|
529
|
+
if (!text) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (role === 'user' && !state.firstUserText) {
|
|
534
|
+
state.firstUserText = text;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
blocks.push(renderSection(role === 'user' ? 'User' : 'Assistant', text, options.outputFormat));
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
for (const item of items) {
|
|
541
|
+
appendClaudeContentItem(item, options, state, blocks, textParts, flushText);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
flushText();
|
|
545
|
+
return blocks;
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
const appendClaudeContentItem = (
|
|
549
|
+
item: JsonValue,
|
|
550
|
+
options: ClaudeCliOptions,
|
|
551
|
+
state: ConvertState,
|
|
552
|
+
blocks: string[],
|
|
553
|
+
textParts: string[],
|
|
554
|
+
flushText: () => void,
|
|
555
|
+
) => {
|
|
556
|
+
if (typeof item === 'string') {
|
|
557
|
+
textParts.push(item);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const contentItem = asObject(item);
|
|
562
|
+
if (!contentItem) {
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
appendClaudeStructuredContentItem(contentItem, options, state, blocks, textParts, flushText);
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const appendClaudeStructuredContentItem = (
|
|
570
|
+
contentItem: Record<string, JsonValue>,
|
|
571
|
+
options: ClaudeCliOptions,
|
|
572
|
+
state: ConvertState,
|
|
573
|
+
blocks: string[],
|
|
574
|
+
textParts: string[],
|
|
575
|
+
flushText: () => void,
|
|
576
|
+
) => {
|
|
577
|
+
const type = asString(contentItem.type);
|
|
578
|
+
if (isClaudeTextContentType(type)) {
|
|
579
|
+
appendClaudeTextContentItem(contentItem, textParts);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (type === 'thinking') {
|
|
584
|
+
flushText();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (type === 'tool_use') {
|
|
589
|
+
appendClaudeToolUseContentItem(contentItem, options, state, blocks, flushText);
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (type === 'tool_result') {
|
|
594
|
+
appendClaudeToolResultContentItem(contentItem, options, state, blocks, flushText);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
appendClaudeFallbackContentItem(contentItem, textParts);
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const appendClaudeTextContentItem = (contentItem: Record<string, JsonValue>, textParts: string[]) => {
|
|
602
|
+
const text = asString(contentItem.text);
|
|
603
|
+
if (text) {
|
|
604
|
+
textParts.push(text);
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
const appendClaudeToolUseContentItem = (
|
|
609
|
+
contentItem: Record<string, JsonValue>,
|
|
610
|
+
options: ClaudeCliOptions,
|
|
611
|
+
state: ConvertState,
|
|
612
|
+
blocks: string[],
|
|
613
|
+
flushText: () => void,
|
|
614
|
+
) => {
|
|
615
|
+
flushText();
|
|
616
|
+
captureBashToolCall(contentItem, state);
|
|
617
|
+
|
|
618
|
+
if (options.includeTools) {
|
|
619
|
+
const block = renderToolCallBlock(contentItem, options.outputFormat);
|
|
620
|
+
if (block) {
|
|
621
|
+
blocks.push(block);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const appendClaudeToolResultContentItem = (
|
|
627
|
+
contentItem: Record<string, JsonValue>,
|
|
628
|
+
options: ClaudeCliOptions,
|
|
629
|
+
state: ConvertState,
|
|
630
|
+
blocks: string[],
|
|
631
|
+
flushText: () => void,
|
|
632
|
+
) => {
|
|
633
|
+
flushText();
|
|
634
|
+
|
|
635
|
+
if (options.includeTools) {
|
|
636
|
+
const block = renderToolResultBlock(contentItem, state, options.outputFormat);
|
|
637
|
+
if (block) {
|
|
638
|
+
blocks.push(block);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const appendClaudeFallbackContentItem = (contentItem: Record<string, JsonValue>, textParts: string[]) => {
|
|
644
|
+
const fallbackText = asString(contentItem.text);
|
|
645
|
+
if (fallbackText) {
|
|
646
|
+
textParts.push(fallbackText);
|
|
647
|
+
}
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
const isClaudeTextContentType = (type: string | null): boolean =>
|
|
651
|
+
type === 'text' || type === 'input_text' || type === 'output_text';
|
|
652
|
+
|
|
653
|
+
const captureBashToolCall = (contentItem: Record<string, JsonValue>, state: ConvertState) => {
|
|
654
|
+
if (asString(contentItem.name) !== 'Bash') {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const toolId = asString(contentItem.id);
|
|
659
|
+
const input = asObject(contentItem.input);
|
|
660
|
+
const command = asString(input?.command ?? null);
|
|
661
|
+
|
|
662
|
+
if (!toolId || !command) {
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
state.bashCallsById.set(toolId, {
|
|
667
|
+
command,
|
|
668
|
+
id: toolId,
|
|
669
|
+
});
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
const renderToolCallBlock = (contentItem: Record<string, JsonValue>, outputFormat: ExportFormat): string => {
|
|
673
|
+
if (asString(contentItem.name) !== 'Bash') {
|
|
674
|
+
return '';
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const input = asObject(contentItem.input);
|
|
678
|
+
const command = asString(input?.command ?? null)?.trim();
|
|
679
|
+
if (!command) {
|
|
680
|
+
return '';
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return renderSection('Tool', `Command: ${formatInlineLiteral(command, outputFormat)}`, outputFormat);
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
const renderToolResultBlock = (
|
|
687
|
+
contentItem: Record<string, JsonValue>,
|
|
688
|
+
state: ConvertState,
|
|
689
|
+
outputFormat: ExportFormat,
|
|
690
|
+
): string => {
|
|
691
|
+
const toolUseId = asString(contentItem.tool_use_id);
|
|
692
|
+
if (!toolUseId || !state.bashCallsById.has(toolUseId)) {
|
|
693
|
+
return '';
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const outputText = cleanExtractedText(extractClaudeText(contentItem.content)).trim();
|
|
697
|
+
if (!outputText) {
|
|
698
|
+
return '';
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
const lines: string[] = [];
|
|
702
|
+
if (asBoolean(contentItem.is_error)) {
|
|
703
|
+
lines.push('Error: true', '');
|
|
704
|
+
}
|
|
705
|
+
lines.push(renderCodeBlock(outputText, outputFormat));
|
|
706
|
+
|
|
707
|
+
return renderSection('Tool Output', lines.join('\n'), outputFormat);
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
const getTitle = (source: ClaudeSource, state: ConvertState, sessionMeta: ClaudeSessionMeta): string => {
|
|
711
|
+
if (source.metadata?.title) {
|
|
712
|
+
return cleanInlineTitle(source.metadata.title);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (state.firstUserText) {
|
|
716
|
+
return cleanInlineTitle(state.firstUserText);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return source.metadata?.cliSessionId ?? sessionMeta.sessionId ?? path.basename(source.jsonlPath, '.jsonl');
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
const buildMetadataEntries = (
|
|
723
|
+
source: ClaudeSource,
|
|
724
|
+
outputPath: string,
|
|
725
|
+
sessionMeta: ClaudeSessionMeta,
|
|
726
|
+
): MetadataEntry[] => {
|
|
727
|
+
return [
|
|
728
|
+
{ key: 'exported_from', value: 'claude_code_session_export_jsonl' },
|
|
729
|
+
...buildClaudeSourceMetadataEntries(source, outputPath),
|
|
730
|
+
...buildClaudeTimelineMetadataEntries(source, sessionMeta),
|
|
731
|
+
...buildClaudePullRequestMetadataEntries(source),
|
|
732
|
+
];
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
const buildClaudeSourceMetadataEntries = (source: ClaudeSource, outputPath: string): MetadataEntry[] => {
|
|
736
|
+
return [
|
|
737
|
+
{
|
|
738
|
+
key: 'session_id',
|
|
739
|
+
value: source.metadata?.cliSessionId ?? source.metadata?.sessionId ?? null,
|
|
740
|
+
},
|
|
741
|
+
{ key: 'title', value: source.metadata?.title ?? null },
|
|
742
|
+
{ key: 'source_transcript_path', value: source.jsonlPath },
|
|
743
|
+
{ key: 'source_metadata_path', value: source.metadataPath },
|
|
744
|
+
{ key: 'output_path', value: outputPath },
|
|
745
|
+
];
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
const buildClaudeTimelineMetadataEntries = (source: ClaudeSource, sessionMeta: ClaudeSessionMeta): MetadataEntry[] => {
|
|
749
|
+
return [
|
|
750
|
+
{
|
|
751
|
+
key: 'cwd',
|
|
752
|
+
value: source.metadata?.cwd ?? source.metadata?.originCwd ?? sessionMeta.cwd ?? null,
|
|
753
|
+
},
|
|
754
|
+
{ key: 'entrypoint', value: sessionMeta.entrypoint ?? null },
|
|
755
|
+
{ key: 'model', value: source.metadata?.model ?? sessionMeta.model ?? null },
|
|
756
|
+
{ key: 'effort', value: source.metadata?.effort ?? null },
|
|
757
|
+
{ key: 'permission_mode', value: source.metadata?.permissionMode ?? null },
|
|
758
|
+
{ key: 'is_archived', value: source.metadata?.isArchived ?? null },
|
|
759
|
+
{ key: 'completed_turns', value: source.metadata?.completedTurns ?? null },
|
|
760
|
+
{ key: 'created_at_unix_ms', value: source.metadata?.createdAt ?? null },
|
|
761
|
+
{ key: 'created_at_iso', value: formatUnixMillis(source.metadata?.createdAt ?? null) },
|
|
762
|
+
{
|
|
763
|
+
key: 'last_activity_at_unix_ms',
|
|
764
|
+
value: source.metadata?.lastActivityAt ?? null,
|
|
765
|
+
},
|
|
766
|
+
{
|
|
767
|
+
key: 'last_activity_at_iso',
|
|
768
|
+
value: formatUnixMillis(source.metadata?.lastActivityAt ?? null),
|
|
769
|
+
},
|
|
770
|
+
{ key: 'first_event_at_iso', value: sessionMeta.firstTimestamp ?? null },
|
|
771
|
+
{ key: 'last_event_at_iso', value: sessionMeta.lastTimestamp ?? null },
|
|
772
|
+
{ key: 'version', value: sessionMeta.version ?? null },
|
|
773
|
+
{ key: 'git_branch', value: sessionMeta.gitBranch ?? null },
|
|
774
|
+
];
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const buildClaudePullRequestMetadataEntries = (source: ClaudeSource): MetadataEntry[] => {
|
|
778
|
+
return [
|
|
779
|
+
{ key: 'pr_number', value: source.metadata?.prNumber ?? null },
|
|
780
|
+
{ key: 'pr_url', value: source.metadata?.prUrl ?? null },
|
|
781
|
+
{ key: 'pr_repository', value: source.metadata?.prRepository ?? null },
|
|
782
|
+
{ key: 'pr_state', value: source.metadata?.prState ?? null },
|
|
783
|
+
];
|
|
784
|
+
};
|
|
785
|
+
|
|
786
|
+
const formatUnixMillis = (value: number | null): string | null => {
|
|
787
|
+
if (value === null || value === undefined) {
|
|
788
|
+
return null;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return new Date(value).toISOString();
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const extractClaudeText = (content: JsonValue): string => {
|
|
795
|
+
if (typeof content === 'string') {
|
|
796
|
+
return content;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
if (Array.isArray(content)) {
|
|
800
|
+
return content
|
|
801
|
+
.map((item) => extractClaudeContentPart(item))
|
|
802
|
+
.filter((part) => part.length > 0)
|
|
803
|
+
.join('\n\n');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const object = asObject(content);
|
|
807
|
+
if (!object) {
|
|
808
|
+
return '';
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const type = asString(object.type);
|
|
812
|
+
const text = asString(object.text);
|
|
813
|
+
|
|
814
|
+
if ((type === 'text' || type === 'input_text' || type === 'output_text') && text) {
|
|
815
|
+
return text;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return text ?? '';
|
|
819
|
+
};
|
|
820
|
+
|
|
821
|
+
const extractClaudeContentPart = (value: JsonValue): string => {
|
|
822
|
+
if (typeof value === 'string') {
|
|
823
|
+
return value;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const object = asObject(value);
|
|
827
|
+
if (!object) {
|
|
828
|
+
return '';
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const type = asString(object.type);
|
|
832
|
+
const text = asString(object.text);
|
|
833
|
+
|
|
834
|
+
if ((type === 'text' || type === 'input_text' || type === 'output_text') && text) {
|
|
835
|
+
return text;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
return text ?? '';
|
|
839
|
+
};
|
|
840
|
+
|
|
841
|
+
const fileExists = async (targetPath: string): Promise<boolean> => {
|
|
842
|
+
try {
|
|
843
|
+
await access(targetPath);
|
|
844
|
+
return true;
|
|
845
|
+
} catch {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
|
|
850
|
+
const requireValue = (value: string | undefined, flag: string): string => {
|
|
851
|
+
if (!value || value.startsWith('--')) {
|
|
852
|
+
throw new CliUsageError(`Missing value for ${flag}`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return value;
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
const parseExportFormat = (value: string): ExportFormat => {
|
|
859
|
+
if (value === 'md' || value === 'txt') {
|
|
860
|
+
return value;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
throw new CliUsageError(`Unsupported output format: ${value}`);
|
|
864
|
+
};
|