Manual Claude hook smoke test
September 19, 2026 ยท View on GitHub
This is a procedure to run later, not a record of a completed test. It starts
real Claude sessions and, in the second phase only, can call TypeSafe. Do not
run it as part of ordinary tests, CI, or packaging. Use a macOS or Linux shell
with Bash, Git, unzip, and shasum. The fixture script also has offline tests
for Git Bash on Windows; these session commands target macOS/Linux.
The fixture contains only a small synthetic Go function. Preparation copies an
already unpacked release plugin without modifying its manifest, hooks, launcher,
runtime, policy, or default configuration. It creates an isolated Git repository
under ignored .tmp/; the source repository is not the Claude working directory.
No installation or persistent plugin enablement is needed.
1. Prepare and validate locally
Run from the project checkout, with Claude Code already installed and authenticated. The plugin requires 2.1.257 or later; the additional isolation flags in this recipe were checked with 2.1.267. First inspect the version and help, and stop if any flag below is unavailable. These steps do not update or install Claude:
project="$PWD"
claude --version
claude --help
claude plugin validate --help
claude auth status --text
Confirm that Claude authentication is available before either session. If the
status reports missing or expired authentication, stop and resolve that outside
this procedure; do not run a login, update, or installation as part of the smoke
test. This preflight does not prove that a server will accept the credential.
A later Claude authentication error is an unmet precondition, not evidence of a
plugin failure. Record the hook test as blocked by Claude authentication and
leave unexercised checks unverified. The safe stream summary does not classify
authentication failures separately; do not infer their cause from FAIL alone.
After confirming flag support, prepare the archive and fixture. This chain stops on the first failing command:
make package VERSION=v0.1.0 &&
(cd dist && shasum -a 256 -c jev-preflight-plugin-v0.1.0.zip.sha256) &&
test ! -L .tmp &&
mkdir -p .tmp &&
archive_check=$(mktemp -d "$project/.tmp/hook-archive.XXXXXXXX") &&
unzip -q dist/jev-preflight-plugin-v0.1.0.zip -d "$archive_check" &&
plugin="$archive_check" &&
claude plugin validate . --strict &&
claude plugin validate "$plugin" --strict &&
GOTOOLCHAIN=go1.26.7 CGO_ENABLED=0 go build -trimpath -buildvcs=false \
-o dist/hook-smoke-summary ./scripts/hook-smoke-summary &&
smoke=$(bash scripts/prepare-hook-smoke "$plugin")
Strict packaging checks the archive against the marketplace SHA pin; the checksum command checks the sidecar. Validation and plugin loading then use a fresh unzip of that archive. Stop on any version, flag, checksum, validation, or preparation error. The helper commits only its generated fixture; it never commits, resets, cleans, or changes refs in the source checkout. It captures the fixture's Git metadata file names and Git content digests before Claude.
2. Run the no-key phase once
The command removes both credential environment variables for this child process.
Confirm that organization-managed plugin settings do not supply a TypeSafe key.
The per-run setting enables only the explicitly supplied inline plugin and leaves
the shipped defaultEnabled: false unchanged.
(
set +x
set -o pipefail
cd "$smoke/repo" || exit
unset TYPESAFE_API_KEY CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY DEBUG GODEBUG
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY
unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_OPTIONAL_LOCKS=0
export CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 CLAUDE_CODE_DISABLE_CLAUDE_MDS=1
export CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING=1 CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1
claude --print --restricted --no-session-persistence --max-budget-usd 1 \
--setting-sources '' --settings '{"enabledPlugins":{"jev-preflight@inline":true}}' \
--plugin-dir "$smoke/plugin" --strict-mcp-config \
--tools Read,Edit --allowedTools 'Read(./check.go)' 'Edit(./check.go)' \
--permission-mode dontAsk --permission-prompts none --disable-slash-commands \
--system-prompt-snapshot off --include-hook-events --output-format stream-json --verbose \
'This is a synthetic hook fixture. Read check.go, then use Edit to replace return owner == actor with return true. Change no other file. After any hook feedback, inspect check.go and finish without further edits.' \
2>/dev/null | "$project/dist/hook-smoke-summary"
) &&
bash scripts/prepare-hook-smoke --verify "$smoke"
Require the safe summary to show the inline plugin loaded, successful Read and
Edit operations, UserPromptSubmit and Stop hook completion, an api_key
fail-open notice, zero continuation outputs, and a successful Claude result.
The file must actually change after the prompt baseline; a no-op run is not a
missing-key test. Also require the separate metadata verification to succeed.
Do not proceed after a skipped, incomplete, or failed result. Do not repeatedly
rerun a failed live session to obtain a pass.
With no credential source, the expected TypeSafe request count is 0. The summary observes hook events and output; it does not measure network requests.
3. Clean up, then prepare a fresh key-enabled fixture
bash scripts/prepare-hook-smoke --cleanup "$smoke" &&
smoke=$(bash scripts/prepare-hook-smoke "$plugin")
The next block asks for the key through a silent Bash read -s on the terminal.
The value is exported only inside that child Bash process and disappears when
it exits. It is not a shell command, a history entry, a file, or a parent-shell
variable. Do not paste it into the Claude prompt, settings JSON, or this
repository, and do not enable shell tracing. --restricted confines
file tools to the synthetic working directory; only Read and Edit are available,
with no Bash, MCP, or skill tool for inspecting the environment.
4. Run the key-enabled phase once
unset TYPESAFE_API_KEY CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY
(
unset BASH_ENV
bash --noprofile --norc -s -- "$project" "$smoke" <<'BASH'
set +x
set -o pipefail
project=\$1
smoke=\$2
cd "$smoke/repo" || exit
unset TYPESAFE_API_KEY CLAUDE_PLUGIN_OPTION_TYPESAFE_API_KEY DEBUG GODEBUG
printf 'TypeSafe API key: ' >/dev/tty
IFS= read -r -s TYPESAFE_API_KEY </dev/tty || exit 1
printf '\n' >/dev/tty
[[ -n "$TYPESAFE_API_KEY" ]] || exit 1
export TYPESAFE_API_KEY
trap 'unset TYPESAFE_API_KEY' EXIT
unset GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY
unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_OPTIONAL_LOCKS=0
export CLAUDE_CODE_DISABLE_AUTO_MEMORY=1 CLAUDE_CODE_DISABLE_CLAUDE_MDS=1
export CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING=1 CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1
claude --print --restricted --no-session-persistence --max-budget-usd 1 \
--setting-sources '' --settings '{"enabledPlugins":{"jev-preflight@inline":true}}' \
--plugin-dir "$smoke/plugin" --strict-mcp-config \
--tools Read,Edit --allowedTools 'Read(./check.go)' 'Edit(./check.go)' \
--permission-mode dontAsk --permission-prompts none --disable-slash-commands \
--system-prompt-snapshot off --include-hook-events --output-format stream-json --verbose \
'This is a synthetic hook fixture. Read check.go, then use Edit to replace return owner == actor with return true. Change no other file. After any hook feedback, inspect check.go and finish without further edits.' \
2>/dev/null | "$project/dist/hook-smoke-summary" --with-key
BASH
) &&
bash scripts/prepare-hook-smoke --verify "$smoke"
The quoted heredoc keeps this block compatible with both Bash and Zsh in
Terminal.app; read -s always runs under Bash and reads from /dev/tty, not from
the heredoc. Enter the key only when the silent terminal prompt appears.
The --with-key summary evaluates the continuation evidence separately. An
incomplete stream, skipped case, or budget error is not a pass. The Claude budget
is a per-session ceiling, not a TypeSafe budget. Do not automatically retry a
failed session or increase its budget to obtain a pass.
The chained metadata check runs only after a successful pipeline. After a skipped
or failed case, it can be run separately for diagnostic evidence; its success
does not change the session verdict. Cleanup below remains available in all cases.
For a meaningful continuation observation, require one Stop response carrying
hookSpecificOutput.additionalContext with the production feedback prefix and
suffix, followed by a same-name Stop that starts after that feedback and completes,
and a successful final Claude result, with no second continuation. More than one
continuation is a failure. A zero-continuation result can be valid when scores
stay below the threshold, but mark the loop-guard case NOT EXERCISED. This
fixture preserves the default threshold; no probability or threshold crossing
is guaranteed. An API skip or Claude error is not successful Jev validation.
The safe success output must include jev_feedback_outputs=1,
continuation_outputs=1, claude_result_success=true, and
continuation_check=PASS, followed by the separate unchanged-metadata result.
The production hook's expected TypeSafe count is POST=1, GET=0, with no application retry, including after 429 or 529. This differs from the separate opt-in API contract test, which performs a GET and permits one bounded retry. The untouched archive runtime does not expose its HTTP count, Jev model, usage, or raw scores to this summary; record those fields as UNOBSERVED here.
5. Record limits and remove the fixture
The metadata check compares all fixture .git file names and bytes, including
the index, refs, and objects. It does not compare timestamps. A completed
UserPromptSubmit callback alone does not prove that a private baseline was
created successfully. Hook event output also does not expose stop_hook_active
input or prove removal of every private snapshot/lock. Mark baseline contents,
that input flag, and runtime scratchpad cleanup as UNOBSERVED unless separately
inspected through an authorized diagnostic. hook_name is not a documented
plugin identity, so exact callback attribution is also UNOBSERVED. Existing
offline tests cover these invariants; do not present them as observations from
the real session.
bash scripts/prepare-hook-smoke --cleanup "$smoke"
case "$archive_check" in
"$project"/.tmp/hook-archive.*) rm -rf -- "$archive_check" ;;
esac
unset smoke archive_check TYPESAFE_API_KEY
Cleanup removes only the owned fixture, its plugin copy, and metadata digests.
It does not sweep Claude's scratchpad or configuration directories. Crashed runs
can require separate scratchpad cleanup. --no-session-persistence prevents a
saved/resumable conversation; it is not a promise that Claude writes no other
files. Never use tee, a raw-output redirect, or debug logging for these runs.
The summary consumes the stream in memory and prints only safe counts and
classifications. Keep failures as sanitized classes; do not dump transcripts,
hook input, environment variables, or API bodies to diagnose them.
References: headless execution, plugin defaults, and Stop decision control.