skills/jondotsoy/flags/flags-builder

flags-builder

SKILL.md

@jondotsoy/flags

A zero-dependency, type-safe CLI argument parser with a fluent builder API.

Overview

What this skill is for

Use this skill whenever the task involves parsing command-line arguments in a Node.js or TypeScript program. It covers the full lifecycle: reading process.argv, defining typed flags, handling subcommands, and validating inputs.

When to use it

Trigger this skill when the user's request matches one of these scenarios:

Building a CLI tool from scratch

"I want to create a CLI that accepts --port and --verbose" "Make a script that reads arguments from the terminal"

Adding flags to an existing script

"Add a --dry-run flag to my deploy script" "Support --output=dist in my build tool"

Parsing subcommands

"My CLI needs serve and build commands, each with their own flags" "How do I parse mycli deploy --env production?"

Handling repeated or typed inputs

"I need --tag to be repeatable" "Validate that --port is a number and defaults to 3000"

User phrases that signal this skill

  • "parse argv", "read CLI args", "command-line flags"
  • "how do I get --flag from process.argv"
  • mentions of --flag, -f, subcommands, or argument parsing in a Node.js/TypeScript context

When NOT to use it

  • The project already uses another parser (e.g. yargs, commander, minimist) — prefer extending the existing setup
  • Arguments come from a config file, environment variables, or an HTTP request — this library is specifically for argv

Getting Started

import { flags, flag, command } from "@jondotsoy/flags";

const args = process.argv.slice(2); // remove "node" and script path

const parser = flags({ ... });
const [ok, error, options] = parser.safeParse(args);
if (!ok) { console.error(parser.formatError(error)); process.exit(1); }

Flag Types

All input styles are supported automatically — no extra config needed:

const parser = flags({
  verbose: flag("--verbose", "-v").boolean(), // --verbose
  version: flag("--version").number(),        // --version 1.2
  name:    flag("--name", "-n").string(),     // --name foo  OR  --name=foo
});

const [ok, error, output] = parser.safeParse(args);
if (!ok) { console.error(parser.formatError(error)); process.exit(1); }

const { verbose, version, name } = output;

.boolean()

Presence of the flag sets the value to true. Default: false.

flag("--verbose", "-v").boolean();
// --verbose       → true
// (absent)        → false

.string()

Reads the next token or the value after =. Returns string | null (null if absent).

flag("--name", "-n").string();
// --name foo      → "foo"
// --name=foo      → "foo"
// (absent)        → null

.number()

Like .string() but coerces the value to a number. Returns number | null.

flag("--port", "-p").number();
// --port 3000     → 3000
// --port=3000     → 3000
// (absent)        → null

.strings()

Accumulates repeated flags into an array. Returns string[] (always an array, never null).

flag("--tag").strings();
// --tag a --tag b --tag c   → ["a", "b", "c"]
// (absent)                  → []

.keyValue()

Reads key=value pairs and merges them into a record. Returns Record<string, string> | null.

flag("--env").keyValue();
// --env NODE_ENV=production --env PORT=3000   → { NODE_ENV: "production", PORT: "3000" }
// (absent)                                    → null

Modifiers

Chain modifiers after the type method:

flag("--port", "-p").number().default(3000);   // default value
flag("--output").string().required();           // throw if missing
flag("--tag").strings();                        // accumulate: --tag a --tag b → ["a", "b"]
flag("--count").number().positive();            // must be > 0
flag("--port").number().describe("HTTP port"); // help text

Subcommands — Multi-Parser Pattern

Use command().restArgs() to capture a subcommand's raw arguments, then run a second .safeParse() on them:

import { flags, flag, command } from "@jondotsoy/flags";

// Level 1: identify which subcommand was used
const mainParser = flags({
  serve: command("serve").restArgs(),
});

const [ok, error, output] = mainParser.safeParse(process.argv.slice(2));
if (!ok) { console.error(mainParser.formatError(error)); process.exit(1); }

// Level 2: parse the subcommand's own flags
if (output.serve) {
  const serveParser = flags({
    port: flag("--port", "-p").number().default(3000),
  });

  const [ok2, error2, serveOutput] = serveParser.safeParse(output.serve);
  if (!ok2) { console.error(serveParser.formatError(error2)); process.exit(1); }

  startServer(serveOutput.port);
}

This pattern scales to any number of subcommands:

const mainParser = flags({
  build:  command("build").restArgs(),
  deploy: command("deploy").restArgs(),
});

const [ok, error, output] = mainParser.safeParse(process.argv.slice(2));
if (!ok) { console.error(mainParser.formatError(error)); process.exit(1); }

if (output.build) {
  const buildParser = flags({ outDir: flag("--out").string().default("dist") });
  const [ok2, error2, buildOutput] = buildParser.safeParse(output.build);
  if (!ok2) { console.error(buildParser.formatError(error2)); process.exit(1); }
}

if (output.deploy) {
  const deployParser = flags({ env: flag("--env").string().required() });
  const [ok2, error2, deployOutput] = deployParser.safeParse(output.deploy);
  if (!ok2) { console.error(deployParser.formatError(error2)); process.exit(1); }
}

Recommended File Structure (Large Projects)

This is a recommendation, not a requirement. Always ask the user before applying this structure — it may not fit every project.

As a program grows, putting all parsers in a single file becomes hard to maintain. The recommended approach is to split each parser into its own file under src/args/, mirroring the command hierarchy.

src/args/
  main.ts           ← top-level parser (subcommands)
  serve.ts          ← flags for `serve` subcommand
  containers/
    main.ts         ← flags for `containers` subcommand
    pull.ts         ← flags for `containers pull`
    push.ts         ← flags for `containers push`

Each file exports a named parser constant. The name should be descriptive of the command it handles:

// src/args/main.ts
import { flags, command } from "@jondotsoy/flags";

export const mainParserArgs = flags({
  serve:      command("serve").restArgs(),
  containers: command("containers").restArgs(),
});
// src/args/serve.ts
import { flags, flag } from "@jondotsoy/flags";

export const serveParserArgs = flags({
  port: flag("--port", "-p").number().default(3000),
  host: flag("--host").string().default("localhost"),
});
// src/args/containers/main.ts
import { flags, command } from "@jondotsoy/flags";

export const containersParserArgs = flags({
  pull: command("pull").restArgs(),
  push: command("push").restArgs(),
});

Consuming parsers in the entry point:

import { mainParserArgs } from "./args/main.js";
import { serveParserArgs } from "./args/serve.js";
import { containersParserArgs } from "./args/containers/main.js";

const [ok, error, output] = mainParserArgs.safeParse(process.argv.slice(2));
if (!ok) { console.error(mainParserArgs.formatError(error)); process.exit(1); }

if (output.serve) {
  const [ok2, error2, serveOutput] = serveParserArgs.safeParse(output.serve);
  if (!ok2) { console.error(serveParserArgs.formatError(error2)); process.exit(1); }
}

if (output.containers) {
  const [ok2, error2, containersOutput] = containersParserArgs.safeParse(output.containers);
  if (!ok2) { console.error(containersParserArgs.formatError(error2)); process.exit(1); }
}

When to suggest this structure:

  • The program has 3 or more subcommands
  • Each subcommand has its own set of flags
  • The codebase is expected to grow or be maintained long-term

When to keep it simple (single file):

  • Small scripts with 1–2 flags and no subcommands
  • Throwaway or single-use tools

Help Message

Call .helpMessage() on any parser to get a formatted help string. Use .program(), .describe(), and .version() to populate it, and add .describe() to individual flags for per-flag documentation.

const parser = flags({
  port:    flag("--port", "-p").number().default(3000).describe("Port to listen on"),
  verbose: flag("--verbose", "-v").boolean().describe("Enable verbose output"),
  output:  flag("--output", "-o").string().required().describe("Output directory"),
})
  .program("mycli")
  .describe("A simple CLI tool")
  .version("1.0.0");

console.log(parser.helpMessage());

Typical output:

mycli 1.0.0

A simple CLI tool

Options:
  --port, -p      Port to listen on (default: 3000)
  --verbose, -v   Enable verbose output
  --output, -o    Output directory (required)

Common pattern — print help on --help

const [ok, error, output] = parser.safeParse(process.argv.slice(2));
if (!ok) { console.error(parser.formatError(error)); process.exit(1); }

if (output.help) {
  console.log(parser.helpMessage());
  process.exit(0);
}

With the file structure pattern

Each sub-parser can expose its own help message independently:

// src/args/serve.ts
export const serveParserArgs = flags({
  port: flag("--port", "-p").number().default(3000).describe("Port to listen on"),
  host: flag("--host").string().default("localhost").describe("Host to bind"),
})
  .program("mycli serve")
  .describe("Start the development server");
if (output.serve) {
  const [ok2, error2, serveOutput] = serveParserArgs.safeParse(output.serve);
  if (!ok2) { console.error(serveParserArgs.formatError(error2)); process.exit(1); }
}

Safe Parse

Prefer .safeParse() over .parse() — it makes error handling explicit and avoids unexpected exceptions propagating through the program.

.safeParse() returns a tuple [ok, error, output]. When ok is false, output is undefined and error contains the caught value. When ok is true, output is the fully typed result.

const [ok, error, output] = parser.safeParse(process.argv.slice(2));

if (!ok) {
  console.error(parser.formatError(error));
  process.exit(1);
}

const port = output.port; // fully typed, no undefined check needed

Tuple shape:

Position Name Value when ok Value when error
[0] ok true false
[1] error undefined the caught error (unknown)
[2] output parsed result (fully typed) undefined

When to use .parse() instead

Use .parse() only when you intentionally want to skip error handling — for example in quick scripts or tests where an unhandled exception is acceptable:

// ok for tests or throwaway scripts
const output = parser.parse(["--port", "3000"]);

Never use .parse() in production CLI entry points — prefer .safeParse() there.

Error Handling

Use parser.formatError(error) to get a formatted message that includes the error and a hint to run --help. For advanced cases, the error value can also be narrowed with the exported error classes:

import { UnexpectedArgumentError, RequiredFlagMissingError } from "@jondotsoy/flags";

const [ok, error, output] = parser.safeParse(process.argv.slice(2));

if (!ok) {
  console.error(parser.formatError(error));
  process.exit(1);
}

Narrowing for specific messages:

if (!ok) {
  if (error instanceof UnexpectedArgumentError) console.error("Unknown argument:", error.message);
  else if (error instanceof RequiredFlagMissingError) console.error("Missing required flag:", error.message);
  else console.error(parser.formatError(error));
  process.exit(1);
}
Weekly Installs
2
Repository
jondotsoy/flags
First Seen
6 days ago
Installed on
amp2
cline2
opencode2
cursor2
kimi-cli2
codex2