Class: Hammer
- Inherits:
-
Object
- Object
- Hammer
- 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
- .alt(*names) ⇒ Object
-
.ancestor_chain ⇒ Object
Root -> ...
-
.app_desc(text = nil) ⇒ Object
Top-level description for the whole CLI.
-
.app_local_location?(loc) ⇒ Boolean
True when a captured location lives inside the main app.
- .attach_stdin!(opts) ⇒ Object
-
.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
hammerbinary. -
.collected_global_options ⇒ Object
Root -> self chain of global_opt declarations (outer first).
- .commands ⇒ Object
- .cron(expr) ⇒ 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/foowhen invoked from the project root), otherwise the basename (e.g.luxfor a globally installed bin in PATH). -
.desc(text) ⇒ Object
----- class-level DSL for
def-style commands --------------------- Set pending metadata that the nextdefwill consume. -
.dispatches_to_builtin?(argv) ⇒ Boolean
True if argv goes through a built-in dispatch path (
:defaultor:help) - meaning barehammer, leading-flag invocations likehammer -h, or explicit help requests. -
.dotenv(flag = true) ⇒ Object
Toggle auto-loading of
.env/.env.localfor thehammerbinary. - .dotenv_enabled? ⇒ Boolean
-
.each_command(prefix = nil, include_builtins: true, &block) ⇒ Object
Yield [full_colon_path, Command] for every command in this class and all nested namespaces.
-
.effective_options(cmd) ⇒ Object
Per-task options + globals.
-
.emit_rows(rows, width) ⇒ Object
rowsis an array of [full_path, task_meta] - the per-task hashes fromCommand#to_h(also used to render namespace listings). - .example(text) ⇒ Object
-
.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). - .finalize_option_list!(options) ⇒ 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.
-
.global_opt(name, **o) ⇒ Object
Options available on every command under this class (and children).
- .global_options ⇒ Object
-
.hammer(name, *args, **opts) ⇒ Object
Programmatic dispatch by name.
- .inherited(sub) ⇒ Object
-
.launch_gui!(project_dir) ⇒ Object
Spawn the vendored macOS GUI (gui/Hammer.app), pointed at the project dir and this hammer binary.
-
.load(*paths, **kwargs) ⇒ Object
Load Hammerfile fragments and register their commands on this class.
-
.loader ⇒ Object
Per-target Loader instance.
-
.looks_like_builtin?(argv) ⇒ Boolean
True if argv targets the reserved
h:built-in namespace (h,h:,h:update, ...). - .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
-
.prepare_json!(opts, key: :json) ⇒ Object
Convenience aliases so recipes can write
Hammer.prepare_json!(opts)without the Input module path. -
.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(include_builtins: true) ⇒ Object
Pure rendering off
export_spec- the same grouped structureh:jsonemits, so the listing and the JSON can never drift. -
.print_extras ⇒ Object
Extras shown only in the extended (
help/-h/--help) view: global flags, GitHub footer, and a Hammerfile example for thehammerbinary. - .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
Listed under
Default task options:in--helpso users see what flags fire on bare-flag invocation (hammer --versionetc). -
.print_hammerfile_example ⇒ Object
Hammerfile cheat-sheet shown under
hammer --help. -
.print_help(target = nil, expanded: false, extended: false) ⇒ Object
extended: trueis the verbosehelp/-h/--helpform - appends global flags, the GitHub footer, and (for the hammer binary) a Hammerfile example. -
.print_namespace_help(prefix, ns, expanded: false, extended: false) ⇒ Object
extended:is accepted for parity withprint_helpbut intentionally not used here - the global-flags / Hammerfile-example / footer block is root-help-only. -
.print_recipes_section ⇒ Object
Lists recipes (gem + user-dir) under their own section in
hammer --help. -
.print_run_banner(cmd, full, positional, opts, options: nil) ⇒ Object
Print a gray "> prog cmd --opt=val ARG" banner before a command runs.
-
.print_top_banner ⇒ Object
Gray "lux-hammer X.Y.Z -
" line shown above top-level help in both bare-invocation and --helpmodes, so the link is always one glance away. -
.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. -
.recipe(name, argv = ARGV) ⇒ Object
Entry point for recipe stubs in PATH.
-
.relativize_path(path) ⇒ Object
Trim the cwd prefix off an absolute path so redefinition warnings read as
./lib/tasks/foo.rbinstead of a long absolute path. -
.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
beforehooks from root down through the namespace chain. - .run_command(cmd, argv, full: nil, quiet: false) ⇒ Object
-
.run_needs(cmd) ⇒ Object
Dispatch a command's declared
needsthrough the root class, with per-invocation dedupe. - .run_or_exit(*cmd, **opts) ⇒ Object
-
.run_shebang(path, argv) ⇒ Object
Evaluate a shebang script as a self-contained CLI.
-
.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.
-
.self_update ⇒ Object
hammer h:update: pull main in the install-script checkout and reinstall the gem. -
.shebang_script(arg) ⇒ Object
Returns the script path if
arglooks like a shebang script that delegates to hammer (first line starts with#!and mentionshammer). -
.source_location_of(block) ⇒ Object
"file:line" of the block that defined a task/namespace.
-
.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.
-
.warn_redefinition(kind, name, prev_loc, new_loc) ⇒ Object
Emit a yellow [hammer] warning on stderr when a task/namespace is redefined.
-
.with_target(klass) ⇒ Object
Push
klassas 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
95 |
# File 'lib/lux-hammer.rb', line 95 def alt(*names) ; @pending_alts.concat(names) end |
.ancestor_chain ⇒ Object
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.
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_hooks ⇒ Object
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_options ⇒ Object
Root -> self chain of global_opt declarations (outer first).
274 275 276 |
# File 'lib/lux-hammer.rb', line 274 def ancestor_chain.flat_map { |k| k. } end |
.commands ⇒ Object
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_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).
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.(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
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.
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
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 (cmd) extras = return cmd. if extras.empty? merged = cmd. + 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!() claimed = ['-h'] .each do |o| o.aliases.each { |a| claimed << a if a.length == 2 && a.start_with?('-') && a[1] != '-' } end .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 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.(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_options ⇒ Object
269 270 271 |
# File 'lib/lux-hammer.rb', line 269 def @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.('../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.($PROGRAM_NAME)) pid = Process.spawn(bin, '--project', File.(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 |
.loader ⇒ Object
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.
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 |
.namespaces ⇒ Object
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 |
.parent ⇒ Object
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 |
.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.
1077 1078 1079 1080 1081 1082 1083 1084 1085 |
# File 'lib/lux-hammer.rb', line 1077 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
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. global = 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 |
.print_command_list(include_builtins: true) ⇒ Object
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 |
.print_extras ⇒ Object
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 unless hammer_bin end |
.print_footer ⇒ Object
905 906 907 908 |
# File 'lib/lux-hammer.rb', line 905 def Shell.say '' Shell.say "powered by hammer (v#{VERSION}) - #{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).
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 |
.print_global_flags ⇒ Object
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..empty? Shell.say '' Shell.say 'Default task options:', :yellow default..each { |o| Shell.say " #{o.usage}" } end |
.print_hammerfile_example ⇒ Object
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 |
.print_help(target = nil, expanded: false, extended: false) ⇒ Object
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 != ':' = 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, expanded: ) if ns Shell.print_error("unknown: #{target}") return end 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 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 |
.print_namespace_help(prefix, ns, expanded: false, extended: false) ⇒ Object
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 |
.print_recipes_section ⇒ Object
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_run_banner(cmd, full, positional, opts, options: nil) ⇒ 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 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 (cmd, full, positional, opts, options: nil) ||= (cmd) parts = ["#{program_name} #{full}"] .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 |
.print_top_banner ⇒ Object
Gray "lux-hammer X.Y.Z - --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 return unless root.instance_variable_get(:@hammer_binary) Shell.say "lux-hammer #{VERSION} - #{HOMEPAGE}", :gray Shell.say '' 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.
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 |
.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.
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) = (cmd) positional, opts = Parser.new().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) (cmd, full || cmd.name, positional, opts, 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.) 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).
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_update ⇒ Object
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) = (cmd) pos = .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 .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 |