Class: Hammer
- Inherits:
-
Object
- Object
- Hammer
- 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
- .alt(*names) ⇒ Object
-
.cli(argv = ARGV) ⇒ Object
Entry point for the ‘hammer` binary.
- .commands ⇒ Object
-
.default_program_name ⇒ Object
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).
-
.define(name, &block) ⇒ Object
Define a command.
-
.desc(text) ⇒ Object
—– class-level DSL for ‘def`-style commands ——————— Set pending metadata that the next `def` will consume.
-
.each_command(prefix = nil, &block) ⇒ Object
Yield [full_colon_path, Command] for every command in this class and all nested namespaces.
- .emit_rows(rows, width) ⇒ Object
- .example(text) ⇒ Object
-
.find_command(name) ⇒ Object
Find a command by canonical name or alt within this class.
-
.find_hammerfile(start) ⇒ Object
Walk up the directory tree looking for a Hammerfile.
- .help_requested?(argv) ⇒ Boolean
- .inherited(sub) ⇒ Object
-
.label_for(full, cmd) ⇒ Object
“db:migrate” or “db:migrate (alt: m)”.
-
.load(*paths, **kwargs) ⇒ Object
Load Hammerfile fragments and register their commands on this class.
-
.loader ⇒ Object
Per-target Loader instance.
- .method_added(method_name) ⇒ Object
-
.method_missing(name, *args, **kwargs, &block) ⇒ Object
MyCli.hammer_db_users_list(“a”, verbose: true) -> MyCli.start([“db:users:list”, “a”, “–verbose”]).
-
.namespace(name, &block) ⇒ Object
Open a namespace (group of commands).
- .namespaces ⇒ Object
- .opt(name, **o) ⇒ Object
- .print_command_help(cmd, full = nil) ⇒ Object
- .print_command_list(klass, prefix = nil) ⇒ Object
- .print_footer ⇒ Object
- .print_help(target = nil) ⇒ Object
- .print_namespace_help(prefix, ns) ⇒ Object
- .program_name(name = nil) ⇒ Object
-
.resolve(path) ⇒ Object
Walk “ns1:ns2:cmd” -> [command, owning_class].
-
.resolve_namespace(path) ⇒ Object
Walk “ns1:ns2” -> namespace class, or nil if any segment missing.
- .respond_to_missing?(name, include_private = false) ⇒ Boolean
-
.root ⇒ Object
Topmost class in this CLI tree.
-
.run(argv = ARGV, &block) ⇒ Object
Define and run a CLI inline.
- .run_command(cmd, argv, full: nil) ⇒ Object
-
.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.
-
.start(argv = ARGV) ⇒ Object
Entry point.
-
.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.
Instance Method Summary collapse
-
#method_missing(name, *args, **kwargs, &block) ⇒ Object
Inside a command’s ‘proc do |opts| …
- #respond_to_missing?(name, include_private = false) ⇒ Boolean
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 |
.commands ⇒ Object
182 183 184 |
# File 'lib/lux-hammer.rb', line 182 def commands @commands ||= {} end |
.default_program_name ⇒ Object
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.(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.(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
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 |
.loader ⇒ Object
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 |
.namespaces ⇒ Object
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 |
.print_command_help(cmd, full = nil) ⇒ Object
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..empty? Shell.say Shell.say 'Options:', :yellow cmd..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 |
.print_command_list(klass, prefix = nil) ⇒ Object
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 |
.print_footer ⇒ Object
355 356 357 358 |
# File 'lib/lux-hammer.rb', line 355 def Shell.say Shell.say "powered by hammer - #{HOMEPAGE}", :gray end |
.print_help(target = nil) ⇒ Object
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) end |
.print_namespace_help(prefix, ns) ⇒ Object
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) 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
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 |
.root ⇒ Object
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.).parse(argv) opts[:args] = positional instance = new instance.instance_exec(opts, &cmd.handler) rescue Parser::Error => e Shell.print_error(e.) 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.) 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..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..empty? out.empty? ? '' : " #{out}" end |
Instance Method Details
#respond_to_missing?(name, include_private = false) ⇒ 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 |