[data-reveal]{opacity:1!important;transform:none!important}
CLI Reference

Plugin API

Build a CLI plugin whose output, errors and exit codes match ajs, with the @antelopejs/core/cli module.

Overview

@antelopejs/core/cli is the output layer of ajs itself, published for plugins. A plugin that uses it prints the same symbols and colors as the core commands, writes results and feedback to the same streams, renders the same help pages, reports errors with the same what / why / fix template and exits with the same codes.

The module is CommonJS with TypeScript declarations, and it loads nothing from the AntelopeJS runtime: importing it costs a few milliseconds. The failure descriptions are only loaded when a run fails, and the prompt library only when a question is asked.

Declare @antelopejs/core as a peer dependency, starting with the first version that ships the module:

{
  "peerDependencies": {
    "@antelopejs/core": ">=1.12.0 <2"
  }
}

TypeScript resolves the module through the package exports: use "moduleResolution": "node16", "nodenext" or "bundler".

Output Contract

ajs <plugin> runs the plugin with the standard streams of ajs and exits with the plugin's exit code. A plugin reads like a core command when it follows the same contract, which this module implements:

  • stdout carries results only (data, --json documents, help and version), so it can be piped or redirected.
  • stderr carries everything else: progress, warnings, errors, summaries and next steps.
  • The exit code is 0 on success, 1 on failure, 2 on a usage error and 130 when the user cancels (see Exit Codes). When a signal stops the plugin, ajs exits with 128 plus the signal number.
  • The global options of ajs given before the plugin name arrive in the environment: ajs --no-color dms dev sets NO_COLOR=1, ajs --verbose dms dev sets ANTELOPEJS_VERBOSE=* and --verbose=cli,api sets ANTELOPEJS_VERBOSE=cli,api (only the = form takes channels), and ajs -q dms dev (or --quiet) sets ANTELOPEJS_QUIET=1. The module reads these variables. Options after the plugin name are forwarded as arguments, so declare --no-color and -q, --quiet on your program too.
  • A quiet run (-q, --quiet or ANTELOPEJS_QUIET) prints results, warnings and errors only, and keeps its exit codes: no progress, information, success messages, summaries or next steps. The Ui and the tasks of this module leave them out on their own; isQuietRun() tells whether the run is quiet, for output you write yourself.

A Complete Command

import { Command } from "commander";
import {
  CliError,
  USAGE_EXIT_CODE,
  formatUsageErrors,
  getProcessUi,
  runTask,
  runWithErrorBoundary,
} from "@antelopejs/core/cli";

const program = new Command()
  .name("ajs dms")
  .option("-q, --quiet", "Print only results, warnings and errors")
  .option("--no-color", "Disable colors (also NO_COLOR=1)");

program
  .command("build")
  .option("--port <port>", "Port of the preview server", "3000")
  .action(async (options: { port: string }) => {
    const port = Number(options.port);
    if (!Number.isInteger(port) || port < 1 || port > 65535) {
      throw new CliError({
        title: `Port ${options.port} is out of range`,
        reason: "A port is a number from 1 to 65535.",
        fixes: ["Pass a port such as --port 3000"],
        exitCode: USAGE_EXIT_CODE,
      });
    }
    await runTask("Building the frontend", () => buildFrontend(), {
      done: "Built the frontend",
    });
    getProcessUi().summary({
      headline: "Build complete",
      artifact: "./dist",
      nextSteps: [{ command: "ajs dms start", description: "serve the build" }],
    });
  });

formatUsageErrors(program);

void runWithErrorBoundary(async () => {
  await program.parseAsync();
});

runWithErrorBoundary prints whatever the command throws once, on stderr, and leaves the exit code in process.exitCode. formatUsageErrors turns commander's usage errors (unknown option, missing argument) into the same error template, with exit code 2.

Output

ExportUse
getProcessUi()The Ui bound to stdout and stderr, which writes above the running tasks
createUi({ streams, capabilities, isQuiet })A Ui on other streams, for example in-memory streams in tests
getProcessPalette(channel)The colors of a channel, plain text when that channel has no colors
writeData(ui, { data, isJson, render })One JSON document on stdout with --json, the human rendering otherwise
formatDuration, pluralize, displayPath2.1s, 2 modules, ./modules/auth

A Ui writes results (value, json, table, list, heading, details) on stdout and feedback (message, problem, summary) on stderr. On a terminal, tables are aligned under a header and fit its width: the first column is kept whole, the widest other columns are cut with ui.symbols.ellipsis, and a terminal too narrow even for that gets one block of header and value lines per row. When stdout is piped, tables become one tab-separated line per row. A quiet Ui (isQuiet, which defaults to isQuietRun()) leaves out info, success, skip and hint messages, feedback details and summaries.

On a terminal, everything else a Ui writes as text is wrapped to the terminal width between words: messages and their details, problems (title, reason, details and fixes), list items, details values and the descriptions of next steps. Each line after the first is indented under the text it continues, a detail keeps its own leading spaces, colors do not count toward the width, and a word longer than the line, such as a long path, stays whole. When the stream is not a terminal, or its width is unknown, lines are written whole, so piped output and logs keep one line per message. For text you lay out yourself, wrapText(text, width) wraps it the same way.

Colors and symbols follow the rules of ajs (see Global Options): --no-color anywhere on the command line and NO_COLOR turn colors off, FORCE_COLOR forces them, and terminals that cannot draw Unicode get an ASCII symbol set. detectCapabilities(processCapabilityContext()) returns what was detected, and ui.symbols, selectSymbols and SYMBOL_SETS give the symbols themselves: the level symbols, bullet, rule and spinner, and separator (" · " or " - "), ellipsis ("…" or "...") and arrow ("→" or "->") to join, cut and point without a hard-coded Unicode character. Declare --no-color on your program so commander accepts it.

Help

The help conventions of ajs are exported, so a plugin's help pages read like the core's:

ExportUse
applyHelpConventions(program)-h, --help and help [command] worded like ajs, global options on every page, pages wrapped to the terminal
withExamples(command, examples)Appends an Examples: block: each { description, command } as a comment followed by the command
formatExamples(examples, width)The Examples: block as text
helpWidth(stream), helpTextWidth(context)The width help is wrapped to: the terminal's, up to 80 columns, and 80 columns in a pipe
wrapText(text, width), unbreakable(text)Wraps text between words; the words of an unbreakable run, such as a command, stay on one line
formatHelpItem(term, termWidth, description, width)A term and its description, beside it or under it when fewer than 30 columns are left

applyHelpConventions applies to the command and every subcommand it has at that point, so call it once the program is complete. Text added with addHelpText is not wrapped by commander: pass a function and wrap it with wrapText(text, helpTextWidth(context)).

Progress

runTask(label, work, { done, failed }) runs work as a task of the process TaskList: an animated line on an interactive terminal, a single final line in CI, in pipes and with --verbose. Tasks that take a second or more show their duration. Create a new TaskList({ streams }) to run tasks on other streams.

Errors

Throw a CliError for every failure the user can act on:

throw new CliError(
  {
    title: "Cannot read the manifest",
    reason: "The backend answered 401.",
    fixes: ["Check DMS_TOKEN, then try again"],
  },
  { cause: error },
);

A problem is printed as its title, its reason and its details, then its fixes. Pass the low-level error as cause: --verbose (or ANTELOPEJS_VERBOSE) prints its stack trace, and the error only suggests --verbose when there is such a cause or command output to show. isVerboseRun() tells whether the run is verbose.

Errors your commands do not wrap can be explained in one place with the translate option of runWithErrorBoundary. It receives every failure that is not a CliError and returns a problem, or undefined to keep the default description:

void runWithErrorBoundary(main, {
  translate: (error) =>
    error instanceof TypeError && error.message === "fetch failed"
      ? {
          title: "Cannot reach the DMS backend",
          fixes: ["Start the backend, then try again"],
        }
      : undefined,
});

Prompts

createPrompter({ command, acceptsDefaults, defaultsFlag }) asks the questions a command cannot get from its flags. Each question takes the flag that answers it, so when nobody can answer (stdin is not a terminal, or CI is set) the prompter throws a NeedsInputError naming that flag, with exit code 2. A cancelled prompt throws a CancelledError, reported as Cancelled with exit code 130.

const prompter = createPrompter({ command: "ajs dms clean" });
const isConfirmed = await prompter.confirm({
  message: "Delete the build output?",
  flag: "--yes",
  answer: options.yes,
  defaultAnswer: false,
});

Exit Codes

ExportCodeMeaning
SUCCESS_EXIT_CODE0The command succeeded
FAILURE_EXIT_CODE1The command failed
USAGE_EXIT_CODE2The command line is wrong; nothing was attempted
CANCELLED_EXIT_CODE130The user cancelled the command

A CliError exits with 1 unless its problem sets another exitCode.

Versioning

Everything exported by @antelopejs/core/cli follows semantic versioning with @antelopejs/core. Other paths inside the package are internal and cannot be imported.