Creating CLI Applications in Dart

Creating CLI Applications in Dart

You can interact with computers in two primary ways: through a Graphical User Interface (GUI) or a Command-Line Interface (CLI). While GUIs are more user-friendly for general tasks, CLIs are ideal for scripting, developer tools, and system utilities.

  1. Why Dart?
  2. Parsing Arguments
  3. Defining Commands
  4. Structuring CLI Application
  5. CLI Utilities & Techniques

Why Dart?

Dart is a great language for CLI because it's fast and produces cross-platform, self-contained executables. You don’t need to install extra dependencies, virtual machines, or libraries to run a Dart programβ€”whether on macOS, Windows, Linux, or Mobile.

Parsing Arguments

CLI applications use arguments to determine what action to perform. For example:

$ flutter analyze
$ flutter format

In the first example, the Flutter CLI runs the analyzer. In the second, it formats your code. So, a CLI should be able to interpret passed arguments properly.

Dart provides an args package to parse command-line arguments. This package revolves around the ArgParser class, which lets you define and process options and flags.

  • Flags: Boolean values defined with addFlag(). They don't require a value (e.g., --verbose).
  • Options: Key-value pairs defined with addOption() (e.g., --name Michael).

Here’s an example that uses both:

import 'package:args/args.dart';

void main(List<String> args) {
  final parser = ArgParser()
    ..addOption('name', abbr: 'n', help: 'The name of the person to greet.')
    ..addFlag('spanish', abbr: 's', help: 'Greet in Spanish.');

  final results = parser.parse(args);
  final name = results['name'];
  final isSpanish = results['spanish'] as bool;

  if (name == null) {
    print('Please provide a name using the --name option.');
    return;
  }

  final greeting = isSpanish ? 'Hola, $name!' : 'Hello, $name!';
  print(greeting);
}

Command Line App that greets userwo major ways to interact with computers are

Example usage:

$ dart run bin/hello.dart -n Michael
Hello, Michael!

$ dart run bin/hello.dart -n Michael -s
Hola, Michael!

πŸ“ For small tools, a single ArgParser is often sufficient. For complex tools with multiple subcommands, use CommandRunner as described in the next chapter.

Defining Commands

For more complex applications, you can define commands using the Command class from the args package.

The following example defines a hello command that greets a user. The command accepts two parameters: a --name option and a --spanish flag.

import 'package:args/command_runner.dart';

class HelloCommand extends Command<void> {
  HelloCommand() {
    argParser
      ..addOption(
        'name',
        abbr: 'n',
        help: 'The name of the person to greet.',
      )
      ..addFlag(
        'spanish',
        abbr: 's',
        help: 'Greet in Spanish.',
      );
  }

  @override
  String get description => 'A command that greets the user.';

  @override
  String get name => 'hello';

  @override
  void run() {
    final name = argResults?['name'] ?? 'World';
    final isSpanish = argResults?['spanish'] as bool;

    final greeting = isSpanish ? 'Β‘Hola, $name!' : 'Hello, $name!';
    print(greeting);
  }
}

Each command includes an argParser, a description that appears in help output, and a name used to invoke the command from the terminal. This provides several benefits:

  • Keeps command-specific logic modular and maintainable.
  • Automatically generates usage and help documentation (e.g., when you run --help).

This is how the usage description looks like:

$ dart run bin/main.dart
A simple command-line tool that greets the user.

Usage: hello <command> [arguments]

Global options:
-h, --help    Print this usage information.

Available commands:
  hello   A command that greets the user.

Run "hello help <command>" for more information about a command.

Creating a Command Runner

To make your commands executable, use the CommandRunner class. It acts as the entry point for managing and dispatching commands.

class HelloCommandRunner extends CommandRunner<void> {
  HelloCommandRunner()
      : super(
          'hello',
          'A simple command-line tool that greets the user.',
        ) {
    addCommand(HelloCommand());
  }
}
  • CommandRunner takes a command name and description.
  • Use addCommand() to register each command with the runner.
πŸ’‘
You should typically create one CommandRunner per entry point. For example, HelloCommandRunner might be used in bin/hello.dart, while FooCommandRunner handles bin/foo.dart.

Now, you can invoke the runner from your main function:

void main(List<String> args) {
  final commandRunner = HelloCommandRunner();
  commandRunner.run(args);
}

This structure allows you to support multiple commands, each with its own logic, while keeping the overall application organized and extensible.

Structuring CLI Application

This section explains how to structure the code for your CLI application. A typical structure looks like this:

.
β”œβ”€β”€ bin
β”‚   └── lib.dart # From here you invoke command runner
β”œβ”€β”€ lib
β”‚   β”œβ”€β”€ lib.dart
β”‚   └── src
β”‚       β”œβ”€β”€ cli
β”‚       β”‚   β”œβ”€β”€ commands
β”‚       β”‚   β”‚   β”œβ”€β”€ hello_command.dart
β”‚       β”‚   β”‚   └── foo_command.dart
β”‚       β”‚   └── runner.dart # This is your runner
β”‚       └── core # The core logic of your library
β”‚           └── hello
β”‚               └── logic.dart
└── pubspec.yaml

bin/ directory is the place for entrypoints. You can add as many files as needed, depending on how many commands or entry scripts your application requires.

cli/ directory is a good place for runners, commands, and any utilities specific to the CLI.

The core logic of your library should go in the core/ directory. For example, if library includes an SVG parser, implement and export all related functionality from core/.

Alternatively, instead of combining everything in a single package, you can create a separate package for your CLI. This approach is especially useful for larger projects or when the CLI is optional for users of your library.

CLI Utilities & Techniques

To enhance your command-line tools, you can use the official cli_util package. It provides convenient logging with ANSI support, as well as utilities for locating the Dart SDK and determining configuration directories.

The cli_util package helps you display progress indicators, log messages at different verbosity levels, and format output in a user-friendly way.

Here's a simple example:

import 'package:cli_util/cli_logging.dart';

void main(List<String> args) async {
  final verbose = args.contains('-v');
  final logger = verbose ? Logger.verbose() : Logger.standard();

  logger.stdout('Hello world!');
  logger.trace('message 1');
  await Future<void>.delayed(const Duration(milliseconds: 200));
  logger.trace('message 2');
  logger.trace('message 3');

  final progress = logger.progress('doing some work');
  await Future<void>.delayed(const Duration(seconds: 2));
  progress.finish(showTiming: true);

  logger.stdout('All ${logger.ansi.emphasized('done')}.');
}

In this example:

  • The logger prints standard and trace-level messages, depending on whether the -v flag is passed.
  • It shows a progress spinner while simulating a task.
  • The final message uses ANSI styling to emphasize the word done.

This is what it looks like:

0:00
/0:04

Conclusion

Thanks for reading! Here are the key takeaways:

  • Use the args package to parse command-line arguments. For structured commands and built-in --help support, use Command and CommandRunner.
  • To add a CLI to your package, place CLI sources in lib/src/cli and create an entry point in bin/<name>.dart. For larger projects or reusable CLIs, consider creating a separate package.
  • Use the cli_util package for logging, ANSI styling, progress indicators, locating the Dart SDK, and accessing the configuration directory.

Read more