Content
HotHook
Hooks
What academic-research-skills runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Install
> /plugin marketplace add Imbad0202/academic-research-skills > /plugin install academic-research-skills@academic-research-skills
Ships with academic-research-skills. Installing the plugin gets these hooks.
What fires, and when
SessionStart
Fires once when a session begins, and again after a context compaction. It is where a plugin sets up its environment, or restores state the compaction dropped.
bash "${CLAUDE_PLUGIN_ROOT}/scripts/announce-ars-loaded.sh"
PreToolUse
- Matches
Write|Edit|MultiEdit|Bashbash "${CLAUDE_PLUGIN_ROOT}/hooks/run_guard.sh"
Where it lives
- hooks/run_guard.shRunsGitHub
Read the script
#!/bin/sh # version: 1.0.1 # # ARS write-scope guard LAUNCHER — PreToolUse hook (#454 Windows portability fix). # # WHY THIS EXISTS: the guard hook used to be wired as `python3 ".../ars_write_scope_guard.py"` # directly. On Windows, `python3` is commonly a 0-byte Microsoft Store App Execution Alias # stub (not real Python); invoking it non-interactively fails BEFORE the guard's Python runs, # so none of the guard's own fail-safes apply — it just errors and spams the hook log (#454). # # This launcher finds a REAL Python (skipping stubs), then runs the guard as a SUPERVISED # subprocess. Design: docs/design/2026-06-17-454-windows-python-hook-portability-design.md. # # POSTURE (Plan A — graceful degradation; the guard is OPTIONAL v3.10 hardening and ARS core # needs no Python): if no real Python is found, OR the guard subprocess misbehaves, the # launcher emits a valid PASS-THROUGH hook JSON and exits 0. It NEVER exits non-zero on these # degraded paths (a non-2 exit blocks nothing anyway and only spams logs; exit 2 would # hard-lock the user out of all writes/Bash for an environment gap or an ARS-side bug — wrong # for an optional layer). It stays SILENT on stderr on degraded paths: PreToolUse is a hot # path, so any per-call stderr IS the spam #454 is about. # # Bash 3.2 / POSIX sh compatible (same constraint as scripts/announce-ars-loaded.sh). On # Windows this runs under Git Bash; with no Git Bash, CC falls back to PowerShell which can't # run this .sh — the guard is then inactive (accepted degradation, see spec §3.3). # Canonical pass-through output: no permissionDecision => falls back to the normal permission # flow (NEVER emit "allow" — that would skip every other permission rule). PASS_THROUGH='{"hookSpecificOutput":{"hookEventName":"PreToolUse"}}' emit_passthrough_and_exit() { printf '%s\n' "$PASS_THROUGH" exit 0 } # --- Resolve the guard script from THIS launcher's own location (codex P1) --------------- # CC substitutes ${CLAUDE_PLUGIN_ROOT} into the hook COMMAND text before the shell, but does # NOT guarantee it as an env var inside this script. So compute the guard path from $0. # (No production env override: the guard path is ALWAYS derived from the launcher's own # location. Tests that need a broken/alternate guard run the launcher from a temp plugin # layout, so there is no production back door — P2-e.) # shellcheck disable=SC1007 # `CDPATH= cd` is intentional: clear CDPATH for this one cd only SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || emit_passthrough_and_exit GUARD="$SELF_DIR/../scripts/ars_write_scope_guard.py" # --- Read the payload from stdin ONCE (we must replay it to the guard subprocess) --------- # Known trade-off (gemini round-6 P2, accepted): the payload is held in a shell variable and # replayed with `printf '%s'`. `printf` is a builtin so it is NOT bound by ARG_MAX, and the # stripped trailing newline is irrelevant to the guard's JSON parse, so for ordinary hook # payloads this is safe. A multi-megabyte Write payload on a POSIX shell that caps variable # length is the narrow case this does not cover; buffering to a private temp file would handle # it but adds another temp-file lifecycle (and symlink surface) to a hot path, so it is left as # documented degradation rather than fixed speculatively. PAYLOAD=$(cat) # --- Marker probe: does this candidate run real Python? ---------------------------------- # A candidate is "real" iff the probe exits 0 AND prints the exact marker on stdout. A 0-byte # Store stub fails to execute / prints nothing, so it is skipped. We bound each probe so a # broken-but-hanging interpreter can't wedge the hot path (spec §3.3): prefer `timeout` when # present, else a portable process-group watchdog. MARKER=ARS_PY_OK # Per-candidate (and guard) wall-clock bound, seconds. A small ops knob; validated to be a # bare integer so it can't smuggle anything into the `timeout`/`sleep` args. Default 3. PROBE_BOUND=${ARS_PROBE_BOUND:-3} case "$PROBE_BOUND" in ''|*[!0-9]*) PROBE_BOUND=3 ;; esac # ARS_GUARD_FORCE_WATCHDOG=1 forces the no-`timeout` watchdog path even on hosts that HAVE # `timeout`. This is a test/debug switch: it only changes the BOUNDING MECHANISM (timeout # binary vs process-group watchdog), never the security decision — both paths enforce the # same wall-clock bound and the same pass-through-on-overrun posture. Honored if set; safe # to leave unset (the default prefers the `timeout` binary when present). have_timeout() { [ -z "${ARS_GUARD_FORCE_WATCHDOG:-}" ] && command -v timeout >/dev/null 2>&1 } # Reserved exit status meaning "the bounded command was killed for overrunning its bound". TIMEOUT_STATUS=124 # Run "$@" with a wall-clock bound; its stdout flows to OUR stdout (capture via $(...)). # Returns the command's real exit status, or $TIMEOUT_STATUS if it overran the bound. # Stdin for "$@" is whatever the caller arranges (we redirect it per call site) — the guard # call site PIPES the payload in, so the bounded command MUST keep that stdin. run_bounded() { if have_timeout; then # GNU timeout exits 124 on timeout — normalize to our sentinel for a uniform caller. timeout "${PROBE_BOUND}s" "$@" _st=$? [ "$_st" -eq 124 ] && return "$TIMEOUT_STATUS" return "$_st" fi # No `timeout` binary: a portable background-and-watchdog fallback. Several POSIX subtleties # bite here, all of which broke a naive version (#454 dual-track): # # 1. STDIN: a backgrounded (`&`) command's stdin defaults to /dev/null, which would feed # the GUARD an EMPTY payload (guard reads nothing -> pass-through -> a real `deny` is # LOST and the guard is dead on any timeout-less host). We stash this function's stdin # on fd 3 and redirect the job's stdin from it (`<&3`) so the piped payload arrives. # 2. STDOUT: capturing the job's stdout through the `$(...)` pipe directly would WEDGE the # pipe — if the job spawns a grandchil
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Ships withacademic-research-skills
A comprehensive suite of Claude Code skills for academic research, covering the full pipeline from research to publication.
Get the whole plugin

