Class: Hammer

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

Overview

Thor-inspired tiny CLI builder.

Class DSL:

class MyCli < Hammer
  program_name 'mycli'

  define :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 "building #{opts[:env]} args=#{opts[:args].inspect}", :green
    end
  end
end

MyCli.start(ARGV)

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

Hammer.run(ARGV) do
  program 'inline'
  define :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: Shell Classes: Builder, Command, CommandBuilder, Loader, Option, Parser

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Shell

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

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(name, *args, **kwargs, &block) ⇒ Object

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

define :deploy do
  proc do |opts|
    hammer_build
    hammer_db_migrate(pretend: true)
  end
end


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

def method_missing(name, *args, **kwargs, &block)
  return super unless name.to_s.start_with?('hammer_')
  # Dispatch from the root class so `hammer_a_b` resolves against the
  # full colon path "a:b" even when called inside a namespaced command
  # (where self.class would be the namespace subclass).
  self.class.root.send(name, *args, **kwargs, &block)
end

Class Method Details

.alt(*names) ⇒ Object



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

def alt(*names)      ; @pending_alts.concat(names) 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.



477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
# File 'lib/lux-hammer.rb', line 477

def self.cli(argv = ARGV)
  path = find_hammerfile(Dir.pwd)
  unless path
    Shell.print_error "no Hammerfile found in #{Dir.pwd} or any parent directory"
    Shell.say "create one - example:"
    Shell.say <<~RUBY
      program 'mycli'

      define :hello do
        desc 'say hello'
        proc { |opts| say "hello \#{opts[:args].first || 'world'}", :green }
      end
    RUBY
    exit 1
  end

  klass = Class.new(Hammer)
  # Resolve before chdir so paths like `bin/foo` stay relative to the
  # cwd the user actually invoked from.
  klass.program_name(klass.default_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).instance_eval(File.read(path), path)
  klass.start(argv)
end

.commandsObject



182
183
184
# File 'lib/lux-hammer.rb', line 182

def commands
  @commands ||= {}
end

.default_program_nameObject

Default shown in help/usage when ‘program_name` is not set: 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).



107
108
109
110
111
112
113
114
115
116
# File 'lib/lux-hammer.rb', line 107

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

.define(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`.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/lux-hammer.rb', line 122

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

      Example:

        define :#{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
  commands[cmd.name] = cmd

  # `define` 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     = []
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


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

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

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

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



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

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



385
386
387
388
389
390
# File 'lib/lux-hammer.rb', line 385

def emit_rows(rows, width)
  rows.each do |full, c|
    label = label_for(full, c)
    Shell.say "  #{program_name} #{label.ljust(width)}  # #{c.brief}"
  end
end

.example(text) ⇒ Object



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

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

.find_command(name) ⇒ Object

Find a command by canonical name or alt within this class.



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

def find_command(name)
  commands[name.to_s] || commands.values.find { |c| c.matches?(name) }
end

.find_hammerfile(start) ⇒ Object

Walk up the directory tree looking for a Hammerfile.



506
507
508
509
510
511
512
513
514
515
# File 'lib/lux-hammer.rb', line 506

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

.help_requested?(argv) ⇒ Boolean

Returns:

  • (Boolean)


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

def help_requested?(argv)
  stop = argv.index('--')
  scan = stop ? argv[0...stop] : argv
  scan.include?('-h') || scan.include?('--help')
end

.inherited(sub) ⇒ Object



49
50
51
52
53
54
55
56
57
58
# File 'lib/lux-hammer.rb', line 49

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

.label_for(full, cmd) ⇒ Object

“db:migrate” or “db:migrate (alt: m)”



405
406
407
# File 'lib/lux-hammer.rb', line 405

def label_for(full, cmd)
  cmd.alts.empty? ? full : "#{full} (alt: #{cmd.alts.join(', ')})"
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.



206
207
208
209
210
211
212
213
# File 'lib/lux-hammer.rb', line 206

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.



192
193
194
# File 'lib/lux-hammer.rb', line 192

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

.method_added(method_name) ⇒ Object



76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/lux-hammer.rb', line 76

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

  # 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     = []
end

.method_missing(name, *args, **kwargs, &block) ⇒ Object

MyCli.hammer_db_users_list(“a”, verbose: true) -> MyCli.start([“db:users:list”, “a”, “–verbose”])

Useful for scripting and tests. Underscores in the method name map to colons; underscores in kwarg keys map to dashes in the flag.



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/lux-hammer.rb', line 268

def method_missing(name, *args, **kwargs, &block)
  str = name.to_s
  return super unless str.start_with?('hammer_')

  path = str.sub(/^hammer_/, '').tr('_', ':')
  argv = [path, *args.map(&:to_s)]
  # kwarg key mirrors the CLI flag literally:
  #   verbose: true       -> --verbose
  #   no_cache: true      -> --no-cache  (just the general rule)
  #   env: 'prod'         -> --env=prod
  #   anything: false     -> skipped (no-op; use `no_x: true` to negate)
  kwargs.each do |k, v|
    next if v == false
    flag = "--#{k.to_s.tr('_', '-')}"
    argv << (v == true ? flag : "#{flag}=#{v}")
  end
  start(argv)
end

.namespace(name, &block) ⇒ Object

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

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


162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/lux-hammer.rb', line 162

def namespace(name, &block)
  sub = Class.new(Hammer)
  # Track the top-level CLI class so cross-invocation
  # (`hammer_<colon_path>`) from inside a namespaced command dispatches
  # against the full tree, not just the current namespace.
  sub.instance_variable_set(:@root, root)
  # Inherit program_name so help banners show "myapp ns:cmd", not
  # whichever binary the namespace class fell back to.
  sub.program_name(program_name) if @program_name
  sub.class_eval(&block) if block
  @namespaces[name.to_s] = sub
end

.namespacesObject



186
187
188
# File 'lib/lux-hammer.rb', line 186

def namespaces
  @namespaces ||= {}
end

.opt(name, **o) ⇒ Object



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

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


423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/lux-hammer.rb', line 423

def print_command_help(cmd, full = nil)
  full ||= cmd.name
  Shell.say "Usage: #{program_name} #{full}#{usage_signature(cmd)}", :cyan, bold: true
  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


360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
# File 'lib/lux-hammer.rb', line 360

def print_command_list(klass, prefix = nil)
  rows = []
  klass.each_command(prefix) { |full, c| rows << [full, c] }
  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) }
  width  = rows.map { |full, c| label_for(full, c).length }.max
  first  = true

  if (rooted = groups.delete(:root))
    Shell.say 'Commands:', :yellow
    emit_rows(rooted.sort_by { |full, _| 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 }, width)
  end
end


355
356
357
358
# File 'lib/lux-hammer.rb', line 355

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


330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/lux-hammer.rb', line 330

def print_help(target = nil)
  if target
    cmd, _ = resolve(target)
    return print_command_help(cmd, target) if cmd
    ns = resolve_namespace(target)
    return print_namespace_help(target, ns) if ns
    Shell.print_error("unknown: #{target}")
    return
  end

  Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan, bold: true
  Shell.say
  print_command_list(self)
  print_footer
end


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

def print_namespace_help(prefix, ns)
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan, bold: true
  Shell.say
  print_command_list(ns, prefix)
  print_footer
end

.program_name(name = nil) ⇒ Object



98
99
100
101
# File 'lib/lux-hammer.rb', line 98

def program_name(name = nil)
  @program_name = name if name
  @program_name || default_program_name
end

.resolve(path) ⇒ Object

Walk “ns1:ns2:cmd” -> [command, owning_class]. Returns [nil, nil] if any segment is missing or the final segment isn’t a command.



245
246
247
248
249
250
251
252
253
# File 'lib/lux-hammer.rb', line 245

def resolve(path)
  parts = path.to_s.split(':')
  klass = self
  parts[0..-2].each do |ns|
    klass = klass.namespaces[ns] or return [nil, nil]
  end
  cmd = klass.find_command(parts.last)
  cmd ? [cmd, klass] : [nil, nil]
end

.resolve_namespace(path) ⇒ Object

Walk “ns1:ns2” -> namespace class, or nil if any segment missing.



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

def resolve_namespace(path)
  parts = path.to_s.split(':')
  klass = self
  parts.each { |ns| klass = klass.namespaces[ns] or return nil }
  klass
end

.respond_to_missing?(name, include_private = false) ⇒ Boolean

Returns:

  • (Boolean)


287
288
289
# File 'lib/lux-hammer.rb', line 287

def respond_to_missing?(name, include_private = false)
  name.to_s.start_with?('hammer_') || super
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.



178
179
180
# File 'lib/lux-hammer.rb', line 178

def root
  @root || self
end

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

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



468
469
470
471
472
# File 'lib/lux-hammer.rb', line 468

def self.run(argv = ARGV, &block)
  klass = Class.new(Hammer)
  Builder.new(klass).instance_eval(&block)
  klass.start(argv)
end

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



304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/lux-hammer.rb', line 304

def run_command(cmd, argv, full: nil)
  # -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
  instance = new
  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

.section_for(full, prefix) ⇒ 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.



396
397
398
399
400
401
402
# File 'lib/lux-hammer.rb', line 396

def section_for(full, prefix)
  segs = full.split(':')[0..-2]
  if prefix && !prefix.empty?
    segs = segs[prefix.split(':').size..] || []
  end
  segs.empty? ? :root : segs.first
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”.



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

def start(argv = ARGV)
  argv = argv.dup
  name = argv.shift

  if name.nil? || name == 'help' || name == '-h' || name == '--help'
    target = argv.shift
    return print_help(target)
  end

  cmd, owner = resolve(name)
  return owner.run_command(cmd, argv, full: name) if cmd

  ns = resolve_namespace(name)
  return print_namespace_help(name, ns) if ns

  Shell.print_error("unknown command: #{name}")
  print_help
  exit 1
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.



412
413
414
415
416
417
418
419
420
421
# File 'lib/lux-hammer.rb', line 412

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

Instance Method Details

#respond_to_missing?(name, include_private = false) ⇒ Boolean

Returns:

  • (Boolean)


460
461
462
# File 'lib/lux-hammer.rb', line 460

def respond_to_missing?(name, include_private = false)
  name.to_s.start_with?('hammer_') || super
end