Skip to content
Merged
120 changes: 73 additions & 47 deletions docs/commands.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
# Adding & Modifying CLI Commands

**Keywords:** command, factory pattern, CLIContext, isNonInteractive, runCommand, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro
**Keywords:** command, factory pattern, Base44Command, isNonInteractive, runTask, spinner, theming, chalk, program.ts, register, banner, intro, outro

Commands live in `src/cli/commands/<domain>/`. They use a **factory pattern** with dependency injection via `CLIContext`.
Commands live in `src/cli/commands/<domain>/`. They use a **factory pattern** — each file exports a function that returns a `Base44Command`.

## Command File Template

```typescript
// src/cli/commands/<domain>/<action>.ts
import { Command } from "commander";
import { log } from "@clack/prompts";
import type { CLIContext } from "@/cli/types.js";
import { runCommand, runTask, theme } from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import { Base44Command, runTask, theme } from "@/cli/utils/index.js";

async function myAction(): Promise<RunCommandResult> {
const result = await runTask(
Expand All @@ -32,21 +31,39 @@ async function myAction(): Promise<RunCommandResult> {
return { outroMessage: `Created ${theme.styles.bold(result.name)}` };
}

export function getMyCommand(context: CLIContext): Command {
return new Command("<name>")
export function getMyCommand(): Command {
return new Base44Command("<name>")
.description("<description>")
.option("-f, --flag", "Some flag")
.action(async (options) => {
await runCommand(myAction, { requireAuth: true }, context);
});
.action(myAction);
}
```

**Key rules**:
- Export a **factory function** (`getMyCommand`), not a static command instance
- The factory receives `CLIContext` (contains `errorReporter` and `isNonInteractive`)
- Commands must NOT call `intro()` or `outro()` directly -- `runCommand()` handles both
- Always pass `context` as the third argument to `runCommand()`
- Use `Base44Command` class
- Commands must NOT call `intro()` or `outro()` directly
- The action function must return `RunCommandResult` with an `outroMessage`

## Base44Command Options

Pass options as the second argument to the constructor:

```typescript
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.

## Registering a Command

Expand All @@ -56,51 +73,32 @@ Add the import and registration in `src/cli/program.ts`:
import { getMyCommand } from "@/cli/commands/<domain>/<action>.js";

// Inside createProgram(context):
program.addCommand(getMyCommand(context));
program.addCommand(getMyCommand());
```

## runCommand Options

```typescript
await runCommand(myAction, undefined, context); // Standard (loads app config)
await runCommand(myAction, { fullBanner: true }, context); // ASCII art banner
await runCommand(myAction, { requireAuth: true }, context); // Auto-login if needed
await runCommand(myAction, { requireAppConfig: false }, context); // Skip app config loading
await runCommand(myAction, { fullBanner: true, requireAuth: true }, context);
```

- `fullBanner` - Show ASCII art banner instead of simple tag (for special commands like `create`)
- `requireAuth` - Check authentication before running, auto-triggers login if needed
- `requireAppConfig` - Load `.app.jsonc` and cache for sync access (default: `true`)

## CLIContext (Dependency Injection)
## CLIContext (Automatic Injection)

```typescript
export interface CLIContext {
errorReporter: ErrorReporter;
isNonInteractive: boolean;
distribution: Distribution;
}
```

- Created once in `runCLI()` at startup
- `isNonInteractive` is `true` when stdin/stdout are not a TTY (e.g., CI, piped output, AI agents). Use it to skip interactive prompts, browser opens, and animations.
- Passed to `createProgram(context)`, which passes it to each command factory
- Commands pass it to `runCommand()` for error reporting integration
- `isNonInteractive` is `true` when stdin/stdout are not a TTY (e.g., CI, piped output, AI agents). Controls quiet mode — when true, all clack UI is suppressed.

### Using `isNonInteractive`

Pass `context.isNonInteractive` to your action when the command has interactive behavior (browser opens, confirmation prompts, animations):
When a command needs to check `isNonInteractive` (e.g., to skip browser opens or confirmation prompts), access it from the command instance. Commander passes the command as the last argument to action handlers:

```typescript
export function getMyCommand(context: CLIContext): Command {
return new Command("open")
export function getMyCommand(): Command {
return new Base44Command("open")
.description("Open something in browser")
.action(async () => {
await runCommand(
() => myAction(context.isNonInteractive),
{ requireAuth: true },
context,
);
.action(async (_options: unknown, command: Base44Command) => {
return await myAction(command.isNonInteractive);
});
}

Expand All @@ -112,6 +110,32 @@ async function myAction(isNonInteractive: boolean): Promise<RunCommandResult> {
}
```

### Guarding Interactive Commands

Commands that use interactive prompts (`select`, `text`, `confirm`, `group` from `@clack/prompts`) **must** guard against non-interactive environments. If the user didn't provide enough flags to skip all prompts, error early:

```typescript
export function getMyCommand(): Command {
return new Base44Command("my-cmd", { requireAppConfig: false })
.option("-n, --name <name>", "Project name")
.option("-p, --path <path>", "Project path")
.action(async (options: MyOptions, command: Base44Command) => {
const skipPrompts = !!options.name && !!options.path;
if (!skipPrompts && command.isNonInteractive) {
throw new InvalidInputError(
"--name and --path are required in non-interactive mode",
);
}
if (skipPrompts) {
return await createNonInteractive(options);
}
return await createInteractive(options);
});
}
```

Commands that only use `log.*` (display-only, no input) don't need this guard. See `project/create.ts`, `project/link.ts`, and `project/eject.ts` for real examples.

## runTask (Async Operations with Spinners)

Use `runTask()` for any async operation that takes time:
Expand Down Expand Up @@ -182,13 +206,13 @@ function validateInput(command: Command): void {
}
}

export function getMyCommand(context: CLIContext): Command {
return new Command("my-cmd")
export function getMyCommand(): Command {
return new Base44Command("my-cmd")
.argument("[entries...]", "Input entries")
.option("--flag-a <value>", "Alternative input")
.hook("preAction", validateInput)
.action(async (entries, options) => {
await runCommand(() => myAction(entries, options), { requireAuth: true }, context);
return await myAction(entries, options);
});
}
```
Expand All @@ -197,9 +221,11 @@ Access `command.args` for positional arguments and `command.opts()` for options

## Rules (Command-Specific)

- **Command factory pattern** - Commands export `getXCommand(context)` functions, not static instances
- **Command wrapper** - All commands use `runCommand(fn, options, context)` utility
- **Command factory pattern** - Commands export `getXCommand()` functions (no parameters), not static instances
- **Use `Base44Command`** - All commands use `new Base44Command(name, options?)`
- **No context plumbing** - Context is injected automatically. Command files should never import `CLIContext`.
- **Task wrapper** - Use `runTask()` for async operations with spinners
- **Use theme for styling** - Never use `chalk` directly; import `theme` from `@/cli/utils/` and use semantic names
- **Use fs.ts utilities** - Always use `@/core/utils/fs.js` for file operations
- **Guard interactive prompts** - Commands using `select`, `text`, `confirm`, or `group` from `@clack/prompts` must check `command.isNonInteractive` and throw `InvalidInputError` if 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.
7 changes: 3 additions & 4 deletions packages/cli/src/cli/commands/agents/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { getAgentsPullCommand } from "./pull.js";
import { getAgentsPushCommand } from "./push.js";

export function getAgentsCommand(context: CLIContext): Command {
export function getAgentsCommand(): Command {
return new Command("agents")
.description("Manage project agents")
.addCommand(getAgentsPushCommand(context))
.addCommand(getAgentsPullCommand(context));
.addCommand(getAgentsPushCommand())
.addCommand(getAgentsPullCommand());
}
15 changes: 6 additions & 9 deletions packages/cli/src/cli/commands/agents/pull.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import { dirname, join } from "node:path";
import { log } from "@clack/prompts";
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import { Base44Command, runTask } from "@/cli/utils/index.js";
import { readProjectConfig } from "@/core/index.js";
import { fetchAgents, writeAgents } from "@/core/resources/agent/index.js";
import { runCommand, runTask } from "../../utils/index.js";
import type { RunCommandResult } from "../../utils/runCommand.js";

async function pullAgentsAction(): Promise<RunCommandResult> {
const { project } = await readProjectConfig();
Expand Down Expand Up @@ -50,12 +49,10 @@ async function pullAgentsAction(): Promise<RunCommandResult> {
};
}

export function getAgentsPullCommand(context: CLIContext): Command {
return new Command("pull")
export function getAgentsPullCommand(): Command {
return new Base44Command("pull")
.description(
"Pull agents from Base44 to local files (replaces all local agent configs)",
)
.action(async () => {
await runCommand(pullAgentsAction, { requireAuth: true }, context);
});
.action(pullAgentsAction);
}
15 changes: 6 additions & 9 deletions packages/cli/src/cli/commands/agents/push.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { log } from "@clack/prompts";
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import { Base44Command, runTask } from "@/cli/utils/index.js";
import { readProjectConfig } from "@/core/index.js";
import { pushAgents } from "@/core/resources/agent/index.js";
import { runCommand, runTask } from "../../utils/index.js";
import type { RunCommandResult } from "../../utils/runCommand.js";

async function pushAgentsAction(): Promise<RunCommandResult> {
const { agents } = await readProjectConfig();
Expand Down Expand Up @@ -39,12 +38,10 @@ async function pushAgentsAction(): Promise<RunCommandResult> {
return { outroMessage: "Agents pushed to Base44" };
}

export function getAgentsPushCommand(context: CLIContext): Command {
return new Command("push")
export function getAgentsPushCommand(): Command {
return new Base44Command("push")
.description(
"Push local agents to Base44 (replaces all remote agent configs)",
)
.action(async () => {
await runCommand(pushAgentsAction, { requireAuth: true }, context);
});
.action(pushAgentsAction);
}
2 changes: 1 addition & 1 deletion packages/cli/src/cli/commands/auth/login-flow.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { log } from "@clack/prompts";
import pWaitFor from "p-wait-for";
import type { RunCommandResult } from "@/cli/types.js";
import { runTask } from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
import { theme } from "@/cli/utils/theme.js";
import type {
DeviceCodeResponse,
Expand Down
16 changes: 8 additions & 8 deletions packages/cli/src/cli/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { runCommand } from "@/cli/utils/index.js";
import type { Command } from "commander";
import { Base44Command } from "@/cli/utils/index.js";
import { login } from "./login-flow.js";

export function getLoginCommand(context: CLIContext): Command {
return new Command("login")
export function getLoginCommand(): Command {
return new Base44Command("login", {
requireAuth: false,
requireAppConfig: false,
})
.description("Authenticate with Base44")
.action(async () => {
await runCommand(login, { requireAppConfig: false }, context);
});
.action(login);
}
18 changes: 9 additions & 9 deletions packages/cli/src/cli/commands/auth/logout.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { runCommand } from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import { Base44Command } from "@/cli/utils/index.js";
import { deleteAuth } from "@/core/auth/index.js";

async function logout(): Promise<RunCommandResult> {
await deleteAuth();
return { outroMessage: "Logged out successfully" };
}

export function getLogoutCommand(context: CLIContext): Command {
return new Command("logout")
export function getLogoutCommand(): Command {
return new Base44Command("logout", {
requireAuth: false,
requireAppConfig: false,
})
.description("Logout from current device")
.action(async () => {
await runCommand(logout, { requireAppConfig: false }, context);
});
.action(logout);
}
19 changes: 6 additions & 13 deletions packages/cli/src/cli/commands/auth/whoami.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,15 @@
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { runCommand, theme } from "@/cli/utils/index.js";
import type { RunCommandResult } from "@/cli/utils/runCommand.js";
import type { Command } from "commander";
import type { RunCommandResult } from "@/cli/types.js";
import { Base44Command, theme } from "@/cli/utils/index.js";
import { readAuth } from "@/core/auth/index.js";

async function whoami(): Promise<RunCommandResult> {
const auth = await readAuth();
return { outroMessage: `Logged in as: ${theme.styles.bold(auth.email)}` };
}

export function getWhoamiCommand(context: CLIContext): Command {
return new Command("whoami")
export function getWhoamiCommand(): Command {
return new Base44Command("whoami", { requireAppConfig: false })
.description("Display current authenticated user")
.action(async () => {
await runCommand(
whoami,
{ requireAuth: true, requireAppConfig: false },
context,
);
});
.action(whoami);
}
9 changes: 4 additions & 5 deletions packages/cli/src/cli/commands/connectors/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
import { Command } from "commander";
import type { CLIContext } from "@/cli/types.js";
import { getConnectorsListAvailableCommand } from "./list-available.js";
import { getConnectorsPullCommand } from "./pull.js";
import { getConnectorsPushCommand } from "./push.js";

export function getConnectorsCommand(context: CLIContext): Command {
export function getConnectorsCommand(): Command {
return new Command("connectors")
.description("Manage project connectors (OAuth integrations)")
.addCommand(getConnectorsListAvailableCommand(context))
.addCommand(getConnectorsPullCommand(context))
.addCommand(getConnectorsPushCommand(context));
.addCommand(getConnectorsListAvailableCommand())
.addCommand(getConnectorsPullCommand())
.addCommand(getConnectorsPushCommand());
}
Loading
Loading