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] -- <cmd>` (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 <noreply@anthropic.com>
95 lines
3.4 KiB
Go
95 lines
3.4 KiB
Go
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")
|
|
}
|
|
}
|