Adding a config option to Neru
Config changes fail in review when one link of the chain is missing — a default
that exists on macOS but not Linux, a validator that never runs, or an example
file that still shows the old key. internal/config/AGENTS.md states the
contract and which test fails per link; this is the order to do it in.
Five steps are universal. Then check the conditional four — the ones that turn
a one-line option into a ten-file one, and that no guide used to name.
The universal five
- Schema. Add the struct field in
internal/config/config.go with a
toml:"snake_case_name" tag matching the surrounding section. Follow the
existing nesting — options live in their feature's sub-struct, not at root.
- Shared default. Set it in
newDefaultConfig() in
internal/config/config_defaults.go. Assign the field by name even when the
value you assign is the zero value, and say in a comment what the zero
means: a zero nobody wrote cannot be told from a forgotten one.
TestEverySchemaFieldHasAnExplicitDefault fails on an omission.
- Example. Add the line to
configs/default-config.toml, which is the file
a user copies. Comment it out if the option has no default — an uncommented
empty value asserts a default that does not exist.
TestConfigOptionsAppearInTheDefaultExample fails when the line is missing.
Update the mode-specific examples (grid-only-config.toml,
hints-only-config.toml, recursive-grid-only-config.toml) only if they
show the affected section; they are deliberately partial, and only their
keys are checked, by TestShippedExamplesWriteOnlySchemaKeys. Those four
are the whole shipped set — configs/embed.go lists them, and anything else
in configs/ is a working file rather than a project artifact. Only
default-config.toml is embedded; the rest are read from disk by the tests
that check them.
- Docs. Add the row to
docs/CONFIGURATION.md. That file is the single
home for config reference facts; do not also describe the option in
ARCHITECTURE or README. No test catches a missing row — this is the one
universal link a reviewer has to check by eye.
- Platform column. Add the option's path to
PlatformSupport() in
internal/config/platform_support.go, saying which of macOS, Linux and
Windows writing it actually does something on. Say so even when the answer
is all three: a column nobody wrote cannot be told from a forgotten one, and
TestEveryConfigOptionDeclaresItsPlatformSupport fails on an option that is
neither. A narrower column needs a note saying why, in the words the user
reads — it reaches the load-time warning, the neru doctor
platform_support row and the published table, which you regenerate with
just gensupportref. A config.Color is declared at the field, not at its
light and dark leaves
(docs/adr/0013-parity-is-measured-in-words-not-subsystems.md).
Steps 2 and 3 exempt three shapes, and an option with one of them passes both
tests whether or not you do the work: the light/dark leaves under a Color,
a collection that ships empty, and any field of a repeated table nobody ships
entries for (app_configs, layers). Do the work anyway — see
internal/config/AGENTS.md.
The conditional four
Each is skippable, and each is a whole file when it is not.
- Platform override. If a platform needs a different default, set it in
applyPlatformDefaults() in the matching config_<os>.go
(config_darwin.go, config_linux.go, config_windows.go,
config_other.go). Both layers are reached via DefaultConfig() in
config_platform.go — never call newDefaultConfig() directly from feature
code. Nothing checks this; pin the difference in a test.
- Validator. Add checks to the relevant
Validate* method on *Config in
internal/config/validators_*.go (hints, grid, style, hotkeys, macros,
pointer, appconfig, …). Invalid values must fail validation with a clear
message, not get silently clamped; new enum-ish strings get an allowlist. A
brand-new Validate* method must be called from ValidateWithWarnings in
validate.go, or TestEveryConfigValidatorRunsInTheLadder fails.
- Derivation helper. If the value the daemon runs on is not the value the
user writes, resolve it once after the configuration is assembled rather than
in each consumer —
internal/config/grid_labels.go and held_repeat.go are
the worked examples. The declared default stays what a user would type.
- Cross-field rule. If the option only makes sense alongside another, and a
user could act on the mismatch, collect it into
Warnings so it rides out on
LoadResult and reaches neru config validate. logger.Warn reaches nobody
(ADR 0002, docs/adr/0002-severity-tiered-config-validation.md).
Hot reload
Config is hot-reloadable via internal/config/loader (load/watch/persist/
set-field). If the option should be settable at runtime with neru config set,
confirm the field path resolves through the loader's set-field lookup; if it
intentionally requires a restart, say so in the docs.
Tests
- Table-driven validator tests next to the validator you touched
(
validators_*_test.go), covering the default, a valid custom value, and at
least one rejected value.
- If the default differs per platform, pin it in
config_defaults_test.go or
the platform-tagged config test.
Verify
just test-foundation # the fast cross-platform-safe slice
That one recipe now runs every package this skill's guardrails live in — the
config package, its loader, and internal/architecture.
Then the standard pre-commit gate from AGENTS.md.
1---2name: neru-add-config-option3description: Add or change a Neru config.toml option end to end: struct field, shared and platform defaults, validation, examples, docs, and hot-reload behavior. Use whenever a change adds, renames, or removes a key in the TOML config schema. Not for theme palette entries or CLI flags.4---56# Adding a config option to Neru78Config changes fail in review when one link of the chain is missing — a default9that exists on macOS but not Linux, a validator that never runs, or an example10file that still shows the old key. `internal/config/AGENTS.md` states the11contract and which test fails per link; this is the order to do it in.1213Five steps are universal. Then check the conditional four — the ones that turn14a one-line option into a ten-file one, and that no guide used to name.1516## The universal five17181. **Schema.** Add the struct field in `internal/config/config.go` with a19 `toml:"snake_case_name"` tag matching the surrounding section. Follow the20 existing nesting — options live in their feature's sub-struct, not at root.212. **Shared default.** Set it in `newDefaultConfig()` in22 `internal/config/config_defaults.go`. Assign the field by name even when the23 value you assign is the zero value, and say in a comment what the zero24 means: a zero nobody wrote cannot be told from a forgotten one.25 `TestEverySchemaFieldHasAnExplicitDefault` fails on an omission.263. **Example.** Add the line to `configs/default-config.toml`, which is the file27 a user copies. Comment it out if the option has no default — an uncommented28 empty value asserts a default that does not exist.29 `TestConfigOptionsAppearInTheDefaultExample` fails when the line is missing.30 Update the mode-specific examples (`grid-only-config.toml`,31 `hints-only-config.toml`, `recursive-grid-only-config.toml`) only if they32 show the affected section; they are deliberately partial, and only their33 *keys* are checked, by `TestShippedExamplesWriteOnlySchemaKeys`. Those four34 are the whole shipped set — `configs/embed.go` lists them, and anything else35 in `configs/` is a working file rather than a project artifact. Only36 `default-config.toml` is embedded; the rest are read from disk by the tests37 that check them.384. **Docs.** Add the row to `docs/CONFIGURATION.md`. That file is the single39 home for config reference facts; do not also describe the option in40 ARCHITECTURE or README. **No test catches a missing row** — this is the one41 universal link a reviewer has to check by eye.425. **Platform column.** Add the option's path to `PlatformSupport()` in43 `internal/config/platform_support.go`, saying which of macOS, Linux and44 Windows writing it actually does something on. Say so even when the answer45 is all three: a column nobody wrote cannot be told from a forgotten one, and46 `TestEveryConfigOptionDeclaresItsPlatformSupport` fails on an option that is47 neither. A narrower column needs a note saying why, in the words the user48 reads — it reaches the load-time warning, the `neru doctor`49 `platform_support` row and the published table, which you regenerate with50 `just gensupportref`. A `config.Color` is declared at the field, not at its51 `light` and `dark` leaves52 (`docs/adr/0013-parity-is-measured-in-words-not-subsystems.md`).5354Steps 2 and 3 exempt three shapes, and an option with one of them passes both55tests whether or not you do the work: the `light`/`dark` leaves under a `Color`,56a collection that ships empty, and any field of a repeated table nobody ships57entries for (`app_configs`, `layers`). Do the work anyway — see58`internal/config/AGENTS.md`.5960## The conditional four6162Each is skippable, and each is a whole file when it is not.6364- **Platform override.** If a platform needs a different default, set it in65 `applyPlatformDefaults()` in the matching `config_<os>.go`66 (`config_darwin.go`, `config_linux.go`, `config_windows.go`,67 `config_other.go`). Both layers are reached via `DefaultConfig()` in68 `config_platform.go` — never call `newDefaultConfig()` directly from feature69 code. Nothing checks this; pin the difference in a test.70- **Validator.** Add checks to the relevant `Validate*` method on `*Config` in71 `internal/config/validators_*.go` (hints, grid, style, hotkeys, macros,72 pointer, appconfig, …). Invalid values must fail validation with a clear73 message, not get silently clamped; new enum-ish strings get an allowlist. A74 brand-new `Validate*` method must be called from `ValidateWithWarnings` in75 `validate.go`, or `TestEveryConfigValidatorRunsInTheLadder` fails.76- **Derivation helper.** If the value the daemon runs on is not the value the77 user writes, resolve it once after the configuration is assembled rather than78 in each consumer — `internal/config/grid_labels.go` and `held_repeat.go` are79 the worked examples. The declared default stays what a user would type.80- **Cross-field rule.** If the option only makes sense alongside another, and a81 user could act on the mismatch, collect it into `Warnings` so it rides out on82 `LoadResult` and reaches `neru config validate`. `logger.Warn` reaches nobody83 (ADR 0002, `docs/adr/0002-severity-tiered-config-validation.md`).8485## Hot reload8687Config is hot-reloadable via `internal/config/loader` (load/watch/persist/88set-field). If the option should be settable at runtime with `neru config set`,89confirm the field path resolves through the loader's set-field lookup; if it90intentionally requires a restart, say so in the docs.9192## Tests9394- Table-driven validator tests next to the validator you touched95 (`validators_*_test.go`), covering the default, a valid custom value, and at96 least one rejected value.97- If the default differs per platform, pin it in `config_defaults_test.go` or98 the platform-tagged config test.99100## Verify101102```bash103just test-foundation # the fast cross-platform-safe slice104```105106That one recipe now runs every package this skill's guardrails live in — the107config package, its loader, and `internal/architecture`.108109Then the standard pre-commit gate from AGENTS.md.