From 48225f2deaede34b97b5424165502034f6a8bc62 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 20 Jun 2026 23:46:09 +0000 Subject: [PATCH] homelab CLI v0.7: add `ha token` + `ha ssh` for Home Assistant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mined another devvm user's Claude sessions for repeated, hand-rolled command patterns worth absorbing into the shared CLI. The dominant signal was Home Assistant "Sofia" work: a `kubectl | base64 | jq` token-extraction pipeline re-derived ~420x, and a bespoke non-interactive `ssh -o …` invocation reinvented ~30x — every session. The existing `home-assistant-sofia.py` already covers the API but goes unused from an arbitrary cwd (needs an env var set + a cwd-relative path), so agents bypassed it and hand-rolled everything. Add two verbs covering exactly the gaps the `ha` MCP can't (entity state/control stays with the MCP): - `ha token [--instance sofia|london]` (read): resolves the long-lived API token live from k8s secret openclaw/openclaw-secrets via the ambient kubeconfig — no pre-set env var. Composes as `curl -H "Authorization: Bearer $(homelab ha token)"`. - `ha ssh [--instance sofia|london] -- ` (write): deterministic non-interactive ssh to the HA host using the invoking user's key. Also fix the root cause: `home-assistant-sofia.py` now falls back to `homelab ha token` when its env var is unset (works from any directory), and the home-assistant skill points agents at these verbs + `homelab metrics query` instead of hand-rolled curls. README + ADR-0012 + AGENTS.md updated per the per-verb-group convention. Co-Authored-By: Claude Opus 4.8 --- .claude/home-assistant-sofia.py | 29 +++- .claude/skills/home-assistant/SKILL.md | 6 + AGENTS.md | 2 +- cli/README.md | 26 +++- cli/VERSION | 2 +- cli/cmd_ha.go | 180 +++++++++++++++++++++++++ cli/cmd_ha_test.go | 95 +++++++++++++ cli/homelab.go | 1 + docs/adr/0012-homelab-ha-verbs.md | 46 +++++++ 9 files changed, 378 insertions(+), 9 deletions(-) create mode 100644 cli/cmd_ha.go create mode 100644 cli/cmd_ha_test.go create mode 100644 docs/adr/0012-homelab-ha-verbs.md diff --git a/.claude/home-assistant-sofia.py b/.claude/home-assistant-sofia.py index b0ccdca7..d8121f6c 100644 --- a/.claude/home-assistant-sofia.py +++ b/.claude/home-assistant-sofia.py @@ -7,6 +7,7 @@ Control and query Home Assistant entities on ha-sofia.viktorbarzin.me. import argparse import json import os +import subprocess import sys from urllib.parse import urljoin @@ -17,13 +18,29 @@ except ImportError: print(" pip install requests") sys.exit(1) -# Configuration from environment variables (ha-sofia specific) -HA_URL = os.environ.get("HOME_ASSISTANT_SOFIA_URL", "").rstrip("/") -HA_TOKEN = os.environ.get("HOME_ASSISTANT_SOFIA_TOKEN") -if not HA_URL or not HA_TOKEN: - print("ERROR: HOME_ASSISTANT_SOFIA_URL and HOME_ASSISTANT_SOFIA_TOKEN environment variables must be set.") - print("These should be set when activating the Claude venv (~/.venvs/claude)") +def _token_from_homelab(): + """Resolve the token via the homelab CLI when the env var isn't set, so the + script works from any directory / unprovisioned session (see ADR-0012).""" + try: + out = subprocess.run( + ["homelab", "ha", "token", "--instance", "sofia"], + capture_output=True, text=True, timeout=30) + if out.returncode == 0 and out.stdout.strip(): + return out.stdout.strip() + except Exception: + pass + return None + + +# Configuration: prefer env vars (set by the Claude venv); otherwise fall back to +# defaults + the homelab CLI so the script is not cwd/env dependent (ADR-0012). +HA_URL = os.environ.get("HOME_ASSISTANT_SOFIA_URL", "").rstrip("/") or "https://ha-sofia.viktorbarzin.me" +HA_TOKEN = os.environ.get("HOME_ASSISTANT_SOFIA_TOKEN") or _token_from_homelab() + +if not HA_TOKEN: + print("ERROR: no ha-sofia API token available.") + print("Set HOME_ASSISTANT_SOFIA_TOKEN, or ensure `homelab ha token` works (kubeconfig reachable).") sys.exit(1) HEADERS = { diff --git a/.claude/skills/home-assistant/SKILL.md b/.claude/skills/home-assistant/SKILL.md index fe761f8c..61aaa6af 100644 --- a/.claude/skills/home-assistant/SKILL.md +++ b/.claude/skills/home-assistant/SKILL.md @@ -44,6 +44,12 @@ There are **two** Home Assistant instances: - Environment variables for each instance: - **ha-london**: `HOME_ASSISTANT_URL` and `HOME_ASSISTANT_TOKEN` - **ha-sofia**: `HOME_ASSISTANT_SOFIA_URL` and `HOME_ASSISTANT_SOFIA_TOKEN` + - If those env vars aren't set (e.g. you're not in the infra repo / Claude venv), don't hand-roll a `kubectl | base64 | jq` token pipeline — use the global **`homelab` CLI** instead (on `$PATH` in any directory): + +## homelab CLI (preferred — works from any directory) +- **Token**: `homelab ha token [--instance sofia|london]` resolves the long-lived API token live from the cluster. Use it directly in curl: `curl -H "Authorization: Bearer $(homelab ha token)" https://ha-sofia.viktorbarzin.me/api/states`. (The `home-assistant-sofia.py` script also auto-falls-back to this when its env var is unset.) +- **Host shell** (ha-sofia): `homelab ha ssh -- ` runs a command on the HA host with deterministic non-interactive ssh (no host-key prompt) — e.g. `homelab ha ssh -- "sudo docker ps"`, `homelab ha ssh -- "cat /config/configuration.yaml"`. Replaces bespoke `ssh -o StrictHostKeyChecking=no …` invocations. +- **Cluster metrics/logs** (not HA-specific): prefer `homelab metrics query ""` / `homelab logs query ""` over hand-rolled `curl …/api/v1/query`, and `homelab claim`/`release` over calling `scripts/presence` directly. ## API Control diff --git a/AGENTS.md b/AGENTS.md index 5e30bd9e..1088647b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -289,7 +289,7 @@ curl -X POST -H "Authorization: token $TOK" -H 'Content-Type: application/json' ``` ## Common Operations -- **`homelab` CLI** (`/usr/local/bin/homelab`, source `cli/`): unified infra-ops verbs — run `homelab manifest` to discover the surface (each verb tagged read/write). Infra loop: `homelab tf plan|fmt|apply ` (wraps `scripts/tg`; `apply` auto-claims presence + releases on exit, warns out-of-band), `homelab claim|release :`, `homelab work start|land|clean ` (worktree lifecycle; `land` gates on verification, `--verify-cmd`/`--no-verify`). Kubernetes (v0.2): `homelab k8s status|get|logs|describe|debug|pf|rollout-status ` (read; `` defaults to the namespace, target to `deploy/`), `homelab k8s db [--mysql] -- ""`, `k8s exec`, `k8s restart`, `k8s rm-pod` (pods/jobs only) — config-mutation kubectl verbs are intentionally absent (Terraform-only). Memory (v0.3): `homelab memory recall ""` (semantic search), `memory list|categories|tags|stats|secret`, `memory store|update|delete` — a direct HTTP client to claude-memory that works even when the memory MCP is down. CI/deploy (v0.4): `homelab ci status|watch [commit]` (Woodpecker, repo resolved from cwd), `homelab deploy wait / [--sha]` (image-sha + rollout) — `work land` now auto-watches CI to green. Net/obs (v0.5): `homelab net check [path]` (external-CF vs internal-LB reachability), `dns lookup ` (Technitium vs public diff), `metrics query ""` / `metrics alerts` (Prometheus via LB), `logs query "" [--since]` (Loki via LB) — endpoint resolution baked in, no port-forward. Usage telemetry (v0.6): every dispatched verb fire-and-forgets a Loki line (`{user,verb}` + exit only, NO args/secrets; opt-out `HOMELAB_TELEMETRY=0`); `homelab usage top [--since][--user]` ranks verb usage across all users — evidence for what to build next, queryable without reading anyone's home. Full docs: `cli/README.md`. +- **`homelab` CLI** (`/usr/local/bin/homelab`, source `cli/`): unified infra-ops verbs — run `homelab manifest` to discover the surface (each verb tagged read/write). Infra loop: `homelab tf plan|fmt|apply ` (wraps `scripts/tg`; `apply` auto-claims presence + releases on exit, warns out-of-band), `homelab claim|release :`, `homelab work start|land|clean ` (worktree lifecycle; `land` gates on verification, `--verify-cmd`/`--no-verify`). Kubernetes (v0.2): `homelab k8s status|get|logs|describe|debug|pf|rollout-status ` (read; `` defaults to the namespace, target to `deploy/`), `homelab k8s db [--mysql] -- ""`, `k8s exec`, `k8s restart`, `k8s rm-pod` (pods/jobs only) — config-mutation kubectl verbs are intentionally absent (Terraform-only). Memory (v0.3): `homelab memory recall ""` (semantic search), `memory list|categories|tags|stats|secret`, `memory store|update|delete` — a direct HTTP client to claude-memory that works even when the memory MCP is down. CI/deploy (v0.4): `homelab ci status|watch [commit]` (Woodpecker, repo resolved from cwd), `homelab deploy wait / [--sha]` (image-sha + rollout) — `work land` now auto-watches CI to green. Net/obs (v0.5): `homelab net check [path]` (external-CF vs internal-LB reachability), `dns lookup ` (Technitium vs public diff), `metrics query ""` / `metrics alerts` (Prometheus via LB), `logs query "" [--since]` (Loki via LB) — endpoint resolution baked in, no port-forward. Usage telemetry (v0.6): every dispatched verb fire-and-forgets a Loki line (`{user,verb}` + exit only, NO args/secrets; opt-out `HOMELAB_TELEMETRY=0`); `homelab usage top [--since][--user]` ranks verb usage across all users — evidence for what to build next, queryable without reading anyone's home. Home Assistant (v0.7): `homelab ha token [--instance sofia|london]` (prints the long-lived API token, resolved live from k8s Secret `openclaw/openclaw-secrets` — use as `curl -H "Authorization: Bearer $(homelab ha token)"`), `homelab ha ssh [--instance sofia|london] -- ` (run a command on the HA host; deterministic non-interactive ssh, the invoking user's `~/.ssh/id_ed25519`, sofia=`vbarzin@192.168.1.8` default) — entity state/control stays with the `ha` MCP, these cover only what an API-only MCP can't (token + host shell). Full docs: `cli/README.md`. - **Deploy new service**: Use `stacks//` as template. Create stack, add DNS in tfvars, apply platform then service. - **Fix crashed pods**: Run healthcheck first. Safe to delete evicted/failed pods and CrashLoopBackOff pods with >10 restarts. - **OOMKilled**: Check `kubectl describe limitrange tier-defaults -n `. Increase `resources.limits.memory` in the stack's main.tf. diff --git a/cli/README.md b/cli/README.md index e21da6d2..0ee27093 100644 --- a/cli/README.md +++ b/cli/README.md @@ -147,6 +147,30 @@ the privacy-preserving answer to "what does the team use." |---|---|---| | `usage top [--since 30d] [--user U] [--json]` | read | rank verbs by invocation count across all users (or one), via `sum by (verb) (count_over_time({job="homelab-usage"}[…]))` | +### v0.7 verbs — Home Assistant + +Cover exactly the two things the `ha` **MCP server can't**: resolving the +long-lived API token out of the cluster, and SSH to the HA host for host-level +work (config files, docker, add-ons). Entity state and control (`turn_on`, +`get_state`, services) stay with the MCP — *actions an MCP already encodes are +out of scope* (see top of this doc). The value here is the same as `net`/`dns`: +the non-obvious *which secret, which host, which key, which flags* you'd +otherwise re-derive every session — agents were hand-rolling a +`kubectl | base64 | jq` token pipeline and a bespoke `ssh -o …` invocation on +every run because the existing `home-assistant-sofia.py` needs an env var set +and a cwd-relative path, neither of which holds in an arbitrary session. + +| Command | Tier | What it does | +|---|---|---| +| `ha token [--instance sofia\|london]` | read | print the long-lived HA API token, resolved live from k8s Secret `openclaw/openclaw-secrets` (`skill_secrets` JSON) via the ambient kubeconfig — no pre-set env var. Use as `curl -H "Authorization: Bearer $(homelab ha token)" …` | +| `ha ssh [--instance sofia\|london] [-i KEY] -- ` | write | run `` on the HA host over ssh with deterministic non-interactive flags (explicit key = the invoking user's `~/.ssh/id_ed25519`, no user ssh-config, no known_hosts prompt). sofia (`vbarzin@192.168.1.8`) is reachable from the devvm LAN; london is documented but generally remote | + +`--instance` defaults to **sofia** (the devvm shares the Sofia LAN). `ha token` +prints the bare token to stdout so it composes in `$(…)`; it's read-tier like +`memory secret`. `ha ssh` resolves the *invoking user's* key, so it's per-user, +not tied to whoever first wrote the workflow (the user's key must be enrolled on +the HA host). + ## Build / install Built from source to `/usr/local/bin/homelab` during devvm provisioning @@ -166,4 +190,4 @@ original flag-based path unchanged, so the webhook handler is unaffected. ## Design -See `infra/docs/adr/0004`–`0011` for the architecture decisions. +See `infra/docs/adr/0004`–`0012` for the architecture decisions. diff --git a/cli/VERSION b/cli/VERSION index 60f63432..8b20e485 100644 --- a/cli/VERSION +++ b/cli/VERSION @@ -1 +1 @@ -v0.6.0 +v0.7.0 diff --git a/cli/cmd_ha.go b/cli/cmd_ha.go new file mode 100644 index 00000000..4553dec0 --- /dev/null +++ b/cli/cmd_ha.go @@ -0,0 +1,180 @@ +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Home Assistant verbs cover the two things the `ha` MCP server can't: resolving +// the long-lived API token out of the cluster, and SSH to the HA host for +// host-level work (config files, docker, add-ons). Entity state/control stays +// with the MCP — see docs/adr/0012. +// +// The token lives in a k8s Secret (a JSON blob of several skill tokens), the +// same place the openclaw agent reads it from. `ha token` resolves it on demand +// via the ambient kubeconfig, so it never depends on a pre-set env var (the gap +// that made agents re-derive the kubectl|base64|jq pipeline every session). + +type haInstance struct { + name string // sofia | london + sshUser string // SSH login on the HA host + sshHost string // host reachable from the devvm (Sofia LAN) + secretKey string // key inside skill_secrets holding this instance's token +} + +const ( + haDefaultInstance = "sofia" + haSecretNamespace = "openclaw" + haSecretName = "openclaw-secrets" + haSecretField = "skill_secrets" // a base64 JSON blob: {token-name: token} +) + +// haInstances maps instance name → connection/secret facts. sofia is the default +// because the devvm is on the Sofia LAN; london is documented but its host +// (192.168.8.x) is only reachable remotely, so `ha ssh --instance london` +// generally won't connect from here (token resolution still works). +var haInstances = map[string]haInstance{ + "sofia": {name: "sofia", sshUser: "vbarzin", sshHost: "192.168.1.8", secretKey: "home_assistant_sofia_token"}, + "london": {name: "london", sshUser: "hassio", sshHost: "192.168.8.103", secretKey: "home_assistant_token"}, +} + +func haCommands() []Command { + return []Command{ + {Path: []string{"ha", "token"}, Tier: TierRead, + Summary: "reveal the HA long-lived API token from the cluster: ha token [--instance sofia|london]", Run: haToken}, + {Path: []string{"ha", "ssh"}, Tier: TierWrite, + Summary: "run a command on the HA host over ssh: ha ssh [--instance sofia|london] [-i KEY] -- ", Run: haSSH}, + } +} + +// resolveHAInstance looks up an instance by name; "" yields the default (sofia). +func resolveHAInstance(name string) (haInstance, error) { + if name == "" { + name = haDefaultInstance + } + inst, ok := haInstances[name] + if !ok { + return haInstance{}, fmt.Errorf("unknown HA instance %q (want sofia or london)", name) + } + return inst, nil +} + +// parseSkillSecret decodes the base64 skill_secrets blob (as returned by kubectl +// jsonpath, trailing whitespace tolerated) and returns the value for key. +func parseSkillSecret(b64, key string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) + if err != nil { + return "", fmt.Errorf("decode %s: %w", haSecretField, err) + } + var m map[string]string + if err := json.Unmarshal(raw, &m); err != nil { + return "", fmt.Errorf("parse %s json: %w", haSecretField, err) + } + v, ok := m[key] + if !ok { + return "", fmt.Errorf("key %q not present in %s", key, haSecretField) + } + return v, nil +} + +func haToken(args []string) error { + name, _ := firstPositional(args) // accept `ha token sofia` as well as `--instance sofia` + for i := 0; i < len(args); i++ { + if args[i] == "--instance" && i+1 < len(args) { + name = args[i+1] + } else if strings.HasPrefix(args[i], "--instance=") { + name = strings.TrimPrefix(args[i], "--instance=") + } + } + inst, err := resolveHAInstance(name) + if err != nil { + return err + } + b64, err := kubectlCapture(haSecretNamespace, "get", "secret", haSecretName, + "-o", "jsonpath={.data."+haSecretField+"}") + if err != nil { + return fmt.Errorf("read secret %s/%s (kubeconfig set?): %w", haSecretNamespace, haSecretName, err) + } + if b64 == "" { + return fmt.Errorf("secret %s/%s has no %q field", haSecretNamespace, haSecretName, haSecretField) + } + tok, err := parseSkillSecret(b64, inst.secretKey) + if err != nil { + return err + } + fmt.Println(tok) + return nil +} + +// defaultHAKeyPath is the invoking user's ed25519 key, so the verb is per-user +// rather than tied to whoever first wrote the workflow. +func defaultHAKeyPath() string { + if home, err := os.UserHomeDir(); err == nil && home != "" { + return filepath.Join(home, ".ssh", "id_ed25519") + } + return filepath.Join("~", ".ssh", "id_ed25519") +} + +// parseHASSH reads `[--instance X] [-i|--key PATH] [-- ] `. Tokens after +// `--` are taken verbatim; bare tokens before it are also the remote command. +func parseHASSH(args []string) (inst haInstance, keyPath string, remote []string, err error) { + name := haDefaultInstance + keyPath = defaultHAKeyPath() + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--": + remote = append(remote, args[i+1:]...) + i = len(args) + case a == "--instance": + if i+1 < len(args) { + name = args[i+1] + i++ + } + case strings.HasPrefix(a, "--instance="): + name = strings.TrimPrefix(a, "--instance=") + case a == "--key" || a == "-i": + if i+1 < len(args) { + keyPath = args[i+1] + i++ + } + case strings.HasPrefix(a, "--key="): + keyPath = strings.TrimPrefix(a, "--key=") + default: + remote = append(remote, a) + } + } + inst, err = resolveHAInstance(name) + return inst, keyPath, remote, err +} + +// buildHASSHArgs assembles deterministic, non-interactive ssh args: an explicit +// key, no user ssh config, and no known_hosts prompt/record — so it runs +// unattended in an agent session without hanging on a host-key prompt. +func buildHASSHArgs(inst haInstance, keyPath string, remote []string) []string { + args := []string{ + "-F", "/dev/null", + "-o", "IdentityFile=" + keyPath, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=10", + "-o", "BatchMode=yes", + inst.sshUser + "@" + inst.sshHost, + } + return append(args, remote...) +} + +func haSSH(args []string) error { + inst, keyPath, remote, err := parseHASSH(args) + if err != nil { + return err + } + if len(remote) == 0 { + return fmt.Errorf(`usage: homelab ha ssh [--instance sofia|london] [-i KEY] -- `) + } + return runStreaming("ssh", buildHASSHArgs(inst, keyPath, remote)...) +} diff --git a/cli/cmd_ha_test.go b/cli/cmd_ha_test.go new file mode 100644 index 00000000..7493b26e --- /dev/null +++ b/cli/cmd_ha_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "encoding/base64" + "reflect" + "strings" + "testing" +) + +func TestResolveHAInstance(t *testing.T) { + // empty defaults to sofia (the devvm sits on the Sofia LAN) + if got, err := resolveHAInstance(""); err != nil || got.name != "sofia" { + t.Fatalf(`resolveHAInstance("") = %+v, %v; want sofia`, got, err) + } + if got, err := resolveHAInstance("sofia"); err != nil || got.secretKey != "home_assistant_sofia_token" { + t.Fatalf("sofia secretKey = %q, %v", got.secretKey, err) + } + if got, err := resolveHAInstance("london"); err != nil || got.secretKey != "home_assistant_token" || got.sshUser != "hassio" { + t.Fatalf("london = %+v, %v", got, err) + } + if _, err := resolveHAInstance("paris"); err == nil { + t.Fatalf("resolveHAInstance(paris) should error on unknown instance") + } +} + +func TestParseSkillSecret(t *testing.T) { + blob := base64.StdEncoding.EncodeToString([]byte( + `{"home_assistant_sofia_token":"tok-sofia","home_assistant_token":"tok-london","slack_webhook":"https://x"}`)) + + if got, err := parseSkillSecret(blob, "home_assistant_sofia_token"); err != nil || got != "tok-sofia" { + t.Fatalf("parseSkillSecret sofia = %q, %v; want tok-sofia", got, err) + } + // kubectl jsonpath output can carry trailing whitespace/newline — must tolerate it + if got, err := parseSkillSecret(blob+"\n", "home_assistant_token"); err != nil || got != "tok-london" { + t.Fatalf("parseSkillSecret london (trailing ws) = %q, %v; want tok-london", got, err) + } + if _, err := parseSkillSecret(blob, "missing_key"); err == nil { + t.Fatalf("parseSkillSecret should error on a key absent from the blob") + } + if _, err := parseSkillSecret("not-base64!!", "home_assistant_sofia_token"); err == nil { + t.Fatalf("parseSkillSecret should error on undecodable base64") + } +} + +func TestBuildHASSHArgs(t *testing.T) { + inst, _ := resolveHAInstance("sofia") + got := buildHASSHArgs(inst, "/home/u/.ssh/id_ed25519", []string{"cat", "/config/configuration.yaml"}) + want := []string{ + "-F", "/dev/null", + "-o", "IdentityFile=/home/u/.ssh/id_ed25519", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=10", + "-o", "BatchMode=yes", + "vbarzin@192.168.1.8", + "cat", "/config/configuration.yaml", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("buildHASSHArgs =\n %v\nwant\n %v", got, want) + } +} + +func TestParseHASSH(t *testing.T) { + // instance flag + everything after `--` is the verbatim remote command + inst, key, remote, err := parseHASSH([]string{"--instance", "sofia", "--", "docker", "ps", "-a"}) + if err != nil { + t.Fatalf("parseHASSH err: %v", err) + } + if inst.name != "sofia" { + t.Errorf("instance = %q, want sofia", inst.name) + } + if !strings.HasSuffix(key, "/.ssh/id_ed25519") { + t.Errorf("default key = %q, want it to end in /.ssh/id_ed25519", key) + } + if !reflect.DeepEqual(remote, []string{"docker", "ps", "-a"}) { + t.Errorf("remote = %v, want [docker ps -a]", remote) + } + + // bare args (no `--`) are also taken as the remote command; -i overrides the key + _, key2, remote2, err := parseHASSH([]string{"-i", "/tmp/k", "uptime"}) + if err != nil { + t.Fatalf("parseHASSH err: %v", err) + } + if key2 != "/tmp/k" { + t.Errorf("key = %q, want /tmp/k", key2) + } + if !reflect.DeepEqual(remote2, []string{"uptime"}) { + t.Errorf("remote = %v, want [uptime]", remote2) + } + + // unknown instance surfaces as an error + if _, _, _, err := parseHASSH([]string{"--instance", "paris", "--", "ls"}); err == nil { + t.Errorf("parseHASSH should error on unknown instance") + } +} diff --git a/cli/homelab.go b/cli/homelab.go index 350b081f..fb12a169 100644 --- a/cli/homelab.go +++ b/cli/homelab.go @@ -21,6 +21,7 @@ func buildRegistry() []Command { reg = append(reg, netCommands()...) reg = append(reg, obsCommands()...) reg = append(reg, usageCommands()...) + reg = append(reg, haCommands()...) return reg } diff --git a/docs/adr/0012-homelab-ha-verbs.md b/docs/adr/0012-homelab-ha-verbs.md new file mode 100644 index 00000000..465a3014 --- /dev/null +++ b/docs/adr/0012-homelab-ha-verbs.md @@ -0,0 +1,46 @@ +# homelab Home Assistant verbs: token resolution + host SSH, not entity control + +v0.7 adds `ha token` and `ha ssh`. They were chosen by mining a heavy HA +operator's sessions: across ~1,900 shell commands the single most-repeated line +(420×) was a hand-rolled `kubectl … | base64 -d | python -c '…token'` pipeline, +and a bespoke `ssh -o StrictHostKeyChecking=no -o …` invocation was redefined as +a shell function ~30× — both re-derived from scratch every session. The existing +`home-assistant-sofia.py` already covers the *API*, but it goes unused from an +arbitrary cwd (it needs `HOME_ASSISTANT_SOFIA_TOKEN` set and is referenced by a +cwd-relative path), so agents bypassed it. A global verb on `$PATH` closes that +gap for every user in every directory. + +## Decisions + +- **Only the two gaps the `ha` MCP can't fill.** The `ha` MCP server already + does entity state and control (`get_state`, `call_service`, history, logs). + Per the CLI's founding rule — *MCP-encoded actions are out of scope* (ADR-0004) + — we do **not** reimplement `on`/`off`/`list`/`state`. We add only token + *resolution* and host *SSH*, neither of which an API-only MCP can provide. The + value is endpoint/secret/host resolution, exactly like `net`/`dns` (ADR-0010). +- **`ha token` resolves live from the cluster, not from an env var.** It reads + k8s Secret `openclaw/openclaw-secrets`, field `skill_secrets` (a base64 JSON + blob of several tokens), and prints the per-instance key + (`home_assistant_sofia_token` / `home_assistant_token`) via the ambient + kubeconfig. This is robust to env drift — the precise failure that made agents + re-derive the pipeline. Read-tier, prints the bare token to stdout so it + composes in `$(…)`, mirroring `memory secret`. +- **`ha ssh` is deterministic and per-user.** Flags are fixed for unattended + use: `-F /dev/null` (ignore user ssh-config), `StrictHostKeyChecking=no` + + `UserKnownHostsFile=/dev/null` (no host-key prompt/record — agents have no + TTY), `BatchMode=yes` + `ConnectTimeout=10` (fail fast, never hang). The key + is the **invoking user's** `~/.ssh/id_ed25519`, so the verb isn't tied to + whoever first wrote the workflow; that user's key must be enrolled on the HA + host. Write-tier (runs an arbitrary remote command). +- **sofia is the default; london is structural.** The devvm sits on the Sofia + LAN, so `vbarzin@192.168.1.8` is reachable and is the default instance. london + (`hassio@192.168.8.103`) is in the instance map so `ha token --instance london` + works (a pure secret read), but `ha ssh --instance london` generally won't + connect from here — london is remote. We model it correctly rather than + pretend it's reachable. +- **Scope held at two verbs.** `ha api` (an authenticated curl passthrough for + the endpoints the MCP/script don't cover — `/api/template`, `/reload`, + `check_config`, `/error_log`) was deferred: once `ha token` exists, raw curl is + already unblocked, and a generic passthrough overlaps the MCP. Re-measure via + `usage top` (ADR-0011); add targeted sugar verbs only if those endpoints are + still hand-rolled often.