diff --git a/CHANGELOG.md b/CHANGELOG.md index f523079c..0ef47106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- `base44 sessions import` continues a Claude Code or Codex session in Base Code and prints the branch link. `--current` picks the session the command runs inside (what Base Code's Import session prompt runs), a claude.ai session address pulls a cloud session down with `claude --teleport`, `--file` uploads a file as is, and no argument opens a picker. `--token ` uses the one-time key from Base Code instead of the CLI login. Before uploading the current session it stops if the work isn't on GitHub yet (Base Code starts from there) — `--skip-push-check` imports without it — and it sends the current branch as `source_branch` for the new branch to start from. - `base44 site install` runs the site's `installCommand` and nothing else, so a machine that only needs a project's dependencies no longer has to go through `create`, `scaffold` or `eject`. Local only: no login and no app id required. - `base44 site dev` runs the site's `serveCommand` exactly as written, with no local backend and the frontend reaching its backend same-origin — the shape a hosted sandbox needs, where `base44 dev` (local backend) and `dev --remote` (the published app) are the developer-machine paths. It appends nothing of its own — anything after `--` is appended as given, so `npm run dev` needs a second `--` to reach the script (`base44 site dev -- -- --config wrapper.mjs`) — and where the dev server binds is the command's own business, and in a sandbox `@base44/vite-plugin` binds Base44 apps to `0.0.0.0:5173`. Serving is its whole job, so it falls back to `npm run dev` when the block names none. diff --git a/docs/commands.md b/docs/commands.md index ff7cf2c9..e31c5a79 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -46,6 +46,20 @@ Omitting the flag preserves existing main-app behavior. - `fetch` reads stdin only for `--body @-`. An agent harness can hold stdin open forever, so reading it unasked would hang the call. - A failed `fetch` reports to telemetry without the request or response body, `--body`, `--form`, or the query string: any endpoint can carry secrets. +## Session import (`base44 sessions import`) + +`src/cli/commands/sessions/import.ts` uploads one Claude Code or Codex session file to `POST /api/apps/{app_id}/imported/session-imports`; the logic is in `src/core/session-import/`. The backend parses the file and creates or continues the branch, so the CLI sends it unchanged. + +- `--current` finds the running session: `CLAUDE_CODE_SESSION_ID` names `~/.claude/projects/*/.jsonl`; otherwise `CODEX_THREAD_ID` (set by Codex; the rollout file name ends with it), else the newest `~/.codex/sessions/*/*/*/rollout-*.jsonl` whose `session_meta` cwd is this folder. +- A claude.ai link runs `claude --teleport -p "Reply with OK."` in a temp clone of this checkout's `origin`, strips that exchange, then deletes the clone and its `~/.claude/projects` folder. Tests swap the binary with the `claudeBin` test override. +- **Push check.** For `--current` and the picker, the checkout's work must be on GitHub, because Base Code starts the branch from there. Uncommitted changes, unpushed commits, a branch with no upstream or a deleted one (`[gone]`) stop the command before the upload, so the one-time key stays valid for the re-run. `--skip-push-check` imports anyway and says the local changes aren't included. Outside a git checkout there is no check. A link (the changes live in the cloud session) and `--file` skip it. +- **`source_branch`.** The current branch (`git rev-parse --abbrev-ref HEAD`, left out when detached) goes with the upload as a multipart field. The backend starts the new branch from it when it's on GitHub, otherwise from the branch recorded in the file, then the repo default. +- **Per-command key auth.** `--token` is a one-time `b44imp_` key. The command is `requireAuth: false` and calls `ensureAuth` only without a key. The key goes through a bare `ky` call, never `base44Client`: its auth hook would replace the header, and its 401 handler would refresh and retry as the stored login. +- Errors are rebuilt as plain messages an agent can relay, with no request or response body (the body is the conversation). The action replaces the reported command context so the link and `--token` never reach telemetry. +- When the session's pushed branch already exists in Base Code, the upload answers 409 `branch_exists` with `details.branch_url`, and the command prints that link. A success with `continued: true` means Base Code continued on the session's own branch rather than creating one. +- Codex cloud sets `CODEX_THREAD_ID` but keeps the conversation on OpenAI's side, so a thread id with no rollout file means Codex cloud, which can't be imported. +- Inside claude.ai (`CLAUDE_CODE_REMOTE`), a failed connection or a non-JSON 403 means the environment's network allowlist blocks Base44; the message says how to allow it. + ## Command File Template ```typescript diff --git a/packages/cli/src/cli/commands/sessions/import.ts b/packages/cli/src/cli/commands/sessions/import.ts new file mode 100644 index 00000000..65e1b8b7 --- /dev/null +++ b/packages/cli/src/cli/commands/sessions/import.ts @@ -0,0 +1,179 @@ +import { cancel, isCancel, select } from "@clack/prompts"; +import type { Command } from "commander"; +import ms from "ms"; +import { CLIExitError } from "@/cli/errors.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { + Base44Command, + ensureAuth, + requireApp, + toJsonStdout, +} from "@/cli/utils/index.js"; +import { getBase44ApiUrl } from "@/core/config.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { MAX_SESSION_BYTES, uploadSession } from "@/core/session-import/api.js"; +import { + findCurrentSession, + type LocalSession, + listSessions, + parseSessionLink, + teleportSession, +} from "@/core/session-import/sessions.js"; +import { readFile } from "@/core/utils/fs.js"; +import { readGitState } from "@/core/utils/git.js"; + +interface ImportOptions { + current?: boolean; + file?: string; + token?: string; + skipPushCheck?: boolean; +} + +const TOOL_LABELS: Record = { + claude_code: "Claude Code", + codex: "Codex", +}; + +async function pickSession(cwd: string): Promise { + const sessions = await listSessions(cwd); + if (sessions.length === 0) { + throw new InvalidInputError( + "No Claude Code or Codex sessions found in this folder. Pass --file .", + ); + } + const choice = await select({ + message: "Which session do you want to continue in Base Code?", + options: sessions.map((session) => ({ + value: session.path, + label: session.firstMessage.replace(/\s+/g, " ").slice(0, 70), + hint: `${TOOL_LABELS[session.tool]}, ${ms(Date.now() - session.updatedAt.getTime())} ago`, + })), + }); + if (isCancel(choice)) { + cancel("Operation cancelled."); + throw new CLIExitError(0); + } + return choice; +} + +async function readSession( + ctx: CLIContext, + link: string | undefined, + options: ImportOptions, +): Promise { + const cwd = process.cwd(); + if (options.file) return readFile(options.file); + if (link) { + const sessionId = parseSessionLink(link); + const content = await ctx.runTask("Downloading the claude.ai session", () => + teleportSession(sessionId, cwd), + ); + return Buffer.from(content); + } + if (options.current) { + const path = await findCurrentSession(cwd); + if (!path) { + throw new InvalidInputError( + "Couldn't find the current session. Run this from inside Claude Code or Codex, or pass --file .", + { hints: [] }, + ); + } + return readFile(path); + } + if (ctx.isNonInteractive || ctx.jsonMode) { + throw new InvalidInputError( + "Choose a session: pass --current, a claude.ai session link, or --file .", + ); + } + return readFile(await pickSession(cwd)); +} + +async function importAction( + ctx: CLIContext, + link: string | undefined, + options: ImportOptions, + command: Command, +): Promise { + // First: the transcript link and the key must never reach error reporting. + ctx.errorReporter.setContext({ + command: { + name: "sessions import", + args: link ? ["[not reported]"] : [], + options: { + ...command.opts(), + ...(options.token ? { token: "[not reported]" } : {}), + }, + }, + }); + const chosen = [link, options.current, options.file].filter(Boolean).length; + if (chosen > 1) { + throw new InvalidInputError( + "Pass only one of: a session link, --current, or --file.", + ); + } + + const appId = requireApp(ctx).id; + const content = await readSession(ctx, link, options); + if (content.length > MAX_SESSION_BYTES) { + throw new InvalidInputError( + "This session is over 25 MB and can't be imported.", + { hints: [] }, + ); + } + // A link's changes live in the cloud session and --file may come from + // anywhere, so only a session from this checkout is held to its git state. + const git = + link || options.file ? undefined : await readGitState(process.cwd()); + if (git?.unpushed && !options.skipPushCheck) { + throw new InvalidInputError( + "Your changes aren't on GitHub yet. Ask the user whether to commit and push them (to a new branch if on main or master), then run this again. If they don't want to, run it again with --skip-push-check.", + { hints: [] }, + ); + } + const note = git?.unpushed + ? "Base Code started from what's on GitHub; your local changes aren't included." + : undefined; + + if (!options.token) await ensureAuth(ctx); + + ctx.log.info("This uploads the full conversation to Base Code."); + const imported = await ctx.runTask("Importing session", () => + uploadSession(appId, content, { + token: options.token, + sourceBranch: git?.branch, + }), + ); + const url = `${getBase44ApiUrl()}${imported.branchUrl}`; + return { + outroMessage: [ + imported.continued + ? `✓ Imported ${imported.turnCount} messages from ${TOOL_LABELS[imported.source]}. Continued on "${imported.branchName}".` + : `✓ Imported ${imported.turnCount} messages from ${TOOL_LABELS[imported.source]} into branch "${imported.branchName}".`, + ...(note ? [note] : []), + `Open it: ${url}`, + ].join("\n"), + stdout: ctx.jsonMode + ? toJsonStdout({ ...imported, url, ...(note ? { note } : {}) }) + : undefined, + }; +} + +export function getSessionsImportCommand(): Command { + return new Base44Command("import", { requireAuth: false }) + .description("Continue a Claude Code or Codex session in Base Code") + .argument( + "[session-link]", + "A claude.ai session address (https://claude.ai/code/session_…)", + ) + .option("--current", "Import the session this command runs inside") + .option("--file ", "Import this session file as is") + .option( + "--skip-push-check", + "Import even though local changes aren't on GitHub (they won't be included)", + ) + .option( + "--token ", + "One-time import key from Base Code (no login needed)", + ) + .action(importAction); +} diff --git a/packages/cli/src/cli/commands/sessions/index.ts b/packages/cli/src/cli/commands/sessions/index.ts new file mode 100644 index 00000000..5342a060 --- /dev/null +++ b/packages/cli/src/cli/commands/sessions/index.ts @@ -0,0 +1,8 @@ +import { Command } from "commander"; +import { getSessionsImportCommand } from "./import.js"; + +export function getSessionsCommand(): Command { + return new Command("sessions") + .description("Move Claude Code and Codex sessions to Base Code") + .addCommand(getSessionsImportCommand()); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 468a6fd9..fd34b96f 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -22,6 +22,7 @@ import { getVisibilityCommand } from "@/cli/commands/project/visibility.js"; import { getPublishCommand } from "@/cli/commands/publish.js"; import { getSandboxCommand } from "@/cli/commands/sandbox/index.js"; import { getSecretsCommand } from "@/cli/commands/secrets/index.js"; +import { getSessionsCommand } from "@/cli/commands/sessions/index.js"; import { getSiteCommand } from "@/cli/commands/site/index.js"; import { getTypesCommand } from "@/cli/commands/types/index.js"; import { getVersionsCommand } from "@/cli/commands/versions/index.js"; @@ -144,5 +145,8 @@ export function createProgram(context: CLIContext): Command { // Register logs command program.addCommand(getLogsCommand()); + // Register Base Code session import + program.addCommand(getSessionsCommand()); + return program; } diff --git a/packages/cli/src/core/project/schema.ts b/packages/cli/src/core/project/schema.ts index 76080d5d..862a6f0c 100644 --- a/packages/cli/src/core/project/schema.ts +++ b/packages/cli/src/core/project/schema.ts @@ -122,6 +122,7 @@ export const TestOverridesSchema = z.object({ }) .optional(), latestVersion: z.string().nullable().optional(), + claudeBin: z.string().optional(), }); export type TestOverrides = z.infer; diff --git a/packages/cli/src/core/session-import/api.ts b/packages/cli/src/core/session-import/api.ts new file mode 100644 index 00000000..89fda85c --- /dev/null +++ b/packages/cli/src/core/session-import/api.ts @@ -0,0 +1,165 @@ +import ky, { HTTPError, type KyResponse } from "ky"; +import { z } from "zod"; +import { getAppClient } from "@/core/clients/index.js"; +import { getBase44ApiUrl } from "@/core/config.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; + +export const MAX_SESSION_BYTES = 25 * 1024 * 1024; + +const SessionImportResponseSchema = z + .object({ + branch_id: z.string(), + branch_name: z.string(), + source: z.enum(["claude_code", "codex"]), + turn_count: z.number(), + branch_url: z.string(), + continued: z.boolean(), + source_ref: z.string(), + }) + .transform((data) => ({ + branchId: data.branch_id, + branchName: data.branch_name, + source: data.source, + turnCount: data.turn_count, + branchUrl: data.branch_url, + continued: data.continued, + sourceRef: data.source_ref, + })); + +type SessionImport = z.infer; + +const ErrorEnvelopeSchema = z.object({ + error: z.object({ + code: z.string().optional(), + details: z + .object({ branch_name: z.string(), branch_url: z.string() }) + .partial() + .optional(), + }), +}); + +type ErrorDetails = z.infer["error"]["details"]; + +const NETWORK_BLOCKED = + "Your Claude cloud environment can't reach Base44 yet. In the environment's settings, open Network access, add `app.base44.com` to Allowed domains, and try again."; + +function messageFor( + status: number, + code: string | undefined, + details: ErrorDetails, + viaToken: boolean, +): string | undefined { + if (code === "branch_exists" && details?.branch_url) { + return `Base Code already has ${details.branch_name ?? "a branch for this session"}. Open it: ${new URL(details.branch_url, getBase44ApiUrl())}`; + } + if (code === "repository_not_connected") { + return "Connect this app to its GitHub repository in Base Code first."; + } + if (status === 401 && viaToken) { + return "This import key expired or was already used. In Base Code, open Import session and copy a new prompt."; + } + if (status === 403) { + return "You don't have edit access to this app, or this key was made for a different app."; + } + if (status === 404) return "Session import isn't available for this app."; + if (status === 413) + return "This session is over 25 MB and can't be imported."; + if (code === "unsupported_file") { + return "That file isn't a Claude Code or Codex session."; + } + if (code === "repository_mismatch") { + return "This session is from a different repository than the app."; + } + if (status === 429) return "Too many imports. Wait a minute and try again."; + return undefined; +} + +/** + * A fresh error that carries no request or response body: the body is the + * user's conversation, and error reporting sends ApiError fields to PostHog. + */ +async function toImportError( + error: unknown, + viaToken: boolean, +): Promise { + if (!(error instanceof HTTPError)) { + // No HTTP response: the claude.ai sandbox refuses the connection. + const reason = error instanceof Error ? error.message : String(error); + return new ApiError( + process.env.CLAUDE_CODE_REMOTE + ? NETWORK_BLOCKED + : `Couldn't reach Base44: ${reason}`, + { hints: [] }, + ); + } + const { status } = error.response; + const fallback = await ApiError.fromHttpError(error, "importing session"); + const envelope = ErrorEnvelopeSchema.safeParse(fallback.responseBody); + const { code, details } = envelope.success ? envelope.data.error : {}; + // The claude.ai egress proxy answers a blocked host with its own non-JSON 403. + const blocked = + process.env.CLAUDE_CODE_REMOTE && + status === 403 && + typeof fallback.responseBody !== "object"; + const known = blocked + ? NETWORK_BLOCKED + : messageFor(status, code, details, viaToken); + return new ApiError(known ?? fallback.message, { + statusCode: status, + requestUrl: error.request.url, + requestMethod: error.request.method, + requestId: fallback.requestId, + // The CLI's default 401 hint says `base44 login`, which a key user doesn't need. + hints: known ? [] : fallback.hints, + }); +} + +/** + * Uploads a session file. With an import key it goes through its own client: + * base44Client would replace the Authorization header with the stored login and, + * on a 401, refresh and retry as that user. + */ +export async function uploadSession( + appId: string, + content: Uint8Array, + { token, sourceBranch }: { token?: string; sourceBranch?: string } = {}, +): Promise { + const form = new FormData(); + form.append("file", new Blob([content]), "session.jsonl"); + // The backend starts the new branch from this one when it's on GitHub. + if (sourceBranch) form.append("source_branch", sourceBranch); + + let response: KyResponse; + try { + response = token + ? await ky.post( + new URL( + `/api/apps/${appId}/imported/session-imports`, + getBase44ApiUrl(), + ), + { + body: form, + headers: { + Authorization: `Bearer ${token}`, + "User-Agent": "Base44 CLI", + }, + timeout: 180_000, + }, + ) + : await getAppClient().post("imported/session-imports", { + body: form, + timeout: 180_000, + }); + } catch (error) { + throw await toImportError(error, Boolean(token)); + } + + const result = SessionImportResponseSchema.safeParse(await response.json()); + if (!result.success) { + throw new SchemaValidationError( + "Invalid session import response from server", + result.error, + ); + } + return result.data; +} diff --git a/packages/cli/src/core/session-import/sessions.ts b/packages/cli/src/core/session-import/sessions.ts new file mode 100644 index 00000000..4d2ba156 --- /dev/null +++ b/packages/cli/src/core/session-import/sessions.ts @@ -0,0 +1,293 @@ +import { createReadStream } from "node:fs"; +import { realpath, rm, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; +import { createInterface } from "node:readline"; +import { execa } from "execa"; +import { globby } from "globby"; +import { dir } from "tmp-promise"; +import { getTestOverrides } from "@/core/config.js"; +import { DependencyNotFoundError, InvalidInputError } from "@/core/errors.js"; +import { readFile } from "@/core/utils/fs.js"; + +export type SessionTool = "claude_code" | "codex"; + +export interface LocalSession { + tool: SessionTool; + path: string; + updatedAt: Date; + firstMessage: string; +} + +const claudeDir = () => + process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"); +const codexDir = () => process.env.CODEX_HOME || join(homedir(), ".codex"); + +/** Reads JSONL lines until `pick` returns a value. Session files can be large. */ +async function scanLines( + path: string, + pick: (entry: Record) => T | undefined, + maxLines = Number.POSITIVE_INFINITY, +): Promise { + const lines = createInterface({ input: createReadStream(path) }); + let count = 0; + try { + for await (const line of lines) { + if (++count > maxLines) return undefined; + try { + const found = pick(JSON.parse(line)); + if (found !== undefined) return found; + } catch { + // A torn line (the file is being written) is normal. + } + } + } finally { + lines.close(); + } + return undefined; +} + +const asRecord = (value: unknown): Record => + value && typeof value === "object" ? (value as Record) : {}; + +const codexCwd = (entry: Record) => + entry.type === "session_meta" + ? String(asRecord(entry.payload).cwd ?? "") + : undefined; + +async function newest(paths: string[]): Promise { + let best: { path: string; mtime: number } | undefined; + for (const path of paths) { + const { mtimeMs } = await stat(path); + if (!best || mtimeMs > best.mtime) best = { path, mtime: mtimeMs }; + } + return best?.path; +} + +async function sameDir(a: string, b: string): Promise { + if (a === b) return true; + const [ra, rb] = await Promise.all([ + realpath(a).catch(() => a), + realpath(b).catch(() => b), + ]); + return ra === rb; +} + +const codexRollouts = () => + globby("sessions/*/*/*/rollout-*.jsonl", { + cwd: codexDir(), + absolute: true, + }); + +async function codexRolloutsIn(cwd: string): Promise { + const matches: string[] = []; + for (const path of await codexRollouts()) { + const recorded = await scanLines(path, codexCwd, 1); + if (recorded && (await sameDir(recorded, cwd))) matches.push(path); + } + return matches; +} + +/** The file of the Claude Code or Codex session this process runs inside. */ +export async function findCurrentSession( + cwd: string, +): Promise { + const claudeId = process.env.CLAUDE_CODE_SESSION_ID; + if (claudeId) { + const [path] = await globby(`projects/*/${claudeId}.jsonl`, { + cwd: claudeDir(), + absolute: true, + }); + return path; + } + // Codex sets CODEX_THREAD_ID for the commands it runs, and names the rollout + // after it. Without it (older Codex), assume the newest rollout recorded in + // this folder is the running one. + const codexId = process.env.CODEX_THREAD_ID; + if (codexId) { + const rollouts = await codexRollouts(); + const rollout = rollouts.find((path) => path.endsWith(`-${codexId}.jsonl`)); + // Codex cloud sets the id but keeps the conversation on OpenAI's side. + if (!rollout) { + throw new InvalidInputError( + "Codex cloud sessions can't be imported yet. Run this from the Codex app or CLI on your computer.", + { hints: [] }, + ); + } + return rollout; + } + return newest(await codexRolloutsIn(cwd)); +} + +function claudeUserText(entry: Record): string | undefined { + if (entry.type !== "user" || entry.isMeta || entry.isSidechain) return; + const content = asRecord(entry.message).content; + // Command and caveat messages Claude Code injects start with a tag. + return typeof content === "string" && !content.startsWith("<") + ? content + : undefined; +} + +function codexUserText(entry: Record): string | undefined { + const payload = asRecord(entry.payload); + return entry.type === "event_msg" && payload.type === "user_message" + ? String(payload.message ?? "") + : undefined; +} + +const PICKER_LIMIT = 20; + +/** This folder's Claude Code and Codex sessions, newest first. */ +export async function listSessions(cwd: string): Promise { + // ponytail: Claude Code names the folder by replacing every + // non-alphanumeric character of the cwd with "-". + const claudeFolder = cwd.replace(/[^a-zA-Z0-9]/g, "-"); + const claude = await globby(`projects/${claudeFolder}/*.jsonl`, { + cwd: claudeDir(), + absolute: true, + }); + const candidates = [ + ...claude.map((path) => ({ tool: "claude_code" as const, path })), + ...(await codexRolloutsIn(cwd)).map((path) => ({ + tool: "codex" as const, + path, + })), + ]; + const dated = await Promise.all( + candidates.map(async (c) => ({ + ...c, + updatedAt: (await stat(c.path)).mtime, + })), + ); + dated.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()); + const sessions: LocalSession[] = []; + for (const candidate of dated.slice(0, PICKER_LIMIT)) { + const firstMessage = await scanLines( + candidate.path, + candidate.tool === "claude_code" ? claudeUserText : codexUserText, + ); + if (firstMessage) sessions.push({ ...candidate, firstMessage }); + } + return sessions; +} + +const SESSION_ID = /session_[A-Za-z0-9]+/; + +/** The `session_…` id in a claude.ai address, a bare id, or a `claude --teleport` command. */ +export function parseSessionLink(link: string): string { + if (/cshk_/.test(link) || !SESSION_ID.test(link)) { + throw new InvalidInputError( + `That isn't a claude.ai session address. Copy it from your browser's address bar (not "Copy link").`, + { hints: [] }, + ); + } + return (link.match(SESSION_ID) as RegExpMatchArray)[0]; +} + +const TELEPORT_PROMPT = "Reply with OK."; +const TELEPORT_NOTE = "This session is being continued from another machine"; + +function parseLine(line: string): Record { + try { + return asRecord(JSON.parse(line)); + } catch { + return {}; + } +} + +/** + * Drops what the teleport added: the queued TELEPORT_PROMPT (written as a + * `queue-operation` line), the continued-from-another-machine note (a meta user + * line), and the prompt's turn with everything after it, when it ran. + */ +function stripTeleportExchange(content: string): string { + const lines = content.split("\n"); + const entries = lines.map(parseLine); + const userText = (entry: Record) => + entry.type === "user" ? asRecord(entry.message).content : undefined; + + let end = lines.length; + for (let i = lines.length - 1; i >= 0; i--) { + if (userText(entries[i] ?? {}) === TELEPORT_PROMPT) { + end = i; + break; + } + } + return lines + .slice(0, end) + .filter((_, i) => { + const entry = entries[i] ?? {}; + const text = userText(entry); + const queued = + entry.type === "queue-operation" && entry.content === TELEPORT_PROMPT; + const note = typeof text === "string" && text.startsWith(TELEPORT_NOTE); + return !queued && !note; + }) + .join("\n"); +} + +const NEEDS_CLAUDE = + "Claude Code must be installed and signed in to the same claude.ai account to import a claude.ai session."; + +/** + * Pulls a claude.ai cloud session down with `claude --teleport` and returns its + * file content. Teleport checks out the session's branch, so it runs in a temp + * clone of this checkout's origin; the clone and its Claude project folder are + * deleted afterwards. + */ +export async function teleportSession( + sessionId: string, + cwd: string, +): Promise { + const origin = await execa("git", ["remote", "get-url", "origin"], { + cwd, + reject: false, + }); + if (origin.exitCode !== 0 || !origin.stdout) { + throw new InvalidInputError( + "Run this from a folder with the app's GitHub repository, so the session can be checked out.", + ); + } + + const tmp = await dir({ unsafeCleanup: true }); + const repo = join(tmp.path, "repo"); + let projectFolder: string | undefined; + try { + await execa("git", ["clone", "--quiet", origin.stdout, repo]); + const repoReal = await realpath(repo); + const startedAt = Date.now(); + const claudeBin = getTestOverrides()?.claudeBin ?? "claude"; + try { + await execa(claudeBin, ["--teleport", sessionId, "-p", TELEPORT_PROMPT], { + cwd: repo, + stdin: "ignore", + }); + } catch { + // Not chained: execa's message holds the command line and session id. + throw new DependencyNotFoundError(NEEDS_CLAUDE, { hints: [] }); + } + + const fresh: string[] = []; + for (const path of await globby("projects/*/*.jsonl", { + cwd: claudeDir(), + absolute: true, + })) { + if ((await stat(path)).mtimeMs < startedAt) continue; + const sessionCwd = await scanLines( + path, + (entry) => (typeof entry.cwd === "string" ? entry.cwd : undefined), + 50, + ); + if (sessionCwd === repo || sessionCwd === repoReal) fresh.push(path); + } + const file = await newest(fresh); + if (!file) throw new DependencyNotFoundError(NEEDS_CLAUDE, { hints: [] }); + projectFolder = dirname(file); + return stripTeleportExchange((await readFile(file)).toString("utf8")); + } finally { + // Only the temp clone writes to this folder, so all of it goes. + if (projectFolder) + await rm(projectFolder, { recursive: true, force: true }); + await tmp.cleanup(); + } +} diff --git a/packages/cli/src/core/utils/git.ts b/packages/cli/src/core/utils/git.ts index f3a5be13..18d09b5c 100644 --- a/packages/cli/src/core/utils/git.ts +++ b/packages/cli/src/core/utils/git.ts @@ -1,6 +1,37 @@ +import { execa } from "execa"; + /** Abbreviated or full commit hash — the same pattern the server validates. */ const GIT_HASH_PATTERN = /^[a-fA-F0-9]{7,64}$/; export function isGitCommitHash(value: string): boolean { return GIT_HASH_PATTERN.test(value); } + +interface GitState { + /** The checked-out branch; undefined when HEAD is detached. */ + branch?: string; + /** Uncommitted changes, or a branch whose commits aren't all on its remote. */ + unpushed: boolean; +} + +/** The checkout's branch and whether it has work its remote lacks; undefined outside a git checkout. */ +export async function readGitState(cwd: string): Promise { + const status = await execa("git", ["status", "--porcelain", "--branch"], { + cwd, + reject: false, + }); + if (status.exitCode !== 0) return undefined; + const [header = "", ...changes] = status.stdout.split("\n").filter(Boolean); + const head = await execa("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd, + reject: false, + }); + const branch = + head.exitCode === 0 && head.stdout !== "HEAD" ? head.stdout : undefined; + // "## main...origin/main [ahead 1]". No "..." means no upstream; "[gone]" + // means the upstream was deleted. Both leave the branch off the remote. + const offRemote = + branch !== undefined && + (!header.includes("...") || /\[(ahead|gone)/.test(header)); + return { branch, unpushed: changes.length > 0 || offRemote }; +} diff --git a/packages/cli/tests/cli/sessions_import.spec.ts b/packages/cli/tests/cli/sessions_import.spec.ts new file mode 100644 index 00000000..6a9641b7 --- /dev/null +++ b/packages/cli/tests/cli/sessions_import.spec.ts @@ -0,0 +1,647 @@ +import { chmod, mkdir, readdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { execa } from "execa"; +import { beforeEach, describe, expect, it } from "vitest"; +import { setupCLITests } from "./testkit/index.js"; + +const APP_ID = "import-app"; +const ROUTE = `/api/apps/${APP_ID}/imported/session-imports`; +const TOKEN = "b44imp_secret-key"; + +const SUCCESS = { + branch_id: "b1", + branch_name: "add-coupon-codes", + source: "claude_code", + turn_count: 14, + branch_url: `/apps/${APP_ID}/editor/preview?branch=add-coupon-codes`, + continued: false, + source_ref: "main", +}; + +const claudeLines = (cwd: string, text: string) => + `${[ + { type: "user", cwd, message: { role: "user", content: text } }, + { + type: "assistant", + cwd, + message: { role: "assistant", content: [{ type: "text", text: "Sure" }] }, + }, + ] + .map((line) => JSON.stringify(line)) + .join("\n")}\n`; + +interface Upload { + authorization?: string; + body: string; +} + +describe("sessions import command", () => { + const t = setupCLITests(); + let uploads: Upload[]; + + const home = () => t.getTempDir(); + + async function plant(relativePath: string, content: string) { + const path = join(home(), relativePath); + await mkdir(join(path, ".."), { recursive: true }); + await writeFile(path, content); + return path; + } + + function mockUpload(status = 200, body: unknown = SUCCESS) { + t.api.mockRoute("POST", ROUTE, (req, res) => { + uploads.push({ + authorization: req.headers.authorization, + body: (req.body as Buffer).toString("utf8"), + }); + res.status(status).json(body); + }); + } + + beforeEach(() => { + uploads = []; + // execa inherits the developer's env, which may itself be a Claude Code session. + t.givenEnv({ + CLAUDE_CODE_SESSION_ID: "", + CLAUDE_CODE_REMOTE: "", + CLAUDE_CONFIG_DIR: "", + CODEX_HOME: "", + CODEX_THREAD_ID: "", + }); + }); + + it("imports the current Claude Code session with an import key, without a login", async () => { + await plant( + ".claude/projects/-some-folder/abc-123.jsonl", + claudeLines("/some/folder", "Add coupon codes"), + ); + await plant( + ".claude/projects/-other/zzz.jsonl", + claudeLines("/other", "Not this one"), + ); + t.givenEnv({ CLAUDE_CODE_SESSION_ID: "abc-123" }); + mockUpload(); + + const result = await t.run( + "sessions", + "import", + "--current", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain( + 'Imported 14 messages from Claude Code into branch "add-coupon-codes"', + ); + t.expectResult(result).toContain( + `/apps/${APP_ID}/editor/preview?branch=add-coupon-codes`, + ); + t.expectResult(result).toContain("This uploads the full conversation"); + expect(uploads).toHaveLength(1); + expect(uploads[0]?.authorization).toBe(`Bearer ${TOKEN}`); + expect(uploads[0]?.body).toContain("Add coupon codes"); + expect(uploads[0]?.body).not.toContain("Not this one"); + expect(`${result.stdout}${result.stderr}`).not.toContain(TOKEN); + }); + + it("imports the newest Codex session recorded in this folder", async () => { + const meta = (cwd: string) => + `${JSON.stringify({ type: "session_meta", payload: { cwd } })}\n`; + await plant( + ".codex/sessions/2026/10/20/rollout-old.jsonl", + `${meta(home())}{"note":"older"}\n`, + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + await plant( + ".codex/sessions/2026/10/21/rollout-new.jsonl", + `${meta(home())}{"note":"newest"}\n`, + ); + await plant( + ".codex/sessions/2026/10/22/rollout-elsewhere.jsonl", + `${meta("/elsewhere")}{"note":"other folder"}\n`, + ); + mockUpload(200, { ...SUCCESS, source: "codex" }); + + const result = await t.run( + "sessions", + "import", + "--current", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("from Codex"); + expect(uploads[0]?.body).toContain("newest"); + }); + + it("imports the Codex session named by CODEX_THREAD_ID", async () => { + await plant( + ".codex/sessions/2026/10/25/rollout-2026-10-25T15-41-21-thread-a.jsonl", + '{"note":"this thread"}\n', + ); + await plant( + ".codex/sessions/2026/10/25/rollout-2026-10-25T15-42-00-thread-b.jsonl", + '{"note":"other thread"}\n', + ); + t.givenEnv({ CODEX_THREAD_ID: "thread-a" }); + mockUpload(200, { ...SUCCESS, source: "codex" }); + + const result = await t.run( + "sessions", + "import", + "--current", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + expect(uploads[0]?.body).toContain("this thread"); + }); + + it("explains how to proceed when there is no current session", async () => { + const result = await t.run( + "sessions", + "import", + "--current", + "--app-id", + APP_ID, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Couldn't find the current session. Run this from inside Claude Code or Codex, or pass --file .", + ); + expect(uploads).toHaveLength(0); + }); + + it("returns the import as JSON with the full link", async () => { + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(); + + const result = await t.run( + "sessions", + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + "--json", + ); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toMatchObject({ + branchName: "add-coupon-codes", + turnCount: 14, + source: "claude_code", + url: expect.stringMatching( + new RegExp(`/apps/${APP_ID}/editor/preview\\?branch=add-coupon-codes$`), + ), + }); + }); + + it("tells a reused key to copy a new prompt, and leaves the stored login alone", async () => { + await t.givenLoggedIn({ email: "me@example.com", name: "Me" }); + const authBefore = await t.readAuthFile(); + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(401, { + detail: "This import key is invalid, expired or already used.", + }); + + const result = await t.run( + "sessions", + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + "--json", + ); + + t.expectResult(result).toFail(); + const envelope = JSON.parse(result.stdout); + expect(envelope.error).toBe( + "This import key expired or was already used. In Base Code, open Import session and copy a new prompt.", + ); + expect(envelope.hints).toBeUndefined(); + expect(uploads).toHaveLength(1); + expect(await t.readAuthFile()).toEqual(authBefore); + }); + + it.each([ + [403, "forbidden", "You don't have edit access to this app"], + [404, "not_found", "Session import isn't available for this app."], + [ + 422, + "unsupported_file", + "That file isn't a Claude Code or Codex session.", + ], + [ + 422, + "repository_mismatch", + "This session is from a different repository than the app.", + ], + [429, "rate_limited", "Too many imports. Wait a minute and try again."], + [ + 409, + "repository_not_connected", + "Connect this app to its GitHub repository in Base Code first.", + ], + ])("maps HTTP %i %s to a plain message", async (status, code, message) => { + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(status, { error: { code, message: "server words" } }); + + const result = await t.run( + "sessions", + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain(message); + }); + + it("links to the existing branch when Base Code already has one", async () => { + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(409, { + error: { + code: "branch_exists", + message: "Base Code already has a branch for feature/x.", + details: { + branch_name: "feature/x", + branch_url: `/apps/${APP_ID}/editor/preview?branch=feature%2Fx`, + }, + }, + }); + + const result = await t.run( + "sessions", + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Base Code already has feature/x. Open it: http", + ); + t.expectResult(result).toContain( + `/apps/${APP_ID}/editor/preview?branch=feature%2Fx`, + ); + }); + + it("says it continued on the session's branch when Base Code adopted it", async () => { + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(200, { + ...SUCCESS, + branch_name: "feature/x", + continued: true, + source_ref: "feature/x", + }); + + const result = await t.run( + "sessions", + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain( + 'Imported 14 messages from Claude Code. Continued on "feature/x".', + ); + }); + + it("explains that Codex cloud sessions can't be imported", async () => { + // Codex cloud sets the thread id but writes no rollout file. + t.givenEnv({ CODEX_THREAD_ID: "cloud-thread" }); + + const result = await t.run( + "sessions", + "import", + "--current", + "--app-id", + APP_ID, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Codex cloud sessions can't be imported yet. Run this from the Codex app or CLI on your computer.", + ); + expect(uploads).toHaveLength(0); + }); + + it("uses the stored login when no key is given", async () => { + await t.givenLoggedIn({ email: "me@example.com", name: "Me" }); + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(); + + const result = await t.run( + "sessions", + "import", + "--file", + file, + "--app-id", + APP_ID, + ); + + t.expectResult(result).toSucceed(); + expect(uploads[0]?.authorization).toMatch(/^Bearer /); + expect(uploads[0]?.authorization).not.toBe(`Bearer ${TOKEN}`); + }); + + it("refuses a file over 25 MB before uploading", async () => { + const file = await plant("big.jsonl", "x".repeat(25 * 1024 * 1024 + 1)); + + const result = await t.run( + "sessions", + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "This session is over 25 MB and can't be imported.", + ); + expect(uploads).toHaveLength(0); + }); + + it("rejects a claude.ai 'Copy link' address", async () => { + const result = await t.run( + "sessions", + "import", + "https://claude.ai/code/cshk_abc123", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "That isn't a claude.ai session address. Copy it from your browser's address bar", + ); + }); + + describe("git state of the checkout", () => { + const git = (...args: string[]) => + execa( + "git", + [ + "-c", + "user.name=t", + "-c", + "user.email=t@t", + "-c", + "commit.gpgsign=false", + ...args, + ], + { cwd: home() }, + ); + const SOURCE_BRANCH = /name="source_branch"\r\n\r\nfeature\r\n/; + + /** HOME is the checkout; ignoring everything keeps the planted sessions out of git status. */ + async function givenPushedBranch() { + await git("init", "--quiet", "--bare", "origin.git"); + await git("init", "--quiet", "-b", "feature"); + await writeFile(join(home(), ".gitignore"), "*\n"); + await git("commit", "--quiet", "--allow-empty", "-m", "init"); + await git("remote", "add", "origin", join(home(), "origin.git")); + await git("push", "--quiet", "-u", "origin", "feature"); + } + + async function runCurrent(...extra: string[]) { + await plant( + ".claude/projects/-f/abc-123.jsonl", + claudeLines("/f", "Add coupon codes"), + ); + t.givenEnv({ CLAUDE_CODE_SESSION_ID: "abc-123" }); + return t.run( + "sessions", + "import", + "--current", + "--app-id", + APP_ID, + "--token", + TOKEN, + ...extra, + ); + } + + it("sends the pushed branch to start from", async () => { + await givenPushedBranch(); + mockUpload(); + + const result = await runCurrent(); + + t.expectResult(result).toSucceed(); + expect(uploads[0]?.body).toMatch(SOURCE_BRANCH); + t.expectResult(result).toNotContain("local changes aren't included"); + }); + + it("stops before uploading when there are uncommitted changes", async () => { + await givenPushedBranch(); + await writeFile(join(home(), "dirty.txt"), "x"); + await git("add", "-f", "dirty.txt"); + mockUpload(); + + const result = await runCurrent(); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Your changes aren't on GitHub yet. Ask the user whether to commit and push them", + ); + t.expectResult(result).toContain("--skip-push-check"); + expect(uploads).toHaveLength(0); + }); + + it("stops when the branch was never pushed", async () => { + await givenPushedBranch(); + await git("checkout", "--quiet", "-b", "local-only"); + mockUpload(); + + const result = await runCurrent(); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("Your changes aren't on GitHub yet."); + expect(uploads).toHaveLength(0); + }); + + it("stops when the branch has commits GitHub lacks", async () => { + await givenPushedBranch(); + await git("commit", "--quiet", "--allow-empty", "-m", "local"); + mockUpload(); + + const result = await runCurrent(); + + t.expectResult(result).toFail(); + expect(uploads).toHaveLength(0); + }); + + it("imports without the local changes under --skip-push-check, and says so", async () => { + await givenPushedBranch(); + await git("commit", "--quiet", "--allow-empty", "-m", "local"); + mockUpload(); + + const result = await runCurrent("--skip-push-check", "--json"); + + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout).note).toBe( + "Base Code started from what's on GitHub; your local changes aren't included.", + ); + expect(uploads[0]?.body).toMatch(SOURCE_BRANCH); + }); + + it("leaves out the branch when HEAD is detached", async () => { + await givenPushedBranch(); + await git("checkout", "--quiet", "--detach"); + mockUpload(); + + const result = await runCurrent(); + + t.expectResult(result).toSucceed(); + expect(uploads[0]?.body).not.toContain('name="source_branch"'); + }); + + it("doesn't check git for --file", async () => { + await givenPushedBranch(); + await git("commit", "--quiet", "--allow-empty", "-m", "local"); + const file = await plant("s.jsonl", claudeLines("/x", "hi")); + mockUpload(); + + const result = await t.run( + "sessions", + "import", + "--file", + file, + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + expect(uploads[0]?.body).not.toContain('name="source_branch"'); + }); + }); + + it("teleports a claude.ai session, drops the teleport exchange, and cleans up", async () => { + // The run's folder is a checkout whose origin is a local bare repo. + await execa("git", ["init", "--quiet", "--bare", "origin.git"], { + cwd: home(), + }); + await execa("git", ["init", "--quiet"], { cwd: home() }); + await execa( + "git", + ["remote", "add", "origin", join(home(), "origin.git")], + { + cwd: home(), + }, + ); + // A stand-in for `claude --teleport -p "Reply with OK."`. + const fakeClaude = await plant( + "fake-claude.mjs", + `#!/usr/bin/env node +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +const [flag, id] = process.argv.slice(2); +if (flag !== "--teleport" || id !== "session_01AbC") process.exit(1); +const cwd = process.cwd(); +const dir = join(process.env.HOME, ".claude", "projects", cwd.replace(/[^a-zA-Z0-9]/g, "-")); +mkdirSync(dir, { recursive: true }); +const lines = [ + { type: "queue-operation", operation: "enqueue", content: "Reply with OK." }, + { type: "user", cwd, message: { role: "user", content: "Cloud question" } }, + { type: "assistant", cwd, message: { role: "assistant", content: [{ type: "text", text: "Cloud answer" }] } }, + { type: "user", isMeta: true, cwd, message: { role: "user", content: "This session is being continued from another machine. Application state may have changed." } }, + { type: "user", cwd, message: { role: "user", content: "Reply with OK." } }, + { type: "assistant", cwd, message: { role: "assistant", content: [{ type: "text", text: "OK" }] } }, +]; +writeFileSync(join(dir, "local-id.jsonl"), lines.map((l) => JSON.stringify(l)).join("\\n") + "\\n"); +`, + ); + await chmod(fakeClaude, 0o755); + t.givenClaudeBin(fakeClaude); + mockUpload(); + + const result = await t.run( + "sessions", + "import", + "https://claude.ai/code/session_01AbC?from=cli&m=0", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toSucceed(); + const body = uploads[0]?.body ?? ""; + expect(body).toContain("Cloud question"); + expect(body).toContain("Cloud answer"); + expect(body).not.toContain("Reply with OK."); + expect(body).not.toContain("continued from another machine"); + // The checkout here is dirty, but a link's changes live in the cloud. + expect(body).not.toContain('name="source_branch"'); + // The teleported project folder is gone. + expect(await readdir(join(home(), ".claude", "projects"))).toEqual([]); + }); + + it("explains that Claude Code is needed when teleport fails", async () => { + await execa("git", ["init", "--quiet", "--bare", "origin.git"], { + cwd: home(), + }); + await execa("git", ["init", "--quiet"], { cwd: home() }); + await execa( + "git", + ["remote", "add", "origin", join(home(), "origin.git")], + { + cwd: home(), + }, + ); + t.givenClaudeBin(join(home(), "no-such-claude")); + + const result = await t.run( + "sessions", + "import", + "claude --teleport session_01AbC", + "--app-id", + APP_ID, + "--token", + TOKEN, + ); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain( + "Claude Code must be installed and signed in to the same claude.ai account", + ); + }); +}); diff --git a/packages/cli/tests/cli/testkit/CLITestkit.ts b/packages/cli/tests/cli/testkit/CLITestkit.ts index 7b24d402..d28659ac 100644 --- a/packages/cli/tests/cli/testkit/CLITestkit.ts +++ b/packages/cli/tests/cli/testkit/CLITestkit.ts @@ -49,6 +49,7 @@ export interface RunLiveHandle { interface TestOverrides { appConfig?: { id: string; projectRoot: string }; latestVersion?: string | null; + claudeBin?: string; } export class CLITestkit { @@ -140,6 +141,11 @@ export class CLITestkit { this.testOverrides.latestVersion = version; } + /** Run this executable in place of `claude` (session teleport) */ + givenClaudeBin(path: string): void { + this.testOverrides.claudeBin = path; + } + /** Simulate piped stdin for the next run() call */ givenStdin(content: string): void { this.stdinContent = content; diff --git a/packages/cli/tests/cli/testkit/index.ts b/packages/cli/tests/cli/testkit/index.ts index 227601fe..f7a3d4de 100644 --- a/packages/cli/tests/cli/testkit/index.ts +++ b/packages/cli/tests/cli/testkit/index.ts @@ -38,6 +38,9 @@ export interface TestContext { givenLatestVersion: (version: string | null | undefined) => void; + /** Run this executable in place of `claude` (session teleport) */ + givenClaudeBin: (path: string) => void; + /** Simulate piped stdin for the next run() call */ givenStdin: (content: string) => void; @@ -131,6 +134,7 @@ export function setupCLITests(): TestContext { await getKit().givenProject(fixturePath); }, givenLatestVersion: (version) => getKit().givenLatestVersion(version), + givenClaudeBin: (path) => getKit().givenClaudeBin(path), givenStdin: (content) => getKit().givenStdin(content), givenEnv: (vars) => getKit().givenEnv(vars),