Skip to content
Content
Hook

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.

From plugin
academic-research-skills
41k38 agents16 commands2 hooks
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

  • MatchesWrite|Edit|MultiEdit|Bashbash "${CLAUDE_PLUGIN_ROOT}/hooks/run_guard.sh"
Read hooks/hooks.json

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