From 47af7462ea7224911e364cd38fc20e4041c08e13 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 18 Aug 2026 18:00:12 +0530 Subject: [PATCH] feat(cli): improve session export flow (#43229) --- packages/cli/src/commands/commands.ts | 6 +- packages/cli/src/commands/handlers/export.ts | 208 +++++++------------ packages/cli/test/import-export.test.ts | 38 ++-- 3 files changed, 105 insertions(+), 147 deletions(-) diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 1ce8df43c0..f27f4804f5 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -170,11 +170,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME description: "Export session data as JSON", params: { ...ServerParams, - session: Flag.string("session").pipe( - Flag.withAlias("s"), - Flag.withDescription("Session ID to export to stdout"), - Flag.optional, - ), + session: Argument.string("session").pipe(Argument.withDescription("Session ID to export"), Argument.optional), sanitize: Flag.boolean("sanitize").pipe( Flag.withDescription("Redact sensitive transcript and file data"), Flag.withDefault(false), diff --git a/packages/cli/src/commands/handlers/export.ts b/packages/cli/src/commands/handlers/export.ts index 9a7a5e9d26..abeb2fb8ba 100644 --- a/packages/cli/src/commands/handlers/export.ts +++ b/packages/cli/src/commands/handlers/export.ts @@ -1,140 +1,88 @@ -import { OpenCode, type SessionInfo } from "@opencode-ai/client" +import { autocomplete, cancel, intro, isCancel, log, outro } from "@clack/prompts" +import { OpenCode } from "@opencode-ai/client" import { Service } from "@opencode-ai/client/effect/service" import { Effect, Option } from "effect" -import { EOL, tmpdir } from "node:os" -import path from "node:path" -import { emitKeypressEvents, type Key } from "node:readline" +import { EOL } from "node:os" import { Commands } from "../commands" import { Runtime } from "../../framework/runtime" import { ServerConnection } from "../../services/server-connection" +import { errorMessage } from "../../ui/prompt" export default Runtime.handler( Commands.commands.export, - Effect.fn("cli.export")(function* (input) { - const server = yield* ServerConnection.resolve({ - server: Option.getOrUndefined(input.server), - standalone: input.standalone, - }) - const client = OpenCode.make({ - baseUrl: server.endpoint.url, - headers: Service.headers(server.endpoint), - }) - const requested = Option.getOrUndefined(input.session) - const selected = requested - ? undefined - : yield* Effect.promise(async () => { - const location = await client.location.get({ location: { directory: process.cwd() } }) - const page = await client.session.list({ - directory: location.directory, - workspace: location.workspaceID, - parentID: null, - order: "desc", - limit: 50, + Effect.fn("cli.export")((input) => + Effect.gen(function* () { + const requested = Option.getOrUndefined(input.session) + if (!requested && !process.stdin.isTTY) { + yield* Effect.fail(new Error("Pass a session ID when running without an interactive terminal")) + } + const server = yield* ServerConnection.resolve({ + server: Option.getOrUndefined(input.server), + standalone: input.standalone, + }) + const client = OpenCode.make({ + baseUrl: server.endpoint.url, + headers: Service.headers(server.endpoint), + }) + const sessionID = requested + ? requested + : yield* Effect.gen(function* () { + intro("Export session", { output: process.stderr }) + const location = yield* Effect.tryPromise({ + try: () => client.location.get({ location: { directory: process.cwd() } }), + catch: (cause) => cause, + }) + const page = yield* Effect.tryPromise({ + try: () => + client.session.list({ + directory: location.directory, + workspace: location.workspaceID, + parentID: null, + order: "desc", + limit: 50, + }), + catch: (cause) => cause, + }) + if (page.data.length === 0) { + log.error("No sessions found", { output: process.stderr }) + outro("Done", { output: process.stderr }) + return undefined + } + const selected = yield* Effect.tryPromise({ + try: () => + autocomplete({ + message: "Select session to export", + maxItems: 10, + options: page.data.map((session) => ({ + label: session.title, + value: session.id, + hint: `${new Date(session.time.updated).toLocaleString()} - ${session.id.slice(-8)}`, + })), + output: process.stderr, + }), + catch: (cause) => cause, + }) + if (isCancel(selected)) { + cancel("Cancelled", { output: process.stderr }) + process.exitCode = 130 + return undefined + } + outro("Exporting session...", { output: process.stderr }) + return selected }) - if (page.data.length === 0) { - process.stderr.write(`No sessions found${EOL}`) - return undefined - } - return selectSession(page.data, input.sanitize) - }) - const sessionID = requested ?? selected?.session.id - if (!sessionID) return - const data = yield* Effect.promise(() => - client.session.export({ sessionID, sanitize: selected?.sanitize ?? input.sanitize }), - ) - process.stdout.write(yield* Effect.promise(() => writeExport(data, sessionID, requested !== undefined))) - }), + if (!sessionID) return + const data = yield* Effect.tryPromise({ + try: () => client.session.export({ sessionID, sanitize: input.sanitize }), + catch: (cause) => cause, + }) + process.stdout.write(JSON.stringify(data, null, 2) + EOL) + }).pipe( + Effect.catch((error) => + Effect.sync(() => { + process.stderr.write(errorMessage(error) + EOL) + process.exitCode = 1 + }), + ), + ), + ), ) - -type Selection = { session: SessionInfo; sanitize: boolean } - -function selectSession(sessions: SessionInfo[], initialSanitize: boolean) { - if (!process.stdin.isTTY) return Promise.reject(new Error("Session ID is required when stdin is not interactive")) - const input = process.stdin - const output = process.stderr - const wasRaw = input.isRaw - const wasPaused = input.isPaused() - const date = new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }) - const columns = output.columns ?? 100 - const titleWidth = Math.max(8, Math.min(48, columns - 34)) - let selected = 0 - let offset = 0 - let sanitize = initialSanitize - let height = 0 - - const render = () => { - const visible = sessions.slice(offset, offset + 10) - const lines = [" \x1b[36mExport session\x1b[0m", ""] - lines.push( - ...visible.map((session) => { - const index = sessions.indexOf(session) - const title = (session.title ?? "Untitled session").slice(0, titleWidth).padEnd(titleWidth) - const updated = date.format(session.time.updated).slice(0, 18).padEnd(18) - const row = `${index === selected ? ">" : " "} ${title} ${updated} ${session.id.slice(-8)}` - return index === selected ? `\x1b[1m${row}\x1b[0m` : row - }), - "", - ` [${sanitize ? "x" : " "}] sanitize sensitive data`, - "", - " navigate \x1b[2mup/down\x1b[0m sanitize \x1b[2mspace\x1b[0m export \x1b[2menter\x1b[0m cancel \x1b[2mesc\x1b[0m", - ) - if (height > 0) output.write(`\x1b[${height}F\x1b[J`) - output.write(lines.join(EOL) + EOL) - height = lines.length - } - const clear = () => { - if (height > 0) output.write(`\x1b[${height}F\x1b[J`) - output.write("\x1b[?25h") - input.removeListener("keypress", onKeypress) - input.setRawMode(wasRaw ?? false) - if (wasPaused) input.pause() - } - const onKeypress = (value: string | undefined, key: Key) => { - if (key.name === "up") { - selected = (selected - 1 + sessions.length) % sessions.length - if (selected === sessions.length - 1) offset = Math.max(0, sessions.length - 10) - if (selected < offset) offset = selected - } - if (key.name === "down") { - selected = (selected + 1) % sessions.length - if (selected === 0) offset = 0 - if (selected >= offset + 10) offset = selected - 9 - } - if (key.name === "space" || value === " ") sanitize = !sanitize - if (key.name === "return") return finish(sessions[selected]) - if (key.name === "escape" || (key.ctrl && key.name === "c")) return cancel() - render() - } - const finish = (session: SessionInfo) => { - clear() - resolveSelection?.({ session, sanitize }) - } - const cancel = () => { - clear() - resolveSelection?.() - } - let resolveSelection: ((selection?: Selection) => void) | undefined - - emitKeypressEvents(input) - input.setRawMode(true) - input.resume() - input.on("keypress", onKeypress) - output.write("\x1b[?25l") - render() - return new Promise((resolve) => { - resolveSelection = resolve - }) -} - -export async function writeExport(data: unknown, sessionID: string, stdout: boolean) { - const json = JSON.stringify(data, null, 2) + EOL - if (stdout) return json - const file = path.join(tmpdir(), `opencode-session-${sessionID}-${crypto.randomUUID().slice(0, 8)}.json`) - await Bun.write(file, json) - return file + EOL -} diff --git a/packages/cli/test/import-export.test.ts b/packages/cli/test/import-export.test.ts index dd64283ea1..1709639fb8 100644 --- a/packages/cli/test/import-export.test.ts +++ b/packages/cli/test/import-export.test.ts @@ -3,7 +3,6 @@ import fs from "node:fs/promises" import os from "node:os" import path from "node:path" import { OPENCODE_VERSION } from "../src/version" -import { writeExport } from "../src/commands/handlers/export" const info = { id: "ses_export_test", @@ -72,7 +71,7 @@ test("export is raw by default and supports explicit sanitization", async () => }) try { - const [stdout, , exitCode] = await run(["export", "-s", info.id, "--server", server.url.toString()]) + const [stdout, , exitCode] = await run(["export", info.id, "--server", server.url.toString()]) const exported = JSON.parse(stdout) expect(exitCode).toBe(0) @@ -80,7 +79,6 @@ test("export is raw by default and supports explicit sanitization", async () => const [sanitized, , sanitizedExitCode] = await run([ "export", - "-s", info.id, "--sanitize", "--server", @@ -94,7 +92,7 @@ test("export is raw by default and supports explicit sanitization", async () => } }, 15_000) -test("export reports an empty session list without a stack trace", async () => { +test("export requires a session outside an interactive terminal", async () => { const server = Bun.serve({ port: 0, fetch(request) { @@ -114,23 +112,39 @@ test("export reports an empty session list without a stack trace", async () => { try { const [stdout, stderr, exitCode] = await run(["export", "--server", server.url.toString()]) - expect(exitCode).toBe(0) + expect(exitCode).toBe(1) expect(stdout).toBe("") - expect(stderr).toBe(`No sessions found${os.EOL}`) + expect(stderr).toBe(`Pass a session ID when running without an interactive terminal${os.EOL}`) } finally { await server.stop(true) } }) -test("interactive export writes a temporary JSON file", async () => { - const output = await writeExport(transfer, info.id, false) - const file = output.trim() +test("export reports a missing session without a stack trace", async () => { + const sessionID = "ses_missing" + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/api/health") return health() + if (url.pathname === `/api/session/${sessionID}/export`) { + return Response.json( + { _tag: "SessionNotFoundError", sessionID, message: `Session not found: ${sessionID}` }, + { status: 404 }, + ) + } + return new Response("Not found", { status: 404 }) + }, + }) try { - expect(path.dirname(file)).toBe(os.tmpdir()) - expect(await Bun.file(file).json()).toEqual(transfer) + const [stdout, stderr, exitCode] = await run(["export", sessionID, "--server", server.url.toString()]) + + expect(exitCode).toBe(1) + expect(stdout).toBe("") + expect(stderr).toBe(`Session not found: ${sessionID}${os.EOL}`) } finally { - await fs.rm(file, { force: true }) + await server.stop(true) } })