diff --git a/.claude/tasks/0018-setup-skill-and-hooks.md b/.claude/tasks/0018-setup-skill-and-hooks.md index 17da3a2..5d51298 100644 --- a/.claude/tasks/0018-setup-skill-and-hooks.md +++ b/.claude/tasks/0018-setup-skill-and-hooks.md @@ -15,8 +15,25 @@ These commands touch only the local filesystem, not the Gitea API, so their real ## Acceptance criteria -- [ ] The Agent Skill markdown is bundled in the package and follows the minimal-pointer shape (trigger description, when-to-use, command-group one-liners, discovery pointers) -- [ ] `gitea-axi setup` installs the skill and outputs `setup: { skill, path, status }`; re-running reports `updated` or `unchanged` rather than failing -- [ ] `gitea-axi setup hooks` registers the SessionStart hook for all three integrations via the SDK and outputs the `hooks:` block with a restart help line; managed entries are updated in place on re-run -- [ ] `gitea-axi update` fails with `VALIDATION_ERROR` (exit 2) and the npm update help line; the SDK's `UPDATE_ERROR` never surfaces -- [ ] Integration tests drive `setup`, `setup hooks`, and `update` at the CLI seam against a temporary HOME, asserting the real skill file and the three managed hook configs are written and updated in place, and covering the setup idempotency states and the update shadow (this temp-HOME filesystem tier is the applicable real-integration test; these commands make no Gitea API calls, so there is no live-Gitea e2e case) +- [x] The Agent Skill markdown is bundled in the package and follows the minimal-pointer shape (trigger description, when-to-use, command-group one-liners, discovery pointers) +- [x] `gitea-axi setup` installs the skill and outputs `setup: { skill, path, status }`; re-running reports `updated` or `unchanged` rather than failing +- [x] `gitea-axi setup hooks` registers the SessionStart hook for all three integrations via the SDK and outputs the `hooks:` block with a restart help line; managed entries are updated in place on re-run +- [x] `gitea-axi update` fails with `VALIDATION_ERROR` (exit 2) and the npm update help line; the SDK's `UPDATE_ERROR` never surfaces +- [x] Integration tests drive `setup`, `setup hooks`, and `update` at the CLI seam against a temporary HOME, asserting the real skill file and the three managed hook configs are written and updated in place, and covering the setup idempotency states and the update shadow (this temp-HOME filesystem tier is the applicable real-integration test; these commands make no Gitea API calls, so there is no live-Gitea e2e case) + +## Implementation Notes + +The bundled skill lives at `skills/gitea-axi/SKILL.md` and ships via a new `"skills"` entry in `package.json`'s `files`. +`src/commands/setup.ts` resolves both the skill source and the CLI entrypoint relative to `import.meta.url` (`../../skills/...` and `../main.js`), so they track the install tree regardless of how the process was launched; the dist layout mirrors `src/`, so the same relative paths resolve under both the built output and the source-run test tier. +`setup` reads `HOME` from the injected env (falling back to `USERPROFILE`, then `os.homedir()`), which is what lets the integration tests drive it against a temporary HOME through the ordinary CLI seam. + +`setup hooks` calls the SDK's `installSessionStartHooks()` with an explicit `marker`/`binaryNames`/`execPath`/`homeDir` and `shouldInstall: () => true`. +The unconditional install is deliberate: the SDK's auto-install safety gate is tuned for an inferred `dist/bin/.js` entrypoint, which gitea-axi does not use (its entrypoint is `dist/main.js`), so the default gate would refuse to install. `setup hooks` is an explicit user action, so gating it on the entrypoint layout is inappropriate. +Hook errors from the SDK are collected via `onError` and, if any occur, surfaced as a single thrown error rather than silently swallowed. + +`update` is registered as a normal command in `cli.ts`, which shadows the SDK's reserved built-in (the SDK only runs its own `update` when the tool has not registered one); the handler always throws `VALIDATION_ERROR` with the npm-update help line, so the SDK's `UPDATE_ERROR` can never surface. + +Deviations / follow-ups: + +- **Process deviation:** the implementation was written before the tests this cycle, then tests were authored test-first-style by a sub-agent from the public interface only. The RED step therefore did not produce genuine failures (the code already existed); the tests were confirmed green instead. Assertions were derived from the spec/ADRs as independent literals, not from observed output. +- **Follow-up (out of scope here):** ADR 0009's consequences mention the dashboard suggestion table hinting at `setup` for discoverability. That hint is not among this task's acceptance criteria and would touch task 0017's `dashboard.ts`, so it is left as a follow-up; `setup` is currently discoverable via the top-level `--help`. diff --git a/package.json b/package.json index 33b8045..ef7aa50 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "gitea-axi": "dist/main.js" }, "files": [ - "dist" + "dist", + "skills" ], "scripts": { "build": "tsc -p tsconfig.build.json", diff --git a/skills/gitea-axi/SKILL.md b/skills/gitea-axi/SKILL.md new file mode 100644 index 0000000..fcbf6a9 --- /dev/null +++ b/skills/gitea-axi/SKILL.md @@ -0,0 +1,35 @@ +--- +name: gitea-axi +description: Use when working with a Gitea repository's issues, pull requests, labels, reviews, comments, or milestones — listing, viewing, creating, editing, commenting, reviewing, or merging on a Gitea host such as git.alexion.dev. Prefer this over the `tea` CLI, raw Gitea API calls, or improvised `git` commands for issue/PR/label work. +--- + +# gitea-axi + +`gitea-axi` is an agent-ergonomic CLI for a Gitea repository's issues and pull requests. +Its output is compact TOON built for another program to read, and its errors are structured with actionable suggestions. + +## When to use it + +Reach for `gitea-axi` whenever a task touches a Gitea repository's issues, pull requests, labels, or reviews. + +- **Over `tea`:** `gitea-axi` returns structured output and typed errors instead of human-formatted tables, and it defaults the repository and login from the local checkout. +- **Over raw Gitea API calls:** it handles auth, pagination, name-to-ID resolution, and review-decision aggregation for you, so you do not hand-roll HTTP. +- **Over improvised `git`:** for anything about issues or pull requests as entities (state, reviews, labels, comments) rather than local commits and branches. + +Run it inside a Gitea checkout, or pass `-R OWNER/NAME` (and `--login `) to target a repository explicitly. + +## Command groups + +- `issue` — list, view, create, comment on, edit, close/reopen, pin, and link issues. +- `pr` — create, view, comment on, edit, review, merge, check out, diff, and inspect the checks of pull requests. +- `label` — list, create, edit, and delete labels. +- `search` — full-text search across issues and pull requests. +- `setup` — install this skill (`setup`) and, opt-in, the SessionStart dashboard hook (`setup hooks`). + +## Discovery + +This skill is a pointer, not a command reference — the CLI is the single source of truth for its own interface. + +- Run `gitea-axi` with no arguments for the repository dashboard (open issues and pull requests). + Add `--full` for the open-PR table and issue counts by label. +- Run `gitea-axi --help` (or `gitea-axi --help`) for the exact flags of any command. diff --git a/src/cli.ts b/src/cli.ts index ba3a162..14f2093 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -5,6 +5,8 @@ import { issueCommand } from "./commands/issue.js"; import { labelCommand } from "./commands/label.js"; import { prCommand } from "./commands/pr.js"; import { searchCommand } from "./commands/search.js"; +import { setupCommand } from "./commands/setup.js"; +import { updateCommand } from "./commands/update.js"; import type { CliDeps, GlobalFlags } from "./deps.js"; import { consumeFlagValue, splitFlag } from "./flags.js"; import { renderErrorOutput } from "./render.js"; @@ -28,6 +30,7 @@ commands: label create Create a label search issues Full-text search for issues in the current repository search prs Full-text search for pull requests in the current repository + setup Install the bundled Agent Skill (setup hooks adds the session-start hook) global flags: -R, --repo Override the repository detected from the git origin remote @@ -119,6 +122,9 @@ export async function runCli(options: RunCliOptions): Promise { pr: prCommand(deps), label: labelCommand(deps), search: searchCommand(deps), + setup: setupCommand(deps), + // Shadow the SDK's built-in `update` self-update command (ADR 0013). + update: updateCommand(deps), }, home: dashboardCommand(deps, full), stdout: options.stdout, diff --git a/src/commands/setup.ts b/src/commands/setup.ts new file mode 100644 index 0000000..81290b9 --- /dev/null +++ b/src/commands/setup.ts @@ -0,0 +1,140 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +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 { renderDetail } from "../render.js"; + +export const SETUP_HELP = `usage: gitea-axi setup [hooks] + +Install gitea-axi's ambient context for agent sessions. + + setup Install the bundled Agent Skill into ~/.claude/skills/ + setup hooks Also install a SessionStart hook that runs the dashboard at + session start (Claude Code, Codex, and OpenCode) + +Both are idempotent: re-running updates the managed files in place rather than +failing. There is no postinstall script — installation is always explicit. + +flags: + --help Show this help +`; + +// The bundled Agent Skill and the CLI entrypoint are resolved relative to this +// module so they track the install tree regardless of how the process was +// launched. From dist/commands/setup.js these are ../../skills/... and +// ../main.js (the dist layout mirrors src/, so the same paths resolve in tests). +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)); + +const HOOK_INTEGRATIONS = ["Claude Code", "Codex", "OpenCode"]; + +/** The home directory, from the injected env first so tests can point at a temp HOME. */ +function resolveHome(deps: CliDeps): string { + return deps.env.HOME ?? deps.env.USERPROFILE ?? homedir(); +} + +/** Collapse a leading home directory to `~` for readable output. */ +function collapseHome(path: string, home: string): string { + if (path === home) { + return "~"; + } + const prefix = home.endsWith("/") ? home : `${home}/`; + return path.startsWith(prefix) ? `~/${path.slice(prefix.length)}` : path; +} + +type SkillStatus = "installed" | "updated" | "unchanged"; + +/** + * Copy the bundled skill into `~/.claude/skills/gitea-axi/SKILL.md`, idempotently. + * A missing target is `installed`, a byte-identical one is `unchanged`, and a + * differing one is overwritten and reported `updated` — re-running never fails. + */ +function installSkill(home: string): { skill: string; path: string; status: SkillStatus } { + const source = readFileSync(SKILL_SOURCE, "utf8"); + const targetDir = join(home, ".claude", "skills", SKILL_NAME); + const targetPath = join(targetDir, "SKILL.md"); + + let status: SkillStatus; + if (!existsSync(targetPath)) { + status = "installed"; + } else { + status = readFileSync(targetPath, "utf8") === source ? "unchanged" : "updated"; + } + + if (status !== "unchanged") { + mkdirSync(targetDir, { recursive: true }); + writeFileSync(targetPath, source, "utf8"); + } + + return { skill: SKILL_NAME, path: collapseHome(targetPath, home), status }; +} + +async function setupSkill(deps: CliDeps): Promise { + const home = resolveHome(deps); + const result = installSkill(home); + return renderDetail({ + noun: "setup", + item: result, + help: [ + "Run `gitea-axi setup hooks` to also inject the dashboard at session start", + ], + }); +} + +async function setupHooks(deps: CliDeps): Promise { + const home = resolveHome(deps); + const errors: string[] = []; + installSessionStartHooks({ + marker: SKILL_NAME, + binaryNames: [SKILL_NAME], + execPath: EXEC_PATH, + 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/.js entrypoint layout gitea-axi does not use). + shouldInstall: () => true, + onError: (message) => errors.push(message), + }); + + if (errors.length > 0) { + throw axiError(`Failed to install session hooks: ${errors.join("; ")}`, "UNKNOWN"); + } + + return renderDetail({ + noun: "hooks", + item: { status: "installed", integrations: HOOK_INTEGRATIONS }, + help: ["Restart your agent session for the hook to take effect"], + }); +} + +/** + * The `setup` command (ADR 0009): the bundled Agent Skill by default, the opt-in + * SessionStart hook under `setup hooks`. These touch only the local filesystem — + * no repository context is resolved and no Gitea request is made. + */ +export function setupCommand(deps: CliDeps) { + return async (args: string[]): Promise => { + if (args.includes("--help")) { + return SETUP_HELP; + } + const [subcommand, ...rest] = args; + if (subcommand === undefined) { + return setupSkill(deps); + } + if (subcommand === "hooks") { + if (rest.length > 0) { + throw axiError(`Unexpected argument: ${rest[0]}`, "VALIDATION_ERROR", [ + "Run `gitea-axi setup hooks`", + ]); + } + return setupHooks(deps); + } + throw axiError(`Unknown setup command: ${subcommand}`, "VALIDATION_ERROR", [ + "Run `gitea-axi setup --help` to see available setup commands", + ]); + }; +} diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..c2452d2 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,20 @@ +import type { CliDeps } from "../deps.js"; +import { axiError } from "../errors.js"; + +/** + * Shadow axi-sdk-js's built-in `update` self-update command (ADR 0013). The + * built-in would query npmjs.org and rewrite the install under its own + * `UPDATE_ERROR` code — an unspecced command and an eleventh error code on top + * of the documented ten. This handler rejects the command with a + * `VALIDATION_ERROR` and points at the explicit npm update instead, so the + * SDK's `UPDATE_ERROR` never surfaces and the command surface stays as specced. + */ +export function updateCommand(_deps: CliDeps) { + return async (): Promise => { + throw axiError( + "gitea-axi does not self-update", + "VALIDATION_ERROR", + ["Run `npm install -g gitea-axi@latest` to update"], + ); + }; +} diff --git a/test/setup.test.ts b/test/setup.test.ts new file mode 100644 index 0000000..a4c7dad --- /dev/null +++ b/test/setup.test.ts @@ -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"); + }); +}); diff --git a/test/skill.test.ts b/test/skill.test.ts new file mode 100644 index 0000000..bde0c3a --- /dev/null +++ b/test/skill.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const skill = readFileSync( + new URL("../skills/gitea-axi/SKILL.md", import.meta.url), + "utf8", +); +const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +) as { files?: string[] }; + +describe("bundled Agent Skill markdown", () => { + it("is published in the package", () => { + expect(packageJson.files).toContain("skills"); + }); + + it("has frontmatter with a description that triggers on Gitea issue/PR work", () => { + // Frontmatter is a leading `---`-delimited block. + const match = skill.match(/^---\n([\s\S]*?)\n---/); + const frontmatter = match?.[1]; + expect(frontmatter, "expected YAML frontmatter delimited by ---").toBeDefined(); + expect(frontmatter).toMatch(/description:/i); + + const description = frontmatter!.toLowerCase(); + expect(description).toContain("gitea"); + expect(description).toContain("issue"); + expect(description).toContain("pull request"); + }); + + it("says to prefer gitea-axi over tea, raw API, and git", () => { + const body = skill.toLowerCase(); + expect(body).toContain("tea"); + expect(body).toContain("api"); + expect(body).toContain("git"); + }); + + it("references each command group as a one-liner", () => { + const body = skill.toLowerCase(); + for (const group of ["issue", "pr", "label", "search", "setup"]) { + expect(body, `expected the skill to mention the ${group} command group`).toContain( + group, + ); + } + }); + + it("points at the bare dashboard and per-command help for discovery", () => { + const body = skill.toLowerCase(); + // Bare dashboard: running the binary with no arguments. + expect(body).toContain("no argument"); + expect(body).toContain("dashboard"); + // Per-command help. + expect(body).toContain("--help"); + }); +}); diff --git a/test/update.test.ts b/test/update.test.ts new file mode 100644 index 0000000..59c7189 --- /dev/null +++ b/test/update.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { runCliTest } from "./harness.js"; + +describe("update (shadowed self-update)", () => { + it("refuses to self-update: VALIDATION_ERROR, exit 2, npm help line, no SDK UPDATE_ERROR", async () => { + const { stdout, exitCode } = await runCliTest(["update"]); + + expect(exitCode).toBe(2); + expect(stdout).toContain("code: VALIDATION_ERROR"); + expect(stdout).toContain("npm install -g gitea-axi@latest"); + expect(stdout).not.toContain("UPDATE_ERROR"); + }); +});