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

1#!/usr/bin/env python3 

2"""Generate the client-agnostic skill bundle under ``bundle/``. 

3 

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. 

11 

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. 

14 

15Three decisions worth knowing before changing it: 

16 

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. 

21 

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. 

27 

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. 

32 

33Usage: 

34 uv run --python 3.12 --no-project python \ 

35 scripts/build_bundle.py [--root DIR] [--check] 

36 

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""" 

40 

41from __future__ import annotations 

42 

43import argparse 

44import re 

45from collections.abc import Mapping 

46from pathlib import Path 

47 

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) 

57 

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_.-]+)\*?\)") 

63 

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-" 

67 

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) 

75 

76 

77def rewrite_paths(body: str) -> str: 

78 """Point every plugin-root path at ``${RHIZA_ROOT}`` instead. 

79 

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. 

82 

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 

91 

92 

93def split_frontmatter(text: str) -> tuple[dict[str, str], str]: 

94 """Return the frontmatter fields of *text* and the body after it. 

95 

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``. 

98 

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") 

109 

110 

111def binaries(allowed_tools: str) -> list[str]: 

112 """Return the shell binaries an ``allowed-tools`` line permits, in order. 

113 

114 >>> binaries("Bash(git*), Bash(uv*), Read, AskUserQuestion") 

115 ['git', 'uv'] 

116 >>> binaries("Read") 

117 [] 

118 """ 

119 return _BINARY.findall(allowed_tools) 

120 

121 

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 ] 

130 

131 

132def _shared_notes() -> list[str]: 

133 """Return the preamble bullets every bundled file carries. 

134 

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 ] 

154 

155 

156def _frontmatter_notes(fields: Mapping[str, str]) -> list[str]: 

157 """Return the bullets restating the frontmatter keys the bundle drops. 

158 

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. 

162 

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 

180 

181 

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" 

186 

187 

188def render_command(name: str, text: str) -> str: 

189 """Return the bundled ``SKILL.md`` for command *name*. 

190 

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)}" 

200 

201 

202def render_procedure(name: str, text: str) -> str: 

203 """Return the bundled copy of procedure *name*. 

204 

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)}" 

211 

212 

213def _first_sentence(description: str) -> str: 

214 """Return the first sentence of *description*, for the bundle's index table. 

215 

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

223 

224 

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 

231 

232**Generated by `plugin/scripts/build_bundle.py`. Edit `plugin/`, then run `make bundle` 

233— edits here are overwritten.** 

234 

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. 

239 

240## Use it 

241 

242```bash 

243git clone https://github.com/Jebel-Quant/rhiza-claude.git ~/.local/share/rhiza-claude 

244export RHIZA_ROOT=~/.local/share/rhiza-claude 

245``` 

246 

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}/`. 

251 

252`uv` and `git` must be on PATH; there is nothing to install beyond them, since every 

253script is stdlib-only Python. 

254 

255## What is here 

256 

257| Skill | What it does | 

258| --- | --- | 

259{rows} 

260 

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. 

264 

265## What you give up against the plugin 

266 

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. 

276 

277## Without an agent at all 

278 

279The deterministic half runs with no model in the loop — see `docs/headless.md`, which 

280maps each command to the scripts underneath it. 

281""" 

282 

283 

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 

297 

298 

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")} 

305 

306 

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. 

309 

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 

320 

321 

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 

337 

338 

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

346 

347 

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) 

356 

357 root = Path(args.root).resolve() 

358 files = bundle_files(root) 

359 

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 

367 

368 if changed := write(root, files): 

369 print("\n".join(changed)) 

370 print(f"bundle: {len(files)} files") 

371 return 0 

372 

373 

374if __name__ == "__main__": # pragma: no cover 

375 raise SystemExit(main())