Module: Hammer::Input

Defined in:
lib/hammer/input.rb

Overview

Stdin + JSON helpers for opts. Hammer exposes piped stdin as opts[:stdin], read lazily on first access; recipes opt into JSON body handling via Hammer::Input.prepare_json! (typically from a before hook) and/or opt :json, type: :json / global_opt :json, type: :json.

Constant Summary collapse

LAZY_STDIN =

Reads stdin into the hash on the first opts[:stdin] lookup and keeps the result, so a command that never asks for stdin never waits on it.

lambda do |hash, key|
  key == :stdin ? (hash[:stdin] = Input.read_stdin) : nil
end

Class Method Summary collapse

Class Method Details

.attach_stdin!(opts) ⇒ Object

Idempotent: leaves an explicit opts alone, otherwise installs the lazy reader. Nothing is read until a recipe looks at opts.



49
50
51
52
53
# File 'lib/hammer/input.rb', line 49

def attach_stdin!(opts)
  return if opts.key?(:stdin) || opts.default_proc.equal?(LAZY_STDIN)

  opts.default_proc = LAZY_STDIN
end

.idle_socket?(io) ⇒ Boolean

Returns:

  • (Boolean)


33
34
35
36
37
38
39
# File 'lib/hammer/input.rb', line 33

def idle_socket?(io)
  return false unless io.respond_to?(:stat) && io.stat.socket?

  IO.select([io], nil, nil, 0).nil?
rescue StandardError
  false
end

.parse_json(raw, source: 'json') ⇒ Object

Parse a JSON source string into a Hash/Array with symbol keys. source is only used in error messages ('stdin', '--json', path, ...).



57
58
59
60
61
62
# File 'lib/hammer/input.rb', line 57

def parse_json(raw, source: 'json')
  data = JSON.parse(raw)
  symbolize(data)
rescue JSON::ParserError => e
  raise Hammer::Parser::Error, "invalid JSON (#{source}): #{e.message}"
end

.prepare_json!(opts, key: :json) ⇒ Object

Prepare opts as a Hash/Array when possible.

1. If opts[key] is already Hash/Array -> symbolize, done
2. If opts[key] is a String ("...", @file, -) -> parse
3. If opts[key] is nil/absent and opts[:stdin] looks like JSON
 object/array -> fill from stdin
4. Boolean true/false left alone (legacy `opt :json, type: :boolean`)

Returns the resolved value (or nil). Mutates opts.



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/hammer/input.rb', line 100

def prepare_json!(opts, key: :json)
  attach_stdin!(opts)
  key = key.to_sym
  val = opts[key]

  if val.is_a?(Hash) || val.is_a?(Array)
    opts[key] = symbolize(val)
    return opts[key]
  end

  if val.is_a?(String) && !val.empty?
    opts[key] = resolve_json_value(val, opts, source: "--#{key.to_s.tr('_', '-')}")
    return opts[key]
  end

  # Skip booleans (output-mode flags) and other non-nil junk.
  return val unless val.nil?

  raw = opts[:stdin]
  return nil if raw.nil? || raw.empty?

  stripped = raw.lstrip
  return nil unless stripped.start_with?('{', '[')

  opts[key] = parse_json(raw, source: 'stdin')
  opts[key]
end

.read_stdinObject

Read piped stdin once. Returns nil when stdin is a TTY or empty. Does not consume an interactive TTY, and does not block on a socket that has nothing pending: agent harnesses (Claude Code's Bash tool) hand every command a unix socket as stdin that never reaches EOF, so a plain read would hang there for good. A shell pipe or a redirected file is a FIFO or a regular file and is still read in full.



19
20
21
22
23
24
25
26
27
28
29
30
31
# File 'lib/hammer/input.rb', line 19

def read_stdin
  return nil if $stdin.closed?
  return nil if $stdin.tty?
  return nil if idle_socket?($stdin)

  data = $stdin.read
  return nil if data.nil?

  data = data.dup.force_encoding(Encoding::UTF_8) if data.respond_to?(:force_encoding)
  data.empty? ? nil : data
rescue StandardError
  nil
end

.resolve_json_value(value, opts = {}, source: 'json') ⇒ Object

Resolve a value that may be:

- Hash / Array  -> returned as-is (symbolized if Hash has string keys)
- "-"           -> use opts[:stdin]
- "@path"       -> File.read(path)
- JSON string   -> parse


69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/hammer/input.rb', line 69

def resolve_json_value(value, opts = {}, source: 'json')
  return symbolize(value) if value.is_a?(Hash) || value.is_a?(Array)

  s = value.to_s
  if s == '-'
    attach_stdin!(opts)
    raw = opts[:stdin]
    raise Hammer::Parser::Error, "no JSON on stdin for #{source}" if raw.nil? || raw.empty?

    return parse_json(raw, source: 'stdin')
  end

  if s.start_with?('@')
    path = s[1..]
    raise Hammer::Parser::Error, "JSON file not found: #{path}" unless File.file?(path)

    return parse_json(File.read(path), source: path)
  end

  parse_json(s, source: source)
end

.symbolize(obj) ⇒ Object



128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/hammer/input.rb', line 128

def symbolize(obj)
  case obj
  when Hash
    obj.each_with_object({}) do |(k, v), h|
      h[k.respond_to?(:to_sym) ? k.to_sym : k] = symbolize(v)
    end
  when Array
    obj.map { |v| symbolize(v) }
  else
    obj
  end
end