Skip to content
Draft
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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -608,6 +608,39 @@ mb content-translation upload --file translations.csv --profile prod --json
| --------------- | ------------------------------------------------------- |
| `--file <path>` | Complete content translation dictionary CSV (required). |

## Data sensitivity

Propose a `data_sensitivity` label for every field of a table, a schema, or a database with Metabot's LLM through `/api/ee/data-sensitivity`. These commands require write access to the database, the `data_sensitivity` premium feature (Metabase v64+), and a configured AI provider. A scan is a dry run: nothing is written, the response is the proposal diffed against each field's current label. Apply one with `mb field update <field-id> --body '{"data_sensitivity":"PII"}'`.

Each field comes back with a status: `agree` (the proposal matches the current label), `disagree`, `new` (no current label yet), `abstain` (the model was unsure), or `dropped` (no usable answer). Text output prints a summary line and a table with one cell per LLM output, `current -> proposed` where they differ, covering every field with a changed label or semantic type; `--json` returns the whole result, including `usage` with `input_tokens` (the whole prompt, cache buckets included), `output_tokens`, `cache_creation_tokens`, `cache_read_tokens`, and `total_tokens`; the text summary breaks the input down by cache bucket when any of it was cached. Every scan spends provider tokens. A whole-database result is often larger than the default `--max-bytes`, so narrow it with `--status disagree,new` or `--schema`, raise `--max-bytes`, or scan one table at a time.

| Flag | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--schema <name>` | `scan-db` only: scan only the tables in this schema. |
| `--status <a,b>` | Keep only fields with these statuses (`agree`, `disagree`, `new`, `abstain`, `dropped`). Text default: every field with a changed label or semantic type; JSON default: all. Counts and token usage stay the server's totals for the scan. |
| `--timeout <ms>` | HTTP timeout for the single synchronous scan request (default 600000). |

### `mb data-sensitivity scan-db <id>`

Scan every active table of a database, or only those in one schema. The request is synchronous and runs as long as the scan, so raise `--timeout` for a large database. A table the server could not classify appears as one row with the error message in the Sensitivity cell, and the run still exits 0.

```sh
mb data-sensitivity scan-db 1
mb data-sensitivity scan-db 1 --schema public
mb data-sensitivity scan-db 1 --status disagree,new --json
mb data-sensitivity scan-db 1 --json --fields counts,failed
```

### `mb data-sensitivity scan-table <id>`

Scan every active field of one table.

```sh
mb data-sensitivity scan-table 3
mb data-sensitivity scan-table 3 --status disagree,new --json
mb data-sensitivity scan-table 3 --json --fields counts,usage
```

## Cards

CRUD plus query execution on `/api/card`. A "card" is a Metabase question, model, or metric. The `query` subcommand runs the card and either returns Metabase's JSON envelope or streams a raw CSV / XLSX export.
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/skill-data/core/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: core
description: Foundations for driving Metabase from the terminal with the `mb` CLI — authentication and named profiles, the flag/output/`--json` conventions every command shares, JSON body input, command discovery via `--help` (add `--json` for machine-readable schemas), and the per-resource footguns (db, table, field, upload, content translation, card, dashboard, collection, segment, measure, timeline, alert, subscription, library, setting, search, eid). Load first for any `mb` task; it routes to the specialized skills for deeper work.
description: Foundations for driving Metabase from the terminal with the `mb` CLI — authentication and named profiles, the flag/output/`--json` conventions every command shares, JSON body input, command discovery via `--help` (add `--json` for machine-readable schemas), and the per-resource footguns (db, table, field, upload, content translation, data sensitivity, card, dashboard, collection, segment, measure, timeline, alert, subscription, library, setting, search, eid). Load first for any `mb` task; it routes to the specialized skills for deeper work.
allowed-tools: Read, Write, Edit, Bash, AskUserQuestion
---

Expand All @@ -11,8 +11,8 @@ The official Metabase CLI (`mb`) drives a Metabase instance over its REST API: a
Top-level command groups (run `mb <group> --help` to discover verbs):

```
auth | db | table | field | upload | content-translation | query | card | dashboard | snippet | segment | measure | collection | library
document | timeline | timeline-event | transform | transform-job | transform-tag | alert | subscription | setting
auth | db | table | field | upload | content-translation | data-sensitivity | query | card | dashboard | snippet | segment | measure | collection
library | document | timeline | timeline-event | transform | transform-job | transform-tag | alert | subscription | setting
search | git-sync | setup | eid | uuid | upgrade | skills
```

Expand Down Expand Up @@ -130,6 +130,7 @@ Routine verb shapes (list / get / create / update), every flag, and output schem
- **field has no `list`.** Fields are per-table — get them via `table get <id> --include fields`. Never enumerate fields across a whole db (context blow-up). `field summary` is live cardinality `{field_id, count, distincts}`; `field values` is the cached distinct set (`has_more_values: true` ⇒ truncated cache). `field update` patches metadata only (`base_type` isn't editable) — this is where you set a column's `semantic_type` or foreign-key target.
- **upload (CSV → tables).** `upload csv --file <path>` creates a new table + model (prints `{model_id, table_id}`); `upload append <table-id>` / `upload replace <table-id> --file <path>` add to / overwrite a table **previously created by upload** (columns must match). The destination db+schema is admin-configured, not per-call — check with `mb setting get uploads-settings --json` (`db_id: null` ⇒ uploads off/unconfigured; needs admin to read). `--collection <id|root>` only sets the model's collection. Max 50 MB. Errors: **"The uploads database is not configured."** = no db has uploads enabled; **"Uploads are not enabled."** = the append/replace target isn't an uploaded table.
- **content-translation.** EE-only (`content_translation` premium feature), admin-only, and separate from Remote Sync. `content-translation download > translations.csv` streams the complete active dictionary; `content-translation upload --file translations.csv` replaces every active translation with the file's contents. Always upload the canonical complete CSV, never a partial patch. An empty dictionary downloads as Metabase's four-row sample dictionary — don't re-upload it as real translations. Metabase limits dictionaries to 1.5 MiB.
- **data-sensitivity.** EE-only (`data_sensitivity` feature, v64+, AI provider). `scan-db <db-id>` / `scan-table <table-id>` ask the LLM for a `data_sensitivity` label per field; dry run: spends tokens, writes nothing. Act on `--status disagree,new`; apply via `field update` (→ `metadata`).
- **card.** `dataset_query` is the **flat** `mbql/query` value, not a legacy `{type:"query",query:…}` envelope (→ `mbql`). `--export-format csv|xlsx` streams the raw export (pipe to a file), bypassing the JSON envelope. `archive` is the only delete; unarchive with `update --body '{"archived":false}'`. `visualization_settings` keys are scoped by `display` and aren't pre-flighted — see `visualization`.
- **dashboard.** Dashcards round-trip through `PUT /api/dashboard/:id` (no per-dashcard endpoint): `update-dashcard <dash-id> <dashcard-id>` patches one safely; `update --body '{"dashcards":[…]}'` replaces the whole set (omitted ids are deleted server-side; negative ids for new cards). Every dashcard must include `card_id`, including existing rows; use `card_id:null` plus a `visualization_settings.virtual_card` block (`{display:"text"|"heading"|"link"|…}`) for non-question cards. `create` accepts the **same** `dashcards` array in its initial body, so lay out the whole dashboard in one call. `create`/`update` pre-flight every positive `card_id` and exit **2** with `{ok:false,errors:[…]}` on a bad ref (non-bypassable). `dashboard get <id>` (or `--full`) hydrates dashcards/tabs; `list` omits them. **The grid is 24 columns wide:** each dashcard's `{col, row, size_x, size_y}` is in grid units — **full-width is `size_x: 24`** (`size_x: 12` is half a row, the usual cause of a card filling only half the width). Keep `col + size_x ≤ 24`, start a full-width stack's `col` at 0, and don't overlap (the server stores collisions as sent — no auto-fix). Layout patterns and per-chart default sizes → the `dashboard` skill; load it before composing any `dashcards` array.
- **dashboard parameters (filters).** A dashboard's `parameters` array holds its filter widgets; they're part of the dashboard record, so read them with `dashboard get <id> --fields parameters --json` (no separate verb). **Editing replaces the _whole_ array** (like dashcards), so it's a read-modify-write loop and omitting a parameter deletes it. A parameter only filters a card once it is **mapped** onto that dashcard's `parameter_mappings` — an unmapped parameter is an inert widget. `type` is a **closed enum**; an unlisted value is a hard parse error that echoes the full allowed set back to you. `dashboard parameter-values <id> <parameter-id> [--query <substr>]` fetches a widget's selectable values (`{values, has_more_values}`; `--query` is a case-insensitive substring search). Parameter types, ids, mapping targets, and value sources → the `dashboard` skill; load it before authoring a `parameters` array.
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/skill-data/metadata/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ This is the whole point of the skill. Each edit below is a key in the `field upd
| `visibility_type: "details-only"` | hidden in table views, shown in the single-record detail view (for long blobs) |
| `coercion_strategy: <strategy>` | **actually casts** the column — the only entry here that changes the value's type (below) |
| `display_name` / `description` | the human label and help text shown everywhere |
| `data_sensitivity: "PII"` (or `"PCI_FIN"`, …, `"PUBLIC"`, `null`) | records how sensitive the column is — metadata only, no query or display effect; `null` means never scanned, `PUBLIC` reviewed and clean |

`table update` carries the table-level equivalents: `display_name`, `description`, `visibility_type` (`hidden` / `technical` / `cruft` — hides the whole table from the builder), `field_order`, and `entity_type`.

Expand Down Expand Up @@ -59,6 +60,14 @@ mb field update 42 --body '{"coercion_strategy":"Coercion/UNIXSeconds->DateTime"

The full semantic-type catalog — every value grouped by the base type it attaches to, plus the `has_field_values` and `visibility_type` value tables and the exact writable-key lists — is in `references/semantic-types.md` (`mb skills get metadata --full`).

## Data sensitivity labels

`data_sensitivity` is a per-column label (`SEC_KEY`, `SYS_TELEMETRY`, `PHI`, `BIO_GEN`, `PCI_FIN`, `SENS_PERS`, `PII`, `CORP_IP`, `BIZ_CONF`, `PUBLIC`, most severe first) recorded as metadata: it changes nothing about queries, formatting, or access. `null` means the column has never been scanned; `PUBLIC` means it was scanned or reviewed and nothing sensitive was found. A value a person set through `field update` is ground truth; a scanner reports where it differs but never overrides it. `mb data-sensitivity scan-table <id>` / `scan-db <id>` (EE, `data_sensitivity` feature, v64+) ask the LLM to propose a label per field as a dry run; apply one with:

```bash
mb field update 42 --body '{"data_sensitivity":"PII"}' --profile <n> --json
```

## Sync, scan, fingerprint — three different refreshes

When a column looks stale or missing, know which one you need (`db` verbs, mechanics in `core`):
Expand Down
76 changes: 76 additions & 0 deletions packages/cli/src/commands/data-sensitivity/examples.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";

import type {
DataSensitivityDatabaseResult,
DataSensitivityTableResult,
} from "@metabase/client/domain/data-sensitivity";

import { applyProjection } from "../../output/projection";
import {
dataSensitivityDatabaseView,
dataSensitivityTableView,
} from "../../output/views/data-sensitivity";
import { getMetabaseAugment } from "../../runtime/command-augment";
import type { ResourceView } from "../../output/view";

import scanDb from "./scan-db";
import scanTable from "./scan-table";

const TABLE: DataSensitivityTableResult = {
table_id: 3,
table_name: "PEOPLE",
schema: "PUBLIC",
database_id: 1,
model: "anthropic/claude-haiku-4-5-20251001",
requests: 1,
usage: {
input_tokens: 1,
output_tokens: 1,
cache_read_tokens: 0,
cache_creation_tokens: 0,
total_tokens: 2,
},
sample_error: null,
counts: { fields: 0, agree: 0, disagree: 0, new: 0, abstain: 0, dropped: 0, semantic_changed: 0 },
fields: [],
};

const DATABASE: DataSensitivityDatabaseResult = {
database_id: 1,
schema: null,
tables: [TABLE],
counts: TABLE.counts,
usage: TABLE.usage,
requests: 1,
failed: 0,
};

// The projection walks plain objects only, so an example advertising a path through `tables` or
// `fields` would throw for every user who copied it.
function fieldsPathsInExamples(cmd: object): string[][] {
const examples = getMetabaseAugment(cmd)?.examples ?? [];
return examples.flatMap((example) => {
const tokens = example.split(/\s+/);
const flagIndex = tokens.indexOf("--fields");
const value = flagIndex === -1 ? undefined : tokens[flagIndex + 1];
return value === undefined ? [] : [value.split(",")];
});
}

function projectsEveryExample<T>(cmd: object, fixture: T, view: ResourceView<T>): void {
const paths = fieldsPathsInExamples(cmd);
expect(paths.length).toBeGreaterThan(0);
for (const fields of paths) {
expect(() => applyProjection(fixture, view, false, fields)).not.toThrow();
}
}

describe("data-sensitivity --fields examples", () => {
it("scan-db advertises only paths the projection can resolve", () => {
projectsEveryExample(scanDb, DATABASE, dataSensitivityDatabaseView);
});

it("scan-table advertises only paths the projection can resolve", () => {
projectsEveryExample(scanTable, TABLE, dataSensitivityTableView);
});
});
11 changes: 11 additions & 0 deletions packages/cli/src/commands/data-sensitivity/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { defineCommandGroup } from "../group";

export default defineCommandGroup({
name: "data-sensitivity",
description: "Propose data sensitivity labels for fields with the LLM (dry run, nothing written)",
skills: [{ skill: "metadata", purpose: "what a data_sensitivity label is and how to apply one" }],
subCommands: {
"scan-db": () => import("./scan-db").then((mod) => mod.default),
"scan-table": () => import("./scan-table").then((mod) => mod.default),
},
});
56 changes: 56 additions & 0 deletions packages/cli/src/commands/data-sensitivity/scan-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { DataSensitivityDatabaseResult } from "@metabase/client/domain/data-sensitivity";

import { filterResult, formatDataSensitivityReport } from "../../output/data-sensitivity-report";
import { renderSummary } from "../../output/render";
import { dataSensitivityDatabaseView } from "../../output/views/data-sensitivity";
import { connectionFlags, outputFlags, profileFlag } from "../flags";
import { parseId } from "../parse-id";
import { defineMetabaseCommand } from "../runtime";

import { parseScanFlags, scanFlags } from "./scan-flags";

const OVERSIZE_HINT =
"narrow the scan with `--schema <name>`, keep fewer rows with `--status disagree,new`, raise " +
"`--max-bytes`, or scan one table at a time with `mb data-sensitivity scan-table <table-id>`";

export default defineMetabaseCommand({
meta: {
name: "scan-db",
description:
"Propose a data sensitivity label for every field of a database with the LLM (dry run)",
},
details:
'Dry run: nothing is written, the response is the proposal. For every active table (or only those in --schema) the server builds one packet of names, types, descriptions, fingerprints and a few sample values, asks the LLM for a label per field, and diffs each proposal against the field\'s current data_sensitivity label, which the model never sees. Statuses: agree, disagree, new (no current label yet), abstain (model unsure), dropped (no usable answer). The request is synchronous and runs as long as the scan, so raise --timeout for large databases; every request spends provider tokens. A table the server could not classify appears as an error entry and the run still exits 0. Apply a proposal with `mb field update <field-id> --body \'{"data_sensitivity":"PII"}\'`.',
capabilities: { minVersion: 64, tokenFeature: "data_sensitivity" },
args: {
...outputFlags,
...profileFlag,
...connectionFlags,
...scanFlags,
id: { type: "positional", description: "Database id", required: true },
schema: { type: "string", description: "Scan only the tables in this schema" },
},
outputSchema: DataSensitivityDatabaseResult,
examples: [
"mb data-sensitivity scan-db 1",
"mb data-sensitivity scan-db 1 --schema public",
"mb data-sensitivity scan-db 1 --status disagree,new --json",
"mb data-sensitivity scan-db 1 --json --fields counts,failed",
],
async run({ args, ctx, getClient }) {
const id = parseId(args.id);
const scan = parseScanFlags(args);
const client = await getClient();
const result = await client.dataSensitivity.classifyDatabase(
id,
{ schema: args.schema },
{ timeoutMs: scan.timeoutMs },
);
renderSummary(
filterResult(result, scan.statuses),
dataSensitivityDatabaseView,
() => formatDataSensitivityReport(result, scan.statuses),
{ ...ctx, oversizeHint: OVERSIZE_HINT },
);
},
});
35 changes: 35 additions & 0 deletions packages/cli/src/commands/data-sensitivity/scan-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { ParsedArgs } from "citty";

import { DataSensitivityStatus } from "@metabase/client/domain/data-sensitivity";
import { DEFAULT_TIMEOUT_MS } from "@metabase/client/poll";

import { parseEnumCsv } from "../../runtime/csv";
import { parseId } from "../parse-id";

export const scanFlags = {
status: {
type: "string",
description: `Keep only fields with these statuses, comma separated: ${DataSensitivityStatus.options.join(" | ")} (text default: every field with a changed label or semantic type; JSON default: all)`,
},
timeout: {
type: "string",
description: "HTTP timeout in ms for the single synchronous scan request",
// One synchronous request covers the whole scan, so its budget is the one a polled wait gets,
// not the transport's per-request default.
default: String(DEFAULT_TIMEOUT_MS),
},
} as const;

type ScanArgs = ParsedArgs<typeof scanFlags>;

interface ScanOptions {
statuses: DataSensitivityStatus[] | null;
timeoutMs: number;
}

export function parseScanFlags(args: ScanArgs): ScanOptions {
return {
statuses: parseEnumCsv(args.status, DataSensitivityStatus, "--status") ?? null,
timeoutMs: parseId(args.timeout, "timeout"),
};
}
Loading
Loading