Skip to Content
Parse command-line arguments

Parse command-line arguments

A Noir entry point is an ordinary Dart console program. Receive the startup arguments in main(List<String> arguments), parse them before you mount the widget tree, and pass typed values into the root widget.

Noir’s development runner forwards everything after the entry-point path without interpreting it, so both of these deliver the same arguments:

dart run bin/logview.dart build.log --follow dart run noir:run bin/logview.dart build.log --follow

Add Dart’s argument parser:

dart pub add args

Parse flags for one application

Use ArgParser when one root widget takes its initial configuration from flags or positional values. Parse once in main, convert the result into a type your application owns, and pass that value to the root widget:

bin/logview.dart
import 'dart:io' as io; import 'package:args/args.dart'; import 'package:noir/noir.dart'; final class LogOptions { const LogOptions({required this.path, required this.follow}); final String path; final bool follow; } void main(List<String> arguments) { final parser = ArgParser() ..addFlag('follow', abbr: 'f', negatable: false, help: 'Stay at the end.') ..addFlag('help', abbr: 'h', negatable: false, help: 'Show this usage.'); final LogOptions options; try { final results = parser.parse(arguments); if (results.flag('help')) { io.stdout.writeln('Usage: logview <file> [options]\n${parser.usage}'); return; } if (results.rest.length != 1) { throw const FormatException('Expected exactly one log file.'); } options = LogOptions( path: results.rest.single, follow: results.flag('follow'), ); } on FormatException catch (error) { io.stderr.writeln('${error.message}\n\n${parser.usage}'); io.exitCode = 64; return; } runTuiApp(LogViewApp(options: options)); } class LogViewApp extends StatelessWidget { const LogViewApp({required this.options, super.key}); final LogOptions options; @override Widget build(BuildContext context) => Panel( title: options.path, child: Text(options.follow ? 'Following new lines' : 'Showing the file'), ); }

Three boundaries keep this readable:

  • main is the process boundary. It parses, reports usage errors, and calls runTuiApp exactly once.
  • LogOptions is the application type. LogViewApp never reads ArgResults or a string-keyed option map.
  • Invalid arguments fail before runTuiApp, so the program never enters terminal mode only to print usage.

ArgParserException extends FormatException, so one catch covers both an unknown flag and your own validation.

Print usage on --help and exit 0. Report an invalid argument on stderr and exit 64.

Optional: dispatch several subcommands

Use CommandRunner only when a command name selects a separate shell operation, such as patcher review against patcher search. A subcommand is not in-app navigation: after Noir mounts, ordinary widget state owns tabs, pages, dialogs, and command palettes.

bin/patcher.dart
import 'dart:io' as io; import 'package:args/command_runner.dart'; import 'package:noir/noir.dart'; enum ReviewMode { unified, split } final class ReviewOptions { const ReviewOptions({ required this.initialPath, required this.mode, required this.staged, }); final String? initialPath; final ReviewMode mode; final bool staged; } Future<void> main(List<String> arguments) async { final runner = CommandRunner<ReviewOptions>( 'patcher', 'Review patches in a terminal UI.', )..addCommand(ReviewCommand()); final ReviewOptions? options; try { options = await runner.run(arguments); } on UsageException catch (error) { io.stderr.writeln(error); io.exitCode = 64; return; } if (options == null) return; runTuiApp(ReviewApp(options: options)); } final class ReviewCommand extends Command<ReviewOptions> { ReviewCommand() { argParser ..addOption( 'mode', allowed: [for (final mode in ReviewMode.values) mode.name], defaultsTo: ReviewMode.unified.name, ) ..addFlag('staged', negatable: false); } @override String get name => 'review'; @override String get description => 'Review a patch interactively.'; @override ReviewOptions run() { final paths = argResults!.rest; if (paths.length > 1) { usageException('Expected zero or one patch path.'); } return ReviewOptions( initialPath: paths.isEmpty ? null : paths.single, mode: ReviewMode.values.byName(argResults!.option('mode')!), staged: argResults!.flag('staged'), ); } } class ReviewApp extends StatelessWidget { const ReviewApp({required this.options, super.key}); final ReviewOptions options; @override Widget build(BuildContext context) => Panel( title: 'Review', child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 1, children: [ Text(options.initialPath ?? 'Choose a patch in the TUI'), Text('Mode: ${options.mode.name}'), Text(options.staged ? 'Source: staged changes' : 'Source: working tree'), ], ), ); }

The leaf command is the boundary between CLI strings and application types. It returns ReviewOptions without mounting a terminal. CommandRunner.run returns null after it prints help or top-level usage, so that branch returns without mounting anything.

If several subcommands select different workflows, give CommandRunner a sealed result type your application owns. Each leaf returns one subtype, and main passes the selected value to one root widget. That root may own shared themes, services, overlays, and in-app navigation. It should not receive arguments, ArgResults, or the CommandRunner: startup parsing is already complete.

Decide how a missing value behaves

The review command above allows the patch path to be omitted, so ReviewApp receives a null initialPath and can collect the path with a TextInput, an Autocomplete, or a file-picker flow. That is an application decision, not parser behavior. For a command that must stay non-interactive, reject the missing value in run() with usageException instead.

Startup parsing happens once

runTuiApp returns as soon as the tree is mounted. Dart’s input and signal subscriptions keep the process alive after main finishes. Quit from the widget tree with TuiApp.exit(context) so Noir restores the terminal resources it owns; calling dart:io’s exit from a running TUI can skip that cleanup.

Hot reload rebuilds the retained widget tree but does not rerun main(). Restart the process to change a command, a flag, or a startup value.

This startup boundary is separate from Noir’s Shortcuts, Intent, and Actions APIs. Those route semantic input inside a mounted widget tree; see Handle input and focus. For nested commands, aliases, multi-value options, and the complete parser contract, continue with the package:args documentation.

Last updated on