Class: Hammer
- Inherits:
-
Object
- Object
- Hammer
- Includes:
- Shell
- Defined in:
- lib/lux-hammer.rb,
lib/hammer/shell.rb,
lib/hammer/dotenv.rb,
lib/hammer/loader.rb,
lib/hammer/option.rb,
lib/hammer/parser.rb,
lib/hammer/builder.rb,
lib/hammer/command.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: DSL, Dotenv, Shell Classes: Builder, Command, CommandBuilder, Loader, Option, Parser
Class Method Summary collapse
- .alt(*names) ⇒ Object
-
.ancestor_chain ⇒ Object
Root -> …
-
.before(&block) ⇒ Object
Register a hook to run before every command in this class (root or namespace).
- .before_hooks ⇒ Object
-
.cli(argv = ARGV) ⇒ Object
Entry point for the ‘hammer` binary.
- .commands ⇒ Object
-
.default_program_name ⇒ Object
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).
-
.desc(text) ⇒ Object
—– class-level DSL for ‘def`-style commands ——————— Set pending metadata that the next `def` will consume.
-
.dotenv(flag = true) ⇒ Object
Toggle auto-loading of ‘.env` / `.env.local` for the `hammer` binary.
- .dotenv_enabled? ⇒ Boolean
-
.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.
-
.find_namespace(name) ⇒ Object
Find a namespace by name within this class.
-
.find_namespace_sibling(canonical) ⇒ Object
Returns the command at the parent that shares its name with this namespace.
-
.fuzzy_pick(name, items, kind, &keys_for) ⇒ Object
Shared fuzzy matcher used by find_command and find_namespace.
-
.hammer(name, *args, **opts) ⇒ Object
Programmatic dispatch by name.
- .help_requested?(argv) ⇒ Boolean
- .inherited(sub) ⇒ Object
-
.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
-
.namespace(name, &block) ⇒ Object
Open a namespace (group of commands).
- .namespaces ⇒ Object
- .needs(*names) ⇒ Object
- .opt(name, **o) ⇒ Object
- .parent ⇒ Object
-
.print_ai_help ⇒ Object
Dump the gem’s AGENTS.md to stdout - AI-optimized guide for writing Hammerfiles.
- .print_command_help(cmd, full = nil) ⇒ Object
- .print_command_list(klass, prefix = nil) ⇒ Object
- .print_footer ⇒ Object
-
.print_full_block(path, cmd) ⇒ Object
One “task block” for the expanded listing: blank line separator then the standard per-command help (usage + desc + options + examples).
-
.print_global_flags ⇒ Object
Global flags only exist when invoked via the ‘hammer` binary (see `Hammer.cli`), not for user-built CLIs that call `start` on their own subclass.
- .print_help(target = nil, full: false) ⇒ Object
- .print_namespace_help(prefix, ns, full: false) ⇒ Object
-
.print_run_banner(cmd, full, positional, opts) ⇒ Object
Print a gray “> prog cmd –opt=val ARG” banner before a command runs.
-
.program_name ⇒ Object
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.
-
.resolve(path) ⇒ Object
Walk “ns1:ns2:cmd” -> [command, owning_class, canonical_path].
-
.resolve_namespace(path) ⇒ Object
Walk “ns1:ns2” -> [namespace_class, canonical_path].
-
.root ⇒ Object
Topmost class in this CLI tree.
-
.run(argv = ARGV, &block) ⇒ Object
Define and run a CLI inline.
-
.run_before_hooks(instance, opts) ⇒ Object
Fire ‘before` hooks from root down through the namespace chain.
- .run_command(cmd, argv, full: nil) ⇒ Object
-
.run_needs(cmd) ⇒ Object
Dispatch a command’s declared ‘needs` through the root class, with per-invocation dedupe.
-
.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.
-
.start(argv = ARGV) ⇒ Object
Entry point.
-
.task(name, &block) ⇒ Object
Define a command.
-
.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.
-
.with_target(klass) ⇒ Object
Push ‘klass` as the current Hammer target for the duration of the block.
Instance Method Summary collapse
-
#hammer(name, *args, **opts) ⇒ Object
Inside a command’s ‘proc do |opts| …
Methods included from Shell
ask, choose, choose_numbered, color!, color?, error, paint, print_error, say, sh, yes?
Class Method Details
.alt(*names) ⇒ Object
79 |
# File 'lib/lux-hammer.rb', line 79 def alt(*names) ; @pending_alts.concat(names) end |
.ancestor_chain ⇒ Object
Root -> … -> self. Used to gather ‘before` hooks for a command.
224 225 226 227 228 229 230 231 232 |
# File 'lib/lux-hammer.rb', line 224 def ancestor_chain chain = [] klass = self while klass chain.unshift klass klass = klass.parent end chain end |
.before(&block) ⇒ Object
Register a hook to run before every command in this class (root or namespace). Hooks receive the command’s ‘opts` hash. All hooks run outer -> inner, once per top-level `start` (prereqs don’t re-trigger).
before { |opts| Dotenv.load }
namespace :db do
before { hammer :env }
task :migrate do ... end
end
199 200 201 |
# File 'lib/lux-hammer.rb', line 199 def before(&block) before_hooks << block end |
.before_hooks ⇒ Object
203 204 205 |
# File 'lib/lux-hammer.rb', line 203 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.
‘–ai` is a meta-flag handled here, before Hammerfile lookup, so it works anywhere (no project required).
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 825 826 827 828 829 830 831 832 833 834 |
# File 'lib/lux-hammer.rb', line 780 def self.cli(argv = ARGV) if argv.include?('--ai') print_ai_help exit 0 end path = find_hammerfile(Dir.pwd) unless path 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 <<~RUBY task :hello do desc 'say hello' proc do |opts| say.green "hello \#{opts[:args].first || 'world'}" end end RUBY Shell.say '' Shell.say "tip: run `#{File.basename($PROGRAM_NAME)} --ai` 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 globals like `--ai`. 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? klass.start(argv) end |
.commands ⇒ Object
241 242 243 |
# File 'lib/lux-hammer.rb', line 241 def commands @commands ||= {} end |
.default_program_name ⇒ Object
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).
117 118 119 120 121 122 123 124 125 126 |
# File 'lib/lux-hammer.rb', line 117 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 |
.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
76 |
# File 'lib/lux-hammer.rb', line 76 def desc(text) ; @pending_desc = text.to_s.rstrip 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`.
211 212 213 |
# File 'lib/lux-hammer.rb', line 211 def dotenv(flag = true) @dotenv_enabled = flag end |
.dotenv_enabled? ⇒ Boolean
215 216 217 |
# File 'lib/lux-hammer.rb', line 215 def dotenv_enabled? @dotenv_enabled != false end |
.each_command(prefix = nil, &block) ⇒ Object
Yield [full_colon_path, Command] for every command in this class and all nested namespaces.
481 482 483 484 485 486 487 488 489 490 |
# File 'lib/lux-hammer.rb', line 481 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
664 665 666 667 668 669 |
# File 'lib/lux-hammer.rb', line 664 def emit_rows(rows, width) rows.each do |full, c| brief = c.alts.empty? ? c.brief : "#{c.brief} (alt: #{c.alts.join(', ')})" Shell.say " #{program_name} #{full.ljust(width)} # #{brief}" end end |
.example(text) ⇒ Object
77 |
# File 'lib/lux-hammer.rb', line 77 def example(text) ; @pending_examples << text end |
.find_command(name) ⇒ Object
Find a command by canonical name or alt within this class. Falls back to fuzzy match (prefix first, then substring) when no exact hit. Raises AmbiguousMatch if the fuzzy pass matches more than one.
375 376 377 378 379 380 |
# File 'lib/lux-hammer.rb', line 375 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.
837 838 839 840 841 842 843 844 845 846 |
# File 'lib/lux-hammer.rb', line 837 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 |
.find_namespace(name) ⇒ Object
Find a namespace by name within this class. Same fuzzy fallback as find_command.
384 385 386 387 388 389 |
# File 'lib/lux-hammer.rb', line 384 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.
395 396 397 398 399 400 401 402 403 |
# File 'lib/lux-hammer.rb', line 395 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.
443 444 445 446 447 448 449 450 451 452 453 454 |
# File 'lib/lux-hammer.rb', line 443 def fuzzy_pick(name, items, kind, &keys_for) [:start_with?, :include?].each do |op| matches = items.select { |item| keys_for.call(item).any? { |k| k.send(op, name) } } next if matches.empty? if matches.size > 1 labels = matches.map { |m| keys_for.call(m).first }.sort raise AmbiguousMatch, "multiple #{kind}s match '#{name}': #{labels.join(', ')}" end return matches.first end nil end |
.hammer(name, *args, **opts) ⇒ Object
Programmatic dispatch by name. Useful for scripting and tests.
MyCli.hammer :build -> start(["build"])
MyCli.hammer 'db:users:list' -> start(["db:users:list"])
MyCli.hammer :eval, 'puts 42' -> start(["eval", "puts 42"])
MyCli.hammer :build, env: 'prod' -> start(["build", "--env=prod"])
MyCli.hammer :build, verbose: true -> start(["build", "--verbose"])
MyCli.hammer :build, no_cache: true -> start(["build", "--no-cache"])
MyCli.hammer :build, cache: false -> skipped (no-op)
Symbols are single-segment names; pass a string with colons for namespaced paths. Trailing positionals become positional ARGV. Underscores in option keys become dashes in flags.
469 470 471 472 473 474 475 476 477 |
# File 'lib/lux-hammer.rb', line 469 def hammer(name, *args, **opts) argv = [name.to_s, *args.map(&:to_s)] opts.each do |k, v| next if v == false flag = "--#{k.to_s.tr('_', '-')}" argv << (v == true ? flag : "#{flag}=#{v}") end start(argv) end |
.help_requested?(argv) ⇒ Boolean
561 562 563 564 565 |
# File 'lib/lux-hammer.rb', line 561 def help_requested?(argv) stop = argv.index('--') scan = stop ? argv[0...stop] : argv scan.include?('-h') || scan.include?('--help') end |
.inherited(sub) ⇒ Object
51 52 53 54 55 56 57 58 59 60 61 62 63 |
# File 'lib/lux-hammer.rb', line 51 def inherited(sub) super sub.instance_variable_set(:@commands, {}) sub.instance_variable_set(:@namespaces, {}) sub.instance_variable_set(:@before_hooks, []) sub.instance_variable_set(:@parent, nil) sub.instance_variable_set(:@program_name, nil) sub.instance_variable_set(:@pending_desc, nil) sub.instance_variable_set(:@pending_examples, []) sub.instance_variable_set(:@pending_options, []) sub.instance_variable_set(:@pending_alts, []) sub.instance_variable_set(:@pending_needs, []) end |
.load(*paths, **kwargs) ⇒ Object
Load Hammerfile fragments and register their commands on this class. Rake-style: split a CLI across multiple files.
load # auto-discover *_hammer.rb under caller dir
load auto: true # same
load 'tasks/db_hammer.rb' # one file
load 'tasks/*_hammer.rb' # glob
Paths resolve relative to the file calling ‘load`. See `Hammer::Loader` for the full implementation.
277 278 279 280 281 282 283 284 |
# File 'lib/lux-hammer.rb', line 277 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.
251 252 253 |
# File 'lib/lux-hammer.rb', line 251 def loader @loader ||= Loader.new(self) end |
.method_added(method_name) ⇒ Object
82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/lux-hammer.rb', line 82 def method_added(method_name) super return unless @pending_desc cmd = Command.new(name: method_name.to_s, desc: @pending_desc) @pending_examples.each { |e| cmd.add_example(e) } @pending_options.each { |o| cmd.add_option(o) } @pending_alts.each { |n| cmd.add_alt(n) } @pending_needs.each { |n| cmd.add_need(n) } # If the method takes no args, call it without opts. Otherwise pass # opts. So both `def build` and `def build(opts)` work. m = method_name arity = instance_method(method_name).arity cmd.handler = arity.zero? ? proc { send(m) } : proc { |opts| send(m, opts) } commands[cmd.name] = cmd @pending_desc = nil @pending_examples = [] @pending_options = [] @pending_alts = [] @pending_needs = [] end |
.namespace(name, &block) ⇒ Object
Open a namespace (group of commands). Everything inside the block (task, nested namespace, …) belongs to that namespace, evaluated against an anonymous Hammer subclass.
namespace :db do
task :migrate do ... end
namespace :users do ... end
end
173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/lux-hammer.rb', line 173 def namespace(name, &block) sub = Class.new(Hammer) # Track the top-level CLI class so cross-invocation # (`hammer 'ns:cmd'`) from inside a namespaced command dispatches # against the full tree, not just the current namespace. sub.instance_variable_set(:@root, root) # Parent link, so `before` hooks defined further up the namespace # tree can be collected and run outer -> inner before a command. sub.instance_variable_set(:@parent, self) # Share the parent's resolved program_name so help banners show # "myapp ns:cmd" with the same prefix everywhere - and so the value # captured pre-chdir (see `Hammer.cli`) survives into nested classes. sub.instance_variable_set(:@program_name, program_name) Hammer.with_target(sub) { sub.class_eval(&block) } if block @namespaces[name.to_s] = sub end |
.namespaces ⇒ Object
245 246 247 |
# File 'lib/lux-hammer.rb', line 245 def namespaces @namespaces ||= {} end |
.needs(*names) ⇒ Object
80 |
# File 'lib/lux-hammer.rb', line 80 def needs(*names) ; @pending_needs.concat(names) end |
.opt(name, **o) ⇒ Object
78 |
# File 'lib/lux-hammer.rb', line 78 def opt(name, **o) ; @pending_options << Option.new(name, **o) end |
.parent ⇒ Object
219 220 221 |
# File 'lib/lux-hammer.rb', line 219 def parent @parent end |
.print_ai_help ⇒ Object
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.
764 765 766 767 768 769 770 771 772 |
# File 'lib/lux-hammer.rb', line 764 def self.print_ai_help path = File.('../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 |
.print_command_help(cmd, full = nil) ⇒ Object
703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 |
# File 'lib/lux-hammer.rb', line 703 def print_command_help(cmd, full = nil) full ||= cmd.name Shell.say "Usage: #{program_name} #{full}#{usage_signature(cmd)}", :cyan cmd.desc.each_line do |line| stripped = line.chomp Shell.say(stripped.empty? ? '' : " #{stripped}") end unless cmd.desc.empty? Shell.say " alias: #{cmd.alts.join(', ')}" unless cmd.alts.empty? unless cmd..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
636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 |
# File 'lib/lux-hammer.rb', line 636 def print_command_list(klass, prefix = nil) rows = [] # Commands without a `desc` are hidden from listings but still # dispatchable + `hammer`-callable - useful for private helpers # invoked from `before` hooks or other commands (e.g. `:env`, `:app`). klass.each_command(prefix) { |full, c| rows << [full, c] unless c.desc.empty? } return if rows.empty? # group by "section" = everything between the view prefix and the # leaf name. Bare leaves go in :root. groups = rows.group_by { |full, _| section_for(full, prefix, klass) } width = rows.map { |full, _| full.length }.max first = true if (rooted = groups.delete(:root)) Shell.say 'Commands:', :yellow emit_rows(rooted.sort_by { |full, _| [full.count(':'), full] }, width) first = false end groups.each do |section, items| Shell.say unless first first = false Shell.say "#{section}:", :yellow emit_rows(items.sort_by { |full, _| [full.count(':'), full] }, width) end end |
.print_footer ⇒ Object
631 632 633 634 |
# File 'lib/lux-hammer.rb', line 631 def Shell.say '' Shell.say "powered by hammer - #{HOMEPAGE}", :gray end |
.print_full_block(path, cmd) ⇒ Object
One “task block” for the expanded listing: blank line separator then the standard per-command help (usage + desc + options + examples).
614 615 616 617 |
# File 'lib/lux-hammer.rb', line 614 def print_full_block(path, cmd) Shell.say '' print_command_help(cmd, path) end |
.print_global_flags ⇒ Object
Global flags only exist when invoked via the ‘hammer` binary (see `Hammer.cli`), not for user-built CLIs that call `start` on their own subclass.
624 625 626 627 628 629 |
# File 'lib/lux-hammer.rb', line 624 def print_global_flags return unless root.instance_variable_get(:@hammer_binary) Shell.say '' Shell.say 'Global:', :yellow Shell.say ' --ai # Print AGENTS.md (AI-friendly Hammerfile authoring docs)' end |
.print_help(target = nil, full: false) ⇒ Object
567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 |
# File 'lib/lux-hammer.rb', line 567 def print_help(target = nil, full: false) if target # `help ns:` is equivalent to `ns:` - namespace listing. if target.end_with?(':') && target != ':' = target.chomp(':') ns, canonical = resolve_namespace() 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, full: full) if ns Shell.print_error("unknown: #{target}") return end Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan if full each_command { |path, c| print_full_block(path, c) unless c.desc.empty? } else Shell.say '' print_command_list(self) end print_global_flags end |
.print_namespace_help(prefix, ns, full: false) ⇒ Object
596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 |
# File 'lib/lux-hammer.rb', line 596 def print_namespace_help(prefix, ns, full: false) Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan rows = [] sibling = find_namespace_sibling(prefix) rows << [prefix, sibling] if sibling && !sibling.desc.empty? ns.each_command(prefix) { |path, c| rows << [path, c] unless c.desc.empty? } unless rows.empty? Shell.say '' Shell.say 'Commands:', :yellow width = rows.map { |path, _| path.length }.max emit_rows(rows.sort_by { |path, _| [path.count(':'), path] }, width) end print_global_flags end |
.print_run_banner(cmd, full, positional, opts) ⇒ Object
Print a gray “> prog cmd –opt=val ARG” banner before a command runs. Helps see what was actually picked when fuzzy matching resolved a partial name. Only opts that differ from their default are shown; booleans render as ‘–flag` / `–no-flag`.
519 520 521 522 523 524 525 526 527 528 529 530 531 532 |
# File 'lib/lux-hammer.rb', line 519 def (cmd, full, positional, opts) parts = ["#{program_name} #{full}"] cmd..each do |o| val = opts[o.name] next if val.nil? || val == o.default if o.boolean? parts << (val ? "--#{o.name}" : "--no-#{o.name}") else parts << "--#{o.name}=#{val}" end end parts.concat(positional) Shell.say "> #{parts.join(' ')}", :gray end |
.program_name ⇒ Object
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.
109 110 111 |
# File 'lib/lux-hammer.rb', line 109 def program_name @program_name ||= default_program_name 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).
409 410 411 412 413 414 415 416 417 418 419 420 421 422 |
# File 'lib/lux-hammer.rb', line 409 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.
426 427 428 429 430 431 432 433 434 435 436 |
# File 'lib/lux-hammer.rb', line 426 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 |
.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.
237 238 239 |
# File 'lib/lux-hammer.rb', line 237 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.
746 747 748 749 750 751 752 753 754 755 756 757 758 759 |
# File 'lib/lux-hammer.rb', line 746 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.
537 538 539 540 541 542 543 544 |
# File 'lib/lux-hammer.rb', line 537 def run_before_hooks(instance, opts) ran = Thread.current[:hammer_before_ran] ||= {} ancestor_chain.each do |klass| next if ran[klass.object_id] ran[klass.object_id] = true klass.before_hooks.each { |hook| instance.instance_exec(opts, &hook) } end end |
.run_command(cmd, argv, full: nil) ⇒ Object
492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 |
# File 'lib/lux-hammer.rb', line 492 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 (cmd, full || cmd.name, positional, opts) instance = new run_before_hooks(instance, opts) run_needs(cmd) 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 |
.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).
548 549 550 551 552 553 554 555 556 557 558 559 |
# File 'lib/lux-hammer.rb', line 548 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 |
.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.
679 680 681 682 683 684 685 686 687 |
# File 'lib/lux-hammer.rb', line 679 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 |
.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.
294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
# File 'lib/lux-hammer.rb', line 294 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`.
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 |
# File 'lib/lux-hammer.rb', line 132 def task(name, &block) cmd = Command.new(name: name.to_s) 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 commands[cmd.name] = cmd # `task` ignores pending class-level state, but clear it so a # later `def` doesn't accidentally consume stale metadata. @pending_desc = nil @pending_examples = [] @pending_options = [] @pending_alts = [] @pending_needs = [] end |
.usage_signature(cmd) ⇒ Object
“ URL [ENV] [OPTIONS]” - shows the positional-fill names for declared non-boolean opts (required bare, optional bracketed), plus a generic [OPTIONS] tail if any flags exist.
692 693 694 695 696 697 698 699 700 701 |
# File 'lib/lux-hammer.rb', line 692 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 |
.with_target(klass) ⇒ Object
Push ‘klass` as the current Hammer target for the duration of the block. Top-level DSL methods (`task`, `namespace`, `before` - see `Hammer::DSL`) read this thread-local, so files `require`d from inside a Hammerfile register against the right target.
259 260 261 262 263 264 265 |
# File 'lib/lux-hammer.rb', line 259 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.
735 736 737 |
# File 'lib/lux-hammer.rb', line 735 def hammer(name, *args, **opts) self.class.root.hammer(name, *args, **opts) end |