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 int from main and Dart uses it as the process exit code. Alternatively call exit(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_USAGE value. A script's caller checks this, not your log output.
  • Write diagnostics to stderr and real output to stdout. Piping a tool into another one breaks the moment a progress line goes to stdout.
  • For a tool with arguments, ArgParser also generates --help and 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);
  });
}
CodeMeaningWhen
0SuccessNormal termination
1General errorAn operation failed
2Misuse of shell builtinsRarely used by tools
64EX_USAGEBad arguments or unreadable usage
66EX_NOINPUTThe input file does not exist
130128 + SIGINTTerminated by Ctrl+C
141128 + SIGPIPEThe 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.

Packages, pubspec and project layout Isolates, compilation and packaging

Last refreshed 2026-09-18.