feat: add setup skill/hooks and update shadow (task 0018)
Distribute gitea-axi's ambient context via explicit user actions (ADRs 0009, 0013), with no postinstall script: - Bundle the Agent Skill markdown at skills/gitea-axi/SKILL.md (a minimal pointer, not a command reference) and ship it via package.json files. - Add `setup`, which installs the skill into ~/.claude/skills/ idempotently (installed/updated/unchanged). - Add `setup hooks`, which registers a SessionStart hook running the bare dashboard for Claude Code, Codex, and OpenCode via the SDK's installSessionStartHooks(), updating managed entries in place. - Shadow the SDK's built-in `update` so it fails with VALIDATION_ERROR and points at the npm update command, keeping the ten-code error list intact. Integration tests drive all three at the CLI seam against a temporary HOME; these commands make no Gitea API calls, so there is no live-Gitea e2e case.
This commit was merged in pull request #19.
This commit is contained in:
110
test/setup.test.ts
Normal file
110
test/setup.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { runCliTest } from "./harness.js";
|
||||
|
||||
let tempHome: string;
|
||||
|
||||
afterEach(() => {
|
||||
if (tempHome) {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
it("installs the skill and is idempotent: installed -> unchanged -> updated", async () => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
|
||||
const installedPath = join(tempHome, ".claude", "skills", "gitea-axi", "SKILL.md");
|
||||
|
||||
// 1. First run on a clean HOME → installed, file now on disk.
|
||||
const first = await runCliTest(["setup"], { env: { HOME: tempHome } });
|
||||
expect(first.exitCode).toBe(0);
|
||||
expect(first.stdout).toContain("setup:");
|
||||
expect(first.stdout).toContain("skill: gitea-axi");
|
||||
expect(first.stdout).toContain("status: installed");
|
||||
expect(existsSync(installedPath)).toBe(true);
|
||||
|
||||
// 2. Immediate second run, nothing changed → unchanged, still exit 0.
|
||||
const second = await runCliTest(["setup"], { env: { HOME: tempHome } });
|
||||
expect(second.exitCode).toBe(0);
|
||||
expect(second.stdout).toContain("status: unchanged");
|
||||
|
||||
// 3. Tamper with the installed file → next run reports updated and restores it.
|
||||
writeFileSync(installedPath, "tampered");
|
||||
const third = await runCliTest(["setup"], { env: { HOME: tempHome } });
|
||||
expect(third.exitCode).toBe(0);
|
||||
expect(third.stdout).toContain("status: updated");
|
||||
expect(readFileSync(installedPath, "utf8")).not.toBe("tampered");
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup hooks", () => {
|
||||
it("registers the SessionStart hook for all three integrations and reports a restart hint", async () => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["setup", "hooks"], {
|
||||
env: { HOME: tempHome },
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("hooks:");
|
||||
expect(stdout).toContain("status: installed");
|
||||
expect(stdout).toContain("Claude Code");
|
||||
expect(stdout).toContain("Codex");
|
||||
expect(stdout).toContain("OpenCode");
|
||||
expect(stdout.toLowerCase()).toContain("restart");
|
||||
|
||||
// Claude Code: settings.json with a non-empty SessionStart hook array.
|
||||
const claudeSettingsPath = join(tempHome, ".claude", "settings.json");
|
||||
expect(existsSync(claudeSettingsPath)).toBe(true);
|
||||
const claudeSettings = JSON.parse(readFileSync(claudeSettingsPath, "utf8"));
|
||||
expect(Array.isArray(claudeSettings.hooks?.SessionStart)).toBe(true);
|
||||
expect(claudeSettings.hooks.SessionStart.length).toBeGreaterThan(0);
|
||||
|
||||
// Codex: hooks.json and config.toml.
|
||||
expect(existsSync(join(tempHome, ".codex", "hooks.json"))).toBe(true);
|
||||
expect(existsSync(join(tempHome, ".codex", "config.toml"))).toBe(true);
|
||||
|
||||
// OpenCode: plugin file.
|
||||
expect(
|
||||
existsSync(join(tempHome, ".config", "opencode", "plugins", "axi-gitea-axi.js")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("updates the managed entry in place on re-run rather than appending a duplicate", async () => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
|
||||
|
||||
const first = await runCliTest(["setup", "hooks"], { env: { HOME: tempHome } });
|
||||
expect(first.exitCode).toBe(0);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setup dispatch", () => {
|
||||
it("prints setup usage for setup --help", async () => {
|
||||
const { stdout, exitCode } = await runCliTest(["setup", "--help"]);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(stdout).toContain("usage: gitea-axi setup");
|
||||
});
|
||||
|
||||
it("rejects an unknown setup subcommand with a VALIDATION_ERROR", async () => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), "gitea-axi-setup-"));
|
||||
|
||||
const { stdout, exitCode } = await runCliTest(["setup", "bogus"], {
|
||||
env: { HOME: tempHome },
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(2);
|
||||
expect(stdout).toContain("code: VALIDATION_ERROR");
|
||||
expect(stdout).toContain("bogus");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user