mirror of
https://github.com/alexbelgium/hassio-addons.git
synced 2026-08-19 03:17:19 +02:00
Merge pull request #2863 from alexbelgium/feat/claude_desktop-ha-api-helper nobuild
feat(claude_desktop): add ha-cli Core-API helper for configuring Home Assistant
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/<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
|
||||
|
||||
The add-on includes the repository standard custom-script executor. On first
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="<!-- 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 command -v rtk &> /dev/null; then
|
||||
bashio::log.info "Configuring rtk Claude Code integration"
|
||||
|
||||
236
claude_desktop/rootfs/usr/local/bin/ha-cli
Executable file
236
claude_desktop/rootfs/usr/local/bin/ha-cli
Executable file
@@ -0,0 +1,236 @@
|
||||
#!/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:]))
|
||||
Reference in New Issue
Block a user