From 087e2f35ad5ae618d262754b3ccf5fe10e691fc0 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Wed, 28 Jan 2026 01:18:52 +0800 Subject: [PATCH] feat(cli): add session export/import commands Add 'kilocode export' and 'kilocode import' subcommands for session portability. Export: kilocode export [sessionId] [-o output.json] - Exports session metadata and conversation history to JSON - Fetches data from cloud blob storage Import: kilocode import - Reads and validates exported session JSON - Shows preview of session contents - Provides guidance for full restore via /session fork Exported data includes: - Session metadata (title, mode, model, timestamps) - API conversation history - UI messages - Task metadata --- .changeset/cli-session-export.md | 5 + .../commands/__tests__/session-export.test.ts | 296 ++++++++++++++++++ cli/src/commands/session-export.ts | 229 ++++++++++++++ cli/src/index.ts | 40 ++- 4 files changed, 569 insertions(+), 1 deletion(-) create mode 100644 .changeset/cli-session-export.md create mode 100644 cli/src/commands/__tests__/session-export.test.ts create mode 100644 cli/src/commands/session-export.ts diff --git a/.changeset/cli-session-export.md b/.changeset/cli-session-export.md new file mode 100644 index 00000000000..dbafb18c7b4 --- /dev/null +++ b/.changeset/cli-session-export.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": minor +--- + +Add session export/import commands (`kilocode export`, `kilocode import`) diff --git a/cli/src/commands/__tests__/session-export.test.ts b/cli/src/commands/__tests__/session-export.test.ts new file mode 100644 index 00000000000..d1c2e3f09e2 --- /dev/null +++ b/cli/src/commands/__tests__/session-export.test.ts @@ -0,0 +1,296 @@ +/** + * Tests for the session export/import commands + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import { readExportedSession, previewImport, exportSession, type ExportedSession } from "../session-export.js" +import * as fs from "fs" +import { loadConfig, getKiloToken } from "../../config/persistence.js" + +// Mock fs module +vi.mock("fs", () => ({ + writeFileSync: vi.fn(), + readFileSync: vi.fn(), + existsSync: vi.fn(), +})) + +// Mock config/persistence +vi.mock("../../config/persistence.js", () => ({ + loadConfig: vi.fn(), + getKiloToken: vi.fn(), +})) + +// Mock config/env-config +vi.mock("../../config/env-config.js", () => ({ + applyEnvOverrides: vi.fn((config) => config), +})) + +// Mock SessionClient and TrpcClient as classes +const mockSessionClientGet = vi.fn() +vi.mock("../../../../src/shared/kilocode/cli-sessions/core/SessionClient.js", () => ({ + SessionClient: function () { + this.get = mockSessionClientGet + }, +})) + +vi.mock("../../../../src/shared/kilocode/cli-sessions/core/TrpcClient.js", () => ({ + TrpcClient: function () {}, +})) + +// Mock global fetch +const mockFetch = vi.fn() +vi.stubGlobal("fetch", mockFetch) + +describe("session-export", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("readExportedSession", () => { + it("should throw error when file does not exist", () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + expect(() => readExportedSession("nonexistent.json")).toThrow("File not found") + }) + + it("should throw error for invalid JSON structure", () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue("{}") + + expect(() => readExportedSession("invalid.json")).toThrow("Invalid session export format") + }) + + it("should throw error for unsupported version", () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify({ + version: 999, + session: { id: "test" }, + data: {}, + }), + ) + + expect(() => readExportedSession("future.json")).toThrow("Unsupported export version") + }) + + it("should parse valid exported session", () => { + const validExport: ExportedSession = { + version: 1, + exportedAt: "2026-01-28T00:00:00Z", + session: { + id: "test-session", + title: "Test Session", + createdAt: "2026-01-28T00:00:00Z", + updatedAt: "2026-01-28T00:00:00Z", + gitUrl: null, + mode: "code", + model: "claude-sonnet-4", + }, + data: { + apiConversationHistory: [{ role: "user", content: "hello" }], + uiMessages: [{ type: "say", text: "hi" }], + taskMetadata: { some: "data" }, + gitState: null, + }, + } + + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(validExport)) + + const result = readExportedSession("valid.json") + + expect(result.version).toBe(1) + expect(result.session.id).toBe("test-session") + expect(result.session.title).toBe("Test Session") + expect(result.data.apiConversationHistory).toHaveLength(1) + expect(result.data.uiMessages).toHaveLength(1) + }) + }) + + describe("previewImport", () => { + it("should format session preview correctly", () => { + const exported: ExportedSession = { + version: 1, + exportedAt: "2026-01-28T00:00:00Z", + session: { + id: "abc123", + title: "My Session", + createdAt: "2026-01-28T00:00:00Z", + updatedAt: "2026-01-28T00:00:00Z", + gitUrl: "https://github.com/test/repo", + mode: "architect", + model: "gpt-4o", + }, + data: { + apiConversationHistory: [1, 2, 3] as unknown[], + uiMessages: [1, 2] as unknown[], + taskMetadata: { exists: true }, + gitState: { branch: "main" }, + }, + } + + const preview = previewImport(exported) + + expect(preview).toContain("Session: My Session") + expect(preview).toContain("Original ID: abc123") + expect(preview).toContain("Mode: architect") + expect(preview).toContain("Model: gpt-4o") + expect(preview).toContain("API History: 3 messages") + expect(preview).toContain("UI Messages: 2 messages") + expect(preview).toContain("Task Metadata: Yes") + expect(preview).toContain("Git State: Yes") + }) + + it("should handle missing optional fields", () => { + const exported: ExportedSession = { + version: 1, + exportedAt: "2026-01-28T00:00:00Z", + session: { + id: "abc123", + title: "", + createdAt: "2026-01-28T00:00:00Z", + updatedAt: "2026-01-28T00:00:00Z", + gitUrl: null, + mode: null, + model: null, + }, + data: { + apiConversationHistory: null, + uiMessages: null, + taskMetadata: null, + gitState: null, + }, + } + + const preview = previewImport(exported) + + expect(preview).toContain("Session: Untitled") + expect(preview).toContain("Mode: N/A") + expect(preview).toContain("Model: N/A") + expect(preview).toContain("API History: 0 messages") + expect(preview).toContain("UI Messages: 0 messages") + expect(preview).toContain("Task Metadata: No") + expect(preview).toContain("Git State: No") + }) + }) + + describe("exportSession", () => { + const mockSession = { + session_id: "test-123", + title: "Test Session", + created_at: "2026-01-28T00:00:00Z", + updated_at: "2026-01-28T01:00:00Z", + git_url: "https://github.com/test/repo", + last_mode: "code", + last_model: "claude-sonnet-4", + api_conversation_history_blob_url: "https://blob.example.com/api-history", + ui_messages_blob_url: "https://blob.example.com/ui-messages", + task_metadata_blob_url: "https://blob.example.com/task-metadata", + git_state_blob_url: "https://blob.example.com/git-state", + } + + const mockApiHistory = [{ role: "user", content: "hello" }, { role: "assistant", content: "hi" }] + const mockUiMessages = [{ type: "say", text: "hello" }] + const mockTaskMetadata = { task: "test" } + const mockGitState = { branch: "main", commit: "abc123" } + + beforeEach(() => { + vi.mocked(loadConfig).mockResolvedValue({ config: {}, configPath: "/test/config" }) + vi.mocked(getKiloToken).mockReturnValue("test-token") + mockSessionClientGet.mockReset() + }) + + it("should throw error when no token is configured", async () => { + vi.mocked(getKiloToken).mockReturnValue(null) + + await expect(exportSession("test-123")).rejects.toThrow("No Kilo Code token found") + }) + + it("should throw error when session is not found", async () => { + mockSessionClientGet.mockResolvedValue(null) + + await expect(exportSession("nonexistent")).rejects.toThrow("Session not found: nonexistent") + }) + + it("should export session with all blob data", async () => { + mockSessionClientGet.mockResolvedValue(mockSession) + + mockFetch.mockImplementation((url: string) => { + const responses: Record = { + "https://blob.example.com/api-history": mockApiHistory, + "https://blob.example.com/ui-messages": mockUiMessages, + "https://blob.example.com/task-metadata": mockTaskMetadata, + "https://blob.example.com/git-state": mockGitState, + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(responses[url]), + }) + }) + + const result = await exportSession("test-123") + + expect(result.version).toBe(1) + expect(result.session.id).toBe("test-123") + expect(result.session.title).toBe("Test Session") + expect(result.session.gitUrl).toBe("https://github.com/test/repo") + expect(result.session.mode).toBe("code") + expect(result.session.model).toBe("claude-sonnet-4") + expect(result.data.apiConversationHistory).toEqual(mockApiHistory) + expect(result.data.uiMessages).toEqual(mockUiMessages) + expect(result.data.taskMetadata).toEqual(mockTaskMetadata) + expect(result.data.gitState).toEqual(mockGitState) + expect(result.exportedAt).toBeDefined() + }) + + it("should handle blob fetch failures gracefully", async () => { + const sessionWithoutBlobs = { + ...mockSession, + api_conversation_history_blob_url: "https://blob.example.com/fail", + } + + mockSessionClientGet.mockResolvedValue(sessionWithoutBlobs) + + mockFetch.mockImplementation((url: string) => { + if (url === "https://blob.example.com/fail") { + return Promise.resolve({ ok: false, status: 404, statusText: "Not Found" }) + } + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(null), + }) + }) + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await exportSession("test-123") + + expect(result.data.apiConversationHistory).toBeNull() + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Warning")) + + consoleErrorSpy.mockRestore() + }) + + it("should export session without blob URLs", async () => { + const sessionWithoutUrls = { + session_id: "test-456", + title: "Simple Session", + created_at: "2026-01-28T00:00:00Z", + updated_at: "2026-01-28T00:00:00Z", + git_url: null, + last_mode: null, + last_model: null, + } + + mockSessionClientGet.mockResolvedValue(sessionWithoutUrls) + + const result = await exportSession("test-456") + + expect(result.session.id).toBe("test-456") + expect(result.data.apiConversationHistory).toBeNull() + expect(result.data.uiMessages).toBeNull() + expect(result.data.taskMetadata).toBeNull() + expect(result.data.gitState).toBeNull() + }) + }) +}) diff --git a/cli/src/commands/session-export.ts b/cli/src/commands/session-export.ts new file mode 100644 index 00000000000..993095ac6a4 --- /dev/null +++ b/cli/src/commands/session-export.ts @@ -0,0 +1,229 @@ +/** + * Session export/import functionality + * Allows exporting sessions to JSON files and importing them back + */ + +import { writeFileSync, readFileSync, existsSync } from "fs" +import { resolve } from "path" +import { SessionClient } from "../../../src/shared/kilocode/cli-sessions/core/SessionClient.js" +import { TrpcClient } from "../../../src/shared/kilocode/cli-sessions/core/TrpcClient.js" +import { loadConfig, getKiloToken } from "../config/persistence.js" +import { applyEnvOverrides } from "../config/env-config.js" + +export interface ExportedSession { + version: number + exportedAt: string + session: { + id: string + title: string + createdAt: string + updatedAt: string + gitUrl: string | null + mode: string | null + model: string | null + } + data: { + apiConversationHistory: unknown[] | null + uiMessages: unknown[] | null + taskMetadata: unknown | null + gitState: unknown | null + } +} + +const EXPORT_VERSION = 1 + +/** + * Create a lightweight session client for export operations + */ +async function createSessionClient(): Promise { + const { config } = await loadConfig() + const finalConfig = applyEnvOverrides(config) + const token = getKiloToken(finalConfig) + + if (!token) { + throw new Error("No Kilo Code token found. Run 'kilocode config' to set your token.") + } + + const trpcClient = new TrpcClient({ + getToken: () => Promise.resolve(token), + }) + + return new SessionClient(trpcClient) +} + +/** + * Export a session to JSON format + */ +export async function exportSession(sessionId: string): Promise { + if (!sessionId) { + throw new Error("Session ID is required") + } + + const sessionClient = await createSessionClient() + + // Get session with blob URLs + const session = await sessionClient.get({ + session_id: sessionId, + include_blob_urls: true, + }) + + if (!session) { + throw new Error(`Session not found: ${sessionId}`) + } + + // Fetch blob data if available + let apiConversationHistory: unknown[] | null = null + let uiMessages: unknown[] | null = null + let taskMetadata: unknown | null = null + let gitState: unknown | null = null + const warnings: string[] = [] + + const sessionWithUrls = session as { + api_conversation_history_blob_url?: string | null + ui_messages_blob_url?: string | null + task_metadata_blob_url?: string | null + git_state_blob_url?: string | null + } + + if (sessionWithUrls.api_conversation_history_blob_url) { + try { + const response = await fetch(sessionWithUrls.api_conversation_history_blob_url) + if (response.ok) { + apiConversationHistory = await response.json() + } else { + warnings.push(`Failed to fetch API conversation history: ${response.status} ${response.statusText}`) + } + } catch (error) { + warnings.push( + `Failed to fetch API conversation history: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + if (sessionWithUrls.ui_messages_blob_url) { + try { + const response = await fetch(sessionWithUrls.ui_messages_blob_url) + if (response.ok) { + uiMessages = await response.json() + } else { + warnings.push(`Failed to fetch UI messages: ${response.status} ${response.statusText}`) + } + } catch (error) { + warnings.push(`Failed to fetch UI messages: ${error instanceof Error ? error.message : String(error)}`) + } + } + + if (sessionWithUrls.task_metadata_blob_url) { + try { + const response = await fetch(sessionWithUrls.task_metadata_blob_url) + if (response.ok) { + taskMetadata = await response.json() + } else { + warnings.push(`Failed to fetch task metadata: ${response.status} ${response.statusText}`) + } + } catch (error) { + warnings.push(`Failed to fetch task metadata: ${error instanceof Error ? error.message : String(error)}`) + } + } + + if (sessionWithUrls.git_state_blob_url) { + try { + const response = await fetch(sessionWithUrls.git_state_blob_url) + if (response.ok) { + gitState = await response.json() + } else { + warnings.push(`Failed to fetch git state: ${response.status} ${response.statusText}`) + } + } catch (error) { + warnings.push(`Failed to fetch git state: ${error instanceof Error ? error.message : String(error)}`) + } + } + + // Report warnings to stderr + for (const warning of warnings) { + console.error(`⚠️ Warning: ${warning}`) + } + + return { + version: EXPORT_VERSION, + exportedAt: new Date().toISOString(), + session: { + id: session.session_id, + title: session.title, + createdAt: session.created_at, + updatedAt: session.updated_at, + gitUrl: session.git_url, + mode: session.last_mode, + model: session.last_model, + }, + data: { + apiConversationHistory, + uiMessages, + taskMetadata, + gitState, + }, + } +} + +/** + * Export session to a file + */ +export async function exportSessionToFile(sessionId: string, outputPath?: string): Promise { + const exported = await exportSession(sessionId) + + const filename = outputPath || `kilocode-session-${exported.session.id}.json` + const fullPath = resolve(filename) + + writeFileSync(fullPath, JSON.stringify(exported, null, 2)) + + return fullPath +} + +/** + * Read and validate an exported session file + */ +export function readExportedSession(filePath: string): ExportedSession { + const fullPath = resolve(filePath) + + if (!existsSync(fullPath)) { + throw new Error(`File not found: ${fullPath}`) + } + + const content = readFileSync(fullPath, "utf-8") + const data = JSON.parse(content) as ExportedSession + + // Validate structure + if (!data.version || !data.session || !data.data) { + throw new Error("Invalid session export format") + } + + if (data.version > EXPORT_VERSION) { + throw new Error(`Unsupported export version: ${data.version}. Please update kilocode CLI.`) + } + + return data +} + +/** + * Import a session from exported data (preview only - shows what would be imported) + * Note: Full import requires cloud API support which may not be available + */ +export function previewImport(exported: ExportedSession): string { + const { session, data } = exported + + const lines = [ + `Session: ${session.title || "Untitled"}`, + `Original ID: ${session.id}`, + `Created: ${new Date(session.createdAt).toLocaleString()}`, + `Mode: ${session.mode || "N/A"}`, + `Model: ${session.model || "N/A"}`, + ``, + `Data:`, + ` - API History: ${data.apiConversationHistory?.length || 0} messages`, + ` - UI Messages: ${data.uiMessages?.length || 0} messages`, + ` - Task Metadata: ${data.taskMetadata ? "Yes" : "No"}`, + ` - Git State: ${data.gitState ? "Yes" : "No"}`, + ] + + return lines.join("\n") +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 9fd583f7511..b49b3146589 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -67,7 +67,7 @@ program // Subcommand names - if prompt matches one, Commander.js should handle it via subcommand // This is a defensive check for cases where Commander.js routing might not work as expected // (e.g., when spawned as a child process with stdin disconnected) - const SUBCOMMANDS = ["auth", "config", "debug", "models"] + const SUBCOMMANDS = ["auth", "config", "debug", "models", "export", "import"] if (SUBCOMMANDS.includes(prompt)) { return } @@ -404,6 +404,44 @@ program await modelsApiCommand(options) }) +// Export command - export a session to JSON file +program + .command("export") + .description("Export a session to a JSON file") + .argument("", "Session ID to export") + .option("-o, --output ", "Output file path") + .action(async (sessionId: string, options?: { output?: string }) => { + const { exportSessionToFile } = await import("./commands/session-export.js") + try { + const outputPath = await exportSessionToFile(sessionId, options?.output) + console.log(`✅ Session exported to: ${outputPath}`) + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) + } + }) + +// Import command - import a session from JSON file +program + .command("import") + .description("Import a session from a JSON file") + .argument("", "Path to the exported session JSON file") + .action(async (file: string) => { + const { readExportedSession, previewImport } = await import("./commands/session-export.js") + try { + const exported = readExportedSession(file) + console.log("Session Preview:") + console.log("─".repeat(40)) + console.log(previewImport(exported)) + console.log("─".repeat(40)) + console.log("\n⚠️ Note: Full session import requires starting the CLI and using /session fork") + console.log(` The original session ID is: ${exported.session.id}`) + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) + } + }) + // Handle process termination signals process.on("SIGINT", async () => { if (cli?.requestExitConfirmation()) {