mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-09-14 05:39:09 +02:00
* perf(claude_desktop): render on the GPU and stop duplicating MCP servers Measured live inside a running add-on (amd64, 4 cores): 3388 MB RSS across 73 processes, with the Electron renderer burning ~44% of a core even with no browser client connected. GPU: Chromium was rendering everything on the CPU. Under Xvfb it probes GLX, finds only Xvfb's indirect/software path, and falls back to `--use-gl=disabled` plus `--disable-gpu-compositing` — while a perfectly good iGPU sits idle behind /dev/dri. Claude Desktop is now launched through ANGLE's OpenGL backend over EGL, but only when the new claude-gpu-probe confirms that Desktop's own bundled ANGLE can create a hardware GL context on this host; the probe rejects llvmpipe/SwiftShader, is bounded by a timeout, and any failure leaves the command line exactly as it was. New `gpu_acceleration` option (auto|on|off). MCP: every stdio MCP server is a separate process per client, and Desktop starts another full set for each Claude Code session it hosts. Claude Code now reaches the Home Assistant MCP server over its native HTTP transport instead of the mcp-proxy stdio bridge, removing the most expensive duplicate (~45 MB of private RSS per copy). Desktop keeps the bridge: its remote-entry config schema could not be confirmed, and guessing would silently break it. New `mcp_servers_desktop` / `mcp_servers_code` options let each client register only what it actually uses; defaults are unchanged. Display: new `max_resolution` option (default 1920x1080) caps the virtual screen via the base image's MAX_RES. Xvfb ran at 15360x8640, so it and the Selkies capture loop tracked damage over a 133-megapixel area continuously. This is a CPU saving, not a memory one — the framebuffer is a lazily populated shared segment whose unused portion was never resident. Dockerfile: the Intel graphics block was dead code. It was gated on TARGETARCH, which this repo's builder does not pass, so it never ran — the shipped amd64 image has no vainfo and no intel-media-va-driver-non-free, and its apt history contains no matching install. It now uses BUILD_ARCH, and its Vulkan ICD check no longer names intel_icd.x86_64.json, a file Debian does not ship. Also removes `--disable-dev-shm-usage` (a workaround for a 64 MB /dev/shm; this image has 7.7 GB) and fixes stale Home Assistant MCP registrations, including the bearer token inside them, being left behind when enable_ha_mcp is disabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(claude_desktop): satisfy static analysis in claude-gpu-probe Codacy flagged two new issues, both in the probe: a broad exception catch and too many locals in main(). Split the EGL bring-up into load_angle(), open_angle_display(), make_current_context() and describe_renderer(), each raising a dedicated ProbeFailure, so the failure paths read as intent rather than as a chain of early returns. The catch-all remains — a probe must never stop the desktop from starting — but is now explicit and narrowly scoped. No behaviour change: exits 0 with a hardware renderer under DISPLAY, 1 without. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(claude_desktop): address PR review on shm and MCP entry ownership Two review findings, both correct. /dev/shm: dropping --disable-dev-shm-usage outright was generalised from one host. The flag was added to fix a real Electron renderer crash loop on Docker's 64 MB default, and Home Assistant ignores the add-on's shm_size, so the size genuinely varies per install and cannot be asserted from this repo. The size is now read at startup: the workaround is kept below 256 MB, dropped above it, and kept when the size cannot be determined. MCP ownership: claiming an HTTP 'homeassistant' entry by URL and shape would have deleted a user's own manually configured server on the first boot after upgrade, since ha_mcp_url defaults to the same public endpoint that a hand- written entry would use, and enable_ha_mcp defaults to false. An HTTP entry is now only ever modified or removed when the add-on recorded writing it, in ~/.config/claude_desktop_addon/managed-mcp.json. Anything not written by the add-on is untouchable regardless of how it looks. Also drops the invalid '?' optional marker from the list *item* type in the mcp_servers_* schema; both keys always carry defaults, so the marker was meaningless as well as wrong. Tests cover the regression directly: a user-owned HTTP entry on the default URL now survives both a disabled and an enabled boot, while the add-on's own entry is still removed with its token when enable_ha_mcp is turned off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(claude_desktop): tighten max_resolution validation and reporting grep anchors ^...$ per line, so a multi-line max_resolution such as "1920x1080\n640x480" passed validation on its first line and was then written to MAX_RES verbatim, leaving svc-xorg with a corrupt screen size. Bash's =~ anchors the whole string and rejects the embedded newline. Also stop reporting success when no s6 environment directory existed and nothing was written — the cap silently did not apply, and the log said it did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
788 lines
37 KiB
Bash
Executable File
788 lines
37 KiB
Bash
Executable File
#!/usr/bin/with-contenv bashio
|
|
# shellcheck shell=bash
|
|
set -e
|
|
set -o pipefail
|
|
|
|
mkdir -p "$HOME/.claude"
|
|
CLAUDE_MD="$HOME/.claude/CLAUDE.md"
|
|
|
|
run_as_runtime_user() {
|
|
s6-setuidgid abc env HOME="$HOME" "$@"
|
|
}
|
|
|
|
# Managed, idempotent guidance block in the user's global CLAUDE.md, delimited by
|
|
# "<!-- BEGIN/END <name> (managed by claude_desktop addon) -->" markers. `add` appends the
|
|
# block (body on stdin) unless the marker is already present; `remove` strips the whole
|
|
# block, surrounding blank padding included, and leaves everything else untouched.
|
|
manage_claude_md_block() {
|
|
local name="$1" action="$2"
|
|
local begin="<!-- BEGIN ${name} (managed by claude_desktop addon) -->"
|
|
if [ "$action" = "add" ]; then
|
|
if ! { [ -f "$CLAUDE_MD" ] && grep -qF "$begin" "$CLAUDE_MD"; }; then
|
|
bashio::log.info "Adding ${name} guidance to CLAUDE.md"
|
|
mkdir -p "$(dirname "$CLAUDE_MD")"
|
|
{
|
|
[ -s "$CLAUDE_MD" ] && printf '\n'
|
|
printf '%s\n' "$begin"
|
|
cat
|
|
printf '%s\n' "<!-- END ${name} (managed by claude_desktop addon) -->"
|
|
} >> "$CLAUDE_MD"
|
|
fi
|
|
elif [ -f "$CLAUDE_MD" ] && grep -qF "$begin" "$CLAUDE_MD"; then
|
|
bashio::log.info "Removing ${name} guidance from CLAUDE.md"
|
|
CLAUDE_MD="$CLAUDE_MD" BLOCK_NAME="$name" python3 - <<'PY' || bashio::log.warning "Unable to remove the ${name} guidance automatically"
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
path = Path(os.environ["CLAUDE_MD"])
|
|
name = re.escape(os.environ["BLOCK_NAME"])
|
|
text = path.read_text(encoding="utf-8")
|
|
pattern = re.compile(
|
|
rf"\n*<!-- BEGIN {name} \(managed by claude_desktop addon\) -->.*?"
|
|
rf"<!-- END {name} \(managed by claude_desktop addon\) -->\n?",
|
|
re.DOTALL,
|
|
)
|
|
new = pattern.sub("", text)
|
|
if new != text:
|
|
path.write_text(new, encoding="utf-8")
|
|
PY
|
|
fi
|
|
}
|
|
|
|
# Managed hook entry in ~/.claude/settings.json (settings hooks apply to terminal, cowork,
|
|
# dispatch and cron sessions alike). The managed command is stripped everywhere first and
|
|
# re-appended when adding, so one pass handles removal, de-duplication, and matcher migration
|
|
# on upgrades; hooks owned by other tools (e.g. tokensave's own entries) are preserved, and
|
|
# the final text comparison keeps the write idempotent across boots.
|
|
manage_settings_hook() {
|
|
# manage_settings_hook <event> <matcher> <command> <add|remove>
|
|
HOOK_EVENT="$1" HOOK_MATCHER="$2" HOOK_COMMAND="$3" HOOK_ACTION="$4" \
|
|
python3 - <<'PY' || bashio::log.warning "Unable to update the $1 hook for '$3'"
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
event = os.environ["HOOK_EVENT"]
|
|
matcher = os.environ["HOOK_MATCHER"]
|
|
command = os.environ["HOOK_COMMAND"]
|
|
action = os.environ["HOOK_ACTION"]
|
|
|
|
path = Path.home() / ".claude" / "settings.json"
|
|
original = path.read_text() if path.exists() else None
|
|
if original is None and action != "add":
|
|
raise SystemExit(0)
|
|
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(event) if isinstance(hooks.get(event), list) else []
|
|
|
|
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[event] = entries
|
|
else:
|
|
hooks.pop(event, 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
|
|
}
|
|
|
|
# Headroom's proxy routing works by setting ANTHROPIC_BASE_URL, which the Claude Desktop
|
|
# Electron app force-overrides to the production endpoint (headroom #869). Desktop therefore
|
|
# uses Headroom's MCP tools. Claude Code launches that resolve `claude` through PATH use the
|
|
# add-on's /usr/local/bin/claude wrapper and can be transparently proxied when enabled.
|
|
#
|
|
# Register the add-on-managed MCP servers (headroom, tokensave, homeassistant) in both Claude
|
|
# Desktop's config and Claude Code's user config (used by Desktop cowork/dispatch sessions).
|
|
# The merge is idempotent, preserves any other MCP servers, never overwrites a user-customized
|
|
# entry with a different command, and removes only add-on-managed entries when disabled.
|
|
CLAUDE_DESKTOP_CONFIG="$HOME/.config/Claude/claude_desktop_config.json"
|
|
CLAUDE_CODE_CONFIG="$HOME/.claude.json"
|
|
|
|
HEADROOM_ENABLED=false
|
|
if bashio::config.true 'install_headroom'; then
|
|
if command -v headroom &> /dev/null; then
|
|
HEADROOM_ENABLED=true
|
|
bashio::log.info "headroom $(headroom --version 2> /dev/null || true) available; registering the headroom MCP server"
|
|
else
|
|
bashio::log.warning "headroom is not available"
|
|
fi
|
|
fi
|
|
|
|
TOKENSAVE_ENABLED=false
|
|
if bashio::config.true 'install_tokensave'; then
|
|
if command -v tokensave &> /dev/null; then
|
|
TOKENSAVE_ENABLED=true
|
|
bashio::log.info "tokensave $(tokensave --version 2> /dev/null || true) available; configuring the complete Claude Code integration"
|
|
# The upstream installer adds the MCP entry, PreToolUse/UserPromptSubmit/Stop hooks,
|
|
# MCP permissions, global CLAUDE.md rules, and the global post-commit/checkout sync hook.
|
|
run_as_runtime_user tokensave install --agent claude --git-hook yes \
|
|
|| bashio::log.warning "tokensave Claude Code integration setup failed"
|
|
else
|
|
bashio::log.warning "tokensave is not available"
|
|
fi
|
|
elif command -v tokensave &> /dev/null; then
|
|
bashio::log.info "Removing the tokensave Claude Code integration"
|
|
run_as_runtime_user tokensave uninstall --agent claude \
|
|
|| bashio::log.warning "tokensave Claude Code integration removal failed"
|
|
fi
|
|
|
|
# Initialize or incrementally sync only explicitly configured repositories. TokenSave deliberately
|
|
# requires one-time per-project opt-in; an empty list therefore has no startup or storage cost.
|
|
# Runs before the MCP registration merge below on purpose: a first-time `tokensave init` also
|
|
# rewrites ~/.claude.json itself (at default permissions), and the merge afterwards reconciles
|
|
# the managed entries and re-tightens the file mode around the stored HA token.
|
|
if $TOKENSAVE_ENABLED; then
|
|
declare -A TOKENSAVE_REPOS_SEEN=()
|
|
# Capture the list BEFORE looping: bashio::config's internals trip the errexit that
|
|
# process substitution inherits from the bashio wrapper (a `read -d ''` that always
|
|
# returns non-zero), so `done < <(bashio::config ...)` silently fed the loop an EMPTY
|
|
# list — the startup index/sync never ran. Command substitution runs without errexit
|
|
# (inherit_errexit is off), making this form reliable. bashio::config prints list
|
|
# entries one per line, without a trailing newline and as "null" when the key is absent
|
|
# (bashio::config.array only exists in the repo's standalone bashio, not the real one
|
|
# here); the `|| [ -n ... ]` test keeps the final unterminated record in the loop.
|
|
TOKENSAVE_PROJECT_PATHS="$(bashio::config 'tokensave_project_paths')"
|
|
while IFS= read -r configured_path || [ -n "$configured_path" ]; do
|
|
# Trim surrounding whitespace while preserving spaces inside paths.
|
|
configured_path="${configured_path#"${configured_path%%[![:space:]]*}"}"
|
|
configured_path="${configured_path%"${configured_path##*[![:space:]]}"}"
|
|
if [ -z "$configured_path" ] || [ "$configured_path" = "null" ]; then
|
|
continue
|
|
fi
|
|
|
|
case "$configured_path" in
|
|
/*) ;;
|
|
*)
|
|
bashio::log.warning "Skipping non-absolute tokensave_project_paths entry: ${configured_path}"
|
|
continue
|
|
;;
|
|
esac
|
|
if [ ! -d "$configured_path" ]; then
|
|
bashio::log.warning "Skipping missing TokenSave project path: ${configured_path}"
|
|
continue
|
|
fi
|
|
|
|
# The one-shot safe.directory override is used only to discover the repository root.
|
|
repo_root="$(run_as_runtime_user git -c safe.directory='*' -C "$configured_path" rev-parse --show-toplevel 2> /dev/null || true)"
|
|
if [ -z "$repo_root" ] || [ "$repo_root" = "/" ]; then
|
|
bashio::log.warning "Skipping TokenSave path that is not a supported Git repository: ${configured_path}"
|
|
continue
|
|
fi
|
|
if [[ -n "${TOKENSAVE_REPOS_SEEN[$repo_root]:-}" ]]; then
|
|
continue
|
|
fi
|
|
TOKENSAVE_REPOS_SEEN[$repo_root]=1
|
|
|
|
# Persist the resolved root in the runtime user's Git config so the sync/init below,
|
|
# tokensave's git hooks, and Claude sessions all pass Git's dubious-ownership check.
|
|
if ! run_as_runtime_user git config --global --get-all safe.directory \
|
|
| grep -Fxq -- "$repo_root"; then
|
|
run_as_runtime_user git config --global --add safe.directory "$repo_root"
|
|
bashio::log.info "Marked TokenSave repository as safe for Git: ${repo_root}"
|
|
fi
|
|
|
|
bashio::log.info "Preparing TokenSave index: ${repo_root}"
|
|
# Prepare the per-repo semantic graph defensively so a hard add-on stop or storage
|
|
# hiccup can never leave a broken index that fails every subsequent boot:
|
|
# * a startup-scoped flock serializes against an overlapping restart (and any git
|
|
# post-commit/checkout sync hook that fires mid-boot); waits up to 60s for the
|
|
# other writer to finish rather than silently skipping, since a held lock clears
|
|
# itself the moment its holder exits or dies (the kernel releases flock on exit);
|
|
# * an existing index is refreshed with a cheap incremental `sync`, retried a few
|
|
# times because SQLITE_BUSY under lock contention is transient, not corruption;
|
|
# * quarantine is reserved for sync failures whose stderr actually names database
|
|
# 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 the startup 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}"
|
|
done <<< "$TOKENSAVE_PROJECT_PATHS"
|
|
fi
|
|
|
|
# Codex CLI is installed by 81-codex_cli.sh into /data/codex/bin — deliberately outside $HOME,
|
|
# because is_managed() below treats any command under $HOME as user-installed.
|
|
CODEX_BIN="/data/codex/bin/codex"
|
|
CODEX_ENABLED=false
|
|
CODEX_SANDBOX_MODE="$(bashio::config 'codex_sandbox_mode' 'workspace-write')"
|
|
if bashio::config.true 'install_codex_cli'; then
|
|
if [ -x "$CODEX_BIN" ]; then
|
|
CODEX_ENABLED=true
|
|
bashio::log.info "codex $("$CODEX_BIN" --version 2> /dev/null || true) available; registering the codex MCP server (sandbox: ${CODEX_SANDBOX_MODE})"
|
|
else
|
|
bashio::log.warning "codex is not available"
|
|
fi
|
|
fi
|
|
|
|
HA_MCP_ENABLED=false
|
|
HA_MCP_TOKEN=""
|
|
# Read unconditionally, even when enable_ha_mcp is off. Home Assistant keeps an option's value
|
|
# when its toggle is disabled, and the reconciliation below needs this URL to recognise the
|
|
# HTTP entry it previously wrote so that it can be removed — together with the bearer token
|
|
# inside it — rather than orphaned in ~/.claude.json.
|
|
HA_MCP_URL="$(bashio::config 'ha_mcp_url' 'http://homeassistant:8123/api/mcp')"
|
|
if bashio::config.true 'enable_ha_mcp'; then
|
|
if bashio::config.has_value 'ha_mcp_token'; then
|
|
HA_MCP_TOKEN="$(bashio::config 'ha_mcp_token')"
|
|
fi
|
|
if [ -z "$HA_MCP_TOKEN" ]; then
|
|
bashio::log.warning "enable_ha_mcp is on but ha_mcp_token is empty; set a Home Assistant long-lived access token (Profile -> Security) and enable the 'Model Context Protocol Server' integration"
|
|
elif ! command -v mcp-proxy &> /dev/null; then
|
|
bashio::log.warning "mcp-proxy is not available; cannot register the Home Assistant MCP server"
|
|
else
|
|
HA_MCP_ENABLED=true
|
|
bashio::log.info "Registering the Home Assistant MCP server (${HA_MCP_URL})"
|
|
fi
|
|
fi
|
|
|
|
# Which of the managed MCP servers each client gets.
|
|
#
|
|
# Every stdio MCP server is a separate process *per client*, and Claude Desktop starts another
|
|
# full set for each Claude Code session it hosts — so a server registered in both clients is
|
|
# paid for several times over. Measured on a live add-on with three sets running, the private
|
|
# (non-shared) resident cost was roughly 54 MB per extra `headroom mcp serve`, 45 MB per extra
|
|
# `mcp-proxy`, and only ~11 MB and ~2 MB for `codex` and `tokensave`, which share most of their
|
|
# pages. Registering a server only where it is actually used is therefore the cheapest lever
|
|
# available; these two options expose that choice.
|
|
#
|
|
# Defaults keep every enabled server in both clients, i.e. the pre-existing behaviour.
|
|
MCP_ALL_SERVERS="headroom tokensave homeassistant codex"
|
|
|
|
mcp_client_list() {
|
|
local option="$1"
|
|
local selected=() entry raw rc=0
|
|
|
|
# An option that is absent entirely — i.e. an existing install upgrading from a config that
|
|
# predates these options — keeps the previous behaviour of registering every enabled server.
|
|
# bashio distinguishes this from an explicitly empty list: an unset key yields the literal
|
|
# "null", while `[]` yields an empty string. Those must not be conflated, because an empty
|
|
# list is a legitimate way to say "no MCP servers in this client" and defaulting it back to
|
|
# all four would silently ignore the user.
|
|
if ! bashio::config.exists "$option"; then
|
|
echo "$MCP_ALL_SERVERS"
|
|
return 0
|
|
fi
|
|
|
|
# Capture first: reading a bashio list straight into `while read` via process substitution
|
|
# silently yields nothing under this script's errexit. The exit status is kept separately
|
|
# so that a failed read is not mistaken for a deliberate empty selection.
|
|
raw="$(bashio::config "$option" 2> /dev/null)" || rc=$?
|
|
if [ "$rc" -ne 0 ]; then
|
|
bashio::log.warning "Could not read '${option}'; registering every enabled MCP server for this client"
|
|
echo "$MCP_ALL_SERVERS"
|
|
return 0
|
|
fi
|
|
|
|
while read -r entry; do
|
|
[ -n "$entry" ] || continue
|
|
# Reconciliation deletes any managed server not named here, so an unrecognised value
|
|
# must never be treated as an authoritative selection.
|
|
case " $MCP_ALL_SERVERS " in
|
|
*" $entry "*) selected+=("$entry") ;;
|
|
*)
|
|
bashio::log.warning "Ignoring unknown MCP server '${entry}' in '${option}'; registering every enabled server for this client"
|
|
echo "$MCP_ALL_SERVERS"
|
|
return 0
|
|
;;
|
|
esac
|
|
done <<< "$raw"
|
|
|
|
echo "${selected[@]:-}"
|
|
}
|
|
|
|
MCP_SERVERS_DESKTOP="$(mcp_client_list 'mcp_servers_desktop')"
|
|
MCP_SERVERS_CODE="$(mcp_client_list 'mcp_servers_code')"
|
|
bashio::log.info "MCP servers for Claude Desktop: ${MCP_SERVERS_DESKTOP}"
|
|
bashio::log.info "MCP servers for Claude Code: ${MCP_SERVERS_CODE}"
|
|
|
|
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)" \
|
|
CODEX_ENABLED="$CODEX_ENABLED" CODEX_BIN="$CODEX_BIN" CODEX_SANDBOX_MODE="$CODEX_SANDBOX_MODE" \
|
|
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_SERVERS_DESKTOP="$MCP_SERVERS_DESKTOP" MCP_SERVERS_CODE="$MCP_SERVERS_CODE" \
|
|
CLAUDE_DESKTOP_CONFIG="$CLAUDE_DESKTOP_CONFIG" CLAUDE_CODE_CONFIG="$CLAUDE_CODE_CONFIG" \
|
|
python3 - <<'PY' || bashio::log.warning "Unable to update the MCP server registrations automatically"
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
MANAGED_BASENAMES = {
|
|
"headroom": "headroom",
|
|
"tokensave": "tokensave",
|
|
"homeassistant": "mcp-proxy",
|
|
"codex": "codex",
|
|
}
|
|
|
|
desired = {}
|
|
if os.environ["HEADROOM_ENABLED"] == "true":
|
|
desired["headroom"] = {
|
|
"command": os.environ["HEADROOM_BIN"],
|
|
"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":
|
|
desired["tokensave"] = {"command": os.environ["TOKENSAVE_BIN"], "args": ["serve"]}
|
|
if os.environ["CODEX_ENABLED"] == "true":
|
|
# `codex mcp-server` exposes Codex itself as an stdio MCP server (tools: codex, codex-reply),
|
|
# which is what lets a Claude session hand a task to ChatGPT Codex. The sandbox/approval
|
|
# policy is pinned with root-level `-c` overrides, which Codex forwards to the MCP server;
|
|
# they must precede the subcommand. approval_policy is always "never" because an MCP-driven
|
|
# run has nobody to answer a prompt. The sandbox defaults to workspace-write; users can opt
|
|
# into danger-full-access explicitly if the nested sandbox is unavailable in their container.
|
|
# 81-codex_cli.sh writes the same values into ~/.codex/config.toml so plain terminal `codex`
|
|
# runs behave identically.
|
|
desired["codex"] = {
|
|
"command": os.environ["CODEX_BIN"],
|
|
"args": [
|
|
"-c",
|
|
f'sandbox_mode="{os.environ["CODEX_SANDBOX_MODE"]}"',
|
|
"-c",
|
|
'approval_policy="never"',
|
|
"mcp-server",
|
|
],
|
|
}
|
|
if os.environ["HA_MCP_ENABLED"] == "true":
|
|
# Home Assistant's MCP Server integration speaks stateless Streamable HTTP on /api/mcp;
|
|
# mcp-proxy defaults to SSE, so the transport flags are required.
|
|
desired["homeassistant"] = {
|
|
"command": os.environ["MCP_PROXY_BIN"],
|
|
"args": ["--transport=streamablehttp", "--stateless", os.environ["HA_MCP_URL"]],
|
|
"env": {"API_ACCESS_TOKEN": os.environ["HA_MCP_TOKEN"]},
|
|
}
|
|
|
|
# Claude Code speaks Streamable HTTP MCP natively, so pointing it straight at Home Assistant
|
|
# removes the mcp-proxy bridge process entirely — it exists only to translate stdio to the HTTP
|
|
# transport Home Assistant already serves. That bridge was the most expensive duplicate
|
|
# measured (~45 MB of private RSS per copy, one per Claude Code session).
|
|
#
|
|
# Claude Desktop keeps the stdio bridge. Its bundled MCP SDK does contain a remote transport,
|
|
# but the shape `claude_desktop_config.json` accepts for a remote entry — and whether it
|
|
# persists a static bearer header — could not be confirmed, and a wrong guess would silently
|
|
# break Home Assistant access in Desktop. Revisit once that schema is verified upstream.
|
|
HA_MCP_CODE_ENTRY = None
|
|
if os.environ["HA_MCP_ENABLED"] == "true":
|
|
HA_MCP_CODE_ENTRY = {
|
|
"type": "http",
|
|
"url": os.environ["HA_MCP_URL"],
|
|
"headers": {"Authorization": "Bearer " + os.environ["HA_MCP_TOKEN"]},
|
|
}
|
|
|
|
# An entry is add-on-managed when its command is one of our binaries living outside the
|
|
# persistent home. Matching on the basename (rather than the exact path recorded at write
|
|
# time) keeps entries updatable when a base-image upgrade moves the binary, while commands
|
|
# under $HOME stay untouched because those are user-installed.
|
|
HOME_PREFIX = os.path.expanduser("~") + os.sep
|
|
|
|
|
|
# Ownership record for the HTTP Home Assistant entry.
|
|
#
|
|
# The stdio entries can be recognised on sight, because their `command` points at a binary this
|
|
# image installs outside $HOME. An HTTP entry has no such tell: it is just a URL plus a bearer
|
|
# header, and a user who configured `homeassistant` by hand — very plausibly at the same default
|
|
# http://homeassistant:8123/api/mcp — would be indistinguishable from ours. Inferring ownership
|
|
# from shape or URL would let this script delete or overwrite that entry, including their token.
|
|
#
|
|
# So ownership is recorded rather than guessed: the URL of an entry this script actually wrote is
|
|
# remembered here, and only an entry matching that record is ever modified or removed. Anything
|
|
# this script did not write is untouchable, whatever it looks like. The file holds no secrets —
|
|
# just the endpoint — but is written 0600 to match the configs it describes.
|
|
STATE_PATH = Path(os.path.expanduser("~")) / ".config" / "claude_desktop_addon" / "managed-mcp.json"
|
|
|
|
|
|
def load_state():
|
|
try:
|
|
state = json.loads(STATE_PATH.read_text())
|
|
return state if isinstance(state, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def save_state(state):
|
|
try:
|
|
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
STATE_PATH.write_text(json.dumps(state, indent=2, sort_keys=True) + "\n")
|
|
STATE_PATH.chmod(0o600)
|
|
except Exception:
|
|
# Losing the record only costs us the ability to clean up later; never fail the boot.
|
|
pass
|
|
|
|
|
|
def is_managed_http_ha(entry, owned_url):
|
|
"""True only for an HTTP entry this script previously wrote."""
|
|
return (
|
|
bool(owned_url)
|
|
and entry.get("url") == owned_url
|
|
and set(entry) == {"type", "url", "headers"}
|
|
and entry.get("type") == "http"
|
|
and isinstance(entry.get("headers"), dict)
|
|
and set(entry["headers"]) == {"Authorization"}
|
|
)
|
|
|
|
|
|
def is_managed(name, entry, owned_url):
|
|
if not isinstance(entry, dict):
|
|
return False
|
|
command = entry.get("command")
|
|
if not isinstance(command, str) or command.startswith(HOME_PREFIX):
|
|
# A commandless entry is ours only when it is an HTTP Home Assistant registration this
|
|
# script recorded writing. Anything else — including a user's own remote server that
|
|
# reuses the name, even on the same URL — is left alone.
|
|
if command is None and name == "homeassistant":
|
|
return is_managed_http_ha(entry, owned_url)
|
|
return False
|
|
return os.path.basename(command) == MANAGED_BASENAMES[name]
|
|
|
|
|
|
SELECTED = {
|
|
"CLAUDE_DESKTOP_CONFIG": set(os.environ["MCP_SERVERS_DESKTOP"].split()),
|
|
"CLAUDE_CODE_CONFIG": set(os.environ["MCP_SERVERS_CODE"].split()),
|
|
}
|
|
|
|
state = load_state()
|
|
state_changed = False
|
|
|
|
for config_var, stdio_type in (("CLAUDE_DESKTOP_CONFIG", False), ("CLAUDE_CODE_CONFIG", True)):
|
|
path = Path(os.environ[config_var])
|
|
selected = SELECTED[config_var]
|
|
owned_url = state.get(str(path), {}).get("homeassistant_http_url", "")
|
|
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 = {}
|
|
servers = data.get("mcpServers")
|
|
if not isinstance(servers, dict):
|
|
servers = {}
|
|
changed = False
|
|
for name in MANAGED_BASENAMES:
|
|
existing = servers.get(name)
|
|
if name in desired and name in selected:
|
|
if stdio_type and name == "homeassistant" and HA_MCP_CODE_ENTRY is not None:
|
|
# Claude Code talks to Home Assistant over HTTP directly; no bridge process.
|
|
entry = dict(HA_MCP_CODE_ENTRY)
|
|
else:
|
|
entry = dict(desired[name])
|
|
if stdio_type:
|
|
entry["type"] = "stdio"
|
|
# An entry we did not write is never overwritten, so a user's own HTTP
|
|
# `homeassistant` survives even when it sits on the configured URL.
|
|
claimable = existing is None or is_managed(name, existing, owned_url)
|
|
if claimable:
|
|
if existing != entry:
|
|
servers[name] = entry
|
|
changed = True
|
|
if name == "homeassistant" and entry.get("type") == "http":
|
|
if owned_url != entry["url"]:
|
|
state.setdefault(str(path), {})["homeassistant_http_url"] = entry["url"]
|
|
owned_url = entry["url"]
|
|
state_changed = True
|
|
elif existing is not None and is_managed(name, existing, owned_url):
|
|
# Covers both "feature disabled" and "deselected for this client".
|
|
del servers[name]
|
|
changed = True
|
|
if name == "homeassistant" and state.get(str(path), {}).pop(
|
|
"homeassistant_http_url", None
|
|
):
|
|
owned_url = ""
|
|
state_changed = True
|
|
if changed:
|
|
if servers:
|
|
data["mcpServers"] = servers
|
|
else:
|
|
data.pop("mcpServers", None)
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(data, indent=2) + "\n")
|
|
# The Home Assistant long-lived access token is stored here in clear text. Enforced even
|
|
# on no-change boots because tokensave's own writes can recreate the file with default
|
|
# permissions between merges.
|
|
if path.exists():
|
|
path.chmod(0o600)
|
|
|
|
if state_changed:
|
|
save_state(state)
|
|
PY
|
|
|
|
# Guide Claude to actually use the Headroom compression tools so the MCP integration produces
|
|
# real savings when transparent proxying is unavailable.
|
|
if $HEADROOM_ENABLED; then
|
|
manage_claude_md_block headroom add <<'MD'
|
|
## Headroom context compression
|
|
|
|
A local Headroom proxy (127.0.0.1:8787) backs the `headroom` MCP tools. To save context tokens:
|
|
when you produce or read a **large, structured** blob you will keep referring to — file listings,
|
|
search results, JSON/config dumps, big command outputs, roughly >500 tokens — call
|
|
`mcp__headroom__headroom_compress` on it and keep the returned compressed text + `hash` instead of
|
|
the raw content. Call `mcp__headroom__headroom_retrieve` with that hash when you need the full
|
|
original back. Skip compression for error/stack-trace output (Headroom deliberately protects it)
|
|
and for small or one-off content. Use `mcp__headroom__headroom_stats` to check savings.
|
|
MD
|
|
else
|
|
manage_claude_md_block headroom remove
|
|
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. 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
|
|
manage_settings_hook PostToolUse "Bash|Grep|Glob|WebFetch" "$HEADROOM_HOOK_CMD" "$HEADROOM_HOOK_ACTION"
|
|
|
|
# Tell Claude Code that it can configure Home Assistant over the Core API via the shipped
|
|
# `ha-cli` helper (no /config filesystem mount needed).
|
|
if bashio::config.true 'enable_ha_api_helper'; then
|
|
manage_claude_md_block ha-api-helper add <<'MD'
|
|
## 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`).
|
|
MD
|
|
else
|
|
manage_claude_md_block ha-api-helper remove
|
|
fi
|
|
|
|
# Registering the MCP server is not enough on its own: without guidance the model rarely reaches
|
|
# for a second agent, the same gap the Headroom block above exists to close.
|
|
if $CODEX_ENABLED; then
|
|
manage_claude_md_block codex add <<'MD'
|
|
## Delegating to ChatGPT Codex
|
|
|
|
The `codex` MCP server runs OpenAI's Codex agent locally, signed in with the user's ChatGPT
|
|
subscription. It is a genuinely independent second agent — a different model family, reading the
|
|
files itself — not a search tool. It is slow and costs the user's ChatGPT quota, so use it when a
|
|
second opinion is worth minutes, not for routine lookups.
|
|
|
|
Good uses: an independent review of a design or a risky change before it lands; a second
|
|
diagnosis of a bug you have a theory about but cannot confirm; a competing implementation of a
|
|
self-contained piece you can then compare against your own.
|
|
|
|
Call `mcp__codex__codex` with `prompt` and always set `cwd` to the repository being discussed —
|
|
Codex reads the files itself, so it needs the right working directory and enough context in the
|
|
prompt to act without seeing this conversation. Continue an exchange with
|
|
`mcp__codex__codex-reply` (note the hyphen) using the `threadId` it returned, rather than
|
|
starting a fresh `codex` call. Treat its answers as a peer's opinion: verify claims about this
|
|
codebase before acting on them.
|
|
MD
|
|
else
|
|
manage_claude_md_block codex remove
|
|
fi
|
|
|
|
if bashio::config.true 'install_rtk'; then
|
|
if command -v rtk &> /dev/null; then
|
|
bashio::log.info "Configuring rtk Claude Code integration"
|
|
# `rtk init -g` writes ~/.claude/RTK.md and its @RTK.md include in CLAUDE.md, but in
|
|
# non-interactive mode it deliberately refuses to patch settings.json, so the hook
|
|
# entry that actually rewrites Bash commands is registered here.
|
|
run_as_runtime_user env RTK_NONINTERACTIVE=1 rtk init -g \
|
|
|| bashio::log.warning "rtk global files configuration failed"
|
|
manage_settings_hook PreToolUse Bash "rtk hook claude" add
|
|
else
|
|
bashio::log.warning "rtk is not available"
|
|
fi
|
|
else
|
|
manage_settings_hook PreToolUse Bash "rtk hook claude" remove
|
|
fi
|
|
|
|
if bashio::config.true 'install_caveman'; then
|
|
if [ -d "$HOME/.claude/plugins/caveman" ] || find "$HOME/.claude" -maxdepth 4 -iname '*caveman*' -print -quit | grep -q .; then
|
|
bashio::log.info "caveman Claude Code plugin already configured"
|
|
else
|
|
bashio::log.info "Installing caveman Claude Code plugin"
|
|
curl --connect-timeout 10 --max-time 60 -fsSL https://raw.githubusercontent.com/JuliusBrussee/caveman/main/install.sh | bash > /dev/null \
|
|
|| bashio::log.warning "caveman install failed (offline?)"
|
|
fi
|
|
else
|
|
bashio::log.info "Disabling caveman Claude Code plugin"
|
|
find "$HOME/.claude" -maxdepth 4 -iname '*caveman*' -exec rm -rf {} + 2> /dev/null || true
|
|
fi
|
|
|
|
# Ownership of everything written above is reconciled by 84-claude_runtime_ownership.sh after
|
|
# the remaining Claude configuration scripts have run.
|