Skip to content

Commit 4a3775b

Browse files
authored
fix(hook): emit SessionStart hint on stdout so it's visible (#4)
1 parent 8f6ba76 commit 4a3775b

6 files changed

Lines changed: 58 additions & 45 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"owner": {
66
"name": "nnemirovsky"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "passthru",
3-
"version": "0.2.0",
3+
"version": "0.2.1",
44
"description": "Regex-based permission rules for Claude Code via hooks",
55
"license": "MIT"
66
}

hooks/handlers/session-start.sh

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,20 @@
55
# One-time, best-effort hint nudging brand-new passthru users to run
66
# `/passthru:bootstrap` if they already have native permission rules in
77
# their `~/.claude/settings.json` that could be imported. Prints a
8-
# stderr message at the start of the Claude Code session (SessionStart
9-
# stderr is visible to the user) and touches a marker so the hint does
10-
# not fire again.
8+
# single-line plain-text message on stdout, which Claude Code surfaces
9+
# in the session header as "SessionStart:startup says: <text>". Then
10+
# touches a marker so the hint does not fire again.
1111
#
1212
# Contract:
1313
# stdin - JSON envelope from Claude Code (SessionStart hook). We do
1414
# not need any of its fields; we still drain stdin defensively
1515
# so the writer does not hit SIGPIPE.
16-
# stdout - `{}` (no additional context). SessionStart hooks can return
17-
# an `additionalContext` field, but we keep the session-log
18-
# hint off-session via stderr to avoid nagging inside the
19-
# conversation.
20-
# exit - always 0. Any error fails open.
16+
# stdout - a single line of plain text when the hint should fire,
17+
# otherwise EMPTY. Per Claude Code docs, SessionStart stdout
18+
# is appended to the session view as
19+
# "SessionStart:startup says: <text>". Anything else (e.g.
20+
# `{}`) would appear verbatim to the user.
21+
# exit - always 0. Any error fails open (empty stdout, exit 0).
2122
#
2223
# Gating:
2324
# - If the marker file `${PASSTHRU_USER_HOME}/.claude/passthru.bootstrap-hint-shown`
@@ -27,7 +28,7 @@
2728
# marker and exit silently.
2829
# - If `~/.claude/settings.json` has no `.permissions.allow` entries,
2930
# there is nothing to import - touch the marker and exit silently.
30-
# - Otherwise: count the allow entries, emit the hint to stderr, touch
31+
# - Otherwise: count the allow entries, emit the hint on stdout, touch
3132
# the marker.
3233
#
3334
# Paths honor PASSTHRU_USER_HOME and PASSTHRU_PROJECT_DIR so bats tests
@@ -46,17 +47,16 @@ else
4647
_PASSTHRU_COMMON="${_PASSTHRU_HANDLER_DIR}/../common.sh"
4748
if [ ! -f "$_PASSTHRU_COMMON" ]; then
4849
# Never block session start, even on a broken install.
49-
printf '{}\n'
5050
exit 0
5151
fi
5252
# shellcheck disable=SC1090
5353
source "$_PASSTHRU_COMMON"
5454
fi
5555

5656
# ---------------------------------------------------------------------------
57-
# Fail-open wrapper: any unexpected error prints {} + exit 0.
57+
# Fail-open wrapper: any unexpected error prints nothing + exit 0.
5858
# ---------------------------------------------------------------------------
59-
trap 'printf "[passthru] unexpected error in session-start.sh\n" >&2; printf "{}\n"; exit 0' ERR
59+
trap 'printf "[passthru] unexpected error in session-start.sh\n" >&2; exit 0' ERR
6060

6161
# ---------------------------------------------------------------------------
6262
# Drain stdin defensively so the parent does not see SIGPIPE on its
@@ -74,7 +74,6 @@ MARKER="${USER_HOME}/.claude/passthru.bootstrap-hint-shown"
7474
# 1. Marker already set -> silent no-op.
7575
# ---------------------------------------------------------------------------
7676
if [ -e "$MARKER" ]; then
77-
printf '{}\n'
7877
exit 0
7978
fi
8079

@@ -86,7 +85,6 @@ fi
8685
MARKER_DIR="$(dirname "$MARKER")"
8786
if [ ! -d "$MARKER_DIR" ]; then
8887
mkdir -p "$MARKER_DIR" 2>/dev/null || {
89-
printf '{}\n'
9088
exit 0
9189
}
9290
fi
@@ -107,7 +105,6 @@ PROJECT_IMPORTED="$(passthru_project_imported_path)"
107105
if [ -f "$USER_AUTHORED" ] || [ -f "$USER_IMPORTED" ] \
108106
|| [ -f "$PROJECT_AUTHORED" ] || [ -f "$PROJECT_IMPORTED" ]; then
109107
touch_marker
110-
printf '{}\n'
111108
exit 0
112109
fi
113110

@@ -131,17 +128,14 @@ fi
131128

132129
if [ "$COUNT" -eq 0 ]; then
133130
touch_marker
134-
printf '{}\n'
135131
exit 0
136132
fi
137133

138134
# ---------------------------------------------------------------------------
139-
# 4. Emit the one-time hint and persist the marker.
135+
# 4. Emit the one-time hint on stdout and persist the marker.
136+
# Single line keeps the session header readable.
140137
# ---------------------------------------------------------------------------
141-
printf '[passthru] Detected %s importable rule(s) in your existing settings.json.\n' "$COUNT" >&2
142-
printf '[passthru] Run /passthru:bootstrap to import them into passthru'\''s regex format.\n' >&2
143-
printf '[passthru] This hint only shows once.\n' >&2
138+
printf 'passthru: detected %s importable permission rule(s) in ~/.claude/settings.json. Run /passthru:bootstrap to convert them. This tip only shows once.\n' "$COUNT"
144139

145140
touch_marker
146-
printf '{}\n'
147141
exit 0

hooks/hooks.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
],
2828
"SessionStart": [
2929
{
30+
"matcher": "startup",
3031
"hooks": [
3132
{
3233
"type": "command",

tests/plugin_loads.bats

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,14 @@ setup() {
137137
[ "$output" = "1" ]
138138
}
139139

140+
@test "hooks/hooks.json SessionStart matcher is startup" {
141+
# Restricts the one-time hint to brand-new sessions. Resume/clear/compact
142+
# must not re-fire it.
143+
run jq -r '.hooks.SessionStart[0].matcher' "$REPO_ROOT/hooks/hooks.json"
144+
[ "$status" -eq 0 ]
145+
[ "$output" = "startup" ]
146+
}
147+
140148
@test "hooks/hooks.json SessionStart command uses CLAUDE_PLUGIN_ROOT" {
141149
run jq -r '.hooks.SessionStart[0].hooks[0].command' "$REPO_ROOT/hooks/hooks.json"
142150
[ "$status" -eq 0 ]

tests/session_start_hook.bats

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,17 @@
1414
# * marker absent + no passthru files + settings.json with empty allow
1515
# -> marker created, no hint
1616
# * marker absent + no passthru files + settings.json with N allow entries
17-
# -> hint on stderr, marker created
17+
# -> hint on stdout, marker created
1818
# * malformed stdin -> fail open (exit 0, no crash)
1919
#
2020
# Hermetic via PASSTHRU_USER_HOME / PASSTHRU_PROJECT_DIR.
21+
#
22+
# Contract notes:
23+
# Per Claude Code docs, SessionStart stdout is plain text and surfaces in the
24+
# session view as "SessionStart:startup says: <text>". The handler therefore
25+
# emits the hint on stdout (not stderr) when it fires, and emits nothing on
26+
# stdout in all other cases - including the marker-present short-circuit -
27+
# so the session header stays clean.
2128

2229
setup() {
2330
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)"
@@ -70,8 +77,8 @@ run_handler() {
7077

7178
run_handler '{}'
7279
[ "$status" -eq 0 ]
73-
# stdout is just "{}\n".
74-
[ "$STDOUT" = "{}" ]
80+
# stdout must be empty - no `{}` noise in the session header.
81+
[ -z "$STDOUT" ]
7582
# No stderr hint.
7683
[ -z "$STDERR" ]
7784
}
@@ -86,7 +93,7 @@ run_handler() {
8693

8794
run_handler '{}'
8895
[ "$status" -eq 0 ]
89-
[ "$STDOUT" = "{}" ]
96+
[ -z "$STDOUT" ]
9097
[ -z "$STDERR" ]
9198
[ -f "$(marker_path)" ]
9299
}
@@ -97,7 +104,7 @@ run_handler() {
97104

98105
run_handler '{}'
99106
[ "$status" -eq 0 ]
100-
[ "$STDOUT" = "{}" ]
107+
[ -z "$STDOUT" ]
101108
[ -z "$STDERR" ]
102109
[ -f "$(marker_path)" ]
103110
}
@@ -108,7 +115,7 @@ run_handler() {
108115

109116
run_handler '{}'
110117
[ "$status" -eq 0 ]
111-
[ "$STDOUT" = "{}" ]
118+
[ -z "$STDOUT" ]
112119
[ -z "$STDERR" ]
113120
[ -f "$(marker_path)" ]
114121
}
@@ -119,7 +126,7 @@ run_handler() {
119126

120127
run_handler '{}'
121128
[ "$status" -eq 0 ]
122-
[ "$STDOUT" = "{}" ]
129+
[ -z "$STDOUT" ]
123130
[ -z "$STDERR" ]
124131
[ -f "$(marker_path)" ]
125132
}
@@ -131,7 +138,7 @@ run_handler() {
131138
@test "session-start: no passthru files and no settings.json -> marker created, no hint" {
132139
run_handler '{}'
133140
[ "$status" -eq 0 ]
134-
[ "$STDOUT" = "{}" ]
141+
[ -z "$STDOUT" ]
135142
[ -z "$STDERR" ]
136143
[ -f "$(marker_path)" ]
137144
}
@@ -141,7 +148,7 @@ run_handler() {
141148

142149
run_handler '{}'
143150
[ "$status" -eq 0 ]
144-
[ "$STDOUT" = "{}" ]
151+
[ -z "$STDOUT" ]
145152
[ -z "$STDERR" ]
146153
[ -f "$(marker_path)" ]
147154
}
@@ -151,7 +158,7 @@ run_handler() {
151158

152159
run_handler '{}'
153160
[ "$status" -eq 0 ]
154-
[ "$STDOUT" = "{}" ]
161+
[ -z "$STDOUT" ]
155162
[ -z "$STDERR" ]
156163
[ -f "$(marker_path)" ]
157164
}
@@ -160,18 +167,20 @@ run_handler() {
160167
# Actual hint path.
161168
# ---------------------------------------------------------------------------
162169

163-
@test "session-start: settings.json with N allow entries -> hint on stderr mentions N and /passthru:bootstrap" {
170+
@test "session-start: settings.json with N allow entries -> hint on stdout mentions N and /passthru:bootstrap" {
164171
printf '%s\n' '{"permissions":{"allow":["Bash(ls:*)","Bash(echo hello)","mcp__context7__query-docs"]}}' \
165172
> "$USER_ROOT/.claude/settings.json"
166173

167174
run_handler '{}'
168175
[ "$status" -eq 0 ]
169-
[ "$STDOUT" = "{}" ]
170176

171-
# Hint mentions the count (3) and the slash command name.
172-
[[ "$STDERR" == *"Detected 3 importable rule(s)"* ]]
173-
[[ "$STDERR" == *"/passthru:bootstrap"* ]]
174-
[[ "$STDERR" == *"only shows once"* ]]
177+
# Hint lands on stdout so Claude Code can surface it in the session header.
178+
[[ "$STDOUT" == *"3 importable"* ]]
179+
[[ "$STDOUT" == *"/passthru:bootstrap"* ]]
180+
[[ "$STDOUT" == *"only shows once"* ]]
181+
182+
# Nothing on stderr in the happy path.
183+
[ -z "$STDERR" ]
175184

176185
# Marker is created so we do not re-hint.
177186
[ -f "$(marker_path)" ]
@@ -183,12 +192,13 @@ run_handler() {
183192

184193
run_handler '{}'
185194
[ "$status" -eq 0 ]
186-
[ -n "$STDERR" ]
195+
[ -n "$STDOUT" ]
187196
[ -f "$(marker_path)" ]
188197

189-
# Second invocation must be silent.
198+
# Second invocation must be silent on both streams.
190199
run_handler '{}'
191200
[ "$status" -eq 0 ]
201+
[ -z "$STDOUT" ]
192202
[ -z "$STDERR" ]
193203
}
194204

@@ -203,8 +213,8 @@ run_handler() {
203213

204214
run_handler 'not-json{{{'
205215
[ "$status" -eq 0 ]
206-
# stdout is still valid (either {} or the hint, but process did not crash).
207-
[ "$STDOUT" = "{}" ]
216+
# The hint should still fire (stdin is drained, never parsed).
217+
[[ "$STDOUT" == *"/passthru:bootstrap"* ]]
208218
# Marker must still be touched since nothing else went wrong.
209219
[ -f "$(marker_path)" ]
210220
}
@@ -215,7 +225,7 @@ run_handler() {
215225

216226
run_handler ''
217227
[ "$status" -eq 0 ]
218-
[ "$STDOUT" = "{}" ]
228+
[[ "$STDOUT" == *"/passthru:bootstrap"* ]]
219229
[ -f "$(marker_path)" ]
220230
}
221231

@@ -228,7 +238,7 @@ run_handler() {
228238

229239
run_handler '{}'
230240
[ "$status" -eq 0 ]
231-
[ "$STDOUT" = "{}" ]
241+
[ -z "$STDOUT" ]
232242
[ -z "$STDERR" ]
233243
[ -f "$(marker_path)" ]
234244
}

0 commit comments

Comments
 (0)