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.

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- cli-tool-building

Installs to ~/.agents/skills/cli-tool-building/ for every project on this machine.

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

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.

~/.agents/skills/cli-tool-building/SKILL.md

--- name: cli-tool-building description: Builds and publishes Node.js command-line tools with Commander for arguments and subcommands and @clack/prompts for interactive flows - bin and… license: MIT ---

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

NeedDefaultNotes
Argument parsing, subcommands, helpcommanderCurrent major needs Node 20+; check engines before bumping
Interactive prompts, spinners@clack/promptsEvery prompt must have a flag equivalent
Tiny script, one or two flagsnode:util parseArgsNo dependency at all
Coloursnode:util styleText or picocolorsRespect NO_COLOR and non-TTY output
Build TypeScripttsc 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 bin target must start with #!/usr/bin/env node. tsc keeps a shebang on the first line of the source file.
  • files whitelists what ships; check with npm pack --dry-run.
  • A package named create-foo makes npm create foo (and npx create-foo) work.
  • Pin versions to what npm view <pkg> version reports 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 / signalUse
stdoutThe result: data, paths, JSON. Pipeable
stderrProgress, spinners, warnings, errors, prompts
exit 0Success, including “nothing to do”
exit 1The check ran and found problems
exit 2Usage or configuration error
exit 130Cancelled 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

  • __dirname does not exist in ESM; use new URL('./x', import.meta.url) or import.meta.dirname.
  • Windows has no shebangs; npm generates .cmd shims from bin, so the entry must be a plain JS file, not a shell script.
  • Forgetting files publishes src/, tests and sometimes .env; forgetting dist/ in files publishes a package that cannot run.
  • A postinstall script that downloads things breaks offline and locked-down installs; avoid it.
  • Top-level await with program.parse() (sync) skips async action errors; use parseAsync.
  • npx foo resolves the npm package named foo, which may not be yours if your bin name differs from the package name; document npx @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.yamlgenerated from this page

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.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem