Plugin API
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,
--jsondocuments, 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
0on success,1on failure,2on a usage error and130when the user cancels (see Exit Codes). When a signal stops the plugin,ajsexits with128plus the signal number. - The global options of
ajsgiven before the plugin name arrive in the environment:ajs --no-color dms devsetsNO_COLOR=1,ajs --verbose dms devsetsANTELOPEJS_VERBOSE=*and--verbose=cli,apisetsANTELOPEJS_VERBOSE=cli,api(only the=form takes channels), andajs -q dms dev(or--quiet) setsANTELOPEJS_QUIET=1. The module reads these variables. Options after the plugin name are forwarded as arguments, so declare--no-colorand-q, --quieton your program too. - A quiet run (
-q,--quietorANTELOPEJS_QUIET) prints results, warnings and errors only, and keeps its exit codes: no progress, information, success messages, summaries or next steps. TheUiand 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
| Export | Use |
|---|---|
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, displayPath | 2.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:
| Export | Use |
|---|---|
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
| Export | Code | Meaning |
|---|---|---|
SUCCESS_EXIT_CODE | 0 | The command succeeded |
FAILURE_EXIT_CODE | 1 | The command failed |
USAGE_EXIT_CODE | 2 | The command line is wrong; nothing was attempted |
CANCELLED_EXIT_CODE | 130 | The 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.