lux-hammer 0.2.4 → 0.2.6

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.
Files changed (7) hide show
  1. checksums.yaml +4 -4
  2. data/.version +1 -1
  3. data/AGENTS.md +294 -0
  4. data/README.md +6 -5
  5. data/bin/hammer +7 -6
  6. data/lib/lux-hammer.rb +55 -16
  7. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7007c5628c3466147f7776a2de86832fa8737623ef7d91c8c4dc4a4b3db7e557
4
- data.tar.gz: 38d2781742e9cb9d8d12c22b3e8c416fe9f26901a7ef0bed678b206f872a9468
3
+ metadata.gz: 173d405adff150c31284b295fbcc13b56d73a70d6752509f4954f5a89d5f4df7
4
+ data.tar.gz: 1439b6d6e44bec16a5571ffaec390d98ea945360862fb4514f55fedf89a575df
5
5
  SHA512:
6
- metadata.gz: d3dd10a43530fd33c1535ea38086eb3a86ce8d3750e3f79acb765b390b667ab70048bda841e3ab3959effcc1438aa0557e07bfd7f24d727d64a8e963a3ed8b6c
7
- data.tar.gz: e976dc67d490f64c44044fab9dcad7e2b5431570eefdada070feb2689daba6fea8af46572ba223a6f801c881917ff522f519d08cf19218bd530ac438f91d4759
6
+ metadata.gz: 537c36703b561535f96fc6dd5454a2f6e01df6cac7059b8115b9c6ed176875edab529fbe0d530695d4bbc233fadb67964f2485baa8497de324e4a76b33de353b
7
+ data.tar.gz: 3abb187bb1fe58b03571fdbad1aac1e5a22a2023aeda4b8289ac0508813a85916bbc50a6b07b1988d9d888d3aff50b09658d77b5427704c1061a2c475c9bf151
data/.version CHANGED
@@ -1 +1 @@
1
- 0.2.4
1
+ 0.2.6
data/AGENTS.md ADDED
@@ -0,0 +1,294 @@
1
+ # AGENTS.md - lux-hammer
2
+
3
+ Conventions and constraints for AI agents working on this gem.
4
+
5
+ ## What this gem is
6
+
7
+ A tiny CLI builder stitched together from three influences:
8
+ namespaces and prereqs from Rake, typed option parsing from Thor, and
9
+ the `task :name do ... end` block DSL from Joshua. Users drop a
10
+ `Hammerfile` in their project, run `hammer`, get a structured CLI. Also
11
+ usable as a library (`require 'lux-hammer'`, subclass `Hammer`, call
12
+ `.start(ARGV)`).
13
+
14
+ ## Hard constraints
15
+
16
+ * **One root constant**: `Hammer`. Never pollute `Object` or introduce
17
+ another top-level constant. Sub-types live as `Hammer::Shell`,
18
+ `Hammer::Option`, `Hammer::Parser`, `Hammer::Command`,
19
+ `Hammer::Loader`, `Hammer::Builder`, `Hammer::CommandBuilder`. The one
20
+ exception is `String#color(:name)` (defined in `lib/hammer/shell.rb`)
21
+ - a tiny convenience for `"hi".color(:cyan)`. Do not add more
22
+ monkey-patches.
23
+ * **Zero runtime dependencies**. The gem must work with stdlib only. New
24
+ dependencies require explicit user approval.
25
+ * **Ruby >= 2.7**. Do not use language features introduced after 2.7
26
+ without flagging.
27
+ * **`desc` is single-arg, multi-line allowed**. The argument is one
28
+ string; it may contain newlines. The first line is the brief shown in
29
+ command listings, the full string renders (indented) in per-command
30
+ help. Trailing whitespace is stripped so heredocs work cleanly. `desc`
31
+ is never multi-arg - that form was tried and reverted.
32
+ * **The `proc` is the trailing expression of a `task` block.** Do not
33
+ introduce `run do ... end` or similar - the user explicitly chose this.
34
+
35
+ ## File layout
36
+
37
+ ```
38
+ bin/hammer # CLI entry point
39
+ lib/lux-hammer.rb # Entry - defines class Hammer and its DSL
40
+ lib/hammer/shell.rb # ANSI/IO helpers
41
+ lib/hammer/option.rb # One option definition
42
+ lib/hammer/parser.rb # ARGV -> [positional, opts_hash]
43
+ lib/hammer/command.rb # One registered command (name, opts, alts, handler)
44
+ lib/hammer/loader.rb # `*_hammer.rb` fragment loader (auto/glob/file)
45
+ lib/hammer/builder.rb # Block-DSL context (Hammerfile / Hammer.run)
46
+ lib/hammer/command_builder.rb # `task :name do ... end` context
47
+ test/dsl_test.rb # DSL surface, dispatch, help formatting
48
+ test/load_test.rb # `load` / `*_hammer.rb` fragment loader
49
+ test/parser_test.rb # ARGV parsing edge cases
50
+ test/option_test.rb # Option declaration / casting
51
+ test/command_test.rb # Command data type
52
+ test/shell_test.rb # ANSI / IO helpers
53
+ test/cli_test.rb # `hammer` binary end-to-end
54
+ examples/Hammerfile # Reference Hammerfile
55
+ examples/class_dsl.rb # Reference class DSL usage
56
+ examples/block_dsl.rb # Reference block DSL usage
57
+ ```
58
+
59
+ ## DSL surface (do not break compatibility silently)
60
+
61
+ Inside a `task :name do ... end` block (CommandBuilder context):
62
+
63
+ * `desc 'short description'` (string may contain `\n`; first line is
64
+ the brief shown in listings, full text renders in per-command help)
65
+ * `example 'invocation example'` (callable many times)
66
+ * `opt :name, type:, default:, alias:, desc:, req:` - any other kwarg
67
+ raises `Hammer::Error` ("unknown opt parameter(s)")
68
+ * `alt :other_name` (callable many times)
69
+ * `needs :other_cmd, 'ns:cmd'` - prereqs run before the handler;
70
+ resolved against root (same lookup as `hammer`), deduped per
71
+ top-level `start` so each prereq fires at most once. Dedupe also
72
+ spans `+`-chained segments. Unknown prereq raises `Hammer::Error`.
73
+ * `proc do |opts| ... end` - **the last expression**, becomes handler
74
+
75
+ At class scope (for `def`-style commands):
76
+
77
+ * `desc`, `example`, `opt`, `alt`, `needs` set pending state
78
+ * The next `def` consumes the pending state IF `desc` was set; otherwise
79
+ the method is treated as a plain helper
80
+ * Methods with arity 0 are called without opts; methods that take an arg
81
+ receive the opts hash
82
+
83
+ At class or `Hammerfile` scope:
84
+
85
+ * `task :name do ... end`
86
+ * `namespace :name do ... end`
87
+ * `load` / `load auto: true` / `load 'path/file.rb'` / `load 'glob/*.rb'` /
88
+ `load 'some/dir'` - pull in Hammerfile fragments from `*_hammer.rb`
89
+ files. Paths resolve relative to the caller's file. A directory
90
+ argument triggers the same recursive scan as `load auto: true` but
91
+ anchored at the given dir (empty result is OK, no error - apps with
92
+ no fragments are normal). Globs and explicit file paths still raise
93
+ on zero matches as a typo guard. Fragments are de-duplicated per
94
+ target class, so re-entrant `load` is safe. Skipped dirs:
95
+ `.git`, `.bundle`, `node_modules`, `tmp`, `vendor`, `dist`, `build`,
96
+ `coverage`, plus any hidden dir. Fragment shape is the block DSL
97
+ (`task`, `namespace`); class-DSL fragments belong in plain `.rb`
98
+ files loaded with `require_relative`.
99
+
100
+ Runtime cross-invocation:
101
+
102
+ * `hammer(name, *args, **opts)` - `name` is a symbol for a top-level
103
+ command (`hammer :build`) or a colon-path string for namespaced ones
104
+ (`hammer 'db:users:list'`). Trailing positionals become positional
105
+ ARGV. Kwargs become flags: underscores in keys map to dashes,
106
+ `true` -> `--flag`, `false` -> skipped (use `no_x: true` to negate),
107
+ any other value -> `--key=value`. Available both on a class
108
+ (`MyCli.hammer ...`) and inside a handler proc.
109
+
110
+ ## Entry points
111
+
112
+ * `Hammer.run(argv = ARGV) { ... }` - inline block DSL, builds an
113
+ anonymous subclass, dispatches `argv` against it.
114
+ * `Hammer.run(argv = ARGV)` - **without a block**: load `./Hammerfile`
115
+ if present, otherwise auto-discover `*_hammer.rb` under `Dir.pwd`,
116
+ then dispatch. No walk-up, no chdir. The "do the obvious thing"
117
+ entry point for a fixed bin script; users who need
118
+ walk-up + chdir + missing-Hammerfile error use `Hammer.cli`
119
+ (`bin/hammer`).
120
+ * `Hammer.cli(argv = ARGV)` - internal entry for `bin/hammer` only:
121
+ walks up from `Dir.pwd` for a `Hammerfile`, chdirs into its dir,
122
+ errors if none found anywhere up the tree. Not part of the
123
+ user-facing surface - don't recommend it in examples; `Hammer.run`
124
+ is what library users should reach for.
125
+ * `class MyCli < Hammer; ... end; MyCli.start(ARGV)` - classic class
126
+ DSL. `.start` is the dispatch primitive everything else funnels into.
127
+
128
+ ## `opts` hash contract
129
+
130
+ Always a `Hash` with **symbol keys**. Never change this without an
131
+ explicit ADR-level discussion. Keys:
132
+
133
+ * one per declared option
134
+ * `opts[:args]` - leftover positional ARGV (after declaration-order
135
+ fill)
136
+
137
+ ## Dispatch model
138
+
139
+ * Commands are addressed by colon path: `db:users:list`.
140
+ * The root `Hammer` subclass holds `@commands` and `@namespaces`.
141
+ * `resolve('a:b:c')` walks `@namespaces['a'].namespaces['b']` and
142
+ finds command `c` in the last class.
143
+ * There is **no per-level dispatch**. A namespace is a container, not a
144
+ CLI of its own. Do not reintroduce `subclass.start(remaining_argv)`.
145
+ * `start(argv)` is a two-step pipeline: `split_chain(argv)` (private)
146
+ splits on bare `+` tokens and unescapes `++` -> `+`, then `dispatch`
147
+ (private) runs each segment. `start` is the only public entry that
148
+ also sets up the per-invocation `needs`/`before` dedupe state, so the
149
+ whole `+` chain shares one dedupe scope. Don't have `dispatch` call
150
+ back into `start` for sub-segments - that re-triggers chain detection
151
+ on already-unescaped `+` tokens (bug we fixed; the test
152
+ `test_chain_escape_double_plus_yields_literal_plus_arg` guards it).
153
+
154
+ ## Help formatting
155
+
156
+ * Program name in usage lines is computed automatically (invocation path
157
+ relative to cwd if the bin lives inside cwd, otherwise the basename of
158
+ `$PROGRAM_NAME`). There is no user-facing override; the auto-detection
159
+ is the whole API. `Hammer.cli` warms the cache before chdir-ing into
160
+ the Hammerfile's directory so the resolved name stays relative to the
161
+ cwd the user invoked from.
162
+ * `hammer` (no args), `hammer -h`, and `hammer --help` all print top-level help.
163
+ * `hammer COMMAND -h` / `--help` prints per-command help (reserved on every command).
164
+ * Commands listed flat with colon paths, grouped by top-level namespace.
165
+ * Bare namespace (`hammer db`) prints the same listing scoped to that
166
+ namespace.
167
+ * Per-command help: usage line shows declared opts inline (required
168
+ bare, optional bracketed), then options with `(default: ...)` /
169
+ `(required)` annotations, then examples.
170
+
171
+ ## Inline helpers (`Hammer::Shell`)
172
+
173
+ Mixed into every handler. Also callable directly as `Hammer::Shell.<x>`.
174
+ Complete surface:
175
+
176
+ ```ruby
177
+ say 'plain' # print
178
+ say.green 'ok' # colored via proxy (preferred form)
179
+ say 'ok', :green # equivalent two-arg form
180
+ say '' # blank line (NOT `say` with no args)
181
+
182
+ 'OK'.color(:green) # paint without printing
183
+ Hammer::Shell.paint('x', :red) # the underlying primitive
184
+
185
+ error 'config missing' unless ok # raise Hammer::Error -> dispatcher exits 1
186
+ name = ask 'name' # read line
187
+ env = ask 'env', default: 'dev' # blank input -> default
188
+ exit 0 unless yes? 'continue?' # y/Y prefix -> true, else false
189
+ idx = choose 'Pick env', envs # arrow-key picker, returns Integer or nil
190
+ sh 'bundle install' # echo "$ cmd" gray, raise on non-zero
191
+
192
+ Hammer::Shell.color!(true|false) # force ANSI on/off
193
+ Hammer::Shell.color? # current state
194
+ Hammer::Shell.print_error 'msg' # stderr, no raise - dispatcher-only
195
+ ```
196
+
197
+ Doc rule: prefer `say.<color> 'x'` over `say 'x', :<color>` in README
198
+ and examples - both work, but the proxy form reads better and is what
199
+ new code in this gem should show.
200
+
201
+ `choose` invariants worth knowing:
202
+
203
+ * Uses `io/console` (stdlib, no new dependency).
204
+ * TTY path renders the list, redraws on each keystroke (`\e[NA\e[J`),
205
+ hides/restores the cursor, and reads bytes via `$stdin.raw { ... }`.
206
+ * Keys: `j`/`k` and arrow keys (ESC `[` `A`/`B`) move; Enter confirms;
207
+ `q`, ESC alone, and Ctrl-C cancel. ESC-vs-arrow is disambiguated with
208
+ a tiny `IO.select` timeout - the arrow's `[A` follows ESC immediately,
209
+ a bare ESC keystroke is alone.
210
+ * Non-TTY path (`!$stdin.tty?` or stdin without `raw`) prints a numbered
211
+ list and reads one line. Both paths return the same: `Integer` index
212
+ or `nil`. Don't break this contract - it's why `choose` is scriptable.
213
+ * On confirm, the rendered list is collapsed to one green line; on
214
+ cancel, the list is cleared. Don't leave the raw-mode list behind.
215
+ * Empty `items` raises `Hammer::Error` ('at least one item') - same
216
+ failure pattern as bad colors.
217
+
218
+ ## Colors
219
+
220
+ * Color set: `:black :red :green :yellow :blue :magenta :cyan :white :gray`
221
+ (see `Hammer::Shell::COLORS`). No bold, no bright variants, no
222
+ background colors. Don't add more without an explicit ask.
223
+ * All four entry points (`say x, :c`, `say.c x`, `paint x, :c`,
224
+ `'x'.color(:c)`) flow through `Shell.paint` - it's the only place
225
+ ANSI codes live.
226
+ * Unknown colors must raise `Hammer::Error` with the list of valid
227
+ names. This is enforced in `Shell.paint` and in `SayProxy#method_missing`
228
+ - don't bypass it. Validation runs even when colors are disabled, so
229
+ a typo fails loudly in CI.
230
+ * `say` with no args returns the `SayProxy`; `say ''` is how you print
231
+ a blank line. Don't restore `say` (no args) -> blank-line semantics.
232
+
233
+ ## Error handling
234
+
235
+ * `Hammer::Shell.error 'msg'` raises `Hammer::Error`. The dispatcher
236
+ catches it inside `run_command`, prints `[error] msg` in red to
237
+ stderr, and exits 1 - no backtrace, no per-command help.
238
+ * `Hammer::Shell.print_error 'msg'` prints without raising; reserved
239
+ for the dispatcher's own diagnostics (unknown command, missing
240
+ Hammerfile).
241
+ * `Hammer::Parser::Error` is also caught in `run_command` and
242
+ additionally prints per-command help (because the input *was* aimed
243
+ at that command).
244
+ * Inside a handler, just call `error 'msg'` - it resolves via the
245
+ `Shell` mixin.
246
+
247
+ ## Coding rules
248
+
249
+ * No trailing spaces on empty lines. End every file with a newline.
250
+ * Use ASCII only - `-`, not unicode dashes; `*` for bullets in docs.
251
+ * Preserve existing style. Minimize diff size.
252
+ * Comments only when the *why* is non-obvious. Don't restate the code.
253
+ * No `// removed` markers - delete cleanly.
254
+ * Constants: prefer `FOO ||=` over `FOO =` (re-load safety).
255
+ * Never commit or push without explicit confirmation.
256
+
257
+ ## Testing
258
+
259
+ * Run: `bundle exec rake test`
260
+ * Single file: `bundle exec ruby -Ilib -Itest test/parser_test.rb`
261
+ * Single test by name: `... test/parser_test.rb -n test_boolean_via_short_alias`
262
+ * Helper `CaptureIO` in `test/test_helper.rb` swaps `$stdout`/`$stderr`
263
+ for `StringIO`. Use `capture { ... }` for non-exiting code and
264
+ `capture_exit { ... }` for code that calls `exit`.
265
+
266
+ When you add a feature, add a test in the matching `_test.rb`. When you
267
+ fix a bug, write the failing test first.
268
+
269
+ ## What NOT to do
270
+
271
+ * Don't add generators / templates (Thor-style file scaffolding).
272
+ * Don't add shell-completion generation to core - that belongs in a
273
+ separate plugin gem.
274
+ * Don't introduce a class hierarchy for commands. Commands are data
275
+ (`Hammer::Command` instances), not classes.
276
+ * Don't reintroduce a user-facing `program` / `program_name` setter -
277
+ the auto-detected name is the whole API and was deliberately reduced
278
+ to that.
279
+ * Don't propagate `program_name` into namespaces as a longer prefix -
280
+ there is only one program name.
281
+ * Don't make `desc` multi-arg again (it once took `usage, description`
282
+ - that was reverted intentionally).
283
+ * Don't add a `subcommand 'name', SomeClass` form - it was replaced by
284
+ `namespace :name do ... end` intentionally.
285
+ * Don't auto-namespace fragments by filename (e.g. `db_hammer.rb` does
286
+ not implicitly wrap in `namespace :db do ... end`). Be explicit, as
287
+ Rake's `import` is. If a fragment wants a namespace, it writes it.
288
+
289
+ ## When in doubt
290
+
291
+ * Read `lib/lux-hammer.rb` top-to-bottom. It's the whole DSL surface.
292
+ * Run the example: `cd examples && ruby -I../lib ../bin/hammer`
293
+ * Check `test/dsl_test.rb` for the canonical behavior of each feature.
294
+ * Check `test/load_test.rb` for `load` / fragment loader behavior.
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
data/bin/hammer CHANGED
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env ruby
2
- begin
3
- require 'lux-hammer'
4
- rescue LoadError
5
- # dev fallback: running from the repo without the gem installed
6
- $LOAD_PATH.unshift File.expand_path('../lib', __dir__)
7
- require 'lux-hammer'
2
+ # Prefer the adjacent lib/ when this script lives in a repo checkout
3
+ # (lib/lux-hammer.rb sits next to bin/), otherwise fall back to the
4
+ # installed gem. Otherwise an installed older gem shadows local edits.
5
+ local_lib = File.expand_path('../lib/lux-hammer.rb', __dir__)
6
+ if File.file?(local_lib)
7
+ $LOAD_PATH.unshift File.dirname(local_lib)
8
8
  end
9
+ require 'lux-hammer'
9
10
  Hammer.cli(ARGV)
data/lib/lux-hammer.rb CHANGED
@@ -467,6 +467,7 @@ class Hammer
467
467
  Shell.say ''
468
468
  print_command_list(self)
469
469
  end
470
+ print_global_flags
470
471
  print_footer
471
472
  end
472
473
 
@@ -478,6 +479,7 @@ class Hammer
478
479
  Shell.say ''
479
480
  print_command_list(ns, prefix)
480
481
  end
482
+ print_global_flags
481
483
  print_footer
482
484
  end
483
485
 
@@ -490,6 +492,16 @@ class Hammer
490
492
 
491
493
  HOMEPAGE ||= 'https://github.com/dux/hammer'.freeze
492
494
 
495
+ # Global flags only exist when invoked via the `hammer` binary
496
+ # (see `Hammer.cli`), not for user-built CLIs that call `start`
497
+ # on their own subclass.
498
+ def print_global_flags
499
+ return unless root.instance_variable_get(:@hammer_binary)
500
+ Shell.say ''
501
+ Shell.say 'Global:', :yellow
502
+ Shell.say ' --ai # Print AGENTS.md (AI-friendly Hammerfile authoring docs)'
503
+ end
504
+
493
505
  def print_footer
494
506
  Shell.say ''
495
507
  Shell.say "powered by hammer - #{HOMEPAGE}", :gray
@@ -505,13 +517,13 @@ class Hammer
505
517
 
506
518
  # group by "section" = everything between the view prefix and the
507
519
  # 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
520
+ groups = rows.group_by { |full, _| section_for(full, prefix, klass) }
521
+ width = rows.map { |full, _| full.length }.max
510
522
  first = true
511
523
 
512
524
  if (rooted = groups.delete(:root))
513
525
  Shell.say 'Commands:', :yellow
514
- emit_rows(rooted.sort_by { |full, _| full }, width)
526
+ emit_rows(rooted.sort_by { |full, _| [full.count(':'), full] }, width)
515
527
  first = false
516
528
  end
517
529
 
@@ -519,14 +531,14 @@ class Hammer
519
531
  Shell.say unless first
520
532
  first = false
521
533
  Shell.say "#{section}:", :yellow
522
- emit_rows(items.sort_by { |full, _| full }, width)
534
+ emit_rows(items.sort_by { |full, _| [full.count(':'), full] }, width)
523
535
  end
524
536
  end
525
537
 
526
538
  def emit_rows(rows, width)
527
539
  rows.each do |full, c|
528
- label = label_for(full, c)
529
- Shell.say " #{program_name} #{label.ljust(width)} # #{c.brief}"
540
+ brief = c.alts.empty? ? c.brief : "#{c.brief} (alt: #{c.alts.join(', ')})"
541
+ Shell.say " #{program_name} #{full.ljust(width)} # #{brief}"
530
542
  end
531
543
  end
532
544
 
@@ -534,17 +546,18 @@ class Hammer
534
546
  # for 'db:users:list' viewed from 'db'; :root if the command sits at
535
547
  # the view's top level. Only the first segment under the view groups,
536
548
  # 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..] || []
549
+ #
550
+ # Exception: a bare command that shares its name with a sibling
551
+ # namespace (e.g. `mount` alongside a `mount:` namespace) groups
552
+ # under that namespace's section, not :root.
553
+ def section_for(full, prefix, klass = nil)
554
+ segs = full.split(':')
555
+ segs = segs[prefix.split(':').size..] || [] if prefix && !prefix.empty?
556
+ if segs.size == 1 && klass && klass.namespaces.key?(segs.first)
557
+ return segs.first
541
558
  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(', ')})"
559
+ parent = segs[0..-2]
560
+ parent.empty? ? :root : parent.first
548
561
  end
549
562
 
550
563
  # " URL [ENV] [OPTIONS]" - shows the positional-fill names for
@@ -619,10 +632,31 @@ class Hammer
619
632
  klass.start(argv)
620
633
  end
621
634
 
635
+ # Dump the gem's AGENTS.md to stdout - AI-optimized guide for
636
+ # writing Hammerfiles. Bundled with the gem and resolved relative
637
+ # to this file so it works from any install location.
638
+ def self.print_ai_help
639
+ path = File.expand_path('../AGENTS.md', __dir__)
640
+ if File.file?(path)
641
+ puts File.read(path)
642
+ else
643
+ Shell.print_error "AGENTS.md not found at #{path}"
644
+ exit 1
645
+ end
646
+ end
647
+
622
648
  # Entry point for the `hammer` binary. Walks up from CWD until it
623
649
  # finds a Hammerfile, evaluates it as the block DSL, then dispatches
624
650
  # ARGV against the resulting CLI.
651
+ #
652
+ # `--ai` is a meta-flag handled here, before Hammerfile lookup,
653
+ # so it works anywhere (no project required).
625
654
  def self.cli(argv = ARGV)
655
+ if argv.include?('--ai')
656
+ print_ai_help
657
+ exit 0
658
+ end
659
+
626
660
  path = find_hammerfile(Dir.pwd)
627
661
  unless path
628
662
  Shell.print_error "no Hammerfile found in #{Dir.pwd} or any parent directory"
@@ -649,10 +683,15 @@ class Hammer
649
683
  end
650
684
  end
651
685
  RUBY
686
+ Shell.say ''
687
+ Shell.say "tip: run `#{File.basename($PROGRAM_NAME)} --ai` for AI-friendly Hammerfile authoring docs", :gray
652
688
  exit 1
653
689
  end
654
690
 
655
691
  klass = Class.new(Hammer)
692
+ # Mark this class as the `hammer` binary's root so help output can
693
+ # surface binary-only globals like `--ai`.
694
+ klass.instance_variable_set(:@hammer_binary, true)
656
695
  # Resolve before chdir so paths like `bin/foo` stay relative to the
657
696
  # cwd the user actually invoked from. `program_name` memoizes.
658
697
  klass.program_name
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.4
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dino Reic
@@ -32,6 +32,7 @@ extensions: []
32
32
  extra_rdoc_files: []
33
33
  files:
34
34
  - "./.version"
35
+ - "./AGENTS.md"
35
36
  - "./README.md"
36
37
  - "./bin/hammer"
37
38
  - "./lib/hammer/builder.rb"