Skip to content
Entertainment
Hook

Hooks

What workout-gate runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
workout-gate
2211 command2 hooks
Install
> /plugin marketplace add BotchetDig/workout-gate
> /plugin install workout-gate@workout-gate

Ships with workout-gate. Installing the plugin gets these hooks.

What fires, and when

UserPromptSubmit

Fires before Claude sees each prompt you send. A plugin can use it to inject context, so the same instruction reaches the model every turn instead of only at session start.

  • "${CLAUDE_PLUGIN_ROOT}/hooks/gate.sh"

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.

  • "${CLAUDE_PLUGIN_ROOT}/hooks/session_start.sh"
Read hooks/hooks.json

Where it lives

  • hooks/gate.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """UserPromptSubmit hook: the gate itself.
    
    Shared by all supported surfaces — Claude Code CLI + desktop and Codex CLI +
    desktop — all running this one file against the one runtime in ~/.workout-gate/.
    Codex's UserPromptSubmit payload carries the same prompt/session_id (plus
    turn_id), so no per-tool shim is needed here.
    
    Exit 0 = prompt goes through. Exit 2 = prompt blocked (stderr shown to user).
    
    Escape hatches (non-negotiable):
    - gate-management prompts are whitelisted so you can always turn it off:
      `/workout ...` (Claude slash command) AND the bare-word `workout ...` / `wg ...`
      forms (Codex may swallow a leading `/` as a UI command before the hook sees it)
    - WORKOUT_GATE_OFF=1 env var bypasses everything
    - config enabled=false bypasses
    - a challenge already running in another tool/session -> fail open (no second
      webcam window); the debt stays pending and gates again later
    - any unexpected error -> FAIL OPEN: prompt goes through, error logged to
      ~/.workout-gate/gate.log. A broken webcam must never lock you out.
    """
    import hashlib
    import json
    import os
    import sys
    import time
    import traceback
    from pathlib import Path
    
    PROJECT_DIR = Path(__file__).resolve().parent.parent
    sys.path.insert(0, str(PROJECT_DIR))
    
    from workout_gate import store, trigger  # noqa: E402
    
    
    def log(msg: str) -> None:
        try:
            with (store.data_dir() / "gate.log").open("a") as f:
                f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n")
        except OSError:
            pass
    
    
    def _is_escape(prompt: str) -> bool:
        """Gate-management prompts are never blocked, so you can always turn it off.
        `/workout` is the Claude slash command; the bare `workout`/`wg` forms are the
        fallback for Codex, where a leading `/` may be captured as a UI command
        before it ever reaches this hook."""
        p = prompt.strip().lower()
        return (p.startswith("/workout") or p in ("workout", "wg")
                or p.startswith("workout ") or p.startswith("wg "))
    
    
    def _source(payload: dict) -> str:
        """Which tool is making the user pay right now — drives the speaker name on
        the challenge window (same voice, just the tag). Priority: an explicit
        WORKOUT_GATE_SOURCE (set by the Codex project/global hook configs we write);
        then the plugin path (Codex exposes a bare PLUGIN_ROOT, Claude only ever sets
        CLAUDE_PLUGIN_ROOT); then a payload hint (non-Claude model); else Claude."""
        env = os.environ.get("WORKOUT_GATE_SOURCE")
        if env:
            return env.lower()
        if os.environ.get("PLUGIN_ROOT"):
            return "codex"
        model = (payload.get("model") or "").lower()
        if model and not model.startswith("claude"):
            return "codex"
        return "claude"
    
    
    def duplicate_invocation(payload: dict, window_s: float = 5.0) -> bool:
        """True if another gate hook already handled this very prompt (the gate
        can be wired as plugin AND project/global hook at once - count it once).
        Keyed on session+turn+prompt: Codex provides turn_id, which pins the dedup
        to a single turn even if the same prompt text recurs."""
        raw = (f"{payload.get('session_id', '')}:{payload.get('turn_id', '')}:"
               f"{payload.get('prompt', '')}")
        key = hashlib.md5(raw.encode()).hexdigest()
        path = store.data_dir() / "last-gate"
        now = time.time()
        # check-and-set under the shared lock, or plugin + global firing the same
        # prompt in parallel can both read a stale file and both pass.
        with store.locked():
            try:
                prev_key, prev_ts = path.read_text().split(" ")
                if prev_key == key and now - float(prev_ts) < window_s:
                    return True
            except (OSError, ValueError):
                pass
            path.write_text(f"{key} {now}")
        return False
    
    
    def main() -> int:
        if os.environ.get("WORKOUT_GATE_OFF") == "1":
            return 0
    
        try:
            payload = json.load(sys.stdin)
        except json.JSONDecodeError:
            payload = {}
        if _is_escape(payload.get("prompt") or ""):
            return 0
    
        config = store.load_config()
        if not config["enabled"]:
            return 0
    
        if duplicate_invocation(payload):
            return 0
    
        # Bump the prompt counter and decide under a cross-process lock so two tools
        # firing at once can't lose an increment or both trigger.
        def _bump(state):
            state["prompt_count"] += 1
            return trigger.challenge_due(config, state)
        due = store.mutate_state(_bump)
        if not due:
            return 0
    
        # Single-flight across tools: atomically claim the one challenge slot. If a
        # challenge is already active (another Claude/Codex session) this returns
        # False -> fail open (no second webcam window). The debt stays pending and
        # gates again once the active challenge is settled.
        if not store.try_claim_challenge():
            log("challenge already active in another session — letting prompt through")
            return 0
    
        try:
            # tag the challenge window with the tool that triggered this prompt
            os.environ["WORKOUT_GATE_SOURCE"] = _source(payload)
            from workout_gate import challenge
    
            # Persist the debt BEFORE opening the window: closing everything mid-
            # challenge keeps it owed for the next session.
            state = store.load_state()
            if state["debt_reps"] <= 0 and not state.get("debt_offers"):
                challenge.new_debt()
            owed = challenge.pending_summary(store.load_state())
            log(f"challenge triggered: {owed} owed")
    
            # Under a desktop app the hook has no terminal of its own, so route the
            # webcam window through Terminal.app (camera permission, visible window).
            paid = (challenge.settle_external() if challenge.should_externalize()
                    else challenge.settle_debt())
        finally:
            store.clear_challenge_pid()  # release the claim (webcam process clears its own)
    
        if paid:
            print(f"[workout-gate] The user just did {owed} to send this prompt.")
            return 0
    
        # NON-BLO
  • hooks/gate.shRunsGitHub
    Read the script
    #!/bin/sh
    # Plugin-friendly gate entry, shared by Claude Code AND Codex (both expose
    # CLAUDE_PLUGIN_ROOT, and this script resolves from its own path anyway).
    # Prefer the vendored shared runtime (~/.workout-gate/app) so every tool runs
    # ONE version against the shared state; else the code dir this script lives in
    # (plugin cache, git clone, dev checkout). Fail open: a missing runtime must
    # never block a prompt.
    ROOT="$(cd "$(dirname "$0")/.." && pwd)"
    RT="${WORKOUT_GATE_DIR:-$HOME/.workout-gate}"
    [ -f "$RT/app/hooks/gate.py" ] && ROOT="$RT/app"
    PY="$RT/venv/bin/python"
    [ -x "$PY" ] || PY="$ROOT/.venv/bin/python"
    [ -x "$PY" ] || exit 0  # not bootstrapped yet; SessionStart handles onboarding
    exec "$PY" "$ROOT/hooks/gate.py"
    
  • hooks/session_start.shRunsGitHub
    Read the script
    #!/bin/sh
    # SessionStart hook: plugins can't run install scripts, so first-run setup
    # happens here - pop the bootstrap (deps + wizard) in a Terminal window, once.
    # stdout becomes session context, so Claude can tell the user what's going on.
    ROOT="$(cd "$(dirname "$0")/.." && pwd)"
    HOME_DIR="$HOME/.workout-gate"
    mkdir -p "$HOME_DIR"
    # keep the launcher pointed at the current code (plugin cache moves on update)
    echo "$ROOT" > "$HOME_DIR/app-path"
    
    PY="$HOME_DIR/venv/bin/python"
    [ -x "$PY" ] || PY="$ROOT/.venv/bin/python"
    MODEL="$HOME_DIR/models/pose_landmarker_full.task"
    
    # "Ready" means the runtime can actually run a challenge: venv python + deps
    # importable + pose model on disk. The old check was just `[ -x "$PY" ]`, but
    # bootstrap.sh creates the venv BEFORE installing deps and the model, so a first
    # run that died half-way (network blip, closed window) left a python binary with
    # no cv2/mediapipe/model - and this hook then treated it as set up forever, the
    # gate silently fail-opening on every challenge. Verify for real: the cheap
    # `ready` marker (written by bootstrap on success) is the fast path; an install
    # from before the marker existed is blessed once via a real import check.
    ready=0
    if [ -x "$PY" ] && [ -f "$MODEL" ]; then
        if [ -f "$HOME_DIR/ready" ]; then
            ready=1
        elif "$PY" -c "import cv2, mediapipe" >/dev/null 2>&1; then
            : > "$HOME_DIR/ready"
            ready=1
        fi
    fi
    
    if [ "$ready" = 1 ]; then  # set up: refresh launcher + vendor newest code, stay silent
        ( cd "$ROOT" && "$PY" -c "from workout_gate import installer; installer._install_launcher(); installer.sync_app()" ) >/dev/null 2>&1 || true
        exit 0
    fi
    
    # Not ready: a first run, or a previous bootstrap that never finished. (Re)open
    # it - but only one at a time, so several sessions starting at once don't run
    # concurrent pip installs into the same venv. `mkdir` is the atomic claim; a
    # stale lock (>15 min = the old attempt died) is cleared first. bootstrap.sh
    # removes the lock and writes `ready` on success.
    LOCK="$HOME_DIR/bootstrapping"
    [ -n "$(find "$LOCK" -prune -mmin +15 2>/dev/null)" ] && rmdir "$LOCK" 2>/dev/null
    if ! mkdir "$LOCK" 2>/dev/null; then
      echo "Workout Gate is still finishing its first-run setup in another Terminal window (installing dependencies + pose model). Tell the user to let it complete; prompts aren't gated until it's done."
      exit 0
    fi
    
    if command -v osascript >/dev/null 2>&1 && \
       osascript -e 'tell application "Terminal"' -e 'activate' \
                 -e "do script \"exec '$ROOT/bootstrap.sh'\"" -e 'end tell' >/dev/null 2>&1; then
      echo "Workout Gate plugin: first-run setup just opened in a separate Terminal window (installs dependencies, then a 30-second wizard with a 2-pushup camera test). Let the user know and invite them to complete it there. Prompts are not gated until setup is done."
    else
      rmdir "$LOCK" 2>/dev/null  # nothing will finish the bootstrap, so don't hold the lock
      echo "Workout Gate plugin is installed but not set up yet. Tell the user to run this once in a terminal: $ROOT/bootstrap.sh"
    fi
    exit 0
    

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 withworkout-gate

Your AI works hard, so should you. A Claude Code and Codex hook that blocks your prompt until you work out — push-ups or squats, counted live via webcam. When a challenge fires you pick your pain (say 6 push-ups or 9 squats).

Get the whole plugin
Stats
221
Stars
23
Forks
Maintained
Maintenance
Python
Language
MIT
License
4mo ago
Last commit
4mo ago
Created
3d ago
Added

Repo: BotchetDig/workout-gate