Measure how far the CLI is from the Issues and Projects API

67f140a95349 · AtlantisPleb · · parent 81e4c25eb5b5

Measure how far the CLI is from the Issues and Projects API

The owner's belief was that the CLI only has the repository upload path. It
is right, and the shape of the gap is worth writing down before anyone starts
closing it, because two of the three things blocking terminal issue
management are not missing commands.

The router exposes 50 routes under `/api/v3`. The CLI calls 11 — exactly the
ones named in the hand-written contract document it vendors. All 39 issue,
comment, label, milestone, assignee, and project routes have no CLI surface.

Three findings outrank the endpoint count. You can create an issue in a
private repository and cannot read it back, because writes resolve through
`get_writable_by_path!/3` while reads run in a pipeline that discards the
bearer token and then filter `visibility == "public"`. The list endpoints are
unpaginated and expose one filter, while the paginated, filterable query the
web interface uses sits beside them unused. The whole `projectsV2` surface is
pinned to the initial repository, so project commands built on it today would
silently only ever address one repository.

On the generation question the answer is not one option. There is no OpenAPI
document; the contract artifact covers 22% of the surface, carries no types,
and is verified by neither side — the CLI's contract test hashes a copy it
ships itself. So the drift story today is that nothing fails. The document
recommends a generic `openagents api` passthrough first, because it buys total
coverage for about 200 lines and cannot drift, then hand-written commands for
the ten that carry the traffic, and a router-derived contract only once the
surface stops moving. Never generated commands: generated UX is worst exactly
where usage is highest, which is why `gh` ships both `gh issue` and `gh api`.

The staged path is ordered server-first and no stage touches both
repositories, since the CLI ships from the monorepo on its own cadence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o8HwTaqLKEWCHTjsjFtrB
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • added docs/2026-08-21-cli-api-parity-audit.md

Diff

1 file changed, +385 -0

docs/2026-08-21-cli-api-parity-audit.md added +385

@@ -0,0 +1,385 @@

1
# CLI and API parity
2
3
**Date:** 2026-08-21
4
**Commits measured:** `81e4c25eb5b5` (`openagents/main`, the forge) for the API; `5bd0061e4f6e` in the `openagents` monorepo for `packages/openagents-cli` (package version `0.1.7`)
5
**Question:** Does the CLI only cover repository upload? What of the Issues and Projects API does it reach? Should that coverage be generated from an OpenAPI document instead of hand-written? What is the fastest honest path to managing issues and projects from a terminal?
6
**Method:** direct reading of `lib/openagents_web/router.ex`, every controller it routes to under `/api/v3`, the contexts behind them (`lib/openagents/issues.ex`, `labels.ex`, `milestones.ex`, `projects.ex`, `repositories.ex`), the auth plugs, `lib/openagents_web/route_authority.ex`, `priv/api-contracts/repositories-v1.json` and its controller and test, and `docs/openagents-cli/`; plus direct reading of all 21 source files in the `openagents` monorepo at `packages/openagents-cli/src/` and its tests. The CLI lives in a different repository, so every CLI citation names it. Claims that neither repository can settle are in section 7 with the command that would settle them.
7
8
---
9
10
## 0. Summary
11
12
The owner is right. The CLI is repository-shaped and reaches **none** of the Issues and Projects API.
13
14
The numbers are clean. The router exposes **50 routes under `/api/v3`** (`lib/openagents_web/router.ex:229`, `:253`, `:259`). The CLI calls **11 of them** — the authenticated user, repository create, list, view, delete, the two import routes, import status, and the two device-authorization routes. Those 11 are exactly the routes named in the hand-written contract document at `priv/api-contracts/repositories-v1.json:18`. The other **39 routes — every issue, comment, label, milestone, assignee, and project endpoint — have no CLI surface at all**, and the CLI's own documentation says so: `docs/openagents-cli/command-reference.md:3` scopes the tool to "authentication and hosted repositories", and `:171` lists a generic API command among the things the release does not provide.
15
16
There is **no OpenAPI document**. What exists instead is a 99-line hand-written JSON artifact served at `/api/contracts/repositories-v1.json` (`lib/openagents_web/router.ex:222`). It lists endpoints as opaque `"METHOD /path"` strings, has no types, no request bodies, no response schemas, and no status codes. It covers 11 of the 50 routes. Nothing derives it from the router, and nothing verifies it against the router.
17
18
The drift story today is that **nothing fails**. The server's contract test asserts the document is served with the right headers and carries the right name and version, and nothing else (`test/openagents_web/controllers/api_contract_controller_test.exs:4`). The CLI's contract test hashes the copy it vendors itself and compares it to a constant in its own source (`openagents` monorepo, `packages/openagents-cli/test/api-contract.test.ts:13`) — it never fetches the server's copy. The two files are byte-identical today, both hashing to `5be86539258c…`, and that is a coincidence maintained by hand. Rename a field and both test suites stay green; the failure surfaces in a user's terminal as a `ContractError`.
19
20
Three findings matter more than the raw endpoint count, because they mean the gap is not only "the CLI has no `issue` command":
21
22
1. **You can write an issue into a private repository and cannot read it back.** Writes resolve the repository through `Repositories.get_writable_by_path!/3` (`lib/openagents_web/controllers/issue_controller.ex:18`), which does not filter on visibility. Reads run through a pipeline with no authentication at all (`router.ex:230`) and resolve through `Repositories.get_public_by_path!/2` and `Issues.get_issue_by_path!/3`, which require `visibility == "public"` (`lib/openagents/repositories.ex:68`, `lib/openagents/issues.ex:159`). `POST` returns `201` with the issue body; the following `GET` returns `404`. This is deliberate, not accidental — `test/openagents_web/route_authority_test.exs:26` asserts the read is `:public_read` and the write is `:authenticated_api` — but it makes terminal issue management impossible for private work regardless of what commands the CLI grows.
23
2. **The list endpoints have no pagination and one filter.** `IssueController.index` calls `Issues.list_issues/2` with `state` only (`issue_controller.ex:10`), returning every matching issue in one unbounded array, while `Issues.list_issues_page/2` — which already supports `:label`, `:assignee`, `:milestone`, `:q`, and `:page` — sits unused by the API (`lib/openagents/issues.ex:40`).
24
3. **The whole `projectsV2` surface is pinned to one repository.** `Projects.list_projects_by_owner/1` and `get_project_by_owner_and_number!/2` both scope to `Repositories.initial_repository!()` (`lib/openagents/projects.ex:26`, `:131`), which is the constant `OpenAgentsInc/openagents.com` (`lib/openagents/repositories.ex:22`). `ProjectController.create` ignores its own `:owner` path segment when choosing a repository and writes into that same one (`lib/openagents_web/controllers/project_controller.ex:21`).
25
26
On generation: **do not generate the commands.** Ship a generic `openagents api` passthrough first, hand-write the ten commands that carry the traffic, and only then invest in a derived contract document. Section 4 argues it. The short version is that a passthrough buys total endpoint coverage for roughly 200 lines and can never drift because it makes no claims, while generated command surfaces are worst exactly at the commands people use every day. Generating a *client* is defensible; generating a *CLI* is not.
27
28
The first two stages are small and independent: the passthrough command (CLI repository only, no server change), then moving the read routes behind optional bearer auth so a private repository's issues are readable by someone entitled to read them (this repository only, additive).
29
30
---
31
32
## 1. What the CLI covers today
33
34
### 1.1 The command tree
35
36
The CLI is 3,452 lines of Effect TypeScript across 21 files in `packages/openagents-cli/src/` in the `openagents` monorepo. It registers two command groups and twelve leaf commands, wired at `cli.ts:800`:
37
38
| Group | Command | Source line (`openagents` monorepo, `packages/openagents-cli/src/cli.ts`) |
39
| --- | --- | --- |
40
| `auth` | `login` (with `--headless`, `--resume`, `--token-stdin`) | `:183` |
41
| `auth` | `token-stdin` | `:144` |
42
| `auth` | `status` | `:79` |
43
| `auth` | `logout` | `:331` |
44
| `auth` | `setup-git` | `:369` |
45
| `auth` | `git-credential` (internal Git credential-helper protocol) | `:349` |
46
| `repo` | `create` | `:445` |
47
| `repo` | `import` | `:557` |
48
| `repo` | `list` | `:640` |
49
| `repo` | `view` | `:699` |
50
| `repo` | `clone` | `:719` |
51
| `repo` | `delete` | `:756` |
52
53
Four global flags apply to all of them — `--profile`, `--api-url`, `--json`, `--no-color` (`cli.ts:23`–`:31`) — and the root command describes itself as "Manage OpenAgents repositories" (`cli.ts:34`).
54
55
### 1.2 The endpoints it calls
56
57
Eleven, all of them in the repository, identity, and device-authorization families:
58
59
| Method and path | Called from (`openagents` monorepo) | Reached by |
60
| --- | --- | --- |
61
| `POST /api/v3/device/authorizations` | `src/device-client.ts:52` | `auth login` |
62
| `POST /api/v3/device/authorizations/token` | `src/device-client.ts:83` | `auth login` |
63
| `GET /api/v3/user` | `src/repository-client.ts:363` | `auth status`, `repo import` |
64
| `GET /api/v3/user/repos` | `src/repository-client.ts:381` | `repo list` |
65
| `POST /api/v3/user/repos` | `src/repository-client.ts:333` | `repo create` |
66
| `POST /api/v3/orgs/{org}/repos` | `src/repository-client.ts:333` | `repo create` |
67
| `POST /api/v3/user/repos/imports` | `src/repository-client.ts:524` | `repo import` |
68
| `POST /api/v3/orgs/{org}/repos/imports` | `src/repository-client.ts:525` | `repo import` |
69
| `GET /api/v3/repos/{owner}/{repo}` | `src/repository-client.ts:278`, `:399` | `repo view`, `repo clone`, provisioning poll |
70
| `DELETE /api/v3/repos/{owner}/{repo}` | `src/repository-client.ts:413` | `repo delete` |
71
| `GET /api/v3/repository-imports/{id}` | `src/repository-client.ts:424` | `repo import` wait loop |
72
73
Git data transfer is not in this list because it is not API traffic. `repo clone` shells out to `git` (`src/git-runner.ts`), and `auth setup-git` installs this binary as a Git credential helper so ordinary `git push` and `git fetch` authenticate against the smart HTTP transport at `router.ex:203`.
74
75
### 1.3 The contract layer, and what it actually checks
76
77
`src/api-contract.ts` is 105 lines of Effect `Schema` definitions for exactly four response shapes — the authenticated user, a repository, a repository import, and a loose error envelope (`api-contract.ts:14`, `:51`, `:64`, `:93`). Every response is decoded through it, and a decode failure becomes a `ContractError` naming the operation (`src/repository-client.ts:227`).
78
79
The transport underneath is deliberately thin: one `request` function that takes an origin, a method, a path string, an optional bearer token, and an optional JSON body (`src/api-transport.ts:82`). Paths are built by string interpolation at each call site. There is no route table, no path type, and no generated surface — adding an endpoint means adding a method to `repository-client.ts`, a schema to `api-contract.ts`, and a command to `cli.ts`.
80
81
The contract *artifact* is a separate thing from the contract *schemas*, and this is where the drift story lives. `api-contract.ts:5` pins a SHA-256, and `test/api-contract.test.ts:13` reads `packages/openagents-cli/contracts/repositories-v1.json` — a copy vendored inside the CLI package and shipped in its npm tarball (`package.json`, `files`) — hashes it, and asserts it equals that constant. **The test never contacts the server.** It detects an unannounced edit to the vendored copy. It cannot detect that the server changed.
82
83
Both copies currently hash to `5be86539258c38d5249887ff2628680b1f90c93909d6e83e86a836d0250ce79f`, so the two repositories agree today. Nothing enforces that they keep agreeing.
84
85
### 1.4 Verdict on the owner's belief
86
87
Confirmed, with one refinement. "Repo upload stuff" understates it slightly — the CLI also does device-flow authentication, credential storage, Git credential-helper installation, repository listing, viewing, cloning, and deletion, and it waits on durable provisioning and import state machines. But on the substance the belief is exactly right: **the CLI has no issue, comment, label, milestone, assignee, or project command, and calls no endpoint in those families.** Its own reference documentation states the boundary (`docs/openagents-cli/command-reference.md:3`, `:171`) and its `README.md` in the monorepo has sections only for install, API selection, sign-in, and repositories.
88
89
One documentation defect found in passing, relevant because it is evidence for section 4: `docs/openagents-cli/command-reference.md:173` says the release "does not provide `repo delete`" while the same file documents `repo delete` in full at `:122`, the command exists at `cli.ts:756`, and `docs/openagents-cli/index.md:34` describes it as available. A hand-maintained description drifted from the thing it describes, in the smallest possible surface, in the same file.
90
91
---
92
93
## 2. What the API offers
94
95
Fifty routes under `/api/v3`, in three scopes distinguished by pipeline.
96
97
### 2.1 Authentication, in one paragraph
98
99
Three pipelines serve `/api/v3`. `:api` (`router.ex:25`) runs `accepts ["json"]` and nothing else — **no authentication whatsoever**; a bearer token sent to one of these routes is ignored and `conn.assigns.current_user` is never set. `:forge_write_api` (`router.ex:38`) requires `Authorization: Bearer oa_pat_…` carrying scope `forge:write`, or it halts with `401 {"error":"invalid_api_token"}` (`lib/openagents_web/plugs/api_token_auth.ex:10`). `:optional_forge_api` (`router.ex:43`) accepts the same token but tolerates its absence, which is what lets `GET /api/v3/repos/:owner/:repo` widen from public repositories to the caller's private ones (`lib/openagents_web/controllers/repository_controller.ex:64`). There is exactly one scope in the whole system: `@allowed_scopes ["forge:write"]` (`lib/openagents/api_tokens.ex:12`). A token that can file an issue can also delete a repository.
100
101
### 2.2 Reads — no authentication, public repositories only
102
103
All sixteen are in the `:api` scope at `router.ex:229`.
104
105
| Method and path | Controller action | Line | GitHub-compatible path? |
106
| --- | --- | --- | --- |
107
| `GET /repos/:owner/:repo/issues` | `IssueController.index` | `:235` | yes |
108
| `GET /repos/:owner/:repo/issues/:issue_number` | `IssueController.show` | `:236` | yes |
109
| `GET /repos/:owner/:repo/issues/:issue_number/comments` | `CommentController.index` | `:237` | yes |
110
| `GET /repos/:owner/:repo/issues/comments/:id` | `CommentController.show` | `:238` | yes |
111
| `GET /repos/:owner/:repo/issues/:issue_number/labels` | `IssueLabelController.index` | `:239` | yes |
112
| `GET /repos/:owner/:repo/issues/:issue_number/assignees` | `IssueAssigneeController.index` | `:240` | no — GitHub carries assignees inside the issue object |
113
| `GET /repos/:owner/:repo/labels` | `LabelController.index` | `:241` | yes |
114
| `GET /repos/:owner/:repo/labels/:name` | `LabelController.show` | `:242` | yes |
115
| `GET /repos/:owner/:repo/milestones` | `MilestoneController.index` | `:243` | yes |
116
| `GET /repos/:owner/:repo/milestones/:milestone_number` | `MilestoneController.show` | `:244` | yes |
117
| `GET /repos/:owner/:repo/assignees` | `AssigneeController.index` | `:245` | yes |
118
| `GET /repos/:owner/:repo/assignees/:assignee` | `AssigneeController.show` | `:246` | path yes, body no — GitHub answers `204`/`404` with no body |
119
| `GET /users/:username/projectsV2` | `ProjectController.index` | `:247` | no — ProjectsV2 is GraphQL-only on GitHub |
120
| `GET /users/:username/projectsV2/:project_number` | `ProjectController.show` | `:248` | no |
121
| `GET /users/:username/projectsV2/:project_number/items` | `ProjectController.items` | `:249` | no |
122
| `GET /users/:username/projectsV2/:project_number/fields` | `ProjectController.fields` | `:250` | no |
123
124
Every issue, label, milestone, and assignee read is gated in the controller by `Repositories.get_public_by_path!/2` or an equivalent `visibility == "public"` clause, so a private repository is indistinguishable from a missing one. The four project reads apply **no visibility predicate at all** (`project_controller.ex:8`, `:41`, `:53`, `:144`); they are constrained only by being pinned to the initial repository.
125
126
### 2.3 Writes — bearer token with `forge:write`, repository membership required
127
128
All twenty-three are in the `:forge_write_api` scope at `router.ex:259`, alongside the nine repository and import routes the CLI already uses.
129
130
| Method and path | Controller action | Line | GitHub-compatible path? |
131
| --- | --- | --- | --- |
132
| `POST /repos/:owner/:repo/issues` | `IssueController.create` | `:271` | yes |
133
| `PUT /repos/:owner/:repo/issues/:issue_number` | `IssueController.update` | `:272` | no — GitHub uses `PATCH` only |
134
| `PATCH /repos/:owner/:repo/issues/:issue_number` | `IssueController.update` | `:273` | yes |
135
| `POST /repos/:owner/:repo/issues/:issue_number/comments` | `CommentController.create` | `:274` | yes |
136
| `PUT /repos/:owner/:repo/issues/comments/:id` | `CommentController.update` | `:275` | no |
137
| `PATCH /repos/:owner/:repo/issues/comments/:id` | `CommentController.update` | `:276` | yes |
138
| `DELETE /repos/:owner/:repo/issues/comments/:id` | `CommentController.delete` | `:277` | yes |
139
| `POST /repos/:owner/:repo/issues/:issue_number/labels` | `IssueLabelController.create` | `:278` | yes |
140
| `DELETE /repos/:owner/:repo/issues/:issue_number/labels/:name` | `IssueLabelController.delete` | `:279` | yes |
141
| `POST /repos/:owner/:repo/issues/:issue_number/assignees` | `IssueAssigneeController.create` | `:280` | yes |
142
| `DELETE /repos/:owner/:repo/issues/:issue_number/assignees` | `IssueAssigneeController.delete` | `:281` | yes |
143
| `POST /repos/:owner/:repo/labels` | `LabelController.create` | `:282` | yes |
144
| `PUT /repos/:owner/:repo/labels/:name` | `LabelController.update` | `:283` | no |
145
| `PATCH /repos/:owner/:repo/labels/:name` | `LabelController.update` | `:284` | yes |
146
| `DELETE /repos/:owner/:repo/labels/:name` | `LabelController.delete` | `:285` | yes |
147
| `POST /repos/:owner/:repo/milestones` | `MilestoneController.create` | `:286` | yes |
148
| `PUT /repos/:owner/:repo/milestones/:milestone_number` | `MilestoneController.update` | `:287` | no |
149
| `PATCH /repos/:owner/:repo/milestones/:milestone_number` | `MilestoneController.update` | `:288` | yes |
150
| `DELETE /repos/:owner/:repo/milestones/:milestone_number` | `MilestoneController.delete` | `:289` | yes |
151
| `POST /:owner/projectsV2` | `ProjectController.create` | `:290` | no, and inconsistent with its own reads |
152
| `POST /users/:username/projectsV2/:project_number/items` | `ProjectController.create_item` | `:291` | no |
153
| `POST /users/:username/projectsV2/:project_number/fields` | `ProjectController.create_field` | `:292` | no |
154
| `PATCH /users/:username/projectsV2/:project_number/items/:item_id` | `ProjectController.update_item` | `:294` | no |
155
156
Note `router.ex:290`: project creation lives at `/api/v3/:owner/projectsV2` while every project read and every other project write lives under `/api/v3/users/:username/projectsV2`. A generated client would faithfully reproduce that inconsistency; a human writing a command would notice it.
157
158
No routed action is a stub. Every one of the fifty reaches a real context function.
159
160
There is **no** search endpoint, no `GET /api/v3/users/:username`, no `GET /api/v3/orgs/:org`, no issue events, reactions, or timeline, and no `/api/v3/meta`.
161
162
### 2.4 Where "GitHub-compatible" stops being true
163
164
The paths are largely GitHub-shaped. The bodies are not, in one systematic way that matters for any plan involving generated clients: **every list response is wrapped in a named envelope**, where GitHub returns a bare array.
165
166
`GET /repos/:owner/:repo/issues` returns `{"issues": [...]}` (`lib/openagents_web/controllers/issue_json.ex:6`), and the same pattern holds for labels (`label_json.ex:6`), milestones (`milestone_json.ex:6`), comments (`comment_json.ex:6`), and projects (`project_json.ex:6`). Repository lists use a third shape again, `{"repositories": [...], "next_cursor": …}` (`priv/api-contracts/repositories-v1.json:56` describes it; the CLI decodes it at `packages/openagents-cli/src/api-contract.ts:56` in the monorepo).
167
168
Two further deviations worth recording:
169
170
- **Two error dialects.** The repository endpoints emit `{code, message, request_id}` with a fixed vocabulary of twenty stable codes (`priv/api-contracts/repositories-v1.json:74`), and the CLI decodes exactly that (`packages/openagents-cli/src/repository-client.ts:157` in the monorepo). The issues endpoints emit `{"message": "Not Found"}` for 404 and `{"errors": {field => [messages]}}` for 422 (`issue_controller.ex:44`, `issue_json.ex:14`). The CLI's decoder tolerates the 404 because it reads a `message` key, and degrades to the generic `The API returned HTTP 422.` for the validation case, discarding the field errors.
171
- **Hardcoded origin in generated URLs.** `issue_json.ex:38` builds `html_url` and `url` from the literal `https://openagents.com`, so a staging response points at production.
172
173
The practical consequence: you cannot point GitHub's own OpenAPI description, or a client generated from it, at this server and expect it to work. The surface is GitHub-*inspired*, not GitHub-*compatible*, and any generated client has to be generated from this repository's own description of itself.
174
175
---
176
177
## 3. The gap
178
179
Thirty-nine of fifty routes have no CLI surface. Not "partial" — zero. Every row in section 2.2 and section 2.3 is uncovered.
180
181
### 3.1 Ranked by what a person managing issues from a terminal reaches for first
182
183
| Rank | What you want to type | Endpoints behind it | Blocked by anything beyond the missing command? |
184
| --- | --- | --- | --- |
185
| 1 | `openagents issue list` | `GET .../issues` (`router.ex:235`) | Yes — public repositories only, unpaginated, `state` is the only filter |
186
| 2 | `openagents issue view 41` | `GET .../issues/:n`, `GET .../issues/:n/comments` (`:236`, `:237`) | Yes — public repositories only |
187
| 3 | `openagents issue create` | `POST .../issues` (`:271`) | No |
188
| 4 | `openagents issue close` / `reopen` / `edit` | `PATCH .../issues/:n` (`:273`) | No — `state` is castable (`lib/openagents/issues/issue.ex:32`) and covered by `test/openagents_web/controllers/issue_controller_test.exs:92` |
189
| 5 | `openagents issue comment 41` | `POST .../issues/:n/comments` (`:274`) | No |
190
| 6 | `openagents issue label add` / `remove` | `POST`, `DELETE .../issues/:n/labels` (`:278`, `:279`) | No |
191
| 7 | `openagents issue assign` / `unassign` | `POST`, `DELETE .../issues/:n/assignees` (`:280`, `:281`) | No |
192
| 8 | `openagents label list` / `create` / `edit` / `delete` | `router.ex:241`, `:242`, `:282`, `:284`, `:285` | Reads are public-only |
193
| 9 | `openagents milestone` CRUD | `router.ex:243`, `:244`, `:286`, `:288`, `:289` | Reads are public-only |
194
| 10 | `openagents project list` / `view` / `item add` / `item move` | `router.ex:247`–`:250`, `:290`–`:296` | Yes — the whole surface is pinned to one repository |
195
196
Ranks 3 through 7 are pure CLI work: the endpoints exist, take a bearer token, and enforce membership correctly. Ranks 1, 2, 8, and 9 need a server change first or they only work on public repositories. Rank 10 needs a server change that is larger than a command.
197
198
### 3.2 Three gaps that no CLI command can close
199
200
**The private-repository read asymmetry.** This is the one that matters most, because it defeats the use case rather than delaying it. Consider a private repository you own:
201
202
```
203
POST /api/v3/repos/you/private/issues        → 201, issue #1 in the body
204
GET  /api/v3/repos/you/private/issues/1      → 404 {"message":"Not Found"}
205
```
206
207
The write path resolves through `Repositories.get_writable_by_path!/3`, which joins on membership and filters on `lifecycle_state` but never on `visibility` (`lib/openagents/repositories.ex:111`). The read path runs in a pipeline that discards the bearer token entirely (`router.ex:230`) and then filters `visibility == "public"` in the query (`lib/openagents/issues.ex:159`, `lib/openagents/repositories.ex:68`). The repository already has the mechanism to fix this — `Repositories.get_visible_by_path!/3` (`repositories.ex:72`) and the `:optional_forge_api` pipeline that `GET /api/v3/repos/:owner/:repo` uses (`router.ex:254`).
208
209
**No pagination, one filter.** `IssueController.index` passes only `state` into `Issues.list_issues/2` (`issue_controller.ex:10`), which returns every match with no limit (`issues.ex:26`). The paginated, filterable query the web interface uses is right beside it and unreachable from the API: `Issues.list_issues_page/2` supports `:state`, `:label`, `:assignee`, `:milestone`, `:q`, and `:page` (`issues.ex:40`). A repository with two thousand issues returns two thousand issues.
210
211
**Projects are one repository's projects.** Both project read functions scope to `Repositories.initial_repository!()` (`lib/openagents/projects.ex:26`, `:131`), which resolves the module constants `OpenAgentsInc` and `openagents.com` (`lib/openagents/repositories.ex:22`). `ProjectController.create` checks that the `:owner` segment matches the caller's GitHub login and then discards it, resolving `Repositories.initial_path()` for the repository the project attaches to (`project_controller.ex:17`, `:21`). A cross-repository paginated query exists in the context for the web interface (`projects.ex:43`) and the API does not use it. Building `openagents project` commands on the surface as it stands would ship a command that silently only ever addresses one repository.
212
213
---
214
215
## 4. How the coverage should be produced
216
217
This is the part worth being slow about, because the answer determines the cost of every future endpoint.
218
219
### 4.1 What exists today, and what it does not check
220
221
**No OpenAPI document exists.** There is no `open_api_spex`, `phoenix_swagger`, or equivalent in `mix.exs`. The single mention of OpenAPI anywhere in the repository is a future intention in a planning document: `docs/repository-creation-and-openagents-cli-spec.md:1008` proposes publishing "a bounded OpenAPI document or equivalent JSON Schema artifact for the repository endpoints" as a step in client-contract synchronization. There is no `.well-known` route and no capability manifest.
222
223
**A hand-written contract artifact exists**, and it is worth understanding precisely because it is the seed of any answer here. `priv/api-contracts/repositories-v1.json` is 99 lines, served by `ApiContractController.repositories_v1` which reads the file at request time and sets an ETag from its SHA-256 (`lib/openagents_web/controllers/api_contract_controller.ex:8`). It carries:
224
225
- an `endpoints` map of eleven `name → "METHOD /path"` strings (`:18`), covering repositories, imports, the authenticated user, and device authorization, and **nothing** from the issues or projects families;
226
- authentication metadata — bearer, `oa_pat_` prefix, `forge:write` scope (`:4`);
227
- which four `POST`s require `Idempotency-Key` (`:8`);
228
- flat lists of required field *names* per object, plus enum values (`:33`–`:66`);
229
- pagination parameter names (`:68`) and the error shape with twenty stable codes (`:74`).
230
231
What it does not carry: any type, any request body, any response body, any status code, any relationship between an endpoint and a schema. It is a vocabulary agreement, not a description. **You could not generate a client from it.** It also is not generated from anything — it is a checked-in file that a human edits.
232
233
**An executable route inventory exists**, and this is the genuinely promising asset. `lib/openagents_web/route_authority.ex` walks `OpenAgentsWeb.Router.__routes__/0` and classifies every route with a class, principal, scope, and mutation flag (`:59`), and `test/openagents_web/route_authority_test.exs:6` fails if any route lacks them. The derivation this repository would need already runs in CI.
234
235
But it also demonstrates the failure mode that any derived-document plan has to design against, twice over:
236
237
- Its `@moduledoc` states "The classifier intentionally has no catch-all policy" (`route_authority.ex:5`), and for `/api/v3` there are in fact two catch-alls: any unmatched `GET` becomes `:public_read` and anything else becomes `:authenticated_api` with scope `forge:write` (`:195`, `:198`). A new `/api/v3` route is classified automatically, so **adding an endpoint cannot fail this test**.
238
- It declares scope `forge:read` for `GET /api/v3/user`, `GET /api/v3/user/repos`, and `GET /api/v3/repository-imports/:id` (`:184`). Those three routes are in the `:forge_write_api` pipeline (`router.ex:260`, `:262`, `:263`, `:269`), which requires `forge:write`, and `forge:read` is not a scope that exists anywhere in the system (`lib/openagents/api_tokens.ex:12`). The label is derived from a hand-written policy function that nothing checks against the plug it describes.
239
240
So the state of the art here is: one derivation that runs but does not constrain, and one description that constrains nothing and describes 22% of the surface.
241
242
### 4.2 The four options, honestly
243
244
**Option A — keep hand-writing commands (status quo).**
245
246
*Cost.* Measured from the existing code: `repository-client.ts` is 595 lines for nine operations (about 66 lines each), and `cli.ts` is 803 lines for twelve leaf commands (about 67 lines each), in the `openagents` monorepo. Thirty-nine endpoints at that rate is roughly 2,600 lines of client plus perhaps 1,300 of command surface before tests — which would roughly double a 3,452-line codebase. In a second repository, in a second language, on a second release cadence, published to npm.
247
248
*What it buys.* The best possible user experience at every command, because a human decides what `issue list` prints, that `-R owner/repo` falls back to the origin remote, that `41` means issue 41, and that `issue create` opens `$EDITOR`.
249
250
*What it forecloses.* Nothing. Hand-written commands compose with every other option.
251
252
*Where it breaks down.* At exactly the volume in front of us. The marginal endpoint is never worth the marginal pull request, so coverage stalls where it is now — which is the observed outcome, since the API has had these 39 routes long enough to accumulate full controller test files for all of them.
253
254
*Drift.* Silent, and this is not hypothetical: it is today's behavior. Nothing fails when an endpoint changes. Both contract tests stay green. The user finds out.
255
256
**Option B — generate a typed client, hand-write the command surface.**
257
258
Generate the transport and schema layer from a machine-readable description of this API; keep `cli.ts` hand-written on top of it.
259
260
*Cost.* Dominated by the prerequisite. The description does not exist and must be produced, and — the important part — it must be **derived** from the router rather than maintained beside it, or you have moved the drift one layer up and added a file to forget. Paths, methods, and pipeline-derived auth are mechanically derivable from `Router.__routes__/0`, as `RouteAuthority` proves. Response schemas are the hard half: they live in the `*_json.ex` view functions, and six controllers render inline with `json/2` and have no view module at all (`assignee_controller.ex`, `issue_assignee_controller.ex`, `issue_label_controller.ex`, `forge_user_controller.ex`, `device_authorization_controller.ex`, `repository_controller.ex`). Deriving the response shape for those means restructuring them first.
261
262
*What it buys.* The 66-lines-per-operation client layer mostly disappears, and — the real prize — a server-side schema change becomes a **compile error in the CLI** on regeneration, before anyone ships.
263
264
*What it forecloses.* Little. A generated method can always be wrapped or bypassed by hand.
265
266
*Where it breaks down.* When the description lies. A generated client inherits every inaccuracy of its source with total confidence, and the two demonstrated inaccuracies in this repository — the stale `repo delete` line in the command reference and the `forge:read` label in the route inventory — are both in hand-maintained descriptions.
267
268
*Drift.* Loud, conditionally. Regeneration diffs, and CI fails on an uncommitted diff — but only for the parts the description actually covers, and only if the description is derived. A hand-maintained OpenAPI file gives you a *feeling* of drift detection and no drift detection.
269
270
**Option C — generate the commands too.**
271
272
*Cost.* High fixed cost (a generator, plus naming and flag conventions), near-zero marginal cost.
273
274
*What it buys.* All 50 endpoints covered in one step, and every future endpoint free.
275
276
*What it forecloses.* The product. Generated command surfaces are worst precisely where usage is highest. A generator produces `openagents repos-owner-repo-issues-list --owner X --repo Y --issue-number 41`. It will not infer `-R owner/repo` from the origin remote — which this CLI already does, at `cli.ts:695` — it will not decide that a bare `41` means issue 41, it will not open an editor for a body, it will not render a table, it will not ask before a destructive call the way `repo delete` demands `--yes` (`cli.ts:761`), and it will faithfully expose both the `PUT` and the `PATCH` spelling of every update route because the router has both. It will also expose `POST /api/v3/:owner/projectsV2` under a different noun than every other project command, because that is what the router says.
277
278
*Where it breaks down.* The first day a person uses it for real work.
279
280
*Drift.* Loud — the regeneration diff — but with a cost the other options do not have: the CLI's public interface becomes generator output, so a path rename on the server renames a user's command.
281
282
**Option D — a generic passthrough, the way `gh api` works.**
283
284
```
285
openagents api /repos/OpenAgentsInc/openagents.com/issues
286
openagents api -X POST -f title="…" -f body="…" /repos/o/r/issues
287
```
288
289
*Cost.* One command. The CLI already owns every part except argument parsing: endpoint resolution and profiles (`src/endpoint.ts`), the credential store and `OPENAGENTS_TOKEN` fallback (`src/session.ts:30`), a transport that takes an arbitrary method, path, and JSON body (`src/api-transport.ts:82`), JSON and human output modes (`src/output.ts`), and an error decoder with exit-code mapping (`src/errors.ts`). Estimate 150–250 lines including tests — the size of one existing `repo` subcommand.
290
291
*What it buys.* Complete endpoint coverage on the day it lands, permanently, at zero marginal cost per endpoint — **including endpoints that do not exist yet**. It also decouples the two release trains: a new server endpoint becomes reachable from the terminal without an npm publish.
292
293
*What it forecloses.* Nothing. `gh` ships `gh issue` and `gh api` side by side and has for years; the passthrough is where power users and scripts go, and the named commands are where everyone else stays.
294
295
*Where it breaks down.* It is not issue management, it is authenticated `curl` with sane defaults. The caller has to know the path, spell the JSON, and read the envelope. Nobody triages with it.
296
297
*Drift.* None to detect, because it makes no claims — its correctness does not depend on any description of the API. That is its strength and, symmetrically, its limit: it will also never warn you that the server broke a shape.
298
299
### 4.3 Recommendation
300
301
**Ship D now, do A for the top ten commands, invest in B only once the description is derived and verified, and never do C.**
302
303
The reasoning, in order of confidence:
304
305
1. **Never C.** The CLI's entire reason to exist is that it is nicer than `curl`. Generating its command surface trades away the only thing it adds, to save work on commands nobody types. The `gh` precedent is decisive here — GitHub has the largest OpenAPI description in public software and still hand-writes `gh issue`.
306
307
2. **D first, because it is the cheapest thing that answers the owner's actual question.** "So I can do issues and projects management from the CLI" is satisfied for scripting and one-off work by roughly 200 lines of code, in one repository, with no server change and no coordination. Everything after it is an ergonomics upgrade on a capability that already exists rather than a capability that does not.
308
309
3. **A for the top ten.** Thirty-nine hand-written commands is the trap; ten is a week. The ranking in section 3.1 is the list, and it is short because issue work is concentrated: list, view, create, close, comment, label, assign. Those get hand-written UX because that is where UX pays.
310
311
4. **B is right, but its prerequisite is the whole job, and the prerequisite is worth doing on its own merits.** The rule that makes it worth anything: **generate the document from the router, not beside it.** `RouteAuthority` already walks `Router.__routes__/0`; extending that walk to emit paths, methods, pipeline-derived auth, and — where a `*_json.ex` module exists — the response key set, produces a document that cannot drift on the parts it covers, because CI fails on an uncommitted regeneration diff. Then generate the CLI's schema layer from it.
312
313
There is a cheaper move available immediately that is worth naming separately, because it converts today's silence into a red build for about twenty lines per repository, and it is a prerequisite for trusting any of the above:
314
315
- **In this repository:** assert that every route in `Router.__routes__/0` whose path starts with `/api/v3` appears in `priv/api-contracts/repositories-v1.json`. Today that test fails immediately on 39 routes, which is the correct first result — it converts "the contract covers 22% of the API" from a fact nobody knows into a fact the build states.
316
- **In the `openagents` monorepo:** make the CLI's contract check fetch `/api/contracts/repositories-v1.json` from the configured origin and compare it to the vendored copy, rather than hashing the vendored copy against a constant in the same package (`packages/openagents-cli/test/api-contract.test.ts:13`). Run it against staging in the CLI's `verify` script. Today it would pass, because the two files happen to be identical; tomorrow it is the only thing that would notice they are not.
317
318
The order matters. The passthrough removes the urgency, the drift tests remove the silence, the hand-written commands do the ergonomics, and the derived document — which is real work with a real restructuring prerequisite — happens once the surface has stopped moving, not while it is moving fastest.
319
320
---
321
322
## 5. A staged path to parity
323
324
Each stage is independently shippable and independently useful. Sizes are rough and relative.
325
326
### Stage 1 — Ship `openagents api` (CLI repository only, small)
327
328
Add one leaf command taking a path, an optional `-X/--method`, repeated `-f key=value` body fields or `--input -` for raw JSON, and honoring the existing `--json` and profile flags. **Seam:** `packages/openagents-cli/src/cli.ts` plus a thin passthrough client in the `openagents` monorepo; no schema, no server change. **Size:** 150–250 lines with tests. **Effect:** every one of the 50 endpoints becomes reachable from a terminal, and every future endpoint arrives free. Also update `docs/openagents-cli/command-reference.md:171`, which currently lists a generic API command among the things the release does not provide, and fix the stale `repo delete` claim on the same line while you are in there.
329
330
### Stage 2 — Let an authenticated caller read their own private issues (this repository only, medium)
331
332
Move the issue, comment, label, milestone, and assignee read routes (`router.ex:235`–`:246`) from `pipe_through :api` into `pipe_through :optional_forge_api`, and change the resolution calls in `IssueController`, `CommentController`, `IssueLabelController`, `IssueAssigneeController`, `LabelController`, `MilestoneController`, and `AssigneeController` from `Repositories.get_public_by_path!/2` to `Repositories.get_visible_by_path!/3`, threading `conn.assigns.current_user` (which the optional plug sets to `nil` for anonymous callers). The visibility filters inside `Issues.get_issue_by_path!/3` (`issues.ex:159`), `Issues.get_comment_by_path!/3`, `Labels.get_label_by_path!/3`, and `Milestones.get_milestone_by_path!/3` need the same treatment. **Seam:** router pipelines, ten or so controller call sites, four context queries, and the `RouteAuthority` policy at `route_authority.ex:184`, whose `:public_read` classification for these paths becomes wrong. **Size:** medium; mechanical but wide, and every one of these controllers already has a test file to extend. **Effect:** additive and backward-compatible — anonymous callers see exactly what they see today; the change is that a bearer token now widens the result instead of being discarded. Without this, sections 3.1 ranks 1, 2, 8, and 9 only ever work on public repositories, so this gates most of the value of Stage 3.
333
334
### Stage 3 — The five commands that carry the traffic (CLI repository only, medium)
335
336
`issue list`, `issue view`, `issue create`, `issue close` / `reopen`, `issue comment`. Hand-written, with `-R owner/repo` falling back to the origin remote the way `repo view` already does (`cli.ts:695`), bare integers for issue numbers, `--json` for scripting, and a table for humans. **Seam:** new `issue-client.ts` and schema additions in `api-contract.ts` in the `openagents` monorepo, on top of Stage 2's reads. **Size:** medium — roughly five client methods and five commands at the codebase's observed 66 lines each, plus tests.
337
338
### Stage 4 — Pagination and filters on the API list endpoints (this repository only, small)
339
340
Change `IssueController.index` to call `Issues.list_issues_page/2` and pass through `label`, `assignee`, `milestone`, `q`, and `page` (`issue_controller.ex:10`; the query already supports all of them at `issues.ex:40`). Decide the pagination contract deliberately — the repository list already uses opaque cursors (`next_cursor`, `after`, `priv/api-contracts/repositories-v1.json:68`) while the issue context is offset-paged, and shipping a third convention would be a mistake. **Size:** small on the server; unlocks `issue list -l bug -a me` in the CLI without further server work.
341
342
### Stage 5 — Derive the contract document, and fail CI on divergence (both repositories, large)
343
344
Extend the `RouteAuthority` walk to emit the full `/api/v3` surface — path, method, pipeline-derived auth and scope, and response key sets where a `*_json.ex` view exists — into the published contract artifact, and add the two drift tests from section 4.3. Restructure the six controllers that render inline with `json/2` into view modules first, or accept that their responses stay undescribed. **Size:** large, and it is the only stage that should wait.
345
346
### Stage 6 — Labels, milestones, and assignee commands (CLI repository, medium)
347
348
Ranks 6 through 9. Straightforward once Stage 3 has established the client and command shapes.
349
350
### Stage 7 — Make projects addressable, then build project commands (this repository, then CLI; large)
351
352
Unpin the project surface from `Repositories.initial_repository!()` (`projects.ex:26`, `:131`), make `ProjectController.create` honor its own `:owner` segment instead of resolving `Repositories.initial_path()` (`project_controller.ex:21`), reconcile `POST /api/v3/:owner/projectsV2` with the `/users/:username/projectsV2` shape every other project route uses (`router.ex:290`), and give the project reads a visibility predicate. Only then are there project commands worth writing. **Size:** large, and it is genuinely a redesign rather than a port.
353
354
### What makes issue management usable this week versus complete
355
356
Stages 1 and 2 make it usable — the passthrough covers everything, and the read fix makes private repositories work. Stage 3 makes it pleasant. Stages 4 through 7 make it complete. Stage 5 is what makes it stay correct, and it is deliberately not early: deriving a document from a surface that Stages 2, 4, and 7 are still reshaping means regenerating it three times.
357
358
### The cross-repository cost, and how to handle it
359
360
The CLI ships from the `openagents` monorepo as `@openagentsinc/cli`, published to npm at version `0.1.7`, with its own `verify` pipeline (format, lint, typecheck, test, build) and its own release cadence. This repository deploys on its own. Four rules keep that from becoming a coordination tax:
361
362
1. **Order every stage server-first.** A released CLI in a user's `$PATH` will meet a deployed server. The reverse — a server change that only works with an unreleased CLI — creates a window where the published tool is broken. Stages 2, 4, and 7 land and deploy before the CLI stage that depends on them.
363
2. **Never require simultaneous merges.** Each stage above touches exactly one repository, by construction. Stage 2 is additive: anonymous reads keep their current behavior, so no released CLI breaks.
364
3. **Use Stage 1 to buy slack.** The passthrough's real strategic value is that it decouples the release trains — a new endpoint is reachable from the terminal the moment it deploys, with no npm publish, so nothing is ever blocked on a CLI release again.
365
4. **Make the one shared artifact a build failure rather than a memory.** `priv/api-contracts/repositories-v1.json` and its vendored twin at `packages/openagents-cli/contracts/repositories-v1.json` are the only file that must change in both repositories together. Generate it here, vendor it there, and have the CLI's CI fetch and compare (section 4.3). That converts a coordination problem into a named, failing test in a specific repository — which is the only form of cross-repository discipline that survives contact with two release cadences.
366
367
---
368
369
## 6. What is worth preserving
370
371
Not everything here needs changing, and two decisions are better than they look:
372
373
- **The bearer-token boundary is clean.** One credential family, one plug, one scope check, constant-time comparison, revocation and expiry honored, `last_used_at` bumped (`lib/openagents/api_tokens.ex:52`). The CLI's device flow and credential store are built to match it, and the `--profile` and `--api-url` split means a single binary talks to production, staging, and a local server without reconfiguration (`packages/openagents-cli/src/endpoint.ts:8` in the `openagents` monorepo).
374
- **`RouteAuthority` is the right instinct in the wrong strength.** An executable inventory that walks the real router and fails a test is exactly the mechanism a derived contract needs. It needs its two catch-alls narrowed and its scope labels checked against the pipelines that enforce them, not replacing.
375
- **The CLI's decode-on-every-response discipline is worth keeping under any generation strategy.** A `ContractError` naming the operation (`packages/openagents-cli/src/repository-client.ts:227`) is a much better failure than silently reading `undefined` off a changed field.
376
377
---
378
379
## 7. Open questions
380
381
- **Does the published npm package's vendored contract match the deployed server's?** Both working copies hash to `5be86539258c…`, but a stale local build in the CLI's gitignored `dist/` carries `9355cac5191e…`, which suggests the constant has changed at least once. Settle with `npm pack @openagentsinc/cli@0.1.7`, then `shasum -a 256 package/contracts/repositories-v1.json`, and compare against `curl -s https://openagents.com/api/contracts/repositories-v1.json | shasum -a 256`.
382
- **Is the absence of any visibility predicate on the four project reads intentional?** `ProjectController.index`, `show`, `items`, and `fields` apply none (`project_controller.ex:8`, `:41`, `:53`, `:144`), unlike every sibling controller. The pinning to one repository may be masking it. A test asserting that a project on a private repository is not listed anonymously would settle the policy either way.
383
- **Is `forge:read` in `route_authority.ex:184` an aspiration or a mistake?** If the intent is a read-only token scope, it needs adding to `@allowed_scopes` (`lib/openagents/api_tokens.ex:12`) and a `:forge_read_api` pipeline; if not, the label should say `forge:write`. Settle by asking whether a token that can file an issue should also be able to delete a repository, which is today's answer.
384
- **Does anything consume the `{"issues": [...]}` envelope, or could the list endpoints move to bare arrays for GitHub compatibility?** Settle by grepping the `openagents` monorepo and this repository's web layer for consumers before Stage 5 freezes the shape into a generated document.
385
- **What is the intended pagination convention across the whole API?** Repositories use opaque cursors and issues are offset-paged internally. Stage 4 has to pick one, and the choice belongs to whoever owns the contract document, not to the first endpoint that needs a page.

This page updates live while a promote is in flight · changelog