Articles / TypeScript

Build Command Line Tools in TypeScript: A Step-by-Step Guide

Nico Devs·
Build Command Line Tools in TypeScript: A Step-by-Step Guide

How far is Tokyo from Buenos Aires? You could open a map, drop two pins, and squint at the scale bar. Or you could type a single command:

npx @nicodevs/howfar Tokyo

That's what we're building today: a command-line tool in TypeScript that tells you how far any city is from yours. We'll publish it to npm, so anyone with Node.js can run it from any folder without installing anything.

It follows the same structure I use in climail, my command-line email client, and it prints JSON, so both people and AI agents can use its output.

I know, I know, measuring distances is a simple example. But you can swap the distance math for your own code. What carries over is how to structure the project, how to publish it, and which libraries make a CLI beautiful and useful without reinventing the wheel.

Let's dive in!

How npx Works

If you've never published a package before, that command has a few moving parts, so let's go through them one by one.

When you install Node.js, you also get npm, its package manager. npm comes with a public registry, npmjs.com, which is where packages live. When you run npm install something, npm downloads something from that registry and adds it to your project.

npx comes with npm too, and it works a little differently. Instead of adding a package to your project, it downloads it to a cache, runs the command inside it, and leaves your project untouched. That's why npx @nicodevs/howfar Tokyo works from any folder, without installing anything first.

Now, what about the @nicodevs/ part? That's called a scope. Every package on the registry needs a unique name, and short names like howfar get claimed fast (this one already is). Scopes solve that problem. When you create an account on npmjs.com, you get a scope that matches your username, and only you can publish packages under it. My npm username is nicodevs, so my package is called @nicodevs/howfar. If your npm username is jane, your package will be called @jane/howfar, and people will run it with npx @jane/howfar.

When we build our own version, I'll use @username as a placeholder. Whenever you see it, replace it with your own npm username.

Can npx run a GitHub repository too?

It can, but with a different syntax. Without the @, npx jane/howfar (or the more explicit npx github:jane/howfar) means "the howfar repository of the GitHub user jane." In that case, npx clones the repository, installs it, and runs its command, with no npm account or registry involved.

There's one catch, though. A GitHub repository usually holds the source code, not the built file that npx needs to run. In our project, the built file will live in a dist folder that we keep out of Git. So if you want to go the GitHub route, you'll need to either commit dist or make the prepare script build the project, since npm runs prepare when it installs a package from Git. Publishing to npm avoids all of that, so that's what we'll do in this article.

The Final Product

Okay, let's take a look at the final product. The first time, we run the setup wizard:

npx @nicodevs/howfar init

It asks for our city, and then for the unit we want our distances in:

┌  howfar setup
│
◇  Your city
│  Buenos Aires
│
◆  Distance unit
│  ● Kilometers
│  ○ Miles
│  ↑/↓ to navigate • Enter: confirm
└

Once we pick a unit and press Enter, it saves our answers and tells us what to try next:

◇  Distance unit
│  Kilometers
│
◆  Saved config to ~/.config/howfar.conf
│
└  Done. Try: npx @nicodevs/howfar Tokyo

That's it. From now on, we can ask how far any city is from home:

npx @nicodevs/howfar Tokyo
{
  "ok": true,
  "from": "Buenos Aires, Argentina",
  "city": "Tokyo, Japan",
  "latitude": 35.6895,
  "longitude": 139.69171,
  "distance": 18370,
  "unit": "km",
  "summary": "Tokyo is 18,370 km from Buenos Aires"
}

We can also measure several cities at once:

npx @nicodevs/howfar "Tokyo, San Francisco"
{
  "ok": true,
  "from": "Buenos Aires, Argentina",
  "cities": [
    {
      "city": "Tokyo, Japan",
      "latitude": 35.6895,
      "longitude": 139.69171,
      "distance": 18370,
      "unit": "km",
      "summary": "Tokyo is 18,370 km from Buenos Aires"
    },
    {
      "city": "San Francisco, United States",
      "latitude": 37.77493,
      "longitude": -122.41942,
      "distance": 10411,
      "unit": "km",
      "summary": "San Francisco is 10,411 km from Buenos Aires"
    }
  ]
}

And when we want to measure from somewhere else, --from overrides our home city for a single run, as in npx @nicodevs/howfar Tokyo --from Lima.

Pretty cool, right? It's already published, so you can try any of these commands right now. And the full source code is on GitHub.

The Building Blocks

A CLI has to parse arguments, ask the user questions, remember settings between runs, and check those settings before trusting them. Rather than one big framework, we'll use a small package for each of those jobs. Three of them come from the UnJS collection, which is a set of small, well-maintained packages that work anywhere JavaScript runs.

  • citty defines commands and parses arguments. You describe a command as an object (name, description, args, run function) and citty handles --flags, positional arguments, and the --help screen.
  • @clack/prompts draws interactive prompts, such as text inputs, password fields, and select menus. It's what draws the init wizard you just saw.
  • rc9 reads and writes config files in the user's home directory, so the tool remembers its settings between runs.
  • zod validates that config before we trust it.

Then, for tooling, we'll use Vite+, a single binary called vp that bundles a formatter, a linter, a test runner, a bundler, and Git hooks. Before Vite+, I had five dev dependencies and five config files for that. Now I have one of each.

And finally, to find each city, we'll call the Open-Meteo geocoding API, which is free, needs no key, and returns the city's coordinates. The distance between two sets of coordinates is a formula we'll write ourselves, so that's the only API we need.

Creating the Project

Alright, let's get started. First, let's install the global vp binary:

curl -fsSL https://vite.plus | bash

Now, let's create the folder and initialize the package. We'll also add vite-plus as a dev dependency, so the project pins its own version and our config file can import from it. The odd-looking vite@npm:... entry installs the Vite+ core under the name vite, which is the name the test runner looks for. Without it, npm test fails with Cannot find package 'vite':

mkdir howfar && cd howfar
git init
npm init -y
npm install citty @clack/prompts rc9 zod
npm install -D typescript vite-plus @types/node vite@npm:@voidzero-dev/vite-plus-core

npm install crashes with Cannot read properties of null (reading 'edgesOut')?

That's a bug in npm 10, the version bundled with Node 22, when it resolves the peer dependencies of vite-plus. You can fix it by upgrading npm with npm install -g npm@11, or by switching to Node 24, which ships with it. Then, run the install again.

Then, let's open package.json and make it look like this:

package.json
{
  "name": "@username/howfar",
  "version": "0.1.0",
  "description": "How far any city is from yours, as JSON.",
  "license": "MIT",
  "type": "module",
  "bin": {
    "howfar": "dist/howfar.mjs"
  },
  "files": ["dist"],
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "lint": "vp lint",
    "format": "vp fmt",
    "test": "vp test run",
    "type-check": "tsc --noEmit",
    "build": "vp run type-check && vp pack",
    "prepare": "vp config --no-agent",
    "prepublishOnly": "npm run build"
  },
  "dependencies": {
    "@clack/prompts": "^1.8.1",
    "citty": "^0.2.2",
    "rc9": "^3.1.0",
    "zod": "^4.6.7"
  },
  "devDependencies": {
    "@types/node": "^26.6.6",
    "typescript": "^7.0.2",
    "vite": "npm:@voidzero-dev/vite-plus-core@^1.4.0",
    "vite-plus": "^1.4.0"
  }
}

First, the name uses the scope we talked about at the beginning, so remember to replace @username with your npm username.

Then, there are three keys that do the heavy lifting for a CLI:

  • bin maps a command name to a file. When someone runs npx @username/howfar, npm executes dist/howfar.mjs. If they install the package globally, the command they type is still just howfar.
  • files limits what gets published to the dist folder. Nobody needs our TypeScript sources or tests in their node_modules.
  • prepublishOnly runs the build right before npm publish, so you can never publish stale output.

Next, let's add the TypeScript config:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "outDir": "dist",
    "rootDir": ".",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "verbatimModuleSyntax": true,
    "noUncheckedIndexedAccess": true,
    "types": ["node"]
  },
  "include": ["bin", "commands", "lib"]
}

I'd never turn off strict or noUncheckedIndexedAccess. The second one types array[0] as T | undefined, which is the truth, and it will catch a whole category of bugs before your users do.

And the types line matters more than it looks. Since TypeScript 6, the compiler no longer loads every @types package automatically, so without it, process is an unknown name and the entry point won't compile.

Now, let's add the Vite+ config:

vite.config.ts
import { defineConfig } from 'vite-plus'

export default defineConfig({
  fmt: {
    semi: false,
    singleQuote: true,
    ignorePatterns: ['dist/**', '**/*.md'],
  },
  lint: {
    ignorePatterns: ['dist/**'],
    rules: {
      curly: ['error', 'all'],
    },
  },
  pack: {
    entry: ['bin/howfar.ts'],
    format: ['es'],
    platform: 'node',
    target: 'node22',
  },
  staged: {
    '*.{ts,js}': ['vp check --fix', 'vp test related --run'],
  },
})

Here, the pack block is the important one. It tells Vite+ to take bin/howfar.ts, follow every import, and bundle the result into a single ES module for Node 22. The staged block sets up a pre-commit hook that formats and lints the files you're about to commit, then runs the tests that import them. The prepare script we added earlier installs that hook the first time you run npm install.

Finally, let's add a .gitignore:

.gitignore
node_modules/
dist/

Then, let's run npm install once more so the prepare script installs the Git hook. And with that, the setup is done.

The Folder Structure

Before we write the first command, here's the layout we're building toward. I settled on it after trying a few, and it's the one climail uses too:

howfar/
├── bin/
│   └── howfar.ts        # entry point: wires commands together
├── commands/
│   ├── init.ts          # one file per command
│   └── lookup.ts
├── lib/
│   ├── cli.ts           # shared argument parsers
│   ├── config.ts        # read, validate and write the config file
│   └── distance.ts      # finds cities and measures between them
├── tests/
│   ├── cli.test.ts
│   ├── distance.test.ts
│   └── lookup.test.ts
├── package.json
├── tsconfig.json
└── vite.config.ts

The layout stays tidy as long as commands stay thin and libraries stay thick. A file in commands/ reads its arguments, calls into lib/, and returns a result. Everything that does actual work lives in lib/, where it can be imported, reused, and tested on its own, with no terminal involved.

The Entry Point

Okay, let's start writing code. The file in bin/ is what runs when someone types howfar. Its only job is to collect the commands and dispatch to the right one.

bin/howfar.ts
#!/usr/bin/env node

import { defineCommand, runCommand, showUsage, type CommandDef } from 'citty'
import { lookup } from '../commands/lookup.js'

const subCommands: Record<string, CommandDef<any>> = {}

const main = defineCommand({ ...lookup, subCommands })

async function run() {
  const argv = process.argv.slice(2)
  const [name, ...rest] = argv
  const subCommand = name ? subCommands[name] : undefined
  const wantsHelp = argv.includes('--help') || argv.includes('-h')

  if (wantsHelp) {
    await (subCommand ? showUsage(subCommand, main) : showUsage(main))

    return
  }

  const command = subCommand ?? lookup
  const { result } = await runCommand(command, { rawArgs: subCommand ? rest : argv })

  if (result !== undefined) {
    console.log(JSON.stringify(result, null, 2))
  }
}

run().catch((error: unknown) => {
  console.error(error instanceof Error ? error.message : error)
  process.exit(1)
})

Let's break this down:

  • The first line, #!/usr/bin/env node, is the shebang. It means "execute me with whatever node is on the user's PATH". Vite+ keeps it in the bundled output, so the published file is directly executable.
  • process.argv.slice(2) drops the first two entries (the Node binary and the script path) and leaves us with what the user actually typed. The first word is either a subcommand name or a city.
  • If the first word matches a subcommand, we run it with the rest of the arguments. Anything else is a city, so the whole list goes to the default lookup command. That's what lets users type howfar Tokyo instead of howfar lookup Tokyo. The subcommand list is empty for now, but init will land there soon.
  • main is the lookup command with the subcommands attached. We only use it for --help, so the top-level help screen lists both the city argument and every subcommand.
  • Every command returns its result instead of printing it. The entry point is the only place that calls console.log, and it always prints JSON.

That last point is deliberate. When output is JSON, your tool works in a pipeline: npx @username/howfar Tokyo | jq .distance, a shell script, a cron job, or an AI agent that runs your command and reads the result. Plain text is nicer to look at, but much harder to build on. climail prints JSON for exactly this reason, and it's what makes it useful to agents.

And then the catch at the bottom turns any thrown error into a single line on stderr and a non-zero exit code. Without it, a misspelled city would dump a stack trace at the user, which is fine for us and terrible for them.

Why the .js extension when the file is called lookup.ts?

That's how ES modules with NodeNext resolution work: imports point at the file that will exist after compilation. TypeScript knows to look for the .ts source, and the bundler does the same. It looks wrong the first time, but then you stop noticing it.

The First Command

Each command lives in its own file and exports a defineCommand object. Here's lookup, the default command. For now, it takes the city to measure the distance to, and a --from flag with the city to measure from:

commands/lookup.ts
import { defineCommand } from 'citty'
import { fetchDistance } from '../lib/distance.js'

const lookup = defineCommand({
  meta: { name: 'howfar', description: 'Distance between two cities, as JSON' },
  args: {
    city: {
      type: 'positional',
      required: true,
      description: 'City to measure the distance to',
    },
    from: {
      type: 'string',
      required: true,
      description: 'City to measure the distance from',
    },
  },
  run: async ({ args }) => {
    return fetchDistance(args.city, args.from)
  },
})

export { lookup }

The args object is the whole argument parser. citty reads it to validate input, to generate the help screen, and to type args inside run. A positional argument is a bare word, like Tokyo, and a string argument is a flag, like --from Lima or --from=Lima. Since we marked both as required, args.city and args.from are typed as string in the run function, with no casting needed. A few lines of declaration, and --help already works.

Next, let's write the distance logic, which goes in lib/:

lib/distance.ts
type Place = { name: string; country: string; latitude: number; longitude: number }

type Unit = 'km' | 'mi'

const EARTH_RADIUS: Record<Unit, number> = { km: 6371, mi: 3959 }

async function geocode(city: string): Promise<Place> {
  const url = new URL('https://geocoding-api.open-meteo.com/v1/search')
  url.searchParams.set('name', city)
  url.searchParams.set('count', '1')

  const response = await fetch(url)
  const data = (await response.json()) as { results?: Place[] }
  const place = data.results?.[0]

  if (!place) {
    throw new Error(`Could not find a city called "${city}"`)
  }

  return place
}

function toRadians(degrees: number): number {
  return (degrees * Math.PI) / 180
}

/** Shortest distance over the Earth's surface between two places, using the haversine formula. */
function distanceBetween(a: Place, b: Place, unit: Unit): number {
  const latitude = toRadians(b.latitude - a.latitude)
  const longitude = toRadians(b.longitude - a.longitude)
  const h =
    Math.sin(latitude / 2) ** 2 +
    Math.cos(toRadians(a.latitude)) * Math.cos(toRadians(b.latitude)) * Math.sin(longitude / 2) ** 2

  return Math.round(2 * EARTH_RADIUS[unit] * Math.asin(Math.sqrt(h)))
}

function describe(place: Place, from: Place, unit: Unit) {
  const distance = distanceBetween(from, place, unit)

  return {
    city: `${place.name}, ${place.country}`,
    latitude: place.latitude,
    longitude: place.longitude,
    distance,
    unit,
    summary: `${place.name} is ${distance.toLocaleString('en-US')} ${unit} from ${from.name}`,
  }
}

async function fetchDistance(city: string, from: string, unit: Unit = 'km') {
  const [place, origin] = await Promise.all([geocode(city), geocode(from)])

  return { ok: true, from: `${origin.name}, ${origin.country}`, ...describe(place, origin, unit) }
}

export { geocode, distanceBetween, fetchDistance }

export type { Place, Unit }

The geocoding API gives us each city's latitude and longitude, and distanceBetween does the rest. It uses the haversine formula, which takes two points on a sphere and returns the length of the shortest path between them along its surface. The formula works with angles in radians, so toRadians converts the degrees we get from the API first. Then, multiplying by the radius of the Earth in kilometers or miles gives us the distance in that unit. And since fetchDistance geocodes both cities with Promise.all, the two requests run in parallel.

Why not just subtract the coordinates?

Because a degree of longitude doesn't have a fixed length. At the equator it's about 111 km, and it shrinks to zero at the poles, where every meridian meets. The haversine formula accounts for that. It does treat the Earth as a perfect sphere, which it isn't, so the result can be off by up to about 0.5%. For "how far is Tokyo?", that's close enough.

Nothing in this file knows it's part of a CLI. It takes strings, returns objects, and throws a plain Error when something goes wrong. That's what makes it easy to test later, and it's why the "thin commands, thick libraries" rule matters.

Alright, it's time to try it. Let's build and run:

npm run build
node dist/howfar.mjs Tokyo --from "Buenos Aires"
{
  "ok": true,
  "from": "Buenos Aires, Argentina",
  "city": "Tokyo, Japan",
  "latitude": 35.6895,
  "longitude": 139.69171,
  "distance": 18370,
  "unit": "km",
  "summary": "Tokyo is 18,370 km from Buenos Aires"
}

We also get the help screen for free:

node dist/howfar.mjs --help
Distance between two cities, as JSON (howfar)

USAGE howfar [OPTIONS] <CITY> --from=<from>

ARGUMENTS

  CITY    City to measure the distance to (Required)

OPTIONS

  --from=<from>    City to measure the distance from (Required)

Every command we add from here follows the same shape: one file, one defineCommand, one returned result.

A Config File and an Init Wizard

Typing --from "Buenos Aires" on every run gets old fast. Real tools remember your settings, so we'll store your city and preferred unit in a config file. After that, --from becomes optional, and every lookup measures from home unless you say otherwise. Then, we'll add the init command that asks for them.

Let's start with the config module. rc9 handles the file, and zod handles the shape:

lib/config.ts
import { z } from 'zod'
import { readUserConfig, writeUserConfig } from 'rc9'

// Lives at ~/.config/howfar.conf so the tool works from any directory.
const RC_NAME = 'howfar.conf'

const configSchema = z.object({
  home: z.string(),
  unit: z.enum(['km', 'mi']).default('km'),
})

type Config = z.infer<typeof configSchema>

function loadConfig(): Config | null {
  const raw = readUserConfig(RC_NAME)

  if (!raw.home) {
    return null
  }

  const result = configSchema.safeParse(raw)

  if (!result.success) {
    throw new Error(`Configuration validation failed:\n${z.prettifyError(result.error)}`)
  }

  return result.data
}

function resolveConfig(from?: string) {
  const config = loadConfig()
  const origin = from ?? config?.home

  if (!origin) {
    throw new Error('No starting city. Run "npx @username/howfar init" or pass --from.')
  }

  return { from: origin, unit: config?.unit ?? 'km' }
}

function saveConfig(config: Config) {
  writeUserConfig(config, RC_NAME)
}

export { loadConfig, resolveConfig, saveConfig }

export type { Config }

Here's what's happening:

  • readUserConfig looks for howfar.conf in ~/.config (or wherever $XDG_CONFIG_HOME points). We never build that path ourselves, so it's correct on macOS, Linux, and Windows.
  • The file on disk is just text. zod's safeParse is where we turn it into a Config we can trust. If someone edits the file by hand and types unit=miles, the tool stops right there, with an error that names the field and the values it accepts.
  • loadConfig returns null when nothing is configured. The init wizard uses it to prefill the prompts with existing values when you run it again.
  • resolveConfig is what commands use. When you pass --from, that city wins. Without it, the tool measures from your home city. With neither, it throws a helpful error instead of guessing.

Here's the error for that hand-edited unit=miles:

Configuration validation failed:
✖ Invalid option: expected one of "km"|"mi"
  → at unit

With the config in place, we can build the wizard:

commands/init.ts
import { defineCommand } from 'citty'
import { intro, outro, text, select, isCancel, cancel, log } from '@clack/prompts'
import { loadConfig, saveConfig } from '../lib/config.js'

function required(value: string | undefined) {
  return value ? undefined : 'Required'
}

async function runInit() {
  intro('howfar setup')

  const existing = loadConfig()

  const home = await text({
    message: 'Your city',
    placeholder: 'Buenos Aires',
    initialValue: existing?.home ?? '',
    validate: required,
  })
  if (isCancel(home)) {
    return cancel('Setup cancelled')
  }

  const unit = await select({
    message: 'Distance unit',
    options: [
      { value: 'km', label: 'Kilometers' },
      { value: 'mi', label: 'Miles' },
    ],
    initialValue: existing?.unit ?? 'km',
  })
  if (isCancel(unit)) {
    return cancel('Setup cancelled')
  }

  saveConfig({ home, unit })

  log.success('Saved config to ~/.config/howfar.conf')
  outro('Done. Try: npx @username/howfar Tokyo')
}

const init = defineCommand({
  meta: { name: 'init', description: 'Setup wizard for your city' },
  run: () => runInit(),
})

export { init }

There are two details worth pointing out here. First, every prompt is followed by an isCancel check, because the user can press Ctrl+C at any point and clack returns a cancel symbol instead of a value. Without that check, you'd save a cancel symbol as someone's city. Second, init returns nothing, so the entry point prints nothing. The wizard draws its own UI, so a JSON blob after the outro line would look broken.

Next, let's update lookup to use the config. --from is no longer required, and resolveConfig decides where to measure from:

commands/lookup.ts
import { defineCommand } from 'citty'
import { resolveConfig } from '../lib/config.js'
import { fetchDistance } from '../lib/distance.js'

const lookup = defineCommand({
  meta: { name: 'howfar', description: 'Distance from your city to another, as JSON' },
  args: {
    city: {
      type: 'positional',
      required: true,
      description: 'City to measure the distance to',
    },
    from: {
      type: 'string',
      required: false,
      description: 'City to measure the distance from (defaults to yours)',
    },
  },
  run: async ({ args }) => {
    const { from, unit } = resolveConfig(args.from)

    return fetchDistance(args.city, from, unit)
  },
})

export { lookup }

Notice that lib/distance.ts didn't change at all. fetchDistance already took a starting city and a unit, so the config only had to decide which values to pass. That's the payoff of thin commands.

Then, let's register init in the entry point:

bin/howfar.ts
import { init } from '../commands/init.js'
import { lookup } from '../commands/lookup.js'

const subCommands: Record<string, CommandDef<any>> = {
  init,
}

That's all it takes. Let's build, run the wizard once, and measure Tokyo again, this time without --from:

npm run build
node dist/howfar.mjs init
node dist/howfar.mjs Tokyo
{
  "ok": true,
  "from": "Buenos Aires, Argentina",
  "city": "Tokyo, Japan",
  "latitude": 35.6895,
  "longitude": 139.69171,
  "distance": 18370,
  "unit": "km",
  "summary": "Tokyo is 18,370 km from Buenos Aires"
}

We get the same result, and from now on, --from is only for the times we want to measure from somewhere else.

Multiple Cities and Validation

Alright, now we're going to add the second feature: several cities at once, each with its distance from yours. This is where beginners usually pile the parsing into run(), next to everything else, and a month later, nobody wants to touch it. The fix is a tiny module for parsing arguments:

lib/cli.ts
/** Split a comma-separated list into one or more city names. */
function parseCities(value: string): [string, ...string[]] {
  const [first, ...rest] = value.split(',').map((city) => city.trim())

  if (!first || rest.some((city) => city === '')) {
    throw new Error(`Expected a comma-separated list of cities, got "${value}"`)
  }

  return [first, ...rest]
}

export { parseCities }

The return type, [string, ...string[]], says there's always at least one city. That's what lets the command read cities[0] as a string without noUncheckedIndexedAccess complaining.

Why throw on an empty city instead of skipping it?

Because Tokyo,,Lima is almost always a typo. Silently dropping the empty entry hides the mistake, while a clear error tells the user (or the agent) exactly what went wrong. Parsing the raw string myself costs a few lines and gives me full control over that message.

Next, let's add a fetchDistances function to lib/distance.ts, right below fetchDistance:

lib/distance.ts
async function fetchDistances(cities: string[], from: string, unit: Unit = 'km') {
  const [places, origin] = await Promise.all([Promise.all(cities.map(geocode)), geocode(from)])

  return {
    ok: true,
    from: `${origin.name}, ${origin.country}`,
    cities: places.map((place) => describe(place, origin, unit)),
  }
}

export { geocode, distanceBetween, fetchDistance, fetchDistances }

The starting city and the whole list are geocoded in parallel, so measuring five cities takes about as long as measuring one. The starting city is also geocoded only once, no matter how many cities we measure.

And then the command itself, which is now almost entirely wiring:

commands/lookup.ts
import { defineCommand } from 'citty'
import { resolveConfig } from '../lib/config.js'
import { parseCities } from '../lib/cli.js'
import { fetchDistance, fetchDistances } from '../lib/distance.js'

const lookup = defineCommand({
  meta: { name: 'howfar', description: 'Distance from your city to one or more cities, as JSON' },
  args: {
    city: {
      type: 'positional',
      required: true,
      description: 'City, or several separated by commas',
    },
    from: {
      type: 'string',
      required: false,
      description: 'City to measure the distance from (defaults to yours)',
    },
  },
  run: async ({ args }) => {
    const { from, unit } = resolveConfig(args.from)
    const cities = parseCities(args.city)

    return cities.length === 1
      ? fetchDistance(cities[0], from, unit)
      : fetchDistances(cities, from, unit)
  },
})

export { lookup }

Let's rebuild and try it:

node dist/howfar.mjs "Tokyo, San Francisco"
{
  "ok": true,
  "from": "Buenos Aires, Argentina",
  "cities": [
    {
      "city": "Tokyo, Japan",
      "latitude": 35.6895,
      "longitude": 139.69171,
      "distance": 18370,
      "unit": "km",
      "summary": "Tokyo is 18,370 km from Buenos Aires"
    },
    {
      "city": "San Francisco, United States",
      "latitude": 37.77493,
      "longitude": -122.41942,
      "distance": 10411,
      "unit": "km",
      "summary": "San Francisco is 10,411 km from Buenos Aires"
    }
  ]
}

That's a second feature, and it took five minutes because the structure was already there.

Adding Tests

Now let's make sure this keeps working. Because the logic lives in lib/ and the commands are thin, the tests are short. Vite+ ships Vitest, so there's nothing to install.

Let's start with the pure functions. First, the city parser:

tests/cli.test.ts
import { describe, expect, it } from 'vitest'
import { parseCities } from '../lib/cli.js'

describe('parseCities', () => {
  it('returns a single city', () => {
    expect(parseCities('Tokyo')).toEqual(['Tokyo'])
  })

  it('splits and trims several cities', () => {
    expect(parseCities(' Tokyo ,San Francisco, Lima')).toEqual(['Tokyo', 'San Francisco', 'Lima'])
  })

  it.each(['', ' , ', 'Tokyo,,Lima'])('rejects %j', (value) => {
    expect(() => parseCities(value)).toThrow('Expected a comma-separated list of cities')
  })
})

Then, the distance math. distanceBetween takes two places and returns a number, so we can pass it coordinates directly, with no network request and nothing to mock:

tests/distance.test.ts
import { describe, expect, it } from 'vitest'
import { distanceBetween } from '../lib/distance.js'

const buenosAires = {
  name: 'Buenos Aires',
  country: 'Argentina',
  latitude: -34.61315,
  longitude: -58.37723,
}
const tokyo = { name: 'Tokyo', country: 'Japan', latitude: 35.6895, longitude: 139.69171 }

describe('distanceBetween', () => {
  it('measures the distance in kilometers', () => {
    expect(distanceBetween(buenosAires, tokyo, 'km')).toBe(18370)
  })

  it('measures the distance in miles', () => {
    expect(distanceBetween(buenosAires, tokyo, 'mi')).toBe(11415)
  })

  it('returns zero for the same place', () => {
    expect(distanceBetween(tokyo, tokyo, 'km')).toBe(0)
  })
})

Then, let's write a command test. Here, the trick is that citty's runCommand accepts a raw argument array, so we can drive a command exactly the way the terminal would, without spawning a process. We mock the distance module so no network request happens, and the config module so the test never reads the real config file on your machine:

tests/lookup.test.ts
import { describe, expect, it, vi } from 'vitest'
import { runCommand } from 'citty'
import { lookup } from '../commands/lookup.js'
import { fetchDistance, fetchDistances } from '../lib/distance.js'

vi.mock('../lib/config.js', () => ({
  resolveConfig: (from?: string) => ({ from: from ?? 'Lisbon', unit: 'km' }),
}))

vi.mock('../lib/distance.js', () => ({
  fetchDistance: vi.fn(async () => ({ ok: true })),
  fetchDistances: vi.fn(async () => ({ ok: true })),
}))

describe('lookup', () => {
  it('measures from your city by default', async () => {
    await runCommand(lookup, { rawArgs: ['Tokyo'] })

    expect(fetchDistance).toHaveBeenCalledWith('Tokyo', 'Lisbon', 'km')
  })

  it('measures from another city with --from', async () => {
    await runCommand(lookup, { rawArgs: ['Tokyo', '--from', 'Lima'] })

    expect(fetchDistance).toHaveBeenCalledWith('Tokyo', 'Lima', 'km')
  })

  it('measures several cities', async () => {
    await runCommand(lookup, { rawArgs: ['Tokyo, San Francisco'] })

    expect(fetchDistances).toHaveBeenCalledWith(['Tokyo', 'San Francisco'], 'Lisbon', 'km')
  })
})

Now, let's run them:

npm test
 ✓ tests/cli.test.ts (5 tests)
 ✓ tests/lookup.test.ts (3 tests)
 ✓ tests/distance.test.ts (3 tests)

 Test Files  3 passed (3)
      Tests  11 passed (11)

And remember the staged block from the Vite+ config we set up earlier? From now on, every commit formats and lints the files you touched and runs the tests that import them. If a test fails, the commit is rejected. I've had that hook catch more bugs than I'd like to admit.

Publishing the Package

Alright, the last step. Before publishing, it's a good idea to check that the name is free:

npm view @username/howfar

If that returns a package, you'll need to pick another name. Then, let's log in and publish. If you don't have an npm account yet, you can create one at npmjs.com first:

npm login
npm publish --access public

The --access public flag is only needed the first time, because scoped packages default to private. And that's it. The prepublishOnly script runs the type check and vp pack, and the files field makes sure only dist goes up. A minute later, anyone with Node installed can run:

npx @username/howfar init
npx @username/howfar Tokyo

npm downloads the package into a cache, finds the bin entry, and runs it. The user never installs anything globally or builds anything.

Later, to ship a new version, you can bump the number and publish again:

npm version patch
npm publish

Where to Go From Here

Everything in howfar maps one-to-one onto climail, which is a much bigger tool. climail has thirteen commands instead of two, an IMAP client instead of a geocoding API, and a config with two nested blocks instead of two fields. The bin/ file is almost identical. The lib/cli.ts file has a parseCount where we have parseCities. The init wizard asks for a host and a password instead of a city. Same skeleton, more code.

So when your tool needs to grow, you already know where things go:

  • A new feature is a new file in commands/.
  • Logic that two commands share moves to lib/.
  • A new setting is a new field in the zod schema and a new prompt in init.
  • A new argument that needs validation gets a parse function in lib/cli.ts, with a test next to it.

In Closing

We started from an empty folder and ended with a published npx command that has a default command, a flag, a subcommand, a help screen, a setup wizard, a validated config file, tests, and a pre-commit hook. The distance part was about seventy lines. The rest is a structure you can copy into your next tool tomorrow.

And the next time someone asks how far Tokyo is, you won't need to drop any pins.

If you turn one of your own scripts into a tool, I'd love to hear what you built! I'm @nicodevs.

Until next time!

More in TypeScript