lux-hammer 0.0.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,1013 @@
1
+ # hammer
2
+
3
+ A modern CLI builder for Ruby. The good parts of
4
+ [Rake](https://github.com/ruby/rake) and
5
+ [Thor](https://github.com/rails/thor) without the cruft. Drop a
6
+ `Hammerfile`, run `hammer`, ship.
7
+
8
+ ## Install
9
+
10
+ ```ruby
11
+ gem 'lux-hammer'
12
+ ```
13
+
14
+ Or from the command line:
15
+
16
+ ```sh
17
+ gem install lux-hammer
18
+ ```
19
+
20
+ This installs the `hammer` binary and exposes `require 'lux-hammer'`.
21
+
22
+ ## Quick start
23
+
24
+ Create a `Hammerfile` in your project root:
25
+
26
+ ```ruby
27
+ define :hello do
28
+ desc 'say hi'
29
+ proc do |opts|
30
+ say.green "hello #{opts[:args].first || 'world'}"
31
+ end
32
+ end
33
+ ```
34
+
35
+ Then:
36
+
37
+ ```sh
38
+ $ hammer hello
39
+ hello world
40
+ $ hammer hello dino
41
+ hello dino
42
+ $ hammer
43
+ Usage: hammer COMMAND [ARGS]
44
+
45
+ Commands:
46
+ hammer hello # say hi
47
+ ```
48
+
49
+ That's it. `hammer` walks up from your current directory looking for a
50
+ `Hammerfile`, evaluates it, and dispatches.
51
+
52
+ ## Why hammer (the short pitch)
53
+
54
+ A handful of papercuts from Rake and Thor that hammer just doesn't have.
55
+
56
+ ### Rake task arguments are awkward
57
+
58
+ Rake forces you into `task[arg1,arg2]` syntax with no types, no flags,
59
+ no help, and shell-hostile brackets:
60
+
61
+ ```ruby
62
+ # Rakefile
63
+ task :greet, [:name, :loud] do |_, args|
64
+ puts args[:loud] == 'true' ? "HELLO #{args[:name].upcase}" : "hello #{args[:name]}"
65
+ end
66
+ ```
67
+ ```sh
68
+ $ rake 'greet[dino,true]' # quotes required - zsh/bash treat [] as globs
69
+ ```
70
+
71
+ Hammer takes typed options, positional fill, and any common flag form:
72
+
73
+ ```ruby
74
+ # Hammerfile
75
+ define :greet do
76
+ desc 'Say hello'
77
+ opt :name
78
+ opt :loud, type: :boolean, alias: :l
79
+ proc { |o| say o[:loud] ? "HELLO #{o[:name].upcase}" : "hello #{o[:name]}" }
80
+ end
81
+ ```
82
+ ```sh
83
+ $ hammer greet dino -l # positional fills :name, -l sets :loud
84
+ $ hammer greet --name=dino --loud # or be explicit
85
+ $ hammer greet -h # real help, with defaults and examples
86
+ ```
87
+
88
+ ### Invoking another task
89
+
90
+ ```ruby
91
+ # Rake
92
+ Rake::Task['db:migrate'].invoke('prod')
93
+ ```
94
+ ```ruby
95
+ # hammer - reads like a normal Ruby method call
96
+ hammer_db_migrate(env: 'prod')
97
+ ```
98
+
99
+ ### Thor: `desc` welded to a method, no aliases, two arg systems
100
+
101
+ Thor splits arguments between method parameters and `method_option`,
102
+ needs a usage string repeated in `desc`, and has no first-class command
103
+ aliases (you reach for `map`):
104
+
105
+ ```ruby
106
+ # Thor
107
+ class MyCli < Thor
108
+ desc 'greet NAME', 'Say hello' # usage repeated by hand
109
+ method_option :loud, type: :boolean, aliases: '-l'
110
+ def greet(name) # name is a method param...
111
+ options[:loud] ? puts(name.upcase) : puts(name) # ...options live elsewhere
112
+ end
113
+ map 'g' => :greet # aliases bolted on
114
+ end
115
+ ```
116
+
117
+ ```ruby
118
+ # hammer - one arg system, real aliases, no usage string to maintain
119
+ define :greet do
120
+ desc 'Say hello'
121
+ alt :g
122
+ opt :name
123
+ opt :loud, type: :boolean, alias: :l
124
+ proc { |o| say o[:loud] ? o[:name].upcase : o[:name] }
125
+ end
126
+ ```
127
+
128
+ Usage is generated from your `opt`s, `alt :g` registers a real alias,
129
+ and there's one place to look for everything the command takes.
130
+
131
+ ## The two styles
132
+
133
+ ### `define :name do ... end` (block DSL)
134
+
135
+ The block's **last expression must be `proc do |opts| ... end`**. That
136
+ proc is the handler. Everything before it is metadata.
137
+
138
+ ```ruby
139
+ define :build do
140
+ desc 'Build the project'
141
+ example 'build prod -v'
142
+ opt :verbose, type: :boolean, alias: :v
143
+ opt :env, default: 'dev'
144
+
145
+ proc do |opts|
146
+ say.green "building #{opts[:env]}"
147
+ end
148
+ end
149
+ ```
150
+
151
+ ### `desc` + `def` (classic DSL)
152
+
153
+ For when you'd rather write a Ruby method:
154
+
155
+ ```ruby
156
+ class MyCli < Hammer
157
+ desc 'Build the project'
158
+ opt :verbose, type: :boolean, alias: :v
159
+ opt :env, default: 'dev'
160
+ def build(opts)
161
+ say.green "building #{opts[:env]}"
162
+ end
163
+
164
+ desc 'Ping with no opts'
165
+ def ping
166
+ say 'pong'
167
+ end
168
+ end
169
+
170
+ MyCli.start(ARGV)
171
+ ```
172
+
173
+ `desc` is the trigger - a `def` without a preceding `desc` is just a
174
+ regular method, never a command. Methods with arity 0 are called
175
+ without opts; methods that take an argument receive the opts hash.
176
+
177
+ Both styles can coexist in the same class.
178
+
179
+ ## Options (`opt`)
180
+
181
+ ### Declaration
182
+
183
+ ```ruby
184
+ opt :name,
185
+ type: :string, # :string (default) :boolean :integer :float :array
186
+ default: nil, # default value when omitted
187
+ alias: :n, # one or many - see below
188
+ desc: 'help text',# shown in `help COMMAND`
189
+ req: false # raise at parse time if not supplied
190
+ ```
191
+
192
+ Underscores in the name become dashes in the flag:
193
+ `opt :dry_run` → `--dry-run`. The kwarg key in `opts` is still
194
+ `:dry_run`.
195
+
196
+ ### Invocation forms
197
+
198
+ For value options (anything that's not `:boolean`), all three forms work:
199
+
200
+ ```
201
+ --port=3000 # long with equals
202
+ --port 3000 # long with space
203
+ -p 3000 # short alias with space (requires alias: :p)
204
+ ```
205
+
206
+ Not supported: attached short form (`-p3000`), combined short flags
207
+ (`-vf`).
208
+
209
+ For boolean options:
210
+
211
+ ```
212
+ --verbose # set to true
213
+ --no-verbose # set to false (only if a default of true is in play)
214
+ -v # short alias if declared
215
+ ```
216
+
217
+ ### Per-type behavior
218
+
219
+ #### `:string` (default)
220
+
221
+ ```ruby
222
+ opt :env, default: 'dev'
223
+ ```
224
+ ```
225
+ hammer build --env prod # opts[:env] = "prod"
226
+ hammer build --env=prod # opts[:env] = "prod"
227
+ hammer build # opts[:env] = "dev" (default)
228
+ ```
229
+
230
+ #### `:boolean`
231
+
232
+ ```ruby
233
+ opt :verbose, type: :boolean, alias: :v
234
+ opt :cache, type: :boolean, default: true
235
+ ```
236
+ ```
237
+ hammer build -v # opts[:verbose] = true
238
+ hammer build --verbose # opts[:verbose] = true
239
+ hammer build --no-cache # opts[:cache] = false (negates default)
240
+ hammer build # opts[:cache] = true (default)
241
+ # opts[:verbose] = nil (no default)
242
+ ```
243
+
244
+ Booleans never consume a positional. `--no-X` only meaningfully overrides
245
+ a `default: true`.
246
+
247
+ #### `:integer`
248
+
249
+ ```ruby
250
+ opt :port, type: :integer, default: 3000, alias: :p
251
+ ```
252
+ ```
253
+ hammer serve --port 8080 # opts[:port] = 8080
254
+ hammer serve -p 8080 # opts[:port] = 8080
255
+ hammer serve # opts[:port] = 3000
256
+ ```
257
+
258
+ Bad input raises a parse error: `--port=abc` → `invalid value for Integer()`.
259
+
260
+ #### `:float`
261
+
262
+ ```ruby
263
+ opt :threshold, type: :float, default: 0.5
264
+ ```
265
+ ```
266
+ hammer scan --threshold 0.95 # opts[:threshold] = 0.95
267
+ ```
268
+
269
+ #### `:array`
270
+
271
+ Comma-separated. No surrounding whitespace.
272
+
273
+ ```ruby
274
+ opt :tags, type: :array, default: []
275
+ ```
276
+ ```
277
+ hammer deploy --tags a,b,c # opts[:tags] = ["a", "b", "c"]
278
+ hammer deploy --tags=foo # opts[:tags] = ["foo"]
279
+ hammer deploy # opts[:tags] = []
280
+ ```
281
+
282
+ ### Aliases (`alias:`)
283
+
284
+ A symbol becomes a flag automatically (1 char -> short, more -> long).
285
+ Strings starting with `-` pass through. One value or an array:
286
+
287
+ ```ruby
288
+ opt :port, alias: :p # -> -p
289
+ opt :pretend, alias: :p # -> -p
290
+ opt :rollback, alias: :back # -> --back (multi-letter symbol)
291
+ opt :verbose, alias: [:v, :V, :loud] # -> -v, -V, --loud
292
+ opt :env, alias: '-E' # string with `-` passes through
293
+ ```
294
+
295
+ ### Required
296
+
297
+ `req: true` raises a parse error if neither a flag nor a positional
298
+ fills the opt:
299
+
300
+ ```ruby
301
+ opt :url, req: true
302
+ ```
303
+ ```
304
+ $ hammer deploy
305
+ [error] missing required --url
306
+ ```
307
+
308
+ A positional satisfies it: `hammer deploy https://x.com` works because
309
+ of the declaration-order positional fill (see below).
310
+
311
+ ### Defaults
312
+
313
+ `default:` is used when neither a flag nor a positional supplies the
314
+ value:
315
+
316
+ ```ruby
317
+ opt :env, default: 'dev'
318
+ ```
319
+
320
+ Note: boolean defaults of `nil` (the implicit default) and `false` are
321
+ not the same. `nil` means "not set; key absent from opts unless a flag
322
+ appears". Explicit `default: false` means "key always present, value
323
+ false unless `--flag` is passed".
324
+
325
+ ### Positional fill (declaration order)
326
+
327
+ Anything in ARGV without `-` / `--` fills the next un-set
328
+ **non-boolean** opt, in declaration order:
329
+
330
+ ```ruby
331
+ define :deploy do
332
+ opt :url
333
+ opt :env, default: 'dev'
334
+ proc { |opts| ... }
335
+ end
336
+ ```
337
+
338
+ These all produce the same `opts`:
339
+
340
+ ```
341
+ hammer deploy https://x.com prod # both positional
342
+ hammer deploy https://x.com --env=prod # mixed
343
+ hammer deploy --url=https://x.com prod # mixed reverse
344
+ hammer deploy --url=https://x.com --env=prod # both flags
345
+ ```
346
+
347
+ Rules recap:
348
+
349
+ * Boolean opts are skipped during positional fill.
350
+ * A flag value wins over a positional for the same opt.
351
+ * Leftover positionals go to `opts[:args]`.
352
+ * A positional satisfying a `req: true` opt counts as supplied.
353
+
354
+ ### The `opts` hash
355
+
356
+ Always a `Hash` with **symbol keys**. Keys present:
357
+
358
+ * one per declared option that was supplied (via flag, positional, or
359
+ default)
360
+ * `opts[:args]` - array of positional ARGV not absorbed by an opt
361
+
362
+ ```ruby
363
+ define :show do
364
+ opt :env, default: 'dev'
365
+ opt :loud, type: :boolean
366
+ proc { |opts| p opts }
367
+ end
368
+ ```
369
+ ```
370
+ $ hammer show foo bar --env=prod --loud
371
+ {env: "prod", loud: true, args: ["foo", "bar"]}
372
+
373
+ $ hammer show
374
+ {env: "dev", args: []}
375
+ ```
376
+
377
+ ### Stopping option parsing (`--`)
378
+
379
+ A bare `--` ends option parsing; everything after goes to `opts[:args]`
380
+ verbatim, even if it looks like a flag:
381
+
382
+ ```
383
+ hammer build --env=prod -- --not-a-flag foo
384
+ # opts[:env] = "prod"
385
+ # opts[:args] = ["--not-a-flag", "foo"]
386
+ ```
387
+
388
+ ## Namespaces (Rake-style colon paths)
389
+
390
+ Commands inside a `namespace :name do ... end` block are reached via
391
+ colon-paths from the root binary - just like `rake db:migrate`:
392
+
393
+ ```ruby
394
+ namespace :db do
395
+ define :migrate do
396
+ proc { |opts| ... }
397
+ end
398
+
399
+ namespace :users do
400
+ define :list do
401
+ proc { |opts| ... }
402
+ end
403
+ end
404
+ end
405
+ ```
406
+
407
+ Then:
408
+
409
+ ```
410
+ hammer db:migrate
411
+ hammer db:users:list
412
+ hammer db # bare namespace lists everything under it
413
+ hammer db:migrate -h # per-command help
414
+ ```
415
+
416
+ Namespaces nest to any depth. There is no per-level dispatch - the root
417
+ parses the whole colon path and walks the namespace tree.
418
+
419
+ ## Pre-hooks (`before`)
420
+
421
+ A `before do ... end` block at the root scope or inside a `namespace`
422
+ runs before every command in that scope (and its nested namespaces).
423
+ Hooks fire outer -> inner, then the command's handler:
424
+
425
+ ```ruby
426
+ before { Dotenv.load } # runs before every command
427
+
428
+ namespace :db do
429
+ before { hammer_env } # runs before every db:* command
430
+ define :migrate do
431
+ proc { |opts| ... } # no boilerplate require inside
432
+ end
433
+ end
434
+ ```
435
+
436
+ `before` is intentionally not available inside `define` - the proc body
437
+ *is* the command body, just put the setup line at the top of the proc.
438
+
439
+ Pairs naturally with hidden commands (next section): keep `:env` /
440
+ `:app` setup as undocumented helpers and pull them in via `before`.
441
+
442
+ ## Hidden commands (no `desc`)
443
+
444
+ A command declared without a `desc` is **hidden from help listings**
445
+ but stays fully dispatchable and `hammer_*`-callable:
446
+
447
+ ```ruby
448
+ define :env do
449
+ proc { |_| require './config/env' } # no desc -> hidden
450
+ end
451
+
452
+ namespace :db do
453
+ before { hammer_env } # call it from a hook
454
+ define :migrate do
455
+ desc 'Run migrations'
456
+ proc { |_| ... }
457
+ end
458
+ end
459
+ ```
460
+
461
+ `hammer` and `hammer db` won't list `env`, but `hammer env`,
462
+ `hammer_env` from another proc, and `before { hammer_env }` all work.
463
+
464
+ ## Prereqs (`needs`)
465
+
466
+ Declare commands that must run before this one (Rake-style task deps):
467
+
468
+ ```ruby
469
+ define :env do
470
+ proc { |_| require './config/env' } # hidden helper
471
+ end
472
+
473
+ define :app do
474
+ needs :env # runs `env` first
475
+ desc 'start the app'
476
+ proc { |opts| App.start }
477
+ end
478
+
479
+ define :deploy do
480
+ needs :env, :build # multiple prereqs, in order
481
+ proc { |opts| ... }
482
+ end
483
+ ```
484
+
485
+ Prereq names are colon paths resolved against the root class - same
486
+ lookup as `hammer_*`. Use `needs 'db:env'` to depend on a namespaced
487
+ command.
488
+
489
+ Each prereq fires **at most once per top-level invocation**, so if
490
+ `:app` needs `:env` and `:build` also needs `:env`, `:env` still runs
491
+ only once. Prereqs run with default options (no argv passed through).
492
+
493
+ `needs` vs `before`:
494
+ * `before { hammer_env }` - fires for *every* command in a scope.
495
+ * `needs :env` - declared per command, deduped across the call chain.
496
+
497
+ ## Command aliases (`alt`)
498
+
499
+ `alt :short_name` (or several) registers extra names for a command:
500
+
501
+ ```ruby
502
+ define :server do
503
+ alt :s, :srv
504
+ proc { |opts| ... }
505
+ end
506
+ ```
507
+
508
+ Then `hammer server`, `hammer s`, and `hammer srv` all dispatch to the
509
+ same command. Alts work inside namespaces too: `alt :m` on `db:migrate`
510
+ makes `db:m` resolve.
511
+
512
+ ## Cross-invocation (`hammer_*`)
513
+
514
+ From inside any command's proc - or from outside via the class - you can
515
+ invoke other commands without re-shelling out:
516
+
517
+ ```ruby
518
+ define :deploy do
519
+ proc do |opts|
520
+ hammer_build(env: 'prod', verbose: true)
521
+ hammer_db_migrate
522
+ say.green 'deployed'
523
+ end
524
+ end
525
+ ```
526
+
527
+ The mapping mirrors the CLI literally:
528
+
529
+ * `hammer_X_Y_Z` → command path `X:Y:Z` (underscores in the method
530
+ name become colons)
531
+ * positional args → positional ARGV
532
+ * `verbose: true` → `--verbose`
533
+ * `no_cache: true` → `--no-cache` (just the same rule - underscores in
534
+ the kwarg key become dashes)
535
+ * `dry_run: true` → `--dry-run`
536
+ * `env: 'prod'` → `--env=prod`
537
+ * `anything: false` → skipped (no-op; use `no_x: true` to negate)
538
+
539
+ `MyCli.hammer_db_users_list("a", verbose: true)` also works at the
540
+ class level, useful for tests and scripting.
541
+
542
+ ## Shell helpers
543
+
544
+ These are mixed into every handler (and also live on `Hammer::Shell` for
545
+ direct use).
546
+
547
+ ### `say` - print a line
548
+
549
+ ```ruby
550
+ say 'plain output' # no color
551
+ say.green 'ok' # color via proxy (preferred)
552
+ say.cyan "env=#{env}" # interpolation works the same
553
+ say 'equivalent', :green # two-arg form is still supported
554
+ say '' # blank line
555
+ ```
556
+
557
+ `say` with no args returns a tiny proxy that exposes one method per
558
+ color: `say.red 'x'` is just `say('x', :red)`. The proxy form reads
559
+ better when colors are the common case. Use `say ''` for an explicit
560
+ blank line.
561
+
562
+ ### `String#color` - paint a string without printing
563
+
564
+ ```ruby
565
+ label = 'OK'.color(:green)
566
+ puts "[#{label}] done" # embed colored chunks anywhere
567
+
568
+ Hammer::Shell.paint('x', :red) # the underlying primitive `say` uses
569
+ ```
570
+
571
+ ### Colors
572
+
573
+ Valid names: `:black :red :green :yellow :blue :magenta :cyan :white :gray`.
574
+ Unknown colors (in `say`, `say.<color>`, `paint`, or `String#color`)
575
+ raise `Hammer::Error` listing the valid names - even when colors are
576
+ disabled, so typos fail loudly in CI.
577
+
578
+ Colors are auto-disabled when stdout isn't a TTY or when `NO_COLOR` is
579
+ set. Force on/off programmatically:
580
+
581
+ ```ruby
582
+ Hammer::Shell.color!(true) # force ANSI on
583
+ Hammer::Shell.color!(false) # force off
584
+ Hammer::Shell.color? # current state (bool)
585
+ ```
586
+
587
+ ### `error` - controlled failure
588
+
589
+ ```ruby
590
+ error 'config file missing' unless File.exist?('config.yml')
591
+ # -> dispatcher prints "[error] config file missing" in red, exits 1
592
+ # No backtrace, no per-command help spam.
593
+ ```
594
+
595
+ Internally it just raises `Hammer::Error`; the dispatcher catches it.
596
+
597
+ ### `ask` - read one line from stdin
598
+
599
+ ```ruby
600
+ name = ask 'your name' # required-style prompt
601
+ env = ask 'env', default: 'dev' # blank input -> "dev"
602
+ ```
603
+
604
+ The prompt is shown in cyan with the default in brackets when present.
605
+ Returns the typed line (chomped) or the default on a blank line.
606
+
607
+ ### `yes?` - y/N confirmation
608
+
609
+ ```ruby
610
+ exit 0 unless yes? 'continue?' # anything starting with y/Y -> true
611
+ # blank, n, anything else -> false
612
+ ```
613
+
614
+ ### `choose` - arrow-key picker
615
+
616
+ ```ruby
617
+ envs = %w[dev staging prod]
618
+ idx = choose 'Pick env', envs
619
+ say.green "deploying to #{envs[idx]}" if idx
620
+ ```
621
+
622
+ While the picker is up: `j`/`k` or `↑`/`↓` to move, `Enter` to confirm,
623
+ `q` / `ESC` / Ctrl-C to cancel. Returns the integer index of the
624
+ selected item, or `nil` on cancel.
625
+
626
+ When stdin isn't a TTY (pipes, redirected scripts, tests), `choose`
627
+ falls back to a numbered prompt: it prints each item with a number and
628
+ reads a line - same return contract, so calling code doesn't change.
629
+
630
+ ```
631
+ $ echo 2 | mycli some-cmd
632
+ Pick env
633
+ 1) dev
634
+ 2) staging
635
+ 3) prod
636
+ select [1-3]:
637
+ # idx -> 1 (zero-based)
638
+ ```
639
+
640
+ ### `sh` - run a shell command, abort on failure
641
+
642
+ ```ruby
643
+ sh 'bundle install' # echoes "$ bundle install" in gray
644
+ sh "git tag v#{version}" # raises Hammer::Error on non-zero
645
+ ```
646
+
647
+ Returns `true` on success. On non-zero exit it raises `Hammer::Error`,
648
+ which the dispatcher turns into `[error] command failed: ...` + exit 1.
649
+
650
+ ## Splitting across files (`load`)
651
+
652
+ Once a `Hammerfile` grows past a screen or two, split it. Drop fragments
653
+ in any file ending in `_hammer.rb` and pull them in with `load`:
654
+
655
+ ```ruby
656
+ # Hammerfile
657
+ load auto: true # recursive scan for *_hammer.rb from here
658
+ ```
659
+
660
+ ```ruby
661
+ # tasks/db_hammer.rb
662
+ namespace :db do
663
+ define :migrate do
664
+ desc 'Run pending migrations'
665
+ opt :pretend, type: :boolean, alias: :p
666
+ proc { |o| say.green "migrating pretend=#{o[:pretend].inspect}" }
667
+ end
668
+ end
669
+ ```
670
+
671
+ ```ruby
672
+ # tasks/deploy_hammer.rb
673
+ define :deploy do
674
+ desc 'Deploy to prod'
675
+ proc do |_|
676
+ hammer_db_migrate # cross-file invocation just works
677
+ say.cyan 'deployed'
678
+ end
679
+ end
680
+ ```
681
+
682
+ ### Call shapes
683
+
684
+ ```ruby
685
+ load # same as load auto: true
686
+ load auto: true # recursive scan for *_hammer.rb under caller dir
687
+ load 'tasks/db_hammer.rb' # one file (path relative to caller)
688
+ load 'tasks/*_hammer.rb' # glob
689
+ load 'a.rb', 'b.rb' # several explicit paths
690
+ load 'tasks' # directory -> recursive scan under it (empty OK)
691
+ ```
692
+
693
+ Paths resolve relative to the file calling `load`, not cwd. Inside a
694
+ `Hammerfile` that means "relative to the Hammerfile"; inside a class
695
+ body it means "relative to that file".
696
+
697
+ A directory argument is the explicit-anchor twin of `load auto: true` -
698
+ walks the directory for `*_hammer.rb` with the same skip rules. Useful
699
+ when you want to pull fragments from a known sibling tree without
700
+ making it the caller's dir.
701
+
702
+ ### What's skipped
703
+
704
+ Auto-discovery walks recursively but skips `.git`, `.bundle`,
705
+ `node_modules`, `tmp`, `vendor`, `dist`, `build`, `coverage`, and any
706
+ hidden directory.
707
+
708
+ ### Fragment shape
709
+
710
+ A `*_hammer.rb` file is a **block-DSL fragment** - same surface as a
711
+ `Hammerfile`: `define`, `namespace`, and nested `load`. Not a class
712
+ re-open. If you want to extend a `Hammer` subclass in the classic
713
+ `desc` + `def` style across files, use plain `require_relative`.
714
+
715
+ ### Dedup and re-entrancy
716
+
717
+ Each file loads at most once per target class, keyed by absolute path.
718
+ A fragment can `load` other fragments without worrying about cycles.
719
+
720
+ ### Errors
721
+
722
+ If a fragment raises during load, the error surfaces as
723
+ `failed loading <path>: <message>` so you know which file blew up.
724
+ An explicit pattern (`load 'x_hammer.rb'`, `load 'tasks/*.rb'`) that
725
+ matches zero files raises; auto-mode finding nothing is silent.
726
+
727
+ ## Block DSL outside a Hammerfile
728
+
729
+ Same shape as a Hammerfile, just inline:
730
+
731
+ ```ruby
732
+ require 'lux-hammer'
733
+
734
+ Hammer.run(ARGV) do
735
+ define :hello do
736
+ desc 'say hi'
737
+ opt :loud, type: :boolean, alias: :l
738
+ proc do |opts|
739
+ msg = "hello #{opts[:args].first || 'world'}"
740
+ msg = msg.upcase if opts[:loud]
741
+ say.cyan msg
742
+ end
743
+ end
744
+ end
745
+ ```
746
+
747
+ ### `Hammer.run` without a block
748
+
749
+ If you call `Hammer.run(ARGV)` with no block, it does the obvious thing
750
+ relative to `Dir.pwd`:
751
+
752
+ * If `./Hammerfile` exists, it's evaluated as the block DSL.
753
+ * Otherwise, `*_hammer.rb` files under `Dir.pwd` are auto-discovered
754
+ (same walk + skip rules as `load auto: true`).
755
+
756
+ Either way ARGV is dispatched against the resulting CLI. Useful for a
757
+ one-line custom bin:
758
+
759
+ ```ruby
760
+ #!/usr/bin/env ruby
761
+ require 'lux-hammer'
762
+ Hammer.run ARGV
763
+ ```
764
+
765
+ For more control - e.g. loading a Hammerfile from a fixed path *and*
766
+ auto-discovering from cwd - pass a block and use `load` explicitly:
767
+
768
+ ```ruby
769
+ Hammer.run ARGV do
770
+ load File.join(MY_ROOT, 'Hammerfile')
771
+ load Dir.pwd if Dir.pwd != MY_ROOT
772
+ end
773
+ ```
774
+
775
+ ## Complete example (every feature)
776
+
777
+ ```ruby
778
+ # Simple top-level command
779
+ define :build do
780
+ desc 'Build the project'
781
+ example 'build prod -v'
782
+ example 'build --env=staging'
783
+ opt :verbose, type: :boolean, alias: :v, desc: 'verbose output'
784
+ opt :env, default: 'dev', desc: 'target env'
785
+
786
+ proc do |opts|
787
+ target = opts[:args].first || opts[:env]
788
+ say.green "building #{target}"
789
+ say ' verbose on' if opts[:verbose]
790
+ end
791
+ end
792
+
793
+ # Command that calls another command
794
+ define :deploy do
795
+ desc 'Deploy to URL'
796
+ alt :ship
797
+ opt :url, req: true
798
+ opt :force, type: :boolean
799
+
800
+ proc do |opts|
801
+ hammer_build(env: 'prod')
802
+ exit 0 unless yes? "deploy to #{opts[:url]}?" unless opts[:force]
803
+ say.yellow "deploying to #{opts[:url]}"
804
+ end
805
+ end
806
+
807
+ # Namespace with two levels of nesting
808
+ namespace :db do
809
+ define :migrate do
810
+ desc 'Run pending migrations'
811
+ alt :m
812
+ example 'db:migrate 3 --pretend'
813
+ opt :pretend, type: :boolean, alias: :p
814
+
815
+ proc do |opts|
816
+ step = opts[:args].first || 'all'
817
+ say.green "migrating #{step} pretend=#{opts[:pretend].inspect}"
818
+ end
819
+ end
820
+
821
+ namespace :users do
822
+ define :list do
823
+ desc 'List users'
824
+ opt :role, default: 'all'
825
+ opt :limit, type: :integer, default: 100
826
+
827
+ proc do |opts|
828
+ say.cyan "users role=#{opts[:role]} limit=#{opts[:limit]}"
829
+ end
830
+ end
831
+
832
+ define :create do
833
+ desc 'Create a user'
834
+ opt :email, req: true
835
+ opt :admin, type: :boolean
836
+
837
+ proc do |opts|
838
+ say "create #{opts[:email]} admin=#{opts[:admin]}"
839
+ end
840
+ end
841
+ end
842
+ end
843
+ ```
844
+
845
+ ```sh
846
+ $ hammer
847
+ Usage: demo COMMAND [ARGS]
848
+
849
+ Commands:
850
+ demo build # Build the project
851
+ demo deploy (alt: ship) # Deploy to URL
852
+
853
+ db:
854
+ demo db:migrate (alt: m) # Run pending migrations
855
+
856
+ db:users:
857
+ demo db:users:list # List users
858
+ demo db:users:create # Create a user
859
+
860
+ $ hammer build prod -v
861
+ building prod
862
+ verbose on
863
+
864
+ $ hammer deploy --url=https://example.com --force
865
+ building prod
866
+ deploying to https://example.com
867
+
868
+ $ hammer db:m 3 -p # alt 'm' + positional + short bool
869
+ migrating 3 pretend=true
870
+
871
+ $ hammer db:users:create --email=dino@example.com --admin
872
+ create dino@example.com admin=true
873
+
874
+ $ hammer db # bare namespace shows its contents
875
+ Usage: demo db:COMMAND [ARGS]
876
+
877
+ Commands:
878
+ demo db:migrate (alt: m) # Run pending migrations
879
+
880
+ users:
881
+ demo db:users:list # List users
882
+ demo db:users:create # Create a user
883
+
884
+ $ hammer db:users:create -h
885
+ Usage: demo db:users:create EMAIL [OPTIONS]
886
+ Create a user
887
+
888
+ Options:
889
+ --email EMAIL (required)
890
+ --admin
891
+ ```
892
+
893
+ ## Programmatic use
894
+
895
+ Outside a Hammerfile, you can build a `Hammer` subclass and run it
896
+ directly. Useful for embedding or testing:
897
+
898
+ ```ruby
899
+ require 'lux-hammer'
900
+
901
+ class MyCli < Hammer
902
+ define :greet do
903
+ opt :loud, type: :boolean
904
+ proc do |opts|
905
+ msg = "hello #{opts[:args].first}"
906
+ say(opts[:loud] ? msg.upcase : msg)
907
+ end
908
+ end
909
+ end
910
+
911
+ MyCli.start(ARGV) # or:
912
+ MyCli.hammer_greet('dino', loud: true)
913
+ ```
914
+
915
+ ## Development
916
+
917
+ ```sh
918
+ git clone https://github.com/dux/hammer
919
+ cd lux-hammer
920
+ bundle install
921
+ bundle exec rake test
922
+ ```
923
+
924
+ Tests live in `test/` and use minitest. Run a single file with
925
+ `bundle exec ruby -Ilib -Itest test/parser_test.rb`.
926
+
927
+ ## How hammer compares to Thor and Rake
928
+
929
+ Short version: hammer carves a sweet spot between the two. It's a tiny
930
+ CLI builder with Rake's namespacing and a cleaner DSL than Thor, plus a
931
+ few small things that have been bugging me about both for years.
932
+
933
+ ### Versus Thor
934
+
935
+ | | Thor | hammer |
936
+ |-|-|-|
937
+ | Lines of code | ~6,000 | ~400 |
938
+ | Runtime deps | a few | zero |
939
+ | Root constants | `Thor`, `Thor::Group`, `Thor::Shell`, `Thor::Actions`, ... | just `Hammer` |
940
+ | Command DSL | `desc 'usage', 'help'` + `method_option` + `def name(arg)` | `define :name do ... proc do \|opts\| end end` (or classic `desc` + `def`) |
941
+ | Opts container | `Thor::CoreExt::HashWithIndifferentAccess` | plain `Hash` with symbol keys |
942
+ | Positional args | method positional params + `method_option`, two parallel systems | declared-order opts fill from positional, single system |
943
+ | Sub-namespaces | `register SubClass, 'name', '...'` (inheritance ceremony) | `namespace :name do ... end` (no classes needed) |
944
+ | Cross-invoke | `invoke 'name', [args], opts` | `hammer_name(*args, **kwargs)` (looks like a method call) |
945
+ | Inline CLI | class only | class DSL **or** `Hammer.run do ... end` block DSL **or** a `Hammerfile` |
946
+
947
+ **What hammer does better and why:**
948
+
949
+ * **One root constant.** Thor exposes `Thor`, `Thor::Group`, `Thor::Shell`,
950
+ `Thor::Actions` at the top level - Bundler had to vendor its own copy at
951
+ `Bundler::Thor` to avoid clashes. Hammer is just `Hammer`.
952
+ * **The opts hash is just a Hash.** Symbol keys, always. No magic accessor
953
+ object to remember, no string-vs-symbol confusion, no method_missing.
954
+ * **Positional args fill opts in declaration order.** Thor either forces
955
+ you into method params (which then clash with options) or makes you read
956
+ `ARGV` yourself. Hammer just says: opts you declared come first, leftover
957
+ goes to `opts[:args]`.
958
+ * **Cross-invocation reads as Ruby.** `hammer_db_migrate(env: 'prod')`
959
+ looks like a method call. Thor's `invoke('db:migrate', [], env: 'prod')`
960
+ always feels like reflection.
961
+ * **No generator complexity.** Thor's other half is file scaffolding and
962
+ ERB templates. If you don't need that (and most CLIs don't), Thor still
963
+ drags it along.
964
+
965
+ ### Versus Rake
966
+
967
+ | | Rake | hammer |
968
+ |-|-|-|
969
+ | Primary use case | build/task automation with file deps | general CLIs |
970
+ | Task file | `Rakefile` | `Hammerfile` |
971
+ | Namespacing | colon paths (`db:migrate`) | colon paths (`db:migrate`) - parity |
972
+ | Per-task options | `task[a,b,c]` positional only | typed `opt`s with flags, aliases, defaults, required |
973
+ | Help | `rake -T` (plain list) | bare `hammer` lists everything grouped by namespace; `hammer X -h` for per-command help with examples and defaults |
974
+ | Cross-invoke | `Rake::Task['db:migrate'].invoke` | `hammer_db_migrate` |
975
+ | Prerequisites | `task :build => [:clean, :compile]` (declarative DAG) | explicit - call `hammer_clean; hammer_compile` in the proc |
976
+ | File tasks | yes (mtime-based) | no |
977
+ | Aliases | none (workarounds via re-defined tasks) | `alt :short_name` |
978
+ | Split across files | `import 'other.rake'` | `load auto: true` (or explicit paths/globs) |
979
+
980
+ **What hammer does better and why:**
981
+
982
+ * **Per-command options with types.** Rake's `task[a,b]` syntax is a
983
+ long-standing wart - no types, no validation, awkward to type in the
984
+ shell, no help. `opt :port, type: :integer, default: 3000` is what
985
+ every CLI library has converged on.
986
+ * **Help is actually useful.** `hammer build -h` shows usage, options
987
+ with defaults and required markers, and examples. `rake -T` is just a
988
+ list of one-liners.
989
+ * **Command aliases.** `alt :m` for `db:migrate` is two characters of
990
+ declaration. Rake makes you redefine the task or use prerequisites.
991
+ * **CLI semantics.** Rake assumes "build artifacts from sources"; it's
992
+ great at that. Hammer assumes "give me commands with arguments and
993
+ flags"; it's better at that.
994
+
995
+ **What Rake does better:**
996
+
997
+ * **File tasks with mtime tracking.** `file 'foo.o' => 'foo.c' do ... end`
998
+ skips work when the target is newer than the source. Genuine win for
999
+ compilation pipelines. Hammer doesn't have this and isn't going to -
1000
+ it's not what a CLI builder is for.
1001
+
1002
+ ### When to pick which
1003
+
1004
+ * **CLI for a tool, app, or service** (run servers, manage data, ship
1005
+ releases, scripts your team uses) - **hammer**.
1006
+ * **Build pipeline with file-mtime dependencies** (compiling assets,
1007
+ generating code, classic Make-style work) - **Rake**.
1008
+ * **Need to ship file generators / templates** (Rails-style
1009
+ scaffolding) - **Thor**.
1010
+
1011
+ ## License
1012
+
1013
+ MIT - see [LICENSE](LICENSE).