diff --git a/README.md b/README.md index 76093d1..ff4a451 100644 --- a/README.md +++ b/README.md @@ -1434,7 +1434,7 @@ When the embedded query (`card.dataset_query`, `transform.source.query` for `sou Pass `--skip-validate` to bypass the pre-flight on any of `card create`, `card update`, `transform create`, `transform update`, `measure create`, `measure update`, `segment create`, or `segment update` — the body is sent as-is and the server is the authority. Same escape hatch as on `mb query`; use only when the bundled schema disagrees with what the server actually accepts. -Agent discovery path: `mb --help --json` lists a command's args and output schema; the description for `card create`/`update`, `transform create`/`update`, `measure create`/`update`, and `segment create`/`update` references `mb query --print-schema` so an agent can fetch the validating schema directly. +Agent discovery path: `mb --help --json` lists a command's args, JSON-body input schema, and output schema; the description for `card create`/`update`, `transform create`/`update`, `measure create`/`update`, and `segment create`/`update` references `mb query --print-schema` so an agent can fetch the validating schema directly. The bundled query schema is synced from a pinned `@metabase/representations` release via `bun run sync:representations`; CI guards against drift. @@ -1566,12 +1566,13 @@ The former `METABASE_`-prefixed names (`METABASE_URL`, `METABASE_API_KEY`, `META Every node of the command tree answers `--help --json` with machine-readable help, mirroring what text help shows at that level: -- A leaf command emits its full entry — name, description, `details`, examples, citty args with types/defaults/enums, `capabilities` (min server version / token feature), and the output Zod schema rendered as JSON Schema. +- A leaf command emits its full entry — name, description, `details`, examples, citty args with types/defaults/enums, `capabilities` (min server version / token feature), and the input and output Zod schemas rendered as JSON Schema (`inputSchema` is the exact validator `readBody` enforces on the JSON body, `null` for commands that take none). - A command group (and the root) emits a flat `{ commands: [{ command, description }] }` index of every leaf in its subtree, with full-path names. ```sh mb --help --json | jq -r '.commands[].command' # every command mb card query --help --json | jq .outputSchema # one command's output schema +mb card create --help --json | jq .inputSchema # the JSON-body contract it validates ``` The entry and index schemas (`CommandHelpEntry`, `CommandHelpIndex`) are exported from `src/runtime/command-help.ts`. diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 018eaaf..dbda329 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -105,11 +105,12 @@ Cheapest source that answers the question wins: - What groups/verbs exist? → `mb --help`, then `mb --help`. Add `--json` for a machine-readable `{command, description}` index (`mb --help --json` lists every command). - What flags does a command take? → `mb --help` — flags with enums and defaults, examples, ~1 KB. -- Output JSON Schema before parsing, machine-readable arg types, min server version? → `mb --help --json` — that command's full entry. +- Output JSON Schema before parsing, JSON-body input schema before authoring, machine-readable arg types, min server version? → `mb --help --json` — that command's full entry (`inputSchema` is the exact validator the command runs on the body; `null` when it takes none). ```bash mb card query --help # flags, enums, defaults, examples mb card list --help --json | jq .outputSchema # output schema before parsing +mb card create --help --json | jq .inputSchema # body schema before authoring mb transform --help --json | jq -r '.commands[].command' # verbs under "transform" ``` diff --git a/src/commands/card/create.ts b/src/commands/card/create.ts index bb66d81..2017c08 100644 --- a/src/commands/card/create.ts +++ b/src/commands/card/create.ts @@ -30,6 +30,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, ...skipValidateFlag, }, + inputSchema: CardCreateInput, outputSchema: Card, examples: [ "cat card.json | mb card create", diff --git a/src/commands/card/update.ts b/src/commands/card/update.ts index e92c037..63882e2 100644 --- a/src/commands/card/update.ts +++ b/src/commands/card/update.ts @@ -31,6 +31,7 @@ export default defineMetabaseCommand({ ...skipValidateFlag, id: { type: "positional", description: "Card id", required: true }, }, + inputSchema: CardUpdateInput, outputSchema: Card, examples: [ "cat patch.json | mb card update 1", diff --git a/src/commands/collection/create.ts b/src/commands/collection/create.ts index 0f21d46..fe78701 100644 --- a/src/commands/collection/create.ts +++ b/src/commands/collection/create.ts @@ -26,6 +26,7 @@ export default defineMetabaseCommand({ description: `Collection namespace: ${CollectionNamespace.options.join("|")} (omit for a normal collection)`, }, }, + inputSchema: CollectionCreateInput, outputSchema: Collection, examples: [ "cat collection.json | mb collection create", diff --git a/src/commands/dashboard/create.ts b/src/commands/dashboard/create.ts index 12945ff..7e639f7 100644 --- a/src/commands/dashboard/create.ts +++ b/src/commands/dashboard/create.ts @@ -30,6 +30,7 @@ export default defineMetabaseCommand({ ...connectionFlags, ...bodyInputFlags, }, + inputSchema: DashboardCreateInput, outputSchema: Dashboard, examples: [ "cat dashboard.json | mb dashboard create", diff --git a/src/commands/dashboard/preflight.ts b/src/commands/dashboard/preflight.ts index 6e2efa0..d38f8ef 100644 --- a/src/commands/dashboard/preflight.ts +++ b/src/commands/dashboard/preflight.ts @@ -19,9 +19,9 @@ interface CardReference { type CardCheck = { status: "ok" } | { status: "error"; message: string }; export function collectDashcardCardReferences( - dashcards: ReadonlyArray | undefined, + dashcards: ReadonlyArray | null | undefined, ): CardReference[] { - if (dashcards === undefined) { + if (dashcards === undefined || dashcards === null) { return []; } const refs: CardReference[] = []; @@ -40,7 +40,7 @@ export function collectDashcardCardReferences( export async function preflightDashcardCardReferences( client: Client, - dashcards: ReadonlyArray | undefined, + dashcards: ReadonlyArray | null | undefined, ): Promise { const references = collectDashcardCardReferences(dashcards); if (references.length === 0) { diff --git a/src/commands/dashboard/update-dashcard.ts b/src/commands/dashboard/update-dashcard.ts index 09dc0f3..c5c822f 100644 --- a/src/commands/dashboard/update-dashcard.ts +++ b/src/commands/dashboard/update-dashcard.ts @@ -26,6 +26,7 @@ export default defineMetabaseCommand({ "dashboard-id": { type: "positional", description: "Dashboard id", required: true }, "dashcard-id": { type: "positional", description: "Dashcard id", required: true }, }, + inputSchema: DashcardPatchInput, outputSchema: Dashcard, examples: [ 'mb dashboard update-dashcard 1 5 --body \'{"row":2,"col":0}\'', diff --git a/src/commands/dashboard/update.ts b/src/commands/dashboard/update.ts index f3f2328..acf370c 100644 --- a/src/commands/dashboard/update.ts +++ b/src/commands/dashboard/update.ts @@ -27,6 +27,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, id: { type: "positional", description: "Dashboard id", required: true }, }, + inputSchema: DashboardUpdateInput, outputSchema: DashboardDetail, examples: [ "cat patch.json | mb dashboard update 1", diff --git a/src/commands/document/create.ts b/src/commands/document/create.ts index 175592a..51a5d1c 100644 --- a/src/commands/document/create.ts +++ b/src/commands/document/create.ts @@ -9,6 +9,7 @@ export default defineMetabaseCommand({ meta: { name: "create", description: "Create a document" }, capabilities: { minVersion: 58 }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, + inputSchema: DocumentCreateInput, outputSchema: Document, examples: [ "cat doc.json | mb document create", diff --git a/src/commands/document/update.ts b/src/commands/document/update.ts index 1c86a14..df4c12d 100644 --- a/src/commands/document/update.ts +++ b/src/commands/document/update.ts @@ -16,6 +16,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, id: { type: "positional", description: "Document id", required: true }, }, + inputSchema: DocumentUpdateInput, outputSchema: Document, examples: [ "cat patch.json | mb document update 1", diff --git a/src/commands/eid.ts b/src/commands/eid.ts index a675671..1fbe80d 100644 --- a/src/commands/eid.ts +++ b/src/commands/eid.ts @@ -38,11 +38,12 @@ export default defineMetabaseCommand({ description: "Comma-separated EIDs to translate (used with --model)", }, }, + inputSchema: EidTranslateInput, outputSchema: EidTranslateResult, examples: [ - "mb eid --model card abc123XYZ,def456ABC", + "mb eid --model card Ss3mHTaWs8T-VLPYEeraG,5oQpn8LO4qXBRZ1Wq3jIp", "mb eid --file translate.json", - 'mb eid --body \'{"entity_ids":{"card":["abc123XYZ"]}}\'', + 'mb eid --body \'{"entity_ids":{"card":["Ss3mHTaWs8T-VLPYEeraG"]}}\'', ], async run({ args, ctx, getClient }) { const pair = requireBothOrNeither( diff --git a/src/commands/field/update.ts b/src/commands/field/update.ts index 4444d14..0004fde 100644 --- a/src/commands/field/update.ts +++ b/src/commands/field/update.ts @@ -19,6 +19,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, id: { type: "positional", description: "Field id", required: true }, }, + inputSchema: FieldUpdateInput, outputSchema: Field, examples: [ 'mb field update 100 --body \'{"description":"customer email"}\'', diff --git a/src/commands/group.ts b/src/commands/group.ts index 6ec3484..61cd4d1 100644 --- a/src/commands/group.ts +++ b/src/commands/group.ts @@ -26,6 +26,7 @@ export function defineCommandGroup(def: CommandGroupDef): CommandDef { examples: [], details: null, skills: def.skills ?? [], + inputSchema: null, outputSchema: null, capabilities: null, }); diff --git a/src/commands/measure/create.ts b/src/commands/measure/create.ts index fc19ceb..6fbc591 100644 --- a/src/commands/measure/create.ts +++ b/src/commands/measure/create.ts @@ -26,6 +26,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, ...skipValidateFlag, }, + inputSchema: MeasureCreateInput, outputSchema: Measure, examples: [ "cat measure.json | mb measure create", diff --git a/src/commands/measure/update.ts b/src/commands/measure/update.ts index 21b04c3..b6d94db 100644 --- a/src/commands/measure/update.ts +++ b/src/commands/measure/update.ts @@ -28,6 +28,7 @@ export default defineMetabaseCommand({ ...skipValidateFlag, id: { type: "positional", description: "Measure id", required: true }, }, + inputSchema: MeasureUpdateInput, outputSchema: Measure, examples: [ "cat patch.json | mb measure update 1", diff --git a/src/commands/query.ts b/src/commands/query.ts index 3270336..0773af0 100644 --- a/src/commands/query.ts +++ b/src/commands/query.ts @@ -17,7 +17,11 @@ import { connectionFlags, outputFlags, profileFlag } from "./flags"; import { defineMetabaseCommand } from "./runtime"; import { skipValidateFlag } from "./validate-query"; -const QueryBody = z.unknown(); +const QueryBody = z + .unknown() + .describe( + "MBQL 5, legacy MBQL, or native query body — full MBQL 5 schema: mb query --print-schema", + ); const QUERY_ENDPOINT = "/api/dataset"; @@ -45,6 +49,7 @@ export default defineMetabaseCommand({ }, ...skipValidateFlag, }, + inputSchema: QueryBody, outputSchema: CardQueryResult, examples: [ "mb query --print-schema", diff --git a/src/commands/runtime.ts b/src/commands/runtime.ts index 8d811cf..25bcb2c 100644 --- a/src/commands/runtime.ts +++ b/src/commands/runtime.ts @@ -48,6 +48,7 @@ export interface MetabaseCommandDef { examples?: readonly string[]; details?: string; skills?: readonly SkillPointer[]; + inputSchema?: ZodType; outputSchema?: ZodType; capabilities?: Partial | null; run: (context: MetabaseCommandContext) => Promise | void; @@ -125,6 +126,7 @@ export function defineMetabaseCommand( examples: def.examples ?? [], details: def.details ? def.details : null, skills: def.skills ?? [], + inputSchema: def.inputSchema ?? null, outputSchema: def.outputSchema ?? null, capabilities: required, }); diff --git a/src/commands/segment/create.ts b/src/commands/segment/create.ts index d9bb8eb..b74a14e 100644 --- a/src/commands/segment/create.ts +++ b/src/commands/segment/create.ts @@ -26,6 +26,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, ...skipValidateFlag, }, + inputSchema: SegmentCreateInput, outputSchema: Segment, examples: [ "cat segment.json | mb segment create", diff --git a/src/commands/segment/update.ts b/src/commands/segment/update.ts index 546d4aa..6367872 100644 --- a/src/commands/segment/update.ts +++ b/src/commands/segment/update.ts @@ -28,6 +28,7 @@ export default defineMetabaseCommand({ ...skipValidateFlag, id: { type: "positional", description: "Segment id", required: true }, }, + inputSchema: SegmentUpdateInput, outputSchema: Segment, examples: [ "cat patch.json | mb segment update 1", diff --git a/src/commands/setting/set.ts b/src/commands/setting/set.ts index fcdd525..f3021e6 100644 --- a/src/commands/setting/set.ts +++ b/src/commands/setting/set.ts @@ -8,6 +8,10 @@ import { defineMetabaseCommand } from "../runtime"; import { parseSettingKey, rethrowSettingError } from "./key"; +const SettingValueInput = z + .unknown() + .describe("Any JSON value; the setting's own type constraints are enforced server-side"); + export default defineMetabaseCommand({ meta: { name: "set", description: "Set a setting value (parsed strictly as JSON)" }, capabilities: { minVersion: 58 }, @@ -19,6 +23,7 @@ export default defineMetabaseCommand({ key: { type: "positional", description: "Setting key", required: true }, value: { type: "positional", description: "JSON-encoded value", required: false }, }, + inputSchema: SettingValueInput, outputSchema: SettingValue, examples: [ `mb setting set remote-sync-branch '"main"'`, @@ -31,7 +36,7 @@ export default defineMetabaseCommand({ const key = parseSettingKey(args.key); const value = await readBody( { file: args.file, positional: args.value, source: `setting ${key} value` }, - z.unknown(), + SettingValueInput, ); const client = await getClient(); await client diff --git a/src/commands/setup.ts b/src/commands/setup.ts index 39dbb6e..2c1da05 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -18,6 +18,7 @@ export default defineMetabaseCommand({ ...connectionFlags, ...bodyInputFlags, }, + inputSchema: SetupInput, outputSchema: SetupResult, examples: [ "cat setup.json | mb setup", diff --git a/src/commands/snippet/create.ts b/src/commands/snippet/create.ts index e99eeea..b717023 100644 --- a/src/commands/snippet/create.ts +++ b/src/commands/snippet/create.ts @@ -14,6 +14,7 @@ export default defineMetabaseCommand({ ...connectionFlags, ...bodyInputFlags, }, + inputSchema: SnippetCreateInput, outputSchema: Snippet, examples: [ "cat snippet.json | mb snippet create", diff --git a/src/commands/snippet/update.ts b/src/commands/snippet/update.ts index 8e3ac4b..9c3a7ea 100644 --- a/src/commands/snippet/update.ts +++ b/src/commands/snippet/update.ts @@ -16,6 +16,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, id: { type: "positional", description: "Snippet id", required: true }, }, + inputSchema: SnippetUpdateInput, outputSchema: Snippet, examples: [ "cat patch.json | mb snippet update 1", diff --git a/src/commands/table/update.ts b/src/commands/table/update.ts index 932aef5..2182455 100644 --- a/src/commands/table/update.ts +++ b/src/commands/table/update.ts @@ -19,6 +19,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, id: { type: "positional", description: "Table id", required: true }, }, + inputSchema: TableUpdateInput, outputSchema: Table, examples: [ 'mb table update 42 --body \'{"display_name":"Customers"}\'', diff --git a/src/commands/transform-job/create.ts b/src/commands/transform-job/create.ts index 32d00ab..4d03120 100644 --- a/src/commands/transform-job/create.ts +++ b/src/commands/transform-job/create.ts @@ -13,6 +13,7 @@ export default defineMetabaseCommand({ meta: { name: "create", description: "Create a transform job" }, capabilities: { minVersion: 59 }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, + inputSchema: TransformJobCreateInput, outputSchema: TransformJob, examples: [ "cat job.json | mb transform-job create", diff --git a/src/commands/transform-job/update.ts b/src/commands/transform-job/update.ts index bea8dda..ac87a9a 100644 --- a/src/commands/transform-job/update.ts +++ b/src/commands/transform-job/update.ts @@ -20,6 +20,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, id: { type: "positional", description: "Transform job id", required: true }, }, + inputSchema: TransformJobUpdateInput, outputSchema: TransformJob, examples: [ "cat patch.json | mb transform-job update 1", diff --git a/src/commands/transform-tag/create.ts b/src/commands/transform-tag/create.ts index 6204cb1..a13750b 100644 --- a/src/commands/transform-tag/create.ts +++ b/src/commands/transform-tag/create.ts @@ -13,6 +13,7 @@ export default defineMetabaseCommand({ meta: { name: "create", description: "Create a transform tag" }, capabilities: { minVersion: 59 }, args: { ...outputFlags, ...profileFlag, ...connectionFlags, ...bodyInputFlags }, + inputSchema: TransformTagCreateInput, outputSchema: TransformTag, examples: [ 'mb transform-tag create --body \'{"name":"nightly"}\'', diff --git a/src/commands/transform-tag/update.ts b/src/commands/transform-tag/update.ts index 1a0e175..4336832 100644 --- a/src/commands/transform-tag/update.ts +++ b/src/commands/transform-tag/update.ts @@ -20,6 +20,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, id: { type: "positional", description: "Transform tag id", required: true }, }, + inputSchema: TransformTagUpdateInput, outputSchema: TransformTag, examples: [ 'mb transform-tag update 5 --body \'{"name":"renamed"}\'', diff --git a/src/commands/transform/create.ts b/src/commands/transform/create.ts index f45ac79..c4cb2fa 100644 --- a/src/commands/transform/create.ts +++ b/src/commands/transform/create.ts @@ -28,6 +28,7 @@ export default defineMetabaseCommand({ ...bodyInputFlags, ...skipValidateFlag, }, + inputSchema: TransformCreateInput, outputSchema: Transform, examples: [ "cat transform.json | mb transform create", diff --git a/src/commands/transform/update.ts b/src/commands/transform/update.ts index e03873c..1949c63 100644 --- a/src/commands/transform/update.ts +++ b/src/commands/transform/update.ts @@ -30,6 +30,7 @@ export default defineMetabaseCommand({ id: { type: "positional", description: "Transform id", required: true }, ...skipValidateFlag, }, + inputSchema: TransformUpdateInput, outputSchema: Transform, examples: [ "cat patch.json | mb transform update 1", diff --git a/src/domain/card.ts b/src/domain/card.ts index f6d0c93..8f4ed60 100644 --- a/src/domain/card.ts +++ b/src/domain/card.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { EmbeddingParams } from "./embedding"; import { FieldBaseType, FieldSemanticType } from "./field"; import { Parameter, ParameterMapping } from "./parameter"; import type { ResourceView } from "./view"; @@ -19,7 +20,8 @@ export const CardDatasetQuery = z .refine((value) => "lib/type" in value || "type" in value, { message: 'dataset_query must include "lib/type" (MBQL 5) or "type" (legacy MBQL/native); empty `{}` is rejected', - }); + }) + .describe("MBQL 5, legacy MBQL, or native query — full MBQL 5 schema: mb query --print-schema"); export type CardDatasetQuery = z.infer; export const Card = z @@ -68,43 +70,52 @@ export const cardView: ResourceView = { ], }; +const CollectionIdOrEntityId = z.union([ + z.number().int().positive(), + z.string().length(21).describe("collection entity_id (21-char NanoID)"), +]); + export const CardCreateInput = z .object({ name: z.string().min(1), - type: CardType.optional(), + type: CardType.nullable().optional(), dataset_query: CardDatasetQuery, display: z.string().min(1), visualization_settings: z.record(z.string(), z.unknown()), description: z.string().min(1).nullable().optional(), - collection_id: z.number().int().positive().nullable().optional(), + entity_id: z.string().min(1).nullable().optional(), + collection_id: CollectionIdOrEntityId.nullable().optional(), collection_position: z.number().int().positive().nullable().optional(), + cache_ttl: z.number().int().positive().nullable().optional(), dashboard_id: z.number().int().positive().nullable().optional(), - parameters: z.array(Parameter).optional(), - parameter_mappings: z.array(ParameterMapping).optional(), + dashboard_tab_id: z.number().int().positive().nullable().optional(), + parameters: z.array(Parameter).nullable().optional(), + parameter_mappings: z.array(ParameterMapping).nullable().optional(), + result_metadata: z.array(z.unknown()).nullable().optional(), }) .loose(); export type CardCreateInput = z.infer; export const CardUpdateInput = z .object({ - name: z.string().min(1).optional(), - type: CardType.optional(), + name: z.string().min(1).nullable().optional(), + type: CardType.nullable().optional(), dataset_query: CardDatasetQuery.optional(), - display: z.string().min(1).optional(), - visualization_settings: z.record(z.string(), z.unknown()).optional(), + display: z.string().min(1).nullable().optional(), + visualization_settings: z.record(z.string(), z.unknown()).nullable().optional(), description: z.string().nullable().optional(), - archived: z.boolean().optional(), - enable_embedding: z.boolean().optional(), - embedding_type: z.string().optional(), - embedding_params: z.unknown().optional(), + archived: z.boolean().nullable().optional(), + enable_embedding: z.boolean().nullable().optional(), + embedding_type: z.string().nullable().optional(), + embedding_params: EmbeddingParams.nullable().optional(), collection_id: z.number().int().positive().nullable().optional(), collection_position: z.number().int().positive().nullable().optional(), - collection_preview: z.boolean().optional(), + collection_preview: z.boolean().nullable().optional(), cache_ttl: z.number().int().positive().nullable().optional(), dashboard_id: z.number().int().positive().nullable().optional(), dashboard_tab_id: z.number().int().positive().nullable().optional(), - parameters: z.array(Parameter).optional(), - parameter_mappings: z.array(ParameterMapping).optional(), + parameters: z.array(Parameter).nullable().optional(), + parameter_mappings: z.array(ParameterMapping).nullable().optional(), result_metadata: z.array(z.unknown()).nullable().optional(), }) .loose(); diff --git a/src/domain/collection.ts b/src/domain/collection.ts index 33b3c42..2e8b362 100644 --- a/src/domain/collection.ts +++ b/src/domain/collection.ts @@ -164,7 +164,7 @@ export const CollectionTreeNode: z.ZodType = CollectionTreeN export const CollectionCreateInput = z .object({ name: z.string().min(1), - description: z.string().nullable().optional(), + description: z.string().min(1).nullable().optional(), parent_id: z.number().int().positive().nullable().optional(), namespace: CollectionNamespace.nullable().optional(), authority_level: CollectionAuthorityLevel.nullable().optional(), diff --git a/src/domain/dashboard.ts b/src/domain/dashboard.ts index 08f639f..9d3252a 100644 --- a/src/domain/dashboard.ts +++ b/src/domain/dashboard.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { EmbeddingParams } from "./embedding"; import { Parameter, ParameterMapping } from "./parameter"; import type { ResourceView } from "./view"; @@ -119,34 +120,61 @@ export const dashboardView: ResourceView = { ], }; +const DashcardWrite = z + .object({ + id: z.number().int().describe("existing dashcard id; negative to create a new dashcard"), + size_x: z.number().int().positive(), + size_y: z.number().int().positive(), + row: z.number().int().nonnegative(), + col: z.number().int().nonnegative(), + parameter_mappings: z.array(ParameterMapping).nullable().optional(), + inline_parameters: z.array(z.string().min(1)).nullable().optional(), + series: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + }) + .loose(); + +const DashboardTabWrite = z + .object({ + id: z.number().int().describe("existing tab id; negative to create a new tab"), + name: z.string().min(1), + }) + .loose(); + +// The server's POST /api/dashboard takes no dashcards/tabs; the create command +// accepts them anyway and applies them through a follow-up PUT. export const DashboardCreateInput = z .object({ name: z.string().min(1), description: z.string().nullable().optional(), - parameters: z.array(Parameter).optional(), - cache_ttl: z.number().int().positive().optional(), + parameters: z.array(Parameter).nullable().optional(), + cache_ttl: z.number().int().positive().nullable().optional(), collection_id: z.number().int().positive().nullable().optional(), collection_position: z.number().int().positive().nullable().optional(), - dashcards: z.array(z.unknown()).optional(), - tabs: z.array(DashboardTab.partial()).optional(), + dashcards: z.array(DashcardWrite).optional(), + tabs: z.array(DashboardTabWrite).optional(), }) .loose(); export type DashboardCreateInput = z.infer; export const DashboardUpdateInput = z .object({ - name: z.string().min(1).optional(), + name: z.string().min(1).nullable().optional(), description: z.string().nullable().optional(), - archived: z.boolean().optional(), + caveats: z.string().nullable().optional(), + points_of_interest: z.string().nullable().optional(), + show_in_getting_started: z.boolean().nullable().optional(), + archived: z.boolean().nullable().optional(), + position: z.number().int().positive().nullable().optional(), width: DashboardWidth.optional(), - enable_embedding: z.boolean().optional(), - embedding_params: z.unknown().optional(), - parameters: z.array(Parameter).optional(), + enable_embedding: z.boolean().nullable().optional(), + embedding_type: z.string().nullable().optional(), + embedding_params: EmbeddingParams.nullable().optional(), + parameters: z.array(Parameter).nullable().optional(), cache_ttl: z.number().int().positive().nullable().optional(), collection_id: z.number().int().positive().nullable().optional(), collection_position: z.number().int().positive().nullable().optional(), - dashcards: z.array(z.unknown()).optional(), - tabs: z.array(DashboardTab.partial()).optional(), + dashcards: z.array(DashcardWrite).nullable().optional(), + tabs: z.array(DashboardTabWrite).nullable().optional(), }) .loose(); export type DashboardUpdateInput = z.infer; @@ -158,10 +186,10 @@ export const DashcardPatchInput = z size_x: z.number().int().positive().optional(), size_y: z.number().int().positive().optional(), dashboard_tab_id: z.number().int().nullable().optional(), - parameter_mappings: z.array(ParameterMapping).optional(), - inline_parameters: z.array(z.string()).optional(), + parameter_mappings: z.array(ParameterMapping).nullable().optional(), + inline_parameters: z.array(z.string().min(1)).nullable().optional(), visualization_settings: z.unknown().optional(), - series: z.array(z.unknown()).optional(), + series: z.array(z.record(z.string(), z.unknown())).nullable().optional(), }) .strict() .refine((value) => Object.keys(value).length > 0, { diff --git a/src/domain/document.ts b/src/domain/document.ts index 6b99bc2..b379605 100644 --- a/src/domain/document.ts +++ b/src/domain/document.ts @@ -90,23 +90,53 @@ export const documentView: ResourceView = { ], }; -export const DocumentCreateInput = z +const DocumentName = z.string().min(1).max(254); + +const DocumentCardInput = z .object({ name: z.string().min(1), + dataset_query: z.record(z.string(), z.unknown()), + display: z.string().min(1), + visualization_settings: z.record(z.string(), z.unknown()), + entity_id: z.string().min(1).nullable().optional(), + parameters: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + parameter_mappings: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + description: z.string().min(1).nullable().optional(), + result_metadata: z.array(z.record(z.string(), z.unknown())).nullable().optional(), + cache_ttl: z.number().int().positive().nullable().optional(), + }) + .loose(); + +const NEW_CARD_KEY_DESCRIPTION = + "cards to create inline, keyed by the negative placeholder id referenced from cardEmbed nodes"; + +export const DocumentCreateInput = z + .object({ + name: DocumentName, document: TipTapNodeInput, collection_id: z.number().int().positive().nullable().optional(), collection_position: z.number().int().positive().nullable().optional(), + cards: z + .record(z.string().regex(/^-\d+$/), DocumentCardInput) + .nullable() + .optional() + .describe(NEW_CARD_KEY_DESCRIPTION), }) .loose(); export type DocumentCreateInput = z.infer; export const DocumentUpdateInput = z .object({ - name: z.string().min(1).optional(), + name: DocumentName.optional(), document: TipTapNodeInput.optional(), collection_id: z.number().int().positive().nullable().optional(), collection_position: z.number().int().positive().nullable().optional(), archived: z.boolean().nullable().optional(), + cards: z + .record(z.string().regex(/^-?\d+$/), DocumentCardInput) + .nullable() + .optional() + .describe("cards keyed by card id; negative ids create new cards"), }) .loose(); export type DocumentUpdateInput = z.infer; diff --git a/src/domain/eid-translation.ts b/src/domain/eid-translation.ts index 648abae..817342a 100644 --- a/src/domain/eid-translation.ts +++ b/src/domain/eid-translation.ts @@ -32,7 +32,7 @@ export const EidTranslateInput = z .object({ // partialRecord (not record) — Zod 4's z.record(enum, …) treats every // enum key as required, but the API accepts any subset of models. - entity_ids: z.partialRecord(EidModel, z.array(z.string().min(1)).min(1)), + entity_ids: z.partialRecord(EidModel, z.array(z.string().length(21))), }) .loose(); export type EidTranslateInput = z.infer; diff --git a/src/domain/embedding.ts b/src/domain/embedding.ts new file mode 100644 index 0000000..1e7ba85 --- /dev/null +++ b/src/domain/embedding.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; + +export const EmbeddingParams = z.record(z.string(), z.enum(["disabled", "enabled", "locked"])); +export type EmbeddingParams = z.infer; diff --git a/src/domain/field.ts b/src/domain/field.ts index f5d66a0..472590d 100644 --- a/src/domain/field.ts +++ b/src/domain/field.ts @@ -113,6 +113,8 @@ export const FieldSemanticType = z.enum([ "type/Birthdate", "type/Structured", "type/SerializedJSON", + "type/JSON", + "type/DruidJSON", "type/XML", "type/User", "type/Author", @@ -203,17 +205,17 @@ const NonBlankNullable = z.string().min(1).nullable(); export const FieldUpdateInput = z .object({ - display_name: z.string().min(1).optional(), + display_name: NonBlankNullable.optional(), description: NonBlankNullable.optional(), caveats: NonBlankNullable.optional(), points_of_interest: NonBlankNullable.optional(), semantic_type: FieldSemanticType.nullable().optional(), coercion_strategy: FieldCoercionStrategy.nullable().optional(), fk_target_field_id: z.number().int().positive().nullable().optional(), - visibility_type: FieldVisibilityType.optional(), - has_field_values: FieldValuesType.optional(), + visibility_type: FieldVisibilityType.nullable().optional(), + has_field_values: FieldValuesType.nullable().optional(), settings: z.record(z.string(), z.unknown()).nullable().optional(), - nfc_path: z.array(z.string()).nullable().optional(), + nfc_path: z.array(z.string().min(1)).nullable().optional(), json_unfolding: z.boolean().nullable().optional(), }) .loose(); diff --git a/src/domain/measure.ts b/src/domain/measure.ts index 21d1d58..266298b 100644 --- a/src/domain/measure.ts +++ b/src/domain/measure.ts @@ -42,11 +42,17 @@ export const measureView: ResourceView = { ], }; +const MeasureDefinition = z + .record(z.string(), z.unknown()) + .describe( + "MBQL query holding exactly one aggregation — full MBQL 5 schema: mb query --print-schema", + ); + export const MeasureCreateInput = z .object({ name: z.string().min(1), table_id: z.number().int().positive(), - definition: z.record(z.string(), z.unknown()), + definition: MeasureDefinition, description: z.string().nullable().optional(), }) .loose(); @@ -54,10 +60,10 @@ export type MeasureCreateInput = z.infer; export const MeasureUpdateInput = z .object({ - name: z.string().min(1).optional(), - definition: z.record(z.string(), z.unknown()).optional(), + name: z.string().min(1).nullable().optional(), + definition: MeasureDefinition.nullable().optional(), revision_message: z.string().min(1), - archived: z.boolean().optional(), + archived: z.boolean().nullable().optional(), description: z.string().nullable().optional(), }) .loose(); diff --git a/src/domain/segment.ts b/src/domain/segment.ts index 2754272..03fa1d2 100644 --- a/src/domain/segment.ts +++ b/src/domain/segment.ts @@ -42,11 +42,15 @@ export const segmentView: ResourceView = { ], }; +const SegmentDefinition = z + .record(z.string(), z.unknown()) + .describe("MBQL query holding the filter — full MBQL 5 schema: mb query --print-schema"); + export const SegmentCreateInput = z .object({ name: z.string().min(1), table_id: z.number().int().positive(), - definition: z.record(z.string(), z.unknown()), + definition: SegmentDefinition, description: z.string().nullable().optional(), }) .loose(); @@ -54,14 +58,14 @@ export type SegmentCreateInput = z.infer; export const SegmentUpdateInput = z .object({ - name: z.string().min(1).optional(), - definition: z.record(z.string(), z.unknown()).optional(), + name: z.string().min(1).nullable().optional(), + definition: SegmentDefinition.nullable().optional(), revision_message: z.string().min(1), - archived: z.boolean().optional(), + archived: z.boolean().nullable().optional(), description: z.string().nullable().optional(), caveats: z.string().nullable().optional(), points_of_interest: z.string().nullable().optional(), - show_in_getting_started: z.boolean().optional(), + show_in_getting_started: z.boolean().nullable().optional(), }) .loose(); export type SegmentUpdateInput = z.infer; diff --git a/src/domain/setup.ts b/src/domain/setup.ts index 5cd63a2..f41d340 100644 --- a/src/domain/setup.ts +++ b/src/domain/setup.ts @@ -4,10 +4,13 @@ import type { ResourceView } from "./view"; const SetupUserInput = z .object({ - first_name: z.string().nullable().optional(), - last_name: z.string().nullable().optional(), - email: z.string().min(1), - password: z.string().min(1), + first_name: z.string().min(1).nullable().optional(), + last_name: z.string().min(1).nullable().optional(), + email: z.email(), + password: z + .string() + .min(1) + .describe("must satisfy the server's password complexity requirements"), }) .loose(); diff --git a/src/domain/snippet.ts b/src/domain/snippet.ts index d9ff715..8d39cd3 100644 --- a/src/domain/snippet.ts +++ b/src/domain/snippet.ts @@ -39,9 +39,13 @@ export const snippetView: ResourceView = { ], }; +const SNIPPET_NAME_PATTERN = /^(?!\s)[^}]*$/; +const SNIPPET_NAME_MESSAGE = "snippet names cannot include '}' or start with spaces"; +const snippetName = z.string().regex(SNIPPET_NAME_PATTERN, SNIPPET_NAME_MESSAGE); + export const SnippetCreateInput = z .object({ - name: z.string().min(1), + name: snippetName, content: z.string(), description: z.string().nullable().optional(), collection_id: z.number().int().positive().nullable().optional(), @@ -51,10 +55,10 @@ export type SnippetCreateInput = z.infer; export const SnippetUpdateInput = z .object({ - name: z.string().min(1).optional(), - content: z.string().optional(), + name: snippetName.nullable().optional(), + content: z.string().nullable().optional(), description: z.string().nullable().optional(), - archived: z.boolean().optional(), + archived: z.boolean().nullable().optional(), collection_id: z.number().int().positive().nullable().optional(), }) .loose(); diff --git a/src/domain/table.ts b/src/domain/table.ts index 54f7275..adf9c66 100644 --- a/src/domain/table.ts +++ b/src/domain/table.ts @@ -69,16 +69,24 @@ export const tableView: ResourceView = { ], }; +const TableDataAuthority = z.enum(["unconfigured", "authoritative", "computed", "ingested"]); + export const TableUpdateInput = z .object({ - display_name: z.string().min(1).optional(), + display_name: z.string().min(1).nullable().optional(), entity_type: TableEntityType.nullable().optional(), visibility_type: TableVisibilityType.nullable().optional(), description: z.string().nullable().optional(), caveats: z.string().nullable().optional(), points_of_interest: z.string().nullable().optional(), - show_in_getting_started: z.boolean().optional(), - field_order: TableFieldOrder.optional(), + show_in_getting_started: z.boolean().nullable().optional(), + field_order: TableFieldOrder.nullable().optional(), + data_authority: TableDataAuthority.nullable().optional(), + data_source: z.string().nullable().optional(), + data_layer: z.string().nullable().optional(), + owner_email: z.string().nullable().optional(), + owner_user_id: z.number().int().nullable().optional(), + collection_id: z.number().int().positive().nullable().optional(), }) .loose(); export type TableUpdateInput = z.infer; diff --git a/src/domain/transform.ts b/src/domain/transform.ts index edfb42e..b12db9b 100644 --- a/src/domain/transform.ts +++ b/src/domain/transform.ts @@ -17,10 +17,30 @@ const TransformRunMethod = z.enum(["manual", "cron"]); const TransformRunTrigger = z.enum(["none", "global-schedule"]); +const TransformCheckpointStrategy = z + .object({ + type: z.literal("checkpoint"), + "checkpoint-filter-field-id": z.number().int().positive().optional(), + }) + .loose(); + +const TransformSourceTableEntry = z + .object({ + alias: z.string(), + database_id: z.number().int(), + schema: z.string().nullable(), + table: z.string().optional(), + table_id: z.number().int().nullable().optional(), + }) + .loose(); + const TransformQuerySource = z .object({ type: z.literal("query"), - query: z.unknown(), + query: z + .unknown() + .describe("Native SQL or MBQL query — full MBQL 5 schema: mb query --print-schema"), + "source-incremental-strategy": TransformCheckpointStrategy.optional(), }) .loose(); @@ -28,16 +48,40 @@ const TransformPythonSource = z .object({ type: z.literal("python"), body: z.string(), + "source-tables": z.array(TransformSourceTableEntry), + "source-database": z.number().int().optional(), + "source-incremental-strategy": TransformCheckpointStrategy.optional(), }) .loose(); const TransformSource = z.discriminatedUnion("type", [TransformQuerySource, TransformPythonSource]); +const TransformAppendStrategy = z.object({ type: z.literal("append") }).loose(); + +const TransformMergeKeyColumn = z + .object({ + name: z.string().min(1).optional(), + "field-id": z.number().int().positive().nullable().optional(), + }) + .loose(); + +const TransformMergeStrategy = z + .object({ + type: z.literal("merge"), + "unique-key": z.array(TransformMergeKeyColumn), + }) + .loose(); + +const TransformTargetIncrementalStrategy = z.discriminatedUnion("type", [ + TransformAppendStrategy, + TransformMergeStrategy, +]); + const TransformTableTarget = z .object({ type: z.literal("table"), database: z.number().int().optional(), - schema: z.string().nullable().optional(), + schema: z.string().min(1).nullable().optional(), name: z.string(), }) .loose(); @@ -46,8 +90,9 @@ const TransformTableIncrementalTarget = z .object({ type: z.literal("table-incremental"), database: z.number().int().optional(), - schema: z.string().nullable().optional(), + schema: z.string().min(1).nullable().optional(), name: z.string(), + "target-incremental-strategy": TransformTargetIncrementalStrategy, }) .loose(); diff --git a/src/main.ts b/src/main.ts index 83332c3..dfef374 100644 --- a/src/main.ts +++ b/src/main.ts @@ -50,6 +50,7 @@ setMetabaseAugment(main, { purpose: "guided end-to-end: raw data → clean tables → metrics → answers → dashboards", }, ], + inputSchema: null, outputSchema: null, capabilities: null, }); diff --git a/src/output/help.test.ts b/src/output/help.test.ts index cbff889..3062bf5 100644 --- a/src/output/help.test.ts +++ b/src/output/help.test.ts @@ -326,6 +326,7 @@ describe("showUsage", () => { examples: [], details: null, skills: [{ skill: "core", purpose: "auth and conventions" }], + inputSchema: null, outputSchema: null, capabilities: null, }); @@ -416,6 +417,7 @@ describe("showUsageJson", () => { skills: [], examples: ["mb foo bar --json"], args: [], + inputSchema: null, outputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", diff --git a/src/runtime/command-augment.test.ts b/src/runtime/command-augment.test.ts index 7d0096a..2e9ffd1 100644 --- a/src/runtime/command-augment.test.ts +++ b/src/runtime/command-augment.test.ts @@ -17,6 +17,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { examples: ["mb card list"], details: "see mb skills get mbql", skills: [], + inputSchema: null, outputSchema: z.object({ id: z.number() }), capabilities: BASELINE_CAPABILITIES, }; @@ -31,6 +32,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { examples: ["a"], details: null, skills: [], + inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, }; @@ -38,6 +40,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { examples: ["b"], details: null, skills: [], + inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, }; @@ -53,6 +56,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { examples: ["before"], details: null, skills: [], + inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, }; @@ -60,6 +64,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { examples: ["after"], details: null, skills: [], + inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, }; @@ -74,6 +79,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { examples: [], details: null, skills: [], + inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, }; @@ -83,6 +89,7 @@ describe("getMetabaseAugment / setMetabaseAugment", () => { examples: [], details: null, skills: [], + inputSchema: null, outputSchema: null, capabilities: BASELINE_CAPABILITIES, }); diff --git a/src/runtime/command-augment.ts b/src/runtime/command-augment.ts index dd38891..997249e 100644 --- a/src/runtime/command-augment.ts +++ b/src/runtime/command-augment.ts @@ -11,6 +11,7 @@ export interface MetabaseAugment { examples: readonly string[]; details: string | null; skills: readonly SkillPointer[]; + inputSchema: ZodType | null; outputSchema: ZodType | null; capabilities: Capabilities | null; } diff --git a/src/runtime/command-help.test.ts b/src/runtime/command-help.test.ts index 128055e..b1ff5b5 100644 --- a/src/runtime/command-help.test.ts +++ b/src/runtime/command-help.test.ts @@ -132,6 +132,7 @@ describe("buildHelpEntry", () => { skills: [], examples: ["root leaf --json"], args: [], + inputSchema: null, outputSchema: { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", @@ -146,6 +147,29 @@ describe("buildHelpEntry", () => { }); }); + it("emits the JSON Schema of inputSchema for commands that declare a body contract", async () => { + const leaf = defineMetabaseCommand({ + meta: { name: "create", description: "create" }, + args: {}, + inputSchema: z.object({ name: z.string() }), + run() { + return; + }, + }); + + const entry = await buildHelpEntry(leaf, ["create"]); + + expect(entry.inputSchema).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { + name: { type: "string" }, + }, + required: ["name"], + additionalProperties: false, + }); + }); + it("forwards details for metabase commands that declare it and omits the key otherwise", async () => { const withDetails = defineMetabaseCommand({ meta: { name: "with", description: "short" }, @@ -211,6 +235,7 @@ describe("buildHelpEntry", () => { skills: [], examples: [], args: [], + inputSchema: null, outputSchema: null, capabilities: null, }); @@ -365,6 +390,15 @@ describe("command tree contract", () => { } }); + it("declares an input schema on every command that accepts a JSON body", async () => { + for (const entry of await allEntries()) { + const acceptsBody = entry.args.some((arg) => arg.name === "body"); + if (acceptsBody) { + expect(entry.inputSchema, `missing inputSchema for ${entry.command}`).not.toBeNull(); + } + } + }); + it("gates every measure command at v59 and keeps card commands at baseline", async () => { const entries = await allEntries(); const measureCapabilities = Object.fromEntries( diff --git a/src/runtime/command-help.ts b/src/runtime/command-help.ts index 2c20c8f..abcb89b 100644 --- a/src/runtime/command-help.ts +++ b/src/runtime/command-help.ts @@ -29,6 +29,7 @@ export const CommandHelpEntry = z.object({ skills: z.array(CommandHelpSkill), examples: z.array(z.string()), args: z.array(CommandHelpArg), + inputSchema: z.unknown().nullable(), outputSchema: z.unknown().nullable(), capabilities: Capabilities.nullable(), }); @@ -49,6 +50,7 @@ const EMPTY_AUGMENT: MetabaseAugment = { examples: [], details: null, skills: [], + inputSchema: null, outputSchema: null, capabilities: null, }; @@ -66,6 +68,7 @@ export async function buildHelpEntry( skills: Array.from(augment.skills), examples: Array.from(augment.examples), args: convertArgs(args), + inputSchema: augment.inputSchema ? z.toJSONSchema(augment.inputSchema) : null, outputSchema: augment.outputSchema ? z.toJSONSchema(augment.outputSchema) : null, capabilities: augment.capabilities, };