Class: Hammer

Inherits:
Object
  • Object
show all
Includes:
Shell
Defined in:
lib/lux-hammer.rb,
lib/hammer/cron.rb,
lib/hammer/input.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/cron_web.rb,
lib/hammer/cron_server.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, Input, Recipe, Shell Classes: Builder, Command, CommandBuilder, Cron, CronServer, CronWeb, 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



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

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

.ancestor_chainObject

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



352
353
354
355
356
357
358
359
360
# File 'lib/lux-hammer.rb', line 352

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.



136
137
138
139
# File 'lib/lux-hammer.rb', line 136

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

.app_local_location?(loc) ⇒ Boolean

True when a captured location lives inside the main app. relativize_path rewrites in-app absolute paths to a .-relative form, so anything still starting with "/" is an absolute path outside cwd - a framework, plugin, or gem file. Relative locations are already cwd-anchored, hence local.

Returns:

  • (Boolean)


340
341
342
# File 'lib/lux-hammer.rb', line 340

def app_local_location?(loc)
  !loc.to_s.start_with?('/')
end

.attach_stdin!(opts) ⇒ Object



59
60
61
# File 'lib/lux-hammer.rb', line 59

def self.attach_stdin!(opts)
  Input.attach_stdin!(opts)
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 }
before { |opts| Hammer::Input.prepare_json!(opts) }
namespace :db do
before { hammer :env }
task :migrate do ... end
end


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

def before(&block)
  before_hooks << block
end

.before_hooksObject



254
255
256
# File 'lib/lux-hammer.rb', line 254

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.



1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
# File 'lib/lux-hammer.rb', line 1163

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

  # 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)

  # `hammer --gui` opens the native macOS runner pointed at this project
  # (the Hammerfile's dir, or cwd when none was found). The CLI just
  # launches the bundled app and returns.
  return launch_gui!(path ? File.dirname(path) : Dir.pwd) if launch_gui

  unless path
    # No Hammerfile (or --system) - all built-ins are reachable. Bare
    # `hammer`, `hammer h:recipes`, `hammer h:update`, `hammer h:agents`,
    # `hammer h:version`, `hammer h: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)
      # No project Hammerfile was found - only built-ins are loaded. The
      # bare-invocation help uses this to note that no Hammerfile exists.
      klass.instance_variable_set(:@no_hammerfile, true)
      klass.program_name
      require_relative 'hammer/builtins'
      Hammer::Builtins.register(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} h:init` to drop the example above into ./Hammerfile", :gray
    Shell.say "tip: run `#{bin} h: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?

  # Built-ins register AFTER Hammerfile eval so user-defined tasks win
  # (the `unless commands.key?(...)` guards skip a built-in when the
  # Hammerfile already owns the name - no redefinition warning). All
  # built-ins live under `h:`, so they can't collide with project root
  # tasks and the full set registers in every context.
  require_relative 'hammer/builtins'
  Hammer::Builtins.register(klass)

  klass.start(argv)
end

.collected_global_optionsObject

Root -> self chain of global_opt declarations (outer first).



274
275
276
# File 'lib/lux-hammer.rb', line 274

def collected_global_options
  ancestor_chain.flat_map { |k| k.global_options }
end

.commandsObject



369
370
371
# File 'lib/lux-hammer.rb', line 369

def commands
  @commands ||= {}
end

.cron(expr) ⇒ Object



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

def cron(expr)       ; @pending_cron = expr 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).



145
146
147
148
149
150
151
152
153
154
# File 'lib/lux-hammer.rb', line 145

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


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

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)


1286
1287
1288
1289
1290
# File 'lib/lux-hammer.rb', line 1286

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.



308
309
310
# File 'lib/lux-hammer.rb', line 308

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

.dotenv_enabled?Boolean

Returns:

  • (Boolean)


312
313
314
# File 'lib/lux-hammer.rb', line 312

def dotenv_enabled?
  @dotenv_enabled != false
end

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

Yield [full_colon_path, Command] for every command in this class and all nested namespaces. include_builtins: false prunes namespaces flagged @builtin_namespace (the reserved h: tree) - used so the compact listing hides built-ins outside --help. Only affects descent from a parent; iterating a flagged namespace directly (e.g. hammer h:) still lists its own commands.



640
641
642
643
644
645
646
647
648
649
650
# File 'lib/lux-hammer.rb', line 640

def each_command(prefix = nil, include_builtins: true, &block)
  commands.each_value do |c|
    full = prefix ? "#{prefix}:#{c.name}" : c.name
    yield full, c
  end
  namespaces.each do |ns_name, sub|
    next if !include_builtins && sub.instance_variable_get(:@builtin_namespace)
    sub_prefix = prefix ? "#{prefix}:#{ns_name}" : ns_name
    sub.each_command(sub_prefix, include_builtins: include_builtins, &block)
  end
end

.effective_options(cmd) ⇒ Object

Per-task options + globals. Globals last so bare positionals bind to the task's own opts first. Short aliases finalized on the combined set.



280
281
282
283
284
285
286
287
# File 'lib/lux-hammer.rb', line 280

def effective_options(cmd)
  extras = collected_global_options
  return cmd.options if extras.empty?

  merged = cmd.options + extras
  finalize_option_list!(merged)
  merged
end

.emit_rows(rows, width) ⇒ Object

rows is an array of [full_path, task_meta] - the per-task hashes from Command#to_h (also used to render namespace listings).



939
940
941
942
943
944
945
# File 'lib/lux-hammer.rb', line 939

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

.example(text) ⇒ Object



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

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

.export_spec(include_builtins: false) ⇒ Object

Machine-readable spec for h:json -> the macOS GUI (and, later, for lux itself to render the default listing). One hash:

commands => { group => { full_path => task_meta } }

Grouping/sort mirror the bare-hammer listing exactly: group by the first namespace segment (a bare task sharing a namespace's name joins that group via section_for), root tasks under "__root", "__root" first, remaining groups in first-encounter order, tasks within a group by [depth, name]. Hidden (no-desc) tasks are skipped and the reserved h: tree is pruned unless include_builtins.



661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
# File 'lib/lux-hammer.rb', line 661

def export_spec(include_builtins: false)
  groups = {}   # group => { full_path => meta }, in first-encounter order

  each_command(include_builtins: include_builtins) do |path, c|
    next if c.desc.empty?
    section = section_for(path, nil, self)
    key = section == :root ? '__root' : section.to_s
    (groups[key] ||= {})[path] = c.to_h(path)
  end

  sort_tasks = ->(h) { h.sort_by { |p, _| [p.count(':'), p] }.to_h }
  ordered = {}
  ordered['__root'] = sort_tasks.call(groups.delete('__root')) if groups.key?('__root')
  groups.each { |k, v| ordered[k] = sort_tasks.call(v) }

  {
    schema:         1,
    hammer_version: VERSION,
    program_name:   program_name,
    app_desc:       app_desc,
    commands:       ordered
  }
end

.finalize_option_list!(options) ⇒ Object



289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/lux-hammer.rb', line 289

def finalize_option_list!(options)
  claimed = ['-h']
  options.each do |o|
    o.aliases.each { |a| claimed << a if a.length == 2 && a.start_with?('-') && a[1] != '-' }
  end
  options.each do |o|
    next if o.aliases.any? { |a| a.length == 2 && a.start_with?('-') && a[1] != '-' }
    short = "-#{o.name.to_s[0]}"
    next if claimed.include?(short)
    o.aliases << short
    claimed << short
  end
  options
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.



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

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.



1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
# File 'lib/lux-hammer.rb', line 1303

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.



533
534
535
536
537
538
# File 'lib/lux-hammer.rb', line 533

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.



544
545
546
547
548
549
550
551
552
# File 'lib/lux-hammer.rb', line 544

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.



592
593
594
595
596
597
598
599
600
601
602
603
604
# File 'lib/lux-hammer.rb', line 592

def fuzzy_pick(name, items, kind, &keys_for)
  return nil if name.empty?
  [: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

.global_opt(name, **o) ⇒ Object

Options available on every command under this class (and children). Declared once at root (or a namespace); merged after per-task opts so positionals still fill task params first. type: :json never takes positionals regardless.

global_opt :json, type: :json, desc: 'JSON body (inline, @file, pipe)'
global_opt :as_json, type: :boolean, alias: :j, default: false


265
266
267
# File 'lib/lux-hammer.rb', line 265

def global_opt(name, **o)
  (@global_options ||= []) << Option.new(name, **o)
end

.global_optionsObject



269
270
271
# File 'lib/lux-hammer.rb', line 269

def global_options
  @global_options || []
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_reset: true       -> start(["build", "--no-reset"])
MyCli.hammer :build, cache: false         -> skipped (false flags are omitted)

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. Boolean presence is the only truth: there is no auto --no-X negation.



620
621
622
623
624
625
626
627
628
629
630
631
632
# File 'lib/lux-hammer.rb', line 620

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

.inherited(sub) ⇒ Object



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/lux-hammer.rb', line 64

def inherited(sub)
  super
  sub.instance_variable_set(:@commands, {})
  sub.instance_variable_set(:@namespaces, {})
  sub.instance_variable_set(:@before_hooks, [])
  sub.instance_variable_set(:@global_options, [])
  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, [])
  sub.instance_variable_set(:@pending_cron, nil)
end

.launch_gui!(project_dir) ⇒ Object

Spawn the vendored macOS GUI (gui/Hammer.app), pointed at the project dir and this hammer binary. Launched directly (not via open) so it inherits the caller's environment - the GUI shells back out to this same hammer for h:json and task runs, and that needs the same PATH.



1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
# File 'lib/lux-hammer.rb', line 1318

def self.launch_gui!(project_dir)
  bin = File.expand_path('../gui/Hammer.app/Contents/MacOS/HammerGUI', __dir__)
  unless File.executable?(bin)
    Shell.print_error "GUI app not found at #{bin}"
    Shell.say 'build it: ./gui/HammerGUI/build_app.sh', :yellow
    exit 1
  end
  hammer_bin = (File.realpath($PROGRAM_NAME) rescue File.expand_path($PROGRAM_NAME))
  pid = Process.spawn(bin, '--project', File.expand_path(project_dir), '--hammer', hammer_bin)
  Process.detach(pid)
  Shell.say "launched Hammer GUI for #{project_dir} (pid #{pid})", :green
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.



405
406
407
408
409
410
411
412
# File 'lib/lux-hammer.rb', line 405

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.



379
380
381
# File 'lib/lux-hammer.rb', line 379

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

.looks_like_builtin?(argv) ⇒ Boolean

True if argv targets the reserved h: built-in namespace (h, h:, h:update, ...). Used in the no-Hammerfile branch to wake up the built-ins for invocations like hammer h:recipes that aren't a flag or help request.

Returns:

  • (Boolean)


1296
1297
1298
1299
1300
# File 'lib/lux-hammer.rb', line 1296

def self.looks_like_builtin?(argv)
  first = argv.first
  return false unless first
  first == 'h' || first.start_with?('h:')
end

.method_added(method_name) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/lux-hammer.rb', line 99

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) }
  cmd.set_cron(@pending_cron) if @pending_cron

  # 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
  takes_arg = instance_method(method_name).parameters.any? { |type, _| %i[req opt rest].include?(type) }
  cmd.handler = takes_arg ? proc { |opts| send(m, opts) } : proc { send(m) }
  cmd.finalize!
  commands[cmd.name] = cmd

  @pending_desc = nil
  @pending_examples = []
  @pending_options  = []
  @pending_alts     = []
  @pending_needs    = []
  @pending_cron     = nil
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

Reopening a namespace merges: the same namespace :db do ... end can be split across files (Rake-style) and the blocks accumulate onto one subclass. Only a duplicate task name inside warns - that's handled by task. The namespace subclass is created lazily on first mention.



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/lux-hammer.rb', line 219

def namespace(name, &block)
  sub = (@namespaces[name.to_s] ||= begin
    ns = 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.
    ns.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.
    ns.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.
    ns.instance_variable_set(:@program_name, program_name)
    ns.instance_variable_set(:@location, source_location_of(block))
    ns
  end)

  Hammer.with_target(sub) { sub.class_eval(&block) } if block
end

.namespacesObject



373
374
375
# File 'lib/lux-hammer.rb', line 373

def namespaces
  @namespaces ||= {}
end

.needs(*names) ⇒ Object



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

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

.opt(name, **o) ⇒ Object



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

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

.parentObject



316
317
318
# File 'lib/lux-hammer.rb', line 316

def parent
  @parent
end

.prepare_json!(opts, key: :json) ⇒ Object

Convenience aliases so recipes can write Hammer.prepare_json!(opts) without the Input module path. See Hammer::Input.



55
56
57
# File 'lib/lux-hammer.rb', line 55

def self.prepare_json!(opts, key: :json)
  Input.prepare_json!(opts, key: key)
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.



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

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


981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
# File 'lib/lux-hammer.rb', line 981

def print_command_help(cmd, full = nil)
  full ||= cmd.name
  local  = cmd.options
  global = collected_global_options
  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?
  Shell.say "  cron: #{cmd.cron}" if cmd.cron
  unless local.empty?
    Shell.say ''
    Shell.say 'Options:', :yellow
    local.each { |o| Shell.say "  #{o.usage}" }
  end
  unless global.empty?
    Shell.say ''
    Shell.say 'Global options:', :yellow
    global.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

Pure rendering off export_spec - the same grouped structure h:json emits, so the listing and the JSON can never drift. export_spec already does the work: drops hidden (no-desc) tasks, prunes the h: tree unless include_builtins, groups by first namespace segment ("__root" for bare tasks), orders "__root" first, and sorts each group by [depth, name].



925
926
927
928
929
930
931
932
933
934
935
# File 'lib/lux-hammer.rb', line 925

def print_command_list(include_builtins: true)
  groups = export_spec(include_builtins: include_builtins)[:commands]
  return if groups.empty?

  width = groups.values.flat_map(&:keys).map(&:length).max
  groups.each_with_index do |(section, tasks), i|
    Shell.say unless i.zero?
    Shell.say(section == '__root' ? 'Commands:' : "#{section}:", :yellow)
    emit_rows(tasks.to_a, 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.



886
887
888
889
890
891
# File 'lib/lux-hammer.rb', line 886

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


905
906
907
908
# File 'lib/lux-hammer.rb', line 905

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

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



865
866
867
868
# File 'lib/lux-hammer.rb', line 865

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.



897
898
899
900
901
902
903
# File 'lib/lux-hammer.rb', line 897

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 h:agents.



913
914
915
916
917
# File 'lib/lux-hammer.rb', line 913

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.



778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
# File 'lib/lux-hammer.rb', line 778

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
  # Compact (bare-invocation) view only - the extended `--help` view
  # already IS the full usage, so don't nag about it there.
  unless extended
    Shell.say "add `--help` to show usage help", :gray
    # No project Hammerfile + no custom tasks loaded: point the user at
    # `h:init`. The flag is set by `Hammer.cli` when the lookup misses.
    if instance_variable_get(:@no_hammerfile)
      Shell.say "no Hammerfile found in #{Dir.pwd} - run `#{program_name} h:init` to create one", :gray
    end
  end
  if @app_desc && !@app_desc.empty?
    Shell.say ''
    @app_desc.each_line { |l| Shell.say "  #{l.chomp}" }
  end
  # Built-in `h:` commands only surface in the extended view
  # (`--help` / `-h` / `help`); the bare-invocation listing stays
  # focused on the project's own tasks. They remain dispatchable
  # regardless - this only governs what the listing shows.
  if expanded
    each_command(include_builtins: extended) { |path, c| print_full_block(path, c) unless c.desc.empty? }
  else
    Shell.say ''
    print_command_list(include_builtins: extended)
  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.



849
850
851
852
853
854
855
856
857
858
859
860
861
# File 'lib/lux-hammer.rb', line 849

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.to_h(prefix)] if sibling && !sibling.desc.empty?
  ns.each_command(prefix) { |path, c| rows << [path, c.to_h(path)] 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.



830
831
832
833
834
835
836
837
838
839
840
841
842
843
# File 'lib/lux-hammer.rb', line 830

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} h: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 when true.



716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
# File 'lib/lux-hammer.rb', line 716

def print_run_banner(cmd, full, positional, opts, options: nil)
  options ||= effective_options(cmd)
  parts = ["#{program_name} #{full}"]
  options.each do |o|
    val = opts[o.name]
    next if val.nil? || val == o.default
    if o.boolean?
      parts << o.switch if val
    else
      rendered =
        if o.type == :json || val.is_a?(Hash)
          val.respond_to?(:to_json) ? val.to_json : val.inspect
        elsif val.is_a?(Array)
          val.join(',')
        else
          val
        end
      parts << "#{o.switch}=#{rendered}"
    end
  end
  parts.concat(positional)
  # Diagnostic, not program output - to stderr so stdout stays clean
  # for machine-readable tasks (`h:json`, `h:version`) and pipes.
  warn Shell.paint("> #{parts.join(' ')}", :gray)
end

Gray "lux-hammer X.Y.Z - " 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).



876
877
878
879
880
# File 'lib/lux-hammer.rb', line 876

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.



129
130
131
# File 'lib/lux-hammer.rb', line 129

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).



1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
# File 'lib/lux-hammer.rb', line 1058

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 h: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

.relativize_path(path) ⇒ Object

Trim the cwd prefix off an absolute path so redefinition warnings read as ./lib/tasks/foo.rb instead of a long absolute path. Paths outside cwd (framework / gem files) are left absolute.



331
332
333
334
# File 'lib/lux-hammer.rb', line 331

def relativize_path(path)
  prefix = "#{Dir.pwd}/"
  path.start_with?(prefix) ? ".#{path[Dir.pwd.length..]}" : path
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).



558
559
560
561
562
563
564
565
566
567
568
569
570
571
# File 'lib/lux-hammer.rb', line 558

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.



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

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.



365
366
367
# File 'lib/lux-hammer.rb', line 365

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.



1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
# File 'lib/lux-hammer.rb', line 1032

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.



745
746
747
748
749
750
751
752
753
754
755
# File 'lib/lux-hammer.rb', line 745

def run_before_hooks(instance, opts)
  # Built-in `h:` meta-commands parent to the project root but must not
  # trigger the project's own `before` hooks (dotenv, env checks, ...).
  return if instance_variable_get(:@builtin_namespace)
  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



685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
# File 'lib/lux-hammer.rb', line 685

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)

  options = effective_options(cmd)
  positional, opts = Parser.new(options).parse(argv)
  opts[:args] = positional
  # Always attach piped stdin (nil when TTY / empty). Recipes that want
  # JSON body handling call Hammer::Input.prepare_json! in a before hook.
  Hammer::Input.attach_stdin!(opts)
  print_run_banner(cmd, full || cmd.name, positional, opts, options: options) 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).



759
760
761
762
763
764
765
766
767
768
769
770
# File 'lib/lux-hammer.rb', line 759

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



1150
1151
1152
1153
1154
# File 'lib/lux-hammer.rb', line 1150

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 Builtins.register 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.



1275
1276
1277
1278
1279
1280
# File 'lib/lux-hammer.rb', line 1275

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.



955
956
957
958
959
960
961
962
963
# File 'lib/lux-hammer.rb', line 955

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 h: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.



1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
# File 'lib/lux-hammer.rb', line 1128

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.



1260
1261
1262
1263
1264
1265
1266
1267
# File 'lib/lux-hammer.rb', line 1260

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).



323
324
325
326
# File 'lib/lux-hammer.rb', line 323

def source_location_of(block)
  loc = block&.source_location
  loc ? "#{relativize_path(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.



422
423
424
425
426
427
428
429
430
431
432
433
434
435
# File 'lib/lux-hammer.rb', line 422

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[:args].



160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
# File 'lib/lux-hammer.rb', line 160

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

  # Only warn when overriding a task that was also defined inside the
  # main app. Overriding one that came from outside - a framework
  # default, plugin, or gem - is an intentional override, so stay quiet
  # and don't tag it `(redefined)` in help.
  if (prev = commands[cmd.name]) && app_local_location?(prev.location)
    cmd.prev_location = prev.location
    warn_redefinition('task', cmd.name, prev.location, cmd.location)
  end

  cmd.finalize!
  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    = []
  @pending_cron     = nil
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. type: :json and other skip_positional_fill? opts are flag-only (not listed as positionals).



969
970
971
972
973
974
975
976
977
978
979
# File 'lib/lux-hammer.rb', line 969

def usage_signature(cmd)
  options = effective_options(cmd)
  pos = options.reject { |o| o.boolean? || o.skip_positional_fill? }.map { |o|
    name = o.name.to_s.upcase
    o.required ? name : "[#{name}]"
  }
  out = pos.join(' ')
  out = "#{out} ".lstrip unless out.empty?
  out += '[OPTIONS]' unless 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).



347
348
349
# File 'lib/lux-hammer.rb', line 347

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 required from inside a Hammerfile register against the right target.



387
388
389
390
391
392
393
# File 'lib/lux-hammer.rb', line 387

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.



1021
1022
1023
# File 'lib/lux-hammer.rb', line 1021

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