CLI design standard
This page tells authors and reviewers of commands inapps/cli how Orbit collects input and presents results. It defines the shared design standard. A command’s adoption record states which requirements the command meets and how they were verified. This standard alone does not make a command compliant.
gateway:release:rollback uses --yes to confirm a code switch that leaves the database in place. Its interactive confirmation defaults to No; see the command contract.
Scope and authority
The current command contract supplies the command name, permitted selectors, required inputs, defaults, response schema, error codes, exit statuses, and supported modes. This standard supplies the interaction and presentation rules, including explicit destructive consent. A documented exception names its governing contract and verification. An implementation gap is not an exception. Command names follow the CLI command vocabulary. Product boundaries stay in their owning references. A rendering change does not introduce a target fallback, a permission, a new transport, or a new public option.Input and output modes
Input mode determines whether prompts are available. Output mode determines how results are presented.
Machine output contains no prompt, human heading, progress animation, or ANSI escape sequence. A command that already uses JSON lines keeps that contract. A final JSON command does not gain progress frames merely because it can take time. Human and machine output describe the same result, subject to documented redaction and human-only display limits.
Public product commands that return data support
--json. Their existing final-response or streaming schema governs that mode. Framework and internal commands retain their separately documented output contracts.
Resolve and validate required fields before mutations. Validate a supplied value when it is read, and validate a prompted value when the user submits it. If a prompted value is invalid, ask again without an arbitrary retry cap. Stop on cancellation or end of input, using the command’s failure status, before mutation begins.
In a terminal, a command prompts for every required input the caller omitted, and it offers a default where one exists, such as root for a bootstrap user or the Project default branch. A destructive target is selected through the interactive data list, never inferred. A noninteractive or machine call refuses an omitted required input with the command’s documented error code. Read-only lookups needed to resolve a prompt may run before consent. A slow lookup needs visible waiting feedback.
Prompt selection
Choose a prompt by the values the command admits.
Use Laravel Prompts for prompts and tables. An interactive data list means its columnar datatable primitive, with stable selector keys, row highlighting, keyboard selection, and a
Press / to search hint. Display columns never become identity. A grouped property display is not an interactive data list. Do not substitute the console framework’s question or table helpers for these primitives.
Use a finite selector for an existing registry entity; an open text or numeric prompt must not replace it. Select and multiselect defaults belong to the admitted choices. Suggest accepts a valid value outside its suggestions. Multi-value prompts use the command’s documented multi-value argument or option in noninteractive mode. An empty candidate set produces an explicit empty result or selection failure; it never invents a default target.
Search callbacks are read-only. A noninteractive caller supplies the same value through the documented argument or option. Input secrets are not echoed or logged. Only an explicit current credential-delivery contract may return secret values. Verify those commands with disposable credentials and keep raw artifacts private; remove credentials from shared report excerpts. For each prompt, the command contract states its ID, label, source argument or option, choices, default, validation timing, cancellation, and follow-up behavior.
Consent
Resolve the subject before asking for destructive consent. State the target and the effect in the prompt. Wrap the full question at narrow widths; never truncate the target or effect to fit a confirmation label. Destructive confirmations and source-ownership transfers default to No. Declining or cancelling begins no mutation. Keep an existing option that already supplies explicit consent. When a destructive command has no independent consent option, use--yes for automated consent and a default-No prompt for interactive consent. Noninteractive and machine modes require that explicit consent option; they never imply consent.
A force option may permit a separate destructive override, such as discarding source changes; preserve that meaning and do not reinterpret it as generic confirmation. That override still requires independent consent. A command may offer a dry run only when it defines a preview without side effects. Command-specific contracts state the consent option, refusal and cancellation failures, and the independent override conditions.
Results
Select the display from the task the user is performing.
Human output names a related record by its name or slug, such as the Project
charlie-shop or the Node beast, never by its numeric id alone. JSON output carries the id and, where the Gateway provides it, the name and slug beside it.
A list does not become interactive unless its command contract defines a selection and follow-up action. Tables use short uppercase headers. Data lists retain the exact documented column names. Empty lists state that no matching records were found. Missing display values use an em dash; JSON keeps its documented null or omission behavior. A property list uses a group heading, a primary item label, and indented labeled values.
When a human field represents a user and host together, display user@host; retain separate machine fields. Display a top-level domain with its leading dot. Project detail output may include its source defaults and task settings, plus a separate table of related Instances; keep those machine values in JSON without changing accepted selectors. These formatting rules do not change accepted selectors or serialized values.
Do not dump nested JSON into human output as a replacement for a documented renderer. Avoid introductory lines that repeat the table or tree heading. Errors identify the failed field or operation in clear prose, preserve request correlation where provided, and give recovery guidance only when it is valid for the current product.
Detail tree
A detail tree uses the following shape, with one empty terminal line before and after it.Showing ... introduction. A command may add a separate related-record table when its contract calls for one.
Property values wrap at spaces. Only a word longer than the line breaks by width.
Optional feature properties follow the command’s contract, not a second machine schema. For example, instance:show adds Annotator port and Annotator URL only when a port is assigned. An unavailable URL uses an em dash in the detail tree, while JSON always carries the nullable annotator_port and annotator_url fields. Keep this distinction in the response fixtures and expected CLI output.
Progress and liveness
Show feedback before slow work starts. A human command that can take longer than one second uses a progress tree, except when its primary output is a log stream or its current contract defines a bespoke panel. A spinner serves a sub-second wait or an inner wait whose surrounding tree already supplies the context. After input resolution and consent, render the applicable progress structure before mutation. Show the known steps; reveal conditional steps only when their branch is selected. A fast read may render its result directly. A read that can wait on slow external work needs waiting feedback, even though it does not mutate state.
Active indicators alternate about every 300 milliseconds and continue while a request blocks or between stream events. The pending footer is dim
Working.... Active and completed labels are never dim. A terminal success footer names the outcome at full strength; a failure footer is red. Final footers are never dim. Plain output retains a readable outcome without color or cursor movement.
The tree has one title, a blank continuation row, one row for each known step with continuation rows between them, and a closing footer. Initial steps are waiting. Only admitted active work animates. Shared renderers own glyphs, color, spacing, repainting, and terminal cleanup so command families do not develop separate visual conventions.
Use operator-facing labels. Where steps have distinct states, use imperative, active, and completed forms such as Resolve resource, Resolving resource, and Resolved resource. Do not expose storage mechanics as progress labels.
Progress reflects evidence. Running work does not return to waiting, and terminal work does not become active again. Do not mark work active before it is admitted for execution. Stop sequential work at a failure. For one opaque request, show an indeterminate operation instead of inventing completed internal phases. Percentages require real completed and total units. A completed transport exchange does not by itself establish a successful product result.
Logs are their own output and do not need an enclosing progress tree. A bespoke panel may reuse the same state vocabulary when its current command requires a panel; it must not invent new backend progress or product operations.
Failures and process status
A successful command returns zero. A handled command failure returns the current command’s nonzero status; success-with-warning is successful only when the command contract defines that outcome. Keep parser usage errors distinct from handled product failures. Do not introduce new numeric error classes or rename stable error codes as a rendering change. A JSON failure is oneerror envelope with code, message, optional details, and request_id. The details keep only bounded values from the Gateway: the failed step or teardown_step, the closed outcome, adoption_block, reason, cleanup, role, field, and error_code tokens, a record id, the Node, stage, and message of a failed Caddy build, and validation field messages.
When a revision conflict occurs, project_documents.revision_conflict also keeps positive integer entry_id and current_revision fields so clients can preserve and reconcile drafts. env.configuration_invalid keeps only the key, rule, and placeholder of the invalid value. Other details, such as command output, drop. A Gateway consent refusal that ends a noninteractive or JSON run keeps its details too, such as field: force and reason: destructive_consent_required. Human output prints each kept detail as a field: value line after the message, except the build details that the message already names.
Preserve each command’s documented stdout and stderr channels. Machine output must remain parseable on its result channel, including failures and stream termination. Human diagnostics and progress must not leak into that channel. Check the two streams separately with redirection; a combined PTY transcript cannot prove channel placement.
Partial work reports the verified outcome and remaining failure. A request that fails after mutation must not claim rollback unless rollback was verified. Offer a next command only when the current product supports that recovery. Prompt cancellation before mutation and interruption after work starts are distinct cases; the latter requires checking actual resulting state.
Terminal behavior
Decorated terminals may repaint active output in place. Restore cursor visibility and terminal settings on success, failure, cancellation, and timeout. Handle interruption while an HTTP response is pending, without waiting for the remote operation to finish. SIGINT and SIGTERM remain cancellation (exit 128 plus the signal) even when a request-ID or transport layer wraps the throw. A client interruption does not establish that the server stopped or rolled back. Piped and undecorated output has no escape codes or repeated animation frames; emit readable settled results. Keep color selection separate from input availability and live terminal capability. When stdin is a pipe, measure the selected output’s terminal. Plain output may write one complete waiting line before admitted slow work, followed by its settled outcome. A forced color option cannot turn a pipe into a terminal. Wrap panel content inside its borders. Account for visible character width rather than ANSI bytes. Long labels and errors must not collide with borders or depend on terminal auto-wrap. Summaries and detail lines have distinct jobs and do not repeat the same full error twice. A human issue cap reports omitted items and does not truncate the machine result. Measure ordinary terminal cells without assuming emoji sequences collapse into one glyph. Keep each grapheme intact when wrapping, but count its visible base characters separately. Combining marks and joiners add no cells. Verify widths in the actual terminal. Text measurements do not prove terminal alignment. Tables keep all supplied columns and wrap headers and values within their cells. Their minimum width includes borders, separators, padding, and room for each column’s widest indivisible character. Below that width, a read-only table uses a plain labeled record display that preserves every field. An interactive data list reports the required width and stops without selecting a row. It never hides a column or truncates a selector to force a selection into the available space. Prompt, progress, and interruption state belong to one invocation. Finishing a nested or sequential command restores the surrounding command’s prompt configuration, output, cursor, terminal settings, and interrupt intent. A forced color option does not make a pipe repaintable or permit escape codes in machine output. An interactive prompt without decoration still shows input edits, search results, and the current selection before submission. Append changed prompt frames without escape codes when repainting is unavailable. These user-driven updates are separate from animation. SIGINT and SIGTERM interrupt a waiting prompt, restore its terminal, and stop before the command can use a submitted value.Shared helpers
The shared implementation lives underapps/cli/app/Support/Console. ConsoleMode keeps machine output, prompt admission, decoration, repainting, and terminal width separate. GatewayCommand resolves these facts after input binding when a command requests its shared helpers. CommandPrompts takes a factory for a Laravel Prompts instance and owns its input and terminal scope. Commands still decide which inputs may be prompted and how to report a typed PromptAborted failure.
HumanRenderer returns terminal bytes for details, tables, properties, and failures. Write these bytes through ConsoleWriter so literal formatter tags remain data. ProgressDisplay admits known steps and runs each callback in the parent process. Its during() method returns the callback result without inferring product success. Set the terminal state with complete() after checking that result, then call finish() with the verified outcome. SpinnerDisplay::during() reports a completed wait and preserves the callback’s value or exception. An inner spinner shares the active tree’s rendering and animation clock.
These helpers do not migrate a command automatically. Keep its adoption verdict unverified until its documented input, output, and terminal cases pass.
Canonical renderings
The agreed rendering of a command is its expected output underapps/cli/tests/Expected, which the CLI contract tests enforce against recorded Gateway responses. Copy the canonical example for a display instead of the nearest command, because some commands do not follow this standard yet. Every change to a rendering appears as a diff in these files, and the reviewer accepts that diff as the new agreed rendering.
macOS enrollment and removal reuse that progress tree and JSON result. Their recordings are macos-enrolled.human.txt and macos-removed.human.txt. The removal recording lists
user, package-managers, and host-wireguard under retained_on_node in both human output and macos-removed.json. Refusals before SSH use the same failure envelope, for example macos-account-required.json.
Streams and prompt flows have no recorded canonical rendering yet. The design:node-add sketch under apps/cli/design is the reference for a prompt flow until its real command lands. Gateway response fixtures describes how a family gains recorded renderings.
The Tasks JSON rendering preserves the distinction between a watched branch pull request and the reviewed pull request Orbit opened. The canonical watched task result includes watched_pr_url, watched_pr_number, and watched_pr_state with a null pr_url. Unwatched task results keep all three watched fields null. Do not substitute the watched URL for pr_url. The Tasks command contract owns these fields; the human rendering still uses the reviewed pull request.
tasks:question:close reuses the progress tree and prints the closed question as tasks:question:list does. Its recordings are closed.human.txt and the failure envelope already-closed.json.
Subtask detail output adds a Topology property when the subtask declares workload nodes, with the roles separated by commas. An empty declaration omits that human property. JSON always retains the topology array, including []. The subtask commands own the input contract; recorded task responses and CLI expectations cover both empty and declared topology.
Command reference
Every visible product command has its own section indocs/cli, headed ## orbit <command>. Two closely related commands can share one heading, such as orbit gateway:release:auto:enable and disable, when the section shows a usage line for each. The section names every argument as `name` or <name>, and every option as --option. --json is documented once per page.
apps/cli/tests/Feature/CommandSurfaceTest.php reads the registered commands and fails when a command has no section or its section misses an argument or option. CI runs that test on every pull request, including a pull request that changes only docs.
Verification and adoption
Each supported public command has an adoption record with its source identity, supported modes, applicable rules, contract-backed exceptions, checks, terminal artifacts, and verdict. Include extension-provided commands with the extension enabled. Extension command discovery reads the enabled set from the active Gateway; do not assume an extension command appears in the default command list when that Gateway has it disabled or its state is unknown. Account separately for internal commands that share input or output infrastructure. Check success, empty results, invalid and missing input, cancellation, consent, warnings, partial results, and failures wherever the command supports them. Check human, JSON, streaming, noninteractive, piped, and decorated paths that exist for that command. Expected command failures are successful verification only when a verifier asserts the exact intended result and absence of forbidden side effects. Thenode:wireguard-key command uses shared Node selection, a validated key prompt, and an indeterminate progress operation for hub convergence. Its command tests check prompt cancellation, invalid-key retry, missing or invalid supplied input before Gateway access, and human/JSON success and failure contracts. The recorded outputs preserve the request ID and safe error codes. Review its interactive behavior with a real terminal recording as well as these text assertions.
Recording
Automated text assertions establish content. Terminal evidence establishes interactive selection, repainting, cadence, wrapping, and liveness. Record timestamped raw PTY output, decoded chunks, reconstructed frames, terminal size, terminal settings, candidate and launcher identity, exit status, first-output delay, and idle gaps. Verify real frame transitions rather than the presence of two glyphs in a transcript. Use disposable fixtures for mutations. Exercise the real candidate in the assigned runtime and keep evidence tied to that candidate. A recording is stale after a fix changes the behavior it records. Do not declare a command compliant until every applicable check is supported by inspected evidence. Runbin/cli-contract --changed to find and run the contract tests that a changed Gateway response reaches, and rewrite expected output only with ORBIT_EXPECTED=update after the diff is reviewed. For an Agent pin change, replay Gateway status, Fleet rollout, and self-update contracts so JSON and human output name the same released version.
A regression check for a recovered UX requirement demonstrates both an accepted and a rejected case. Check observable behavior rather than similarity of wording or implementation style. For example, a state analyzer accepts a running row that reaches a terminal state and rejects the same row returning to waiting. Apply each check only to the surface it covers.