@mercury-fw/cli 0.25.0 → 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 +29 -0
- package/README.md +140 -7
- package/dist/src/app/commands.d.ts +70 -0
- package/dist/src/app/find-app.d.ts +6 -0
- package/dist/src/args.d.ts +18 -3
- package/dist/src/main.d.ts +7 -2
- package/dist/src/program.d.ts +22 -0
- package/package.json +4 -3
- package/src/app/commands.ts +148 -0
- package/src/app/find-app.ts +25 -0
- package/src/args.ts +23 -33
- package/src/main.ts +21 -62
- package/src/program.ts +230 -0
- package/src/render.ts +12 -4
- package/src/versions.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
22
|
+
## 0.25.1
|
|
23
|
+
|
|
24
|
+
### Patch Changes
|
|
25
|
+
|
|
26
|
+
- 5613e5e: - Mercury starts even when Qdrant isn't answering yet: the memory collections are set up in the background and retried until Qdrant is reachable, instead of crashing the process at startup.
|
|
27
|
+
- A new session's context primer goes on without the last-session recap when Qdrant doesn't answer, instead of failing the turn.
|
|
28
|
+
- The scaffolded app's `docker-compose.yml` restarts the app service unless it was stopped.
|
|
29
|
+
- Updated dependencies [5613e5e]
|
|
30
|
+
- @mercury-fw/core@0.25.1
|
|
31
|
+
|
|
3
32
|
## 0.25.0
|
|
4
33
|
|
|
5
34
|
### 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)
|
|
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
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/src/args.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
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;
|
package/dist/src/main.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* pre-fill it.
|
|
5
|
-
*
|
|
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
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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();
|
|
@@ -267,6 +273,8 @@ function renderCompose(name: string, hasTools: boolean): string {
|
|
|
267
273
|
"services:",
|
|
268
274
|
" mercury:",
|
|
269
275
|
" build: .",
|
|
276
|
+
" # Back up after a crash or a host reboot.",
|
|
277
|
+
" restart: unless-stopped",
|
|
270
278
|
" env_file:",
|
|
271
279
|
" - path: .env",
|
|
272
280
|
" required: false",
|
|
@@ -315,11 +323,11 @@ A Mercury app, scaffolded by \`mfw create\`.
|
|
|
315
323
|
\`\`\`bash
|
|
316
324
|
bun install
|
|
317
325
|
cp .env.example .env
|
|
318
|
-
|
|
319
|
-
|
|
326
|
+
bunx mfw start
|
|
327
|
+
bunx mfw repl
|
|
320
328
|
\`\`\`
|
|
321
329
|
|
|
322
|
-
\`bun install\` here gives your editor
|
|
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.
|
|
323
331
|
${tools.length > 0 ? CREDENTIALS_SECTION : ""}`;
|
|
324
332
|
}
|
|
325
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";
|