@metabase/rde 0.1.18 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -15
  2. package/dist/cli.mjs +301 -253
  3. package/package.json +12 -2
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @metabase/rde
2
2
 
3
- `rde` prepares a Mac or Linux machine for data-engineering work with Metabase and coding agents. One command, `rde init`, sets up everything a working setup needs: the `mb` CLI, the `rde` skill for your coding agents, a running Metabase Enterprise instance (in Docker, or a jar run with the machine's Java), an admin user, an API key wired into `mb`, transforms switched on, your license, a database of your own for transforms to write to, and a git repository connected through Metabase's remote sync. It decides what it can on its own and asks only for what it cannot know: your license token, a connection string for your own database (`init` goes no further without one), and with a license, where the git repository lives. Every step checks whether it is already done, so the wizard can be interrupted at any prompt and picked up again with the same command.
3
+ `rde` prepares a Mac or Linux machine for data-engineering work with Metabase and coding agents. One command, `rde init`, sets up everything a working setup needs: the `mb` CLI, the `metabase-rde` skill for your coding agents, a running Metabase Enterprise instance (in Docker, or a jar run with the machine's Java), an admin user, an API key wired into `mb`, transforms switched on, your license, a database of your own for transforms to write to, and a git repository connected through Metabase's remote sync. It decides what it can on its own and asks only for what it cannot know: your license token, a connection string for your own database (`init` goes no further without one), and with a license, where the git repository lives. Every step checks whether it is already done, so the wizard can be interrupted at any prompt and picked up again with the same command.
4
4
 
5
5
  After `init`, the rest is lifecycle: `rde start`, `rde stop`, `rde status`, `rde logs`, `rde open`, and `rde db add` when another database comes along. The instance lives under `~/.rde`, listens on `127.0.0.1` only, and is used through `mb` and through the agent skill.
6
6
 
@@ -60,13 +60,13 @@ The git step reads `–` until a license with remote sync is active; it is check
60
60
  | Install the Metabase CLI | `@metabase/cli` (`mb`) at the `latest` dist-tag, with the package manager that installed `rde`; an installed `mb` older than what the dist-tag names now is updated. `rde status` and `rde doctor` check only what is installed and ask no registry |
61
61
  | Install Metabase | **Docker** when it is available: pulls the major line of the newest Metabase Enterprise release, such as `metabase/metabase-enterprise:v1.65.x` (or `--image`), and creates the container. **Jar** otherwise, or with `--runtime jar`: downloads the newest enterprise release (or the tag or `.jar` path given as `--jar`) into `~/.rde/jars/` and runs it with your Java. [Metabase versions](#metabase-versions) says how the release is found. Either way on port 23000, or the first free port above it that nothing else claims, or `--port` |
62
62
  | Start Metabase | Starts the container or the jar and waits for `/api/health`, showing Metabase's own progress |
63
- | Install the rde and data-app skills | The data-app skills (`metabase-data-app-setup`, `-routing`, `-actions`, `-semantic-layer`, `-migrate`) from the release branch of the Metabase the instance runs (`metabase/metabase/skills#release-x.65.x` for Metabase 65, `release-x.66.x` for 66, and so on; 65 is the first with them), then the `rde` and `rde-data-apps` skills from the `main` branch of `metabase/agent-skills` (the rde skill hands an explicit app request to `rde-data-apps`, which hands the build to the data-app skills), for the coding agents you pick at a terminal, from the ones found on this machine (Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Windsurf). When none is found, the step is skipped and says how to add the skills once an agent is installed; `rde` installs no agent. Without a terminal, or with `--yes`, every agent found gets the skills; `--agents` picks a set ahead of time (the skills are installed for them whether or not they are on the machine yet), `--skip-agents` none; `RDE_SKILL_SOURCE` installs the two rde skills from another URL or a local checkout of `metabase/agent-skills`. Installed skills whose directory changed on its branch since are installed again, without a question when every detected agent already has them all. The skills are installed globally, not per instance, so every instance on the machine shares one set: instances on different Metabase majors share the data-app branch the last `init` (or `rde upgrade` to another major) installed |
63
+ | Install the rde and data-app skills | The `metabase-data-apps` skill, one skill with a guide per data-app task (setup, routing, actions, semantic layer, migrate), from the `skills/metabase-data-apps/v1` folder of the `main` branch of `metabase/agent-skills`, then the `metabase-rde` skill from the `skills/metabase-rde/v1` folder of the same branch (its data-app reference settles what an app needs on an rde machine and hands the build to `metabase-data-apps`), for the coding agents you pick at a terminal, from the ones found on this machine (OpenCode, Claude Code, Codex, Cursor, Gemini CLI, Windsurf). When none is found, the step is skipped and says how to add the skills once an agent is installed; `rde` installs no agent. Without a terminal, or with `--yes`, every agent found gets the skills; `--agents` picks a set ahead of time (the skills are installed for them whether or not they are on the machine yet), `--skip-agents` none; `RDE_SKILL_SOURCE` installs both from another branch URL or a local checkout of `metabase/agent-skills`. The `v1` folder of `metabase-data-apps` is the data-app contract version Metabase serves, the same whatever Metabase version the instance runs; a breaking contract change gets a folder of its own (`v2`, …), and the guide for migrating an app Metabase marks Outdated moves it to the newer one. Installed skills whose directory changed on its branch since are installed again, without a question when every detected agent already has them all, and copies of the earlier per-task data-app skills (`metabase-data-app-setup`, `-routing`, `-actions`, `-semantic-layer`, `-migrate`) that the skills lock attributes to `metabase/metabase`, whoever installed them, are removed once `metabase-data-apps` is in, so the two never compete; a copy also installed for an agent that has no `metabase-data-apps` after the run stays, with a warning naming those agents and the `rde init --only agents-skills --agents <…>` command that replaces it. Copies of `rde` and `rde-data-apps`, the two skills an earlier rde installed in place of `metabase-rde`, are removed the same way once `metabase-rde` is in, wherever they came from. The skills are installed globally, not per instance, so every instance on the machine shares one set |
64
64
  | Create the admin user | `admin@metabase.local` with a generated password that `rde credentials` shows, or your own with `--admin prompted`, see [Credentials](#credentials) |
65
65
  | Connect the Metabase CLI | Creates an API key named `rde` and logs `mb` in with it under the profile `default`, so plain `mb` uses the instance; when `default` already points at another Metabase it asks first, see [The `mb` profile](#the-mb-profile); a key Metabase rejects is replaced |
66
66
  | Enable transforms | Turns transforms on, unless someone already set them either way: turned off in the admin settings, they stay off on every later `rde init`, and the admin settings turn them back on |
67
67
  | Activate your license | Optional. Paste a token, or press Enter to skip. The token unlocks remote sync (the `remote-sync` feature, which the git step needs), data apps (`data-apps`), and transform tests (`transforms-testing`); skipping the license goes without all three, and an activated token that lacks some is named with the ones it lacks. A token Metabase refuses is asked for again at a terminal |
68
68
  | Connect a git repository | Only with a license. Initializes or reuses a repository and points Metabase's remote sync at it, with transforms included in the sync (the Transforms row under Collections to sync), and imports the content it already holds |
69
- | Choose what syncs to git | Only once a repository is connected. Creates the Library (when the license has the `library` feature) and an `Analytics` collection, unless the repository already brought synced top-level collections in, and marks them for sync. Before it changes anything it asks whether to push them to the branch (default yes); without the push, or when the branch has commits Metabase has not imported, it prints the export to run instead |
69
+ | Choose what syncs to git | Only once a repository is connected, and only when the license has the `library` feature. Creates the Library unless the repository already brought one in, and marks it for sync; nothing else is created or marked. When the Library was not synced yet, it asks before it changes anything whether to push it to the branch (default yes); without the push, or when the branch has commits Metabase has not imported, it prints the export to run instead |
70
70
  | Connect your databases | Required: `init` goes no further until a database of your own is connected, as a user that can create the tables transforms write. At a terminal: pick the databases the imported content uses from a checklist, or, with none, paste a connection string (Enter goes to the engine list); review, test, add; repeat. `--database` names them ahead of time; with `--yes` or without a terminal and none named, the step fails naming `--database` and the answers file. `rde db add` starts from the same checklist, then the engine list, where the fields can also be filled one by one |
71
71
  | Summary | URL, login email, the `mb` command for the instance, databases, repository, the export still to run when Metabase has unexported changes, and the command that opens each detected agent |
72
72
 
@@ -95,7 +95,7 @@ The second run on the same machine detects every step as done and prints the sum
95
95
  │ Databases: │
96
96
  │ warehouse (postgres, syncing; `rde status` shows when it finishes) │
97
97
  │ Remote sync: not configured (no license) │
98
- │ rde skill: installed for Claude Code │
98
+ │ metabase-rde skill: installed for Claude Code │
99
99
  │ Start working: │
100
100
  │ claude (Claude Code) │
101
101
  │ Lifecycle: rde status (--json for scripts), rde stop, rde start │
@@ -152,7 +152,7 @@ Docker is used whenever it is available: the image is self-contained, and the co
152
152
 
153
153
  rde runs Metabase Enterprise 65 or newer. Once Metabase is up, `rde init`, `rde start`, and `rde upgrade` read the version it reports and refuse the OSS edition (a `v0.…` version, which `metabase/metabase` runs), a release before 65, and a build of master (a `-SNAPSHOT` version, which `metabase/metabase-enterprise-head` runs), with exit code 2 and the way to another: `rde upgrade --to <image or release tag>` for this instance, or `rde uninstall`, then `rde init --image <image>` or `rde init --jar <release tag>`, for a new one. The refused instance is left running as it is. `--jar` refuses a `v0.…` tag before anything is downloaded. With `RDE_DEV=1`, for working on rde and Metabase themselves, every version runs.
154
154
 
155
- A new instance runs the newest release that Metabase's release feed (`https://static.metabase.com/version-info-ee.json`) lists of Enterprise 65 or newer, betas and release candidates left out. The feed's own `latest`, like the Docker `:latest` tag, moves to a new major only weeks after its release, so rde reads neither. Docker gets the major line of that release, `metabase/metabase-enterprise:v1.<major>.x`, a tag Metabase moves to every build of the major: the state file records the line, and each pull of it brings the newest build. The jar gets the release itself. While the feed lists no release of 65 or newer, `init` stops with exit code 2 before it pulls or downloads anything, and names the ways out: `--image <image>`, `--jar <release tag>`, or `RDE_DEV=1` for the development build. When the feed cannot be read, `init` stops the same way, with the cause and `--image <image>` or `--jar <release tag>` as the way past it. In development mode the image is `metabase/metabase-enterprise-head:latest`, and the jar's `latest` is the feed's own `latest`.
155
+ A new instance runs the newest release that Metabase's release feed (`https://static.metabase.com/version-info-ee.json`) lists of Enterprise 65 or newer, a beta included: a major's betas are its `.0` builds and its first final release is `.1`, so a beta runs only until that final is out. The feed's own `latest`, like the Docker `:latest` tag, moves to a new major only weeks after its release, so rde reads neither. Docker gets the major line of that release, `metabase/metabase-enterprise:v1.<major>.x`, a tag Metabase moves to every build of the major: the state file records the line, and each pull of it brings the newest build. The jar gets the release itself. While the feed lists no release of 65 or newer, `init` stops with exit code 2 before it pulls or downloads anything, and names the ways out: `--image <image>`, `--jar <release tag>`, or `RDE_DEV=1` for the development build. When the feed cannot be read, `init` stops the same way, with the cause and `--image <image>` or `--jar <release tag>` as the way past it. In development mode the image is `metabase/metabase-enterprise-head:latest`, and the jar's `latest` is the feed's own `latest`.
156
156
 
157
157
  ## Everyday commands
158
158
 
@@ -195,9 +195,9 @@ With a license that includes remote sync, the step after the license connects a
195
195
 
196
196
  A fresh repository gets a first commit with a `README.md` and a `.gitignore` that keeps out what a data app built in it must not push: `.env`, `.env.local`, `.env.*.local`, `node_modules/`, `.vite/`, the rde skill's `.scratch/`, and `.DS_Store` (a data app's built `dist/` stays tracked, since that bundle is what Metabase serves); a `.gitignore` already there keeps its lines and gets the missing ones, and a repository that already has commits is left as it is. A repository that already holds Metabase content is imported on the spot; each database it names that this instance lacks becomes a stub, which the databases step offers to connect.
197
197
 
198
- Remote sync carries only what is marked for it, so the step after the repository decides what that is: the Library (published tables with their measures and segments in Data, metrics in Metrics), and one top-level collection for dashboards, questions, models, and documents; transforms already sync through the repository step. The collection is `Analytics`, created for the purpose (beside any `Analytics` of the user's, which stays out of git), unless the import brought top-level collections the repository already syncs, in which case those are used and nothing is created; the Library is created only when the import did not bring one, since a second Library would conflict with the repository's on the next import. Anything filed outside these collections stays on this instance. The step refuses to run after an import that did not finish cleanly, and names the import to run first. While remote sync is read-only, the repository decides what syncs: `rde init` skips the step, and `rde init --only sync-scope` stops and asks for read-write in Admin. `rde init --only sync-scope` runs it again; it creates nothing that is already there.
198
+ Remote sync carries only what is marked for it, so the step after the repository decides what that is: the Library, where everything for consumption goes (published tables with their measures and segments in Data, metrics in Metrics, official dashboards in Dashboards). Transforms already sync through the repository step, by setting rather than by collection, and data apps sync their own collections. The step never creates or marks any other collection; top-level collections the repository already syncs come in marked with the import and are left as they are. The Library is created only when the import did not bring one, since a second Library would conflict with the repository's on the next import, and when the import brought it already synced, the normal case for an instance cloned from a production repository, the step changes nothing and asks nothing. Anything filed outside the synced collections stays on this instance. The step refuses to run after an import that did not finish cleanly, and names the import to run first. While remote sync is read-only, the repository decides what syncs: `rde init` skips the step, and `rde init --only sync-scope` stops and asks for read-write in Admin. `rde init --only sync-scope` runs it again; it creates nothing that is already there. An instance set up by an earlier `rde` may have an `Analytics` collection that step created; it stays as it is, synced or not, and `rde` no longer tracks it.
199
199
 
200
- Marking the scope is itself a change Metabase has not exported, and until it is exported a plain `mb git-sync import` refuses ("There are unsaved changes in the Remote Sync collection"). So the step asks, before it creates or marks anything, whether to push the scope to the branch, and on yes runs `mb git-sync export` and checks that Metabase is in sync afterwards. It asks because the branch may be one others share. On no, or when the push fails, it prints `mb git-sync export -b <branch>` and the summary repeats it while Metabase has unexported changes. When the branch has commits Metabase has not imported, it asks nothing and pushes nothing: a plain export would end in a conflict, which counts the remote commit as synced, so it names `mb git-sync export-preflight` and `mb git-sync export -b <branch> --merge` instead. Afterwards:
200
+ Marking the Library is itself a change Metabase has not exported, and until it is exported a plain `mb git-sync import` refuses ("There are unsaved changes in the Remote Sync collection"). So when the Library is not synced yet, as with a fresh repository, the step asks, before it creates or marks anything, whether to push it to the branch, and on yes runs `mb git-sync export` and checks that Metabase is in sync afterwards. It asks because the branch may be one others share. On no, or when the push fails, it prints `mb git-sync export -b <branch>` and the summary repeats it while Metabase has unexported changes. When the branch has commits Metabase has not imported, it asks nothing and pushes nothing: a plain export would end in a conflict, which counts the remote commit as synced, so it names `mb git-sync export-preflight` and `mb git-sync export -b <branch> --merge` instead. Afterwards:
201
201
 
202
202
  ```sh
203
203
  cd <your working directory>
@@ -208,7 +208,7 @@ git pull # after an export, to see the YAML files
208
208
 
209
209
  ## Running `init` without a terminal
210
210
 
211
- Every prompt can be answered ahead of time, by a flag or by a JSON answers file, so an agent or a CI job can run `rde init` unattended. The choices `init` makes on its own need no answer, so a run without a terminal only has to cover the license, the databases, the git repository, and whether to push the sync scope. When stdin is not a terminal, the first prompt nothing answers fails with exit code 2 and a message naming the step, the prompt, and the flag or answers key that would have answered it; nothing hangs. A text prompt with a default (a display name) takes its default, as Enter would.
211
+ Every prompt can be answered ahead of time, by a flag or by a JSON answers file, so an agent or a CI job can run `rde init` unattended. The choices `init` makes on its own need no answer, so a run without a terminal only has to cover the license, the databases, the git repository, and whether to push the Library. When stdin is not a terminal, the first prompt nothing answers fails with exit code 2 and a message naming the step, the prompt, and the flag or answers key that would have answered it; nothing hangs. A text prompt with a default (a display name) takes its default, as Enter would.
212
212
 
213
213
  ### Flags
214
214
 
@@ -220,11 +220,12 @@ rde init --skip-license \
220
220
 
221
221
  | Flag | Answers |
222
222
  | --- | --- |
223
- | `--agents claude-code,codex` / `--skip-agents` | Which agents get the skill, instead of asking at a terminal or taking the ones found on the machine |
223
+ | `--agents opencode,claude-code` / `--skip-agents` | Which agents get the skill, instead of asking at a terminal or taking the ones found on the machine |
224
224
  | `--runtime docker\|jar`, `--image`, `--jar`, `--port` | How Metabase runs, the Docker image or the jar (`latest`, an enterprise release tag, or a `.jar` path), and the host port, instead of Docker when available, the major line of the newest release, the newest release, and the first free port from 23000 that nothing else claims; `--image` implies `docker` and `--jar` implies `jar` |
225
225
  | `--admin generated\|prompted`, `--admin-email`, `--admin-password` | The admin user, `generated` unless given; an email or password implies `prompted` |
226
226
  | `--license-token` / `--skip-license` | The license |
227
227
  | `--database <connection string>` (repeatable) | Your own databases, the engine read from the string; `<engine>=<connection string>` names it when the string fits several (`druid=jdbc:avatica:…`). Without a terminal, one of them or the answers file's `databases` section is required unless the instance already has a database |
228
+ | `--database-name <name>` (repeatable) | Display name of the `--database` in the same position. A name the repository's content already uses (the production Metabase's name) connects the placeholder a remote-sync import left for it instead of adding a second database |
228
229
  | `--git-dir`, `--git-remote local\|hosted`, `--git-url`, `--git-token` / `--skip-git` | The content repository; a remote or URL without `--git-dir` means the current directory |
229
230
  | `--mb-profile default\|rde` | The `mb` profile when `default` already points at another Metabase; `default` (replace it) unless given |
230
231
  | `--yes` | Every confirmation, and the recommended choice of a question (replacing `default`); your own database then comes from `--database` or the answers file, as without a terminal. A driver's toggle (SSL, an SSH tunnel) is a setting, not a confirmation, and keeps the driver's default |
@@ -255,7 +256,7 @@ rde init --license-token '$RDE_LICENSE_TOKEN' …
255
256
  }
256
257
  ```
257
258
 
258
- `"runtime"` takes `"kind": "docker"` or `"jar"`, which `"image"` and `"jar"` imply on their own (`"runtime": { "jar": "latest" }` runs the newest release with Java). `"license": { "token": null }` skips the license and `"git-sync": { "location": "skip" }` leaves the repository for later. `"sync-scope": { "export": false }` leaves the first export of the sync scope to you. `"databases"` lists at least one database: an empty list is refused, since `init` needs one; leave the section out to be asked at a terminal. A database is pasted as a `uri` or entered with `fields`, keyed by the driver's field names as the admin form shows them; a file field takes a path. Anything a `fields` entry leaves out is asked in the terminal, or named in the error without one. A field that engine does not ask with the others given, such as one behind the admin form's advanced options, is named in a warning and ignored.
259
+ `"runtime"` takes `"kind": "docker"` or `"jar"`, which `"image"` and `"jar"` imply on their own (`"runtime": { "jar": "latest" }` runs the newest release with Java). `"license": { "token": null }` skips the license and `"git-sync": { "location": "skip" }` leaves the repository for later. `"sync-scope": { "export": false }` leaves the first export of the Library to you. `"databases"` lists at least one database: an empty list is refused, since `init` needs one; leave the section out to be asked at a terminal. A database is pasted as a `uri` or entered with `fields`, keyed by the driver's field names as the admin form shows them; a file field takes a path. Anything a `fields` entry leaves out is asked in the terminal, or named in the error without one. A field that engine does not ask with the others given, such as one behind the admin form's advanced options, is named in a warning and ignored.
259
260
 
260
261
  ### Recording a run
261
262
 
@@ -265,7 +266,7 @@ rde init --license-token '$RDE_LICENSE_TOKEN' …
265
266
 
266
267
  `rde upgrade` moves the instance to a newer Metabase without losing its application database. It makes sure the instance is running and reads the version it is on, fetches the target, and stops there with `Already on …` when the target is what the instance already runs. Otherwise it shows the running version, the target, and the backup path, asks once (`--yes` skips the question; without a terminal `--yes` is required), and then stops Metabase, copies the whole `data/` directory to `~/.rde/instances/default/backups/<timestamp>/`, switches the instance to the target, starts it, waits for health, and records the new version. Metabase migrates its application database forward on that first start.
267
268
 
268
- A move to another major version (65 to 66, say) is named before the backup with `This moves Metabase from 65 to 66`, ahead of the same question. A target that names no release shows its version only once it runs, and the plan says so: a major line (`:v1.66.x`) still names its major, so the move is named before the backup all the same, while `:latest` or a jar path names nothing, so the line comes after the start instead. Either way, once the new major runs, `rde upgrade` runs the skills step, because the data-app skills come from the release branch of the Metabase the instance runs; at a terminal it asks which agents get them like `rde init` does, and with `--yes` every agent found gets them.
269
+ A move to another major version (65 to 66, say) is named before the backup with `This moves Metabase from 65 to 66`, ahead of the same question. A target that names no release shows its version only once it runs, and the plan says so: a major line (`:v1.66.x`) still names its major, so the move is named before the backup all the same, while `:latest` or a jar path names nothing, so the line comes after the start instead. Either way, once the new major runs, `rde upgrade` runs the skills step, which installs again every skill whose folder changed on its branch; at a terminal it asks which agents get them like `rde init` does, and with `--yes` every agent found gets them.
269
270
 
270
271
  For a Docker instance the target is an image ref (`--to <image ref>`; by default, for an image of `metabase/metabase-enterprise`, `metabase/metabase-enterprise-head`, or `metabase/metabase`, the major line of the newest release, found the way `init` finds it, whatever tag the instance ran before: `:latest`, an older major line, or the same line), the pulled image is compared by id with the one the container runs, and the container is removed and created again from the target with the same port and mount and the environment rde sets now. An image from any other repository (a registry of your own, an image you built) is pulled again under its own ref, and the plan says so. The default target follows the feed rules of [Metabase versions](#metabase-versions): a feed that lists no release of 65 or newer, or cannot be read, stops the upgrade before it starts Metabase or pulls anything. With `RDE_DEV=1` the default target is the instance's own image. When the target is the image already running, a container created under another ref of it (`:latest` that names the same image as the line, say) or one that keeps variables rde sets otherwise now (the staging store of development mode, say) is still created again from that image under the target's ref, with no backup since nothing migrates; `rde doctor` names a container with such variables, with `rde upgrade` as the fix. A build of master (`metabase/metabase-enterprise-head:latest`, the development image) reports a major ahead of every release, so `rde upgrade` refuses it as a downgrade; `rde doctor` names one outside development mode, and the way onto a release is `rde uninstall`, then `rde init`, while `RDE_DEV=1` keeps running it. For a jar instance the target is `latest` (the default, the newest release), a release tag, or a `.jar` path; a release is downloaded into `~/.rde/jars/` before the question, and the instance's `runtime.source` is switched to it.
271
272
 
@@ -294,7 +295,7 @@ With the local `content.git` remote, that repository lives in the instance direc
294
295
  ```sh
295
296
  rde uninstall # container or process, mb profile, ~/.rde/instances/default
296
297
  rde uninstall --keep-data # container or process and profile only; the data and secrets.json stay, and `rde init` reinstalls from them
297
- rde uninstall --remove-skill # also the rde and rde-data-apps skills from your coding agents
298
+ rde uninstall --remove-skill # also the metabase-rde and metabase-data-apps skills (and the older rde and rde-data-apps) from your coding agents
298
299
  rde uninstall --remove-image # also the Docker images or the downloaded jar (otherwise they stay for the next init)
299
300
  rde uninstall --yes --force # also deletes commits in content.git that your working directory has not pulled
300
301
  ```
@@ -308,7 +309,7 @@ Running it twice is a no-op. It also works with no `~/.rde` at all, so a leftove
308
309
  | Variable | What it does |
309
310
  | --- | --- |
310
311
  | `RDE_HOME` | Where rde keeps its state instead of `~/.rde`; a relative path is resolved against the working directory |
311
- | `RDE_SKILL_SOURCE` | Development only: where the `rde` and `rde-data-apps` skills are installed from instead of `metabase/agent-skills`, such as a local checkout, so they can be tried before they are pushed |
312
+ | `RDE_SKILL_SOURCE` | Development only: the `metabase/agent-skills` branch or checkout the `metabase-rde` and `metabase-data-apps` skills are installed from (from its `skills/metabase-rde/v1` and `skills/metabase-data-apps/v1` folders) instead of the `main` branch: a GitHub URL that names a branch (`…/tree/<branch>`) or the root of a local checkout, so they can be tried before they are pushed |
312
313
  | `RDE_DEV` | Development only: `1` runs instances on the enterprise build of master and checks license tokens against the staging store; `bin/rde-dev` sets it |
313
314
 
314
315
  An empty value counts as unset.
@@ -372,9 +373,9 @@ Start Docker (or `colima start`), or install Java, and run `rde init` again. `rd
372
373
  plain `mb` uses prod (MB_PROFILE): prod.acme.example
373
374
  ```
374
375
 
375
- **The agent follows an old copy of the skill.** The `rde skill copies` row lists every copy of the skill the `skills` CLI knows, global and in the current directory's project, with where it came from, and fails when one did not come from rde's source (the branch is read from the `skills` lock file; against a local `RDE_SKILL_SOURCE` the copy's `SKILL.md` is compared with the checkout's `skills/rde/SKILL.md`). With several copies it says which one Claude Code started in that directory loads (a personal skill wins over a project one). `rde init --only agents-skills` reinstalls the global copy; restart the agent session afterwards.
376
+ **The agent follows an old copy of the skill.** The `metabase-rde skill copies` row lists every copy of the skill the `skills` CLI knows, global and in the current directory's project, with where it came from, and fails when one did not come from rde's source (the branch is read from the `skills` lock file; against a local `RDE_SKILL_SOURCE` the copy's `SKILL.md` is compared with the checkout's `skills/metabase-rde/v1/SKILL.md`), and also fails while a global copy of `rde` or `rde-data-apps`, which `metabase-rde` replaces, is still there. With several copies it says which one Claude Code started in that directory loads (a personal skill wins over a project one). `rde init --only agents-skills` reinstalls the global copy and removes the replaced ones; restart the agent session afterwards.
376
377
 
377
- **The data-app skills are for another Metabase version.** The `data-app skills` row names the branch of `metabase/metabase` the instance's Metabase version takes them from (`release-x.65.x` for 65, and so on) and fails when a data-app skill is missing or its copy came from another branch, or when `rde-data-apps` did not come from rde's source. The skills are global, so it happens when an instance on another major installed them last, or when the skills step was left out after `rde upgrade` moved to another major. `rde init --only agents-skills` (or `rde doctor --fix`) installs them for this instance's version; restart the agent session afterwards.
378
+ **The agent follows the wrong data-app skill.** The `data-app skills` row fails when `metabase-data-apps` is missing or its copy did not come from rde's source (against a local `RDE_SKILL_SOURCE`, the checkout's `skills/metabase-data-apps/v1/SKILL.md`), or when a copy of an earlier per-task data-app skill (`metabase-data-app-setup` and the like) that the skills lock attributes to `metabase/metabase` is still there, whoever installed it. `rde init --only agents-skills` (or `rde doctor --fix`) installs the skills and removes the copies `metabase-data-apps` replaces; a copy also installed for an agent that has no `metabase-data-apps` after the run stays, and the run names the `--agents` that let it go. Restart the agent session afterwards.
378
379
 
379
380
  **The license token is rejected.** Metabase checks the token with `token-check.metabase.com` before it saves it. `The license token was refused: <reason>` means the store turned the token down: check that you pasted the whole token and that its subscription is active. `The instance could not reach token-check.metabase.com to check the license token` means the check never got an answer: the container (or the jar) needs outbound network access to that host. At a terminal the step asks for the token again, and Enter skips the license and carries on without remote sync, data apps, and transform tests; without a terminal the run stops, and `rde init --from license` tries again.
380
381
 
package/dist/cli.mjs CHANGED
@@ -369,7 +369,7 @@ const StepRecord = z.object({
369
369
  completedAt: z.string()
370
370
  });
371
371
  const InstanceState = z.object({
372
- schemaVersion: z.literal(7),
372
+ schemaVersion: z.literal(8),
373
373
  name: z.string(),
374
374
  runtime: RuntimeState.nullable(),
375
375
  url: z.string().nullable(),
@@ -380,7 +380,6 @@ const InstanceState = z.object({
380
380
  databases: z.array(DatabaseRecord),
381
381
  gitRemote: GitRemoteState.nullable(),
382
382
  movedFrom: z.string().nullable(),
383
- syncRoot: z.int().nullable(),
384
383
  steps: z.partialRecord(StepId, StepRecord)
385
384
  });
386
385
  function configuredRuntime(state) {
@@ -410,7 +409,7 @@ function mbCommand(profile) {
410
409
  }
411
410
  function emptyInstanceState(name) {
412
411
  return {
413
- schemaVersion: 7,
412
+ schemaVersion: 8,
414
413
  name,
415
414
  runtime: null,
416
415
  url: null,
@@ -421,7 +420,6 @@ function emptyInstanceState(name) {
421
420
  databases: [],
422
421
  gitRemote: null,
423
422
  movedFrom: null,
424
- syncRoot: null,
425
423
  steps: {}
426
424
  };
427
425
  }
@@ -434,6 +432,7 @@ const V3 = 3;
434
432
  const V4 = 4;
435
433
  const V5 = 5;
436
434
  const V6 = 6;
435
+ const V7 = 7;
437
436
  const StateV1 = z.object({ schemaVersion: z.literal(V1) }).loose();
438
437
  const StateV2 = z.object({ schemaVersion: z.literal(V2) }).loose();
439
438
  const AdminV3 = z.discriminatedUnion("kind", [z.object({
@@ -450,6 +449,7 @@ const StateV5 = z.object({
450
449
  steps: z.record(z.string(), z.unknown())
451
450
  }).loose();
452
451
  const StateV6 = z.object({ schemaVersion: z.literal(V6) }).loose();
452
+ const StateV7 = z.object({ schemaVersion: z.literal(V7) }).loose();
453
453
  const StateV3 = z.object({
454
454
  schemaVersion: z.literal(V3),
455
455
  admin: AdminV3.nullable(),
@@ -487,8 +487,14 @@ function liftV5(raw) {
487
487
  function liftV6(raw) {
488
488
  return {
489
489
  ...StateV6.parse(raw),
490
- schemaVersion: 7,
491
- syncRoot: null
490
+ schemaVersion: V7
491
+ };
492
+ }
493
+ function liftV7(raw) {
494
+ const { syncRoot: _syncRoot, ...state } = StateV7.parse(raw);
495
+ return {
496
+ ...state,
497
+ schemaVersion: 8
492
498
  };
493
499
  }
494
500
  function adminV4(admin) {
@@ -530,15 +536,16 @@ function migrateInstanceState(raw) {
530
536
  case V3: return liftV3(raw);
531
537
  case V4: return migrateInstanceState(liftV4(raw));
532
538
  case V5: return migrateInstanceState(liftV5(raw));
533
- case V6: return {
534
- state: InstanceState.parse(liftV6(raw)),
539
+ case V6: return migrateInstanceState(liftV6(raw));
540
+ case V7: return {
541
+ state: InstanceState.parse(liftV7(raw)),
535
542
  movedSecrets: null
536
543
  };
537
- case 7: return {
544
+ case 8: return {
538
545
  state: InstanceState.parse(raw),
539
546
  movedSecrets: null
540
547
  };
541
- default: throw new ConfigError(`state schemaVersion ${schemaVersion} is not supported by this build (expected 7)`);
548
+ default: throw new ConfigError(`state schemaVersion ${schemaVersion} is not supported by this build (expected 8)`);
542
549
  }
543
550
  }
544
551
  //#endregion
@@ -714,6 +721,7 @@ async function requireInstanceState(name) {
714
721
  }
715
722
  const LOCK_SUFFIX = ".lock";
716
723
  const STALE_SUFFIX = ".stale";
724
+ const PENDING_SUFFIX = ".pending";
717
725
  const LOCK_RETRY_MS = 25;
718
726
  const LOCK_WAIT_MS = 3e4;
719
727
  function lockToken() {
@@ -731,6 +739,15 @@ function isRunning(pid) {
731
739
  return !hasCode(error, "ESRCH");
732
740
  }
733
741
  }
742
+ async function createLock(path, token) {
743
+ const pending = `${path}${PENDING_SUFFIX}.${token.replaceAll(" ", "-")}`;
744
+ await promises.writeFile(pending, token, { mode: 384 });
745
+ try {
746
+ await promises.link(pending, path);
747
+ } finally {
748
+ await promises.rm(pending, { force: true });
749
+ }
750
+ }
734
751
  async function takeOverDeadLock(path, held) {
735
752
  const aside = `${path}${STALE_SUFFIX}.${held.replaceAll(" ", "-")}`;
736
753
  try {
@@ -755,10 +772,7 @@ async function acquireLock(name, signal) {
755
772
  const deadline = Date.now() + LOCK_WAIT_MS;
756
773
  for (;;) {
757
774
  try {
758
- await promises.writeFile(path, token, {
759
- flag: "wx",
760
- mode: 384
761
- });
775
+ await createLock(path, token);
762
776
  return token;
763
777
  } catch (error) {
764
778
  if (isFileNotFound$1(error)) await requireInstanceState(name);
@@ -2116,14 +2130,23 @@ async function whichCommand(name) {
2116
2130
  //#endregion
2117
2131
  //#region src/tools/agents.ts
2118
2132
  const AgentId = z.enum([
2133
+ "opencode",
2119
2134
  "claude-code",
2120
2135
  "codex",
2121
2136
  "cursor",
2122
2137
  "gemini-cli",
2123
- "opencode",
2124
2138
  "windsurf"
2125
2139
  ]);
2126
2140
  const AGENTS = [
2141
+ {
2142
+ id: "opencode",
2143
+ displayName: "OpenCode",
2144
+ skillsCliName: "opencode",
2145
+ command: "opencode",
2146
+ launch: "opencode",
2147
+ readsSharedSkills: true,
2148
+ configDir: (_home, configHome) => join(configHome, "opencode")
2149
+ },
2127
2150
  {
2128
2151
  id: "claude-code",
2129
2152
  displayName: "Claude Code",
@@ -2160,15 +2183,6 @@ const AGENTS = [
2160
2183
  readsSharedSkills: true,
2161
2184
  configDir: (home) => join(home, ".gemini")
2162
2185
  },
2163
- {
2164
- id: "opencode",
2165
- displayName: "OpenCode",
2166
- skillsCliName: "opencode",
2167
- command: "opencode",
2168
- launch: "opencode",
2169
- readsSharedSkills: true,
2170
- configDir: (_home, configHome) => join(configHome, "opencode")
2171
- },
2172
2186
  {
2173
2187
  id: "windsurf",
2174
2188
  displayName: "Windsurf",
@@ -2229,7 +2243,6 @@ const VERSION_INFO_PATH = "/version-info-ee.json";
2229
2243
  const JAR_FILE = "metabase.jar";
2230
2244
  const RELEASE_TAG$1 = /^v(?<edition>[01])\.(?<major>\d+)\.\d+(?:\.\d+)?(?:-[\w.]+)?$/;
2231
2245
  const MAJOR_LINE_TAG = /^v(?<edition>[01])\.(?<major>\d+)\.x$/;
2232
- const FINAL_ENTERPRISE_RELEASE = /^v1\.\d+\.\d+(?:\.\d+)?$/;
2233
2246
  const ENTERPRISE_EDITION = "1";
2234
2247
  const OPEN_SOURCE_EDITION = "0";
2235
2248
  const SNAPSHOT_SUFFIX = "-SNAPSHOT";
@@ -2268,11 +2281,13 @@ function compareReleases(left, right) {
2268
2281
  return 0;
2269
2282
  }
2270
2283
  function supportedRelease(version) {
2271
- if (!FINAL_ENTERPRISE_RELEASE.test(version)) return null;
2272
- const major = releaseMajor(version);
2273
- return major !== null && major >= 65 ? {
2284
+ const groups = RELEASE_TAG$1.exec(version)?.groups;
2285
+ const major = groups?.["major"];
2286
+ if (groups?.["edition"] !== ENTERPRISE_EDITION || major === void 0) return null;
2287
+ const number = Number.parseInt(major, 10);
2288
+ return number >= 65 ? {
2274
2289
  version,
2275
- major
2290
+ major: number
2276
2291
  } : null;
2277
2292
  }
2278
2293
  function newestSupportedRelease(versions) {
@@ -5722,6 +5737,14 @@ function gitTool(runner) {
5722
5737
  "HEAD"
5723
5738
  ], { cwd: dir })).exitCode === 0;
5724
5739
  }
5740
+ async function gitPath(dir, name) {
5741
+ const result = await git([
5742
+ "rev-parse",
5743
+ "--git-path",
5744
+ name
5745
+ ], { cwd: dir });
5746
+ return resolve(dir, result.stdout.trim());
5747
+ }
5725
5748
  async function hasIdentity(dir) {
5726
5749
  const result = await runner.run(GIT, [
5727
5750
  "config",
@@ -5839,6 +5862,7 @@ function gitTool(runner) {
5839
5862
  initBare,
5840
5863
  init,
5841
5864
  hasCommits,
5865
+ gitPath,
5842
5866
  commitPaths,
5843
5867
  addRemote,
5844
5868
  clone,
@@ -6056,6 +6080,7 @@ const README_NAME = "README.md";
6056
6080
  const README_CONTENT = "# Metabase content\n\nMetabase content synced by rde.\n";
6057
6081
  const INITIAL_COMMIT_MESSAGE = "Initial commit";
6058
6082
  const GITIGNORE_NAME = ".gitignore";
6083
+ const EXCLUDE_GIT_PATH = "info/exclude";
6059
6084
  const IGNORED_PATHS = [
6060
6085
  ".env",
6061
6086
  ".env.local",
@@ -6076,10 +6101,7 @@ const TERMINAL_TASK_STATUSES = /* @__PURE__ */ new Set([
6076
6101
  "timed-out",
6077
6102
  "conflict"
6078
6103
  ]);
6079
- const SYNC_MODE_SETTINGS = {
6080
- "remote-sync-type": "read-write",
6081
- "remote-sync-transforms": true
6082
- };
6104
+ const SYNC_MODE_SETTINGS = { "remote-sync-type": "read-write" };
6083
6105
  function hostDeps$11(ctx) {
6084
6106
  return {
6085
6107
  runner: processRunner(ctx.signal),
@@ -6318,20 +6340,21 @@ function hostedScreen(run) {
6318
6340
  }
6319
6341
  };
6320
6342
  }
6321
- async function ignoreDataAppFiles(workdir) {
6322
- const path = join(workdir, GITIGNORE_NAME);
6343
+ async function ignoreDataAppFiles(path) {
6323
6344
  const existing = await readTextFile(path);
6324
6345
  const present = new Set(existing === null ? [] : existing.split("\n").map((line) => line.trim()));
6325
6346
  const missing = IGNORED_PATHS.filter((entry) => !present.has(entry));
6326
6347
  if (missing.length === 0) return;
6327
6348
  const separator = existing === null || existing.endsWith("\n") ? "" : "\n";
6349
+ await promises.mkdir(dirname(path), { recursive: true });
6328
6350
  await promises.appendFile(path, `${separator}${missing.join("\n")}\n`);
6329
6351
  }
6330
6352
  async function prepareWorkdir(run, workdir) {
6331
6353
  if (!await run.git.isRepo(workdir)) await run.git.init(workdir, { branch: DEFAULT_BRANCH });
6332
- if (!await run.git.hasCommits(workdir)) {
6354
+ if (await run.git.hasCommits(workdir)) await ignoreDataAppFiles(await run.git.gitPath(workdir, EXCLUDE_GIT_PATH));
6355
+ else {
6333
6356
  await promises.writeFile(join(workdir, README_NAME), README_CONTENT);
6334
- await ignoreDataAppFiles(workdir);
6357
+ await ignoreDataAppFiles(join(workdir, GITIGNORE_NAME));
6335
6358
  await run.git.commitPaths(workdir, [README_NAME, GITIGNORE_NAME], INITIAL_COMMIT_MESSAGE);
6336
6359
  }
6337
6360
  return {
@@ -6385,12 +6408,9 @@ async function cloneHosted(run, workdir, credentials) {
6385
6408
  throw error;
6386
6409
  }
6387
6410
  spinner.stop(`Cloned into ${workdir}`);
6388
- if (await run.git.hasCommits(workdir)) return {
6389
- workdir,
6390
- branch: await run.git.currentBranch(workdir)
6391
- };
6411
+ const hadCommits = await run.git.hasCommits(workdir);
6392
6412
  const prepared = await prepareWorkdir(run, workdir);
6393
- await run.git.push(workdir, ORIGIN, prepared.branch, credentials);
6413
+ if (!hadCommits) await run.git.push(workdir, ORIGIN, prepared.branch, credentials);
6394
6414
  return prepared;
6395
6415
  }
6396
6416
  async function adoptRepository(run, workdir, url) {
@@ -6469,7 +6489,8 @@ function unfinishedImport(outcome) {
6469
6489
  }
6470
6490
  async function importExistingContent(run, gitRemote) {
6471
6491
  if (!await remoteHasContent(run, gitRemote)) {
6472
- run.ctx.ui.info(`The repository holds no Metabase content yet; the first \`${exportCommand(run.ctx.state.mbProfile, gitRemote.branch)}\` populates it`);
6492
+ await run.client.remoteSync.syncTransforms();
6493
+ run.ctx.ui.info(`Nothing to import from the repository; \`${exportCommand(run.ctx.state.mbProfile, gitRemote.branch)}\` publishes this Metabase's content to it`);
6473
6494
  return;
6474
6495
  }
6475
6496
  const spinner = run.ctx.ui.spinner("Importing the repository's content into Metabase…");
@@ -6607,16 +6628,18 @@ function githubContents(signal, options = {}) {
6607
6628
  //#region src/tools/skills-cli.ts
6608
6629
  const NPX = "npx";
6609
6630
  const SKILLS_PACKAGE = "skills@latest";
6610
- const RDE_DATA_APPS_SKILL_NAME = "rde-data-apps";
6611
- const RDE_SOURCE_SKILL_NAMES = ["rde", RDE_DATA_APPS_SKILL_NAME];
6612
- const DATA_APP_REPOSITORY = "metabase/metabase";
6613
- const DATA_APP_SKILL_NAMES = [
6614
- "metabase-data-app-setup",
6615
- "metabase-data-app-routing",
6616
- "metabase-data-app-actions",
6617
- "metabase-data-app-semantic-layer",
6618
- "metabase-data-app-migrate"
6619
- ];
6631
+ const LIST_INTERNAL_SKILLS = { INSTALL_INTERNAL_SKILLS: "1" };
6632
+ const DEFAULT_RDE_SKILL_SOURCE = "https://github.com/metabase/agent-skills/tree/main";
6633
+ const RDE_SKILL_NAME = "metabase-rde";
6634
+ const DATA_APPS_SKILL_NAME = "metabase-data-apps";
6635
+ const RDE_INSTALLED_SKILL_NAMES = [DATA_APPS_SKILL_NAME, RDE_SKILL_NAME];
6636
+ const REPLACED_RDE_SKILL_NAMES = ["rde", "rde-data-apps"];
6637
+ const RDE_OWNED_SKILL_NAMES = [...RDE_INSTALLED_SKILL_NAMES, ...REPLACED_RDE_SKILL_NAMES];
6638
+ const SKILL_VERSIONS = {
6639
+ [DATA_APPS_SKILL_NAME]: 1,
6640
+ [RDE_SKILL_NAME]: 1
6641
+ };
6642
+ const SOURCE_SKILLS_DIR = "skills";
6620
6643
  function rdeSkillSource() {
6621
6644
  return readSetEnv("RDE_SKILL_SOURCE") ?? "https://github.com/metabase/agent-skills/tree/main";
6622
6645
  }
@@ -6656,16 +6679,6 @@ const hostSkillFiles = {
6656
6679
  readJson: readJsonFile,
6657
6680
  readText: readTextFile
6658
6681
  };
6659
- function dataAppSkillsSource(ref) {
6660
- return `${DATA_APP_REPOSITORY}/skills#${ref}`;
6661
- }
6662
- function dataAppSkillsOrigin(ref) {
6663
- return {
6664
- kind: "github",
6665
- repository: DATA_APP_REPOSITORY,
6666
- ref
6667
- };
6668
- }
6669
6682
  const GITHUB_URL = /^https:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/tree\/([^/]+)(?:\/.*)?)?\/?$/;
6670
6683
  function skillOrigin(source, cwd) {
6671
6684
  const github = GITHUB_URL.exec(source);
@@ -6686,6 +6699,23 @@ function skillOrigin(source, cwd) {
6686
6699
  source
6687
6700
  };
6688
6701
  }
6702
+ function sourceSkillDirectory(skill) {
6703
+ return posix.join(SOURCE_SKILLS_DIR, skill, `v${SKILL_VERSIONS[skill]}`);
6704
+ }
6705
+ function skillSource(rdeSource, skill, cwd) {
6706
+ const directory = sourceSkillDirectory(skill);
6707
+ const origin = skillOrigin(rdeSource, cwd);
6708
+ switch (origin.kind) {
6709
+ case "github":
6710
+ if (origin.ref === null) throw new ConfigError(`RDE_SKILL_SOURCE must name a branch: https://github.com/${origin.repository}/tree/<branch>`);
6711
+ return `https://github.com/${origin.repository}/tree/${origin.ref}/${directory}`;
6712
+ case "local": return join(origin.path, directory);
6713
+ case "other": {
6714
+ const [path, ref] = origin.source.split("#", 2);
6715
+ return ref === void 0 ? `${path}/${directory}` : `${path}/${directory}#${ref}`;
6716
+ }
6717
+ }
6718
+ }
6689
6719
  function skillsCli(runner) {
6690
6720
  async function add(options) {
6691
6721
  const argv = [
@@ -6719,7 +6749,10 @@ function skillsCli(runner) {
6719
6749
  "-g",
6720
6750
  "--json"
6721
6751
  ];
6722
- const result = await runner.run(NPX, argv, { stdoutToFile: true });
6752
+ const result = await runner.run(NPX, argv, {
6753
+ stdoutToFile: true,
6754
+ addedEnv: LIST_INTERNAL_SKILLS
6755
+ });
6723
6756
  if (result.exitCode !== 0) throw toolFailure(NPX, argv, result);
6724
6757
  return parseJsonOutput(NPX, argv, result.stdout, InstalledSkillList);
6725
6758
  }
@@ -6732,7 +6765,8 @@ function skillsCli(runner) {
6732
6765
  ];
6733
6766
  const result = await runner.run(NPX, argv, {
6734
6767
  cwd,
6735
- stdoutToFile: true
6768
+ stdoutToFile: true,
6769
+ addedEnv: LIST_INTERNAL_SKILLS
6736
6770
  });
6737
6771
  if (result.exitCode !== 0) throw toolFailure(NPX, argv, result);
6738
6772
  return parseJsonOutput(NPX, argv, result.stdout, InstalledSkillList);
@@ -6749,7 +6783,23 @@ function skillsCli(runner) {
6749
6783
  const STEP_ID$5 = "agents-skills";
6750
6784
  const AGENTS_SKILLS_KEYS = { agents: `${STEP_ID$5}.agents` };
6751
6785
  const MECHANICS_NOTE = "The rde skill's mechanics come from `mb skills get <name>` at runtime, so nothing else needs installing.";
6752
- const REQUIRED_SKILL_NAMES = [...DATA_APP_SKILL_NAMES, ...RDE_SOURCE_SKILL_NAMES];
6786
+ const REQUIRED_SKILL_NAMES = RDE_INSTALLED_SKILL_NAMES;
6787
+ const LEGACY_DATA_APP_REPOSITORY = "metabase/metabase";
6788
+ const LEGACY_DATA_APP_SKILL_NAMES = [
6789
+ "metabase-data-app-setup",
6790
+ "metabase-data-app-routing",
6791
+ "metabase-data-app-actions",
6792
+ "metabase-data-app-semantic-layer",
6793
+ "metabase-data-app-migrate"
6794
+ ];
6795
+ const REPLACED_RDE_NAMES = REPLACED_RDE_SKILL_NAMES;
6796
+ const REPLACEMENTS = [{
6797
+ by: DATA_APPS_SKILL_NAME,
6798
+ names: LEGACY_DATA_APP_SKILL_NAMES
6799
+ }, {
6800
+ by: RDE_SKILL_NAME,
6801
+ names: REPLACED_RDE_NAMES
6802
+ }];
6753
6803
  const DETECT_AGENTS = { kind: "detected" };
6754
6804
  const ASK_AGENTS = { kind: "ask" };
6755
6805
  function hostDeps$10(ctx) {
@@ -6792,34 +6842,17 @@ function coveredAgentNames(entry, agents) {
6792
6842
  function installedLines(entries, agents) {
6793
6843
  return entries.map((entry) => `${entry.name} (${coveredAgentNames(entry, agents).join(", ")}): ${entry.path}`).join("\n");
6794
6844
  }
6795
- const DEVELOPMENT_BRANCH = "master";
6796
- function releaseBranch(major) {
6797
- return `release-x.${major}.x`;
6798
- }
6799
- function dataAppSkillsRef(metabaseVersion, development) {
6800
- if (metabaseVersion === null) return releaseBranch(65);
6801
- if (development && isMasterBuild(metabaseVersion)) return DEVELOPMENT_BRANCH;
6802
- const major = releaseMajor(metabaseVersion) ?? 65;
6803
- return releaseBranch(Math.max(major, 65));
6804
- }
6805
- function wantedDataAppRef(metabaseVersion) {
6806
- return dataAppSkillsRef(metabaseVersion, developmentMode());
6807
- }
6808
- function skillSets(deps, metabaseVersion) {
6809
- const dataAppRef = wantedDataAppRef(metabaseVersion);
6810
- return [{
6811
- label: "data-app",
6812
- source: dataAppSkillsSource(dataAppRef),
6813
- sourceNote: ` from ${dataAppRef}`,
6814
- origin: dataAppSkillsOrigin(dataAppRef),
6815
- skills: DATA_APP_SKILL_NAMES
6816
- }, {
6817
- label: "rde",
6818
- source: deps.skillSource,
6819
- sourceNote: deps.skillSource === "https://github.com/metabase/agent-skills/tree/main" ? "" : ` from ${deps.skillSource}`,
6820
- origin: skillOrigin(deps.skillSource, deps.cwd),
6821
- skills: RDE_SOURCE_SKILL_NAMES
6822
- }];
6845
+ function skillInstalls(deps) {
6846
+ const defaultSource = deps.skillSource === DEFAULT_RDE_SKILL_SOURCE;
6847
+ return RDE_INSTALLED_SKILL_NAMES.map((skill) => {
6848
+ const source = skillSource(deps.skillSource, skill, deps.cwd);
6849
+ return {
6850
+ skill,
6851
+ source,
6852
+ sourceNote: ` from ${defaultSource ? sourceSkillDirectory(skill) : source}`,
6853
+ origin: skillOrigin(source, deps.cwd)
6854
+ };
6855
+ });
6823
6856
  }
6824
6857
  function hashLookup(github) {
6825
6858
  const asked = /* @__PURE__ */ new Map();
@@ -6844,7 +6877,8 @@ async function readLockedSkills(deps) {
6844
6877
  const lockPath = globalSkillLockPath(deps.probe.home, deps.stateHome);
6845
6878
  try {
6846
6879
  const lock = await deps.files.readJson(lockPath);
6847
- return new Map(REQUIRED_SKILL_NAMES.map((name) => [name, lockedSkill(lockPath, lock, name)]));
6880
+ const names = [...REQUIRED_SKILL_NAMES, ...LEGACY_DATA_APP_SKILL_NAMES];
6881
+ return new Map(names.map((name) => [name, lockedSkill(lockPath, lock, name)]));
6848
6882
  } catch (error) {
6849
6883
  if (error instanceof ConfigError) return null;
6850
6884
  throw error;
@@ -6864,25 +6898,54 @@ function lockedCopy(locked, origin) {
6864
6898
  hash: locked.skillFolderHash
6865
6899
  };
6866
6900
  }
6867
- async function setBehind(set, check) {
6868
- const { origin } = set;
6901
+ async function installBehind(install, check) {
6902
+ const { origin } = install;
6869
6903
  if (origin.kind !== "github" || check.locked === null) return true;
6870
- const copies = [];
6871
- for (const skill of set.skills) {
6872
- const copy = lockedCopy(check.locked.get(skill) ?? null, origin);
6873
- if (copy === null) return true;
6874
- copies.push(copy);
6875
- }
6904
+ const copy = lockedCopy(check.locked.get(install.skill) ?? null, origin);
6905
+ if (copy === null) return true;
6876
6906
  if (check.updates === "installed") return false;
6877
- for (const copy of copies) {
6878
- const current = await check.hashes(origin.repository, copy.ref, copy.directory);
6879
- if (current !== null && current.get(copy.folder) !== copy.hash) return true;
6907
+ const current = await check.hashes(origin.repository, copy.ref, copy.directory);
6908
+ return current !== null && current.get(copy.folder) !== copy.hash;
6909
+ }
6910
+ function isLegacyDataAppCopy(name, source) {
6911
+ return LEGACY_DATA_APP_SKILL_NAMES.includes(name) && source === LEGACY_DATA_APP_REPOSITORY;
6912
+ }
6913
+ function isReplacedRdeCopy(name) {
6914
+ return REPLACED_RDE_NAMES.includes(name);
6915
+ }
6916
+ function replacedCopies(installed, locked) {
6917
+ const found = /* @__PURE__ */ new Map();
6918
+ for (const { by, names } of REPLACEMENTS) {
6919
+ const present = names.filter((name) => by === "metabase-data-apps" ? isLegacyDataAppCopy(name, locked?.get(name)?.source) : installed.some((entry) => entry.name === name));
6920
+ if (present.length > 0) found.set(by, present);
6880
6921
  }
6881
- return false;
6922
+ return found;
6882
6923
  }
6883
6924
  function hasSkill(installed, skill, agent) {
6884
6925
  return installed.some((entry) => entry.name === skill && skillCoversAgent(entry, agent));
6885
6926
  }
6927
+ function splitReplaced(by, replaced, installed, all) {
6928
+ const split = {
6929
+ removable: [],
6930
+ kept: [],
6931
+ uncovered: []
6932
+ };
6933
+ for (const name of replaced) {
6934
+ const copies = installed.filter((entry) => entry.name === name);
6935
+ const uncovered = all.filter((agent) => copies.some((entry) => skillCoversAgent(entry, agent)) && !hasSkill(installed, by, agent));
6936
+ if (uncovered.length === 0) {
6937
+ split.removable.push(name);
6938
+ continue;
6939
+ }
6940
+ split.kept.push(name);
6941
+ for (const agent of uncovered) if (!split.uncovered.includes(agent)) split.uncovered.push(agent);
6942
+ }
6943
+ return split;
6944
+ }
6945
+ function keptReplacedLine(by, split) {
6946
+ const ids = split.uncovered.map((agent) => agent.id).join(",");
6947
+ return `${by} is not installed for ${displayNames(split.uncovered)}, so rde keeps ${split.kept.join(", ")}; \`rde init --only ${STEP_ID$5} --agents ${ids}\` installs it there and removes what it replaces`;
6948
+ }
6886
6949
  function coversAll(installed, skills, agents) {
6887
6950
  return agents.every((agent) => skills.every((skill) => hasSkill(installed, skill, agent)));
6888
6951
  }
@@ -6948,7 +7011,7 @@ function skipLine(all, source) {
6948
7011
  }
6949
7012
  function agentsSkillsStep(options, updates, deps = hostDeps$10) {
6950
7013
  let hashes = null;
6951
- async function setCheck(resolved) {
7014
+ async function installCheck(resolved) {
6952
7015
  hashes ??= hashLookup(resolved.github);
6953
7016
  return {
6954
7017
  updates,
@@ -6964,9 +7027,11 @@ function agentsSkillsStep(options, updates, deps = hostDeps$10) {
6964
7027
  const resolved = deps(ctx);
6965
7028
  const detected = detectedOnly(await detectAgents(resolved.probe));
6966
7029
  if (detected.length === 0) return ctx.state.steps[STEP_ID$5]?.status === "skipped" ? "not-applicable" : "todo";
6967
- if (missingSkills(await skillsCli(resolved.runner).list(), detected).length > 0) return "todo";
6968
- const check = await setCheck(resolved);
6969
- for (const set of skillSets(resolved, ctx.state.version)) if (await setBehind(set, check)) return "outdated";
7030
+ const installed = await skillsCli(resolved.runner).list();
7031
+ if (missingSkills(installed, detected).length > 0) return "todo";
7032
+ const check = await installCheck(resolved);
7033
+ if (replacedCopies(installed, check.locked).size > 0) return "outdated";
7034
+ for (const install of skillInstalls(resolved)) if (await installBehind(install, check)) return "outdated";
6970
7035
  return "done";
6971
7036
  },
6972
7037
  async run(ctx) {
@@ -6981,28 +7046,37 @@ function agentsSkillsStep(options, updates, deps = hostDeps$10) {
6981
7046
  return { outcome: "skipped" };
6982
7047
  }
6983
7048
  const before = await installedNow();
6984
- const check = await setCheck(resolved);
7049
+ const check = await installCheck(resolved);
6985
7050
  const pending = [];
6986
- for (const set of skillSets(resolved, ctx.state.version)) if (!coversAll(before, set.skills, selection.agents) || await setBehind(set, check)) pending.push(set);
7051
+ for (const install of skillInstalls(resolved)) if (!coversAll(before, [install.skill], selection.agents) || await installBehind(install, check)) pending.push(install);
7052
+ const replaced = replacedCopies(before, check.locked);
6987
7053
  const names = displayNames(selection.agents);
6988
- if (pending.length === 0) {
7054
+ if (pending.length === 0 && replaced.size === 0) {
6989
7055
  ctx.ui.success(`The rde and data-app skills are current for ${names}`);
6990
7056
  return { outcome: "completed" };
6991
7057
  }
6992
7058
  const agents = selection.agents.map((agent) => agent.skillsCliName);
6993
7059
  const forWhom = `for ${names} (${SOURCE_NOTES[selection.source]})`;
6994
- for (const set of pending) {
6995
- ctx.ui.info(`Installing the ${set.label} skills ${forWhom}${set.sourceNote}`);
7060
+ for (const install of pending) {
7061
+ ctx.ui.info(`Installing ${install.skill} ${forWhom}${install.sourceNote}`);
6996
7062
  await skills.add({
6997
- source: set.source,
6998
- skills: set.skills,
7063
+ source: install.source,
7064
+ skills: [install.skill],
6999
7065
  agents,
7000
7066
  output: ctx.ui.childOutput
7001
7067
  });
7002
7068
  }
7003
- const installed = await skills.list();
7069
+ const installed = pending.length === 0 ? before : await skills.list();
7004
7070
  const missing = unlistedSkills(installed);
7005
7071
  if (missing.length > 0) throw new ConfigError(`the skills installer finished but \`skills list\` does not show ${missing.join(", ")}`);
7072
+ for (const [by, old] of replaced) {
7073
+ const split = splitReplaced(by, old, installed, all);
7074
+ if (split.removable.length > 0) {
7075
+ ctx.ui.info(`Removing ${split.removable.join(", ")}, which ${by} replaces`);
7076
+ await skills.remove(split.removable, ctx.ui.childOutput);
7077
+ }
7078
+ if (split.kept.length > 0) ctx.ui.warn(keptReplacedLine(by, split));
7079
+ }
7006
7080
  ctx.ui.note(installedLines(requiredEntries(installed), selection.agents), "Skills installed");
7007
7081
  ctx.ui.info(MECHANICS_NOTE);
7008
7082
  return { outcome: "completed" };
@@ -7190,7 +7264,7 @@ function npmRegistry(signal, options = {}) {
7190
7264
  //#endregion
7191
7265
  //#region src/steps/install-mb.ts
7192
7266
  const STEP_ID$4 = "install-mb";
7193
- const MIN_MB_VERSION = "0.3.0";
7267
+ const MIN_MB_VERSION = "0.5.1";
7194
7268
  const MB_RANGE = `>=${MIN_MB_VERSION}`;
7195
7269
  function installedWith(scriptPath) {
7196
7270
  return scriptPath === void 0 ? "npm" : packageManagerFor(scriptPath);
@@ -7918,10 +7992,8 @@ function startMetabaseStep(deps = hostDeps$5, accepted = "supported") {
7918
7992
  //#region src/steps/sync-scope.ts
7919
7993
  const STEP_ID$2 = "sync-scope";
7920
7994
  const SYNC_SCOPE_KEYS = { export: `${STEP_ID$2}.export` };
7921
- const AGENT_COLLECTION_NAME = "Analytics";
7922
7995
  const LIBRARY_NAME = "Library";
7923
7996
  const ROOT_COLLECTION_NAME = "Our analytics";
7924
- const AGENT_COLLECTION_DESCRIPTION = "Dashboards, questions, models, and documents that reach git on export.";
7925
7997
  function hostDeps$4(ctx) {
7926
7998
  return {
7927
7999
  client: (baseUrl, auth) => createMetabaseClient(baseUrl, auth, ctx.signal),
@@ -7932,22 +8004,11 @@ function hostDeps$4(ctx) {
7932
8004
  function isCollection(entry) {
7933
8005
  return typeof entry.id === "number";
7934
8006
  }
7935
- function isOrdinary(collection) {
7936
- return collection.type === null && collection.namespace === null && collection.personal_owner_id === null;
7937
- }
7938
- function readScope(list, syncRoot) {
7939
- const topLevel = list.filter(isCollection).filter((collection) => collection.location === "/");
7940
- const ordinary = topLevel.filter(isOrdinary);
7941
- const unsynced = ordinary.filter((collection) => !collection.is_remote_synced);
7942
- return {
7943
- library: topLevel.find((collection) => collection.type === "library") ?? null,
7944
- syncedRoots: ordinary.filter((collection) => collection.is_remote_synced),
7945
- unsyncedRecordedRoot: unsynced.find((collection) => collection.id === syncRoot) ?? null,
7946
- foreignNamesake: unsynced.some((collection) => collection.id !== syncRoot && collection.name === "Analytics")
7947
- };
8007
+ function findLibrary(list) {
8008
+ return list.filter(isCollection).find((collection) => collection.location === "/" && collection.type === "library") ?? null;
7948
8009
  }
7949
- function isComplete(scope, libraryLicensed) {
7950
- return (!libraryLicensed || scope.library !== null && scope.library.is_remote_synced) && scope.syncedRoots.length > 0;
8010
+ function isComplete(library, libraryLicensed) {
8011
+ return !libraryLicensed || library !== null && library.is_remote_synced;
7951
8012
  }
7952
8013
  function isNotAnsweringYet(error) {
7953
8014
  return error instanceof MetabaseUnreachableError || error instanceof MetabaseInitializingError || isRejectedCredential(error);
@@ -7976,34 +8037,8 @@ async function isReadOnly(client) {
7976
8037
  async function requireReadWrite(client) {
7977
8038
  if (await isReadOnly(client)) throw new ConfigError(`remote sync is read-only; the repository decides what syncs. Switch it to read-write in Admin, then \`rde init --only ${STEP_ID$2}\``);
7978
8039
  }
7979
- async function ensureLibrary(client, scope, libraryLicensed) {
7980
- if (!libraryLicensed) return null;
7981
- return scope.library ?? client.collection.createLibrary();
7982
- }
7983
- async function ensureRoots(ctx, client, scope) {
7984
- if (scope.syncedRoots.length > 0) return {
7985
- roots: scope.syncedRoots,
7986
- created: false,
7987
- besideNamesake: false
7988
- };
7989
- if (scope.unsyncedRecordedRoot !== null) return {
7990
- roots: [scope.unsyncedRecordedRoot],
7991
- created: false,
7992
- besideNamesake: false
7993
- };
7994
- const created = await client.collection.create({
7995
- name: AGENT_COLLECTION_NAME,
7996
- description: AGENT_COLLECTION_DESCRIPTION
7997
- });
7998
- await ctx.saveState({
7999
- ...ctx.state,
8000
- syncRoot: created.id
8001
- });
8002
- return {
8003
- roots: [created],
8004
- created: true,
8005
- besideNamesake: scope.foreignNamesake
8006
- };
8040
+ async function ensureLibrary(client, library) {
8041
+ return library ?? client.collection.createLibrary();
8007
8042
  }
8008
8043
  function itemLabel(item) {
8009
8044
  return item.name === null ? `${item.model} ${item.id}` : `${item.model} ${item.id} "${item.name}"`;
@@ -8040,21 +8075,12 @@ function explainRefusal(error) {
8040
8075
  if (!refusal.success) return error;
8041
8076
  return new ConfigError([...refusal.data.errors.required.flatMap(requiredSyncLines), `After the fix, run \`rde init --only ${STEP_ID$2}\`.`].join("\n"));
8042
8077
  }
8043
- function plannedNames(scope, libraryLicensed) {
8044
- const library = libraryLicensed ? [scope.library?.name ?? LIBRARY_NAME] : [];
8045
- if (scope.syncedRoots.length > 0) return [...library, ...scope.syncedRoots.map((collection) => collection.name)];
8046
- return [...library, scope.unsyncedRecordedRoot?.name ?? "Analytics"];
8047
- }
8048
- function nameList(names) {
8049
- if (names.length <= 2) return names.join(" and ");
8050
- return `${names.slice(0, -1).join(", ")}, and ${names.at(-1)}`;
8051
- }
8052
8078
  function exportAdvice(profile, branch, changes) {
8053
8079
  const command = exportCommand(profile, branch);
8054
8080
  if (!remoteMoved(changes)) return `\`${command}\``;
8055
8081
  return `the branch has commits Metabase has not imported; \`${mbCommand(profile)} git-sync export-preflight\`, then \`${command} --merge\``;
8056
8082
  }
8057
- async function planExport(ctx, runner, instance, names) {
8083
+ async function planExport(ctx, runner, instance, name) {
8058
8084
  const changes = await mbTool(runner).gitSyncHasRemoteChanges(instance);
8059
8085
  if (remoteMoved(changes)) return {
8060
8086
  changes,
@@ -8064,7 +8090,7 @@ async function planExport(ctx, runner, instance, names) {
8064
8090
  changes,
8065
8091
  push: await ctx.prompts.confirm({
8066
8092
  key: SYNC_SCOPE_KEYS.export,
8067
- message: `Push ${nameList(names)} to ${requireGitRemote(ctx).branch} now?`,
8093
+ message: `Push ${name} to ${requireGitRemote(ctx).branch} now?`,
8068
8094
  initialValue: true
8069
8095
  })
8070
8096
  };
@@ -8079,7 +8105,7 @@ function reportPending(run) {
8079
8105
  }
8080
8106
  async function exportScope(run, runner) {
8081
8107
  const mb = mbTool(runner);
8082
- const label = nameList(run.names);
8108
+ const label = run.name;
8083
8109
  const spinner = run.ctx.ui.spinner(`Pushing ${label} to ${run.gitRemote.branch}…`);
8084
8110
  try {
8085
8111
  const final = (await mb.gitSyncExport(run.instance, {
@@ -8100,9 +8126,6 @@ async function exportScope(run, runner) {
8100
8126
  reportPending(run);
8101
8127
  }
8102
8128
  }
8103
- function scopeLabel(synced) {
8104
- return `Synced to git: ${synced.map((collection) => collection.name).join(", ")}`;
8105
- }
8106
8129
  function syncScopeStep(deps = hostDeps$4) {
8107
8130
  return {
8108
8131
  id: STEP_ID$2,
@@ -8118,7 +8141,7 @@ function syncScopeStep(deps = hostDeps$4) {
8118
8141
  key: ctx.secrets.apiKey
8119
8142
  });
8120
8143
  try {
8121
- if (isComplete(readScope(await client.collection.list(), ctx.state.syncRoot), hasLibrary(ctx.state.license))) return "done";
8144
+ if (isComplete(findLibrary(await client.collection.list()), hasLibrary(ctx.state.license))) return "done";
8122
8145
  return await isReadOnly(client) ? "not-applicable" : "todo";
8123
8146
  } catch (error) {
8124
8147
  if (isNotAnsweringYet(error)) return "todo";
@@ -8137,30 +8160,27 @@ function syncScopeStep(deps = hostDeps$4) {
8137
8160
  await requireReadWrite(client);
8138
8161
  await requireSettledImport(ctx, client, gitRemote);
8139
8162
  const libraryLicensed = hasLibrary(ctx.state.license);
8140
- const scope = readScope(await client.collection.list(), ctx.state.syncRoot);
8141
- const names = plannedNames(scope, libraryLicensed);
8142
- const plan = isComplete(scope, libraryLicensed) ? null : await planExport(ctx, resolved.runner, instance, names);
8163
+ const found = findLibrary(await client.collection.list());
8164
+ if (isComplete(found, libraryLicensed)) {
8165
+ ctx.ui.info(libraryLicensed ? `Synced to git: ${found?.name ?? LIBRARY_NAME}` : "The license has no Library, so there is nothing to mark for sync");
8166
+ return { outcome: "completed" };
8167
+ }
8168
+ const name = found?.name ?? LIBRARY_NAME;
8169
+ const plan = await planExport(ctx, resolved.runner, instance, name);
8143
8170
  const spinner = ctx.ui.spinner("Choosing what syncs to git…");
8144
- let synced;
8145
- let chosen;
8146
8171
  try {
8147
- const library = await ensureLibrary(client, scope, libraryLicensed);
8148
- chosen = await ensureRoots(ctx, client, scope);
8149
- synced = library === null ? chosen.roots : [library, ...chosen.roots];
8150
- await client.remoteSync.updateScope({ collections: Object.fromEntries(synced.map((collection) => [collection.id, true])) });
8172
+ const library = await ensureLibrary(client, found);
8173
+ await client.remoteSync.updateScope({ collections: { [library.id]: true } });
8151
8174
  } catch (error) {
8152
8175
  spinner.fail("Could not choose what syncs to git");
8153
8176
  throw explainRefusal(error);
8154
8177
  }
8155
- spinner.stop(scopeLabel(synced));
8156
- if (chosen.created) ctx.ui.info(`Created the ${AGENT_COLLECTION_NAME} collection: dashboards, questions, models, and documents filed under it reach git on the next export`);
8157
- if (chosen.besideNamesake) ctx.ui.info(`Another collection is also named ${AGENT_COLLECTION_NAME}; rde did not create it, so it stays out of git`);
8158
- if (plan === null) return { outcome: "completed" };
8178
+ spinner.stop(`Synced to git: ${name}`);
8159
8179
  const exportRun = {
8160
8180
  ctx,
8161
8181
  instance,
8162
8182
  gitRemote,
8163
- names,
8183
+ name,
8164
8184
  changes: plan.changes
8165
8185
  };
8166
8186
  if (plan.push) await exportScope(exportRun, resolved.runner);
@@ -8247,10 +8267,11 @@ async function pendingExportLines(state, secrets, runner) {
8247
8267
  throw error;
8248
8268
  }
8249
8269
  }
8250
- function skillLine(entries, detected) {
8270
+ function skillLine(entries, detected, skipped) {
8251
8271
  const agents = [...new Set(entries.flatMap((entry) => coveredAgentNames(entry, detected)))];
8252
- if (agents.length === 0) return `rde skill: not installed; \`rde init --only agents-skills\``;
8253
- return `rde skill: installed for ${agents.join(", ")}`;
8272
+ if (agents.length === 0) return `${RDE_SKILL_NAME} skill: not installed; \`rde init --only agents-skills\``;
8273
+ if (skipped) return `${RDE_SKILL_NAME} skill: skipped by this run; an earlier install covers ${agents.join(", ")}`;
8274
+ return `${RDE_SKILL_NAME} skill: installed for ${agents.join(", ")}`;
8254
8275
  }
8255
8276
  function launchLines(agents, workdir) {
8256
8277
  const detected = agents.filter((agent) => agent.detected);
@@ -8270,7 +8291,7 @@ async function summaryLines(state, secrets, deps) {
8270
8291
  const installed = await skillsCli(deps.runner).list();
8271
8292
  const agents = await detectAgents(deps.probe);
8272
8293
  const detected = agents.filter((agent) => agent.detected);
8273
- lines.push(skillLine(installed.filter((entry) => entry.name === "rde"), detected));
8294
+ lines.push(skillLine(installed.filter((entry) => entry.name === RDE_SKILL_NAME), detected, state.steps["agents-skills"]?.status === "skipped"));
8274
8295
  lines.push(...launchLines(agents, state.gitRemote?.workdir ?? null));
8275
8296
  lines.push("Lifecycle: rde status (--json for scripts), rde stop, rde start");
8276
8297
  return lines;
@@ -8714,6 +8735,18 @@ function parseDatabase(spec) {
8714
8735
  uri
8715
8736
  };
8716
8737
  }
8738
+ function databasesSection(flags) {
8739
+ if (flags.databaseName.length > flags.database.length) throw new ConfigError("--database-name names a --database; give one --database for each name");
8740
+ if (flags.database.length === 0) return;
8741
+ return flags.database.map((spec, index) => {
8742
+ const name = flags.databaseName[index];
8743
+ if (name !== void 0 && name.trim().length === 0) throw new ConfigError("--database-name needs a display name");
8744
+ return {
8745
+ ...parseDatabase(spec),
8746
+ ...name !== void 0 && { name }
8747
+ };
8748
+ });
8749
+ }
8717
8750
  function adminSection(flags, secrets) {
8718
8751
  const prompted = flags.adminEmail !== void 0 || secrets.has("--admin-password");
8719
8752
  if (flags.admin === void 0 && !prompted) return;
@@ -8851,7 +8884,7 @@ async function answersFromFlags(flags, readStdin) {
8851
8884
  runtime: runtimeSection(flags),
8852
8885
  "admin-user": adminSection(flags, secrets),
8853
8886
  license: licenseSection(flags, secrets),
8854
- databases: flags.database.length > 0 ? flags.database.map(parseDatabase) : void 0,
8887
+ databases: databasesSection(flags),
8855
8888
  "git-sync": gitSection(flags, secrets),
8856
8889
  "agents-skills": agentsSection(flags),
8857
8890
  "api-key": flags.mbProfile === void 0 ? void 0 : { profile: flags.mbProfile }
@@ -8908,6 +8941,11 @@ function givenFlags(flags) {
8908
8941
  flag: "--database",
8909
8942
  given: flags.database.length > 0
8910
8943
  },
8944
+ {
8945
+ step: "databases",
8946
+ flag: "--database-name",
8947
+ given: flags.databaseName.length > 0
8948
+ },
8911
8949
  {
8912
8950
  step: "git-sync",
8913
8951
  flag: "--git-dir",
@@ -9336,7 +9374,7 @@ const db = defineCommand({
9336
9374
  //#endregion
9337
9375
  //#region package.json
9338
9376
  var name = "@metabase/rde";
9339
- var version = "0.1.18";
9377
+ var version = "0.5.1";
9340
9378
  var description = "Prepares a local machine for data-engineering work with Metabase and coding agents";
9341
9379
  //#endregion
9342
9380
  //#region src/version.ts
@@ -10050,8 +10088,8 @@ async function gitSyncRow(state, instance, deps) {
10050
10088
  const SKILL_STEP = "agents-skills";
10051
10089
  const GLOBAL_SCOPE = "global";
10052
10090
  const SKILL_FILE = "SKILL.md";
10053
- const SOURCE_SKILLS_DIR = "skills";
10054
10091
  const CLAUDE_CODE = "Claude Code";
10092
+ const COPIES_LABEL = `${RDE_SKILL_NAME} skill copies`;
10055
10093
  const CLAUDE_CODE_SKILL_RULE = "a personal skill wins over a project one: https://code.claude.com/docs/en/skills";
10056
10094
  function skillRowId(agent) {
10057
10095
  return `skill.${agent.id}`;
@@ -10063,12 +10101,12 @@ async function copyRef(entry, deps) {
10063
10101
  const path = entry.scope === GLOBAL_SCOPE ? globalSkillLockPath(deps.probe.home, deps.skills.stateHome) : projectSkillLockPath(deps.skills.cwd);
10064
10102
  return lockedSkill(path, await deps.skills.files.readJson(path), entry.name)?.ref ?? null;
10065
10103
  }
10066
- async function judgeCopy(entry, origin, deps) {
10104
+ async function judgeCopy(skill, entry, origin, deps) {
10067
10105
  const where = `${entry.scope} ${entry.path}`;
10068
10106
  switch (origin.kind) {
10069
10107
  case "local": {
10070
10108
  const installed = await deps.skills.files.readText(join(entry.path, SKILL_FILE));
10071
- const wanted = await deps.skills.files.readText(join(origin.path, SOURCE_SKILLS_DIR, entry.name, SKILL_FILE));
10109
+ const wanted = await deps.skills.files.readText(join(origin.path, sourceSkillDirectory(skill), SKILL_FILE));
10072
10110
  const current = installed !== null && installed === wanted;
10073
10111
  return {
10074
10112
  entry,
@@ -10109,80 +10147,80 @@ function loadedCopyLine(entries, cwd) {
10109
10147
  if (loaded === void 0) return count;
10110
10148
  return `${count}; ${CLAUDE_CODE} started in ${cwd} loads ${loaded.path} (${CLAUDE_CODE_SKILL_RULE})`;
10111
10149
  }
10112
- function staleCopiesRow(stale, detail, deps) {
10150
+ function staleCopiesRow(stale, replaced, detail, deps) {
10113
10151
  const globalStale = stale.some((copy) => copy.entry.scope === GLOBAL_SCOPE);
10114
10152
  const projectStale = stale.some((copy) => copy.entry.scope !== GLOBAL_SCOPE);
10115
10153
  const repairs = [];
10116
- if (globalStale) repairs.push(`\`rde init --only ${SKILL_STEP}\` reinstalls the global copy from ${deps.skills.source}`);
10117
- if (projectStale) repairs.push(`remove the project copy with \`npx -y skills@latest remove rde -y\` in ${deps.skills.cwd}`);
10118
- return failed("skill-copies", "rde skill copies", {
10154
+ const stepFixes = [];
10155
+ if (globalStale) stepFixes.push(`reinstalls the global copy from ${deps.skills.source}`);
10156
+ if (replaced) stepFixes.push(`removes the copies ${RDE_SKILL_NAME} replaces`);
10157
+ if (stepFixes.length > 0) repairs.push(`\`rde init --only ${SKILL_STEP}\` ${stepFixes.join(" and ")}`);
10158
+ if (projectStale) repairs.push(`remove the project copy with \`npx -y skills@latest remove ${RDE_SKILL_NAME} -y\` in ${deps.skills.cwd}`);
10159
+ return failed("skill-copies", COPIES_LABEL, {
10119
10160
  detail,
10120
10161
  hint: `${repairs.join("; ")}; then restart the agent session`,
10121
- step: globalStale ? SKILL_STEP : null
10162
+ step: stepFixes.length > 0 ? SKILL_STEP : null
10122
10163
  });
10123
10164
  }
10124
- async function skillCopiesRow(globalEntries, deps) {
10165
+ async function skillCopiesRow(globalEntries, replaced, deps) {
10125
10166
  const id = "skill-copies";
10126
- const label = "rde skill copies";
10167
+ const label = COPIES_LABEL;
10127
10168
  const cwd = deps.skills.cwd;
10128
- const projectEntries = cwd === deps.probe.home ? [] : (await skillsCli(deps.runner).listProject(cwd)).filter((entry) => entry.name === "rde");
10169
+ const projectEntries = cwd === deps.probe.home ? [] : (await skillsCli(deps.runner).listProject(cwd)).filter((entry) => entry.name === RDE_SKILL_NAME);
10129
10170
  const entries = [...globalEntries, ...projectEntries];
10130
- if (entries.length === 0) return skipped(id, label, "no copy installed");
10171
+ if (entries.length === 0 && replaced.length === 0) return skipped(id, label, "no copy installed");
10131
10172
  const origin = skillOrigin(deps.skills.source, cwd);
10132
10173
  const judged = [];
10133
- for (const entry of entries) judged.push(await judgeCopy(entry, origin, deps));
10174
+ for (const entry of entries) judged.push(await judgeCopy(RDE_SKILL_NAME, entry, origin, deps));
10134
10175
  const loaded = loadedCopyLine(entries, cwd);
10135
- const lines = judged.map((copy) => copy.line);
10176
+ const lines = [...judged.map((copy) => copy.line), ...replaced.map((entry) => `${entry.scope} ${entry.path}: replaced by ${RDE_SKILL_NAME}`)];
10136
10177
  const detail = (loaded === null ? lines : [...lines, loaded]).join("\n");
10137
10178
  const stale = judged.filter((copy) => !copy.current);
10138
- return stale.length === 0 ? ok(id, label, detail) : staleCopiesRow(stale, detail, deps);
10179
+ if (stale.length === 0 && replaced.length === 0) return ok(id, label, detail);
10180
+ return staleCopiesRow(stale, replaced.length > 0, detail, deps);
10139
10181
  }
10140
- function dataAppBranchLine(version, origin) {
10141
- const wanted = withRef(origin.repository, origin.ref);
10142
- return version === null ? `Metabase has not reported its version yet; rde installs them from ${wanted}` : `Metabase ${version} takes them from ${wanted}`;
10182
+ function dataAppSkillsFix({ current, legacy }) {
10183
+ const command = `\`rde init --only ${SKILL_STEP}\``;
10184
+ const removal = `the copies ${DATA_APPS_SKILL_NAME} replaces`;
10185
+ if (!legacy) return `${command} installs it`;
10186
+ return current ? `${command} removes ${removal}` : `${command} installs it and removes ${removal}`;
10143
10187
  }
10144
- async function dataAppSkillsRow(state, installed, deps) {
10188
+ async function dataAppSkillsRow(installed, deps) {
10145
10189
  const id = "data-app-skills";
10146
10190
  const label = "data-app skills";
10147
- const branch = dataAppSkillsOrigin(wantedDataAppRef(state.version));
10148
- const wanted = [...DATA_APP_SKILL_NAMES.map((name) => ({
10149
- name,
10150
- origin: branch
10151
- })), {
10152
- name: RDE_DATA_APPS_SKILL_NAME,
10153
- origin: skillOrigin(deps.skills.source, deps.skills.cwd)
10154
- }];
10155
- const lines = [dataAppBranchLine(state.version, branch)];
10156
- let current = true;
10157
- for (const skill of wanted) {
10158
- const entry = installed.find((candidate) => candidate.name === skill.name);
10159
- if (entry === void 0) {
10160
- lines.push(`${skill.name}: not installed`);
10161
- current = false;
10162
- continue;
10163
- }
10164
- const judged = await judgeCopy(entry, skill.origin, deps);
10191
+ const origin = skillOrigin(deps.skills.source, deps.skills.cwd);
10192
+ const lines = [];
10193
+ const copy = installed.find((candidate) => candidate.name === DATA_APPS_SKILL_NAME);
10194
+ let current = false;
10195
+ if (copy === void 0) lines.push(`${DATA_APPS_SKILL_NAME}: not installed`);
10196
+ else {
10197
+ const judged = await judgeCopy(DATA_APPS_SKILL_NAME, copy, origin, deps);
10165
10198
  lines.push(judged.line);
10166
- current &&= judged.current;
10199
+ current = judged.current;
10167
10200
  }
10201
+ const legacy = installed.filter((entry) => isLegacyDataAppCopy(entry.name, entry.source));
10202
+ for (const entry of legacy) lines.push(`${entry.scope} ${entry.path}: replaced by ${DATA_APPS_SKILL_NAME}`);
10168
10203
  const detail = lines.join("\n");
10169
- if (current) return ok(id, label, detail);
10204
+ if (current && legacy.length === 0) return ok(id, label, detail);
10170
10205
  return failed(id, label, {
10171
10206
  detail,
10172
- hint: `\`rde init --only ${SKILL_STEP}\` installs them; then restart the agent session`,
10207
+ hint: `${dataAppSkillsFix({
10208
+ current,
10209
+ legacy: legacy.length > 0
10210
+ })}; then restart the agent session`,
10173
10211
  step: SKILL_STEP
10174
10212
  });
10175
10213
  }
10176
10214
  async function skillRows(state, deps) {
10177
- const label = "rde skill";
10215
+ const label = `${RDE_SKILL_NAME} skill`;
10178
10216
  if (state === null) return [skipped("skill", label, NO_INSTANCE_REASON)];
10179
10217
  if (state.steps[SKILL_STEP]?.status === "skipped") return [skipped("skill", label, `skipped during init; \`rde init --only ${SKILL_STEP}\``)];
10180
10218
  const detected = (await detectAgents(deps.probe)).filter((agent) => agent.detected);
10181
10219
  if (detected.length === 0) return [skipped("skill", label, "no coding agents detected")];
10182
10220
  const installed = await skillsCli(deps.runner).list();
10183
- const entries = installed.filter((entry) => entry.name === "rde");
10184
- const copies = await skillCopiesRow(entries, deps);
10185
- const dataApps = await dataAppSkillsRow(state, installed, deps);
10221
+ const entries = installed.filter((entry) => entry.name === RDE_SKILL_NAME);
10222
+ const copies = await skillCopiesRow(entries, installed.filter((entry) => isReplacedRdeCopy(entry.name)), deps);
10223
+ const dataApps = await dataAppSkillsRow(installed, deps);
10186
10224
  if (detected.filter((agent) => entries.some((entry) => skillCoversAgent(entry, agent))).length === 0) return [
10187
10225
  failed("skill", label, {
10188
10226
  detail: `not installed for ${detected.map((agent) => agent.displayName).join(", ")}`,
@@ -10642,6 +10680,10 @@ const init = defineRdeCommand({
10642
10680
  type: "string",
10643
10681
  description: "Database to connect, a connection string or <engine>=<connection string> when it fits several engines; repeatable. init needs one, and asks for it only at a terminal"
10644
10682
  },
10683
+ "database-name": {
10684
+ type: "string",
10685
+ description: "Display name of the --database in the same position, such as the name the production Metabase uses; repeatable"
10686
+ },
10645
10687
  "git-dir": {
10646
10688
  type: "string",
10647
10689
  description: "Directory for the content repository"
@@ -10699,6 +10741,7 @@ const init = defineRdeCommand({
10699
10741
  licenseToken: args["license-token"],
10700
10742
  skipLicense: args["skip-license"],
10701
10743
  database: repeatedValues(rawArgs, "--database"),
10744
+ databaseName: repeatedValues(rawArgs, "--database-name"),
10702
10745
  gitDir: args["git-dir"],
10703
10746
  gitRemote: args["git-remote"],
10704
10747
  gitUrl: args["git-url"],
@@ -11264,17 +11307,22 @@ async function findMbProfiles(candidates, urls, runner) {
11264
11307
  }
11265
11308
  return found;
11266
11309
  }
11310
+ function spokenList(items) {
11311
+ const last = items.at(-1);
11312
+ if (last === void 0 || items.length <= 2) return items.join(" and ");
11313
+ return `${items.slice(0, -1).join(", ")}, and ${last}`;
11314
+ }
11267
11315
  function skillLabel(names, entries) {
11268
11316
  const agents = [...new Set(entries.flatMap((entry) => entry.agents))];
11269
11317
  const noun = names.length === 1 ? "skill" : "skills";
11270
- return `${names.join(" and ")} ${noun} for ${agents.join(", ")}`;
11318
+ return `${spokenList(names)} ${noun} for ${agents.join(", ")}`;
11271
11319
  }
11320
+ const OWNED_SKILL_NAMES = RDE_OWNED_SKILL_NAMES;
11272
11321
  async function findSkill(runner, childOutput) {
11273
11322
  const skills = skillsCli(runner);
11274
- const owned = RDE_SOURCE_SKILL_NAMES;
11275
- const entries = (await skills.list()).filter((entry) => owned.includes(entry.name));
11323
+ const entries = (await skills.list()).filter((entry) => OWNED_SKILL_NAMES.includes(entry.name));
11276
11324
  if (entries.length === 0) return null;
11277
- const names = [...new Set(entries.map((entry) => entry.name))];
11325
+ const names = OWNED_SKILL_NAMES.filter((name) => entries.some((entry) => entry.name === name));
11278
11326
  return {
11279
11327
  label: skillLabel(names, entries),
11280
11328
  remove: () => skills.remove(names, childOutput)
@@ -11376,7 +11424,7 @@ function keptOnDisk(options, state, dirPresent) {
11376
11424
  const kept = [];
11377
11425
  if (options.keepData && dirPresent) kept.push(`Instance directory ${instanceDir(options.instance)} (--keep-data); \`rde init\` reinstalls from it`);
11378
11426
  if (state?.gitRemote !== null && state?.gitRemote !== void 0) kept.push(`Working directory ${state.gitRemote.workdir} and its git history`);
11379
- if (!options.removeSkill) kept.push(`The ${RDE_SOURCE_SKILL_NAMES.join(" and ")} skills for your agents; pass --remove-skill to remove them`);
11427
+ if (!options.removeSkill) kept.push(`The ${spokenList(RDE_INSTALLED_SKILL_NAMES)} skills for your agents; pass --remove-skill to remove them`);
11380
11428
  return kept;
11381
11429
  }
11382
11430
  async function planUninstall(options, deps) {
@@ -11535,7 +11583,7 @@ const uninstallCommand = defineRdeCommand({
11535
11583
  },
11536
11584
  "remove-skill": {
11537
11585
  type: "boolean",
11538
- description: "Also remove the rde and rde-data-apps skills from your coding agents",
11586
+ description: "Also remove the metabase-data-apps and metabase-rde skills, and the rde and rde-data-apps skills they replace, from your coding agents",
11539
11587
  default: false
11540
11588
  },
11541
11589
  "remove-image": {
@@ -11857,7 +11905,7 @@ async function upgrade(ctx, options, deps = hostDeps) {
11857
11905
  const backup = join(instanceBackupsDir(ctx.state.name), backupStamp(resolved.now()));
11858
11906
  ctx.ui.note(planLines(configured, current, target, backup).join("\n"), "rde upgrade");
11859
11907
  const announced = majorMove(current, target.releaseTag);
11860
- if (announced !== null) ctx.ui.warn(`This moves Metabase from ${announced.from} to ${announced.to}, a new major version; the data-app skills follow it after the upgrade`);
11908
+ if (announced !== null) ctx.ui.warn(`This moves Metabase from ${announced.from} to ${announced.to}, a new major version; rde updates your coding agents' skills after the upgrade`);
11861
11909
  if (!options.yes) {
11862
11910
  if (!await ctx.prompts.confirm({
11863
11911
  message: "Upgrade now?",
@@ -11880,7 +11928,7 @@ async function upgrade(ctx, options, deps = hostDeps) {
11880
11928
  ctx.ui.success(`Upgraded Metabase from ${current} to ${to}; the backup at ${backup} can go once you are happy with it`);
11881
11929
  const moved = majorMove(current, to);
11882
11930
  if (moved !== null) {
11883
- if (announced === null) ctx.ui.warn(`This moved Metabase from ${moved.from} to ${moved.to}, a new major version; the data-app skills follow it`);
11931
+ if (announced === null) ctx.ui.warn(`This moved Metabase from ${moved.from} to ${moved.to}, a new major version`);
11884
11932
  await runWizard([resolved.skills(options.yes ? DETECT_AGENTS : ASK_AGENTS)], ctx);
11885
11933
  }
11886
11934
  return {
package/package.json CHANGED
@@ -1,8 +1,17 @@
1
1
  {
2
2
  "name": "@metabase/rde",
3
- "version": "0.1.18",
3
+ "version": "0.5.1",
4
4
  "description": "Prepares a local machine for data-engineering work with Metabase and coding agents",
5
+ "keywords": [
6
+ "analytics",
7
+ "claude-code",
8
+ "cli",
9
+ "coding-agents",
10
+ "data-engineering",
11
+ "metabase"
12
+ ],
5
13
  "license": "AGPL-3.0",
14
+ "author": "Metabase",
6
15
  "bin": {
7
16
  "rde": "dist/cli.mjs"
8
17
  },
@@ -31,7 +40,8 @@
31
40
  "test:e2e": "vitest run --project e2e --reporter=verbose tests/e2e/init.e2e.test.ts tests/e2e/mb-profiles.e2e.test.ts",
32
41
  "test:e2e:databases": "vitest run --project e2e --reporter=verbose tests/e2e/databases.e2e.test.ts",
33
42
  "check": "bun run typecheck && bun run lint && bun run format:check && bun run test",
34
- "check:fix": "bun run lint:fix && bun run format"
43
+ "check:fix": "bun run lint:fix && bun run format",
44
+ "prepublishOnly": "bun run build"
35
45
  },
36
46
  "dependencies": {
37
47
  "@clack/prompts": "^0.8.2",