lux-hammer 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bb668fabef5892decb2ee58add685f52dad7fe742ae0cd8597ba62da576c3d13
4
- data.tar.gz: f74beef4ccb0af5aaa546c7c4a2dec29bc08fffdb929edfa5f3dcdf261627b69
3
+ metadata.gz: d5fc940f903d901587d17cd94c6f34eed5f92de64ded400a882228e34037cad1
4
+ data.tar.gz: 480bd5d42e755ea6c92d956e48eb146777718e6be46a74042b9c8f0192479c27
5
5
  SHA512:
6
- metadata.gz: 9fddfa7b7f657cb1baa30793e46fe331cd3f65704ac31ce582bcfc851b154a15eb72e5610e0a0b8af29ed99a763823f7c7bbdd1ef7ad33f40b95fcd803a6216f
7
- data.tar.gz: 6b176bb61def54c305a28f0c9b9419074ed16c14c1b8412ccc084cd2ed5f1a0bfa93d947aac9fcce704cde00c2307bb897e0255e200f1bf1bb077f2795f40330
6
+ metadata.gz: 33bf95c45d0eba2bf3d37aa55f7dcb6dcf317c25b3badeaba965f00f582c27cb92285db07f360e290c3c8d41f82f575bbb6171583bd5cce106b065843f7ad08d
7
+ data.tar.gz: 2dec31a7d86c8a3f525fa2beac4134576a634141001550b9226eed0ed723f6768a56bf404be4fbdf239a954eccb094be060695838a7c25cd820aae72af7db186
data/.version CHANGED
@@ -1 +1 @@
1
- 0.2.5
1
+ 0.2.7
data/AGENTS.md CHANGED
@@ -84,6 +84,12 @@ At class or `Hammerfile` scope:
84
84
 
85
85
  * `task :name do ... end`
86
86
  * `namespace :name do ... end`
87
+ * `dotenv false` - opt out of auto `.env` / `.env.local` loading.
88
+ Only meaningful from the `hammer` binary (`Hammer.cli`); the load
89
+ happens after Hammerfile evaluation, before dispatch. Shell-set vars
90
+ always win (`ENV[k] ||= v`). For full dotenv-gem features
91
+ (interpolation, multiline) keep `dotenv false` and call `Dotenv.load`
92
+ from a `before` hook instead.
87
93
  * `load` / `load auto: true` / `load 'path/file.rb'` / `load 'glob/*.rb'` /
88
94
  `load 'some/dir'` - pull in Hammerfile fragments from `*_hammer.rb`
89
95
  files. Paths resolve relative to the caller's file. A directory
@@ -140,6 +146,23 @@ explicit ADR-level discussion. Keys:
140
146
  * The root `Hammer` subclass holds `@commands` and `@namespaces`.
141
147
  * `resolve('a:b:c')` walks `@namespaces['a'].namespaces['b']` and
142
148
  finds command `c` in the last class.
149
+ * Each segment is fuzzy-matched: exact name/alt first, then prefix
150
+ (`b` -> `build`), then substring (`es` -> `test`). Per-scope: matching
151
+ only looks at this class's commands/namespaces, not globally across
152
+ the tree. Ambiguous fuzzy matches raise `Hammer::AmbiguousMatch`,
153
+ which `dispatch` catches and prints as
154
+ `multiple commands match 'b': bake, build`. Same fallback applies to
155
+ namespaces via `find_namespace`.
156
+ * `resolve` and `resolve_namespace` return a `canonical` colon-path
157
+ alongside the resolved class/command, so help banners and the
158
+ pre-run banner display the canonical name even when the user typed
159
+ a fuzzy prefix.
160
+ * Before a command's handler runs, `run_command` prints a gray
161
+ `> prog cmd --opt=val ARG` banner so it's visible which command was
162
+ actually picked when fuzzy matching kicks in. Only options that
163
+ differ from their default are listed; booleans render as `--flag`
164
+ / `--no-flag`. Help paths (`-h`, bare namespace) short-circuit
165
+ before the banner.
143
166
  * There is **no per-level dispatch**. A namespace is a container, not a
144
167
  CLI of its own. Do not reintroduce `subclass.start(remaining_argv)`.
145
168
  * `start(argv)` is a two-step pipeline: `split_chain(argv)` (private)
data/README.md CHANGED
@@ -1,16 +1,17 @@
1
1
  # hammer
2
2
 
3
- The bastard Frankenstein child of Rake, Thor, and Joshua. Sewn
4
- together from three good ideas, with the rest of each parent left on
3
+ The bastard Frankenstein child of Rake](https://github.com/ruby/rake),
4
+ [Thor](https://github.com/rails/thor), and [Joshua](https://github.com/dux/joshua).
5
+ Sewn together from three good ideas, with the rest of each parent left on
5
6
  the cutting room floor.
6
7
 
7
8
  Drop a `Hammerfile`, run `hammer`, ship. AI LLM-s love `hammer`.
8
9
 
9
10
  ```ruby
10
- namespace :db do # Rake-style colon paths
11
- task :migrate do # Joshua-style task block
11
+ namespace :db do # Rake-style colon paths
12
+ task :migrate do # Joshua-style task block
12
13
  desc 'Run pending migrations'
13
- opt :pretend, type: :boolean, alias: :p # Thor-style typed opts
14
+ opt :pretend, type: :boolean, alias: :p # Thor-style typed opts
14
15
  proc do |o|
15
16
  say.green "migrating pretend=#{o[:pretend].inspect}"
16
17
  end
@@ -468,7 +469,7 @@ runs before every command in that scope (and its nested namespaces).
468
469
  Hooks fire outer -> inner, then the command's handler:
469
470
 
470
471
  ```ruby
471
- before { Dotenv.load } # runs before every command
472
+ before { hammer :env } # runs before every command
472
473
 
473
474
  namespace :db do
474
475
  before { hammer :env } # runs before every db:* command
@@ -478,6 +479,15 @@ namespace :db do
478
479
  end
479
480
  ```
480
481
 
482
+ `.env` and `.env.local` next to the `Hammerfile` are loaded
483
+ automatically by the `hammer` binary - no `before` hook needed.
484
+ Shell-set vars are never overwritten, and `.env.local` overrides
485
+ `.env`. Put `dotenv false` at the top of the Hammerfile to opt out:
486
+
487
+ ```ruby
488
+ dotenv false # disable auto .env loading
489
+ ```
490
+
481
491
  `before` is intentionally not available inside `task` - the proc body
482
492
  *is* the command body, just put the setup line at the top of the proc.
483
493
 
@@ -21,6 +21,11 @@ class Hammer
21
21
  @klass.before(&block)
22
22
  end
23
23
 
24
+ # Opt out of auto `.env` loading in `Hammer.cli`. Default is on.
25
+ def dotenv(flag = true)
26
+ @klass.dotenv(flag)
27
+ end
28
+
24
29
  # Same surface as `Hammer.load`. Resolved relative to the file that
25
30
  # called us, so `load auto: true` inside a Hammerfile picks up
26
31
  # *_hammer.rb under the Hammerfile's directory.
@@ -28,5 +33,33 @@ class Hammer
28
33
  anchor = Loader.caller_anchor(caller_locations(1, 1).first)
29
34
  @klass.loader.load(anchor, paths, kwargs)
30
35
  end
36
+
37
+ # instance_eval wrapper that also publishes the current Hammer target
38
+ # via Thread.current[:hammer_target]. That lets `*_hammer.rb` files
39
+ # pulled in through Ruby's own `require` (e.g. `Dir.require_all
40
+ # 'lib/tasks'` inside a Hammerfile) call `task`/`namespace`/`before`
41
+ # at top-level scope and have them register on the right target.
42
+ def evaluate(source = nil, path = nil, &block)
43
+ Hammer.with_target(@klass) do
44
+ block ? instance_eval(&block) : instance_eval(source, path)
45
+ end
46
+ end
47
+ end
48
+
49
+ # Top-level Hammer DSL. Extended onto `main` (below) so any file
50
+ # `require`d from within a Hammerfile - where `self == main` - can
51
+ # still call `task`, `namespace`, and `before`. Delegates to whichever
52
+ # Hammer subclass is currently being evaluated.
53
+ module DSL
54
+ %i[task namespace before dotenv].each do |m|
55
+ define_method(m) do |*args, &block|
56
+ target = Thread.current[:hammer_target]
57
+ raise Hammer::Error, "`#{m}` called outside a Hammer context " \
58
+ '(Hammerfile / Hammer.run block / *_hammer.rb)' unless target
59
+ target.send(m, *args, &block)
60
+ end
61
+ end
31
62
  end
32
63
  end
64
+
65
+ TOPLEVEL_BINDING.eval('self').extend(Hammer::DSL)
@@ -0,0 +1,50 @@
1
+ class Hammer
2
+ # Tiny built-in .env loader. Kept dependency-free on purpose - the
3
+ # gemspec advertises zero runtime deps.
4
+ #
5
+ # `Hammer.cli` calls `Dotenv.load(Dir.pwd)` after evaluating the
6
+ # Hammerfile (unless the Hammerfile said `dotenv false`), so vars
7
+ # from `.env` are available to every command handler.
8
+ #
9
+ # Semantics:
10
+ # * `.env` is loaded, then `.env.local` (latter overrides former).
11
+ # * Missing files are silently skipped - auto-load must not raise.
12
+ # * Existing `ENV[k]` always wins (`ENV[k] ||= v`); shell-set values
13
+ # are never clobbered.
14
+ # * Supported lines: `KEY=value`, `KEY="value"`, `KEY='value'`, optional
15
+ # leading `export `, `#` comments, blank lines.
16
+ # * NOT supported: `${VAR}` interpolation, multiline values, inline
17
+ # comments after a value. Reach for the `dotenv` gem from a `before`
18
+ # hook if you need those.
19
+ module Dotenv
20
+ FILES ||= %w[.env .env.local].freeze
21
+
22
+ def self.load(dir)
23
+ FILES.each do |name|
24
+ path = File.join(dir, name)
25
+ next unless File.file?(path)
26
+ parse(File.read(path)).each { |k, v| ENV[k] ||= v }
27
+ end
28
+ end
29
+
30
+ def self.parse(text)
31
+ out = {}
32
+ text.each_line do |line|
33
+ line = line.strip
34
+ next if line.empty? || line.start_with?('#')
35
+ line = line.sub(/\Aexport\s+/, '')
36
+ key, sep, val = line.partition('=')
37
+ next if sep.empty?
38
+ key = key.strip
39
+ next if key.empty?
40
+ val = val.strip
41
+ if (val.start_with?('"') && val.end_with?('"')) ||
42
+ (val.start_with?("'") && val.end_with?("'"))
43
+ val = val[1..-2]
44
+ end
45
+ out[key] = val
46
+ end
47
+ out
48
+ end
49
+ end
50
+ end
data/lib/hammer/loader.rb CHANGED
@@ -72,7 +72,7 @@ class Hammer
72
72
  def load_one(abs_path)
73
73
  return if @loaded[abs_path]
74
74
  @loaded[abs_path] = true
75
- Builder.new(@target).instance_eval(File.read(abs_path), abs_path)
75
+ Builder.new(@target).evaluate(File.read(abs_path), abs_path)
76
76
  rescue Hammer::Error
77
77
  raise
78
78
  rescue StandardError => e
data/lib/hammer/option.rb CHANGED
@@ -1,9 +1,9 @@
1
1
  class Hammer
2
2
  # Single option/flag definition.
3
3
  class Option
4
- attr_reader :name, :type, :default, :desc, :aliases, :required
4
+ attr_reader :name, :type, :default, :desc, :aliases, :required, :placeholder
5
5
 
6
- ALLOWED_KEYS ||= %i[type default desc alias req].freeze
6
+ ALLOWED_KEYS ||= %i[type default desc alias req placeholder].freeze
7
7
  RESERVED_FLAGS ||= %w[-h --help].freeze
8
8
 
9
9
  def initialize(name, **opts)
@@ -13,12 +13,15 @@ class Hammer
13
13
  "unknown opt parameter(s) for :#{name}: #{bad.join(', ')}. " \
14
14
  "allowed: #{ALLOWED_KEYS.join(', ')}"
15
15
  end
16
- @name = name.to_sym
17
- @type = opts[:type] || :string
18
- @default = opts[:default]
19
- @desc = opts[:desc]
20
- @aliases = Array(opts[:alias]).map { |a| Option.normalize_alias(a) }
21
- @required = opts[:req] ? true : false
16
+ @name = name.to_sym
17
+ @type = opts[:type] || :string
18
+ @default = opts[:default]
19
+ @desc = opts[:desc]
20
+ @aliases = Array(opts[:alias]).map { |a| Option.normalize_alias(a) }
21
+ @required = opts[:req] ? true : false
22
+ # Custom usage placeholder, e.g. `placeholder: 't/f'` -> `--log t/f`.
23
+ # Falls back to uppercased name (`--log LOG`) when nil.
24
+ @placeholder = opts[:placeholder]
22
25
 
23
26
  # Reserve -h / --help so every command supports them uniformly.
24
27
  if RESERVED_FLAGS.include?(switch)
@@ -60,7 +63,7 @@ class Hammer
60
63
 
61
64
  def usage
62
65
  flag = switch
63
- flag += " #{name.to_s.upcase}" unless boolean?
66
+ flag += " #{placeholder || name.to_s.upcase}" unless boolean?
64
67
  line = aliases.empty? ? flag : "#{aliases.join(', ')}, #{flag}"
65
68
  suffix = annotation
66
69
  line = "#{line.ljust(28)} #{desc}" if desc
data/lib/lux-hammer.rb CHANGED
@@ -5,6 +5,7 @@ require_relative 'hammer/command'
5
5
  require_relative 'hammer/loader'
6
6
  require_relative 'hammer/builder'
7
7
  require_relative 'hammer/command_builder'
8
+ require_relative 'hammer/dotenv'
8
9
 
9
10
  # Thor-inspired tiny CLI builder.
10
11
  #
@@ -40,7 +41,11 @@ require_relative 'hammer/command_builder'
40
41
  class Hammer
41
42
  include Shell
42
43
 
43
- Error ||= Class.new(StandardError)
44
+ Error ||= Class.new(StandardError)
45
+ AmbiguousMatch ||= Class.new(Error)
46
+
47
+ # Gem version, read once from the bundled .version file.
48
+ VERSION ||= File.read(File.expand_path('../.version', __dir__)).strip
44
49
 
45
50
  class << self
46
51
  def inherited(sub)
@@ -178,7 +183,7 @@ class Hammer
178
183
  # "myapp ns:cmd" with the same prefix everywhere - and so the value
179
184
  # captured pre-chdir (see `Hammer.cli`) survives into nested classes.
180
185
  sub.instance_variable_set(:@program_name, program_name)
181
- sub.class_eval(&block) if block
186
+ Hammer.with_target(sub) { sub.class_eval(&block) } if block
182
187
  @namespaces[name.to_s] = sub
183
188
  end
184
189
 
@@ -199,6 +204,18 @@ class Hammer
199
204
  @before_hooks
200
205
  end
201
206
 
207
+ # Toggle auto-loading of `.env` / `.env.local` for the `hammer`
208
+ # binary. Default is ON. Call `dotenv false` at the top of a
209
+ # Hammerfile to suppress. No-op for standalone `MyCli.start` -
210
+ # auto-load only fires from `Hammer.cli`.
211
+ def dotenv(flag = true)
212
+ @dotenv_enabled = flag
213
+ end
214
+
215
+ def dotenv_enabled?
216
+ @dotenv_enabled != false
217
+ end
218
+
202
219
  def parent
203
220
  @parent
204
221
  end
@@ -235,6 +252,18 @@ class Hammer
235
252
  @loader ||= Loader.new(self)
236
253
  end
237
254
 
255
+ # Push `klass` as the current Hammer target for the duration of the
256
+ # block. Top-level DSL methods (`task`, `namespace`, `before` - see
257
+ # `Hammer::DSL`) read this thread-local, so files `require`d from
258
+ # inside a Hammerfile register against the right target.
259
+ def with_target(klass)
260
+ prev = Thread.current[:hammer_target]
261
+ Thread.current[:hammer_target] = klass
262
+ yield
263
+ ensure
264
+ Thread.current[:hammer_target] = prev
265
+ end
266
+
238
267
  # Load Hammerfile fragments and register their commands on this
239
268
  # class. Rake-style: split a CLI across multiple files.
240
269
  #
@@ -295,60 +324,119 @@ class Hammer
295
324
  argv = argv.dup
296
325
  name = argv.shift
297
326
 
298
- if name.nil? || name == 'help' || name == '-h' || name == '--help'
327
+ if name.nil?
328
+ # Bare invocation of the `hammer` binary: print a gem banner
329
+ # above the help so users can see which lux-hammer they have.
330
+ # Skipped for `-h` / `help` (those stay terse) and for user-
331
+ # built CLIs (their own program name, not lux-hammer).
332
+ if root.instance_variable_get(:@hammer_binary)
333
+ Shell.say "lux-hammer #{VERSION}"
334
+ Shell.say ''
335
+ end
336
+ return print_help
337
+ end
338
+
339
+ if name == 'help' || name == '-h' || name == '--help'
299
340
  target = argv.shift
300
341
  return print_help(target)
301
342
  end
302
343
 
303
- # Trailing colon ("db:") -> expanded namespace listing with full
304
- # per-command help on every task. Bare ":" expands the root.
344
+ # Trailing colon ("db:") -> namespace listing. Bare ":" lists root.
305
345
  if name.end_with?(':') && name != ':'
306
346
  bare = name.chomp(':')
307
- ns = resolve_namespace(bare)
308
- return print_namespace_help(bare, ns, full: true) if ns
347
+ ns, canonical = resolve_namespace(bare)
348
+ return print_namespace_help(canonical, ns) if ns
309
349
  Shell.print_error("unknown namespace: #{bare}")
310
350
  print_help
311
351
  exit 1
312
352
  elsif name == ':'
313
- return print_help(nil, full: true)
353
+ return print_help
314
354
  end
315
355
 
316
- cmd, owner = resolve(name)
317
- return owner.run_command(cmd, argv, full: name) if cmd
356
+ cmd, owner, canonical = resolve(name)
357
+ return owner.run_command(cmd, argv, full: canonical) if cmd
318
358
 
319
- ns = resolve_namespace(name)
320
- return print_namespace_help(name, ns) if ns
359
+ ns, canonical = resolve_namespace(name)
360
+ return print_namespace_help(canonical, ns) if ns
321
361
 
322
362
  Shell.print_error("unknown command: #{name}")
323
363
  print_help
324
364
  exit 1
365
+ rescue AmbiguousMatch => e
366
+ Shell.print_error(e.message)
367
+ exit 1
325
368
  end
326
369
 
327
370
  public
328
371
 
329
- # Find a command by canonical name or alt within this class.
372
+ # Find a command by canonical name or alt within this class. Falls
373
+ # back to fuzzy match (prefix first, then substring) when no exact
374
+ # hit. Raises AmbiguousMatch if the fuzzy pass matches more than one.
330
375
  def find_command(name)
331
- commands[name.to_s] || commands.values.find { |c| c.matches?(name) }
376
+ name = name.to_s
377
+ exact = commands[name] || commands.values.find { |c| c.matches?(name) }
378
+ return exact if exact
379
+ fuzzy_pick(name, commands.values, 'command') { |c| [c.name, *c.alts] }
380
+ end
381
+
382
+ # Find a namespace by name within this class. Same fuzzy fallback
383
+ # as find_command.
384
+ def find_namespace(name)
385
+ name = name.to_s
386
+ return namespaces[name] if namespaces.key?(name)
387
+ pair = fuzzy_pick(name, namespaces.to_a, 'namespace') { |p| [p.first] }
388
+ pair&.last
332
389
  end
333
390
 
334
- # Walk "ns1:ns2:cmd" -> [command, owning_class]. Returns [nil, nil]
335
- # if any segment is missing or the final segment isn't a command.
391
+ # Walk "ns1:ns2:cmd" -> [command, owning_class, canonical_path].
392
+ # Returns [nil, nil, nil] if any segment is missing or the final
393
+ # segment isn't a command. canonical_path uses the canonical name
394
+ # of every segment (so a fuzzy `b` resolves to `build` in the path).
336
395
  def resolve(path)
337
396
  parts = path.to_s.split(':')
338
397
  klass = self
398
+ canonical = []
339
399
  parts[0..-2].each do |ns|
340
- klass = klass.namespaces[ns] or return [nil, nil]
400
+ sub = klass.find_namespace(ns) or return [nil, nil, nil]
401
+ canonical << klass.namespaces.key(sub)
402
+ klass = sub
341
403
  end
342
404
  cmd = klass.find_command(parts.last)
343
- cmd ? [cmd, klass] : [nil, nil]
405
+ return [nil, nil, nil] unless cmd
406
+ canonical << cmd.name
407
+ [cmd, klass, canonical.join(':')]
344
408
  end
345
409
 
346
- # Walk "ns1:ns2" -> namespace class, or nil if any segment missing.
410
+ # Walk "ns1:ns2" -> [namespace_class, canonical_path].
411
+ # Returns [nil, nil] if any segment is missing.
347
412
  def resolve_namespace(path)
348
413
  parts = path.to_s.split(':')
349
414
  klass = self
350
- parts.each { |ns| klass = klass.namespaces[ns] or return nil }
351
- klass
415
+ canonical = []
416
+ parts.each do |ns|
417
+ sub = klass.find_namespace(ns) or return [nil, nil]
418
+ canonical << klass.namespaces.key(sub)
419
+ klass = sub
420
+ end
421
+ [klass, canonical.join(':')]
422
+ end
423
+
424
+ # Shared fuzzy matcher used by find_command and find_namespace.
425
+ # The block returns the strings to match against for each item
426
+ # (canonical name plus alts for commands, just the key for namespaces).
427
+ # Tries prefix match first, then substring; raises AmbiguousMatch
428
+ # when either pass hits more than one item.
429
+ def fuzzy_pick(name, items, kind, &keys_for)
430
+ [:start_with?, :include?].each do |op|
431
+ matches = items.select { |item| keys_for.call(item).any? { |k| k.send(op, name) } }
432
+ next if matches.empty?
433
+ if matches.size > 1
434
+ labels = matches.map { |m| keys_for.call(m).first }.sort
435
+ raise AmbiguousMatch, "multiple #{kind}s match '#{name}': #{labels.join(', ')}"
436
+ end
437
+ return matches.first
438
+ end
439
+ nil
352
440
  end
353
441
 
354
442
  # Programmatic dispatch by name. Useful for scripting and tests.
@@ -394,6 +482,7 @@ class Hammer
394
482
 
395
483
  positional, opts = Parser.new(cmd.options).parse(argv)
396
484
  opts[:args] = positional
485
+ print_run_banner(cmd, full || cmd.name, positional, opts)
397
486
  instance = new
398
487
  run_before_hooks(instance, opts)
399
488
  run_needs(cmd)
@@ -409,6 +498,25 @@ class Hammer
409
498
  exit 1
410
499
  end
411
500
 
501
+ # Print a gray "> prog cmd --opt=val ARG" banner before a command
502
+ # runs. Helps see what was actually picked when fuzzy matching
503
+ # resolved a partial name. Only opts that differ from their default
504
+ # are shown; booleans render as `--flag` / `--no-flag`.
505
+ def print_run_banner(cmd, full, positional, opts)
506
+ parts = ["#{program_name} #{full}"]
507
+ cmd.options.each do |o|
508
+ val = opts[o.name]
509
+ next if val.nil? || val == o.default
510
+ if o.boolean?
511
+ parts << (val ? "--#{o.name}" : "--no-#{o.name}")
512
+ else
513
+ parts << "--#{o.name}=#{val}"
514
+ end
515
+ end
516
+ parts.concat(positional)
517
+ Shell.say "> #{parts.join(' ')}", :gray
518
+ end
519
+
412
520
  # Fire `before` hooks from root down through the namespace chain.
413
521
  # Each class's hooks fire at most once per top-level `start`, so
414
522
  # prereqs dispatched via `needs` won't re-trigger them.
@@ -444,18 +552,18 @@ class Hammer
444
552
 
445
553
  def print_help(target = nil, full: false)
446
554
  if target
447
- # `help ns:` is equivalent to `ns:` - expanded namespace listing.
555
+ # `help ns:` is equivalent to `ns:` - namespace listing.
448
556
  if target.end_with?(':') && target != ':'
449
557
  bare = target.chomp(':')
450
- ns = resolve_namespace(bare)
451
- return print_namespace_help(bare, ns, full: true) if ns
558
+ ns, canonical = resolve_namespace(bare)
559
+ return print_namespace_help(canonical, ns) if ns
452
560
  Shell.print_error("unknown: #{target}")
453
561
  return
454
562
  end
455
- cmd, _ = resolve(target)
456
- return print_command_help(cmd, target) if cmd
457
- ns = resolve_namespace(target)
458
- return print_namespace_help(target, ns, full: full) if ns
563
+ cmd, _, canonical = resolve(target)
564
+ return print_command_help(cmd, canonical) if cmd
565
+ ns, canonical = resolve_namespace(target)
566
+ return print_namespace_help(canonical, ns, full: full) if ns
459
567
  Shell.print_error("unknown: #{target}")
460
568
  return
461
569
  end
@@ -467,6 +575,7 @@ class Hammer
467
575
  Shell.say ''
468
576
  print_command_list(self)
469
577
  end
578
+ print_global_flags
470
579
  print_footer
471
580
  end
472
581
 
@@ -478,6 +587,7 @@ class Hammer
478
587
  Shell.say ''
479
588
  print_command_list(ns, prefix)
480
589
  end
590
+ print_global_flags
481
591
  print_footer
482
592
  end
483
593
 
@@ -490,6 +600,16 @@ class Hammer
490
600
 
491
601
  HOMEPAGE ||= 'https://github.com/dux/hammer'.freeze
492
602
 
603
+ # Global flags only exist when invoked via the `hammer` binary
604
+ # (see `Hammer.cli`), not for user-built CLIs that call `start`
605
+ # on their own subclass.
606
+ def print_global_flags
607
+ return unless root.instance_variable_get(:@hammer_binary)
608
+ Shell.say ''
609
+ Shell.say 'Global:', :yellow
610
+ Shell.say ' --ai # Print AGENTS.md (AI-friendly Hammerfile authoring docs)'
611
+ end
612
+
493
613
  def print_footer
494
614
  Shell.say ''
495
615
  Shell.say "powered by hammer - #{HOMEPAGE}", :gray
@@ -505,13 +625,13 @@ class Hammer
505
625
 
506
626
  # group by "section" = everything between the view prefix and the
507
627
  # leaf name. Bare leaves go in :root.
508
- groups = rows.group_by { |full, _| section_for(full, prefix) }
509
- width = rows.map { |full, c| label_for(full, c).length }.max
628
+ groups = rows.group_by { |full, _| section_for(full, prefix, klass) }
629
+ width = rows.map { |full, _| full.length }.max
510
630
  first = true
511
631
 
512
632
  if (rooted = groups.delete(:root))
513
633
  Shell.say 'Commands:', :yellow
514
- emit_rows(rooted.sort_by { |full, _| full }, width)
634
+ emit_rows(rooted.sort_by { |full, _| [full.count(':'), full] }, width)
515
635
  first = false
516
636
  end
517
637
 
@@ -519,14 +639,14 @@ class Hammer
519
639
  Shell.say unless first
520
640
  first = false
521
641
  Shell.say "#{section}:", :yellow
522
- emit_rows(items.sort_by { |full, _| full }, width)
642
+ emit_rows(items.sort_by { |full, _| [full.count(':'), full] }, width)
523
643
  end
524
644
  end
525
645
 
526
646
  def emit_rows(rows, width)
527
647
  rows.each do |full, c|
528
- label = label_for(full, c)
529
- Shell.say " #{program_name} #{label.ljust(width)} # #{c.brief}"
648
+ brief = c.alts.empty? ? c.brief : "#{c.brief} (alt: #{c.alts.join(', ')})"
649
+ Shell.say " #{program_name} #{full.ljust(width)} # #{brief}"
530
650
  end
531
651
  end
532
652
 
@@ -534,17 +654,18 @@ class Hammer
534
654
  # for 'db:users:list' viewed from 'db'; :root if the command sits at
535
655
  # the view's top level. Only the first segment under the view groups,
536
656
  # so deeper paths fold into their top-level section.
537
- def section_for(full, prefix)
538
- segs = full.split(':')[0..-2]
539
- if prefix && !prefix.empty?
540
- segs = segs[prefix.split(':').size..] || []
657
+ #
658
+ # Exception: a bare command that shares its name with a sibling
659
+ # namespace (e.g. `mount` alongside a `mount:` namespace) groups
660
+ # under that namespace's section, not :root.
661
+ def section_for(full, prefix, klass = nil)
662
+ segs = full.split(':')
663
+ segs = segs[prefix.split(':').size..] || [] if prefix && !prefix.empty?
664
+ if segs.size == 1 && klass && klass.namespaces.key?(segs.first)
665
+ return segs.first
541
666
  end
542
- segs.empty? ? :root : segs.first
543
- end
544
-
545
- # "db:migrate" or "db:migrate (alt: m)"
546
- def label_for(full, cmd)
547
- cmd.alts.empty? ? full : "#{full} (alt: #{cmd.alts.join(', ')})"
667
+ parent = segs[0..-2]
668
+ parent.empty? ? :root : parent.first
548
669
  end
549
670
 
550
671
  # " URL [ENV] [OPTIONS]" - shows the positional-fill names for
@@ -607,11 +728,11 @@ class Hammer
607
728
  def self.run(argv = ARGV, &block)
608
729
  klass = Class.new(Hammer)
609
730
  if block
610
- Builder.new(klass).instance_eval(&block)
731
+ Builder.new(klass).evaluate(&block)
611
732
  else
612
733
  hf = File.join(Dir.pwd, 'Hammerfile')
613
734
  if File.file?(hf)
614
- Builder.new(klass).instance_eval(File.read(hf), hf)
735
+ Builder.new(klass).evaluate(File.read(hf), hf)
615
736
  else
616
737
  klass.loader.load(Dir.pwd, [], auto: true)
617
738
  end
@@ -676,6 +797,9 @@ class Hammer
676
797
  end
677
798
 
678
799
  klass = Class.new(Hammer)
800
+ # Mark this class as the `hammer` binary's root so help output can
801
+ # surface binary-only globals like `--ai`.
802
+ klass.instance_variable_set(:@hammer_binary, true)
679
803
  # Resolve before chdir so paths like `bin/foo` stay relative to the
680
804
  # cwd the user actually invoked from. `program_name` memoizes.
681
805
  klass.program_name
@@ -683,7 +807,11 @@ class Hammer
683
807
  # chdir into the Hammerfile's directory for the entire run so commands
684
808
  # operate on the project root (Rake-style).
685
809
  Dir.chdir(File.dirname(path))
686
- Builder.new(klass).instance_eval(File.read(path), path)
810
+ Builder.new(klass).evaluate(File.read(path), path)
811
+ # Auto-load `.env` / `.env.local` after eval so a top-level
812
+ # `dotenv false` in the Hammerfile can suppress it. Trade-off: vars
813
+ # are NOT visible during Hammerfile evaluation, only inside handlers.
814
+ Hammer::Dotenv.load(Dir.pwd) if klass.dotenv_enabled?
687
815
  klass.start(argv)
688
816
  end
689
817
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lux-hammer
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dino Reic
@@ -38,6 +38,7 @@ files:
38
38
  - "./lib/hammer/builder.rb"
39
39
  - "./lib/hammer/command.rb"
40
40
  - "./lib/hammer/command_builder.rb"
41
+ - "./lib/hammer/dotenv.rb"
41
42
  - "./lib/hammer/loader.rb"
42
43
  - "./lib/hammer/option.rb"
43
44
  - "./lib/hammer/parser.rb"
@@ -62,7 +63,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
62
63
  - !ruby/object:Gem::Version
63
64
  version: '0'
64
65
  requirements: []
65
- rubygems_version: 4.0.10
66
+ rubygems_version: 4.0.11
66
67
  specification_version: 4
67
68
  summary: Thor-inspired tiny CLI builder
68
69
  test_files: []