lux-hammer 0.3.5 → 0.3.7

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/lib/lux-hammer.rb CHANGED
@@ -141,6 +141,7 @@ class Hammer
141
141
  # lives at `opts[:args]`.
142
142
  def task(name, &block)
143
143
  cmd = Command.new(name: name.to_s)
144
+ cmd.location = source_location_of(block)
144
145
  handler = CommandBuilder.new(cmd).instance_eval(&block)
145
146
  unless handler.is_a?(Proc)
146
147
  raise Error, <<~MSG
@@ -161,6 +162,12 @@ class Hammer
161
162
  MSG
162
163
  end
163
164
  cmd.handler = handler
165
+
166
+ if (prev = commands[cmd.name])
167
+ cmd.prev_location = prev.location
168
+ warn_redefinition('task', cmd.name, prev.location, cmd.location)
169
+ end
170
+
164
171
  commands[cmd.name] = cmd
165
172
 
166
173
  # `task` ignores pending class-level state, but clear it so a
@@ -181,12 +188,7 @@ class Hammer
181
188
  # namespace :users do ... end
182
189
  # end
183
190
  #
184
- # `:self` is reserved for the `hammer` binary's built-ins (see
185
- # Hammer::Builtins). User code that tries to open it raises.
186
191
  def namespace(name, &block)
187
- if name.to_s == 'self' && !Thread.current[:hammer_builtins_loading]
188
- raise Error, "namespace 'self' is reserved for hammer's built-in commands"
189
- end
190
192
  sub = Class.new(Hammer)
191
193
  # Track the top-level CLI class so cross-invocation
192
194
  # (`hammer 'ns:cmd'`) from inside a namespaced command dispatches
@@ -199,7 +201,14 @@ class Hammer
199
201
  # "myapp ns:cmd" with the same prefix everywhere - and so the value
200
202
  # captured pre-chdir (see `Hammer.cli`) survives into nested classes.
201
203
  sub.instance_variable_set(:@program_name, program_name)
204
+ sub.instance_variable_set(:@location, source_location_of(block))
202
205
  Hammer.with_target(sub) { sub.class_eval(&block) } if block
206
+
207
+ if (prev = @namespaces[name.to_s])
208
+ sub.instance_variable_set(:@prev_location, prev.instance_variable_get(:@location))
209
+ warn_redefinition('namespace', name.to_s, prev.instance_variable_get(:@location), sub.instance_variable_get(:@location))
210
+ end
211
+
203
212
  @namespaces[name.to_s] = sub
204
213
  end
205
214
 
@@ -236,6 +245,21 @@ class Hammer
236
245
  @parent
237
246
  end
238
247
 
248
+ # "file:line" of the block that defined a task/namespace. Falls back
249
+ # to "(unknown)" for blocks without a usable source_location (rare -
250
+ # built-in C-defined procs, eval'd blocks).
251
+ def source_location_of(block)
252
+ loc = block&.source_location
253
+ loc ? "#{loc[0]}:#{loc[1]}" : '(unknown)'
254
+ end
255
+
256
+ # Emit a yellow [hammer] warning on stderr when a task/namespace is
257
+ # redefined. Last write wins (commands[name] = cmd), but the prior
258
+ # location is captured so listings can tag the entry as `(redefined)`.
259
+ def warn_redefinition(kind, name, prev_loc, new_loc)
260
+ warn Shell.paint("[hammer] redefined #{kind} :#{name} - was #{prev_loc || '(unknown)'}, now #{new_loc || '(unknown)'}", :yellow)
261
+ end
262
+
239
263
  # Root -> ... -> self. Used to gather `before` hooks for a command.
240
264
  def ancestor_chain
241
265
  chain = []
@@ -340,13 +364,18 @@ class Hammer
340
364
  argv = argv.dup
341
365
  name = argv.shift
342
366
 
343
- if name.nil?
344
- return print_help
367
+ # Bare invocation OR leading flag (other than -h/--help) -> :default
368
+ # task. Re-prepend the flag so :default's option parser sees it.
369
+ # If no :default is defined, fall back to top-level help.
370
+ if name.nil? || (name.start_with?('-') && name != '-h' && name != '--help')
371
+ argv.unshift(name) if name
372
+ return dispatch_to_builtin('default', argv) { print_help }
345
373
  end
346
374
 
375
+ # Explicit help requests -> :help task. Remaining positionals (e.g.
376
+ # `help build`, `help db:`) reach :help via opts[:args].
347
377
  if name == 'help' || name == '-h' || name == '--help'
348
- target = argv.shift
349
- return print_help(target, extended: true)
378
+ return dispatch_to_builtin('help', argv) { print_help(argv.first, extended: true) }
350
379
  end
351
380
 
352
381
  # Trailing colon ("db:") -> namespace listing. Bare ":" lists root.
@@ -375,6 +404,16 @@ class Hammer
375
404
  exit 1
376
405
  end
377
406
 
407
+ # Run a built-in routing task (:default or :help) if defined,
408
+ # otherwise yield to the fallback block. The implicit run banner
409
+ # is suppressed (the user typed `hammer --version`, not `hammer
410
+ # default --version` - no point echoing the rewritten form).
411
+ def dispatch_to_builtin(name, argv)
412
+ cmd = commands[name]
413
+ return yield unless cmd
414
+ run_command(cmd, argv, full: name, quiet: true)
415
+ end
416
+
378
417
  public
379
418
 
380
419
  # Find a command by canonical name or alt within this class. Falls
@@ -497,14 +536,14 @@ class Hammer
497
536
  end
498
537
  end
499
538
 
500
- def run_command(cmd, argv, full: nil)
539
+ def run_command(cmd, argv, full: nil, quiet: false)
501
540
  # -h / --help is reserved on every command. Anywhere before a `--`
502
541
  # stop-marker, it short-circuits to per-command help.
503
542
  return print_command_help(cmd, full) if help_requested?(argv)
504
543
 
505
544
  positional, opts = Parser.new(cmd.options).parse(argv)
506
545
  opts[:args] = positional
507
- print_run_banner(cmd, full || cmd.name, positional, opts)
546
+ print_run_banner(cmd, full || cmd.name, positional, opts) unless quiet || ENV['HAMMER_QUIET']
508
547
  instance = new
509
548
  run_before_hooks(instance, opts)
510
549
  run_needs(cmd)
@@ -623,7 +662,7 @@ class Hammer
623
662
  entries.each do |name, file|
624
663
  desc = Hammer::Recipe.desc(file)
625
664
  installed = Hammer::Recipe.installed_path(name)
626
- suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} self:recipe install #{name}]"
665
+ suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} recipes --install #{name}]"
627
666
  Shell.say " #{name.ljust(width)} # #{desc}"
628
667
  Shell.say " #{' ' * width} #{suffix}", :gray
629
668
  end
@@ -632,8 +671,8 @@ class Hammer
632
671
  # `extended:` is accepted for parity with `print_help` but intentionally
633
672
  # not used here - the global-flags / Hammerfile-example / footer block
634
673
  # is root-help-only. A namespace listing is just the commands under
635
- # that prefix; tool-meta noise (self:, Recipes:, --update alias) is
636
- # reserved for `hammer --help` at the top level.
674
+ # that prefix; tool-meta noise (Recipes: section) is reserved for
675
+ # `hammer --help` at the top level.
637
676
  def print_namespace_help(prefix, ns, full: false, extended: false)
638
677
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
639
678
  rows = []
@@ -678,14 +717,16 @@ class Hammer
678
717
  print_footer unless hammer_bin
679
718
  end
680
719
 
681
- # Global flags only exist when invoked via the `hammer` binary
682
- # (see `Hammer.cli`), not for user-built CLIs that call `start`
683
- # on their own subclass.
720
+ # Listed under `Default task options:` in `--help` so users see what
721
+ # flags fire on bare-flag invocation (`hammer --version` etc).
722
+ # Re-rendered from the live `:default` task so user-defined
723
+ # overrides surface their own flags here automatically.
684
724
  def print_global_flags
685
- return unless root.instance_variable_get(:@hammer_binary)
725
+ default = root.commands['default']
726
+ return unless default && !default.options.empty?
686
727
  Shell.say ''
687
- Shell.say 'Global:', :yellow
688
- Shell.say ' --update # alias for `self:update`'
728
+ Shell.say 'Default task options:', :yellow
729
+ default.options.each { |o| Shell.say " #{o.usage}" }
689
730
  end
690
731
 
691
732
  def print_footer
@@ -693,35 +734,13 @@ class Hammer
693
734
  Shell.say "powered by hammer - #{HOMEPAGE}", :gray
694
735
  end
695
736
 
696
- # Small Hammerfile cheat-sheet shown under `hammer --help`. Touches
697
- # the main surface area (task/desc/example/opt, namespace, before,
698
- # needs, sh, say) without trying to be exhaustive - that's --ai's job.
737
+ # Hammerfile cheat-sheet shown under `hammer --help`. Same content
738
+ # as `hammer --init` writes - single source of truth via
739
+ # `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer agents`.
699
740
  def print_hammerfile_example
700
741
  Shell.say ''
701
742
  Shell.say 'Hammerfile example:', :yellow
702
- Shell.say <<~RUBY
703
- desc 'My project tools - build, deploy, test'
704
-
705
- task :hello do
706
- desc 'Greet someone'
707
- example 'hello world --loud'
708
- opt :loud, type: :boolean, alias: :l
709
- proc do |opts|
710
- msg = "hello \#{opts[:args].first || 'world'}"
711
- say(opts[:loud] ? msg.upcase : msg, :cyan)
712
- end
713
- end
714
-
715
- namespace :db do
716
- before { hammer :env }
717
-
718
- task :migrate do
719
- desc 'Run migrations'
720
- needs 'db:check'
721
- proc { sh 'bin/rails db:migrate' }
722
- end
723
- end
724
- RUBY
743
+ Shell.say Hammer::STARTER_HAMMERFILE
725
744
  end
726
745
 
727
746
  def print_command_list(klass, prefix = nil)
@@ -755,6 +774,7 @@ class Hammer
755
774
  def emit_rows(rows, width)
756
775
  rows.each do |full, c|
757
776
  brief = c.alts.empty? ? c.brief : "#{c.brief} (alt: #{c.alts.join(', ')})"
777
+ brief = "#{brief} #{Shell.paint('(redefined)', :yellow)}" if c.prev_location
758
778
  Shell.say " #{program_name} #{full.ljust(width)} # #{brief}"
759
779
  end
760
780
  end
@@ -866,7 +886,7 @@ class Hammer
866
886
  Shell.print_error "unknown recipe: #{name}"
867
887
  Shell.say 'available recipes:', :yellow
868
888
  Recipe.all.keys.sort.each { |n| Shell.say " #{n}" }
869
- Shell.say 'try `hammer self:recipe` to list with descriptions', :gray
889
+ Shell.say 'try `hammer recipes` to list with descriptions', :gray
870
890
  exit 1
871
891
  end
872
892
 
@@ -889,12 +909,45 @@ class Hammer
889
909
  end
890
910
  end
891
911
 
892
- # Default install dir used by install.sh and `hammer --update`.
912
+ # Canonical starter Hammerfile. Written verbatim by `hammer --init`
913
+ # and shown inline by `print_hammerfile_example` (the `Hammerfile
914
+ # example:` block in `hammer --help`) - one source of truth for both.
915
+ # Annotated as a mini tutorial of the common DSL surface in one task.
916
+ # Single-quoted heredoc so file contents pass through unescaped.
917
+ STARTER_HAMMERFILE ||= <<~'RUBY'
918
+ desc 'My project tools'
919
+
920
+ # `namespace :name do ... end` groups tasks under a colon prefix.
921
+ # Address as `hammer demo:greet`.
922
+ namespace :demo do
923
+ # run before every task - global, or on a namespace
924
+ before { say.gray "[demo] booting in #{Dir.pwd}" }
925
+
926
+ task :greet do
927
+ desc 'Demo task - shows the common DSL features in one place'
928
+ example 'demo:greet world --from=alice --loud --times=3'
929
+ alt :hi # `hammer demo:hi` also dispatches here
930
+
931
+ opt :from, default: 'anon', desc: 'who is greeting'
932
+ opt :loud, type: :boolean, alias: :l, desc: 'shout the greeting'
933
+ opt :times, type: :integer, default: 1, desc: 'repeat N times'
934
+
935
+ proc do |opts|
936
+ who = opts[:args].first || 'world'
937
+ msg = "hello #{who} from #{opts[:from]}"
938
+ msg = msg.upcase if opts[:loud]
939
+ opts[:times].times { say msg, :cyan }
940
+ end
941
+ end
942
+ end
943
+ RUBY
944
+
945
+ # Default install dir used by install.sh and `hammer update`.
893
946
  SELF_UPDATE_DIR ||= File.expand_path('~/.local/share/lux-hammer')
894
947
  SELF_UPDATE_REPO ||= 'https://github.com/dux/hammer.git'
895
948
  SELF_INSTALL_URL ||= 'https://raw.githubusercontent.com/dux/hammer/main/install.sh'
896
949
 
897
- # `hammer --update`: pull main in the install-script checkout and
950
+ # `hammer update`: pull main in the install-script checkout and
898
951
  # reinstall the gem. Assumes the install.sh layout - if the dir is
899
952
  # missing, point the user at the curl-pipe installer.
900
953
  def self.self_update
@@ -929,31 +982,25 @@ class Hammer
929
982
  # finds a Hammerfile, evaluates it as the block DSL, then dispatches
930
983
  # ARGV against the resulting CLI.
931
984
  #
932
- # The `self:` namespace (recipe management, AGENTS.md dump, self-
933
- # update) is registered on-demand when the user invokes help or
934
- # types a `self:` path - see `builtins_triggered?`.
985
+ # `--system` forces the no-Hammerfile branch even from inside a
986
+ # project - the escape hatch for reaching `recipes`/`init` when a
987
+ # user-defined task tree would otherwise own the root.
935
988
  def self.cli(argv = ARGV)
936
- # `--update` is a back-compat alias for `self:update`. Rewrite so
937
- # normal dispatch handles it (after builtins registration).
938
- if (i = argv.index('--update'))
939
- argv = argv.dup
940
- argv.delete_at(i)
941
- argv.unshift('self:update')
942
- end
943
-
944
- wants_builtins = builtins_triggered?(argv)
989
+ argv = argv.dup
990
+ force_system = !!argv.delete('--system')
945
991
 
946
- path = find_hammerfile(Dir.pwd)
992
+ path = force_system ? nil : find_hammerfile(Dir.pwd)
947
993
  unless path
948
- # No Hammerfile - still allow `hammer self:*` commands to run
949
- # (they don't depend on a project). Otherwise print the help-ish
950
- # "create one" message.
951
- if wants_builtins
994
+ # No Hammerfile (or --system) - all built-ins are reachable. Bare
995
+ # `hammer`, `hammer recipes`, `hammer update`, `hammer agents`,
996
+ # `hammer version`, `hammer init` all work.
997
+ if force_system || dispatches_to_builtin?(argv) || looks_like_builtin?(argv)
952
998
  klass = Class.new(Hammer)
953
999
  klass.instance_variable_set(:@hammer_binary, true)
954
1000
  klass.program_name
955
1001
  require_relative 'hammer/builtins'
956
- Hammer::Builtins.register(klass)
1002
+ Hammer::Builtins.register_core(klass)
1003
+ Hammer::Builtins.register_no_project(klass)
957
1004
  klass.start(argv)
958
1005
  return
959
1006
  end
@@ -974,24 +1021,17 @@ class Hammer
974
1021
 
975
1022
  Shell.say "create one - example:"
976
1023
  puts
977
- Shell.say <<~RUBY
978
- desc 'My project tools'
979
-
980
- task :hello do
981
- desc 'say hello'
982
- proc do |opts|
983
- say.green "hello \#{opts[:args].first || 'world'}"
984
- end
985
- end
986
- RUBY
1024
+ Shell.say STARTER_HAMMERFILE
987
1025
  Shell.say ''
988
- Shell.say "tip: run `#{File.basename($PROGRAM_NAME)} self:ai` for AI-friendly Hammerfile authoring docs", :gray
1026
+ bin = File.basename($PROGRAM_NAME)
1027
+ Shell.say "tip: run `#{bin} init` to drop the example above into ./Hammerfile", :gray
1028
+ Shell.say "tip: run `#{bin} agents` for AI-friendly Hammerfile authoring docs", :gray
989
1029
  exit 1
990
1030
  end
991
1031
 
992
1032
  klass = Class.new(Hammer)
993
1033
  # Mark this class as the `hammer` binary's root so help output can
994
- # surface binary-only sections (`Recipes:`, `self:` namespace).
1034
+ # surface binary-only sections (`Recipes:` listing).
995
1035
  klass.instance_variable_set(:@hammer_binary, true)
996
1036
  # Resolve before chdir so paths like `bin/foo` stay relative to the
997
1037
  # cwd the user actually invoked from. `program_name` memoizes.
@@ -1006,24 +1046,37 @@ class Hammer
1006
1046
  # are NOT visible during Hammerfile evaluation, only inside handlers.
1007
1047
  Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
1008
1048
 
1009
- if wants_builtins
1010
- require_relative 'hammer/builtins'
1011
- Hammer::Builtins.register(klass)
1012
- end
1049
+ # Core built-ins register AFTER Hammerfile eval so user-defined
1050
+ # tasks win (the `unless commands.key?(...)` guards in register_core
1051
+ # skip the built-in when overridden - no redefinition warning).
1052
+ # `register_no_project` (:recipes, :init) is intentionally NOT
1053
+ # called here - those would clash too easily with user tasks. Use
1054
+ # `hammer --system recipes` to reach them from inside a project.
1055
+ require_relative 'hammer/builtins'
1056
+ Hammer::Builtins.register_core(klass)
1013
1057
 
1014
1058
  klass.start(argv)
1015
1059
  end
1016
1060
 
1017
- # True if argv references the `self:` namespace or asks for help -
1018
- # any of which means the user wants to see / invoke the built-ins.
1019
- # Bare `hammer` (empty argv) does NOT trigger - the no-args listing
1020
- # stays a clean project-command view without `self:` or `Recipes:`.
1021
- # Cheap scan, runs once per invocation.
1022
- def self.builtins_triggered?(argv)
1023
- argv.any? do |a|
1024
- a == '--help' || a == '-h' || a == 'help' ||
1025
- a == 'self' || a.start_with?('self:')
1026
- end
1061
+ # True if argv goes through a built-in dispatch path (`:default` or
1062
+ # `:help`) - meaning bare `hammer`, leading-flag invocations like
1063
+ # `hammer -h`, or explicit help requests. These don't need a project
1064
+ # Hammerfile to run.
1065
+ def self.dispatches_to_builtin?(argv)
1066
+ return true if argv.empty?
1067
+ first = argv.first
1068
+ first == 'help' || first == '-h' || first == '--help' || first.start_with?('-')
1069
+ end
1070
+
1071
+ # True if argv names a built-in task (`recipes`, `update`, `agents`,
1072
+ # `version`, `init`). Used in the no-Hammerfile branch to wake up the
1073
+ # built-ins for invocations like `hammer recipes` that aren't a flag
1074
+ # or help request.
1075
+ BUILTIN_TASKS ||= %w[recipes update agents version init].freeze
1076
+ def self.looks_like_builtin?(argv)
1077
+ first = argv.first
1078
+ return false unless first
1079
+ BUILTIN_TASKS.include?(first) || BUILTIN_TASKS.any? { |t| first.start_with?("#{t}:") }
1027
1080
  end
1028
1081
 
1029
1082
  # Walk up the directory tree looking for a Hammerfile.