feat(nix): expose declarative outputs and a home-manager module (task 0045) #54
@@ -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.
|
||||||
@@ -46,16 +46,19 @@ Alongside the flake, the continuous integration workflow moves off end-of-life N
|
|||||||
|
|
||||||
### Flake surface
|
### 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.
|
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.
|
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.
|
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.
|
Both remain purely additive to add later.
|
||||||
|
|
||||||
A home-manager module is explicitly deferred rather than rejected.
|
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.
|
||||||
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.
|
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.
|
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.
|
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.
|
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.
|
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.
|
An overlay output and a NixOS module output.
|
||||||
|
|
||||||
A direnv configuration.
|
A direnv configuration.
|
||||||
|
|||||||
@@ -32,14 +32,52 @@ The two installation paths remain independent and both supported: the command fo
|
|||||||
|
|
||||||
## Acceptance criteria
|
## Acceptance criteria
|
||||||
|
|
||||||
- [ ] The bundled Agent Skill is installed to a stable location in the package output that is not an internal implementation path.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [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.
|
||||||
- [ ] The Skill and the hook each have their own toggle, both defaulting to on.
|
- [x] 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.
|
- [x] 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.
|
- [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.
|
||||||
- [ ] The user-facing documentation describes both installation paths and when each applies.
|
- [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.
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ A new build-relevant top-level file — a TypeScript configuration, a runner con
|
|||||||
The failure is loud but disconnected from its cause: the error names the missing file, not the allowlist that omitted it.
|
The failure is loud but disconnected from its cause: the error names the missing file, not the allowlist that omitted it.
|
||||||
The flip side is the point of the design — touching an ADR, a spec, a task, a `bench/` file, or prose documentation must *not* change the derivation's output path.
|
The flip side is the point of the design — touching an ADR, a spec, a task, a `bench/` file, or prose documentation must *not* change the derivation's output path.
|
||||||
|
|
||||||
|
`nix flake check` on a **dirty working tree** intermittently fails with `error: path '<hash>-source' is not valid`, naming the store path of the derivation's filtered source.
|
||||||
|
It is an evaluation-cache artefact of the dirty-tree flake source being re-created, not a fault in the expression — running `nix build .#gitea-axi --dry-run` first makes the very next `nix flake check` pass unchanged.
|
||||||
|
Reach for that before debugging the `lib.fileset` allowlist, which the error's wording otherwise points straight at.
|
||||||
|
|
||||||
`buildNpmPackage` provides **no check hook**, so `doCheck = true` on its own is silently inert — the build logs `no Makefile or custom checkPhase, doing nothing` and ships a package whose tests never ran.
|
`buildNpmPackage` provides **no check hook**, so `doCheck = true` on its own is silently inert — the build logs `no Makefile or custom checkPhase, doing nothing` and ships a package whose tests never ran.
|
||||||
Running the fast tier inside the derivation requires an explicit `checkPhase`; it also needs `git` and `which` in `nativeCheckInputs` and a writable `HOME`, since some of those tests shell out to `git`.
|
Running the fast tier inside the derivation requires an explicit `checkPhase`; it also needs `git` and `which` in `nativeCheckInputs` and a writable `HOME`, since some of those tests shell out to `git`.
|
||||||
|
|
||||||
|
|||||||
111
INSTALL.md
Normal file
111
INSTALL.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Installing gitea-axi
|
||||||
|
|
||||||
|
gitea-axi is a CLI plus two pieces of *ambient context* it installs for your agent:
|
||||||
|
|
||||||
|
- an **Agent Skill**, which teaches the agent to reach for gitea-axi instead of `tea`, raw API calls, or improvised `git`;
|
||||||
|
- a **SessionStart hook**, which renders the repository dashboard at the start of every agent session.
|
||||||
|
|
||||||
|
Installing the binary and installing that context are separate steps, and the second one has two paths.
|
||||||
|
Which path you want depends on who owns `~/.claude`.
|
||||||
|
|
||||||
|
## Installing the binary
|
||||||
|
|
||||||
|
### npm
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install -g gitea-axi
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing has been published to the registry yet, so this works only once the first release lands — see [PUBLISHING.md](PUBLISHING.md).
|
||||||
|
Until then, the Nix path below and a local `npm pack` are the working ones.
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
The flake exposes the package as `packages.<system>.gitea-axi`, with `default` as an alias.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nix run git+https://git.alexion.dev/alexion/gitea-axi -- --help
|
||||||
|
```
|
||||||
|
|
||||||
|
To install it from a system configuration, add the flake as an input and put `gitea-axi.packages.${system}.default` in `environment.systemPackages`.
|
||||||
|
Point the flake's `nixpkgs` input at your own to deduplicate.
|
||||||
|
|
||||||
|
gitea-axi shells out to `git` and to `tea` — the latter for credential discovery, per [ADR 0001](.claude/adr/0001-diff-auth-via-tea-login-list.md).
|
||||||
|
The Nix package wraps the binary so both are reachable without your installing them, while still preferring your own where you have them ([ADR 0018](.claude/adr/0018-nix-wrapper-defers-to-operator-binaries.md)).
|
||||||
|
|
||||||
|
## Installing the ambient context
|
||||||
|
|
||||||
|
### The `setup` command, if you own your agent configuration
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-axi setup # the Agent Skill
|
||||||
|
gitea-axi setup hooks # and the SessionStart hook
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are idempotent, and there is no postinstall script — installation is always explicit ([ADR 0009](.claude/adr/0009-setup-command-over-postinstall.md)).
|
||||||
|
`setup hooks` covers three agents: Claude Code, Codex, and OpenCode.
|
||||||
|
|
||||||
|
This is the right path when the files under `~/.claude` (and `~/.codex`, and `~/.config/opencode`) are yours to write.
|
||||||
|
|
||||||
|
### The home-manager module, if your configuration owns them
|
||||||
|
|
||||||
|
If your agent configuration is generated declaratively, `setup` cannot write to it — the targets are read-only, and gitea-axi reports that rather than failing obscurely.
|
||||||
|
Use the module instead ([ADR 0020](.claude/adr/0020-home-manager-module-for-declarative-context.md)).
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
inputs.gitea-axi.url = "git+https://git.alexion.dev/alexion/gitea-axi";
|
||||||
|
inputs.gitea-axi.inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
imports = [ inputs.gitea-axi.homeModules.default ];
|
||||||
|
|
||||||
|
programs.claude-code.enable = true;
|
||||||
|
programs.gitea-axi.enable = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
That installs the package, declares the Agent Skill, and registers the SessionStart hook.
|
||||||
|
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.
|
||||||
|
|
||||||
|
#### 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. |
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
The two toggles let you mix the paths: turn one off and write that piece with `setup` while the module manages the other.
|
||||||
|
|
||||||
|
#### What the module does not cover
|
||||||
|
|
||||||
|
Only the Claude Code integration is declarative.
|
||||||
|
The Codex and OpenCode files that `setup hooks` also writes have no home-manager module owning them, so gitea-axi does not write them declaratively either.
|
||||||
|
On a declarative system those targets are unmanaged and therefore writable, so `gitea-axi setup hooks` still installs them.
|
||||||
|
|
||||||
|
## Verifying an install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitea-axi --help
|
||||||
|
gitea-axi # the dashboard, from inside a Gitea repository
|
||||||
|
```
|
||||||
|
|
||||||
|
A SessionStart hook takes effect on the next agent session, not the current one.
|
||||||
11
flake.nix
11
flake.nix
@@ -41,6 +41,17 @@
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
# Declarative ambient context for an operator whose agent configuration is
|
||||||
|
# generated rather than owned (ADR 0020). Not per-system: it is a module
|
||||||
|
# function, and the package it defaults to comes from the importing
|
||||||
|
# configuration's own `pkgs` rather than from this flake's nixpkgs — which
|
||||||
|
# is how a consumer deduplicates, and the same reason the derivation is a
|
||||||
|
# callable expression rather than a flake-bound one.
|
||||||
|
homeModules = rec {
|
||||||
|
gitea-axi = ./home-manager-module.nix;
|
||||||
|
default = gitea-axi;
|
||||||
|
};
|
||||||
|
|
||||||
# The toolchain the repository actually needs: the build and the fast tier
|
# The toolchain the repository actually needs: the build and the fast tier
|
||||||
# want Node, the live end-to-end tier and the benchmark harness additionally
|
# want Node, the live end-to-end tier and the benchmark harness additionally
|
||||||
# shell out to `git`, `tea`, and `curl` — none of which the repository
|
# shell out to `git`, `tea`, and `curl` — none of which the repository
|
||||||
|
|||||||
111
home-manager-module.nix
Normal file
111
home-manager-module.nix
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 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).
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# Importing this module changes nothing until `programs.gitea-axi.enable` is set.
|
||||||
|
{
|
||||||
|
config,
|
||||||
|
lib,
|
||||||
|
pkgs,
|
||||||
|
...
|
||||||
|
}:
|
||||||
|
let
|
||||||
|
cfg = config.programs.gitea-axi;
|
||||||
|
|
||||||
|
# The package the declarations are read out of. `package = null` opts out of
|
||||||
|
# putting the binary on PATH, not out of the configuration — an operator who
|
||||||
|
# installs gitea-axi system-wide still wants the Skill — so the declarations
|
||||||
|
# fall back to the default build, which in that arrangement is already in the
|
||||||
|
# closure anyway.
|
||||||
|
sourcePackage = if cfg.package != null then cfg.package else defaultPackage;
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
options.programs.gitea-axi = {
|
||||||
|
enable = lib.mkEnableOption "gitea-axi, an agent-ergonomic CLI for Gitea issues and pull requests";
|
||||||
|
|
||||||
|
package = lib.mkOption {
|
||||||
|
type = lib.types.nullOr lib.types.package;
|
||||||
|
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 SessionStart hook records a name resolved on `PATH`, so a binary
|
||||||
|
installed elsewhere satisfies it. With `null` the Agent Skill is still
|
||||||
|
taken from the default build.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
skill.enable = 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.
|
||||||
|
|
||||||
|
Turn this off to keep writing the Skill with `gitea-axi setup` while
|
||||||
|
managing the rest declaratively.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
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.
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable (
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
|
||||||
|
(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.
|
||||||
|
'';
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
55
package.nix
55
package.nix
@@ -19,6 +19,20 @@ let
|
|||||||
# reading it here means a store path and a released version cannot disagree.
|
# reading it here means a store path and a released version cannot disagree.
|
||||||
manifest = lib.importJSON ./package.json;
|
manifest = lib.importJSON ./package.json;
|
||||||
|
|
||||||
|
# Where the bundled Agent Skill lands in the output, and the one address a
|
||||||
|
# consumer may depend on. The Skill's other copy — inside the installed node
|
||||||
|
# modules tree, where `setup` resolves it relative to its own module — is an
|
||||||
|
# artefact of how the command finds it at runtime, and moves whenever the
|
||||||
|
# packaging method changes.
|
||||||
|
skillSubdir = "share/gitea-axi/skills/gitea-axi";
|
||||||
|
|
||||||
|
# The SessionStart hook entry, read from the committed specification rather
|
||||||
|
# than written out here. The imperative `setup hooks` writes this same entry
|
||||||
|
# through the agent SDK, and a test drives it and asserts the two agree — so
|
||||||
|
# declaring it a second time in Nix would be a second source of truth with
|
||||||
|
# nothing checking it against the first.
|
||||||
|
sessionStartHook = lib.importJSON ./session-start-hook.json;
|
||||||
|
|
||||||
# An explicit allowlist of what the build and its tests actually read. The
|
# An explicit allowlist of what the build and its tests actually read. The
|
||||||
# repository's highest-churn directories — .claude, bench, prose docs — are
|
# repository's highest-churn directories — .claude, bench, prose docs — are
|
||||||
# all build-irrelevant, so a whole-repository source would let writing an ADR
|
# all build-irrelevant, so a whole-repository source would let writing an ADR
|
||||||
@@ -43,10 +57,14 @@ let
|
|||||||
./tsconfig.build.json
|
./tsconfig.build.json
|
||||||
./vitest.config.ts
|
./vitest.config.ts
|
||||||
./vitest.packaging.config.ts
|
./vitest.packaging.config.ts
|
||||||
|
# Read by the fast tier, which asserts the imperative hook install writes
|
||||||
|
# what this declares. Also read at evaluation time above, but that read is
|
||||||
|
# of the flake source rather than of `src` and would not require it here.
|
||||||
|
./session-start-hook.json
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
in
|
in
|
||||||
buildNpmPackage {
|
buildNpmPackage (finalAttrs: {
|
||||||
pname = "gitea-axi";
|
pname = "gitea-axi";
|
||||||
inherit (manifest) version;
|
inherit (manifest) version;
|
||||||
inherit src nodejs;
|
inherit src nodejs;
|
||||||
@@ -101,9 +119,17 @@ buildNpmPackage {
|
|||||||
# ADR 0018: append, never prepend. The operator's own `tea` owns the
|
# ADR 0018: append, never prepend. The operator's own `tea` owns the
|
||||||
# credential store it refreshes in place, so the closure's copy is a
|
# credential store it refreshes in place, so the closure's copy is a
|
||||||
# fresh-machine fallback rather than an override.
|
# fresh-machine fallback rather than an override.
|
||||||
|
#
|
||||||
|
# The Agent Skill is also published under a stable address (ADR 0020), so a
|
||||||
|
# Nix expression can install it declaratively without reaching into the node
|
||||||
|
# modules tree. `cp` failing on a missing source is the guard that this
|
||||||
|
# address keeps pointing at something.
|
||||||
postInstall = ''
|
postInstall = ''
|
||||||
wrapProgram $out/bin/gitea-axi \
|
wrapProgram $out/bin/gitea-axi \
|
||||||
--suffix PATH : ${lib.makeBinPath [ git tea ]}
|
--suffix PATH : ${lib.makeBinPath [ git tea ]}
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$out/${skillSubdir}")"
|
||||||
|
cp -R skills/gitea-axi "$out/${skillSubdir}"
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# Drive the binary that was just installed through the shared installed-binary
|
# Drive the binary that was just installed through the shared installed-binary
|
||||||
@@ -144,11 +170,26 @@ buildNpmPackage {
|
|||||||
runHook postInstallCheck
|
runHook postInstallCheck
|
||||||
'';
|
'';
|
||||||
|
|
||||||
# The Node the package is built against, published as a declared interface
|
# The package's declared interface to Nix expressions, published rather than
|
||||||
# rather than left to be read off the build environment. The flake's dev shell
|
# left to be read off the build environment or guessed at from the output's
|
||||||
# consumes exactly this, so the two cannot drift onto different majors — and
|
# layout. Every attribute here has a consumer that breaks if it is removed.
|
||||||
# this attribute is why that holds, so removing it breaks the shell.
|
passthru = {
|
||||||
passthru = { inherit nodejs; };
|
# The Node the package is built against. The flake's dev shell consumes
|
||||||
|
# exactly this, so development and the shipped artifact cannot drift onto
|
||||||
|
# different majors.
|
||||||
|
inherit nodejs;
|
||||||
|
|
||||||
|
# The bundled Agent Skill's directory, for a configuration that installs it
|
||||||
|
# declaratively. A directory rather than the SKILL.md inside it, so a Skill
|
||||||
|
# that grows helper files stays one reference.
|
||||||
|
skill = "${finalAttrs.finalPackage}/${skillSubdir}";
|
||||||
|
|
||||||
|
# The SessionStart hook entry, verbatim as it belongs in a Claude Code
|
||||||
|
# settings.json. Evaluating this builds nothing: it is the committed
|
||||||
|
# specification, and the command it records is a name resolved on PATH
|
||||||
|
# rather than a store path (ADR 0019).
|
||||||
|
inherit sessionStartHook;
|
||||||
|
};
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
inherit (manifest) description homepage;
|
inherit (manifest) description homepage;
|
||||||
@@ -166,4 +207,4 @@ buildNpmPackage {
|
|||||||
# evaluate. Consumed against 26.05, x86_64-darwin builds fine from here.
|
# evaluate. Consumed against 26.05, x86_64-darwin builds fine from here.
|
||||||
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
platforms = lib.platforms.linux ++ lib.platforms.darwin;
|
||||||
};
|
};
|
||||||
}
|
})
|
||||||
|
|||||||
10
session-start-hook.json
Normal file
10
session-start-hook.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"matcher": "",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "gitea-axi",
|
||||||
|
"timeout": 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -91,9 +91,52 @@ function writeFakeBinary(dir: string, contents: string): string {
|
|||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The SessionStart entry declared in `session-start-hook.json`, the committed
|
||||||
|
* specification the Nix home-manager module writes into a declarative
|
||||||
|
* configuration.
|
||||||
|
*
|
||||||
|
* That module and this command are two ways to arrive at the same entry, with
|
||||||
|
* nothing structural keeping them agreed — so the specification is read here
|
||||||
|
* rather than restated, and the assertion below is what holds them together. It
|
||||||
|
* fails if either side drifts, including if the agent SDK changes the envelope
|
||||||
|
* it writes out from under the imperative path.
|
||||||
|
*/
|
||||||
|
function declaredSessionStartEntry(): unknown {
|
||||||
|
return JSON.parse(
|
||||||
|
readFileSync(new URL("../session-start-hook.json", import.meta.url), "utf8"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `setup hooks` with a wrapper-based install of this entrypoint on PATH.
|
||||||
|
*
|
||||||
|
* That is a wrapper-based install in miniature — a script that *invokes* the
|
||||||
|
* entrypoint, so its realpath is itself and the agent SDK could never match it
|
||||||
|
* from the entrypoint's side — and it is the shape every real install produces,
|
||||||
|
* Nix's included. It is also the only arrangement in which the bare name gets
|
||||||
|
* recorded, so any assertion about that name has to arrange it first.
|
||||||
|
*/
|
||||||
|
async function installHooksBehindWrapper(home: string): Promise<void> {
|
||||||
|
const binDir = writeFakeBinary(
|
||||||
|
join(home, "wrapper"),
|
||||||
|
`#!/bin/sh\nexec node ${entrypointPath()} "$@"\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { exitCode } = await withPath(`${binDir}${delimiter}${process.env.PATH ?? ""}`, () =>
|
||||||
|
runCliTest(["setup", "hooks"], { env: { HOME: home } }),
|
||||||
|
);
|
||||||
|
expect(exitCode).toBe(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Claude Code settings the hook install wrote into `home`. */
|
||||||
|
function claudeSettings(home: string) {
|
||||||
|
return JSON.parse(readFileSync(join(home, ".claude", "settings.json"), "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
/** The single command string recorded in the Claude Code SessionStart hook. */
|
/** The single command string recorded in the Claude Code SessionStart hook. */
|
||||||
function recordedHookCommand(home: string): string {
|
function recordedHookCommand(home: string): string {
|
||||||
const settings = JSON.parse(readFileSync(join(home, ".claude", "settings.json"), "utf8"));
|
const settings = claudeSettings(home);
|
||||||
expect(settings.hooks.SessionStart).toHaveLength(1);
|
expect(settings.hooks.SessionStart).toHaveLength(1);
|
||||||
expect(settings.hooks.SessionStart[0].hooks).toHaveLength(1);
|
expect(settings.hooks.SessionStart[0].hooks).toHaveLength(1);
|
||||||
return settings.hooks.SessionStart[0].hooks[0].command;
|
return settings.hooks.SessionStart[0].hooks[0].command;
|
||||||
@@ -222,31 +265,27 @@ describe("setup hooks", () => {
|
|||||||
const second = await runCliTest(["setup", "hooks"], { env: { HOME: tempHome } });
|
const second = await runCliTest(["setup", "hooks"], { env: { HOME: tempHome } });
|
||||||
expect(second.exitCode).toBe(0);
|
expect(second.exitCode).toBe(0);
|
||||||
|
|
||||||
const claudeSettingsPath = join(tempHome, ".claude", "settings.json");
|
const settings = claudeSettings(tempHome);
|
||||||
const claudeSettings = JSON.parse(readFileSync(claudeSettingsPath, "utf8"));
|
expect(settings.hooks.SessionStart).toHaveLength(1);
|
||||||
expect(claudeSettings.hooks.SessionStart).toHaveLength(1);
|
expect(settings.hooks.SessionStart[0].hooks).toHaveLength(1);
|
||||||
expect(claudeSettings.hooks.SessionStart[0].hooks).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("records the bare binary name when a wrapper on PATH runs this entrypoint", async () => {
|
it("records the bare binary name when a wrapper on PATH runs this entrypoint", async () => {
|
||||||
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
|
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
|
||||||
|
|
||||||
// A wrapper-based install in miniature — a script that *invokes* the
|
await installHooksBehindWrapper(tempHome);
|
||||||
// entrypoint, so its realpath is itself and the SDK could never match it
|
|
||||||
// from the entrypoint's side. This is the shape Nix installs.
|
|
||||||
const binDir = writeFakeBinary(
|
|
||||||
join(tempHome, "wrapper"),
|
|
||||||
`#!/bin/sh\nexec node ${entrypointPath()} "$@"\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { exitCode } = await withPath(`${binDir}${delimiter}${process.env.PATH ?? ""}`, () =>
|
|
||||||
runCliTest(["setup", "hooks"], { env: { HOME: tempHome } }),
|
|
||||||
);
|
|
||||||
expect(exitCode).toBe(0);
|
|
||||||
|
|
||||||
expect(recordedHookCommand(tempHome)).toBe("gitea-axi");
|
expect(recordedHookCommand(tempHome)).toBe("gitea-axi");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes exactly the SessionStart entry the packaged specification declares", async () => {
|
||||||
|
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
|
||||||
|
|
||||||
|
await installHooksBehindWrapper(tempHome);
|
||||||
|
|
||||||
|
expect(claudeSettings(tempHome).hooks.SessionStart).toEqual([declaredSessionStartEntry()]);
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to the absolute entrypoint path when gitea-axi is not on PATH", async () => {
|
it("falls back to the absolute entrypoint path when gitea-axi is not on PATH", async () => {
|
||||||
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
|
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
|
||||||
const emptyDir = join(tempHome, "empty");
|
const emptyDir = join(tempHome, "empty");
|
||||||
|
|||||||
Reference in New Issue
Block a user