Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
578 changes: 350 additions & 228 deletions lib/hook-utils.sh

Large diffs are not rendered by default.

460 changes: 375 additions & 85 deletions lib/hook-utils.test.sh

Large diffs are not rendered by default.

38 changes: 21 additions & 17 deletions lib/prerequisites.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
// --for keeps the entries scoped to that skill, hook or MCP server, plus
// the plugin-wide ones. --data-dir is the plugin data directory, which a
// command run through the Bash tool does not get in its environment.
// probe A SessionStart hook. Notifies once per session for each missing entry a
// hook needs, on both hook channels. Always exits 0 on a valid manifest,
// probe A SessionStart hook. Notifies the user (systemMessage) once per session
// for each missing entry a hook needs; the model hears of it when the hook
// first skips. Always exits 0 on a valid manifest,
// because Claude Code reads hook JSON only from a zero exit.
// --run-if-unset-or-true is the plugin option gate exec-bash.mjs offers: the
// probe stays silent when CLAUDE_PLUGIN_OPTION_<OPTION> is set to anything
Expand Down Expand Up @@ -472,12 +473,12 @@ function readHookInput(stdin) {
}
}

// Once per session per entry, with the marker name hook::notice_once uses for
// its prerequisite class, so a bash probe and this one share a latch.
// Once per session per entry, on the user marker hook::notice_once reads, so the
// hook's first skip of the same tool does not tell the user again.
function firstNoticeThisSession(plugin, id, session, env) {
if (!env.CLAUDE_PLUGIN_DATA) return true;
const dir = path.join(env.CLAUDE_PLUGIN_DATA, "skip-notices");
const marker = path.join(dir, `${plugin}-${id}.${session}.session`);
const marker = path.join(dir, `${plugin}-${id}.${session}.user`);
try {
if (existsSync(marker)) return false;
mkdirSync(dir, { recursive: true });
Expand Down Expand Up @@ -577,22 +578,25 @@ function cmdProbe(opts, ctx, out) {
for (const entry of manifest.requires.filter((e) => e.for.some((s) => s.startsWith("hook:")))) {
const { status, detail } = detectEntry(entry, { ...ctx, root });
if (!isFailure(status) || !firstNoticeThisSession(plugin, entry.id, session, ctx.env)) continue;
notices.push(
`${plugin}: ${detail}. Hooks that need ${entry.id} skip until it is installed. ${remediation(entry)} It does not install.`,
);
}
if (notices.length) {
const msg = notices.join("\n");
out.stdout(
JSON.stringify({
hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: msg },
systemMessage: msg,
}),
);
notices.push(userNotice(plugin, entry, status, detail));
}
if (notices.length) out.stdout(JSON.stringify({ systemMessage: notices.join("\n") }));
return 0;
}

// The user line for a missing hook dependency. hook::prereq_notice_to in
// hook-utils.sh renders the same line from the same fields when the hook skips.
// A version floor or a non-PATH kind keeps the detector's own detail.
function userNotice(plugin, entry, status, detail) {
const onPath = status === "missing" && (entry.kind === "cli" || entry.kind === "runtime");
const local = entry.detect.local_bin?.length ? ` or at ${entry.detect.local_bin.join(" or ")}` : "";
const what = onPath ? `${entry.id} not on the hook PATH${local}` : detail;
return (
`${plugin}: ${what}. ${entry.degrade} Install (${installText(entry)}). ` +
`Hooks read Claude Code's PATH, not your shell profile. No further notice this session; ${entry.check} diagnoses.`
);
}

// main(argv, ctx, out) -> exit code. ctx and out are injectable for tests.
export function main(argv, ctx = {}, out = {}) {
const context = {
Expand Down
4 changes: 2 additions & 2 deletions lib/prerequisites.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') {
$plugin = ($check.TrimStart('/') -split ':')[0]
if (-not $plugin) { $plugin = 'plugin' }
if (-not $check) { $check = 'the plugin check skill' }
$msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session."
[Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}')
$msg = "${plugin}: node is not on PATH, so hooks that launch through node (this plugin's and others') do not run. Install Node.js (https://nodejs.org/en/download), restart Claude Code, then run $check."
[Console]::Out.WriteLine('{"systemMessage":"' + $msg + '"}')
exit 0
}
if ($null -eq $node) {
Expand Down
7 changes: 4 additions & 3 deletions lib/prerequisites.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
# sh prerequisites.sh node-notice <check-command> [<enabled-option-name>]
#
# With node on PATH it exits 0 and prints nothing. Without node it prints one
# SessionStart notice on both hook channels, then exits 0, because Claude Code
# SessionStart notice to the user (systemMessage; only the user can install
# node), then exits 0, because Claude Code
# reads hook JSON only from a zero exit. Every plugin's notice shares one latch
# keyed by session id in the temp directory, so a session sees it once. Old
# latch files stay until the OS clears the temp directory. A plugin's kill
Expand Down Expand Up @@ -42,8 +43,8 @@ if [ "${1:-}" = node-notice ]; then
fi
plugin="${check#/}"
plugin="${plugin%%:*}"
msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session."
printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg"
msg="${plugin:-plugin}: node is not on PATH, so hooks that launch through node (this plugin's and others') do not run. Install Node.js (https://nodejs.org/en/download), restart Claude Code, then run ${check:-the plugin check skill}."
printf '{"systemMessage":"%s"}\n' "$msg"
exit 0
fi
if command -v node >/dev/null 2>&1; then
Expand Down
58 changes: 49 additions & 9 deletions lib/prerequisites.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -456,17 +456,53 @@ describe("report", { skip: !posix && "the stub tools are POSIX shell scripts" },
describe("probe", { skip: !posix && "the stub tools are POSIX shell scripts" }, () => {
const hookInput = (id) => () => JSON.stringify({ session_id: id, hook_event_name: "SessionStart" });

test("notifies on both channels for a missing hook dependency and exits 0", () => {
// The user line hook::prereq_notice_to renders for the same entry, written
// out by hand: the model hears of a missing tool at the hook's first skip,
// where it is relevant, so the probe tells only the user.
test("notifies the user only for a missing hook dependency and exits 0", () => {
const root = plugin([entry({ for: ["hook:format.sh"] })], "probe-a");
const r = runMain(["probe", root], { stdin: hookInput("s1") });
assert.equal(r.code, 0);
const doc = JSON.parse(r.out);
assert.equal(doc.hookSpecificOutput.hookEventName, "SessionStart");
assert.equal(doc.systemMessage, doc.hookSpecificOutput.additionalContext);
assert.deepEqual(JSON.parse(r.out), {
systemMessage:
"probe-a: jq not on the hook PATH. Without jq the hooks skip their edit. Install (brew: jq; docs: https://jqlang.org/download/). Hooks read Claude Code's PATH, not your shell profile. No further notice this session; /demo:check diagnoses.",
});
});

test("names each detect.local_bin path where the hook also looks", () => {
const biome = entry({
id: "biome",
for: ["hook:format.sh"],
detect: { any: ["biome"], local_bin: ["node_modules/.bin/biome"] },
});
const r = runMain(["probe", plugin([biome], "probe-l")], { stdin: hookInput("s1") });
assert.match(
doc.systemMessage,
/^probe-a: jq was not found on PATH\. Hooks that need jq skip until it is installed\. .*Then run \/demo:check\. It does not install\.$/,
JSON.parse(r.out).systemMessage,
/^probe-l: biome not on the hook PATH or at node_modules\/\.bin\/biome\. /,
);
});

test("renders the user line hook::prereq_notice_to renders", () => {
const biome = entry({
id: "biome",
for: ["hook:format.sh"],
detect: { any: ["biome"], local_bin: ["node_modules/.bin/biome", ".venv/bin/biome"] },
degrade: 'Without biome, "edits" go through unformatted.',
install: { npm: "npm i -D @biomejs/biome", docs: "https://biomejs.dev/" },
});
const root = plugin([biome], "probe-parity");
const probe = JSON.parse(runMain(["probe", root], { stdin: hookInput("s1") }).out).systemMessage;
const bash = spawnSync(
"bash",
[
"-c",
'source "$1"; hook::prereq_notice_to m u biome \'{"session_id":"s1"}\' 2>/dev/null; printf %s "$u"',
"bash",
path.join(here, "hook-utils.sh"),
],
{ env: { ...process.env, CLAUDE_PLUGIN_ROOT: root, CLAUDE_PLUGIN_DATA: "" }, encoding: "utf8" },
);
assert.equal(bash.stdout, probe);
});

test("stays silent about a dependency only a skill needs", () => {
Expand All @@ -490,7 +526,9 @@ describe("probe", { skip: !posix && "the stub tools are POSIX shell scripts" },
const root = plugin([entry({ for: ["hook:a.sh"] })], "probe-b");
const env = { PATH: "", CLAUDE_PLUGIN_DATA: data };
assert.notEqual(runMain(["probe", root], { env, stdin: hookInput("s1") }).out, "");
assert.ok(existsSync(path.join(data, "skip-notices", "probe-b-jq.s1.session")));
// The user marker hook::notice_once reads, so the hook's first skip does
// not repeat this notice to the user.
assert.ok(existsSync(path.join(data, "skip-notices", "probe-b-jq.s1.user")));
assert.equal(runMain(["probe", root], { env, stdin: hookInput("s1") }).out, "");
assert.notEqual(runMain(["probe", root], { env, stdin: hookInput("s2") }).out, "");
});
Expand Down Expand Up @@ -701,9 +739,11 @@ describe("shipped plugin manifests", { skip: !posix && "the stub tools are POSIX
return;
}
assert.equal(r.out.split("\n").length, 1, "one hook document");
const message = JSON.parse(r.out).systemMessage;
const doc = JSON.parse(r.out);
assert.equal(doc.hookSpecificOutput, undefined, "user channel only");
const message = doc.systemMessage;
for (const entry of hookEntries) {
assert.ok(message.includes(`Hooks that need ${entry.id} skip`), `${entry.id} is reported`);
assert.ok(message.includes(`${name}: ${entry.id} not on the hook PATH`), `${entry.id} is reported`);
assert.ok(message.includes(entry.check), `${entry.id}: ${entry.check}`);
for (const hint of Object.values(entry.install)) assert.ok(message.includes(hint), `${entry.id}: ${hint}`);
}
Expand Down
9 changes: 5 additions & 4 deletions lib/prerequisites.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,15 @@ notice_sh() { # <session-json> <args...>
shift
printf '%s' "$payload" | PATH="$TOOLS" TMPDIR="$WORK/tmp" "$SH" "$LIB/prerequisites.sh" node-notice "$@"
}
notice_shape() { # reads a notice on stdin; prints ok when both channels name <plugin> and its check skill
node -e 'const j=JSON.parse(require("fs").readFileSync(0,"utf8"));const p=process.argv[1];process.stdout.write(j.systemMessage.startsWith(p+": node is not on PATH")&&j.systemMessage.includes("Run /"+p+":check to verify")&&j.hookSpecificOutput.hookEventName==="SessionStart"&&j.hookSpecificOutput.additionalContext.includes("/"+p+":check")?"ok":"bad")' "$1"
notice_shape() { # reads a notice on stdin; prints ok when it is exactly the user-channel line for <plugin>
# Only the user can install node; a model copy would only relay "tell the user".
node -e 'const j=JSON.parse(require("fs").readFileSync(0,"utf8"));const p=process.argv[1];const want=p+": node is not on PATH, so hooks that launch through node (this plugin'"'"'s and others'"'"') do not run. Install Node.js (https://nodejs.org/en/download), restart Claude Code, then run /"+p+":check.";process.stdout.write(Object.keys(j).join()==="systemMessage"&&j.systemMessage===want?"ok":"bad")' "$1"
}
s1='{"session_id":"sess-1","hook_event_name":"SessionStart"}'

out="$(notice_sh "$s1" /bash-format:check)"
if [[ "$(printf '%s' "$out" | notice_shape bash-format)" == ok ]]; then
pass "sh node-notice: node absent prints one SessionStart notice on both channels naming the check skill"
pass "sh node-notice: node absent prints one SessionStart notice to the user naming the check skill"
else
fail "sh node-notice: node absent gave $out"
fi
Expand Down Expand Up @@ -133,7 +134,7 @@ if [[ -n "${PWSH:-}" ]]; then
}
out="$(notice_ps "$s1" /bash-format:check)"
if [[ "$(printf '%s' "$out" | notice_shape bash-format)" == ok ]]; then
pass "pwsh node-notice: node absent prints one SessionStart notice on both channels"
pass "pwsh node-notice: node absent prints one SessionStart notice to the user"
else
fail "pwsh node-notice: node absent gave $out"
fi
Expand Down
2 changes: 1 addition & 1 deletion plugins/actionlint/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "actionlint",
"version": "0.12.5",
"version": "0.12.6",
"description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.",
"author": {
"name": "Melodic Software",
Expand Down
6 changes: 6 additions & 0 deletions plugins/actionlint/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
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.12.6] - 2026-10-04

### Changed

- **Shared hook notice text ([#6225](https://github.com/melodic-software/claude-code-plugins/issues/6225)).** Skip notices from the shared hook helpers are never renewed: each tells the model once per agent and the user once per session, and says the notice will not repeat. A missing-tool notice no longer carries the hook's PATH; that goes to the debug log. The SessionStart notice for a missing hook tool goes to the user only, and names where the hook looks for it; the model hears of it at the hook's first skip. The SessionStart notice for a missing node goes to the user only, in one shorter line. The jq `degrade` text in `prerequisites.json` no longer says the skip lasts the session or that the hook says so once.

## [0.12.5] - 2026-10-04

### Changed
Expand Down
60 changes: 27 additions & 33 deletions plugins/actionlint/hooks/actionlint-check.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -346,17 +346,17 @@ else
fi
rm -f "$ABSENT_TEL"

# The missing-binary notice is the session-only prerequisite class: a subagent
# is one more edit in the same session, so it shares the session's latch instead
# of earning a second first notice. The first notice names the check skill and
# the install route.
# The missing-binary notice latches per channel: the user is told once per
# session, and each agent's model once, since a subagent does not see what the
# main agent was told. The first notice names the check skill and the install
# route.
run_absent_as() {
local data="$1" session="$2" agent="$3" payload
printf -v payload '{"session_id":"%s","agent_id":"%s","tool_input":{"file_path":"%s"},"tool_name":"Write"}' \
"$session" "$agent" "$REPO/.github/workflows/clean.yml"
(
cd "$UNRELATED" || return 1
env -u CLAUDE_PROJECT_DIR PATH="$FAKEBIN" CLAUDE_PLUGIN_DATA="$data" HOOK_NOTICE_RENEW_EVERY=8 \
env -u CLAUDE_PROJECT_DIR PATH="$FAKEBIN" CLAUDE_PLUGIN_DATA="$data" \
CLAUDE_PLUGIN_OPTION_ACTIONLINT_ENABLED=true bash "$HOOK" <<<"$payload"
)
}
Expand All @@ -368,34 +368,26 @@ else
fail "actionlint-absent first notice lacks the check skill or install route: $OUT_ABS"
fi
OUT_ABS3=$(run_absent_as "$ABSENT_DATA" test-absent-1 subagent-other)
if [[ -z "$OUT_ABS3" ]]; then
ok "actionlint-absent -> a different agent in the same session is silent (session-only key)"
if jq -e '(.hookSpecificOutput.additionalContext | contains("actionlint")) and (has("systemMessage") | not)' \
<<<"$OUT_ABS3" >/dev/null 2>&1; then
ok "actionlint-absent -> a different agent in the same session tells its model only"
else
fail "actionlint-absent: different agent_id re-emitted the notice: $OUT_ABS3"
fail "actionlint-absent: different agent_id: want the model notice alone: $OUT_ABS3"
fi

# Eight skips, one per agent in rotation, in a fresh session: the count is the
# session's, so skips 2-7 are silent and the eighth is the renewal, which keeps
# the install route and reports the count.
# Sixteen skips, one per agent in rotation, in a fresh session: each of the
# three agents' first skip tells its model; every later skip is silent, since
# the notice is never renewed.
RENEW_DATA="$(mktemp -d "$WORK/plugdata.XXXXXX")"
RENEW_QUIET=1
RENEW_OUT=""
for i in 1 2 3 4 5 6 7 8; do
for i in $(seq 1 16); do
RENEW_OUT=$(run_absent_as "$RENEW_DATA" test-renew-1 "agent-$((i % 3))")
if [[ $i -ge 2 && $i -le 7 && -n "$RENEW_OUT" ]]; then RENEW_QUIET=0; fi
if [[ $i -ge 4 && -n "$RENEW_OUT" ]]; then RENEW_QUIET=0; fi
done
if [[ $RENEW_QUIET -eq 1 ]]; then
ok "actionlint-absent -> skips 2-7 across three agents are silent"
ok "actionlint-absent -> skips 4-16 across three agents are silent (no renewal)"
else
fail "actionlint-absent: a skip between the first notice and the renewal was not silent"
fi
if jq -e --arg check "$MF_CHECK" --arg install "$MF_INSTALL" '
(.systemMessage | contains($install) and contains($check) and contains("8 skips this session")) and
(.hookSpecificOutput.additionalContext | contains($install) and contains("8 skips this session"))
' <<<"$RENEW_OUT" >/dev/null 2>&1; then
ok "actionlint-absent -> the eighth skip renews the notice and keeps the install route"
else
fail "actionlint-absent: renewal notice wrong: $RENEW_OUT"
fail "actionlint-absent: a skip after each agent's first notice was not silent"
fi

# --- jq-absent -> exit 0, VISIBLE notice once per session and agent --------------------
Expand Down Expand Up @@ -454,13 +446,14 @@ OUT_MIN=$(
)
RC_MIN=$?
if [[ $RC_MIN -eq 0 ]]; then ok "actionlint-absent (minimal PATH) -> exit 0"; else fail "actionlint-absent exit $RC_MIN"; fi
# The probed PATH goes to stderr (the debug log), never to a channel.
if printf '%s' "$OUT_MIN" | jq -e '
(.hookSpecificOutput.additionalContext | contains("PATH probed:")) and
(.hookSpecificOutput.additionalContext | contains("there is no skip latch")) and
(.systemMessage | contains("PATH probed:")) and
((.hookSpecificOutput.additionalContext | contains("PATH probed:")) | not) and
(.hookSpecificOutput.additionalContext | contains("this notice does not repeat this session")) and
((.systemMessage | contains("PATH probed:")) | not) and
((.hookSpecificOutput.additionalContext | contains("skipped for this session")) | not)
' >/dev/null 2>&1; then
ok "actionlint-absent -> notice-only latch + PATH diagnostic (#2732)"
ok "actionlint-absent -> notice-only latch, no PATH on either channel (#2732)"
else
fail "actionlint-absent latch/PATH diagnostic wrong: $OUT_MIN"
fi
Expand Down Expand Up @@ -690,8 +683,8 @@ else
fi
done

# The probe and the PostToolUse notice share one latch key, so once the probe has
# printed its notice the same session's first missing-binary edit is silent. MINBIN
# The probe tells the user and writes the user latch the PostToolUse notice reads,
# so the same session's first missing-binary edit tells the model only. MINBIN
# holds jq and no actionlint, the PATH under which the hook emits its first notice.
PG_DATA="$(mktemp -d "$PG_WORK/data.XXXXXX")"
run_probe true "$PG_DATA" >/dev/null
Expand All @@ -702,10 +695,11 @@ else
env -u CLAUDE_PROJECT_DIR PATH="$MINBIN" CLAUDE_PLUGIN_DATA="$PG_DATA" \
CLAUDE_PLUGIN_OPTION_ACTIONLINT_ENABLED=true bash "$HOOK"
)
if [[ -z "$OUT_PG" ]]; then
ok "probe-gate: the probe's notice latches the PostToolUse notice (one key)"
if jq -e '(.hookSpecificOutput.additionalContext | contains("actionlint")) and (has("systemMessage") | not)' \
<<<"$OUT_PG" >/dev/null 2>&1; then
ok "probe-gate: after the probe's notice, the first skip tells the model only (one user latch)"
else
fail "probe-gate: the PostToolUse notice fired after the probe's notice in the same session: $OUT_PG"
fail "probe-gate: the first skip after the probe's notice: want the model notice alone: $OUT_PG"
fi
rm -rf "${PG_WORK:?}"
fi
Expand Down
Loading
Loading