Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions packages/opencode/src/cli/cmd/export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { Session } from "@/session/session"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { MessageV2 } from "../../session/message-v2"
import { SessionID } from "../../session/schema"
import { effectCmd, fail } from "../effect-cmd"
import { SessionBundle } from "../../session/bundle"
import { Database } from "@opencode-ai/core/database/database"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { InstanceRef } from "@/effect/instance-ref"
import { effectCmd, fail, CliError } from "../effect-cmd"
import { UI } from "../ui"
import * as prompts from "@clack/prompts"
import { EOL } from "os"
Expand Down Expand Up @@ -231,7 +235,8 @@ export const ExportCommand = effectCmd({
.option("sanitize", {
describe: "redact sensitive transcript and file data",
type: "boolean",
}),
})
.command(ExportSessionBundleCommand),
handler: Effect.fn("Cli.export")(function* (args) {
return yield* run(args)
}),
Expand Down Expand Up @@ -290,3 +295,68 @@ const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string;
process.stdout.write(EOL)
}).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`)))
})

/**
* `opencode export session <id[,id2,...] | --all>` — portable JSONL bundle
* (first line per session = session row, then message rows and part rows in
* storage order; IDs and timestamps preserved verbatim). Byte-faithful to the
* storage layer: import into another store reproduces the rows exactly.
*/
export const ExportSessionBundleCommand = effectCmd({
command: "session [sessions..]",
describe:
"export sessions as a portable JSONL bundle (session metadata first, then messages and parts in order, IDs and timestamps preserved)",
builder: (yargs) =>
yargs
.positional("sessions", {
describe: "session ids to export (comma-separated lists allowed)",
type: "string",
array: true,
})
.option("all", {
describe: "export every session in the current project scope",
type: "boolean",
default: false,
})
.option("out", {
alias: "o",
describe: "write the bundle to a file instead of stdout",
type: "string",
}),
handler: Effect.fn("Cli.export.session")(function* (args: { sessions?: string[]; all?: boolean; out?: string }) {
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die("InstanceRef not provided")
const db = (yield* Database.Service).db

let ids: string[]
if (args.all) {
ids = yield* SessionBundle.allSessionIDs(db, ctx.project.id)
if (ids.length === 0) {
process.stderr.write("No sessions found in project scope" + EOL)
}
} else {
ids = (args.sessions ?? [])
.flatMap((value) => value.split(","))
.map((value) => value.trim())
.filter(Boolean)
if (ids.length === 0) {
return yield* fail("Specify a session id (or comma-separated ids), or pass --all")
}
}

const blocks = yield* SessionBundle.exportBlocks(db, ids).pipe(
Effect.mapError((error) => new CliError({ message: error.message })),
)
const text = SessionBundle.serialize(blocks) + EOL

if (args.out) {
const fs = yield* FSUtil.Service
yield* fs.writeWithDirs(args.out, text).pipe(
Effect.mapError(() => new CliError({ message: `Failed to write bundle to ${args.out}` })),
)
process.stdout.write(`Exported ${blocks.length} session(s) to ${args.out}` + EOL)
return
}
process.stdout.write(text)
}),
})
92 changes: 85 additions & 7 deletions packages/opencode/src/cli/cmd/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Session } from "@/session/session"
import { MessageV2 } from "../../session/message-v2"
import { CliError, effectCmd } from "../effect-cmd"
import { SessionBundle } from "../../session/bundle"
import { CliError, effectCmd, fail } from "../effect-cmd"
import { Database } from "@opencode-ai/core/database/database"
import { SessionTable, MessageTable, PartTable } from "@opencode-ai/core/session/sql"
import { InstanceRef } from "@/effect/instance-ref"
Expand Down Expand Up @@ -92,21 +93,98 @@ export function transformShareData(shareData: ShareData[]): {
type ExportData = { info: SDKSession; messages: Array<{ info: Message; parts: Part[] }> }

export const ImportCommand = effectCmd({
command: "import <file>",
command: "import [file]",
describe: "import session data from JSON file or URL",
builder: (yargs) =>
yargs.positional("file", {
describe: "path to JSON file or share URL",
type: "string",
demandOption: true,
}),
yargs
.positional("file", {
describe: "path to JSON file or share URL",
type: "string",
})
.command(ImportSessionBundleCommand),
handler: Effect.fn("Cli.import")(function* (args) {
if (!args.file) {
return yield* fail("Specify a file or share URL, or run `opencode import session <bundle>`")
}
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die("InstanceRef not provided")
return yield* runImport(args.file, ctx)
}),
})

/**
* `opencode import session <bundle> [--remap-dir <from>=<to> ...]` — imports a
* portable JSONL bundle produced by `opencode export session`. Idempotent by
* session ID: an existing ID is skipped (exit 0). Each session is written in a
* single transaction. Single-writer: do not run concurrent imports against the
* same store — SQLite WAL protects concurrent readers, but writers are the
* caller's responsibility to serialize.
*/
export const ImportSessionBundleCommand = effectCmd({
command: "session <bundle>",
describe:
"import sessions from a portable JSONL bundle (idempotent by session id; single-writer — do not run concurrent imports against one store)",
builder: (yargs) =>
yargs
.positional("bundle", {
describe: "path to the JSONL bundle file",
type: "string",
demandOption: true,
})
.option("remap-dir", {
describe: "rewrite matching session directories at import: --remap-dir <from>=<to> (repeatable)",
type: "array",
default: [] as string[],
}),
handler: Effect.fn("Cli.import.session")(function* (args: { bundle: string; remapDir?: string[] }) {
const ctx = yield* InstanceRef
if (!ctx) return yield* Effect.die("InstanceRef not provided")
return yield* runBundleImport(args.bundle, (args.remapDir ?? []).map(String), ctx)
}),
})

const runBundleImport = Effect.fn("Cli.import.session.body")(function* (
file: string,
remapSpecs: string[],
ctx: InstanceContext,
) {
const fs = yield* FSUtil.Service
const db = (yield* Database.Service).db

let remaps: [string, string][]
try {
remaps = remapSpecs.map(SessionBundle.parseRemap)
} catch (error) {
return yield* fail(error instanceof SessionBundle.BundleError ? error.message : `Invalid --remap-dir value`)
}

const text = yield* fs.readFileString(file).pipe(
Effect.mapError((error) => new CliError({ message: formatImportFileError(file, error) })),
)
const blocks = yield* SessionBundle.parse(text).pipe(
Effect.mapError((error) => new CliError({ message: error.message })),
)
if (blocks.length === 0) {
return yield* fail(`No sessions found in bundle: ${file}`)
}

const result = yield* SessionBundle.importBlocks(db, blocks, {
fallbackProjectID: ctx.project.id,
remaps,
}).pipe(
Effect.mapError((error) => new CliError({ message: `Failed to import bundle: ${error.message}` })),
)

for (const id of result.imported) {
process.stdout.write(`Imported session: ${id}`)
process.stdout.write(EOL)
}
for (const id of result.skipped) {
process.stdout.write(`Session ${id} already exists, skipped`)
process.stdout.write(EOL)
}
})

const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: InstanceContext) {
const share = yield* ShareNext.Service
const fs = yield* FSUtil.Service
Expand Down
Loading
Loading