From e5983f47186f53af23dbac0a62f9d5b1f4081bd7 Mon Sep 17 00:00:00 2001 From: alexbelgium Date: Wed, 15 Jul 2026 14:05:19 +0200 Subject: [PATCH 1/2] feat(claude_desktop): add ha-cli Core-API helper for configuring Home Assistant Ship a `ha-cli` command that lets Claude configure Home Assistant (automations, scripts, scenes, helpers, dashboards, registries, service calls) through the Home Assistant Core API instead of a /config filesystem mount, so secrets.yaml and other add-ons' credentials stay out of reach. It authenticates automatically with the add-on's SUPERVISOR_TOKEN via the Supervisor Core-API proxy (no token setup), with optional HA_TOKEN / ha_mcp_token overrides for a scoped Home Assistant user. A managed guidance block in ~/.claude/CLAUDE.md tells Claude Code to use the helper and to confirm before writes. Gated by the new enable_ha_api_helper option (default on). Adds the websockets dependency for the WebSocket subcommand. Co-Authored-By: Claude Opus 4.8 --- claude_desktop/CHANGELOG.md | 4 + claude_desktop/Dockerfile | 4 +- claude_desktop/README.md | 40 ++++ claude_desktop/config.yaml | 4 +- .../rootfs/etc/cont-init.d/82-claude_tools.sh | 55 +++++ claude_desktop/rootfs/usr/local/bin/ha-cli | 213 ++++++++++++++++++ 6 files changed, 317 insertions(+), 3 deletions(-) create mode 100755 claude_desktop/rootfs/usr/local/bin/ha-cli diff --git a/claude_desktop/CHANGELOG.md b/claude_desktop/CHANGELOG.md index 5757b7603d..c92bbdc23e 100644 --- a/claude_desktop/CHANGELOG.md +++ b/claude_desktop/CHANGELOG.md @@ -1,3 +1,7 @@ +## 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`. + ## 1.21 (15-07-2026) - Fix Claude Code bypass permissions being rejected when the add-on uses its default root `PUID`. diff --git a/claude_desktop/Dockerfile b/claude_desktop/Dockerfile index e8bff0593f..fc90eb52d2 100644 --- a/claude_desktop/Dockerfile +++ b/claude_desktop/Dockerfile @@ -74,7 +74,7 @@ RUN curl -fsSL --retry 3 --retry-delay 2 \ # cannot alter executables elsewhere in the image. COPY rootfs/ / RUN find /etc/cont-init.d /etc/s6-overlay /defaults /usr/local/bin -type f \ - \( -name "*.sh" -o -name "run" -o -name "finish" \) -print -exec chmod +x {} \; && \ + \( -name "*.sh" -o -name "run" -o -name "finish" -o -name "ha-cli" \) -print -exec chmod +x {} \; && \ chmod +x /usr/local/bin/claude # Uses /bin for compatibility purposes @@ -146,7 +146,7 @@ RUN /usr/local/bin/rtk --version && /usr/local/bin/tokensave --version # installer used for the additional_pip option). RUN apt-get update && \ apt-get install -y --no-install-recommends nodejs && \ - pip3 install --break-system-packages "headroom-ai[proxy,code,mcp]" mcp-proxy uv && \ + pip3 install --break-system-packages "headroom-ai[proxy,code,mcp]" mcp-proxy uv websockets && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* /root/.cache diff --git a/claude_desktop/README.md b/claude_desktop/README.md index fd5600d25b..50c138bfb2 100644 --- a/claude_desktop/README.md +++ b/claude_desktop/README.md @@ -111,6 +111,7 @@ 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`). | | `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. | +| `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_pip` | | Comma-separated pip packages installed at startup (via `uv`). | | `data_location` | `/data/data` | Persistent home directory for Claude and tooling. | @@ -216,6 +217,45 @@ 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 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/ # read one automation +ha-cli post config/automation/config/ @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 The add-on includes the repository standard custom-script executor. On first diff --git a/claude_desktop/config.yaml b/claude_desktop/config.yaml index d3606e84c1..39dc679aaa 100644 --- a/claude_desktop/config.yaml +++ b/claude_desktop/config.yaml @@ -42,6 +42,7 @@ options: enable_ha_mcp: false ha_mcp_url: http://homeassistant:8123/api/mcp ha_mcp_token: "" + enable_ha_api_helper: true github_token: "" github_username: "" enable_tools_health_report: true @@ -86,6 +87,7 @@ schema: enable_ha_mcp: bool? ha_mcp_url: str? ha_mcp_token: password? + enable_ha_api_helper: bool? github_token: password? github_username: str? enable_tools_health_report: bool @@ -103,5 +105,5 @@ slug: claude_desktop tmpfs: true udev: true url: https://github.com/alexbelgium/hassio-addons -version: "1.22" +version: "1.23" video: true diff --git a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh index cd8e9fbf9e..ae9e881392 100755 --- a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh +++ b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh @@ -251,6 +251,61 @@ if new != text: PY fi +# 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="" +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' + +## 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/` and the `script`/`scene` equivalents); +service calls (`ha-cli call ''`); 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 + } >> "$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() +pattern = re.compile( + r"\n*.*?" + r"\n?", + re.DOTALL, +) +new = pattern.sub("", text) +if new != text: + path.write_text(new) +PY +fi + if bashio::config.true 'install_rtk'; then if command -v rtk &> /dev/null; then bashio::log.info "Configuring rtk Claude Code integration" diff --git a/claude_desktop/rootfs/usr/local/bin/ha-cli b/claude_desktop/rootfs/usr/local/bin/ha-cli new file mode 100755 index 0000000000..aba01d1dd8 --- /dev/null +++ b/claude_desktop/rootfs/usr/local/bin/ha-cli @@ -0,0 +1,213 @@ +#!/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. $SUPERVISOR_TOKEN Supervisor Core-API proxy + (needs homeassistant_api: true, + which this add-on sets — zero setup) + 3. `ha_mcp_token` in /data/options.json user long-lived token -> :8123 + +Subcommands: + ha-cli get GET e.g. get config/automation/config/1700000000 + ha-cli post [BODY] POST BODY = inline JSON, @file, or - (stdin) + ha-cli delete DELETE + ha-cli call [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 one WebSocket command (BODY = JSON, @file, or -) + +`` 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 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("/") + ws = rest.replace("http", "ws", 1).rsplit("/api", 1)[0] + "/api/websocket" + return rest, ws, token + + token = os.environ.get("SUPERVISOR_TOKEN") + if token: + return "http://supervisor/core/api", "ws://supervisor/core/websocket", token + + token = _load_option("ha_mcp_token") + if token: + return ( + "http://homeassistant:8123/api", + "ws://homeassistant:8123/api/websocket", + token, + ) + + sys.exit( + "ha-cli: no credentials. Expected $SUPERVISOR_TOKEN (default inside the " + "add-on), or $HA_BASE_URL+$HA_TOKEN, or ha_mcp_token in the add-on options." + ) + + +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 + + 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 ") + _print(rest("GET", rest_base, token, args[0])) + elif cmd == "post": + if not args: + sys.exit("usage: ha-cli post [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 ") + _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 [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 ") + 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:])) From 0223cc351138f62508d042d42870b80183b7d11c Mon Sep 17 00:00:00 2001 From: alexbelgium Date: Wed, 15 Jul 2026 14:24:17 +0200 Subject: [PATCH 2/2] fix(claude_desktop): address ha-cli review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Check ha_mcp_token before SUPERVISOR_TOKEN: this add-on always sets homeassistant_api, so the admin-equivalent Supervisor token was always present and silently shadowed a user's deliberately scoped-down ha_mcp_token, defeating the documented scoping path (Codex P1). - Make ha-cli itself refuse to run when enable_ha_api_helper is false, instead of only removing the CLAUDE.md guidance text — disabling the option now actually disables the helper (Codex P2). - Normalize HA_BASE_URL to include /api when the user omits it, so REST calls don't 404 (CodeRabbit). - Read/write CLAUDE.md with explicit UTF-8 in the ha-api-helper removal block, matching the emoji/special characters Claude tends to write there (CodeRabbit). Co-Authored-By: Claude Sonnet 5 --- .../rootfs/etc/cont-init.d/82-claude_tools.sh | 4 +- claude_desktop/rootfs/usr/local/bin/ha-cli | 43 ++++++++++++++----- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh index ae9e881392..03c343c1f6 100755 --- a/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh +++ b/claude_desktop/rootfs/etc/cont-init.d/82-claude_tools.sh @@ -294,7 +294,7 @@ import re from pathlib import Path path = Path(os.environ["CLAUDE_MD"]) -text = path.read_text() +text = path.read_text(encoding="utf-8") pattern = re.compile( r"\n*.*?" r"\n?", @@ -302,7 +302,7 @@ pattern = re.compile( ) new = pattern.sub("", text) if new != text: - path.write_text(new) + path.write_text(new, encoding="utf-8") PY fi diff --git a/claude_desktop/rootfs/usr/local/bin/ha-cli b/claude_desktop/rootfs/usr/local/bin/ha-cli index aba01d1dd8..6d521ef256 100755 --- a/claude_desktop/rootfs/usr/local/bin/ha-cli +++ b/claude_desktop/rootfs/usr/local/bin/ha-cli @@ -9,10 +9,15 @@ 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. $SUPERVISOR_TOKEN Supervisor Core-API proxy - (needs homeassistant_api: true, - which this add-on sets — zero setup) - 3. `ha_mcp_token` in /data/options.json user long-lived token -> :8123 + 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 GET e.g. get config/automation/config/1700000000 @@ -49,19 +54,26 @@ def _load_option(name): 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 - token = os.environ.get("SUPERVISOR_TOKEN") - if token: - return "http://supervisor/core/api", "ws://supervisor/core/websocket", 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 ( @@ -70,9 +82,14 @@ def resolve_endpoint(): 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 $SUPERVISOR_TOKEN (default inside the " - "add-on), or $HA_BASE_URL+$HA_TOKEN, or ha_mcp_token in the add-on options." + "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." ) @@ -170,6 +187,12 @@ def main(argv): 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:]