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 twelve slash commands are deterministic end to end and have an exact headless equivalent. The other six 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 |
/rhiza:fix |
issue_status.py lists the issues and derives their signals |
the triage, and the fix itself |
/rhiza:history |
pr_inventory.py, pr_status.py, issue_status.py |
deciding what is obsolete, the merge order, and what comes next |
The bottom six 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, which of two designs an issue was asking for, whether a request is dead or merely waiting.
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, unmanaged-path collision, git failure) — no template files or lock written for an adoption collision |
Before writing, sync checks each destination against .rhiza/template.lock. An existing
path absent from the lock is unmanaged, even if its bytes already match upstream. All
collisions are reported together, and the sync stops before copying any template files.
Move the local path or exclude the destination in template.yml, then retry. If replacing
the local file is intentional, explicitly transfer ownership with:
That flag replaces conflicting unmanaged files and records the delivered paths in the
lock, so later syncs use the ordinary three-way merge. It also removes an unmanaged
action.yml/action.yaml sibling when adopting the other filename, since both claim the
same GitHub Action directory. Directories that block a template file are never recursively
replaced; move or exclude those paths first. The flag is deliberately opt-in and applies
to every reported file conflict in that sync.
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.