Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions lib/hook-utils.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,122 @@ hook::check_enabled() {
fi
}

# --- Prerequisite visibility --------------------------------------------------
# Doctrine: a missing runtime prerequisite must surface to BOTH the agent
# (additionalContext) and the user (systemMessage) — a silently skipped feature
# is a defect. Everything in this section is jq-FREE by design: the most common
# missing prerequisite is jq itself.

# JSON-escape a string for embedding in a hand-built JSON document. Escapes
# backslash, double quote, and the line-structure control bytes by name
# (\n \r \t); the remaining C0 bytes JSON forbids raw are dropped — notice text
# never carries meaningful control bytes beyond line structure. Byte-safe under
# UTF-8: every escaped byte is ASCII, and UTF-8 continuation bytes are >= 0x80.
hook::json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\r'/\\r}"
s="${s//$'\t'/\\t}"
# tr drops the residual C0 bytes; if tr itself is unavailable, fall back to
# the escaped string as-is — notice text is hook-authored and does not carry
# raw control bytes in practice.
local out
out=$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037' 2>/dev/null) || out="$s"
printf '%s' "$out"
}

# Emit hook JSON carrying an agent-channel context (additionalContext) and/or a
# user-channel message (systemMessage) as ONE document — CC parses the hook's
# whole stdout as a single JSON doc, so a run that has both lint findings and a
# pending skip notice must compose them here rather than print twice. Either
# channel may be empty; emits nothing when both are.
# hook::emit_channels PostToolUse "$ctx" "$sysmsg"
hook::emit_channels() {
local event="$1" ctx="$2" sysmsg="$3"
[[ -n "$ctx" || -n "$sysmsg" ]] || return 0
local out="{"
if [[ -n "$ctx" ]]; then
out+='"hookSpecificOutput":{"hookEventName":"'"$(hook::json_escape "$event")"'","additionalContext":"'"$(hook::json_escape "$ctx")"'"}'
[[ -n "$sysmsg" ]] && out+=","
fi
[[ -n "$sysmsg" ]] && out+='"systemMessage":"'"$(hook::json_escape "$sysmsg")"'"'
out+="}"
printf '%s\n' "$out"
}

# Visible skip notice: the same message on both channels. The caller must exit 0
# right after unless it composes via hook::emit_channels itself.
# hook::emit_skip_notice PostToolUse "my-plugin: tool X not found — ..."
hook::emit_skip_notice() {
hook::emit_channels "$1" "$2" "$2"
}

# systemMessage-only variant for hook events with no additionalContext channel
# (e.g. Notification).
hook::emit_system_message() {
hook::emit_channels "" "" "$1"
}

# Once-per-session gate for skip notices. Returns 0 (emit now) the first time a
# given <key> fires in the current session, 1 afterwards — a missing-tool notice
# behind a broad matcher (every Write|Edit) must not repeat on every edit. The
# session id is regex-extracted from the raw hook input JSON (jq-free, see
# section header); marker files live under ${CLAUDE_PLUGIN_DATA} (survives
# plugin updates; mkdir -p defensively since creation is documented only on
# first *reference*) and markers older than 7 days are pruned so per-session
# files cannot accumulate unboundedly. Fails open toward visibility: when no
# marker can be tracked, emit every time.
# hook::notice_once "my-plugin-jq" "$INPUT" && hook::emit_skip_notice ...
hook::notice_once() {
local key="$1" input="${2:-}" session="no-session"
if [[ "$input" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
session="${BASH_REMATCH[1]}"
session="${session//[^A-Za-z0-9_-]/-}"
fi
local dir="${CLAUDE_PLUGIN_DATA:-}"
[[ -n "$dir" ]] || return 0
dir="$dir/skip-notices"
mkdir -p "$dir" 2>/dev/null || return 0
find "$dir" -type f -mtime +7 -delete 2>/dev/null
local marker="$dir/${key}.${session}"
[[ -f "$marker" ]] && return 1
: >"$marker" 2>/dev/null
return 0
}

# Best-effort jq-free extraction of tool_input.file_path from the raw hook
# input, for the applicability pre-filter an extension-scoped hook runs BEFORE
# its jq gate — a missing-jq notice must never fire for an edit the hook would
# not process anyway (e.g. a README edit reaching a workflow-lint hook whose
# Write|Edit matcher is broader than its file filter). The value is returned
# JSON-escaped (backslashes doubled); that is fine for extension/segment
# matching, which is all the pre-filter does. Returns 1 when no file_path is
# present.
# RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0
hook::raw_file_path() {
[[ "$1" =~ \"file_path\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]] || return 1
[[ -n "${BASH_REMATCH[1]}" ]] || return 1
printf '%s' "${BASH_REMATCH[1]}"
}

# jq gate for hooks whose input parsing cannot proceed without it. When jq is
# absent: one visible skip notice per session, then exit 0 — an advisory hook
# never blocks the tool over a missing prerequisite. Place after
# hook::check_enabled (and after any jq-free applicability pre-filter), passing
# the buffered stdin for session scoping.
# hook::require_jq PostToolUse my-plugin "$INPUT"
hook::require_jq() {
command -v jq >/dev/null 2>&1 && return 0
local event="$1" plugin="$2" input="${3:-}"
if hook::notice_once "${plugin}-jq" "$input"; then
hook::emit_skip_notice "$event" \
"$plugin: jq not found on PATH — hook skipped for this session. Install jq (https://jqlang.org/download/) to enable it."
fi
exit 0
}

# Normalize a path for the membership comparison below: backslashes → forward
# slashes, and — only on Windows/MSYS, whose filesystem is case-insensitive —
# fold a leading drive (POSIX `/c/...` or `c:/...`) to an upper-case drive
Expand Down
163 changes: 163 additions & 0 deletions lib/hook-utils.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,169 @@ else
fi
unset HOOK_TELEMETRY_SINK

# --- Test 14: hook::json_escape ----------------------------------------------
# Input via a variable: on Cygwin/MSYS bash a $'...' literal containing \r
# passed as a direct argument inside $(...) loses the CR at parse time.
in14=$'he said "hi" \\ path\nline2\ttab\rcr'
esc=$(hook::json_escape "$in14")
if [[ "$esc" == 'he said \"hi\" \\ path\nline2\ttab\rcr' ]]; then
ok "json_escape: quote/backslash/newline/tab/cr escaped"
else
fail "json_escape: got '$esc'"
fi
esc2=$(hook::json_escape $'a\001b\033c')
if [[ "$esc2" == "abc" ]]; then
ok "json_escape: residual C0 bytes dropped, other bytes kept"
else
fail "json_escape: wrong residual-C0 handling: $(printf '%q' "$esc2")"
fi

# --- Test 15: hook::emit_skip_notice — valid JSON, both channels, jq-free -----
notice=$(hook::emit_skip_notice PostToolUse 'my-plugin: tool "x" missing — skipped')
if jq -e '.hookSpecificOutput.hookEventName == "PostToolUse"
and (.hookSpecificOutput.additionalContext | contains("missing"))
and (.systemMessage | contains("missing"))' <<<"$notice" >/dev/null 2>&1; then
ok "emit_skip_notice: valid JSON with both channels"
else
fail "emit_skip_notice: bad JSON or missing fields: $notice"
fi
# jq-free path: PATH stripped of everything (tr fallback keeps the message).
EMPTY15="$(mktemp -d)"
notice_nojq=$(PATH="$EMPTY15" hook::emit_skip_notice PostToolUse "p: jq missing")
rmdir "$EMPTY15" 2>/dev/null || true
if [[ "$notice_nojq" == *'"systemMessage":"p: jq missing"'* ]]; then
ok "emit_skip_notice: emits without any external binaries"
else
fail "emit_skip_notice: empty-PATH emission broken: $notice_nojq"
fi
# Combined: distinct agent context + user message in ONE document.
combined=$(hook::emit_channels PostToolUse $'finding line 1\nfinding line 2' 'tool missing — skipped')
if jq -e '(.hookSpecificOutput.additionalContext | contains("finding line 2"))
and .systemMessage == "tool missing — skipped"' <<<"$combined" >/dev/null 2>&1; then
ok "emit_channels: combined ctx + sysmsg in one document"
else
fail "emit_channels: combined shape wrong: $combined"
fi
if [[ -z "$(hook::emit_channels PostToolUse "" "")" ]]; then
ok "emit_channels: both empty → no output"
else
fail "emit_channels: emitted with both channels empty"
fi
sysmsg=$(hook::emit_system_message 'notify: jq missing')
if jq -e '.systemMessage == "notify: jq missing" and (has("hookSpecificOutput") | not)' <<<"$sysmsg" >/dev/null 2>&1; then
ok "emit_system_message: systemMessage-only shape"
else
fail "emit_system_message: wrong shape: $sysmsg"
fi

# --- Test 16: hook::notice_once — per-session dedup via CLAUDE_PLUGIN_DATA ----
DATA16="$(mktemp -d)"
INPUT_S1='{"session_id":"aaaa-1111","hook_event_name":"PostToolUse"}'
INPUT_S2='{"session_id":"bbbb-2222","hook_event_name":"PostToolUse"}'
if (CLAUDE_PLUGIN_DATA="$DATA16" hook::notice_once "k1" "$INPUT_S1"); then
ok "notice_once: first call emits"
else
fail "notice_once: first call suppressed"
fi
if (CLAUDE_PLUGIN_DATA="$DATA16" hook::notice_once "k1" "$INPUT_S1"); then
fail "notice_once: repeat call in same session emitted again"
else
ok "notice_once: repeat call suppressed"
fi
if (CLAUDE_PLUGIN_DATA="$DATA16" hook::notice_once "k1" "$INPUT_S2"); then
ok "notice_once: new session emits again"
else
fail "notice_once: new session suppressed"
fi
if (CLAUDE_PLUGIN_DATA="$DATA16" hook::notice_once "k2" "$INPUT_S1"); then
ok "notice_once: distinct key emits"
else
fail "notice_once: distinct key suppressed"
fi
# No CLAUDE_PLUGIN_DATA → fail open toward visibility (emit every time).
if (
unset CLAUDE_PLUGIN_DATA 2>/dev/null
hook::notice_once "k1" "$INPUT_S1"
) &&
(
unset CLAUDE_PLUGIN_DATA 2>/dev/null
hook::notice_once "k1" "$INPUT_S1"
); then
ok "notice_once: no data dir → fail-open emit"
else
fail "notice_once: no data dir suppressed a notice"
fi
rm -rf "$DATA16"

# --- Test 16b: hook::raw_file_path — jq-free extraction -----------------------
if got=$(hook::raw_file_path '{"session_id":"s","tool_input":{"file_path":"src/app.py"},"tool_name":"Write"}') && [[ "$got" == "src/app.py" ]]; then
ok "raw_file_path: plain path extracted"
else
fail "raw_file_path: plain path (got '$got')"
fi
if got=$(hook::raw_file_path '{"tool_input":{"file_path":"C:\\repo\\a.yml"}}') && [[ "$got" == 'C:\\repo\\a.yml' ]]; then
ok "raw_file_path: JSON-escaped Windows path returned escaped"
else
fail "raw_file_path: escaped path (got '$got')"
fi
if got=$(hook::raw_file_path '{"tool_input":{"file_path":"a \"quoted\" name.md"}}') && [[ "$got" == 'a \"quoted\" name.md' ]]; then
ok "raw_file_path: escaped quotes inside value survive"
else
fail "raw_file_path: quoted value (got '$got')"
fi
if hook::raw_file_path '{"tool_name":"Bash","tool_input":{"command":"ls"}}' >/dev/null; then
fail "raw_file_path: no file_path should return 1"
else
ok "raw_file_path: absent file_path returns 1"
fi

# --- Test 17: hook::require_jq — gate behavior --------------------------------
# jq present → returns 0, no output, no exit.
out17=$( (
hook::require_jq PostToolUse tp '{"session_id":"s"}'
echo "alive"
) 2>/dev/null)
if [[ "$out17" == "alive" ]]; then
ok "require_jq: jq present → pass-through"
else
fail "require_jq: jq present misbehaved: $out17"
fi
# jq absent → notice on first run, exit 0; suppressed on second. Simulate the
# REAL missing-jq shape (Git Bash without jq): a stub PATH that still carries
# the coreutils notice_once needs (mkdir/find/tr) but no jq — an empty PATH
# would also hide mkdir, making the dedup marker untrackable and the notice
# fail-open on every run. bash is resolved via $BASH since the stub PATH has none.
DATA17="$(mktemp -d)"
FAKEBIN17="$(mktemp -d)"
for t in mkdir find tr; do
real_t=$(command -v "$t")
printf '#!/bin/sh\nexec "%s" "$@"\n' "$real_t" >"$FAKEBIN17/$t"
chmod +x "$FAKEBIN17/$t"
done
run17() {
CLAUDE_PLUGIN_DATA="$DATA17" "$BASH" -c '
PATH="'"$FAKEBIN17"'"
source "'"$HOOK_DIR"'/hook-utils.sh"
hook::require_jq PostToolUse tp "{\"session_id\":\"cccc-3333\"}"
echo "unreachable"
' 2>/dev/null
}
first17=$(run17)
rc_first=$?
second17=$(run17)
rc_second=$?
if [[ $rc_first -eq 0 && "$first17" == *'"systemMessage"'* && "$first17" != *unreachable* ]]; then
ok "require_jq: jq absent → visible notice + exit 0"
else
fail "require_jq: first run rc=$rc_first out=$first17"
fi
if [[ $rc_second -eq 0 && "$second17" != *'"systemMessage"'* && "$second17" != *unreachable* ]]; then
ok "require_jq: second run same session → silent exit 0"
else
fail "require_jq: second run rc=$rc_second out=$second17"
fi
rm -rf "$DATA17" "$FAKEBIN17"

echo
echo "PASS=$PASS FAIL=$FAIL"
[[ $FAIL -eq 0 ]]
2 changes: 1 addition & 1 deletion plugins/actionlint/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "actionlint",
"version": "0.2.0",
"version": "0.3.0",
"description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.",
"author": {
"name": "Melodic Software",
Expand Down
15 changes: 15 additions & 0 deletions plugins/actionlint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
All notable changes to the `actionlint` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.

## [0.3.0]

### Changed

- **Missing prerequisites now skip visibly** (prerequisite-visibility wave;
doctrine: a silently skipped feature is a defect). When `actionlint` or `jq`
is absent, the hook emits a once-per-session notice to both Claude
(`additionalContext`) and the user (`systemMessage`) instead of a silent
no-op, then still exits `0` (advisory, never blocking). Notice dedup state
lives under `${CLAUDE_PLUGIN_DATA}/skip-notices`.
- Shared `hook-utils.sh` resynced with the new prerequisite-visibility helpers
(jq-free notice emitters, once-per-session gate, jq gate).
- README now declares the full hook runtime: Bash (Git Bash on native Windows),
`jq`, and `actionlint`, each with its absence behavior.

## [0.2.0]

### Changed
Expand Down
13 changes: 10 additions & 3 deletions plugins/actionlint/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,19 @@ your `PATH`.
ShellCheck deadlocks on large blocks under the Windows subprocess IPC path in
actionlint 1.7.x, and either adds latency unsuited to an edit-time hook.
Native workflow diagnostics are unaffected; run the full integrations in CI.
- **Graceful degrade.** When `actionlint` is not on `PATH` the hook is a silent
no-op.
- **Graceful degrade.** When `actionlint` (or `jq`) is not on `PATH` the hook
skips and says so — a once-per-session notice to both Claude
(`additionalContext`) and you (`systemMessage`), never a silent no-op.

## Requirements

- **actionlint** on `PATH`. See the
- **Bash** — the hook is a Bash script. On native Windows, install
[Git for Windows](https://code.claude.com/docs/en/setup#set-up-on-windows) so
Claude Code can run it under Git Bash.
- **jq** on `PATH` — parses the hook payload. Absent: the hook skips with a
visible once-per-session notice. [Install jq](https://jqlang.org/download/).
- **actionlint** on `PATH` — the linter itself. Absent: workflow lint skips
with a visible once-per-session notice. See the
[actionlint install guide](https://github.com/rhysd/actionlint/blob/main/docs/install.md).

## Install
Expand Down
Loading
Loading