@mercury-fw/cli 0.28.4 → 0.29.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @mercury-fw/cli
2
2
 
3
+ ## 0.29.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [60916cb]
8
+ - @mercury-fw/core@0.29.1
9
+
10
+ ## 0.29.0
11
+
12
+ ### Minor Changes
13
+
14
+ - e498dad: `mfw` is meant to be installed globally (`bun add -g @mercury-fw/cli`) and used as a plain command. Inside an app, a global `mfw` at another version hands the command over to the app's own CLI, so the app's commands always match its framework. `mfw upgrade` installs the registry's latest globally, and `mfw create` run by a stale `mfw` says to. The CLI's messages, the generated app and the docs say `mfw` instead of `bunx mfw`.
15
+
16
+ ### Patch Changes
17
+
18
+ - @mercury-fw/core@0.29.0
19
+
3
20
  ## 0.28.4
4
21
 
5
22
  ### Patch Changes
package/README.md CHANGED
@@ -20,17 +20,21 @@
20
20
  - [`mfw credentials set <plugin> [--from <dir>] [--print]`](#mfw-credentials-set-plugin---from-dir---print)
21
21
  - [`mfw credentials reset <plugin>`](#mfw-credentials-reset-plugin)
22
22
  - [`mfw google-chat set-key <key-file> [--subscription <name>]`](#mfw-google-chat-set-key-key-file---subscription-name)
23
+ - [`mfw upgrade`](#mfw-upgrade)
23
24
  - [Help](#help)
24
25
 
25
26
  ## Getting it
26
27
 
27
- A new app comes from `bun create mercury-agent`, which is `mfw create` under the `create` convention:
28
+ Install it once, globally, and `mfw` works from anywhere (Bun puts it in `~/.bun/bin`, which its installer adds to the `PATH`):
28
29
 
29
30
  ```bash
30
- bun create mercury-agent my-agent
31
+ bun add -g @mercury-fw/cli
32
+ mfw create my-agent
31
33
  ```
32
34
 
33
- The app it writes lists `@mercury-fw/cli` among its devDependencies, at the same version as the framework it depends on, so after `bun install` in the app every other command runs as `bunx mfw <command>`, with no global install.
35
+ `mfw upgrade` keeps it current. Every app also lists `@mercury-fw/cli` among its devDependencies, at the framework's version, and inside an app the global `mfw` hands its commands over to that one when the two differ (it says so in one line; `mfw --version` and `mfw --help` stay the global one's), so an app's commands always match the framework it runs, whatever version is installed globally. An app not installed yet (no `bun install`) runs on the global one.
36
+
37
+ Without a global install: `bun create mercury-agent my-agent` creates an app, and `bunx mfw <command>` runs the app's own CLI from inside it.
34
38
 
35
39
  ## Usage
36
40
 
@@ -54,8 +58,8 @@ It writes the `mercury.config.ts` for that selection, the persona, the service a
54
58
  | `-y`, `--yes` | No questions: the flags, and the defaults for the rest. |
55
59
 
56
60
  ```bash
57
- bun create mercury-agent my-agent
58
- bunx @mercury-fw/cli create my-agent --assistant-name Hermes --channels http --plugins jira --yes
61
+ mfw create my-agent
62
+ mfw create my-agent --assistant-name Hermes --channels http --plugins jira --yes
59
63
  ```
60
64
 
61
65
  The framework packages get the CLI's own version (they're released together); each chosen plugin or channel gets its latest version on the registry, `https://registry.npmjs.org` unless `MFW_REGISTRY` names another.
@@ -67,8 +71,8 @@ Before anything else it asks the registry for the latest `@mercury-fw/cli`: a ne
67
71
  Builds the app's image and starts the app and Qdrant in the background (`docker compose up -d --build`). Docker's cache means only what changed gets rebuilt, and a running container is recreated only if its image or configuration changed, so it's also the command to run after changing a dependency, the Dockerfile or `.env`. `--no-cache` rebuilds everything from scratch (`docker compose build --no-cache`, then `up -d`), which is what refetches a tool plugin's CLI binary when a new release is out: a normal build keeps the cached one.
68
72
 
69
73
  ```bash
70
- bunx mfw start
71
- bunx mfw start --no-cache
74
+ mfw start
75
+ mfw start --no-cache
72
76
  ```
73
77
 
74
78
  ### `mfw stop`
@@ -76,7 +80,7 @@ bunx mfw start --no-cache
76
80
  Stops the app and Qdrant and removes their containers (`docker compose down`). The volumes stay, so memory, the wiki and the CLI credentials are all there on the next start.
77
81
 
78
82
  ```bash
79
- bunx mfw stop
83
+ mfw stop
80
84
  ```
81
85
 
82
86
  ### `mfw restart [--no-cache]`
@@ -84,8 +88,8 @@ bunx mfw stop
84
88
  Like `start`, but recreates the containers even when nothing changed (`--force-recreate`): a clean restart, for a process that got stuck. A changed `.env` doesn't need it, `start` already recreates what the change touches. `--no-cache` as in `start`.
85
89
 
86
90
  ```bash
87
- bunx mfw restart
88
- bunx mfw restart --no-cache
91
+ mfw restart
92
+ mfw restart --no-cache
89
93
  ```
90
94
 
91
95
  ### `mfw logs [service]`
@@ -93,8 +97,8 @@ bunx mfw restart --no-cache
93
97
  Follows the logs of every service, interleaved, or only of the one you name, `mercury` or `qdrant` (`docker compose logs -f`). One service at most. `Ctrl+C` stops following, the app keeps running.
94
98
 
95
99
  ```bash
96
- bunx mfw logs
97
- bunx mfw logs mercury
100
+ mfw logs
101
+ mfw logs mercury
98
102
  ```
99
103
 
100
104
  ### `mfw repl`
@@ -102,7 +106,7 @@ bunx mfw logs mercury
102
106
  Opens the dev REPL, a conversation with the assistant in the terminal, in a one-off container (`docker compose run --rm mercury bun run repl`). The REPL has no user identity by design, so what you try there never lands in anyone's memory; ending it (`Ctrl+D`) removes the one-off container and leaves a running app alone.
103
107
 
104
108
  ```bash
105
- bunx mfw repl
109
+ mfw repl
106
110
  ```
107
111
 
108
112
  ### `mfw shell`
@@ -110,7 +114,7 @@ bunx mfw repl
110
114
  Opens a shell in the app's container: the running one if the app is up (`docker compose exec mercury bash`), a one-off one otherwise (`docker compose run --rm mercury bash`). The tool plugins' CLIs are on `PATH` there with their credentials, so it's where you check that a command works before blaming the model.
111
115
 
112
116
  ```bash
113
- bunx mfw shell
117
+ mfw shell
114
118
  ```
115
119
 
116
120
  ### `mfw vault <command>`
@@ -126,10 +130,10 @@ Maintains the wiki vault, which lives on a Docker volume and not in the app's fo
126
130
  | `write-raw <path>` | Writes raw material for the nightly review to triage, the body read from stdin. |
127
131
 
128
132
  ```bash
129
- bunx mfw vault list
130
- bunx mfw vault read curated/standards/jira-fields.md
131
- bunx mfw vault grep "story points"
132
- cat note.md | bunx mfw vault write-curated curated/standards/new-note.md --author luca
133
+ mfw vault list
134
+ mfw vault read curated/standards/jira-fields.md
135
+ mfw vault grep "story points"
136
+ cat note.md | mfw vault write-curated curated/standards/new-note.md --author luca
133
137
  ```
134
138
 
135
139
  There's no command writing inferred notes on purpose: those are the agent's own, written only by its consolidation.
@@ -139,7 +143,7 @@ There's no command writing inferred notes on purpose: those are the agent's own,
139
143
  Lists the collections of the memory on Qdrant with how many points each holds, in a one-off container that reaches Qdrant the way the app does. Read-only.
140
144
 
141
145
  ```bash
142
- bunx mfw memory list
146
+ mfw memory list
143
147
  ```
144
148
 
145
149
  ```
@@ -154,19 +158,19 @@ verbatim_archive 0 points
154
158
  Prints a collection's points, each as its id followed by one line per payload field. Newest first where the collection has a timestamp index (episodic memory and the verbatim archive do); in Qdrant's own order otherwise, and it says so. `--limit` defaults to 20. Read-only.
155
159
 
156
160
  ```bash
157
- bunx mfw memory read episodic_memory
158
- bunx mfw memory read semantic_facts --limit 5
161
+ mfw memory read episodic_memory
162
+ mfw memory read semantic_facts --limit 5
159
163
  ```
160
164
 
161
165
  ### `mfw reset <memory|wiki>`
162
166
 
163
167
  Deletes for good what the assistant remembers: `memory` is every collection on Qdrant, `wiki` the whole vault. It reads the volume's real name from the compose file, tells you which one is about to go, and asks you to type the app's name (the `name` in `package.json`); anything else, an empty answer, a `y` or closing the input included, deletes nothing and exits 1. Once confirmed it stops the service using the volume, removes its container and the volume, and starts the service again on an empty one (`docker compose stop`, `rm -f`, `docker volume rm`, `up -d`). After `memory`, a running app is restarted too (`docker compose restart mercury`), since it sets up its collections only when it starts.
164
168
 
165
- If a step fails once the service is stopped (the volume still in use by a one-off container, say), it stops there and says the service is down: `bunx mfw start` brings it back.
169
+ If a step fails once the service is stopped (the volume still in use by a one-off container, say), it stops there and says the service is down: `mfw start` brings it back.
166
170
 
167
171
  ```bash
168
- bunx mfw reset memory
169
- bunx mfw reset wiki
172
+ mfw reset memory
173
+ mfw reset wiki
170
174
  ```
171
175
 
172
176
  Useful for clearing out test data; the other layer isn't touched.
@@ -178,9 +182,9 @@ Hands a tool plugin's CLI its login. Every such CLI keeps it in a config folder
178
182
  When the container starts, the app's `docker-entrypoint.sh` unpacks the variable onto the credentials volume, but only if that CLI's folder isn't there yet: what the CLI writes back while running, like a refreshed token, stays on the volume across redeploys, and an older value in `.env` never overwrites it. `<plugin>` has to be a tool plugin the app depends on.
179
183
 
180
184
  ```bash
181
- bunx mfw credentials set jira
182
- bunx mfw credentials set bitbucket --from ~/work/bitbucket-login
183
- bunx mfw credentials set jira --print
185
+ mfw credentials set jira
186
+ mfw credentials set bitbucket --from ~/work/bitbucket-login
187
+ mfw credentials set jira --print
184
188
  ```
185
189
 
186
190
  ### `mfw credentials reset <plugin>`
@@ -188,18 +192,26 @@ bunx mfw credentials set jira --print
188
192
  Deletes the plugin's CLI folder from the credentials volume, so the variable in `.env` is unpacked again at the next start: what to run after correcting a variable whose folder is already on the volume, since the entrypoint never touches an existing folder. It asks you to type the plugin's name first, because a token the CLI refreshed on the volume goes too (and with a CLI that rotates its refresh token, the one in `.env` may no longer work). Once confirmed it stops the app, removes the folder in a one-off container of the app's own image, and starts the app again (`docker compose stop mercury`, `run --rm --no-deps -T mercury rm -rf …`, `up -d mercury`).
189
193
 
190
194
  ```bash
191
- bunx mfw credentials reset jira
195
+ mfw credentials reset jira
192
196
  ```
193
197
 
194
198
  ### `mfw google-chat set-key <key-file> [--subscription <name>]`
195
199
 
196
200
  Writes the Google Chat channel's credentials into the app's `.env`, from the service account's JSON key (the file `gcloud iam service-accounts keys create` writes): `GOOGLE_CHAT_APP_CLIENT_EMAIL`, and `GOOGLE_CHAT_APP_PRIVATE_KEY` on one line with literal `\n`, the way the channel reads it. With `--subscription` (`projects/<project>/subscriptions/<name>`) it sets `GOOGLE_CHAT_PUBSUB_SUBSCRIPTION` too; without it, that line stays as it is, which is what you want when only the key changes. Older values and the empty lines `mfw create` leaves are replaced, the other lines stay alone, and the key is never printed.
197
201
 
198
- It checks everything before writing: the app has to depend on `@mercury-fw/channel-google-chat`, the file has to be a service account key and the subscription has to have that shape, otherwise it exits 1 saying why and `.env` stays as it was. Delete the key file afterwards; `bunx mfw start` applies the change to a running app. The whole setup of the Chat app is in the [channel's README](https://github.com/lucabro81/mercury-fw/tree/main/packages/channels/channel-google-chat#setting-up-the-chat-app).
202
+ It checks everything before writing: the app has to depend on `@mercury-fw/channel-google-chat`, the file has to be a service account key and the subscription has to have that shape, otherwise it exits 1 saying why and `.env` stays as it was. Delete the key file afterwards; `mfw start` applies the change to a running app. The whole setup of the Chat app is in the [channel's README](https://github.com/lucabro81/mercury-fw/tree/main/packages/channels/channel-google-chat#setting-up-the-chat-app).
203
+
204
+ ```bash
205
+ mfw google-chat set-key key.json --subscription projects/my-project/subscriptions/mercury-chat-sub
206
+ mfw google-chat set-key new-key.json
207
+ ```
208
+
209
+ ### `mfw upgrade`
210
+
211
+ Installs the registry's latest `mfw` globally (`bun add -g @mercury-fw/cli@<latest>`) when it's newer than the one running, and says it's already the latest otherwise; a registry that doesn't answer exits 1. It's about the global `mfw` only: an app's framework, its own CLI included, moves with `bun update` in the app. `mfw create` run by a stale global `mfw` creates the app with the latest one anyway, and says to run this.
199
212
 
200
213
  ```bash
201
- bunx mfw google-chat set-key key.json --subscription projects/my-project/subscriptions/mercury-chat-sub
202
- bunx mfw google-chat set-key new-key.json
214
+ mfw upgrade
203
215
  ```
204
216
 
205
217
  ## Help
@@ -1,13 +1,16 @@
1
1
  import { type AppDeps } from "./app/commands.ts";
2
2
  /** Runs `argv` with stdio inherited and `env` on top of this process's
3
3
  * environment; returns its exit code. */
4
- export type Relaunch = (argv: string[], env: Record<string, string>) => Promise<number>;
4
+ export type Relaunch = (argv: string[], env: Record<string, string>, opts?: {
5
+ quiet?: boolean;
6
+ }) => Promise<number>;
5
7
  /** Runs `mfw` with `argv` (the arguments after the command name) and returns
6
8
  * the exit code, printing to stdout/stderr. `bin.ts` and `create-mercury-agent`
7
9
  * both call it; the app commands look for the app from `cwd` and run docker
8
10
  * through `deps`; `relaunch` is how `create` hands over to a newer CLI. */
9
- export declare function main(argv: string[], { cwd, deps, relaunch }?: {
11
+ export declare function main(argv: string[], { cwd, deps, relaunch, globalInstall, }?: {
10
12
  cwd?: string;
11
13
  deps?: AppDeps;
12
14
  relaunch?: Relaunch;
15
+ globalInstall?: boolean;
13
16
  }): Promise<number>;
@@ -13,6 +13,8 @@ export type AppCommands = ReturnType<typeof appCommands>;
13
13
  export type ProgramHandlers = {
14
14
  /** `mfw create`; returns the exit code. */
15
15
  create: (args: CreateArgs) => Promise<number>;
16
+ /** `mfw upgrade`; returns the exit code. */
17
+ upgrade: () => Promise<number>;
16
18
  /** The app the app commands act on; throws outside one. */
17
19
  app: () => AppCommands;
18
20
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/cli",
3
- "version": "0.28.4",
3
+ "version": "0.29.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,16 +36,16 @@
36
36
  "comment:shape": "The Mercury CLI (`mfw`). `mfw create <dir>` writes a new Mercury app from the template. The framework packages move in lockstep with it, so a new app gets them at the CLI's own version; plugins and channels at the registry's latest. The plugin and channel packages are devDependencies only for the catalog test. `main(argv)` is exported for create-mercury-agent.",
37
37
  "dependencies": {
38
38
  "@clack/prompts": "^1.8.1",
39
- "@mercury-fw/core": "0.28.4",
39
+ "@mercury-fw/core": "0.29.1",
40
40
  "commander": "^15.0.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@mercury-fw/channel-google-chat": "0.1.2",
44
44
  "@mercury-fw/channel-http": "0.1.0",
45
- "@mercury-fw/formatter": "0.28.4",
45
+ "@mercury-fw/formatter": "0.29.1",
46
46
  "@mercury-fw/plugin-atlassian-admin": "0.1.0",
47
47
  "@mercury-fw/plugin-bitbucket": "0.1.0",
48
- "@mercury-fw/plugin-jira": "0.1.1",
48
+ "@mercury-fw/plugin-jira": "0.1.2",
49
49
  "@mercury-fw/typescript-config": "*",
50
50
  "@types/bun": "^1.4.2",
51
51
  "typescript": "^6.0.3"
@@ -128,7 +128,7 @@ export function appCommands(app: App, deps: AppDeps) {
128
128
  setEnvVar(envFile, variable, value);
129
129
  deps.print(`${variable} set in ${envFile}, from ${source}.`);
130
130
  deps.print(
131
- `The app unpacks it at its next start, if the volume has no ${folder} folder yet; if it has one, run bunx mfw credentials reset ${plugin} first.`,
131
+ `The app unpacks it at its next start, if the volume has no ${folder} folder yet; if it has one, run mfw credentials reset ${plugin} first.`,
132
132
  );
133
133
  return 0;
134
134
  },
@@ -171,7 +171,7 @@ export function appCommands(app: App, deps: AppDeps) {
171
171
  ? "GOOGLE_CHAT_APP_CLIENT_EMAIL and GOOGLE_CHAT_APP_PRIVATE_KEY"
172
172
  : "GOOGLE_CHAT_APP_CLIENT_EMAIL, GOOGLE_CHAT_APP_PRIVATE_KEY and GOOGLE_CHAT_PUBSUB_SUBSCRIPTION";
173
173
  deps.print(`${written} set in ${envFile}, from ${source}.`);
174
- deps.print(`Delete ${source} now, the env file holds the key. bunx mfw start applies it to a running app.`);
174
+ deps.print(`Delete ${source} now, the env file holds the key. mfw start applies it to a running app.`);
175
175
  return 0;
176
176
  },
177
177
  };
@@ -184,7 +184,7 @@ export function appCommands(app: App, deps: AppDeps) {
184
184
  for (const [i, argv] of all.entries()) {
185
185
  const code = await deps.run(argv, { cwd: app.dir });
186
186
  if (code === 0) continue;
187
- if (i > 0) deps.print(`The ${service} service was stopped and not restarted: bunx mfw start brings it back.`);
187
+ if (i > 0) deps.print(`The ${service} service was stopped and not restarted: mfw start brings it back.`);
188
188
  return code;
189
189
  }
190
190
  return 0;
package/src/main.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * commands operate an existing app from inside its folder (see
6
6
  * `app/commands.ts`). The command line itself is declared in `program.ts`.
7
7
  */
8
+ import { existsSync, readFileSync } from "node:fs";
8
9
  import { basename, dirname, join, resolve } from "node:path";
9
10
  import type { CreateArgs } from "./args.ts";
10
11
  import { CATALOG } from "./catalog.ts";
@@ -13,7 +14,7 @@ import { runProgram } from "./program.ts";
13
14
  import { renderApp, selectionError } from "./render.ts";
14
15
  import { appVersions, cliVersion, newerCli, registryFrom } from "./versions.ts";
15
16
  import { appCommands, terminalDeps, type AppDeps } from "./app/commands.ts";
16
- import { findApp } from "./app/find-app.ts";
17
+ import { findApp, type App } from "./app/find-app.ts";
17
18
  import { askAnswers, DEFAULT_ASSISTANT_NAME, DEFAULT_ROLE, type Answers } from "./wizard.ts";
18
19
  import { targetError, writeApp } from "./write.ts";
19
20
 
@@ -30,14 +31,30 @@ function answersFromFlags(args: CreateArgs, defaultName: string): Answers {
30
31
 
31
32
  /** Runs `argv` with stdio inherited and `env` on top of this process's
32
33
  * environment; returns its exit code. */
33
- export type Relaunch = (argv: string[], env: Record<string, string>) => Promise<number>;
34
+ export type Relaunch = (argv: string[], env: Record<string, string>, opts?: { quiet?: boolean }) => Promise<number>;
34
35
 
35
- /** The real relaunch: a child process on the user's terminal. */
36
- const spawnRelaunch: Relaunch = async (argv, env) => {
37
- const proc = Bun.spawn(argv, { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: { ...process.env, ...env } });
38
- return await proc.exited;
36
+ /** The real relaunch: a child process on the user's terminal (its output
37
+ * dropped with `quiet`). While it runs, Ctrl+C is the child's to handle: this
38
+ * process ignores SIGINT, so it doesn't exit ahead of the child and lose its
39
+ * exit code. */
40
+ const spawnRelaunch: Relaunch = async (argv, env, opts) => {
41
+ const output = opts?.quiet ? "ignore" : "inherit";
42
+ const proc = Bun.spawn(argv, { stdin: "inherit", stdout: output, stderr: output, env: { ...process.env, ...env } });
43
+ const ignore = () => {};
44
+ process.on("SIGINT", ignore);
45
+ try {
46
+ return await proc.exited;
47
+ } finally {
48
+ process.off("SIGINT", ignore);
49
+ }
39
50
  };
40
51
 
52
+ /** Whether this CLI runs from Bun's global install (`bun add -g`), the one
53
+ * `mfw upgrade` updates, rather than from `bunx`'s cache or an app. */
54
+ function isGlobalInstall(): boolean {
55
+ return /[\\/]install[\\/]global[\\/]node_modules[\\/]/.test(import.meta.dir);
56
+ }
57
+
41
58
  /** When the registry has a newer `@mercury-fw/cli` than this one (a stale copy
42
59
  * out of Bun's bunx cache), installs it (`bunx … --version`), then runs
43
60
  * `create` again through it with the same arguments as typed and returns its
@@ -45,7 +62,7 @@ const spawnRelaunch: Relaunch = async (argv, env) => {
45
62
  * carry on here: no newer CLI, a check skipped inside a relaunch
46
63
  * (`MFW_SELF_UPDATED`), a registry that can't answer, or a newer CLI that
47
64
  * can't be installed; the last two with a warning. */
48
- async function relaunchIfStale(rawArgs: string[], relaunch: Relaunch): Promise<number | undefined> {
65
+ async function relaunchIfStale(rawArgs: string[], relaunch: Relaunch, globalInstall: boolean): Promise<number | undefined> {
49
66
  if (process.env.MFW_SELF_UPDATED) return undefined;
50
67
  const registry = registryFrom(process.env.MFW_REGISTRY).replace(/\/+$/, "");
51
68
  let newer: string | undefined;
@@ -60,7 +77,7 @@ async function relaunchIfStale(rawArgs: string[], relaunch: Relaunch): Promise<n
60
77
  const env = { MFW_SELF_UPDATED: newer, NPM_CONFIG_REGISTRY: registry };
61
78
  let installed: number;
62
79
  try {
63
- installed = await relaunch(["bunx", cli, "--version"], env);
80
+ installed = await relaunch(["bunx", cli, "--version"], env, { quiet: true });
64
81
  } catch {
65
82
  installed = -1;
66
83
  }
@@ -69,13 +86,14 @@ async function relaunchIfStale(rawArgs: string[], relaunch: Relaunch): Promise<n
69
86
  return undefined;
70
87
  }
71
88
  console.error(`mfw ${cliVersion()} is behind the registry's ${newer}: running ${newer} instead.`);
89
+ if (globalInstall) console.error("update your mfw: mfw upgrade");
72
90
  return relaunch(["bunx", cli, "create", ...rawArgs], env);
73
91
  }
74
92
 
75
93
  /** `mfw create`: returns the exit code. `rawArgs` are the arguments after
76
94
  * `create` as typed, for a relaunch. */
77
- async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch): Promise<number> {
78
- const relaunched = await relaunchIfStale(rawArgs, relaunch);
95
+ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, globalInstall: boolean): Promise<number> {
96
+ const relaunched = await relaunchIfStale(rawArgs, relaunch, globalInstall);
79
97
  if (relaunched !== undefined) return relaunched;
80
98
  // The folder is created in kebab case, only its own name: the parent path is
81
99
  // taken as typed. Its name is also the app name's default.
@@ -106,21 +124,90 @@ Next:
106
124
  cd ${dir}
107
125
  bun install
108
126
  cp .env.example .env # then fill it in
109
- bunx mfw start`);
127
+ mfw start`);
110
128
  return 0;
111
129
  }
112
130
 
131
+ /** `mfw upgrade`: installs the registry's latest `@mercury-fw/cli` globally
132
+ * when it's newer than this one; returns the install's exit code, 0 when
133
+ * there's nothing newer, 1 when the registry can't answer. */
134
+ async function upgrade(relaunch: Relaunch): Promise<number> {
135
+ const registry = registryFrom(process.env.MFW_REGISTRY).replace(/\/+$/, "");
136
+ let newer: string | undefined;
137
+ try {
138
+ newer = await newerCli({ registry });
139
+ } catch (err) {
140
+ console.error(`couldn't check for a newer mfw: ${err instanceof Error ? err.message : String(err)}`);
141
+ return 1;
142
+ }
143
+ if (newer === undefined) {
144
+ console.log(`mfw ${cliVersion()} is the latest.`);
145
+ return 0;
146
+ }
147
+ const code = await relaunch(["bun", "add", "-g", `@mercury-fw/cli@${newer}`], { NPM_CONFIG_REGISTRY: registry });
148
+ if (code === 0) console.log(`mfw upgraded from ${cliVersion()} to ${newer}.`);
149
+ return code;
150
+ }
151
+
152
+ /** The commands that belong to the CLI itself, wherever it runs: never
153
+ * handed over to an app's CLI. */
154
+ const OWN_COMMANDS = new Set(["create", "upgrade"]);
155
+
156
+ /** Inside an app whose own CLI (its devDependency) is installed at another
157
+ * version than this one, as a global `mfw` can be, runs `argv` through that
158
+ * CLI and returns its exit code: the app's commands then always match the
159
+ * framework the app runs. `undefined` means run here: not an app command, not
160
+ * inside an app, the app not installed yet, the same version, or already
161
+ * handed over (`MFW_DEFERRED`). */
162
+ async function handOverToAppCli(argv: string[], cwd: string, relaunch: Relaunch): Promise<number | undefined> {
163
+ const command = argv[0];
164
+ if (command === undefined || command.startsWith("-") || OWN_COMMANDS.has(command)) return undefined;
165
+ if (process.env.MFW_DEFERRED) return undefined;
166
+ let app: App;
167
+ try {
168
+ app = findApp(cwd);
169
+ } catch {
170
+ return undefined;
171
+ }
172
+ const local = join(app.dir, "node_modules", "@mercury-fw", "cli");
173
+ const manifest = join(local, "package.json");
174
+ if (!existsSync(manifest)) return undefined;
175
+ let version: unknown;
176
+ try {
177
+ version = (JSON.parse(readFileSync(manifest, "utf-8")) as { version?: unknown } | null)?.version;
178
+ } catch (err) {
179
+ console.error(`couldn't read the app's mfw (${manifest}): ${err instanceof Error ? err.message : String(err)}; running mfw ${cliVersion()}`);
180
+ return undefined;
181
+ }
182
+ if (typeof version !== "string" || version === cliVersion()) return undefined;
183
+ console.error(`mfw ${cliVersion()}: running the app's ${version}`);
184
+ try {
185
+ return await relaunch(["bun", join(local, "src", "bin.ts"), ...argv], { MFW_DEFERRED: "1" });
186
+ } catch (err) {
187
+ console.error(`couldn't run the app's mfw: ${err instanceof Error ? err.message : String(err)}; running mfw ${cliVersion()}`);
188
+ return undefined;
189
+ }
190
+ }
191
+
113
192
  /** Runs `mfw` with `argv` (the arguments after the command name) and returns
114
193
  * the exit code, printing to stdout/stderr. `bin.ts` and `create-mercury-agent`
115
194
  * both call it; the app commands look for the app from `cwd` and run docker
116
195
  * through `deps`; `relaunch` is how `create` hands over to a newer CLI. */
117
196
  export async function main(
118
197
  argv: string[],
119
- { cwd = process.cwd(), deps, relaunch = spawnRelaunch }: { cwd?: string; deps?: AppDeps; relaunch?: Relaunch } = {},
198
+ {
199
+ cwd = process.cwd(),
200
+ deps,
201
+ relaunch = spawnRelaunch,
202
+ globalInstall = isGlobalInstall(),
203
+ }: { cwd?: string; deps?: AppDeps; relaunch?: Relaunch; globalInstall?: boolean } = {},
120
204
  ): Promise<number> {
205
+ const handedOver = await handOverToAppCli(argv, cwd, relaunch);
206
+ if (handedOver !== undefined) return handedOver;
121
207
  const rawCreateArgs = argv.slice(argv.indexOf("create") + 1);
122
208
  return runProgram(argv, {
123
- create: (args) => create(args, rawCreateArgs, relaunch),
209
+ create: (args) => create(args, rawCreateArgs, relaunch, globalInstall),
210
+ upgrade: () => upgrade(relaunch),
124
211
  app: () => appCommands(findApp(cwd), deps ?? terminalDeps()),
125
212
  });
126
213
  }
package/src/program.ts CHANGED
@@ -18,6 +18,8 @@ export type AppCommands = ReturnType<typeof appCommands>;
18
18
  export type ProgramHandlers = {
19
19
  /** `mfw create`; returns the exit code. */
20
20
  create: (args: CreateArgs) => Promise<number>;
21
+ /** `mfw upgrade`; returns the exit code. */
22
+ upgrade: () => Promise<number>;
21
23
  /** The app the app commands act on; throws outside one. */
22
24
  app: () => AppCommands;
23
25
  };
@@ -74,6 +76,16 @@ Examples:
74
76
  result.code = await handlers.create(toCreateArgs(folder, opts));
75
77
  });
76
78
 
79
+ program
80
+ .command("upgrade")
81
+ .summary("updates the global mfw")
82
+ .description(
83
+ "Installs the registry's latest mfw globally (bun add -g @mercury-fw/cli@<latest>), when it's newer than this one. An app's own framework, its CLI included, is upgraded with bun update in the app.",
84
+ )
85
+ .action(async () => {
86
+ result.code = await handlers.upgrade();
87
+ });
88
+
77
89
  // commander reads --no-cache as "cache: false"; the commands take noCache.
78
90
  const cacheOff = (opts: { cache: boolean }) => ({ noCache: !opts.cache });
79
91
 
package/src/render.ts CHANGED
@@ -113,10 +113,10 @@ function renderEntrypoint(tools: Array<CatalogEntry & { credentials: CliCredenti
113
113
  return `#!/usr/bin/env bash
114
114
  # Starts the service, first materializing each tool plugin's CLI credentials
115
115
  # onto the cli-credentials volume: its variable in the env file is the CLI's
116
- # config folder as a base64 tar.gz (bunx mfw credentials set <plugin> writes
116
+ # config folder as a base64 tar.gz (mfw credentials set <plugin> writes
117
117
  # it). Only when that CLI's folder isn't on the volume yet: what a CLI writes
118
118
  # back while running, like a refreshed token, stays there across redeploys,
119
- # and an older value in the env file never overwrites it. bunx mfw credentials
119
+ # and an older value in the env file never overwrites it. mfw credentials
120
120
  # reset <plugin> clears one folder so its variable is materialized again.
121
121
  set -euo pipefail
122
122
 
@@ -275,8 +275,8 @@ function renderPackageJson(
275
275
  private: true,
276
276
  scripts: { start: "bun src/index.ts", repl: "bun src/repl.ts", typecheck: "tsc --noEmit" },
277
277
  dependencies,
278
- // The CLI in the app itself, so `bunx mfw` runs the version that matches
279
- // the framework the app depends on.
278
+ // The CLI in the app itself, at the framework's version: a global `mfw`
279
+ // hands the app's commands over to it.
280
280
  devDependencies: { "@mercury-fw/cli": `^${cli}`, "@types/bun": "^1.4.2", typescript: "^6.0.3" },
281
281
  };
282
282
  const trusted = [...new Set([...tools.map((t) => t.package), ...[...channels, ...tools].flatMap((e) => e.trusts ?? [])])];
@@ -307,7 +307,7 @@ function renderEnv(channels: CatalogEntry[], tools: CatalogEntry[]): string {
307
307
  if (entry.credentials !== undefined) {
308
308
  vars.push({
309
309
  name: entry.credentials.variable,
310
- comment: `${entry.credentials.folder}'s config folder, packed: bunx mfw credentials set ${entry.id} writes it; materialized on the credentials volume at the first start without that folder`,
310
+ comment: `${entry.credentials.folder}'s config folder, packed: mfw credentials set ${entry.id} writes it; materialized on the credentials volume at the first start without that folder`,
311
311
  });
312
312
  }
313
313
  if (vars.length > 0) {
@@ -388,14 +388,22 @@ A Mercury app, scaffolded by \`mfw create\`.
388
388
 
389
389
  ## Running it
390
390
 
391
+ \`mfw\` is Mercury's command-line tool. Install it once, globally (\`mfw upgrade\` keeps it current), or put \`bunx\` in front of every command instead (\`bunx mfw start\`):
392
+
393
+ \`\`\`bash
394
+ bun add -g @mercury-fw/cli
395
+ \`\`\`
396
+
397
+ Then, in the app:
398
+
391
399
  \`\`\`bash
392
400
  bun install
393
401
  cp .env.example .env
394
- bunx mfw start
395
- bunx mfw repl
402
+ mfw start
403
+ mfw repl
396
404
  \`\`\`
397
405
 
398
- \`bun install\` here gives your editor, \`bun run typecheck\` and \`mfw\` the packages (tool plugins download their CLI binary as they install); the image installs its own copy when it builds. \`bunx mfw start\` builds the image and starts the app with Qdrant in the background, \`bunx mfw repl\` opens a terminal conversation with the assistant. \`bunx mfw --help\` lists the rest: stopping and restarting, logs, a shell in the container, the wiki and the memory, and resetting them.
406
+ \`bun install\` here gives your editor, \`bun run typecheck\` and the app's own \`mfw\` (the one a global \`mfw\` runs inside the app) the packages (tool plugins download their CLI binary as they install); the image installs its own copy when it builds. \`mfw start\` builds the image and starts the app with Qdrant in the background, \`mfw repl\` opens a terminal conversation with the assistant. \`mfw --help\` lists the rest: stopping and restarting, logs, a shell in the container, the wiki and the memory, and resetting them.
399
407
  ${renderHttpSection(channels)}${renderCredentialsSection(withCredentials(tools))}`;
400
408
  }
401
409
 
@@ -422,7 +430,7 @@ function renderCredentialsSection(tools: Array<CatalogEntry & { credentials: Cli
422
430
  Each tool plugin runs its own CLI, and each CLI keeps its login in a folder of its own: ${list}. Log in with the CLI on your machine first (its own README says how), then hand that folder to the app:
423
431
 
424
432
  \`\`\`bash
425
- bunx mfw credentials set ${first.id}
433
+ mfw credentials set ${first.id}
426
434
  \`\`\`
427
435
 
428
436
  It packs the folder into its variable in \`.env\` (\`--from <folder>\` if it isn't where the CLI usually keeps it, \`--print\` to get the line to paste on another host instead). When the container starts, \`docker-entrypoint.sh\` unpacks it onto the \`cli-credentials\` volume, but only if that CLI's folder isn't there yet: what the CLI writes back afterwards, like a refreshed token, stays on the volume across redeploys, and the older value in \`.env\` never overwrites it.
@@ -430,7 +438,7 @@ It packs the folder into its variable in \`.env\` (\`--from <folder>\` if it isn
430
438
  That same rule means a corrected variable does nothing while the old folder is on the volume. Clear it, and the next start unpacks the variable again:
431
439
 
432
440
  \`\`\`bash
433
- bunx mfw credentials reset ${first.id}
441
+ mfw credentials reset ${first.id}
434
442
  \`\`\`
435
443
  `;
436
444
  }