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/<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", { requireAppConfig: false }) // Skip app config loading
new Base44Command("my-cmd", { fullBanner: true }) // ASCII art banner
new Base44Command("my-cmd", { requireAuth: false, requireAppConfig: false })| Option | Default | Description |
|---|---|---|
requireAuth |
true |
Check authentication before running, auto-triggers login if needed |
requireAppConfig |
true |
Load .app.jsonc and cache for sync access via getAppConfig() |
fullBanner |
false |
Show ASCII art banner instead of simple intro tag |
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;
}- 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.
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", { requireAppConfig: 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/link.ts, and project/eject.ts for real examples.
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.