Skip to main content
Deno 2 is finally here 🎉️
Learn more
How to build a CLI with Deno.

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

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" with { 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/[email protected]/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/[email protected]/flags/mod.ts";
import type { Args } from "https://deno.land/[email protected]/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/[email protected]/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.