11.5 `@motir/cli` migrates onto `/api/v1` — generated types replace the hand-mirrored interfaces
The story that makes the API's first claim true — the CLI is a peer client of the public API, with no private surface and no privileges. Until this lands, /api/v1 has no in-house consumer proving it is complete, and @motir/cli still reaches the server through an agent-shaped protocol.
blocked_by 11.4 — the generated types come from that story's spec, and the point of the migration is to STOP hand-mirroring — and blocked_by 11.7, which ships the ten endpoints this story's own audit found missing (below).
Why the CLIENT side is small (verified, origin/main @ d4023b3b)
The MCP coupling is confined to exactly ONE file. packages/cli/src/mcpClient.ts is the only file in packages/cli/src that imports the MCP SDK or touches callTool / structuredContent; all 32 call sites in the command modules reach it through MotirClient's methods (client.listSprints(…), client.getWorkItem(…)). So the CLI's whole value — the command surface, the flags, render.ts, the worktree/dispatch orchestration, session.ts — is already transport-agnostic. Swapping the class's innards from tools/call to GET /api/v1/… touches no command and no renderer.
That remains true. What was NOT true is the assumption underneath it — that /api/v1 already had somewhere for each of those methods to go.
⚠️ The open caveat is RESOLVED — and the answer moved work OUT of this story (expansion pass, 2026-08-05)
This story asked for "a rung-2 audit of mcpClient.ts's method list against the shipped v1 surface" before assuming every operation had a home. That audit ran. Of the CLI's 19 server operations, 9 are covered, 10 have no /api/v1 endpoint at all — dispatch_prompt, mark_integrated, complete_session, expand_item, the four plan-session verbs, get_plan, and the changes / all views of get_work_item_activity. Those are not peripheral: they are what motir run, motir auto, motir batch and motir plan are made of. Three response FIELDS are missing too — per-child dependency edges on the detail read, per-row edges on the work-item collection, and the blocked ancestor's title — each one feeding a renderer that would otherwise silently lose a column.
The full table, its evidence and its remedy live in 11.7, created by this pass and wired as this story's blocker. It went to a new story rather than into this one for two reasons: this story's boundary (below) forbids server changes and that boundary is correct; and this story's original instruction — "a corrective card in the owning story" — resolved to 11.1–11.4, all four done, i.e. to nowhere.
So no command is silently dropped, and no endpoint is invented here. Every gap has an owner, a card and an edge.
What to build
- Re-implement
MotirClientover HTTP — same public method surface,fetchagainst/api/v1with theAuthorization: BearerPAT the CLI already stores (.motir.json+ the user config;auth loginneeds no change beyond its validation probe). Keepconnect()/close()as no-ops or drop them behind the method surface, so no command file changes. - Replace the ~20 hand-mirrored interfaces with GENERATED types from 11.4's spec, and parse instead of cast — the
structuredContent as Tunchecked cast becomes a validated parse at the client boundary, so a server-shape change surfaces as a precise error naming the field rather thanundefinedin a rendered table. DeleteReadyItemSummary,SearchItemSummary,WorkItemDetail,SprintSummaryand the rest as hand-written types. - Adapt, don't re-render. The v1 shapes are not the MCP shapes (
{ item, ancestors, parent, children, blockedBy, … }becomes a flat item withancestorKeys+linksgroups).render.tsmust not change, so the client owns a thin ADAPTER layer that maps each v1 response onto the view model the renderers already consume. That adapter boundary is the migration's real seam and is where the byte-identical property is kept or lost. - Map HTTP statuses to the CLI's existing error types — 401 →
AuthError(replacing the current/\b401\b|unauthorized/iregex over an error message, which exists only because MCP reports auth failures in band), 403 → a scope-hint error naming the missing scope, 429 → a rate-limit error that reportsX-RateLimit-Reset, 404 → the not-found path. - Handle the version skew honestly — the CLI is published to npm independently of the server, so it reads the server's declared API version and, on an incompatible server, says "this CLI needs Motir ≥ X — upgrade" ONCE, instead of the per-field defensive degradation branches the MCP era required (e.g. 7.9.16's "a server that returns rows without a
dependenciesblock degrades to today's columns"). One gate replaces N hand-written fallbacks. - Drop
@modelcontextprotocol/sdkfrompackages/cli's dependencies once nothing imports it — a published CLI should not ship an agent-protocol SDK to every installer — and stop describing itself as an MCP client inpackage.json, the README anddocs/cli.md. - Release it. A migration nobody can install is not shipped (Principle #10). The story ends at a published
@motir/cliwhose npm build speaks v1, in the canonical three-leg shape the package already uses: prep (a PR), the tag cut (a human, after the merge), and the record leg.
Sequencing — Story 7.9 needs no rework
MOTIR-809 continues on MCP while this epic is built, and that costs nothing: its in-flight cards (7.9.16, 7.9.16b) ship rendering and projection work that is transport-independent, and this story rewrites the one file underneath them. Do NOT block or re-scope any 7.9 card on this epic.
Type sweep — what this story does NOT need, deliberately
design — no rendered surface, and the strongest possible form of that: the terminal output must be BYTE-IDENTICAL, so render.ts is forbidden to change. decision — one, on the client contract (the generator, the runtime-validation boundary, the skew comparison). copy/translate — the CLI is English-only (no next-intl, no message catalog in packages/cli); the new 403 / 429 / skew sentences are error strings the decision card pins, not localized product copy. legal — no new artifact. research/spike — the decision card carries the grounding. manual/human — exactly ONE, the tag cut; the npm name, the OIDC Trusted Publisher and the cli-v* lane all shipped with 8.7.9 and its successors, so nothing needs provisioning. So: one decision, six code, two test, one doc, one deploy.
No acceptance video. The story's own success condition is that a user sees no change — there is nothing to watch — and the epic's sibling API stories took the same non-UI exemption. Acceptance rides the tests and the byte-identical assertion.
Scope BOUNDARY
Ends at a PUBLISHED @motir/cli speaking /api/v1 with generated, validated types. (Amended by this pass: the original boundary stopped at the merged migration. A published CLI still speaking MCP is the whole gap this story exists to close, so the release trio is in scope — the three legs are separate cards precisely because a merge and a publish are the two seams one card cannot cross.)
It does NOT change any command's behaviour, flags or output format, and does NOT change render.ts — a user must see identical output before and after, which is the migration's own safety property; does NOT add or remove a CLI command; does NOT change the server (11.1–11.4 built it, 11.7 completes it — every endpoint gap found by the audit is 11.7's, and this story adds none); does NOT change the MCP surface (11.6); and does NOT touch the agent-launch, git or worktree code.
ONE repo — motir-core, which is where packages/cli lives — for every card, so nothing here straddles the open-core boundary.
⚠️ Amended 2026-08-06 — the "does NOT change the server" boundary has ONE exception, and why
That boundary was written while 11.7 was OPEN and could own any gap this story's consumer exposed — "every endpoint gap found by the audit is 11.7's, and this story adds none." 11.7 is now done, so gaps found by this story's own RUN have no other owner, and deferring them to a shipped story defers them to nowhere (the same dead end the original 11.5 caveat hit when it pointed at 11.1–11.4).
So: 11.5.13 is the one card here that touches /api/v1, and it is scoped to exactly what the CLI migration proved missing on the ready row — a decision plus at most one additive field. Everything else in the boundary above stands unchanged: no new endpoint, no MCP change, no other resource's schema.
The rule this leaves behind: a consumer story that finds a producer gap AFTER its producer story has shipped closes the gap itself, in one narrowly-scoped card, and amends its own boundary to say so — rather than filing into a done story or spawning a story per finding.
Acceptance criteria
- Every CLI command works against
/api/v1with byte-identical human output to the MCP-era implementation for the same data — asserted by the existing command-level suites, whose assertion strings are UNCHANGED (a diff on them is the tell that the migration lost something, not that the test was wrong). packages/cli/src/mcpClient.ts's hand-written response interfaces are GONE, replaced by generated types; noas Tcast remains on a wire payload.- A response whose shape does not match its schema produces a precise error naming the field — asserted with a deliberately malformed payload — never a silently-blank rendered cell.
- 401 / 403 / 429 / 404 map to the CLI's typed errors; the 403 message names the missing scope and the 429 reports the reset time.
- An incompatible server version produces ONE clear upgrade message, not a per-field degradation.
@modelcontextprotocol/sdkis no longer a dependency ofpackages/cli, and no file imports it — asserted by a test, not by inspection.- Every operation the CLI performs resolves to a
/api/v1endpoint or to a named 11.7 deliverable — no command is silently dropped, and no private route is added for the CLI. - The built binary is driven against the REAL
/api/v1routes over real HTTP with a real PAT and real Postgres (the existing story-suite harness, re-pointed), not a mocked client. packages/cliholds its ≥90% per-file coverage floor.- The published npm package a user installs speaks v1 — proven by installing the published version and running a command against a real server, not by the release lane being green.
Context refs
packages/cli/src/mcpClient.ts— the ONE file this story rewrites:MotirClient, its 19 typed wrappers, the ~20 mirror interfaces,callStructured,isUnauthorized's 401 regex.packages/cli/src/commands/— the 32 call sites that must NOT change.packages/cli/src/render.ts— pure rendering, must not change.packages/cli/src/errors.ts—CliError/AuthError, the typed errors statuses map to.packages/cli/vitest.config.ts— the per-file ≥90% coverage gate and its documented carve-outs.tests/cli/cli-story.test.ts+tests/helpers/cliHarness.ts+tests/helpers/mcpHttpServer.ts— the built-binary-over-real-HTTP harness. The last of those serves real route modules over a socket and today serves exactly/api/mcpand the two device routes; re-pointing it at/api/v1is a real deliverable, because every v1 route takes a DYNAMIC path segment and the adapter has never had to extract one.app/api/openapi/v1.json/route.ts+lib/api/v1/openapi/emit.ts— where the spec is served and whereinfo.version(V1_CONTRACT_VERSION,1.0.0) comes from..github/workflows/release-cli.yml— thecli-v*tag lane, which also fires the BYOK sandbox image build.- Producer: 11.4 (the spec + generated types). Blocker: 11.7 (the missing endpoints). Release precedent: prep → cut → record, the shape
cli-v0.1.1shipped in. Related: MOTIR-809 (the CLI story, continuing on MCP meanwhile). Parent epic: the public REST API.
⚠️ Amended 2026-08-05 by 11.4.1 — the spec's URL, its emitter and what info.version means
ADR Amendment 4 settles the three things this story consumes, so nothing here has to be rediscovered:
- The spec is fetched from
/api/openapi/v1.json(Amendment 4, Q3) — a stable, anonymous URL outsideapp/api/v1, so no PAT is needed to generate types and CI can fetch it. Point the type-generation step at that path; do NOT hard-code/api/v1/openapi.json, which was the pre-decision guess and is not where it lives. - Types are generated from an OpenAPI 3.1 / JSON-Schema-2020-12 document emitted by
zod/v4's first-partyz.toJSONSchema()(Q1). The generator must accept 3.1 — there is no 3.0 down-converted variant to fall back to. - The version-skew gate (criterion 5 above) reads
info.version, and Amendment 4 Q6 pins its meaning: the API contract'sMAJOR.MINOR.PATCH, where MAJOR is the path version, MINOR increments on an additive change under §8 and PATCH on a documentation-only correction. It is not the deployment's release number, so "this CLI needs Motir ≥ X" is a comparison against the contract version, never against an app release tag. - A
v2is a SECOND document at/api/openapi/v2.json, served alongsidev1(Q6) — so the skew gate compares majors and never assumes one document exists.