Command-line tools: args, stdin and exit codes
Parse argv with package:args, read stdin, work with the file system, and compile a self-contained executable.
Parsing arguments
import 'dart:io';
import 'package:args/args.dart';
Future<int> main(List<String> argv) async {
final parser = ArgParser()
..addFlag('verbose', abbr: 'v', negatable: false, help: 'Print more output')
..addOption('output', abbr: 'o', help: 'Write the report here', defaultsTo: 'out.txt')
..addOption('format', allowed: ['text', 'json', 'csv'], defaultsTo: 'text')
..addMultiOption('include', abbr: 'i', help: 'Extra paths to scan')
..addCommand('serve')
..addOption('port', defaultsTo: '8080');
ArgResults args;
try {
args = parser.parse(argv);
} on FormatException catch (e) {
stderr.writeln('error: ${e.message}');
stderr.writeln(parser.usage);
return 64; // EX_USAGE, the conventional exit code
}
if (args['verbose'] as bool) print('verbose mode');
switch (args.command) {
case 'serve':
final cmd = args.command!;
print('serving on port ${cmd['port']}');
return 0;
case null:
final files = [
...args.rest,
...(args['include'] as List<String>),
];
if (files.isEmpty) {
stderr.writeln('error: no input files');
stderr.writeln(parser.usage);
return 64;
}
await File(args['output'] as String).writeAsString(
files.join(Platform.lineTerminator),
);
return 0;
default:
stderr.writeln('unknown command');
return 64;
}
}- Return an
intfrommainand Dart uses it as the process exit code. Alternatively callexit(code), which terminates immediately without running pending asynchronous work. - Use conventional exit codes: 0 success, 1 a general failure, 2 usage, 64 the BSD
EX_USAGEvalue. A script's caller checks this, not your log output. - Write diagnostics to
stderrand real output tostdout. Piping a tool into another one breaks the moment a progress line goes to stdout. - For a tool with arguments,
ArgParseralso generates--helpand a usage string, so you do not hand-write either.
stdin, working directory and signals
import 'dart:convert';
import 'dart:io';
Future<void> main(List<String> argv) async {
// read stdin line by line: the idiom for a filter tool
if (!stdin.hasTerminal) {
await for (final line in stdin.transform(utf8.decoder).transform(const LineSplitter())) {
stdout.writeln(line.toUpperCase());
}
} else {
stderr.writeln('reading from a terminal; type lines or pipe input in');
await for (final line in stdin.transform(utf8.decoder).transform(const LineSplitter())) {
if (line == 'quit') break;
stdout.writeln('you said: $line');
}
}
// the working directory is where the user invoked the tool, not where it lives
final cwd = Directory.current;
final scriptDir = File(Platform.script.toFilePath()).parent;
print('cwd=$cwd script=$scriptDir');
// find the project root by walking up
Directory? findRoot(Directory start, String marker) {
var dir = start;
while (true) {
if (File('${dir.path}${Platform.pathSeparator}$marker').existsSync()) return dir;
final parent = dir.parent;
if (parent.path == dir.path) return null;
dir = parent;
}
}
print(findRoot(cwd, 'pubspec.yaml')?.path);
// environment variables and exit codes
final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE'];
print('home=$home');
// run a child process and stream its output
final result = await Process.run('git', ['rev-parse', '--short', 'HEAD']);
if (result.exitCode != 0) {
stderr.writeln('git failed: ${result.stderr}');
exit(1);
}
print('commit ${(result.stdout as String).trim()}');
// handle Ctrl+C so you can clean up before exiting
ProcessSignal.sigint.watch().listen((_) async {
stderr.writeln('interrupted, cleaning up');
exit(130);
});
}| Code | Meaning | When |
|---|---|---|
| 0 | Success | Normal termination |
| 1 | General error | An operation failed |
| 2 | Misuse of shell builtins | Rarely used by tools |
| 64 | EX_USAGE | Bad arguments or unreadable usage |
| 66 | EX_NOINPUT | The input file does not exist |
| 130 | 128 + SIGINT | Terminated by Ctrl+C |
| 141 | 128 + SIGPIPE | The consumer of stdout closed the pipe |
A tool that reports a failure in its log and still exits 0 is unusable in a pipeline. The exit code is the interface; the log is for the human reading it afterwards.
Compiling and shipping an executable
# JIT: fast to start developing, needs the Dart runtime present
dart run bin/shop.dart
# AOT: a native executable with no Dart runtime required
dart compile exe bin/shop.dart -o build/shop
./build/shop --help
# cross-compile from Linux or macOS to Windows, or to Linux for containers
dart compile exe bin/shop.dart -o build/shop --target-os=windows
dart compile exe bin/shop.dart -o build/shop --target-os=linux
# a snapshot loads the program state instead of re-parsing it
dart compile kernel bin/shop.dart -o build/shop.dill
# size and startup
ls -lh build/shop# to publish the tool so others can run it with dart pub global activate
executables:
shop: shop💡
An AOT executable starts in milliseconds and needs no runtime on the target machine, which makes it the right choice for a CLI. A snapshot is faster to build and must be run by the matching
dart binary, so it is only useful when you already control the runtime.FAQ
How do I read a password from the terminal?
Set
stdin.echoMode = false and stdin.lineMode = false before reading, then restore them afterwards. Always restore them in a finally, or the user's terminal is left without echo.How do I make the tool usable in a pipe?
Read from stdin when it is not a terminal, write only the result to stdout, send all diagnostics to stderr, and exit non-zero on failure. That is the whole contract, and it is enough for any shell pipeline.
Related
Packages, pubspec and project layout Isolates, compilation and packaging
Last refreshed 2026-09-18.