@ryoppippi

My JS CLI Stack 2025

12 Aug 2025 ・ 9 min read


Hello, I’m ryoppippi. Nice to meet you if we haven’t met before!

Over the past few years, I’ve created various CLI tools as OSS projects:

In the JS ecosystem, various libraries constantly appear and disappear. I’ve tried many different tools myself, and here’s my current stack for CLI tool development as of 2025.

My Principles

When creating CLI tools as OSS, I keep these principles in mind:

  • Choose libraries that guarantee type safety
  • The smaller the bundle size, the better
  • Provide comprehensive documentation
  • Take maximum precautions to prevent malicious code injection

Stack

Package Manager

I use bun for the following reasons:

  • Lightning-fast installation: It’s incredibly fast. You can set up environments blazingly fast both locally and in CI
  • Native TypeScript support: You can directly execute TypeScript files with bun run, improving the development experience
  • Excellent compatibility: Very high compatibility with Node.js - I haven’t encountered any compatibility issues so far
  • Useful extensions: bun shell is particularly handy for writing simple scripts or executing shell scripts within package.json

While pnpm had the edge for monorepo usage, bun is catching up with the recent implementation of pnpm style isolated install. Once this stabilises, bun will become more suitable for monorepos as well.

Bundler

I currently use tsdown, a bundler for JS/TS based on rolldown, which is written in Rust.

Rolldown is a project that aims to reimplement rollup in Rust. While still under development, it’s promising because it aims to achieve rollup’s excellent tree shaking capabilities with Rust’s performance.

Here’s why I chose tsdown:

  • Blazing fast builds: Overwhelming speed thanks to being Rust-based
  • Superior tree shaking: Inheriting rollup’s design philosophy, it offers more accurate tree shaking than esbuild or bun build
  • Integration with quality assurance tools: Excellent integration with tools like publint and unplugin-unused
  • Rollup plugin compatibility: Being able to use unplugin plugins directly is a huge advantage
  • Simple configuration: Very simple config files, including type generation and source map generation
  • Continuous improvements: Benefits from rolldown updates, such as bundle size reduction

When comparing bundle size and build times, I’ve achieved better results than unbuild, mkdist, tsup, and bun build.

Particularly, esbuild-based tools tend to have less accurate tree shaking, resulting in larger bundle sizes.

Here’s an actual example of bundle size improvement through tsdown and rolldown updates:

Before rolldown came along, I was a devoted bun build user and even created plugins for bun, but I switched to tsdown as it proved more convenient.

It’s been incredibly useful at my new job too!

I used to frequently use tsup, but since the author has also switched to tsdown, I’ll likely be using tsdown more often going forward.

Bundling Strategy

When distributing CLI tools, I bundle all dependencies and keep dependencies at zero. There are clear reasons for this:

  • Faster installation: Dependency resolution is slow. While bun is fast, it’s noticeably slower with npm or deno
  • Efficient code distribution: Tree-shaking ensures only actually used code is included. Distributing as dependencies would mean downloading unnecessary code
  • Operational stability: Avoids issues from version mismatches. CLI tool users don’t need to worry about dependency versions, so including all packages at a specific point ensures consistent operation

This strategy significantly reduces distribution size:

To keep bundle sizes small, I prioritise the following when selecting libraries:

  • Small size with minimal dependencies
  • Effective tree shaking compatibility
  • Provides necessary features without excess

For example, ccusage stays under 1MB even without minification:

install size

I also contribute to frequently used tools to reduce their bundle sizes:

CLI Framework

I’ve tried various JS frameworks:

Currently, I mainly use KAZUPON’s gunshi:

  • Type-safe API: parseArgs-like API with type-safe command-line argument parsing
  • Comprehensive features: Includes negatable, enum, alias, type checking, and more
  • Small bundle size: Lightweight compared to other frameworks
  • Active development: Innovative features like plugin systems are being added
  • Future potential: Shell completion, i18n, and help customisation are in development

I originally used cleye, but migrated to gunshi as it maintains a similar interface while being lighter and more feature-rich.

Example of using gunshi in curxy

https://github.com/ryoppippi/curxy/blob/7073bf01ce6c5b87f068d36bf3d9bb247af8f998/main.ts#L15C1-L90C4

const command = define({
	toKebab: true,
	args: {
		endpoint: {
			type: 'custom',
			alias: 'e', // alias configuration
			default: 'http://localhost:11434',
			description: 'The endpoint to Ollama server.',
			parse: validateURL, // custom validation function can be set
		},
		openaiEndpoint: {
			type: 'custom',
			alias: 'o',
			default: 'https://api.openai.com',
			description: 'The endpoint to OpenAI server.',
			parse: validateURL,
		},
		port: {
			type: 'number',
			alias: 'p',
			default: await getRandomPort(),
			description: 'The port to run the server on. Default is random',
		},
		hostname: {
			type: 'string',
			default: '127.0.0.1',
			description: 'The hostname to run the server on.',
		},
		cloudflared: {
			type: 'boolean',
			alias: 'c',
			default: true,
			negatable: true, // automatically generates `--no-cloudflared` from `--cloudflared` option (https://gunshi.dev/guide/essentials/declarative-configuration#negatable-boolean-options)
			description: 'Use cloudflared to tunnel the server',
		},
	},
	examples: ['curxy'].join('\n'),

	// Type-safe argument type definitions
	async run(ctx) {
		const app = createApp({
			openAIEndpoint: ctx.values.openaiEndpoint,
			ollamaEndpoint: ctx.values.endpoint,
			OPENAI_API_KEY,
		});

		await Promise.all([
			Bun.serve(
				{ port: ctx.values.port, hostname: ctx.values.hostname },
				app.fetch,
			),
			ctx.values.cloudflared
			&& startTunnel({ port: ctx.values.port, hostname: ctx.values.hostname })
				.then(async tunnel => ensure(await tunnel?.getURL(), is.String))
				.then(url =>
					console.log(
						`Server running at: ${bold(terminalLink(url, url))}\n`,
						green(
							`enter ${bold(terminalLink(`${url}/v1`, `${url}/v1`))} into ${
								italic(`Override OpenAl Base URL`)
							} section in cursor settings`,
						),
					)
				),
		]);
	},
});

Logging

I use consola for log output. It’s attractive for its ability to easily produce rich logs:

  • Rich log levels like success, info, error
  • Rich output features like boxes and tables
  • Simple user input collection using prompts

While not the smallest in terms of bundle size, I chose it for its balance of features.

For more interactive interfaces, I sometimes use @clack/prompts.

Testing

I use Vitest for testing CLI tools. Vitest offers several advantages for CLI tool development:

  • High performance: Extremely fast execution thanks to its native ES modules support
  • Safe environment variable mocking: Provides safe and isolated environment variable mocking, crucial for CLI tools that depend on environment configurations
  • In-source testing: Allows writing tests directly alongside source code using if (import.meta.vitest), eliminating the need to export functions purely for testing purposes

In-source testing is particularly valuable for CLI tools because it allows testing internal functions without cluttering the public API. You can keep implementation details private while ensuring comprehensive test coverage.

Distribution

npm

I upload packages to npm for distribution.

I previously had high hopes for JSR-IO. JSR had attractive features like publishing TypeScript directly without building and automatic documentation generation. However, for CLI tool distribution, it had these issues:

  • The only practical option to run tools on jsr is using deno
  • CLI tool users don’t necessarily use deno
  • When you can control your own build process and documentation generation, JSR’s advantages diminish

Therefore, I returned to npm for its versatility.

Security

Security concerns are often raised about npx execution. To address this, I use OIDC authentication and CI/CD through GitHub Actions to demonstrate package safety. This allows users to verify that distributed packages are trustworthy.

Recommending bunx

bunx is bun’s package execution tool, essentially bun’s version of npx. It provides functionality to temporarily download and execute packages from the npm registry with these characteristics:

For my OSS projects, I recommend using bunx when running CLI tools from npm. I generally don’t recommend global installation like npm i -g <package> for my packages.

Here’s why:

  • Fast installation: Uses bun install, making it significantly faster than deno npm:foo or npx -y foo, especially noticeable with tools that have many dependencies
  • Maintained compatibility: Executes with node if node is specified in the shebang, maintaining runtime compatibility while benefiting from faster installation
  • Clean environment: Creates cache in /private/tmp, avoiding user environment pollution
  • Automatic updates: Cache automatically revalidates every 24 hours, ensuring you always use the latest version (clear advantage over global install)

For packages with appropriate bundle sizes where frequent version pinning isn’t necessary, CLI execution via bunx foo balances user convenience and reduces maintenance burden. I believe bunx execution is optimal, especially for frequently updated CLI tools like ccusage.

Recently, ccusage was added to homebrew without my knowledge, but since it’s not my recommended method, I haven’t added it to the documentation.

Documentation

I primarily use Claude Code to enrich READMEs. For larger projects, I sometimes create documentation using vitepress.

Vitepress isn’t just a static site generator; it excels in:

  • Integration with typedoc
  • Ability to add features like llms.txt generation as plugins
  • Beautiful code highlighting using shiki

Other Tools

  • bumpp: Tool for easy semantic versioning
  • publint: Tool for maintaining package quality
  • clean-pkg-json: Removes unnecessary package.json fields before publishing
  • changelogithub: Creates beautiful GitHub Releases
  • renovate: Automates dependency updates. Highly configurable with auto-merge capabilities
  • eslint: Tool for maintaining code quality. I use biome with fewer contributors, but with more contributors, rules make reviews easier. Rules are managed with @ryoppippi/eslint-config. Looking forward to oxlint’s type-aware rule development
  • pkg-pr-new: Automatically publishes packages to npm-compatible registry per commit. Easy to test locally
  • bun-only: Used when creating tools that only work with bun

Conclusion

I’ve introduced my stack for CLI tool development as of 2025. Looking back, I’m reminded of how much I rely on various libraries and ecosystems to advance my development.

I look forward to new libraries and tools making CLI tool development even more convenient in the future.

Addendum

If you’re interested in the internals of ccusage, check out deepwiki.

comment on bluesky / twitter
CC BY-NC-SA 4.0 2022-PRESENT © ryoppippi