lux-hammer 0.3.1 → 0.3.3

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
@@ -6,6 +6,7 @@ require_relative 'hammer/loader'
6
6
  require_relative 'hammer/builder'
7
7
  require_relative 'hammer/command_builder'
8
8
  require_relative 'hammer/dotenv'
9
+ require_relative 'hammer/recipe'
9
10
 
10
11
  # Thor-inspired tiny CLI builder.
11
12
  #
@@ -55,6 +56,7 @@ class Hammer
55
56
  sub.instance_variable_set(:@before_hooks, [])
56
57
  sub.instance_variable_set(:@parent, nil)
57
58
  sub.instance_variable_set(:@program_name, nil)
59
+ sub.instance_variable_set(:@app_desc, nil)
58
60
  sub.instance_variable_set(:@pending_desc, nil)
59
61
  sub.instance_variable_set(:@pending_examples, [])
60
62
  sub.instance_variable_set(:@pending_options, [])
@@ -110,6 +112,14 @@ class Hammer
110
112
  @program_name ||= default_program_name
111
113
  end
112
114
 
115
+ # Top-level description for the whole CLI. Set from a Hammerfile (block
116
+ # DSL) via `desc 'text'` at top level - see `Hammer::Builder#desc`.
117
+ # Rendered under the Usage line in `--help` output.
118
+ def app_desc(text = nil)
119
+ return @app_desc if text.nil?
120
+ @app_desc = text.to_s.rstrip
121
+ end
122
+
113
123
  # Program name shown in help/usage: the invocation path relative to cwd
114
124
  # if the script lives inside it (e.g. `bin/foo` when invoked from the
115
125
  # project root), otherwise the basename (e.g. `lux` for a globally
@@ -170,7 +180,13 @@ class Hammer
170
180
  # task :migrate do ... end
171
181
  # namespace :users do ... end
172
182
  # end
183
+ #
184
+ # `:self` is reserved for the `hammer` binary's built-ins (see
185
+ # Hammer::Builtins). User code that tries to open it raises.
173
186
  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
174
190
  sub = Class.new(Hammer)
175
191
  # Track the top-level CLI class so cross-invocation
176
192
  # (`hammer 'ns:cmd'`) from inside a namespaced command dispatches
@@ -580,15 +596,44 @@ class Hammer
580
596
 
581
597
  print_top_banner
582
598
  Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan
599
+ if @app_desc && !@app_desc.empty?
600
+ Shell.say ''
601
+ @app_desc.each_line { |l| Shell.say " #{l.chomp}" }
602
+ end
583
603
  if full
584
604
  each_command { |path, c| print_full_block(path, c) unless c.desc.empty? }
585
605
  else
586
606
  Shell.say ''
587
607
  print_command_list(self)
588
608
  end
609
+ print_recipes_section if extended && root.instance_variable_get(:@hammer_binary)
589
610
  print_extras if extended
590
611
  end
591
612
 
613
+ # Lists recipes (gem + user-dir) under their own section in
614
+ # `hammer --help`. Each row shows the recipe's `# desc:` line and
615
+ # either an install hint or the path of the existing stub on PATH.
616
+ # Only rendered when this CLI is the `hammer` binary's root.
617
+ def print_recipes_section
618
+ entries = Hammer::Recipe.all
619
+ return if entries.empty?
620
+ Shell.say ''
621
+ Shell.say 'Recipes:', :yellow
622
+ width = entries.keys.map(&:length).max
623
+ entries.each do |name, file|
624
+ desc = Hammer::Recipe.desc(file)
625
+ installed = Hammer::Recipe.installed_path(name)
626
+ suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} self:recipe install #{name}]"
627
+ Shell.say " #{name.ljust(width)} # #{desc}"
628
+ Shell.say " #{' ' * width} #{suffix}", :gray
629
+ end
630
+ end
631
+
632
+ # `extended:` is accepted for parity with `print_help` but intentionally
633
+ # not used here - the global-flags / Hammerfile-example / footer block
634
+ # 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.
592
637
  def print_namespace_help(prefix, ns, full: false, extended: false)
593
638
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
594
639
  rows = []
@@ -601,7 +646,6 @@ class Hammer
601
646
  width = rows.map { |path, _| path.length }.max
602
647
  emit_rows(rows.sort_by { |path, _| [path.count(':'), path] }, width)
603
648
  end
604
- print_extras if extended
605
649
  end
606
650
 
607
651
  # One "task block" for the expanded listing: blank line separator
@@ -641,7 +685,7 @@ class Hammer
641
685
  return unless root.instance_variable_get(:@hammer_binary)
642
686
  Shell.say ''
643
687
  Shell.say 'Global:', :yellow
644
- Shell.say ' --ai # Print AGENTS.md - AI-friendly Hammerfile authoring docs'
688
+ Shell.say ' --update # alias for `self:update`'
645
689
  end
646
690
 
647
691
  def print_footer
@@ -656,6 +700,8 @@ class Hammer
656
700
  Shell.say ''
657
701
  Shell.say 'Hammerfile example:', :yellow
658
702
  Shell.say <<~RUBY
703
+ desc 'My project tools - build, deploy, test'
704
+
659
705
  task :hello do
660
706
  desc 'Greet someone'
661
707
  example 'hello world --loud'
@@ -803,6 +849,33 @@ class Hammer
803
849
  klass.start(argv)
804
850
  end
805
851
 
852
+ # Entry point for recipe stubs in PATH. A recipe is a standalone
853
+ # Hammerfile-style script bundled with the gem (or in
854
+ # ~/.config/hammer/recipes/) that is exposed as its own bin via a
855
+ # tiny Ruby wrapper containing:
856
+ #
857
+ # require 'lux-hammer'
858
+ # Hammer.recipe(:srt, ARGV)
859
+ #
860
+ # The recipe runs as a self-contained CLI: program_name is the recipe
861
+ # name, only its own tasks show in --help, no global hammer commands
862
+ # appear. Runs in the caller's cwd (no chdir, no Hammerfile lookup).
863
+ def self.recipe(name, argv = ARGV)
864
+ path = Recipe.path(name)
865
+ unless path
866
+ Shell.print_error "unknown recipe: #{name}"
867
+ Shell.say 'available recipes:', :yellow
868
+ Recipe.all.keys.sort.each { |n| Shell.say " #{n}" }
869
+ Shell.say 'try `hammer self:recipe` to list with descriptions', :gray
870
+ exit 1
871
+ end
872
+
873
+ klass = Class.new(Hammer)
874
+ klass.instance_variable_set(:@program_name, name.to_s)
875
+ Builder.new(klass).evaluate(File.read(path), path)
876
+ klass.start(argv)
877
+ end
878
+
806
879
  # Dump the gem's AGENTS.md to stdout - AI-optimized guide for
807
880
  # writing Hammerfiles. Bundled with the gem and resolved relative
808
881
  # to this file so it works from any install location.
@@ -816,20 +889,75 @@ class Hammer
816
889
  end
817
890
  end
818
891
 
892
+ # Default install dir used by install.sh and `hammer --update`.
893
+ SELF_UPDATE_DIR ||= File.expand_path('~/.local/share/lux-hammer')
894
+ SELF_UPDATE_REPO ||= 'https://github.com/dux/hammer.git'
895
+ SELF_INSTALL_URL ||= 'https://raw.githubusercontent.com/dux/hammer/main/install.sh'
896
+
897
+ # `hammer --update`: pull main in the install-script checkout and
898
+ # reinstall the gem. Assumes the install.sh layout - if the dir is
899
+ # missing, point the user at the curl-pipe installer.
900
+ def self.self_update
901
+ dir = ENV['LUX_HAMMER_DIR'] || SELF_UPDATE_DIR
902
+ unless File.directory?(File.join(dir, '.git'))
903
+ Shell.print_error "no lux-hammer git checkout at #{dir}"
904
+ Shell.say 'reinstall with:', :yellow
905
+ Shell.say " curl -fsSL #{SELF_INSTALL_URL} | bash"
906
+ exit 1
907
+ end
908
+
909
+ Shell.say "* updating lux-hammer at #{dir}", :cyan
910
+ Dir.chdir(dir) do
911
+ run_or_exit('git', 'fetch', '--quiet', 'origin', 'main')
912
+ run_or_exit('git', 'reset', '--quiet', '--hard', 'origin/main')
913
+ version = File.read('.version').strip
914
+ gem_file = "lux-hammer-#{version}.gem"
915
+ run_or_exit('gem', 'build', 'lux-hammer.gemspec', out: File::NULL)
916
+ run_or_exit('gem', 'install', '--quiet', gem_file)
917
+ File.unlink(gem_file) if File.exist?(gem_file)
918
+ Shell.say "* lux-hammer #{version} installed", :green
919
+ end
920
+ end
921
+
922
+ def self.run_or_exit(*cmd, **opts)
923
+ return if system(*cmd, **opts)
924
+ Shell.print_error "command failed: #{cmd.join(' ')}"
925
+ exit 1
926
+ end
927
+
819
928
  # Entry point for the `hammer` binary. Walks up from CWD until it
820
929
  # finds a Hammerfile, evaluates it as the block DSL, then dispatches
821
930
  # ARGV against the resulting CLI.
822
931
  #
823
- # `--ai` is a meta-flag handled here, before Hammerfile lookup,
824
- # so it works anywhere (no project required).
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?`.
825
935
  def self.cli(argv = ARGV)
826
- if argv.include?('--ai')
827
- print_ai_help
828
- exit 0
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')
829
942
  end
830
943
 
944
+ wants_builtins = builtins_triggered?(argv)
945
+
831
946
  path = find_hammerfile(Dir.pwd)
832
947
  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
952
+ klass = Class.new(Hammer)
953
+ klass.instance_variable_set(:@hammer_binary, true)
954
+ klass.program_name
955
+ require_relative 'hammer/builtins'
956
+ Hammer::Builtins.register(klass)
957
+ klass.start(argv)
958
+ return
959
+ end
960
+
833
961
  Shell.print_error "no Hammerfile found in #{Dir.pwd} or any parent directory"
834
962
 
835
963
  # Heuristic: *.rb files referencing `Hammer.` are likely inline CLIs
@@ -847,6 +975,8 @@ class Hammer
847
975
  Shell.say "create one - example:"
848
976
  puts
849
977
  Shell.say <<~RUBY
978
+ desc 'My project tools'
979
+
850
980
  task :hello do
851
981
  desc 'say hello'
852
982
  proc do |opts|
@@ -855,13 +985,13 @@ class Hammer
855
985
  end
856
986
  RUBY
857
987
  Shell.say ''
858
- Shell.say "tip: run `#{File.basename($PROGRAM_NAME)} --ai` for AI-friendly Hammerfile authoring docs", :gray
988
+ Shell.say "tip: run `#{File.basename($PROGRAM_NAME)} self:ai` for AI-friendly Hammerfile authoring docs", :gray
859
989
  exit 1
860
990
  end
861
991
 
862
992
  klass = Class.new(Hammer)
863
993
  # Mark this class as the `hammer` binary's root so help output can
864
- # surface binary-only globals like `--ai`.
994
+ # surface binary-only sections (`Recipes:`, `self:` namespace).
865
995
  klass.instance_variable_set(:@hammer_binary, true)
866
996
  # Resolve before chdir so paths like `bin/foo` stay relative to the
867
997
  # cwd the user actually invoked from. `program_name` memoizes.
@@ -875,9 +1005,27 @@ class Hammer
875
1005
  # `dotenv false` in the Hammerfile can suppress it. Trade-off: vars
876
1006
  # are NOT visible during Hammerfile evaluation, only inside handlers.
877
1007
  Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
1008
+
1009
+ if wants_builtins
1010
+ require_relative 'hammer/builtins'
1011
+ Hammer::Builtins.register(klass)
1012
+ end
1013
+
878
1014
  klass.start(argv)
879
1015
  end
880
1016
 
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
1027
+ end
1028
+
881
1029
  # Walk up the directory tree looking for a Hammerfile.
882
1030
  def self.find_hammerfile(start)
883
1031
  dir = File.expand_path(start)