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.tsand its wire testpackages/client/src/resources/segment.test.ts, thenpackages/client/src/client.tsfor how a namespace is composed onto the client, andpackages/client/src/version/requirements.tsfor how every method is keyed there. If the resource's routes or shapes differ between supported Metabase majors, also readpackages/client/src/version/features.tsand the drift site inpackages/client/src/domain/transform.ts+packages/client/src/resources/transform.ts. - Read
packages/cli/src/output/view.ts(the CLI-sideColumnDef<T>/ResourceView<T>contract) and one existingpackages/cli/src/output/views/<r>.tspresentation binding. - Read one existing list command and one existing get command (look under
packages/cli/src/commands/<noun>/). Note how<Resource>ListEnvelope,outputSchema,requires,parseId,renderList,renderItem, anddefineMetabaseCommandcompose. - Read one existing e2e test (
tests/e2e/<noun>.e2e.test.ts) and the harness (tests/e2e/run-cli.ts,tests/e2e/bootstrap-data.ts). Theadd-e2e-testskill's runtime contract is binding here — re-read it before writing the e2e file. - Read
packages/cli/src/output/types.tsforlistEnvelopeSchema, theListEnvelope<T>interface, andListRange; thenpackages/cli/src/output/window.tsfor the three window helpers (windowList,windowServerPage,collectForOutput) and thePageSource<T>/PageRequestcontract.
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/version/requirements.ts— one"<r>.<method>"entry per method inMETHOD_REQUIREMENTS, and a rule inpackages/client/src/version/features.tsif the route is gated by a major or a token feature. - 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: oneitper method, asserting the exact URL, method, headers and body throughcaptureFetch.packages/cli/src/output/views/<r>.ts— the CLI-side<resource>Viewpresentation binding.packages/cli/src/commands/<r>/index.ts— citty group with subcommands.packages/cli/src/commands/<r>/list.ts— exports<Resource>ListEnvelope, usesrenderList.packages/cli/src/commands/<r>/get.ts— usesparseIdfor the positional id, usesrenderItem.- (optional)
packages/cli/src/commands/<r>/<verb>.test.ts— unit test only where there is non-orchestration logic to test. tests/e2e/<r>.e2e.test.ts— comprehensive e2e suite.- 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 literalALL_COMMANDSlist.
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.
Step 1 — Domain schema (packages/client/src/domain/<r>.ts)
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, siblingdomain/*files and — only for a reader that selects a wire shape by generation —import type { Features } from "../version/features", nothing else. Not the rest of the client (../json,../poll), notnode:builtins, and neverpackages/cli— a client file that reaches into the CLI inverts the dependency between the two packages. The widerzod+node:budget is the client package's ceiling;domain/sits well inside it. - Nothing CLI-shaped leaks in. No
@clack/prompts, noprocess.stdout.write, noprocess.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";
export const Card = z
.object({
id: z.number().int(),
name: z.string(),
archived: z.boolean(),
// ...other API fields with their real Zod types
})
.loose();
export type Card = z.infer<typeof Card>;
export const CardCompact = Card.pick({ id: true, name: true, archived: true }).strip();
export type CardCompact = z.infer<typeof CardCompact>;
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 parallelinterface— it will drift silently. - Optional vs. nullable. Metabase returns
nullfor absent values; it rarely omits the key. Default toz.<base>().nullable()and reach for.optional()only when you have observed the key actually missing in a real response on every supported server..optional()means "any server may omit this"; a key that one generation sends and another does not is version drift and takes the wire-variant path below, never an optional. 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>UpdateInputdeclared alongside the resource, and both the CLI'sreadBodyand 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. Adomain/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
string | nulleven when the backend enumerates the value via Clojure(derive :namespace/X :namespace/parent)hierarchies. Pin the schema toz.enum([...])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 — typicallysrc/metabase/types/core.cljcor the resource'sapi.clj— forderivedeclarations 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 consumesz.infer<typeof Schema>. - Declaring a separate
interface <Resource> { ... }next toconst <Resource> = z.object(...). Use the inferred type alias. - Putting the schema anywhere except
packages/client/src/domain/. Noschemas.ts, noapi.ts, nopackages/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 toresources/(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.
Step 1a — When a shape differs between supported Metabase majors
Establish the fact first: read the endpoint on each release branch of the reference Metabase checkout (origin/release-x.58.x … the newest, and master), or curl the matrix stacks, and write down which majors answer which shape. Then:
- Add a rule to
FEATURE_RULESinpackages/client/src/version/features.ts, named after the behaviour (transformTargetTableId,libraryChildrenCarryType) and never after a version.{ since }for a shape that arrived,{ since, until }for one only older servers have,tokenFeaturewhen a premium feature gates it.features.test.tsfails a version-gated rule that is constant acrossKNOWN_RANGE.min … max + 1(the head slot included, so a rule written against head is legal), andmajor-comparison-guard.test.tsfails a.majorcomparison anywhere else — the rule is the only place the major is read. - The canonical
<Resource>is the newest supported server's shape. A field an older server cannot report is.nullable()on the canonical,nullmeaning "this server cannot say". - Each older generation gets a module-private
<Resource>WireV<N>schema (N is the first major answering that shape) that parses exactly what that server sends, and a converter to the canonical shape. A converter may add fields but never maps two distinguishable wire states onto one canonical state, and it strips the wire fields it owns so the canonical JSON is the same on every server. - Export a reader,
<resource><Endpoint>Schema(features: Features): z.ZodType<Resource>, that returns the canonical schema on the newest generation andwire.transform(converter)on the others. The resource hands it totransport.requestParsed, so a mismatch is the transport'sResponseShapeErrornaming the server tag.
import type { Features } from "../version/features";
const TransformBase = z.object({ id: z.number().int(), name: z.string() /* … */ }).loose();
export const Transform = TransformBase.extend({ target_table_id: z.number().int().nullable() });
export type Transform = z.infer<typeof Transform>;
const TransformWireV59Detail = TransformBase.extend({
table: z.object({ id: z.number().int() }).loose().nullable(),
});
function fromHydratedTable(wire: z.infer<typeof TransformWireV59Detail>): Transform {
const { table, ...rest } = wire;
return { ...rest, target_table_id: table === null ? null : table.id };
}
/** The shape `GET /api/transform/{id}` answers on a server with `features`, read as `Transform`. */
export function transformDetailSchema(features: Features): z.ZodType<Transform> {
return features.transformTargetTableId
? Transform
: TransformWireV59Detail.transform(fromHydratedTable);
}
When the converter needs a second request (the canonical field is on no generation's wire), the reader returns a tagged wire ({ childrenCarryType: true, root }) and a separate exported converter takes the extra input — libraryWireSchema(features) + toLibrary(wire, listing) in packages/client/src/domain/library.ts is the model. Unit-test the converters in packages/client/src/domain/<r>.test.ts over evaluateFeatures(<major>, <tokenFeatures>) with exact toEqual per generation, and the refusal direction (an older wire on a newer profile) in the resource wire test with the exact ResponseShapeError message.
Two things that look like drift are not: a setting value's encoding and a 404's content type are dispatched on runtime type and handler, identical on every supported server. Verify against the server's middleware before adding a switch.
Step 2 — Resource methods (packages/client/src/resources/<r>.ts)
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,
type CardCreateInput,
type CardListFilter,
type CardUpdateInput,
} from "../domain/card";
import type { RequestOptions, Transport } from "../http/transport";
import type { 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.
const CardApiList = z.array(Card);
export interface CardListParams {
f?: CardListFilter | undefined;
model_id?: string | undefined;
}
export function cardResource(transport: Transport) {
/** List cards. `f` picks a server-side preset; `model_id` scopes the presets that need an id. */
async function list(
params: CardListParams = {},
options: RequestOptions = {},
): Promise<ListResult<Card>> {
const data = await transport.requestParsed(CardApiList, "/api/card", {
...options,
query: { f: params.f, model_id: params.model_id },
});
return { data, total: null };
}
/** Get one card by id. */
async function get(id: number, options: RequestOptions = {}): Promise<Card> {
return transport.requestParsed(Card, `/api/card/${id}`, { ...options });
}
/** Create a card — a question, a model, or a metric — from a full card body. */
async function create(params: CardCreateInput, options: RequestOptions = {}): Promise<Card> {
return transport.requestParsed(Card, "/api/card", { ...options, method: "POST", body: params });
}
/** Update a card by id, patching only the fields the body carries. */
async function update(
id: number,
params: CardUpdateInput,
options: RequestOptions = {},
): Promise<Card> {
return transport.requestParsed(Card, `/api/card/${id}`, {
...options,
method: "PUT",
body: params,
});
}
/** Archive (soft-delete) a card by id. Metabase models this as an update, not its own endpoint. */
async function archive(id: number, options: RequestOptions = {}): Promise<Card> {
return update(id, { archived: true }, options);
}
return { list, get, create, update, archive };
}
The nine 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 aresnippeteven though the endpoint is/api/native-query-snippet, because the domain schema, the CLI noun and the Metabase concept all readsnippet.- 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 trailingoptions, never in params. - Wire envelope schemas are module-private to
resources/; they never appear indomain/. - 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.
- Every method has an entry in
METHOD_REQUIREMENTS(packages/client/src/version/requirements.ts), keyed exactly as the method is reached on the client:[]when every supported server answers the route, otherwise the feature names it needs, strictest first. A gated method is exported throughexplain("<method>", method), whereconst { explain, explainWalk, refuse, refuseAfterReading } = explainer(transport, "<r>")frompackages/client/src/version/refusal.tsopens the factory (take only the wrappers the resource uses), and a<thing>Pagesmethod answering an async iterable throughexplainWalk("<method>", method), so the server's rejection reads as the feature it lacks; nothing refuses on the table's word, andexplain-guard.test.tsfails a gated method not wrapped exactly once. A parameter only some servers accept is handled by what an unsupporting server does with it: one it rejects (a 400 on an enum, a 404) is sent and named throughexplain's third argument, a small named function beside the method that takes the method's arguments and returns{ feature, fields }entries — the feature, and the requestfieldsthe argument travels in as the server names them in a 400; one it ignores while a newer server requires it is sent on every server; one it drops or rewrites without a word is refused before the wire withawait transport.requireFeatures([...], options)(a check by value readstransport.preflightServer(options)and refuses throughtransport.refuseBeforeSending, never by throwing), with a comment saying what the server would do. A method every call of which such a server would answer wrongly without a word (its whole answer misread, or its whole body dropped) is exported throughrefuse("<method>", method)instead ofexplain, with that comment beside it in the factory's return:refuseruns the method's wholeMETHOD_REQUIREMENTSentry throughtransport.requireFeaturesbefore the call, so the method must end in optionalRequestOptions. Such a method answering from a setting the session properties carry is exported throughrefuseAfterReading("<method>", reader, method)instead, wherereaderextendsSessionPropertieswith the setting andmethodis a pure function of the properties read: the wrapper reads them afresh throughtransport.probe, judges the entry against the profile that read settled, and exposes a method taking only optionalRequestOptions, so the call costs one request.requirements.test.tsfails a client method the table lacks and a table key no method answers to.
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.tsimports the factory and adds<r>: <r>Resource(transport)to the returned object, alphabetically. Until that line exists,client.<r>does not resolve. - Only
client.tsandresources/may importresources/. The direction runs one way; adomain/orhttp/file reaching for a resource inverts it. - A paged endpoint returns pages. Where Metabase itself applies
limit/offset, the method returnsAsyncIterable<Page<T>>frompaginatePages(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
fetchOptionalParsedinpackages/client/src/resources/optional-parsed.tsrather 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:
it("sends the get request", async () => {
const { mb, capture } = clientOver([jsonResponse(SEGMENT)]);
await mb.segment.get(4);
expect(capture.calls).toEqual([
{
url: "https://mb.example.com/metabase/api/segment/4",
method: "GET",
headers: JSON_READ_HEADERS,
body: null,
},
]);
});
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.
import { type Card, CardCompact } from "@metabase/client/domain/card";
import type { ResourceView } from "../view";
export const cardView: ResourceView<Card> = {
compactPick: CardCompact,
tableColumns: [
{ key: "id", label: "ID" },
{ key: "name", label: "Name" },
{ key: "archived", label: "Archived" },
],
};
tableColumnskeys must be valid keys of the resource type; the columns render text/table output whilecompactPickdrives 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:
import { defineCommandGroup } from "../group";
export default defineCommandGroup({
name: "<r>",
description: "Inspect Metabase <resource-plural>",
subCommands: {
list: () => import("./list").then((mod) => mod.default),
get: () => import("./get").then((mod) => mod.default),
},
});
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:
import { <Resource>Compact } from "@metabase/client/domain/<r>";
import { renderList } from "../../output/render";
import { listEnvelopeSchema } from "../../output/types";
import { <resource>View } from "../../output/views/<r>";
import { windowList } from "../../output/window";
import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags";
import { defineMetabaseCommand } from "../runtime";
export const <Resource>ListEnvelope = listEnvelopeSchema(<Resource>Compact);
export default defineMetabaseCommand({
meta: { name: "list", description: "List <resource-plural>" },
requires: ["<r>.list"],
args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags /* + filter flags */ },
outputSchema: <Resource>ListEnvelope,
examples: ["mb <r> list", "mb <r> list --json"],
async run({ ctx, getClient }) {
const client = await getClient();
const { data, total } = await client.<r>.list();
renderList(windowList(data, ctx.range, total), <resource>View, ctx);
},
});
...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). |
const envelope = await collectForOutput(
(request) =>
client.collection.itemPages(ref, params, {
offset: ctx.range.offset,
...(request.max !== undefined && { max: request.max }),
...(request.pageSize !== undefined && { pageSize: request.pageSize }),
}),
collectionItemView,
ctx,
);
renderList(envelope, collectionItemView, ctx);
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 requires: the list of "<r>.<method>" keys its run body reaches (plus the methods of any helper it hands the client to), or null for a command that never touches a Metabase server. packages/cli/src/commands/requires-guard.test.ts reads the body and fails a declaration that differs from the calls in it, so the list is transcribed, not chosen. The CLI runs no check of its own: the client sends each call and explains a refusal by the method's features, with exit code 2. help --json reports the features derived from METHOD_REQUIREMENTS as requires: { methods, features }. Nothing on the command names a version.
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:
import { <Resource> } from "@metabase/client/domain/<r>";
import { renderItem } from "../../output/render";
import { <resource>View } from "../../output/views/<r>";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";
export default defineMetabaseCommand({
meta: { name: "get", description: "Get a <r> by id" },
requires: ["<r>.get"],
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
id: { type: "positional", description: "<R> id", required: true },
},
outputSchema: <Resource>,
examples: ["mb <r> get 1", "mb <r> get 1 --json"],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const client = await getClient();
const item = await client.<r>.get(id);
renderItem(item, <resource>View, ctx);
},
});
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;
parseIditself 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')orvi.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 shipstesting/fake-client.tsandtesting/fetch-capture.tsas 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 built packages/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 frompackages/cli/src/commands/<r>/list.ts), asserts the seeded items appear with the expected compact fields via a singletoEqual({ ...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 onetoEqual({ ... })over the full expected payload, never a sequence ofexpect(parsed.id).toBe(...)/expect(parsed.name).toBe(...)field pokes. Use a seeded id fromtests/e2e/seed/seeded.ts(SEEDED) or a pinned constant fromtests/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 frompackages/cli/src/commands/parse-integer.ts, whichparseIddelegates to), stdout empty. - Get, valid format but missing on server (e.g.
9999999) —exitCode === 1(HttpError),stderr.toContain("Not found.")(the literaluserMessagefrom Metabase's 404 envelope; seepackages/client/src/http/errors.tsfor the taxonomy). - Gating and generations. A suite whose methods need a feature gates itself with
requireServer("<noun> › <lane>", ["<feature>"])fromtests/e2e/server-gate.tsviadescribe.skipIf, so a stack that lacks the route reports a skip rather than a failure. Where every stack answers but the answers differ (a drift site from Step 1a), branch withserverHas("<feature>")and assert the exact outcome for each generation — the canonical JSON is the same shape on every server, so the assertion usually differs only in a value (expect.any(Number)vsnull).
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). Thepackages/client/src/errors.tstaxonomy is fixed:ConfigError=2,CapabilityError(packages/client/src/version/capability-error.ts)=2,AbortError=130, all others=1. - Error strings — always
toContain("<exact substring>")ortoBe("<exact full string>"). NevertoMatch(/.../i). Look the literal up inpackages/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 withtoBe. - Parsed payloads — always one full
toEqual({ ... }). Field-by-fieldtoBeafterparseJsonis 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>Compactfrom@metabase/client/domain/<r>. - List envelope:
<Resource>ListEnvelopefrom../../packages/cli/src/commands/<r>/list. parseJsonfrom@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"
rg -n "explainer\(transport, \"<r>\"\)" packages/client/src/resources/<r>.ts || echo "CHECK: no explainer — right only when METHOD_REQUIREMENTS gates no <r> method"
rg -n "\"<r>\." packages/client/src/version/requirements.ts || echo "FAIL: no METHOD_REQUIREMENTS entries for <r>"
rg -n "\.major\s*(<|>|=)" packages/client/src/domain/<r>.ts packages/client/src/resources/<r>.ts && echo FAIL || echo OK # only version/features.ts compares a major
# Command files (CLI surface — flags in, client call, render out):
rg -n '["`]/api/' packages/cli/src/commands/<r>/ && echo FAIL || echo OK # the API-path rule
rg -n "\.request(Parsed|Raw|Stream)\(|paginatePages\(" packages/cli/src/commands/<r>/ && echo FAIL || echo OK # the transport rule
rg -n "from \"\.\./\.\./domain/" packages/cli/src/commands/<r>/ && echo FAIL || echo OK # use @metabase/client/domain/<r>
rg -n "minVersion|capabilities:" packages/cli/src/commands/<r>/ && echo FAIL || echo OK # commands declare `requires`, never a version
# E2E test:
rg -n 'from\s+"execa"|from\s+"node:child_process"|from\s+"child_process"' tests/e2e/<r>.e2e.test.ts && echo FAIL || echo OK
rg -n "\bspawn\(|\bexec\(|\bexecFile\(" tests/e2e/<r>.e2e.test.ts && echo FAIL || echo OK
rg -n "\bfetch\s*\(" tests/e2e/<r>.e2e.test.ts && echo FAIL || echo OK
rg -n "vi\.(mock|spyOn|hoisted|fn)\b" tests/e2e/<r>.e2e.test.ts && echo FAIL || echo OK
rg -n "JSON\.parse\(" tests/e2e/<r>.e2e.test.ts && echo FAIL || echo OK # use parseJson
rg -n "z\.object\(\{[^}]*\bdata\b[^}]*\breturned\b" tests/e2e/<r>.e2e.test.ts && echo FAIL || echo OK # redeclared envelope
rg -n "env:\s*process\.env|\.\.\.process\.env" tests/e2e/<r>.e2e.test.ts && echo FAIL || echo OK
rg -n "\.not\.toBe\(0\)" tests/e2e/<r>.e2e.test.ts && echo FAIL || echo OK # exact exitCode required
rg -n "\.toMatch\(/" tests/e2e/<r>.e2e.test.ts && echo FAIL || echo OK # exact substring required
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/reviewuntil it returnsRESULT: 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/simplifysurfaces (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 onlyzod, siblingdomain/*files and (for a generation reader)import type { Features }. - Domain pair:
<Resource>with.loose(),<Resource>Compactwith.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 file
packages/client/src/resources/<r>.tsexports<r>Resource(transport), holds every/api/path and every transport call, and follows all nine conventions — positional path params then params then options, Metabase's own field names, transport concerns inoptions, wire envelopes module-private, domain values returned,ListResult<T>for a non-paginated list,encodeURIComponenton every string path param, a doc comment per method, aMETHOD_REQUIREMENTSentry per method, and each gated method exported through exactly one of theexplain/explainWalk/refuse/refuseAfterReadingofexplainer(transport, "<r>"). - Requirements — every method has its
"<r>.<method>"entry inMETHOD_REQUIREMENTS; a gated route has a behaviour-named rule inFEATURE_RULESverified against the Metabase release branches; a shape that differs by generation has its<Resource>WireV<N>+ converter + exported reader, with converter unit tests per generation. - Namespace composed onto
packages/client/src/client.tsas<r>: <r>Resource(transport). - Wire test
packages/client/src/resources/<r>.test.tsasserts URL, method, headers and body for every method throughcaptureFetch. - View binding
<resource>Viewlanded inpackages/cli/src/output/views/<r>.ts(not the domain file, not the resource file), with anyformat:cell helpers colocated there. - Commands landed in
packages/cli/src/commands/<r>/, callclient.<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 asoutputSchema. - List command spreads
...listFlagsand builds its envelope with the window helper that matches who applied the window (windowList/windowServerPage/collectForOutput) — never a hand-rolled object. - Get command uses
parseIdfor the positional integer id. - Every leaf command declares
requires(the method keys its body reaches, ornull) and names no version. -
packages/cli/src/main.tsregisters 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.tscovering at minimum: list default, list filtered (if applicable), get success, get invalid id, get missing id; gated withrequireServerwhere a stack may lack the route, branched withserverHaswhere generations differ. - E2E test imports schemas from
@metabase/client/domain/<r>and../../packages/cli/src/commands/<r>/list; noz.object({...})redeclaration of any output shape. -
packages/cli/src/runtime/command-help.test.tsALL_COMMANDSlist 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. -
/reviewreturnedPASS. -
/simplifyfindings either applied or explicitly skipped with a reason.
If any box is unchecked, the task is unfinished — state explicitly which and continue working.