Skip to content
Merged
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<encoded-cwd>/*.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 "<line>"`), and `file_changed`
(from `Edit` / `Write` / `NotebookEdit` tool uses). `--all` imports every
transcript for a project, `--session <id>` 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
Expand Down
197 changes: 197 additions & 0 deletions packages/cli/src/commands/import.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<Record<string, unknown>>,
): Promise<void> {
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<Record<string, unknown>> {
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<string[]> {
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 <id> 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");
});
});
Loading