Coverage for plugin/scripts/build_bundle.py: 100%
117 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-26 04:21 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-26 04:21 +0000
1#!/usr/bin/env python3
2"""Generate the client-agnostic skill bundle under ``bundle/``.
4The ten commands and nine procedures are the only Claude-bound part of this plugin —
512,500 lines of ``scripts/`` are stdlib-only Python that any caller can drive, and
6``docs/headless.md`` already documents them as a standalone surface. What binds the
7prose is a short list: ``${CLAUDE_PLUGIN_ROOT}``, the ``allowed-tools`` /
8``argument-hint`` / ``disable-model-invocation`` frontmatter keys, a handful of tool
9names, and ``AskUserQuestion``. The ``SKILL.md`` format around them is now an open
10standard that many clients read.
12So this is a **translation, not a port**: same prose, same scripts, with the four
13bindings rewritten or restated in a preamble every client can read.
15Three decisions worth knowing before changing it:
17**It writes a build artifact, and that artifact is committed.** A client is pointed at a
18checkout; asking users to run a build first would defeat the purpose. The cost of a
19generated file in git is drift, which is why ``--check`` runs as a hook — the same
20arrangement ``render_command_docs.py`` already uses for the docs reference blocks.
22**It copies no Python.** ``${RHIZA_ROOT}`` is the checkout, so a bundled skill reaches
23``${RHIZA_ROOT}/plugin/scripts/<name>.py`` — the same file the plugin runs, under the
24same eight gates. A bundle carrying its own copy would be a second ``scripts/`` tree to
25keep in step, and this repository has already decided against a second implementation of
26anything.
28**It restates what it removes.** ``allowed-tools`` is dropped from the frontmatter,
29because a client that ignores the key enforces nothing and the reader is left believing
30otherwise. The binaries it named reappear in the preamble as prose, where they are a
31fact the model can act on rather than a permission the client silently didn't apply.
33Usage:
34 uv run --python 3.12 --no-project python \
35 scripts/build_bundle.py [--root DIR] [--check]
37Writes the bundle by default, printing what changed. With ``--check`` it writes nothing
38and exits 1 if the committed bundle is stale, which is how the hook runs it.
39"""
41from __future__ import annotations
43import argparse
44import re
45from collections.abc import Mapping
46from pathlib import Path
48from _rhiza_layout import (
49 BUNDLE_DIR,
50 BUNDLE_PROMPTS_DIR,
51 BUNDLE_SKILLS_DIR,
52 PROMPTS_DIR,
53 SKILL_FILE,
54 SKILLS_DIR,
55 command_files,
56)
58# The frontmatter block, and one `key: value` line inside it.
59_FRONTMATTER = re.compile(r"\A---\n(.*?)\n---\n", re.S)
60_FIELD = re.compile(r"^([a-z-]+):[ \t]*(.*)$", re.M)
61# A binary named in `allowed-tools`, e.g. `Bash(make*)`.
62_BINARY = re.compile(r"Bash\(([a-zA-Z0-9_.-]+)\*?\)")
64# Every bundled skill is namespaced. A folder of skills is a shared namespace in most
65# clients, and `docs`, `status` and `release` are names somebody else will also want.
66_PREFIX = "rhiza-"
68# Applied in order, so the specific paths win before the bare variable catch-all.
69_REWRITES = (
70 ("${CLAUDE_PLUGIN_ROOT}/scripts/", "${RHIZA_ROOT}/plugin/scripts/"),
71 ("${CLAUDE_PLUGIN_ROOT}/prompts/", f"${{RHIZA_ROOT}}/{BUNDLE_PROMPTS_DIR}/"),
72 ("${CLAUDE_PLUGIN_ROOT}", "${RHIZA_ROOT}/plugin"),
73 (f"{PROMPTS_DIR}/", f"{BUNDLE_PROMPTS_DIR}/"),
74)
77def rewrite_paths(body: str) -> str:
78 """Point every plugin-root path at ``${RHIZA_ROOT}`` instead.
80 The procedures move — the bundle carries its own translated copies — while the
81 scripts do not, which is why the two halves resolve to different places.
83 >>> rewrite_paths('Run "${CLAUDE_PLUGIN_ROOT}/scripts/sync.py" .')
84 'Run "${RHIZA_ROOT}/plugin/scripts/sync.py" .'
85 >>> rewrite_paths("Read plugin/prompts/pr-base.md")
86 'Read bundle/prompts/pr-base.md'
87 """
88 for old, new in _REWRITES:
89 body = body.replace(old, new)
90 return body
93def split_frontmatter(text: str) -> tuple[dict[str, str], str]:
94 """Return the frontmatter fields of *text* and the body after it.
96 A file with no frontmatter yields no fields and its whole text as the body, which is
97 what every procedure is: they carry none by rule 2 of ``check_prompt_wiring.py``.
99 >>> split_frontmatter("---\\ndescription: hi\\n---\\n\\nbody\\n")
100 ({'description': 'hi'}, 'body\\n')
101 >>> split_frontmatter("just prose\\n")
102 ({}, 'just prose\\n')
103 """
104 match = _FRONTMATTER.match(text)
105 if match is None:
106 return {}, text
107 fields = {key: value.strip() for key, value in _FIELD.findall(match.group(1))}
108 return fields, text[match.end() :].lstrip("\n")
111def binaries(allowed_tools: str) -> list[str]:
112 """Return the shell binaries an ``allowed-tools`` line permits, in order.
114 >>> binaries("Bash(git*), Bash(uv*), Read, AskUserQuestion")
115 ['git', 'uv']
116 >>> binaries("Read")
117 []
118 """
119 return _BINARY.findall(allowed_tools)
122def _source_note(source: str) -> list[str]:
123 """Return the "this file is generated" opening of a preamble."""
124 return [
125 f"> **Portable copy of `{source}`, generated by",
126 "> `plugin/scripts/build_bundle.py`. Edit the source and run `make bundle` —",
127 "> an edit here is overwritten.**",
128 ">",
129 ]
132def _shared_notes() -> list[str]:
133 """Return the preamble bullets every bundled file carries.
135 These are the four bindings the translation cannot rewrite away: a path variable
136 only Claude Code sets, a slash-command spelling only it resolves, tool names that
137 differ per client, and a question tool most clients do not have.
138 """
139 return [
140 "> - **`${RHIZA_ROOT}`** is your `rhiza-claude` checkout. Export it, or",
141 "> substitute the path wherever it appears. Any remark below about the",
142 "> variable being empty in a source checkout is Claude Code's spelling of the",
143 "> same idea — `${RHIZA_ROOT}` replaces it, and the repo-relative fallbacks do",
144 "> not apply, because you are working in the *user's* repo, not in this one.",
145 "> - **`/rhiza:<name>`** names another skill in this bundle, `rhiza-<name>`.",
146 "> Invoke it however your client invokes skills.",
147 "> - **Tools.** `Read`, `Edit` and `Write` read and write files; `Grep` and",
148 "> `Glob` search; `Bash` is a shell in the user's repo. Use your equivalents.",
149 "> - **`AskUserQuestion`** is a multiple-choice question put to the user. With no",
150 "> such tool, ask in plain text, number the options, and **wait for a reply**:",
151 "> where the procedure says nothing is created without an explicit selection,",
152 "> that holds however the question was asked.",
153 ]
156def _frontmatter_notes(fields: Mapping[str, str]) -> list[str]:
157 """Return the bullets restating the frontmatter keys the bundle drops.
159 Dropping them silently is the failure this avoids: a client that ignores
160 ``allowed-tools`` enforces nothing, so the permission surface changes while the
161 reader has no way to notice. Restated as prose, the same facts still bind.
163 ``$ARGUMENTS`` rides along with the argument hint for the same reason. Claude Code
164 substitutes it before the model ever sees the text; elsewhere it arrives literally,
165 and a model that does not know that runs the command against the string.
166 """
167 notes = []
168 if hint := fields.get("argument-hint"):
169 notes.append(f"> - **Arguments:** {hint.strip(chr(34))}.")
170 notes.append("> The text below writes `$ARGUMENTS` for what the user passed;")
171 notes.append("> substitute it yourself if your client does not.")
172 if found := binaries(fields.get("allowed-tools", "")):
173 listed = ", ".join(f"`{binary}`" for binary in found)
174 notes.append(f"> - **Runs:** {listed}. Nothing here enforces that list, where the")
175 notes.append("> plugin's frontmatter did — your client has to permit them.")
176 if fields.get("disable-model-invocation") == "true":
177 notes.append("> - **Never run this on your own initiative** — only when the user")
178 notes.append("> asks for it by name.")
179 return notes
182def preamble(source: str, fields: Mapping[str, str]) -> str:
183 """Return the block prepended to one bundled file."""
184 lines = [*_source_note(source), *_shared_notes(), *_frontmatter_notes(fields)]
185 return "\n".join(lines) + "\n"
188def render_command(name: str, text: str) -> str:
189 """Return the bundled ``SKILL.md`` for command *name*.
191 The frontmatter is reduced to the two fields the open format requires, and ``name``
192 is added — which the plugin spelling must *not* carry, since there the directory
193 already decides the command. That is the one place the two formats contradict each
194 other, and it is why this is generated rather than edited in place.
195 """
196 fields, body = split_frontmatter(text)
197 source = f"{SKILLS_DIR}/{name}/{SKILL_FILE}"
198 header = f"---\nname: {_PREFIX}{name}\ndescription: {fields.get('description', '')}\n---\n"
199 return f"{header}\n{preamble(source, fields)}\n{rewrite_paths(body)}"
202def render_procedure(name: str, text: str) -> str:
203 """Return the bundled copy of procedure *name*.
205 No frontmatter is added: a procedure is not a skill, and giving it one is how it
206 would become invocable in a client that scans the directory — the exact property
207 ``prompts/`` exists to deny.
208 """
209 fields, body = split_frontmatter(text)
210 return f"{preamble(f'{PROMPTS_DIR}/{name}.md', fields)}\n{rewrite_paths(body)}"
213def _first_sentence(description: str) -> str:
214 """Return the first sentence of *description*, for the bundle's index table.
216 >>> _first_sentence("Sync the repo. Runs no gates.")
217 'Sync the repo.'
218 >>> _first_sentence("No full stop here")
219 'No full stop here'
220 """
221 head, separator, _ = description.partition(". ")
222 return head + separator.strip()
225def render_readme(commands: list[tuple[str, str]]) -> str:
226 """Return the bundle's own README, listing *commands* as ``(name, description)``."""
227 rows = "\n".join(
228 f"| `{_PREFIX}{name}` | {_first_sentence(description)} |" for name, description in commands
229 )
230 return f"""# rhiza, for any agent
232**Generated by `plugin/scripts/build_bundle.py`. Edit `plugin/`, then run `make bundle`
233— edits here are overwritten.**
235The same commands the Claude Code plugin ships, translated for any client that reads the
236open `SKILL.md` format. The prose is unchanged; what differs is that the Claude-specific
237bindings have been rewritten or restated, because a client that silently ignores them
238would leave the reader believing something the run no longer does.
240## Use it
242```bash
243git clone https://github.com/Jebel-Quant/rhiza-claude.git ~/.local/share/rhiza-claude
244export RHIZA_ROOT=~/.local/share/rhiza-claude
245```
247Then point your client at `$RHIZA_ROOT/{BUNDLE_SKILLS_DIR}` — copy or symlink the
248directories into wherever it loads skills from. Each one is self-contained apart from
249two things it reaches by path: the scripts under `$RHIZA_ROOT/plugin/scripts/`, and the
250procedures under `$RHIZA_ROOT/{BUNDLE_PROMPTS_DIR}/`.
252`uv` and `git` must be on PATH; there is nothing to install beyond them, since every
253script is stdlib-only Python.
255## What is here
257| Skill | What it does |
258| --- | --- |
259{rows}
261The procedures under `prompts/` are **not** skills and must not be installed as any:
262they are steps a skill reaches with a file read, and several are shared. A client that
263scanned them as skills would offer the user half a workflow.
265## What you give up against the plugin
267- **The `PreToolUse` guard.** In Claude Code a hook makes three rules structural: one
268 bare `make` per gate, never force-push, never push to the default branch. Here they
269 are prose, promised by the skills that make them — so a client with hooks of its own
270 is worth wiring up, and one without needs the rules honoured by the model.
271- **The tool allow-list.** `allowed-tools` is a Claude Code key; the binaries each skill
272 runs are restated in its preamble instead, as a fact rather than an enforcement.
273- **`AskUserQuestion`.** Where a skill asks the user to choose, ask in plain text and
274 wait for an explicit answer. Every "nothing happens without a selection" rule holds
275 however the question was put.
277## Without an agent at all
279The deterministic half runs with no model in the loop — see `docs/headless.md`, which
280maps each command to the scripts underneath it.
281"""
284def bundle_files(root: Path) -> dict[str, str]:
285 """Return every file the bundle should contain, as ``path -> content``."""
286 files: dict[str, str] = {}
287 index: list[tuple[str, str]] = []
288 for name, path in command_files(root):
289 text = path.read_text(encoding="utf-8")
290 files[f"{BUNDLE_SKILLS_DIR}/{_PREFIX}{name}/{SKILL_FILE}"] = render_command(name, text)
291 index.append((name, split_frontmatter(text)[0].get("description", "")))
292 for path in sorted((root / PROMPTS_DIR).glob("*.md")):
293 text = path.read_text(encoding="utf-8")
294 files[f"{BUNDLE_PROMPTS_DIR}/{path.stem}.md"] = render_procedure(path.stem, text)
295 files[f"{BUNDLE_DIR}/README.md"] = render_readme(index)
296 return files
299def existing_files(root: Path) -> set[str]:
300 """Return every markdown path currently under the bundle, relative to *root*."""
301 bundle = root / BUNDLE_DIR
302 if not bundle.is_dir():
303 return set()
304 return {path.relative_to(root).as_posix() for path in bundle.rglob("*.md")}
307def stale(root: Path, files: Mapping[str, str]) -> list[str]:
308 """Return every bundle path that is missing, out of date, or no longer generated.
310 An orphan matters as much as a stale file: a renamed command leaves its old skill
311 behind, and a client pointed at the directory goes on offering it.
312 """
313 out = [
314 f"{rel}: {'missing' if not (root / rel).is_file() else 'out of date'}"
315 for rel, content in sorted(files.items())
316 if not (root / rel).is_file() or (root / rel).read_text(encoding="utf-8") != content
317 ]
318 out += [f"{rel}: no longer generated" for rel in sorted(existing_files(root) - set(files))]
319 return out
322def write(root: Path, files: Mapping[str, str]) -> list[str]:
323 """Write the bundle, remove what is no longer generated, and report what changed."""
324 changed = []
325 for rel, content in sorted(files.items()):
326 path = root / rel
327 if path.is_file() and path.read_text(encoding="utf-8") == content:
328 continue
329 path.parent.mkdir(parents=True, exist_ok=True)
330 path.write_text(content, encoding="utf-8")
331 changed.append(f" wrote {rel}")
332 for rel in sorted(existing_files(root) - set(files)):
333 (root / rel).unlink()
334 changed.append(f" removed {rel}")
335 _prune(root / BUNDLE_DIR)
336 return changed
339def _prune(directory: Path) -> None:
340 """Remove every empty directory under *directory*, deepest first."""
341 if not directory.is_dir():
342 return
343 for path in sorted(directory.rglob("*"), key=lambda p: len(p.parts), reverse=True):
344 if path.is_dir() and not any(path.iterdir()):
345 path.rmdir()
348def main(argv: list[str] | None = None) -> int:
349 """Write or verify the bundle; exit 1 under ``--check`` when it is stale."""
350 parser = argparse.ArgumentParser(description=__doc__)
351 parser.add_argument("--root", default=".", help="Repository root (default: cwd).")
352 parser.add_argument(
353 "--check", action="store_true", help="Verify only; write nothing. Exit 1 if stale."
354 )
355 args = parser.parse_args(argv)
357 root = Path(args.root).resolve()
358 files = bundle_files(root)
360 if args.check:
361 if problems := stale(root, files):
362 print("The bundle is out of date. Run `make bundle`.")
363 print("\n".join(f" {problem}" for problem in problems))
364 return 1
365 print(f"bundle up to date ({len(files)} files)")
366 return 0
368 if changed := write(root, files):
369 print("\n".join(changed))
370 print(f"bundle: {len(files)} files")
371 return 0
374if __name__ == "__main__": # pragma: no cover
375 raise SystemExit(main())