Dart for CLI: Build, Automate, and Publish Your Developer Tools
This article guides developers through building Command Line Interface (CLI) tools with Dart. It covers the fundamental anatomy of CLI commands, Dart's core mechanisms for processing terminal input, and essential `dart:io` concepts like `stdout`, `stderr`, exit codes, file operations, and running external processes. The piece also introduces the `args` package for streamlined argument parsing, empowering developers to create robust, automatable, and publishable developer tools.

As developers, we navigate the terminal daily, running commands like flutter build, git push, or dart pub get. Each of these powerful interactions is facilitated by a Command Line Interface (CLI). Despite their ubiquity, many developers haven't ventured into building their own CLI tools. This represents a significant missed opportunity. CLI tools are incredibly practical for automating workflows, standardizing team processes, and creating tangible, distributable developer utilities.
This article aims to guide you through the essentials of building robust CLI tools with Dart, from understanding their fundamental structure to leveraging Dart's core capabilities and advanced argument parsing. By the end, you'll grasp not just how to build, but also why CLIs are an invaluable addition to your development toolkit.
The Power of Command Line Tools
At its heart, a CLI is a program designed for interaction via text commands in a terminal, contrasting with graphical user interfaces. Think of the tools you already use daily – Flutter, Git, npm, Dart's own pub – all are CLIs. Building your own offers compelling advantages:
- Automate Repetitive Tasks: If you find yourself typing a sequence of commands more than a couple of times a week, it's a prime candidate for automation. CLIs can generate boilerplate, execute command sequences, or validate environments, transforming multi-step manual processes into a single, efficient command.
- Standardize Team Workflows: Instead of complex README instructions, a CLI provides a single, consistent command for team members, reducing errors and ensuring uniformity across development environments.
- Create Shareable Developer Tooling: A well-crafted Dart CLI can be published to
pub.dev, making it discoverable and usable by the wider developer community. This showcases practical engineering depth beyond a typical portfolio.
Dissecting CLI Command Structure
Before diving into code, understanding the universal anatomy of a CLI command is crucial. Commands generally follow a consistent pattern:
bash tool [subcommand] [arguments] [options/flags]
Let's break down a familiar example: flutter build apk --release --obfuscate
- Tool: The program itself (e.g.,
flutter,dart,git). - Subcommand: The specific action to perform (e.g.,
build,run,pub). - Arguments: The data the action operates on (e.g.,
apk,main.dart). - Flags and Options: Modifiers that alter the command's behavior. These come in two main forms:
- Boolean Flags:
--release(present or absent). - Key-Value Options:
--output=build/app(a name and an associated value). - Short Flags:
-v(a single hyphen followed by a single character).
- Boolean Flags:
Designing your CLI with this structure in mind leads to intuitive and robust tools.
Dart's Approach to Terminal Input
In Dart, all user input following your tool's name is conveniently packaged into the main function's List<String> args parameter:
dart void main(List<String> args) { print(args); }
If you run dart run bin/mytool.dart hello world --name=Seyi, the args list inside your main function will contain ['hello', 'world', '--name=Seyi']. Every subcommand, argument, or flag the user types becomes an element in this simple list. All advanced CLI features, like subcommands and flag parsing, are ultimately built upon processing this foundational list.
Core Dart Concepts for Robust CLIs
Building effective CLIs requires understanding several foundational Dart concepts, primarily from the dart:io library.
Separating Output with stdout and stderr
While print() works for basic output, production-grade CLIs distinguish between stdout (for regular output intended for the user) and stderr (for error messages and diagnostic information). This separation is vital because users can redirect stdout to a file without capturing error messages.
dart import 'dart:io';
void main(List<String> args) { if (args.isEmpty) { stderr.writeln('Error: no arguments provided'); exit(1); } stdout.writeln('Processing: ${args[0]}'); }
Properly using stdout and stderr mimics professional tools like git and flutter.
Signaling Success or Failure with Exit Codes
Every program returns an exit code upon completion, which tells the operating system or calling scripts whether the execution succeeded or failed. The conventions are clear:
0: Indicates success.1: Denotes a general failure.2: Signifies incorrect usage (e.g., wrong arguments).
dart import 'dart:io';
void main(List<String> args) { if (args.isEmpty) { stderr.writeln('Error: please provide an argument'); exit(1); // Indicate failure } stdout.writeln('Done'); exit(0); // Indicate success (also the default) }
Exit codes are critical for integration with shell scripts or CI/CD pipelines, ensuring that failures halt workflows as expected.
Interacting with the Filesystem
Many CLIs need to read from or write to the file system. Dart's dart:io library provides comprehensive tools for file and directory operations:
dart import 'dart:io';
void main(List<String> args) { if (args.isEmpty) { stderr.writeln('Usage: tool <filename>'); exit(2); } final file = File(args[0]); if (!file.existsSync()) { stderr.writeln('Error: "${args[0]}" not found'); exit(1); } final contents = file.readAsStringSync(); stdout.writeln(contents); final output = File('output.txt'); output.writeAsStringSync('Processed: $contents'); stdout.writeln('Written to output.txt'); }
Working with directories is equally straightforward:
dart import 'dart:io';
void main() { final cwd = Directory.current.path; stdout.writeln('Working directory: $cwd');
final dir = Directory('$cwd/generated'); if (!dir.existsSync()) { dir.createSync(recursive: true); // Creates parent directories too stdout.writeln('Created: ${dir.path}'); } else { stdout.writeln('Already exists: ${dir.path}'); } }
Orchestrating Other Tools: Running External Processes
One of the most powerful capabilities of a CLI is its ability to programmatically execute other shell commands or programs (git, flutter, npm, etc.). Dart offers two primary methods:
Process.run: Executes a command and waits for its completion, returning all output at once. Ideal for short, discrete commands.
dart import 'dart:io';
void main() async { final result = await Process.run('dart', ['pub', 'get']); stdout.write(result.stdout); if (result.exitCode != 0) { stderr.write(result.stderr); exit(result.exitCode); } stdout.writeln('Dependencies installed successfully'); }
Process.start: Initiates a command and allows you to stream its output in real-time. Essential for long-running processes where users need to see live progress.
dart import 'dart:io';
void main() async { final process = await Process.start('flutter', ['build', 'apk']); process.stdout.pipe(stdout); // Stream stdout live process.stderr.pipe(stderr); // Stream stderr live final exitCode = await process.exitCode; exit(exitCode); }
Asynchronous Operations in CLI
Dart CLIs fully support async/await. Most real-world operations, such as file I/O, network requests, or external process execution, are asynchronous. Making your main function async is a common pattern:
dart import 'dart:io';
void main() async { stdout.writeln('Starting...'); await Future.delayed(const Duration(seconds: 1)); // Simulate async work stdout.writeln('Done'); }
Setting Up Your Dart CLI Project
To begin, create a new Dart console project:
bash dart create -t console my_cli_tool cd my_cli_tool
This generates a standard structure. Crucially, for distributing your CLI via pub.dev, you need to configure the executables block in your pubspec.yaml:
yaml name: my_cli_tool description: A sample CLI tool built with Dart version: 1.0.0 environment: sdk: '>=3.0.0 <4.0.0' executables: my_cli_tool: my_cli_tool # executable name: bin file name dependencies: args: ^2.4.2
This block tells Dart which script in your bin/ directory should be exposed as a global command after installation with dart pub global activate.
The args Package: Streamlining Command Parsing
While manual parsing of List<String> args works for simple CLIs, it quickly becomes cumbersome when dealing with complex flags (e.g., --priority=high), boolean options (--done), or multiple subcommands. The args package simplifies this by handling parsing, validation, and even help text generation.
First, add it to your pubspec.yaml:
yaml dependencies: args: ^2.4.2
Then run dart pub get. The ArgParser is the core of the package:
dart import 'package:args/args.dart';
void main(List<String> arguments) { final parser = ArgParser() ..addCommand('add') ..addCommand('list') ..addFlag('help', abbr: 'h', negatable: false);
final results = parser.parse(arguments);
if (results['help'] as bool) { print(parser.usage); return; } // ... handle commands based on results.command.name }
For CLIs with multiple subcommands, each having their own unique flags, you can define nested ArgParser instances:
dart final parser = ArgParser(); final addCommand = ArgParser() ..addOption('priority', abbr: 'p', defaultsTo: 'normal'); parser.addCommand('add', addCommand); // ... other commands
The args package abstracts away the tedious string manipulation, allowing you to focus on your CLI's logic rather than its parsing mechanics.
Conclusion
Dart provides a robust and developer-friendly ecosystem for building powerful command-line interface tools. From understanding the core stdout/stderr streams and exit codes to leveraging dart:io for file system interactions and external process management, Dart equips you with everything needed. Furthermore, packages like args streamline the complex task of argument parsing, enabling you to build sophisticated and user-friendly CLIs. With these foundational concepts, you're well-prepared to develop your own developer tools, automate your workflows, and share your creations with the wider community.
FAQ
Q: Why is it important to separate stdout and stderr in a CLI application?
A: Separating stdout and stderr allows users to cleanly redirect normal program output to files or other processes without mixing in diagnostic or error messages. This also enables robust error handling and logging, as error streams can be independently monitored.
Q: When should I use Process.run versus Process.start for executing external commands?
A: Use Process.run for short-lived commands where you need all of the command's output after it has fully completed. Use Process.start for long-running commands where you need to process or display output as it streams in real-time, such as build processes or continuous monitoring tools.
Q: What is the significance of exit codes in CLI tools, especially for automation?
A: Exit codes are crucial for signaling the success or failure of a CLI tool to the operating system or any calling scripts (like shell scripts or CI/CD pipelines). A zero exit code typically indicates success, while a non-zero code indicates a specific type of failure, allowing automated systems to react appropriately by continuing or halting a workflow.
Related articles
Microsoft Unveils ASSERT, Simplifying AI Behavior Testing with Text
Microsoft has launched ASSERT, an open-source framework designed to simplify AI behavior testing. It enables developers to create comprehensive, application-specific evaluations using natural language descriptions, ensuring AI systems act as intended for particular products and services. The tool translates high-level goals into structured tests, generates scenarios, scores results, and logs execution paths.
Great Question (YC W21) Seeks Applied AI Interns: A Deep Dive
As fellow developers, we’re constantly scanning the landscape for companies pushing the boundaries, especially in the rapidly evolving AI space. Great Question, a Y Combinator W21 alumnus, has caught our eye with an
Navigating the Global AI Arena: Beyond Silicon Valley's Borders
The international AI landscape presents unique challenges and opportunities, requiring developers to think beyond traditional tech hubs. Key aspects include adapting AI models to local languages and cultures, navigating the complex global supply chain for critical hardware like semiconductors, and understanding how venture capital assesses these international ventures. Success hinges on deep local market understanding, robust technical solutions for localization, and resilience against logistical hurdles.
Asus ROG Azoth Extreme Edition 20: A Golden, Hefty Keyboard Statement
The Asus ROG Azoth Extreme Edition 20 is a luxurious, weighty 75% mechanical keyboard celebrating ROG's 20th anniversary with a stunning black-and-gold design. Offering top-tier build quality, smooth linear switches, an interactive AMOLED screen, and versatile connectivity, it's a premium, albeit expensive, choice for discerning gamers and enthusiasts.
A Gamer's Co-Pilot: Pelsee P1 Pro 4K Dashcam Deal Levels Up Your Ride
The Pelsee P1 Pro 4K Front and Rear Dashcam Bundle is currently an unbeatable deal on Amazon, dropping to just $49.99 with a special coupon code. This bundle offers a high-resolution 4K front camera with a premium Sony STARVIS 2 sensor for superior low-light recording, a 1080p rear camera, and includes all necessary accessories like a 64GB memory card. It's a fantastic value for enhanced road safety and recording.
Engineering a Solution: Debugging Global Mosquito-Borne Diseases
As developers, we're constantly tasked with solving complex problems, whether it's optimizing a database query or architecting a distributed system. But what if the 'bug' we're trying to fix is biological, with global




