lux-hammer 0.3.6 → 0.3.8

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
@@ -188,12 +188,7 @@ class Hammer
188
188
  # namespace :users do ... end
189
189
  # end
190
190
  #
191
- # `:self` is reserved for the `hammer` binary's built-ins (see
192
- # Hammer::Builtins). User code that tries to open it raises.
193
191
  def namespace(name, &block)
194
- if name.to_s == 'self' && !Thread.current[:hammer_builtins_loading]
195
- raise Error, "namespace 'self' is reserved for hammer's built-in commands"
196
- end
197
192
  sub = Class.new(Hammer)
198
193
  # Track the top-level CLI class so cross-invocation
199
194
  # (`hammer 'ns:cmd'`) from inside a namespaced command dispatches
@@ -369,13 +364,18 @@ class Hammer
369
364
  argv = argv.dup
370
365
  name = argv.shift
371
366
 
372
- if name.nil?
373
- 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 }
374
373
  end
375
374
 
375
+ # Explicit help requests -> :help task. Remaining positionals (e.g.
376
+ # `help build`, `help db:`) reach :help via opts[:args].
376
377
  if name == 'help' || name == '-h' || name == '--help'
377
- target = argv.shift
378
- return print_help(target, extended: true)
378
+ return dispatch_to_builtin('help', argv) { print_help(argv.first, extended: true) }
379
379
  end
380
380
 
381
381
  # Trailing colon ("db:") -> namespace listing. Bare ":" lists root.
@@ -404,6 +404,16 @@ class Hammer
404
404
  exit 1
405
405
  end
406
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
+
407
417
  public
408
418
 
409
419
  # Find a command by canonical name or alt within this class. Falls
@@ -526,14 +536,14 @@ class Hammer
526
536
  end
527
537
  end
528
538
 
529
- def run_command(cmd, argv, full: nil)
539
+ def run_command(cmd, argv, full: nil, quiet: false)
530
540
  # -h / --help is reserved on every command. Anywhere before a `--`
531
541
  # stop-marker, it short-circuits to per-command help.
532
542
  return print_command_help(cmd, full) if help_requested?(argv)
533
543
 
534
544
  positional, opts = Parser.new(cmd.options).parse(argv)
535
545
  opts[:args] = positional
536
- print_run_banner(cmd, full || cmd.name, positional, opts)
546
+ print_run_banner(cmd, full || cmd.name, positional, opts) unless quiet || ENV['HAMMER_QUIET']
537
547
  instance = new
538
548
  run_before_hooks(instance, opts)
539
549
  run_needs(cmd)
@@ -652,7 +662,7 @@ class Hammer
652
662
  entries.each do |name, file|
653
663
  desc = Hammer::Recipe.desc(file)
654
664
  installed = Hammer::Recipe.installed_path(name)
655
- suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} self:recipe install #{name}]"
665
+ suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} recipes --install #{name}]"
656
666
  Shell.say " #{name.ljust(width)} # #{desc}"
657
667
  Shell.say " #{' ' * width} #{suffix}", :gray
658
668
  end
@@ -661,8 +671,8 @@ class Hammer
661
671
  # `extended:` is accepted for parity with `print_help` but intentionally
662
672
  # not used here - the global-flags / Hammerfile-example / footer block
663
673
  # is root-help-only. A namespace listing is just the commands under
664
- # that prefix; tool-meta noise (self:, Recipes:, --update alias) is
665
- # 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.
666
676
  def print_namespace_help(prefix, ns, full: false, extended: false)
667
677
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
668
678
  rows = []
@@ -707,14 +717,16 @@ class Hammer
707
717
  print_footer unless hammer_bin
708
718
  end
709
719
 
710
- # Global flags only exist when invoked via the `hammer` binary
711
- # (see `Hammer.cli`), not for user-built CLIs that call `start`
712
- # 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.
713
724
  def print_global_flags
714
- return unless root.instance_variable_get(:@hammer_binary)
725
+ default = root.commands['default']
726
+ return unless default && !default.options.empty?
715
727
  Shell.say ''
716
- Shell.say 'Global:', :yellow
717
- Shell.say ' --update # alias for `self:update`'
728
+ Shell.say 'Default task options:', :yellow
729
+ default.options.each { |o| Shell.say " #{o.usage}" }
718
730
  end
719
731
 
720
732
  def print_footer
@@ -722,35 +734,13 @@ class Hammer
722
734
  Shell.say "powered by hammer - #{HOMEPAGE}", :gray
723
735
  end
724
736
 
725
- # Small Hammerfile cheat-sheet shown under `hammer --help`. Touches
726
- # the main surface area (task/desc/example/opt, namespace, before,
727
- # 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`.
728
740
  def print_hammerfile_example
729
741
  Shell.say ''
730
742
  Shell.say 'Hammerfile example:', :yellow
731
- Shell.say <<~RUBY
732
- desc 'My project tools - build, deploy, test'
733
-
734
- task :hello do
735
- desc 'Greet someone'
736
- example 'hello world --loud'
737
- opt :loud, type: :boolean, alias: :l
738
- proc do |opts|
739
- msg = "hello \#{opts[:args].first || 'world'}"
740
- say(opts[:loud] ? msg.upcase : msg, :cyan)
741
- end
742
- end
743
-
744
- namespace :db do
745
- before { hammer :env }
746
-
747
- task :migrate do
748
- desc 'Run migrations'
749
- needs 'db:check'
750
- proc { sh 'bin/rails db:migrate' }
751
- end
752
- end
753
- RUBY
743
+ Shell.say Hammer::STARTER_HAMMERFILE
754
744
  end
755
745
 
756
746
  def print_command_list(klass, prefix = nil)
@@ -896,7 +886,7 @@ class Hammer
896
886
  Shell.print_error "unknown recipe: #{name}"
897
887
  Shell.say 'available recipes:', :yellow
898
888
  Recipe.all.keys.sort.each { |n| Shell.say " #{n}" }
899
- Shell.say 'try `hammer self:recipe` to list with descriptions', :gray
889
+ Shell.say 'try `hammer recipes` to list with descriptions', :gray
900
890
  exit 1
901
891
  end
902
892
 
@@ -919,12 +909,45 @@ class Hammer
919
909
  end
920
910
  end
921
911
 
922
- # 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`.
923
946
  SELF_UPDATE_DIR ||= File.expand_path('~/.local/share/lux-hammer')
924
947
  SELF_UPDATE_REPO ||= 'https://github.com/dux/hammer.git'
925
948
  SELF_INSTALL_URL ||= 'https://raw.githubusercontent.com/dux/hammer/main/install.sh'
926
949
 
927
- # `hammer --update`: pull main in the install-script checkout and
950
+ # `hammer update`: pull main in the install-script checkout and
928
951
  # reinstall the gem. Assumes the install.sh layout - if the dir is
929
952
  # missing, point the user at the curl-pipe installer.
930
953
  def self.self_update
@@ -959,31 +982,25 @@ class Hammer
959
982
  # finds a Hammerfile, evaluates it as the block DSL, then dispatches
960
983
  # ARGV against the resulting CLI.
961
984
  #
962
- # The `self:` namespace (recipe management, AGENTS.md dump, self-
963
- # update) is registered on-demand when the user invokes help or
964
- # 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.
965
988
  def self.cli(argv = ARGV)
966
- # `--update` is a back-compat alias for `self:update`. Rewrite so
967
- # normal dispatch handles it (after builtins registration).
968
- if (i = argv.index('--update'))
969
- argv = argv.dup
970
- argv.delete_at(i)
971
- argv.unshift('self:update')
972
- end
973
-
974
- wants_builtins = builtins_triggered?(argv)
989
+ argv = argv.dup
990
+ force_system = !!argv.delete('--system')
975
991
 
976
- path = find_hammerfile(Dir.pwd)
992
+ path = force_system ? nil : find_hammerfile(Dir.pwd)
977
993
  unless path
978
- # No Hammerfile - still allow `hammer self:*` commands to run
979
- # (they don't depend on a project). Otherwise print the help-ish
980
- # "create one" message.
981
- 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)
982
998
  klass = Class.new(Hammer)
983
999
  klass.instance_variable_set(:@hammer_binary, true)
984
1000
  klass.program_name
985
1001
  require_relative 'hammer/builtins'
986
- Hammer::Builtins.register(klass)
1002
+ Hammer::Builtins.register_core(klass)
1003
+ Hammer::Builtins.register_no_project(klass)
987
1004
  klass.start(argv)
988
1005
  return
989
1006
  end
@@ -1004,24 +1021,17 @@ class Hammer
1004
1021
 
1005
1022
  Shell.say "create one - example:"
1006
1023
  puts
1007
- Shell.say <<~RUBY
1008
- desc 'My project tools'
1009
-
1010
- task :hello do
1011
- desc 'say hello'
1012
- proc do |opts|
1013
- say.green "hello \#{opts[:args].first || 'world'}"
1014
- end
1015
- end
1016
- RUBY
1024
+ Shell.say STARTER_HAMMERFILE
1017
1025
  Shell.say ''
1018
- 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
1019
1029
  exit 1
1020
1030
  end
1021
1031
 
1022
1032
  klass = Class.new(Hammer)
1023
1033
  # Mark this class as the `hammer` binary's root so help output can
1024
- # surface binary-only sections (`Recipes:`, `self:` namespace).
1034
+ # surface binary-only sections (`Recipes:` listing).
1025
1035
  klass.instance_variable_set(:@hammer_binary, true)
1026
1036
  # Resolve before chdir so paths like `bin/foo` stay relative to the
1027
1037
  # cwd the user actually invoked from. `program_name` memoizes.
@@ -1036,24 +1046,37 @@ class Hammer
1036
1046
  # are NOT visible during Hammerfile evaluation, only inside handlers.
1037
1047
  Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
1038
1048
 
1039
- if wants_builtins
1040
- require_relative 'hammer/builtins'
1041
- Hammer::Builtins.register(klass)
1042
- 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)
1043
1057
 
1044
1058
  klass.start(argv)
1045
1059
  end
1046
1060
 
1047
- # True if argv references the `self:` namespace or asks for help -
1048
- # any of which means the user wants to see / invoke the built-ins.
1049
- # Bare `hammer` (empty argv) does NOT trigger - the no-args listing
1050
- # stays a clean project-command view without `self:` or `Recipes:`.
1051
- # Cheap scan, runs once per invocation.
1052
- def self.builtins_triggered?(argv)
1053
- argv.any? do |a|
1054
- a == '--help' || a == '-h' || a == 'help' ||
1055
- a == 'self' || a.start_with?('self:')
1056
- 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}:") }
1057
1080
  end
1058
1081
 
1059
1082
  # Walk up the directory tree looking for a Hammerfile.