modular_cli_sdk 0.11.0
modular_cli_sdk: ^0.11.0 copied to clipboard
Command-centric SDK for building modular CLIs with Dart — Command/Input/Output contract, structured errors, output formatting, and automatic TTY detection. Built on cli_router.
modular_cli_sdk #
SDK for building modular CLIs with Dart, around two kinds of route:
a Query reads and answers; a Command changes something, and says
what it would change before anything runs.
Also see: modular_api — the HTTP counterpart with the same architecture, and preview_executor — the engine behind
--plan/--apply.
Quick start #
import 'package:cli_router/cli_router.dart';
import 'package:modular_cli_sdk/modular_cli_sdk.dart';
void main(List<String> args) async {
final cli = ModularCli(suggestionDistance: 2);
// Reads. No --plan, no --apply — it has nothing to plan.
cli.query<VersionInput, VersionOutput>(
'version',
(req) => VersionQuery(VersionInput.fromCliRequest(req)),
contract: CliContract.none,
description: 'Print version info',
);
cli.module('notes', (m) {
m.query<ListInput, ListOutput>(
'list',
(req) => ListNotes(ListInput.fromCliRequest(req)),
description: 'List your notes',
contract: ListInput.contract,
);
// Changes something. --plan / --apply / --autoapprove are declared,
// enforced and acted on by the SDK; none of it is written here.
m.command<WriteInput, WriteOutput>(
'write <name>',
(req) => WriteNote(WriteInput.fromCliRequest(req)),
description: 'Write a note',
contract: WriteInput.contract,
);
});
final code = await cli.run(args);
exit(code);
}
dart run bin/main.dart version
# version: 0.2.0
dart run bin/main.dart notes list
# notes: today, ideas
dart run bin/main.dart notes write today
# Error: Choose --plan or --apply.
# --plan show what would change; nothing is touched
# --apply show it, ask for approval, then do it
# exit code 7
# Options must come before the positional: cli_router's grammar requires
# it (an option can never follow an operand), so --plan precedes today.
dart run bin/main.dart notes write --plan today
# Plan — notes write
#
# create notes
# create notes/today.md (10 characters)
#
# Nothing was changed. Re-run with --apply to carry this out.
See example/ for a full working example: three read-only modules and one that writes.
Queries and commands #
A CLI has one transport, so nothing about argv tells a reader whether a route
is safe to try. modular_api gets that distinction from where a use case is
published — GraphQL for reads, REST for the rest. Here it is declared, and the
SDK makes the declaration mean something.
Query |
Command |
|
|---|---|---|
| Contract | validate(), execute() |
validate(), steps(), describe() |
--plan / --apply |
rejected | declared and enforced |
| Listed under | Queries: | Commands: |
help --json |
"kind": "query" |
"kind": "command" |
They share no method, so neither can be mistaken for the other by accident.
Commands that say what they would do #
A command is an ordered list of steps. Each states its intention through
preview() and does its work through perform() — two methods, not one method
with a dry-run flag, because a flag threaded through the work leaves nothing
holding the switched-off pass and the real one to the same behaviour.
class WriteNote implements Step {
WriteNote(this.path, this.contents);
final String path;
final String contents;
@override
Preview preview() => File(path).existsSync()
? Preview(verb: 'keep', target: path, detail: 'already exists')
: Preview(verb: 'create', target: path);
@override
Future<Outcome> perform(StepContext context) async {
if (File(path).existsSync()) return Outcome(verb: 'keep', target: path);
File(path).writeAsStringSync(contents);
return Outcome(verb: 'create', target: path);
}
}
class WriteNoteCommand implements Command<WriteInput, WriteOutput> {
@override
final WriteInput input;
WriteNoteCommand(this.input);
@override
String? validate() => input.name.isEmpty ? 'a <name> is required' : null;
@override
Future<List<Step>> steps() async => [
EnsureDirectory(input.directory),
WriteNote(input.path, await render(input)),
];
@override
WriteOutput describe(Execution execution) =>
WriteOutput(written: execution.outcomes.map((o) => o.target).toList());
}
Under --apply the SDK previews the steps, shows the plan, takes the approval,
performs them in order, and compares what each step did against what it had
said. A step that acted differently is reported on stderr whatever the command
chose to say; a step that threw stops the run and fails the invocation.
A command that builds no steps has nothing to approve, so nobody is asked:
--apply reports that nothing would change and exits 0. Asking y/N about no
change at all is noise on a terminal, and on a run without one it failed an
invocation that had nothing to fail at.
A refused approval and a step failure are both reported the same way every
other error is (see Error handling): a refusal is written
with id: 'approval-refused' and the refusal reason as details.reason; a
step failure reuses the thrown CommandException's own id and exitCode
when the step threw one directly, or is wrapped under id: 'step-failed'
otherwise. Neither bypasses the structured envelope, under --json or not.
Note what describe does not carry: no planPath, no blocked, no
message. Those describe the gate, and the gate is the SDK's.
Two decisions stay with the host:
ModularCli(
approver: (plan) => askOnTheTerminal(plan), // defaults to stdin
planSink: (plan) => filePlanUnder('.myapp/plans', plan), // defaults to nowhere
);
--plan produces a report, not an executable plan. Nothing reads it back:
--apply re-previews immediately before it acts, so there is no saved plan that
can go stale. See ADR 0002.
Testing a command #
A command has no execute() to call, so package:modular_cli_sdk/testing.dart
drives one the way the framework does:
import 'package:modular_cli_sdk/testing.dart';
final previews = await previewCommand(cmd); // --plan: nothing is performed
final execution = await runCommand(cmd); // --apply --autoapprove
final output = await applyCommand(cmd); // …and the Output it describes
applyCommand returns the command's own O, so a test asserts on its fields
without casting.
Use these rather than writing your own. Your own would be a second description of the same lifecycle with nothing keeping the two in agreement — so the day this framework changes, your suite stays green while testing a flow your CLI no longer takes.
PreviewExecutor is exported from here and deliberately not from
modular_cli_sdk.dart: production code that could reach it could run steps with
no plan shown, no approval taken, and no check that what happened is what was
announced.
The commands above are illustrative — they show how you would run your CLI, whose entry point is
bin/main.dart. The runnable equivalents in this repository live underexample/, e.g.dart run example/example.dart version.
Adopting this in a CLI that already exists #
Rewriting the units is the mechanical part. The part that surprises is that
--plan/--apply changes your CLI's contract with everything that ever
typed one of its commands — installers, CI, agent instructions, a
post-install child process, and every message that tells a user to run
something. None of those breaks the build.
Two hosts have made this migration. ADR 0003 records what it cost them and the three obligations that follow, including a test that sweeps your own sources for messages naming a command without a mode — derived from the catalog, so it cannot drift from your registrations:
final commands = cli.catalog
.ofKind(CommandKind.command)
.map((c) => c.name); // 'host get', 'upgrade', …
Running it during development #
dart run is equivalent to the built binary for a CLI that locates nothing
beside its own executable — the one in the Quick start is such a CLI, and you
can develop it that way all day.
It stops being equivalent the moment your CLI needs assets. A modern CLI usually does: templates, schemas, locale files. During development those assets have to resolve to your development folder, not to the real installation — and the way to get that is a dev build: the compiled binary placed where the CLI will actually live, with its assets beside it.
example/beside_executable.dart exists to show
the difference. Built into the layout an installed CLI has — the binary in
bin/, assets/ beside it — it works:
dart compile exe example/beside_executable.dart -o <root>/bin/beside_executable
mkdir <root>/assets && echo "Hello from the assets folder" > <root>/assets/greeting.txt
<root>/bin/beside_executable greet
# greeting: Hello from the assets folder
Run the same code from source and it does not:
dart run example/beside_executable.dart greet
# Error: no assets/ folder beside this executable: the CLI is running from source [assets-not-found]
# lookedFor: <your-dart-sdk>/assets/greeting.txt
# resolvedExecutable: <your-dart-sdk>/bin/dart.exe
# exit code 4
Read the lookedFor line. Under dart run, Platform.resolvedExecutable is
the Dart binary, so a CLI resolving paths relative to its own executable
resolves them inside the Dart SDK. Yours will not be there. The two paths are
the only part of that output specific to your machine.
A CLI that does not name this cause reports a broken installation instead, and sends you to diagnose one that is not broken. That failure has cost real time on projects built with this SDK, which is why the example is in the repository rather than in a paragraph.
See Compile to executable for the layout.
Help and the command contract #
Each command declares its arguments once, on its Input, as a CliContract:
CliParam for its options, CliPositional for words read by their place in
the route rather than by a --name, and CliConstraint (ExactlyOne,
MutuallyExclusive) for rules spanning more than one option. The SDK
introspects its own command registry to render help: the CLI counterpart of
the OpenAPI document modular_api generates from its registered use cases.
class HelloInput extends Input {
final String name;
HelloInput({required this.name});
static final contract = CliContract(
options: [
CliParam.string(
'name',
abbr: 'n',
required: false,
repeatable: false,
defaultValue: const DeclaredDefault('World', reason: 'the name used when nobody gave one'),
description: 'Who to greet',
),
],
);
factory HelloInput.fromCliRequest(CliRequest req) =>
HelloInput(name: req.flagString('name')!); // already resolved and defaulted
@override
Map<String, dynamic> toJson() => {'name': name};
}
Declaring is parsing. The same declaration that help renders is the one the
framework enforces before your Input reads a flag: it resolves -n to
--name, applies the declared default (with its reason, which help renders
alongside it: default: World), coerces --a abc to a validation error
instead of a silent 0, rejects an option nobody declared, and checks
allowed values. A positional works the same way through CliPositional, and
a route pattern's <placeholder> is where it binds. Help therefore cannot
describe a contract the CLI does not actually apply. A query that declares
CliContract.none (the default) is neither described as taking arguments nor
enforced beyond that; one with a non-empty contract is both.
A command is always enforced: its declared contract always gains
--plan, --apply and --autoapprove from the SDK, even when the command's
own contract is CliContract.none. A route that changes something cannot be
the one whose arguments nobody checks, and the three flags have to be
declared to be typed at all.
cli_router requires every option to precede the first positional on the
actual command line: an option can never follow an operand. notes write --plan today parses; notes write today --plan is rejected. The Usage:
line the SDK renders is written in that same order.
A CliPositional's declaration must match its route pattern. show <id>
requires a CliPositional named id; [<name>] (a trailing optional
segment) requires one declared required: false. A missing, extra,
misnamed or wrongly-required/optional positional is an ArgumentError at
registration, before the route can ever be dispatched to.
shortcut() declares a route that runs another route's handler under a
narrower contract:
cli.shortcut(
'<program>',
target: 'eval rpn',
globals: false,
contract: CliContract.none,
);
mycli '1 2 +' then runs the same handler as mycli eval rpn '1 2 +', but
through its own, narrower contract: here, with globals: false, none of the
SDK's global options (--json, --quiet, --help) are accepted on the
shortcut itself. A shortcut never declares its own positionals: program is
taken from the target's own declaration, by name, and rebound to whichever
cardinality <program> itself gives it (required here, even though the
target's own [<program>] makes it optional). contract is required, like
every other registration call (CliContract.none for a shortcut that
declares nothing of its own); passing one lets a shortcut add its own options
or constraints on top. When target is a Command, contract gains
--plan/--apply/--autoapprove automatically, exactly as command()
itself always gains them: a shortcut to a route that changes something is
still a route that changes something. target must already be registered
and name exactly one route; registering a shortcut to a route that does not
exist, or that matches more than one registered route, is an ArgumentError,
and so is declaring a positional directly in a shortcut's own contract.
Like every other registration call, globals is required: there is no
default that would silently decide it for you.
suggest() answers "did you mean?" over the command catalog:
final closest = cli.suggest('shwo'); // → 'show'
It compares word against the catalog's route vocabulary by restricted edit
distance (Levenshtein plus one adjacent-transposition operation), returns the
closest match within maxDistance (ties broken by catalog registration
order), or null if nothing is close enough. cli.suggest() uses the CLI's
own suggestionDistance (required on ModularCli(...), no default) unless a
call passes maxDistance to override it for that one call.
CommandCatalog.suggest(word, {required maxDistance}), the lower-level call
a CommandCatalog is read through directly, requires maxDistance
explicitly every time: the catalog has no distance of its own to fall back
to. The SDK calls cli.suggest() itself on an unknownCommand/incomplete
rejection, so mycli commands shwo power suggests show on stderr.
Help is a success, not an error:
mycli # no args → command list on stdout, exit 0
mycli help # → same
mycli --help # or -h → same
mycli greetings hello -h # → only that command's contract, exit 0
mycli greetings --help # → every command in the module, exit 0
mycli help --json # → the full contract catalog (help.json), exit 0
mycli bogus # unknown → error + a hint line on stderr, exit 64
mycli math add --b 7 # rejected → error + that command's usage on stderr, exit 7
help --json is the machine-readable twin of the text help: every route with
its kind (query or command), description and parameters (name, aliases,
type, required, default, allowed), plus the global options. The kind is how an
agent tells a reader from a writer without running either.
A help command you register yourself always wins over the built-in one.
Help epilog #
A route description is one cell of the catalog table, so it is the wrong place
for examples or usage hints. Give them once as helpEpilog:
final cli = ModularCli(
suggestionDistance: 2,
name: 'cx',
version: '1.0.0',
helpEpilog: 'Examples:\n'
' cx eval rpn "1 2 +"',
);
...
Global options:
--json Emit machine-readable JSON
-q, --quiet Suppress non-essential output
-h, --help Show this contract
Examples:
cx eval rpn "1 2 +"
The text is printed verbatim after the full catalog (help, root --help /
-h, a bare invocation with no root route, printHelp) and nowhere else:
not in module or focused help, a narrowed catalog or an error. help --json
gains an epilog key with the text. Trailing whitespace is dropped; a value
that is empty after trimming throws ArgumentError. Left null, nothing
changes.
Plugins #
A CliPlugin registers routes the way main() does, but is written by
someone who is not your CLI: a shared package that wants to add version,
doctor, or an installer to whatever CLI depends on it, without that CLI
hand-wiring the routes itself.
final cli = ModularCli(name: 'mycli', version: '1.4.0')
..plugin(const VersionPlugin(version: '1.4.0'))
..plugin(const DoctorPlugin())
..plugin(InstallationPlugin(
config: CliInstallationConfig(
repository: 'you/mycli',
tagPrefix: 'cli-v', // your app's own `v*` tags are left alone
executable: 'mycli',
alias: 'mc',
assets: {'linux': 'mycli-linux', 'windows': 'mycli-windows.exe'},
),
));
final code = await cli.run(args); // buildPlugins() runs once, before routing
A plugin declares what it needs, not where it must sit. Its
CliPluginManifest names an id, a version, the hostApiVersion it was
built against, and which other plugin ids it requires. Plugins are ordered
by that dependency graph before any setup() runs: InstallationPlugin
contributes checks to DoctorPlugin's extension point regardless of which
one you called .plugin() on first, as long as it declares
requires: ['modular_cli.doctor'].
The whole set builds, or none of it does. A missing dependency, a cycle,
two plugins sharing an id, or a hostApiVersion this host's plugin API does
not satisfy is a CliPluginError thrown before any plugin's setup() runs,
there is no state where half the plugins registered their routes and the rest
did not.
Extension points are typed. A plugin that wants other plugins to extend it
calls host.declareExtensionPoint<CliDoctorCheck>('doctor.checks') once; a
contributor calls host.contribute<CliDoctorCheck>('doctor.checks', check).
Contributing to an id nobody declared, or contributing the wrong type, is a
build-time CliPluginError, not a silently-dropped value.
The three standard plugins #
| Plugin | Registers | Needs |
|---|---|---|
VersionPlugin(version:) |
version |
ModularCli(name:, version:); must match |
DoctorPlugin |
doctor, and the doctor.checks extension point |
(none) |
InstallationPlugin |
upgrade, uninstall; contributes 1 check to doctor.checks |
DoctorPlugin, a CliInstallationConfig |
doctor runs every contributed CliDoctorCheck and reports them together. A
run where nothing errored (warnings are fine) reports {"checks": [...]} on
stdout and exits ExitCode.ok. A run where at least one check errored
reports the single error shape instead, on stderr: {"error": {"id": "doctor-check-failed", "message": "<n> check(s) failed: <names>", "exitCode": 78, "checks": [...]}}, the checks array being every check's
result, in run order, exactly as the success shape would have shown it, not
only the failed ones. Text mode writes the same check lines, then the error
line, both on stderr, nothing on stdout. InstallationPlugin contributes one
check, release (is a newer tagged release available), which only ever
warns, including when the lookup itself fails, and never errors.
upgrade and uninstall are ordinary Commands: --plan shows what would
happen, --apply (with approval, or --autoapprove) does it, and a step that
fails stops the run at that step with nothing rolled back, the same contract
every command in this SDK already has. Looking up the release happens before
either flag is branched on, so a failed lookup reports
release-lookup-failed and exits 1 under --plan too, not only --apply.
Every network, download and platform access InstallationPlugin makes goes
through an injectable interface (CliReleaseSource, Downloader,
PlatformOps), each with a real default (HttpCliReleaseSource,
downloadOverHttp, PlatformOps.current(), which resolves to
WindowsPlatformOps/LinuxPlatformOps; macOS is not supported, and
PlatformOps.current() throws UnsupportedError there, the same as macss's
and inquiry's own factories): pass your own in tests, and nothing downloads,
extracts or touches a real PATH.
Adopting InstallationPlugin #
InstallationPlugin is extracted from macss's and inquiry's own
upgrade/uninstall commands (see
docs/installation-parity.md for what each
CLI did before, and what it does through this plugin now). Only Windows and
Linux are supported; macOS is not, and PlatformOps.current() throws
UnsupportedError there, the same as both source CLIs. Adopting it means
configuring it, not writing an upgrade or uninstall command of your own:
InstallationPlugin(
config: CliInstallationConfig(
repository: 'you/mycli', // owner/repo on GitHub
executable: 'mycli', // the binary's name on PATH
alias: 'mc', // optional: a second name expected to resolve the same way
assets: {
'linux': 'mycli-linux',
'windows': 'mycli-windows.exe',
},
// Optional: only when this repository's releases share tags with a
// different series, e.g. 'cli-v' when the CLI is tagged 'cli-v1.2.3'
// inside a repo whose other tags are plain 'v1.2.3'.
tagPrefix: 'cli-v',
// Optional: whether the freshly extracted binary is run with
// postInstallArguments (default ['version']) right after extraction,
// failing the upgrade if that fails. Defaults to true, matching macss.
// A CLI that verifies leniently instead, through its own
// postUpgradeSteps (inquiry's host redeploy), sets this to false so the
// two checks do not both run.
verifyAfterInstall: true,
// Optional: run after upgrade / before uninstall, built from the install
// directory and the same PlatformOps the command itself is using. This
// is where a CLI expresses whatever it did here before this plugin
// existed, such as inquiry's own host redeploy/cleanup: neither of
// those is, or will be, built into the plugin itself.
postUpgradeSteps: (installDir, platformOps) => [MyRedeployStep(...)],
preUninstallSteps: (installDir, platformOps) => [MyCleanupStep(...)],
),
)
alias is optional; a CLI with no second name to resolve can leave it out,
and only the doctor release check's upgrade hint changes (it names
executable instead). PlatformOps is resolved lazily: upgrade and
uninstall on a platform with no configured asset fail with a
platform-not-supported CommandException when the command actually runs,
not at plugin construction, and any other command keeps working normally on
such a platform.
What carried over unchanged. The install directory is still derived from
Platform.resolvedExecutable, never looked up on PATH: an upgrade always
replaces the binary that is actually running, the same as before. The
release lookup still happens once, while the plan is built, so the
version/asset/URL a person approves under --plan are exactly the ones
downloaded under --apply. The alias is still never created or rewritten by
upgrade/uninstall themselves, only by each CLI's own install script
(install.ps1/install.sh). The freshly extracted binary is still run with
postInstallArguments right after extraction by default, failing the
upgrade if that fails, the same hard-fail check macss's own upgrade
command always ran (verifyAfterInstall, default true); a CLI that
verifies leniently instead, only through its own postUpgradeSteps, sets it
to false.
What is new. release as a doctor check is new: neither macss's nor
inquiry's own doctor.dart runs it today. postUpgradeSteps/
preUninstallSteps are new as a named extension mechanism, though what they
let a CLI express (a redeploy, a cleanup, or nothing at all) is exactly what
each CLI's own upgrade/uninstall code already did inline before this plugin
existed.
What was tried and removed. binary and alias doctor checks (is the
executable/alias reachable on PATH) existed in an earlier draft and were
removed: neither macss nor inquiry checks its own binary or alias this way,
so there was no precedent to extract. See
issue #34 if you
need them back. MacosPlatformOps was removed the same way, for the same
reason: see the macOS row in
docs/installation-parity.md.
Breaking, from 0.7.0. Every 0.7.0 installation type (CliFileSystem,
CliPlatform, CliProcessLauncher, the cleanup-worker machinery behind
uninstall, and the bare-executable, no archive, download path) is gone,
replaced by the config shape above and a plain platformOps.scheduleDeletion
call. There is no compatibility shim; a CLI on 0.7.0 upgrades by switching to
this config.
Also breaking, from 0.7.0: approval is no longer skippable. 0.7.0's
upgrade/uninstall implemented SkipsInteractiveApproval, so --apply
bypassed the approval gate even without --autoapprove. Neither macss's nor
inquiry's own upgrade/uninstall ever worked that way, and 0.8.0 now
matches them: --apply asks for approval unless --autoapprove is given,
the same as every other command. SkipsInteractiveApproval itself is
removed, since nothing in the SDK implements it any more.
Features #
Query<I, O>— reads and answers; pure business logic, no I/O concernsCommand<I, O>— changes something, as steps that are held to what they said--plan/--apply/--autoapprove— declared, enforced and acted on for every command; neither of the first two is a defaultApprover/PlanSink— how approval is taken and where a plan is filed, left to the hostCliContract: a route's declared arguments,CliParam(options),CliPositional(positionals) andCliConstraint(ExactlyOne,MutuallyExclusive); renders help and enforces parsingDeclaredDefault<T>: a default value that carries its ownreason, which help renders alongside it- Native help:
help, no args,--help/-hon stdout with exit 0;help --jsonfor machines, withkindon every route.--helpwins over enforcement on an otherwise-invalid invocation, so asking how a command is used never requires already knowing Input/Output— typed DTOs for I/OCommandException: structured errors with a kebab-caseid, message, required exit code, and optionaldetails- A router-level rejection (unknown command, missing required option, and so on) is reported through the same JSON error envelope as a
CommandException:{"error": {"id", "message", "exitCode", ...}}, withcontractanddetailspresent only when they apply (see Error handling) ModularCli+ModuleBuilder— module registration and routing- Root routes — register without a module prefix via
cli.query()/cli.command() CliPlugin/ModularCli.plugin(): a package registers routes and extension-point contributions into a host CLI, ordered by declared dependencies and validated as a whole before any of it runs- Three standard plugins:
VersionPlugin,DoctorPlugin,InstallationPlugin(upgrade/uninstallagainst tagged GitHub releases) --jsonglobal flag — machine-readable JSON output--quietglobal flag — suppress informational messages- TTY detection — automatic format selection
- Semantic exit codes: 0 (OK), 1 (error), 2 (API error), 4 (not found), 5 (unauthorized), 6 (conflict), 7 (validation), 64 (usage), 65 (data error), 78 (config error)
- Built on
cli_router: GNU flags, middleware, modular mounting, and a grammar where every option precedes the first positional on the command line
Installation #
dart pub add modular_cli_sdk
That resolves the most recent release and writes the constraint for you. This
README deliberately does not print a version to copy into pubspec.yaml: a
number written here is one nobody updates, and it was already two releases stale
before anybody noticed.
Error handling #
@override
Future<MyOutput> execute() async {
final ticket = await repository.findById(input.ticketId);
if (ticket == null) {
throw CommandException(
id: 'ticket-not-found',
message: 'Ticket #${input.ticketId} not found',
exitCode: ExitCode.notFound,
);
}
return ShowTicketOutput(ticket: ticket);
}
Error: Ticket #42 not found [ticket-not-found]
With --json:
{"error": {"id": "ticket-not-found", "message": "Ticket #42 not found", "exitCode": 4}}
Every error written in JSON mode, whether a CommandException thrown from a
command, a router-level rejection (unknown command, missing required option,
and so on), or a plugin error, uses this one shape, nested under "error":
{"error": {"id": "<kebab-case id>", "message": "...", "exitCode": <int>, "contract": {...}, "details": {...}}}
id is always kebab-case. contract (the failing route's contract, when one
is known) and details (a typed map of extra fields: a validation
failure's parameter, or a domain error's own fields, such as a calculatrix
parse error's token and position) are both optional: present only when
they apply. There is no kind field and no isRetryable field.
A CommandException's own id is chosen by the code that throws it (and
must be kebab-case, or the constructor throws ArgumentError). A
router-level rejection's id comes from a fixed table, one entry per
CliRejectionKind:
| Rejection | id |
|---|---|
| Unknown command | unknown-command |
| Incomplete command | incomplete-command |
| Missing required argument | missing-argument |
| Extra argument | extra-argument |
| Unknown option | unknown-option |
| Misplaced option | misplaced-option |
| Missing required option | missing-required-option |
| Repeated option | repeated-option |
| Invalid short option | invalid-short-option |
| Missing option value | missing-value |
| Unexpected option value | unexpected-value |
A contract violation the SDK itself enforces (a required option missing, a
value of the wrong type, an allow-list mismatch, a failed CliConstraint)
raises a CommandException with id: 'validation-failed' (the one id
this table does not list, because it is not a router rejection). A refused
--apply approval and a step failure with nothing more specific of its own
to report are two more such SDK-raised ids not in the table, above:
approval-refused and step-failed (see Commands that say what they would
do).
A middleware registered through ModularCli.use() runs inside its own error
boundary: a CommandException it throws is caught there and turned into this
same envelope, honoring the resolved request's --json mode, instead of
escaping run() as an uncaught exception.
InstallationPlugin error ids #
upgrade and uninstall are ordinary Commands: a failing step raises an
ordinary CommandException, reported through the same envelope as any other
command's (see Error handling above), not through a
field of its own on UpgradeOutput/UninstallOutput (neither has one).
installation_plugin.dart throws exactly two ids itself:
| id | reported when |
|---|---|
release-lookup-failed |
the release source could not be queried, a release's tag does not parse as semver once tagPrefix is stripped, or (with tagPrefix set) no release with that prefix exists |
asset-not-found |
the release found has no asset named for the current platform in config.assets |
uninstall never fails this way over PATH or the install directory: removing
an entry PATH does not contain, or an install directory that no longer
exists, is a no-op, not an error, matching the installations both source CLIs
actually run against.
Architecture #
dart:io / Process — I/O primitive
↓
cli_router — routing engine (routes, GNU flags, middleware)
↓
modular_cli_sdk — SDK/framework
↓
ModularCli → Module → Query → Business Logic → Output → formatted terminal output
→ Command → Steps → preview → plan → approval → perform
- Query / Command layer — pure logic, independent of output format
- Step layer — everything a command changes, each piece announced first
- Output adapter — turns Output into JSON or plain text based on flags/TTY
- Middleware — cross-cutting concerns (logging, auth, metrics)
Documentation #
- API reference — generated dartdoc on pub.dev
- docs/architecture.md — the stack, the command lifecycle, the conventions, and the symmetry with modular_api
- docs/roadmap.md — what has shipped, and what is being considered
- docs/adr/ — architecture decision records
- CHANGELOG.md — the record of every release
Compile to executable #
Compile the binary into the place the CLI will actually live, with whatever it needs beside it:
<root>/
bin/
my-cli ← the compiled binary
assets/ ← whatever the CLI reads at runtime
dart compile exe bin/main.dart -o <root>/bin/my-cli
The compiled binary includes the Dart runtime and runs without the SDK installed.
The -o path is not a detail. A binary compiled into a build/ directory
with nothing next to it will not find what it expects beside itself — see
Running it during development. Give it the
same layout it will have once installed, and the dev build answers exactly as
the installed CLI does.
License #
MIT © ccisne.dev