A Claude Code plugin marketplace providing the
rhiza plugin — slash commands for working in rhiza-managed repos (template
sync, code-quality scoring, and README/doc upkeep).
📖 Documentation: https://jebel-quant.github.io/rhiza-claude/ — a dedicated
page for every command. Build it locally with make book.
Ten repos need the same CI, Makefile, gates and docs build — and copied scaffolding
drifts. rhiza is that scaffolding kept once,
in a template repository. rhiza-claude is how a repo adopts it: sync from a
pinned template release, then get scored on the result.
Two repos, one boundary. The template owns CI, the Makefile and the docs base;
your repo owns source, tests, pyproject.toml and README prose. A sync
writes only template-owned paths — the ones the last sync recorded — so your code
can't be swept in. There is no blanket git add --all in the flow.
"rhiza-managed" is two files, answering different questions: .rhiza/template.yml
is a pointer (which template, which pinned ref — what we'd sync from), and
.rhiza/template.lock is a record (repo, ref, SHA, timestamp, strategy, every managed
file — what actually arrived). They can disagree, which is why /rhiza:status reports
both and /rhiza:quality checks for both before scoring: the pointer says the repo
is managed, the record says a sync actually happened, and its gates are the ones that
sync delivers. With no record it drops to a degraded mode — template gates skipped,
your own gates run, design scored in full, and the report says so. It probes the
record rather than any file the sync wrote, because which files those are is a
property of the template version: /quality used to look for .rhiza/rhiza.mk, which
template v1.4 stopped shipping, and every fully synced v1.4 repo was told it had never
been synced. /rhiza:release needs neither.
The order still matters — /rhiza:init → merge → /rhiza:update → merge →
/rhiza:quality. /init writes one file of its own and syncs nothing; /update
performs the first sync; /quality needs that content for its full assessment. So
"/init ran and no CI appeared" is the expected result of step one, and a /quality
run before the sync gives you the narrower score rather than nothing.
Two kinds of markdown, and the difference is enforced. skills/ holds
the ten slash commands you invoke; prompts/ holds eight internal procedures they
Read — kept outside both so they can't be invoked directly. The procedures are where
shared behaviour lives, which is why /init and /update behave identically where they
overlap.
Why prose commands at all: deterministic work belongs in tested code, judgement belongs in markdown. The bundled stdlib-only Python does what has one right answer (parsing the lock, merging synced files, comparing versions); the markdown does what needs a reading of your repo. CI gates the prose too, so a command naming a script or flag that no longer exists fails the build instead of failing mid-task in front of you.
👉 New here? The documentation site has the full introduction and a worked first run — empty directory to synced, scored repo.
/plugin marketplace add Jebel-Quant/rhiza-claude
/plugin install rhiza@rhiza-claude
Or, from a shell, make install runs the equivalent claude CLI commands:
make installThe commands then appear namespaced under the plugin: /rhiza:init,
/rhiza:update, /rhiza:quality, /rhiza:docs. Type /rhiza
to have Claude Code autocomplete them.
By default the marketplace tracks this repo's default branch, so /plugin install pulls the latest release. To pin to a specific published version,
append that version's git tag as a #<ref> suffix when you add the marketplace
(see the releases page
for available tags):
/plugin marketplace add Jebel-Quant/rhiza-claude#v0.14.0
/plugin install rhiza@rhiza-claude
The same #<ref> suffix works from a shell:
claude plugin marketplace add Jebel-Quant/rhiza-claude#v0.14.0
claude plugin install rhiza@rhiza-claudePinning happens at the marketplace layer, not per plugin — once the marketplace
is added, /plugin install uses whatever ref it points at. To switch versions,
remove the marketplace and re-add it at the tag you want, which works in either
direction — an older release as readily as a newer one:
/plugin marketplace remove rhiza-claude
/plugin marketplace add Jebel-Quant/rhiza-claude#<tag>
<tag> is any published tag from the releases
page. It is a placeholder
here on purpose: the two examples above pin the current release and are rewritten
by bump-my-version on every release, but this one has to name a different tag
to show that switching goes both ways — so a literal version here would be one no
release could keep current, and it silently aged three minor versions before
anyone noticed.
/plugin install validates every marketplace you have configured before it
installs anything, so a manifest this repo has nothing to do with can stop the
rhiza install:
❯ /plugin install rhiza@rhiza-claude
⎿ Invalid schema: plugins.0.source: Invalid input, …
plugins.76: Unrecognized key: "displayName", … plugins.290.source: Invalid input
The indices name the culprit. This marketplace ships a single plugin, so there is no
plugins.290 here to be invalid — that entry belongs to another configured
marketplace, in practice Anthropic's own claude-plugins-official, which is a few
hundred entries long. Its entries use source object forms (git-subdir, url,
archive) and a displayName key that older Claude Code releases have no schema
for, so an out-of-date client rejects that manifest and the rhiza install is
collateral damage. /plugin marketplace add Jebel-Quant/rhiza-claude succeeding
while the install fails is the tell: the add validated this manifest and was happy
with it.
The fix is to update Claude Code, not to change anything here:
claude updateIf you can't update, dropping the marketplace that fails to validate also unblocks the install:
/plugin marketplace list
/plugin marketplace remove claude-plugins-official
The commands drive a rhiza-managed repo with uv —
install it once:
curl -LsSf https://astral.sh/uv/install.sh | shYou don't have to do this ahead of time, though: both /rhiza:init and
/rhiza:update check for uv as their first step and offer to install it (with your
approval) on any platform. git and make are also used but are near-universal, and
the plugin's own bundled scripts are stdlib-only Python — no rhiza CLI required.
That last point cuts further than it looks: the deterministic half runs without Claude Code at all. Every command's scripted work — the template sync, the status read, the bootstrap, detaching — is a stdlib-only script with its own CLI, so it can be driven from a shell script or a CI job with no LLM in the loop. What you give up is the judgement the markdown supplies; what you get is the same code path the commands drive. Without Claude Code is the map — the command-to-script table, the sync step by step, and every exit code it can hand you.
/rhiza:init— make the current folder rhiza-managed. It writes one file itself —.rhiza/template.yml, the pointer at a template repo and pinned ref — and delegates the rest to the internal procedures (see Internals): install-uv, then skeleton, then license. It detects platform/owner/name from an existingorigin(or asks), picks the language (Python, Go or Rust — the three are not equally supported; see Language support) and template repo (jebel-quant/rhizafor all three — the template is multi-language, layering apython-core/rust-core/go-corebundle on a neutralcore— or a fork) plus its latest release as the initial pin — checking that ref actually defines the profile it's about to name, since an unsatisfiable pointer merges cleanly and then fails the first/rhiza:update— and opens a PR on arhiza_init_<date>branch — never pushing to the default branch (a brand-new repo's base branch is created by you). It runs no sync and no gates: the template content (CI,Makefile, docs base) arrives when you run/rhiza:updateafter the PR merges — a second PR./rhiza:update— sync the repo to the latest (or a given) template release: bump therefin.rhiza/template.yml, run the bundled sync, resolve conflicts by taking the upstream side, and open a PR containing only template-owned files — the paths.rhiza/template.lockrecords, never a blanketgit add --all, so your own source is never swept in. The template repo is read fromtemplate.yml, so a fork works too. Runs no gates and files no issues: use/rhiza:qualityfor a scorecard./rhiza:quality— run the rhiza code-quality gate (lint, types, docs, deps, security, tests, complexity, architecture) and score the repo. It also checks the documentation for truth rather than presence: the>>>examples in your docstrings, and every fenced block inREADME.md— shell parsed withbash -n, Python withcompile(), and apythonfence diffed against theresultblock that follows it.interrogatecan only tell you a docstring exists; this is what tells you it is still right. Shell fences are never executed, and executing anything at all is opt-in — a module it cannot import is reported unmeasured, never failed./rhiza:docs— create or refresh the repo's three top-of-repo documentation files:README.md(with the standard badge set),CLAUDE.md, andmkdocs.yml. Detects platform, owner/repo and project metadata at runtime, preserves hand-written prose, and keeps the README'smake helptarget list in lockstep with the realMakefile. Badges are generated with omit, don't fake — a badge whose backing fact isn't detected is never emitted. Writes files only; no commit, no PR./rhiza:release— release through a pull request: table up the legal next versions and let you pick (it never suggests or defaults to one), guard that the choice strictly increases past every prior release, then letbump-my-versionwrite it into every location the repo declares in[tool.bumpversion]—pyproject.toml, plugin manifests, self-referencing CI stub pins — regenerateCHANGELOG.md, and open a release PR. Because the locations are declared rather than inferred, a dependency that happens to share the version number is never rewritten. Then it hands the merge to the forge (--squash --auto), waits for the bump to reach the default branch, and tags the commit that actually landed — one run, because a tag must name a commit on the default branch and a squash-merge rewrites the SHA, so the commit worth tagging doesn't exist until the PR merges. The version is the decision and the checks are the gate; what keeps the tag safe is the guard refusing a non-increasing or already-existing tag. If review outlasts the wait, the run stops with no tag created and re-running it finishes the release — it works that out from the repo's own state./rhiza:remote— ask the forge what CI actually said about the repo's open requests, then diagnose and fix the red ones./rhiza:qualityfiles findings as issues, those issues become branches, and those branches become requests that were green when they left — local green and origin green are different claims, and only the second one merges. It normalises GitHub's and GitLab's very different answers into one vocabulary, prints the exact drill-down command under each failing check, and sorts every failure into one of four kinds — real defect, environment difference, infrastructure flake, known upstream — because only one of them is a code fix. Fixes land on the request's own branch; never the default branch, never a force-push. It will not make a check green by weakening it: if the honest fix is out of reach it leaves the build red and says why.
The fence below is not an illustration. /rhiza:quality's example gate executes it and
diffs its output against the expected-output block underneath, so if the shipped
classify_host ever stops refusing a host that merely embeds a known forge domain,
this README turns the build red rather than quietly going on claiming otherwise.
import sys
sys.path.insert(0, "plugin/scripts")
from _rhiza_forge import classify_host
for host in ("github.com", "code.gitlab.com", "gitlab.acme.io", "github.com.evil.example"):
print(f"{host} -> {classify_host(host)}")github.com -> github
code.gitlab.com -> gitlab
gitlab.acme.io -> gitlab
github.com.evil.example -> None
That last line is the one that matters: github.com.evil.example is not a subdomain of
github.com, and a forge detector that guessed would act against the wrong host.
Not slash commands. These are internal procedures under prompts/ —
deliberately outside skills/ so they can't be invoked directly. /rhiza:init and
/rhiza:update read and follow them, and most are backed by a deterministic,
stdlib-only script.
- install-uv (
plugin/prompts/install-uv.md) — make sureuv, the plugin's one hard dependency, is installed, via the official astral.sh installer, Homebrew, or winget. Never installs without approval; sorts out thePATHstep the installer leaves behind. The first step of both/rhiza:initand/rhiza:update. - pr-base (
plugin/prompts/pr-base.md) — a work branch based on an up-to-date remote default branch, which is never pushed to: when it doesn't exist yet, you create the repo with an empty README rather than the command pushing one. Shared by/rhiza:initand/rhiza:update, so both behave identically. - skeleton (
plugin/prompts/skeleton.md→plugin/scripts/init_skeleton.py) —uv init --libwhen there's nopyproject.toml, then the shape the template's gates require: a package docstring in place of uv'shello()placeholder, the description, and the[project.urls]and[dependency-groups]entries the synced pyproject gate asserts. Idempotent and additive. The template never ships apyproject.toml, so the quality gates need this to have run. - license (
plugin/prompts/license.md→plugin/scripts/set_license.py) — the SPDXlicense/license-filesmetadata inpyproject.tomlplus theLICENSEfile's full text (MIT, Apache-2.0, BSD-3-Clause bundled). Overwriting an existingLICENSEneeds--force;noneclears the metadata. Never writes a deprecatedLicense ::trove classifier. - design-analysis (
plugin/prompts/design-analysis.md) — the complexity and architecture evidence nomakegate measures: radon CC/MI, module sizes, the import graph, layering direction, and cycles including ones hidden behind function-local imports. Gathers evidence; doesn't judge. - scorecard (
plugin/prompts/scorecard.md) — the 1–10 rubric: the subcategory list, the coverage bar, the findings format, the issue menu, and the scoping rule that stops a managed repo being marked down for its own template. - python-version (
plugin/prompts/python-version.md→plugin/scripts/set_python_version.py) — pinrequires-pythonand rewrite theProgramming Language :: Python :: X.Yclassifiers to the supported range (3.11–3.14; never a bare:: 3), and sync.python-version.
Thin, read-only, stdlib-only commands backed by bundled scripts — they work
without the rhiza CLI installed, reading .rhiza/template.lock /
.rhiza/template.yml directly where they need a repo at all. None of them writes
anything.
/rhiza:status— report both halves of the repo's rhiza state: whether.rhiza/template.ymlis valid (what you'd sync from), and what.rhiza/template.lockrecords as actually synced (repository, ref, SHA, timestamp, strategy). They can disagree in both directions, so reporting one alone misleads. Add--files(alias--tree) to list the managed files as a directory tree, or--checkto compare the pinned ref against the latest upstream release. Read-only./rhiza:maffay— return a bonmot from a random Peter Maffay song, for the middle of a long refactor. Takes an optional theme keyword (mut,sommer,nessaja, …) or part of a song title. The draw lives inplugin/scripts/maffay.pybecause a model asked for a random song reaches for the same two hits every time. Quotes the title line only, attributed — no lyric bodies — and theFür uns:gloss beside it is ours, not Maffay's. Needs no repo, no git and no network.
/rhiza:completions— install make tab-completion for bash, zsh or both, somake <TAB>lists a project's targets. The one command that writes outside a repo: the destinations are under${XDG_DATA_HOME:-$HOME/.local/share}, which is also why it isn't model-invocable — you have to name it. The completion is generic (it parses the make database in the current directory), so install it once per machine, not once per repo; this is the plugin taking over a job the template used to do by syncing four identical files into every managed repo. The installed names are the shells' genericmake/_make, so a completion this plugin didn't write is reported and left alone rather than overwritten —--forceis opt-in.
/rhiza:detach— detach the repo from rhiza: delete every rhiza-managed file listed in.rhiza/template.lock, prune the emptied directories, and remove the lock. This is the only command that removes files wholesale; it prompts for confirmation unless--forceis passed. It detaches a repo, not the plugin — the inverse of the sync, not of the installation, so uninstalling the plugin via/pluginis a different thing entirely and leaves every synced file in place.
/rhiza:init offers Python, Go and Rust, and all three point at the same multi-language
template. They are not equally supported, and it is worth knowing which you're
signing up for before the four-step bootstrap rather than after.
| Python | Rust | Go | |
|---|---|---|---|
/init, /update, /status, /release, /detach |
✅ | ✅ | ✅ |
| Local toolchain from the template | ✅ | ✅ cargo, clippy, nextest, llvm-cov, cargo-deny | ✅ go test, golangci-lint, govulncheck, revive |
| Hosted CI workflows | ✅ | ❌ none yet | ❌ none yet |
/quality gate list |
✅ known and named | ||
| Test-layout parity subcategory | ✅ | n/a | n/a |
| Executable-documentation gate | ✅ docstrings + README |
Python is the fully-supported axis. /rhiza:quality's gate list is the Python
profile — the one the plugin has actually run against. On a Rust or Go repo it probes
the Makefile with check_make_targets.py, scores the targets it discovers, and marks
the language-specific subcategories out-of-scope. That is deliberate: a hand-written
table of targets for templates this plugin has never run against would be prose
asserting things it can't back. But it does mean a Rust or Go scorecard rests on a
narrower base than a Python one, and /quality now says so in its own output.
There is deliberately no rust-github-project or go-github-project profile — those
are almost entirely CI workflows, and rhiza's github/gitlab bundles still ship
Python ones. Add hosted CI yourself until those land.
The shipped plugin lives in plugin/; everything that builds or checks it stays at
the repository root. .claude-plugin/marketplace.json points at it with
"source": "./plugin", which is the documented way to keep a plugin in a subdirectory
of its marketplace repo.
Two of the directories inside are the spec's and two are this repo's.
skills/ and hooks/ are discovery locations — Claude Code finds
components by those names at the plugin root, so they cannot be renamed. prompts/ and
scripts/ are local conventions the spec has never heard of; prompts/ exists precisely
because it is not a discovery location, so a procedure kept there cannot be invoked as a
slash command.
All ten commands are skills: plugin/skills/<name>/SKILL.md, where the directory
names the command, so skills/init/SKILL.md is what answers /rhiza:init. Check the
plugin docs rather than this table before
assuming what the spec requires.
| Path | Purpose |
|---|---|
.claude-plugin/marketplace.json |
Marketplace manifest listing the rhiza plugin. Stays at the repo root — that's where /plugin marketplace add looks. |
plugin/ |
The plugin as shipped. Everything below is inside it. |
plugin/.claude-plugin/plugin.json |
The rhiza plugin manifest. |
plugin/skills/ |
The plugin's ten slash commands (<name>/SKILL.md, the directory naming the command). |
plugin/prompts/ |
Internal procedures the commands Read — deliberately not commands, so users can't invoke them. |
plugin/hooks/ |
hooks.json — a PreToolUse hook guarding Bash calls at runtime (compound make, force-push, push to the default branch). Fails open. |
plugin/scripts/ |
Bundled stdlib-only Python the commands and procedures drive, plus the non-Python assets they copy out (licenses/, completions/). |
tests/scripts/ |
Pytest suite mirroring plugin/scripts/ 1:1. Not shipped. |
docs/ |
The MkDocs site. Not shipped. |
paper/ |
A LaTeX introduction — the long form of the framing above, with figures captured from real command output (render_figures.py). make paper builds it; CI rebuilds it on every commit and publishes the PDF with the docs site. |
Inside a command, ${CLAUDE_PLUGIN_ROOT} resolves to plugin/, so
"${CLAUDE_PLUGIN_ROOT}/scripts/sync.py" is unchanged by the move. Only the
source-checkout fallback paths gained the prefix: plugin/scripts/sync.py.
Branch off main, make the change, and open a PR — CI runs the same
make fmt && make test you can run locally, so a green pair means a green PR.
CONTRIBUTING.md has the details: the prerequisites, the
checklist for adding or changing a command, and the commit conventions the
changelog is generated from.
Once a change lands on main, installed users pick it up the next time the
marketplace refreshes.