Skip to main content

Packaging and Distribution

You've built a working AI chat CLI. It runs beautifully on your machine with tsx src/index.ts. But that's useless to anyone else. Your colleague can't type ai-chat "Hello" and have it work. Your CLI only exists in your development environment.

The gap between "works for me" and "works for everyone" is distribution. This lesson teaches you to cross that gap: package your TypeScript CLI so anyone can install it with a single npm install -g command and immediately start using it.

Every tool you use daily went through this journey. When Anthropic built Claude Code, they didn't just write the code; they packaged it so you could run claude from any terminal. That's what you'll learn here: the professional workflow for turning TypeScript source into a distributable npm package.

The Distribution Problem

Your CLI currently exists as TypeScript source files. To run it, you need:

  1. Node.js installed
  2. The project cloned
  3. Dependencies installed with npm install
  4. Either tsx for direct execution or a build step

That's four steps before someone can try your tool. Compare that to:

npm install -g @yourname/ai-chat
ai-chat "Hello, world!"

Two commands. No cloning, no build setup, no TypeScript tooling. This is what distribution enables.

Package.json: The Distribution Contract

The package.json file defines how npm packages, installs, and runs your CLI. Here's a complete configuration:

{
"name": "@yourname/ai-chat",
"version": "1.0.0",
"description": "AI chat CLI with streaming support",
"type": "module",
"bin": {
"ai-chat": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": ["cli", "ai", "chat", "typescript"],
"author": "Your Name <you@example.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/yourname/ai-chat-cli"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^5.3.0",
"ora": "^8.0.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/node": "^20.0.0"
}
}

Let's break down the critical fields.

The bin Field: Creating Commands

The bin field maps command names to executable files:

{
"bin": {
"ai-chat": "./dist/index.js"
}
}

When someone installs your package globally, npm creates a symlink from ai-chat to your script. The user types ai-chat, and npm runs ./dist/index.js.

Output:

# After: npm install -g @yourname/ai-chat
which ai-chat
# /usr/local/bin/ai-chat (symlink to your package)

ai-chat --version
# 1.0.0

Multiple commands: If your CLI has subcommands as separate entry points:

{
"bin": {
"ai-chat": "./dist/index.js",
"ai-config": "./dist/config.js"
}
}

The files Field: What Gets Published

The files array specifies what npm includes in the published package:

{
"files": [
"dist"
]
}

This is an allowlist. Only the dist folder (your compiled JavaScript) gets published. Your TypeScript source, test files, and configuration stay behind.

Why this matters:

Without files fieldWith files: ["dist"]
Publishes everythingPublishes only dist
Package size: 2MBPackage size: 50KB
Includes src/, test/, .envClean, minimal package
Exposes internal detailsProfessional distribution

Default exclusions: npm automatically excludes node_modules, .git, and files in .gitignore. But without files, it includes everything else.

The Shebang: Making Files Executable

Your entry point needs a shebang so the shell knows to run it with Node:

#!/usr/bin/env node

import { program } from "commander";
import chalk from "chalk";

program
.name("ai-chat")
.description("Chat with AI from your terminal")
.version("1.0.0");

// ... rest of CLI

The #!/usr/bin/env node line tells the operating system to find node in the PATH and use it to execute this file. Without it, trying to run ai-chat directly results in a syntax error.

Output:

# Without shebang:
ai-chat "Hello"
# ./dist/index.js: line 1: import: command not found

# With shebang:
ai-chat "Hello"
# (Works correctly, runs with Node.js)

Before publishing, test your package as if it were installed globally. The npm link command creates a global symlink to your local development version.

# In your project directory
npm run build
npm link

Output:

npm link
# added 1 package in 2s

which ai-chat
# /usr/local/bin/ai-chat -> /usr/local/lib/node_modules/@yourname/ai-chat/dist/index.js

ai-chat --version
# 1.0.0

Now you can test from anywhere:

cd /tmp
ai-chat "Explain what npm link does"
# (Your CLI runs, streaming the response)

The development workflow:

# 1. Make changes to src/
vim src/index.ts

# 2. Rebuild
npm run build

# 3. Test immediately (no re-linking needed)
ai-chat "Test my changes"

The symlink points to your built files. Rebuilding updates what the command runs.

Cleaning up:

# Remove the global link
npm unlink -g @yourname/ai-chat

# Or from project directory
npm unlink

Build Automation with prepublishOnly

The prepublishOnly script runs automatically before npm publish. This ensures your package is always built before publishing:

{
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
}
}

What happens when you publish:

npm publish --access public
# > @yourname/ai-chat@1.0.0 prepublishOnly
# > npm run build
#
# > @yourname/ai-chat@1.0.0 build
# > tsc
#
# npm notice Publishing to https://registry.npmjs.org/
# + @yourname/ai-chat@1.0.0

Output:

npm notice 📦  @yourname/ai-chat@1.0.0
npm notice === Tarball Contents ===
npm notice 1.2kB dist/index.js
npm notice 856B dist/commands/chat.js
npm notice 423B dist/lib/streaming.js
npm notice 2.1kB package.json
npm notice === Tarball Details ===
npm notice name: @yourname/ai-chat
npm notice version: 1.0.0
npm notice filename: yourname-ai-chat-1.0.0.tgz
npm notice package size: 1.8 kB
npm notice unpacked size: 4.6 kB
npm notice total files: 4

Alternative: prepare script

The prepare script runs on both npm install (for local development) and before npm publish:

{
"scripts": {
"build": "tsc",
"prepare": "npm run build"
}
}

Use prepare if you want npm install to automatically build. Use prepublishOnly if you only want builds before publishing.

Publishing to npm

Step 1: Create an npm Account

If you don't have an npm account:

npm adduser

Follow the prompts. For existing accounts:

npm login

Output:

npm login
# npm WARN adduser `adduser` will be replaced by `login` in a future version
# Login at:
# https://www.npmjs.com/login?next=/login/cli/abc123
# Press ENTER to open in the browser...

Step 2: Choose a Package Name

Scoped packages (@yourname/package) are easier to get unique names for:

{
"name": "@yourname/ai-chat"
}

Naming rules:

RuleExample
Lowercase onlyai-chat not AI-Chat
No spacesai-chat not ai chat
URL-safe charactersLetters, numbers, hyphens, underscores
Max 214 charactersKeep it short

Check if a name is available:

npm view @yourname/ai-chat
# npm ERR! 404 Not Found

# Name is available!

Step 3: Publish

For scoped packages, you must specify public access (scoped packages default to private):

npm publish --access public

Output:

npm publish --access public
# npm notice
# npm notice 📦 @yourname/ai-chat@1.0.0
# npm notice === Tarball Contents ===
# npm notice 1.2kB dist/index.js
# npm notice 2.1kB package.json
# npm notice === Tarball Details ===
# npm notice name: @yourname/ai-chat
# npm notice version: 1.0.0
# npm notice package size: 1.8 kB
# npm notice shasum: abc123...
# npm notice integrity: sha512-...
# npm notice total files: 2
# npm notice
# + @yourname/ai-chat@1.0.0

Your package is now live! Anyone can install it:

npm install -g @yourname/ai-chat
ai-chat "Hello from the published package!"

Publishing Updates

To publish a new version:

# Update version (patch: 1.0.0 -> 1.0.1)
npm version patch

# Or minor: 1.0.0 -> 1.1.0
npm version minor

# Or major: 1.0.0 -> 2.0.0
npm version major

# Publish
npm publish --access public

Output:

npm version patch
# v1.0.1

npm publish --access public
# + @yourname/ai-chat@1.0.1

Distribution Options Beyond npm

npm publish covers developers who have Node.js installed. For broader distribution:

MethodAudienceProsCons
npm install -gNode.js developersFamiliar, easy updatesRequires Node.js
npxQuick usersNo install neededSlower startup, requires Node.js
pkg binaryNon-developersSingle executable, no dependenciesLarger file, per-platform builds
HomebrewmacOS developersNative feel, auto-updatesmacOS only, formula maintenance
GitHub ReleasesAll platformsUniversal accessManual downloads

npx: Zero-Install Execution

Users can run your CLI without installing:

npx @yourname/ai-chat "Quick question"

Output:

npx @yourname/ai-chat "Hello"
# Need to install the following packages:
# @yourname/ai-chat@1.0.0
# Ok to proceed? (y) y
#
# Hello! How can I help you today?

npx downloads, caches, and runs in one command. Great for one-time use.

Binary Distribution with pkg

The pkg tool bundles Node.js with your code into a single executable:

npm install -g pkg
pkg dist/index.js --targets node18-macos-x64,node18-linux-x64,node18-win-x64

Output:

pkg dist/index.js --targets node18-macos-x64,node18-linux-x64,node18-win-x64
# > pkg@5.8.1
# > Targets:
# node18-macos-x64
# node18-linux-x64
# node18-win-x64
# > Output:
# index-macos
# index-linux
# index-win.exe

Users download one file and run it directly. No Node.js required.

Complete Example: From Source to Published

Here's the full workflow:

# 1. Project structure
ai-chat-cli/
├── src/
│ └── index.ts
├── dist/ # (generated)
├── package.json
└── tsconfig.json

# 2. package.json
cat package.json
{
"name": "@yourname/ai-chat",
"version": "1.0.0",
"description": "AI chat CLI with streaming support",
"type": "module",
"bin": {
"ai-chat": "./dist/index.js"
},
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"prepublishOnly": "npm run build"
},
"engines": {
"node": ">=18.0.0"
},
"dependencies": {
"commander": "^12.0.0",
"chalk": "^5.3.0",
"ora": "^8.0.0"
},
"devDependencies": {
"typescript": "^5.4.0",
"@types/node": "^20.0.0"
}
}
# 3. Entry point with shebang
head -5 src/index.ts
#!/usr/bin/env node

import { program } from "commander";
// ...
# 4. Build and test locally
npm run build
npm link
ai-chat --version
# 1.0.0

ai-chat "Test"
# (Response streams to terminal)

# 5. Publish
npm login
npm publish --access public
# + @yourname/ai-chat@1.0.0

# 6. Verify
npm unlink -g @yourname/ai-chat
npm install -g @yourname/ai-chat
ai-chat "Hello from npm!"
# (Works!)

Output:

+ @yourname/ai-chat@1.0.0
added 1 package in 2s

Common Mistakes

Missing shebang

// WRONG - no shebang
import { program } from "commander";

// RIGHT - with shebang
#!/usr/bin/env node
import { program } from "commander";

Pointing bin to TypeScript

// WRONG - can't execute .ts directly
{
"bin": {
"ai-chat": "./src/index.ts"
}
}

// RIGHT - point to compiled JavaScript
{
"bin": {
"ai-chat": "./dist/index.js"
}
}

Forgetting files field

// RISKY - publishes everything
{
"name": "@yourname/ai-chat",
"bin": { "ai-chat": "./dist/index.js" }
}

// SAFE - publishes only dist
{
"name": "@yourname/ai-chat",
"bin": { "ai-chat": "./dist/index.js" },
"files": ["dist"]
}

Publishing without building

# WRONG - publishes stale code
npm publish

# RIGHT - prepublishOnly builds automatically
{
"scripts": {
"prepublishOnly": "npm run build"
}
}
npm publish

Try With AI

I'm trying to test my CLI with npm link but it's not working.

Here's my setup:
- package.json has: "bin": { "mycli": "./dist/index.js" }
- I ran npm link and it succeeded
- But when I type "mycli" I get "command not found"

Help me debug this. What are the common causes and how do I fix each one?

What you're learning: Systematic debugging of distribution issues. Understanding where symlinks are created and why they might fail helps you troubleshoot real packaging problems.

Prompt 2: Design a Version Strategy

I'm publishing my first npm package. I need to understand semantic versioning:

1. When do I bump patch vs minor vs major?
2. What happens if I publish a breaking change as a patch?
3. How do I handle pre-releases (beta, rc versions)?
4. What's the npm version command doing under the hood?

Give me practical examples for a CLI tool.

What you're learning: Professional versioning practices that communicate change magnitude to users. Getting this right builds trust with your package consumers.

Prompt 3: Plan Multi-Platform Distribution

My AI CLI is popular with developers (npm works fine) but I want to
distribute to non-technical users who don't have Node.js.

Help me plan a distribution strategy:
1. What are my options for creating standalone binaries?
2. How do I handle platform-specific builds (macOS, Windows, Linux)?
3. How do I set up automated releases on GitHub?
4. What's the tradeoff between binary size and convenience?

I want this to be professional, like how Claude Code is distributed.

What you're learning: Enterprise distribution thinking. Understanding the full spectrum from npm packages to standalone binaries prepares you for production-grade tool delivery.


Safety note: Before publishing, verify you're not including sensitive data. Check your published package contents with npm pack --dry-run before npm publish. Never include .env files, API keys, or credentials in your package. The files field is your defense: explicitly list only what should be public.