| |

Dart 73 🎯 Writing Command-Line Interfaces (CLIs) with package:args

⏱️ Estimated read: 15 min | |

A Dart command-line interface is a program that reads arguments from main(List<String> args), parses them into structured values, and routes execution to the correct logic. The package:args library provides two levels of abstraction for this: ArgParser for parsing flags and options in a single-command script, and CommandRunner for building multi-command CLIs with subcommands, automatic help generation, and usage error handling . The distinction between the two determines the architecture of the tool. A simple script that accepts a --name flag uses ArgParser directly. A tool like git that has commit, push, and status subcommands uses CommandRunner with a separate Command class for each subcommand.

The parser is not just a convenience. It handles the GNU/POSIX conventions that users expect: --name=value, --name value, -n value, combined short flags like -vfi, and the -- separator that stops option parsing so the remaining arguments are treated as positional . It also generates the help output automatically from the declared options, which means the --help text stays in sync with the code without manual maintenance. The parser throws a FormatException or ArgParserException when the input is invalid, and the CommandRunner converts those into a UsageException that prints the usage string and exits with a non-zero code.

Key point: Use ArgParser directly for simple scripts with flags and options, and CommandRunner with Command subclasses for multi-command CLIs; the parser handles GNU/POSIX conventions, generates help text from the declarations, and throws UsageException on invalid input, which the runner prints with the usage string.


Why package:args exists

The manual parsing problem. Without a library, parsing args means iterating over the list, checking for -- prefixes, handling = separators, and validating values. Every script reinvents the same logic, and every script has slightly different edge cases. ArgParser centralizes the logic and the conventions: it knows that --name=value and --name value are equivalent, that -n is an abbreviation for --name if declared, and that --no-flag negates a boolean flag . The parser also validates against a declared set of allowed values and throws a specific exception when the input does not match.

The subcommand problem. A CLI with subcommands—git commit, git push, docker run, docker build—needs to route the first positional argument to a command object, pass the remaining arguments to that command’s parser, and handle the case where the command does not exist. CommandRunner does this routing. It maintains a map of command names to Command instances, parses the top-level arguments to find the command, then delegates the rest to the command’s own ArgParser . Without the runner, the routing logic is a hand-written switch statement that grows with every new command.

The help generation problem. Every CLI needs --help. Writing the help text by hand means it drifts from the actual options. ArgParser generates the usage string from the declarations: the option names, abbreviations, help text, defaults, and allowed values all appear in the output automatically . CommandRunner generates a top-level help that lists the available commands and their descriptions, and each Command generates its own help. The help command is added automatically, so mycli help and mycli help subcommand both work .

The error handling problem. When the user types an invalid option, the CLI should print a clear error, show the usage string, and exit with a non-zero code. CommandRunner.run() catches ArgParserException, converts it to UsageException, prints the message and the usage, and sets the exit code . If the code uses run() inside a try/catch, the UsageException can be handled explicitly. The default behavior is to print to stderr and exit with code 64, which is the standard EX_USAGE code from sysexits.h .


a. ArgParser for single-command scripts

The ArgParser is created, configured with addFlag and addOption, and then parse() is called with the arguments from main. The result is an ArgResults object that provides typed access to the values.

// Import the args package for argument parsing
import 'package:args/args.dart';

void main(List<String> arguments) {
  // Create a parser and declare the options
  final parser = ArgParser()
    // A string option named "name" with abbreviation "n"
    // defaultsTo provides the value when the option is absent
    // help is shown in the generated usage text
    ..addOption('name', abbr: 'n', defaultsTo: 'World', help: 'Who to greet.')
    // A boolean flag named "verbose" with abbreviation "v"
    // negatable: false means --no-verbose is not accepted
    ..addFlag('verbose', abbr: 'v', negatable: false, help: 'Enable verbose output.');

  // Parse the arguments from the command line
  // If parsing fails, ArgParserException is thrown
  final results = parser.parse(arguments);

  // Read the "name" option as a string
  // The defaultsTo ensures it is never null
  final name = results['name'] as String;

  // Read the "verbose" flag as a boolean
  final verbose = results['verbose'] as bool;

  // Use the parsed values
  print('Hello, $name!');
  if (verbose) {
    print('Verbose mode enabled.');
  }
}

The addOption method declares a string option. The abbr parameter provides a single-character alias. The defaultsTo parameter provides the value when the option is not present on the command line. The addFlag method declares a boolean flag. By default, flags are negatable, which means --no-verbose sets the flag to false. The negatable: false parameter disables that behavior .

The parse() method returns an ArgResults object. Accessing a value uses the bracket operator: results['name'] returns the value of the name option. For flags, the value is a bool. For options, the value is a String? unless defaultsTo is set, in which case it is a String. The ArgResults also provides wasParsed() to check whether an option was explicitly provided, and rest for the positional arguments that were not consumed by options .

The parser supports allowed values for validation. If the user provides a value outside the allowed set, the parser throws an ArgParserException :

// Restrict the "mode" option to two values
parser.addOption('mode', allowed: ['debug', 'release']);

The mandatory parameter makes an option required. If the option is not provided, accessing it in the results throws an ArgumentError :

// The "mode" option must be provided
parser.addOption('mode', mandatory: true);

b. CommandRunner for multi-command CLIs

A CommandRunner orchestrates a set of Command objects. Each command has a name, a description, and its own ArgParser for command-specific options. The runner parses the top-level arguments, finds the command, and delegates to its run() method.

The Command class is extended for each subcommand. The name and description are overridden as fields. The argParser is configured in the constructor. The run() method contains the command’s logic and has access to argResults, which is the parsed result of the command’s own arguments.

// Import the command runner and the standard IO library
import 'dart:io';
import 'package:args/command_runner.dart';

// A command that greets a user
class GreetCommand extends Command {
  // The name of the command as typed on the command line
  @override
  final String name = 'greet';

  // The description shown in the help output
  @override
  final String description = 'Greet a user by name.';

  GreetCommand() {
    // Declare the options that this command accepts
    argParser.addOption('name', abbr: 'n', help: 'The name to greet.');
    argParser.addFlag('shout', help: 'Print the greeting in uppercase.');
  }

  // The logic that runs when the command is invoked
  @override
  void run() {
    // argResults holds the parsed values for this command
    // If "name" is not provided, use "World" as the fallback
    final name = argResults?['name'] as String? ?? 'World';
    // If "shout" is provided, the greeting is uppercased
    final shout = argResults?['shout'] as bool? ?? false;
    final greeting = 'Hello, $name!';
    // Print the result, uppercased if the flag is set
    print(shout ? greeting.toUpperCase() : greeting);
  }
}

void main(List<String> arguments) async {
  // Create the runner with the executable name and description
  final runner = CommandRunner('mycli', 'A sample CLI built with package:args.')
    // Register the commands that the CLI supports
    ..addCommand(GreetCommand());

  // Run the command that matches the first argument
  // If no command matches, the runner prints the usage and exits
  await runner.run(arguments);
}

The runner.run(arguments) call does three things. It parses the top-level arguments to find the command name. It looks up the command in the internal map. It calls the command’s run() method with the remaining arguments parsed by the command’s own ArgParser . If the command name is not found, the runner throws a UsageException that prints the top-level usage and lists the available commands.

The runner adds the help command automatically. Running mycli help prints the top-level usage. Running mycli help greet prints the usage for the greet command, including the options declared in its argParser . The --help flag is also available on every command, so mycli greet --help produces the same output as mycli help greet.


c. Error handling, exit codes, and the usage exception

The CommandRunner.run() method catches ArgParserException and converts it to UsageException. The UsageException contains the error message and the usage string. The default behavior is to print both to stderr and set the exit code to 64 . If the application needs to handle the error differently—log it, show a custom message, or exit with a different code—it can catch the UsageException explicitly.

// Import the standard IO library for exit codes
import 'dart:io';
import 'package:args/command_runner.dart';

void main(List<String> arguments) async {
  final runner = CommandRunner('mycli', 'A sample CLI.')
    ..addCommand(GreetCommand());

  try {
    // Run the command; if the arguments are invalid, UsageException is thrown
    await runner.run(arguments);
  } on UsageException catch (e) {
    // Print the error message and the usage string to stderr
    stderr.writeln(e.message);
    stderr.writeln(e.usage);
    // Exit with the standard usage error code
    exit(64);
  }
}

The UsageException has two properties: message is the specific error, and usage is the usage string for the command or runner that threw the exception . The exit(64) call sets the exit code to EX_USAGE, which is the standard code for a command-line usage error. A script that calls this CLI can check the exit code to determine whether the failure was a usage error or a runtime error.

The Command class also provides a usageException() method that throws a UsageException from inside the run() method. This is useful when the command’s logic determines that the arguments are valid syntactically but invalid semantically—for example, a deploy command that requires a --environment flag when the --production flag is set:

// Inside a Command's run() method
if (argResults?['production'] == true && argResults?['environment'] == null) {
  // Throw a usage exception with a specific message
  // The runner will catch it and print the usage string
  usageException('--environment is required when --production is set.');
}

Complete Example Session

The following session builds a multi-command CLI with a greet command and an add command, shows the automatic help generation, and demonstrates the error handling.

// ============================================
// PART 1: THE GREET COMMAND
// ============================================
import 'dart:io';
import 'package:args/command_runner.dart';

class GreetCommand extends Command {
  // The command name as typed on the command line
  @override
  final String name = 'greet';

  // The description shown in the help output
  @override
  final String description = 'Greet a user.';

  GreetCommand() {
    // The name option with a default value
    argParser.addOption('name', abbr: 'n', defaultsTo: 'World', help: 'Who to greet.');
    // The shout flag, not negatable
    argParser.addFlag('shout', negatable: false, help: 'Uppercase the greeting.');
  }

  @override
  void run() {
    // Read the values from argResults
    final name = argResults?['name'] as String;
    final shout = argResults?['shout'] as bool? ?? false;
    final greeting = 'Hello, $name!';
    print(shout ? greeting.toUpperCase() : greeting);
  }
}

// ============================================
// PART 2: THE ADD COMMAND
// ============================================
class AddCommand extends Command {
  @override
  final String name = 'add';

  @override
  final String description = 'Add two numbers.';

  AddCommand() {
    // Two required numeric options
    argParser.addOption('x', mandatory: true, help: 'First number.');
    argParser.addOption('y', mandatory: true, help: 'Second number.');
  }

  @override
  void run() {
    // Parse the values as integers
    final x = int.parse(argResults?['x'] as String);
    final y = int.parse(argResults?['y'] as String);
    print('$x + $y = ${x + y}');
  }
}

// ============================================
// PART 3: THE RUNNER AND ERROR HANDLING
// ============================================
void main(List<String> arguments) async {
  final runner = CommandRunner('mycli', 'A sample CLI with two commands.')
    ..addCommand(GreetCommand())
    ..addCommand(AddCommand());

  try {
    await runner.run(arguments);
  } on UsageException catch (e) {
    // Print the error and usage to stderr
    stderr.writeln(e.message);
    stderr.writeln(e.usage);
    // Exit with the standard usage error code
    exit(64);
  }
}

// ============================================
// PART 4: EXAMPLE INVOCATIONS
// ============================================
// dart run bin/cli.dart greet
// → Hello, World!
//
// dart run bin/cli.dart greet --name=Alice --shout
// → HELLO, ALICE!
//
// dart run bin/cli.dart add --x=3 --y=4
// → 3 + 4 = 7
//
// dart run bin/cli.dart greet --bad-option
// → Could not find an option named "bad-option".
// → (usage string)
// → exit code 64

The four parts cover the greet command with a default value and a flag, the add command with two mandatory options, the runner with explicit UsageException handling, and the example invocations. What the session demonstrates is that the help output is generated from the declarations, the mandatory options throw when missing, and the invalid option produces a clear error with the usage string and a non-zero exit code.


Quick Reference

ArgParser Methods

MethodPurpose
addFlag(name)Declare a boolean flag
addOption(name)Declare a string option
addMultiOption(name)Declare a repeatable option
parse(args)Parse the arguments

addOption Parameters

ParameterPurpose
abbrSingle-character abbreviation
defaultsToValue when absent
helpText in usage output
allowedValidation set
mandatoryRequired option

CommandRunner Methods

MethodPurpose
addCommand(cmd)Register a command
run(args)Parse and dispatch
usageThe top-level usage string

Command Overrides

MemberPurpose
nameCommand name
descriptionHelp text
argParserCommand-specific parser
run()Command logic
usageException(msg)Throw a usage error

Best Practices

✅ Do This:

// Use ArgParser for simple scripts
final parser = ArgParser()..addOption('name', abbr: 'n');

// Use CommandRunner for multi-command CLIs
final runner = CommandRunner('cli', 'Description')..addCommand(GreetCommand());

// Let the runner handle UsageException, or catch it explicitly
try { await runner.run(args); } on UsageException catch (e) { exit(64); }

// Use defaultsTo for optional values
parser.addOption('mode', defaultsTo: 'debug');

❌ Don’t Do This:

// Hand-parse args when package:args does it
if (args[0] == '--name') { ... } // reinventing the wheel

// Forget mandatory options cause a throw on access
argParser.addOption('x', mandatory: true); // access throws if missing

// Ignore UsageException and let the default print
// (fine, but the exit code is 64, not 1)

Common Pitfalls

PitfallWhy It HappensFix
results['name'] is nullNo defaultsTo and option absentUse as String? ?? 'fallback'
Mandatory option throwsAccessing without providingCheck wasParsed() first
Command not foundTypo in command nameRunner prints available commands
--help does not workParser not configuredRunner adds it automatically
Exit code is 64UsageException defaultCatch and set custom code

Real-World Examples

1. Basic option

parser.addOption('name', abbr: 'n', defaultsTo: 'World');

2. Basic flag

parser.addFlag('verbose', abbr: 'v', negatable: false);

3. Allowed values

parser.addOption('mode', allowed: ['debug', 'release']);

4. Mandatory option

parser.addOption('x', mandatory: true);

5. Command class

class GreetCommand extends Command {
  final String name = 'greet';
  final String description = 'Greet a user.';
}

6. Command option

GreetCommand() { argParser.addOption('name'); }

7. Command run

void run() { print('Hello, ${argResults?['name']}'); }

8. Runner setup

final runner = CommandRunner('cli', 'Description')..addCommand(GreetCommand());

9. Usage exception

try { await runner.run(args); } on UsageException catch (e) { exit(64); }

10. Custom usage error

usageException('--env is required.');

Visual

ArgParser Flow

┌──────────────────────────────────────────────┐
│  main(List<String> args)                     │
│         │                                    │
│         ▼                                    │
│  ArgParser.parse(args)                       │
│         │                                    │
│    ┌────┴────┐                               │
│    │         │                               │
│  valid     invalid                           │
│    │         │                               │
│    ▼         ▼                               │
│  ArgResults  ArgParserException              │
│  results['x']│                               │
│              ▼                               │
│         print error                          │
│         exit(64)                             │
└──────────────────────────────────────────────┘

CommandRunner Flow

┌──────────────────────────────────────────────┐
│  runner.run(args)                            │
│         │                                    │
│         ▼                                    │
│  Parse top-level args                        │
│         │                                    │
│         ▼                                    │
│  Find command by name                        │
│         │                                    │
│    ┌────┴────┐                               │
│    │         │                               │
│  found     not found                         │
│    │         │                               │
│    ▼         ▼                               │
│  cmd.run()  UsageException                   │
│  with args  (print usage, exit 64)          │
└──────────────────────────────────────────────┘

Help Generation

┌──────────────────────────────────────────────┐
│  parser.addOption('name',                    │
│    abbr: 'n',                                │
│    defaultsTo: 'World',                      │
│    help: 'Who to greet.')                    │
│         │                                    │
│         ▼                                    │
│  --help output:                              │
│  -n, --name    Who to greet.                 │
│                (defaults to "World")         │
└──────────────────────────────────────────────┘

UsageException Handling

┌──────────────────────────────────────────────┐
│  Invalid option: --bad                       │
│         │                                    │
│         ▼                                    │
│  ArgParserException                          │
│         │                                    │
│         ▼                                    │
│  CommandRunner converts to UsageException    │
│         │                                    │
│         ▼                                    │
│  try/catch in main                           │
│         │                                    │
│         ▼                                    │
│  stderr: error message + usage               │
│  exit(64)                                    │
└──────────────────────────────────────────────┘

Summary

ItemValue
Packageargs
Single-command parserArgParser
Multi-command runnerCommandRunner
Subcommand baseCommand
Flag methodaddFlag
Option methodaddOption
Parse resultArgResults
Error typeUsageException
Default exit code64 (EX_USAGE)
Help commandAdded automatically

Key takeaways:

  • ArgParser is for single-command scripts. It declares flags and options, parses the arguments, and returns an ArgResults object. The help output is generated from the declarations .
  • CommandRunner is for multi-command CLIs. It routes the first positional argument to a Command object, delegates the remaining arguments to that command’s parser, and generates the top-level help with the list of commands .
  • The parser handles GNU/POSIX conventions. --name=value, --name value, -n value, combined short flags, and --no-flag negation all work without additional code .
  • defaultsTo and mandatory control the option contract. A default value means the option is never null in the results. A mandatory option throws when accessed if it was not provided .
  • CommandRunner.run() catches parse errors. It converts ArgParserException to UsageException, prints the message and usage to stderr, and exits with code 64 .
  • The help command is automatic. mycli help and mycli help subcommand both work without any code beyond the command registrations .

Remember: package:args is the standard library for Dart CLIs. The decision is between ArgParser for a simple script and CommandRunner for a tool with subcommands. The parser handles the conventions, the validation, and the help generation. The runner handles the routing and the error reporting. The UsageException is the contract for invalid input: catch it if you need a custom response, or let the runner print the usage and exit with code 64. The help text is not a separate document—it is derived from the same declarations that the parser uses, which is why it stays accurate as the CLI evolves.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!