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:
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:
# Using the just CLI
just db migrate up --steps 3Arguments and Flags
JustScale automatically parses arguments based on your Zod schema:
Positional Arguments
Required fields without defaults become positional arguments:
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:
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.
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.
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:
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:
$ 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 nameValidation 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:
$ 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
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
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
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
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:
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:
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)
);# In another terminal:
just db migrate up --steps 3
just db seed --file ./data.jsonProgrammatic Invocation
Invoke commands programmatically using invoke():
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():
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,
});# Run with tsx or node:
tsx cli.ts db migrate up --steps 3Tab 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 <cursor> <words>, which resolves commands from the cwd's project. Two projects, two different completion sets:
cd app-a && just <TAB> # shows app-a's commands
cd app-b && just <TAB> # shows app-b's commandsOpt-outs
Auto-install is gated to dev invocations. It skips when any of the following are true:
JUSTSCALE_NO_COMPLETION_INSTALL=1— explicit opt-outCI=true— standard CI environmentNODE_ENVisproduction,test, or anything other thandevelopment
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:
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:
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
);
}),
}),
});