diff --git a/.claude/skills/add_generate_strix/SKILL.md b/.claude/skills/add_generate_strix/SKILL.md new file mode 100644 index 0000000..4c8aefb --- /dev/null +++ b/.claude/skills/add_generate_strix/SKILL.md @@ -0,0 +1,224 @@ +--- +name: add_generate_strix +description: Register a new protocol extractor for the Strix config generator. Use when adding support for Tuya, Tapo, Nest, Ring, Roborock and other camera protocols that need credentials in a separate YAML section of frigate-config.yaml. This skill adds the glue; it does NOT add the protocol itself -- that's /add_protocol_strix. +disable-model-invocation: true +argument-hint: [protocol-name] +--- + +# Add Generate Extractor to Strix + +You are registering a new protocol with the Strix config generator so that `/api/generate` can produce a valid `frigate-config.yaml` for cameras of that protocol. + +The protocol name is provided as argument (e.g. `/add_generate_strix tuya`). If no argument, use AskUserQuestion to ask which protocol. + +This skill is NARROW. It adds ONE extractor function plus tests. Do NOT modify `pkg/generate/` internals (registry.go, config.go, writer.go, insert.go) -- they are protocol-agnostic by design. If you are tempted to change them, stop and ask the user. + +## Repositories + +- Strix: current working directory (`/home/user/Strix`) +- go2rtc: `/home/user/go2rtc` (reference for YAML section format) + +--- + +## STEP 0: Study the xiaomi reference + +Before writing anything, read these files completely. Xiaomi is the golden reference for this skill. Every new protocol copies its structure. + +- `internal/xiaomi/xiaomi.go` -- how `extractForConfig` is written and where `generate.RegisterExtract` is called inside `Init()` +- `pkg/generate/xiaomi_test.go` -- the 16 test scenarios that every new protocol must pass +- `pkg/generate/registry.go` -- the `ExtractFunc` contract (do not modify, only understand) + +Then glance at these to understand what NOT to touch: + +- `pkg/generate/config.go` -- how `runExtract` is called for main/sub/go2rtc-override URLs +- `pkg/generate/writer.go` -- how `writeCredentials` nests sections under `go2rtc:` +- `pkg/generate/insert.go` -- `upsertCredentials` merges into existing configs + +--- + +## STEP 1: Study the protocol in go2rtc + +Read these files in the go2rtc repo: + +- `internal//README.md` -- authoritative YAML format +- `internal//.go` -- how go2rtc unmarshals the credentials section (look for `yaml:""` in a struct tag) +- `pkg//` -- URL scheme: what goes into userinfo, what into query, what into path + +You need to answer exactly three questions: + +1. **Where does the secret live in the URL?** + - `?token=X` in query (xiaomi, nest, ring) + - `userinfo` password (tapo, roborock) + - Custom (read the module) + +2. **What is the YAML section name?** Usually the same as the URL scheme (`tuya:`, `tapo:`). Confirm from go2rtc's `yaml:` struct tag. + +3. **What is the YAML key format?** Examples: + - xiaomi: `""` (quoted, matches `ci` field in device) + - tapo: `@` + - roborock: `` + - nest: `""` + +Write these three answers down. They drive the extractor. + +--- + +## STEP 2: Verify the internal module already exists + +Check `internal//.go` exists and has a working `Init()` with `tester.RegisterSource` or similar. If the module does NOT exist, stop and tell the user to run `/add_protocol_strix ` first. This skill only adds the generate hook to an existing module. + +--- + +## STEP 3: Add the extractor to `internal//.go` + +Open `internal//.go`. + +**3a. Add import** (if not present): + +```go +import "github.com/eduard256/strix/pkg/generate" +``` + +**3b. Register the extractor at the END of `Init()`:** + +```go +generate.RegisterExtract("", extractForConfig) +``` + +**3c. Add the function.** Use the exact xiaomi style. Place it directly after `Init()`. + +### Template: secret in query (xiaomi-like) + +```go +// extractForConfig strips ?=... from :// URL and returns +// + for a go2rtc:
: block. +// ex. ://:@?...&=T +// -> ://:@?..., "
", "", "T" +func extractForConfig(rawURL string) (cleaned, section, key, value string) { + u, err := url.Parse(rawURL) + if err != nil || u.User == nil { + return rawURL, "", "", "" + } + + q := u.Query() + token := q.Get("") + if token == "" { + return rawURL, "", "", "" + } + q.Del("") + u.RawQuery = q.Encode() + + return u.String(), "
", u.User.Username(), token +} +``` + +### Template: secret in userinfo password (tapo-like) + +```go +func extractForConfig(rawURL string) (cleaned, section, key, value string) { + u, err := url.Parse(rawURL) + if err != nil || u.User == nil { + return rawURL, "", "", "" + } + + pw, ok := u.User.Password() + if !ok || pw == "" { + return rawURL, "", "", "" + } + + // ex. tapo: key = "admin@192.168.1.100" + key = u.User.Username() + "@" + u.Host + // URL stays as-is -- go2rtc reads credentials from userinfo directly + return rawURL, "
", key, pw +} +``` + +### Rules for the extractor (strict) + +- MUST return `(rawURL, "", "", "")` on any parse error or missing secret. Never return an empty `cleaned`. +- MUST NOT log, MUST NOT touch filesystem, MUST NOT call APIs. +- MUST be deterministic -- same input always returns same output. +- MUST NOT URL-encode the token value. `writeCredentials` emits it raw; YAML parses `V1:abc+/=` fine. +- Keep it under 20 lines. If it grows, you're doing too much. + +--- + +## STEP 4: Write `pkg/generate/_test.go` + +Copy the structure from `pkg/generate/xiaomi_test.go` exactly. Change: + +- `registerXiaomi` -> `register` (use a new `sync.Once` per file -- do NOT share `registerOnce` across files) +- `xurl(...)` -> `url(...)` -- builds a URL in this protocol's format +- Test function names: `TestXiaomi_*` -> `Test_*` +- All string literals that reference `xiaomi://`, `"acc1"`, `V1:TOK_A` -> equivalents for this protocol + +### The 16 tests to write (all MUST pass) + +All scenarios from `xiaomi_test.go` are relevant and must be present: + +| # | Test | What it verifies | +|---|---|---| +| 1 | NewConfig_SingleCamera | Nested `go2rtc:\n
:\n : ` with correct indentation. URL in streams has no secret left. | +| 2 | SameAccount_TokenNotDuplicated | Two cameras, same key -> exactly one entry in the section. | +| 3 | TwoAccounts_SortedKeys | Two keys in the section appear in ASCII-sorted order. | +| 4 | TokenRefresh_OverwritesValue | Re-adding a camera with a new token replaces the stored value, exactly one key remains. | +| 5 | MainAndSub_SameAccount_OneToken | Main + Sub with identical credentials -> one key. | +| 6 | MainAndSub_DifferentAccounts | Main + Sub with two accounts -> two keys. | +| 7 | Scale_10Cameras_3Accounts | 10 cameras across 3 accounts sequentially added -> exactly 3 keys at the end, most-recent values. | +| 8 | URLWithoutToken_NoSection | URL missing the secret -> no `
:` header written (check `"\n
:\n"`, not the URL scheme substring). | +| 9 | MalformedURL_DoesNotPanic | `://%%%bad` does not crash. | +| 10 | TokenSpecialChars_PreservedRaw | Secret with `+`, `/`, `=`, `:` is emitted verbatim. | +| 11 | Go2RTCOverride_PassesThroughExtractor | `req.Go2RTC.MainStreamSource` with this protocol is also extracted. | +| 12 | AddToConfig_NoExistingSection | Start from rtsp-only config, add this protocol -> new `
:` block created under `go2rtc:`. | +| 13 | AddToConfig_ExistingSection | Start from a config that already has `
:` -> new key merged, one header. | +| 14 | CustomName_URLStillClean | `req.Name` set, URL still has no secret in streams. | +| 15 | MixedProtocols | rtsp + this protocol together -- rtsp URL untouched, secret extracted. | +| 16 | SectionOrder | Order: go2rtc -> streams -> `
` -> cameras -> version. | + +### Common pitfalls in tests + +- `assertNotContains(cfg, "
:")` is WRONG when the URL scheme contains the same substring. Use `"\n
:\n"` instead (nested form) to avoid matching `://`. +- For protocols where `extractForConfig` returns `rawURL` unchanged (userinfo template), the "URL cleaning" assertion `assertNotContains(cfg, "token=")` does not apply -- skip or adjust. +- Use `registerOnce` with `sync.Once` so running the whole package test suite twice does not duplicate-register the extractor. + +--- + +## STEP 5: Build and run the tests + +```bash +cd /home/user/Strix +go build ./... +go test ./pkg/generate/ -v -run Test +``` + +Every test must pass. If any fails: + +- Re-read the corresponding xiaomi test and the difference in your version. +- Re-read the extractor function -- usually the bug is there, not in generate internals. +- Do NOT "fix" `pkg/generate/` to make tests pass. The generator has 16 passing tests for xiaomi already -- your protocol must fit the same contract. + +--- + +## STEP 6: Sanity check the full generator output + +Run once with a realistic URL manually (Bash + `go run` inline tool) and eyeball the YAML. Confirm: + +- `go2rtc:` is top-level +- `streams:` and `
:` are both siblings under `go2rtc:` with 2-space indent +- Keys under `
:` use 4-space indent +- No duplicate headers +- Secret appears verbatim (no `%2F`, no `%3D`) + +Do NOT commit. Leave changes staged for the user to review and commit manually. + +--- + +## ABSOLUTES -- DO NOT VIOLATE + +1. **Never modify `pkg/generate/registry.go`, `config.go`, `writer.go`, `insert.go`.** If you think you need to, the protocol probably doesn't fit the extractor contract -- stop and discuss with the user. +2. **Never add a new extractor to `pkg/generate/`.** Extractors live in `internal//`, the test file is the only thing in `pkg/generate/`. +3. **Never modify `pkg//`.** That code comes from go2rtc; we don't own it. +4. **Never write tests in `internal//`.** All generator tests go to `pkg/generate/_test.go`. +5. **Never call `generate.RegisterExtract` from inside a test.** Use `sync.Once` + a helper like `register()` inside the test file. +6. **Never commit.** Leave the changes for the user. +7. **Never skip tests.** All 16 scenarios are mandatory -- they caught real regressions during development.