Skip to content

Instantly share code, notes, and snippets.

@rblaine95
Created June 17, 2026 13:11
Show Gist options
  • Select an option

  • Save rblaine95/50320fa9937109346e9a2316cb7b5d80 to your computer and use it in GitHub Desktop.

Select an option

Save rblaine95/50320fa9937109346e9a2316cb7b5d80 to your computer and use it in GitHub Desktop.
Claude Code -> Oh-My-Pi session importer
#!/usr/bin/env bun
/**
* claude-to-omp.ts: Convert Claude Code session transcripts into oh-my-pi (omp) v3 sessions.
*
* WHY THIS EXISTS
* ---------------
* Claude Code stores one JSONL transcript per session under:
* ~/.claude/projects/<abs-path-encoded>/<uuid>.jsonl
* Every line is a flat, `uuid`/`parentUuid`-linked event using Anthropic message
* shapes (`{type:"text"|"thinking"|"tool_use"|"tool_result"|"image", ...}`).
*
* omp stores sessions under:
* ~/.omp/agent/sessions/<home-relative-encoded-cwd>/<timestamp>_<sessionId>.jsonl
* Line 1 is a `{"type":"session","version":3,...}` header; the remaining lines are
* tree entries (`{"type":"message","id","parentId","message":{...}}`) plus state
* changes. omp's loader returns an empty session for any file whose first entry is
* not a session header, which is why Claude transcripts cannot simply be dropped
* into omp's store; they must be converted.
*
* This script performs that conversion, preserving the conversation tree, thinking
* blocks, tool calls, tool results, and inline base64 images. Tool names and inputs
* are kept verbatim (NOT remapped onto omp's native tool surface) so the imported
* history stays faithful to what actually happened.
*
* USAGE
* -----
* bun claude-to-omp.ts [options] [paths...]
*
* Options:
* --claude-dir <dir> Claude projects root (default: ~/.claude/projects)
* --out <dir> omp sessions root (default: ~/.omp/agent/sessions)
* --dry-run Parse and report only; write nothing
* --include-sidechains Include subagent (Task) sidechain messages (default: skip)
* --overwrite Re-import even if a converted copy already exists
* --limit <n> Convert at most n transcripts
* -h, --help Show this help and exit
*
* Positional paths (optional): specific `.jsonl` files or directories to convert.
* When supplied, they replace the default scan of --claude-dir.
*
* IDEMPOTENCY
* -----------
* Each imported session records `parentSession: "claude-import:<sourceSessionId>"`
* in its header. Before writing, the script scans the destination project directory
* for an existing import of the same source session and skips it unless --overwrite
* is set, so re-running never duplicates sessions.
*
* Requires Bun (uses `Bun.file`, `Bun.write`, `Bun.Glob`, and `Bun.randomUUIDv7`).
*/
import { randomBytes } from "node:crypto";
import { existsSync, readdirSync, rmSync, statSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { basename, join, resolve, sep } from "node:path";
import { Glob, randomUUIDv7 } from "bun";
// ---------------------------------------------------------------------------
// Source schema (Claude Code transcript lines)
// ---------------------------------------------------------------------------
interface ClaudeImageSource {
type?: string;
media_type?: string;
data?: string;
url?: string;
file_id?: string;
}
interface ClaudeBlock {
type?: string;
// text
text?: string;
// thinking
thinking?: string;
signature?: string;
// tool_use
id?: string;
name?: string;
input?: unknown;
// tool_result
tool_use_id?: string;
content?: unknown;
is_error?: boolean;
// image
source?: ClaudeImageSource;
}
interface ClaudeUsage {
input_tokens?: number;
output_tokens?: number;
cache_creation_input_tokens?: number;
cache_read_input_tokens?: number;
}
interface ClaudeMessage {
role?: string;
model?: string;
content?: string | ClaudeBlock[];
usage?: ClaudeUsage;
stop_reason?: string;
}
interface ClaudeEntry {
type?: string;
uuid?: string;
parentUuid?: string | null;
timestamp?: string;
message?: ClaudeMessage;
isSidechain?: boolean;
isMeta?: boolean;
cwd?: string;
sessionId?: string;
aiTitle?: string;
summary?: string;
}
// ---------------------------------------------------------------------------
// Target schema (omp v3 session)
// ---------------------------------------------------------------------------
interface OmpTextBlock {
type: "text";
text: string;
}
interface OmpThinkingBlock {
type: "thinking";
thinking: string;
thinkingSignature: string;
}
interface OmpToolCallBlock {
type: "toolCall";
id: string;
name: string;
arguments: unknown;
}
interface OmpImageBlock {
type: "image";
data: string;
mimeType: string;
}
type OmpContentBlock = OmpTextBlock | OmpImageBlock;
type OmpRichBlock =
| OmpTextBlock
| OmpThinkingBlock
| OmpToolCallBlock
| OmpImageBlock;
interface OmpUsageCost {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
}
interface OmpUsage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
cost: OmpUsageCost;
}
interface OmpUserMessage {
role: "user";
content: OmpRichBlock[];
attribution: "user";
timestamp: number;
}
interface OmpAssistantMessage {
role: "assistant";
content: OmpRichBlock[];
api: string;
provider: string;
model: string;
usage: OmpUsage;
stopReason?: string;
timestamp: number;
}
interface OmpToolResultMessage {
role: "toolResult";
toolCallId: string;
toolName: string;
content: OmpContentBlock[];
isError: boolean;
timestamp: number;
}
type OmpMessage = OmpUserMessage | OmpAssistantMessage | OmpToolResultMessage;
interface OmpMessageEntry {
type: "message";
id: string;
parentId: string | null;
timestamp: string;
message: OmpMessage;
}
interface OmpModelChangeEntry {
type: "model_change";
id: string;
parentId: string | null;
timestamp: string;
model: string;
}
type OmpEntry = OmpMessageEntry | OmpModelChangeEntry;
interface OmpHeader {
type: "session";
version: 3;
id: string;
timestamp: string;
cwd: string;
title?: string;
titleSource: "auto";
parentSession: string;
}
interface ConvertedSession {
header: OmpHeader;
entries: OmpEntry[];
messageCount: number;
malformed: number;
claudeSessionId: string;
}
// ---------------------------------------------------------------------------
// CLI options
// ---------------------------------------------------------------------------
interface Options {
claudeDir: string;
outDir: string;
dryRun: boolean;
includeSidechains: boolean;
overwrite: boolean;
revert: boolean;
limit: number;
paths: string[];
help: boolean;
}
const HELP = `claude-to-omp: import Claude Code transcripts into oh-my-pi sessions
Usage:
bun claude-to-omp.ts [options] [paths...]
Options:
--write Perform the import (default is a dry run that writes nothing)
--claude-dir <dir> Claude projects root (default: ~/.claude/projects)
--out <dir> omp sessions root (default: ~/.omp/agent/sessions)
--include-sidechains Include subagent (Task) sidechain messages (default: skip)
--overwrite Re-import even if a converted copy already exists
--revert Remove imported sessions under --out (with --write, deletes them)
--limit <n> Convert at most n transcripts
--dry-run Force a dry run (already the default)
-h, --help Show this help and exit
By default nothing is written; review the report, then re-run with --write.
Positional paths replace the default scan of --claude-dir; each may be a .jsonl
file or a directory to walk for *.jsonl transcripts.`;
function defaultOptions(): Options {
return {
claudeDir: join(homedir(), ".claude", "projects"),
outDir: join(homedir(), ".omp", "agent", "sessions"),
dryRun: true,
includeSidechains: false,
overwrite: false,
revert: false,
limit: Infinity,
paths: [],
help: false,
};
}
function parseArgs(argv: string[]): Options {
const opts = defaultOptions();
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
switch (arg) {
case "-h":
case "--help":
opts.help = true;
break;
case "--write":
opts.dryRun = false;
break;
case "--dry-run":
opts.dryRun = true;
break;
case "--include-sidechains":
opts.includeSidechains = true;
break;
case "--overwrite":
opts.overwrite = true;
break;
case "--revert":
opts.revert = true;
break;
case "--claude-dir":
opts.claudeDir = resolve(expandHome(requireValue(argv, ++i, arg)));
break;
case "--out":
opts.outDir = resolve(expandHome(requireValue(argv, ++i, arg)));
break;
case "--limit":
opts.limit = parseLimit(requireValue(argv, ++i, arg));
break;
default:
if (arg.startsWith("-")) throw new Error(`Unknown option: ${arg}`);
opts.paths.push(resolve(expandHome(arg)));
}
}
return opts;
}
function parseLimit(raw: string): number {
const n = Number.parseInt(raw, 10);
if (!Number.isFinite(n) || n <= 0)
throw new Error(`--limit expects a positive integer, got "${raw}"`);
return n;
}
function requireValue(argv: string[], index: number, flag: string): string {
const value = argv[index];
if (value === undefined) throw new Error(`${flag} expects a value`);
return value;
}
function expandHome(p: string): string {
if (p === "~") return homedir();
if (p.startsWith(`~${sep}`) || p.startsWith("~/"))
return join(homedir(), p.slice(2));
return p;
}
// ---------------------------------------------------------------------------
// Path / encoding helpers
// ---------------------------------------------------------------------------
/** Replace path separators and colons with `-`, matching omp's directory encoding. */
function replaceSeps(s: string): string {
return s.replace(/[/\\:]/g, "-");
}
function stripTrailingSep(p: string): string {
return p.replace(/[/\\]+$/, "");
}
/**
* Reproduce omp's session directory encoding for a given cwd:
* - the home directory itself -> "-"
* - inside home -> "-<relative>" (seps -> "-")
* - inside the OS temp root -> "-tmp-<relative>"
* - anywhere else (legacy absolute path) -> "--<abs-without-leading-slash>--"
*/
function encodeCwd(cwd: string): string {
const home = stripTrailingSep(homedir());
const tmp = stripTrailingSep(tmpdir());
const dir = stripTrailingSep(cwd);
if (dir === home) return "-";
if (dir.startsWith(home + sep))
return `-${replaceSeps(dir.slice(home.length + 1))}`;
if (dir === tmp) return "-tmp-";
if (dir.startsWith(tmp + sep))
return `-tmp-${replaceSeps(dir.slice(tmp.length + 1))}`;
return `--${replaceSeps(dir.replace(/^[/\\]/, ""))}--`;
}
/** ISO timestamp with `:` and `.` replaced by `-`, as omp uses in filenames. */
function timestampForFilename(iso: string): string {
return new Date(iso).toISOString().replace(/:/g, "-").replace(/\./g, "-");
}
function isoToMs(iso: string | undefined): number {
if (!iso) return Date.now();
const ms = Date.parse(iso);
return Number.isFinite(ms) ? ms : Date.now();
}
// ---------------------------------------------------------------------------
// Block conversion
// ---------------------------------------------------------------------------
function claudeImageToOmp(
source: ClaudeImageSource | undefined,
): OmpContentBlock {
if (source && source.type === "base64" && typeof source.data === "string") {
return {
type: "image",
data: source.data,
mimeType: source.media_type ?? "image/png",
};
}
const ref = source?.url ?? source?.file_id ?? "";
return { type: "text", text: ref ? `[image: ${ref}]` : "[image]" };
}
/** Convert assistant/user content blocks (everything except tool_result). */
function convertRichBlocks(
blocks: ClaudeBlock[],
toolNameById: Map<string, string>,
): OmpRichBlock[] {
const out: OmpRichBlock[] = [];
for (const block of blocks) {
switch (block.type) {
case "text":
out.push({ type: "text", text: block.text ?? "" });
break;
case "thinking":
out.push({
type: "thinking",
thinking: block.thinking ?? "",
thinkingSignature: block.signature ?? "",
});
break;
case "tool_use":
if (typeof block.id === "string" && typeof block.name === "string") {
toolNameById.set(block.id, block.name);
out.push({
type: "toolCall",
id: block.id,
name: block.name,
arguments: block.input ?? {},
});
}
break;
case "image":
out.push(claudeImageToOmp(block.source));
break;
// tool_result is handled by the caller; unknown block types are dropped.
}
}
return out;
}
/** Normalize a tool_result block's `content` into omp toolResult content blocks. */
function normalizeToolResultContent(content: unknown): OmpContentBlock[] {
if (typeof content === "string") return [{ type: "text", text: content }];
if (Array.isArray(content)) {
const out: OmpContentBlock[] = [];
for (const item of content as ClaudeBlock[]) {
if (item?.type === "text")
out.push({ type: "text", text: item.text ?? "" });
else if (item?.type === "image") out.push(claudeImageToOmp(item.source));
else
out.push({
type: "text",
text: typeof item === "string" ? item : JSON.stringify(item),
});
}
return out.length > 0 ? out : [{ type: "text", text: "" }];
}
if (content == null) return [{ type: "text", text: "" }];
return [{ type: "text", text: JSON.stringify(content) }];
}
function mapStopReason(reason: string | undefined): string | undefined {
switch (reason) {
case undefined:
return undefined;
case "tool_use":
return "toolUse";
case "end_turn":
return "endTurn";
case "max_tokens":
return "maxTokens";
case "stop_sequence":
return "stopSequence";
default:
return reason;
}
}
function mapUsage(usage: ClaudeUsage | undefined): OmpUsage {
const input = usage?.input_tokens ?? 0;
const output = usage?.output_tokens ?? 0;
const cacheRead = usage?.cache_read_input_tokens ?? 0;
const cacheWrite = usage?.cache_creation_input_tokens ?? 0;
return {
input,
output,
cacheRead,
cacheWrite,
totalTokens: input + output + cacheRead + cacheWrite,
// Pricing is not available offline; cost is left at zero rather than guessed.
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
}
// ---------------------------------------------------------------------------
// Transcript conversion
// ---------------------------------------------------------------------------
interface ParsedTranscript {
entries: ClaudeEntry[];
malformed: number;
}
function parseTranscript(raw: string): ParsedTranscript {
const entries: ClaudeEntry[] = [];
let malformed = 0;
for (const line of raw.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
entries.push(JSON.parse(trimmed) as ClaudeEntry);
} catch {
malformed++;
}
}
return { entries, malformed };
}
interface TranscriptMeta {
parentByUuid: Map<string, string | null>;
cwd: string;
sessionId: string;
firstTimestamp: string;
aiTitle: string;
firstUserText: string;
firstModel: string;
}
// Map every uuid -> its parentUuid (including skipped lines) so children of a
// dropped entry can attach to the nearest surviving ancestor; also gather the
// session-level fields used to build the header.
function collectMeta(
entries: ClaudeEntry[],
fallbackCwd: string,
): TranscriptMeta {
const parentByUuid = new Map<string, string | null>();
let cwd = "";
let sessionId = "";
let firstTimestamp = "";
let aiTitle = "";
let firstUserText = "";
let firstModel = "";
for (const entry of entries) {
if (typeof entry.uuid === "string")
parentByUuid.set(
entry.uuid,
typeof entry.parentUuid === "string" ? entry.parentUuid : null,
);
if (!cwd && entry.cwd) cwd = entry.cwd;
if (!sessionId && entry.sessionId) sessionId = entry.sessionId;
if (!firstTimestamp && entry.timestamp) firstTimestamp = entry.timestamp;
if (
entry.type === "ai-title" &&
typeof entry.aiTitle === "string" &&
entry.aiTitle
)
aiTitle = entry.aiTitle;
if (
!firstModel &&
entry.message?.role === "assistant" &&
entry.message.model
)
firstModel = entry.message.model;
if (!firstUserText && entry.message?.role === "user" && !entry.isMeta)
firstUserText = firstTextOf(entry.message.content);
}
return {
parentByUuid,
cwd: cwd || fallbackCwd,
sessionId,
firstTimestamp: firstTimestamp || new Date().toISOString(),
aiTitle,
firstUserText,
firstModel,
};
}
/** Mint unique 8-hex entry ids (only required to be unique within one file). */
function createIdFactory(): () => string {
const used = new Set<string>();
return () => {
let id = randomBytes(4).toString("hex");
while (used.has(id)) id = randomBytes(4).toString("hex");
used.add(id);
return id;
};
}
/** Resolve a Claude parentUuid to an emitted omp id, walking past skipped ancestors. */
function resolveParentId(
parentUuid: string | null | undefined,
parentByUuid: Map<string, string | null>,
lastIdByUuid: Map<string, string>,
rootId: string | null,
): string | null {
let cursor: string | null =
typeof parentUuid === "string" ? parentUuid : null;
while (cursor !== null && !lastIdByUuid.has(cursor))
cursor = parentByUuid.get(cursor) ?? null;
if (cursor === null) return rootId;
return lastIdByUuid.get(cursor) ?? rootId;
}
function buildAssistantMessages(
message: ClaudeMessage,
toolNameById: Map<string, string>,
firstModel: string,
ms: number,
): OmpMessage[] {
const blocks = Array.isArray(message.content)
? convertRichBlocks(message.content, toolNameById)
: typeof message.content === "string"
? [{ type: "text", text: message.content } as OmpRichBlock]
: [];
if (blocks.length === 0) return [];
return [
{
role: "assistant",
content: blocks,
api: "anthropic-messages",
provider: "anthropic",
model: message.model ?? firstModel ?? "unknown",
usage: mapUsage(message.usage),
stopReason: mapStopReason(message.stop_reason),
timestamp: ms,
},
];
}
// tool_result blocks each become their own omp toolResult message; remaining
// text/image blocks collapse into one user message.
function buildUserMessages(
message: ClaudeMessage,
toolNameById: Map<string, string>,
ms: number,
): OmpMessage[] {
if (typeof message.content === "string") {
return message.content.length > 0
? [
{
role: "user",
content: [{ type: "text", text: message.content }],
attribution: "user",
timestamp: ms,
},
]
: [];
}
if (!Array.isArray(message.content)) return [];
const built: OmpMessage[] = [];
const others: ClaudeBlock[] = [];
for (const block of message.content) {
if (block?.type === "tool_result") {
const toolCallId = block.tool_use_id ?? "";
built.push({
role: "toolResult",
toolCallId,
toolName: toolNameById.get(toolCallId) ?? "tool",
content: normalizeToolResultContent(block.content),
isError: block.is_error === true,
timestamp: ms,
});
} else {
others.push(block);
}
}
const otherBlocks = convertRichBlocks(others, toolNameById);
if (otherBlocks.length > 0)
built.push({
role: "user",
content: otherBlocks,
attribution: "user",
timestamp: ms,
});
return built;
}
function buildMessages(
entry: ClaudeEntry,
toolNameById: Map<string, string>,
firstModel: string,
): OmpMessage[] {
const message = entry.message;
if (!message) return [];
const ms = isoToMs(entry.timestamp);
if (message.role === "assistant")
return buildAssistantMessages(message, toolNameById, firstModel, ms);
if (message.role === "user")
return buildUserMessages(message, toolNameById, ms);
const text = firstTextOf(message.content);
return text
? [
{
role: "user",
content: [{ type: "text", text }],
attribution: "user",
timestamp: ms,
},
]
: [];
}
function buildHeader(meta: TranscriptMeta, headerTimestamp: string): OmpHeader {
const title = (
meta.aiTitle ||
meta.firstUserText ||
"Imported Claude session"
).slice(0, 120);
return {
type: "session",
version: 3,
id: randomUUIDv7(isoToMs(meta.firstTimestamp)),
timestamp: headerTimestamp,
cwd: meta.cwd,
title,
titleSource: "auto",
parentSession: `claude-import:${meta.sessionId || "unknown"}`,
};
}
function convertTranscript(
raw: string,
fallbackCwd: string,
includeSidechains: boolean,
): ConvertedSession | null {
const { entries: claudeEntries, malformed } = parseTranscript(raw);
if (claudeEntries.length === 0) return null;
const meta = collectMeta(claudeEntries, fallbackCwd);
const newId = createIdFactory();
const entries: OmpEntry[] = [];
const lastIdByUuid = new Map<string, string>();
const toolNameById = new Map<string, string>();
const headerTimestamp = new Date(isoToMs(meta.firstTimestamp)).toISOString();
// A leading model_change makes the imported model visible and acts as the tree root.
let rootId: string | null = null;
if (meta.firstModel) {
rootId = newId();
entries.push({
type: "model_change",
id: rootId,
parentId: null,
timestamp: headerTimestamp,
model: `anthropic/${meta.firstModel}`,
});
}
let messageCount = 0;
for (const entry of claudeEntries) {
if (!entry.message || entry.isMeta) continue;
if (entry.isSidechain && !includeSidechains) continue;
const built = buildMessages(entry, toolNameById, meta.firstModel);
if (built.length === 0) continue;
const iso = new Date(isoToMs(entry.timestamp)).toISOString();
let parentId = resolveParentId(
entry.parentUuid,
meta.parentByUuid,
lastIdByUuid,
rootId,
);
for (const message of built) {
const id = newId();
entries.push({ type: "message", id, parentId, timestamp: iso, message });
parentId = id;
messageCount++;
}
if (typeof entry.uuid === "string") lastIdByUuid.set(entry.uuid, parentId);
}
if (messageCount === 0) return null;
return {
header: buildHeader(meta, headerTimestamp),
entries,
messageCount,
malformed,
claudeSessionId: meta.sessionId || "unknown",
};
}
function firstTextOf(content: string | ClaudeBlock[] | undefined): string {
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) {
for (const block of content) {
if (block?.type === "text" && block.text) return block.text.trim();
}
}
return "";
}
// ---------------------------------------------------------------------------
// Filesystem
// ---------------------------------------------------------------------------
function discoverTranscripts(opts: Options): string[] {
const found: string[] = [];
const roots = opts.paths.length > 0 ? opts.paths : [opts.claudeDir];
for (const root of roots) {
if (!existsSync(root)) {
console.warn(`! skip (not found): ${root}`);
continue;
}
if (statSync(root).isDirectory()) {
const glob = new Glob("**/*.jsonl");
for (const rel of glob.scanSync(root)) found.push(join(root, rel));
} else if (root.endsWith(".jsonl")) {
found.push(root);
}
}
// Stable, oldest first by mtime so the omp picker lists imports chronologically.
return [...new Set(found)].sort(
(a, b) => statSync(a).mtimeMs - statSync(b).mtimeMs,
);
}
function sessionFilePath(opts: Options, header: OmpHeader): string {
const dir = join(opts.outDir, encodeCwd(header.cwd));
return join(
dir,
`${timestampForFilename(header.timestamp)}_${header.id}.jsonl`,
);
}
/** Read and parse a session file's header line; null if it is not a session header. */
async function readSessionHeader(
path: string,
): Promise<Partial<OmpHeader> | null> {
try {
const head = await Bun.file(path).slice(0, 8192).text();
const firstLine = head.split("\n", 1)[0];
if (!firstLine) return null;
const parsed = JSON.parse(firstLine) as Partial<OmpHeader>;
return parsed.type === "session" ? parsed : null;
} catch {
return null;
}
}
/** Find a prior import of the same Claude session, if any (for idempotency). */
async function findExistingImport(
opts: Options,
header: OmpHeader,
): Promise<string | null> {
const dir = join(opts.outDir, encodeCwd(header.cwd));
if (!existsSync(dir)) return null;
for (const name of readdirSync(dir)) {
if (!name.endsWith(".jsonl")) continue;
const path = join(dir, name);
const parsed = await readSessionHeader(path);
if (parsed?.parentSession === header.parentSession) return path;
}
return null;
}
function listSessionFiles(root: string): string[] {
if (!existsSync(root)) return [];
const glob = new Glob("**/*.jsonl");
return Array.from(glob.scanSync(root), (rel) => join(root, rel));
}
/** Remove every session previously written by this importer (header marked claude-import:). */
async function revertImports(opts: Options): Promise<void> {
const imported: string[] = [];
for (const path of listSessionFiles(opts.outDir)) {
const header = await readSessionHeader(path);
if (header?.parentSession?.startsWith("claude-import:"))
imported.push(path);
}
console.log(
`${opts.dryRun ? "[dry-run] " : ""}Found ${imported.length} imported session(s) under ${opts.outDir}`,
);
let removed = 0;
for (const path of imported) {
if (opts.dryRun) {
console.log(` ~ would remove ${path}`);
continue;
}
rmSync(path, { force: true });
rmSync(path.replace(/\.jsonl$/, ""), { recursive: true, force: true });
removed++;
console.log(` - removed ${path}`);
}
if (opts.dryRun)
console.log(
`\nDry run only - nothing removed. Re-run with --revert --write to delete ${imported.length} file(s).`,
);
else console.log(`\nDone. removed=${removed} of ${imported.length}`);
}
function serializeSession(session: ConvertedSession): string {
const lines = [
JSON.stringify(session.header),
...session.entries.map((e) => JSON.stringify(e)),
];
return `${lines.join("\n")}\n`;
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
interface Tally {
converted: number;
skippedExisting: number;
skippedEmpty: number;
errors: number;
messages: number;
malformed: number;
}
async function processTranscript(
source: string,
opts: Options,
tally: Tally,
): Promise<void> {
const label = basename(source);
try {
const raw = await Bun.file(source).text();
const session = convertTranscript(
raw,
process.cwd(),
opts.includeSidechains,
);
if (!session) {
tally.skippedEmpty++;
console.log(` - ${label}: no convertible messages`);
return;
}
tally.malformed += session.malformed;
const existing = opts.overwrite
? null
: await findExistingImport(opts, session.header);
if (existing) {
tally.skippedExisting++;
console.log(` = ${label}: already imported -> ${existing}`);
return;
}
const outPath = sessionFilePath(opts, session.header);
if (!opts.dryRun) await Bun.write(outPath, serializeSession(session));
tally.converted++;
tally.messages += session.messageCount;
console.log(
` ${opts.dryRun ? "~" : "+"} ${label}: ${session.messageCount} message(s) -> ${outPath}`,
);
} catch (err) {
tally.errors++;
console.error(
` x ${label}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
async function runImport(opts: Options): Promise<void> {
const transcripts = discoverTranscripts(opts);
const sourceLabel =
opts.paths.length > 0 ? opts.paths.join(", ") : opts.claudeDir;
if (transcripts.length === 0) {
console.log(`No transcripts found under ${sourceLabel}`);
return;
}
console.log(
`${opts.dryRun ? "[dry-run] " : ""}Converting up to ${Math.min(transcripts.length, opts.limit)} of ${transcripts.length} transcript(s)`,
);
console.log(` from: ${sourceLabel}`);
console.log(
` to: ${opts.outDir}${opts.dryRun ? " (dry run: nothing written; pass --write to apply)" : ""}\n`,
);
const tally: Tally = {
converted: 0,
skippedExisting: 0,
skippedEmpty: 0,
errors: 0,
messages: 0,
malformed: 0,
};
let processed = 0;
for (const source of transcripts) {
if (processed >= opts.limit) break;
processed++;
await processTranscript(source, opts, tally);
}
console.log(
`\nDone. converted=${tally.converted} skipped(existing)=${tally.skippedExisting} skipped(empty)=${tally.skippedEmpty} errors=${tally.errors} messages=${tally.messages} malformed-lines=${tally.malformed}`,
);
if (opts.dryRun)
console.log(
"\nDry run only - no files written. Re-run with --write to import.",
);
else if (tally.converted > 0)
console.log(
"\nResume in omp from the matching project directory, for example: omp --resume (Tab -> all projects)",
);
}
async function main(): Promise<void> {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) {
console.log(HELP);
return;
}
if (opts.revert) {
await revertImports(opts);
return;
}
await runImport(opts);
}
await main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment