Building Dart CLI Applications
Contents
- Project Setup & Architecture
- Argument Parsing & Command Routing
- Execution & Error Handling
- Testing CLI Applications
- Compilation & Distribution
- Workflows
- Examples
Project Setup & Architecture
Initialize new CLI projects using the official Dart template to ensure standard directory structures.
- Run
dart create -t cli <project_name>to scaffold a console application with basic argument parsing. - Place executable entry points (files containing
main()) exclusively in thebin/directory. - Place internal implementation logic in
lib/src/and expose public APIs vialib/<project_name>.dart. - Enforce formatting in CI environments by running
dart format . --set-exit-if-changed. This returns exit code 1 if formatting violations exist.
Argument Parsing & Command Routing
Implement the args package to manage command-line arguments, flags, and subcommands.
- If building a simple script: Use
ArgParserdirectly to define flags (addFlag) and options (addOption). - If building a complex, multi-command CLI (like
git): ImplementCommandRunnerand extendCommandfor each subcommand. - Define global arguments on the
CommandRunner.argParserand command-specific arguments on the individualCommand.argParser. - Catch
UsageExceptionto gracefully handle invalid arguments and display the automatically generated help text.
Execution & Error Handling
Use the io and stack_trace packages to build reliable, production-ready CLI tools.
- Use the
iopackage’sExitCodeenum to return standard POSIX exit codes (e.g.,ExitCode.success.code,ExitCode.usage.code). - Use
sharedStdInfrom theiopackage if multiple asynchronous listeners need sequential access to standard input. - Wrap the application execution in
Chain.capture()from thestack_tracepackage to track asynchronous stack chains. - Format output stack traces using
Trace.terseorChain.terseto strip noisy core library frames and present readable errors to the user.
Testing CLI Applications
Use test_process and test_descriptor to write high-fidelity integration tests for your CLI.
- Define expected filesystem states using
test_descriptor(d.dir,d.file). - Create the mock filesystem before execution using
await d.Descriptor.create(). - Spawn the CLI process using
TestProcess.start('dart', ['run', 'bin/cli.dart', ...args]). - Validate standard output and error streams using
StreamQueuematchers (e.g.,emitsThrough,emits). - Assert the final exit code using
await process.shouldExit(0). - Validate resulting filesystem mutations using
await d.Descriptor.validate().
Compilation & Distribution
Select the appropriate compilation target based on your distribution requirements.
- If testing locally during development: Use
dart run bin/cli.dart. This uses the JIT compiler for rapid iteration. - If bundling code assets and dynamic libraries: Use
dart build cli. This runs build hooks and outputs tobuild/cli/_/bundle/. - If distributing a standalone native executable: Use
dart compile exe bin/cli.dart -o <output_path>. This bundles the Dart runtime and machine code into a single file. - If distributing multiple apps with strict disk space limits: Use
dart compile aot-snapshot bin/cli.dart. Run the resulting.aotfile usingdartaotruntime.
Cross-Compilation Targets (Linux Only)
Dart supports cross-compiling to Linux from macOS, Windows, or Linux hosts.
Use the --target-os and --target-arch flags with dart compile exe or dart compile aot-snapshot.
--target-os=linux(Only Linux is currently supported as a cross-compilation target)--target-arch=arm64(64-bit ARM)--target-arch=x64(x86-64)--target-arch=arm(32-bit ARM)--target-arch=riscv64(64-bit RISC-V)
Example: dart compile exe --target-os=linux --target-arch=arm64 bin/cli.dart
Workflows
Task Progress: Implement a New CLI Command
- [ ] Create a new class extending
Commandinlib/src/commands/. - [ ] Define the
nameanddescriptionproperties. - [ ] Register command-specific flags in the constructor using
argParser.addFlag()orargParser.addOption(). - [ ] Implement the
run()method with the core logic. - [ ] Register the new command in the
CommandRunnerinstance inbin/cli.dartusingaddCommand(). - [ ] Run validator -> Execute
dart run bin/cli.dart help <command_name>to verify help text generation.
Task Progress: Compile and Release Native Executable
- [ ] Run validator -> Execute
dart format . --set-exit-if-changedto ensure code formatting. - [ ] Run validator -> Execute
dart analyzeto ensure no static analysis errors. - [ ] Run validator -> Execute
dart testto pass all integration tests. - [ ] Compile for host OS:
dart compile exe bin/cli.dart -o build/cli-host - [ ] Compile for Linux (if host is macOS/Windows):
dart compile exe --target-os=linux --target-arch=x64 bin/cli.dart -o build/cli-linux-x64
Examples
Example: CommandRunner Implementation
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:stack_trace/stack_trace.dart';
class CommitCommand extends Command {
@override
final String name = 'commit';
@override
final String description = 'Record changes to the repository.';
CommitCommand() {
argParser.addFlag('all', abbr: 'a', help: 'Commit all changed files.');
}
@override
Future<void> run() async {
final commitAll = argResults?['all'] as bool? ?? false;
print('Committing... (All: $commitAll)');
}
}
void main(List<String> args) {
Chain.capture(() async {
final runner = CommandRunner('dgit', 'Distributed version control.')
..addCommand(CommitCommand());
await runner.run(args);
}, onError: (error, chain) {
if (error is UsageException) {
stderr.writeln(error.message);
stderr.writeln(error.usage);
exit(64); // ExitCode.usage.code
} else {
stderr.writeln('Fatal error: $error');
stderr.writeln(chain.terse);
exit(1);
}
});
}
Example: Integration Testing with Subprocesses
import 'package:test/test.dart';
import 'package:test_process/test_process.dart';
import 'package:test_descriptor/test_descriptor.dart' as d;
void main() {
test('CLI formats output correctly and modifies filesystem', () async {
// 1. Setup mock filesystem
await d.dir('project', [
d.file('config.json', '{"key": "value"}')
]).create();
// 2. Spawn the CLI process
final process = await TestProcess.start(
'dart',
['run', 'bin/cli.dart', 'process', '--path', '${d.sandbox}/project']
);
// 3. Validate stdout stream
await expectLater(process.stdout, emitsThrough('Processing complete.'));
// 4. Validate exit code
await process.shouldExit(0);
// 5. Validate filesystem mutations
await d.dir('project', [
d.file('config.json', '{"key": "value"}'),
d.file('output.log', 'Success')
]).validate();
});
}