Skip to main content

modular_cli_sdk 0.11.0 copy "modular_cli_sdk: ^0.11.0" to clipboard
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.

pub package license

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 under example/, 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 concerns
  • Command<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 default
  • Approver / PlanSink — how approval is taken and where a plan is filed, left to the host
  • CliContract: a route's declared arguments, CliParam (options), CliPositional (positionals) and CliConstraint (ExactlyOne, MutuallyExclusive); renders help and enforces parsing
  • DeclaredDefault<T>: a default value that carries its own reason, which help renders alongside it
  • Native help: help, no args, --help/-h on stdout with exit 0; help --json for machines, with kind on every route. --help wins over enforcement on an otherwise-invalid invocation, so asking how a command is used never requires already knowing
  • Input / Output — typed DTOs for I/O
  • CommandException: structured errors with a kebab-case id, message, required exit code, and optional details
  • 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", ...}}, with contract and details present 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 / uninstall against tagged GitHub releases)
  • --json global flag — machine-readable JSON output
  • --quiet global 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 #


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

1
likes
160
points
1.13k
downloads

Documentation

API reference

Publisher

verified publisherccisne.dev

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#cli #command-line #macss

License

MIT (license)

Dependencies

cli_router, http, path, preview_executor, pub_semver

More

Packages that depend on modular_cli_sdk