diff --git a/.claude/settings.json b/.claude/settings.json index 17f9b11..d6188bd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,16 +1,30 @@ { "hooks": { "PreToolUse": [ + { + "matcher": "Edit|Write|Read", + "hooks": [ + { + "type": "command", + "command": "jq -r '.tool_input.file_path // empty' | { read -r f; if [[ \"$f\" == *.env && \"$f\" != *.env.example ]]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Refusing to read/edit a real .env file - it holds live secrets (HA_TOKEN, DISCORD_TOKEN, SwitchBot token/secret, etc). Use the corresponding .env.example instead.\"}}'; fi; }" + } + ] + }, { "matcher": "Edit|Write", "hooks": [ { "type": "command", - "command": "jq -r '.tool_input.file_path // empty' | { read -r f; if [[ \"$f\" == *.env && \"$f\" != *.env.example ]]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Refusing to edit a real .env file - it holds live secrets (HA_TOKEN, DISCORD_TOKEN). Edit the corresponding .env.example instead.\"}}'; fi; }" - }, + "command": "jq -r '.tool_input.file_path // empty' | { read -r f; if [[ \"$f\" == */gen/* ]]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"gen/ is committed buf-generated protobuf/gRPC code. Edit the source .proto file under proto/ and run buf generate instead.\"}}'; fi; }" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ { "type": "command", - "command": "jq -r '.tool_input.file_path // empty' | { read -r f; if [[ \"$f\" == */gen/* ]]; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"gen/ is committed buf-generated protobuf/gRPC code. Edit the source .proto file under proto/ and run buf generate instead.\"}}'; fi; }" + "command": "jq -r '.tool_input.command // empty' | { cmd=$(cat); reduced=$(echo \"$cmd\" | sed 's/\\.env\\.example//g'); if echo \"$reduced\" | grep -qE '\\.env'; then echo '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"Refusing to run a shell command that references a real .env file - these hold live secrets and must never be read, printed, sourced, or exposed. Use .env.example, or ask the user to check the value directly.\"}}'; fi; }" } ] } diff --git a/.claude/skills/switchbot-control/SKILL.md b/.claude/skills/switchbot-control/SKILL.md new file mode 100644 index 0000000..f7b9eaf --- /dev/null +++ b/.claude/skills/switchbot-control/SKILL.md @@ -0,0 +1,87 @@ +--- +name: switchbot-control +description: Gated SwitchBot Cloud device control via the local reference CLI (tmp/reference/switchbot-control-reference) — turn-on/off, fan/AC/lock commands, and custom IR buttons. Always resolves and inspects the target first, always dry-runs before a real send, always requires explicit user approval before executing. +--- + +# SwitchBot Control (gated) + +This wraps `tmp/reference/switchbot-control-reference/scripts/switchbotctl`, +the full control CLI. Every real control call fires a real signal at real +hardware (an actual IR blast, an actual lock toggle) — treat every non-dry-run +invocation as something you are asking permission to do, not something you +just do. + +## Required sequence, every time + +1. **Resolve the device.** Use the `switchbot-read` skill (`list all` / + `list remotes` / `list switches` / `list locks`) to find the exact + `deviceId` for the device the user named. Never guess an ID or reuse one + from memory without re-confirming it still matches — if more than one + device could match the name, ask which one; never pick one silently. +2. **Inspect the interface.** Run `switchbot-read interface DEVICE_ID` and + confirm the command you're about to send is actually in its documented + `commands` list (or, for a custom button, that the device is an infrared + remote where `customize`-type commands apply). +3. **Dry-run first.** Always construct and check the exact call with + `--dry-run` before ever running it for real: + ```bash + cd tmp/reference/switchbot-control-reference + GOWORK=off ./scripts/switchbotctl ac DEVICE_ID 24 cool medium on --dry-run + ``` + Confirm the echoed `command`/`parameter`/`commandType` in the dry-run + output is exactly what you intend. +4. **Ask for explicit approval** before running the same command without + `--dry-run`. State the exact device (name + ID) and the exact action in + plain language. Do not treat a general "go ahead with the plan" from + earlier in the conversation as approval for a specific real control call — + ask again, per command, right before running it. +5. **Execute once.** Run the approved command exactly once. Do not retry + automatically on failure or on an ambiguous result — a repeated lock, + toggle, Bot press, or custom IR command can produce a real second action + (e.g. re-toggling something back off). If it needs retrying, ask first. +6. **Treat success as API acceptance, not physical confirmation.** SwitchBot + Cloud's infrared control is fire-and-forget — a `200`/`statusCode: 100` + response means the cloud accepted the command, not that the appliance + actually received or acted on the IR signal. + +## Custom IR buttons (`button`) need the user, not a guess + +```bash +GOWORK=off ./scripts/switchbotctl button DEVICE_ID "Exact Button Name" --dry-run +``` + +SwitchBot's API cannot enumerate a device's learned custom button names — +they only exist as case-sensitive labels in the SwitchBot app. Never +approximate, guess, or case-fold a button name. If you don't have the exact +label from the user, stop and ask for it rather than trying a plausible +guess. + +## Command reference (see `tmp/reference/switchbot-control-reference/README.md` for the full list) + +```bash +switchbotctl turn-on DEVICE_ID [--dry-run] +switchbotctl turn-off DEVICE_ID [--dry-run] +switchbotctl press DEVICE_ID [--dry-run] +switchbotctl lock DEVICE_ID [--dry-run] +switchbotctl unlock DEVICE_ID [--dry-run] +switchbotctl fan DEVICE_ID swing|timer|lowSpeed|middleSpeed|highSpeed [--dry-run] +switchbotctl ac DEVICE_ID TEMPERATURE MODE FAN_SPEED POWER [--dry-run] +switchbotctl button DEVICE_ID "Exact Button Name" [--dry-run] +switchbotctl send DEVICE_ID COMMAND PARAMETER COMMAND_TYPE [--dry-run] # generic escape hatch +``` + +Always run from `tmp/reference/switchbot-control-reference/` with +`GOWORK=off` prefixed (this repo's root `go.work` doesn't include this nested +module — see the `switchbot-read` skill for why not to fix that by editing +`go.work`). + +## Hard rules + +- Never read, print, edit, source, or expose `.env` in this directory. +- Never add an automatic hook, cron, or script that calls `switchbotctl` + without a human in the loop for that specific invocation. +- This CLI is a **development/testing aid only** — it is not a dependency of + `ha-gateway`. The actual `RemoteService` implementation in `ha-gateway` has + its own hand-rolled SwitchBot HTTP client (see `plan.md`); use this CLI to + discover device IDs and verify command behavior during development, not as + something the running services call at runtime. diff --git a/.claude/skills/switchbot-read/SKILL.md b/.claude/skills/switchbot-read/SKILL.md new file mode 100644 index 0000000..eaf7a17 --- /dev/null +++ b/.claude/skills/switchbot-read/SKILL.md @@ -0,0 +1,65 @@ +--- +name: switchbot-read +description: Read-only SwitchBot Cloud discovery via the local reference CLI (tmp/reference/switchbot-control-reference) — list devices/infrared remotes and inspect a device's documented command interface. Never sends control commands and never touches .env. +--- + +# SwitchBot Read-Only Discovery + +Use this whenever you need to find a SwitchBot device ID, confirm a device's +type, or see which commands are documented for it — e.g. resolving "the +Dyson" or "the AC" to an exact `deviceId` before building or running any +control call in `ha-gateway`'s `RemoteService`. + +This wraps `tmp/reference/switchbot-control-reference/scripts/switchbot-read`, +a wrapper that only accepts `list` and `interface` — it has no code path that +can send a device command, so it's safe to run freely. + +## Why `GOWORK=off` is required + +This reference tool is nested inside the `home-service` repo but is its own +Go module, not a member of the root `go.work`. Running it from inside the +workspace fails with "directory prefix . does not contain modules listed in +go.work" unless you disable workspace mode for that one subprocess: + +```bash +cd tmp/reference/switchbot-control-reference +GOWORK=off ./scripts/switchbot-read list all +``` + +Do not add this tool to the root `go.work` — that file is committed, `tmp/` +is gitignored, and pointing a committed file at a gitignored path would break +every other clone of this repo. + +## Commands + +```bash +GOWORK=off ./scripts/switchbot-read list all +GOWORK=off ./scripts/switchbot-read list physical +GOWORK=off ./scripts/switchbot-read list remotes # infrared/virtual remotes — Dyson, AC live here +GOWORK=off ./scripts/switchbot-read list switches +GOWORK=off ./scripts/switchbot-read list locks +GOWORK=off ./scripts/switchbot-read interface DEVICE_ID +``` + +Output is JSON: `{filter, count, devices: [...]}` for `list`, or a single +`deviceView` object for `interface`. Each device includes a `commands` array +of documented `{name, parameter, commandType, description}` entries derived +from the device's type — this is a **static catalog**, not something +dynamically read from the appliance, and it does not include learned custom +IR button names (SwitchBot's API has no endpoint for that — see +`CustomButtonNamesDiscoverable: false` on infrared entries). + +## Rules + +1. Never read, print, or expose `tmp/reference/switchbot-control-reference/.env` + yourself — the CLI reads it internally via `os.Getenv`, you never need to. + (A project hook already blocks `Read`/`Edit`/`Write` on real `.env` files + and Bash commands referencing them, but don't rely on the hook — just + don't try.) +2. This skill is discovery-only. If you need to send a command, use the + `switchbot-control` skill instead — never shell out to `switchbotctl` + (the full CLI) from here. +3. Treat `commands` in the output as "what SwitchBot's documented API + supports for this device type," not confirmation the physical appliance + will respond correctly to every listed command — some DIY/customized + remotes reject a documented standard command with error 160. diff --git a/.gitignore b/.gitignore index 7a300db..ff5881b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Environment files — never commit real tokens .env *.env +tmp/ !.env.example # Go build output