@mercury-fw/cli 0.25.1 → 0.26.0

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,24 @@
1
1
  # @mercury-fw/cli
2
2
 
3
+ ## 0.26.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8b92abb: - `mfw` operates an app from inside its folder: `start`, `stop` and `restart` (with `--no-cache`), `logs`, `repl`, `shell`, all as `docker compose` calls.
8
+ - `mfw vault` maintains the wiki vault (`list`, `read`, `grep`, `write-curated`, `write-raw`) in a one-off container.
9
+ - `mfw memory list` and `mfw memory read <collection>` read the memory on Qdrant, newest first where the collection has a timestamp index.
10
+ - `mfw reset memory` and `mfw reset wiki` delete a memory volume after you type the app's name, then bring the service back up empty; after a memory reset a running app is restarted, so it sets up its collections again.
11
+ - The command line is declared with commander: `--help` at every level, a suggestion for a mistyped command, and every argument checked before anything runs.
12
+ - A scaffolded app lists `@mercury-fw/cli` among its devDependencies, at the framework's version, and its README runs it through `bunx mfw`.
13
+ - A scaffolded app's `@types/bun` and `typescript` use caret ranges instead of exact versions.
14
+ - The core ships a read-only memory CLI (`src/memory/memory-cli.ts`) next to the vault one, which is what `mfw memory` runs.
15
+ - The vault CLI says so when asked to read a note that doesn't exist, instead of printing a stack trace.
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [8b92abb]
20
+ - @mercury-fw/core@0.26.0
21
+
3
22
  ## 0.25.1
4
23
 
5
24
  ### Patch Changes
package/README.md CHANGED
@@ -1,18 +1,37 @@
1
1
  # @mercury-fw/cli
2
2
 
3
- `mfw`, the command-line tool of [Mercury](https://github.com/lucabro81/mercury-fw). For now it has one command, `mfw create`, which scaffolds a new app; operating an app (starting it, the REPL, memory maintenance) moves here next.
3
+ `mfw`, the command-line tool of [Mercury](https://github.com/lucabro81/mercury-fw): it creates an app, then runs it. Every command except `create` works from inside an app, meaning its folder or any folder under it (the one holding `mercury.config.ts`), and wraps the `docker compose` calls that app needs, so you don't have to remember them; each section below says which calls those are. New commands get documented here as they're added.
4
4
 
5
- ```bash
6
- bunx @mercury-fw/cli create my-agent
7
- ```
5
+ ## Table of contents
6
+
7
+ - [Getting it](#getting-it)
8
+ - [Usage](#usage)
9
+ - [`mfw create <folder>`](#mfw-create-folder)
10
+ - [`mfw start [--no-cache]`](#mfw-start---no-cache)
11
+ - [`mfw stop`](#mfw-stop)
12
+ - [`mfw restart [--no-cache]`](#mfw-restart---no-cache)
13
+ - [`mfw logs [service]`](#mfw-logs-service)
14
+ - [`mfw repl`](#mfw-repl)
15
+ - [`mfw shell`](#mfw-shell)
16
+ - [`mfw vault <command>`](#mfw-vault-command)
17
+ - [`mfw memory list`](#mfw-memory-list)
18
+ - [`mfw memory read <collection> [--limit N]`](#mfw-memory-read-collection---limit-n)
19
+ - [`mfw reset <memory|wiki>`](#mfw-reset-memorywiki)
20
+ - [Help](#help)
8
21
 
9
- or, the same thing under the `create` convention:
22
+ ## Getting it
23
+
24
+ A new app comes from `bun create mercury-agent`, which is `mfw create` under the `create` convention:
10
25
 
11
26
  ```bash
12
27
  bun create mercury-agent my-agent
13
28
  ```
14
29
 
15
- ## `mfw create <folder>`
30
+ 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.
31
+
32
+ ## Usage
33
+
34
+ ### `mfw create <folder>`
16
35
 
17
36
  Writes a new Mercury app into `<folder>`, which has to be missing or empty; the folder's own name is turned into kebab case (`My Agent` becomes `my-agent`, the path above it stays as typed). Without flags it asks:
18
37
 
@@ -32,9 +51,123 @@ It writes the `mercury.config.ts` for that selection, the persona, the service a
32
51
  | `-y`, `--yes` | No questions: the flags, and the defaults for the rest. |
33
52
 
34
53
  ```bash
35
- mfw create my-agent --assistant-name Hermes --channels http --plugins jira --yes
54
+ bun create mercury-agent my-agent
55
+ bunx @mercury-fw/cli create my-agent --assistant-name Hermes --channels http --plugins jira --yes
36
56
  ```
37
57
 
38
58
  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.
39
59
 
60
+ ### `mfw start [--no-cache]`
61
+
62
+ 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.
63
+
64
+ ```bash
65
+ bunx mfw start
66
+ bunx mfw start --no-cache
67
+ ```
68
+
69
+ ### `mfw stop`
70
+
71
+ 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.
72
+
73
+ ```bash
74
+ bunx mfw stop
75
+ ```
76
+
77
+ ### `mfw restart [--no-cache]`
78
+
79
+ 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`.
80
+
81
+ ```bash
82
+ bunx mfw restart
83
+ bunx mfw restart --no-cache
84
+ ```
85
+
86
+ ### `mfw logs [service]`
87
+
88
+ 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.
89
+
90
+ ```bash
91
+ bunx mfw logs
92
+ bunx mfw logs mercury
93
+ ```
94
+
95
+ ### `mfw repl`
96
+
97
+ 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.
98
+
99
+ ```bash
100
+ bunx mfw repl
101
+ ```
102
+
103
+ ### `mfw shell`
104
+
105
+ 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.
106
+
107
+ ```bash
108
+ bunx mfw shell
109
+ ```
110
+
111
+ ### `mfw vault <command>`
112
+
113
+ Maintains the wiki vault, which lives on a Docker volume and not in the app's folder, so every command runs in a one-off container on that volume (`docker compose run --rm -T mercury bun …`). Paths are vault-relative, the way `list` prints them, `curated/` or `raw/` included.
114
+
115
+ | Command | |
116
+ |---|---|
117
+ | `list` | Every note. |
118
+ | `read <path>` | One note (a path that isn't one says so, exit 1). |
119
+ | `grep <pattern>` | Every line matching `<pattern>`, a regular expression, as `path:line:text`. A pattern starting with `-` goes after `--` (`mfw vault grep -- -h`). |
120
+ | `write-curated <path> [--author NAME]` | Writes a curated note, the body read from stdin. |
121
+ | `write-raw <path>` | Writes raw material for the nightly review to triage, the body read from stdin. |
122
+
123
+ ```bash
124
+ bunx mfw vault list
125
+ bunx mfw vault read curated/standards/jira-fields.md
126
+ bunx mfw vault grep "story points"
127
+ cat note.md | bunx mfw vault write-curated curated/standards/new-note.md --author luca
128
+ ```
129
+
130
+ There's no command writing inferred notes on purpose: those are the agent's own, written only by its consolidation.
131
+
132
+ ### `mfw memory list`
133
+
134
+ 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.
135
+
136
+ ```bash
137
+ bunx mfw memory list
138
+ ```
139
+
140
+ ```
141
+ episodic_memory 25 points
142
+ semantic_facts 20 points
143
+ tool_corrections 37 points
144
+ verbatim_archive 0 points
145
+ ```
146
+
147
+ ### `mfw memory read <collection> [--limit N]`
148
+
149
+ 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.
150
+
151
+ ```bash
152
+ bunx mfw memory read episodic_memory
153
+ bunx mfw memory read semantic_facts --limit 5
154
+ ```
155
+
156
+ ### `mfw reset <memory|wiki>`
157
+
158
+ 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.
159
+
160
+ 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.
161
+
162
+ ```bash
163
+ bunx mfw reset memory
164
+ bunx mfw reset wiki
165
+ ```
166
+
167
+ Useful for clearing out test data; the other layer isn't touched.
168
+
169
+ ## Help
170
+
171
+ `mfw --help` lists every command, and `--help` after any of them describes it, down to the subcommands (`mfw vault write-curated --help`). A mistyped command gets a suggestion (`mfw strat` → "Did you mean start?"). Every argument is checked before anything runs: a wrong one exits 1 saying why, with no container started.
172
+
40
173
  MIT
@@ -0,0 +1,70 @@
1
+ /**
2
+ * The commands that operate an app (`mfw start`, `mfw vault`, …): each one is
3
+ * a short sequence of `docker compose` calls run from the app's folder, so the
4
+ * docker details live here and not in every app. The command line is parsed
5
+ * and validated before any of this runs (`program.ts`); what runs a command is
6
+ * injected (`AppDeps`), which is how the tests see the exact calls.
7
+ */
8
+ import type { App } from "./find-app.ts";
9
+ export type AppDeps = {
10
+ /** Runs `argv` in `cwd` on the user's terminal (stdin, stdout, stderr) and returns its exit code. */
11
+ run: (argv: string[], opts: {
12
+ cwd: string;
13
+ }) => Promise<number>;
14
+ /** Runs `argv` in `cwd` and returns its stdout; throws when it fails. */
15
+ capture: (argv: string[], opts: {
16
+ cwd: string;
17
+ }) => Promise<string>;
18
+ /** Asks the user `question` and returns the answer as typed. */
19
+ ask: (question: string) => Promise<string>;
20
+ /** Tells the user something. */
21
+ print: (line: string) => void;
22
+ };
23
+ /** The core's maintenance CLIs, from the container's working directory (the
24
+ * app's folder, where node_modules/@mercury-fw/core is). A path and not a bin:
25
+ * a monorepo image installs before copying the sources, and Bun doesn't link a
26
+ * bin whose file isn't there yet. The core moves in lockstep with this CLI. */
27
+ export declare const VAULT_CLI = "node_modules/@mercury-fw/core/src/wiki/vault-cli.ts";
28
+ export declare const MEMORY_CLI = "node_modules/@mercury-fw/core/src/memory/memory-cli.ts";
29
+ /** What `mfw reset` can wipe: the compose service using the volume, the
30
+ * volume's key in the compose file, and how to say what's lost. */
31
+ export declare const RESET_TARGETS: {
32
+ readonly memory: {
33
+ readonly service: "qdrant";
34
+ readonly volume: "qdrant-data";
35
+ readonly what: "Layer-3 memory (every Qdrant collection)";
36
+ };
37
+ readonly wiki: {
38
+ readonly service: "mercury";
39
+ readonly volume: "wiki-vault";
40
+ readonly what: "the wiki vault (every note)";
41
+ };
42
+ };
43
+ export type ResetTarget = keyof typeof RESET_TARGETS;
44
+ /** The commands bound to `app`, each returning its exit code. */
45
+ export declare function appCommands(app: App, deps: AppDeps): {
46
+ start: ({ noCache }: {
47
+ noCache: boolean;
48
+ }) => Promise<number>;
49
+ restart: ({ noCache }: {
50
+ noCache: boolean;
51
+ }) => Promise<number>;
52
+ stop: () => Promise<number>;
53
+ logs: (service?: string) => Promise<number>;
54
+ repl: () => Promise<number>;
55
+ shell: () => Promise<number>;
56
+ /** `args` is the vault CLI's own command line (`list`, `read <path>`, …). */
57
+ vault: (args: string[]) => Promise<number>;
58
+ /** `args` is the memory CLI's own command line (`list`, `read <collection>`, …). */
59
+ memory: (args: string[]) => Promise<number>;
60
+ /** Deletes `target`'s volume once the user types the app's name, then
61
+ * brings its service back up on an empty volume. The volume's real name
62
+ * comes from the compose file, and a wrong answer deletes nothing. */
63
+ reset: (target: ResetTarget) => Promise<number>;
64
+ };
65
+ /** The real deps: docker on the user's terminal, questions on `input`
66
+ * (stdin by default). */
67
+ export declare function terminalDeps({ input, output, }?: {
68
+ input?: NodeJS.ReadableStream;
69
+ output?: NodeJS.WritableStream;
70
+ }): AppDeps;
@@ -0,0 +1,6 @@
1
+ export type App = {
2
+ dir: string;
3
+ name: string;
4
+ };
5
+ /** The app containing `from`; throws when there's none, or when its manifest has no name. */
6
+ export declare function findApp(from: string): App;
@@ -1,3 +1,10 @@
1
+ /**
2
+ * `mfw create`'s answers as the command line gives them: the target folder
3
+ * plus what can be given as flags, either to skip the wizard (`--yes`) or to
4
+ * pre-fill it. The command line itself is parsed in `program.ts`; no
5
+ * validation of the answers here: `renderApp` owns that, so the wizard and the
6
+ * flags go through the same checks.
7
+ */
1
8
  /** What the command line says. An answer left out is asked by the wizard, or
2
9
  * takes its default with `yes`. */
3
10
  export type CreateArgs = {
@@ -9,6 +16,14 @@ export type CreateArgs = {
9
16
  plugins?: string[];
10
17
  yes: boolean;
11
18
  };
12
- /** Parses the arguments that follow `create`. Throws on a missing or extra
13
- * folder and on an unknown flag. */
14
- export declare function parseCreateArgs(argv: string[]): CreateArgs;
19
+ /** `create`'s options as commander hands them over. */
20
+ export type CreateOptions = {
21
+ name?: string;
22
+ assistantName?: string;
23
+ role?: string;
24
+ channels?: string;
25
+ plugins?: string;
26
+ yes?: boolean;
27
+ };
28
+ /** The answers in `folder` and `opts`, with only what was actually given. */
29
+ export declare function toCreateArgs(folder: string, opts: CreateOptions): CreateArgs;
@@ -1,4 +1,9 @@
1
+ import { type AppDeps } from "./app/commands.ts";
1
2
  /** Runs `mfw` with `argv` (the arguments after the command name) and returns
2
3
  * the exit code, printing to stdout/stderr. `bin.ts` and `create-mercury-agent`
3
- * both call it. */
4
- export declare function main(argv: string[]): Promise<number>;
4
+ * both call it; the app commands look for the app from `cwd` and run docker
5
+ * through `deps`. */
6
+ export declare function main(argv: string[], { cwd, deps }?: {
7
+ cwd?: string;
8
+ deps?: AppDeps;
9
+ }): Promise<number>;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The `mfw` command line, declared with commander: every command, its
3
+ * arguments and options, and the help for each level (`mfw --help`,
4
+ * `mfw vault --help`, `mfw vault write-curated --help`). Parsing and
5
+ * validation happen here, before anything runs; what a command does lives in
6
+ * `create` (passed in) and `app/commands.ts`.
7
+ */
8
+ import { type OutputConfiguration } from "commander";
9
+ import { type CreateArgs } from "./args.ts";
10
+ import { appCommands } from "./app/commands.ts";
11
+ /** The commands that operate an app, bound to it. */
12
+ export type AppCommands = ReturnType<typeof appCommands>;
13
+ export type ProgramHandlers = {
14
+ /** `mfw create`; returns the exit code. */
15
+ create: (args: CreateArgs) => Promise<number>;
16
+ /** The app the app commands act on; throws outside one. */
17
+ app: () => AppCommands;
18
+ };
19
+ /** Runs `mfw` on `argv` (the arguments after the command name) and returns
20
+ * the exit code. Commander's own messages (help, errors) go to `output`, and
21
+ * so does the message of an error a command throws. */
22
+ export declare function runProgram(argv: string[], handlers: ProgramHandlers, output?: OutputConfiguration): Promise<number>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/cli",
3
- "version": "0.25.1",
3
+ "version": "0.26.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,12 +36,13 @@
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.25.1"
39
+ "@mercury-fw/core": "0.26.0",
40
+ "commander": "^15.0.0"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@mercury-fw/channel-google-chat": "0.1.0",
43
44
  "@mercury-fw/channel-http": "0.1.0",
44
- "@mercury-fw/formatter": "0.25.1",
45
+ "@mercury-fw/formatter": "0.26.0",
45
46
  "@mercury-fw/plugin-atlassian-admin": "0.1.0",
46
47
  "@mercury-fw/plugin-bitbucket": "0.1.0",
47
48
  "@mercury-fw/plugin-jira": "0.1.0",
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The commands that operate an app (`mfw start`, `mfw vault`, …): each one is
3
+ * a short sequence of `docker compose` calls run from the app's folder, so the
4
+ * docker details live here and not in every app. The command line is parsed
5
+ * and validated before any of this runs (`program.ts`); what runs a command is
6
+ * injected (`AppDeps`), which is how the tests see the exact calls.
7
+ */
8
+ import type { App } from "./find-app.ts";
9
+
10
+ export type AppDeps = {
11
+ /** Runs `argv` in `cwd` on the user's terminal (stdin, stdout, stderr) and returns its exit code. */
12
+ run: (argv: string[], opts: { cwd: string }) => Promise<number>;
13
+ /** Runs `argv` in `cwd` and returns its stdout; throws when it fails. */
14
+ capture: (argv: string[], opts: { cwd: string }) => Promise<string>;
15
+ /** Asks the user `question` and returns the answer as typed. */
16
+ ask: (question: string) => Promise<string>;
17
+ /** Tells the user something. */
18
+ print: (line: string) => void;
19
+ };
20
+
21
+ const COMPOSE = ["docker", "compose"];
22
+
23
+ /** The app's service, the one the image builds. */
24
+ const SERVICE = "mercury";
25
+
26
+ /** The core's maintenance CLIs, from the container's working directory (the
27
+ * app's folder, where node_modules/@mercury-fw/core is). A path and not a bin:
28
+ * a monorepo image installs before copying the sources, and Bun doesn't link a
29
+ * bin whose file isn't there yet. The core moves in lockstep with this CLI. */
30
+ export const VAULT_CLI = "node_modules/@mercury-fw/core/src/wiki/vault-cli.ts";
31
+ export const MEMORY_CLI = "node_modules/@mercury-fw/core/src/memory/memory-cli.ts";
32
+
33
+ /** What `mfw reset` can wipe: the compose service using the volume, the
34
+ * volume's key in the compose file, and how to say what's lost. */
35
+ export const RESET_TARGETS = {
36
+ memory: { service: "qdrant", volume: "qdrant-data", what: "Layer-3 memory (every Qdrant collection)" },
37
+ wiki: { service: SERVICE, volume: "wiki-vault", what: "the wiki vault (every note)" },
38
+ } as const;
39
+ export type ResetTarget = keyof typeof RESET_TARGETS;
40
+
41
+ /** The compose calls that build and start the app; `recreate` restarts containers even when nothing changed. */
42
+ function startCalls(noCache: boolean, recreate: boolean): string[][] {
43
+ const up = [...COMPOSE, "up", "-d", ...(noCache ? [] : ["--build"]), ...(recreate ? ["--force-recreate"] : [])];
44
+ return noCache ? [[...COMPOSE, "build", "--no-cache"], up] : [up];
45
+ }
46
+
47
+ /** The commands bound to `app`, each returning its exit code. */
48
+ export function appCommands(app: App, deps: AppDeps) {
49
+ /** Runs `calls` in order from the app's folder, stopping at the first that fails; returns its exit code, 0 if none did. */
50
+ const runAll = async (calls: string[][]): Promise<number> => {
51
+ for (const argv of calls) {
52
+ const code = await deps.run(argv, { cwd: app.dir });
53
+ if (code !== 0) return code;
54
+ }
55
+ return 0;
56
+ };
57
+ const oneOff = (argv: string[]) => runAll([[...COMPOSE, "run", "--rm", "-T", SERVICE, ...argv]]);
58
+ /** The compose services running right now. */
59
+ const running = async (): Promise<string[]> =>
60
+ (await deps.capture([...COMPOSE, "ps", "--status", "running", "--services"], { cwd: app.dir }))
61
+ .split("\n")
62
+ .map((s) => s.trim());
63
+
64
+ return {
65
+ start: ({ noCache }: { noCache: boolean }) => runAll(startCalls(noCache, false)),
66
+ restart: ({ noCache }: { noCache: boolean }) => runAll(startCalls(noCache, true)),
67
+ stop: () => runAll([[...COMPOSE, "down"]]),
68
+ logs: (service?: string) => runAll([[...COMPOSE, "logs", "-f", ...(service === undefined ? [] : [service])]]),
69
+ repl: () => runAll([[...COMPOSE, "run", "--rm", SERVICE, "bun", "run", "repl"]]),
70
+ shell: async () => {
71
+ const shell = (await running()).includes(SERVICE) ? ["exec", SERVICE, "bash"] : ["run", "--rm", SERVICE, "bash"];
72
+ return runAll([[...COMPOSE, ...shell]]);
73
+ },
74
+ /** `args` is the vault CLI's own command line (`list`, `read <path>`, …). */
75
+ vault: (args: string[]) => oneOff(["bun", VAULT_CLI, ...args]),
76
+ /** `args` is the memory CLI's own command line (`list`, `read <collection>`, …). */
77
+ memory: (args: string[]) => oneOff(["bun", MEMORY_CLI, ...args]),
78
+ /** Deletes `target`'s volume once the user types the app's name, then
79
+ * brings its service back up on an empty volume. The volume's real name
80
+ * comes from the compose file, and a wrong answer deletes nothing. */
81
+ reset: async (target: ResetTarget) => {
82
+ const { service, volume: key, what } = RESET_TARGETS[target];
83
+ const config = JSON.parse(
84
+ await deps.capture([...COMPOSE, "config", "--no-interpolate", "--format", "json"], { cwd: app.dir }),
85
+ ) as { volumes?: Record<string, { name?: string }> };
86
+ const volume = config.volumes?.[key]?.name;
87
+ if (volume === undefined) {
88
+ throw new Error(`The compose file has no "${key}" volume to reset`);
89
+ }
90
+ const answer = await deps.ask(`This deletes ${what} for good: volume ${volume}. Type the app's name (${app.name}) to confirm: `);
91
+ if (answer.trim() !== app.name) {
92
+ deps.print("Not confirmed: nothing deleted.");
93
+ return 1;
94
+ }
95
+ const steps = [
96
+ [...COMPOSE, "stop", service],
97
+ [...COMPOSE, "rm", "-f", service],
98
+ ["docker", "volume", "rm", volume],
99
+ [...COMPOSE, "up", "-d", service],
100
+ ];
101
+ for (const [i, argv] of steps.entries()) {
102
+ const code = await deps.run(argv, { cwd: app.dir });
103
+ if (code === 0) continue;
104
+ if (i > 0) deps.print(`The ${service} service was stopped and not restarted: bunx mfw start brings it back.`);
105
+ return code;
106
+ }
107
+ // A running app sets up its Qdrant collections only when it starts.
108
+ if (target === "memory" && (await running()).includes(SERVICE)) {
109
+ return runAll([[...COMPOSE, "restart", SERVICE]]);
110
+ }
111
+ return 0;
112
+ },
113
+ };
114
+ }
115
+
116
+ /** The real deps: docker on the user's terminal, questions on `input`
117
+ * (stdin by default). */
118
+ export function terminalDeps({
119
+ input = process.stdin,
120
+ output = process.stdout,
121
+ }: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {}): AppDeps {
122
+ return {
123
+ run: async (argv, { cwd }) => {
124
+ const proc = Bun.spawn(argv, { cwd, stdin: "inherit", stdout: "inherit", stderr: "inherit" });
125
+ return await proc.exited;
126
+ },
127
+ capture: async (argv, { cwd }) => {
128
+ const proc = Bun.spawn(argv, { cwd, stdin: "ignore", stdout: "pipe", stderr: "inherit" });
129
+ const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
130
+ if (code !== 0) throw new Error(`${argv.join(" ")} failed (exit ${code})`);
131
+ return out;
132
+ },
133
+ // Input closing before a line (Ctrl+D, `< /dev/null`) is an empty answer,
134
+ // never a question left waiting.
135
+ ask: async (question) => {
136
+ const { createInterface } = await import("node:readline");
137
+ const rl = createInterface({ input, output });
138
+ return await new Promise<string>((resolve) => {
139
+ rl.once("close", () => resolve(""));
140
+ rl.question(question, (answer) => {
141
+ resolve(answer);
142
+ rl.close();
143
+ });
144
+ });
145
+ },
146
+ print: (line) => console.log(line),
147
+ };
148
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Finds the Mercury app the app commands (`mfw start`, `mfw vault`, …) act
3
+ * on: the nearest folder, from `from` upwards, holding `mercury.config.ts`.
4
+ * Its `package.json` name is the app's name (what `mfw reset` asks to type).
5
+ */
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { dirname, join, resolve } from "node:path";
8
+
9
+ export type App = { dir: string; name: string };
10
+
11
+ /** The app containing `from`; throws when there's none, or when its manifest has no name. */
12
+ export function findApp(from: string): App {
13
+ const start = resolve(from);
14
+ for (let dir = start; ; dir = dirname(dir)) {
15
+ if (existsSync(join(dir, "mercury.config.ts"))) {
16
+ const manifest = join(dir, "package.json");
17
+ const name = existsSync(manifest) ? (JSON.parse(readFileSync(manifest, "utf-8")) as { name?: unknown }).name : undefined;
18
+ if (typeof name !== "string" || name === "") throw new Error(`${manifest} has no name`);
19
+ return { dir, name };
20
+ }
21
+ if (dirname(dir) === dir) {
22
+ throw new Error(`Not inside a Mercury app: no mercury.config.ts in ${start} or any folder above it`);
23
+ }
24
+ }
25
+ }
package/src/args.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Parses `mfw create`'s command line: one target folder plus the answers
3
- * that can be given as flags, either to skip the wizard (`--yes`) or to
4
- * pre-fill it. No validation of the answers themselves here: `renderApp` owns
5
- * that, so the wizard and the flags go through the same checks.
2
+ * `mfw create`'s answers as the command line gives them: the target folder
3
+ * plus what can be given as flags, either to skip the wizard (`--yes`) or to
4
+ * pre-fill it. The command line itself is parsed in `program.ts`; no
5
+ * validation of the answers here: `renderApp` owns that, so the wizard and the
6
+ * flags go through the same checks.
6
7
  */
7
- import { parseArgs } from "node:util";
8
8
 
9
9
  /** What the command line says. An answer left out is asked by the wizard, or
10
10
  * takes its default with `yes`. */
@@ -18,6 +18,16 @@ export type CreateArgs = {
18
18
  yes: boolean;
19
19
  };
20
20
 
21
+ /** `create`'s options as commander hands them over. */
22
+ export type CreateOptions = {
23
+ name?: string;
24
+ assistantName?: string;
25
+ role?: string;
26
+ channels?: string;
27
+ plugins?: string;
28
+ yes?: boolean;
29
+ };
30
+
21
31
  /** A comma-separated list, trimmed, empty items dropped. */
22
32
  const list = (value: string): string[] =>
23
33
  value
@@ -25,33 +35,13 @@ const list = (value: string): string[] =>
25
35
  .map((s) => s.trim())
26
36
  .filter(Boolean);
27
37
 
28
- /** Parses the arguments that follow `create`. Throws on a missing or extra
29
- * folder and on an unknown flag. */
30
- export function parseCreateArgs(argv: string[]): CreateArgs {
31
- const { values, positionals } = parseArgs({
32
- args: argv,
33
- allowPositionals: true,
34
- strict: true,
35
- options: {
36
- name: { type: "string" },
37
- "assistant-name": { type: "string" },
38
- role: { type: "string" },
39
- channels: { type: "string" },
40
- plugins: { type: "string" },
41
- yes: { type: "boolean", short: "y" },
42
- },
43
- });
44
- if (positionals.length === 0) {
45
- throw new Error("Missing the folder to create the app in");
46
- }
47
- if (positionals.length > 1) {
48
- throw new Error(`Expected one folder, got ${positionals.length}: ${positionals.join(" ")}`);
49
- }
50
- const args: CreateArgs = { dir: positionals[0] as string, yes: values.yes ?? false };
51
- if (values.name !== undefined) args.name = values.name;
52
- if (values["assistant-name"] !== undefined) args.assistantName = values["assistant-name"];
53
- if (values.role !== undefined) args.role = values.role;
54
- if (values.channels !== undefined) args.channels = list(values.channels);
55
- if (values.plugins !== undefined) args.plugins = list(values.plugins);
38
+ /** The answers in `folder` and `opts`, with only what was actually given. */
39
+ export function toCreateArgs(folder: string, opts: CreateOptions): CreateArgs {
40
+ const args: CreateArgs = { dir: folder, yes: opts.yes ?? false };
41
+ if (opts.name !== undefined) args.name = opts.name;
42
+ if (opts.assistantName !== undefined) args.assistantName = opts.assistantName;
43
+ if (opts.role !== undefined) args.role = opts.role;
44
+ if (opts.channels !== undefined) args.channels = list(opts.channels);
45
+ if (opts.plugins !== undefined) args.plugins = list(opts.plugins);
56
46
  return args;
57
47
  }
package/src/main.ts CHANGED
@@ -1,47 +1,22 @@
1
1
  /**
2
- * The `mfw` command. One subcommand for now, `create <folder>`: writes a
3
- * new Mercury app from the template, asking what to put in it (or taking the
4
- * answers from flags with `--yes`). It writes the files; `bun install` in the
5
- * new app is left to the user.
2
+ * The `mfw` command. `create <folder>` writes a new Mercury app from the
3
+ * template, asking what to put in it (or taking the answers from flags with
4
+ * `--yes`); `bun install` in the new app is left to the user. The other
5
+ * commands operate an existing app from inside its folder (see
6
+ * `app/commands.ts`). The command line itself is declared in `program.ts`.
6
7
  */
7
8
  import { basename, dirname, join, resolve } from "node:path";
8
- import { parseCreateArgs, type CreateArgs } from "./args.ts";
9
+ import type { CreateArgs } from "./args.ts";
9
10
  import { CATALOG } from "./catalog.ts";
10
11
  import { kebabCase } from "./naming.ts";
12
+ import { runProgram } from "./program.ts";
11
13
  import { renderApp, selectionError } from "./render.ts";
12
14
  import { appVersions, registryFrom } from "./versions.ts";
15
+ import { appCommands, terminalDeps, type AppDeps } from "./app/commands.ts";
16
+ import { findApp } from "./app/find-app.ts";
13
17
  import { askAnswers, DEFAULT_ASSISTANT_NAME, DEFAULT_ROLE, type Answers } from "./wizard.ts";
14
18
  import { targetError, writeApp } from "./write.ts";
15
19
 
16
- const USAGE = `mfw, the Mercury command-line tool.
17
-
18
- Usage:
19
- mfw create <folder> [options]
20
-
21
- mfw create writes a new Mercury app into <folder>, which has to be missing or
22
- empty (its own name is turned into kebab case). Without options it asks for the
23
- app name, the assistant's name and role, and which channels and tool plugins
24
- to include; then it writes mercury.config.ts for that selection, the persona
25
- (persona/identity.md, persona/tone.md), the service and REPL entrypoints, a
26
- Dockerfile, a compose file with Qdrant, and an env example listing every
27
- variable the app reads. Nothing is installed: run bun install in the new app.
28
-
29
- Options:
30
- --name <name> app name, as in package.json (default: the folder's name)
31
- --assistant-name <name> the assistant's name (default: ${DEFAULT_ASSISTANT_NAME})
32
- --role <text> completes "You are <name>, …" (default: ${DEFAULT_ROLE})
33
- --channels <ids> comma-separated: ${CATALOG.filter((e) => e.kind === "channel").map((e) => e.id).join(", ")}
34
- --plugins <ids> comma-separated: ${CATALOG.filter((e) => e.kind === "tool").map((e) => e.id).join(", ")}
35
- -y, --yes don't ask: use the flags and the defaults
36
-
37
- Examples:
38
- mfw create my-agent
39
- mfw create my-agent --assistant-name Hermes --channels http --plugins jira --yes
40
-
41
- The framework packages get this CLI's version; each chosen plugin or channel
42
- its latest on the registry (https://registry.npmjs.org, or MFW_REGISTRY).
43
- `;
44
-
45
20
  /** The answers taken from the flags alone, defaults for the rest. */
46
21
  function answersFromFlags(args: CreateArgs, defaultName: string): Answers {
47
22
  return {
@@ -54,8 +29,7 @@ function answersFromFlags(args: CreateArgs, defaultName: string): Answers {
54
29
  }
55
30
 
56
31
  /** `mfw create`: returns the exit code. */
57
- async function create(argv: string[]): Promise<number> {
58
- const args = parseCreateArgs(argv);
32
+ async function create(args: CreateArgs): Promise<number> {
59
33
  // The folder is created in kebab case, only its own name: the parent path is
60
34
  // taken as typed. Its name is also the app name's default.
61
35
  const typed = resolve(args.dir);
@@ -85,35 +59,20 @@ Next:
85
59
  cd ${dir}
86
60
  bun install
87
61
  cp .env.example .env # then fill it in
88
- docker compose up --build`);
62
+ bunx mfw start`);
89
63
  return 0;
90
64
  }
91
65
 
92
66
  /** Runs `mfw` with `argv` (the arguments after the command name) and returns
93
67
  * the exit code, printing to stdout/stderr. `bin.ts` and `create-mercury-agent`
94
- * both call it. */
95
- export async function main(argv: string[]): Promise<number> {
96
- const [command, ...rest] = argv;
97
- if (command === "--help" || command === "-h") {
98
- console.log(USAGE);
99
- return 0;
100
- }
101
- if (command === undefined) {
102
- console.error(USAGE);
103
- return 1;
104
- }
105
- if (command === "create" && rest.some((a) => a === "--help" || a === "-h")) {
106
- console.log(USAGE);
107
- return 0;
108
- }
109
- if (command !== "create") {
110
- console.error(`Unknown command "${command}"\n\n${USAGE}`);
111
- return 1;
112
- }
113
- try {
114
- return await create(rest);
115
- } catch (err) {
116
- console.error(err instanceof Error ? err.message : String(err));
117
- return 1;
118
- }
68
+ * both call it; the app commands look for the app from `cwd` and run docker
69
+ * through `deps`. */
70
+ export async function main(
71
+ argv: string[],
72
+ { cwd = process.cwd(), deps }: { cwd?: string; deps?: AppDeps } = {},
73
+ ): Promise<number> {
74
+ return runProgram(argv, {
75
+ create,
76
+ app: () => appCommands(findApp(cwd), deps ?? terminalDeps()),
77
+ });
119
78
  }
package/src/program.ts ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * The `mfw` command line, declared with commander: every command, its
3
+ * arguments and options, and the help for each level (`mfw --help`,
4
+ * `mfw vault --help`, `mfw vault write-curated --help`). Parsing and
5
+ * validation happen here, before anything runs; what a command does lives in
6
+ * `create` (passed in) and `app/commands.ts`.
7
+ */
8
+ import { Argument, Command, CommanderError, InvalidArgumentError, Option, type OutputConfiguration } from "commander";
9
+ import { toCreateArgs, type CreateArgs, type CreateOptions } from "./args.ts";
10
+ import { appCommands, RESET_TARGETS, type ResetTarget } from "./app/commands.ts";
11
+ import { CATALOG } from "./catalog.ts";
12
+ import { cliVersion } from "./versions.ts";
13
+ import { DEFAULT_ASSISTANT_NAME, DEFAULT_ROLE } from "./wizard.ts";
14
+
15
+ /** The commands that operate an app, bound to it. */
16
+ export type AppCommands = ReturnType<typeof appCommands>;
17
+
18
+ export type ProgramHandlers = {
19
+ /** `mfw create`; returns the exit code. */
20
+ create: (args: CreateArgs) => Promise<number>;
21
+ /** The app the app commands act on; throws outside one. */
22
+ app: () => AppCommands;
23
+ };
24
+
25
+ /** `--limit`'s value: a positive whole number. */
26
+ function positiveInt(value: string): string {
27
+ if (!/^\d+$/.test(value) || Number(value) < 1) {
28
+ throw new InvalidArgumentError(`--limit takes a positive whole number (got "${value}").`);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ /** The help's closing paragraph for the commands that run inside an app. */
34
+ const INSIDE_AN_APP = "\nRun it from the app's folder or any folder under it (the one holding mercury.config.ts).";
35
+
36
+ /** Builds the `mfw` program; every action stores its exit code in `result`. */
37
+ function buildProgram(handlers: ProgramHandlers, result: { code: number }): Command {
38
+ const program = new Command("mfw")
39
+ .description("The Mercury command-line tool: creates an app, then runs it.")
40
+ .version(cliVersion(), "-V, --version")
41
+ .showSuggestionAfterError()
42
+ .helpCommand(false);
43
+ const inApp = (run: (app: AppCommands) => Promise<number>) => async () => {
44
+ result.code = await run(handlers.app());
45
+ };
46
+
47
+ program
48
+ .command("create")
49
+ .summary("writes a new Mercury app")
50
+ .description(
51
+ "Writes a new Mercury app into <folder>, which has to be missing or empty (its own name is turned into kebab case). Without options it asks for the app name, the assistant's name and role, and which channels and tool plugins to include; then it writes mercury.config.ts for that selection, the persona (persona/identity.md, persona/tone.md), the service and REPL entrypoints, a Dockerfile, a compose file with Qdrant, and an env example listing every variable the app reads. Nothing is installed: run bun install in the new app.",
52
+ )
53
+ .argument("<folder>", "where to write the app")
54
+ .option("--name <name>", "app name, as in package.json (default: the folder's name)")
55
+ .option("--assistant-name <name>", `the assistant's name (default: ${DEFAULT_ASSISTANT_NAME})`)
56
+ .option("--role <text>", `completes "You are <name>, …" (default: ${DEFAULT_ROLE})`)
57
+ .option(
58
+ "--channels <ids>",
59
+ `comma-separated: ${CATALOG.filter((e) => e.kind === "channel").map((e) => e.id).join(", ")}`,
60
+ )
61
+ .option("--plugins <ids>", `comma-separated: ${CATALOG.filter((e) => e.kind === "tool").map((e) => e.id).join(", ")}`)
62
+ .option("-y, --yes", "don't ask: use the flags and the defaults")
63
+ .addHelpText(
64
+ "after",
65
+ `
66
+ The framework packages get this CLI's version; each chosen plugin or channel
67
+ its latest on the registry (https://registry.npmjs.org, or MFW_REGISTRY).
68
+
69
+ Examples:
70
+ mfw create my-agent
71
+ mfw create my-agent --assistant-name Hermes --channels http --plugins jira --yes`,
72
+ )
73
+ .action(async (folder: string, opts: CreateOptions) => {
74
+ result.code = await handlers.create(toCreateArgs(folder, opts));
75
+ });
76
+
77
+ // commander reads --no-cache as "cache: false"; the commands take noCache.
78
+ const cacheOff = (opts: { cache: boolean }) => ({ noCache: !opts.cache });
79
+
80
+ program
81
+ .command("start")
82
+ .summary("builds and starts the app")
83
+ .description(
84
+ "Builds the app's image and starts the app and Qdrant in the background. Only what changed is rebuilt, and only what changed (image, .env, compose file) is recreated.",
85
+ )
86
+ .addOption(new Option("--no-cache", "rebuild everything from scratch"))
87
+ .addHelpText("after", INSIDE_AN_APP)
88
+ .action(async (opts: { cache: boolean }) => inApp((app) => app.start(cacheOff(opts)))());
89
+
90
+ program
91
+ .command("stop")
92
+ .summary("stops the app")
93
+ .description("Stops the app and Qdrant and removes their containers. The volumes (memory, wiki, CLI credentials) stay.")
94
+ .addHelpText("after", INSIDE_AN_APP)
95
+ .action(inApp((app) => app.stop()));
96
+
97
+ program
98
+ .command("restart")
99
+ .summary("rebuilds and restarts the app")
100
+ .description("Like mfw start, but recreates the containers even when nothing changed: a clean restart.")
101
+ .addOption(new Option("--no-cache", "rebuild everything from scratch"))
102
+ .addHelpText("after", INSIDE_AN_APP)
103
+ .action(async (opts: { cache: boolean }) => inApp((app) => app.restart(cacheOff(opts)))());
104
+
105
+ program
106
+ .command("logs")
107
+ .summary("follows the logs")
108
+ .description("Follows the logs of every service, or only of [service].")
109
+ .argument("[service]", "mercury or qdrant")
110
+ .addHelpText("after", INSIDE_AN_APP)
111
+ .action(async (service: string | undefined) => inApp((app) => app.logs(service))());
112
+
113
+ program
114
+ .command("repl")
115
+ .summary("opens the dev REPL")
116
+ .description("Opens the dev REPL, a terminal conversation with the assistant, in a one-off container. A running app isn't touched.")
117
+ .addHelpText("after", INSIDE_AN_APP)
118
+ .action(inApp((app) => app.repl()));
119
+
120
+ program
121
+ .command("shell")
122
+ .summary("opens a shell in the app's container")
123
+ .description("Opens a shell in the app's container: the running one if the app is up, otherwise a one-off container.")
124
+ .addHelpText("after", INSIDE_AN_APP)
125
+ .action(inApp((app) => app.shell()));
126
+
127
+ const vault = program
128
+ .command("vault")
129
+ .summary("wiki vault maintenance")
130
+ .description(
131
+ "Maintains the wiki vault, in a one-off container on the vault's volume. Paths are vault-relative, as mfw vault list prints them (curated/…, raw/…).",
132
+ )
133
+ .helpCommand(false)
134
+ .addHelpText("after", INSIDE_AN_APP);
135
+ vault
136
+ .command("list")
137
+ .summary("every note")
138
+ .description("Lists every note.")
139
+ .action(async () => inApp((app) => app.vault(["list"]))());
140
+ vault
141
+ .command("read")
142
+ .summary("a note")
143
+ .description("Prints a note.")
144
+ .argument("<path>", "the note, vault-relative")
145
+ .action(async (path: string) => inApp((app) => app.vault(["read", path]))());
146
+ vault
147
+ .command("grep")
148
+ .summary("lines matching a pattern")
149
+ .description("Prints every line matching <pattern> (a regular expression) as path:line:text.")
150
+ .argument("<pattern>", "a regular expression; after --, one starting with - too")
151
+ .action(async (pattern: string) => inApp((app) => app.vault(["grep", pattern]))());
152
+ vault
153
+ .command("write-curated")
154
+ .summary("writes a curated note from stdin")
155
+ .description("Writes a curated note, the body read from stdin.")
156
+ .argument("<path>", "curated/…, vault-relative")
157
+ .option("--author <name>", "who wrote it")
158
+ .addHelpText("after", "\nExample:\n cat note.md | mfw vault write-curated curated/standards/new-note.md --author luca")
159
+ .action(async (path: string, opts: { author?: string }) =>
160
+ inApp((app) => app.vault(["write-curated", path, ...(opts.author === undefined ? [] : ["--author", opts.author])]))(),
161
+ );
162
+ vault
163
+ .command("write-raw")
164
+ .summary("writes raw material from stdin")
165
+ .description("Writes raw material for the nightly review to triage, the body read from stdin.")
166
+ .argument("<path>", "raw/…, vault-relative")
167
+ .action(async (path: string) => inApp((app) => app.vault(["write-raw", path]))());
168
+
169
+ const memory = program
170
+ .command("memory")
171
+ .summary("reads Layer-3 memory")
172
+ .description("Reads Layer-3 memory on Qdrant, in a one-off container. Read-only.")
173
+ .helpCommand(false)
174
+ .addHelpText("after", INSIDE_AN_APP);
175
+ memory
176
+ .command("list")
177
+ .summary("the collections and their points")
178
+ .description("Lists the collections and how many points each holds.")
179
+ .action(async () => inApp((app) => app.memory(["list"]))());
180
+ memory
181
+ .command("read")
182
+ .summary("a collection's points")
183
+ .description(
184
+ "Prints a collection's points, each as its id and one line per payload field: newest first where the collection has a timestamp index, in Qdrant's own order otherwise.",
185
+ )
186
+ .argument("<collection>", "as mfw memory list prints it")
187
+ .option("--limit <n>", "how many points (default: 20)", positiveInt)
188
+ .action(async (collection: string, opts: { limit?: string }) =>
189
+ inApp((app) => app.memory(["read", collection, ...(opts.limit === undefined ? [] : ["--limit", opts.limit])]))(),
190
+ );
191
+
192
+ program
193
+ .command("reset")
194
+ .summary("deletes memory or the wiki, after confirmation")
195
+ .description(
196
+ "Deletes for good what the assistant remembers: memory is every Qdrant collection, wiki the whole vault. It asks you to type the app's name first (anything else deletes nothing), then brings the service back up empty.",
197
+ )
198
+ .addArgument(new Argument("<target>", "what to delete").choices(Object.keys(RESET_TARGETS)))
199
+ .addHelpText("after", INSIDE_AN_APP)
200
+ .action(async (target: ResetTarget) => inApp((app) => app.reset(target))());
201
+
202
+ return program;
203
+ }
204
+
205
+ /** Runs `mfw` on `argv` (the arguments after the command name) and returns
206
+ * the exit code. Commander's own messages (help, errors) go to `output`, and
207
+ * so does the message of an error a command throws. */
208
+ export async function runProgram(argv: string[], handlers: ProgramHandlers, output?: OutputConfiguration): Promise<number> {
209
+ const result = { code: 0 };
210
+ const program = buildProgram(handlers, result);
211
+ const writeErr = output?.writeErr ?? ((s: string) => process.stderr.write(s));
212
+ const configure = (cmd: Command): void => {
213
+ cmd.exitOverride();
214
+ if (output !== undefined) cmd.configureOutput(output);
215
+ cmd.commands.forEach(configure);
216
+ };
217
+ configure(program);
218
+ if (argv.length === 0) {
219
+ program.outputHelp({ error: true });
220
+ return 1;
221
+ }
222
+ try {
223
+ await program.parseAsync(argv, { from: "user" });
224
+ return result.code;
225
+ } catch (err) {
226
+ if (err instanceof CommanderError) return err.exitCode;
227
+ writeErr(`${err instanceof Error ? err.message : String(err)}\n`);
228
+ return 1;
229
+ }
230
+ }
package/src/render.ts CHANGED
@@ -213,6 +213,10 @@ function renderPackageJson(
213
213
  }
214
214
  dependencies[pkg] = `^${version}`;
215
215
  }
216
+ const cli = versions["@mercury-fw/cli"];
217
+ if (cli === undefined) {
218
+ throw new Error("No version known for @mercury-fw/cli");
219
+ }
216
220
  const manifest: Record<string, unknown> = {
217
221
  name,
218
222
  version: "0.1.0",
@@ -220,7 +224,9 @@ function renderPackageJson(
220
224
  private: true,
221
225
  scripts: { start: "bun src/index.ts", repl: "bun src/repl.ts", typecheck: "tsc --noEmit" },
222
226
  dependencies,
223
- devDependencies: { "@types/bun": "1.4.0", typescript: "6.0.3" },
227
+ // The CLI in the app itself, so `bunx mfw` runs the version that matches
228
+ // the framework the app depends on.
229
+ devDependencies: { "@mercury-fw/cli": `^${cli}`, "@types/bun": "^1.4.0", typescript: "^6.0.3" },
224
230
  };
225
231
  if (tools.length > 0) {
226
232
  manifest.trustedDependencies = tools.map((t) => t.package).sort();
@@ -317,11 +323,11 @@ A Mercury app, scaffolded by \`mfw create\`.
317
323
  \`\`\`bash
318
324
  bun install
319
325
  cp .env.example .env
320
- docker compose up --build
321
- docker compose run --rm mercury bun run repl
326
+ bunx mfw start
327
+ bunx mfw repl
322
328
  \`\`\`
323
329
 
324
- \`bun install\` here gives your editor and \`bun run typecheck\` the packages (tool plugins download their CLI binary as they install); the image installs its own copy when it builds.
330
+ \`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.
325
331
  ${tools.length > 0 ? CREDENTIALS_SECTION : ""}`;
326
332
  }
327
333
 
package/src/versions.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import pkg from "../package.json";
8
8
 
9
9
  /** The framework packages a new app can depend on: always at the CLI's version. */
10
- export const FRAMEWORK_PACKAGES = ["@mercury-fw/core", "@mercury-fw/formatter"];
10
+ export const FRAMEWORK_PACKAGES = ["@mercury-fw/cli", "@mercury-fw/core", "@mercury-fw/formatter"];
11
11
 
12
12
  /** The registry asked when `MFW_REGISTRY` doesn't name another. */
13
13
  export const DEFAULT_REGISTRY = "https://registry.npmjs.org";