Node CLI Tool Building skill for Codex
Node CLIs that run from npx and never hang in CI. Codex builds Node command-line tools with Commander for subcommands and help, and Clack for prompts that each have a flag equivalent and switch off when there is no TTY. Results go to stdout, noise to stderr, exit codes mean something, and the package is checked with npm pack and a clean npx run before you approve publishing.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- cli-tool-building Installs to ~/.agents/skills/cli-tool-building/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install cli-tool-building The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project cli-tool-building Installs to .agents/skills/cli-tool-building/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/cli-tool-building.zipunzip -o cli-tool-building.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) cli-tool-building PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $cli-tool-building, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds and publishes Node.js command-line tools with Commander for arguments and subcommands and @clack/prompts for interactive flows - bin and shebang setup, ESM packaging, TTY and CI detection, exit codes, stdout versus stderr, JSON output, tests, npm link, npm pack checks and npx or npm create usage. Use when asked to build a CLI in Node or TypeScript, add an interactive setup wizard, make a tool runnable with npx, scaffold a create-* initializer, or fix a CLI that hangs in CI or breaks when installed globally.
Use it when
- You are building a command-line tool in Node.js or TypeScript
- A CLI needs an interactive setup wizard that also works non-interactively
- A tool should be installable and runnable with npx
- An existing CLI misbehaves in pipes, CI or after a global install
Use something else when
- The CLI is written in Python with Typer or Click: use python-cli-tools
- You only need better --help text and man pages: use cli-help-text
How to check the result
Install the packed tarball in a temp folder, run it via npx with --help and a real command, pipe its output to confirm stdout carries only data, and check the exit codes.
The mistake it prevents
Prompting unconditionally, so the tool waits forever on stdin in CI, Docker builds and shell pipes.
A good CLI does the obvious thing with no flags, explains itself with --help, never
prompts when it is not attached to a terminal, writes data to stdout and everything else
to stderr, and exits non-zero on any failure. It installs and runs from npx on a clean
machine, not just from the author’s checkout.
Stack defaults
| Need | Default | Notes |
|---|---|---|
| Argument parsing, subcommands, help | commander | Current major needs Node 20+; check engines before bumping |
| Interactive prompts, spinners | @clack/prompts | Every prompt must have a flag equivalent |
| Tiny script, one or two flags | node:util parseArgs | No dependency at all |
| Colours | node:util styleText or picocolors | Respect NO_COLOR and non-TTY output |
| Build TypeScript | tsc or tsup to dist/ | Ship JS, not .ts; tsx only in dev |
Read AGENTS.md and package.json first: package manager, module type, Node version,
lint and test commands. Installing dependencies needs network; ask for approval in a
sandbox with network off. For Python CLIs use $python-cli-tools; for Rust, $rust-clap-cli.
Package layout
{
"name": "@acme/envcheck",
"version": "0.1.0",
"type": "module",
"bin": { "envcheck": "./dist/cli.js" },
"files": ["dist"],
"engines": { "node": ">=20" },
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "node --test",
"prepublishOnly": "npm run build && npm test"
},
"dependencies": { "@clack/prompts": "^0.11.0", "commander": "^14.0.0" }
}
- The
bintarget must start with#!/usr/bin/env node. tsc keeps a shebang on the first line of the source file. fileswhitelists what ships; check withnpm pack --dry-run.- A package named
create-foomakesnpm create foo(andnpx create-foo) work. - Pin versions to what
npm view <pkg> versionreports when adding dependencies; the ranges above are examples.
Entry point
#!/usr/bin/env node
import { Command, Option } from 'commander';
import { readFile } from 'node:fs/promises';
import { checkEnv } from './check.js';
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
const program = new Command();
program
.name('envcheck')
.description('Compare a .env file against .env.example')
.version(pkg.version);
program
.command('check')
.description('report missing and extra keys')
.argument('[file]', 'env file to check', '.env')
.option('-e, --example <path>', 'reference file', '.env.example')
.addOption(new Option('-f, --format <type>', 'output format').choices(['text', 'json']).default('text'))
.option('--strict', 'exit 1 when extra keys exist')
.action(async (file: string, opts) => {
const result = await checkEnv(file, opts.example);
if (opts.format === 'json') {
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
} else {
for (const k of result.missing) console.log(`missing ${k}`);
for (const k of result.extra) console.log(`extra ${k}`);
}
if (result.missing.length || (opts.strict && result.extra.length)) process.exitCode = 1;
});
program.parseAsync().catch((err: Error) => {
console.error(`envcheck: ${err.message}`);
process.exitCode = 2;
});
Set process.exitCode instead of calling process.exit() mid-flight so pending output
flushes. Throw user-facing errors with a clear message; show a stack only with --debug.
Interactive flows with Clack
import * as p from '@clack/prompts';
export async function init(opts: { name?: string; template?: string; yes?: boolean }) {
const interactive = process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !opts.yes;
if (!interactive) {
if (!opts.name) throw new Error('--name is required in non-interactive mode');
return create(opts.name, opts.template ?? 'basic');
}
p.intro('create-acme-app');
const name = opts.name ?? await p.text({
message: 'Project name?',
placeholder: 'my-app',
validate: (v) => (/^[a-z0-9-]+$/.test(v ?? '') ? undefined : 'lowercase letters, digits, hyphens'),
});
if (p.isCancel(name)) { p.cancel('Cancelled'); process.exit(130); }
const template = opts.template ?? await p.select({
message: 'Template',
options: [{ value: 'basic', label: 'Basic' }, { value: 'api', label: 'API server', hint: 'Hono' }],
});
if (p.isCancel(template)) { p.cancel('Cancelled'); process.exit(130); }
const s = p.spinner();
s.start('Copying files');
await create(name, template);
s.stop('Project created');
p.outro(`cd ${name} && npm install`);
}
Rules: every prompt has a flag; --yes accepts defaults; check isCancel after every
prompt (Ctrl-C returns a symbol, not an exception); never prompt when stdin is not a TTY,
because CI and pipes will hang forever.
Output contract
| Stream / signal | Use |
|---|---|
| stdout | The result: data, paths, JSON. Pipeable |
| stderr | Progress, spinners, warnings, errors, prompts |
| exit 0 | Success, including “nothing to do” |
| exit 1 | The check ran and found problems |
| exit 2 | Usage or configuration error |
| exit 130 | Cancelled by the user (Ctrl-C) |
Offer --json (or --format json) on any command whose output someone might script.
Disable colour and spinners when !process.stdout.isTTY or NO_COLOR is set. Help text
quality (examples, man pages) is covered in $cli-help-text.
Testing
// test/cli.test.js - run the built binary as a user would
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
test('json output lists missing keys', () => {
let out;
try {
execFileSync('node', ['dist/cli.js', 'check', 'test/fixtures/.env',
'-e', 'test/fixtures/.env.example', '-f', 'json'], { encoding: 'utf8' });
} catch (err) {
assert.equal(err.status, 1);
out = JSON.parse(err.stdout);
}
assert.deepEqual(out.missing, ['DATABASE_URL']);
});
Test the core logic as plain functions, and a handful of end-to-end runs of the binary for exit codes, stdout shape and non-TTY behaviour (spawned processes are not TTYs, so these tests also prove prompts are skipped).
Release checklist
- [ ] npm run build && npm test
- [ ] npm pack --dry-run: only dist/, README, LICENSE, package.json listed
- [ ] npm pack, then in a temp dir: npx /path/to/acme-envcheck-0.1.0.tgz --help
- [ ] npm link, run from another directory, npm unlink
- [ ] Version bumped (npm version patch|minor|major) and changelog updated
- [ ] User confirms, then npm publish (scoped public packages need --access public)
Publishing is outward-facing and irreversible for that version number: never run
npm publish without the user’s explicit go-ahead. From GitHub Actions, prefer trusted
publishing or npm publish --provenance over long-lived tokens.
Gotchas
__dirnamedoes not exist in ESM; usenew URL('./x', import.meta.url)orimport.meta.dirname.- Windows has no shebangs; npm generates
.cmdshims frombin, so the entry must be a plain JS file, not a shell script. - Forgetting
filespublishessrc/, tests and sometimes.env; forgettingdist/infilespublishes a package that cannot run. - A
postinstallscript that downloads things breaks offline and locked-down installs; avoid it. - Top-level
awaitwithprogram.parse()(sync) skips async action errors; useparseAsync. npx fooresolves the npm package namedfoo, which may not be yours if your bin name differs from the package name; documentnpx @acme/envcheck.
Handoff
State the command surface (envcheck check [file] -e -f --strict), exit codes, how to run
locally (npm link), what was tested, and whether it is published or awaiting approval.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Node CLI Tool Building"
short_description: "Node CLIs that run from npx and never hang in CI"
default_prompt: "$cli-tool-building Build a Node CLI that checks a .env file against .env.example and supports --json" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.