Mamba

Mamba is a declarative Dart framework for building command-line applications. It lets Dart developers define commands and their inputs once, then uses those definitions for parsing, validation, help output, execution, testing, and shell completion. Mamba exists to remove the drift and repetitive glue between a CLI's syntax and the code that implements it.

pub package downloads license release workflow

Mamba logo

Read the full documentation

Features

  • Define commands as Dart classes with typed, validated inputs.
  • Support nested commands through GroupCommand.
  • Handle positionals, variadic arguments, boolean and count flags, typed options, repeatable options, paired options, and dotted accessors.
  • Generate help from the same command definitions used by the parser.
  • Add lifecycle hooks and typed executor-scoped context.
  • Test invocations without writing to process streams through Executor.fake().
  • Generate Bash, Zsh, Fish, PowerShell, and Carapace completions.
  • Scaffold Dart CLI projects, executables, commands, and tests with mamba.

Installation

Mamba requires Dart SDK ^3.13.2.

Mamba uses Dart 3.13 primary constructors where appropriate and concise in-body constructor declarations throughout its source and examples. Constructors declared inside a class use new or factory without repeating the class name.

Add it to an existing Dart package:

dart pub add mamba

To install the optional project and command scaffolding executable globally:

dart pub global activate mamba

Scaffold a project

The global executable can create a Dart console package and add command skeletons:

mamba create my_app "Manage my application."
# Initialize a Git repository? [y/N]
cd my_app
dart run bin/my_app.dart
mamba command greet
mamba command admin --group
mamba binary worker
mamba command status --test

mamba create automatically runs dart pub get and installs Mamba's package-provided skills for generic agents and Claude with dart run skills@ get --all -p mamba --agent generic and dart run skills@ get --all -p mamba --agent claude. It then prompts you whether to initialize the project as a Git repository. The short description is optional: leave it off and the command asks for one. The generated command file still needs to be registered in the application's command list. --group generates an empty GroupCommand for nesting child commands. mamba binary <name> creates another executable in bin/, while mamba test <name> creates a grouped test suite for the matching lib/<name>.dart command. Pass --test to mamba command to create the matching suite at the same time:

mamba command greet --test
mamba command admin --group --test
mamba command user lib/admin.dart --append --test
mamba test role lib/admin.dart --append

Appended commands and suites share mirrored files: lib/admin.dart maps to test/admin_test.dart. Generated tests call Executor.fake() and check the command's help path, so the starter test works for both Command and GroupCommand. Run mamba --help, mamba command --help, or mamba test --help for all scaffolding options.

mamba component <kind> <name> writes lib/components/<name>.dart: a plain class that encapsulates one terminice call behind one async render method, so every component is used the same way and a command awaits it instead of blocking on a synchronous prompt. The kinds are prompt, selector, picker, and indicator:

mamba component prompt ask
mamba component selector choose
mamba component picker target
mamba component indicator report
final class AskComponent {
  const AskComponent({this.label = 'Project name'});

  final String label;

  Future<String?> render() async => terminice.text(label);
}

// await ask();  is the same as  await ask.render();

A selector takes the choices it filters through an options field. The indicator is generic, because it reports on work it cannot know ahead of time: Future<T> render<T>(Future<T> Function() work) awaits the work behind the spinner.

Quick start

Create an executable such as bin/hello.dart:

import 'package:mamba/mamba.dart';

final class HelloCommand extends Command {
  @override
  String get name => 'hello';

  @override
  String get shortDescription => 'Say hello.';

  @override
  String run(ParsedInputs inputs, List<String> args) =>
      'Hello from Mamba!';
}

Future<void> main(List<String> args) => Executor(
  'hello',
  'A small Mamba CLI.',
  '1.0.0',
  [HelloCommand()],
).create().execute(args);

Run it with Dart:

dart run bin/hello.dart hello
dart run bin/hello.dart --help

Usage

Define commands and inputs

Scalar options can supply typed defaults with StringOption.withDefault, IntOption.withDefault, and DoubleOption.withDefault. Repeatable defaults are immutable lists and are replaced, rather than extended, by explicit input. Accessor leaves also offer required and withDefault constructors. Command resolution always skips an option's value, so a value equal to a command name cannot select that command.

A command declares its syntax in its constructor and receives parsed values in run:

final class AddCommand extends Command {
  new()
      : super(
          mandatoryPositionals: [path],
          flags: [all],
          options: [message],
        );

  static final path = NormalPositional('path');
  static final all = BooleanFlag(
    'all',
    short: 'a',
    description: 'Add every path.',
  );
  static final message = StringOption.required(
    'message',
    short: 'm',
    regex: RegExp(r'.+'),
    description: 'Commit message.',
  );

  @override
  String get name => 'add';

  @override
  String get shortDescription => 'Add a path.';

  @override
  String run(ParsedInputs inputs, List<String> args) {
    final pathValue = inputs.valueOf(path);
    final allPaths = inputs.valueOf(all);
    final messageValue = inputs.valueOf(message);
    return 'Adding ${allPaths ? 'all paths' : pathValue} with: $messageValue';
  }
}

Register it with the application executor:

final executor = Executor(
  'git-like',
  'Manage source changes.',
  '1.0.0',
  [AddCommand()],
).create();

run is called only after the invocation has been parsed and validated. It may return a String, return null for no output, or return a Future. Production execution forwards a returned string unchanged to stdout and writes nothing for null. It assigns the process exit code only when execution fails.

Input types

Input Use
NormalPositional / ChoicePositional Required or optional values in command order.
NormalVariadic / ChoiceVariadic Validate values supplied after --.
BooleanFlag / CountFlag Valueless switches, aliases, bundles, and verbosity counts.
StringOption, IntOption, DoubleOption, ChoiceOption Typed named values with optional aliases, defaults, ranges, or validation.
Repeatable*Option Collect multiple values into typed lists.
PairedOptions Require members together and map them by option name.
SelectedOptions<T> Map selected pair options to an immutable Map<String, T>.
AccessorListOption and accessor leaves Parse nested values such as --database.port 5432 into an immutable map.

SelectedOptions<T> accepts zero or more members by default. Use the required constructor to require at least one member, and set single: true on either constructor to limit the result to at most one member:

final json = PairStringOption('json');
final text = PairStringOption('text');

final optionalFormat = SelectedOptions<String>(
  [json, text],
  single: true,
);
final requiredFormat = SelectedOptions<String>.required(
  [json, text],
  single: true,
);

The optional form accepts zero or one selection. The required form accepts exactly one.

Long options accept --name value and --name=value; short options accept -n value and -n=value. A flag-only prefix may precede a final value-taking short with equals supply, for example -vo=file. No-equals attachments and separate-value mixed bundles remain unsupported. Unconstrained strings accept empty and whitespace-containing argv values; explicit validators constrain content, and literal option-looking text requires inline supply.

Repeated positionals have a positive finite times maximum and reserve one token for each following required positional. Validators check the assigned layout rather than discovering boundaries. -- starts a separate immutable raw trailing list, optionally validated by Variadic; it never supplies ordinary positionals. Trailing values are not stored in ParsedInputs.

Choice declarations return typed enum members. Ordinary enums retain member-name syntax; enums implementing MambaEnumValue supply exact, case-sensitive String spellings through value, without implicit name aliases. Offered spellings must be unique. See the options reference for a compiling implements example and migration guidance.

Conflicting inputs

Commands can reject incompatible named inputs with conflicts. Each map key conflicts with every name in its list. Names may refer to flags, ordinary, paired, or selected option members, and accessor leaves use dotted paths. Positional names, variadics, and accessor group names are not conflict inputs.

final class DeployCommand extends Command {
  new()
    : super(
        flags: [replace],
        options: [output],
        conflicts: {
          'replace': ['output'],
        },
      );

  static const replace = BooleanFlag('replace');
  static final output = StringOption('output');

  @override
  String get name => 'deploy';

  @override
  String get shortDescription => 'Deploy the application.';

  @override
  String run(ParsedInputs inputs, List<String> args) => 'Deployed.';
}

Conflicts use explicit occurrences, including false-valued negations, rather than defaults. Applicable global and propagated endpoints may be referenced; edges inherit by declaration identity through compatible overrides, not just matching names. ParsedInputs.contains continues to mean stored-value presence. The map belongs to its declaring command; Executor does not define conflicts. Registry creation rejects a conflict between a required input and another input because the other input could never be supplied. When both inputs are required, the error names both inputs and explains that they cannot be used together.

Group commands

Use GroupCommand for nested command paths such as remote add. Selecting a group without a child prints that group's help, which lists its children: GroupCommand.run formats the registry the executor handed it. Groups can propagate flags and options to themselves and all descendants, and select a child by setting defaultSubCommandPath. Defaults are resolved before parsing, so the selected child receives its own typed inputs and hooks. A nested default can select another group with a default; invalid paths fail when building the executor. Calling GroupCommand.run directly is a low-level operation and does not parse inputs or run child hooks. --help describes the explicitly named group, not its default child.

final class RemoteCommand extends GroupCommand {
  new()
      : super(
          [RemoteAddCommand()],
          propagatedFlags: [
            BooleanFlag('verbose', short: 'v', description: 'Show details.'),
          ],
        );

  @override
  String get name => 'remote';

  @override
  String get shortDescription => 'Manage remotes.';
}

Hooks and context

Mix HookRunner into a command for pre- and post-execution work. Mix PersistentHookRunner into a group to run hooks around descendant commands. Every group on the resolved command path participates, nested child groups included, with pre-hooks running outermost-first and post-hooks unwinding in reverse. MambaContext is a typed, executor-scoped scalar state bag that persistent hooks can share and mutate. Keys use String, bool, int, or double; write with the matching sealed wrapper (such as MambaContextString) and read the primitive directly. Context is hook state, not a dependency container, so collections and domain objects are unsupported. MambaReadContext is the read-only view a command receives: it exposes get and nothing else, so a subclass of MambaContext adds members no command can reach. Environment variables and configuration files remain application responsibilities.

Construction validates the whole tree before atomically assigning each command instance one configured owner and canonical path. Use fresh command objects for different configurations or paths; aliases and immutable declaration sharing remain valid. Multiple adapters from one owner share the retained scalar context. Await calls sequentially: overlapping or reentrant executions raise StateError before entering the second invocation, and the guard releases even after Errors. Recoverable cleanup remains Exception-only and nonzero statuses are exception-driven.

Shell completions

Register the preset completion command to generate Bash, Zsh, Fish, PowerShell, or Carapace artifacts from the application's registry:

final completion = CompletionCommand.preset(createFile: null);

The Bash artifact requires bash 4 or newer; it uses associative arrays to keep command names scoped to their parent. macOS still ships bash 3.2 as /bin/bash, and sourcing the artifact with it prints a one-line explanation instead of loading. The full support table is in the completions guide.

Mamba supports these shells:

Shell Requires Verified against
Bash 4 or newer Linux Bash and Homebrew Bash on macOS
Zsh 5.9 or newer 5.9 on Linux and macOS
Fish 3.7 or newer 3.7 on Linux, 4.9 on macOS
PowerShell 5.1 or newer Windows PowerShell 5.1 and pwsh on Windows CI
Carapace any Carapace that reads YAML specs generated as a spec file, not run by a shell

CI uses .github/actions/setup-bash to select Bash 4+ before shell checks. On macOS it installs Homebrew Bash and puts it first on PATH. Missing, unreadable, or older Bash fails CI rather than skipping completion tests. The verified column describes the CI gates, not a claim that every local shell was executed. Bash and PowerShell also have real runtime regressions. Static shell relationship/cardinality enforcement remains best-effort; the parser is authoritative. Distinct PowerShell application/path namespaces are isolated, but executable registration itself remains case-insensitive. Regenerate and reinstall artifacts after upgrading. Large stepped-double materialization remains a deferred resource risk; this milestone adds no enumeration cap.

After adding completion to the executor's command list, select the shell and a shell-appropriate output path:

dart run bin/hello.dart completion bash ./hello.bash
dart run bin/hello.dart completion carapace ./hello.yaml

Pass a callback through the required createFile parameter instead of null to route the generated script somewhere else. The callback receives the validated destination and the script itself. See the completions guide for direct converter and Carapace platform-writer usage.

Configuration

Executor is the composition root for an application. In addition to its name, description, version, and commands, it can receive:

  • longDescription for detailed help;
  • root flags, options, and accessors available to every command;
  • defaultCommandPath for a command to run when no command is selected, even when root flags or default-command options are supplied;
  • a custom MambaContext; and
  • a custom HelpFormatter.

Commands read an executor accessor through the same retained AccessorListOption instance passed in accessors.

Every executor includes --help/-h, --verbose/-v, and --version/-V. Register MambaBuiltInFlags.dryRun through the executor's flags parameter when the application supports --dry-run. Application code decides what dry-run and verbosity mean; --version prints the semantic version supplied to Executor.

Examples

The repository includes a persisted task-list CLI in example/example.dart:

dart run example/example.dart create \
  --title "Review pull request" \
  --description "Check the parser changes"
dart run example/example.dart list --pending
dart run example/example.dart complete 1

The task data is stored in the system temporary directory. Use it as a compact example of typed options, validated positionals, command errors, and a completion command.

Testing

Use fake() in tests instead of the production executor. It returns a MambaSuccessResult or MambaFailureResult rather than writing to stdout or stderr. Pass standardInput when testing a command that reads piped input in HookRunner.preRun:

final result = await Executor(
  'hello',
  'A test CLI.',
  '1.0.0',
  [HelloCommand()],
).fake().execute(['hello']);

expect(result, isA<MambaSuccessResult>());

final input = ProcessedStandardInput(utf8.encode('{"enabled":true}'));
final importResult = await Executor(
  'hello',
  'A test CLI.',
  '1.0.0',
  [ImportCommand()],
).fake(standardInput: input).execute(['import']);

Import dart:convert when using utf8.encode. Call create() only in the application entry point; it always connects execution to the current process.

Run the package tests with:

dart test

Project documentation

The complete guides and API reference are available at https://mamba-docs.onrender.com/. The repository source for the documentation site is in docs/.

The main library entry point is lib/mamba.dart. The public API is organized around these components:

  • Command and GroupCommand define the CLI surface and behavior.
  • CommandRegistry validates and indexes declarations.
  • Parser turns tokens into typed values without executing commands.
  • Executor handles dispatch, help, output, hooks, and the process boundary.
  • HelpFormatter renders the selected command registry.
  • Integration converters translate registry records into completion artifacts.

Development

For the Dart package:

dart pub get
dart format .
dart analyze --fatal-infos
dart test
dart run tool/check_examples.dart

The example checker compiles every Dart code block in this README, docs/, and skills/, then executes the example assertions. Context templates under tool/doc_examples/ provide imports and application-owned declarations; a new block without a template fails the check. See tool/doc_examples/README.md for the format.

The documentation site is an Astro Starlight project. To work on it locally:

cd docs
pnpm install
pnpm dev

Other documentation commands are pnpm build, pnpm preview, and pnpm test. See docs/README.md for the page generator workflow.

Contributing

Issues and pull requests are welcome. For code changes, include focused tests and run formatting, analysis, and the test suite before submitting a pull request. Documentation changes should update the relevant page under docs/ and be checked with the documentation build.

License

Mamba is released under the MIT License.

Libraries

built_in_flags
command
completion_command
context
errors
executor
help_formatter
integrations
mamba
The public API for defining and executing Mamba command-line applications.
mamba_cli
parser
processed_standard_input
registry