feat(nix): expose declarative outputs and a home-manager module (task 0045)
All checks were successful
CI / test (22) (pull_request) Successful in 49s
CI / test (true, 24) (pull_request) Successful in 1m5s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m5s
CI / flake (push) Successful in 3s
All checks were successful
CI / test (22) (pull_request) Successful in 49s
CI / test (true, 24) (pull_request) Successful in 1m5s
CI / flake (pull_request) Successful in 3s
CI / test (22) (push) Successful in 49s
CI / test (true, 24) (push) Successful in 1m5s
CI / flake (push) Successful in 3s
Let a Nix configuration declare gitea-axi's ambient context instead of running a command that writes it. `setup` and `setup hooks` are write-only against files the operator is assumed to own, so an operator whose agent configuration is generated cannot use them at all. The package installs the bundled Agent Skill to share/gitea-axi/skills and publishes it as `passthru.skill`, alongside `passthru.sessionStartHook` read from `session-start-hook.json` — a committed declaration the fast tier reads too, so a test drives `setup hooks` and asserts the two agree. On top of that, `homeModules.gitea-axi` declares both from those attributes through home-manager's own Claude Code options, so an operator's existing skills and SessionStart hooks compose rather than collide. Importing it without enabling it yields a byte-identical generation. The spec's Out of Scope entry deferring a home-manager module is deleted; ADR 0020 records the reversal, and INSTALL.md describes both paths.
This commit was merged in pull request #54.
This commit is contained in:
@@ -91,9 +91,52 @@ function writeFakeBinary(dir: string, contents: string): string {
|
||||
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 = JSON.parse(readFileSync(join(home, ".claude", "settings.json"), "utf8"));
|
||||
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;
|
||||
@@ -222,31 +265,27 @@ 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-"));
|
||||
|
||||
// A wrapper-based install in miniature — a script that *invokes* the
|
||||
// entrypoint, so its realpath is itself and the SDK could never match it
|
||||
// from the entrypoint's side. This is the shape Nix installs.
|
||||
const binDir = writeFakeBinary(
|
||||
join(tempHome, "wrapper"),
|
||||
`#!/bin/sh\nexec node ${entrypointPath()} "$@"\n`,
|
||||
);
|
||||
|
||||
const { exitCode } = await withPath(`${binDir}${delimiter}${process.env.PATH ?? ""}`, () =>
|
||||
runCliTest(["setup", "hooks"], { env: { HOME: tempHome } }),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user