jwynia/npx-cli
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.
npx skills add https://github.com/jwynia/agent-skills --skill npx-cli
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).
Use when:
Do NOT use when:
npm-package skill)| 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 |
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 onlyThen 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
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
Same structure minus src/index.ts and src/index.test.ts. No exports field in package.json, only bin.
Separate logic from CLI wiring. The CLI entry (cli.ts) is a thin wrapper that:
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
All rules from the npm-package skill apply here. These additional rules are specific to CLI packages:
#!/usr/bin/env node in published bin files. Never #!/usr/bin/env bun. The vast majority of npx users don't have Bun installed.bin at compiled JavaScript in dist/. Never at TypeScript source. npx consumers won't have your build toolchain.chmod +x dist/cli.js after compilation."type": "module" in package.json.types must be the first condition in every exports block.files: ["dist"]. Whitelist only.exports field exposes the library API. The bin field exposes the CLI. They are independent — bin is NOT part of exports.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.
Read these before modifying configuration:
exports map, dual package hazard, common mistakesfiles field, Trusted Publishing, CI pipelineimport { 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);
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.
// 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');
});
});
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');
});
});
# 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
src/commands/init.ts, src/commands/build.tsdefineCommand() resultsubCommandssrc/index.ts with the public APIexports field to package.json alongside the existing bindts: { entry: ['src/index.ts'] }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.#!/usr/bin/env bun in published packages. Your users don't have Bun.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.Take jwynia/npx-cli from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference npx.
Without those the skill loads but fails at the first command.