lux-hammer 0.2.9 → 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
@@ -325,20 +341,12 @@ class Hammer
325
341
  name = argv.shift
326
342
 
327
343
  if name.nil?
328
- # Bare invocation of the `hammer` binary: print a gem banner
329
- # above the help so users can see which lux-hammer they have.
330
- # Skipped for `-h` / `help` (those stay terse) and for user-
331
- # built CLIs (their own program name, not lux-hammer).
332
- if root.instance_variable_get(:@hammer_binary)
333
- Shell.say "lux-hammer #{VERSION}"
334
- Shell.say ''
335
- end
336
344
  return print_help
337
345
  end
338
346
 
339
347
  if name == 'help' || name == '-h' || name == '--help'
340
348
  target = argv.shift
341
- return print_help(target)
349
+ return print_help(target, extended: true)
342
350
  end
343
351
 
344
352
  # Trailing colon ("db:") -> namespace listing. Bare ":" lists root.
@@ -564,36 +572,69 @@ class Hammer
564
572
  scan.include?('-h') || scan.include?('--help')
565
573
  end
566
574
 
567
- def print_help(target = nil, full: false)
575
+ # `extended: true` is the verbose `help` / `-h` / `--help` form -
576
+ # appends global flags, the GitHub footer, and (for the hammer binary)
577
+ # a Hammerfile example. Bare invocation passes `extended: false` so
578
+ # the no-args output stays a clean command listing.
579
+ def print_help(target = nil, full: false, extended: false)
568
580
  if target
569
581
  # `help ns:` is equivalent to `ns:` - namespace listing.
570
582
  if target.end_with?(':') && target != ':'
571
583
  bare = target.chomp(':')
572
584
  ns, canonical = resolve_namespace(bare)
573
- return print_namespace_help(canonical, ns) if ns
585
+ return print_namespace_help(canonical, ns, extended: extended) if ns
574
586
  Shell.print_error("unknown: #{target}")
575
587
  return
576
588
  end
577
589
  cmd, _, canonical = resolve(target)
578
590
  return print_command_help(cmd, canonical) if cmd
579
591
  ns, canonical = resolve_namespace(target)
580
- return print_namespace_help(canonical, ns, full: full) if ns
592
+ return print_namespace_help(canonical, ns, full: full, extended: extended) if ns
581
593
  Shell.print_error("unknown: #{target}")
582
594
  return
583
595
  end
584
596
 
597
+ print_top_banner
585
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
586
603
  if full
587
604
  each_command { |path, c| print_full_block(path, c) unless c.desc.empty? }
588
605
  else
589
606
  Shell.say ''
590
607
  print_command_list(self)
591
608
  end
592
- print_global_flags
593
- print_footer
609
+ print_recipes_section if extended && root.instance_variable_get(:@hammer_binary)
610
+ print_extras if extended
594
611
  end
595
612
 
596
- def print_namespace_help(prefix, ns, full: false)
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.
637
+ def print_namespace_help(prefix, ns, full: false, extended: false)
597
638
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
598
639
  rows = []
599
640
  sibling = find_namespace_sibling(prefix)
@@ -605,8 +646,6 @@ class Hammer
605
646
  width = rows.map { |path, _| path.length }.max
606
647
  emit_rows(rows.sort_by { |path, _| [path.count(':'), path] }, width)
607
648
  end
608
- print_global_flags
609
- print_footer
610
649
  end
611
650
 
612
651
  # One "task block" for the expanded listing: blank line separator
@@ -618,6 +657,27 @@ class Hammer
618
657
 
619
658
  HOMEPAGE ||= 'https://github.com/dux/hammer'.freeze
620
659
 
660
+ # Gray "lux-hammer X.Y.Z - <homepage>" line shown above top-level help
661
+ # in both bare-invocation and `--help` modes, so the link is always
662
+ # one glance away. User CLIs skip it (the lux-hammer name/link is
663
+ # irrelevant outside the `hammer` binary).
664
+ def print_top_banner
665
+ return unless root.instance_variable_get(:@hammer_binary)
666
+ Shell.say "lux-hammer #{VERSION} - #{HOMEPAGE}", :gray
667
+ Shell.say ''
668
+ end
669
+
670
+ # Extras shown only in the extended (`help` / `-h` / `--help`) view:
671
+ # global flags, GitHub footer, and a Hammerfile example for the
672
+ # `hammer` binary. The footer is skipped for the hammer binary
673
+ # because `print_top_banner` already surfaces the same link.
674
+ def print_extras
675
+ hammer_bin = root.instance_variable_get(:@hammer_binary)
676
+ print_global_flags
677
+ print_hammerfile_example if hammer_bin
678
+ print_footer unless hammer_bin
679
+ end
680
+
621
681
  # Global flags only exist when invoked via the `hammer` binary
622
682
  # (see `Hammer.cli`), not for user-built CLIs that call `start`
623
683
  # on their own subclass.
@@ -625,7 +685,7 @@ class Hammer
625
685
  return unless root.instance_variable_get(:@hammer_binary)
626
686
  Shell.say ''
627
687
  Shell.say 'Global:', :yellow
628
- Shell.say ' --ai # Print AGENTS.md (AI-friendly Hammerfile authoring docs)'
688
+ Shell.say ' --update # alias for `self:update`'
629
689
  end
630
690
 
631
691
  def print_footer
@@ -633,6 +693,37 @@ class Hammer
633
693
  Shell.say "powered by hammer - #{HOMEPAGE}", :gray
634
694
  end
635
695
 
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.
699
+ def print_hammerfile_example
700
+ Shell.say ''
701
+ 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
725
+ end
726
+
636
727
  def print_command_list(klass, prefix = nil)
637
728
  rows = []
638
729
  # Commands without a `desc` are hidden from listings but still
@@ -758,6 +849,33 @@ class Hammer
758
849
  klass.start(argv)
759
850
  end
760
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
+
761
879
  # Dump the gem's AGENTS.md to stdout - AI-optimized guide for
762
880
  # writing Hammerfiles. Bundled with the gem and resolved relative
763
881
  # to this file so it works from any install location.
@@ -771,20 +889,75 @@ class Hammer
771
889
  end
772
890
  end
773
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
+
774
928
  # Entry point for the `hammer` binary. Walks up from CWD until it
775
929
  # finds a Hammerfile, evaluates it as the block DSL, then dispatches
776
930
  # ARGV against the resulting CLI.
777
931
  #
778
- # `--ai` is a meta-flag handled here, before Hammerfile lookup,
779
- # 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?`.
780
935
  def self.cli(argv = ARGV)
781
- if argv.include?('--ai')
782
- print_ai_help
783
- 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')
784
942
  end
785
943
 
944
+ wants_builtins = builtins_triggered?(argv)
945
+
786
946
  path = find_hammerfile(Dir.pwd)
787
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
+
788
961
  Shell.print_error "no Hammerfile found in #{Dir.pwd} or any parent directory"
789
962
 
790
963
  # Heuristic: *.rb files referencing `Hammer.` are likely inline CLIs
@@ -802,6 +975,8 @@ class Hammer
802
975
  Shell.say "create one - example:"
803
976
  puts
804
977
  Shell.say <<~RUBY
978
+ desc 'My project tools'
979
+
805
980
  task :hello do
806
981
  desc 'say hello'
807
982
  proc do |opts|
@@ -810,13 +985,13 @@ class Hammer
810
985
  end
811
986
  RUBY
812
987
  Shell.say ''
813
- 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
814
989
  exit 1
815
990
  end
816
991
 
817
992
  klass = Class.new(Hammer)
818
993
  # Mark this class as the `hammer` binary's root so help output can
819
- # surface binary-only globals like `--ai`.
994
+ # surface binary-only sections (`Recipes:`, `self:` namespace).
820
995
  klass.instance_variable_set(:@hammer_binary, true)
821
996
  # Resolve before chdir so paths like `bin/foo` stay relative to the
822
997
  # cwd the user actually invoked from. `program_name` memoizes.
@@ -830,9 +1005,27 @@ class Hammer
830
1005
  # `dotenv false` in the Hammerfile can suppress it. Trade-off: vars
831
1006
  # are NOT visible during Hammerfile evaluation, only inside handlers.
832
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
+
833
1014
  klass.start(argv)
834
1015
  end
835
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
+
836
1029
  # Walk up the directory tree looking for a Hammerfile.
837
1030
  def self.find_hammerfile(start)
838
1031
  dir = File.expand_path(start)