Modern CLI Tips for LLMs (and Curious Humans)
Modern CLI Tips for LLMs (and Curious Humans)
Let's face it: you're probably not going to read this. Just point your LLM here and let it work its magic. If you're curious about what it's going to do, read ahead.
TL;DR for humans: The fastest way to build a CLI in 2026 isn't learning all the libraries—it's teaching an LLM your stack preferences once. This guide is optimized for LLM consumption but works for humans too.
Claude Code CLI Skill
If you're using Claude Code, you can install the CLI skill to get all these best practices automatically:
npx skills add alexanderchan/cli-skill
Then just use /cli and describe what you want to build. The skill encapsulates all the patterns from this post into a reusable prompt template.
The Modern Stack
After years of iteration, here's an iteration for a modern TypeScript CLI:
- @commander-js/extra-typings - Command parsing with inferred TypeScript types (better than plain
commander) - @clack/prompts - Beautiful, modern interactive prompts (replaces the older
promptsor@inquirer/promptslibrary) - zx - Shell command execution that actually works like shell scripts
- TypeScript - Just use real TypeScript, not JSDoc
Why Not Shell Scripts?
You might ask: "Why not just write a bash script?" Fair question. Here's why TypeScript or Go is often better:
Maintainability: Shell scripts become unreadable quickly. Complex logic with proper error handling, JSON parsing, and conditionals turns into a mess of [[, $(()), and cryptic syntax.
# Shell script complexity escalates fast if [[ -f "$CONFIG_FILE" ]]; then VALUE=$(cat "$CONFIG_FILE" | jq -r '.key // empty') if [[ -z "$VALUE" ]]; then echo "Error: key not found" >&2 exit 1 fi fi
vs TypeScript:
const config = JSON.parse(await fs.readFile(configFile, 'utf-8')) if (!config.key) { console.error('Error: key not found') process.exit(1) }
LLM accuracy: Modern LLMs are significantly better at TypeScript and Go than bash. They'll generate correct error handling, proper quoting, and safe variable expansion in TypeScript/Go. With bash, they often produce scripts that work in simple cases but fail with edge cases (spaces in filenames, special characters, etc.).
Portability: Shell scripts are less portable than you think:
- Bash vs sh vs zsh differences
- macOS vs Linux command differences (
sed,date,findall have different flags) - Windows requires WSL or Git Bash
- TypeScript runs anywhere Node.js runs
- Go compiles to a single binary for any platform
Type safety and tooling: You get autocomplete, type checking, and refactoring support. Shell scripts have none of this.
When shell scripts ARE the right choice:
- Very simple glue code (< 20 lines)
- System administration tasks that heavily use pipes and standard Unix tools
- When you need to run on systems without Node.js/Go and can't distribute binaries
For everything else, use TypeScript with zx (which gives you shell-like syntax with TypeScript safety) or Go for production tools.
The LLM Prompt Pattern
Instead of memorizing APIs, give your LLM this context:
Using @commander-js/extra-typings, @clack/prompts, and zx build a cli that does the following... [describe what you want the CLI to do] example header: #!/usr/bin/env tsx import { Command } from '@commander-js/extra-typings' import * as p from '@clack/prompts' import { $ } from 'zx' $.verbose = true
That's it. Modern LLMs know these libraries well and will generate working code.
Why These Libraries?
@commander-js/extra-typings over plain commander
Plain commander has TypeScript types, but they're generic. The extra-typings package adds inferred types based on your actual option and argument definitions:
// With plain commander - opts is generic import { Command } from 'commander' const program = new Command() .option('-p, --port <number>', 'port number') .action((opts) => { // opts is just Record<string, any> opts.port // no autocomplete ❌ }) // With extra-typings - opts is inferred import { Command } from '@commander-js/extra-typings' const program = new Command() .option('-p, --port <number>', 'port number') .action((opts) => { // opts.port is automatically typed! ✅ opts.port // full autocomplete })
The types build up as you chain methods, giving you full IntelliSense.
@clack/prompts over prompts
The older prompts library hasn't been updated in 4 years. @clack/prompts is actively maintained and offers:
- Better default styling out of the box
- Built-in intro/outro for session framing
- Better TypeScript support
- More prompt types (autocomplete, grouped prompts)
- Used by major projects (Svelte, shadcn, etc.)
import * as p from '@clack/prompts' p.intro('My CLI Tool') const name = await p.text({ message: 'What is your name?', validate: value => !value ? 'Name is required' : undefined }) const confirmed = await p.confirm({ message: 'Are you sure?' }) p.outro('Done!')
zx over execa
While execa is great for programmatic command execution, zx is better for replacing shell scripts because:
- Pipes work naturally:
await $\cat file.txt | grep pattern`` - No special escaping needed for most cases
- Conditional arguments are simpler
- Feels more like writing bash
// Complex pipelines just work await $`docker ps | grep my-app | awk '{print $1}'` // Template literals handle variables naturally const tag = 'v1.0.0' await $`git tag ${tag} && git push origin ${tag}`
Running Your CLI
If you want something more opinionated than Commander, check out brocli from the Drizzle team. It has a cleaner API for defining commands and better built-in help generation.
Example: Complete CLI in One Prompt
Here's what you might ask an LLM:
Using @commander-js/extra-typings, @clack/prompts, and zx Build a CLI that: - Accepts a list of git repos as arguments or from a --repos-file - For each repo, checks if there are uncommitted changes - If clean, creates a branch (default name: YYYY-MM-DD-update-deps) - Runs npm update or yarn upgrade depending on package manager - Creates a PR using gh CLI if there are changes Include proper error handling and a spinner for long operations.
The LLM will generate a complete, working CLI with proper types, error handling, and user feedback.
Running Your CLI
Development:
chmod +x script.ts # Run with tsx (recommended) tsx script.ts # Or with bun bun script.ts # Or with node + tsx loader (slower) node --import tsx script.ts
Distribution (add to package.json):
Option 1: esbuild (simple, single file)
{ "bin": { "my-cli": "./dist/cli.js" }, "scripts": { "build": "esbuild --bundle --platform=node --target=node22 src/cli.ts --outfile=dist/cli.js" } }
For multiple CLIs with esbuild, you need to build each separately:
{ "bin": { "my-cli": "./dist/my-cli.js", "another-cli": "./dist/another-cli.js", "third-cli": "./dist/third-cli.js" }, "scripts": { "build": "npm run build:my-cli && npm run build:another-cli && npm run build:third-cli", "build:my-cli": "esbuild --bundle --platform=node --target=node22 src/cli/my-cli.ts --outfile=dist/my-cli.js", "build:another-cli": "esbuild --bundle --platform=node --target=node22 src/cli/another-cli.ts --outfile=dist/another-cli.js", "build:third-cli": "esbuild --bundle --platform=node --target=node22 src/cli/third-cli.ts --outfile=dist/third-cli.js" } }
This works but gets verbose quickly. That's where tsdown shines.
Option 2: tsdown (multiple CLIs, better DX)
For projects with multiple CLI entry points, tsdown provides a cleaner config-based approach:
npm install -D tsdown
Create tsdown.config.js:
import { defineConfig } from "tsdown"; export default defineConfig({ entry: [ "./src/cli/my-cli.ts", "./src/cli/another-cli.ts", ], format: "commonjs", });
Update package.json:
{ "bin": { "my-cli": "./dist/my-cli.js", "another-cli": "./dist/another-cli.js" }, "scripts": { "build": "tsdown", "build:watch": "tsdown --watch" }, "files": ["dist"] }
Benefits of tsdown:
- Handles multiple entry points automatically
- Built-in watch mode
- Cleaner config for complex builds
- Better defaults for Node.js CLIs
Then npm publish or npm link for local testing.
If Go Is Your Thing
TypeScript is great for quick scripts and Node.js ecosystems, but if you're building a production CLI that needs to be distributed as a standalone binary, Go is the superior choice. Here's the modern Go CLI stack based on real-world production use:
The Go Stack
- Bubble Tea - TUI framework for interactive interfaces
- Lip Gloss - Terminal styling and layout
- Huh - Interactive forms and prompts
- modernc.org/sqlite - Pure Go SQLite (no CGO!)
- Standard library
flag- Built-in flag parsing (or cobra for complex CLIs)
Why Go for CLIs?
Single binary distribution: Compile once, run anywhere. No runtime dependencies.
# Build for all platforms from one machine GOOS=darwin GOARCH=arm64 go build -o alex-runner-darwin-arm64 GOOS=linux GOARCH=amd64 go build -o alex-runner-linux-amd64 GOOS=windows GOARCH=amd64 go build -o alex-runner-windows-amd64.exe
No CGO = Easy cross-compilation: Using pure Go libraries like modernc.org/sqlite means you can cross-compile without C toolchains:
// go.mod require ( modernc.org/sqlite v1.34.4 // Pure Go - no CGO! )
Fast startup: Go binaries start in milliseconds, not hundreds of milliseconds like Node.js.
Small binaries: With proper build flags, Go binaries are 5-15MB. TypeScript bundles + Node.js runtime are much larger.
Cross-Platform Build Setup
Use GoReleaser for automated multi-platform builds and releases:
# .goreleaser.yaml version: 2 builds: - id: my-cli main: ./cmd/my-cli binary: my-cli env: - CGO_ENABLED=0 # Pure Go - no CGO! goos: - linux - darwin - windows goarch: - amd64 - arm64 ldflags: - -s -w # Strip debug info for smaller binaries - -X main.version={{.Version}} - -X main.commit={{.Commit}} archives: - formats: [tar.gz] format_overrides: - goos: windows formats: [zip]
GitHub Actions integration:
# .github/workflows/release.yml name: Release on: push: tags: - 'v*' jobs: goreleaser: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: '1.23' - uses: goreleaser/goreleaser-action@v5 with: version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Push a tag, get binaries for all platforms automatically.
Library Choices: Pure Go Over CGO
Always prefer pure Go implementations to avoid CGO complexity:
| Need | ❌ CGO Version | ✅ Pure Go Version |
|---|---|---|
| SQLite | mattn/go-sqlite3 | modernc.org/sqlite |
| Image processing | Avoid C libs | golang.org/x/image |
| Compression | C libraries | compress/* (stdlib) |
Why avoid CGO?
- Cross-compilation requires C toolchains for each target
- Slower builds
- Harder to debug
- Breaks
go getsimplicity
Project Structure
my-cli/ ├── cmd/ │ └── my-cli/ │ └── main.go # Entry point ├── internal/ │ ├── ui.go # Bubble Tea UI │ ├── db.go # Database logic │ ├── completion.go # Shell completion │ └── *_test.go # Tests ├── .goreleaser.yaml # Release config ├── Makefile # Build shortcuts └── go.mod
Makefile for common tasks:
.PHONY: build test install build: go build -o my-cli ./cmd/my-cli test: go test ./internal/... -v install: go install ./cmd/my-cli
Shell Completion
Go makes it easy to generate shell completions:
// In your main.go func generateCompletion(shell string) string { switch shell { case "bash": return bashCompletionScript case "zsh": return zshCompletionScript case "fish": return fishCompletionScript } return "" } // Usage: // my-cli --generate-completion bash > /etc/bash_completion.d/my-cli
Version Management with Changesets
Even for Go projects, use Changesets for version management:
# Install changesets pnpm add -D @changesets/cli # Add a changeset during development pnpm changeset # Release (bumps version, updates changelog) pnpm release
This works great with GoReleaser - changesets manage the version, GoReleaser builds the binaries.
Real-World Example: alex-runner
Check out alex-runner - a production-ready frecency-based npm/pnpm/yarn script runner that demonstrates all these best practices:
- ✅ Pure Go SQLite (no CGO) - uses
modernc.org/sqlite - ✅ Cross-platform builds with GoReleaser - binaries for macOS, Linux, Windows
- ✅ Beautiful TUI with Bubble Tea - interactive script selection with live filtering
- ✅ Shell completion for bash/zsh/fish - frecency-aware tab completion
- ✅ Automated releases via GitHub Actions - push a tag, get binaries
- ✅ Frecency algorithm for smart suggestions - learns which scripts you use most
- ✅ Per-directory usage tracking - project-specific suggestions
Key files to study:
.goreleaser.yaml- Multi-platform build config with CGO_ENABLED=0internal/ui.go- Bubble Tea interactive UI implementationinternal/completion.go- Shell completion generation for bash/zsh/fishinternal/db.go- Pure Go SQLite usage with modernc.org/sqlitecmd/alex-runner/main.go- Flag parsing and command structure
The entire project is a great reference for building production CLIs in Go.
When to Choose Go vs TypeScript
Choose Go when:
- You need a standalone binary
- Cross-platform distribution is important
- Startup time matters
- You want simple deployment (single file)
- You're building a tool for developers who may not have Node.js
Choose TypeScript when:
- You're already in a Node.js ecosystem
- You need rapid prototyping
- The CLI is project-specific (not distributed)
- You want to leverage npm packages
- Your team is more comfortable with JavaScript
The Go LLM Prompt Pattern
Just like with TypeScript, teach your LLM the Go stack once:
Using Go with Bubble Tea, Lip Gloss, and modernc.org/sqlite Build a CLI that does the following... [describe what you want] Example structure: package main import ( "flag" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) func main() { // Flag parsing var verbose bool flag.BoolVar(&verbose, "v", false, "verbose output") flag.Parse() // Run Bubble Tea program p := tea.NewProgram(initialModel()) if _, err := p.Run(); err != nil { log.Fatal(err) } }
CLI Best Practices
When building CLIs, it's worth following established best practices for user experience and interface design. Two excellent resources:
- Command Line Interface Guidelines (clig.dev) - A comprehensive guide to CLI UX, covering everything from argument parsing to output formatting, error messages, and configuration. Essential reading for building user-friendly CLIs.
- The Art of Command Line - A curated guide to mastering the command line, useful both for understanding how users expect CLIs to behave and for building better tools.
These resources will help you build CLIs that feel natural to experienced command-line users while remaining accessible to newcomers.
Key Conventions to Follow
Be non-destructive by default. Always prompt before mutating operations:
import * as p from '@clack/prompts' const shouldDelete = await p.confirm({ message: `Delete ${files.length} files?`, initialValue: false }) if (!shouldDelete) { p.cancel('Operation cancelled') process.exit(0) }
Support --yes or --non-interactive mode for CI/CD and automation:
import { Command } from '@commander-js/extra-typings' const program = new Command() .option('-y, --yes', 'skip confirmation prompts') .option('--non-interactive', 'run in non-interactive mode') .action(async (opts) => { const shouldProceed = opts.yes || opts.nonInteractive ? true : await p.confirm({ message: 'Continue?' }) if (!shouldProceed) return // ... do the work })
Store config in standard locations using well-known formats:
import { homedir } from 'os' import { join } from 'path' import { readFileSync, writeFileSync } from 'fs' // Simple config: JSON or YAML in ~/.config/package-name/ const configDir = join(homedir(), '.config', 'my-cli') const configPath = join(configDir, 'config.json') // For complex state: SQLite import Database from 'better-sqlite3' const dbPath = join(configDir, 'data.db') const db = new Database(dbPath)
For Go:
import ( "os" "path/filepath" ) // Get config directory configDir, _ := os.UserConfigDir() // Returns ~/.config on Unix appConfigDir := filepath.Join(configDir, "my-cli") // Simple config: JSON configPath := filepath.Join(appConfigDir, "config.json") // Complex state: SQLite with modernc.org/sqlite dbPath := filepath.Join(appConfigDir, "data.db")
Keep project-local scripts separate from distributed CLIs:
TypeScript projects: Use ./scripts for local utilities:
# Project-local scripts (not distributed) ./scripts/ ├── dev-setup.ts # Project-specific setup ├── migrate-data.ts # One-off migrations └── generate-types.ts # Build-time codegen # Run with tsx directly tsx scripts/dev-setup.ts # Global tools (distributed via npm) npm install -g my-cli
TypeScript project structure:
my-ts-project/ ├── scripts/ # Project-local utilities (not published) │ ├── setup.ts │ └── migrate.ts ├── src/ │ └── cli/ # Publishable CLIs (if any) │ └── my-tool.ts └── package.json { "scripts": { "setup": "tsx scripts/setup.ts" // Local scripts }, "bin": { "my-tool": "./dist/my-tool.js" // Global CLI } }
Go projects: Use cmd/ for all CLIs (both local and distributed):
my-go-project/ ├── cmd/ │ ├── my-cli/ # Main distributed CLI │ │ └── main.go │ └── dev-setup/ # Project-local utility │ └── main.go ├── internal/ # Shared internal packages │ ├── ui.go │ └── db.go └── go.mod # Run local utilities during development go run ./cmd/dev-setup # Install distributed CLI globally go install github.com/user/my-go-project/cmd/my-cli@latest
The distinction:
- TypeScript:
scripts/for maintainers,bin/for end users - Go:
cmd/for both, but only main CLIs get published/documented
The Bottom Line
In 2026, building CLIs is less about memorizing APIs and more about:
- Knowing the right stack:
- TypeScript: @clack/prompts + commander + zx
- Go: Bubble Tea + Lip Gloss + pure Go libraries
- Teaching your LLM once via a good prompt template
- Iterating quickly with AI assistance
- Choosing the right tool:
- TypeScript for quick scripts and Node.js ecosystems
- Go for production CLIs that need standalone distribution
- Following best practices from resources like clig.dev and The Art of Command Line
The tools have matured, the LLMs know them well, and you can focus on what your CLI should do rather than how to build it.
For more detailed examples and utilities, check out my original CLI guide which covers additional libraries for specific use cases (file processing, config loading, parallelization, etc.).