weehong zrewidował ten Gist 3 weeks ago. Przejdź do rewizji
1 file changed, 76 insertions, 12 deletions
README.md
| @@ -4,13 +4,83 @@ Makes [Matt Pocock's grilling skills](https://github.com/mattpocock/skills) | |||
| 4 | 4 | ask their questions with Claude Code's `AskUserQuestion` tool instead of plain | |
| 5 | 5 | text rounds. It is an add-on: the upstream skill files are never touched. | |
| 6 | 6 | ||
| 7 | - | ## Quick Install | |
| 7 | + | ## Step by Step | |
| 8 | + | ||
| 9 | + | ### 1. Install Matt Pocock's skills (once) | |
| 10 | + | ||
| 11 | + | Skip this if you already have them. | |
| 12 | + | ||
| 13 | + | ```bash | |
| 14 | + | npx --yes skills@latest add mattpocock/skills --global --agent claude-code --skill '*' --yes | |
| 15 | + | ``` | |
| 16 | + | ||
| 17 | + | ### 2. Install this add-on | |
| 8 | 18 | ||
| 9 | 19 | ```bash | |
| 10 | 20 | bash -o pipefail -c 'curl -fsSL https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/install-question-tools.sh | bash' | |
| 11 | 21 | ``` | |
| 12 | 22 | ||
| 13 | - | Then start a fresh Claude Code session. `/hooks` should list the two hooks. | |
| 23 | + | It prints each file it wrote (`Updated:`) and any backup it made (`Backup:`). | |
| 24 | + | If it prints `Installation failed`, nothing was changed; fix the reason it | |
| 25 | + | gives and run it again. | |
| 26 | + | ||
| 27 | + | ### 3. Start a fresh Claude Code session | |
| 28 | + | ||
| 29 | + | Hooks and `CLAUDE.md` are read when a session starts, so close any open | |
| 30 | + | session and start a new one. Type `/hooks` and check you see: | |
| 31 | + | ||
| 32 | + | - `PostToolUse` with matcher `Skill` | |
| 33 | + | - `UserPromptSubmit` | |
| 34 | + | ||
| 35 | + | Both run `grilling-question-hook.py`. | |
| 36 | + | ||
| 37 | + | ### 4. Start a grilling session | |
| 38 | + | ||
| 39 | + | Use any of these, the same way you would without the add-on: | |
| 40 | + | ||
| 41 | + | | You type | What it does | | |
| 42 | + | | --- | --- | | |
| 43 | + | | `/grill-me <your plan>` | Interview to sharpen a plan or design. | | |
| 44 | + | | `/grill-with-docs <your plan>` | Same, and writes ADRs and a glossary as you go. | | |
| 45 | + | | `/grilling <your plan>` | The core interview directly. | | |
| 46 | + | | `/loop-me` | Grill workflow specs for recurring tasks. | | |
| 47 | + | | "grill me on this" | Claude picks up the grilling skill by itself. | | |
| 48 | + | ||
| 49 | + | Skills that grill as part of their work (`wayfinder`, `triage`, | |
| 50 | + | `improve-codebase-architecture`) get the same behavior automatically. | |
| 51 | + | ||
| 52 | + | ### 5. Answer the question cards | |
| 53 | + | ||
| 54 | + | Instead of a wall of numbered text, each round arrives as `AskUserQuestion` | |
| 55 | + | cards: | |
| 56 | + | ||
| 57 | + | - Up to 4 questions per card. A bigger round comes as several cards in a row. | |
| 58 | + | - The first option is Claude's pick, marked **(Recommended)**, with the reason | |
| 59 | + | in its description. | |
| 60 | + | - Pick an option, or choose **Other** to type your own answer. | |
| 61 | + | - If you skip or dismiss a question, it stays open. Claude will not treat it as | |
| 62 | + | a yes. | |
| 63 | + | ||
| 64 | + | After each round Claude works out the next questions from your answers. The | |
| 65 | + | session ends when nothing is left open, and Claude asks you to confirm the | |
| 66 | + | shared understanding before acting on it. | |
| 67 | + | ||
| 68 | + | ### 6. If you still get plain text questions | |
| 69 | + | ||
| 70 | + | That is the expected fallback when `AskUserQuestion` cannot be used, for | |
| 71 | + | example in non-interactive `claude -p` runs, or for a question that is truly | |
| 72 | + | open ended. If it happens in a normal session: | |
| 73 | + | ||
| 74 | + | 1. Run `/hooks`. If the two hooks are missing, rerun step 2 and start a new session. | |
| 75 | + | 2. Check `python3 --version` works in the shell Claude Code uses. | |
| 76 | + | 3. Ask Claude "Did the grilling question policy load?" to see what it received. | |
| 77 | + | ||
| 78 | + | ### Update or remove | |
| 79 | + | ||
| 80 | + | - Update: run the step 2 command again. Nothing changes if you are already current. | |
| 81 | + | - Remove: see [Uninstall](#uninstall). | |
| 82 | + | ||
| 83 | + | ## Inspect Before Installing | |
| 14 | 84 | ||
| 15 | 85 | **Trust check:** this runs remote code and downloads a policy and a hook | |
| 16 | 86 | script from this gist. Read them first if you do not already trust the | |
| @@ -21,7 +91,7 @@ Files: [installer](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b | |||
| 21 | 91 | | [policy](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/grilling-question-tools.md) | |
| 22 | 92 | | [hook](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/grilling-question-hook.py) | |
| 23 | 93 | ||
| 24 | - | ### Download and Inspect Instead | |
| 94 | + | To read everything first and then install from the same gist: | |
| 25 | 95 | ||
| 26 | 96 | ```bash | |
| 27 | 97 | base=https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056 | |
| @@ -75,14 +145,8 @@ permissions and higher priority instructions still apply. | |||
| 75 | 145 | - Linux, macOS, or WSL; Bash 3.2+, Python 3.8+, `curl`. `python3` must also | |
| 76 | 146 | be on the `PATH` Claude Code uses to run hooks. | |
| 77 | 147 | - Run as your normal user, without `sudo`. | |
| 78 | - | - Matt Pocock's skills installed separately, for example: | |
| 79 | - | ||
| 80 | - | ```bash | |
| 81 | - | npx --yes skills@latest add mattpocock/skills --global --agent claude-code --skill '*' --yes | |
| 82 | - | ``` | |
| 83 | - | ||
| 84 | - | Avoid installing the Claude plugin alongside those files, which can expose | |
| 85 | - | duplicate skills. | |
| 148 | + | - Matt Pocock's skills installed separately (step 1). Use either the `npx` | |
| 149 | + | install or the Claude plugin, not both, or you may see duplicate skills. | |
| 86 | 150 | ||
| 87 | 151 | ## Updates and Safety | |
| 88 | 152 | ||
| @@ -128,4 +192,4 @@ python3 -B -m unittest -v test_install_question_tools | |||
| 128 | 192 | ``` | |
| 129 | 193 | ||
| 130 | 194 | They use temporary homes and a mocked `curl`: no network, no real install. The | |
| 131 | - | permission test is skipped when running as root. | |
| 195 | + | permission test is skipped when running as root. | |
weehong zrewidował ten Gist 3 weeks ago. Przejdź do rewizji
6 files changed, 536 insertions, 300 deletions
ELI5.mdx (usunięto plik)
| @@ -1,17 +0,0 @@ | |||
| 1 | - | --- | |
| 2 | - | name: ELI5 | |
| 3 | - | description: keep it simple pls | |
| 4 | - | keep-coding-instructions: true | |
| 5 | - | --- | |
| 6 | - | ||
| 7 | - | It's been a long day and my brain is fried, talk to me like I'm 5. | |
| 8 | - | ||
| 9 | - | Small words, short sentences, short paragraphs. If you have to use | |
| 10 | - | a big word, explain it right after. Only return what's actually necessary. | |
| 11 | - | ||
| 12 | - | Just tell me what you did, did it work, what do I do now. | |
| 13 | - | ||
| 14 | - | If I have to decide something: 2 options max, the context I need to pick fast, | |
| 15 | - | and which one you'd go with. | |
| 16 | - | ||
| 17 | - | Keep paths and commands exact. I have no brain cells left for the rest. | |
README.md
| @@ -1,148 +1,131 @@ | |||
| 1 | - | # AI Coding Agent Skill Collection | |
| 1 | + | # AskUserQuestion for Matt Pocock's Grilling Skills | |
| 2 | 2 | ||
| 3 | - | ## Quick Install: Grilling Question Tools | |
| 3 | + | Makes [Matt Pocock's grilling skills](https://github.com/mattpocock/skills) | |
| 4 | + | ask their questions with Claude Code's `AskUserQuestion` tool instead of plain | |
| 5 | + | text rounds. It is an add-on: the upstream skill files are never touched. | |
| 4 | 6 | ||
| 5 | - | Install or update the question-tool preference for **Claude Code, Codex, and | |
| 6 | - | OpenCode** in one command: | |
| 7 | + | ## Quick Install | |
| 7 | 8 | ||
| 8 | 9 | ```bash | |
| 9 | 10 | bash -o pipefail -c 'curl -fsSL https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/install-question-tools.sh | bash' | |
| 10 | 11 | ``` | |
| 11 | 12 | ||
| 12 | - | **Trust check:** this runs remote code and downloads remote instructions from | |
| 13 | - | this gist. Read both before running if you do not already trust the publisher | |
| 14 | - | and server. `pipefail` reports download failures; it is not a security sandbox | |
| 15 | - | or a guarantee against executing partially downloaded shell code. | |
| 13 | + | Then start a fresh Claude Code session. `/hooks` should list the two hooks. | |
| 16 | 14 | ||
| 17 | - | Quick links: [installer](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/install-question-tools.sh) | |
| 18 | - | | [authoritative policy](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/grilling-question-tools.md) | |
| 19 | - | | [ELI5 writing style](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/ELI5.mdx) | |
| 15 | + | **Trust check:** this runs remote code and downloads a policy and a hook | |
| 16 | + | script from this gist. Read them first if you do not already trust the | |
| 17 | + | publisher and server. `pipefail` reports download failures; it is not a | |
| 18 | + | security sandbox. | |
| 20 | 19 | ||
| 21 | - | ### Download and Inspect Instead | |
| 20 | + | Files: [installer](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/install-question-tools.sh) | |
| 21 | + | | [policy](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/grilling-question-tools.md) | |
| 22 | + | | [hook](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/grilling-question-hook.py) | |
| 22 | 23 | ||
| 23 | - | Run in a scratch directory. These downloads alone do not install anything: | |
| 24 | + | ### Download and Inspect Instead | |
| 24 | 25 | ||
| 25 | 26 | ```bash | |
| 26 | 27 | base=https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056 | |
| 27 | - | curl -fsSL "$base/raw/HEAD/install-question-tools.sh" -o install-question-tools.sh | |
| 28 | - | curl -fsSL "$base/raw/HEAD/grilling-question-tools.md" -o grilling-question-tools.md | |
| 29 | - | less install-question-tools.sh grilling-question-tools.md | |
| 30 | - | # After inspection: | |
| 28 | + | for f in install-question-tools.sh grilling-question-tools.md grilling-question-hook.py; do | |
| 29 | + | curl -fsSL "$base/raw/HEAD/$f" -o "$f" | |
| 30 | + | done | |
| 31 | + | less install-question-tools.sh grilling-question-tools.md grilling-question-hook.py | |
| 31 | 32 | bash install-question-tools.sh | |
| 32 | 33 | ``` | |
| 33 | 34 | ||
| 34 | - | By default the installer fetches the policy at `HEAD`, not the inspected local | |
| 35 | - | copy. To avoid changes between inspection and installation, get a full commit | |
| 36 | - | hash from the gist's [Revisions](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/revisions), | |
| 37 | - | replace `HEAD` in **both** download URLs with that hash, and run | |
| 38 | - | `QUESTION_TOOLS_REVISION=<same-full-40-character-hash> bash install-question-tools.sh`. | |
| 39 | - | The revision variable controls the policy download, not the installer download. | |
| 40 | - | ||
| 41 | - | ## Requirements and Scope | |
| 42 | - | ||
| 43 | - | - Linux, macOS, or WSL; Bash 3.2+, Python 3.8+, and `curl` with HTTPS support. | |
| 44 | - | - Run as your normal user, without `sudo`, and with no concurrent installer or | |
| 45 | - | editor changing the destination files. Native Windows shells are not supported. | |
| 46 | - | - This installs instructions only, not the Matt Pocock skills, tools, plugins, | |
| 47 | - | or permissions. Install your desired grilling skills separately; upstream | |
| 48 | - | skill files are untouched. | |
| 49 | - | - Only Matt Pocock grilling flows use this preference. The policy retains the | |
| 50 | - | flow's pacing and recommendations, custom answers, unresolved decisions, | |
| 51 | - | and text fallback when the native question tool is unavailable or restricted. | |
| 52 | - | - This is **not runtime enforcement**. Host permissions, mode restrictions, | |
| 53 | - | instruction precedence, and agent instruction-loading limits still apply. | |
| 54 | - | ||
| 55 | - | To install or overwrite the upstream skills globally for all three hosts: | |
| 35 | + | The installer downloads the policy and hook at `HEAD`, not your inspected | |
| 36 | + | copies. To pin them, take a full commit hash from the gist's | |
| 37 | + | [Revisions](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/revisions) | |
| 38 | + | and run `QUESTION_TOOLS_REVISION=<40-character-hash> bash install-question-tools.sh`. | |
| 39 | + | ||
| 40 | + | ## How It Works | |
| 41 | + | ||
| 42 | + | Four pieces, all under your Claude config directory (`~/.claude`, or | |
| 43 | + | `$CLAUDE_CONFIG_DIR` when set): | |
| 44 | + | ||
| 45 | + | | File | Purpose | | |
| 46 | + | | --- | --- | | |
| 47 | + | | `grilling-question-tools/grilling-question-tools.md` | The policy: how to turn a grilling round into `AskUserQuestion` calls. | | |
| 48 | + | | `grilling-question-tools/grilling-question-hook.py` | Hands the policy to Claude the moment a grilling flow starts. | | |
| 49 | + | | `settings.json` | Registers the hook for two events (below). | | |
| 50 | + | | `CLAUDE.md` | A short marked pointer to the policy, as a fallback. | | |
| 51 | + | ||
| 52 | + | The hook fires on: | |
| 53 | + | ||
| 54 | + | | Event | When | | |
| 55 | + | | --- | --- | | |
| 56 | + | | `PostToolUse`, matcher `Skill` | Claude loads `grilling`, `grill-me`, `grill-with-docs`, or `loop-me`. This covers `/grill-me`, `/grill-with-docs`, plugin names such as `mattpocock-skills:grilling`, and skills that call grilling (`wayfinder`, `triage`, `improve-codebase-architecture`). | | |
| 57 | + | | `UserPromptSubmit` | You type `/grilling` or `/loop-me` yourself, which skips the Skill tool. | | |
| 58 | + | ||
| 59 | + | Why a hook: the upstream `grilling` skill ships its own text format for each | |
| 60 | + | round, and it arrives later in the conversation than `CLAUDE.md`, so a | |
| 61 | + | `CLAUDE.md` note alone often loses. The hook adds the policy right after the | |
| 62 | + | skill loads. It only adds context: it never blocks or edits anything, and any | |
| 63 | + | error exits 0 silently so it cannot break a session. It reads the policy file | |
| 64 | + | each time, so policy updates need no settings change. | |
| 65 | + | ||
| 66 | + | The policy keeps the grilling flow intact: same rounds (split into calls of | |
| 67 | + | up to 4 questions), a recommended option first marked `(Recommended)`, the | |
| 68 | + | built-in Other answer for custom replies, skipped answers treated as | |
| 69 | + | unresolved, and the upstream text format as the fallback when | |
| 70 | + | `AskUserQuestion` is unavailable. It is guidance, not enforcement; Claude Code | |
| 71 | + | permissions and higher priority instructions still apply. | |
| 72 | + | ||
| 73 | + | ## Requirements | |
| 74 | + | ||
| 75 | + | - Linux, macOS, or WSL; Bash 3.2+, Python 3.8+, `curl`. `python3` must also | |
| 76 | + | be on the `PATH` Claude Code uses to run hooks. | |
| 77 | + | - Run as your normal user, without `sudo`. | |
| 78 | + | - Matt Pocock's skills installed separately, for example: | |
| 56 | 79 | ||
| 57 | 80 | ```bash | |
| 58 | - | npx --yes skills@latest add mattpocock/skills --global --agent claude-code codex opencode --skill '*' --yes | |
| 81 | + | npx --yes skills@latest add mattpocock/skills --global --agent claude-code --skill '*' --yes | |
| 59 | 82 | ``` | |
| 60 | 83 | ||
| 61 | - | This selects every skill, including in-progress and miscellaneous skills. Back | |
| 62 | - | up local skill edits before overwriting them. Keep custom preferences outside | |
| 63 | - | the skill directories, and avoid installing the Claude plugin alongside these | |
| 64 | - | files because that can expose duplicate skills. | |
| 65 | - | ||
| 66 | - | ## Destinations | |
| 67 | - | ||
| 68 | - | All three destinations are installed, even if a corresponding CLI is not yet | |
| 69 | - | installed. Export overrides before running the quick command so both shells | |
| 70 | - | inherit them. Empty variables use defaults; paths must be absolute, without | |
| 71 | - | `..` components. Spaces are supported. | |
| 72 | - | ||
| 73 | - | | Agent | Default file | Override | | |
| 74 | - | | --- | --- | --- | | |
| 75 | - | | Claude Code | `~/.claude/CLAUDE.md` | `$CLAUDE_CONFIG_DIR/CLAUDE.md` | | |
| 76 | - | | Codex | `~/.codex/AGENTS.md` | `$CODEX_HOME/AGENTS.md` | | |
| 77 | - | | OpenCode | `~/.config/opencode/AGENTS.md` | `$OPENCODE_CONFIG_DIR/AGENTS.md`, otherwise `$XDG_CONFIG_HOME/opencode/AGENTS.md` | | |
| 78 | - | | Shared policy | `~/.config/ai-agents/grilling-question-tools.md` | `$QUESTION_TOOLS_POLICY_DIR/grilling-question-tools.md`, otherwise `$XDG_CONFIG_HOME/ai-agents/grilling-question-tools.md` | | |
| 79 | - | ||
| 80 | - | The downloaded authoritative policy is installed as a separate file. Each host | |
| 81 | - | gets only a small marked pointer instructing it to read the absolute policy path | |
| 82 | - | before grilling and prefer native question delivery over upstream text formatting, | |
| 83 | - | while preserving the rest of the workflow. For example, export | |
| 84 | - | `QUESTION_TOOLS_POLICY_DIR="$HOME/my agent policies"` to choose a policy directory. | |
| 85 | - | Paths containing newlines or backticks are rejected so the pointer stays unambiguous. | |
| 86 | - | ||
| 87 | - | `HOME` supplies `~`. Current OpenCode supports `OPENCODE_CONFIG_DIR` for global | |
| 88 | - | instructions; older versions may only read the XDG location. For an older | |
| 89 | - | release, leave `OPENCODE_CONFIG_DIR` unset and use `XDG_CONFIG_HOME` instead. | |
| 90 | - | See [OpenCode instruction loading](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/instruction.ts) | |
| 91 | - | and [global paths](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/global.ts). | |
| 92 | - | OpenCode normally prefers its own global `AGENTS.md` over the Claude fallback: | |
| 93 | - | creating this file can stop fallback loading of `~/.claude/CLAUDE.md`. If you | |
| 94 | - | rely on unrelated instructions in that fallback, explicitly configure them in | |
| 95 | - | OpenCode's `instructions` setting before installing. | |
| 96 | - | Codex's `AGENTS.override.md`, if present, can take precedence over `AGENTS.md`; | |
| 97 | - | review it if the new preference is not being loaded. | |
| 84 | + | Avoid installing the Claude plugin alongside those files, which can expose | |
| 85 | + | duplicate skills. | |
| 98 | 86 | ||
| 99 | 87 | ## Updates and Safety | |
| 100 | 88 | ||
| 101 | - | Rerun the quick command to update the separate policy file and managed pointers. | |
| 102 | - | Existing managed blocks containing an embedded policy are migrated by replacing | |
| 103 | - | only that block with the pointer. Identical reruns leave files unchanged and | |
| 104 | - | create no new backups. The downloaded policy bytes are installed verbatim; | |
| 105 | - | the installer does not maintain an embedded policy copy. | |
| 106 | - | ||
| 107 | - | - Download errors or an invalid/incomplete policy abort before destination edits. | |
| 108 | - | Validation checks UTF-8, versioned start/end markers, title, and all seven | |
| 109 | - | numbered rules. These are integrity sanity checks, not a signature. | |
| 110 | - | - Every destination is preflighted before any directories, backups, or files | |
| 111 | - | are created. Malformed or duplicate managed markers abort the entire run. | |
| 112 | - | - The unique HTML markers are `<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-TOOLS -->` | |
| 113 | - | and `<!-- END MATT-POCOCK-GRILLING-QUESTION-TOOLS -->`. Keep them intact. | |
| 114 | - | - Bytes outside an existing managed block are preserved exactly. A first | |
| 115 | - | installation appends a separator and block without changing existing bytes. | |
| 116 | - | - Each changed existing file gets a byte-for-byte sibling backup named | |
| 117 | - | `<filename>.question-tools-backup-<unique-suffix>` (private mode `0600`). New | |
| 118 | - | files need no backup. Paths are printed; previous backups are never replaced. | |
| 119 | - | - Existing file and directory symlinks are resolved to physical targets and | |
| 120 | - | preserved. Hosts sharing a symlink target get one pointer and one backup beside | |
| 121 | - | the physical target, not three. Dangling/looping symlinks, nonregular files, | |
| 122 | - | and inaccessible destinations are rejected. The policy cannot share a host target. | |
| 123 | - | - Replacements are atomic per file, not a transaction across all destinations. | |
| 124 | - | Staging and backups precede replacements, but disk/permission errors during | |
| 125 | - | writing can leave directories/backups or a partial install. Inspect the | |
| 126 | - | printed paths, restore from backups if needed, and rerun after fixing the cause. | |
| 127 | - | File modes are retained; ownership, ACLs, extended attributes and hard-link | |
| 128 | - | relationships are not preserved by replacement. Avoid concurrent writers. | |
| 129 | - | ||
| 130 | - | After installation, start **fresh Claude Code and Codex sessions**, and **quit | |
| 131 | - | and restart OpenCode**. Ask the agent which instruction files it loaded if the | |
| 132 | - | preference is missing. To uninstall, remove only the managed pointer block from | |
| 133 | - | each distinct host target, then remove the separate policy file if no pointers | |
| 134 | - | still use it. Restore a backup only if it will not erase newer unrelated edits. | |
| 135 | - | Changing the policy directory leaves the previous policy file in place; remove | |
| 136 | - | it manually once no pointers use it. | |
| 137 | - | ||
| 138 | - | ## Installer Tests | |
| 139 | - | ||
| 140 | - | Run `python3 -B -m unittest -v test_install_question_tools` from this directory. | |
| 141 | - | Tests use temporary homes and a mocked `curl`, with no network access or real | |
| 142 | - | home installation. Permission checks are skipped when running tests as root. | |
| 143 | - | ||
| 144 | - | ## Existing Style | |
| 145 | - | ||
| 146 | - | [`ELI5.mdx`](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/ELI5.mdx) | |
| 147 | - | is the original short, plain-language writing style. It is preserved unchanged | |
| 148 | - | and is not installed by this script. | |
| 89 | + | Rerun the quick command to update. Identical reruns change nothing and make no | |
| 90 | + | backups. | |
| 91 | + | ||
| 92 | + | - Download errors or an invalid policy or hook abort before any file is | |
| 93 | + | changed. The checks (versioned markers, title, seven rules, hook header, and | |
| 94 | + | that the hook compiles) are sanity checks, not a signature. | |
| 95 | + | - Everything is checked before anything is written. Malformed or duplicate | |
| 96 | + | `CLAUDE.md` markers, a `settings.json` that is not a JSON object, or an | |
| 97 | + | unexpected `hooks` layout abort the run. | |
| 98 | + | - `CLAUDE.md` bytes outside the managed block are kept exactly. The markers are | |
| 99 | + | `<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-TOOLS -->` and | |
| 100 | + | `<!-- END MATT-POCOCK-GRILLING-QUESTION-TOOLS -->`; keep them intact. | |
| 101 | + | - In `settings.json`, only hook entries whose command runs | |
| 102 | + | `grilling-question-hook.py` are managed. Other settings and hooks are kept in | |
| 103 | + | order, but the file is rewritten as 2 space indented JSON when it changes. | |
| 104 | + | - Every changed file gets a sibling backup named | |
| 105 | + | `<filename>.question-tools-backup-<suffix>` (mode `0600`). Old backups are | |
| 106 | + | never replaced. | |
| 107 | + | - Symlinked files and directories (for example a dotfiles repo) are followed | |
| 108 | + | and kept as symlinks. Dangling symlinks and non-regular files are rejected. | |
| 109 | + | - Each file is replaced atomically, but not all files as one transaction. If a | |
| 110 | + | write fails midway, restore from the printed backups and rerun. | |
| 111 | + | ||
| 112 | + | Upgrading from the earlier multi-agent version moves the hook entry and the | |
| 113 | + | `CLAUDE.md` pointer to the new paths automatically. It leaves the old files in | |
| 114 | + | `~/.config/ai-agents/` and the pointer blocks in `~/.codex/AGENTS.md` and | |
| 115 | + | `~/.config/opencode/AGENTS.md`; remove those by hand if you no longer want them. | |
| 116 | + | ||
| 117 | + | ## Uninstall | |
| 118 | + | ||
| 119 | + | 1. `QUESTION_TOOLS_CLAUDE_HOOK=0 bash install-question-tools.sh` removes the | |
| 120 | + | managed hook entries from `settings.json`. | |
| 121 | + | 2. Delete the marked block from `CLAUDE.md`. | |
| 122 | + | 3. Delete the `grilling-question-tools` folder in your Claude config directory. | |
| 123 | + | ||
| 124 | + | ## Tests | |
| 125 | + | ||
| 126 | + | ```bash | |
| 127 | + | python3 -B -m unittest -v test_install_question_tools | |
| 128 | + | ``` | |
| 129 | + | ||
| 130 | + | They use temporary homes and a mocked `curl`: no network, no real install. The | |
| 131 | + | permission test is skipped when running as root. | |
grilling-question-hook.py(stworzono plik)
| @@ -0,0 +1,73 @@ | |||
| 1 | + | #!/usr/bin/env python3 | |
| 2 | + | # MATT-POCOCK-GRILLING-QUESTION-HOOK v2 | |
| 3 | + | """Claude Code hook: attach the AskUserQuestion policy when grilling starts. | |
| 4 | + | ||
| 5 | + | Registered for two events by install-question-tools.sh: | |
| 6 | + | PostToolUse (matcher "Skill") fires when Claude loads a grilling skill, | |
| 7 | + | including grilling called by grill-me, | |
| 8 | + | grill-with-docs, wayfinder, triage, etc. | |
| 9 | + | UserPromptSubmit fires when the user types /grilling or | |
| 10 | + | /loop-me (typed slash commands do not go | |
| 11 | + | through the Skill tool). | |
| 12 | + | ||
| 13 | + | It never blocks or edits anything. It only adds context. Any error exits 0 | |
| 14 | + | silently so a broken hook can never interrupt a session. | |
| 15 | + | Usage: python3 grilling-question-hook.py /absolute/path/grilling-question-tools.md | |
| 16 | + | """ | |
| 17 | + | import json | |
| 18 | + | import re | |
| 19 | + | import sys | |
| 20 | + | ||
| 21 | + | SKILLS = ('grilling', 'grill-me', 'grill-with-docs', 'loop-me') | |
| 22 | + | # /grill-me and /grill-with-docs always call the Skill tool for "grilling", so | |
| 23 | + | # PostToolUse already covers them; matching them here would inject twice. | |
| 24 | + | TYPED = ('grilling', 'loop-me') | |
| 25 | + | SLASH = re.compile(r'^/(?:[\w.-]+:)?(%s)(?=\s|$)' % '|'.join(map(re.escape, TYPED))) | |
| 26 | + | MAX_POLICY = 32768 | |
| 27 | + | ||
| 28 | + | ||
| 29 | + | def skill_name(value): | |
| 30 | + | # Plugin installs namespace skills, e.g. "mattpocock-skills:grilling". | |
| 31 | + | return value.strip().rsplit(':', 1)[-1] if isinstance(value, str) else '' | |
| 32 | + | ||
| 33 | + | ||
| 34 | + | def triggered(event): | |
| 35 | + | name = event.get('hook_event_name') | |
| 36 | + | if name == 'PostToolUse' and event.get('tool_name') == 'Skill': | |
| 37 | + | tool_input = event.get('tool_input') | |
| 38 | + | if isinstance(tool_input, dict) and skill_name(tool_input.get('skill')) in SKILLS: | |
| 39 | + | return name | |
| 40 | + | if name == 'UserPromptSubmit': | |
| 41 | + | prompt = event.get('prompt') | |
| 42 | + | if isinstance(prompt, str) and SLASH.match(prompt.lstrip()): | |
| 43 | + | return name | |
| 44 | + | return None | |
| 45 | + | ||
| 46 | + | ||
| 47 | + | def main(): | |
| 48 | + | if len(sys.argv) != 2: | |
| 49 | + | return | |
| 50 | + | event = json.load(sys.stdin) | |
| 51 | + | if not isinstance(event, dict): | |
| 52 | + | return | |
| 53 | + | name = triggered(event) | |
| 54 | + | if not name: | |
| 55 | + | return | |
| 56 | + | with open(sys.argv[1], 'rb') as stream: | |
| 57 | + | policy = stream.read(MAX_POLICY + 1).decode('utf-8') | |
| 58 | + | if len(policy) > MAX_POLICY or 'MATT-POCOCK-GRILLING-QUESTION-POLICY' not in policy: | |
| 59 | + | print('Grilling question policy missing or invalid: %s' % sys.argv[1], file=sys.stderr) | |
| 60 | + | return | |
| 61 | + | context = ('A Matt Pocock grilling flow is active. Deliver its questions with ' | |
| 62 | + | 'AskUserQuestion as this policy describes; everything else in the ' | |
| 63 | + | 'flow is unchanged.\n\n' + policy.strip() + '\n') | |
| 64 | + | json.dump({'hookSpecificOutput': {'hookEventName': name, 'additionalContext': context}}, | |
| 65 | + | sys.stdout) | |
| 66 | + | ||
| 67 | + | ||
| 68 | + | if __name__ == '__main__': | |
| 69 | + | try: | |
| 70 | + | main() | |
| 71 | + | except Exception as error: # Never break the session. | |
| 72 | + | print('Grilling question hook skipped: %s' % error, file=sys.stderr) | |
| 73 | + | sys.exit(0) | |
grilling-question-tools.md
| @@ -1,46 +1,46 @@ | |||
| 1 | - | <!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-POLICY v1 --> | |
| 2 | - | # Question Tools for Matt Pocock Grilling Flows | |
| 1 | + | <!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-POLICY v2 --> | |
| 2 | + | # AskUserQuestion for Matt Pocock Grilling Flows | |
| 3 | 3 | ||
| 4 | - | Scope: apply only while running Matt Pocock's grilling flows (grill-me, | |
| 5 | - | grilling, grill-with-docs, loop-me, or a skill explicitly delegating its | |
| 6 | - | interview to one of these). This changes question delivery, not the flow's | |
| 7 | - | research, decision criteria, documentation, or completion requirements. | |
| 8 | - | Ordinary coding conversations and unrelated skills keep their own behavior. | |
| 4 | + | Scope: apply only while running Matt Pocock's grilling flows (grilling, | |
| 5 | + | grill-me, grill-with-docs, loop-me, or a skill that calls grilling, such as | |
| 6 | + | wayfinder, triage, or improve-codebase-architecture). This changes how Claude | |
| 7 | + | Code delivers questions, not the flow's research, decision criteria, | |
| 8 | + | documentation, or completion requirements. Other conversations and skills keep | |
| 9 | + | their own behavior. | |
| 9 | 10 | ||
| 10 | - | 1. Research what the codebase can answer before asking the user. The main | |
| 11 | - | conversation agent owns the interview; subagents return findings and proposed | |
| 12 | - | questions to it rather than independently prompting the user. | |
| 13 | - | 2. Use the host's native interactive question tool when exposed and permitted | |
| 14 | - | in the current session: Claude Code `AskUserQuestion`, Codex | |
| 15 | - | `request_user_input`, or OpenCode `question`. Follow its actual schema and | |
| 16 | - | mode/permission restrictions. Do not invent a tool, change modes merely to | |
| 17 | - | unlock one, or bypass a restriction. In Codex, `request_user_input` may be | |
| 18 | - | limited to Plan mode; use the fallback when it is unavailable. | |
| 19 | - | 3. Preserve the originating flow's round size and pacing. If it calls for one | |
| 20 | - | question at a time, ask one. If it allows a round of independent questions, | |
| 21 | - | batch only those that can be answered without knowing another answer. | |
| 22 | - | Wait for each answer before formulating dependent follow-ups. Split rounds | |
| 23 | - | across sequential tool calls when question, option, or text limits require | |
| 24 | - | it; retain every decision rather than silently dropping questions. | |
| 25 | - | 4. Give enough context to decide, concrete options with tradeoffs, and your | |
| 26 | - | recommendation with a short reason when you have one. Mark the recommended | |
| 27 | - | option clearly. Keep choices neutral enough to disagree with; never invent | |
| 28 | - | a recommendation just to fill the UI. Put context outside the tool if its | |
| 29 | - | field limits would lose necessary detail. | |
| 30 | - | 5. Preserve custom responses: use the tool's built-in free-text/Other facility | |
| 31 | - | where available. If it cannot accept a needed custom answer, invite a plain | |
| 32 | - | text reply instead. Do not force an open-ended question into false choices | |
| 33 | - | or interpret Other as agreement with a listed option. | |
| 34 | - | 6. Treat skipped, dismissed, empty, or ambiguous answers as unresolved, not | |
| 35 | - | consent and not acceptance of the recommendation. Clarify a blocking gap or | |
| 36 | - | explicitly defer it with the user's agreement. Track unresolved decisions | |
| 37 | - | and keep them out of the confirmed decisions in any spec, ADR, or summary. | |
| 38 | - | 7. If the native tool is absent, disallowed, fails, or cannot express the | |
| 39 | - | question faithfully, ask in plain text and wait. Use the same round rules, | |
| 40 | - | numbered questions/options, recommendation and reason, and an explicit | |
| 41 | - | invitation to give a custom answer. Resume the native tool when usable. | |
| 11 | + | 1. Research first. Look up what the codebase or tools can answer before asking | |
| 12 | + | the user. The main conversation owns the interview; subagents return | |
| 13 | + | findings and proposed questions to it and never prompt the user themselves. | |
| 14 | + | 2. Ask with AskUserQuestion when it is available and permitted in this | |
| 15 | + | session. Follow its actual schema and permission rules. Do not change modes | |
| 16 | + | or bypass a restriction to unlock it. | |
| 17 | + | 3. Keep the flow's rounds. A round is every question whose prerequisites are | |
| 18 | + | already settled. Each call takes 1 to 4 questions, so a larger round becomes | |
| 19 | + | several calls in a row; every question in the round stays independent of | |
| 20 | + | the others. Wait for answers before asking dependent follow ups. Never drop | |
| 21 | + | a question to fit the limits. | |
| 22 | + | 4. Make each question decidable: a header of at most 12 characters and 2 to 4 | |
| 23 | + | concrete options with their tradeoffs in the descriptions. Put your | |
| 24 | + | recommended option first, end its label with " (Recommended)", and give the | |
| 25 | + | reason in its description. Never invent a recommendation to fill the UI. | |
| 26 | + | When the fields would lose needed context, give it in a message just before | |
| 27 | + | the call. | |
| 28 | + | 5. Keep custom answers possible. AskUserQuestion adds an "Other" free text | |
| 29 | + | choice automatically, so do not add your own. Do not force an open ended | |
| 30 | + | question into false choices; ask it in plain text instead. Never read Other | |
| 31 | + | as agreement with a listed option. | |
| 32 | + | 6. Treat skipped, dismissed, empty, or ambiguous answers as unresolved, not as | |
| 33 | + | consent or acceptance of the recommendation. Clarify a blocking gap or defer | |
| 34 | + | it with the user's agreement. Keep unresolved decisions out of the confirmed | |
| 35 | + | decisions in any spec, ADR, or summary. | |
| 36 | + | 7. Fall back to the upstream text format. If AskUserQuestion is unavailable, | |
| 37 | + | disallowed, fails, or cannot express a question faithfully, use the grilling | |
| 38 | + | skill's own "Format a round like so" layout (numbered questions, each with a | |
| 39 | + | recommended answer), invite custom answers, and wait. Return to | |
| 40 | + | AskUserQuestion when it is usable. | |
| 42 | 41 | ||
| 43 | - | This is an instruction preference, not runtime enforcement. Higher-priority | |
| 44 | - | instructions and host restrictions still apply. Finish only when the original | |
| 45 | - | flow's completion criteria are met; changing the UI does not satisfy them. | |
| 46 | - | <!-- END MATT-POCOCK-GRILLING-QUESTION-POLICY v1 --> | |
| 42 | + | This is an instruction preference, not runtime enforcement. Higher priority | |
| 43 | + | instructions and Claude Code restrictions still apply. Finish only when the | |
| 44 | + | original flow's completion criteria are met; changing the UI does not satisfy | |
| 45 | + | them. | |
| 46 | + | <!-- END MATT-POCOCK-GRILLING-QUESTION-POLICY v2 --> | |
install-question-tools.sh
| @@ -8,8 +8,10 @@ main() { | |||
| 8 | 8 | command -v python3 >/dev/null || { printf '%s\n' 'Python 3.8+ is required.' >&2; return 1; } | |
| 9 | 9 | command -v curl >/dev/null || { printf '%s\n' 'curl is required.' >&2; return 1; } | |
| 10 | 10 | python3 - <<'PY' | |
| 11 | + | import json | |
| 11 | 12 | import os | |
| 12 | 13 | import re | |
| 14 | + | import shlex | |
| 13 | 15 | import stat | |
| 14 | 16 | import subprocess | |
| 15 | 17 | import sys | |
| @@ -19,9 +21,68 @@ from pathlib import Path | |||
| 19 | 21 | BEGIN = b'<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-TOOLS -->' | |
| 20 | 22 | END = b'<!-- END MATT-POCOCK-GRILLING-QUESTION-TOOLS -->' | |
| 21 | 23 | TOKEN = b'MATT-POCOCK-GRILLING-QUESTION-TOOLS' | |
| 22 | - | POLICY_BEGIN = '<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-POLICY v1 -->' | |
| 23 | - | POLICY_END = '<!-- END MATT-POCOCK-GRILLING-QUESTION-POLICY v1 -->' | |
| 24 | + | POLICY_BEGIN = '<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-POLICY v2 -->' | |
| 25 | + | POLICY_END = '<!-- END MATT-POCOCK-GRILLING-QUESTION-POLICY v2 -->' | |
| 24 | 26 | BASE = 'https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056' | |
| 27 | + | HOOK_NAME = 'grilling-question-hook.py' | |
| 28 | + | HOOK_HEADER = '#!/usr/bin/env python3\n# MATT-POCOCK-GRILLING-QUESTION-HOOK v2\n' | |
| 29 | + | HOOK_EVENTS = (('PostToolUse', 'Skill'), ('UserPromptSubmit', None)) | |
| 30 | + | ||
| 31 | + | ||
| 32 | + | def download(url): | |
| 33 | + | result = subprocess.run(['curl', '-fsSL', '--proto', '=https', '--proto-redir', '=https', | |
| 34 | + | '--connect-timeout', '15', '--max-time', '60', url], | |
| 35 | + | stdout=subprocess.PIPE, check=True) | |
| 36 | + | return result.stdout | |
| 37 | + | ||
| 38 | + | ||
| 39 | + | def is_managed_hook(hook): | |
| 40 | + | return (isinstance(hook, dict) and isinstance(hook.get('command'), str) | |
| 41 | + | and HOOK_NAME in hook['command']) | |
| 42 | + | ||
| 43 | + | ||
| 44 | + | def merge_settings(path, old, command): | |
| 45 | + | # Returns new settings bytes. Only hook entries running HOOK_NAME are managed; | |
| 46 | + | # every other key, event, and hook is kept in its original order. | |
| 47 | + | try: | |
| 48 | + | settings = json.loads(old.decode('utf-8')) if old.strip() else {} | |
| 49 | + | except (UnicodeDecodeError, ValueError) as error: | |
| 50 | + | raise ValueError('Claude settings are not valid JSON: %s (%s)' % (path, error)) | |
| 51 | + | if not isinstance(settings, dict): | |
| 52 | + | raise ValueError('Claude settings must be a JSON object: %s' % path) | |
| 53 | + | original = json.loads(json.dumps(settings)) | |
| 54 | + | hooks = settings.get('hooks', {}) | |
| 55 | + | if not isinstance(hooks, dict): | |
| 56 | + | raise ValueError('Claude settings "hooks" must be an object: %s' % path) | |
| 57 | + | for event in list(hooks): | |
| 58 | + | groups = hooks[event] | |
| 59 | + | if not isinstance(groups, list) or not all( | |
| 60 | + | isinstance(g, dict) and isinstance(g.get('hooks', []), list) for g in groups): | |
| 61 | + | raise ValueError('Unexpected Claude hooks layout for %s in %s. Fix it manually.' % (event, path)) | |
| 62 | + | kept, removed = [], False | |
| 63 | + | for group in groups: | |
| 64 | + | entries = group.get('hooks', []) | |
| 65 | + | others = [h for h in entries if not is_managed_hook(h)] | |
| 66 | + | if len(others) != len(entries): | |
| 67 | + | removed = True | |
| 68 | + | if not others: | |
| 69 | + | continue | |
| 70 | + | group = dict(group, hooks=others) | |
| 71 | + | kept.append(group) | |
| 72 | + | if removed and not kept: | |
| 73 | + | del hooks[event] | |
| 74 | + | else: | |
| 75 | + | hooks[event] = kept | |
| 76 | + | if command: | |
| 77 | + | for event, matcher in HOOK_EVENTS: | |
| 78 | + | group = {'matcher': matcher} if matcher else {} | |
| 79 | + | group['hooks'] = [{'type': 'command', 'command': command, 'timeout': 10}] | |
| 80 | + | hooks.setdefault(event, []).append(group) | |
| 81 | + | if hooks or 'hooks' in settings: | |
| 82 | + | settings['hooks'] = hooks | |
| 83 | + | if settings == original: | |
| 84 | + | return old | |
| 85 | + | return (json.dumps(settings, indent=2, ensure_ascii=False) + '\n').encode('utf-8') | |
| 25 | 86 | ||
| 26 | 87 | ||
| 27 | 88 | def resolve_target(path): | |
| @@ -58,55 +119,74 @@ def install(): | |||
| 58 | 119 | revision = os.environ.get('QUESTION_TOOLS_REVISION') or 'HEAD' | |
| 59 | 120 | if revision != 'HEAD' and not re.fullmatch(r'[0-9a-fA-F]{40}', revision): | |
| 60 | 121 | raise ValueError('QUESTION_TOOLS_REVISION must be HEAD or a full 40-character commit hash.') | |
| 122 | + | hook_setting = os.environ.get('QUESTION_TOOLS_CLAUDE_HOOK') or '1' | |
| 123 | + | if hook_setting not in ('0', '1'): | |
| 124 | + | raise ValueError('QUESTION_TOOLS_CLAUDE_HOOK must be 1 (install) or 0 (skip and remove).') | |
| 61 | 125 | url = BASE + '/raw/' + revision + '/grilling-question-tools.md' | |
| 62 | - | result = subprocess.run(['curl', '-fsSL', '--proto', '=https', '--proto-redir', '=https', | |
| 63 | - | '--connect-timeout', '15', '--max-time', '60', url], | |
| 64 | - | stdout=subprocess.PIPE, check=True) | |
| 65 | - | policy = result.stdout.decode('utf-8') | |
| 126 | + | policy_bytes = download(url) | |
| 127 | + | policy = policy_bytes.decode('utf-8') | |
| 66 | 128 | lines = policy.splitlines() | |
| 67 | 129 | if (not lines or lines[0] != POLICY_BEGIN or lines[-1] != POLICY_END | |
| 68 | 130 | or policy.count(POLICY_BEGIN) != 1 or policy.count(POLICY_END) != 1 | |
| 69 | 131 | or len(policy) > 32768 or '\x00' in policy | |
| 70 | 132 | or TOKEN.decode() in policy | |
| 71 | - | or not policy.startswith(POLICY_BEGIN + '\n# Question Tools for Matt Pocock Grilling Flows\n') | |
| 133 | + | or not policy.startswith(POLICY_BEGIN + '\n# AskUserQuestion for Matt Pocock Grilling Flows\n') | |
| 72 | 134 | or not all('\n%d. ' % i in policy for i in range(1, 8))): | |
| 73 | 135 | raise ValueError('Downloaded policy is incomplete or invalid; no instruction files changed.') | |
| 136 | + | hook_bytes = None | |
| 137 | + | if hook_setting == '1': | |
| 138 | + | hook_url = BASE + '/raw/' + revision + '/' + HOOK_NAME | |
| 139 | + | hook_bytes = download(hook_url) | |
| 140 | + | hook_source = hook_bytes.decode('utf-8') | |
| 141 | + | if (not hook_source.startswith(HOOK_HEADER) or len(hook_source) > 32768 | |
| 142 | + | or '\x00' in hook_source or TOKEN.decode() in hook_source | |
| 143 | + | or not hook_source.rstrip().endswith('sys.exit(0)')): | |
| 144 | + | raise ValueError('Downloaded Claude hook is incomplete or invalid; no instruction files changed.') | |
| 145 | + | try: | |
| 146 | + | compile(hook_source, HOOK_NAME, 'exec') | |
| 147 | + | except SyntaxError as error: | |
| 148 | + | raise ValueError('Downloaded Claude hook does not compile: %s' % error) | |
| 74 | 149 | home = os.environ.get('HOME') | |
| 75 | 150 | if not home or not os.path.isabs(home): | |
| 76 | 151 | raise ValueError('HOME must be an absolute path.') | |
| 77 | - | config = os.environ.get('XDG_CONFIG_HOME') or home + '/.config' | |
| 78 | - | policy_dir = os.environ.get('QUESTION_TOOLS_POLICY_DIR') or config + '/ai-agents' | |
| 79 | - | directories = [ | |
| 80 | - | os.environ.get('CLAUDE_CONFIG_DIR') or home + '/.claude', | |
| 81 | - | os.environ.get('CODEX_HOME') or home + '/.codex', | |
| 82 | - | os.environ.get('OPENCODE_CONFIG_DIR') or | |
| 83 | - | config + '/opencode', | |
| 84 | - | ] | |
| 85 | - | if any(not os.path.isabs(d) or '..' in Path(d).parts or any(c in d for c in '\r\n`') | |
| 86 | - | for d in directories + [policy_dir]): | |
| 87 | - | raise ValueError('Config directories must be absolute paths without .. components.') | |
| 88 | - | policy_path = resolve_target(Path(policy_dir) / 'grilling-question-tools.md') | |
| 152 | + | claude_dir = os.environ.get('CLAUDE_CONFIG_DIR') or home + '/.claude' | |
| 153 | + | if (not os.path.isabs(claude_dir) or '..' in Path(claude_dir).parts | |
| 154 | + | or any(c in claude_dir for c in '\r\n`')): | |
| 155 | + | raise ValueError('CLAUDE_CONFIG_DIR must be an absolute path without .. components.') | |
| 156 | + | policy_dir = Path(claude_dir) / 'grilling-question-tools' | |
| 157 | + | policy_path = resolve_target(policy_dir / 'grilling-question-tools.md') | |
| 89 | 158 | if any(c in str(policy_path) for c in '\r\n`'): | |
| 90 | 159 | raise ValueError('Policy path cannot contain newlines or backticks.') | |
| 91 | 160 | block = (BEGIN + b'\n' + ( | |
| 92 | - | 'Before running a Matt Pocock grilling flow (grill-me, grilling, grill-with-docs,\n' | |
| 93 | - | 'loop-me, or an interview delegated to one of them), read `%s`.\n' | |
| 94 | - | 'Use its native question delivery preference over upstream text formatting;\n' | |
| 161 | + | 'Before running a Matt Pocock grilling flow (grilling, grill-me, grill-with-docs,\n' | |
| 162 | + | 'loop-me, or a skill that calls grilling), read `%s`.\n' | |
| 163 | + | 'Ask its questions with AskUserQuestion as that policy describes;\n' | |
| 95 | 164 | 'preserve all other workflow requirements.\n' % policy_path | |
| 96 | 165 | ).encode('utf-8') + END) | |
| 97 | - | paths = list(dict.fromkeys(resolve_target(Path(d) / name) for d, name in | |
| 98 | - | zip(directories, ['CLAUDE.md', 'AGENTS.md', 'AGENTS.md']))) | |
| 99 | - | if policy_path in paths or (policy_path.exists() and any( | |
| 100 | - | p.exists() and os.path.samefile(policy_path, p) for p in paths)): | |
| 101 | - | raise ValueError('Policy and instruction files must be separate files.') | |
| 166 | + | paths = [resolve_target(Path(claude_dir) / 'CLAUDE.md')] | |
| 167 | + | hook_path = resolve_target(policy_dir / HOOK_NAME) | |
| 168 | + | settings_path = resolve_target(Path(claude_dir) / 'settings.json') | |
| 169 | + | # A hook command is run by a shell, so quote both paths. | |
| 170 | + | command = ('python3 %s %s' % (shlex.quote(str(hook_path)), shlex.quote(str(policy_path))) | |
| 171 | + | if hook_bytes is not None else None) | |
| 172 | + | managed = [policy_path, hook_path, settings_path] | |
| 173 | + | everything = managed + paths | |
| 174 | + | existing = [p for p in everything if p.exists()] | |
| 175 | + | if len(set(everything)) != len(everything) or any( | |
| 176 | + | os.path.samefile(a, b) for i, a in enumerate(existing) for b in existing[i + 1:]): | |
| 177 | + | raise ValueError('Policy, hook, settings, and CLAUDE.md must be separate files.') | |
| 102 | 178 | changes = [] | |
| 103 | 179 | # Preflight every destination before creating directories, backups, or files. | |
| 104 | - | for path in [policy_path] + paths: | |
| 180 | + | for path in managed + paths: | |
| 105 | 181 | check_path(path) | |
| 106 | 182 | exists = path.exists() | |
| 107 | 183 | old = path.read_bytes() if exists else b'' | |
| 108 | 184 | if path == policy_path: | |
| 109 | - | new = result.stdout | |
| 185 | + | new = policy_bytes | |
| 186 | + | elif path == hook_path: | |
| 187 | + | new = hook_bytes if hook_bytes is not None else old | |
| 188 | + | elif path == settings_path: | |
| 189 | + | new = merge_settings(path, old, command) | |
| 110 | 190 | elif TOKEN in old: | |
| 111 | 191 | if old.count(TOKEN) != 2 or old.count(BEGIN) != 1 or old.count(END) != 1: | |
| 112 | 192 | raise ValueError('Malformed/duplicate managed markers: %s. Repair them manually before rerunning.' % path) | |
| @@ -123,7 +203,7 @@ def install(): | |||
| 123 | 203 | new = old + separator + block + b'\n' | |
| 124 | 204 | if new != old: | |
| 125 | 205 | changes.append((path, exists, old, new)) | |
| 126 | - | else: | |
| 206 | + | elif exists: | |
| 127 | 207 | print('Unchanged: %s' % path) | |
| 128 | 208 | ||
| 129 | 209 | staged = [] | |
| @@ -156,7 +236,11 @@ def install(): | |||
| 156 | 236 | if os.path.exists(temporary): | |
| 157 | 237 | os.unlink(temporary) | |
| 158 | 238 | print('Policy source: %s' % url) | |
| 159 | - | print('Start fresh Claude Code/Codex sessions; quit and restart OpenCode.') | |
| 239 | + | if command: | |
| 240 | + | print('Claude Code hook: %s (PostToolUse on Skill, UserPromptSubmit)' % settings_path) | |
| 241 | + | else: | |
| 242 | + | print('Claude Code hook: skipped (QUESTION_TOOLS_CLAUDE_HOOK=0); managed entries removed if present.') | |
| 243 | + | print('Start a fresh Claude Code session; /hooks lists the registered hooks.') | |
| 160 | 244 | ||
| 161 | 245 | ||
| 162 | 246 | try: | |
test_install_question_tools.py
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | """Sandbox-only installer tests; curl never accesses the network.""" | |
| 2 | + | import json | |
| 2 | 3 | import os | |
| 3 | 4 | from pathlib import Path | |
| 5 | + | import shlex | |
| 4 | 6 | import subprocess | |
| 5 | 7 | import tempfile | |
| 6 | 8 | import unittest | |
| @@ -8,9 +10,12 @@ import unittest | |||
| 8 | 10 | ||
| 9 | 11 | ROOT = Path(__file__).resolve().parent | |
| 10 | 12 | INSTALLER = ROOT / 'install-question-tools.sh' | |
| 13 | + | HOOK_SCRIPT = ROOT / 'grilling-question-hook.py' | |
| 11 | 14 | POLICY = (ROOT / 'grilling-question-tools.md').read_bytes() | |
| 15 | + | HOOK = HOOK_SCRIPT.read_bytes() | |
| 12 | 16 | BEGIN = b'<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-TOOLS -->' | |
| 13 | 17 | END = b'<!-- END MATT-POCOCK-GRILLING-QUESTION-TOOLS -->' | |
| 18 | + | BASE = 'https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056' | |
| 14 | 19 | ||
| 15 | 20 | ||
| 16 | 21 | class InstallerTests(unittest.TestCase): | |
| @@ -24,23 +29,31 @@ class InstallerTests(unittest.TestCase): | |||
| 24 | 29 | self.bin.mkdir() | |
| 25 | 30 | self.fixture = self.root / 'policy.md' | |
| 26 | 31 | self.fixture.write_bytes(POLICY) | |
| 32 | + | self.hook_fixture = self.root / 'hook.py' | |
| 33 | + | self.hook_fixture.write_bytes(HOOK) | |
| 27 | 34 | curl = self.bin / 'curl' | |
| 28 | 35 | curl.write_text( | |
| 29 | 36 | '#!/usr/bin/env python3\n' | |
| 30 | 37 | 'import os, pathlib, sys\n' | |
| 31 | - | 'pathlib.Path(os.environ["CURL_LOG"]).write_text(sys.argv[-1])\n' | |
| 32 | - | 'if os.environ.get("CURL_FAIL"): sys.exit(22)\n' | |
| 33 | - | 'sys.stdout.buffer.write(pathlib.Path(os.environ["POLICY_FIXTURE"]).read_bytes())\n' | |
| 38 | + | 'url = sys.argv[-1]\n' | |
| 39 | + | 'with open(os.environ["CURL_LOG"], "a") as log: log.write(url + "\\n")\n' | |
| 40 | + | 'if os.environ.get("CURL_FAIL") and url.endswith(os.environ["CURL_FAIL"]): sys.exit(22)\n' | |
| 41 | + | 'name = "HOOK_FIXTURE" if url.endswith(".py") else "POLICY_FIXTURE"\n' | |
| 42 | + | 'sys.stdout.buffer.write(pathlib.Path(os.environ[name]).read_bytes())\n' | |
| 34 | 43 | ) | |
| 35 | 44 | curl.chmod(0o700) | |
| 36 | 45 | self.env = {key: value for key, value in os.environ.items() if key not in ( | |
| 37 | - | 'HOME', 'XDG_CONFIG_HOME', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME', | |
| 38 | - | 'OPENCODE_CONFIG_DIR', 'QUESTION_TOOLS_POLICY_DIR', 'QUESTION_TOOLS_REVISION')} | |
| 46 | + | 'HOME', 'CLAUDE_CONFIG_DIR', 'QUESTION_TOOLS_REVISION', 'QUESTION_TOOLS_CLAUDE_HOOK')} | |
| 39 | 47 | self.env.update(HOME=str(self.home), PATH=str(self.bin) + ':' + os.environ['PATH'], | |
| 40 | - | POLICY_FIXTURE=str(self.fixture), CURL_LOG=str(self.root / 'curl.log')) | |
| 41 | - | self.hosts = [self.home / '.claude/CLAUDE.md', self.home / '.codex/AGENTS.md', | |
| 42 | - | self.home / '.config/opencode/AGENTS.md'] | |
| 43 | - | self.policy = self.home / '.config/ai-agents/grilling-question-tools.md' | |
| 48 | + | POLICY_FIXTURE=str(self.fixture), HOOK_FIXTURE=str(self.hook_fixture), | |
| 49 | + | CURL_LOG=str(self.root / 'curl.log')) | |
| 50 | + | self.use_claude_dir(self.home / '.claude') | |
| 51 | + | ||
| 52 | + | def use_claude_dir(self, claude): | |
| 53 | + | self.claude_md = claude / 'CLAUDE.md' | |
| 54 | + | self.settings = claude / 'settings.json' | |
| 55 | + | self.policy = claude / 'grilling-question-tools/grilling-question-tools.md' | |
| 56 | + | self.hook = claude / 'grilling-question-tools/grilling-question-hook.py' | |
| 44 | 57 | ||
| 45 | 58 | def run_install(self, success=True): | |
| 46 | 59 | result = subprocess.run(['bash', str(INSTALLER)], env=self.env, | |
| @@ -48,10 +61,9 @@ class InstallerTests(unittest.TestCase): | |||
| 48 | 61 | self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) | |
| 49 | 62 | return result | |
| 50 | 63 | ||
| 51 | - | def write_hosts(self, data=b'Unrelated\x80 instructions\r\n'): | |
| 52 | - | for host in self.hosts: | |
| 53 | - | host.parent.mkdir(parents=True, exist_ok=True) | |
| 54 | - | host.write_bytes(data) | |
| 64 | + | def write_claude_md(self, data=b'Unrelated\x80 instructions\r\n'): | |
| 65 | + | self.claude_md.parent.mkdir(parents=True, exist_ok=True) | |
| 66 | + | self.claude_md.write_bytes(data) | |
| 55 | 67 | ||
| 56 | 68 | def snapshot(self): | |
| 57 | 69 | return {str(p.relative_to(self.home)): ( | |
| @@ -59,73 +71,166 @@ class InstallerTests(unittest.TestCase): | |||
| 59 | 71 | ('file', p.read_bytes(), p.stat().st_mode, p.stat().st_mtime_ns) if p.is_file() | |
| 60 | 72 | else ('directory',)) for p in self.home.rglob('*')} | |
| 61 | 73 | ||
| 62 | - | def test_regular_hosts_separate_policy_and_unchanged_rerun(self): | |
| 74 | + | def curl_urls(self): | |
| 75 | + | return (self.root / 'curl.log').read_text().splitlines() | |
| 76 | + | ||
| 77 | + | def managed_commands(self, settings=None): | |
| 78 | + | data = settings if settings is not None else json.loads(self.settings.read_text()) | |
| 79 | + | found = {} | |
| 80 | + | for event, groups in data.get('hooks', {}).items(): | |
| 81 | + | for group in groups: | |
| 82 | + | for hook in group.get('hooks', []): | |
| 83 | + | if 'grilling-question-hook.py' in hook.get('command', ''): | |
| 84 | + | found.setdefault(event, []).append((group.get('matcher'), hook['command'])) | |
| 85 | + | return found | |
| 86 | + | ||
| 87 | + | def run_hook(self, event): | |
| 88 | + | result = subprocess.run(['python3', str(HOOK_SCRIPT), str(self.fixture)], | |
| 89 | + | input=json.dumps(event), capture_output=True, text=True) | |
| 90 | + | self.assertEqual(result.returncode, 0, result.stderr) | |
| 91 | + | return json.loads(result.stdout) if result.stdout.strip() else None | |
| 92 | + | ||
| 93 | + | # Installer | |
| 94 | + | ||
| 95 | + | def test_install_and_unchanged_rerun(self): | |
| 63 | 96 | original = b'Unrelated\x80 instructions\r\n' | |
| 64 | - | self.write_hosts(original) | |
| 65 | - | self.hosts[0].chmod(0o640) | |
| 97 | + | self.write_claude_md(original) | |
| 98 | + | self.claude_md.chmod(0o640) | |
| 66 | 99 | self.run_install() | |
| 67 | 100 | self.assertEqual(self.policy.read_bytes(), POLICY) | |
| 68 | - | for host in self.hosts: | |
| 69 | - | content = host.read_bytes() | |
| 70 | - | self.assertTrue(content.startswith(original)) | |
| 71 | - | self.assertEqual(content.count(BEGIN), 1) | |
| 72 | - | self.assertIn(str(self.policy).encode(), content) | |
| 73 | - | self.assertNotIn(POLICY, content) | |
| 74 | - | backups = list(host.parent.glob(host.name + '.question-tools-backup-*')) | |
| 75 | - | self.assertEqual(len(backups), 1) | |
| 76 | - | self.assertEqual(backups[0].read_bytes(), original) | |
| 77 | - | self.assertEqual(backups[0].stat().st_mode & 0o777, 0o600) | |
| 78 | - | self.assertEqual(self.hosts[0].stat().st_mode & 0o777, 0o640) | |
| 101 | + | self.assertEqual(self.hook.read_bytes(), HOOK) | |
| 102 | + | content = self.claude_md.read_bytes() | |
| 103 | + | self.assertTrue(content.startswith(original)) | |
| 104 | + | self.assertEqual(content.count(BEGIN), 1) | |
| 105 | + | self.assertIn(str(self.policy).encode(), content) | |
| 106 | + | self.assertNotIn(POLICY, content) | |
| 107 | + | backups = list(self.claude_md.parent.glob('CLAUDE.md.question-tools-backup-*')) | |
| 108 | + | self.assertEqual(len(backups), 1) | |
| 109 | + | self.assertEqual(backups[0].read_bytes(), original) | |
| 110 | + | self.assertEqual(backups[0].stat().st_mode & 0o777, 0o600) | |
| 111 | + | self.assertEqual(self.claude_md.stat().st_mode & 0o777, 0o640) | |
| 112 | + | self.assertEqual(self.curl_urls(), [BASE + '/raw/HEAD/grilling-question-tools.md', | |
| 113 | + | BASE + '/raw/HEAD/grilling-question-hook.py']) | |
| 114 | + | # Only Claude Code files are touched. | |
| 115 | + | self.assertEqual(sorted(p.name for p in self.home.iterdir()), ['.claude']) | |
| 79 | 116 | before = self.snapshot() | |
| 80 | 117 | self.run_install() | |
| 81 | 118 | self.assertEqual(self.snapshot(), before) | |
| 82 | 119 | ||
| 83 | - | def test_shared_symlink_target_and_migration(self): | |
| 84 | - | target = self.home / 'shared.md' | |
| 120 | + | def test_hook_merged_into_existing_settings(self): | |
| 121 | + | existing = {'model': 'opus', 'hooks': {'PostToolUse': [ | |
| 122 | + | {'matcher': 'Bash', 'hooks': [{'type': 'command', 'command': 'echo mine'}]}]}} | |
| 123 | + | self.settings.parent.mkdir(parents=True) | |
| 124 | + | self.settings.write_text(json.dumps(existing)) | |
| 125 | + | self.run_install() | |
| 126 | + | data = json.loads(self.settings.read_text()) | |
| 127 | + | self.assertEqual(data['model'], 'opus') | |
| 128 | + | self.assertEqual(data['hooks']['PostToolUse'][0], existing['hooks']['PostToolUse'][0]) | |
| 129 | + | commands = self.managed_commands(data) | |
| 130 | + | self.assertEqual([m for m, _ in commands['PostToolUse']], ['Skill']) | |
| 131 | + | self.assertEqual([m for m, _ in commands['UserPromptSubmit']], [None]) | |
| 132 | + | self.assertEqual(shlex.split(commands['PostToolUse'][0][1]), | |
| 133 | + | ['python3', str(self.hook), str(self.policy)]) | |
| 134 | + | backups = list(self.settings.parent.glob('settings.json.question-tools-backup-*')) | |
| 135 | + | self.assertEqual(len(backups), 1) | |
| 136 | + | self.assertEqual(json.loads(backups[0].read_text()), existing) | |
| 137 | + | before = self.snapshot() | |
| 138 | + | self.run_install() | |
| 139 | + | self.assertEqual(self.snapshot(), before) | |
| 140 | + | ||
| 141 | + | def test_migrates_older_install(self): | |
| 142 | + | # An older version embedded the policy in CLAUDE.md and pointed the hook | |
| 143 | + | # at ~/.config/ai-agents. Both are replaced, not duplicated. | |
| 85 | 144 | prefix, suffix = b'Existing\xff\r\n\n', b'\r\nAfter\x80' | |
| 86 | - | original = prefix + BEGIN + b'\n' + POLICY + END + suffix | |
| 87 | - | target.write_bytes(original) | |
| 88 | - | for host in self.hosts: | |
| 89 | - | host.parent.mkdir(parents=True, exist_ok=True) | |
| 90 | - | host.symlink_to(os.path.relpath(target, host.parent)) | |
| 91 | - | links = [os.readlink(p) for p in self.hosts] | |
| 145 | + | self.write_claude_md(prefix + BEGIN + b'\nold embedded policy\n' + END + suffix) | |
| 146 | + | old_command = "python3 '/old/ai-agents/grilling-question-hook.py' '/old/ai-agents/grilling-question-tools.md'" | |
| 147 | + | self.settings.write_text(json.dumps({'hooks': {'PostToolUse': [ | |
| 148 | + | {'matcher': 'Skill', 'hooks': [{'type': 'command', 'command': old_command, 'timeout': 10}]}]}})) | |
| 92 | 149 | self.run_install() | |
| 93 | - | content = target.read_bytes() | |
| 150 | + | content = self.claude_md.read_bytes() | |
| 94 | 151 | self.assertTrue(content.startswith(prefix + BEGIN)) | |
| 95 | 152 | self.assertTrue(content.endswith(END + suffix)) | |
| 96 | 153 | self.assertEqual(content.count(BEGIN), 1) | |
| 97 | - | self.assertNotIn(POLICY, content) | |
| 98 | - | self.assertEqual([os.readlink(p) for p in self.hosts], links) | |
| 99 | - | backups = list(self.home.rglob('*.question-tools-backup-*')) | |
| 100 | - | self.assertEqual(len(backups), 1) | |
| 101 | - | self.assertEqual(backups[0].read_bytes(), original) | |
| 102 | - | before = self.snapshot() | |
| 154 | + | self.assertNotIn(b'old embedded policy', content) | |
| 155 | + | commands = self.managed_commands() | |
| 156 | + | self.assertEqual({event: len(entries) for event, entries in commands.items()}, | |
| 157 | + | {'PostToolUse': 1, 'UserPromptSubmit': 1}) | |
| 158 | + | self.assertNotIn('/old/', json.dumps(commands)) | |
| 159 | + | ||
| 160 | + | def test_symlinked_claude_md_and_directory(self): | |
| 161 | + | dotfiles = self.home / 'dotfiles' | |
| 162 | + | (dotfiles / 'claude').mkdir(parents=True) | |
| 163 | + | (dotfiles / 'claude/CLAUDE.md').write_bytes(b'Mine\n') | |
| 164 | + | (self.home / '.claude').symlink_to(dotfiles / 'claude', target_is_directory=True) | |
| 103 | 165 | self.run_install() | |
| 104 | - | self.assertEqual(self.snapshot(), before) | |
| 166 | + | self.assertTrue((self.home / '.claude').is_symlink()) | |
| 167 | + | self.assertEqual((dotfiles / 'claude/CLAUDE.md').read_bytes().count(BEGIN), 1) | |
| 168 | + | self.assertEqual((dotfiles / 'claude/grilling-question-tools/grilling-question-tools.md').read_bytes(), POLICY) | |
| 169 | + | ||
| 170 | + | def test_claude_config_dir_with_space_and_revision(self): | |
| 171 | + | self.env['CLAUDE_CONFIG_DIR'] = str(self.home / 'claude config') | |
| 172 | + | self.env['QUESTION_TOOLS_REVISION'] = 'a' * 40 | |
| 173 | + | self.use_claude_dir(self.home / 'claude config') | |
| 174 | + | self.run_install() | |
| 175 | + | self.assertEqual(self.policy.read_bytes(), POLICY) | |
| 176 | + | self.assertIn(str(self.policy).encode(), self.claude_md.read_bytes()) | |
| 177 | + | self.assertEqual(shlex.split(self.managed_commands()['PostToolUse'][0][1]), | |
| 178 | + | ['python3', str(self.hook), str(self.policy)]) | |
| 179 | + | self.assertEqual(self.curl_urls(), [BASE + '/raw/' + 'a' * 40 + '/grilling-question-tools.md', | |
| 180 | + | BASE + '/raw/' + 'a' * 40 + '/grilling-question-hook.py']) | |
| 181 | + | self.assertFalse((self.home / '.claude').exists()) | |
| 182 | + | self.env['QUESTION_TOOLS_REVISION'] = 'HEAD~1' | |
| 183 | + | self.run_install(False) | |
| 184 | + | ||
| 185 | + | def test_hook_opt_out_removes_only_managed_entries(self): | |
| 186 | + | self.run_install() | |
| 187 | + | self.assertTrue(self.managed_commands()) | |
| 188 | + | self.env['QUESTION_TOOLS_CLAUDE_HOOK'] = '0' | |
| 189 | + | self.run_install() | |
| 190 | + | self.assertEqual(self.managed_commands(), {}) | |
| 191 | + | self.assertEqual(json.loads(self.settings.read_text()), {'hooks': {}}) | |
| 192 | + | self.assertEqual(self.curl_urls()[2:], [BASE + '/raw/HEAD/grilling-question-tools.md']) | |
| 193 | + | self.assertEqual(self.claude_md.read_bytes().count(BEGIN), 1) | |
| 194 | + | self.env['QUESTION_TOOLS_CLAUDE_HOOK'] = 'yes' | |
| 195 | + | self.run_install(False) | |
| 105 | 196 | ||
| 106 | 197 | def test_malformed_blocks_preflight_no_writes(self): | |
| 107 | 198 | for malformed in (BEGIN, END + b'\n' + BEGIN, BEGIN + b'\nx\n' + END + END, | |
| 108 | 199 | b'prefix' + BEGIN + b'\nx\n' + END, BEGIN + b'x\n' + END): | |
| 109 | 200 | with self.subTest(malformed=malformed): | |
| 110 | - | self.write_hosts() | |
| 111 | - | self.hosts[-1].write_bytes(malformed) | |
| 201 | + | self.write_claude_md(malformed) | |
| 202 | + | before = self.snapshot() | |
| 203 | + | self.run_install(False) | |
| 204 | + | self.assertEqual(self.snapshot(), before) | |
| 205 | + | ||
| 206 | + | def test_invalid_settings_abort_before_writes(self): | |
| 207 | + | for bad in (b'{not json', b'[]', b'{"hooks": []}', b'{"hooks": {"Stop": {}}}'): | |
| 208 | + | with self.subTest(bad=bad): | |
| 209 | + | self.write_claude_md() | |
| 210 | + | self.settings.write_bytes(bad) | |
| 112 | 211 | before = self.snapshot() | |
| 113 | 212 | self.run_install(False) | |
| 114 | 213 | self.assertEqual(self.snapshot(), before) | |
| 115 | 214 | ||
| 116 | - | def test_download_failure_and_invalid_policy_no_writes(self): | |
| 117 | - | self.write_hosts() | |
| 215 | + | def test_download_failure_and_invalid_files_no_writes(self): | |
| 216 | + | self.write_claude_md() | |
| 118 | 217 | before = self.snapshot() | |
| 119 | - | self.env['CURL_FAIL'] = '1' | |
| 218 | + | for failing in ('.md', '.py'): | |
| 219 | + | with self.subTest(failing=failing): | |
| 220 | + | self.env['CURL_FAIL'] = failing | |
| 221 | + | self.run_install(False) | |
| 222 | + | self.assertEqual(self.snapshot(), before) | |
| 223 | + | del self.env['CURL_FAIL'] | |
| 224 | + | self.hook_fixture.write_bytes(HOOK[:len(HOOK) // 2]) | |
| 120 | 225 | self.run_install(False) | |
| 121 | 226 | self.assertEqual(self.snapshot(), before) | |
| 122 | - | del self.env['CURL_FAIL'] | |
| 227 | + | self.hook_fixture.write_bytes(HOOK) | |
| 123 | 228 | self.fixture.write_bytes(b'incomplete') | |
| 124 | 229 | self.run_install(False) | |
| 125 | 230 | self.assertEqual(self.snapshot(), before) | |
| 126 | 231 | ||
| 127 | 232 | def test_dangling_file_and_directory_symlinks(self): | |
| 128 | - | for path in (self.hosts[-1], self.hosts[-1].parent): | |
| 233 | + | for path in (self.claude_md, self.claude_md.parent): | |
| 129 | 234 | with self.subTest(path=path): | |
| 130 | 235 | path.parent.mkdir(parents=True, exist_ok=True) | |
| 131 | 236 | path.symlink_to(self.home / 'missing') | |
| @@ -133,57 +238,65 @@ class InstallerTests(unittest.TestCase): | |||
| 133 | 238 | self.run_install(False) | |
| 134 | 239 | self.assertEqual(self.snapshot(), before) | |
| 135 | 240 | path.unlink() | |
| 136 | - | if path == self.hosts[-1]: | |
| 241 | + | if path == self.claude_md: | |
| 137 | 242 | path.parent.rmdir() | |
| 138 | 243 | ||
| 139 | 244 | def test_nonregular_destination(self): | |
| 140 | - | self.write_hosts() | |
| 141 | - | self.hosts[-1].unlink() | |
| 142 | - | os.mkfifo(self.hosts[-1]) | |
| 245 | + | self.claude_md.parent.mkdir(parents=True) | |
| 246 | + | os.mkfifo(self.claude_md) | |
| 143 | 247 | self.run_install(False) | |
| 144 | 248 | self.assertFalse(self.policy.exists()) | |
| 145 | 249 | self.assertEqual(list(self.home.rglob('*.question-tools-backup-*')), []) | |
| 146 | 250 | ||
| 147 | 251 | @unittest.skipIf(os.geteuid() == 0, 'root bypasses filesystem permissions') | |
| 148 | 252 | def test_permission_failure_preflight(self): | |
| 149 | - | self.write_hosts() | |
| 150 | - | self.hosts[-1].chmod(0o400) | |
| 253 | + | self.write_claude_md() | |
| 254 | + | self.claude_md.chmod(0o400) | |
| 151 | 255 | before = self.snapshot() | |
| 152 | 256 | self.run_install(False) | |
| 153 | 257 | self.assertEqual(self.snapshot(), before) | |
| 154 | 258 | ||
| 155 | - | def test_overrides_revision_and_directory_symlink(self): | |
| 156 | - | config = self.home / 'config space' | |
| 157 | - | config.mkdir() | |
| 158 | - | (self.home / 'linked').symlink_to(config, target_is_directory=True) | |
| 159 | - | self.env['XDG_CONFIG_HOME'] = str(self.home / 'linked') | |
| 160 | - | self.env['QUESTION_TOOLS_REVISION'] = 'a' * 40 | |
| 161 | - | self.run_install() | |
| 162 | - | policy = config / 'ai-agents/grilling-question-tools.md' | |
| 163 | - | self.assertEqual(policy.read_bytes(), POLICY) | |
| 164 | - | self.assertTrue((self.home / 'linked').is_symlink()) | |
| 165 | - | self.assertIn(str(policy).encode(), self.hosts[0].read_bytes()) | |
| 166 | - | self.assertTrue((self.root / 'curl.log').read_text().endswith('/raw/' + 'a' * 40 + '/grilling-question-tools.md')) | |
| 167 | - | self.env['QUESTION_TOOLS_POLICY_DIR'] = str(self.home / 'custom policy') | |
| 168 | - | self.run_install() | |
| 169 | - | self.assertEqual((self.home / 'custom policy/grilling-question-tools.md').read_bytes(), POLICY) | |
| 170 | - | ||
| 171 | - | def test_policy_cannot_alias_host(self): | |
| 172 | - | self.write_hosts() | |
| 173 | - | self.policy.parent.mkdir(parents=True, exist_ok=True) | |
| 174 | - | self.policy.symlink_to(self.hosts[0]) | |
| 259 | + | def test_policy_cannot_alias_claude_md(self): | |
| 260 | + | self.write_claude_md() | |
| 261 | + | self.policy.parent.mkdir(parents=True) | |
| 262 | + | self.policy.symlink_to(self.claude_md) | |
| 175 | 263 | before = self.snapshot() | |
| 176 | 264 | self.run_install(False) | |
| 177 | 265 | self.assertEqual(self.snapshot(), before) | |
| 178 | 266 | ||
| 179 | - | def test_eli5_is_unchanged_and_not_an_input(self): | |
| 180 | - | eli5 = ROOT / 'ELI5.mdx' | |
| 181 | - | before = eli5.read_bytes() | |
| 182 | - | self.run_install() | |
| 183 | - | self.assertEqual(eli5.read_bytes(), before) | |
| 184 | - | self.assertNotIn('ELI5', INSTALLER.read_text()) | |
| 185 | - | self.assertEqual((self.root / 'curl.log').read_text(), | |
| 186 | - | 'https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/grilling-question-tools.md') | |
| 267 | + | # Hook | |
| 268 | + | ||
| 269 | + | def test_hook_triggers_only_for_grilling(self): | |
| 270 | + | post = {'hook_event_name': 'PostToolUse', 'tool_name': 'Skill'} | |
| 271 | + | for skill in ('grilling', 'grill-me', 'mattpocock-skills:grilling', 'grill-with-docs', 'loop-me'): | |
| 272 | + | with self.subTest(skill=skill): | |
| 273 | + | out = self.run_hook(dict(post, tool_input={'skill': skill})) | |
| 274 | + | self.assertEqual(out['hookSpecificOutput']['hookEventName'], 'PostToolUse') | |
| 275 | + | context = out['hookSpecificOutput']['additionalContext'] | |
| 276 | + | self.assertIn('AskUserQuestion', context) | |
| 277 | + | self.assertIn(POLICY.decode().strip(), context) | |
| 278 | + | prompt = {'hook_event_name': 'UserPromptSubmit'} | |
| 279 | + | for text in ('/loop-me', ' /grilling my plan', '/mattpocock-skills:grilling x'): | |
| 280 | + | with self.subTest(text=text): | |
| 281 | + | out = self.run_hook(dict(prompt, prompt=text)) | |
| 282 | + | self.assertEqual(out['hookSpecificOutput']['hookEventName'], 'UserPromptSubmit') | |
| 283 | + | for event in (dict(post, tool_input={'skill': 'tdd'}), | |
| 284 | + | dict(post, tool_name='Bash', tool_input={'command': 'grilling'}), | |
| 285 | + | dict(prompt, prompt='please grill me'), dict(prompt, prompt='/grilling-extra'), | |
| 286 | + | dict(prompt, prompt='/tdd /grilling'), | |
| 287 | + | # Covered by the Skill call these make; avoids double injection. | |
| 288 | + | dict(prompt, prompt='/grill-me'), dict(prompt, prompt='/grill-with-docs')): | |
| 289 | + | with self.subTest(event=event): | |
| 290 | + | self.assertIsNone(self.run_hook(event)) | |
| 291 | + | ||
| 292 | + | def test_hook_never_fails_the_session(self): | |
| 293 | + | for stdin, policy in (('not json', self.fixture), ('[]', self.fixture), | |
| 294 | + | (json.dumps({'hook_event_name': 'UserPromptSubmit', 'prompt': '/grilling'}), | |
| 295 | + | self.root / 'missing.md')): | |
| 296 | + | with self.subTest(stdin=stdin, policy=policy): | |
| 297 | + | result = subprocess.run(['python3', str(HOOK_SCRIPT), str(policy)], | |
| 298 | + | input=stdin, capture_output=True, text=True) | |
| 299 | + | self.assertEqual((result.returncode, result.stdout), (0, '')) | |
| 187 | 300 | ||
| 188 | 301 | ||
| 189 | 302 | if __name__ == '__main__': | |
Vernon Wee Hong KOH zrewidował ten Gist 1 month ago. Przejdź do rewizji
3 files changed, 268 insertions, 18 deletions
README.md
| @@ -44,13 +44,25 @@ The revision variable controls the policy download, not the installer download. | |||
| 44 | 44 | - Run as your normal user, without `sudo`, and with no concurrent installer or | |
| 45 | 45 | editor changing the destination files. Native Windows shells are not supported. | |
| 46 | 46 | - This installs instructions only, not the Matt Pocock skills, tools, plugins, | |
| 47 | - | or permissions. Install your desired grilling skills separately. | |
| 47 | + | or permissions. Install your desired grilling skills separately; upstream | |
| 48 | + | skill files are untouched. | |
| 48 | 49 | - Only Matt Pocock grilling flows use this preference. The policy retains the | |
| 49 | 50 | flow's pacing and recommendations, custom answers, unresolved decisions, | |
| 50 | 51 | and text fallback when the native question tool is unavailable or restricted. | |
| 51 | 52 | - This is **not runtime enforcement**. Host permissions, mode restrictions, | |
| 52 | 53 | instruction precedence, and agent instruction-loading limits still apply. | |
| 53 | 54 | ||
| 55 | + | To install or overwrite the upstream skills globally for all three hosts: | |
| 56 | + | ||
| 57 | + | ```bash | |
| 58 | + | npx --yes skills@latest add mattpocock/skills --global --agent claude-code codex opencode --skill '*' --yes | |
| 59 | + | ``` | |
| 60 | + | ||
| 61 | + | This selects every skill, including in-progress and miscellaneous skills. Back | |
| 62 | + | up local skill edits before overwriting them. Keep custom preferences outside | |
| 63 | + | the skill directories, and avoid installing the Claude plugin alongside these | |
| 64 | + | files because that can expose duplicate skills. | |
| 65 | + | ||
| 54 | 66 | ## Destinations | |
| 55 | 67 | ||
| 56 | 68 | All three destinations are installed, even if a corresponding CLI is not yet | |
| @@ -63,6 +75,14 @@ inherit them. Empty variables use defaults; paths must be absolute, without | |||
| 63 | 75 | | Claude Code | `~/.claude/CLAUDE.md` | `$CLAUDE_CONFIG_DIR/CLAUDE.md` | | |
| 64 | 76 | | Codex | `~/.codex/AGENTS.md` | `$CODEX_HOME/AGENTS.md` | | |
| 65 | 77 | | OpenCode | `~/.config/opencode/AGENTS.md` | `$OPENCODE_CONFIG_DIR/AGENTS.md`, otherwise `$XDG_CONFIG_HOME/opencode/AGENTS.md` | | |
| 78 | + | | Shared policy | `~/.config/ai-agents/grilling-question-tools.md` | `$QUESTION_TOOLS_POLICY_DIR/grilling-question-tools.md`, otherwise `$XDG_CONFIG_HOME/ai-agents/grilling-question-tools.md` | | |
| 79 | + | ||
| 80 | + | The downloaded authoritative policy is installed as a separate file. Each host | |
| 81 | + | gets only a small marked pointer instructing it to read the absolute policy path | |
| 82 | + | before grilling and prefer native question delivery over upstream text formatting, | |
| 83 | + | while preserving the rest of the workflow. For example, export | |
| 84 | + | `QUESTION_TOOLS_POLICY_DIR="$HOME/my agent policies"` to choose a policy directory. | |
| 85 | + | Paths containing newlines or backticks are rejected so the pointer stays unambiguous. | |
| 66 | 86 | ||
| 67 | 87 | `HOME` supplies `~`. Current OpenCode supports `OPENCODE_CONFIG_DIR` for global | |
| 68 | 88 | instructions; older versions may only read the XDG location. For an older | |
| @@ -78,10 +98,11 @@ review it if the new preference is not being loaded. | |||
| 78 | 98 | ||
| 79 | 99 | ## Updates and Safety | |
| 80 | 100 | ||
| 81 | - | Rerun the quick command to fetch the latest policy and replace its managed | |
| 82 | - | block. Identical reruns leave file bytes unchanged and create no new backups. | |
| 83 | - | The authoritative policy is `grilling-question-tools.md`; the installer embeds | |
| 84 | - | its downloaded content rather than maintaining another policy copy. | |
| 101 | + | Rerun the quick command to update the separate policy file and managed pointers. | |
| 102 | + | Existing managed blocks containing an embedded policy are migrated by replacing | |
| 103 | + | only that block with the pointer. Identical reruns leave files unchanged and | |
| 104 | + | create no new backups. The downloaded policy bytes are installed verbatim; | |
| 105 | + | the installer does not maintain an embedded policy copy. | |
| 85 | 106 | ||
| 86 | 107 | - Download errors or an invalid/incomplete policy abort before destination edits. | |
| 87 | 108 | Validation checks UTF-8, versioned start/end markers, title, and all seven | |
| @@ -95,10 +116,11 @@ its downloaded content rather than maintaining another policy copy. | |||
| 95 | 116 | - Each changed existing file gets a byte-for-byte sibling backup named | |
| 96 | 117 | `<filename>.question-tools-backup-<unique-suffix>` (private mode `0600`). New | |
| 97 | 118 | files need no backup. Paths are printed; previous backups are never replaced. | |
| 98 | - | - Symlinked files **and ancestor directories** are refused without following | |
| 99 | - | them. Use physical directory paths and regular files, or integrate the policy | |
| 100 | - | manually through your dotfile manager. The installer never unlinks symlinks. | |
| 101 | - | - Replacements are atomic per file, not a transaction across all three files. | |
| 119 | + | - Existing file and directory symlinks are resolved to physical targets and | |
| 120 | + | preserved. Hosts sharing a symlink target get one pointer and one backup beside | |
| 121 | + | the physical target, not three. Dangling/looping symlinks, nonregular files, | |
| 122 | + | and inaccessible destinations are rejected. The policy cannot share a host target. | |
| 123 | + | - Replacements are atomic per file, not a transaction across all destinations. | |
| 102 | 124 | Staging and backups precede replacements, but disk/permission errors during | |
| 103 | 125 | writing can leave directories/backups or a partial install. Inspect the | |
| 104 | 126 | printed paths, restore from backups if needed, and rerun after fixing the cause. | |
| @@ -107,8 +129,17 @@ its downloaded content rather than maintaining another policy copy. | |||
| 107 | 129 | ||
| 108 | 130 | After installation, start **fresh Claude Code and Codex sessions**, and **quit | |
| 109 | 131 | and restart OpenCode**. Ask the agent which instruction files it loaded if the | |
| 110 | - | preference is missing. To uninstall, remove only the managed block from each | |
| 111 | - | destination, or restore a backup if it will not erase newer unrelated edits. | |
| 132 | + | preference is missing. To uninstall, remove only the managed pointer block from | |
| 133 | + | each distinct host target, then remove the separate policy file if no pointers | |
| 134 | + | still use it. Restore a backup only if it will not erase newer unrelated edits. | |
| 135 | + | Changing the policy directory leaves the previous policy file in place; remove | |
| 136 | + | it manually once no pointers use it. | |
| 137 | + | ||
| 138 | + | ## Installer Tests | |
| 139 | + | ||
| 140 | + | Run `python3 -B -m unittest -v test_install_question_tools` from this directory. | |
| 141 | + | Tests use temporary homes and a mocked `curl`, with no network access or real | |
| 142 | + | home installation. Permission checks are skipped when running tests as root. | |
| 112 | 143 | ||
| 113 | 144 | ## Existing Style | |
| 114 | 145 | ||
install-question-tools.sh
| @@ -24,10 +24,23 @@ POLICY_END = '<!-- END MATT-POCOCK-GRILLING-QUESTION-POLICY v1 -->' | |||
| 24 | 24 | BASE = 'https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056' | |
| 25 | 25 | ||
| 26 | 26 | ||
| 27 | + | def resolve_target(path): | |
| 28 | + | # Resolve existing components strictly, but allow new files/directories. | |
| 29 | + | target = Path(path.anchor) | |
| 30 | + | for part in path.parts[1:]: | |
| 31 | + | target = target / part | |
| 32 | + | if target.is_symlink(): | |
| 33 | + | try: | |
| 34 | + | target = target.resolve(strict=True) | |
| 35 | + | except (OSError, RuntimeError) as error: | |
| 36 | + | raise ValueError('Dangling or unresolvable symlink: %s' % target) from error | |
| 37 | + | return target | |
| 38 | + | ||
| 39 | + | ||
| 27 | 40 | def check_path(path): | |
| 28 | 41 | for part in [path] + list(path.parents): | |
| 29 | 42 | if part.is_symlink(): | |
| 30 | - | raise ValueError('Symlink refused: %s. Use physical directories and a regular instruction file; manage linked files manually.' % part) | |
| 43 | + | raise ValueError('Path changed to a symlink during installation: %s' % part) | |
| 31 | 44 | if part.exists() and not (part.is_file() if part == path else part.is_dir()): | |
| 32 | 45 | raise ValueError('Not a regular file/directory: %s' % part) | |
| 33 | 46 | parent = path.parent | |
| @@ -58,27 +71,43 @@ def install(): | |||
| 58 | 71 | or not policy.startswith(POLICY_BEGIN + '\n# Question Tools for Matt Pocock Grilling Flows\n') | |
| 59 | 72 | or not all('\n%d. ' % i in policy for i in range(1, 8))): | |
| 60 | 73 | raise ValueError('Downloaded policy is incomplete or invalid; no instruction files changed.') | |
| 61 | - | block = BEGIN + b'\n' + policy.rstrip('\r\n').encode('utf-8') + b'\n' + END | |
| 62 | 74 | home = os.environ.get('HOME') | |
| 63 | 75 | if not home or not os.path.isabs(home): | |
| 64 | 76 | raise ValueError('HOME must be an absolute path.') | |
| 77 | + | config = os.environ.get('XDG_CONFIG_HOME') or home + '/.config' | |
| 78 | + | policy_dir = os.environ.get('QUESTION_TOOLS_POLICY_DIR') or config + '/ai-agents' | |
| 65 | 79 | directories = [ | |
| 66 | 80 | os.environ.get('CLAUDE_CONFIG_DIR') or home + '/.claude', | |
| 67 | 81 | os.environ.get('CODEX_HOME') or home + '/.codex', | |
| 68 | 82 | os.environ.get('OPENCODE_CONFIG_DIR') or | |
| 69 | - | (os.environ.get('XDG_CONFIG_HOME') or home + '/.config') + '/opencode', | |
| 83 | + | config + '/opencode', | |
| 70 | 84 | ] | |
| 71 | - | if any(not os.path.isabs(d) or '..' in Path(d).parts for d in directories): | |
| 85 | + | if any(not os.path.isabs(d) or '..' in Path(d).parts or any(c in d for c in '\r\n`') | |
| 86 | + | for d in directories + [policy_dir]): | |
| 72 | 87 | raise ValueError('Config directories must be absolute paths without .. components.') | |
| 73 | - | paths = list(dict.fromkeys(Path(d) / name for d, name in | |
| 88 | + | policy_path = resolve_target(Path(policy_dir) / 'grilling-question-tools.md') | |
| 89 | + | if any(c in str(policy_path) for c in '\r\n`'): | |
| 90 | + | raise ValueError('Policy path cannot contain newlines or backticks.') | |
| 91 | + | block = (BEGIN + b'\n' + ( | |
| 92 | + | 'Before running a Matt Pocock grilling flow (grill-me, grilling, grill-with-docs,\n' | |
| 93 | + | 'loop-me, or an interview delegated to one of them), read `%s`.\n' | |
| 94 | + | 'Use its native question delivery preference over upstream text formatting;\n' | |
| 95 | + | 'preserve all other workflow requirements.\n' % policy_path | |
| 96 | + | ).encode('utf-8') + END) | |
| 97 | + | paths = list(dict.fromkeys(resolve_target(Path(d) / name) for d, name in | |
| 74 | 98 | zip(directories, ['CLAUDE.md', 'AGENTS.md', 'AGENTS.md']))) | |
| 99 | + | if policy_path in paths or (policy_path.exists() and any( | |
| 100 | + | p.exists() and os.path.samefile(policy_path, p) for p in paths)): | |
| 101 | + | raise ValueError('Policy and instruction files must be separate files.') | |
| 75 | 102 | changes = [] | |
| 76 | 103 | # Preflight every destination before creating directories, backups, or files. | |
| 77 | - | for path in paths: | |
| 104 | + | for path in [policy_path] + paths: | |
| 78 | 105 | check_path(path) | |
| 79 | 106 | exists = path.exists() | |
| 80 | 107 | old = path.read_bytes() if exists else b'' | |
| 81 | - | if TOKEN in old: | |
| 108 | + | if path == policy_path: | |
| 109 | + | new = result.stdout | |
| 110 | + | elif TOKEN in old: | |
| 82 | 111 | if old.count(TOKEN) != 2 or old.count(BEGIN) != 1 or old.count(END) != 1: | |
| 83 | 112 | raise ValueError('Malformed/duplicate managed markers: %s. Repair them manually before rerunning.' % path) | |
| 84 | 113 | start, end = old.index(BEGIN), old.index(END) | |
test_install_question_tools.py(stworzono plik)
| @@ -0,0 +1,190 @@ | |||
| 1 | + | """Sandbox-only installer tests; curl never accesses the network.""" | |
| 2 | + | import os | |
| 3 | + | from pathlib import Path | |
| 4 | + | import subprocess | |
| 5 | + | import tempfile | |
| 6 | + | import unittest | |
| 7 | + | ||
| 8 | + | ||
| 9 | + | ROOT = Path(__file__).resolve().parent | |
| 10 | + | INSTALLER = ROOT / 'install-question-tools.sh' | |
| 11 | + | POLICY = (ROOT / 'grilling-question-tools.md').read_bytes() | |
| 12 | + | BEGIN = b'<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-TOOLS -->' | |
| 13 | + | END = b'<!-- END MATT-POCOCK-GRILLING-QUESTION-TOOLS -->' | |
| 14 | + | ||
| 15 | + | ||
| 16 | + | class InstallerTests(unittest.TestCase): | |
| 17 | + | def setUp(self): | |
| 18 | + | self.temp = tempfile.TemporaryDirectory(prefix='question-tools-test-') | |
| 19 | + | self.addCleanup(self.temp.cleanup) | |
| 20 | + | self.root = Path(self.temp.name) | |
| 21 | + | self.home = self.root / 'home' | |
| 22 | + | self.home.mkdir() | |
| 23 | + | self.bin = self.root / 'bin' | |
| 24 | + | self.bin.mkdir() | |
| 25 | + | self.fixture = self.root / 'policy.md' | |
| 26 | + | self.fixture.write_bytes(POLICY) | |
| 27 | + | curl = self.bin / 'curl' | |
| 28 | + | curl.write_text( | |
| 29 | + | '#!/usr/bin/env python3\n' | |
| 30 | + | 'import os, pathlib, sys\n' | |
| 31 | + | 'pathlib.Path(os.environ["CURL_LOG"]).write_text(sys.argv[-1])\n' | |
| 32 | + | 'if os.environ.get("CURL_FAIL"): sys.exit(22)\n' | |
| 33 | + | 'sys.stdout.buffer.write(pathlib.Path(os.environ["POLICY_FIXTURE"]).read_bytes())\n' | |
| 34 | + | ) | |
| 35 | + | curl.chmod(0o700) | |
| 36 | + | self.env = {key: value for key, value in os.environ.items() if key not in ( | |
| 37 | + | 'HOME', 'XDG_CONFIG_HOME', 'CLAUDE_CONFIG_DIR', 'CODEX_HOME', | |
| 38 | + | 'OPENCODE_CONFIG_DIR', 'QUESTION_TOOLS_POLICY_DIR', 'QUESTION_TOOLS_REVISION')} | |
| 39 | + | self.env.update(HOME=str(self.home), PATH=str(self.bin) + ':' + os.environ['PATH'], | |
| 40 | + | POLICY_FIXTURE=str(self.fixture), CURL_LOG=str(self.root / 'curl.log')) | |
| 41 | + | self.hosts = [self.home / '.claude/CLAUDE.md', self.home / '.codex/AGENTS.md', | |
| 42 | + | self.home / '.config/opencode/AGENTS.md'] | |
| 43 | + | self.policy = self.home / '.config/ai-agents/grilling-question-tools.md' | |
| 44 | + | ||
| 45 | + | def run_install(self, success=True): | |
| 46 | + | result = subprocess.run(['bash', str(INSTALLER)], env=self.env, | |
| 47 | + | cwd=self.root, capture_output=True, text=True) | |
| 48 | + | self.assertEqual(result.returncode == 0, success, result.stdout + result.stderr) | |
| 49 | + | return result | |
| 50 | + | ||
| 51 | + | def write_hosts(self, data=b'Unrelated\x80 instructions\r\n'): | |
| 52 | + | for host in self.hosts: | |
| 53 | + | host.parent.mkdir(parents=True, exist_ok=True) | |
| 54 | + | host.write_bytes(data) | |
| 55 | + | ||
| 56 | + | def snapshot(self): | |
| 57 | + | return {str(p.relative_to(self.home)): ( | |
| 58 | + | ('link', os.readlink(p)) if p.is_symlink() else | |
| 59 | + | ('file', p.read_bytes(), p.stat().st_mode, p.stat().st_mtime_ns) if p.is_file() | |
| 60 | + | else ('directory',)) for p in self.home.rglob('*')} | |
| 61 | + | ||
| 62 | + | def test_regular_hosts_separate_policy_and_unchanged_rerun(self): | |
| 63 | + | original = b'Unrelated\x80 instructions\r\n' | |
| 64 | + | self.write_hosts(original) | |
| 65 | + | self.hosts[0].chmod(0o640) | |
| 66 | + | self.run_install() | |
| 67 | + | self.assertEqual(self.policy.read_bytes(), POLICY) | |
| 68 | + | for host in self.hosts: | |
| 69 | + | content = host.read_bytes() | |
| 70 | + | self.assertTrue(content.startswith(original)) | |
| 71 | + | self.assertEqual(content.count(BEGIN), 1) | |
| 72 | + | self.assertIn(str(self.policy).encode(), content) | |
| 73 | + | self.assertNotIn(POLICY, content) | |
| 74 | + | backups = list(host.parent.glob(host.name + '.question-tools-backup-*')) | |
| 75 | + | self.assertEqual(len(backups), 1) | |
| 76 | + | self.assertEqual(backups[0].read_bytes(), original) | |
| 77 | + | self.assertEqual(backups[0].stat().st_mode & 0o777, 0o600) | |
| 78 | + | self.assertEqual(self.hosts[0].stat().st_mode & 0o777, 0o640) | |
| 79 | + | before = self.snapshot() | |
| 80 | + | self.run_install() | |
| 81 | + | self.assertEqual(self.snapshot(), before) | |
| 82 | + | ||
| 83 | + | def test_shared_symlink_target_and_migration(self): | |
| 84 | + | target = self.home / 'shared.md' | |
| 85 | + | prefix, suffix = b'Existing\xff\r\n\n', b'\r\nAfter\x80' | |
| 86 | + | original = prefix + BEGIN + b'\n' + POLICY + END + suffix | |
| 87 | + | target.write_bytes(original) | |
| 88 | + | for host in self.hosts: | |
| 89 | + | host.parent.mkdir(parents=True, exist_ok=True) | |
| 90 | + | host.symlink_to(os.path.relpath(target, host.parent)) | |
| 91 | + | links = [os.readlink(p) for p in self.hosts] | |
| 92 | + | self.run_install() | |
| 93 | + | content = target.read_bytes() | |
| 94 | + | self.assertTrue(content.startswith(prefix + BEGIN)) | |
| 95 | + | self.assertTrue(content.endswith(END + suffix)) | |
| 96 | + | self.assertEqual(content.count(BEGIN), 1) | |
| 97 | + | self.assertNotIn(POLICY, content) | |
| 98 | + | self.assertEqual([os.readlink(p) for p in self.hosts], links) | |
| 99 | + | backups = list(self.home.rglob('*.question-tools-backup-*')) | |
| 100 | + | self.assertEqual(len(backups), 1) | |
| 101 | + | self.assertEqual(backups[0].read_bytes(), original) | |
| 102 | + | before = self.snapshot() | |
| 103 | + | self.run_install() | |
| 104 | + | self.assertEqual(self.snapshot(), before) | |
| 105 | + | ||
| 106 | + | def test_malformed_blocks_preflight_no_writes(self): | |
| 107 | + | for malformed in (BEGIN, END + b'\n' + BEGIN, BEGIN + b'\nx\n' + END + END, | |
| 108 | + | b'prefix' + BEGIN + b'\nx\n' + END, BEGIN + b'x\n' + END): | |
| 109 | + | with self.subTest(malformed=malformed): | |
| 110 | + | self.write_hosts() | |
| 111 | + | self.hosts[-1].write_bytes(malformed) | |
| 112 | + | before = self.snapshot() | |
| 113 | + | self.run_install(False) | |
| 114 | + | self.assertEqual(self.snapshot(), before) | |
| 115 | + | ||
| 116 | + | def test_download_failure_and_invalid_policy_no_writes(self): | |
| 117 | + | self.write_hosts() | |
| 118 | + | before = self.snapshot() | |
| 119 | + | self.env['CURL_FAIL'] = '1' | |
| 120 | + | self.run_install(False) | |
| 121 | + | self.assertEqual(self.snapshot(), before) | |
| 122 | + | del self.env['CURL_FAIL'] | |
| 123 | + | self.fixture.write_bytes(b'incomplete') | |
| 124 | + | self.run_install(False) | |
| 125 | + | self.assertEqual(self.snapshot(), before) | |
| 126 | + | ||
| 127 | + | def test_dangling_file_and_directory_symlinks(self): | |
| 128 | + | for path in (self.hosts[-1], self.hosts[-1].parent): | |
| 129 | + | with self.subTest(path=path): | |
| 130 | + | path.parent.mkdir(parents=True, exist_ok=True) | |
| 131 | + | path.symlink_to(self.home / 'missing') | |
| 132 | + | before = self.snapshot() | |
| 133 | + | self.run_install(False) | |
| 134 | + | self.assertEqual(self.snapshot(), before) | |
| 135 | + | path.unlink() | |
| 136 | + | if path == self.hosts[-1]: | |
| 137 | + | path.parent.rmdir() | |
| 138 | + | ||
| 139 | + | def test_nonregular_destination(self): | |
| 140 | + | self.write_hosts() | |
| 141 | + | self.hosts[-1].unlink() | |
| 142 | + | os.mkfifo(self.hosts[-1]) | |
| 143 | + | self.run_install(False) | |
| 144 | + | self.assertFalse(self.policy.exists()) | |
| 145 | + | self.assertEqual(list(self.home.rglob('*.question-tools-backup-*')), []) | |
| 146 | + | ||
| 147 | + | @unittest.skipIf(os.geteuid() == 0, 'root bypasses filesystem permissions') | |
| 148 | + | def test_permission_failure_preflight(self): | |
| 149 | + | self.write_hosts() | |
| 150 | + | self.hosts[-1].chmod(0o400) | |
| 151 | + | before = self.snapshot() | |
| 152 | + | self.run_install(False) | |
| 153 | + | self.assertEqual(self.snapshot(), before) | |
| 154 | + | ||
| 155 | + | def test_overrides_revision_and_directory_symlink(self): | |
| 156 | + | config = self.home / 'config space' | |
| 157 | + | config.mkdir() | |
| 158 | + | (self.home / 'linked').symlink_to(config, target_is_directory=True) | |
| 159 | + | self.env['XDG_CONFIG_HOME'] = str(self.home / 'linked') | |
| 160 | + | self.env['QUESTION_TOOLS_REVISION'] = 'a' * 40 | |
| 161 | + | self.run_install() | |
| 162 | + | policy = config / 'ai-agents/grilling-question-tools.md' | |
| 163 | + | self.assertEqual(policy.read_bytes(), POLICY) | |
| 164 | + | self.assertTrue((self.home / 'linked').is_symlink()) | |
| 165 | + | self.assertIn(str(policy).encode(), self.hosts[0].read_bytes()) | |
| 166 | + | self.assertTrue((self.root / 'curl.log').read_text().endswith('/raw/' + 'a' * 40 + '/grilling-question-tools.md')) | |
| 167 | + | self.env['QUESTION_TOOLS_POLICY_DIR'] = str(self.home / 'custom policy') | |
| 168 | + | self.run_install() | |
| 169 | + | self.assertEqual((self.home / 'custom policy/grilling-question-tools.md').read_bytes(), POLICY) | |
| 170 | + | ||
| 171 | + | def test_policy_cannot_alias_host(self): | |
| 172 | + | self.write_hosts() | |
| 173 | + | self.policy.parent.mkdir(parents=True, exist_ok=True) | |
| 174 | + | self.policy.symlink_to(self.hosts[0]) | |
| 175 | + | before = self.snapshot() | |
| 176 | + | self.run_install(False) | |
| 177 | + | self.assertEqual(self.snapshot(), before) | |
| 178 | + | ||
| 179 | + | def test_eli5_is_unchanged_and_not_an_input(self): | |
| 180 | + | eli5 = ROOT / 'ELI5.mdx' | |
| 181 | + | before = eli5.read_bytes() | |
| 182 | + | self.run_install() | |
| 183 | + | self.assertEqual(eli5.read_bytes(), before) | |
| 184 | + | self.assertNotIn('ELI5', INSTALLER.read_text()) | |
| 185 | + | self.assertEqual((self.root / 'curl.log').read_text(), | |
| 186 | + | 'https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/grilling-question-tools.md') | |
| 187 | + | ||
| 188 | + | ||
| 189 | + | if __name__ == '__main__': | |
| 190 | + | unittest.main() | |
Vernon Wee Hong KOH zrewidował ten Gist 1 month ago. Przejdź do rewizji
3 files changed, 304 insertions
README.md(stworzono plik)
| @@ -0,0 +1,117 @@ | |||
| 1 | + | # AI Coding Agent Skill Collection | |
| 2 | + | ||
| 3 | + | ## Quick Install: Grilling Question Tools | |
| 4 | + | ||
| 5 | + | Install or update the question-tool preference for **Claude Code, Codex, and | |
| 6 | + | OpenCode** in one command: | |
| 7 | + | ||
| 8 | + | ```bash | |
| 9 | + | bash -o pipefail -c 'curl -fsSL https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/install-question-tools.sh | bash' | |
| 10 | + | ``` | |
| 11 | + | ||
| 12 | + | **Trust check:** this runs remote code and downloads remote instructions from | |
| 13 | + | this gist. Read both before running if you do not already trust the publisher | |
| 14 | + | and server. `pipefail` reports download failures; it is not a security sandbox | |
| 15 | + | or a guarantee against executing partially downloaded shell code. | |
| 16 | + | ||
| 17 | + | Quick links: [installer](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/install-question-tools.sh) | |
| 18 | + | | [authoritative policy](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/grilling-question-tools.md) | |
| 19 | + | | [ELI5 writing style](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/ELI5.mdx) | |
| 20 | + | ||
| 21 | + | ### Download and Inspect Instead | |
| 22 | + | ||
| 23 | + | Run in a scratch directory. These downloads alone do not install anything: | |
| 24 | + | ||
| 25 | + | ```bash | |
| 26 | + | base=https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056 | |
| 27 | + | curl -fsSL "$base/raw/HEAD/install-question-tools.sh" -o install-question-tools.sh | |
| 28 | + | curl -fsSL "$base/raw/HEAD/grilling-question-tools.md" -o grilling-question-tools.md | |
| 29 | + | less install-question-tools.sh grilling-question-tools.md | |
| 30 | + | # After inspection: | |
| 31 | + | bash install-question-tools.sh | |
| 32 | + | ``` | |
| 33 | + | ||
| 34 | + | By default the installer fetches the policy at `HEAD`, not the inspected local | |
| 35 | + | copy. To avoid changes between inspection and installation, get a full commit | |
| 36 | + | hash from the gist's [Revisions](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/revisions), | |
| 37 | + | replace `HEAD` in **both** download URLs with that hash, and run | |
| 38 | + | `QUESTION_TOOLS_REVISION=<same-full-40-character-hash> bash install-question-tools.sh`. | |
| 39 | + | The revision variable controls the policy download, not the installer download. | |
| 40 | + | ||
| 41 | + | ## Requirements and Scope | |
| 42 | + | ||
| 43 | + | - Linux, macOS, or WSL; Bash 3.2+, Python 3.8+, and `curl` with HTTPS support. | |
| 44 | + | - Run as your normal user, without `sudo`, and with no concurrent installer or | |
| 45 | + | editor changing the destination files. Native Windows shells are not supported. | |
| 46 | + | - This installs instructions only, not the Matt Pocock skills, tools, plugins, | |
| 47 | + | or permissions. Install your desired grilling skills separately. | |
| 48 | + | - Only Matt Pocock grilling flows use this preference. The policy retains the | |
| 49 | + | flow's pacing and recommendations, custom answers, unresolved decisions, | |
| 50 | + | and text fallback when the native question tool is unavailable or restricted. | |
| 51 | + | - This is **not runtime enforcement**. Host permissions, mode restrictions, | |
| 52 | + | instruction precedence, and agent instruction-loading limits still apply. | |
| 53 | + | ||
| 54 | + | ## Destinations | |
| 55 | + | ||
| 56 | + | All three destinations are installed, even if a corresponding CLI is not yet | |
| 57 | + | installed. Export overrides before running the quick command so both shells | |
| 58 | + | inherit them. Empty variables use defaults; paths must be absolute, without | |
| 59 | + | `..` components. Spaces are supported. | |
| 60 | + | ||
| 61 | + | | Agent | Default file | Override | | |
| 62 | + | | --- | --- | --- | | |
| 63 | + | | Claude Code | `~/.claude/CLAUDE.md` | `$CLAUDE_CONFIG_DIR/CLAUDE.md` | | |
| 64 | + | | Codex | `~/.codex/AGENTS.md` | `$CODEX_HOME/AGENTS.md` | | |
| 65 | + | | OpenCode | `~/.config/opencode/AGENTS.md` | `$OPENCODE_CONFIG_DIR/AGENTS.md`, otherwise `$XDG_CONFIG_HOME/opencode/AGENTS.md` | | |
| 66 | + | ||
| 67 | + | `HOME` supplies `~`. Current OpenCode supports `OPENCODE_CONFIG_DIR` for global | |
| 68 | + | instructions; older versions may only read the XDG location. For an older | |
| 69 | + | release, leave `OPENCODE_CONFIG_DIR` unset and use `XDG_CONFIG_HOME` instead. | |
| 70 | + | See [OpenCode instruction loading](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/instruction.ts) | |
| 71 | + | and [global paths](https://github.com/anomalyco/opencode/blob/dev/packages/core/src/global.ts). | |
| 72 | + | OpenCode normally prefers its own global `AGENTS.md` over the Claude fallback: | |
| 73 | + | creating this file can stop fallback loading of `~/.claude/CLAUDE.md`. If you | |
| 74 | + | rely on unrelated instructions in that fallback, explicitly configure them in | |
| 75 | + | OpenCode's `instructions` setting before installing. | |
| 76 | + | Codex's `AGENTS.override.md`, if present, can take precedence over `AGENTS.md`; | |
| 77 | + | review it if the new preference is not being loaded. | |
| 78 | + | ||
| 79 | + | ## Updates and Safety | |
| 80 | + | ||
| 81 | + | Rerun the quick command to fetch the latest policy and replace its managed | |
| 82 | + | block. Identical reruns leave file bytes unchanged and create no new backups. | |
| 83 | + | The authoritative policy is `grilling-question-tools.md`; the installer embeds | |
| 84 | + | its downloaded content rather than maintaining another policy copy. | |
| 85 | + | ||
| 86 | + | - Download errors or an invalid/incomplete policy abort before destination edits. | |
| 87 | + | Validation checks UTF-8, versioned start/end markers, title, and all seven | |
| 88 | + | numbered rules. These are integrity sanity checks, not a signature. | |
| 89 | + | - Every destination is preflighted before any directories, backups, or files | |
| 90 | + | are created. Malformed or duplicate managed markers abort the entire run. | |
| 91 | + | - The unique HTML markers are `<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-TOOLS -->` | |
| 92 | + | and `<!-- END MATT-POCOCK-GRILLING-QUESTION-TOOLS -->`. Keep them intact. | |
| 93 | + | - Bytes outside an existing managed block are preserved exactly. A first | |
| 94 | + | installation appends a separator and block without changing existing bytes. | |
| 95 | + | - Each changed existing file gets a byte-for-byte sibling backup named | |
| 96 | + | `<filename>.question-tools-backup-<unique-suffix>` (private mode `0600`). New | |
| 97 | + | files need no backup. Paths are printed; previous backups are never replaced. | |
| 98 | + | - Symlinked files **and ancestor directories** are refused without following | |
| 99 | + | them. Use physical directory paths and regular files, or integrate the policy | |
| 100 | + | manually through your dotfile manager. The installer never unlinks symlinks. | |
| 101 | + | - Replacements are atomic per file, not a transaction across all three files. | |
| 102 | + | Staging and backups precede replacements, but disk/permission errors during | |
| 103 | + | writing can leave directories/backups or a partial install. Inspect the | |
| 104 | + | printed paths, restore from backups if needed, and rerun after fixing the cause. | |
| 105 | + | File modes are retained; ownership, ACLs, extended attributes and hard-link | |
| 106 | + | relationships are not preserved by replacement. Avoid concurrent writers. | |
| 107 | + | ||
| 108 | + | After installation, start **fresh Claude Code and Codex sessions**, and **quit | |
| 109 | + | and restart OpenCode**. Ask the agent which instruction files it loaded if the | |
| 110 | + | preference is missing. To uninstall, remove only the managed block from each | |
| 111 | + | destination, or restore a backup if it will not erase newer unrelated edits. | |
| 112 | + | ||
| 113 | + | ## Existing Style | |
| 114 | + | ||
| 115 | + | [`ELI5.mdx`](https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056/raw/HEAD/ELI5.mdx) | |
| 116 | + | is the original short, plain-language writing style. It is preserved unchanged | |
| 117 | + | and is not installed by this script. | |
grilling-question-tools.md(stworzono plik)
| @@ -0,0 +1,46 @@ | |||
| 1 | + | <!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-POLICY v1 --> | |
| 2 | + | # Question Tools for Matt Pocock Grilling Flows | |
| 3 | + | ||
| 4 | + | Scope: apply only while running Matt Pocock's grilling flows (grill-me, | |
| 5 | + | grilling, grill-with-docs, loop-me, or a skill explicitly delegating its | |
| 6 | + | interview to one of these). This changes question delivery, not the flow's | |
| 7 | + | research, decision criteria, documentation, or completion requirements. | |
| 8 | + | Ordinary coding conversations and unrelated skills keep their own behavior. | |
| 9 | + | ||
| 10 | + | 1. Research what the codebase can answer before asking the user. The main | |
| 11 | + | conversation agent owns the interview; subagents return findings and proposed | |
| 12 | + | questions to it rather than independently prompting the user. | |
| 13 | + | 2. Use the host's native interactive question tool when exposed and permitted | |
| 14 | + | in the current session: Claude Code `AskUserQuestion`, Codex | |
| 15 | + | `request_user_input`, or OpenCode `question`. Follow its actual schema and | |
| 16 | + | mode/permission restrictions. Do not invent a tool, change modes merely to | |
| 17 | + | unlock one, or bypass a restriction. In Codex, `request_user_input` may be | |
| 18 | + | limited to Plan mode; use the fallback when it is unavailable. | |
| 19 | + | 3. Preserve the originating flow's round size and pacing. If it calls for one | |
| 20 | + | question at a time, ask one. If it allows a round of independent questions, | |
| 21 | + | batch only those that can be answered without knowing another answer. | |
| 22 | + | Wait for each answer before formulating dependent follow-ups. Split rounds | |
| 23 | + | across sequential tool calls when question, option, or text limits require | |
| 24 | + | it; retain every decision rather than silently dropping questions. | |
| 25 | + | 4. Give enough context to decide, concrete options with tradeoffs, and your | |
| 26 | + | recommendation with a short reason when you have one. Mark the recommended | |
| 27 | + | option clearly. Keep choices neutral enough to disagree with; never invent | |
| 28 | + | a recommendation just to fill the UI. Put context outside the tool if its | |
| 29 | + | field limits would lose necessary detail. | |
| 30 | + | 5. Preserve custom responses: use the tool's built-in free-text/Other facility | |
| 31 | + | where available. If it cannot accept a needed custom answer, invite a plain | |
| 32 | + | text reply instead. Do not force an open-ended question into false choices | |
| 33 | + | or interpret Other as agreement with a listed option. | |
| 34 | + | 6. Treat skipped, dismissed, empty, or ambiguous answers as unresolved, not | |
| 35 | + | consent and not acceptance of the recommendation. Clarify a blocking gap or | |
| 36 | + | explicitly defer it with the user's agreement. Track unresolved decisions | |
| 37 | + | and keep them out of the confirmed decisions in any spec, ADR, or summary. | |
| 38 | + | 7. If the native tool is absent, disallowed, fails, or cannot express the | |
| 39 | + | question faithfully, ask in plain text and wait. Use the same round rules, | |
| 40 | + | numbered questions/options, recommendation and reason, and an explicit | |
| 41 | + | invitation to give a custom answer. Resume the native tool when usable. | |
| 42 | + | ||
| 43 | + | This is an instruction preference, not runtime enforcement. Higher-priority | |
| 44 | + | instructions and host restrictions still apply. Finish only when the original | |
| 45 | + | flow's completion criteria are met; changing the UI does not satisfy them. | |
| 46 | + | <!-- END MATT-POCOCK-GRILLING-QUESTION-POLICY v1 --> | |
install-question-tools.sh(stworzono plik)
| @@ -0,0 +1,141 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | # Requires Bash 3.2+, Python 3.8+, and curl. No sudo; run one installer at a time. | |
| 3 | + | set -euo pipefail | |
| 4 | + | ||
| 5 | + | # Defining the complete function before calling it also avoids starting an | |
| 6 | + | # installation from a download truncated inside the embedded Python program. | |
| 7 | + | main() { | |
| 8 | + | command -v python3 >/dev/null || { printf '%s\n' 'Python 3.8+ is required.' >&2; return 1; } | |
| 9 | + | command -v curl >/dev/null || { printf '%s\n' 'curl is required.' >&2; return 1; } | |
| 10 | + | python3 - <<'PY' | |
| 11 | + | import os | |
| 12 | + | import re | |
| 13 | + | import stat | |
| 14 | + | import subprocess | |
| 15 | + | import sys | |
| 16 | + | import tempfile | |
| 17 | + | from pathlib import Path | |
| 18 | + | ||
| 19 | + | BEGIN = b'<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-TOOLS -->' | |
| 20 | + | END = b'<!-- END MATT-POCOCK-GRILLING-QUESTION-TOOLS -->' | |
| 21 | + | TOKEN = b'MATT-POCOCK-GRILLING-QUESTION-TOOLS' | |
| 22 | + | POLICY_BEGIN = '<!-- BEGIN MATT-POCOCK-GRILLING-QUESTION-POLICY v1 -->' | |
| 23 | + | POLICY_END = '<!-- END MATT-POCOCK-GRILLING-QUESTION-POLICY v1 -->' | |
| 24 | + | BASE = 'https://opengist.resetrix.work/weehong/9e381dc89dff4670b7232b910454f056' | |
| 25 | + | ||
| 26 | + | ||
| 27 | + | def check_path(path): | |
| 28 | + | for part in [path] + list(path.parents): | |
| 29 | + | if part.is_symlink(): | |
| 30 | + | raise ValueError('Symlink refused: %s. Use physical directories and a regular instruction file; manage linked files manually.' % part) | |
| 31 | + | if part.exists() and not (part.is_file() if part == path else part.is_dir()): | |
| 32 | + | raise ValueError('Not a regular file/directory: %s' % part) | |
| 33 | + | parent = path.parent | |
| 34 | + | while not parent.exists(): | |
| 35 | + | parent = parent.parent | |
| 36 | + | if not os.access(parent, os.W_OK | os.X_OK): | |
| 37 | + | raise ValueError('Directory is not writable/searchable: %s' % parent) | |
| 38 | + | if path.exists() and not os.access(path, os.R_OK | os.W_OK): | |
| 39 | + | raise ValueError('Instruction file is not readable/writable: %s' % path) | |
| 40 | + | ||
| 41 | + | ||
| 42 | + | def install(): | |
| 43 | + | if sys.version_info < (3, 8): | |
| 44 | + | raise ValueError('Python 3.8+ is required.') | |
| 45 | + | revision = os.environ.get('QUESTION_TOOLS_REVISION') or 'HEAD' | |
| 46 | + | if revision != 'HEAD' and not re.fullmatch(r'[0-9a-fA-F]{40}', revision): | |
| 47 | + | raise ValueError('QUESTION_TOOLS_REVISION must be HEAD or a full 40-character commit hash.') | |
| 48 | + | url = BASE + '/raw/' + revision + '/grilling-question-tools.md' | |
| 49 | + | result = subprocess.run(['curl', '-fsSL', '--proto', '=https', '--proto-redir', '=https', | |
| 50 | + | '--connect-timeout', '15', '--max-time', '60', url], | |
| 51 | + | stdout=subprocess.PIPE, check=True) | |
| 52 | + | policy = result.stdout.decode('utf-8') | |
| 53 | + | lines = policy.splitlines() | |
| 54 | + | if (not lines or lines[0] != POLICY_BEGIN or lines[-1] != POLICY_END | |
| 55 | + | or policy.count(POLICY_BEGIN) != 1 or policy.count(POLICY_END) != 1 | |
| 56 | + | or len(policy) > 32768 or '\x00' in policy | |
| 57 | + | or TOKEN.decode() in policy | |
| 58 | + | or not policy.startswith(POLICY_BEGIN + '\n# Question Tools for Matt Pocock Grilling Flows\n') | |
| 59 | + | or not all('\n%d. ' % i in policy for i in range(1, 8))): | |
| 60 | + | raise ValueError('Downloaded policy is incomplete or invalid; no instruction files changed.') | |
| 61 | + | block = BEGIN + b'\n' + policy.rstrip('\r\n').encode('utf-8') + b'\n' + END | |
| 62 | + | home = os.environ.get('HOME') | |
| 63 | + | if not home or not os.path.isabs(home): | |
| 64 | + | raise ValueError('HOME must be an absolute path.') | |
| 65 | + | directories = [ | |
| 66 | + | os.environ.get('CLAUDE_CONFIG_DIR') or home + '/.claude', | |
| 67 | + | os.environ.get('CODEX_HOME') or home + '/.codex', | |
| 68 | + | os.environ.get('OPENCODE_CONFIG_DIR') or | |
| 69 | + | (os.environ.get('XDG_CONFIG_HOME') or home + '/.config') + '/opencode', | |
| 70 | + | ] | |
| 71 | + | if any(not os.path.isabs(d) or '..' in Path(d).parts for d in directories): | |
| 72 | + | raise ValueError('Config directories must be absolute paths without .. components.') | |
| 73 | + | paths = list(dict.fromkeys(Path(d) / name for d, name in | |
| 74 | + | zip(directories, ['CLAUDE.md', 'AGENTS.md', 'AGENTS.md']))) | |
| 75 | + | changes = [] | |
| 76 | + | # Preflight every destination before creating directories, backups, or files. | |
| 77 | + | for path in paths: | |
| 78 | + | check_path(path) | |
| 79 | + | exists = path.exists() | |
| 80 | + | old = path.read_bytes() if exists else b'' | |
| 81 | + | if TOKEN in old: | |
| 82 | + | if old.count(TOKEN) != 2 or old.count(BEGIN) != 1 or old.count(END) != 1: | |
| 83 | + | raise ValueError('Malformed/duplicate managed markers: %s. Repair them manually before rerunning.' % path) | |
| 84 | + | start, end = old.index(BEGIN), old.index(END) | |
| 85 | + | after = end + len(END) | |
| 86 | + | if (end < start or (start and old[start-1:start] != b'\n') | |
| 87 | + | or old[end-1:end] != b'\n' | |
| 88 | + | or (old[after:] and not old[after:].startswith((b'\n', b'\r\n'))) | |
| 89 | + | or not old[start+len(BEGIN):].startswith((b'\n', b'\r\n'))): | |
| 90 | + | raise ValueError('Malformed managed marker boundaries: %s' % path) | |
| 91 | + | new = old[:start] + block + old[after:] | |
| 92 | + | else: | |
| 93 | + | separator = (b'\n' if old.endswith(b'\n') else b'\n\n') if old else b'' | |
| 94 | + | new = old + separator + block + b'\n' | |
| 95 | + | if new != old: | |
| 96 | + | changes.append((path, exists, old, new)) | |
| 97 | + | else: | |
| 98 | + | print('Unchanged: %s' % path) | |
| 99 | + | ||
| 100 | + | staged = [] | |
| 101 | + | try: | |
| 102 | + | for path, exists, old, new in changes: | |
| 103 | + | check_path(path) | |
| 104 | + | if path.exists() != exists or (exists and path.read_bytes() != old): | |
| 105 | + | raise ValueError('File changed during installation: %s. Retry with other writers stopped.' % path) | |
| 106 | + | path.parent.mkdir(parents=True, exist_ok=True) | |
| 107 | + | mode = stat.S_IMODE(path.stat().st_mode) if exists else 0o600 | |
| 108 | + | fd, temporary = tempfile.mkstemp(prefix='.' + path.name + '.question-tools-', dir=str(path.parent)) | |
| 109 | + | staged.append((path, temporary)) | |
| 110 | + | with os.fdopen(fd, 'wb') as stream: | |
| 111 | + | stream.write(new) | |
| 112 | + | stream.flush() | |
| 113 | + | os.fsync(stream.fileno()) | |
| 114 | + | os.fchmod(stream.fileno(), mode) | |
| 115 | + | if exists: | |
| 116 | + | fd, backup = tempfile.mkstemp(prefix=path.name + '.question-tools-backup-', dir=str(path.parent)) | |
| 117 | + | with os.fdopen(fd, 'wb') as stream: | |
| 118 | + | stream.write(old) | |
| 119 | + | stream.flush() | |
| 120 | + | os.fsync(stream.fileno()) | |
| 121 | + | print('Backup: %s' % backup) | |
| 122 | + | for path, temporary in staged: | |
| 123 | + | os.replace(temporary, path) | |
| 124 | + | print('Updated: %s' % path) | |
| 125 | + | finally: | |
| 126 | + | for _, temporary in staged: | |
| 127 | + | if os.path.exists(temporary): | |
| 128 | + | os.unlink(temporary) | |
| 129 | + | print('Policy source: %s' % url) | |
| 130 | + | print('Start fresh Claude Code/Codex sessions; quit and restart OpenCode.') | |
| 131 | + | ||
| 132 | + | ||
| 133 | + | try: | |
| 134 | + | install() | |
| 135 | + | except (OSError, ValueError, subprocess.SubprocessError) as error: | |
| 136 | + | print('Installation failed: %s' % error, file=sys.stderr) | |
| 137 | + | sys.exit(1) | |
| 138 | + | PY | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | main "$@" | |
weehong zrewidował ten Gist 2 months ago. Przejdź do rewizji
1 file changed, 0 insertions, 0 deletions
gistfile1.txt zmieniono nazwę na ELI5.mdx
Zmieniono nazwę pliku bez modyfikacji zawartości
weehong zrewidował ten Gist 2 months ago. Przejdź do rewizji
1 file changed, 17 insertions
gistfile1.txt(stworzono plik)
| @@ -0,0 +1,17 @@ | |||
| 1 | + | --- | |
| 2 | + | name: ELI5 | |
| 3 | + | description: keep it simple pls | |
| 4 | + | keep-coding-instructions: true | |
| 5 | + | --- | |
| 6 | + | ||
| 7 | + | It's been a long day and my brain is fried, talk to me like I'm 5. | |
| 8 | + | ||
| 9 | + | Small words, short sentences, short paragraphs. If you have to use | |
| 10 | + | a big word, explain it right after. Only return what's actually necessary. | |
| 11 | + | ||
| 12 | + | Just tell me what you did, did it work, what do I do now. | |
| 13 | + | ||
| 14 | + | If I have to decide something: 2 options max, the context I need to pick fast, | |
| 15 | + | and which one you'd go with. | |
| 16 | + | ||
| 17 | + | Keep paths and commands exact. I have no brain cells left for the rest. | |