libcli — CLI Development

Logger Conventions

CLI programs use libtelemetry's Logger for operational output and reserve console.log / direct stdout writes for primary data output. The decision matrix:

Output type Use Logger? Why
Progress updates Yes Structured attributes beat free text
Errors and exceptions Yes Preserves trace context
Help text No Rendered by libcli
Pure data output No Primary result for piping
Version string No Single value, rendered by libcli

Creating a Logger

import { createLogger } from "@forwardimpact/libtelemetry";

const logger = createLogger("codegen");

Domain naming: use the package name without the lib prefix. Examples: "codegen" for libcodegen, "rc" for librc, "pathway" for fit-pathway.

Level usage

  • debug — internal tracing, invisible unless LOG_LEVEL=debug
  • info — operational output the user expects to see
  • error — errors with context attributes
  • exception — caught errors with stack traces (use in catch blocks)

Structuring attributes

Attributes make log lines parseable by both humans and agents:

logger.info("step", "Generated types", { files: "12" });
logger.error("compile", "Proto compilation failed", { path: "agent.proto" });

Suppression

The --silent / --quiet pattern (established in fit-rc) suppresses Logger output. When a CLI supports these flags, configure the Logger's minimum level accordingly — --silent raises it above error, --quiet raises it above info.

Output format

Logger emits RFC 5424 structured messages:

{level} {timestamp} {domain} {appId} {procId} {msgId} [{attrs}] {message}

Help Text

libcli renders help from a definition object — a plain data structure that declares the CLI's name, version, description, commands, options, and examples.

const definition = {
  name: "fit-example",
  version: "0.1.0",
  description: "Example CLI showing standard pattern",
  usage: "fit-example <input>",
  commands: [
    { name: "validate", args: "<file>", description: "Validate a framework" },
    { name: "list",                      description: "List all entities" },
  ],
  options: {
    output: { type: "string", description: "Output path" },
    json:   { type: "boolean", description: "Output as JSON" },
    help:   { type: "boolean", short: "h", description: "Show this help" },
    version: { type: "boolean", description: "Show version" },
  },
  examples: [
    "fit-example data.yaml",
    "fit-example data.yaml --json",
  ],
};

One line per command

Each command occupies exactly one line in help output, with aligned columns. This is intentional — agents parse help text line-by-line, and a predictable one-command-per-line layout makes discovery reliable.

Human and machine modes

  • --help renders human-readable formatted text to stdout
  • --help --json emits the definition object as JSON, suitable for agent consumption

Both modes are handled automatically by cli.parse().


Error Handling

Standard format

All errors write to stderr in a consistent format with no ANSI color codes:

cli-name: error: message

Exit codes

Code Meaning Method
1 Runtime error cli.error()
2 Usage error cli.usageError()

cli.error(message) writes the formatted error and sets process.exitCode = 1. Use it for runtime failures — file not found, network errors, invalid data.

cli.usageError(message) writes the same format but sets process.exitCode = 2. Use it for bad arguments — missing positionals, unknown flags, invalid combinations.

Exception logging

In catch blocks, use logger.exception() for operational errors before calling cli.error(). This preserves the stack trace in structured logs while showing a clean message on stderr:

main().catch((error) => {
  logger.exception("main", error);
  cli.error(error.message);
  process.exit(1);
});

Summary Output

SummaryRenderer prints a post-command summary — a title line followed by aligned label/description pairs.

import { SummaryRenderer } from "@forwardimpact/libcli";

const summary = new SummaryRenderer({ process });

summary.render({
  title: "Generated 3 files",
  items: [
    { label: "types.js",    description: "Compiled proto types" },
    { label: "clients.js",  description: "Service client stubs" },
    { label: "index.js",    description: "Re-export barrel" },
  ],
});

Output:

Generated 3 files
  types.js    — Compiled proto types
  clients.js  — Service client stubs
  index.js    — Re-export barrel

The data structure is { title: string, items: Array<{ label, description }> }. The fit-codegen CLI is the reference pattern for summary usage.


Argument Parsing

The definition object's options field serves double duty — it drives both help text generation and argument parsing.

cli.parse(argv) wraps node:util parseArgs with allowPositionals: true always set. It returns { values, positionals } on success, or null if --help or --version was handled (the caller should exit cleanly).

const parsed = cli.parse(process.argv.slice(2));
if (!parsed) process.exit(0);

const { values, positionals } = parsed;

Positional validation is the caller's responsibility — libcli does not enforce required positionals because usage patterns vary across CLIs:

const [input] = positionals;
if (!input) {
  cli.usageError("missing required argument <input>");
  process.exit(2);
}

Composition with Other Libraries

libcli covers CLI chrome. Other libraries handle content and sessions:

Library Scope
libcli CLI chrome: help, errors, summaries, argument parsing, color
libformat Content rendering: markdown to HTML or ANSI terminal output
librepl Interactive sessions: command loops, state, history
libtelemetry Operational diagnostics: Logger, Tracer, Observer

A CLI that renders markdown (fit-guide) uses libformat for content and libcli for chrome. A REPL-based CLI uses libcli for initial argument parsing and librepl for the interactive session.


Minimal CLI Example

A complete, runnable CLI showing the standard pattern from shebang to exit code:

#!/usr/bin/env node

import { createCli } from "@forwardimpact/libcli";
import { createLogger } from "@forwardimpact/libtelemetry";

const definition = {
  name: "fit-example",
  version: "0.1.0",
  description: "Example CLI showing standard pattern",
  usage: "fit-example <input>",
  options: {
    output:  { type: "string", description: "Output path" },
    json:    { type: "boolean", description: "Output as JSON" },
    help:    { type: "boolean", short: "h", description: "Show this help" },
    version: { type: "boolean", description: "Show version" },
  },
  examples: [
    "fit-example data.yaml",
    "fit-example data.yaml --json",
  ],
};

const logger = createLogger("example");
const cli = createCli(definition);

async function main() {
  const parsed = cli.parse(process.argv.slice(2));
  if (!parsed) process.exit(0);

  const { values, positionals } = parsed;
  const [input] = positionals;

  if (!input) {
    cli.usageError("missing required argument <input>");
    process.exit(2);
  }

  // ... do work, using logger for operational output
  logger.info("main", "Processing complete", { file: input });
}

main().catch((error) => {
  logger.exception("main", error);
  cli.error(error.message);
  process.exit(1);
});

This demonstrates: shebang line, imports, definition as data, Logger creation, cli.parse() with null check, positional validation with usage error, and the top-level catch pattern with exception logging.