Skip to main content

add-resource-command

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.

Quellinformationen

Repository
metabase/metabase-cli
Letzte Quellaktivität
7. Oktober 2026 um 17:29
Erkannte Sprache von SKILL.md
Englisch
Sterne
16
Forks
6

Installationsoptionen

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.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
add-resource-command
description
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.** 1. `ls packages/client/src/domain/` and read **one** existing resource schema file end-to-end (e.g. `packages/client/src/domain/segment.ts`). 2. 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, and `packages/client/src/version/requirements.ts` for how every method is keyed there. If the resource's routes or shapes differ between supported Metabase majors, also read `packages/client/src/version/features.ts` and the drift site in `packages/client/src/domain/transform.ts` + `packages/client/src/resources/transform.ts`. 3. 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. 4. 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`, and `defineMetabaseCommand` compose. 5. 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. 6. 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: 1. `packages/client/src/domain/<r>.ts` — the schema pair (`<Resource>` + `<Resource>Compact`). No fixture, no schema-parse unit test. 2. Updated `packages/client/src/index.ts` — re-export every value the domain file exports from the public barrel. 3. `packages/client/src/resources/<r>.ts` — `<r>Resource(transport)`, one method per endpoint. 4. Updated `packages/client/src/version/requirements.ts` — one `"<r>.<method>"` entry per method in `METHOD_REQUIREMENTS`, and a rule in `packages/client/src/version/features.ts` if the route is gated by a major or a token feature. 5. Updated `packages/client/src/client.ts` — compose the namespace onto the client as `<r>: <r>Resource(transport)`. 6. `packages/client/src/resources/<r>.test.ts` — the wire test: one `it` per method, asserting the exact URL, method, headers and body through `captureFetch`. 7. `packages/cli/src/output/views/<r>.ts` — the CLI-side `<resource>View` presentation binding. 8. `packages/cli/src/commands/<r>/index.ts` — citty group with subcommands. 9. `packages/cli/src/commands/<r>/list.ts` — exports `<Resource>ListEnvelope`, uses `renderList`. 10. `packages/cli/src/commands/<r>/get.ts` — uses `parseId` for the positional id, uses `renderItem`. 11. (optional) `packages/cli/src/commands/<r>/<verb>.test.ts` — unit test **only where there is non-orchestration logic to test**. 12. `tests/e2e/<r>.e2e.test.ts` — comprehensive e2e suite. 13. Updated `packages/cli/src/main.ts` — register the new top-level subcommand. 14. 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. ## 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`, sibling `domain/*` 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`), 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` + `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. ```ts 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 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 **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>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 `string | null` even when the backend enumerates the value via Clojure `(derive :namespace/X :namespace/parent)` hierarchies. Pin the schema to `z.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 — typically `src/metabase/types/core.cljc` or the resource's `api.clj` — for `derive` 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. ### 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: 1. Add a rule to `FEATURE_RULES` in `packages/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, `tokenFeature` when a premium feature gates it. `features.test.ts` fails a version-gated rule that is constant across `KNOWN_RANGE.min … max + 1` (the head slot included, so a rule written against head is legal), and `major-comparison-guard.test.ts` fails a `.major` comparison anywhere else — the rule is the only place the major is read. 2. The canonical `<Resource>` is the **newest** supported server's shape. A field an older server cannot report is `.nullable()` on the canonical, `null` meaning "this server cannot say". 3. 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. 4. Export a reader, `<resource><Endpoint>Schema(features: Features): z.ZodType<Resource>`, that returns the canonical schema on the newest generation and `wire.transform(converter)` on the others. The resource hands it to `transport.requestParsed`, so a mismatch is the transport's `ResponseShapeError` naming the server tag. ```ts 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. ```ts 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 });
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen