lux-hammer 0.2.4 → 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.
Files changed (6) hide show
  1. checksums.yaml +4 -4
  2. data/.version +1 -1
  3. data/AGENTS.md +294 -0
  4. data/bin/hammer +7 -6
  5. data/lib/lux-hammer.rb +23 -0
  6. 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: bb668fabef5892decb2ee58add685f52dad7fe742ae0cd8597ba62da576c3d13
4
+ data.tar.gz: f74beef4ccb0af5aaa546c7c4a2dec29bc08fffdb929edfa5f3dcdf261627b69
5
5
  SHA512:
6
- metadata.gz: d3dd10a43530fd33c1535ea38086eb3a86ce8d3750e3f79acb765b390b667ab70048bda841e3ab3959effcc1438aa0557e07bfd7f24d727d64a8e963a3ed8b6c
7
- data.tar.gz: e976dc67d490f64c44044fab9dcad7e2b5431570eefdada070feb2689daba6fea8af46572ba223a6f801c881917ff522f519d08cf19218bd530ac438f91d4759
6
+ metadata.gz: 9fddfa7b7f657cb1baa30793e46fe331cd3f65704ac31ce582bcfc851b154a15eb72e5610e0a0b8af29ed99a763823f7c7bbdd1ef7ad33f40b95fcd803a6216f
7
+ data.tar.gz: 6b176bb61def54c305a28f0c9b9419074ed16c14c1b8412ccc084cd2ed5f1a0bfa93d947aac9fcce704cde00c2307bb897e0255e200f1bf1bb077f2795f40330
data/.version CHANGED
@@ -1 +1 @@
1
- 0.2.4
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/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
@@ -619,10 +619,31 @@ class Hammer
619
619
  klass.start(argv)
620
620
  end
621
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
+
622
635
  # Entry point for the `hammer` binary. Walks up from CWD until it
623
636
  # finds a Hammerfile, evaluates it as the block DSL, then dispatches
624
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).
625
641
  def self.cli(argv = ARGV)
642
+ if argv.include?('--ai')
643
+ print_ai_help
644
+ exit 0
645
+ end
646
+
626
647
  path = find_hammerfile(Dir.pwd)
627
648
  unless path
628
649
  Shell.print_error "no Hammerfile found in #{Dir.pwd} or any parent directory"
@@ -649,6 +670,8 @@ class Hammer
649
670
  end
650
671
  end
651
672
  RUBY
673
+ Shell.say ''
674
+ Shell.say "tip: run `#{File.basename($PROGRAM_NAME)} --ai` for AI-friendly Hammerfile authoring docs", :gray
652
675
  exit 1
653
676
  end
654
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.4
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"