Class: Hammer

Inherits:
Object
  • Object
show all
Includes:
Shell
Defined in:
lib/lux-hammer.rb,
lib/hammer/shell.rb,
lib/hammer/dotenv.rb,
lib/hammer/loader.rb,
lib/hammer/option.rb,
lib/hammer/parser.rb,
lib/hammer/recipe.rb,
lib/hammer/builder.rb,
lib/hammer/command.rb,
lib/hammer/builtins.rb,
lib/hammer/command_builder.rb

Overview

Thor-inspired tiny CLI builder.

Class DSL:

class MyCli < Hammer
  task :build do
    desc    'Build the project'
    example 'build -v --env=prod'
    opt :verbose, type: :boolean, alias: :v
    opt :env,     type: :string,  default: 'dev'
    proc do |opts|
      say.green "building #{opts[:env]} args=#{opts[:args].inspect}"
    end
  end
end

MyCli.start(ARGV)

Block DSL is identical, just inside ‘Hammer.run`:

Hammer.run(ARGV) do
  task :hello do
    desc 'Greet someone'
    opt :loud, type: :boolean, alias: :l
    proc do |opts|
      msg = "hello #{opts[:args].first || 'world'}"
      msg = msg.upcase if opts[:loud]
      say msg, :cyan
    end
  end
end

Defined Under Namespace

Modules: Builtins, DSL, Dotenv, Recipe, Shell Classes: Builder, Command, CommandBuilder, Loader, Option, Parser

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Shell

ask, choose, choose_numbered, color!, color?, error, paint, print_error, say, sh, yes?

Class Method Details

.alt(*names) ⇒ Object



81
# File 'lib/lux-hammer.rb', line 81

def alt(*names)      ; @pending_alts.concat(names) end

.ancestor_chainObject

Root -> … -> self. Used to gather ‘before` hooks for a command.



264
265
266
267
268
269
270
271
272
# File 'lib/lux-hammer.rb', line 264

def ancestor_chain
  chain = []
  klass = self
  while klass
    chain.unshift klass
    klass = klass.parent
  end
  chain
end

.app_desc(text = nil) ⇒ Object

Top-level description for the whole CLI. Set from a Hammerfile (block DSL) via ‘desc ’text’‘ at top level - see `Hammer::Builder#desc`. Rendered under the Usage line in `–help` output.



118
119
120
121
# File 'lib/lux-hammer.rb', line 118

def app_desc(text = nil)
  return @app_desc if text.nil?
  @app_desc = text.to_s.rstrip
end

.before(&block) ⇒ Object

Register a hook to run before every command in this class (root or namespace). Hooks receive the command’s ‘opts` hash. All hooks run outer -> inner, once per top-level `start` (prereqs don’t re-trigger).

before { |opts| Dotenv.load }
namespace :db do
  before { hammer :env }
  task :migrate do ... end
end


224
225
226
# File 'lib/lux-hammer.rb', line 224

def before(&block)
  before_hooks << block
end

.before_hooksObject



228
229
230
# File 'lib/lux-hammer.rb', line 228

def before_hooks
  @before_hooks
end

.cli(argv = ARGV) ⇒ Object

Entry point for the ‘hammer` binary. Walks up from CWD until it finds a Hammerfile, evaluates it as the block DSL, then dispatches ARGV against the resulting CLI.

‘–system` forces the no-Hammerfile branch even from inside a project - the escape hatch for reaching `recipes`/`init` when a user-defined task tree would otherwise own the root.



990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
# File 'lib/lux-hammer.rb', line 990

def self.cli(argv = ARGV)
  argv = argv.dup
  force_system = !!argv.delete('--system')

  # Shebang invocation: `hammer /path/to/script ...args` (kernel passes
  # the script path as argv[0] for `#!/usr/bin/env hammer` files).
  # Treat the script as a self-contained CLI: no Hammerfile lookup, no
  # chdir (commands run in the caller's cwd), no `hammer`-binary
  # built-ins/banners. Detection requires a `#!`+`hammer` first line so
  # task names that happen to be paths don't get hijacked.
  if (script = shebang_script(argv.first))
    argv.shift
    return run_shebang(script, argv)
  end

  path = force_system ? nil : find_hammerfile(Dir.pwd)
  unless path
    # No Hammerfile (or --system) - all built-ins are reachable. Bare
    # `hammer`, `hammer recipes`, `hammer update`, `hammer agents`,
    # `hammer version`, `hammer init` all work.
    if force_system || dispatches_to_builtin?(argv) || looks_like_builtin?(argv)
      klass = Class.new(Hammer)
      klass.instance_variable_set(:@hammer_binary, true)
      klass.program_name
      require_relative 'hammer/builtins'
      Hammer::Builtins.register_core(klass)
      Hammer::Builtins.register_no_project(klass)
      klass.start(argv)
      return
    end

    Shell.print_error "no Hammerfile found in #{Dir.pwd} or any parent directory"

    # Heuristic: *.rb files referencing `Hammer.` are likely inline CLIs
    # the user could promote into a Hammerfile.
    excludes = %w[.git node_modules tmp vendor coverage dist build]
               .map { |d| "--exclude-dir=#{d}" }.join(' ')
    candidates = `grep -rl --include='*.rb' #{excludes} 'Hammer\\.' . 2>/dev/null`
                 .lines.map(&:strip).reject(&:empty?)
    unless candidates.empty?
      Shell.say "possible CLI implementation(s) - files referencing `Hammer.`:", :yellow
      candidates.first(10).each { |f| Shell.say "  #{f.sub(%r{\A\./}, '')}" }
      Shell.say ''
    end

    Shell.say "create one - example:"
    puts
    Shell.say STARTER_HAMMERFILE
    Shell.say ''
    bin = File.basename($PROGRAM_NAME)
    Shell.say "tip: run `#{bin} init` to drop the example above into ./Hammerfile", :gray
    Shell.say "tip: run `#{bin} agents` for AI-friendly Hammerfile authoring docs", :gray
    exit 1
  end

  klass = Class.new(Hammer)
  # Mark this class as the `hammer` binary's root so help output can
  # surface binary-only sections (`Recipes:` listing).
  klass.instance_variable_set(:@hammer_binary, true)
  # Resolve before chdir so paths like `bin/foo` stay relative to the
  # cwd the user actually invoked from. `program_name` memoizes.
  klass.program_name

  # chdir into the Hammerfile's directory for the entire run so commands
  # operate on the project root (Rake-style).
  Dir.chdir(File.dirname(path))
  Builder.new(klass).evaluate(File.read(path), path)
  # Auto-load `.env` / `.env.local` after eval so a top-level
  # `dotenv false` in the Hammerfile can suppress it. Trade-off: vars
  # are NOT visible during Hammerfile evaluation, only inside handlers.
  Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?

  # Core built-ins register AFTER Hammerfile eval so user-defined
  # tasks win (the `unless commands.key?(...)` guards in register_core
  # skip the built-in when overridden - no redefinition warning).
  # `register_no_project` (:recipes, :init) is intentionally NOT
  # called here - those would clash too easily with user tasks. Use
  # `hammer --system recipes` to reach them from inside a project.
  require_relative 'hammer/builtins'
  Hammer::Builtins.register_core(klass)

  klass.start(argv)
end

.commandsObject



281
282
283
# File 'lib/lux-hammer.rb', line 281

def commands
  @commands ||= {}
end

.default_program_nameObject

Program name shown in help/usage: the invocation path relative to cwd if the script lives inside it (e.g. ‘bin/foo` when invoked from the project root), otherwise the basename (e.g. `lux` for a globally installed bin in PATH).



127
128
129
130
131
132
133
134
135
136
# File 'lib/lux-hammer.rb', line 127

def default_program_name
  prog = $PROGRAM_NAME
  return File.basename(prog) unless prog.include?('/')
  # Resolve symlinks on both sides so e.g. macOS `/tmp` -> `/private/tmp`
  # doesn't cause a false miss when comparing prefixes.
  abs = File.realpath(prog) rescue File.expand_path(prog)
  cwd = File.realpath(Dir.pwd) rescue Dir.pwd
  return abs[(cwd.length + 1)..] if abs.start_with?("#{cwd}/")
  File.basename(prog)
end

.desc(text) ⇒ Object

—– class-level DSL for ‘def`-style commands ——————— Set pending metadata that the next `def` will consume.

class MyCli < Hammer
  desc 'Build'
  opt :env, default: 'dev'
  def build(opts)
    say "building #{opts[:env]}"
  end
end


78
# File 'lib/lux-hammer.rb', line 78

def desc(text)       ; @pending_desc = text.to_s.rstrip end

.dispatches_to_builtin?(argv) ⇒ Boolean

True if argv goes through a built-in dispatch path (‘:default` or `:help`) - meaning bare `hammer`, leading-flag invocations like `hammer -h`, or explicit help requests. These don’t need a project Hammerfile to run.

Returns:

  • (Boolean)


1105
1106
1107
1108
1109
# File 'lib/lux-hammer.rb', line 1105

def self.dispatches_to_builtin?(argv)
  return true if argv.empty?
  first = argv.first
  first == 'help' || first == '-h' || first == '--help' || first.start_with?('-')
end

.dotenv(flag = true) ⇒ Object

Toggle auto-loading of ‘.env` / `.env.local` for the `hammer` binary. Default is ON. Call `dotenv false` at the top of a Hammerfile to suppress. No-op for standalone `MyCli.start` - auto-load only fires from `Hammer.cli`.



236
237
238
# File 'lib/lux-hammer.rb', line 236

def dotenv(flag = true)
  @dotenv_enabled = flag
end

.dotenv_enabled?Boolean

Returns:

  • (Boolean)


240
241
242
# File 'lib/lux-hammer.rb', line 240

def dotenv_enabled?
  @dotenv_enabled != false
end

.each_command(prefix = nil, &block) ⇒ Object

Yield [full_colon_path, Command] for every command in this class and all nested namespaces.



535
536
537
538
539
540
541
542
543
544
# File 'lib/lux-hammer.rb', line 535

def each_command(prefix = nil, &block)
  commands.each_value do |c|
    full = prefix ? "#{prefix}:#{c.name}" : c.name
    yield full, c
  end
  namespaces.each do |ns_name, sub|
    sub_prefix = prefix ? "#{prefix}:#{ns_name}" : ns_name
    sub.each_command(sub_prefix, &block)
  end
end

.emit_rows(rows, width) ⇒ Object



776
777
778
779
780
781
782
# File 'lib/lux-hammer.rb', line 776

def emit_rows(rows, width)
  rows.each do |full, c|
    brief = c.alts.empty? ? c.brief : "#{c.brief} (alt: #{c.alts.join(', ')})"
    brief = "#{brief} #{Shell.paint('(redefined)', :yellow)}" if c.prev_location
    Shell.say "  #{program_name} #{full.ljust(width)}  # #{brief}"
  end
end

.example(text) ⇒ Object



79
# File 'lib/lux-hammer.rb', line 79

def example(text)    ; @pending_examples << text end

.find_command(name) ⇒ Object

Find a command by canonical name or alt within this class. Falls back to fuzzy match (prefix first, then substring) when no exact hit. Raises AmbiguousMatch if the fuzzy pass matches more than one.



429
430
431
432
433
434
# File 'lib/lux-hammer.rb', line 429

def find_command(name)
  name = name.to_s
  exact = commands[name] || commands.values.find { |c| c.matches?(name) }
  return exact if exact
  fuzzy_pick(name, commands.values, 'command') { |c| [c.name, *c.alts] }
end

.find_hammerfile(start) ⇒ Object

Walk up the directory tree looking for a Hammerfile.



1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
# File 'lib/lux-hammer.rb', line 1123

def self.find_hammerfile(start)
  dir = File.expand_path(start)
  loop do
    candidate = File.join(dir, 'Hammerfile')
    return candidate if File.file?(candidate)
    parent = File.dirname(dir)
    return nil if parent == dir
    dir = parent
  end
end

.find_namespace(name) ⇒ Object

Find a namespace by name within this class. Same fuzzy fallback as find_command.



438
439
440
441
442
443
# File 'lib/lux-hammer.rb', line 438

def find_namespace(name)
  name = name.to_s
  return namespaces[name] if namespaces.key?(name)
  pair = fuzzy_pick(name, namespaces.to_a, 'namespace') { |p| [p.first] }
  pair&.last
end

.find_namespace_sibling(canonical) ⇒ Object

Returns the command at the parent that shares its name with this namespace. E.g. for path “gem:version” returns the ‘version` command in the `gem` namespace (if defined), so `gem:version:` listings can include `gem:version` itself at the top.



449
450
451
452
453
454
455
456
457
# File 'lib/lux-hammer.rb', line 449

def find_namespace_sibling(canonical)
  parts = canonical.to_s.split(':')
  return nil if parts.empty?
  parent = self
  parts[0..-2].each do |seg|
    parent = parent.namespaces[seg] or return nil
  end
  parent.commands[parts.last]
end

.fuzzy_pick(name, items, kind, &keys_for) ⇒ Object

Shared fuzzy matcher used by find_command and find_namespace. The block returns the strings to match against for each item (canonical name plus alts for commands, just the key for namespaces). Tries prefix match first, then substring; raises AmbiguousMatch when either pass hits more than one item.



497
498
499
500
501
502
503
504
505
506
507
508
# File 'lib/lux-hammer.rb', line 497

def fuzzy_pick(name, items, kind, &keys_for)
  [:start_with?, :include?].each do |op|
    matches = items.select { |item| keys_for.call(item).any? { |k| k.send(op, name) } }
    next if matches.empty?
    if matches.size > 1
      labels = matches.map { |m| keys_for.call(m).first }.sort
      raise AmbiguousMatch, "multiple #{kind}s match '#{name}': #{labels.join(', ')}"
    end
    return matches.first
  end
  nil
end

.hammer(name, *args, **opts) ⇒ Object

Programmatic dispatch by name. Useful for scripting and tests.

MyCli.hammer :build                       -> start(["build"])
MyCli.hammer 'db:users:list'              -> start(["db:users:list"])
MyCli.hammer :eval, 'puts 42'             -> start(["eval", "puts 42"])
MyCli.hammer :build, env: 'prod'          -> start(["build", "--env=prod"])
MyCli.hammer :build, verbose: true        -> start(["build", "--verbose"])
MyCli.hammer :build, no_cache: true       -> start(["build", "--no-cache"])
MyCli.hammer :build, cache: false         -> skipped (no-op)

Symbols are single-segment names; pass a string with colons for namespaced paths. Trailing positionals become positional ARGV. Underscores in option keys become dashes in flags.



523
524
525
526
527
528
529
530
531
# File 'lib/lux-hammer.rb', line 523

def hammer(name, *args, **opts)
  argv = [name.to_s, *args.map(&:to_s)]
  opts.each do |k, v|
    next if v == false
    flag = "--#{k.to_s.tr('_', '-')}"
    argv << (v == true ? flag : "#{flag}=#{v}")
  end
  start(argv)
end

.inherited(sub) ⇒ Object



52
53
54
55
56
57
58
59
60
61
62
63
64
65
# File 'lib/lux-hammer.rb', line 52

def inherited(sub)
  super
  sub.instance_variable_set(:@commands, {})
  sub.instance_variable_set(:@namespaces, {})
  sub.instance_variable_set(:@before_hooks, [])
  sub.instance_variable_set(:@parent, nil)
  sub.instance_variable_set(:@program_name, nil)
  sub.instance_variable_set(:@app_desc, nil)
  sub.instance_variable_set(:@pending_desc, nil)
  sub.instance_variable_set(:@pending_examples, [])
  sub.instance_variable_set(:@pending_options, [])
  sub.instance_variable_set(:@pending_alts, [])
  sub.instance_variable_set(:@pending_needs, [])
end

.load(*paths, **kwargs) ⇒ Object

Load Hammerfile fragments and register their commands on this class. Rake-style: split a CLI across multiple files.

load                        # auto-discover *_hammer.rb under caller dir
load auto: true             # same
load 'tasks/db_hammer.rb'   # one file
load 'tasks/*_hammer.rb'    # glob

Paths resolve relative to the file calling ‘load`. See `Hammer::Loader` for the full implementation.



317
318
319
320
321
322
323
324
# File 'lib/lux-hammer.rb', line 317

def load(*paths, **kwargs)
  if self == Hammer
    raise Error, 'use `load` from inside a Hammerfile / Hammer.run block / Hammer subclass body, ' \
                 'or call SubClass.load - Hammer.load itself has no target'
  end
  anchor = Loader.caller_anchor(caller_locations(1, 1).first)
  loader.load(anchor, paths, kwargs)
end

.loaderObject

Per-target Loader instance. Owns the dedup cache, so re-entrant ‘load` from inside a fragment is safe and idempotent.



291
292
293
# File 'lib/lux-hammer.rb', line 291

def loader
  @loader ||= Loader.new(self)
end

.looks_like_builtin?(argv) ⇒ Boolean

Returns:

  • (Boolean)


1116
1117
1118
1119
1120
# File 'lib/lux-hammer.rb', line 1116

def self.looks_like_builtin?(argv)
  first = argv.first
  return false unless first
  BUILTIN_TASKS.include?(first) || BUILTIN_TASKS.any? { |t| first.start_with?("#{t}:") }
end

.method_added(method_name) ⇒ Object



84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/lux-hammer.rb', line 84

def method_added(method_name)
  super
  return unless @pending_desc

  cmd = Command.new(name: method_name.to_s, desc: @pending_desc)
  @pending_examples.each { |e| cmd.add_example(e) }
  @pending_options.each  { |o| cmd.add_option(o) }
  @pending_alts.each     { |n| cmd.add_alt(n) }
  @pending_needs.each    { |n| cmd.add_need(n) }

  # If the method takes no args, call it without opts. Otherwise pass
  # opts. So both `def build` and `def build(opts)` work.
  m = method_name
  arity = instance_method(method_name).arity
  cmd.handler = arity.zero? ? proc { send(m) } : proc { |opts| send(m, opts) }
  commands[cmd.name] = cmd

  @pending_desc = nil
  @pending_examples = []
  @pending_options  = []
  @pending_alts     = []
  @pending_needs    = []
end

.namespace(name, &block) ⇒ Object

Open a namespace (group of commands). Everything inside the block (task, nested namespace, …) belongs to that namespace, evaluated against an anonymous Hammer subclass.

namespace :db do
  task :migrate do ... end
  namespace :users do ... end
end


191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/lux-hammer.rb', line 191

def namespace(name, &block)
  sub = Class.new(Hammer)
  # Track the top-level CLI class so cross-invocation
  # (`hammer 'ns:cmd'`) from inside a namespaced command dispatches
  # against the full tree, not just the current namespace.
  sub.instance_variable_set(:@root, root)
  # Parent link, so `before` hooks defined further up the namespace
  # tree can be collected and run outer -> inner before a command.
  sub.instance_variable_set(:@parent, self)
  # Share the parent's resolved program_name so help banners show
  # "myapp ns:cmd" with the same prefix everywhere - and so the value
  # captured pre-chdir (see `Hammer.cli`) survives into nested classes.
  sub.instance_variable_set(:@program_name, program_name)
  sub.instance_variable_set(:@location, source_location_of(block))
  Hammer.with_target(sub) { sub.class_eval(&block) } if block

  if (prev = @namespaces[name.to_s])
    sub.instance_variable_set(:@prev_location, prev.instance_variable_get(:@location))
    warn_redefinition('namespace', name.to_s, prev.instance_variable_get(:@location), sub.instance_variable_get(:@location))
  end

  @namespaces[name.to_s] = sub
end

.namespacesObject



285
286
287
# File 'lib/lux-hammer.rb', line 285

def namespaces
  @namespaces ||= {}
end

.needs(*names) ⇒ Object



82
# File 'lib/lux-hammer.rb', line 82

def needs(*names)    ; @pending_needs.concat(names) end

.opt(name, **o) ⇒ Object



80
# File 'lib/lux-hammer.rb', line 80

def opt(name, **o)   ; @pending_options << Option.new(name, **o) end

.parentObject



244
245
246
# File 'lib/lux-hammer.rb', line 244

def parent
  @parent
end

Dump the gem’s AGENTS.md to stdout - AI-optimized guide for writing Hammerfiles. Bundled with the gem and resolved relative to this file so it works from any install location.



904
905
906
907
908
909
910
911
912
# File 'lib/lux-hammer.rb', line 904

def self.print_ai_help
  path = File.expand_path('../AGENTS.md', __dir__)
  if File.file?(path)
    puts File.read(path)
  else
    Shell.print_error "AGENTS.md not found at #{path}"
    exit 1
  end
end


816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
# File 'lib/lux-hammer.rb', line 816

def print_command_help(cmd, full = nil)
  full ||= cmd.name
  Shell.say "Usage: #{program_name} #{full}#{usage_signature(cmd)}", :cyan
  cmd.desc.each_line do |line|
    stripped = line.chomp
    Shell.say(stripped.empty? ? '' : "  #{stripped}")
  end unless cmd.desc.empty?
  Shell.say "  alias: #{cmd.alts.join(', ')}" unless cmd.alts.empty?
  unless cmd.options.empty?
    Shell.say ''
    Shell.say 'Options:', :yellow
    cmd.options.each { |o| Shell.say "  #{o.usage}" }
  end
  unless cmd.examples.empty?
    Shell.say ''
    Shell.say 'Examples:', :yellow
    cmd.examples.each { |e| Shell.say "  #{program_name} #{e}" }
  end
end


748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
# File 'lib/lux-hammer.rb', line 748

def print_command_list(klass, prefix = nil)
  rows = []
  # Commands without a `desc` are hidden from listings but still
  # dispatchable + `hammer`-callable - useful for private helpers
  # invoked from `before` hooks or other commands (e.g. `:env`, `:app`).
  klass.each_command(prefix) { |full, c| rows << [full, c] unless c.desc.empty? }
  return if rows.empty?

  # group by "section" = everything between the view prefix and the
  # leaf name. Bare leaves go in :root.
  groups = rows.group_by { |full, _| section_for(full, prefix, klass) }
  width  = rows.map { |full, _| full.length }.max
  first  = true

  if (rooted = groups.delete(:root))
    Shell.say 'Commands:', :yellow
    emit_rows(rooted.sort_by { |full, _| [full.count(':'), full] }, width)
    first = false
  end

  groups.each do |section, items|
    Shell.say unless first
    first = false
    Shell.say "#{section}:", :yellow
    emit_rows(items.sort_by { |full, _| [full.count(':'), full] }, width)
  end
end

Extras shown only in the extended (‘help` / `-h` / `–help`) view: global flags, GitHub footer, and a Hammerfile example for the `hammer` binary. The footer is skipped for the hammer binary because `print_top_banner` already surfaces the same link.



715
716
717
718
719
720
# File 'lib/lux-hammer.rb', line 715

def print_extras
  hammer_bin = root.instance_variable_get(:@hammer_binary)
  print_global_flags
  print_hammerfile_example if hammer_bin
  print_footer unless hammer_bin
end


734
735
736
737
# File 'lib/lux-hammer.rb', line 734

def print_footer
  Shell.say ''
  Shell.say "powered by hammer - #{HOMEPAGE}", :gray
end

One “task block” for the expanded listing: blank line separator then the standard per-command help (usage + desc + options + examples).



694
695
696
697
# File 'lib/lux-hammer.rb', line 694

def print_full_block(path, cmd)
  Shell.say ''
  print_command_help(cmd, path)
end

Listed under ‘Default task options:` in `–help` so users see what flags fire on bare-flag invocation (`hammer –version` etc). Re-rendered from the live `:default` task so user-defined overrides surface their own flags here automatically.



726
727
728
729
730
731
732
# File 'lib/lux-hammer.rb', line 726

def print_global_flags
  default = root.commands['default']
  return unless default && !default.options.empty?
  Shell.say ''
  Shell.say 'Default task options:', :yellow
  default.options.each { |o| Shell.say "  #{o.usage}" }
end

Hammerfile cheat-sheet shown under ‘hammer –help`. Same content as `hammer –init` writes - single source of truth via `Hammer::STARTER_HAMMERFILE`. For exhaustive docs see `hammer agents`.



742
743
744
745
746
# File 'lib/lux-hammer.rb', line 742

def print_hammerfile_example
  Shell.say ''
  Shell.say 'Hammerfile example:', :yellow
  Shell.say Hammer::STARTER_HAMMERFILE
end

‘extended: true` is the verbose `help` / `-h` / `–help` form - appends global flags, the GitHub footer, and (for the hammer binary) a Hammerfile example. Bare invocation passes `extended: false` so the no-args output stays a clean command listing.



621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
# File 'lib/lux-hammer.rb', line 621

def print_help(target = nil, expanded: false, extended: false)
  if target
    # `help ns:` is equivalent to `ns:` - namespace listing.
    if target.end_with?(':') && target != ':'
      bare = target.chomp(':')
      ns, canonical = resolve_namespace(bare)
      return print_namespace_help(canonical, ns) if ns
      Shell.print_error("unknown: #{target}")
      return
    end
    cmd, _, canonical = resolve(target)
    return print_command_help(cmd, canonical) if cmd
    ns, canonical = resolve_namespace(target)
    return print_namespace_help(canonical, ns, expanded: expanded) if ns
    Shell.print_error("unknown: #{target}")
    return
  end

  print_top_banner
  Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan
  if @app_desc && !@app_desc.empty?
    Shell.say ''
    @app_desc.each_line { |l| Shell.say "  #{l.chomp}" }
  end
  if expanded
    each_command { |path, c| print_full_block(path, c) unless c.desc.empty? }
  else
    Shell.say ''
    print_command_list(self)
  end
  print_recipes_section if extended && root.instance_variable_get(:@hammer_binary)
  print_extras if extended
end

‘extended:` is accepted for parity with `print_help` but intentionally not used here - the global-flags / Hammerfile-example / footer block is root-help-only. `expanded:` is also accepted for parity; a namespace listing is always the compact command list.



678
679
680
681
682
683
684
685
686
687
688
689
690
# File 'lib/lux-hammer.rb', line 678

def print_namespace_help(prefix, ns, expanded: false, extended: false)
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
  rows = []
  sibling = find_namespace_sibling(prefix)
  rows << [prefix, sibling] if sibling && !sibling.desc.empty?
  ns.each_command(prefix) { |path, c| rows << [path, c] unless c.desc.empty? }
  unless rows.empty?
    Shell.say ''
    Shell.say 'Commands:', :yellow
    width = rows.map { |path, _| path.length }.max
    emit_rows(rows.sort_by { |path, _| [path.count(':'), path] }, width)
  end
end

Lists recipes (gem + user-dir) under their own section in ‘hammer –help`. Each row shows the recipe’s ‘# desc:` line and either an install hint or the path of the existing stub on PATH. Only rendered when this CLI is the `hammer` binary’s root.



659
660
661
662
663
664
665
666
667
668
669
670
671
672
# File 'lib/lux-hammer.rb', line 659

def print_recipes_section
  entries = Hammer::Recipe.all
  return if entries.empty?
  Shell.say ''
  Shell.say 'Recipes:', :yellow
  width = entries.keys.map(&:length).max
  entries.each do |name, file|
    desc = Hammer::Recipe.desc(file)
    installed = Hammer::Recipe.installed_path(name)
    suffix = installed ? "(installed: #{installed})" : "[install: #{program_name} recipes --install #{name}]"
    Shell.say "  #{name.ljust(width)}  # #{desc}"
    Shell.say "  #{' ' * width}    #{suffix}", :gray
  end
end

Print a gray “> prog cmd –opt=val ARG” banner before a command runs. Helps see what was actually picked when fuzzy matching resolved a partial name. Only opts that differ from their default are shown; booleans render as ‘–flag` / `–no-flag`.



573
574
575
576
577
578
579
580
581
582
583
584
585
586
# File 'lib/lux-hammer.rb', line 573

def print_run_banner(cmd, full, positional, opts)
  parts = ["#{program_name} #{full}"]
  cmd.options.each do |o|
    val = opts[o.name]
    next if val.nil? || val == o.default
    if o.boolean?
      parts << (val ? "--#{o.name}" : "--no-#{o.name}")
    else
      parts << "--#{o.name}=#{val}"
    end
  end
  parts.concat(positional)
  Shell.say "> #{parts.join(' ')}", :gray
end

Gray “lux-hammer X.Y.Z - <homepage>” line shown above top-level help in both bare-invocation and ‘–help` modes, so the link is always one glance away. User CLIs skip it (the lux-hammer name/link is irrelevant outside the `hammer` binary).



705
706
707
708
709
# File 'lib/lux-hammer.rb', line 705

def print_top_banner
  return unless root.instance_variable_get(:@hammer_binary)
  Shell.say "lux-hammer #{VERSION} - #{HOMEPAGE}", :gray
  Shell.say ''
end

.program_nameObject

Resolved lazily on first read and memoized, so callers that need the cwd-relative form (see ‘default_program_name`) can warm the cache before chdir-ing elsewhere.



111
112
113
# File 'lib/lux-hammer.rb', line 111

def program_name
  @program_name ||= default_program_name
end

.recipe(name, argv = ARGV) ⇒ Object

Entry point for recipe stubs in PATH. A recipe is a standalone Hammerfile-style script bundled with the gem (or in ~/.config/hammer/recipes/) that is exposed as its own bin via a tiny Ruby wrapper containing:

require 'lux-hammer'
Hammer.recipe(:srt, ARGV)

The recipe runs as a self-contained CLI: program_name is the recipe name, only its own tasks show in –help, no global hammer commands appear. Runs in the caller’s cwd (no chdir, no Hammerfile lookup).



885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
# File 'lib/lux-hammer.rb', line 885

def self.recipe(name, argv = ARGV)
  path = Recipe.path(name)
  unless path
    Shell.print_error "unknown recipe: #{name}"
    Shell.say 'available recipes:', :yellow
    Recipe.all.keys.sort.each { |n| Shell.say "  #{n}" }
    Shell.say 'try `hammer recipes` to list with descriptions', :gray
    exit 1
  end

  klass = Class.new(Hammer)
  klass.instance_variable_set(:@program_name, name.to_s)
  Builder.new(klass).evaluate(File.read(path), path)
  klass.start(argv)
end

.resolve(path) ⇒ Object

Walk “ns1:ns2:cmd” -> [command, owning_class, canonical_path]. Returns [nil, nil, nil] if any segment is missing or the final segment isn’t a command. canonical_path uses the canonical name of every segment (so a fuzzy ‘b` resolves to `build` in the path).



463
464
465
466
467
468
469
470
471
472
473
474
475
476
# File 'lib/lux-hammer.rb', line 463

def resolve(path)
  parts = path.to_s.split(':')
  klass = self
  canonical = []
  parts[0..-2].each do |ns|
    sub = klass.find_namespace(ns) or return [nil, nil, nil]
    canonical << klass.namespaces.key(sub)
    klass = sub
  end
  cmd = klass.find_command(parts.last)
  return [nil, nil, nil] unless cmd
  canonical << cmd.name
  [cmd, klass, canonical.join(':')]
end

.resolve_namespace(path) ⇒ Object

Walk “ns1:ns2” -> [namespace_class, canonical_path]. Returns [nil, nil] if any segment is missing.



480
481
482
483
484
485
486
487
488
489
490
# File 'lib/lux-hammer.rb', line 480

def resolve_namespace(path)
  parts = path.to_s.split(':')
  klass = self
  canonical = []
  parts.each do |ns|
    sub = klass.find_namespace(ns) or return [nil, nil]
    canonical << klass.namespaces.key(sub)
    klass = sub
  end
  [klass, canonical.join(':')]
end

.rootObject

Topmost class in this CLI tree. For user-defined ‘class MyCli < Hammer` or `Class.new(Hammer)` it’s self; for namespace subclasses it’s whichever class opened the namespace.



277
278
279
# File 'lib/lux-hammer.rb', line 277

def root
  @root || self
end

.run(argv = ARGV, &block) ⇒ Object

Define and run a CLI inline. Inside the block use ‘task :name do … end`, `namespace`, and `load`.

Without a block: load ./Hammerfile if it exists, otherwise auto-discover *_hammer.rb under Dir.pwd, then dispatch ARGV.



859
860
861
862
863
864
865
866
867
868
869
870
871
872
# File 'lib/lux-hammer.rb', line 859

def self.run(argv = ARGV, &block)
  klass = Class.new(Hammer)
  if block
    Builder.new(klass).evaluate(&block)
  else
    hf = File.join(Dir.pwd, 'Hammerfile')
    if File.file?(hf)
      Builder.new(klass).evaluate(File.read(hf), hf)
    else
      klass.loader.load(Dir.pwd, [], auto: true)
    end
  end
  klass.start(argv)
end

.run_before_hooks(instance, opts) ⇒ Object

Fire ‘before` hooks from root down through the namespace chain. Each class’s hooks fire at most once per top-level ‘start`, so prereqs dispatched via `needs` won’t re-trigger them.



591
592
593
594
595
596
597
598
# File 'lib/lux-hammer.rb', line 591

def run_before_hooks(instance, opts)
  ran = Thread.current[:hammer_before_ran] ||= {}
  ancestor_chain.each do |klass|
    next if ran[klass.object_id]
    ran[klass.object_id] = true
    klass.before_hooks.each { |hook| instance.instance_exec(opts, &hook) }
  end
end

.run_command(cmd, argv, full: nil, quiet: false) ⇒ Object



546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
# File 'lib/lux-hammer.rb', line 546

def run_command(cmd, argv, full: nil, quiet: false)
  # -h / --help is reserved on every command. Anywhere before a `--`
  # stop-marker, it short-circuits to per-command help.
  return print_command_help(cmd, full) if help_requested?(argv)

  positional, opts = Parser.new(cmd.options).parse(argv)
  opts[:args] = positional
  print_run_banner(cmd, full || cmd.name, positional, opts) unless quiet || ENV['HAMMER_QUIET']
  instance = new
  run_before_hooks(instance, opts)
  run_needs(cmd)
  instance.instance_exec(opts, &cmd.handler)
rescue Parser::Error => e
  Shell.print_error(e.message)
  print_command_help(cmd, full)
  exit 1
rescue Hammer::Error => e
  # Raised by `error 'msg'` inside a handler - controlled exit, no
  # backtrace, no per-command help spam.
  Shell.print_error(e.message)
  exit 1
end

.run_needs(cmd) ⇒ Object

Dispatch a command’s declared ‘needs` through the root class, with per-invocation dedupe. Prereqs run with default options (no argv).



602
603
604
605
606
607
608
609
610
611
612
613
# File 'lib/lux-hammer.rb', line 602

def run_needs(cmd)
  return if cmd.needs.empty?
  ran = Thread.current[:hammer_needs_ran] ||= {}
  cmd.needs.each do |path|
    key = path.to_s
    next if ran[key]
    ran[key] = true
    target, = root.resolve(key)
    raise Error, "needs: unknown command '#{key}' in #{cmd.name}" unless target
    root.start([key])
  end
end

.run_or_exit(*cmd, **opts) ⇒ Object



977
978
979
980
981
# File 'lib/lux-hammer.rb', line 977

def self.run_or_exit(*cmd, **opts)
  return if system(*cmd, **opts)
  Shell.print_error "command failed: #{cmd.join(' ')}"
  exit 1
end

.run_shebang(path, argv) ⇒ Object

Evaluate a shebang script as a self-contained CLI. Mirrors ‘recipe` semantics: no chdir, no `@hammer_binary` flag, no `register_core` built-ins (so the script’s ‘–help` shows only what it defines). `program_name` is the script’s basename so help reads “myscript foo” rather than “hammer foo” - works even when invoked via a symlink in PATH, since argv is the path the user typed.



1094
1095
1096
1097
1098
1099
# File 'lib/lux-hammer.rb', line 1094

def self.run_shebang(path, argv)
  klass = Class.new(Hammer)
  klass.instance_variable_set(:@program_name, File.basename(path))
  Builder.new(klass).evaluate(File.read(path), path)
  klass.start(argv)
end

.section_for(full, prefix, klass = nil) ⇒ Object

‘db’ for ‘db:migrate’ or ‘db:users:list’ viewed from root; ‘users’ for ‘db:users:list’ viewed from ‘db’; :root if the command sits at the view’s top level. Only the first segment under the view groups, so deeper paths fold into their top-level section.

Exception: a bare command that shares its name with a sibling namespace (e.g. ‘mount` alongside a `mount:` namespace) groups under that namespace’s section, not :root.



792
793
794
795
796
797
798
799
800
# File 'lib/lux-hammer.rb', line 792

def section_for(full, prefix, klass = nil)
  segs = full.split(':')
  segs = segs[prefix.split(':').size..] || [] if prefix && !prefix.empty?
  if segs.size == 1 && klass && klass.namespaces.key?(segs.first)
    return segs.first
  end
  parent = segs[0..-2]
  parent.empty? ? :root : parent.first
end

.self_updateObject

‘hammer update`: pull main in the install-script checkout and reinstall the gem. Assumes the install.sh layout - if the dir is missing, point the user at the curl-pipe installer.



955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
# File 'lib/lux-hammer.rb', line 955

def self.self_update
  dir = ENV['LUX_HAMMER_DIR'] || SELF_UPDATE_DIR
  unless File.directory?(File.join(dir, '.git'))
    Shell.print_error "no lux-hammer git checkout at #{dir}"
    Shell.say 'reinstall with:', :yellow
    Shell.say "  curl -fsSL #{SELF_INSTALL_URL} | bash"
    exit 1
  end

  Shell.say "* updating lux-hammer at #{dir}", :cyan
  Dir.chdir(dir) do
    run_or_exit('git', 'fetch', '--quiet', 'origin', 'main')
    run_or_exit('git', 'reset', '--quiet', '--hard', 'origin/main')
    version = File.read('.version').strip
    gem_file = "lux-hammer-#{version}.gem"
    run_or_exit('gem', 'build', 'lux-hammer.gemspec', out: File::NULL)
    run_or_exit('gem', 'install', '--quiet', gem_file)
    File.unlink(gem_file) if File.exist?(gem_file)
    Shell.say "* lux-hammer #{version} installed", :green
  end
end

.shebang_script(arg) ⇒ Object

Returns the script path if ‘arg` looks like a shebang script that delegates to hammer (first line starts with `#!` and mentions `hammer`). Returns nil otherwise. Used by `cli` to detect `#!/usr/bin/env hammer` invocations where the kernel passes the script path as argv.



1079
1080
1081
1082
1083
1084
1085
1086
# File 'lib/lux-hammer.rb', line 1079

def self.shebang_script(arg)
  return nil unless arg
  return nil if arg.start_with?('-')
  return nil unless File.file?(arg) && File.readable?(arg)
  head = File.open(arg, &:gets).to_s
  return nil unless head.start_with?('#!') && head.include?('hammer')
  arg
end

.source_location_of(block) ⇒ Object

“file:line” of the block that defined a task/namespace. Falls back to “(unknown)” for blocks without a usable source_location (rare - built-in C-defined procs, eval’d blocks).



251
252
253
254
# File 'lib/lux-hammer.rb', line 251

def source_location_of(block)
  loc = block&.source_location
  loc ? "#{loc[0]}:#{loc[1]}" : '(unknown)'
end

.start(argv = ARGV) ⇒ Object

Entry point. Parses ARGV, finds the right command, runs it. Command names are Rake-style colon paths: “build”, “db:migrate”, “db:users:list”.

Rake-style chained dispatch: ‘hammer build + deploy + notify`. A bare `+` argv token separates commands; `++` escapes to a literal `+` positional. Quoted shell args (`–foo=“a + b”`) arrive as a single token and are not split.



334
335
336
337
338
339
340
341
342
343
344
345
346
347
# File 'lib/lux-hammer.rb', line 334

def start(argv = ARGV)
  # Track prereqs fired during this top-level invocation so a `needs`
  # chain runs each prereq at most once. Nested `start` calls (e.g.
  # `needs` -> `hammer` -> `start`, or a `+` chain) share the set;
  # the outermost call owns its lifetime.
  outer = Thread.current[:hammer_needs_ran].nil?
  Thread.current[:hammer_needs_ran] ||= {}
  Thread.current[:hammer_before_ran] ||= {}

  split_chain(argv).each { |seg| dispatch(seg) }
ensure
  Thread.current[:hammer_needs_ran] = nil if outer
  Thread.current[:hammer_before_ran] = nil if outer
end

.task(name, &block) ⇒ Object

Define a command. Block runs in a CommandBuilder context and must return a Proc as its last expression. That proc is the handler and receives a single ‘opts` hash with symbol keys; positional ARGV lives at `opts`.



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/lux-hammer.rb', line 142

def task(name, &block)
  cmd = Command.new(name: name.to_s)
  cmd.location = source_location_of(block)
  handler = CommandBuilder.new(cmd).instance_eval(&block)
  unless handler.is_a?(Proc)
    raise Error, <<~MSG
      task(:#{name}) block must end with a `proc do |opts| ... end`.
      The proc's return value is what becomes the command handler.

      Example:

        task :#{name} do
          desc    'what it does'
          example '#{name} foo --env=prod'
          opt :env, default: 'dev'

          proc do |opts|
            # your code here - opts[:env], opts[:args], ...
          end
        end
    MSG
  end
  cmd.handler = handler

  if (prev = commands[cmd.name])
    cmd.prev_location = prev.location
    warn_redefinition('task', cmd.name, prev.location, cmd.location)
  end

  commands[cmd.name] = cmd

  # `task` ignores pending class-level state, but clear it so a
  # later `def` doesn't accidentally consume stale metadata.
  @pending_desc = nil
  @pending_examples = []
  @pending_options  = []
  @pending_alts     = []
  @pending_needs    = []
end

.usage_signature(cmd) ⇒ Object

“ URL [ENV] [OPTIONS]” - shows the positional-fill names for declared non-boolean opts (required bare, optional bracketed), plus a generic [OPTIONS] tail if any flags exist.



805
806
807
808
809
810
811
812
813
814
# File 'lib/lux-hammer.rb', line 805

def usage_signature(cmd)
  pos = cmd.options.reject(&:boolean?).map { |o|
    name = o.name.to_s.upcase
    o.required ? name : "[#{name}]"
  }
  out = pos.join(' ')
  out = "#{out} ".lstrip unless out.empty?
  out += '[OPTIONS]' unless cmd.options.empty?
  out.empty? ? '' : " #{out}"
end

.warn_redefinition(kind, name, prev_loc, new_loc) ⇒ Object

Emit a yellow [hammer] warning on stderr when a task/namespace is redefined. Last write wins (commands = cmd), but the prior location is captured so listings can tag the entry as ‘(redefined)`.



259
260
261
# File 'lib/lux-hammer.rb', line 259

def warn_redefinition(kind, name, prev_loc, new_loc)
  warn Shell.paint("[hammer] redefined #{kind} :#{name} - was #{prev_loc || '(unknown)'}, now #{new_loc || '(unknown)'}", :yellow)
end

.with_target(klass) ⇒ Object

Push ‘klass` as the current Hammer target for the duration of the block. Top-level DSL methods (`task`, `namespace`, `before` - see `Hammer::DSL`) read this thread-local, so files `require`d from inside a Hammerfile register against the right target.



299
300
301
302
303
304
305
# File 'lib/lux-hammer.rb', line 299

def with_target(klass)
  prev = Thread.current[:hammer_target]
  Thread.current[:hammer_target] = klass
  yield
ensure
  Thread.current[:hammer_target] = prev
end

Instance Method Details

#hammer(name, *args, **opts) ⇒ Object

Inside a command’s ‘proc do |opts| … end`, call sibling commands:

task :deploy do
  proc do |opts|
    hammer :build
    hammer 'db:migrate', pretend: true
  end
end

Dispatches from the root class so colon paths resolve against the full tree even when called from inside a namespaced command.



848
849
850
# File 'lib/lux-hammer.rb', line 848

def hammer(name, *args, **opts)
  self.class.root.hammer(name, *args, **opts)
end