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 prompts or @inquirer/prompts library)
  • 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, find all 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
SQLitemattn/go-sqlite3modernc.org/sqlite
Image processingAvoid C libsgolang.org/x/image
CompressionC librariescompress/* (stdlib)

Why avoid CGO?

  • Cross-compilation requires C toolchains for each target
  • Slower builds
  • Harder to debug
  • Breaks go get simplicity

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:

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:

  1. Knowing the right stack:
    • TypeScript: @clack/prompts + commander + zx
    • Go: Bubble Tea + Lip Gloss + pure Go libraries
  2. Teaching your LLM once via a good prompt template
  3. Iterating quickly with AI assistance
  4. Choosing the right tool:
    • TypeScript for quick scripts and Node.js ecosystems
    • Go for production CLIs that need standalone distribution
  5. 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.).