lux-hammer 0.2.2 → 0.2.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b973a2becedf10ed4247fce46d195f07619c6f6b9bf56f41464b57764733716
4
- data.tar.gz: a1ffdc8fa9b19f47599a3e930181fa1972095872db4f92740308cf393090bfcd
3
+ metadata.gz: 7007c5628c3466147f7776a2de86832fa8737623ef7d91c8c4dc4a4b3db7e557
4
+ data.tar.gz: 38d2781742e9cb9d8d12c22b3e8c416fe9f26901a7ef0bed678b206f872a9468
5
5
  SHA512:
6
- metadata.gz: 55e093ed6cc2ed08349fdfd9296360153b2d0cb4763b57546e4e9453657e0da7e74608179b70946d6600499624b0cac1c57017208a91143d3eba6917a1cef976
7
- data.tar.gz: 3401f9c5defba4419e40c571cfcab4869ceb408dbcf8a88d9bab98aea2760ad810ceee02feaa31f78b661849e88b1ecb7c5e63ad3e930ed3baa371637d15f20b
6
+ metadata.gz: d3dd10a43530fd33c1535ea38086eb3a86ce8d3750e3f79acb765b390b667ab70048bda841e3ab3959effcc1438aa0557e07bfd7f24d727d64a8e963a3ed8b6c
7
+ data.tar.gz: e976dc67d490f64c44044fab9dcad7e2b5431570eefdada070feb2689daba6fea8af46572ba223a6f801c881917ff522f519d08cf19218bd530ac438f91d4759
data/.version CHANGED
@@ -1 +1 @@
1
- 0.2.2
1
+ 0.2.4
data/README.md CHANGED
@@ -1,9 +1,52 @@
1
1
  # hammer
2
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.
3
+ The bastard Frankenstein child of Rake, Thor, and Joshua. Sewn
4
+ together from three good ideas, with the rest of each parent left on
5
+ the cutting room floor.
6
+
7
+ Drop a `Hammerfile`, run `hammer`, ship. AI LLM-s love `hammer`.
8
+
9
+ ```ruby
10
+ namespace :db do # Rake-style colon paths
11
+ task :migrate do # Joshua-style task block
12
+ desc 'Run pending migrations'
13
+ opt :pretend, type: :boolean, alias: :p # Thor-style typed opts
14
+ proc do |o|
15
+ say.green "migrating pretend=#{o[:pretend].inspect}"
16
+ end
17
+ end
18
+ end
19
+ ```
20
+
21
+ ```sh
22
+ $ hammer db:migrate --pretend
23
+ migrating pretend=true
24
+ ```
25
+
26
+ ## The bloodline
27
+
28
+ * **From [Rake](https://github.com/ruby/rake)** we took the *namespace
29
+ tree* and *task prereqs* - `db:users:list` colon paths, `needs :env`
30
+ dependencies, the one-file `Hammerfile` at the project root. Left on
31
+ the table: the build-system DNA (file/mtime tasks) and that
32
+ `task[a,b,c]` argument syntax nobody enjoys typing into a shell.
33
+
34
+ * **From [Thor](https://github.com/rails/thor)** we took *real CLI
35
+ ergonomics* - typed options, defaults, aliases, required flags, and
36
+ generated `-h` help. Left on the table: the four-constant top-level
37
+ (`Thor`, `Thor::Group`, `Thor::Shell`, `Thor::Actions`), the
38
+ parallel method-params-vs-`method_option` arg systems, the
39
+ `HashWithIndifferentAccess`, and the file generator half of the gem
40
+ that most CLIs never reach for.
41
+
42
+ * **From [Joshua](https://github.com/dux/joshua)** we took the
43
+ *`task :name do ... end` block DSL* - declarative metadata up
44
+ top, one `proc do |opts| ... end` at the bottom doing the work. No
45
+ `def`-and-`desc` split, no class required, no boilerplate between
46
+ "what this command is" and "what it does".
47
+
48
+ The result: ~400 lines of code, zero runtime dependencies, and a file
49
+ that reads top-to-bottom like a recipe.
7
50
 
8
51
  ## Install
9
52
 
@@ -24,7 +67,7 @@ This installs the `hammer` binary and exposes `require 'lux-hammer'`.
24
67
  Create a `Hammerfile` in your project root:
25
68
 
26
69
  ```ruby
27
- define :hello do
70
+ task :hello do
28
71
  desc 'say hi'
29
72
  proc do |opts|
30
73
  say.green "hello #{opts[:args].first || 'world'}"
@@ -72,7 +115,7 @@ Hammer takes typed options, positional fill, and any common flag form:
72
115
 
73
116
  ```ruby
74
117
  # Hammerfile
75
- define :greet do
118
+ task :greet do
76
119
  desc 'Say hello'
77
120
  opt :name
78
121
  opt :loud, type: :boolean, alias: :l
@@ -93,7 +136,7 @@ Rake::Task['db:migrate'].invoke('prod')
93
136
  ```
94
137
  ```ruby
95
138
  # hammer - reads like a normal Ruby method call
96
- hammer_db_migrate(env: 'prod')
139
+ hammer 'db:migrate', env: 'prod'
97
140
  ```
98
141
 
99
142
  ### Thor: `desc` welded to a method, no aliases, two arg systems
@@ -116,7 +159,7 @@ end
116
159
 
117
160
  ```ruby
118
161
  # hammer - one arg system, real aliases, no usage string to maintain
119
- define :greet do
162
+ task :greet do
120
163
  desc 'Say hello'
121
164
  alt :g
122
165
  opt :name
@@ -130,13 +173,13 @@ and there's one place to look for everything the command takes.
130
173
 
131
174
  ## The two styles
132
175
 
133
- ### `define :name do ... end` (block DSL)
176
+ ### `task :name do ... end` (block DSL)
134
177
 
135
178
  The block's **last expression must be `proc do |opts| ... end`**. That
136
179
  proc is the handler. Everything before it is metadata.
137
180
 
138
181
  ```ruby
139
- define :build do
182
+ task :build do
140
183
  desc 'Build the project'
141
184
  example 'build prod -v'
142
185
  opt :verbose, type: :boolean, alias: :v
@@ -328,7 +371,7 @@ Anything in ARGV without `-` / `--` fills the next un-set
328
371
  **non-boolean** opt, in declaration order:
329
372
 
330
373
  ```ruby
331
- define :deploy do
374
+ task :deploy do
332
375
  opt :url
333
376
  opt :env, default: 'dev'
334
377
  proc { |opts| ... }
@@ -360,7 +403,7 @@ Always a `Hash` with **symbol keys**. Keys present:
360
403
  * `opts[:args]` - array of positional ARGV not absorbed by an opt
361
404
 
362
405
  ```ruby
363
- define :show do
406
+ task :show do
364
407
  opt :env, default: 'dev'
365
408
  opt :loud, type: :boolean
366
409
  proc { |opts| p opts }
@@ -392,12 +435,12 @@ colon-paths from the root binary - just like `rake db:migrate`:
392
435
 
393
436
  ```ruby
394
437
  namespace :db do
395
- define :migrate do
438
+ task :migrate do
396
439
  proc { |opts| ... }
397
440
  end
398
441
 
399
442
  namespace :users do
400
- define :list do
443
+ task :list do
401
444
  proc { |opts| ... }
402
445
  end
403
446
  end
@@ -410,7 +453,9 @@ Then:
410
453
  hammer db:migrate
411
454
  hammer db:users:list
412
455
  hammer db # bare namespace lists everything under it
456
+ hammer db: # trailing colon: full per-task help for every command
413
457
  hammer db:migrate -h # per-command help
458
+ hammer : # trailing colon at root: full help for every command
414
459
  ```
415
460
 
416
461
  Namespaces nest to any depth. There is no per-level dispatch - the root
@@ -426,14 +471,14 @@ Hooks fire outer -> inner, then the command's handler:
426
471
  before { Dotenv.load } # runs before every command
427
472
 
428
473
  namespace :db do
429
- before { hammer_env } # runs before every db:* command
430
- define :migrate do
474
+ before { hammer :env } # runs before every db:* command
475
+ task :migrate do
431
476
  proc { |opts| ... } # no boilerplate require inside
432
477
  end
433
478
  end
434
479
  ```
435
480
 
436
- `before` is intentionally not available inside `define` - the proc body
481
+ `before` is intentionally not available inside `task` - the proc body
437
482
  *is* the command body, just put the setup line at the top of the proc.
438
483
 
439
484
  Pairs naturally with hidden commands (next section): keep `:env` /
@@ -442,16 +487,16 @@ Pairs naturally with hidden commands (next section): keep `:env` /
442
487
  ## Hidden commands (no `desc`)
443
488
 
444
489
  A command declared without a `desc` is **hidden from help listings**
445
- but stays fully dispatchable and `hammer_*`-callable:
490
+ but stays fully dispatchable and `hammer`-callable:
446
491
 
447
492
  ```ruby
448
- define :env do
493
+ task :env do
449
494
  proc { |_| require './config/env' } # no desc -> hidden
450
495
  end
451
496
 
452
497
  namespace :db do
453
- before { hammer_env } # call it from a hook
454
- define :migrate do
498
+ before { hammer :env } # call it from a hook
499
+ task :migrate do
455
500
  desc 'Run migrations'
456
501
  proc { |_| ... }
457
502
  end
@@ -459,31 +504,31 @@ end
459
504
  ```
460
505
 
461
506
  `hammer` and `hammer db` won't list `env`, but `hammer env`,
462
- `hammer_env` from another proc, and `before { hammer_env }` all work.
507
+ `hammer :env` from another proc, and `before { hammer :env }` all work.
463
508
 
464
509
  ## Prereqs (`needs`)
465
510
 
466
511
  Declare commands that must run before this one (Rake-style task deps):
467
512
 
468
513
  ```ruby
469
- define :env do
514
+ task :env do
470
515
  proc { |_| require './config/env' } # hidden helper
471
516
  end
472
517
 
473
- define :app do
518
+ task :app do
474
519
  needs :env # runs `env` first
475
520
  desc 'start the app'
476
521
  proc { |opts| App.start }
477
522
  end
478
523
 
479
- define :deploy do
524
+ task :deploy do
480
525
  needs :env, :build # multiple prereqs, in order
481
526
  proc { |opts| ... }
482
527
  end
483
528
  ```
484
529
 
485
530
  Prereq names are colon paths resolved against the root class - same
486
- lookup as `hammer_*`. Use `needs 'db:env'` to depend on a namespaced
531
+ lookup as `hammer`. Use `needs 'db:env'` to depend on a namespaced
487
532
  command.
488
533
 
489
534
  Each prereq fires **at most once per top-level invocation**, so if
@@ -491,7 +536,7 @@ Each prereq fires **at most once per top-level invocation**, so if
491
536
  only once. Prereqs run with default options (no argv passed through).
492
537
 
493
538
  `needs` vs `before`:
494
- * `before { hammer_env }` - fires for *every* command in a scope.
539
+ * `before { hammer :env }` - fires for *every* command in a scope.
495
540
  * `needs :env` - declared per command, deduped across the call chain.
496
541
 
497
542
  ## Command aliases (`alt`)
@@ -499,7 +544,7 @@ only once. Prereqs run with default options (no argv passed through).
499
544
  `alt :short_name` (or several) registers extra names for a command:
500
545
 
501
546
  ```ruby
502
- define :server do
547
+ task :server do
503
548
  alt :s, :srv
504
549
  proc { |opts| ... }
505
550
  end
@@ -509,36 +554,62 @@ Then `hammer server`, `hammer s`, and `hammer srv` all dispatch to the
509
554
  same command. Alts work inside namespaces too: `alt :m` on `db:migrate`
510
555
  makes `db:m` resolve.
511
556
 
512
- ## Cross-invocation (`hammer_*`)
557
+ ## Cross-invocation (`hammer`)
513
558
 
514
559
  From inside any command's proc - or from outside via the class - you can
515
560
  invoke other commands without re-shelling out:
516
561
 
517
562
  ```ruby
518
- define :deploy do
563
+ task :deploy do
519
564
  proc do |opts|
520
- hammer_build(env: 'prod', verbose: true)
521
- hammer_db_migrate
565
+ hammer :build, env: 'prod', verbose: true
566
+ hammer 'db:migrate'
522
567
  say.green 'deployed'
523
568
  end
524
569
  end
525
570
  ```
526
571
 
527
- The mapping mirrors the CLI literally:
572
+ Signature: `hammer(name, *args, **opts)`. `name` is a symbol for a
573
+ single command or a colon-path string for namespaced commands. Trailing
574
+ positionals become positional ARGV; kwargs become CLI flags:
528
575
 
529
- * `hammer_X_Y_Z` → command path `X:Y:Z` (underscores in the method
530
- name become colons)
531
- * positional args → positional ARGV
576
+ * `hammer :foo` → `foo`
577
+ * `hammer 'db:users:list'` → `db:users:list`
578
+ * `hammer :evaluate, 'puts 42'` `evaluate "puts 42"` (positional ARGV)
532
579
  * `verbose: true` → `--verbose`
533
- * `no_cache: true` → `--no-cache` (just the same rule - underscores in
534
- the kwarg key become dashes)
580
+ * `no_cache: true` → `--no-cache` (underscores in the key become dashes)
535
581
  * `dry_run: true` → `--dry-run`
536
582
  * `env: 'prod'` → `--env=prod`
537
583
  * `anything: false` → skipped (no-op; use `no_x: true` to negate)
538
584
 
539
- `MyCli.hammer_db_users_list("a", verbose: true)` also works at the
585
+ `MyCli.hammer 'db:users:list', verbose: true` also works at the
540
586
  class level, useful for tests and scripting.
541
587
 
588
+ ## Chained dispatch (`+`)
589
+
590
+ Run several commands in one shot, Rake-style, using a bare `+` token
591
+ as the separator:
592
+
593
+ ```sh
594
+ hammer build + deploy + notify
595
+ hammer build prod -v + db:migrate --pretend + deploy --url=https://x.com
596
+ ```
597
+
598
+ Each segment is parsed and dispatched independently - its own opts,
599
+ its own positional args. `needs` prereqs dedupe across the whole
600
+ chain, so a shared `:env` step runs once even if every chained command
601
+ declares `needs :env`.
602
+
603
+ A `+` only acts as a separator when it arrives as its own argv token.
604
+ Quoting protects it: `--foo="a + b"` reaches the parser as a single
605
+ token and is left alone. To pass a literal `+` as a positional, double
606
+ it - `++` unescapes to a single `+`:
607
+
608
+ ```sh
609
+ hammer echo ++ x # echo gets positional args ["+", "x"]
610
+ hammer echo + bar # runs echo, then runs bar
611
+ ```
612
+
542
613
  ## Shell helpers
543
614
 
544
615
  These are mixed into every handler (and also live on `Hammer::Shell` for
@@ -660,7 +731,7 @@ load auto: true # recursive scan for *_hammer.rb from here
660
731
  ```ruby
661
732
  # tasks/db_hammer.rb
662
733
  namespace :db do
663
- define :migrate do
734
+ task :migrate do
664
735
  desc 'Run pending migrations'
665
736
  opt :pretend, type: :boolean, alias: :p
666
737
  proc { |o| say.green "migrating pretend=#{o[:pretend].inspect}" }
@@ -670,10 +741,10 @@ end
670
741
 
671
742
  ```ruby
672
743
  # tasks/deploy_hammer.rb
673
- define :deploy do
744
+ task :deploy do
674
745
  desc 'Deploy to prod'
675
746
  proc do |_|
676
- hammer_db_migrate # cross-file invocation just works
747
+ hammer 'db:migrate' # cross-file invocation just works
677
748
  say.cyan 'deployed'
678
749
  end
679
750
  end
@@ -708,7 +779,7 @@ hidden directory.
708
779
  ### Fragment shape
709
780
 
710
781
  A `*_hammer.rb` file is a **block-DSL fragment** - same surface as a
711
- `Hammerfile`: `define`, `namespace`, and nested `load`. Not a class
782
+ `Hammerfile`: `task`, `namespace`, and nested `load`. Not a class
712
783
  re-open. If you want to extend a `Hammer` subclass in the classic
713
784
  `desc` + `def` style across files, use plain `require_relative`.
714
785
 
@@ -732,7 +803,7 @@ Same shape as a Hammerfile, just inline:
732
803
  require 'lux-hammer'
733
804
 
734
805
  Hammer.run(ARGV) do
735
- define :hello do
806
+ task :hello do
736
807
  desc 'say hi'
737
808
  opt :loud, type: :boolean, alias: :l
738
809
  proc do |opts|
@@ -776,7 +847,7 @@ end
776
847
 
777
848
  ```ruby
778
849
  # Simple top-level command
779
- define :build do
850
+ task :build do
780
851
  desc 'Build the project'
781
852
  example 'build prod -v'
782
853
  example 'build --env=staging'
@@ -791,14 +862,14 @@ define :build do
791
862
  end
792
863
 
793
864
  # Command that calls another command
794
- define :deploy do
865
+ task :deploy do
795
866
  desc 'Deploy to URL'
796
867
  alt :ship
797
868
  opt :url, req: true
798
869
  opt :force, type: :boolean
799
870
 
800
871
  proc do |opts|
801
- hammer_build(env: 'prod')
872
+ hammer :build, env: 'prod'
802
873
  exit 0 unless yes? "deploy to #{opts[:url]}?" unless opts[:force]
803
874
  say.yellow "deploying to #{opts[:url]}"
804
875
  end
@@ -806,7 +877,7 @@ end
806
877
 
807
878
  # Namespace with two levels of nesting
808
879
  namespace :db do
809
- define :migrate do
880
+ task :migrate do
810
881
  desc 'Run pending migrations'
811
882
  alt :m
812
883
  example 'db:migrate 3 --pretend'
@@ -819,7 +890,7 @@ namespace :db do
819
890
  end
820
891
 
821
892
  namespace :users do
822
- define :list do
893
+ task :list do
823
894
  desc 'List users'
824
895
  opt :role, default: 'all'
825
896
  opt :limit, type: :integer, default: 100
@@ -829,7 +900,7 @@ namespace :db do
829
900
  end
830
901
  end
831
902
 
832
- define :create do
903
+ task :create do
833
904
  desc 'Create a user'
834
905
  opt :email, req: true
835
906
  opt :admin, type: :boolean
@@ -899,7 +970,7 @@ directly. Useful for embedding or testing:
899
970
  require 'lux-hammer'
900
971
 
901
972
  class MyCli < Hammer
902
- define :greet do
973
+ task :greet do
903
974
  opt :loud, type: :boolean
904
975
  proc do |opts|
905
976
  msg = "hello #{opts[:args].first}"
@@ -909,7 +980,7 @@ class MyCli < Hammer
909
980
  end
910
981
 
911
982
  MyCli.start(ARGV) # or:
912
- MyCli.hammer_greet('dino', loud: true)
983
+ MyCli.hammer :greet, loud: true
913
984
  ```
914
985
 
915
986
  ## Development
@@ -937,11 +1008,11 @@ few small things that have been bugging me about both for years.
937
1008
  | Lines of code | ~6,000 | ~400 |
938
1009
  | Runtime deps | a few | zero |
939
1010
  | 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`) |
1011
+ | Command DSL | `desc 'usage', 'help'` + `method_option` + `def name(arg)` | `task :name do ... proc do \|opts\| end end` (or classic `desc` + `def`) |
941
1012
  | Opts container | `Thor::CoreExt::HashWithIndifferentAccess` | plain `Hash` with symbol keys |
942
1013
  | Positional args | method positional params + `method_option`, two parallel systems | declared-order opts fill from positional, single system |
943
1014
  | 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) |
1015
+ | Cross-invoke | `invoke 'name', [args], opts` | `hammer :name, **opts` (looks like a method call) |
945
1016
  | Inline CLI | class only | class DSL **or** `Hammer.run do ... end` block DSL **or** a `Hammerfile` |
946
1017
 
947
1018
  **What hammer does better and why:**
@@ -955,7 +1026,7 @@ few small things that have been bugging me about both for years.
955
1026
  you into method params (which then clash with options) or makes you read
956
1027
  `ARGV` yourself. Hammer just says: opts you declared come first, leftover
957
1028
  goes to `opts[:args]`.
958
- * **Cross-invocation reads as Ruby.** `hammer_db_migrate(env: 'prod')`
1029
+ * **Cross-invocation reads as Ruby.** `hammer 'db:migrate', env: 'prod'`
959
1030
  looks like a method call. Thor's `invoke('db:migrate', [], env: 'prod')`
960
1031
  always feels like reflection.
961
1032
  * **No generator complexity.** Thor's other half is file scaffolding and
@@ -971,8 +1042,8 @@ few small things that have been bugging me about both for years.
971
1042
  | Namespacing | colon paths (`db:migrate`) | colon paths (`db:migrate`) - parity |
972
1043
  | Per-task options | `task[a,b,c]` positional only | typed `opt`s with flags, aliases, defaults, required |
973
1044
  | 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 |
1045
+ | Cross-invoke | `Rake::Task['db:migrate'].invoke` | `hammer 'db:migrate'` |
1046
+ | Prerequisites | `task :build => [:clean, :compile]` (declarative DAG) | explicit - call `hammer :clean; hammer :compile` in the proc |
976
1047
  | File tasks | yes (mtime-based) | no |
977
1048
  | Aliases | none (workarounds via re-defined tasks) | `alt :short_name` |
978
1049
  | Split across files | `import 'other.rake'` | `load auto: true` (or explicit paths/globs) |
@@ -7,8 +7,8 @@ class Hammer
7
7
  @klass = klass
8
8
  end
9
9
 
10
- def define(name, &block)
11
- @klass.define(name, &block)
10
+ def task(name, &block)
11
+ @klass.task(name, &block)
12
12
  end
13
13
 
14
14
  def namespace(name, &block)
@@ -1,5 +1,5 @@
1
1
  class Hammer
2
- # Context object for `define :name do ... end` blocks. Exposes
2
+ # Context object for `task :name do ... end` blocks. Exposes
3
3
  # desc/example/opt/alt; the block's return value (a `proc do |opts|`)
4
4
  # becomes the command handler.
5
5
  class CommandBuilder
data/lib/lux-hammer.rb CHANGED
@@ -11,13 +11,13 @@ require_relative 'hammer/command_builder'
11
11
  # Class DSL:
12
12
  #
13
13
  # class MyCli < Hammer
14
- # define :build do
14
+ # task :build do
15
15
  # desc 'Build the project'
16
16
  # example 'build -v --env=prod'
17
17
  # opt :verbose, type: :boolean, alias: :v
18
18
  # opt :env, type: :string, default: 'dev'
19
19
  # proc do |opts|
20
- # say "building #{opts[:env]} args=#{opts[:args].inspect}", :green
20
+ # say.green "building #{opts[:env]} args=#{opts[:args].inspect}"
21
21
  # end
22
22
  # end
23
23
  # end
@@ -27,7 +27,7 @@ require_relative 'hammer/command_builder'
27
27
  # Block DSL is identical, just inside `Hammer.run`:
28
28
  #
29
29
  # Hammer.run(ARGV) do
30
- # define :hello do
30
+ # task :hello do
31
31
  # desc 'Greet someone'
32
32
  # opt :loud, type: :boolean, alias: :l
33
33
  # proc do |opts|
@@ -124,17 +124,17 @@ class Hammer
124
124
  # return a Proc as its last expression. That proc is the handler and
125
125
  # receives a single `opts` hash with symbol keys; positional ARGV
126
126
  # lives at `opts[:args]`.
127
- def define(name, &block)
127
+ def task(name, &block)
128
128
  cmd = Command.new(name: name.to_s)
129
129
  handler = CommandBuilder.new(cmd).instance_eval(&block)
130
130
  unless handler.is_a?(Proc)
131
131
  raise Error, <<~MSG
132
- define(:#{name}) block must end with a `proc do |opts| ... end`.
132
+ task(:#{name}) block must end with a `proc do |opts| ... end`.
133
133
  The proc's return value is what becomes the command handler.
134
134
 
135
135
  Example:
136
136
 
137
- define :#{name} do
137
+ task :#{name} do
138
138
  desc 'what it does'
139
139
  example '#{name} foo --env=prod'
140
140
  opt :env, default: 'dev'
@@ -148,7 +148,7 @@ class Hammer
148
148
  cmd.handler = handler
149
149
  commands[cmd.name] = cmd
150
150
 
151
- # `define` ignores pending class-level state, but clear it so a
151
+ # `task` ignores pending class-level state, but clear it so a
152
152
  # later `def` doesn't accidentally consume stale metadata.
153
153
  @pending_desc = nil
154
154
  @pending_examples = []
@@ -158,17 +158,17 @@ class Hammer
158
158
  end
159
159
 
160
160
  # Open a namespace (group of commands). Everything inside the block
161
- # (define, nested namespace, ...) belongs to that namespace, evaluated
161
+ # (task, nested namespace, ...) belongs to that namespace, evaluated
162
162
  # against an anonymous Hammer subclass.
163
163
  #
164
164
  # namespace :db do
165
- # define :migrate do ... end
165
+ # task :migrate do ... end
166
166
  # namespace :users do ... end
167
167
  # end
168
168
  def namespace(name, &block)
169
169
  sub = Class.new(Hammer)
170
170
  # Track the top-level CLI class so cross-invocation
171
- # (`hammer_<colon_path>`) from inside a namespaced command dispatches
171
+ # (`hammer 'ns:cmd'`) from inside a namespaced command dispatches
172
172
  # against the full tree, not just the current namespace.
173
173
  sub.instance_variable_set(:@root, root)
174
174
  # Parent link, so `before` hooks defined further up the namespace
@@ -188,8 +188,8 @@ class Hammer
188
188
  #
189
189
  # before { |opts| Dotenv.load }
190
190
  # namespace :db do
191
- # before { hammer_env }
192
- # define :migrate do ... end
191
+ # before { hammer :env }
192
+ # task :migrate do ... end
193
193
  # end
194
194
  def before(&block)
195
195
  before_hooks << block
@@ -257,15 +257,41 @@ class Hammer
257
257
  # Entry point. Parses ARGV, finds the right command, runs it.
258
258
  # Command names are Rake-style colon paths: "build", "db:migrate",
259
259
  # "db:users:list".
260
+ #
261
+ # Rake-style chained dispatch: `hammer build + deploy + notify`.
262
+ # A bare `+` argv token separates commands; `++` escapes to a literal
263
+ # `+` positional. Quoted shell args (`--foo="a + b"`) arrive as a
264
+ # single token and are not split.
260
265
  def start(argv = ARGV)
261
266
  # Track prereqs fired during this top-level invocation so a `needs`
262
267
  # chain runs each prereq at most once. Nested `start` calls (e.g.
263
- # `needs` -> `hammer_*` -> `start`) share the set; the outermost
264
- # call owns its lifetime.
268
+ # `needs` -> `hammer` -> `start`, or a `+` chain) share the set;
269
+ # the outermost call owns its lifetime.
265
270
  outer = Thread.current[:hammer_needs_ran].nil?
266
271
  Thread.current[:hammer_needs_ran] ||= {}
267
272
  Thread.current[:hammer_before_ran] ||= {}
268
273
 
274
+ split_chain(argv).each { |seg| dispatch(seg) }
275
+ ensure
276
+ Thread.current[:hammer_needs_ran] = nil if outer
277
+ Thread.current[:hammer_before_ran] = nil if outer
278
+ end
279
+
280
+ private
281
+
282
+ # Split argv on bare `+` tokens; unescape `++` -> `+` within each
283
+ # segment. Returns [argv] when no chain operator is present.
284
+ def split_chain(argv)
285
+ return [argv] unless argv.include?('+') || argv.include?('++')
286
+ segs = argv.slice_when { |a, b| a == '+' || b == '+' }
287
+ .map { |seg| seg.reject { |t| t == '+' } }
288
+ .map { |seg| seg.map { |t| t == '++' ? '+' : t } }
289
+ .reject(&:empty?)
290
+ segs.empty? ? [argv] : segs
291
+ end
292
+
293
+ # Run a single (already-split) command segment.
294
+ def dispatch(argv)
269
295
  argv = argv.dup
270
296
  name = argv.shift
271
297
 
@@ -274,6 +300,19 @@ class Hammer
274
300
  return print_help(target)
275
301
  end
276
302
 
303
+ # Trailing colon ("db:") -> expanded namespace listing with full
304
+ # per-command help on every task. Bare ":" expands the root.
305
+ if name.end_with?(':') && name != ':'
306
+ bare = name.chomp(':')
307
+ ns = resolve_namespace(bare)
308
+ return print_namespace_help(bare, ns, full: true) if ns
309
+ Shell.print_error("unknown namespace: #{bare}")
310
+ print_help
311
+ exit 1
312
+ elsif name == ':'
313
+ return print_help(nil, full: true)
314
+ end
315
+
277
316
  cmd, owner = resolve(name)
278
317
  return owner.run_command(cmd, argv, full: name) if cmd
279
318
 
@@ -283,11 +322,10 @@ class Hammer
283
322
  Shell.print_error("unknown command: #{name}")
284
323
  print_help
285
324
  exit 1
286
- ensure
287
- Thread.current[:hammer_needs_ran] = nil if outer
288
- Thread.current[:hammer_before_ran] = nil if outer
289
325
  end
290
326
 
327
+ public
328
+
291
329
  # Find a command by canonical name or alt within this class.
292
330
  def find_command(name)
293
331
  commands[name.to_s] || commands.values.find { |c| c.matches?(name) }
@@ -313,23 +351,22 @@ class Hammer
313
351
  klass
314
352
  end
315
353
 
316
- # MyCli.hammer_db_users_list("a", verbose: true) ->
317
- # MyCli.start(["db:users:list", "a", "--verbose"])
354
+ # Programmatic dispatch by name. Useful for scripting and tests.
318
355
  #
319
- # Useful for scripting and tests. Underscores in the method name map
320
- # to colons; underscores in kwarg keys map to dashes in the flag.
321
- def method_missing(name, *args, **kwargs, &block)
322
- str = name.to_s
323
- return super unless str.start_with?('hammer_')
324
-
325
- path = str.sub(/^hammer_/, '').tr('_', ':')
326
- argv = [path, *args.map(&:to_s)]
327
- # kwarg key mirrors the CLI flag literally:
328
- # verbose: true -> --verbose
329
- # no_cache: true -> --no-cache (just the general rule)
330
- # env: 'prod' -> --env=prod
331
- # anything: false -> skipped (no-op; use `no_x: true` to negate)
332
- kwargs.each do |k, v|
356
+ # MyCli.hammer :build -> start(["build"])
357
+ # MyCli.hammer 'db:users:list' -> start(["db:users:list"])
358
+ # MyCli.hammer :eval, 'puts 42' -> start(["eval", "puts 42"])
359
+ # MyCli.hammer :build, env: 'prod' -> start(["build", "--env=prod"])
360
+ # MyCli.hammer :build, verbose: true -> start(["build", "--verbose"])
361
+ # MyCli.hammer :build, no_cache: true -> start(["build", "--no-cache"])
362
+ # MyCli.hammer :build, cache: false -> skipped (no-op)
363
+ #
364
+ # Symbols are single-segment names; pass a string with colons for
365
+ # namespaced paths. Trailing positionals become positional ARGV.
366
+ # Underscores in option keys become dashes in flags.
367
+ def hammer(name, *args, **opts)
368
+ argv = [name.to_s, *args.map(&:to_s)]
369
+ opts.each do |k, v|
333
370
  next if v == false
334
371
  flag = "--#{k.to_s.tr('_', '-')}"
335
372
  argv << (v == true ? flag : "#{flag}=#{v}")
@@ -337,10 +374,6 @@ class Hammer
337
374
  start(argv)
338
375
  end
339
376
 
340
- def respond_to_missing?(name, include_private = false)
341
- name.to_s.start_with?('hammer_') || super
342
- end
343
-
344
377
  # Yield [full_colon_path, Command] for every command in this class
345
378
  # and all nested namespaces.
346
379
  def each_command(prefix = nil, &block)
@@ -409,29 +442,52 @@ class Hammer
409
442
  scan.include?('-h') || scan.include?('--help')
410
443
  end
411
444
 
412
- def print_help(target = nil)
445
+ def print_help(target = nil, full: false)
413
446
  if target
447
+ # `help ns:` is equivalent to `ns:` - expanded namespace listing.
448
+ if target.end_with?(':') && target != ':'
449
+ bare = target.chomp(':')
450
+ ns = resolve_namespace(bare)
451
+ return print_namespace_help(bare, ns, full: true) if ns
452
+ Shell.print_error("unknown: #{target}")
453
+ return
454
+ end
414
455
  cmd, _ = resolve(target)
415
456
  return print_command_help(cmd, target) if cmd
416
457
  ns = resolve_namespace(target)
417
- return print_namespace_help(target, ns) if ns
458
+ return print_namespace_help(target, ns, full: full) if ns
418
459
  Shell.print_error("unknown: #{target}")
419
460
  return
420
461
  end
421
462
 
422
463
  Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan
423
- Shell.say ''
424
- print_command_list(self)
464
+ if full
465
+ each_command { |path, c| print_full_block(path, c) unless c.desc.empty? }
466
+ else
467
+ Shell.say ''
468
+ print_command_list(self)
469
+ end
425
470
  print_footer
426
471
  end
427
472
 
428
- def print_namespace_help(prefix, ns)
473
+ def print_namespace_help(prefix, ns, full: false)
429
474
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
430
- Shell.say ''
431
- print_command_list(ns, prefix)
475
+ if full
476
+ ns.each_command(prefix) { |path, c| print_full_block(path, c) unless c.desc.empty? }
477
+ else
478
+ Shell.say ''
479
+ print_command_list(ns, prefix)
480
+ end
432
481
  print_footer
433
482
  end
434
483
 
484
+ # One "task block" for the expanded listing: blank line separator
485
+ # then the standard per-command help (usage + desc + options + examples).
486
+ def print_full_block(path, cmd)
487
+ Shell.say ''
488
+ print_command_help(cmd, path)
489
+ end
490
+
435
491
  HOMEPAGE ||= 'https://github.com/dux/hammer'.freeze
436
492
 
437
493
  def print_footer
@@ -442,7 +498,7 @@ class Hammer
442
498
  def print_command_list(klass, prefix = nil)
443
499
  rows = []
444
500
  # Commands without a `desc` are hidden from listings but still
445
- # dispatchable + `hammer_*`-callable - useful for private helpers
501
+ # dispatchable + `hammer`-callable - useful for private helpers
446
502
  # invoked from `before` hooks or other commands (e.g. `:env`, `:app`).
447
503
  klass.each_command(prefix) { |full, c| rows << [full, c] unless c.desc.empty? }
448
504
  return if rows.empty?
@@ -528,28 +584,23 @@ class Hammer
528
584
 
529
585
  # Inside a command's `proc do |opts| ... end`, call sibling commands:
530
586
  #
531
- # define :deploy do
587
+ # task :deploy do
532
588
  # proc do |opts|
533
- # hammer_build
534
- # hammer_db_migrate(pretend: true)
589
+ # hammer :build
590
+ # hammer 'db:migrate', pretend: true
535
591
  # end
536
592
  # end
537
- def method_missing(name, *args, **kwargs, &block)
538
- return super unless name.to_s.start_with?('hammer_')
539
- # Dispatch from the root class so `hammer_a_b` resolves against the
540
- # full colon path "a:b" even when called inside a namespaced command
541
- # (where self.class would be the namespace subclass).
542
- self.class.root.send(name, *args, **kwargs, &block)
543
- end
544
-
545
- def respond_to_missing?(name, include_private = false)
546
- name.to_s.start_with?('hammer_') || super
593
+ #
594
+ # Dispatches from the root class so colon paths resolve against the
595
+ # full tree even when called from inside a namespaced command.
596
+ def hammer(name, *args, **opts)
597
+ self.class.root.hammer(name, *args, **opts)
547
598
  end
548
599
 
549
600
  # ----- block DSL -----------------------------------------------------
550
601
 
551
602
  # Define and run a CLI inline. Inside the block use
552
- # `define :name do ... end`, `namespace`, and `load`.
603
+ # `task :name do ... end`, `namespace`, and `load`.
553
604
  #
554
605
  # Without a block: load ./Hammerfile if it exists, otherwise
555
606
  # auto-discover *_hammer.rb under Dir.pwd, then dispatch ARGV.
@@ -575,11 +626,27 @@ class Hammer
575
626
  path = find_hammerfile(Dir.pwd)
576
627
  unless path
577
628
  Shell.print_error "no Hammerfile found in #{Dir.pwd} or any parent directory"
629
+
630
+ # Heuristic: *.rb files referencing `Hammer.` are likely inline CLIs
631
+ # the user could promote into a Hammerfile.
632
+ excludes = %w[.git node_modules tmp vendor coverage dist build]
633
+ .map { |d| "--exclude-dir=#{d}" }.join(' ')
634
+ candidates = `grep -rl --include='*.rb' #{excludes} 'Hammer\\.' . 2>/dev/null`
635
+ .lines.map(&:strip).reject(&:empty?)
636
+ unless candidates.empty?
637
+ Shell.say "possible CLI implementation(s) - files referencing `Hammer.`:", :yellow
638
+ candidates.first(10).each { |f| Shell.say " #{f.sub(%r{\A\./}, '')}" }
639
+ Shell.say ''
640
+ end
641
+
578
642
  Shell.say "create one - example:"
643
+ puts
579
644
  Shell.say <<~RUBY
580
- define :hello do
645
+ task :hello do
581
646
  desc 'say hello'
582
- proc { |opts| say "hello \#{opts[:args].first || 'world'}", :green }
647
+ proc do |opts|
648
+ say.green "hello \#{opts[:args].first || 'world'}"
649
+ end
583
650
  end
584
651
  RUBY
585
652
  exit 1
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lux-hammer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.2
4
+ version: 0.2.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dino Reic
@@ -61,7 +61,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
61
  - !ruby/object:Gem::Version
62
62
  version: '0'
63
63
  requirements: []
64
- rubygems_version: 4.0.8
64
+ rubygems_version: 4.0.10
65
65
  specification_version: 4
66
66
  summary: Thor-inspired tiny CLI builder
67
67
  test_files: []