claude_hooks 1.2.1 → 1.4.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/README.md +21 -15
  4. data/docs/API/COMMON.md +4 -3
  5. data/docs/API/DIRECTORY_ADDED.md +34 -0
  6. data/docs/API/NOTIFICATION.md +5 -4
  7. data/docs/API/PERMISSION_DENIED.md +4 -0
  8. data/docs/API/PERMISSION_REQUEST.md +3 -3
  9. data/docs/API/POST_MODEL_SWITCH.md +53 -0
  10. data/docs/API/POST_TOOL_USE.md +7 -0
  11. data/docs/API/POST_TOOL_USE_FAILURE.md +1 -0
  12. data/docs/API/PRE_MODEL_SWITCH.md +67 -0
  13. data/docs/API/PRE_TOOL_USE.md +1 -0
  14. data/docs/API/SESSION_START.md +7 -1
  15. data/docs/API/SETUP.md +3 -3
  16. data/docs/API/STOP.md +6 -0
  17. data/docs/API/SUBAGENT_STOP.md +10 -0
  18. data/docs/API/TASK_CREATED.md +9 -7
  19. data/docs/API/USER_PROMPT_SUBMIT.md +14 -0
  20. data/docs/API/WORKTREE_REMOVE.md +9 -4
  21. data/docs/external/claude-hooks-reference.md +1188 -345
  22. data/docs/mitts/prd.md +8 -0
  23. data/docs/mitts/setup.md +5 -3
  24. data/lib/claude_hooks/base.rb +12 -0
  25. data/lib/claude_hooks/directory_added.rb +24 -0
  26. data/lib/claude_hooks/output/base.rb +6 -0
  27. data/lib/claude_hooks/output/directory_added.rb +21 -0
  28. data/lib/claude_hooks/output/notification.rb +10 -1
  29. data/lib/claude_hooks/output/post_model_switch.rb +50 -0
  30. data/lib/claude_hooks/output/post_tool_batch.rb +10 -0
  31. data/lib/claude_hooks/output/post_tool_use.rb +23 -5
  32. data/lib/claude_hooks/output/pre_model_switch.rb +121 -0
  33. data/lib/claude_hooks/output/pre_tool_use.rb +8 -0
  34. data/lib/claude_hooks/output/task_created.rb +34 -1
  35. data/lib/claude_hooks/output/user_prompt_expansion.rb +10 -0
  36. data/lib/claude_hooks/output/user_prompt_submit.rb +19 -6
  37. data/lib/claude_hooks/output/worktree_remove.rb +4 -2
  38. data/lib/claude_hooks/permission_denied.rb +7 -0
  39. data/lib/claude_hooks/permission_request.rb +10 -1
  40. data/lib/claude_hooks/post_model_switch.rb +70 -0
  41. data/lib/claude_hooks/post_tool_use.rb +14 -0
  42. data/lib/claude_hooks/post_tool_use_failure.rb +7 -0
  43. data/lib/claude_hooks/pre_model_switch.rb +90 -0
  44. data/lib/claude_hooks/pre_tool_use.rb +7 -0
  45. data/lib/claude_hooks/session_start.rb +18 -0
  46. data/lib/claude_hooks/setup.rb +3 -0
  47. data/lib/claude_hooks/task_created.rb +17 -0
  48. data/lib/claude_hooks/user_prompt_submit.rb +26 -5
  49. data/lib/claude_hooks/version.rb +1 -1
  50. data/lib/claude_hooks.rb +6 -0
  51. metadata +11 -2
data/docs/mitts/prd.md CHANGED
@@ -102,6 +102,14 @@ The `pr-review` action loads `AGENTS.md` (via its own `load_project_skills` call
102
102
  but does not use `agent_task.py`, so it doesn't have cross-run memory or the
103
103
  guardrail hook.
104
104
 
105
+ The workflow runs the upstream review script (`plugins/pr-review/scripts/agent_script.py`,
106
+ pinned to an `OpenHands/extensions` commit) through `.github/mitts/pr_review_entry.py`.
107
+ The upstream script can't pass extra request headers, and the OpenCode gateway rejects
108
+ requests without `x-opencode-session`. So the wrapper adds the same headers as
109
+ `agent_task.py`: `User-Agent: mitts-agent/1.0`, plus `x-opencode-session` set to
110
+ `OPENHANDS_CONVERSATION_KEY` (`pr-review-<number>`). The wrapper is checked out from the
111
+ default branch, never from the PR under review.
112
+
105
113
  ### Issue and PR bots — `agent_task.py`
106
114
 
107
115
  There is no turnkey OpenHands Action for "issue → PR" or "PR follow-up", so these
data/docs/mitts/setup.md CHANGED
@@ -267,9 +267,11 @@ reference:
267
267
  - `OPENHANDS_CONVERSATION_KEY` — per-thread key for the artifact (e.g. `pr-42`,
268
268
  `issue-7`).
269
269
 
270
- The `mitts-pr-review.yml` review action also accepts an `extensions-version:`
271
- input (currently `main`) — pin it to a tag/SHA alongside `EXTENSIONS_REF` when
272
- you want reproducible reviews.
270
+ `mitts-pr-review.yml` sets its own `OPENHANDS_CONVERSATION_KEY` (`pr-review-<number>`),
271
+ which `.github/mitts/pr_review_entry.py` sends as the `x-opencode-session` header. It
272
+ pins the `OpenHands/extensions` checkout to a commit SHA. When you bump that SHA, check
273
+ that the upstream `agent_script.py` still builds its LLM with `openhands.sdk.LLM(...)`,
274
+ because the wrapper relies on it.
273
275
 
274
276
  ---
275
277
 
@@ -92,6 +92,14 @@ module ClaudeHooks
92
92
  @input_data.dig('effort', 'level')
93
93
  end
94
94
 
95
+ # Path to the session's scratchpad directory. Absent when the session has no
96
+ # scratchpad or the temp directory is unavailable. Requires Claude Code v2.1.257+.
97
+ # Intentionally not in COMMON_INPUT_FIELDS: it is version-gated and often absent,
98
+ # so it must not trigger the missing-field warning in validate_input!.
99
+ def scratchpad_dir
100
+ @input_data['scratchpad_dir'] || @input_data['scratchpadDir']
101
+ end
102
+
95
103
  def read_transcript
96
104
  unless transcript_path && File.exist?(transcript_path)
97
105
  log "Transcript file not found at #{transcript_path}", level: :warn
@@ -120,10 +128,14 @@ module ClaudeHooks
120
128
  end
121
129
 
122
130
  # Hide stdout from transcript mode (default: false)
131
+ #
132
+ # DEPRECATED: Claude Code accepts the `suppressOutput` field but no longer acts
133
+ # on it (it has no effect). Kept for backward compatibility; safe to call, but a no-op.
123
134
  def suppress_output!
124
135
  @output_data['suppressOutput'] = true
125
136
  end
126
137
 
138
+ # DEPRECATED: see suppress_output! — `suppressOutput` is now a no-op in Claude Code.
127
139
  def show_output!
128
140
  @output_data['suppressOutput'] = false
129
141
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ class DirectoryAdded < Base
7
+ def self.hook_type
8
+ 'DirectoryAdded'
9
+ end
10
+
11
+ def self.input_fields
12
+ %w[directory source]
13
+ end
14
+
15
+ def directory
16
+ @input_data['directory']
17
+ end
18
+
19
+ # Values: slash_command | register_repo_root
20
+ def source
21
+ @input_data['source']
22
+ end
23
+ end
24
+ end
@@ -180,6 +180,12 @@ module ClaudeHooks
180
180
  ElicitationResult.new(data)
181
181
  when 'WorktreeCreate'
182
182
  WorktreeCreate.new(data)
183
+ when 'DirectoryAdded'
184
+ DirectoryAdded.new(data)
185
+ when 'PreModelSwitch'
186
+ PreModelSwitch.new(data)
187
+ when 'PostModelSwitch'
188
+ PostModelSwitch.new(data)
183
189
  else
184
190
  raise ArgumentError, "Unknown hook type: #{hook_type}"
185
191
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ # DirectoryAdded is non-blocking — the directory is already added.
8
+ # Only `systemMessage` is consumed (as Claude context for `slash_command`,
9
+ # debug log for `register_repo_root`).
10
+ class DirectoryAdded < Base
11
+ def exit_code
12
+ 0
13
+ end
14
+
15
+ def self.merge(*outputs)
16
+ merged = super(*outputs)
17
+ new(merged.data)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -6,9 +6,18 @@ module ClaudeHooks
6
6
  module Output
7
7
  class Notification < Base
8
8
  # === EXIT CODE LOGIC ===
9
+ #
10
+ # Claude Code ignores Notification's exit code and stderr, and discards
11
+ # `systemMessage`/`continue`, while still emitting `terminalSequence`.
12
+ # So always exit 0 and write JSON to stdout, keeping the desktop-notification
13
+ # (`terminal_sequence!`) pattern working even when a handler set continue:false.
9
14
 
10
15
  def exit_code
11
- default_exit_code
16
+ 0
17
+ end
18
+
19
+ def output_stream
20
+ :stdout
12
21
  end
13
22
 
14
23
  # === MERGE HELPER ===
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ # PostModelSwitch is context-only — it can't block, because the model has
8
+ # already changed. Its stdout is shown to Claude as additional context.
9
+ class PostModelSwitch < Base
10
+ # === CONTEXT ACCESSORS ===
11
+
12
+ def additional_context
13
+ hook_specific_output['additionalContext'] || ''
14
+ end
15
+
16
+ # === EXIT CODE LOGIC ===
17
+
18
+ def exit_code
19
+ default_exit_code
20
+ end
21
+
22
+ # === MERGE HELPER ===
23
+
24
+ def self.merge(*outputs)
25
+ compacted_outputs = outputs.compact
26
+ return compacted_outputs.first if compacted_outputs.length == 1
27
+ return super(*outputs) if compacted_outputs.empty?
28
+
29
+ merged = super(*outputs)
30
+ merged_data = merged.data
31
+ contexts = []
32
+
33
+ compacted_outputs.each do |output|
34
+ output_data = output.respond_to?(:data) ? output.data : output
35
+ context = output_data.dig('hookSpecificOutput', 'additionalContext')
36
+ contexts << context if context && !context.empty?
37
+ end
38
+
39
+ unless contexts.empty?
40
+ merged_data['hookSpecificOutput'] = {
41
+ 'hookEventName' => 'PostModelSwitch',
42
+ 'additionalContext' => contexts.join("\n\n")
43
+ }
44
+ end
45
+
46
+ new(merged_data)
47
+ end
48
+ end
49
+ end
50
+ end
@@ -33,6 +33,7 @@ module ClaudeHooks
33
33
  merged = super(*outputs)
34
34
  merged_data = merged.data
35
35
  reasons = []
36
+ contexts = []
36
37
 
37
38
  compacted_outputs.each do |output|
38
39
  output_data = output.respond_to?(:data) ? output.data : output
@@ -41,9 +42,18 @@ module ClaudeHooks
41
42
  r = output_data['reason']
42
43
  reasons << r if r && !r.empty?
43
44
  end
45
+
46
+ context = output_data.dig('hookSpecificOutput', 'additionalContext')
47
+ contexts << context if context && !context.empty?
44
48
  end
45
49
 
46
50
  merged_data['reason'] = reasons.join('; ') if reasons.any?
51
+ unless contexts.empty?
52
+ merged_data['hookSpecificOutput'] = {
53
+ 'hookEventName' => 'PostToolBatch',
54
+ 'additionalContext' => contexts.join("\n\n")
55
+ }
56
+ end
47
57
  new(merged_data)
48
58
  end
49
59
  end
@@ -27,6 +27,10 @@ module ClaudeHooks
27
27
  hook_specific_output['updatedMCPToolOutput']
28
28
  end
29
29
 
30
+ def classifier_context
31
+ hook_specific_output['classifierContext'] || ''
32
+ end
33
+
30
34
  # === SEMANTIC HELPERS ===
31
35
 
32
36
  def blocked?
@@ -64,6 +68,11 @@ module ClaudeHooks
64
68
  merged = super(*outputs)
65
69
  merged_data = merged.data
66
70
  contexts = []
71
+ classifier_contexts = []
72
+ # Tool-output rewrites: last non-nil wins (most recent transformation), as with
73
+ # PreToolUse's updatedInput. Dropping them would let e.g. a redacted output
74
+ # revert to the original when composed with another handler.
75
+ rewrites = {}
67
76
 
68
77
  compacted_outputs.each do |output|
69
78
  output_data = output.respond_to?(:data) ? output.data : output
@@ -72,13 +81,22 @@ module ClaudeHooks
72
81
 
73
82
  context = output_data.dig('hookSpecificOutput', 'additionalContext')
74
83
  contexts << context if context && !context.empty?
84
+
85
+ classifier_context = output_data.dig('hookSpecificOutput', 'classifierContext')
86
+ classifier_contexts << classifier_context if classifier_context && !classifier_context.empty?
87
+
88
+ %w[updatedToolOutput updatedMCPToolOutput].each do |key|
89
+ value = output_data.dig('hookSpecificOutput', key)
90
+ rewrites[key] = value unless value.nil?
91
+ end
75
92
  end
76
93
 
77
- unless contexts.empty?
78
- merged_data['hookSpecificOutput'] = {
79
- 'hookEventName' => 'PostToolUse',
80
- 'additionalContext' => contexts.join("\n\n")
81
- }
94
+ if !contexts.empty? || !classifier_contexts.empty? || !rewrites.empty?
95
+ hso = { 'hookEventName' => 'PostToolUse' }
96
+ hso['additionalContext'] = contexts.join("\n\n") unless contexts.empty?
97
+ hso['classifierContext'] = classifier_contexts.join("\n\n") unless classifier_contexts.empty?
98
+ hso.merge!(rewrites)
99
+ merged_data['hookSpecificOutput'] = hso
82
100
  end
83
101
 
84
102
  new(merged_data)
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ module Output
7
+ class PreModelSwitch < Base
8
+ # === PERMISSION DECISION ACCESSORS ===
9
+
10
+ def permission_decision
11
+ hook_specific_output['permissionDecision']
12
+ end
13
+
14
+ def permission_reason
15
+ hook_specific_output['permissionDecisionReason'] || ''
16
+ end
17
+
18
+ # === TOP-LEVEL DECISION ACCESSORS ===
19
+
20
+ def decision
21
+ @data['decision']
22
+ end
23
+
24
+ def reason
25
+ @data['reason'] || ''
26
+ end
27
+
28
+ # === SEMANTIC HELPERS ===
29
+
30
+ def allowed?
31
+ permission_decision == 'allow'
32
+ end
33
+
34
+ def denied?
35
+ permission_decision == 'deny'
36
+ end
37
+
38
+ # Blocked when denied via permissionDecision OR via top-level decision:block.
39
+ def blocked?
40
+ denied? || decision == 'block'
41
+ end
42
+
43
+ def should_ask?
44
+ permission_decision == 'ask'
45
+ end
46
+ alias should_ask_permission? should_ask?
47
+
48
+ # === EXIT CODE LOGIC ===
49
+ #
50
+ # PreModelSwitch uses the advanced JSON API with exit code 0.
51
+ # As on PreToolUse, structured JSON with permissionDecision always goes to
52
+ # stdout with exit 0; the decision fields control whether the switch proceeds.
53
+ def exit_code
54
+ 0
55
+ end
56
+
57
+ def output_stream
58
+ :stdout
59
+ end
60
+
61
+ # === MERGE HELPER ===
62
+
63
+ def self.merge(*outputs)
64
+ compacted_outputs = outputs.compact
65
+ return compacted_outputs.first if compacted_outputs.length == 1
66
+ return super(*outputs) if compacted_outputs.empty?
67
+
68
+ merged = super(*outputs)
69
+ merged_data = merged.data
70
+
71
+ # PreModelSwitch precedence: deny > ask > allow (most restrictive wins).
72
+ # Stays nil when no hook decided, so the merge doesn't invent an 'allow'.
73
+ permission_decision = nil
74
+ permission_reasons = []
75
+ block_reasons = []
76
+ any_block = false
77
+
78
+ compacted_outputs.each do |output|
79
+ output_data = output.respond_to?(:data) ? output.data : output
80
+
81
+ if output_data['decision'] == 'block'
82
+ any_block = true
83
+ block_reasons << output_data['reason'] if output_data['reason'] && !output_data['reason'].empty?
84
+ end
85
+
86
+ current_decision = output_data.dig('hookSpecificOutput', 'permissionDecision')
87
+ case current_decision
88
+ when 'deny'
89
+ permission_decision = 'deny'
90
+ when 'ask'
91
+ permission_decision = 'ask' unless permission_decision == 'deny'
92
+ when 'allow'
93
+ permission_decision ||= 'allow'
94
+ end
95
+
96
+ reason = output_data.dig('hookSpecificOutput', 'permissionDecisionReason')
97
+ permission_reasons << reason if reason && !reason.empty?
98
+ end
99
+
100
+ # A top-level block cancels the switch, so the merged permissionDecision
101
+ # must agree with it instead of saying 'allow'.
102
+ permission_decision = 'deny' if any_block && permission_decision
103
+
104
+ if permission_decision
105
+ merged_data['hookSpecificOutput'] = {
106
+ 'hookEventName' => 'PreModelSwitch',
107
+ 'permissionDecision' => permission_decision,
108
+ 'permissionDecisionReason' => permission_reasons.join('; ')
109
+ }
110
+ end
111
+
112
+ if any_block
113
+ merged_data['decision'] = 'block'
114
+ merged_data['reason'] = block_reasons.join('; ') unless block_reasons.empty?
115
+ end
116
+
117
+ new(merged_data)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -111,6 +111,14 @@ module ClaudeHooks
111
111
 
112
112
  merged_data['hookSpecificOutput']['updatedInput'] = updated_inputs.last if updated_inputs.any?
113
113
 
114
+ # Join every handler's additionalContext, as the other context-carrying merges do.
115
+ contexts = compacted_outputs.filter_map do |output|
116
+ output_data = output.respond_to?(:data) ? output.data : output
117
+ context = output_data.dig('hookSpecificOutput', 'additionalContext')
118
+ context unless context.nil? || context.empty?
119
+ end
120
+ merged_data['hookSpecificOutput']['additionalContext'] = contexts.join("\n\n") if contexts.any?
121
+
114
122
  new(merged_data)
115
123
  end
116
124
  end
@@ -5,13 +5,46 @@ require_relative 'base'
5
5
  module ClaudeHooks
6
6
  module Output
7
7
  class TaskCreated < Base
8
+ # === DECISION ACCESSORS ===
9
+ #
10
+ # Claude Code ignores `continue: false` for TaskCreated; block via exit 2 or
11
+ # a top-level decision:block.
12
+
13
+ def decision
14
+ @data['decision']
15
+ end
16
+
17
+ def reason
18
+ @data['reason'] || ''
19
+ end
20
+
21
+ def blocked?
22
+ decision == 'block'
23
+ end
24
+
8
25
  def exit_code
9
26
  default_exit_code
10
27
  end
11
28
 
12
29
  def self.merge(*outputs)
30
+ compacted_outputs = outputs.compact
31
+ return compacted_outputs.first if compacted_outputs.length == 1
32
+ return super(*outputs) if compacted_outputs.empty?
33
+
13
34
  merged = super(*outputs)
14
- new(merged.data)
35
+ merged_data = merged.data
36
+ reasons = []
37
+
38
+ compacted_outputs.each do |output|
39
+ output_data = output.respond_to?(:data) ? output.data : output
40
+ merged_data['decision'] = 'block' if output_data['decision'] == 'block'
41
+ reason = output_data['reason']
42
+ reasons << reason if reason && !reason.empty?
43
+ end
44
+
45
+ merged_data['reason'] = reasons.join('; ') unless reasons.empty?
46
+
47
+ new(merged_data)
15
48
  end
16
49
  end
17
50
  end
@@ -33,6 +33,7 @@ module ClaudeHooks
33
33
  merged = super(*outputs)
34
34
  merged_data = merged.data
35
35
  reasons = []
36
+ contexts = []
36
37
 
37
38
  compacted_outputs.each do |output|
38
39
  output_data = output.respond_to?(:data) ? output.data : output
@@ -41,9 +42,18 @@ module ClaudeHooks
41
42
  r = output_data['reason']
42
43
  reasons << r if r && !r.empty?
43
44
  end
45
+
46
+ context = output_data.dig('hookSpecificOutput', 'additionalContext')
47
+ contexts << context if context && !context.empty?
44
48
  end
45
49
 
46
50
  merged_data['reason'] = reasons.join('; ') if reasons.any?
51
+ unless contexts.empty?
52
+ merged_data['hookSpecificOutput'] = {
53
+ 'hookEventName' => 'UserPromptExpansion',
54
+ 'additionalContext' => contexts.join("\n\n")
55
+ }
56
+ end
47
57
  new(merged_data)
48
58
  end
49
59
  end
@@ -19,6 +19,14 @@ module ClaudeHooks
19
19
  hook_specific_output['additionalContext'] || ''
20
20
  end
21
21
 
22
+ def session_title
23
+ hook_specific_output['sessionTitle']
24
+ end
25
+
26
+ def suppress_original_prompt?
27
+ hook_specific_output['suppressOriginalPrompt'] == true
28
+ end
29
+
22
30
  # === SEMANTIC HELPERS ===
23
31
 
24
32
  def blocked?
@@ -53,22 +61,27 @@ module ClaudeHooks
53
61
  merged_data = merged.data
54
62
 
55
63
  contexts = []
64
+ specific = {}
56
65
 
57
66
  compacted_outputs.each do |output|
58
67
  output_data = output.respond_to?(:data) ? output.data : output
68
+ hso = output_data['hookSpecificOutput'] || {}
59
69
 
60
70
  merged_data['decision'] = 'block' if output_data['decision'] == 'block'
61
71
  merged_data['reason'] = [merged_data['reason'], output_data['reason']].compact.reject(&:empty?).join('; ')
62
72
 
63
- context = output_data.dig('hookSpecificOutput', 'additionalContext')
73
+ context = hso['additionalContext']
64
74
  contexts << context if context && !context.empty?
75
+
76
+ # Last non-nil title wins; suppressing the prompt wins if any hook asks for it.
77
+ specific['sessionTitle'] = hso['sessionTitle'] unless hso['sessionTitle'].nil?
78
+ specific['suppressOriginalPrompt'] = true if hso['suppressOriginalPrompt'] == true
65
79
  end
66
80
 
67
- unless contexts.empty?
68
- merged_data['hookSpecificOutput'] = {
69
- 'hookEventName' => 'UserPromptSubmit',
70
- 'additionalContext' => contexts.join("\n\n")
71
- }
81
+ specific['additionalContext'] = contexts.join("\n\n") unless contexts.empty?
82
+
83
+ unless specific.empty?
84
+ merged_data['hookSpecificOutput'] = { 'hookEventName' => 'UserPromptSubmit' }.merge(specific)
72
85
  end
73
86
 
74
87
  new(merged_data)
@@ -4,10 +4,12 @@ require_relative 'base'
4
4
 
5
5
  module ClaudeHooks
6
6
  module Output
7
- # WorktreeRemove is non-blocking — only suppressOutput is honored.
7
+ # WorktreeRemove can block: a non-zero exit code now fails the worktree
8
+ # removal when the directory still exists (Claude Code no longer ignores it).
9
+ # Use prevent_continue!(reason) to block; exit code follows continue (0/2).
8
10
  class WorktreeRemove < Base
9
11
  def exit_code
10
- 0
12
+ default_exit_code
11
13
  end
12
14
 
13
15
  def self.merge(*outputs)
@@ -24,6 +24,13 @@ module ClaudeHooks
24
24
  @input_data['tool_use_id'] || @input_data['toolUseId']
25
25
  end
26
26
 
27
+ # For MCP tools: { 'name' => ..., 'source' => ... } describing where the server's
28
+ # definition came from (e.g. 'plugin', 'sdk', 'user', 'project'). Base trust
29
+ # decisions on 'source', not on the name. nil for non-MCP tools. Requires Claude Code v2.1.274+.
30
+ def mcp_server
31
+ @input_data['mcp_server'] || @input_data['mcpServer']
32
+ end
33
+
27
34
  def reason
28
35
  @input_data['reason']
29
36
  end
@@ -9,7 +9,7 @@ module ClaudeHooks
9
9
  end
10
10
 
11
11
  def self.input_fields
12
- %w[tool_name tool_input tool_use_id]
12
+ %w[tool_name tool_input]
13
13
  end
14
14
 
15
15
  # === INPUT DATA ACCESS ===
@@ -22,10 +22,19 @@ module ClaudeHooks
22
22
  @input_data['tool_input']
23
23
  end
24
24
 
25
+ # DEPRECATED: Claude Code doesn't send tool_use_id to PermissionRequest hooks, so this
26
+ # is always nil. Kept for backward compatibility; not part of input_fields.
25
27
  def tool_use_id
26
28
  @input_data['tool_use_id'] || @input_data['toolUseId']
27
29
  end
28
30
 
31
+ # For MCP tools: { 'name' => ..., 'source' => ... } describing where the server's
32
+ # definition came from (e.g. 'plugin', 'sdk', 'user', 'project'). Base trust
33
+ # decisions on 'source', not on the name. nil for non-MCP tools. Requires Claude Code v2.1.274+.
34
+ def mcp_server
35
+ @input_data['mcp_server'] || @input_data['mcpServer']
36
+ end
37
+
29
38
  def permission_suggestions
30
39
  @input_data['permissionSuggestions'] || @input_data['permission_suggestions'] || []
31
40
  end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module ClaudeHooks
6
+ class PostModelSwitch < Base
7
+ def self.hook_type
8
+ 'PostModelSwitch'
9
+ end
10
+
11
+ def self.input_fields
12
+ %w[from_model to_model requested_model source]
13
+ end
14
+
15
+ # === INPUT DATA ACCESS ===
16
+ #
17
+ # PostModelSwitch receives the same fields as PreModelSwitch, with two extra
18
+ # `source` values ("auto", "resume"). `requested_model` is null when source is
19
+ # "auto", and the restored setting when source is "resume".
20
+
21
+ def from_model
22
+ @input_data['from_model'] || @input_data['fromModel']
23
+ end
24
+
25
+ def to_model
26
+ @input_data['to_model'] || @input_data['toModel']
27
+ end
28
+
29
+ def requested_model
30
+ @input_data['requested_model'] || @input_data['requestedModel']
31
+ end
32
+
33
+ def source
34
+ @input_data['source']
35
+ end
36
+
37
+ def context_tokens
38
+ @input_data['context_tokens'] || @input_data['contextTokens']
39
+ end
40
+
41
+ def prompt_cache_warm
42
+ @input_data.key?('prompt_cache_warm') ? @input_data['prompt_cache_warm'] : @input_data['promptCacheWarm']
43
+ end
44
+
45
+ def cache_ttl
46
+ @input_data['cache_ttl'] || @input_data['cacheTtl']
47
+ end
48
+
49
+ def estimated_cache_write_usd
50
+ @input_data['estimated_cache_write_usd'] || @input_data['estimatedCacheWriteUsd']
51
+ end
52
+
53
+ def pricing
54
+ @input_data['pricing']
55
+ end
56
+
57
+ # === OUTPUT DATA HELPERS ===
58
+ #
59
+ # PostModelSwitch can't block (the model already changed); it only adds context.
60
+ # Its stdout is also shown to Claude as context.
61
+
62
+ def add_additional_context!(context)
63
+ @output_data['hookSpecificOutput'] = {
64
+ 'hookEventName' => hook_event_name,
65
+ 'additionalContext' => context
66
+ }
67
+ end
68
+ alias_method :add_context!, :add_additional_context!
69
+ end
70
+ end