diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index e44b5e6..25e7898 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -10,7 +10,7 @@
{
"name": "metabase-cli",
"description": "Be your data analyst / data engineer for Metabase, from the terminal via the `mb` CLI. Go from raw data to something a non-technical person can use: clean tables, reusable metrics, dashboards, and written answers. Use when someone wants to \"make sense of my data\", \"build a data model\", \"go from raw data to a dashboard\", \"answer questions about my data\", \"report on who registered / signed up / responded\", or \"set up analytics for X\". Also full CRUD on every Metabase resource (cards, dashboards, transforms, queries), git-sync content to and from a remote, and on-demand workflow skills served by `mb skills get`.",
- "source": "./",
+ "source": "./packages/cli",
"strict": false,
"skills": ["./skills/metabase-cli"],
"category": "development"
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..7538464
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,5 @@
+{
+ "worktree": {
+ "baseRef": "head"
+ }
+}
diff --git a/.claude/skills/add-e2e-test/SKILL.md b/.claude/skills/add-e2e-test/SKILL.md
index 9784736..f30038f 100644
--- a/.claude/skills/add-e2e-test/SKILL.md
+++ b/.claude/skills/add-e2e-test/SKILL.md
@@ -5,7 +5,9 @@ description: Add an end-to-end test that drives the built CLI against a real Met
# add-e2e-test
-The e2e tier exists to run the **built `dist/cli.mjs`** against a real Metabase, with no mocks. Subtle violations of the harness contract corrupt shared state for every other test in the run. Reading this is required before generating any e2e file.
+The e2e tier exists to run the **built `packages/cli/dist/cli.mjs`** against a real Metabase, with no mocks. Subtle violations of the harness contract corrupt shared state for every other test in the run. Reading this is required before generating any e2e file.
+
+The tier is workspace-wide: it lives at the repo root under `tests/e2e/`, not inside either package, because it exercises the shipped binary rather than a package's source. Unit tests are the mirror image — they sit beside the code under `packages/
/src/**/*.test.ts`.
## Step 0 — Pre-flight (mandatory, do not skip)
@@ -13,16 +15,22 @@ Before generating anything, anchor to the existing harness:
1. `ls tests/e2e/` — see the layout and existing nouns.
2. Read **one** existing e2e test end-to-end (e.g. `tests/e2e/auth.e2e.test.ts`).
-3. Read `tests/e2e/run-cli.ts` (helpers) and `tests/e2e/bootstrap-data.ts` (the `Bootstrap` schema you must NOT redeclare).
+3. Read the harness modules every later step reaches for:
+ - `tests/e2e/run-cli.ts` — the only sanctioned way to invoke the CLI.
+ - `tests/e2e/bootstrap-data.ts` — the `Bootstrap` schema you must NOT redeclare.
+ - `tests/e2e/cli-error.ts` — `cliErrorMessage` / `cliErrorCategory`, how you assert on stderr.
+ - `tests/e2e/defaults.ts` — base URL, stack id, snapshot name.
+ - `tests/e2e/server-gate.ts` — `requireServer` / `serverVersionBelow`, how a suite skips.
+ - `tests/e2e/seed/seeded.ts` and `tests/e2e/seed/ids.ts` — discovered and fixed entity ids.
Skip this and you will reinvent harness pieces that already exist, or redeclare the bootstrap schema and silently drift from the writer.
## When to add an e2e test
-- A new command was added under `src/commands//.ts`. Every new command needs an e2e test.
+- A new command was added under `packages/cli/src/commands//.ts`. Every new command needs an e2e test.
- An existing command grew a flag, output mode, or behavior that unit tests cannot exercise (real network round-trip, real auth flow, real polling).
-If the command can be fully covered by colocated `src/**/*.test.ts` unit tests, do **not** add an e2e test — the unit tier is faster and more deterministic.
+If the command can be fully covered by colocated `packages//src/**/*.test.ts` unit tests, do **not** add an e2e test — the unit tier is faster and more deterministic.
## Where the test lives
@@ -39,7 +47,7 @@ You must follow all of these. Each rule has bitten the harness before.
import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli";
```
-- `runCli({ args, configHome, env, stdin, timeoutMs })` spawns `node dist/cli.mjs` via `execa` with an isolated `XDG_CONFIG_HOME`, `MB_CLI_DISABLE_KEYRING=1`, and stripped env (no inherited `MB_*`/`METABASE_*`).
+- `runCli({ args, configHome, env, stdin, timeoutMs })` spawns `node packages/cli/dist/cli.mjs` via `execa` with an isolated `XDG_CONFIG_HOME`, `MB_CLI_DISABLE_KEYRING=1`, and stripped env (no inherited `MB_*`/`METABASE_*`).
- **Do not** import `execa`, `child_process`, `node:child_process`, or `spawn` directly.
- **Do not** call `fetch` against the Metabase instance. Bootstrap owns network setup; tests drive the CLI.
- **Do not** spread `process.env` into the `env` param. `env: process.env`, `env: { ...process.env, ... }`, and friends defeat the entire isolation guarantee — they let developer-shell `METABASE_*` leak into the test. Pass only the explicit keys you need.
@@ -53,7 +61,7 @@ The whole point of e2e is end-to-end with real I/O. These are forbidden anywhere
- `vi.hoisted(...)`
- `vi.fn(...)` to stand in for a real dep
-If you find yourself wanting a mock, you are in the wrong tier — write a unit test colocated under `src/`.
+If you find yourself wanting a mock, you are in the wrong tier — write a unit test colocated under `packages/
/src/`.
**2. Read admin credentials only via `readBootstrap()`.**
@@ -67,10 +75,34 @@ beforeAll(async () => {
```
- The `Bootstrap` schema lives in `tests/e2e/bootstrap-data.ts`. **Never redeclare it.** If you need a new field, edit the schema there — the writer (`tests/e2e/setup/bootstrap.ts`) consumes the same type, so drift is mechanically prevented.
-- The seeded `bootstrap.adminApiKey` authenticates as a synthetic api-key user (email `api-key-user-…@api-key.invalid`). For tests that need a real human admin, run the in-process OAuth login harness (`tests/e2e/setup/oauth-harness.ts` — `consentingBrowser` with `bootstrap.admin`; OAuth-capable servers only, gate with `requireOAuthServer()`) and **explain in the test name why** — don't paper over it.
+- The seeded `bootstrap.adminApiKey` authenticates as a synthetic api-key user (email `api-key-user-…@api-key.invalid`). For tests that need a real human admin, run the in-process OAuth login harness (`tests/e2e/setup/oauth-harness.ts` — `consentingBrowser` with `bootstrap.admin`; OAuth-capable servers only, gate with `requireOAuthServer("")`) and **explain in the test name why** — don't paper over it.
- **Never invoke the setup wizard from a test.** That mutates global state. Bootstrap runs once per `bun run test:e2e` via `tests/e2e/setup/global-setup.ts`.
- **Never hard-code an API key.** Always read from `bootstrap`.
+**2a. Seeded entity ids come from `SEEDED`, never a literal.**
+
+```ts
+import { SEEDED } from "./seed/seeded";
+
+const tableId = SEEDED.tables.orders;
+```
+
+- `tests/e2e/seed/seeded.ts` exposes the ids the bootstrap **discovered** by name (warehouse db, collection, card, dashboard, dashcard, plus warehouse tables and fields). Ids are not stable across stacks — a literal `5` passes on your machine and fails in the matrix.
+- Constants that genuinely are fixed by Metabase itself (the `All Users` and `Administrators` group ids) live in `tests/e2e/seed/ids.ts`. Import from there rather than inlining the number.
+
+**2b. Gate the suite on server capability.**
+
+```ts
+import { requireServer } from "./server-gate";
+
+const skipReason = requireServer("transform › transform e2e", { minVersion: 59 });
+
+describe.skipIf(skipReason !== null)("transform e2e", () => {
+```
+
+- If the command under test declares `capabilities` above the baseline `{ minVersion: 58 }` — a higher major, or a `tokenFeature` like `remote_sync` or `library` — the suite must gate itself. `requireServer` feeds the persisted server probe through the production `checkCapabilities` and returns a skip reason (or `null`). Its first argument is the lane label naming the describe or test the gate guards; an unmet gate appends it to `.gate-skips..json`, which the closing block prints so a skipped lane is reported rather than counted as a passing one.
+- The point is that a lane **passes or skips**, never fails, on a server that cannot satisfy the command. A suite that hits HTTP 402 or 404 because nobody gated it is a broken lane, not a real failure.
+
**3. Each test gets its own config home.**
```ts
@@ -89,33 +121,52 @@ async function makeIsolatedConfigHome(): Promise {
- One `XDG_CONFIG_HOME` per test, drained in `afterEach`. Sharing a config home across tests leaks profile state and is a flake source.
-**4. Never mutate snapshot state.**
+**4. Every test starts from the restored snapshot.**
+
+`vitest.workspace.ts` registers `setupFiles: ["tests/e2e/setup/restore-each.ts"]` for the e2e project, whose `beforeEach` restores the app-db snapshot (`resetToCliDefault`) and the warehouse (`resetWarehouse`) before **every** test. Two consequences:
-- The `/api/testing/snapshot` and `/api/testing/restore` endpoints are reserved for `tests/e2e/setup/bootstrap.ts`. A test that calls them corrupts every other test in the run.
-- If your test mutates server state (creates a card, runs a transform), assume the next test sees that state. Either clean up at the end of the `it`, or design the assertion to be resilient.
+- **Never rely on state a previous `it` created.** Each test builds the entities it needs, inside itself. A test that passes only when its neighbour ran first will fail the moment either is renamed, skipped, or filtered by `-t`.
+- **No manual cleanup.** Don't unwind a created card or transform at the end of the `it` — the restore does it. Teardown code that duplicates the harness is noise, and it hides the state leak it was written to fix.
+
+The `/api/testing/snapshot` and `/api/testing/restore` endpoints belong to `tests/e2e/setup/**`. A test that calls them directly re-enters the restore path mid-run and corrupts the very isolation it was reaching for.
**5. Use `parseJson` for `--json` output, not `JSON.parse + Zod.parse`.**
-The schema is the contract. Import it from the production source — never redeclare in the test:
+The schema is the contract. Import it from the production source — never redeclare in the test. Client modules come in by package specifier; CLI modules come in by relative path from the repo root:
```ts
-import { LoginResult } from "../../src/commands/auth/login";
-import { parseJson } from "../../src/runtime/json";
+import { parseJson } from "@metabase/client/json";
+
+import { LoginResult } from "../../packages/cli/src/commands/auth/login";
const result = parseJson(login.stdout, LoginResult);
expect(result).toEqual({ profile: "default", ...});
```
-If the command emits a single domain resource, import the `` / `Compact` schema from `src/domain/.ts`. **For list commands, import the `ListEnvelope` from the command file itself** (`src/commands//list.ts` exports it as a named const built from `listEnvelopeSchema(Compact)`). Never redeclare a `z.object({ data, returned, total })` envelope inline — the envelope shape is owned by `src/output/types.ts:listEnvelopeSchema` and threaded through the command's `outputSchema`. Tests reuse production schemas; copying any shape into the test is silent drift the type-checker can't catch.
+If the command emits a single domain resource, import the `` / `Compact` schema from `@metabase/client/domain/`. **For list commands, import the `ListEnvelope` from the command file itself** (`packages/cli/src/commands//list.ts` exports it as a named const built from `listEnvelopeSchema(Compact)`). Never redeclare a `z.object({ data, returned, offset, limit, total, has_more, next_offset, truncated })` envelope inline — the envelope shape is owned by `packages/cli/src/output/types.ts:listEnvelopeSchema` and threaded through the command's `outputSchema`. Tests reuse production schemas; copying any shape into the test is silent drift the type-checker can't catch.
+
+Which package a schema belongs to is a boundary question, not a stylistic one. A Metabase API resource is client surface and lives in `packages/client/src/domain/`; a shape that exists only because a command renders it that way (`LoginResult`, `AuthStatus`, `ListEnvelope`) is CLI surface and lives with its command. Import from wherever it actually is — do not mirror it into the other package to shorten the specifier.
**5b. Assertion strictness.** Every assertion is exact:
- **Parsed payloads:** one `toEqual({ ...full expected... })` over the parsed object — never a sequence of `expect(parsed.id).toBe(...)`, `expect(parsed.name).toBe(...)`. The whole point of `parseJson` is that the structure is now in your hands; pinning each field individually leaves the rest untested and lets a regression that flips an unchecked field pass silently.
-- **Exit codes:** `expect(result.exitCode).toBe()` with the exact integer. Never `.not.toBe(0)`. The taxonomy is `src/core/errors.ts`: `ConfigError` → 2, `AbortError` → 130, everything else (`HttpError`, `ValidationError`, `NetworkError`, `TimeoutError`, `UnknownError`) → 1. A test that says "non-zero" doesn't distinguish "the right error fired" from "any failure at all".
-- **Error strings:** `expect(result.stderr).toContain("")` or `toBe("")`. Never `toMatch(/.../i)` against stderr/stdout. Look up the literal message in `src/` — `src/core/http/errors.ts` for HTTP status messages (e.g. a 404 produces `"Not found."` from the JSON envelope), `src/commands//.ts` for `ConfigError` strings (e.g. `"--yes required to clear credentials non-interactively"`, `"verification failed: …"`), `src/commands/parse-id.ts` for `"invalid id: …"`. Pinning the literal substring catches a refactor that swaps the message; a regex with `\d+` or `.*` does not.
+- **Exit codes:** `expect(result.exitCode).toBe()` with the exact integer. Never `.not.toBe(0)`. The taxonomy is `packages/client/src/errors.ts`: `ConfigError` → 2, `CapabilityError` (`packages/client/src/version/preflight-error.ts`) → 2, `AbortError` → 130, everything else (`HttpError`, `ValidationError`, `NetworkError`, `TimeoutError`, `UnknownError`) → 1. A test that says "non-zero" doesn't distinguish "the right error fired" from "any failure at all".
+- **Error strings:** the harness is never a TTY, so the CLI renders errors as a JSON envelope on stderr rather than plain text. Assert through `cliErrorMessage(result.stderr)` from `./cli-error`, which unwraps that envelope and hands back the human message:
+
+ ```ts
+ import { cliErrorMessage } from "./cli-error";
+
+ expect(result.exitCode).toBe(2);
+ expect(cliErrorMessage(result.stderr)).toContain('invalid id: "abc" (expected integer)');
+ ```
+
+ A raw `expect(result.stderr).toContain("…")` also works when the expected text is a plain substring, and plenty of suites use it that way. But `toBe` against raw stderr never matches — stderr carries the whole envelope, plus any leading `warn()` lines. And any message containing a quote, backslash, or newline **requires** `cliErrorMessage`, because the envelope JSON-escapes those characters, so the raw substring is absent from stderr. Never `toMatch(/.../i)` against stderr/stdout.
+
+ Look up the literal message where it is raised — `packages/client/src/http/errors.ts` for HTTP status messages (a 404 produces `` `Not found: ${method} ${path}.` ``; the bare `"Not found."` in that file is the legacy plain-text body older servers send, used only to classify the error kind), `packages/cli/src/commands//.ts` for `ConfigError` strings (e.g. `"--yes required to clear credentials non-interactively"`, `"verification failed: …"`), `packages/cli/src/commands/parse-integer.ts` for `"invalid id: …"`. Pinning the literal substring catches a refactor that swaps the message; a regex with `\d+` or `.*` does not.
+
- **Dynamic messages:** if the message contains a generated value (a byte count, a created id, a timestamp), build the expected string from the same data the production code consumed and assert with `toBe`/`toContain`. Don't paper over the dynamic part with a regex.
-**5a. Command-list test parity.** When you add a new leaf command, update the literal `ALL_COMMANDS` list in `src/runtime/command-help.test.ts` to include the new entry (and remove any path you renamed/deleted). The index is generated by walking `src/main.ts`, so the contract test will fail until the literal matches the new tree.
+**5a. Command-list test parity.** When you add a new leaf command, update the literal `ALL_COMMANDS` list in `packages/cli/src/runtime/command-help.test.ts` to include the new entry (and remove any path you renamed/deleted). The index is generated by walking `packages/cli/src/main.ts`, so the contract test will fail until the literal matches the new tree.
**6. License token: opaque only.**
@@ -138,9 +189,9 @@ import { resolveE2EBaseUrl } from "./defaults";
```ts
import { afterEach, beforeAll, describe, expect, it } from "vitest";
-import { LoginResult } from "../../src/commands/auth/login";
-import { parseJson } from "../../src/runtime/json";
+import { parseJson } from "@metabase/client/json";
+import { LoginResult } from "../../packages/cli/src/commands/auth/login";
import { readBootstrap, type E2EBootstrap } from "./bootstrap-data";
import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli";
@@ -175,7 +226,9 @@ describe(" e2e", () => {
});
expect(result.exitCode, result.stderr).toBe(0);
- expect(parseJson(result.stdout /* schema from src/commands or src/domain */)).toEqual({
+ expect(
+ parseJson(result.stdout /* schema from @metabase/client/domain or the command file */),
+ ).toEqual({
// ...
});
});
@@ -240,10 +293,10 @@ Replace `` with your actual file name.
## Step N — Runnable verification (mandatory)
-Type-check is required and cheap; the suite itself is opt-in (slow, brings up docker).
+The gate is required and cheap; the e2e suite itself is opt-in (slow, brings up docker).
```sh
-npx tsc --noEmit
+bun run check
```
Must exit 0. If you ran the suite, also include:
@@ -260,14 +313,16 @@ If you did **not** run the e2e suite (it requires `bun run e2e:up && bun run e2e
- [ ] File path is `tests/e2e/.e2e.test.ts` (note `.e2e.test.ts`).
- [ ] Imports `runCli`, `mkTempConfigHome`, `cleanupConfigHome` from `./run-cli`. No `execa`/`child_process`/`fetch` import.
- [ ] Reads creds via `readBootstrap()`. No hard-coded API keys, no `Bootstrap` schema redeclaration, no setup-wizard call.
+- [ ] Seeded entity ids come from `SEEDED` (`./seed/seeded`) or `./seed/ids`; no literal id anywhere.
+- [ ] If the command's `capabilities` are above `{ minVersion: 58 }`, the suite gates on `requireServer("", {...})` via `describe.skipIf`, with a lane label naming what the gate guards.
- [ ] No `vi.mock` / `vi.spyOn` / `vi.hoisted` / `vi.fn` anywhere in the file.
- [ ] No `process.env` spread into `runCli({ env: ... })`. Only the explicit keys the test needs.
- [ ] Per-test `makeIsolatedConfigHome()` pattern with `tempDirs` + `afterEach` cleanup.
-- [ ] `--json` assertions go through `parseJson(stdout, )` where `` is imported from `src/commands/...` or `src/domain/...`.
+- [ ] `--json` assertions go through `parseJson(stdout, )` (`parseJson` from `@metabase/client/json`) where `` is imported from `@metabase/client/domain/...` or `../../packages/cli/src/commands/...`.
- [ ] Does not call `/api/testing/snapshot` or `/api/testing/restore`.
- [ ] If license-touching: token only as opaque stdin; existence check via `=== undefined`; no logging/snapshotting/asserting on the value.
- [ ] Self-grep step ran clean (no FAIL lines).
-- [ ] `npx tsc --noEmit` exited 0.
+- [ ] `bun run check` exited 0.
- [ ] If e2e suite was run, it was run after `bun run e2e:up && bun run e2e:bootstrap`. If it was NOT run, that is stated explicitly.
If any box is unchecked, the test is unfinished — do not report it as done. State explicitly which box is unchecked and continue working.
diff --git a/.claude/skills/add-resource-command/SKILL.md b/.claude/skills/add-resource-command/SKILL.md
index c7557ae..b0089a1 100644
--- a/.claude/skills/add-resource-command/SKILL.md
+++ b/.claude/skills/add-resource-command/SKILL.md
@@ -1,46 +1,58 @@
---
name: add-resource-command
-description: Add a Metabase API resource end-to-end — Zod schema in `src/domain/`, list/get commands in `src/commands//`, unit tests where logic warrants them, and a comprehensive e2e suite. Use whenever the user asks to "add a card/dashboard/ command", "wire up `/api/` end-to-end", "add list/get for ", 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 `src/domain/`, `src/commands//`, or `tests/e2e/.e2e.test.ts` — the contract is strict and the dependencies between layers are easy to break.
+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//`, unit tests where logic warrants them, and a comprehensive e2e suite. Use whenever the user asks to "add a card/dashboard/ command", "wire up `/api/` end-to-end", "add list/get for ", 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//`, or `tests/e2e/.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 four layers — domain schema, commands, unit tests, e2e suite — 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.
+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.
-This skill subsumes what was previously called "add-domain-resource." Adding a domain file alone is no longer the unit of work — a resource without a command isn't useful, and a command without an e2e test isn't trustworthy.
+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..(…)`, and renders the result. Every `/api/` path, every query-parameter name, and every call to the HTTP transport lives in `packages/client/src/resources/.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 src/domain/` and read **one** existing resource file end-to-end (e.g. `src/domain/user.ts`).
-2. Read `src/domain/view.ts` (the `ColumnDef` / `ResourceView` contract).
-3. Read **one** existing list command and **one** existing get command (look under `src/commands//`). Note how `ListEnvelope`, `outputSchema`, `parseId`, `renderList`, `renderItem`, and `defineMetabaseCommand` compose.
-4. Read **one** existing e2e test (`tests/e2e/.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.
-5. Read `src/output/types.ts` to confirm `listEnvelopeSchema` and the `ListEnvelope` interface.
+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.
+3. Read `packages/cli/src/output/view.ts` (the CLI-side `ColumnDef` / `ResourceView` contract) and one existing `packages/cli/src/output/views/.ts` presentation binding.
+4. Read **one** existing list command and **one** existing get command (look under `packages/cli/src/commands//`). Note how `ListEnvelope`, `outputSchema`, `capabilities`, `parseId`, `renderList`, `renderItem`, and `defineMetabaseCommand` compose.
+5. Read **one** existing e2e test (`tests/e2e/.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` interface, and `ListRange`; then `packages/cli/src/output/window.ts` for the three window helpers (`windowList`, `windowServerPage`, `collectForOutput`) and the `PageSource` / `PageRequest` contract.
## What you must produce
For a typical list/get pair on a new resource:
-1. `src/domain/.ts` — the trio (schema + Compact + view). No fixture, no schema-parse unit test.
-2. `src/commands//index.ts` — citty group with subcommands.
-3. `src/commands//list.ts` — exports `ListEnvelope`, uses `renderList`.
-4. `src/commands//get.ts` — uses `parseId` for the positional id, uses `renderItem`.
-5. (optional) `src/commands//.test.ts` — unit test **only where there is non-orchestration logic to test**.
-6. `tests/e2e/.e2e.test.ts` — comprehensive e2e suite.
-7. Updated `src/main.ts` — register the new top-level subcommand.
-8. Updated `src/runtime/command-help.test.ts` — add the new leaf paths to the literal `ALL_COMMANDS` list.
+1. `packages/client/src/domain/.ts` — the schema pair (`` + `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/.ts` — `Resource(transport)`, one method per endpoint.
+4. Updated `packages/client/src/client.ts` — compose the namespace onto the client as `: Resource(transport)`.
+5. `packages/client/src/resources/.test.ts` — the wire test: one `it` per method, asserting the exact URL, method, headers and body through `captureFetch`.
+6. `packages/cli/src/output/views/.ts` — the CLI-side `View` presentation binding.
+7. `packages/cli/src/commands//index.ts` — citty group with subcommands.
+8. `packages/cli/src/commands//list.ts` — exports `ListEnvelope`, uses `renderList`.
+9. `packages/cli/src/commands//get.ts` — uses `parseId` for the positional id, uses `renderItem`.
+10. (optional) `packages/cli/src/commands//.test.ts` — unit test **only where there is non-orchestration logic to test**.
+11. `tests/e2e/.e2e.test.ts` — comprehensive e2e suite.
+12. Updated `packages/cli/src/main.ts` — register the new top-level subcommand.
+13. 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 ` values` for fetching distinct values), add them under the same group; the rules below scale per-verb.
-## Step 1 — Domain schema (`src/domain/.ts`)
+## Step 1 — Domain schema (`packages/client/src/domain/.ts`)
+
+The domain file is client surface, so it lives in `@metabase/client`, not the CLI. Two constraints follow from that:
-A single file in `src/domain/` may host multiple resources (e.g. `domain/user.ts` exports `CurrentUser` / `CurrentUserCompact` / `userView`). The trio holds **per resource**, not per file. `` is PascalCase; `View` is camelCase.
+- **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. `` is PascalCase.
```ts
import { z } from "zod";
-import type { ColumnDef, ResourceView } from "./view";
export const Card = z
.object({
@@ -54,15 +66,6 @@ export type Card = z.infer;
export const CardCompact = Card.pick({ id: true, name: true, archived: true }).strip();
export type CardCompact = z.infer;
-
-export const cardView: ResourceView = {
- compactPick: CardCompact,
- tableColumns: [
- { key: "id", label: "ID" },
- { key: "name", label: "Name" },
- { key: "archived", label: "Archived" },
- ],
-};
```
Rules:
@@ -70,88 +73,257 @@ 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.
-- `tableColumns` keys must be valid keys of the **compact** type (the projection drives both JSON and text output).
- Type aliases via `z.infer`. 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.().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 `CreateInput` / `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/` 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>`, `any`, `unknown`, or an inline `{ ... }` shape cast. The Zod schema is the single source of truth; downstream code consumes `z.infer`.
-- Editing an existing command to wire the schema in. The domain file is purely additive; the command in Step 2 is the one that consumes it.
- Declaring a separate `interface { ... }` next to `const = z.object(...)`. Use the inferred type alias.
-- Putting the schema anywhere except `src/domain/`. No `src/schemas.ts`, no `src/api.ts`, no `commands//types.ts`.
-- Adding a `tests/fixtures//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 4.
+- Putting the schema anywhere except `packages/client/src/domain/`. No `schemas.ts`, no `api.ts`, no `packages/cli/src/commands//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//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 2 — Resource methods (`packages/client/src/resources/.ts`)
+
+One file per Metabase resource, exporting `Resource(transport)` — a factory returning the object of methods reached as `mb..(…)`. 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> {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ return update(id, { archived: true }, options);
+ }
+
+ return { list, get, create, update, archive };
+}
+```
+
+The eight conventions every method follows:
+
+1. `client..(...)`, 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`.
+2. Path parameters positional, then params, then options: `update(id, params, options?)`.
+3. Params use Metabase's own field names verbatim — `f`, `model_id`, `include_inactive`. **No mapping
+ layer, no renaming.**
+4. Transport concerns (`signal`, `timeoutMs`, `retries`) live in the trailing `options`, never in
+ params.
+5. Wire envelope schemas are module-private to `resources/`; they never appear in `domain/`.
+6. Methods return domain values, never wire envelopes. Non-paginated lists return
+ `ListResult` = `{ data, total }`.
+7. A string path parameter always goes through `encodeURIComponent`.
+8. 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 `: Resource(transport)` to the returned object, alphabetically. Until that line exists, `client.` 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>` from `paginatePages(transport, path, ItemSchema, { query, offset, max, pageSize, signal })` and is named `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/.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`:
+
+```ts
+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 2 — Commands
+## Step 3 — Presentation and commands
-Each leaf command uses `defineMetabaseCommand`. Three artifacts per resource group:
+Commands are CLI surface: they live in `packages/cli/src/commands//` and reach the schema across the package boundary as `@metabase/client/domain/`. Everything CLI-internal (`../../output/render`, `../../output/views/`, `../flags`) stays relative. Each leaf command uses `defineMetabaseCommand`.
-**`src/commands//index.ts`** — a tiny citty group:
+**`packages/cli/src/output/views/.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.
```ts
-import { defineCommand } from "citty";
+import { type Card, CardCompact } from "@metabase/client/domain/card";
-export default defineCommand({
- meta: { name: "", description: "Inspect Metabase " },
+import type { ResourceView } from "../view";
+
+export const cardView: ResourceView = {
+ compactPick: CardCompact,
+ tableColumns: [
+ { key: "id", label: "ID" },
+ { key: "name", label: "Name" },
+ { key: "archived", label: "Archived" },
+ ],
+};
+```
+
+- `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//index.ts`** — a tiny citty group via `defineCommandGroup`:
+
+```ts
+import { defineCommandGroup } from "../group";
+
+export default defineCommandGroup({
+ name: "",
+ description: "Inspect Metabase ",
subCommands: {
- list: () => import("./list").then((m) => m.default),
- get: () => import("./get").then((m) => m.default),
+ list: () => import("./list").then((mod) => mod.default),
+ get: () => import("./get").then((mod) => mod.default),
},
});
```
-Add `alias` to the `meta` if a short or alternate name is helpful (e.g. `db` aliasing `database`).
+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.
-**`src/commands//list.ts`** — exports the envelope schema, uses it as `outputSchema`:
+**`packages/cli/src/commands//list.ts`** — exports the envelope schema, uses it as `outputSchema`:
```ts
-import { z } from "zod";
+import { Compact } from "@metabase/client/domain/";
-import { , Compact, View } from "../../domain/";
import { renderList } from "../../output/render";
-import { listEnvelopeSchema, type ListEnvelope } from "../../output/types";
-import { connectionFlags, outputFlags, profileFlag } from "../flags";
+import { listEnvelopeSchema } from "../../output/types";
+import { View } from "../../output/views/";
+import { windowList } from "../../output/window";
+import { connectionFlags, listFlags, outputFlags, profileFlag } from "../flags";
import { defineMetabaseCommand } from "../runtime";
-const ApiList = /* schema for the actual API response shape */;
-
export const ListEnvelope = listEnvelopeSchema(Compact);
export default defineMetabaseCommand({
meta: { name: "list", description: "List " },
- args: { ...outputFlags, ...profileFlag, ...connectionFlags /* + filter flags */ },
+ capabilities: { minVersion: 58 },
+ args: { ...outputFlags, ...listFlags, ...profileFlag, ...connectionFlags /* + filter flags */ },
outputSchema: ListEnvelope,
examples: ["mb list", "mb list --json"],
async run({ ctx, getClient }) {
const client = await getClient();
- const response = await client.requestParsed(ApiList, "/api/");
- const envelope: ListEnvelope<> = {
- data: /* extract items */,
- returned: /* count */,
- total: /* count or undefined */,
- };
- renderList(envelope, View, ctx);
+ const { data, total } = await client..list();
+ renderList(windowList(data, ctx.range, total), View, ctx);
},
});
```
-The `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 a `z.object({ data, returned, total })` shape inline anywhere.
+`...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 API response schema (`ApiList` above) is the _server's_ envelope shape (often `{ data: [...], total: N }` or a bare array) and is distinct from the _CLI's_ envelope. Keep it private to the file.
+**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.
-**`src/commands//get.ts`** — positional id parsed via `parseId`:
+**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` 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` forwarding the helper's `PageRequest` into the resource's `Pages` method (see below). |
```ts
-import { , View } from "../../domain/";
+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 `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 `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//get.ts`** — positional id parsed via `parseId`:
+
+```ts
+import { } from "@metabase/client/domain/";
+
import { renderItem } from "../../output/render";
+import { View } from "../../output/views/";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";
export default defineMetabaseCommand({
meta: { name: "get", description: "Get a by id" },
+ capabilities: { minVersion: 58 },
args: {
...outputFlags,
...profileFlag,
@@ -163,76 +335,91 @@ export default defineMetabaseCommand({
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const client = await getClient();
- const item = await client.requestParsed(, `/api//${id}`);
+ const item = await client..get(id);
renderItem(item, View, ctx);
},
});
```
-**`src/main.ts`** — register the new top-level subcommand alongside the existing entries.
+**`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 3 — Unit tests
+## 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 `.test.ts` next to a command **only where there is non-orchestration logic to test**. Examples that warrant a unit test:
-- A `--`-style filter applied client-side after the API returns (test the filter independently of the network round-trip).
+- A `--`-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 endpoints' output.
+- 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('../../core/http/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.
-- Tests that re-encode the implementation (e.g. asserting the exact arg list passed to `client.requestParsed`).
+- `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 "call 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.
+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 4 — E2E tests (comprehensive)
+## Step 5 — E2E tests (comprehensive)
-Live under `tests/e2e/.e2e.test.ts`. Drive the **built** `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.
+Live under `tests/e2e/.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:
-1. **List, default flags** — `exitCode === 0`, parsed via `ListEnvelope` (imported from `src/commands//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.
+1. **List, default flags** — `exitCode === 0`, parsed via `ListEnvelope` (imported from `packages/cli/src/commands//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.
2. **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.
-3. **Get, success** — `exitCode === 0`, parsed via `` (with `--full`) or `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 stable identifier from `tests/e2e/seed/ids.ts` if pinned, otherwise look up the id dynamically by listing first and filtering by a known name.
-4. **Get, invalid positional** (`abc`, empty, negative, zero) — `exitCode === 2` (`ConfigError`), `stderr.toContain('invalid id: "" (expected integer)')` (the literal message from `src/commands/parse-id.ts`), stdout empty.
-5. **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 `src/core/http/errors.ts` for the taxonomy).
+3. **Get, success** — `exitCode === 0`, parsed via `` (with `--full`) or `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.
+4. **Get, invalid positional** (`abc`, empty, negative, zero) — `exitCode === 2` (`ConfigError`), `stderr.toContain('invalid id: "" (expected integer)')` (the literal message from `packages/cli/src/commands/parse-integer.ts`, which `parseId` delegates to), stdout empty.
+5. **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 `src/core/errors.ts` taxonomy is fixed: `ConfigError`=2, `AbortError`=130, all others=1.
-- **Error strings** — always `toContain("")` or `toBe("")`. Never `toMatch(/.../i)`. Look the literal up in `src/` 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`.
+- **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("")` or `toBe("")`. 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:
+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: `` / `Compact` from `src/domain/.ts`.
-- List envelope: `ListEnvelope` from `src/commands//list.ts`.
+- Single item: `` / `Compact` from `@metabase/client/domain/`.
+- List envelope: `ListEnvelope` from `../../packages/cli/src/commands//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 5 — Command-list parity
+## Step 6 — Command-list parity
-`src/runtime/command-help.test.ts` has a literal `ALL_COMMANDS` list. Add the new entries (` list`, ` get`, …) in the same place new commands appear. Without this update the command-tree contract test fails on a clean run.
+`packages/cli/src/runtime/command-help.test.ts` has a literal `ALL_COMMANDS` list. Add the new entries (` list`, ` get`, …) in the same place new commands appear. Without this update the command-tree contract test fails on a clean run.
-## Step 6 — Self-grep before close-out (mandatory)
+## Step 7 — Self-grep before close-out (mandatory)
Run each of these. Any hit must be fixed; then re-run.
```sh
-# Domain file:
-rg -n "Record<\s*string\s*,\s*unknown\s*>" src/domain/.ts && echo FAIL || echo OK
-rg -n "\bas \b[A-Z]" src/domain/.ts && echo FAIL || echo OK
-rg -n ":\s*any\b|" src/domain/.ts && echo FAIL || echo OK
-rg -n "[\w\)\]]!\." src/domain/.ts && echo FAIL || echo OK
-rg -n "@ts-(ignore|nocheck|expect-error)" src/domain/.ts && echo FAIL || echo OK
-rg -n "\.pick\(\{[^}]*\}\)\s*;" src/domain/.ts && echo FAIL || echo OK # `.pick(...)` not followed by `.strip()`
+# Domain file (client surface — lives in packages/client):
+rg -n "Record<\s*string\s*,\s*unknown\s*>" packages/client/src/domain/.ts && echo FAIL || echo OK
+rg -n "\bas \b[A-Z]" packages/client/src/domain/.ts && echo FAIL || echo OK
+rg -n ":\s*any\b|" packages/client/src/domain/.ts && echo FAIL || echo OK
+rg -n "[\w\)\]]!\." packages/client/src/domain/.ts && echo FAIL || echo OK
+rg -n "@ts-(ignore|nocheck|expect-error)" packages/client/src/domain/.ts && echo FAIL || echo OK
+rg -n "\.pick\(\{[^}]*\}\)\s*;" packages/client/src/domain/.ts && echo FAIL || echo OK # `.pick(...)` not followed by `.strip()`
+rg -n "@metabase/cli|\.\./\.\./cli/" packages/client/src/domain/.ts && echo FAIL || echo OK # the client must not reach into the CLI
+rg -n "/api/" packages/client/src/domain/.ts && echo FAIL || echo OK # endpoints belong to resources/
+
+# Resource file (the endpoint layer):
+rg -n "\.request(Parsed|Raw|Stream)\(" packages/client/src/resources/.ts || echo "FAIL: no transport call — is this file doing anything?"
+rg -n "\$\{[a-zA-Z_$][\w$]*\}" packages/client/src/resources/.ts # every string interpolation: numeric id, or encodeURIComponent?
+rg -n "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 '["`]/api/' packages/cli/src/commands// && echo FAIL || echo OK # the API-path rule
+rg -n "\.request(Parsed|Raw|Stream)\(|paginatePages\(" packages/cli/src/commands// && echo FAIL || echo OK # the transport rule
+rg -n "from \"\.\./\.\./domain/" packages/cli/src/commands// && echo FAIL || echo OK # use @metabase/client/domain/
# E2E test:
rg -n 'from\s+"execa"|from\s+"node:child_process"|from\s+"child_process"' tests/e2e/.e2e.test.ts && echo FAIL || echo OK
@@ -246,43 +433,53 @@ rg -n "\.not\.toBe\(0\)" tests/e2e/.e2e.test.ts && echo FAIL || echo OK # e
rg -n "\.toMatch\(/" tests/e2e/.e2e.test.ts && echo FAIL || echo OK # exact substring required
```
-## Step 7 — Runnable verification (mandatory, all green before close-out)
+## Step 8 — Runnable verification (mandatory, all green before close-out)
```sh
-npx tsc --noEmit
-bun run test
+bun run check
bun run build
bun run test:e2e tests/e2e/.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 8 — Close-out: `/review` then `/simplify` (mandatory, in this order)
+## Step 9 — Close-out: `/review` then `/simplify` (mandatory, in this order)
-Both are mandatory and must run **after** Step 7 is green. Do not skip either.
+Both are mandatory and must run **after** Step 8 is green. Do not skip either.
1. **`/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`.
2. **`/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()`, redeclared list envelope, missing command-list entry, e2e test missing a coverage axis), the work is unfinished — fix and re-run the close-out from `/review`.
+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, list command, get command, e2e test, output/types).
-- [ ] **Domain trio**: `` with `.loose()`, `Compact` with `.pick({...}).strip()`, `View`. No hand-written parallel interface.
+- [ ] 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**: `` with `.loose()`, `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.
-- [ ] **List command** exports `ListEnvelope = listEnvelopeSchema(Compact)` and uses it as `outputSchema`. The API-response shape is a separate private schema.
+- [ ] **Resource file** `packages/client/src/resources/.ts` exports `