Skip to content
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.

From plugin
procoder
2111 skill30 commands2 hooks
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

Repo: azrtydxb/procoder