npx CLI Tool Development (Bun-First)
Build and publish npx-executable command-line tools using Bun as the primary runtime and toolchain, producing binaries that work for all npm/npx users (Node.js runtime).
When to Use This Skill
Use when:
- Creating a new CLI tool from scratch
- Building an npx-executable binary
- Setting up argument parsing, sub-commands, or terminal UX for a CLI
- Publishing a CLI tool to npm
- Adding a CLI to an existing library package
Do NOT use when:
- Building a library without a CLI (use the
npm-packageskill) - Building an application (not a published package)
- Working in a monorepo (this skill targets single-package repos)
Toolchain
| Concern | Tool | Why |
|---|---|---|
| Runtime / package manager | Bun | Fast install, run, transpile |
| Bundler | Bunup | Bun-native, dual entry (lib + cli), .d.ts |
| Argument parsing | citty | ~3KB, TypeScript-native, auto-help, runMain() |
| Terminal colors | picocolors | ~7KB, CJS+ESM, auto-detect |
| TypeScript | module: "nodenext", strict: true + extras |
Maximum correctness |
| Formatting + basic linting | Biome v2 | Fast, single tool |
| Type-aware linting | ESLint + typescript-eslint | Deep type safety |
| Testing | Vitest | Isolation, mocking, coverage |
| Versioning | Changesets | File-based, explicit |
| Publishing | npm publish --provenance |
Trusted Publishing / OIDC |
Scaffolding a New CLI
Run the scaffold script:
bun run <skill-path>/scripts/scaffold.ts ./my-cli \
--name my-cli \
--bin my-cli \
--description "What this CLI does" \
--author "Your Name" \
--license MITOptions:
--bin <name>โ Binary name for npx (defaults to package name without scope)--cli-onlyโ No library exports, CLI binary only--no-eslintโ Skip ESLint, use Biome only
Then install dependencies:
cd my-cli
bun install
bun add -d bunup typescript vitest @vitest/coverage-v8 @biomejs/biome @changesets/cli
bun add citty picocolors
bun add -d eslint typescript-eslint # unless --no-eslintProject Structure
Dual (Library + CLI) โ Default
my-cli/
โโโ src/
โ โโโ index.ts # Library exports (programmatic API)
โ โโโ index.test.ts # Unit tests for library
โ โโโ cli.ts # CLI entry point (imports from index.ts)
โ โโโ cli.test.ts # CLI integration tests
โโโ dist/
โ โโโ index.js # Library bundle
โ โโโ index.d.ts # Type declarations
โ โโโ cli.js # CLI binary (with shebang)
โโโ .changeset/
โ โโโ config.json
โโโ package.json
โโโ tsconfig.json
โโโ bunup.config.ts
โโโ biome.json
โโโ eslint.config.ts
โโโ vitest.config.ts
โโโ .gitignore
โโโ README.md
โโโ LICENSECLI-Only (No Library Exports)
Same structure minus src/index.ts and src/index.test.ts. No exports field in package.json, only bin.
Architecture Pattern
Separate logic from CLI wiring. The CLI entry (cli.ts) is a thin wrapper that:
- Parses arguments with citty
- Calls into the library/core modules
- Formats output for the terminal
All business logic lives in importable modules (index.ts or internal modules). This makes logic unit-testable without spawning processes.
cli.ts โ imports from โ index.ts / core modules
โ
unit testsKey Rules (Non-Negotiable)
All rules from the npm-package skill apply here. These additional rules are specific to CLI packages:
Binary Configuration
Always use
#!/usr/bin/env nodein published bin files. Never#!/usr/bin/env bun. The vast majority of npx users don't have Bun installed.Point
binat compiled JavaScript indist/. Never at TypeScript source. npx consumers won't have your build toolchain.Ensure the bin file is executable. The build script includes
chmod +x dist/cli.jsafter compilation.Build with Node.js as the target. Bunup's output must run on Node.js, not require Bun runtime features.
Package Configuration
Always use
"type": "module"in package.json.typesmust be the first condition in every exports block.Use
files: ["dist"]. Whitelist only.For dual packages (library + CLI): The
exportsfield exposes the library API. Thebinfield exposes the CLI. They are independent โbinis NOT part ofexports.
Code Quality
anyis banned. Useunknownand narrow.Use
import typefor type-only imports.Handle errors gracefully. CLI users should never see raw stack traces. Use citty's
runMain()which handles this automatically, plusprocess.on('SIGINT', ...)for cleanup.Exit with appropriate codes. 0 for success, 1 for errors, 2 for bad arguments, 130 for SIGINT.
Reference Documentation
Read these before modifying configuration:
- reference/cli-patterns.md โ bin setup, citty patterns, sub-commands, error handling, terminal UX, testing CLI binaries
- reference/esm-cjs-guide.md โ
exportsmap, dual package hazard, common mistakes - reference/strict-typescript.md โ tsconfig, Biome rules, ESLint type-aware rules, Vitest config
- reference/publishing-workflow.md โ Changesets,
filesfield, Trusted Publishing, CI pipeline
Argument Parsing with citty
Single Command
import { defineCommand, runMain } from 'citty';
const main = defineCommand({
meta: { name: 'my-cli', version: '1.0.0', description: '...' },
args: {
input: { type: 'positional', description: 'Input file', required: true },
output: { alias: 'o', type: 'string', description: 'Output path', default: './out' },
verbose: { alias: 'v', type: 'boolean', description: 'Verbose output', default: false },
},
run({ args }) {
// args is fully typed
},
});
void runMain(main);Sub-Commands
import { defineCommand, runMain } from 'citty';
const init = defineCommand({ meta: { name: 'init' }, /* ... */ });
const build = defineCommand({ meta: { name: 'build' }, /* ... */ });
const main = defineCommand({
meta: { name: 'my-cli', version: '1.0.0' },
subCommands: { init, build },
});
void runMain(main);See reference/cli-patterns.md for complete examples including error handling, colors, and spinners.
Testing Strategy
Unit Tests โ Test the Logic
// src/index.test.ts
import { describe, it, expect } from 'vitest';
import { processInput } from './index.js';
describe('processInput', () => {
it('handles valid input', () => {
expect(processInput('test')).toBe('expected');
});
});Integration Tests โ Test the Binary
Build first (bun run build), then spawn the compiled binary:
// src/cli.test.ts
import { describe, it, expect } from 'vitest';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
const exec = promisify(execFile);
describe('CLI', () => {
it('prints help', async () => {
const { stdout } = await exec('node', ['./dist/cli.js', '--help']);
expect(stdout).toContain('my-cli');
});
});Development Workflow
# Write code and tests
bun run test:watch # Vitest watch mode
# Check everything
bun run lint # Biome + ESLint
bun run typecheck # tsc --noEmit
bun run test # Vitest
# Build and try the CLI locally
bun run build
node ./dist/cli.js --help
node ./dist/cli.js some-input
# Prepare release
bunx changeset
bunx changeset version
# Publish
bun run release # Build + npm publish --provenanceAdding Sub-Commands Later
- Create a new file per sub-command:
src/commands/init.ts,src/commands/build.ts - Each exports a
defineCommand()result - Import and wire into the main command's
subCommands - Keep logic in testable modules, commands are thin wrappers
Converting a CLI-Only Package to Dual (Library + CLI)
- Create
src/index.tswith the public API - Update bunup.config.ts to include both entry points
- Add
exportsfield to package.json alongside the existingbin - Add .d.ts generation:
dts: { entry: ['src/index.ts'] }
Bun-Specific Gotchas
bun builddoes not generate .d.ts files. Use Bunup ortsc --emitDeclarationOnly.bun builddoes not downlevel syntax. ES2022+ ships as-is.bun publishdoes not support--provenance. Usenpm publish.bun publishusesNPM_CONFIG_TOKEN, notNODE_AUTH_TOKEN.- Never use
#!/usr/bin/env bunin published packages. Your users don't have Bun. - Bunup
banneradds the shebang to ALL output files, including the library entry. If this is a problem, use a post-build script to add the shebang only todist/cli.js.