Skip to content

From idea to CLI: building a Deno tool

5 min read

How I designed, structured, and shipped a cross‑platform CLI with Deno. DX tradeoffs, testing strategies, packaging tips, and a minimal skeleton you can reuse.

Deno makes it pleasantly simple to build cross‑platform command line tools: no node_modules sprawl, first‑class TypeScript, robust permissions, and single‑binary distribution if you want it.

This post captures a minimal yet production‑friendly setup I use to ship small CLIs quickly.

Why Deno for CLIs?

  1. Zero config TypeScript.
  2. Batteries included: fetch, Deno.Command, std libs.
  3. Clear permission model.
  4. Ship as script (deno run -A mod.ts), lock it (deno.json), or compile to a single binary.

If you’re targeting developer machines or CI, Deno’s ergonomics and portability are a great fit.

Collection layout

I like a flat root with a tiny src/ and clear entry points:

text
.
├─ mod.ts          # main entry (exports run function for testability)
├─ cli.ts          # argument parsing and top-level error handling
├─ deno.json       # tasks + compilerOptions
└─ src/
   ├─ commands/    # subcommands (each small and composable)
   │  ├─ hello.ts
   │  └─ version.ts
   └─ utils/       # helpers (io, colors, parsing)

This layout keeps responsibilities obvious:

  • cli.ts is the user entry (argv parsing, help).
  • mod.ts is the program entry (invokes run logic; easy to test).
  • src/ contains reusable pieces that don’t touch global process state.

A tiny argument parser

For small tools, I prefer a micro parser over a heavy dependency. Here’s a pragmatic one that covers flags, subcommands, --, and short aliases:

ts
// src/utils/args.ts
export type Parsed = {
  _: string[];
  flags: Record<string, string | boolean>;
};

export function parseArgs(argv: string[]): Parsed {
  const out: Parsed = { _: [], flags: {} };
  let i = 0;
  while (i < argv.length) {
    const a = argv[i++];
    if (a === "--") {
      out._.push(...argv.slice(i));
      break;
    }
    if (a.startsWith("--")) {
      const [k, v] = a.slice(2).split("=", 2);
      if (v === undefined) {
        // boolean or next value
        const peek = argv[i];
        if (peek && !peek.startsWith("-")) {
          out.flags[k] = peek;
          i++;
        } else {
          out.flags[k] = true;
        }
      } else {
        out.flags[k] = v;
      }
    } else if (a.startsWith("-") && a !== "-") {
      // short flags: -abc => -a -b -c (booleans)
      const shorts = a.slice(1).split("");
      for (const s of shorts) out.flags[s] = true;
    } else {
      out._.push(a);
    }
  }
  return out;
}

This gets you 90% there without a framework.

Commands: single purpose, composable

Each command is a function receiving args and returning an exit code (0 success, non‑zero failure). Keep side effects (I/O) clear and parameterized.

ts
// src/commands/hello.ts
export async function hello(
  args: { name?: string; stdout: WritableStreamDefaultWriter<string> },
) {
  const name = args.name?.trim() || "World";
  await args.stdout.write(`Hello, ${name}!\n`);
  return 0;
}
ts
// src/commands/version.ts
export async function version(
  args: { stdout: WritableStreamDefaultWriter<string> },
) {
  const ver = "1.0.0"; // optionally inject from env or build step
  await args.stdout.write(`${ver}\n`);
  return 0;
}

Program entry (mod.ts)

This is the “brains” that maps subcommands to implementations and orchestrates I/O. Keep it small and testable by accepting injected stdio and now.

ts
// mod.ts
import { parseArgs } from "./src/utils/args.ts";
import { hello } from "./src/commands/hello.ts";
import { version } from "./src/commands/version.ts";

export type Stdio = {
  stdout: WritableStreamDefaultWriter<string>;
  stderr: WritableStreamDefaultWriter<string>;
};

function writer(w: WritableStream<Uint8Array> | WritableStream<string>) {
  if ((w as WritableStream<string>).getWriter) {
    return (w as WritableStream<string>).getWriter();
  }
  // Deno.stdout/stderr are Uint8Array streams; wrap them for strings
  const text = new TextEncoder();
  const bytes = (w as WritableStream<Uint8Array>).getWriter();
  return {
    async write(s: string) {
      await bytes.write(text.encode(s));
    },
    async close() {
      await bytes.close();
    },
    releaseLock() {
      bytes.releaseLock();
    },
  } as unknown as WritableStreamDefaultWriter<string>;
}

export async function run(argv: string[], stdio?: Stdio): Promise<number> {
  const out = stdio?.stdout ?? writer(Deno.stdout.writable);
  const err = stdio?.stderr ?? writer(Deno.stderr.writable);

  const parsed = parseArgs(argv);
  const [cmd, ...rest] = parsed._;

  if (parsed.flags.h || parsed.flags.help) {
    await out.write(help());
    return 0;
  }

  try {
    switch (cmd) {
      case "hello": {
        const name = rest[0] ?? (parsed.flags.name as string | undefined);
        return await hello({ name, stdout: out });
      }
      case "version":
      case "-v":
      case "--version": {
        return await version({ stdout: out });
      }
      case undefined:
      case "":
        await out.write(help());
        return 0;
      default:
        await err.write(`Unknown command: ${cmd}\n\n${help()}`);
        return 1;
    }
  } finally {
    // writers come from getWriter(); release to avoid locking across runs/tests
    out.releaseLock?.();
    err.releaseLock?.();
  }
}

function help() {
  return `mycli

Usage:
  mycli hello [name]         Greet someone
  mycli version              Print version

Options:
  -h, --help                 Show help

Examples:
  mycli hello
  mycli hello Alice
  mycli --help
`;
}

CLI entry (cli.ts)

A tiny shell that calls run(), and sets the process exit code. Keep all logic in mod.ts so tests can exercise it without spawning processes.

ts
// cli.ts
import { run } from "./mod.ts";

const code = await run(Deno.args);
Deno.exit(code);

Tasks and config (deno.json)

Define tasks for dev, test, and compile; and pin compiler options.

json
{
  "tasks": {
    "dev": "deno run -A --watch=mod.ts,cli.ts,src cli.ts",
    "fmt": "deno fmt",
    "lint": "deno lint",
    "test": "deno test -A",
    "build": "deno compile -A --output mycli cli.ts"
  },
  "compilerOptions": {
    "lib": ["deno.ns", "dom"],
    "strict": true
  }
}

Run it locally:

bash
deno task dev
# or just:
deno run -A cli.ts hello

Build a single binary (mac/linux/windows from your OS):

bash
deno task build
./mycli hello

Testing

Because run() accepts argv + stdio, you can test command behavior without spawning a process or touching real stdout/stderr.

ts
// mod_test.ts
import { assertEquals } from "@std/assert";
import { run } from "./mod.ts";

function makeBuffer() {
  let buf = "";
  const w = new WritableStream<string>({
    write(chunk) {
      buf += chunk;
    },
  }).getWriter();
  return { writer: w, read: () => buf };
}

Deno.test("hello default", async () => {
  const out = makeBuffer();
  const err = makeBuffer();
  const code = await run(["hello"], { stdout: out.writer, stderr: err.writer });
  out.writer.releaseLock();
  err.writer.releaseLock();
  assertEquals(code, 0);
  assertEquals(out.read(), "Hello, World!\n");
});

Deno.test("version", async () => {
  const out = makeBuffer();
  const code = await run(["version"], {
    stdout: out.writer,
    stderr: makeBuffer().writer,
  });
  out.writer.releaseLock();
  assertEquals(code, 0);
  // naive check
  const v = out.read().trim();
  assertEquals(/^\d+\.\d+\.\d+$/.test(v), true);
});

This style yields fast, deterministic tests without subprocess overhead.

Packaging and distribution

  • For simple distribution, publish your repo and document deno run -A https://raw.githubusercontent.com/you/repo/main/cli.ts ....
  • For corporate environments or offline usage, ship the deno compile binary.
  • If you need reproducibility, pin versions and vendor dependencies with deno vendor (or keep imports to jsr: and standard libs).

Practical tips

  • Prefer explicit permissions; only use -A (all) if your CLI truly needs broad access.
  • Make command outputs parseable when appropriate (--json flag).
  • Keep logs to stderr, results to stdout.
  • Treat your CLI as a library: pure functions, injected I/O, no hidden globals.
  • Document a couple of copy‑pasteable examples in --help.

Closing thoughts

Deno’s philosophy and tooling make CLIs a joy: TypeScript by default, minimal friction, and clear security. Start from the skeleton above, keep modules small, and you’ll be able to ship reliable tools quickly—without a dependency avalanche.

If you want a more complete starter (with subcommand autoloading, richer flag parsing, and telemetry hooks), I can share a template I use for client collections.