Without Claude Code¶
Some people want rhiza's template sync without an LLM anywhere near it — in CI, in a
Makefile, from a shell script, or just because they'd rather drive it themselves. That
works, and it needs nothing this repository doesn't already ship.
The reason is the plugin's central split: deterministic work belongs in tested code,
judgement belongs in markdown. Everything with one right answer — parsing the lock,
cloning a pinned ref, merging synced files, staging exactly what was delivered — is
Python under plugin/scripts/, stdlib-only, type-checked and covered. The markdown
around it supplies the judgement. Take the markdown away and the scripts still run.
This is the same code path, not a second one
Nothing here is a re-implementation. /rhiza:update shells out to the very
sync.py invocation shown below; the command's contribution is resolving which ref
to move to, reading the exit code, and writing a PR body. A separate CLI would be a
second implementation of sync to keep in step with the first, and that is the one
thing this project has decided not to maintain. One operation, one entry point.
Setup¶
git clone https://github.com/Jebel-Quant/rhiza-claude.git ~/.local/share/rhiza-claude
export RHIZA=~/.local/share/rhiza-claude/plugin/scripts
That is the whole install. No /plugin install, no rhiza package on PyPI, no
dependencies — the scripts import nothing outside the standard library, and each puts
its own directory on sys.path, so running one by absolute path works from any
directory.
Use Python 3.12. tomllib and datetime.UTC put the hard floor at 3.11, but 3.12
is the version every command pins and the only one CI exercises. A bare python3 on
macOS is 3.9 and crashes sync.py. The commands get 3.12 through uv, and so should
you:
--no-project stops uv resolving the target repo's environment for a script that
needs no environment at all. Every example below assumes those two definitions.
Most scripts take --json, which is the surface to script against: human output is
prose and may be reworded, the JSON keys are what the tests pin.
What maps, and what doesn't¶
Six of the ten slash commands are deterministic end to end and have an exact headless equivalent. The other four exist because they need a reading of your repository that no script can perform.
| Command | Headless equivalent | What you give up |
|---|---|---|
/rhiza:status |
status.py, validate.py |
nothing |
/rhiza:update |
sync.py → resolve_conflicts.py → stage_synced.py |
ref resolution and the PR body |
/rhiza:init |
init_scaffold.py, init_skeleton.py, set_license.py |
the interview that fills in the flags |
/rhiza:detach |
detach.py |
nothing |
/rhiza:completions |
install_completions.py |
nothing |
/rhiza:maffay |
maffay.py |
nothing |
/rhiza:quality |
check_make_targets.py probes the gates |
the score — reading a repo and judging it is the command |
/rhiza:docs |
render_badges.py, sync_readme_help.py |
the prose, which is most of it |
/rhiza:release |
check_version_bump.py, wait_for_merge.py, bump-my-version, git-cliff |
choosing the version, writing the changelog section |
/rhiza:remote |
pr_status.py reports what CI said |
diagnosing the failure and fixing it |
The bottom four are worth being precise about. They are not commands that were never scripted; they are commands whose deterministic half was already extracted into the scripts named beside them. What is left over in each is irreducibly a judgement: whether a finding matters, whether a breaking change should spend the 1.0 signal, what to preserve in a README someone wrote by hand.
Read-only: what state is this repo in?¶
py "$RHIZA/validate.py" . # is .rhiza/template.yml well-formed?
py "$RHIZA/status.py" . --files # what did the last sync actually deliver?
py "$RHIZA/status.py" . --check # is the pinned ref behind the latest release?
validate.py exits 0 when the configuration passes and 1 when it fails, which makes it
usable as a CI gate as-is. status.py --json gives you the lock's contents — repo, ref,
commit SHA, timestamp, strategy, file list — as one object.
The sync, step by step¶
This is /rhiza:update with the judgement taken out. Do it on a branch; the sync
refuses a dirty tree.
1. Pick the ref and bump the pointer. The command asks the forge for the template's latest release and refuses a major bump without confirmation. Headless, you name it:
TARGET=v1.5.2
git checkout -b "rhiza_$TARGET" origin/main
sed -i.bak "s/^ref: .*/ref: \"$TARGET\"/" .rhiza/template.yml && rm .rhiza/template.yml.bak
git commit -am "chore: bump rhiza to $TARGET"
Change ref: and nothing else. profiles:, templates:, exclude: and language:
are a deliberate, separate decision — a version bump should not carry one.
2. Sync. Read the exit code before doing anything else; all three are expected:
| Exit | Meaning |
|---|---|
| 0 | synced cleanly, or already up to date |
| 1 | synced with conflicts — the lock is written and merged files are on disk |
| 2 | could not sync (dirty tree, invalid template.yml, git failure) — nothing applied |
3. Resolve conflicts, on exit 1 only. Take the upstream side of every marker — a rhiza-managed file is the template's to own, so local divergence in one is drift to undo, not work to preserve:
Exit 0 means every marker is resolved. Exit 1 means a *.rej file remains and needs a
human — the sync cannot create those, so one here predates this run. Exit 2 means a
malformed conflict block and nothing was written.
4. Stage only what the template owns. Never git add --all here. The lock records
exactly which paths the sync materialized, and this stages precisely that set,
deletions included, printing anything it deliberately left behind:
py "$RHIZA/stage_synced.py" . --json
SKIP=check-managed-files git commit -m "chore: apply rhiza sync $TARGET"
Exit 1 means there is no lock — the sync never ran. Exit 2 is a git failure. Exit 3 means the lock names a path resolving outside the repository and nothing was staged; report it rather than hand-staging around it.
Keep the SKIP=. rhiza-hooks' check-managed-files refuses a commit touching any path
in the lock's files: list, and this commit is by construction exactly that list.
5. Open the request, if you want the same PR the command would open:
py "$RHIZA/platform_cli.py" pr-create --base main --head "rhiza_$TARGET" \
--title "chore: update rhiza to $TARGET" --body-file BODY.md
It reads origin and issues gh pr create or glab mr create, which differ in
subcommand and flag names. Or push the branch and open it yourself — nothing
downstream depends on how the request was created.
Becoming rhiza-managed¶
/rhiza:init detects the platform, owner and name from origin and asks when it can't.
Headless, you pass what it would have detected:
py "$RHIZA/init_scaffold.py" . --host github --language python --ref v1.5.2
py "$RHIZA/init_skeleton.py" . --owner my-org --repo my-lib --language python \
--description "..."
py "$RHIZA/set_license.py" . --license MIT --owner "My Org"
init_scaffold.py writes .rhiza/template.yml and syncs nothing — the template
content arrives with the first sync.py, exactly as it does with the command. Check
that the ref you pin actually defines the profile before you rely on it:
py "$RHIZA/check_template_profile.py" github-project \
--template-repo jebel-quant/rhiza --ref v1.5.2
A ref that doesn't define the profile fails here rather than at the first sync.
Leaving¶
Deletes every file .rhiza/template.lock records, prunes the emptied directories and
removes the lock. --force skips the confirmation prompt; without a TTY the prompt is
treated as "no" and the run cancels, so --force is required from a script.
Machine setup¶
Generic make tab-completion, installed under ${XDG_DATA_HOME:-$HOME/.local/share}.
It refuses to overwrite a completion it did not write unless --force is passed. Not
repo-specific, so it works in every project on the machine.
What this page is not needed for¶
Day-to-day work in a rhiza-managed repository already involves no LLM and no plugin.
The gates arrive as a task runner and a Makefile shim, and make fmt, make test and
make lint are what you run. The plugin only appears at the lifecycle moments — adopt,
sync, score, release — which is why the headless surface above is small.
The commands are the contract, the flags are the implementation
CI gates every flag a command passes, so a renamed flag fails the build rather than a user's task. That check spans the commands only. If you script against these invocations directly, pin the clone to a release tag and read the changelog before moving it — you are using the engine, not a published CLI, and it is versioned as part of the plugin rather than on its own.