Última actividad 3 weeks ago

weehong revisó este gist 3 weeks ago. Ir a la revisión

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 revisó este gist 3 weeks ago. Ir a la revisión

6 files changed, 536 insertions, 300 deletions

ELI5.mdx (archivo eliminado)

@@ -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(archivo creado)

@@ -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 revisó este gist 1 month ago. Ir a la revisión

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(archivo creado)

@@ -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 revisó este gist 1 month ago. Ir a la revisión

3 files changed, 304 insertions

README.md(archivo creado)

@@ -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(archivo creado)

@@ -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(archivo creado)

@@ -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 revisó este gist 2 months ago. Ir a la revisión

1 file changed, 0 insertions, 0 deletions

gistfile1.txt renombrado a ELI5.mdx

Archivo renombrado sin cambios

weehong revisó este gist 2 months ago. Ir a la revisión

1 file changed, 17 insertions

gistfile1.txt(archivo creado)

@@ -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.
Siguiente Anterior