feat: add setup skill/hooks and update shadow (task 0018)
All checks were successful
CI / test (pull_request) Successful in 52s
CI / test (push) Successful in 51s

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:
2026-07-14 11:26:56 -04:00
parent 5b6a9b4917
commit be7226b321
9 changed files with 402 additions and 6 deletions

View File

@@ -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 <OWNER/NAME> Override the repository detected from the git origin remote
@@ -119,6 +122,9 @@ export async function runCli(options: RunCliOptions): Promise<number> {
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,

140
src/commands/setup.ts Normal file
View File

@@ -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<string> {
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<string> {
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/<name>.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<string> => {
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",
]);
};
}

20
src/commands/update.ts Normal file
View File

@@ -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<string> => {
throw axiError(
"gitea-axi does not self-update",
"VALIDATION_ERROR",
["Run `npm install -g gitea-axi@latest` to update"],
);
};
}