From c1de1921f736c3bb19c30283b03e8888e94ca068 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 03:18:48 +0000 Subject: [PATCH 1/2] Add gh-web-fallback Claude Code hook for GitHub API guidance Adds a PreToolUse hook that detects when `gh` CLI commands are attempted in environments where `gh` is unavailable but GITHUB_TOKEN is present. Provides proactive guidance to use the GitHub REST API with curl instead, avoiding failed command attempts. Resolves #125 --- .claude/hooks/gh-web-fallback.py | 262 +++++++++++++++++++++++++++++ .claude/hooks/run-with-fallback.sh | 39 +++++ .claude/settings.json | 15 ++ .gitignore | 1 - 4 files changed, 316 insertions(+), 1 deletion(-) create mode 100755 .claude/hooks/gh-web-fallback.py create mode 100755 .claude/hooks/run-with-fallback.sh create mode 100644 .claude/settings.json diff --git a/.claude/hooks/gh-web-fallback.py b/.claude/hooks/gh-web-fallback.py new file mode 100755 index 0000000..e250683 --- /dev/null +++ b/.claude/hooks/gh-web-fallback.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = [] +# /// +""" +gh-web-fallback: Proactively guide to GitHub API when gh CLI is unavailable in Web environments. + +Event: PreToolUse (Bash) + +Purpose: Proactively guides Claude to use the GitHub REST API with curl BEFORE attempting `gh` +commands in environments where `gh` CLI is unavailable but `GITHUB_TOKEN` is available +(e.g., Claude Code Web). + +Behavior: +- Detects when Claude attempts to invoke `gh` CLI commands +- Checks if `gh` CLI is NOT available using system PATH lookup +- Checks if `GITHUB_TOKEN` environment variable is available +- If both conditions are met, provides comprehensive guidance on using curl with GitHub API +- Includes 5-minute cooldown mechanism to avoid repetitive suggestions + +Triggers on: +- Bash commands containing `gh` invocations: `gh issue list`, `git status && gh pr create`, etc. +- `gh` CLI is NOT available in PATH +- `GITHUB_TOKEN` is available and non-empty + +Does NOT trigger when: +- `gh` CLI is available (defers to `prefer-gh-for-own-repos.py` for those cases) +- `GITHUB_TOKEN` is not available (no alternative available) +- Within 5-minute cooldown period since last suggestion +- Non-Bash tools +- Command doesn't contain `gh` invocations + +Command detection: +Uses regex pattern `(?:^|[;&|]\s*)gh\s+` to match: +- Simple: `gh issue list` +- Piped: `git status | gh issue view 10` +- Chained: `git status && gh pr create` +- OR chains: `cat file || gh pr view 10` +- But NOT: `sigh`, `high` (gh must be standalone command) + +Guidance provided: +- Environment explanation (gh unavailable, token available) +- 4 practical curl examples with proper authentication headers: + 1. View issue/PR + 2. List issues + 3. Create pull request + 4. Check CI status +- Tips on using `-s` flag and JSON parsing with `jq` +- Link to GitHub API documentation + +Example patterns: +```bash +# View issue +curl -s -H "Authorization: token $(printenv GITHUB_TOKEN)" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/OWNER/REPO/issues/NUMBER" + +# Create PR +curl -X POST -H "Authorization: token $(printenv GITHUB_TOKEN)" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/OWNER/REPO/pulls" \ + -d '{"title":"PR Title","head":"branch","base":"main","body":"Description"}' +``` + +State management: +- Cooldown state stored in: `~/.claude/hook-state/gh-web-fallback-cooldown` +- Contains Unix timestamp of last suggestion +- 300-second (5-minute) cooldown period +- Gracefully handles corrupted state files +- Auto-creates state directory as needed + +Benefits: +- Prevents failed `gh` command attempts in Web environments +- Provides guidance proactively (before failure) rather than reactively +- Saves a tool call (no fail-then-retry cycle) +- Works alongside `gh-fallback-helper.py` as defense in depth + +Relationship with other hooks: +- **Complements `prefer-gh-for-own-repos.py`**: When `gh` IS available, that hook suggests using it; + when gh is NOT available, this hook suggests the API +- **Works with `gh-fallback-helper.py`**: This hook provides proactive guidance (PreToolUse); + if it's missed or cooldown prevents it, gh-fallback-helper provides reactive guidance (PostToolUseFailure) + +Limitations: +- Cooldown may prevent guidance on subsequent `gh` commands within 5 minutes +- Command detection is regex-based; unusual command structures may not be detected +- Only monitors Bash tool (not other command execution methods) +""" +import json +import sys +import shutil +import os +import re +import time +from pathlib import Path + +# Cooldown period in seconds (5 minutes) +COOLDOWN_PERIOD = 300 + +# State file location +STATE_DIR = Path.home() / ".claude" / "hook-state" +STATE_FILE = STATE_DIR / "gh-web-fallback-cooldown" + +# Regex pattern to detect gh command invocations +# Matches: gh, && gh, || gh, ; gh, etc. +# But NOT: sigh, high (gh must be a standalone command) +GH_COMMAND_PATTERN = r"(?:^|[;&|]\s*)gh\s+" + + +def is_gh_available(): + """Check if gh CLI is available in PATH.""" + try: + return shutil.which("gh") is not None + except Exception: + return False + + +def is_github_token_available(): + """Check if GITHUB_TOKEN environment variable is set and non-empty.""" + try: + token = os.environ.get("GITHUB_TOKEN", "").strip() + return len(token) > 0 + except Exception: + return False + + +def is_gh_command(command): + """Check if command is attempting to use gh CLI using regex pattern.""" + try: + if not command: + return False + # Use multiline mode to detect gh in chained commands + return bool(re.search(GH_COMMAND_PATTERN, command, re.MULTILINE)) + except Exception: + return False + + +def is_within_cooldown(): + """Check if we're within the cooldown period since last suggestion.""" + try: + if not STATE_FILE.exists(): + return False + + last_suggestion_time = float(STATE_FILE.read_text().strip()) + current_time = time.time() + + return (current_time - last_suggestion_time) < COOLDOWN_PERIOD + except Exception: + # Gracefully handle corrupted state file + return False + + +def record_suggestion(): + """Record that we just made a suggestion.""" + try: + STATE_DIR.mkdir(parents=True, exist_ok=True) + STATE_FILE.write_text(str(time.time())) + except Exception as e: + # Log but don't fail - cooldown is nice-to-have, not critical + print(f"Warning: Could not record cooldown state: {e}", file=sys.stderr) + + +def main(): + try: + input_data = json.load(sys.stdin) + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + + # Only monitor Bash tool + if tool_name != "Bash": + print("{}") + sys.exit(0) + + # Extract command from tool input + command = tool_input.get("command", "") + + # Check if command is attempting to use gh + if not is_gh_command(command): + print("{}") + sys.exit(0) + + # Check if gh is available - if it is, don't suggest (let prefer-gh hook handle it) + if is_gh_available(): + print("{}") + sys.exit(0) + + # Check if GITHUB_TOKEN is available - if not, we can't help + if not is_github_token_available(): + print("{}") + sys.exit(0) + + # Check if we're within cooldown period - if so, don't suggest again + if is_within_cooldown(): + print("{}") + sys.exit(0) + + # Record this suggestion to enable cooldown + record_suggestion() + + # Provide guidance to use GitHub API with curl + output = { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "additionalContext": """**ENVIRONMENT NOTICE: Claude Code Web Detected** + +The `gh` CLI is not available in this environment, but `GITHUB_TOKEN` is available. +Use the GitHub REST API with curl instead. + +**GitHub API Patterns:** + +1. **View issue/PR:** + ```bash + curl -s -H "Authorization: token $(printenv GITHUB_TOKEN)" \\ + -H "Accept: application/vnd.github.v3+json" \\ + "https://api.github.com/repos/OWNER/REPO/issues/NUMBER" + ``` + +2. **List issues:** + ```bash + curl -s -H "Authorization: token $(printenv GITHUB_TOKEN)" \\ + -H "Accept: application/vnd.github.v3+json" \\ + "https://api.github.com/repos/OWNER/REPO/issues" + ``` + +3. **Create pull request:** + ```bash + curl -X POST -H "Authorization: token $(printenv GITHUB_TOKEN)" \\ + -H "Accept: application/vnd.github.v3+json" \\ + "https://api.github.com/repos/OWNER/REPO/pulls" \\ + -d '{"title":"PR Title","head":"branch","base":"main","body":"Description"}' + ``` + +4. **Check CI status:** + ```bash + curl -s -H "Authorization: token $(printenv GITHUB_TOKEN)" \\ + -H "Accept: application/vnd.github.v3+json" \\ + "https://api.github.com/repos/OWNER/REPO/commits/SHA/check-runs" + ``` + +**Tips:** +- Use `-s` flag for silent mode (no progress) +- Parse JSON with `jq` or `python3 -m json.tool` (never manual string parsing) +- Use `$(printenv GITHUB_TOKEN)` instead of `$GITHUB_TOKEN` when using pipes +- GitHub API docs: https://docs.github.com/en/rest + +**This message will only appear once per 5 minutes.**""" + } + } + + print(json.dumps(output)) + sys.exit(0) + + except Exception as e: + # Log to stderr for debugging + print(f"Error in gh-web-fallback hook: {e}", file=sys.stderr) + # Always output valid JSON on error + print("{}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.claude/hooks/run-with-fallback.sh b/.claude/hooks/run-with-fallback.sh new file mode 100755 index 0000000..437ac8a --- /dev/null +++ b/.claude/hooks/run-with-fallback.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Wrapper to run hooks with graceful failure handling +# Usage: run-with-fallback.sh +# fail-mode: "open" (advisory) or "closed" (safety-critical) + +set -uo pipefail + +FAIL_MODE="$1" +HOOK_SCRIPT="$2" +HOOK_NAME="$(basename "$HOOK_SCRIPT")" + +# Check if hook file exists +if [[ ! -f "$HOOK_SCRIPT" ]]; then + if [[ "$FAIL_MODE" == "closed" ]]; then + echo "{\"hookSpecificOutput\": {\"permissionDecision\": \"deny\", \"permissionDecisionReason\": \"Safety hook not found: $HOOK_NAME. Blocking for safety. Check .claude/hooks/ directory.\"}}" + else + echo "{\"hookSpecificOutput\": {\"additionalContext\": \"Warning: Hook not found: $HOOK_NAME. Proceeding without validation.\"}}" + fi + exit 0 +fi + +# Check if hook is executable +if [[ ! -x "$HOOK_SCRIPT" ]]; then + chmod +x "$HOOK_SCRIPT" 2>/dev/null || true +fi + +# Try to execute the hook +if uv run --script "$HOOK_SCRIPT"; then + exit 0 +fi + +# Hook execution failed +if [[ "$FAIL_MODE" == "closed" ]]; then + echo "{\"hookSpecificOutput\": {\"permissionDecision\": \"deny\", \"permissionDecisionReason\": \"Safety hook execution failed: $HOOK_NAME. Blocking for safety.\"}}" +else + echo "{\"hookSpecificOutput\": {\"additionalContext\": \"Warning: Hook execution failed: $HOOK_NAME. Check hook logs for details.\"}}" +fi + +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3167909 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/run-with-fallback.sh open \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/gh-web-fallback.py" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index ca27515..f7116d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ !.gitignore -.claude/ # Python cache __pycache__/ From cec45722a97dbe1b62146b4552d0c2f5b045d915 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Jan 2026 03:24:30 +0000 Subject: [PATCH 2/2] Fix Ruff linting issues in gh-web-fallback hook --- .claude/hooks/gh-web-fallback.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.claude/hooks/gh-web-fallback.py b/.claude/hooks/gh-web-fallback.py index e250683..5798315 100755 --- a/.claude/hooks/gh-web-fallback.py +++ b/.claude/hooks/gh-web-fallback.py @@ -31,7 +31,7 @@ - Command doesn't contain `gh` invocations Command detection: -Uses regex pattern `(?:^|[;&|]\s*)gh\s+` to match: +Uses regex pattern `(?:^|[;&|]\\s*)gh\\s+` to match: - Simple: `gh issue list` - Piped: `git status | gh issue view 10` - Chained: `git status && gh pr create` @@ -86,14 +86,16 @@ - Command detection is regex-based; unusual command structures may not be detected - Only monitors Bash tool (not other command execution methods) """ + import json -import sys -import shutil import os import re +import shutil +import sys import time from pathlib import Path + # Cooldown period in seconds (5 minutes) COOLDOWN_PERIOD = 300 @@ -243,7 +245,7 @@ def main(): - Use `$(printenv GITHUB_TOKEN)` instead of `$GITHUB_TOKEN` when using pipes - GitHub API docs: https://docs.github.com/en/rest -**This message will only appear once per 5 minutes.**""" +**This message will only appear once per 5 minutes.**""", } }