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.
- checksums.yaml +4 -4
- data/.version +1 -1
- data/AGENTS.md +28 -4
- 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/lux-hammer.rb +157 -9
- 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
|
|
@@ -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 ' --
|
|
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
|
-
#
|
|
824
|
-
#
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
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)}
|
|
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
|
|
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)
|