## Problem Statement Coding agents that need to drive a Gitea-hosted workflow (issues, pull requests, labels) today have two poor options. The official `tea` CLI is human-oriented: it has no token-efficiency, no contextual guidance, and no agent-facing error conventions. Gitea's MCP servers expose the full API surface (dozens of tools) rather than being tuned for token or turn efficiency. There is no Gitea-focused tool built to the same "agent ergonomics" standard that `gh-axi` established for GitHub. ## Solution Build `gitea-axi`: a thin TypeScript CLI that calls the Gitea REST API directly via `gitea-js`, reshaping its output according to all 10 AXI (Agent eXperience Interface) principles. The canonical principle text at https://axi.md/ is the design authority. The `gh-axi` reference implementation is a non-binding shape reference for concrete interface details (block names, flag names, field extractors), departed from freely — with each deliberate departure documented in place. It gives coding agents an ergonomic, low-token way to drive issues and pull requests on any Gitea instance. It ships as both an installable npm CLI and a bundled Agent Skill, so any agent session can adopt it with one install step. ## User Stories 1. As a coding agent, I want to create a Gitea issue with a title, body, and labels, so that I can record work items for later retrieval. 2. As a coding agent, I want to find issues by label and state, so that I can locate relevant work without already knowing its issue number. 3. As a coding agent, I want to read an issue's full body, labels, and comments, so that I can load its context into a session. 4. As a coding agent, I want to add and remove labels on an existing issue, so that I can reflect state transitions as work progresses. 5. As a coding agent, I want to create a pull request from the current branch, so that completed work becomes reviewable. 6. As a coding agent, I want to fetch a pull request's metadata and diff, so that review tooling can operate on it without re-deriving it from git. 7. As a coding agent, I want to post a comment on a pull request, so that findings or notes are visible as a permanent reference on the PR itself. 8. As a coding agent, I want command output in TOON format with minimal default fields and truncated large fields, so that repeated calls across a long-running session don't consume excessive context. 9. As a coding agent, I want pre-computed aggregates in list and read output, so that I don't need follow-up calls just to derive obvious derived fields. 10. As a coding agent, I want explicit empty-state output when a query returns nothing, so that "no results" is never ambiguous with an error or a hang. 11. As a coding agent, I want structured errors with actionable suggestions and meaningful exit codes instead of prose failures, so that I can self-correct without the operator's help. 12. As a coding agent, I want mutations to be idempotent and to never prompt interactively, so that unattended, scripted use never stalls or double-applies. 13. As a coding agent, I want contextual next-step suggestions appended after output, so that I know what to call next without being taught the tool from scratch every session. 14. As a coding agent, I want a consistent per-subcommand `--help`, so that I can discover the interface on demand rather than needing it pre-loaded in context. 15. As an operator, I want `gitea-axi` run with no arguments to show live, actionable repository state instead of a help screen, so that I get immediate value without memorizing flags. 16. As an operator, I want `gitea-axi` to reuse my existing `tea` login configuration, so that I don't manage a second set of credentials. 17. As an operator, I want `gitea-axi`'s command surface to stay generic, with no workflow-specific behavior baked in, so that it's useful across different projects without code changes. 18. As an operator, I want `gitea-axi` published to npm and as an installable Agent Skill, so that I (and others) can adopt it with a single install command plus an explicit one-time `gitea-axi setup`. 19. As an operator, I want an optional `gitea-axi setup hooks` command that injects the dashboard into my agent sessions at session start, so that my agent begins each session already aware of the repository's live state. ## Implementation Decisions ### Language and Runtime 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 Call the Gitea REST API directly via `gitea-js` (the official TypeScript client generated from Gitea's OpenAPI spec). This is not a subprocess wrapper — it is a direct HTTP API client. Tea was evaluated as a subprocess target and rejected during design (see ADR 0002). The short version: tea's create commands have no `--output json` flag, its PR list has no head-branch filter, it exposes no review or total counts in JSON, and diff content requires a direct HTTP GET regardless — making a tea wrapper a patchwork of subprocess calls and text parsing rather than a clean pipeline. `gitea-js` gives clean typed responses, `X-Total-Count` headers for pagination, review counts, and immediate JSON from create operations. ### Auth gitea-axi reads credentials from tea's login store via `tea login list --output json`. It requires `tea` to be installed and at least one login configured via `tea login add`. At startup, it detects the current repository's Gitea hostname (see Repository Context Detection), finds the matching login entry, and extracts the token for all subsequent API calls. When multiple logins match the hostname: if tea's default login is among them it is used; otherwise `VALIDATION_ERROR` listing the matching profile names, with help to pass `--login ` — an arbitrary identity is never picked silently. Tea is used only for credential discovery — no commands are dispatched through the tea subprocess. ### Command Surface #### Dashboard `gitea-axi` (no args): a two-tier home view showing live repository state, preceded by the `bin:` + `description:` header from `axi-sdk-js`, followed by next-step suggestions (see ADR 0012). The short tier (no flags — also what the SessionStart hook runs) matches gh-axi's home shape: up to 3 open issues (`number`, `title`, `state`, `author`) and up to 3 open PRs (`number`, `title`, `author`, `review`), fetched in parallel with `limit=3`. The `review` field is computed client-side via the same parallel review fetch as `pr list` (see ADR 0006) — at most 3 extra HTTP calls. The short tier's `help:` block always includes a hint pointing at `--full`. The full tier (`gitea-axi --full`): open PRs as a TOON table and open issue counts grouped by label (a label→count record). Full-tier PR table default fields: `number`, `title`, `author` (plucked from `user.login`), `labels` (joined label names), `review` (same client-side computation). The full-tier PR table is capped at 20 rows with a standard count line (`count: 20 of T total`). Full-tier issue aggregation fetches all pages of open issues up to a hard cap of 1000 (page size 50, 20 pages max); if the cap is hit, label counts are suffixed with `+`. `--full` here selects the full tier — an intentional overload of the flag that elsewhere suppresses truncation. Output blocks (both tiers): a `repo: owner/name` line, then `prs:` and `issues:`. Empty states are explicit and match gh-axi's home: `prs: 0 open` / `issues: 0 open` (raw strings; list commands keep their `[0]: (none)` convention). Issue fetching passes `type=issues` so PRs never pollute the issue block (see Issue/PR Type Guard). Outside a recognizable Gitea repo the dashboard errors with `REPO_NOT_FOUND` (help: use `-R` + `--login`) — login selection requires a hostname. This holds even when invoked by the SessionStart hook; error noise in non-Gitea sessions is an accepted consequence (see ADR 0009). #### Issue Commands **`issue list [flags]`** `--state ` (default open); `--label ` (API-supported — Gitea issue list accepts comma-separated label names); `--assignee ` (API-supported — maps to `assigned_by` param); `--author ` (API-supported — maps to `created_by` param); `--milestone ` (API-supported — maps to `milestones` param); `--sort ` (client-side — Gitea issue list has no sort param; always descending, matching gh-axi); `--limit ` (default 30); `--fields `. `--search` is explicitly forbidden (VALIDATION_ERROR; help: `` Use `gitea-axi search issues ""` for full-text search ``). Always passes `type=issues` — Gitea's issue endpoints also serve PRs, which must never appear in issue lists (see Issue/PR Type Guard). Client-side `--sort` reorders without changing membership, so the standard `count: N of T total` line is kept (see ADR 0005); full pagination still precedes sorting. Default output fields (matching gh-axi): `number`, `title`, `state` (lowercased), `author` (plucked from `user.login`), `created` (relative time). Extra fields via `--fields`: `body` (truncated at 500, exactly as `issue view` presents it; `--full` shows it raw), `closedAt` (relative time, as `closed_at`), `labels` (joined names), `milestone` (title), `updatedAt` (relative time, as `updated_at`), `url`. No `type` field in output — Gitea has no issue types. **`issue view [flags]`** `--comments` (expand full comments — renders all comments with no count cap, each body truncated at 800 chars with cleanBody applied); `--full` (suppress all truncation in the output — issue body and comment bodies alike). Default output fields (matching gh-axi, minus `type`): `number`, `title`, `state`, `author`, `created`, `body` (truncated at 500), plus `comment_count`. Type guard: if `` is a pull request, fails with `VALIDATION_ERROR` ("issue #N is a pull request") and a `pr view ` help line (see Issue/PR Type Guard). No `type` field in output (Gitea has no issue types). No sub-issue augmentation (Gitea does not model issue hierarchies; use `issue blocks` and `issue blocked-by` for dependency relationships). **`issue create [flags]`** `--title ` (required); `--body ` or `--body-file `; `--assignee `; `--label ` (repeatable; resolved to label ID via `GET /labels`, case-insensitive — `VALIDATION_ERROR` if not found); `--milestone ` (resolved to milestone ID via `GET /milestones?name=` — `VALIDATION_ERROR` if not found). `--project` is excluded (Gitea has no projects REST API). `--type` is excluded (Gitea has no issue types). Output schema: `issue: { number, title, state, url }` (where `url` = `html_url`). Extra fields available via `--fields`: `labels`, `assignees`, `milestone`, `body` (truncated at 500, exactly as `issue view` presents it; `--full` shows it raw). **`issue edit [flags]`** `--title`; `--body ` or `--body-file `; `--add-label `; `--remove-label `; `--add-assignee `; `--remove-assignee `; `--milestone ` (resolved to milestone ID via `GET /milestones?name=` — `VALIDATION_ERROR` if not found). Output on success: `edited: { number, status: "ok" }` — the action-block/entity-block pattern (see `pr create`) applied uniformly across issue and PR mutations; a deliberate departure from gh-axi, whose `issue edit` returns the updated `issue:` entity block. Label mutations use Gitea's dedicated additive/removal label endpoints (idempotent). `--add-label` passes the name directly via `POST /issues/{index}/labels` (Gitea accepts names here — no lookup needed). `--remove-label` requires an ID: resolved via case-insensitive label lookup; `VALIDATION_ERROR` if the label name does not exist in the repo; if the label exists but is not applied to this issue, Gitea's 404 on `DELETE /labels/{id}` is treated as silent success. Assignee mutations use fetch-then-patch: the current assignee list is read first, the addition or removal applied in-process, then the full resulting list is sent in a single PATCH (see ADR 0007). **`issue close [flags]`** `--comment `. Closing sets `state: "closed"` via PATCH on the issue. `--reason` is excluded (Gitea has no `state_reason` concept). When `--comment` is provided, two API calls are made: PATCH to close, then POST to create the comment. If the PATCH succeeds but the POST fails, the error is surfaced — the issue remains closed but the failure is reported rather than silently swallowed. Output on success: `closed: { number, status: "ok" }` (action-block/entity-block pattern). Idempotent: returns early with `message: "Already closed"` if already closed. **`issue reopen `** Sets `state: "open"` via PATCH. Output on success: `reopened: { number, status: "ok" }` (action-block/entity-block pattern). Idempotent: returns early with `message: "Already open"` if already open. **`issue comment [flags]`** `--body ` or `--body-file ` (required). Gitea's `POST /issues/{index}/comments` returns the created `Comment` object directly. Output block: `comment: { number, author, created, body }` (body truncated at 800 chars). `number` is the issue number the comment was posted to; the comment's own id is not output (nothing in the command surface consumes comment ids). **`issue delete `** Hard-deletes the issue via `DELETE /issues/{index}` (requires admin or owner permissions). Not idempotent: a nonexistent issue errors with `ISSUE_NOT_FOUND` rather than reporting success (see ADR 0010). Output: `issue: { number, status: "deleted" }`. **`issue pin `** `POST /issues/{index}/pin`. Idempotent: returns early with `message: "Already pinned"` if already pinned. Output: `issue: { number, state, pinned }`. **`issue unpin `** `DELETE /issues/{index}/pin`. Idempotent: returns early with `message: "Already unpinned"` if already unpinned. Output: `issue: { number, state, pinned }`. **`issue blocks ` (Gitea-specific)** Manages the set of issues that this issue blocks (downstream dependents that cannot proceed until this issue is resolved). `issue blocks list ` — lists issues blocked by ``; output block `blocked_issues`. `issue blocks add ` — makes `` block ``; output `blocks: { issue: n, blocks: target }`. `issue blocks remove ` — removes the blocking relationship. Idempotent: `add` of an existing relationship returns `already: true` (fetch-first check against the current list); `remove` of a nonexistent relationship is silent success; self-reference and cycle errors still surface as `VALIDATION_ERROR` via the 422 mapping. Gitea API: `GET/POST/DELETE /repos/{owner}/{repo}/issues/{index}/blocks`. No gh-axi equivalent. **`issue blocked-by ` (Gitea-specific)** Manages the set of issues that block this issue (upstream blockers that must be resolved before this issue can proceed). `issue blocked-by list ` — lists issues that block ``; output block `blocking_issues`. `issue blocked-by add ` — makes `` depend on ``; output `blocked_by: { issue: n, blocked_by: blocker }`. `issue blocked-by remove ` — removes the dependency. Same idempotency rules as `issue blocks`. Gitea API: `GET/POST/DELETE /repos/{owner}/{repo}/issues/{index}/dependencies`. No gh-axi equivalent. #### Excluded Issue Commands `issue lock` / `issue unlock` — excluded: Gitea exposes `is_locked` as a readable field but has no lock/unlock API endpoint. `issue transfer` — excluded: no Gitea equivalent. `issue subissue` — excluded: GitHub-specific hierarchy model; Gitea uses blocking/dependency relationships instead (see `issue blocks` and `issue blocked-by`). #### PR Commands **`pr list [flags]`** `--state ` (default open); `--label ` (requires name→ID lookup — Gitea PR list takes `labels: number[]`; see label name lookup in CONTEXT.md); `--label-id ` (Gitea-specific shortcut — bypasses the name→ID lookup and passes the ID directly); `--assignee ` (client-side filter — Gitea PR list has no assignee param); `--author ` (API-supported — maps to `poster` param); `--base ` (client-side filter — no API param); `--head ` (client-side filter — no API param); `--draft` (client-side filter — no API param); `--sort ` (Gitea-specific extension — maps directly to the API `sort` param); `--limit ` (default 30); `--fields `. `--search` is explicitly forbidden (VALIDATION_ERROR; help: `` Use `gitea-axi search prs ""` for full-text search ``). Default output fields (matching gh-axi): `number`, `title`, `state` (lowercased), `author` (plucked from `user.login`), `draft` (bool→yes/no), `review` (`reviewDecision` mapped: APPROVED→approved, CHANGES_REQUESTED→changes_requested, REVIEW_REQUIRED→required). Extra fields via `--fields`: `body` (truncated at 500, exactly as `pr view` presents it; `--full` shows it raw), `createdAt` (relative time, as `created`), `labels` (joined names), `milestone` (title), `mergedAt` (relative time, as `merged_at`), `url`. `reviewDecision` is computed client-side by fetching reviews for each PR in parallel (one extra HTTP call per PR; see ADR 0006). When any client-side filter is active, the count line shows `count: N of T total` with `T` computed from the in-memory filtered result set (see ADR 0005). **`pr view [flags]`** `--comments` (renders all comments with no count cap, each body truncated at 800 chars with cleanBody applied); `--reviews`; `--full` (suppress all truncation in the output — PR body and comment bodies alike). Default output fields (matching gh-axi): `number`, `title`, `state`, `author`, `draft`, `merged`, `checks`, `body` (truncated at 500), plus `comment_count` and `review_count`. The `checks` field is populated from Gitea commit statuses via `GET /commits/{sha}/status` using the PR head SHA. It renders as `"N passed, N failed[, N skipped][, N pending], N total"`, or `"0 passed, 0 failed — this PR has no CI checks configured"` when no statuses exist. Commit status states map to gh-axi's four-value classification: `success`→`pass`; `failure`/`error`/`warning`→`fail` (matching Gitea's own combine logic, which treats `warning` as failure); `skipped`→`skip`; `pending`→`pending`. Older Gitea instances never emit `skipped`, so the `skip` bucket is simply absent there. `pr view` always makes three API calls: the PR fetch and `GET /pulls/{index}/reviews` are issued in parallel, then the combined-status fetch runs once the head SHA is known — so `review_count` and `checks` are always in the default output without requiring `--reviews`. When `--reviews` is passed, additionally fetches per-review inline comments (`GET /pulls/{index}/reviews/{id}/comments` for each review). Gitea-specific fields exposed on review objects when `--reviews` is passed: `official` (whether the review counts toward required approvals) and `stale` (whether the PR head has moved since review submission). **`pr create [flags]`** `--title ` (required); `--body ` or `--body-file `; `--base `; `--head `; `--assignee `; `--reviewer `; `--label ` (repeatable; resolved to label ID via `GET /labels`, case-insensitive — `VALIDATION_ERROR` if not found); `--milestone ` (resolved to milestone ID via `GET /milestones?name=` — `VALIDATION_ERROR` if not found). `--draft` is excluded (Gitea cannot create draft PRs via API). `--project` is excluded (Gitea has no projects REST API). When `--head` is not specified, defaults to the current local branch (via `git rev-parse --abbrev-ref HEAD`). When `--base` is not specified, the repository's default branch is used (fetched via `GET /repos/{owner}/{repo}`). Idempotent: before creating, checks `GET /pulls/{base}/{head}` for an existing open PR for the same branch pair. If found, returns `pull_request: { number, url, already: true }` without creating a duplicate. Output on success: `created: { number, url }` — completing gh-axi's action-block/entity-block pattern (action-named block when the mutation ran, entity-named block when it was a no-op). **`pr edit [flags]`** `--title`; `--body ` or `--body-file `; `--add-label `; `--remove-label `; `--add-assignee `; `--remove-assignee `; `--add-reviewer `; `--remove-reviewer `; `--milestone ` (resolved to milestone ID via `GET /milestones?name=` — `VALIDATION_ERROR` if not found); `--base `. Assignee mutations use fetch-then-patch (see ADR 0007). Reviewer mutations use Gitea's dedicated review-request endpoints (`POST`/`DELETE /pulls/{index}/requested_reviewers` with `{ reviewers: [login] }`) — `EditPullRequestOption` has no reviewers field, so fetch-then-patch is structurally impossible for reviewers (see ADR 0007 amendment). Output: `edited: { number, status: "ok" }`. **`pr close [flags]`** `--comment `. When `--comment` is provided, two API calls are made (PATCH to close, then POST the comment); if the PATCH succeeds but the POST fails, the error is surfaced — same partial-failure policy as `issue close`. Idempotent: returns `pull_request: { number, state, already: true }` if already closed or merged. Output on success: `closed: { number, status: "ok" }`. **`pr merge [flags]`** `--method `; `--merge`, `--squash`, `--rebase` (shorthands for the three common methods); `--auto`; `--delete-branch`; `--body ` or `--body-file `; `--subject `; `--merge-commit-id ` (required when `--method manually-merged`; VALIDATION_ERROR if omitted; VALIDATION_ERROR if provided with any other method). Gitea-specific methods not in gh-axi: `rebase-merge` (rebase + explicit merge commit), `fast-forward-only`, `manually-merged` (records an out-of-band merge without actually merging). Idempotent: if already merged, returns `pull_request: { number, state: "merged", merged_by, merged_at }` without calling the API. Output on success: `merged: { number, status: "ok", method }`. **`pr review [flags]`** `--approve`; `--request-changes`; `--comment`; `--body ` or `--body-file `. Exactly one of the three action flags is required; zero or multiple → `VALIDATION_ERROR` before any API call (mirroring the `pr merge` shorthand-conflict rule). Body requirements are not pre-validated locally: if Gitea rejects a body-less review event, its 422 surfaces as `VALIDATION_ERROR` with the server's message. Output: `review: { number, action }`. **`pr checks `** Fetches combined commit status for the PR head SHA via `GET /commits/{sha}/status`. Output matches gh-axi: a `summary` line (`N passed, N failed[, N skipped][, N pending], N total`) followed by a `checks` list of `{ name, conclusion }`. Conclusions: `pass`, `fail`, `skip`, or `pending`, using the same state mapping as `pr view` (`skipped`→`skip`; `warning`→`fail`). When no statuses are configured: `checks: "0 passed, 0 failed — this PR has no CI checks configured"`. **`pr diff [flags]`** `--full`. Fetches raw diff from `GET /pulls/{index}.diff`. Truncation limit: 4000 chars. Output: `pr_diff: { number, diff[, truncated, original_length] }`. **`pr checkout `** Fetches the PR head branch name from `GET /pulls/{index}` (`head.ref` field), then runs in the current working directory, three-cased on the local branch state so a re-checkout is idempotent (git refuses to fetch into the checked-out branch, and a moved PR head makes the plain fetch non-fast-forward): 1. Branch absent: `git fetch origin pull//head:`, then `git checkout `. 2. Branch exists, not checked out: `git fetch origin +pull//head:` (force — the branch mirrors the PR head), then `git checkout `. 3. Branch currently checked out: `git fetch origin pull//head`, then `git merge --ff-only FETCH_HEAD`; if not fast-forwardable (local commits diverge from the PR head), `GIT_ERROR` with a help line explaining the divergence — local commits are never discarded silently. Fetching `refs/pull/{index}/head` from the base repo works uniformly for same-repo and fork PRs — the head branch itself may live in a fork that is not a configured remote (see ADR 0011). Git subprocess failures (dirty worktree, network) map to `GIT_ERROR`, carrying git's first stderr line and a remediation help line. Output: `checkout: { number, branch, status: "ok" }`. **`pr reopen `** Idempotent: returns `pull_request: { number, state: "open", already: true }` if already open. Output on success: `reopened: { number, status: "ok" }`. **`pr comment [flags]`** `--body ` or `--body-file ` (required). PRs share the issue comment endpoint in Gitea (`POST /issues/{index}/comments`), which returns the created `Comment` object directly. Output block: `comment: { number, author, created, body }` (body truncated at 800 chars). `number` is the PR number the comment was posted to; the comment's own id is not output. This diverges from gh-axi's `commented: { number, status: "ok" }` — returning the created comment eliminates the need for a follow-up view call (AXI Principle 4; see ADR 0008). **`pr update-branch [flags]`** `--style ` (Gitea-specific; default `merge`). Merges the base branch into the PR head branch via `POST /pulls/{index}/update?style=