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-package skill)
- 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 MIT
Options:
--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-eslint
Project 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
└── LICENSE
CLI-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 tests
Key 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 node in published bin files. Never #!/usr/bin/env bun. The vast majority of npx users don't have Bun installed.
Point bin at compiled JavaScript in dist/. 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.js after 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.
types must be the first condition in every exports block.
Use files: ["dist"]. Whitelist only.
For dual packages (library + CLI): The exports field exposes the library API. The bin field exposes the CLI. They are independent — bin is NOT part of exports.
Code Quality
any is banned. Use unknown and narrow.
Use import type for type-only imports.
Handle errors gracefully. CLI users should never see raw stack traces. Use citty's runMain() which handles this automatically, plus process.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 —
exports map, dual package hazard, common mistakes
- reference/strict-typescript.md — tsconfig, Biome rules, ESLint type-aware rules, Vitest config
- reference/publishing-workflow.md — Changesets,
files field, 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 --provenance
Adding 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.ts with the public API
- Update bunup.config.ts to include both entry points
- Add
exports field to package.json alongside the existing bin
- Add .d.ts generation:
dts: { entry: ['src/index.ts'] }
Bun-Specific Gotchas
bun build does not generate .d.ts files. Use Bunup or tsc --emitDeclarationOnly.
bun build does not downlevel syntax. ES2022+ ships as-is.
bun publish does not support --provenance. Use npm publish.
bun publish uses NPM_CONFIG_TOKEN, not NODE_AUTH_TOKEN.
- Never use
#!/usr/bin/env bun in published packages. Your users don't have Bun.
- Bunup
banner adds 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 to dist/cli.js.
1---2name: npx-cli3description: Build and publish npx-executable CLI tools using Bun as the primary toolchain with npm-compatible output. Use when the user wants to create a new CLI tool, set up a command-line package for npx execution, configure argument parsing and terminal output, or publish a CLI to npm. Covers scaffolding, citty arg parsing, sub-commands, terminal UX, strict TypeScript, Biome + ESLint linting, Vitest testing, Bunup bundling, and publishing workflows. Keywords: npx, cli, command-line, binary, bin, tool, bun, citty, commander, terminal, publish, typescript, biome, vitest.4---56# npx CLI Tool Development (Bun-First)78Build 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).910## When to Use This Skill1112Use when:13- Creating a new CLI tool from scratch14- Building an npx-executable binary15- Setting up argument parsing, sub-commands, or terminal UX for a CLI16- Publishing a CLI tool to npm17- Adding a CLI to an existing library package1819Do NOT use when:20- Building a library without a CLI (use the `npm-package` skill)21- Building an application (not a published package)22- Working in a monorepo (this skill targets single-package repos)2324## Toolchain2526| Concern | Tool | Why |27|---------|------|-----|28| Runtime / package manager | Bun | Fast install, run, transpile |29| Bundler | Bunup | Bun-native, dual entry (lib + cli), .d.ts |30| Argument parsing | citty | ~3KB, TypeScript-native, auto-help, `runMain()` |31| Terminal colors | picocolors | ~7KB, CJS+ESM, auto-detect |32| TypeScript | `module: "nodenext"`, `strict: true` + extras | Maximum correctness |33| Formatting + basic linting | Biome v2 | Fast, single tool |34| Type-aware linting | ESLint + typescript-eslint | Deep type safety |35| Testing | Vitest | Isolation, mocking, coverage |36| Versioning | Changesets | File-based, explicit |37| Publishing | `npm publish --provenance` | Trusted Publishing / OIDC |3839## Scaffolding a New CLI4041Run the scaffold script:4243```bash44bun run <skill-path>/scripts/scaffold.ts ./my-cli \45 --name my-cli \46 --bin my-cli \47 --description "What this CLI does" \48 --author "Your Name" \49 --license MIT50```5152Options:53- `--bin <name>` — Binary name for npx (defaults to package name without scope)54- `--cli-only` — No library exports, CLI binary only55- `--no-eslint` — Skip ESLint, use Biome only5657Then install dependencies:5859```bash60cd my-cli61bun install62bun add -d bunup typescript vitest @vitest/coverage-v8 @biomejs/biome @changesets/cli63bun add citty picocolors64bun add -d eslint typescript-eslint # unless --no-eslint65```6667## Project Structure6869### Dual (Library + CLI) — Default7071```72my-cli/73├── src/74│ ├── index.ts # Library exports (programmatic API)75│ ├── index.test.ts # Unit tests for library76│ ├── cli.ts # CLI entry point (imports from index.ts)77│ └── cli.test.ts # CLI integration tests78├── dist/79│ ├── index.js # Library bundle80│ ├── index.d.ts # Type declarations81│ └── cli.js # CLI binary (with shebang)82├── .changeset/83│ └── config.json84├── package.json85├── tsconfig.json86├── bunup.config.ts87├── biome.json88├── eslint.config.ts89├── vitest.config.ts90├── .gitignore91├── README.md92└── LICENSE93```9495### CLI-Only (No Library Exports)9697Same structure minus `src/index.ts` and `src/index.test.ts`. No `exports` field in package.json, only `bin`.9899## Architecture Pattern100101**Separate logic from CLI wiring.** The CLI entry (`cli.ts`) is a thin wrapper that:1021. Parses arguments with citty1032. Calls into the library/core modules1043. Formats output for the terminal105106All business logic lives in importable modules (`index.ts` or internal modules). This makes logic unit-testable without spawning processes.107108```109cli.ts → imports from → index.ts / core modules110 ↑111 unit tests112```113114## Key Rules (Non-Negotiable)115116All rules from the npm-package skill apply here. These additional rules are specific to CLI packages:117118### Binary Configuration1191201. **Always use `#!/usr/bin/env node` in published bin files.** Never `#!/usr/bin/env bun`. The vast majority of npx users don't have Bun installed.1211222. **Point `bin` at compiled JavaScript in `dist/`.** Never at TypeScript source. npx consumers won't have your build toolchain.1231243. **Ensure the bin file is executable.** The build script includes `chmod +x dist/cli.js` after compilation.1251264. **Build with Node.js as the target.** Bunup's output must run on Node.js, not require Bun runtime features.127128### Package Configuration1291305. **Always use `"type": "module"` in package.json.**1311326. **`types` must be the first condition** in every exports block.1331347. **Use `files: ["dist"]`.** Whitelist only.1351368. **For dual packages (library + CLI):** The `exports` field exposes the library API. The `bin` field exposes the CLI. They are independent — `bin` is NOT part of `exports`.137138### Code Quality1391409. **`any` is banned.** Use `unknown` and narrow.14114210. **Use `import type` for type-only imports.**14314411. **Handle errors gracefully.** CLI users should never see raw stack traces. Use citty's `runMain()` which handles this automatically, plus `process.on('SIGINT', ...)` for cleanup.14514612. **Exit with appropriate codes.** 0 for success, 1 for errors, 2 for bad arguments, 130 for SIGINT.147148## Reference Documentation149150Read these before modifying configuration:151152- **[reference/cli-patterns.md](./reference/cli-patterns.md)** — bin setup, citty patterns, sub-commands, error handling, terminal UX, testing CLI binaries153- **[reference/esm-cjs-guide.md](./reference/esm-cjs-guide.md)** — `exports` map, dual package hazard, common mistakes154- **[reference/strict-typescript.md](./reference/strict-typescript.md)** — tsconfig, Biome rules, ESLint type-aware rules, Vitest config155- **[reference/publishing-workflow.md](./reference/publishing-workflow.md)** — Changesets, `files` field, Trusted Publishing, CI pipeline156157## Argument Parsing with citty158159### Single Command160161```typescript162import { defineCommand, runMain } from 'citty';163164const main = defineCommand({165 meta: { name: 'my-cli', version: '1.0.0', description: '...' },166 args: {167 input: { type: 'positional', description: 'Input file', required: true },168 output: { alias: 'o', type: 'string', description: 'Output path', default: './out' },169 verbose: { alias: 'v', type: 'boolean', description: 'Verbose output', default: false },170 },171 run({ args }) {172 // args is fully typed173 },174});175176void runMain(main);177```178179### Sub-Commands180181```typescript182import { defineCommand, runMain } from 'citty';183184const init = defineCommand({ meta: { name: 'init' }, /* ... */ });185const build = defineCommand({ meta: { name: 'build' }, /* ... */ });186187const main = defineCommand({188 meta: { name: 'my-cli', version: '1.0.0' },189 subCommands: { init, build },190});191192void runMain(main);193```194195See [reference/cli-patterns.md](./reference/cli-patterns.md) for complete examples including error handling, colors, and spinners.196197## Testing Strategy198199### Unit Tests — Test the Logic200201```typescript202// src/index.test.ts203import { describe, it, expect } from 'vitest';204import { processInput } from './index.js';205206describe('processInput', () => {207 it('handles valid input', () => {208 expect(processInput('test')).toBe('expected');209 });210});211```212213### Integration Tests — Test the Binary214215Build first (`bun run build`), then spawn the compiled binary:216217```typescript218// src/cli.test.ts219import { describe, it, expect } from 'vitest';220import { execFile } from 'node:child_process';221import { promisify } from 'node:util';222223const exec = promisify(execFile);224225describe('CLI', () => {226 it('prints help', async () => {227 const { stdout } = await exec('node', ['./dist/cli.js', '--help']);228 expect(stdout).toContain('my-cli');229 });230});231```232233## Development Workflow234235```bash236# Write code and tests237bun run test:watch # Vitest watch mode238239# Check everything240bun run lint # Biome + ESLint241bun run typecheck # tsc --noEmit242bun run test # Vitest243244# Build and try the CLI locally245bun run build246node ./dist/cli.js --help247node ./dist/cli.js some-input248249# Prepare release250bunx changeset251bunx changeset version252253# Publish254bun run release # Build + npm publish --provenance255```256257## Adding Sub-Commands Later2582591. Create a new file per sub-command: `src/commands/init.ts`, `src/commands/build.ts`2602. Each exports a `defineCommand()` result2613. Import and wire into the main command's `subCommands`2624. Keep logic in testable modules, commands are thin wrappers263264## Converting a CLI-Only Package to Dual (Library + CLI)2652661. Create `src/index.ts` with the public API2672. Update bunup.config.ts to include both entry points2683. Add `exports` field to package.json alongside the existing `bin`2694. Add .d.ts generation: `dts: { entry: ['src/index.ts'] }`270271## Bun-Specific Gotchas272273- **`bun build` does not generate .d.ts files.** Use Bunup or `tsc --emitDeclarationOnly`.274- **`bun build` does not downlevel syntax.** ES2022+ ships as-is.275- **`bun publish` does not support `--provenance`.** Use `npm publish`.276- **`bun publish` uses `NPM_CONFIG_TOKEN`**, not `NODE_AUTH_TOKEN`.277- **Never use `#!/usr/bin/env bun` in published packages.** Your users don't have Bun.278- **Bunup `banner` adds 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 to `dist/cli.js`.