lux-hammer 0.3.5 → 0.3.7
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 +68 -11
- data/README.md +98 -18
- data/lib/hammer/builtins.rb +143 -55
- data/lib/hammer/command.rb +1 -1
- data/lib/hammer/recipe.rb +2 -2
- data/lib/lux-hammer.rb +146 -93
- data/recipes/llm.rb +434 -0
- metadata +3 -2
data/lib/lux-hammer.rb
CHANGED
|
@@ -141,6 +141,7 @@ class Hammer
|
|
|
141
141
|
# lives at `opts[:args]`.
|
|
142
142
|
def task(name, &block)
|
|
143
143
|
cmd = Command.new(name: name.to_s)
|
|
144
|
+
cmd.location = source_location_of(block)
|
|
144
145
|
handler = CommandBuilder.new(cmd).instance_eval(&block)
|
|
145
146
|
unless handler.is_a?(Proc)
|
|
146
147
|
raise Error, <<~MSG
|
|
@@ -161,6 +162,12 @@ class Hammer
|
|
|
161
162
|
MSG
|
|
162
163
|
end
|
|
163
164
|
cmd.handler = handler
|
|
165
|
+
|
|
166
|
+
if (prev = commands[cmd.name])
|
|
167
|
+
cmd.prev_location = prev.location
|
|
168
|
+
warn_redefinition('task', cmd.name, prev.location, cmd.location)
|
|
169
|
+
end
|
|
170
|
+
|
|
164
171
|
commands[cmd.name] = cmd
|
|
165
172
|
|
|
166
173
|
# `task` ignores pending class-level state, but clear it so a
|
|
@@ -181,12 +188,7 @@ class Hammer
|
|
|
181
188
|
# namespace :users do ... end
|
|
182
189
|
# end
|
|
183
190
|
#
|
|
184
|
-
# `:self` is reserved for the `hammer` binary's built-ins (see
|
|
185
|
-
# Hammer::Builtins). User code that tries to open it raises.
|
|
186
191
|
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
|
|
190
192
|
sub = Class.new(Hammer)
|
|
191
193
|
# Track the top-level CLI class so cross-invocation
|
|
192
194
|
# (`hammer 'ns:cmd'`) from inside a namespaced command dispatches
|
|
@@ -199,7 +201,14 @@ class Hammer
|
|
|
199
201
|
# "myapp ns:cmd" with the same prefix everywhere - and so the value
|
|
200
202
|
# captured pre-chdir (see `Hammer.cli`) survives into nested classes.
|
|
201
203
|
sub.instance_variable_set(:@program_name, program_name)
|
|
204
|
+
sub.instance_variable_set(:@location, source_location_of(block))
|
|
202
205
|
Hammer.with_target(sub) { sub.class_eval(&block) } if block
|
|
206
|
+
|
|
207
|
+
if (prev = @namespaces[name.to_s])
|
|
208
|
+
sub.instance_variable_set(:@prev_location, prev.instance_variable_get(:@location))
|
|
209
|
+
warn_redefinition('namespace', name.to_s, prev.instance_variable_get(:@location), sub.instance_variable_get(:@location))
|
|
210
|
+
end
|
|
211
|
+
|
|
203
212
|
@namespaces[name.to_s] = sub
|
|
204
213
|
end
|
|
205
214
|
|
|
@@ -236,6 +245,21 @@ class Hammer
|
|
|
236
245
|
@parent
|
|
237
246
|
end
|
|
238
247
|
|
|
248
|
+
# "file:line" of the block that defined a task/namespace. Falls back
|
|
249
|
+
# to "(unknown)" for blocks without a usable source_location (rare -
|
|
250
|
+
# built-in C-defined procs, eval'd blocks).
|
|
251
|
+
def source_location_of(block)
|
|
252
|
+
loc = block&.source_location
|
|
253
|
+
loc ? "#{loc[0]}:#{loc[1]}" : '(unknown)'
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# Emit a yellow [hammer] warning on stderr when a task/namespace is
|
|
257
|
+
# redefined. Last write wins (commands[name] = cmd), but the prior
|
|
258
|
+
# location is captured so listings can tag the entry as `(redefined)`.
|
|
259
|
+
def warn_redefinition(kind, name, prev_loc, new_loc)
|
|
260
|
+
warn Shell.paint("[hammer] redefined #{kind} :#{name} - was #{prev_loc || '(unknown)'}, now #{new_loc || '(unknown)'}", :yellow)
|
|
261
|
+
end
|
|
262
|
+
|
|
239
263
|
# Root -> ... -> self. Used to gather `before` hooks for a command.
|
|
240
264
|
def ancestor_chain
|
|
241
265
|
chain = []
|
|
@@ -340,13 +364,18 @@ class Hammer
|
|
|
340
364
|
argv = argv.dup
|
|
341
365
|
name = argv.shift
|
|
342
366
|
|
|
343
|
-
|
|
344
|
-
|
|
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 }
|
|
345
373
|
end
|
|
346
374
|
|
|
375
|
+
# Explicit help requests -> :help task. Remaining positionals (e.g.
|
|
376
|
+
# `help build`, `help db:`) reach :help via opts[:args].
|
|
347
377
|
if name == 'help' || name == '-h' || name == '--help'
|
|
348
|
-
|
|
349
|
-
return print_help(target, extended: true)
|
|
378
|
+
return dispatch_to_builtin('help', argv) { print_help(argv.first, extended: true) }
|
|
350
379
|
end
|
|
351
380
|
|
|
352
381
|
# Trailing colon ("db:") -> namespace listing. Bare ":" lists root.
|
|
@@ -375,6 +404,16 @@ class Hammer
|
|
|
375
404
|
exit 1
|
|
376
405
|
end
|
|
377
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
|
+
|
|
378
417
|
public
|
|
379
418
|
|
|
380
419
|
# Find a command by canonical name or alt within this class. Falls
|
|
@@ -497,14 +536,14 @@ class Hammer
|
|
|
497
536
|
end
|
|
498
537
|
end
|
|
499
538
|
|
|
500
|
-
def run_command(cmd, argv, full: nil)
|
|
539
|
+
def run_command(cmd, argv, full: nil, quiet: false)
|
|
501
540
|
# -h / --help is reserved on every command. Anywhere before a `--`
|
|
502
541
|
# stop-marker, it short-circuits to per-command help.
|
|
503
542
|
return print_command_help(cmd, full) if help_requested?(argv)
|
|
504
543
|
|
|
505
544
|
positional, opts = Parser.new(cmd.options).parse(argv)
|
|
506
545
|
opts[:args] = positional
|
|
507
|
-
print_run_banner(cmd, full || cmd.name, positional, opts)
|
|
546
|
+
print_run_banner(cmd, full || cmd.name, positional, opts) unless quiet || ENV['HAMMER_QUIET']
|
|
508
547
|
instance = new
|
|
509
548
|
run_before_hooks(instance, opts)
|
|
510
549
|
run_needs(cmd)
|
|
@@ -623,7 +662,7 @@ class Hammer
|
|
|
623
662
|
entries.each do |name, file|
|
|
624
663
|
desc = Hammer::Recipe.desc(file)
|
|
625
664
|
installed = Hammer::Recipe.installed_path(name)
|
|
626
|
-
suffix = installed ? "(installed: #{installed})" : "[install: #{program_name}
|
|
665
|
+
suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} recipes --install #{name}]"
|
|
627
666
|
Shell.say " #{name.ljust(width)} # #{desc}"
|
|
628
667
|
Shell.say " #{' ' * width} #{suffix}", :gray
|
|
629
668
|
end
|
|
@@ -632,8 +671,8 @@ class Hammer
|
|
|
632
671
|
# `extended:` is accepted for parity with `print_help` but intentionally
|
|
633
672
|
# not used here - the global-flags / Hammerfile-example / footer block
|
|
634
673
|
# is root-help-only. A namespace listing is just the commands under
|
|
635
|
-
# that prefix; tool-meta noise (
|
|
636
|
-
#
|
|
674
|
+
# that prefix; tool-meta noise (Recipes: section) is reserved for
|
|
675
|
+
# `hammer --help` at the top level.
|
|
637
676
|
def print_namespace_help(prefix, ns, full: false, extended: false)
|
|
638
677
|
Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
|
|
639
678
|
rows = []
|
|
@@ -678,14 +717,16 @@ class Hammer
|
|
|
678
717
|
print_footer unless hammer_bin
|
|
679
718
|
end
|
|
680
719
|
|
|
681
|
-
#
|
|
682
|
-
#
|
|
683
|
-
#
|
|
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.
|
|
684
724
|
def print_global_flags
|
|
685
|
-
|
|
725
|
+
default = root.commands['default']
|
|
726
|
+
return unless default && !default.options.empty?
|
|
686
727
|
Shell.say ''
|
|
687
|
-
Shell.say '
|
|
688
|
-
Shell.say
|
|
728
|
+
Shell.say 'Default task options:', :yellow
|
|
729
|
+
default.options.each { |o| Shell.say " #{o.usage}" }
|
|
689
730
|
end
|
|
690
731
|
|
|
691
732
|
def print_footer
|
|
@@ -693,35 +734,13 @@ class Hammer
|
|
|
693
734
|
Shell.say "powered by hammer - #{HOMEPAGE}", :gray
|
|
694
735
|
end
|
|
695
736
|
|
|
696
|
-
#
|
|
697
|
-
#
|
|
698
|
-
#
|
|
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`.
|
|
699
740
|
def print_hammerfile_example
|
|
700
741
|
Shell.say ''
|
|
701
742
|
Shell.say 'Hammerfile example:', :yellow
|
|
702
|
-
Shell.say
|
|
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
|
|
743
|
+
Shell.say Hammer::STARTER_HAMMERFILE
|
|
725
744
|
end
|
|
726
745
|
|
|
727
746
|
def print_command_list(klass, prefix = nil)
|
|
@@ -755,6 +774,7 @@ class Hammer
|
|
|
755
774
|
def emit_rows(rows, width)
|
|
756
775
|
rows.each do |full, c|
|
|
757
776
|
brief = c.alts.empty? ? c.brief : "#{c.brief} (alt: #{c.alts.join(', ')})"
|
|
777
|
+
brief = "#{brief} #{Shell.paint('(redefined)', :yellow)}" if c.prev_location
|
|
758
778
|
Shell.say " #{program_name} #{full.ljust(width)} # #{brief}"
|
|
759
779
|
end
|
|
760
780
|
end
|
|
@@ -866,7 +886,7 @@ class Hammer
|
|
|
866
886
|
Shell.print_error "unknown recipe: #{name}"
|
|
867
887
|
Shell.say 'available recipes:', :yellow
|
|
868
888
|
Recipe.all.keys.sort.each { |n| Shell.say " #{n}" }
|
|
869
|
-
Shell.say 'try `hammer
|
|
889
|
+
Shell.say 'try `hammer recipes` to list with descriptions', :gray
|
|
870
890
|
exit 1
|
|
871
891
|
end
|
|
872
892
|
|
|
@@ -889,12 +909,45 @@ class Hammer
|
|
|
889
909
|
end
|
|
890
910
|
end
|
|
891
911
|
|
|
892
|
-
#
|
|
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`.
|
|
893
946
|
SELF_UPDATE_DIR ||= File.expand_path('~/.local/share/lux-hammer')
|
|
894
947
|
SELF_UPDATE_REPO ||= 'https://github.com/dux/hammer.git'
|
|
895
948
|
SELF_INSTALL_URL ||= 'https://raw.githubusercontent.com/dux/hammer/main/install.sh'
|
|
896
949
|
|
|
897
|
-
# `hammer
|
|
950
|
+
# `hammer update`: pull main in the install-script checkout and
|
|
898
951
|
# reinstall the gem. Assumes the install.sh layout - if the dir is
|
|
899
952
|
# missing, point the user at the curl-pipe installer.
|
|
900
953
|
def self.self_update
|
|
@@ -929,31 +982,25 @@ class Hammer
|
|
|
929
982
|
# finds a Hammerfile, evaluates it as the block DSL, then dispatches
|
|
930
983
|
# ARGV against the resulting CLI.
|
|
931
984
|
#
|
|
932
|
-
#
|
|
933
|
-
#
|
|
934
|
-
#
|
|
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.
|
|
935
988
|
def self.cli(argv = ARGV)
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
if (i = argv.index('--update'))
|
|
939
|
-
argv = argv.dup
|
|
940
|
-
argv.delete_at(i)
|
|
941
|
-
argv.unshift('self:update')
|
|
942
|
-
end
|
|
943
|
-
|
|
944
|
-
wants_builtins = builtins_triggered?(argv)
|
|
989
|
+
argv = argv.dup
|
|
990
|
+
force_system = !!argv.delete('--system')
|
|
945
991
|
|
|
946
|
-
path = find_hammerfile(Dir.pwd)
|
|
992
|
+
path = force_system ? nil : find_hammerfile(Dir.pwd)
|
|
947
993
|
unless path
|
|
948
|
-
# No Hammerfile
|
|
949
|
-
#
|
|
950
|
-
#
|
|
951
|
-
if
|
|
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)
|
|
952
998
|
klass = Class.new(Hammer)
|
|
953
999
|
klass.instance_variable_set(:@hammer_binary, true)
|
|
954
1000
|
klass.program_name
|
|
955
1001
|
require_relative 'hammer/builtins'
|
|
956
|
-
Hammer::Builtins.
|
|
1002
|
+
Hammer::Builtins.register_core(klass)
|
|
1003
|
+
Hammer::Builtins.register_no_project(klass)
|
|
957
1004
|
klass.start(argv)
|
|
958
1005
|
return
|
|
959
1006
|
end
|
|
@@ -974,24 +1021,17 @@ class Hammer
|
|
|
974
1021
|
|
|
975
1022
|
Shell.say "create one - example:"
|
|
976
1023
|
puts
|
|
977
|
-
Shell.say
|
|
978
|
-
desc 'My project tools'
|
|
979
|
-
|
|
980
|
-
task :hello do
|
|
981
|
-
desc 'say hello'
|
|
982
|
-
proc do |opts|
|
|
983
|
-
say.green "hello \#{opts[:args].first || 'world'}"
|
|
984
|
-
end
|
|
985
|
-
end
|
|
986
|
-
RUBY
|
|
1024
|
+
Shell.say STARTER_HAMMERFILE
|
|
987
1025
|
Shell.say ''
|
|
988
|
-
|
|
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
|
|
989
1029
|
exit 1
|
|
990
1030
|
end
|
|
991
1031
|
|
|
992
1032
|
klass = Class.new(Hammer)
|
|
993
1033
|
# Mark this class as the `hammer` binary's root so help output can
|
|
994
|
-
# surface binary-only sections (`Recipes
|
|
1034
|
+
# surface binary-only sections (`Recipes:` listing).
|
|
995
1035
|
klass.instance_variable_set(:@hammer_binary, true)
|
|
996
1036
|
# Resolve before chdir so paths like `bin/foo` stay relative to the
|
|
997
1037
|
# cwd the user actually invoked from. `program_name` memoizes.
|
|
@@ -1006,24 +1046,37 @@ class Hammer
|
|
|
1006
1046
|
# are NOT visible during Hammerfile evaluation, only inside handlers.
|
|
1007
1047
|
Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
|
|
1008
1048
|
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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)
|
|
1013
1057
|
|
|
1014
1058
|
klass.start(argv)
|
|
1015
1059
|
end
|
|
1016
1060
|
|
|
1017
|
-
# True if argv
|
|
1018
|
-
#
|
|
1019
|
-
#
|
|
1020
|
-
#
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
argv.
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
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}:") }
|
|
1027
1080
|
end
|
|
1028
1081
|
|
|
1029
1082
|
# Walk up the directory tree looking for a Hammerfile.
|