diff --git a/.claude/agents/hexagonal-layer-reviewer.md b/.claude/agents/hexagonal-layer-reviewer.md new file mode 100644 index 0000000..d9b457d --- /dev/null +++ b/.claude/agents/hexagonal-layer-reviewer.md @@ -0,0 +1,50 @@ +--- +name: hexagonal-layer-reviewer +description: Use proactively after changes to internal/core, internal/app, or internal/adapters in ha-gateway, ai-gateway, or discord-bot to check that hexagonal dependency direction wasn't violated. Also invoke on request ("check layering", "did I break the architecture"). +tools: Read, Grep, Glob, Bash +--- + +You review Go import graphs in this repo against its hexagonal architecture rule: +**dependencies point inward only.** + +``` +adapters/primary ─┐ +adapters/secondary ─┼─→ app ─→ core/domain + │ core/ports (driving, driven) +cmd/ ──┘ (wiring only — allowed to import everything) +``` + +## Rules to check + +1. **`internal/core/domain` and `internal/core/ports`** must not import anything from + `internal/app` or `internal/adapters`. These packages define the domain and the + interfaces — they should have almost no internal imports at all. +2. **`internal/app`** must not import `internal/adapters/*` directly. It may only depend + on `internal/core/domain` and `internal/core/ports`. Adapters are injected as + interface values (driven ports) through constructors — `app` should never construct + or reference a concrete adapter type. +3. **`internal/adapters/primary/*`** (gRPC servers, Discord handlers) may import + `internal/app` and `internal/core`, but should not import + `internal/adapters/secondary/*` directly — that dependency should flow through `app`. +4. **`internal/adapters/secondary/*`** should only implement `core/ports/driven` + interfaces and depend on `core/domain`; it should not import `internal/app` or + `internal/adapters/primary/*`. +5. Only `cmd//main.go` is allowed to import across all of the above to wire + the dependency graph together. + +## How to check + +For each changed `.go` file (or the whole service if asked generally): + +```bash +grep -n "gitea.nik4nao.com/nik/home-services//internal" +``` + +Compare the importing package's path against the rules above. `go list -deps` can also +confirm a suspicious transitive dependency if a direct grep is ambiguous. + +## Report format + +List violations as `file:line — imports X from Y, violates rule `. If a file being +reviewed is `cmd/**/main.go`, skip it — wiring code is exempt by design. If nothing +violates the rules, say so in one line; don't enumerate every clean import. diff --git a/.claude/agents/internal-exposure-reviewer.md b/.claude/agents/internal-exposure-reviewer.md new file mode 100644 index 0000000..48017ea --- /dev/null +++ b/.claude/agents/internal-exposure-reviewer.md @@ -0,0 +1,43 @@ +--- +name: internal-exposure-reviewer +description: Use proactively when changes touch gRPC server setup, TLS_DIR/mTLS handling, listen addresses, reflection/health registration, or anything handling HA_TOKEN/DISCORD_TOKEN, across ha-gateway, ai-gateway, or discord-bot. Also invoke on request for a security pass before deploying a service change. +tools: Read, Grep, Glob +--- + +You review changes against this repo's one documented, repo-wide security limitation: +**none of the three services (`ha-gateway`, `ai-gateway`, `discord-bot`) implement +app-layer authorization.** Every README says the same thing almost verbatim — they rely +entirely on staying on a trusted internal network, or on mTLS via `TLS_DIR`. Your job is +to catch anything that quietly widens what's reachable or weakens that boundary. + +## What to look for + +1. **New or changed listen bindings.** Any change to how `GRPC_PORT` is bound — flag + binding to `0.0.0.0` or an external interface where it previously bound loopback-only, + or removing the existing bind logic entirely. +2. **`TLS_DIR` / mTLS handling.** Any change to the code paths in + `internal/adapters/primary/grpc/interceptor.go` or server setup in `cmd/*/main.go` + that reads `TLS_DIR` — flag anything that makes mTLS optional where it was previously + required, weakens client cert verification, or adds a plaintext fallback that wasn't + there before. +3. **Reflection and health registration.** `ha-gateway` always registers gRPC reflection; + `ai-gateway` only does so when `LOG_LEVEL=debug`. Flag any change that registers + reflection unconditionally in `ai-gateway`, or exposes new debug-only surface without + a similar guard. +4. **New RPCs that skip the interceptor chain.** Every RPC should go through the same + auth/telemetry interceptor setup as existing ones — flag a new service registration + that bypasses `interceptor.go`. +5. **Token handling.** `HA_TOKEN` (ha-gateway), `DISCORD_TOKEN` (discord-bot), and any + Ollama credentials should never be logged, echoed back in a gRPC response/error + message, or written anywhere outside `internal/config`. Flag any `log.*`/`slog.*` call + or returned error that could include a token value, and any new field that echoes + request-supplied credentials back to the caller. +6. **New inbound endpoints without corresponding docs.** If a new RPC is genuinely + internet- or cross-trust-boundary-facing (not just internal service-to-service), + flag that this repo's model assumes a trusted network and ask whether that still + holds. + +## Report format + +List findings as `file:line — `, ordered most severe +first. If nothing in the diff touches these areas, say so in one line. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..17f9b11 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,19 @@ +{ + "hooks": { + "PreToolUse": [ + { + "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; }" + }, + { + "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; }" + } + ] + } + ] + } +} diff --git a/.claude/skills/add-rpc/SKILL.md b/.claude/skills/add-rpc/SKILL.md new file mode 100644 index 0000000..f93e6a3 --- /dev/null +++ b/.claude/skills/add-rpc/SKILL.md @@ -0,0 +1,68 @@ +--- +name: add-rpc +description: Scaffold a new gRPC RPC end-to-end (proto, buf generate, hexagonal ports/app/adapter layers, tests, README) following this repo's established pattern across ha-gateway, ai-gateway, and discord-bot. +--- + +# Add a gRPC RPC + +Use this whenever a new RPC or field is being added to `ha-gateway`, `ai-gateway`, or a new +gRPC client call is being added to `discord-bot`. All three services share the same +hexagonal layering, so follow this order — do not skip layers or reach across them. + +## Steps + +1. **Identify the target service and proto package.** + - `ha-gateway` → `proto/ha/v1/*.proto` (packages: `entity`, `light`, `switch`, `event`, `common`) + - `ai-gateway` → `proto/ai/v1/ai.proto` + - `discord-bot` has no proto of its own; it only consumes `ha.v1` / `ai.v1` clients. + +2. **Edit the `.proto` file** under `proto//v1/`. Add the message/RPC there first — + it is the source of truth; `gen/` is derived from it. + +3. **Regenerate generated code:** + ```bash + buf generate + ``` + This writes into `gen//v1/`. Never hand-edit files under `gen/`. + +4. **Extend the port interface** in `internal/core/ports/`: + - `ports/driving/*.go` — the interface the gRPC adapter calls *into* (app-facing, inbound). + - `ports/driven/*.go` — the interface the app calls *out* to (HA REST, Ollama, ha-gateway + client, etc.), if the new RPC needs a new external call. + - `core/domain/*.go` — add/extend domain types here, not in adapter or proto types. + +5. **Implement orchestration in `internal/app/`.** This is where business logic and + validation live — it depends only on `core/domain` and `core/ports`, never on + `adapters/*` types directly. Look at an existing file (e.g. `ha-gateway/internal/app/light.go`) + for the shape: a struct holding driven-port dependencies, constructed via `NewXApp(...)`. + +6. **Wire the primary adapter** in `internal/adapters/primary/grpc/.go`. Implement + the generated server interface method, translate between proto types and domain types + (see `mapping.go` in ha-gateway for the existing translation pattern), and call the app + layer. Keep proto <-> domain conversion here, not in `app`. + +7. **If the RPC needs a new outbound call**, implement it in the relevant + `internal/adapters/secondary//client.go`, implementing the `driven` port interface + from step 4. + +8. **Add a test** using the repo's convention: plain `testing` package (no testify), with a + hand-written mock struct that implements the driven port interface using function fields + (see `ha-gateway/internal/app/entity_test.go` or `ai-gateway/internal/app/query_test.go` + for the pattern). Table-driven `t.Run` subtests are the norm. + +9. **Update the service's `README.md`:** + - Move the RPC between "Implemented" and "Stubbed" in the gRPC API section if its status + changed. + - Add any new environment variables to the Configuration table (and to `.env.example`). + - Add any new package to the Package Map section if you created one. + +10. **Verify:** + ```bash + cd && go vet ./... && go test ./... + ``` + +## Layering rule to enforce + +Dependencies point inward only: `adapters` → `app` → `core`. Never import +`internal/adapters/*` from `internal/app` or `internal/core`. Only `cmd//main.go` +is allowed to import across all layers to wire dependencies together. diff --git a/.claude/skills/readme-drift-check/SKILL.md b/.claude/skills/readme-drift-check/SKILL.md new file mode 100644 index 0000000..2c2b6e4 --- /dev/null +++ b/.claude/skills/readme-drift-check/SKILL.md @@ -0,0 +1,58 @@ +--- +name: readme-drift-check +description: Audit each service README (ha-gateway, ai-gateway, discord-bot) against the actual code — env var tables vs config.go, Implemented/Stubbed RPC lists vs proto and adapter code, and Package Map vs the real directory tree. Reports drift without editing anything. +disable-model-invocation: true +--- + +# README Drift Check + +This repo documents contracts by hand in three READMEs (`ha-gateway/README.md`, +`ai-gateway/README.md`, `discord-bot/README.md`), and those have gone stale before +(e.g. `OLLAMA_TIMEOUT`'s default needed a manual doc update after a config change). This +skill finds the next drift before it ships. **Read-only** — report findings, do not edit +README or code unless the user asks you to apply a fix afterward. + +## Checks, per service + +### 1. Environment variable table vs `internal/config/config.go` + +- Read the Configuration table in the service's README. +- Read `internal/config/config.go` and find every env var read (`os.Getenv`, + `os.LookupEnv`, or equivalent helper) plus its default. +- Flag: vars in code missing from the README table, vars in the README table no longer + read in code, and default values that don't match. + +### 2. Implemented / Stubbed RPC list vs actual server code + +- Read the gRPC API section's Implemented/Stubbed split in the README. +- Read the corresponding `proto//v1/*.proto` for the full RPC list. +- Read `internal/adapters/primary/grpc/*.go` and check each method body: a method that + just returns `status.Errorf(codes.Unimplemented, ...)` (or equivalent) is Stubbed; + anything else is Implemented. +- Flag: any RPC whose README status doesn't match what the code actually does, and any + RPC in the proto missing from the README entirely. + +### 3. Package Map vs real directory tree + +- Read the Package Map section in the README. +- Run `find /internal -type d | sort` (and `cmd/`) and compare. +- Flag: directories that exist but aren't documented, and documented paths that no longer + exist. + +## Output + +Report per service, grouped by check, e.g.: + +``` +## ha-gateway +### Env vars +- MISSING FROM README: `NEW_VAR` (default "x") — read in config.go:42 +### RPC status +- README says `SwitchService.TurnOn` is Stubbed, but internal/adapters/primary/grpc/switch.go:18 + now implements it — README is stale. +### Package map +- OK +``` + +If everything matches for a service, say so briefly rather than listing every checked +item — don't pad the report with confirmations of things that are fine. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3c5a858 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,117 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Repo Overview + +`home-services` is a Go workspace of three internal services for home control, connected by gRPC and sharing committed protobuf-generated code: + +```text +Discord users + | + v +discord-bot -----> ha-gateway -----> Home Assistant REST API + | + v +ai-gateway ------> Ollama + | + v +ha-gateway +``` + +- **ha-gateway** (port `50051`) — gRPC boundary for Home Assistant. Talks to HA's REST API; implements entity state and light control/discovery and switch discovery; switch control and event streaming are stubbed. +- **ai-gateway** (port `50052`) — gRPC service that turns free-form text into home actions. Calls Ollama for intent extraction, resolves intents against a cached light list from `ha-gateway`, and calls `ha-gateway` to execute approved actions. +- **discord-bot** — registers `/light`, `/switch`, `/ai` slash commands and calls `ha-gateway`/`ai-gateway` via gRPC clients. + +Each service is a separate Go module (own `go.mod`) joined by `go.work` at the root, plus a `gen` module for shared generated code. Module paths are `gitea.nik4nao.com/nik/home-services/{ha-gateway,ai-gateway,discord-bot,gen}`. + +## Architecture (hexagonal, per service) + +Every service follows the same internal layout, with dependencies pointing inward: + +```text +cmd// # process entrypoint and wiring (loads .env, builds adapters, starts gRPC) +internal/adapters/primary/ # inbound edges: gRPC servers, Discord handlers +internal/adapters/secondary/ # outbound edges: HA REST client, Ollama client, ha-gateway/ai-gateway gRPC clients +internal/app/ # use-case orchestration +internal/core/domain/ # domain types +internal/core/ports/ # driving (inbound) and driven (outbound) interfaces +internal/config/ # environment loading +internal/logger/ # slog setup +internal/telemetry/ # OpenTelemetry setup +``` + +`internal/core` has no dependency on adapters — ports are interfaces that adapters implement (driven) or call into (driving). When adding a capability, the usual path is: define/extend a port in `core/ports`, implement orchestration in `app`, then wire an adapter in `adapters/primary` or `adapters/secondary`. + +Protobuf contracts live in `proto/` (buf module, `ai/v1` and `ha/v1` packages). Generated Go code is committed under `gen/` and consumed by all three services through the Go workspace — do not hand-edit files in `gen/`. + +## Common Commands + +Regenerate protobuf code after changing anything under `proto/` (requires `buf`): + +```bash +buf generate +``` + +Run tests / vet (from repo root, or `cd` into a service and drop the prefix): + +```bash +go test ./ha-gateway/... ./ai-gateway/... ./discord-bot/... +go vet ./ha-gateway/... ./ai-gateway/... ./discord-bot/... +``` + +Run a single test: + +```bash +cd ha-gateway && go test ./internal/app/... -run TestEntityAppGetState +``` + +Build binaries: + +```bash +go build ./ha-gateway/... ./ai-gateway/... ./discord-bot/... +``` + +Run a service locally (each loads `.env` from its own working directory via `godotenv`, so `cd` into the service dir first): + +```bash +cd ha-gateway && cp .env.example .env && go run ./cmd/gateway +cd ai-gateway && cp .env.example .env && go run ./cmd/gateway +cd discord-bot && cp .env.example .env && go run ./cmd/bot +``` + +For local plaintext dev, point gateways at each other with `TLS_DIR` empty, e.g. `HA_GATEWAY_ADDR=localhost:50051`, `AI_GATEWAY_ADDR=localhost:50052`. + +Build container images (from repo root, since Dockerfiles reference the whole workspace): + +```bash +docker build -f ha-gateway/Dockerfile -t ha-gateway:dev . +docker build -f ai-gateway/Dockerfile -t ai-gateway:dev . +docker build -f discord-bot/Dockerfile -t discord-bot:dev . +``` + +gRPC smoke checks (with the relevant service running): + +```bash +grpcurl -plaintext -d '{"domain":"light"}' localhost:50051 ha.v1.EntityService/ListStates +grpcurl -plaintext -d '{"entity_id":"light.living_room","brightness_pct":80}' localhost:50051 ha.v1.LightService/TurnOn +grpcurl -plaintext -d '{"text":"turn on the desk lamp","source":"local"}' localhost:50052 ai.v1.AIService/Query +grpcurl -plaintext -d '{}' localhost:50052 ai.v1.AIService/ListModels +``` + +Note: `ha-gateway` always registers gRPC reflection; `ai-gateway` only registers reflection when `LOG_LEVEL=debug`. + +## Testing Conventions + +Tests use the standard library `testing` package only (no testify). Mocks are hand-written structs implementing the relevant `core/ports/driven` interface with function fields (see `ha-gateway/internal/app/entity_test.go` for the pattern). + +## Configuration Notes + +- Every service reads `TLS_DIR` to enable optional mTLS; when set, the directory must contain `tls.crt`, `tls.key`, and `ca.crt`. +- `OTEL_ENDPOINT` enables OTLP gRPC traces/metrics; leave empty for local no-op telemetry. +- `LOG_FORMAT=json` is the production default; `text` is easier to read locally. +- None of the services implement app-layer authorization — they rely on being kept on a trusted internal network or on mTLS. Keep this in mind before adding any endpoint that wasn't previously reachable. + +## CI + +`.gitea/workflows/ci.yaml` runs `go vet` and `go test` for all four modules (`gen`, `ai-gateway`, `ha-gateway`, `discord-bot`), then builds and pushes Docker images for the three services on pushes to `main`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ae1bf5c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,117 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repo Overview + +`home-services` is a Go workspace of three internal services for home control, connected by gRPC and sharing committed protobuf-generated code: + +```text +Discord users + | + v +discord-bot -----> ha-gateway -----> Home Assistant REST API + | + v +ai-gateway ------> Ollama + | + v +ha-gateway +``` + +- **ha-gateway** (port `50051`) — gRPC boundary for Home Assistant. Talks to HA's REST API; implements entity state and light control/discovery and switch discovery; switch control and event streaming are stubbed. +- **ai-gateway** (port `50052`) — gRPC service that turns free-form text into home actions. Calls Ollama for intent extraction, resolves intents against a cached light list from `ha-gateway`, and calls `ha-gateway` to execute approved actions. +- **discord-bot** — registers `/light`, `/switch`, `/ai` slash commands and calls `ha-gateway`/`ai-gateway` via gRPC clients. + +Each service is a separate Go module (own `go.mod`) joined by `go.work` at the root, plus a `gen` module for shared generated code. Module paths are `gitea.nik4nao.com/nik/home-services/{ha-gateway,ai-gateway,discord-bot,gen}`. + +## Architecture (hexagonal, per service) + +Every service follows the same internal layout, with dependencies pointing inward: + +```text +cmd// # process entrypoint and wiring (loads .env, builds adapters, starts gRPC) +internal/adapters/primary/ # inbound edges: gRPC servers, Discord handlers +internal/adapters/secondary/ # outbound edges: HA REST client, Ollama client, ha-gateway/ai-gateway gRPC clients +internal/app/ # use-case orchestration +internal/core/domain/ # domain types +internal/core/ports/ # driving (inbound) and driven (outbound) interfaces +internal/config/ # environment loading +internal/logger/ # slog setup +internal/telemetry/ # OpenTelemetry setup +``` + +`internal/core` has no dependency on adapters — ports are interfaces that adapters implement (driven) or call into (driving). When adding a capability, the usual path is: define/extend a port in `core/ports`, implement orchestration in `app`, then wire an adapter in `adapters/primary` or `adapters/secondary`. + +Protobuf contracts live in `proto/` (buf module, `ai/v1` and `ha/v1` packages). Generated Go code is committed under `gen/` and consumed by all three services through the Go workspace — do not hand-edit files in `gen/`. + +## Common Commands + +Regenerate protobuf code after changing anything under `proto/` (requires `buf`): + +```bash +buf generate +``` + +Run tests / vet (from repo root, or `cd` into a service and drop the prefix): + +```bash +go test ./ha-gateway/... ./ai-gateway/... ./discord-bot/... +go vet ./ha-gateway/... ./ai-gateway/... ./discord-bot/... +``` + +Run a single test: + +```bash +cd ha-gateway && go test ./internal/app/... -run TestEntityAppGetState +``` + +Build binaries: + +```bash +go build ./ha-gateway/... ./ai-gateway/... ./discord-bot/... +``` + +Run a service locally (each loads `.env` from its own working directory via `godotenv`, so `cd` into the service dir first): + +```bash +cd ha-gateway && cp .env.example .env && go run ./cmd/gateway +cd ai-gateway && cp .env.example .env && go run ./cmd/gateway +cd discord-bot && cp .env.example .env && go run ./cmd/bot +``` + +For local plaintext dev, point gateways at each other with `TLS_DIR` empty, e.g. `HA_GATEWAY_ADDR=localhost:50051`, `AI_GATEWAY_ADDR=localhost:50052`. + +Build container images (from repo root, since Dockerfiles reference the whole workspace): + +```bash +docker build -f ha-gateway/Dockerfile -t ha-gateway:dev . +docker build -f ai-gateway/Dockerfile -t ai-gateway:dev . +docker build -f discord-bot/Dockerfile -t discord-bot:dev . +``` + +gRPC smoke checks (with the relevant service running): + +```bash +grpcurl -plaintext -d '{"domain":"light"}' localhost:50051 ha.v1.EntityService/ListStates +grpcurl -plaintext -d '{"entity_id":"light.living_room","brightness_pct":80}' localhost:50051 ha.v1.LightService/TurnOn +grpcurl -plaintext -d '{"text":"turn on the desk lamp","source":"local"}' localhost:50052 ai.v1.AIService/Query +grpcurl -plaintext -d '{}' localhost:50052 ai.v1.AIService/ListModels +``` + +Note: `ha-gateway` always registers gRPC reflection; `ai-gateway` only registers reflection when `LOG_LEVEL=debug`. + +## Testing Conventions + +Tests use the standard library `testing` package only (no testify). Mocks are hand-written structs implementing the relevant `core/ports/driven` interface with function fields (see `ha-gateway/internal/app/entity_test.go` for the pattern). + +## Configuration Notes + +- Every service reads `TLS_DIR` to enable optional mTLS; when set, the directory must contain `tls.crt`, `tls.key`, and `ca.crt`. +- `OTEL_ENDPOINT` enables OTLP gRPC traces/metrics; leave empty for local no-op telemetry. +- `LOG_FORMAT=json` is the production default; `text` is easier to read locally. +- None of the services implement app-layer authorization — they rely on being kept on a trusted internal network or on mTLS. Keep this in mind before adding any endpoint that wasn't previously reachable. + +## CI + +`.gitea/workflows/ci.yaml` runs `go vet` and `go test` for all four modules (`gen`, `ai-gateway`, `ha-gateway`, `discord-bot`), then builds and pushes Docker images for the three services on pushes to `main`.