Files
cligen/CLAUDE.md
T

22 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Commands

# Run all specs. `make` / `make spec` is the same thing with -v;
# `make spec_silent` is the bare form.
crystal spec

# Run a single spec file
crystal spec spec/cligen/flag_spec.cr

# Type-check without codegen — fastest way to validate macro expansion
crystal build src/cligen.cr --no-codegen

# End-to-end flag resolution matrix (default/env/CLI, 16 cases)
./utils/flag_matrix.sh

# API docs
make doc && make doc_show

Setting DEBUG=1 in the environment turns on {% debug %} / {% puts %} macro tracing in app/generate.cr, command/argument.cr, command/help_template.cr, and command_node.cr#help. Very noisy, but it's the only way to see generated code.

What This Is

cligen is a Crystal shard (library) — not a standalone application. Consumers subclass CliGen::Command, declare flags with the argument macro and subcommands with the subcommand macro, and the library builds the whole CLI tree at compile time. There is no runtime registration and no OptionParsercligen implements its own argument scanner.

lib/cligen is a symlink back to the repo root so that require "cligen" resolves in this project's own test programs.

Architecture

Compile-time flow, in order:

  1. Command.inherited installs a macro finished hook on each subclass, which calls validate_command_tree, define_command_initializer, generate_gather_handler, generate_register_command, generate_gather_handler (and define_singleton_init when @[CommandInfo(singleton_init: true)]).
  2. Each subclass therefore gets its own self.register_command(array, parent:) that builds its CommandNode(T) + Flag(T) objects and recurses into its children.
  3. CliGen::App's macro finished (in app/generate.cr) selects the root commands — those whose @[CommandInfo] has no parent: — and calls register_command on each.
  4. At runtime App.process lazily calls generate, then walks ARGV.

Nesting is expressed by annotation, not inheritance: a subcommand subclasses CliGen::Command directly and names its parent via @[CommandInfo(parent: Other)]. Subclassing a Command instead would give it subclasses, which turns T into the virtual type T+ and makes T.has_constant?/T.methods fail with a compiler BUG.

Entry point: src/cligen.cr

Requires everything and defines CliGen::VERSION, CliGen::APPNAME (File.basename(PROGRAM_NAME)), and override_help_template (sets CliGen::HELP_OVERRIDE_TEMPLATE to an absolute path, checked with file_exists?).

Object model

The runtime tree is built from four object families. Two of them have a non-generic abstract base so heterogeneous children can live in one array — this is load-bearing and comes up constantly:

Generic Base Why the base exists
Flag(T) BaseFlag Array(BaseFlag) holds flags of mixed T
CommandNode(T) BaseCommandNode Array(BaseCommandNode) holds the command tree
  • src/cligen/flag.crFlag(T). Owns @value, @default, @options, @validate, @on_match, @format. Its process, coerce, and validate! are giant compile-time {% if %} chains over T. Supported T: Bool, String, Int* (signed and unsigned), Float*, Time, Array(Int*|Float*|String|Coercable), plus any type that extends CliGen::Coercable or CliGen::Parsable. Unsupported T is a {% raise %}.
  • src/cligen/flag/base.crBaseFlag: var, short, long, long_key, env_var, description, delimiter, meta. @long_key is @long split on \s|=, so a declaration like long: "--help TOPIC" still keys off --help.
  • src/cligen/flag/meta.crFlagMeta record (type/array/format/default/options), stringified metadata used solely by the ECR help template.
  • src/cligen/command_node/base.crBaseCommandNode: the tree walk, find_match, get(long:)/get(short:), all_flags, and the duplicate checks.
  • src/cligen/command_node.crCommandNode(T): subcommands, help, check!, and the main process(Array(Arg)) loop, all of which need T.
  • src/cligen/app.crApp < CommandNode(Nil). Singleton (@@instance), root of the tree, adds env-var collision detection and the error boundary.

Argument scanning: src/cligen/arg.cr

CliGen::Arg wraps (value, index) and tracks a one-way processed? flag. Calling #processed twice raises ArgReprocessedError — a deliberate fail-fast so double-consumption bugs surface during development rather than silently eating an argument. The parser never does index math; it filters with args.reject(&.processed?).

Arg also holds the class-level predicates flag?, int?, uint?, float?, which delegate to CliGen::Regex.

The dispatch loop: CommandNode(T)#process

find_match(token) returns a BaseCommandNode, a BaseFlag, or a MatchType enum member. process cases over that:

  • BaseCommandNode — a child command matched. Hands the unprocessed args off to the child and exit 0. The cast back to a concrete CommandNode(T) is done by a macro-generated case over CliGen::Command.subclasses; falling through raises UnknownCommandNodeError.
  • BaseFlag — if requires_arg? (i.e. T != Bool), it is handed the run of following args that either match nothing or are in the flag's options; otherwise process with no args.
  • MatchType::SubCommand — records matched_subcommand; a second one raises.
  • MatchType::Help — raises HelpRequestedError carrying the rendered help.
  • MatchType::FlagWithArg--flag=value, re-split and dispatched.
  • MatchType::FlagMultipleShort-abc bundles. Only the last flag in a bundle may take an argument; otherwise FlagBundleError. If the second char isn't a known flag, raises FlagArgumentError (inline short args like -n5 are not supported).
  • MatchType::NoMatch — raises HelpRequestedError with an "unknown token" preamble.

After the loop, if no child command took over: instantiate T, run its @[PreRunCommand] methods, then dispatch to the matched @[SubCommand] method or main. App itself (T == Nil) just prints help.

Value resolution

Flag(T)#value! resolves in strict priority order — CLI arg → env var → default → raise MissingRequiredFlagError — and then runs validate!(v) on whatever it got, so env-var and default values are validated on exactly the same path as CLI input.

Two footguns here, both previously live bugs:

  • validate!(v : T? = nil) must use v = value! if v.nil?, not v ||= value!. With Flag(Bool) and default: false, ||= treats false as absent and recurses into value! forever.
  • The MissingRequiredFlagError raise must stay above the validate!(v) call in value!, or the same infinite recursion occurs when nothing resolved.

Env vars for command arguments are namespaced <COMMAND>_<VAR> (e.g. GREET_LEVEL, not LEVEL) unless an explicit env_var: is given. Global flags are un-namespaced, derived as long.gsub(/--/,"").gsub(/-/,"_").upcase. Both macros reject an explicit env_var: containing -.

Validation: check!

Run at the top of every process, so misconfiguration fails on first invocation:

  • Compile timecheck_flag_vars validates every declaration (both argument and add_global_flag route through it); validate_command_tree rejects a missing @[CommandInfo], a self-parent, and parent: cycles.
  • Load timeadd_global_flag constructs the flag, then checks GLOBAL_FLAGS for a long_key/short collision and aborts with __FILE__:__LINE__ of the call site. Runs during module init, so it is outside handle_command_raises.
  • Flag#check! — validates short against FLAG_SHORT and long_key against FLAG_LONG. Only invoked on @flags, never on GLOBAL_FLAGS, which is why the built-in --help/--verbose don't trip their own checks.
  • CommandNode#check! — duplicate shorts/longs across @flags + GLOBAL_FLAGS (DuplicateFlagError), duplicate child command names (DuplicateCommandError), and (when T != Nil) requires either subcommands or a #main (MissingDispatchError).
  • App#check!super, then env-var collisions across all_flags.uniq + GLOBAL_FLAGS. all_flags recurses the whole tree. The .uniq is load-bearing: a parent's flag object can appear in several nodes, and uniq collapses it via Reference identity (Flag overrides neither == nor hash). Adding a custom == to Flag would silently make this collapse distinct flags and stop catching real collisions.

Errors: src/cligen/exceptions.cr

Everything derives from CliGen::Error, in three buckets plus a signal:

  • InternalError — framework invariant broken; should never reach a user (ArgReprocessedError, RegexInvariantError, UnknownCommandNodeError).
  • ConfigurationError — the shard consumer wired something wrong (ReservedFlagError, DuplicateFlagError, DuplicateCommandError, MissingDispatchError, FlagNotFoundError, FlagMissingArgumentError, ParseableInvariantError).
  • RuntimeError — bad end-user input (MissingRequiredFlagError, ValidationError, FlagArgumentError, InvalidFlagValueError, InvalidOptionError, UnknownFlagError, FlagBundleError, TimeParseError).
  • HelpRequestedError — not an error; carries rendered help, caught and exit 0.

App.handle_command_raises is the single error boundary: RuntimeError and ConfigurationError abort with the message, HelpRequestedError prints and exits 0. Each rescue does Fiber.yield first to let buffered Log output flush — a known-fragile workaround, not a design.

Never let a non-CliGen exception escape. The whole point of the typed hierarchy is that handle_command_raises catches everything; a stray stdlib exception (e.g. Time::Location::InvalidTimezoneOffsetError) reaches the user as a stack trace. Wrap and re-raise at the boundary — Flag(Time) does exactly this, translating TimeParseError into InvalidFlagValueError.

Time parsing: src/cligen/timeparse.cr

CliGen::Timeparse.parse(raw) : Time is a single case over four anchored matchers from CliGen::Regex, each branching on whether match["timezone"]? is present (offset-aware vs. parse_local). Supported: %Y-%m-%d %H:%M:%S [%z], %Y-%m-%d [%z], @<epoch> [%z], and one-or-more relative operations ("+1 day -2 hours").

timeparse/relative_operation.crRelativeOperation struct. get_operations scans with RELATIVE_OPERATION and applies each in sequence. apply is macro-generated from OperationUnit.constants using case ... in (exhaustive, so no else and the return type collapses to Time).

Regex: src/cligen/regex.cr

Two tiers, and the distinction matters:

  • Components (TIMEZONE, TIME, DATE, EPOCH, RELATIVE) are unanchored and exist only to be interpolated. Interpolating a Crystal Regex renders it as (?-imsx:...), so an anchor here would end up buried mid-pattern in the composite and could never match.
  • Matchers (INPUT_DATE_FULL, INPUT_DATE_SIMPLE, INPUT_DATE_EPOCH, INPUT_RELATIVE_OPERATIONS) are fully ^...$ anchored. Match user input only against these.

TIMEZONE's offset is deliberately bounded to 23:59 so it can't produce an offset outside Time::Location.fixed's ±24h limit.

Also here: FLAG_REGEX, FLAG_WITH_ARG, FLAG_MULTIPLE_SHORT, INT, UINT, FLOAT.

Help output

CommandNode#help picks a template at compile time, in priority order: the command's own HELP_TEMPLATE (set by the help_template macro) → CliGen::HELP_OVERRIDE_TEMPLATE (set by CliGen.override_help_template) → the bundled default.

The default path is hardcoded relative to the CWD: ECR.render("lib/cligen/src/cligen/template/cmd_help.ecr"). Anything that runs a cligen binary must therefore run from a directory with a lib/cligen — which is why utils/flag_matrix.sh cds to the project root.

The template renders per-flag detail (type, env var, format, delimiter) only when verbose?, which reads the --verbose global flag.

Public macro API

Called inside a CliGen::Command subclass:

Macro File Purpose
argument(var : T, description, ...) command/argument.cr Declares a flag-backed ivar. Options: long, short, validation, on_match, def_setter, def_getter, options, delimiter, format, allow_no_verification, env_var
subcommand(func, description, examples) { ... } command/subcommand.cr Defines a @[SubCommand] method from a block
resolve_value(var, default: nil) command/resolve_value.cr Reads a flag value from this command or any ancestor. Walks the parent: chain at compile time to find the declaring class, then walks handler.parent? at runtime to find its node. Returns the exact T, not a union. If the declaring ivar has no default, default: is required — the emitted %default : T = ... makes Crystal type-check it at the call site, and a rescue MissingRequiredFlagError uses it as the fallback
help_template(filepath) command/help_template.cr Per-command ECR override

Module-level:

Macro File Purpose
CliGen.add_global_flag(T, long:, description:, ...) global_flag/add_global_flag.cr Appends to GLOBAL_FLAGS; visible on every command
CliGen.override_help_template(filepath) cligen.cr Project-wide ECR override

global_flag.cr dogfoods add_global_flag for the built-in -v/--verbose and -h/--help, both passing internal: true to bypass the reserved-name check.

CliGen::MAX_COMMAND_DEPTH (default 32, user-overridable by defining it before the requires) bounds every parent-chain walk — macros have no while, so the walks are {% for i in (1..MAX_COMMAND_DEPTH) %} with a sentinel. It doubles as the cycle guard.

Extension points

Module Contract Used for
CliGen::Coercable self.coerce(arg : String) Building T from a single string (also used for env vars and array elements)
CliGen::Parsable self.parse_args(args : Array(CliGen::Arg)) Multi-arg consumption; must mark at least one Arg as processed or ParseableInvariantError is raised

Both are extended, not included — hence the metaclass checks T.class < CliGen::Parsable in flag.cr.

Annotations

src/cligen/annotations.cr declares six; only four are wired:

Annotation Applied to Status
@[CommandInfo(description:, parent:, singleton_init:)] Command subclass Required. description must be a StringLiteral. parent: names the command this is a subcommand of (see nesting above). singleton_init: true generates a no-arg initializer + self.get accessor — note the check is == true, so any other value silently does nothing
@[Argument(short:, long:, description:, validation:, on_match:, options:, delimiter:, format:, env_var:)] ivar Emitted by argument; read by App.generate and define_command_initializer
@[SubCommand(description:, examples:)] method Emitted by subcommand; read by CommandNode#subcommands and the dispatch case
@[PreRunCommand] method Run unconditionally before subcommand dispatch
@[ProxyCommand] Declared only; unused
@[Trigger] Declared only; unused

Crystal macro gotchas

These have each caused real bugs in this codebase — check for them before touching a macro:

  • Macro arguments arrive as unresolved AST (Path, Generic), not TypeNode. == silently returns false and < raises undefined macro method 'Path#<'. Call .resolve first: validation.return_type.resolve == Bool, type.resolve <= Array. Generic type parameters (T inside Flag(T)) are already TypeNode and are safe as-is. This is the single most frequent bug in this codebase — it has appeared five separate times, and it usually fails silently (a Path == TypeNode comparison is just always false) or with an unrelated-looking error like undefined macro method 'Path#annotation'. Resolve once at the point of extraction, not at each use.
  • {% cond %} failing inside a macro reports the wrong line. When a macro expression raises, the error points at the line that consumes the variable, not the assignment that blew up — "undefined macro variable x" almost always means the line assigning x errored. Look one line up.
  • Macros cannot be called from macro scope. {% if some_macro(x) %} gives undefined macro method. Macros emit code; they don't return values to the evaluator. To share logic, either precompute into a constant both can read, or accept duplication.
  • @type.instance_vars only works in method scope. In class-body scope (including a macro finished directly in a class body) it raises "instance vars are not yet initialized". Declaring an ivar requires class-body scope, so a macro can never read one class's ivars and declare ivars in another. Constants are readable in both scopes and are the way around it (@type.constant("X") << ... mutates one).
  • Macro for has no break, and reassigning the loop variable does nothing. Bound the loop and guard the body with a sentinel ({% unless done || found %}).
  • Emitted locals leak into the caller's scope. flg = ... in a macro body clobbers a user's flg. Use %flg, which is unique per expansion (and avoids type unions when the same macro is called with different T in one scope).
  • {% verbatim do %} is required whenever a macro body must emit macro code that runs in the subclass's macro finished context (see command.cr, define_singleton_init.cr, define_command_initializer.cr).
  • Signed/unsigned dispatch is done by string inspection, since there's no UInt supertype to test against: {% int_case = T.stringify =~ /^UInt/ ? "uint?".id : "int?".id %}.
  • macro finished ordering is why App.generate can see every Command subclass.

Spec structure

  • spec/cligen/arg_spec.cr — plain specs for Arg.
  • spec/cligen/flag_spec.cr — reopens CliGen::Flag(T) to expose test_coerce and a String-array process overload, then generates most it blocks with {% for int in Int.subclasses %} etc. so every numeric width is covered. MyGoodData / MyBadData exercise Coercable / Parsable, including the "didn't mark anything processed" failure.
  • spec/cligen/command_spec.crresolve_value at depth 1/2/3, both construction paths (define_singleton_init's no-arg new and new(handler:)), and the required-flag raise.
  • spec/cligen/timeparse_spec.cr / relative_operation_spec.cr — the time subsystem.
  • spec/cligen/regex_spec.cr — the flag and date matchers.

Two spec hazards, both already hit: env vars set in one example leak into others, so anything touching ENV needs a before_each { ENV.delete(...) } — run crystal spec --order=<n> for a few seeds before trusting a green suite. And time assertions must not compare two independently-sampled clocks; RelativeOperation#apply takes an explicit Time, so assert against a fixed Time.utc and reserve bracket assertions for Timeparse.parse's single Time.local call. Check TZ=UTC crystal spec too.

utils/flag_matrix.cr + utils/flag_matrix.sh cover what specs can't: env vars must be set before process startup, so each of the 16 cases needs its own process. Run this after touching value resolution, check!, or help rendering.

Current state (branch object_rework)

Green: crystal build --no-codegen clean, crystal spec 276 examples / 0 failures, ./utils/flag_matrix.sh 16/16. shard.yml and CliGen::VERSION are both bumped to 0.2.0; no git tag exists yet.

Verifying a change needs a consuming app, not just the lib build. crystal build src/cligen.cr --no-codegen does not instantiate App.process, check_for_env_duplicates, or satisfied?, so type errors there stay invisible. Write a throwaway require "cligen" program with a Command subclass and build that.

Known gaps, all deliberate:

  • Enum support — deferred to v0.2.1; requires reworking five type-dispatch chains. DESIGN.md marks it as planned.
  • Array(Time) — unsupported; the three array element chains have no Time case.
  • Colon-based relative time formats ([-+]%H:%M:%S) — documented in DESIGN.md as planned.
  • @[ProxyCommand] and @[Trigger] are declared but unused (see above).
  • The Fiber.yield in app.cr's handle_command_raises is load-bearing, not superstition — Crystal's default Log backend dispatches async at INFO even with no Log.setup call, so without the yield abort's synchronous stderr write beats the log lines that explain it. A sync dispatcher would be deterministic but is the consumer's global setting, so the library can't impose it.
  • resolve_value can no longer raise MissingRequiredFlagError — the declaring ivar either has a default or the call site must supply one, so both paths are covered. The gate applies only to the parent-chain branch; a local ivar was already resolved during initialize, and the not-found branch raises at compile time regardless.
  • resolve_value's own cycle detection is unreachable: validate_command_tree runs in macro finished and catches cycles before any method body instantiates. The commands array it accumulates is still live — it feeds the "valid options are..." error listing.

Repo conventions

  • Every .cr file starts with # SPDX-License-Identifier: MIT and # Copyright 2026 Tristan Ancelet. Add these to new files.
  • .gitignore is a deny-all allowlist (* followed by ! exceptions). New top-level files and directories are ignored silently and fail closed — add an explicit ! entry when creating one. This has bitten DESIGN.md and spec/.
  • Log is stdlib (::Log.for(...) on BaseFlag and BaseCommandNode), deliberately chosen over a dependency. Always use the block form — it's zero-cost when the level is disabled.
  • Licensed MIT.