Skip to content

Commit d42a2ec

Browse files
committed
feat: add session export cli command
opencode session export [sessionID] export session transcript to file Positionals: sessionID session id to export Options: -o, --output output file path -f, --format output format [string] [choices: "markdown", "json"] [default: "markdown"] Fixes: anomalyco#5426 Signed-off-by: Christian Stewart <[email protected]>
1 parent 08005d7 commit d42a2ec

File tree

2 files changed

+168
-2
lines changed

2 files changed

+168
-2
lines changed

packages/opencode/src/cli/cmd/session.ts

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import type { Argv } from "yargs"
2+
import path from "path"
23
import { cmd } from "./cmd"
34
import { Session } from "../../session"
45
import { bootstrap } from "../bootstrap"
56
import { UI } from "../ui"
67
import { Locale } from "../../util/locale"
78
import { Flag } from "../../flag/flag"
89
import { EOL } from "os"
9-
import path from "path"
10+
import * as prompts from "@clack/prompts"
1011

1112
function pagerCmd(): string[] {
1213
const lessOptions = ["-R", "-S"]
@@ -38,7 +39,7 @@ function pagerCmd(): string[] {
3839
export const SessionCommand = cmd({
3940
command: "session",
4041
describe: "manage sessions",
41-
builder: (yargs: Argv) => yargs.command(SessionListCommand).demandCommand(),
42+
builder: (yargs: Argv) => yargs.command(SessionListCommand).command(SessionExportCommand).demandCommand(),
4243
async handler() {},
4344
})
4445

@@ -133,3 +134,119 @@ function formatSessionJSON(sessions: Session.Info[]): string {
133134
}))
134135
return JSON.stringify(jsonData, null, 2)
135136
}
137+
138+
export const SessionExportCommand = cmd({
139+
command: "export [sessionID]",
140+
describe: "export session transcript to file",
141+
builder: (yargs: Argv) => {
142+
return yargs
143+
.positional("sessionID", {
144+
describe: "session id to export",
145+
type: "string",
146+
})
147+
.option("output", {
148+
alias: "o",
149+
describe: "output file path",
150+
type: "string",
151+
})
152+
.option("format", {
153+
alias: "f",
154+
describe: "output format",
155+
type: "string",
156+
choices: ["markdown", "json"],
157+
default: "markdown",
158+
})
159+
},
160+
handler: async (args) => {
161+
await bootstrap(process.cwd(), async () => {
162+
let sessionID = args.sessionID
163+
164+
if (!sessionID) {
165+
prompts.intro("Export session", {
166+
output: process.stderr,
167+
})
168+
169+
const sessions = []
170+
for await (const session of Session.list()) {
171+
sessions.push(session)
172+
}
173+
174+
if (sessions.length === 0) {
175+
prompts.log.error("No sessions found", {
176+
output: process.stderr,
177+
})
178+
prompts.outro("Done", {
179+
output: process.stderr,
180+
})
181+
return
182+
}
183+
184+
sessions.sort((a, b) => b.time.updated - a.time.updated)
185+
186+
const selectedSession = await prompts.autocomplete({
187+
message: "Select session to export",
188+
maxItems: 10,
189+
options: sessions.map((session) => ({
190+
label: session.title,
191+
value: session.id,
192+
hint: `${new Date(session.time.updated).toLocaleString()}${session.id.slice(-8)}`,
193+
})),
194+
output: process.stderr,
195+
})
196+
197+
if (prompts.isCancel(selectedSession)) {
198+
throw new UI.CancelledError()
199+
}
200+
201+
sessionID = selectedSession as string
202+
}
203+
204+
const sessionInfo = await Session.get(sessionID!)
205+
206+
let content: string
207+
let defaultExtension: string
208+
209+
if (args.format === "json") {
210+
const sessionMessages = await Session.messages({ sessionID: sessionID! })
211+
const exportData = {
212+
info: sessionInfo,
213+
messages: sessionMessages.map((msg) => ({
214+
info: msg.info,
215+
parts: msg.parts,
216+
})),
217+
}
218+
content = JSON.stringify(exportData, null, 2)
219+
defaultExtension = "json"
220+
} else {
221+
content = await Session.exportMarkdown({
222+
sessionID: sessionID!,
223+
includeThinking: true,
224+
includeToolDetails: true,
225+
})
226+
defaultExtension = "md"
227+
}
228+
229+
const outputPath = await (async () => {
230+
if (args.output) return args.output
231+
const defaultFilename = `session-${sessionInfo.id.slice(0, 8)}.${defaultExtension}`
232+
const filenameInput = await prompts.text({
233+
message: "Export filename",
234+
defaultValue: defaultFilename,
235+
output: process.stderr,
236+
})
237+
238+
if (prompts.isCancel(filenameInput)) {
239+
throw new UI.CancelledError()
240+
}
241+
242+
return filenameInput.trim()
243+
})()
244+
245+
await Bun.write(outputPath, content)
246+
247+
prompts.outro(`Session exported to ${outputPath}`, {
248+
output: process.stderr,
249+
})
250+
})
251+
},
252+
})

packages/opencode/src/session/index.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,55 @@ export namespace Session {
305305
},
306306
)
307307

308+
export const exportMarkdown = fn(
309+
z.object({
310+
sessionID: Identifier.schema("session"),
311+
includeThinking: z.boolean().optional().default(true),
312+
includeToolDetails: z.boolean().optional().default(true),
313+
}),
314+
async (input) => {
315+
const sessionInfo = await get(input.sessionID)
316+
const sessionMessages = await messages({ sessionID: input.sessionID })
317+
318+
let transcript = `# ${sessionInfo.title}\n\n`
319+
transcript += `**Session ID:** ${sessionInfo.id}\n`
320+
transcript += `**Created:** ${new Date(sessionInfo.time.created).toLocaleString()}\n`
321+
transcript += `**Updated:** ${new Date(sessionInfo.time.updated).toLocaleString()}\n\n`
322+
transcript += `---\n\n`
323+
324+
for (const msg of sessionMessages) {
325+
const role = msg.info.role === "user" ? "User" : "Assistant"
326+
transcript += `## ${role}\n\n`
327+
328+
for (const part of msg.parts) {
329+
if (part.type === "text" && !part.synthetic) {
330+
transcript += `${part.text}\n\n`
331+
} else if (part.type === "reasoning") {
332+
if (input.includeThinking) {
333+
transcript += `_Thinking:_\n\n${part.text}\n\n`
334+
}
335+
} else if (part.type === "tool") {
336+
transcript += `\`\`\`\nTool: ${part.tool}\n`
337+
if (input.includeToolDetails && part.state.input) {
338+
transcript += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
339+
}
340+
if (input.includeToolDetails && part.state.status === "completed" && part.state.output) {
341+
transcript += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
342+
}
343+
if (input.includeToolDetails && part.state.status === "error" && part.state.error) {
344+
transcript += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
345+
}
346+
transcript += `\n\`\`\`\n\n`
347+
}
348+
}
349+
350+
transcript += `---\n\n`
351+
}
352+
353+
return transcript
354+
},
355+
)
356+
308357
export async function* list() {
309358
const project = Instance.project
310359
for (const item of await Storage.list(["session", project.id])) {

0 commit comments

Comments
 (0)