@mercury-fw/cli 0.33.0 → 0.35.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 +35 -0
- package/README.md +37 -4
- package/dist/src/app/commands.d.ts +3 -2
- package/dist/src/app/local-packages.d.ts +18 -3
- package/dist/src/args.d.ts +6 -0
- package/dist/src/catalog.d.ts +6 -5
- package/dist/src/e2e/define.d.ts +30 -7
- package/dist/src/e2e/http-session.d.ts +30 -0
- package/dist/src/e2e/runner.d.ts +18 -8
- package/dist/src/e2e/session.d.ts +2 -2
- package/dist/src/render.d.ts +11 -4
- package/dist/src/versions.d.ts +6 -2
- package/dist/src/wizard.d.ts +2 -0
- package/package.json +8 -6
- package/src/app/commands.ts +20 -18
- package/src/app/local-packages.ts +34 -5
- package/src/args.ts +8 -0
- package/src/catalog.ts +29 -6
- package/src/e2e/define.ts +28 -8
- package/src/e2e/http-session.ts +122 -0
- package/src/e2e/runner.ts +63 -23
- package/src/e2e/session.ts +2 -2
- package/src/main.ts +31 -10
- package/src/program.ts +10 -2
- package/src/render.ts +52 -31
- package/src/versions.ts +13 -5
- package/src/wizard.ts +29 -2
package/src/render.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* from `template/` imported as text, so they are bundled with the CLI; the
|
|
6
6
|
* config, the manifest, the env example, the compose file, the persona and the
|
|
7
7
|
* README are generated from the selection. Channels and plugins are always
|
|
8
|
-
* written in catalog order, whatever order they were chosen in
|
|
8
|
+
* written in catalog order, whatever order they were chosen in; the auth
|
|
9
|
+
* provider, when there is one, after the channels.
|
|
9
10
|
*/
|
|
10
11
|
import { DEFAULT_PERSONA_TONE } from "@mercury-fw/core";
|
|
11
12
|
import { CATALOG, type CatalogEntry, type EnvVar } from "./catalog.ts";
|
|
@@ -25,6 +26,8 @@ export type RenderInput = {
|
|
|
25
26
|
role: string;
|
|
26
27
|
channels: string[];
|
|
27
28
|
plugins: string[];
|
|
29
|
+
/** The HTTP channel's auth provider: required with it, refused without it. */
|
|
30
|
+
auth?: string;
|
|
28
31
|
versions: Record<string, string>;
|
|
29
32
|
};
|
|
30
33
|
|
|
@@ -47,8 +50,9 @@ const CORE_ENV: EnvVar[] = [
|
|
|
47
50
|
];
|
|
48
51
|
|
|
49
52
|
const CONFIG_HEADER = `/**
|
|
50
|
-
* This app's composition: the tool plugins and channels it runs,
|
|
51
|
-
*
|
|
53
|
+
* This app's composition: the tool plugins and channels it runs, the auth
|
|
54
|
+
* provider when a channel needs one, how the lists its plugins hand over read,
|
|
55
|
+
* and the assistant's persona. The entrypoints
|
|
52
56
|
* (\`src/index.ts\`, \`src/repl.ts\`) hand this config to \`composeMercury\`.
|
|
53
57
|
*
|
|
54
58
|
* Every tool plugin and channel declared here is active.
|
|
@@ -61,18 +65,19 @@ export function renderApp(input: RenderInput): Map<string, string> {
|
|
|
61
65
|
validate(input);
|
|
62
66
|
const channels = selected("channel", input.channels);
|
|
63
67
|
const tools = selected("tool", input.plugins);
|
|
68
|
+
const auth = selected("auth", input.auth === undefined ? [] : [input.auth])[0];
|
|
64
69
|
const assistantName = input.assistantName.trim();
|
|
65
70
|
|
|
66
71
|
return new Map([
|
|
67
72
|
[".dockerignore", dockerignore],
|
|
68
|
-
[".env.example", renderEnv(channels, tools)],
|
|
73
|
+
[".env.example", renderEnv(channels, auth, tools)],
|
|
69
74
|
[".gitignore", gitignore],
|
|
70
75
|
["Dockerfile", dockerfile],
|
|
71
|
-
["README.md", renderReadme(input.name, channels, tools)],
|
|
76
|
+
["README.md", renderReadme(input.name, channels, auth, tools)],
|
|
72
77
|
["docker-compose.yml", renderCompose(input.name, channels.some((c) => c.id === "http"))],
|
|
73
78
|
["markdown.d.ts", markdownDts],
|
|
74
|
-
["mercury.config.ts", renderConfig(channels, tools)],
|
|
75
|
-
["package.json", renderPackageJson(input.name, channels, tools, input.versions)],
|
|
79
|
+
["mercury.config.ts", renderConfig(channels, auth, tools)],
|
|
80
|
+
["package.json", renderPackageJson(input.name, channels, auth, tools, input.versions)],
|
|
76
81
|
["persona/identity.md", `You are ${assistantName}, ${input.role.trim().replace(/\.+$/, "")}.\n`],
|
|
77
82
|
// A replacer function, not a string: in a replacement string "$&" and the
|
|
78
83
|
// like are patterns, and the name must land verbatim.
|
|
@@ -107,30 +112,44 @@ function validate(input: RenderInput): void {
|
|
|
107
112
|
if (/[\r\n]/.test(input.assistantName) || /[\r\n]/.test(input.role)) {
|
|
108
113
|
throw new Error("The assistant name and role must each be one line");
|
|
109
114
|
}
|
|
110
|
-
const selection = selectionError(input.channels, input.plugins);
|
|
115
|
+
const selection = selectionError(input.channels, input.plugins, input.auth) ?? pairingError(input.channels, input.auth);
|
|
111
116
|
if (selection !== undefined) {
|
|
112
117
|
throw new Error(selection);
|
|
113
118
|
}
|
|
114
119
|
}
|
|
115
120
|
|
|
116
|
-
/**
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
/** The ids of the catalog entries of `kind`. */
|
|
122
|
+
const idsOf = (kind: CatalogEntry["kind"]): string[] => CATALOG.filter((e) => e.kind === kind).map((e) => e.id);
|
|
123
|
+
|
|
124
|
+
/** Names the first channel, plugin or auth provider id the catalog doesn't
|
|
125
|
+
* have, with the valid ones, or undefined when all are known. Shared with the
|
|
126
|
+
* command, which checks the flags before asking anything. */
|
|
127
|
+
export function selectionError(channels: string[], plugins: string[], auth?: string): string | undefined {
|
|
128
|
+
for (const [kind, ids, label] of [
|
|
129
|
+
["channel", channels, "channel"],
|
|
130
|
+
["tool", plugins, "plugin"],
|
|
131
|
+
["auth", auth === undefined ? [] : [auth], "auth provider"],
|
|
123
132
|
] as const) {
|
|
124
|
-
const valid =
|
|
133
|
+
const valid = idsOf(kind);
|
|
125
134
|
const unknown = ids.find((id) => !valid.includes(id));
|
|
126
135
|
if (unknown !== undefined) {
|
|
127
|
-
const label = kind === "channel" ? "channel" : "plugin";
|
|
128
136
|
return `Unknown ${label} "${unknown}" (valid: ${valid.join(", ")})`;
|
|
129
137
|
}
|
|
130
138
|
}
|
|
131
139
|
return undefined;
|
|
132
140
|
}
|
|
133
141
|
|
|
142
|
+
/** What's wrong with pairing the HTTP channel and the auth provider: it
|
|
143
|
+
* doesn't start without one, and one without it has nothing to authenticate.
|
|
144
|
+
* Undefined when they go together. Shared with the command, which checks it
|
|
145
|
+
* before writing when nothing will ask (`--yes`). */
|
|
146
|
+
export function pairingError(channels: string[], auth?: string): string | undefined {
|
|
147
|
+
const http = channels.includes("http");
|
|
148
|
+
if (http && auth === undefined) return `The http channel needs an auth provider (valid: ${idsOf("auth").join(", ")})`;
|
|
149
|
+
if (!http && auth !== undefined) return `The auth provider "${auth}" goes with the http channel, which isn't chosen`;
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
134
153
|
/** The catalog entries of `kind` among `ids`, deduplicated, in catalog order. */
|
|
135
154
|
function selected(kind: CatalogEntry["kind"], ids: string[]): CatalogEntry[] {
|
|
136
155
|
return CATALOG.filter((e) => e.kind === kind && ids.includes(e.id));
|
|
@@ -139,7 +158,7 @@ function selected(kind: CatalogEntry["kind"], ids: string[]): CatalogEntry[] {
|
|
|
139
158
|
/** `mercury.config.ts`: imports, the formatter helpers of the plugins that have
|
|
140
159
|
* any, then the config with each plugin (wrapped in the formatter when it has
|
|
141
160
|
* starting rules) and channel. */
|
|
142
|
-
function renderConfig(channels: CatalogEntry[], tools: CatalogEntry[]): string {
|
|
161
|
+
function renderConfig(channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
|
|
143
162
|
const withRules = tools.filter((t) => t.formatter);
|
|
144
163
|
const imports = ['import { defineMercuryConfig } from "@mercury-fw/core";'];
|
|
145
164
|
if (withRules.length > 0) {
|
|
@@ -149,7 +168,7 @@ function renderConfig(channels: CatalogEntry[], tools: CatalogEntry[]): string {
|
|
|
149
168
|
const names = t.formatter ? `${t.exportName}, type ${t.formatter.displaysType}` : t.exportName;
|
|
150
169
|
imports.push(`import { ${names} } from "${t.package}";`);
|
|
151
170
|
}
|
|
152
|
-
for (const c of channels) {
|
|
171
|
+
for (const c of [...channels, ...(auth ? [auth] : [])]) {
|
|
153
172
|
imports.push(`import { ${c.exportName} } from "${c.package}";`);
|
|
154
173
|
}
|
|
155
174
|
imports.push('import identity from "./persona/identity.md" with { type: "text" };');
|
|
@@ -171,6 +190,7 @@ function renderConfig(channels: CatalogEntry[], tools: CatalogEntry[]): string {
|
|
|
171
190
|
" persona: { identity, tone },",
|
|
172
191
|
plugins,
|
|
173
192
|
channelList,
|
|
193
|
+
...(auth ? [` auth: ${auth.exportName},`] : []),
|
|
174
194
|
"});",
|
|
175
195
|
"",
|
|
176
196
|
].join("\n");
|
|
@@ -197,10 +217,11 @@ function renderPluginEntry(t: CatalogEntry): string {
|
|
|
197
217
|
function renderPackageJson(
|
|
198
218
|
name: string,
|
|
199
219
|
channels: CatalogEntry[],
|
|
220
|
+
auth: CatalogEntry | undefined,
|
|
200
221
|
tools: CatalogEntry[],
|
|
201
222
|
versions: Record<string, string>,
|
|
202
223
|
): string {
|
|
203
|
-
const packages = ["@mercury-fw/core", ...channels
|
|
224
|
+
const packages = ["@mercury-fw/core", ...[...channels, ...(auth ? [auth] : []), ...tools].map((e) => e.package)];
|
|
204
225
|
if (tools.some((t) => t.formatter)) {
|
|
205
226
|
packages.push("@mercury-fw/formatter");
|
|
206
227
|
}
|
|
@@ -236,10 +257,10 @@ function renderPackageJson(
|
|
|
236
257
|
|
|
237
258
|
/** The env example: the core's variables, then a section per chosen entry
|
|
238
259
|
* that reads any variable. */
|
|
239
|
-
function renderEnv(channels: CatalogEntry[], tools: CatalogEntry[]): string {
|
|
260
|
+
function renderEnv(channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
|
|
240
261
|
const block = (vars: EnvVar[]) => vars.map((v) => `# ${v.comment}\n${v.name}=${v.value ?? ""}`).join("\n");
|
|
241
262
|
const sections = [block(CORE_ENV)];
|
|
242
|
-
for (const entry of [...channels, ...tools]) {
|
|
263
|
+
for (const entry of [...channels, ...(auth ? [auth] : []), ...tools]) {
|
|
243
264
|
if (entry.env.length > 0) {
|
|
244
265
|
sections.push(`# --- ${entry.id}\n${block(entry.env)}`);
|
|
245
266
|
}
|
|
@@ -254,7 +275,7 @@ function renderEnv(channels: CatalogEntry[], tools: CatalogEntry[]): string {
|
|
|
254
275
|
function renderCompose(name: string, hasHttp: boolean): string {
|
|
255
276
|
const httpPort = hasHttp
|
|
256
277
|
? [
|
|
257
|
-
" # The HTTP surface, on the host:
|
|
278
|
+
" # The HTTP surface, on the host: every route but its OpenAPI document needs a token.",
|
|
258
279
|
" ports:",
|
|
259
280
|
' - "${HTTP_SURFACE_PORT:-4100}:${HTTP_SURFACE_PORT:-4100}"',
|
|
260
281
|
]
|
|
@@ -296,14 +317,14 @@ function renderCompose(name: string, hasHttp: boolean): string {
|
|
|
296
317
|
}
|
|
297
318
|
|
|
298
319
|
/** The app's README: what it was scaffolded with and how to run it. */
|
|
299
|
-
function renderReadme(name: string, channels: CatalogEntry[], tools: CatalogEntry[]): string {
|
|
320
|
+
function renderReadme(name: string, channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
|
|
300
321
|
const list = (entries: CatalogEntry[]) => (entries.length > 0 ? entries.map((e) => e.id).join(", ") : "none");
|
|
301
322
|
return `# ${name}
|
|
302
323
|
|
|
303
324
|
A Mercury app, scaffolded by \`mfw create\`.
|
|
304
325
|
|
|
305
326
|
- Channels: ${list(channels)}
|
|
306
|
-
- Tool plugins: ${list(tools)}
|
|
327
|
+
${auth ? `- Auth: ${auth.id}\n` : ""}- Tool plugins: ${list(tools)}
|
|
307
328
|
|
|
308
329
|
## Layout
|
|
309
330
|
|
|
@@ -330,17 +351,17 @@ mfw repl
|
|
|
330
351
|
\`\`\`
|
|
331
352
|
|
|
332
353
|
\`bun install\` here gives your editor, \`bun run typecheck\` and the app's own \`mfw\` (the one a global \`mfw\` runs inside the app) the packages (tool plugins download their CLI binary as they install); the image installs its own copy when it builds. \`mfw start\` builds the image and starts the app with Qdrant in the background, \`mfw repl\` opens a terminal conversation with the assistant. \`mfw --help\` lists the rest: stopping and restarting, logs, a shell in the container, the wiki and the memory, and resetting them.
|
|
333
|
-
${renderHttpSection(channels)}${CREDENTIALS_SECTION}`;
|
|
354
|
+
${renderHttpSection(channels, auth)}${CREDENTIALS_SECTION}`;
|
|
334
355
|
}
|
|
335
356
|
|
|
336
|
-
/** The README section on the HTTP surface, for an app with the HTTP channel
|
|
337
|
-
* nothing without it. */
|
|
338
|
-
function renderHttpSection(channels: CatalogEntry[]): string {
|
|
339
|
-
if (!channels.some((c) => c.id === "http")) return "";
|
|
357
|
+
/** The README section on the HTTP surface, for an app with the HTTP channel
|
|
358
|
+
* (and so its auth provider); nothing without it. */
|
|
359
|
+
function renderHttpSection(channels: CatalogEntry[], auth: CatalogEntry | undefined): string {
|
|
360
|
+
if (!channels.some((c) => c.id === "http") || auth === undefined) return "";
|
|
340
361
|
return `
|
|
341
362
|
## HTTP surface
|
|
342
363
|
|
|
343
|
-
The HTTP channel listens on \`http://<host>:4100\`, the port in \`HTTP_SURFACE_PORT\` (the compose file publishes whatever port it holds on the host).
|
|
364
|
+
The HTTP channel listens on \`http://<host>:4100\`, the port in \`HTTP_SURFACE_PORT\` (the compose file publishes whatever port it holds on the host). Every route but its OpenAPI document needs \`Authorization: Bearer <token>\`, and the auth provider, \`${auth.id}\` (\`${auth.package}\`), decides who the token belongs to: its variables are in the env example. If the provider can't load, the channel doesn't start and the logs say why.
|
|
344
365
|
`;
|
|
345
366
|
}
|
|
346
367
|
|
package/src/versions.ts
CHANGED
|
@@ -50,14 +50,22 @@ async function latestVersion(
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** Maps the framework packages to the CLI's version and each of `packages`
|
|
53
|
-
* (plugins
|
|
54
|
-
*
|
|
53
|
+
* (plugins, channels, auth providers) to the registry's `latest`. A package
|
|
54
|
+
* in `local` (packed tarballs, `--local-packages`) takes that version instead,
|
|
55
|
+
* a framework one included, and the registry isn't asked for it. Rejects
|
|
56
|
+
* naming the package the registry doesn't have, or saying the registry can't
|
|
57
|
+
* be reached. */
|
|
55
58
|
export async function appVersions(
|
|
56
59
|
packages: string[],
|
|
57
|
-
opts: { registry: string; fetchFn?: typeof fetch },
|
|
60
|
+
opts: { registry: string; fetchFn?: typeof fetch; local?: Record<string, string> },
|
|
58
61
|
): Promise<Record<string, string>> {
|
|
59
|
-
const
|
|
60
|
-
const
|
|
62
|
+
const local = opts.local ?? {};
|
|
63
|
+
const versions: Record<string, string> = Object.fromEntries(FRAMEWORK_PACKAGES.map((p) => [p, local[p] ?? cliVersion()]));
|
|
64
|
+
for (const name of packages) {
|
|
65
|
+
if (local[name] !== undefined) versions[name] = local[name];
|
|
66
|
+
}
|
|
67
|
+
const remote = packages.filter((name) => local[name] === undefined);
|
|
68
|
+
const latest = await Promise.all(remote.map(async (name) => [name, await latestVersion(name, opts)] as const));
|
|
61
69
|
for (const [name, version] of latest) {
|
|
62
70
|
versions[name] = version;
|
|
63
71
|
}
|
package/src/wizard.ts
CHANGED
|
@@ -17,6 +17,8 @@ export type Answers = {
|
|
|
17
17
|
role: string;
|
|
18
18
|
channels: string[];
|
|
19
19
|
plugins: string[];
|
|
20
|
+
/** The HTTP channel's auth provider, there exactly when that channel is. */
|
|
21
|
+
auth?: string;
|
|
20
22
|
gitRemote?: string;
|
|
21
23
|
};
|
|
22
24
|
|
|
@@ -65,8 +67,24 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
|
|
|
65
67
|
validate: (v) => (v === undefined || v === "" || v.trim() ? undefined : "The role can't be empty"),
|
|
66
68
|
});
|
|
67
69
|
if (p.isCancel(role)) return cancelled();
|
|
68
|
-
|
|
70
|
+
// A --auth with no --channels starts from the HTTP channel it goes with.
|
|
71
|
+
const initialChannels = args.channels ?? (args.auth !== undefined ? ["http"] : []);
|
|
72
|
+
const channels = await pick("channel", "Channels (space to select, none is fine: the REPL always works)", initialChannels);
|
|
69
73
|
if (channels === undefined) return cancelled();
|
|
74
|
+
// The HTTP channel doesn't start without an auth provider, so choosing it
|
|
75
|
+
// means choosing one; without it there's nothing to ask.
|
|
76
|
+
let auth: string | undefined;
|
|
77
|
+
if (channels.includes("http")) {
|
|
78
|
+
const picked = await p.select({
|
|
79
|
+
message: "Auth provider for the HTTP channel (who may call it)",
|
|
80
|
+
options: CATALOG.filter((e) => e.kind === "auth").map((e) => ({ value: e.id, label: e.id, hint: e.package })),
|
|
81
|
+
...(args.auth !== undefined ? { initialValue: args.auth } : {}),
|
|
82
|
+
});
|
|
83
|
+
if (p.isCancel(picked)) return cancelled();
|
|
84
|
+
auth = picked;
|
|
85
|
+
} else if (args.auth !== undefined) {
|
|
86
|
+
p.log.warn(`--auth ${args.auth} ignored: it goes with the http channel, which isn't chosen`);
|
|
87
|
+
}
|
|
70
88
|
const plugins = await pick("tool", "Tool plugins (space to select)", args.plugins ?? []);
|
|
71
89
|
if (plugins === undefined) return cancelled();
|
|
72
90
|
// Asked only when there will be a repository and the flag didn't say.
|
|
@@ -85,6 +103,7 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
|
|
|
85
103
|
`App: ${name}`,
|
|
86
104
|
`Assistant: You are ${assistantName}, ${role}.`,
|
|
87
105
|
`Channels: ${channels.length > 0 ? channels.join(", ") : "none"}`,
|
|
106
|
+
...(auth !== undefined ? [`Auth: ${auth}`] : []),
|
|
88
107
|
`Tool plugins: ${plugins.length > 0 ? plugins.join(", ") : "none"}`,
|
|
89
108
|
...(args.git ? [`Origin: ${gitRemote ?? "none"}`] : []),
|
|
90
109
|
].join("\n"),
|
|
@@ -92,7 +111,15 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
|
|
|
92
111
|
);
|
|
93
112
|
const ok = await p.confirm({ message: `Create it in ${dir}?` });
|
|
94
113
|
if (p.isCancel(ok) || !ok) return cancelled();
|
|
95
|
-
return {
|
|
114
|
+
return {
|
|
115
|
+
name,
|
|
116
|
+
assistantName,
|
|
117
|
+
role,
|
|
118
|
+
channels,
|
|
119
|
+
plugins,
|
|
120
|
+
...(auth !== undefined ? { auth } : {}),
|
|
121
|
+
...(gitRemote !== undefined ? { gitRemote } : {}),
|
|
122
|
+
};
|
|
96
123
|
}
|
|
97
124
|
|
|
98
125
|
function cancelled(): undefined {
|