
Npx Cli
- 283 installs
- 133 repo stars
- Updated February 24, 2026
- jwynia/agent-skills
npx-cli is a CLI development agent skill that scaffolds, tests, bundles, and publishes npx-executable command-line tools using Bun, citty, Bunup, and Vitest for developers shipping npm CLI packages.
About
npx-cli is a CLI toolchain skill in jwynia/agent-skills—part of a 112-skill collection—that teaches building and publishing npx-executable command-line tools with Bun as the primary runtime while producing Node.js-compatible npm binaries. The SKILL.md covers project scaffolding, citty argument parsing with runMain() and sub-commands, picocolors terminal UX, strict TypeScript with module nodenext, Biome v2 formatting, ESLint with typescript-eslint, Vitest testing, Bunup dual-entry bundling for library and CLI outputs, Changesets versioning, and npm publish --provenance releases. Key rules enforce files whitelist packaging, Node shebangs on published bins, and separating thin CLI wiring from importable core modules. Developers reach for npx-cli when creating a new agent-invokable CLI, adding a bin entry to an existing library, or publishing typed tools consumable via npx without manual shell choreography. Install with npx skills add jwynia/agent-skills --skill npx-cli from the skills/tech/development/tooling path.
- npx package execution
- CLI scaffolding
- Third-party tool wiring
- Non-interactive runs
- Agent-safe shell patterns
Npx Cli by the numbers
- 283 all-time installs (skills.sh)
- +3 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #177 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/jwynia/agent-skills --skill npx-cliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 283 |
|---|---|
| repo stars | ★ 133 |
| Last updated | February 24, 2026 |
| Repository | jwynia/agent-skills ↗ |
How do you build and publish an npx CLI?
Run and scaffold npm/npx CLI packages from agent sessions to bootstrap tools, codegen utilities, and third-party CLIs without manual shell choreography.
Who is it for?
Developers creating npx-installable CLI tools who want a Bun-first scaffold with citty, Bunup, Vitest, and provenance publishing patterns.
Skip if: Teams running third-party CLIs only once via npx without authoring a package, or Python/Rust CLI projects outside the Bun/npm ecosystem.
When should I use this skill?
The user wants to create a new CLI tool, add an npx bin to a package, configure citty sub-commands, or publish a command-line utility to npm.
What you get
Scaffolded CLI package, citty command parser, Bunup bundles with .d.ts types, Vitest test suite, and npm-publishable bin entry.
- CLI package scaffold
- Bundled bin entry
- Vitest test suite
By the numbers
- Part of jwynia/agent-skills collection with 112 reusable agent skills
- Documents toolchain stack: Bun, citty, Bunup, Vitest, Biome v2, and Changesets
Files
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: 1. Parses arguments with citty 2. Calls into the library/core modules 3. 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
1. 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.
2. Point `bin` at compiled JavaScript in `dist/`. Never at TypeScript source. npx consumers won't have your build toolchain.
3. Ensure the bin file is executable. The build script includes chmod +x dist/cli.js after compilation.
4. Build with Node.js as the target. Bunup's output must run on Node.js, not require Bun runtime features.
Package Configuration
5. Always use `"type": "module"` in package.json.
6. `types` must be the first condition in every exports block.
7. Use `files: ["dist"]`. Whitelist only.
8. 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
9. `any` is banned. Use unknown and narrow.
10. Use `import type` for type-only imports.
11. 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.
12. 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](./reference/cli-patterns.md) — bin setup, citty patterns, sub-commands, error handling, terminal UX, testing CLI binaries
- [reference/esm-cjs-guide.md](./reference/esm-cjs-guide.md) —
exportsmap, dual package hazard, common mistakes - [reference/strict-typescript.md](./reference/strict-typescript.md) — tsconfig, Biome rules, ESLint type-aware rules, Vitest config
- [reference/publishing-workflow.md](./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
1. Create a new file per sub-command: src/commands/init.ts, src/commands/build.ts 2. Each exports a defineCommand() result 3. Import and wire into the main command's subCommands 4. Keep logic in testable modules, commands are thin wrappers
Converting a CLI-Only Package to Dual (Library + CLI)
1. Create src/index.ts with the public API 2. Update bunup.config.ts to include both entry points 3. Add exports field to package.json alongside the existing bin 4. 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.
CLI Package Patterns
Bin Entry Point Configuration
package.json bin Field
{
"bin": {
"my-cli": "./dist/cli.js"
}
}For single-command CLIs where the binary name matches the package name:
{
"bin": "./dist/cli.js"
}Critical Rules
1. Always use `#!/usr/bin/env node` in the compiled output — never #!/usr/bin/env bun. Bun is not installed for the vast majority of npm/npx users. The shebang must target Node.js for universal compatibility.
2. Point `bin` at compiled JavaScript in `dist/`, never at TypeScript source. npx consumers won't have your build toolchain.
3. Build with `--target node` to ensure Node.js runtime compatibility.
4. Ensure the bin file is executable. The build script should chmod +x dist/cli.js after compilation, or the scaffold's build step should handle this.
Bunup Configuration for CLI
import { defineConfig } from 'bunup';
export default defineConfig({
entry: {
index: 'src/index.ts', // Library exports (if any)
cli: 'src/cli.ts', // CLI entry point
},
format: ['esm'],
dts: true,
clean: true,
// Bunup adds shebang to bin entries automatically when
// it detects them in package.json. If not, add manually:
banner: {
js: '#!/usr/bin/env node',
},
});Note: If the CLI is the only entry point (no library exports), simplify to a single entry:
import { defineConfig } from 'bunup';
export default defineConfig({
entry: ['src/cli.ts'],
format: ['esm'],
dts: false, // No types needed for CLI-only packages
clean: true,
banner: {
js: '#!/usr/bin/env node',
},
});Argument Parsing with citty
citty is a lightweight (~3KB), TypeScript-native argument parser from the UnJS ecosystem. It's built on Node.js's native util.parseArgs.
Basic Structure
// src/cli.ts
import { defineCommand, runMain } from 'citty';
const main = defineCommand({
meta: {
name: 'my-cli',
version: '1.0.0',
description: 'What this CLI does',
},
args: {
input: {
type: 'positional',
description: 'Input file path',
required: true,
},
output: {
alias: 'o',
type: 'string',
description: 'Output file path',
default: './output',
},
verbose: {
alias: 'v',
type: 'boolean',
description: 'Enable verbose output',
default: false,
},
},
run({ args }) {
// args is fully typed: { input: string; output: string; verbose: boolean }
console.log(`Processing ${args.input} → ${args.output}`);
},
});
runMain(main);Sub-Commands
import { defineCommand, runMain } from 'citty';
const init = defineCommand({
meta: { name: 'init', description: 'Initialize a new project' },
args: {
template: {
alias: 't',
type: 'string',
description: 'Template to use',
default: 'default',
},
},
run({ args }) {
console.log(`Initializing with template: ${args.template}`);
},
});
const build = defineCommand({
meta: { name: 'build', description: 'Build the project' },
args: {
watch: {
alias: 'w',
type: 'boolean',
description: 'Watch for changes',
default: false,
},
},
run({ args }) {
console.log(`Building${args.watch ? ' (watch mode)' : ''}...`);
},
});
const main = defineCommand({
meta: {
name: 'my-cli',
version: '1.0.0',
description: 'My CLI tool',
},
subCommands: { init, build },
});
runMain(main);Why citty Over Alternatives
- citty: ~3KB, TypeScript-native types, built on
util.parseArgs, auto-generated help,runMain()handles errors gracefully. Best for new projects. - commander: Most widely used, imperative API, massive community. Better if you need extensive documentation/examples from the ecosystem.
- yargs: Feature-rich, ~200KB+, excellent validation. Overkill for most CLIs.
Error Handling
runMain() Pattern
citty's runMain() automatically:
- Catches unhandled errors and prints clean messages (no stack traces)
- Handles
--helpand--versionflags - Exits with appropriate codes
For custom error handling:
import { defineCommand, runMain } from 'citty';
import process from 'node:process';
const main = defineCommand({
meta: { name: 'my-cli', version: '1.0.0' },
run() {
// Your logic here
},
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\nInterrupted. Cleaning up...');
// Cleanup logic
process.exit(130);
});
// Unhandled rejections
process.on('unhandledRejection', (error) => {
console.error('Unexpected error:', error instanceof Error ? error.message : error);
if (process.env['DEBUG']) {
console.error(error);
}
process.exit(1);
});
runMain(main);Exit Codes
0— Success1— General error2— Misuse of command (bad arguments)130— SIGINT (Ctrl+C)
Terminal Output
Colors: picocolors
import pc from 'picocolors';
console.log(pc.green('✓ Success'));
console.log(pc.red('✗ Error: file not found'));
console.log(pc.yellow('⚠ Warning: deprecated option'));
console.log(pc.dim(' Processing files...'));picocolors is ~7KB, supports CJS and ESM, auto-detects color support. No chained API — use template literals for combinations: ` pc.bold(pc.red('Error')) `.
For chained styles or Truecolor support, use ansis instead.
Note: Node.js v22+ includes util.styleText() for zero-dependency coloring. Consider it if you want to eliminate the picocolors dependency for Node.js 22+ targets.
Spinners: nanospinner
import { createSpinner } from 'nanospinner';
const spinner = createSpinner('Processing...').start();
// ... do work ...
spinner.success({ text: 'Done!' });
// or
spinner.error({ text: 'Failed!' });Dual Library + CLI Package
Some packages export both a programmatic API and a CLI. Structure these as:
src/
├── index.ts # Library exports (public API)
├── cli.ts # CLI entry point (imports from index.ts)
└── internal/ # Shared implementation{
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"bin": {
"my-cli": "./dist/cli.js"
}
}The CLI entry (cli.ts) imports from the library entry (index.ts) — never the reverse. This keeps the library tree-shakeable and the CLI as a thin wrapper.
Testing CLI Commands
Unit Test the Logic, Not the CLI
Separate business logic from CLI argument handling:
// src/core.ts — pure logic, fully testable
export function processInput(input: string, options: ProcessOptions): Result {
// ...
}
// src/cli.ts — thin CLI wrapper
import { processInput } from './core.js';
const main = defineCommand({
// ...
run({ args }) {
const result = processInput(args.input, { verbose: args.verbose });
console.log(result);
},
});Integration Test the CLI Binary
For end-to-end CLI tests, spawn the built binary:
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');
});
it('processes input', async () => {
const { stdout } = await exec('node', ['./dist/cli.js', 'input.txt', '-o', 'output.txt']);
expect(stdout).toContain('Processing');
});
it('exits with error on missing input', async () => {
await expect(exec('node', ['./dist/cli.js'])).rejects.toThrow();
});
});ESM/CJS Interoperability Guide
The Current State (2026)
ESM-only is now the correct default for new packages. require(esm) is stable and unflagged in all supported Node.js LTS versions (v20.19.0+, v22.12.0+). Node.js 18 reached EOL in April 2025.
Default to ESM-only (`"type": "module"`) unless the package has a specific, documented need to support Node.js < 20.19.0.
Package.json exports Map
ESM-Only (Preferred)
{
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": ["dist"]
}Dual CJS/ESM (When Required)
Use the module-sync condition (Node.js 22.10+, backported to 20.19.0) to serve ESM to both import and require() consumers, eliminating the dual package hazard:
{
"type": "module",
"exports": {
".": {
"module-sync": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./package.json": "./package.json"
}
}Critical Rules
types Must Be First
Within each condition block, types must appear before default or any other condition. TypeScript silently fails to resolve types otherwise.
// CORRECT
{ "types": "./dist/index.d.ts", "default": "./dist/index.js" }
// BROKEN — TypeScript won't find types
{ "default": "./dist/index.js", "types": "./dist/index.d.ts" }exports Encapsulates Completely
Once you add an exports field, all non-exported paths become inaccessible to consumers. Always explicitly export "./package.json": "./package.json" — many tools need it.
Condition Order Matters
default must always be last. Custom conditions go before import/require. Wrong order silently serves the wrong file to some consumers.
Prefer Named Exports
Default exports behave differently between CJS and ESM. A CJS consumer doing const pkg = require('your-pkg') gets the module namespace, not the default export, unless they use pkg.default. Named exports avoid this entirely.
// PREFER
export function doThing(): void { /* ... */ }
export const CONFIG = { /* ... */ };
// AVOID as primary API surface
export default class MyThing { /* ... */ }Changes to exports Are Semver-Major
Adding, removing, or restructuring export paths will break some consumer in some environment. Treat exports map changes as breaking.
The Dual Package Hazard
When Node.js loads both a CJS and ESM copy of the same package (e.g., one dependency imports it and another require()s it), the package initializes twice. This causes:
- Duplicate state (singletons aren't singletons)
instanceofchecks fail across module boundaries- Side effects run twice
Solutions (in order of preference): 1. Ship ESM-only — no hazard possible 2. Use module-sync condition — both import and require resolve to the same ESM file 3. Use a stateless API design where duplicate instantiation doesn't matter
Subpath Exports
For packages that expose multiple entry points:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./utils": {
"types": "./dist/utils.d.ts",
"default": "./dist/utils.js"
},
"./package.json": "./package.json"
}
}Each subpath needs its own types + default pair. The types condition must be first in every subpath.
Common Mistakes
1. Using `main` instead of `exports`: main is legacy. Use exports for all new packages. Only add main as a fallback for very old tooling.
2. Forgetting `"type": "module"`: Without this, .js files are treated as CJS by Node.js, even if they contain ESM syntax.
3. Using `.mjs`/`.cjs` extensions when unnecessary: With "type": "module", .js is ESM. Only use .cjs for the rare CJS file in an ESM package. Avoid .mjs in new packages — it causes issues with some tooling.
4. Path aliases in published code: tsc does not rewrite path aliases ("@lib/utils") in emitted JS or .d.ts files. Consumers can't resolve them. Use Node.js subpath imports ("#imports" in package.json) instead, or keep directory structures flat.
Publishing Workflow
Versioning: Changesets
`standard-version` is deprecated. Use Changesets for new projects.
Changesets uses a file-based approach: each PR includes a changeset file describing what changed and whether it's a patch, minor, or major bump. At release time, changesets are consumed to bump versions and generate changelogs.
Setup
bun add -d @changesets/cli
bunx changeset initThis creates a .changeset/ directory with a config.json:
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}Workflow
1. During development: Run bunx changeset to create a changeset file describing the change 2. At release time: Run bunx changeset version to consume changesets, bump package.json version, update CHANGELOG.md 3. Publish: Run the build + publish pipeline
Package.json Scripts
{
"scripts": {
"changeset": "changeset",
"version": "changeset version",
"release": "bun run build && npm publish"
}
}Pre-Publish Checklist
Use files Field, Never .npmignore
The files field is a whitelist — only listed paths are included in the published tarball. This prevents accidentally shipping secrets, .env files, test fixtures, or source code.
{
"files": ["dist"]
}.npmignore is a blacklist that replaces .gitignore (they are not merged). This is a common source of credential leaks — if .gitignore blocks .env but .npmignore doesn't, your secrets ship to npm.
Verify Before Publishing
Always dry-run before publishing:
npm pack --dry-runThis shows exactly what will be in the tarball. Review the file list. If anything unexpected appears, fix files in package.json.
Use prepublishOnly for Build + Test
{
"scripts": {
"prepublishOnly": "bun run lint && bun run test && bun run build"
}
}Never use the legacy prepublish hook — it runs on both npm publish AND npm install (in npm v7+), which is almost never what you want.
Publishing
npm publish with Provenance (Recommended)
npm publish --provenance --access publicProvenance signing creates a cryptographic attestation linking the published package to its source code and build process. This is the gold standard for supply chain security.
Note: bun publish exists and works, but does NOT support --provenance. Use npm publish for provenance signing.
npm Trusted Publishing (OIDC)
npm Trusted Publishing eliminates long-lived npm tokens. Configure it on npmjs.com by linking your GitHub repository to your npm package. Then in GitHub Actions:
name: Release
on:
push:
tags: ['v*']
permissions:
contents: read
id-token: write # Required for OIDC
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run build
- uses: actions/setup-node@v4
with:
node-version: '22'
registry-url: 'https://registry.npmjs.org'
- run: npm publish --provenance --access publicNo NODE_AUTH_TOKEN needed — the OIDC token is obtained automatically.
bun publish (When Provenance Not Required)
bun publish --access publicBun publish handles workspace: protocol stripping, respects .npmrc, supports --dry-run and --tag. Use NPM_CONFIG_TOKEN (not NODE_AUTH_TOKEN) for authentication in CI.
GitHub Actions: Full Release Pipeline
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run lint
- run: bun run typecheck
- run: bun run test
- run: bun run buildVersion Tagging Convention
After changeset version bumps the version:
git add .
git commit -m "chore: release v$(node -p "require('./package.json').version")"
git tag "v$(node -p "require('./package.json').version")"
git push --follow-tagsOr automate this with a release script.
Access Control
For scoped packages (@scope/package-name), the first publish requires --access public (scoped packages default to restricted). Subsequent publishes inherit the access level.
{
"publishConfig": {
"access": "public"
}
}Adding publishConfig.access to package.json avoids needing --access public on every publish.
Strict TypeScript & Linting Configuration
TypeScript: nodenext for Libraries
Use module: "nodenext" for all published packages. Not "bundler".
Why: "bundler" allows extensionless imports (import { foo } from "./utils") that work in bundlers but crash in Node.js with ERR_MODULE_NOT_FOUND. Code satisfying nodenext constraints works everywhere; the reverse is not true.
Recommended tsconfig.json
{
"compilerOptions": {
// Module system
"module": "nodenext",
"moduleResolution": "nodenext",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"isolatedModules": true,
// Output
"target": "es2022",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
// Strict type safety — ALL of these are non-negotiable for published packages
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
// Build optimization
"skipLibCheck": true,
// Interop
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}Key Settings Explained
`strict: true` enables noImplicitAny, strictNullChecks, strictFunctionTypes, strictBindCallApply, strictPropertyInitialization, alwaysStrict, useUnknownInCatchVariables. This is non-negotiable for published packages — without it, your .d.ts files can contain types that error when consumers compile with strict mode.
`noUncheckedIndexedAccess: true` adds | undefined to all index signature access. Not part of strict, but catches real runtime bugs where array/object access might be undefined.
`exactOptionalPropertyTypes: true` distinguishes between { x?: string } (property may be absent) and { x: string | undefined } (property present but undefined). Catches subtle API design bugs.
`verbatimModuleSyntax: true` replaces isolatedModules conceptually. Ensures import/export statements are preserved exactly as written — critical for tree-shaking and type-only import elision. Guarantees each file can be independently transpiled (required by esbuild, SWC, Bun).
`declarationMap: true` generates .d.ts.map files enabling "Go to Definition" to navigate to your original source. Always ship these — they dramatically improve consumer DX.
`moduleDetection: "force"` treats every file as a module regardless of whether it has import/export statements. Prevents accidental global script files.
What NOT to Do
Don't use path aliases (`paths`) in published packages. tsc does not rewrite path aliases in emitted JS or .d.ts files. Your consumers will see unresolvable "@lib/utils" imports. Use relative imports or Node.js subpath imports (#imports in package.json) instead.
Don't set `target` lower than `es2022` unless you have a documented reason. Bun and all supported Node.js versions support ES2022. Lower targets lose features like top-level await and cause-chained errors.
Don't use `composite: true` for single-package repos. It's a project-references feature for monorepos and adds unnecessary build artifacts.
Biome Configuration
Biome handles formatting and syntax-level linting. It's 10-25x faster than ESLint for linting and 25x faster than Prettier for formatting.
biome.json
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"organizeImports": {
"enabled": true
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error"
},
"style": {
"noNonNullAssertion": "warn",
"useImportType": "error",
"useConsistentArrayType": {
"level": "error",
"options": { "syntax": "generic" }
},
"noNamespace": "error"
},
"complexity": {
"noBannedTypes": "error",
"useOptionalChain": "error"
},
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "warn"
},
"nursery": {
"noFloatingPromises": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}Biome Key Rules for Strict TypeScript
- `noExplicitAny: "error"` — Bans
anytype annotations. Useunknownand narrow, or use// biome-ignore suspicious/noExplicitAny: <reason>when genuinely unavoidable. - `useImportType: "error"` — Enforces
import typefor type-only imports, aligning withverbatimModuleSyntax. - `noFloatingPromises: "error"` — Biome v2's first type-aware rule. Catches unhandled promise rejections.
- `noUnusedImports: "error"` — Keeps imports clean. Biome auto-fixes these.
What Biome Cannot Do (Yet)
Biome v2 has exactly one type-aware rule (noFloatingPromises). ESLint + typescript-eslint provides 40+ type-aware rules. For strict library authoring, the most impactful missing rules are:
no-misused-promises— Catches promises used in boolean contextsawait-thenable— Catchesawaiton non-Promise valuesno-unsafe-assignment/no-unsafe-return— Catchesanypropagation even from inferred typesstrict-boolean-expressions— Catches falsy-value bugsno-unnecessary-condition— Catches always-truthy/falsy checks
ESLint: Type-Aware Rules Only
Use ESLint exclusively for type-aware rules that Biome cannot provide. Biome handles everything else.
eslint.config.ts
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['dist/', 'node_modules/', '*.config.*'],
},
{
files: ['src/**/*.ts'],
extends: [tseslint.configs.strictTypeChecked],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Disable rules that Biome already covers
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
// Keep the type-aware rules that Biome can't do
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/strict-boolean-expressions': 'warn',
'@typescript-eslint/prefer-readonly': 'error',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/return-await': ['error', 'always'],
},
},
);Why This Hybrid Works
Biome runs in ~50ms for a medium project. ESLint with type-checking runs in 2-10 seconds. By using Biome for all syntax-level checks (fast, in-editor feedback) and ESLint only for type-aware rules (slower, CI and pre-commit), you get:
- Fast editor feedback (Biome)
- Deep type safety (ESLint)
- No rule conflicts (Biome rules disabled in ESLint config)
Package.json Scripts
{
"scripts": {
"lint": "biome check . && eslint src/",
"lint:fix": "biome check --write . && eslint src/ --fix",
"format": "biome format --write ."
}
}Vitest Configuration
Vitest is the testing framework. It provides test isolation (each file in its own worker), mature mocking, coverage, snapshot testing, and rich IDE integration.
vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
include: ['src/**/*.test.ts'],
typecheck: {
enabled: true,
},
},
});Test File Convention
Place test files next to the code they test:
src/
├── utils.ts
├── utils.test.ts
├── parser.ts
└── parser.test.tsRunning Tests
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
}
}#!/usr/bin/env bun
/**
* npx-cli scaffold script
*
* Usage: bun run <skill-path>/scripts/scaffold.ts <project-dir> [options]
*
* Options:
* --name <n> Package name (defaults to directory name)
* --bin <name> Binary name for npx (defaults to package name without scope)
* --description <desc> Package description
* --author <author> Author name
* --license <license> License (default: MIT)
* --cli-only No library exports, CLI binary only
* --no-eslint Skip ESLint setup (Biome only)
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, copyFileSync } from 'node:fs';
import { resolve, dirname, basename, join } from 'node:path';
// ---------------------------------------------------------------------------
// Argument parsing
// ---------------------------------------------------------------------------
const args = process.argv.slice(2);
function getFlag(name: string): boolean {
const idx = args.indexOf(`--${name}`);
if (idx !== -1) {
args.splice(idx, 1);
return true;
}
return false;
}
function getOption(name: string, fallback: string): string {
const idx = args.indexOf(`--${name}`);
if (idx !== -1 && idx + 1 < args.length) {
const val = args[idx + 1]!;
args.splice(idx, 2);
return val;
}
return fallback;
}
const projectDir = resolve(args[0] ?? '.');
const dirName = basename(projectDir);
const packageName = getOption('name', dirName);
// Strip scope from package name for binary name: @scope/my-cli -> my-cli
const defaultBin = packageName.startsWith('@') ? packageName.split('/')[1] ?? dirName : packageName;
const binName = getOption('bin', defaultBin);
const description = getOption('description', '');
const author = getOption('author', '');
const license = getOption('license', 'MIT');
const cliOnly = getFlag('cli-only');
const noEslint = getFlag('no-eslint');
// ---------------------------------------------------------------------------
// Handlebars-lite template engine
// ---------------------------------------------------------------------------
interface TemplateContext {
packageName: string;
binName: string;
description: string;
author: string;
license: string;
[key: string]: string;
}
function render(template: string, ctx: TemplateContext): string {
return template.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => ctx[key] ?? '');
}
// ---------------------------------------------------------------------------
// Resolve paths
// ---------------------------------------------------------------------------
const scriptDir = dirname(new URL(import.meta.url).pathname);
const skillRoot = resolve(scriptDir, '..');
const templatesDir = join(skillRoot, 'templates');
const ctx: TemplateContext = { packageName, binName, description, author, license };
// ---------------------------------------------------------------------------
// File helpers
// ---------------------------------------------------------------------------
function ensureDir(dir: string): void {
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
}
function writeTemplate(templatePath: string, outputPath: string): void {
const raw = readFileSync(templatePath, 'utf-8');
const content = templatePath.endsWith('.hbs') ? render(raw, ctx) : raw;
ensureDir(dirname(outputPath));
writeFileSync(outputPath, content, 'utf-8');
}
function copyFile(src: string, dest: string): void {
ensureDir(dirname(dest));
copyFileSync(src, dest);
}
console.log(`\nScaffolding npx CLI package: ${packageName}`);
console.log(`Binary name: ${binName}`);
console.log(`Directory: ${projectDir}\n`);
ensureDir(projectDir);
ensureDir(join(projectDir, 'src'));
// ---------------------------------------------------------------------------
// Static templates
// ---------------------------------------------------------------------------
copyFile(join(templatesDir, 'tsconfig.json'), join(projectDir, 'tsconfig.json'));
copyFile(join(templatesDir, 'biome.json'), join(projectDir, 'biome.json'));
copyFile(join(templatesDir, 'vitest.config.ts'), join(projectDir, 'vitest.config.ts'));
copyFile(join(templatesDir, 'gitignore'), join(projectDir, '.gitignore'));
if (!noEslint) {
copyFile(join(templatesDir, 'eslint.config.ts'), join(projectDir, 'eslint.config.ts'));
}
// ---------------------------------------------------------------------------
// Handlebars templates
// ---------------------------------------------------------------------------
writeTemplate(join(templatesDir, 'src', 'cli.ts.hbs'), join(projectDir, 'src', 'cli.ts'));
writeTemplate(join(templatesDir, 'src', 'cli.test.ts.hbs'), join(projectDir, 'src', 'cli.test.ts'));
if (!cliOnly) {
writeTemplate(join(templatesDir, 'src', 'index.ts.hbs'), join(projectDir, 'src', 'index.ts'));
writeTemplate(join(templatesDir, 'src', 'index.test.ts.hbs'), join(projectDir, 'src', 'index.test.ts'));
// Post-build script to add shebang only to cli.js (not index.js)
copyFile(join(templatesDir, 'scripts', 'postbuild.ts'), join(projectDir, 'scripts', 'postbuild.ts'));
}
// ---------------------------------------------------------------------------
// package.json
// ---------------------------------------------------------------------------
const pkgRaw = readFileSync(join(templatesDir, 'package.json.hbs'), 'utf-8');
let pkgContent = render(pkgRaw, ctx);
let pkg = JSON.parse(pkgContent) as Record<string, unknown>;
if (cliOnly) {
// Remove library exports, keep only bin
delete pkg['exports'];
// CLI-only uses banner for shebang, so simpler build script
const scripts = pkg['scripts'] as Record<string, string>;
scripts['build'] = 'bunup && chmod +x dist/cli.js';
}
if (noEslint) {
const scripts = pkg['scripts'] as Record<string, string>;
scripts['lint'] = 'biome check .';
scripts['lint:fix'] = 'biome check --write .';
}
writeFileSync(join(projectDir, 'package.json'), JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
// ---------------------------------------------------------------------------
// bunup.config.ts
// ---------------------------------------------------------------------------
let bunupConfig: string;
if (cliOnly) {
bunupConfig = `import { defineConfig } from 'bunup';
export default defineConfig({
\tentry: ['src/cli.ts'],
\tformat: ['esm'],
\tdts: false,
\tclean: true,
\tbanner: {
\t\tjs: '#!/usr/bin/env node',
\t},
});
`;
} else {
bunupConfig = readFileSync(join(templatesDir, 'bunup.config.ts'), 'utf-8');
}
writeFileSync(join(projectDir, 'bunup.config.ts'), bunupConfig, 'utf-8');
// ---------------------------------------------------------------------------
// .changeset/config.json
// ---------------------------------------------------------------------------
ensureDir(join(projectDir, '.changeset'));
writeFileSync(
join(projectDir, '.changeset', 'config.json'),
JSON.stringify(
{
$schema: 'https://unpkg.com/@changesets/config@3.1.1/schema.json',
changelog: '@changesets/cli/changelog',
commit: false,
fixed: [],
linked: [],
access: 'public',
baseBranch: 'main',
updateInternalDependencies: 'patch',
ignore: [],
},
null,
2,
) + '\n',
'utf-8',
);
// ---------------------------------------------------------------------------
// README.md
// ---------------------------------------------------------------------------
const cliUsage = cliOnly
? `## Usage
\`\`\`bash
npx ${packageName} <input>
\`\`\`
Or install globally:
\`\`\`bash
npm install -g ${packageName}
${binName} <input>
\`\`\``
: `## CLI Usage
\`\`\`bash
npx ${packageName} <input>
\`\`\`
## Programmatic Usage
\`\`\`typescript
import { process } from '${packageName}';
console.log(process('hello'));
\`\`\``;
const readme = `# ${packageName}
${description}
## Installation
\`\`\`bash
npm install ${cliOnly ? '-g ' : ''}${packageName}
\`\`\`
${cliUsage}
## Development
\`\`\`bash
bun install
bun run build
bun run test
\`\`\`
## License
${license}
`;
writeFileSync(join(projectDir, 'README.md'), readme, 'utf-8');
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
console.log('Created files:');
console.log(' package.json');
console.log(' tsconfig.json');
console.log(' bunup.config.ts');
console.log(' biome.json');
if (!noEslint) console.log(' eslint.config.ts');
console.log(' vitest.config.ts');
console.log(' .gitignore');
console.log(' .changeset/config.json');
console.log(' README.md');
console.log(' src/cli.ts');
console.log(' src/cli.test.ts');
if (!cliOnly) {
console.log(' src/index.ts');
console.log(' src/index.test.ts');
console.log(' scripts/postbuild.ts');
}
console.log('');
console.log('Next steps:');
console.log(` cd ${projectDir}`);
console.log(' bun install');
console.log(' bun add -d bunup typescript vitest @vitest/coverage-v8 @biomejs/biome @changesets/cli');
console.log(' bun add citty picocolors');
if (!noEslint) console.log(' bun add -d eslint typescript-eslint');
console.log(' bun run build');
console.log(' bun run test');
{
"$schema": "https://biomejs.dev/schemas/2.0.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"organizeImports": {
"enabled": true
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 100
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noExplicitAny": "error"
},
"style": {
"noNonNullAssertion": "warn",
"useImportType": "error",
"useConsistentArrayType": {
"level": "error",
"options": { "syntax": "generic" }
},
"noNamespace": "error"
},
"complexity": {
"noBannedTypes": "error",
"useOptionalChain": "error"
},
"correctness": {
"noUnusedImports": "error",
"noUnusedVariables": "warn",
"useExhaustiveDependencies": "warn"
},
"nursery": {
"noFloatingPromises": "error"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"trailingCommas": "all",
"semicolons": "always"
}
}
}
import { defineConfig } from 'bunup';
export default defineConfig({
entry: {
index: 'src/index.ts',
cli: 'src/cli.ts',
},
format: ['esm'],
dts: {
entry: ['src/index.ts'],
},
clean: true,
// NOTE: Shebang is added to dist/cli.js via the build script's post-build step
// rather than using `banner`, which would add it to ALL output files including index.js.
});
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: ['dist/', 'node_modules/', '*.config.*'],
},
{
files: ['src/**/*.ts'],
extends: [tseslint.configs.strictTypeChecked],
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Disabled — Biome covers these
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
// Type-aware rules — Biome cannot do these
'@typescript-eslint/no-misused-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-argument': 'error',
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/strict-boolean-expressions': 'warn',
'@typescript-eslint/prefer-readonly': 'error',
'@typescript-eslint/require-await': 'error',
'@typescript-eslint/return-await': ['error', 'always'],
},
},
);
node_modules/
dist/
*.tsbuildinfo
.env
.env.*
coverage/
{
"name": "{{packageName}}",
"version": "0.0.0",
"description": "{{description}}",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
},
"bin": {
"{{binName}}": "./dist/cli.js"
},
"files": [
"dist"
],
"scripts": {
"build": "bunup && bun run scripts/postbuild.ts",
"dev": "bunup --watch",
"lint": "biome check . && eslint src/",
"lint:fix": "biome check --write . && eslint src/ --fix",
"format": "biome format --write .",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"prepublishOnly": "bun run lint && bun run typecheck && bun run test && bun run build",
"changeset": "changeset",
"version": "changeset version",
"release": "bun run build && npm publish --provenance --access public"
},
"keywords": [],
"author": "{{author}}",
"license": "{{license}}",
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=20.19.0"
}
}
#!/usr/bin/env bun
/**
* Post-build script: adds shebang to CLI binary and makes it executable.
*
* Bunup's `banner` option applies to ALL output files. Since this package
* has both a library entry (index.js) and a CLI entry (cli.js), we add
* the shebang only to cli.js after the build.
*/
import { readFileSync, writeFileSync, chmodSync } from 'node:fs';
const CLI_PATH = 'dist/cli.js';
const SHEBANG = '#!/usr/bin/env node\n';
const content = readFileSync(CLI_PATH, 'utf-8');
if (!content.startsWith('#!')) {
writeFileSync(CLI_PATH, SHEBANG + content, 'utf-8');
}
chmodSync(CLI_PATH, 0o755);
console.log('✓ Added shebang and set executable: dist/cli.js');
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 with --help', async () => {
const { stdout } = await exec('node', ['./dist/cli.js', '--help']);
expect(stdout).toContain('{{binName}}');
});
it('prints version with --version', async () => {
const { stdout } = await exec('node', ['./dist/cli.js', '--version']);
expect(stdout).toContain('0.0.0');
});
it('processes positional input', async () => {
const { stdout } = await exec('node', ['./dist/cli.js', 'hello']);
expect(stdout).toContain('Processing: hello');
});
});
import { defineCommand, runMain } from 'citty';
import process from 'node:process';
const main = defineCommand({
meta: {
name: '{{binName}}',
version: '0.0.0',
description: '{{description}}',
},
args: {
input: {
type: 'positional',
description: 'Input to process',
required: true,
},
verbose: {
alias: 'v',
type: 'boolean',
description: 'Enable verbose output',
default: false,
},
},
run({ args }) {
if (args.verbose) {
console.log('Verbose mode enabled');
}
console.log(`Processing: ${args.input}`);
},
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('\nInterrupted.');
process.exit(130);
});
void runMain(main);
import { describe, it, expect } from 'vitest';
import { process as processInput } from './index.js';
describe('{{packageName}}', () => {
it('processes input', () => {
expect(processInput('test')).toBe('Processed: test');
});
});
/**
* {{packageName}}
*
* {{description}}
*
* This module exports the programmatic API.
* For CLI usage, run: npx {{binName}}
*/
export function process(input: string): string {
return `Processed: ${input}`;
}
{
"compilerOptions": {
"module": "nodenext",
"moduleResolution": "nodenext",
"moduleDetection": "force",
"verbatimModuleSyntax": true,
"isolatedModules": true,
"target": "es2022",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noFallthroughCasesInSwitch": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/**/*.d.ts'],
thresholds: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
include: ['src/**/*.test.ts'],
typecheck: {
enabled: true,
},
},
});
Related skills
How it compares
Pick npx-cli for authoring publishable npm CLIs with Bun; use generic shell skills only for one-off npx invocations of existing third-party tools.
FAQ
What toolchain does npx-cli recommend for CLI development?
npx-cli recommends Bun for install and run, citty for argument parsing, Bunup for dual lib and CLI bundling with .d.ts, Vitest for tests, Biome v2 plus ESLint for linting, and Changesets with npm publish --provenance for releases.
Does npx-cli produce Node-compatible binaries?
npx-cli uses Bun as the primary toolchain but bundles CLI output with a Node shebang so published packages run for all npm and npx users on the Node.js runtime.
When should agents load the npx-cli skill?
npx-cli loads when a user wants to create a new CLI from scratch, add an npx bin to a library, configure sub-commands and terminal UX, or publish a typed command-line package to npm.