Building CLI Tools with AI: From Idea to npm Package
You have a workflow that involves four terminal commands, a copy-paste from a config file, and a silent prayer that you remembered the right flag order. You've been doing it manually for months. You know it should be a CLI tool. You just never built one because the setup ceremony felt like more work than the problem itself.
That calculus changed in 2026. With AI coding agents like Claude Code, Gemini CLI, and Codex, you can go from "I wish this existed" to a published npm package in an afternoon. Not a toy demo -- a real tool with argument parsing, interactive prompts, error handling, tests, and a proper npx-able distribution.
This guide walks through the entire pipeline: scaffolding a CLI project, wiring up Commander.js and Inquirer.js, writing tests, and publishing to npm -- all with an AI agent doing the heavy lifting while you steer.
๐ What You'll Need
- Node.js 20+ -- LTS is currently v22. Check with
node --version - An npm account -- Free at npmjs.com. You'll need this to publish
- An AI coding agent -- Claude Code, Gemini CLI, or Codex CLI. This guide uses Claude Code for examples, but the workflow applies to any terminal-based agent
- A real problem to solve -- The best CLI tools scratch your own itch. Think about a workflow you repeat weekly
- Basic TypeScript familiarity -- You don't need to be an expert, but you should know what an interface is
๐ง Why CLI Tools Are the Perfect AI Project
Before we get into the build, here's why CLI tools are uniquely well-suited to AI-assisted development:
The scope is naturally bounded. A CLI tool takes input, does a thing, produces output. There's no state management, no UI framework, no auth layer. The surface area is small enough that an AI agent can hold the entire project in context.
The feedback loop is instant. Run the command, see the output, fix the issue. No browser refreshes, no deployment pipelines, no waiting for CI. You can iterate a dozen times in five minutes.
The testing story is clean. CLI tools are essentially functions: given these arguments, produce this output. That maps directly to unit tests. AI agents are exceptionally good at generating these.
The distribution is solved. npm publish and your tool is instantly available to every developer on earth via npx. No app stores, no review processes, no infrastructure.
Here's what the development flow looks like with an AI agent in the loop:
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
โ Describe โโโโโโบโ AI Builds โโโโโโบโ You Test โโโโโโบโ AI Fixes โ
โ the Feature โ โ First Draft โ โ & Review โ โ & Iterates โ
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Repeat until done
The key insight: you're the product manager and QA engineer, the AI is the developer. You define what the tool should do and verify it works. The agent writes the code and fixes the bugs.
๐๏ธ Scaffolding the Project
Let's build a real tool. We'll create quickenv -- a CLI that generates .env files from templates, with support for multiple environments (dev, staging, production) and interactive prompts for missing values. It's the kind of thing every team needs and nobody wants to build.
The AI-First Approach
Open your terminal, create a directory, and start a Claude Code session:
mkdir quickenv && cd quickenv
claude
Now give Claude the full picture upfront. Don't trickle requirements -- front-load the context:
> Initialize a TypeScript CLI project called "quickenv". It should:
> - Use Commander.js for argument parsing
> - Use Inquirer.js for interactive prompts
> - Use Chalk for colored output
> - Have a bin entry point at ./dist/index.js
> - Use tsup for bundling
> - Use Vitest for testing
> - Include a proper .gitignore, tsconfig.json, and package.json
> - The package.json "name" should be "quickenv"
> - Add a "bin" field mapping "quickenv" to "./dist/index.js"
Claude will scaffold the entire project. Here's what the generated package.json should look like:
{
"name": "quickenv",
"version": "0.1.0",
"description": "Generate .env files from templates with interactive prompts",
"type": "module",
"bin": {
"quickenv": "./dist/index.js"
},
"scripts": {
"build": "tsup src/index.ts --format esm --dts",
"dev": "tsup src/index.ts --format esm --watch",
"test": "vitest run",
"test:watch": "vitest",
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build"
},
"keywords": ["cli", "env", "dotenv", "template", "environment"],
"author": "Your Name",
"license": "MIT",
"dependencies": {
"chalk": "^5.4.1",
"commander": "^14.0.0",
"inquirer": "^12.3.0"
},
"devDependencies": {
"tsup": "^8.4.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
}
}
"prepublishOnly": "npm run build" in your scripts. This ensures the package is compiled before every publish, so you never accidentally ship stale JavaScript.
The Shebang Line
The entry point needs a shebang so the OS knows to run it with Node:
#!/usr/bin/env node
import { Command } from "commander";
import { createEnvFile } from "./commands/create.js";
import { listTemplates } from "./commands/list.js";
const program = new Command();
program
.name("quickenv")
.description("Generate .env files from templates")
.version("0.1.0");
program
.command("create")
.description("Create a .env file from a template")
.argument("[template]", "Template name (dev, staging, prod)")
.option("-o, --output <path>", "Output file path", ".env")
.option("-f, --force", "Overwrite existing .env file")
.option("-i, --interactive", "Prompt for each value")
.action(createEnvFile);
program
.command("list")
.description("List available templates")
.action(listTemplates);
program.parse();
Notice the structure: one file per command, imported into a thin entry point. This is the pattern Commander.js was designed for, and it keeps each command testable in isolation.
The tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}
โ๏ธ Building the Core Commands
This is where the AI agent earns its keep. Instead of writing the implementation yourself, describe the behavior you want and let the agent build it.
The create Command
Tell Claude what the command should do:
> Build the "create" command in src/commands/create.ts. It should:
> 1. Look for template files in a .quickenv/ directory (in the project root
> or home directory)
> 2. Templates are .env-style files with optional {{PROMPT:label}} placeholders
> 3. If --interactive flag is set, prompt the user for each placeholder value
> using Inquirer.js
> 4. If a value exists in the current environment, use it as the default
> 5. Write the resulting .env file to the --output path
> 6. Refuse to overwrite unless --force is passed
> 7. Print a summary of what was written using Chalk
Here's what a well-structured implementation looks like:
// src/commands/create.ts
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import chalk from "chalk";
import { input } from "@inquirer/prompts";
interface CreateOptions {
output: string;
force?: boolean;
interactive?: boolean;
}
interface TemplateVariable {
key: string;
label: string;
defaultValue?: string;
}
const PLACEHOLDER_REGEX = /\{\{PROMPT:(.+?)\}\}/g;
export async function createEnvFile(
templateName: string | undefined,
options: CreateOptions
): Promise<void> {
const template = templateName ?? "dev";
const templatePath = await resolveTemplatePath(template);
if (!templatePath) {
console.error(
chalk.red(`Template "${template}" not found.`),
chalk.dim("Run `quickenv list` to see available templates.")
);
process.exit(1);
}
// Check if output file exists
if (!options.force) {
const exists = await fileExists(options.output);
if (exists) {
console.error(
chalk.red(`${options.output} already exists.`),
chalk.dim("Use --force to overwrite.")
);
process.exit(1);
}
}
const raw = await fs.readFile(templatePath, "utf-8");
const variables = parseVariables(raw);
let result = raw;
if (variables.length > 0 && options.interactive) {
console.log(chalk.blue(`\nFilling ${variables.length} template variable(s):\n`));
for (const variable of variables) {
const envDefault = process.env[variable.key];
const answer = await input({
message: variable.label,
default: envDefault ?? variable.defaultValue ?? "",
});
result = result.replace(`{{PROMPT:${variable.label}}}`, answer);
}
} else {
// Strip unfilled placeholders, leave the key with empty value
result = result.replace(PLACEHOLDER_REGEX, "");
}
await fs.writeFile(options.output, result, "utf-8");
const lineCount = result.split("\n").filter((l) => l.trim() && !l.startsWith("#")).length;
console.log(
chalk.green(`\nโ Created ${options.output}`),
chalk.dim(`(${lineCount} variables from "${template}" template)`)
);
}
function parseVariables(content: string): TemplateVariable[] {
const variables: TemplateVariable[] = [];
let match: RegExpExecArray | null;
while ((match = PLACEHOLDER_REGEX.exec(content)) !== null) {
const label = match[1];
const lineMatch = content.match(new RegExp(`^([A-Z_]+)=.*${escapeRegex(match[0])}`, "m"));
variables.push({
key: lineMatch?.[1] ?? label.toUpperCase().replace(/\s+/g, "_"),
label,
defaultValue: undefined,
});
}
return variables;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
async function resolveTemplatePath(name: string): Promise<string | null> {
const candidates = [
path.join(process.cwd(), ".quickenv", `${name}.env`),
path.join(os.homedir(), ".quickenv", `${name}.env`),
];
for (const candidate of candidates) {
if (await fileExists(candidate)) {
return candidate;
}
}
return null;
}
async function fileExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
The Template Format
A .quickenv/dev.env template file looks like this:
# Database
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_NAME=myapp_dev
DATABASE_USER={{PROMPT:Database username}}
DATABASE_PASSWORD={{PROMPT:Database password}}
# Redis
REDIS_URL=redis://localhost:6379
# App
APP_SECRET={{PROMPT:App secret key}}
APP_ENV=development
APP_DEBUG=true
When a user runs quickenv create dev --interactive, they get prompted for the three placeholder values. Everything else is filled from the template as-is.
The list Command
This one's simpler -- a good place to see how straightforward CLI commands can be:
// src/commands/list.ts
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import chalk from "chalk";
export async function listTemplates(): Promise<void> {
const dirs = [
{ path: path.join(process.cwd(), ".quickenv"), label: "Project" },
{ path: path.join(os.homedir(), ".quickenv"), label: "Global" },
];
let found = false;
for (const dir of dirs) {
try {
const files = await fs.readdir(dir.path);
const templates = files.filter((f) => f.endsWith(".env"));
if (templates.length > 0) {
found = true;
console.log(chalk.bold(`\n${dir.label} templates (${dir.path}):`));
for (const file of templates) {
const name = file.replace(".env", "");
const content = await fs.readFile(path.join(dir.path, file), "utf-8");
const varCount = content.split("\n").filter(
(l) => l.trim() && !l.startsWith("#")
).length;
console.log(` ${chalk.cyan(name)} ${chalk.dim(`(${varCount} variables)`)}`);
}
}
} catch {
// Directory doesn't exist, skip
}
}
if (!found) {
console.log(chalk.yellow("No templates found."));
console.log(chalk.dim("Create a .quickenv/ directory with .env template files to get started."));
}
}
๐งช Testing Your CLI
This is where most CLI tutorials wave their hands and say "testing is left as an exercise." Don't skip this. CLI tools that aren't tested break silently and frustrate users.
Setting Up Vitest
Your vitest.config.ts:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: true,
environment: "node",
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
exclude: ["src/index.ts"],
},
},
});
Testing Command Logic
The key to testable CLIs is separating the command logic from the Commander.js wiring. Your createEnvFile function is a plain async function -- it doesn't know or care about Commander. That means you can test it directly:
// src/commands/__tests__/create.test.ts
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { createEnvFile } from "../create.js";
describe("createEnvFile", () => {
let tmpDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "quickenv-test-"));
const templateDir = path.join(tmpDir, ".quickenv");
await fs.mkdir(templateDir);
await fs.writeFile(
path.join(templateDir, "dev.env"),
"DATABASE_HOST=localhost\nDATABASE_PORT=5432\nAPP_ENV=development\n"
);
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("creates .env from template", async () => {
const outputPath = path.join(tmpDir, ".env");
const originalCwd = process.cwd();
try {
process.chdir(tmpDir);
await createEnvFile("dev", { output: outputPath });
const content = await fs.readFile(outputPath, "utf-8");
expect(content).toContain("DATABASE_HOST=localhost");
expect(content).toContain("APP_ENV=development");
} finally {
process.chdir(originalCwd);
}
});
it("refuses to overwrite without --force", async () => {
const outputPath = path.join(tmpDir, ".env");
await fs.writeFile(outputPath, "existing content");
const originalCwd = process.cwd();
const mockExit = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit called");
});
try {
process.chdir(tmpDir);
await expect(
createEnvFile("dev", { output: outputPath })
).rejects.toThrow("process.exit called");
} finally {
process.chdir(originalCwd);
mockExit.mockRestore();
}
});
it("overwrites with --force flag", async () => {
const outputPath = path.join(tmpDir, ".env");
await fs.writeFile(outputPath, "old content");
const originalCwd = process.cwd();
try {
process.chdir(tmpDir);
await createEnvFile("dev", { output: outputPath, force: true });
const content = await fs.readFile(outputPath, "utf-8");
expect(content).toContain("DATABASE_HOST=localhost");
expect(content).not.toContain("old content");
} finally {
process.chdir(originalCwd);
}
});
});
The AI Testing Workflow
Here's the prompt pattern that generates good test coverage:
> Write comprehensive tests for src/commands/create.ts using Vitest. Cover:
> 1. Happy path: template exists, output doesn't, creates file
> 2. Template not found: exits with error
> 3. Output exists without --force: exits with error
> 4. Output exists with --force: overwrites
> 5. Template with placeholders but no --interactive: strips placeholders
> 6. Edge case: empty template file
> Run the tests and fix any failures.
That last line -- "Run the tests and fix any failures" -- is critical. It creates a feedback loop where the AI agent iterates until everything passes:
$ npm test
โ src/commands/__tests__/create.test.ts (6 tests)
โ src/commands/__tests__/list.test.ts (3 tests)
Test Files 2 passed (2)
Tests 9 passed (9)
npm test after every implementation change. The tight write-test-fix loop is where AI agents genuinely outperform manual development. They don't get frustrated by red tests -- they just fix them.
๐ฆ Publishing to npm
You've built the tool, it works, the tests pass. Time to ship it.
Pre-Publish Checklist
Before you run npm publish, get these right:
| Item | Why It Matters | How to Check |
|---|---|---|
"name" in package.json |
Must be unique on npm | Search npmjs.com |
"bin" field |
Maps command name to entry point | โ
"quickenv": "./dist/index.js" |
"files" field |
Controls what gets published | โ
["dist"] -- don't ship src/ or tests |
"type": "module" |
ESM support | โ Required for modern Node.js |
"engines" field |
Minimum Node.js version | โ
{"node": ">=20"} |
README.md |
Your npm page content | โ Include usage examples |
LICENSE |
Legal protection | โ MIT is the standard for CLI tools |
.npmignore or "files" |
Exclude dev files | โ Only ship compiled output |
Add the "files" field to your package.json:
{
"files": ["dist", "README.md", "LICENSE"]
}
This is cleaner than .npmignore -- it's an allowlist instead of a blocklist. Only these files end up in the published package.
Verify Before Publishing
Always do a dry run first:
npm pack --dry-run
This shows exactly what files will be included in the package. If you see src/, tests/, node_modules/, or .env -- stop and fix your "files" field.
npm pack
This creates a .tgz file you can inspect. You can even install it locally to test:
npm install -g ./quickenv-0.1.0.tgz
quickenv --help
The Actual Publish
# Login to npm (first time only)
npm login
# Publish a public package
npm publish --access public
If you're using a scoped name (like @yourusername/quickenv), the --access public flag is required for the first publish -- scoped packages default to private.
Publishing With Provenance
In 2026, npm provenance is becoming the standard for supply chain security. It cryptographically links your published package to the exact Git commit and CI workflow that built it:
# .github/workflows/publish.yml
name: Publish to npm
on:
release:
types: [created]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: "https://registry.npmjs.org"
- run: npm ci
- run: npm test
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
When users see the "Built and signed on GitHub Actions" badge on your npm page, they know the code came from your repo and wasn't tampered with.
๐ง Polishing the Developer Experience
A CLI tool that works is table stakes. A CLI tool that feels good is what gets adopted. Here's where to invest the polish.
Helpful Error Messages
Bad:
Error: ENOENT: no such file or directory
Good:
โ Template "staging" not found.
Looked in:
โ /Users/you/project/.quickenv/staging.env
โ /Users/you/.quickenv/staging.env
Run `quickenv list` to see available templates.
Run `quickenv create dev` to use the default template.
Tell your AI agent: "Rewrite all error messages to include what went wrong, where the tool looked, and what the user should do next."
Progress Indicators
For operations that take more than a second, show progress. The ora package gives you spinners:
import ora from "ora";
const spinner = ora("Generating .env file...").start();
// ... do work ...
spinner.succeed("Created .env (12 variables)");
Auto-Generated Help
Commander.js generates --help output automatically, but you can enhance it:
program
.command("create")
.description("Create a .env file from a template")
.argument("[template]", "Template name (dev, staging, prod)", "dev")
.addHelpText("after", `
Examples:
$ quickenv create # Uses "dev" template
$ quickenv create staging # Uses "staging" template
$ quickenv create dev -i # Interactive mode, prompts for values
$ quickenv create prod -o .env.production -f
`);
Version Checking
Let users know when a new version is available. The update-notifier pattern:
import updateNotifier from "update-notifier";
import { readFileSync } from "node:fs";
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
updateNotifier({ pkg }).notify();
This checks npm once a day (non-blocking) and shows a message if a newer version exists.
๐ ๏ธ Troubleshooting
"command not found: quickenv" after global install
The npm global bin directory isn't in your PATH. Fix it:
# Find where npm installs global bins
npm config get prefix
# Add to your shell profile (~/.zshrc or ~/.bashrc)
export PATH="$(npm config get prefix)/bin:$PATH"
Alternatively, use npx quickenv which doesn't require global installation.
"ERR! 403 You do not have permission to publish"
Three common causes:
- Package name taken -- search npmjs.com and pick a unique name or use a scope (
@yourname/quickenv) - Not logged in -- run
npm loginand verify withnpm whoami - Scoped package defaults to private -- add
--access publicto the publish command
TypeScript compilation errors after AI generation
AI agents sometimes generate code targeting the wrong TypeScript version. Check:
# Verify your tsconfig.json matches your Node.js version
node --version # Should be v20+ for ES2022 target
npx tsc --noEmit # Run type checking without building
Common fix: set "moduleResolution": "bundler" in tsconfig.json when using tsup. The "node" resolution mode doesn't handle .js extensions in ESM imports correctly.
Tests pass locally but npm pack produces broken package
The build output might be stale. Always rebuild before packing:
rm -rf dist && npm run build && npm pack --dry-run
Check that dist/index.js starts with #!/usr/bin/env node. If tsup strips the shebang, add a banner option to your tsup.config.ts:
import { defineConfig } from "tsup";
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
banner: {
js: "#!/usr/bin/env node",
},
});
Inquirer.js prompts hang in CI environments
Interactive prompts wait for user input that never comes in CI. Guard against this:
if (!process.stdin.isTTY && options.interactive) {
console.error(chalk.red("Interactive mode requires a terminal. Use non-interactive mode in CI."));
process.exit(1);
}
๐ The AI-Assisted Maintenance Loop
Publishing version 0.1.0 is the beginning, not the end. Here's the ongoing workflow for maintaining a CLI tool with AI assistance:
Bug reports become fix prompts. When someone files an issue like "quickenv crashes when template has Windows line endings," you paste the issue into Claude Code and say:
> Read the GitHub issue above. Reproduce the bug with a test, then fix it.
> Run all existing tests to make sure nothing else breaks.
Feature requests become specs. A user asks for YAML template support. You tell the agent:
> Add support for .yaml template files alongside .env templates. The YAML
> format should support nested keys that get flattened with underscores
> (e.g., database.host becomes DATABASE_HOST). Write tests. Update the
> README. Bump the minor version.
Dependency updates become one-liners. When npm audit shows vulnerabilities:
> Run npm audit, update any vulnerable dependencies to safe versions.
> Run the test suite to verify nothing broke.
The pattern is consistent: describe the outcome, let the agent handle implementation, verify with tests. It works for the initial build and it works for every update after.
๐ What's Next
Now that you know the full pipeline from idea to published npm package:
- Add GitHub Actions CI to run tests on every push and auto-publish on release tags -- the provenance workflow above is a solid starting point
- Explore MCP integrations to connect your CLI tool's development to external services. See our Claude Code Workflow Guide for MCP setup patterns
- Study how other CLI tools are built -- look at the source for
degit,create-t3-app, orchangesetsfor patterns worth stealing - Read up on prompt engineering to write better instructions for your AI agent. Our Prompt Engineering for Code guide covers the patterns that produce the best results
- Build more tools. The second one takes half the time. The third one takes a quarter. The muscle memory of describing intent to an AI agent compounds fast
The gap between "I wish this existed" and "I published it" has never been smaller. The tools are there. The distribution is free. The AI agent handles the boilerplate. All that's left is your idea and an afternoon.
Want to see how AI coding agents compare for projects like this? Read our AI Coding Agents Compared breakdown, or explore how The Rise of the AI Engineer is reshaping what developers build.