- Finished implementing relative date parsing and migrated date parsing from Flag(T) to it's own dedicated module. Additionally added a the ability to do "recursive" relative operations for time searching. - Additionally made a Struct & Enum for containing relative operations around dates. - Added additional checks in App & CommandNode(T) around checking env_vars of flags & checking for duplicate commands - Added a CommandMeta record to contain the classname of the CommandNode(T) - Seperated out all of the monolitic files (command_node.cr & file.cr). Moved the base classes and records into their own files in the dir with the same name of their generic counterparts. - Fixed a number of macro related bugs around Command.argument & CliGen.add_global_flag - My ass hurts from sitting here for hours and doing these changes and arguing with claude over what needs to be done. Fun tho.
cligen
A Crystal shard that generates CLI parsers from class definitions using annotations and macros. Define your commands as classes; cligen builds the runtime parse tree.
How It Works
Subclass CliGen::Command, annotate your instance variables with @[CliGen::Argument] or @[CliGen::Selection], and register the command with an CliGen::App. At compile time, macros inspect the annotations and generate typed Flag(T) objects; at runtime, CliGen::App.process walks the CommandNode tree to route arguments, populate your command instance, and dispatch to the right method.
Installation
-
Add the dependency to your
shard.yml:dependencies: cligen: git: https://git.arcanium.tech/tristan/cligen -
Run
shards install
Usage
require "cligen"
@[CliGen::CommandInfo(description: "Greet someone")]
class Greet < CliGen::Command
@[CliGen::Argument(short: "-n", long: "--name VALUE", description: "Name to greet")]
@name : String = "world"
argument(otherval : String = "abc",
long: "--other",
short: "-o",
options: %w[ abc def ghi ],
description: "This provides a way of setting the second string taht is printed"
)
argument(myvars : Array(String) = [ "a" ],
short: "-m",
options: %w[ a b c ],
description: "Provide multiple things to be printed out in the main function"
)
def main
puts "1) Hello, #{@name}!"
puts "2) #{@otherval}"
@myvars.each_with_index do |var, index|
puts "%d) %s" % [ 3 + index, var ]
end
end
end
CliGen::App.process
$ myapp greet --name Alice -m a a -m a,a,b
1) Hello, Alice!
2) abc
3) a
4) a
5) a
6) a
7) b
Full API documentation and design notes are in design.adoc.
Architecture
As a short overview, this projects makes HEAVY use of Crystal macros to learn the shape of your project & command subclasses.
Subclassing to CliGen::Command injects macros into your class that provides you user friendly DSLs/Macros for defining arguments/flags & subcommands. This is later used in the library src/cligen/app/generate.cr to generate a object graph of your commands and all annotated "arguments/flags" and stores them in a tree from the App object itself.
This allows the project to "learn" your project & generate a command tree from the defined data.
The MAJORITY of stdlib types (Int*, Float*, String, Bool & Time) are all supported in-place (as these are the primary data-types you might try to comsume from the CLI. However, custom data types are supported provided you extend the class's metaclass with CliGen::Coercable && CliGen::Parsable modules and define the self.parse_args(args : Array(CliGen::Arg) and self.coerce(arg : String) class methods.
EX:
module MyModule
class MyData
extend CliGen::Parsable
extend CliGen::Coercable
@value : Int32
def initialize(value : String)
@value = value.to_i32
end
def self.parse_args(args : Array(CliGen::Arg))
arg = args.first
# Mark the argument as processed
arg.processed
new(arg.value)
end
def self.coerce(arg : String)
new(arg)
end
end
end
Coercable Method:
def self.coerce(arg : String)
new(arg)
end
The coerce method provides you the ability to parse a single string value into your class/datatype. This is generally only used when parsing from ENV VAR and when being used by parsing from an Array(T) type.
This should only be used in the case you need a simple datatype that can be learned from a single string.
Parsable Method:
def self.parse_args(args : Array(CliGen::Arg))
arg = args.first
# Mark the argument as processed (required)
arg.processed
new(arg.value)
end
This method is used the most and is used for when using the bare class as the generic type in the Flag(T). With this the Flag(T) will collect all provided arguments (cli arguments that weren't determined to be flags or subcommands) and pass them to your parse_args method so that you can parse them how you see fit and determine if the args provided by the user are enough and to be able to raise if data is not provided correctly/in the right format.
This gives the framework a way to allow you to extend the parser in your own custom way to allow for a "custom" format to be procesed. However, it's very strict and you MUST properly mark the arguments as processed so that the mainloop won't double-process arguments passed to your custom parser. However, this won't happen as in the Flag(T) I am doing a check to ensure that args were processed after passing it to your code.
Development
# Type-check without running
crystal build src/cligen.cr --no-codegen
# Run specs
crystal spec
AI Assistance Disclosure
This project uses Claude Code as a development aid — specifically for catching bugs, spotting typos, reviewing implementations, and talking through design decisions. All architecture decisions, code, and design are written by the author. Claude is used the way one might use a second pair of eyes on a diff, not as a code generator.
CLAUDE.md at the repo root documents the project structure for Claude's context. .claude/ holds project-level Claude Code settings.
Contributors
- Tristan Ancelet - creator and maintainer