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.green "hello #{opts[:args].first || 'world'}"
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.green "building #{opts[:env]}"
end
end
desc + def (classic DSL)
For when you'd rather write a Ruby method:
class MyCli < Hammer
desc 'Build the project'
opt :verbose, type: :boolean, alias: :v
opt :env, default: 'dev'
def build(opts)
say.green "building #{opts[:env]}"
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.
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.
Pre-hooks (before)
A before do ... end block at the root scope or inside a namespace
runs before every command in that scope (and its nested namespaces).
Hooks fire outer -> inner, then the command's handler:
before { Dotenv.load } # runs before every command
namespace :db do
before { hammer_env } # runs before every db:* command
define :migrate do
proc { |opts| ... } # no boilerplate require inside
end
end
before is intentionally not available inside define - the proc body
is the command body, just put the setup line at the top of the proc.
Pairs naturally with hidden commands (next section): keep :env /
:app setup as undocumented helpers and pull them in via before.
Hidden commands (no desc)
A command declared without a desc is hidden from help listings
but stays fully dispatchable and hammer_*-callable:
define :env do
proc { |_| require './config/env' } # no desc -> hidden
end
namespace :db do
before { hammer_env } # call it from a hook
define :migrate do
desc 'Run migrations'
proc { |_| ... }
end
end
hammer and hammer db won't list env, but hammer env,
hammer_env from another proc, and before { hammer_env } all work.
Prereqs (needs)
Declare commands that must run before this one (Rake-style task deps):
define :env do
proc { |_| require './config/env' } # hidden helper
end
define :app do
needs :env # runs `env` first
desc 'start the app'
proc { |opts| App.start }
end
define :deploy do
needs :env, :build # multiple prereqs, in order
proc { |opts| ... }
end
Prereq names are colon paths resolved against the root class - same
lookup as hammer_*. Use needs 'db:env' to depend on a namespaced
command.
Each prereq fires at most once per top-level invocation, so if
:app needs :env and :build also needs :env, :env still runs
only once. Prereqs run with default options (no argv passed through).
needs vs before:
before { hammer_env }- fires for every command in a scope.needs :env- declared per command, deduped across the call chain.
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.green 'deployed'
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
These are mixed into every handler (and also live on Hammer::Shell for
direct use).
say - print a line
say 'plain output' # no color
say.green 'ok' # color via proxy (preferred)
say.cyan "env=#{env}" # interpolation works the same
say 'equivalent', :green # two-arg form is still supported
say '' # blank line
say with no args returns a tiny proxy that exposes one method per
color: say.red 'x' is just say('x', :red). The proxy form reads
better when colors are the common case. Use say '' for an explicit
blank line.
String#color - paint a string without printing
label = 'OK'.color(:green)
puts "[#{label}] done" # embed colored chunks anywhere
Hammer::Shell.paint('x', :red) # the underlying primitive `say` uses
Colors
Valid names: :black :red :green :yellow :blue :magenta :cyan :white :gray.
Unknown colors (in say, say.<color>, paint, or String#color)
raise Hammer::Error listing the valid names - even when colors are
disabled, so typos fail loudly in CI.
Colors are auto-disabled when stdout isn't a TTY or when NO_COLOR is
set. Force on/off programmatically:
Hammer::Shell.color!(true) # force ANSI on
Hammer::Shell.color!(false) # force off
Hammer::Shell.color? # current state (bool)
error - controlled failure
error 'config file missing' unless File.exist?('config.yml')
# -> dispatcher prints "[error] config file missing" in red, exits 1
# No backtrace, no per-command help spam.
Internally it just raises Hammer::Error; the dispatcher catches it.
ask - read one line from stdin
name = ask 'your name' # required-style prompt
env = ask 'env', default: 'dev' # blank input -> "dev"
The prompt is shown in cyan with the default in brackets when present. Returns the typed line (chomped) or the default on a blank line.
yes? - y/N confirmation
exit 0 unless yes? 'continue?' # anything starting with y/Y -> true
# blank, n, anything else -> false
choose - arrow-key picker
envs = %w[dev staging prod]
idx = choose 'Pick env', envs
say.green "deploying to #{envs[idx]}" if idx
While the picker is up: j/k or ↑/↓ to move, Enter to confirm,
q / ESC / Ctrl-C to cancel. Returns the integer index of the
selected item, or nil on cancel.
When stdin isn't a TTY (pipes, redirected scripts, tests), choose
falls back to a numbered prompt: it prints each item with a number and
reads a line - same return contract, so calling code doesn't change.
$ echo 2 | mycli some-cmd
Pick env
1) dev
2) staging
3) prod
select [1-3]:
# idx -> 1 (zero-based)
sh - run a shell command, abort on failure
sh 'bundle install' # echoes "$ bundle install" in gray
sh "git tag v#{version}" # raises Hammer::Error on non-zero
Returns true on success. On non-zero exit it raises Hammer::Error,
which the dispatcher turns into [error] command failed: ... + exit 1.
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
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.green "migrating pretend=#{o[:pretend].inspect}" }
end
end
# tasks/deploy_hammer.rb
define :deploy do
desc 'Deploy to prod'
proc do |_|
hammer_db_migrate # cross-file invocation just works
say.cyan 'deployed'
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
load 'tasks' # directory -> recursive scan under it (empty OK)
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".
A directory argument is the explicit-anchor twin of load auto: true -
walks the directory for *_hammer.rb with the same skip rules. Useful
when you want to pull fragments from a known sibling tree without
making it the caller's dir.
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
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.cyan msg
end
end
end
Hammer.run without a block
If you call Hammer.run(ARGV) with no block, it does the obvious thing
relative to Dir.pwd:
- If
./Hammerfileexists, it's evaluated as the block DSL. - Otherwise,
*_hammer.rbfiles underDir.pwdare auto-discovered (same walk + skip rules asload auto: true).
Either way ARGV is dispatched against the resulting CLI. Useful for a one-line custom bin:
#!/usr/bin/env ruby
require 'lux-hammer'
Hammer.run ARGV
For more control - e.g. loading a Hammerfile from a fixed path and
auto-discovering from cwd - pass a block and use load explicitly:
Hammer.run ARGV do
load File.join(MY_ROOT, 'Hammerfile')
load Dir.pwd if Dir.pwd != MY_ROOT
end
Complete example (every feature)
# 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.green "building #{target}"
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.yellow "deploying to #{opts[:url]}"
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.green "migrating #{step} pretend=#{opts[:pretend].inspect}"
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.cyan "users role=#{opts[:role]} limit=#{opts[:limit]}"
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: hammer COMMAND [ARGS]
Commands:
hammer build # Build the project
hammer deploy (alt: ship) # Deploy to URL
db:
hammer db:migrate (alt: m) # Run pending migrations
db:users:
hammer db:users:list # List users
hammer 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: hammer db:COMMAND [ARGS]
Commands:
hammer db:migrate (alt: m) # Run pending migrations
users:
hammer db:users:list # List users
hammer db:users:create # Create a user
$ hammer db:users:create -h
Usage: hammer 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
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.