lux-hammer 0.3.17 → 0.3.20

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: b3fcc893fd7391f6b7cdd1694fd243729b3319344727a88bcf8599cdd1d23e6d
4
- data.tar.gz: f722dd982a954141f551832b8d742ba317d5aff3430611929dd2a261df550545
3
+ metadata.gz: 0547c6b7b08fe92297694e5d3f19d4726bc3ec3781e9a7169bfeefd1191f7cdd
4
+ data.tar.gz: e2c8bd882af53a49a24e881ec2329241430c63ec1d6a9f90dd310c4e258364ed
5
5
  SHA512:
6
- metadata.gz: 43d2baaf2c2451279d8d9ffb1d78209cff145471781858401efa313c274914795ec03aa078be8b1b083d7ed89bf6fe327b8d592c6e4a25ed6744e1d15b382fe7
7
- data.tar.gz: 37e28dc827e1492f3ac5efd346edd2bdafac3bd6d53a6e267f8f4a49937813d46ca514ecf9a2bb0f39ec6b52e8b098ee04f4dc58633948c9bf5e042e3512707c
6
+ metadata.gz: 94bf60197a56713381b1201a8344941595083770c4b3d8d753b624d455326f7cf5208923d49f3046d436bbfe560f28bbfc97bcfe44f5cf4fe85657b856e5f9e8
7
+ data.tar.gz: 5d5d8d807fd5dee383a528d0e8b38d8a275b4d553ac873c30e703641ed6d85d2f203fbd604384624c25c03f4971b4eb51e3d9c28145a0af06f5067cb189083f9
data/.version CHANGED
@@ -1 +1 @@
1
- 0.3.17
1
+ 0.3.20
data/AGENTS.md CHANGED
@@ -38,7 +38,8 @@ usable as a library (`require 'lux-hammer'`, subclass `Hammer`, call
38
38
  bin/hammer # CLI entry point
39
39
  lib/lux-hammer.rb # Entry - defines class Hammer and its DSL
40
40
  lib/hammer/shell.rb # ANSI/IO helpers
41
- lib/hammer/option.rb # One option definition
41
+ lib/hammer/option.rb # One option definition (types: string/boolean/integer/float/array/json)
42
+ lib/hammer/input.rb # opts[:stdin], prepare_json!, JSON @file / pipe helpers
42
43
  lib/hammer/parser.rb # ARGV -> [positional, opts_hash]
43
44
  lib/hammer/command.rb # One registered command (name, opts, alts, handler)
44
45
  lib/hammer/cron.rb # `cron '<expr>'` schedule parser (cron/interval/@shortcut)
@@ -72,12 +73,19 @@ Inside a `task :name do ... end` block (CommandBuilder context):
72
73
  the brief shown in listings, full text renders in per-command help)
73
74
  * `example 'invocation example'` (callable many times)
74
75
  * `opt :name, type:, default:, alias:, desc:, req:` - any other kwarg
75
- raises `Hammer::Error` ("unknown opt parameter(s)")
76
+ raises `Hammer::Error` ("unknown opt parameter(s)"). Types include
77
+ `:string`, `:boolean`, `:integer`, `:float`, `:array`, `:json`
78
+ (`:json` is flag/file/pipe only — never bare positional).
79
+ * `global_opt :name, ...` - same kwargs as `opt`, available on every
80
+ command under the declaring class/namespace (help: "Global options").
76
81
  * `alt :other_name` (callable many times)
77
82
  * `needs :other_cmd, 'ns:cmd'` - prereqs run before the handler;
78
83
  resolved against root (same lookup as `hammer`), deduped per
79
84
  top-level `start` so each prereq fires at most once. Dedupe also
80
85
  spans `+`-chained segments. Unknown prereq raises `Hammer::Error`.
86
+ * `opts[:stdin]` is always attached in `run_command` (piped non-TTY
87
+ body or `nil`). JSON body coercion is opt-in via
88
+ `Hammer.prepare_json!(opts)` (usually from `before`).
81
89
  * `cron '<expr>'` - schedule for the `h:cron` job server. Accepts
82
90
  5-field cron (`'*/10 * * * *'`), an interval (`'10s'`, `'10m'`,
83
91
  `'2h'`, `'1d'`, counted from the last run) or `@hourly` / `@daily` /
@@ -217,10 +225,12 @@ explicit ADR-level discussion. Keys:
217
225
  `> prog cmd --opt=val ARG` banner so it's visible which command was
218
226
  actually picked when fuzzy matching kicks in. Only options that
219
227
  differ from their default are listed; booleans render as `--flag`
220
- / `--no-flag`. Help paths (`-h`, bare namespace) short-circuit
221
- before the banner. Set `HAMMER_QUIET=1` to suppress the banner
222
- globally - useful when a task writes machine-readable output to
223
- stdout (e.g. a JSON-emitting Claude Code / Codex hook).
228
+ when true (no auto `--no-flag`). Help paths (`-h`, bare namespace)
229
+ short-circuit before the banner. Set `HAMMER_QUIET=1` to suppress
230
+ the banner globally - useful when a task writes machine-readable
231
+ output to stdout (e.g. a JSON-emitting Claude Code / Codex hook).
232
+ Boolean opts: presence of the switch/alias sets true; there is no
233
+ auto-negation from a `--no-` prefix.
224
234
  * There is **no per-level dispatch**. A namespace is a container, not a
225
235
  CLI of its own. Do not reintroduce `subclass.start(remaining_argv)`.
226
236
  * `start(argv)` is a two-step pipeline: `split_chain(argv)` (private)
data/README.md CHANGED
@@ -274,11 +274,14 @@ Not supported: attached short form (`-p3000`), combined short flags
274
274
  For boolean options:
275
275
 
276
276
  ```
277
- --verbose # set to true
278
- --no-verbose # set to false (only if a default of true is in play)
277
+ --verbose # set to true (presence alone)
279
278
  -v # short alias if declared
279
+ --no-reset # also true if the opt is named :no_reset
280
280
  ```
281
281
 
282
+ There is **no** auto `--no-X` negation. Name the flag what you mean
283
+ (`:verbose`, `:no_reset`, `:force`).
284
+
282
285
  ### Per-type behavior
283
286
 
284
287
  #### `:string` (default)
@@ -295,19 +298,20 @@ hammer build # opts[:env] = "dev" (default)
295
298
  #### `:boolean`
296
299
 
297
300
  ```ruby
298
- opt :verbose, type: :boolean, alias: :v
299
- opt :cache, type: :boolean, default: true
301
+ opt :verbose, type: :boolean, alias: :v
302
+ opt :no_reset, type: :boolean
303
+ opt :cache, type: :boolean, default: true
300
304
  ```
301
305
  ```
302
- hammer build -v # opts[:verbose] = true
303
- hammer build --verbose # opts[:verbose] = true
304
- hammer build --no-cache # opts[:cache] = false (negates default)
305
- hammer build # opts[:cache] = true (default)
306
- # opts[:verbose] = nil (no default)
306
+ hammer build -v # opts[:verbose] = true
307
+ hammer build --verbose # opts[:verbose] = true
308
+ hammer build --no-reset # opts[:no_reset] = true (name is the flag)
309
+ hammer build # opts[:cache] = true (default)
310
+ # opts[:verbose] = nil (no default)
307
311
  ```
308
312
 
309
- Booleans never consume a positional. `--no-X` only meaningfully overrides
310
- a `default: true`.
313
+ Booleans never consume a positional. Presence true; omitted → default
314
+ (or nil). Use `--flag=false` only if you need an explicit false value.
311
315
 
312
316
  #### `:integer`
313
317
 
@@ -500,6 +504,72 @@ namespace :db do
500
504
  end
501
505
  ```
502
506
 
507
+ ## Stdin, JSON input, and global options
508
+
509
+ Every command receives **`opts[:stdin]`** automatically when stdin is a
510
+ pipe (non-TTY) and non-empty; otherwise it is `nil`. Interactive TTYs are
511
+ never consumed.
512
+
513
+ ```ruby
514
+ task :count do
515
+ proc { |o| say "bytes=#{o[:stdin]&.bytesize || 0}" }
516
+ end
517
+ # cat file.txt | hammer count
518
+ ```
519
+
520
+ ### `type: :json` and `Hammer.prepare_json!`
521
+
522
+ For JSON bodies, declare `type: :json` and (optionally) coerce in a
523
+ `before` hook so pipes, `@file`, `-`, and inline JSON all land as a
524
+ **Hash/Array** on the same key:
525
+
526
+ ```ruby
527
+ global_opt :json, type: :json, desc: 'JSON body (inline, @file, -, or pipe)'
528
+ before { |o| Hammer.prepare_json!(o) } # fills o[:json] from stdin when unset
529
+
530
+ task :create do
531
+ opt :name
532
+ proc do |o|
533
+ body = o[:json] || {}
534
+ body[:name] ||= o[:name]
535
+ # body is a Hash with symbol keys
536
+ end
537
+ end
538
+ ```
539
+
540
+ Supply the body any of these ways (equivalent when `prepare_json!` runs):
541
+
542
+ ```sh
543
+ hammer create --json '{"name":"Ship"}'
544
+ hammer create --json @payload.json
545
+ cat payload.json | hammer create
546
+ cat payload.json | hammer create --json - # explicit stdin
547
+ ```
548
+
549
+ Rules for `Hammer.prepare_json!(opts, key: :json)`:
550
+
551
+ 1. Already a Hash/Array → leave (symbolize keys).
552
+ 2. String → parse inline JSON, `@path`, or `-` (stdin).
553
+ 3. `nil` + stdin looks like `{` / `[` → parse stdin into `opts[key]`.
554
+ 4. Boolean left alone (legacy `opt :json, type: :boolean` output flags).
555
+
556
+ `type: :json` **never** takes a bare positional (flag / file / pipe only),
557
+ so it won't steal `REF` from `update REF`.
558
+
559
+ ### `global_opt`
560
+
561
+ Register options once at root (or a namespace); every command under that
562
+ scope accepts them. Shown under **Global options:** in help. Declared
563
+ **after** per-task opts for positional fill order.
564
+
565
+ ```ruby
566
+ global_opt :json, type: :json
567
+ global_opt :as_json, type: :boolean, alias: :j, default: false,
568
+ desc: 'Force JSON on stdout'
569
+ ```
570
+
571
+ Helpers: `Hammer.prepare_json!`, `Hammer.attach_stdin!`, `Hammer::Input.*`.
572
+
503
573
  `.env` and `.env.local` next to the `Hammerfile` are loaded
504
574
  automatically by the `hammer` binary - no `before` hook needed.
505
575
  Shell-set vars are never overwritten, and `.env.local` overrides
@@ -794,10 +864,10 @@ positionals become positional ARGV; kwargs become CLI flags:
794
864
  * `hammer 'db:users:list'` → `db:users:list`
795
865
  * `hammer :evaluate, 'puts 42'` → `evaluate "puts 42"` (positional ARGV)
796
866
  * `verbose: true` → `--verbose`
797
- * `no_cache: true` → `--no-cache` (underscores in the key become dashes)
867
+ * `no_reset: true` → `--no-reset` (underscores in the key become dashes)
798
868
  * `dry_run: true` → `--dry-run`
799
869
  * `env: 'prod'` → `--env=prod`
800
- * `anything: false` → skipped (no-op; use `no_x: true` to negate)
870
+ * `anything: false` → skipped (false flags are omitted)
801
871
 
802
872
  `MyCli.hammer 'db:users:list', verbose: true` also works at the
803
873
  class level, useful for tests and scripting.
@@ -45,6 +45,11 @@ class Hammer
45
45
  @klass.before(&block)
46
46
  end
47
47
 
48
+ # Options available on every command (see Hammer.global_opt).
49
+ def global_opt(name, **opts)
50
+ @klass.global_opt(name, **opts)
51
+ end
52
+
48
53
  # Opt out of auto `.env` loading in `Hammer.cli`. Default is on.
49
54
  def dotenv(flag = true)
50
55
  @klass.dotenv(flag)
@@ -75,7 +80,7 @@ class Hammer
75
80
  # still call `task`, `namespace`, and `before`. Delegates to whichever
76
81
  # Hammer subclass is currently being evaluated.
77
82
  module DSL
78
- %i[task namespace before dotenv].each do |m|
83
+ %i[task namespace before dotenv global_opt].each do |m|
79
84
  define_method(m) do |*args, &block|
80
85
  target = Thread.current[:hammer_target]
81
86
  raise Hammer::Error, "`#{m}` called outside a Hammer context " \
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ class Hammer
6
+ # Stdin + JSON helpers for opts. Hammer always attaches piped stdin as
7
+ # `opts[:stdin]`; recipes opt into JSON body handling via
8
+ # `Hammer::Input.prepare_json!` (typically from a `before` hook) and/or
9
+ # `opt :json, type: :json` / `global_opt :json, type: :json`.
10
+ module Input
11
+ module_function
12
+
13
+ # Read piped stdin once. Returns nil when stdin is a TTY or empty.
14
+ # Does not consume an interactive TTY.
15
+ def read_stdin
16
+ return nil if $stdin.closed?
17
+ return nil if $stdin.tty?
18
+
19
+ data = $stdin.read
20
+ return nil if data.nil?
21
+
22
+ data = data.dup.force_encoding(Encoding::UTF_8) if data.respond_to?(:force_encoding)
23
+ data.empty? ? nil : data
24
+ rescue StandardError
25
+ nil
26
+ end
27
+
28
+ # Idempotent: sets opts[:stdin] if not already present.
29
+ def attach_stdin!(opts)
30
+ return opts[:stdin] if opts.key?(:stdin)
31
+
32
+ opts[:stdin] = read_stdin
33
+ end
34
+
35
+ # Parse a JSON source string into a Hash/Array with symbol keys.
36
+ # source is only used in error messages ('stdin', '--json', path, ...).
37
+ def parse_json(raw, source: 'json')
38
+ data = JSON.parse(raw)
39
+ symbolize(data)
40
+ rescue JSON::ParserError => e
41
+ raise Hammer::Parser::Error, "invalid JSON (#{source}): #{e.message}"
42
+ end
43
+
44
+ # Resolve a value that may be:
45
+ # - Hash / Array -> returned as-is (symbolized if Hash has string keys)
46
+ # - "-" -> use opts[:stdin]
47
+ # - "@path" -> File.read(path)
48
+ # - JSON string -> parse
49
+ def resolve_json_value(value, opts = {}, source: 'json')
50
+ return symbolize(value) if value.is_a?(Hash) || value.is_a?(Array)
51
+
52
+ s = value.to_s
53
+ if s == '-'
54
+ attach_stdin!(opts)
55
+ raw = opts[:stdin]
56
+ raise Hammer::Parser::Error, "no JSON on stdin for #{source}" if raw.nil? || raw.empty?
57
+
58
+ return parse_json(raw, source: 'stdin')
59
+ end
60
+
61
+ if s.start_with?('@')
62
+ path = s[1..]
63
+ raise Hammer::Parser::Error, "JSON file not found: #{path}" unless File.file?(path)
64
+
65
+ return parse_json(File.read(path), source: path)
66
+ end
67
+
68
+ parse_json(s, source: source)
69
+ end
70
+
71
+ # Prepare opts[key] as a Hash/Array when possible.
72
+ #
73
+ # 1. If opts[key] is already Hash/Array -> symbolize, done
74
+ # 2. If opts[key] is a String ("...", @file, -) -> parse
75
+ # 3. If opts[key] is nil/absent and opts[:stdin] looks like JSON
76
+ # object/array -> fill from stdin
77
+ # 4. Boolean true/false left alone (legacy `opt :json, type: :boolean`)
78
+ #
79
+ # Returns the resolved value (or nil). Mutates opts.
80
+ def prepare_json!(opts, key: :json)
81
+ attach_stdin!(opts)
82
+ key = key.to_sym
83
+ val = opts[key]
84
+
85
+ if val.is_a?(Hash) || val.is_a?(Array)
86
+ opts[key] = symbolize(val)
87
+ return opts[key]
88
+ end
89
+
90
+ if val.is_a?(String) && !val.empty?
91
+ opts[key] = resolve_json_value(val, opts, source: "--#{key.to_s.tr('_', '-')}")
92
+ return opts[key]
93
+ end
94
+
95
+ # Skip booleans (output-mode flags) and other non-nil junk.
96
+ return val unless val.nil?
97
+
98
+ raw = opts[:stdin]
99
+ return nil if raw.nil? || raw.empty?
100
+
101
+ stripped = raw.lstrip
102
+ return nil unless stripped.start_with?('{', '[')
103
+
104
+ opts[key] = parse_json(raw, source: 'stdin')
105
+ opts[key]
106
+ end
107
+
108
+ def symbolize(obj)
109
+ case obj
110
+ when Hash
111
+ obj.each_with_object({}) do |(k, v), h|
112
+ h[k.respond_to?(:to_sym) ? k.to_sym : k] = symbolize(v)
113
+ end
114
+ when Array
115
+ obj.map { |v| symbolize(v) }
116
+ else
117
+ obj
118
+ end
119
+ end
120
+ end
121
+ end
data/lib/hammer/option.rb CHANGED
@@ -3,7 +3,7 @@ class Hammer
3
3
  class Option
4
4
  attr_reader :name, :type, :default, :desc, :aliases, :required, :placeholder
5
5
 
6
- ALLOWED_KEYS ||= %i[type default desc alias req placeholder].freeze
6
+ ALLOWED_KEYS ||= %i[type default desc alias req placeholder positional].freeze
7
7
  RESERVED_FLAGS ||= %w[-h --help].freeze
8
8
 
9
9
  def initialize(name, **opts)
@@ -22,6 +22,11 @@ class Hammer
22
22
  # Custom usage placeholder, e.g. `placeholder: 't/f'` -> `--log t/f`.
23
23
  # Falls back to uppercased name (`--log LOG`) when nil.
24
24
  @placeholder = opts[:placeholder]
25
+ # `positional: false` means "flag form only" - the option never claims a
26
+ # bare positional. For commands that forward their positionals somewhere
27
+ # else (`llm wrap --lines 3 -- claude`), the default fill would otherwise
28
+ # swallow the first one.
29
+ @positional = opts.key?(:positional) ? !!opts[:positional] : true
25
30
 
26
31
  # Reserve -h / --help so every command supports them uniformly.
27
32
  if RESERVED_FLAGS.include?(switch)
@@ -47,22 +52,33 @@ class Hammer
47
52
  "--#{name.to_s.tr('_', '-')}"
48
53
  end
49
54
 
50
- def negation
51
- "--no-#{name.to_s.tr('_', '-')}"
52
- end
53
-
54
55
  def cast(value)
55
56
  case type
56
57
  when :boolean then !!value && value != 'false' && value != '0'
57
58
  when :integer then Integer(value)
58
59
  when :float then Float(value)
59
60
  when :array then value.is_a?(Array) ? value : value.to_s.split(',')
61
+ when :json
62
+ # Hash/Array pass through. Strings may be inline JSON, @file, or "-"
63
+ # (stdin resolved later by Hammer::Input.prepare_json!). "@" and
64
+ # inline JSON are parsed here so opts[:x] is already a Hash in
65
+ # the handler when the flag is used.
66
+ return value if value.is_a?(Hash) || value.is_a?(Array)
67
+ s = value.to_s
68
+ return s if s == '-' || s.empty?
69
+ Hammer::Input.resolve_json_value(s, {}, source: switch)
60
70
  else value.to_s
61
71
  end
62
72
  rescue ArgumentError, TypeError
63
73
  raise Hammer::Parser::Error, "invalid #{type} value for --#{name}: #{value.inspect}"
64
74
  end
65
75
 
76
+ # JSON bodies are flags / stdin / @file — never bare positionals. Anything
77
+ # declared `positional: false` opts out the same way.
78
+ def skip_positional_fill?
79
+ type == :json || !@positional
80
+ end
81
+
66
82
  def usage
67
83
  flag = switch
68
84
  flag += " #{placeholder || name.to_s.upcase}" unless boolean?
@@ -81,9 +97,9 @@ class Hammer
81
97
 
82
98
  # Structured form for JSON export (`h:json`). The GUI maps `type`
83
99
  # to a form widget; `default`/`required`/`desc` decorate it. Mirrors
84
- # the data behind `usage`. `negation` is only meaningful for booleans.
100
+ # the data behind `usage`.
85
101
  def to_h
86
- h = {
102
+ {
87
103
  name: name.to_s,
88
104
  type: type.to_s,
89
105
  default: default,
@@ -94,8 +110,6 @@ class Hammer
94
110
  aliases: aliases,
95
111
  usage: usage.strip
96
112
  }
97
- h[:negation] = negation if boolean?
98
- h
99
113
  end
100
114
  end
101
115
  end
data/lib/hammer/parser.rb CHANGED
@@ -5,11 +5,10 @@ class Hammer
5
5
  Error ||= Class.new(StandardError)
6
6
 
7
7
  def initialize(options)
8
- @options = options
9
- @by_switch = {}
8
+ @options = options
9
+ @by_switch = {}
10
10
  options.each do |opt|
11
11
  register_switch(opt.switch, opt)
12
- register_switch(opt.negation, opt) if opt.boolean?
13
12
  opt.aliases.each { |a| register_switch(a, opt) }
14
13
  end
15
14
  end
@@ -31,11 +30,7 @@ class Hammer
31
30
  if token.start_with?('--') && token.include?('=')
32
31
  key, val = token.split('=', 2)
33
32
  opt = lookup!(key)
34
- if opt.boolean? && key.start_with?('--no-')
35
- values[opt.name] = !opt.cast(val) # `--no-x=false` -> true
36
- else
37
- values[opt.name] = opt.cast(val)
38
- end
33
+ values[opt.name] = opt.cast(val)
39
34
  i += 1
40
35
  next
41
36
  end
@@ -43,7 +38,8 @@ class Hammer
43
38
  if @by_switch.key?(token)
44
39
  opt = @by_switch[token]
45
40
  if opt.boolean?
46
- values[opt.name] = !token.start_with?('--no-')
41
+ # Presence alone is enough — the flag is true regardless of name.
42
+ values[opt.name] = true
47
43
  i += 1
48
44
  else
49
45
  val = argv[i + 1]
@@ -74,12 +70,14 @@ class Hammer
74
70
  end
75
71
 
76
72
  # Fill un-set non-boolean opts from positional args in declaration
77
- # order. Booleans always need an explicit flag. Scalars take one
78
- # positional each; an :array opt slurps whatever's left, so it's
79
- # filled last and never starves a later-declared scalar opt.
73
+ # order. Booleans always need an explicit flag. type: :json and
74
+ # `positional: false` are flag-only (skip_positional_fill?). Scalars
75
+ # take one positional each; an :array opt slurps whatever's left,
76
+ # so it's filled last
77
+ # and never starves a later-declared scalar opt.
80
78
  array_opt = nil
81
79
  @options.each do |opt|
82
- next if opt.boolean? || values.key?(opt.name)
80
+ next if opt.boolean? || opt.skip_positional_fill? || values.key?(opt.name)
83
81
  if opt.type == :array
84
82
  array_opt = opt
85
83
  next
@@ -102,7 +100,7 @@ class Hammer
102
100
  private
103
101
 
104
102
  # Map a flag string to its option, refusing silent shadowing when two
105
- # options would claim the same switch/negation/alias.
103
+ # options would claim the same switch/alias.
106
104
  def register_switch(flag, opt)
107
105
  if (prev = @by_switch[flag]) && prev != opt
108
106
  raise Error, "flag #{flag} claimed by both :#{prev.name} and :#{opt.name}"