From idea to CLI: building a Deno tool
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?
- Zero config TypeScript.
- Batteries included:
fetch,Deno.Command, std libs. - Clear permission model.
- 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:
.
├─ 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.tsis the user entry (argv parsing, help).mod.tsis 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:
// 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.
// 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;
}
// 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.
// 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.
// 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.
{
"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:
deno task dev
# or just:
deno run -A cli.ts hello
Build a single binary (mac/linux/windows from your OS):
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.
// 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 compilebinary. - If you need reproducibility, pin versions and vendor dependencies with
deno vendor(or keep imports tojsr: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 (
--jsonflag). - 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.