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 unlessLOG_LEVEL=debug -
info— operational output the user expects to see -
error— errors with context attributes -
exception— caught errors with stack traces (use incatchblocks)
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
-
--helprenders human-readable formatted text to stdout -
--help --jsonemits 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.