This release adds plugins. An Application installs each one explicitly through its plugins list, and a plugin contributes options, one middleware with declared activation, extension values, failure renderers, and a claim on the process signals. Core installs nothing on its own.
@loomcli/plugins is a new package. It ships the first-party plugins as separately installable subpath exports, starting with @loomcli/plugins/help and @loomcli/plugins/version, and it declares @loomcli/core as a peer dependency at its own version, so pin both packages at the same version.
Two changes are breaking. Command takes one options object, and ExitCode gains 130 and 143 for cancelled runs. Each migration section below gives the exact steps.
Breaking Changes
- Change
Commandto take one options object.new Command(name, { globals })replaces the positionalnew Command(name, globals)form, which no longer compiles and no longer builds. - Add
descriptionandversionas core facts.descriptionis optional on the Application, on a Command, on an option, and on an argument, and holds a character other than whitespace and no line terminator.versionis an optional string on the Application under the same rule, and a version that is not a string, is blank, or holds a line terminator fails build withThe Application version must be a string that holds a character other than whitespace and no line terminator. Supply a string such as "1.2.0".Build validates both ininspect()and inrun(). - Add the facts to
inspect().CommandGraphreportsversionas a string,0.0.0when the Application declares none, which means unversioned, so a projection never branches on an absent version. It reportsdescription, and eachCommandNode,ArgumentNode, andOptionNodereports its owndescription, orundefinedwhere the declaration omits one. The rootCommandNodereports the Application's description, the valueCommandGraph.descriptionholds. - Export the
CommandOptionstype from core.
Migration
Affected surface. Every new Command(name, globals) call that supplies a GlobalOptions value positionally. The Application constructor, the declaration calls, and every runtime behavior are unchanged.
Why. A Command now carries facts beside its globals, so its second argument is one options object, as the Application's already is. The retired form supplies a value that holds no globals key, so the globals would vanish silently and an operator, not the author, would meet the result as an unknown-option error.
Before and after.
Before:
import { Command } from '@loomcli/core';
import { globals } from '../globals.js';
export const get = new Command('get', globals).argument('path', { required: true }).action(getValue);After:
import { Command } from '@loomcli/core';
import { globals } from '../globals.js';
export const get = new Command('get', { description: 'Reads one value.', globals })
.argument('path', { required: true })
.action(getValue);Steps.
- Replace each
new Command(name, globals)withnew Command(name, { globals }). - Leave
new Command(name)unchanged, because a Command without globals still omits the second argument. - Add
descriptionto a Command, an option, or an argument, anddescriptionandversionto the Application, where a projection needs them. Each fact is optional. - Read the new
inspect()fields in any projection that renders the graph.
Validation. Run the application's type check to find each remaining positional call, which reports Type 'GlobalOptions<...>' has no properties in common with type 'CommandOptions<...>'. Run one invocation of a Command the application declares, such as node ./your-cli.js get user.name: a missed call reports Invalid declaration: Command "get" takes an options object. Supply { globals } instead of a positional GlobalOptions value. and exits with code 1. Run the application's test command to confirm the graph builds and dispatches.
- Add
run({ signal }). It accepts a caller-ownedAbortSignalthat cancels the run; core subscribes at run entry and honors the abort at every phase boundary. A value that is not anAbortSignalis an internal error with code 1. - Add the signals slot. One installed plugin claims
SIGINT,SIGTERM, or both, each of them once; core installs one process listener per claimed signal once the graph has validated, removes them on every exit path of that run, and re-raises a repeated signal so the default disposition ends the process when no other listener remains. - Add a typed cancellation reason. The
signalon a middleware context and on an action context aborts with a reason core owns, the exportedCancellationReason,{ source: 'SIGINT' | 'SIGTERM' | 'caller', cause?: unknown }, wherecausecarries the caller's ownsignal.reason, so a middleware readssourceand never infers a signal name. - Change
ExitCodeto widen from0 | 1 | 2to0 | 1 | 2 | 130 | 143: 130 forSIGINTor a caller abort, 143 forSIGTERM.
Migration
Affected surface. Every consumer that switches exhaustively on the published ExitCode type, including a switch with no default case or a type-level exhaustiveness check.
Why. A cancelled run now resolves a code of its own, 130 or 143, instead of falling into an existing code. A consumer that matched every member of ExitCode before this change now has a switch that no longer type-checks, because two members are unhandled.
Before and after.
Before:
import type { ExitCode } from '@loomcli/core';
function describe(code: ExitCode): string {
switch (code) {
case 0:
return 'succeeded';
case 1:
return 'failed';
case 2:
return 'invalid input';
}
}After:
import type { ExitCode } from '@loomcli/core';
function describe(code: ExitCode): string {
switch (code) {
case 0:
return 'succeeded';
case 1:
return 'failed';
case 2:
return 'invalid input';
case 130:
return 'cancelled by SIGINT or a caller abort';
case 143:
return 'cancelled by SIGTERM';
}
}Steps.
- Find every exhaustive
switchor lookup overExitCodewith the type checker; a missing case reports the unhandled literals. - Add a
130case forSIGINTor a caller-supplied abort, and a143case forSIGTERM. - Where the consumer treats an unknown code as success, confirm that treatment still holds for 130 and 143, since a script that reports 0 after an interrupt carries on as if the work finished.
- If the consumer owns a supervising process, decide whether to propagate 130 or 143 to its own exit code or to translate them, and update its own documented exit codes accordingly.
Validation. Run pnpm exec tsc --noEmit, or the consumer's own type check, to confirm every ExitCode switch compiles with the two new cases. A code of 130 or 143 reaches a consumer only when the application installs a plugin that owns the signals slot, or when the caller supplies run({ signal }); with neither, core installs no listener and a process signal keeps its default effect. With an owner installed, interrupt a long-running invocation with Ctrl-C and confirm the process exits 130, then send SIGTERM to another and confirm it exits 143.
See the core reference for the exit code table and the signals and cancellation section, and ADR-0018 for the decision.
Changes
-
Add plugins.
plugin(identity, definition)returns a frozen value that an Application installs throughpluginsin its options object, in the order every contribution composes. A plugin holds declarations alone and performs no work when it is created or installed. Build rejects an entry that is not a plugin, an identity installed twice, and an empty identity. See the plugin contract. -
Add plugin options. A plugin declares options under
optionswith the parsing keystype,short,shortOnly,polarity,multiple,default,description, andextensions, and no schema or presence rule. They join the globals table after the application's own globals, so the pre-scan consumes them at any placement with the same value rules and the same diagnostics. Every collision by key or by spelling is a build error. A plugin option reaches its own plugin's middleware alone: an action never receives it. -
Add middleware with declared activation and lazy loading. Core runs the middleware of each installed plugin whose activation matched between routing and the callable check, in installation order, and calls a plugin's
loadonly when the chain reaches it.activateis'always'or a list of the plugin's own option names, evaluated from the pre-scan before any plugin code loads. A middleware receivesoptions,graph,command,host,out,signal, andnext, takes over by returning without callingnext(), and reads what it wrapped from theChainOutcomethatnext()resolves. -
Add extensions.
extension(identity, { schema, target })returns a descriptor that is also a factory, and a declaration lists the values it produces underextensionson the Application, on a Command, on any option, and on an argument. Build validates each value once, synchronously, and stores its plain-data output on the graph node under the extension's identity.readExtension(node, descriptor)is the typed read; a fact whose plugin is not installed stays inert and is still reported. -
Add failure renderers from plugins. A plugin registers renderers under
failures, which enter resolution after the application's own in installation order. The same class registered by two contributors resolves first-in-wins. -
Add
scopeandextensionstoinspect().globalsholds the application's options and every plugin option in one list, andscopereads'application'or'plugin'. EveryCommandNode,ArgumentNode, andOptionNodereportsextensionsas the frozen record its declaration carries, or an empty record. -
Add
signalto the action context. It is the run's cancellation signal, and it never aborts until a caller or an installed signals owner can abort it. -
Export
plugin,extension, andreadExtension, with the typesPlugin,PluginDefinition,PluginOptions,PluginOptionValues,OptionsOf,AnyExtension,Extension,ExtensionValue,Middleware,MiddlewareContext, andChainOutcome. -
Add
@loomcli/plugins, the first-party plugin pack, which ships each plugin as its own subpath export and has no root export.@loomcli/plugins/helpcontributes the Boolean optionhelpwith the short spellingh, and prints the help page of the routed Command, derived from the graph alone: the masthead, the details the node carries, USAGE, COMMANDS, ARGUMENTS, OPTIONS, GLOBAL OPTIONS, EXAMPLES, and the closing hint.@loomcli/plugins/versioncontributes the Boolean optionversionwith the short spellingV, and prints<name> v<version>, so an Application that declares no version printsv0.0.0. Each one takes over the invocation, so the remaining tokens are never parsed and the exit code is 0.@loomcli/plugins/help/extensionexports the two descriptors a declaration carries for the page:helpCommand({ details, examples })on the Application and on a Command, andhelpInput({ placeholder })on a local, global, or plugin option. The pack declares@loomcli/coreas a peer dependency at its own version, so one core instance serves the application and its plugins. See First-party plugins. -
Fix the extension value diagnostic so a schema message that ends with its own full stop is not followed by a second one. Core now adds the full stop only when the message carries none.
-
Add
hiddento a named Command's options object and to an option config, including aGlobalOptionsdeclaration and a plugin option. A hidden member routes, parses, and runs as any other member, and every listing omits it. An omittedhiddenreadsfalse. -
Add
deprecatedto the same declarations. Its value is the one-line migration message a projection shows beside the member, such as'Use get instead.'. A baretrueis rejected, because a deprecation with no migration path leaves an operator with nothing to do. -
Reject either fact on an argument config and on the Application options, because a positional cannot leave the grammar it sits in and the root is every page's entry point.
-
Report
hiddenanddeprecatedon everyCommandNodeandOptionNodethatinspect()returns, so a projection reads both without a plugin installed. -
Omit a hidden child from the candidates a routing failure carries. When every child of a Command is hidden the candidate list is empty, so a renderer that offers candidates handles the empty case.
-
Render both facts on the help pages that
@loomcli/pluginsprints. A hidden Command or option leaves its own row out of every usage form, COMMANDS section, OPTIONS section, and closing hint, though a group whose children are all hidden still keeps its own children usage form, and a hidden Command routed to directly still prints its own page. A deprecated Command adds aDeprecated: <message>line, indented two spaces, under its masthead, and a deprecated child or option carriesdeprecated: <message>as the last fact of its right cell.