# Agent Guidelines — QMK RGB Tool ## Read README.md First `README.md` is the single source of truth for this project's behaviour: the command surface, the effect catalog, zone semantics, the protocol, the keyboard list and platform setup. **Read it in full before you work on this repository.** This file deliberately does not repeat any of it. A second copy is a second thing that can go stale, and that has happened: a command prefix that had not existed for many commits survived in eleven lines of prose, and a `keyboards.json` field was documented here as load-bearing while the code never read it. The file itself is now gone, replaced by the definition file and the keyboard's own product string; a second place for a name is a second place for it to go stale. So the split is: - `README.md` — what the software does. Reference. - `AGENTS.md` — how to work on it. Discipline. - `.claude/skills/qmk-rgb/SKILL.md` — the live-query workflow for the tool. For the effect catalog specifically, read it from no document at all. Run `qmk-rgb-tool effect all` and use the output. Logo and Side accept fewer effects than Backlight, and only the tool knows what the current firmware supports. ## Keep README.md Short, Because a Person Reads It A fact belongs in README.md only while it changes what someone types. The measured detail behind a fact — how many collections a board reports, which usage page a firmware picks, what a filter rejects and why — belongs here, and the README may carry it as one sentence or a console transcript, not as the argument. Two tests before a paragraph goes in: would a user who only wants to run the command have to read it, and is it a fact about the software rather than about how the software was arrived at? A README that has to be read twice before the command is written has stopped being a reference. ## Fetch the Remote Before the First Edit `origin/main` moves while you work, and this repository is worked on from more than one machine. Check it before the first edit of a session, not before the push: a working tree that starts a rebase half-finished has already lost work. ```sh git fetch origin && git log --oneline HEAD..origin/main && git diff --stat HEAD...origin/main ``` Read what the incoming commits touch, and rebase onto them before editing: ```sh git rebase origin/main ``` `git status -sb` reporting `ahead N, behind M` means both are true, and `M > 0` is the one that ends in a rejected push. A `non-fast-forward` rejection is that same finding one step too late — rebase, never force-push, because the commits behind are other people's work and this branch is not the only writer. Three times now the local checkout has been behind at the moment of the push, and once the incoming commits touched `AGENTS.md` and `README.md` as well, which is where a stale copy of this file is written. A commit built against a tree that no longer exists is not wrong, it is orphaned, and the checks that would have caught it — `go test ./...` and the doc tests in `cmd/qmk-rgb-tool` — run against whatever is on disk, not against `origin/main`. ## Objective Cross-platform Go CLI library for programmatic/agent-friendly control of QMK keyboard RGB lighting. ## Architecture ## Device Selection Rules, with the reason they exist after the dash. The measurements behind them are in [Measurements](#measurements); `README.md` carries the user-facing half. **Discovery** - Match the QMK Raw HID signature: Usage Page `0xFF60`, Usage `0x61`, the same check `qmk/qmk_udev` performs. Nothing has to be registered for a board; a board states its own name over USB, and a board with no name and no definition file is still driven, because channel numbers follow from the QMK subsystem. - The pair is exact and is checked **per collection**, not per device: macOS reports one HID device per usage pair, Linux one interface at a time. Key the `seen` map on the path *after* the filter, or a keyboard's own keyboard collection consumes the raw HID entry. A vendor page near `0xFF60` is not a raw HID interface and no intent is guessed — the firmware picked the page, and saying which page it picked is the answer. - An empty result must explain itself. `DiscoverEvery` is the whole of that: `keyboard info` prints the HID devices it passed over, one line per device with the usage pages each exposes, and `otherHidDevices` carries them in the JSON shape on every run. "No keyboard" is what an unplugged board looks like and also what an unreachable one looks like, and nothing outside the tool can tell them apart. - Those lines may say what was looked for and why a QMK firmware has that collection; they may **not** say VIA was checked — this command never opens a device, and the filter asks about a usage page. "No QMK keyboard found" is overstated anyway: most QMK builds do not enable Raw HID at all. **Where a board's files live** - `definitionsPath()` and `profilesPath()` in `cmd/qmk-rgb-tool/datadir.go` are the only things that say where, and both are the per-user directory `os.UserConfigDir` answers — a hardcoded `~/.config` is wrong on macOS and Windows. `userDataDir` is the only place that asks. - One directory each, and it is both what is written and what is read. No search, ever: a directory beside the executable hides the file `keyboard fetch` just wrote, and a checkout's `profiles/` makes `load ` read a different file than `save ` wrote. `TestDataDirectoriesAreNotSearched` is what keeps the search from coming back. - A profile argument ending in `.json` is a path, handed to the file system as written (`resolveProfileTarget`, `cmd/qmk-rgb-tool/profile.go`). No fallback between path and name, because a fallback is the search this file rules out. `delete` and `list` stay name-only. - A board's definition has one further source: the file built into the binary with `go:embed` (`definitions/embed.go`), consulted last by `candidateDefinitions` in `cmd/qmk-rgb-tool/catalog.go`, after `--definition` and after the per-user directory. Embedding a vendor's file is not adding a list: one copy of the vendor's own bytes, and `go install` creates no data directory, so without it an installed tool has no definition for the Impact 80. **Devices and zones** - `keyboard info` numbers the keyboards from 1 in a stable order (vendor ID, product ID, path). `--device` takes that number, never a HID path. Without it a command runs only when exactly one keyboard is connected, and otherwise fails and lists the numbers — so a command never targets an unintended keyboard. Numbers hold for the current session only: HID paths are reassigned on reboot. - No command validates the zone or `--device` before the command that needs it does. A root-level pre-run would make `keyboard info`, `list`, `delete` and `completion` demand a keyboard, and the command you run to choose a keyboard cannot require that you have chosen one. Where two errors apply the board wins: a keyboard's channel vocabulary is not knowable without the keyboard. - `keyboard info` must not report the board's channels: it does not open the board and does not know them. `info` and the effect list open it and do know. - The zone is a **positional argument**, not a flag — a flag on the root is a flag every help lists, and `keyboard info` and `list` were told about a channel they cannot address and ignored it. `zoneArgs` in `cmd/qmk-rgb-tool/main.go` is the one place a zone count is declared; `withZoneArgs` is every command's route into it and `load` calls it directly because its zone is the second argument. Commands that take no zone use cobra's `NoArgs`/`MaximumNArgs` — a stray token there is a mistyped command, not a missing channel. `zoneArgs` prints the usage line, because cobra's own message does not say what the argument should have been. - The zone takes a VIA lighting channel named by its QMK subsystem; a board's definition may name a channel differently. Comma separated lists, `all` for every channel, and `all` next to another name is `all` — all in `resolveZoneName` in `cmd/qmk-rgb-tool/zones.go`, which also returns a list in channel order however it was written, one order for every spelling of one selection. Channels are asked of the keyboard, never assumed, and a name the board does not have is refused by name, every missing one of a list, so nothing is written on the way to a failure. - A command that writes lighting cannot be called without a zone, and the argument count is what keeps it that way. A command that only reads — `info`, `effect` without a name, `save` — takes no zone and reports every channel. `load` takes a profile name and at most a zone. Write the arity into a new command that writes, or it becomes the one command that writes every channel by default. - The zone is a parameter of `prepareTarget` and `openTarget`, never a package-level variable, so a command that forgets to pass one passes `""` — which means every channel. Only `load` skips a channel that does not support an effect, warning on stderr, because there a whole profile is applied. `effect` refuses instead, `all` included: `effect all` asks for every channel, so a name only one of them has is a request the tool cannot carry out. ## Stack - Go 1.26+ (cross-platform: Linux, macOS, Windows) - `github.com/sstallion/go-hid` for HID access - `github.com/spf13/cobra` for CLI framework ## Constraints - CLI-first, no GUI — designed for automation and agent consumption - Output should be machine-parseable (JSON when possible) - Cross-platform from day one ## Consistency Documentation describes real, verifiable behavior. Names are only the start — behavior counts too. Before any edit: 1. **Binary name** — the `go build -o` target is `qmk-rgb-tool`, which is not the module's last path element. It must match every example in README, AGENTS.md, comments, and docs. 2. **CLI command** — the root command name (e.g. `qmk-rgb-tool`) is the binary name; every usage example, CLI reference table, and doc must use the same string. 3. **Module path** — the `go.mod` module path is the source of truth for `go install` targets. 4. **Zone names** — the vocabulary is QMK's subsystem names, so they must be identical in code, docs, and examples: `backlight`, `rgblight`, `rgb_matrix`, `audio`, `led_matrix`. A board's own names (`logo`, `Backlight`, `side` on the Impact 80) are **data** in its definition file, not code and not part of the vocabulary: do not hardcode one into Go, and do not put one in a zone's help text. A board's *model* name is the definition's `name`, or the USB product string, or nothing — there is no lookup file left to put one in. Documented behavior must also match the code: 5. **Flags and accepted values** — what a flag accepts and rejects, its type, its default, and whether it persists across commands. A doc promising `--device 1` must not accept a path. 6. **Error messages and exit codes** — the exact strings a user is told to look for, which error takes precedence when several apply, and when JSON is still printed before a non-zero exit. 7. **JSON output shape** — field names, types, and whether a field is always present, omitted, or `null`. Agents and scripts parse this. 8. **Ranges, defaults, and stability** — accepted ranges, default values, boundary behavior, and any ordering or stability guarantee together with the scope it holds over (a device number is stable for one session, not across reboots). ## Firmware Transforms Values The Impact 80 firmware rescales or clamps brightness and speed per channel, so an accepted 0–255 request is not the value the keyboard holds. README.md tabulates the per-channel behaviour; the rule that follows from it is the part that matters when you write code here: - `brightness`, `speed`, `color` and `effect` read every selected zone back and print what was actually applied; where all zones match they print the plain success line, where any differs they print one summary line naming each zone's real value and the request. Never let a command report success for a value the keyboard did not accept. - All of them read back through the shared `readBackValues`, so a future parameter needs no new read-back path — but the helper above it is not as shared as it looks. `setValueVerified` writes one value to every channel, which fits `brightness` and `speed` and fits nothing else: `effect` carries a different ID per channel, because one effect name is a different index on each subsystem, and so has its own `setEffectVerified`. Assume the next parameter needs its own wrapper, and share only the read. - A board can refuse an ID that looks valid. The Impact 80's `logo` and `side` channels read effect ID 0 as "lighting off" and leave the mode register alone, so `effect logo none` there does nothing to the effect and is reported as the effect still running. `disable` is the command that turns a channel off, because it also writes brightness 0. Above the top the behaviour is the opposite: the backlight channel does not refuse ID 47 or ID 99 but clamps it to the 46 it holds, so an effect index there is reported as the index the keyboard ended up with. Read the register back; never report the number that was asked for. - There is no hand-written effect catalog, and that is deliberate. A board's names come from its VIA definition file, read at runtime. A hand-written list beside the vendor's file is two places to update one name, which is exactly how the two drifted apart: the compiled-in list spelled `breathing` and `fixed_wave` where the file writes `breathe` and `fixed wave`. Do not add such a list back, and do not "correct" a definition file either. - **Embedding a vendor's file is not adding a list.** `definitions/embed.go` builds the JSON files in that directory into the binary with `go:embed`, and `candidateDefinitions` consults them last, after `--definition` and after a file in a definitions directory. The distinction is the one that matters: the bytes are the vendor's own file, there is exactly one copy of it in the repository, and nothing transcribes it. A file the user places still wins, so the built-in copy is a fallback and not a second source a name could drift from. - A file names only the effects a board's firmware implements, so it may stop short of the board's highest ID: this one's backlight channel ends at 45 while the board takes 46, which is left `unknown` on purpose. A name no source publishes is a name the tool would be inventing. - When you see a name or a behavior in one file, grep for it across the whole repo before deciding if a change is consistent. ## Read the Firmware When Something Is Unclear Unclear firmware behaviour is answered by reading source, not by inferring it from names, from a general QMK assumption, or from a doc line that nobody has checked. Two sources settle nearly everything, and both are cheap to reach. Upstream QMK is public: `quantum/rgb_matrix/rgb_matrix.h` for the value API, `quantum/rgb_matrix/animations/` for what an effect actually does to hue, saturation and value. The vendor's VIA definition JSON for the board settles what the interface exposes at all — which custom value IDs exist, which ranges they take, and for which effects a color control is offered. It is the authority on the wire format, more than upstream is, because the ID mapping is the vendor's. For the Impact 80 that JSON is `https://drive.wobkey.com/f/d/6BtO/Impact_80.JSON`, reached from the vendor's [Driver & Firmware page](https://wiki.wobkey.com/en/Products/PMOKEY-Impact-80/Driver-Firmware); the page is the place to re-read, because it also carries what a wrong flash does to this tool: the vendor ships a proprietary firmware alongside the VIA one, it disables VIA completely, and a board on it is invisible rather than unsupported. It also documents that VIA only sees the board in wired mode. A keyboard the tool cannot find is therefore not yet evidence of a bug here. The board's own firmware is a vendor binary with no public source, so both sources describe the family, not the unit on the desk. Confirm against the hardware where that is cheap, then put the observation in README.md. One trap this session paid for: the vendor channels are not all the same QMK subsystem. One is `rgb_matrix`, one is the lightweight `rgblight`, and one is the audio effect subsystem. An upstream file that governs one channel does not govern another, so "the QMK source says" has to name the channel it was read for. A formula from the wrong subsystem is how this repo ended up claiming that speed 0 and 1 behaved identically. ## Effect Names Come From Definition Files A board's effect names live in a VIA definition file, the same one VIA reads. The tool therefore depends on those files, exactly as VIA does, and takes them in a fixed order: `resolveCatalog` in `cmd/qmk-rgb-tool/catalog.go` is the **only** place a catalog is looked up, and the order is `--definition`, then a file in `definitions/` whose `vendorId`/`productId` match the board. There is no third step: `CatalogFor` is gone, and a command that reached for a catalog itself would silently ignore a file the user had placed, so do not add one. Two things about reading those files are easy to get wrong, and both were measured on VIA's own collection rather than assumed: - An option's number is not its position, so a board can name its first effect as ID 1 and have no ID 0 at all. `internal/rgb/definition.go` reads the number and falls back to the position only when there is none. This is why `Catalog` holds `[]Effect{ID, Name}` and not a `[]string` indexed by ID. - A definition names its channels too, in the sub-menu it puts each one under, and those names are what VIA shows, so they are the display name. The QMK subsystem name stays accepted as an alternative and matching ignores case, so `brightness backlight 100` keeps working on a board whose definition writes `Backlight`. The QMK subsystem name always works, which is why the subsystem is what a document should tell a user to type. - A fetched file has to be parsed and matched before it is believed. VIA answers an unknown board with its own web page and HTTP 200, so status alone proves nothing. `keyboard fetch` tries v3 then v2, and only stores a document that parses and names the board that was asked about. The files are VIA's format, but their addressing is QMK's, and that is not a choice this tool made. The HID interface, the channel numbers, the lighting value IDs and the whole channel probe are QMK's; only the names come from the file, because the VIA protocol cannot carry names and VIA reads them from a file for the same reason. All effect dropdowns in VIA's collection use a QMK value key, so keying the parser on `id_qmk_*_effect` loses nothing today. `effectValueKeys` in `internal/rgb/definition.go` is the only place to register a vendor's own key, and a board that invents one gets no names until it is added there. A definition writes the manufacturer's spelling, which need not be the tool's, so the alias table is a package default every catalog gets rather than part of one board's list, and `EffectID` resolves a name to itself, to the name it aliases, and to an alias of it, and `byName` treats a space and an underscore as the same character, because that is the whole difference between a manufacturer's display spelling and the tool's identifier — `fixed wave` and `fixed_wave` are one effect, and a documented name may not stop working because a definition file is present. A definition names the effects a board's firmware implements, so it may cover fewer IDs than the board takes. A file replaces the built-in copy for its board, which is worth knowing before adding one for the Impact 80: the vendor's file stops at ID 45, so no name for ID 46 is reachable while that file is present, built in or placed. README.md carries the user-facing half of this. **Naming an effect nobody named is the user's job, and the tool's job is to refuse to pretend otherwise.** The measured answer is that no programmatic, deterministic route exists to the spelling of an effect for a board whose vendor never wrote one down, and README says so in a section of its own rather than filling the gap. What *is* answerable is the slots: writing above the top and reading back what the firmware kept gives the channel's highest effect ID, because QMK clamps rather than rejects. `EffectTop` in `internal/via/channel.go` is that probe, and it is four round trips, not a search. So a board with no names is a normal case, not an error, and the two halves are separate: the **slots** come from the keyboard and are measured, the **names** come from a file and are only ever as good as whoever wrote it. `keyboard definitions generate` writes the first and leaves the second open, and a name it wrote itself would be indistinguishable from the manufacturer's, because nothing in a file can be read back off a keyboard to check it. **Vial is answered before the keyboard is written to, and the answer is in Vial's namespace.** `internal/via/vial.go` is written from Vial's source and has never run against Vial firmware; the command says `experimental` in its help and on stdout, and that word is not to be dropped before someone has. Three things about it are readings of Vial's source rather than assumptions, and getting any of them wrong makes the feature silently useless: - VialRGB is **not** behind Vial's `0xFE` prefix. It answers VIA's ordinary `0x08` lighting command and carries its own command **in the channel byte**, so asking for the supported IDs is a custom get with a channel of `0x42`. Vial's handlers take `msg = &data[1]`, so they echo neither channel nor value ID and `readResponse`'s echoed-byte checks would reject a correct answer. - The discriminator is stock QMK's `default: { *command_id = id_unhandled; }`. Vial's own default case does not set that marker, which is what makes `0xFE` worth asking about. A version of zero means the request was echoed. - The IDs it sends are `VIALRGB_EFFECT_*` and the mapping to QMK's stays inside the firmware. The spellings in `spotted.json` are indexed by QMK's numbers and **must not** be listed beside them. The note says why instead. - With `VIALRGB_ENABLE` set, QMK does not compile `VIA_QMK_RGB_MATRIX_ENABLE`, so VIA's `id_qmk_rgb_matrix_effect` is not served at all and an effect set by number on such a board addresses Vial's field. That is a claim about Vial builds, not a measurement on one, and it is why the path is experimental. The names other keyboards use are evidence and travel with their counts. `internal/spotted/spotted.json` is generated by `internal/spotted/generate` from a checkout of VIA's collection and carries the commit it was measured from. Three things about it are not to be undone: - It is **generated, never hand-edited**, and regenerating it is a documented command rather than a patch. - Every name comes with **how many boards wrote it and which manufacturer most of them were**. That is not decoration: 95 of the 122 definitions listing an rgb_matrix effect are one manufacturer's, so a bare majority is one house style, and where definitions disagree they disagree about which effect a number *is* — ID 7 is `rainbow_moving_chevron` on 95 boards and `cycle_out_in` on 8. Drop the counts and the note becomes the guess this section exists to prevent. - The names live in a **`.spotted.txt` note beside the definition, not in it**. JSON cannot hold a comment: the tool would have to read one and VIA's parser would reject the file. An option with an **empty name** is a slot without a name and the parser skips it, so a generated file reports `0 effects` until the user fills the names in. That is the honest state, and it is the one the board was already in. **A definition file is the manufacturer's, and a names file is yours.** They are two formats in two directories — `definitions/` and `names/` — and the rule behind that is that the tool writes no definition of its own. A VIA definition is a menu description for someone else's keyboard; authoring one meant handing the user a nested `menus[0].content[1].options[7][0]` to type a single name into, which is not a user interface. A names file is flat — a channel, an effect ID and a name — and `Names.Apply` merges it over whatever the definition said, per effect, so overriding one name does not mean restating the ones that were right. Four things about that split are not to be undone: - `Names.Apply` works on a **nil** catalog. A board with no definition is the case the file exists for, and a nil dereference there is a panic, not an error. - Each name carries a `Source`: `SourceVendor` for a name the vendor published, `SourceUser` for one a person typed. A name is the one value the tool cannot read back off a keyboard, so the distinction has to be visible wherever a name is printed. - A names file declares `"kind": "names"`, and `LoadDefinitionsDir` skips a file carrying it. Without that a names file parses as a definition — it has the board's identifiers and no menus — and shadows the board's real file, because the user directory is searched before the built-in ones. A names file dropped into `definitions/` is caught by the declaration and nowhere else. - A **new writable directory needs its own test override in the same commit.** `names/` had none, and the tests wrote two boards' names files and their notes into the user's real per-user directory. `forceNamesDir` is what stops it, and `generateSetup` gives the names and definitions directories *different* temporary directories, because one test directory for both hides exactly the bug above. There is no shared database of effect names to be had, and do not plan around one. The closest thing is VIA's collection, `the-via/keyboards`, and a board's names live in an `id_qmk_rgb_matrix_effect` dropdown under `menus` — not under a `lighting` or `effects` key, so a grep for those finds nothing and looks like proof that no bulk source exists. Most boards deviate from the built-in list, so a subsystem default is wrong for most of them, and a board's names come from the definition its own vendor publishes. OpenRGB drives the same raw HID interface but keeps its device data in C++ controllers, so it settles VID/PID, not effects. The conclusion for code here is that a catalog is transcribed per board and cannot be generated from a common source. **The names for VIA's built-in menu are in no definition file at all.** A definition that writes `"menus": ["qmk_rgb_matrix"]` — a string, not a menu object — refers to the menu VIA's own app carries, and those names live in `the-via/reader` (the `@the-via/reader` package), in `src/common-menus/qmk_rgb_matrix.ts`: the `options` array of the `id_qmk_rgb_matrix_effect` dropdown, where position equals effect ID 0 to 44. It is TypeScript with nested `content`, `showIf` and range constraints, so nothing here can read it at runtime, and the spellings are VIA display labels rather than QMK enum identifiers — `Solid Color` and `Breathing` reach `solid_color` and `breathing` through `byName`'s space/underscore rule, while `All Off` and `Band Sat.` reach nothing the tool spells today. The GMMK Pro (0x320F/0x5044) is one of these boards: `keyboard fetch` succeeds, stores the file, and the catalog comes out empty, so no effect name resolves. Whether to fall back to those 45 names, and where such a fallback's list would come from, is still open — a list typed into this repository is the drift these rules forbid, so do not add one without asking. **Nobody ships names over the wire, which is the reason this tool exists.** These projects drive the same raw HID interface from a terminal, and each of them takes an effect **ID**: - — Rust CLI, plus a Python GUI in `python/`; BSD-3-Clause. `qmk_hid via --rgb-effect 38`, `--rgb-brightness`, `--rgb-hue`, `--rgb-saturation`, `--rgb-effect-speed`, `--rgb-color`, `--backlight`, `--backlight-breathing`, `--save`, `--device-indication`, `--eeprom-reset`, `--bootloader`, `-l`/`--vid`/`--pid`. Its README states the reason out loud: "the effect numbers can be different per keyboard", and it says the tool "will soon be superceded by QMK XAP". - — C++ against hidapi, one `qmk-light.cpp`; **no licence file**, so nothing may be taken from it. `--list`, `--get-brightness`, `--set-brightness` (absolute or `+5`/`-10`), `--list-effects`, `--get-effect`, `--set-effect`, `--get-effect-speed`, `--set-effect-speed`, `--get-color`, `--set-color h,s`, `--device `, `--first-device`, `--quiet`. - — Python, GPL-3.0, untouched since 2021; a proof of concept that needs a keymap of its own with `RAW_ENABLE = yes` and the board's VID/PID written into the source. None of them reads a definition file, so none can name an effect. That is the gap this tool's catalog and `keyboard fetch` sit in. Read them for that gap, and for anything this tool does not do yet: their flag surfaces are where a gap in ours shows up first — persistence (`--save`, `--eeprom-reset`), device indication, effect speed as its own parameter, colour by name, jumping to the bootloader, and explicit device selection where more than one board is attached (`--first-device`). Any of those is a candidate feature here, not a duplicate to reimplement badly. Check a project's licence before taking code from it, and check whether it has moved on before treating its behaviour as current. **The `qmk` CLI has never had a lighting command.** Do not explain a missing feature by saying QMK removed one. `lib/python/qmk/cli/` carries no `led`, `rgblight` or `hid` module at tags 0.6, 0.9, 0.10, 0.15 through 0.21, 0.24 or on master, the commits API returns zero commits for those paths, and `docs/cli_commands.md` documents none at any of those tags. The path filter does report deletions, so an empty result means the file was never there, not that it was removed. ## Output Shape `keyboard info`, `info`, `list`, the effect list and `keyboard definitions` print text, because that is what a person reads, and JSON only behind the persistent `--json`. The field names are the ones the JSON always had, so a consumer that passes the flag is unaffected — where a field had to be added, it is added rather than renamed or reshaped. `keyboard definitions` grew a `source` field per entry for exactly this reason: it now lists the definitions built into the binary as well as the ones in the user directory, and a path alone does not say which of the two a line is, because a built-in one has a path relative to the build. A command that emits structured data and neither honours `--json` nor says why is the bug this rule exists for. Four shapes are deliberately not in that list: `effect` with no effect name routes to the effect list, `keyboard fetch` writes a file and prints a line about it, and `save` and `delete` say which file they wrote or removed — the latter two only since `save` could name a file by path, where a bare "saved" would leave the one thing worth reporting unsaid, and where a delete that printed nothing could not be told apart from one that removed something else. The two lines come in one shape, `savedProfileLine` and `deletedProfileLine` next to each other in profile.go, so a file the user asked for is always named back to them and annotated by describeDataDir. `keyboard info` does not open the board, so it must not report the board's channels or an effect list from one: it reports the name and whether this tool has effect names for that board, and the channels come from `info` and the effect list, which open it. ## Code vs Documentation All documentation (README.md, comments, AGENTS.md) must stay in sync with the code. When code and documentation conflict, **ask the user** before deciding which one to change. Do not silently pick a winner — explicitly state the conflict and get direction. ## Measurements Facts about the world that a rule above rests on. They are recorded here because the rules are what someone needs while editing, and because a rule without the number behind it tends to be adjusted on feeling. Re-measure before quoting one twice; several are counts of a collection that moves. | What | Measured | Where | |---|---|---| | VIA's collection | 2029 definitions, one per unique VID/PID | `the-via/keyboards`, tree API | | …that carry an effect list with names | 161 | as above, `internal/spotted/generate` | | …that name one of VIA's built-in menus | 947 (`qmk_rgblight` 452, `qmk_backlight_rgblight` 220, `qmk_rgb_matrix` 179, `qmk_backlight` 94, `qmk_audio` 7) | as above | | …that have no lighting section at all | 865 | as above | | …listing an `id_qmk_rgb_matrix_effect` dropdown | 122, of which 95 are one manufacturer | as above | | Where the collection disagrees, it disagrees about the effect | ID 7: `rainbow_moving_chevron` 95×, `cycle_out_in` 8× | as above | | Effect names in the vendor's own firmware image | **0**, in 76 KB of Cortex-M code, 735 strings, none readable | Wobkey `impact_80.bin`, `strings` | | QMK's `rgb_matrix_effects.inc` order | the effect ID *is* the line number; inserted twice since 2021, renumbering everything after | `quantum/rgb_matrix/animations/rgb_matrix_effects.inc`, "order determines enum order" | | …name VIA's built-in `qmk_rgb_matrix` menu | 179 boards | as above | | …ship their own `id_qmk_rgb_matrix_effect` dropdown | 163 boards, of which 10 are an exact prefix of the built-in 45 | as above | | …of those, write names as QMK enum identifiers | 11; the other 152 use display labels | as above | | Effect options whose number is not their position | 194 | as above | | Effect dropdowns in the collection that use a QMK value key | 190, which is all of them, so keying on `id_qmk_*_effect` loses nothing today | as above | | HID collections per device | 5 on the Impact 80 (`0x0001/0x02`, `0x0001/0x01`, `0x0001/0x80`, `0x000C/0x01`, `0x0001/0x06`) | macOS, this Mac, `qmk-rgb-tool`-adjacent enumeration | | HID devices a Mac reports that are never a keyboard | 43 collections across 10 devices, Impact 80 attached | macOS, same run | | `qmk` CLI lighting commands | none, at any tag — see the rule above | `qmk/qmk_firmware`, contents + commits API per path; the path filter does report deletions, e.g. `4723f308a` removes `cformat` and `multibuild` | | Impact 80 brightness and speed per channel | `logo`/`side` cap at 160 with three reachable speeds (0 freezes, 1 slowest, above 1 becomes 4); `backlight` scales to 255 | README tabulates it; measured on the board | | Impact 80 effect IDs | `backlight` takes 0–46 and clamps above; `logo`/`side` take 0–6 and read ID 0 as off | measured on the board | | VIA's built-in menu names | 45, `All Off` … `Solid Multi Splash`, at IDs 0–44 | `the-via/reader/src/common-menus/qmk_rgb_matrix.ts` | ## Go Development ### Tooling - `gopls` — Go Language Server, installed separately with `go install golang.org/x/tools/gopls@latest` (LSP, diagnostics, go-to-def, rename, references) - `gofmt` / `goimports` — formatting. `goimports` adds/removes imports automatically. Always run before committing. - `go test` — built-in test framework. Tests live in `*_test.go` files alongside source. - `go vet` — static analysis. Run before committing. - `go build` — compiles without installing. Fast, cached. - `go mod tidy` — adds missing deps, removes unused ones. Run after every import change. ### Style (Effective Go + Code Review Comments) - **Formatting**: always `gofmt` or `goimports`. Tabs for indentation, no line-length limit but avoid uncomfortably long lines. - **Names**: `MixedCaps` / `mixedCaps`, no underscores. Short local variables (`i`, `c`, `r`). Descriptive for globals. - **Initialisms**: consistent case — `URL`, `ID`, `HTTP` → `appID`, `urlPony`, `ServeHTTP`. - **Comments**: doc comments start with the name, end with period. `// Package rgb provides RGB effect handling.` - **Imports**: standard library first, blank line, then third-party. Grouped. ### Error Handling - Return errors, never `panic` for normal control flow. - Indent error flow early — happy path at minimal indentation: ```go if err != nil { return err } // normal code ``` - Error strings: lowercase, no period — `fmt.Errorf("device not found")`. - Do not discard errors with `_`. ### Interfaces & Types - Define interfaces in the **consumer** package, not the producer. - Use pointer receivers when in doubt (mutation, large structs, sync fields). - Return concrete types from constructors — let consumers mock if needed. - Empty slices: `var t []string` (nil), not `t := []string{}` (non-nil), unless JSON encoding requires `[]`. ### Concurrency - Pass `context.Context` as **first parameter**. Never store in structs. - Clear goroutine lifetimes — document when/why they exit. Avoid leaks via channels. - Prefer synchronous functions — callers can add concurrency, not remove it. ### Testing - Table-driven tests preferred for multiple cases. - Failure messages: `t.Errorf("Effect(%q) = %d, want %d", input, got, want)`. - Use `*_test.go` files. Test package should match source package (internal tests). - Example functions (`func Example...`) double as docs and tests. ### Modules & Packages - Module path: `github.com//` (when published). - Package names: short, single-word, lowercase. No `util`, `common`, `api`, `types`. - Zero values should be useful (e.g. `bytes.Buffer`, `sync.Mutex`). ## graphify This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships. When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else. Rules: - For codebase questions, first run `graphify query ""` when graphify-out/graph.json exists. Use `graphify path "" ""` for relationships and `graphify explain ""` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output. - Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it. - If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing. - Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context. - After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).