lux-hammer 0.2.3 → 0.2.5

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: ae2c5c9dbcd3b17941440e327e04e84dbffaec7209e4ce15b205cf83525b3724
4
- data.tar.gz: 516eb4a4cb0f8f02f489eb00d2e4500cfe70f600eaebe192fb1749c81fe37074
3
+ metadata.gz: bb668fabef5892decb2ee58add685f52dad7fe742ae0cd8597ba62da576c3d13
4
+ data.tar.gz: f74beef4ccb0af5aaa546c7c4a2dec29bc08fffdb929edfa5f3dcdf261627b69
5
5
  SHA512:
6
- metadata.gz: d922e58738cb3afc95c54864fc79d9a6ec556e3634a94f258711652abf220ba7f436e1d0f0cf24f48ba9c6372af22339d8377b491c9de93f708e19a7ca13692d
7
- data.tar.gz: 6ad385997b87bf70ba58ec3ae6d701b3717d5e9584ef00aae29ebeb5c9b3666a39f6032277e275987f6de94161809767eeb838e288718fda09287327009756b6
6
+ metadata.gz: 9fddfa7b7f657cb1baa30793e46fe331cd3f65704ac31ce582bcfc851b154a15eb72e5610e0a0b8af29ed99a763823f7c7bbdd1ef7ad33f40b95fcd803a6216f
7
+ data.tar.gz: 6b176bb61def54c305a28f0c9b9419074ed16c14c1b8412ccc084cd2ed5f1a0bfa93d947aac9fcce704cde00c2307bb897e0255e200f1bf1bb077f2795f40330
data/.version CHANGED
@@ -1 +1 @@
1
- 0.2.3
1
+ 0.2.5
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
@@ -4,11 +4,11 @@ The bastard Frankenstein child of Rake, Thor, and Joshua. Sewn
4
4
  together from three good ideas, with the rest of each parent left on
5
5
  the cutting room floor.
6
6
 
7
- Drop a `Hammerfile`, run `hammer`, ship.
7
+ Drop a `Hammerfile`, run `hammer`, ship. AI LLM-s love `hammer`.
8
8
 
9
9
  ```ruby
10
10
  namespace :db do # Rake-style colon paths
11
- define :migrate do # Joshua-style define block
11
+ task :migrate do # Joshua-style task block
12
12
  desc 'Run pending migrations'
13
13
  opt :pretend, type: :boolean, alias: :p # Thor-style typed opts
14
14
  proc do |o|
@@ -40,7 +40,7 @@ migrating pretend=true
40
40
  that most CLIs never reach for.
41
41
 
42
42
  * **From [Joshua](https://github.com/dux/joshua)** we took the
43
- *`define :name do ... end` block DSL* - declarative metadata up
43
+ *`task :name do ... end` block DSL* - declarative metadata up
44
44
  top, one `proc do |opts| ... end` at the bottom doing the work. No
45
45
  `def`-and-`desc` split, no class required, no boilerplate between
46
46
  "what this command is" and "what it does".
@@ -67,7 +67,7 @@ This installs the `hammer` binary and exposes `require 'lux-hammer'`.
67
67
  Create a `Hammerfile` in your project root:
68
68
 
69
69
  ```ruby
70
- define :hello do
70
+ task :hello do
71
71
  desc 'say hi'
72
72
  proc do |opts|
73
73
  say.green "hello #{opts[:args].first || 'world'}"
@@ -115,7 +115,7 @@ Hammer takes typed options, positional fill, and any common flag form:
115
115
 
116
116
  ```ruby
117
117
  # Hammerfile
118
- define :greet do
118
+ task :greet do
119
119
  desc 'Say hello'
120
120
  opt :name
121
121
  opt :loud, type: :boolean, alias: :l
@@ -159,7 +159,7 @@ end
159
159
 
160
160
  ```ruby
161
161
  # hammer - one arg system, real aliases, no usage string to maintain
162
- define :greet do
162
+ task :greet do
163
163
  desc 'Say hello'
164
164
  alt :g
165
165
  opt :name
@@ -173,13 +173,13 @@ and there's one place to look for everything the command takes.
173
173
 
174
174
  ## The two styles
175
175
 
176
- ### `define :name do ... end` (block DSL)
176
+ ### `task :name do ... end` (block DSL)
177
177
 
178
178
  The block's **last expression must be `proc do |opts| ... end`**. That
179
179
  proc is the handler. Everything before it is metadata.
180
180
 
181
181
  ```ruby
182
- define :build do
182
+ task :build do
183
183
  desc 'Build the project'
184
184
  example 'build prod -v'
185
185
  opt :verbose, type: :boolean, alias: :v
@@ -371,7 +371,7 @@ Anything in ARGV without `-` / `--` fills the next un-set
371
371
  **non-boolean** opt, in declaration order:
372
372
 
373
373
  ```ruby
374
- define :deploy do
374
+ task :deploy do
375
375
  opt :url
376
376
  opt :env, default: 'dev'
377
377
  proc { |opts| ... }
@@ -403,7 +403,7 @@ Always a `Hash` with **symbol keys**. Keys present:
403
403
  * `opts[:args]` - array of positional ARGV not absorbed by an opt
404
404
 
405
405
  ```ruby
406
- define :show do
406
+ task :show do
407
407
  opt :env, default: 'dev'
408
408
  opt :loud, type: :boolean
409
409
  proc { |opts| p opts }
@@ -435,12 +435,12 @@ colon-paths from the root binary - just like `rake db:migrate`:
435
435
 
436
436
  ```ruby
437
437
  namespace :db do
438
- define :migrate do
438
+ task :migrate do
439
439
  proc { |opts| ... }
440
440
  end
441
441
 
442
442
  namespace :users do
443
- define :list do
443
+ task :list do
444
444
  proc { |opts| ... }
445
445
  end
446
446
  end
@@ -453,7 +453,9 @@ Then:
453
453
  hammer db:migrate
454
454
  hammer db:users:list
455
455
  hammer db # bare namespace lists everything under it
456
+ hammer db: # trailing colon: full per-task help for every command
456
457
  hammer db:migrate -h # per-command help
458
+ hammer : # trailing colon at root: full help for every command
457
459
  ```
458
460
 
459
461
  Namespaces nest to any depth. There is no per-level dispatch - the root
@@ -470,13 +472,13 @@ before { Dotenv.load } # runs before every command
470
472
 
471
473
  namespace :db do
472
474
  before { hammer :env } # runs before every db:* command
473
- define :migrate do
475
+ task :migrate do
474
476
  proc { |opts| ... } # no boilerplate require inside
475
477
  end
476
478
  end
477
479
  ```
478
480
 
479
- `before` is intentionally not available inside `define` - the proc body
481
+ `before` is intentionally not available inside `task` - the proc body
480
482
  *is* the command body, just put the setup line at the top of the proc.
481
483
 
482
484
  Pairs naturally with hidden commands (next section): keep `:env` /
@@ -488,13 +490,13 @@ A command declared without a `desc` is **hidden from help listings**
488
490
  but stays fully dispatchable and `hammer`-callable:
489
491
 
490
492
  ```ruby
491
- define :env do
493
+ task :env do
492
494
  proc { |_| require './config/env' } # no desc -> hidden
493
495
  end
494
496
 
495
497
  namespace :db do
496
498
  before { hammer :env } # call it from a hook
497
- define :migrate do
499
+ task :migrate do
498
500
  desc 'Run migrations'
499
501
  proc { |_| ... }
500
502
  end
@@ -509,17 +511,17 @@ end
509
511
  Declare commands that must run before this one (Rake-style task deps):
510
512
 
511
513
  ```ruby
512
- define :env do
514
+ task :env do
513
515
  proc { |_| require './config/env' } # hidden helper
514
516
  end
515
517
 
516
- define :app do
518
+ task :app do
517
519
  needs :env # runs `env` first
518
520
  desc 'start the app'
519
521
  proc { |opts| App.start }
520
522
  end
521
523
 
522
- define :deploy do
524
+ task :deploy do
523
525
  needs :env, :build # multiple prereqs, in order
524
526
  proc { |opts| ... }
525
527
  end
@@ -542,7 +544,7 @@ only once. Prereqs run with default options (no argv passed through).
542
544
  `alt :short_name` (or several) registers extra names for a command:
543
545
 
544
546
  ```ruby
545
- define :server do
547
+ task :server do
546
548
  alt :s, :srv
547
549
  proc { |opts| ... }
548
550
  end
@@ -558,7 +560,7 @@ From inside any command's proc - or from outside via the class - you can
558
560
  invoke other commands without re-shelling out:
559
561
 
560
562
  ```ruby
561
- define :deploy do
563
+ task :deploy do
562
564
  proc do |opts|
563
565
  hammer :build, env: 'prod', verbose: true
564
566
  hammer 'db:migrate'
@@ -729,7 +731,7 @@ load auto: true # recursive scan for *_hammer.rb from here
729
731
  ```ruby
730
732
  # tasks/db_hammer.rb
731
733
  namespace :db do
732
- define :migrate do
734
+ task :migrate do
733
735
  desc 'Run pending migrations'
734
736
  opt :pretend, type: :boolean, alias: :p
735
737
  proc { |o| say.green "migrating pretend=#{o[:pretend].inspect}" }
@@ -739,7 +741,7 @@ end
739
741
 
740
742
  ```ruby
741
743
  # tasks/deploy_hammer.rb
742
- define :deploy do
744
+ task :deploy do
743
745
  desc 'Deploy to prod'
744
746
  proc do |_|
745
747
  hammer 'db:migrate' # cross-file invocation just works
@@ -777,7 +779,7 @@ hidden directory.
777
779
  ### Fragment shape
778
780
 
779
781
  A `*_hammer.rb` file is a **block-DSL fragment** - same surface as a
780
- `Hammerfile`: `define`, `namespace`, and nested `load`. Not a class
782
+ `Hammerfile`: `task`, `namespace`, and nested `load`. Not a class
781
783
  re-open. If you want to extend a `Hammer` subclass in the classic
782
784
  `desc` + `def` style across files, use plain `require_relative`.
783
785
 
@@ -801,7 +803,7 @@ Same shape as a Hammerfile, just inline:
801
803
  require 'lux-hammer'
802
804
 
803
805
  Hammer.run(ARGV) do
804
- define :hello do
806
+ task :hello do
805
807
  desc 'say hi'
806
808
  opt :loud, type: :boolean, alias: :l
807
809
  proc do |opts|
@@ -845,7 +847,7 @@ end
845
847
 
846
848
  ```ruby
847
849
  # Simple top-level command
848
- define :build do
850
+ task :build do
849
851
  desc 'Build the project'
850
852
  example 'build prod -v'
851
853
  example 'build --env=staging'
@@ -860,7 +862,7 @@ define :build do
860
862
  end
861
863
 
862
864
  # Command that calls another command
863
- define :deploy do
865
+ task :deploy do
864
866
  desc 'Deploy to URL'
865
867
  alt :ship
866
868
  opt :url, req: true
@@ -875,7 +877,7 @@ end
875
877
 
876
878
  # Namespace with two levels of nesting
877
879
  namespace :db do
878
- define :migrate do
880
+ task :migrate do
879
881
  desc 'Run pending migrations'
880
882
  alt :m
881
883
  example 'db:migrate 3 --pretend'
@@ -888,7 +890,7 @@ namespace :db do
888
890
  end
889
891
 
890
892
  namespace :users do
891
- define :list do
893
+ task :list do
892
894
  desc 'List users'
893
895
  opt :role, default: 'all'
894
896
  opt :limit, type: :integer, default: 100
@@ -898,7 +900,7 @@ namespace :db do
898
900
  end
899
901
  end
900
902
 
901
- define :create do
903
+ task :create do
902
904
  desc 'Create a user'
903
905
  opt :email, req: true
904
906
  opt :admin, type: :boolean
@@ -968,7 +970,7 @@ directly. Useful for embedding or testing:
968
970
  require 'lux-hammer'
969
971
 
970
972
  class MyCli < Hammer
971
- define :greet do
973
+ task :greet do
972
974
  opt :loud, type: :boolean
973
975
  proc do |opts|
974
976
  msg = "hello #{opts[:args].first}"
@@ -1006,7 +1008,7 @@ few small things that have been bugging me about both for years.
1006
1008
  | Lines of code | ~6,000 | ~400 |
1007
1009
  | Runtime deps | a few | zero |
1008
1010
  | Root constants | `Thor`, `Thor::Group`, `Thor::Shell`, `Thor::Actions`, ... | just `Hammer` |
1009
- | Command DSL | `desc 'usage', 'help'` + `method_option` + `def name(arg)` | `define :name do ... proc do \|opts\| end end` (or classic `desc` + `def`) |
1011
+ | Command DSL | `desc 'usage', 'help'` + `method_option` + `def name(arg)` | `task :name do ... proc do \|opts\| end end` (or classic `desc` + `def`) |
1010
1012
  | Opts container | `Thor::CoreExt::HashWithIndifferentAccess` | plain `Hash` with symbol keys |
1011
1013
  | Positional args | method positional params + `method_option`, two parallel systems | declared-order opts fill from positional, single system |
1012
1014
  | Sub-namespaces | `register SubClass, 'name', '...'` (inheritance ceremony) | `namespace :name do ... end` (no classes needed) |
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)
@@ -7,8 +7,8 @@ class Hammer
7
7
  @klass = klass
8
8
  end
9
9
 
10
- def define(name, &block)
11
- @klass.define(name, &block)
10
+ def task(name, &block)
11
+ @klass.task(name, &block)
12
12
  end
13
13
 
14
14
  def namespace(name, &block)
@@ -1,5 +1,5 @@
1
1
  class Hammer
2
- # Context object for `define :name do ... end` blocks. Exposes
2
+ # Context object for `task :name do ... end` blocks. Exposes
3
3
  # desc/example/opt/alt; the block's return value (a `proc do |opts|`)
4
4
  # becomes the command handler.
5
5
  class CommandBuilder
data/lib/lux-hammer.rb CHANGED
@@ -11,7 +11,7 @@ require_relative 'hammer/command_builder'
11
11
  # Class DSL:
12
12
  #
13
13
  # class MyCli < Hammer
14
- # define :build do
14
+ # task :build do
15
15
  # desc 'Build the project'
16
16
  # example 'build -v --env=prod'
17
17
  # opt :verbose, type: :boolean, alias: :v
@@ -27,7 +27,7 @@ require_relative 'hammer/command_builder'
27
27
  # Block DSL is identical, just inside `Hammer.run`:
28
28
  #
29
29
  # Hammer.run(ARGV) do
30
- # define :hello do
30
+ # task :hello do
31
31
  # desc 'Greet someone'
32
32
  # opt :loud, type: :boolean, alias: :l
33
33
  # proc do |opts|
@@ -124,17 +124,17 @@ class Hammer
124
124
  # return a Proc as its last expression. That proc is the handler and
125
125
  # receives a single `opts` hash with symbol keys; positional ARGV
126
126
  # lives at `opts[:args]`.
127
- def define(name, &block)
127
+ def task(name, &block)
128
128
  cmd = Command.new(name: name.to_s)
129
129
  handler = CommandBuilder.new(cmd).instance_eval(&block)
130
130
  unless handler.is_a?(Proc)
131
131
  raise Error, <<~MSG
132
- define(:#{name}) block must end with a `proc do |opts| ... end`.
132
+ task(:#{name}) block must end with a `proc do |opts| ... end`.
133
133
  The proc's return value is what becomes the command handler.
134
134
 
135
135
  Example:
136
136
 
137
- define :#{name} do
137
+ task :#{name} do
138
138
  desc 'what it does'
139
139
  example '#{name} foo --env=prod'
140
140
  opt :env, default: 'dev'
@@ -148,7 +148,7 @@ class Hammer
148
148
  cmd.handler = handler
149
149
  commands[cmd.name] = cmd
150
150
 
151
- # `define` ignores pending class-level state, but clear it so a
151
+ # `task` ignores pending class-level state, but clear it so a
152
152
  # later `def` doesn't accidentally consume stale metadata.
153
153
  @pending_desc = nil
154
154
  @pending_examples = []
@@ -158,11 +158,11 @@ class Hammer
158
158
  end
159
159
 
160
160
  # Open a namespace (group of commands). Everything inside the block
161
- # (define, nested namespace, ...) belongs to that namespace, evaluated
161
+ # (task, nested namespace, ...) belongs to that namespace, evaluated
162
162
  # against an anonymous Hammer subclass.
163
163
  #
164
164
  # namespace :db do
165
- # define :migrate do ... end
165
+ # task :migrate do ... end
166
166
  # namespace :users do ... end
167
167
  # end
168
168
  def namespace(name, &block)
@@ -189,7 +189,7 @@ class Hammer
189
189
  # before { |opts| Dotenv.load }
190
190
  # namespace :db do
191
191
  # before { hammer :env }
192
- # define :migrate do ... end
192
+ # task :migrate do ... end
193
193
  # end
194
194
  def before(&block)
195
195
  before_hooks << block
@@ -300,6 +300,19 @@ class Hammer
300
300
  return print_help(target)
301
301
  end
302
302
 
303
+ # Trailing colon ("db:") -> expanded namespace listing with full
304
+ # per-command help on every task. Bare ":" expands the root.
305
+ if name.end_with?(':') && name != ':'
306
+ bare = name.chomp(':')
307
+ ns = resolve_namespace(bare)
308
+ return print_namespace_help(bare, ns, full: true) if ns
309
+ Shell.print_error("unknown namespace: #{bare}")
310
+ print_help
311
+ exit 1
312
+ elsif name == ':'
313
+ return print_help(nil, full: true)
314
+ end
315
+
303
316
  cmd, owner = resolve(name)
304
317
  return owner.run_command(cmd, argv, full: name) if cmd
305
318
 
@@ -429,29 +442,52 @@ class Hammer
429
442
  scan.include?('-h') || scan.include?('--help')
430
443
  end
431
444
 
432
- def print_help(target = nil)
445
+ def print_help(target = nil, full: false)
433
446
  if target
447
+ # `help ns:` is equivalent to `ns:` - expanded namespace listing.
448
+ if target.end_with?(':') && target != ':'
449
+ bare = target.chomp(':')
450
+ ns = resolve_namespace(bare)
451
+ return print_namespace_help(bare, ns, full: true) if ns
452
+ Shell.print_error("unknown: #{target}")
453
+ return
454
+ end
434
455
  cmd, _ = resolve(target)
435
456
  return print_command_help(cmd, target) if cmd
436
457
  ns = resolve_namespace(target)
437
- return print_namespace_help(target, ns) if ns
458
+ return print_namespace_help(target, ns, full: full) if ns
438
459
  Shell.print_error("unknown: #{target}")
439
460
  return
440
461
  end
441
462
 
442
463
  Shell.say "Usage: #{program_name} COMMAND [ARGS]", :cyan
443
- Shell.say ''
444
- print_command_list(self)
464
+ if full
465
+ each_command { |path, c| print_full_block(path, c) unless c.desc.empty? }
466
+ else
467
+ Shell.say ''
468
+ print_command_list(self)
469
+ end
445
470
  print_footer
446
471
  end
447
472
 
448
- def print_namespace_help(prefix, ns)
473
+ def print_namespace_help(prefix, ns, full: false)
449
474
  Shell.say "Usage: #{program_name} #{prefix}:COMMAND [ARGS]", :cyan
450
- Shell.say ''
451
- print_command_list(ns, prefix)
475
+ if full
476
+ ns.each_command(prefix) { |path, c| print_full_block(path, c) unless c.desc.empty? }
477
+ else
478
+ Shell.say ''
479
+ print_command_list(ns, prefix)
480
+ end
452
481
  print_footer
453
482
  end
454
483
 
484
+ # One "task block" for the expanded listing: blank line separator
485
+ # then the standard per-command help (usage + desc + options + examples).
486
+ def print_full_block(path, cmd)
487
+ Shell.say ''
488
+ print_command_help(cmd, path)
489
+ end
490
+
455
491
  HOMEPAGE ||= 'https://github.com/dux/hammer'.freeze
456
492
 
457
493
  def print_footer
@@ -548,7 +584,7 @@ class Hammer
548
584
 
549
585
  # Inside a command's `proc do |opts| ... end`, call sibling commands:
550
586
  #
551
- # define :deploy do
587
+ # task :deploy do
552
588
  # proc do |opts|
553
589
  # hammer :build
554
590
  # hammer 'db:migrate', pretend: true
@@ -564,7 +600,7 @@ class Hammer
564
600
  # ----- block DSL -----------------------------------------------------
565
601
 
566
602
  # Define and run a CLI inline. Inside the block use
567
- # `define :name do ... end`, `namespace`, and `load`.
603
+ # `task :name do ... end`, `namespace`, and `load`.
568
604
  #
569
605
  # Without a block: load ./Hammerfile if it exists, otherwise
570
606
  # auto-discover *_hammer.rb under Dir.pwd, then dispatch ARGV.
@@ -583,10 +619,31 @@ class Hammer
583
619
  klass.start(argv)
584
620
  end
585
621
 
622
+ # Dump the gem's AGENTS.md to stdout - AI-optimized guide for
623
+ # writing Hammerfiles. Bundled with the gem and resolved relative
624
+ # to this file so it works from any install location.
625
+ def self.print_ai_help
626
+ path = File.expand_path('../AGENTS.md', __dir__)
627
+ if File.file?(path)
628
+ puts File.read(path)
629
+ else
630
+ Shell.print_error "AGENTS.md not found at #{path}"
631
+ exit 1
632
+ end
633
+ end
634
+
586
635
  # Entry point for the `hammer` binary. Walks up from CWD until it
587
636
  # finds a Hammerfile, evaluates it as the block DSL, then dispatches
588
637
  # ARGV against the resulting CLI.
638
+ #
639
+ # `--ai` is a meta-flag handled here, before Hammerfile lookup,
640
+ # so it works anywhere (no project required).
589
641
  def self.cli(argv = ARGV)
642
+ if argv.include?('--ai')
643
+ print_ai_help
644
+ exit 0
645
+ end
646
+
590
647
  path = find_hammerfile(Dir.pwd)
591
648
  unless path
592
649
  Shell.print_error "no Hammerfile found in #{Dir.pwd} or any parent directory"
@@ -606,13 +663,15 @@ class Hammer
606
663
  Shell.say "create one - example:"
607
664
  puts
608
665
  Shell.say <<~RUBY
609
- define :hello do
666
+ task :hello do
610
667
  desc 'say hello'
611
668
  proc do |opts|
612
669
  say.green "hello \#{opts[:args].first || 'world'}"
613
670
  end
614
671
  end
615
672
  RUBY
673
+ Shell.say ''
674
+ Shell.say "tip: run `#{File.basename($PROGRAM_NAME)} --ai` for AI-friendly Hammerfile authoring docs", :gray
616
675
  exit 1
617
676
  end
618
677
 
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.3
4
+ version: 0.2.5
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"
@@ -61,7 +62,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
62
  - !ruby/object:Gem::Version
62
63
  version: '0'
63
64
  requirements: []
64
- rubygems_version: 4.0.8
65
+ rubygems_version: 4.0.10
65
66
  specification_version: 4
66
67
  summary: Thor-inspired tiny CLI builder
67
68
  test_files: []