#!/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 os import re 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' def check_path(path): for part in [path] + list(path.parents): if part.is_symlink(): raise ValueError('Symlink refused: %s. Use physical directories and a regular instruction file; manage linked files manually.' % 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.') url = BASE + '/raw/' + revision + '/grilling-question-tools.md' result = subprocess.run(['curl', '-fsSL', '--proto', '=https', '--proto-redir', '=https', '--connect-timeout', '15', '--max-time', '60', url], stdout=subprocess.PIPE, check=True) policy = result.stdout.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# Question Tools 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.') block = BEGIN + b'\n' + policy.rstrip('\r\n').encode('utf-8') + b'\n' + END home = os.environ.get('HOME') if not home or not os.path.isabs(home): raise ValueError('HOME must be an absolute path.') directories = [ os.environ.get('CLAUDE_CONFIG_DIR') or home + '/.claude', os.environ.get('CODEX_HOME') or home + '/.codex', os.environ.get('OPENCODE_CONFIG_DIR') or (os.environ.get('XDG_CONFIG_HOME') or home + '/.config') + '/opencode', ] if any(not os.path.isabs(d) or '..' in Path(d).parts for d in directories): raise ValueError('Config directories must be absolute paths without .. components.') paths = list(dict.fromkeys(Path(d) / name for d, name in zip(directories, ['CLAUDE.md', 'AGENTS.md', 'AGENTS.md']))) changes = [] # Preflight every destination before creating directories, backups, or files. for path in paths: check_path(path) exists = path.exists() old = path.read_bytes() if exists else b'' if 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)) else: 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) print('Start fresh Claude Code/Codex sessions; quit and restart OpenCode.') try: install() except (OSError, ValueError, subprocess.SubprocessError) as error: print('Installation failed: %s' % error, file=sys.stderr) sys.exit(1) PY } main "$@"