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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/AGENTS.md +32 -5
- data/README.md +97 -0
- data/lib/hammer/builder.rb +44 -0
- data/lib/hammer/builtins.rb +181 -0
- data/lib/hammer/recipe.rb +92 -0
- data/lib/hammer/shell.rb +10 -6
- data/lib/lux-hammer.rb +218 -25
- data/recipes/git-helper.rb +624 -0
- data/recipes/srt.rb +270 -0
- metadata +6 -2
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
|
-
|
|
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
|
-
|
|
593
|
-
|
|
609
|
+
print_recipes_section if extended && root.instance_variable_get(:@hammer_binary)
|
|
610
|
+
print_extras if extended
|
|
594
611
|
end
|
|
595
612
|
|
|
596
|
-
|
|
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 ' --
|
|
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
|
-
#
|
|
779
|
-
#
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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)}
|
|
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
|
|
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)
|