Pages
Contents
COMP_cli_adr-001 β Command Registration via Autodiscovery
Status: ACCEPTED Date: 2026-03-16 Author: xPulse
Context
@xpulse/cli needs a mechanism to aggregate commands from various xParts
without having to depend on them β and without boilerplate in every xPart.
Additionally, a command should be fully abstracted from CLI internals:
no import of @xpulse/event, no manual emission of cli:done,
no knowledge of timeout mechanisms.
Decision
@xpulse/cli is a runner β not a command owner.
- Each xPart places command classes under
src/commands/ @xpulse/clidiscovers allnode_modules/@xpulse/*/src/commands/*.json startup- Found
Commandsubclasses are registered automatically - No
cli.jsin the xPart root required
| @xpulse/cli discovers β @xpulse/doc/src/commands/FetchCommand.js |
| β @xpulse/config/src/commands/ConfigShowCommand.js |
Command Contract
| import { Command } from '@xpulse/cli/command'; |
| export class FetchCommand extends Command { |
| configure() { |
| this |
| .setName('doc:fetch') |
| .setAlias('df') |
| .setDescription('Fetch docs via git archive') |
| .addArgument('tool', null, 'Tool name') |
| .addOption('tag', null, 'Git tag') |
| .addFlag('dry-run', false, 'Dry run'); |
| } |
| async run(input, output) { |
| while (!done) { |
| await doWork(); |
| this.keepAlive(); // β no event import required |
| } |
| output.success('Done!'); |
| return 0; // β exit code instead of cli:done event |
| } |
| } |
Full Abstraction
A command imports nothing from @xpulse/event and knows nothing about
CLI internals. @xpulse/cli handles everything:
| Task | Who does it |
|---|---|
| Autodiscovery | @xpulse/cli |
| Build input | @xpulse/cli |
| Build output | @xpulse/cli |
Inject keepAlive function |
@xpulse/cli |
| Start + stop timer | @xpulse/cli |
process.exit(code) |
@xpulse/cli |
| Implement logic | xPart |
Exit Codes
run() returns a numeric exit code β the well-known Unix scheme:
| Code | Meaning |
|---|---|
0 |
Success |
1 |
Error |
2 |
Misuse β wrong or missing arguments |
@xpulse/cli calls process.exit(exitCode) after run().
No cli:done event β run() resolves and the process ends.
`cli:keepalive` β internal
cli:keepalive continues to exist as an internal event in @xpulse/cli.
xParts do not see it β they call this.keepAlive(), and the base class
emits the event internally via an injected function.
`--help` + `--list`
| xpulse --help # all commands with name, alias, description |
| xpulse --help doc:fetch # command detail with arguments, options, flags, example |
| xpulse --list # names only, machine-readable β for autocomplete/scripting |
Dependencies
| @xpulse/cli β @xpulse/event β internal for keepAlive |
Rationale
- Single Responsibility β each xPart fully owns its commands
- Zero Coupling β
@xpulse/cliknows no other xParts - Full Abstraction β a command knows nothing about events, timeouts, or
process termination;
run()simply returns an exit code - No Boilerplate β no
cli.js, nocli:done, no event import - Self-describing β
configure()holds everything: name, alias, description, help text, arguments, options, flags - Known scheme β exit codes 0/1/2 are Unix standard, universally understood
keepAlive()injected rather than imported β command stays decoupled, CLI stays in control of the timer--listfor tooling β machine-readable output enables shell autocomplete and external tools without HTML-parsing of--help
Consequences
- Every xPart that provides CLI commands places them under
src/commands/ - One file = one command class (convention)
run()always returns an exit code (0/1/2)this.keepAlive()for long-running tasks β no event import required@xpulse/cliis a dependency in xParts that write commandsxpulse --helpandxpulse --listare dynamic β show only what is installed
Alternatives Rejected
cli.jsin the root β boilerplate, manual event binding in every xPartcli:doneevent from the command β command must import@xpulse/event; exit code as return value is simpler and more universalstatic name/descriptionβ not extensible for arguments/options/flags/aliascli:keepalivedirectly in the command β command must know the event system;this.keepAlive()via injection is cleaner- Hard timeout without keepalive β would abort long-running tasks
@xpulse/configas dependency β CLI is global, no guaranteed project context