16 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 callsdefine_command_initializer(anddef_initwhen@[CommandInfo(def_init: true)]).CliGen::App'smacro finished(inapp/generate.cr) walksCliGen::Command.subclassesand emitsApp.generate, which constructs the runtimeFlag(T)/CommandNode(T)object tree.- At runtime
App.processlazily callsgenerate, then walksARGV.
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:
Flag#check!— rejects-h/--help(ReservedFlagError).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 + GLOBAL_FLAGS.all_flagsrecurses the whole tree; empty env vars are excluded.
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 |
selection(var : T, description, options, ...) |
command/selection.cr |
Like argument but constrained to a fixed option list |
subcommand(func, description, examples) { ... } |
command/subcommand.cr |
Defines a @[SubCommand] method from a block |
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.
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 seven; only five are wired:
| Annotation | Applied to | Status |
|---|---|---|
@[CommandInfo(description:, def_init:)] |
Command subclass | Required. description must be a StringLiteral; def_init: true generates a no-arg initializer + self.get singleton accessor |
@[Argument(short:, long:, description:, validation:, on_match:, options:, delimiter:, format:, env_var:)] |
ivar | Emitted by argument and selection; 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 |
@[Selection] |
ivar | Read by generate/define_command_initializer, but never emitted — selection emits @[Argument]. Effectively dead. |
@[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. {% verbatim do %}is required whenever a macro body must emit macro code that runs in the subclass'smacro finishedcontext (seecommand.cr,def_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.
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 164 examples / 0 failures,
./utils/flag_matrix.sh 16/16. Targeting v0.2.0 (shard.yml and CliGen::VERSION both
still say 0.1.0).
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. @[Selection],@[ProxyCommand],@[Trigger]are dead (see above).- The
Fiber.yieldlog-flush workaround inapp.cris fragile; synchronous dispatch would be deterministic.
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.