Add a Metabase API resource end-to-end — Zod schema in `packages/client/src/domain/`, methods in `packages/client/src/resources/`, list/get commands in `packages/cli/src/commands/<noun>/`, unit tests where logic warrants them, and a comprehensive e2e suite. Use whenever the user asks to "add a card/dashboard/<noun> command", "wire up `/api/<endpoint>` end-to-end", "add list/get for <noun>", or anything that introduces a new top-level subcommand backed by a previously-untyped Metabase resource. Loading this skill is mandatory before generating any file under `packages/client/src/domain/`, `packages/client/src/resources/`, `packages/cli/src/commands/<noun>/`, or `tests/e2e/<noun>.e2e.test.ts` — the contract is strict and the dependencies between layers are easy to break.
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
Add a Metabase API resource end-to-end — Zod schema in `packages/client/src/domain/`, methods in `packages/client/src/resources/`, list/get commands in `packages/cli/src/commands/<noun>/`, unit tests where logic warrants them, and a comprehensive e2e suite. Use whenever the user asks to "add a card/dashboard/<noun> command", "wire up `/api/<endpoint>` end-to-end", "add list/get for <noun>", or anything that introduces a new top-level subcommand backed by a previously-untyped Metabase resource. Loading this skill is mandatory before generating any file under `packages/client/src/domain/`, `packages/client/src/resources/`, `packages/cli/src/commands/<noun>/`, or `tests/e2e/<noun>.e2e.test.ts` — the contract is strict and the dependencies between layers are easy to break.
add-resource-command
Authoritative contract for adding a resource end-to-end. The work spans five layers — domain schema, resource methods, presentation view, commands, tests — plus a mandatory close-out (/review then /simplify) before the task is done. The order matters: earlier layers feed types into later ones, and deviating from house style at any step produces drift the type-checker can't catch.
A resource straddles both workspace packages. The schema and the endpoint knowledge are client surface and land in packages/client/src/domain/ and packages/client/src/resources/; the presentation binding and the commands that consume them are CLI surface and land in packages/cli/. Adding a domain file alone is not the unit of work — a schema without a method is unreachable, a resource without a command isn't useful, and a command without an e2e test isn't trustworthy.
The one-line shape of the whole thing: a command resolves its flags, calls client.<resource>.<method>(…), and renders the result. Every /api/ path, every query-parameter name, and every call to the HTTP transport lives in packages/client/src/resources/<r>.ts.
Step 0 — Pre-flight (mandatory)
Anchor to the existing house style. Skip this and you will produce drift the type-checker won't catch.
ls packages/client/src/domain/ and read one existing resource schema file end-to-end (e.g. packages/client/src/domain/segment.ts).
Read the matching packages/client/src/resources/segment.ts and its wire test packages/client/src/resources/segment.test.ts, then packages/client/src/client.ts for how a namespace is composed onto the client.
Read packages/cli/src/output/view.ts (the CLI-side ColumnDef<T> / ResourceView<T> contract) and one existing packages/cli/src/output/views/<r>.ts presentation binding.
Read one existing list command and one existing get command (look under packages/cli/src/commands/<noun>/). Note how <Resource>ListEnvelope, outputSchema, capabilities, parseId, renderList, renderItem, and defineMetabaseCommand compose.
Read one existing e2e test (tests/e2e/<noun>.e2e.test.ts) and the harness (tests/e2e/run-cli.ts, tests/e2e/bootstrap-data.ts). The add-e2e-test skill's runtime contract is binding here — re-read it before writing the e2e file.
Read packages/cli/src/output/types.ts for listEnvelopeSchema, the ListEnvelope<T> interface, and ListRange; then packages/cli/src/output/window.ts for the three window helpers (windowList, windowServerPage, collectForOutput) and the PageSource<T> / PageRequest contract.
What you must produce
For a typical list/get pair on a new resource:
packages/client/src/domain/<r>.ts — the schema pair (<Resource> + <Resource>Compact). No fixture, no schema-parse unit test.
Updated packages/client/src/index.ts — re-export every value the domain file exports from the public barrel.
packages/client/src/resources/<r>.ts — <r>Resource(transport), one method per endpoint.
Updated packages/client/src/client.ts — compose the namespace onto the client as <r>: <r>Resource(transport).
packages/client/src/resources/<r>.test.ts — the wire test: one it per method, asserting the exact URL, method, headers and body through captureFetch.
packages/cli/src/output/views/<r>.ts — the CLI-side <resource>View presentation binding.
packages/cli/src/commands/<r>/index.ts — citty group with subcommands.
Updated packages/cli/src/main.ts — register the new top-level subcommand.
Updated packages/cli/src/runtime/command-help.test.ts — add the new leaf paths to the literal ALL_COMMANDS list.
If the resource genuinely has more verbs (e.g. a <r> values for fetching distinct values), add them under the same group; the rules below scale per-verb.
The domain file is client surface, so it lives in @metabase/client, not the CLI. Two constraints follow from that:
A domain file imports zod and sibling domain/* files, nothing else. Not the rest of the client (../json, ../poll), not node: builtins, and never packages/cli — a client file that reaches into the CLI inverts the dependency between the two packages. The wider zod + semver + node: budget is the client package's ceiling; domain/ sits well inside it.
Nothing CLI-shaped leaks in. No @clack/prompts, no process.stdout.write, no process.exit — the client never owns presentation or process control.
A single file in packages/client/src/domain/ may host multiple resources (e.g. domain/user.ts exports CurrentUser / CurrentUserCompact). The pair holds per resource, not per file. <Resource> is PascalCase.
import { z } from"zod";
exportconstCard = z
.object({
id: z.number().int(),
name: z.string(),
archived: z.boolean(),
// ...other API fields with their real Zod types
})
.loose();
exporttypeCard = z.infer<typeofCard>;
exportconstCardCompact = Card.pick({ id: true, name: true, archived: true }).strip();
exporttypeCardCompact = z.infer<typeofCardCompact>;
Rules:
.loose() is the default — Metabase API additions must not break us. Tighten over time, never on first land. (Zod 4: .passthrough() is deprecated; use .loose().)
.strip() after .pick() is mandatory on the Compact, not optional. .pick({...}) on a .loose() parent inherits the loose catchall, and the picked schema then passes every API field through unchanged at parse time — your "compact" projection silently leaks the full payload into list output and default (compact) JSON. The bug is invisible until you eyeball the rendered output. Always end with .strip().
The compact projection is the agent-facing contract — it shows up in list output and default (compact) JSON. Pick the smallest set of fields that uniquely identifies + describes the resource for an LLM caller.
Type aliases via z.infer<typeof X>. Never hand-write a parallel interface — it will drift silently.
Optional vs. nullable. Metabase returns null for absent values; it rarely omits the key. Default to z.<base>().nullable() and reach for .optional() only when you have observed the key actually missing in a real response. Wrong here causes silent parse failures on real payloads.
Schema scope is principal-engineer judgment, not "mirror the frontend type." Pick the fields the agent needs to do its job (write queries, choose content). Drop sync flags, fingerprints, JSON-unfolding metadata, audit timestamps, and other internal plumbing. .loose() keeps the door open for fields the agent doesn't need declared. The schema's job is to declare what's required and what's typed — not to recapitulate the API.
Request-body schemas belong here too. A create or update verb takes a <Resource>CreateInput / <Resource>UpdateInput declared alongside the resource, and both the CLI's readBody and the resource method's parameter type read it from this one place.
Re-export every value the domain file exports from packages/client/src/index.ts, in the alphabetical ./domain/<r> block. A domain/ value the barrel does not name is not public client surface — a domain file alone does not make one.
Pin closed enums when the backend has one. The frontend often types a field as even when the backend enumerates the value via Clojure hierarchies. Pin the schema to over the closed set: agents get a typed surface, and a server-side addition becomes a hard parse failure (a signal we can act on) rather than a silent string. To find the closed set, check the backend Clojure source — typically or the resource's — for declarations under the resource's keyword namespace.
Forbidden:
Typing an API response as Array<Record<string, unknown>>, any, unknown, or an inline { ... } shape cast. The Zod schema is the single source of truth; downstream code consumes z.infer<typeof Schema>.
Declaring a separate interface <Resource> { ... } next to const <Resource> = z.object(...). Use the inferred type alias.
Putting the schema anywhere except packages/client/src/domain/. No schemas.ts, no api.ts, no packages/cli/src/commands/<x>/types.ts — a schema declared CLI-side is invisible to every other consumer of the client.
Declaring the server's wire envelope here. { data: [...], total: N } around a list, or any other shape the transport unwraps and never hands back, is module-private to resources/ (Step 2). domain/ holds only what a caller receives.
Editing an existing command to wire the schema in. Every layer here is purely additive; the command in Step 3 is the one that consumes it.
Adding a packages/client/tests/fixtures/<r>/sample.json + colocated parse-test pair. Schema.parse(fixture).toEqual(fixture) is a tautology against Zod itself, with zero signal about whether the schema matches a real response. The schema is contract-tested by the e2e tier in Step 5.
One file per Metabase resource, exporting <r>Resource(transport) — a factory returning the object of methods reached as mb.<r>.<method>(…). This is the only layer that names an /api/ path or calls the transport.
import { z } from"zod";
import {
Card,
typeCardCreateInput,
typeCardListFilter,
typeCardUpdateInput,
} from"../domain/card";
importtype { RequestOptions, Transport } from"../http/transport";
importtype { ListResult } from"../list";
// `GET /api/card` answers a bare array rather than a `{ data, total }` envelope, so the count a// caller reads off `ListResult` is the array's own length and the server reports none.constCardApiList = z.array(Card);
exportinterfaceCardListParams {
f?: CardListFilter | undefined;
model_id?: string | undefined;
}
exportfunctioncardResource(transport: Transport) {
/** List cards. `f` picks a server-side preset; `model_id` scopes the presets that need an id. */asyncfunctionlist(): <<>> {
data = transport.(, , {
...options,
: { : params., : params. },
});
{ data, : };
}
(): <> {
transport.(, , { ...options });
}
(): <> {
transport.(, , { ...options, : , : params });
}
(): <> {
transport.(, , {
...options,
: ,
: params,
});
}
(): <> {
(id, { : }, options);
}
{ list, get, create, update, archive };
}
The eight conventions every method follows:
client.<resource>.<method>(...), namespace named after the API resource (mb.card, singular).
The resource is what Metabase calls the thing, not what it calls the route: the file and the
namespace are snippet even though the endpoint is /api/native-query-snippet, because the
domain schema, the CLI noun and the Metabase concept all read snippet.
Path parameters positional, then params, then options: update(id, params, options?).
Params use Metabase's own field names verbatim — f, model_id, include_inactive. No mapping
layer, no renaming.
Transport concerns (signal, timeoutMs, retries) live in the trailing options, never in
params.
Wire envelope schemas are module-private to resources/; they never appear in domain/.
Methods return domain values, never wire envelopes. Non-paginated lists return
ListResult<T> = { data, total }.
A string path parameter always goes through encodeURIComponent.
Every method carries the endpoint's description as a doc comment.
Convention 7 has a companion habit: when every path parameter on a resource is a numeric id, say so in a one-line comment at the top of the factory, so a reader knows the omission was decided rather than forgotten.
Further rules:
Register the namespace.packages/client/src/client.ts imports the factory and adds <r>: <r>Resource(transport) to the returned object, alphabetically. Until that line exists, client.<r> does not resolve.
Only client.ts and resources/ may import resources/. The direction runs one way; a domain/ or http/ file reaching for a resource inverts it.
A paged endpoint returns pages. Where Metabase itself applies limit/offset, the method returns AsyncIterable<Page<T>> from paginatePages(transport, path, ItemSchema, { query, offset, max, pageSize, signal }) and is named <thing>Pages. The caller decides how far to pull.
A 204-for-absent endpoint goes through fetchOptionalParsed in packages/client/src/resources/optional-parsed.ts rather than a bespoke status check.
The client never formats, prompts, or exits. A method returns a value or throws from the taxonomy in packages/client/src/errors.ts.
packages/client/src/resources/<r>.test.ts — the wire test, one it per method. It builds a real client over captureFetch from @metabase/client/testing/fetch-capture and asserts the exact request in a single toEqual:
This is the test that pins the path, the verb, the query vocabulary and the body serialization — the things a rename would otherwise silently change. vi.mock is not how you write it: captureFetch scripts real responses through the real transport.
Step 3 — Presentation and commands
Commands are CLI surface: they live in packages/cli/src/commands/<r>/ and reach the schema across the package boundary as @metabase/client/domain/<r>. Everything CLI-internal (../../output/render, ../../output/views/<r>, ../flags) stays relative. Each leaf command uses defineMetabaseCommand.
packages/cli/src/output/views/<r>.ts — the presentation binding, one file per domain resource-file, imported by the commands. Presentation is CLI-owned; it never sits on the client surface.
tableColumns keys must be valid keys of the resource type; the columns render text/table output while compactPick drives compact JSON.
Any presentation-only cell formatter (a format: function that turns a nested value into a table string) lives in this file beside the view — never in the domain schema.
Then the three command artifacts per resource group:
packages/cli/src/commands/<r>/index.ts — a tiny citty group via defineCommandGroup:
Add alias if a short or alternate name is helpful (e.g. db aliasing database), and skills to point agents at the skill files that explain the resource's payloads.
packages/cli/src/commands/<r>/list.ts — exports the envelope schema, uses it as outputSchema:
...listFlags is mandatory on every list verb: it contributes --limit / --offset, which the command shell resolves into the ctx.range that the window helper consumes. Omit it and ctx.range is the full range, so the flags an agent needs to page silently do nothing.
The command names no endpoint. Two absolute rules govern packages/cli/src/commands/** — a command must not name an API path (any "/api/ or `/api/ literal) and must not drive the HTTP transport directly (requestParsed / requestRaw / requestStream / paginatePages). Neither admits an exception. A new command that builds its own request breaks both, and the fix is always the same — the request belongs in Step 2.
Pick the window helper by who applied the window — one of the three in packages/cli/src/output/window.ts. Never hand-roll the envelope object; picking wrong produces a plausible envelope that no test catches.
Helper
Use when
windowList(data, ctx.range, total)
The method returned the whole result set as a ListResult<T> and the slice is client-side. The common case — forward the total it reports as it stands.
windowServerPage(data, total, ctx.range)
The endpoint applied limit/offset itself, so data is already the slice and total is the server's count (see search.ts).
collectForOutput(source, view, ctx)
A genuinely paged endpoint, where source is a PageSource<T> forwarding the helper's PageRequest into the resource's <thing>Pages method (see below).
collectForOutput pulls only as far as the output byte budget can display, so an unbounded listing over a large collection costs a page or two rather than a full drain the cap then discards. It sizes each request through the PageRequest it hands the source — forward max and pageSize verbatim, or the budget cannot bound the walk.
Every command declares capabilities: { minVersion: 58 } is the supported baseline (no probe, no enforcement), a higher minVersion or a tokenFeature gates the command behind the preflight check, and null marks a command that never touches a Metabase server. Validate the right minVersion against the Metabase route files before picking one.
The <Resource>ListEnvelope export is mandatory. It is consumed by JSON help (--help --json, via outputSchema) and by the matching e2e test (which imports it back to parse --json output). Do not redeclare the envelope shape inline anywhere. It is the CLI's envelope — { data, returned, offset, limit?, total?, has_more, next_offset?, truncated? }, declared in packages/cli/src/output/types.ts — and has nothing to do with the server's wire envelope, which stayed module-private in Step 2.
packages/cli/src/commands/<r>/get.ts — positional id parsed via parseId:
packages/cli/src/main.ts — register the new top-level subcommand alongside the existing entries.
When smoke-testing commands by hand, never pass an API key on argv — Metabase keys must come through env (MB_URL, MB_API_KEY) or stdin. The runtime hook will block argv-embedded keys.
Step 4 — Unit tests
The resource wire test from Step 2 is not optional; everything in this step is on top of it.
Add a <verb>.test.ts next to a command only where there is non-orchestration logic to test. Examples that warrant a unit test:
A --<flag>-style filter the CLI applies locally after the client call returns (test the filter independently of the network round-trip).
Argument-parsing branches with multiple failure modes (a new parser; parseId itself is centralized and already covered).
A response-shape transformation that maps multiple fields or merges two methods' output.
An error-mapping branch that converts a specific HTTP status to a specific user message.
Forbidden unit tests (silent drift, fail-by-tautology):
Schema.parse(fixture).toEqual(fixture) — only proves Zod works.
vi.mock('citty') or vi.mock('@metabase/client/client') to "test" a command — collapses to "the mock returned what I configured." If the only way to unit-test a branch is to mock a project helper, push the coverage to e2e instead. The client ships testing/fake-client.ts and testing/fetch-capture.ts as the sanctioned test doubles; reach for those before a module mock.
Tests that re-encode the implementation (e.g. asserting the exact arg list a command passed to a resource method).
When in doubt, push coverage to the e2e tier — it runs the real path. A command whose run body is purely "resolve flags, call the client, render" has nothing meaningful to assert at the unit tier; that's fine, and you should say so explicitly when filling in the sanity-check list at the bottom.
Step 5 — E2E tests (comprehensive)
Live under tests/e2e/<r>.e2e.test.ts at the repo root — the e2e tier is workspace-wide, not per-package. Drive the builtpackages/cli/dist/cli.mjs against the seeded warehouse via runCli. Re-read the add-e2e-test skill before writing the file — its runtime contract (no vi.mock, no execa/spawn, env hygiene, per-test config home, opaque license tokens, no /api/testing/* calls) is binding here.
A comprehensive suite for a typical list/get pair covers, at minimum:
List, default flags — exitCode === 0, parsed via <Resource>ListEnvelope (imported from packages/cli/src/commands/<r>/list.ts), asserts the seeded items appear with the expected compact fields via a single toEqual({ ...full envelope... }) when feasible. If the list is unbounded (paginated or non-deterministic order), assert the shape and the presence of stable items rather than the full array, and still spell out the expected items as full objects.
List, a meaningful filter flag (if the command has one) — exitCode === 0, asserts the filter narrowed the result and that every returned item satisfies the filter.
Get, success — exitCode === 0, parsed via <Resource> (with --full) or <Resource>Compact (default). Assert the parsed object with one toEqual({ ... }) over the full expected payload, never a sequence of expect(parsed.id).toBe(...)/expect(parsed.name).toBe(...) field pokes. Use a seeded id from tests/e2e/seed/seeded.ts (SEEDED) or a pinned constant from tests/e2e/seed/ids.ts, otherwise look up the id dynamically by listing first and filtering by a known name.
Get, invalid positional (abc, empty, negative, zero) — exitCode === 2 (ConfigError), stderr.toContain('invalid id: "<value>" (expected integer)') (the literal message from packages/cli/src/commands/parse-integer.ts, which parseId delegates to), stdout empty.
Get, valid format but missing on server (e.g. 9999999) — exitCode === 1 (HttpError), stderr.toContain("Not found.") (the literal userMessage from Metabase's 404 envelope; see packages/client/src/http/errors.ts for the taxonomy).
Assertions are exact at every level — these are not stylistic preferences, they are hard rules from CLAUDE.md and the add-e2e-test skill:
Exit codes — always the exact integer (toBe(0), toBe(1), toBe(2), toBe(130)). Never .not.toBe(0). The packages/client/src/errors.ts taxonomy is fixed: ConfigError=2, CapabilityError (packages/client/src/version/preflight-error.ts)=2, AbortError=130, all others=1.
Error strings — always toContain("<exact substring>") or toBe("<exact full string>"). Never toMatch(/.../i). Look the literal up in packages/ and pin it. A regex with \d+ or .* for a dynamic part is FAIL — build the expected string from the same source the production code consumed and assert with toBe.
Parsed payloads — always one full toEqual({ ... }). Field-by-field toBe after parseJson is FAIL.
If the command has additional verbs or flags with branching behavior, add a test per branch.
Schemas are imported, never redeclared. E2E tests reach the client by package specifier and the CLI by relative path from the repo root:
Single item: <Resource> / <Resource>Compact from @metabase/client/domain/<r>.
List envelope: <Resource>ListEnvelope from ../../packages/cli/src/commands/<r>/list.
parseJson from @metabase/client/json.
If the command needs auth (the common case), pass bootstrap.adminApiKey and bootstrap.baseUrl via runCli({ env: { MB_URL, MB_API_KEY } }) — never via argv.
Step 6 — Command-list parity
packages/cli/src/runtime/command-help.test.ts has a literal ALL_COMMANDS list. Add the new entries (<r> list, <r> get, …) in the same place new commands appear. Without this update the command-tree contract test fails on a clean run.
Step 7 — Self-grep before close-out (mandatory)
Run each of these. Any hit must be fixed; then re-run.
# Domain file (client surface — lives in packages/client):
rg -n "Record<\s*string\s*,\s*unknown\s*>" packages/client/src/domain/<r>.ts && echo FAIL || echo OK
rg -n "\bas \b[A-Z]" packages/client/src/domain/<r>.ts && echo FAIL || echo OK
rg -n ":\s*any\b|<any>" packages/client/src/domain/<r>.ts && echo FAIL || echo OK
rg -n "[\w\)\]]!\." packages/client/src/domain/<r>.ts && echo FAIL || echo OK
rg -n "@ts-(ignore|nocheck|expect-error)" packages/client/src/domain/<r>.ts && echo FAIL || echo OK
rg -n "\.pick\(\{[^}]*\}\)\s*;" packages/client/src/domain/<r>.ts && echo FAIL || echo OK # `.pick(...)` not followed by `.strip()`
rg -n "@metabase/cli|\.\./\.\./cli/" packages/client/src/domain/<r>.ts && echo FAIL || echo OK # the client must not reach into the CLI
rg -n "/api/" packages/client/src/domain/<r>.ts && echo FAIL || echo OK # endpoints belong to resources/# Resource file (the endpoint layer):
rg -n "\.request(Parsed|Raw|Stream)\(" packages/client/src/resources/<r>.ts || echo"FAIL: no transport call — is this file doing anything?"
rg -n "\$\{[a-zA-Z_$][\w$]*\}" packages/client/src/resources/<r>.ts # every string interpolation: numeric id, or encodeURIComponent?
rg -n "<r>Resource" packages/client/src/client.ts || echo"FAIL: namespace not composed onto the client"# Command files (CLI surface — flags in, client call, render out):
rg -n packages/cli/src/commands/<r>/ && FAIL || OK
rg -n packages/cli/src/commands/<r>/ && FAIL || OK
rg -n packages/cli/src/commands/<r>/ && FAIL || OK
rg -n tests/e2e/<r>.e2e.test.ts && FAIL || OK
rg -n tests/e2e/<r>.e2e.test.ts && FAIL || OK
rg -n tests/e2e/<r>.e2e.test.ts && FAIL || OK
rg -n tests/e2e/<r>.e2e.test.ts && FAIL || OK
rg -n tests/e2e/<r>.e2e.test.ts && FAIL || OK
rg -n tests/e2e/<r>.e2e.test.ts && FAIL || OK
rg -n tests/e2e/<r>.e2e.test.ts && FAIL || OK
rg -n tests/e2e/<r>.e2e.test.ts && FAIL || OK
rg -n tests/e2e/<r>.e2e.test.ts && FAIL || OK
Step 8 — Runnable verification (mandatory, all green before close-out)
bun run check
bun run build
bun run test:e2e tests/e2e/<r>.e2e.test.ts
bun run check is the whole gate — typecheck, lint, format, then the unit tier: the resource wire tests above, plus every colocated *.test.ts in both packages.
If any step fails: fix and re-run, do not paper over. If you cannot run them in your environment, say so explicitly — do not claim "done."
Step 9 — Close-out: /review then /simplify (mandatory, in this order)
Both are mandatory and must run after Step 8 is green. Do not skip either.
/review — runs the strict end-of-task review skill against the diff. Any FAIL must be addressed before continuing. Do not argue with findings; fix them, or skip with an explicit one-line rationale. Re-run /review until it returns RESULT: PASS.
/simplify — runs the reuse / quality / efficiency review. Apply each finding that survives the rubric; skip false positives with a one-line note. Resolve any structural issues /simplify surfaces (redeclared schemas, missing command-list entries, magic-literal duplication, etc.) by going back to the relevant earlier step.
If either skill surfaces a structural issue (missing .strip(), a request built inside a command, a redeclared list envelope, a missing command-list entry, an e2e test missing a coverage axis), the work is unfinished — fix and re-run the close-out from /review.
Sanity checks before declaring done
Step 0 actually performed (read existing domain file, resource file + wire test, client.ts, list command, get command, e2e test, packages/cli/src/output/types.ts, packages/cli/src/output/window.ts).
Domain file landed in packages/client/src/domain/, importing only zod and sibling domain/* files.
Domain pair: <Resource> with .loose(), <Resource>Compact with .pick({...}).strip(). No hand-written parallel interface.
Every value the domain file exports is re-exported from packages/client/src/index.ts.
Closed enums pinned via z.enum([...]) where the backend defines a closed set.
Schema scope is query/agent-relevant fields only — no sync flags, fingerprints, audit timestamps, or other internal plumbing unless they drive an actual decision.
No fixture or schema-parse unit test added.
Resource filepackages/client/src/resources/<r>.ts exports <r>Resource(transport), holds every /api/ path and every transport call, and follows all eight conventions — positional path params then params then options, Metabase's own field names, transport concerns in options, wire envelopes module-private, domain values returned, ListResult<T> for a non-paginated list, encodeURIComponent on every string path param, a doc comment per method.
Namespace composed onto packages/client/src/client.ts as <r>: <r>Resource(transport).
Wire testpackages/client/src/resources/<r>.test.ts asserts URL, method, headers and body for every method through captureFetch.
landed in (not the domain file, not the resource file), with any cell helpers colocated there.
If any box is unchecked, the task is unfinished — state explicitly which and continue working.
Commands landed in packages/cli/src/commands/<r>/, call client.<r>.<method>(…), and name no /api/ path and no transport method — the two rules admit no exception.
List command exports <Resource>ListEnvelope = listEnvelopeSchema(<Resource>Compact) and uses it as outputSchema.
List command spreads ...listFlags and builds its envelope with the window helper that matches who applied the window (windowList / windowServerPage / collectForOutput) — never a hand-rolled object.
Get command uses parseId for the positional integer id.
Every leaf command declares capabilities explicitly ({ minVersion: … } or null).
packages/cli/src/main.ts registers the new subcommand.
Command unit test added only where non-orchestration logic exists (or none added, with explicit rationale — "command body is pure orchestration" is acceptable).
E2E test at tests/e2e/<r>.e2e.test.ts covering at minimum: list default, list filtered (if applicable), get success, get invalid id, get missing id.
E2E test imports schemas from @metabase/client/domain/<r> and ../../packages/cli/src/commands/<r>/list; no z.object({...}) redeclaration of any output shape.
packages/cli/src/runtime/command-help.test.tsALL_COMMANDS list updated with the new leaves.
Self-grep step (Step 7) ran clean.
bun run check, bun run build, bun run test:e2e <new-files> all exited 0.
/review returned PASS.
/simplify findings either applied or explicitly skipped with a reason.