TypeScript CLI Distribution: tsx

· combray's blog


Summary #

tsx (TypeScript Execute) is the recommended solution for distributing TypeScript CLI tools via npm without a build step. With 11.6k GitHub stars and 416k dependents[1], tsx has become the de facto standard for running TypeScript directly in Node.js. It uses esbuild under the hood for fast transpilation and requires zero configuration—no tsconfig.json needed to get started[2].

For distributing a CLI as a Claude Code plugin, you can ship TypeScript source files directly and use the shebang #!/usr/bin/env -S npx tsx to make your entry point executable. This approach eliminates the build step entirely while providing full TypeScript support including features that Node.js native type-stripping doesn't handle (like path aliases and certain tsconfig options)[3].

While Node.js 23.6+ now includes native TypeScript support via type-stripping[4], it still has significant limitations: it ignores tsconfig.json, doesn't support features like paths aliases, and the Node.js team still recommends against production use[5]. tsx provides a more complete and battle-tested solution.

Philosophy & Mental Model #

tsx is a drop-in replacement for node that transparently handles TypeScript. Think of it as node with superpowers:

The key mental model: tsx makes TypeScript files behave like JavaScript files. Anywhere you'd use node script.js, you can use tsx script.ts.

Setup #

For a Distributable CLI Package #

1. Initialize your package:

1npm init -y

2. Install tsx as a regular dependency:

1npm install tsx

Important: Use dependencies, not devDependencies, since consumers need tsx to run your CLI.

3. Create your CLI entry point (e.g., cli.ts):

 1#!/usr/bin/env -S npx tsx
 2
 3import { Command } from 'commander';
 4
 5const program = new Command();
 6
 7program
 8  .name('my-cli')
 9  .description('My awesome CLI tool')
10  .version('1.0.0');
11
12program
13  .argument('<input>', 'input to process')
14  .action((input) => {
15    console.log(`Processing: ${input}`);
16  });
17
18program.parse();

4. Configure package.json:

 1{
 2  "name": "my-cli",
 3  "version": "1.0.0",
 4  "type": "module",
 5  "bin": {
 6    "my-cli": "./cli.ts"
 7  },
 8  "files": [
 9    "cli.ts",
10    "src/**/*.ts"
11  ],
12  "dependencies": {
13    "tsx": "^4.21.0"
14  }
15}

5. Make executable (for local testing):

1chmod +x cli.ts

6. Test locally:

1# Direct execution
2./cli.ts hello
3
4# Via npx (simulates how users will run it)
5npx . hello

Publishing #

1npm publish

Users can then run your CLI with:

1npx my-cli hello

Core Usage Patterns #

Pattern 1: The Shebang for Distribution #

The magic line that makes your TypeScript file executable:

1#!/usr/bin/env -S npx tsx

Why -S? The -S flag tells env to split the argument string. Without it, npx tsx would be treated as a single command name. This works on macOS and modern Linux (coreutils 8.30+)[6].

Why npx? Using npx tsx instead of just tsx ensures tsx is available even if not globally installed—npx will fetch it if needed.

Pattern 2: Hybrid CLI and Library #

Create a single file that works both as an executable CLI and an importable module[7]:

 1#!/usr/bin/env -S npx tsx
 2
 3// Exported function for library usage
 4export function processData(input: string): string {
 5  return input.toUpperCase();
 6}
 7
 8// CLI entry point - only runs when executed directly
 9if (import.meta.url === `file://${process.argv[1]}`) {
10  const args = process.argv.slice(2);
11  if (args.length === 0) {
12    console.error('Usage: my-cli <input>');
13    process.exit(1);
14  }
15  console.log(processData(args[0]));
16}

Users can:

Pattern 3: Multi-File Project Structure #

my-cli/
├── cli.ts          # Entry point with shebang
├── src/
│   ├── commands/
│   │   ├── generate.ts
│   │   └── process.ts
│   ├── utils/
│   │   └── helpers.ts
│   └── index.ts    # Re-exports for library usage
├── package.json
└── tsconfig.json   # Optional but recommended

cli.ts:

1#!/usr/bin/env -S npx tsx
2
3import { program } from './src/commands/index.js';
4program.parse();

Note: Use .js extensions in imports even for .ts files when using ESM. tsx handles the resolution.

Pattern 4: Watch Mode for Development #

During development, use watch mode to auto-reload on changes:

1npx tsx watch ./cli.ts

Or add to package.json:

1{
2  "scripts": {
3    "dev": "tsx watch ./cli.ts",
4    "start": "tsx ./cli.ts"
5  }
6}

Pattern 5: Environment Variables with dotenv #

tsx works seamlessly with dotenv:

1#!/usr/bin/env -S npx tsx
2
3import 'dotenv/config';
4
5console.log(process.env.API_KEY);

Anti-Patterns & Pitfalls #

Don't: Rely on tsx for Type Checking #

1// This will run successfully despite the type error!
2const x: number = "not a number";
3console.log(x);

Why it's wrong: tsx strips types without checking them. Your code will run but may have runtime errors that TypeScript would have caught.

Instead: Run tsc Separately #

1{
2  "scripts": {
3    "typecheck": "tsc --noEmit",
4    "build": "npm run typecheck && npm run start"
5  }
6}

Don't: Use #!/usr/bin/env tsx Without -S npx #

1#!/usr/bin/env tsx  // Won't work if tsx isn't globally installed

Why it's wrong: This requires tsx to be globally installed on the user's system, which you can't guarantee.

Instead: Always Use the Full Shebang #

1#!/usr/bin/env -S npx tsx

Don't: Put tsx in devDependencies for CLIs #

1{
2  "devDependencies": {
3    "tsx": "^4.21.0"  // Wrong for distributed CLIs!
4  }
5}

Why it's wrong: When users install your package, devDependencies aren't installed. Your CLI will fail because tsx won't be available.

Instead: Use Regular Dependencies #

1{
2  "dependencies": {
3    "tsx": "^4.21.0"
4  }
5}

Don't: Forget the files Field #

1{
2  "bin": {
3    "my-cli": "./cli.ts"
4  }
5  // Missing "files" field!
6}

Why it's wrong: npm might not include your TypeScript source files in the published package.

Instead: Explicitly List Files #

1{
2  "bin": {
3    "my-cli": "./cli.ts"
4  },
5  "files": [
6    "cli.ts",
7    "src/**/*.ts"
8  ]
9}

Don't: Mix CommonJS and ESM Carelessly #

1// In an ESM project (type: "module")
2const fs = require('fs');  // This will fail

Why it's wrong: tsx respects your module system. If you're using ESM, use ESM imports.

Instead: Be Consistent With Your Module System #

1// In ESM (type: "module")
2import fs from 'fs';
3import { readFileSync } from 'fs';
4
5// Or use createRequire if you must use require
6import { createRequire } from 'module';
7const require = createRequire(import.meta.url);

Caveats #

References #

[1] tsx GitHub Repository - GitHub stars count and dependents data

[2] tsx Official Documentation - Getting Started - Zero-config setup and installation guide

[3] How to run TypeScript files directly in Node.js in 2025 - Comparison of tsx vs Node.js native TypeScript support

[4] Node.js 23.6 Now Runs TypeScript Natively - InfoQ - Node.js native TypeScript type stripping announcement

[5] Node's new built-in support for TypeScript - Detailed analysis of limitations and production readiness

[6] Stack Overflow: Using NPX command for shell script shebang - Explanation of the -S flag requirement

[7] Creating a Hybrid TypeScript CLI and Library Function with tsx - Pattern for dual CLI/library usage

[8] ts-node GitHub Issue #995 - Discussion of npx overhead and shebang patterns

[9] npm Community Forum: Making a command for a package - npm's cross-platform executable handling

[10] Add CLI Scripts to Your TypeScript/Node Project with TSX - Practical guide for CLI script creation

last updated: