Skip to content

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_PATH is already refreshed by /himmel-update’s own dependency chain (step 6 of the update chain); reserve /luna-upgrade for an explicit, Luna-only run, and /himmel-update-all for 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 himmelctl yet? This page assumes the current installer is already what wired your machine. If himmel got there by an older adopt.sh run, by hand, or by a himmelctl older 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 fromThe updateDetail
A git checkout (git clone), no release channel recordedgit pull --ff-only on the checked-out branch, then the rest of the chainThe 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 downgradesRelease channels
A release tarball with the versioned layout (~/.local/share/himmel/<version>/, current symlink)himmelctl update downloads, verifies and swaps currentInstalls 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.json run 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 --version prints the VERSION file, the git describe string 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 origin only pulls your fork; add an upstream remote (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:

Terminal window
bash ~/himmel/scripts/himmel-update.sh --check # what each update step would do
bash ~/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:

Terminal window
cd <your-project>
bash ~/himmel/scripts/adopt.sh --profile core --scope project --dry-run # prints "would replace <file>" per changed copy

Update

  1. Run one of these; they are the same engine:

    Terminal window
    /himmel-update # inside Claude Code
    bash ~/himmel/scripts/himmel-update.sh # from a shell
    node ~/himmel/scripts/himmelctl/bin.js update # or himmelctl update / himmel update

    himmelctl update on a tarball install with the versioned layout takes the download-and-swap route instead of the chain.

  2. Read the closing status table: each step reports updated, up-to-date, skipped, failed or not-attempted, and a DRIFT block names anything the installer could not converge, each with a remedy line.

  3. Read the .env migration block: it shows where each .env key would go and asks Move these keys now (backup first)? [y/N]; after a move it names the backup path and the restore command (detail).

  4. If the graphify pin sync skipped because a Claude session was live, run bash ~/himmel/scripts/himmel-update.sh --graphify-only once none is.

  5. 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
  6. 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

Terminal window
bash ~/himmel/scripts/himmel-update.sh --versions

Exit 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 status shows any install item that still differs from the manifest; node ~/himmel/scripts/himmelctl/bin.js ensure converges it.

Recover

  • A step failed. The first genuine failure aborts the chain: later steps report not-attempted and 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 reported not-attempted, for example bash ~/himmel/scripts/himmel-update.sh --only marketplace (the step names).
  • The .env migration failed its check. .env is unchanged: each MISMATCH <key> (<reader>) line names a reader that sees a different value after the move, or a value ~/.himmel already holds that differs from .env. Make them agree, then run bash ~/himmel/scripts/himmel-update.sh --only env_migration.
  • You want the old .env back. Run the restore: command the migration printed (cp <backup> <your .env>). After a move that passed its check, the copies in ~/.himmel can stay: they hold the same values. After a MISMATCH on a value ~/.himmel already held, restoring .env does 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 in git stash list. Autostash does not apply on a release channel.
  • The branch diverged from upstream. git pull --ff-only fails loud; reconcile the branch by hand, then re-run. On a release channel, a HEAD that diverged from the tag fails the same way rather than guessing.
  • A tarball update went wrong. A failed download or verify leaves current untouched. The previous version directory is kept, so ln -sfn <previous-version> ~/.local/share/himmel/current rolls 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.

Terminal window
# preview: prints the footprint, changes nothing
node ~/himmel/scripts/himmelctl/bin.js uninstall --dry-run
# the real thing (asks Proceed? [y/N])
node ~/himmel/scripts/himmelctl/bin.js uninstall

Undo 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 himmelctl launcher, the cache and the trust entry it created.

Kept on purpose:

  • Telegram pairing, your settings in ~/.himmel and 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:

Terminal window
bash scripts/uninstall.sh --dry-run # preview; nothing is executed
HIMMEL_UNINSTALL_REAL_HOME=1 bash scripts/uninstall.sh --yes # remove code wiring, keep operator state
HIMMEL_UNINSTALL_REAL_HOME=1 bash scripts/uninstall.sh --yes --purge-state # ...and remove operator state + the provenance ledger too
HIMMEL_UNINSTALL_REAL_HOME=1 bash scripts/uninstall.sh --yes --purge-state --keep-backups # ...but keep the ledger's backup copies

The 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:

  1. Sweep: discovers your vaults (from ~/.claude/luna-vaults.json and a scan of ~/Documents), classifies each (luna-family vs not-yet-stamped), and prints a per-vault table: already-current / clean-upgrade / conflict.
  2. Per-vault confirm: it never auto-applies. You approve each vault before it is upgraded.
  3. 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, run luna-upgrade-all.sh restore --vault <path> (add --list to see available backups).
  4. Conflict help: if your customized _CLAUDE.md conflicts 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-file exits 2 and the upgrade reports “NOT writing the version stamp” every time). If the new template’s semantic content already reached your _CLAUDE.md another way, resolve ours-wins (change nothing) and hand-stamp .vault-template.json to the new version so the next sweep reports current. Note the upgrade refreshing template-owned .obsidian files 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 with luna-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”:

SurfaceWhere it livesUpdated by
Core hooks (scripts/hooks/*)your checkout, run from $CLAUDE_PROJECT_DIRgit pull
Slash commands (.claude/commands/*)your checkoutgit pull
settings.json wiring, scripts/*, CLAUDE.md, docsyour checkoutgit pull
himmel plugins (marketplace/plugins/*)your checkout, sourced ./plugins/*see below
Upstream plugin (claude-obsidian)GitHub AgriciDaniel/claude-obsidian, tag-pinnedonly when the pin is bumped (= a git pull)
Vendored plugin (obsidian)GitHub, SHA-pinnedonly 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 local directory source (path: <himmel-path>/marketplace) with autoUpdate: true. A directory source has nothing to fetch over the network; autoUpdate only re-syncs plugins from the on-disk dir, which changes only when you git 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. There autoUpdate pulls 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 on main reaches this install with the next release. A fresh install between releases gets main’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:

  1. 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).
  2. claude plugin marketplace update himmel: refresh plugins from the freshly-pulled local dir.
  3. jira CLI dist rebuild: scripts/jira/dist is a gitignored build artifact; a pull that changes the jira TypeScript source needs a rebuild to take effect.
  4. qmd fork update: qmd ships from a himmel-pinned fork outside this checkout, so git pull here never touches it.
  5. hermes junior-tier update: hermes is a separate editable git checkout outside this repo; pulls + reinstalls it, skipping cleanly when hermes isn’t installed.
  6. 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.

  1. Plan. One line per .env key: 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 reads stays (read by <file>); if the readers cannot be searched, every himmel secret stays. Any other key reads stays (not himmel's, left in place).
  2. Backup. .env is copied to ~/.himmel/env-backups/env.<UTC time>.<pid>, mode 600, before anything else is written; the path is printed.
  3. Copy. Each key is copied to its new place. A key already set there is left alone.
  4. Check. The real readers run twice, once with the keys only in .env and once with them only in their new place: the shell load_dotenv and load_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, prints MISMATCH <key> (<reader>) (or a verify: line) and no line is removed from .env.
  5. 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 .env for 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 reads pending.
  • 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 highest vX.Y.Z release tag (no -pre.N suffix).
  • channel: pre: follows the highest tag overall, -pre.N prereleases included (a stable tag still outranks a -pre.N of the same major.minor.patch; -pre.10 outranks -pre.9, compared numerically, not as text).

Set it two ways, checked in this order (first one set wins):

  1. HIMMEL_UPDATE_CHANNEL=stable or HIMMEL_UPDATE_CHANNEL=pre: env var, per-invocation or exported.
  2. channel in your install profile: ${HIMMELCTL_CACHE_DIR:-~/.claude/himmel}/install-profile.json, the same cache himmelctl install writes its answers to. himmelctl install defaults a new adopter’s profile to channel: stable; it never overwrites a profile that already has a channel value.

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.
  • HEAD an ancestor of the tag (behind) → --check reports behind stable vX.Y.Z (at <describe>); apply mode runs git switch --detach vX.Y.Z (never git checkout; this repo hard-refuses that shape).
  • HEAD a descendant of the tag (ahead: you’re on newer work than the last release) → left alone, reported not behind — leaving as-is. A channel never downgrades you.
  • HEAD and 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 → stable with no release tags prints exactly no stable release yet — nothing to follow and exits 0; 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’s VERSION file 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-403 for a rate limit, no-curl, bad-response; a missing VERSION is reported too). A failed check is never reported as “up to date”. --check still exits 0 either way.
  • bash scripts/himmel-update.sh and --only pull refuse (exit 1), change nothing, and name the update route. They never run git 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?

Terminal window
himmelctl --version # himmel <VERSION> + git describe string + commit
himmelctl --version --all # ... plus one line per updatable component
bash 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.json and 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-mcp processes hold the uv tool 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 VERSION pin. A host at or ahead of the pin is left alone (never a downgrade), and a version that cannot be compared (no python3, or a stamp that is not a version) is left alone too, with the manual roll command printed instead. cli-proxy-lane.sh owns 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.sh re-syncs the mkt-manual rows (autoUpdate off) 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): claude is held to a launch floor (2.1.280, the first release with Opus 5.5 and --autocompact; one constant in scripts/lib/claude-version.sh). In apply mode a Claude Code below the floor or behind the latest is updated by its owner’s updater (native claude update; npm npm 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. --check only reports. Below the floor, armed console/headed-arm/arm-resume launches refuse naming claude update (print-only and --dry-run warn), and himmel-doctor check C63 WARNs.
  • node/npm/bun toolchain: reports node against the pinned .nvmrc at 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 as lts/* as a plain report (never moves it: a live lane is very likely running on the current node the instant this runs; prints the nvm/fnm fix instead) and self-upgrades npm/bun in place via their own built-in updaters. If bun is 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:

Terminal window
# preview what would be disabled (writes nothing)
bash scripts/machine-setup/reconcile-enabled-plugins.sh --dry-run
# apply
bash scripts/machine-setup/reconcile-enabled-plugins.sh

It 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:

Terminal window
echo 'export HIMMEL_RECONCILE_PLUGINS=1' >> ~/.bashrc # or ~/.zshrc / your profile
export HIMMEL_RECONCILE_PLUGINS=1 # this shell, now

Other 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/Himmel instead of cloning it directly? Add an upstream remote and git pull upstream main; a plain git pull origin only 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-check flags claude-obsidian / obsidian as “served from another marketplace”, an external auto-updating copy is shadowing the himmel SHA pin. Migrate it to the pinned @himmel source operator-present, in a fresh session: bash scripts/machine-setup/migrate-plugin-to-himmel.sh --apply <name@market> … (it mutates settings.json via claude plugin, so it is an operator step). Run it from a shell where the claude CLI is on PATH. The external marketplace can silently re-appear later (its autoUpdate re-adds it), so re-run the migrate whenever --plugins-check flags the shadow again.