Compare commits

..

3 Commits

Author SHA1 Message Date
4e9e4cb9d3 Merge remote-tracking branch 'origin/main' into task-0034-pr-review-anchor-fields
All checks were successful
CI / test (pull_request) Successful in 55s
2026-07-18 16:09:04 -04:00
cb801522a9 feat: add --comments-file to pr review for inline comments (task 0035)
All checks were successful
CI / test (pull_request) Successful in 57s
`pr review <n>` gains `--comments-file <path>`, a JSON array of inline
comments submitted with the review. Each entry is one of two exclusive
shapes: a new comment `{ path, line, body }` (mapped to `new_position`,
always the new side) or a reply `{ reply_to, body }`. A reply carries no
line or side — gitea-axi finds the target via the reviews-plus-comments
fan-out (there is no get-comment-by-id endpoint), reconstructs its anchor
from the target's own `diff_hunk`, and infers old/new side from it, so a
same-line post threads with the existing conversation. All entries map
onto the review-submission payload's `comments[]`; no new HTTP layer is
added. An unknown `reply_to` is a VALIDATION_ERROR raised before the POST,
and the submitted inline-comment count rides the action block.

The shared path-resolve-and-read behind --body-file and --comments-file is
extracted into src/flag-file.ts.
2026-07-18 16:06:53 -04:00
237c10e38b feat: surface inline-comment anchor fields on pr view --reviews (task 0034)
All checks were successful
CI / test (pull_request) Successful in 53s
Each inline review comment under `pr view <n> --reviews` now renders its
`id` (the handle a reply targets), `resolved` (`yes`/`no`, from whether
Gitea populated the comment's `resolver`), and `diff_hunk`. The hunk is
structurally trimmed to its `@@` header line plus its last two lines by
default (hunks of three lines or fewer are left whole) and emitted
verbatim under `--full`, so the trim never touches the char-based body
truncation path. The raw `position`/`original_position` diff offsets stay
unsurfaced. The fields ride the existing reviews-plus-per-review-comments
fetch — no extra API calls.
2026-07-18 15:46:30 -04:00
58 changed files with 282 additions and 4026 deletions

View File

@@ -99,8 +99,6 @@ _Avoid_: depends, depends-on, dependencies
**search**: The full-text query commands (`search issues <query>`, `search prs <query>`), repo-scoped via `owner` param plus [[client-side filtering]] by repository (Gitea's `/repos/issues/search` has no repo-name filter). **search**: The full-text query commands (`search issues <query>`, `search prs <query>`), repo-scoped via `owner` param plus [[client-side filtering]] by repository (Gitea's `/repos/issues/search` has no repo-name filter).
Results use a locator schema (`number`, `title`, `state`, `author`, `created`) — search finds the number; `issue view` / `pr view` load the detail. Results use a locator schema (`number`, `title`, `state`, `author`, `created`) — search finds the number; `issue view` / `pr view` load the detail.
The [[next-step suggestion]] is conditioned on the in-repo match count: zero matches point at the non-indexed `issue list --state all` / `pr list --state all` fallback ("to list all … instead"), which recovers from both an over-narrow query and issue-indexer lag; exactly one match fills the real number (`issue view <n>`, Principle 9's single-id fill); two or more keep the parameterized `<number>` placeholder.
Search never auto-loads the detail even on a single match — it stays a locator (see ADR 0017).
The forbidden `--search` flag on the list commands redirects here. The forbidden `--search` flag on the list commands redirects here.
_Avoid_: query command, find _Avoid_: query command, find
@@ -156,44 +154,16 @@ _Avoid_: mock mode, stub mode
### Distribution ### Distribution
**Agent Skill**: The markdown file bundled inside the npm package and installed to `~/.claude/skills/` by the `setup` command, or declared from the package by the [[home-manager module]]. **Agent Skill**: The markdown file bundled inside the npm package and installed to `~/.claude/skills/` by the `setup` command.
_Avoid_: skill file, Claude skill _Avoid_: skill file, Claude skill
**setup**: The explicit subcommand that installs the Agent Skill into `~/.claude/skills/`; gitea-axi's primary fulfillment of AXI Principle 7 (Ambient context). **setup**: The explicit subcommand that installs the Agent Skill into `~/.claude/skills/`; gitea-axi's primary fulfillment of AXI Principle 7 (Ambient context).
Idempotent: re-running reports already-installed/updated rather than failing. Idempotent: re-running reports already-installed/updated rather than failing.
There is no postinstall script — installation of the skill is always an explicit user action. There is no postinstall script — installation of the skill is always an explicit user action.
`setup hooks` additionally opts into the [[SessionStart hook]]. `setup hooks` additionally opts into the [[SessionStart hook]].
It is the [[imperative install path]], and works only where the operator owns the target files; against a read-only target it reports the condition rather than writing.
_Avoid_: postinstall, installer script _Avoid_: postinstall, installer script
**SessionStart hook**: An opt-in ambient-context mechanism installed by `setup hooks` via `axi-sdk-js`'s `installSessionStartHooks()` (Claude Code `settings.json`, Codex `hooks.json`, OpenCode plugin), or declared by the [[home-manager module]]. **SessionStart hook**: An opt-in ambient-context mechanism installed by `setup hooks` via `axi-sdk-js`'s `installSessionStartHooks()` (Claude Code `settings.json`, Codex `hooks.json`, OpenCode plugin).
It runs the bare `gitea-axi` binary (the short [[dashboard]] tier) in the session's working directory at session start and injects the output into the agent's context. It runs the bare `gitea-axi` binary (the short [[dashboard]] tier) in the session's working directory at session start and injects the output into the agent's context.
The SDK's installer registers the binary with no arguments, so the hook always runs the short tier; outside a Gitea repo it produces the dashboard's `REPO_NOT_FOUND` error, an accepted noise trade-off. The SDK's installer registers the binary with no arguments, so the hook always runs the short tier; outside a Gitea repo it produces the dashboard's `REPO_NOT_FOUND` error, an accepted noise trade-off.
The recorded command is currently the entrypoint's absolute path on any wrapper-based install, which rots whenever that path moves; recording the bare binary name and resolving it through `PATH` is agreed and lands with task 0043.
_Avoid_: session hook, ambient hook, postinstall hook _Avoid_: session hook, ambient hook, postinstall hook
**imperative install path**: Installation of the Agent Skill and the [[SessionStart hook]] by running `setup`, which writes into the operator's agent configuration directory.
Requires the operator to own those files; a declaratively generated configuration renders them read-only and the command reports rather than writes.
Contrast the [[declarative install path]]. Both are supported and neither supersedes the other.
_Avoid_: manual install, imperative setup
**declarative install path**: Installation of the Agent Skill and the [[SessionStart hook]] by declaring them in a Nix configuration, which generates the agent configuration rather than mutating it.
Agreed and specified; the outputs it consumes land with task 0045.
Consumes the package's exposed Skill location and [[hook specification]], either directly or through the [[home-manager module]].
Chosen where the operator's agent configuration is generated and therefore read-only; contrast the [[imperative install path]].
_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 `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

@@ -28,7 +28,3 @@ gitea-axi adds the same opt-in `setup hooks`; the skill remains the default `set
Hooks are not the default because the hook runs the dashboard in every session in every directory, and outside a Gitea repo the dashboard errors with `REPO_NOT_FOUND` — a graceful exit-0 degradation was considered and rejected in favor of keeping the error explicit, so hook noise in non-Gitea sessions is an accepted consequence for users who opt in. Hooks are not the default because the hook runs the dashboard in every session in every directory, and outside a Gitea repo the dashboard errors with `REPO_NOT_FOUND` — a graceful exit-0 degradation was considered and rejected in favor of keeping the error explicit, so hook noise in non-Gitea sessions is an accepted consequence for users who opt in.
The SDK registers the bare binary as the hook command, so the hook always runs the short dashboard tier (see ADR 0012). The SDK registers the bare binary as the hook command, so the hook always runs the short dashboard tier (see ADR 0012).
**Amended by [ADR 0019](0019-hook-records-search-path-name.md):** that last sentence held only for npm installs.
The SDK records the bare name only when a `PATH` entry realpath-matches the entrypoint it is handed, which npm's symlinked `bin` satisfies and a wrapper-based install cannot.
`setup hooks` now resolves the binary on `PATH` itself and hands that location over, so the bare name is recorded for wrapper-based installs too; the absolute entrypoint path remains the fallback when the name resolves to no install of ours.

View File

@@ -1,26 +0,0 @@
# Search stays a locator; a single match does not auto-load its detail
`search issues` / `search prs` always return a locator list (`number`, `title`, `state`, `author`, `created`) plus a next-step suggestion — never the full detail, even when exactly one result matches.
This deliberately narrows a literal reading of AXI Principle 4 ("eliminate round trips").
## Considered Options
**Auto-collapse to `view` on a single match** (rejected) — On exactly one result, run `issue view` / `pr view` and return the detail record, sparing the agent a second command.
It reads as the purest Principle 4 outcome, and it is what prompted this decision.
But the agent that searches most often wants the *number* to feed a mutation (`edit`, `close`, `comment`), not the body — so auto-loading the detail spends exactly the body tokens Principle 3's truncation exists to avoid, taxing the common find-then-act path to save a step on the rarer find-then-read one.
It also makes the output shape non-uniform — a list for zero and 2+ matches, a detail record for one — which the agent can no longer rely on.
**Stay a locator, suggest the next step** (chosen) — Search's job is finding the number to feed into `view` / `edit` (the spec's locator-schema rationale).
On a single match the next-step suggestion fills the real number (`issue view 2`), applying Principle 9's single-id fill; the agent decides whether that number feeds a `view`, an `edit`, or a `close`.
## The dividing line
Principle 4 eliminates a *redundant* round trip — a mutation returns the entity it just wrote, so no follow-up `view` is needed (ADR 0008).
`search``view` is not redundant: the follow-up is optional and its intent (read vs. act) is the agent's to choose, so collapsing it means guessing intent and over-fetching when the guess is wrong.
## Consequences
- `search` output shape is uniform across all match counts: always a locator list with a `help[N]:` next step.
- The next-step suggestion is conditioned on the in-repo match count: 0 → `list --state all` fallback ("to list all … instead"); 1 → `view <n>` with the real number; 2+ → `view <number>` placeholder.
- The zero-match fallback points at the non-indexed list, so it recovers from both an over-narrow query and issue-indexer lag without the command having to tell the two apart.
- An agent that does want the detail spends one more command (`view <n>`) by design — the number is already in hand, and it pays only for the detail it actually asks for.

View File

@@ -1,36 +0,0 @@
# The Nix wrapper appends `git` and `tea` to PATH rather than prepending them
The Nix-packaged `gitea-axi` binary is wrapped with `makeWrapper --suffix PATH`, not `--prefix`.
The operator's own `git` and `tea` win whenever they are present; the ones from the Nix closure are a fallback that makes the tool work on a machine where neither is installed.
This is the reverse of the usual Nix instinct, which is to pin runtime dependencies so the packaged tool behaves identically everywhere.
## Considered Options
**`--prefix PATH` for both** (rejected) — The hermetic choice: the closure's `git` and `tea` always win, `TEA_NOT_INSTALLED` becomes unreachable, and a half-upgraded system `tea` cannot break gitea-axi.
It fails on `tea` specifically.
Per ADR 0001 as amended, the token comes from `tea login helper get`, which **refreshes near-expiry OAuth tokens in place** — so the invoked `tea` does not merely read `~/.config/tea/config.yml`, it *writes* to it.
Prefixing would put two `tea` versions on one mutable store: the operator's, used interactively for `tea login add`, and the closure's, used for token refresh.
nixpkgs currently carries 0.14.0 while ADR 0001 was verified against 0.14.2, so this is a live version skew, not a hypothetical one.
Divergence in that file surfaces later as an auth failure with no visible connection to its cause.
**`--set PATH`** (rejected) — Fully sealing the environment is defensible in principle, because `src/subprocess.ts` is the single spawn point and invokes only these two binaries, so the surface is small enough to seal.
It inherits every problem above in stronger form, and additionally breaks whatever `git` itself shells out to that is not in the closure: credential helpers, LFS filters, diff and merge drivers, and `ssh` for SSH remotes — which would take `pr checkout` with it.
**`--prefix` for `git`, `--suffix` for `tea`** (rejected) — Puts the hermetic guarantee where state is not shared and defers where it is.
Examined and dropped because the reproducibility it buys on `git` is largely illusory: a pinned `git` still reads the operator's `~/.gitconfig`, so behaviour is not pinned, only the binary is.
Worse, a closure `git` missing an extension the operator relies on can *introduce* the divergence prefixing was meant to prevent.
That leaves a two-rule wrapper paying real explanatory cost for close to nothing.
**`--suffix PATH` for both** (chosen) — One rule, one sentence to explain.
A single `tea` — the one that created the credential store — owns reading and writing it.
The fresh-machine fallback is preserved, so nothing regresses for an operator who has neither binary.
## Consequences
- gitea-axi's behaviour depends on ambient `PATH`, so it is not reproducible across machines in the way a Nix package normally is.
This is accepted deliberately: the tool's job is to drive *the operator's* repositories using *the operator's* credentials, both of which are ambient state already.
- An operator whose `tea` predates the `login helper` interface hits an obscure failure while a working `tea` sits unused in the closure.
Judged acceptable — that interface exists in 0.14.0, the oldest version in nixpkgs.
- The closure carries `git` and `tea` that are usually unused. This is the price of the fallback.
- If the `tea` dependency is ever removed (see ADR 0002's retained credential-discovery role), the argument here collapses to the `git`-only case, and prefixing could be reconsidered — though the `~/.gitconfig` objection would still stand.

View File

@@ -1,47 +0,0 @@
# Record the SessionStart hook as a search-path name, not an install-tree path
`setup hooks` resolves `gitea-axi` on `PATH` and hands that location to `installSessionStartHooks`, so the recorded hook command is the bare name `gitea-axi`.
When the name resolves nowhere on `PATH`, the module-relative entrypoint is handed over instead and the absolute path is recorded, exactly as before.
A `PATH` entry qualifies only when it resolves to the running entrypoint — a symlink pointing at it, or a generated wrapper naming it — so a same-named binary that is some other program does not count.
`setup hooks` also collapses duplicate managed entries itself, recognising its own hook by the exact command it records rather than by a substring of that command.
## Context
The agent SDK's `resolvePortableHookCommand` returns a bare binary name only when some `PATH` entry *realpath-matches* the entrypoint it is handed, and the absolute path otherwise.
Task 0042 verified that this splits the two installation methods: npm symlinks its `bin` entry straight at `dist/main.js` so the match succeeds, while any wrapper-based install cannot match, because a script that *invokes* a file never resolves *to* that file.
Under Nix the recorded path is content-addressed — it changes on every rebuild and is eventually garbage-collected — and a SessionStart hook that cannot execute does not run and does not warn.
## Considered Options
**Document a re-run after upgrade** (rejected; this was the task 0042 mitigation being replaced) — Documentation against a silent failure is the weakest kind of fix.
It also does not work reliably: the re-run may leave the stale entry behind rather than replacing it, so the help text had to caveat its own remedy.
**Detect store paths in application code** (rejected) — Special-casing Nix in the CLI is the wrong shape.
The problem is not Nix; it is every wrapper-based install — a shim, a launcher, a generated `.cmd`.
**Write the hook files directly, bypassing the SDK** (rejected) — Would duplicate the SDK's handling of three integrations and four files to change one string, and would drift from it on every SDK change.
**Hand the SDK the `PATH` location** (chosen) — The SDK already prefers the search-path name; it was only ever reaching for it from the wrong end.
Resolving the name the way a shell does and handing that over makes the SDK's own realpath test succeed, so the preference becomes reachable for every installation method rather than only for the symlink shape npm happens to use.
Recording a bare name and letting `PATH` resolve it is also the convention for tools writing into user-owned configuration; absolute paths belong in configuration a package manager regenerates.
## Consequences
- The hook survives an upgrade whenever the binary is on `PATH`, so `setup hooks` no longer needs re-running after one, and the help text saying so is gone.
- The absolute path remains the documented fallback for a binary that is not on `PATH` — a source checkout run through `node dist/main.js`, say — where it is the only thing that could work.
- Which command gets recorded now depends on the invoking environment's `PATH`, not only on how the package was installed.
`PATH` is therefore read from the process rather than from the injected environment, since it must agree with the SDK's own probing.
- A same-named binary on `PATH` that is *not* this program does not qualify.
The name must resolve to the running entrypoint — by realpath for a symlink, or by the wrapper naming it — or the fallback applies.
Accepting any file that merely bears the name would make the SDK's realpath test a tautology, since the path handed over would trivially match itself.
- The SDK recognises its managed hook by finding the marker inside the recorded command, which made a re-run append a duplicate whenever the entrypoint path lacked the substring `gitea-axi`.
`setup hooks` now prunes duplicates by matching the exact command it records, so idempotency no longer depends on the recorded command's shape, and another tool's hook can never be mistaken for ours.
- `package.nix` no longer renames its build tree in `postUnpack`.
That rename existed only to give the fast tier an entrypoint path containing the marker.
With the coupling gone the build runs from `/build/source` and the idempotency test passes there, which is what demonstrates the coupling is actually broken.
- ADR 0009's addendum claimed "the SDK registers the bare binary as the hook command".
That was true only for npm installs; as of this decision it is true for any install whose `PATH` entry resolves to this entrypoint.
ADR 0009 is amended accordingly.

View File

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

View File

@@ -1,79 +0,0 @@
# 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

@@ -39,7 +39,7 @@ It ships as both an installable npm CLI and a bundled Agent Skill, so any agent
### Language and Runtime ### Language and Runtime
TypeScript on the supported Node long-term-support majors — currently 22 and 24, as declared in the manifest's engine range and matrixed over by continuous integration. TypeScript on Node 20+, matching the `gh-axi` reference implementation.
ESM module format. ESM module format.
### Implementation Strategy ### Implementation Strategy
@@ -491,7 +491,7 @@ The dashboard's empty states are `prs: 0 open` / `issues: 0 open` (raw strings,
Empty output is never silent. Empty output is never silent.
**Principle 6 — Structured errors, exit codes, idempotent mutations, no prompts.** **Principle 6 — Structured errors, exit codes, idempotent mutations, no prompts.**
Errors are represented as a typed `AxiError` with one of eleven named codes: `REPO_NOT_FOUND`, `ISSUE_NOT_FOUND`, `PR_NOT_FOUND`, `AUTH_REQUIRED`, `FORBIDDEN`, `RATE_LIMITED`, `TEA_NOT_INSTALLED`, `VALIDATION_ERROR`, `GIT_ERROR`, `TARGET_NOT_WRITABLE`, `UNKNOWN`. Errors are represented as a typed `AxiError` with one of ten named codes: `REPO_NOT_FOUND`, `ISSUE_NOT_FOUND`, `PR_NOT_FOUND`, `AUTH_REQUIRED`, `FORBIDDEN`, `RATE_LIMITED`, `TEA_NOT_INSTALLED`, `VALIDATION_ERROR`, `GIT_ERROR`, `UNKNOWN`.
The `ISSUE_NOT_FOUND`/`PR_NOT_FOUND` split (vs gh-axi's single `NOT_FOUND`) is a deliberate divergence enabled by path-based 404 classification. The `ISSUE_NOT_FOUND`/`PR_NOT_FOUND` split (vs gh-axi's single `NOT_FOUND`) is a deliberate divergence enabled by path-based 404 classification.
API error responses are classified by HTTP status code and calling context: API error responses are classified by HTTP status code and calling context:
@@ -516,8 +516,6 @@ tea has logins but none match the detected hostname → `REPO_NOT_FOUND` (the re
HTTP 401 from the API → `AUTH_REQUIRED` (token invalid or revoked), per the status table. HTTP 401 from the API → `AUTH_REQUIRED` (token invalid or revoked), per the status table.
A `--login` value naming a nonexistent profile is `VALIDATION_ERROR`, listing the available profile names. A `--login` value naming a nonexistent profile is `VALIDATION_ERROR`, listing the available profile names.
`GIT_ERROR` classifies non-zero git subprocess exits (currently only `pr checkout`), carrying git's first stderr line. `GIT_ERROR` classifies non-zero git subprocess exits (currently only `pr checkout`), carrying git's first stderr line.
`TARGET_NOT_WRITABLE` classifies a `setup` target the filesystem refuses (`EACCES`, `EPERM`, `EROFS`), naming the file and pointing at the general remedy: it appears to be managed by another tool, so the skill or hook belongs in that tool's configuration.
It never names or infers a particular configuration manager — read-only is not diagnostic of one.
Error output is TOON-encoded to stdout (not stderr): `error: <message>`, `code: <CODE>`, and optionally `help[N]:` with suggestion lines. Error output is TOON-encoded to stdout (not stderr): `error: <message>`, `code: <CODE>`, and optionally `help[N]:` with suggestion lines.
The suggestions field is named `help`, not `hint`. The suggestions field is named `help`, not `hint`.
Exit codes: 0 success, 1 error, 2 for `VALIDATION_ERROR` — covering unknown flags, missing required inputs, and server-side 422 rejections alike (the `axi-sdk-js` `exitCodeForError` mapping; see ADR 0004). Exit codes: 0 success, 1 error, 2 for `VALIDATION_ERROR` — covering unknown flags, missing required inputs, and server-side 422 rejections alike (the `axi-sdk-js` `exitCodeForError` mapping; see ADR 0004).

View File

@@ -1,151 +0,0 @@
## 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

@@ -1,244 +0,0 @@
## Problem Statement
The maintainer runs NixOS and wants gitea-axi installed declaratively through the system configuration, alongside every other tool on the machine.
Today there is no declarative path.
gitea-axi is distributed only as an npm package, and nothing has been published yet — there are no release tags and no tarball on the registry.
Installing it means an imperative global npm install, which sits outside the system configuration, is invisible to rollbacks, and drifts from the rest of the machine's declarative state.
The repository also has no declarative development environment.
It carries no Nix expression, no direnv configuration, and no Node version file, while documenting a build, a live end-to-end tier, and a benchmark harness that all assume a Node toolchain the repository never specifies.
The continuous integration workflow pins Node 20, which reached end-of-life in April 2026, so the one place a Node version *is* named now names an unsupported one.
## Solution
A Nix flake at the repository root that exposes gitea-axi as a package, so the maintainer's NixOS configuration can add it to the system package set the same way it adds anything else.
The flake also exposes a development shell carrying the toolchain the repository actually needs — Node, `git`, and `tea` — giving `nix develop` a declarative answer to "what do I need to work on this".
A checks output makes `nix flake check` build the package and run its tests, so the flake itself is verifiable rather than silently rotting.
Because gitea-axi discovers credentials by shelling out to `tea`, and drives repositories by shelling out to `git`, the installed binary is wrapped so both are reachable from the Nix closure.
The wrapper defers to the operator's own binaries when present, and supplies the closure's only as a fallback.
Alongside the flake, the continuous integration workflow moves off end-of-life Node, tests the full range of Node versions the package claims to support, and gains the two test tiers it currently never runs.
## User Stories
1. As the maintainer, I want gitea-axi available as a flake package, so that I can install it from my NixOS configuration instead of through an imperative global npm install.
2. As the maintainer, I want the installed binary to find `git` and `tea` without my having to install them separately, so that the tool works on a fresh machine with no manual setup.
3. As the maintainer, I want my own `tea` to be the one gitea-axi invokes when I have one, so that a single `tea` version owns the credential store it reads and writes.
4. As the maintainer, I want the flake to build without my maintaining a dependency hash, so that bumping an npm dependency does not also require an edit to the Nix expression.
5. As the maintainer, I want the package version taken from the package manifest, so that a released version and its store path can never disagree.
6. As the maintainer, I want the Nix build to run the fast test tier, so that a package that builds is also a package whose behavior was checked.
7. As the maintainer, I want the Nix build to drive the binary it just installed, so that a broken executable bit or a misplaced bundled Agent Skill fails the build instead of failing on first use.
8. As the maintainer, I want the build to depend only on files that can change its output, so that writing an ADR or landing a benchmark result does not trigger a rebuild and a full test run.
9. As the maintainer, I want a development shell with Node, `git`, and `tea`, so that `nix develop` gives me the toolchain for the build, the live end-to-end tier, and the benchmark harness.
10. As the maintainer, I want the development shell and the packaged binary to share one Node reference, so that development and the shipped artifact cannot drift onto different major versions.
11. As the maintainer, I want `nix flake check` to actually build and test, so that the conventional health-check command is not a silent no-op.
12. As the maintainer, I want continuous integration to build the flake, so that I learn a build-relevant file was omitted from the source filter at the commit that caused it rather than weeks later at my next system rebuild.
13. As the maintainer, I want continuous integration to run on supported Node versions only, so that I am not gating merges on a runtime that receives no security fixes.
14. As the maintainer, I want the declared engine range to match the versions actually tested, so that the compatibility promise in the manifest is verified rather than asserted.
15. As the maintainer, I want the benchmark harness tier to run in continuous integration, so that harness logic is guarded rather than relying on my remembering its non-default runner configuration.
16. As the maintainer, I want the packaging tier to run in continuous integration, so that a broken distribution artifact is caught before a manual publish rather than by whoever installs it.
17. As the maintainer, I want the assertions about an installed binary to be written once and driven by both the npm install path and the Nix install path, so that the two distribution methods cannot drift apart in what they guarantee.
## Implementation Decisions
### Flake surface
The flake exposes a package, a development shell, a checks output, and a home-manager module.
It deliberately exposes no NixOS module and no overlay.
A NixOS module was rejected because gitea-axi is a stateless CLI with no daemon and no system-level configuration; a module would wrap the system package set and nothing else.
An overlay was rejected as an interface with no consumer — the maintainer is the sole consumer and already knows the package goes into the system package set.
Both remain purely additive to add later.
A home-manager module was initially deferred rather than rejected, on the grounds that a module managing the Agent Skill declaratively would reintroduce the automatism ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script, and that the trade-off deserved its own decision made with usage evidence.
That evidence arrived and reversed the deferral; the flake exposes `homeModules.gitea-axi` as of task 0045.
The reasoning is recorded in [ADR 0020](../adr/0020-home-manager-module-for-declarative-context.md).
In short: `setup` and `setup hooks` are write-only against files the operator is assumed to own, so an operator whose agent configuration is generated cannot use them at all and hand-copies the Skill instead, where it drifts from the package shipping it.
And a module the operator imports and then explicitly enables is not the automatism ADR 0009 guards against — that guard is against installation without intent, and an `enable` option is intent.
Consumers deduplicate nixpkgs by pointing the flake's nixpkgs input at their own.
Consequently the flake's own nixpkgs input governs only standalone builds, the development shell, and flake checks — never the deployed artifact.
That input tracks the unstable channel, matching the maintainer's system, so the development shell reflects the same package set the installed binary is built against.
System coverage is the four common Linux and Darwin targets, enumerated with a small helper built from the nixpkgs standard library rather than by taking a dependency on a third-party systems-enumeration flake.
Cross-platform support is close to free because the package contains no compiled code; the only per-system variation is which Node, `git`, and `tea` are pulled in.
### Package expression
The derivation lives in its own expression, separate from the flake, written in the conventional callable form that nixpkgs uses.
This keeps the flake's own file to interface concerns — what it consumes and what it exports — and leaves the derivation buildable outside a flake context, usable in an overlay unchanged, and upstreamable to nixpkgs later.
Dependencies are fetched by deriving each package's fetch from the integrity fields already present in the lockfile, rather than by a single fixed-output derivation keyed on a hash committed to the Nix expression.
The hash-based approach was rejected on maintenance grounds: it breaks on every lockfile change and is repaired by copying a hash out of an error message into the expression, which is a permanent recurring tax and a standing source of stale-hash commits.
The lockfile-derived approach is viable here specifically because every runtime dependency resolves to the public npm registry with an integrity field, there are no git or filesystem dependencies, and the lockfile is version 3.
The package version is read from the package manifest at evaluation time.
The manifest is already the canonical version — the documented release flow bumps it — and hardcoding it in the Nix expression would create a second place to update, whose omission yields a store path labelled with one version containing another's code.
The runtime Node is the nixpkgs default, currently a supported long-term-support release.
Node 20 is not an option: nixpkgs marks it with known vulnerabilities as end-of-life, so using it would require the consuming configuration to permit an insecure package.
The development shell references the same Node attribute as the package, so the two cannot drift.
### Source filtering
The derivation's source is an explicit allowlist of the paths the build and its tests actually read: the TypeScript sources, the test tier, the bundled Agent Skill, the package manifest and lockfile, the two TypeScript configurations, and the default test-runner configuration.
Taking the whole repository was rejected because this repository's highest-churn directories are all build-irrelevant — the ADR, spec, and task directories, the benchmark harness, and the prose documentation.
Under a whole-repository source, writing an ADR invalidates the derivation and forces a full rebuild including the test suite.
A gitignore-derived filter was rejected as insufficient for the same reason: it still admits the benchmark harness, the agent-context directory, and the several additional test-runner configurations.
The cost is that adding a build-relevant top-level file requires updating the allowlist.
That failure is loud and immediate — the build fails on a missing file — but it is disconnected enough from its cause to warrant both a Gotcha entry in the agent instructions and the continuous-integration flake job described below.
### Runtime dependency wrapping
The installed binary is wrapped so that the closure's `git` and `tea` are appended to the operator's existing search path, not prepended.
The operator's own binaries win where present; the closure supplies a fallback so a fresh machine works and the not-installed error path becomes unreachable in practice.
This is the reverse of the hermetic instinct, and the reason is specific to `tea`.
Per ADR 0001 as amended, the token is fetched through `tea`'s git-credential-protocol interface, which refreshes near-expiry OAuth tokens *in place*.
The invoked `tea` therefore writes to the operator's credential store, so prepending would mean two `tea` versions mutating one state file — the maintainer's for interactive login management, the closure's for token refresh.
`git` takes the same treatment for uniformity, after the reproducibility argument for prepending it was examined and rejected as illusory: a pinned `git` still reads the operator's global configuration, so behavior is not actually pinned, while a closure `git` lacking an extension the operator relies on — a filter, a credential helper — would *introduce* the divergence that prepending is meant to prevent.
Replacing the search path outright was rejected for the same reasons in stronger form, plus it would break repository operations over SSH remotes.
`tea` remains a runtime dependency for credential discovery only.
ADR 0002 moved command dispatch to the Gitea REST API but explicitly retained `tea` for auth, and nothing in this work changes that.
### Verification inside the build
The build runs the fast test tier, which requires a real `git` and a `which` in the check inputs because two of its files invoke `git` directly and one resolves `git` by lookup; `tea` is already stubbed within that tier.
The live end-to-end tier and the benchmark smoke tier are excluded because they require a live Gitea host.
After installation, the build drives the wrapped binary through the shared installed-binary tier described under Testing Decisions.
This guards a class of failure the fast tier structurally cannot reach: the compiler does not set the executable bit that npm would otherwise set from the manifest's `bin` entry at install time, and the `setup` command resolves the bundled Agent Skill relative to its own module location, which makes the relative arrangement of the built output and the bundled Skill load-bearing.
The checks output aliases the package, so the conventional flake health-check command builds it and thereby runs both phases.
Granular per-stage checks were rejected: the one stage that would add real coverage is the full typecheck, which spans the test and benchmark directories and would therefore drag the benchmark harness into the derivation's inputs, undoing the source filtering above.
The full typecheck stays in continuous integration, where it already runs.
### Continuous integration
The workflow matrixes over the two supported Node versions and the declared engine range narrows to match.
The current declaration promises support down to Node 20 while testing only Node 20, so the entire claimed range below the tested version is unverified and its floor is end-of-life.
Narrowing the range is free right now because nothing has been published and no tags exist; that window closes at first publish.
The live end-to-end tier runs on the highest matrix leg only, because it exercises the Gitea API contract rather than Node-version behavior, and each leg provisions a full Gitea service.
Two tiers join the workflow.
The benchmark harness tier runs on every leg: it is deterministic, needs no network or agent SDK, and is currently unguarded despite its non-default runner configuration being an easy thing to believe is running when it is not.
The packaging tier runs on the highest leg only, being slow and largely version-independent; it is the only automated guard on the distribution artifact given that publishing is a manual command.
The benchmark smoke tier stays out of continuous integration.
It targets a live host discovered through the maintainer's own credentials and skips cleanly when they are absent, so in continuous integration it would pass by skipping — a green check that verified nothing.
A separate job builds the flake, on both push and pull request.
It is deliberately non-gating for the other jobs, so an infrastructure problem with Nix availability on the runner does not block an otherwise legitimate change.
Its value is detecting flake rot — most concretely, a build-relevant file omitted from the source allowlist — at the commit that causes it rather than at the maintainer's next system rebuild.
Its cost is honest: because the checks output aliases the package, this job re-runs the fast tier inside the derivation and, without a warm store, rebuilds the whole dependency closure.
Release automation stays out of scope.
Automating a publish path that has never once been exercised manually would encode assumptions about a process with no track record, and the artifact-integrity concern that would motivate it is already covered by adding the packaging tier.
## Testing Decisions
A good test here asserts externally observable behavior of an *installed* gitea-axi — that the binary runs, renders, and installs its Agent Skill — and not the mechanics of how it came to be installed.
Nothing should assert on store paths, wrapper script internals, derivation attribute values, or the arrangement of files within the installed tree, because all of those are implementation detail of the packaging method and would have to change in lockstep with it.
### The seam
There is one new seam, and it is a parameterization of an existing tier rather than a new suite.
The packaging tier today contains two kinds of assertion coupled only by a shared setup step: assertions about the shape of the packed tarball and its manifest, and assertions that drive the resulting installed binary.
The second group is parameterized by exactly one value — the path of the binary to drive — and is precisely what the Nix build needs to assert about its own installed output.
That group is therefore split out and taught to accept its binary path from the environment.
When the environment names an already-installed binary, the tier drives it directly and skips the pack-and-install setup; when it does not, the tier packs and installs as it does today and drives the result.
The npm distribution path and the Nix installation path become two callers of one seam.
The tarball-shape assertions remain npm-only, since a Nix installation produces no tarball and no packed manifest.
This split also improves the existing tier on its own terms, by separating two concerns that were only ever joined by an expensive shared setup.
Two alternatives were rejected.
A bespoke shell smoke test in the Nix build would need no TypeScript change and no test runner inside the derivation, but it is a second and weaker seam asserting the same intent, and it would not have caught the bundled-Skill arrangement bug that motivates the check at all.
Running only the fast tier after installation was rejected because it does not exercise the installed layout, which is the entire class of failure the post-install check exists to catch.
### Consequential change to existing assertions
The packaging tier currently asserts that the declared engine range mentions Node 20.
Narrowing the engine range changes that assertion.
It is part of this work rather than a later surprise.
### Prior art
The installed-binary assertions already exist and are the model: they drive a real subprocess, answer its HTTP calls with the in-process fixture server used throughout the suite, and point the `setup` command at a temporary home directory to observe the Agent Skill being written.
Unlike the in-process CLI-seam harness, which keeps the environment fully explicit, this tier deliberately inherits the parent environment because the spawned binary genuinely needs it — that remains true, and is more true under a wrapper.
### Not covered by automated tests
The flake's consumption from a NixOS configuration is verified by the maintainer performing a system rebuild, not by an automated test.
Building the package proves the derivation is correct; whether the maintainer's configuration wires it in correctly is outside this repository.
## Out of Scope
Removing the `tea` runtime dependency.
It was raised and examined during design: ADR 0002 moved command dispatch off `tea` but retained it for credential discovery, and eliminating it would mean either owning a credential store or reading `tea`'s internal configuration format, which ADR 0001 explicitly rejected because it forfeits OAuth token refresh.
Wrapping the binary makes the dependency invisible in practice, which removes most of the practical motivation.
An overlay output and a NixOS module output.
A direnv configuration.
The maintainer does not currently run direnv, so committing one would be configuration for a tool that is not installed.
Release and publish automation, and the first publish itself.
Migrating continuous integration to build via Nix.
The workflow keeps its container-and-npm shape, deliberately preserving the GitHub Actions compatibility the workflow documents as a goal; the Nix job is additive.
## Further Notes
### Resolved verification item: the hook records the absolute path under Nix
The `setup` command's hook installation passes the SDK both an absolute path to the running entrypoint and the bare binary name.
The design-time hope was that the SDK prefers search-path resolution and treats the absolute path as a fallback, which would have made this a non-issue.
It was verified against the installed dependency and against a real Nix build, and the answer is the unfavourable one: **under Nix the absolute store path is recorded**, even with the binary on `PATH`.
The SDK's `resolvePortableHookCommand` returns the bare name only when some `PATH` entry *realpath-matches* the entrypoint, and the absolute path otherwise.
That test is what splits the two installation methods, and the split is a property of how each one puts the binary on `PATH`:
- **npm** symlinks the `bin` entry directly at the entrypoint, so the realpath comparison succeeds and the bare name is recorded.
- **Nix** installs the `bin` entry as a *generated wrapper script* that invokes `node <path>``nodejsInstallExecutables` inside `npmInstallHook`, plus this package's own `makeWrapper` layer for `git` and `tea`.
A wrapper's realpath is the wrapper, never the entrypoint, so the comparison cannot succeed and the absolute path is recorded.
So the preference for the bare name is real, but it is unreachable through any wrapper-based install.
It is not that Nix was overlooked; it is that the mechanism keys on a filesystem relationship only the symlink shape has.
The first mitigation was documentation: the `setup` command's help text stated that `setup hooks` must be re-run after an upgrade.
The failure it guarded against is silent — a session-start hook that cannot execute simply does not run, so a user gets no error, only the quiet absence of their ambient dashboard.
**Task 0043 superseded that mitigation and this section's conclusion.**
The preference for the bare name is reachable through a wrapper-based install after all; it was only being reached for from the wrong end.
`setup hooks` now resolves `gitea-axi` on `PATH` the way a shell does and hands *that* location to the SDK, so the realpath comparison succeeds and the bare name is recorded — under Nix as under npm.
The absolute path survives as the fallback for a binary that is not on `PATH` at all.
The reasoning is recorded in [ADR 0019](../adr/0019-hook-records-search-path-name.md); the help text's re-run instruction is gone, having become false.
The related defect on the same line went with it: `isManagedHook` recognises its own hook by testing whether the recorded command string *contains* the marker `gitea-axi`, so an entrypoint path lacking that substring made `setup hooks` append a duplicate rather than update in place.
`setup hooks` now prunes duplicates itself, recognising its entry by the exact command it records rather than by a substring of it.
That coupling was why `package.nix` renamed its build tree in `postUnpack`; the rename is deleted, and the build running green from `/build/source` is what demonstrates the coupling is gone.
### Verified during design
The `tea` in nixpkgs carries the credential-helper interface that ADR 0001's amendment depends on, under the singular alias the code uses.
All three runtime dependencies resolve to the public npm registry.
The lockfile is version 3 and the lockfile-derived fetching helper is available.
There are no native modules and no install scripts in the runtime closure.
The reserved self-update command is already shadowed and never writes, so it poses no read-only-store hazard.
### Candidate ADR
The wrapper's deference to the operator's own binaries warrants an ADR.
It is surprising without context, since the hermetic instinct points the other way; it is the product of a real trade-off between reproducibility and single-owner mutable state; and it is hard to reverse in the sense that flipping it can corrupt an operator's credential store rather than merely changing behavior.

View File

@@ -1,49 +0,0 @@
---
spec: nix-flake-packaging
---
## What to build
The packaging tier today holds two kinds of assertion joined only by an expensive shared setup step: assertions about the shape of the packed tarball and its manifest, and assertions that drive the resulting installed binary.
Split them, and teach the second group to accept the path of the binary to drive from the environment.
When the environment names an already-installed binary, the tier drives that binary directly and skips the pack-and-install setup entirely.
When it does not, the tier packs and installs exactly as it does today and drives the result.
This makes one seam that both the npm distribution path and — later — the Nix installation path call, so the two cannot drift apart in what they guarantee about an installed gitea-axi.
The tarball-shape assertions stay npm-only, since a Nix installation produces no tarball and no packed manifest.
The assertions themselves do not change in character: they drive a real subprocess, answer its HTTP calls with the in-process fixture server used throughout the suite, and point the `setup` command at a temporary home directory to observe the Agent Skill being written.
This tier deliberately inherits the parent environment, unlike the in-process CLI-seam harness — the spawned binary genuinely needs it.
Nothing may assert on store paths, wrapper internals, or the arrangement of files within the installed tree; those are implementation detail of the installation method.
## Acceptance criteria
- [x] The installed-binary assertions live separately from the tarball-shape assertions, and both still run under the packaging tier's own runner configuration.
- [x] An environment variable naming an existing binary makes the installed-binary group drive that binary and skip pack-and-install.
- [x] With that variable unset, the group packs, installs, and drives the result as before — the default developer experience is unchanged.
- [x] The full packaging tier passes in both modes.
- [x] No assertion in the installed-binary group depends on how the binary was installed.
## Implementation Notes
`test/packaging/packaging.test.ts` split into `tarball.test.ts` and `installed-binary.test.ts`, with the shared `npm pack` / extract / global-install mechanics factored into a non-test `npm-artifact.ts` module beside them.
The runner configuration is untouched apart from its comments: its `include` glob already matched the whole directory, so both new files run under it unchanged.
The environment variable is `GITEA_AXI_INSTALLED_BIN`, matching the existing `GITEA_AXI_*` convention.
An empty value counts as unset, so exporting it blank behaves the same as not exporting it.
A path that does not exist fails in `beforeAll` with an explanatory message rather than letting every assertion fail on an opaque spawn `ENOENT`.
Two deviations from the plan, both minor and both deliberate:
The single `it` that drove the installed binary became three — usage, dashboard render, and Agent Skill installation.
The task said the assertions "do not change in character", and they do not; this only splits one case into the three behaviours the spec itself names ("the binary runs, renders, and installs its Agent Skill"), so a failure names which one broke.
`PUBLISHING.md`'s "Verifying the packed artifact" section was rewritten to describe the two facets and document the new variable.
Not an acceptance criterion, but the section described the tier as one undifferentiated thing and would otherwise have been left stale by this change.
Verified by running the full tier three ways: with the variable unset (both facets pack as before), with it pointing at a separately installed binary (the installed-binary facet skipped its setup — 2.2s down to 0.3s, with no `prepack` build), and with it set to the empty string (falls back to pack-and-install).
One consequence worth flagging for task 0038: the two facets now each run `npm pack`, so `npm run test:pack` builds twice.
That is invisible to the Nix build, which will run only `test/packaging/installed-binary.test.ts` against its own installed output rather than the full tier.

View File

@@ -1,104 +0,0 @@
---
spec: nix-flake-packaging
---
## What to build
A Nix flake at the repository root exposing gitea-axi as a package, so the maintainer's NixOS configuration can add it to the system package set the way it adds anything else.
Building the package and running the resulting binary is the demoable outcome of this slice.
The derivation lives in its own expression, separate from the flake, in the conventional callable form nixpkgs uses.
The flake's own file stays limited to interface concerns — what it consumes and what it exports — leaving the derivation buildable outside a flake context and usable in an overlay unchanged.
The flake exposes only the package for now; the development shell and checks output arrive in a later slice, and no NixOS module or overlay is exposed at all.
Its nixpkgs input tracks the unstable channel, matching the maintainer's system; consumers deduplicate by pointing that input at their own, so it governs only standalone builds.
Systems coverage is the four common Linux and Darwin targets, enumerated with a small helper built from the nixpkgs standard library rather than a third-party systems-enumeration flake input.
Dependencies are fetched by deriving each package's fetch from the integrity fields already in the lockfile, not from a single fixed-output hash committed to the expression — the latter breaks on every lockfile change and is repaired by copying a hash out of an error message, which is a permanent recurring tax.
The package version is read from the package manifest at evaluation time, so a released version and its store path can never disagree.
The runtime Node is the nixpkgs default; Node 20 is not an option, as nixpkgs marks it end-of-life with known vulnerabilities.
The derivation's source is an explicit allowlist of the paths the build and its tests actually read — the TypeScript sources, the test tier, the bundled Agent Skill, the package manifest and lockfile, the two TypeScript configurations, and the default test-runner configuration.
Taking the whole repository, or a gitignore-derived filter, would let the highest-churn and entirely build-irrelevant directories invalidate the derivation and force a full rebuild with tests.
The installed binary is wrapped so the closure's `git` and `tea` are **appended** to the operator's existing search path, never prepended or substituted — ADR 0018 records why, and this slice lands that ADR.
The operator's own binaries win where present; the closure supplies a fallback so a fresh machine works with no manual setup.
The build runs the fast test tier, which needs a real `git` and a `which` available to it because some of its files invoke `git` directly and one resolves it by lookup.
The live end-to-end and benchmark smoke tiers are excluded — they require a live Gitea host.
The allowlist's failure mode is loud but disconnected from its cause, so this slice also records a Gotcha in the agent instructions: a new build-relevant top-level file must be added to the source allowlist or the Nix build fails on a missing file.
## Acceptance criteria
- [x] Building the flake's package from a clean checkout produces a runnable `gitea-axi` that prints help and reports its version.
- [x] The store path's version matches the package manifest's version, with the version appearing in no Nix expression.
- [x] Changing a dependency in the lockfile requires no edit to any Nix expression.
- [x] The derivation is a separate callable expression that the flake file consumes; it evaluates outside a flake context.
- [-] The package builds for the four supported Linux and Darwin systems, with no third-party flake input beyond nixpkgs.
- [x] Touching a file outside the source allowlist — an ADR, a spec, a benchmark file, prose documentation — does not change the derivation's output path.
- [x] The wrapped binary finds `git` and `tea` on a machine where neither is otherwise installed.
- [x] With the operator's own `git` and `tea` on the search path, those are the ones the binary invokes.
- [x] The fast test tier runs and passes inside the build; a deliberately failing test fails the build.
- [x] ADR 0018 is committed as part of this slice.
- [x] The agent instructions carry a Gotcha about extending the source allowlist for new build-relevant files.
## Implementation Notes
### Three systems, not four — `x86_64-darwin` is gone
The one dropped criterion. nixpkgs 26.11, which `nixos-unstable` now points at, has removed `x86_64-darwin` support outright.
`legacyPackages.x86_64-darwin` *throws* on evaluation rather than merely failing to build, so enumerating it would break `nix flake show` and `nix flake check` for **every** system at once, not just that one.
The flake therefore covers `x86_64-linux`, `aarch64-linux`, and `aarch64-darwin`, with the omission commented at the `systems` list.
Intel macOS would need the 26.05 branch; nobody is asking for it.
The rest of the criterion holds: nixpkgs is the only flake input.
### `doCheck = true` was silently doing nothing
`buildNpmPackage` wires config, build, and install hooks but supplies **no check hook**, so `doCheck` alone is inert.
The first green build logged `no Makefile or custom checkPhase, doing nothing` and produced a package whose tests had never run — a passing build that verified nothing.
The fix is an explicit `checkPhase`, plus `git` and `which` in `nativeCheckInputs` and a writable `HOME`.
Recorded as a Gotcha, since the failure mode is a *green* build.
The second half of that criterion was demonstrated unintentionally but genuinely: with the check phase live, a failing test failed the build with exit code 1 before anything was installed.
### The build tree is named, and why that is a workaround
`postUnpack` renames the build tree from the builder's generic `source` to `gitea-axi`.
This is not cosmetic. `test/setup.test.ts` asserts that `setup hooks` updates its managed entry in place rather than appending a duplicate.
The SDK recognises its own hook by testing whether the recorded command *string contains* `"gitea-axi"`, and the recorded command is the entrypoint's absolute path whenever PATH resolution does not match it — which it never does under vitest, since the entrypoint resolves to `src/main.js`, a file that does not exist.
So the assertion holds only when the checkout's path happens to contain `gitea-axi`.
That was verified rather than assumed: copying the repository to `/tmp/clean-probe-9d3/proj` and running the tier reproduces the failure outside Nix entirely.
A first probe under the session scratchpad passed and was misleading — that path contains `-home-alexion-wrk-gitea-axi-…`, so it satisfied the substring by accident.
The rename makes the build environment representative of a real installation (`node_modules/gitea-axi/…` under npm, `…-gitea-axi-<version>/…` under Nix) rather than an arrangement no operator ever has.
It is a workaround for a defect, not a property worth keeping, and is commented as such.
### Follow-up for task 0042 — the open verification item is answered, unfavourably
The spec's open verification item asked whether the SDK prefers the bare binary name over the absolute entrypoint path.
It does not.
`resolvePortableHookCommand` returns the bare name only when a `PATH` entry realpath-matches the entrypoint, and the absolute path otherwise.
Driving the Nix-built binary shows what actually lands in `~/.claude/settings.json`:
```
/nix/store/nmkzjny0hpzjvyxzdz189whk605di8b6-gitea-axi-0.1.0/lib/node_modules/gitea-axi/dist/main.js
```
That is content-addressed: it changes on every rebuild and is eventually garbage-collected, and a `SessionStart` hook that cannot execute simply does not run.
So the dashboard stops appearing after an upgrade, silently and with nothing pointing at the cause — the exact failure the item hoped to rule out, now confirmed under the install method this slice adds.
Two findings for 0042, both tracing to the same line:
1. The stale store path above — user-facing breakage, and the more serious of the two.
2. `setup hooks` appends a duplicate entry instead of updating in place whenever the entrypoint path lacks the marker.
Both were left alone deliberately, at the maintainer's direction, to keep this slice about packaging; fixing hook resolution here would have pre-empted 0042's design work with a decision made in passing.
When 0042 lands, the `postUnpack` rename should be removed with it.
### ADR 0018
Already committed in `0cbfe43` during the planning pass, ahead of this branch, so the criterion is satisfied by an earlier commit rather than by this one.
No change was needed; `package.nix` implements it via `--suffix PATH`.

View File

@@ -1,71 +0,0 @@
---
spec: nix-flake-packaging
blocked-by: [0036-parameterized-installed-binary-tier, 0037-flake-package-and-wrapper]
---
## What to build
After it installs, the Nix build drives the wrapped binary it just produced through the shared installed-binary tier, pointing that tier at the installed path rather than letting it pack and install.
This guards a class of failure the fast tier structurally cannot reach.
The compiler does not set the executable bit that npm would otherwise set from the manifest's `bin` entry at install time.
And the `setup` command resolves the bundled Agent Skill relative to its own module location, which makes the relative arrangement of the built output and the bundled Skill load-bearing — an arrangement that only exists once installed.
The check reuses the seam from the parameterized tier; it does not introduce a second, weaker set of assertions in shell script, and it does not re-run the fast tier, which would not exercise the installed layout at all.
## Acceptance criteria
- [x] The Nix build drives the installed binary through the shared installed-binary tier after installation.
- [x] A binary installed without its executable bit fails the build.
- [x] A bundled Agent Skill installed at the wrong location relative to the built output fails the build.
- [x] The post-install phase adds no assertions of its own beyond pointing the shared tier at the installed binary.
- [x] `nix build` still succeeds end to end on a clean checkout.
## Implementation Notes
### Where the check hangs
`installCheckPhase`, not `postInstall`.
It runs after `fixupPhase`, which is where `wrapProgram` has already done its work — so the binary the tier drives is the wrapped one an operator actually gets, not the bare entrypoint.
The phase sets `GITEA_AXI_INSTALLED_BIN=$out/bin/gitea-axi` and runs `npm run test:installed`, a new script that runs the installed-binary facet alone under the packaging runner configuration.
Naming the binary is all it does; the assertions stay in the tier, satisfying the fourth criterion by construction.
`vitest.packaging.config.ts` had to join the source allowlist in `package.nix` — exactly the Gotcha task 0037 recorded, hit on the first build.
### Dev dependencies are gone by install time
`npmInstallHook` runs `npm prune --omit=dev` against the build tree's `node_modules` during `installPhase`, so vitest no longer exists when `installCheckPhase` runs.
`preInstall` snapshots the tree with `cp -al` first — hardlinks, so it costs neither time nor space, and the prune's deletions do not follow through to the copy — and the check restores it.
Restoring copies rather than moves, so a replayed phase (`--keep-failed` debugging) does not consume the only surviving snapshot.
Recorded as a Gotcha, since the failure is disconnected from its cause.
### Criterion 2 holds, but by a different mechanism than the task assumed
The task motivates the executable-bit guard with "the compiler does not set the executable bit that npm would otherwise set from the manifest's `bin` entry".
That is true of the npm path and *not* of the Nix path: `nodejsInstallExecutables` installs each `bin` entry as a generated wrapper invoking `node <path>`, not as a symlink to the entrypoint.
So `chmod -x` on `dist/main.js` changes nothing — verified, the build stayed green — while `chmod -x` on `$out/bin/gitea-axi` fails all three tests with `EACCES`.
The criterion as written is therefore satisfied, and was demonstrated by probe, but the bit it protects under Nix is one `makeWrapper` always sets.
The assertion earns its keep on the npm caller, where the failure the task describes is real.
This is an argument for the shared tier rather than against it: neither installation path gets to pick which guarantees it feels like offering.
Both mechanism and probe results are recorded as a Gotcha.
The Skill-location criterion is the one doing real work under Nix — moving the installed `skills` directory aside fails the `setup` test and the build.
### Two defects found and fixed in passing
Both were surfaced by the review pass rather than planned.
`test:installed` pins a test file by path, and the packaging runner sets `passWithNoTests: true`.
Renaming or moving that file would have made vitest match nothing, exit 0, and take `nix build` green having asserted nothing — the same silently-inert trap that `doCheck` sprang in task 0037.
The script now passes `--passWithNoTests=false`.
`nix build --rebuild` reported the derivation "may not be deterministic".
The cause predates this task: `checkPhase`'s vitest leaves a run cache at `node_modules/.vite/…/results.json` recording durations and timestamps, and `npmInstallHook` copies `node_modules` into `$out` wholesale — so every build shipped a stray cache in the closure and no two outputs matched.
`checkPhase` now removes it, and `--rebuild` passes.
Strictly this belonged to 0037, but this task adds a second vitest run over the same surface, and the fix is one line in a file already being edited.
### Scope note
`.gitignore` gains `result` / `result-*`.
That is 0037's flake output rather than this task's, but the symlink appears the moment anyone runs `nix build` without `--no-link` and was already showing up as untracked.

View File

@@ -1,53 +0,0 @@
---
spec: nix-flake-packaging
blocked-by: 0037-flake-package-and-wrapper
---
## What to build
Two further flake outputs, so `nix develop` gives a declarative answer to "what do I need to work on this" and `nix flake check` is not a silent no-op.
The development shell carries the toolchain the repository actually needs: Node, `git`, and `tea` — enough for the build, the live end-to-end tier, and the benchmark harness, all of which the repository documents while specifying no toolchain anywhere.
It references the same Node attribute as the package, so development and the shipped artifact cannot drift onto different major versions.
The checks output aliases the package, so the conventional health-check command builds it and thereby runs both its verification phases.
Granular per-stage checks are deliberately not added: the one stage that would add real coverage is the full typecheck, which spans the test and benchmark directories and would therefore drag the benchmark harness into the derivation's inputs, undoing the source filtering.
The full typecheck stays in continuous integration, where it already runs.
## Acceptance criteria
- [x] `nix develop` yields a shell with Node, `git`, and `tea` available.
- [x] The build, the fast tier, and the benchmark harness's runner all work from inside that shell.
- [x] The shell's Node and the package's Node come from one reference — changing it moves both, and they cannot be set independently.
- [x] `nix flake check` builds the package and runs its tests, and fails when the package fails.
- [x] No per-stage check derivations are added.
## Implementation Notes
### The single Node reference is a `passthru`, not a second `pkgs.nodejs`
The shell takes `self.packages.${system}.gitea-axi.nodejs` rather than naming `pkgs.nodejs` again.
Naming it twice would satisfy the criterion's letter while leaving two places to edit, which is the drift the criterion exists to prevent; reading it back off the derivation means there is genuinely one reference.
The first cut relied on `buildNpmPackage` incidentally surfacing its `nodejs` argument as a derivation attribute — which works, but only as a side effect of `inherit src nodejs`, with nothing marking it load-bearing.
Review caught that: moving or dropping that `inherit` would have broken the shell silently at a distance.
`package.nix` now declares `passthru = { inherit nodejs; };`, making it an interface with a comment saying what depends on it.
`passthru` does not enter the derivation, so the store path is unchanged and the change costs no rebuild — verified: the drv hash before and after is identical.
### `curl` was a missing part of the toolchain
The benchmark's `raw-api` arm shells out to `curl` (`ARM_BINARY` in `bench/guard.ts`), so the shell carries it alongside Node, `git`, and `tea`.
### The shell deliberately does not supply `gitea-axi`
The criterion asks that the benchmark harness's runner work from inside the shell, and it does.
A *live* arm run is a further step the shell cannot take: `provisionArmBin` resolves each arm's binary by name off `PATH`, and the `gitea-axi` arm's binary must be the locally built `dist/main.js` so a run measures the working tree rather than whatever the flake last packaged.
Putting the packaged binary on `PATH` would satisfy the lookup with the wrong artifact — a silently misleading benchmark, worse than a missing one.
Exposing the *built* one was considered and rejected as well: `tsc` does not set an executable bit on `dist/main.js` (npm sets it at install time from the manifest's `bin` entry, which is what the packaging tier's bit assertion guards), so a `shellHook` would have had to `chmod +x` the build output on every shell entry — mutating build artifacts to work around a lookup that is the benchmark's own concern.
Both the flake and the CLAUDE.md gotcha now state the boundary rather than implying the shell covers it.
### CLAUDE.md's toolchain gotcha was stale by construction
It read "the repository has no dev shell yet (task 0039 adds one)".
This slice is that task, so the entry was rewritten to point at `nix develop --command`, keeping `nix shell nixpkgs#nodejs -c ...` only as the one-off outside the repository.

View File

@@ -1,61 +0,0 @@
---
spec: nix-flake-packaging
blocked-by: 0036-parameterized-installed-binary-tier
---
## What to build
Continuous integration moves off end-of-life Node, tests the full range of Node versions the package claims to support, and gains the two test tiers it currently never runs.
The workflow matrixes over the two supported Node versions, and the declared engine range in the package manifest narrows to match.
Today the manifest promises support down to Node 20 while testing only Node 20, so the entire claimed range below the tested version is unverified and its floor is end-of-life.
Narrowing is free right now because nothing has been published and no tags exist; that window closes at first publish.
The packaging tier currently asserts that the declared range mentions Node 20, so that assertion changes with it — part of this work rather than a later surprise.
The live end-to-end tier moves to the highest matrix leg only: it exercises the Gitea API contract rather than Node-version behavior, and each leg provisions a full Gitea service.
Two tiers join the workflow.
The benchmark harness tier runs on every leg — it is deterministic, needs no network or agent SDK, and is currently unguarded despite its non-default runner configuration being an easy thing to believe is running when it is not.
The packaging tier runs on the highest leg only, being slow and largely version-independent; it is the only automated guard on the distribution artifact, given that publishing is a manual command.
The benchmark smoke tier stays out: it targets a live host discovered through the maintainer's own credentials and skips cleanly when they are absent, so here it would pass by skipping — a green check that verified nothing.
The workflow keeps its container-and-npm shape and its GitHub Actions compatibility; nothing migrates to building via Nix.
## Acceptance criteria
- [x] The workflow runs a matrix over the two supported Node versions, and no leg runs an end-of-life Node.
- [x] The manifest's declared engine range names exactly the versions the matrix tests.
- [x] The packaging tier's engine assertion matches the narrowed range and passes.
- [x] The live end-to-end tier runs on the highest leg only.
- [x] The benchmark harness tier runs on every leg, under its own runner configuration.
- [x] The packaging tier runs on the highest leg only.
- [x] The benchmark smoke tier does not run.
- [x] The workflow syntax stays GitHub-Actions-compatible.
## Implementation Notes
The supported majors are 22 and 24 — the two current long-term-support lines, and the pair that brackets the nixpkgs default the flake already builds against (24.18.0).
The engine range became `^22 || ^24` rather than `>=22`, so it names those two majors and nothing else; a bare floor would have re-promised the odd-numbered 23, which is itself end-of-life.
The single matrix job was kept rather than split into a matrixed job plus a separate single-version job for the once-only tiers.
The acceptance criteria are phrased in terms of matrix legs, one job keeps the version list in exactly one place, and the task asked that the workflow keep its container-and-npm shape.
The cost is that the Gitea service container starts on the Node 22 leg without the end-to-end tier consuming it — cheap next to running that tier twice, which is what the spec's rationale was actually guarding against.
The once-only steps condition on `matrix.highest`, a flag attached to the `24` leg through a `strategy.matrix.include` entry, rather than on `matrix.node == '24'` at each site.
An `include` entry whose keys match an existing combination augments that leg rather than adding a new one, on both GitHub Actions and Gitea Actions, and an undefined context property is falsy on the other leg.
Two consequential changes beyond the criteria, both surfaced by review:
`@types/node` moved from `^20.19.0` to `^22.20.1`.
It was the last Node 20 reference left in the manifest, and the typecheck runs on every leg.
It tracks the *floor* rather than the highest leg deliberately: typings for 24 would let source compile against APIs the Node 22 leg does not have.
`.claude/spec/gitea-axi.md`'s "Language and Runtime" section still read "TypeScript on Node 20+", which this change falsified.
Verified by running the whole matrix's worth of tiers locally through `nix develop`: typecheck, the fast tier with coverage thresholds (410 tests), the benchmark harness tier under its own runner configuration (117), and the full packaging tier (7), plus `nix build .#gitea-axi` after the lockfile moved.
The workflow was parsed with `yq` to confirm the matrix, the container expression, and the two `if:` conditions land where intended.
The live end-to-end tier was not run locally — it needs a disposable Gitea instance, and the diff only gates it.
One follow-up worth flagging: `vitest.bench.config.ts` sets `passWithNoTests: true`, so if its include glob ever broke, the new benchmark step would go green while running nothing — precisely the failure mode this task cites as the reason to add the step.
Pre-existing, and left alone here rather than widened into a test-configuration change.

View File

@@ -1,71 +0,0 @@
---
spec: nix-flake-packaging
blocked-by: 0037-flake-package-and-wrapper
---
## What to build
A separate continuous-integration job that builds the flake, on both push and pull request.
Its value is detecting flake rot — most concretely, a build-relevant file omitted from the source allowlist — at the commit that causes it rather than weeks later at the maintainer's next system rebuild.
It is deliberately non-gating for the other jobs, so an infrastructure problem with Nix availability on the runner does not block an otherwise legitimate change.
Its cost is honest and accepted: because the checks output aliases the package, this job re-runs the fast tier inside the derivation and, without a warm store, rebuilds the whole dependency closure.
## Acceptance criteria
- [x] A distinct job builds the flake on push and on pull request.
- [x] Its failure does not block or fail the other jobs.
- [x] Removing a build-relevant file from the source allowlist makes this job fail.
- [x] The job's cost and its non-gating intent are stated in the workflow so neither reads as an oversight.
## Implementation Notes
The job sits alongside `test` in the existing workflow rather than in a file of its own, so it inherits the `on:` triggers already there — push to `main` and every pull request — with no second copy to keep in step.
Non-gating is achieved two ways, and both are needed.
The absence of a `needs:` edge means the two jobs run concurrently and neither waits on the other, so a slow or failing flake build cannot hold the test job back.
`continue-on-error: true` then keeps a red flake job from failing the workflow run as a whole, which is the part that would otherwise block a merge.
That combination is what the criterion asks for; either alone leaves a gap.
### `continue-on-error` goes on the steps, not the job
The obvious spelling — `continue-on-error: true` as a job key — is inert on the platform this workflow primarily targets, and the first draft of this task had it there.
Gitea Actions runs on a fork of `act`, and that fork's `pkg/model/workflow.go` declares `RawContinueOnError` on its **`Step`** struct only; the `Job` struct has no such field.
A job-level flag is therefore parsed as an unknown key and silently ignored, so a failing `nix flake check` would have failed the whole workflow run — exactly the merge-blocking outcome the criterion forbids, and with nothing in the logs to say why.
Gitea's own comparison page does not list the gap, which is presumably how it survives; go-gitea#25897 mentions it in passing while reporting the sibling gap in `jobs.<id>.if`.
Moving the flag onto both steps fixes it and costs no portability: step-level `continue-on-error` is honoured by `act` and GitHub Actions alike, and a job whose every step carries it concludes green on either platform.
The install step carries it too, not just the build — an action that fails to fetch or install Nix is precisely the infrastructure failure the non-gating stance exists to absorb.
### `nix flake check`, not `nix build`
The two build the same derivation, since the `checks` output aliases the package.
The check output is what a consumer would verify with, though, so exercising that path additionally catches a `checks` output that has stopped evaluating.
One limit worth recording: `nix flake check` builds the current system's outputs and merely *evaluates* the others, so the two systems the runner is not — `aarch64-linux` and `aarch64-darwin` — are type-checked rather than built.
`--all-systems` would not change that; building them needs runners of those architectures.
The job's value is the allowlist guard, which is architecture-independent, so this is a limit rather than a shortfall.
### The allowlist criterion was verified, not assumed
Deleting `./tsconfig.build.json` from `package.nix`'s `lib.fileset.unions` and re-running `nix flake check` locally fails the build in `buildPhase`:
```
> tsc -p tsconfig.build.json
error TS5058: The specified path does not exist: 'tsconfig.build.json'.
```
`package.nix` was restored immediately afterwards; the probe is not in the diff.
This is the failure mode the job exists to catch, and it confirms the loud-but-disconnected shape the 0037 Gotcha describes — the error names the missing file, never the allowlist that omitted it.
### `cachix/install-nix-action` is a third-party action
Nix is not in the runner image, so the job installs it.
That is the one dependency in this file on an action outside `actions/`, resolved from github.com by Gitea Actions the same way `actions/checkout` is.
It is also the most likely source of the infrastructure flakiness `continue-on-error` exists to absorb, which is part of why that flag is set rather than merely tolerated, and why the install step carries it as well as the build step.
It is pinned to a mutable major tag (`@v31`), matching how `actions/checkout@v4` is pinned elsewhere in the file rather than introducing a second convention.
A commit SHA would be the supply-chain-tight choice; the exposure here is a non-gating job holding no credentials and no `secrets` access, and pinning one action by SHA while the rest of the file uses tags would be inconsistent without being materially safer.
Worth revisiting as a file-wide decision rather than a local one.

View File

@@ -1,98 +0,0 @@
---
spec: nix-flake-packaging
---
## What to build
Resolve the spec's one open verification item, then act on what is found.
The `setup` command's hook installation passes the agent SDK both an absolute path to the running entrypoint and the bare binary name.
Under Nix the absolute path is content-addressed: it changes on every rebuild and is eventually garbage-collected, so a hook that records it would break silently — a session-start hook that cannot execute simply does not run.
The bare binary name strongly suggests the SDK prefers search-path resolution and treats the absolute path as a fallback, which would make this a non-issue, but that could not be confirmed during design because the dependency was not installed.
The decision is to verify before acting.
Determine, against the installed SDK, which of the two the hook installation actually records.
If it prefers the bare name, record the finding and close the item — no code changes.
If it records the absolute path, the immediate mitigation is documenting that the hook setup should be re-run after an upgrade.
Changing the `setup` command to prefer the bare name is explicitly **not** part of this task: it would become a separate task with its own ADR, justified on the grounds that a stable search-path name is more robust for *every* installation method, and explicitly not as a special case that detects Nix store paths in application code.
## Acceptance criteria
- [x] The SDK's actual hook-path behavior is determined by observation against the installed dependency, not inference from its interface.
- [x] The finding is recorded where a future reader will meet it, so the question is not re-opened from scratch.
- [x] If the absolute path is recorded, the documentation states that hook setup must be re-run after an upgrade.
- [x] No change is made to how the `setup` command constructs the hook in this task.
## Evidence gathered during task 0037
Task 0037 built the flake, which made the SDK's behaviour directly observable.
The answer is the unfavourable one: **the absolute path is recorded**, so the mitigation branch of this task applies, not the close-the-item branch.
`resolvePortableHookCommand` in `axi-sdk-js` returns the bare binary name only when a `PATH` entry realpath-matches the entrypoint, and the absolute path in every other case.
Driving the Nix-built binary writes this into `~/.claude/settings.json`:
```
/nix/store/nmkzjny0hpzjvyxzdz189whk605di8b6-gitea-axi-0.1.0/lib/node_modules/gitea-axi/dist/main.js
```
That path is content-addressed, so it changes on every rebuild and is eventually garbage-collected, and the session-start hook then silently stops running.
A second defect surfaced from the same line.
`isManagedHook` recognises its own hook by testing whether the recorded command *string contains* the marker `"gitea-axi"`, so when the entrypoint path lacks that substring, `setup hooks` appends a duplicate entry instead of updating in place — contradicting the idempotency its help text promises.
This is reproducible outside Nix: copy the checkout to a path containing no `gitea-axi` segment and `test/setup.test.ts` fails.
Consequences for this task:
- Both defects trace to the same resolution line, so they should be weighed together.
- The stale store path is user-facing breakage on the install method task 0037 added, which argues for not letting this drift far behind it.
- `package.nix` carries a `postUnpack` rename of the build tree purely to work around the substring coupling. It is commented as a workaround and should be **deleted as part of this task**, once the hook no longer depends on the entrypoint path.
## Implementation Notes
### The observation
Task 0037's evidence was re-verified independently rather than taken on trust, since acceptance criterion 1 asks for observation and not for citation.
Two observations were made against the installed dependency.
A probe drove `resolvePortableHookCommand` directly with two synthetic install trees.
Given a `PATH` entry that is a *symlink* to the entrypoint it returned the bare name `gitea-axi`; given a `PATH` entry that is a *wrapper script* invoking `node <entrypoint>` it returned the absolute path.
Then the flake was built and the resulting binary driven for real against a temporary `HOME`.
With `$out/bin` on `PATH`, `~/.claude/settings.json` recorded:
```
/nix/store/pqxhyy5cg1rljyn78kxfpxyfpgz7rgzk-gitea-axi-0.1.0/lib/node_modules/gitea-axi/dist/main.js
```
This confirms 0037's finding and sharpens it.
The task framed the bare name as a hint that the SDK "prefers search-path resolution"; that preference is real, but it is gated on a `PATH` entry whose realpath equals the entrypoint.
npm satisfies that by symlinking its `bin` entry; Nix cannot, because `nodejsInstallExecutables` generates a wrapper script and this package adds a second `makeWrapper` layer for `git` and `tea`.
So the behaviour is not Nix-specific — it applies to *any* wrapper-based install — which strengthens the case, already recorded, that the eventual fix belongs in the `setup` command for every installation method rather than as a Nix special case.
### Deviations
**The `postUnpack` rename in `package.nix` was kept, not deleted.**
The "Evidence gathered during task 0037" section above says it "should be **deleted as part of this task**", which conflicts with acceptance criterion 4 and with the "What to build" section's statement that changing the hook to prefer the bare name is "explicitly **not** part of this task".
The conflict resolves on the Evidence section's own wording: the deletion is conditioned on "once the hook no longer depends on the entrypoint path", and establishing that precondition is exactly the out-of-scope change.
Deleting the rename now would leave the derivation's build tree at a path with no `gitea-axi` segment, which `isManagedHook`'s substring test still requires, and `test/setup.test.ts` would fail inside `checkPhase`.
The rename's comment was rewritten instead: it previously promised that task 0042 would remove the coupling, which would have become a stale forward reference the moment this task landed.
**The documentation surface is the `setup` help text.**
The repository has no README, so the command's own help is the only place a user meets this.
Criterion 4 is untouched — `binaryNames`, `execPath`, and the `installSessionStartHooks` call are all unchanged; only the `usage` string moved.
**One sentence beyond the strict ask.**
The help text asserted "Both are idempotent" unqualified, which the finding recorded in this same commit makes false for the duplicate-append case.
Leaving a statement the commit itself documents as untrue seemed worse than a one-line caveat, so the new paragraph notes that a stale entry may survive a re-run.
### Follow-up
The successor is task 0043, which covers both defects together — the absolute-path recording and `isManagedHook` recognising its hook by substring — since both trace to the same resolution line, and deletes `package.nix`'s `postUnpack` rename with them.
Grilling the mitigation afterwards found the framing here too narrow.
The maintainer's agent configuration is generated declaratively, so `~/.claude/settings.json` and the installed Skill are both read-only symlinks into the Nix store: `setup hooks` cannot write at all, and `setup` crashes outright on an unhandled filesystem error.
The recorded path's *shape* is therefore not the whole defect — the deeper one is that `setup` is write-only against a target that some operators cannot let it write.
Tasks 0044 and 0045 follow from that: a clean failure on unwritable targets, and a declarative install path that generates the configuration instead of mutating it.
The help-text mitigation added by this task is deliberately left in place rather than pre-emptively reverted.
It is accurate until task 0043 lands, which removes it as an acceptance criterion.

View File

@@ -1,68 +0,0 @@
---
spec: nix-flake-packaging
---
## What to build
Make the SessionStart hook survive an upgrade by recording a name that does not move.
Task 0042 established by observation that the hook records the entrypoint's absolute path on every wrapper-based install, and documented a re-run-after-upgrade mitigation.
This task removes the need for that mitigation.
The SDK returns the bare binary name only when a `PATH` entry realpath-matches the entrypoint it is handed.
An npm install satisfies that by symlinking its `bin` entry straight at the entrypoint; a wrapper-based install cannot, because a script that *invokes* a file never resolves *to* that file.
Handing the SDK the location where the binary actually resolves on `PATH`, rather than the module-relative entrypoint, makes the match succeed and the bare name get recorded — using the SDK's own resolution rather than bypassing it.
When the binary is not on `PATH` there is nothing to hand it, and the existing absolute-path behaviour stands unchanged as the fallback.
This is not a Nix accommodation.
Any wrapper-based install has the same shape — a shim, a launcher, a generated `.cmd` — and the fix is the convention for tools that write into user-owned configuration: prior art records a bare name and lets `PATH` resolve it, reserving absolute paths for configuration that a package manager regenerates.
A second defect shares the same line and is fixed here.
The hook is recognised as its own by testing whether the recorded command string *contains* the marker, so an entrypoint path lacking that substring makes re-running `setup hooks` append a duplicate rather than update in place, contradicting the idempotency its help text promises.
Recording the bare name makes the marker match by construction, but the recognition itself should not depend on the recorded command's shape.
The Nix derivation renames its build tree solely to work around that substring coupling.
Once the coupling is gone the rename has no remaining purpose and goes with it.
## Acceptance criteria
- [x] The recorded hook command is the bare binary name whenever that name resolves to the running program on `PATH`.
- [x] The recorded hook command remains the absolute entrypoint path when the binary is not resolvable on `PATH`, and that fallback is exercised by a test.
- [x] Re-running `setup hooks` updates the existing entry in place rather than appending a second one, including when the entrypoint path does not contain the marker.
- [x] The `setup` help text no longer instructs the user to re-run hooks after an upgrade, that instruction having become false.
- [x] The derivation no longer renames its build tree, and the build still passes with the tree at a path that does not contain the marker.
- [x] The behaviour is verified against a real wrapper-based install, not only against a source checkout.
## Implementation Notes
The decision is recorded as [ADR 0019](../adr/0019-hook-records-search-path-name.md).
ADR 0009's addendum claimed the SDK registers the bare binary as the hook command, which held only for npm; it is amended in place.
The spec's "Resolved verification item" section, which concluded the bare name was unreachable through a wrapper, is rewritten to record that task 0043 superseded it.
### Resolving the name had to be stricter than first written
The first cut accepted any executable file named `gitea-axi` on `PATH` and handed it to the SDK.
That satisfied the letter of the change — the SDK's realpath test passed and the bare name got recorded — but only because the path handed over trivially matched itself, which made the SDK's check a tautology rather than a use of it.
Criterion 1 asks for the name to resolve *to the running program*, and that version would have recorded a bare name for a different `gitea-axi` shadowing this one on `PATH`.
`resolveEntrypointOnPath` therefore requires the candidate to be either a symlink whose realpath is the entrypoint (npm's shape) or a wrapper that names the entrypoint in its text (the generated shape).
Driving the real Nix binary showed the wrapper case is two hops, not one: `bin/gitea-axi` sets `PATH` and execs `bin/.gitea-axi-wrapped`, and only that second script names the entrypoint.
Containment follows the chain, bounded by hop, file-count and file-size caps so a dense chain cannot run away, and falls back to the absolute path wherever it cannot reach the entrypoint.
### Recognising the tool's own hook
The SDK's `isManagedHook` is a substring test against the recorded command and is not ours to change, so `setup hooks` prunes duplicates itself after the SDK writes.
An early version's predicate was `recorded === command || recorded.includes("gitea-axi")`, which reintroduced the very coupling this task removes and could have deleted an unrelated tool's hook whose command merely mentioned `gitea-axi`.
It is now exact-equality only.
That is sufficient: a *re-run* records an identical command, and the upgrade case is handled by the bare name being stable in the first place.
Duplicates are pruned only from `~/.claude/settings.json` and `~/.codex/hooks.json`.
The third integration, OpenCode, is a plugin file the SDK rewrites wholesale behind its own managed marker, so it cannot accumulate duplicates.
### Verification
Criterion 6 was met by driving the built Nix binary rather than by a test, since no test tier installs a wrapper.
Against `result/bin/gitea-axi`: on `PATH` records `gitea-axi`; off `PATH` records the store entrypoint path; a same-named impostor on `PATH` falls back rather than recording the name; and re-running in both the on-`PATH` and fallback cases leaves exactly one entry.
A globally `npm install`-ed pack of the same tree records `gitea-axi` through its symlinked `bin`, confirming the npm shape still resolves.
Criterion 5 is what `nix build` now demonstrates: with `postUnpack` deleted the fast tier runs from `/build/source`, a path with no marker in it, and the re-run idempotency test passes there — which it could not before the pruning change.

View File

@@ -1,52 +0,0 @@
---
spec: nix-flake-packaging
---
## What to build
Report an unwritable target as an error the user can act on, instead of crashing.
Both halves of `setup` assume the files they manage are writable.
When they are not — because a configuration manager owns them, because a file is flagged immutable, because the path is root-owned — the skill install raises a raw filesystem error with no handling at all, and the hook install surfaces the underlying message through its error collector without saying what a reader should do about it.
Neither failure is exotic.
Any tool that manages a user's agent configuration declaratively renders these paths read-only, and gitea-axi's own Nix install method encourages exactly that arrangement.
The error names the file and the condition, and points at the general remedy: the file appears to be managed elsewhere, so the skill or hook should be declared through that configuration rather than installed by this command.
It deliberately does not guess at the cause.
Read-only is not diagnostic of any particular manager, and naming one would be wrong for most users who hit this.
The failure follows the CLI's existing error convention rather than inventing a shape, so it carries a code and help lines like every other error the tool reports.
## Acceptance criteria
- [x] An unwritable skill target produces a structured CLI error rather than an unhandled filesystem exception.
- [x] An unwritable hook target produces the same class of error, with the same guidance.
- [x] Both errors name the file that could not be written and state that it appears to be managed by another tool.
- [x] Neither error names or infers a specific configuration manager.
- [x] A skill target that is unwritable but already byte-identical to the bundled copy succeeds rather than failing, since nothing needs to be written.
- [x] The errors carry a code and help lines consistent with the rest of the CLI's error surface.
## Implementation Notes
The condition is `EACCES`, `EPERM`, or `EROFS` — the three ways a filesystem refuses a write for a reason the user has to settle outside this tool.
The new `TARGET_NOT_WRITABLE` code joins the spec's enumerated list, alongside a paragraph describing it.
Two things came out of review and go slightly beyond the literal criteria.
The skill half now guards its comparison read as well as its write.
A target the filesystem will not let us read is one it will not let us replace either — the same condition reached one call earlier — so a mode-`000` file reports the same error rather than the raw exception the criteria were written against.
The hook half collects its failures as `{path, detail}` records rather than the agent SDK's flattened `<path>: <message>` text.
The SDK reports through a string, so the two halves are separated once at that boundary and judged apart.
This matters for correctness, not just shape: testing the whole formatted string for an errno would misclassify an unrelated failure whose *path* happened to contain `EACCES`.
Two known limits, both judged acceptable rather than fixed.
The hook error names the target the SDK was writing, which is the intended path rather than necessarily the blocking one — if `~/.claude` were unwritable and `settings.json` absent, it would name the file rather than the directory.
The SDK discards the error object, so its `path` is not recoverable; the skill half, which catches its own errors, does report the blocking path and is tested for it.
An unwritable `~/.claude/settings.json` still leaves the Codex and OpenCode integrations installed, because the SDK writes them before the failure surfaces.
The command exits 1 having done part of its work.
Making the hook install transactional across three integrations owned by the SDK is a larger change than this task, and re-running after fixing the permission converges correctly.

View File

@@ -1,83 +0,0 @@
---
spec: nix-flake-packaging
blocked-by: 0043-hook-records-bare-binary-name
---
## What to build
Let a Nix configuration declare gitea-axi's ambient context, instead of running a command that writes it.
`setup` and `setup hooks` are write-only.
They install the Agent Skill and the SessionStart hook by writing into the user's agent configuration directory, which works only when the user owns those files imperatively.
An operator whose agent configuration is generated declaratively cannot use either: the targets are read-only, and the operator is left hand-copying the Skill into their own configuration, where it silently drifts from the package that ships it.
The spec currently lists a home-manager module under Out of Scope, deferring it until there was usage evidence that the trade-off was worth making.
That evidence now exists, and the deferral's stated reasoning does not survive it: the concern was the automatism that ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script, and a module the operator explicitly imports and enables is the opposite of an implicit install.
Revising that Out of Scope entry, and recording the decision as an ADR, is part of this task.
Two layers, the second built on the first.
The package gains a stable, documented location for the bundled Agent Skill, and exposes both the Skill and the hook's specification as attributes a Nix expression can consume.
Today the Skill's only address is a path inside the installed node modules tree, which is an implementation detail no consumer should depend on.
On top of that, the flake exposes a home-manager module: a thin wiring layer that declares the Skill and the hook from those attributes.
It follows the conventions the home-manager module tree overwhelmingly uses — an enable option so that importing the module does nothing until it is switched on, an overridable package option, and installation of that package by default with a null value as the documented opt-out for an operator who supplies the binary another way.
Each managed piece has its own toggle, defaulting on, so an operator can take the Skill declaratively while continuing to write the hook by hand.
The hook's specification is declared once, in a committed file that both the Nix expression and the test suite read.
Declaring it in the Nix expression alone would create a second source of truth alongside the behaviour of the imperative install path, with nothing to keep them agreed; a test that hardcoded the same values a third time would verify nothing.
The test drives the imperative install against a temporary home directory and asserts that what it writes matches what the file declares, so a divergence — including one introduced by the SDK changing the envelope it writes — fails a test rather than passing silently into a release.
The two installation paths remain independent and both supported: the command for operators who own their configuration, the module for operators whose configuration owns them.
## Acceptance criteria
- [x] The bundled Agent Skill is installed to a stable location in the package output that is not an internal implementation path.
- [x] The package exposes the Skill and the hook specification as attributes consumable from a Nix expression without building or running anything.
- [x] The hook specification is declared in a single committed file, read by both the Nix expression and the test suite.
- [x] A test drives the imperative hook install and asserts that what it writes matches the declared specification, failing if either side drifts.
- [x] The flake exposes a home-manager module that declares the Skill and the hook.
- [x] Importing the module without enabling it changes nothing about the resulting configuration.
- [x] The module installs the package by default, and accepts a null package as the documented way to declare the configuration without installing the binary.
- [x] The Skill and the hook each have their own toggle, both defaulting to on.
- [x] The module composes with an existing configuration that already declares its own SessionStart hooks and skills, rather than conflicting with it.
- [x] The spec's Out of Scope entry excluding a home-manager module is revised, and the decision to reverse it is recorded as an ADR.
- [x] The user-facing documentation describes both installation paths and when each applies.
## Implementation Notes
The decision is recorded as [ADR 0020](../adr/0020-home-manager-module-for-declarative-context.md).
The spec's Out of Scope entry is deleted and its "Flake surface" section rewritten to record the reversal rather than to pretend the deferral never happened.
### The hook specification is the settings entry, not its parts
`session-start-hook.json` holds the SessionStart entry verbatim as it belongs in a Claude Code `settings.json` — matcher, and the hook array inside it — rather than the fields the entry is assembled from.
Declaring the fields would have left the *grouping* restated in both the Nix expression and the test, which is exactly the kind of second source of truth the file exists to prevent.
As written, the Nix expression is `[ sourcePackage.sessionStartHook ]` and the test is a deep-equality against the same value, so neither restates anything.
The file's contents were derived by observation — running the installed binary against a temporary home and reading what the agent SDK wrote — and the new test was confirmed to fail when the declaration is perturbed, rather than being assumed to bite.
### The module declares through `programs.claude-code`, not through `home.file`
Writing `~/.claude/settings.json` directly would collide with home-manager's own Claude Code module, so the module sets that module's options and lets home-manager's merge semantics compose.
Verified against real home-manager before landing, on five configurations: importing without enabling produces a **byte-identical** generation to never importing at all; enabling alongside a configuration that already declares its own SessionStart hook and its own skill yields both of each; `package = null` installs no binary but still declares the Skill; the skill-only toggle declares no hook; and omitting `programs.claude-code.enable` fails the assertion with the intended message.
### `package = null` still sources the Skill from the default build
The task called null "the documented opt-out for an operator who supplies the binary another way", which settles where the *binary* comes from but not where the Skill's bytes do.
They come from the default build, which for the intended case — a system-wide install of this same package — is already in the closure.
The sharp edge is an operator whose system-wide copy is a different build: their Skill would come from a package they are not running.
That is documented on the option itself rather than designed away, since the alternative is refusing to declare a Skill at all in the one arrangement the null value exists to serve.
### Two limitations documented rather than fixed
Home-manager inspects the Skill path during evaluation, so a rebuild realises the package at evaluation time even under `package = null`.
This is inherent to sourcing the Skill from the package and is a rebuild-latency cost, not a correctness one; the alternative would ignore `package` overrides entirely.
`programs.claude-code.skills` also accepts a bare path standing for a whole skills directory, and a configuration using that form cannot have an entry merged into it.
Both are recorded in INSTALL.md and in the ADR's Consequences.
### Follow-up worth flagging
The repository has no `README.md`, so `INSTALL.md` — which follows the existing convention of topic-scoped root documents alongside `PUBLISHING.md` — is discoverable only by browsing the repository, and is not in the npm `files` allowlist so it does not ship in the tarball.
Neither was changed here: adding a README is its own piece of work, and installation instructions inside an already-installed tarball are of little use.

View File

@@ -1,48 +0,0 @@
---
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

@@ -1,49 +0,0 @@
---
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
- [x] The flake has a home-manager input whose nixpkgs follows the flake's nixpkgs; `flake.lock` is updated.
- [x] 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.
- [x] The attribute-set-skills configuration asserts the module's Skill and the operator's skill both land, each at its own name.
- [x] 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.
- [x] The Claude-Code-disabled configuration asserts no gitea-axi Skill entry is written.
- [x] A configuration with an operator's own SessionStart hook asserts the module's hook merges into that list rather than replacing it.
- [x] The check asserts on the generation's file tree only, not on option values or store paths.
- [x] `nix flake check` runs the new check across the flake's systems and passes.
## Implementation Notes
The check lives in its own file, `checks/home-manager-module.nix`, imported from the flake's `checks` output beside the existing package check; the `forAllSystems` callback now destructures `{ pkgs, system }` because the check needs `pkgs` to build fixtures and the derivation.
`home-manager` is added as a flake input with `inputs.nixpkgs.follows = "nixpkgs"`; it is a check-only development input with no bearing on the package or the module a consumer imports, and the comment in `flake.nix` says so.
Each of the four configurations builds `config.home-files` — the home-manager file-linkage layer — and the final `runCommandLocal` asserts on that tree with `test`/`grep` only: skill files present or absent by path, and the two SessionStart commands present as quoted JSON string values in `settings.json`.
The hook-merge assertion reads generated file content because that is the only place a list merge is observable; the spec's Testing Decisions name "the hook merges into the operator's own hooks" as a required assertion, so this stays within "assert on the file tree, not on option values or store paths".
On the cross-system criterion: `nix flake check` builds the check for the host system and passes, and omits the incompatible systems (`aarch64-*`) with a warning — identical per-system semantics to the pre-existing package check, which also builds only natively.
The check is import-from-derivation-bearing (the Claude Code module reads the fixture skill directories at evaluation time), so evaluating a foreign system's check forces a cross-platform fixture build rather than skipping cleanly; this is not exercised by the default `nix flake check` and does not affect the native run.
Verified by `nix flake check` (passes, all outputs) and by inspecting each configuration's built `home-files` tree directly: the disabled-Claude-Code generation contains no `.claude` directory at all, and the merged-hook `settings.json` contains both hooks in the `SessionStart` array — confirming the assertions are not vacuous.

View File

@@ -2,7 +2,7 @@
# is kept GitHub-Actions-compatible so the GitHub mirror can adopt this file # is kept GitHub-Actions-compatible so the GitHub mirror can adopt this file
# nearly verbatim (copy it to .github/workflows/). # nearly verbatim (copy it to .github/workflows/).
# #
# The `test` job runs inside a node container so the disposable Gitea service is # The job runs inside a node container so the disposable Gitea service is
# reachable by its service name (`gitea:3000`) on both Gitea Actions and GitHub # reachable by its service name (`gitea:3000`) on both Gitea Actions and GitHub
# Actions — avoiding the host-vs-service-name networking difference between the # Actions — avoiding the host-vs-service-name networking difference between the
# two platforms. # two platforms.
@@ -16,22 +16,7 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: node:${{ matrix.node }}-bookworm container: node:20-bookworm
strategy:
# Every leg's result is wanted: a failure on one Node major says nothing
# about the other, and cancelling the sibling would hide half the answer.
fail-fast: false
matrix:
# The supported Node majors, matching the manifest's declared engine
# range. Node 20 is end-of-life and deliberately absent.
node: ["22", "24"]
# Augments the highest leg with a flag, so the steps that run once
# share one named condition rather than each restating a version
# number. Keep this entry's `node` matching the last element above.
include:
- node: "24"
highest: true
services: services:
gitea: gitea:
@@ -65,64 +50,5 @@ jobs:
- name: Unit and integration tiers (with coverage thresholds) - name: Unit and integration tiers (with coverage thresholds)
run: npm run test:coverage run: npm run test:coverage
# Deterministic, and needs neither network nor the agent SDK, so it runs
# everywhere. Its non-default runner configuration makes it an easy tier
# to believe is running when it is not.
- name: Benchmark harness tier
run: npm run test:bench
# Exercises the Gitea API contract rather than Node-version behaviour, so
# one leg is enough.
- name: End-to-end tier - name: End-to-end tier
if: matrix.highest
run: npm run test:e2e run: npm run test:e2e
# Slow, and near enough version-independent — but the only automated guard
# on the distribution artifact, since publishing is a manual command.
- name: Packaging tier
if: matrix.highest
run: npm run test:pack
# Builds the flake, catching flake rot — most concretely a build-relevant file
# left out of package.nix's source allowlist — at the commit that causes it,
# rather than weeks later at the maintainer's next system rebuild.
#
# `continue-on-error` is deliberate, not an oversight: this job is non-gating.
# Nix is not part of the runner image, so an infrastructure problem installing
# or reaching it must not block an otherwise legitimate change. Read its result
# as a signal, not as a verdict — a red mark here still merges.
#
# Its cost is likewise accepted rather than accidental. The flake's `checks`
# output aliases the package, so this builds the whole dependency closure from
# cold — nothing warms the store between runs — and re-runs the fast tier and
# the installed-binary tier inside the derivation, both of which the `test` job
# has already run. That duplication buys the allowlist guard, which nothing
# else provides.
flake:
runs-on: ubuntu-latest
# No `needs`: it neither waits on the test job nor is waited on, so the two
# run concurrently and neither can hold the other back.
#
# `continue-on-error` is set per step rather than on the job, which reads as
# the odd spelling but is the only one that works here: Gitea's `act` fork
# has the field on its Step struct and not on its Job struct, so a job-level
# flag is parsed and silently ignored, and a red build would fail the run
# after all. Step-level is honoured by both act and GitHub Actions, and a job
# whose every step is continue-on-error concludes green on either — so this
# spelling keeps the file portable as well as correct.
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v31
continue-on-error: true
with:
extra_nix_config: |
experimental-features = nix-command flakes
# `checks` is the package, so this builds exactly what `nix build` does,
# entered through the output a consumer would verify with — which also
# catches a `checks` output that has stopped evaluating.
- name: Check the flake (builds the package)
continue-on-error: true
run: nix flake check --print-build-logs

2
.gitignore vendored
View File

@@ -2,5 +2,3 @@ node_modules/
dist/ dist/
coverage/ coverage/
bench/results/ bench/results/
result
result-*

View File

@@ -13,15 +13,10 @@ The benchmark arms invoke the **built `dist/main.js`** (the `gitea-axi` binary o
Run `npm run build` before any live `bench:run` if you want `src/` changes reflected; the bench does not run from source. Run `npm run build` before any live `bench:run` if you want `src/` changes reflected; the bench does not run from source.
Prefer this project's own CLI for pull requests — it is the tool being built, so opening its PRs with it is the dogfood path: Prefer this project's own CLI for pull requests — it is the tool being built, so opening its PRs with it is the dogfood path:
`npm run build && node dist/main.js pr create --login alexion --base main --head <branch> --title <text> --body-file <path>`. `npm run build && node dist/main.js pr create --login axi --base main --head <branch> --title <text> --body-file <path>`.
It reuses the `tea` login store, which here holds exactly `alexion` — there is no `axi` profile, and the `csv-reviewer` profile that used to exist is gone for good. The login profile is named `axi`, not `alexion` — passing `--login alexion` fails with `VALIDATION_ERROR`.
`selectLogin` matches the `--login` value against those names exactly, so `--login alexion` works and an unknown name like `--login axi` fails with `VALIDATION_ERROR` ("Login profile "axi" not found"). It reuses the `tea` login profiles, so it needs no separate credentials.
Fall back to `tea pr create --login alexion --base main --head <branch>` only for what gitea-axi cannot do yet; `tea pr` still lists PRs until `pr list` lands (task 0008). Fall back to `tea pr create --login axi --base main --head <branch>` only for what gitea-axi cannot do yet; `tea pr` still lists PRs until `pr list` lands (task 0008).
The same login store backs the benchmark: `npm run bench:run -- --arm <arm> --login alexion --task <id>` (or set `GITEA_AXI_BENCH_LOGIN=alexion`).
The `bench/` unit tests only run under their own Vitest project config: `npx vitest run --config vitest.bench.config.ts bench/<file>.test.ts`.
Plain `npx vitest run bench/<file>.test.ts` reports "no tests" because the default `vitest.config.ts` includes only `test/**`.
Task branches are merged into `main` on the remote, so the local `main` goes stale. Task branches are merged into `main` on the remote, so the local `main` goes stale.
Always `git fetch origin` and cut a task branch from `origin/main`, not from whatever local `main` happens to point at. Always `git fetch origin` and cut a task branch from `origin/main`, not from whatever local `main` happens to point at.
@@ -29,60 +24,3 @@ Always `git fetch origin` and cut a task branch from `origin/main`, not from wha
Gitea's issue/PR search endpoint (`GET /repos/issues/search`, behind `search issues`/`search prs`) is backed by an **asynchronous, eventually-consistent issue indexer** (bleve by default). Gitea's issue/PR search endpoint (`GET /repos/issues/search`, behind `search issues`/`search prs`) is backed by an **asynchronous, eventually-consistent issue indexer** (bleve by default).
Content created moments earlier may not be searchable yet, so end-to-end assertions that create an issue/PR and then search for it must poll (e.g. `expect.poll`) until it is indexed rather than searching once. Content created moments earlier may not be searchable yet, so end-to-end assertions that create an issue/PR and then search for it must poll (e.g. `expect.poll`) until it is indexed rather than searching once.
The fixture tier is unaffected — it stubs the endpoint — so this bites only the live `test/e2e` tier. The fixture tier is unaffected — it stubs the endpoint — so this bites only the live `test/e2e` tier.
`tea` is **still a runtime dependency**, despite [ADR 0002](.claude/adr/0002-direct-gitea-api-over-tea-subprocess.md) being titled "use direct Gitea API instead of wrapping the `tea` subprocess".
That ADR moved *command dispatch* to `gitea-js`; it explicitly kept `tea` for **credential discovery**, and its own Consequences section says so.
Per [ADR 0001](.claude/adr/0001-diff-auth-via-tea-login-list.md) as amended, `src/context.ts` resolves auth by shelling out to `tea login list --output json` (discovery) and `tea login helper get --login <name>` (token, with in-place OAuth refresh).
The only bypass is the test hook requiring `GITEA_AXI_API_URL` + `GITEA_AXI_TOKEN` + `GITEA_AXI_REPO` together; there is no user-facing path that avoids `tea`, and `TEA_NOT_INSTALLED` exists for its absence.
Neither `node`/`npm` nor `tea` is on the `PATH` in a non-interactive shell on this machine, and there is no `~/.gitconfig`.
This is a NixOS host with no global Node install, so run work through the flake's dev shell: `nix develop --command <cmd>`, which carries Node, `git`, `tea`, and `curl` — enough for the build, the fast tier, the live end-to-end tier, and the benchmark harness's own runner.
That covers anything resolving credentials, including `gitea-axi pr create`.
The shell deliberately does *not* put `gitea-axi` on the `PATH`, so a live `bench:run` of the `gitea-axi` arm still needs the built binary exposed under that name itself — see the benchmark gotcha above.
`nix shell nixpkgs#nodejs -c ...` still works for a one-off outside the repository, but inside it the dev shell is the declarative answer and pins the same Node the package is built against.
`node_modules/` may be absent too, so `npm ci` first.
Commits need an explicit identity: `git -c user.name=alexion -c user.email=contact@alexion.dev commit ...`, matching the existing history.
The Nix derivation's source is an **explicit allowlist** in [`package.nix`](package.nix), not the whole repository and not a gitignore filter.
A new build-relevant top-level file — a TypeScript configuration, a runner configuration the fast tier loads, a directory the build reads — must be added to that `lib.fileset.unions` list or `nix build` fails on a missing file.
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.
`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.
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`.
`nodejsInstallExecutables` (inside `npmInstallHook`) installs each manifest `bin` entry as a **generated wrapper that invokes `node <path>` explicitly**, not as a symlink to the entrypoint.
So under Nix the executable bit on `dist/main.js` is not load-bearing — `chmod -x` on it changes nothing — while the bit on `$out/bin/gitea-axi` is, and makeWrapper always sets it.
The npm path differs: there the `bin` entry is symlinked and npm sets the bit at install time.
`npmInstallHook` runs `npm prune --omit=dev` against the **build tree's** `node_modules` during `installPhase`, so dev dependencies (vitest included) are gone by the time `postInstall` or `installCheckPhase` runs.
Anything that needs them after install must snapshot the tree in `preInstall` first; `cp -al` is the cheap way, since the prune's deletions do not follow hardlinks.
Running vitest inside the derivation leaves a run cache at `node_modules/.vite/…/results.json` that records durations and timestamps, and `npmInstallHook` copies `node_modules` into `$out` wholesale.
Left alone it ships a stray cache in the closure and makes the output non-reproducible — visible only under `nix build --rebuild`, which reports "may not be deterministic"; an ordinary build stays green.
`checkPhase` deletes it before the install phase runs.
nixpkgs 26.11 (the `nixos-unstable` the flake tracks) has **dropped `x86_64-darwin`**.
`legacyPackages.x86_64-darwin` now *throws* rather than merely failing to build, so listing that system in the flake's `systems` breaks `nix flake show` and `nix flake check` for every system at once, not just that one.
Intel macOS would need the 26.05 branch.
Gitea Actions ignores **job-level `continue-on-error`**.
Its `act` fork declares `RawContinueOnError` on the `Step` struct only — `pkg/model/workflow.go` has no such field on `Job` — so `jobs.<id>.continue-on-error` is parsed as an unknown key and silently dropped, and the job fails the run as if the flag were never written.
Gitea's own syntax-comparison page does not list the gap.
Put `continue-on-error` on each step instead: `act` and GitHub Actions both honour it there, and a job whose every step carries it concludes green on either platform.
The same fork historically ignored `jobs.<id>.if` (go-gitea#25897), so treat any job-level key as needing a check against the fork's structs rather than against GitHub's documentation.
`resolvePortableHookCommand` in `axi-sdk-js` returns the bare binary name only when a `PATH` entry *realpath-matches* the **`execPath` it is handed**, and the absolute path otherwise.
Passing `binaryNames` does not by itself make the hook portable: npm symlinks its `bin` entry straight at `dist/main.js` so the match succeeds there, but a wrapper-based install (Nix, a shim, a generated `.cmd`) never can, because a script that *invokes* a file does not resolve *to* it.
Per [ADR 0019](.claude/adr/0019-hook-records-search-path-name.md), `setup hooks` therefore resolves `gitea-axi` on `PATH` itself and hands *that* location over, which makes the match succeed and the bare name get recorded.
It only accepts a `PATH` entry that resolves to the running entrypoint — a symlink to it, or a wrapper naming it in its text — so a same-named binary that is some other program falls through to the absolute-path fallback, as does a name that is not on `PATH` at all.
It reads `PATH` from `process.env`, not from the injected `deps.env`, because it has to agree with the SDK's own probing — so a test that wants to steer this has to set `process.env.PATH`.
Verify hook behaviour by driving the installed binary and reading `~/.claude/settings.json`, not by reading the SDK's interface; a hook whose recorded path no longer exists does not run and does not warn.
The SDK's `isManagedHook` recognises its own hook by testing whether the recorded command *contains* the marker, so it appends a duplicate instead of updating in place whenever the recorded command lacks the substring `gitea-axi`.
`setup hooks` compensates by pruning duplicates itself after the SDK writes.
This is why `package.nix` no longer needs to rename its build tree: the fast tier runs from `/build/source`, whose path has no marker in it, and the idempotency test passes there.

View File

@@ -1,106 +0,0 @@
# 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;
}
```
`programs.gitea-axi.enable` installs the CLI, always.
The Claude Code SessionStart hook follows the harness: it is declared under `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.
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 Agent Skill is exposed as `packages.<system>.gitea-axi-skill` for project-local delivery through an agent-agnostic dev-shell helper.
#### Options
| Option | Default | Meaning |
| --- | --- | --- |
| `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 hook without the binary. |
| `programs.gitea-axi.enableClaudeCodeIntegration` | `true` | Declare the SessionStart hook when Claude Code is enabled. |
`package = null` declares the hook 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.
Turn `enableClaudeCodeIntegration` off to keep the CLI declarative while writing the hook yourself with `setup`.
#### What the module does not cover
Only the Claude Code SessionStart hook 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.
The Agent Skill is packaged for project-local delivery, not installed globally by this module.
## 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.

View File

@@ -28,8 +28,7 @@ Bump the version first with `npm version <patch|minor|major>`, which updates `pa
## Verifying the packed artifact ## Verifying the packed artifact
The packaging smoke test comes in two facets: one asserts the shape of the packed tarball and its manifest, the other drives an installed binary — `--help`, the dashboard header, and `setup` finding the bundled skill. The packaging smoke test packs the real tarball, installs it globally into a throwaway prefix, and drives the installed binary — `--help`, the dashboard header, and `setup` finding the bundled skill:
By default it packs the real tarball and installs it globally into a throwaway prefix to produce that binary:
```sh ```sh
npm run test:pack npm run test:pack
@@ -37,10 +36,3 @@ npm run test:pack
It builds, packs, and fetches the runtime dependencies from the registry, so it is slower than the unit and integration tiers and is not part of the default `npm test` run. It builds, packs, and fetches the runtime dependencies from the registry, so it is slower than the unit and integration tiers and is not part of the default `npm test` run.
Distribution touches no Gitea API, so this smoke test is the distribution analogue of the live-Gitea end-to-end tier. Distribution touches no Gitea API, so this smoke test is the distribution analogue of the live-Gitea end-to-end tier.
The two facets are `test/packaging/tarball.test.ts`, which is npm-specific by nature, and `test/packaging/installed-binary.test.ts`, which asserts what any *installed* gitea-axi must do, whatever installed it.
That second facet is therefore also the check a non-npm installation path runs against its own output.
Set `GITEA_AXI_INSTALLED_BIN` to the path of an already-installed binary to have it drive that one and skip the pack-and-install setup.
The Nix build is that other caller: its `installCheckPhase` points this variable at the wrapped binary it has just installed and runs the facet alone via `npm run test:installed`.
So `nix build` and `npm run test:pack` guarantee the same things about an installed gitea-axi, and neither can quietly weaken while the other holds.

View File

@@ -1,8 +1,8 @@
# Benchmark harness # Benchmark harness
This directory holds the benchmark that tests gitea-axi's central claim — that it is an agent-ergonomic, low-token interface to Gitea — against the `tea` CLI, the official `gitea-mcp` server, and raw Gitea REST calls. This directory holds the benchmark that tests gitea-axi's central claim — that it is an agent-ergonomic, low-token interface to Gitea — against the `tea` CLI, the official `gitea-mcp` server, and raw Gitea REST calls.
The result is that gitea-axi has reached the token floor: it is the cheapest of the structured interfaces by a wide margin and now runs neck-and-neck with hand-rolled raw REST — within ~1% overall — at 100% task success and the fewest turns of any arm. The result is honest rather than flattering: gitea-axi is the lowest-cost of the *structured* interfaces — it beats both `tea` and `gitea-mcp` on every tier at 100% task success — but hand-rolled raw REST is cheaper still, because terse HTTP is the token floor no wrapper undercuts.
Keeping the raw-REST arm in the comparison is deliberate: a benchmark of agent-CLIs that omits the hand-rolled baseline will always flatter the wrapper, and this one refuses to — which is exactly what makes gitea-axi matching that baseline meaningful. Keeping the raw-REST arm in the comparison is deliberate: a benchmark of agent-CLIs that omits it will always crown the wrapper, and this one refuses to.
## How it works ## How it works
@@ -15,25 +15,23 @@ Every arm is credentialed the way its product is really configured — the token
| arm | cost-equivalent tokens | raw tokens | turns | success | imputed cost | | arm | cost-equivalent tokens | raw tokens | turns | success | imputed cost |
| --- | ---: | ---: | ---: | ---: | ---: | | --- | ---: | ---: | ---: | ---: | ---: |
| raw-api | 17,613 | 60,384 | 5.0 | 100% | ~$0.11 | | raw-api | 16,971 | 52,586 | 4.3 | 100% | ~$0.11 |
| gitea-axi | 17,815 | 62,705 | 4.8 | 100% | ~$0.11 | | gitea-axi | 19,240 | 81,067 | 6.0 | 100% | ~$0.12 |
| gitea-mcp | 23,198 | 76,378 | 5.2 | 100% | ~$0.15 | | tea | 20,568 | 82,188 | 6.2 | 97% | ~$0.12 |
| tea | 23,210 | 94,600 | 7.2 | 85% | ~$0.14 | | gitea-mcp | 21,803 | 79,961 | 5.7 | 100% | ~$0.14 |
All four arms completed the full matrix — 20 of 20 tasks each — and success is near-perfect: only `tea` slips, to 85% overall (67% on find-then-act), while the other three pass every run. All four arms completed the full matrix — 20 of 20 tasks each, at the reporting floor — and success is near-perfect: only `tea` slips, to 89% on find-then-act, while the other three pass every run.
Raw REST posts the lowest cost-equivalent tokens, but only barely: gitea-axi lands within ~1% of it (17,815 vs 17,613), a gap well inside the noise of three trials. Raw REST posts the lowest cost-equivalent tokens and leads every tier.
Direct HTTP with the token in the request header is the token floor no wrapper is supposed to undercut — and a structured tool drawing level with it is the headline of this snapshot. It is direct HTTP with the token in the request header, so it takes the fewest turns (4.3) and reads the least cached context, and no higher-level tool beats that on tokens alone.
This is the honest ceiling, and the reason gitea-axi does not claim the cost crown outright.
gitea-axi is the cheapest structured interface by a wide margin — roughly 23% under both the official `gitea-mcp` server and the `tea` CLI — and it beats both of them on every tier, at 100% success. gitea-axi is a clear second overall and the cheapest of the structured tools: it undercuts the official `gitea-mcp` server and the `tea` CLI on every tier, at 100% success, with the lowest output-token count of any arm.
It also takes the fewest turns of any arm (4.8, raw REST included) and the lowest output-token count of the shell arms; its compact TOON answers are what let a structured tool run this close to the floor. Note the split between raw and cost-equivalent tokens — gitea-axi spends more raw tokens than `gitea-mcp` yet costs less, because output is weighted 5× and gitea-axi's answers are compact.
By tier the picture is sharper than the overall total. By tier, raw REST's edge is widest on reads (10,921 vs gitea-axi's 14,415) — a read is one HTTP request for curl, where a CLI still spends a turn or two — and narrows on multi-step (24,348 vs 26,963), where the work itself dominates and interface overhead matters less.
gitea-axi is the *cheapest arm outright* on the two discovery-heavy tiers — reads (13,294 vs raw's 14,656) and find-then-act (17,585 vs 18,631) — where finding the right entity is the work, and its compact search and list output beats reconstructing and parsing raw JSON.
Raw REST reclaims the lead on the mutation-heavy tiers — narrowly on single-mutation (15,212 vs 15,552), more clearly on multi-step (22,643 vs 26,079) — where the task is a handful of terse POSTs that no wrapper undercuts.
So the two trade tiers: gitea-axi wins where an interface earns its keep, raw REST wins where the request was already minimal.
Cost parity on the scored suite also understates gitea-axi, because the suite is the subset every arm can do at all. Cost parity on the scored suite also understates gitea-axi, because the suite is the subset every arm can do at all.
The bonus table records capability-asymmetric operations — full-text issue search, rendering a PR's diff and checks, issue dependencies — that gitea-axi handles directly and raw REST has no first-class equivalent for. The bonus table records capability-asymmetric operations — full-text issue search, rendering a PR's diff and checks, issue dependencies — that gitea-axi handles directly and raw REST has no first-class equivalent for.
_Snapshot: 2026-07-19 — 4 arms × 20 tasks × 3 trials each (240 samples), a single clean run with all four arms executed together against one live Gitea host; imputed cost is the mean per-task Anthropic-API-priced dollar cost._ _Snapshot: 2026-07-17 — 4 arms × 20 tasks × 3 trials each (240 samples), a single clean run with all four arms executed together against one live Gitea host; imputed cost is the mean per-task Anthropic-API-priced dollar cost._

View File

@@ -340,64 +340,6 @@ describe("checkReadAnswer", () => {
expect(result.pass).toBe(false); expect(result.pass).toBe(false);
}); });
it("passes via pattern when filler words break every contiguous anyOf phrase", () => {
// A count fact is brittle when pinned to fixed phrases: a human padding the
// answer with filler words ("issues are currently") splits any contiguous
// rendering. The optional `pattern` — a case-insensitive regex source — spans
// that filler as a bounded run of alphabetic words between the number and
// "open", so the fact is satisfied even though no `anyOf` phrase appears verbatim.
const facts: RequiredFact[] = [
{
description: "count of open issues",
anyOf: ["5 open", "5 issues are open"],
pattern: "\\b5(?: [a-z]+){0,4} open\\b",
},
];
// "issues are currently" is inserted between "5" and "open", so neither
// contiguous anyOf phrase matches — but the pattern's alphabetic filler run does.
const report = "5 issues are currently open (5 of 5 total).";
expect(checkReadAnswer(facts, report)).toEqual({ pass: true });
});
it("fails when a digit interrupts the number-to-open run, so a wrong count cannot slip through", () => {
// The same pattern-bearing fact as above. Here the report states a DIFFERENT
// open count (3), merely mentioning the number 5 elsewhere. The pattern's
// filler run is alphabetic only, so the digit "3" between the matched "5" and
// "open" is not spanned, and no anyOf phrase matches either — the fact fails.
const facts: RequiredFact[] = [
{
description: "count of open issues",
anyOf: ["5 open", "5 issues are open"],
pattern: "\\b5(?: [a-z]+){0,4} open\\b",
},
];
// "5 issues in total, and 3 open" — the 5 is a total, the open count is 3.
const report = "There are 5 issues in total, and 3 open.";
const result = checkReadAnswer(facts, report);
expect(result.pass).toBe(false);
// The unmet fact must remain identifiable by its own description.
if (result.pass === false) {
expect(result.differences.some((d) => d.includes("count of open issues"))).toBe(true);
}
});
it("still matches on anyOf alone when a fact carries no pattern", () => {
// A fact without `pattern` behaves exactly as before: only the contiguous
// anyOf renderings are consulted, unaffected by the new pattern support.
const facts: RequiredFact[] = [
{ description: "count of open issues", anyOf: ["7 open issues", "seven open issues"] },
];
const report = "The board shows 7 open issues right now.";
expect(checkReadAnswer(facts, report)).toEqual({ pass: true });
});
}); });
describe("score", () => { describe("score", () => {

View File

@@ -241,7 +241,9 @@ function matchByKey<T>(
*/ */
export function checkReadAnswer(facts: RequiredFact[], report: string): CheckResult { export function checkReadAnswer(facts: RequiredFact[], report: string): CheckResult {
const haystack = normalizeText(report); const haystack = normalizeText(report);
const missing = facts.filter((fact) => !factPresent(fact, haystack)); const missing = facts.filter(
(fact) => !fact.anyOf.some((rendering) => haystack.includes(normalizeText(rendering))),
);
if (missing.length === 0) { if (missing.length === 0) {
return { pass: true }; return { pass: true };
} }
@@ -251,20 +253,6 @@ export function checkReadAnswer(facts: RequiredFact[], report: string): CheckRes
}; };
} }
/**
* Whether a required fact is present in the normalized report: any `anyOf`
* rendering as a contiguous substring, or the optional `pattern` regex matching.
* The pattern is compiled case-insensitively over the already-normalized text, so
* it recognises a count padded with filler ("5 issues are currently open") that no
* fixed phrase would.
*/
function factPresent(fact: RequiredFact, haystack: string): boolean {
if (fact.anyOf.some((rendering) => haystack.includes(normalizeText(rendering)))) {
return true;
}
return fact.pattern !== undefined && new RegExp(fact.pattern, "i").test(haystack);
}
/** /**
* Lower-case, drop markdown emphasis/code markers, and collapse runs of * Lower-case, drop markdown emphasis/code markers, and collapse runs of
* whitespace so incidental phrasing and formatting do not matter — a report that * whitespace so incidental phrasing and formatting do not matter — a report that

View File

@@ -57,8 +57,6 @@ export interface RunArgs {
turnCap: number; turnCap: number;
wallClockMs: number; wallClockMs: number;
storeRoot: string; storeRoot: string;
/** Optional bundled-skill override for the gitea-axi arm; defaults to the shipped SKILL.md. */
skillPath?: string;
} }
/** The parse outcome: a request for help, or a resolved configuration to run. */ /** The parse outcome: a request for help, or a resolved configuration to run. */
@@ -73,7 +71,6 @@ const KNOWN_FLAGS = new Set([
"turn-cap", "turn-cap",
"wall-clock-ms", "wall-clock-ms",
"store", "store",
"skill",
]); ]);
/** A usage error, surfaced to the maintainer with the offending detail. */ /** A usage error, surfaced to the maintainer with the offending detail. */
@@ -160,7 +157,6 @@ export function parseRunArgs(
turnCap, turnCap,
wallClockMs, wallClockMs,
storeRoot: flags.get("store") ?? DEFAULT_STORE_ROOT, storeRoot: flags.get("store") ?? DEFAULT_STORE_ROOT,
...(flags.has("skill") ? { skillPath: flags.get("skill") } : {}),
}; };
} }
@@ -184,7 +180,6 @@ Options:
--turn-cap <n> Per-run turn cap (default: ${DEFAULT_TURN_CAP}) --turn-cap <n> Per-run turn cap (default: ${DEFAULT_TURN_CAP})
--wall-clock-ms <n> Per-run wall-clock backstop in ms (default: ${DEFAULT_WALL_CLOCK_MS}) --wall-clock-ms <n> Per-run wall-clock backstop in ms (default: ${DEFAULT_WALL_CLOCK_MS})
--store <dir> Sample store root (default: ${DEFAULT_STORE_ROOT}) --store <dir> Sample store root (default: ${DEFAULT_STORE_ROOT})
--skill <path> Override the gitea-axi arm's bundled skill (default: shipped SKILL.md)
-h, --help Show this help`; -h, --help Show this help`;
/** Render the run-loop tally into the lines printed after a sitting. */ /** Render the run-loop tally into the lines printed after a sitting. */
@@ -251,7 +246,7 @@ export async function runBenchCommand(
driver: sdkAgentDriver(), driver: sdkAgentDriver(),
store, store,
bounds: { turnCap: parsed.turnCap, wallClockMs: parsed.wallClockMs }, bounds: { turnCap: parsed.turnCap, wallClockMs: parsed.wallClockMs },
build: { binRoot, ...(parsed.skillPath !== undefined ? { skillPath: parsed.skillPath } : {}) }, build: { binRoot },
}); });
for (const line of summarize(result, parsed.storeRoot)) { for (const line of summarize(result, parsed.storeRoot)) {
out(line); out(line);

View File

@@ -131,21 +131,10 @@ export interface RepoState {
* whitespace and case normalization), so a count or a name can be phrased * whitespace and case normalization), so a count or a name can be phrased
* variously without resorting to an LLM judge. `description` names the fact in * variously without resorting to an LLM judge. `description` names the fact in
* diagnostics when it is missing. * diagnostics when it is missing.
*
* `anyOf` matches a *contiguous* substring, which is brittle for facts a human
* naturally pads with filler — "5 issues are currently open" does not contain
* the fixed phrase "5 issues are open". For those, supply `pattern`: a regular
* expression (matched against the same normalized report) that satisfies the
* fact when it matches, so the count itself can be recognised rather than one
* exact wording. A fact is present when any `anyOf` rendering *or* `pattern`
* matches; `anyOf` stays the human-readable renderings even when `pattern`
* carries the real matcher.
*/ */
export interface RequiredFact { export interface RequiredFact {
description: string; description: string;
anyOf: string[]; anyOf: string[];
/** Optional regex (source, matched case-insensitively against the normalized report). */
pattern?: string;
} }
/** /**

View File

@@ -359,122 +359,12 @@ async function ensurePullRequest(
await ensureReviews(access, coords, number, pr.reviews); await ensureReviews(access, coords, number, pr.reviews);
} }
/** How long the readiness gate polls before giving up, and how often it re-checks. */
const READINESS_TIMEOUT_MS = 60_000;
const READINESS_INTERVAL_MS = 750;
/** Resolve after `ms` milliseconds. */
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** A search hit's minimal shape: the repository the matched issue/pull belongs to. */
interface SearchHit {
repository?: { name?: string } | null;
}
/**
* Whether the issue indexer has caught up: a full-text search for `title` of the
* given kind returns a hit in this repository. The endpoint spans every repo the
* owner can access (mirroring `gitea-axi search`), so a hit only counts when its
* repository matches. A non-2xx or a not-yet-indexed title reads as not-ready.
*/
async function searchIndexed(
access: BenchAccess,
coords: RepoCoords,
type: "issues" | "pulls",
title: string,
): Promise<boolean> {
const query = new URLSearchParams({ q: title, type, owner: coords.owner, state: "all", limit: "50" });
const res = await request(access, "GET", `/repos/issues/search?${query.toString()}`);
if (!res.ok) {
return false;
}
const hits = (await res.json()) as SearchHit[];
return hits.some((hit) => hit.repository?.name === coords.repo);
}
/**
* The reason a freshly seeded repository is not yet ready for the agent, or `null`
* when it is. Ready means an independent read sees the repository and its full
* seeded issue and pull spread, and the issue indexer returns a seeded issue and
* pull — the two consistency windows (repo visibility and index lag) an agent
* would otherwise race and fail against. Every read is non-throwing, so a
* transient error reads as not-ready and is retried rather than propagated.
*/
async function readinessGap(access: BenchAccess, coords: RepoCoords): Promise<string | null> {
const repoPath = `/repos/${coords.owner}/${coords.repo}`;
const repoRes = await request(access, "GET", repoPath);
if (!repoRes.ok) {
return `repository read returned ${repoRes.status}`;
}
const issuesRes = await request(access, "GET", `${repoPath}/issues?type=issues&state=all&limit=100`);
if (!issuesRes.ok) {
return `issue list returned ${issuesRes.status}`;
}
const issues = (await issuesRes.json()) as GiteaIssue[];
if (issues.length < SEED_PLAN.issues.length) {
return `only ${issues.length}/${SEED_PLAN.issues.length} issues visible`;
}
const pullsRes = await request(access, "GET", `${repoPath}/pulls?state=all&limit=100`);
if (!pullsRes.ok) {
return `pull list returned ${pullsRes.status}`;
}
const pulls = (await pullsRes.json()) as GiteaPull[];
if (pulls.length < SEED_PLAN.pullRequests.length) {
return `only ${pulls.length}/${SEED_PLAN.pullRequests.length} pull requests visible`;
}
const sampleIssue = SEED_PLAN.issues[0]!.title;
if (!(await searchIndexed(access, coords, "issues", sampleIssue))) {
return `issue "${sampleIssue}" not yet indexed for search`;
}
const samplePull = SEED_PLAN.pullRequests[0]!.title;
if (!(await searchIndexed(access, coords, "pulls", samplePull))) {
return `pull request "${samplePull}" not yet indexed for search`;
}
return null;
}
/**
* Block until a freshly seeded repository is fully consistent for the agent, or
* fail loudly if it never settles within the timeout. Gitea makes a just-created
* repository and its just-written issues and pulls visible to the seed's own
* writes immediately, but an independent reader — the agent, a fresh process
* moments later — can hit a brief 404 window on the repository and a longer lag on
* the async issue indexer. Both are the benchmark's races, not the tool's, so
* closing them here keeps a scored run measuring gitea-axi rather than host
* propagation. A timeout is a harness failure surfaced to the maintainer, never a
* scored agent failure.
*/
export async function waitForSeedReady(
access: BenchAccess,
coords: RepoCoords,
timeoutMs = READINESS_TIMEOUT_MS,
intervalMs = READINESS_INTERVAL_MS,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
let gap = await readinessGap(access, coords);
while (gap !== null) {
if (Date.now() >= deadline) {
throw new Error(
`seeded repository ${coords.owner}/${coords.repo} not ready after ${timeoutMs}ms: ${gap}`,
);
}
await delay(intervalMs);
gap = await readinessGap(access, coords);
}
}
/** /**
* Seed a freshly provisioned repository to the ground truth, idempotently. Labels * Seed a freshly provisioned repository to the ground truth, idempotently. Labels
* come first (so issues and pull requests can apply them), then the issue spread, * come first (so issues and pull requests can apply them), then the issue spread,
* then the pull requests. Returns the deterministic ground-truth RepoState the * then the pull requests. Returns the deterministic ground-truth RepoState the
* checker scores against; on a fresh repository the created numbers match it, * checker scores against; on a fresh repository the created numbers match it,
* and a re-run leaves them unchanged. * and a re-run leaves them unchanged.
*
* Before returning, it waits out the repository-visibility and issue-indexer
* consistency windows (see waitForSeedReady), so the agent that runs next never
* races a repository that is not yet readable or searchable.
*/ */
export async function seedRepo(access: BenchAccess, coords: RepoCoords): Promise<RepoState> { export async function seedRepo(access: BenchAccess, coords: RepoCoords): Promise<RepoState> {
const user = await currentUser(access); const user = await currentUser(access);
@@ -494,7 +384,6 @@ export async function seedRepo(access: BenchAccess, coords: RepoCoords): Promise
await ensurePullRequest(access, coords, pr, labelIds, pullsByTitle); await ensurePullRequest(access, coords, pr, labelIds, pullsByTitle);
} }
await waitForSeedReady(access, coords);
return groundTruth(user); return groundTruth(user);
} }

View File

@@ -92,11 +92,6 @@ function readTasks(): BenchTask[] {
{ {
description: "the repository has 5 open issues", description: "the repository has 5 open issues",
anyOf: ["5 open", "five open", "open issues: 5", "open: 5", "5 issues are open"], anyOf: ["5 open", "five open", "open issues: 5", "open: 5", "5 issues are open"],
// "5" followed by "open" across up to four alphabetic filler words, so
// natural padding ("5 issues are currently open") is recognised while a
// digit between them (a wrong "5 issues, 3 open") is not: the filler run
// is alphabetic only, so it cannot span another count.
pattern: "\\b5(?: [a-z]+){0,4} open\\b",
}, },
]), ]),
}, },

View File

@@ -1,104 +0,0 @@
# The first automated proof of the home-manager module's composition (ADR 0021).
#
# It evaluates the *real* module through home-manager's standalone configuration
# entry point and builds the resulting home files derivation — the file-linkage
# layer that actually decides whether two declarations collide — under several
# operator configurations, then asserts on the tree each one produces. It never
# reads module internals: not option values, not store paths, not the shape of
# the file mechanism, only which files a generation contains. Building the home
# files derivation needs neither the Claude Code binary nor a running agent.
#
# The configurations cover the remaining composition risks: the explicit
# sibling-enable gate that keeps the hook off a host without Claude Code, the
# hook merging into an operator's own SessionStart list rather than replacing it,
# and the Agent Skill staying out of global harness directories because project
# dev shells deliver it through the agent-agnostic skills helper.
{
pkgs,
home-manager,
module,
package,
}:
let
# A distinctive command so the merged-hook assertion can tell the operator's
# own SessionStart hook apart from gitea-axi's in the generated settings.json.
operatorHook = {
matcher = "";
hooks = [
{
type = "command";
command = "operator-own-session-hook";
}
];
};
# Evaluate the real module through home-manager's standalone entry point and
# return the home files derivation — the tree home-manager would link into
# $HOME. `programs.gitea-axi.enable` is on in every configuration; the package
# is the flake's own build, so the check reuses the store path the package
# check already produces rather than building a second time.
homeFiles =
operatorConfig:
(home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
module
{
home.username = "operator";
home.homeDirectory = "/home/operator";
home.stateVersion = "24.11";
programs.gitea-axi.enable = true;
programs.gitea-axi.package = package;
}
operatorConfig
];
}).config.home-files;
# Claude Code disabled: the sibling-enable gate in the Claude Code module must
# leave no settings file or Skill entry in the generation.
claudeCodeOff = homeFiles {
programs.claude-code.enable = false;
};
# Claude Code enabled: the module contributes only the SessionStart hook, not
# a global Skill entry.
claudeCodeOn = homeFiles {
programs.claude-code.enable = true;
};
# An operator with their own SessionStart hook: the module's hook must merge
# into that list rather than replace it.
mergedHook = homeFiles {
programs.claude-code.enable = true;
programs.claude-code.settings.hooks.SessionStart = [ operatorHook ];
};
in
pkgs.runCommandLocal "gitea-axi-home-manager-module-check"
{
# Forcing each derivation as a build input is what actually builds the home
# files tree under every configuration.
inherit
claudeCodeOff
claudeCodeOn
mergedHook
;
}
''
echo "Claude Code disabled: no hook settings or global Skill entry is written"
test ! -e "$claudeCodeOff/.claude/settings.json"
test ! -e "$claudeCodeOff/.claude/skills/gitea-axi"
echo "Claude Code enabled: hook lands, but the Skill is not globally installed"
grep -q '"gitea-axi"' "$claudeCodeOn/.claude/settings.json"
test ! -e "$claudeCodeOn/.claude/skills/gitea-axi"
echo "operator's own SessionStart hook: the module's hook merges in"
# Match the commands as quoted JSON string values, not by their position or
# the emitter's colon spacing: both must be present for a merge (rather than
# a replacement) of the two SessionStart hooks.
grep -q '"operator-own-session-hook"' "$mergedHook/.claude/settings.json"
grep -q '"gitea-axi"' "$mergedHook/.claude/settings.json"
touch "$out"
''

48
flake.lock generated
View File

@@ -1,48 +0,0 @@
{
"nodes": {
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784588016,
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784356753,
"narHash": "sha256-12KrbMiWLcf8m7pCvAtZh1ZrgF85ZXDXvfR/fWTKy84=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "61b7c44c4073f0b827768aff0049561b5110ea5a",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"home-manager": "home-manager",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

128
flake.nix
View File

@@ -1,128 +0,0 @@
{
description = "Agent-ergonomic CLI for Gitea issues and pull requests";
# Tracks unstable to match the maintainer's system. Consumers deduplicate by
# pointing this input at their own nixpkgs, so it governs standalone builds
# only — never the deployed artifact.
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# Present only so `nix flake check` can evaluate the home-manager module
# against real home-manager (the `home-manager-module` check). Its nixpkgs
# follows this flake's, so the module is checked against the same
# nixpkgs-and-home-manager pairing a consumer following this flake would get.
# It has no bearing on the package or the module a consumer imports — the
# module is a bare function that takes the importing configuration's own
# home-manager and pkgs.
inputs.home-manager.url = "github:nix-community/home-manager";
inputs.home-manager.inputs.nixpkgs.follows = "nixpkgs";
outputs =
{ self, nixpkgs, home-manager }:
let
# x86_64-darwin is deliberately absent: nixpkgs 26.11 dropped it, and
# `legacyPackages.x86_64-darwin` now throws rather than merely failing to
# build — so listing it would break `nix flake show` and `nix flake check`
# for every system, not just that one. Intel macOS needs the 26.05 branch.
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
# Hands each output both the package set and the system name — the latter
# because the shell and the checks reach back into `self.packages` for the
# system being evaluated, and `pkgs.system` is discouraged in favour of a
# considerably wordier spelling.
forAllSystems =
f:
nixpkgs.lib.genAttrs systems (
system:
f {
inherit system;
pkgs = nixpkgs.legacyPackages.${system};
}
);
in
{
packages = forAllSystems (
{ pkgs, ... }: rec {
gitea-axi = pkgs.callPackage ./package.nix { };
gitea-axi-skill = pkgs.runCommandLocal "gitea-axi-skill"
{
passthru.skillName = "gitea-axi";
}
''
mkdir -p "$out"
cp -R ${gitea-axi.skill}/. "$out/"
test -f "$out/SKILL.md"
'';
default = gitea-axi;
}
);
# 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
# 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
# specifies anywhere else.
#
# Not `gitea-axi` itself, which the benchmark's own arm resolves by name off
# PATH: that has to be the locally built `dist/main.js`, so that a bench run
# measures the working tree rather than whatever the flake last packaged.
# Supplying it here would silently substitute the wrong binary.
devShells = forAllSystems (
{ pkgs, system }: {
default = pkgs.mkShell {
packages = [
# The package's own Node, taken from its passthru rather than named
# a second time here. There is one reference, so development and
# the shipped artifact cannot drift onto different majors — and
# they cannot be set independently even by mistake.
self.packages.${system}.gitea-axi.nodejs
pkgs.git
pkgs.tea
# The benchmark's raw-api arm shells out to curl.
pkgs.curl
];
};
}
);
# Two checks. `gitea-axi` is an alias for the package, so `nix flake check`
# builds it and thereby runs both its verification phases — the fast tier
# in `checkPhase`, the installed-binary tier in `installCheckPhase`.
#
# `home-manager-module` evaluates the home-manager module through real
# home-manager and builds the home files derivation it produces under
# several operator configurations, proving the module's composition (ADR
# 0021). It reuses the package check's store path rather than rebuilding.
#
# No granular per-stage checks for the package: the one stage that would
# add coverage the package build does not already have is the full
# typecheck, which spans `test/` and `bench/` and would therefore drag the
# benchmark harness into the derivation's inputs — undoing the source
# filtering that keeps benchmark churn from forcing a rebuild. That
# typecheck stays in continuous integration, where it already runs.
checks = forAllSystems (
{ pkgs, system }: {
inherit (self.packages.${system}) gitea-axi;
home-manager-module = import ./checks/home-manager-module.nix {
inherit pkgs home-manager;
module = self.homeModules.gitea-axi;
package = self.packages.${system}.gitea-axi;
};
}
);
};
}

View File

@@ -1,75 +0,0 @@
# A home-manager module installing gitea-axi and, when Claude Code is present,
# its SessionStart hook.
#
# `programs.gitea-axi.enable` installs the CLI, always.
# The Agent Skill is exposed as a package output for agent-agnostic delivery by
# a project's dev shell or an operator's shared skill delivery module.
#
# 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 hook declaration — an operator who
# installs gitea-axi system-wide still wants the hook — 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 { };
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 ambient
Claude Code hook 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.
'';
};
enableClaudeCodeIntegration = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Whether to declare gitea-axi's Claude Code SessionStart hook alongside
the CLI.
The hook lands only when `programs.claude-code.enable` is also on;
with it off it is silently absent, matching how home-manager's own
`enableBashIntegration`-style toggles behave against a disabled sibling.
Turn this off to install gitea-axi declaratively while writing the
Claude Code hook by hand or with `gitea-axi setup`.
'';
};
};
config = lib.mkIf cfg.enable (
lib.mkMerge [
(lib.mkIf (cfg.package != null) { home.packages = [ cfg.package ]; })
(lib.mkIf cfg.enableClaudeCodeIntegration {
# 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.
programs.claude-code.settings.hooks.SessionStart = [ sourcePackage.sessionStartHook ];
})
]
);
}

10
package-lock.json generated
View File

@@ -17,14 +17,14 @@
"gitea-axi": "dist/main.js" "gitea-axi": "dist/main.js"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.1", "@types/node": "^20.19.0",
"@vitest/coverage-v8": "^3.2.7", "@vitest/coverage-v8": "^3.2.7",
"tsx": "^4.23.1", "tsx": "^4.23.1",
"typescript": "^5.8.0", "typescript": "^5.8.0",
"vitest": "^3.2.0" "vitest": "^3.2.0"
}, },
"engines": { "engines": {
"node": "^22 || ^24" "node": ">=20"
}, },
"peerDependencies": { "peerDependencies": {
"@anthropic-ai/claude-agent-sdk": ">=0.3.0" "@anthropic-ai/claude-agent-sdk": ">=0.3.0"
@@ -1050,9 +1050,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.20.1", "version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^22 || ^24" "node": ">=20"
}, },
"bin": { "bin": {
"gitea-axi": "dist/main.js" "gitea-axi": "dist/main.js"
@@ -35,7 +35,6 @@
"test:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts",
"test:pack": "vitest run --config vitest.packaging.config.ts", "test:pack": "vitest run --config vitest.packaging.config.ts",
"test:installed": "vitest run --config vitest.packaging.config.ts --passWithNoTests=false test/packaging/installed-binary.test.ts",
"test:bench": "vitest run --config vitest.bench.config.ts", "test:bench": "vitest run --config vitest.bench.config.ts",
"test:bench:smoke": "vitest run --config vitest.bench-smoke.config.ts", "test:bench:smoke": "vitest run --config vitest.bench-smoke.config.ts",
"bench:run": "tsx bench/run.ts", "bench:run": "tsx bench/run.ts",
@@ -47,7 +46,7 @@
"gitea-js": "^1.23.0" "gitea-js": "^1.23.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.1", "@types/node": "^20.19.0",
"@vitest/coverage-v8": "^3.2.7", "@vitest/coverage-v8": "^3.2.7",
"tsx": "^4.23.1", "tsx": "^4.23.1",
"typescript": "^5.8.0", "typescript": "^5.8.0",

View File

@@ -1,211 +0,0 @@
{
lib,
buildNpmPackage,
importNpmLock,
makeWrapper,
nodejs,
git,
tea,
which,
}:
let
# Where the install check finds the dev dependencies that `npmInstallHook`
# prunes out of the build tree. Named once: it is a contract between
# `preInstall`, which writes it, and `installCheckPhase`, which reads it.
devNodeModules = "$NIX_BUILD_TOP/node_modules-dev";
# The manifest is the canonical version: the release flow bumps it, and
# reading it here means a store path and a released version cannot disagree.
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
# repository's highest-churn directories — .claude, bench, prose docs — are
# all build-irrelevant, so a whole-repository source would let writing an ADR
# invalidate the derivation and force a rebuild with a full test run.
#
# Adding a build-relevant top-level file means adding it here too; the build
# otherwise fails on a missing file.
src = lib.fileset.toSource {
root = ./.;
fileset = lib.fileset.unions [
./src
# The fast tier only. `test/e2e` needs a live Gitea host and is excluded
# from the runner config, so admitting it would let end-to-end churn
# invalidate the derivation — the very cost this allowlist exists to
# avoid. `test/packaging` stays: task 0038 drives it against the
# installed binary.
(lib.fileset.difference ./test ./test/e2e)
./skills
./package.json
./package-lock.json
./tsconfig.json
./tsconfig.build.json
./vitest.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
buildNpmPackage (finalAttrs: {
pname = "gitea-axi";
inherit (manifest) version;
inherit src nodejs;
# Each dependency's fetch is derived from the integrity fields already in the
# lockfile, so a lockfile change needs no edit here. A single fixed-output
# hash would break on every dependency bump and be repaired by copying a hash
# out of an error message — a permanent recurring tax.
npmDeps = importNpmLock { npmRoot = src; };
inherit (importNpmLock) npmConfigHook;
nativeBuildInputs = [ makeWrapper ];
# The fast tier only. The live end-to-end and benchmark smoke tiers need a
# live Gitea host. Two of these test files invoke `git` directly and one
# resolves it with `which`; `tea` is already stubbed within this tier.
doCheck = true;
nativeCheckInputs = [
git
which
];
# `buildNpmPackage` wires config, build and install hooks but no check hook, so
# `doCheck` alone is inert and the phase has to be spelled out. `git init` and
# `git commit` in the fast tier also need a writable HOME, which the sandbox
# otherwise points at a non-existent directory.
checkPhase = ''
runHook preCheck
export HOME=$(mktemp -d)
npm run test
# vitest leaves a run cache under node_modules/.vite whose results.json
# records durations and timestamps. `npmInstallHook` copies node_modules
# into $out wholesale, so leaving it there both ships a stray cache in the
# closure and makes the output non-reproducible `nix build --rebuild`
# reports the derivation "may not be deterministic" on that one file.
rm -rf node_modules/.vite
runHook postCheck
'';
# `npmInstallHook` prunes dev dependencies out of the build tree's
# node_modules on its way to assembling $out, which would take vitest with it
# and leave the install check with nothing to run. Snapshot the tree first —
# as hardlinks, so it costs neither time nor space, and so the prune's
# deletions do not follow through to the copy.
preInstall = ''
cp -al node_modules "${devNodeModules}"
'';
# 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
# 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 = ''
wrapProgram $out/bin/gitea-axi \
--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
# tier, which the npm distribution path drives too — so the two cannot drift
# apart in what they guarantee about an installed gitea-axi.
#
# This guards a class of failure `checkPhase` structurally cannot reach,
# because it runs against the source tree rather than an installation. The
# one that bites here is Skill resolution: `setup` locates the bundled Agent
# Skill relative to its own module location, so the built output's position
# relative to that Skill is load-bearing — an arrangement that exists only
# once installed. A probe moving the installed `skills` aside does fail this
# phase.
#
# The tier's executable-bit assertion carries less weight under Nix than
# under npm, and deliberately so: `nodejsInstallExecutables` generates a
# wrapper invoking `node <path>` rather than symlinking the entrypoint, so
# the bit that matters is the one on `$out/bin/gitea-axi`, which makeWrapper
# always sets. That assertion earns its keep on the npm path, where npm sets
# the bit from the manifest's `bin` entry and `tsc` does not. Sharing one
# tier means neither path picks which guarantees it feels like offering.
#
# `installCheckPhase` runs after `fixupPhase`, so the binary named here is the
# wrapped one an operator would actually get. Naming it is all this phase
# does: the assertions live in the tier, not in shell script here.
doInstallCheck = true;
installCheckPhase = ''
runHook preInstallCheck
# Restore by copying, not moving, so the snapshot survives for a replayed
# phase `--keep-failed` debugging, or `genericBuild` re-entered by hand.
rm -rf node_modules
cp -al "${devNodeModules}" node_modules
export HOME=$(mktemp -d)
GITEA_AXI_INSTALLED_BIN=$out/bin/gitea-axi npm run test:installed
runHook postInstallCheck
'';
# The package's declared interface to Nix expressions, published rather than
# left to be read off the build environment or guessed at from the output's
# layout. Every attribute here has a consumer that breaks if it is removed.
passthru = {
# 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 packages it
# into an agent-agnostic project-local delivery mechanism. 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 = {
inherit (manifest) description homepage;
# Looked up by SPDX identifier rather than hardcoded, for the same reason
# the version is read from the manifest: one canonical source, no second
# place to update on a relicence.
license = lib.licensesSpdx.${manifest.license};
mainProgram = "gitea-axi";
# Broader than the flake's `systems` list, deliberately. This describes what
# the package supports — everything, since it contains no compiled code —
# whereas that list encodes which systems the pinned nixpkgs can still
# evaluate. Consumed against 26.05, x86_64-darwin builds fine from here.
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
})

View File

@@ -1,10 +0,0 @@
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "gitea-axi",
"timeout": 10
}
]
}

View File

@@ -6,34 +6,43 @@ description: Use when working with a Gitea repository's issues, pull requests, l
# gitea-axi # gitea-axi
`gitea-axi` is an agent-ergonomic CLI for a Gitea repository's issues and pull requests. `gitea-axi` is an agent-ergonomic CLI for a Gitea repository's issues and pull requests.
Its output is compact TOON meant to be read directly, and a failed command's error names the fix — follow that suggestion rather than guessing at another command. Its output is compact TOON built for another program to read, and its errors are structured with actionable suggestions.
## When to use it
Reach for `gitea-axi` whenever a task touches a Gitea repository's issues, pull requests, labels, or reviews.
- **Over `tea`:** `gitea-axi` returns structured output and typed errors instead of human-formatted tables, and it defaults the repository and login from the local checkout.
- **Over raw Gitea API calls:** it handles auth, pagination, name-to-ID resolution, and review-decision aggregation for you, so you do not hand-roll HTTP.
- **Over improvised `git`:** for anything about issues or pull requests as entities (state, reviews, labels, comments) rather than local commits and branches.
## Targeting and authentication ## Targeting and authentication
Every command resolves a repository and credentials; getting both right on the first call is the difference between one command and a retry. Every command resolves two things: which repository to act on, and which credentials to authenticate with.
Get both right on the first call — they are the usual reason a command fails and has to be retried.
- **Repository.** Inside a Gitea checkout it comes from the `origin` remote. - **Repository.** Inside a Gitea checkout it is taken from the `origin` remote automatically.
Outside one, pass `-R OWNER/NAME` on every command, or set `GITEA_AXI_REPO=OWNER/NAME` once for the session. Outside a checkout you must name it: pass `-R OWNER/NAME` on every command (or set `GITEA_AXI_REPO=OWNER/NAME` once for the session).
- **Credentials.** With `GITEA_AXI_TOKEN` and `GITEA_AXI_API_URL` set, authentication is automatic. - **Credentials.** When the environment is pre-configured — `GITEA_AXI_TOKEN` together with `GITEA_AXI_API_URL` authentication is automatic and you need nothing more.
Otherwise pass `--login <name>`, or set `GITEA_AXI_LOGIN`. Otherwise credentials come from a `tea` login: pass `--login <name>` (or set `GITEA_AXI_LOGIN=<name>`) unless the checkout's remote already selects one.
Outside a checkout with the token in the environment, `gitea-axi <command> -R OWNER/NAME …` is the whole invocation — don't look for a config file or a login profile. So outside a checkout with the token in the environment, `gitea-axi <command> -R OWNER/NAME …` is all you need; do not go hunting for a config file or a login profile.
## Commands ## Command groups
- `issue` — list, view, create, comment, edit, close, reopen, pin, and link (blocks / blocked-by). - `issue` — list, view, create, comment on, edit, close/reopen, pin, and link issues.
- `pr`list, view, create, comment, edit, review, merge, close, reopen, diff, checks, and checkout. - `pr`create, view, comment on, edit, review, merge, check out, diff, and inspect the checks of pull requests.
- `label` — list, create, edit, delete. - `label` — list, create, edit, and delete labels.
- `search issues "<query>"` and `search prs "<query>"` — full-text search (a bare `search "<query>"` is not valid). - `search` — full-text search; it takes a subcommand, so search issues with `search issues "<query>"` and pull requests with `search prs "<query>"` (a bare `search "<query>"` is not valid).
- `setup` — install this skill (`setup`) and, opt-in, the SessionStart dashboard hook (`setup hooks`).
## Finding and acting To read one issue's fields, reach straight for `issue view <number>`: it shows labels and state by default, and takes `--fields assignees,milestone,…` for the rest.
You rarely need `issue list` to answer a question about a single issue.
Find the target, then act on it — two commands, not a survey of the repository. ## Discovery
- **Find it.** If you already know the number, act on it directly. This skill is a pointer, not a command reference — the CLI is the single source of truth for its own interface.
Otherwise reach for one command — `search issues "<query>"` for a title or keyword, or `issue list --state all --label <name>` to narrow by a property — not both.
- **Read one issue or PR.** `issue view <number>` (or `pr view <number>`) shows labels and state by default, and takes `--fields assignees,milestone,…` for the rest. - Run `gitea-axi` with no arguments for the repository dashboard (open issues and pull requests).
You do not need `issue list` to answer a question about a single known issue. Add `--full` for the open-PR table and issue counts by label.
- **Act on it.** `issue edit <number>` and `pr edit <number>` change fields with repeatable `--add-label` / `--remove-label` and `--add-assignee` / `--remove-assignee`, plus `--title`, `--body`, and `--milestone`. - Run `gitea-axi <command> --help` (or `gitea-axi <group> <command> --help`) for the exact flags of any command.
Reviewing is `pr review <number>` with exactly one of `--approve`, `--request-changes`, or `--comment`, and an optional `--body`.
A comment is `issue comment <number> --body <text>`; a new label is `label create --name <text> --color <hex-without-#>`.

View File

@@ -107,10 +107,6 @@ interface SearchKind {
noun: string; noun: string;
/** The command a matched number feeds into. */ /** The command a matched number feeds into. */
viewCommand: string; viewCommand: string;
/** The list command suggested as the fallback when a search finds nothing. */
listCommand: string;
/** Human plural for the fallback note, e.g. "issues" or "pull requests". */
things: string;
/** The `--help` text for this variant. */ /** The `--help` text for this variant. */
help: string; help: string;
} }
@@ -120,8 +116,6 @@ const SEARCH_ISSUES: SearchKind = {
type: "issues", type: "issues",
noun: "issues", noun: "issues",
viewCommand: "issue view", viewCommand: "issue view",
listCommand: "issue list",
things: "issues",
help: SEARCH_ISSUES_HELP, help: SEARCH_ISSUES_HELP,
}; };
@@ -130,8 +124,6 @@ const SEARCH_PRS: SearchKind = {
type: "pulls", type: "pulls",
noun: "pull_requests", noun: "pull_requests",
viewCommand: "pr view", viewCommand: "pr view",
listCommand: "pr list",
things: "pull requests",
help: SEARCH_PRS_HELP, help: SEARCH_PRS_HELP,
}; };
@@ -225,24 +217,11 @@ async function runSearch(deps: CliDeps, args: string[], kind: SearchKind): Promi
extractRow(issue, [...SEARCH_FIELDS, ...extraFields], { now, host: context.host, full }), extractRow(issue, [...SEARCH_FIELDS, ...extraFields], { now, host: context.host, full }),
); );
// The next-step suggestion is conditioned on the match count. On a miss the
// `view` hint is nonsensical, so point at the non-indexed list as a fallback
// (it recovers from both an over-narrow query and index lag); on a single match
// fill the real number (Principle 9's single-id fill); otherwise leave the
// number parameterized. Search stays a locator either way — it never auto-loads
// the detail (see ADR 0017).
const suggestion =
total === 0
? suggestCommand(context, `${kind.listCommand} --state all`, `to list all ${kind.things} instead`)
: total === 1
? suggestCommand(context, `${kind.viewCommand} ${matches[0]!.number}`, "to see it in full")
: suggestCommand(context, `${kind.viewCommand} <number>`, "to see a match in full");
return renderList({ return renderList({
noun: kind.noun, noun: kind.noun,
rows, rows,
countLine: formatCountLine(rows.length, total, false), countLine: formatCountLine(rows.length, total, false),
help: [suggestion], help: [suggestCommand(context, `${kind.viewCommand} <number>`, "to see a match in full")],
}); });
} }

View File

@@ -4,13 +4,7 @@ import { join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { installSessionStartHooks } from "axi-sdk-js"; import { installSessionStartHooks } from "axi-sdk-js";
import type { CliDeps } from "../deps.js"; import type { CliDeps } from "../deps.js";
import { import { axiError } from "../errors.js";
axiError,
isNotWritableError,
isNotWritableMessage,
unwritableTargetError,
} from "../errors.js";
import { pruneDuplicateManagedHooks, resolveEntrypointOnPath } from "../hooks.js";
import { renderDetail } from "../render.js"; import { renderDetail } from "../render.js";
export const SETUP_HELP = `usage: gitea-axi setup [hooks] export const SETUP_HELP = `usage: gitea-axi setup [hooks]
@@ -36,11 +30,6 @@ const SKILL_NAME = "gitea-axi";
const SKILL_SOURCE = new URL("../../skills/gitea-axi/SKILL.md", import.meta.url); const SKILL_SOURCE = new URL("../../skills/gitea-axi/SKILL.md", import.meta.url);
const EXEC_PATH = fileURLToPath(new URL("../main.js", import.meta.url)); const EXEC_PATH = fileURLToPath(new URL("../main.js", import.meta.url));
// The same string as SKILL_NAME, kept apart because it names a different thing:
// the executable as it is spelled on PATH, which is what the SessionStart hook
// records. They are free to diverge; the SDK's marker follows the skill.
const BINARY_NAME = "gitea-axi";
const HOOK_INTEGRATIONS = ["Claude Code", "Codex", "OpenCode"]; const HOOK_INTEGRATIONS = ["Claude Code", "Codex", "OpenCode"];
/** The home directory, from the injected env first so tests can point at a temp HOME. */ /** The home directory, from the injected env first so tests can point at a temp HOME. */
@@ -70,30 +59,15 @@ function installSkill(home: string): { skill: string; path: string; status: Skil
const targetPath = join(targetDir, "SKILL.md"); const targetPath = join(targetDir, "SKILL.md");
let status: SkillStatus; let status: SkillStatus;
try { if (!existsSync(targetPath)) {
if (!existsSync(targetPath)) { status = "installed";
status = "installed"; } else {
} else { status = readFileSync(targetPath, "utf8") === source ? "unchanged" : "updated";
status = readFileSync(targetPath, "utf8") === source ? "unchanged" : "updated"; }
}
// A target that already holds these bytes needs no write, so its being if (status !== "unchanged") {
// read-only is beside the point and the run succeeds. mkdirSync(targetDir, { recursive: true });
if (status !== "unchanged") { writeFileSync(targetPath, source, "utf8");
mkdirSync(targetDir, { recursive: true });
writeFileSync(targetPath, source, "utf8");
}
} catch (error) {
// The comparison read is inside the guard because a target the filesystem
// will not let us read is one it will not let us replace either — the same
// condition, reached one call earlier.
if (isNotWritableError(error)) {
// The path the filesystem names is the directory when it is the directory
// that is read-only, so report that rather than assuming the file.
const blocked = (error as { path?: string }).path ?? targetPath;
throw unwritableTargetError(blocked, `the ${SKILL_NAME} skill`);
}
throw error;
} }
return { skill: SKILL_NAME, path: collapseHome(targetPath, home), status }; return { skill: SKILL_NAME, path: collapseHome(targetPath, home), status };
@@ -111,129 +85,23 @@ async function setupSkill(deps: CliDeps): Promise<string> {
}); });
} }
// The two files the SDK writes the SessionStart hook array into. Its third
// integration, OpenCode, is a whole plugin file it rewrites wholesale behind
// its own managed marker, so that one cannot accumulate duplicates.
const HOOK_SETTINGS_FILES = [
[".claude", "settings.json"],
[".codex", "hooks.json"],
];
/** One target the hook install could not write, and why. */
interface TargetFailure {
/** The file being written when it failed. */
path: string;
/** The underlying failure, with no path spliced into it. */
detail: string;
}
/**
* Recover a {@link TargetFailure} from the agent SDK's reporting.
*
* It hands its failures to `onError` as `<path>: <message>` text rather than as
* errors, so the two halves have to be separated again before either can be
* judged on its own. Text in that shape is all it ever emits; anything else is
* returned whole as the detail, with no path to attribute it to.
*/
function parseReportedFailure(reported: string): TargetFailure {
const separator = reported.indexOf(": ");
if (separator === -1) {
return { path: "", detail: reported };
}
return {
path: reported.slice(0, separator),
detail: reported.slice(separator + 2),
};
}
/** A failure rendered back as the `<path>: <detail>` line a reader sees. */
function formatFailure({ path, detail }: TargetFailure): string {
return path === "" ? detail : `${path}: ${detail}`;
}
/**
* Collapse any duplicate managed entry the SDK's own recognition missed.
*
* It identifies its hook by finding the marker inside the recorded command, so
* an entrypoint path that does not happen to contain "gitea-axi" makes a re-run
* append a second entry rather than update the first. Matching the exact
* command this run records makes idempotency independent of the recorded
* command's shape — and cannot mistake another tool's hook for ours the way a
* substring test can.
*/
function pruneHookSettingsFiles(
home: string,
command: string,
errors: TargetFailure[],
): void {
const isManaged = (recorded: string) => recorded === command;
for (const segments of HOOK_SETTINGS_FILES) {
const target = join(home, ...segments);
if (!existsSync(target)) {
continue;
}
try {
const current = JSON.parse(readFileSync(target, "utf8"));
const { settings, changed } = pruneDuplicateManagedHooks(current, isManaged);
if (changed) {
writeFileSync(target, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
}
} catch (error) {
errors.push({
path: target,
detail: error instanceof Error ? error.message : String(error),
});
}
}
}
async function setupHooks(deps: CliDeps): Promise<string> { async function setupHooks(deps: CliDeps): Promise<string> {
const home = resolveHome(deps); const home = resolveHome(deps);
const errors: TargetFailure[] = []; const errors: string[] = [];
// ADR 0019: record a search-path name, not an install-tree path. Handing the
// SDK where the binary resolves on PATH — rather than the module-relative
// entrypoint — is what lets its realpath test succeed for a wrapper-based
// install, so it records the bare, upgrade-stable name. When the name
// resolves to no wrapper or symlink of ours, the entrypoint stands as the
// fallback and the absolute path is recorded exactly as before.
//
// PATH is read from the process rather than the injected environment on
// purpose: it has to be the same PATH the SDK itself probes, and the SDK
// reads its own.
const onPath = resolveEntrypointOnPath(BINARY_NAME, EXEC_PATH, process.env.PATH);
const execPath = onPath ?? EXEC_PATH;
const command = onPath ? BINARY_NAME : EXEC_PATH;
installSessionStartHooks({ installSessionStartHooks({
marker: SKILL_NAME, marker: SKILL_NAME,
binaryNames: [SKILL_NAME], binaryNames: [SKILL_NAME],
execPath, execPath: EXEC_PATH,
homeDir: home, homeDir: home,
// This is an explicit user command, so install unconditionally rather than // This is an explicit user command, so install unconditionally rather than
// deferring to the SDK's auto-install safety gate (which is tuned for the // deferring to the SDK's auto-install safety gate (which is tuned for the
// inferred dist/bin/<name>.js entrypoint layout gitea-axi does not use). // inferred dist/bin/<name>.js entrypoint layout gitea-axi does not use).
shouldInstall: () => true, shouldInstall: () => true,
onError: (message) => errors.push(parseReportedFailure(message)), onError: (message) => errors.push(message),
}); });
pruneHookSettingsFiles(home, command, errors);
if (errors.length > 0) { if (errors.length > 0) {
// A read-only target is the most actionable thing that can be in here — it throw axiError(`Failed to install session hooks: ${errors.join("; ")}`, "UNKNOWN");
// names something the user must settle elsewhere rather than a bug — so it
// is reported ahead of whatever else was collected.
const unwritable = errors.find(
(failure) => failure.path !== "" && isNotWritableMessage(failure.detail),
);
if (unwritable) {
throw unwritableTargetError(unwritable.path, `the ${SKILL_NAME} session hook`);
}
throw axiError(
`Failed to install session hooks: ${errors.map(formatFailure).join("; ")}`,
"UNKNOWN",
);
} }
return renderDetail({ return renderDetail({

View File

@@ -60,17 +60,6 @@ function hostnameOf(url: string, origin: string): string {
} }
} }
/**
* Normalize a Gitea base URL to the host root the client expects. The client
* (gitea-js) appends `/api/v1` itself, so a value that already carries it — a
* natural guess when the variable is literally named `..._API_URL` — would double
* the segment and 404 as a spurious `REPO_NOT_FOUND`. Strip a trailing `/api/v1`
* (with any trailing slashes) so the host base and the API endpoint both work.
*/
function normalizeApiBase(url: string): string {
return url.replace(/\/+$/, "").replace(/\/api\/v1$/, "");
}
function resolveTestModeContext( function resolveTestModeContext(
deps: CliDeps, deps: CliDeps,
apiUrl: string, apiUrl: string,
@@ -83,11 +72,10 @@ function resolveTestModeContext(
["Set `GITEA_AXI_REPO=OWNER/NAME` or pass `-R OWNER/NAME`"], ["Set `GITEA_AXI_REPO=OWNER/NAME` or pass `-R OWNER/NAME`"],
); );
} }
const base = normalizeApiBase(apiUrl);
return { return {
...parseRepoSpec(overrides.repoSpec, overrides.repoOrigin), ...parseRepoSpec(overrides.repoSpec, overrides.repoOrigin),
host: hostnameOf(base, "`GITEA_AXI_API_URL`"), host: hostnameOf(apiUrl, "`GITEA_AXI_API_URL`"),
apiUrl: base, apiUrl: apiUrl.replace(/\/+$/, ""),
token: deps.env.GITEA_AXI_TOKEN ?? "", token: deps.env.GITEA_AXI_TOKEN ?? "",
repoSource: overrides.repoSource, repoSource: overrides.repoSource,
loginSource: overrides.loginSource, loginSource: overrides.loginSource,
@@ -196,7 +184,7 @@ export async function resolveRepoContext(deps: CliDeps): Promise<RepoContext> {
owner, owner,
name, name,
host, host,
apiUrl: normalizeApiBase(login.url), apiUrl: login.url.replace(/\/+$/, ""),
token, token,
repoSource: overrides.repoSource, repoSource: overrides.repoSource,
loginSource: overrides.loginSource, loginSource: overrides.loginSource,

View File

@@ -10,7 +10,6 @@ export type AxiErrorCode =
| "TEA_NOT_INSTALLED" | "TEA_NOT_INSTALLED"
| "VALIDATION_ERROR" | "VALIDATION_ERROR"
| "GIT_ERROR" | "GIT_ERROR"
| "TARGET_NOT_WRITABLE"
| "UNKNOWN"; | "UNKNOWN";
export function axiError( export function axiError(
@@ -21,50 +20,6 @@ export function axiError(
return new AxiError(message, code, suggestions); return new AxiError(message, code, suggestions);
} }
// The three ways a filesystem refuses a write for reasons the user must settle
// outside this tool: the permission bits deny it, the file is flagged immutable
// or otherwise protected, or the filesystem itself is mounted read-only.
const NOT_WRITABLE_ERRNOS = ["EACCES", "EPERM", "EROFS"];
/** Whether a caught filesystem error means the target cannot be written. */
export function isNotWritableError(error: unknown): boolean {
const errno = (error as { code?: unknown } | null)?.code;
return typeof errno === "string" && NOT_WRITABLE_ERRNOS.includes(errno);
}
/**
* The same judgement made from an error's *message*, for a caller handed the
* formatted text rather than the error object.
*
* This takes the message alone, never a string the target's path has been
* spliced into: a path is the user's to name, and one that happened to contain
* `EACCES` would otherwise misreport an unrelated failure as a read-only target.
*/
export function isNotWritableMessage(message: string): boolean {
return NOT_WRITABLE_ERRNOS.some((errno) => message.includes(errno));
}
/**
* A read-only target reported as something the user can act on.
*
* The remedy is deliberately general. Read-only is not diagnostic of any
* particular configuration manager, and every plausible cause — a declarative
* home manager, an immutable flag, a root-owned path — has the same answer:
* whatever renders the file read-only is where this belongs, not here.
*
* `subject` names what the caller was installing, for the remedy line.
*/
export function unwritableTargetError(path: string, subject: string): AxiError {
return axiError(
`Cannot write ${path}: it is not writable, so it appears to be managed by another tool`,
"TARGET_NOT_WRITABLE",
[
`Declare ${subject} through that tool's configuration rather than installing it with this command`,
`Or make ${path} writable and re-run`,
],
);
}
interface HttpResponseLike { interface HttpResponseLike {
status: number; status: number;
url: string; url: string;

View File

@@ -1,217 +0,0 @@
import { readFileSync, realpathSync, statSync } from "node:fs";
import { delimiter, join } from "node:path";
/** Filesystem reads {@link resolveEntrypointOnPath} needs, injectable for tests. */
export interface PathProbe {
/** The resolved real path of `candidate`, or `undefined` if it is not a file. */
realPath: (candidate: string) => string | undefined;
/** The contents of `candidate`, or `undefined` if it cannot be read as text. */
readText: (candidate: string) => string | undefined;
}
/**
* Where `name` resolves on `pathValue` *to the program running `entrypoint`*, or
* `undefined` when it resolves nowhere, or resolves to some other program.
*
* This exists so `setup hooks` can hand the agent SDK the location the binary
* actually resolves to on `PATH` rather than the module-relative entrypoint.
* The SDK records a bare, upgrade-stable name only when a `PATH` entry
* realpath-matches the path it is given, and from the entrypoint's side that
* can only ever succeed under npm, which symlinks its `bin` entry straight at
* it. A wrapper-based install cannot: a script that *invokes* a file never
* resolves *to* that file.
*
* The two install shapes are therefore recognised on their own terms:
*
* - a **symlink** to the entrypoint, matched by realpath — npm's shape;
* - a **generated wrapper** naming the entrypoint in its text, matched by
* containment — the shape Nix, shims and `.cmd` launchers all produce.
*
* Wrappers chain, so containment follows the references a wrapper makes to
* other files rather than only reading the first one. A Nix install is two
* hops: `bin/gitea-axi` sets `PATH` and execs `bin/.gitea-axi-wrapped`, which
* is what actually names the entrypoint.
*
* Requiring one of these is what keeps the answer honest. Accepting any file
* that merely *bears the name* would hand the SDK a path that trivially
* realpath-matches itself, turning its check into a tautology and recording a
* bare name that resolves to a different program than the one asked.
*
* Windows `PATHEXT` suffixes are deliberately not tried. The package supports
* Linux and macOS, and wherever the lookup misses — an unreadable wrapper, a
* compiled launcher, a chain deeper than {@link MAX_WRAPPER_HOPS} — the
* caller's absolute-path fallback still produces a working hook.
*/
export function resolveEntrypointOnPath(
name: string,
entrypoint: string,
pathValue: string | undefined,
probe: PathProbe = defaultPathProbe,
): string | undefined {
const entrypointReal = probe.realPath(entrypoint);
for (const dir of (pathValue ?? "").split(delimiter)) {
if (!dir) {
continue;
}
const candidate = join(dir, name);
const candidateReal = probe.realPath(candidate);
if (!candidateReal) {
continue;
}
if (entrypointReal !== undefined && candidateReal === entrypointReal) {
return candidate;
}
if (wrapperLeadsTo(candidate, entrypoint, probe)) {
return candidate;
}
}
return undefined;
}
/** How many wrapper-to-wrapper hops to follow. Nix needs two; the cap is slack. */
const MAX_WRAPPER_HOPS = 4;
/** How many files one lookup may read before giving up, so a dense chain cannot run away. */
const MAX_WRAPPER_FILES = 32;
/** Absolute paths appearing in a script's text, stopping at shell quoting and separators. */
function absolutePathsIn(text: string): string[] {
return text.match(/\/[^\s"';|&()]+/g) ?? [];
}
/** Whether `start`, followed through the files it names, ends up naming `entrypoint`. */
function wrapperLeadsTo(start: string, entrypoint: string, probe: PathProbe): boolean {
const seen = new Set<string>();
let frontier = [start];
for (let hop = 0; hop <= MAX_WRAPPER_HOPS && frontier.length > 0; hop++) {
const next: string[] = [];
for (const file of frontier) {
if (seen.has(file) || seen.size >= MAX_WRAPPER_FILES) {
continue;
}
seen.add(file);
const text = probe.readText(file);
if (text === undefined) {
continue;
}
if (text.includes(entrypoint)) {
return true;
}
for (const referenced of absolutePathsIn(text)) {
if (!seen.has(referenced) && probe.realPath(referenced)) {
next.push(referenced);
}
}
}
frontier = next;
}
return false;
}
/**
* Anything much larger than a wrapper script is not one. The cap keeps a chain
* that happens to name `node` or `bash` from reading whole binaries back.
*/
const MAX_WRAPPER_BYTES = 64 * 1024;
const defaultPathProbe: PathProbe = {
realPath: (candidate) => {
try {
return statSync(candidate).isFile() ? realpathSync(candidate) : undefined;
} catch {
return undefined;
}
},
readText: (candidate) => {
try {
if (statSync(candidate).size > MAX_WRAPPER_BYTES) {
return undefined;
}
return readFileSync(candidate, "utf8");
} catch {
return undefined;
}
},
};
interface HookEntry {
command?: unknown;
}
interface HookGroup {
hooks?: unknown;
}
interface HookSettings {
hooks?: { SessionStart?: unknown };
}
/**
* Collapse repeated managed SessionStart entries down to the last one, which is
* the entry the SDK has just written or refreshed.
*
* The SDK recognises its own hook by testing whether the recorded command
* *contains* the marker, so an entrypoint path that happens not to contain
* "gitea-axi" makes a re-run append a second entry instead of updating the
* first — contradicting the idempotency `setup` promises. Recognition here does
* not depend on the command's shape: callers pass an `isManaged` that matches
* the exact command this run records, so a re-run identifies its own previous
* entry by equality rather than by a substring accident — and a hook belonging
* to another tool is never a candidate, whatever its command happens to spell.
*
* The *last* match survives. Every match holds the identical command, so the
* choice can only affect `type` and `timeout`, and the last is the entry the
* SDK has just appended in the case this exists to repair.
*
* Groups left with no hooks are dropped rather than kept as empty objects.
*/
export function pruneDuplicateManagedHooks(
settings: unknown,
isManaged: (command: string) => boolean,
): { settings: unknown; changed: boolean } {
const pruned = structuredClone(settings) as HookSettings | null;
const groups = pruned?.hooks?.SessionStart;
if (!Array.isArray(groups)) {
return { settings, changed: false };
}
const isManagedHook = (hook: HookEntry) =>
typeof hook?.command === "string" && isManaged(hook.command);
const managedCount = (groups as HookGroup[]).reduce(
(total, group) =>
total +
(Array.isArray(group?.hooks) ? (group.hooks as HookEntry[]).filter(isManagedHook).length : 0),
0,
);
if (managedCount < 2) {
return { settings, changed: false };
}
// Every managed entry but the last is a leftover from an earlier run.
let remaining = managedCount - 1;
const kept: HookGroup[] = [];
for (const group of groups as HookGroup[]) {
if (!Array.isArray(group?.hooks)) {
kept.push(group);
continue;
}
const survivors = (group.hooks as HookEntry[]).filter((hook) => {
if (remaining > 0 && isManagedHook(hook)) {
remaining--;
return false;
}
return true;
});
if (survivors.length > 0) {
group.hooks = survivors;
kept.push(group);
}
}
(pruned as HookSettings).hooks = { ...pruned?.hooks, SessionStart: kept };
return { settings: pruned, changed: true };
}

View File

@@ -1,6 +1,4 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { resolveRepoContext } from "../src/context.js";
import type { CliDeps } from "../src/deps.js";
import { startFixtureServer, type FixtureServer } from "./fixture-server.js"; import { startFixtureServer, type FixtureServer } from "./fixture-server.js";
import { runCliTest, testModeEnv } from "./harness.js"; import { runCliTest, testModeEnv } from "./harness.js";
@@ -119,49 +117,3 @@ describe("context overrides", () => {
expect(exitCode).toBe(0); expect(exitCode).toBe(0);
}); });
}); });
describe("apiUrl normalization", () => {
function depsWithApiUrl(apiUrl: string): CliDeps {
return {
env: {
GITEA_AXI_API_URL: apiUrl,
GITEA_AXI_REPO: "acme/widgets",
GITEA_AXI_TOKEN: "test-token",
},
cwd: process.cwd(),
globals: {},
};
}
it("strips a trailing /api/v1 suffix from the host base", async () => {
const context = await resolveRepoContext(
depsWithApiUrl("https://git.example.com/api/v1"),
);
expect(context.apiUrl).toBe("https://git.example.com");
});
it("strips a trailing /api/v1/ with a trailing slash", async () => {
const context = await resolveRepoContext(
depsWithApiUrl("https://git.example.com/api/v1/"),
);
expect(context.apiUrl).toBe("https://git.example.com");
});
it("leaves a host base without an /api/v1 suffix unchanged", async () => {
const context = await resolveRepoContext(
depsWithApiUrl("https://git.example.com"),
);
expect(context.apiUrl).toBe("https://git.example.com");
});
it("strips a lone trailing slash from the host base", async () => {
const context = await resolveRepoContext(
depsWithApiUrl("https://git.example.com/"),
);
expect(context.apiUrl).toBe("https://git.example.com");
});
});

View File

@@ -1,263 +0,0 @@
import { delimiter } from "node:path";
import { describe, expect, it } from "vitest";
import {
type PathProbe,
pruneDuplicateManagedHooks,
resolveEntrypointOnPath,
} from "../src/hooks.js";
describe("resolveEntrypointOnPath", () => {
const ENTRYPOINT = "/opt/gitea-axi/dist/main.js";
/** A probe over a fake filesystem: real paths, plus text for wrapper scripts. */
const probeOver = (
realPaths: Record<string, string>,
texts: Record<string, string> = {},
): PathProbe => ({
realPath: (candidate) => realPaths[candidate],
readText: (candidate) => texts[candidate],
});
const path = ["/empty", "/usr/local/bin", "/usr/bin"].join(delimiter);
it("matches a symlink to the entrypoint by real path — npm's install shape", () => {
const probe = probeOver({
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": ENTRYPOINT,
});
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
"/usr/local/bin/gitea-axi",
);
});
it("matches a wrapper naming the entrypoint in its text — the Nix install shape", () => {
// The wrapper's own realpath is the wrapper, never the entrypoint, which is
// precisely why the realpath test alone cannot see this install.
const probe = probeOver(
{
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": "/usr/local/bin/gitea-axi",
},
{ "/usr/local/bin/gitea-axi": `#!/bin/sh\nexec node ${ENTRYPOINT} "$@"\n` },
);
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
"/usr/local/bin/gitea-axi",
);
});
it("follows a chained wrapper to the entrypoint — the real Nix shape", () => {
// Nix is two hops: bin/gitea-axi sets PATH and execs bin/.gitea-axi-wrapped,
// and only that second script names the entrypoint.
const wrapped = "/usr/local/bin/.gitea-axi-wrapped";
const probe = probeOver(
{
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": "/usr/local/bin/gitea-axi",
[wrapped]: wrapped,
},
{
"/usr/local/bin/gitea-axi": `#!/bin/bash -e\nexport PATH\nexec -a "$0" "${wrapped}" "$@"\n`,
[wrapped]: `#!/bin/bash -e\nexec "/usr/bin/node" ${ENTRYPOINT} "$@"\n`,
},
);
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
"/usr/local/bin/gitea-axi",
);
});
it("gives up on a wrapper chain that never names the entrypoint", () => {
// A cycle: it must terminate rather than following the loop forever.
const other = "/usr/local/bin/other";
const probe = probeOver(
{
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": "/usr/local/bin/gitea-axi",
[other]: other,
},
{
"/usr/local/bin/gitea-axi": `exec "${other}"\n`,
[other]: `exec "/usr/local/bin/gitea-axi"\n`,
},
);
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBeUndefined();
});
it("refuses a same-named binary that is some other program", () => {
// Accepting this would hand the SDK a path that realpath-matches itself,
// making its check a tautology and recording a name for a program the
// caller never asked about.
const probe = probeOver(
{
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": "/somewhere/else/gitea-axi",
},
{ "/usr/local/bin/gitea-axi": "#!/bin/sh\nexec node /somewhere/else/dist/main.js\n" },
);
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBeUndefined();
});
it("returns undefined when the name resolves nowhere on PATH", () => {
const probe = probeOver({ [ENTRYPOINT]: ENTRYPOINT });
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBeUndefined();
});
it("returns undefined for an unset or empty PATH", () => {
const probe = probeOver({ [ENTRYPOINT]: ENTRYPOINT });
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, undefined, probe)).toBeUndefined();
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, "", probe)).toBeUndefined();
});
it("skips empty PATH entries rather than probing the working directory", () => {
const probed: string[] = [];
resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, ["", "/usr/bin", ""].join(delimiter), {
realPath: (candidate) => {
probed.push(candidate);
return undefined;
},
readText: () => undefined,
});
expect(probed).toEqual([ENTRYPOINT, "/usr/bin/gitea-axi"]);
});
it("takes the first PATH entry that matches, not a later one", () => {
const probe = probeOver({
[ENTRYPOINT]: ENTRYPOINT,
"/usr/local/bin/gitea-axi": ENTRYPOINT,
"/usr/bin/gitea-axi": ENTRYPOINT,
});
expect(resolveEntrypointOnPath("gitea-axi", ENTRYPOINT, path, probe)).toBe(
"/usr/local/bin/gitea-axi",
);
});
});
describe("pruneDuplicateManagedHooks", () => {
const settingsWith = (...commands: string[]) => ({
hooks: {
SessionStart: commands.map((command) => ({
matcher: "",
hooks: [{ type: "command", command, timeout: 10 }],
})),
},
});
interface ReadBack {
hooks: { SessionStart: { hooks: { command: string; timeout?: number }[] }[] };
}
const commandsOf = (settings: unknown) =>
(settings as ReadBack).hooks.SessionStart.flatMap((group) =>
group.hooks.map((hook) => hook.command),
);
it("collapses a duplicated entry whose command does not contain the marker", () => {
// The case the SDK cannot handle: it recognises its own hook by finding the
// marker inside the recorded command, so an entrypoint path without
// "gitea-axi" in it makes a re-run append rather than update.
const entrypoint = "/build/source/dist/main.js";
const result = pruneDuplicateManagedHooks(
settingsWith(entrypoint, entrypoint),
(command) => command === entrypoint,
);
expect(result.changed).toBe(true);
expect(commandsOf(result.settings)).toEqual([entrypoint]);
});
it("keeps the last managed entry, which is the one the SDK just appended", () => {
// Both entries carry the same command, so the survivor is identified by the
// rest of its shape: the stale one has a timeout the SDK no longer writes.
const result = pruneDuplicateManagedHooks(
{
hooks: {
SessionStart: [
{ matcher: "", hooks: [{ type: "command", command: "gitea-axi", timeout: 99 }] },
{ matcher: "", hooks: [{ type: "command", command: "gitea-axi", timeout: 10 }] },
],
},
},
(command) => command === "gitea-axi",
);
const settings = result.settings as ReadBack;
expect(settings.hooks.SessionStart).toHaveLength(1);
expect(settings.hooks.SessionStart[0]?.hooks[0]).toMatchObject({ timeout: 10 });
});
it("leaves hooks belonging to other tools untouched", () => {
const result = pruneDuplicateManagedHooks(
settingsWith("other-tool", "gitea-axi", "another-tool", "gitea-axi"),
(command) => command === "gitea-axi",
);
expect(commandsOf(result.settings)).toEqual(["other-tool", "another-tool", "gitea-axi"]);
});
it("prunes duplicates that share a single group", () => {
const result = pruneDuplicateManagedHooks(
{
hooks: {
SessionStart: [
{
matcher: "",
hooks: [
{ type: "command", command: "gitea-axi", timeout: 10 },
{ type: "command", command: "other-tool", timeout: 10 },
{ type: "command", command: "gitea-axi", timeout: 10 },
],
},
],
},
},
(command) => command === "gitea-axi",
);
expect(commandsOf(result.settings)).toEqual(["other-tool", "gitea-axi"]);
});
it("reports no change and returns the input when there is nothing to prune", () => {
const single = settingsWith("gitea-axi");
const result = pruneDuplicateManagedHooks(single, (command) => command === "gitea-axi");
expect(result.changed).toBe(false);
expect(result.settings).toBe(single);
});
it("preserves sibling settings keys and other hook events", () => {
const result = pruneDuplicateManagedHooks(
{
model: "opus",
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "audit" }] }],
SessionStart: settingsWith("gitea-axi", "gitea-axi").hooks.SessionStart,
},
},
(command) => command === "gitea-axi",
);
const settings = result.settings as ReadBack & {
model: string;
hooks: { PreToolUse: unknown[] };
};
expect(settings.model).toBe("opus");
expect(settings.hooks.PreToolUse).toHaveLength(1);
expect(commandsOf(settings)).toEqual(["gitea-axi"]);
});
it("tolerates settings with no SessionStart hooks at all", () => {
for (const input of [{}, { hooks: {} }, { hooks: { SessionStart: "nonsense" } }, null]) {
const result = pruneDuplicateManagedHooks(input, () => true);
expect(result.changed).toBe(false);
expect(result.settings).toBe(input);
}
});
});

View File

@@ -1,118 +0,0 @@
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { startFixtureServer } from "../fixture-server.js";
import { installGlobally, packTarball } from "./npm-artifact.js";
// What an *installed* gitea-axi must do, whatever installed it: run, render
// against a Gitea, and install its Agent Skill.
//
// The binary under test comes from the environment when GITEA_AXI_INSTALLED_BIN
// names one, and otherwise from packing and globally installing the npm tarball
// right here. That makes this one seam with two callers — the npm distribution
// path and the Nix installation path — so the two cannot drift apart in what
// they guarantee.
//
// Consequently nothing below may assert on how the binary came to exist: no
// store paths, no wrapper internals, no arrangement of files within the
// installed tree. Those are implementation detail of the installation method.
/**
* Set by a caller that has already installed gitea-axi and wants that one
* driven. An empty value counts as unset, so exporting it blank is the same as
* not exporting it at all.
*/
const providedBin = process.env.GITEA_AXI_INSTALLED_BIN || undefined;
let workDir: string | undefined;
let binPath: string;
// Unlike the in-process CLI-seam harness (test/harness.ts), which keeps the
// environment fully explicit so nothing leaks in, this tier spawns a real
// installed binary as a subprocess. That subprocess genuinely needs the parent
// env (PATH to resolve node/git/tea, npm config, etc.), so both helpers inherit
// `process.env` on purpose and layer the per-call `env` on top.
/** Run the installed binary, returning its stdout. */
function run(args: string[], env: Record<string, string> = {}): string {
return execFileSync(binPath, args, { encoding: "utf8", env: { ...process.env, ...env } });
}
const execFileAsync = promisify(execFile);
/**
* Run the installed binary without blocking this process's event loop, so an
* in-process fixture server can answer the CLI's HTTP calls while it runs. (A
* synchronous `execFileSync` would freeze the loop the fixture server lives on,
* deadlocking the request/response.)
*/
async function runAsync(args: string[], env: Record<string, string> = {}): Promise<string> {
const { stdout } = await execFileAsync(binPath, args, {
encoding: "utf8",
env: { ...process.env, ...env },
});
return stdout;
}
beforeAll(() => {
if (providedBin !== undefined) {
// Fail here rather than letting every assertion fail on a confusing ENOENT
// from the spawn.
if (!existsSync(providedBin)) {
throw new Error(
`GITEA_AXI_INSTALLED_BIN points at ${providedBin}, which does not exist. ` +
"Unset it to have this tier pack and install the npm tarball itself.",
);
}
binPath = providedBin;
return;
}
workDir = mkdtempSync(join(tmpdir(), "gitea-axi-install-"));
binPath = installGlobally(packTarball(workDir), workDir);
}, 300_000);
afterAll(() => {
if (workDir) {
rmSync(workDir, { recursive: true, force: true });
}
});
describe("installed gitea-axi binary", () => {
it("is executable and prints its usage", () => {
expect(existsSync(binPath)).toBe(true);
expect(run(["--help"])).toContain("usage: gitea-axi");
});
it("renders the dashboard header against a Gitea", async () => {
const server = await startFixtureServer([
{ method: "GET", path: "/api/v1/repos/o/r/pulls", body: [] },
{ method: "GET", path: "/api/v1/repos/o/r/issues", body: [] },
]);
try {
const dashboard = await runAsync([], {
GITEA_AXI_API_URL: server.url,
GITEA_AXI_REPO: "o/r",
GITEA_AXI_TOKEN: "x",
});
expect(dashboard).toContain("bin:");
expect(dashboard).toContain("description: Agent-ergonomic CLI for Gitea");
expect(dashboard).toContain("repo: o/r");
} finally {
await server.close();
}
});
it("finds its bundled Agent Skill and installs it into HOME/.claude", () => {
const home = mkdtempSync(join(tmpdir(), "gitea-axi-home-"));
try {
expect(run(["setup"], { HOME: home })).toContain("status: installed");
expect(existsSync(join(home, ".claude", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});

View File

@@ -1,53 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
// Building the npm distribution artifact, factored out so the two packaging
// facets can each take just the part they need: the tarball facet packs and
// extracts, the installed-binary facet packs and installs.
export const projectRoot = fileURLToPath(new URL("../..", import.meta.url));
/**
* Pack the real tarball into `destDir`, returning its path.
*
* `npm pack --json` reports the tarball filename; the shape shifted across npm
* majors (an array of entries on npm <12, an object keyed by package name on
* npm 12+), so accept either and pull the one filename out.
*/
export function packTarball(destDir: string): string {
const packOutput = execFileSync("npm", ["pack", "--json", "--pack-destination", destDir], {
cwd: projectRoot,
encoding: "utf8",
});
const packResult = JSON.parse(packOutput) as unknown;
const packEntries = Array.isArray(packResult)
? (packResult as Array<{ filename: string }>)
: Object.values(packResult as Record<string, { filename: string }>);
return join(destDir, packEntries[0]!.filename);
}
/**
* Extract `tarball` into a fresh `extract/` under `destDir`, returning the
* directory npm nests everything under (`<destDir>/extract/package`).
*/
export function extractTarball(tarball: string, destDir: string): string {
const extractDir = join(destDir, "extract");
mkdirSync(extractDir);
execFileSync("tar", ["-xzf", tarball, "-C", extractDir]);
return join(extractDir, "package");
}
/**
* Install `tarball` globally into a throwaway prefix under `destDir`, returning
* the path of the installed binary on that prefix's bin dir.
*/
export function installGlobally(tarball: string, destDir: string): string {
const prefix = join(destDir, "prefix");
execFileSync("npm", ["install", "-g", "--prefix", prefix, tarball], {
cwd: projectRoot,
encoding: "utf8",
});
return join(prefix, "bin", "gitea-axi");
}

View File

@@ -0,0 +1,176 @@
import { execFile, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { startFixtureServer } from "../fixture-server.js";
// The one artifact under test: the packed npm tarball, installed globally into a
// throwaway prefix. Packing plus a global install is expensive and identical for
// every facet, so it is built once here and the four facets assert against the
// shared result.
const projectRoot = fileURLToPath(new URL("../..", import.meta.url));
let workDir: string;
let extractDir: string;
let binPath: string;
/** The manifest as it ships inside the packed tarball. */
let packedManifest: Record<string, unknown>;
// Unlike the in-process CLI-seam harness (test/harness.ts), which keeps the
// environment fully explicit so nothing leaks in, this tier spawns a real
// globally-installed binary as a subprocess. That subprocess genuinely needs the
// parent env (PATH to resolve node/npm/tar, npm config, etc.), so both helpers
// inherit `process.env` on purpose and layer the per-call `env` on top.
/** Run the globally installed binary, returning its stdout. */
function run(args: string[], env: Record<string, string> = {}): string {
return execFileSync(binPath, args, { encoding: "utf8", env: { ...process.env, ...env } });
}
const execFileAsync = promisify(execFile);
/**
* Run the installed binary without blocking this process's event loop, so an
* in-process fixture server can answer the CLI's HTTP calls while it runs. (A
* synchronous `execFileSync` would freeze the loop the fixture server lives on,
* deadlocking the request/response.)
*/
async function runAsync(args: string[], env: Record<string, string> = {}): Promise<string> {
const { stdout } = await execFileAsync(binPath, args, {
encoding: "utf8",
env: { ...process.env, ...env },
});
return stdout;
}
beforeAll(() => {
workDir = mkdtempSync(join(tmpdir(), "gitea-axi-pack-"));
// Pack the real tarball. `npm pack --json` reports the tarball filename; the
// shape shifted across npm majors (an array of entries on npm <12, an object
// keyed by package name on npm 12+), so accept either and pull the one
// filename out.
const packOutput = execFileSync(
"npm",
["pack", "--json", "--pack-destination", workDir],
{ cwd: projectRoot, encoding: "utf8" },
);
const packResult = JSON.parse(packOutput) as unknown;
const packEntries = Array.isArray(packResult)
? (packResult as Array<{ filename: string }>)
: Object.values(packResult as Record<string, { filename: string }>);
const tarball = join(workDir, packEntries[0]!.filename);
// Extract to inspect the packed manifest and confirm bundled files. npm nests
// everything under `package/`.
extractDir = join(workDir, "extract");
mkdirSync(extractDir);
execFileSync("tar", ["-xzf", tarball, "-C", extractDir]);
packedManifest = JSON.parse(
readFileSync(join(extractDir, "package", "package.json"), "utf8"),
) as Record<string, unknown>;
// Install globally into a throwaway prefix so the binary lands on a PATH-like
// bin dir we control.
const prefix = join(workDir, "prefix");
execFileSync("npm", ["install", "-g", "--prefix", prefix, tarball], {
cwd: projectRoot,
encoding: "utf8",
});
binPath = join(prefix, "bin", "gitea-axi");
}, 300_000);
afterAll(() => {
if (workDir) {
rmSync(workDir, { recursive: true, force: true });
}
});
describe("npm distribution artifact", () => {
it("bundles the built CLI, the bin entry, and the Agent Skill, and declares no postinstall", () => {
expect(existsSync(join(extractDir, "package", "dist", "main.js"))).toBe(true);
expect(existsSync(join(extractDir, "package", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
const bin = packedManifest.bin as Record<string, string> | undefined;
expect(bin?.["gitea-axi"]).toBe("dist/main.js");
const scripts = packedManifest.scripts as Record<string, string> | undefined;
expect(scripts?.postinstall).toBeUndefined();
});
it("excludes the bench/ harness directory from the package", () => {
expect(existsSync(join(extractDir, "package", "bench"))).toBe(false);
});
it("declares complete metadata: unscoped name, description, repo, license, engines, ESM type", () => {
const name = packedManifest.name as string;
expect(name).toBe("gitea-axi");
expect(name).not.toContain("@");
expect(name).not.toContain("/");
const description = packedManifest.description as string;
expect(typeof description).toBe("string");
expect(description.length).toBeGreaterThan(0);
const repository = packedManifest.repository as string | { url?: string } | undefined;
const repositoryUrl = typeof repository === "string" ? repository : repository?.url;
expect(repositoryUrl).toContain("gitea-axi");
expect(packedManifest.license).toBe("MIT");
const engines = packedManifest.engines as Record<string, string> | undefined;
expect(engines?.node).toMatch(/20/);
expect(packedManifest.type).toBe("module");
});
it("scripts and documents the publish flow so publishing is a single command", () => {
const scripts = packedManifest.scripts as Record<string, string> | undefined;
expect(scripts?.prepack).toBe("npm run build");
const publishConfig = packedManifest.publishConfig as Record<string, string> | undefined;
expect(publishConfig?.access).toBe("public");
const publishingDoc = join(projectRoot, "PUBLISHING.md");
expect(existsSync(publishingDoc)).toBe(true);
expect(readFileSync(publishingDoc, "utf8")).toMatch(/npm publish/);
});
it("puts a working gitea-axi on the PATH: dashboard header, --help, and setup all function", async () => {
expect(existsSync(binPath)).toBe(true);
expect(run(["--help"])).toContain("usage: gitea-axi");
// Dashboard header renders against a stubbed Gitea.
const server = await startFixtureServer([
{ method: "GET", path: "/api/v1/repos/o/r/pulls", body: [] },
{ method: "GET", path: "/api/v1/repos/o/r/issues", body: [] },
]);
try {
const dashboard = await runAsync([], {
GITEA_AXI_API_URL: server.url,
GITEA_AXI_REPO: "o/r",
GITEA_AXI_TOKEN: "x",
});
expect(dashboard).toContain("bin:");
expect(dashboard).toContain("description: Agent-ergonomic CLI for Gitea");
expect(dashboard).toContain("repo: o/r");
} finally {
await server.close();
}
// `setup` installs the Agent Skill into HOME/.claude.
const home = mkdtempSync(join(tmpdir(), "gitea-axi-home-"));
try {
expect(run(["setup"], { HOME: home })).toContain("status: installed");
expect(existsSync(join(home, ".claude", "skills", "gitea-axi", "SKILL.md"))).toBe(true);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});

View File

@@ -1,88 +0,0 @@
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { extractTarball, packTarball, projectRoot } from "./npm-artifact.js";
// The shape of the packed npm tarball and the manifest that ships inside it.
// These assertions are npm-specific by nature — no other distribution method
// produces a tarball or a packed manifest — so they stay separate from the
// installed-binary assertions, which any installation method can drive.
let workDir: string;
/** The extracted tarball's root — npm nests everything under `package/`. */
let packageDir: string;
/** The manifest as it ships inside the packed tarball. */
let packedManifest: Record<string, unknown>;
beforeAll(() => {
workDir = mkdtempSync(join(tmpdir(), "gitea-axi-pack-"));
const tarball = packTarball(workDir);
packageDir = extractTarball(tarball, workDir);
packedManifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")) as Record<
string,
unknown
>;
}, 300_000);
afterAll(() => {
if (workDir) {
rmSync(workDir, { recursive: true, force: true });
}
});
describe("npm distribution artifact", () => {
it("bundles the built CLI, the bin entry, and the Agent Skill, and declares no postinstall", () => {
expect(existsSync(join(packageDir, "dist", "main.js"))).toBe(true);
expect(existsSync(join(packageDir, "skills", "gitea-axi", "SKILL.md"))).toBe(true);
const bin = packedManifest.bin as Record<string, string> | undefined;
expect(bin?.["gitea-axi"]).toBe("dist/main.js");
const scripts = packedManifest.scripts as Record<string, string> | undefined;
expect(scripts?.postinstall).toBeUndefined();
});
it("excludes the bench/ harness directory from the package", () => {
expect(existsSync(join(packageDir, "bench"))).toBe(false);
});
it("declares complete metadata: unscoped name, description, repo, license, engines, ESM type", () => {
const name = packedManifest.name as string;
expect(name).toBe("gitea-axi");
expect(name).not.toContain("@");
expect(name).not.toContain("/");
const description = packedManifest.description as string;
expect(typeof description).toBe("string");
expect(description.length).toBeGreaterThan(0);
const repository = packedManifest.repository as string | { url?: string } | undefined;
const repositoryUrl = typeof repository === "string" ? repository : repository?.url;
expect(repositoryUrl).toContain("gitea-axi");
expect(packedManifest.license).toBe("MIT");
// The declared range names exactly the majors continuous integration
// matrixes over, so nothing is promised that is never tested. Node 20 is
// end-of-life and deliberately no longer named.
const engines = packedManifest.engines as Record<string, string> | undefined;
expect(engines?.node).toMatch(/22/);
expect(engines?.node).toMatch(/24/);
expect(engines?.node).not.toMatch(/20/);
expect(packedManifest.type).toBe("module");
});
it("scripts and documents the publish flow so publishing is a single command", () => {
const scripts = packedManifest.scripts as Record<string, string> | undefined;
expect(scripts?.prepack).toBe("npm run build");
const publishConfig = packedManifest.publishConfig as Record<string, string> | undefined;
expect(publishConfig?.access).toBe("public");
const publishingDoc = join(projectRoot, "PUBLISHING.md");
expect(existsSync(publishingDoc)).toBe(true);
expect(readFileSync(publishingDoc, "utf8")).toMatch(/npm publish/);
});
});

View File

@@ -402,149 +402,3 @@ describe("search empty results", () => {
}); });
} }
}); });
/**
* The `help[1]:` next-step line is emitted by `suggestCommand`, so it appears
* wrapped in a `Run \`gitea-axi …\`` line with `-R`/`--login` normalization.
* These tests pull that help block out of the output and assert on the
* count-conditional suggestion within it.
*/
function helpBlock(stdout: string): string {
const lines = stdout.split("\n");
const start = lines.findIndex((line) => /^help\[\d+\]:/.test(line));
expect(start).toBeGreaterThanOrEqual(0);
// The block runs from the help[N]: header to the next blank line / EOF.
const rest = lines.slice(start);
const end = rest.findIndex((line, i) => i > 0 && line.trim() === "");
return (end === -1 ? rest : rest.slice(0, end)).join("\n");
}
describe("search issues count-conditional next-step suggestion", () => {
it("suggests the list fallback when there are zero in-repo matches", async () => {
server = await startFixtureServer([
{ method: "GET", path: SEARCH_PATH, headers: { "X-Total-Count": "0" }, body: [] },
]);
const { stdout, exitCode } = await runCliTest(
["search", "issues", "login bug"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("issue list --state all");
expect(help).toContain("to list all issues instead");
expect(help).not.toContain("issue view");
});
it("fills the real number when there is exactly one in-repo match", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "1" },
body: [searchIssueOf(2, { title: "Fix login redirect loop" })],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "issues", "login bug"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("issue view 2");
expect(help).toContain("to see it in full");
expect(help).not.toContain("issue view <number>");
});
it("keeps the <number> placeholder when there are two or more in-repo matches", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "2" },
body: [
searchIssueOf(42, { title: "Fix login redirect loop" }),
searchIssueOf(41, { title: "Login button unresponsive" }),
],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "issues", "login bug"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("issue view <number>");
expect(help).toContain("to see a match in full");
});
});
describe("search prs count-conditional next-step suggestion", () => {
it("suggests the list fallback when there are zero in-repo matches", async () => {
server = await startFixtureServer([
{ method: "GET", path: SEARCH_PATH, headers: { "X-Total-Count": "0" }, body: [] },
]);
const { stdout, exitCode } = await runCliTest(
["search", "prs", "flaky ci"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("pr list --state all");
expect(help).toContain("to list all pull requests instead");
expect(help).not.toContain("pr view");
});
it("fills the real number when there is exactly one in-repo match", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "1" },
body: [searchIssueOf(2, { title: "Retry flaky CI jobs" })],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "prs", "flaky ci"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("pr view 2");
expect(help).toContain("to see it in full");
expect(help).not.toContain("pr view <number>");
});
it("keeps the <number> placeholder when there are two or more in-repo matches", async () => {
server = await startFixtureServer([
{
method: "GET",
path: SEARCH_PATH,
headers: { "X-Total-Count": "2" },
body: [
searchIssueOf(73, { title: "Retry flaky CI jobs" }),
searchIssueOf(72, { title: "Stabilize CI runners" }),
],
},
]);
const { stdout, exitCode } = await runCliTest(
["search", "prs", "flaky ci"],
{ env: testModeEnv(server.url) },
);
expect(exitCode).toBe(0);
const help = helpBlock(stdout);
expect(help).toContain("pr view <number>");
expect(help).toContain("to see a match in full");
});
});

View File

@@ -1,147 +1,17 @@
import { import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { delimiter, isAbsolute, join } from "node:path"; import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { type CliResult, runCliTest } from "./harness.js"; import { runCliTest } from "./harness.js";
let tempHome: string; let tempHome: string;
/** Restore write permission everywhere under `dir` so the tree can be removed. */
function restorePermissions(dir: string): void {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
chmodSync(path, entry.isDirectory() ? 0o700 : 0o600);
if (entry.isDirectory()) {
restorePermissions(path);
}
}
}
afterEach(() => { afterEach(() => {
// Not every test creates a HOME, so this may be a directory an earlier test if (tempHome) {
// already removed.
if (tempHome && existsSync(tempHome)) {
// The read-only-target tests leave files and directories unwritable, and
// an unwritable directory cannot have its entries unlinked.
chmodSync(tempHome, 0o700);
restorePermissions(tempHome);
rmSync(tempHome, { recursive: true, force: true }); rmSync(tempHome, { recursive: true, force: true });
} }
tempHome = "";
}); });
/**
* Permission bits are not enforced for root, so the read-only-target tests
* cannot express their premise there and are skipped rather than passing
* vacuously.
*/
const itUnlessRoot = process.getuid?.() === 0 ? it.skip : it;
/**
* Assert the error's wording infers no particular configuration manager.
*
* Read-only is not diagnostic of one, so naming one would be wrong for most
* readers who hit this. The paths the error quotes are exempt — they are the
* user's own, and here the temp directory sits under a `nix-shell` TMPDIR.
*/
function expectNamesNoManager(stdout: string, home: string): void {
const wording = stdout.split(home).join("<home>");
expect(wording).not.toMatch(/\b(nix|home-manager|nixos|chezmoi|ansible|stow|guix)\b/i);
}
/**
* Run `body` with `process.env.PATH` replaced. `setup hooks` reads PATH from the
* process rather than the injected environment, because it has to agree with
* the agent SDK's own probing — so this is the seam that decides whether the
* recorded hook command is the bare name or the absolute entrypoint path.
*/
async function withPath(path: string, body: () => Promise<CliResult>): Promise<CliResult> {
const original = process.env.PATH;
process.env.PATH = path;
try {
return await body();
} finally {
process.env.PATH = original;
}
}
/**
* The entrypoint `setup hooks` resolves for itself — `src/main.js` here, since
* the dist layout mirrors src/ and setup.ts locates it relative to its own
* module.
*/
function entrypointPath(): string {
return fileURLToPath(new URL("../src/main.js", import.meta.url));
}
/** Write an executable `gitea-axi` into a fresh `dir`, and return that dir. */
function writeFakeBinary(dir: string, contents: string): string {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "gitea-axi"), contents, { mode: 0o755 });
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. */
function recordedHookCommand(home: string): string {
const settings = claudeSettings(home);
expect(settings.hooks.SessionStart).toHaveLength(1);
expect(settings.hooks.SessionStart[0].hooks).toHaveLength(1);
return settings.hooks.SessionStart[0].hooks[0].command;
}
describe("setup", () => { describe("setup", () => {
it("installs the skill and is idempotent: installed -> unchanged -> updated", async () => { it("installs the skill and is idempotent: installed -> unchanged -> updated", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-")); tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
@@ -167,60 +37,6 @@ describe("setup", () => {
expect(third.stdout).toContain("status: updated"); expect(third.stdout).toContain("status: updated");
expect(readFileSync(installedPath, "utf8")).not.toBe("tampered"); expect(readFileSync(installedPath, "utf8")).not.toBe("tampered");
}); });
itUnlessRoot("reports a read-only skill target as a structured error", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const installedPath = join(tempHome, ".claude", "skills", "gitea-axi", "SKILL.md");
// A declaratively managed install in miniature: the file is present, its
// content differs from the bundled copy, and it cannot be written.
mkdirSync(join(tempHome, ".claude", "skills", "gitea-axi"), { recursive: true });
writeFileSync(installedPath, "managed elsewhere\n");
chmodSync(installedPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(installedPath);
expect(stdout).toContain("managed by another tool");
expectNamesNoManager(stdout, tempHome);
// The bundled copy is untouched by a failed run.
expect(readFileSync(installedPath, "utf8")).toBe("managed elsewhere\n");
});
itUnlessRoot("names the directory when it is the directory that is read-only", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const skillsDir = join(tempHome, ".claude", "skills");
// Nothing installed yet, and no new entry can be created here — so the
// blocked path is the directory, not the file that would have gone in it.
mkdirSync(skillsDir, { recursive: true });
chmodSync(skillsDir, 0o555);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(join(skillsDir, "gitea-axi"));
expectNamesNoManager(stdout, tempHome);
});
itUnlessRoot("succeeds on a read-only skill target that is already up to date", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const installedPath = join(tempHome, ".claude", "skills", "gitea-axi", "SKILL.md");
const first = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(first.exitCode).toBe(0);
// Same bytes the command would write, so there is nothing to write and the
// target's being read-only is beside the point.
chmodSync(installedPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup"], { env: { HOME: tempHome } });
expect(exitCode).toBe(0);
expect(stdout).toContain("status: unchanged");
});
}); });
describe("setup hooks", () => { describe("setup hooks", () => {
@@ -265,77 +81,10 @@ 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 settings = claudeSettings(tempHome); const claudeSettingsPath = join(tempHome, ".claude", "settings.json");
expect(settings.hooks.SessionStart).toHaveLength(1); const claudeSettings = JSON.parse(readFileSync(claudeSettingsPath, "utf8"));
expect(settings.hooks.SessionStart[0].hooks).toHaveLength(1); expect(claudeSettings.hooks.SessionStart).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 () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
await installHooksBehindWrapper(tempHome);
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 () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const emptyDir = join(tempHome, "empty");
mkdirSync(emptyDir, { recursive: true });
const { exitCode } = await withPath(emptyDir, () =>
runCliTest(["setup", "hooks"], { env: { HOME: tempHome } }),
);
expect(exitCode).toBe(0);
const command = recordedHookCommand(tempHome);
expect(isAbsolute(command)).toBe(true);
expect(command).toBe(entrypointPath());
});
it("falls back rather than recording a name for some unrelated gitea-axi", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
// Same name on PATH, different program. Recording the bare name here would
// point the hook at a binary the user never asked to install.
const binDir = writeFakeBinary(
join(tempHome, "impostor"),
"#!/bin/sh\nexec node /somewhere/else/dist/main.js \"$@\"\n",
);
const { exitCode } = await withPath(binDir, () =>
runCliTest(["setup", "hooks"], { env: { HOME: tempHome } }),
);
expect(exitCode).toBe(0);
expect(recordedHookCommand(tempHome)).toBe(entrypointPath());
});
itUnlessRoot("reports a read-only hook target as the same structured error", async () => {
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
const settingsPath = join(tempHome, ".claude", "settings.json");
mkdirSync(join(tempHome, ".claude"), { recursive: true });
writeFileSync(settingsPath, "{}\n");
chmodSync(settingsPath, 0o444);
const { stdout, exitCode } = await runCliTest(["setup", "hooks"], {
env: { HOME: tempHome },
});
expect(exitCode).toBe(1);
expect(stdout).toContain("code: TARGET_NOT_WRITABLE");
expect(stdout).toContain(settingsPath);
expect(stdout).toContain("managed by another tool");
expectNamesNoManager(stdout, tempHome);
}); });
}); });

View File

@@ -36,23 +36,19 @@ describe("bundled Agent Skill markdown", () => {
it("references each command group as a one-liner", () => { it("references each command group as a one-liner", () => {
const body = skill.toLowerCase(); const body = skill.toLowerCase();
for (const group of ["issue", "pr", "label", "search"]) { for (const group of ["issue", "pr", "label", "search", "setup"]) {
expect(body, `expected the skill to mention the ${group} command group`).toContain( expect(body, `expected the skill to mention the ${group} command group`).toContain(
group, group,
); );
} }
}); });
it("steers find-then-act and does not push exploratory discovery", () => { it("points at the bare dashboard and per-command help for discovery", () => {
const body = skill.toLowerCase(); const body = skill.toLowerCase();
// The intended steering: find the target, then act on it. // Bare dashboard: running the binary with no arguments.
expect(body).toContain("find the target"); expect(body).toContain("no argument");
expect(body).toContain("act on it"); expect(body).toContain("dashboard");
// The find/act discipline: not a full survey, and not both find commands at once. // Per-command help.
expect(body).toContain("not a survey"); expect(body).toContain("--help");
expect(body).toContain("not both");
// The removed exploratory anti-pattern must be gone.
expect(body).not.toContain("dashboard");
expect(body).not.toContain("no argument");
}); });
}); });

View File

@@ -2,20 +2,17 @@ import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
test: { test: {
// The packaging tier, in two facets. `tarball` asserts the shape of the // The packaging tier: pack the real tarball, install it globally into a
// packed npm tarball and its manifest; `installed-binary` drives an // throwaway prefix, then drive the installed binary. It builds, packs, and
// installed gitea-axi, either one named by GITEA_AXI_INSTALLED_BIN or one // fetches runtime deps from the registry, so it is far slower than the fast
// it packs and installs itself. It builds, packs, and fetches runtime deps // tiers and runs on its own via `test:pack`. Distribution touches no Gitea
// from the registry, so it is far slower than the fast tiers and runs on // API, so this smoke test is the distribution analogue of the live-Gitea
// its own via `test:pack`. Distribution touches no Gitea API, so this smoke // e2e tier rather than a member of it.
// test is the distribution analogue of the live-Gitea e2e tier rather than
// a member of it.
include: ["test/packaging/**/*.test.ts"], include: ["test/packaging/**/*.test.ts"],
testTimeout: 180_000, testTimeout: 180_000,
hookTimeout: 300_000, hookTimeout: 300_000,
// Each facet's setup may run `npm pack` against the one shared project // Keep the pack/install work in one process: the tarball is built once in a
// root, whose `prepack` writes `dist/`; serialize the files so two builds // shared setup and reused across the assertions.
// cannot race on it.
fileParallelism: false, fileParallelism: false,
passWithNoTests: true, passWithNoTests: true,
}, },