## 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 Node 20+, matching the `gh-axi` reference implementation. 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. 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). 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` (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`. **`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). 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. Idempotent: returns early with `message: "Already closed"` if already closed. **`issue reopen `** Sets `state: "open"` via PATCH. 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). 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` (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 and reviewer mutations use fetch-then-patch (see ADR 0007). Output: `edited: { number, status: "ok" }`. **`pr close [flags]`** `--comment `. 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: 1. `git fetch origin pull//head:` 2. `git checkout ` 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=