@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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two halves of `mfw credentials set`: packing a CLI's config folder into
|
|
3
|
+
* its credentials variable (a base64 tar.gz with the folder at its root, what
|
|
4
|
+
* the app's `docker-entrypoint.sh` unpacks into `~/.config` on the volume), and
|
|
5
|
+
* writing that variable into the app's env file.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
chmodSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
realpathSync,
|
|
13
|
+
renameSync,
|
|
14
|
+
rmSync,
|
|
15
|
+
statSync,
|
|
16
|
+
symlinkSync,
|
|
17
|
+
writeFileSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { tmpdir } from "node:os";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
|
|
22
|
+
/** `from` packed as `<folder>/…` into a base64 tar.gz on one line. The real
|
|
23
|
+
* folder behind `from` is archived through a symlink named `folder` and
|
|
24
|
+
* dereferenced (`-h`): whatever `from` is called, and even when it's itself a
|
|
25
|
+
* symlink (dotfiles), the archive holds the files under the CLI's folder name. */
|
|
26
|
+
export async function packCredentials(from: string, folder: string): Promise<string> {
|
|
27
|
+
const source = resolve(from);
|
|
28
|
+
if (!existsSync(source) || !statSync(source).isDirectory()) {
|
|
29
|
+
throw new Error(`No folder at ${source}`);
|
|
30
|
+
}
|
|
31
|
+
const staging = mkdtempSync(join(tmpdir(), "mfw-credentials-"));
|
|
32
|
+
symlinkSync(realpathSync(source), join(staging, folder));
|
|
33
|
+
try {
|
|
34
|
+
// COPYFILE_DISABLE: macOS's tar would otherwise add ._ files for metadata.
|
|
35
|
+
const proc = Bun.spawn(["tar", "-czh", "--no-xattrs", "-f", "-", "-C", staging, folder], {
|
|
36
|
+
env: { ...process.env, COPYFILE_DISABLE: "1" },
|
|
37
|
+
stdout: "pipe",
|
|
38
|
+
stderr: "pipe",
|
|
39
|
+
});
|
|
40
|
+
const [archive, stderr, code] = await Promise.all([
|
|
41
|
+
new Response(proc.stdout).arrayBuffer(),
|
|
42
|
+
new Response(proc.stderr).text(),
|
|
43
|
+
proc.exited,
|
|
44
|
+
]);
|
|
45
|
+
if (code !== 0) throw new Error(`tar failed packing ${source}: ${stderr.trim()}`);
|
|
46
|
+
return Buffer.from(archive).toString("base64");
|
|
47
|
+
} finally {
|
|
48
|
+
rmSync(staging, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Sets `name=value` in the env file at `file`. Every line that assigns
|
|
53
|
+
* `name` (`export name=` and spaces around `=` included) gives way to one
|
|
54
|
+
* line, where the first was; with none, it's appended. Every other line stays
|
|
55
|
+
* as it was. The file is replaced whole through a temporary file next to it,
|
|
56
|
+
* keeping its permissions, so a failed write never leaves it half written; a
|
|
57
|
+
* missing file is created readable by its owner only. */
|
|
58
|
+
export function setEnvVar(file: string, name: string, value: string): void {
|
|
59
|
+
const line = `${name}=${value}`;
|
|
60
|
+
if (!existsSync(file)) {
|
|
61
|
+
writeFileSync(file, `${line}\n`, { mode: 0o600 });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const text = readFileSync(file, "utf-8");
|
|
65
|
+
const assigns = new RegExp(`^\\s*(export\\s+)?${name}\\s*=`);
|
|
66
|
+
const lines = text.split("\n");
|
|
67
|
+
const first = lines.findIndex((l) => assigns.test(l));
|
|
68
|
+
let next: string;
|
|
69
|
+
if (first === -1) {
|
|
70
|
+
next = `${text}${text === "" || text.endsWith("\n") ? "" : "\n"}${line}\n`;
|
|
71
|
+
} else {
|
|
72
|
+
next = lines
|
|
73
|
+
.flatMap((l, i) => (i === first ? [line] : assigns.test(l) ? [] : [l]))
|
|
74
|
+
.join("\n");
|
|
75
|
+
}
|
|
76
|
+
const temporary = join(dirname(file), `.${name}.${process.pid}.tmp`);
|
|
77
|
+
const mode = statSync(file).mode & 0o777;
|
|
78
|
+
writeFileSync(temporary, next, { mode });
|
|
79
|
+
// The umask may have narrowed `mode` on creation.
|
|
80
|
+
chmodSync(temporary, mode);
|
|
81
|
+
renameSync(temporary, file);
|
|
82
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Finds the Mercury app the app commands (`mfw start`, `mfw vault`, …) act
|
|
3
|
+
* on: the nearest folder, from `from` upwards, holding `mercury.config.ts`.
|
|
4
|
+
* Its `package.json` name is the app's name (what `mfw reset` asks to type).
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { dirname, join, resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
export type App = { dir: string; name: string };
|
|
10
|
+
|
|
11
|
+
/** The app containing `from`; throws when there's none, or when its manifest has no name. */
|
|
12
|
+
export function findApp(from: string): App {
|
|
13
|
+
const start = resolve(from);
|
|
14
|
+
for (let dir = start; ; dir = dirname(dir)) {
|
|
15
|
+
if (existsSync(join(dir, "mercury.config.ts"))) {
|
|
16
|
+
const manifest = join(dir, "package.json");
|
|
17
|
+
const name = existsSync(manifest) ? (JSON.parse(readFileSync(manifest, "utf-8")) as { name?: unknown }).name : undefined;
|
|
18
|
+
if (typeof name !== "string" || name === "") throw new Error(`${manifest} has no name`);
|
|
19
|
+
return { dir, name };
|
|
20
|
+
}
|
|
21
|
+
if (dirname(dir) === dir) {
|
|
22
|
+
throw new Error(`Not inside a Mercury app: no mercury.config.ts in ${start} or any folder above it`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/args.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* pre-fill it.
|
|
5
|
-
*
|
|
2
|
+
* `mfw create`'s answers as the command line gives them: the target folder
|
|
3
|
+
* plus what can be given as flags, either to skip the wizard (`--yes`) or to
|
|
4
|
+
* pre-fill it. The command line itself is parsed in `program.ts`; no
|
|
5
|
+
* validation of the answers here: `renderApp` owns that, so the wizard and the
|
|
6
|
+
* flags go through the same checks.
|
|
6
7
|
*/
|
|
7
|
-
import { parseArgs } from "node:util";
|
|
8
8
|
|
|
9
9
|
/** What the command line says. An answer left out is asked by the wizard, or
|
|
10
10
|
* takes its default with `yes`. */
|
|
@@ -18,6 +18,16 @@ export type CreateArgs = {
|
|
|
18
18
|
yes: boolean;
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
/** `create`'s options as commander hands them over. */
|
|
22
|
+
export type CreateOptions = {
|
|
23
|
+
name?: string;
|
|
24
|
+
assistantName?: string;
|
|
25
|
+
role?: string;
|
|
26
|
+
channels?: string;
|
|
27
|
+
plugins?: string;
|
|
28
|
+
yes?: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
21
31
|
/** A comma-separated list, trimmed, empty items dropped. */
|
|
22
32
|
const list = (value: string): string[] =>
|
|
23
33
|
value
|
|
@@ -25,33 +35,13 @@ const list = (value: string): string[] =>
|
|
|
25
35
|
.map((s) => s.trim())
|
|
26
36
|
.filter(Boolean);
|
|
27
37
|
|
|
28
|
-
/**
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
name: { type: "string" },
|
|
37
|
-
"assistant-name": { type: "string" },
|
|
38
|
-
role: { type: "string" },
|
|
39
|
-
channels: { type: "string" },
|
|
40
|
-
plugins: { type: "string" },
|
|
41
|
-
yes: { type: "boolean", short: "y" },
|
|
42
|
-
},
|
|
43
|
-
});
|
|
44
|
-
if (positionals.length === 0) {
|
|
45
|
-
throw new Error("Missing the folder to create the app in");
|
|
46
|
-
}
|
|
47
|
-
if (positionals.length > 1) {
|
|
48
|
-
throw new Error(`Expected one folder, got ${positionals.length}: ${positionals.join(" ")}`);
|
|
49
|
-
}
|
|
50
|
-
const args: CreateArgs = { dir: positionals[0] as string, yes: values.yes ?? false };
|
|
51
|
-
if (values.name !== undefined) args.name = values.name;
|
|
52
|
-
if (values["assistant-name"] !== undefined) args.assistantName = values["assistant-name"];
|
|
53
|
-
if (values.role !== undefined) args.role = values.role;
|
|
54
|
-
if (values.channels !== undefined) args.channels = list(values.channels);
|
|
55
|
-
if (values.plugins !== undefined) args.plugins = list(values.plugins);
|
|
38
|
+
/** The answers in `folder` and `opts`, with only what was actually given. */
|
|
39
|
+
export function toCreateArgs(folder: string, opts: CreateOptions): CreateArgs {
|
|
40
|
+
const args: CreateArgs = { dir: folder, yes: opts.yes ?? false };
|
|
41
|
+
if (opts.name !== undefined) args.name = opts.name;
|
|
42
|
+
if (opts.assistantName !== undefined) args.assistantName = opts.assistantName;
|
|
43
|
+
if (opts.role !== undefined) args.role = opts.role;
|
|
44
|
+
if (opts.channels !== undefined) args.channels = list(opts.channels);
|
|
45
|
+
if (opts.plugins !== undefined) args.plugins = list(opts.plugins);
|
|
56
46
|
return args;
|
|
57
47
|
}
|
package/src/catalog.ts
CHANGED
|
@@ -19,8 +19,15 @@ export type CatalogEntry = {
|
|
|
19
19
|
exportName: string;
|
|
20
20
|
env: EnvVar[];
|
|
21
21
|
formatter?: FormatterExample;
|
|
22
|
+
credentials?: CliCredentials;
|
|
22
23
|
};
|
|
23
24
|
|
|
25
|
+
/** Where a tool plugin's CLI keeps its login: `folder` under the CLI user's
|
|
26
|
+
* `~/.config`, and the env variable that carries that folder into the
|
|
27
|
+
* container (a base64 tar.gz with the folder at its root), materialized on
|
|
28
|
+
* the credentials volume the first time the folder isn't there. */
|
|
29
|
+
export type CliCredentials = { folder: string; variable: string };
|
|
30
|
+
|
|
24
31
|
/** Starting formatter rules for a plugin that hands the user lists: without a
|
|
25
32
|
* rule a kind of list isn't shown, so the scaffolded config wraps the plugin
|
|
26
33
|
* with these. `displaysType` is the type the plugin exports for its kinds,
|
|
@@ -58,6 +65,7 @@ export const CATALOG: CatalogEntry[] = [
|
|
|
58
65
|
kind: "tool",
|
|
59
66
|
package: "@mercury-fw/plugin-jira",
|
|
60
67
|
exportName: "jiraPlugin",
|
|
68
|
+
credentials: { folder: "jira-cli", variable: "JIRA_CLI_CONFIG_TAR_B64" },
|
|
61
69
|
env: [{ name: "JIRA_SITE_URL", comment: "Jira site the issue links point to (https://<site>.atlassian.net)" }],
|
|
62
70
|
formatter: {
|
|
63
71
|
displaysType: "JiraDisplays",
|
|
@@ -76,6 +84,7 @@ export const CATALOG: CatalogEntry[] = [
|
|
|
76
84
|
kind: "tool",
|
|
77
85
|
package: "@mercury-fw/plugin-bitbucket",
|
|
78
86
|
exportName: "bitbucketPlugin",
|
|
87
|
+
credentials: { folder: "bitbucket-cli", variable: "BITBUCKET_CLI_CONFIG_TAR_B64" },
|
|
79
88
|
env: [],
|
|
80
89
|
},
|
|
81
90
|
{
|
|
@@ -83,6 +92,7 @@ export const CATALOG: CatalogEntry[] = [
|
|
|
83
92
|
kind: "tool",
|
|
84
93
|
package: "@mercury-fw/plugin-atlassian-admin",
|
|
85
94
|
exportName: "atlassianAdminPlugin",
|
|
95
|
+
credentials: { folder: "atlassian-admin-cli", variable: "ATLASSIAN_ADMIN_CLI_CONFIG_TAR_B64" },
|
|
86
96
|
env: [],
|
|
87
97
|
},
|
|
88
98
|
];
|
package/src/main.ts
CHANGED
|
@@ -1,47 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `mfw` command.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The `mfw` command. `create <folder>` writes a new Mercury app from the
|
|
3
|
+
* template, asking what to put in it (or taking the answers from flags with
|
|
4
|
+
* `--yes`); `bun install` in the new app is left to the user. The other
|
|
5
|
+
* commands operate an existing app from inside its folder (see
|
|
6
|
+
* `app/commands.ts`). The command line itself is declared in `program.ts`.
|
|
6
7
|
*/
|
|
7
8
|
import { basename, dirname, join, resolve } from "node:path";
|
|
8
|
-
import {
|
|
9
|
+
import type { CreateArgs } from "./args.ts";
|
|
9
10
|
import { CATALOG } from "./catalog.ts";
|
|
10
11
|
import { kebabCase } from "./naming.ts";
|
|
12
|
+
import { runProgram } from "./program.ts";
|
|
11
13
|
import { renderApp, selectionError } from "./render.ts";
|
|
12
14
|
import { appVersions, registryFrom } from "./versions.ts";
|
|
15
|
+
import { appCommands, terminalDeps, type AppDeps } from "./app/commands.ts";
|
|
16
|
+
import { findApp } from "./app/find-app.ts";
|
|
13
17
|
import { askAnswers, DEFAULT_ASSISTANT_NAME, DEFAULT_ROLE, type Answers } from "./wizard.ts";
|
|
14
18
|
import { targetError, writeApp } from "./write.ts";
|
|
15
19
|
|
|
16
|
-
const USAGE = `mfw, the Mercury command-line tool.
|
|
17
|
-
|
|
18
|
-
Usage:
|
|
19
|
-
mfw create <folder> [options]
|
|
20
|
-
|
|
21
|
-
mfw create writes a new Mercury app into <folder>, which has to be missing or
|
|
22
|
-
empty (its own name is turned into kebab case). Without options it asks for the
|
|
23
|
-
app name, the assistant's name and role, and which channels and tool plugins
|
|
24
|
-
to include; then it writes mercury.config.ts for that selection, the persona
|
|
25
|
-
(persona/identity.md, persona/tone.md), the service and REPL entrypoints, a
|
|
26
|
-
Dockerfile, a compose file with Qdrant, and an env example listing every
|
|
27
|
-
variable the app reads. Nothing is installed: run bun install in the new app.
|
|
28
|
-
|
|
29
|
-
Options:
|
|
30
|
-
--name <name> app name, as in package.json (default: the folder's name)
|
|
31
|
-
--assistant-name <name> the assistant's name (default: ${DEFAULT_ASSISTANT_NAME})
|
|
32
|
-
--role <text> completes "You are <name>, …" (default: ${DEFAULT_ROLE})
|
|
33
|
-
--channels <ids> comma-separated: ${CATALOG.filter((e) => e.kind === "channel").map((e) => e.id).join(", ")}
|
|
34
|
-
--plugins <ids> comma-separated: ${CATALOG.filter((e) => e.kind === "tool").map((e) => e.id).join(", ")}
|
|
35
|
-
-y, --yes don't ask: use the flags and the defaults
|
|
36
|
-
|
|
37
|
-
Examples:
|
|
38
|
-
mfw create my-agent
|
|
39
|
-
mfw create my-agent --assistant-name Hermes --channels http --plugins jira --yes
|
|
40
|
-
|
|
41
|
-
The framework packages get this CLI's version; each chosen plugin or channel
|
|
42
|
-
its latest on the registry (https://registry.npmjs.org, or MFW_REGISTRY).
|
|
43
|
-
`;
|
|
44
|
-
|
|
45
20
|
/** The answers taken from the flags alone, defaults for the rest. */
|
|
46
21
|
function answersFromFlags(args: CreateArgs, defaultName: string): Answers {
|
|
47
22
|
return {
|
|
@@ -54,8 +29,7 @@ function answersFromFlags(args: CreateArgs, defaultName: string): Answers {
|
|
|
54
29
|
}
|
|
55
30
|
|
|
56
31
|
/** `mfw create`: returns the exit code. */
|
|
57
|
-
async function create(
|
|
58
|
-
const args = parseCreateArgs(argv);
|
|
32
|
+
async function create(args: CreateArgs): Promise<number> {
|
|
59
33
|
// The folder is created in kebab case, only its own name: the parent path is
|
|
60
34
|
// taken as typed. Its name is also the app name's default.
|
|
61
35
|
const typed = resolve(args.dir);
|
|
@@ -85,35 +59,20 @@ Next:
|
|
|
85
59
|
cd ${dir}
|
|
86
60
|
bun install
|
|
87
61
|
cp .env.example .env # then fill it in
|
|
88
|
-
|
|
62
|
+
bunx mfw start`);
|
|
89
63
|
return 0;
|
|
90
64
|
}
|
|
91
65
|
|
|
92
66
|
/** Runs `mfw` with `argv` (the arguments after the command name) and returns
|
|
93
67
|
* the exit code, printing to stdout/stderr. `bin.ts` and `create-mercury-agent`
|
|
94
|
-
* both call it
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
if (command === "create" && rest.some((a) => a === "--help" || a === "-h")) {
|
|
106
|
-
console.log(USAGE);
|
|
107
|
-
return 0;
|
|
108
|
-
}
|
|
109
|
-
if (command !== "create") {
|
|
110
|
-
console.error(`Unknown command "${command}"\n\n${USAGE}`);
|
|
111
|
-
return 1;
|
|
112
|
-
}
|
|
113
|
-
try {
|
|
114
|
-
return await create(rest);
|
|
115
|
-
} catch (err) {
|
|
116
|
-
console.error(err instanceof Error ? err.message : String(err));
|
|
117
|
-
return 1;
|
|
118
|
-
}
|
|
68
|
+
* both call it; the app commands look for the app from `cwd` and run docker
|
|
69
|
+
* through `deps`. */
|
|
70
|
+
export async function main(
|
|
71
|
+
argv: string[],
|
|
72
|
+
{ cwd = process.cwd(), deps }: { cwd?: string; deps?: AppDeps } = {},
|
|
73
|
+
): Promise<number> {
|
|
74
|
+
return runProgram(argv, {
|
|
75
|
+
create,
|
|
76
|
+
app: () => appCommands(findApp(cwd), deps ?? terminalDeps()),
|
|
77
|
+
});
|
|
119
78
|
}
|
package/src/program.ts
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mfw` command line, declared with commander: every command, its
|
|
3
|
+
* arguments and options, and the help for each level (`mfw --help`,
|
|
4
|
+
* `mfw vault --help`, `mfw vault write-curated --help`). Parsing and
|
|
5
|
+
* validation happen here, before anything runs; what a command does lives in
|
|
6
|
+
* `create` (passed in) and `app/commands.ts`.
|
|
7
|
+
*/
|
|
8
|
+
import { Argument, Command, CommanderError, InvalidArgumentError, Option, type OutputConfiguration } from "commander";
|
|
9
|
+
import { toCreateArgs, type CreateArgs, type CreateOptions } from "./args.ts";
|
|
10
|
+
import { appCommands, RESET_TARGETS, type ResetTarget } from "./app/commands.ts";
|
|
11
|
+
import { CATALOG } from "./catalog.ts";
|
|
12
|
+
import { cliVersion } from "./versions.ts";
|
|
13
|
+
import { DEFAULT_ASSISTANT_NAME, DEFAULT_ROLE } from "./wizard.ts";
|
|
14
|
+
|
|
15
|
+
/** The commands that operate an app, bound to it. */
|
|
16
|
+
export type AppCommands = ReturnType<typeof appCommands>;
|
|
17
|
+
|
|
18
|
+
export type ProgramHandlers = {
|
|
19
|
+
/** `mfw create`; returns the exit code. */
|
|
20
|
+
create: (args: CreateArgs) => Promise<number>;
|
|
21
|
+
/** The app the app commands act on; throws outside one. */
|
|
22
|
+
app: () => AppCommands;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** `--limit`'s value: a positive whole number. */
|
|
26
|
+
function positiveInt(value: string): string {
|
|
27
|
+
if (!/^\d+$/.test(value) || Number(value) < 1) {
|
|
28
|
+
throw new InvalidArgumentError(`--limit takes a positive whole number (got "${value}").`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The help's closing paragraph for the commands that run inside an app. */
|
|
34
|
+
const INSIDE_AN_APP = "\nRun it from the app's folder or any folder under it (the one holding mercury.config.ts).";
|
|
35
|
+
|
|
36
|
+
/** Builds the `mfw` program; every action stores its exit code in `result`. */
|
|
37
|
+
function buildProgram(handlers: ProgramHandlers, result: { code: number }): Command {
|
|
38
|
+
const program = new Command("mfw")
|
|
39
|
+
.description("The Mercury command-line tool: creates an app, then runs it.")
|
|
40
|
+
.version(cliVersion(), "-V, --version")
|
|
41
|
+
.showSuggestionAfterError()
|
|
42
|
+
.helpCommand(false);
|
|
43
|
+
const inApp = (run: (app: AppCommands) => Promise<number>) => async () => {
|
|
44
|
+
result.code = await run(handlers.app());
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
program
|
|
48
|
+
.command("create")
|
|
49
|
+
.summary("writes a new Mercury app")
|
|
50
|
+
.description(
|
|
51
|
+
"Writes a new Mercury app into <folder>, which has to be missing or empty (its own name is turned into kebab case). Without options it asks for the app name, the assistant's name and role, and which channels and tool plugins to include; then it writes mercury.config.ts for that selection, the persona (persona/identity.md, persona/tone.md), the service and REPL entrypoints, a Dockerfile, a compose file with Qdrant, and an env example listing every variable the app reads. Nothing is installed: run bun install in the new app.",
|
|
52
|
+
)
|
|
53
|
+
.argument("<folder>", "where to write the app")
|
|
54
|
+
.option("--name <name>", "app name, as in package.json (default: the folder's name)")
|
|
55
|
+
.option("--assistant-name <name>", `the assistant's name (default: ${DEFAULT_ASSISTANT_NAME})`)
|
|
56
|
+
.option("--role <text>", `completes "You are <name>, …" (default: ${DEFAULT_ROLE})`)
|
|
57
|
+
.option(
|
|
58
|
+
"--channels <ids>",
|
|
59
|
+
`comma-separated: ${CATALOG.filter((e) => e.kind === "channel").map((e) => e.id).join(", ")}`,
|
|
60
|
+
)
|
|
61
|
+
.option("--plugins <ids>", `comma-separated: ${CATALOG.filter((e) => e.kind === "tool").map((e) => e.id).join(", ")}`)
|
|
62
|
+
.option("-y, --yes", "don't ask: use the flags and the defaults")
|
|
63
|
+
.addHelpText(
|
|
64
|
+
"after",
|
|
65
|
+
`
|
|
66
|
+
The framework packages get this CLI's version; each chosen plugin or channel
|
|
67
|
+
its latest on the registry (https://registry.npmjs.org, or MFW_REGISTRY).
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
mfw create my-agent
|
|
71
|
+
mfw create my-agent --assistant-name Hermes --channels http --plugins jira --yes`,
|
|
72
|
+
)
|
|
73
|
+
.action(async (folder: string, opts: CreateOptions) => {
|
|
74
|
+
result.code = await handlers.create(toCreateArgs(folder, opts));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// commander reads --no-cache as "cache: false"; the commands take noCache.
|
|
78
|
+
const cacheOff = (opts: { cache: boolean }) => ({ noCache: !opts.cache });
|
|
79
|
+
|
|
80
|
+
program
|
|
81
|
+
.command("start")
|
|
82
|
+
.summary("builds and starts the app")
|
|
83
|
+
.description(
|
|
84
|
+
"Builds the app's image and starts the app and Qdrant in the background. Only what changed is rebuilt, and only what changed (image, .env, compose file) is recreated.",
|
|
85
|
+
)
|
|
86
|
+
.addOption(new Option("--no-cache", "rebuild everything from scratch"))
|
|
87
|
+
.addHelpText("after", INSIDE_AN_APP)
|
|
88
|
+
.action(async (opts: { cache: boolean }) => inApp((app) => app.start(cacheOff(opts)))());
|
|
89
|
+
|
|
90
|
+
program
|
|
91
|
+
.command("stop")
|
|
92
|
+
.summary("stops the app")
|
|
93
|
+
.description("Stops the app and Qdrant and removes their containers. The volumes (memory, wiki, CLI credentials) stay.")
|
|
94
|
+
.addHelpText("after", INSIDE_AN_APP)
|
|
95
|
+
.action(inApp((app) => app.stop()));
|
|
96
|
+
|
|
97
|
+
program
|
|
98
|
+
.command("restart")
|
|
99
|
+
.summary("rebuilds and restarts the app")
|
|
100
|
+
.description("Like mfw start, but recreates the containers even when nothing changed: a clean restart.")
|
|
101
|
+
.addOption(new Option("--no-cache", "rebuild everything from scratch"))
|
|
102
|
+
.addHelpText("after", INSIDE_AN_APP)
|
|
103
|
+
.action(async (opts: { cache: boolean }) => inApp((app) => app.restart(cacheOff(opts)))());
|
|
104
|
+
|
|
105
|
+
program
|
|
106
|
+
.command("logs")
|
|
107
|
+
.summary("follows the logs")
|
|
108
|
+
.description("Follows the logs of every service, or only of [service].")
|
|
109
|
+
.argument("[service]", "mercury or qdrant")
|
|
110
|
+
.addHelpText("after", INSIDE_AN_APP)
|
|
111
|
+
.action(async (service: string | undefined) => inApp((app) => app.logs(service))());
|
|
112
|
+
|
|
113
|
+
program
|
|
114
|
+
.command("repl")
|
|
115
|
+
.summary("opens the dev REPL")
|
|
116
|
+
.description("Opens the dev REPL, a terminal conversation with the assistant, in a one-off container. A running app isn't touched.")
|
|
117
|
+
.addHelpText("after", INSIDE_AN_APP)
|
|
118
|
+
.action(inApp((app) => app.repl()));
|
|
119
|
+
|
|
120
|
+
program
|
|
121
|
+
.command("shell")
|
|
122
|
+
.summary("opens a shell in the app's container")
|
|
123
|
+
.description("Opens a shell in the app's container: the running one if the app is up, otherwise a one-off container.")
|
|
124
|
+
.addHelpText("after", INSIDE_AN_APP)
|
|
125
|
+
.action(inApp((app) => app.shell()));
|
|
126
|
+
|
|
127
|
+
const vault = program
|
|
128
|
+
.command("vault")
|
|
129
|
+
.summary("wiki vault maintenance")
|
|
130
|
+
.description(
|
|
131
|
+
"Maintains the wiki vault, in a one-off container on the vault's volume. Paths are vault-relative, as mfw vault list prints them (curated/…, raw/…).",
|
|
132
|
+
)
|
|
133
|
+
.helpCommand(false)
|
|
134
|
+
.addHelpText("after", INSIDE_AN_APP);
|
|
135
|
+
vault
|
|
136
|
+
.command("list")
|
|
137
|
+
.summary("every note")
|
|
138
|
+
.description("Lists every note.")
|
|
139
|
+
.action(async () => inApp((app) => app.vault(["list"]))());
|
|
140
|
+
vault
|
|
141
|
+
.command("read")
|
|
142
|
+
.summary("a note")
|
|
143
|
+
.description("Prints a note.")
|
|
144
|
+
.argument("<path>", "the note, vault-relative")
|
|
145
|
+
.action(async (path: string) => inApp((app) => app.vault(["read", path]))());
|
|
146
|
+
vault
|
|
147
|
+
.command("grep")
|
|
148
|
+
.summary("lines matching a pattern")
|
|
149
|
+
.description("Prints every line matching <pattern> (a regular expression) as path:line:text.")
|
|
150
|
+
.argument("<pattern>", "a regular expression; after --, one starting with - too")
|
|
151
|
+
.action(async (pattern: string) => inApp((app) => app.vault(["grep", pattern]))());
|
|
152
|
+
vault
|
|
153
|
+
.command("write-curated")
|
|
154
|
+
.summary("writes a curated note from stdin")
|
|
155
|
+
.description("Writes a curated note, the body read from stdin.")
|
|
156
|
+
.argument("<path>", "curated/…, vault-relative")
|
|
157
|
+
.option("--author <name>", "who wrote it")
|
|
158
|
+
.addHelpText("after", "\nExample:\n cat note.md | mfw vault write-curated curated/standards/new-note.md --author luca")
|
|
159
|
+
.action(async (path: string, opts: { author?: string }) =>
|
|
160
|
+
inApp((app) => app.vault(["write-curated", path, ...(opts.author === undefined ? [] : ["--author", opts.author])]))(),
|
|
161
|
+
);
|
|
162
|
+
vault
|
|
163
|
+
.command("write-raw")
|
|
164
|
+
.summary("writes raw material from stdin")
|
|
165
|
+
.description("Writes raw material for the nightly review to triage, the body read from stdin.")
|
|
166
|
+
.argument("<path>", "raw/…, vault-relative")
|
|
167
|
+
.action(async (path: string) => inApp((app) => app.vault(["write-raw", path]))());
|
|
168
|
+
|
|
169
|
+
const memory = program
|
|
170
|
+
.command("memory")
|
|
171
|
+
.summary("reads Layer-3 memory")
|
|
172
|
+
.description("Reads Layer-3 memory on Qdrant, in a one-off container. Read-only.")
|
|
173
|
+
.helpCommand(false)
|
|
174
|
+
.addHelpText("after", INSIDE_AN_APP);
|
|
175
|
+
memory
|
|
176
|
+
.command("list")
|
|
177
|
+
.summary("the collections and their points")
|
|
178
|
+
.description("Lists the collections and how many points each holds.")
|
|
179
|
+
.action(async () => inApp((app) => app.memory(["list"]))());
|
|
180
|
+
memory
|
|
181
|
+
.command("read")
|
|
182
|
+
.summary("a collection's points")
|
|
183
|
+
.description(
|
|
184
|
+
"Prints a collection's points, each as its id and one line per payload field: newest first where the collection has a timestamp index, in Qdrant's own order otherwise.",
|
|
185
|
+
)
|
|
186
|
+
.argument("<collection>", "as mfw memory list prints it")
|
|
187
|
+
.option("--limit <n>", "how many points (default: 20)", positiveInt)
|
|
188
|
+
.action(async (collection: string, opts: { limit?: string }) =>
|
|
189
|
+
inApp((app) => app.memory(["read", collection, ...(opts.limit === undefined ? [] : ["--limit", opts.limit])]))(),
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
program
|
|
193
|
+
.command("reset")
|
|
194
|
+
.summary("deletes memory or the wiki, after confirmation")
|
|
195
|
+
.description(
|
|
196
|
+
"Deletes for good what the assistant remembers: memory is every Qdrant collection, wiki the whole vault. It asks you to type the app's name first (anything else deletes nothing), then brings the service back up empty.",
|
|
197
|
+
)
|
|
198
|
+
.addArgument(new Argument("<target>", "what to delete").choices(Object.keys(RESET_TARGETS)))
|
|
199
|
+
.addHelpText("after", INSIDE_AN_APP)
|
|
200
|
+
.action(async (target: ResetTarget) => inApp((app) => app.reset(target))());
|
|
201
|
+
|
|
202
|
+
const credentials = program
|
|
203
|
+
.command("credentials")
|
|
204
|
+
.summary("the tool plugins' CLI credentials")
|
|
205
|
+
.description(
|
|
206
|
+
"Hands a tool plugin's CLI its login: the CLI's config folder travels in a variable of the env file, and the container unpacks it onto the credentials volume at the first start without that folder. What the CLI refreshes afterwards stays on the volume.",
|
|
207
|
+
)
|
|
208
|
+
.helpCommand(false)
|
|
209
|
+
.addHelpText("after", INSIDE_AN_APP);
|
|
210
|
+
credentials
|
|
211
|
+
.command("set")
|
|
212
|
+
.summary("packs a CLI's config folder into the env file")
|
|
213
|
+
.description(
|
|
214
|
+
"Packs the plugin's CLI config folder (~/.config/<cli> unless --from says otherwise) and writes it as the plugin's variable in the app's env file, replacing an older value. The value is never printed, unless --print asks for the line instead.",
|
|
215
|
+
)
|
|
216
|
+
.argument("<plugin>", "a tool plugin of the app: jira, bitbucket, atlassian-admin")
|
|
217
|
+
.option("--from <dir>", "the CLI's config folder, when it isn't ~/.config/<cli>")
|
|
218
|
+
.option("--print", "print the line to paste elsewhere, and leave the env file alone")
|
|
219
|
+
.action(async (plugin: string, opts: { from?: string; print?: boolean }) =>
|
|
220
|
+
inApp((app) => app.credentialsSet(plugin, { ...(opts.from === undefined ? {} : { from: opts.from }), print: opts.print ?? false }))(),
|
|
221
|
+
);
|
|
222
|
+
credentials
|
|
223
|
+
.command("reset")
|
|
224
|
+
.summary("clears a CLI's folder from the volume")
|
|
225
|
+
.description(
|
|
226
|
+
"Deletes the plugin's CLI folder from the credentials volume, after you type the plugin's name, so its variable is unpacked again at the next start: what to run after correcting the variable. Any token the CLI refreshed on the volume goes with it.",
|
|
227
|
+
)
|
|
228
|
+
.argument("<plugin>", "a tool plugin of the app")
|
|
229
|
+
.action(async (plugin: string) => inApp((app) => app.credentialsReset(plugin))());
|
|
230
|
+
|
|
231
|
+
return program;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Runs `mfw` on `argv` (the arguments after the command name) and returns
|
|
235
|
+
* the exit code. Commander's own messages (help, errors) go to `output`, and
|
|
236
|
+
* so does the message of an error a command throws. */
|
|
237
|
+
export async function runProgram(argv: string[], handlers: ProgramHandlers, output?: OutputConfiguration): Promise<number> {
|
|
238
|
+
const result = { code: 0 };
|
|
239
|
+
const program = buildProgram(handlers, result);
|
|
240
|
+
const writeErr = output?.writeErr ?? ((s: string) => process.stderr.write(s));
|
|
241
|
+
const configure = (cmd: Command): void => {
|
|
242
|
+
cmd.exitOverride();
|
|
243
|
+
if (output !== undefined) cmd.configureOutput(output);
|
|
244
|
+
cmd.commands.forEach(configure);
|
|
245
|
+
};
|
|
246
|
+
configure(program);
|
|
247
|
+
if (argv.length === 0) {
|
|
248
|
+
program.outputHelp({ error: true });
|
|
249
|
+
return 1;
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
await program.parseAsync(argv, { from: "user" });
|
|
253
|
+
return result.code;
|
|
254
|
+
} catch (err) {
|
|
255
|
+
if (err instanceof CommanderError) return err.exitCode;
|
|
256
|
+
writeErr(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
}
|