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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <b44imp_…>` 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.

Expand Down
14 changes: 14 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/*/<id>.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 <session_id> -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
Expand Down
179 changes: 179 additions & 0 deletions packages/cli/src/cli/commands/sessions/import.ts
Original file line number Diff line number Diff line change
@@ -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<LocalSession["tool"], string> = {
claude_code: "Claude Code",
codex: "Codex",
};

async function pickSession(cwd: string): Promise<string> {
const sessions = await listSessions(cwd);
if (sessions.length === 0) {
throw new InvalidInputError(
"No Claude Code or Codex sessions found in this folder. Pass --file <path>.",
);
}
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<Uint8Array> {
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 <path>.",
{ hints: [] },
);
}
return readFile(path);
}
if (ctx.isNonInteractive || ctx.jsonMode) {
throw new InvalidInputError(
"Choose a session: pass --current, a claude.ai session link, or --file <path>.",
);
}
return readFile(await pickSession(cwd));
}

async function importAction(
ctx: CLIContext,
link: string | undefined,
options: ImportOptions,
command: Command,
): Promise<RunCommandResult> {
// 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 <path>", "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 <key>",
"One-time import key from Base Code (no login needed)",
)
.action(importAction);
}
8 changes: 8 additions & 0 deletions packages/cli/src/cli/commands/sessions/index.ts
Original file line number Diff line number Diff line change
@@ -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());
}
4 changes: 4 additions & 0 deletions packages/cli/src/cli/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions packages/cli/src/core/project/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const TestOverridesSchema = z.object({
})
.optional(),
latestVersion: z.string().nullable().optional(),
claudeBin: z.string().optional(),
});

export type TestOverrides = z.infer<typeof TestOverridesSchema>;
Loading
Loading