<!-- Markdown mirror of https://justscale.sh/docs/cli/usage -->

# CLI Usage

Build command-line interfaces with JustScale

## Overview

The CLI transport allows you to expose controller methods as command-line commands. Routes defined with `Cli()` become executable commands with automatic argument parsing, validation, and rich terminal output.

## Basic CLI Route

Create a CLI route using the `Cli()` factory in your controller:

controllers/db.tsTypeScript

```typescript
import { createController } from '@justscale/core';
import { Cli } from '@justscale/core/cli';
import { z } from 'zod';

export const DbController = createController('db', {
  routes: () => ({
    migrate: Cli('migrate')
      .input(z.object({
        direction: z.enum(['up', 'down']),
        steps: z.number().default(1),
      }))
      .handle(({ args, io }) => {
        io.log(`Running ${args.steps} migrations ${args.direction}...`);
        io.result({ applied: args.steps });
      }),
  }),
});
```

This creates a command that can be run as:

Bash

```bash
# Using the just CLI
just db migrate up --steps 3
```

## Arguments and Flags

JustScale automatically parses arguments based on your Zod schema:

### Positional Arguments

Required fields without defaults become positional arguments:

create-user-command.tsTypeScript

```typescript
import { Cli } from '@justscale/core/cli';
import { z } from 'zod';

const CreateUserArgs = z.object({
  email: z.string().email(),
  name: z.string(),
  admin: z.boolean().default(false),
});

Cli('create-user')
  .input(CreateUserArgs)
  .handle(({ args, io }) => {
    // Email and name are positional: create-user john@example.com "John Doe"
    // Admin is a flag: --admin
    io.log(`Creating user: ${args.name} (${args.email})`);
  });
```

### Named Flags

Optional fields and fields with defaults become named flags:

build-command.tsTypeScript

```typescript
import { Cli } from '@justscale/core/cli';
import { z } from 'zod';

const BuildArgs = z.object({
  src: z.string().default('./src'),
  out: z.string().default('./dist'),
  verbose: z.boolean().default(false),
});

Cli('build')
  .input(BuildArgs)
  .handle(({ args, io }) => {
    // All are flags: build --src ./app --out ./build --verbose
  });
```

### Interactive Prompts

Any required argument that is missing from the command line will auto-prompt when stdin is a TTY. A plain `z.string()`on a required field will produce a sensible default prompt (derived from the field name, or from the field's `.meta({ description })` when present). You only need `arg()` when you want a password prompt, a re-entry confirmation, a custom label, or to mask input.

interactive-command.tsTypeScript

```typescript
import { Cli, arg } from '@justscale/core/cli';
import { z } from 'zod';

const CreateUserArgs = z.object({
  // Missing? User is prompted: "email:"
  email: z.email(),

  // Missing? Prompted with secret input and confirmation re-entry.
  password: arg(z.string(), {
    prompt: 'Password',
    secret: true,
    confirm: true,
  }),
});
```

## Descriptions & Help Text

Two places to attach human-readable text: a one-line command summary via `.describe()` on the builder, and per-field metadata via zod v4's `.meta({ description, examples })`. Both feed `--help`, and field metadata also feeds validation-error messages when an argument is missing.

user-add-command.tsTypeScript

```typescript
import { Cli } from '@justscale/core/cli';
import { z } from 'zod';

Cli('user add')
  .describe('Create a new user account')
  .input(z.object({
    email: z.email().meta({
      description: 'User email address',
      examples: ['alice@example.com'],
    }),
    name: z.string().optional().meta({
      description: 'Display name',
    }),
  }))
  .handle(({ args, io }) => {
    io.log(`Created ${args.email}`);
  });
```

### Top-level --help

`just --help` groups commands by prefix (or by the first word of a multi-word command name) and aligns descriptions:

Bash

```bash
Usage: just <command> [options]

Commands:
  status          Show runtime status

migrate:
  up              Apply pending migrations

session:
  list            List active sessions
  revoke          Revoke all sessions for a user

user:
  add             Create a new user account
  list            List all registered users

Run 'just <command> --help' for details on a command.
```

### Per-command --help

Field descriptions and examples surface on the individual command's help screen:

Bash

```bash
$ just user add --help
Usage: just user add <email> [--name <name>]

Create a new user account

Arguments:
  <email>         User email address
                  example: "alice@example.com"

Options:
  --name <name>   Display name
```

### Validation errors

When a required argument is missing (and stdin is not a TTY, so no prompt happens), the error uses the same description and example from `.meta()` — no raw zod output:

Bash

```bash
$ just user add
error: missing argument: User email address (<email>)
       example: "alice@example.com"

Usage: just user add <email> [--name <name>]

Run `just user add --help` for details.
```

## The io Object

The `io` object provides methods for terminal output and user interaction:

### Basic Output

status-command.tsTypeScript

```typescript
import { Cli } from '@justscale/core/cli';

Cli('status').handle(({ io }) => {
  io.log('Everything is working');     // Normal output
  io.warn('This might be a problem');  // Yellow warning
  io.error('Something went wrong');    // Red error to stderr
  io.debug('Verbose information');     // Only shown with --verbose
});
```

### Progress Indicators

deploy-command.tsTypeScript

```typescript
import { Cli } from '@justscale/core/cli';

Cli('deploy').handle(async ({ io }) => {
  // Spinner for indeterminate operations
  const spinner = io.spinner('Deploying application...');
  await performDeploy();
  spinner.success('Deployment complete');

  // Progress bar for tasks with known total
  const progress = io.progress('Uploading files', 100);
  for (let i = 0; i <= 100; i += 10) {
    progress.update(i, `Uploading file ${i/10}`);
    await uploadFile(i);
  }
  progress.complete();
});
```

### Interactive Input

setup-command.tsTypeScript

```typescript
import { Cli } from '@justscale/core/cli';

Cli('setup').handle(async ({ io }) => {
  // Text prompt
  const name = await io.prompt('What is your name?', 'Default Name');

  // Confirmation
  const proceed = await io.confirm('Continue with installation?');

  // Single selection
  const env = await io.select('Select environment', [
    'development',
    'staging',
    'production'
  ]);

  // Selection with labels
  const region = await io.select('Select region', [
    { label: 'US East', value: 'us-east-1' },
    { label: 'EU West', value: 'eu-west-1' },
  ]);

  // Password (hidden input)
  const password = await io.password('Enter password');
});
```

### Tables

list-users-command.tsTypeScript

```typescript
import { Cli } from '@justscale/core/cli';

Cli('list-users').handle(async ({ io, users }) => {
  const allUsers = await users.findAll();

  io.table(allUsers, ['id', 'email', 'createdAt']);

  // Or with custom columns
  io.table(allUsers, [
    { key: 'id', header: 'ID', width: 8 },
    { key: 'email', header: 'Email Address', width: 30 },
    { key: 'createdAt', header: 'Created', align: 'right' },
  ]);
});
```

### Structured Results

Use `io.result()` to return structured data for programmatic use:

status-result-command.tsTypeScript

```typescript
import { Cli } from '@justscale/core/cli';
import { z } from 'zod';

const StatusResult = z.object({
  branch: z.string(),
  clean: z.boolean(),
  ahead: z.number(),
});

Cli('status')
  .returns(0, StatusResult)
  .handle(({ io }) => {
    io.log('Checking git status...');

    // Return structured data (validated against schema)
    io.result({
      branch: 'main',
      clean: true,
      ahead: 0,
    });
  });
```

## Running CLI Commands

There are several ways to execute CLI commands:

### Using the app socket

When `just dev` starts an app, a Unix socket is created that allows the `just` binary to invoke commands defined on your controllers:

app.tsTypeScript

```typescript
import JustScale, { defineApp } from '@justscale/core';
import type { AppEnv } from './env-contract';
import { DbController } from './controllers/db';

// defineApp is the canonical entrypoint. It opens the cluster socket
// automatically; no manual app.serve() call needed.
export default defineApp(import.meta, (env: AppEnv) =>
  JustScale()
    .add(env)
    .add(DbController)
);
```

Bash

```bash
# In another terminal:
just db migrate up --steps 3
just db seed --file ./data.json
```

### Programmatic Invocation

Invoke commands programmatically using `invoke()`:

invoke-example.tsTypeScript

```typescript
import JustScale from '@justscale/core';
import { invoke } from '@justscale/core/cli';
import { DbController } from './controllers/db';

const app = JustScale()
  .add(DbController)
  .build();

const result = await invoke(app, 'db migrate', {
  direction: 'up',
  steps: 3,
});
```

### Direct Execution

Run commands directly from argv using `run()`:

cli.tsTypeScript

```typescript
import JustScale from '@justscale/core';
import { run } from '@justscale/core/cli';
import { DbController } from './controllers/db';

const app = JustScale()
  .add(DbController)
  .build();

// This parses process.argv and executes the command
await run(app, {
  name: 'myapp',
  exitOnError: true,
});
```

Bash

```bash
# Run with tsx or node:
tsx cli.ts db migrate up --steps 3
```

## Tab Completion

The first time you run any `just ...`command in dev mode, JustScale installs a shell completion function into your shell's rc file. No manual setup, no `eval` line to paste. The install is idempotent — a `# justscale:tab-completion` marker keeps it from duplicating on repeat runs.

### Supported shells

- bash — appended to ~/.bashrc
- zsh — appended to ~/.zshrc
- fish — written to ~/.config/fish/completions/just.fish

The shell is detected from `$SHELL`. The completion function is inlined directly — no external script to keep in sync.

### Per-project commands

Completion candidates come from the project in your current working directory. The installed shell function shells out to `just __complete `, which resolves commands from the cwd's project. Two projects, two different completion sets:

Bash

```bash
cd app-a && just <TAB>   # shows app-a's commands
cd app-b && just <TAB>   # shows app-b's commands
```

### Opt-outs

Auto-install is gated to dev invocations. It skips when any of the following are true:

- JUSTSCALE_NO_COMPLETION_INSTALL=1 — explicit opt-out
- CI=true — standard CI environment
- NODE_ENV is production, test, or anything other than development

In practice: your dev machine installs once. CI, production hosts, and test runs don't touch your rc file.

## Middleware and Guards

CLI routes support middleware and guards just like HTTP routes:

delete-user-command.tsTypeScript

```typescript
import { createMiddleware } from '@justscale/core';
import { Cli } from '@justscale/core/cli';
import { z } from 'zod';
import { AuthService } from './auth-service';

const requireAuth = createMiddleware({
  inject: { auth: AuthService },
  handler: ({ auth }) => async (ctx: { args: { token?: string } }) => {
    const token = ctx.args.token;
    if (!token || !auth.verify(token)) {
      throw new Error('Authentication required');
    }
    return { user: await auth.getUser(token) };
  },
});

Cli('delete-user')
  .input(z.object({
    userId: z.string(),
    token: z.string(),
  }))
  .use(requireAuth)
  .handle(({ userId, user, io }) => {
    // Only runs if authenticated
    io.log(`Deleting user ${userId} (authorized by ${user.email})`);
  });
```

## Calling Other Commands

Use `CliService` to invoke other commands from within a CLI handler:

shell-controller.tsTypeScript

```typescript
import { createController } from '@justscale/core';
import { Cli, CliService } from '@justscale/core/cli';

const ShellController = createController({
  inject: { cli: CliService },
  routes: (services) => ({
    shell: Cli('shell').handle(async ({ io }) => {
      const { cli } = services;
      // List all available commands
      const commands = cli.listCommands();
      io.table(commands.map(cmd => ({ command: cmd })));

      // Execute another command
      const result = await cli.execute(
        'db migrate',
        { direction: 'up', steps: 1 },
        io
      );
    }),
  }),
});
```

## Next Steps

- Cluster
- Request Handling
- Controllers
