Skip to content

New Machine Setup

Complete checklist for getting a new machine to full working state.

Adopting himmel on your own repo (not developing himmel itself)? See docs/setup/install.md instead; it covers the portable-core flow and tells you which sections here apply to you.

Support matrix

TierPlatformsWhat it promises
SupportedLinux, macOSLinux is CI-gated on every PR (required check) — green bun-suites runs on main; adopter round trip verified on both. macOS CI runs on the nightly only (not a per-PR required check); its nightly is green — macos-cadence nightly runs (8/8 shards, 0 red suites), with the macos-cadence workflow as the ongoing check.
Not supportedWindows, WSL includedDropped (HIMMEL-5729).

Install channels: release tarball (himmel-<version>-linux.tar.gz + .sha256, Linux): supported, steps in the README; git clone + himmelctl install: supported; plugin marketplace (marketplace/plugins/*): supported, through the Claude Code plugin marketplace (claude plugin install/update/uninstall), not himmelctl; AUR: landed (recipe in packaging/aur/); brew: not supported, reopens only if a shared macOS/Linux tap becomes worthwhile; nix / deb / rpm: not supported, reopen on user-driven demand. The tarball’s fresh-guest run is green.

See docs/internals/harness-compat.md for the same tiering applied to hook/skill/agent compatibility.

Verified floors, by platform

One row per platform, each floor linked to the run that actually proves it. A cell reading unverified means: no floor is declared/enforced anywhere in the repo for that tool, or no green run exists to cite. Never assume a value that isn’t backed by one of these links.

OS/distroShellClaude Code CLI floorgit floornode / bunInstall channelsProof
Linux (Ubuntu/Debian/Arch/Fedora)bash 3.2+unverified — no minimum declared or enforced anywhere in the repo2.31, not the declared 2.30 (git rev-parse --path-format needs 2.31+; correcting the declared floor in this doc and scripts/install/deps.json is a known follow-up)node 18+ to build, 20.17+ to push · bun 1.0+tarball, git clone + himmelctl install, plugin marketplace, AURgreen CI on main — per-PR required check
macOSbash 3.2 (4+ via brew install bash)unverifiedsame real/declared gap as Linuxnode 18+ / bun 1.0+ (same floors; not build-verified on this platform — see Proof)git clone + himmelctl install, plugin marketplace (no tarball — Linux-only artifact)green macos-cadence nightly runs — 8/8 shards, 0 red suites, at ba4192922 (earlier reds on 2026-09-24, 09-25 and 09-26). Floors above stay as declared: the run proves the suites pass, not a build-verified node/bun floor.

1. Required environment

scripts/setup.sh verifies these at step 0 and fails fast with install hints. Run bash scripts/setup.sh and the script tells you exactly what’s missing.

Foundational (every platform — verified at setup)

ToolMin versionWhy
bash3.2 (most scripts) · 4.0 (8 scripts use mapfile)macOS ships bash 3.2 — brew install bash if you hit mapfile: command not found from the 8 bash-4 scripts listed below
git2.30+Worktrees + --show-toplevel
node18+ to build · 20.17+ to pushJira plugin build + plugin-install workflow. The pre-push signature gate needs npm 11, whose own engine floor is ^20.17.0 || >=22.9.0, so a node-18 box can build and commit but cannot push.
npm11+ (needs node ^20.17.0 || >=22.9.0)Lockfile audit hooks + plugin install. NOT bundled on Debian/Ubuntu, and apt’s own npm is 9.2 — old enough that its expired registry key fails npm audit signatures on every push. See the Linux table.
bun1.0+Runs the handover armed-resume resolver, the qmd search index, the Telegram bridge, and the obsidian-triage tools. Install: curl -fsSL https://bun.sh/install | bash
python33.10+realpath -m fallback (macOS) + JSON helpers in 28 scripts. PEP 668 (Ubuntu 24.04+) blocks system pip — use uv or pipx for pre-commit.
jq1.6+Hook input parsing (13 scripts incl. all Claude PreToolUse hooks)
gh2.xIssue + PR + CR workflows (12 scripts)
mktempBSD or GNU (both fine)19 scripts
pre-commit3.5+Pre-commit framework
claude (CLI)latestNative installer: curl -fsSL https://claude.ai/install.sh | bash

Per-platform additions

Linux (Ubuntu / Debian / Arch / Fedora):

ToolHow
at + atd runningsudo apt install at && sudo systemctl enable --now atd (or distro equivalent). Required by scripts/handover/arm-resume.sh for cron-armed Claude relaunches.
realpath -m (coreutils)Default on most distros.
shellcheck, gitleakssudo apt-get install -y shellcheck gitleaks. Both are real apt packages on Ubuntu (verified 26.04: ShellCheck 0.11.0, gitleaks 8.16.0) — the tarball is only needed on distros that do not package gitleaks. Used by pre-commit.
shfmtsudo apt-get install -y shfmt (or brew install shfmt, pacman -S shfmt). block-git-stash and block-graphify-egress parse a command with shfmt; with it missing they deny any command that names a stash, ref-plumbing or graphify verb, with a message naming shfmt. CI pins 3.14.1.
ghsudo apt-get install -y gh on Ubuntu >= 24.04 / Debian >= 13 — gh is a real apt package there. himmelctl deps ensure / setup.sh’s [0/9] preflight asks whichever package manager is present (apt-get/dnf/brew) whether it has a candidate for gh, and installs it automatically when the answer is yes — not just on apt. When the selected manager has no candidate, the manual routes are the fallback: GitHub’s official apt repo (https://cli.github.com/packages) or the release tarball (https://github.com/cli/cli/releases).
uv (or pipx for pre-commit only)curl -LsSf https://astral.sh/uv/install.sh | sh (recommended). PEP 668 blocks system pip. pipx can install pre-commit, but graphify is uv-managed — without uv, himmel-update skips graphify (status row graphify skipped uv missing) and it never installs.
npmNot bundled. Debian/Ubuntu’s nodejs package ships without npm, so a box with a working node can still fail the install preflight (ERROR: missing required tools: npm). sudo apt-get install -y npm, or install Node + npm together from NodeSource. And apt’s npm is too old to push with — 9.2.0’s bundled registry key expired 2025-01-29, so the pre-push npm audit signatures gate refuses every push with EEXPIREDSIGNATUREKEY. Follow up with sudo npm install -g npm@11 (npm 12 requires node ^22.22.2; apt ships 22.22.1).
sudoThe toolchain steps that use apt-get need root. himmelctl deps ensure does not elevate — run the apt lines yourself (see the clean-install walkthrough below).
snyk CLI (optional)Version on the station: 1.1308.2 (/usr/local/bin/snyk, free plan). Install from the Snyk CLI docs, then snyk auth once by hand; the token stays in snyk’s own config and himmel never reads it. bash scripts/security/snyk-scan.sh --check prints OK or a WARN and always exits 0. Used by the weekly scripts/security/snyk-scan-cadence.sh (see cadences); without it the scan reports WARN snyk and the rest of himmel is unaffected. Snyk Code additionally needs enabling in the Snyk org settings.
claudex lane (optional)bash scripts/setup/cli-proxy-lane.sh (--install → --login → --register) brings up the codex-proxied cc-codex lane on this host — needs systemctl --user + ss (iproute2), both stock on Ubuntu/Debian/Arch/Fedora. See cli-proxy-lane.md.

macOS:

ToolHow
bash 4+brew install bash — system bash is 3.2 and 8 scripts need 4+. Add /usr/local/bin/bash (Intel) or /opt/homebrew/bin/bash (Apple Silicon) to PATH or use #!/usr/bin/env bash (already the convention).
at daemonPreinstalled but disabled. Enable: sudo launchctl load -F /System/Library/LaunchDaemons/com.apple.atrun.plist.
realpath -m (optional)Macos has no realpath -m; the 5 scripts that use it (arm-resume.sh, auto-commit.sh, block-edit-on-main.sh, check-hookspath.sh) already include a python3 -c "from pathlib import Path; print(Path(p).resolve(strict=False))" fallback. Pure-GNU operators: brew install coreutils && export PATH="$(brew --prefix coreutils)/libexec/gnubin:$PATH" exposes grealpath as plain realpath.
uv (or pipx for pre-commit only)brew install uv. pipx can install pre-commit, but graphify is uv-managed — without uv, himmel-update skips graphify.
coreutils (for gtimeout)brew install coreutils — macOS has no GNU timeout. Without gtimeout, scripts/graphify/refresh-graph-map.sh runs graph extraction with its run deadline DISABLED (unbounded) and skips the bounded pre-pull auto-commit/fetch. Homebrew installs it as gtimeout; no PATH change is needed.
bunbrew tap oven-sh/bun && brew install oven-sh/bun/bun — bun is NOT in homebrew-core; the tap is still required (verified 2026-09 against github.com/oven-sh/homebrew-bun). scripts/machine-setup/macos.sh installs both uv and bun this way; it previously installed neither.

Fleet CLIs — auxiliary post-install items

Installing the CLI fleet on a new station (claude, codex, hermes, copilot, grok) leaves a set of one-time auxiliary steps that the plain installers do NOT cover. Calibrated on two stations, 2026-07-12:

ItemStepNotes
hermes homeexport HERMES_HOME=$HOME/.hermes for scripts/hermes/invoke.sh callsNewer hermes installs use ~/.hermes; the chokepoint’s resolver does not probe it yet (a known limitation).
hermes profilebash scripts/hermes/install-himmel-profile.sh (with HERMES_HOME set), then hermes profile use himmel_agentInstalls the senior-reviewer profile + parity_guard hook.
hermes hooksApprove the parity_guard hook once in a live hermes sessionHook approval is interactive; one-shots inherit it afterward.
hermes channelsKeep chat channels (telegram etc.) enabled: false on stationsONE poller per bot token, fleet-wide — the host’s always-on bridge owns the channel. A station gateway that polls the same token 409-conflicts it.
provider authMerge/verify the providers your lanes need in ~/.hermes/auth.json (e.g. the openai-codex provider for gpt-6.1-sol one-shots)A fresh login may create fewer providers than your primary machine has.
codexcodex login (ChatGPT OAuth), then codex login status
copilotFirst run is interactive: login + approve the repo as a trusted folderConfig files alone (config.json/settings.json) are not sufficient.
grokgrok login (browser OAuth) per machineA non-authed station hangs at “Waiting for authorization…” on one-shots.
jqsudo apt install -y jq (Debian/Ubuntu; use your distro’s package manager elsewhere)Required by most himmel hooks; not preinstalled on minimal distros.
pre-commit hooks (non-himmel clones)pre-commit install --hook-type pre-commit --hook-type commit-msg --hook-type pre-push in any OTHER repo clone on the stationA bare pre-commit install leaves commit-msg + pre-push gates silently absent. The himmel checkout itself no longer needs this: himmelctl install/scripts/setup.sh place all three hook types by default.

Linux clean install, end to end (verified on Ubuntu 26.04, 2026-09-02)

Walked end to end on an Ubuntu 26.04 guest, 2026-09-02. Every failure, message and workaround quoted below is reproduced verbatim from that run; where himmel’s own tooling could not do a step, it is called out rather than smoothed over, and the workaround is the documented path until the linked ticket lands.

Two honest caveats about the walkthrough itself. The guest was not pristine: it carried a ~/.claude and an older himmel checkout from earlier test runs, which is why the hook-rewiring behaviour below could be observed at all. And steps 1-3 are the standard documented installs for tools that box already had (uv, pre-commit, bun, the claude CLI): they are listed so the sequence is complete, but the commands exercised on this run were steps 0, 0b and 4 onward. Everything after step 4 was executed exactly as written.

Terminal window
# 0. Prerequisites apt owns. node alone is NOT enough: Ubuntu's nodejs
# package has no npm, and the install preflight hard-fails without it.
sudo apt-get update
sudo apt-get install -y nodejs npm git jq gh shellcheck gitleaks at
sudo systemctl enable --now atd # arm-resume's scheduler backend
# 0b. npm 11+. Ubuntu's apt npm is 9.2.0, whose bundled registry key EXPIRED
# 2025-01-29 — the pre-push `npm audit signatures` gate then refuses every
# push with EEXPIREDSIGNATUREKEY. Pin 11 rather than latest: npm 12 wants
# node ^22.22.2 and apt ships 22.22.1, one patch short.
sudo npm install -g npm@11
# 1. uv (PEP 668 blocks system pip on 24.04+), then pre-commit through it.
curl -LsSf https://astral.sh/uv/install.sh | sh
uv tool install pre-commit
# 2. bun — the official installer, which writes ~/.bun/bin.
curl -fsSL https://bun.sh/install | bash
# 3. The claude CLI, if you do not already have it.
curl -fsSL https://claude.ai/install.sh | bash
# 4. Clone, then run the installer FROM INSIDE the clone. `himmelctl` is not
# on PATH yet — the launcher is written to ~/.local/bin BY the install —
# so the first invocation is necessarily clone-relative.
mkdir -p ~/Documents/github && cd ~/Documents/github
git clone https://github.com/yotamleo-org/Himmel.git himmel
cd himmel
node scripts/himmelctl/bin.js install --dry-run | tee dryrun.txt # the plan
node scripts/himmelctl/bin.js install # the real thing

(--dry-run is teed to a file because the headless workaround below needs the profile JSON it prints; on a box with a terminal you can drop the tee.)

The install trusts the workspace it just configured: without this, the permissions.allow/permissions.additionalDirectories entries it just wired into .claude/settings.json are silently ignored until a human runs claude interactively here once. If the trust write itself fails (an unwritable ~/.claude.json, say), the install summary’s still manual section names it. Run claude interactively in the clone once and accept the trust dialog, or set projects["<abs clone path>"].hasTrustDialogAccepted: true in ~/.claude.json yourself; the summary’s remedy prints the exact key to use.

Two rough edges you will hit on this path today. Both are filed; both have a workaround that completes the install.

  1. Run the install in user scope when the adopted project is the himmel clone itself. The default derivation picks project scope with the target set to the current directory, and the portable-core copy then tries to copy the checkout’s files onto themselves:

    cp: '.../scripts/hooks/auto-approve-safe-bash.sh' and '.../scripts/hooks/auto-approve-safe-bash.sh' are the same file

    Answer user at the scope question (or pass a profile with "scope": "user"). User scope skips the portable-core copy entirely and the install then completes: plugins, marketplaces, the Jira CLI build, the statusline, the launcher, and the hook wiring in ~/.claude/settings.json.

  2. A headless / SSH-only box needs --from-profile, and that path does not save the profile. With no TTY the real install refuses (non-interactive install requires --from-profile <path>), while --dry-run prints a complete profile it does not save. Capture the printed JSON block, install from it, then seed the cache by hand so himmelctl status and ensure work afterwards:

    Terminal window
    sed -n '/^{$/,/^}$/p' dryrun.txt > profile.json # the block --dry-run printed
    sed -i 's/"scope": "project"/"scope": "user"/' profile.json
    node scripts/himmelctl/bin.js install --from-profile profile.json
    mkdir -p ~/.claude/himmel && cp profile.json ~/.claude/himmel/install-profile.json

    On a box with a terminal, answer the questions interactively instead and neither step is needed: the interactive path writes the cache itself.

himmelctl deps ensure does not finish the job on Linux. On a stock Ubuntu box it reports 0 installed, 4 failed: its apt-get calls run unprivileged, gitleaks has no automated recipe, and bun/qmd are not re-resolved on PATH within the same pass. Install those four from step 0 and step 2 above and himmelctl status goes green on them.

Expect reds you did not cause. A lean adopter’s first himmelctl status lists rows for opt-in capabilities that were never selected; hermes-checkout in particular reports a Windows AppData path on Linux. Reds for graphify, rtk, and telegram-bridge simply mean those optional pieces are not set up; none of them blocks the core loop. A clean starter install’s optional Jira (jira-env-keys) and Bitbucket (bitbucket-cli-build, unless the repo’s origin is on Bitbucket Cloud or FORGE=bitbucket, where an unbuilt CLI stays red) rows, and a not-yet-created inline handovers/ dir (handover-wiring), read n/a, not red; the himmelctl status count line says how many n/a rows were wanted but not set up.

The installer places these hooks by default; pass --skip-hooks to opt out. What it places depends on whether the target repo already has a .pre-commit-config.yaml:

  • Your own config present: the installer runs pre-commit install and leaves your config untouched; the hooks run whatever you configured.
  • No config: the pre-commit framework’s hooks would skip on every commit and push (“config file not found”), so the installer writes himmel’s native commit-msg, pre-push and pre-commit gates directly instead. Those gate without any config file.

himmelctl status reports pre-commit-hooks as red when the installed hooks are framework stubs with no config to run. Ticket discipline itself is default-on.

Two things to know once they are placed:

  • Ticket discipline is on by default. The commit-msg gate enforces conventional-commit shape always, and a ticket reference unless you opt out. The pattern comes from TICKET_ID_PATTERN if set, else JIRA_PROJECT_KEY (PROJECT-123), else himmel’s own #N enumeration (#123). That enumeration is the ticket system for a repo without Jira, not a relaxed fallback, and /handover new-epic / /handover new-task allocate the next free number for you. To turn the ticket half off entirely, set TICKET_ID_REQUIRED=0 in the repo’s .env or the environment; the conventional-shape check stays on either way.

  • The lockfile gate finds bun for itself now. It used to depend on who ran git commit: a hook inherits the environment of the invoking git process rather than building a fresh PATH, so a commit from your own terminal worked (that shell sourced ~/.bashrc, where bun’s installer put ~/.bun/bin) while the same commit from an SSH command, a script, CI or an agent died with ERROR: bun did not report a version on any commit, docs-only included. The gate now looks in PATH, $BUN_INSTALL/bin and $HOME/.bun/bin in turn, and when it still finds no bun it refuses only the commits that stage something under the bun package; a docs commit is skipped with a line naming everywhere it looked. Nothing to do on a normal install.

    The symlink below is still worth making: it fixes the same blind spot for every other non-interactive consumer (cadences, scheduled runs, himmelctl deps ensure), since ~/.local/bin is exported by ~/.profile, which has no interactive-only early return, though that only helps a launching chain that sources ~/.profile in the first place (a login shell), not a bare ssh host 'cmd' or a cron job with neither PATH layer; see the two PATH layers:

    Terminal window
    ln -sf ~/.bun/bin/bun ~/.local/bin/bun

Your first push will take a few rounds, and each refusal is real. On the guest it took three attempts, in this order:

  1. npm audit signatures refused because apt’s npm is 9.2 (step 0b above); fixed by sudo npm install -g npm@11. The gate checks npm --version before the signature check and says so in as many words; on an npm predating that fix you get the raw EEXPIREDSIGNATUREKEY per package instead, which is the same problem wearing a supply-chain costume.
  2. It refused again for scripts/jira only: “found no dependencies to audit that were installed from a supported registry”, because the worktree bootstrap had populated node_modules without registry provenance. npm ci in scripts/jira fixes it; that is exactly the remediation the gate names.
  3. The CR gate refused once to install its own ref-stream hook (pre-push.legacy) and told you to retry; the retry passes.

Then it goes through. The attestation gates (“Cross-platform attestation”, “Security-review attestation”) pass first-try as long as the trailers are in the FIRST commit. Do not try to add them with git commit --amend after a refusal.

worktree.sh new leaves the new worktree dirty. It installs the Jira CLI dependencies, which rewrites scripts/jira/package-lock.json. Revert that file rather than committing it: git checkout -- scripts/jira/package-lock.json.

PATH: ~/.bun/bin will look missing even when bun works. See the two PATH layers. This is why a probe can report bun (or claude) missing on a machine where both are installed and working.

Linux station (CachyOS, first fleet day 2026-09-04)

Distinct from the Ubuntu walkthrough above: these surfaced running himmel as the daily driver on an Arch-family main station, not from a fresh install.

Lean plugin floor + local override. See Lean profile above for how settings.local.json overrides the floor in both directions. The lesson on this box: toggling a plugin off with claude plugin disable alone does not survive the next reconcile if its key is still true in settings.local.json: flip the override, not just the live toggle.

/plugins → “enabled in project settings but isn’t installed.” A migrated ~/.claude/settings.json can carry false keys for plugins never installed on this machine: reconcile-enabled-plugins.sh’s whitelist logic carries any unknown live key forward as false rather than deleting it, so a dead entry survives every reconcile untouched. That’s an enabledPlugins cleanup, not an install problem: f=~/.claude/settings.json; t=$(mktemp "$f.XXXXXX") && jq 'del(.enabledPlugins["<name>"])' "$f" > "$t" && mv "$t" "$f" (a mktemp beside the target, never a predictable cwd-relative name).

Toggling a community plugin. claude plugin enable/disable <name> is the sanctioned toggle. himmelctl config set hooks.plugin.<name> only knows the fixed table in scripts/machine-setup/full-plugin-enable.json (the lean-profile re-enable list above); it rejects any other plugin name, so reach for claude plugin directly for anything outside that list. That toggle alone doesn’t survive the next reconcile, though (see Lean profile above): claude plugin enable <name> also needs "<name>": true under enabledPlugins in ~/.claude/settings.local.json, or the next reconcile drops it back to the floor; claude plugin disable <name> mirrors this with false there, or the next reconcile brings it back.

arm-resume on Linux. Needs at + a running atd. On Arch/CachyOS: sudo pacman -S at && sudo systemctl enable --now atd; cronie is the weaker fallback when at is absent (see §4c above). The POSIX launcher in scripts/handover/arm-resume.sh’s schedule_arm submits an at -t <timestamp> heredoc job whose body is a plain claude ... --model ... invocation: no controlling terminal, nothing opens a window. --dry-run is side-effect-free (it prints the exact job body and “dry-run complete (no changes made)” without touching the at queue) and is the safe way to see what an arm will actually run. Because the fired session has no TTY/stdin, any permission-gated action it hits resolves as a silent DENY at rc=0 after a ~3s stdin watchdog rather than hanging. Pass --automerge if the arm must push a dependency chain through green PRs unattended (it sets ARMAUTOMERGE=1 + CR_MERGE_GATE_OK=1 on the launched session); without it the script only WARNs and the dependency chain stops at the first green PR. .env’s ARMAUTOMERGE is only the default used when --automerge was not passed on this invocation; an explicit flag always wins over it.

Jira CLI. No env-assignment prefix on the invocation other than a bare literal JIRA_PROJECT_KEY=<KEY> and the innocuous LANG/LANGUAGE/LC_*/TZ locale prefixes: block-jira-compound-write.sh refuses any other prefix shape, not Jira writes themselves. The sanctioned invocation is the plain node <repo-root>/scripts/jira/dist/index.js <op>, which picks the key up from the primary checkout’s .env (see Required .env values above); pass --project only for cross-project work.

Station traps, one line each. Arch ships /usr/bin/pre-commit and co-locates node with coreutils in /usr/bin; a hermetic-PATH test fixture that pins PATH=/usr/bin:/bin or scrubs the PATH must stub these tools out, not assume scrubbing /usr/bin removes only what the suite intends (it also silently drops xargs). Scripts tracked from Windows commits land mode 100644 (no +x) and fail direct invocation with rc=126 on Linux; a check-shebang-scripts-are-executable pre-commit hook now guards new commits from reintroducing this. run-shell-tests.sh’s machine lock leaking SUITE_LOCK_WAIT into test-suite-concurrency.sh (a queued after-report inheriting an unrelated wait budget instead of the suite’s own) is fixed. Verify hooks actually fire on a dash-/bin/sh box (Debian/Ubuntu; Arch’s /bin/sh is bash, so this is silent here too): from inside a himmel clone, dash -c 'command -p sh scripts/lib/run-node.sh -v' prints a version, not nothing. Then run dash scripts/lib/run-node.sh -v as well. Two probes because a launcher resolves to /bin/sh: the first pins the shape production actually wires, but on a bash-/bin/sh box it executes run-node.sh’s own body under bash, so a dash-incompatible expansion inside the file sails straight through it. The second puts that body under dash. A bash-only expansion anywhere in the chain aborts silently at exit 0 instead. A third probe pins the PATH boundary: env PATH=/nonexistent /bin/sh -c 'command -p sh scripts/lib/run-node.sh -v' (the outer shell by ABSOLUTE path; env clobbers PATH before it could look up a bare dash) must ALSO print a version; a bare sh there dies rc=127 before any guardrail runs, which is why hooks are wired command -p sh. (command -p sh, not .: POSIX leaves . with operands beyond the filename unspecified and dash DROPS them: dash -c '. probe.sh a b' sees zero arguments where bash sees two. A dot-wired run-node.sh therefore ran a bare node with an empty argv, and every hook was a silent no-op on Debian/Ubuntu; hook commands are now wired command -p sh "…/run-node.sh" <args> and run-node.sh refuses loudly with rc=2 on an empty "$@" rather than exec’ing a payload-less node. Invoked this way $0 names run-node.sh, so it resolves resolve-node.sh from its own directory and no CLAUDE_PROJECT_DIR is needed. The older sourced probe CLAUDE_PROJECT_DIR="$PWD" dash -c '. scripts/lib/run-node.sh' sh -v still works: operands placed after the -c string become the shell’s OWN positional params, which a sourced file inherits, and without one of $0, the plugin root or CLAUDE_PROJECT_DIR set run-node.sh silently takes its own fail-open path instead of printing anything.)

Scripts requiring bash 4+

These will error mapfile: command not found on macOS system bash (3.2). Either install bash 4+ via brew install bash, or convert the mapfile use to a while IFS= read loop (cheap port if needed):

  • scripts/luna/sweep-himmel.sh
  • scripts/hooks/check-mcp-plugin-refs.sh
  • scripts/hooks/check-lockfile-integrity.sh
  • scripts/hooks/check-npm-audit.sh
  • scripts/hooks/check-npm-audit-signatures.sh
  • scripts/hooks/check-npm-licenses.sh
  • scripts/hooks/check-uv-lock.sh

Every other script is bash 3.2-compatible.

Single-source tool dependencies

These tools show up in only one script, easy to drop if not on PATH (the affected feature degrades):

  • cygpath (Windows-only path translation) → scripts/handover/arm-resume.sh and scripts/lib/qmd-bin.sh (junction creation); falls back to the unconverted path when absent.
  • shellcheck, gitleaks → scripts/machine-setup/ubuntu.sh bootstrap only; pre-commit framework re-fetches them per-hook so they don’t need to be on PATH for normal use.
  • pipx → fallback in setup.sh + ubuntu.sh only (uv is primary).
  • qmd → setup.sh step 4 only (collection register); optional if you don’t use qmd search.

Other tools

  • Obsidian: https://obsidian.md (for luna vault).
  • Claude Code CLI: see foundational table above.

Required .env values (per-variable walkthrough)

scripts/setup.sh copies .env.example → .env for you (§4); you then fill in your own values. Run setup with --fill-env to be prompted for each one; the prompt prints a short help blurb (what the value is + where to get it, sourced from the .env.example comments) before each field, so you don’t have to read the file first. Press Enter at a prompt to keep the current value; non-interactive shells skip the prompt entirely.

Two kinds of variable live in .env and they reach code differently; this is the classic “I set it in .env but nothing happened” tax. The same split is documented at the top of .env.example:

  • TOOL-LOADED: a himmel CLI reads .env itself, so a value sitting in the file is enough; no shell export needed.
  • PROCESS-ENV: hooks, shell scripts, and skill tools read the live environment, not the file. Export these in the shell that launches claude, or set them in ~/.claude/settings.json "env": {}. A value sitting only in .env is not seen, except the ones bridged from .env by scripts/lib/load-dotenv.sh (HANDOVER_DIR, USER_SLUG, CR_PROFILE, the HIMMEL_INITIATIVE* set, HIMMEL_WHERE_ARE_WE + _STALE_HOURS (but NOT the statusline-only _ROLLUP_TTL / _SEG_TIMEOUT knobs, which are live-env only), HIMMEL_DOC_FRESHNESS and the nudge flags; the full bridged list lives in the .env.example header), noted below. A third class, SESSION-ONLY (guardrail bypasses like EDIT_ON_MAIN_OK, per-launch opt-ins like TELEGRAM_OWN_POLLER), is never read from .env at all: set in the launching shell; inventoried in .env.example’s SESSION-ONLY section.

TOOL-LOADED (a value in .env is enough):

VariableWhat it isWhere to get it
USER_SLUGOperator slug — names handover bucket paths + registry.json. Falls back to your git user.name slugified if unset. Also bridged into the live env.Pick a short kebab-case handle.
JIRA_BASE_URLYour Atlassian site URL.https://<your-site>.atlassian.net
JIRA_EMAILAtlassian account email.The address you sign into Jira with.
JIRA_API_TOKENJira / Confluence CLI auth token.id.atlassian.com/manage-profile/security/api-tokens
JIRA_PROJECT_KEYDefault project for jira ops.Your Jira project key (e.g. ACME).
JIRA_CLOUD_IDAtlassian tenant cloud id (REST / MCP).Atlassian admin console, or the getAccessibleAtlassianResources API.
ZAI_API_KEYZ.ai GLM key for the claude-glm launcher of the GLM lane, which is dormant (HIMMEL-1967; the lane registry offers it only when GLM_LANE_OK is set) (see tooling-catalog). The launcher takes the live process env first, else reads it from .env. It cannot distinguish shell env from settings.json-injected env — don’t put this key in settings.json (that hands it to every session); use per-launch shell env or .env.z.ai account → API keys. Optional — only if you use claude-glm.

Jira Sector (optional)

A sector is a stable, coarse product area: the “where in the system” axis of a ticket, next to epics (“what goal”) and versions (“when”). Tagging every ticket with one lets a board or report answer “where is our effort going, and which area is starved?” without hand-tagging; together with the epic-or-standalone rule it stops orphan tickets. Sectors are a custom Jira field, so they are off until you configure them.

  1. node <repo-root>/scripts/jira/dist/index.js sector setup --dry-run prints the explanation, a suggested nine-sector set and the exact Jira calls.
  2. ... sector setup (add --sectors "A,B,C" for your own set, --yes to skip the prompt) creates the single-select field and its options and prints a {"jira": {"sectorField": "customfield_NNNNN"}} snippet; merge it into ~/.himmel/config.json yourself (the setup command never writes that file; the field id is not a secret, so it is not a .env key).
  3. ... sector setup --opt-out prints the "sectorField": "none" snippet instead. With it absent or none, create neither requires nor sends a Sector; the epic-or-standalone rule stays, and edit --sector and edit-batch refuse.

The valid options are read live from the field, so adding one in Jira needs no code change.

PROCESS-ENV (export, or set in settings.json "env"; a value only in .env is not read unless it’s a bridged exception):

VariableWhat it isWhere to get it / note
HIMMEL_REPOPath to your himmel checkout.Auto-set by setup/adopt into settings.json; set explicitly only for a non-default clone path.
HANDOVER_DIRHandover state root (Mode B — external state repo).Run /handover-setup. Bridged from .env. Unset = inline <repo>/handovers/.
LUNA_VAULT_PATHLuna vault root for end-session capture.Your Obsidian vault path. Not bridged — export it. Unset → ~/Documents/luna.
CLAUDE_LANE_AUTO_RESEEDOptional opt-out for the lane launchers’ config auto-refresh. By default claude-codex (and the dormant claude-glm) re-seeds its isolated config dir when your ~/.claude settings or plugin registry changed, so lane workers pick up plugin-profile changes automatically. Set 0 to restore the once-only seed (first launch + explicit --reseed) if the auto-refresh ever blocks a launch in your setup.Leave unset (default on). Not bridged from .env — set it in the launching shell. Only relevant if you use the offload lanes.
HIMMEL_INITIATIVEDrive-to-ship auto-ship steps (opt-in, default OFF).Uncomment one line in .env (auto-ship step grammar documented inline in .env.example); read from .env by the SessionStart hook.
PERPLEXITY_API_KEYPerplexity Sonar — /research, /research-deep. Provisional — pending operator sign-off (2026-07-29, no active subscription — see docs/tooling-catalog.md).Perplexity API settings. Optional (blank = feature off).
XAI_API_KEYxAI Grok — /x-read, /x-pulse, /youtube. Provisional — pending operator sign-off (2026-07-29, no active subscription — see docs/tooling-catalog.md).xAI API console. Optional.

OLLAMA_NO_CLOUD (optional — ollama zero-egress defense-in-depth pin)

Not a himmel .env variable: the ollama binary itself reads it, so it must be set at OS/user scope (not just exported in the shell that launches claude) to reach a background ollama service. The primary zero-egress guarantee for the ollama-local lane (see /lanes) is structural and holds without this: bare model names never reach cloud, cloud is opt-in only via the -cloud suffix. OLLAMA_NO_CLOUD=1 is an additional belt-and-suspenders pin, checked (advisory, never a hard fail) by /himmel-doctor.

OSSet command
macOSlaunchctl setenv OLLAMA_NO_CLOUD 1 (current login session) — also add export OLLAMA_NO_CLOUD=1 to your shell profile so it survives reboots
LinuxAdd export OLLAMA_NO_CLOUD=1 to your shell profile, or if ollama runs as a systemd user service, a drop-in: systemctl --user edit ollama.service → Environment=OLLAMA_NO_CLOUD=1

For the full inventory (optional Bitbucket, Confluence, VM, and hermes keys, the automated review / pr-check critic profile (CR_PROFILE), handover/AFK (unattended) mode tuning, Telegram bridge flags, and the SESSION-ONLY guardrail-bypass list), read the annotated .env.example; it is the single source of truth (the complete operator flag map) and every entry there carries its own inline guidance.



2. Global Claude Config

These files live at ~/.claude/ and must be copied on every new machine.

Terminal window
# Create dir if missing
mkdir -p ~/.claude
# Copy from this repo
cp docs/setup/global-claude-md.md ~/.claude/CLAUDE.md
cp docs/setup/rtk-md.md ~/.claude/RTK.md

Source of truth: docs/setup/global-claude-md.md and docs/setup/rtk-md.md.

CLAUDE.md uses @RTK.md; both files must be present in ~/.claude/.

The model key carries no [1m] suffix

~/.claude/settings.json’s model key sets the station-wide default model. Write it bare: "model": "claude-fable-5-1", never "claude-fable-5-1[1m]". Say why precisely, because the measurements below do not support the stronger claim: a suffix there is a station-wide default nobody chose per worker, and it is not even a reliable one. Fable, the very family in the example, silently strips [1m], so that exact string set nothing at all; and dropping the suffix did not move plain launches off a 1M window. So write it bare as configuration hygiene (the key should make no station-wide claim about context mode) and set the mode where it is actually observable, at arming.

Context mode is an arming-time choice instead: --context 1m|standard on scripts/handover/arm-resume.sh (7th positional on headed-arm.sh), defaulting to standard for every arm, chief or not; a chief (armed through chief.sh, which has no --context flag of its own) can still opt into 1m via CHIEF_CONTEXT=1m in the launching shell. The measured behaviour of both levers (which model ids accept the suffix, what a plain launch actually reports, and why --autocompact is the half that does the work) is in docs/internals/lane-calibration.md.

Two variants of the working principles, activated by profile

The general engineering defaults (think before coding / simplicity first / surgical changes / goal-driven execution) are not in himmel’s project CLAUDE.md; that file is held to a byte budget and carries himmel invariants only (gate scripts/ci/check-claude-md-budget.sh). They ship at user scope instead, in one of two ways:

VariantWho gets itHow
Operatorthis machine and any other operator stationthe manual cp above — global-claude-md.md is the full personal file, including @RTK.md
Adopterbash scripts/adopt.sh --profile core --scope user, user scope onlywire_user_claude_md appends the fenced block from user-scope-claude-md-template.md to both ~/.claude/CLAUDE.md (Claude Code) and ~/.codex/AGENTS.md (Codex), creating each file if absent

The adopter path is idempotent and never destructive: it skips when the HIMMEL:working-principles marker is already present, appends (never overwrites) when it is not, and also skips on a heuristic phrase match so a hand-written target that already states the principles is left alone. It runs against both targets, but only under --scope user: a --scope project install must not touch anything under $HOME, so a project-scope adopter who also wants the principles runs adopt.sh --scope user separately. A Claude-only install would otherwise leave a Codex adopter with the principles nowhere. Hermes is not a target because it already carries the same four principles in its himmel_agent profile SOUL (scripts/hermes/assets/himmel-agent.SOUL.md, § How you work), installed by install-himmel-profile.sh. Re-running adopt is also the migration path for an install that predates this, and adds nothing otherwise.

Existing installs also get a best-effort backfill on the harness self-update path: scripts/himmel-update.sh’s advisory steps run the same two-target wire_user_claude_md install unconditionally on every himmel-update (not under --check), so an adopter who already ran adopt.sh before the block shipped picks the block up on their next update without re-running adopt by hand. Like the other advisory steps, it never fails the update.


3. RTK (Rust Token Killer)

RTK is a token-saving CLI proxy that wraps common commands.

Terminal window
# Install (Linux / macOS)
cargo install rtk # or download binary from releases
# Verify (must show rtk, NOT reachingforthejack/rtk)
rtk --version
rtk gain

⚠️ Name collision risk: see docs/setup/rtk-md.md.

3a. Expected post-setup state: “No hook installed” banner

After himmel machine-setup, rtk init --show reports:

[--] Hook: not found
[warn] settings.json: exists but RTK hook not configured

And every rewritten command prints [rtk] /!\ No hook installed to stderr.

Both are benign and expected. himmel replaces rtk’s bare rtk hook claude PreToolUse entry with scripts/hooks/rtk-hook-guard.sh, which proxies rtk internally and adds a compound-predicate filter to fix broken find rewrites. rtk’s self-check looks for its own rtk hook claude signature (which the guard replaces), so it can’t find the hook even though rewriting is fully operational. rtk gain will show real token savings accumulating.

Do NOT run rtk init -g to “fix” it. That re-adds the bare entry the setup already replaced. The next run of reconcile-rtk-hook.sh collapses it back, but there’s no need to create the problem in the first place.

If bare entries do accumulate (e.g. you ran rtk init -g outside of machine-setup), run the idempotent reconciler:

Terminal window
bash scripts/lib/reconcile-rtk-hook.sh ~/.claude/settings.json <himmel-path>

This swaps every bare rtk hook claude entry to the guard and collapses the result to exactly one guard entry. Safe to run multiple times.


4. himmel Repo

Terminal window
git clone https://github.com/yotamleo-org/Himmel.git himmel
cd himmel
node scripts/himmelctl/bin.js install

himmelctl is the install wizard and the primary install path. It runs the preflight + required-tools gate, then walks you through a few questions and derives + runs the right underlying command. A missing required tool (git/jq/python3, or a JS package manager) is auto-fetched via the platform package manager when possible, else the wizard fails loud and points you at the required-environment table.

QuestionChoicesNotes
profilestarter | luna | operator | customReplaces the old adopter/contributor role fork. Every install now walks the SAME question set; profile only SEEDS the default answer at each later question below (a numbered menu with per-option help text) — it never skips one. Default starter. The contributor-dev overlay is set only by the --contribute flag, never a question (see below).
scopeproject | userUniversal now (no longer role-gated).
vaultnone | default-template | existingnone scaffolds no vault; default-template scaffolds a luna vault from template — if the destination already exists it is refused unless it carries the luna stamp (adopt.sh would skip the copy and silently adopt whatever is there), and a stamped destination is reused with the summary saying so rather than claiming a scaffold; existing wires a STAMPED luna vault (non-luna→luna conversion deferred). Skipping adopt.sh/setup.sh wiring entirely (no pre-commit hooks/guardrails/statusline/env.HIMMEL_REPO — operator wires manually) has no in-wizard option: don’t run the wizard.
handoverinline | externalexternal persists HANDOVER_DIR to an external state repo.
pluginSetleanThe only option (the default). full was dropped: its enable set didn’t reflect what himmel actually runs — see §6 for the documented per-plugin manual recipe instead.
lanescodex | hermes | none (comma list)Universal now. Default none. v1 ships Claude tiers as the only implementation lanes — codex and hermes are offered ONLY as cross-model automated review lanes for /pr-check, never as implementation lanes. ollama-local and copilot-cli — the two lanes this question used to default-select — are dropped from the wizard entirely; they still exist as ordinary (now dormant) rows in scripts/lanes/lanes.json, reachable only by an operator who opts in directly with that registry’s optInEnv (OLLAMA_LOCAL_LANE_OK=1 / COPILOT_CLI_LANE_OK=1), never through himmelctl. The interactive menu carries a help line per option naming what it needs (e.g. “codex — requires the codex CLI + its own login; skip if you don’t have it”) — selecting either AT THE PROMPT is itself the explicit consent --with-codex / --with-hermes provide non-interactively. The --lanes CSV flag stays restricted to none only: naming codex/hermes there is still refused (so the flag never becomes a second, quieter door around that consent), and naming ollama/copilot there is refused the SAME way — one door, not two — with the refusal naming the lanes.json opt-in env instead of pretending a --with-ollama flag exists. Without codex or hermes selected (by either door), /pr-check’s review panel runs Claude-only and CR_REQUIRE_CROSS_MODEL cannot be satisfied — the wizard discloses this at the question and the summary reflects the resulting floor.
alwaysOnyes | noUniversal now. Default no. Chooses the machine-hardening checklist over a one-line pointer — nothing is executed either way (see below).
cadencesper-cadence multi-select: pipeline, qmd, graphmap, drift-fix, upstream-watch, doctor, pull-cadence — comma list or nonePER-ROW gated, not whole-question vault gated: pipeline/qmd/graphmap are offered only when vault≠none; drift-fix/upstream-watch/doctor carry requires:'none' and are ALWAYS offered (they need neither a vault nor a lane); pull-cadence is offered only once GOOGLE_HEALTH_REFRESH_TOKEN is set in the primary checkout’s .env (docs/luna/google-health-connector-setup.md) — asked only when at least one row is offered, so e.g. a vault-less machine still gets asked (offering drift-fix/upstream-watch). Replaces the old binary luna-cadence question: pick WHICH recurring cadence jobs to arm now, not all-or-nothing. Enter accepts the profile-seeded recommended set (operator seeds pipeline,qmd,graphmap; every other profile seeds none). pipeline arms via the flags himmelctl derives from luna.cadence.* (see Adopter config file below); every other row arms via its own script’s all-default invocation (no per-adopter schedule surface yet). luna.cadence.enabled mirrors the pipeline selection for older readers. If the config document could not be saved it refuses to arm any unit, rather than leaving machine state the config cannot account for. scripts/graphify/graph-cadence.sh is NOT a separate row — it has no cmd_arm of its own; arming graphmap already arms it unconditionally as a fifth, standalone task.
disarm cadenceyes | noAsked only when at least one offered cadence above was declined, naming every declined unit. Default no. A yes runs each declined unit’s own disarm subcommand with consent (--dry-run shows it) to tear down any already-armed jobs; a no leaves them armed and the run’s summary says so explicitly, per declined unit, rather than implying off already disarmed anything.
PHI declarationyes | novault≠none only. Default no. Preceded by a printed, read-only PHI checklist (§4d). Records only the operator’s yes/no answer at luna.phi.declared — himmelctl creates none of the PHI markers themselves (see §4d).
secrets walkrun | skipvault≠none only. Default skip. Walks luna secrets interactively (instruction card + a probe per secret); himmelctl never harvests the secret value itself. Walks only the secrets whose feature tag (scripts/himmelctl/lib/secrets-manifest.json) matches what you actually selected elsewhere in this run — vault≠none for luna-source credentials, telegram bridge on for TELEGRAM_BOT_TOKEN/WHISPER_MODEL. A feature you declined (or never asked about) is skipped, with one line naming how many secrets and why; .env.example’s own generated block stays the full union for every adopter, reorganized into the same per-feature sections.
telegram bridgeoff | onvault≠none only. Default off. Configures the bridge for voice/text ingestion (see §8.6).
bridge .env path / whisper CLI / whisper modelpaths (blank = default/autodetect)Asked only when the telegram-bridge answer above was on. Defaults: ~/.claude/channels/telegram/.env, autodetect, ggml-small.bin.
bridge persistenceyes | noAsked only when the bridge is on and this platform has an installer: a systemd --user unit (telegram-bridge.service) + linger (Linux). Default no. On any other platform (macOS included) the question is skipped entirely and himmelctl prints a one-line notice to install/enable persistence by hand instead.

Flags: --dry-run prints the derived plan without executing; --from-profile <path> replays a saved answer profile non-interactively (the wizard caches your answers, so the same install replays verbatim); --scope project|user (below) runs non-interactively too, from a shipped profile rather than a saved one; --contribute layers the contributor-dev setup.sh primitive on top of the install (never a question); --lanes <csv|none>, --with-codex and --with-hermes answer the lane question up front (all three are refused alongside --from-profile; a saved profile already carries its lane selection and stays the sole authority on a replay). To offboard later: node scripts/himmelctl/bin.js uninstall, a thin wrapper over scripts/uninstall.sh (§8.7).

Non-interactive install. --default-scope only presets a wizard answer; the wizard still prompts. To skip the wizard entirely, run one of the shipped, placeholder-free adopter profiles:

Terminal window
node scripts/himmelctl/bin.js install --scope project # or: --scope user

This is shorthand for --from-profile docs/setup/profiles/adopter-<scope>.install-profile.json: a plain starter, pluginSet: lean install with no lanes, no vault, and no telegram bridge (see docs/setup/profiles/README.md). Point --from-profile at your own saved or hand-authored profile instead for anything more; combining --from-profile with --scope overrides just that profile’s scope field.

What the adopter profile does NOT do (v1, deliberate). Two things it reports rather than performs, because in both cases doing them would mean the installer asserting something it cannot stand behind:

  • Lanes are probed, never installed or force-activated. The wizard resolves each selected lane through the same evaluator and the same merged configuration /lanes uses (scripts/lanes/probe.mjs + resolve.mjs, base lanes.json overlaid with lanes.local.json), so the two can never disagree; notably hermes is detected via $HERMES_PY / venvs under $HERMES_HOME, not by looking for hermes on PATH, and a lane you have turned off locally reports DISABLED (with the config set command that re-enables it) rather than available, and a corrupt lanes.local.json makes lanes report UNKNOWN naming that file rather than quietly falling back to the base registry. A single overlay entry that is degenerate (a null/unknown-kind probe) fails closed the same way the resolver does: the lane reads not-usable with the reason named, because /lanes excludes it too. Whenever a lane needs an overlay fix, the summary lists that fix and the lane’s own setup step, in that order, including the case where a lane is both missing and locally disabled, where installing the CLI alone would leave /lanes still excluding it. Under the resolver’s LANES_REGISTRY test override the same replacement-no-overlay semantics apply here, so the report never describes a different lane set than /lanes resolves. Physical presence is always decided by the base registry probe, never by the overlay: an override can turn a lane off, but config set lanes.<id> on cannot conjure a binary you never installed; that combination reports MISCONFIGURED and keeps its install command. Note the probes establish that a binary exists, not that a lane is configured: hermes has no richer readiness probe, so a present hermes binary always stays listed under still manual as a verify item (confirm the venv/auth manually). Only a lane with a richer readiness probe (codex, via the manifest’s provisioning check) can report ready. A lane whose CLI is already present is reported as binary already present and skipped (so a re-run is idempotent); a lane that is absent is listed under still manual with its install command. That command and the follow-up setup step point at scripts/codex/install-himmel-codex.sh and the POSIX branch of docs/hermes-runbook.md. It is NOT installed for you. An applied adopter install writes the top-level profileAllowlist and profileAllowlistScope fields to lanes.local.json; selected lanes keep their real base probes. It does not call himmelctl config set lanes.<id> on, because that would write probe.kind=always and make an absent lane report as available.
  • Machine hardening is printed, never executed. With alwaysOn=yes the run ends in a checklist of the Phase-6 steps: power profile, no-lock-on-wake, sshd, auto-logon. They are machine-wide, need an elevated shell, and himmelctl uninstall cannot undo them, so they stay a checklist you run by hand.

Every run ends with an honest summary: what was installed, what was skipped and why, and what remains manual. Under --dry-run the first bucket is labelled would install and the installed list is empty; a preview never reports work it did not do.

On an applied adopter install, the selection narrows the runtime /lanes inventory. Non-selected optional lanes whose real probes succeed remain visible under suppressed-by-profile; they are physically available but not routable. No profileAllowlist means the pre-profile resolver behaviour is unchanged.

Node-less (clean) machine? himmelctl install itself needs node. Run the bootstrap shim first. It installs node (+ bun on macOS via brew; apt has no bun package, so Linux gets node + npm and bun stays an optional post-bootstrap step) via the platform package manager (brew/apt) then hands off to himmelctl install (himmelctl’s own preflight covers every other tool):

Terminal window
bash scripts/himmelctl/bootstrap.sh --dry-run # Linux/macOS: preview the plan
bash scripts/himmelctl/bootstrap.sh # then run for real

Deprecated setup shim: scripts/machine-setup/ubuntu.sh. This full-machine provisioning script is soft-deprecated as an install entry point. They still provision the complete toolchain (git / python / jq / shellcheck / gitleaks, nvm+node, uv, the Claude CLI, rtk, with zero capability loss, locked O4) but no longer do himmel/luna wiring themselves; they print a deprecation notice and delegate the wiring to himmelctl bootstrap → himmelctl install. Prefer himmelctl install (above) on a machine that already has node, or the bootstrap shim on one that doesn’t. The hard-remove of this now-deprecated shim script itself (once its toolchain-provisioning role is also absorbed) is deferred to a follow-up.

What the wizard runs under the hood no longer depends on a role (that fork was removed): scripts/adopt.sh (the portable-core flow; see docs/setup/install.md) always runs. Passing --contribute layers scripts/setup.sh on top of it afterward (pre-commit install, Jira CLI build, .env from .env.example, plugin install, and wiring the statusline + env.HIMMEL_REPO + the UNIVERSAL hooks into ~/.claude/settings.json, user scope; see §4b), never instead of adopt.sh, and never asked as a question.

Build artifacts are gitignored: build them after cloning. scripts/jira/dist/index.js and scripts/bitbucket/dist/index.js are TypeScript build outputs, NOT tracked. A fresh clone has no dist/, so a direct node scripts/jira/dist/index.js list dies with MODULE_NOT_FOUND. bash scripts/setup.sh builds them (step [3/10], Jira + Bitbucket CLIs). If you skip setup, build by hand:

Terminal window
cd scripts/jira && npm install && npm run build && cd ../.. # or: bun install && bun run build
node scripts/jira/dist/index.js list # verify
# same shape for the Bitbucket CLI: cd scripts/bitbucket && npm install && npm run build

(Needs Node 18+ with npm, OR bun; see §1.)

To update an existing checkout later, run /himmel-update (or bash scripts/himmel-update.sh): git pull is what delivers himmel updates; marketplace autoUpdate does not. See updating.md.

himmelctl status — read-only install-state check

Terminal window
node scripts/himmelctl/bin.js status [--items a,b] [--json]

Diffs desired state (the manifest at scripts/install/manifest.json, crossed with your recorded install state) against actual state (live probes), severity-grouped (red/degraded/green/n/a). Read-only: never prompts; never mutates on repeat runs (the first check for a given target may persist one derived state entry: the sanctioned derive-write). --items scopes the run to a comma-list of item ids; --json emits stable machine-readable output instead of text.

Six luna/telegram-bridge items probe subsystems most adopters never opt into, so a fresh install must not read as a wall of red for something never turned on:

idwhat it checks
cadence-armedwhether the configured cadence schedules are actually registered with the OS scheduler
luna-sourceseach configured fetch-health source, via fetch-health.py --probe <source>
phi-coherencea .salus marker under the vault vs. its listing in phi-roots — reports degraded on a mismatch, never red
engine-allowlistarmed cadence jobs’ required engines against cadence-approve-engines.sh --print-engine-list
bridge-healthaccess.json + a live getMe call + exactly one getUpdates consumer (it counts poller.ts command lines anchored to THIS checkout, so a supervisor plus its poller child is one consumer, not two). Linux/macOS prefer systemctl --user show telegram-bridge.service and walk its MainPID’s children (pgrep -f <poller path> when no unit exists). Any other platform reports degraded naming process identity as unverified, never a silent pass
bridge-persistencewhether bridge persistence will actually survive a restart

bridge-persistence is pass/warn/opt-in; it never reports a hard red once opted in. A query that FAILS reports degraded/unknown, never absent: “not there” and “could not ask” stay distinct results. On Linux it checks unit-installed + enabled + linger, each with its own degraded reason (a unit that installs but whose linger step fails is reported as a partial install with the remediation named, never auto-rolled-back; without loginctl enable-linger, the systemd user unit only runs while that user is logged in and stops at logout). On macOS it always reports degraded, naming launchd as Stage 2; an unverified persistence state is never reported as healthy. When bridge.enabled is not true in ~/.himmel/config.json, bridge-persistence and bridge-health report a clean absence (n/a), not a failure: opting out is not a fault.

Three items scoped by your recorded SELECTIONS, not a config flag. telegram-bridge, hermes-lanes and codex-cli carry no ~/.himmel/config.json flag of their own (unlike the six above), so their only record of whether you ever opted in is the cached wizard answers read from install-profile.json (see below). A missing credential/install for a feature you never selected (telegram bridge off, the hermes/codex lane not chosen) reports n/a, not red; a genuinely selected-but-absent one still reports red. A machine with no recorded profile at all keeps today’s full-nag behavior rather than going quiet (fail-open, never silent). The same selection→feature mapping (adopterProfileLib.resolveActiveFeatures()) also scopes the wizard’s own secrets walk (previous section).

The scope comes from your last wizard run (the cached ~/.claude/himmel/install-profile.json): under project scope the target is keyed by the directory you run it from, so run it from the adopted project’s root; under user scope the target is the single user entry regardless of cwd. (A per-run scope override lands with the Phase-2 verbs.) It’s the shared pre-check/post-check/enable-time primitive later himmelctl verbs (install/uninstall/enable) build on.

himmelctl ensure — converge this target

Terminal window
node scripts/himmelctl/bin.js ensure [--profile core|luna|all] [--items a,b] [--prune] [--yes] [--dry-run]

Three steps: a status-shaped pre-check read, driving whatever it reports red/degraded through the EXISTING install primitives (adopt.sh/setup.sh/ the wire-*.sh libs/install-plugins.sh/qmd’s qmd_install, never reimplemented), then a status-shaped post-check. Idempotent: an already-green target is a no-op. --profile reconciles the target to a different profile FIRST (so e.g. ensure --profile luna on a core-derived target converges the luna-only items too) before converging. --items scopes the run to a comma-list of item ids. Both status --items and ensure --items reject an unknown item id outright (exit 2); what ensure --items adds (unlike status, which validates ids and nothing further, staying a pure read/display filter) is that it validates the selection’s dependency closure in BOTH directions before touching anything: a selection that omits a still-desired, red/degraded prerequisite an item being converged depends on is rejected (exit 2, zero mutation), naming both ids and the remediation that actually resolves it; the mirror check on the toward-disabled side rejects unwiring a prerequisite while a still-desired dependent isn’t being unwired too. Naming a prerequisite in --items is not by itself sufficient to satisfy this check if that prerequisite is hint-only (see below, and the “never fail-closes” caveat there); only a prerequisite that will genuinely converge this run does. --dry-run prints the ordered convergence plan and makes zero mutations: no primitive is ever invoked. Converging and disabling are separately consented, and disabling is opt-in. A default ensure converges only: it never disables anything, whatever the target’s state says. Removing wiring requires --prune, and even then it is a second, independent Proceed with disabling? [Y/n], asked after the converge confirm and declinable on its own; declining it still lets the convergence proceed. Each disable candidate is printed with per-item evidence naming what the recorded profile says, what the live probe found, and the exact command the disable would run; not enabled for this target (profile/scope) alone is not something an operator can consent to. When the recorded profile looks under-recorded relative to live state, ensure says so and refuses to offer mass disables even under --prune, pointing at the wizard rather than reconciling reality downward.

Declining the converge confirm aborts the run outright: the --prune pass is not reached, and the message says so. That asymmetry is deliberate: “no” to the first prompt reads as “stop”, and following it by offering to remove things would be a worse answer, not a more independent one.

This replaces the pre-2349 behaviour, in which one consolidated himmelctl: about to ... line could list convergence work (“converge N item(s): …”) and disable/unwire work (“disable N item(s): …”) semicolon-joined, and a single Proceed? authorized everything listed including the unwiring. That bundling is the incident this fixes: an operator had to accept eight unwanted disables to take one wanted convergence. A non-interactive, non-dry-run run (piped/automation, how ensure runs outside a Claude session) that has any work to perform (convergence, disable, or both) and passes neither --yes nor --dry-run exits 2 with non-interactive ensure requires --yes and makes zero mutations; automation must pass --yes explicitly; there is no silent unattended consent. An already-green run (nothing to converge or disable) needs no consent and exits 0 whether or not --yes is given.

An item with no automated install path yet (a config-type item, pending sub-ticket D, or one with no install descriptor at all) is reported as a hint. Two distinct guarantees here, not one: once a run is actually converging, a hint-only item never fail-closes it on its own account: it’s never dispatched, so ensure never waits on or blocks over its success. But --items dependency-closure VALIDATION (above) is a separate, EARLIER gate: naming a dependent whose desired+red prerequisite is hint-only is rejected before anything runs at all, precisely because that prerequisite can never converge no matter how it’s selected. So a hint-only item CAN block a selection from proceeding in the first place, even though it never blocks convergence once a run is genuinely underway. An enabled item the operator no longer wants converges by its removable field: per-item runs the matching unwire-*.sh primitive; full-offboard-only errors, naming the item and pointing at himmelctl uninstall.

HIMMELCTL_SUDO_PASSWORD — optional, for unattended Linux dep installs

ensure can converge missing host binaries (dep-type manifest items: jq, git, shellcheck, …) via the platform package manager. On Linux that means apt-get, which needs sudo. HIMMELCTL_SUDO_PASSWORD is an optional var: set it to let an unattended ensure run authenticate and install via apt non-interactively; leave it unset and ensure falls back to the non-interactive sudo -n form, which never prompts and never hangs: a dep whose sudo would PROMPT for a password fails FAST rather than blocking forever waiting for interactive input nobody outside a Claude session is there to provide. sudo -n is not an unconditional failure: where sudo is already cached from a recent authentication, or the account is configured NOPASSWD, it succeeds and the dep installs normally; the var only matters when authentication would actually prompt. Resolution order mirrors every other himmel .env-backed setting: a live HIMMELCTL_SUDO_PASSWORD in the process environment wins; otherwise it’s read from the primary checkout’s .env. Without it configured, ensure prints a one-line advisory (once per run, never per dep item) pointing here; it never reveals whether the var was merely absent or present-but-empty, only “not configured”.

Security: the password is passed to sudo on stdin only (sudo -S -p ''), never as a command-line argument (ps-visible to every user on the box), never logged, never printed in a --dry-run preview or an error message, and never present in the child process’s environment either: ensure passes every apt/brew primitive an explicit, scrubbed copy of its own environment with HIMMELCTL_SUDO_PASSWORD deleted. An inherited env var would otherwise be readable from /proc/<pid>/environ by any other process running as your user for the primitive’s whole lifetime, and would propagate to every grandchild it spawns (apt hooks, postinst scripts, …), none of which need it. A timed-out primitive is torn down tree-wide (not just the direct child) so a wedged installer does not leave a credential-bearing descendant running in the background, but the two platforms give materially different guarantees, and the POSIX one is weaker: on Linux/macOS the installer runs in its own process group, killed as a group on timeout, which is best-effort: a descendant that deliberately leaves that group (a setsid, a double-fork: routine daemon behaviour) is no longer reached by the group kill and survives it. Do not read the POSIX path as a Job-Object equivalent; it has no comparable structural teardown.

Because the value lives in a plaintext .env file, treat it like any other secret in that file:

  • The primary checkout’s .env must stay untracked; it is already listed in himmel’s .gitignore; never git add -f it or copy its contents into a tracked file.
  • Give it owner-only permissions on Linux/macOS: chmod 600 .env.
  • Where available, prefer a real secret manager (your OS keychain, pass, a vault/secrets-manager integration, CI secret store, …) over a plaintext .env value. Set HIMMELCTL_SUDO_PASSWORD in the process environment from there instead of writing it to disk at all; recall the resolution order above (a live process-env value always wins over the .env fallback), so this requires no code changes, just not writing the file-based fallback.

macOS needs no equivalent for a standard Homebrew install: the default, supported prefix (/opt/homebrew on Apple Silicon, /usr/local on Intel) is owned by the invoking user, so brew install never prompts for a password there. A custom prefix, or an install directory with pre-existing permission problems, can still require elevation or fail outright. HIMMELCTL_SUDO_PASSWORD has no effect on brew either way (it’s only ever consumed by the Linux sudo -S apt-get path), so a brew failure of that kind needs manual remediation.

The three generic guardrails (auto-approve-safe-bash, block-edit-on-main, block-read-secrets) can live at the user scope (protects every repo you work in) or the project scope only. himmel ships them project-level, so if you also wire them at the user scope they would fire twice inside himmel: a doubled bash spawn per tool call. setup-hooks.sh --guardrail-mode manages the himmel-owned user-level block so exactly one layer is active:

Terminal window
# global (default): protect all your repos; inside himmel the user-level copies
# run through guardrail-skip-in-himmel.js (one cheap node spawn, no double bash).
bash scripts/setup-hooks.sh --guardrail-mode global # add --yes to skip the prompt
# project: drop the user-level block (himmel-only protection; single native spawn).
bash scripts/setup-hooks.sh --guardrail-mode project

A bare bash scripts/setup-hooks.sh installs the git hooks and only prints the current mode. /himmel-update reports if a global block’s baked node path drifts.

After setup, fill in your .env values; see the per-variable walkthrough in §1 Required .env values, or re-run setup with --fill-env to be prompted with inline help for each field:

Terminal window
# Fill in Jira token (or run setup with --fill-env for guided prompts)
vi .env # set JIRA_API_TOKEN=...
# Verify
node scripts/jira/dist/index.js list
pre-commit run --all-files

Adopter config file — ~/.himmel/config.json

The luna-cadence, PHI-declaration and telegram-bridge wizard answers above are written to a single shared adopter config document, ~/.himmel/config.json (override: HIMMEL_LUNA_CONFIG_PATH), the same path on every platform. This is not the wizard’s own private answer cache (~/.claude/himmel/install-profile.json, mentioned above); that file replays your wizard answers on a --from-profile run; this one is the runtime config document the luna/bridge subsystems themselves read. Writes are atomic (write-to-temp, validate, rename) and keep one timestamped .bak.

Schema v1: these are the fields himmelctl manages; only a schedule’s day is optional. A key not in this table (top level or nested) is yours: an install keeps it untouched and prints one WARN naming it; it never fails the install. A value of the wrong type for a field below is still refused:

fieldtypedefault
versionexactly 11
luna.vaultPathstring~/Documents/luna
luna.cadence.enabledbooleanfalse
luna.cadence.schedules.fetchHealth.timeHH:MM01:30
luna.cadence.schedules.harvest.timeHH:MM02:00
luna.cadence.schedules.synthesize.timeHH:MM03:00
luna.cadence.schedules.health.time / .dayHH:MM / weekday (optional)04:00 / SUN
luna.cadence.models.harvest / .synthesize / .healthstringsonnet / sonnet / haiku
luna.phi.declaredbooleanfalse
bridge.enabledbooleanfalse
bridge.envPathstring~/.claude/channels/telegram/.env
bridge.whisper.clistring or nullnull
bridge.whisper.modelstringggml-small.bin

luna.phi.declared records only the adopter’s yes/no answer to the PHI question above; himmelctl creates none of the PHI markers themselves; §4d below stays the authority on those (the .salus marker, the phi-roots listing, the egress-denylist entries).

The wizard writes only the fields the run actually supplied. An unsupplied section (a contributor run, or an adopter who answered vault=none and so never saw these questions) is left exactly as loaded on disk, never coerced to a default; the write is skipped entirely when nothing changed.

Older or hand-trimmed files are migrated, not rejected. If the file lacks any field in the table, the install fills each missing field from the default above (or from this run’s own wizard answer, where it asked), after backing the file up byte-exact to config.json.bak-<timestamp> and recording that backup in the install provenance ledger. It prints one himmelctl: migrated ~/.himmel/config.json: <field> = <value> (default | wizard answer) line per field. A value you set is never dropped or rewritten, known key or not. A second run finds nothing missing, so it writes nothing and makes no second backup; --dry-run reports the migration and writes nothing. A file that is not valid JSON is refused and left untouched; the error names the hand-fix command (cp <file> <file>.hand-fix.bak && $EDITOR <file>).

Precedence (governs WHISPER_CLI / WHISPER_MODEL / TELEGRAM_ENV): a process environment variable overrides the configured value, which overrides the hardcoded default above.

4a. Optional — single-writer opt-out

For personal repos that commit straight to main (e.g. luna, salus, your docs/state repo), opt them out of the block-edit-on-main hook by dropping a local .single-writer marker at each repo’s root. The marker is gitignored (via global excludes) so it never propagates to a clone; a checkout without it stays protected.

Terminal window
# Single-writer opt-out for block-edit-on-main: ignore the marker
# globally (once), then drop one in each single-writer repo.
EX="$(git config --global core.excludesfile)"
[ -z "$EX" ] && EX="$HOME/.config/git/ignore" && mkdir -p "$(dirname "$EX")" && git config --global core.excludesfile "$EX"
grep -qxF ".single-writer" "$EX" 2>/dev/null || printf '.single-writer\n' >> "$EX"
touch ~/Documents/luna/.single-writer ~/Documents/salus/.single-writer ~/Documents/github/work-notes/.single-writer

This lets those repos opt out of the on-main edit block locally without the marker ever being committed.

4b. Hook scope: user vs project

himmel’s hooks split into two scopes. scripts/setup.sh (and adopt --scope user) wires the UNIVERSAL set into your user-scope ~/.claude/settings.json so they apply to every Claude session, in any directory, not just inside the himmel clone:

  • UNIVERSAL (user scope): auto-approve-safe-bash, block-edit-on-main, block-read-secrets (PreToolUse) + inject-initiative (SessionStart). Without user-scope wiring, a session launched outside the repo has no auto-approve (so the allow-listed Jira CLI gets denied) and no auto-ship-step injector (so HIMMEL_INITIATIVE never fires).
  • HIMMEL-DEV-ONLY (project scope): check-cr-marker-on-pr-create, block-backend-tier, auto-arm-on-cap, check-update-available, …; they only make sense while working inside the himmel repo, so they stay in the repo’s committed .claude/settings.json and are not user-wired.

The hooks reference this clone’s absolute path and dedup by hook basename, so re-running setup after moving the clone repairs the wiring instead of double-wiring. A fresh contributor who clones himmel still gets the safety hooks from the committed project .claude/settings.json even before running setup.

Duplication is benign. Inside the himmel repo the UNIVERSAL hooks are wired at both scopes and fire twice; that is idempotent (two auto-approve passes = the same allow; two block passes = the same block), so setup stays silent about it in-repo. For another adopted project that also carries a project-scope copy, setup prints an advisory listing the dupes + the unwire-pretooluse-hooks --scope project --target <repo> command to collapse them (never automatic).

HIMMEL_INITIATIVE and the AFK (unattended) pair are read from the himmel clone’s .env by the SessionStart hook (a value exported in the launching shell or set in settings.json env still wins); they ship commented in .env.example, so the opt-in default-OFF is preserved; uncomment one line to enable.

scripts/uninstall.sh step [6/8] is the symmetric teardown: it removes exactly what setup/adopt wired (preserving your non-himmel keys); --skip-settings keeps it.

4c. Scheduler backend (auto-arm resume)

The usage-cap auto-resume (auto-arm-on-cap → arm-resume.sh) schedules a relaunch via the OS scheduler. The watchdog hook is wired above, but the scheduler backend it relies on must exist or the armed resume silently never fires:

  • Linux: needs at + a running atd. ubuntu.sh installs+enables it (prompted); or do it by hand: sudo apt install -y at && sudo systemctl enable --now atd. (crontab is only a weaker fallback when at is absent.)
  • macOS: uses crontab (arm-resume skips at/atrun, which is off-by-default / SIP-fragile). Run scripts/machine-setup/macos.sh (ALPHA: validate it fires and file an issue). cron may need Full Disk Access on modern macOS.

Diagnose any existing install with /himmel-doctor (the C9-scheduler check reports OK / WARN + the exact per-OS remediation; it never runs a privileged command).

4d. Optional — PHI vault marker for claude-glm

The GLM lane is dormant (HIMMEL-1967; the lane registry offers it only when GLM_LANE_OK is set). If you still use the claude-glm launcher (Claude Code on the Z.ai GLM flat-rate lane; see tooling-catalog), drop a .salus marker file at the root of every PHI-bearing vault (e.g. ~/Documents/salus). The launcher refuses to start (exit 3, no override) when the marker sits in the directory you launch from; the check is per-directory, not subtree: launching from a subdirectory of a marked vault does not see the marker. For a hand-rolled PHI vault the marker is not placed by any himmel script; create it by hand (a vault scaffolded via setup.sh --medical gets it automatically):

Terminal window
touch ~/Documents/salus/.salus

For whole-subtree coverage (any launch cwd under the root refused), also list the absolute PHI roots one-per-line in ~/.config/claude-glm/phi-roots: same PHI-tier refusal, but subtree-wide.

4e. qmd search bootstrap (optional)

Skip if you don’t use qmd semantic search over the himmel docs + luna vault. Optional; the harness runs without it.

qmd is a local markdown search engine (BM25 + vector + rerank). himmel’s clone runs it as a shared HTTP daemon (localhost:8181) auto-brought-up by the qmd plugin’s SessionStart hook, so every session shares one read-only index. The standalone CLI installs from a local clone of the carried fork yotamleo/qmd until tobi/qmd#1029 is fixed upstream (the launcher forwards signals to bun, upstream PR tobi/qmd#1030; a follow-up pins back to tobi/qmd, and re-running the install re-points an existing clone’s origin in place), pinned to an immutable commit SHA rather than a mutable branch. Never bun add -g @tobilu/qmd directly, which EPERM-wedges on this project’s machines (zombie qmd mcp stdio processes hold locks) and bun blocks its postinstall script. bun itself is still required to build the clone (project rule: bun, never npm; see §1 foundational table). bash scripts/setup.sh step [4/10] + adopt.sh’s wire_qmd_core already run this install, register the himmel collection, and best-effort qmd pull; this section is the manual bootstrap if you skipped those or want the luna vault indexed too.

Terminal window
# 1. Install the qmd CLI: clone the fork, build it with bun, then symlink it
# onto the bun-global @tobilu/qmd path —
# scripts/lib/qmd-bin.sh is the single chokepoint (also used by adopt.sh/
# setup.sh); repo/ref/clone-dir are overridable via QMD_FORK_REPO /
# QMD_FORK_REF / QMD_FORK_DIR. Idempotent — re-run to update.
bash scripts/lib/qmd-bin.sh install
# 2. Pull the embedding + rerank models. WARNING: ~2.1 GB download — Ctrl-C-safe
# (re-run resumes); run once. Semantic search needs these.
qmd pull
# 3. Register collections (idempotent; skip ones you don't have).
qmd collection add /path/to/himmel --name himmel
qmd collection add ~/Documents/luna --name luna # your luna vault
# 3b. Optional: choose the embed model BEFORE the first embed. gemma is the
# default; Qwen3-Embedding-0.6B retrieves better but needs a build-capable
# host (GPU) to embed a corpus. See docs/internals/qmd-embed-model.md.
bash scripts/luna/qmd-embed-model.sh capability
bash scripts/luna/qmd-embed-model.sh set qwen # only on a build-capable host
# 4. Index + embed. `qmd update` ingests new/changed docs (fast); `qmd embed`
# builds the vector embeddings — CPU-intensive on a big vault (the luna vault
# can take tens of minutes on first embed; subsequent runs are incremental).
qmd update
qmd embed
# Verify
qmd collection list
qmd status # collections + doc counts (index registered)

Notes:

  • The qmd Claude plugin ships a path stub in ~/.claude/plugins/cache/qmd/qmd/<v>/bin/qmd that references an unbuilt dist/; scripts/lib/fix-qmd-stub.sh (run by setup + adopt) rewrites it to locate the bun-global install so plain qmd works everywhere.
  • Stop the shared daemon: qmd mcp stop. The index is sqlite+WAL read per query, so docs added by qmd update are live immediately; no daemon restart needed.

4f. cc-codex / cc-glm offload shims

scripts/shell/himmel-offload-shims.sh provides the cc-codex and cc-glm Bash functions (cc-glm launches the dormant GLM lane, HIMMEL-1967). bash scripts/setup.sh installs the guarded source line in ~/.bashrc best-effort; install it directly with:

Terminal window
bash scripts/shell/himmel-offload-shims.sh install

The resulting ~/.bashrc line sources the checkout’s absolute scripts/shell/himmel-offload-shims.sh path only when that file exists.


5. Luna Vault

Skip if you don’t use the Luna vault (the author’s personal Obsidian vault). §§5a, 6, 7, 8, 8.6 all depend on it; skip those too.

Canonical layout (after the layout fix): the vault lives at ~/Documents/luna, flat, not double-nested. If your machine has ~/Documents/luna/luna from a pre-fix clone, scripts/machine-setup/ubuntu.sh includes a migration step that flattens it.

Terminal window
# Clone (or restore from backup) — flat path, NOT ~/Documents/luna/luna
git clone <luna-remote> ~/Documents/luna
# Install pre-commit hooks
cd ~/Documents/luna
uv tool install pre-commit # or: pipx install pre-commit
pre-commit install
pre-commit install --hook-type commit-msg
pre-commit install --hook-type pre-push
# Verify
pre-commit run --all-files

Hooks: gitleaks (secrets scan) + standard hooks. Luna-specific rules live in ~/Documents/luna/CLAUDE.md (not linked here; it’s in a different repo).

Vault not at ~/Documents/luna, or want to route a repo’s session notes to a different vault? Point the end-session-wiki capture at it; see Choosing the target vault (and §7 below).

5a. Optional — Obsidian Web Clipper templates (Jira LUNA-2)

If you’ll be clipping web pages into Luna (X posts, articles, Reddit threads, newsletters, YouTube videos), install the Obsidian Web Clipper Chrome extension + drop in the 6 pre-built JSON templates that ship with Luna. Skip if you only want to use Luna for native notes.

Terminal window
# Templates ship in the Luna repo:
ls ~/Documents/luna/_Templates/Web-Clipper/import/
# 01-General-Article.json, 02-Research-Article.json, 03-Tweet.json,
# 04-Reddit-Thread.json, 05-Newsletter.json, 06-YouTube-Video.json,
# README.md

Install:

  1. Install Obsidian Web Clipper from the Chrome Web Store.
  2. Extension icon → Settings (gear) → scroll to Templates.
  3. Drag and drop all 6 .json files from ~/Documents/luna/_Templates/Web-Clipper/import/ onto the Templates list. Six templates appear: names, triggers, properties, body all wired up.
  4. Mark General Article as Default template (it has no triggers → fires when no other template matches).
  5. Confirm path: "Clippings" matches Luna’s Clippings/ folder (or change in extension settings).
  6. Smoke-test: clip any x.com/... URL → confirm Tweet template fires + note lands in Clippings/.

The drag-and-drop is the entire install. Trigger tables + post-install tuning live in luna at _Templates/Web-Clipper/import/README.md; the why behind each section lives in luna at 30-Resources/Tech/Obsidian Web Clipper Templates.md (both in the luna repo, not linkable from himmel).

Triage of clipped notes (action items / labels / Related Notes hygiene) is implemented as the obsidian-triage plugin shipped from himmel’s marketplace; see §6 below for install. Ticket: LUNA-3; handover tracked in the operator’s private handover repo.


6. Claude Code Plugins

Skip if you skipped §5 (Luna vault). The plugins in this section are either luna-dependent or personal-workflow tools; none are required for core himmel operation. Install only what you need.

Plugins live at ~/.claude/plugins/. Different install methods per plugin: read the Source column carefully:

PluginSourceInstall methodWhy
obsidian-second-brainhimmel marketplace (sources eugeniughelbur/obsidian-second-brain, tag-pinned)claude plugin install obsidian-second-brain@himmel after adding himmel marketplace. Never clone it into ~/.claude/skills/: a user-scope copy ignores enabledPlugins and loads into every sessionDaily notes, kanban, ADRs, vault operating manual. Opt-in: no named profile enables it; /profile enable does
handoverhimmel marketplace/plugin install after adding himmel marketplaceHandover doc workflows for cross-session continuity
obsidian-triage (LUNA-3)himmel marketplace/plugin install after adding himmel marketplaceAutonomous triage of Web Clipper output: /triage-clips + /synthesize-clips. Required only if you set up the Web Clipper templates in §5a
obsidian (Steph Ango’s skills)himmel marketplace (sources kepano/obsidian-skills, SHA-pinned)/plugin install after adding himmel marketplaceobsidian-markdown, obsidian-bases, json-canvas, obsidian-cli, defuddle. obsidian-triage can use obsidian-markdown for proper OFM when editing clipped notes (recommended, not required — fallback documented in the command body)
claude-obsidianhimmel marketplace (sources upstream AgriciDaniel/claude-obsidian, tag-pinned)/plugin install after adding himmel marketplaceWiki query, save, ingest, lint, autoresearch. Companion to obsidian-triage — wiki-query optionally powers richer Related Notes inference

Two-tier plugin profile: ALWAYS / ON-DEMAND / NOT INSTALLED

docs/setup/settings-template.json splits every plugin’s install/enable state into three tiers; its own _enabledPluginsComment / _onDemandPluginsComment / _onDemandConnectorsComment are the authoritative definitions; summarized here:

  • ALWAYS (enabledPlugins → true): installed AND enabled on every himmel machine: lean-skills, qmd@himmel, handover@himmel, himmel-ops@himmel. lean-skills replaces superpowers + mattpocock-skills (those two fat upstream plugins carried ~2.2k tok of skill listing into every session for skills almost never invoked; lean-skills vendors verbatim just the subset himmel actually uses; see marketplace/plugins/lean-skills/README.md). plannotator-effective-html (the design/UI kit) is no longer ALWAYS either: it is design-profile-gated (see the design profile in scripts/lanes/plugin-profiles.json): invoked once in 1628 local transcripts, it now opts in per design/UI work instead of paying its ~630 tok cost on every session.
  • ON-DEMAND (a key of the top-level onDemandPlugins object, also false in enabledPlugins): INSTALLED by install-plugins.sh but left DISABLED, reachable in one command, costing no session-start context. Table
    • toggle command below.
  • NOT INSTALLED (an enabledPlugins false entry absent from onDemandPlugins): the earlier lean floor, unchanged by the two-tier profile; the table in the next subsection covers this tier, and re-enabling one there still means claude plugin install, not just enable.

Design profile: log in to context7 and agent-native-dispatch once per machine. Both stay on for design work: context7 serves current library docs, and agent-native-dispatch is the hosted Builder.io MCP server (https://dispatch.agent-native.com/mcp) that builder-visual@himmel registers when the design profile enables it. Until each is authenticated, a design worker cannot answer the OAuth prompt and looks hung. To log in, make sure the plugin is enabled, start an interactive claude session, run /mcp, pick each server and choose Authenticate. himmel-doctor check C72-design-mcp-auth warns when either login is missing (it reads the local MCP OAuth store, no network call, and stays silent where that store is a keychain). Never use agent-native-dispatch on salus/PHI work.

Toggling the on-demand tier — plugin-profile.sh / /profile

scripts/machine-setup/plugin-profile.sh is the only sanctioned writer for an on-demand flip. It writes solely through claude plugin enable|disable <spec> --scope user, never a hand-edited settings.json (hook-blocked: block-edit-live-settings). The /profile slash command wraps it.

Terminal window
bash scripts/machine-setup/plugin-profile.sh list # both tiers + live state + neededBy
bash scripts/machine-setup/plugin-profile.sh lean # disable every on-demand plugin that is currently enabled
bash scripts/machine-setup/plugin-profile.sh full # enable installed on-demand plugins at user scope
bash scripts/machine-setup/plugin-profile.sh enable <spec> # enable one, user scope
bash scripts/machine-setup/plugin-profile.sh disable <spec> # disable one, user scope

<spec> takes a bare plugin name (context7) when it is unambiguous across both tiers, or the full plugin@marketplace form. --dry-run prints the claude plugin … command instead of running it; --json (list only) emits the tier table as JSON; --template PATH overrides the template. disable refuses the three harness-operational plugins handover@himmel, himmel-ops@himmel, qmd@himmel; disabling any of them would break the session running the command.

Each write is verified: after claude plugin enable|disable the script re-reads claude plugin list and confirms the user-scope state, so an exit-0 no-op is reported rather than acknowledged (project/local state is never read as user state; those settings may still override the effective state). Exit codes: 0 reached (or already there); 1 a CLI call failed, the state could not be read or verified, or a bulk lean/full had any write refused (floor) or failed; 2 usage error or a single disable <spec> refused by the floor.

On-demand pluginNeeded byEnable
context7@claude-plugins-official/find-docs and the context7-mcp skill — live library/API documentation lookupsplugin-profile.sh enable context7
typescript-lsp@claude-plugins-officialTypeScript/JS work in this checkout (scripts/jira, marketplace/plugins/claude-hud) — go-to-definition and diagnosticsplugin-profile.sh enable typescript-lsp
playwright@claude-plugins-officialBrowser-driven e2e work; the /run skill’s browser-driven fallbackplugin-profile.sh enable playwright
claude-obsidian@himmelThe obsidian-vault MCP used by the obsidian-second-brain skill and /obsidian-health — disabling the plugin is what disables that MCPplugin-profile.sh enable claude-obsidian
luna-correlate@himmelCross-vault correlation over luna (HIMMEL_MCP_LUNA_CORRELATE workflows)plugin-profile.sh enable luna-correlate
obsidian-triage@himmel/luna-ingest and the luna clip pipeline. The pipeline itself does NOT need it enabled here — see the cadence exception belowplugin-profile.sh enable obsidian-triage
telegram-himmel@himmelThe telegram MCP behind /telegram-clip and the Telegram connector parts — disabling the plugin is what disables that MCPplugin-profile.sh enable telegram-himmel
codex@openai-codexThe codex worker lanes resolve its cached companion by glob — needs the plugin INSTALLED, never enabledplugin-profile.sh enable codex

An on-demand enable is a LIVE toggle only: an opt-in reconcile (HIMMEL_RECONCILE_PLUGINS=1, e.g. via /himmel-update) writes the template’s enabledPlugins map verbatim and turns it back off; see the floor-reapply note below, which now applies to this tier too. To keep one on permanently on this machine, record "<spec>": true in ~/.claude/settings.local.json as himmel reconciliation input. The next opt-in reconcile copies that value into settings.json; Claude Code does not read the user sibling as a runtime settings layer.

On-demand CONNECTORS — not installed or managed by himmel

The template’s onDemandConnectors (claude-in-chrome, the claude.ai Gmail/Drive/Calendar connectors as google-workspace) is discovery-only: himmel does not install or configure either. claude-in-chrome is a per-site permission in the Chrome extension; google-workspace is a toggle under claude.ai → Settings → Connectors. They’re listed in the template purely so plugin-profile.sh list can name them and the install summary can point at them.

Cadence exception: force-enabling an on-demand plugin per run

scripts/luna/pipeline-cadence.sh’s emit_settings_fragment force-enables obsidian-triage@himmel via a --settings fragment ({"enabledPlugins": {"obsidian-triage@himmel": true}}) on every cadence run: --settings is the highest non-managed precedence layer, so it wins over a settings.local.json disable and keeps the cadence live even while the machine’s own profile leaves obsidian-triage on the on-demand tier, disabled. This is the pattern for any future unattended job that depends on an on-demand plugin: force-enable it per run via its own settings fragment, never by flipping the machine’s live profile.

Lean profile — disabled by default, enable on need

This is the NOT INSTALLED tier of the two-tier profile above: none of the plugins in this table are keys of onDemandPlugins, so re-enabling one here is a full claude plugin install, not just an enable. docs/setup/settings-template.json ships lean: 16 re-enableable @claude-plugins-official plugins (the table below) and obsidian@obsidian-skills are false in enabledPlugins; every adopter and every re-provisioned operator machine gets the minimal set by default instead of re-creating the maximal 31-plugin install every time. (A 17th @claude-plugins-official entry, pr-review-toolkit, is also false but is not listed for re-enable: /pr-check dispatches pr-review-toolkit:* agents only when CR_CLAUDE_AGENTS=1.) Turn any of these back on with one command (--scope user shown; swap for project/local per Scope above):

PluginEnable commandNote
github@claude-plugins-officialclaude plugin install github@claude-plugins-official --scope usergh CLI over MCP is the standing project rule — enable only for a one-off need
feature-dev@claude-plugins-officialclaude plugin install feature-dev@claude-plugins-official --scope user
plugin-dev@claude-plugins-officialclaude plugin install plugin-dev@claude-plugins-official --scope user
code-review@claude-plugins-officialclaude plugin install code-review@claude-plugins-official --scope userhimmel ships its own pr-review-toolkit(-himmel) critics — enable this only to also run the upstream flow
ralph-loop@claude-plugins-officialclaude plugin install ralph-loop@claude-plugins-official --scope user
pyright-lsp@claude-plugins-officialclaude plugin install pyright-lsp@claude-plugins-official --scope useroperator convention: don’t flip this on at user scope — dispatch a subagent with the plugin enabled for the one python task instead
agent-sdk-dev@claude-plugins-officialclaude plugin install agent-sdk-dev@claude-plugins-official --scope user
claude-code-setup@claude-plugins-officialclaude plugin install claude-code-setup@claude-plugins-official --scope user
code-simplifier@claude-plugins-officialclaude plugin install code-simplifier@claude-plugins-official --scope user
commit-commands@claude-plugins-officialclaude plugin install commit-commands@claude-plugins-official --scope user
playground@claude-plugins-officialclaude plugin install playground@claude-plugins-official --scope user
skill-creator@claude-plugins-officialclaude plugin install skill-creator@claude-plugins-official --scope user
atlassian@claude-plugins-officialclaude plugin install atlassian@claude-plugins-official --scope userenable-on-need: Jira is CLI-first per project rules (scripts/jira/dist/index.js) — enable only when a session needs interactive Confluence/Atlassian skills. The optional atlassian MCP integration is separate (see Optional integrations)
claude-md-management@claude-plugins-officialclaude plugin install claude-md-management@claude-plugins-official --scope userpowers /claude-md-audit: that command degrades to a one-line re-enable hint while this is off; re-enable to run CLAUDE.md audits via the claude-md-improver skill
hookify@claude-plugins-officialclaude plugin install hookify@claude-plugins-official --scope userScaffold hooks from a prompt — dev-authoring only
security-guidance@claude-plugins-officialclaude plugin install security-guidance@claude-plugins-official --scope userredundant with himmel’s CR gate — see the note below. Enable only if you are running himmel without the critic panel / CodeRabbit
obsidian@obsidian-skillsclaude plugin marketplace add kepano/obsidian-skills then claude plugin install obsidian@obsidian-skills --scope userobsidian-triage falls back to plain markdown when this isn’t enabled (documented in the command body) — enable if you need proper OFM parity

Note: scripts/machine-setup/install-plugins.sh calls claude plugin install for the union of enabledPlugins entries flagged true and onDemandPlugins keys (mirrored false in enabledPlugins). A fresh adopt/re-provision therefore installs the ALWAYS and ON-DEMAND tiers, not the NOT INSTALLED table above or the pre-lean 31-plugin default. Use the per-plugin commands above to opt any of those NOT INSTALLED plugins back in.

Note: the floor is re-applied on every /himmel-update by scripts/machine-setup/reconcile-enabled-plugins.sh, a whitelist reconciler, not the additive-only installer above: anything true in your live enabledPlugins that isn’t true in the template is forced back to false, so a manual “turn it back on” toggle does not survive the next update. Preview without writing: bash scripts/machine-setup/reconcile-enabled-plugins.sh --dry-run --scope user; an already-converged machine prints no drift — already at the lean floor. ~/.claude/settings.local.json is the one file reconcile never rewrites; it is read as an override that wins in both directions: a true there keeps a personal plugin (e.g. codex@openai-codex) enabled through every reconcile, a false there drops a floor plugin (e.g. playwright) you don’t want. Toggling a plugin off with claude plugin disable while its key is still true in settings.local.json does not stick: the next reconcile reads the override, not the live claude plugin state, and turns it back on. Change the override, not just the live toggle. This is the same mechanism the on-demand tier’s escape hatch above relies on; it is not floor-specific.

security-guidance@claude-plugins-official runs an LLM diff review at the end of every turn, an agentic commit review on every git commit, and ~9 hooks per session (Claude and Codex wiring). himmel already gates the same surface, harder and later: the /pr-check cross-model critic panel must be clean before gh pr create (the review-owed marker hook blocks it otherwise), and the CodeRabbit App review (which can only run once the PR exists) must be clean, with zero unresolved threads, before merge. Running security-guidance too means paying a model call per turn for a weaker version of a check those gates already block on, and adding process spawns to the pool that a kernel-handle leak was traced to.

Adopter default: OFF. docs/setup/settings-template.json and the user profile in scripts/lanes/plugin-profiles.json both ship it false as of this change; no adopter or dispatched lane enables it.

Operator box: decision pending. It is still ENABLED on the operator machine (user scope, Claude + Codex). The recommendation is to disable it:

Terminal window
claude plugin disable security-guidance@claude-plugins-official

But that is a user-scope change the operator applies, so that change deliberately did not flip it. Re-enable per-session or per-repo instead if you ever want the per-turn review back.

Install sequence

Terminal window
# himmel marketplace (carries handover + obsidian-triage + claude-obsidian
# + obsidian-second-brain)
# inside Claude Code:
# /plugin marketplace add yotamleo/himmel
# /plugin install handover
#
# # Optional — obsidian-second-brain, tag-pinned. Install it as
# # a plugin, never as a clone under ~/.claude/skills/: a user-scope copy
# # ignores enabledPlugins and loads into every session, bare included.
# /plugin install obsidian-second-brain # = claude plugin install obsidian-second-brain@himmel
#
# # Optional — Web Clipper triage stack (skip if §5a was skipped)
# /plugin install obsidian-triage
# /plugin install claude-obsidian # AgriciDaniel/claude-obsidian (upstream), tag-pinned
# # obsidian (kepano) is NOT in the himmel marketplace — install from its own:
# /plugin marketplace add kepano/obsidian-skills && /plugin install obsidian@obsidian-skills

After restoring plugins, verify skills load:

/obsidian-daily # from obsidian-second-brain, once installed and enabled
/triage-clips --dry-run # from obsidian-triage; should exit 0 with "no Clippings/" or per-clip preview

Note: claude-obsidian is pinned to an immutable tag in himmel’s marketplace/.claude-plugin/marketplace.json per supply-chain policy (a bare commit SHA is not installable). To update it, follow the Pin update workflow in marketplace/plugins/obsidian-triage/README.md. obsidian (kepano) is not in himmel’s marketplace; install it from obsidian@obsidian-skills.

Scope: user vs project

The /plugin install flow above records the plugin at user scope (~/.claude/settings.json), enabled for you across every project. That’s the right default for personal-workflow plugins. The alternative is project scope: declare the marketplace + plugins in a repo’s .claude/settings.json, so anyone who clones that repo gets them auto-known and enabled (each person is still prompted to trust the marketplace on first use).

The setup scripts let you pick: scripts/machine-setup/install-plugins.sh takes --scope user|project|local (default user). The top-level setup script prompts you to choose. For project/local the target is the current directory, so run from the repo you want the plugins scoped to. The CLI does it directly too: claude plugin install <name>@himmel --scope project writes the block below for you. Same keys, different file:

// <repo>/.claude/settings.json
{
"extraKnownMarketplaces": {
"himmel": { "source": { "source": "github", "repo": "yotamleo/himmel" } }
},
"enabledPlugins": {
"obsidian-triage@himmel": true
}
}

Pick by intent: yours, everywhere → user scope; part of this project, shared on clone → project scope. Committing extraKnownMarketplaces ships a “trust this third-party registry” into the repo: fine for your own repos, a supply-chain call if it has outside contributors. (The JSON above is the illustrative hand-edit form. himmel’s setup scripts instead read settings-template.json (which registers the marketplace from a local directory source rather than the GitHub repo shown above) and apply the plugin set at whichever --scope you pick.)

Direct install (copy-paste, no setup script)

The Install sequence above runs as part of the machine setup. To skip setup and just add the marketplace + the plugins you want, choosing the scope per command, copy-paste instead:

Terminal window
# Register the marketplace (once; the GitHub slug is case-insensitive)
claude plugin marketplace add yotamleo/himmel
# Install plugins — --scope user (default, every project) or
# --scope project (this repo's .claude/settings.json, shared on clone)
claude plugin install handover@himmel --scope user
claude plugin install obsidian-triage@himmel --scope user
claude plugin install claude-obsidian@himmel --scope user
claude plugin install himmel-ops@himmel --scope user
# obsidian (kepano) is NOT in himmel's marketplace — install from its own:
claude plugin marketplace add kepano/obsidian-skills
claude plugin install obsidian@obsidian-skills --scope user
# optional operator-coupled fork: telegram-himmel@himmel

Or install the entire manifest (the himmel plugins plus the official ones it builds on) in one shot from a clone, at a chosen scope:

Terminal window
git clone https://github.com/yotamleo-org/Himmel himmel
bash himmel/scripts/machine-setup/install-plugins.sh --scope project

The plugins carry their own slash commands + skills; that’s all you need to use them.

Troubleshooting: Host key verification failed on plugin install

Every @himmel plugin installs from the local marketplace clone except claude-obsidian, which is sourced from a separate GitHub repo and is the only one Claude Code must git clone over the network. himmel’s manifest now points it at an explicit HTTPS url so a fresh machine clones over HTTPS (no SSH host key needed). If you still hit:

Failed to clone repository: ... No ED25519 host key is known for github.com
and you have requested strict checking. Host key verification failed.

your git is rewriting HTTPS → SSH (a url."git@github.com:".insteadOf "https://github.com/" in ~/.gitconfig), so the clone resolves over SSH on a box with no github.com entry in ~/.ssh/known_hosts. Pre-seed the host key once, then retry the install:

Terminal window
ssh-keyscan github.com >> ~/.ssh/known_hosts
claude plugin install claude-obsidian@himmel --scope user

Remove / move between scopes

  • Remove (user scope): /plugin uninstall <name>@himmel.
  • Remove (project scope): delete that plugin’s enabledPlugins line from the repo’s .claude/settings.json (and drop the extraKnownMarketplaces.himmel block once no himmel plugin is left).
  • Move user → project: /plugin uninstall <name>@himmel, then add it to the repo’s .claude/settings.json as above.
  • Move project → user: remove its enabledPlugins line from the repo settings, then /plugin install <name>@himmel.

7. End-session wiki hook (auto-capture to Luna vault)

Skip if you don’t use the Luna vault; this hook writes session notes there and has no effect without it.

Every Claude Code session is auto-captured to the Luna vault as a structured note under sessions/YYYY/MM/YYYY-MM-DD-HHMM-<repo>-<branch>.md via a SessionEnd hook. Built by epic #7; full schema + opt-out in docs/luna/end-session-wiki.md.

Register it by hand. bash scripts/setup.sh hands off to himmel install, whose wire targets do not include SessionEnd, so add the block to ~/.claude/settings.json yourself (back the file up first):

"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "bash \"<himmel-path>/scripts/hooks/end-session-wiki.sh\"",
"shell": "bash",
"timeout": 30
}
]
}
]

For per-repo opt-out, env-var disable, dry-run mode, and full operational reference, see docs/luna/end-session-wiki.md.


8. MCP Servers

himmelctl status now reports the graphify-mcp item by checking its registration in ~/.claude.json (for a focused check: node scripts/himmelctl/bin.js status --items graphify-mcp).

tokensave — dropped

tokensave was evaluated on 2026-09-06 and dropped: it lost to plain Grep+Read on all three measured symbol-level ops. Do not install it; audit reports live in the state repo. The installer no longer provisions it (removed: the manifest item, install dispatch, and its minimal/other-profile membership); a fresh install never registers it.

graphify — optional knowledge-graph CLI + MCP server

graphify powers the structure/architecture half of the retrieval routing (qmd finds content, graphify explains structure; see CLAUDE.md). Opt-in; install via setup:

Terminal window
bash scripts/setup.sh --with-graphify

(Installs the himmel fork via uv tool install at a pinned commit SHA; an existing foreign graphify install is adopted as-is, never reinstalled; see scripts/lib/graphify-bin.sh.)

The install exposes two binaries: graphify (the CLI) and graphify-mcp (the MCP server). The CLI (graphify query|explain|path, worker-graph.sh) is the supported path and what agents use. The MCP server is on demand, not default (HIMMEL-7177): setup.sh --with-graphify (and adopt.sh --with-graphify at user/local scope) write the absolute graphify-mcp path into the on-demand registry ~/.config/himmel/mcp-on-demand.json (override HIMMEL_MCP_ONDEMAND_FILE) and never into ~/.claude.json, so a normal session does not load its 10 tool schemas. To use mcp__graphify__*, generate the profile and launch with it (node scripts/mcp/build-mcp-profiles.mjs, then claude --strict-mcp-config --mcp-config .claude/mcp-profiles/local.graph.json). adopt.sh --scope project still registers the bare name in the committed .mcp.json (a project adopter’s explicit choice, portable across machines).

To (re-)register on demand by hand: bash scripts/lib/graphify-bin.sh register-mcp-on-demand. A machine that registered graphify at user scope before this change keeps loading it by default until you run claude mcp remove graphify -s user and then the command above.

Claude Code is the only agent graphify needs (no codex/other-CLI prerequisite).

Graph refresh stays lean-invoke (graphify <corpus-copy> --update), never a hook; extraction backends are governed by the egress matrix; see the graphify section in CLAUDE.md.

Optional integrations

Skip if you don’t use the Luna vault or Atlassian (Jira/Confluence). Both entries here are optional integrations.

Configure in Claude Code settings (~/.claude/settings.json):

  • obsidian-vault (on demand, HIMMEL-7176): uvx mcp-obsidian pointing to the Luna vault path. Put its spec in ~/.config/himmel/mcp-on-demand.json ({"mcpServers":{"obsidian-vault":{...}}}, same shape as ~/.claude.json), not in ~/.claude.json, which every session loads. Launch with node scripts/mcp/build-mcp-profiles.mjs, then claude --strict-mcp-config --mcp-config .claude/mcp-profiles/local.vault.json. The Obsidian Local REST API plugin stays installed either way: the end-session-wiki hook writes session notes through it.
  • atlassian: Jira/Confluence MCP (requires token)

8.6. Telegram bridge onboarding

Skip if you don’t use the Telegram bridge for sending messages to Claude. This is operator-specific infrastructure; it is not required for any core himmel workflow.

scripts/setup.sh step [7/8] runs scripts/setup/onboard-telegram.sh: scaffold-only, also safe to run standalone. It:

  • creates ~/.claude/channels/telegram/ + a TELEGRAM_BOT_TOKEN= .env template (never overwrites an existing .env);
  • reports access.json (pairing) status. Setup never writes access.json: the allowlist is a prompt-injection surface and the live bridge must be restarted by the operator after edits. Create it yourself: {"allowFrom":["<your-telegram-user-id>"]};
  • never starts the bridge (one getUpdates owner per token; a blind start could 409-conflict a live poller). Bring-up after token + pairing: bash scripts/telegram/restart-bridge.sh start. Reboot persistence + full ops: scripts/telegram/README.md / docs/internals/telegram-bridge.md.

To find the ids for access.json without guessing, run bun scripts/telegram/onboard.ts [--timeout <seconds>] (default 300s) before bringing the bridge up: it prints a one-time nonce, waits for that nonce to come back in a Telegram message, then reports the chat.id / chat.type / from.id it arrived on: the values you hand-copy in. It is a getUpdates consumer itself, so it refuses to start while a poller is live (the same one-owner-per-token rule); stop the bridge first (bash scripts/telegram/restart-bridge.sh stop, or bun scripts/telegram/supervisor.ts --kill) if it is already running. Like the rest of setup it never writes access.json itself. Full walkthrough: scripts/telegram/README.md.

8.7. Uninstall / offboard

scripts/uninstall.sh is the symmetric teardown of setup + install-plugins. Run it through the wizard (node scripts/himmelctl/bin.js uninstall), which adds its own one confirm then execs the script below with --yes (see §4); or invoke the script directly. Destructive and fail-closed: interactive runs prompt; non-interactive runs abort without --yes. Preview first:

Terminal window
bash scripts/uninstall.sh --dry-run

himmelctl uninstall --yes confirms without prompting; --dry-run previews without changing anything. 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.

Steps: (1) stop the telegram bun bridge (bun supervisor.ts --kill), (2) remove telegram pairing + bridge state (~/.claude/channels/telegram/ incl. token + access.json, ~/.claude/handover/bridge/); local delete does NOT revoke the bot token; revoke via @BotFather when decommissioning, (3) remove HIMMEL-Resume-* scheduled jobs + the HimmelTelegramBridge logon task, (4) uninstall each installed Claude plugin at its own scope as reported by claude plugin list --json, with project-scope rows limited to the current project; the recorded install profile supplies only a fallback scope when enumeration is unavailable, (5) pre-commit uninstall ×3 hook types, (6) unwire ~/.claude/settings.json: remove env.HIMMEL_REPO, env.LUNA_VAULT_PATH, and the UNIVERSAL hooks that setup/adopt wired unconditionally (each helper removes ONLY its own key/stanza; non-himmel keys (your own hooks, MCP config, the rtk guard) are preserved), (7) remove the Claude marketplaces (only those no installed plugin still needs), (8) remove 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 (4), the claude-hud config, marketplaces (7) and adopter-script copies are kept with a printed hand command instead of being 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. Partial offboard via --keep-telegram-state / --skip-plugins / --skip-tasks / --skip-hooks / --skip-settings (PS: -KeepTelegramState etc.). --purge-state also removes the provenance ledger (--keep-backups spares its backup copies). Not touched: the himmel clone + .env, your non-himmel ~/.claude/settings.json keys, handover state outside the bridge root.


9. Verification Checklist

CORE — required for any adopter

  • rtk --version works
  • pre-commit run --all-files passes in himmel
  • Claude Code loads and hooks fire (run any command; check no hook errors appear)
  • Worktree round-trip: /worktree test/smoke creates a worktree; /clean removes it after merging

OPTIONAL — per integration

graphify:

  • graphify --version works; a fresh session does NOT list the graphify MCP server (on demand), and ~/.config/himmel/mcp-on-demand.json holds its entry, which a graph-profile session connects

Jira / HIMMEL project:

  • node scripts/jira/dist/index.js list returns HIMMEL issues

Luna vault:

  • pre-commit run --all-files passes in Luna vault
  • /obsidian-daily creates today’s note in Luna

Luna cadence (only if pipeline was armed in the wizard’s cadences question):

  • node scripts/himmelctl/bin.js status --items cadence-armed,engine-allowlist,luna-sources reports green/n/a, not red

Telegram bridge:

  • Bridge responds to a test message sent from Telegram
  • (only if bridge persistence was installed) node scripts/himmelctl/bin.js status --items bridge-persistence,bridge-health reports green/n/a, not degraded

core.hooksPath gate

The machine-setup script (ubuntu.sh) gates the post-clone flow on git config --get core.hooksPath being either unset OR set to an existing path inside the himmel working tree. If neither holds, setup aborts before pre-commit install runs.

Why: the repo on disk was once renamed yotam_internal → himmel, but the .git/config was copied with the old hard-coded core.hooksPath absolute string. Git silently skipped every pre-commit and pre-push hook for an unknown duration (no-push-to-main, npm-audit, npm-licenses, code-review-before-push, platforms-tested). PR #100 caught it manually mid-overnight. This gate prevents recurrence.

The same script (scripts/hooks/check-hookspath.sh) is wired in three other places:

  1. .pre-commit-config.yaml pre-commit stage: every commit fails if misconfigured.
  2. Claude Code ~/.claude/settings.json SessionStart array: prints a one-line warning when a session starts on a misconfigured repo. Non-blocking. The shared docs/setup/settings-template.json does NOT carry this entry; instead scripts/machine-setup/ubuntu.sh appends the bash entry.
  3. The smoke test at scripts/hooks/test-check-hookspath.sh covers eleven cases: unset, set-inside-repo (absolute + relative), set-but- missing, set-outside-repo, bypass-via-env, outside-any-git-repo, linked worktree pointing at primary git-common-dir, outside-both- worktree-and-git-common-dir, Windows-drive-relative, and Windows mixed-case absolute prefix (case-insensitive NTFS).

Existing machines (not freshly cloned)

The machine-setup script patches ~/.claude/settings.json on every run, so the SessionStart entries land automatically. If you have an EXISTING ~/.claude/settings.json you don’t want re-patched, copy the following fragment manually into the SessionStart[0].hooks array (replacing <himmel-path> with the absolute path to your himmel clone):

{
"type": "command",
"command": "bash \"<himmel-path>/scripts/hooks/check-hookspath.sh\"",
"shell": "bash",
"timeout": 10
}

Intentional bypass

If you have a legitimate reason to point core.hooksPath outside the repo (running a custom hook manager, e.g. lefthook, husky-in-parent-dir), set:

HOOKSPATH_OK=1 git commit ...
HOOKSPATH_OK=1 bash scripts/machine-setup/ubuntu.sh ...

Session-sticky: the env var must be set in the shell that launches the operation; it cannot be injected per-call by Claude. To restore the gate, unset the variable and re-launch.

The SessionStart hook has NO bypass: it is non-blocking; the printed warning IS the affordance.

Manual repro (verify the gate works)

Terminal window
# Inside a himmel clone:
git config core.hooksPath /tmp/nope
git commit --allow-empty -m "should be blocked"
# Expect: exit nonzero, "⛔ check-hookspath: core.hooksPath points
# at a path that does not exist" in stderr, no new commit created.
# (The pre-commit framework invokes the bash script, which uses ⛔.)
git config --unset core.hooksPath