@mercury-fw/cli 0.25.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 +8 -0
- package/README.md +40 -0
- package/dist/src/args.d.ts +14 -0
- package/dist/src/bin.d.ts +2 -0
- package/dist/src/catalog.d.ts +37 -0
- package/dist/src/main.d.ts +4 -0
- package/dist/src/naming.d.ts +8 -0
- package/dist/src/render.d.ts +21 -0
- package/dist/src/template.d.ts +6 -0
- package/dist/src/versions.d.ts +16 -0
- package/dist/src/wizard.d.ts +15 -0
- package/dist/src/write.d.ts +8 -0
- package/package.json +52 -0
- package/src/args.ts +57 -0
- package/src/bin.ts +5 -0
- package/src/catalog.ts +93 -0
- package/src/main.ts +119 -0
- package/src/naming.ts +18 -0
- package/src/render.ts +331 -0
- package/src/template.d.ts +6 -0
- package/src/versions.ts +58 -0
- package/src/wizard.ts +88 -0
- package/src/write.ts +38 -0
- package/template/Dockerfile.tpl +32 -0
- package/template/dockerignore.tpl +6 -0
- package/template/gitignore.tpl +6 -0
- package/template/markdown.d.ts.tpl +5 -0
- package/template/src/index.ts.tpl +42 -0
- package/template/src/repl.ts.tpl +20 -0
- package/template/tsconfig.json.tpl +20 -0
package/CHANGELOG.md
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# @mercury-fw/cli
|
|
2
|
+
|
|
3
|
+
`mfw`, the command-line tool of [Mercury](https://github.com/lucabro81/mercury-fw). For now it has one command, `mfw create`, which scaffolds a new app; operating an app (starting it, the REPL, memory maintenance) moves here next.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
bunx @mercury-fw/cli create my-agent
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
or, the same thing under the `create` convention:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun create mercury-agent my-agent
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## `mfw create <folder>`
|
|
16
|
+
|
|
17
|
+
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
|
+
|
|
19
|
+
- the app name (the `name` in `package.json`, defaulting to the folder's);
|
|
20
|
+
- the assistant's name and role, which become `persona/identity.md` ("You are Hermes, the platform team's release assistant.") next to a `persona/tone.md` to edit;
|
|
21
|
+
- which channels and which tool plugins to include (none is fine: the REPL always works).
|
|
22
|
+
|
|
23
|
+
It writes the `mercury.config.ts` for that selection, the persona, the service and REPL entrypoints, a Dockerfile, a compose file with Qdrant, and an env example listing every variable the chosen pieces read. A plugin that hands over lists comes wrapped in the formatter with a starting rule, yours to change. Nothing is installed: run `bun install` in the new app.
|
|
24
|
+
|
|
25
|
+
| Flag | |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `--name <name>` | The app name. |
|
|
28
|
+
| `--assistant-name <name>` | The assistant's name (default `Mercury`). |
|
|
29
|
+
| `--role <text>` | Completes "You are <name>, …" (default `an internal assistant`). |
|
|
30
|
+
| `--channels <ids>` | Comma-separated: `google-chat`, `http`. |
|
|
31
|
+
| `--plugins <ids>` | Comma-separated: `jira`, `bitbucket`, `atlassian-admin`. |
|
|
32
|
+
| `-y`, `--yes` | No questions: the flags, and the defaults for the rest. |
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
mfw create my-agent --assistant-name Hermes --channels http --plugins jira --yes
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
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
|
+
|
|
40
|
+
MIT
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** What the command line says. An answer left out is asked by the wizard, or
|
|
2
|
+
* takes its default with `yes`. */
|
|
3
|
+
export type CreateArgs = {
|
|
4
|
+
dir: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
assistantName?: string;
|
|
7
|
+
role?: string;
|
|
8
|
+
channels?: string[];
|
|
9
|
+
plugins?: string[];
|
|
10
|
+
yes: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** Parses the arguments that follow `create`. Throws on a missing or extra
|
|
13
|
+
* folder and on an unknown flag. */
|
|
14
|
+
export declare function parseCreateArgs(argv: string[]): CreateArgs;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The channels and tool plugins `mfw create` can put in a new app. Written
|
|
3
|
+
* by hand for now: once the packages are published, each plugin will describe
|
|
4
|
+
* itself (kind, export, env vars) and this list goes away. `catalog.test.ts`
|
|
5
|
+
* checks every entry against the real package until then.
|
|
6
|
+
*/
|
|
7
|
+
/** One environment variable an entry reads, as it appears in the app's env
|
|
8
|
+
* example: `value` is the example value, empty when the deployer must fill it. */
|
|
9
|
+
export type EnvVar = {
|
|
10
|
+
name: string;
|
|
11
|
+
comment: string;
|
|
12
|
+
value?: string;
|
|
13
|
+
};
|
|
14
|
+
/** A channel or tool plugin the app can include. `id` is the plugin's own
|
|
15
|
+
* `name` (what MERCURY_CLIS lists for a tool plugin), `exportName` the value the
|
|
16
|
+
* app's config imports from `package`. */
|
|
17
|
+
export type CatalogEntry = {
|
|
18
|
+
id: string;
|
|
19
|
+
kind: "channel" | "tool";
|
|
20
|
+
package: string;
|
|
21
|
+
exportName: string;
|
|
22
|
+
env: EnvVar[];
|
|
23
|
+
formatter?: FormatterExample;
|
|
24
|
+
};
|
|
25
|
+
/** Starting formatter rules for a plugin that hands the user lists: without a
|
|
26
|
+
* rule a kind of list isn't shown, so the scaffolded config wraps the plugin
|
|
27
|
+
* with these. `displaysType` is the type the plugin exports for its kinds,
|
|
28
|
+
* `helpers` the code the rules use (written above the config), `rules` the
|
|
29
|
+
* `kind: rule` lines. Once written, they're the app's to change. */
|
|
30
|
+
export type FormatterExample = {
|
|
31
|
+
displaysType: string;
|
|
32
|
+
helpers: string;
|
|
33
|
+
rules: string[];
|
|
34
|
+
};
|
|
35
|
+
export declare const CATALOG: CatalogEntry[];
|
|
36
|
+
/** The catalog entry of `kind` with `id`, or undefined when there is none. */
|
|
37
|
+
export declare function findEntry(kind: CatalogEntry["kind"], id: string): CatalogEntry | undefined;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naming helpers for what the CLI turns into paths: the folder a new app is
|
|
3
|
+
* created in is always kebab case, whatever was typed.
|
|
4
|
+
*/
|
|
5
|
+
/** `text` in kebab case: accents dropped, camelCase split into words,
|
|
6
|
+
* lowercase, every run of anything but ASCII letters and digits turned into a
|
|
7
|
+
* single hyphen, no hyphen at either end. Empty when nothing usable is left. */
|
|
8
|
+
export declare function kebabCase(text: string): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** What the new app is made of. `versions` maps each package the app depends
|
|
2
|
+
* on to the version its range is written against. */
|
|
3
|
+
export type RenderInput = {
|
|
4
|
+
name: string;
|
|
5
|
+
assistantName: string;
|
|
6
|
+
role: string;
|
|
7
|
+
channels: string[];
|
|
8
|
+
plugins: string[];
|
|
9
|
+
versions: Record<string, string>;
|
|
10
|
+
};
|
|
11
|
+
/** Builds every file of the new app. Throws on an invalid app name, an empty
|
|
12
|
+
* assistant name or role, an id the catalog doesn't have, or a package with no
|
|
13
|
+
* version in `input.versions`. */
|
|
14
|
+
export declare function renderApp(input: RenderInput): Map<string, string>;
|
|
15
|
+
/** Why `name` can't be an app name, or undefined when it can. Shared with the
|
|
16
|
+
* wizard, which checks the name as it's typed. */
|
|
17
|
+
export declare function appNameError(name: string): string | undefined;
|
|
18
|
+
/** Names the first channel or plugin id the catalog doesn't have, with the
|
|
19
|
+
* valid ones, or undefined when all are known. Shared with the command, which
|
|
20
|
+
* checks the flags before asking anything. */
|
|
21
|
+
export declare function selectionError(channels: string[], plugins: string[]): string | undefined;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** The framework packages a new app can depend on: always at the CLI's version. */
|
|
2
|
+
export declare const FRAMEWORK_PACKAGES: string[];
|
|
3
|
+
/** The registry asked when `MFW_REGISTRY` doesn't name another. */
|
|
4
|
+
export declare const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
5
|
+
/** The registry to ask: `value` (from `MFW_REGISTRY`) unless it's unset or
|
|
6
|
+
* blank, the default otherwise. */
|
|
7
|
+
export declare function registryFrom(value: string | undefined): string;
|
|
8
|
+
/** This CLI's version, which is the framework's. */
|
|
9
|
+
export declare function cliVersion(): string;
|
|
10
|
+
/** Maps the framework packages to the CLI's version and each of `packages`
|
|
11
|
+
* (plugins and channels) to the registry's `latest`. Rejects naming the
|
|
12
|
+
* package the registry doesn't have, or saying the registry can't be reached. */
|
|
13
|
+
export declare function appVersions(packages: string[], opts: {
|
|
14
|
+
registry: string;
|
|
15
|
+
fetchFn?: typeof fetch;
|
|
16
|
+
}): Promise<Record<string, string>>;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CreateArgs } from "./args.ts";
|
|
2
|
+
/** The answers `renderApp` needs, apart from the package versions. */
|
|
3
|
+
export type Answers = {
|
|
4
|
+
name: string;
|
|
5
|
+
assistantName: string;
|
|
6
|
+
role: string;
|
|
7
|
+
channels: string[];
|
|
8
|
+
plugins: string[];
|
|
9
|
+
};
|
|
10
|
+
export declare const DEFAULT_ASSISTANT_NAME = "Mercury";
|
|
11
|
+
export declare const DEFAULT_ROLE = "an internal assistant";
|
|
12
|
+
/** Asks every question, starting from `args`, for an app to be written into
|
|
13
|
+
* `dir` (already in kebab case, and the app name's default). Resolves to
|
|
14
|
+
* undefined when the user cancels (Ctrl+C) or doesn't confirm. */
|
|
15
|
+
export declare function askAnswers(args: CreateArgs, dir: string): Promise<Answers | undefined>;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Writes every `path → content` of `files` under `dir`, creating the folders
|
|
2
|
+
* they need. Throws, writing nothing, when `dir` is a file or a non-empty
|
|
3
|
+
* folder (hidden entries count). */
|
|
4
|
+
export declare function writeApp(dir: string, files: Map<string, string>): void;
|
|
5
|
+
/** Why `dir` can't receive a new app (a file, or a folder with something in
|
|
6
|
+
* it), or undefined when it's missing or empty. Shared with the command, which
|
|
7
|
+
* checks the target before asking anything. */
|
|
8
|
+
export declare function targetError(dir: string): string | undefined;
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mercury-fw/cli",
|
|
3
|
+
"version": "0.25.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/lucabro81/mercury-fw.git",
|
|
9
|
+
"directory": "apps/cli"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src",
|
|
14
|
+
"template",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"!**/*.test.ts",
|
|
17
|
+
"!**/__fixtures__"
|
|
18
|
+
],
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public"
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"mfw": "./src/bin.ts"
|
|
24
|
+
},
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"mercury-fw-source": "./src/main.ts",
|
|
28
|
+
"types": "./dist/src/main.d.ts",
|
|
29
|
+
"default": "./src/main.ts"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"test": "bun test"
|
|
35
|
+
},
|
|
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
|
+
"dependencies": {
|
|
38
|
+
"@clack/prompts": "^1.8.1",
|
|
39
|
+
"@mercury-fw/core": "0.25.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@mercury-fw/channel-google-chat": "0.1.0",
|
|
43
|
+
"@mercury-fw/channel-http": "0.1.0",
|
|
44
|
+
"@mercury-fw/formatter": "0.25.0",
|
|
45
|
+
"@mercury-fw/plugin-atlassian-admin": "0.1.0",
|
|
46
|
+
"@mercury-fw/plugin-bitbucket": "0.1.0",
|
|
47
|
+
"@mercury-fw/plugin-jira": "0.1.0",
|
|
48
|
+
"@mercury-fw/typescript-config": "*",
|
|
49
|
+
"@types/bun": "^1.4.0",
|
|
50
|
+
"typescript": "^6.0.3"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/args.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses `mfw create`'s command line: one target folder plus the answers
|
|
3
|
+
* that can be given as flags, either to skip the wizard (`--yes`) or to
|
|
4
|
+
* pre-fill it. No validation of the answers themselves here: `renderApp` owns
|
|
5
|
+
* that, so the wizard and the flags go through the same checks.
|
|
6
|
+
*/
|
|
7
|
+
import { parseArgs } from "node:util";
|
|
8
|
+
|
|
9
|
+
/** What the command line says. An answer left out is asked by the wizard, or
|
|
10
|
+
* takes its default with `yes`. */
|
|
11
|
+
export type CreateArgs = {
|
|
12
|
+
dir: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
assistantName?: string;
|
|
15
|
+
role?: string;
|
|
16
|
+
channels?: string[];
|
|
17
|
+
plugins?: string[];
|
|
18
|
+
yes: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** A comma-separated list, trimmed, empty items dropped. */
|
|
22
|
+
const list = (value: string): string[] =>
|
|
23
|
+
value
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((s) => s.trim())
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
|
|
28
|
+
/** Parses the arguments that follow `create`. Throws on a missing or extra
|
|
29
|
+
* folder and on an unknown flag. */
|
|
30
|
+
export function parseCreateArgs(argv: string[]): CreateArgs {
|
|
31
|
+
const { values, positionals } = parseArgs({
|
|
32
|
+
args: argv,
|
|
33
|
+
allowPositionals: true,
|
|
34
|
+
strict: true,
|
|
35
|
+
options: {
|
|
36
|
+
name: { type: "string" },
|
|
37
|
+
"assistant-name": { type: "string" },
|
|
38
|
+
role: { type: "string" },
|
|
39
|
+
channels: { type: "string" },
|
|
40
|
+
plugins: { type: "string" },
|
|
41
|
+
yes: { type: "boolean", short: "y" },
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
if (positionals.length === 0) {
|
|
45
|
+
throw new Error("Missing the folder to create the app in");
|
|
46
|
+
}
|
|
47
|
+
if (positionals.length > 1) {
|
|
48
|
+
throw new Error(`Expected one folder, got ${positionals.length}: ${positionals.join(" ")}`);
|
|
49
|
+
}
|
|
50
|
+
const args: CreateArgs = { dir: positionals[0] as string, yes: values.yes ?? false };
|
|
51
|
+
if (values.name !== undefined) args.name = values.name;
|
|
52
|
+
if (values["assistant-name"] !== undefined) args.assistantName = values["assistant-name"];
|
|
53
|
+
if (values.role !== undefined) args.role = values.role;
|
|
54
|
+
if (values.channels !== undefined) args.channels = list(values.channels);
|
|
55
|
+
if (values.plugins !== undefined) args.plugins = list(values.plugins);
|
|
56
|
+
return args;
|
|
57
|
+
}
|
package/src/bin.ts
ADDED
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The channels and tool plugins `mfw create` can put in a new app. Written
|
|
3
|
+
* by hand for now: once the packages are published, each plugin will describe
|
|
4
|
+
* itself (kind, export, env vars) and this list goes away. `catalog.test.ts`
|
|
5
|
+
* checks every entry against the real package until then.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** One environment variable an entry reads, as it appears in the app's env
|
|
9
|
+
* example: `value` is the example value, empty when the deployer must fill it. */
|
|
10
|
+
export type EnvVar = { name: string; comment: string; value?: string };
|
|
11
|
+
|
|
12
|
+
/** A channel or tool plugin the app can include. `id` is the plugin's own
|
|
13
|
+
* `name` (what MERCURY_CLIS lists for a tool plugin), `exportName` the value the
|
|
14
|
+
* app's config imports from `package`. */
|
|
15
|
+
export type CatalogEntry = {
|
|
16
|
+
id: string;
|
|
17
|
+
kind: "channel" | "tool";
|
|
18
|
+
package: string;
|
|
19
|
+
exportName: string;
|
|
20
|
+
env: EnvVar[];
|
|
21
|
+
formatter?: FormatterExample;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Starting formatter rules for a plugin that hands the user lists: without a
|
|
25
|
+
* rule a kind of list isn't shown, so the scaffolded config wraps the plugin
|
|
26
|
+
* with these. `displaysType` is the type the plugin exports for its kinds,
|
|
27
|
+
* `helpers` the code the rules use (written above the config), `rules` the
|
|
28
|
+
* `kind: rule` lines. Once written, they're the app's to change. */
|
|
29
|
+
export type FormatterExample = { displaysType: string; helpers: string; rules: string[] };
|
|
30
|
+
|
|
31
|
+
export const CATALOG: CatalogEntry[] = [
|
|
32
|
+
{
|
|
33
|
+
id: "google-chat",
|
|
34
|
+
kind: "channel",
|
|
35
|
+
package: "@mercury-fw/channel-google-chat",
|
|
36
|
+
exportName: "googleChatChannel",
|
|
37
|
+
env: [
|
|
38
|
+
{
|
|
39
|
+
name: "GOOGLE_CHAT_PUBSUB_SUBSCRIPTION",
|
|
40
|
+
comment: "Pub/Sub subscription the Chat app's events arrive on (projects/<p>/subscriptions/<s>); empty leaves Google Chat inert",
|
|
41
|
+
},
|
|
42
|
+
{ name: "GOOGLE_CHAT_APP_CLIENT_EMAIL", comment: "Service account the Chat app authenticates as" },
|
|
43
|
+
{ name: "GOOGLE_CHAT_APP_PRIVATE_KEY", comment: "That service account's private key (PEM)" },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "http",
|
|
48
|
+
kind: "channel",
|
|
49
|
+
package: "@mercury-fw/channel-http",
|
|
50
|
+
exportName: "httpChannel",
|
|
51
|
+
env: [
|
|
52
|
+
{ name: "HTTP_SURFACE_PORT", comment: "Port of the HTTP surface (no authentication: keep it off the public network)", value: "4100" },
|
|
53
|
+
{ name: "HTTP_SURFACE_CORS_ORIGIN", comment: "Origin allowed to call the HTTP surface from a browser, if any" },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "jira",
|
|
58
|
+
kind: "tool",
|
|
59
|
+
package: "@mercury-fw/plugin-jira",
|
|
60
|
+
exportName: "jiraPlugin",
|
|
61
|
+
env: [{ name: "JIRA_SITE_URL", comment: "Jira site the issue links point to (https://<site>.atlassian.net)" }],
|
|
62
|
+
formatter: {
|
|
63
|
+
displaysType: "JiraDisplays",
|
|
64
|
+
helpers: [
|
|
65
|
+
"/** One Jira issue in a search result: key, status in brackets when there is",
|
|
66
|
+
" * one, summary, and the browse link on its own line. A starting point: change",
|
|
67
|
+
" * it to change how Jira lists read. */",
|
|
68
|
+
'const jiraIssueLine = (issue: JiraDisplays["issue-list"]) =>',
|
|
69
|
+
' `${issue.key} ${issue.status ? `[${issue.status}] ` : ""}${issue.summary}\\n${issue.url}`;',
|
|
70
|
+
].join("\n"),
|
|
71
|
+
rules: ['"issue-list": { item: jiraIssueLine, empty: "No matching issues." },'],
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "bitbucket",
|
|
76
|
+
kind: "tool",
|
|
77
|
+
package: "@mercury-fw/plugin-bitbucket",
|
|
78
|
+
exportName: "bitbucketPlugin",
|
|
79
|
+
env: [],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "atlassian-admin",
|
|
83
|
+
kind: "tool",
|
|
84
|
+
package: "@mercury-fw/plugin-atlassian-admin",
|
|
85
|
+
exportName: "atlassianAdminPlugin",
|
|
86
|
+
env: [],
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/** The catalog entry of `kind` with `id`, or undefined when there is none. */
|
|
91
|
+
export function findEntry(kind: CatalogEntry["kind"], id: string): CatalogEntry | undefined {
|
|
92
|
+
return CATALOG.find((e) => e.kind === kind && e.id === id);
|
|
93
|
+
}
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mfw` command. One subcommand for now, `create <folder>`: writes a
|
|
3
|
+
* new Mercury app from the template, asking what to put in it (or taking the
|
|
4
|
+
* answers from flags with `--yes`). It writes the files; `bun install` in the
|
|
5
|
+
* new app is left to the user.
|
|
6
|
+
*/
|
|
7
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
+
import { parseCreateArgs, type CreateArgs } from "./args.ts";
|
|
9
|
+
import { CATALOG } from "./catalog.ts";
|
|
10
|
+
import { kebabCase } from "./naming.ts";
|
|
11
|
+
import { renderApp, selectionError } from "./render.ts";
|
|
12
|
+
import { appVersions, registryFrom } from "./versions.ts";
|
|
13
|
+
import { askAnswers, DEFAULT_ASSISTANT_NAME, DEFAULT_ROLE, type Answers } from "./wizard.ts";
|
|
14
|
+
import { targetError, writeApp } from "./write.ts";
|
|
15
|
+
|
|
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
|
+
/** The answers taken from the flags alone, defaults for the rest. */
|
|
46
|
+
function answersFromFlags(args: CreateArgs, defaultName: string): Answers {
|
|
47
|
+
return {
|
|
48
|
+
name: args.name ?? defaultName,
|
|
49
|
+
assistantName: args.assistantName ?? DEFAULT_ASSISTANT_NAME,
|
|
50
|
+
role: args.role ?? DEFAULT_ROLE,
|
|
51
|
+
channels: args.channels ?? [],
|
|
52
|
+
plugins: args.plugins ?? [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `mfw create`: returns the exit code. */
|
|
57
|
+
async function create(argv: string[]): Promise<number> {
|
|
58
|
+
const args = parseCreateArgs(argv);
|
|
59
|
+
// The folder is created in kebab case, only its own name: the parent path is
|
|
60
|
+
// taken as typed. Its name is also the app name's default.
|
|
61
|
+
const typed = resolve(args.dir);
|
|
62
|
+
const folder = kebabCase(basename(typed));
|
|
63
|
+
if (folder === "") {
|
|
64
|
+
throw new Error(`"${basename(typed)}" has no letters or digits to name a folder with`);
|
|
65
|
+
}
|
|
66
|
+
const dir = join(dirname(typed), folder);
|
|
67
|
+
// What the command line already settles is checked before any question, so
|
|
68
|
+
// the wizard is never answered for nothing.
|
|
69
|
+
const early = targetError(dir) ?? selectionError(args.channels ?? [], args.plugins ?? []);
|
|
70
|
+
if (early !== undefined) {
|
|
71
|
+
throw new Error(early);
|
|
72
|
+
}
|
|
73
|
+
const answers = args.yes ? answersFromFlags(args, folder) : await askAnswers(args, dir);
|
|
74
|
+
if (answers === undefined) {
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
const chosen = CATALOG.filter(
|
|
78
|
+
(e) => (e.kind === "channel" ? answers.channels : answers.plugins).includes(e.id),
|
|
79
|
+
).map((e) => e.package);
|
|
80
|
+
const versions = await appVersions(chosen, { registry: registryFrom(process.env.MFW_REGISTRY) });
|
|
81
|
+
writeApp(dir, renderApp({ ...answers, versions }));
|
|
82
|
+
console.log(`Created ${answers.name} in ${dir}
|
|
83
|
+
|
|
84
|
+
Next:
|
|
85
|
+
cd ${dir}
|
|
86
|
+
bun install
|
|
87
|
+
cp .env.example .env # then fill it in
|
|
88
|
+
docker compose up --build`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Runs `mfw` with `argv` (the arguments after the command name) and returns
|
|
93
|
+
* the exit code, printing to stdout/stderr. `bin.ts` and `create-mercury-agent`
|
|
94
|
+
* both call it. */
|
|
95
|
+
export async function main(argv: string[]): Promise<number> {
|
|
96
|
+
const [command, ...rest] = argv;
|
|
97
|
+
if (command === "--help" || command === "-h") {
|
|
98
|
+
console.log(USAGE);
|
|
99
|
+
return 0;
|
|
100
|
+
}
|
|
101
|
+
if (command === undefined) {
|
|
102
|
+
console.error(USAGE);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
if (command === "create" && rest.some((a) => a === "--help" || a === "-h")) {
|
|
106
|
+
console.log(USAGE);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
if (command !== "create") {
|
|
110
|
+
console.error(`Unknown command "${command}"\n\n${USAGE}`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
return await create(rest);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/naming.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Naming helpers for what the CLI turns into paths: the folder a new app is
|
|
3
|
+
* created in is always kebab case, whatever was typed.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** `text` in kebab case: accents dropped, camelCase split into words,
|
|
7
|
+
* lowercase, every run of anything but ASCII letters and digits turned into a
|
|
8
|
+
* single hyphen, no hyphen at either end. Empty when nothing usable is left. */
|
|
9
|
+
export function kebabCase(text: string): string {
|
|
10
|
+
return text
|
|
11
|
+
.normalize("NFD")
|
|
12
|
+
.replace(/[̀-ͯ]/g, "")
|
|
13
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
14
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
17
|
+
.replace(/^-+|-+$/g, "");
|
|
18
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/// <reference path="./template.d.ts" />
|
|
2
|
+
/**
|
|
3
|
+
* Turns the wizard's answers into the new app's files, as a `path → content`
|
|
4
|
+
* map, without touching the disk (`write.ts` does that). The static files come
|
|
5
|
+
* from `template/` imported as text, so they are bundled with the CLI; the
|
|
6
|
+
* config, the manifest, the env example, the compose file, the persona and the
|
|
7
|
+
* README are generated from the selection. Channels and plugins are always
|
|
8
|
+
* written in catalog order, whatever order they were chosen in.
|
|
9
|
+
*/
|
|
10
|
+
import { DEFAULT_PERSONA_TONE } from "@mercury-fw/core";
|
|
11
|
+
import { CATALOG, type CatalogEntry, type EnvVar } from "./catalog.ts";
|
|
12
|
+
import indexTs from "../template/src/index.ts.tpl" with { type: "text" };
|
|
13
|
+
import replTs from "../template/src/repl.ts.tpl" with { type: "text" };
|
|
14
|
+
import markdownDts from "../template/markdown.d.ts.tpl" with { type: "text" };
|
|
15
|
+
import tsconfigJson from "../template/tsconfig.json.tpl" with { type: "text" };
|
|
16
|
+
import gitignore from "../template/gitignore.tpl" with { type: "text" };
|
|
17
|
+
import dockerignore from "../template/dockerignore.tpl" with { type: "text" };
|
|
18
|
+
import dockerfile from "../template/Dockerfile.tpl" with { type: "text" };
|
|
19
|
+
|
|
20
|
+
/** What the new app is made of. `versions` maps each package the app depends
|
|
21
|
+
* on to the version its range is written against. */
|
|
22
|
+
export type RenderInput = {
|
|
23
|
+
name: string;
|
|
24
|
+
assistantName: string;
|
|
25
|
+
role: string;
|
|
26
|
+
channels: string[];
|
|
27
|
+
plugins: string[];
|
|
28
|
+
versions: Record<string, string>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** An app name that is a valid unscoped npm name and a valid prefix for the
|
|
32
|
+
* compose volume names. */
|
|
33
|
+
const APP_NAME = /^[a-z0-9][a-z0-9._-]*$/;
|
|
34
|
+
const APP_NAME_MAX = 214;
|
|
35
|
+
|
|
36
|
+
/** The variables the core reads, first in every app's env example. */
|
|
37
|
+
const CORE_ENV: EnvVar[] = [
|
|
38
|
+
{
|
|
39
|
+
name: "OLLAMA_HOST",
|
|
40
|
+
comment: "Model endpoint (Ollama-compatible). Ollama running on the Docker host is host.docker.internal.",
|
|
41
|
+
value: "http://host.docker.internal:11434",
|
|
42
|
+
},
|
|
43
|
+
{ name: "OLLAMA_MODEL", comment: "Chat model the assistant runs on" },
|
|
44
|
+
{ name: "OLLAMA_EMBEDDING_MODEL", comment: "Embedding model for the episodic memory", value: "nomic-embed-text" },
|
|
45
|
+
{ name: "QDRANT_URL", comment: "Qdrant, the compose service", value: "http://qdrant:6333" },
|
|
46
|
+
{ name: "WIKI_VAULT_PATH", comment: "Wiki vault, the named volume's mount point", value: "/app/wiki-vault" },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const CONFIG_HEADER = `/**
|
|
50
|
+
* This app's composition: the tool plugins and channels it runs, how the lists
|
|
51
|
+
* its plugins hand over read, and the assistant's persona. The entrypoints
|
|
52
|
+
* (\`src/index.ts\`, \`src/repl.ts\`) hand this config to \`composeMercury\`.
|
|
53
|
+
*
|
|
54
|
+
* A tool plugin contributes only when it's also listed in MERCURY_CLIS; a
|
|
55
|
+
* channel is active as soon as it's declared here.
|
|
56
|
+
*/`;
|
|
57
|
+
|
|
58
|
+
/** Builds every file of the new app. Throws on an invalid app name, an empty
|
|
59
|
+
* assistant name or role, an id the catalog doesn't have, or a package with no
|
|
60
|
+
* version in `input.versions`. */
|
|
61
|
+
export function renderApp(input: RenderInput): Map<string, string> {
|
|
62
|
+
validate(input);
|
|
63
|
+
const channels = selected("channel", input.channels);
|
|
64
|
+
const tools = selected("tool", input.plugins);
|
|
65
|
+
const assistantName = input.assistantName.trim();
|
|
66
|
+
|
|
67
|
+
return new Map([
|
|
68
|
+
[".dockerignore", dockerignore],
|
|
69
|
+
[".env.example", renderEnv(channels, tools)],
|
|
70
|
+
[".gitignore", gitignore],
|
|
71
|
+
["Dockerfile", dockerfile],
|
|
72
|
+
["README.md", renderReadme(input.name, channels, tools)],
|
|
73
|
+
["docker-compose.yml", renderCompose(input.name, tools.length > 0)],
|
|
74
|
+
["markdown.d.ts", markdownDts],
|
|
75
|
+
["mercury.config.ts", renderConfig(channels, tools)],
|
|
76
|
+
["package.json", renderPackageJson(input.name, channels, tools, input.versions)],
|
|
77
|
+
["persona/identity.md", `You are ${assistantName}, ${input.role.trim().replace(/\.+$/, "")}.\n`],
|
|
78
|
+
// A replacer function, not a string: in a replacement string "$&" and the
|
|
79
|
+
// like are patterns, and the name must land verbatim.
|
|
80
|
+
["persona/tone.md", `${DEFAULT_PERSONA_TONE.replaceAll("Mercury", () => assistantName)}\n`],
|
|
81
|
+
["src/index.ts", indexTs],
|
|
82
|
+
["src/repl.ts", replTs],
|
|
83
|
+
["tsconfig.json", tsconfigJson],
|
|
84
|
+
]);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Why `name` can't be an app name, or undefined when it can. Shared with the
|
|
88
|
+
* wizard, which checks the name as it's typed. */
|
|
89
|
+
export function appNameError(name: string): string | undefined {
|
|
90
|
+
if (!APP_NAME.test(name) || name.length > APP_NAME_MAX) {
|
|
91
|
+
return `Invalid app name "${name}": lowercase letters, digits, ".", "_" and "-", starting with a letter or digit`;
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Rejects what would produce a broken app, naming the valid choices. */
|
|
97
|
+
function validate(input: RenderInput): void {
|
|
98
|
+
const nameError = appNameError(input.name);
|
|
99
|
+
if (nameError !== undefined) {
|
|
100
|
+
throw new Error(nameError);
|
|
101
|
+
}
|
|
102
|
+
if (!input.assistantName.trim()) {
|
|
103
|
+
throw new Error("The assistant name can't be empty");
|
|
104
|
+
}
|
|
105
|
+
if (!input.role.trim()) {
|
|
106
|
+
throw new Error("The assistant's role can't be empty");
|
|
107
|
+
}
|
|
108
|
+
if (/[\r\n]/.test(input.assistantName) || /[\r\n]/.test(input.role)) {
|
|
109
|
+
throw new Error("The assistant name and role must each be one line");
|
|
110
|
+
}
|
|
111
|
+
const selection = selectionError(input.channels, input.plugins);
|
|
112
|
+
if (selection !== undefined) {
|
|
113
|
+
throw new Error(selection);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Names the first channel or plugin id the catalog doesn't have, with the
|
|
118
|
+
* valid ones, or undefined when all are known. Shared with the command, which
|
|
119
|
+
* checks the flags before asking anything. */
|
|
120
|
+
export function selectionError(channels: string[], plugins: string[]): string | undefined {
|
|
121
|
+
for (const [kind, ids] of [
|
|
122
|
+
["channel", channels],
|
|
123
|
+
["tool", plugins],
|
|
124
|
+
] as const) {
|
|
125
|
+
const valid = CATALOG.filter((e) => e.kind === kind).map((e) => e.id);
|
|
126
|
+
const unknown = ids.find((id) => !valid.includes(id));
|
|
127
|
+
if (unknown !== undefined) {
|
|
128
|
+
const label = kind === "channel" ? "channel" : "plugin";
|
|
129
|
+
return `Unknown ${label} "${unknown}" (valid: ${valid.join(", ")})`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The catalog entries of `kind` among `ids`, deduplicated, in catalog order. */
|
|
136
|
+
function selected(kind: CatalogEntry["kind"], ids: string[]): CatalogEntry[] {
|
|
137
|
+
return CATALOG.filter((e) => e.kind === kind && ids.includes(e.id));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** `mercury.config.ts`: imports, the formatter helpers of the plugins that have
|
|
141
|
+
* any, then the config with each plugin (wrapped in the formatter when it has
|
|
142
|
+
* starting rules) and channel. */
|
|
143
|
+
function renderConfig(channels: CatalogEntry[], tools: CatalogEntry[]): string {
|
|
144
|
+
const withRules = tools.filter((t) => t.formatter);
|
|
145
|
+
const imports = ['import { defineMercuryConfig } from "@mercury-fw/core";'];
|
|
146
|
+
if (withRules.length > 0) {
|
|
147
|
+
imports.push('import { formatterPlugin, formatter } from "@mercury-fw/formatter";');
|
|
148
|
+
}
|
|
149
|
+
for (const t of tools) {
|
|
150
|
+
const names = t.formatter ? `${t.exportName}, type ${t.formatter.displaysType}` : t.exportName;
|
|
151
|
+
imports.push(`import { ${names} } from "${t.package}";`);
|
|
152
|
+
}
|
|
153
|
+
for (const c of channels) {
|
|
154
|
+
imports.push(`import { ${c.exportName} } from "${c.package}";`);
|
|
155
|
+
}
|
|
156
|
+
imports.push('import identity from "./persona/identity.md" with { type: "text" };');
|
|
157
|
+
imports.push('import tone from "./persona/tone.md" with { type: "text" };');
|
|
158
|
+
|
|
159
|
+
const helpers = withRules.map((t) => `${t.formatter?.helpers}\n\n`).join("");
|
|
160
|
+
|
|
161
|
+
const plugins =
|
|
162
|
+
tools.length === 0
|
|
163
|
+
? " plugins: [],"
|
|
164
|
+
: [" plugins: [", ...tools.map(renderPluginEntry), " ],"].join("\n");
|
|
165
|
+
const channelList = ` channels: [${channels.map((c) => c.exportName).join(", ")}],`;
|
|
166
|
+
|
|
167
|
+
return [
|
|
168
|
+
CONFIG_HEADER,
|
|
169
|
+
...imports,
|
|
170
|
+
"",
|
|
171
|
+
`${helpers}export default defineMercuryConfig({`,
|
|
172
|
+
" persona: { identity, tone },",
|
|
173
|
+
plugins,
|
|
174
|
+
channelList,
|
|
175
|
+
"});",
|
|
176
|
+
"",
|
|
177
|
+
].join("\n");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** One element of the config's `plugins` array. */
|
|
181
|
+
function renderPluginEntry(t: CatalogEntry): string {
|
|
182
|
+
if (!t.formatter) {
|
|
183
|
+
return ` ${t.exportName},`;
|
|
184
|
+
}
|
|
185
|
+
return [
|
|
186
|
+
" formatterPlugin(",
|
|
187
|
+
` ${t.exportName},`,
|
|
188
|
+
` formatter<${t.formatter.displaysType}>({`,
|
|
189
|
+
...t.formatter.rules.map((r) => ` ${r}`),
|
|
190
|
+
" }),",
|
|
191
|
+
" ),",
|
|
192
|
+
].join("\n");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** `package.json`: the core, the formatter when a plugin is wrapped in it, the
|
|
196
|
+
* chosen packages, and every tool plugin trusted to run its postinstall (it
|
|
197
|
+
* downloads the plugin's CLI). */
|
|
198
|
+
function renderPackageJson(
|
|
199
|
+
name: string,
|
|
200
|
+
channels: CatalogEntry[],
|
|
201
|
+
tools: CatalogEntry[],
|
|
202
|
+
versions: Record<string, string>,
|
|
203
|
+
): string {
|
|
204
|
+
const packages = ["@mercury-fw/core", ...channels.map((c) => c.package), ...tools.map((t) => t.package)];
|
|
205
|
+
if (tools.some((t) => t.formatter)) {
|
|
206
|
+
packages.push("@mercury-fw/formatter");
|
|
207
|
+
}
|
|
208
|
+
const dependencies: Record<string, string> = {};
|
|
209
|
+
for (const pkg of packages.sort()) {
|
|
210
|
+
const version = versions[pkg];
|
|
211
|
+
if (version === undefined) {
|
|
212
|
+
throw new Error(`No version known for ${pkg}`);
|
|
213
|
+
}
|
|
214
|
+
dependencies[pkg] = `^${version}`;
|
|
215
|
+
}
|
|
216
|
+
const manifest: Record<string, unknown> = {
|
|
217
|
+
name,
|
|
218
|
+
version: "0.1.0",
|
|
219
|
+
type: "module",
|
|
220
|
+
private: true,
|
|
221
|
+
scripts: { start: "bun src/index.ts", repl: "bun src/repl.ts", typecheck: "tsc --noEmit" },
|
|
222
|
+
dependencies,
|
|
223
|
+
devDependencies: { "@types/bun": "1.4.0", typescript: "6.0.3" },
|
|
224
|
+
};
|
|
225
|
+
if (tools.length > 0) {
|
|
226
|
+
manifest.trustedDependencies = tools.map((t) => t.package).sort();
|
|
227
|
+
}
|
|
228
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The env example: the core's variables, MERCURY_CLIS when a tool plugin was
|
|
232
|
+
* chosen, then a section per chosen entry that reads any variable. */
|
|
233
|
+
function renderEnv(channels: CatalogEntry[], tools: CatalogEntry[]): string {
|
|
234
|
+
const block = (vars: EnvVar[]) => vars.map((v) => `# ${v.comment}\n${v.name}=${v.value ?? ""}`).join("\n");
|
|
235
|
+
const sections = [block(CORE_ENV)];
|
|
236
|
+
if (tools.length > 0) {
|
|
237
|
+
sections.push(
|
|
238
|
+
block([
|
|
239
|
+
{
|
|
240
|
+
name: "MERCURY_CLIS",
|
|
241
|
+
comment: "Tool plugins this instance enables (their ids, comma-separated)",
|
|
242
|
+
value: tools.map((t) => t.id).join(","),
|
|
243
|
+
},
|
|
244
|
+
]),
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
for (const entry of [...channels, ...tools]) {
|
|
248
|
+
if (entry.env.length > 0) {
|
|
249
|
+
sections.push(`# --- ${entry.id}\n${block(entry.env)}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return `${sections.join("\n\n")}\n`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** `docker-compose.yml`: the app and Qdrant, with named volumes prefixed by the
|
|
256
|
+
* app's name; the CLI credentials volume only when a tool plugin was chosen. */
|
|
257
|
+
function renderCompose(name: string, hasTools: boolean): string {
|
|
258
|
+
const credentialsMount = hasTools
|
|
259
|
+
? [
|
|
260
|
+
" # The tool plugins' CLI credentials, on a volume so what a CLI writes back",
|
|
261
|
+
" # (refreshed tokens) survives a redeploy. It starts empty: see README.md.",
|
|
262
|
+
" - cli-credentials:/home/mercury/.config",
|
|
263
|
+
]
|
|
264
|
+
: [];
|
|
265
|
+
const credentialsVolume = hasTools ? [" cli-credentials:", ` name: ${name}_cli-credentials`] : [];
|
|
266
|
+
return [
|
|
267
|
+
"services:",
|
|
268
|
+
" mercury:",
|
|
269
|
+
" build: .",
|
|
270
|
+
" env_file:",
|
|
271
|
+
" - path: .env",
|
|
272
|
+
" required: false",
|
|
273
|
+
" volumes:",
|
|
274
|
+
" - wiki-vault:/app/wiki-vault",
|
|
275
|
+
...credentialsMount,
|
|
276
|
+
" extra_hosts:",
|
|
277
|
+
' - "host.docker.internal:host-gateway"',
|
|
278
|
+
" depends_on:",
|
|
279
|
+
" - qdrant",
|
|
280
|
+
"",
|
|
281
|
+
" qdrant:",
|
|
282
|
+
" image: qdrant/qdrant:v1.19.0",
|
|
283
|
+
" volumes:",
|
|
284
|
+
" - qdrant-data:/qdrant/storage",
|
|
285
|
+
"",
|
|
286
|
+
"volumes:",
|
|
287
|
+
" wiki-vault:",
|
|
288
|
+
` name: ${name}_wiki-vault`,
|
|
289
|
+
" qdrant-data:",
|
|
290
|
+
` name: ${name}_qdrant-data`,
|
|
291
|
+
...credentialsVolume,
|
|
292
|
+
"",
|
|
293
|
+
].join("\n");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The app's README: what it was scaffolded with and how to run it. */
|
|
297
|
+
function renderReadme(name: string, channels: CatalogEntry[], tools: CatalogEntry[]): string {
|
|
298
|
+
const list = (entries: CatalogEntry[]) => (entries.length > 0 ? entries.map((e) => e.id).join(", ") : "none");
|
|
299
|
+
return `# ${name}
|
|
300
|
+
|
|
301
|
+
A Mercury app, scaffolded by \`mfw create\`.
|
|
302
|
+
|
|
303
|
+
- Channels: ${list(channels)}
|
|
304
|
+
- Tool plugins: ${list(tools)}
|
|
305
|
+
|
|
306
|
+
## Layout
|
|
307
|
+
|
|
308
|
+
- \`mercury.config.ts\`: what the app is made of (tool plugins, channels, how their lists read) and the assistant's persona.
|
|
309
|
+
- \`persona/identity.md\`, \`persona/tone.md\`: who the assistant is and how it answers. Edit them freely.
|
|
310
|
+
- \`src/index.ts\`: the service (channels, crons). \`src/repl.ts\`: an interactive terminal for trying things out.
|
|
311
|
+
- \`.env.example\`: every variable the app reads. Copy it to \`.env\` and fill it in.
|
|
312
|
+
|
|
313
|
+
## Running it
|
|
314
|
+
|
|
315
|
+
\`\`\`bash
|
|
316
|
+
bun install
|
|
317
|
+
cp .env.example .env
|
|
318
|
+
docker compose up --build
|
|
319
|
+
docker compose run --rm mercury bun run repl
|
|
320
|
+
\`\`\`
|
|
321
|
+
|
|
322
|
+
\`bun install\` here gives your editor and \`bun run typecheck\` the packages (tool plugins download their CLI binary as they install); the image installs its own copy when it builds.
|
|
323
|
+
${tools.length > 0 ? CREDENTIALS_SECTION : ""}`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The README section on CLI credentials, for an app with tool plugins. */
|
|
327
|
+
const CREDENTIALS_SECTION = `
|
|
328
|
+
## CLI credentials
|
|
329
|
+
|
|
330
|
+
Each tool plugin runs its own CLI, and each CLI keeps its login under \`/home/mercury/.config\` in the container, on the \`cli-credentials\` volume. The volume starts empty: nothing in this app provisions it yet, so authenticate each CLI once inside the container (\`docker compose run --rm mercury <cli> --help\` lists its auth commands) or copy its config folder into the volume. What the CLIs write back afterwards, like refreshed tokens, stays on the volume across redeploys.
|
|
331
|
+
`;
|
package/src/versions.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version each package of a new app is written against. The framework
|
|
3
|
+
* packages move in lockstep with this CLI, so they take its own version; a
|
|
4
|
+
* plugin or channel is versioned on its own, so it takes the registry's
|
|
5
|
+
* `latest` (asked only for the ones chosen).
|
|
6
|
+
*/
|
|
7
|
+
import pkg from "../package.json";
|
|
8
|
+
|
|
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"];
|
|
11
|
+
|
|
12
|
+
/** The registry asked when `MFW_REGISTRY` doesn't name another. */
|
|
13
|
+
export const DEFAULT_REGISTRY = "https://registry.npmjs.org";
|
|
14
|
+
|
|
15
|
+
/** The registry to ask: `value` (from `MFW_REGISTRY`) unless it's unset or
|
|
16
|
+
* blank, the default otherwise. */
|
|
17
|
+
export function registryFrom(value: string | undefined): string {
|
|
18
|
+
return value?.trim() ? value.trim() : DEFAULT_REGISTRY;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** This CLI's version, which is the framework's. */
|
|
22
|
+
export function cliVersion(): string {
|
|
23
|
+
return pkg.version;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Maps the framework packages to the CLI's version and each of `packages`
|
|
27
|
+
* (plugins and channels) to the registry's `latest`. Rejects naming the
|
|
28
|
+
* package the registry doesn't have, or saying the registry can't be reached. */
|
|
29
|
+
export async function appVersions(
|
|
30
|
+
packages: string[],
|
|
31
|
+
opts: { registry: string; fetchFn?: typeof fetch },
|
|
32
|
+
): Promise<Record<string, string>> {
|
|
33
|
+
const registry = opts.registry.replace(/\/+$/, "");
|
|
34
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
35
|
+
const versions: Record<string, string> = Object.fromEntries(FRAMEWORK_PACKAGES.map((p) => [p, cliVersion()]));
|
|
36
|
+
const latest = await Promise.all(
|
|
37
|
+
packages.map(async (name) => {
|
|
38
|
+
let res: Response;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetchFn(`${registry}/${name.replace("/", "%2F")}/latest`);
|
|
41
|
+
} catch {
|
|
42
|
+
throw new Error(`Can't reach ${registry} to look up ${name}`);
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
throw new Error(`${name} is not on ${registry}`);
|
|
46
|
+
}
|
|
47
|
+
const body = (await res.json().catch(() => undefined)) as { version?: unknown } | undefined;
|
|
48
|
+
if (typeof body?.version !== "string") {
|
|
49
|
+
throw new Error(`${registry} gave no version for ${name}`);
|
|
50
|
+
}
|
|
51
|
+
return [name, body.version] as const;
|
|
52
|
+
}),
|
|
53
|
+
);
|
|
54
|
+
for (const [name, version] of latest) {
|
|
55
|
+
versions[name] = version;
|
|
56
|
+
}
|
|
57
|
+
return versions;
|
|
58
|
+
}
|
package/src/wizard.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive side of `mfw create`: asks for the answers, pre-filled
|
|
3
|
+
* with whatever the flags already gave, shows a summary and asks to confirm.
|
|
4
|
+
* Only questions; building and writing the app is `index.ts`'s job.
|
|
5
|
+
*/
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import * as p from "@clack/prompts";
|
|
8
|
+
import { CATALOG, type CatalogEntry } from "./catalog.ts";
|
|
9
|
+
import { appNameError } from "./render.ts";
|
|
10
|
+
import type { CreateArgs } from "./args.ts";
|
|
11
|
+
|
|
12
|
+
/** The answers `renderApp` needs, apart from the package versions. */
|
|
13
|
+
export type Answers = {
|
|
14
|
+
name: string;
|
|
15
|
+
assistantName: string;
|
|
16
|
+
role: string;
|
|
17
|
+
channels: string[];
|
|
18
|
+
plugins: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_ASSISTANT_NAME = "Mercury";
|
|
22
|
+
export const DEFAULT_ROLE = "an internal assistant";
|
|
23
|
+
|
|
24
|
+
/** A text answer's default, shown greyed out and taken on a bare Enter; typing
|
|
25
|
+
* replaces it instead of appending to it. */
|
|
26
|
+
const suggest = (value: string) => ({ placeholder: value, defaultValue: value });
|
|
27
|
+
|
|
28
|
+
/** A multi-select over the catalog entries of `kind`; none selected is fine.
|
|
29
|
+
* Undefined when the user cancels. */
|
|
30
|
+
async function pick(kind: CatalogEntry["kind"], message: string, initial: string[]): Promise<string[] | undefined> {
|
|
31
|
+
const picked = await p.multiselect({
|
|
32
|
+
message,
|
|
33
|
+
options: CATALOG.filter((e) => e.kind === kind).map((e) => ({ value: e.id, label: e.id, hint: e.package })),
|
|
34
|
+
initialValues: initial,
|
|
35
|
+
required: false,
|
|
36
|
+
});
|
|
37
|
+
return p.isCancel(picked) ? undefined : picked;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Asks every question, starting from `args`, for an app to be written into
|
|
41
|
+
* `dir` (already in kebab case, and the app name's default). Resolves to
|
|
42
|
+
* undefined when the user cancels (Ctrl+C) or doesn't confirm. */
|
|
43
|
+
export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers | undefined> {
|
|
44
|
+
p.intro("mfw create");
|
|
45
|
+
// A --name that isn't a valid app name isn't offered; then there's no
|
|
46
|
+
// default, and an empty answer is rejected.
|
|
47
|
+
const nameDefault = args.name ?? basename(dir);
|
|
48
|
+
const name = await p.text({
|
|
49
|
+
message: "App name",
|
|
50
|
+
...(appNameError(nameDefault) === undefined ? suggest(nameDefault) : {}),
|
|
51
|
+
validate: (v) => (v ? appNameError(v) : appNameError(nameDefault)),
|
|
52
|
+
});
|
|
53
|
+
if (p.isCancel(name)) return cancelled();
|
|
54
|
+
const assistantName = await p.text({
|
|
55
|
+
message: "Assistant name",
|
|
56
|
+
...suggest(args.assistantName ?? DEFAULT_ASSISTANT_NAME),
|
|
57
|
+
validate: (v) => (v === undefined || v === "" || v.trim() ? undefined : "The assistant needs a name"),
|
|
58
|
+
});
|
|
59
|
+
if (p.isCancel(assistantName)) return cancelled();
|
|
60
|
+
const role = await p.text({
|
|
61
|
+
message: `${assistantName} is… (completes "You are ${assistantName}, …")`,
|
|
62
|
+
...suggest(args.role ?? DEFAULT_ROLE),
|
|
63
|
+
validate: (v) => (v === undefined || v === "" || v.trim() ? undefined : "The role can't be empty"),
|
|
64
|
+
});
|
|
65
|
+
if (p.isCancel(role)) return cancelled();
|
|
66
|
+
const channels = await pick("channel", "Channels (space to select, none is fine: the REPL always works)", args.channels ?? []);
|
|
67
|
+
if (channels === undefined) return cancelled();
|
|
68
|
+
const plugins = await pick("tool", "Tool plugins (space to select)", args.plugins ?? []);
|
|
69
|
+
if (plugins === undefined) return cancelled();
|
|
70
|
+
|
|
71
|
+
p.note(
|
|
72
|
+
[
|
|
73
|
+
`App: ${name}`,
|
|
74
|
+
`Assistant: You are ${assistantName}, ${role}.`,
|
|
75
|
+
`Channels: ${channels.length > 0 ? channels.join(", ") : "none"}`,
|
|
76
|
+
`Tool plugins: ${plugins.length > 0 ? plugins.join(", ") : "none"}`,
|
|
77
|
+
].join("\n"),
|
|
78
|
+
"Summary",
|
|
79
|
+
);
|
|
80
|
+
const ok = await p.confirm({ message: `Create it in ${dir}?` });
|
|
81
|
+
if (p.isCancel(ok) || !ok) return cancelled();
|
|
82
|
+
return { name, assistantName, role, channels, plugins };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cancelled(): undefined {
|
|
86
|
+
p.cancel("Nothing written.");
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
package/src/write.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes a rendered app (`render.ts`) to disk. The target must be missing or an
|
|
3
|
+
* empty folder: scaffolding never writes over an existing project, and the
|
|
4
|
+
* check happens before any file is written.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** Writes every `path → content` of `files` under `dir`, creating the folders
|
|
10
|
+
* they need. Throws, writing nothing, when `dir` is a file or a non-empty
|
|
11
|
+
* folder (hidden entries count). */
|
|
12
|
+
export function writeApp(dir: string, files: Map<string, string>): void {
|
|
13
|
+
const error = targetError(dir);
|
|
14
|
+
if (error !== undefined) {
|
|
15
|
+
throw new Error(error);
|
|
16
|
+
}
|
|
17
|
+
for (const [path, content] of files) {
|
|
18
|
+
const target = join(dir, path);
|
|
19
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
20
|
+
writeFileSync(target, content);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Why `dir` can't receive a new app (a file, or a folder with something in
|
|
25
|
+
* it), or undefined when it's missing or empty. Shared with the command, which
|
|
26
|
+
* checks the target before asking anything. */
|
|
27
|
+
export function targetError(dir: string): string | undefined {
|
|
28
|
+
if (!existsSync(dir)) {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
if (!statSync(dir).isDirectory()) {
|
|
32
|
+
return `${dir} is not a folder`;
|
|
33
|
+
}
|
|
34
|
+
if (readdirSync(dir).length > 0) {
|
|
35
|
+
return `${dir} is not empty`;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Debian, not Alpine: the tool plugins' CLIs are glibc binaries.
|
|
2
|
+
FROM oven/bun:1
|
|
3
|
+
|
|
4
|
+
# ca-certificates: the CLIs verify TLS against the OS trust store.
|
|
5
|
+
# git: the wiki vault is a git repository Mercury initializes at startup.
|
|
6
|
+
RUN apt-get update && apt-get upgrade -y \
|
|
7
|
+
&& apt-get install -y --no-install-recommends ca-certificates git \
|
|
8
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
9
|
+
|
|
10
|
+
RUN groupadd -r mercury && useradd -r -g mercury mercury
|
|
11
|
+
|
|
12
|
+
WORKDIR /app
|
|
13
|
+
|
|
14
|
+
# Each tool plugin downloads its pinned CLI in its postinstall (allowed by
|
|
15
|
+
# package.json's trustedDependencies); the binaries go on PATH.
|
|
16
|
+
COPY --chown=mercury:mercury package.json bun.lock* ./
|
|
17
|
+
RUN bun install --production && chown -R mercury:mercury node_modules
|
|
18
|
+
RUN find /app/node_modules -path '*/@mercury-fw/*/bin/*' -type f -exec ln -sf {} /usr/local/bin/ \;
|
|
19
|
+
|
|
20
|
+
COPY --chown=mercury:mercury mercury.config.ts markdown.d.ts ./
|
|
21
|
+
COPY --chown=mercury:mercury persona ./persona
|
|
22
|
+
COPY --chown=mercury:mercury src ./src
|
|
23
|
+
|
|
24
|
+
# Mount points of the named volumes, owned by the runtime user before a fresh
|
|
25
|
+
# volume attaches (a new volume takes the ownership it finds here).
|
|
26
|
+
RUN mkdir -p /app/wiki-vault /home/mercury/.config \
|
|
27
|
+
&& chown mercury:mercury /app/wiki-vault \
|
|
28
|
+
&& chown -R mercury:mercury /home/mercury
|
|
29
|
+
|
|
30
|
+
USER mercury
|
|
31
|
+
|
|
32
|
+
CMD ["bun", "src/index.ts"]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service entrypoint: builds the app with `composeMercury`, fed
|
|
3
|
+
* `mercury.config.ts`, and starts what a long-running service runs: the
|
|
4
|
+
* declared channels, the admin panel (when enabled) and the memory crons.
|
|
5
|
+
* Headless; the interactive terminal is `bun run repl` (`repl.ts`).
|
|
6
|
+
*
|
|
7
|
+
* Stays alive on the channels' background resources and the cron intervals,
|
|
8
|
+
* and shuts down on SIGINT/SIGTERM (what `docker compose stop` sends).
|
|
9
|
+
*/
|
|
10
|
+
import { composeMercury, loadChannels, type LoadedChannel } from "@mercury-fw/core";
|
|
11
|
+
import mercuryConfig from "../mercury.config.ts";
|
|
12
|
+
|
|
13
|
+
const app = await composeMercury(mercuryConfig);
|
|
14
|
+
|
|
15
|
+
const loadedChannels: LoadedChannel[] = loadChannels(app.channels, { runtime: app.channelRuntime });
|
|
16
|
+
for (const { name, provider } of loadedChannels) {
|
|
17
|
+
await provider.start(app.handleTurn);
|
|
18
|
+
console.error(`[channel] ${name} started`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const adminServer = app.startAdmin();
|
|
22
|
+
const crons = app.startCrons();
|
|
23
|
+
console.error("[service] up — channels and crons started; waiting for SIGINT/SIGTERM");
|
|
24
|
+
|
|
25
|
+
// Release everything that holds the event loop open, then exit: the model and
|
|
26
|
+
// Qdrant clients keep pooled sockets with no dispose. Guarded so a second
|
|
27
|
+
// signal during teardown doesn't run it twice.
|
|
28
|
+
let shuttingDown = false;
|
|
29
|
+
async function shutdown(signal: string): Promise<void> {
|
|
30
|
+
if (shuttingDown) return;
|
|
31
|
+
shuttingDown = true;
|
|
32
|
+
console.error(`[shutdown] ${signal} received, releasing subsystems`);
|
|
33
|
+
crons.stop();
|
|
34
|
+
adminServer?.stop();
|
|
35
|
+
for (const { name, provider } of loadedChannels) {
|
|
36
|
+
await provider.stop?.();
|
|
37
|
+
console.error(`[shutdown] channel ${name} stopped`);
|
|
38
|
+
}
|
|
39
|
+
process.exit(0);
|
|
40
|
+
}
|
|
41
|
+
process.on("SIGINT", () => void shutdown("SIGINT"));
|
|
42
|
+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev REPL entrypoint (`bun run repl`): boots the same app the service runs and
|
|
3
|
+
* opens an interactive terminal on it. For trying things out and debugging, not
|
|
4
|
+
* a channel: no user identity (nothing goes to per-user memory), no channels,
|
|
5
|
+
* crons or admin panel.
|
|
6
|
+
*/
|
|
7
|
+
import { composeMercury, createTerminalProvider } from "@mercury-fw/core";
|
|
8
|
+
import mercuryConfig from "../mercury.config.ts";
|
|
9
|
+
|
|
10
|
+
const app = await composeMercury(mercuryConfig);
|
|
11
|
+
|
|
12
|
+
await createTerminalProvider({
|
|
13
|
+
confirmDeps: app.confirmDeps,
|
|
14
|
+
ollamaHost: app.ollamaHost,
|
|
15
|
+
ollamaModel: app.ollamaModel,
|
|
16
|
+
}).start(app.handleTurn);
|
|
17
|
+
|
|
18
|
+
// The REPL ended (Ctrl+D); the model and Qdrant clients keep pooled sockets
|
|
19
|
+
// open, so exit explicitly.
|
|
20
|
+
process.exit(0);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "Preserve",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"allowJs": true,
|
|
8
|
+
"types": ["bun"],
|
|
9
|
+
"moduleResolution": "bundler",
|
|
10
|
+
"allowImportingTsExtensions": true,
|
|
11
|
+
"verbatimModuleSyntax": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"strict": true,
|
|
15
|
+
"skipLibCheck": true,
|
|
16
|
+
"noFallthroughCasesInSwitch": true,
|
|
17
|
+
"noUncheckedIndexedAccess": true,
|
|
18
|
+
"noImplicitOverride": true
|
|
19
|
+
}
|
|
20
|
+
}
|