hammer
A modern CLI builder for Ruby. The good parts of
Rake and
Thor without the cruft. Drop a
Hammerfile, run hammer, ship.
Install
gem 'lux-hammer'
Or from the command line:
gem install lux-hammer
This installs the hammer binary and exposes require 'lux-hammer'.
Quick start
Create a Hammerfile in your project root:
define :hello do
desc 'say hi'
proc do |opts|
say "hello #{opts[:args].first || 'world'}", :green
end
end
Then:
$ hammer hello
hello world
$ hammer hello dino
hello dino
$ hammer
Usage: hammer COMMAND [ARGS]
Commands:
hammer hello # say hi
That's it. hammer walks up from your current directory looking for a
Hammerfile, evaluates it, and dispatches.
Why hammer (the short pitch)
A handful of papercuts from Rake and Thor that hammer just doesn't have.
Rake task arguments are awkward
Rake forces you into task[arg1,arg2] syntax with no types, no flags,
no help, and shell-hostile brackets:
# Rakefile
task :greet, [:name, :loud] do |_, args|
puts args[:loud] == 'true' ? "HELLO #{args[:name].upcase}" : "hello #{args[:name]}"
end
$ rake 'greet[dino,true]' # quotes required - zsh/bash treat [] as globs
Hammer takes typed options, positional fill, and any common flag form:
# Hammerfile
define :greet do
desc 'Say hello'
opt :name
opt :loud, type: :boolean, alias: :l
proc { |o| say o[:loud] ? "HELLO #{o[:name].upcase}" : "hello #{o[:name]}" }
end
$ hammer greet dino -l # positional fills :name, -l sets :loud
$ hammer greet --name=dino --loud # or be explicit
$ hammer greet -h # real help, with defaults and examples
Invoking another task
# Rake
Rake::Task['db:migrate'].invoke('prod')
# hammer - reads like a normal Ruby method call
hammer_db_migrate(env: 'prod')
Thor: desc welded to a method, no aliases, two arg systems
Thor splits arguments between method parameters and method_option,
needs a usage string repeated in desc, and has no first-class command
aliases (you reach for map):
# Thor
class MyCli < Thor
desc 'greet NAME', 'Say hello' # usage repeated by hand
method_option :loud, type: :boolean, aliases: '-l'
def greet(name) # name is a method param...
[:loud] ? puts(name.upcase) : puts(name) # ...options live elsewhere
end
map 'g' => :greet # aliases bolted on
end
# hammer - one arg system, real aliases, no usage string to maintain
define :greet do
desc 'Say hello'
alt :g
opt :name
opt :loud, type: :boolean, alias: :l
proc { |o| say o[:loud] ? o[:name].upcase : o[:name] }
end
Usage is generated from your opts, alt :g registers a real alias,
and there's one place to look for everything the command takes.
The two styles
define :name do ... end (block DSL)
The block's last expression must be proc do |opts| ... end. That
proc is the handler. Everything before it is metadata.
define :build do
desc 'Build the project'
example 'build prod -v'
opt :verbose, type: :boolean, alias: :v
opt :env, default: 'dev'
proc do |opts|
say "building #{opts[:env]}", :green
end
end
desc + def (classic DSL)
For when you'd rather write a Ruby method:
class MyCli < Hammer
program_name 'mycli'
desc 'Build the project'
opt :verbose, type: :boolean, alias: :v
opt :env, default: 'dev'
def build(opts)
say "building #{opts[:env]}", :green
end
desc 'Ping with no opts'
def ping
say 'pong'
end
end
MyCli.start(ARGV)
desc is the trigger - a def without a preceding desc is just a
regular method, never a command. Methods with arity 0 are called
without opts; methods that take an argument receive the opts hash.
Both styles can coexist in the same class.
Program name
program_name 'foo' (alias program 'foo') sets the name shown in
help/usage output. It is optional. When omitted, the default is:
- the invocation path relative to cwd if the script lives inside
the current working directory (e.g.
bin/foowhen invoked from the project root as./bin/fooorbin/foo), or - the basename of
$PROGRAM_NAMEotherwise (e.g.luxfor a globally installed bin inPATH).
So a project-local wrapper at bin/foo shows up in help as
Usage: bin/foo COMMAND [ARGS], while the same library invoked
through a globally installed lux shim shows Usage: lux .... Set
program 'whatever' explicitly to override.
Options (opt)
Declaration
opt :name,
type: :string, # :string (default) :boolean :integer :float :array
default: nil, # default value when omitted
alias: :n, # one or many - see below
desc: 'help text',# shown in `help COMMAND`
req: false # raise at parse time if not supplied
Underscores in the name become dashes in the flag:
opt :dry_run → --dry-run. The kwarg key in opts is still
:dry_run.
Invocation forms
For value options (anything that's not :boolean), all three forms work:
--port=3000 # long with equals
--port 3000 # long with space
-p 3000 # short alias with space (requires alias: :p)
Not supported: attached short form (-p3000), combined short flags
(-vf).
For boolean options:
--verbose # set to true
--no-verbose # set to false (only if a default of true is in play)
-v # short alias if declared
Per-type behavior
:string (default)
opt :env, default: 'dev'
hammer build --env prod # opts[:env] = "prod"
hammer build --env=prod # opts[:env] = "prod"
hammer build # opts[:env] = "dev" (default)
:boolean
opt :verbose, type: :boolean, alias: :v
opt :cache, type: :boolean, default: true
hammer build -v # opts[:verbose] = true
hammer build --verbose # opts[:verbose] = true
hammer build --no-cache # opts[:cache] = false (negates default)
hammer build # opts[:cache] = true (default)
# opts[:verbose] = nil (no default)
Booleans never consume a positional. --no-X only meaningfully overrides
a default: true.
:integer
opt :port, type: :integer, default: 3000, alias: :p
hammer serve --port 8080 # opts[:port] = 8080
hammer serve -p 8080 # opts[:port] = 8080
hammer serve # opts[:port] = 3000
Bad input raises a parse error: --port=abc → invalid value for Integer().
:float
opt :threshold, type: :float, default: 0.5
hammer scan --threshold 0.95 # opts[:threshold] = 0.95
:array
Comma-separated. No surrounding whitespace.
opt :tags, type: :array, default: []
hammer deploy --tags a,b,c # opts[:tags] = ["a", "b", "c"]
hammer deploy --tags=foo # opts[:tags] = ["foo"]
hammer deploy # opts[:tags] = []
Aliases (alias:)
A symbol becomes a flag automatically (1 char -> short, more -> long).
Strings starting with - pass through. One value or an array:
opt :port, alias: :p # -> -p
opt :pretend, alias: :p # -> -p
opt :rollback, alias: :back # -> --back (multi-letter symbol)
opt :verbose, alias: [:v, :V, :loud] # -> -v, -V, --loud
opt :env, alias: '-E' # string with `-` passes through
Required
req: true raises a parse error if neither a flag nor a positional
fills the opt:
opt :url, req: true
$ hammer deploy
[error] missing required --url
A positional satisfies it: hammer deploy https://x.com works because
of the declaration-order positional fill (see below).
Defaults
default: is used when neither a flag nor a positional supplies the
value:
opt :env, default: 'dev'
Note: boolean defaults of nil (the implicit default) and false are
not the same. nil means "not set; key absent from opts unless a flag
appears". Explicit default: false means "key always present, value
false unless --flag is passed".
Positional fill (declaration order)
Anything in ARGV without - / -- fills the next un-set
non-boolean opt, in declaration order:
define :deploy do
opt :url
opt :env, default: 'dev'
proc { |opts| ... }
end
These all produce the same opts:
hammer deploy https://x.com prod # both positional
hammer deploy https://x.com --env=prod # mixed
hammer deploy --url=https://x.com prod # mixed reverse
hammer deploy --url=https://x.com --env=prod # both flags
Rules recap:
- Boolean opts are skipped during positional fill.
- A flag value wins over a positional for the same opt.
- Leftover positionals go to
opts[:args]. - A positional satisfying a
req: trueopt counts as supplied.
The opts hash
Always a Hash with symbol keys. Keys present:
- one per declared option that was supplied (via flag, positional, or default)
opts[:args]- array of positional ARGV not absorbed by an opt
define :show do
opt :env, default: 'dev'
opt :loud, type: :boolean
proc { |opts| p opts }
end
$ hammer show foo bar --env=prod --loud
{env: "prod", loud: true, args: ["foo", "bar"]}
$ hammer show
{env: "dev", args: []}
Stopping option parsing (--)
A bare -- ends option parsing; everything after goes to opts[:args]
verbatim, even if it looks like a flag:
hammer build --env=prod -- --not-a-flag foo
# opts[:env] = "prod"
# opts[:args] = ["--not-a-flag", "foo"]
Namespaces (Rake-style colon paths)
Commands inside a namespace :name do ... end block are reached via
colon-paths from the root binary - just like rake db:migrate:
namespace :db do
define :migrate do
proc { |opts| ... }
end
namespace :users do
define :list do
proc { |opts| ... }
end
end
end
Then:
hammer db:migrate
hammer db:users:list
hammer db # bare namespace lists everything under it
hammer db:migrate -h # per-command help
Namespaces nest to any depth. There is no per-level dispatch - the root parses the whole colon path and walks the namespace tree.
Command aliases (alt)
alt :short_name (or several) registers extra names for a command:
define :server do
alt :s, :srv
proc { |opts| ... }
end
Then hammer server, hammer s, and hammer srv all dispatch to the
same command. Alts work inside namespaces too: alt :m on db:migrate
makes db:m resolve.
Cross-invocation (hammer_*)
From inside any command's proc - or from outside via the class - you can invoke other commands without re-shelling out:
define :deploy do
proc do |opts|
hammer_build(env: 'prod', verbose: true)
hammer_db_migrate
say 'deployed', :green
end
end
The mapping mirrors the CLI literally:
hammer_X_Y_Z→ command pathX:Y:Z(underscores in the method name become colons)- positional args → positional ARGV
verbose: true→--verboseno_cache: true→--no-cache(just the same rule - underscores in the kwarg key become dashes)dry_run: true→--dry-runenv: 'prod'→--env=prodanything: false→ skipped (no-op; useno_x: trueto negate)
MyCli.hammer_db_users_list("a", verbose: true) also works at the
class level, useful for tests and scripting.
Shell helpers
Available inside any handler:
say 'ok', :green
say 'big', :yellow, bold: true
error 'something broke' # prints + exits 1 (raises Hammer::Error)
name = ask 'name', default: 'world'
exit 0 unless yes? 'continue?'
sh 'bundle install' # echoes "$ bundle install", aborts on non-zero
Colors are auto-disabled when stdout isn't a TTY, when NO_COLOR is
set, or programmatically via Hammer::Shell.color!(false).
Splitting across files (load)
Once a Hammerfile grows past a screen or two, split it. Drop fragments
in any file ending in _hammer.rb and pull them in with load:
# Hammerfile
program 'demo'
load auto: true # recursive scan for *_hammer.rb from here
# tasks/db_hammer.rb
namespace :db do
define :migrate do
desc 'Run pending migrations'
opt :pretend, type: :boolean, alias: :p
proc { |o| say "migrating pretend=#{o[:pretend].inspect}", :green }
end
end
# tasks/deploy_hammer.rb
define :deploy do
desc 'Deploy to prod'
proc do |_|
hammer_db_migrate # cross-file invocation just works
say 'deployed', :cyan
end
end
Call shapes
load # same as load auto: true
load auto: true # recursive scan for *_hammer.rb under caller dir
load 'tasks/db_hammer.rb' # one file (path relative to caller)
load 'tasks/*_hammer.rb' # glob
load 'a.rb', 'b.rb' # several explicit paths
Paths resolve relative to the file calling load, not cwd. Inside a
Hammerfile that means "relative to the Hammerfile"; inside a class
body it means "relative to that file".
What's skipped
Auto-discovery walks recursively but skips .git, .bundle,
node_modules, tmp, vendor, dist, build, coverage, and any
hidden directory.
Fragment shape
A *_hammer.rb file is a block-DSL fragment - same surface as a
Hammerfile: define, namespace, and nested load. Not a class
re-open. If you want to extend a Hammer subclass in the classic
desc + def style across files, use plain require_relative.
Dedup and re-entrancy
Each file loads at most once per target class, keyed by absolute path.
A fragment can load other fragments without worrying about cycles.
Errors
If a fragment raises during load, the error surfaces as
failed loading <path>: <message> so you know which file blew up.
An explicit pattern (load 'x_hammer.rb', load 'tasks/*.rb') that
matches zero files raises; auto-mode finding nothing is silent.
Block DSL outside a Hammerfile
Same shape as a Hammerfile, just inline:
require 'lux-hammer'
Hammer.run(ARGV) do
program 'inline'
define :hello do
desc 'say hi'
opt :loud, type: :boolean, alias: :l
proc do |opts|
msg = "hello #{opts[:args].first || 'world'}"
msg = msg.upcase if opts[:loud]
say msg, :cyan
end
end
end
Complete example (every feature)
# Hammerfile
program 'demo'
# Simple top-level command
define :build do
desc 'Build the project'
example 'build prod -v'
example 'build --env=staging'
opt :verbose, type: :boolean, alias: :v, desc: 'verbose output'
opt :env, default: 'dev', desc: 'target env'
proc do |opts|
target = opts[:args].first || opts[:env]
say "building #{target}", :green, bold: true
say ' verbose on' if opts[:verbose]
end
end
# Command that calls another command
define :deploy do
desc 'Deploy to URL'
alt :ship
opt :url, req: true
opt :force, type: :boolean
proc do |opts|
hammer_build(env: 'prod')
exit 0 unless yes? "deploy to #{opts[:url]}?" unless opts[:force]
say "deploying to #{opts[:url]}", :yellow
end
end
# Namespace with two levels of nesting
namespace :db do
define :migrate do
desc 'Run pending migrations'
alt :m
example 'db:migrate 3 --pretend'
opt :pretend, type: :boolean, alias: :p
proc do |opts|
step = opts[:args].first || 'all'
say "migrating #{step} pretend=#{opts[:pretend].inspect}", :green
end
end
namespace :users do
define :list do
desc 'List users'
opt :role, default: 'all'
opt :limit, type: :integer, default: 100
proc do |opts|
say "users role=#{opts[:role]} limit=#{opts[:limit]}", :cyan
end
end
define :create do
desc 'Create a user'
opt :email, req: true
opt :admin, type: :boolean
proc do |opts|
say "create #{opts[:email]} admin=#{opts[:admin]}"
end
end
end
end
$ hammer
Usage: demo COMMAND [ARGS]
Commands:
demo build # Build the project
demo deploy (alt: ship) # Deploy to URL
db:
demo db:migrate (alt: m) # Run pending migrations
db:users:
demo db:users:list # List users
demo db:users:create # Create a user
$ hammer build prod -v
building prod
verbose on
$ hammer deploy --url=https://example.com --force
building prod
deploying to https://example.com
$ hammer db:m 3 -p # alt 'm' + positional + short bool
migrating 3 pretend=true
$ hammer db:users:create --email=dino@example.com --admin
create dino@example.com admin=true
$ hammer db # bare namespace shows its contents
Usage: demo db:COMMAND [ARGS]
Commands:
demo db:migrate (alt: m) # Run pending migrations
users:
demo db:users:list # List users
demo db:users:create # Create a user
$ hammer db:users:create -h
Usage: demo db:users:create EMAIL [OPTIONS]
Create a user
Options:
--email EMAIL (required)
--admin
Programmatic use
Outside a Hammerfile, you can build a Hammer subclass and run it
directly. Useful for embedding or testing:
require 'lux-hammer'
class MyCli < Hammer
program_name 'mycli'
define :greet do
opt :loud, type: :boolean
proc do |opts|
msg = "hello #{opts[:args].first}"
say(opts[:loud] ? msg.upcase : msg)
end
end
end
MyCli.start(ARGV) # or:
MyCli.hammer_greet('dino', loud: true)
Development
git clone https://github.com/dux/hammer
cd lux-hammer
bundle install
bundle exec rake test
Tests live in test/ and use minitest. Run a single file with
bundle exec ruby -Ilib -Itest test/parser_test.rb.
How hammer compares to Thor and Rake
Short version: hammer carves a sweet spot between the two. It's a tiny CLI builder with Rake's namespacing and a cleaner DSL than Thor, plus a few small things that have been bugging me about both for years.
Versus Thor
| Thor | hammer | |
|---|---|---|
| Lines of code | ~6,000 | ~400 |
| Runtime deps | a few | zero |
| Root constants | Thor, Thor::Group, Thor::Shell, Thor::Actions, ... |
just Hammer |
| Command DSL | desc 'usage', 'help' + method_option + def name(arg) |
`define :name do ... proc do \ |
| Opts container | Thor::CoreExt::HashWithIndifferentAccess |
plain Hash with symbol keys |
| Positional args | method positional params + method_option, two parallel systems |
declared-order opts fill from positional, single system |
| Sub-namespaces | register SubClass, 'name', '...' (inheritance ceremony) |
namespace :name do ... end (no classes needed) |
| Cross-invoke | invoke 'name', [args], opts |
hammer_name(*args, **kwargs) (looks like a method call) |
| Inline CLI | class only | class DSL or Hammer.run do ... end block DSL or a Hammerfile |
What hammer does better and why:
- One root constant. Thor exposes
Thor,Thor::Group,Thor::Shell,Thor::Actionsat the top level - Bundler had to vendor its own copy atBundler::Thorto avoid clashes. Hammer is justHammer. - The opts hash is just a Hash. Symbol keys, always. No magic accessor object to remember, no string-vs-symbol confusion, no method_missing.
- Positional args fill opts in declaration order. Thor either forces
you into method params (which then clash with options) or makes you read
ARGVyourself. Hammer just says: opts you declared come first, leftover goes toopts[:args]. - Cross-invocation reads as Ruby.
hammer_db_migrate(env: 'prod')looks like a method call. Thor'sinvoke('db:migrate', [], env: 'prod')always feels like reflection. - No generator complexity. Thor's other half is file scaffolding and ERB templates. If you don't need that (and most CLIs don't), Thor still drags it along.
Versus Rake
| Rake | hammer | |
|---|---|---|
| Primary use case | build/task automation with file deps | general CLIs |
| Task file | Rakefile |
Hammerfile |
| Namespacing | colon paths (db:migrate) |
colon paths (db:migrate) - parity |
| Per-task options | task[a,b,c] positional only |
typed opts with flags, aliases, defaults, required |
| Help | rake -T (plain list) |
bare hammer lists everything grouped by namespace; hammer X -h for per-command help with examples and defaults |
| Cross-invoke | Rake::Task['db:migrate'].invoke |
hammer_db_migrate |
| Prerequisites | task :build => [:clean, :compile] (declarative DAG) |
explicit - call hammer_clean; hammer_compile in the proc |
| File tasks | yes (mtime-based) | no |
| Aliases | none (workarounds via re-defined tasks) | alt :short_name |
| Split across files | import 'other.rake' |
load auto: true (or explicit paths/globs) |
What hammer does better and why:
- Per-command options with types. Rake's
task[a,b]syntax is a long-standing wart - no types, no validation, awkward to type in the shell, no help.opt :port, type: :integer, default: 3000is what every CLI library has converged on. - Help is actually useful.
hammer build -hshows usage, options with defaults and required markers, and examples.rake -Tis just a list of one-liners. - Command aliases.
alt :mfordb:migrateis two characters of declaration. Rake makes you redefine the task or use prerequisites. - CLI semantics. Rake assumes "build artifacts from sources"; it's great at that. Hammer assumes "give me commands with arguments and flags"; it's better at that.
What Rake does better:
- File tasks with mtime tracking.
file 'foo.o' => 'foo.c' do ... endskips work when the target is newer than the source. Genuine win for compilation pipelines. Hammer doesn't have this and isn't going to - it's not what a CLI builder is for.
When to pick which
- CLI for a tool, app, or service (run servers, manage data, ship releases, scripts your team uses) - hammer.
- Build pipeline with file-mtime dependencies (compiling assets, generating code, classic Make-style work) - Rake.
- Need to ship file generators / templates (Rails-style scaffolding) - Thor.
License
MIT - see LICENSE.