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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE +21 -0
- data/README.md +782 -0
- data/bin/codex-notify +6 -0
- data/bin/codex-notify-hook +6 -0
- data/lib/codex_notify/cli.rb +112 -0
- data/lib/codex_notify/config.rb +113 -0
- data/lib/codex_notify/config_diagnostics.rb +61 -0
- data/lib/codex_notify/config_migrator.rb +148 -0
- data/lib/codex_notify/config_support.rb +157 -0
- data/lib/codex_notify/destination_name.rb +18 -0
- data/lib/codex_notify/destination_resolver.rb +68 -0
- data/lib/codex_notify/durable_slack_publisher.rb +70 -0
- data/lib/codex_notify/env_source_loader.rb +91 -0
- data/lib/codex_notify/hook_cli.rb +90 -0
- data/lib/codex_notify/hook_config.rb +110 -0
- data/lib/codex_notify/hook_event.rb +16 -0
- data/lib/codex_notify/hook_formatter.rb +61 -0
- data/lib/codex_notify/hook_input_validator.rb +241 -0
- data/lib/codex_notify/hook_runner.rb +181 -0
- data/lib/codex_notify/hook_store.rb +133 -0
- data/lib/codex_notify/hook_thread_publisher.rb +107 -0
- data/lib/codex_notify/log_event_parser.rb +230 -0
- data/lib/codex_notify/message_formatter.rb +115 -0
- data/lib/codex_notify/outbox_commands.rb +43 -0
- data/lib/codex_notify/secret_protection.rb +37 -0
- data/lib/codex_notify/session_log.rb +63 -0
- data/lib/codex_notify/slack_client.rb +117 -0
- data/lib/codex_notify/slack_delivery_worker.rb +269 -0
- data/lib/codex_notify/slack_outbox.rb +206 -0
- data/lib/codex_notify/stream_processor.rb +141 -0
- data/lib/codex_notify/trusted_config_loader.rb +159 -0
- data/lib/codex_notify/version.rb +5 -0
- data/lib/codex_notify.rb +36 -0
- 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,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
|