From 1c8ec4062396ff037a325d13917b1b9abb49ca29 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko Date: Fri, 15 May 2026 17:17:44 -0400 Subject: [PATCH] bundle skill into cli --- .claude-plugin/marketplace.json | 19 ++ README.md | 47 ++- package.json | 5 +- skill-data/core/SKILL.md | 575 ++++++++++++++++++++++++++++++++ skill-data/git-sync/SKILL.md | 196 +++++++++++ skill-data/transform/SKILL.md | 235 +++++++++++++ skill-data/workspace/SKILL.md | 408 ++++++++++++++++++++++ skills/metabase-cli/SKILL.md | 42 +++ src/commands/skills/get.ts | 102 ++++++ src/commands/skills/index.ts | 15 + src/commands/skills/list.ts | 42 +++ src/commands/skills/path.ts | 53 +++ src/core/skills.test.ts | 336 +++++++++++++++++++ src/core/skills.ts | 261 +++++++++++++++ src/main.ts | 1 + tests/e2e/manifest.e2e.test.ts | 3 + tests/e2e/skills.e2e.test.ts | 161 +++++++++ 17 files changed, 2493 insertions(+), 8 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 skill-data/core/SKILL.md create mode 100644 skill-data/git-sync/SKILL.md create mode 100644 skill-data/transform/SKILL.md create mode 100644 skill-data/workspace/SKILL.md create mode 100644 skills/metabase-cli/SKILL.md create mode 100644 src/commands/skills/get.ts create mode 100644 src/commands/skills/index.ts create mode 100644 src/commands/skills/list.ts create mode 100644 src/commands/skills/path.ts create mode 100644 src/core/skills.test.ts create mode 100644 src/core/skills.ts create mode 100644 tests/e2e/skills.e2e.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..030ae6e --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", + "name": "metabase", + "description": "Official Metabase tooling for Claude Code", + "owner": { + "name": "Metabase", + "email": "support@metabase.com" + }, + "plugins": [ + { + "name": "metabase-cli", + "description": "Drive a Metabase instance from the terminal via the `mb` CLI: auth, list/get/create/update/delete on every resource, run queries and transforms, git-sync content to and from a remote, manage Enterprise workspaces. Bundles workspace, transform, and git-sync references as on-demand skills served by `mb skills get`.", + "source": "./", + "strict": false, + "skills": ["./skills/metabase-cli"], + "category": "development" + } + ] +} diff --git a/README.md b/README.md index 8252c16..5fb11ec 100644 --- a/README.md +++ b/README.md @@ -1454,15 +1454,48 @@ Auto-install happens only when `installMethod === "npm-global"`; everything else Exit codes: `0` success (including up-to-date / printed-instructions), `1` registry or install failure, `2` invalid `--to` value, `130` user cancelled the prompt. +## Skills + +The CLI ships with bundled agent skills (Claude Code / `npx skills add` compatible) that document `mb` itself. Content is served at runtime from the installed CLI version, so the instructions an agent fetches always match the binary it's about to run — no drift between a separately-installed skill copy and the CLI. + +```sh +mb skills list # discover bundled skills (table or JSON) +mb skills get core # print the top-level guide +mb skills get core --full # include references and templates +mb skills get workspace,transform # comma-separated, multi-skill fetch +mb skills get --all --json --max-bytes 0 # every non-hidden skill, structured (default cap truncates) +mb skills path # absolute paths for direct Read +mb skills path core # one path +``` + +`mb skills get` honors the shared `--max-bytes` list cap. With the default 65 536 cap, `--all` will return only the first skill and emit a truncation notice — pass `--max-bytes 0` to dump every skill in one envelope. + +Bundled skills: + +| Name | Use | +| ----------- | -------------------------------------------------------------------------------------- | +| `core` | Top-level guide: auth, flag conventions, output flags, body input, every command group | +| `workspace` | Enterprise workspace lifecycle (create, provision, start, child credentials, diagnose) | +| `transform` | Authoring and running transforms (native SQL + MBQL 5), iteration, run inspection | +| `git-sync` | Round-tripping Metabase content to/from a git remote | + +Discovery surfaces: + +- **Claude Code plugin marketplace**: `.claude-plugin/marketplace.json` declares a `metabase-cli` plugin pointing at the in-repo discovery stub. Users install with `/plugin marketplace add metabase/mb-cli` then `/plugin install metabase-cli@metabase`. +- **`npx skills add`**: the same stub at `skills/metabase-cli/SKILL.md` is picked up by `npx skills add metabase/mb-cli`. The stub is intentionally minimal — it redirects the agent at `mb skills get core` so the real workflow content always comes from the installed CLI version. + +Exit codes: `0` success, `2` `ConfigError` (missing name, unknown name, `MB_SKILLS_DIR` not a directory), `1` unexpected I/O. + ## Environment variables -| Variable | Effect | -| ------------------------ | ------------------------------------------------------------------------------ | -| `METABASE_URL` | Default URL for `auth login` and config resolution. | -| `METABASE_API_KEY` | Default API key (overrides interactive prompt; not stored). | -| `METABASE_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | -| `METABASE_LICENSE_TOKEN` | Default license token for `license set`. | -| `METABASE_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | +| Variable | Effect | +| ------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `METABASE_URL` | Default URL for `auth login` and config resolution. | +| `METABASE_API_KEY` | Default API key (overrides interactive prompt; not stored). | +| `METABASE_PROFILE` | Default profile when `--profile` is omitted. Falls back to `default`. | +| `METABASE_LICENSE_TOKEN` | Default license token for `license set`. | +| `METABASE_VERBOSE` | When set to `1`, prints structured developer-detail JSON to stderr on failure. | +| `MB_SKILLS_DIR` | Override the directory `mb skills` scans (dev/test only; defaults to the CLI's bundled `skills` + `skill-data` trees). | ## Agent integration diff --git a/package.json b/package.json index f6f5deb..97d2ce8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "mb": "./dist/cli.mjs" }, "files": [ - "dist" + "dist", + "skills", + "skill-data", + ".claude-plugin" ], "type": "module", "publishConfig": { diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md new file mode 100644 index 0000000..132658a --- /dev/null +++ b/skill-data/core/SKILL.md @@ -0,0 +1,575 @@ +--- +name: core +description: Drive a Metabase instance from the terminal via the `mb` CLI. Authenticate with named profiles; inspect databases (list, get, full metadata rollup, schemas, tables in a schema) and trigger manual schema sync / field-values rescan; inspect tables, fields; list/get/create/update/archive cards (questions, models, metrics) and run them as JSON/CSV/XLSX; list/get/create/update dashboards and patch dashcards; list/get/create collections and traverse the hierarchy by id, entity_id, or "root"/"trash" (with items and recursive tree); list/get/create/update/archive native query snippets, segments, and measures; author/update/run transforms and schedule transform-jobs; read/update settings; search content (cards, dashboards, collections, transforms, metrics); manage Enterprise workspaces; git-sync to/from a git remote (status, dirty, import, export, branches, stash, add/remove a collection from sync). Use whenever the user wants to interact with a Metabase from the terminal — "log into metabase", "what profiles do I have", "list cards", "run card 42 as CSV", "create a transform", "list dashboards", "move a dashcard", "list collections", "what's in collection 4", "show the collection tree", "list snippets", "create a segment", "archive a measure", "search metabase for X", "spin up a workspace", "import the latest changes", "add a directory to git sync", "set a setting", "what schemas are in this database", "trigger a sync", "rescan field values", or anything hitting `mb `. +allowed-tools: Read, Write, Edit, Bash, AskUserQuestion +--- + +# metabase-cli (core) + +The official Metabase CLI (`mb`) drives a Metabase instance over its REST API. It covers auth, list/get/create/update/delete on every resource, query and transform execution, content search, git-sync (representations ↔ instance), Enterprise workspaces, and entity-id translation. + +Top-level command groups (run `mb --help` to discover verbs): + +``` +auth | license | db | table | field | query | card | dashboard | snippet | segment | measure | collection | transform | transform-job +setting | search | git-sync | workspace | setup | api-key | eid +``` + +The general patterns below — auth, flag conventions, output flags, body input, common verb shapes — apply across **every** group. Three flows have enough surface to warrant their own specialized skills; load them on demand (see "Specialized skills" near the bottom). + +## Auth & profiles + +**The agent does not log in for the user.** Authentication is the human's job — they pick the base URL, paste credentials, and store them as a named profile under their own login. The agent's role is to _check_ what profiles exist, _ask_ which to use, and pass `--profile ` through every command. + +**The one exception** is a freshly bootstrapped workspace child. The child's API credentials are minted by the parent the human already authorized; the agent reads them via `mb workspace credentials ` and saves them as a new profile non-interactively. This is the **only** legitimate place for the agent to call `auth login`. See the `workspace` skill, step 4 — and even there, pipe the key on stdin (`--api-key-stdin`), never on a flag value. + +For everything else (parent profile, staging, prod, anything pointing at a Metabase the user has direct credentials for), follow the flow below. + +### Discover what's already configured + +```bash +mb auth list --json # → {data: [{profile, url, present}], returned, total} +mb auth status --json # → {profile, present, url} for the default profile +mb auth status --profile --json # → status of a specific profile +``` + +`auth list` is the primary enumeration path — one call returns every configured profile with sanitized URL and `present` flag. Use it before asking the user which profile to pick. `auth status` is a single-profile probe; reach for it when you know the name and want a quick health check. + +If `auth list` returns an empty `data: []` or the user has no profile set up, **stop and ask them to log in themselves**: + +> Please run, yourself, `mb auth login --url --profile `. Tell me the profile name when you're done. + +Don't suggest a base URL, paste an API key, or run `auth login` on their behalf. Profile names are arbitrary local labels — `prod`, `staging`, the workspace name — let the user pick. + +### Pick the profile to use + +Run `mb auth list --json` first. If exactly one profile is configured and the user's intent doesn't disambiguate, use it. If multiple profiles exist and the user hasn't named one, ask via `AskUserQuestion`, presenting the names from `auth list` as options. Once a name is established, pass `--profile ` to **every** subsequent command. + +### Other secrets (license, warehouse passwords) + +Same rule: the human runs the storing command. To check whether a license is present: + +```bash +mb license status --profile --json # → {present: bool} +``` + +If `present: false`, ask: + +> Please run `echo "" | mb license set --profile ` from your terminal — don't paste the token in chat. + +## Flag conventions (read once, internalize) + +These trip up every fresh run. + +### `--profile` is per-subcommand, not global + +```bash +✅ mb table list --profile prod --json +❌ mb --profile prod table list # → error: "Unknown command prod" +``` + +`--profile` attaches **after** the full verb chain (`table list`, `card get`, `workspace start`). + +### When you do call `auth login` (workspace child only), pipe the key on stdin + +The agent normally doesn't run `auth login` (see "Auth & profiles" — the human does). The one place it _does_ — saving a workspace child's API key after `workspace credentials` — must use stdin, not a flag value: + +```bash +✅ printf '%s' "$KEY" | mb auth login --url --api-key-stdin --profile --json +❌ mb auth login --api-key "$KEY" … # → warns + rejects +``` + +Reason: shell history and process listings leak the value. The CLI rejects the flag form on purpose. + +### `--wait` for async operations + +`workspace start`, `workspace database provision`, `transform run`, and similar async verbs return immediately by default. Pass `--wait` for any interactive flow where the next step depends on completion. Without `--wait` you'll race the operation and see "not ready" / `state: starting` / transient connection refusals. + +### Some outputs are JSON envelopes, not bare strings + +A handful of "lookup" verbs return a JSON object even when you only want a single field. `mb workspace url ` returns `{"workspace_id": ..., "url": "http://..."}`, not `"http://..."`. Don't drop them raw into another flag — extract: + +```bash +WS_URL=$(mb workspace url --profile --json | jq -r '.url') +``` + +If you find yourself writing `--url $(mb ...)` and the receiving command rejects it with "URL must start with http://", this is what happened. + +## Output + +Every list/get verb supports the same output flags: + +| Flag | Effect | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--json` | Emit full JSON envelope; safe for piping into `jq`. Default is human-readable text. | +| `--full` | Include every field (compact projection is the default for list/get). | +| `--fields a,b.c.d` | Project specific dot-paths. Mutually exclusive with `--full`. | +| `--max-bytes ` | Cap **list** output size (drops trailing items, sets `truncated`). Default 65 536; `0` disables. Single-item commands (`get`, `metadata`) never truncate — they only emit a stderr advisory when the body is over the cap. | + +List envelope shape: + +```json +{ + "data": [ + /* items */ + ], + "returned": 10, + "total": 42, + "limit": 50, + "truncated": false +} +``` + +Use `jq '.data[] | { ... }'` to slice it. The compact item projection is the agent-facing contract — for full Metabase fields, add `--full`. + +`total` is best-effort and may be omitted or `null` — the server returns `null` for empty/permissions-filtered collections, and `--limit` early-stop omits it because the true total is unknown. Don't depend on it being a number; use `returned` for the count you actually got back and `data.length` for the rendered slice. + +## Body input (create / update / run) + +Verbs that take a payload accept it from one of four sources, **first non-empty wins**: + +1. `--body ''` +2. `--file ` — JSON file +3. stdin (auto-detected when piped, or explicit with `--stdin` on commands that support it) +4. positional argument + +Picking exactly one is required; passing two of `--body` + `--file` + `--stdin` is rejected with a `ConfigError`. + +Common pattern: + +```bash +cat > /tmp/body.json <<'EOF' +{ ... } +EOF +mb create --file /tmp/body.json --profile --json +``` + +Heredoc with single-quoted `'EOF'` prevents shell from interpolating `$vars` inside the JSON. + +## Discover the full surface: `mb __manifest` + +For the canonical, machine-readable inventory of every command — name, description, examples, every flag with type and default, and the output JSON Schema — run: + +```bash +mb __manifest +``` + +The leading `__` marks it as an internal command (hidden from `--help`), but it's stable: the build relies on it, and so do the in-repo tests. Reach for it instead of running `--help` per command when you need flag/output details. It pairs naturally with `jq`: + +```bash +# Every command name: +mb __manifest | jq -r '.commands[].command' + +# Every verb under "transform": +mb __manifest | jq -r '.commands[] | select(.command | startswith("transform")) | .command' + +# Flags + types for `card query`: +mb __manifest | jq '.commands[] | select(.command == "card query") | .args' + +# Output schema for `card list` (so you know what to parse): +mb __manifest | jq '.commands[] | select(.command == "card list") | .outputSchema' +``` + +Use it to (a) enumerate verbs you don't know by heart, (b) validate flag names before constructing a command, (c) read an output schema before parsing. Cheaper and more reliable than scraping `--help` text. + +## Resources at a glance + +The CLI exposes the Metabase REST API in 13 command groups beyond `auth` / `license`. Each follows the same shape (list/get/create/…); flags + output schemas are in `__manifest`. Only the deviations and quirks worth memorizing are below. + +### `db` (alias `database`) — list and inspect databases + +**Default agent traversal (granular, scales to real warehouses):** + +```bash +mb database list --profile --json # discover db ids +mb database schemas --profile --json # list schema names in one db +mb database schema-tables --profile --json # tables in ONE schema (compact) +mb table get --include fields --profile --json # fields for ONE table (see `table` section) +``` + +This is the path to use. A production Metabase typically has dozens of schemas, hundreds of tables, and dozens of fields per table — walking three levels and pulling one table's fields at a time keeps each response in the kilobytes. The rollup endpoints below pull megabytes and will blow the context window on any real warehouse. + +**Other commands:** + +```bash +mb database list --saved --profile --json # include the Saved Questions virtual db (id -1337) +mb database get --profile --json # db record only (no tables) +mb database sync-schema --profile # POST /sync_schema; queues async work, returns {status:"ok"} +mb database rescan-values --profile # POST /rescan_values; queues async work, returns {status:"ok"} +``` + +**Rollup commands — only on small/dev warehouses:** + +```bash +mb database list --include tables --profile --full --json # every db with its full table list +mb database get --include tables.fields --profile --full --json # one db, every table, every field +mb database metadata --profile --full --json # alias for the above, server-rolled +``` + +Reach for these only when you know the db is small (a seeded dev instance, a sample db, a freshly-bootstrapped test fixture) or when you genuinely need every column of every table in one shot. On a real warehouse the response will exceed the agent context — use the granular traversal instead. + +`sync-schema` / `rescan-values` are the two manual triggers admins reach for after warehouse-side changes; both queue work and return immediately. + +### `table` — list and inspect tables + +```bash +mb table list --db-id --profile --json # all tables in a db (compact, no fields) +mb table get --profile --json # table-level metadata only +mb table get --include fields --profile --json # bundles compact-projected fields ← default for field-listing +mb table fields --profile --json # just the fields, as a list envelope +mb table metadata --profile --json # fields + FKs + dimensions hydrated (heavier) +mb table update --body '{"display_name":"Customers"}' --profile --json +``` + +`table get` hits `/api/table/:id` and never returns fields on its own — `--full` only widens the projection over the already-fetched object. Pass `--include fields` for the field shape needed to author a card, transform, or measure; the hydrated path goes through `/api/table/:id/query_metadata`. Use `table fields` when you want just the field array (no surrounding table metadata) and `table metadata` only when you also need FKs and dimensions hydrated. + +`table list --db-id ` returns every table across every schema as a flat compact list. On a real warehouse with hundreds of tables this is still smaller than `database get --include tables.fields`, but `database schema-tables ` is the right starting point when you know which schema you want. + +`table update ` patches table-level metadata only — `display_name`, `description`, `caveats`, `points_of_interest`, `entity_type`, `visibility_type` (`normal`/`hidden`/`details-only`/`technical`/`cruft`), `field_order` (`alphabetical`/`custom`/`database`/`smart`), `show_in_getting_started`. Only the keys you send are touched. The underlying physical schema (the columns themselves) is not editable here — that's the warehouse's responsibility. + +### `field` — inspect a single field, edit metadata, peek at distinct values + +```bash +mb field get --profile --full --json +mb field values --profile --json # cached distinct values (FieldValues) +mb field summary --profile --json # {field_id, count, distincts} — live from the warehouse +mb field update --body '{"semantic_type":"type/Email"}' --profile --json +mb field update --body '{"fk_target_field_id":}' --profile --json +mb field update --body '{"description":"customer email"}' --profile --json +``` + +No `list` — fields are per-table, so use `table get --include fields` (compact) or `table fields ` (list envelope). Never try to enumerate fields across an entire database — that's what blows up the context. + +`field update` patches metadata only — `display_name`, `description`, `caveats`, `points_of_interest`, `semantic_type` (Metabase type hierarchy: `type/Email`, `type/Category`, `type/PK`, `type/FK`, …), `coercion_strategy`, `fk_target_field_id` (the foreign-key target field), `visibility_type` (`normal`/`hidden`/`details-only`/`sensitive`/`retired`), `has_field_values` (`list`/`search`/`none`/`auto-list`), `settings`, `nfc_path`, `json_unfolding`. Only the keys you send are touched. `base_type` is not editable — that's the column's type as the warehouse reports it. + +`field values` returns the _cached_ distinct values populated by the most recent field-values scan (`mb db rescan-values ` triggers a refresh). Useful when authoring a filter and you need the closed set of categorical values. Returns `{values, field_id, has_more_values, has_field_values}` — `has_more_values: true` means the cache was truncated; consider widening the cap server-side rather than treating the snapshot as exhaustive. + +`field summary` returns `{field_id, count, distincts}` — cardinality straight from the warehouse, not the cache. Cheap pre-flight when deciding whether a column makes sense as a `list`-widget filter (low cardinality) or a `search` widget (high cardinality), and a quick way to spot a field that's effectively constant before you build a card around it. + +### `query` — run ad-hoc MBQL with pre-flight validation + +```bash +mb query --print-schema --profile > /tmp/mbql.json # fetch the JSON Schema +mb query --file q.json --dry-run --profile # validate, no network +mb query --file q.json --profile --json # validate + run +``` + +The canonical agent-side path for ad-hoc MBQL. Three modes: + +- `--print-schema` — emits `{ schema, defs }` where `defs` carries `id.yaml` / `parameter.yaml` / `ref.yaml` / `temporal_bucketing.yaml` keyed by the path used in the schema's `$ref`s. Use this **first** when authoring a non-trivial query — it's cheaper than guess-and-fail. +- `--dry-run` — validates and emits `{ ok, errors: [{path, message}] }`. Exit 0 if valid, 2 if not. No request sent. +- run (no flag) — validates, then on success runs the query. On validation failure: same envelope on stdout, exit 2, **never sends** the request. + +MBQL 5 bodies use numeric IDs (`database: 1`, `source-table: 7`) and POST to `/api/dataset`. The bundled schema's `id.yaml` is overridden to require positive integers for every ID `$def`. + +Validation error envelope (same shape across `query`, `card create`, `transform create/update`): + +```json +{ "ok": false, "errors": [{ "path": "/stages/0/aggregation/0", "message": "must be array" }] } +``` + +`path` is a JSON Pointer into the body, `message` is the validator error string. Iterate against `--dry-run` until `ok: true`, then drop `--dry-run` to run. + +Exit codes: `0` valid + ran, `2` validation failed / malformed body, `1` server-side error after a valid pre-flight. + +**Any non-MBQL 5 body skips pre-flight automatically.** Legacy MBQL 4 (`{type:"query", database:N, query:{source-table:T, …}}`), legacy native (`{type:"native", database:N, native:{query:"…"}}`), and any other shape that doesn't carry `lib/type:"mbql/query"` are accepted by `/api/dataset` as-is and normalized server-side by `lib-be/normalize-query` (the same normalizer that backs `card create` / `transform create`, so behavior is symmetric across endpoints). The bundled schema only models MBQL 5; the CLI skips validation for the rest. Just `mb query --file probe.json` works for ad-hoc native SQL or legacy MBQL 4 probes; no `--skip-validate` needed. `--dry-run` on a non-MBQL 5 body returns `{ ok: true, errors: [] }`. The double-wrap footgun (`{type:"query", query:{lib/type:"mbql/query",…}}`) is still rejected with a `ConfigError` before send. + +**`--skip-validate`** is the escape hatch for MBQL 5 bodies: bypasses the pre-flight and sends the body as-is. Use only when the bundled schema disagrees with what the server actually accepts (drift, false negative). Mutually exclusive with `--dry-run`. Same flag works on `mb card create` and `mb transform create / update`. + +**MBQL 5 clause shape — opts always second.** Every clause is `[op, {options}, ...args]`: options object is the **second** element, not the third. Field refs are `["field", {options}, fieldId]` (id third), not the legacy MBQL 4 shape `["field", id, opts]`. The same `[op, {options}, …]` rule applies to aggregations (`["count", {options}]`, `["sum", {options}, ]`), filters (`["=", {options}, , ]`), order-by (`["asc", {options}, ]`), and every other clause. Slot-1 violations surface from `--dry-run` as `must be the field options object` / `must be the clause options object` at `/stages/0///1`. + +### `uuid` — mint UUID v4 strings for `lib/uuid` slots + +```bash +mb uuid # one UUID, v4 from crypto.randomUUID +mb uuid --count 5 # five UUIDs (one per line in TTY, JSON when piped) +mb uuid --count 5 --json # ["uuid1", "uuid2", …] +``` + +**Hard rule for agents: never generate, invent, hard-code, or reuse UUID values.** Always call `mb uuid` for fresh UUIDs at the moment you need them. Do not copy UUIDs from documentation examples, prior conversations, prior queries you authored, or anywhere else — every `lib/uuid` slot gets a freshly-minted value. The bundled schema enforces RFC 4122 format strictly, so placeholder strings (`"a1"`, `"uuid-1"`, `"agg-uuid-001"`, …) fail pre-flight with `must be a UUID v4 (RFC 4122) — run \`mb uuid\` …`. The same rule applies to native template-tag `id`fields, parameter ids, and any other`format: "uuid"` slot. + +Workflow when assembling an MBQL 5 body: + +1. Count the `lib/uuid` slots you need (one per clause options object, plus aggregation-ref ↔ aggregation pairings — those two share the same string). +2. `mb uuid --count --json` — mint exactly that many in one call. +3. Substitute each minted value into its slot as you build the JSON. + +Aggregation-ref pairing: the `["aggregation", {options}, ""]` ref's third arg must equal the target aggregation's own `lib/uuid` (string equality). Mint the aggregation's `lib/uuid` once, then reuse that _same minted value_ for the ref — that's the only legitimate "reuse" pattern, and it's intra-body, not across bodies or sessions. + +### `card` — questions, models, metrics + +```bash +mb card list --profile --json +mb card get --profile --full --json +mb card query --profile --json --limit 50 +mb card query --profile --export-format csv > /tmp/results.csv +mb card query --profile --export-format xlsx > /tmp/results.xlsx +mb card query --profile --parameters '[{"type":"category","value":"A","target":["variable",["template-tag","c"]]}]' +mb card create --file body.json --profile --json +mb card update --body '{"name":"renamed"}' --profile --json +mb card update --body '{"display":"bar"}' --profile --json +mb card update --body '{"archived":false}' --profile --json # unarchive +mb card archive --profile # soft-delete; not undoable from the CLI +``` + +`--export-format csv|xlsx` bypasses the JSON envelope and streams the raw export — pipe to a file. There is no permanent-delete; `archive` is the only delete verb (and `update --body '{"archived":false}'` is the unarchive path). + +**`card update `** patches a partial subset of the create shape (`name`, `display`, `dataset_query`, `visualization_settings`, `description`, `archived`, `collection_id`, `dashboard_id`, `cache_ttl`, `parameters`, `parameter_mappings`, …). Only the keys you send are touched. If `dataset_query` is MBQL 5 (`lib/type: "mbql/query"`) it goes through the same pre-flight validation as `card create` and `mb query`; pass `--skip-validate` to bypass. + +**MBQL 5 `dataset_query` is a _flat_ `mbql/query`, not a legacy envelope.** This is the most common authoring mistake — the legacy MBQL4 shape `{type:"query", database:N, query:{...}}` looks similar but the server _will silently double-wrap_ an MBQL5 body submitted that way (you'll see the second-level `stages` nested inside an outer empty stage on `card get`), and queries fail with `"Initial MBQL stage must have either :source-table or :source-card"`. The right shape: + +```json +{ + "name": "Total shipments", + "display": "scalar", + "collection_id": 8, + "dataset_query": { + "lib/type": "mbql/query", + "database": 2, + "stages": [ + { + "lib/type": "mbql.stage/mbql", + "source-table": 190, + "aggregation": [["count", { "lib/uuid": "" }]] + } + ] + }, + "visualization_settings": {} +} +``` + +`dataset_query` is the mbql/query value itself — no `type:"query"` envelope, no `query:` nesting. + +**MBQL 5 pre-flight on `card create` / `card update`:** when `dataset_query` has `lib/type: "mbql/query"`, the body is validated against the same schema as `mb query` before sending. On failure, exit 2 with the standard `{ ok, errors }` envelope on stdout. Legacy `dataset_query` shapes (MBQL 4, native) skip pre-flight. The pre-flight also rejects the double-wrap mistake above (MBQL 5 nested inside a legacy `{type:"query", query:…}` envelope) with a `ConfigError` pointing at the right shape — no `--skip-validate` will get that past pre-flight. Author MBQL 5 by fetching the schema via `mb query --print-schema` and iterating with `mb query --dry-run`. Pass `--skip-validate` to bypass the pre-flight on schema-shape disagreements and let the server be the authority. + +**Visualization settings.** The valid keys for `visualization_settings` are scoped by the card's `display` value (`scalar`, `bar`, `line`, `area`, `combo`, `pie`, `table`, `pivot`, `row`, `waterfall`, `scatter`, `boxplot`, …). The CLI does not validate this object client-side — the schema lives in the **`metabase-representation-format`** skill, `spec.md` "Visualization Settings" section (graph / series / table / pivot / pie / scalar subsections, plus common `column_settings`). Load that skill if it isn't active when authoring viz keys. Common keys you'll reach for: + +- `bar` / `line` / `area` / `combo` / `scatter` / `waterfall` / `row` / `boxplot`: `graph.dimensions`, `graph.metrics`, `graph.show_values`, `graph.x_axis.title_text`, `graph.y_axis.title_text`, `graph.show_goal`, `graph.goal_value`, `stackable.stack_type`, plus per-series settings (`series_settings`). +- `pie`: `pie.dimension`, `pie.metric`, `pie.show_total`, `pie.percent_visibility`, `pie.show_legend`. +- `scalar`: `scalar.prefix`, `scalar.suffix`, `scalar.decimals`, plus `column_settings` for number formatting on the displayed column. +- `table`: `table.columns` (order + visibility), `table.column_formatting` (conditional formatting), `column_settings` for per-column display. +- `pivot`: `pivot_table.column_split` (rows / columns / values), `pivot.show_row_totals`, `pivot.show_column_totals`. + +Empty `{}` is always valid; defaults apply. + +### `dashboard` — dashboards and dashcards + +```bash +mb dashboard list --profile --json +mb dashboard list --filter archived --profile --json +mb dashboard get --profile --full --json # --full hydrates dashcards + tabs +mb dashboard cards --profile --json # list of dashcards on the dashboard +mb dashboard create --file body.json --profile --json +mb dashboard create --body '{"name":"D","dashcards":[{"id":-1,"card_id":42,"row":0,"col":0,"size_x":12,"size_y":6}]}' --profile --json +mb dashboard update --body '{"name":"renamed"}' --profile --json +mb dashboard update-dashcard --body '{"row":4,"col":2}' --profile --json +``` + +A "dashcard" is a card placement on a dashboard — its own id, position (`row`/`col`), and size (`size_x`/`size_y`). Dashcards are nested inside the parent dashboard's response; the API has no per-dashcard endpoint, so dashcard edits round-trip through `PUT /api/dashboard/:id`. + +A dashcard's `visualization_settings` overrides the underlying card's — same key list as the `card` section above. Dashcards can additionally set `click_behavior` for cell-level navigation; see the `metabase-representation-format` skill's "Click Behavior" subsection for that schema. + +**`dashboard create` accepts `dashcards` and `tabs` in the body.** The create endpoint itself only sets dashboard metadata (name, description, collection, parameters); when the body carries `dashcards` or `tabs`, the CLI chains a `PUT /api/dashboard/:id` automatically and renders the hydrated dashboard back. The compact projection includes the resulting `dashcards` and `tabs` arrays (each entry projected to id / position / size / card_id / tab_id), so the agent can confirm the placements landed without a second call. Use `--full` to also see dashboard-level metadata (width, embedding flags, parameters, …). Use a negative id (`-1`, `-2`, …) for new dashcards. + +**Card-reference pre-flight on `dashboard create` / `dashboard update`.** Before either command sends anything, every positive `card_id` referenced from `dashcards` is checked against `GET /api/card/:id` in parallel (de-duplicated per id). Cards that don't exist, are archived, or aren't readable fail pre-flight: the CLI writes a `{ok:false, errors:[{path, message}]}` envelope to stdout (one entry per offending dashcard, `path` = JSON pointer like `/dashcards/3/card_id`) and exits **2** with `dashboard card-reference pre-flight failed: N error(s) — fix the dashcard card_id values listed above` on stderr. No dashboard is created or modified on a pre-flight miss — this is the contract that eliminates orphan dashboards from chained creates. The pre-flight is non-bypassable: it queries live server state (no bundled schema), so there is no `--skip-validate` escape hatch. If pre-flight rejects something you believe is valid, the input is stale — `card list --json` to confirm, then re-author. + +**Chained-PUT failures call out the orphan risk explicitly.** If the chained `PUT /api/dashboard/:id` fails after the `POST /api/dashboard` already created the row (rare with pre-flight, but possible on permission / 5xx / network mid-flight), the user-facing error becomes `dashboard created but follow-up PUT /api/dashboard/ failed: ; dashcards not applied`. Recovery: `mb dashboard get ` to confirm the empty row, then either `dashboard update --body '{"dashcards":[...]}'` to retry the dashcards, or `dashboard update --body '{"archived":true}'` to archive the orphan. Split-into-two recipe for debugging: `dashboard create` with a metadata-only body, then `dashboard update ` with the `dashcards` array — isolates which leg of the chain is at fault. + +Two ways to edit dashcards: + +- **`dashboard update --body { "dashcards": [...] }`** — replaces the entire dashcard set. IDs in the array are kept (and updated to the values you send); IDs **absent** are deleted server-side. Use a negative id (`-1`, `-2`, …) for cards the server should create. You must include every existing dashcard you want to preserve. +- **`dashboard update-dashcard `** — patches a single dashcard's layout / settings without touching the others. Internally: GET dashboard → merge patch into the targeted dashcard → PUT the whole array. Safer than hand-rolling the full-array variant if you only meant to nudge one card. + +`dashboard list` is a thin filter helper (`--filter all|mine|archived`; default `all`). The list endpoint omits `dashcards` / `tabs`; `dashboard get ` includes them as compact projections, and `dashboard get --full` (or `dashboard cards `) gives the full hydrated form. + +Patch fields supported by `update-dashcard`: + +| Field | Type | +| ------------------------ | ---------------------------------- | +| `row`, `col` | non-negative integer | +| `size_x`, `size_y` | positive integer | +| `dashboard_tab_id` | integer or `null` | +| `parameter_mappings` | array of parameter-mapping objects | +| `inline_parameters` | array of strings | +| `visualization_settings` | object | + +Empty-object patches are rejected client-side before any network call. + +### `snippet` — native query snippets (reusable SQL fragments) + +```bash +mb snippet list --profile --json +mb snippet list --archived --profile --json # → ONLY archived (mutually exclusive with active) +mb snippet get --profile --full --json +mb snippet create --body '{"name":"active","content":"WHERE active = true"}' --profile --json +mb snippet update --body '{"name":"renamed"}' --profile --json +mb snippet update --body '{"archived":false}' --profile --json # unarchive +mb snippet archive --profile # soft-delete +``` + +Hits `/api/native-query-snippet`. A snippet is a named, reusable piece of native (SQL) query text — referenced from cards via `{{snippet: Name}}`. **`--archived` is a swap, not a union**: list returns either active (default) or archived rows, never both. Compact projection: `id`, `name`, `description`, `archived`, `collection_id`. Create body required fields: `name`, `content`. Update body is partial — `name`, `content`, `description`, `archived`, `collection_id`. + +### `segment` — saved MBQL filter macros + +```bash +mb segment list --profile --json +mb segment get --profile --full --json +mb segment create --file segment.json --profile --json +mb segment update --body '{"name":"renamed","revision_message":"rename"}' --profile --json +mb segment archive --profile # default audit message +mb segment archive --revision-message "deprecated" --profile # custom audit message +``` + +Hits `/api/segment`. A segment is a saved MBQL filter macro tied to a table — used in card filters to share a reusable predicate. Create body required: `name`, `table_id`, `definition` (MBQL filter object), optional `description`. **Update bodies MUST include `revision_message`** (a non-blank string captured in the audit log); the CLI does not synthesize it. The `archive` verb hardcodes `"Archived via mb CLI"` by default — override with `--revision-message`. + +Compact projection: `id`, `name`, `description`, `archived`, `table_id`. The list response is bare; only the get/list responses hydrate `creator` and (list-only) `definition_description`. + +### `measure` — saved MBQL aggregation macros + +```bash +mb measure list --profile --json +mb measure get --profile --full --json +mb measure create --file measure.json --profile --json +mb measure update --body '{"name":"renamed","revision_message":"rename"}' --profile --json +mb measure archive --profile +mb measure archive --revision-message "deprecated" --profile +``` + +Hits `/api/measure`. A measure is a saved MBQL aggregation (a single `:aggregation` clause) tied to a table — referenced from cards and metrics to share a reusable computation. Create body required: `name`, `table_id`, `definition` (MBQL aggregation object), optional `description`. Same `revision_message` requirement on update / archive as `segment`. + +Compact projection: `id`, `name`, `description`, `archived`, `table_id`. The full response on `get` adds `dimensions`, `dimension_mappings`, `result_column_name`; the list response adds `definition_description` instead. + +### `collection` — folder hierarchy for cards, dashboards, sub-collections + +```bash +mb collection list --profile --json +mb collection list --filter archived --profile --json # → just the trash collection +mb collection list --filter personal --profile --json # → only personal collections +mb collection get --profile --json --full +mb collection items --profile --json +mb collection items --models card,dashboard --pinned-state is_pinned --profile --json +mb collection tree --profile # → JSON only, recursive +mb collection create --body '{"name":"My Collection","parent_id":4}' --profile --json +``` + +`` (the positional id on `get` and `items`) accepts **four** forms — anything else is rejected client-side with a `ConfigError` before any HTTP call: + +| Form | Example | Notes | +| ----------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| Positive integer | `4` | Database id of the collection. | +| `root` | `mb collection get root` | The virtual "Our analytics" root. Returns a stripped-down shape — `archived`, `description`, `location`, `type`, etc. are _absent_, not `null`. | +| `trash` | `mb collection get trash` | The trash collection — paradoxically returns `archived: false`, `type: "trash"`. Filter via `list --filter archived` to enumerate it. | +| 21-char entity_id | `voo1If9y8Sld0lXej6xl0` | NanoID form (regex `^[A-Za-z0-9_-]{21}$`). Works wherever an int does — Metabase resolves it server-side via the same route. | + +**`collection items` is auto-paginated.** The CLI drains all pages of `/api/collection/:id/items` by default; pass `--limit ` to cap the total returned. With `--limit` set, the result envelope omits `total` (true total is unknown after early-stop). Items at the root level (`collection items root`) carry `collection_id: null`. + +**`collection tree` is JSON-only.** The recursive `{id, name, location, here, children, …}` structure does not render meaningfully as a key/value table; passing `--format text` is rejected with `ConfigError` so the user gets a clear signal rather than silent JSON. + +**Compact projection** (default for `list` / `get`): `id`, `name`, `description`, `archived`, `location`, `parent_id`, `type`, `authority_level`, `is_personal`. Use `--full` for hydrated fields like `slug`, `entity_id`, `can_write`, `namespace`, `personal_owner_id`. The compact projection on items is even tighter: `id`, `model`, `name`, `description`, `archived`, `collection_id`. + +**`collection create` body** accepts the same fields as `POST /api/collection`: `name` (required, non-empty), `description`, `parent_id` (omit or `null` for the root), `namespace`, `authority_level`. Note: the create response does _not_ hydrate `parent_id` (only `location` reflects the parent path); use `collection get ` if you need `parent_id` populated. + +For dashboard / card / collection enumeration, prefer the dedicated `collection list` / `dashboard list` / `card list` verbs over `mb search --models collection` — search is for ranking against a query string or cross-resource lookup, not bulk enumeration. + +### `transform` and `transform-job` + +```bash +mb transform list --profile --json +mb transform run --wait --profile --json +mb transform runs --transform-id --profile --json # recent runs, optionally filtered +mb transform get-run --profile --json # single run by RUN id (not transform id) +mb transform cancel --profile --json # cancel the in-flight run for a transform +mb transform-job list --profile --json +``` + +**MBQL 5 pre-flight on `transform create` / `update`:** when `source.query` has `lib/type: "mbql/query"`, it's validated against the same schema as `mb query` before sending; failures exit 2 with the standard `{ ok, errors }` envelope on stdout. Legacy `source.query` shapes and Python sources skip pre-flight. Pass `--skip-validate` to bypass. + +**Iterate via `transform update`, not re-`create`.** When a `transform run` fails and you want to retry with a fixed body, patch the existing transform with `transform update --file new-body.json` rather than `transform delete ` + `transform create`. Update keeps the same row, `entity_id`, materialized table, and on-disk YAML filename — `git-sync export` produces one clean commit, and you avoid the `_2` suffix the YAML serializer mints when two same-named transforms exist on disk. See the `transform` skill, "Iterating on a failing transform". + +For the body shape, run-with-wait pattern, schedule authoring, and inspection load the `transform` skill via `mb skills get transform`. + +### `setting` (alias `settings`) — admin settings + +```bash +mb setting list --profile --json # admin-only +mb setting get --profile --json +mb setting set --body '""' --profile # value parsed as STRICT JSON +``` + +The value is parsed as strict JSON: a string setting is `'"value"'` (note the inner double quotes), not `value`. Booleans are `true` / `false`, numbers bare. Wrong quoting silently produces a parse error — confirm with `setting get ` after. + +**`setting get --json` works on every value type.** String-valued settings (e.g., `remote-sync-branch=agent/shipments-analysis`, `remote-sync-url=file:///mnt/repo`) come back from `/api/setting/` as bare text rather than a JSON-quoted string; the CLI sniffs the response Content-Type and wraps bare text into the `{key, value}` envelope so `--json` is uniform. The same fix applies to `git-sync status --json` (which reads `remote-sync-branch` internally). + +### `search` — content search across types + +```bash +mb search "orders" --profile --json +mb search "orders" --models card,dashboard --limit 10 --profile --json +mb search "drafts" --archived --verified --profile --json +mb search "orders" --table-db-id --profile --json +``` + +`--models` filters: `card,dataset,metric,dashboard,collection,database,table,segment,measure,snippet,document,action,transform,indexed-entity`. For plain enumeration / inspection of cards, dashboards, or collections, prefer the dedicated `card list` / `dashboard list` / `collection list` verbs above; reach for `search --models ` only when you need ranking against a query string or a cross-resource lookup. + +### `git-sync` — content sync (representations ↔ instance) + +```bash +mb git-sync status --profile --json +mb git-sync import --branch --profile # --wait is the default +mb git-sync export -m "commit message" --profile +mb git-sync branches --profile --json +``` + +14 verbs (status / is-dirty / has-remote-changes / dirty / current-task / cancel-task / wait / import / export / stash / branches / create-branch / add-collection / remove-collection). Both `import --force` and `export --force` are **lossy** — confirm with the user before either. `add-collection ` / `remove-collection ` toggle a collection's `is_remote_synced` and cascade to descendants by location prefix; the server rejects them in the default read-only mode (`mb setting set remote-sync-type '"read-write"'` first). For the dirty-check workflow, stash semantics, and the full collection-toggle prerequisites, load the `git-sync` skill via `mb skills get git-sync`. + +### `workspace` — Enterprise workspaces (parent-side + local child) + +Lifecycle, provisioning, child-credential extraction, diagnose. Load the `workspace` skill via `mb skills get workspace` — it's the densest reference and assumes the conventions above. + +### `api-key` — create API keys + +```bash +mb api-key create --body '{"name":"agent-demo","group_id":}' --profile --json +``` + +Admin-only. The response includes the unmasked key once — capture it; the API never reveals it again. + +### `eid translate` — string EID → numeric id + +```bash +mb eid translate --profile --json +``` + +Useful when an external system gives you a string entity id (like `Nd3A2qlmFIOYa5UZpQdsL`) and you need the numeric id for `card query`, `transform run`, etc. + +### `setup` — initial setup wizard + +```bash +mb setup --file /path/to/setup-spec.json +``` + +Walks the `/api/setup` endpoint with a default user. **Don't run this against an instance the user already set up** — it errors out, and even successful runs are one-shot. Mostly useful for bootstrapping a fresh local instance (e2e harnesses). + +## Specialized skills (load on demand) + +This core file is enough for any single-command task. Specialized flows live in sibling skills, served by the same CLI. **Load the relevant skill proactively when the user's intent matches** — don't wing the workspace lifecycle, transform body, or git-sync workflow from this overview alone. + +| Load this skill | When the user's intent matches | +| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mb skills get workspace` | "spin up a workspace", "provision", "start a local Metabase against my prod", anything `mb workspace …`. **Mandatory** before running `workspace start` — it tells you to ask the user about Remote Sync (current dir / custom path / none) up front, since the bind mount can only be set at container create. | +| `mb skills get transform` | "create a transform", "run a transform", authoring transform body JSON, run inspection | +| `mb skills get git-sync` | "import the latest changes", "export to git", "git sync", "dirty check", "stash before pulling" | + +If a task spans more than one (e.g., "spin up `my_ws`, sync transforms from `main`, run them"), load each. Specialized skills assume you've internalized the general flag conventions above and won't repeat them. + +`mb skills list` enumerates everything available on the installed CLI version. + +## Don't + +- **Don't run `mb auth login` for the user.** Authentication is theirs — ask them to log in and tell you the profile name. The only legitimate exception is saving a freshly created workspace child's credentials (see the `workspace` skill); even there, pipe the key on stdin. +- Don't paste credentials, license tokens, or warehouse passwords in chat. Have the user run the storing command themselves. +- Don't put `--profile` before the verb chain — the CLI parses it as a top-level subcommand and errors out. +- Don't pass an API key with `--api-key "$KEY"`; pipe it on stdin via `--api-key-stdin`. (Comes up only in the workspace-child case.) +- Don't omit `--wait` on `workspace start` / `transform run` / `workspace database provision` for interactive flows; the next step will race the operation. +- Don't drop a JSON-envelope verb's output raw into another flag. Extract with `--json | jq -r '.'`. +- Don't add a third-party HTTP library or shell into `curl` workflows when a `mb ` exists — the CLI is the supported path; `curl` against `/api/...` bypasses retries, schema validation, and credential redaction. diff --git a/skill-data/git-sync/SKILL.md b/skill-data/git-sync/SKILL.md new file mode 100644 index 0000000..e5fcfd9 --- /dev/null +++ b/skill-data/git-sync/SKILL.md @@ -0,0 +1,196 @@ +--- +name: git-sync +description: Round-trip Metabase content (cards, dashboards, transforms, snippets, collections) between an instance and a git remote via `mb git-sync …` — status, dirty / has-remote-changes checks, import (with first-fresh-workspace exception), export (with branch guard + working-tree drift), branches, stash, add/remove a collection from sync. Load when the user wants to "import the latest changes", "export to git", "git sync", "dirty check", "stash before pulling", "add a collection to sync", or anything `mb git-sync …`. +allowed-tools: Read, Write, Edit, Bash, AskUserQuestion +--- + +# git-sync (representations ↔ instance) + +Metabase content (cards, dashboards, transforms, snippets, collections, …) can live in a git repo as YAML and round-trip in and out of a Metabase instance via the `git-sync` verbs. The instance is configured with a `remote-sync-*` settings block (URL, branch, token, type read-only/read-write); the CLI drives the sync tasks against `/api/ee/remote-sync/*`. + +This skill covers the import/export workflow. The general flag conventions and auth setup live in the `core` skill (`mb skills get core`). To author content YAML by hand, also load the `metabase-representation-format` skill — it covers the file-tree layout and per-resource YAML shape. + +## Adding / removing a directory (collection) to sync + +The set of directories under sync is governed by which **collections** carry `is_remote_synced: true`. Every collection so flagged serializes to its own folder under `collections/` in the repo; everything outside that set is local-only. The CLI exposes per-collection toggles that route to the underlying bulk endpoint (`PUT /api/ee/remote-sync/settings`): + +```bash +mb git-sync add-collection --profile --json +mb git-sync remove-collection --profile --json +``` + +`` is a **positive integer**. The bulk endpoint's schema is `pos-int? → boolean`; nano-id / `root` / `trash` refs (which `collection get` accepts) are not supported here. Get the id from `mb collection list --profile --json` first. + +Both verbs return `{ success: true, task_id?: }`. The optional `task_id` only appears when the toggle triggered a follow-up task (e.g., a finalization import after switching to read-only mode); for a normal add/remove in read-write, expect `{ success: true }` and nothing else. + +**Cascade.** A toggle on a parent cascades to every descendant by `location` prefix — `add-collection 4` flips `4` plus every collection nested under it. `remove-collection 4` is the symmetric inverse. There is no per-leaf-only mode. + +**Mode prerequisite.** The server rejects toggles while `remote-sync-type` is `:read-only` (the install default). If `mb git-sync add-collection 12` returns `Metabase returned 400 … Cannot change synced collections when remote-sync-type is read-only.`, switch first with: + +```bash +mb setting set remote-sync-type '"read-write"' --profile +``` + +(Mind the inner double quotes — `setting set` parses the value as strict JSON.) The server also rejects switching to `:read-only` while the Remote Sync collection is dirty; export or `--force` import first if you're going the other way. + +**Verifying the result.** The CLI's `Collection` schema doesn't yet expose `is_remote_synced`, so `collection get --json` won't show the flag. The pragmatic confirmation paths are: + +- `mb git-sync is-dirty --profile --json` after editing a card in the now-synced collection — a `true` reading proves it's tracked. +- The Metabase Admin UI's Remote Sync page renders the per-collection toggles. + +## Read state before mutating + +Always run `status` (or `is-dirty` + `has-remote-changes`) before `import` or `export`. Importing on a dirty instance silently rejects unless you pass `--force`; exporting when the instance is behind the remote pushes a stale state. + +```bash +mb git-sync status --profile --json # → branch, dirty, current task +mb git-sync is-dirty --profile --json # → {dirty: bool}; instance has unexported changes +mb git-sync has-remote-changes --profile --json # → {behind: bool}; remote has unimported commits +mb git-sync dirty --profile --json # → list the dirty objects +mb git-sync current-task --profile --json # → in-flight task (or idle) +``` + +**Clean up before exporting.** If you've created entities you intend to delete (a failed transform you're going to retry, a card you authored to test a body shape, a draft dashboard) — do the deletes _before_ the first `git-sync export`. Once committed, the cleanup needs a second commit, and the failed entity stays visible in `git log` forever. For the transform case specifically, prefer `transform update ` over `delete + create` so iteration never produces "broken-then-fixed" pairs in git history; see the `transform` skill, "Iterating on a failing transform". + +## Import (remote → instance) + +```bash +mb git-sync import --branch --profile +# Default flags: --wait, polling --interval 2000 --timeout 600000 +``` + +Pulls the configured branch and applies it to the instance. Polls until the task reaches a terminal state (`succeeded` / `failed`). + +| Flag | Purpose | +| ----------------- | ------------------------------------------------------------------------------------ | +| `--branch ` | Defaults to the `remote-sync-branch` setting; override per-call. | +| `--no-wait` | Return as soon as the task is queued; combine with `mb git-sync wait` later. | +| `--force` | **Discards local Metabase-side dirty changes** (lossy). Confirm with the user first. | +| `--timeout ` | Polling deadline. Default 600 000. | +| `--interval ` | Polling cadence. Default 2 000. | + +Workflow: + +1. `git-sync status` — confirm `dirty: false` (or `--force` is intended). +2. `git-sync has-remote-changes` — confirm there's actually something to import. +3. `git-sync import --branch ` — runs to terminal status by default. + +### First import on a fresh workspace + +After `workspace start --repo …` brings up a brand-new workspace, the repo content **must be applied** before any other work — without it the instance has none of the repo content and subsequent edits will diverge from what's on disk. + +The container runs a boot-time auto-import on first start, so in most cases the import has already completed by the time `workspace start --wait` returns. Check `git-sync status` first — if `current_task.sync_task_type == "import"` with `status == "successful"` and `.branch` matches the host's branch, you're done; skip the explicit call (it's a wasted round-trip). Only run the explicit `git-sync import` when the auto-import hasn't landed yet. + +When you do need the explicit import, the first one on a fresh instance can report `status: conflict` (typically `conflicts: ["Transforms"]`) even when nothing is dirty — the boot-time auto-import sometimes leaves a stale task record that the first explicit import collides with. Retry the same command once; the second call usually succeeds. If it keeps reporting conflict, `git-sync import --force` is safe in this specific case because the workspace is empty — there's no instance-side work for `--force` to discard. (This is a narrow exception to the usual "confirm with the user before `--force`" rule.) + +```bash +HOST_BRANCH=$(git -C symbolic-ref --short HEAD) +SYNC_STATUS=$(mb git-sync status --profile --json) +if ! echo "$SYNC_STATUS" | jq -e --arg b "$HOST_BRANCH" \ + '.current_task.sync_task_type == "import" and .current_task.status == "successful" and (.branch == $b)' >/dev/null; then + mb git-sync import --branch "$HOST_BRANCH" --profile --json \ + || mb git-sync import --branch "$HOST_BRANCH" --profile --json \ + || mb git-sync import --branch "$HOST_BRANCH" --force --profile --json +fi +``` + +## Export (instance → remote) + +```bash +mb git-sync export -m "commit message" --branch --profile +``` + +Pushes Metabase-side changes back to the configured remote. `-m` is the commit message; without it the server picks a default. Defaults to `--wait`. + +| Flag | Purpose | +| ------------------- | -------------------------------------------------------- | +| `--branch ` | Push to a specific branch instead of the configured one. | +| `-m, --message ` | Commit message. | +| `--force` | Force-push / overwrite remote. Confirm with the user. | +| `--no-wait` | Don't poll. | + +Workflow: + +1. **Branch guard** (below) — confirm the workspace isn't tracking `main`/`master`, or that the user has explicitly accepted exporting to it. +2. `git-sync is-dirty` — confirm there's something to export. +3. `git-sync export -m "..."` — pushes and polls. +4. (Optional) `git-sync status` — verify `dirty: false` after. +5. **Working-tree drift** (below) — if this is a `--repo` bind-mount workspace, the host repo's working tree + index will lag behind the new HEAD. Surface this and offer to realign. + +### Branch guard: don't export to main/master without confirmation + +Workspace work is conventionally done on a feature branch — exporting to `main` (or `master`) commits team-shared content directly. Before `git-sync export`, check the tracked branch and if it's `main`/`master`, ask the user whether to switch first. + +Reading the current branch: + +- For a `--repo` bind-mount workspace, `git -C symbolic-ref --short HEAD` is the most reliable read — that's what the workspace's `remote-sync-branch` was bound to at start time. +- Otherwise: `mb git-sync status --profile --json | jq -r '.branch'`. + +If the branch is `main` or `master`, prompt with `AskUserQuestion`: + +> "The workspace is tracking `` — exporting commits straight to it. Switch to a feature branch first?" +> +> 1. **Create a feature branch via the workspace** — agent suggests a name (e.g., `agent/`); run `mb git-sync create-branch --profile `. This exports current dirty state to the new branch and switches the workspace's tracked branch to it; subsequent `git-sync export` calls go to that branch. +> 2. **Switch the host's branch first (bind-mount workspaces)** — `git -C checkout -b ` on the host, then pass `--branch ` on the next `git-sync export` so the export targets the new branch (the workspace's `remote-sync-branch` setting won't auto-update from a host-side checkout). +> 3. **Proceed on `main`/`master`** — explicitly accepted; surface the resulting commit (`git -C log --oneline -1`) afterwards so the user can amend or revert. + +Skip the prompt only if the user's instructions already specified the branch (e.g., they explicitly said "export to main" or named a feature branch). Don't silently default to whatever `remote-sync-branch` happens to point at. + +### Post-export: working-tree drift on `--repo` bind-mount workspaces + +When the workspace exports against a host bind mount, the in-container serializer writes the new commit object directly into the bind-mounted `.git/` (creating tree/blob objects and advancing the branch ref) but **does not update the host's working tree or index**. After a successful export, the host repo state is: + +- HEAD: the new export commit. +- Index: still matches the _previous_ HEAD (whatever the user had staged before). +- Working tree: still matches the _previous_ HEAD. + +`git status` then shows "Changes to be committed" that look like the export's content reverting back — purely a display artifact, not an actual revert. The container does this on purpose to avoid clobbering work-in-progress on the host. **Realigning is _applying_ the new HEAD's content to your worktree, not discarding work** — the new commit was written by the exporter, not by your local edits, and your tree/index are stale relative to the new HEAD until you realign. + +**Surface this to the user** after an export against a `--repo` workspace — don't leave them staring at a confusing `git status`. Offer to realign. + +**Prefer `git restore` over `git reset --hard`.** When the only "changes" are the drift artifact (no real local edits), `git restore` does the same job and isn't classified as a destructive operation by Claude Code's permission system — `git reset --hard` is, and gets blocked even after a user-confirmation dialog: + +```bash +git -C restore --staged --worktree . # non-destructive; aligns index + working tree to HEAD +``` + +This is the right default after a `git-sync export` realignment when the user had nothing else staged. If `git status` shows a mix of drift artifacts and real pending work, fall back to the stash sequence: + +```bash +git -C stash --include-untracked +git -C restore --staged --worktree . +git -C stash pop +``` + +`git reset --hard HEAD` is the canonical equivalent and still valid — but **confirm with the user** before running it, and expect Claude Code to gate it as destructive even after the dialog. `git restore --staged --worktree .` produces the same end-state with less friction. + +Or pull in the new files selectively with `git -C checkout HEAD -- `. Quick check that this is what you're seeing: `git -C diff --cached HEAD~1 --stat` returns empty (the index matches the parent commit, not the new HEAD). + +## Branches + +```bash +mb git-sync branches --profile --json # list remote branches +mb git-sync create-branch --profile # create + switch sync to it +mb git-sync stash --profile # export current state to a NEW branch +``` + +`stash` is the safe move when the instance has team work you don't want to lose, but you need to pivot to a different branch (`import` would discard, `export --force` would overwrite). It exports current state to a fresh branch first. + +## Polling and cancelling + +```bash +mb git-sync wait --profile # block on the in-flight task +mb git-sync cancel-task --profile # cancel the in-flight task +``` + +Use `wait` after `import --no-wait` / `export --no-wait`. Use `cancel-task` if a git-sync task hangs and you want to abandon it. + +## Don't (git-sync-specific) + +- Don't run `git-sync import --force` or `git-sync export --force` without explicit user confirmation. Both are lossy — `--force` import discards instance-side work, `--force` export overwrites the remote branch. +- Don't drive `git-sync` against a Metabase instance that doesn't have remote-sync configured — every verb returns an error pointing at the missing `remote-sync-*` settings. To check: `mb setting get remote-sync-url --profile --json`. +- Don't author content directly via `card create` / `transform create` and then assume `git-sync export` will commit it cleanly — the instance and repo can drift if you mix direct API writes with sync-tracked changes. If you do, follow direct writes immediately with `git-sync export -m "..."` to keep them in step. +- Don't omit `-m` on `export` if the user wants a meaningful commit message — the default server-generated message is generic. +- Don't `git-sync export` to `main`/`master` without explicit user confirmation — workspace work is conventionally on a feature branch. See "Branch guard" above. +- Don't pretend the host's `git status` is clean after `git-sync export` against a `--repo` bind mount — the export advances HEAD but leaves the working tree + index behind. See "Working-tree drift" above. +- Don't reach for `mb setting set` to mark a collection as remote-synced — that endpoint writes single-key settings, not the bulk `collections` map. Use `mb git-sync add-collection ` / `mb git-sync remove-collection ` (see "Adding / removing a directory (collection) to sync" above), and remember the toggle cascades to descendants. diff --git a/skill-data/transform/SKILL.md b/skill-data/transform/SKILL.md new file mode 100644 index 0000000..193b9c8 --- /dev/null +++ b/skill-data/transform/SKILL.md @@ -0,0 +1,235 @@ +--- +name: transform +description: Author and run Metabase transforms via `mb` — body shape (native SQL + MBQL 5), create + run-with-wait, run inspection, cancel, the `update`-vs-recreate iteration rule, and the writable-keys-only PATCH contract. Load when the user touches transforms — "create a transform", "run a transform", "fix a failing transform", "list transform runs", "cancel a running transform", or anything `mb transform …`. +allowed-tools: Read, Write, Edit, Bash, AskUserQuestion +--- + +# Transforms + +A **transform** persists the result of a query (native SQL or MBQL) to a warehouse table the user can read from cards, dashboards, and other transforms. It runs on a schedule (via `transform-job`) or on-demand (`transform run`). + +This skill covers the create-and-run flow. The general flag conventions, body-input precedence, and output flags live in the `core` skill (`mb skills get core`). If you're authoring a transform inside a workspace, also load the `workspace` skill for the canonical-vs-isolation-schema rule. + +## Body shape + +A transform has two halves: + +- `source` — the query to run (`type: "query"`, with `query.type` of `native` or `mbql`). +- `target` — the warehouse destination (`type: "table"`, with `database`, `schema`, `name`). + +Native SQL is the simplest source and the easiest to author by hand. MBQL is what the Metabase UI emits and is much more verbose; pull a sample with `mb transform get --full --json` if you need its shape. + +If `source.query` is **MBQL 5** (`lib/type: "mbql/query"`), `transform create` and `transform update` validate it against the bundled query schema before sending; failure exits 2 with `{ ok, errors: [{path, message}] }` on stdout. To author MBQL 5 by hand: fetch the schema via `mb query --print-schema --profile `, iterate the body with `mb query --file q.json --dry-run --profile ` until `ok: true`, then drop it into `source.query`. Legacy MBQL 4 and native sources skip pre-flight. Pass `--skip-validate` to bypass the pre-flight and let the server be the authority — useful when the bundled schema disagrees with what the server actually accepts. + +**Mint UUIDs for `lib/uuid` slots before assembling the body — never invent, hard-code, or reuse them.** Every clause options object carries a `lib/uuid` (UUID v4); the bundled schema enforces RFC 4122 format strictly, so placeholder strings fail `--dry-run`. Workflow: count the slots, run `mb uuid --count --json`, substitute each minted value into its slot. The examples below use `` sentinels (NOT valid UUIDs) so the assembly step is unambiguous — replace each sentinel with a freshly-minted UUID before sending. Same `` token must be replaced with the same minted UUID (used for aggregation-ref ↔ aggregation pairing); distinct sentinels get distinct UUIDs. + +**Clause shape: opts always second, args after.** Every clause is `[op, {options}, ...args]`. Field refs are `["field", {options}, fieldId]` (id third), not the legacy MBQL 4 shape `["field", id, opts]`. The same rule holds for aggregations, filters, order-by — the options object never moves out of slot 1. + +## MBQL 5 aggregations: name your output columns + +Default MBQL 5 aggregations materialize as `count`, `count_where`, `count_where_2`, `avg`, `avg_2`, `sum`, … — ugly when the result is a transform target. Pass `name` and `display-name` in the aggregation's options object to control them. Mint 4 UUIDs (`mb uuid --count 4 --json`) for the slots below before assembling: + +```json +["count", + {"lib/uuid": "", "name": "shipments_shipped", "display-name": "Shipments shipped"}] + +["count-where", + {"lib/uuid": "", "name": "shipments_delivered", "display-name": "Shipments delivered"}, + ["=", {"lib/uuid": ""}, + ["field", {"base-type": "type/Text", "lib/uuid": ""}, 1779], + "delivered"]] +``` + +The `name` value becomes the warehouse column name on the materialized table. The `display-name` is the column header in the UI. + +## MBQL 5 order-by referencing an aggregation + +Order by an aggregation column with an `["aggregation", {…}, ""]` ref — the third arg is the **string UUID** of the target aggregation's `lib/uuid`, **not** its numeric position. The aggregation's own `lib/uuid` and the ref's third element must be the same minted UUID (string equality); the order-by clause itself and the ref clause each carry their own separate `lib/uuid` in their options. Mint 3 UUIDs and substitute — note that `` appears twice and gets the same minted value: + +```json +"aggregation": [ + ["count", {"lib/uuid": ""}] +], +"order-by": [ + ["desc", {"lib/uuid": ""}, + ["aggregation", {"lib/uuid": ""}, + ""]] +] +``` + +A numeric index (`["aggregation", {…}, 0]`) fails pre-flight with `must be the target aggregation's lib/uuid (string), not a numeric position` at `/stages/0/order-by/0/2/2`. + +## Create + run (native SQL) + +```bash +cat > /tmp/transform.json <<'EOF' +{ + "name": "user_counts_by_signup_year", + "description": "Sample transform: counts users by year of signup", + "source": { + "type": "query", + "query": { + "type": "native", + "database": , + "native": { + "query": "SELECT date_trunc('year', created_at)::date AS signup_year, COUNT(*)::int AS user_count FROM public.users GROUP BY 1 ORDER BY 1" + } + } + }, + "target": { + "type": "table", + "database": , + "schema": "public", + "name": "user_counts_by_signup_year" + } +} +EOF + +TRANSFORM_ID=$(mb transform create --file /tmp/transform.json --profile --json | jq -r '.id') +mb transform run "$TRANSFORM_ID" --wait --profile --json +``` + +Notes: + +- `` comes from `mb database list --profile --json`. Database ids are per-instance — a workspace child re-numbers them independently of the parent. +- Target `schema` is the **canonical** name (e.g. `public`). In a workspace, the QP rewrites it to the per-workspace isolation schema (`mb__isolation__`) at execution time — don't hard-code that prefix. +- `--wait` on `transform run` polls until status is `succeeded` or `failed`. Without it you only get `{message: "Transform run started", run_id, final: null}` and have to poll yourself. +- The `--json` envelope is shape-stable: `{message, run_id, final}`. `final` is always present — `null` when `--wait` is omitted or the run never started, otherwise a full `TransformRun` object with `status` and `message`. On a failed run (`final.status` ∈ {`failed`, `timeout`, `canceled`}) the CLI exits 1 and writes a one-line summary `transform run failed` to stderr; the failure detail lives only in `final.message` on stdout, so `jq -r '.final.message'` is where to look. +- The heredoc with single-quoted `'EOF'` prevents shell from interpolating any `$vars` inside the SQL. +- `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 the create output — no follow-up `transform get` needed to verify where the transform will write. +- 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" workflows, prefer `transform update ` — see "Iterating on a failing transform" below. + +## Inspect + +```bash +mb transform list --profile --json +mb transform get --profile --full --json # full transform incl. last run summary +``` + +After a run, the materialized table is queryable via `mb` (`card create` against it, native query against `.`, etc.). Columns and types are inferred from the result set; if you change the SELECT shape, drop the table first or the next run will fail on a column-mismatch error. + +## Inspect runs and cancel an in-flight run + +```bash +# Recent runs across all transforms (drains all pages by default; cap with --limit): +mb transform runs --profile --json +mb transform runs --transform-id --limit 10 --profile --json + +# Fetch one run by RUN id (NOT transform id — the run id comes from `transform run` or `transform runs`): +mb transform get-run --profile --json + +# Cancel the currently-running run for a transform: +mb transform cancel --profile --json +``` + +Notes: + +- `transform runs` and `transform get-run` parse against the same `TransformRun` schema, so `get-run` returns the same per-run shape as one entry of `runs`. The compact projection is `{id, transform_id, status, run_method, start_time, end_time, message}`. Pass `--full` on `get-run` for the hydrated row including `is_active`, `user_id`, `transform_name`, `transform_entity_id`, `checkpoint_*` fields, and a nested `transform: {id, name, …}` block. +- `transform cancel` takes the **transform** id and 404s with `Endpoint not found — is this a Metabase instance?` if there is no active run. The response shape is `{canceled: true, id: }`. +- For native-SQL transforms, cancel marks the run as `canceling` but does **not** kill the warehouse query mid-flight — the query runs to completion, then the run lands as `canceled` (or stays `succeeded` if the cancel arrived after the writer committed). For Python transforms the worker is interrupted directly. Don't expect cancel to free warehouse resources instantly on long native queries; expect it to flip state and prevent downstream consumers from treating the result as good. +- The `--transform-id` filter on `runs` accepts a single integer; the CLI translates to the server's `transform-ids` query vector. To cross-filter multiple transforms, run `transform runs --json` and `jq` post-hoc. + +## Update body: send only writable keys, never round-trip the GET body + +`transform update ` is **PATCH semantics** — only send the fields you actually want to change. The endpoint accepts exactly these writable keys: + +``` +name, description, source, target, run_trigger, +tag_ids, collection_id, owner_user_id, owner_email +``` + +**Don't paste the output of `transform get` into a `transform update` body.** The GET response carries server-side fields (`id`, `entity_id`, `created_at`, `updated_at`, `creator_id`, `last_run`, `target_db_id`, `target_table_id`, `source_type`, `source_database_id`, `source_readable`, `creator`, `owner`, `table`, …) that the PUT endpoint isn't built to handle. Currently, unknown top-level keys flow into `t2/update!` and produce a leaked H2 SQL error like: + +``` +Column "TAGS" not found; SQL statement: +UPDATE "TRANSFORM" SET "TAGS" = (), "UPDATED_AT" = NOW() WHERE "ID" = ? [42122-214] +``` + +Two specific footguns: + +- **`tags` is not a key on the REST API.** The serdes/YAML representation uses `tags`; the REST contract uses `tag_ids` (an array of integer ids). If you pulled a YAML representation and want to PUT it, translate `tags: [...]` → `tag_ids: [...]` first (or omit it entirely if you're not changing tag membership). +- **`source_type`, `target_db_id`, `target_table_id`, `entity_id`** are derived/computed by the server. They appear in GET responses for the agent's benefit; the server doesn't accept them on update. + +Right shape — patch only what changes: + +```bash +# Rename only: +mb transform update --body '{"name":"renamed"}' --profile --json + +# Rewrite the SQL only: +cat > /tmp/patch.json <<'EOF' +{ "source": { "type": "query", "query": { "type": "native", + "database": , + "native": { "query": "SELECT … FROM public.orders" } } } } +EOF +mb transform update --file /tmp/patch.json --profile --json + +# Change tag membership (note: tag_ids, not tags): +mb transform update --body '{"tag_ids":[1,3]}' --profile --json +``` + +If you really must round-trip, project to the writable subset: + +```bash +mb transform get --full --profile --json \ + | jq '{name, description, source, target, run_trigger, tag_ids, collection_id, owner_user_id, owner_email} + | with_entries(select(.value != null))' \ + > /tmp/patch.json +``` + +## Iterating on a failing transform + +When `transform run` fails and you want to retry with a fixed body, **prefer `transform update --file body.json` over `transform delete ` + `transform create`.** Update keeps the same row, the same `entity_id`, the same materialized table, and the same on-disk YAML filename. Concretely this means: + +- `git-sync export` produces **one** clean commit containing only the fix, instead of "broken transform" + "remove broken transform" landing as two commits in `git log`. +- You don't have to chase `_2` suffixes minted when two YAMLs share a `name` on disk (see the `transform create` notes above). +- The materialized output table either updates in place or, if the SELECT shape changed incompatibly, errors loudly on the next run rather than landing in a parallel `..._2` table the agent has to clean up. (`transform delete-table ` resets the column shape if you need a clean slate.) + +Recipe: + +```bash +# 1. Try once +ID=$(mb transform create --file /tmp/t.json --profile --json | jq -r '.id') +mb transform run "$ID" --wait --profile --json # → failed + +# 2. Fix the body in place; PATCH only what changed. +# Source-only patch — keeps name, target, tags untouched on the server. +cat > /tmp/source-patch.json <<'EOF' +{ "source": { "type": "query", "query": { "type": "native", + "database": , + "native": { "query": "" } } } } +EOF +mb transform update "$ID" --file /tmp/source-patch.json --profile --json + +# 3. Re-run +mb transform run "$ID" --wait --profile --json # → succeeded +``` + +If you really must `create + delete` instead, do the `delete` **before** the first `git-sync export` so the failed entity never lands in git history. Order matters: agents reflex to "export to checkpoint progress," but for transforms an export of a soft-failed state is mostly noise that needs a follow-up cleanup commit. See the `git-sync` skill, "Read state before mutating" for the ordering rule. + +## Drop the materialized table (keep the transform) + +```bash +mb transform delete-table --yes --profile +``` + +Useful when you've changed the SELECT and want a fresh `CREATE TABLE` on the next run. **`--yes` is required** in non-interactive contexts; without it the command exits with `--yes required to delete non-interactively`. + +## Delete the transform + +```bash +mb transform delete --yes --profile +``` + +Removes the definition. Whether the materialized table is dropped depends on the server — check with `mb table list --db-id --profile --json` if it matters. Same `--yes` rule as `delete-table`. + +## Transform jobs (schedules) + +A schedule lives in a separate resource (`transform-job`) and references one or more transform ids. Create with the same body-input pattern (`--file body.json`); see `mb transform-job --help` for the verb list. Most ad-hoc agent work is one-off `transform run`, not job authoring. + +## Don't (transform-specific) + +- Don't put `transform run` calls in tight polling loops — pass `--wait` and let the CLI handle the polling. Manual loops without `--wait` will hammer the server. +- Don't author MBQL 4 (the legacy nested `{ type: "query", query: {...} }` shape) by hand — pull a sample with `mb transform get --full --json`. MBQL 5 (`lib/type: "mbql/query"`) **is** authorable by hand thanks to the `mb query --print-schema` + `--dry-run` feedback loop; for non-trivial pipelines you may still prefer building in the UI and exporting. +- Don't write the workspace isolation schema into `target.schema` or SQL. See the `workspace` skill for the canonical-name rule. +- Don't paste a `transform get` body into `transform update` — the PUT endpoint only accepts writable keys, and unknown keys (notably `tags`, `source_type`, `entity_id`, `created_at`, `last_run`) leak as raw SQL errors. See "Update body: send only writable keys" above. Use `tag_ids` (not `tags`) on the REST contract. diff --git a/skill-data/workspace/SKILL.md b/skill-data/workspace/SKILL.md new file mode 100644 index 0000000..b3cfe99 --- /dev/null +++ b/skill-data/workspace/SKILL.md @@ -0,0 +1,408 @@ +--- +name: workspace +description: Enterprise workspace lifecycle for `mb` — create, provision databases, start (with Remote Sync wiring + branch guard), save child credentials as a profile, diagnose. Load when the user touches `mb workspace …` — "spin up a workspace", "provision a database", "start a local Metabase against my prod", "save the child's API key", "diagnose a workspace that won't start", or anything Enterprise workspaces. +allowed-tools: Read, Write, Edit, Bash, AskUserQuestion +--- + +# Workspaces (Enterprise) + +A **workspace** is a child Metabase instance bound to a parent's databases. Local lifecycle is `mb workspace `; the parent is reached via a profile (the parent's profile — typically `prod` / `staging`). Each provisioned database gets a per-workspace isolation schema on the warehouse, and the QP rewrites references from canonical names (`public.foo`) to that isolation schema (`mb__isolation__.foo`) on the fly. Cards, transforms, and queries authored in the workspace target canonical names; the rewrite is invisible to the author. + +This skill covers the full lifecycle. The general flag conventions, auth setup, and output flags live in the `core` skill; load that first (`mb skills get core`). + +## Always ask about Remote Sync before starting + +Before running `mb workspace start`, **ask the user how they want Remote Sync wired**. The bind mount is set at container-create time — you cannot add it later without a recreate, so this decision belongs at start time. Use `AskUserQuestion` with three options: + +> "How should I wire Remote Sync for this workspace?" +> +> 1. **Current directory** — bind-mount the directory you're running Claude from (`pwd`) as `file:///mnt/repo` and set the workspace to remote-sync against it (read-write). Pick this when the conversation is happening inside the sync repo. +> 2. **Custom path** — you specify a different host directory; same wiring as option 1. +> 3. **No sync** — start the workspace without a repo bind mount; you can configure remote-sync against a remote URL later via `setting set`. + +Default-suggest option 1 if the current working directory looks like a git repo (a `.git/` is present). Otherwise default-suggest option 3 and let the user volunteer a path. + +Map the answer to flags on `workspace start`: + +| Choice | Flags to add to `workspace start` | +| ----------------- | ----------------------------------------------------------------- | +| Current directory | `--repo "$(pwd)"` | +| Custom path | `--repo ` | +| No sync | (omit `--repo` — no bind mount, no remote-sync settings injected) | + +The `--repo` flag (a) bind-mounts the host path into the container at `/mnt/repo`, and (b) injects three settings into the workspace's config.yml at boot: `remote-sync-url=file:///mnt/repo`, `remote-sync-branch=`, `remote-sync-type=read-write`. The branch defaults to the current branch of the host repo (read via `git -C symbolic-ref --short HEAD`); override with `--repo-branch `. Switch to read-only with `--repo-mode read-only` (also makes the bind mount read-only). + +Do not skip this question — silently picking "no sync" loses the user's repo context, and silently picking "current directory" pushes work into a repo they didn't intend. + +## Branch guard before `--repo` + +When the user picks a `--repo` option (current dir or custom path), check the host's branch before `workspace start`. `--repo` reads `git -C symbolic-ref --short HEAD` and injects it as the workspace's `remote-sync-branch` setting; that branch then becomes the default target for every subsequent `git-sync import` and `git-sync export`. If the host is on `main` (or `master`), every export commits straight to it — usually not what the user wants for ephemeral workspace work. + +```bash +HOST_BRANCH=$(git -C symbolic-ref --short HEAD) +``` + +If `HOST_BRANCH` is `main` or `master`, ask the user via `AskUserQuestion`: + +> "The host repo is on `` — the workspace will track and export to that branch by default. Switch to a feature branch first?" +> +> 1. **Create + checkout a feature branch on the host** — agent suggests a name (e.g., `agent/`); run `git -C checkout -b ` then proceed with `workspace start --repo …` so the workspace tracks ``. +> 2. **Pin the workspace to a specific branch** — pass `--repo-branch ` on `workspace start` to override host HEAD. The branch must exist **locally** in the bind-mounted host repo before `workspace start` (create it first with `git -C branch ` or `git -C checkout -b `); it does **not** need to exist on `origin`. Local-only branches are fine — the workspace never pushes, and the remote side gets created on the user's first `git push` later. +> 3. **Proceed on `main`/`master`** — explicitly accepted; downstream `git-sync export` will commit to that branch unless overridden per-call. + +Skip this question only when the user's instructions already named the branch (e.g., they explicitly asked to work against `main`). The same guard applies later at `git-sync export` time — see the `git-sync` skill, "Branch guard". + +## Quick start (copy-pasteable, end-to-end) + +When a parent profile + license are in place, this whole sequence runs in one go. Replace the four shell vars; pick whether to bind-mount a sync repo with `REPO_FLAGS` per the question above. + +```bash +PARENT= # e.g. prod — the parent profile name +WS_NAME= # e.g. my_nice_ws — also reused as the child profile name +DB_ID= # parent database id from `mb database list --profile $PARENT --json` +SCHEMAS= # comma-separated; no "all" wildcard +REPO_FLAGS=(--repo "$(pwd)") # OR (--repo /path/to/sync-repo) OR () for no sync + +# 0. Branch guard (only when REPO_FLAGS is non-empty). If the host repo is on +# main/master, ask the user before continuing — see "Branch guard before --repo" +# above. Skip when REPO_FLAGS is () (no sync = no branch). +if [ ${#REPO_FLAGS[@]} -gt 0 ]; then + HOST_BRANCH=$(git -C "$(pwd)" symbolic-ref --short HEAD) + case "$HOST_BRANCH" in main|master) ;; # ask user; not auto-resolvable + esac +fi + +# 1. Create empty workspace, capture id +WS_ID=$(mb workspace create --name "$WS_NAME" --profile "$PARENT" --json | jq -r '.id') + +# 2. Provision a database into it (blocks on :provisioned) +mb workspace database provision "$WS_ID" \ + --database-id "$DB_ID" \ + --schemas "$SCHEMAS" \ + --wait \ + --profile "$PARENT" + +# 3. Start the child container, block on state=running. +# With REPO_FLAGS set, the child boots already wired to the local repo: +# bind-mounted at /mnt/repo, remote-sync-url=file:///mnt/repo, branch from HEAD. +mb workspace start "$WS_ID" --wait --profile "$PARENT" "${REPO_FLAGS[@]}" + +# 4. Save the child's API key as its own profile (use the workspace name as profile name). +# This is the documented exception to "the agent doesn't run auth login" — the child +# key was minted by the parent the human authorized, and reading it via +# `workspace credentials` is the supported path. +WS_URL=$(mb workspace url "$WS_ID" --profile "$PARENT" --json | jq -r '.url') +WS_API_KEY=$(mb workspace credentials "$WS_ID" --profile "$PARENT" --json | jq -r '.api_key') +printf '%s' "$WS_API_KEY" | mb auth login \ + --url "$WS_URL" \ + --api-key-stdin \ + --profile "$WS_NAME" \ + --json + +# 5. Smoke test: list child databases +mb database list --profile "$WS_NAME" --json + +# 6. (If REPO_FLAGS was set) Verify sync is wired: +mb setting get remote-sync-url --profile "$WS_NAME" --json # → "file:///mnt/repo" +mb git-sync status --profile "$WS_NAME" --json # → branch, dirty, current task + +# 7. (If REPO_FLAGS was set) Ensure the repo has been applied to the fresh workspace. +# The container's boot-time auto-import usually handles this on its own, so check +# `git-sync status` first — if `current_task` already shows a successful `import` for +# the current branch, skip the explicit call (it's a no-op round-trip). +# Only when the auto-import hasn't landed yet do you need an explicit import. +# The first explicit import on a fresh instance can spuriously report +# `status: conflict` (stale task state from the boot-time import); retry once, +# then `--force` is safe because the workspace is empty (nothing to lose). +# Skipping the import entirely is *not* safe — without it the instance has none +# of the repo content and subsequent edits will diverge. +HOST_BRANCH=$(git -C "$(pwd)" symbolic-ref --short HEAD) +SYNC_STATUS=$(mb git-sync status --profile "$WS_NAME" --json) +if ! echo "$SYNC_STATUS" | jq -e --arg b "$HOST_BRANCH" \ + '.current_task.sync_task_type == "import" and .current_task.status == "successful" and (.branch == $b)' >/dev/null; then + mb git-sync import --branch "$HOST_BRANCH" --profile "$WS_NAME" --json \ + || mb git-sync import --branch "$HOST_BRANCH" --profile "$WS_NAME" --json \ + || mb git-sync import --branch "$HOST_BRANCH" --force --profile "$WS_NAME" --json +fi +``` + +After step 5, drive the child via `mb --profile $WS_NAME` for everything (cards, transforms, queries, …). To author a transform on the workspace, load the `transform` skill (`mb skills get transform`). To use the sync flow (import host commits, export instance changes), load the `git-sync` skill (`mb skills get git-sync`). + +## Setup (steps in order) + +### 1. Parent profile + +```bash +mb auth status --profile --json +``` + +If a profile is missing or expired, **stop and ask the operator** to run, themselves: + +> Please run `mb auth login --url --profile ` from your terminal and tell me the profile name when you're done. + +Don't run `auth login` for them and don't suggest a URL — they pick. Verify with `mb auth status --profile --json` once they confirm. If multiple parent profiles exist and the user hasn't named one, use `AskUserQuestion` to disambiguate. + +### 2. License + +```bash +mb license status --profile --json +``` + +If `present: false`, ask the operator to run, themselves: + +```bash +echo "" | mb license set --profile +``` + +A workspace child cannot start without a parent license — it inherits feature gates from the parent. + +### 3. Find or create a workspace + +```bash +mb workspace list --profile --json +``` + +- Empty → create one (below). +- One workspace → use its `id`. Surface name + id to the user. +- Multiple → `AskUserQuestion`. + +Create: + +```bash +mb workspace create --name "" --profile --json +``` + +Note the returned `id`. The workspace is empty; you must provision at least one database before `start` will succeed. + +### 4. Provision databases + +A workspace needs at least one provisioned database. Source databases come from the parent. + +```bash +mb database list --profile --json +``` + +For each source database, decide which schemas to expose. Enumerate the schemas the parent already syncs for that database: + +```bash +mb table list --db-id --profile --json \ + | jq -r '[.data[].schema] | unique | .[]' +``` + +Provision (one db per call; `--schemas` is required, no "all" wildcard): + +```bash +mb workspace database provision \ + --database-id \ + --schemas , \ + --wait \ + --profile +``` + +`--wait` blocks until status is `provisioned`. Repeat per source database. + +Verify all are ready: + +```bash +mb workspace list --profile --full --json \ + | jq '.data[] | select(.id==) | .databases' +``` + +Every entry's `status` must be `provisioned`. + +## Start + +Before running `start`, ask the user about Remote Sync (see "Always ask about Remote Sync before starting" at the top of this file). The bind mount is decided at container-create time and cannot be added later without recreate. + +### Pick a free port up front + +Despite the `--port` flag's "auto-shifts up if taken" hint, in practice `workspace start` fails with `docker start failed for metabase-workspace-` when the host port is occupied — typically by a stale workspace container from a prior session. **List local containers first** and pass an explicit free `--port`: + +```bash +mb workspace ps --profile # → currently-running workspace containers + their host ports +docker ps --filter "name=metabase-workspace" \ + --format "{{.Names}}\t{{.Ports}}\t{{.Status}}" # also surfaces stopped containers +``` + +If 3000 is taken, pass e.g. `--port 3322`. The child's URL in `workspace credentials` and `workspace url` reflects the chosen port automatically. + +```bash +# No sync: +mb workspace start --wait --profile + +# With sync against the current directory: +mb workspace start --repo "$(pwd)" --wait --profile + +# With sync against a custom path, branch override, read-only: +mb workspace start --repo /path/to/repo --repo-branch dev --repo-mode read-only --wait --profile +``` + +`--wait` blocks until `state: "running"`. Don't omit it for interactive bring-up — without it the next step (saving credentials as a child profile) races the container's HTTP listener and you'll get spurious connection errors. + +| Flag | Purpose | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--port ` | Host port (default 3000; **does not** auto-shift reliably — pass an explicit free port if 3000 might be taken). | +| `--wait` | Block until `/api/health` reports ready before returning. | +| `--no-pull` | Skip `docker pull` (image already present). | +| `--no-metadata` | Skip the warehouse metadata export. | +| `--force` | Recreate even if a container for this workspace exists. Preserves the app db. | +| `--timeout ` | Per-phase readiness deadline (default 240000). Covers the post-create config-consumption wait, (with `--wait`) the `/api/health` probe, and (with `--metadata`) the metadata-import status poll on the child. Bump if the first cold boot exceeds the default — image pull + JVM startup can stretch on slow disks/networks. | +| `--repo ` | Bind-mount a host directory at `/mnt/repo` and inject `remote-sync-url=file:///mnt/repo` into config.yml. | +| `--repo-branch ` | `remote-sync-branch` value. Default: current branch of the host repo (`git symbolic-ref --short HEAD`). | +| `--repo-mode ` | `read-write` (default) or `read-only`. Also flips the bind mount's mount mode. | + +**Notes on `--repo`:** + +- `--repo` is honored only on container create. To change the mount on an existing container you must `start --force` (which recreates), passing `--repo` again. The app db volume persists, so users/sessions/saved questions survive. +- The host path must be a directory and must already exist. The CLI does not create or initialize a git repo for you. +- For `--repo-branch` auto-detection, the path needs to be a git repo (a `.git/` ancestor); otherwise pass `--repo-branch` explicitly. +- The `--repo-branch` value must name a branch that already exists **locally** in the host repo. Local-only branches (never pushed to `origin`) are fine — the workspace operates against the bind-mounted working tree, never pushes anywhere itself, and the remote side is created on the user's first `git push` later. If the branch doesn't exist locally yet, create it before `workspace start`: `git -C branch ` (or `checkout -b ` if you also want to switch HEAD). +- File-permission gotcha (Linux only): the Metabase container runs as uid 2000 by default; the host directory must be writable by that uid for `git-sync export` to succeed. macOS Docker Desktop / OrbStack / Colima handle this via their file-sharing layer. + +## Interact with a running workspace + +`url` and `credentials` both return JSON envelopes. Extract fields with `jq`: + +```bash +mb workspace url --profile --json +# → {"workspace_id": ..., "url": "http://localhost:3000"} + +mb workspace credentials --profile --json +# → {"email": ..., "password": ..., "api_key": ...} +``` + +Save the child's API key as its own named profile. **Always pipe the key on stdin** (the CLI rejects `--api-key "$VAR"`). + +```bash +WS_URL=$(mb workspace url --profile --json | jq -r '.url') +WS_API_KEY=$(mb workspace credentials --profile --json | jq -r '.api_key') +printf '%s' "$WS_API_KEY" | mb auth login \ + --url "$WS_URL" \ + --api-key-stdin \ + --profile \ + --json +``` + +Convention: use the workspace name as the profile name (`my_nice_ws` workspace → `my_nice_ws` profile). Then drive the child with the same CLI verbs: + +```bash +mb database list --profile --json +mb card list --profile --json +mb transform list --profile --json +``` + +To create and run a transform in the workspace, load the `transform` skill. The `` referenced there comes from `mb database list --profile --json` — the child re-numbers databases independently of the parent. + +## Open the UI + +``` +http://localhost: # default 3000; honors `--port` from `workspace start` +http://localhost:/admin/transforms/ +``` + +Log in with the **admin email + password** from `workspace credentials` (the API key authenticates as a synthetic api-key user, not as the admin — many UI screens hide content from the api-key user). + +**Don't open the URL before `state: "running"`** — the Metabase setup wizard will hijack it and create a fresh app db, bypassing the workspace bring-up. + +## Lifecycle + +| User intent | Command | +| --------------------------------- | -------------------------------------------------------------------- | +| List local workspace containers | `mb workspace ps --profile ` | +| Tail logs | `mb workspace logs --tail 200 --profile ` | +| Follow logs | `mb workspace logs --follow --profile ` | +| Read admin email/password/API key | `mb workspace credentials --profile --json` | +| Stop (preserves app db) | `mb workspace stop --profile ` | +| Restart | `mb workspace start --force --wait --profile ` | +| Remove container + app db | `mb workspace remove --yes --profile ` | +| Remove container, keep app db | `mb workspace remove --keep-volume --yes --profile ` | + +The supported restart path is `stop` + `start --force` (or `start --force` directly). The app db volume persists across `stop`/`start` cycles, so users/sessions/saved questions survive. `remove`, `start --force`, and `stop` are destructive enough to confirm before running unless the user explicitly asked for them. + +## Diagnose + +Pick the symptom. + +### `start` succeeds but the database isn't visible in the UI + +```bash +mb workspace logs --tail 300 --profile | grep -iE "advanced-config|workspace|error" +``` + +| Log signal | Cause | Fix | +| ---------------------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| `Spec assertion failed ... :input ... :output` | Parent emits keys the child's spec doesn't accept (server-side). | File against the parent. Not a CLI issue. | +| `Connection refused` / `unknown host` against the warehouse host | Container can't reach the source DB. | Source DB credentials configured on the parent use a host that doesn't resolve from inside docker. Use a routable hostname. | +| `Invalid token` / `License expired` | EE license bad or unset on the parent (forwarded into the child). | Re-set on the parent: `mb license set` (operator pastes). | + +### `workspace credentials` returns values that don't authenticate + +Symptom: right after `workspace start`, the API key returned by `mb workspace credentials ` is rejected by the child (`Unauthenticated` on `/api/user/current`, or `Invalid or unauthorized API key` from `mb auth login --skip-verify` followed by any verb). The admin password from the same response also fails (`did not match stored password`). The values inside the container's `/mw-config/credentials.json` match what the parent reports, but the child's app db has different state. + +This is a parent↔child credential drift bug — the parent's record for the workspace can desync from the child's app db, especially after a rapid `start` → `start --force` sequence on the same port. **`start --force` alone does not fix it** (the volume persists across the recreate; the api-key already exists from the prior init and the new credentials.json is ignored). + +Recovery (works reliably): + +```bash +mb workspace remove --yes --profile # destroys container + volume; keeps parent record + provisioned dbs +mb workspace start --port --wait --profile # different port from the bad attempt +mb workspace credentials --profile --json | jq -r '.api_key' \ + | xargs -I{} curl -s -H "x-api-key: {}" http://localhost:/api/user/current # smoke check +``` + +Why "different port": empirically, restarting on the same port after the drifted attempt can cling to the same broken state; switching ports forces a clean parent-side handoff. If you must reuse the original port, `workspace remove --yes` plus a brief pause (a few seconds) before `start` increases the success rate. + +`workspace remove --yes` is destructive — it drops the container _and_ the app db volume — but in the bring-up window (before any user content has been imported) there's nothing to lose. The provisioned-database records on the parent survive the remove and don't need to be re-created. + +### Container exited shortly after `start` + +```bash +mb workspace ps --profile +``` + +`Exited (137)` → OOM. Bump Docker host memory to ≥ 6 GB. + +- Colima: `colima stop && colima start --memory 6 --cpu 2` +- Docker Desktop: Settings → Resources → Memory. + +Then `mb workspace start --force --wait --profile `. + +### `Endpoint not found — is this a Metabase instance?` + +The parent doesn't expose `/api/ee/workspace-manager/*`. Either: + +- Parent is OSS (no EE). +- Parent has no license, or license lacks the workspace feature. +- Parent is on a Metabase version that predates workspaces. + +Confirm the URL points at the right instance with `mb auth status --profile --json`. If the URL is correct, the parent simply lacks the workspace feature — pick a different instance. + +### `workspace has no databases — provision at least one before starting` + +`mb workspace list --profile --full --json` will show the workspace with `databases: []`. Run a `provision` (step 4) and retry. + +### `workspace ... is not ready: database X=provisioning` + +Provisioning is async on the parent. Re-run the original `provision` with `--wait`, or poll: + +```bash +mb workspace list --profile --full --json \ + | jq '.data[] | select(.id==) | .databases[] | {database_id, status}' +``` + +### Workspace UI demands the setup wizard + +You opened the URL before health passed and walked through the wizard, which created a fresh app db and bypassed the workspace bring-up. `mb workspace remove --yes --profile ` then `start --wait` again. Don't open the URL before `state: "running"`. + +### `git status` on the host shows confusing "staged changes" after `git-sync export` + +The in-container exporter writes the new commit object directly into the bind-mounted `.git/` and advances HEAD, but does not update the host's working tree or index. The host then shows the export's content as "Changes to be committed" reverting to the prior commit — display artifact, not a real revert. The non-destructive realignment is `git -C restore --staged --worktree .` (only touches paths that disagree with HEAD; refuses on unmerged paths; does not move HEAD). See the `git-sync` skill, "Working-tree drift on `--repo` bind-mount workspaces" for the full decision tree (when to stash first, when `reset --hard` is acceptable). + +## Don't (workspace-specific) + +- Don't run raw `docker` commands against the workspace container — use the `mb workspace` subcommands. They wrap the right labels, volumes, network, and lifecycle hooks. +- Don't open the workspace URL before `state: "running"` — the setup wizard will hijack it. +- Don't try to share an API key across workspaces — each child mints its own. Save credentials per-workspace under a profile named after the workspace. +- Don't write the workspace's isolation schema (`mb__isolation__`) into transform/card SQL or `target.schema`. Author against the **canonical** schema (e.g. `public`); the QP rewrites at execution time. Hard-coding the isolation prefix breaks portability across workspaces and bypasses the rewrite contract. +- Don't run `workspace start` without first asking the user about Remote Sync (current dir / custom path / no sync). The bind mount is set at create time; "I'll add it after start" is not supported. +- Don't run `workspace start --repo ` when the host repo is on `main`/`master` without first asking the user (see "Branch guard before `--repo`"). The host's HEAD becomes the workspace's `remote-sync-branch`, so every subsequent export targets `main` by default. diff --git a/skills/metabase-cli/SKILL.md b/skills/metabase-cli/SKILL.md new file mode 100644 index 0000000..4af3380 --- /dev/null +++ b/skills/metabase-cli/SKILL.md @@ -0,0 +1,42 @@ +--- +name: metabase-cli +description: Drive a Metabase instance from the terminal via the `mb` CLI. Authenticate with named profiles; inspect databases (list, get, full metadata rollup, schemas, tables in a schema) and trigger manual schema sync / field-values rescan; inspect tables, fields; list/get/create/update/archive cards (questions, models, metrics) and run them as JSON/CSV/XLSX; list/get/create/update dashboards and patch dashcards; list/get/create collections and traverse the hierarchy by id, entity_id, or "root"/"trash" (with items and recursive tree); list/get/create/update/archive native query snippets, segments, and measures; author/update/run transforms and schedule transform-jobs; read/update settings; search content (cards, dashboards, collections, transforms, metrics); manage Enterprise workspaces; git-sync to/from a git remote (status, dirty, import, export, branches, stash, add/remove a collection from sync). Use whenever the user wants to interact with a Metabase from the terminal — "log into metabase", "what profiles do I have", "list cards", "run card 42 as CSV", "create a transform", "list dashboards", "move a dashcard", "list collections", "what's in collection 4", "show the collection tree", "list snippets", "create a segment", "archive a measure", "search metabase for X", "spin up a workspace", "import the latest changes", "add a directory to git sync", "set a setting", "what schemas are in this database", "trigger a sync", "rescan field values", or anything hitting `mb `. +allowed-tools: Bash(mb:*), Bash(npx mb:*), Read, Write, Edit, AskUserQuestion +hidden: true +--- + +# metabase-cli + +The official Metabase CLI (`mb`) drives a Metabase instance over its REST API. + +Install: `npm i -g @metabase/cli` + +## Start here + +This file is a discovery stub, not the usage guide. Before running any `mb` command, load the actual workflow content from the CLI: + +```bash +mb skills get core # start here — auth, flag conventions, every command group +mb skills get core --full # include all references for the deep dive +``` + +The CLI serves skill content bundled with the installed version, so instructions never go stale. The content in this stub cannot change between releases, which is why it just points at `mb skills get core`. + +## Specialized skills + +Load a specialized skill when the task falls outside one-shot CLI use: + +```bash +mb skills get workspace # Enterprise workspaces: create, provision, start, child credentials, diagnose +mb skills get transform # author + run transforms (native SQL and MBQL 5), iterate on failures +mb skills get git-sync # round-trip Metabase content to/from a git remote +``` + +Run `mb skills list` to see everything available on the installed version. + +## Why mb + +- Native `fetch`, typed Zod schemas, redacted secrets — the supported path for Metabase REST automation. +- One `--profile` per command targets staging, prod, a workspace child, whatever the user has configured. +- Output is shaped for agents: compact projection by default, `--full` / `--fields a,b.c` / `--json` / `--max-bytes` on every list/get. +- `mb __manifest` returns the canonical, machine-readable inventory of every command — name, args, output schema. Use it instead of scraping `--help`. diff --git a/src/commands/skills/get.ts b/src/commands/skills/get.ts new file mode 100644 index 0000000..610a4ed --- /dev/null +++ b/src/commands/skills/get.ts @@ -0,0 +1,102 @@ +import { ConfigError } from "../../core/errors"; +import { + loadAllSkills, + loadVisibleSkills, + readSkillContent, + selectSkillsByNames, + SkillContent, + type SkillInfo, +} from "../../core/skills"; +import type { ResourceView } from "../../domain/view"; +import { renderList, writeText } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { parseCsv } from "../../runtime/csv"; +import { outputFlags } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const SkillGetEnvelope = listEnvelopeSchema(SkillContent); + +const skillContentView: ResourceView = { + compactPick: SkillContent, + tableColumns: [ + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { + name: "get", + description: + "Print one or more skills' SKILL.md content. Pass comma-separated names, or --all for every non-hidden skill. --full includes references and templates.", + }, + args: { + ...outputFlags, + names: { + type: "positional", + description: "Skill name (or comma-separated list). Omit when using --all.", + required: false, + }, + all: { + type: "boolean", + description: "Fetch every non-hidden skill", + }, + }, + outputSchema: SkillGetEnvelope, + examples: [ + "mb skills get core", + "mb skills get core --full", + "mb skills get workspace,transform --json", + "mb skills get --all --json", + ], + run({ args, ctx }) { + const selected = pickSkills({ names: args.names, all: args.all === true }); + const payloads = selected.map((info) => readSkillContent(info, { includeExtras: ctx.full })); + + if (ctx.format === "json") { + renderList(wrapList(payloads), skillContentView, ctx); + return; + } + writeText(renderText(payloads, ctx.full)); + }, +}); + +interface PickSkillsArgs { + names: string | undefined; + all: boolean; +} + +function pickSkills({ names, all }: PickSkillsArgs): SkillInfo[] { + if (all && names !== undefined) { + throw new ConfigError("--all conflicts with a positional skill name"); + } + if (all) { + return loadVisibleSkills(); + } + if (names === undefined) { + throw new ConfigError("provide a skill name (comma-separated for multiple) or --all"); + } + return selectSkillsByNames(loadAllSkills(), parseCsv(names)); +} + +function renderText(payloads: readonly SkillContent[], includeExtras: boolean): string { + return payloads.map((payload) => renderTextSection(payload, includeExtras)).join("\n\n"); +} + +function renderTextSection(payload: SkillContent, includeExtras: boolean): string { + const parts = [payload.body.trimEnd()]; + if (!includeExtras) { + return parts.join("\n\n"); + } + for (const ref of payload.references) { + parts.push(extraFileHeader(payload.name, ref.path), ref.content.trimEnd()); + } + for (const tpl of payload.templates) { + parts.push(extraFileHeader(payload.name, tpl.path), tpl.content.trimEnd()); + } + return parts.join("\n\n"); +} + +function extraFileHeader(skillName: string, relPath: string): string { + return `=== ${skillName}/${relPath} ===`; +} diff --git a/src/commands/skills/index.ts b/src/commands/skills/index.ts new file mode 100644 index 0000000..25c1da7 --- /dev/null +++ b/src/commands/skills/index.ts @@ -0,0 +1,15 @@ +import { defineCommand } from "citty"; + +export default defineCommand({ + meta: { + name: "skills", + description: + "Discover and read CLI-bundled skills (SKILL.md files served from the installed version)", + }, + default: "list", + subCommands: { + list: () => import("./list").then((m) => m.default), + get: () => import("./get").then((m) => m.default), + path: () => import("./path").then((m) => m.default), + }, +}); diff --git a/src/commands/skills/list.ts b/src/commands/skills/list.ts new file mode 100644 index 0000000..734805c --- /dev/null +++ b/src/commands/skills/list.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +import { loadVisibleSkills } from "../../core/skills"; +import type { ResourceView } from "../../domain/view"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { outputFlags } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const SkillSummary = z.object({ + name: z.string(), + description: z.string(), +}); +export type SkillSummaryJson = z.infer; + +export const SkillListEnvelope = listEnvelopeSchema(SkillSummary); + +const skillSummaryView: ResourceView = { + compactPick: SkillSummary, + tableColumns: [ + { key: "name", label: "Name" }, + { key: "description", label: "Description" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { + name: "list", + description: + "List skills bundled with this CLI. Hidden discovery stubs are omitted from the default listing.", + }, + args: { ...outputFlags }, + outputSchema: SkillListEnvelope, + examples: ["mb skills list", "mb skills list --json"], + run({ ctx }) { + const items: SkillSummaryJson[] = loadVisibleSkills().map((s) => ({ + name: s.name, + description: s.description, + })); + renderList(wrapList(items), skillSummaryView, ctx); + }, +}); diff --git a/src/commands/skills/path.ts b/src/commands/skills/path.ts new file mode 100644 index 0000000..32cad03 --- /dev/null +++ b/src/commands/skills/path.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { findSkillByName, loadAllSkills, loadVisibleSkills } from "../../core/skills"; +import type { ResourceView } from "../../domain/view"; +import { renderList } from "../../output/render"; +import { listEnvelopeSchema, wrapList } from "../../output/types"; +import { outputFlags } from "../flags"; +import { defineMetabaseCommand } from "../runtime"; + +export const SkillPath = z.object({ + name: z.string(), + dir: z.string(), +}); +export type SkillPathJson = z.infer; + +export const SkillPathListEnvelope = listEnvelopeSchema(SkillPath); + +const skillPathView: ResourceView = { + compactPick: SkillPath, + tableColumns: [ + { key: "name", label: "Name" }, + { key: "dir", label: "Path" }, + ], +}; + +export default defineMetabaseCommand({ + meta: { + name: "path", + description: + "Print the absolute path to a skill (or all skills). Useful when an agent needs to read the SKILL.md or its references with the Read tool directly.", + }, + args: { + ...outputFlags, + name: { + type: "positional", + description: "Skill name (omit to list every non-hidden skill)", + required: false, + }, + }, + outputSchema: SkillPathListEnvelope, + examples: ["mb skills path", "mb skills path core", "mb skills path core --json"], + run({ args, ctx }) { + const items = + args.name === undefined + ? loadVisibleSkills().map(toSkillPath) + : [toSkillPath(findSkillByName(loadAllSkills(), args.name))]; + renderList(wrapList(items), skillPathView, ctx); + }, +}); + +function toSkillPath(info: { name: string; dir: string }): SkillPathJson { + return { name: info.name, dir: info.dir }; +} diff --git a/src/core/skills.test.ts b/src/core/skills.test.ts new file mode 100644 index 0000000..e971a50 --- /dev/null +++ b/src/core/skills.test.ts @@ -0,0 +1,336 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ConfigError } from "./errors"; +import { + availableSkillNames, + discoverSkills, + findSkillByName, + parseFrontmatter, + readSkillContent, + resolveSkillDirs, + selectSkillsByNames, + SKILLS_DIR_ENV, + type SkillInfo, +} from "./skills"; + +describe("parseFrontmatter", () => { + it("parses a minimal frontmatter block", () => { + expect( + parseFrontmatter("---\nname: test-skill\ndescription: A test skill.\n---\n\nBody."), + ).toEqual({ name: "test-skill", description: "A test skill.", hidden: false }); + }); + + it("returns null when there is no frontmatter delimiter", () => { + expect(parseFrontmatter("# A skill\n\nNo frontmatter here.")).toBeNull(); + }); + + it("returns null when the frontmatter is unterminated", () => { + expect(parseFrontmatter("---\nname: foo\ndescription: bar\n")).toBeNull(); + }); + + it("returns null when name is missing or empty", () => { + expect(parseFrontmatter("---\ndescription: no name\n---\n")).toBeNull(); + expect(parseFrontmatter("---\nname:\ndescription: blank name\n---\n")).toBeNull(); + }); + + it("joins multi-line YAML description continuations", () => { + expect( + parseFrontmatter( + "---\nname: multi\ndescription: First sentence.\n Second line.\n Third line.\n---\n", + ), + ).toEqual({ + name: "multi", + description: "First sentence. Second line. Third line.", + hidden: false, + }); + }); + + it("parses hidden: true as hidden, missing or false as visible", () => { + expect(parseFrontmatter("---\nname: a\ndescription: x\nhidden: true\n---\n")).toEqual({ + name: "a", + description: "x", + hidden: true, + }); + expect(parseFrontmatter("---\nname: b\ndescription: x\nhidden: false\n---\n")).toEqual({ + name: "b", + description: "x", + hidden: false, + }); + expect(parseFrontmatter("---\nname: c\ndescription: x\n---\n")).toEqual({ + name: "c", + description: "x", + hidden: false, + }); + }); + + it("returns null on malformed YAML", () => { + expect(parseFrontmatter("---\n:::not yaml\n---\n")).toBeNull(); + }); +}); + +interface TempDirs { + root: string; + skills: string; + skillData: string; +} + +function makeSkillsRoot(): TempDirs { + const root = mkdtempSync(join(tmpdir(), "mb-skills-")); + const skills = join(root, "skills"); + const skillData = join(root, "skill-data"); + mkdirSync(skills); + mkdirSync(skillData); + return { root, skills, skillData }; +} + +interface WriteSkillFrontmatter { + name: string; + description: string; + hidden?: boolean; +} + +function writeSkill( + parent: string, + dirName: string, + frontmatter: WriteSkillFrontmatter, + body: string, +): string { + const dir = join(parent, dirName); + mkdirSync(dir, { recursive: true }); + const lines = ["---", `name: ${frontmatter.name}`, `description: ${frontmatter.description}`]; + if (frontmatter.hidden === true) { + lines.push("hidden: true"); + } + lines.push("---", "", body); + writeFileSync(join(dir, "SKILL.md"), lines.join("\n"), "utf8"); + return dir; +} + +describe("discoverSkills", () => { + let temp: TempDirs; + + beforeEach(() => { + temp = makeSkillsRoot(); + }); + + afterEach(() => { + rmSync(temp.root, { recursive: true, force: true }); + }); + + it("discovers skills from every directory and sorts by name", () => { + writeSkill( + temp.skills, + "metabase-cli", + { name: "metabase-cli", description: "Stub.", hidden: true }, + "stub body", + ); + writeSkill(temp.skillData, "core", { name: "core", description: "Core." }, "core body"); + writeSkill( + temp.skillData, + "workspace", + { name: "workspace", description: "Workspaces." }, + "ws body", + ); + + expect(discoverSkills([temp.skills, temp.skillData])).toEqual([ + { name: "core", description: "Core.", hidden: false, dir: join(temp.skillData, "core") }, + { + name: "metabase-cli", + description: "Stub.", + hidden: true, + dir: join(temp.skills, "metabase-cli"), + }, + { + name: "workspace", + description: "Workspaces.", + hidden: false, + dir: join(temp.skillData, "workspace"), + }, + ]); + }); + + it("skips directories without a SKILL.md and directories whose SKILL.md has no frontmatter", () => { + mkdirSync(join(temp.skillData, "empty")); + const noFmDir = join(temp.skillData, "no-frontmatter"); + mkdirSync(noFmDir); + writeFileSync(join(noFmDir, "SKILL.md"), "# Plain markdown, no YAML.\n", "utf8"); + writeSkill(temp.skillData, "real", { name: "real", description: "Real." }, "real body"); + + expect(discoverSkills([temp.skillData])).toEqual([ + { name: "real", description: "Real.", hidden: false, dir: join(temp.skillData, "real") }, + ]); + }); + + it("returns an empty list when no skill directories exist", () => { + expect(discoverSkills([join(temp.root, "missing-1"), join(temp.root, "missing-2")])).toEqual( + [], + ); + }); +}); + +describe("readSkillContent", () => { + let temp: TempDirs; + + beforeEach(() => { + temp = makeSkillsRoot(); + }); + + afterEach(() => { + rmSync(temp.root, { recursive: true, force: true }); + }); + + it("returns body + references + templates when includeExtras is true", () => { + const skillDir = writeSkill( + temp.skillData, + "core", + { name: "core", description: "Core skill." }, + "main body content", + ); + mkdirSync(join(skillDir, "references")); + writeFileSync(join(skillDir, "references", "b.md"), "ref b", "utf8"); + writeFileSync(join(skillDir, "references", "a.md"), "ref a", "utf8"); + mkdirSync(join(skillDir, "templates")); + writeFileSync(join(skillDir, "templates", "template.json"), '{"x":1}', "utf8"); + + const info: SkillInfo = { + name: "core", + description: "Core skill.", + hidden: false, + dir: skillDir, + }; + + expect(readSkillContent(info, { includeExtras: true })).toEqual({ + name: "core", + description: "Core skill.", + body: "---\nname: core\ndescription: Core skill.\n---\n\nmain body content", + references: [ + { path: "references/a.md", content: "ref a" }, + { path: "references/b.md", content: "ref b" }, + ], + templates: [{ path: "templates/template.json", content: '{"x":1}' }], + }); + }); + + it("omits references and templates when includeExtras is false, even if files exist on disk", () => { + const skillDir = writeSkill( + temp.skillData, + "core", + { name: "core", description: "Core skill." }, + "body", + ); + mkdirSync(join(skillDir, "references")); + writeFileSync(join(skillDir, "references", "a.md"), "ref a", "utf8"); + + const info: SkillInfo = { + name: "core", + description: "Core skill.", + hidden: false, + dir: skillDir, + }; + + expect(readSkillContent(info, { includeExtras: false })).toEqual({ + name: "core", + description: "Core skill.", + body: "---\nname: core\ndescription: Core skill.\n---\n\nbody", + references: [], + templates: [], + }); + }); +}); + +describe("availableSkillNames", () => { + it('formats the visible skill list as "available: a, b"', () => { + const skills: SkillInfo[] = [ + { name: "core", description: "", hidden: false, dir: "/x/core" }, + { name: "workspace", description: "", hidden: false, dir: "/x/workspace" }, + { name: "metabase-cli", description: "", hidden: true, dir: "/x/metabase-cli" }, + ]; + expect(availableSkillNames(skills)).toBe("available: core, workspace"); + }); + + it('falls back to "available: none" when no visible skills exist', () => { + expect(availableSkillNames([])).toBe("available: none"); + expect( + availableSkillNames([{ name: "stub", description: "", hidden: true, dir: "/x/stub" }]), + ).toBe("available: none"); + }); +}); + +describe("findSkillByName", () => { + const skills: SkillInfo[] = [ + { name: "core", description: "Core.", hidden: false, dir: "/x/core" }, + { name: "workspace", description: "Workspaces.", hidden: false, dir: "/x/workspace" }, + ]; + + it("returns the matching skill", () => { + expect(findSkillByName(skills, "core")).toEqual(skills[0]); + }); + + it("throws ConfigError with the available list when the name is unknown", () => { + expect(() => findSkillByName(skills, "nope")).toThrow( + new ConfigError("unknown skill name: nope (available: core, workspace)"), + ); + }); +}); + +describe("selectSkillsByNames", () => { + const skills: SkillInfo[] = [ + { name: "core", description: "", hidden: false, dir: "/x/core" }, + { name: "workspace", description: "", hidden: false, dir: "/x/workspace" }, + { name: "transform", description: "", hidden: false, dir: "/x/transform" }, + ]; + + it("returns selected skills in the requested order", () => { + expect(selectSkillsByNames(skills, ["workspace", "core"])).toEqual([skills[1], skills[0]]); + }); + + it("throws ConfigError listing missing names and the available set", () => { + expect(() => selectSkillsByNames(skills, ["core", "nope", "also-missing"])).toThrow( + new ConfigError( + "unknown skill name(s): nope, also-missing (available: core, workspace, transform)", + ), + ); + }); + + it("throws ConfigError when no names are requested", () => { + expect(() => selectSkillsByNames(skills, [])).toThrow( + new ConfigError("no skill names provided"), + ); + }); +}); + +describe("resolveSkillDirs", () => { + let temp: TempDirs; + const originalEnv = process.env[SKILLS_DIR_ENV]; + + beforeEach(() => { + temp = makeSkillsRoot(); + }); + + afterEach(() => { + rmSync(temp.root, { recursive: true, force: true }); + if (originalEnv === undefined) { + delete process.env[SKILLS_DIR_ENV]; + } else { + process.env[SKILLS_DIR_ENV] = originalEnv; + } + }); + + it("returns the env-var override directory when set", () => { + process.env[SKILLS_DIR_ENV] = temp.skillData; + expect(resolveSkillDirs()).toEqual([temp.skillData]); + }); + + it("throws ConfigError when the env-var override is not a directory", () => { + process.env[SKILLS_DIR_ENV] = join(temp.root, "does-not-exist"); + expect(() => resolveSkillDirs()).toThrow( + new ConfigError( + `${SKILLS_DIR_ENV} points at ${join(temp.root, "does-not-exist")}, which is not a directory`, + ), + ); + }); +}); diff --git a/src/core/skills.ts b/src/core/skills.ts new file mode 100644 index 0000000..2ba2128 --- /dev/null +++ b/src/core/skills.ts @@ -0,0 +1,261 @@ +import { closeSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { z } from "zod"; + +import { parseYamlResult } from "../runtime/yaml"; + +import { ConfigError, isNotFoundError } from "./errors"; + +export const Frontmatter = z + .object({ + name: z.string().min(1), + description: z.string().default(""), + hidden: z.boolean().default(false), + }) + .loose(); +export type Frontmatter = z.infer; + +export const SkillExtraFile = z.object({ + path: z.string(), + content: z.string(), +}); +export type SkillExtraFile = z.infer; + +export const SkillContent = z.object({ + name: z.string(), + description: z.string(), + body: z.string(), + references: z.array(SkillExtraFile), + templates: z.array(SkillExtraFile), +}); +export type SkillContent = z.infer; + +export interface SkillInfo { + name: string; + description: string; + hidden: boolean; + dir: string; +} + +export interface ReadSkillContentOptions { + includeExtras: boolean; +} + +export const SKILL_DIR_NAMES = ["skills", "skill-data"] as const; +export const SKILL_MD_FILENAME = "SKILL.md"; +export const SKILL_REFERENCES_DIR = "references"; +export const SKILL_TEMPLATES_DIR = "templates"; +export const SKILLS_DIR_ENV = "MB_SKILLS_DIR"; + +const FRONTMATTER_PREFIX_BYTES = 8192; +const FRONTMATTER_FENCE = "---"; + +export function loadAllSkills(): SkillInfo[] { + return discoverSkills(resolveSkillDirs()); +} + +export function loadVisibleSkills(): SkillInfo[] { + return loadAllSkills().filter((s) => !s.hidden); +} + +export function findSkillByName(all: readonly SkillInfo[], name: string): SkillInfo { + const hit = all.find((s) => s.name === name); + if (hit === undefined) { + throw new ConfigError(`unknown skill name: ${name} (${availableSkillNames(all)})`); + } + return hit; +} + +export function selectSkillsByNames( + all: readonly SkillInfo[], + requested: readonly string[], +): SkillInfo[] { + if (requested.length === 0) { + throw new ConfigError("no skill names provided"); + } + const byName = new Map(all.map((s) => [s.name, s])); + const missing: string[] = []; + const found: SkillInfo[] = []; + for (const name of requested) { + const hit = byName.get(name); + if (hit === undefined) { + missing.push(name); + continue; + } + found.push(hit); + } + if (missing.length > 0) { + throw new ConfigError( + `unknown skill name(s): ${missing.join(", ")} (${availableSkillNames(all)})`, + ); + } + return found; +} + +export function availableSkillNames(all: readonly SkillInfo[]): string { + const names = all.filter((s) => !s.hidden).map((s) => s.name); + return `available: ${names.length === 0 ? "none" : names.join(", ")}`; +} + +export function resolveSkillDirs(): string[] { + const override = process.env[SKILLS_DIR_ENV]; + if (override !== undefined && override !== "") { + if (!isDirectory(override)) { + throw new ConfigError(`${SKILLS_DIR_ENV} points at ${override}, which is not a directory`); + } + return [resolve(override)]; + } + const root = findPackageRoot(); + if (root === null) { + return []; + } + return SKILL_DIR_NAMES.map((name) => join(root, name)).filter(isDirectory); +} + +function findPackageRoot(): string | null { + const here = fileURLToPath(import.meta.url); + let dir = dirname(here); + while (true) { + for (const name of SKILL_DIR_NAMES) { + if (isDirectory(join(dir, name))) { + return dir; + } + } + const parent = dirname(dir); + if (parent === dir) { + return null; + } + dir = parent; + } +} + +export function discoverSkills(dirs: readonly string[]): SkillInfo[] { + const skills: SkillInfo[] = []; + for (const dir of dirs) { + let entries: string[]; + try { + entries = readdirSync(dir); + } catch (error) { + if (isNotFoundError(error)) { + continue; + } + throw error; + } + for (const entryName of entries) { + const skillDir = join(dir, entryName); + const fm = readFrontmatterFromSkill(skillDir); + if (fm === null) { + continue; + } + skills.push({ name: fm.name, description: fm.description, hidden: fm.hidden, dir: skillDir }); + } + } + skills.sort((a, b) => a.name.localeCompare(b.name)); + return skills; +} + +function readFrontmatterFromSkill(skillDir: string): Frontmatter | null { + const skillMd = join(skillDir, SKILL_MD_FILENAME); + const prefix = readFilePrefix(skillMd, FRONTMATTER_PREFIX_BYTES); + if (prefix === null) { + return null; + } + return parseFrontmatter(prefix); +} + +function readFilePrefix(path: string, maxBytes: number): string | null { + let fd: number; + try { + fd = openSync(path, "r"); + } catch (error) { + if (isNotFoundError(error)) { + return null; + } + throw error; + } + try { + const buffer = Buffer.alloc(maxBytes); + const bytesRead = readSync(fd, buffer, 0, maxBytes, 0); + return buffer.toString("utf8", 0, bytesRead); + } finally { + closeSync(fd); + } +} + +export function parseFrontmatter(content: string): Frontmatter | null { + const trimmed = content.trimStart(); + if (!trimmed.startsWith(FRONTMATTER_FENCE)) { + return null; + } + const afterOpening = trimmed.slice(FRONTMATTER_FENCE.length); + const closingIndex = afterOpening.indexOf(`\n${FRONTMATTER_FENCE}`); + if (closingIndex < 0) { + return null; + } + const block = afterOpening.slice(0, closingIndex); + const result = parseYamlResult(block, Frontmatter); + if (!result.ok) { + return null; + } + return result.value; +} + +export function readSkillContent(info: SkillInfo, opts: ReadSkillContentOptions): SkillContent { + const body = readFileSync(join(info.dir, SKILL_MD_FILENAME), "utf8"); + if (!opts.includeExtras) { + return { + name: info.name, + description: info.description, + body, + references: [], + templates: [], + }; + } + return { + name: info.name, + description: info.description, + body, + references: collectExtraFiles(info.dir, SKILL_REFERENCES_DIR), + templates: collectExtraFiles(info.dir, SKILL_TEMPLATES_DIR), + }; +} + +function collectExtraFiles(skillDir: string, subdirName: string): SkillExtraFile[] { + const subdir = join(skillDir, subdirName); + if (!isDirectory(subdir)) { + return []; + } + const entries = readdirSync(subdir).toSorted(); + const out: SkillExtraFile[] = []; + for (const entry of entries) { + const full = join(subdir, entry); + if (!isFile(full)) { + continue; + } + out.push({ path: `${subdirName}/${entry}`, content: readFileSync(full, "utf8") }); + } + return out; +} + +function isDirectory(path: string): boolean { + try { + return statSync(path).isDirectory(); + } catch (error) { + if (isNotFoundError(error)) { + return false; + } + throw error; + } +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch (error) { + if (isNotFoundError(error)) { + return false; + } + throw error; + } +} diff --git a/src/main.ts b/src/main.ts index 399fe57..abe384d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -33,6 +33,7 @@ const main: CommandDef = defineCommand({ query: () => import("./commands/query").then((mod) => mod.default), uuid: () => import("./commands/uuid").then((mod) => mod.default), upgrade: () => import("./commands/upgrade").then((mod) => mod.default), + skills: () => import("./commands/skills").then((mod) => mod.default), __manifest: (): Promise => import("./commands/manifest").then((mod) => mod.createManifestCommand(main)), }, diff --git a/tests/e2e/manifest.e2e.test.ts b/tests/e2e/manifest.e2e.test.ts index b7c3c6f..3b3bd3a 100644 --- a/tests/e2e/manifest.e2e.test.ts +++ b/tests/e2e/manifest.e2e.test.ts @@ -137,6 +137,9 @@ describe("__manifest e2e", () => { "query", "uuid", "upgrade", + "skills list", + "skills get", + "skills path", ]); // Streaming commands legitimately have no outputSchema — they pipe raw bytes diff --git a/tests/e2e/skills.e2e.test.ts b/tests/e2e/skills.e2e.test.ts new file mode 100644 index 0000000..d87c950 --- /dev/null +++ b/tests/e2e/skills.e2e.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { SkillGetEnvelope } from "../../src/commands/skills/get"; +import { SkillListEnvelope } from "../../src/commands/skills/list"; +import { SkillPathListEnvelope } from "../../src/commands/skills/path"; +import { parseJson } from "../../src/runtime/json"; + +import { cleanupConfigHome, mkTempConfigHome, runCli } from "./run-cli"; + +const BUNDLED_VISIBLE_NAMES = ["core", "git-sync", "transform", "workspace"] as const; + +describe("skills e2e", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all(tempDirs.splice(0).map(cleanupConfigHome)); + }); + + async function makeIsolatedConfigHome(): Promise { + const dir = await mkTempConfigHome(); + tempDirs.push(dir); + return dir; + } + + it("list returns the four bundled non-hidden skills, sorted by name", async () => { + const result = await runCli({ + args: ["skills", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, SkillListEnvelope); + expect(envelope.data.map((s) => s.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); + expect(envelope.returned).toBe(BUNDLED_VISIBLE_NAMES.length); + for (const item of envelope.data) { + expect(item.description.length).toBeGreaterThan(20); + } + }); + + it("list hides the metabase-cli discovery stub", async () => { + const result = await runCli({ + args: ["skills", "list", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode).toBe(0); + const envelope = parseJson(result.stdout, SkillListEnvelope); + expect(envelope.data.map((s) => s.name)).not.toContain("metabase-cli"); + }); + + it("get core returns the SKILL.md body with frontmatter intact and no references unless --full", async () => { + const result = await runCli({ + args: ["skills", "get", "core", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, SkillGetEnvelope); + expect(envelope.returned).toBe(1); + expect(envelope.data).toEqual([ + { + name: "core", + description: expect.stringContaining("Drive a Metabase instance"), + body: expect.stringMatching(/^---\nname: core\n[\s\S]*Top-level command groups/), + references: [], + templates: [], + }, + ]); + }); + + it("get --all returns every non-hidden skill (with --max-bytes 0 to opt out of the list cap)", async () => { + const result = await runCli({ + args: ["skills", "get", "--all", "--json", "--max-bytes", "0"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, SkillGetEnvelope); + expect(envelope.data.map((s) => s.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); + expect(envelope.truncated).toBeUndefined(); + }); + + it("get --all under the default byte cap truncates the trailing skills and surfaces a truncation notice", async () => { + const result = await runCli({ + args: ["skills", "get", "--all", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, SkillGetEnvelope); + expect(envelope.total).toBe(BUNDLED_VISIBLE_NAMES.length); + expect(envelope.returned).toBeLessThan(BUNDLED_VISIBLE_NAMES.length); + expect(envelope.truncated?.reason).toBe("max_bytes"); + expect(result.stderr).toContain("cut at"); + }); + + it("get accepts comma-separated names", async () => { + const result = await runCli({ + args: ["skills", "get", "workspace,transform", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, SkillGetEnvelope); + expect(envelope.data.map((s) => s.name)).toEqual(["workspace", "transform"]); + }); + + it("get rejects an unknown skill name with exit 2 and a ConfigError message listing available names", async () => { + const result = await runCli({ + args: ["skills", "get", "does-not-exist"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain( + "unknown skill name(s): does-not-exist (available: core, git-sync, transform, workspace)", + ); + }); + + it("get without a name or --all errors with exit 2", async () => { + const result = await runCli({ + args: ["skills", "get"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode).toBe(2); + expect(result.stderr).toContain("provide a skill name (comma-separated for multiple) or --all"); + }); + + it("path with no name lists every non-hidden skill's directory", async () => { + const result = await runCli({ + args: ["skills", "path", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, SkillPathListEnvelope); + expect(envelope.data.map((s) => s.name)).toEqual([...BUNDLED_VISIBLE_NAMES]); + for (const item of envelope.data) { + expect(item.dir.endsWith(`/skill-data/${item.name}`)).toBe(true); + } + }); + + it("path returns a single-item envelope", async () => { + const result = await runCli({ + args: ["skills", "path", "core", "--json"], + configHome: await makeIsolatedConfigHome(), + }); + + expect(result.exitCode, result.stderr).toBe(0); + const envelope = parseJson(result.stdout, SkillPathListEnvelope); + expect(envelope.returned).toBe(1); + expect(envelope.data).toHaveLength(1); + const item = envelope.data[0]; + if (item === undefined) { + throw new Error("expected one item in the envelope"); + } + expect(item.name).toBe("core"); + expect(item.dir.endsWith("/skill-data/core")).toBe(true); + }); +});