Keywords: command, factory pattern, Base44Command, CLIContext, Logger, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro
Commands live in src/cli/commands/<domain>/. They use a factory pattern — each file exports a function that returns a Base44Command.
src/cli/commands/actors/ registers deploy and delete through the same lifecycle as
functions. Keep name validation inside the action so failures use the global
JSON error envelope. Successful results use RunCommandResult.stdout; progress
and server warnings use the context logger. Deployment reads project resources;
deletion only needs the selected app context.
Discover names with base44 branches list --app-id <app-id> --json.
The result contains branches with name and status, including main and active
feature branches. Choose the branch matching the user's request before editing.
--branch <name> is global, but currently supported only by sandbox commands.
--branch feature/checkout resolves an exact name within the selected app.
Missing or ambiguous names fail. --branch main explicitly targets main.
No checkout state is saved. IDs are internal; the CLI accepts branch names only.
For example: base44 --branch feature/checkout sandbox read <path> --app-id <app-id>.
Other commands (including functions pull, functions list, and entities push)
reject it before authentication or command execution, rather than silently targeting main.
There is no entities pull command; branch source files can be read through sandbox read.
To add support, set supportsBranch: true on the command and consume
ctx.branchId. First verify that every backend operation honors that scope;
accepting the query parameter alone is not proof of branch isolation.
Omitting the flag preserves existing main-app behavior.
src/cli/commands/api/ (catalog, search, grep, fetch) is driven by the server's OpenAPI spec at runtime, with no per-endpoint code. The logic lives in src/core/api-spec/.
- The spec (
GET /api/openapi.json) is cached per server under~/.base44/cache/<server>/for one hour;--refreshforces a download. - Read-only comes from each operation's
securitylist:PersonalAccessTokenAuth: ["read_only"](the backend's@read_safe).--read-onlyfilters or refuses by it; the server stays the real guard. fetchsends the path as is throughbase44Client, so every auth mode works. Don't add local validation; the server validates.fetchfills only{app_id}, from--app-idor the project, so an endpoint fromsearchorcatalogcan be passed as is. Any other placeholder fails before sending, and--read-onlychecks the filled path.fetchreads the spec only for--helpand--read-only, so a route newer than the cache still works. Both send nothing, so they run before the login check.fetchdisables Commander's help option, so--helpafter a method and path can print that operation's contract.fetchhas no timeout: a call such as a deploy can take minutes.fetchreads stdin only for--body @-. An agent harness can hold stdin open forever, so reading it unasked would hang the call.- A failed
fetchreports to telemetry without the request or response body,--body,--form, or the query string: any endpoint can carry secrets.
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.
--currentfinds the running session:CLAUDE_CODE_SESSION_IDnames~/.claude/projects/*/<id>.jsonl; otherwiseCODEX_THREAD_ID(set by Codex; the rollout file name ends with it), else the newest~/.codex/sessions/*/*/*/rollout-*.jsonlwhosesession_metacwd is this folder.- A claude.ai link runs
claude --teleport <session_id> -p "Reply with OK."in a temp clone of this checkout'sorigin, strips that exchange, then deletes the clone and its~/.claude/projectsfolder. Tests swap the binary with theclaudeBintest override. - Push check. For
--currentand 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-checkimports 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--fileskip 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.Idempotency-Key. Each run sends one fresh UUID on the upload, shared by any retry within the run, so a retried upload gets the first response back instead of creating a second branch. 409idempotency_key_in_usemeans that first upload is still running.- Per-command key auth.
--tokenis a one-timeb44imp_key. The command isrequireAuth: falseand callsensureAuthonly without a key. The key goes through a barekycall, neverbase44Client: 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
--tokennever reach telemetry. - When the session's pushed branch already exists in Base Code, the upload answers 409
branch_existswithdetails.branch_url, and the command prints that link. A success withcontinued: truemeans Base Code continued on the session's own branch rather than creating one. - Importing a session again into an app whose branch for it is still active appends only the newer messages: the success has
reimported: trueandturn_countcounts the new ones (0 prints "Nothing new to import"). 409session_changedmeans the earlier messages no longer match (the dialog's Import into a new branch mints a key that imports it fresh); 409wrong_sessionmeans the key was made for another session's branch; 409branch_busymeans the branch is running a turn. - Codex cloud sets
CODEX_THREAD_IDbut 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.
// src/cli/commands/<domain>/<action>.ts
import type { Command } from "commander";
import type { CLIContext, RunCommandResult } from "@/cli/types.js";
import { Base44Command, runTask, theme } from "@/cli/utils/index.js";
async function myAction({ logger }: CLIContext): Promise<RunCommandResult> {
const result = await runTask(
"Doing something...",
async () => {
// Your async operation here
return someResult;
},
{
successMessage: theme.colors.base44Orange("Done!"),
errorMessage: "Failed to do something",
}
);
logger.success("Operation completed!");
return { outroMessage: `Created ${theme.styles.bold(result.name)}` };
}
export function getMyCommand(): Command {
return new Base44Command("<name>")
.description("<description>")
.option("-f, --flag", "Some flag")
.action(myAction);
}Key rules:
- Export a factory function (
getMyCommand), not a static command instance - Use
Base44Commandclass - Commands must NOT call
intro()oroutro()directly - The action function must return
RunCommandResultwith anoutroMessage - Action functions receive
CLIContextas their first argument (injected byBase44Command), followed by Commander's positional args and options - Destructure what you need from
CLIContext:{ logger },{ logger, isNonInteractive }, or_ctxif nothing needed - Use
.action(fn)directly — no wrapper needed
Pass options as the second argument to the constructor:
new Base44Command("my-cmd") // All defaults
new Base44Command("my-cmd", { requireAuth: false }) // Skip auth check
new Base44Command("my-cmd", { requireAppContext: false }) // Skip app context resolution
new Base44Command("my-cmd", { fullBanner: true }) // ASCII art banner
new Base44Command("my-cmd", { requireAuth: false, requireAppContext: false })| Option | Default | Description |
|---|---|---|
requireAuth |
true |
Check authentication before running, auto-triggers login if needed |
requireAppContext |
true |
Resolve the app ID from --app-id, BASE44_APP_ID, or .app.jsonc, then cache it for sync access via getAppContext() |
fullBanner |
false |
Show ASCII art banner instead of simple intro tag |
BASE44_APP_ID is bound to the global --app-id option in src/cli/program.ts; Commander resolves it before Base44Command calls initAppContext({ appId }).
When the CLI runs in non-interactive mode (CI, piped output), all clack UI (intro, outro, themed errors) is automatically skipped. Errors go to stderr as plain text.
Add the import and registration in src/cli/program.ts:
import { getMyCommand } from "@/cli/commands/<domain>/<action>.js";
// Inside createProgram(context):
program.addCommand(getMyCommand());CLIContext is automatically injected as the first argument to all action functions by Base44Command. Destructure what you need:
export interface CLIContext {
errorReporter: ErrorReporter;
isNonInteractive: boolean;
distribution: Distribution;
logger: Logger;
app?: {
id: string;
projectRoot?: string;
};
}- Created once in
runCLI()at startup isNonInteractiveistruewhen stdin/stdout are not a TTY (e.g., CI, piped output, AI agents). Controls quiet mode — when true, all clack UI is suppressed.loggeris aLoggerinstance —ClackLoggerin interactive mode,SimpleLoggerin non-interactive mode.appis set for commands withrequireAppContext: true. Useapp.idfor the active app andapp.projectRootonly when a local project was resolved.
Destructure isNonInteractive from the context first argument:
async function openDashboard({ isNonInteractive }: CLIContext): Promise<RunCommandResult> {
if (!isNonInteractive) {
await open(url); // Only open browser in interactive mode
}
return { outroMessage: `Opened at ${url}` };
}
export function getMyCommand(): Command {
return new Base44Command("open")
.description("Open something in browser")
.action(openDashboard);
}Commands that use interactive prompts (select, text, confirm, group from @clack/prompts) must guard against non-interactive environments. Move the guard into the action function:
async function myAction(
{ isNonInteractive }: CLIContext,
options: MyOptions,
): Promise<RunCommandResult> {
const skipPrompts = !!options.name && !!options.path;
if (!skipPrompts && isNonInteractive) {
throw new InvalidInputError(
"--name and --path are required in non-interactive mode",
);
}
if (skipPrompts) {
return await createNonInteractive(options);
}
return await createInteractive(options);
}
export function getMyCommand(): Command {
return new Base44Command("my-cmd", { requireAppContext: false })
.option("-n, --name <name>", "Project name")
.option("-p, --path <path>", "Project path")
.action(myAction);
}Commands that only use logger.* (display-only, no input) don't need this guard. See project/create.ts, project/scaffold.ts, project/link.ts, and project/eject.ts for real examples.
project/create.ts and project/scaffold.ts share their post-scaffold logic (push entities, deploy site, install skills, print summary) via project/scaffold-shared.ts — create mints a new app and rejects --app-id/BASE44_APP_ID, while scaffold reuses an existing app id (from --app-id or BASE44_APP_ID).
Use runTask() for any async operation that takes time:
const result = await runTask(
"Deploying site...",
async () => {
return await deploySite(outputDir);
},
{
successMessage: theme.colors.base44Orange("Site deployed!"),
errorMessage: "Failed to deploy site",
}
);Avoid manual try/catch with logger.message for async operations -- use runTask() instead.
When running subprocesses inside runTask(), use { shell: true } without stdio: "inherit" to suppress subprocess output. The spinner provides user feedback.
await runTask("Installing...", async () => {
await execa("npx", ["-y", "some-package"], {
cwd: targetPath,
shell: true,
});
});All CLI styling is centralized in src/cli/utils/theme.ts. Never use chalk directly.
import { theme } from "@/cli/utils/index.js";
// Colors
theme.colors.base44Orange("Success!") // Primary brand color
theme.colors.links(url) // URLs and links
// Styles
theme.styles.bold(email) // Bold emphasis
theme.styles.header("Label") // Dim text for labels
theme.styles.dim(text) // Dimmed text
// Formatters (for error display)
theme.format.errorContext(ctx) // Dimmed pipe-separated context string
theme.format.agentHints(hints) // "[Agent Hints]\n Run: ..."When adding new theme properties, use semantic names (e.g., links, header) not color names.
Use .hook("preAction", validator) to validate command input (required args, mutually exclusive options) before the action runs. This keeps validation separate from business logic.
function validateInput(command: Command): void {
const { flagA, flagB } = command.opts<MyOptions>();
if (!command.args.length && !flagA) {
throw new InvalidInputError("Provide args or use --flag-a.");
}
if (command.args.length > 0 && flagA) {
throw new InvalidInputError("Provide args or --flag-a, but not both.");
}
}
export function getMyCommand(): Command {
return new Base44Command("my-cmd")
.argument("[entries...]", "Input entries")
.option("--flag-a <value>", "Alternative input")
.hook("preAction", validateInput)
.action(myAction);
}Access command.args for positional arguments and command.opts() for options inside the hook. See secrets/set.ts and project/create.ts for real examples.
- Command factory pattern - Commands export
getXCommand()functions (no parameters), not static instances - Use
Base44Command- All commands usenew Base44Command(name, options?) - CLIContext as first arg - All action functions receive
CLIContextas their first argument (auto-injected). Destructure{ logger },{ logger, isNonInteractive }, or use_ctxif nothing is needed. Use.action(fn)directly — no wrappers. - Use
loggerfor output - Never importlogfrom@clack/promptsin command files. Use theloggerfrom CLIContext so output works correctly in both interactive and non-interactive modes. Passloggerto helper functions as a parameter. - Task wrapper - Use
runTask()for async operations with spinners - Use theme for styling - Never use
chalkdirectly; importthemefrom@/cli/utils/and use semantic names - Use fs.ts utilities - Always use
@/core/utils/fs.jsfor file operations - Guard interactive prompts - Commands using
select,text,confirm, orgroupfrom@clack/promptsmust checkisNonInteractive(from CLIContext) and throwInvalidInputErrorif required flags are missing. Never let prompts hang in CI. - Consistent copy across related commands - User-facing messages (errors, success, hints) for commands in the same group should use consistent language and structure. When writing validation errors, outro messages, or spinner text, check sibling commands for parity so the product voice stays coherent.