argy
Argy is a typed CLI framework for Crystal, inspired by Cobra. It helps you build command trees with typed flags, inherited global flags, and auto-generated help output.
Project Meta
- Status: beta (API may evolve before 1.0)
- Crystal compatibility:
>= 1.20.0 - Focus: typed flags, explicit command-tree composition, deterministic lifecycle hooks
- Runtime guarantees:
- Per-execution flag reset (
valueandchanged) - Strict unknown-subcommand errors
- Duplicate visible-flag detection across local, persistent, and inherited scopes
- Per-execution flag reset (
Features
- Nested commands and subcommands
- Typed flags:
string,bool,int,float - Local flags (
flags) and inherited global flags (persistent_flags) - Command lifecycle hooks with
on_pre_runandon_persistent_pre_run - Built-in help (
--help,-h) and usage rendering - Flexible flag parsing styles:
--port 8080--port=8080-p 8080-p8080-p=8080--verbose(boolean implicit true)--verbose false/-v false/-v=false(boolean explicit value)--count -5(negative numeric values)
Installation
Add this dependency to your shard.yml:
dependencies:
argy:
github: AristoRap/argy
Then install dependencies:
shards install
Quick Start
require "argy"
root = Argy::Command.new(
use: "hello",
short: "A tiny greeter"
)
root.on_run do |cmd, _args|
name = cmd.string_flag("name")
puts "Hello, #{name}!"
end
root.flags.string("name", 'n', "world", "name to greet")
root.execute
Run it:
crystal run app.cr -- --name Crystal
Building Command Trees
Create commands and compose them with add_command:
root = Argy::Command.new(use: "devtool", short: "Developer tool")
serve = Argy::Command.new(use: "serve", short: "Start server")
root.add_command(serve)
root.execute
Defining and Reading Flags
Define local flags on flags:
serve.flags.int(name: "port", shorthand: 'p', default: 8080, usage: "port to listen on")
serve.flags.string("host", nil, "127.0.0.1", "host")
serve.flags.bool("tls", nil, false, "enable tls")
serve.flags.float("timeout", nil, 5.0, "request timeout")
Define global inherited flags on persistent_flags:
root.persistent_flags.string("config", 'c', "~/.mytool.yml", "config path")
root.persistent_flags.bool("verbose", 'v', false, "verbose output")
Read typed values inside on_run/on_pre_run callbacks:
serve.on_run do |cmd, _args|
puts cmd.int_flag("port")
puts cmd.string_flag("host")
puts cmd.bool_flag("tls")
puts cmd.float_flag("timeout")
end
Use args when you want positional (non-flag) input:
serve = Argy::Command.new(use: "serve [flags] [paths...]", short: "Start server")
serve.flags.int("port", 'p', 8080, "port to listen on")
serve.on_run do |cmd, args|
puts "Port: #{cmd.int_flag("port")}"
puts "Paths: #{args.join(", ")}" unless args.empty?
end
Example invocation:
devtool serve --port 9000 public assets
In that call, port is parsed from the flag, and args is ["public", "assets"].
Execution Lifecycle
When root.execute is called (or root.execute(argv) in tests), argy follows these steps in order:
1. Routing
The first non-flag token in argv is checked against the current command's subcommands. If a match is found, dispatch recurses into that subcommand with the remaining tokens. This continues until no further subcommand matches or the remaining argv starts with a flag.
infra db migrate --env production
^^ → routed to `db`
^^^^^^^ → routed to `db migrate`
^^^^^^^^^^^^^^^^ → parsed as flags on `db migrate`
2. Flag parsing
Once the target command is identified, all flags are parsed together in a single pass:
- Local flags (
cmd.flags) — defined on and only visible to this command - Own persistent flags (
cmd.persistent_flags) — defined on this command, inherited by all descendants - Inherited persistent flags — the
persistent_flagsof every ancestor, from parent up to root
Remaining tokens (non-flag words) are collected into args and passed to the hooks and on_run.
3. Help short-circuit
If --help or -h was passed, help is printed and execution stops. No hooks fire.
4. on_persistent_pre_run — root → leaf
Every command in the ancestry chain that has an on_persistent_pre_run block registered fires in order from root to the matched leaf. This runs on every execution path, making it ideal for setup that must happen regardless of which subcommand was invoked (loading config, setting up logging, etc.).
Because this fires after flag parsing, flags are fully resolved and readable inside the callback.
root.on_persistent_pre_run do |cmd, args|
# cmd is the command the hook was registered on (root here),
# not necessarily the matched leaf.
# Flags are already parsed and accessible.
setup_logger(cmd.bool_flag("verbose"))
end
5. on_pre_run — matched command only
Fires for the matched command only, after all on_persistent_pre_run hooks. Use this for per-command setup that shouldn't cascade to siblings.
db.on_pre_run do |cmd, _args|
connect_database(cmd.string_flag("env"))
end
6. on_run — matched command only
The command body. If no on_run is registered, argy prints that command's help page instead (useful for group commands like db that exist only to hold subcommands).
db_migrate.on_run do |cmd, args|
# flags fully parsed, all hooks already fired
puts "Migrating #{cmd.string_flag("env")}"
end
Full lifecycle summary
root.execute(argv)
│
├─ route to matching leaf command
│
├─ parse flags (local + own persistent + inherited persistent)
│
├─ --help? → print_help, stop
│
├─ on_persistent_pre_run root → ... → matched command
│
├─ on_pre_run matched command
│
└─ on_run matched command (or print_help if absent)
Flag resolution inside callbacks
Inside any hook or on_run, typed accessors search in this order:
- The command's local
flags - The command's own
persistent_flags - Ancestor
persistent_flags, nearest ancestor first
cmd.string_flag("env") # String
cmd.bool_flag("verbose") # Bool
cmd.int_flag("workers") # Int32
cmd.float_flag("timeout") # Float64
Detecting whether a flag was explicitly set
flag.changed is true only when the user actually passed that flag. This lets you distinguish an explicit --steps 0 from the default value of 0:
steps_flag = cmd.flags.lookup("steps")
label = (steps_flag && steps_flag.changed) ? cmd.int_flag("steps").to_s : "all"
Built-in help
Every command automatically gets --help / -h. If on_run is not registered on a command, argy prints that command's help page when it is invoked directly.
Repeated execution semantics
Calling execute multiple times on the same command tree is supported. Before each run, argy resets all flag values to their declared defaults and clears each flag's changed marker.
Error Handling
Argy raises typed errors such as:
Argy::UnknownFlagError— unknown flag or shorthand passed at runtimeArgy::UnknownCommandError— unknown subcommand token encountered during routingArgy::MissingFlagValueError— flag that requires a value was given noneArgy::InvalidFlagValueError— flag value could not be coerced to the expected typeArgy::DuplicateFlagError— duplicate visible flag name or shorthand detected at runtime (local, persistent, or inherited)
Command#execute catches Argy::Error, prints an error message plus usage hint, and exits with code 1.
Examples
Runnable examples are in the examples directory:
examples/simple.cr— single command with typed flagsexamples/moderate.cr— multiple subcommands andflag.changedusageexamples/complex.cr— nested command tree, persistent flags, and lifecycle hooks
Run them with:
crystal run examples/simple.cr -- --help
crystal run examples/moderate.cr -- info file.txt --format json
crystal run examples/complex.cr -- db migrate --env production --verbose
Development
Run specs:
crystal spec
Contributing
- Fork the repository
- Create a feature branch
- Add or update specs
- Open a pull request
License
MIT