Back

Oct 27, 2022

Oct 27, 2022

Dart CLI Foundations: CLI Tools to Streamline Your Workflow

Learn the structure of Dart CLI projects, working with input/output, reading and writing to files, compiling and installing your Dart CLI apps.

Dart on CLI.
Dart on CLI.
Dart on CLI.

Are you ready to take your software development skills to the next level? Then, it's time to dive into the world of Command Line Interface (CLI) tools with dart CLI. These tools run directly in the terminal, the familiar space for developers, and can streamline your workflow with their minimal interface and quick access.

In this series of articles, we will be exploring Dart CLI apps and how to build them. You'll learn about the structure of Dart CLI projects, handling arguments, working with input/output, reading and writing to files, and finally, compiling and installing your Dart CLI apps. Embrace the full power of the terminal and join us on this exciting journey to build CLI apps in Dart.

Getting Started

Install the Dart SDK to get started. We will be using Visual Studio Code as the development environment for our Command Line Interface (CLI) application. However, the choice of Integrated Development Environment (IDE) is not crucial and you can work with any IDE that you prefer, without affecting the learning outcome.

To create a new Dart CLI project, run the following dart command:

dart create dart_cli -t console

This will create a Dart CLI project in the respective directory.

You can run your Dart CLI application with the following dart command:

dart run bin/dart_cli.dart

Here, -t defines the template for the Dart project. Dart has several templates you can use to work on CLI, web or server-side projects.

Save the above commands

Understanding Project Structure with Dart CLI

Three of the most important things in our project structure are the bin and lib directories along with the pubspec.yaml file.

  • bin: This is a public directory that is the entry point for your Dart CLI application. This includes the executable Dart file that the CLI app will run.

  • lib: Lib includes the public libraries that your application uses. Like Dart methods, classes or any other code, this is imported into the bin directory.

  • pubspec.yaml: The Dart CLI app is a package in itself. This file contains the package/CLI app information like name, app version, any dependencies your app is using, etc. It also includes the entry point for your CLI application defined by the executables. This usually directs to the executable in the bin folder. Whenever you’ll be using your CLI app, you’ll be running this executable.

Arguments in Dart CLI

Within the bin/dart_cli.dart file, you’ll see that the main function accepts a list of strings called arguments. These are inputs provided to CLI applications that you can use to know what to do.

Arguments are broken down into the following major types relevant to this tutorial:

  • Command: The command you wish to run to execute a particular task.

  • Flags: Flags hold a boolean value. They can be used to turn on/off some flows within your program.

  • Options: Options are key-value pairs that you can define. You can also define options that accept a value from an allowed list of values, thus restricting the input that a user can provide for the option.

For example, take a look at the following command, which creates the Dart project with some additional arguments added:

$ dart create dart_cli -t console --force --[no-]pub

Save this code

  1. dart: This is the executable or the program that you want to run. You can treat it as a root-command.

  2. create: The command you want to execute.

  3. dart_cli: This is an argument for the earlier command which defines the name for the project. This is not predefined, so if there are any such arguments that a command would be accepting, then it should know about it.

  4. -t: An option where the t is the abbreviation used for the template for the project. You can also provide options like follow, -t console or --template="console".

  5. console: This is the value for the option earlier.

  6. force: This is a flag and is used to force the creation of a project even if the target directory already exists. You can also turn on certain flags by using the flags abbreviations (if any) provided for them, e.g., -f. Both of these methods will turn the respective flag on.

  7. no-pub: By default, Dart create will run pub get, which will fetch the dependencies within your pubspec.yaml. The prefix no is used to set the flag’s value to false.

Now that we are clear on the different types of arguments we can expect in our CLI application, let’s see how we can handle those arguments within the code.

Parsing Arguments in Dart CLI

You’ll see that within our main method, the arguments are provided as a list of strings. Working with them this way would be inefficient and unmanageable. Therefore, what we’ll do is parse the list of arguments into something that’s more approachable and friendlier to use.

We’ll be using the args package, which helps us define parsers for the raw command line arguments. You can add it to your project by running the following command:

$ dart pub add args

Save this code

This will add the args dependency in your pubspec file. We used the Darts package manager pub to add this dependency.

Now, take a look at the code below:

void main(List arguments) async {
 final ArgParser parser = ArgParser()
    ..addCommand("create")   
  ..addOption(
    "template",
    defaultsTo:   "t",
   // If allowed is non-null, then input will be restricted to the values
   // provided in allowed list.
      allowed:   [
        "console",
        "package"
        "web",
        "server-shelf",
       ],
     )
     ..addFlag("force",   abbr:   "f");
 final ArgResults argResults = parser.parse(arguments);
 final command = argResults.command!.name!;   
 // gets the command entered
 final isForced = argResults["force"] as bool;
}

Save this code

There are two important pieces of information here, the ArgParser and the ArgResults:

  • ArgParser: ArgParser can be used to define parsers to parse the arguments. You can then add to the parser the commands, flags and options that you expect to use in your CLI application.

  • ArgResults: Once you’ve created your parser, you can parse the raw arguments and it’ll return the ArgResults object, which will map the raw values from the arguments list to the defined commands, flags and options. This makes it easier to use the arguments from input through a concrete class. You can also treat it like a map to find different values as we did above to get values for the force flag.

I/O

The dart:io library provides us with different streams through which we can manage input/output events. Let’s take a look at them below.

Output Stream

The stdout from dart:io provides the standard output stream. This stream can be used to log outputs to the terminal, like below:

stdout.writeln("Hello World!");

Save this code

This will output "Hello World" and is a non-blocking operation. This means any other i/o operations will not be blocked from executing while this is running.

Input Stream

The stdin provides the standard input stream, which we can read/listen to get the input from the terminal, like the following:

final input = stdin.readLineSync();stdout.writeln(input);

Save this code

Here, readLineSync() listens to the input from the standard stream. This is a blocking operation, and until input is received and the user presses return, other i/o events are blocked.

Hello World with the Dart CLI.

Alternatively, if you want a non-blocking input read, then you can use the .pipe() method:

final input = await stdin.pipe(stdout);

Save this code

This method is non-blocking and async. It takes in another stream consumer like stdout, which consumes the events from the stdin stream without blocking the output stream.

Error Stream

It’s not uncommon to come across errors in your applications and catch them, or to explicitly throw errors if things don’t add up.

The dart:io library also provides a standard error stream that logs the errors to the console:

stderr.writeln("Ooops! Something's not right!")

Save this code

This will log out the given error. Also, this operation is non-blocking.

Exception Handling

Your application may throw exceptions at runtime. You should handle these exceptions when they occur, and provide the proper message to the user to tell them what went wrong. Exceptions can be easily caught with a try/catch block.

void main(List arguments) {
 try {
  // This will throw an exception as abbr can be either null or character of length 1.
 final ArgParser parser = ArgParser()..addFlag("save", abbr: "save");
      } on ArgParserException catch (e) {
  stdout.writeln("Failed while parsing arguments");
      studout.writeln(e.message);
      exit(1);
    } catch (e) {
  stdout.writeln("Something went wrong");
  studout.writeln(e.message);
          exit(1);
}
}

Save this code

Here, we used a try/catch block to catch any exceptions that might be thrown during the parsing of arguments. Also, we are defining a catch block that will only catch the exceptions of type ArgParserException. This makes it easier to work with different types of exceptions individually and provide outputs that are more helpful for those kinds of exceptions.

The last catch block will catch all other exceptions aside from the ArgParserException type, and provide a general message with the error thrown to the user.

Dart CLI Exit Codes

Exit codes are a small number that shows the success, failure or any other state of the program to the system that called it. Within Dart, there are pre-defined exit codes appropriate for certain conditions.

Generally, exit codes like 0 mean success, 1 mean warning and 2 mean an error has occurred. See more Dart exit codes here: src.

The dart:io library defines a top-level property called exitCode, which you can change to set the exit code for the running program:

void readFile(String path) {
 try {
  final file = File(path);
  file.readAsLines();
 } on FileSystemException catch (e) {
   exitCode = 2;
 }
}

Save this code

Setting up a new exitCode doesn’t terminate the program. The program will continue to execute until it’s complete or an error occurs.

Along with the exitCode, there is also a top-level method exit(exit_code), which sets the exitCode to the given code and terminates the program immediately.

Reading/Writing to Files

As with any other type of application, when working with a CLI, you may need to access files on a user's system for either reading or writing purposes. The dart:io library provides ways to access files on the system. Here’s how you can do that:

Reading a File

final notesFile = File("./bin/notes.txt");
final notes = notesFile.readAsLinesSync();
for (String note in notes) {
       stdout.writeln(note);
   }

Save this code

The File object is part of dart:io and has utility methods that you can use for reading/writing to a file at the given path.

Here, you’re reading from a file named notes.txt within the bin folder, as shown below:

Learn Dart on CLI.🙌
Practice what I learned.
🧑🏽‍💻Build something cool with it.🔥

Save this code

The file is read as lines, and then you log each line to the output.

Output:

$ dart run bin/dart_cli.dart
Learn Dart on CLI.🙌
Practice what I learned.🧑🏽‍💻
Build something cool with it.🔥

Save this code

Writing to a File

final newNotes = [
 "Having fun learning Dart on CLI!🤩",
 "Dart makes it so easy to write cli apps.👍🏽",
 "Can't wait to build something cool with it!💙",
      ];
 final notesFile = File("./bin/notes.txt");
 final notesString = newNotes.join("\n");
      notesFile.writeAsStringSync(
          notesString,
// FileMode.append will add the new content at the end of the file.
            mode: FileMode.append,
//   FileMode.write will override the existing file content with the new content.
 // mode: FileMode.write,
    );

Save this code

You can see that here we have defined some new notes. We are writing them to a notes file by using .writeAsStringSync. Before writing to the file, we joined the existing notes with \\n line delimiters because each note will be written on a new line.

Next, set the FileMode.append, which will add the new content to the end of the file. You can set it to .write if you want to override the existing content of the file.

File has many other utility methods that you may find useful. Some of the most common ones are shown below:

// Creates the file at the path the File object was initialized with.
    file.create();
 // Checks if the file exists.
    file.exists();
 // Deletes the file.
    file.delete();
 // Listens to changes on file.
    file.watch().listen((event) {
 // File changed. Do something.
   });

Save this code

Styling Your Application

Oftentimes, CLI applications can have a less appealing visual experience as coloring the output text, adding a colorful background to text and changing how users can interact with the app is challenging. This is definitely not an easy task; there are a lot of factors that go into getting this right.

Next, we’ll take a look at a logger package that makes it easier for us to style our CLI.

Adding Mason Logger

We can use Mason logger to log stylized and more interactive outputs to the terminal.

To use it, add it as a dependency in your pubspec.yaml, or just run the following command that will add it for you:

$ dart pub add mason_logger

Save this code

Stylized Output

Mason provides us with a range of different output levels like info, warning, error, success, etc. It also allows us to add a background color to text, as in the example below.

import 'package:mason_logger/mason_logger.dart';void main(List arguments) async {
 final logger = Logger();
    logger.info('\nThis is an info.🔵\n');
    logger.warn('\nThis is a warning.🌕');
    logger.err('\nThis is an error.🔴');
    logger.success('\nThis is success!🟢');
    logger.success(
          backgroundBlue.wrap("\nLook! A text with colored background!🥳"),
    );
}

Save this code

Output:

Project output.

Rich Interactions

Mason can not only help with styling output, but also with implementing more common types of interactive outputs with rich experiences like below:

import 'package:mason_logger/mason_logger.dart';void main(List arguments) async {
 final logger = Logger();
 // Confirmation message
 final isConfirmed =
                logger.confirm("This is a confirmation messsage with yes and no option.");
 if (isConfirmed) {
 // User permitted the action.
   }
 // Single choice selection
 final selectedChoice = logger.chooseOne("Options picker", choices: [
 "Choice1",
 "Choice2",
 "Choice3",
      ]);
 logger.info("Selected Choice-->$selectedChoice");
 // Multiple choice selection
 final selectedListOfChoices = logger.chooseAny("Options picker", choices: [
 "Choice1",
 "Choice2",
 "Choice3",
 "Choice4",
 "Choice5",
  ]);

Save this code

Output:

Output of the Dart CLI code above showing an options picker in the terminal.

Compiling

Once you’re done with your app, it’s time to compile it so you can distribute it to stores, or anywhere else you like.

While in the dev process, the application runs on top of Dart VM, which is optimized for faster performance and execution times.

For production, you can use the Dart compiler. This offers AOT compilation to compile the program to native machine code:

$ dart compile exe bin/dart_cli.dart

Save this code

This will generate the application executable in your bin folder.

You can run the executable like so:

$ ./bin/dart_cli.exe

Save this code

Cross-compilation support for Dart CLI is being worked on. You can follow its progress here.

Another interesting fact about the executables is that they can be run without installing Dart SDK on the system they’re running on. This is because the executables are self-contained with everything needed to run properly.

Installing Dart CLI

Besides building the executable, you can also directly install the CLI app through pub. However, before this step, make sure you’ve mentioned the executables in your pubspec.yaml, which you’ll want to activate:

..
...
executables:
    dart_cli: dart_cli # value can be left empty and is inferred from the key
    other_executable: todo_cli
..
...

Save this code

To add the executable, you’ll need to provide a key-value pair. If the value is not provided, then it’s inferred from the key.

Then, run the following command to activate values globally:

$ dart pub global activate --source path

Save this code

Next, provide the path of the project. If you’re in the project’s directory, provide the project_path as . .

This will globally activate the CLI application by installing the executable on your system.

You can run the application from anywhere on your system like so:

$ dart pub global activate --source path

Save this code

Summary on Dart CLI

So, you took a leap of faith in learning to build CLI apps with Dart. You learned the basics of i/o, working with files, HTTP requests and more in Dart. Along with this, you also looked into how to compile and install CLI apps to use them on your system.

Working with Dart on CLI applications is really amazing. The pace at which it’s evolving is astonishing to me. I’ve never grown so fond of a language that makes building CLI tools so easy. There are a lot of interesting things that you can do with Dart right now. You can learn more about them here.

Going forward in this series, we’ll explore building casual/utility CLI apps with Dart and maybe some CLI games! 👀 Who knows? 🤫

Make sure you subscribe to the blog to get updated when the next article drops for this series. 💙 Have an amazing day and see you later :)

Rutvik Tak.
Rutvik Tak.

Written by

Written by

Rutvik Tak

Rutvik Tak

SHARE

SHARE

Title

Title

our newsletter

Sign up for The Pieces Post

Check out our monthly newsletter for curated tips & tricks, product updates, industry insights and more.

our newsletter

Sign up for The Pieces Post

Check out our monthly newsletter for curated tips & tricks, product updates, industry insights and more.

our newsletter

Sign up for The Pieces Post

Check out our monthly newsletter for curated tips & tricks, product updates, industry insights and more.