feat(nix): install the CLI unconditionally, gate context per harness (task 0046) #55

Merged
alexion merged 1 commits from task-0046-reshape-hm-module-per-harness-toggle into main 2026-07-20 19:55:57 -04:00
7 changed files with 395 additions and 73 deletions
Showing only changes of commit 534097121d - Show all commits

View File

@@ -184,9 +184,16 @@ Chosen where the operator's agent configuration is generated and therefore read-
_Avoid_: nix install, declarative setup
**home-manager module**: The flake output that declares the Agent Skill and the [[SessionStart hook]] from the package, as the [[declarative install path]]'s ergonomic front end (task 0045).
Importing it does nothing until enabled; it installs the package by default, with a null package the documented way to declare configuration without installing the binary, and carries a toggle per managed piece.
Importing it does nothing until `programs.gitea-axi.enable` is set, which installs the binary unconditionally; a null package is the documented way to declare the context without installing the binary.
The agent context is gated by one [[harness integration toggle]] per harness rather than a toggle per artefact (ADR 0021).
The hook is declared through `programs.claude-code`'s settings option so home-manager merges it with the operator's own; the Skill is written through home-manager's file mechanism directly, which composes with both forms of the operator's own skills option and fixes the path-form collision ADR 0020 could only escape.
_Avoid_: nix module, HM module
**harness integration toggle**: The module option that declares a harness's agent context — for Claude Code, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting on, covering both the [[Agent Skill]] and the [[SessionStart hook]] (ADR 0021).
Named after home-manager's own `enableBashIntegration` convention, so a future harness reads as an `enableCodexIntegration` sibling.
Both artefacts land only when the harness's own module is enabled, silently and without an assertion; the Skill carries an explicit `programs.claude-code.enable` gate because, unlike the hook, it does not inherit that module's own gate.
_Avoid_: skill toggle, hook toggle, per-artefact toggle
**hook specification**: The single committed declaration of the [[SessionStart hook]]'s recorded shape — command, timeout, and matcher (task 0045).
Read by both the Nix expression and the test suite, so that the [[declarative install path]] and the [[imperative install path]] cannot disagree about what the hook is without failing a test.
_Avoid_: hook config, hook schema

View File

@@ -0,0 +1,79 @@
# Declare the Agent Skill through home.file and gate integration per harness
The home-manager module installs the gitea-axi binary whenever `programs.gitea-axi.enable` is set, and declares the Claude Code Agent Skill and SessionStart hook under a single per-harness toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting on.
Those two artefacts land only when `programs.claude-code.enable` is also on, and are silently absent otherwise — there is no assertion.
The Skill is written through home-manager's own file mechanism, into Claude Code's skills directory under the `gitea-axi` name, rather than by contributing to `programs.claude-code.skills`.
The hook is still declared through `programs.claude-code`'s settings option, unchanged.
This supersedes three decisions of [ADR 0020](0020-home-manager-module-for-declarative-context.md): the per-artefact toggles and their assertion, the Skill's declaration through the Claude Code module's skills option, and the position that the flake takes no home-manager input.
The rest of ADR 0020 stands.
## Context
ADR 0020 gave the Skill and the hook one toggle each, both defaulting on, and made enabling either without `programs.claude-code.enable` an assertion failure.
The assertion fires on the common case.
An operator who wants gitea-axi as a standalone CLI on a host without Claude Code has not made a mistake, but the module meets them with a build failure telling them to turn off two toggles.
Reaching the standalone shape means setting the module's `enable` to whatever `programs.claude-code.enable` is, and then setting `package = null` so the module does not install a binary the operator installs itself — leaving an `enable` that does not mean "enabled" and a `null` package that does not mean "no package".
Underneath that is a composition failure ADR 0020 documented as a limitation rather than fixed.
`programs.claude-code.skills` accepts either an attribute set of skills or a single path standing for a whole skills directory.
Contributing the module's Skill as an attribute entry cannot merge with an operator who set the option to a path: the two are different branches of the option's type, and the evaluation fails.
ADR 0020's remedy was to disable the Skill and place it by hand, which reintroduces the hand-copying-that-drifts the bundled Skill exists to prevent.
The two problems share a root.
The Skill was routed through a sibling module's typed option, so it inherited that option's merge behaviour — including the branch that cannot merge — and the whole integration was gated by an assertion because the declaration's only enforcement point was that assertion.
## Considered Options
**Default the toggles to `programs.claude-code.enable`** (rejected) — The upstream consumer's own suggestion.
It keys an option's default off a sibling module's config, which is the config-derived default the operator found objectionable, and it does not generalise to multiple harnesses without each toggle reaching into a different sibling.
Its stated diagnosis was also wrong: the friction is the assertion firing on the common case, not the module's `config` block sitting inside its own `enable` gate, which is unremarkable.
**Keep the per-artefact toggles, drop the assertion to a warning** (rejected) — Leaves the Skill routed through `programs.claude-code.skills`, so the path-form collision remains, and keeps a per-artefact surface that does not generalise to harnesses whose artefacts differ from Claude Code's.
**Write the Skill and the settings file directly, bypassing both sibling options** (rejected for the hook, adopted for the Skill) — ADR 0020 rejected this as one option, on the grounds that it collides with `programs.claude-code` owning the settings file.
That reasoning is sound for the hook and false for the Skill.
The settings file is a single file the Claude Code module owns wholesale, so writing it directly collides; Skill files are per-path, so writing the Skill's own path collides with nothing the operator has not themselves put there.
ADR 0020 bundled the Skill into a rejection only its sibling earned.
**Declare the Skill through home.file and gate integration per harness** (chosen) — The Skill is written as an ordinary file declaration at its own path, which never touches the skills option's type and so composes with both the attribute-set and whole-directory forms.
The per-artefact toggles collapse into one per-harness toggle whose name follows home-manager's `enableBashIntegration` convention and leaves room for sibling harnesses.
The assertion is removed: writing into a disabled sibling's option is a benign no-op by home-manager convention, and the Skill's own gate makes its absence equally silent.
## Consequences
- The Skill composes with an operator's own skills whichever form they use.
Under the attribute-set form the Claude Code module lowers each skill to its own file declaration, so the module's Skill is one more entry at a distinct name.
Under the whole-directory path form that module installs the directory recursively — individually-linked files under a real directory — so the module's nested Skill entry drops in as a sibling.
This was verified by building the home files derivation under both forms and confirming all entries coexist.
- The composition under the path form couples to the Claude Code module installing a path-form skills directory recursively.
A change there to a non-recursive install would claim the whole skills directory as one link and collide with the module's nested Skill as a build-time file conflict.
The failure would be loud and immediate rather than silent, and the flake check below is what would catch it.
- The Skill no longer inherits the Claude Code module's `enable`-gate, because it is declared by this module's own file mechanism rather than through that module's options.
The module therefore gates the Skill write on `programs.claude-code.enable` explicitly, alongside `programs.gitea-axi.enable` and the integration toggle.
This keeps the intended semantics — no Skill on a host without Claude Code — and keeps the package realisation lazy, since the file mechanism reads the Skill's source path while evaluating and an ungated declaration would realise the package on every host, even one installing nothing.
- The hook keeps its gate for free.
It is declared through the Claude Code module's settings option, which that module drops and never forces when it is disabled.
The two artefacts are thus gated by different mechanisms — the Skill by an explicit sibling-enable condition, the hook by the sibling module's own gate — and the module comments the asymmetry so it is not mistaken for an oversight.
- Granularity is per harness, not per artefact.
One toggle installs both Claude Code artefacts, and future harnesses read as `enableCodexIntegration` and `enableOpenCodeIntegration` siblings.
The per-artefact escape hatch ADR 0020 offered — disable the Skill, keep the hook — is no longer needed, because the case it existed for was the path-form collision, and that collision is now fixed rather than escaped.
- The integration toggle defaults to a literal `true`, not to `programs.claude-code.enable`.
The declared value stays constant, and the honest gating lives at the point of declaration rather than in a config-derived default.
- `nix flake check` now evaluates the module.
A home-manager input is added, with its nixpkgs following the flake's, so the module is checked against the same pairing a consumer following this flake would get.
This reverses ADR 0020's position that the flake takes no home-manager input and the module is verified only by a maintainer rebuild.
The reversal buys the first automated proof of the module's composition — the path-form coexistence, the attribute-set coexistence, the disabled-Claude-Code gating, and the hook merging into an operator's own hooks — where a rebuild proved it only after the fact and only on the maintainer's own configuration.
- `package = null` is unchanged.
It still declares the Skill and hook from the default build without adding the binary to the operator's packages, and the fallback that reads the default build's Skill is kept.
Under the new gating that fallback is reached only when Claude Code is enabled, so the upstream suggestion to revisit it as a wasteful second evaluation does not apply.
- ADR 0020's surviving decisions are unaffected: the hook declared through the Claude Code settings option, the module as a content-free wiring layer sourcing both artefacts from the package, the `hook specification` as the hook's single committed source of truth checked by both paths, and the absence of a declarative counterpart for Codex and OpenCode.

View File

@@ -0,0 +1,151 @@
## Problem Statement
The operator runs a NixOS flake that builds every host they own, and wants gitea-axi installed the way every other tool on the host is: its own [[home-manager module]] with its own enable flag, a standalone CLI alongside the other one-module-per-tool entries.
The agent context — the [[Agent Skill]] and the [[SessionStart hook]] — is a separate concern that should follow only when a harness is present, because gitea-axi is a working CLI without a harness and coupling it to Claude Code would turn adopting a different harness later into an unrelated dependency's problem.
The module as first shipped (task 0045, ADR 0020) does not allow that shape without steering it against its grain.
Enabling the Skill or the hook without `programs.claude-code.enable` is an assertion failure, and both toggles default on, so the common case — an operator who wants the CLI and does not use Claude Code — is a build failure that tells them to turn off two toggles.
Reaching the wanted shape means setting the module's `enable` to whatever Claude Code's `enable` is, and then setting `package = null` so the module does not also install a binary the operator is installing itself.
That leaves an option named `enable` that does not mean "gitea-axi is enabled" and a `null` package that does not mean "no package".
There is a second, quieter problem underneath.
The Skill is declared by contributing an attribute to `programs.claude-code.skills`, whose type accepts either an attribute set of skills or a single path standing for a whole skills directory.
An operator who uses the path form cannot have the module's Skill merged in: the two are different branches of the option's type and the module system cannot merge them, so the configuration fails to evaluate.
The module documents this as a limitation and offers a workaround — disable the Skill and place it by hand — which is exactly the hand-copying-that-drifts the bundled Skill exists to prevent.
## Solution
Reshape the module so that enabling gitea-axi means "install the CLI, always", and the agent context follows the harness that is present.
`programs.gitea-axi.enable` installs the binary unconditionally.
A single per-harness integration toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting on, declares both the Skill and the hook for Claude Code.
Those two artefacts land only when `programs.claude-code.enable` is also on; when it is off they are silently absent, with no assertion, matching how home-manager's own `enableBashIntegration`-style toggles behave against a disabled shell.
An operator who enables gitea-axi on a host without Claude Code gets the CLI and nothing else, which is the honest outcome and today's build failure.
The Skill is declared by writing it through home-manager's file mechanism directly, into Claude Code's skills directory, rather than by contributing to `programs.claude-code.skills`.
This composes with both forms of an operator's own skills option — the attribute-set form and the whole-directory path form — because it never touches that option's type, so the collision disappears at its root rather than being escaped by a toggle.
Because the Skill now rides the module's own file declaration rather than Claude Code's options, it no longer inherits Claude Code's own enable-gate for free, so the module gates the Skill write on `programs.claude-code.enable` explicitly.
The hook continues to be declared through Claude Code's settings option and continues to inherit that module's gate.
A home-manager flake input is added so that `nix flake check` can evaluate the module against real home-manager and prove the composition, replacing the previous position that the module's wiring was verified only by a maintainer rebuild.
## User Stories
1. As an operator, I want `programs.gitea-axi.enable = true` to install the CLI whether or not I use Claude Code, so that enabling a standalone tool does not require me to also run a harness.
2. As an operator without Claude Code, I want enabling gitea-axi to succeed and simply not install any agent context, so that I am not met with a build failure telling me to turn off toggles for a harness I never asked for.
3. As an operator with Claude Code, I want the Skill and the SessionStart hook to appear automatically when I enable gitea-axi, so that the ambient context follows the harness I have without my wiring each piece.
4. As an operator who wants the CLI but manages the agent context myself, I want to turn off the Claude Code integration with a single toggle, so that I keep the binary declaratively while writing the context by hand or with `setup`.
5. As an operator who already declares my own Claude Code skills as a whole directory, I want gitea-axi's Skill to install alongside them, so that adopting the module does not force me to restructure how I manage skills or hand-copy the Skill.
6. As an operator who already declares my own SessionStart hooks, I want gitea-axi's hook merged into mine rather than replacing them, so that composing the module with my configuration adds to it instead of colliding.
7. As an operator installing the binary another way, I want `package = null` to still declare the Skill and hook from the default build, so that a system-wide install keeps the declarative context.
8. As the maintainer, I want the module's composition proven by `nix flake check`, so that a change in home-manager or the Claude Code module that breaks the way the Skill is declared fails a check rather than a rebuild weeks later.
9. As the maintainer, I want a single per-harness toggle whose shape generalises, so that adding Codex or OpenCode later is a sibling toggle rather than a reshaping of the option surface.
## Implementation Decisions
### Option surface
The module keeps `programs.gitea-axi.enable` (install the binary) and `programs.gitea-axi.package` (the package to install, or `null` to declare the context without installing the binary), both unchanged in meaning.
The two per-artefact toggles the first version carried — one for the Skill, one for the hook — are removed and replaced by one per-harness toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting to `true`.
Granularity is per harness, not per artefact: the two Claude Code artefacts move together under one switch.
The name follows home-manager's own `enableBashIntegration` convention rather than a nested attribute set, so future harnesses read as `enableCodexIntegration` and `enableOpenCodeIntegration` siblings.
Removing the old toggles is free because the module is unreleased — it landed in a single commit and has no consumers to break.
The default is a literal `true`, not a value derived from `programs.claude-code.enable`.
A config-derived default was considered and rejected: it makes the option's declared value depend on a sibling module's config, and the honest gating is better expressed where the artefacts are declared than by shifting the option's default.
There is no central aggregator toggle over all harnesses yet.
It is the analogue of home-manager's `home.shell.enableShellIntegration` and earns its place only once a second harness exists to aggregate over; it can be added additively then, at which point the per-harness toggles change their default from literal `true` to the aggregator without a rename.
### How each artefact is declared
The hook is declared through the Claude Code module's own settings option, unchanged from ADR 0020.
That module owns the settings file wholesale, so writing it directly would collide; declaring through its option lets home-manager's merge semantics compose the module's hook with an operator's own.
This leg of ADR 0020 is sound and is kept.
The Skill is declared by writing it through home-manager's own file mechanism, into Claude Code's skills directory under the `gitea-axi` name, sourced from the package's published Skill.
It is no longer contributed to `programs.claude-code.skills`.
This is the change that fixes the path-form collision: the Skill write is an ordinary file declaration at a distinct path, not a contribution to the skills option's type, so it composes with both the attribute-set and whole-directory forms of that option.
The composition with the whole-directory form depends on the Claude Code module installing a path-form skills directory recursively — that is, as individually-linked files under a real directory, rather than as one symlink at the skills directory itself.
A recursive install leaves the skills directory a real directory into which the module's own nested Skill entry drops as a sibling; a non-recursive one would claim the whole directory as a single link and collide with any nested entry.
The Claude Code module installs recursively today, so the composition holds, and this is a coupling to that module's behaviour that the check below guards.
### Gating and evaluation cost
Because the Skill is declared by the module's own file mechanism rather than through the Claude Code module's options, it does not inherit that module's `enable`-gate.
The module therefore gates the Skill write on `programs.claude-code.enable` itself, in addition to `programs.gitea-axi.enable` and the integration toggle.
Without that gate the Skill would install on a host that has no Claude Code, contradicting the intended semantics, and would additionally force the package to be realised during evaluation on every host — the file mechanism reads the Skill's source path while evaluating, so an ungated declaration pays that realisation cost even where nothing consumes the Skill.
Gating on the sibling's `enable` keeps the realisation lazy: on a host without Claude Code the Skill's source is never read, so nothing is realised.
The hook needs no such explicit gate, because it is declared through the Claude Code module's option and that module already drops and never forces the declaration when it is disabled.
The two artefacts are therefore gated by different mechanisms internally — the Skill by an explicit sibling-enable condition, the hook by the sibling module's own gate — and the module comments the asymmetry.
### `package = null`
The `package = null` path is unchanged in intent.
It declares the Skill and hook from the default build without adding the binary to the operator's packages, for an operator who installs the binary another way, such as a system-wide package set.
The module reads the Skill and hook out of the operator-supplied package when one is given and out of the default build otherwise; that fallback is kept.
Its cost is narrow and was accepted: it is a second evaluation of the same derivation for one path, which reduces to a single build when the operator's system-wide install comes from the same package set, and under the new gating it is reached only when Claude Code is enabled.
### Flake input
A home-manager input is added to the flake, with its own nixpkgs following the flake's nixpkgs, so the module is checked against the same nixpkgs-and-home-manager pairing a consumer following this flake would get.
This reverses ADR 0020's position that the flake takes no home-manager input and the module is verified only by a maintainer rebuild.
The reversal is justified by the check below, which is the first automated verification of the module's composition — a thing a rebuild proves only after the fact and only on the maintainer's own configuration.
## Testing Decisions
A good test here asserts which files an operator's generation contains after enabling the module in a given configuration — that the Skill lands at its expected place alongside whatever skills the operator already declares, that the hook merges into the operator's own hooks, and that nothing lands when Claude Code is off.
It does not assert module internals: not option values, not the store paths involved, not the shape of the generated file mechanism, because those are implementation detail of the wiring and would have to change in lockstep with it.
### The seam
There is one new seam, at the highest point available: a flake check that evaluates the actual module through home-manager's standalone configuration entry point and inspects the resulting home files derivation.
Evaluating the real module against real home-manager is the highest seam because it exercises exactly what a consumer's rebuild would, short of a full system, and it is the only seam that can reach the module at all — the existing fast tier and installed-binary tier operate on the built CLI and never evaluate Nix modules.
The check builds the home files derivation under several configurations and asserts on the tree it produces:
- An operator declaring their own skills as an attribute set: the module's Skill lands alongside the operator's, each at its own name.
- An operator declaring their own skills as a whole directory: the module's Skill lands alongside the operator's directory contents.
This is the case that guards the recursive-install coupling; a regression in how the Claude Code module installs a path-form skills directory fails here as a build-time file collision.
- Claude Code disabled: no gitea-axi Skill entry is written.
This guards the explicit sibling-enable gate, which is a correctness risk this design introduces rather than one it inherits.
- The hook merges into an operator's own SessionStart hook list rather than replacing it.
Building the home files derivation is sufficient and does not require the Claude Code binary or a running agent; it is the file-linkage layer of home-manager, which is what actually decides whether two declarations collide.
The byte-identical-generation property — that importing the module without enabling it yields the same generation as never importing it — was considered as a check and rejected as too brittle to assert: home-manager churns generation internals across versions, so the assertion would fail on version drift that is not the module's bug.
The narrower disabled-Claude-Code assertion above captures the part of that property which is actually the module's contract.
### Prior art
The flake already exposes a checks output and the repository already treats `nix flake check` as the health-check command; this adds a module check alongside the existing package check rather than introducing a new kind of verification surface.
The composition assertions mirror the shape of the design-time probes that established the approach: build the home files derivation under a given configuration, then read the skills subtree to confirm which entries are present.
## Out of Scope
Codex and OpenCode declarative integration.
The option surface is shaped to accept sibling toggles for them, but no such toggle is added now, with no second harness module to test against and the imperative `setup hooks` path still available for those harnesses on a declarative host.
A central aggregator toggle over all harnesses.
It is deferred until a second harness exists, and is additive when it arrives.
Any change to the imperative install path.
`setup` and `setup hooks` are unchanged and remain fully supported; the two paths are alternatives, not stages.
Removing or reworking the `package = null` fallback.
It is kept as-is; the upstream suggestion to revisit it was examined and found not to apply under the new gating.
## Further Notes
This spec refines the home-manager module portion of the [nix-flake-packaging spec](nix-flake-packaging.md) and reverses specific decisions of [ADR 0020](../adr/0020-home-manager-module-for-declarative-context.md); the reasoning is recorded in [ADR 0021](../adr/0021-per-harness-integration-and-home-file-skill.md).
The legs of ADR 0020 that survive — the hook declared through the Claude Code settings option, the module as a content-free wiring layer, the `hook specification` as the hook's single committed source of truth, and the absence of a declarative counterpart for Codex and OpenCode — are unchanged.
The upstream consumer's own analysis proposed defaulting the context toggles to `programs.claude-code.enable`.
That proposal is not adopted: it keys an option's default off sibling config, which the operator disliked and which does not generalise cleanly to multiple harnesses, and its stated diagnosis — that the whole config block sitting inside the module's own `enable` gate is what forces the awkward wiring — was incorrect.
The forcing was the assertion, and removing the assertion while reshaping the toggles addresses the friction the analysis identified without the config-derived default.
INSTALL.md's options table, its `package = null` paragraph, and its paragraph documenting the path-form skills limitation are updated to match: the limitation paragraph is removed, because the limitation no longer exists.

View File

@@ -0,0 +1,48 @@
---
spec: hm-module-harness-integration
---
## What to build
Reshape the home-manager module so that enabling gitea-axi means "install the CLI, always", and the Claude Code agent context follows only when Claude Code is present.
`programs.gitea-axi.enable` installs the binary unconditionally.
The two per-artefact toggles (`skill.enable`, `sessionStartHook.enable`) and the assertion that fired when either was on without `programs.claude-code.enable` are removed, replaced by a single per-harness toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting to a literal `true`.
When the integration toggle is on, both Claude Code artefacts are declared; they land only when `programs.claude-code.enable` is also on, and are silently absent otherwise, with no assertion — matching how home-manager's own `enableBashIntegration`-style toggles behave against a disabled sibling.
The SessionStart hook stays declared through the Claude Code module's `settings.hooks.SessionStart` option, so it composes with an operator's own hooks and inherits that module's own enable-gate for free.
The Agent Skill moves off `programs.claude-code.skills` and is written through home-manager's own file mechanism, into Claude Code's skills directory under the `gitea-axi` name, sourced from the package's published Skill.
Because it no longer rides the Claude Code module's options, it no longer inherits that module's enable-gate, so the module gates the Skill write explicitly on `programs.claude-code.enable` (in addition to `enable` and the integration toggle).
This explicit gate also keeps package realisation lazy — the file mechanism reads the Skill's source path during evaluation, so an ungated write would realise the package on every host.
The module comments the resulting asymmetry: the Skill gated by an explicit sibling-enable condition, the hook by the sibling module's own gate.
Writing the Skill as an ordinary file declaration at its own path (rather than as a contribution to the skills option's type) fixes the path-form collision at its root: it composes with both the attribute-set and whole-directory forms of an operator's own `programs.claude-code.skills`.
`programs.gitea-axi.package` is unchanged in meaning, including the `package = null` path, which declares the Skill and hook from the default build without installing the binary.
INSTALL.md is updated to match the new option surface: the options table, the `package = null` paragraph, and the removal of the path-form skills limitation paragraph (the limitation no longer exists).
## Acceptance criteria
- [x] `programs.gitea-axi.enable = true` puts the binary on the operator's packages regardless of whether Claude Code is enabled.
- [x] `skill.enable`, `sessionStartHook.enable`, and the assertion are gone; `programs.gitea-axi.enableClaudeCodeIntegration` exists and defaults to a literal `true` (not derived from `programs.claude-code.enable`).
- [x] Enabling gitea-axi on a host with `programs.claude-code.enable = false` evaluates successfully and installs no Skill and no hook — no assertion failure.
- [x] With `enableClaudeCodeIntegration` and `programs.claude-code.enable` both on, the Skill is written through home-manager's file mechanism into Claude Code's skills directory under `gitea-axi`, and the hook is declared through `programs.claude-code.settings.hooks.SessionStart`.
- [x] The Skill write is gated on `programs.claude-code.enable` explicitly so the package's Skill source is not realised on a host without Claude Code; the hook has no such explicit gate and the asymmetry is commented.
- [x] `package = null` still declares the Skill and hook from the default build without adding the binary to the operator's packages.
- [x] INSTALL.md's options table and `package = null` paragraph reflect the new surface, and the path-form skills limitation paragraph is removed.
## Implementation Notes
The module reads Claude Code's skills location from `config.programs.claude-code.configDir` rather than hardcoding `.claude/skills`, mirroring the sibling module exactly so the Skill lands beside Claude Code's own skills wherever the operator points that option.
Confirmed against the current home-manager `claude-code` module: it lowers a path-form skills directory to a *recursive* `home.file` install (individually-linked files under `configDir/skills`), which is what lets the module's own `configDir/skills/gitea-axi` entry coexist as a sibling.
Task 0047 turns that coupling into an automated check.
The asymmetric gating the spec calls for is expressed structurally: the hook is declared under `enableClaudeCodeIntegration` alone and relies on the Claude Code module's own `mkIf enable` to drop it when disabled; the Skill adds a nested `mkIf claudeCode.enable` because writing through `home.file` does not inherit that gate, and the gate additionally keeps package realisation lazy.
The asymmetry is commented in the module.
Verified by evaluating the real module through `home-manager.lib.homeManagerConfiguration` (the same seam task 0047 automates) under four configurations: both-on (binary + Skill file + hook in settings), Claude Code off (binary only, no Skill, no assertion failure), integration off (binary only, no Skill, no hook), and `package = null` with Claude Code on (Skill declared, gitea-axi absent from `home.packages`).
All matched.
Task 0047 (the flake check and home-manager input) is staged in the same commit as an untracked planning artifact but is implemented separately.

View File

@@ -0,0 +1,36 @@
---
spec: hm-module-harness-integration
blocked-by: 0046-reshape-hm-module-per-harness-toggle
---
## What to build
Add the first automated proof of the module's composition, so that a change in home-manager or the Claude Code module that breaks the way the Skill is declared fails `nix flake check` rather than a maintainer rebuild weeks later.
A home-manager input is added to the flake, with its own nixpkgs following the flake's nixpkgs, so the module is checked against the same nixpkgs-and-home-manager pairing a consumer following this flake would get.
A flake check evaluates the actual module through home-manager's standalone configuration entry point and builds the resulting home files derivation under several configurations, asserting on the tree it produces.
Building the home files derivation is sufficient — it is the file-linkage layer that actually decides whether two declarations collide — and needs neither the Claude Code binary nor a running agent.
The check asserts on which files a generation contains, never on module internals (option values, store paths, the shape of the file mechanism).
The configurations exercised:
- An operator declaring their own skills as an attribute set: the module's Skill lands alongside the operator's, each at its own name.
- An operator declaring their own skills as a whole directory (path form): the module's Skill lands alongside the operator's directory contents.
This is the case that guards the recursive-install coupling; a regression to a non-recursive path-form install fails here as a build-time file collision.
- Claude Code disabled: no gitea-axi Skill entry is written.
This guards the explicit sibling-enable gate introduced in the reshape.
- The hook merges into an operator's own SessionStart hook list rather than replacing it.
This adds a module check alongside the existing package check in the flake's `checks` output rather than introducing a new kind of verification surface.
## Acceptance criteria
- [ ] The flake has a home-manager input whose nixpkgs follows the flake's nixpkgs; `flake.lock` is updated.
- [ ] A new check under the flake's `checks` output evaluates the real module through home-manager's standalone configuration entry point and builds the home files derivation — no Claude Code binary or running agent required.
- [ ] The attribute-set-skills configuration asserts the module's Skill and the operator's skill both land, each at its own name.
- [ ] The whole-directory-skills (path form) configuration asserts the module's Skill lands alongside the operator's directory contents; a non-recursive path-form install would fail this as a build-time collision.
- [ ] The Claude-Code-disabled configuration asserts no gitea-axi Skill entry is written.
- [ ] A configuration with an operator's own SessionStart hook asserts the module's hook merges into that list rather than replacing it.
- [ ] The check asserts on the generation's file tree only, not on option values or store paths.
- [ ] `nix flake check` runs the new check across the flake's systems and passes.

View File

@@ -68,32 +68,26 @@ Use the module instead ([ADR 0020](.claude/adr/0020-home-manager-module-for-decl
}
```
That installs the package, declares the Agent Skill, and registers the SessionStart hook.
`programs.gitea-axi.enable` installs the CLI, always.
The Claude Code context — the Agent Skill and the SessionStart hook — follows the harness: it is declared under a single toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, on by default, and lands only when `programs.claude-code.enable` is also on.
Enable gitea-axi on a host without Claude Code and you get the CLI and nothing else — no assertion, no toggles to turn off.
Importing the module without setting `enable` changes nothing at all.
It declares both pieces through `programs.claude-code`'s own options, so a configuration that already sets `programs.claude-code.skills` or its own `settings.hooks.SessionStart` gets gitea-axi's merged in alongside rather than colliding with it.
That module must be enabled; if it is not, the declarations would be silently dropped, so this is an assertion failure instead.
Two requirements on the surrounding configuration:
- Your home-manager must be recent enough to have `programs.claude-code.skills`.
- You must be using the *attribute-set* form of that option, `skills.<name> = ...`.
Home-manager also accepts a single path there, standing for a whole skills directory, and that form takes over the option outright — a configuration using it cannot have gitea-axi's skill merged in, and gets a type-merge error rather than composition.
Switch to the attribute-set form, or set `programs.gitea-axi.skill.enable = false` and place the Skill in your own directory.
The hook is declared through `programs.claude-code`'s own settings option, so a configuration that already sets its own `settings.hooks.SessionStart` gets gitea-axi's merged in alongside rather than colliding.
The Skill is written straight into Claude Code's skills directory, so it composes with your own skills whether you declare them as `programs.claude-code.skills.<name> = ...` or as a single path standing for a whole directory — there is no form you have to switch to.
#### Options
| Option | Default | Meaning |
| --- | --- | --- |
| `programs.gitea-axi.enable` | `false` | Switches everything below on. |
| `programs.gitea-axi.package` | your `pkgs`' build of the package | The package to install, or `null` to install nothing. |
| `programs.gitea-axi.skill.enable` | `true` | Declare the Agent Skill. |
| `programs.gitea-axi.sessionStartHook.enable` | `true` | Declare the SessionStart hook. |
| `programs.gitea-axi.enable` | `false` | Install the CLI. |
| `programs.gitea-axi.package` | your `pkgs`' build of the package | The package to install, or `null` to declare the context without the binary. |
| `programs.gitea-axi.enableClaudeCodeIntegration` | `true` | Declare the Agent Skill and SessionStart hook when Claude Code is enabled. |
`package = null` is the documented way to declare the context without installing the binary — for instance when you install gitea-axi system-wide through `environment.systemPackages`.
The hook records the bare name `gitea-axi` and lets `PATH` resolve it ([ADR 0019](.claude/adr/0019-hook-records-search-path-name.md)), so a binary installed anywhere on `PATH` satisfies it.
`package = null` declares the context without installing the binary — for instance when you install gitea-axi system-wide through `environment.systemPackages`.
The hook records the bare name `gitea-axi` and lets `PATH` resolve it ([ADR 0019](.claude/adr/0019-hook-records-search-path-name.md)), so a binary installed anywhere on `PATH` satisfies it, and the Skill is still taken from the default build.
The two toggles let you mix the paths: turn one off and write that piece with `setup` while the module manages the other.
Turn `enableClaudeCodeIntegration` off to keep the CLI declarative while writing the Skill and hook yourself with `setup`.
#### What the module does not cover

View File

@@ -1,11 +1,17 @@
# A home-manager module declaring gitea-axi's ambient context — the bundled
# Agent Skill and the SessionStart hook — for an operator whose agent
# configuration is generated rather than owned (ADR 0020).
# A home-manager module installing gitea-axi and, when a harness is present,
# its ambient context — the bundled Agent Skill and the SessionStart hook
# (ADR 0020, reshaped by ADR 0021).
#
# It is a wiring layer and nothing more. Both pieces come from the package's
# published attributes, so what a declarative configuration installs and what
# `gitea-axi setup` writes imperatively are the same two artefacts, and neither
# is restated here.
# `programs.gitea-axi.enable` installs the CLI, always. The Claude Code context
# follows the harness: it is declared under one per-harness toggle and lands
# only when `programs.claude-code.enable` is also on. gitea-axi is a working CLI
# without a harness, so enabling it on a host with no Claude Code installs the
# binary and nothing else, with no assertion.
#
# The module is a wiring layer and nothing more. Both artefacts come from the
# package's published attributes, so what a declarative configuration installs
# and what `gitea-axi setup` writes imperatively are the same two artefacts, and
# neither is restated here.
#
# Importing this module changes nothing until `programs.gitea-axi.enable` is set.
{
@@ -26,8 +32,6 @@ let
defaultPackage = pkgs.callPackage ./package.nix { };
# Both integrations write into files a sibling module owns, so nothing lands
# unless that module is the one writing them.
claudeCode = config.programs.claude-code;
in
{
@@ -39,9 +43,9 @@ in
default = defaultPackage;
defaultText = lib.literalExpression "pkgs.callPackage ./package.nix { }";
description = ''
The gitea-axi package to install, or `null` to declare the
configuration without installing the binary for an operator who
supplies it another way, such as `environment.systemPackages`.
The gitea-axi package to install, or `null` to declare the ambient
context without installing the binary for an operator who supplies it
another way, such as `environment.systemPackages`.
The SessionStart hook records a name resolved on `PATH`, so a binary
installed elsewhere satisfies it. With `null` the Agent Skill is still
@@ -49,27 +53,20 @@ in
'';
};
skill.enable = lib.mkOption {
enableClaudeCodeIntegration = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to install gitea-axi's bundled Agent Skill, which teaches the
agent to reach for gitea-axi over `tea` or raw API calls.
Whether to declare gitea-axi's Claude Code context the bundled Agent
Skill and the SessionStart hook alongside the CLI.
Turn this off to keep writing the Skill with `gitea-axi setup` while
managing the rest declaratively.
'';
};
Both artefacts land only when `programs.claude-code.enable` is also on;
with it off they are silently absent, matching how home-manager's own
`enableBashIntegration`-style toggles behave against a disabled sibling.
sessionStartHook.enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to register the SessionStart hook that renders the gitea-axi
dashboard at the start of an agent session.
Turn this off to keep writing the hook with `gitea-axi setup hooks`
while managing the rest declaratively.
Turn this off to install gitea-axi declaratively while writing the
Claude Code context by hand or with `gitea-axi setup`. The toggle
generalises: a future harness reads as `enableCodexIntegration`.
'';
};
};
@@ -78,34 +75,44 @@ in
lib.mkMerge [
(lib.mkIf (cfg.package != null) { home.packages = [ cfg.package ]; })
# Declared through the Claude Code module's own options rather than by
# writing its files, so an operator who already declares Skills and
# SessionStart hooks gets ours merged into theirs instead of a collision.
(lib.mkIf cfg.skill.enable {
programs.claude-code.skills.gitea-axi = sourcePackage.skill;
})
# The two Claude Code artefacts move together under one per-harness
# toggle. They are gated by different mechanisms internally — the Skill by
# an explicit `claude-code.enable` condition, the hook by the Claude Code
# module's own gate — because of how each is declared, below.
(lib.mkIf cfg.enableClaudeCodeIntegration (
lib.mkMerge [
# The hook is declared through the Claude Code module's own settings
# option. That composes it with an operator's own SessionStart hooks
# instead of colliding, and the module drops the declaration for free
# when it is disabled — so no explicit `claude-code.enable` gate here.
{ programs.claude-code.settings.hooks.SessionStart = [ sourcePackage.sessionStartHook ]; }
(lib.mkIf cfg.sessionStartHook.enable {
programs.claude-code.settings.hooks.SessionStart = [ sourcePackage.sessionStartHook ];
})
{
# Without this the options above are set and silently dropped, leaving
# an operator with a configuration that says the Skill is installed and
# a session that never sees it.
assertions = [
{
assertion = (cfg.skill.enable || cfg.sessionStartHook.enable) -> claudeCode.enable;
message = ''
programs.gitea-axi declares a Claude Code Agent Skill and session
hook, which programs.claude-code writes. Set
programs.claude-code.enable = true, or turn off
programs.gitea-axi.skill.enable and
programs.gitea-axi.sessionStartHook.enable.
'';
}
];
}
# The Skill is written as an ordinary file into Claude Code's skills
# directory, rather than contributed to `programs.claude-code.skills`.
# That composes with both forms of the operator's own skills option —
# an attribute set and a single path for a whole directory — because
# it never touches that option's type, so the path-form collision
# disappears at its root instead of being escaped by a toggle.
#
# Writing through home.file does not inherit the Claude Code module's
# `enable`-gate the way declaring through its options does, so the
# Skill is gated on `claude-code.enable` explicitly. The gate is also
# what keeps package realisation lazy: home.file reads the Skill's
# source directory while evaluating, so an ungated write would realise
# the package on every host — even one with no Claude Code that
# installs nothing from it.
#
# `configDir` is read from the Claude Code module rather than
# hardcoded, so the Skill lands beside that module's own skills
# wherever the operator points it.
(lib.mkIf claudeCode.enable {
home.file."${claudeCode.configDir}/skills/gitea-axi" = {
source = sourcePackage.skill;
recursive = true;
};
})
]
))
]
);
}