Compare commits

..

1 Commits

Author SHA1 Message Date
alexbelgium
8d11bfe8e1 feat(claude_desktop): expose SUDO_PASSWORD option (LinuxServer.io convention)
Add SUDO_PASSWORD as a schema-only option, matching the existing
PASSWORD/TZ pattern. It passes through automatically via the add-on's
existing options-to-env-var mechanism (00-global_var.sh), which the
LinuxServer.io base image already reads to gate sudo access for the abc
user. Disabled by default when left unset; no rootfs changes needed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 14:09:36 +02:00
28 changed files with 132 additions and 1025 deletions

View File

@@ -258,15 +258,13 @@ If you want to do add the repository manually, please follow the procedure highl
![amd64][amd64-badge] ![amd64][amd64-badge]
![ingress][ingress-badge] ![ingress][ingress-badge]
&#10003; ![image](https://api.iconify.design/mdi/robot-happy.svg) [Claude Desktop](claude_desktop/) : Claude Desktop with Headroom, RTK, and TokenSave optimization &#10003; ![image](https://api.iconify.design/mdi/robot-happy.svg) [Claude Desktop](claude_desktop/) : Claude Desktop with Headroom MCP context compression and RTK acceleration
&emsp;&emsp;![Version](https://img.shields.io/badge/dynamic/yaml?label=Version&query=%24.version&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Fclaude_desktop%2Fconfig.yaml) &emsp;&emsp;![Version](https://img.shields.io/badge/dynamic/yaml?label=Version&query=%24.version&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Fclaude_desktop%2Fconfig.yaml)
![Update](https://img.shields.io/badge/dynamic/json?label=Updated&query=%24.last_update&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Fclaude_desktop%2Fupdater.json) ![Update](https://img.shields.io/badge/dynamic/json?label=Updated&query=%24.last_update&url=https%3A%2F%2Fraw.githubusercontent.com%2Falexbelgium%2Fhassio-addons%2Fmaster%2Fclaude_desktop%2Fupdater.json)
![aarch64][aarch64-badge] ![aarch64][aarch64-badge]
![amd64][amd64-badge] ![amd64][amd64-badge]
![ingress][ingress-badge] ![ingress][ingress-badge]
![smb][smb-badge]
![localdisks][localdisks-badge]
&#10003; ![image](https://api.iconify.design/mdi/movie-search.svg) [Cleanuparr](cleanuparr/) : Automatically removes stuck and unwanted downloads from your *arr and download clients &#10003; ![image](https://api.iconify.design/mdi/movie-search.svg) [Cleanuparr](cleanuparr/) : Automatically removes stuck and unwanted downloads from your *arr and download clients

View File

@@ -1,5 +1,3 @@
## source-20260716 (16-07-2026)
- Minor bugs fixed
## source-20260714 (14-07-2026) ## source-20260714 (14-07-2026)
- Minor bugs fixed - Minor bugs fixed
## source-20260709 (09-07-2026) ## source-20260709 (09-07-2026)

View File

@@ -127,5 +127,5 @@ slug: birdnet-go-dev
udev: true udev: true
url: https://github.com/alexbelgium/hassio-addons url: https://github.com/alexbelgium/hassio-addons
usb: true usb: true
version: "source-20260716" version: "source-20260714"
video: true video: true

View File

@@ -77,27 +77,10 @@ for entry in "${prs[@]}"; do
# Fetch the PR head commit by number; works unauthenticated for public repos. # Fetch the PR head commit by number; works unauthenticated for public repos.
git fetch --no-tags origin "refs/pull/${number}/head" git fetch --no-tags origin "refs/pull/${number}/head"
if ! git merge --no-edit --no-ff -m "Merge PR #${number}: ${title}" "${sha}"; then if ! git merge --no-edit --no-ff -m "Merge PR #${number}: ${title}" "${sha}"; then
mapfile -t conflicted_files < <(git diff --name-only --diff-filter=U) echo "!!! Merge conflict while merging PR #${number} (${title})." >&2
echo "!!! Resolve the conflict in the fork or pause this PR, then rebuild." >&2
# package-lock.json is generated content and stacked PRs can carry an git merge --abort || true
# older copy even when their source changes merge cleanly. Keep the exit 1
# lockfile already assembled from upstream and earlier PRs, but only
# when it is the sole conflict. Any source conflict remains fatal.
if [ "${#conflicted_files[@]}" -eq 1 ] \
&& [ "${conflicted_files[0]}" = "frontend/package-lock.json" ]; then
log "Resolving generated frontend/package-lock.json conflict using the accumulated tree"
git checkout --ours -- frontend/package-lock.json
git add frontend/package-lock.json
git commit --no-edit
else
echo "!!! Merge conflict while merging PR #${number} (${title})." >&2
if [ "${#conflicted_files[@]}" -gt 0 ]; then
printf '!!! Conflicting file: %s\n' "${conflicted_files[@]}" >&2
fi
echo "!!! Resolve the conflict in the fork or pause this PR, then rebuild." >&2
git merge --abort || true
exit 1
fi
fi fi
done done

View File

@@ -1,37 +1,6 @@
## 1.30 (16-07-2026)
- Compress large tool outputs automatically in every Claude Code session with a managed `PostToolUse` hook (new `headroom_auto_compress` option, enabled by default). Desktop-spawned sessions (cowork/dispatch) pin `ANTHROPIC_BASE_URL` to the production endpoint (headroom #869), so the transparent proxy never sees their traffic and compression there depended entirely on the model remembering to call the `headroom` MCP tools per the CLAUDE.md guidance — in practice most large outputs went uncompressed. The new `/usr/local/bin/headroom-posttooluse-compress.py` hook fires on `Bash`/`Grep`/`Glob`/`WebFetch` results over ~4000 characters, compresses them with Headroom's rule-based pipeline (SmartCrusher and friends; the Kompress ML path is disabled because its background model load can never complete inside a short-lived hook process), and swaps the result in via `hookSpecificOutput.updatedToolOutput` with a retrieval marker appended. Originals are stored in the shared CCR SQLite store (`~/.headroom/ccr_store.db` — the same one the headroom MCP server reads), so `mcp__headroom__headroom_retrieve` always recovers the full output; savings are recorded to the durable ledger (client `posttooluse-hook`) and show up in the existing gains report. The hook fails open (any error leaves the tool output untouched), never touches `stderr` fields so error text reaches the model verbatim, skips anything below a 50-token savings floor, and is registered idempotently in `~/.claude/settings.json` only after a `--self-test` confirms the interpreter can import headroom; disabling the option (or Headroom) removes the managed entry without touching user-defined hooks. Measured on a representative Home Assistant `states` dump: 10781 -> 2964 tokens (73% saved) at ~1.7 s hook overhead, with sub-100 ms pass-through for small outputs.
## 1.29 (16-07-2026)
- Point the Headroom MCP server at the persistent Kompress model cache. 1.27 set `HF_HOME` on the `svc-headroom` proxy longrun only, but the MCP server is a separate process spawned by Claude Desktop / Claude Code from the registered `mcpServers` entry, so it never inherited that export and kept resolving the HuggingFace cache to `~/.cache` — symlinked to tmpfs here and wiped on every restart. Its Kompress ML path therefore never found the model, re-downloaded ~270 MB into tmpfs on each boot, and lost it again on the next one; `headroom_compress` fell back to `router:noop` (unchanged output) on prose and other unstructured content. The managed `headroom` entry in both `claude_desktop_config.json` and `~/.claude.json` now carries `env.HF_HOME` pointing at the same `~/.headroom/hf` cache the proxy warms. Rule-based compression (SmartCrusher, structured tool output) was unaffected and worked throughout.
- Fix `~/.gitconfig` being written as `root` and left unreadable by the `abc` runtime user, which broke git for the user that actually runs it: every commit failed with `Author identity unknown` and the `gh` credential helper was invisible to authenticated pushes. `git config --global` ran as root during init and rewrites the file on every start, so `20-folders.sh`'s earlier recursive chown never stuck to it (`.config/gh` survived abc-owned only because the "already authenticated" branch skips rewriting it). The git/gh setup now runs as `abc` via `s6-setuidgid`, matching `81-tokensave_repositories.sh`, and reclaims any root-owned copies left by an earlier version before writing.
- Fix `~/.bashrc` accumulating stale `HOME`/`FM_HOME` exports when `data_location` changes. The idempotency guard only tested for the *current* `$LOCATION`, so changing the option and later changing it back appended a second block while leaving the first, and the last one written won for every interactive shell — leaving `$HOME` pointing at a directory the add-on no longer manages. Any tool that resolves config through `$HOME` then read the wrong path (`headroom doctor` reported `claude: not routed (no ~/.claude/settings.json)` against a correctly routed install, and bare `headroom` invocations created a stray `.headroom` tree under the old location). The block is now marker-delimited and rewritten from scratch on every boot, so it is idempotent across any number of `data_location` changes.
## 1.27 (15-07-2026)
- Route Claude Desktop cowork/local-agent-mode sessions through the Headroom proxy. Desktop spawns its bundled Claude Code binary at an absolute path (bypassing the add-on's PATH wrapper) with `ANTHROPIC_BASE_URL` pinned to the production endpoint, so those sessions never produced proxy savings. The add-on now manages `env.ANTHROPIC_BASE_URL` in `~/.claude/settings.json` — settings `env` entries replace inherited environment values at CLI startup — gated on `headroom_wrap_claude_code` and never overwriting a user-customized endpoint.
- Fix Headroom's Kompress compression engine never activating, which made even proxied traffic record zero token savings (e.g. 175 requests, 0 saved). The proxy's startup preload is deliberately cache-only, but the HuggingFace model cache defaulted to `~/.cache` — tmpfs in this add-on, wiped every restart — so the ONNX model (plus the separately fetched `answerdotai/ModernBERT-base` tokenizer) was never cached and the engine idled in "deferred" mode forever, misleadingly logged as `Kompress: not installed`. `svc-headroom` now points `HF_HOME` at persistent storage (`~/.headroom/hf`, ~270 MB); the proxy's own request path already downloads a missing model in the background on first use and passes requests through uncompressed until it lands, so no blocking startup pre-warm is needed — the port binds immediately either way, and Kompress activates within the first couple of requests on the first boot, then loads instantly on every boot after. The already-installed `proxy` extra's ONNX runtime is sufficient — the multi-gigabyte PyTorch `ml` extra is deliberately not installed.
## 1.26 (15-07-2026)
- Fix startup permission failures that prevented Claude Desktop from starting: storage was chowned to a hardcoded `1000:1000`, but the shared `abc` desktop user was never mapped to that UID. During init `abc` was still the image default (`911`), so TokenSave (`.claude.json.new`), RTK (`RTK.md`), nginx, PulseAudio, the Mesa shader cache, and Claude Desktop itself all hit `Permission denied`; the base image's `init-adduser` then remapped `abc` to root mid-startup (PUID/PGID were read from add-on options where they did not exist, falling back to `0`), which also made Claude Code reject `permission_mode: bypass`.
- Add `PUID`/`PGID` add-on options (default `1000:1000`) and remap `abc` to that identity at the very start of folder setup, before any ownership is applied and before any service resolves the user. The base image's `init-adduser` is pinned to the same effective identity so it can no longer remap `abc` mid-startup.
- In `permission_mode: bypass`, a configured `PUID: 0` automatically falls back to UID `1000` (Claude Code refuses bypass permissions as root), retaining the configured group.
- Fix `bashio::config.array: command not found` in the TokenSave repository setup, tools configuration, and `claude-tools-doctor.sh`: the function only exists in the repo's standalone bashio, not in the real bashio shipped in the image. Use `bashio::config`, which prints list entries one per line.
- Return managed Claude configuration files to the effective `abc` identity instead of the raw configured `PUID`/`PGID` (which previously fell back to `0` and left the files root-owned).
- Pre-create `/tmp/.X11-unix` with the standard sticky mode so Xorg, which runs as the non-root `abc` user on a tmpfs `/tmp`, no longer fails to create its socket directory (`_XSERVTransmkdir: euid != 0`).
## 1.25 (15-07-2026)
- Minor bugs fixed
## 1.24 (15-07-2026)
- Fix the `/usr/local/bin/claude` wrapper never routing terminal Claude Code sessions through the Headroom proxy: it hardcoded `HEADROOM_BIN="/usr/local/bin/headroom"` while the binary is installed at `/usr/bin/headroom`, so the executable check always failed and the wrapper fell back to launching Claude Code directly. Resolve the binary with `command -v headroom` instead.
- Harden startup TokenSave indexing so an interrupted `init`/`sync` or a hard add-on stop can no longer leave a corrupt semantic graph that fails every subsequent boot. Each configured repository is now prepared under a startup-scoped `flock` (serialised against overlapping restarts and mid-boot git sync hooks); an existing index is refreshed with a retried incremental `sync` (transient `SQLITE_BUSY` no longer looks like corruption); and only a genuinely unreadable index — or a half-written one flagged by an `init` sentinel — is quarantined to `.tokensave/corrupt-<timestamp>/` and rebuilt from scratch, so the graph self-heals instead of propagating corruption.
## 1.23 (15-07-2026) ## 1.23 (15-07-2026)
- Add a `ha-cli` helper that lets Claude configure Home Assistant (automations, scripts, scenes, helpers, dashboards, area/label/floor/entity registries, and service calls) through the Home Assistant Core API instead of a filesystem mount. It authenticates automatically with the add-on's `SUPERVISOR_TOKEN` via the Supervisor Core-API proxy (no token setup), and deliberately cannot reach `configuration.yaml`/`secrets.yaml` or other add-ons' credentials. Toggle with the new `enable_ha_api_helper` option (default on), which also controls a managed guidance block appended to `~/.claude/CLAUDE.md`. - Expose `SUDO_PASSWORD`, per LinuxServer.io's base-image convention: setting it grants the `abc` user sudo access gated by that password. Sudo access stays disabled by default when left unset. Passed straight through by the existing option-to-env-var mechanism; no rootfs changes needed.
## 1.21 (15-07-2026) ## 1.21 (15-07-2026)

View File

@@ -74,7 +74,7 @@ RUN curl -fsSL --retry 3 --retry-delay 2 \
# cannot alter executables elsewhere in the image. # cannot alter executables elsewhere in the image.
COPY rootfs/ / COPY rootfs/ /
RUN find /etc/cont-init.d /etc/s6-overlay /defaults /usr/local/bin -type f \ RUN find /etc/cont-init.d /etc/s6-overlay /defaults /usr/local/bin -type f \
\( -name "*.sh" -o -name "run" -o -name "finish" -o -name "ha-cli" \) -print -exec chmod +x {} \; && \ \( -name "*.sh" -o -name "run" -o -name "finish" \) -print -exec chmod +x {} \; && \
chmod +x /usr/local/bin/claude chmod +x /usr/local/bin/claude
# Uses /bin for compatibility purposes # Uses /bin for compatibility purposes
@@ -108,22 +108,27 @@ RUN install -d -m 0755 /etc/apt/keyrings && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
# Install the current upstream hadolint and actionlint releases for both supported # Install the current upstream hadolint and actionlint releases for both supported
ARG HADOLINT_VERSION=v2.14.0 # architectures. The GitHub release API resolves the latest asset at build time, so these
ARG ACTIONLINT_VERSION=v1.7.12 # developer tools are intentionally not version-pinned.
RUN set -eux; \ RUN set -eux; \
case "${BUILD_ARCH}" in \ case "${BUILD_ARCH}" in \
amd64) hadolint_arch="x86_64"; actionlint_arch="amd64" ;; \ amd64) hadolint_arch="x86_64"; actionlint_arch="amd64" ;; \
aarch64) hadolint_arch="arm64"; actionlint_arch="arm64" ;; \ aarch64) hadolint_arch="arm64"; actionlint_arch="arm64" ;; \
*) echo "Unsupported validation-tools architecture: ${BUILD_ARCH}" >&2; exit 1 ;; \ *) echo "Unsupported validation-tools architecture: ${BUILD_ARCH}" >&2; exit 1 ;; \
esac; \ esac; \
curl -fsSL --retry 3 --retry-delay 2 \ hadolint_name="hadolint-linux-${hadolint_arch}"; \
-o /usr/local/bin/hadolint \ hadolint_url="$(curl -fsSL https://api.github.com/repos/hadolint/hadolint/releases/latest \
"https://github.com/hadolint/hadolint/releases/download/${HADOLINT_VERSION}/hadolint-linux-${hadolint_arch}"; \ | jq -r --arg name "${hadolint_name}" '.assets[] | select(.name == $name) | .browser_download_url' \
| head -n 1)"; \
test -n "${hadolint_url}"; \
curl -fsSL --retry 3 --retry-delay 2 -o /usr/local/bin/hadolint "${hadolint_url}"; \
chmod 0755 /usr/local/bin/hadolint; \ chmod 0755 /usr/local/bin/hadolint; \
curl -fsSL --retry 3 --retry-delay 2 \ actionlint_suffix="_linux_${actionlint_arch}.tar.gz"; \
-o /tmp/actionlint.tar.gz \ actionlint_url="$(curl -fsSL https://api.github.com/repos/rhysd/actionlint/releases/latest \
"https://github.com/rhysd/actionlint/releases/download/${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION#v}_linux_${actionlint_arch}.tar.gz"; \ | jq -r --arg suffix "${actionlint_suffix}" '.assets[] | select(.name | endswith($suffix)) | .browser_download_url' \
| head -n 1)"; \
test -n "${actionlint_url}"; \
curl -fsSL --retry 3 --retry-delay 2 -o /tmp/actionlint.tar.gz "${actionlint_url}"; \
tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint; \ tar -xzf /tmp/actionlint.tar.gz -C /tmp actionlint; \
install -m 0755 /tmp/actionlint /usr/local/bin/actionlint; \ install -m 0755 /tmp/actionlint /usr/local/bin/actionlint; \
rm -f /tmp/actionlint /tmp/actionlint.tar.gz; \ rm -f /tmp/actionlint /tmp/actionlint.tar.gz; \
@@ -138,13 +143,10 @@ RUN /usr/local/bin/rtk --version && /usr/local/bin/tokensave --version
# Install only the Headroom proxy, code-compression, and MCP features used by this add-on, # Install only the Headroom proxy, code-compression, and MCP features used by this add-on,
# plus mcp-proxy (stdio->HTTP bridge for the Home Assistant MCP server) and uv (fast # plus mcp-proxy (stdio->HTTP bridge for the Home Assistant MCP server) and uv (fast
# installer used for the additional_pip option). The `proxy` extra already ships the ONNX # installer used for the additional_pip option).
# runtime + transformers needed by the Kompress compressor — the `ml` extra (full PyTorch,
# ~5 GB with CUDA wheels) is deliberately NOT installed; svc-headroom pre-warms the ONNX
# model into the persistent HF cache instead.
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends nodejs && \ apt-get install -y --no-install-recommends nodejs && \
pip3 install --break-system-packages "headroom-ai[proxy,code,mcp]" mcp-proxy uv websockets && \ pip3 install --break-system-packages "headroom-ai[proxy,code,mcp]" mcp-proxy uv && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* /root/.cache rm -rf /var/lib/apt/lists/* /root/.cache

View File

@@ -35,10 +35,9 @@ PATH tools.
`/usr/bin/claude` directly, the session remains functional and still has the `/usr/bin/claude` directly, the session remains functional and still has the
shared permission mode and Headroom MCP tools, but transparent proxy shared permission mode and Headroom MCP tools, but transparent proxy
compression cannot be injected. compression cannot be injected.
- The shared `abc` desktop account runs under the configured `PUID`/`PGID` - When `permission_mode: bypass` is selected while `PUID` is `0`, the add-on
(default `1000:1000`). When `permission_mode: bypass` is selected while automatically remaps the shared `abc` desktop account to an unused non-root
`PUID` is `0`, the add-on automatically falls back to UID `1000` before UID before Selkies and Claude Desktop start. Claude Code refuses bypass mode
Selkies and Claude Desktop start, because Claude Code refuses bypass mode
under an effective root UID. under an effective root UID.
- **gnome-keyring** provides the Secret Service backend Electron needs to - **gnome-keyring** provides the Secret Service backend Electron needs to
persist sign-in and dispatch permission grants across restarts. persist sign-in and dispatch permission grants across restarts.
@@ -89,17 +88,17 @@ Git synchronization hooks. A repository is indexed only when it is listed in
| Option | Default | Description | | Option | Default | Description |
| ------ | ------- | ----------- | | ------ | ------- | ----------- |
| `PUID` / `PGID` | `1000` / `1000` | Numeric user and group of the shared `abc` desktop account that owns the data location and runs Claude Desktop. In bypass mode, a root `PUID` is automatically replaced at runtime by UID `1000` while the configured group is retained. | | `PUID` / `PGID` | `0` / `0` | Numeric user and group applied by LinuxServer initialization. In bypass mode, a root `PUID` is automatically replaced at runtime by an unused non-root UID while the configured group is retained. |
| `TZ` | | Optional timezone, for example `Europe/Brussels`. | | `TZ` | | Optional timezone, for example `Europe/Brussels`. |
| `KEYBOARD` | | Optional Selkies keyboard layout. | | `KEYBOARD` | | Optional Selkies keyboard layout. |
| `PASSWORD` | | Optional password for direct Selkies ports. | | `PASSWORD` | | Optional password for direct Selkies ports. |
| `SUDO_PASSWORD` | | LinuxServer.io convention: grants the `abc` user sudo access gated by this password. Sudo access is disabled by default when left unset. |
| `DRINODE` | | Optional GPU device override for Selkies. | | `DRINODE` | | Optional GPU device override for Selkies. |
| `DNS_server` | `8.8.8.8` | DNS server used by the standard DNS module. | | `DNS_server` | `8.8.8.8` | DNS server used by the standard DNS module. |
| `auto_update` | `true` | Upgrade `claude-desktop` from Anthropic's apt repository at startup. | | `auto_update` | `true` | Upgrade `claude-desktop` from Anthropic's apt repository at startup. |
| `permission_mode` | `auto` | Claude Code permission policy: `strict`, `auto`, or `bypass`. | | `permission_mode` | `auto` | Claude Code permission policy: `strict`, `auto`, or `bypass`. |
| `install_headroom` | `true` | Register Headroom MCP and run the supervised local proxy. | | `install_headroom` | `true` | Register Headroom MCP and run the supervised local proxy. |
| `headroom_wrap_claude_code` | `true` | Route PATH-based Claude Code launches through the already-running Headroom proxy. | | `headroom_wrap_claude_code` | `true` | Route PATH-based Claude Code launches through the already-running Headroom proxy. |
| `headroom_auto_compress` | `true` | Auto-compress large tool outputs in every Claude Code session via a managed `PostToolUse` hook. |
| `expose_headroom_dashboard` | `false` | Bind Headroom to all interfaces. Port `8787/tcp` must also be mapped manually. | | `expose_headroom_dashboard` | `false` | Bind Headroom to all interfaces. Port `8787/tcp` must also be mapped manually. |
| `install_rtk` | `true` | Configure RTK's Claude Code `PreToolUse` Bash hook. | | `install_rtk` | `true` | Configure RTK's Claude Code `PreToolUse` Bash hook. |
| `install_tokensave` | `true` | Install TokenSave's complete global Claude integration. | | `install_tokensave` | `true` | Install TokenSave's complete global Claude integration. |
@@ -113,7 +112,6 @@ Git synchronization hooks. A repository is indexed only when it is listed in
| `enable_ha_mcp` | `false` | Register Home Assistant's MCP server in Claude (requires `ha_mcp_token`). | | `enable_ha_mcp` | `false` | Register Home Assistant's MCP server in Claude (requires `ha_mcp_token`). |
| `ha_mcp_url` | `http://homeassistant:8123/api/mcp` | Streamable HTTP endpoint of Home Assistant's MCP Server integration. | | `ha_mcp_url` | `http://homeassistant:8123/api/mcp` | Streamable HTTP endpoint of Home Assistant's MCP Server integration. |
| `ha_mcp_token` | | Home Assistant long-lived access token used by the MCP bridge. | | `ha_mcp_token` | | Home Assistant long-lived access token used by the MCP bridge. |
| `enable_ha_api_helper` | `true` | Ship the `ha-cli` Core-API helper and add guidance so Claude can configure Home Assistant without a `/config` mount. |
| `additional_apps` | | Comma-separated Debian apt packages to install at startup. | | `additional_apps` | | Comma-separated Debian apt packages to install at startup. |
| `additional_pip` | | Comma-separated pip packages installed at startup (via `uv`). | | `additional_pip` | | Comma-separated pip packages installed at startup (via `uv`). |
| `data_location` | `/data/data` | Persistent home directory for Claude and tooling. | | `data_location` | `/data/data` | Persistent home directory for Claude and tooling. |
@@ -133,11 +131,11 @@ permission_mode: auto
`--dangerously-skip-permissions` for wrapper-launched sessions. `--dangerously-skip-permissions` for wrapper-launched sessions.
Claude Code does not permit bypass mode when its effective UID is `0`. If the Claude Code does not permit bypass mode when its effective UID is `0`. If the
add-on is configured with `PUID: 0`, selecting `bypass` runs the shared `abc` add-on is configured with `PUID: 0`, selecting `bypass` remaps only the shared
runtime account as UID `1000` instead, before storage ownership and Desktop `abc` runtime account to an available non-root UID (preferring `1000`, then
startup. Its configured primary GID is retained, so group-based access to `911`) before storage ownership and Desktop startup. Its configured primary
mounted Home Assistant paths remains available. Strict and auto modes keep the GID is retained, so group-based access to mounted Home Assistant paths remains
configured identity unchanged. available. Strict and auto modes keep the configured identity unchanged.
A root shell invoking `/usr/local/bin/claude` in bypass mode is also dropped to A root shell invoking `/usr/local/bin/claude` in bypass mode is also dropped to
the remapped `abc` account. Directly invoking `/usr/bin/claude` as root still the remapped `abc` account. Directly invoking `/usr/bin/claude` as root still
@@ -176,16 +174,6 @@ the MCP integration. The `/usr/local/bin/claude` wrapper routes PATH-based Claud
Code sessions through `headroom wrap claude --no-proxy`, reusing the supervised Code sessions through `headroom wrap claude --no-proxy`, reusing the supervised
backend without starting a second proxy. backend without starting a second proxy.
With `headroom_auto_compress` enabled (the default), a managed Claude Code
`PostToolUse` hook additionally compresses large `Bash`/`Grep`/`Glob`/`WebFetch`
outputs (over ~4000 characters) in **every** session type — terminal, Desktop
cowork, dispatch, and cron — without the model having to remember to call the
MCP tools. The original output is kept in Headroom's local store for one hour
and can always be recovered with `mcp__headroom__headroom_retrieve` using the
hash printed in the compression marker. Error text (`stderr`) is never
compressed, and plain prose passes through unchanged; the savings come from
structured output such as JSON dumps, search results, and logs.
The dashboard is disabled externally by default. To expose it: The dashboard is disabled externally by default. To expose it:
1. Set `expose_headroom_dashboard: true`. 1. Set `expose_headroom_dashboard: true`.
@@ -229,45 +217,6 @@ The add-on bridges Claude to the integration's stateless Streamable HTTP
endpoint (`/api/mcp`) with `mcp-proxy`. Override `ha_mcp_url` only if your Home endpoint (`/api/mcp`) with `mcp-proxy`. Override `ha_mcp_url` only if your Home
Assistant instance is not reachable as `homeassistant:8123` from add-ons. Assistant instance is not reachable as `homeassistant:8123` from add-ons.
## Configuring Home Assistant (API helper)
When `enable_ha_api_helper` is on (the default), the add-on ships a `ha-cli`
command and tells Claude — via a managed block in `~/.claude/CLAUDE.md` — that
it can configure Home Assistant through the Home Assistant **Core API** rather
than a filesystem mount. This is deliberately more contained than mapping
`/config`: the API cannot read `configuration.yaml`, `secrets.yaml`, or any
other add-on's stored credentials.
`ha-cli` authenticates automatically with the add-on's `SUPERVISOR_TOKEN`
through the Supervisor Core-API proxy (the add-on already sets
`homeassistant_api: true`), so there is nothing to configure. It can create and
edit automations, scripts, and scenes; call any service; read entity states;
and, over WebSocket, manage helpers, dashboards, and the area/label/floor/entity
registries. Run `ha-cli --help` inside the add-on for the full command
reference.
```bash
ha-cli config # connectivity check
ha-cli get config/automation/config/<id> # read one automation
ha-cli post config/automation/config/<id> @new.json # create/update it
ha-cli call automation.reload # apply YAML-mode changes
ha-cli ws '{"type":"config/area_registry/list"}'
```
Security notes:
- The Supervisor proxy token grants **admin-equivalent** Core API access (it can
call any service and edit any UI-managed configuration), but it cannot reach
the raw YAML files or other add-ons' data. For a tighter scope, set
`HA_BASE_URL`/`HA_TOKEN` (or the `ha_mcp_token` option) to a limited Home
Assistant user's long-lived token — `ha-cli` prefers those when present.
- The guidance instructs Claude to read each object and show you the intended
change before writing, but Claude Code's own tool-permission prompts remain
the real gate: each `ha-cli` call still needs your approval unless
`permission_mode` is set to `bypass`.
- Set `enable_ha_api_helper: false` to remove both the guidance block and the
helper's registration if you do not want Claude configuring Home Assistant.
## Custom scripts ## Custom scripts
The add-on includes the repository standard custom-script executor. On first The add-on includes the repository standard custom-script executor. On first

View File

@@ -34,8 +34,6 @@ name: Claude Desktop
options: options:
env_vars: [] env_vars: []
DNS_server: 8.8.8.8 DNS_server: 8.8.8.8
PGID: 1000
PUID: 1000
data_location: /data/data data_location: /data/data
additional_apps: "" additional_apps: ""
additional_pip: "" additional_pip: ""
@@ -44,12 +42,10 @@ options:
enable_ha_mcp: false enable_ha_mcp: false
ha_mcp_url: http://homeassistant:8123/api/mcp ha_mcp_url: http://homeassistant:8123/api/mcp
ha_mcp_token: "" ha_mcp_token: ""
enable_ha_api_helper: true
github_token: "" github_token: ""
github_username: "" github_username: ""
enable_tools_health_report: true enable_tools_health_report: true
expose_headroom_dashboard: false expose_headroom_dashboard: false
headroom_auto_compress: true
headroom_wrap_claude_code: true headroom_wrap_claude_code: true
install_caveman: false install_caveman: false
install_github_cli: true install_github_cli: true
@@ -78,8 +74,7 @@ schema:
DRINODE: list(/dev/dri/card0|/dev/dri/card1|/dev/dri/card2|/dev/dri/renderD128|/dev/dri/renderD129|)? DRINODE: list(/dev/dri/card0|/dev/dri/card1|/dev/dri/card2|/dev/dri/renderD128|/dev/dri/renderD129|)?
KEYBOARD: list(da-dk-qwerty|de-de-qwertz|en-gb-qwerty|en-us-qwerty|es-es-qwerty|fr-ch-qwertz|fr-fr-azerty|it-it-qwerty|ja-jp-qwerty|pt-br-qwerty|sv-se-qwerty|tr-tr-qwerty)? KEYBOARD: list(da-dk-qwerty|de-de-qwertz|en-gb-qwerty|en-us-qwerty|es-es-qwerty|fr-ch-qwertz|fr-fr-azerty|it-it-qwerty|ja-jp-qwerty|pt-br-qwerty|sv-se-qwerty|tr-tr-qwerty)?
PASSWORD: str? PASSWORD: str?
PGID: int SUDO_PASSWORD: password?
PUID: int
TZ: match([A-Z][a-z]*./[A-Z][a-z]*.)? TZ: match([A-Z][a-z]*./[A-Z][a-z]*.)?
additional_apps: str? additional_apps: str?
additional_pip: str? additional_pip: str?
@@ -92,12 +87,10 @@ schema:
enable_ha_mcp: bool? enable_ha_mcp: bool?
ha_mcp_url: str? ha_mcp_url: str?
ha_mcp_token: password? ha_mcp_token: password?
enable_ha_api_helper: bool?
github_token: password? github_token: password?
github_username: str? github_username: str?
enable_tools_health_report: bool enable_tools_health_report: bool
expose_headroom_dashboard: bool expose_headroom_dashboard: bool
headroom_auto_compress: bool?
headroom_wrap_claude_code: bool headroom_wrap_claude_code: bool
install_caveman: bool install_caveman: bool
install_github_cli: bool install_github_cli: bool
@@ -111,5 +104,5 @@ slug: claude_desktop
tmpfs: true tmpfs: true
udev: true udev: true
url: https://github.com/alexbelgium/hassio-addons url: https://github.com/alexbelgium/hassio-addons
version: "1.30" version: "1.23"
video: true video: true

View File

@@ -0,0 +1,48 @@
#!/usr/bin/with-contenv bashio
# shellcheck shell=bash
set -e
set -o pipefail
# Claude Code deliberately refuses bypass-permissions mode when its effective UID is 0.
# The add-on historically defaults PUID to 0, so switch the shared `abc` desktop user to
# an unused non-root UID before storage ownership and Selkies runtime directories are set up.
# Keep abc's configured primary group (commonly group 0) so existing group-based access to
# Home Assistant mounts is preserved. Strict and auto permission modes are unchanged.
if [ "$(bashio::config 'permission_mode')" != "bypass" ]; then
exit 0
fi
CURRENT_UID="$(id -u abc)"
if [ "$CURRENT_UID" -ne 0 ]; then
bashio::log.info "Claude bypass runtime already uses non-root UID ${CURRENT_UID}"
exit 0
fi
find_available_uid() {
local candidate owner
for candidate in 1000 911 $(seq 1001 1099); do
owner="$(getent passwd "$candidate" | cut -d: -f1 || true)"
if [ -z "$owner" ] || [ "$owner" = "abc" ]; then
printf '%s' "$candidate"
return 0
fi
done
return 1
}
TARGET_UID="$(find_available_uid || true)"
if [ -z "$TARGET_UID" ]; then
bashio::exit.nok "Claude bypass mode requires a non-root runtime user, but no free fallback UID was found"
fi
usermod --uid "$TARGET_UID" abc
if [ "$(id -u abc)" -eq 0 ]; then
bashio::exit.nok "Unable to switch the Claude Desktop runtime away from root for bypass mode"
fi
mkdir -p /run/s6/container_environment
printf '%s' "$TARGET_UID" > /run/s6/container_environment/CLAUDE_RUNTIME_UID
printf '%s' "$(id -g abc)" > /run/s6/container_environment/CLAUDE_RUNTIME_GID
bashio::log.warning "Claude bypass mode cannot run as root; remapped abc from UID 0 to UID ${TARGET_UID} (GID $(id -g abc))"

View File

@@ -3,35 +3,10 @@
# shellcheck disable=SC2046 # shellcheck disable=SC2046
set -e set -e
# Align the shared desktop user (abc) with the configured PUID/PGID before any storage is # Use the effective shared desktop user identity. In bypass mode an earlier init script may
# chowned and before any service or s6-setuidgid call resolves abc. The base image's # remap abc away from UID 0 because Claude Code rejects bypass permissions when run as root.
# init-adduser applies the same remap, but it runs after cont-init, so doing it here first is PUID="$(id -u abc)"
# what lets the tokensave/rtk/git setup in the 8x scripts run under the final identity. PGID="$(id -g abc)"
PUID="$(if bashio::config.has_value 'PUID'; then bashio::config 'PUID'; else echo '1000'; fi)"
PGID="$(if bashio::config.has_value 'PGID'; then bashio::config 'PGID'; else echo '1000'; fi)"
# Claude Code refuses bypass-permissions mode under an effective root UID, so bypass mode
# always needs a non-root desktop user.
if [ "$(bashio::config 'permission_mode')" = "bypass" ] && [ "$PUID" -eq 0 ]; then
bashio::log.warning "permission_mode: bypass cannot run Claude Code as root; using UID 1000 instead of the configured PUID 0"
PUID=1000
fi
groupmod -o -g "$PGID" abc 2> /dev/null || true
usermod -o -u "$PUID" abc 2> /dev/null || true
if [ "$(id -u abc)" -ne "$PUID" ] || [ "$(id -g abc)" -ne "$PGID" ]; then
PUID="$(id -u abc)"
PGID="$(id -g abc)"
bashio::log.warning "Unable to remap the abc desktop user; continuing with its current identity ${PUID}:${PGID}"
fi
# The base image's init-adduser reads PUID/PGID from the raw add-on options (default 0) and
# runs mid-startup, racing the services. Pin it to the effective identity chosen above so it
# can never remap abc away from the ownership applied below.
ADDUSER_RUN="/etc/s6-overlay/s6-rc.d/init-adduser/run"
if [ -f "$ADDUSER_RUN" ]; then
sed -i "s|^PUID=.*|PUID=${PUID}|;s|^PGID=.*|PGID=${PGID}|" "$ADDUSER_RUN"
fi
# Check data location # Check data location
LOCATION="$(bashio::config 'data_location')" LOCATION="$(bashio::config 'data_location')"
@@ -85,23 +60,10 @@ printf "%s" "$LOCATION" > "$S6_ENVDIR/HOME"
printf "%s" "$LOCATION" > "$S6_ENVDIR/FM_HOME" printf "%s" "$LOCATION" > "$S6_ENVDIR/FM_HOME"
printf "%s" "/tmp/cache" > "$S6_ENVDIR/XDG_CACHE_HOME" printf "%s" "/tmp/cache" > "$S6_ENVDIR/XDG_CACHE_HOME"
printf "%s" "$XDG_RUNTIME_DIR" > "$S6_ENVDIR/XDG_RUNTIME_DIR" printf "%s" "$XDG_RUNTIME_DIR" > "$S6_ENVDIR/XDG_RUNTIME_DIR"
# Re-derived on every boot rather than gated on a "does it already say $LOCATION" grep: that grep -qxF "export HOME=\"$LOCATION\"" ~/.bashrc 2>/dev/null || {
# guard only ever recognized the CURRENT $LOCATION, so a user who changed data_location and
# later changed it back left two stale HOME/FM_HOME exports in ~/.bashrc, with the last one
# (not necessarily the correct one) winning for every interactive shell. The marker makes this
# idempotent regardless of how many times $LOCATION has changed: strip any previously managed
# block, then append one that reflects the current value.
BASHRC_HOME_BEGIN="# --- BEGIN ADDON HOME (managed) ---"
BASHRC_HOME_END="# --- END ADDON HOME (managed) ---"
if [ -f ~/.bashrc ]; then
sed -i "/^${BASHRC_HOME_BEGIN}\$/,/^${BASHRC_HOME_END}\$/d" ~/.bashrc
fi
{
printf "%s\n" "$BASHRC_HOME_BEGIN"
printf "%s\n" "export HOME=\"$LOCATION\"" printf "%s\n" "export HOME=\"$LOCATION\""
printf "%s\n" "export FM_HOME=\"$LOCATION\"" printf "%s\n" "export FM_HOME=\"$LOCATION\""
printf "%s\n" "export XDG_CACHE_HOME=\"/tmp/cache\"" printf "%s\n" "export XDG_CACHE_HOME=\"/tmp/cache\""
printf "%s\n" "$BASHRC_HOME_END"
} >> ~/.bashrc } >> ~/.bashrc
bashio::log.info "Creating $LOCATION" bashio::log.info "Creating $LOCATION"
@@ -109,11 +71,6 @@ mkdir -p "$LOCATION" /tmp/cache "$XDG_RUNTIME_DIR"
chmod 755 /tmp/cache chmod 755 /tmp/cache
chmod 700 "$XDG_RUNTIME_DIR" chmod 700 "$XDG_RUNTIME_DIR"
# /tmp is a tmpfs and Xorg runs as the non-root abc user, which cannot create the X11 socket
# directory itself (_XSERVTransmkdir: euid != 0). Pre-create it with the standard sticky mode.
mkdir -p /tmp/.X11-unix
chmod 1777 /tmp/.X11-unix
# Pre-create the Selkies joystick log so the base image's "chmod 777 /tmp/selkies*" # Pre-create the Selkies joystick log so the base image's "chmod 777 /tmp/selkies*"
# calls (in init-selkies-config and svc-de) never fail on an empty glob. # calls (in init-selkies-config and svc-de) never fail on an empty glob.
touch /tmp/selkies_js.log touch /tmp/selkies_js.log
@@ -125,7 +82,7 @@ fi
ln -sfn /tmp/cache "$LOCATION/.cache" ln -sfn /tmp/cache "$LOCATION/.cache"
bashio::log.info "Setting ownership to $PUID:$PGID" bashio::log.info "Setting ownership to $PUID:$PGID"
chown -R "${PUID}:${PGID}" "$LOCATION" /tmp/cache "$XDG_RUNTIME_DIR" /data chown -R "$PUID":"$PGID" "$LOCATION" /tmp/cache "$XDG_RUNTIME_DIR"
chmod -R 700 "$LOCATION" chmod -R 700 "$LOCATION"
# The base init-selkies-config script overrides XDG_RUNTIME_DIR to $HOME/.XDG, which lands # The base init-selkies-config script overrides XDG_RUNTIME_DIR to $HOME/.XDG, which lands

View File

@@ -8,14 +8,10 @@ if ! bashio::config.true 'install_tokensave' || ! command -v git > /dev/null 2>&
fi fi
declare -A REPOS_SEEN=() declare -A REPOS_SEEN=()
# bashio::config prints its result without a trailing newline, so the last record arrives while IFS= read -r configured_path; do
# with read returning non-zero; the extra test keeps that final path in the loop.
while IFS= read -r configured_path || [ -n "$configured_path" ]; do
configured_path="${configured_path#"${configured_path%%[![:space:]]*}"}" configured_path="${configured_path#"${configured_path%%[![:space:]]*}"}"
configured_path="${configured_path%"${configured_path##*[![:space:]]}"}" configured_path="${configured_path%"${configured_path##*[![:space:]]}"}"
if [ -z "$configured_path" ] || [ "$configured_path" = "null" ]; then [ -n "$configured_path" ] || continue
continue
fi
case "$configured_path" in case "$configured_path" in
/*) ;; /*) ;;
@@ -37,6 +33,4 @@ while IFS= read -r configured_path || [ -n "$configured_path" ]; do
s6-setuidgid abc env HOME="$HOME" git config --global --add safe.directory "$repo_root" s6-setuidgid abc env HOME="$HOME" git config --global --add safe.directory "$repo_root"
bashio::log.info "Marked TokenSave repository as safe for Git: ${repo_root}" bashio::log.info "Marked TokenSave repository as safe for Git: ${repo_root}"
fi fi
# bashio::config prints list options one entry per line ("null" when the key is absent); done < <(bashio::config.array 'tokensave_project_paths')
# bashio::config.array only exists in the repo's standalone bashio, not in the real bashio here.
done < <(bashio::config 'tokensave_project_paths')

View File

@@ -3,10 +3,8 @@
set -e set -e
set -o pipefail set -o pipefail
# 20-folders.sh already remapped abc to the effective runtime identity (never root in bypass PUID="$(if bashio::config.has_value 'PUID'; then bashio::config 'PUID'; else echo '0'; fi)"
# mode), so follow abc instead of re-reading the raw PUID/PGID options here. PGID="$(if bashio::config.has_value 'PGID'; then bashio::config 'PGID'; else echo '0'; fi)"
RUNTIME_UID="$(id -u abc)"
RUNTIME_GID="$(id -g abc)"
mkdir -p "$HOME/.claude" mkdir -p "$HOME/.claude"
run_as_runtime_user() { run_as_runtime_user() {
@@ -76,7 +74,6 @@ if bashio::config.true 'enable_ha_mcp'; then
fi fi
HEADROOM_ENABLED="$HEADROOM_ENABLED" HEADROOM_BIN="$(command -v headroom || echo headroom)" \ HEADROOM_ENABLED="$HEADROOM_ENABLED" HEADROOM_BIN="$(command -v headroom || echo headroom)" \
HEADROOM_HF_HOME="${HOME}/.headroom/hf" \
TOKENSAVE_ENABLED="$TOKENSAVE_ENABLED" TOKENSAVE_BIN="$(command -v tokensave || echo tokensave)" \ TOKENSAVE_ENABLED="$TOKENSAVE_ENABLED" TOKENSAVE_BIN="$(command -v tokensave || echo tokensave)" \
HA_MCP_ENABLED="$HA_MCP_ENABLED" HA_MCP_URL="$HA_MCP_URL" HA_MCP_TOKEN="$HA_MCP_TOKEN" \ HA_MCP_ENABLED="$HA_MCP_ENABLED" HA_MCP_URL="$HA_MCP_URL" HA_MCP_TOKEN="$HA_MCP_TOKEN" \
MCP_PROXY_BIN="$(command -v mcp-proxy || echo mcp-proxy)" \ MCP_PROXY_BIN="$(command -v mcp-proxy || echo mcp-proxy)" \
@@ -97,11 +94,6 @@ if os.environ["HEADROOM_ENABLED"] == "true":
desired["headroom"] = { desired["headroom"] = {
"command": os.environ["HEADROOM_BIN"], "command": os.environ["HEADROOM_BIN"],
"args": ["mcp", "serve", "--proxy-url", "http://127.0.0.1:8787"], "args": ["mcp", "serve", "--proxy-url", "http://127.0.0.1:8787"],
# The MCP server is a separate process from the svc-headroom proxy longrun and does
# not inherit its HF_HOME export, so Kompress falls back to the default (tmpfs, wiped
# every restart) cache dir, never finds the model, and silently no-ops every
# compression request. Point it at the same persistent cache the proxy warms.
"env": {"HF_HOME": os.environ["HEADROOM_HF_HOME"]},
} }
if os.environ["TOKENSAVE_ENABLED"] == "true": if os.environ["TOKENSAVE_ENABLED"] == "true":
desired["tokensave"] = {"command": os.environ["TOKENSAVE_BIN"], "args": ["serve"]} desired["tokensave"] = {"command": os.environ["TOKENSAVE_BIN"], "args": ["serve"]}
@@ -173,15 +165,11 @@ PY
# requires one-time per-project opt-in; an empty list therefore has no startup or storage cost. # requires one-time per-project opt-in; an empty list therefore has no startup or storage cost.
if $TOKENSAVE_ENABLED; then if $TOKENSAVE_ENABLED; then
declare -A TOKENSAVE_REPOS_SEEN=() declare -A TOKENSAVE_REPOS_SEEN=()
# bashio::config prints its result without a trailing newline, so the last record arrives while IFS= read -r configured_path; do
# with read returning non-zero; the extra test keeps that final path in the loop.
while IFS= read -r configured_path || [ -n "$configured_path" ]; do
# Trim surrounding whitespace while preserving spaces inside paths. # Trim surrounding whitespace while preserving spaces inside paths.
configured_path="${configured_path#"${configured_path%%[![:space:]]*}"}" configured_path="${configured_path#"${configured_path%%[![:space:]]*}"}"
configured_path="${configured_path%"${configured_path##*[![:space:]]}"}" configured_path="${configured_path%"${configured_path##*[![:space:]]}"}"
if [ -z "$configured_path" ] || [ "$configured_path" = "null" ]; then [ -n "$configured_path" ] || continue
continue
fi
case "$configured_path" in case "$configured_path" in
/*) ;; /*) ;;
@@ -205,81 +193,16 @@ if $TOKENSAVE_ENABLED; then
fi fi
TOKENSAVE_REPOS_SEEN[$repo_root]=1 TOKENSAVE_REPOS_SEEN[$repo_root]=1
bashio::log.info "Preparing TokenSave index: ${repo_root}" if [ -f "$repo_root/.tokensave/tokensave.db" ]; then
# Prepare the per-repo semantic graph defensively so a hard add-on stop or storage bashio::log.info "Synchronizing TokenSave index: ${repo_root}"
# hiccup can never leave a broken index that fails every subsequent boot: run_as_runtime_user tokensave sync "$repo_root" \
# * a startup-scoped flock serializes against an overlapping restart (and any git || bashio::log.warning "TokenSave sync failed for ${repo_root}"
# post-commit/checkout sync hook that fires mid-boot); waits up to 60s for the else
# other writer to finish rather than silently skipping, since a held lock clears bashio::log.info "Initializing TokenSave index: ${repo_root}"
# itself the moment its holder exits or dies (the kernel releases flock on exit); run_as_runtime_user tokensave init "$repo_root" \
# * an existing index is refreshed with a cheap incremental `sync`, retried a few || bashio::log.warning "TokenSave initialization failed for ${repo_root}"
# times because SQLITE_BUSY under lock contention is transient, not corruption; fi
# * quarantine is reserved for sync failures whose stderr actually names database done < <(bashio::config.array 'tokensave_project_paths')
# corruption (SQLite's own "malformed"/"not a database"/"disk image" wording) or
# a half-written index from an interrupted `init` (sentinel-flagged). Any other
# failure (permissions, disk full, missing binary, ...) leaves the existing index
# untouched and simply retries on the next start — corruption should self-heal,
# a transient environment problem should not nuke a healthy graph;
# * `init` is bracketed by a sentinel file so an interrupted full build is detected
# as incomplete on the next start and rebuilt rather than trusted.
# All file operations run as the abc runtime user because the repo `.tokensave`
# directory is not covered by this script's final ownership pass.
# shellcheck disable=SC2016 # single-quoted on purpose: $1/$db/etc. expand in the abc shell
run_as_runtime_user bash -c '
set -o pipefail
repo_root="$1"
ts_dir="$repo_root/.tokensave"
db="$ts_dir/tokensave.db"
lock="$ts_dir/.startup.lock"
initflag="$ts_dir/.init-incomplete"
mkdir -p "$ts_dir"
exec 9>"$lock"
if ! flock -w 60 9; then
echo "TokenSave: index still locked for $repo_root after 60s; skipping startup sync" >&2
exit 0
fi
is_corruption() {
printf "%s" "$1" | grep -qiE "malformed|not a database|file is encrypted|disk image|database.*corrupt"
}
quarantine() {
stamp="$(date +%Y%m%d-%H%M%S)"
bdir="$ts_dir/corrupt-$stamp"
mkdir -p "$bdir"
for f in "$db" "$db-wal" "$db-shm"; do
[ -e "$f" ] && mv -f "$f" "$bdir/" 2>/dev/null || true
done
echo "TokenSave: quarantined suspect index to $bdir" >&2
}
if [ -f "$db" ] && [ ! -f "$initflag" ]; then
attempt=1
while :; do
sync_err="$(tokensave sync "$repo_root" 2>&1 1>/dev/null)" && exit 0
[ "$attempt" -ge 3 ] && break
echo "TokenSave: sync attempt $attempt failed for $repo_root; retrying" >&2
attempt=$((attempt + 1))
sleep 2
done
if is_corruption "$sync_err"; then
echo "TokenSave: sync failed after retries for $repo_root (corruption detected); rebuilding index" >&2
quarantine
else
echo "TokenSave: sync failed after retries for $repo_root (no corruption signature); leaving index in place, will retry next start" >&2
echo "TokenSave: last sync error: $sync_err" >&2
exit 1
fi
elif [ -f "$db" ]; then
echo "TokenSave: previous init did not finish for $repo_root; rebuilding index" >&2
quarantine
fi
: > "$initflag"
tokensave init "$repo_root" && { rm -f "$initflag"; exit 0; }
echo "TokenSave: init failed for $repo_root; will retry on next start" >&2
exit 1
' _ "$repo_root" \
|| bashio::log.warning "TokenSave preparation failed for ${repo_root}"
# bashio::config prints list options one entry per line ("null" when the key is absent);
# bashio::config.array only exists in the repo's standalone bashio, not in the real bashio here.
done < <(bashio::config 'tokensave_project_paths')
fi fi
# Guide Claude to actually use the Headroom compression tools so the MCP integration produces # Guide Claude to actually use the Headroom compression tools so the MCP integration produces
@@ -328,204 +251,6 @@ if new != text:
PY PY
fi fi
# Route every Claude Code session through the Headroom proxy via the `env` block in the user's
# ~/.claude/settings.json. Claude Code writes settings `env` entries into the process
# environment at startup, replacing inherited values — this is the only supported way to reach
# Desktop cowork/local-agent-mode sessions, which spawn the bundled CLI at an absolute path
# (bypassing the PATH wrapper) with ANTHROPIC_BASE_URL pinned to the production endpoint
# (headroom #869). Managed-value semantics: only set or remove the variable when it is absent
# or already equals the add-on-managed proxy URL, so a user-customized endpoint is never
# clobbered. The svc-headroom longrun is s6-supervised, so a crashed proxy restarts within
# seconds; the terminal wrapper's per-launch health check remains as an extra safety net.
if $HEADROOM_ENABLED && bashio::config.true 'headroom_wrap_claude_code'; then
HEADROOM_ROUTE_ACTION="add"
else
HEADROOM_ROUTE_ACTION="remove"
fi
HEADROOM_ROUTE_ACTION="$HEADROOM_ROUTE_ACTION" python3 - <<'PY' || bashio::log.warning "Unable to manage the Claude Code proxy routing env"
import json
import os
from pathlib import Path
MANAGED_URL = "http://127.0.0.1:8787"
path = Path.home() / ".claude" / "settings.json"
try:
data = json.loads(path.read_text()) if path.exists() else {}
if not isinstance(data, dict):
data = {}
except Exception:
if path.exists():
path.rename(path.with_suffix(path.suffix + ".bak"))
data = {}
env = data.get("env")
if not isinstance(env, dict):
env = {}
current = env.get("ANTHROPIC_BASE_URL")
changed = False
if os.environ["HEADROOM_ROUTE_ACTION"] == "add":
if current is None or current == MANAGED_URL:
if current != MANAGED_URL:
env["ANTHROPIC_BASE_URL"] = MANAGED_URL
changed = True
else:
print(f"Claude settings env already sets ANTHROPIC_BASE_URL={current}; leaving it untouched")
elif current == MANAGED_URL:
del env["ANTHROPIC_BASE_URL"]
changed = True
if changed:
if env:
data["env"] = env
elif "env" in data:
del data["env"]
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n")
PY
# Compress large tool outputs automatically in every Claude Code session via a managed
# PostToolUse hook (settings.json hooks apply to terminal, cowork, dispatch and cron sessions
# alike). Desktop-spawned sessions pin ANTHROPIC_BASE_URL to the production endpoint
# (headroom #869) so the proxy never sees their traffic, and the CLAUDE.md guidance above only
# helps when the model remembers to call the MCP tools. The hook closes that gap: outputs over
# ~4000 chars from Bash/Grep/Glob/WebFetch are compressed with Headroom's rule-based pipeline
# and swapped in through hookSpecificOutput.updatedToolOutput, with the original kept in the
# shared CCR store so the model can fetch it back with mcp__headroom__headroom_retrieve. The
# script fails open (any error leaves the tool output untouched) and its --self-test gate
# keeps a broken interpreter path from registering a hook that would warn on every tool call.
HEADROOM_HOOK_CMD="/usr/local/bin/headroom-posttooluse-compress.py"
HEADROOM_HOOK_ACTION="remove"
if $HEADROOM_ENABLED && bashio::config.true 'headroom_auto_compress'; then
if run_as_runtime_user "$HEADROOM_HOOK_CMD" --self-test; then
HEADROOM_HOOK_ACTION="add"
bashio::log.info "Registering the Headroom PostToolUse auto-compression hook"
else
bashio::log.warning "headroom-posttooluse-compress.py --self-test failed; not registering the auto-compression hook"
fi
fi
HEADROOM_HOOK_ACTION="$HEADROOM_HOOK_ACTION" HEADROOM_HOOK_CMD="$HEADROOM_HOOK_CMD" \
HEADROOM_HOOK_MATCHER="Bash|Grep|Glob|WebFetch" \
python3 - <<'PY' || bashio::log.warning "Unable to manage the Headroom auto-compression hook"
import json
import os
from pathlib import Path
action = os.environ["HEADROOM_HOOK_ACTION"]
command = os.environ["HEADROOM_HOOK_CMD"]
matcher = os.environ["HEADROOM_HOOK_MATCHER"]
path = Path.home() / ".claude" / "settings.json"
original = path.read_text() if path.exists() else None
try:
data = json.loads(original) if original is not None else {}
if not isinstance(data, dict):
data = {}
except Exception:
if action != "add":
raise SystemExit(0)
path.rename(path.with_suffix(path.suffix + ".bak"))
original = None
data = {}
hooks = data.get("hooks") if isinstance(data.get("hooks"), dict) else {}
entries = hooks.get("PostToolUse") if isinstance(hooks.get("PostToolUse"), list) else []
# Strip the managed command everywhere first, then re-append when enabled: the same pass
# handles removal, de-duplication, and matcher migration on version upgrades. The final
# text comparison keeps the write idempotent across boots.
filtered = []
for entry in entries:
if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
filtered.append(entry)
continue
kept = [
item
for item in entry["hooks"]
if not (isinstance(item, dict) and item.get("command") == command)
]
if len(kept) != len(entry["hooks"]):
if not kept:
continue
entry = dict(entry)
entry["hooks"] = kept
filtered.append(entry)
entries = filtered
if action == "add":
entries.append({"matcher": matcher, "hooks": [{"type": "command", "command": command}]})
if entries:
hooks["PostToolUse"] = entries
else:
hooks.pop("PostToolUse", None)
if hooks:
data["hooks"] = hooks
else:
data.pop("hooks", None)
serialized = json.dumps(data, indent=2) + "\n"
if serialized != original:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(serialized)
PY
# Tell Claude Code that it can configure Home Assistant over the Core API via the shipped
# `ha-cli` helper (no /config filesystem mount needed). Managed, idempotent block appended to
# the user's global CLAUDE.md; removed when the helper is disabled. Mirrors the headroom block.
HA_HELPER_GUIDE_BEGIN="<!-- BEGIN ha-api-helper (managed by claude_desktop addon) -->"
if bashio::config.true 'enable_ha_api_helper'; then
mkdir -p "$(dirname "$CLAUDE_MD")"
if ! { [ -f "$CLAUDE_MD" ] && grep -qF "$HA_HELPER_GUIDE_BEGIN" "$CLAUDE_MD"; }; then
bashio::log.info "Adding Home Assistant API helper guidance to CLAUDE.md"
{
[ -s "$CLAUDE_MD" ] && printf '\n'
cat <<'MD'
<!-- BEGIN ha-api-helper (managed by claude_desktop addon) -->
## Configuring Home Assistant
You can configure this Home Assistant instance through its Core API using the `ha-cli`
command (on `PATH`). It authenticates automatically with the add-on's `$SUPERVISOR_TOKEN`,
so no token setup is needed. There is **no `/config` filesystem mount** — work only through
`ha-cli`, and never try to read or write Home Assistant YAML files directly.
What is editable this way: automations, scripts, and scenes
(`ha-cli get|post|delete config/automation/config/<id>` and the `script`/`scene` equivalents);
service calls (`ha-cli call <domain.service> '<json>'`); state reads (`ha-cli states`); and,
over WebSocket, helpers, dashboards, and area/label/floor/entity registries
(`ha-cli ws '{"type":"..."}'`). Run `ha-cli --help` for the full reference. Raw YAML
(`configuration.yaml`, `secrets.yaml`) is intentionally unreachable — if a change needs it,
say so instead of working around it.
Rules: run `ha-cli config` first to confirm connectivity; **read the current object and show
the user the intended change, then wait for confirmation** before any create/update/delete or
any state-changing `call`; after writing, read the object back and reload if needed
(e.g. `ha-cli call automation.reload`).
<!-- END ha-api-helper (managed by claude_desktop addon) -->
MD
} >> "$CLAUDE_MD"
fi
elif [ -f "$CLAUDE_MD" ] && grep -qF "$HA_HELPER_GUIDE_BEGIN" "$CLAUDE_MD"; then
bashio::log.info "Removing Home Assistant API helper guidance from CLAUDE.md"
CLAUDE_MD="$CLAUDE_MD" python3 - <<'PY' || bashio::log.warning "Unable to remove Home Assistant API helper guidance automatically"
import os
import re
from pathlib import Path
path = Path(os.environ["CLAUDE_MD"])
text = path.read_text(encoding="utf-8")
pattern = re.compile(
r"\n*<!-- BEGIN ha-api-helper \(managed by claude_desktop addon\) -->.*?"
r"<!-- END ha-api-helper \(managed by claude_desktop addon\) -->\n?",
re.DOTALL,
)
new = pattern.sub("", text)
if new != text:
path.write_text(new, encoding="utf-8")
PY
fi
if bashio::config.true 'install_rtk'; then if bashio::config.true 'install_rtk'; then
if command -v rtk &> /dev/null; then if command -v rtk &> /dev/null; then
bashio::log.info "Configuring rtk Claude Code integration" bashio::log.info "Configuring rtk Claude Code integration"
@@ -632,9 +357,9 @@ else
fi fi
# Startup configuration runs as root, while Claude Desktop runs as abc. Return managed # Startup configuration runs as root, while Claude Desktop runs as abc. Return managed
# persistent files to the effective runtime UID/GID after all writes complete. # persistent files to the configured runtime UID/GID after all writes complete.
for managed_path in "$HOME/.claude" "$HOME/.claude.json" "$HOME/.config/Claude"; do for managed_path in "$HOME/.claude" "$HOME/.claude.json" "$HOME/.config/Claude"; do
if [ -e "$managed_path" ]; then if [ -e "$managed_path" ]; then
chown -R -- "${RUNTIME_UID}:${RUNTIME_GID}" "$managed_path" || bashio::log.warning "Unable to set ownership on $managed_path" chown -R -- "${PUID}:${PGID}" "$managed_path" || bashio::log.warning "Unable to set ownership on $managed_path"
fi fi
done done

View File

@@ -3,10 +3,8 @@
set -e set -e
set -o pipefail set -o pipefail
# 20-folders.sh already remapped abc to the effective runtime identity (never root in bypass PUID="$(if bashio::config.has_value 'PUID'; then bashio::config 'PUID'; else echo '0'; fi)"
# mode), so follow abc instead of re-reading the raw PUID/PGID options here. PGID="$(if bashio::config.has_value 'PGID'; then bashio::config 'PGID'; else echo '0'; fi)"
RUNTIME_UID="$(id -u abc)"
RUNTIME_GID="$(id -g abc)"
PERMISSION_MODE="$(bashio::config 'permission_mode')" PERMISSION_MODE="$(bashio::config 'permission_mode')"
SETTINGS_PATH="$HOME/.claude/settings.json" SETTINGS_PATH="$HOME/.claude/settings.json"
STATE_PATH="$HOME/.claude/.addon-permission-mode.json" STATE_PATH="$HOME/.claude/.addon-permission-mode.json"
@@ -88,7 +86,7 @@ case "$PERMISSION_MODE" in
;; ;;
esac esac
chown -- "${RUNTIME_UID}:${RUNTIME_GID}" "$SETTINGS_PATH" 2> /dev/null || true chown -- "${PUID}:${PGID}" "$SETTINGS_PATH" 2> /dev/null || true
if [ -e "$STATE_PATH" ]; then if [ -e "$STATE_PATH" ]; then
chown -- "${RUNTIME_UID}:${RUNTIME_GID}" "$STATE_PATH" 2> /dev/null || true chown -- "${PUID}:${PGID}" "$STATE_PATH" 2> /dev/null || true
fi fi

View File

@@ -17,54 +17,25 @@ if ! command -v gh > /dev/null 2>&1; then
exit 0 exit 0
fi fi
# Everything below writes into the abc runtime user's HOME, so it must run AS abc. cont-init
# runs as root with HOME already pointing at the persistent data location, so plain
# `git config --global` recreated ~/.gitconfig owned by root:root on every start — and because
# that file is rewritten each boot, 20-folders.sh's earlier recursive chown never stuck to it.
# The user who actually runs git, gh and Claude was then unable to read its own committer
# identity or the gh credential helper, so every commit failed with "Author identity unknown"
# and authenticated pushes fell back to prompting. 20-folders.sh already remapped abc to the
# effective runtime identity (never root in bypass mode), so follow abc rather than re-reading
# the raw PUID/PGID options here.
RUNTIME_UID="$(id -u abc)"
RUNTIME_GID="$(id -g abc)"
run_as_runtime_user() {
s6-setuidgid abc env HOME="$HOME" "$@"
}
# Reclaim any root-owned copies left by an earlier add-on version before writing as abc:
# these paths are not covered by 82-claude_tools.sh's ownership pass, and a root-owned
# ~/.gitconfig would make the first `git config` below fail outright under `set -e`.
mkdir -p "$HOME/.config"
chown -- "${RUNTIME_UID}:${RUNTIME_GID}" "$HOME/.config"
for managed_path in "$HOME/.gitconfig" "$HOME/.config/gh"; do
if [ -e "$managed_path" ]; then
chown -R -- "${RUNTIME_UID}:${RUNTIME_GID}" "$managed_path" || bashio::log.warning "Unable to set ownership on $managed_path"
fi
done
if bashio::config.has_value 'github_username'; then if bashio::config.has_value 'github_username'; then
run_as_runtime_user git config --global user.name "$(bashio::config 'github_username')" git config --global user.name "$(bashio::config 'github_username')"
fi fi
if bashio::config.has_value 'github_email'; then if bashio::config.has_value 'github_email'; then
run_as_runtime_user git config --global user.email "$(bashio::config 'github_email')" git config --global user.email "$(bashio::config 'github_email')"
fi fi
if bashio::config.has_value 'github_token'; then if bashio::config.has_value 'github_token'; then
token="$(bashio::config 'github_token')" token="$(bashio::config 'github_token')"
run_as_runtime_user mkdir -p "$HOME/.config/gh" mkdir -p "$HOME/.config/gh"
run_as_runtime_user chmod 700 "$HOME/.config/gh" chmod 700 "$HOME/.config/gh"
if run_as_runtime_user env -u GH_TOKEN -u GITHUB_TOKEN gh auth status --hostname github.com > /dev/null 2>&1; then if env -u GH_TOKEN -u GITHUB_TOKEN gh auth status --hostname github.com > /dev/null 2>&1; then
bashio::log.info "GitHub CLI already authenticated for github.com" bashio::log.info "GitHub CLI already authenticated for github.com"
else else
bashio::log.info "Configuring GitHub CLI authentication for github.com" bashio::log.info "Configuring GitHub CLI authentication for github.com"
printf '%s\n' "$token" | run_as_runtime_user env -u GH_TOKEN -u GITHUB_TOKEN gh auth login --hostname github.com --with-token || bashio::log.warning "GitHub CLI authentication failed" printf '%s\n' "$token" | env -u GH_TOKEN -u GITHUB_TOKEN gh auth login --hostname github.com --with-token || bashio::log.warning "GitHub CLI authentication failed"
fi fi
run_as_runtime_user env -u GH_TOKEN -u GITHUB_TOKEN gh auth setup-git --hostname github.com || bashio::log.warning "GitHub CLI git credential setup failed" env -u GH_TOKEN -u GITHUB_TOKEN gh auth setup-git --hostname github.com || bashio::log.warning "GitHub CLI git credential setup failed"
else else
bashio::log.info "GitHub CLI available. Set github_token to authenticate gh and git operations." bashio::log.info "GitHub CLI available. Set github_token to authenticate gh and git operations."
fi fi

View File

@@ -2,10 +2,10 @@
# shellcheck shell=bash # shellcheck shell=bash
set -e set -e
# Earlier configuration scripts intentionally run as root. 20-folders.sh remapped abc to the # Earlier configuration scripts intentionally run as root and may use the configured PUID/PGID
# effective runtime identity (never root in bypass mode, where Claude Code refuses to run as # values when returning files to the runtime user. In bypass mode PUID can still be configured as
# root). Reconcile ownership with that identity after all Claude configuration writes are # 0 even though 19-claude_bypass_runtime.sh remapped abc to a non-root UID. Reconcile ownership
# complete, as a safety net in case any intermediate step re-owned a managed path. # with the effective desktop identity after all Claude configuration writes are complete.
RUNTIME_UID="$(id -u abc)" RUNTIME_UID="$(id -u abc)"
RUNTIME_GID="$(id -g abc)" RUNTIME_GID="$(id -g abc)"

View File

@@ -10,21 +10,6 @@ if bashio::config.true 'expose_headroom_dashboard'; then
fi fi
if bashio::config.true 'install_headroom' && command -v headroom > /dev/null 2>&1; then if bashio::config.true 'install_headroom' && command -v headroom > /dev/null 2>&1; then
# Kompress (the ONNX compression engine) needs its model in the local HF cache: the
# proxy's startup preload is deliberately cache-only, and the default HF cache lands
# under ~/.cache, which the add-on points at tmpfs (/tmp/cache) — wiped on every
# restart. Without a warm persistent cache the proxy ran forever in "deferred" mode
# and recorded zero compression savings. Point the cache at persistent storage;
# nothing else is needed here — the proxy's own request path already downloads a
# missing model in the background on first use (ensure_background_load) and passes
# requests through uncompressed until it lands, so this self-heals within a couple of
# requests on the first boot and loads instantly (eager preload) on every boot after.
# A synchronous pre-warm was tried here and removed: it blocked the port bind for up
# to the download's duration, which left the settings-managed ANTHROPIC_BASE_URL
# (see 82-claude_tools.sh) pointing at a proxy that wasn't listening yet.
export HF_HOME="${HOME}/.headroom/hf"
mkdir -p "$HF_HOME"
chown abc:abc "$HF_HOME" 2> /dev/null || true
bashio::log.info "svc-headroom: starting local Headroom proxy on ${host}:${port}" bashio::log.info "svc-headroom: starting local Headroom proxy on ${host}:${port}"
exec s6-setuidgid abc headroom proxy --host "${host}" --port "${port}" --code-aware exec s6-setuidgid abc headroom proxy --host "${host}" --port "${port}" --code-aware
fi fi

View File

@@ -3,7 +3,7 @@
set -o pipefail set -o pipefail
REAL_CLAUDE="/usr/bin/claude" REAL_CLAUDE="/usr/bin/claude"
HEADROOM_BIN="$(command -v headroom || true)" HEADROOM_BIN="/usr/local/bin/headroom"
HEADROOM_URL="http://127.0.0.1:8787" HEADROOM_URL="http://127.0.0.1:8787"
PERMISSION_MODE="$(bashio::config 'permission_mode')" PERMISSION_MODE="$(bashio::config 'permission_mode')"
declare -a CLAUDE_PERMISSION_ARGS=() declare -a CLAUDE_PERMISSION_ARGS=()

View File

@@ -149,10 +149,8 @@ section "TokenSave"
if bashio::config.true 'install_tokensave'; then if bashio::config.true 'install_tokensave'; then
tokensave doctor --agent claude || true tokensave doctor --agent claude || true
tokensave gain --all --range 30d || true tokensave gain --all --range 30d || true
while IFS= read -r configured_path || [ -n "$configured_path" ]; do while IFS= read -r configured_path; do
if [ -z "$configured_path" ] || [ "$configured_path" = "null" ]; then [ -n "$configured_path" ] || continue
continue
fi
repo_root="$(s6-setuidgid abc env HOME="$HOME" git -c safe.directory='*' -C "$configured_path" rev-parse --show-toplevel 2> /dev/null || true)" repo_root="$(s6-setuidgid abc env HOME="$HOME" git -c safe.directory='*' -C "$configured_path" rev-parse --show-toplevel 2> /dev/null || true)"
if [ -z "$repo_root" ]; then if [ -z "$repo_root" ]; then
echo "${configured_path}: not a Git repository" echo "${configured_path}: not a Git repository"
@@ -161,7 +159,7 @@ if bashio::config.true 'install_tokensave'; then
else else
echo "${repo_root}: NOT INITIALIZED" echo "${repo_root}: NOT INITIALIZED"
fi fi
done < <(bashio::config 'tokensave_project_paths') done < <(bashio::config.array 'tokensave_project_paths')
else else
echo "disabled" echo "disabled"
fi fi

View File

@@ -1,236 +0,0 @@
#!/usr/bin/env python3
"""ha-cli — talk to the local Home Assistant Core API from inside the add-on.
Lets Claude Code configure Home Assistant (automations, scripts, scenes,
helpers, dashboards, areas/labels, service calls) through the API, without any
`/config` filesystem mount. `secrets.yaml` and other add-ons' credentials are
therefore never reachable.
Authentication and the base URL are resolved automatically, in this order:
1. $HA_BASE_URL + $HA_TOKEN explicit override (advanced/scoped)
2. `ha_mcp_token` in /data/options.json scoped user long-lived token -> :8123
3. $SUPERVISOR_TOKEN Supervisor Core-API proxy fallback
(admin-equivalent; needs
homeassistant_api: true, which this
add-on sets — zero setup)
The scoped token is checked before the Supervisor fallback so setting
`ha_mcp_token` actually narrows access instead of being shadowed by the
always-present admin-equivalent Supervisor token.
Subcommands:
ha-cli get <path> GET e.g. get config/automation/config/1700000000
ha-cli post <path> [BODY] POST BODY = inline JSON, @file, or - (stdin)
ha-cli delete <path> DELETE
ha-cli call <domain.service> [BODY] call a service (BODY = JSON service data)
ha-cli states [entity_id] all states, or one entity
ha-cli config GET /config (sanity check / core info)
ha-cli ws <BODY> one WebSocket command (BODY = JSON, @file, or -)
`<path>` is relative to the REST API root; a leading slash and/or `api/` prefix
are optional. Responses are printed as formatted JSON. Exit code is non-zero on
HTTP or API errors.
Use the WebSocket subcommand for things the REST API does not expose:
ha-cli ws '{"type":"config/area_registry/list"}'
ha-cli ws '{"type":"input_boolean/create","name":"Guest mode","icon":"mdi:account"}'
ha-cli ws '{"type":"lovelace/config","url_path":null}'
"""
import json
import os
import sys
import urllib.error
import urllib.request
def _load_option(name):
"""Read a single option from the add-on's /data/options.json, if present."""
try:
with open("/data/options.json", encoding="utf-8") as handle:
return json.load(handle).get(name)
except (OSError, ValueError):
return None
def _helper_enabled():
"""Mirror config.yaml's enable_ha_api_helper default (true) when unset."""
value = _load_option("enable_ha_api_helper")
return value is not False
def resolve_endpoint():
"""Return (rest_base, ws_url, token) for the best available auth path."""
base = os.environ.get("HA_BASE_URL")
token = os.environ.get("HA_TOKEN")
if base and token:
rest = base.rstrip("/")
if not rest.endswith("/api"):
rest += "/api"
ws = rest.replace("http", "ws", 1).rsplit("/api", 1)[0] + "/api/websocket"
return rest, ws, token
# Checked before SUPERVISOR_TOKEN: this add-on always sets homeassistant_api,
# so the admin-equivalent Supervisor token is otherwise always present and
# would shadow a user's deliberately scoped-down ha_mcp_token.
token = _load_option("ha_mcp_token")
if token:
return (
"http://homeassistant:8123/api",
"ws://homeassistant:8123/api/websocket",
token,
)
token = os.environ.get("SUPERVISOR_TOKEN")
if token:
return "http://supervisor/core/api", "ws://supervisor/core/websocket", token
sys.exit(
"ha-cli: no credentials. Expected ha_mcp_token in the add-on options "
"(scoped user), $SUPERVISOR_TOKEN (admin-equivalent fallback, default "
"inside the add-on), or an explicit $HA_BASE_URL+$HA_TOKEN override."
)
def _url(base, path):
path = path.lstrip("/")
if path.startswith("api/"):
path = path[len("api/"):]
return base.rstrip("/") + "/" + path
def _read_body(arg):
"""Resolve an inline-JSON / @file / - (stdin) body argument to a dict/list."""
if arg is None:
return None
if arg == "-":
raw = sys.stdin.read()
elif arg.startswith("@"):
with open(arg[1:], encoding="utf-8") as handle:
raw = handle.read()
else:
raw = arg
raw = raw.strip()
if not raw:
return None
try:
return json.loads(raw)
except ValueError as exc:
sys.exit(f"ha-cli: body is not valid JSON: {exc}")
def _print(obj):
if isinstance(obj, (dict, list)):
print(json.dumps(obj, indent=2, ensure_ascii=False))
elif obj not in (None, ""):
print(obj)
def rest(method, base, token, path, body=None):
data = None
headers = {"Authorization": f"Bearer {token}"}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(_url(base, path), data=data, method=method, headers=headers)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
text = resp.read().decode("utf-8")
except urllib.error.HTTPError as exc:
detail = exc.read().decode("utf-8", "replace").strip()
sys.exit(f"ha-cli: HTTP {exc.code} {exc.reason} on {method} {path}\n{detail}")
except urllib.error.URLError as exc:
sys.exit(f"ha-cli: cannot reach Home Assistant ({exc.reason}) on {method} {path}")
try:
return json.loads(text) if text.strip() else None
except ValueError:
return text
def ws_command(ws_url, token, command):
try:
import asyncio
import websockets
except ImportError:
sys.exit(
"ha-cli: the 'websockets' Python package is required for the ws "
"subcommand. REST subcommands work without it."
)
async def run():
async with websockets.connect(ws_url, max_size=None) as sock:
hello = json.loads(await sock.recv())
if hello.get("type") != "auth_required":
raise SystemExit(f"ha-cli: unexpected WS greeting: {hello}")
await sock.send(json.dumps({"type": "auth", "access_token": token}))
if json.loads(await sock.recv()).get("type") != "auth_ok":
raise SystemExit("ha-cli: WebSocket authentication failed")
payload = dict(command)
payload["id"] = 1
await sock.send(json.dumps(payload))
while True:
msg = json.loads(await sock.recv())
if msg.get("id") == 1 and msg.get("type") == "result":
return msg
result = asyncio.run(run())
if not result.get("success", True):
_print(result.get("error", result))
sys.exit(1)
return result.get("result", result)
def main(argv):
if not argv or argv[0] in ("-h", "--help", "help"):
print(__doc__)
return 0
if not _helper_enabled():
sys.exit(
"ha-cli: disabled (enable_ha_api_helper is false in the add-on "
"options). Enable it there to let Claude configure Home Assistant."
)
rest_base, ws_url, token = resolve_endpoint()
cmd, args = argv[0], argv[1:]
if cmd == "get":
if len(args) != 1:
sys.exit("usage: ha-cli get <path>")
_print(rest("GET", rest_base, token, args[0]))
elif cmd == "post":
if not args:
sys.exit("usage: ha-cli post <path> [BODY]")
body = _read_body(args[1]) if len(args) > 1 else None
_print(rest("POST", rest_base, token, args[0], body))
elif cmd == "delete":
if len(args) != 1:
sys.exit("usage: ha-cli delete <path>")
_print(rest("DELETE", rest_base, token, args[0]))
elif cmd == "call":
if not args or "." not in args[0]:
sys.exit("usage: ha-cli call <domain.service> [BODY]")
domain, service = args[0].split(".", 1)
body = _read_body(args[1]) if len(args) > 1 else None
_print(rest("POST", rest_base, token, f"services/{domain}/{service}", body or {}))
elif cmd == "states":
path = f"states/{args[0]}" if args else "states"
_print(rest("GET", rest_base, token, path))
elif cmd == "config":
_print(rest("GET", rest_base, token, "config"))
elif cmd == "ws":
if len(args) != 1:
sys.exit("usage: ha-cli ws <BODY>")
command = _read_body(args[0])
if not isinstance(command, dict) or "type" not in command:
sys.exit('ha-cli: ws BODY must be a JSON object with a "type" field')
_print(ws_command(ws_url, token, command))
else:
sys.exit(f"ha-cli: unknown subcommand '{cmd}' (try: ha-cli --help)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@@ -1,218 +0,0 @@
#!/lsiopy/bin/python3
"""Claude Code PostToolUse hook: auto-compress large tool outputs through Headroom.
Registered in ~/.claude/settings.json by 82-claude_tools.sh (managed entry, matcher
"Bash|Grep|Glob|WebFetch"). Desktop-spawned Claude Code sessions cannot be routed
through the Headroom proxy (the Electron app pins ANTHROPIC_BASE_URL to the
production endpoint, headroom #869), so compression there used to depend on the
model voluntarily calling the headroom MCP tools. This hook makes it automatic for
every session type: when a matched tool returns a large output, the hook compresses
it with Headroom's rule-based pipeline and replaces the tool output via
hookSpecificOutput.updatedToolOutput, appending a retrieval marker. The original is
stored in Headroom's shared CCR store (SQLite at ~/.headroom/ccr_store.db — the
same store the headroom MCP server reads), so the model can always get the full
output back with mcp__headroom__headroom_retrieve.
Design constraints:
- Fail open: any error or non-compressible payload exits 0 with no output, leaving
the tool result untouched. A hook crash must never break a session.
- Fast path first: the payload is inspected before importing headroom (~0.6 s);
small outputs never pay the import cost.
- ML text compression (Kompress) is disabled: its model loads in the background,
which never completes inside a short-lived hook process. The rule-based
transforms (SmartCrusher for JSON, search/log/diff/tabular compressors) carry
the savings on tool output anyway; plain prose passes through unchanged.
- stderr fields are never compressed — error text must reach the model verbatim
(matching Headroom's own error-protection policy).
- File-list arrays (Glob's `filenames`, Grep's `filenames` in files_with_matches
mode — both typed `string[]` by the CLI's own output schema) are handled
separately from prose/JSON-blob fields: Headroom's SmartCrusher subsamples
JSON arrays for informational dumps, which is fine for e.g. a list of sensor
states but silently drops most paths from a file listing the model needs to
act on. Those fields are truncated deterministically instead (keep the first
N entries, append one marker string) so the model always sees a labeled cut
point rather than a shorter list it might mistake for the complete result.
"""
import json
import os
import sys
def _int_env(name: str, default: str) -> int:
try:
return int(os.environ.get(name, default))
except (TypeError, ValueError):
return int(default)
MIN_CHARS = _int_env("HEADROOM_HOOK_MIN_CHARS", "4000")
MIN_SAVED_TOKENS = _int_env("HEADROOM_HOOK_MIN_SAVED_TOKENS", "50")
ARRAY_KEEP = _int_env("HEADROOM_HOOK_ARRAY_KEEP", "40")
TTL_SECONDS = 3600 # matches the headroom MCP server's session TTL
SKIP_KEYS = {"stderr"}
def self_test() -> int:
"""Exit 0 when the interpreter can import headroom (used at registration time)."""
try:
import headroom # noqa: F401
return 0
except Exception:
return 1
def main() -> int:
if os.environ.get("HEADROOM_HOOK_DISABLE"):
return 0
try:
payload = json.load(sys.stdin)
except Exception:
return 0
if not isinstance(payload, dict):
return 0
response = payload.get("tool_response")
# Find big string/array fields before paying the headroom import cost.
def is_string_array(value):
return isinstance(value, list) and len(value) > ARRAY_KEEP and all(isinstance(v, str) for v in value)
if isinstance(response, str):
string_candidates = ["__whole__"] if len(response) >= MIN_CHARS else []
array_candidates = []
elif isinstance(response, dict):
string_candidates = [
key
for key, value in response.items()
if key not in SKIP_KEYS and isinstance(value, str) and len(value) >= MIN_CHARS
]
array_candidates = [
key for key, value in response.items() if key not in SKIP_KEYS and is_string_array(value)
]
else:
string_candidates = []
array_candidates = []
if not string_candidates and not array_candidates:
return 0
# Keep Kompress's cache probe away from the tmpfs-backed ~/.cache default.
os.environ.setdefault("HF_HOME", os.path.expanduser("~/.headroom/hf"))
from headroom import savings_ledger
from headroom.cache.compression_store import get_compression_store
from headroom.compress import compress
store = None
totals = [0, 0] # tokens before, tokens after (only for rewritten fields)
def shrink(text):
nonlocal store
result = compress(
[{"role": "tool", "content": text}],
protect_recent=0,
kompress_model="disabled",
)
compressed = result.messages[0].get("content")
if not isinstance(compressed, str):
compressed = json.dumps(compressed)
saved = result.tokens_before - result.tokens_after
if saved < MIN_SAVED_TOKENS:
return None
if store is None:
store = get_compression_store()
hash_key = store.store(
original=text,
compressed=compressed,
original_tokens=result.tokens_before,
compressed_tokens=result.tokens_after,
compression_strategy="posttooluse_hook",
ttl=TTL_SECONDS,
)
totals[0] += result.tokens_before
totals[1] += result.tokens_after
return (
f"{compressed}\n"
f"[headroom: output compressed {result.tokens_before}->{result.tokens_after} tokens; "
f"call mcp__headroom__headroom_retrieve with hash={hash_key} if you need the full original]"
)
def shrink_array(items):
nonlocal store
original_json = json.dumps(items)
if len(original_json) < MIN_CHARS:
return None
kept = items[:ARRAY_KEEP]
truncated_json = json.dumps(kept)
# No ML/token-counter call needed for a plain truncation decision; a char/4
# estimate is the same fallback Headroom's own cost estimator uses and is
# only used here to decide eligibility and annotate the marker.
tokens_before = max(1, len(original_json) // 4)
tokens_after = max(1, len(truncated_json) // 4)
if tokens_before - tokens_after < MIN_SAVED_TOKENS:
return None
if store is None:
store = get_compression_store()
hash_key = store.store(
original=original_json,
compressed=truncated_json,
original_tokens=tokens_before,
compressed_tokens=tokens_after,
compression_strategy="posttooluse_hook_array_truncate",
ttl=TTL_SECONDS,
)
totals[0] += tokens_before
totals[1] += tokens_after
remaining = len(items) - len(kept)
marker = (
f"[headroom: {remaining} more of {len(items)} entries omitted "
f"({tokens_before}->{tokens_after} tokens); call mcp__headroom__headroom_retrieve "
f"with hash={hash_key} for the complete list]"
)
return kept + [marker]
updated = None
if isinstance(response, str):
updated = shrink(response)
else:
rewritten = dict(response)
changed = False
for key in string_candidates:
new_value = shrink(rewritten[key])
if new_value is not None:
rewritten[key] = new_value
changed = True
for key in array_candidates:
new_value = shrink_array(rewritten[key])
if new_value is not None:
rewritten[key] = new_value
changed = True
if changed:
updated = rewritten
if updated is None:
return 0
savings_ledger.record_savings_event(
tokens_before=totals[0],
tokens_after=totals[1],
client="posttooluse-hook",
source="hook",
)
json.dump(
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"updatedToolOutput": updated,
}
},
sys.stdout,
)
return 0
if __name__ == "__main__":
if "--self-test" in sys.argv:
sys.exit(self_test())
try:
sys.exit(main())
except Exception:
sys.exit(0)

View File

@@ -1,5 +1,3 @@
## 1.4.0-2 (16-07-2026)
- Minor bugs fixed
## 1.4.0 (2026-06-20) ## 1.4.0 (2026-06-20)
- Update to latest version from gtsteffaniak/filebrowser (changelog : https://github.com/gtsteffaniak/filebrowser/releases) - Update to latest version from gtsteffaniak/filebrowser (changelog : https://github.com/gtsteffaniak/filebrowser/releases)

View File

@@ -115,5 +115,4 @@ LABEL \
# 6 Healthcheck # # 6 Healthcheck #
################# #################
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ # Upstream
CMD curl -f http://localhost:3001/health || exit 1

View File

@@ -114,4 +114,4 @@ schema:
slug: filebrowser_quantum slug: filebrowser_quantum
udev: true udev: true
url: https://github.com/alexbelgium/hassio-addons url: https://github.com/alexbelgium/hassio-addons
version: "1.4.0-2" version: "1.4.0"

View File

@@ -1,5 +1,3 @@
## 1.27 (15-07-2026)
- Fix the Gitea add-on HEALTHCHECK to work correctly when SSL is enabled, so Home Assistant can accurately report the add-on's health status regardless of whether the instance uses HTTP or HTTPS.
## 1.26.4 (2026-06-23) ## 1.26.4 (2026-06-23)
- Update to latest version from go-gitea/gitea (changelog : https://github.com/go-gitea/gitea/releases) - Update to latest version from go-gitea/gitea (changelog : https://github.com/go-gitea/gitea/releases)

View File

@@ -133,4 +133,4 @@ HEALTHCHECK \
--retries=5 \ --retries=5 \
--start-period=30s \ --start-period=30s \
--timeout=25s \ --timeout=25s \
CMD curl -A "HealthCheck: Docker/1.0" -s -f -k --http1.1 "$(cat /run/health_protocol 2>/dev/null || echo http)://127.0.0.1:${HEALTH_PORT}${HEALTH_URL}" &>/dev/null || exit 1 CMD curl -A "HealthCheck: Docker/1.0" -s -f "http://127.0.0.1:${HEALTH_PORT}${HEALTH_URL}" &>/dev/null || exit 1

View File

@@ -97,5 +97,5 @@ schema:
slug: gitea slug: gitea
udev: true udev: true
url: https://github.com/alexbelgium/hassio-addons/tree/master/gitea url: https://github.com/alexbelgium/hassio-addons/tree/master/gitea
version: "1.27" version: "1.26.4"
webui: "[PROTO:ssl]://[HOST]:[PORT:3000]" webui: "[PROTO:ssl]://[HOST]:[PORT:3000]"

View File

@@ -55,7 +55,6 @@ for file in /config/app.ini /etc/templates/app.ini; do
PROTOCOL=http PROTOCOL=http
sed -i "/server/a PROTOCOL=http" "$file" sed -i "/server/a PROTOCOL=http" "$file"
fi fi
echo -n "${PROTOCOL}" > /run/health_protocol
################## ##################
# ADAPT ROOT_URL # # ADAPT ROOT_URL #

View File

@@ -1,5 +1,4 @@
## &#9888; Open Issue : [🐛 [qBittorrent] Cant update to 5.2.3 (opened 2026-07-09)](https://github.com/alexbelgium/hassio-addons/issues/2836) by [@tschoehuijs](https://github.com/tschoehuijs) ## &#9888; Open Issue : [🐛 [qBittorrent] Cant update to 5.2.3 (opened 2026-07-09)](https://github.com/alexbelgium/hassio-addons/issues/2836) by [@tschoehuijs](https://github.com/tschoehuijs)
## &#9888; Open Request : [✨ [REQUEST] transmission, document where torrent files are stored (opened 2026-07-14)](https://github.com/alexbelgium/hassio-addons/issues/2852) by [@bilogic](https://github.com/bilogic)
# Hass.io Add-ons: Tor with bridges # Hass.io Add-ons: Tor with bridges
[![Donate][donation-badge]](https://www.buymeacoffee.com/alexbelgium) [![Donate][donation-badge]](https://www.buymeacoffee.com/alexbelgium)