diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fc0e471..eaefd164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to **basou** are recorded here. The project follows ## Unreleased +### Added + +- `basou import claude-code` — derive Basou sessions from Claude Code native + transcripts (`~/.claude/projects//*.jsonl`) after the fact, + rather than wrapping a live process. Each transcript becomes one imported + session carrying `session_started` / `session_ended`, `command_executed` + (from `Bash` tool uses, recorded as `bash -c ""`), and `file_changed` + (from `Edit` / `Write` / `NotebookEdit` tool uses). `--all` imports every + transcript for a project, `--session ` imports one, and `--dry-run` + previews without writing. Transcripts with no observable command / file + action are skipped. Imports reuse the existing `session import` pipeline, + so path sanitization and id minting are unchanged. +- `claude-code-import` session `source.kind` (additive enum value) to + distinguish transcript-derived sessions from live `claude-code-adapter` + runs and Basou-format `import`s. + ## 0.3.1 — 2026-05-21 A small follow-up that picks two paper-cut issues out of the v0.3.0 diff --git a/packages/cli/src/commands/import.test.ts b/packages/cli/src/commands/import.test.ts new file mode 100644 index 00000000..92957318 --- /dev/null +++ b/packages/cli/src/commands/import.test.ts @@ -0,0 +1,197 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { devNull, tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { + basouPaths, + createManifest, + ensureBasouDirectory, + readYamlFile, + SessionSchema, + writeManifest, +} from "@basou/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { doRunImportClaudeCode } from "./import.js"; + +const execFileAsync = promisify(execFile); + +const ENV = { + ...process.env, + GIT_CONFIG_GLOBAL: devNull, + GIT_CONFIG_SYSTEM: devNull, +}; + +const FIXED_WS_ID = "ws_01HXABCDEF1234567890ABCDEF" as const; +const FIXED_DATE = new Date("2026-05-09T03:00:00.000Z"); + +let tmpRepo: string | undefined; +let projectsRoot: string | undefined; + +beforeEach(async () => { + tmpRepo = await mkdtemp(join(tmpdir(), "basou-import-cli-test-")); + projectsRoot = await mkdtemp(join(tmpdir(), "basou-import-projects-")); + await execFileAsync("git", ["-c", "init.defaultBranch=main", "init"], { cwd: tmpRepo, env: ENV }); + await execFileAsync("git", ["config", "user.email", "test@example.com"], { + cwd: tmpRepo, + env: ENV, + }); + await execFileAsync("git", ["config", "user.name", "test"], { cwd: tmpRepo, env: ENV }); +}); + +afterEach(async () => { + for (const dir of [tmpRepo, projectsRoot]) { + if (dir !== undefined) await rm(dir, { recursive: true, force: true }); + } + tmpRepo = undefined; + projectsRoot = undefined; + process.exitCode = 0; + vi.restoreAllMocks(); +}); + +function getProjectsRoot(): string { + if (projectsRoot === undefined) throw new Error("projectsRoot not initialized"); + return projectsRoot; +} + +async function setupInitedRepo(): Promise { + const repo = await realpath(tmpRepo as string); + const paths = await ensureBasouDirectory(repo); + const manifest = createManifest({ + workspaceName: "fixture-ws", + now: FIXED_DATE, + workspaceId: FIXED_WS_ID, + }); + await writeManifest(paths, manifest); + return repo; +} + +/** Write a synthetic Claude transcript into the encoded per-project dir. */ +async function writeTranscript( + repo: string, + sessionId: string, + records: Array>, +): Promise { + const encoded = repo.replaceAll("/", "-"); + const dir = join(getProjectsRoot(), encoded); + await mkdir(dir, { recursive: true }); + const body = records.map((r) => JSON.stringify(r)).join("\n"); + await writeFile(join(dir, `${sessionId}.jsonl`), body); +} + +function actionTranscript(repo: string): Array> { + return [ + { + type: "user", + timestamp: "2026-05-10T00:00:00.000Z", + cwd: repo, + sessionId: "sess-1", + message: { role: "user", content: [{ type: "text", text: "go" }] }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:01.000Z", + cwd: repo, + message: { + content: [{ type: "tool_use", name: "Bash", input: { command: "npm test" } }], + }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:02.000Z", + cwd: repo, + message: { + content: [{ type: "tool_use", name: "Edit", input: { file_path: `${repo}/a.ts` } }], + }, + }, + ]; +} + +async function listSessionDirs(repo: string): Promise { + const paths = basouPaths(repo); + try { + return await readdir(paths.sessions); + } catch { + return []; + } +} + +describe("basou import claude-code", () => { + it("--all imports a transcript with actions into a new session", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, "sess-1", actionTranscript(repo)); + + await doRunImportClaudeCode({ all: true }, { cwd: repo, claudeProjectsDir: getProjectsRoot() }); + + const dirs = await listSessionDirs(repo); + expect(dirs).toHaveLength(1); + + const sessionDir = join(basouPaths(repo).sessions, dirs[0] as string); + const session = SessionSchema.parse(await readYamlFile(join(sessionDir, "session.yaml"))); + expect(session.session.source.kind).toBe("claude-code-import"); + expect(session.session.status).toBe("imported"); + + const eventsBody = await readFile(join(sessionDir, "events.jsonl"), "utf8"); + const types = eventsBody + .split("\n") + .filter((l) => l.trim().length > 0) + .map((l) => (JSON.parse(l) as { type: string }).type); + expect(types).toContain("command_executed"); + expect(types).toContain("file_changed"); + expect(types[0]).toBe("session_started"); + expect(types[types.length - 1]).toBe("session_ended"); + }); + + it("--session imports a single transcript by id", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, "sess-1", actionTranscript(repo)); + + await doRunImportClaudeCode( + { session: "sess-1" }, + { cwd: repo, claudeProjectsDir: getProjectsRoot() }, + ); + + expect(await listSessionDirs(repo)).toHaveLength(1); + }); + + it("--dry-run writes nothing to disk", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, "sess-1", actionTranscript(repo)); + + await doRunImportClaudeCode( + { all: true, dryRun: true }, + { cwd: repo, claudeProjectsDir: getProjectsRoot() }, + ); + + expect(await listSessionDirs(repo)).toHaveLength(0); + }); + + it("skips transcripts with no observable action", async () => { + const repo = await setupInitedRepo(); + await writeTranscript(repo, "sess-empty", [ + { + type: "user", + timestamp: "2026-05-10T00:00:00.000Z", + cwd: repo, + message: { content: [{ type: "text", text: "hi" }] }, + }, + ]); + + await doRunImportClaudeCode({ all: true }, { cwd: repo, claudeProjectsDir: getProjectsRoot() }); + expect(await listSessionDirs(repo)).toHaveLength(0); + }); + + it("requires --session or --all", async () => { + const repo = await setupInitedRepo(); + await expect( + doRunImportClaudeCode({}, { cwd: repo, claudeProjectsDir: getProjectsRoot() }), + ).rejects.toThrow("Specify --session or --all"); + }); + + it("errors when the project's transcript directory is absent", async () => { + const repo = await setupInitedRepo(); + await expect( + doRunImportClaudeCode({ all: true }, { cwd: repo, claudeProjectsDir: getProjectsRoot() }), + ).rejects.toThrow("Claude transcript directory not found for project"); + }); +}); diff --git a/packages/cli/src/commands/import.ts b/packages/cli/src/commands/import.ts new file mode 100644 index 00000000..130066ca --- /dev/null +++ b/packages/cli/src/commands/import.ts @@ -0,0 +1,284 @@ +import { readdir, readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + assertBasouRootSafe, + basouPaths, + type ClaudeTranscriptRecord, + claudeTranscriptToImportPayload, + findErrorCode, + type ImportSessionResult, + importSessionFromJson, + readManifest, + resolveRepositoryRoot, + SessionImportPayloadSchema, +} from "@basou/core"; +import type { Command } from "commander"; +import { isVerbose, renderCliError } from "../lib/error-render.js"; + +const SES_PREFIX = "ses_"; +const SHORT_ID_LEN = 6; + +export type ImportClaudeCodeOptions = { + project?: string; + session?: string; + all?: boolean; + dryRun?: boolean; + json?: boolean; + verbose?: boolean; +}; + +export type ImportContext = { + /** Defaults to `process.cwd()`. Injectable for tests. */ + cwd?: string; + /** + * Root that holds per-project transcript directories. Defaults to + * `~/.claude/projects`. Injectable for tests so no real home dir is touched. + */ + claudeProjectsDir?: string; +}; + +/** + * Wire `basou import claude-code` onto `program`. The `import` group mirrors + * the `session` group layout so future adapters (`import codex`, ...) slot in + * without changing the visible surface. + */ +export function registerImportCommand(program: Command): void { + const importCmd = program + .command("import") + .description("Import provenance from an external AI tool's native logs"); + + importCmd + .command("claude-code") + .description("Derive Basou sessions from Claude Code native transcripts (~/.claude/projects)") + .option( + "--project ", + "Source project path whose transcripts to import (defaults to the current repository root)", + ) + .option("--session ", "Import a single transcript by its Claude session id") + .option("--all", "Import every transcript found for the project") + .option("--dry-run", "Validate and preview only; do not write to disk") + .option("--json", "Output the result as JSON") + .option("-v, --verbose", "Show error causes") + .action(async (options: ImportClaudeCodeOptions) => { + await runImportClaudeCode(options); + }); +} + +/** + * Programmatic entry for `basou import claude-code`. Owns process exit state; + * tests should prefer {@link doRunImportClaudeCode}. + */ +export async function runImportClaudeCode( + options: ImportClaudeCodeOptions, + ctx: ImportContext = {}, +): Promise { + try { + await doRunImportClaudeCode(options, ctx); + } catch (error: unknown) { + renderCliError(error, { verbose: isVerbose(options) }); + process.exitCode = 1; + } +} + +export async function doRunImportClaudeCode( + options: ImportClaudeCodeOptions, + ctx: ImportContext, +): Promise { + if (options.session === undefined && options.all !== true) { + throw new Error("Specify --session or --all"); + } + + const cwd = ctx.cwd ?? process.cwd(); + const repositoryRoot = await resolveRepositoryRootForImport(cwd); + const paths = basouPaths(repositoryRoot); + await assertWorkspaceInitialized(paths.root); + const manifest = await readManifest(paths); + + const projectPath = options.project ?? repositoryRoot; + const projectsRoot = ctx.claudeProjectsDir ?? join(homedir(), ".claude", "projects"); + const transcriptDir = join(projectsRoot, encodeProjectDir(projectPath)); + + const files = await selectTranscriptFiles(transcriptDir, options); + + const results: ImportSessionResult[] = []; + let skipped = 0; + let sanitizedPaths = 0; + for (const file of files) { + const records = await readTranscript(file); + const payload = claudeTranscriptToImportPayload(records, { + workspaceId: manifest.workspace.id, + }); + if (payload === null) { + skipped++; + continue; + } + + const parsed = SessionImportPayloadSchema.safeParse(payload); + if (!parsed.success) { + throw new Error("Invalid import payload", { cause: parsed.error }); + } + if (parsed.data.schema_version !== "0.1.0") { + throw new Error(`Unsupported import schema_version: ${parsed.data.schema_version}`); + } + + const result = await importSessionFromJson(paths, manifest, parsed.data, { + dryRun: options.dryRun === true, + }); + results.push(result); + sanitizedPaths += + result.pathSanitizeReport.relatedFiles + + (result.pathSanitizeReport.workingDirectoryRewritten ? 1 : 0); + } + + if (sanitizedPaths > 0) { + console.error(`Imported sessions: ${sanitizedPaths} path(s) sanitized`); + } + + printImportResult(options, results, skipped); +} + +/** + * Encode an absolute project path into Claude Code's per-project directory + * name. Claude Code replaces path separators with `-`, so + * `/Users/x/projects/foo` becomes `-Users-x-projects-foo`. Best-effort for + * the common case; paths with characters the vendor encodes differently may + * need an explicit `--project`-derived directory in a later revision. + */ +function encodeProjectDir(projectPath: string): string { + return projectPath.replaceAll("/", "-"); +} + +async function selectTranscriptFiles( + transcriptDir: string, + options: ImportClaudeCodeOptions, +): Promise { + if (options.session !== undefined) { + return [join(transcriptDir, `${options.session}.jsonl`)]; + } + let entries: string[]; + try { + entries = await readdir(transcriptDir); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) { + throw new Error("Claude transcript directory not found for project", { cause: error }); + } + throw new Error("Failed to read Claude transcript directory", { cause: error }); + } + return entries + .filter((name) => name.endsWith(".jsonl")) + .sort() + .map((name) => join(transcriptDir, name)); +} + +async function readTranscript(file: string): Promise { + let body: string; + try { + body = await readFile(file, "utf8"); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) { + throw new Error("Transcript not found", { cause: error }); + } + if (findErrorCode(error, "EISDIR")) { + throw new Error("Transcript path is not a file", { cause: error }); + } + throw new Error("Failed to read transcript", { cause: error }); + } + + const records: ClaudeTranscriptRecord[] = []; + for (const line of body.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) { + records.push(parsed as ClaudeTranscriptRecord); + } + } catch { + // A malformed line is skipped rather than failing the whole transcript; + // partial native logs should still yield best-effort provenance. + } + } + return records; +} + +function printImportResult( + options: ImportClaudeCodeOptions, + results: ImportSessionResult[], + skipped: number, +): void { + const isDry = options.dryRun === true; + const eventTotal = results.reduce((sum, r) => sum + r.eventCount, 0); + + if (options.json === true) { + console.log( + JSON.stringify({ + imported: results.map((r) => ({ + session_id: r.sessionId, + event_count: r.eventCount, + status: r.finalStatus, + source: { kind: r.finalSourceKind, version: "0.1.0" }, + })), + imported_count: results.length, + skipped_count: skipped, + event_total: eventTotal, + dry_run: isDry, + }), + ); + return; + } + + if (results.length === 0) { + console.log( + skipped > 0 + ? `No sessions imported (${skipped} transcript(s) had no importable actions)` + : "No transcripts found to import", + ); + return; + } + + const skippedSuffix = skipped > 0 ? `; skipped ${skipped} with no actions` : ""; + if (isDry) { + console.log( + `Dry run: would import ${results.length} session(s) (${eventTotal} events)${skippedSuffix}`, + ); + return; + } + + const single = + results.length === 1 && results[0] !== undefined ? ` (${shortId(results[0].sessionId)})` : ""; + console.log( + `Imported ${results.length} session(s)${single} (${eventTotal} events)${skippedSuffix}`, + ); +} + +function shortId(id: string): string { + if (id.startsWith(SES_PREFIX)) { + return id.slice(SES_PREFIX.length, SES_PREFIX.length + SHORT_ID_LEN); + } + return id.slice(0, SHORT_ID_LEN); +} + +async function resolveRepositoryRootForImport(cwd: string): Promise { + try { + return await resolveRepositoryRoot(cwd); + } catch (error: unknown) { + if (error instanceof Error && error.message === "Not a git repository") { + throw new Error("Not a git repository. Run 'git init' first, then re-run 'basou import'.", { + cause: error, + }); + } + throw error; + } +} + +async function assertWorkspaceInitialized(basouRoot: string): Promise { + try { + await assertBasouRootSafe(basouRoot); + } catch (error: unknown) { + if (findErrorCode(error, "ENOENT")) { + throw new Error("Workspace not initialized. Run 'basou init' first."); + } + throw error; + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 989be858..e3d9d630 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,6 +5,7 @@ import { registerDecisionCommand } from "./commands/decision.js"; import { registerDecisionsCommand } from "./commands/decisions.js"; import { registerExecCommand } from "./commands/exec.js"; import { registerHandoffCommand } from "./commands/handoff.js"; +import { registerImportCommand } from "./commands/import.js"; import { registerInitCommand } from "./commands/init.js"; import { registerRunCommand } from "./commands/run.js"; import { registerSessionCommand } from "./commands/session.js"; @@ -36,6 +37,7 @@ registerStatusCommand(program); registerExecCommand(program); registerRunCommand(program); registerSessionCommand(program); +registerImportCommand(program); registerApprovalCommand(program); registerDecisionCommand(program); registerTaskCommand(program); diff --git a/packages/core/src/adapters/claude-code/index.ts b/packages/core/src/adapters/claude-code/index.ts index 3729a5ca..3206bd05 100644 --- a/packages/core/src/adapters/claude-code/index.ts +++ b/packages/core/src/adapters/claude-code/index.ts @@ -4,3 +4,8 @@ export { resolveClaudeCodeCommand, summarizeAdapterOutput, } from "./claude-code-adapter.js"; +export type { + ClaudeTranscriptRecord, + ClaudeTranscriptToPayloadOptions, +} from "./transcript-importer.js"; +export { CLAUDE_IMPORT_SOURCE, claudeTranscriptToImportPayload } from "./transcript-importer.js"; diff --git a/packages/core/src/adapters/claude-code/transcript-importer.test.ts b/packages/core/src/adapters/claude-code/transcript-importer.test.ts new file mode 100644 index 00000000..97cdf62a --- /dev/null +++ b/packages/core/src/adapters/claude-code/transcript-importer.test.ts @@ -0,0 +1,205 @@ +import { describe, expect, it } from "vitest"; +import { SessionImportPayloadSchema } from "../../schemas/session-import.schema.js"; +import { + CLAUDE_IMPORT_SOURCE, + type ClaudeTranscriptRecord, + claudeTranscriptToImportPayload, +} from "./transcript-importer.js"; + +const WS_ID = "ws_01HXABCDEF1234567890ABCDEF"; +const CWD = "/Users/x/projects/foo"; + +function transform(records: ClaudeTranscriptRecord[]) { + return claudeTranscriptToImportPayload(records, { workspaceId: WS_ID }); +} + +describe("claudeTranscriptToImportPayload", () => { + it("derives session lifecycle + command_executed + file_changed from tool uses", () => { + const records: ClaudeTranscriptRecord[] = [ + { + type: "user", + timestamp: "2026-05-10T00:00:00.000Z", + cwd: CWD, + sessionId: "abc-123", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:01.000Z", + cwd: CWD, + message: { + content: [ + { type: "text", text: "running" }, + { type: "tool_use", name: "Bash", input: { command: "npm test", description: "test" } }, + ], + }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:02.000Z", + cwd: CWD, + message: { + content: [ + { type: "tool_use", name: "Edit", input: { file_path: `${CWD}/a.ts` } }, + { type: "tool_use", name: "Write", input: { file_path: `${CWD}/b.ts` } }, + ], + }, + }, + // Read is not a captured action; the record must be ignored. + { + type: "assistant", + timestamp: "2026-05-10T00:00:03.000Z", + cwd: CWD, + message: { + content: [{ type: "tool_use", name: "Read", input: { file_path: `${CWD}/c.ts` } }], + }, + }, + ]; + + const payload = transform(records); + expect(payload).not.toBeNull(); + if (payload === null) return; + + // Valid against the import payload contract. + expect(SessionImportPayloadSchema.safeParse(payload).success).toBe(true); + + expect(payload.session.source.kind).toBe(CLAUDE_IMPORT_SOURCE); + expect(payload.session.started_at).toBe("2026-05-10T00:00:00.000Z"); + expect(payload.session.ended_at).toBe("2026-05-10T00:00:03.000Z"); + expect(payload.session.working_directory).toBe(CWD); + expect(payload.session.workspace_id).toBe(WS_ID); + expect(payload.session.related_files).toEqual([`${CWD}/a.ts`, `${CWD}/b.ts`]); + + const types = payload.events.map((e) => e.type); + expect(types).toEqual([ + "session_started", + "command_executed", + "file_changed", + "file_changed", + "session_ended", + ]); + + const command = payload.events[1]; + if (command?.type !== "command_executed") throw new Error("expected command_executed"); + expect(command.command).toBe("bash"); + expect(command.args).toEqual(["-c", "npm test"]); + expect(command.cwd).toBe(CWD); + expect(command.exit_code).toBeNull(); + expect(command.duration_ms).toBe(0); + + const edit = payload.events[2]; + if (edit?.type !== "file_changed") throw new Error("expected file_changed"); + expect(edit.change_type).toBe("modified"); + + const write = payload.events[3]; + if (write?.type !== "file_changed") throw new Error("expected file_changed"); + expect(write.change_type).toBe("added"); + }); + + it("emits events in non-decreasing chronological order", () => { + const records: ClaudeTranscriptRecord[] = [ + { type: "user", timestamp: "2026-05-10T00:00:00.000Z", cwd: CWD, message: { content: [] } }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:05.000Z", + cwd: CWD, + message: { content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] }, + }, + ]; + const payload = transform(records); + expect(payload).not.toBeNull(); + if (payload === null) return; + for (let i = 1; i < payload.events.length; i++) { + const prevEvent = payload.events[i - 1]; + const currEvent = payload.events[i]; + if (prevEvent === undefined || currEvent === undefined) continue; + expect(Date.parse(currEvent.occurred_at)).toBeGreaterThanOrEqual( + Date.parse(prevEvent.occurred_at), + ); + } + }); + + it("orders output even when transcript records are not timestamp-sorted on disk", () => { + // Real transcripts interleave sidechain / async-written records, so file + // order is not chronological. The transform must sort regardless. + const records: ClaudeTranscriptRecord[] = [ + { + type: "assistant", + timestamp: "2026-05-10T00:00:05.000Z", + cwd: CWD, + message: { content: [{ type: "tool_use", name: "Bash", input: { command: "second" } }] }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:01.000Z", + cwd: CWD, + message: { content: [{ type: "tool_use", name: "Bash", input: { command: "first" } }] }, + }, + ]; + const payload = transform(records); + expect(payload).not.toBeNull(); + if (payload === null) return; + expect(SessionImportPayloadSchema.safeParse(payload).success).toBe(true); + expect(payload.session.started_at).toBe("2026-05-10T00:00:01.000Z"); + expect(payload.session.ended_at).toBe("2026-05-10T00:00:05.000Z"); + for (let i = 1; i < payload.events.length; i++) { + const prevEvent = payload.events[i - 1]; + const currEvent = payload.events[i]; + if (prevEvent === undefined || currEvent === undefined) continue; + expect(Date.parse(currEvent.occurred_at)).toBeGreaterThanOrEqual( + Date.parse(prevEvent.occurred_at), + ); + } + // The earlier command must come first after sorting. + const commands = payload.events.filter((e) => e.type === "command_executed"); + expect(commands).toHaveLength(2); + if (commands[0]?.type === "command_executed") { + expect(commands[0].args).toEqual(["-c", "first"]); + } + }); + + it("returns null when no observable command / file action exists", () => { + const records: ClaudeTranscriptRecord[] = [ + { + type: "user", + timestamp: "2026-05-10T00:00:00.000Z", + cwd: CWD, + message: { content: [{ type: "text", text: "just chatting" }] }, + }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:01.000Z", + cwd: CWD, + message: { + content: [{ type: "tool_use", name: "Read", input: { file_path: `${CWD}/x` } }], + }, + }, + ]; + expect(transform(records)).toBeNull(); + }); + + it("returns null for an empty transcript", () => { + expect(transform([])).toBeNull(); + }); + + it("skips malformed-shaped records without throwing", () => { + const records: ClaudeTranscriptRecord[] = [ + { type: "queue-operation", operation: "enqueue" }, // no timestamp + { type: "assistant", timestamp: "2026-05-10T00:00:01.000Z", message: "not-an-object" }, + { + type: "assistant", + timestamp: "2026-05-10T00:00:02.000Z", + cwd: CWD, + message: { content: [{ type: "tool_use", name: "Bash", input: { command: "echo hi" } }] }, + }, + ]; + const payload = transform(records); + expect(payload).not.toBeNull(); + if (payload === null) return; + expect(payload.events.map((e) => e.type)).toEqual([ + "session_started", + "command_executed", + "session_ended", + ]); + }); +}); diff --git a/packages/core/src/adapters/claude-code/transcript-importer.ts b/packages/core/src/adapters/claude-code/transcript-importer.ts new file mode 100644 index 00000000..80245a25 --- /dev/null +++ b/packages/core/src/adapters/claude-code/transcript-importer.ts @@ -0,0 +1,229 @@ +import { type PrefixedId, prefixedUlid } from "../../ids/ulid.js"; +import type { Event } from "../../schemas/event.schema.js"; +import type { Manifest } from "../../schemas/manifest.schema.js"; +import type { SessionImportPayload } from "../../schemas/session-import.schema.js"; + +/** + * The `source` string stamped on every event derived from a Claude Code + * native transcript, and the matching session `source.kind`. + */ +export const CLAUDE_IMPORT_SOURCE = "claude-code-import"; + +/** + * One parsed line of a Claude Code native transcript + * (`~/.claude/projects//.jsonl`). The shape is the + * vendor's internal message log, not Basou's event schema, so every field + * is read defensively — unknown record types and missing fields are skipped + * rather than rejected (transcripts are an undocumented format that may gain + * fields between Claude Code releases). + */ +export type ClaudeTranscriptRecord = Record; + +/** Options for {@link claudeTranscriptToImportPayload}. */ +export type ClaudeTranscriptToPayloadOptions = { + /** Workspace id of the target Basou workspace (from its manifest). */ + workspaceId: Manifest["workspace"]["id"]; +}; + +/** + * Transform a Claude Code native transcript into a Basou + * {@link SessionImportPayload}, ready to hand to `importSessionFromJson`. + * + * This is a pure function: no disk or environment access. It DERIVES Basou's + * provenance-level events from the transcript's message-level records, rather + * than mapping one-to-one: + * + * - `session_started` / `session_ended` from the first / last timestamped record. + * - `command_executed` from each `Bash` tool use, recorded as `bash -c ""` + * (the transcript carries the shell line, not a parsed argv). + * - `file_changed` from each `Edit` / `Write` / `NotebookEdit` tool use. + * + * Exit codes and per-command durations are not present in the transcript, so + * `command_executed.exit_code` is `null` and `duration_ms` is `0`. + * + * Returns `null` when the transcript has no timestamped records, or no + * observable command / file action — such sessions carry no provenance worth + * importing and are skipped by the caller. + * + * Event `id` / `session_id` are placeholders; `importSessionFromJson` mints + * fresh ids on the way in. They are valid-by-construction so the payload + * still passes `SessionImportPayloadSchema` validation upstream. + */ +export function claudeTranscriptToImportPayload( + records: ReadonlyArray, + options: ClaudeTranscriptToPayloadOptions, +): SessionImportPayload | null { + const placeholderSessionId = prefixedUlid("ses"); + const derived: Event[] = []; + const relatedFiles = new Set(); + // Real transcripts are NOT strictly ordered by timestamp on disk + // (sidechains and async writes interleave), so file order cannot be + // trusted. Track the earliest / latest timestamp explicitly, and order the + // derived events by occurred_at below. + let minTs: string | undefined; + let maxTs: string | undefined; + let workingDir: string | undefined; + let claudeSessionId: string | undefined; + + for (const record of records) { + const ts = readString(record.timestamp); + if (ts === undefined) continue; + if (minTs === undefined || Date.parse(ts) < Date.parse(minTs)) minTs = ts; + if (maxTs === undefined || Date.parse(ts) > Date.parse(maxTs)) maxTs = ts; + if (workingDir === undefined) workingDir = readString(record.cwd); + if (claudeSessionId === undefined) claudeSessionId = readString(record.sessionId); + + if (readString(record.type) !== "assistant") continue; + const cwd = readString(record.cwd) ?? workingDir ?? "."; + for (const item of toolUses(record)) { + const name = readString(item.name); + const input = isObject(item.input) ? item.input : undefined; + if (input === undefined) continue; + + if (name === "Bash") { + const command = readString(input.command); + if (command !== undefined) { + derived.push(commandExecutedEvent(ts, placeholderSessionId, command, cwd)); + } + continue; + } + + if (name === "Edit" || name === "Write" || name === "NotebookEdit") { + const path = readString(input.file_path) ?? readString(input.notebook_path); + if (path !== undefined) { + // Edit / NotebookEdit mutate an existing file; Write is treated as a + // creation. The transcript does not reliably distinguish a Write + // that overwrites, so "added" is the conservative default. + const changeType = name === "Write" ? "added" : "modified"; + relatedFiles.add(path); + derived.push(fileChangedEvent(ts, placeholderSessionId, path, changeType)); + } + } + } + } + + if (minTs === undefined || maxTs === undefined) return null; + if (derived.length === 0) return null; + + // Order derived events by occurred_at so the assembled stream is + // non-decreasing — importSessionFromJson rejects out-of-order events. + // Array.prototype.sort is stable, so same-timestamp events keep their + // transcript order. + derived.sort((a, b) => Date.parse(a.occurred_at) - Date.parse(b.occurred_at)); + + const events: Event[] = [ + sessionStartedEvent(minTs, placeholderSessionId), + ...derived, + sessionEndedEvent(maxTs, placeholderSessionId), + ]; + + const label = + claudeSessionId !== undefined ? `claude-code import ${claudeSessionId}` : "claude-code import"; + + const payload: SessionImportPayload = { + schema_version: "0.1.0", + session: { + label, + workspace_id: options.workspaceId, + source: { kind: CLAUDE_IMPORT_SOURCE, version: "0.1.0" }, + started_at: minTs, + ended_at: maxTs, + // Validated against the canonical enum here; importSessionFromJson + // overwrites it with the literal "imported" regardless. + status: "imported", + working_directory: workingDir ?? ".", + invocation: { command: "claude", args: [], exit_code: null }, + related_files: [...relatedFiles].sort(), + summary: null, + }, + events, + }; + return payload; +} + +// --- event builders ------------------------------------------------------- + +function baseEvent( + occurredAt: string, + sessionId: PrefixedId<"ses">, +): { + schema_version: "0.1.0"; + id: PrefixedId<"evt">; + session_id: PrefixedId<"ses">; + occurred_at: string; + source: string; +} { + return { + schema_version: "0.1.0", + id: prefixedUlid("evt"), + session_id: sessionId, + occurred_at: occurredAt, + source: CLAUDE_IMPORT_SOURCE, + }; +} + +function sessionStartedEvent(occurredAt: string, sessionId: PrefixedId<"ses">): Event { + return { ...baseEvent(occurredAt, sessionId), type: "session_started" }; +} + +function sessionEndedEvent(occurredAt: string, sessionId: PrefixedId<"ses">): Event { + return { ...baseEvent(occurredAt, sessionId), type: "session_ended" }; +} + +function commandExecutedEvent( + occurredAt: string, + sessionId: PrefixedId<"ses">, + command: string, + cwd: string, +): Event { + return { + ...baseEvent(occurredAt, sessionId), + type: "command_executed", + command: "bash", + args: ["-c", command], + cwd, + exit_code: null, + duration_ms: 0, + }; +} + +function fileChangedEvent( + occurredAt: string, + sessionId: PrefixedId<"ses">, + path: string, + changeType: "added" | "modified", +): Event { + return { + ...baseEvent(occurredAt, sessionId), + type: "file_changed", + path, + change_type: changeType, + }; +} + +// --- defensive readers ---------------------------------------------------- + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Extract the `tool_use` items from an assistant record's + * `message.content[]`. Returns an empty array for any record that does not + * match the expected nesting, so callers can iterate unconditionally. + */ +function toolUses(record: ClaudeTranscriptRecord): Array> { + const message = isObject(record.message) ? record.message : undefined; + const content = message !== undefined && Array.isArray(message.content) ? message.content : []; + const result: Array> = []; + for (const item of content) { + if (isObject(item) && readString(item.type) === "tool_use") { + result.push(item); + } + } + return result; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index b11bea49..5f3aba9f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,9 +4,15 @@ */ export const BASOU_CORE_VERSION = "0.1.0"; -export type { CommandLookup } from "./adapters/claude-code/index.js"; +export type { + ClaudeTranscriptRecord, + ClaudeTranscriptToPayloadOptions, + CommandLookup, +} from "./adapters/claude-code/index.js"; export { + CLAUDE_IMPORT_SOURCE, claudeCodeAdapterMetadata, + claudeTranscriptToImportPayload, resolveClaudeCodeCommand, summarizeAdapterOutput, } from "./adapters/claude-code/index.js"; diff --git a/packages/core/src/schemas/session.schema.ts b/packages/core/src/schemas/session.schema.ts index d87283f0..94310565 100644 --- a/packages/core/src/schemas/session.schema.ts +++ b/packages/core/src/schemas/session.schema.ts @@ -21,9 +21,18 @@ export const SessionStatusSchema = z.enum([ /** Inferred runtime type for {@link SessionStatusSchema}. */ export type SessionStatus = z.infer; -/** Source kind that produced the session. */ +/** + * Source kind that produced the session. + * + * - `claude-code-adapter` — a live `basou run claude-code` process wrap. + * - `claude-code-import` — derived after the fact from a Claude Code native + * transcript (`~/.claude/projects/*.jsonl`) by `basou import claude-code`. + * - `import` — a round-trip of a Basou-format export (`basou session import`). + * - `human` / `terminal` — manually-authored / terminal-recorded sessions. + */ export const SessionSourceKindSchema = z.enum([ "claude-code-adapter", + "claude-code-import", "human", "import", "terminal",