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 OptionParser — cligen 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:
Command.inheritedinstalls amacro finishedhook on each subclass, which callsvalidate_command_tree,define_command_initializer,generate_gather_handler,generate_register_command,generate_gather_handler(anddefine_singleton_initwhen@[CommandInfo(singleton_init: true)]).- Each subclass therefore gets its own
self.register_command(array, parent:)that builds itsCommandNode(T)+Flag(T)objects and recurses into its children. CliGen::App'smacro finished(inapp/generate.cr) selects the root commands — those whose@[CommandInfo]has noparent:— and callsregister_commandon each.- At runtime
App.processlazily callsgenerate, then walksARGV.
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.cr—Flag(T). Owns@value,@default,@options,@validate,@on_match,@format. Itsprocess,coerce, andvalidate!are giant compile-time{% if %}chains overT. SupportedT:Bool,String,Int*(signed and unsigned),Float*,Time,Array(Int*|Float*|String|Coercable), plus any type thatextendsCliGen::CoercableorCliGen::Parsable. UnsupportedTis a{% raise %}.src/cligen/flag/base.cr—BaseFlag:var,short,long,long_key,env_var,description,delimiter,meta.@long_keyis@longsplit on\s|=, so a declaration likelong: "--help TOPIC"still keys off--help.src/cligen/flag/meta.cr—FlagMetarecord (type/array/format/default/options), stringified metadata used solely by the ECR help template.src/cligen/command_node/base.cr—BaseCommandNode: the tree walk,find_match,get(long:)/get(short:),all_flags, and the duplicate checks.src/cligen/command_node.cr—CommandNode(T):subcommands,help,check!, and the mainprocess(Array(Arg))loop, all of which needT.src/cligen/app.cr—App < 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 andexit 0. The cast back to a concreteCommandNode(T)is done by a macro-generatedcaseoverCliGen::Command.subclasses; falling through raisesUnknownCommandNodeError.BaseFlag— ifrequires_arg?(i.e.T != Bool), it is handed the run of following args that either match nothing or are in the flag'soptions; otherwiseprocesswith no args.MatchType::SubCommand— recordsmatched_subcommand; a second one raises.MatchType::Help— raisesHelpRequestedErrorcarrying the rendered help.MatchType::FlagWithArg—--flag=value, re-split and dispatched.MatchType::FlagMultipleShort—-abcbundles. Only the last flag in a bundle may take an argument; otherwiseFlagBundleError. If the second char isn't a known flag, raisesFlagArgumentError(inline short args like-n5are not supported).MatchType::NoMatch— raisesHelpRequestedErrorwith 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 usev = value! if v.nil?, notv ||= value!. WithFlag(Bool)anddefault: false,||=treatsfalseas absent and recurses intovalue!forever.- The
MissingRequiredFlagErrorraise must stay above thevalidate!(v)call invalue!, 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 time —
check_flag_varsvalidates every declaration (bothargumentandadd_global_flagroute through it);validate_command_treerejects a missing@[CommandInfo], a self-parent, andparent:cycles. - Load time —
add_global_flagconstructs the flag, then checksGLOBAL_FLAGSfor along_key/shortcollision andaborts with__FILE__:__LINE__of the call site. Runs during module init, so it is outsidehandle_command_raises. Flag#check!— validatesshortagainstFLAG_SHORTandlong_keyagainstFLAG_LONG. Only invoked on@flags, never onGLOBAL_FLAGS, which is why the built-in--help/--verbosedon't trip their own checks.CommandNode#check!— duplicate shorts/longs across@flags + GLOBAL_FLAGS(DuplicateFlagError), duplicate child command names (DuplicateCommandError), and (whenT != Nil) requires either subcommands or a#main(MissingDispatchError).App#check!—super, then env-var collisions acrossall_flags.uniq + GLOBAL_FLAGS.all_flagsrecurses the whole tree. The.uniqis load-bearing: a parent's flag object can appear in several nodes, anduniqcollapses it viaReferenceidentity (Flagoverrides neither==norhash). Adding a custom==toFlagwould 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 andexit 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.cr — RelativeOperation 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 CrystalRegexrenders 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), notTypeNode.==silently returns false and<raisesundefined macro method 'Path#<'. Call.resolvefirst:validation.return_type.resolve == Bool,type.resolve <= Array. Generic type parameters (TinsideFlag(T)) are alreadyTypeNodeand 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 (aPath == TypeNodecomparison is just always false) or with an unrelated-looking error likeundefined 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 variablex" almost always means the line assigningxerrored. Look one line up.- Macros cannot be called from macro scope.
{% if some_macro(x) %}givesundefined 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_varsonly works in method scope. In class-body scope (including amacro finisheddirectly 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
forhas nobreak, 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'sflg. Use%flg, which is unique per expansion (and avoids type unions when the same macro is called with differentTin one scope). {% verbatim do %}is required whenever a macro body must emit macro code that runs in the subclass'smacro finishedcontext (seecommand.cr,define_singleton_init.cr,define_command_initializer.cr).- Signed/unsigned dispatch is done by string inspection, since there's no
UIntsupertype to test against:{% int_case = T.stringify =~ /^UInt/ ? "uint?".id : "int?".id %}. macro finishedordering is whyApp.generatecan see everyCommandsubclass.
Spec structure
spec/cligen/arg_spec.cr— plain specs forArg.spec/cligen/flag_spec.cr— reopensCliGen::Flag(T)to exposetest_coerceand aString-arrayprocessoverload, then generates mostitblocks with{% for int in Int.subclasses %}etc. so every numeric width is covered.MyGoodData/MyBadDataexerciseCoercable/Parsable, including the "didn't mark anything processed" failure.spec/cligen/command_spec.cr—resolve_valueat depth 1/2/3, both construction paths (define_singleton_init's no-argnewandnew(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 noTimecase.- 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.yieldinapp.cr'shandle_command_raisesis load-bearing, not superstition — Crystal's defaultLogbackend dispatches async at INFO even with noLog.setupcall, so without the yieldabort'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_valuecan no longer raiseMissingRequiredFlagError— 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 duringinitialize, and the not-found branch raises at compile time regardless.resolve_value's own cycle detection is unreachable:validate_command_treeruns inmacro finishedand catches cycles before any method body instantiates. Thecommandsarray it accumulates is still live — it feeds the "valid options are..." error listing.
Repo conventions
- Every
.crfile starts with# SPDX-License-Identifier: MITand# Copyright 2026 Tristan Ancelet. Add these to new files. .gitignoreis 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 bittenDESIGN.mdandspec/.Logis stdlib (::Log.for(...)onBaseFlagandBaseCommandNode), deliberately chosen over a dependency. Always use the block form — it's zero-cost when the level is disabled.- Licensed MIT.