Updating, or undoing the install
Audience: newcomer, after the first install. Owner of the rewrite: HIMMEL-7212.
The task: bring one himmel install up to a newer version and confirm it landed. Six steps: choose your route, confirm the target, preview, update, verify, and recover if a step failed. To take himmel back out instead, see Undo the install. The reference below both explains each part of the update in depth.
Marketplace autoUpdate does not deliver himmel updates on its own: the
hooks and commands run out of your himmel checkout, so only a pull changes them
(why).
This updates the himmel harness. A configured
LUNA_VAULT_PATHis already refreshed by/himmel-update’s own dependency chain (step 6 of the update chain); reserve/luna-upgradefor an explicit, Luna-only run, and/himmel-update-allfor running both the harness and the vault update together deliberately. To bring multiple luna-family vaults up to the current template in one pass, use/luna-upgrade-all(see Updating multiple vaults below).
Not on
himmelctlyet? This page assumes the current installer is already what wired your machine. If himmel got there by an olderadopt.shrun, by hand, or by ahimmelctlolder than this one, converge first: migrating.md has the four from-states, how to tell which one you are in, and the converge path for each.
Choose your route
Two facts about your install decide the commands: how himmel reached the machine, and where it is wired.
How it reached the machine:
| You installed from | The update | Detail |
|---|---|---|
A git checkout (git clone), no release channel recorded | git pull --ff-only on the checked-out branch, then the rest of the chain | The update chain |
A git checkout with channel: stable or pre (what himmelctl install records on a new station) | Detaches onto the newest matching release tag; never downgrades | Release channels |
A release tarball with the versioned layout (~/.local/share/himmel/<version>/, current symlink) | himmelctl update downloads, verifies and swaps current | Installs without a git checkout |
A native package (for example under /usr/share/himmel) | Your package manager (Arch: pacman -Syu himmel) | Installs without a git checkout |
Where it is wired (the installation scope you chose at install):
- User scope: the hooks in
~/.claude/settings.jsonrun from the himmel download, so updating the download is the whole update. - Project scope: the install copied the hooks and scripts into the
project’s own
scripts/and.claude/. Updating the download does not refresh those copies; after the update, re-run the project install (step 4 under Update) and commit the result.
Not sure which scope you have? node ~/himmel/scripts/himmelctl/bin.js scope get
prints it, and ${HIMMELCTL_CACHE_DIR:-~/.claude/himmel}/install-profile.json
records it with your other install answers.
Confirm the target
The update acts on the himmel checkout that holds the script you run, not on
your current directory: bash ~/himmel/scripts/himmel-update.sh updates
~/himmel wherever you run it from. On this page ~/himmel stands for your
himmel root: the checkout, or the install root of a tarball
(~/.local/share/himmel/current) or native package (for example
/usr/share/himmel). The tree, branch and fork checks below apply to a git
checkout only. Before you update:
- Which version is it on?
himmelctl --versionprints theVERSIONfile, thegit describestring and the commit (detail). - Is the tree clean? An apply run refuses a checkout with uncommitted
changes. Commit or stash them first (
git -C ~/himmel status), or read Recover for the autostash opt-in. - Which branch? The pull lands on the checked-out branch’s upstream. Keep the download on its default branch; a feature branch takes the update the way it takes any upstream change (merge or rebase).
- A fork? A plain
git pull originonly pulls your fork; add anupstreamremote (Notes).
Preview
Neither command pulls, rebuilds, restarts or touches your working tree. Both
fetch remote refs to compare against, which writes Git refs but never moves
HEAD:
bash ~/himmel/scripts/himmel-update.sh --check # what each update step would dobash ~/himmel/scripts/himmel-update.sh --versions # installed vs available, one row per component--check reports the chain without pulling (on a release channel the fetch
includes tags). On a tarball or
package install it compares the VERSION file with the latest release tag
instead. For a project-scope install, also preview what re-running the install
would change in the project:
cd <your-project>bash ~/himmel/scripts/adopt.sh --profile core --scope project --dry-run # prints "would replace <file>" per changed copyUpdate
-
Run one of these; they are the same engine:
Terminal window /himmel-update # inside Claude Codebash ~/himmel/scripts/himmel-update.sh # from a shellnode ~/himmel/scripts/himmelctl/bin.js update # or himmelctl update / himmel updatehimmelctl updateon a tarball install with the versioned layout takes the download-and-swap route instead of the chain. -
Read the closing status table: each step reports
updated,up-to-date,skipped,failedornot-attempted, and a DRIFT block names anything the installer could not converge, each with a remedy line. -
Read the
.env migrationblock: it shows where each.envkey would go and asksMove these keys now (backup first)? [y/N]; after a move it names the backup path and the restore command (detail). -
If the graphify pin sync skipped because a Claude session was live, run
bash ~/himmel/scripts/himmel-update.sh --graphify-onlyonce none is. -
Project scope only: from the project root, re-run the install without
--dry-run. Each copy it replaces is backed up under the install’s provenance directory (provenance-backups/). Commit the result through a branch and PR, as you committed the install (install.md):Terminal window bash ~/himmel/scripts/adopt.sh --profile core --scope project -
Read the release notes for the version you landed on: a one-time migration or a moved config field is carried there, not here.
Verify
bash ~/himmel/scripts/himmel-update.sh --versionsExit code 0 means no component is behind or undetermined, 1 means at
least one is still behind, and 3 means none is behind but one could not be
determined (for example offline). A component that is not installed or has no
release yet reads n/a and does not change the exit code. Then:
- Hooks take effect on the next tool call; no restart is needed.
- Plugins, slash commands and skills load at session start: restart any running Claude session to pick them up (detail).
node ~/himmel/scripts/himmelctl/bin.js statusshows any install item that still differs from the manifest;node ~/himmel/scripts/himmelctl/bin.js ensureconverges it.
Recover
- A step failed. The first genuine failure aborts the chain: later steps
report
not-attemptedand the script exits non-zero. Steps that already succeeded are not undone. Fix the cause the step printed, then re-run that step alone, then each later step that reportednot-attempted, for examplebash ~/himmel/scripts/himmel-update.sh --only marketplace(the step names). - The .env migration failed its check.
.envis unchanged: eachMISMATCH <key> (<reader>)line names a reader that sees a different value after the move, or a value~/.himmelalready holds that differs from.env. Make them agree, then runbash ~/himmel/scripts/himmel-update.sh --only env_migration. - You want the old
.envback. Run therestore:command the migration printed (cp <backup> <your .env>). After a move that passed its check, the copies in~/.himmelcan stay: they hold the same values. After aMISMATCHon a value~/.himmelalready held, restoring.envdoes not change that value: make the two agree yourself. - The pull refused a dirty tree. Commit or stash, then re-run. To keep
local tracked diffs on purpose, autostash them for one run:
HIMMEL_UPDATE_AUTOSTASH=1 bash ~/himmel/scripts/himmel-update.sh. If the reapply conflicts, your changes stay ingit stash list. Autostash does not apply on a release channel. - The branch diverged from upstream.
git pull --ff-onlyfails loud; reconcile the branch by hand, then re-run. On a release channel, aHEADthat diverged from the tag fails the same way rather than guessing. - A tarball update went wrong. A failed download or verify leaves
currentuntouched. The previous version directory is kept, soln -sfn <previous-version> ~/.local/share/himmel/currentrolls back. - A project-scope re-install replaced a copy you had edited. Restore it
from
provenance-backups/, then commit.
Undo the install
One command from the same project folder. It shows its plan and asks before it acts.
# preview: prints the footprint, changes nothingnode ~/himmel/scripts/himmelctl/bin.js uninstall --dry-run# the real thing (asks Proceed? [y/N])node ~/himmel/scripts/himmelctl/bin.js uninstallUndo first, then delete the download: the uninstaller lives in it. The
install also keeps a copy in ~/.himmel/uninstall/, so undo still works if
the download is already gone. Run the copy from the same project folder:
node ~/.himmel/uninstall/standalone.js uninstall (add --dry-run to
preview). The himmelctl launcher on your PATH switches to that copy by
itself.
Removed:
- The three git hooks (your own are restored).
- himmel’s wiring in project and user
settings.json; your own keys stay. - Plugins and marketplaces the install recorded as its own.
- The status-line config, the
himmelctllauncher, the cache and the trust entry it created.
Kept on purpose:
- Telegram pairing, your settings in
~/.himmeland the install record (add--purge-state). - The himmel download, its
.env, your worktrees and handover notes. - Copied scripts the install record cannot prove are its own.
The himmel alias in ~/.local/bin also stays; delete it yourself. Every
item, with its path and owner:
What changes on your machine.
This is the plan the preview printed on the trial project: a clean starter
install at project scope on a test VM, on 2026-10-21. It is copied from that
run; only the throwaway home folder is shortened to ~.
==> himmel uninstall (offboard)
This will: 1. stop the telegram bun bridge (if running) and disable + remove the telegram-bridge systemd unit the ledger recorded (linger only if it was not already on) 2. KEEP telegram pairing + bridge state (pass --purge-state to remove it): ~/.claude/channels/telegram ~/.claude/handover/bridge 3. remove HIMMEL-Resume-* scheduled jobs (+ HimmelTelegramBridge logon task) and the cron/at jobs the ledger recorded a cadence arm adding (exact recorded names only) 4. uninstall installed Claude plugins from himmel-owned marketplaces (project/local: current project only; USER-SCOPE: affects every repo on this machine; fallback scope: project) 5. uninstall this repo's git hooks (pre-commit/pre-push/commit-msg) and restore any of your own hooks himmel displaced (<hook>.himmel-backup -> <hook>) 6. unwire ~/.claude/settings.json (statusLine, HIMMEL_REPO, LUNA_VAULT_PATH, HANDOVER_DIR, UNIVERSAL hooks — non-himmel keys untouched) and current-project settings: ~/himmel-trial/.claude/settings.json (himmel's own checkout excluded) and every other project's settings the ledger recorded (himmel's own checkout excluded) and strip himmel's working-principles block from ~/.claude/CLAUDE.md (your own text untouched) and strip himmel's working-principles block from ~/.codex/AGENTS.md (your own text untouched) and remove himmel's claude-hud config: ~/.claude/claude-hud.json 7. remove Claude marketplaces with no remaining installed plugins 8. REMOVE the himmelctl cache + state: ~/.claude/himmel (install-profile.json, state.json — a re-install would otherwise start from the previous install's profile)Staying? Change a setting or an install profile instead: install profiles and settings.
Flags, exit codes and the eight steps
himmelctl uninstall --yes confirms without prompting; --dry-run (-n)
runs the teardown script’s own --dry-run, so the preview is exactly what a
real run would do, and changes nothing. By default only himmel’s code
wiring is removed and operator state (your Telegram pairing + bridge state)
is kept; --purge-state removes that too, unless the bridge is still running
(a guarded purge is refused and leaves the state intact). himmelctl does not
take --keep-telegram-state; scripts/uninstall.sh still accepts it as the
older spelling of the default, and it contradicts --purge-state. Exit codes: 0 = torn down (or preview complete),
2 = non-interactive refusal without --yes, 3 = operator declined;
otherwise the teardown script’s own code is returned. For uninstall.sh,
2 also means bad usage or an incomplete teardown (any failed step halts
later steps), and 3 means the live-HOME safety fence refused a wet run.
Under the hood it derives + confirms, then execs
scripts/uninstall.sh, a symmetric eight-step teardown of what setup.sh/adopt onboard:
stops the Telegram bridge, removes its pairing + bridge state (only with
--purge-state, and never while the bridge is still running), deletes the
HIMMEL-Resume-* scheduled jobs, uninstalls the installed Claude plugins at
their own scope, uninstalls the repo’s git hooks (and restores a hook that
install displaced to <hook>.himmel-backup when its target is free; otherwise
it keeps both the current hook and the backup), unwires the user-scope
~/.claude/settings.json keys himmel added, removes the Claude marketplaces
(only those no installed plugin still needs), and removes the himmelctl
cache + state dir. A provenance ledger, when install wrote one, decides
ownership of the six overwrite-prone rows: your own pre-existing statusLine,
env.HANDOVER_DIR, plugins, marketplaces, the claude-hud config and
adopter-script copies are kept with a printed hand command rather than
guessed at by template; with no ledger, those same six rows are kept the same
way (hand command, never guessed) while everything else still falls back to
template ownership. It is destructive and fail-closed
(a non-interactive run aborts without --yes). Invoke uninstall.sh directly
for the manual or CI path; preview any run with --dry-run, and skip
individual steps with --skip-plugins / --skip-hooks / --skip-tasks /
--skip-settings. A real run on a home folder that holds your Claude login,
an SSH key or ~/.gitconfig refuses (exit 3) unless
HIMMEL_UNINSTALL_REAL_HOME=1 is set; himmelctl uninstall sets it for you
after you confirm:
bash scripts/uninstall.sh --dry-run # preview; nothing is executedHIMMEL_UNINSTALL_REAL_HOME=1 bash scripts/uninstall.sh --yes # remove code wiring, keep operator stateHIMMEL_UNINSTALL_REAL_HOME=1 bash scripts/uninstall.sh --yes --purge-state # ...and remove operator state + the provenance ledger tooHIMMEL_UNINSTALL_REAL_HOME=1 bash scripts/uninstall.sh --yes --purge-state --keep-backups # ...but keep the ledger's backup copiesThe paths it acts on are listed in
scripts/install/uninstall-manifest.tsv
(the script reads it); after the settings step it re-reads the file and prints
verified: no himmel wiring left in <file>.
It deliberately leaves the himmel clone, your .env, worktrees, handover state
outside the bridge root, and non-himmel settings.json keys untouched. hermes
is not torn down: it installs outside the repo (~/.hermes or
$HERMES_HOME); stop its gateway and remove that directory by hand if you are
decommissioning it (see the hermes runbook).
Reference
Updating multiple luna vaults
If you keep more than one luna-family vault (e.g. a personal vault plus a
work vault), /luna-upgrade-all
sweeps them all in one best-effort, dry-run-first pass instead of running
/luna-upgrade once per vault:
- Sweep: discovers your vaults (from
~/.claude/luna-vaults.jsonand a scan of~/Documents), classifies each (luna-family vs not-yet-stamped), and prints a per-vault table: already-current / clean-upgrade / conflict. - Per-vault confirm: it never auto-applies. You approve each vault before it is upgraded.
- Backup before every apply: a timestamped snapshot is written to
~/.claude/luna-upgrade-backups/<vault-slug>/<timestamp>/before any change (<vault-slug>is the vault’s directory name with non-alphanumeric characters replaced by_). If a backup can’t be written, the upgrade is aborted and the vault is left untouched. To undo a vault, runluna-upgrade-all.sh restore --vault <path>(add--listto see available backups). - Conflict help: if your customized
_CLAUDE.mdconflicts with the new template, the command proposes a merged version for you to approve rather than failing or silently overwriting your edits. On a heavily-customized vault this conflict is a permanent shape, not a transient (git merge-fileexits 2 and the upgrade reports “NOT writing the version stamp” every time). If the new template’s semantic content already reached your_CLAUDE.mdanother way, resolve ours-wins (change nothing) and hand-stamp.vault-template.jsonto the new version so the next sweep reports current. Note the upgrade refreshing template-owned.obsidianfiles on a live vault is designed behavior, with one consequence to check before committing the result: bundled plugin files (.obsidian/plugins/*/{main.js,manifest.json,styles.css}) are overwrite-class, so the upgrade can clobber a plugin you’ve since updated with the template’s OLDER bundled copy. If your installed plugin versions are newer, restore the affected plugin paths from the timestamped pre-apply backup (~/.claude/luna-upgrade-backups/<vault-slug>/<timestamp>/) before committing, or abandon the whole upgrade withluna-upgrade-all.sh restore --vault <path>.
It is best-effort: a vault with uncommitted git changes under a path the
upgrade would touch (any path in its dry-run plan, the version stamp) still
appears in the sweep table (flagged dirty=true, plan shown), but apply
refuses it until those paths are committed or stashed. Uncommitted changes
elsewhere (e.g. handovers/, your notes) do not block an apply. A vault that errors during its
dry-run is reported with error state and the sweep continues to the rest. Your
notes, journal, and data are never touched; only template-owned files are
refreshed. Obsidian vaults without a luna stamp show as unstamped and are left
alone (upgrade them deliberately, per vault, with apply --force-unstamped);
directories that aren’t Obsidian vaults are skipped entirely.
Why a pull is required
himmel ships as two delivery layers, and only one of them is a “plugin”:
| Surface | Where it lives | Updated by |
|---|---|---|
Core hooks (scripts/hooks/*) | your checkout, run from $CLAUDE_PROJECT_DIR | git pull |
Slash commands (.claude/commands/*) | your checkout | git pull |
settings.json wiring, scripts/*, CLAUDE.md, docs | your checkout | git pull |
himmel plugins (marketplace/plugins/*) | your checkout, sourced ./plugins/* | see below |
Upstream plugin (claude-obsidian) | GitHub AgriciDaniel/claude-obsidian, tag-pinned | only when the pin is bumped (= a git pull) |
Vendored plugin (obsidian) | GitHub, SHA-pinned | only when the pin is bumped (= a git pull) |
The hooks and commands are not plugins: they execute out of your repo checkout, so nothing but a pull changes them.
The himmel plugins depend on how the marketplace was registered:
- Default setup (
settings-template.json) registers the marketplace from a localdirectorysource (path: <himmel-path>/marketplace) withautoUpdate: true. A directory source has nothing to fetch over the network;autoUpdateonly re-syncs plugins from the on-disk dir, which changes only when yougit pull. So with the default setup, everything is pull-gated, plugins included. - Direct-install path (
claude plugin marketplace add yotamleo/himmel) uses a github source. ThereautoUpdatepulls a separate Claude-managed clone from GitHub, so plugin updates arrive without pulling your working checkout, but the core hooks and commands still run from your checkout and still need a pull. Plugin versions change only in a release cut, and Claude Code replaces its cached copy only when the version differs, so a plugin change onmainreaches this install with the next release. A fresh install between releases getsmain’s content under the last release’s version; pin the marketplace source to a release tag to get exactly a release.
Either way: a git pull is required for the full update; autoUpdate never
covers the core hooks.
The update chain
Every update ends with an installer-drift pass (--only drift,
read-only twin --only drift-check, also run by --check). It runs
himmelctl status --json, converges the allow-listed items (today
pre-commit-hooks, via himmelctl ensure --items <id> --yes) and re-checks, then
prints everything it could not converge in a loud DRIFT block with a remedy
line: credentials such as a luna-sources
re-auth, a leaked url.*.insteadOf in the repo’s local git config (WARN with the
unset command, never auto-deleted), or a codex plugin-unregistered that the
installer did not fix.
ensure --items <id> is scoped to exactly those items: it does not
run the additive recorded-profile reconcile (that belongs to bare ensure,
install and update, which converge the whole profile), never writes other
items into the target’s state.json, and for pre-commit-hooks runs
adopt.sh --only-hooks (git gate hooks only: no settings, plugin, marketplace,
statusline or CLAUDE.md changes). A requested item whose own post-check is green
is not failed by an unrelated installer step; it prints a warning instead.
scripts/himmel-update.sh, in apply mode (no --check),
refuses to run against a dirty checkout (uncommitted changes); commit or
stash first, then re-run. (The read-only --check mode does not reject a dirty
tree; it reports the chain without pulling.) If you are developing with local
tracked diffs you deliberately keep (e.g. locally-installed skills that append
to skills-lock.json / .gitignore), set HIMMEL_UPDATE_AUTOSTASH=1 to
autostash them around the pull instead of refusing:
HIMMEL_UPDATE_AUTOSTASH=1 bash scripts/himmel-update.sh. Prefer the
per-invocation form shown; exporting it in your shell profile leaves the
dirty-tree guard defeated for every future update (any dirty state), so unset
it when done. If the reapply conflicts (upstream changed the same file) the pull
still applies but the step reports failed and your changes are preserved in
git stash list for manual recovery. On a clean tree it runs a
six-item dependency chain, in order, each item
reporting updated / up-to-date / skipped / failed / not-attempted
in a closing status table:
git pull --ff-only(fails loudly if your branch has diverged from upstream or local edits block the update. Reconcile manually; updates land on the currently checked-out branch’s configured upstream).claude plugin marketplace update himmel: refresh plugins from the freshly-pulled local dir.- jira CLI dist rebuild:
scripts/jira/distis a gitignored build artifact; a pull that changes the jira TypeScript source needs a rebuild to take effect. - qmd fork update: qmd ships from a himmel-pinned fork outside this
checkout, so
git pullhere never touches it. - hermes junior-tier update: hermes is a separate editable git checkout outside this repo; pulls + reinstalls it, skipping cleanly when hermes isn’t installed.
- luna template upgrade (
LUNA_VAULT_PATH): content-preserving refresh of template-owned vault files only.
The first genuine failure aborts the chain: later items report
not-attempted, the status table still prints, and the script exits
non-zero. There is no rollback or atomicity: items that already succeeded
before the failure are not undone; only the not-yet-attempted items are
skipped.
After a clean chain the .env migration runs; see below.
After the chain (win or lose, these never abort and always run,
including on a chain failure), six best-effort advisory
steps run: a codex plugin re-sync, a
statusLine hud re-wire, a plugin install-state report
(marketplace update only re-syncs already-installed
plugins, so it can’t surface a himmel plugin that is missing or being served
from a non-@himmel marketplace; the report prints the
claude plugin install …@himmel / migrate commands for any gap; run it
standalone with bash scripts/himmel-update.sh --plugins-check), a lean
plugin-set reconcile, stale cadence-runner / guardrail-mode
block drift checks, and a retired-plugin removal offer (scripts/machine-setup/remove-retired-plugin.sh: silent when the retired
response-compression plugin is absent; on a TTY it asks Remove now? [Y/n]
with the default being remove; a non-interactive run prints the advisory and
the two manual commands and removes nothing).
The .env migration
.env holds secrets only; behaviour and policy keys live in ~/.himmel
(layer table). After a clean chain the update
moves himmel’s own .env items: the keys .env.example names (commented or
not) and the behaviour and gate-policy keys. Any other key belongs to another
tool and stays where it is. An update --check previews the step and
--only env_migration runs it alone.
The step is scripts/setup/migrate-env.sh;
bash ~/himmel/scripts/setup/migrate-env.sh --dry-run prints the plan
without the rest of the update. It prints key names only, never values.
An apply, from the update or run by hand, always prints the plan and the
backup path first, then asks Move these keys now (backup first)? [y/N].
Only y moves anything; any other answer, Enter included, skips the step and
prints the command to run later. With no terminal to answer at (piped input,
a cron job, a hook) it moves nothing, prints that command and exits 3.
- Plan. One line per
.envkey: a behaviour key goes to~/.himmel/himmel.conf, a gate-policy key to~/.himmel/config.json.policy, a himmel secret nothing in himmel reads to~/.config/secrets/himmel.env(mode 600). A himmel secret a himmel file still readsstays (read by <file>); if the readers cannot be searched, every himmel secret stays. Any other key readsstays (not himmel's, left in place). - Backup.
.envis copied to~/.himmel/env-backups/env.<UTC time>.<pid>, mode 600, before anything else is written; the path is printed. - Copy. Each key is copied to its new place. A key already set there is left alone.
- Check. The real readers run twice, once with the keys only in
.envand once with them only in their new place: the shellload_dotenvandload_policy, the Jira CLI’s config, handover-root and release-state readers, and the lanes resolver. Every gate-policy key is compared, moved or not. Any difference, or a reader that fails, printsMISMATCH <key> (<reader>)(or averify:line) and no line is removed from.env. - Remove. Only after the check passes are the moved lines removed from
.env. Comments and the keys that stay are kept, and so is the file’s mode.
The report ends with the restore command (cp <backup> <your .env>) and an
rm for the backup: remove it once you are happy, since it holds your secrets.
- Keep-list. To keep a himmel secret in
.envfor a reader outside this checkout (a station script, another tool), put its name in~/.himmel/env-keep.txt, one per line;#starts a comment. Names only, never values. Behaviour and gate-policy keys always move. - Inside Claude Code (
/himmel-update), an agent may not write~/.himmel/config.json, so the step prints the plan and the command to run at your own prompt, writes nothing, and its status row readspending. - The secrets store is one mode-600 file for now; a keyring store replaces it once that ships.
Release channels
By default, scripts/himmel-update.sh’s pull step is exactly what it has
always been: git pull --ff-only on whatever branch you have checked out.
Nothing below changes that unless you opt in.
Opting a station into a release channel makes the pull step follow a tagged release instead of your branch tip:
channel: stable: follows the highestvX.Y.Zrelease tag (no-pre.Nsuffix).channel: pre: follows the highest tag overall,-pre.Nprereleases included (a stable tag still outranks a-pre.Nof the same major.minor.patch;-pre.10outranks-pre.9, compared numerically, not as text).
Set it two ways, checked in this order (first one set wins):
HIMMEL_UPDATE_CHANNEL=stableorHIMMEL_UPDATE_CHANNEL=pre: env var, per-invocation or exported.channelin your install profile:${HIMMELCTL_CACHE_DIR:-~/.claude/himmel}/install-profile.json, the same cachehimmelctl installwrites its answers to.himmelctl installdefaults a new adopter’s profile tochannel: stable; it never overwrites a profile that already has achannelvalue.
Neither set → the channel is unset and the pull step is unchanged plain
git pull --ff-only.
On a channel, --check (or apply mode’s pull step) fetches tags and
resolves the highest matching one, then compares it to HEAD:
- Already on that tag → up to date, nothing to do.
HEADan ancestor of the tag (behind) →--checkreportsbehind stable vX.Y.Z (at <describe>); apply mode runsgit switch --detach vX.Y.Z(nevergit checkout; this repo hard-refuses that shape).HEADa descendant of the tag (ahead: you’re on newer work than the last release) → left alone, reportednot behind — leaving as-is. A channel never downgrades you.HEADand the tag diverged (neither is an ancestor of the other) → the pull step fails loud rather than guess which one you want.- No tag exists yet on the channel →
stablewith no release tags prints exactlyno stable release yet — nothing to followand exits0; it does not fall back to pulling the branch.
Apply mode never moves a dirty checkout onto a tag: git switch --detach
refuses on uncommitted changes the same as a plain pull would, and
HIMMEL_UPDATE_AUTOSTASH=1 does not apply here: autostash’s
stash/pull/restore semantics are specific to git pull, not git switch, so
a channel-following station must commit or stash by hand before updating.
--check never moves HEAD or touches the working tree on a channel, same
guarantee as the unset-channel path. It does fetch tags from origin first
(writing Git objects/refs, same as any git fetch), but never switches,
pulls, or otherwise changes what you’re on.
Installs without a git checkout
A release-tarball or native-package install (for example under
/usr/share/himmel) has no .git, so there is no upstream to pull. himmel
tells that case apart by the install’s own root having no .git, and:
bash scripts/himmel-update.sh --check(or--dry-run) compares the install’sVERSIONfile with the latest published release tag and prints one of: a newer release is available, up to date, no release published yet, or could not check with the reason (network,http-403for a rate limit,no-curl,bad-response; a missingVERSIONis reported too). A failed check is never reported as “up to date”.--checkstill exits0either way.bash scripts/himmel-update.shand--only pullrefuse (exit1), change nothing, and name the update route. They never rungit pull, so an install nested inside some other repository cannot pull that repository by accident.- The session-start nudge does the same release-tag comparison and tells you
when a newer release exists. It never waits on the network: the lookup runs
detached and the nudge reads what the previous one left. If no lookup has
succeeded for 7 days (
UPDATE_CHECK_STALE, seconds) it says the check could not run, with the last error, instead of staying silent.
To actually update such an install, use the route it came from. A release
tarball installed with the README recipe has the versioned layout (each release
in ~/.local/share/himmel/<version>/, with current pointing at the live one;
himmelctl update updates it: it downloads the latest
release, verifies its sha256 (and its attestation when gh is installed and
logged in), extracts it into a new version directory and only then swaps
current. A failed download or verify leaves current untouched. The previous
version is kept, so ln -sfn <previous-version> ~/.local/share/himmel/current
rolls back. For a native package, use the package manager (Arch: pacman -Syu himmel). For any other tarball install, download the next release tarball from
the releases page, verify its
sha256 checksum, and extract it into a fresh directory (or remove the old
install first): extracting over an existing tree keeps files the new release
deleted.
The only thing the check fetches is the latest release tag, from a fixed HTTPS URL that no environment variable can redirect; nothing is downloaded or executed.
Is this station up to date?
himmelctl --version # himmel <VERSION> + git describe string + commithimmelctl --version --all # ... plus one line per updatable componentbash scripts/himmel-update.sh --versions # the same report, without himmelctl--version prints the VERSION file, the git describe --tags string
(v0.3.0-pre.6 exactly on a tag, v0.3.0-pre.6-N-g<sha> when N commits past it)
and the full commit. A checkout that is not itself a git root prints unknown
for both rather than reading an enclosing repository.
--all (himmel-update.sh --versions) prints one row per component (the himmel
checkout, marketplace plugins, jira CLI build, qmd fork, hermes, luna template,
cli-proxy pin, and node / npm / bun) with the installed value, the available
one, and current, behind, unknown or n/a (not installed here; never
counted). node is compared with .nvmrc; npm and bun have no pin and are shown
as info. The report is read-only and runs the same probes as --check
(it pulls, rebuilds and restarts nothing). Exit code: 1 when any component is
behind, 3 when none is behind but one could not be determined (for example
offline), 0 otherwise.
After upgrading
A pull that moves you to a new release, whether the plain branch pull or a channel detaching onto a new tag, is not the end of the update. Two things still need doing:
- Project-scope adopters must re-run
adopt.sh. A pull changes the files in this checkout; it does not re-wire a project that adopted himmel at an earlier commit. Re-run the same adopt command you used originally so the project’s.claude/settings.jsonand hooks pick up whatever this release changed. - Read the release notes for the version you landed on. Any upgrade step specific to that release (a one-time migration, a config field that moved, a dependency bump) is carried in that release’s notes, not repeated here.
Machine-local catch-up
A merged pin bump reaches this checkout’s repo. It does not reach the
machine. git pull updates scripts/lib/graphify-bin.sh and
scripts/setup/cli-proxy-lane.sh; it does not reinstall graphify, swap the
running cli-proxy binary, or reload the qmd daemon.
That staleness is invisible to scripts/check-plugin-drift.sh. For a
tag_release / mode: base entry the guard reads synced_base (a literal in
scripts/upstreams.json that apply-drift-bump.sh moves together with the
in-repo pin), never the installed artifact. So the row flips to CURRENT the
instant the bump merges, while the host keeps running the old build. No
BEHIND row will ever tell you about it, which is why these steps are
unconditional rather than drift-driven:
- graphify pin sync: reinstalls at the pinned version. When
live
graphify-mcpprocesses hold theuvtool dir it refuses to force and prints the exact reinstall command instead. - cli-proxy-api host roll: rolls the proxy only when the
machine’s version stamp is strictly older than the
VERSIONpin. A host at or ahead of the pin is left alone (never a downgrade), and a version that cannot be compared (nopython3, or a stamp that is not a version) is left alone too, with the manual roll command printed instead.cli-proxy-lane.showns the risk when it does roll: pin-aware, refuses to bounce under a live codex-lane client, staged rollback, health-gated on/v1/models. - installed-marketplaces catch-up:
scripts/upstreams/update-marketplaces.shre-syncs themkt-manualrows (autoUpdateoff) nothing else refreshes. Failure-isolated: every row is attempted regardless of the previous row. - qmd daemon-restart notice: a new qmd build on disk does not reload the daemon already resident on :8181, and stopping that process is termination an agent may not perform. The step prints the operator commands; it never kills anything.
- claude CLI sync (HIMMEL-5501):
claudeis held to a launch floor (2.1.280, the first release with Opus 5.5 and--autocompact; one constant inscripts/lib/claude-version.sh). In apply mode a Claude Code below the floor or behind the latest is updated by its owner’s updater (nativeclaude update; npmnpm install -g @anthropic-ai/claude-code@latest; brew/pacman installs print the needs-you command, never run), then re-checked; still below the floor fails the step.--checkonly reports. Below the floor, armedconsole/headed-arm/arm-resumelaunches refuse namingclaude update(print-only and--dry-runwarn), andhimmel-doctorcheck C63 WARNs. - node/npm/bun toolchain: reports node against the pinned
.nvmrcat the pin’s own granularity (major, major.minor or major.minor.patch) as match / behind pin / ahead of pin, and a non-numeric pin such aslts/*as a plain report (never moves it: a live lane is very likely running on the current node the instant this runs; prints thenvm/fnmfix instead) and self-upgrades npm/bun in place via their own built-in updaters. Ifbunis missing entirely, the qmd fork step’s “skipped” status gets a loud follow-up warning (qmd fork update DISABLED) instead of reading as a routine no-op.
--only <item> re-runs any single step (pull, marketplace, jira_cli,
qmd_fork, hermes, luna_template, graphify, cli_proxy, marketplaces,
toolchain) without walking the chain, for the one thing that did not land.
The nightly /drift-fix cadence runs this whole engine as its step 1A, so a
machine that is on all day catches up without anyone asking.
Plain git pull works too if you don’t need the rest of the chain.
When changes take effect
- Hooks: immediately on the next tool call; PreToolUse/etc. re-read the script from disk each invocation. (This is why the hook fix needs only a pull, no restart.)
- Plugins / slash commands / skills: loaded at session start; restart any running Claude session to pick them up.
Lean plugin set — keeping session-start context low
Every enabled plugin injects its agents + skills + commands into the
context at session start, whether or not you use it. Ad-hoc /plugin installs
(and older maximal templates) drift the enabled set upward over time, and the
plugin install step is additive (it enables the lean template’s plugins but
never disables the extras), so a manual prune used to drift back after the next
update.
The lean floor is the set of plugins flagged true in
docs/setup/settings-template.json. To reconcile your
~/.claude/settings.json back down to it:
# preview what would be disabled (writes nothing)bash scripts/machine-setup/reconcile-enabled-plugins.sh --dry-run# applybash scripts/machine-setup/reconcile-enabled-plugins.shIt uses a whitelist: only floor plugins survive; every other enabled plugin
(including a future one that ships enabled) is set false. /himmel-doctor
(check C15) reports drift read-only, so you can see the cost before acting.
Keep a plugin you actually want: add it to ~/.claude/settings.local.json.
The reconciler reads this sibling file and bakes its entries into
settings.json on every run, in both directions (a true keeps an off-floor
plugin like codex@openai-codex enabled; a false disables a floor plugin like
playwright), so your choice survives each reconcile. The override is enforced
by the reconciler itself, not by Claude Code’s runtime settings precedence:
{ "enabledPlugins": { "codex@openai-codex": true, "playwright@claude-plugins-official": false, "luna-correlate@himmel": false }}(All vendored @himmel plugins stay true in the shared template so adopters
get them; disable the ones you personally don’t run (like luna-correlate)
here, without editing the shared floor.)
/himmel-update is adopter-safe: by default it only warns about drift.
To have it enforce the floor on every update (the operator’s “prune stays
pruned” switch), export the opt-in and persist it in your shell profile (once
per machine) so it reaches /himmel-update and every new shell:
echo 'export HIMMEL_RECONCILE_PLUGINS=1' >> ~/.bashrc # or ~/.zshrc / your profileexport HIMMEL_RECONCILE_PLUGINS=1 # this shell, nowOther lanes inherit this for free: claudex workers seed their config from the
same ~/.claude/settings.json. (The codex lane uses a different plugin model: its
leanness is governed by what install-himmel-codex.sh provisions, not an
enabledPlugins map, and himmelctl’s full-set enable stays a deliberate opt-in
that settings.local.json protects.)
Notes
- On a feature branch? A pull lands updates on the default branch; merge or
rebase it into your branch as you would any upstream change. The hook files
resolve from
$CLAUDE_PROJECT_DIR, so the updated file is on disk as long as the default branch is checked out at pull time. - Forked
yotamleo/Himmelinstead of cloning it directly? Add anupstreamremote andgit pull upstream main; a plaingit pull originonly pulls your fork. - Whether the default registration should switch to a github (floating) source so plugins auto-deliver without a pull is a deliberate trade-off (it would leave the core hooks pull-only regardless).
- Vendored plugin served from its external marketplace? If
--plugins-checkflagsclaude-obsidian/obsidianas “served from another marketplace”, an external auto-updating copy is shadowing the himmel SHA pin. Migrate it to the pinned@himmelsource operator-present, in a fresh session:bash scripts/machine-setup/migrate-plugin-to-himmel.sh --apply <name@market> …(it mutatessettings.jsonviaclaude plugin, so it is an operator step). Run it from a shell where theclaudeCLI is onPATH. The external marketplace can silently re-appear later (itsautoUpdatere-adds it), so re-run the migrate whenever--plugins-checkflags the shadow again.