Skip to main content
Technology & EngineeringCli Development260 lines

CLI Distribution

Package and distribute CLI tools via npm/npx, standalone binaries with pkg, and Homebrew taps

Quick Summary23 lines
You are an expert in packaging and distributing command-line tools through npm, standalone executables, and Homebrew.

## Key Points

- Add a `engines.node` field specifying your minimum Node.js version, and test against it in CI — users on older Node versions get a clear error at install time instead of cryptic runtime failures.
- For `npx` usage, keep install time under 5 seconds by minimizing dependencies — users expect `npx` tools to feel instant, and a 200MB dependency tree defeats the purpose.
- **Forgetting the shebang line** — without `#!/usr/bin/env node` at the top of the bin entry, Linux and macOS will try to execute the file as a shell script, producing baffling syntax errors.
- Forgetting the shebang line (`#!/usr/bin/env node`) in the bin entry point — without it, Linux/macOS will try to execute the file as a shell script and fail with syntax errors.

## Quick Example

```js
#!/usr/bin/env node
// bin/cli.js
import '../dist/index.js';
```

```bash
chmod +x bin/cli.js
npm publish
```
skilldb get cli-development-skills/CLI DistributionFull skill: 260 lines
Paste into your CLAUDE.md or agent config

Packaging & Distributing CLI Tools — CLI Development

You are an expert in packaging and distributing command-line tools through npm, standalone executables, and Homebrew.

Overview

Distributing a CLI tool involves making it easy for users to install and run. The three main channels are: npm (for Node.js users, supports npx for zero-install execution), standalone binaries via pkg or similar bundlers (for users without Node.js), and Homebrew (for macOS/Linux users who prefer brew install).

Setup & Configuration

npm distribution

Configure package.json for CLI publishing:

{
  "name": "my-cli",
  "version": "1.0.0",
  "description": "A CLI tool for doing things",
  "type": "module",
  "bin": {
    "my-cli": "./bin/cli.js"
  },
  "files": [
    "bin/",
    "dist/",
    "LICENSE"
  ],
  "engines": {
    "node": ">=18"
  },
  "keywords": ["cli", "tool"],
  "repository": {
    "type": "git",
    "url": "https://github.com/user/my-cli"
  }
}

Create the bin entry point:

#!/usr/bin/env node
// bin/cli.js
import '../dist/index.js';

Make it executable and publish:

chmod +x bin/cli.js
npm publish

Users can then:

# Install globally
npm install -g my-cli
my-cli --help

# Or run without installing
npx my-cli --help

Core Patterns

Standalone binaries with pkg

npm install --save-dev @yao-pkg/pkg

Configure package.json:

{
  "pkg": {
    "targets": ["node18-linux-x64", "node18-macos-x64", "node18-win-x64"],
    "outputPath": "release",
    "assets": ["templates/**/*", "config.default.json"]
  },
  "scripts": {
    "build:binaries": "pkg . --compress GZip"
  }
}

Build and verify:

npm run build:binaries
# Produces:
#   release/my-cli-linux
#   release/my-cli-macos
#   release/my-cli-win.exe

Standalone binaries with esbuild + sea (Node.js Single Executable)

# Bundle your CLI to a single file
npx esbuild src/index.ts --bundle --platform=node --outfile=dist/cli.cjs --format=cjs

# Create SEA config
cat > sea-config.json << 'EOF'
{
  "main": "dist/cli.cjs",
  "output": "sea-prep.blob",
  "disableExperimentalSEAWarning": true
}
EOF

# Generate the blob and inject into node binary
node --experimental-sea-config sea-config.json
cp $(which node) my-cli
npx postject my-cli NODE_SEA_BLOB sea-prep.blob --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2

Homebrew tap distribution

Create a GitHub repository named homebrew-tap, then add a formula:

# Formula/my-cli.rb
class MyCli < Formula
  desc "A CLI tool for doing things"
  homepage "https://github.com/user/my-cli"
  url "https://github.com/user/my-cli/releases/download/v1.0.0/my-cli-macos-x64.tar.gz"
  sha256 "abc123..."
  license "MIT"

  def install
    bin.install "my-cli"
  end

  test do
    assert_match "my-cli v1.0.0", shell_output("#{bin}/my-cli --version")
  end
end

Users install with:

brew tap user/tap
brew install my-cli

GitHub Release automation

# .github/workflows/release.yml
name: Release
on:
  push:
    tags: ['v*']

jobs:
  build:
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            target: node18-linux-x64
            artifact: my-cli-linux
          - os: macos-latest
            target: node18-macos-x64
            artifact: my-cli-macos
          - os: windows-latest
            target: node18-win-x64
            artifact: my-cli-win.exe
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      - run: npm ci && npm run build
      - run: npx @yao-pkg/pkg . --target ${{ matrix.target }} --output ${{ matrix.artifact }}
      - uses: actions/upload-artifact@v4
        with:
          name: ${{ matrix.artifact }}
          path: ${{ matrix.artifact }}

  release:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
      - uses: softprops/action-gh-release@v2
        with:
          files: |
            my-cli-linux/my-cli-linux
            my-cli-macos/my-cli-macos
            my-cli-win.exe/my-cli-win.exe

Prepublish checklist script

{
  "scripts": {
    "prepublishOnly": "npm run build && npm run test && npm run lint",
    "prepack": "rm -rf dist && npm run build"
  }
}

Version management and changelogs

# Bump version, create git tag, update changelog
npx changeset
npx changeset version
npx changeset publish

# Or simpler with npm
npm version patch -m "Release %s"
npm publish
git push --follow-tags

Best Practices

  • Always include a files field in package.json to whitelist published files — without it, npm publishes everything not in .gitignore, which may include tests, source maps, and config files that bloat the package.
  • Add a engines.node field specifying your minimum Node.js version, and test against it in CI — users on older Node versions get a clear error at install time instead of cryptic runtime failures.
  • For npx usage, keep install time under 5 seconds by minimizing dependencies — users expect npx tools to feel instant, and a 200MB dependency tree defeats the purpose.

Core Philosophy

Distribution is the last mile of CLI development, and it determines whether your tool is adopted or abandoned. A CLI that takes 30 seconds to install via npx will lose users to a competitor that takes 3 seconds. Minimize dependencies, whitelist published files, and test the install experience on a clean machine — not your development environment where everything is already cached.

Meet users where they are. Node.js developers expect npm install -g or npx. System administrators expect Homebrew taps or standalone binaries. DevOps engineers expect Docker images. Offering multiple distribution channels is not redundant — it is respectful of different workflows and environments. Start with npm, add standalone binaries for non-Node users, and add Homebrew if your audience includes macOS developers.

Automate everything about the release process. Version bumping, changelog generation, binary compilation, store publishing, and tag creation should all happen in CI when you push a version tag. Manual releases are error-prone, inconsistent, and demoralizing — especially when you need to publish to multiple channels simultaneously.

Anti-Patterns

  • Publishing without a files whitelist — omitting the files field in package.json publishes tests, source maps, development configs, and other junk that bloats the package and may leak sensitive information.

  • Not testing the install experience — running npm pack and installing the resulting tarball in a clean directory catches missing files, broken shebangs, and import errors that development mode hides.

  • Bundling unnecessary dependencies for npx usage — a 200MB dependency tree defeats the purpose of zero-install execution; audit dependencies and remove or replace heavy ones for tools intended to run via npx.

  • Hardcoding platform-specific paths in the binary — using /usr/local/ or C:\Program Files\ instead of portable path resolution breaks the CLI on unexpected platforms and containerized environments.

  • Forgetting the shebang line — without #!/usr/bin/env node at the top of the bin entry, Linux and macOS will try to execute the file as a shell script, producing baffling syntax errors.

Common Pitfalls

  • Forgetting the shebang line (#!/usr/bin/env node) in the bin entry point — without it, Linux/macOS will try to execute the file as a shell script and fail with syntax errors.
  • Publishing with "type": "module" but using .js extensions in the bin field without ensuring the file is valid ESM — on older npm versions or edge cases this can fail; using an explicit .mjs extension for the bin entry is safer.

Install this skill directly: skilldb add cli-development-skills

Get CLI access →