Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ On success the server is probed once — the rendered output shows the user, rol
| Flag | Description |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `--url <url>` | Metabase URL, including any subpath if the instance is hosted under one (`https://my.org.com/metabase`). Falls back to `MB_URL`, then prompts. |
| `--api-key <value>` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead. |
| `--api-key <value>` | API key. Skips the browser flow. Visible in shell history — pipe on stdin instead; `--api-key -` reads the key from piped stdin. |
| `--client-id <id>` | Pre-registered OAuth client id (only needed when dynamic client registration is disabled on the server). |
| `--profile <name>`, `-p` | Profile to write to (default: `default`). |
| `--skip-verify` | Save without contacting the server (no probe, no cache). |
Expand All @@ -122,6 +122,7 @@ Non-interactive (non-TTY) login requires an API key; resolution order: `--api-ke
mb auth login # interactive: browser or API key
echo "$MB_KEY" | mb auth login --url https://m.example.com
mb auth login --url https://m.example.com < key.txt
mb auth login --url https://m.example.com --api-key - < key.txt
```

### `mb auth status`
Expand Down Expand Up @@ -539,6 +540,15 @@ mb db rescan-values 1
mb db rescan-values 1 --json
```

### `mb db set-data-actions <id> <true|false>`

Enable or disable data actions for a database (`PUT /api/database/:id` with `settings: {"database-enable-actions": …}`), the Data actions toggle of Admin → Databases. While it is off, data actions on the database can be neither created nor run. Needs an admin, and a driver that lists `actions` in its `features`. Returns the database with its `settings`; a value other than `true` or `false` is refused before the request (exit 2).

```sh
mb db set-data-actions 2 true
mb db set-data-actions 2 false --json
```

## Tables

Inspect and edit warehouse tables via `/api/table`. For agent-driven field introspection, `table get --include fields` is the default — it returns the table plus its columns in a single bounded response.
Expand Down Expand Up @@ -1139,7 +1149,7 @@ mb snippet archive 1 --json

## Data actions

CRUD on `/api/action` plus `execute`. A data action is a parameterized native SQL write (`INSERT`, `UPDATE`, `DELETE`) filed in a collection. Data actions are off by default: an admin must enable them on the target database first. `mb data-action create` authors data actions only — no `model_id`, no implicit data actions — and needs a server whose data actions do not require a model.
CRUD on `/api/action` plus `execute`. A data action is a parameterized native SQL write (`INSERT`, `UPDATE`, `DELETE`) filed in a collection. Data actions are off by default: an admin must enable them on the target database first (`mb db set-data-actions <id> true`). `mb data-action create` authors data actions only — no `model_id`, no implicit data actions — and needs a server whose data actions do not require a model.

### `mb data-action list`

Expand Down Expand Up @@ -1169,7 +1179,7 @@ mb data-action create --file data-action.json --skip-validate
| `--file <path>` | Path to JSON body file. |
| `--skip-validate` | Skip the local MBQL 5 pre-flight validation; let the server be the authority. Use only when the bundled schema disagrees with what the server accepts. |

Body fields: `name` (required), `type` (required, `"query"`), `database_id` (required), `dataset_query` (required native query whose `{{tag}}` placeholders are its inputs), `parameters` (one per template tag), `collection_id` (optional; the root when omitted), `description`, `visualization_settings`.
Body fields: `name` (required), `type` (required, `"query"`), `database_id` (required), `dataset_query` (required native query whose `{{tag}}` placeholders are its inputs), `parameters` (one per template tag), `collection_id` (optional; a collection in the `data-actions` or `data-apps` namespace, such as one made by `mb collection create --namespace data-actions`, or the data actions root when omitted — a regular collection is refused), `description`, `visualization_settings`.

### `mb data-action update <id>`

Expand Down Expand Up @@ -1728,11 +1738,13 @@ Read and edit collections on `/api/collection`. Collections are the folders that
mb collection list
mb collection list --json
mb collection list --filter archived --json
mb collection list --namespace transforms --json
```

| Flag | Description |
| ------------------- | --------------------------------------------------------------------------------------------------------------- |
| `--filter <preset>` | One of `all` (default), `archived` (returns the trash collection only), `personal` (only personal collections). |
| Flag | Description |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `--filter <preset>` | One of `all` (default), `archived` (returns the trash collection only), `personal` (only personal collections). |
| `--namespace <ns>` | List one namespace's collections (`transforms`, `snippets`, `data-actions`, …), with its root, instead of normal ones. |

### `mb collection get <id>`

Expand Down Expand Up @@ -2180,7 +2192,7 @@ The import also asserts which branch git-sync tracks, read from the session prop

### `mb git-sync export`

Export Metabase changes back to the configured git remote (Metabase → repo). Auto-polls by default. The export targets the branch git-sync tracks; to push to a new branch, `stash` or `create-branch` first. When the remote has moved past the last sync, a plain export ends in a `conflict` task on Metabase v63 and newer, and is refused with a 400 on older servers. Such a task names the divergence in its text output and error, and may leave the remote's commit counted as synced, as an import conflict may: never answer it with a retry.
Export Metabase changes back to the configured git remote (Metabase → repo). Auto-polls by default. The export targets the branch git-sync tracks; to push to a new branch, `stash` or `create-branch` first. The result names the target `branch` (`null` only where no `--branch` was given and the server, older than v63, exports to the one it tracks), and the text output ends with it. A `--branch` other than the tracked one is looked up in `git-sync branches` first, and one missing from the remote is refused with exit 2, naming `mb git-sync create-branch <branch>`. When the remote has moved past the last sync, a plain export ends in a `conflict` task on Metabase v63 and newer, and is refused with a 400 on older servers. Such a task names the divergence in its text output and error, and may leave the remote's commit counted as synced, as an import conflict may: never answer it with a retry.

```sh
mb git-sync export -m "update dashboards"
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@metabase/cli",
"version": "0.5.1",
"version": "0.5.2",
"description": "Metabase CLI",
"license": "AGPL-3.0",
"repository": {
Expand Down
25 changes: 18 additions & 7 deletions packages/cli/skill-data/data-action/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: data-action
description: Author and run Metabase data actions with the `mb` CLI — saved, parameterized native SQL writes (INSERT, UPDATE, DELETE) filed in a collection and run with values. Covers enabling them on a database, the body, mapping template tags to parameters, running, and the lifecycle. Triggers — "add a data action", "let users update a row", "a button that inserts a record", "run a write query", "a form that saves to the database", "delete rows from Metabase".
description: Author and run Metabase data actions with the `mb` CLI — saved, parameterized native SQL writes (INSERT, UPDATE, DELETE) filed in a collection and run with values. Covers enabling them on a database, the collection they go in, the body, mapping template tags to parameters, running, and the lifecycle. Triggers — "add a data action", "let users update a row", "a button that inserts a record", "run a write query", "a form that saves to the database", "delete rows from Metabase".
allowed-tools: Read, Write, Edit, Bash, AskUserQuestion
requires: [dataActionsWithoutModel]
---
Expand All @@ -13,17 +13,27 @@ Flag conventions and `./.scratch` are in `core`; the native query and its templa

## Check the database first

Data actions are off on every database until an admin turns on **Model actions** for it (Admin → Databases → the database). `mb` cannot change that setting.
Data actions are off on every database until an admin turns on **Data actions** for it ("Allow data actions that use this database to be run", Admin → Databases → the database). That toggle is the database setting `database-enable-actions`.

```bash
mb db get <id> --full --json
```

- `features` must contain `"actions"` — otherwise the driver cannot run writes; pick another database.
- `settings["database-enable-actions"]` must be `true`. If it is not, ask the user to have an admin enable it. Create and execute fail with `Actions are not enabled.` until then.
- `settings["database-enable-actions"]` must be `true`. Create and execute fail with `Actions are not enabled.` until then. An admin profile turns it on with `mb db set-data-actions <id> true`; anyone else asks an admin to. Turning it on lets anyone with access to the database's actions write to it, so confirm with the user first.

## Pick the collection

A data action goes in a data actions collection, a data app collection, or the data actions root (omit `collection_id`). A regular collection is refused with `An action without a model can only go in a data actions or data app collection.` Make one with:

```bash
mb collection create --namespace data-actions --body '{"name":"Order actions"}' --json
```

## The body

Mint one template-tag `id` per tag with `mb uuid --count <n> --format text`.

```json
{
"name": "Set order note",
Expand All @@ -39,14 +49,14 @@ mb db get <id> --full --json
"native": "UPDATE orders SET note = {{note}} WHERE id = {{order_id}}",
"template-tags": {
"order_id": {
"id": "order_id",
"id": "6f1c9a4e-2b7d-4e0a-9c3f-1d2e3f4a5b6c",
"name": "order_id",
"display-name": "Order ID",
"type": "number",
"required": true
},
"note": {
"id": "note",
"id": "0b8d7c6e-5f4a-4b3c-8d2e-1f0a9b8c7d6e",
"name": "note",
"display-name": "Note",
"type": "text",
Expand Down Expand Up @@ -78,7 +88,8 @@ mb db get <id> --full --json
```

- `type` is always `"query"`. Never set `model_id` or `type: "implicit"`; `mb data-action create` refuses both.
- `database_id` and `dataset_query.database` are the same id (`mb db list`). Omit `collection_id` for the root collection (`mb collection list`).
- `database_id` and `dataset_query.database` are the same id (`mb db list`). `collection_id` is the data actions collection (here `12`); omit it for the data actions root.
- A template tag's `id` is a UUID, unique among the tags: the representation schema requires one.
- One statement, no `;`. `create` and `update` validate `dataset_query` as `native-sql` does.

## Parameters: one per template tag
Expand All @@ -92,7 +103,7 @@ Each `{{name}}` needs a `template-tags` entry and a `parameters` entry, all usin
| `date` | `date/single` |
| `boolean` | `boolean/=` |

- Parameter `id` and `slug` are the tag name; `target` is `["variable", ["template-tag", "<name>"]]`.
- Parameter `id` and `slug` are the tag name (not the tag's UUID `id`); `target` is `["variable", ["template-tag", "<name>"]]`.
- Use raw variables only. A field filter (`type: "dimension"`), snippet or card reference does not work in a write — write the comparison yourself (`WHERE id = {{order_id}}`).
- Set `required: true` on every input the statement cannot run without (`WHERE` keys, `NOT NULL` columns).
- Wrap an optional input in `[[ … ]]` with `required: false`; the clause drops out when no value is given. This makes one action do partial updates: `UPDATE orders SET updated_at = now() [[, note = {{note}}]] [[, status = {{status}}]] WHERE id = {{order_id}}`.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/skill-data/transform/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ mb transform run "$TRANSFORM_ID" --wait --profile <name> --json
- The `--json` envelope is shape-stable: `{message, run_id, final}` (plus `target_table_id` under `--sync` — a number, or `null` if the table didn't register before the timeout). `final` is `null` when `--wait` is omitted or the run never started, otherwise a full `TransformRun` with `status` and `message`. On a failed run (`final.status` ∈ {`failed`, `timeout`, `canceled`}) the CLI exits 1 and writes a one-line `transform run <id> failed` to stderr; the failure detail lives only in `final.message` on stdout, so `jq -r '.final.message'` is where to look.
- `transform create --json` returns the agent-facing compact projection: `{id, name, description, source_type, target: {type, database, schema, name}, target_db_id}`. Read `target.schema`/`target.name` directly off it — no follow-up `transform get`.
- If a transform with the same `name` already has a YAML representation on disk under the configured remote-sync repo, `create` mints a `_2` suffix on the exported filename (the new transform gets a fresh `entity_id`; the prior one isn't touched). For "iterate on the same concept", prefer `transform update <id>` — see "Iterating on a failing transform".
- **`collection_id` only accepts a collection in the `:transforms` namespace.** Transforms aren't filed next to cards and dashboards — a normal analytics collection id fails create/update with `collection_id: A Transform can only go in Collections in the :transforms namespace.` Omit `collection_id` to leave the transform uncollected (the common case), or provision one with `mb collection create --body '{"name":"…"}' --namespace transforms --json` (see `core`) and pass the returned `id`. Cards and dashboards you build **on top of** the output table go in ordinary collections — so "put the transform and its dashboard in collection X" means _X holds the dashboard + cards; the transform stays in the transforms namespace._
- **`collection_id` only accepts a collection in the `:transforms` namespace.** Transforms aren't filed next to cards and dashboards — a normal analytics collection id fails create/update with `collection_id: A Transform can only go in Collections in the :transforms namespace.` Omit `collection_id` to leave the transform uncollected (the common case), or reuse one from `mb collection list --namespace transforms --json`, or provision one with `mb collection create --body '{"name":"…"}' --namespace transforms --json` (see `core`) and pass the returned `id`. Cards and dashboards you build **on top of** the output table go in ordinary collections — so "put the transform and its dashboard in collection X" means _X holds the dashboard + cards; the transform stays in the transforms namespace._

## Inspect

Expand Down
45 changes: 43 additions & 2 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
#!/usr/bin/env node
import { runMain } from "citty";
import { parseArgs, runMain } from "citty";
import type { ArgsDef, CommandDef } from "citty";

import { ConfigError } from "@metabase/client/errors";

import { separatePositionals } from "./commands/argv";
import { assertRequiredPositionals, separatePositionals } from "./commands/argv";
import { resolveOutputFormat } from "./commands/context";
import type { Format } from "./output/types";
import { hoistGlobalFlags } from "./commands/global-flags";
import { trustSystemCa } from "./core/system-ca";
import main from "./main";
Expand All @@ -20,6 +22,7 @@ import { installInterruptHandler } from "./runtime/interrupt";

const HELP_FLAGS: ReadonlySet<string> = new Set(["--help", "-h"]);
const JSON_HELP_FLAG = "--json";
const OUTPUT_FORMAT_FLAGS = ["json", "format"] as const;

async function run(): Promise<void> {
installInterruptHandler((code) => process.exit(code));
Expand Down Expand Up @@ -49,10 +52,48 @@ async function run(): Promise<void> {
reportError(new ConfigError(`unknown command: ${unknown}`));
return;
}
if (await refusedLeafArgv(rawArgs)) {
return;
}
}
await runMain(main, { showUsage: showUsageWithBreadcrumb, rawArgs });
}

async function refusedLeafArgv(rawArgs: readonly string[]): Promise<boolean> {
const leaf = await resolveLeafArgv(main, rawArgs);
if (leaf === null) {
return false;
}
const leafArgs = rawArgs.slice(leaf.argsStart);
try {
assertRequiredPositionals(leafArgs, leaf.argsDef);
return false;
} catch (error) {
reportError(error, reportFormat(leafArgs, leaf.argsDef));
return true;
}
}

// Parsed against the output flags alone: citty's parse of the whole definition throws on the very
// positional that is missing. A `--format` value the command would itself refuse leaves the argv
// error in plain text.
function reportFormat(leafArgs: readonly string[], argsDef: ArgsDef): Format | undefined {
const outputFlags = Object.fromEntries(
OUTPUT_FORMAT_FLAGS.flatMap((key) => {
const def = argsDef[key];
return def === undefined ? [] : [[key, def]];
}),
);
const parsed = parseArgs([...leafArgs], outputFlags);
const format = parsed["format"];
const json = parsed["json"] === true;
try {
return resolveOutputFormat(typeof format === "string" ? { json, format } : { json });
} catch {
return undefined;
}
}

async function normalizeArgv(argv: readonly string[]): Promise<string[]> {
const hoisted = hoistGlobalFlags(argv);
const leaf = await resolveLeafArgv(main, hoisted);
Expand Down
26 changes: 25 additions & 1 deletion packages/cli/src/commands/argv.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { assert, describe, expect, it } from "vitest";

import { ConfigError } from "@metabase/client/errors";

import { assertArgv } from "./argv";
import { assertArgv, assertRequiredPositionals } from "./argv";

const ARGS: ArgsDef = {
format: { type: "string", default: "auto" },
Expand Down Expand Up @@ -80,3 +80,27 @@ describe("assertArgv", () => {
expect(() => assertArgv(["--version"], ARGS)).not.toThrow();
});
});

const TWO_POSITIONALS: ArgsDef = {
json: { type: "boolean" },
id: { type: "positional", required: true },
state: { type: "positional", required: true },
};

describe("assertRequiredPositionals", () => {
it("accepts argv that gives every required positional", () => {
expect(() => assertRequiredPositionals(["1", "true", "--json"], TWO_POSITIONALS)).not.toThrow();
});

it("names the missing positional and the command's positionals", () => {
const error = thrownBy(() => assertRequiredPositionals(["1", "--json"], TWO_POSITIONALS));
assert(error instanceof ConfigError, "expected ConfigError");
expect(error.message).toBe("missing argument: <state> (this command takes <id> <state>)");
});

it("reports an unknown flag standing where the positional belongs as the unknown flag", () => {
const error = thrownBy(() => assertRequiredPositionals(["1", "--on"], TWO_POSITIONALS));
assert(error instanceof ConfigError, "expected ConfigError");
expect(error.message).toBe("unknown flag: --on");
});
});
Loading
Loading