codex-notify 1.0.0

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 (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +50 -0
  3. data/LICENSE +21 -0
  4. data/README.md +782 -0
  5. data/bin/codex-notify +6 -0
  6. data/bin/codex-notify-hook +6 -0
  7. data/lib/codex_notify/cli.rb +112 -0
  8. data/lib/codex_notify/config.rb +113 -0
  9. data/lib/codex_notify/config_diagnostics.rb +61 -0
  10. data/lib/codex_notify/config_migrator.rb +148 -0
  11. data/lib/codex_notify/config_support.rb +157 -0
  12. data/lib/codex_notify/destination_name.rb +18 -0
  13. data/lib/codex_notify/destination_resolver.rb +68 -0
  14. data/lib/codex_notify/durable_slack_publisher.rb +70 -0
  15. data/lib/codex_notify/env_source_loader.rb +91 -0
  16. data/lib/codex_notify/hook_cli.rb +90 -0
  17. data/lib/codex_notify/hook_config.rb +110 -0
  18. data/lib/codex_notify/hook_event.rb +16 -0
  19. data/lib/codex_notify/hook_formatter.rb +61 -0
  20. data/lib/codex_notify/hook_input_validator.rb +241 -0
  21. data/lib/codex_notify/hook_runner.rb +181 -0
  22. data/lib/codex_notify/hook_store.rb +133 -0
  23. data/lib/codex_notify/hook_thread_publisher.rb +107 -0
  24. data/lib/codex_notify/log_event_parser.rb +230 -0
  25. data/lib/codex_notify/message_formatter.rb +115 -0
  26. data/lib/codex_notify/outbox_commands.rb +43 -0
  27. data/lib/codex_notify/secret_protection.rb +37 -0
  28. data/lib/codex_notify/session_log.rb +63 -0
  29. data/lib/codex_notify/slack_client.rb +117 -0
  30. data/lib/codex_notify/slack_delivery_worker.rb +269 -0
  31. data/lib/codex_notify/slack_outbox.rb +206 -0
  32. data/lib/codex_notify/stream_processor.rb +141 -0
  33. data/lib/codex_notify/trusted_config_loader.rb +159 -0
  34. data/lib/codex_notify/version.rb +5 -0
  35. data/lib/codex_notify.rb +36 -0
  36. metadata +96 -0
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require_relative 'message_formatter'
5
+ require_relative 'slack_delivery_worker'
6
+
7
+ module CodexNotify
8
+ class DurableSlackPublisher
9
+ def initialize(client:, store:, outbox:, channel:, throttle_sec: 0.0)
10
+ @store = store
11
+ @outbox = outbox
12
+ @channel = channel
13
+ @worker = SlackDeliveryWorker.new(outbox:, client:, store:, inter_message_delay: throttle_sec)
14
+ @queued_ids = []
15
+ end
16
+
17
+ attr_reader :queued_ids
18
+
19
+ def publish_standalone(key:, message:)
20
+ enqueue(key:, action: :standalone, message:)
21
+ end
22
+
23
+ def ensure_thread(key:, root_message:)
24
+ return if @store.thread_ts_for(key)
25
+ return if @outbox.pending_root?(key, generation: @store.generation_for(key))
26
+
27
+ enqueue(key:, action: :ensure_thread, message: root_message)
28
+ end
29
+
30
+ def publish_root_or_reply(key:, message:)
31
+ enqueue(key:, action: :root_or_reply, message:, recovery_root_message: message)
32
+ end
33
+
34
+ def publish_reply(key:, message:, recovery_root_message:)
35
+ has_root = @store.thread_ts_for(key) || @outbox.pending_root?(key, generation: @store.generation_for(key))
36
+ return unless has_root
37
+
38
+ enqueue(key:, action: :reply, message:, recovery_root_message: recovery_root_message || message)
39
+ end
40
+
41
+ def reset(key:)
42
+ @store.advance_generation(key)
43
+ end
44
+
45
+ def drain
46
+ @worker.drain(channel: @channel)
47
+ end
48
+
49
+ def key(namespace, identity)
50
+ Digest::SHA256.hexdigest("#{namespace}\0#{identity}")
51
+ end
52
+
53
+ private
54
+
55
+ def enqueue(key:, action:, message:, recovery_root_message: nil)
56
+ chunks = message ? MessageFormatter.chunks(message).to_a : []
57
+ recovery_chunks = recovery_root_message ? MessageFormatter.chunks(recovery_root_message).to_a : []
58
+ id = @outbox.enqueue(
59
+ channel: @channel,
60
+ ordering_key: key,
61
+ generation: @store.generation_for(key),
62
+ action:,
63
+ chunks:,
64
+ recovery_chunks:
65
+ )
66
+ @queued_ids << id
67
+ id
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'config_support'
4
+ require_relative 'trusted_config_loader'
5
+
6
+ module CodexNotify
7
+ class EnvSourceLoader
8
+ class Error < StandardError; end
9
+
10
+ Source = Struct.new(:kind, :path, :values, keyword_init: true)
11
+ LookupResult = Struct.new(:value, :source, keyword_init: true)
12
+
13
+ class SourceSet
14
+ include Enumerable
15
+
16
+ def initialize(sources)
17
+ @sources = sources.freeze
18
+ end
19
+
20
+ def each(&block)
21
+ @sources.each(&block)
22
+ end
23
+
24
+ def lookup(key)
25
+ each do |source|
26
+ value = source.values[key]
27
+ return LookupResult.new(value:, source:) if value && !value.empty?
28
+ end
29
+ nil
30
+ end
31
+
32
+ def excluding_kind(kind)
33
+ self.class.new(reject { |source| source.kind == kind })
34
+ end
35
+
36
+ def restrict_kind(kind, keys:)
37
+ self.class.new(map do |source|
38
+ next source unless source.kind == kind
39
+
40
+ Source.new(kind: source.kind, path: source.path, values: source.values.slice(*keys))
41
+ end)
42
+ end
43
+ end
44
+
45
+ include ConfigSupport
46
+ private(*ConfigSupport.instance_methods(false))
47
+
48
+ def initialize(legacy_checkout_root: nil, environment: ENV, stderr: $stderr, config_loader: nil)
49
+ @legacy_checkout_root = Pathname(legacy_checkout_root).expand_path if legacy_checkout_root
50
+ @environment = environment
51
+ @stderr = stderr
52
+ @config_loader = config_loader || TrustedConfigLoader.new(environment:, stderr:)
53
+ end
54
+
55
+ def load(path: ConfigSupport::DEFAULT_ENV_PATH, explicit: false, config_path: nil)
56
+ process = Source.new(kind: :process, path: nil, values: @environment.to_h)
57
+ files = resolve_env_paths(path, legacy_checkout_root:).map { |env_path| load_file(env_path, explicit:) }
58
+ configs = @config_loader.load(explicit_path: config_path).map do |config|
59
+ Source.new(kind: config.kind, path: config.path, values: config.values)
60
+ end
61
+ explicit_config, default_config = configs.partition { |source| source.kind == :config_explicit }
62
+
63
+ ordered = if explicit
64
+ [process, *explicit_config, *files, *default_config]
65
+ else
66
+ [process, *explicit_config, *default_config, *files]
67
+ end
68
+ SourceSet.new(ordered)
69
+ end
70
+
71
+ private
72
+
73
+ attr_reader :legacy_checkout_root
74
+
75
+ def load_file(path, explicit:)
76
+ ConfigDiagnostics.warn_if_env_file_insecure(path, stderr: @stderr)
77
+ Source.new(kind: source_kind(path, explicit:), path: path.expand_path, values: Dotenv.parse(path.to_s))
78
+ rescue SystemCallError => e
79
+ raise Error, "could not read env file #{path}: #{e.class}"
80
+ end
81
+
82
+ def source_kind(path, explicit:)
83
+ return :explicit if explicit
84
+
85
+ return :repository unless legacy_checkout_root
86
+
87
+ checkout_env_path = legacy_checkout_root.join(ConfigSupport::DEFAULT_ENV_PATH)
88
+ File.identical?(path, checkout_env_path) ? :tool : :repository
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'hook_config'
5
+ require_relative 'hook_input_validator'
6
+ require_relative 'hook_runner'
7
+ require_relative 'message_formatter'
8
+ require_relative 'outbox_commands'
9
+
10
+ module CodexNotify
11
+ module HookCLI
12
+ MAX_STDIN_BYTES = 1_048_576
13
+
14
+ module_function
15
+
16
+ def main(argv = nil, stdin: $stdin, stderr: $stderr, stdout: $stdout,
17
+ runner_factory: HookRunner.method(:new), legacy_checkout_root: nil)
18
+ args = HookConfig.parse_args(argv, stderr:, legacy_checkout_root:)
19
+
20
+ if args.migrate_config
21
+ env_path = args.env_file if args.env_file_explicit
22
+ return ConfigMigrator.new(legacy_checkout_root:, stdout:, stderr:).run(
23
+ env_path:,
24
+ config_path: args.config_file
25
+ )
26
+ end
27
+
28
+ if args.outbox_action
29
+ return OutboxCommands.run(
30
+ action: args.outbox_action,
31
+ id: args.outbox_id,
32
+ outbox_dir: args.outbox_dir,
33
+ token: args.token,
34
+ channel: args.channel,
35
+ state_file: args.state_file,
36
+ stdout:,
37
+ stderr:
38
+ )
39
+ end
40
+
41
+ unless args.token && args.channel
42
+ stderr.puts('ERROR: need --token/--channel or a Slack destination from environment or config file')
43
+ return 2
44
+ end
45
+
46
+ payload = parse_stdin(stdin)
47
+ event = HookInputValidator.validate(event_name: args.event_name, payload:)
48
+
49
+ runner = runner_factory.call(
50
+ token: args.token,
51
+ channel: args.channel,
52
+ user_name: args.user_name,
53
+ title: args.title,
54
+ state_file: args.state_file,
55
+ outbox_dir: args.outbox_dir,
56
+ mode: args.mode,
57
+ stdout: stdout
58
+ )
59
+ code = runner.run(event:)
60
+ stderr.puts('ERROR: Slack delivery requires outbox review; run --outbox-status') if code == 1
61
+ code
62
+ rescue Interrupt
63
+ 0
64
+ rescue HookInputError => e
65
+ stderr.puts("ERROR: #{e.message}")
66
+ 2
67
+ rescue HookConfig::Error, ConfigMigrator::Error, OptionParser::ParseError => e
68
+ stderr.puts("ERROR: #{e.message}")
69
+ 2
70
+ rescue StandardError => e
71
+ stderr.puts("ERROR: #{e.class}: #{e.message}")
72
+ 1
73
+ end
74
+
75
+ def parse_stdin(stdin)
76
+ raw = stdin.read(MAX_STDIN_BYTES + 1).to_s
77
+ if raw.bytesize > MAX_STDIN_BYTES
78
+ raise HookInputError, "hook stdin exceeds maximum size of #{MAX_STDIN_BYTES} bytes"
79
+ end
80
+ raise HookInputError, 'hook stdin is empty' if raw.strip.empty?
81
+
82
+ payload = JSON.parse(raw)
83
+ raise HookInputError, 'hook payload must be a JSON object' unless payload.is_a?(Hash)
84
+
85
+ payload
86
+ rescue JSON::ParserError
87
+ raise HookInputError, 'hook stdin is not valid JSON'
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require 'pathname'
5
+ require_relative 'config_support'
6
+ require_relative 'config_migrator'
7
+ require_relative 'destination_resolver'
8
+ require_relative 'env_source_loader'
9
+
10
+ module CodexNotify
11
+ module HookConfig
12
+ extend ConfigSupport
13
+
14
+ DEFAULT_ENV_PATH = ConfigSupport::DEFAULT_ENV_PATH
15
+ DEFAULT_STATE_PATH = Pathname(File.expand_path('~/.codex-notify-hook/state.json'))
16
+ DEFAULT_MODE = 'normal'
17
+ MODES = %w[normal debug].freeze
18
+ class Error < StandardError; end
19
+
20
+ Args = Struct.new(
21
+ :env_file,
22
+ :env_file_explicit,
23
+ :config_file,
24
+ :migrate_config,
25
+ :token,
26
+ :channel,
27
+ :destination,
28
+ :user_name,
29
+ :title,
30
+ :state_file,
31
+ :event_name,
32
+ :mode,
33
+ :outbox_dir,
34
+ :outbox_action,
35
+ :outbox_id,
36
+ :token_from_cli,
37
+ keyword_init: true
38
+ )
39
+
40
+ module_function
41
+
42
+ def build_parser
43
+ options = Args.new(
44
+ env_file: DEFAULT_ENV_PATH,
45
+ env_file_explicit: false,
46
+ config_file: nil,
47
+ migrate_config: false,
48
+ token: nil,
49
+ channel: nil,
50
+ destination: nil,
51
+ user_name: nil,
52
+ title: nil,
53
+ state_file: DEFAULT_STATE_PATH.to_s,
54
+ event_name: nil,
55
+ mode: nil,
56
+ outbox_dir: nil,
57
+ outbox_action: nil,
58
+ outbox_id: nil,
59
+ token_from_cli: false
60
+ )
61
+
62
+ parser = OptionParser.new do |opts|
63
+ opts.banner = 'Usage: codex-notify-hook [options]'
64
+ add_common_options(opts, options)
65
+ opts.on('--state-file PATH') { |v| options.state_file = v }
66
+ opts.on('--event NAME') { |v| options.event_name = v }
67
+ opts.on('--mode MODE', MODES) { |v| options.mode = v }
68
+ opts.on('--outbox-dir PATH') { |v| options.outbox_dir = v }
69
+ end
70
+
71
+ [parser, options]
72
+ end
73
+
74
+ def parse_args(argv = nil, stderr: $stderr, legacy_checkout_root: nil)
75
+ parser, options = build_parser
76
+ parser.parse!(argv || [])
77
+ return options if options.migrate_config
78
+
79
+ ConfigDiagnostics.warn_deprecated_cli_token(stderr:) if options.token_from_cli
80
+
81
+ sources = EnvSourceLoader.new(legacy_checkout_root:, stderr:).load(
82
+ path: options.env_file,
83
+ explicit: options.env_file_explicit,
84
+ config_path: options.config_file
85
+ )
86
+ policy = resolve_policy(sources, stderr:)
87
+ warn_ignored_repository_values(sources, policy:, stderr:)
88
+ apply_destination(options, sources, policy:, stderr:)
89
+ apply_presentation(options, sources, policy:)
90
+ options.outbox_dir ||= "#{options.state_file}.outbox"
91
+
92
+ raise Error, "mode must be one of: #{MODES.join(', ')}" unless MODES.include?(options.mode)
93
+ options
94
+ rescue ConfigSupport::Error, EnvSourceLoader::Error, TrustedConfigLoader::Error, DestinationResolver::Error => e
95
+ raise Error, e.message
96
+ end
97
+
98
+ def apply_presentation(options, sources, policy:)
99
+ eligible = eligible_sources(sources, policy:)
100
+ trusted = trusted_sources(sources)
101
+ options.user_name ||= eligible.lookup('CODEX_NOTIFY_USER_NAME')&.value || system_user_name
102
+ options.title ||= eligible.lookup('CODEX_NOTIFY_TITLE')&.value
103
+ options.event_name ||= eligible.lookup('CODEX_HOOK_EVENT')&.value ||
104
+ eligible.lookup('CODEX_NOTIFY_HOOK_EVENT')&.value
105
+ options.mode ||= eligible.lookup('CODEX_NOTIFY_MODE')&.value || DEFAULT_MODE
106
+ options.outbox_dir ||= trusted.lookup('CODEX_NOTIFY_OUTBOX_DIR')&.value
107
+ end
108
+
109
+ end
110
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CodexNotify
4
+ HookEvent = Data.define(
5
+ :name,
6
+ :session_id,
7
+ :cwd,
8
+ :source,
9
+ :prompt,
10
+ :tool_name,
11
+ :tool_input,
12
+ :tool_response,
13
+ :assistant_message,
14
+ :raw_payload
15
+ )
16
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'message_formatter'
5
+
6
+ module CodexNotify
7
+ module HookFormatter
8
+ module_function
9
+
10
+ def session_root_message(event, title:, user_name:)
11
+ body = [
12
+ 'Codex hook notification started.',
13
+ "CWD: #{event.cwd}",
14
+ "User: #{user_name}",
15
+ "Session ID: #{event.session_id}"
16
+ ].join("\n")
17
+ tool_message(title, body)
18
+ end
19
+
20
+ def prompt_message(event, user_name:)
21
+ MessageFormatter.message(title: user_name, body: event.prompt, presentation: :plain)
22
+ end
23
+
24
+ def assistant_message(event)
25
+ MessageFormatter.message(title: 'assistant', body: event.assistant_message, presentation: :plain)
26
+ end
27
+
28
+ def tool_message(title, body)
29
+ MessageFormatter.message(title:, body:, presentation: :block)
30
+ end
31
+
32
+ def pre_tool_message(event)
33
+ command = event.tool_input['command']
34
+ if command.nil? || command.to_s.empty?
35
+ payload = JSON.pretty_generate(event.raw_payload)
36
+ return tool_message('tool', payload)
37
+ end
38
+
39
+ tool_message('tool', "$ #{command}")
40
+ end
41
+
42
+ def post_tool_message(event)
43
+ output = event.tool_response['output']
44
+ exit_code = event.tool_response['exit_code']
45
+ stderr = event.tool_response['stderr']
46
+
47
+ parts = []
48
+ parts << "[exit_code] #{exit_code}" unless exit_code.nil?
49
+ parts << "[output]\n#{output}" unless output.nil? || output.to_s.empty?
50
+ parts << "[stderr]\n#{stderr}" unless stderr.nil? || stderr.to_s.empty?
51
+ parts = [JSON.pretty_generate(event.raw_payload)] if parts.empty?
52
+ tool_message('tool', parts.join("\n\n"))
53
+ end
54
+
55
+ def permission_request_message(event)
56
+ description = event.tool_input['description']
57
+ description ||= 'Codex is waiting for your approval.'
58
+ tool_message("approval required: #{event.tool_name}", description)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'hook_event'
4
+
5
+ module CodexNotify
6
+ class HookInputError < StandardError; end
7
+
8
+ module HookInputValidator
9
+ EVENT_ALIASES = {
10
+ 'userpromptsubmit' => 'UserPromptSubmit',
11
+ 'pretooluse' => 'PreToolUse',
12
+ 'posttooluse' => 'PostToolUse',
13
+ 'permissionrequest' => 'PermissionRequest',
14
+ 'stop' => 'Stop',
15
+ 'sessionstart' => 'SessionStart'
16
+ }.freeze
17
+ SUPPORTED_EVENTS = EVENT_ALIASES.values.freeze
18
+
19
+ module_function
20
+
21
+ def validate(event_name:, payload:)
22
+ raise HookInputError, 'hook payload must be a JSON object' unless payload.is_a?(Hash)
23
+
24
+ normalized_event = validate_event_name(event_name, payload)
25
+ session_id = validate_session_id(payload)
26
+ attributes = normalize_event_payload(normalized_event, payload)
27
+ HookEvent.new(
28
+ name: immutable_copy(normalized_event),
29
+ session_id: immutable_copy(session_id),
30
+ cwd: immutable_copy(payload['cwd'] || Dir.pwd),
31
+ source: nil,
32
+ prompt: nil,
33
+ tool_name: nil,
34
+ tool_input: nil,
35
+ tool_response: nil,
36
+ assistant_message: nil,
37
+ raw_payload: nil,
38
+ **attributes
39
+ )
40
+ end
41
+
42
+ def normalize_event_name(name)
43
+ return nil unless name.is_a?(String)
44
+
45
+ text = name.strip
46
+ return nil if text.empty?
47
+ return text if SUPPORTED_EVENTS.include?(text)
48
+
49
+ EVENT_ALIASES[text.downcase.gsub(/[_\s-]/, '')] || text
50
+ end
51
+
52
+ def validate_event_name(event_name, payload)
53
+ names = [event_name, payload['hook_event_name'], payload['event']].compact
54
+ raise HookInputError, 'hook event name is required' if names.empty?
55
+
56
+ normalized = names.map do |name|
57
+ value = normalize_event_name(name)
58
+ raise HookInputError, 'hook event name must be a non-empty string' if value.nil?
59
+ raise HookInputError, 'unsupported hook event' unless SUPPORTED_EVENTS.include?(value)
60
+
61
+ value
62
+ end
63
+ raise HookInputError, 'hook event names from arguments and payload do not match' unless normalized.uniq.one?
64
+
65
+ normalized.first
66
+ end
67
+ private_class_method :validate_event_name
68
+
69
+ def validate_session_id(payload)
70
+ candidates = [
71
+ payload['session_id'],
72
+ payload['sessionId'],
73
+ nested_session_id(payload)
74
+ ].compact
75
+ raise HookInputError, 'hook session ID is required' if candidates.empty?
76
+
77
+ normalized = candidates.map do |value|
78
+ unless value.is_a?(String) && !value.strip.empty?
79
+ raise HookInputError, 'hook session ID must be a non-empty string'
80
+ end
81
+
82
+ value.strip
83
+ end
84
+ raise HookInputError, 'hook session IDs in payload do not match' unless normalized.uniq.one?
85
+
86
+ normalized.first
87
+ end
88
+ private_class_method :validate_session_id
89
+
90
+ def nested_session_id(payload)
91
+ session = payload['session']
92
+ return nil if session.nil?
93
+ raise HookInputError, 'hook session must be an object' unless session.is_a?(Hash)
94
+
95
+ session['id']
96
+ end
97
+ private_class_method :nested_session_id
98
+
99
+ def normalize_event_payload(event_name, payload)
100
+ case event_name
101
+ when 'SessionStart'
102
+ source = payload_value(payload, 'source')
103
+ require_non_empty_string(source, event_name, 'source')
104
+ { source: immutable_copy(source) }
105
+ when 'UserPromptSubmit'
106
+ prompt = payload_value(payload, 'prompt')
107
+ require_non_empty_string(prompt, event_name, 'prompt')
108
+ { prompt: immutable_copy(prompt) }
109
+ when 'PreToolUse'
110
+ tool_name = require_tool_name(payload, event_name)
111
+ tool_input = normalize_pre_tool_input(payload, event_name)
112
+ raw_payload = payload unless displayable_command?(tool_input['command'])
113
+ { tool_name:, tool_input:, raw_payload: immutable_copy(raw_payload) }
114
+ when 'PostToolUse'
115
+ tool_name = require_tool_name(payload, event_name)
116
+ tool_response = normalize_post_tool_response(payload, event_name)
117
+ raw_payload = payload unless displayable_tool_response?(tool_response)
118
+ { tool_name:, tool_response:, raw_payload: immutable_copy(raw_payload) }
119
+ when 'PermissionRequest'
120
+ tool_name = require_tool_name(payload, event_name)
121
+ value = payload_value(payload, 'tool_input')
122
+ require_hash(value, event_name, 'tool_input')
123
+ tool_input = { 'description' => immutable_copy(value['description']) }.freeze
124
+ { tool_name:, tool_input: }
125
+ when 'Stop'
126
+ value, present = payload_value_with_presence(payload, 'last_assistant_message')
127
+ raise HookInputError, 'Stop requires last_assistant_message' unless present
128
+ raise HookInputError, 'Stop last_assistant_message must be a string' unless value.is_a?(String)
129
+
130
+ { assistant_message: immutable_copy(value) }
131
+ end
132
+ end
133
+ private_class_method :normalize_event_payload
134
+
135
+ def require_tool_name(payload, event_name)
136
+ value = payload_value(payload, 'tool_name')
137
+ require_non_empty_string(value, event_name, 'tool_name')
138
+ immutable_copy(value)
139
+ end
140
+ private_class_method :require_tool_name
141
+
142
+ def normalize_pre_tool_input(payload, event_name)
143
+ value, present = payload_value_with_presence(payload, 'tool_input')
144
+ if present
145
+ valid = value.is_a?(Hash) || (value.is_a?(String) && !value.strip.empty?)
146
+ raise HookInputError, "#{event_name} tool_input must be an object or non-empty string" unless valid
147
+
148
+ command = value.is_a?(Hash) ? value['command'] : value
149
+ return immutable_copy('command' => command)
150
+ end
151
+
152
+ command, command_present = payload_value_with_presence(payload, 'command')
153
+ valid_command = (command.is_a?(Array) && !command.empty?) ||
154
+ (command.is_a?(String) && !command.strip.empty?)
155
+ return immutable_copy('command' => command) if command_present && valid_command
156
+
157
+ raise HookInputError, "#{event_name} requires tool_input or command"
158
+ end
159
+ private_class_method :normalize_pre_tool_input
160
+
161
+ def normalize_post_tool_response(payload, event_name)
162
+ value, present = payload_value_with_presence(payload, 'tool_response')
163
+ if present && !value.is_a?(Hash) && !value.is_a?(String)
164
+ raise HookInputError, "#{event_name} tool_response must be an object or string"
165
+ end
166
+
167
+ legacy_fields = %w[tool_output output exit_code stderr]
168
+ legacy_present = legacy_fields.any? do |field|
169
+ legacy_value, legacy_present = payload_value_with_presence(payload, field)
170
+ legacy_present && !legacy_value.nil?
171
+ end
172
+ unless present || legacy_present
173
+ raise HookInputError, "#{event_name} requires tool_response or a supported legacy result field"
174
+ end
175
+
176
+ response = value.is_a?(Hash) ? value : {}
177
+ output = response['output'] || response['stdout'] || (value unless value.is_a?(Hash))
178
+ output ||= payload_value(payload, 'tool_output') || payload_value(payload, 'output')
179
+ exit_code = response['exit_code'] || response['exitCode'] || payload_value(payload, 'exit_code')
180
+ stderr = response['stderr'] || payload_value(payload, 'stderr')
181
+
182
+ immutable_copy('output' => output, 'exit_code' => exit_code, 'stderr' => stderr)
183
+ end
184
+ private_class_method :normalize_post_tool_response
185
+
186
+ def displayable_command?(command)
187
+ !command.nil? && !command.to_s.empty?
188
+ end
189
+ private_class_method :displayable_command?
190
+
191
+ def displayable_tool_response?(response)
192
+ !response['exit_code'].nil? ||
193
+ (!response['output'].nil? && !response['output'].to_s.empty?) ||
194
+ (!response['stderr'].nil? && !response['stderr'].to_s.empty?)
195
+ end
196
+ private_class_method :displayable_tool_response?
197
+
198
+ def require_non_empty_string(value, event_name, field)
199
+ return if value.is_a?(String) && !value.strip.empty?
200
+
201
+ raise HookInputError, "#{event_name} requires non-empty #{field}"
202
+ end
203
+ private_class_method :require_non_empty_string
204
+
205
+ def require_hash(value, event_name, field)
206
+ return if value.is_a?(Hash)
207
+
208
+ raise HookInputError, "#{event_name} requires #{field} to be an object"
209
+ end
210
+ private_class_method :require_hash
211
+
212
+ def payload_value(payload, key)
213
+ payload_value_with_presence(payload, key).first
214
+ end
215
+ private_class_method :payload_value
216
+
217
+ def payload_value_with_presence(payload, key)
218
+ return [payload[key], true] if payload.key?(key)
219
+
220
+ nested = payload['payload']
221
+ return [nested[key], true] if nested.is_a?(Hash) && nested.key?(key)
222
+
223
+ [nil, false]
224
+ end
225
+ private_class_method :payload_value_with_presence
226
+
227
+ def immutable_copy(value)
228
+ case value
229
+ when Hash
230
+ value.to_h { |key, item| [immutable_copy(key), immutable_copy(item)] }.freeze
231
+ when Array
232
+ value.map { |item| immutable_copy(item) }.freeze
233
+ when String
234
+ value.dup.freeze
235
+ else
236
+ value
237
+ end
238
+ end
239
+ private_class_method :immutable_copy
240
+ end
241
+ end