lux-hammer 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dc61ff9ad69572e54aff2624ada68e0acdfc98da3cf3751d36034b93c2bb0bcd
4
- data.tar.gz: 8fe3cc67068249499ac5600cf2842c5532d7664c28490f2469c4c2ef3306672b
3
+ metadata.gz: 17ad95331316a0cd95554605d58b2a7dce64f9db9d6adc997b34cf5f3b1e2b1a
4
+ data.tar.gz: 633ad47b777e68b848f11552e5345d5b468e01a0fa27a273dc6551aac7d18af1
5
5
  SHA512:
6
- metadata.gz: 7cf2c00b2b87205b80a3f662dcbdfc0a46531ba5aabe4d1402910740111e75476f9c9b88431db5b18c1d9e62c05e6a7a540faada36e82453dacd975c4f00f03c
7
- data.tar.gz: 517f66e40cda59a344a14653d3c9b14e16dc87d51750d28c7c83e4be67db9f344e047ea928e2d13bfe1b8f7e83ab7283ef5c26982567be4488cf45e9d89ea792
6
+ metadata.gz: '0568fccc6f143b0671166a08c1853a3f682bf2c4c5488791fa7d46e1c4d8b3cc311ae29a016e9cf9a93f2cc31a577a551af72f0f4754cc9e9bd3adb66c5f6731'
7
+ data.tar.gz: bf5e04d1e37e50f138c7c14079b889018f85bda195dad8676df4f634a37fe62414a1511a451aacb08f1e0860651426d8a7bca78cf3bad35b1fbf3dc137423438
data/.version CHANGED
@@ -1 +1 @@
1
- 0.1.1
1
+ 0.2.1
data/README.md CHANGED
@@ -27,7 +27,7 @@ Create a `Hammerfile` in your project root:
27
27
  define :hello do
28
28
  desc 'say hi'
29
29
  proc do |opts|
30
- say "hello #{opts[:args].first || 'world'}", :green
30
+ say.green "hello #{opts[:args].first || 'world'}"
31
31
  end
32
32
  end
33
33
  ```
@@ -143,7 +143,7 @@ define :build do
143
143
  opt :env, default: 'dev'
144
144
 
145
145
  proc do |opts|
146
- say "building #{opts[:env]}", :green
146
+ say.green "building #{opts[:env]}"
147
147
  end
148
148
  end
149
149
  ```
@@ -154,13 +154,11 @@ For when you'd rather write a Ruby method:
154
154
 
155
155
  ```ruby
156
156
  class MyCli < Hammer
157
- program_name 'mycli'
158
-
159
157
  desc 'Build the project'
160
158
  opt :verbose, type: :boolean, alias: :v
161
159
  opt :env, default: 'dev'
162
160
  def build(opts)
163
- say "building #{opts[:env]}", :green
161
+ say.green "building #{opts[:env]}"
164
162
  end
165
163
 
166
164
  desc 'Ping with no opts'
@@ -178,22 +176,6 @@ without opts; methods that take an argument receive the opts hash.
178
176
 
179
177
  Both styles can coexist in the same class.
180
178
 
181
- ## Program name
182
-
183
- `program_name 'foo'` (alias `program 'foo'`) sets the name shown in
184
- help/usage output. It is **optional**. When omitted, the default is:
185
-
186
- * the invocation path **relative to cwd** if the script lives inside
187
- the current working directory (e.g. `bin/foo` when invoked from the
188
- project root as `./bin/foo` or `bin/foo`), or
189
- * the **basename** of `$PROGRAM_NAME` otherwise (e.g. `lux` for a
190
- globally installed bin in `PATH`).
191
-
192
- So a project-local wrapper at `bin/foo` shows up in help as
193
- `Usage: bin/foo COMMAND [ARGS]`, while the same library invoked
194
- through a globally installed `lux` shim shows `Usage: lux ...`. Set
195
- `program 'whatever'` explicitly to override.
196
-
197
179
  ## Options (`opt`)
198
180
 
199
181
  ### Declaration
@@ -434,6 +416,84 @@ hammer db:migrate -h # per-command help
434
416
  Namespaces nest to any depth. There is no per-level dispatch - the root
435
417
  parses the whole colon path and walks the namespace tree.
436
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
+
437
497
  ## Command aliases (`alt`)
438
498
 
439
499
  `alt :short_name` (or several) registers extra names for a command:
@@ -459,7 +519,7 @@ define :deploy do
459
519
  proc do |opts|
460
520
  hammer_build(env: 'prod', verbose: true)
461
521
  hammer_db_migrate
462
- say 'deployed', :green
522
+ say.green 'deployed'
463
523
  end
464
524
  end
465
525
  ```
@@ -481,19 +541,111 @@ class level, useful for tests and scripting.
481
541
 
482
542
  ## Shell helpers
483
543
 
484
- Available inside any handler:
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
485
608
 
486
609
  ```ruby
487
- say 'ok', :green
488
- say 'big', :yellow, bold: true
489
- error 'something broke' # prints + exits 1 (raises Hammer::Error)
490
- name = ask 'name', default: 'world'
491
- exit 0 unless yes? 'continue?'
492
- sh 'bundle install' # echoes "$ bundle install", aborts on non-zero
610
+ exit 0 unless yes? 'continue?' # anything starting with y/Y -> true
611
+ # blank, n, anything else -> false
493
612
  ```
494
613
 
495
- Colors are auto-disabled when stdout isn't a TTY, when `NO_COLOR` is
496
- set, or programmatically via `Hammer::Shell.color!(false)`.
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.
497
649
 
498
650
  ## Splitting across files (`load`)
499
651
 
@@ -502,7 +654,6 @@ in any file ending in `_hammer.rb` and pull them in with `load`:
502
654
 
503
655
  ```ruby
504
656
  # Hammerfile
505
- program 'demo'
506
657
  load auto: true # recursive scan for *_hammer.rb from here
507
658
  ```
508
659
 
@@ -512,7 +663,7 @@ namespace :db do
512
663
  define :migrate do
513
664
  desc 'Run pending migrations'
514
665
  opt :pretend, type: :boolean, alias: :p
515
- proc { |o| say "migrating pretend=#{o[:pretend].inspect}", :green }
666
+ proc { |o| say.green "migrating pretend=#{o[:pretend].inspect}" }
516
667
  end
517
668
  end
518
669
  ```
@@ -523,7 +674,7 @@ define :deploy do
523
674
  desc 'Deploy to prod'
524
675
  proc do |_|
525
676
  hammer_db_migrate # cross-file invocation just works
526
- say 'deployed', :cyan
677
+ say.cyan 'deployed'
527
678
  end
528
679
  end
529
680
  ```
@@ -536,12 +687,18 @@ load auto: true # recursive scan for *_hammer.rb under caller dir
536
687
  load 'tasks/db_hammer.rb' # one file (path relative to caller)
537
688
  load 'tasks/*_hammer.rb' # glob
538
689
  load 'a.rb', 'b.rb' # several explicit paths
690
+ load 'tasks' # directory -> recursive scan under it (empty OK)
539
691
  ```
540
692
 
541
693
  Paths resolve relative to the file calling `load`, not cwd. Inside a
542
694
  `Hammerfile` that means "relative to the Hammerfile"; inside a class
543
695
  body it means "relative to that file".
544
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
+
545
702
  ### What's skipped
546
703
 
547
704
  Auto-discovery walks recursively but skips `.git`, `.bundle`,
@@ -575,26 +732,49 @@ Same shape as a Hammerfile, just inline:
575
732
  require 'lux-hammer'
576
733
 
577
734
  Hammer.run(ARGV) do
578
- program 'inline'
579
-
580
735
  define :hello do
581
736
  desc 'say hi'
582
737
  opt :loud, type: :boolean, alias: :l
583
738
  proc do |opts|
584
739
  msg = "hello #{opts[:args].first || 'world'}"
585
740
  msg = msg.upcase if opts[:loud]
586
- say msg, :cyan
741
+ say.cyan msg
587
742
  end
588
743
  end
589
744
  end
590
745
  ```
591
746
 
592
- ## Complete example (every feature)
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:
593
758
 
594
759
  ```ruby
595
- # Hammerfile
596
- program 'demo'
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)
597
776
 
777
+ ```ruby
598
778
  # Simple top-level command
599
779
  define :build do
600
780
  desc 'Build the project'
@@ -605,7 +785,7 @@ define :build do
605
785
 
606
786
  proc do |opts|
607
787
  target = opts[:args].first || opts[:env]
608
- say "building #{target}", :green, bold: true
788
+ say.green "building #{target}"
609
789
  say ' verbose on' if opts[:verbose]
610
790
  end
611
791
  end
@@ -620,7 +800,7 @@ define :deploy do
620
800
  proc do |opts|
621
801
  hammer_build(env: 'prod')
622
802
  exit 0 unless yes? "deploy to #{opts[:url]}?" unless opts[:force]
623
- say "deploying to #{opts[:url]}", :yellow
803
+ say.yellow "deploying to #{opts[:url]}"
624
804
  end
625
805
  end
626
806
 
@@ -634,7 +814,7 @@ namespace :db do
634
814
 
635
815
  proc do |opts|
636
816
  step = opts[:args].first || 'all'
637
- say "migrating #{step} pretend=#{opts[:pretend].inspect}", :green
817
+ say.green "migrating #{step} pretend=#{opts[:pretend].inspect}"
638
818
  end
639
819
  end
640
820
 
@@ -645,7 +825,7 @@ namespace :db do
645
825
  opt :limit, type: :integer, default: 100
646
826
 
647
827
  proc do |opts|
648
- say "users role=#{opts[:role]} limit=#{opts[:limit]}", :cyan
828
+ say.cyan "users role=#{opts[:role]} limit=#{opts[:limit]}"
649
829
  end
650
830
  end
651
831
 
@@ -719,8 +899,6 @@ directly. Useful for embedding or testing:
719
899
  require 'lux-hammer'
720
900
 
721
901
  class MyCli < Hammer
722
- program_name 'mycli'
723
-
724
902
  define :greet do
725
903
  opt :loud, type: :boolean
726
904
  proc do |opts|
@@ -19,6 +19,12 @@ class Hammer
19
19
  @klass.namespace(name, &block)
20
20
  end
21
21
 
22
+ # Per-target / per-namespace pre-hook. Same semantics as
23
+ # `Hammer.before` at the class level - see lux-hammer.rb.
24
+ def before(&block)
25
+ @klass.before(&block)
26
+ end
27
+
22
28
  # Same surface as `Hammer.load`. Resolved relative to the file that
23
29
  # called us, so `load auto: true` inside a Hammerfile picks up
24
30
  # *_hammer.rb under the Hammerfile's directory.
@@ -1,7 +1,7 @@
1
1
  class Hammer
2
2
  # A single registered command on a Hammer class.
3
3
  class Command
4
- attr_reader :name, :desc, :options, :examples, :alts
4
+ attr_reader :name, :desc, :options, :examples, :alts, :needs
5
5
  attr_accessor :handler
6
6
 
7
7
  def initialize(name:, desc: '', handler: nil)
@@ -11,6 +11,7 @@ class Hammer
11
11
  @options = []
12
12
  @examples = []
13
13
  @alts = []
14
+ @needs = []
14
15
  end
15
16
 
16
17
  # First line of `desc`, used in the flat command listing.
@@ -30,6 +31,10 @@ class Hammer
30
31
  @alts << name.to_s
31
32
  end
32
33
 
34
+ def add_need(name)
35
+ @needs << name.to_s
36
+ end
37
+
33
38
  def matches?(name)
34
39
  name = name.to_s
35
40
  name == @name || @alts.include?(name)
@@ -22,5 +22,9 @@ class Hammer
22
22
  def alt(*names)
23
23
  names.each { |n| @cmd.add_alt(n) }
24
24
  end
25
+
26
+ def needs(*names)
27
+ names.each { |n| @cmd.add_need(n) }
28
+ end
25
29
  end
26
30
  end
data/lib/hammer/loader.rb CHANGED
@@ -38,16 +38,16 @@ class Hammer
38
38
 
39
39
  def resolve_pattern(anchor, pattern)
40
40
  abs = File.expand_path(pattern, anchor)
41
- matches =
42
- if abs.match?(/[\*\?\[\{]/)
43
- Dir.glob(abs)
44
- elsif File.file?(abs)
45
- [abs]
46
- else
47
- []
48
- end
49
- raise Hammer::Error, "load: no files matched #{pattern.inspect}" if matches.empty?
50
- matches.sort
41
+ if abs.match?(/[\*\?\[\{]/)
42
+ matches = Dir.glob(abs).sort
43
+ raise Hammer::Error, "load: no files matched #{pattern.inspect}" if matches.empty?
44
+ return matches
45
+ end
46
+ # Directory: discover *_hammer.rb under it. Empty result is OK
47
+ # (an app may legitimately have no fragments).
48
+ return discover(abs) if File.directory?(abs)
49
+ return [abs] if File.file?(abs)
50
+ raise Hammer::Error, "load: no files matched #{pattern.inspect}"
51
51
  end
52
52
 
53
53
  def discover(dir)
data/lib/hammer/shell.rb CHANGED
@@ -1,3 +1,5 @@
1
+ require 'io/console'
2
+
1
3
  class Hammer
2
4
  # ANSI color/output helpers. Mixed into command instances; also callable
3
5
  # directly as `Hammer::Shell.say(...)`.
@@ -19,15 +21,33 @@ class Hammer
19
21
  @color = value
20
22
  end
21
23
 
22
- def paint(text, color = nil, bold: false)
23
- return text.to_s unless color? && (color || bold)
24
- code = COLORS[color] || 0
25
- prefix = bold ? "\e[1;#{code}m" : "\e[#{code}m"
26
- "#{prefix}#{text}\e[0m"
24
+ def paint(text, color = nil)
25
+ if color && !COLORS.key?(color)
26
+ raise Hammer::Error, "unknown color #{color.inspect} (valid: #{COLORS.keys.join(', ')})"
27
+ end
28
+ return text.to_s unless color? && color
29
+ "\e[#{COLORS[color]}m#{text}\e[0m"
30
+ end
31
+
32
+ # `say` with no args returns a proxy so you can write `say.cyan 'hi'`.
33
+ # `say('')` still prints a blank line; `say('x', :cyan)` is unchanged.
34
+ def say(text = nil, color = nil)
35
+ return SayProxy.new if text.nil?
36
+ puts paint(text, color)
27
37
  end
28
38
 
29
- def say(text = '', color = nil, bold: false)
30
- puts paint(text, color, bold: bold)
39
+ class SayProxy
40
+ COLORS.each_key do |name|
41
+ define_method(name) { |text = ''| Shell.say(text, name) }
42
+ end
43
+
44
+ def method_missing(name, *)
45
+ raise Hammer::Error, "unknown color :#{name} (valid: #{COLORS.keys.join(', ')})"
46
+ end
47
+
48
+ def respond_to_missing?(_name, _include_private = false)
49
+ false
50
+ end
31
51
  end
32
52
 
33
53
  # Raise a controlled Hammer::Error. If unhandled, the dispatcher
@@ -41,7 +61,7 @@ class Hammer
41
61
  # Print a red [error] line to stderr (does not exit). Used internally
42
62
  # by the dispatcher to render Hammer::Error messages.
43
63
  def print_error(text)
44
- warn paint("[error] #{text}", :red, bold: true)
64
+ warn paint("[error] #{text}", :red)
45
65
  end
46
66
 
47
67
  def ask(prompt, default: nil)
@@ -59,6 +79,75 @@ class Hammer
59
79
  answer.to_s.strip.downcase.start_with?('y')
60
80
  end
61
81
 
82
+ # Arrow-key picker. Returns the chosen index, or nil on cancel
83
+ # (ESC, Ctrl-C, q). Non-TTY input falls back to a numbered prompt
84
+ # so this stays scriptable.
85
+ #
86
+ # idx = choose 'Pick env', %w[dev staging prod]
87
+ # say.green "chose #{ %w[dev staging prod][idx] }" if idx
88
+ def choose(prompt, items)
89
+ items = items.to_a
90
+ error 'choose needs at least one item' if items.empty?
91
+
92
+ say.cyan prompt
93
+
94
+ return choose_numbered(items) unless $stdin.tty? && $stdin.respond_to?(:raw)
95
+
96
+ selected = 0
97
+ redraw = lambda do |highlight = :cyan|
98
+ items.each_with_index do |item, i|
99
+ puts(i == selected ? paint("> #{item}", highlight) : " #{item}")
100
+ end
101
+ end
102
+ redraw.call
103
+
104
+ $stdout.print "\e[?25l" # hide cursor
105
+ begin
106
+ $stdin.raw do |io|
107
+ loop do
108
+ ch = io.getch
109
+ case ch
110
+ when "\r", "\n"
111
+ # Collapse the list to the chosen line, in green.
112
+ $stdout.print "\e[#{items.size}A\e[J"
113
+ puts paint("> #{items[selected]}", :green)
114
+ return selected
115
+ when "\x03", 'q' # Ctrl-C, q
116
+ $stdout.print "\e[#{items.size}A\e[J"
117
+ return nil
118
+ when "\e"
119
+ # ESC may stand alone or start an arrow sequence \e[A / \e[B.
120
+ if IO.select([io], nil, nil, 0.01) && io.getch == '['
121
+ case io.getch
122
+ when 'A' then selected = (selected - 1) % items.size
123
+ when 'B' then selected = (selected + 1) % items.size
124
+ end
125
+ else
126
+ $stdout.print "\e[#{items.size}A\e[J"
127
+ return nil
128
+ end
129
+ when 'k' then selected = (selected - 1) % items.size
130
+ when 'j' then selected = (selected + 1) % items.size
131
+ end
132
+ $stdout.print "\e[#{items.size}A\e[J"
133
+ redraw.call
134
+ end
135
+ end
136
+ ensure
137
+ $stdout.print "\e[?25h" # show cursor
138
+ end
139
+ end
140
+
141
+ # Fallback for non-TTY stdin (pipes, tests). Returns the index or nil.
142
+ def choose_numbered(items)
143
+ items.each_with_index { |item, i| puts " #{i + 1}) #{item}" }
144
+ print paint("select [1-#{items.size}]: ", :cyan)
145
+ line = $stdin.gets
146
+ return nil if line.nil?
147
+ idx = line.strip.to_i - 1
148
+ idx.between?(0, items.size - 1) ? idx : nil
149
+ end
150
+
62
151
  # Run a shell command. Echoes the command in gray, raises
63
152
  # Hammer::Error on non-zero exit. Returns true on success.
64
153
  def sh(cmd)
@@ -68,3 +157,10 @@ class Hammer
68
157
  end
69
158
  end
70
159
  end
160
+
161
+ # `"hi".color(:cyan)` -> ANSI-painted string. Raises on unknown color.
162
+ class String
163
+ def color(name)
164
+ Hammer::Shell.paint(self, name)
165
+ end
166
+ end
data/lib/lux-hammer.rb CHANGED
@@ -50,11 +50,14 @@ class Hammer
50
50
  super
51
51
  sub.instance_variable_set(:@commands, {})
52
52
  sub.instance_variable_set(:@namespaces, {})
53
+ sub.instance_variable_set(:@before_hooks, [])
54
+ sub.instance_variable_set(:@parent, nil)
53
55
  sub.instance_variable_set(:@program_name, nil)
54
56
  sub.instance_variable_set(:@pending_desc, nil)
55
57
  sub.instance_variable_set(:@pending_examples, [])
56
58
  sub.instance_variable_set(:@pending_options, [])
57
59
  sub.instance_variable_set(:@pending_alts, [])
60
+ sub.instance_variable_set(:@pending_needs, [])
58
61
  end
59
62
 
60
63
  # ----- class-level DSL for `def`-style commands ---------------------
@@ -72,6 +75,7 @@ class Hammer
72
75
  def example(text) ; @pending_examples << text end
73
76
  def opt(name, **o) ; @pending_options << Option.new(name, **o) end
74
77
  def alt(*names) ; @pending_alts.concat(names) end
78
+ def needs(*names) ; @pending_needs.concat(names) end
75
79
 
76
80
  def method_added(method_name)
77
81
  super
@@ -81,6 +85,7 @@ class Hammer
81
85
  @pending_examples.each { |e| cmd.add_example(e) }
82
86
  @pending_options.each { |o| cmd.add_option(o) }
83
87
  @pending_alts.each { |n| cmd.add_alt(n) }
88
+ @pending_needs.each { |n| cmd.add_need(n) }
84
89
 
85
90
  # If the method takes no args, call it without opts. Otherwise pass
86
91
  # opts. So both `def build` and `def build(opts)` work.
@@ -93,6 +98,7 @@ class Hammer
93
98
  @pending_examples = []
94
99
  @pending_options = []
95
100
  @pending_alts = []
101
+ @pending_needs = []
96
102
  end
97
103
 
98
104
  def program_name(name = nil)
@@ -149,6 +155,7 @@ class Hammer
149
155
  @pending_examples = []
150
156
  @pending_options = []
151
157
  @pending_alts = []
158
+ @pending_needs = []
152
159
  end
153
160
 
154
161
  # Open a namespace (group of commands). Everything inside the block
@@ -165,6 +172,9 @@ class Hammer
165
172
  # (`hammer_<colon_path>`) from inside a namespaced command dispatches
166
173
  # against the full tree, not just the current namespace.
167
174
  sub.instance_variable_set(:@root, root)
175
+ # Parent link, so `before` hooks defined further up the namespace
176
+ # tree can be collected and run outer -> inner before a command.
177
+ sub.instance_variable_set(:@parent, self)
168
178
  # Inherit program_name so help banners show "myapp ns:cmd", not
169
179
  # whichever binary the namespace class fell back to.
170
180
  sub.program_name(program_name) if @program_name
@@ -172,6 +182,38 @@ class Hammer
172
182
  @namespaces[name.to_s] = sub
173
183
  end
174
184
 
185
+ # Register a hook to run before every command in this class (root or
186
+ # namespace). Hooks receive the command's `opts` hash. All hooks run
187
+ # outer -> inner, once per top-level `start` (prereqs don't re-trigger).
188
+ #
189
+ # before { |opts| Dotenv.load }
190
+ # namespace :db do
191
+ # before { hammer_env }
192
+ # define :migrate do ... end
193
+ # end
194
+ def before(&block)
195
+ before_hooks << block
196
+ end
197
+
198
+ def before_hooks
199
+ @before_hooks
200
+ end
201
+
202
+ def parent
203
+ @parent
204
+ end
205
+
206
+ # Root -> ... -> self. Used to gather `before` hooks for a command.
207
+ def ancestor_chain
208
+ chain = []
209
+ klass = self
210
+ while klass
211
+ chain.unshift klass
212
+ klass = klass.parent
213
+ end
214
+ chain
215
+ end
216
+
175
217
  # Topmost class in this CLI tree. For user-defined `class MyCli < Hammer`
176
218
  # or `Class.new(Hammer)` it's self; for namespace subclasses it's
177
219
  # whichever class opened the namespace.
@@ -216,6 +258,14 @@ class Hammer
216
258
  # Command names are Rake-style colon paths: "build", "db:migrate",
217
259
  # "db:users:list".
218
260
  def start(argv = ARGV)
261
+ # Track prereqs fired during this top-level invocation so a `needs`
262
+ # 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.
265
+ outer = Thread.current[:hammer_needs_ran].nil?
266
+ Thread.current[:hammer_needs_ran] ||= {}
267
+ Thread.current[:hammer_before_ran] ||= {}
268
+
219
269
  argv = argv.dup
220
270
  name = argv.shift
221
271
 
@@ -233,6 +283,9 @@ class Hammer
233
283
  Shell.print_error("unknown command: #{name}")
234
284
  print_help
235
285
  exit 1
286
+ ensure
287
+ Thread.current[:hammer_needs_ran] = nil if outer
288
+ Thread.current[:hammer_before_ran] = nil if outer
236
289
  end
237
290
 
238
291
  # Find a command by canonical name or alt within this class.
@@ -309,6 +362,8 @@ class Hammer
309
362
  positional, opts = Parser.new(cmd.options).parse(argv)
310
363
  opts[:args] = positional
311
364
  instance = new
365
+ run_before_hooks(instance, opts)
366
+ run_needs(cmd)
312
367
  instance.instance_exec(opts, &cmd.handler)
313
368
  rescue Parser::Error => e
314
369
  Shell.print_error(e.message)
@@ -321,6 +376,33 @@ class Hammer
321
376
  exit 1
322
377
  end
323
378
 
379
+ # Fire `before` hooks from root down through the namespace chain.
380
+ # Each class's hooks fire at most once per top-level `start`, so
381
+ # prereqs dispatched via `needs` won't re-trigger them.
382
+ def run_before_hooks(instance, opts)
383
+ ran = Thread.current[:hammer_before_ran] ||= {}
384
+ ancestor_chain.each do |klass|
385
+ next if ran[klass.object_id]
386
+ ran[klass.object_id] = true
387
+ klass.before_hooks.each { |hook| instance.instance_exec(opts, &hook) }
388
+ end
389
+ end
390
+
391
+ # Dispatch a command's declared `needs` through the root class, with
392
+ # per-invocation dedupe. Prereqs run with default options (no argv).
393
+ def run_needs(cmd)
394
+ return if cmd.needs.empty?
395
+ ran = Thread.current[:hammer_needs_ran] ||= {}
396
+ cmd.needs.each do |path|
397
+ key = path.to_s
398
+ next if ran[key]
399
+ ran[key] = true
400
+ target, = root.resolve(key)
401
+ raise Error, "needs: unknown command '#{key}' in #{cmd.name}" unless target
402
+ root.start([key])
403
+ end
404
+ end
405
+
324
406
  def help_requested?(argv)
325
407
  stop = argv.index('--')
326
408
  scan = stop ? argv[0...stop] : argv
@@ -337,15 +419,15 @@ class Hammer
337
419
  return
338
420
  end
339
421
 
340
- Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan, bold: true
341
- Shell.say
422
+ Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan
423
+ Shell.say ''
342
424
  print_command_list(self)
343
425
  print_footer
344
426
  end
345
427
 
346
428
  def print_namespace_help(prefix, ns)
347
- Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan, bold: true
348
- Shell.say
429
+ Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
430
+ Shell.say ''
349
431
  print_command_list(ns, prefix)
350
432
  print_footer
351
433
  end
@@ -353,13 +435,16 @@ class Hammer
353
435
  HOMEPAGE ||= 'https://github.com/dux/hammer'.freeze
354
436
 
355
437
  def print_footer
356
- Shell.say
438
+ Shell.say ''
357
439
  Shell.say "powered by hammer - #{HOMEPAGE}", :gray
358
440
  end
359
441
 
360
442
  def print_command_list(klass, prefix = nil)
361
443
  rows = []
362
- klass.each_command(prefix) { |full, c| rows << [full, c] }
444
+ # Commands without a `desc` are hidden from listings but still
445
+ # dispatchable + `hammer_*`-callable - useful for private helpers
446
+ # invoked from `before` hooks or other commands (e.g. `:env`, `:app`).
447
+ klass.each_command(prefix) { |full, c| rows << [full, c] unless c.desc.empty? }
363
448
  return if rows.empty?
364
449
 
365
450
  # group by "section" = everything between the view prefix and the
@@ -422,19 +507,19 @@ class Hammer
422
507
 
423
508
  def print_command_help(cmd, full = nil)
424
509
  full ||= cmd.name
425
- Shell.say "Usage: #{program_name} #{full}#{usage_signature(cmd)}", :cyan, bold: true
510
+ Shell.say "Usage: #{program_name} #{full}#{usage_signature(cmd)}", :cyan
426
511
  cmd.desc.each_line do |line|
427
512
  stripped = line.chomp
428
513
  Shell.say(stripped.empty? ? '' : " #{stripped}")
429
514
  end unless cmd.desc.empty?
430
515
  Shell.say " alias: #{cmd.alts.join(', ')}" unless cmd.alts.empty?
431
516
  unless cmd.options.empty?
432
- Shell.say
517
+ Shell.say ''
433
518
  Shell.say 'Options:', :yellow
434
519
  cmd.options.each { |o| Shell.say " #{o.usage}" }
435
520
  end
436
521
  unless cmd.examples.empty?
437
- Shell.say
522
+ Shell.say ''
438
523
  Shell.say 'Examples:', :yellow
439
524
  cmd.examples.each { |e| Shell.say " #{program_name} #{e}" }
440
525
  end
@@ -465,9 +550,21 @@ class Hammer
465
550
 
466
551
  # Define and run a CLI inline. Inside the block use `program`,
467
552
  # `define :name do ... end`, and `namespace`.
553
+ #
554
+ # Without a block: load ./Hammerfile if it exists, otherwise
555
+ # auto-discover *_hammer.rb under Dir.pwd, then dispatch ARGV.
468
556
  def self.run(argv = ARGV, &block)
469
557
  klass = Class.new(Hammer)
470
- Builder.new(klass).instance_eval(&block)
558
+ if block
559
+ Builder.new(klass).instance_eval(&block)
560
+ else
561
+ hf = File.join(Dir.pwd, 'Hammerfile')
562
+ if File.file?(hf)
563
+ Builder.new(klass).instance_eval(File.read(hf), hf)
564
+ else
565
+ klass.loader.load(Dir.pwd, [], auto: true)
566
+ end
567
+ end
471
568
  klass.start(argv)
472
569
  end
473
570
 
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.1.1
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dino Reic