feat(nix): expose declarative outputs and a home-manager module (task 0045)
All checks were successful
CI / test (22) (pull_request) Successful in 49s
CI / test (true, 24) (pull_request) Successful in 1m5s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m5s
CI / flake (push) Successful in 3s

Let a Nix configuration declare gitea-axi's ambient context instead of
running a command that writes it. `setup` and `setup hooks` are write-only
against files the operator is assumed to own, so an operator whose agent
configuration is generated cannot use them at all.

The package installs the bundled Agent Skill to share/gitea-axi/skills and
publishes it as `passthru.skill`, alongside `passthru.sessionStartHook` read
from `session-start-hook.json` — a committed declaration the fast tier reads
too, so a test drives `setup hooks` and asserts the two agree.

On top of that, `homeModules.gitea-axi` declares both from those attributes
through home-manager's own Claude Code options, so an operator's existing
skills and SessionStart hooks compose rather than collide. Importing it
without enabling it yields a byte-identical generation.

The spec's Out of Scope entry deferring a home-manager module is deleted;
ADR 0020 records the reversal, and INSTALL.md describes both paths.
This commit was merged in pull request #54.
This commit is contained in:
2026-07-20 13:51:19 -04:00
parent a1e68dc530
commit e77a1f5e22
10 changed files with 476 additions and 41 deletions

View File

@@ -0,0 +1,69 @@
# Ship a home-manager module for gitea-axi's ambient context
The flake exposes `homeModules.gitea-axi`, a home-manager module that declares the bundled Agent Skill and the SessionStart hook, installing the package by default.
Importing it does nothing; `programs.gitea-axi.enable` switches it on, and the Skill and the hook each carry their own toggle, both defaulting on.
The package publishes what the module consumes: the Skill at a stable address in the output, and the SessionStart hook entry read from `session-start-hook.json`, a committed file the fast tier reads too.
`setup` and `setup hooks` are unchanged and remain fully supported.
The two paths are alternatives, not stages.
## Context
The [nix-flake-packaging spec](../spec/nix-flake-packaging.md) listed a home-manager module under Out of Scope, deferred rather than rejected, on the grounds that managing the Agent Skill declaratively "would reintroduce exactly the automatism that ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script", and that the trade-off deserved its own decision made with usage evidence.
The evidence arrived, and it does not support the deferral's reasoning.
`setup` and `setup hooks` are write-only against files the operator is assumed to own.
An operator whose agent configuration is generated declaratively cannot use either: the targets are read-only, which task 0044 made fail cleanly rather than obscurely, but failing cleanly is not the same as working.
What that operator does instead is hand-copy the Skill into their own configuration, where it silently drifts from the package that ships it — the exact failure the bundled Skill exists to prevent.
The deferral's stated concern also does not survive contact with what a module is.
ADR 0009 rejected a *postinstall script*: something that runs without being asked, as a side effect of installing a package.
A module the operator imports and then explicitly enables is the opposite — it is the declarative spelling of running `setup`, not a substitute for the operator's consent.
The automatism ADR 0009 guards against is absence of intent, and an `enable` option is intent.
## Considered Options
**Keep deferring** (rejected) — The deferral was conditional on evidence, and the evidence is in.
Leaving it deferred means the one operator the whole spec was written for still installs the Skill by hand.
**Teach `setup` to emit a Nix expression** (rejected) — A code generator producing configuration the operator then commits.
It inverts the dependency: the generated expression is a snapshot that goes stale the moment the package changes, which is the drift problem restated rather than solved.
**Write `~/.claude/settings.json` and the Skill from `home.file` directly** (rejected) — The straightforward spelling, and it collides head-on with `programs.claude-code`, which owns `settings.json`.
An operator using both modules would get a home-manager conflict on that file, and the composition the module exists to provide would be exactly what it broke.
**Declare through the Claude Code module's own options** (chosen) — `programs.claude-code.skills.gitea-axi` and `programs.claude-code.settings.hooks.SessionStart`.
Home-manager's own merge semantics then do the composing: attribute sets merge, lists concatenate, and an operator who already declares their own Skills and SessionStart hooks gets ours alongside theirs rather than instead of them.
The cost is a dependency on that sibling module being enabled, which is asserted rather than assumed.
## Consequences
- The module is a wiring layer with no content of its own.
Everything it declares comes from the package's `passthru`, so the declarative and imperative paths install the same two artefacts by construction.
- `session-start-hook.json` is the hook's single declaration.
The Nix expression reads it and the fast tier reads it, and that tier drives `setup hooks` against a temporary home and asserts the written entry equals the declared one.
A divergence — including the agent SDK changing the envelope it writes — fails a test instead of shipping.
Declaring the entry in the Nix expression instead would have created a second source of truth with nothing checking it against the first, and a test restating the same values a third time would have verified nothing.
- The Skill is installed to `share/gitea-axi/skills/gitea-axi` in the output and published as `passthru.skill`.
Its other copy, inside the installed node modules tree where `setup` resolves it relative to its own module, is an implementation detail of runtime resolution that moves with the packaging method; no consumer should address it.
- `package = null` means the binary is not added to `home.packages`, for an operator installing it through `environment.systemPackages` instead.
It does not mean the configuration is empty: the Skill still comes from the default build, which in that arrangement is already in the closure.
The hook is unaffected either way, since it records a name resolved on `PATH` (ADR 0019) rather than a store path.
- Home-manager inspects the Skill path while evaluating, so a rebuild realises the package during evaluation rather than at build time — including under `package = null`, where nothing is being installed.
That is inherent to sourcing the Skill's bytes from the package: any store path handed to `programs.claude-code.skills` has the same effect.
It is a cost in rebuild latency, not in correctness, and pointing the module at the repository's own `skills/` directory to avoid it was rejected — that would ignore `package` entirely, so an operator running an override would get a Skill from a build they are not running.
- `programs.claude-code.skills` also accepts a single path standing for a whole skills directory, and a configuration using that form cannot have an entry merged into it.
Such a configuration gets a type-merge error rather than composition.
This is documented in [INSTALL.md](../../INSTALL.md) rather than worked around, since the workaround would mean writing the Skill file directly and reintroducing the collision this decision avoids.
- The module defaults `package` to `pkgs.callPackage ./package.nix { }` — the importing configuration's own package set, not this flake's nixpkgs.
That is how a consumer deduplicates, and it is why the derivation is a callable expression rather than a flake-bound one.
- Enabling the Skill or the hook without `programs.claude-code.enable` is an assertion failure rather than a silent no-op.
Those options are written by a module that is gated on its own `enable`, so without the assertion an operator would get a configuration that says the Skill is installed and a session that never sees it.
- `nix flake check` does not evaluate the module, because doing so would mean taking home-manager as a flake input purely to test against.
This matches the spec's existing position that the flake's consumption from a system configuration is verified by the maintainer's rebuild rather than by an automated test: building the package proves the derivation, and whether the configuration wires it in is outside this repository.
The module was verified before landing against real home-manager, including that importing it without enabling it yields a byte-identical generation to never importing it at all.
- The two Codex integrations `setup hooks` writes — `~/.codex/hooks.json` and `~/.codex/config.toml` — and the OpenCode plugin file have no declarative counterpart here.
Home-manager has no module owning those files, so declaring them would mean writing them directly and re-creating the collision problem this decision avoids for Claude Code.
An operator wanting those on a declarative system uses `setup hooks`, whose targets are unmanaged there and therefore writable.

View File

@@ -46,16 +46,19 @@ Alongside the flake, the continuous integration workflow moves off end-of-life N
### Flake surface
The flake exposes a package, a development shell, and a checks output.
The flake exposes a package, a development shell, a checks output, and a home-manager module.
It deliberately exposes no NixOS module and no overlay.
A NixOS module was rejected because gitea-axi is a stateless CLI with no daemon and no system-level configuration; a module would wrap the system package set and nothing else.
An overlay was rejected as an interface with no consumer — the maintainer is the sole consumer and already knows the package goes into the system package set.
Both remain purely additive to add later.
A home-manager module is explicitly deferred rather than rejected.
The one piece of per-user state is the Agent Skill that the `setup` command installs, and a module managing it declaratively would reintroduce exactly the automatism that ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script.
That trade-off deserves its own decision, made with usage evidence.
A home-manager module was initially deferred rather than rejected, on the grounds that a module managing the Agent Skill declaratively would reintroduce the automatism ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script, and that the trade-off deserved its own decision made with usage evidence.
That evidence arrived and reversed the deferral; the flake exposes `homeModules.gitea-axi` as of task 0045.
The reasoning is recorded in [ADR 0020](../adr/0020-home-manager-module-for-declarative-context.md).
In short: `setup` and `setup hooks` are write-only against files the operator is assumed to own, so an operator whose agent configuration is generated cannot use them at all and hand-copies the Skill instead, where it drifts from the package shipping it.
And a module the operator imports and then explicitly enables is not the automatism ADR 0009 guards against — that guard is against installation without intent, and an `enable` option is intent.
Consumers deduplicate nixpkgs by pointing the flake's nixpkgs input at their own.
Consequently the flake's own nixpkgs input governs only standalone builds, the development shell, and flake checks — never the deployed artifact.
@@ -186,8 +189,6 @@ Removing the `tea` runtime dependency.
It was raised and examined during design: ADR 0002 moved command dispatch off `tea` but retained it for credential discovery, and eliminating it would mean either owning a credential store or reading `tea`'s internal configuration format, which ADR 0001 explicitly rejected because it forfeits OAuth token refresh.
Wrapping the binary makes the dependency invisible in practice, which removes most of the practical motivation.
A home-manager module, and with it any declarative management of the Agent Skill or the session-start hook.
An overlay output and a NixOS module output.
A direnv configuration.

View File

@@ -32,14 +32,52 @@ The two installation paths remain independent and both supported: the command fo
## Acceptance criteria
- [ ] The bundled Agent Skill is installed to a stable location in the package output that is not an internal implementation path.
- [ ] The package exposes the Skill and the hook specification as attributes consumable from a Nix expression without building or running anything.
- [ ] The hook specification is declared in a single committed file, read by both the Nix expression and the test suite.
- [ ] A test drives the imperative hook install and asserts that what it writes matches the declared specification, failing if either side drifts.
- [ ] The flake exposes a home-manager module that declares the Skill and the hook.
- [ ] Importing the module without enabling it changes nothing about the resulting configuration.
- [ ] The module installs the package by default, and accepts a null package as the documented way to declare the configuration without installing the binary.
- [ ] The Skill and the hook each have their own toggle, both defaulting to on.
- [ ] The module composes with an existing configuration that already declares its own SessionStart hooks and skills, rather than conflicting with it.
- [ ] The spec's Out of Scope entry excluding a home-manager module is revised, and the decision to reverse it is recorded as an ADR.
- [ ] The user-facing documentation describes both installation paths and when each applies.
- [x] The bundled Agent Skill is installed to a stable location in the package output that is not an internal implementation path.
- [x] The package exposes the Skill and the hook specification as attributes consumable from a Nix expression without building or running anything.
- [x] The hook specification is declared in a single committed file, read by both the Nix expression and the test suite.
- [x] A test drives the imperative hook install and asserts that what it writes matches the declared specification, failing if either side drifts.
- [x] The flake exposes a home-manager module that declares the Skill and the hook.
- [x] Importing the module without enabling it changes nothing about the resulting configuration.
- [x] The module installs the package by default, and accepts a null package as the documented way to declare the configuration without installing the binary.
- [x] The Skill and the hook each have their own toggle, both defaulting to on.
- [x] The module composes with an existing configuration that already declares its own SessionStart hooks and skills, rather than conflicting with it.
- [x] The spec's Out of Scope entry excluding a home-manager module is revised, and the decision to reverse it is recorded as an ADR.
- [x] The user-facing documentation describes both installation paths and when each applies.
## Implementation Notes
The decision is recorded as [ADR 0020](../adr/0020-home-manager-module-for-declarative-context.md).
The spec's Out of Scope entry is deleted and its "Flake surface" section rewritten to record the reversal rather than to pretend the deferral never happened.
### The hook specification is the settings entry, not its parts
`session-start-hook.json` holds the SessionStart entry verbatim as it belongs in a Claude Code `settings.json` — matcher, and the hook array inside it — rather than the fields the entry is assembled from.
Declaring the fields would have left the *grouping* restated in both the Nix expression and the test, which is exactly the kind of second source of truth the file exists to prevent.
As written, the Nix expression is `[ sourcePackage.sessionStartHook ]` and the test is a deep-equality against the same value, so neither restates anything.
The file's contents were derived by observation — running the installed binary against a temporary home and reading what the agent SDK wrote — and the new test was confirmed to fail when the declaration is perturbed, rather than being assumed to bite.
### The module declares through `programs.claude-code`, not through `home.file`
Writing `~/.claude/settings.json` directly would collide with home-manager's own Claude Code module, so the module sets that module's options and lets home-manager's merge semantics compose.
Verified against real home-manager before landing, on five configurations: importing without enabling produces a **byte-identical** generation to never importing at all; enabling alongside a configuration that already declares its own SessionStart hook and its own skill yields both of each; `package = null` installs no binary but still declares the Skill; the skill-only toggle declares no hook; and omitting `programs.claude-code.enable` fails the assertion with the intended message.
### `package = null` still sources the Skill from the default build
The task called null "the documented opt-out for an operator who supplies the binary another way", which settles where the *binary* comes from but not where the Skill's bytes do.
They come from the default build, which for the intended case — a system-wide install of this same package — is already in the closure.
The sharp edge is an operator whose system-wide copy is a different build: their Skill would come from a package they are not running.
That is documented on the option itself rather than designed away, since the alternative is refusing to declare a Skill at all in the one arrangement the null value exists to serve.
### Two limitations documented rather than fixed
Home-manager inspects the Skill path during evaluation, so a rebuild realises the package at evaluation time even under `package = null`.
This is inherent to sourcing the Skill from the package and is a rebuild-latency cost, not a correctness one; the alternative would ignore `package` overrides entirely.
`programs.claude-code.skills` also accepts a bare path standing for a whole skills directory, and a configuration using that form cannot have an entry merged into it.
Both are recorded in INSTALL.md and in the ADR's Consequences.
### Follow-up worth flagging
The repository has no `README.md`, so `INSTALL.md` — which follows the existing convention of topic-scoped root documents alongside `PUBLISHING.md` — is discoverable only by browsing the repository, and is not in the npm `files` allowlist so it does not ship in the tarball.
Neither was changed here: adding a README is its own piece of work, and installation instructions inside an already-installed tarball are of little use.