Compare commits
14 Commits
6c31eb9e62
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 627fc9a32b | |||
| 1468003f5b | |||
| 534097121d | |||
| e77a1f5e22 | |||
| a1e68dc530 | |||
| 27aad04984 | |||
| 4e92dde4e4 | |||
| 397da5d7a6 | |||
| 710bbfdeac | |||
| ccc8dbe998 | |||
| 6a9d4d4770 | |||
| 82cdad4ce0 | |||
| 5a2874d11c | |||
| 488058630b |
@@ -156,16 +156,44 @@ _Avoid_: mock mode, stub mode
|
||||
|
||||
### Distribution
|
||||
|
||||
**Agent Skill**: The markdown file bundled inside the npm package and installed to `~/.claude/skills/` by the `setup` command.
|
||||
**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]].
|
||||
_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).
|
||||
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.
|
||||
`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
|
||||
|
||||
**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).
|
||||
**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]].
|
||||
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 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
|
||||
|
||||
**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
|
||||
|
||||
@@ -28,3 +28,7 @@ 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.
|
||||
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.
|
||||
|
||||
47
.claude/adr/0019-hook-records-search-path-name.md
Normal file
47
.claude/adr/0019-hook-records-search-path-name.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 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.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Ship a home-manager module for gitea-axi's ambient context
|
||||
|
||||
The flake exposes `homeModules.gitea-axi`, a home-manager module that declares the bundled Agent Skill and the SessionStart hook, installing the package by default.
|
||||
Importing it does nothing; `programs.gitea-axi.enable` switches it on, and the Skill and the hook each carry their own toggle, both defaulting on.
|
||||
|
||||
The package publishes what the module consumes: the Skill at a stable address in the output, and the SessionStart hook entry read from `session-start-hook.json`, a committed file the fast tier reads too.
|
||||
|
||||
`setup` and `setup hooks` are unchanged and remain fully supported.
|
||||
The two paths are alternatives, not stages.
|
||||
|
||||
## Context
|
||||
|
||||
The [nix-flake-packaging spec](../spec/nix-flake-packaging.md) listed a home-manager module under Out of Scope, deferred rather than rejected, on the grounds that managing the Agent Skill declaratively "would reintroduce exactly the automatism that ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script", and that the trade-off deserved its own decision made with usage evidence.
|
||||
|
||||
The evidence arrived, and it does not support the deferral's reasoning.
|
||||
|
||||
`setup` and `setup hooks` are write-only against files the operator is assumed to own.
|
||||
An operator whose agent configuration is generated declaratively cannot use either: the targets are read-only, which task 0044 made fail cleanly rather than obscurely, but failing cleanly is not the same as working.
|
||||
What that operator does instead is hand-copy the Skill into their own configuration, where it silently drifts from the package that ships it — the exact failure the bundled Skill exists to prevent.
|
||||
|
||||
The deferral's stated concern also does not survive contact with what a module is.
|
||||
ADR 0009 rejected a *postinstall script*: something that runs without being asked, as a side effect of installing a package.
|
||||
A module the operator imports and then explicitly enables is the opposite — it is the declarative spelling of running `setup`, not a substitute for the operator's consent.
|
||||
The automatism ADR 0009 guards against is absence of intent, and an `enable` option is intent.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**Keep deferring** (rejected) — The deferral was conditional on evidence, and the evidence is in.
|
||||
Leaving it deferred means the one operator the whole spec was written for still installs the Skill by hand.
|
||||
|
||||
**Teach `setup` to emit a Nix expression** (rejected) — A code generator producing configuration the operator then commits.
|
||||
It inverts the dependency: the generated expression is a snapshot that goes stale the moment the package changes, which is the drift problem restated rather than solved.
|
||||
|
||||
**Write `~/.claude/settings.json` and the Skill from `home.file` directly** (rejected) — The straightforward spelling, and it collides head-on with `programs.claude-code`, which owns `settings.json`.
|
||||
An operator using both modules would get a home-manager conflict on that file, and the composition the module exists to provide would be exactly what it broke.
|
||||
|
||||
**Declare through the Claude Code module's own options** (chosen) — `programs.claude-code.skills.gitea-axi` and `programs.claude-code.settings.hooks.SessionStart`.
|
||||
Home-manager's own merge semantics then do the composing: attribute sets merge, lists concatenate, and an operator who already declares their own Skills and SessionStart hooks gets ours alongside theirs rather than instead of them.
|
||||
The cost is a dependency on that sibling module being enabled, which is asserted rather than assumed.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The module is a wiring layer with no content of its own.
|
||||
Everything it declares comes from the package's `passthru`, so the declarative and imperative paths install the same two artefacts by construction.
|
||||
- `session-start-hook.json` is the hook's single declaration.
|
||||
The Nix expression reads it and the fast tier reads it, and that tier drives `setup hooks` against a temporary home and asserts the written entry equals the declared one.
|
||||
A divergence — including the agent SDK changing the envelope it writes — fails a test instead of shipping.
|
||||
Declaring the entry in the Nix expression instead would have created a second source of truth with nothing checking it against the first, and a test restating the same values a third time would have verified nothing.
|
||||
- The Skill is installed to `share/gitea-axi/skills/gitea-axi` in the output and published as `passthru.skill`.
|
||||
Its other copy, inside the installed node modules tree where `setup` resolves it relative to its own module, is an implementation detail of runtime resolution that moves with the packaging method; no consumer should address it.
|
||||
- `package = null` means the binary is not added to `home.packages`, for an operator installing it through `environment.systemPackages` instead.
|
||||
It does not mean the configuration is empty: the Skill still comes from the default build, which in that arrangement is already in the closure.
|
||||
The hook is unaffected either way, since it records a name resolved on `PATH` (ADR 0019) rather than a store path.
|
||||
- Home-manager inspects the Skill path while evaluating, so a rebuild realises the package during evaluation rather than at build time — including under `package = null`, where nothing is being installed.
|
||||
That is inherent to sourcing the Skill's bytes from the package: any store path handed to `programs.claude-code.skills` has the same effect.
|
||||
It is a cost in rebuild latency, not in correctness, and pointing the module at the repository's own `skills/` directory to avoid it was rejected — that would ignore `package` entirely, so an operator running an override would get a Skill from a build they are not running.
|
||||
- `programs.claude-code.skills` also accepts a single path standing for a whole skills directory, and a configuration using that form cannot have an entry merged into it.
|
||||
Such a configuration gets a type-merge error rather than composition.
|
||||
This is documented in [INSTALL.md](../../INSTALL.md) rather than worked around, since the workaround would mean writing the Skill file directly and reintroducing the collision this decision avoids.
|
||||
- The module defaults `package` to `pkgs.callPackage ./package.nix { }` — the importing configuration's own package set, not this flake's nixpkgs.
|
||||
That is how a consumer deduplicates, and it is why the derivation is a callable expression rather than a flake-bound one.
|
||||
- Enabling the Skill or the hook without `programs.claude-code.enable` is an assertion failure rather than a silent no-op.
|
||||
Those options are written by a module that is gated on its own `enable`, so without the assertion an operator would get a configuration that says the Skill is installed and a session that never sees it.
|
||||
- `nix flake check` does not evaluate the module, because doing so would mean taking home-manager as a flake input purely to test against.
|
||||
This matches the spec's existing position that the flake's consumption from a system configuration is verified by the maintainer's rebuild rather than by an automated test: building the package proves the derivation, and whether the configuration wires it in is outside this repository.
|
||||
The module was verified before landing against real home-manager, including that importing it without enabling it yields a byte-identical generation to never importing it at all.
|
||||
- The two Codex integrations `setup hooks` writes — `~/.codex/hooks.json` and `~/.codex/config.toml` — and the OpenCode plugin file have no declarative counterpart here.
|
||||
Home-manager has no module owning those files, so declaring them would mean writing them directly and re-creating the collision problem this decision avoids for Claude Code.
|
||||
An operator wanting those on a declarative system uses `setup hooks`, whose targets are unmanaged there and therefore writable.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Declare the Agent Skill through home.file and gate integration per harness
|
||||
|
||||
The home-manager module installs the gitea-axi binary whenever `programs.gitea-axi.enable` is set, and declares the Claude Code Agent Skill and SessionStart hook under a single per-harness toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting on.
|
||||
Those two artefacts land only when `programs.claude-code.enable` is also on, and are silently absent otherwise — there is no assertion.
|
||||
|
||||
The Skill is written through home-manager's own file mechanism, into Claude Code's skills directory under the `gitea-axi` name, rather than by contributing to `programs.claude-code.skills`.
|
||||
The hook is still declared through `programs.claude-code`'s settings option, unchanged.
|
||||
|
||||
This supersedes three decisions of [ADR 0020](0020-home-manager-module-for-declarative-context.md): the per-artefact toggles and their assertion, the Skill's declaration through the Claude Code module's skills option, and the position that the flake takes no home-manager input.
|
||||
The rest of ADR 0020 stands.
|
||||
|
||||
## Context
|
||||
|
||||
ADR 0020 gave the Skill and the hook one toggle each, both defaulting on, and made enabling either without `programs.claude-code.enable` an assertion failure.
|
||||
The assertion fires on the common case.
|
||||
An operator who wants gitea-axi as a standalone CLI on a host without Claude Code has not made a mistake, but the module meets them with a build failure telling them to turn off two toggles.
|
||||
Reaching the standalone shape means setting the module's `enable` to whatever `programs.claude-code.enable` is, and then setting `package = null` so the module does not install a binary the operator installs itself — leaving an `enable` that does not mean "enabled" and a `null` package that does not mean "no package".
|
||||
|
||||
Underneath that is a composition failure ADR 0020 documented as a limitation rather than fixed.
|
||||
`programs.claude-code.skills` accepts either an attribute set of skills or a single path standing for a whole skills directory.
|
||||
Contributing the module's Skill as an attribute entry cannot merge with an operator who set the option to a path: the two are different branches of the option's type, and the evaluation fails.
|
||||
ADR 0020's remedy was to disable the Skill and place it by hand, which reintroduces the hand-copying-that-drifts the bundled Skill exists to prevent.
|
||||
|
||||
The two problems share a root.
|
||||
The Skill was routed through a sibling module's typed option, so it inherited that option's merge behaviour — including the branch that cannot merge — and the whole integration was gated by an assertion because the declaration's only enforcement point was that assertion.
|
||||
|
||||
## Considered Options
|
||||
|
||||
**Default the toggles to `programs.claude-code.enable`** (rejected) — The upstream consumer's own suggestion.
|
||||
It keys an option's default off a sibling module's config, which is the config-derived default the operator found objectionable, and it does not generalise to multiple harnesses without each toggle reaching into a different sibling.
|
||||
Its stated diagnosis was also wrong: the friction is the assertion firing on the common case, not the module's `config` block sitting inside its own `enable` gate, which is unremarkable.
|
||||
|
||||
**Keep the per-artefact toggles, drop the assertion to a warning** (rejected) — Leaves the Skill routed through `programs.claude-code.skills`, so the path-form collision remains, and keeps a per-artefact surface that does not generalise to harnesses whose artefacts differ from Claude Code's.
|
||||
|
||||
**Write the Skill and the settings file directly, bypassing both sibling options** (rejected for the hook, adopted for the Skill) — ADR 0020 rejected this as one option, on the grounds that it collides with `programs.claude-code` owning the settings file.
|
||||
That reasoning is sound for the hook and false for the Skill.
|
||||
The settings file is a single file the Claude Code module owns wholesale, so writing it directly collides; Skill files are per-path, so writing the Skill's own path collides with nothing the operator has not themselves put there.
|
||||
ADR 0020 bundled the Skill into a rejection only its sibling earned.
|
||||
|
||||
**Declare the Skill through home.file and gate integration per harness** (chosen) — The Skill is written as an ordinary file declaration at its own path, which never touches the skills option's type and so composes with both the attribute-set and whole-directory forms.
|
||||
The per-artefact toggles collapse into one per-harness toggle whose name follows home-manager's `enableBashIntegration` convention and leaves room for sibling harnesses.
|
||||
The assertion is removed: writing into a disabled sibling's option is a benign no-op by home-manager convention, and the Skill's own gate makes its absence equally silent.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The Skill composes with an operator's own skills whichever form they use.
|
||||
Under the attribute-set form the Claude Code module lowers each skill to its own file declaration, so the module's Skill is one more entry at a distinct name.
|
||||
Under the whole-directory path form that module installs the directory recursively — individually-linked files under a real directory — so the module's nested Skill entry drops in as a sibling.
|
||||
This was verified by building the home files derivation under both forms and confirming all entries coexist.
|
||||
|
||||
- The composition under the path form couples to the Claude Code module installing a path-form skills directory recursively.
|
||||
A change there to a non-recursive install would claim the whole skills directory as one link and collide with the module's nested Skill as a build-time file conflict.
|
||||
The failure would be loud and immediate rather than silent, and the flake check below is what would catch it.
|
||||
|
||||
- The Skill no longer inherits the Claude Code module's `enable`-gate, because it is declared by this module's own file mechanism rather than through that module's options.
|
||||
The module therefore gates the Skill write on `programs.claude-code.enable` explicitly, alongside `programs.gitea-axi.enable` and the integration toggle.
|
||||
This keeps the intended semantics — no Skill on a host without Claude Code — and keeps the package realisation lazy, since the file mechanism reads the Skill's source path while evaluating and an ungated declaration would realise the package on every host, even one installing nothing.
|
||||
|
||||
- The hook keeps its gate for free.
|
||||
It is declared through the Claude Code module's settings option, which that module drops and never forces when it is disabled.
|
||||
The two artefacts are thus gated by different mechanisms — the Skill by an explicit sibling-enable condition, the hook by the sibling module's own gate — and the module comments the asymmetry so it is not mistaken for an oversight.
|
||||
|
||||
- Granularity is per harness, not per artefact.
|
||||
One toggle installs both Claude Code artefacts, and future harnesses read as `enableCodexIntegration` and `enableOpenCodeIntegration` siblings.
|
||||
The per-artefact escape hatch ADR 0020 offered — disable the Skill, keep the hook — is no longer needed, because the case it existed for was the path-form collision, and that collision is now fixed rather than escaped.
|
||||
|
||||
- The integration toggle defaults to a literal `true`, not to `programs.claude-code.enable`.
|
||||
The declared value stays constant, and the honest gating lives at the point of declaration rather than in a config-derived default.
|
||||
|
||||
- `nix flake check` now evaluates the module.
|
||||
A home-manager input is added, with its nixpkgs following the flake's, so the module is checked against the same pairing a consumer following this flake would get.
|
||||
This reverses ADR 0020's position that the flake takes no home-manager input and the module is verified only by a maintainer rebuild.
|
||||
The reversal buys the first automated proof of the module's composition — the path-form coexistence, the attribute-set coexistence, the disabled-Claude-Code gating, and the hook merging into an operator's own hooks — where a rebuild proved it only after the fact and only on the maintainer's own configuration.
|
||||
|
||||
- `package = null` is unchanged.
|
||||
It still declares the Skill and hook from the default build without adding the binary to the operator's packages, and the fallback that reads the default build's Skill is kept.
|
||||
Under the new gating that fallback is reached only when Claude Code is enabled, so the upstream suggestion to revisit it as a wasteful second evaluation does not apply.
|
||||
|
||||
- ADR 0020's surviving decisions are unaffected: the hook declared through the Claude Code settings option, the module as a content-free wiring layer sourcing both artefacts from the package, the `hook specification` as the hook's single committed source of truth checked by both paths, and the absence of a declarative counterpart for Codex and OpenCode.
|
||||
@@ -39,7 +39,7 @@ It ships as both an installable npm CLI and a bundled Agent Skill, so any agent
|
||||
|
||||
### Language and Runtime
|
||||
|
||||
TypeScript on Node 20+, matching the `gh-axi` reference implementation.
|
||||
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.
|
||||
ESM module format.
|
||||
|
||||
### 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.
|
||||
|
||||
**Principle 6 — Structured errors, exit codes, idempotent mutations, no prompts.**
|
||||
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`.
|
||||
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`.
|
||||
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:
|
||||
|
||||
@@ -516,6 +516,8 @@ 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.
|
||||
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.
|
||||
`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.
|
||||
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).
|
||||
|
||||
151
.claude/spec/hm-module-harness-integration.md
Normal file
151
.claude/spec/hm-module-harness-integration.md
Normal file
@@ -0,0 +1,151 @@
|
||||
## Problem Statement
|
||||
|
||||
The operator runs a NixOS flake that builds every host they own, and wants gitea-axi installed the way every other tool on the host is: its own [[home-manager module]] with its own enable flag, a standalone CLI alongside the other one-module-per-tool entries.
|
||||
The agent context — the [[Agent Skill]] and the [[SessionStart hook]] — is a separate concern that should follow only when a harness is present, because gitea-axi is a working CLI without a harness and coupling it to Claude Code would turn adopting a different harness later into an unrelated dependency's problem.
|
||||
|
||||
The module as first shipped (task 0045, ADR 0020) does not allow that shape without steering it against its grain.
|
||||
Enabling the Skill or the hook without `programs.claude-code.enable` is an assertion failure, and both toggles default on, so the common case — an operator who wants the CLI and does not use Claude Code — is a build failure that tells them to turn off two toggles.
|
||||
Reaching the wanted shape means setting the module's `enable` to whatever Claude Code's `enable` is, and then setting `package = null` so the module does not also install a binary the operator is installing itself.
|
||||
That leaves an option named `enable` that does not mean "gitea-axi is enabled" and a `null` package that does not mean "no package".
|
||||
|
||||
There is a second, quieter problem underneath.
|
||||
The Skill is declared by contributing an attribute to `programs.claude-code.skills`, whose type accepts either an attribute set of skills or a single path standing for a whole skills directory.
|
||||
An operator who uses the path form cannot have the module's Skill merged in: the two are different branches of the option's type and the module system cannot merge them, so the configuration fails to evaluate.
|
||||
The module documents this as a limitation and offers a workaround — disable the Skill and place it by hand — which is exactly the hand-copying-that-drifts the bundled Skill exists to prevent.
|
||||
|
||||
## Solution
|
||||
|
||||
Reshape the module so that enabling gitea-axi means "install the CLI, always", and the agent context follows the harness that is present.
|
||||
|
||||
`programs.gitea-axi.enable` installs the binary unconditionally.
|
||||
A single per-harness integration toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting on, declares both the Skill and the hook for Claude Code.
|
||||
Those two artefacts land only when `programs.claude-code.enable` is also on; when it is off they are silently absent, with no assertion, matching how home-manager's own `enableBashIntegration`-style toggles behave against a disabled shell.
|
||||
An operator who enables gitea-axi on a host without Claude Code gets the CLI and nothing else, which is the honest outcome and today's build failure.
|
||||
|
||||
The Skill is declared by writing it through home-manager's file mechanism directly, into Claude Code's skills directory, rather than by contributing to `programs.claude-code.skills`.
|
||||
This composes with both forms of an operator's own skills option — the attribute-set form and the whole-directory path form — because it never touches that option's type, so the collision disappears at its root rather than being escaped by a toggle.
|
||||
|
||||
Because the Skill now rides the module's own file declaration rather than Claude Code's options, it no longer inherits Claude Code's own enable-gate for free, so the module gates the Skill write on `programs.claude-code.enable` explicitly.
|
||||
The hook continues to be declared through Claude Code's settings option and continues to inherit that module's gate.
|
||||
|
||||
A home-manager flake input is added so that `nix flake check` can evaluate the module against real home-manager and prove the composition, replacing the previous position that the module's wiring was verified only by a maintainer rebuild.
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As an operator, I want `programs.gitea-axi.enable = true` to install the CLI whether or not I use Claude Code, so that enabling a standalone tool does not require me to also run a harness.
|
||||
2. As an operator without Claude Code, I want enabling gitea-axi to succeed and simply not install any agent context, so that I am not met with a build failure telling me to turn off toggles for a harness I never asked for.
|
||||
3. As an operator with Claude Code, I want the Skill and the SessionStart hook to appear automatically when I enable gitea-axi, so that the ambient context follows the harness I have without my wiring each piece.
|
||||
4. As an operator who wants the CLI but manages the agent context myself, I want to turn off the Claude Code integration with a single toggle, so that I keep the binary declaratively while writing the context by hand or with `setup`.
|
||||
5. As an operator who already declares my own Claude Code skills as a whole directory, I want gitea-axi's Skill to install alongside them, so that adopting the module does not force me to restructure how I manage skills or hand-copy the Skill.
|
||||
6. As an operator who already declares my own SessionStart hooks, I want gitea-axi's hook merged into mine rather than replacing them, so that composing the module with my configuration adds to it instead of colliding.
|
||||
7. As an operator installing the binary another way, I want `package = null` to still declare the Skill and hook from the default build, so that a system-wide install keeps the declarative context.
|
||||
8. As the maintainer, I want the module's composition proven by `nix flake check`, so that a change in home-manager or the Claude Code module that breaks the way the Skill is declared fails a check rather than a rebuild weeks later.
|
||||
9. As the maintainer, I want a single per-harness toggle whose shape generalises, so that adding Codex or OpenCode later is a sibling toggle rather than a reshaping of the option surface.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### Option surface
|
||||
|
||||
The module keeps `programs.gitea-axi.enable` (install the binary) and `programs.gitea-axi.package` (the package to install, or `null` to declare the context without installing the binary), both unchanged in meaning.
|
||||
|
||||
The two per-artefact toggles the first version carried — one for the Skill, one for the hook — are removed and replaced by one per-harness toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting to `true`.
|
||||
Granularity is per harness, not per artefact: the two Claude Code artefacts move together under one switch.
|
||||
The name follows home-manager's own `enableBashIntegration` convention rather than a nested attribute set, so future harnesses read as `enableCodexIntegration` and `enableOpenCodeIntegration` siblings.
|
||||
Removing the old toggles is free because the module is unreleased — it landed in a single commit and has no consumers to break.
|
||||
|
||||
The default is a literal `true`, not a value derived from `programs.claude-code.enable`.
|
||||
A config-derived default was considered and rejected: it makes the option's declared value depend on a sibling module's config, and the honest gating is better expressed where the artefacts are declared than by shifting the option's default.
|
||||
|
||||
There is no central aggregator toggle over all harnesses yet.
|
||||
It is the analogue of home-manager's `home.shell.enableShellIntegration` and earns its place only once a second harness exists to aggregate over; it can be added additively then, at which point the per-harness toggles change their default from literal `true` to the aggregator without a rename.
|
||||
|
||||
### How each artefact is declared
|
||||
|
||||
The hook is declared through the Claude Code module's own settings option, unchanged from ADR 0020.
|
||||
That module owns the settings file wholesale, so writing it directly would collide; declaring through its option lets home-manager's merge semantics compose the module's hook with an operator's own.
|
||||
This leg of ADR 0020 is sound and is kept.
|
||||
|
||||
The Skill is declared by writing it through home-manager's own file mechanism, into Claude Code's skills directory under the `gitea-axi` name, sourced from the package's published Skill.
|
||||
It is no longer contributed to `programs.claude-code.skills`.
|
||||
This is the change that fixes the path-form collision: the Skill write is an ordinary file declaration at a distinct path, not a contribution to the skills option's type, so it composes with both the attribute-set and whole-directory forms of that option.
|
||||
|
||||
The composition with the whole-directory form depends on the Claude Code module installing a path-form skills directory recursively — that is, as individually-linked files under a real directory, rather than as one symlink at the skills directory itself.
|
||||
A recursive install leaves the skills directory a real directory into which the module's own nested Skill entry drops as a sibling; a non-recursive one would claim the whole directory as a single link and collide with any nested entry.
|
||||
The Claude Code module installs recursively today, so the composition holds, and this is a coupling to that module's behaviour that the check below guards.
|
||||
|
||||
### Gating and evaluation cost
|
||||
|
||||
Because the Skill is declared by the module's own file mechanism rather than through the Claude Code module's options, it does not inherit that module's `enable`-gate.
|
||||
The module therefore gates the Skill write on `programs.claude-code.enable` itself, in addition to `programs.gitea-axi.enable` and the integration toggle.
|
||||
Without that gate the Skill would install on a host that has no Claude Code, contradicting the intended semantics, and would additionally force the package to be realised during evaluation on every host — the file mechanism reads the Skill's source path while evaluating, so an ungated declaration pays that realisation cost even where nothing consumes the Skill.
|
||||
Gating on the sibling's `enable` keeps the realisation lazy: on a host without Claude Code the Skill's source is never read, so nothing is realised.
|
||||
|
||||
The hook needs no such explicit gate, because it is declared through the Claude Code module's option and that module already drops and never forces the declaration when it is disabled.
|
||||
The two artefacts are therefore gated by different mechanisms internally — the Skill by an explicit sibling-enable condition, the hook by the sibling module's own gate — and the module comments the asymmetry.
|
||||
|
||||
### `package = null`
|
||||
|
||||
The `package = null` path is unchanged in intent.
|
||||
It declares the Skill and hook from the default build without adding the binary to the operator's packages, for an operator who installs the binary another way, such as a system-wide package set.
|
||||
The module reads the Skill and hook out of the operator-supplied package when one is given and out of the default build otherwise; that fallback is kept.
|
||||
Its cost is narrow and was accepted: it is a second evaluation of the same derivation for one path, which reduces to a single build when the operator's system-wide install comes from the same package set, and under the new gating it is reached only when Claude Code is enabled.
|
||||
|
||||
### Flake input
|
||||
|
||||
A home-manager input is added to the flake, with its own nixpkgs following the flake's nixpkgs, so the module is checked against the same nixpkgs-and-home-manager pairing a consumer following this flake would get.
|
||||
This reverses ADR 0020's position that the flake takes no home-manager input and the module is verified only by a maintainer rebuild.
|
||||
The reversal is justified by the check below, which is the first automated verification of the module's composition — a thing a rebuild proves only after the fact and only on the maintainer's own configuration.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
A good test here asserts which files an operator's generation contains after enabling the module in a given configuration — that the Skill lands at its expected place alongside whatever skills the operator already declares, that the hook merges into the operator's own hooks, and that nothing lands when Claude Code is off.
|
||||
It does not assert module internals: not option values, not the store paths involved, not the shape of the generated file mechanism, because those are implementation detail of the wiring and would have to change in lockstep with it.
|
||||
|
||||
### The seam
|
||||
|
||||
There is one new seam, at the highest point available: a flake check that evaluates the actual module through home-manager's standalone configuration entry point and inspects the resulting home files derivation.
|
||||
Evaluating the real module against real home-manager is the highest seam because it exercises exactly what a consumer's rebuild would, short of a full system, and it is the only seam that can reach the module at all — the existing fast tier and installed-binary tier operate on the built CLI and never evaluate Nix modules.
|
||||
|
||||
The check builds the home files derivation under several configurations and asserts on the tree it produces:
|
||||
|
||||
- An operator declaring their own skills as an attribute set: the module's Skill lands alongside the operator's, each at its own name.
|
||||
- An operator declaring their own skills as a whole directory: the module's Skill lands alongside the operator's directory contents.
|
||||
This is the case that guards the recursive-install coupling; a regression in how the Claude Code module installs a path-form skills directory fails here as a build-time file collision.
|
||||
- Claude Code disabled: no gitea-axi Skill entry is written.
|
||||
This guards the explicit sibling-enable gate, which is a correctness risk this design introduces rather than one it inherits.
|
||||
- The hook merges into an operator's own SessionStart hook list rather than replacing it.
|
||||
|
||||
Building the home files derivation is sufficient and does not require the Claude Code binary or a running agent; it is the file-linkage layer of home-manager, which is what actually decides whether two declarations collide.
|
||||
|
||||
The byte-identical-generation property — that importing the module without enabling it yields the same generation as never importing it — was considered as a check and rejected as too brittle to assert: home-manager churns generation internals across versions, so the assertion would fail on version drift that is not the module's bug.
|
||||
The narrower disabled-Claude-Code assertion above captures the part of that property which is actually the module's contract.
|
||||
|
||||
### Prior art
|
||||
|
||||
The flake already exposes a checks output and the repository already treats `nix flake check` as the health-check command; this adds a module check alongside the existing package check rather than introducing a new kind of verification surface.
|
||||
The composition assertions mirror the shape of the design-time probes that established the approach: build the home files derivation under a given configuration, then read the skills subtree to confirm which entries are present.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Codex and OpenCode declarative integration.
|
||||
The option surface is shaped to accept sibling toggles for them, but no such toggle is added now, with no second harness module to test against and the imperative `setup hooks` path still available for those harnesses on a declarative host.
|
||||
|
||||
A central aggregator toggle over all harnesses.
|
||||
It is deferred until a second harness exists, and is additive when it arrives.
|
||||
|
||||
Any change to the imperative install path.
|
||||
`setup` and `setup hooks` are unchanged and remain fully supported; the two paths are alternatives, not stages.
|
||||
|
||||
Removing or reworking the `package = null` fallback.
|
||||
It is kept as-is; the upstream suggestion to revisit it was examined and found not to apply under the new gating.
|
||||
|
||||
## Further Notes
|
||||
|
||||
This spec refines the home-manager module portion of the [nix-flake-packaging spec](nix-flake-packaging.md) and reverses specific decisions of [ADR 0020](../adr/0020-home-manager-module-for-declarative-context.md); the reasoning is recorded in [ADR 0021](../adr/0021-per-harness-integration-and-home-file-skill.md).
|
||||
The legs of ADR 0020 that survive — the hook declared through the Claude Code settings option, the module as a content-free wiring layer, the `hook specification` as the hook's single committed source of truth, and the absence of a declarative counterpart for Codex and OpenCode — are unchanged.
|
||||
|
||||
The upstream consumer's own analysis proposed defaulting the context toggles to `programs.claude-code.enable`.
|
||||
That proposal is not adopted: it keys an option's default off sibling config, which the operator disliked and which does not generalise cleanly to multiple harnesses, and its stated diagnosis — that the whole config block sitting inside the module's own `enable` gate is what forces the awkward wiring — was incorrect.
|
||||
The forcing was the assertion, and removing the assertion while reshaping the toggles addresses the friction the analysis identified without the config-derived default.
|
||||
|
||||
INSTALL.md's options table, its `package = null` paragraph, and its paragraph documenting the path-form skills limitation are updated to match: the limitation paragraph is removed, because the limitation no longer exists.
|
||||
@@ -46,16 +46,19 @@ Alongside the flake, the continuous integration workflow moves off end-of-life N
|
||||
|
||||
### Flake surface
|
||||
|
||||
The flake exposes a package, a development shell, and a checks output.
|
||||
The flake exposes a package, a development shell, a checks output, and a home-manager module.
|
||||
It deliberately exposes no NixOS module and no overlay.
|
||||
|
||||
A NixOS module was rejected because gitea-axi is a stateless CLI with no daemon and no system-level configuration; a module would wrap the system package set and nothing else.
|
||||
An overlay was rejected as an interface with no consumer — the maintainer is the sole consumer and already knows the package goes into the system package set.
|
||||
Both remain purely additive to add later.
|
||||
|
||||
A home-manager module is explicitly deferred rather than rejected.
|
||||
The one piece of per-user state is the Agent Skill that the `setup` command installs, and a module managing it declaratively would reintroduce exactly the automatism that ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script.
|
||||
That trade-off deserves its own decision, made with usage evidence.
|
||||
A home-manager module was initially deferred rather than rejected, on the grounds that a module managing the Agent Skill declaratively would reintroduce the automatism ADR 0009 rejected when it chose an explicit `setup` command over a postinstall script, and that the trade-off deserved its own decision made with usage evidence.
|
||||
|
||||
That evidence arrived and reversed the deferral; the flake exposes `homeModules.gitea-axi` as of task 0045.
|
||||
The reasoning is recorded in [ADR 0020](../adr/0020-home-manager-module-for-declarative-context.md).
|
||||
In short: `setup` and `setup hooks` are write-only against files the operator is assumed to own, so an operator whose agent configuration is generated cannot use them at all and hand-copies the Skill instead, where it drifts from the package shipping it.
|
||||
And a module the operator imports and then explicitly enables is not the automatism ADR 0009 guards against — that guard is against installation without intent, and an `enable` option is intent.
|
||||
|
||||
Consumers deduplicate nixpkgs by pointing the flake's nixpkgs input at their own.
|
||||
Consequently the flake's own nixpkgs input governs only standalone builds, the development shell, and flake checks — never the deployed artifact.
|
||||
@@ -186,8 +189,6 @@ Removing the `tea` runtime dependency.
|
||||
It was raised and examined during design: ADR 0002 moved command dispatch off `tea` but retained it for credential discovery, and eliminating it would mean either owning a credential store or reading `tea`'s internal configuration format, which ADR 0001 explicitly rejected because it forfeits OAuth token refresh.
|
||||
Wrapping the binary makes the dependency invisible in practice, which removes most of the practical motivation.
|
||||
|
||||
A home-manager module, and with it any declarative management of the Agent Skill or the session-start hook.
|
||||
|
||||
An overlay output and a NixOS module output.
|
||||
|
||||
A direnv configuration.
|
||||
@@ -200,15 +201,34 @@ The workflow keeps its container-and-npm shape, deliberately preserving the GitH
|
||||
|
||||
## Further Notes
|
||||
|
||||
### Open verification item
|
||||
### 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.
|
||||
Under Nix the absolute path is content-addressed: it changes on every rebuild and is eventually garbage-collected, so a hook recording it would break silently, since 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 this could not be confirmed during design because the dependency was not installed.
|
||||
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 decision is to verify before acting.
|
||||
If the SDK does record 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 would then 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.
|
||||
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
|
||||
|
||||
|
||||
@@ -30,14 +30,75 @@ The allowlist's failure mode is loud but disconnected from its cause, so this sl
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Building the flake's package from a clean checkout produces a runnable `gitea-axi` that prints help and reports its version.
|
||||
- [ ] The store path's version matches the package manifest's version, with the version appearing in no Nix expression.
|
||||
- [ ] Changing a dependency in the lockfile requires no edit to any Nix expression.
|
||||
- [ ] 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.
|
||||
- [ ] Touching a file outside the source allowlist — an ADR, a spec, a benchmark file, prose documentation — does not change the derivation's output path.
|
||||
- [ ] The wrapped binary finds `git` and `tea` on a machine where neither is otherwise installed.
|
||||
- [ ] With the operator's own `git` and `tea` on the search path, those are the ones the binary invokes.
|
||||
- [ ] The fast test tier runs and passes inside the build; a deliberately failing test fails the build.
|
||||
- [ ] ADR 0018 is committed as part of this slice.
|
||||
- [ ] The agent instructions carry a Gotcha about extending the source allowlist for new build-relevant files.
|
||||
- [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`.
|
||||
|
||||
@@ -15,8 +15,57 @@ The check reuses the seam from the parameterized tier; it does not introduce a s
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The Nix build drives the installed binary through the shared installed-binary tier after installation.
|
||||
- [ ] A binary installed without its executable bit fails the build.
|
||||
- [ ] A bundled Agent Skill installed at the wrong location relative to the built output fails the build.
|
||||
- [ ] The post-install phase adds no assertions of its own beyond pointing the shared tier at the installed binary.
|
||||
- [ ] `nix build` still succeeds end to end on a clean checkout.
|
||||
- [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.
|
||||
|
||||
@@ -16,8 +16,38 @@ The full typecheck stays in continuous integration, where it already runs.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `nix develop` yields a shell with Node, `git`, and `tea` available.
|
||||
- [ ] The build, the fast tier, and the benchmark harness's runner all work from inside that shell.
|
||||
- [ ] The shell's Node and the package's Node come from one reference — changing it moves both, and they cannot be set independently.
|
||||
- [ ] `nix flake check` builds the package and runs its tests, and fails when the package fails.
|
||||
- [ ] No per-stage check derivations are added.
|
||||
- [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.
|
||||
|
||||
@@ -24,11 +24,38 @@ The workflow keeps its container-and-npm shape and its GitHub Actions compatibil
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The workflow runs a matrix over the two supported Node versions, and no leg runs an end-of-life Node.
|
||||
- [ ] The manifest's declared engine range names exactly the versions the matrix tests.
|
||||
- [ ] The packaging tier's engine assertion matches the narrowed range and passes.
|
||||
- [ ] The live end-to-end tier runs on the highest leg only.
|
||||
- [ ] The benchmark harness tier runs on every leg, under its own runner configuration.
|
||||
- [ ] The packaging tier runs on the highest leg only.
|
||||
- [ ] The benchmark smoke tier does not run.
|
||||
- [ ] The workflow syntax stays GitHub-Actions-compatible.
|
||||
- [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.
|
||||
|
||||
@@ -14,7 +14,58 @@ Its cost is honest and accepted: because the checks output aliases the package,
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] A distinct job builds the flake on push and on pull request.
|
||||
- [ ] Its failure does not block or fail the other jobs.
|
||||
- [ ] Removing a build-relevant file from the source allowlist makes this job fail.
|
||||
- [ ] The job's cost and its non-gating intent are stated in the workflow so neither reads as an oversight.
|
||||
- [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.
|
||||
|
||||
@@ -19,7 +19,80 @@ Changing the `setup` command to prefer the bare name is explicitly **not** part
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] The SDK's actual hook-path behavior is determined by observation against the installed dependency, not inference from its interface.
|
||||
- [ ] The finding is recorded where a future reader will meet it, so the question is not re-opened from scratch.
|
||||
- [ ] If the absolute path is recorded, the documentation states that hook setup must be re-run after an upgrade.
|
||||
- [ ] No change is made to how the `setup` command constructs the hook in this task.
|
||||
- [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.
|
||||
|
||||
68
.claude/tasks/0043-hook-records-bare-binary-name.md
Normal file
68
.claude/tasks/0043-hook-records-bare-binary-name.md
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
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.
|
||||
83
.claude/tasks/0045-declarative-nix-outputs-and-hm-module.md
Normal file
83
.claude/tasks/0045-declarative-nix-outputs-and-hm-module.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
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.
|
||||
48
.claude/tasks/0046-reshape-hm-module-per-harness-toggle.md
Normal file
48
.claude/tasks/0046-reshape-hm-module-per-harness-toggle.md
Normal file
@@ -0,0 +1,48 @@
|
||||
---
|
||||
spec: hm-module-harness-integration
|
||||
---
|
||||
|
||||
## What to build
|
||||
|
||||
Reshape the home-manager module so that enabling gitea-axi means "install the CLI, always", and the Claude Code agent context follows only when Claude Code is present.
|
||||
|
||||
`programs.gitea-axi.enable` installs the binary unconditionally.
|
||||
The two per-artefact toggles (`skill.enable`, `sessionStartHook.enable`) and the assertion that fired when either was on without `programs.claude-code.enable` are removed, replaced by a single per-harness toggle, `programs.gitea-axi.enableClaudeCodeIntegration`, defaulting to a literal `true`.
|
||||
When the integration toggle is on, both Claude Code artefacts are declared; they land only when `programs.claude-code.enable` is also on, and are silently absent otherwise, with no assertion — matching how home-manager's own `enableBashIntegration`-style toggles behave against a disabled sibling.
|
||||
|
||||
The SessionStart hook stays declared through the Claude Code module's `settings.hooks.SessionStart` option, so it composes with an operator's own hooks and inherits that module's own enable-gate for free.
|
||||
|
||||
The Agent Skill moves off `programs.claude-code.skills` and is written through home-manager's own file mechanism, into Claude Code's skills directory under the `gitea-axi` name, sourced from the package's published Skill.
|
||||
Because it no longer rides the Claude Code module's options, it no longer inherits that module's enable-gate, so the module gates the Skill write explicitly on `programs.claude-code.enable` (in addition to `enable` and the integration toggle).
|
||||
This explicit gate also keeps package realisation lazy — the file mechanism reads the Skill's source path during evaluation, so an ungated write would realise the package on every host.
|
||||
The module comments the resulting asymmetry: the Skill gated by an explicit sibling-enable condition, the hook by the sibling module's own gate.
|
||||
|
||||
Writing the Skill as an ordinary file declaration at its own path (rather than as a contribution to the skills option's type) fixes the path-form collision at its root: it composes with both the attribute-set and whole-directory forms of an operator's own `programs.claude-code.skills`.
|
||||
|
||||
`programs.gitea-axi.package` is unchanged in meaning, including the `package = null` path, which declares the Skill and hook from the default build without installing the binary.
|
||||
|
||||
INSTALL.md is updated to match the new option surface: the options table, the `package = null` paragraph, and the removal of the path-form skills limitation paragraph (the limitation no longer exists).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `programs.gitea-axi.enable = true` puts the binary on the operator's packages regardless of whether Claude Code is enabled.
|
||||
- [x] `skill.enable`, `sessionStartHook.enable`, and the assertion are gone; `programs.gitea-axi.enableClaudeCodeIntegration` exists and defaults to a literal `true` (not derived from `programs.claude-code.enable`).
|
||||
- [x] Enabling gitea-axi on a host with `programs.claude-code.enable = false` evaluates successfully and installs no Skill and no hook — no assertion failure.
|
||||
- [x] With `enableClaudeCodeIntegration` and `programs.claude-code.enable` both on, the Skill is written through home-manager's file mechanism into Claude Code's skills directory under `gitea-axi`, and the hook is declared through `programs.claude-code.settings.hooks.SessionStart`.
|
||||
- [x] The Skill write is gated on `programs.claude-code.enable` explicitly so the package's Skill source is not realised on a host without Claude Code; the hook has no such explicit gate and the asymmetry is commented.
|
||||
- [x] `package = null` still declares the Skill and hook from the default build without adding the binary to the operator's packages.
|
||||
- [x] INSTALL.md's options table and `package = null` paragraph reflect the new surface, and the path-form skills limitation paragraph is removed.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The module reads Claude Code's skills location from `config.programs.claude-code.configDir` rather than hardcoding `.claude/skills`, mirroring the sibling module exactly so the Skill lands beside Claude Code's own skills wherever the operator points that option.
|
||||
Confirmed against the current home-manager `claude-code` module: it lowers a path-form skills directory to a *recursive* `home.file` install (individually-linked files under `configDir/skills`), which is what lets the module's own `configDir/skills/gitea-axi` entry coexist as a sibling.
|
||||
Task 0047 turns that coupling into an automated check.
|
||||
|
||||
The asymmetric gating the spec calls for is expressed structurally: the hook is declared under `enableClaudeCodeIntegration` alone and relies on the Claude Code module's own `mkIf enable` to drop it when disabled; the Skill adds a nested `mkIf claudeCode.enable` because writing through `home.file` does not inherit that gate, and the gate additionally keeps package realisation lazy.
|
||||
The asymmetry is commented in the module.
|
||||
|
||||
Verified by evaluating the real module through `home-manager.lib.homeManagerConfiguration` (the same seam task 0047 automates) under four configurations: both-on (binary + Skill file + hook in settings), Claude Code off (binary only, no Skill, no assertion failure), integration off (binary only, no Skill, no hook), and `package = null` with Claude Code on (Skill declared, gitea-axi absent from `home.packages`).
|
||||
All matched.
|
||||
|
||||
Task 0047 (the flake check and home-manager input) is staged in the same commit as an untracked planning artifact but is implemented separately.
|
||||
49
.claude/tasks/0047-flake-check-hm-module-composition.md
Normal file
49
.claude/tasks/0047-flake-check-hm-module-composition.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
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.
|
||||
@@ -2,7 +2,7 @@
|
||||
# is kept GitHub-Actions-compatible so the GitHub mirror can adopt this file
|
||||
# nearly verbatim (copy it to .github/workflows/).
|
||||
#
|
||||
# The job runs inside a node container so the disposable Gitea service is
|
||||
# The `test` 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
|
||||
# Actions — avoiding the host-vs-service-name networking difference between the
|
||||
# two platforms.
|
||||
@@ -16,7 +16,22 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
container: node:20-bookworm
|
||||
container: node:${{ matrix.node }}-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:
|
||||
gitea:
|
||||
@@ -50,5 +65,64 @@ jobs:
|
||||
- name: Unit and integration tiers (with coverage thresholds)
|
||||
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
|
||||
if: matrix.highest
|
||||
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
2
.gitignore
vendored
@@ -2,3 +2,5 @@ node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
bench/results/
|
||||
result
|
||||
result-*
|
||||
|
||||
54
CLAUDE.md
54
CLAUDE.md
@@ -14,7 +14,7 @@ Run `npm run build` before any live `bench:run` if you want `src/` changes refle
|
||||
|
||||
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>`.
|
||||
It reuses the `tea` login store, which here holds exactly `alexion` and `csv-reviewer` — there is no `axi` profile.
|
||||
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.
|
||||
`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").
|
||||
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).
|
||||
|
||||
@@ -34,3 +34,55 @@ The fixture tier is unaffected — it stubs the endpoint — so this bites only
|
||||
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.
|
||||
|
||||
106
INSTALL.md
Normal file
106
INSTALL.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 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.
|
||||
@@ -41,3 +41,6 @@ Distribution touches no Gitea API, so this smoke test is the distribution analog
|
||||
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.
|
||||
|
||||
104
checks/home-manager-module.nix
Normal file
104
checks/home-manager-module.nix
Normal file
@@ -0,0 +1,104 @@
|
||||
# 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
Normal file
48
flake.lock
generated
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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
Normal file
128
flake.nix
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
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;
|
||||
};
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
75
home-manager-module.nix
Normal file
75
home-manager-module.nix
Normal file
@@ -0,0 +1,75 @@
|
||||
# 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
10
package-lock.json
generated
@@ -17,14 +17,14 @@
|
||||
"gitea-axi": "dist/main.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/node": "^22.20.1",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^5.8.0",
|
||||
"vitest": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^22 || ^24"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@anthropic-ai/claude-agent-sdk": ">=0.3.0"
|
||||
@@ -1050,9 +1050,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
|
||||
"version": "22.20.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": "^22 || ^24"
|
||||
},
|
||||
"bin": {
|
||||
"gitea-axi": "dist/main.js"
|
||||
@@ -35,6 +35,7 @@
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"test:e2e": "vitest run --config vitest.e2e.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:smoke": "vitest run --config vitest.bench-smoke.config.ts",
|
||||
"bench:run": "tsx bench/run.ts",
|
||||
@@ -46,7 +47,7 @@
|
||||
"gitea-js": "^1.23.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/node": "^22.20.1",
|
||||
"@vitest/coverage-v8": "^3.2.7",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^5.8.0",
|
||||
|
||||
211
package.nix
Normal file
211
package.nix
Normal file
@@ -0,0 +1,211 @@
|
||||
{
|
||||
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;
|
||||
};
|
||||
})
|
||||
10
session-start-hook.json
Normal file
10
session-start-hook.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"matcher": "",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "gitea-axi",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,7 +4,13 @@ import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { installSessionStartHooks } from "axi-sdk-js";
|
||||
import type { CliDeps } from "../deps.js";
|
||||
import { axiError } from "../errors.js";
|
||||
import {
|
||||
axiError,
|
||||
isNotWritableError,
|
||||
isNotWritableMessage,
|
||||
unwritableTargetError,
|
||||
} from "../errors.js";
|
||||
import { pruneDuplicateManagedHooks, resolveEntrypointOnPath } from "../hooks.js";
|
||||
import { renderDetail } from "../render.js";
|
||||
|
||||
export const SETUP_HELP = `usage: gitea-axi setup [hooks]
|
||||
@@ -30,6 +36,11 @@ const SKILL_NAME = "gitea-axi";
|
||||
const SKILL_SOURCE = new URL("../../skills/gitea-axi/SKILL.md", 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"];
|
||||
|
||||
/** The home directory, from the injected env first so tests can point at a temp HOME. */
|
||||
@@ -59,15 +70,30 @@ function installSkill(home: string): { skill: string; path: string; status: Skil
|
||||
const targetPath = join(targetDir, "SKILL.md");
|
||||
|
||||
let status: SkillStatus;
|
||||
if (!existsSync(targetPath)) {
|
||||
status = "installed";
|
||||
} else {
|
||||
status = readFileSync(targetPath, "utf8") === source ? "unchanged" : "updated";
|
||||
}
|
||||
try {
|
||||
if (!existsSync(targetPath)) {
|
||||
status = "installed";
|
||||
} else {
|
||||
status = readFileSync(targetPath, "utf8") === source ? "unchanged" : "updated";
|
||||
}
|
||||
|
||||
if (status !== "unchanged") {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
writeFileSync(targetPath, source, "utf8");
|
||||
// A target that already holds these bytes needs no write, so its being
|
||||
// read-only is beside the point and the run succeeds.
|
||||
if (status !== "unchanged") {
|
||||
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 };
|
||||
@@ -85,23 +111,129 @@ 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> {
|
||||
const home = resolveHome(deps);
|
||||
const errors: string[] = [];
|
||||
const errors: TargetFailure[] = [];
|
||||
|
||||
// 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({
|
||||
marker: SKILL_NAME,
|
||||
binaryNames: [SKILL_NAME],
|
||||
execPath: EXEC_PATH,
|
||||
execPath,
|
||||
homeDir: home,
|
||||
// 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
|
||||
// inferred dist/bin/<name>.js entrypoint layout gitea-axi does not use).
|
||||
shouldInstall: () => true,
|
||||
onError: (message) => errors.push(message),
|
||||
onError: (message) => errors.push(parseReportedFailure(message)),
|
||||
});
|
||||
|
||||
pruneHookSettingsFiles(home, command, errors);
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw axiError(`Failed to install session hooks: ${errors.join("; ")}`, "UNKNOWN");
|
||||
// A read-only target is the most actionable thing that can be in here — it
|
||||
// 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({
|
||||
|
||||
@@ -10,6 +10,7 @@ export type AxiErrorCode =
|
||||
| "TEA_NOT_INSTALLED"
|
||||
| "VALIDATION_ERROR"
|
||||
| "GIT_ERROR"
|
||||
| "TARGET_NOT_WRITABLE"
|
||||
| "UNKNOWN";
|
||||
|
||||
export function axiError(
|
||||
@@ -20,6 +21,50 @@ export function axiError(
|
||||
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 {
|
||||
status: number;
|
||||
url: string;
|
||||
|
||||
217
src/hooks.ts
Normal file
217
src/hooks.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
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 };
|
||||
}
|
||||
263
test/hooks.test.ts
Normal file
263
test/hooks.test.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -63,8 +63,13 @@ describe("npm distribution artifact", () => {
|
||||
|
||||
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(/20/);
|
||||
expect(engines?.node).toMatch(/22/);
|
||||
expect(engines?.node).toMatch(/24/);
|
||||
expect(engines?.node).not.toMatch(/20/);
|
||||
|
||||
expect(packedManifest.type).toBe("module");
|
||||
});
|
||||
|
||||
@@ -1,17 +1,147 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { delimiter, isAbsolute, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runCliTest } from "./harness.js";
|
||||
import { type CliResult, runCliTest } from "./harness.js";
|
||||
|
||||
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(() => {
|
||||
if (tempHome) {
|
||||
// Not every test creates a HOME, so this may be a directory an earlier test
|
||||
// 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 });
|
||||
}
|
||||
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", () => {
|
||||
it("installs the skill and is idempotent: installed -> unchanged -> updated", async () => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
|
||||
@@ -37,6 +167,60 @@ describe("setup", () => {
|
||||
expect(third.stdout).toContain("status: updated");
|
||||
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", () => {
|
||||
@@ -81,10 +265,77 @@ describe("setup hooks", () => {
|
||||
const second = await runCliTest(["setup", "hooks"], { env: { HOME: tempHome } });
|
||||
expect(second.exitCode).toBe(0);
|
||||
|
||||
const claudeSettingsPath = join(tempHome, ".claude", "settings.json");
|
||||
const claudeSettings = JSON.parse(readFileSync(claudeSettingsPath, "utf8"));
|
||||
expect(claudeSettings.hooks.SessionStart).toHaveLength(1);
|
||||
expect(claudeSettings.hooks.SessionStart[0].hooks).toHaveLength(1);
|
||||
const settings = claudeSettings(tempHome);
|
||||
expect(settings.hooks.SessionStart).toHaveLength(1);
|
||||
expect(settings.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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user