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,224 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { createInterface } from 'node:readline';
6
+
7
+ export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
8
+
9
+ export type ExportFormat = 'md' | 'txt';
10
+
11
+ export type MetadataEntry = {
12
+ key: string;
13
+ value: unknown;
14
+ };
15
+
16
+ export class CliUsageError extends Error {}
17
+
18
+ export const expandHome = (value: string): string => {
19
+ if (!value) {
20
+ return value;
21
+ }
22
+
23
+ if (value === '~') {
24
+ return os.homedir();
25
+ }
26
+
27
+ if (value.startsWith('~/')) {
28
+ return path.join(os.homedir(), value.slice(2));
29
+ }
30
+
31
+ return value;
32
+ };
33
+
34
+ export const getPortablePathBasename = (value: string): string => {
35
+ const trimmed = value.replace(/[\\/]+$/u, '');
36
+ if (!trimmed) {
37
+ return '';
38
+ }
39
+
40
+ return path.win32.basename(path.posix.basename(trimmed));
41
+ };
42
+
43
+ export const cleanInlineTitle = (value: string): string => {
44
+ const firstLine =
45
+ value
46
+ .split('\n')
47
+ .map((line) => line.trim())
48
+ .find((line) => line.length > 0) ?? '';
49
+ const compact = firstLine.replace(/\s+/g, ' ').trim();
50
+
51
+ if (compact.length <= 160) {
52
+ return compact;
53
+ }
54
+
55
+ return `${compact.slice(0, 157).trimEnd()}...`;
56
+ };
57
+
58
+ export const cleanExtractedText = (text: string): string => {
59
+ return text.replace(/^\s*<\/?image>\s*$/gm, '').replace(/\n{3,}/g, '\n\n');
60
+ };
61
+
62
+ export const asObject = (value: JsonValue): Record<string, JsonValue> | null => {
63
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
64
+ return null;
65
+ }
66
+
67
+ return value as Record<string, JsonValue>;
68
+ };
69
+
70
+ export const asString = (value: JsonValue): string | null => {
71
+ return typeof value === 'string' ? value : null;
72
+ };
73
+
74
+ export const asNumber = (value: JsonValue): number | null => {
75
+ return typeof value === 'number' ? value : null;
76
+ };
77
+
78
+ export const asBoolean = (value: JsonValue): boolean => {
79
+ return value === true;
80
+ };
81
+
82
+ export const readJsonlObjects = (filePath: string): AsyncIterableIterator<Record<string, JsonValue>> => {
83
+ const stream = createReadStream(filePath, { encoding: 'utf8' });
84
+ const lines = createInterface({
85
+ crlfDelay: Infinity,
86
+ input: stream,
87
+ });
88
+ const lineIterator = lines[Symbol.asyncIterator]();
89
+ let closed = false;
90
+
91
+ const close = () => {
92
+ if (closed) {
93
+ return;
94
+ }
95
+
96
+ closed = true;
97
+ lines.close();
98
+ stream.destroy();
99
+ };
100
+
101
+ const readNext = async (): Promise<IteratorResult<Record<string, JsonValue>>> => {
102
+ while (true) {
103
+ const nextLine = await lineIterator.next();
104
+ if (nextLine.done) {
105
+ close();
106
+ return { done: true, value: undefined as never };
107
+ }
108
+
109
+ const trimmed = nextLine.value.trim();
110
+ if (!trimmed) {
111
+ continue;
112
+ }
113
+
114
+ try {
115
+ return {
116
+ done: false,
117
+ value: JSON.parse(trimmed) as Record<string, JsonValue>,
118
+ };
119
+ } catch {}
120
+ }
121
+ };
122
+
123
+ const iterator: AsyncIterableIterator<Record<string, JsonValue>> = {
124
+ [Symbol.asyncIterator]: () => iterator,
125
+ next: async () => readNext(),
126
+ return: async () => {
127
+ close();
128
+ return { done: true, value: undefined as never };
129
+ },
130
+ throw: async (error?: unknown) => {
131
+ close();
132
+ throw error;
133
+ },
134
+ };
135
+
136
+ return iterator;
137
+ };
138
+
139
+ export const renderDocumentTitle = (title: string, format: ExportFormat): string => {
140
+ if (format === 'md') {
141
+ return `# ${title}`;
142
+ }
143
+
144
+ return [title, '='.repeat(Math.max(title.length, 3))].join('\n');
145
+ };
146
+
147
+ export const renderMetadataBlock = (entries: MetadataEntry[], format: ExportFormat): string => {
148
+ const filteredEntries = entries.filter(
149
+ (entry) => entry.value !== null && entry.value !== undefined && entry.value !== '',
150
+ );
151
+
152
+ if (filteredEntries.length === 0) {
153
+ return '';
154
+ }
155
+
156
+ if (format === 'md') {
157
+ const lines = ['---'];
158
+ for (const entry of filteredEntries) {
159
+ lines.push(`${entry.key}: ${toMetadataValue(entry.value, 'md')}`);
160
+ }
161
+ lines.push('---');
162
+ return `${lines.join('\n')}\n`;
163
+ }
164
+
165
+ const lines = ['Metadata', '--------'];
166
+ for (const entry of filteredEntries) {
167
+ lines.push(`${entry.key}: ${toMetadataValue(entry.value, 'txt')}`);
168
+ }
169
+ return `${lines.join('\n')}\n`;
170
+ };
171
+
172
+ export const renderSection = (title: string, body: string, format: ExportFormat): string => {
173
+ const trimmedBody = body.trimEnd();
174
+ if (!trimmedBody) {
175
+ return '';
176
+ }
177
+
178
+ if (format === 'md') {
179
+ return `## ${title}\n\n${trimmedBody}\n`;
180
+ }
181
+
182
+ return `${title}\n${'-'.repeat(Math.max(title.length, 3))}\n${trimmedBody}\n`;
183
+ };
184
+
185
+ export const renderCodeBlock = (text: string, format: ExportFormat): string => {
186
+ if (format === 'md') {
187
+ return `\`\`\`text\n${text}\n\`\`\``;
188
+ }
189
+
190
+ return text;
191
+ };
192
+
193
+ export const formatInlineLiteral = (value: string, format: ExportFormat): string => {
194
+ return format === 'md' ? inlineCode(value) : value;
195
+ };
196
+
197
+ export const inlineCode = (value: string): string => {
198
+ const backtickRuns = value.match(/`+/g) ?? [];
199
+ const maxRunLength = backtickRuns.reduce((max, run) => Math.max(max, run.length), 0);
200
+ const fence = '`'.repeat(maxRunLength + 1);
201
+ const padded = value.startsWith('`') || value.endsWith('`') ? ` ${value} ` : value;
202
+ return `${fence}${padded}${fence}`;
203
+ };
204
+
205
+ export const writeExportFile = async (outputPath: string, content: string) => {
206
+ await mkdir(path.dirname(outputPath), { recursive: true });
207
+ await Bun.write(outputPath, content);
208
+ };
209
+
210
+ const toMetadataValue = (value: unknown, format: ExportFormat): string => {
211
+ if (Array.isArray(value) || (value && typeof value === 'object')) {
212
+ return JSON.stringify(value);
213
+ }
214
+
215
+ if (typeof value === 'string') {
216
+ return format === 'md' ? JSON.stringify(value) : value;
217
+ }
218
+
219
+ if (typeof value === 'boolean' || typeof value === 'number') {
220
+ return String(value);
221
+ }
222
+
223
+ return format === 'md' ? JSON.stringify(String(value)) : String(value);
224
+ };
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import type { AnySchema } from '@modelcontextprotocol/sdk/server/zod-compat.js';
6
+ import { z } from 'zod';
7
+ import { runClaudeExport } from './lib/claude-exporter';
8
+ import {
9
+ DEFAULT_DB_PATH,
10
+ DEFAULT_INPUT_DIR,
11
+ parseThreadSelectionArg,
12
+ resolveDefaultOutputDir,
13
+ runCodexExport,
14
+ } from './lib/codex-exporter';
15
+ import { expandHome } from './lib/shared';
16
+
17
+ const server = new McpServer({
18
+ name: 'codex-chats-export',
19
+ version: '0.1.0',
20
+ });
21
+
22
+ const exportCodexChatsInputSchema = {
23
+ cwd: z.string().optional().describe('Optional exact cwd filter'),
24
+ dbPath: z.string().optional().describe('Optional override for the Codex SQLite database'),
25
+ deeplinks: z.array(z.string()).optional().describe('Optional Codex deeplinks like codex://threads/<thread-id>'),
26
+ flat: z.boolean().optional().describe('Write output into a single flat folder'),
27
+ includeTools: z.boolean().optional().describe('Include exec_command tool logs'),
28
+ inputDir: z.string().optional().describe('Optional override for the Codex sessions directory'),
29
+ optimized: z.boolean().optional().describe('Suppress metadata and optimize for compact text'),
30
+ outputDir: z.string().optional().describe('Optional output directory'),
31
+ outputFormat: z.enum(['md', 'txt']).optional().describe('Output format'),
32
+ project: z.string().optional().describe('Optional project name matched against path basename'),
33
+ } as unknown as Record<string, AnySchema>;
34
+
35
+ const exportClaudeTranscriptInputSchema = {
36
+ includeTools: z.boolean().optional().describe('Include Bash tool calls and outputs'),
37
+ inputPath: z.string().describe('Path to a Claude transcript .jsonl file or export directory'),
38
+ outputFormat: z.enum(['md', 'txt']).optional().describe('Output format'),
39
+ outputPath: z.string().optional().describe('Optional output file path or directory'),
40
+ } as unknown as Record<string, AnySchema>;
41
+
42
+ server.registerTool(
43
+ 'export_codex_chats',
44
+ {
45
+ description: 'Export Codex chats by deeplink, project name, or cwd to markdown or plain text.',
46
+ inputSchema: exportCodexChatsInputSchema,
47
+ },
48
+ async (args: any) => {
49
+ const threadIds = parseThreadSelections(args.deeplinks ?? []);
50
+ const cwdFilter = args.cwd ? expandHome(args.cwd) : null;
51
+
52
+ if (threadIds.length === 0 && !args.project && !cwdFilter) {
53
+ throw new Error(
54
+ 'Provide at least one deeplink, project, or cwd filter to avoid exporting the entire Codex history by accident.',
55
+ );
56
+ }
57
+
58
+ const result = await runCodexExport({
59
+ cwdFilter,
60
+ dbPath: expandHome(args.dbPath ?? DEFAULT_DB_PATH),
61
+ flat: args.flat ?? false,
62
+ includeTools: args.includeTools ?? false,
63
+ inputDir: expandHome(args.inputDir ?? DEFAULT_INPUT_DIR),
64
+ optimized: args.optimized ?? false,
65
+ outputDir: args.outputDir ? expandHome(args.outputDir) : resolveDefaultOutputDir(cwdFilter),
66
+ outputFormat: args.outputFormat ?? 'md',
67
+ projectFilter: args.project ?? null,
68
+ threadIds,
69
+ });
70
+
71
+ return {
72
+ content: [
73
+ {
74
+ text: JSON.stringify(
75
+ {
76
+ exportedCount: result.exportedCount,
77
+ files: result.files,
78
+ missingThreadIds: result.missingThreadIds,
79
+ outputDir: result.outputDir,
80
+ },
81
+ null,
82
+ 2,
83
+ ),
84
+ type: 'text',
85
+ },
86
+ ],
87
+ };
88
+ },
89
+ );
90
+
91
+ server.registerTool(
92
+ 'export_claude_transcript',
93
+ {
94
+ description: 'Export a Claude Code transcript JSONL or export directory to markdown or plain text.',
95
+ inputSchema: exportClaudeTranscriptInputSchema,
96
+ },
97
+ async (args: any) => {
98
+ const result = await runClaudeExport({
99
+ includeTools: args.includeTools ?? false,
100
+ inputPath: expandHome(args.inputPath),
101
+ outputFormat: args.outputFormat ?? 'md',
102
+ outputPath: args.outputPath ? expandHome(args.outputPath) : null,
103
+ });
104
+
105
+ return {
106
+ content: [
107
+ {
108
+ text: JSON.stringify(result, null, 2),
109
+ type: 'text',
110
+ },
111
+ ],
112
+ };
113
+ },
114
+ );
115
+
116
+ const main = async () => {
117
+ const transport = new StdioServerTransport();
118
+ await server.connect(transport);
119
+ };
120
+
121
+ const parseThreadSelections = (deeplinks: string[]): string[] => {
122
+ return deeplinks.map((deeplink) => {
123
+ const threadId = parseThreadSelectionArg(deeplink);
124
+ if (!threadId) {
125
+ throw new Error(`Invalid Codex deeplink: ${deeplink}. Expected codex://threads/<thread-id>`);
126
+ }
127
+
128
+ return threadId;
129
+ });
130
+ };
131
+
132
+ main().catch((error) => {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ console.error(message);
135
+ process.exit(1);
136
+ });
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { runExportChatsCli } from './export-chats';
4
+ import { runExportClaudeCli } from './export-claude';
5
+
6
+ type SpirachaCommandKind = 'codex' | 'claude' | 'help';
7
+
8
+ type SpirachaInvocation = {
9
+ kind: SpirachaCommandKind;
10
+ argv: string[];
11
+ };
12
+
13
+ export const resolveSpirachaInvocation = (argv: string[]): SpirachaInvocation => {
14
+ const [firstArg, ...rest] = argv;
15
+
16
+ if (firstArg === 'claude') {
17
+ return { argv: rest, kind: 'claude' };
18
+ }
19
+
20
+ if (firstArg === 'codex') {
21
+ return { argv: rest, kind: 'codex' };
22
+ }
23
+
24
+ if (firstArg === '--help' || firstArg === '-h' || firstArg === 'help') {
25
+ return { argv: [], kind: 'help' };
26
+ }
27
+
28
+ return { argv, kind: 'codex' };
29
+ };
30
+
31
+ export const getSpirachaHelpText = (): string => {
32
+ return [
33
+ 'spiracha - export Codex chats and Claude transcripts',
34
+ '',
35
+ 'Usage:',
36
+ ' spiracha',
37
+ ' spiracha codex [Codex options]',
38
+ ' spiracha claude [Claude options]',
39
+ '',
40
+ 'Commands:',
41
+ ' codex Export Codex chats (default when no subcommand is provided)',
42
+ ' claude Export a Claude transcript file or export directory',
43
+ '',
44
+ 'Aliases:',
45
+ ' codex-chats',
46
+ ' codex-chats-claude',
47
+ '',
48
+ 'For command-specific help:',
49
+ ' spiracha codex --help',
50
+ ' spiracha claude --help',
51
+ ].join('\n');
52
+ };
53
+
54
+ export const runSpirachaCli = async (argv = process.argv.slice(2)): Promise<void> => {
55
+ const invocation = resolveSpirachaInvocation(argv);
56
+
57
+ if (invocation.kind === 'help') {
58
+ console.log(getSpirachaHelpText());
59
+ return;
60
+ }
61
+
62
+ if (invocation.kind === 'claude') {
63
+ await runExportClaudeCli(invocation.argv);
64
+ return;
65
+ }
66
+
67
+ await runExportChatsCli(invocation.argv);
68
+ };
69
+
70
+ if (import.meta.main) {
71
+ await runSpirachaCli();
72
+ }