Building CLI Apps with Dart: From Zero to a Published Tool (and Why You'd Even Bother)
Dinko Marinac
Introduction
Something funny happened to the command line over the last year. Everyone raced to ship MCP servers for their AI agents, then the benchmarks landed and showed that agents using plain old CLIs were cheaper on tokens and more reliable than the MCP equivalents. Suddenly the command line is strategic again: Claude Code is a CLI, OpenAI’s Codex is a CLI, and everyone’s shipping one.
Most dev tools now ship a CLI for deployment, provisioning, and permissions. Firebase and GCP have had theirs for years, but since Claude Code showed up plenty of others (Supabase, Vercel, Netlify, Stripe) have doubled down on serious CLIs of their own.
Most of you already know Dart can be used to build CLI tools. You have probably use some of them: patrol_cli, shorebird_cli, serverpod_cli, jaspr_cli or flutterfire_cli, to name a few.
Some of you may remember I attempted to build Dart Cloud Functions for Firebase. An ambitious undertaking that spectacularly failed (thank god the Dart team eventually took this matter into their own hands), but I learned a lot along the way.
One of the things I had to build was a CLI to support everything the framework did: project initialization, autentication, listing projects, deploying a function, deleting one, and so on. That tool, Dartblaze, is where most lessons in this post come from.
One of those lessons (which I’ll share with you later) is about how to handle auth, or any kind of permission and authentication system, inside a Dart CLI. It’s the part that taught me the most, because the moment your CLI needs to log a user in and remember who they are, things start to get messy.
The plan is as follows. I’ll start with why you’d pick Dart over Go or Rust at all, and I’ll be honest about where those languages beat it. Then I’ll show you how dead simple it is to get a working CLI off the ground. From there I’ll get into handling auth, which is where the platform-specific nonsense nobody warns you about shows up, then the packages that make a Dart CLI feel polished, and finally how to ship the thing so people can actually install it.
Let’s get into it.
Why Dart, when Go and Rust exist?
Let me be honest: if you ask a hundred engineers what to write a CLI in, and almost none will say Dart. They have good reasons.
Go is the default for a reason: tiny static binaries, dead-simple cross-compilation, and tools you’ve definitely used (the GitHub CLI gh, the Supabase CLI) are written in it. Rust wins on speed and safety. Node has the biggest package ecosystem on earth and is already installed on every machine, so half the CLIs you touch (Firebase, the Expo CLI, Vercel) run on it.
So why did I pick Dart anyway? Three reasons.
One: I already know it. This is the big one. I didn’t have to change my way of thinking, my debugging habits, my tooling, anything. Building Dartblaze’s CLI, I was fighting the problem, not the language. Every hour I would have spent juggling new syntax, a new paradigm, a new package manager, and debugging code I have no idea about went into actually shipping features. If you already use Flutter, this comes as a natural extension.
Two: I have can use FFI. Dart’s foreign function interface lets me call into Go, Rust, or C whenever I genuinely need to. So I don’t have to learn a whole new language for the 10% of problems that might need it. I can stay in Dart for the 90%, and drop down to something else only when there’s a real reason to.
Three: I wanted a binary, and Dart makes that easy. dart compile exe gives me a single self-contained executable with no runtime for the user to install, on macOS, Windows, or Linux.
None of these are “Dart is objectively the best CLI language” arguments, because it isn’t. They’re my reasons. Yours might be different. But if you already know Dart and its ecosystem, it becomes an obvious choice.
Building a CLI
Enough talk. Let me show you how little it takes to get a working CLI.
You don’t start from a blank file. Dart ships with a console template:
dart create -t cli my_cli
You now have a working command-line app, with a bin/ folder for your entry point and a pubspec.yaml already wired up. If you want something more opinionated, Very Good CLI from Very Good Ventures has a dedicated dart_cli template that scaffolds a full project structure with testing and a command setup ready to go.
💡 Tip: The Dart team published an official Dart CLI skill that your AI assistant can load. It covers entry-point structure, exit codes, argument routing, and testing, so it’s a solid thing to point Claude or Codex at before you start.
Subcommands, the way real CLIs do it
The moment your tool does more than one thing, you don’t want mytool --deploy and mytool --login, you want mytool deploy and mytool login, each with its own arguments. The first-party args package has a built-in pattern for it: CommandRunner and Command.
Each command is its own class:
import 'package:args/command_runner.dart';
class DeployCommand extends Command {
@override
final name = 'deploy';
@override
final description = 'Deploy a function to the cloud.';
@override
Future<void> run() async {
print('Deploying...');
}
}
Then you register them on a runner and let it handle the routing and the help text. This is exactly the structure I used in Dartblaze:
final runner = CommandRunner('dartblaze', 'Dartblaze CLI')
..addCommand(DoctorCommand())
..addCommand(LoginCommand())
..addCommand(LogoutCommand())
..addCommand(NewCommand())
..addCommand(DeployCommand());
await runner.run(arguments);
Every command is a small, self-contained class that knows its own name, help text, and what to do when called. You get dartblaze deploy, dartblaze --help, and dartblaze deploy --help without writing any of it yourself. It scales cleanly, and it’s the foundation everything else in this post builds on.
That’s the happy path. The not-so-simple part starts the moment that authentication kicks in.
Handling Auth
A deploy command is easy. A deploy command that knows who you are is a different animal.
Think about how firebase login or gcloud auth login work. You type the command, your browser pops open, you pick your Google account, and then the terminal just… knows who you are. Not only for that command, but for every command after it, until you log out. That’s the experience I wanted for Dartblaze. The question was how to actually build it.
Supabase made authentication easy
My backend was Supabase, and that turned out to be the thing that made this tractable. Supabase Auth already knows how to do Google OAuth, so I didn’t have to stand up my own identity server, manage Google client secrets by hand, or deal with token signing. I just had to connect the dots between “user clicks a button in a browser” and “my CLI has a valid session.”
The flow comes down to three steps: get an OAuth URL, open it in the browser, and catch the result when Google redirects back. Here’s the heart of it:
Future<void> signInWithGoogle() async {
// 1. Ask Supabase for a Google OAuth URL, pointed at a local callback.
final authResponse = await supabase.auth.getOAuthSignInUrl(
provider: OAuthProvider.google,
redirectTo: 'http://localhost:3000/callback',
);
// 2. Open it in the user's browser.
await openUrl(authResponse.url);
// 3. Spin up a local server to catch the redirect.
final server = await HttpServer.bind('localhost', 3000);
print('Waiting for authentication...');
await for (final request in server) {
final code = request.uri.queryParameters['code'];
if (code != null) {
final response = await supabase.auth.exchangeCodeForSession(code);
// Got a session. Save it, tell the user, shut the server down.
sessionManager.save(response.session);
request.response.write('Success! You can close this window.');
await request.response.close();
await server.close();
return;
}
}
}
That’s the whole trick, and it’s the same one gcloud and firebase use. You start a tiny throwaway HTTP server on localhost, tell the OAuth provider to redirect there after login, and wait for the request to land so you can swap the code for a real session. The browser handles the actual “pick your Google account” part, which is exactly what you want, because nobody should be typing their Google password into your CLI.
Staying logged in
Getting a session is only half the job. The other half is remembering it, so the next command doesn’t make the user log in all over again. On startup, my CLI tries to restore the previous session before doing anything else:
Future<void> init() async {
final storedSession = await sessionManager.load();
if (storedSession != null) {
final response = await supabase.auth.recoverSession(storedSession);
if (response.session != null) {
sessionManager.save(response.session!);
}
}
}
Load the saved session string, hand it to Supabase to recover and refresh, save the refreshed one back. Simple enough in principle. The problem is that one innocent-looking line, sessionManager.load().
Where does that session actually live on disk, and how do you keep it safe? That question is where Dart on the desktop gets genuinely annoying, and I’ll get to it in a second.
Base class for authenticated commands
Here’s a pattern I really like. Most of my commands (deploy, delete, use, projects list) only make sense if you’re logged in. I didn’t want to copy-paste an “are you authenticated?” check into every single one. So I made an abstract AuthCommand that does the gatekeeping once:
abstract class AuthCommand<T> extends Command<T> {
@override
FutureOr<T>? run() async {
if (!GetIt.I<AuthService>().isAuthenticated) {
print('You need to be signed in to run this command.');
exit(1);
}
return runAuthenticated();
}
// Subclasses implement this instead of run().
FutureOr<T> runAuthenticated();
}
Any command that needs a logged-in user extends AuthCommand and implements runAuthenticated() instead of run(). The auth check happens automatically, in one place. I registered them with a small extension so the intent reads clearly at the call site:
runner
..addCommand(LoginCommand()) // no auth needed
..addAuthCommand(DeployCommand()) // auth required
..addAuthCommand(DeleteCommand());
This is just plain Dart inheritance, but it’s the kind of thing that pays off fast. I later hung a terms-of-service acceptance check off the same base class, and every authenticated command picked it up for free, without me touching a single one of them. Any shared piece of logic across a family of commands is a candidate for this.
The desktop problem nobody warns you about
Now, back to that sessionManager.load() line. In Flutter, you’d reach for flutter_secure_storage without a second thought. It wraps the iOS Keychain and the Android Keystore, and you move on with your life.
Except this isn’t Flutter. It’s a pure Dart CLI running on a desktop, and flutter_secure_storage is a Flutter plugin. So you go looking for a pure-Dart way to write a secret to disk safely on macOS, Windows, and Linux, and you discover there isn’t an obvious, blessed answer.
You get the worst of both worlds. Because you are writing desktop software, so you inherit all the per-platform credential-store differences, but you have none of Flutter’s plugins to help you.
After some searching, I’ve found native_storage, a pure-Dart package from the Celest team that talks to each platform’s native store directly, the macOS Keychain, the Windows Credential Manager, and the Linux Secret Service, without dragging Flutter along. It’s the same library Celest uses in their own CLI, which gave me some confidence it would hold up.
If that wasn’t an option, the only other reliable choice is FFI in combination with a Rust package like keyring-core. It’s the kind of sensitive, fiddly, cross-platform problem that someone has already solved properly in another language, and the FFI enables us to leverage it in Dart.
The packages that make it not look like a school project
A bare args CLI works, but it looks like a bare args CLI. Plain white text, no spinners, no color, no “are you sure?” prompts. The gap between that and something that feels like gh or the Supabase CLI is almost entirely a matter of knowing which packages to pull in. Here are the ones I’d reach for, including a few I think are criminally underused.
mason_logger: your output layer
If you install one package from this section, make it mason_logger. It’s the logger that powers the Mason CLI, and the wider ecosystem has more or less standardized on it. You get leveled, color-coded output (info, warn, err, success, detail) out of the box:
final logger = Logger();
logger.info('Reading config...');
logger.success('Done!');
logger.err('Something broke.');
It also handles the two things people usually hand-roll badly. Progress spinners for long tasks:
final progress = logger.progress('Deploying function');
await deploy();
progress.complete('Deployed!');
And interactive prompts, including confirmations and single or multi-select menus:
final region = logger.chooseOne(
'Pick a region',
choices: ['europe-west3', 'us-central1'],
);
That covers most of what you’d otherwise reach for three separate packages to do. In Dartblaze I actually used talker for logging, which is great if you also want structured logs and crash reporting wired through to something like Sentry, but for pure terminal output mason_logger is the cleaner default.
cli_util: the boring stuff you actually need
cli_util is a first-party package that handles the unglamorous parts. The one I leaned on in Dartblaze is applicationConfigHome, which gives you the correct per-user config directory on each platform, so you’re not guessing where to stash your tool’s settings. It also locates the Dart SDK and ships its own verbose-aware logger with a spinner. Not exciting, but you’ll want it the moment your CLI needs to remember anything between runs.
cli_completion: shell completions almost nobody adds
This is the one I think is most underused. cli_completion wires up tab completion for bash, zsh, and fish, so users can hit Tab and see your commands and flags. It plugs straight into CommandRunner. Patrol, Mason, and Very Good CLI all use it, and yet almost no hobby CLI bothers. It’s a small thing that instantly makes your tool feel professional.
pub_updater: let your tool update itself
If you publish to pub.dev, the hardest part is making sure people aren’t stuck on an ancient version. pub_updater (also by Very Good Ventures) solves this. Paired with build_version, which generates a version.dart so the running tool knows its own version, every command can check pub.dev for a newer release and alert the user, and you can ship an update command so the CLI updates itself.
final isUpToDate = await PubUpdater().isUpToDate(
packageName: 'dartblaze_cli',
currentVersion: packageVersion,
);
One caveat worth knowing if you go the pub.dev route: dart pub global activate resolves your dependency versions at install time, so users can end up running your tool against package versions you never actually tested. The Very Good CLI template wires all of this up for you if you’d rather not assemble it by hand.
A few smaller ones worth knowing
ansi_styles if you want to colorize output directly without going through a logger. version for comparing semantic versions properly instead of string-matching them, which matters the second you do update checks. And pubspec_parse for reading pubspec.yaml files in a typed way, handy if your tool inspects or scaffolds Dart projects.
None of these are large or hard to adopt. But together they’re the difference between a script you tolerate and a tool you’re proud to hand to someone else.
Shipping it
You’ve got a working CLI. Now comes the part most tutorials skip: getting it onto other people’s machines, and knowing what they do with it once it’s there.
Know what’s happening in the wild
The second your tool runs on a machine that isn’t yours, you’re blind. You can’t attach a debugger to a user’s terminal. So before I shipped Dartblaze, I wired in two things: crash reporting and analytics.
Crash reporting was sentry (the pure-Dart package, not the Flutter one). You initialize it once at startup and it catches unhandled errors with stack traces:
await Sentry.init((options) {
options.dsn = Env.sentryDsn;
});
Now when a command blows up on someone else’s Windows box, I find out, instead of waiting for a confused message that may never come.
Analytics answers a different question: not “what broke” but “what do people actually use.” Which commands get run, which never do, where users give up. The one thing I’d avoid is wiring yourself to a single vendor. I’ve written before about a modular approach to analytics that lets you swap providers, or run two at once during a migration, by hiding them all behind one interface. It’s plain Dart, so it drops into a CLI unchanged, and you already have the user’s identity from the login flow to tie events to.
One word of care: a developer tool sending telemetry is a sensitive thing. Be upfront that you collect it, and ideally give people a way to turn it off. Nobody likes finding out their CLI was phoning home.
Two ways to get it onto a machine
There are really two distribution stories, and which you pick depends on your audience.
Publish to pub.dev. If your users are Dart developers, this is the lowest-friction option. You add an executables entry to your pubspec.yaml, publish, and they run dart pub global activate your_cli. That’s exactly how Patrol, Mason, and Very Good CLI ship. The catch I mentioned earlier still applies: pub global activate resolves dependency versions at install time, so a user can end up running your tool against package versions you never tested, which is worth keeping in mind for anything you really need to be stable.
Ship a compiled binary. If your users aren’t necessarily Dart developers, you don’t want to make them install the SDK. You hand them a single executable from dart compile exe and let them drop it on their PATH, or distribute through Homebrew, Chocolatey, or apt. Better startup, no toolchain required. More setup on your end.
The cross-platform catch (and how CI solves it)
Here’s the thing that trips people up with the binary route. dart compile exe only produces an executable for the platform you run it on, so to cover macOS Intel, macOS ARM, Windows, and Linux, you need to compile on each of those platforms. You’re not going to keep four machines around for this.
This is what CI is for. There’s a good Codemagic walkthrough that sets up one workflow per target OS, compiles the binary on each, and publishes the results to GitHub Releases on every tag. The same principle works just as well with GitHub Actions: a build matrix across macos, windows, and ubuntu runners, each running dart compile exe and uploading its artifact. Tag a release, and a few minutes later you’ve got binaries for every platform without owning a single extra machine.
The macOS and Windows tax
A bare compiled binary downloaded from the internet is treated as suspicious by default: macOS Gatekeeper and Windows SmartScreen will both throw scary warnings at unsigned executables. Fixing that means code signing and notarization, which costs money and is fiddly to set up, so budget time for it if you’re shipping to a non-technical audience.
Make it agentic
I opened this post by noting that agents reach for CLIs, so it’s fitting to end there. The Shorebird team wrote up what they learned handing an agent their CLI, and it’s a tidy checklist: add a --json flag so agents don’t scrape your tables, add read commands so they can observe state before doing anything destructive, and add a non-interactive mode that fails fast instead of hanging on a prompt. Their honest takeaway is that most of it was just good CLI hygiene anyway, which makes the tool better for humans too.
Conclusion
So, can you build a real CLI in Dart? Yes. Should you? If you already know Dart, almost certainly.
Dart’s package support for CLIs is great and very well maintained, with plenty of open-source examples to help you if you get stuck. Sure, there are some missing packages, but that’s what tools like FFI are made for: not reinventing the wheel every time.
And if there’s one thing to leave you with, it’s the thread that ran through this whole post: CLIs are having a moment because agents reach for them. A clean, scriptable, observable command-line tool is increasingly something both humans and machines want to use. Dart is more than capable of building one. So go build something.
If you found this useful, make sure to like and follow for more content like this. To know when new articles come out, follow me on Twitter and LinkedIn.
Until next time, happy coding!