@mercury-fw/cli 0.25.1 → 0.27.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 +32 -0
- package/README.md +162 -7
- package/dist/src/app/commands.d.ts +76 -0
- package/dist/src/app/credentials.d.ts +12 -0
- package/dist/src/app/find-app.d.ts +6 -0
- package/dist/src/args.d.ts +18 -3
- package/dist/src/catalog.d.ts +9 -0
- 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 +214 -0
- package/src/app/credentials.ts +82 -0
- package/src/app/find-app.ts +25 -0
- package/src/args.ts +23 -33
- package/src/catalog.ts +10 -0
- package/src/main.ts +21 -62
- package/src/program.ts +259 -0
- package/src/render.ts +95 -13
- package/src/versions.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# @mercury-fw/cli
|
|
2
2
|
|
|
3
|
+
## 0.27.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- d2a6be5: - A scaffolded app with tool plugins gets a `docker-entrypoint.sh`: at the first start without a CLI's config folder on the credentials volume, it unpacks that CLI's variable from the env file there, then starts the service. What the CLI refreshes afterwards stays on the volume.
|
|
8
|
+
- The scaffolded env example lists each tool plugin's credentials variable, and the README explains the flow.
|
|
9
|
+
- `mfw credentials set <plugin>` packs a CLI's config folder (`~/.config/<cli>`, or `--from <dir>`) into its variable in the app's env file, never printing it; `--print` prints the line to paste elsewhere.
|
|
10
|
+
- `mfw credentials reset <plugin>` clears a CLI's folder from the credentials volume after you type the plugin's name, so a corrected variable is unpacked at the next start.
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- @mercury-fw/core@0.27.0
|
|
15
|
+
|
|
16
|
+
## 0.26.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- 8b92abb: - `mfw` operates an app from inside its folder: `start`, `stop` and `restart` (with `--no-cache`), `logs`, `repl`, `shell`, all as `docker compose` calls.
|
|
21
|
+
- `mfw vault` maintains the wiki vault (`list`, `read`, `grep`, `write-curated`, `write-raw`) in a one-off container.
|
|
22
|
+
- `mfw memory list` and `mfw memory read <collection>` read the memory on Qdrant, newest first where the collection has a timestamp index.
|
|
23
|
+
- `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.
|
|
24
|
+
- The command line is declared with commander: `--help` at every level, a suggestion for a mistyped command, and every argument checked before anything runs.
|
|
25
|
+
- A scaffolded app lists `@mercury-fw/cli` among its devDependencies, at the framework's version, and its README runs it through `bunx mfw`.
|
|
26
|
+
- A scaffolded app's `@types/bun` and `typescript` use caret ranges instead of exact versions.
|
|
27
|
+
- The core ships a read-only memory CLI (`src/memory/memory-cli.ts`) next to the vault one, which is what `mfw memory` runs.
|
|
28
|
+
- The vault CLI says so when asked to read a note that doesn't exist, instead of printing a stack trace.
|
|
29
|
+
|
|
30
|
+
### Patch Changes
|
|
31
|
+
|
|
32
|
+
- Updated dependencies [8b92abb]
|
|
33
|
+
- @mercury-fw/core@0.26.0
|
|
34
|
+
|
|
3
35
|
## 0.25.1
|
|
4
36
|
|
|
5
37
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,18 +1,39 @@
|
|
|
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
|
+
- [`mfw credentials set <plugin> [--from <dir>] [--print]`](#mfw-credentials-set-plugin---from-dir---print)
|
|
21
|
+
- [`mfw credentials reset <plugin>`](#mfw-credentials-reset-plugin)
|
|
22
|
+
- [Help](#help)
|
|
8
23
|
|
|
9
|
-
|
|
24
|
+
## Getting it
|
|
25
|
+
|
|
26
|
+
A new app comes from `bun create mercury-agent`, which is `mfw create` under the `create` convention:
|
|
10
27
|
|
|
11
28
|
```bash
|
|
12
29
|
bun create mercury-agent my-agent
|
|
13
30
|
```
|
|
14
31
|
|
|
15
|
-
|
|
32
|
+
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.
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
### `mfw create <folder>`
|
|
16
37
|
|
|
17
38
|
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
39
|
|
|
@@ -32,9 +53,143 @@ It writes the `mercury.config.ts` for that selection, the persona, the service a
|
|
|
32
53
|
| `-y`, `--yes` | No questions: the flags, and the defaults for the rest. |
|
|
33
54
|
|
|
34
55
|
```bash
|
|
35
|
-
|
|
56
|
+
bun create mercury-agent my-agent
|
|
57
|
+
bunx @mercury-fw/cli create my-agent --assistant-name Hermes --channels http --plugins jira --yes
|
|
36
58
|
```
|
|
37
59
|
|
|
38
60
|
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
61
|
|
|
62
|
+
### `mfw start [--no-cache]`
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
bunx mfw start
|
|
68
|
+
bunx mfw start --no-cache
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `mfw stop`
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
bunx mfw stop
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### `mfw restart [--no-cache]`
|
|
80
|
+
|
|
81
|
+
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`.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bunx mfw restart
|
|
85
|
+
bunx mfw restart --no-cache
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### `mfw logs [service]`
|
|
89
|
+
|
|
90
|
+
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.
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bunx mfw logs
|
|
94
|
+
bunx mfw logs mercury
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### `mfw repl`
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
bunx mfw repl
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
### `mfw shell`
|
|
106
|
+
|
|
107
|
+
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.
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
bunx mfw shell
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### `mfw vault <command>`
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
| Command | |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `list` | Every note. |
|
|
120
|
+
| `read <path>` | One note (a path that isn't one says so, exit 1). |
|
|
121
|
+
| `grep <pattern>` | Every line matching `<pattern>`, a regular expression, as `path:line:text`. A pattern starting with `-` goes after `--` (`mfw vault grep -- -h`). |
|
|
122
|
+
| `write-curated <path> [--author NAME]` | Writes a curated note, the body read from stdin. |
|
|
123
|
+
| `write-raw <path>` | Writes raw material for the nightly review to triage, the body read from stdin. |
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
bunx mfw vault list
|
|
127
|
+
bunx mfw vault read curated/standards/jira-fields.md
|
|
128
|
+
bunx mfw vault grep "story points"
|
|
129
|
+
cat note.md | bunx mfw vault write-curated curated/standards/new-note.md --author luca
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
There's no command writing inferred notes on purpose: those are the agent's own, written only by its consolidation.
|
|
133
|
+
|
|
134
|
+
### `mfw memory list`
|
|
135
|
+
|
|
136
|
+
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.
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
bunx mfw memory list
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
episodic_memory 25 points
|
|
144
|
+
semantic_facts 20 points
|
|
145
|
+
tool_corrections 37 points
|
|
146
|
+
verbatim_archive 0 points
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### `mfw memory read <collection> [--limit N]`
|
|
150
|
+
|
|
151
|
+
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.
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
bunx mfw memory read episodic_memory
|
|
155
|
+
bunx mfw memory read semantic_facts --limit 5
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### `mfw reset <memory|wiki>`
|
|
159
|
+
|
|
160
|
+
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.
|
|
161
|
+
|
|
162
|
+
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.
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
bunx mfw reset memory
|
|
166
|
+
bunx mfw reset wiki
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Useful for clearing out test data; the other layer isn't touched.
|
|
170
|
+
|
|
171
|
+
### `mfw credentials set <plugin> [--from <dir>] [--print]`
|
|
172
|
+
|
|
173
|
+
Hands a tool plugin's CLI its login. Every such CLI keeps it in a config folder of its own (`jira-cli`, `bitbucket-cli`, `atlassian-admin-cli`); log in with the CLI on your machine first, then this packs that folder (`~/.config/<cli>`, or `--from` when it lives elsewhere) into a base64 tar.gz and writes it as the plugin's variable in the app's `.env` (`JIRA_CLI_CONFIG_TAR_B64` and so on), replacing an older value and leaving the other lines alone. The value is never printed; `--print` prints the whole line instead and leaves `.env` alone, for pasting it into another host's.
|
|
174
|
+
|
|
175
|
+
When the container starts, the app's `docker-entrypoint.sh` unpacks the variable onto the credentials volume, but only if that CLI's folder isn't there yet: what the CLI writes back while running, like a refreshed token, stays on the volume across redeploys, and an older value in `.env` never overwrites it. `<plugin>` has to be a tool plugin the app depends on.
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
bunx mfw credentials set jira
|
|
179
|
+
bunx mfw credentials set bitbucket --from ~/work/bitbucket-login
|
|
180
|
+
bunx mfw credentials set jira --print
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### `mfw credentials reset <plugin>`
|
|
184
|
+
|
|
185
|
+
Deletes the plugin's CLI folder from the credentials volume, so the variable in `.env` is unpacked again at the next start: what to run after correcting a variable whose folder is already on the volume, since the entrypoint never touches an existing folder. It asks you to type the plugin's name first, because a token the CLI refreshed on the volume goes too (and with a CLI that rotates its refresh token, the one in `.env` may no longer work). Once confirmed it stops the app, removes the folder in a one-off container of the app's own image, and starts the app again (`docker compose stop mercury`, `run --rm --no-deps -T mercury rm -rf …`, `up -d mercury`).
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
bunx mfw credentials reset jira
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Help
|
|
192
|
+
|
|
193
|
+
`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.
|
|
194
|
+
|
|
40
195
|
MIT
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { App } from "./find-app.ts";
|
|
2
|
+
export type AppDeps = {
|
|
3
|
+
/** Runs `argv` in `cwd` on the user's terminal (stdin, stdout, stderr) and returns its exit code. */
|
|
4
|
+
run: (argv: string[], opts: {
|
|
5
|
+
cwd: string;
|
|
6
|
+
}) => Promise<number>;
|
|
7
|
+
/** Runs `argv` in `cwd` and returns its stdout; throws when it fails. */
|
|
8
|
+
capture: (argv: string[], opts: {
|
|
9
|
+
cwd: string;
|
|
10
|
+
}) => Promise<string>;
|
|
11
|
+
/** Asks the user `question` and returns the answer as typed. */
|
|
12
|
+
ask: (question: string) => Promise<string>;
|
|
13
|
+
/** Tells the user something. */
|
|
14
|
+
print: (line: string) => void;
|
|
15
|
+
/** The user's home folder, where a CLI keeps its config (`~/.config/<cli>`). */
|
|
16
|
+
home: string;
|
|
17
|
+
};
|
|
18
|
+
/** The core's maintenance CLIs, from the container's working directory (the
|
|
19
|
+
* app's folder, where node_modules/@mercury-fw/core is). A path and not a bin:
|
|
20
|
+
* a monorepo image installs before copying the sources, and Bun doesn't link a
|
|
21
|
+
* bin whose file isn't there yet. The core moves in lockstep with this CLI. */
|
|
22
|
+
export declare const VAULT_CLI = "node_modules/@mercury-fw/core/src/wiki/vault-cli.ts";
|
|
23
|
+
export declare const MEMORY_CLI = "node_modules/@mercury-fw/core/src/memory/memory-cli.ts";
|
|
24
|
+
/** What `mfw reset` can wipe: the compose service using the volume, the
|
|
25
|
+
* volume's key in the compose file, and how to say what's lost. */
|
|
26
|
+
export declare const RESET_TARGETS: {
|
|
27
|
+
readonly memory: {
|
|
28
|
+
readonly service: "qdrant";
|
|
29
|
+
readonly volume: "qdrant-data";
|
|
30
|
+
readonly what: "Layer-3 memory (every Qdrant collection)";
|
|
31
|
+
};
|
|
32
|
+
readonly wiki: {
|
|
33
|
+
readonly service: "mercury";
|
|
34
|
+
readonly volume: "wiki-vault";
|
|
35
|
+
readonly what: "the wiki vault (every note)";
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
export type ResetTarget = keyof typeof RESET_TARGETS;
|
|
39
|
+
/** The commands bound to `app`, each returning its exit code. */
|
|
40
|
+
export declare function appCommands(app: App, deps: AppDeps): {
|
|
41
|
+
start: ({ noCache }: {
|
|
42
|
+
noCache: boolean;
|
|
43
|
+
}) => Promise<number>;
|
|
44
|
+
restart: ({ noCache }: {
|
|
45
|
+
noCache: boolean;
|
|
46
|
+
}) => Promise<number>;
|
|
47
|
+
stop: () => Promise<number>;
|
|
48
|
+
logs: (service?: string) => Promise<number>;
|
|
49
|
+
repl: () => Promise<number>;
|
|
50
|
+
shell: () => Promise<number>;
|
|
51
|
+
/** `args` is the vault CLI's own command line (`list`, `read <path>`, …). */
|
|
52
|
+
vault: (args: string[]) => Promise<number>;
|
|
53
|
+
/** `args` is the memory CLI's own command line (`list`, `read <collection>`, …). */
|
|
54
|
+
memory: (args: string[]) => Promise<number>;
|
|
55
|
+
/** Deletes `target`'s volume once the user types the app's name, then
|
|
56
|
+
* brings its service back up on an empty volume. The volume's real name
|
|
57
|
+
* comes from the compose file, and a wrong answer deletes nothing. */
|
|
58
|
+
reset: (target: ResetTarget) => Promise<number>;
|
|
59
|
+
/** Packs the plugin's CLI config folder (`from`, by default
|
|
60
|
+
* `~/.config/<folder>`) into its credentials variable, written into the
|
|
61
|
+
* app's env file, or printed with `print`. */
|
|
62
|
+
credentialsSet: (plugin: string, { from, print }: {
|
|
63
|
+
from?: string;
|
|
64
|
+
print: boolean;
|
|
65
|
+
}) => Promise<number>;
|
|
66
|
+
/** Deletes the plugin's CLI folder from the credentials volume once the
|
|
67
|
+
* user types the plugin's name, so its variable is unpacked again at the
|
|
68
|
+
* next start. A wrong answer deletes nothing. */
|
|
69
|
+
credentialsReset: (plugin: string) => Promise<number>;
|
|
70
|
+
};
|
|
71
|
+
/** The real deps: docker on the user's terminal, questions on `input`
|
|
72
|
+
* (stdin by default). */
|
|
73
|
+
export declare function terminalDeps({ input, output, }?: {
|
|
74
|
+
input?: NodeJS.ReadableStream;
|
|
75
|
+
output?: NodeJS.WritableStream;
|
|
76
|
+
}): AppDeps;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** `from` packed as `<folder>/…` into a base64 tar.gz on one line. The real
|
|
2
|
+
* folder behind `from` is archived through a symlink named `folder` and
|
|
3
|
+
* dereferenced (`-h`): whatever `from` is called, and even when it's itself a
|
|
4
|
+
* symlink (dotfiles), the archive holds the files under the CLI's folder name. */
|
|
5
|
+
export declare function packCredentials(from: string, folder: string): Promise<string>;
|
|
6
|
+
/** Sets `name=value` in the env file at `file`. Every line that assigns
|
|
7
|
+
* `name` (`export name=` and spaces around `=` included) gives way to one
|
|
8
|
+
* line, where the first was; with none, it's appended. Every other line stays
|
|
9
|
+
* as it was. The file is replaced whole through a temporary file next to it,
|
|
10
|
+
* keeping its permissions, so a failed write never leaves it half written; a
|
|
11
|
+
* missing file is created readable by its owner only. */
|
|
12
|
+
export declare function setEnvVar(file: string, name: string, value: string): void;
|
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/catalog.d.ts
CHANGED
|
@@ -21,6 +21,15 @@ export type CatalogEntry = {
|
|
|
21
21
|
exportName: string;
|
|
22
22
|
env: EnvVar[];
|
|
23
23
|
formatter?: FormatterExample;
|
|
24
|
+
credentials?: CliCredentials;
|
|
25
|
+
};
|
|
26
|
+
/** Where a tool plugin's CLI keeps its login: `folder` under the CLI user's
|
|
27
|
+
* `~/.config`, and the env variable that carries that folder into the
|
|
28
|
+
* container (a base64 tar.gz with the folder at its root), materialized on
|
|
29
|
+
* the credentials volume the first time the folder isn't there. */
|
|
30
|
+
export type CliCredentials = {
|
|
31
|
+
folder: string;
|
|
32
|
+
variable: string;
|
|
24
33
|
};
|
|
25
34
|
/** Starting formatter rules for a plugin that hands the user lists: without a
|
|
26
35
|
* rule a kind of list isn't shown, so the scaffolded config wraps the plugin
|
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.27.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.27.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.27.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,214 @@
|
|
|
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 { readFileSync } from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { CATALOG, type CliCredentials } from "../catalog.ts";
|
|
12
|
+
import { packCredentials, setEnvVar } from "./credentials.ts";
|
|
13
|
+
import type { App } from "./find-app.ts";
|
|
14
|
+
|
|
15
|
+
export type AppDeps = {
|
|
16
|
+
/** Runs `argv` in `cwd` on the user's terminal (stdin, stdout, stderr) and returns its exit code. */
|
|
17
|
+
run: (argv: string[], opts: { cwd: string }) => Promise<number>;
|
|
18
|
+
/** Runs `argv` in `cwd` and returns its stdout; throws when it fails. */
|
|
19
|
+
capture: (argv: string[], opts: { cwd: string }) => Promise<string>;
|
|
20
|
+
/** Asks the user `question` and returns the answer as typed. */
|
|
21
|
+
ask: (question: string) => Promise<string>;
|
|
22
|
+
/** Tells the user something. */
|
|
23
|
+
print: (line: string) => void;
|
|
24
|
+
/** The user's home folder, where a CLI keeps its config (`~/.config/<cli>`). */
|
|
25
|
+
home: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const COMPOSE = ["docker", "compose"];
|
|
29
|
+
|
|
30
|
+
/** The app's service, the one the image builds. */
|
|
31
|
+
const SERVICE = "mercury";
|
|
32
|
+
|
|
33
|
+
/** The core's maintenance CLIs, from the container's working directory (the
|
|
34
|
+
* app's folder, where node_modules/@mercury-fw/core is). A path and not a bin:
|
|
35
|
+
* a monorepo image installs before copying the sources, and Bun doesn't link a
|
|
36
|
+
* bin whose file isn't there yet. The core moves in lockstep with this CLI. */
|
|
37
|
+
export const VAULT_CLI = "node_modules/@mercury-fw/core/src/wiki/vault-cli.ts";
|
|
38
|
+
export const MEMORY_CLI = "node_modules/@mercury-fw/core/src/memory/memory-cli.ts";
|
|
39
|
+
|
|
40
|
+
/** What `mfw reset` can wipe: the compose service using the volume, the
|
|
41
|
+
* volume's key in the compose file, and how to say what's lost. */
|
|
42
|
+
export const RESET_TARGETS = {
|
|
43
|
+
memory: { service: "qdrant", volume: "qdrant-data", what: "Layer-3 memory (every Qdrant collection)" },
|
|
44
|
+
wiki: { service: SERVICE, volume: "wiki-vault", what: "the wiki vault (every note)" },
|
|
45
|
+
} as const;
|
|
46
|
+
export type ResetTarget = keyof typeof RESET_TARGETS;
|
|
47
|
+
|
|
48
|
+
/** The compose calls that build and start the app; `recreate` restarts containers even when nothing changed. */
|
|
49
|
+
function startCalls(noCache: boolean, recreate: boolean): string[][] {
|
|
50
|
+
const up = [...COMPOSE, "up", "-d", ...(noCache ? [] : ["--build"]), ...(recreate ? ["--force-recreate"] : [])];
|
|
51
|
+
return noCache ? [[...COMPOSE, "build", "--no-cache"], up] : [up];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The commands bound to `app`, each returning its exit code. */
|
|
55
|
+
export function appCommands(app: App, deps: AppDeps) {
|
|
56
|
+
/** Runs `calls` in order from the app's folder, stopping at the first that fails; returns its exit code, 0 if none did. */
|
|
57
|
+
const runAll = async (calls: string[][]): Promise<number> => {
|
|
58
|
+
for (const argv of calls) {
|
|
59
|
+
const code = await deps.run(argv, { cwd: app.dir });
|
|
60
|
+
if (code !== 0) return code;
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
};
|
|
64
|
+
const oneOff = (argv: string[]) => runAll([[...COMPOSE, "run", "--rm", "-T", SERVICE, ...argv]]);
|
|
65
|
+
/** The compose services running right now. */
|
|
66
|
+
const running = async (): Promise<string[]> =>
|
|
67
|
+
(await deps.capture([...COMPOSE, "ps", "--status", "running", "--services"], { cwd: app.dir }))
|
|
68
|
+
.split("\n")
|
|
69
|
+
.map((s) => s.trim());
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
start: ({ noCache }: { noCache: boolean }) => runAll(startCalls(noCache, false)),
|
|
73
|
+
restart: ({ noCache }: { noCache: boolean }) => runAll(startCalls(noCache, true)),
|
|
74
|
+
stop: () => runAll([[...COMPOSE, "down"]]),
|
|
75
|
+
logs: (service?: string) => runAll([[...COMPOSE, "logs", "-f", ...(service === undefined ? [] : [service])]]),
|
|
76
|
+
repl: () => runAll([[...COMPOSE, "run", "--rm", SERVICE, "bun", "run", "repl"]]),
|
|
77
|
+
shell: async () => {
|
|
78
|
+
const shell = (await running()).includes(SERVICE) ? ["exec", SERVICE, "bash"] : ["run", "--rm", SERVICE, "bash"];
|
|
79
|
+
return runAll([[...COMPOSE, ...shell]]);
|
|
80
|
+
},
|
|
81
|
+
/** `args` is the vault CLI's own command line (`list`, `read <path>`, …). */
|
|
82
|
+
vault: (args: string[]) => oneOff(["bun", VAULT_CLI, ...args]),
|
|
83
|
+
/** `args` is the memory CLI's own command line (`list`, `read <collection>`, …). */
|
|
84
|
+
memory: (args: string[]) => oneOff(["bun", MEMORY_CLI, ...args]),
|
|
85
|
+
/** Deletes `target`'s volume once the user types the app's name, then
|
|
86
|
+
* brings its service back up on an empty volume. The volume's real name
|
|
87
|
+
* comes from the compose file, and a wrong answer deletes nothing. */
|
|
88
|
+
reset: async (target: ResetTarget) => {
|
|
89
|
+
const { service, volume: key, what } = RESET_TARGETS[target];
|
|
90
|
+
const config = JSON.parse(
|
|
91
|
+
await deps.capture([...COMPOSE, "config", "--no-interpolate", "--format", "json"], { cwd: app.dir }),
|
|
92
|
+
) as { volumes?: Record<string, { name?: string }> };
|
|
93
|
+
const volume = config.volumes?.[key]?.name;
|
|
94
|
+
if (volume === undefined) {
|
|
95
|
+
throw new Error(`The compose file has no "${key}" volume to reset`);
|
|
96
|
+
}
|
|
97
|
+
const answer = await deps.ask(`This deletes ${what} for good: volume ${volume}. Type the app's name (${app.name}) to confirm: `);
|
|
98
|
+
if (answer.trim() !== app.name) {
|
|
99
|
+
deps.print("Not confirmed: nothing deleted.");
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
const code = await stopped(service, [
|
|
103
|
+
[...COMPOSE, "rm", "-f", service],
|
|
104
|
+
["docker", "volume", "rm", volume],
|
|
105
|
+
]);
|
|
106
|
+
if (code !== 0) return code;
|
|
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
|
+
/** Packs the plugin's CLI config folder (`from`, by default
|
|
114
|
+
* `~/.config/<folder>`) into its credentials variable, written into the
|
|
115
|
+
* app's env file, or printed with `print`. */
|
|
116
|
+
credentialsSet: async (plugin: string, { from, print }: { from?: string; print: boolean }) => {
|
|
117
|
+
const { folder, variable } = credentialsOf(app, plugin);
|
|
118
|
+
const source = resolve(from ?? join(deps.home, ".config", folder));
|
|
119
|
+
const value = await packCredentials(source, folder);
|
|
120
|
+
if (print) {
|
|
121
|
+
deps.print(`${variable}=${value}`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
const envFile = join(app.dir, ".env");
|
|
125
|
+
setEnvVar(envFile, variable, value);
|
|
126
|
+
deps.print(`${variable} set in ${envFile}, from ${source}.`);
|
|
127
|
+
deps.print(
|
|
128
|
+
`The app unpacks it at its next start, if the volume has no ${folder} folder yet; if it has one, run bunx mfw credentials reset ${plugin} first.`,
|
|
129
|
+
);
|
|
130
|
+
return 0;
|
|
131
|
+
},
|
|
132
|
+
/** Deletes the plugin's CLI folder from the credentials volume once the
|
|
133
|
+
* user types the plugin's name, so its variable is unpacked again at the
|
|
134
|
+
* next start. A wrong answer deletes nothing. */
|
|
135
|
+
credentialsReset: async (plugin: string) => {
|
|
136
|
+
const { folder } = credentialsOf(app, plugin);
|
|
137
|
+
const answer = await deps.ask(
|
|
138
|
+
`This deletes ${folder}'s folder from the app's credentials volume, and any token the CLI refreshed since it was unpacked. Type the plugin's name (${plugin}) to confirm: `,
|
|
139
|
+
);
|
|
140
|
+
if (answer.trim() !== plugin) {
|
|
141
|
+
deps.print("Not confirmed: nothing deleted.");
|
|
142
|
+
return 1;
|
|
143
|
+
}
|
|
144
|
+
return stopped(SERVICE, [[...COMPOSE, "run", "--rm", "--no-deps", "-T", SERVICE, "rm", "-rf", `/home/mercury/.config/${folder}`]]);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Stops `service`, runs `steps`, starts it again; stops at the first
|
|
149
|
+
* failure and, once the service is stopped, says it's down and how to bring
|
|
150
|
+
* it back. Returns the exit code. */
|
|
151
|
+
async function stopped(service: string, steps: string[][]): Promise<number> {
|
|
152
|
+
const all = [[...COMPOSE, "stop", service], ...steps, [...COMPOSE, "up", "-d", service]];
|
|
153
|
+
for (const [i, argv] of all.entries()) {
|
|
154
|
+
const code = await deps.run(argv, { cwd: app.dir });
|
|
155
|
+
if (code === 0) continue;
|
|
156
|
+
if (i > 0) deps.print(`The ${service} service was stopped and not restarted: bunx mfw start brings it back.`);
|
|
157
|
+
return code;
|
|
158
|
+
}
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The credentials of the tool plugin `plugin`, which `app` must depend on;
|
|
164
|
+
* throws naming the ones it has otherwise. */
|
|
165
|
+
function credentialsOf(app: App, plugin: string): CliCredentials {
|
|
166
|
+
const manifest = JSON.parse(readFileSync(join(app.dir, "package.json"), "utf-8")) as {
|
|
167
|
+
dependencies?: Record<string, string>;
|
|
168
|
+
};
|
|
169
|
+
const have = CATALOG.filter(
|
|
170
|
+
(e) => e.kind === "tool" && e.credentials !== undefined && manifest.dependencies?.[e.package] !== undefined,
|
|
171
|
+
);
|
|
172
|
+
const entry = have.find((e) => e.id === plugin);
|
|
173
|
+
if (entry?.credentials === undefined) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`${app.name} has no "${plugin}" tool plugin with CLI credentials. It has: ${have.map((e) => e.id).join(", ") || "none"}.`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return entry.credentials;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** The real deps: docker on the user's terminal, questions on `input`
|
|
182
|
+
* (stdin by default). */
|
|
183
|
+
export function terminalDeps({
|
|
184
|
+
input = process.stdin,
|
|
185
|
+
output = process.stdout,
|
|
186
|
+
}: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {}): AppDeps {
|
|
187
|
+
return {
|
|
188
|
+
run: async (argv, { cwd }) => {
|
|
189
|
+
const proc = Bun.spawn(argv, { cwd, stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
190
|
+
return await proc.exited;
|
|
191
|
+
},
|
|
192
|
+
capture: async (argv, { cwd }) => {
|
|
193
|
+
const proc = Bun.spawn(argv, { cwd, stdin: "ignore", stdout: "pipe", stderr: "inherit" });
|
|
194
|
+
const [out, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
|
|
195
|
+
if (code !== 0) throw new Error(`${argv.join(" ")} failed (exit ${code})`);
|
|
196
|
+
return out;
|
|
197
|
+
},
|
|
198
|
+
// Input closing before a line (Ctrl+D, `< /dev/null`) is an empty answer,
|
|
199
|
+
// never a question left waiting.
|
|
200
|
+
ask: async (question) => {
|
|
201
|
+
const { createInterface } = await import("node:readline");
|
|
202
|
+
const rl = createInterface({ input, output });
|
|
203
|
+
return await new Promise<string>((resolve) => {
|
|
204
|
+
rl.once("close", () => resolve(""));
|
|
205
|
+
rl.question(question, (answer) => {
|
|
206
|
+
resolve(answer);
|
|
207
|
+
rl.close();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
print: (line) => console.log(line),
|
|
212
|
+
home: homedir(),
|
|
213
|
+
};
|
|
214
|
+
}
|