#!/usr/bin/env bash # Requires Bash 3.2+, Python 3.8+, and curl. No sudo; run one installer at a time. set -euo pipefail # Defining the complete function before calling it also avoids starting an # installation from a download truncated inside the embedded Python program. main() { command -v python3 >/dev/null || { printf '%s\n' 'Python 3.8+ is required.' >&2; return 1; } command -v curl >/dev/null || { printf '%s\n' 'curl is required.' >&2; return 1; } python3 - <<'PY' import json import os import re import shlex import stat import subprocess import sys import tempfile from pathlib import Path BEGIN = b'' END = b'' TOKEN = b'MATT-POCOCK-GRILLING-QUESTION-TOOLS' POLICY_BEGIN = '' POLICY_END = '' BASE = 'https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056' HOOK_NAME = 'grilling-question-hook.py' HOOK_HEADER = '#!/usr/bin/env python3\n# MATT-POCOCK-GRILLING-QUESTION-HOOK v2\n' HOOK_EVENTS = (('PostToolUse', 'Skill'), ('UserPromptSubmit', None)) def download(url): result = subprocess.run(['curl', '-fsSL', '--proto', '=https', '--proto-redir', '=https', '--connect-timeout', '15', '--max-time', '60', url], stdout=subprocess.PIPE, check=True) return result.stdout def is_managed_hook(hook): return (isinstance(hook, dict) and isinstance(hook.get('command'), str) and HOOK_NAME in hook['command']) def merge_settings(path, old, command): # Returns new settings bytes. Only hook entries running HOOK_NAME are managed; # every other key, event, and hook is kept in its original order. try: settings = json.loads(old.decode('utf-8')) if old.strip() else {} except (UnicodeDecodeError, ValueError) as error: raise ValueError('Claude settings are not valid JSON: %s (%s)' % (path, error)) if not isinstance(settings, dict): raise ValueError('Claude settings must be a JSON object: %s' % path) original = json.loads(json.dumps(settings)) hooks = settings.get('hooks', {}) if not isinstance(hooks, dict): raise ValueError('Claude settings "hooks" must be an object: %s' % path) for event in list(hooks): groups = hooks[event] if not isinstance(groups, list) or not all( isinstance(g, dict) and isinstance(g.get('hooks', []), list) for g in groups): raise ValueError('Unexpected Claude hooks layout for %s in %s. Fix it manually.' % (event, path)) kept, removed = [], False for group in groups: entries = group.get('hooks', []) others = [h for h in entries if not is_managed_hook(h)] if len(others) != len(entries): removed = True if not others: continue group = dict(group, hooks=others) kept.append(group) if removed and not kept: del hooks[event] else: hooks[event] = kept if command: for event, matcher in HOOK_EVENTS: group = {'matcher': matcher} if matcher else {} group['hooks'] = [{'type': 'command', 'command': command, 'timeout': 10}] hooks.setdefault(event, []).append(group) if hooks or 'hooks' in settings: settings['hooks'] = hooks if settings == original: return old return (json.dumps(settings, indent=2, ensure_ascii=False) + '\n').encode('utf-8') def resolve_target(path): # Resolve existing components strictly, but allow new files/directories. target = Path(path.anchor) for part in path.parts[1:]: target = target / part if target.is_symlink(): try: target = target.resolve(strict=True) except (OSError, RuntimeError) as error: raise ValueError('Dangling or unresolvable symlink: %s' % target) from error return target def check_path(path): for part in [path] + list(path.parents): if part.is_symlink(): raise ValueError('Path changed to a symlink during installation: %s' % part) if part.exists() and not (part.is_file() if part == path else part.is_dir()): raise ValueError('Not a regular file/directory: %s' % part) parent = path.parent while not parent.exists(): parent = parent.parent if not os.access(parent, os.W_OK | os.X_OK): raise ValueError('Directory is not writable/searchable: %s' % parent) if path.exists() and not os.access(path, os.R_OK | os.W_OK): raise ValueError('Instruction file is not readable/writable: %s' % path) def install(): if sys.version_info < (3, 8): raise ValueError('Python 3.8+ is required.') revision = os.environ.get('QUESTION_TOOLS_REVISION') or 'HEAD' if revision != 'HEAD' and not re.fullmatch(r'[0-9a-fA-F]{40}', revision): raise ValueError('QUESTION_TOOLS_REVISION must be HEAD or a full 40-character commit hash.') hook_setting = os.environ.get('QUESTION_TOOLS_CLAUDE_HOOK') or '1' if hook_setting not in ('0', '1'): raise ValueError('QUESTION_TOOLS_CLAUDE_HOOK must be 1 (install) or 0 (skip and remove).') url = BASE + '/raw/' + revision + '/grilling-question-tools.md' policy_bytes = download(url) policy = policy_bytes.decode('utf-8') lines = policy.splitlines() if (not lines or lines[0] != POLICY_BEGIN or lines[-1] != POLICY_END or policy.count(POLICY_BEGIN) != 1 or policy.count(POLICY_END) != 1 or len(policy) > 32768 or '\x00' in policy or TOKEN.decode() in policy or not policy.startswith(POLICY_BEGIN + '\n# AskUserQuestion for Matt Pocock Grilling Flows\n') or not all('\n%d. ' % i in policy for i in range(1, 8))): raise ValueError('Downloaded policy is incomplete or invalid; no instruction files changed.') hook_bytes = None if hook_setting == '1': hook_url = BASE + '/raw/' + revision + '/' + HOOK_NAME hook_bytes = download(hook_url) hook_source = hook_bytes.decode('utf-8') if (not hook_source.startswith(HOOK_HEADER) or len(hook_source) > 32768 or '\x00' in hook_source or TOKEN.decode() in hook_source or not hook_source.rstrip().endswith('sys.exit(0)')): raise ValueError('Downloaded Claude hook is incomplete or invalid; no instruction files changed.') try: compile(hook_source, HOOK_NAME, 'exec') except SyntaxError as error: raise ValueError('Downloaded Claude hook does not compile: %s' % error) home = os.environ.get('HOME') if not home or not os.path.isabs(home): raise ValueError('HOME must be an absolute path.') claude_dir = os.environ.get('CLAUDE_CONFIG_DIR') or home + '/.claude' if (not os.path.isabs(claude_dir) or '..' in Path(claude_dir).parts or any(c in claude_dir for c in '\r\n`')): raise ValueError('CLAUDE_CONFIG_DIR must be an absolute path without .. components.') policy_dir = Path(claude_dir) / 'grilling-question-tools' policy_path = resolve_target(policy_dir / 'grilling-question-tools.md') if any(c in str(policy_path) for c in '\r\n`'): raise ValueError('Policy path cannot contain newlines or backticks.') block = (BEGIN + b'\n' + ( 'Before running a Matt Pocock grilling flow (grilling, grill-me, grill-with-docs,\n' 'loop-me, or a skill that calls grilling), read `%s`.\n' 'Ask its questions with AskUserQuestion as that policy describes;\n' 'preserve all other workflow requirements.\n' % policy_path ).encode('utf-8') + END) paths = [resolve_target(Path(claude_dir) / 'CLAUDE.md')] hook_path = resolve_target(policy_dir / HOOK_NAME) settings_path = resolve_target(Path(claude_dir) / 'settings.json') # A hook command is run by a shell, so quote both paths. command = ('python3 %s %s' % (shlex.quote(str(hook_path)), shlex.quote(str(policy_path))) if hook_bytes is not None else None) managed = [policy_path, hook_path, settings_path] everything = managed + paths existing = [p for p in everything if p.exists()] if len(set(everything)) != len(everything) or any( os.path.samefile(a, b) for i, a in enumerate(existing) for b in existing[i + 1:]): raise ValueError('Policy, hook, settings, and CLAUDE.md must be separate files.') changes = [] # Preflight every destination before creating directories, backups, or files. for path in managed + paths: check_path(path) exists = path.exists() old = path.read_bytes() if exists else b'' if path == policy_path: new = policy_bytes elif path == hook_path: new = hook_bytes if hook_bytes is not None else old elif path == settings_path: new = merge_settings(path, old, command) elif TOKEN in old: if old.count(TOKEN) != 2 or old.count(BEGIN) != 1 or old.count(END) != 1: raise ValueError('Malformed/duplicate managed markers: %s. Repair them manually before rerunning.' % path) start, end = old.index(BEGIN), old.index(END) after = end + len(END) if (end < start or (start and old[start-1:start] != b'\n') or old[end-1:end] != b'\n' or (old[after:] and not old[after:].startswith((b'\n', b'\r\n'))) or not old[start+len(BEGIN):].startswith((b'\n', b'\r\n'))): raise ValueError('Malformed managed marker boundaries: %s' % path) new = old[:start] + block + old[after:] else: separator = (b'\n' if old.endswith(b'\n') else b'\n\n') if old else b'' new = old + separator + block + b'\n' if new != old: changes.append((path, exists, old, new)) elif exists: print('Unchanged: %s' % path) staged = [] try: for path, exists, old, new in changes: check_path(path) if path.exists() != exists or (exists and path.read_bytes() != old): raise ValueError('File changed during installation: %s. Retry with other writers stopped.' % path) path.parent.mkdir(parents=True, exist_ok=True) mode = stat.S_IMODE(path.stat().st_mode) if exists else 0o600 fd, temporary = tempfile.mkstemp(prefix='.' + path.name + '.question-tools-', dir=str(path.parent)) staged.append((path, temporary)) with os.fdopen(fd, 'wb') as stream: stream.write(new) stream.flush() os.fsync(stream.fileno()) os.fchmod(stream.fileno(), mode) if exists: fd, backup = tempfile.mkstemp(prefix=path.name + '.question-tools-backup-', dir=str(path.parent)) with os.fdopen(fd, 'wb') as stream: stream.write(old) stream.flush() os.fsync(stream.fileno()) print('Backup: %s' % backup) for path, temporary in staged: os.replace(temporary, path) print('Updated: %s' % path) finally: for _, temporary in staged: if os.path.exists(temporary): os.unlink(temporary) print('Policy source: %s' % url) if command: print('Claude Code hook: %s (PostToolUse on Skill, UserPromptSubmit)' % settings_path) else: print('Claude Code hook: skipped (QUESTION_TOOLS_CLAUDE_HOOK=0); managed entries removed if present.') print('Start a fresh Claude Code session; /hooks lists the registered hooks.') try: install() except (OSError, ValueError, subprocess.SubprocessError) as error: print('Installation failed: %s' % error, file=sys.stderr) sys.exit(1) PY } main "$@"