Writing abx command modules
This directory holds the command modules of the CLI (sandbox.ts, sandbox_files.ts, sandbox_process.ts,
sandbox_system.ts, template.ts, config_cmd.ts, mcp.ts, deploy.ts, ...). The framework they build on is a
byte-for-byte port of Click 8.5.0 (src/framework/); the output layer is a port of cli/output.py
(src/cli/output.ts). Everything below is the contract between those layers and a command module.
1. Export convention (how main.ts finds your commands)
src/cli/main.ts registers command modules lazily. A registry entry is a spec string
"commands/<module>:<attr>" (module path relative to src/cli, no extension). When the root group needs the command
(help listing, resolution, invocation) it runs import("./commands/<module>.js") and reads the export called <attr>.
The registry is static code in src/cli/command_surface.ts: GROUP_COMMANDS (the command groups) and SHORTCUTS (the
fixed top-level shortcuts). It is not configurable; tests/test_cli/test_command_surface.test.ts checks it against
abx --help and against docs/en/reference/cli-commands.md. A module exports each click command/group under the
attribute name used in its spec (these are data keys, do not rename them to camelCase):
| spec | the module must export |
|---|---|
commands/sandbox:sandbox |
export const sandbox = group("sandbox", ...) |
commands/config_cmd:config |
export const config = group("config", ...) |
commands/mcp:mcp |
export const mcp = group("mcp", ...) |
commands/template:template |
export const template = group("template", ...) |
commands/provider:provider |
export const provider = group("provider", ...) |
commands/sandbox:create / list_cmd / info / kill / exec_cmd / connect / upload / download / run_cmd |
the shortcut targets (abx create, abx list, ...) |
commands/deploy:deploy_shortcut |
idem |
Rules:
- The same
Commandobject may be exported under several names / attached to several groups: a top-level shortcut is the very command object of its canonical path (abx execisabx sandbox exec). - Sub-groups (
sandbox files,sandbox process,sandbox command, ...) are ordinaryGroups registered withparent.addCommand(cmd).sandbox.tsimportssandbox_files.js& co. itself because it attaches them eagerly. - Adding a top-level command means editing
command_surface.tsanddocs/{en,zh}/reference/cli-commands.md; the surface test fails until both agree. - Do not import
main.tsfor anything excepthandleErrorsand theEXIT_*constants (a static import is fine:main.tsonly imports command modules dynamically, there is no top-level await, so the cycle is harmless). - Tests that need to substitute a command module use the seam
mainSeams.importModule(modPath)inmain.ts.
2. Framework API cheat-sheet (src/framework/index.ts)
There are no decorators; every factory returns the object.
import {
command, group, option, argument, versionOption, helpOption, // factories
Context, Command, Group, Choice, IntRange, FloatRange, Path, Tuple, INT, FLOAT, BOOL, STRING, UUID, UNPROCESSED,
UsageError, BadParameter, MissingParameter, ClickException, Abort, Exit,
echo, secho, style, prompt, confirm, getCurrentContext, sys, sysExit, SystemExit,
} from "../../framework/index.js";
import { getOutput, enableVerbose } from "../output.js";
import { getFormatter } from "../formatters.js";
import { regionOption, resolveRegion } from "../region.js";
import { handleErrors, EXIT_AUTH } from "../main.js";
export const create = command("create", {
help: "Create a new sandbox.\n\n\b\nExamples:\n abx create --template base", // Python docstring, cleandoc'd, \b kept
params: [
argument(["description"], { required: false }),
option(["--template", "-t"], { default: null, help: "Template name" }),
option(["--timeout"], { type: INT, default: null }),
option(["--verbose", "-v"], { is_flag: true }),
regionOption(), // shared --region/-r
],
callback: handleErrors(async (ctx, params) => { // params: { description, template, timeout, verbose, region }
const out = getOutput(ctx);
if (params.verbose) enableVerbose(ctx);
out.info("Creating sandbox...");
out.data({ ID: "sbx-1", Status: "running" });
}),
});
- Callback:
(ctx: Context, params: Record<string, any>) => unknown | Promise<unknown>.paramsis keyed by the Python parameter name (--no-color->no_color,["--json", "-j", "output_json"]->output_json). It replaces Python's@click.pass_context+ keyword arguments. Callbacks may beasync; alwaysawaitinside them. - Options:
option(decls, attrs);attrsmirror Click's keyword names verbatim (is_flag,flag_value,multiple,count,type,default,required,help,metavar,envvar,show_default,hidden,callback,is_eager,expose_value,prompt,hide_input,nargs,show_choices, ...).typetakes aParamType(INT,new Choice([...], { case_sensitive: false }),new IntRange({ min: 1 }),new Path({...})). Default"--flag/--no-flag"boolean pairs work as in Click. - Arguments:
argument(decls, attrs)(required,nargs,type,default,metavar). - Commands/groups:
command(name, { help, short_help, epilog, params, callback, hidden, deprecated, context_settings, no_args_is_help, add_help_option, options_metavar });group(name, { ..., invoke_without_command, chain, commands, subcommand_metavar, result_callback });parent.addCommand(cmd, name?),parent.command(name, attrs),parent.group(name, attrs),parent.resultCallback(fn).cls: SubClassselects a subclass (CommandAttrs & { cls }). - Context:
ctx.obj(the root stores a plain object withjson,quiet,verbose,no_color,timeout,profile),ctx.meta(plain object shared down the tree),ctx.parent,ctx.params,ctx.invoked_subcommand,ctx.invoke(otherCommand, kwargs),ctx.forward(...),ctx.exit(code),ctx.fail(msg),ctx.abort(),ctx.ensureObject(Object),ctx.findRoot(),ctx.getParameterSource(name),ctx.callOnClose(fn).getCurrentContext(silent?)returns the active context (AsyncLocalStorage based). - Errors:
throw new UsageError(msg, ctx)(exit 2, usage + "Try ..." hint on stderr),BadParameter,ClickException(exit 1,Error: msg),Abort("Aborted!", exit 1),Exit(code)/ctx.exit(code),sysExit(code)(throwsSystemExit).handleErrors(fn)mapsSandboxError& remote errors to exit codes (1 general, 3 auth, 4 not found, 5 timeout, 6 quota) and rethrowsSystemExit/Exit/Abort/KeyboardInterrupt/EOFError. Note (as in Python) it also catchesUsageErrorraised inside the wrapped callback and turns it into exit 1. - Help: byte-identical to Click 8.5.0 at width 80 (
-h/--helpare available everywhere through the root'shelp_option_names).ctx.getHelp()is async. - Echo/prompt:
echo(msg, { err, nl, color }),secho,style(msg, { fg, bold, ... }),await prompt(...),await confirm(...). They write throughsys.stdout/sys.stderrfrom the framework (whatCliRunnercaptures) - never useprocess.stdout.write/console.login command code. - Python's synchronous APIs that touch commands (
get_command,list_commands,resolve_command,invoke,get_help,make_context,main) areasynchere and use camelCase:getCommand,listCommands,resolveCommand,ctx.invoke,ctx.getHelp,cmd.makeContext,cmd.main.
3. Output (src/cli/output.ts)
getOutput(ctx?) returns the OutputManager the root group stored in ctx.meta["abx.output"] (falls back to a default
manager when called outside a context). Channel policy: stdout = results (data, table, success),
stderr = everything else (info, progress, warning, error, debug, and every logging record).
out.info("..."); out.progress("..."); out.warning("..."); out.debug("...");
out.error("boom", { code: "E1001", suggestion: "retry" });
out.success("done");
out.data(new Map([["ID", "sbx-1"], ["Status", "running"]])); // object or Map (Map keeps key order for integer-like keys)
out.table(["ID", "Status"], [["sbx-1", "running"]]);
await out.spinner("Creating sandbox", () => doIt()); // context-manager -> callback
await out.activity("Researching", async (update) => { update?.("line1\nline2"); }, { tick: true });
const act = out.startActivity("Building", { tick: true }); // imperative form: { update | null, close() }
try { act.update?.("..."); } finally { act.close(); }
jsonMode / quiet / noColor / verbose / ci are plain fields (json_mode / no_color getters exist as
Python-spelling aliases). Constructor options accept both spellings (new OutputManager({ jsonMode: true }) /
{ json_mode: true }).
getFormatter(ctx) (formatters.ts) gives the legacy OutputFormatter (printSuccess, printError(msg, { code,
suggestion }), printTable, printDict, printData). handleErrors uses it with ctx.obj.
Test seams (Python module-attribute monkeypatches) are on the mutable outputSeams object (sys, isTty, echo,
Console, activityMinInterval, activityTickSeconds, monotonic).
4. Testing a command
import { CliRunner } from "../helpers/cliRunner.js";
import { cli } from "../../src/cli/main.js";
const runner = new CliRunner();
const result = await runner.invoke(cli, ["create", "--template", "base"]);
expect(result.exit_code).toBe(0);
expect(result.stdout).toContain("created successfully"); // stdout / stderr / output (interleaved) like Click 8.5
CliRunner.invoke(cmd, args, { input, env, catch_exceptions, color, prog_name }) - isolated env, buffered in-memory
non-tty streams, forced terminal width 80, ANSI stripped unless color: true. tests/test_cli/conftest.ts provides the
shared fixtures (runner, pristineLogging, defaultShortcutsOnly, capsys).