Development
Hook
Hooks
What procoder 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 azrtydxb/procoder > /plugin install procoder@procoder
Ships with procoder. Installing the plugin gets these hooks.
Where it lives
- hooks/host-command-text.mjsGitHub
Read the script
// The one rule that turns a Claude Code command into a command for a host // without a plugin root. // // commands/*.md is written once, for Claude Code, and says // "${CLAUDE_PLUGIN_ROOT}/hooks/launcher.sh". Hosts that set no such variable // need the same sentence with the launcher named instead — OpenCode and Kilo // name the binary on PATH, pi names the launcher beside the installed package. // // It lives here rather than inside internal/portability/portability_test.go // because the rule has two callers that run in different languages: the Go // parity check applies it to prove the OpenCode twins still match commands/, // and the pi adapter applies it at load. In one file, a drift between them is a // red test; in two, it is a command that runs the wrong binary, which nothing // notices. // // `launcher` is the string that stands for procoder in the host: `procoder` // where the host resolves it from PATH, a quoted absolute path where the caller // knows where its own copy lives. // Every Claude-only launcher form, longest first, replaced in ONE pass. // // One pass is not a style choice. The plugin-root form ends in `launcher.sh`, // so a sequence of separate replacements re-scans the text it just inserted: // asking for a launcher whose own name contains `launcher.sh` produced // `"/opt/pkg/hooks/"/opt/pkg/hooks/launcher.sh""`. Invisible while the other // caller substitutes `procoder`, which cannot contain it, and found the moment // this repository asked for an absolute path. const FORMS = /"\$\{CLAUDE_PLUGIN_ROOT\}\/hooks\/launcher\.sh"|launcher\.sh |launcher\.sh/g; export function hostCommandText(body, launcher) { // CRLF levelled first: a Windows checkout rewrites line endings, and the // multi-line phrase below would otherwise never match there. const text = String(body).replaceAll("\r\n", "\n"); // A trailing space belongs to the sentence, not to the match, so it is put // back rather than eaten by the alternative that matched it. const swapped = text.replace(FORMS, (form) => form.endsWith(" ") ? `${launcher} ` : launcher, ); // The two sentences that explain the launcher are only true of a // PATH-resolved one, and they are pinned byte for byte by the OpenCode twin // parity check. A host that names its own launcher needs no such sentence: // "The launcher is: /opt/procoder/hooks/launcher.sh" already says the true // thing, and rewriting it to mention PATH would trade a clear instruction for // a false one. if (launcher.includes("/")) return swapped; return swapped .replaceAll( "The launcher is: procoder", "The command below is the `procoder` binary on PATH.", ) .replaceAll( "The launcher for every procoder command below is:\nprocoder", "Every procoder command below is the `procoder` binary on PATH.", ); } - hooks/launcher.shGitHub
Read the script
#!/bin/sh # procoder launcher — resolves the one binary this machine runs, fetching it # once if it is not here yet. # # The binary is NOT committed. CI builds all five targets at the tag and # publishes them with the checksums it generated, and this script fetches the # one for this platform on first use, verifies it, and caches it beside the # plugin. Nothing is ever built locally — not the binaries, not a bootstrap, # not the checksums — which is why the only thing shipped here is text. # # This replaces a design contract that read "marketplace install, no runtime, # no network". The first run now needs the network. What that buys is in # ADR 0004; what it costs is a first run that can fail, and everything below # is about that failing safely. # # Two rules shape the failure handling, and only one of them is obvious: # # - A HOOK that cannot get its binary warns and lets the session continue. # Hooks fire inside somebody's editing session; one that exits non-zero # takes the session with it, and procoder has then broken the thing it # exists to help. # # - A COMMAND that cannot get its binary refuses. A launcher that exits 0 # having run nothing is a silent green underneath every check in the # tool — the same defect as `check --staged` exiting 0 having assessed a # mistyped filename. set -u # ---------------------------------------------------------------- where dir="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" case "$(uname -s)" in Darwin) os=darwin ext="" ;; Linux) os=linux ext="" ;; MINGW* | MSYS* | CYGWIN*) os=windows ext=".exe" ;; *) echo "procoder: unsupported OS $(uname -s)" >&2 exit 1 ;; esac case "$(uname -m)" in arm64 | aarch64) arch=arm64 ;; x86_64 | amd64) arch=amd64 ;; *) echo "procoder: unsupported architecture $(uname -m)" >&2 exit 1 ;; esac platform="$os-$arch" bin="$dir/dist/$platform/procoder$ext" asset="procoder-$platform$ext" # PROCODER_BIN is the caller's own file and NOTHING about it is checked — # not its version, not its checksum. It exists for a mirror, for a bisect, # and for the tests, which need to point this script at a fixture. if [ -n "${PROCODER_BIN:-}" ]; then exec "$PROCODER_BIN" "$@" fi # ------------------------------------------------- the steady-state path # Already here: exec it and do nothing else. This is what runs on every # session start, every Bash call and every write, so it must stay exactly # as cheap as it was when the binary was committed — no network, no # subprocess, no arithmetic. if [ -x "$bin" ]; then exec "$bin" "$@" fi # ------------------------------------------------------- failing safely # A hook is `hook <sub>` OR anything carrying --hook. Both shapes are wired # in hooks/claude-hooks.json: PreToolUse, PostToolUse, Stop and PreCompact # use the first, and SessionStart uses `principles --hook`. Matching only # the first would refuse loudly at session start on a machine that cannot # fetch, which is the mechanism breaking sessions at the one moment it was # written to protect them. is_hook=no [ "${1:-}" = "hook" ] && is_hook=yes for a in "$@"; do [ "$a" = "--hook" ] && is_hook=yes done # give_up prints why, then obeys the split: silence and 0 for a hook, # because no stdout is "no decision" to PreToolUse and "no context" to # PostToolUse; a refusal for anything else. give_up() { echo "procoder: $1" >&2 if [ "$is_hook" = yes ]; then echo "procoder: the gate is NOT running for this action" >&2 exit 0 fi exit 1 } # ---------------------------------------------------- the failure memory # Hooks fire dozens of times a minute. Retrying a failing download on each # one would put a dead network call on the hot path, so a failure is # remembered for a short while. This is a memory, not a silence: the reason # is still printed on every invocation. fail_marker="$dir/dist/.fetch-failed" fail_window=300 now="$(date +%s 2>/dev/null || echo 0)" if [ -f "$fail_marker" ]; then then_at="$(head -n 1 "$fail_marker" 2>/dev/null || echo 0)" why="$(tail -n +2 "$fail_marker" 2>/dev/null)" case "$then_at" in '' | *[!0-9]*) then_at=0 ;; esac if [ "$now" -ne 0 ] && [ "$((now - then_at))" -lt "$fail_window" ]; then give_up "${why:-the last fetch failed} (not retrying for $((fail_window - now + then_at))s)" fi fi remember_failure() { mkdir -p "$dir/dist" 2>/dev/null || true printf '%s\n%s\n' "$now" "$1" >"$fail_marker" 2>/dev/null || true give_up "$1" } [ -n "${PROCODER_NO_FETCH:-}" ] && give_up "no binary at $bin and fetching is disabled (PROCODER_NO_FETCH)" # ------------------------------------------------------------ the version # Read from the manifest beside this script, never guessed. The launcher # carries no version of its own, which is what makes it work unchanged for # every release — and it never falls back to "latest": installing a version # the plugin does not declare is worse than installing nothing, and it is # the silent kind. manifest="$dir/.claude-plugin/plugin.json" [ -f "$manifest" ] || remember_failure "no $manifest — cannot tell which version to fetch" version="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -n 1)" [ -n "$version" ] || remember_failure "no version in $manifest — cannot tell which version to fetch" base="${PROCODER_RELEASE_BASE:-https://github.com/azrtydxb/procoder/releases/download}" url="$base/v$version/$asset" sums_url="$base/v$version/SHA256SUMS" # ------------------------------------------------------------- fetching fetch() { # fetch <url> <dest> if command -v curl >/dev/null 2>&1; then curl -sSfL --retry 2 --max-time 120 -o "$2" "$1" 2>/dev/null elif command -v wget >/dev/null 2>&1; then wget -q -T 120 -O "$2" "$1" 2>/dev/null else return 127 fi } sha256() { # sha256 <file> if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1 elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1 else return 127 fi } tmp="$(mktemp -d 2>/dev/nu
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 withprocoder
Senior-developer discipline for AI coding agents. A commit gate that counts unchecked as failing, quality controllers that refuse to call unfinished work done, and a lessons loop that closes each escaped bug's class. One Go binary, no runtime deps, works with 20+ agents.
Get the whole plugin
Stats
210
Stars
17
Forks
Active
Maintenance
Go
Language
Apache-2.0
License
1d ago
Last commit
2mo ago
Created
5h ago
Added
In this plugin
See everything inside 
