sg-python.sh 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122
  1. #!/usr/bin/env bash
  2. # Find a working Python 3 interpreter and exec the hook with it.
  3. #
  4. # On Windows + Git Bash, `python3` typically resolves to the Microsoft Store
  5. # stub at C:\Users\<user>\AppData\Local\Microsoft\WindowsApps\python3, which
  6. # exits 49 silently in non-TTY subprocess context (a known Microsoft Store
  7. # stub behavior). This shim
  8. # probes each candidate with `-c ""` and skips any that fails, so the Store
  9. # stub falls through to the real python.org install (`python` in Git Bash) or
  10. # the `py -3` launcher.
  11. #
  12. # Order:
  13. # 1. python3 — canonical on macOS/Linux; the Store stub fails the probe.
  14. # 2. python — python.org installs on Windows; some Linux distros (RHEL 7
  15. # EOL'd 2024-06) point this at Python 2, but `-c ""` succeeds
  16. # on Python 2 too — guard with a version check.
  17. # 3. py -3 — Windows Python launcher.
  18. #
  19. # Args after the shim path are passed straight through to the chosen
  20. # interpreter, so the hooks.json invocation is:
  21. # bash "${CLAUDE_PLUGIN_ROOT}/hooks/sg-python.sh" \
  22. # "${CLAUDE_PLUGIN_ROOT}/hooks/security_reminder_hook.py"
  23. set -e
  24. # Force UTF-8 for ALL Python filesystem + IO operations (PEP 540).
  25. # Without this, Windows Python defaults `locale.getpreferredencoding()` to
  26. # cp1252 — which makes `text=True` in subprocess.run / open() / json.load
  27. # crash the internal reader thread on any byte that's undefined in cp1252
  28. # (e.g. the 0x81 byte from ف, present in any path/filename with
  29. # Arabic/Hebrew/CJK characters). See #2056, #2099.
  30. #
  31. # No-op on macOS/Linux (already UTF-8). Must be set BEFORE Python starts —
  32. # changing it from inside the interpreter has no effect.
  33. export PYTHONUTF8=1
  34. # Git Bash / MSYS on Windows hands script paths to this shim in POSIX form
  35. # (`/c/Users/...`). When we exec a Windows `python.exe` (which we do on
  36. # Windows since `python3` is the Microsoft Store stub), python interprets the
  37. # leading `/` as the root of the current drive — e.g. `/c/Users/...` becomes
  38. # `C:\c\Users\...` or `D:\c\Users\...` (whichever drive the shell is on),
  39. # fails with ENOENT, and every Edit/Write/MultiEdit tool use blocks until the
  40. # session restarts. See anthropics/claude-plugins-official#2043.
  41. #
  42. # Fix: convert absolute path args to native Windows form via `cygpath -w`
  43. # before exec. `cygpath` is a Git Bash builtin; it's absent on macOS/Linux,
  44. # where the `command -v` guard makes this a no-op. `cygpath -w` is idempotent
  45. # for already-Windows paths so the rare mixed-form case is safe.
  46. if command -v cygpath >/dev/null 2>&1; then
  47. converted=()
  48. for a in "$@"; do
  49. case "$a" in
  50. /*) converted+=("$(cygpath -w "$a")") ;;
  51. *) converted+=("$a") ;;
  52. esac
  53. done
  54. set -- "${converted[@]}"
  55. fi
  56. probe() {
  57. # $1..N: the interpreter command (may be multi-word like `py -3`)
  58. # Writes "<major>.<minor>" to stdout and exits 0 iff at least Python 3.
  59. "$@" -c 'import sys; print(f"{sys.version_info[0]}.{sys.version_info[1]}")' 2>/dev/null
  60. }
  61. # True iff arg is a "M.m" version string >= 3.10. claude_agent_sdk requires
  62. # Python >= 3.10; below that, pip install fails ("No matching distribution")
  63. # and the LLM-powered review (Stop / commit / push) silently no-ops while
  64. # pattern checks (PostToolUse regex) keep working. macOS ships 3.9.6 as the
  65. # default `python3` on current versions, so this guard matters in practice.
  66. # See anthropics/claude-plugins-official#2071.
  67. is_sdk_compatible() {
  68. case "$1" in
  69. 3.1[0-9]|3.[2-9][0-9]|[4-9].*|[1-9][0-9].*) return 0 ;;
  70. *) return 1 ;;
  71. esac
  72. }
  73. # Pass 1 — try minor-versioned binaries in descending order. These are only
  74. # present if the user explicitly installed them (Homebrew / python.org / pyenv),
  75. # so picking one here always upgrades over the system `python3`. Highest
  76. # available wins; the user doesn't have to PATH-prefer it.
  77. for cmd in "python3.13" "python3.12" "python3.11" "python3.10"; do
  78. v=$(probe "$cmd") || continue
  79. if is_sdk_compatible "$v"; then
  80. exec "$cmd" "$@"
  81. fi
  82. done
  83. # Pass 2 — bare interpreters, but only if SDK-compatible. Covers Linux distros
  84. # that ship 3.10+ as the default `python3`, and Windows where `python` /
  85. # `py -3` resolves to the user's python.org install.
  86. for cmd in "python3" "python" "py -3"; do
  87. # shellcheck disable=SC2086
  88. v=$(probe $cmd) || continue
  89. if is_sdk_compatible "$v"; then
  90. # shellcheck disable=SC2086
  91. exec $cmd "$@"
  92. fi
  93. done
  94. # Pass 3 — fallback to any Python 3, even <3.10. Pattern-based checks
  95. # (PostToolUse regex on Edit/Write) only need 3.6+ and are useful on their
  96. # own; the SDK-dependent paths will detect the version mismatch and degrade
  97. # inside the Python code. Without this fallback, the entire plugin would
  98. # stop working on default macOS, which is a regression vs today.
  99. for cmd in "python3" "python" "py -3"; do
  100. # shellcheck disable=SC2086
  101. v=$(probe $cmd) || continue
  102. # Accept anything that successfully reported a "M.m" string.
  103. case "$v" in
  104. [0-9]*.[0-9]*)
  105. # shellcheck disable=SC2086
  106. exec $cmd "$@"
  107. ;;
  108. esac
  109. done
  110. echo "security-guidance: no working Python 3 interpreter found." >&2
  111. echo " tried: python3.13, python3.12, python3.11, python3.10, python3, python, py -3" >&2
  112. echo " on Windows, install Python from https://python.org (NOT the Microsoft Store)" >&2
  113. echo " on macOS, install Python 3.10+ via Homebrew (\`brew install python\`)" >&2
  114. exit 1