Build a Cross-Platform CLI with Deno in 5 minutes
Command line interfaces (“CLI”) are useful, simple to use, and in many cases, the fastest way to get something done. While there are many ways to build a CLI, Deno’s zero config, all-in-one modern tooling, ability to compile your script to a portable executable binary, makes building CLIs a breeze.
In this post, we’ll go over building a basic CLI -
greetme-cli
. It takes your name and
a color as arguments, and outputs a random greeting:
$ greetme --name=Andy --color=blue
Hello, Andy!
Through building the CLI, we’ll cover:
- Setup your CLI
- Parsing Arguments
- Interacting with Browser Methods
- Managing State
- Testing
- Compiling and Distributing
- Additional Resources
Setup your CLI
If you haven’t already, install Deno and setup your IDE.
Next, create a folder for your CLI. We’ll name ours greetme-cli
.
In that folder, create main.ts
, which will contain the logic, and
greetings.json
, which will contain
a JSON array of random greetings.
In our main.ts
:
import greetings from "./greetings.json" assert { type: "json" };
/**
* Main logic of CLI.
*/
function main(): void {
console.log(
`${greetings[Math.floor(Math.random() * greetings.length) - 1]}!`,
);
}
/**
* Run CLI.
*/
main();
When we run it, we should see a random greeting:
$ deno run main.ts
Good evening!
Cool, but not very interactive. Let’s add a way to parse arguments and flags.
Parsing Arguments
Deno will automatically parse arguments from the command into a Deno.args
array:
// The command `deno run main.ts --name=Andy --color=blue`
console.log(Deno.args); // [ "--name=Andy", "--color=blue" ]
But instead of manually parsing Deno.arg
, we can use the
flags
module from
Deno’s standard library, which is a set of modules
audited by the core team. Here’s an example:
// parse.ts
import { parse } from "https://deno.land/std@0.200.0/flags/mod.ts";
console.dir(parse(Deno.args));
When we run parse.ts
with flags and options, parse(Deno.args))
returns an
object with flags and options mapped to keys and values:
$ deno run parse.ts -a beep -b boop
{ _: [], a: 'beep', b: 'boop' }
$ deno run parse.ts -x 3 -y 4 -n5 -abc --beep=boop foo bar baz
{ _: [ 'foo', 'bar', 'baz' ],
x: 3,
y: 4,
n: 5,
a: true,
b: true,
c: true,
beep: 'boop' }
But the best part of parse()
is the ability to define types, assign default
values, and create aliases for each argument by passing an optional object:
const flags = parse(Deno.args, {
boolean: ["help", "save"],
string: [ "name", "color"]
alias: { "help": "h" }
default: { "color": "blue" }
})
For more information about parse()
, refer to
this example or
this documentation.
For our greetme-cli
example, let’s add the following flags:
-h --help Display this help and exit
-s --save Save settings for future greetings
-n --name Set your name for the greeting
-c --color Set the color of the greeting
Let’s create a new function called parseArguments
in main.ts
:
import { parse } from "https://deno.land/std@0.200.0/flags/mod.ts";
import type { Args } from "https://deno.land/std@0.200.0/flags/mod.ts";
function parseArguments(args: string[]): Args {
// All boolean arguments
const booleanArgs = [
"help",
"save",
];
// All string arguments
const stringArgs = [
"name",
"color",
];
// And a list of aliases
const alias = {
"help": "h",
"save": "s",
"name": "n",
"color": "c",
};
return parse(args, {
alias,
boolean: booleanArgs,
string: stringArgs,
stopEarly: false,
"--": true,
});
}
And also a printHelp
function that’ll console.log
information when the
--help
flag is enabled:
function printHelp(): void {
console.log(`Usage: greetme [OPTIONS...]`);
console.log("\nOptional flags:");
console.log(" -h, --help Display this help and exit");
console.log(" -s, --save Save settings for future greetings");
console.log(" -n, --name Set your name for the greeting");
console.log(" -c, --color Set the color of the greeting");
}
And finally let’s tie it all together in our main
function:
function main(inputArgs: string[]): void {
const args = parseArguments(inputArgs);
// If help flag enabled, print help.
if (args.help) {
printHelp();
Deno.exit(0);
}
let name: string | null = args.name;
let color: string | null = args.color;
let save: boolean = args.save;
console.log(
`%c${
greetings[Math.floor(Math.random() * greetings.length) - 1]
}, ${name}!`,
`color: ${color}; font-weight: bold`,
);
}
Now, let’s run the CLI with the newly supported flags:
$ deno run main.ts --help
Usage: greetme [OPTIONS...]
Optional flags:
-h, --help Display this help and exit
-s, --save Save settings for future greetings
-n, --name Set your name for the greeting
-c, --color Set the color of the greeting
$ deno run main.ts --name=Andy --color=blue
It's nice to see you, Andy!
$ deno run main.ts -n=Steve -c=red
Morning, Steve!
Looking good. But how do we add functionality for the --save
option?
Managing state
Depending on your CLI, you may want to persist state across user sessions. As an
example, let’s add save functionality via --save
flag to greetme-cli
.
We can add persistant storage to our CLI using Deno KV, which is a key-value data store built right into the runtime. It’s backed by SQLite locally and FoundationDB when deployed to Deno Deploy (though CLIs aren’t meant to be deployed).
Since it’s built into the runtime, we don’t need to manage any secret keys or environmental variables to get it setup. We can open a connection through one line of code:
const kv = await Deno.openKv("/tmp/kv.db");
Note we do need to pass an explicit path in .openKv()
, as the compiled binary
does not have a default storage directory set.
Let’s update our main
function to use Deno KV:
- function main(inputArgs: string[]): void {
+ async function main(inputArgs: string[]): Promise<void> {
const args = parseArguments(inputArgs);
// If help flag enabled, print help.
if (args.help) {
printHelp();
Deno.exit(0);
}
let name: string | null = args.name;
let color: string | null = args.color;
let save: boolean = args.save;
+ const kv = await Deno.openKv("/tmp/kv.db");
+ let askToSave = false;
+ if (!name) {
+ name = (await kv.get(["name"])).value as string;
+ }
+ if (!color) {
+ color = (await kv.get(["color"])).value as string;
+ }
+ if (save) {
+ await kv.set(["name"], name);
+ await kv.set(["color"], color);
+ }
console.log(
`%c${
greetings[Math.floor(Math.random() * greetings.length) - 1]
}, ${name}!`,
`color: ${color}; font-weight: bold`,
);
}
This simple addition opens a connection to Deno KV and writes the data with
.set()
if --save
option is true
. If no --name
or --color
is set in the command, it will
read the data with
.get()
.
Let’s try it out. Note that we’ll need to add the flags --unstable
to use Deno
KV, as well as --allow-read
and
--allow-write
for writing and
reading to the filesystem:
$ deno run --unstable --allow-read --allow-write main.ts --name=Andy --save
Greetings, Andy!
$ deno run --unstable --allow-read --allow-write main.ts
It's nice to see you, Andy!
The CLI remembered my name in the second command!
Interacting with Browser Methods
Sometimes you might want to offer other modes of interactivity aside from command line flags. An easy way to do that with Deno is via browser methods.
Deno offers web platform APIs
where possible, and browser methods are no exception. That means
you have access to alert()
, confirm()
, and prompt()
,
all which can be used on the command line.
Let’s update our main()
function with some interactive prompts in situations
where the flags are not set:
async function main(inputArgs: string[]): Promise<void> {
const args = parseArguments(inputArgs);
// If help flag enabled, print help.
if (args.help) {
printHelp();
Deno.exit(0);
}
let name: string | null = args.name;
let color: string | null = args.color;
let save: boolean = args.save;
const kv = await Deno.openKv("/tmp/kv.db");
let askToSave = false;
// If there isn't any name or color, then prompt.
if (!name) {
name = (await kv.get(["name"])).value as string;
+ if (!name) {
+ name = prompt("What is your name?");
+ askToSave = true;
+ }
}
if (!color) {
color = (await kv.get(["color"])).value as string;
+ if (!color) {
+ color = prompt("What is your favorite color?");
+ askToSave = true;
+ }
}
+ if (!save && askToSave) {
+ const savePrompt: string | null = prompt(
+ "Do you want to save these settings? Y/n",
+ );
+ if (savePrompt?.toUpperCase() === "Y") save = true;
+ }
if (save) {
await kv.set(["name"], name);
await kv.set(["color"], color);
}
console.log(
`%c${
greetings[Math.floor(Math.random() * greetings.length) - 1]
}, ${name}!`,
`color: ${color}; font-weight: bold`,
);
}
Now, when we run the command without flags, we’ll receive a prompt:
$ deno run --unstable --allow-read --allow-write main.ts
What is your name? Andy
What is your favorite color? blue
Do you want to save these settings? Y/n Y
Howdy, Andy!
$ deno run --unstable --allow-read --allow-write main.ts --name=Steve
Pleased to meet you, Steve!
Great! The second time reads the variables that we chose to save via the prompts.
Browser methods are a quick and simple way to add interactivity in your scripts or CLI.
Testing
Setting up a test runner in Deno is easy, since it’s built right into the runtime.
Let’s write a simple test to make sure that the CLI is parsing the input flags
properly. Let’s create main_test.ts
and register a test case using
Deno.test()
:
import { assertEquals } from "https://deno.land/std@0.200.0/testing/asserts.ts";
import { parseArguments } from "./main.ts";
Deno.test("parseArguments should correctly parse CLI arguments", () => {
const args = parseArguments([
"-h",
"--name",
"Andy",
"--color",
"blue",
"--save",
]);
assertEquals(args, {
_: [],
help: true,
h: true,
name: "Andy",
n: "Andy",
color: "blue",
c: "blue",
save: true,
s: true,
"--": [],
});
});
Now, we can run the test using deno test
with the necessary flags:
$ deno test --unstable --allow-write --allow-read
What's happening, Andy!
running 1 test from ./main_test.ts
parseArguments should correctly parse CLI arguments ... ok (16ms)
ok | 1 passed | 0 failed (60ms)
Note if you’re using VS Code, Deno tests are automatically detected and you can run them right from your IDE.
Compiling and distributing
Deno makes it easy to distribute your CLI (or any Deno program for that matter)
with deno compile
, which compiles
your JavaScript or TypeScript file into a single executable binary that will run
on all major platforms.
Let’s deno compile
our main.ts
with the flags required to run the binary:
$ deno compile --allow-read --allow-write --unstable main.ts --output greetme
Check file:///Users/andyjiang/deno/greetme-cli/main.ts
Compile file:///Users/andyjiang/deno/greetme-cli/main.ts to greetme
You should now have a greetme
binary in the same directory. Let’s run it:
$ ./greetme --name=Andy --color=blue --save
It's nice to see you, Andy!
And if we run it again:
$ ./greetme
Howdy, Andy!
Now, you can share the binary to be run on all major platforms. For an example
of how the creator of Homebrew uses deno compile
as part of their GitHub
Actions build and release workflow,
check out this blog post.
Additional Resources
While this tutorial showed how to build a CLI using Deno, it is very simple and didn’t require any third party dependencies. For more complex CLIs, having modules or frameworks can help in development.
Here are some helpful modules you can use when building your CLI (some more fun than others):
- yargs: the modern, pirate-themed successor to optimist
- cliffy: a simple and type-safe commandline framework
- denomander: a Commander.js-inspired framework for building CLIs
- tui: a simple framework for building terminal user interfaces
- terminal_images: a TypeScript module for displaying images in the terminal
- cliui: create complex multi-line CLIs
- chalk: colorizes terminal output (and here’s the Deno module)
- figlet.js: creates ASCII art from text
- dax: Cross-platform shell tools for Deno inspired by zx
Are you building something with Deno? Let us know on Twitter or in Discord.