@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.
@@ -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
- * 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.
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
- /** 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);
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. 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.
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 { parseCreateArgs, type CreateArgs } from "./args.ts";
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(argv: string[]): Promise<number> {
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
- docker compose up --build`);
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
- 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
- }
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
+ }