@mercury-fw/cli 0.42.0 → 0.44.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 CHANGED
@@ -1,5 +1,39 @@
1
1
  # @mercury-fw/cli
2
2
 
3
+ ## 0.44.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2c63bbf: One person across accounts: linking them by a code.
8
+
9
+ - `POST /link` on the HTTP surface hands the caller a one-time code (`xxxx-xxxx-xxxx`, ten minutes, single use). They send it from the other account, on any channel, twice. The first time Mercury says whom the account would be linked to, the second time it links it.
10
+ - From then on the account is the person it's linked to: one private area, one memory, the same roles. Its earlier area stays where it was, and undoing the link gives it back.
11
+ - A link never touches the terminal, never chains, and is refused for an account the directory already knows as someone else.
12
+ - The links live on the vault's volume, out of the vault's git and out of reach of the model and the HTTP reads.
13
+ - `mfw identity links | link | unlink` lets the operator manage them, for instances where nobody can be shown a code.
14
+ - Channels get the code through the confirmation hook they already call (`ctx.confirm`), and the HTTP surface gets `ctx.linking`.
15
+ - Fixed: `ctx.confirm` refused someone the core doesn't admit before checking that their text was a token at all. On Google Chat, every message from an unknown sender came back as the refusal.
16
+
17
+ ### Patch Changes
18
+
19
+ - Updated dependencies [2c63bbf]
20
+ - @mercury-fw/core@0.44.0
21
+ - @mercury-fw/utils@0.44.0
22
+
23
+ ## 0.43.0
24
+
25
+ ### Minor Changes
26
+
27
+ - 07eb858: ZITADEL as the user directory.
28
+
29
+ - `@mercury-fw/plugin-zitadel` exports `zitadelDirectory`. Someone calling with a ZITADEL token is the ZITADEL user the token names, with the role keys of their active role assignments on the project `ZITADEL_PROJECT_ID`. A user ZITADEL doesn't find, or one that isn't active, is unknown. It reads through the zitadel CLI's service user, from code.
30
+ - `mfw create --directory <id>` (`static` or `zitadel`) gives the new app a user directory, and the wizard asks for one.
31
+
32
+ ### Patch Changes
33
+
34
+ - @mercury-fw/core@0.43.0
35
+ - @mercury-fw/utils@0.43.0
36
+
3
37
  ## 0.42.0
4
38
 
5
39
  ### Patch Changes
package/README.md CHANGED
@@ -16,6 +16,7 @@
16
16
  - [`mfw vault <command>`](#mfw-vault-command)
17
17
  - [`mfw memory list`](#mfw-memory-list)
18
18
  - [`mfw memory read <collection> [--limit N]`](#mfw-memory-read-collection---limit-n)
19
+ - [`mfw identity links`, `link`, `unlink`](#mfw-identity-links-link-unlink)
19
20
  - [`mfw reset <memory|wiki>`](#mfw-reset-memorywiki)
20
21
  - [`mfw credentials setup <plugin>`](#mfw-credentials-setup-plugin)
21
22
  - [`mfw credentials check <plugin>`](#mfw-credentials-check-plugin)
@@ -62,6 +63,7 @@ Then it runs `bun install` in the app and creates a git repository on `main` wit
62
63
  | `--channels <ids>` | Comma-separated: `google-chat`, `http`. |
63
64
  | `--plugins <ids>` | Comma-separated: `jira`, `bitbucket`, `atlassian-admin`, `zitadel`. |
64
65
  | `--auth <id>` | The HTTP channel's auth provider, `oidc` or `static`: required with `http`, refused without it, since the channel doesn't start without one. The wizard asks for it once `http` is chosen. |
66
+ | `--directory <id>` | The user directory, `static` or `zitadel`: who the people are and their roles. Without one, the person is whoever the channel says, with no roles. |
65
67
  | `--local-packages <folder>` | Installs the packages packed in `<folder>` (`bun pm pack`) instead of the registry's, as [`mfw local-packages`](#mfw-local-packages-folder--mfw-local-packages---off) does on an existing app: their versions come from the tarballs, so a package that isn't published yet works too, and no newer `mfw` is looked up on the registry. For trying unreleased packages in a new app; the test bed makes its apps this way. |
66
68
  | `--git-remote <url>` | The repository's `origin`, taken as typed. |
67
69
  | `--no-install` | Don't run `bun install`. |
@@ -173,6 +175,16 @@ mfw memory read episodic_memory
173
175
  mfw memory read semantic_facts --limit 5
174
176
  ```
175
177
 
178
+ ### `mfw identity links`, `link`, `unlink`
179
+
180
+ The links that make several accounts one person: one private area, one memory, the same roles. People link their own accounts with a code (the HTTP surface's `POST /link` gives one, they send it from the other account, twice); these are for the operator, in a one-off container, where nobody can be shown a code. Identities are written `<provider>:<id>`, as the channel or the auth provider vouches for them (`google-chat:users/123`, `oidc:3123`, `static:alice`). The running service applies a change within five minutes. Undoing a link gives the account back its own area, untouched.
181
+
182
+ ```bash
183
+ mfw identity links
184
+ mfw identity link google-chat:users/123 oidc:3123
185
+ mfw identity unlink google-chat:users/123
186
+ ```
187
+
176
188
  ### `mfw reset <memory|wiki>`
177
189
 
178
190
  Deletes for good what the assistant remembers: `memory` is every collection on Qdrant, `wiki` the whole vault. It reads the volume's real name from the compose file, tells you which one is about to go, and asks you to type the app's name (the `name` in `package.json`); anything else, an empty answer, a `y` or closing the input included, deletes nothing and exits 1. Once confirmed it stops the service using the volume, removes its container and the volume, and starts the service again on an empty one (`docker compose stop`, `rm -f`, `docker volume rm`, `up -d`). After `memory`, a running app is restarted too (`docker compose restart mercury`), since it sets up its collections only when it starts.
@@ -21,6 +21,7 @@ export type AppDeps = {
21
21
  * bin whose file isn't there yet. The core moves in lockstep with this CLI. */
22
22
  export declare const VAULT_CLI = "node_modules/@mercury-fw/core/src/wiki/vault-cli.ts";
23
23
  export declare const MEMORY_CLI = "node_modules/@mercury-fw/core/src/memory/memory-cli.ts";
24
+ export declare const IDENTITY_CLI = "node_modules/@mercury-fw/core/src/identity/links-cli.ts";
24
25
  /** What `mfw reset` can wipe: the compose service using the volume, the
25
26
  * volume's key in the compose file, and how to say what's lost. */
26
27
  export declare const RESET_TARGETS: {
@@ -52,6 +53,8 @@ export declare function appCommands(app: App, deps: AppDeps): {
52
53
  vault: (args: string[]) => Promise<number>;
53
54
  /** `args` is the memory CLI's own command line (`list`, `read <collection>`, …). */
54
55
  memory: (args: string[]) => Promise<number>;
56
+ /** `args` is the account links CLI's own command line (`links`, `link <identity> <owner>`, `unlink <identity>`). */
57
+ identity: (args: string[]) => Promise<number>;
55
58
  /** Deletes `target`'s volume once the user types the app's name, then
56
59
  * brings its service back up on an empty volume. The volume's real name
57
60
  * comes from the compose file, and a wrong answer deletes nothing. */
@@ -16,6 +16,8 @@ export type CreateArgs = {
16
16
  plugins?: string[];
17
17
  /** The auth provider, which goes with the HTTP channel. */
18
18
  auth?: string;
19
+ /** The user directory. */
20
+ directory?: string;
19
21
  /** The repository's origin, taken as typed. */
20
22
  gitRemote?: string;
21
23
  /** A folder of packed tarballs the app installs instead of the registry's packages. */
@@ -34,6 +36,7 @@ export type CreateOptions = {
34
36
  channels?: string;
35
37
  plugins?: string;
36
38
  auth?: string;
39
+ directory?: string;
37
40
  gitRemote?: string;
38
41
  localPackages?: string;
39
42
  yes?: boolean;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The channels, auth providers and tool plugins `mfw create` can put in a new app. Written
2
+ * The channels, auth providers, tool plugins and user directories `mfw create` can put in a new app. Written
3
3
  * by hand for now: once the packages are published, each plugin will describe
4
4
  * itself (kind, export, env vars) and this list goes away. `catalog.test.ts`
5
5
  * checks every entry against the real package until then.
@@ -11,13 +11,14 @@ export type EnvVar = {
11
11
  comment: string;
12
12
  value?: string;
13
13
  };
14
- /** A channel, auth provider or tool plugin the app can include. `id` is the
15
- * plugin's own `name` (what the logs and `dependsOn` call it), `exportName` the
16
- * value the app's config imports from `package`. An auth provider goes with the
17
- * HTTP channel, which doesn't start without one. */
14
+ /** A channel, auth provider, tool plugin or user directory the app can include.
15
+ * `id` is the plugin's own `name` (what the logs and `dependsOn` call it; for a
16
+ * directory, the choice's name), `exportName` the value the app's config
17
+ * imports from `package`. An auth provider goes with the HTTP channel, which
18
+ * doesn't start without one; a directory goes with any channel. */
18
19
  export type CatalogEntry = {
19
20
  id: string;
20
- kind: "channel" | "auth" | "tool";
21
+ kind: "channel" | "auth" | "tool" | "directory";
21
22
  package: string;
22
23
  exportName: string;
23
24
  env: EnvVar[];
@@ -8,6 +8,8 @@ export type RenderInput = {
8
8
  plugins: string[];
9
9
  /** The HTTP channel's auth provider: required with it, refused without it. */
10
10
  auth?: string;
11
+ /** The user directory: who the people are and their roles. */
12
+ directory?: string;
11
13
  versions: Record<string, string>;
12
14
  };
13
15
  /** Builds every file of the new app. Throws on an invalid app name, an empty
@@ -17,10 +19,10 @@ export declare function renderApp(input: RenderInput): Map<string, string>;
17
19
  /** Why `name` can't be an app name, or undefined when it can. Shared with the
18
20
  * wizard, which checks the name as it's typed. */
19
21
  export declare function appNameError(name: string): string | undefined;
20
- /** Names the first channel, plugin or auth provider id the catalog doesn't
21
- * have, with the valid ones, or undefined when all are known. Shared with the
22
- * command, which checks the flags before asking anything. */
23
- export declare function selectionError(channels: string[], plugins: string[], auth?: string): string | undefined;
22
+ /** Names the first channel, plugin, auth provider or directory id the catalog
23
+ * doesn't have, with the valid ones, or undefined when all are known. Shared
24
+ * with the command, which checks the flags before asking anything. */
25
+ export declare function selectionError(channels: string[], plugins: string[], auth?: string, directory?: string): string | undefined;
24
26
  /** What's wrong with pairing the HTTP channel and the auth provider: it
25
27
  * doesn't start without one, and one without it has nothing to authenticate.
26
28
  * Undefined when they go together. Shared with the command, which checks it
@@ -9,6 +9,8 @@ export type Answers = {
9
9
  plugins: string[];
10
10
  /** The HTTP channel's auth provider, there exactly when that channel is. */
11
11
  auth?: string;
12
+ /** The user directory, when there's one. */
13
+ directory?: string;
12
14
  gitRemote?: string;
13
15
  };
14
16
  export declare const DEFAULT_ASSISTANT_NAME = "Mercury";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/cli",
3
- "version": "0.42.0",
3
+ "version": "0.44.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -41,20 +41,21 @@
41
41
  "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.",
42
42
  "dependencies": {
43
43
  "@clack/prompts": "^1.8.1",
44
- "@mercury-fw/core": "0.42.0",
45
- "@mercury-fw/utils": "0.42.0",
44
+ "@mercury-fw/core": "0.44.0",
45
+ "@mercury-fw/utils": "0.44.0",
46
46
  "commander": "^15.0.0"
47
47
  },
48
48
  "devDependencies": {
49
- "@mercury-fw/channel-google-chat": "0.5.0",
50
- "@mercury-fw/channel-http": "0.6.0",
51
49
  "@mercury-fw/auth-oidc": "0.1.0",
52
50
  "@mercury-fw/auth-static": "0.2.0",
53
- "@mercury-fw/formatter": "0.42.0",
51
+ "@mercury-fw/channel-google-chat": "0.5.0",
52
+ "@mercury-fw/channel-http": "0.7.0",
53
+ "@mercury-fw/directory-static": "0.1.0",
54
+ "@mercury-fw/formatter": "0.44.0",
54
55
  "@mercury-fw/plugin-atlassian-admin": "0.3.1",
55
- "@mercury-fw/plugin-bitbucket": "0.3.0",
56
- "@mercury-fw/plugin-jira": "0.6.0",
57
- "@mercury-fw/plugin-zitadel": "0.1.0",
56
+ "@mercury-fw/plugin-bitbucket": "0.3.1",
57
+ "@mercury-fw/plugin-jira": "0.6.1",
58
+ "@mercury-fw/plugin-zitadel": "0.2.0",
58
59
  "@mercury-fw/typescript-config": "*",
59
60
  "@types/bun": "^1.4.2",
60
61
  "typescript": "^6.0.3"
@@ -44,6 +44,7 @@ const SERVICE = "mercury";
44
44
  * bin whose file isn't there yet. The core moves in lockstep with this CLI. */
45
45
  export const VAULT_CLI = "node_modules/@mercury-fw/core/src/wiki/vault-cli.ts";
46
46
  export const MEMORY_CLI = "node_modules/@mercury-fw/core/src/memory/memory-cli.ts";
47
+ export const IDENTITY_CLI = "node_modules/@mercury-fw/core/src/identity/links-cli.ts";
47
48
 
48
49
  /** What `mfw reset` can wipe: the compose service using the volume, the
49
50
  * volume's key in the compose file, and how to say what's lost. */
@@ -100,6 +101,8 @@ export function appCommands(app: App, deps: AppDeps) {
100
101
  vault: (args: string[]) => oneOff(["bun", VAULT_CLI, ...args]),
101
102
  /** `args` is the memory CLI's own command line (`list`, `read <collection>`, …). */
102
103
  memory: (args: string[]) => oneOff(["bun", MEMORY_CLI, ...args]),
104
+ /** `args` is the account links CLI's own command line (`links`, `link <identity> <owner>`, `unlink <identity>`). */
105
+ identity: (args: string[]) => oneOff(["bun", IDENTITY_CLI, ...args]),
103
106
  /** Deletes `target`'s volume once the user types the app's name, then
104
107
  * brings its service back up on an empty volume. The volume's real name
105
108
  * comes from the compose file, and a wrong answer deletes nothing. */
package/src/args.ts CHANGED
@@ -17,6 +17,8 @@ export type CreateArgs = {
17
17
  plugins?: string[];
18
18
  /** The auth provider, which goes with the HTTP channel. */
19
19
  auth?: string;
20
+ /** The user directory. */
21
+ directory?: string;
20
22
  /** The repository's origin, taken as typed. */
21
23
  gitRemote?: string;
22
24
  /** A folder of packed tarballs the app installs instead of the registry's packages. */
@@ -36,6 +38,7 @@ export type CreateOptions = {
36
38
  channels?: string;
37
39
  plugins?: string;
38
40
  auth?: string;
41
+ directory?: string;
39
42
  gitRemote?: string;
40
43
  localPackages?: string;
41
44
  yes?: boolean;
@@ -59,6 +62,7 @@ export function toCreateArgs(folder: string, opts: CreateOptions): CreateArgs {
59
62
  if (opts.channels !== undefined) args.channels = list(opts.channels);
60
63
  if (opts.plugins !== undefined) args.plugins = list(opts.plugins);
61
64
  if (opts.auth !== undefined && opts.auth.trim() !== "") args.auth = opts.auth.trim();
65
+ if (opts.directory !== undefined && opts.directory.trim() !== "") args.directory = opts.directory.trim();
62
66
  if (opts.localPackages !== undefined) args.localPackages = opts.localPackages;
63
67
  // Blank is none, as in the wizard.
64
68
  if (opts.gitRemote !== undefined && opts.gitRemote.trim() !== "") args.gitRemote = opts.gitRemote.trim();
package/src/catalog.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The channels, auth providers and tool plugins `mfw create` can put in a new app. Written
2
+ * The channels, auth providers, tool plugins and user directories `mfw create` can put in a new app. Written
3
3
  * by hand for now: once the packages are published, each plugin will describe
4
4
  * itself (kind, export, env vars) and this list goes away. `catalog.test.ts`
5
5
  * checks every entry against the real package until then.
@@ -9,13 +9,14 @@
9
9
  * example: `value` is the example value, empty when the deployer must fill it. */
10
10
  export type EnvVar = { name: string; comment: string; value?: string };
11
11
 
12
- /** A channel, auth provider or tool plugin the app can include. `id` is the
13
- * plugin's own `name` (what the logs and `dependsOn` call it), `exportName` the
14
- * value the app's config imports from `package`. An auth provider goes with the
15
- * HTTP channel, which doesn't start without one. */
12
+ /** A channel, auth provider, tool plugin or user directory the app can include.
13
+ * `id` is the plugin's own `name` (what the logs and `dependsOn` call it; for a
14
+ * directory, the choice's name), `exportName` the value the app's config
15
+ * imports from `package`. An auth provider goes with the HTTP channel, which
16
+ * doesn't start without one; a directory goes with any channel. */
16
17
  export type CatalogEntry = {
17
18
  id: string;
18
- kind: "channel" | "auth" | "tool";
19
+ kind: "channel" | "auth" | "tool" | "directory";
19
20
  package: string;
20
21
  exportName: string;
21
22
  env: EnvVar[];
@@ -122,6 +123,28 @@ export const CATALOG: CatalogEntry[] = [
122
123
  exportName: "zitadelPlugin",
123
124
  env: [],
124
125
  },
126
+ {
127
+ id: "static",
128
+ kind: "directory",
129
+ package: "@mercury-fw/directory-static",
130
+ exportName: "staticDirectory",
131
+ env: [
132
+ {
133
+ name: "DIRECTORY_STATIC_PEOPLE",
134
+ comment:
135
+ 'Required: the people, as JSON: [{"id": "alice", "roles": ["mercury.act-as-self"], "identities": ["static:alice"]}]',
136
+ },
137
+ ],
138
+ },
139
+ {
140
+ id: "zitadel",
141
+ kind: "directory",
142
+ package: "@mercury-fw/plugin-zitadel",
143
+ exportName: "zitadelDirectory",
144
+ env: [{ name: "ZITADEL_PROJECT_ID", comment: "Required: the ZITADEL project whose roles count" }],
145
+ // It calls the zitadel CLI, which the package downloads as it installs.
146
+ trusts: ["@mercury-fw/plugin-zitadel"],
147
+ },
125
148
  ];
126
149
 
127
150
  /** The catalog entry of `kind` with `id`, or undefined when there is none. */
package/src/main.ts CHANGED
@@ -29,6 +29,7 @@ function answersFromFlags(args: CreateArgs, defaultName: string): Answers {
29
29
  channels: args.channels ?? [],
30
30
  plugins: args.plugins ?? [],
31
31
  ...(args.auth !== undefined ? { auth: args.auth } : {}),
32
+ ...(args.directory !== undefined ? { directory: args.directory } : {}),
32
33
  ...(args.gitRemote !== undefined ? { gitRemote: args.gitRemote } : {}),
33
34
  };
34
35
  }
@@ -127,7 +128,7 @@ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, g
127
128
  const pairedByFlags = args.yes || (args.channels !== undefined && args.auth !== undefined);
128
129
  const early =
129
130
  targetError(dir) ??
130
- selectionError(args.channels ?? [], args.plugins ?? [], args.auth) ??
131
+ selectionError(args.channels ?? [], args.plugins ?? [], args.auth, args.directory) ??
131
132
  (pairedByFlags ? pairingError(args.channels ?? [], args.auth) : undefined) ??
132
133
  remoteError(args);
133
134
  if (early !== undefined) {
@@ -138,9 +139,17 @@ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, g
138
139
  if (answers === undefined) {
139
140
  return 1;
140
141
  }
141
- const chosen = CATALOG.filter((e) =>
142
- e.kind === "auth" ? e.id === answers.auth : (e.kind === "channel" ? answers.channels : answers.plugins).includes(e.id),
143
- ).map((e) => e.package);
142
+ const chosen = [
143
+ ...new Set(
144
+ CATALOG.filter((e) =>
145
+ e.kind === "auth"
146
+ ? e.id === answers.auth
147
+ : e.kind === "directory"
148
+ ? e.id === answers.directory
149
+ : (e.kind === "channel" ? answers.channels : answers.plugins).includes(e.id),
150
+ ).map((e) => e.package),
151
+ ),
152
+ ];
144
153
  const versions = await appVersions(chosen, {
145
154
  registry: registryFrom(process.env.MFW_REGISTRY),
146
155
  ...(packs !== undefined ? { local: Object.fromEntries(packs.map((p) => [p.name, p.version])) } : {}),
@@ -162,7 +171,7 @@ async function create(args: CreateArgs, rawArgs: string[], relaunch: Relaunch, g
162
171
  ...(answers.gitRemote !== undefined ? { remote: answers.gitRemote } : {}),
163
172
  commitMessage: [
164
173
  `Scaffold with mfw create ${cliVersion()}`,
165
- `Channels: ${none(answers.channels)}\nPlugins: ${none(answers.plugins)}${answers.auth !== undefined ? `\nAuth: ${answers.auth}` : ""}`,
174
+ `Channels: ${none(answers.channels)}\nPlugins: ${none(answers.plugins)}${answers.auth !== undefined ? `\nAuth: ${answers.auth}` : ""}${answers.directory !== undefined ? `\nDirectory: ${answers.directory}` : ""}`,
166
175
  ],
167
176
  },
168
177
  run,
package/src/program.ts CHANGED
@@ -67,6 +67,10 @@ function buildProgram(handlers: ProgramHandlers, result: { code: number }): Comm
67
67
  "--auth <id>",
68
68
  `the HTTP channel's auth provider, required with it: ${CATALOG.filter((e) => e.kind === "auth").map((e) => e.id).join(", ")}`,
69
69
  )
70
+ .option(
71
+ "--directory <id>",
72
+ `the user directory, who the people are and their roles: ${CATALOG.filter((e) => e.kind === "directory").map((e) => e.id).join(", ")} (default: none)`,
73
+ )
70
74
  .option("--git-remote <url>", "the repository's origin, taken as typed (default: none)")
71
75
  .option(
72
76
  "--local-packages <folder>",
@@ -214,6 +218,35 @@ Examples:
214
218
  inApp((app) => app.memory(["read", collection, ...(opts.limit === undefined ? [] : ["--limit", opts.limit])]))(),
215
219
  );
216
220
 
221
+ const identity = program
222
+ .command("identity")
223
+ .summary("the accounts linked to a person")
224
+ .description(
225
+ "Lists, makes and undoes the links that make several accounts one person (one private area, the same roles), in a one-off container. People link their own accounts with a code; this is for the operator, where nobody can be shown one.",
226
+ )
227
+ .helpCommand(false)
228
+ .addHelpText("after", INSIDE_AN_APP);
229
+ identity
230
+ .command("links")
231
+ .summary("the linked accounts")
232
+ .description("Lists every linked account and the account it belongs to.")
233
+ .action(async () => inApp((app) => app.identity(["links"]))());
234
+ identity
235
+ .command("link")
236
+ .summary("links an account to another")
237
+ .description(
238
+ "Makes <identity> the same person as <owner>, both as <provider>:<id> (e.g. google-chat:users/123, oidc:3123). The running service applies it within five minutes.",
239
+ )
240
+ .argument("<identity>", "the account to link")
241
+ .argument("<owner>", "the account it belongs to")
242
+ .action(async (account: string, owner: string) => inApp((app) => app.identity(["link", account, owner]))());
243
+ identity
244
+ .command("unlink")
245
+ .summary("undoes an account's link")
246
+ .description("Makes <identity> a person of its own again; what it had before being linked is still there.")
247
+ .argument("<identity>", "as mfw identity links prints it")
248
+ .action(async (account: string) => inApp((app) => app.identity(["unlink", account]))());
249
+
217
250
  program
218
251
  .command("reset")
219
252
  .summary("deletes memory or the wiki, after confirmation")
package/src/render.ts CHANGED
@@ -28,6 +28,8 @@ export type RenderInput = {
28
28
  plugins: string[];
29
29
  /** The HTTP channel's auth provider: required with it, refused without it. */
30
30
  auth?: string;
31
+ /** The user directory: who the people are and their roles. */
32
+ directory?: string;
31
33
  versions: Record<string, string>;
32
34
  };
33
35
 
@@ -66,18 +68,19 @@ export function renderApp(input: RenderInput): Map<string, string> {
66
68
  const channels = selected("channel", input.channels);
67
69
  const tools = selected("tool", input.plugins);
68
70
  const auth = selected("auth", input.auth === undefined ? [] : [input.auth])[0];
71
+ const directory = selected("directory", input.directory === undefined ? [] : [input.directory])[0];
69
72
  const assistantName = input.assistantName.trim();
70
73
 
71
74
  return new Map([
72
75
  [".dockerignore", dockerignore],
73
- [".env.example", renderEnv(channels, auth, tools)],
76
+ [".env.example", renderEnv(channels, auth, tools, directory)],
74
77
  [".gitignore", gitignore],
75
78
  ["Dockerfile", dockerfile],
76
- ["README.md", renderReadme(input.name, channels, auth, tools)],
79
+ ["README.md", renderReadme(input.name, channels, auth, tools, directory)],
77
80
  ["docker-compose.yml", renderCompose(input.name, channels.some((c) => c.id === "http"))],
78
81
  ["markdown.d.ts", markdownDts],
79
- ["mercury.config.ts", renderConfig(channels, auth, tools)],
80
- ["package.json", renderPackageJson(input.name, channels, auth, tools, input.versions)],
82
+ ["mercury.config.ts", renderConfig(channels, auth, tools, directory)],
83
+ ["package.json", renderPackageJson(input.name, channels, auth, tools, input.versions, directory)],
81
84
  ["persona/identity.md", `You are ${assistantName}, ${input.role.trim().replace(/\.+$/, "")}.\n`],
82
85
  // A replacer function, not a string: in a replacement string "$&" and the
83
86
  // like are patterns, and the name must land verbatim.
@@ -112,7 +115,8 @@ function validate(input: RenderInput): void {
112
115
  if (/[\r\n]/.test(input.assistantName) || /[\r\n]/.test(input.role)) {
113
116
  throw new Error("The assistant name and role must each be one line");
114
117
  }
115
- const selection = selectionError(input.channels, input.plugins, input.auth) ?? pairingError(input.channels, input.auth);
118
+ const selection =
119
+ selectionError(input.channels, input.plugins, input.auth, input.directory) ?? pairingError(input.channels, input.auth);
116
120
  if (selection !== undefined) {
117
121
  throw new Error(selection);
118
122
  }
@@ -121,14 +125,15 @@ function validate(input: RenderInput): void {
121
125
  /** The ids of the catalog entries of `kind`. */
122
126
  const idsOf = (kind: CatalogEntry["kind"]): string[] => CATALOG.filter((e) => e.kind === kind).map((e) => e.id);
123
127
 
124
- /** Names the first channel, plugin or auth provider id the catalog doesn't
125
- * have, with the valid ones, or undefined when all are known. Shared with the
126
- * command, which checks the flags before asking anything. */
127
- export function selectionError(channels: string[], plugins: string[], auth?: string): string | undefined {
128
+ /** Names the first channel, plugin, auth provider or directory id the catalog
129
+ * doesn't have, with the valid ones, or undefined when all are known. Shared
130
+ * with the command, which checks the flags before asking anything. */
131
+ export function selectionError(channels: string[], plugins: string[], auth?: string, directory?: string): string | undefined {
128
132
  for (const [kind, ids, label] of [
129
133
  ["channel", channels, "channel"],
130
134
  ["tool", plugins, "plugin"],
131
135
  ["auth", auth === undefined ? [] : [auth], "auth provider"],
136
+ ["directory", directory === undefined ? [] : [directory], "directory"],
132
137
  ] as const) {
133
138
  const valid = idsOf(kind);
134
139
  const unknown = ids.find((id) => !valid.includes(id));
@@ -158,7 +163,12 @@ function selected(kind: CatalogEntry["kind"], ids: string[]): CatalogEntry[] {
158
163
  /** `mercury.config.ts`: imports, the formatter helpers of the plugins that have
159
164
  * any, then the config with each plugin (wrapped in the formatter when it has
160
165
  * starting rules) and channel. */
161
- function renderConfig(channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
166
+ function renderConfig(
167
+ channels: CatalogEntry[],
168
+ auth: CatalogEntry | undefined,
169
+ tools: CatalogEntry[],
170
+ directory: CatalogEntry | undefined,
171
+ ): string {
162
172
  const withRules = tools.filter((t) => t.formatter);
163
173
  const imports = ['import { defineMercuryConfig } from "@mercury-fw/core";'];
164
174
  if (withRules.length > 0) {
@@ -168,7 +178,7 @@ function renderConfig(channels: CatalogEntry[], auth: CatalogEntry | undefined,
168
178
  const names = t.formatter ? `${t.exportName}, type ${t.formatter.displaysType}` : t.exportName;
169
179
  imports.push(`import { ${names} } from "${t.package}";`);
170
180
  }
171
- for (const c of [...channels, ...(auth ? [auth] : [])]) {
181
+ for (const c of [...channels, ...(auth ? [auth] : []), ...(directory ? [directory] : [])]) {
172
182
  imports.push(`import { ${c.exportName} } from "${c.package}";`);
173
183
  }
174
184
  imports.push('import identity from "./persona/identity.md" with { type: "text" };');
@@ -191,6 +201,7 @@ function renderConfig(channels: CatalogEntry[], auth: CatalogEntry | undefined,
191
201
  plugins,
192
202
  channelList,
193
203
  ...(auth ? [` auth: ${auth.exportName},`] : []),
204
+ ...(directory ? [` directory: ${directory.exportName},`] : []),
194
205
  "});",
195
206
  "",
196
207
  ].join("\n");
@@ -213,15 +224,17 @@ function renderPluginEntry(t: CatalogEntry): string {
213
224
 
214
225
  /** `package.json`: the core, the formatter when a plugin is wrapped in it, the
215
226
  * chosen packages, and every tool plugin trusted to run its postinstall (it
216
- * downloads the plugin's CLI). */
227
+ * downloads the plugin's CLI), plus what a chosen entry says it needs trusted. */
217
228
  function renderPackageJson(
218
229
  name: string,
219
230
  channels: CatalogEntry[],
220
231
  auth: CatalogEntry | undefined,
221
232
  tools: CatalogEntry[],
222
233
  versions: Record<string, string>,
234
+ directory: CatalogEntry | undefined,
223
235
  ): string {
224
- const packages = ["@mercury-fw/core", ...[...channels, ...(auth ? [auth] : []), ...tools].map((e) => e.package)];
236
+ const chosen = [...channels, ...(auth ? [auth] : []), ...tools, ...(directory ? [directory] : [])];
237
+ const packages = [...new Set(["@mercury-fw/core", ...chosen.map((e) => e.package)])];
225
238
  if (tools.some((t) => t.formatter)) {
226
239
  packages.push("@mercury-fw/formatter");
227
240
  }
@@ -248,7 +261,7 @@ function renderPackageJson(
248
261
  // hands the app's commands over to it.
249
262
  devDependencies: { "@mercury-fw/cli": `^${cli}`, "@types/bun": "^1.4.2", typescript: "^6.0.3" },
250
263
  };
251
- const trusted = [...new Set([...tools.map((t) => t.package), ...[...channels, ...tools].flatMap((e) => e.trusts ?? [])])];
264
+ const trusted = [...new Set([...tools.map((t) => t.package), ...chosen.flatMap((e) => e.trusts ?? [])])];
252
265
  if (trusted.length > 0) {
253
266
  manifest.trustedDependencies = trusted.sort();
254
267
  }
@@ -257,12 +270,18 @@ function renderPackageJson(
257
270
 
258
271
  /** The env example: the core's variables, then a section per chosen entry
259
272
  * that reads any variable. */
260
- function renderEnv(channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
273
+ function renderEnv(
274
+ channels: CatalogEntry[],
275
+ auth: CatalogEntry | undefined,
276
+ tools: CatalogEntry[],
277
+ directory: CatalogEntry | undefined,
278
+ ): string {
261
279
  const block = (vars: EnvVar[]) => vars.map((v) => `# ${v.comment}\n${v.name}=${v.value ?? ""}`).join("\n");
262
280
  const sections = [block(CORE_ENV)];
263
- for (const entry of [...channels, ...(auth ? [auth] : []), ...tools]) {
281
+ for (const entry of [...channels, ...(auth ? [auth] : []), ...tools, ...(directory ? [directory] : [])]) {
264
282
  if (entry.env.length > 0) {
265
- sections.push(`# --- ${entry.id}\n${block(entry.env)}`);
283
+ // A directory can share its id with a tool plugin of the same package.
284
+ sections.push(`# --- ${entry.id}${entry.kind === "directory" ? " directory" : ""}\n${block(entry.env)}`);
266
285
  }
267
286
  }
268
287
  return `${sections.join("\n\n")}\n`;
@@ -317,7 +336,13 @@ function renderCompose(name: string, hasHttp: boolean): string {
317
336
  }
318
337
 
319
338
  /** The app's README: what it was scaffolded with and how to run it. */
320
- function renderReadme(name: string, channels: CatalogEntry[], auth: CatalogEntry | undefined, tools: CatalogEntry[]): string {
339
+ function renderReadme(
340
+ name: string,
341
+ channels: CatalogEntry[],
342
+ auth: CatalogEntry | undefined,
343
+ tools: CatalogEntry[],
344
+ directory: CatalogEntry | undefined,
345
+ ): string {
321
346
  const list = (entries: CatalogEntry[]) => (entries.length > 0 ? entries.map((e) => e.id).join(", ") : "none");
322
347
  return `# ${name}
323
348
 
@@ -325,7 +350,7 @@ A Mercury app, scaffolded by \`mfw create\`.
325
350
 
326
351
  - Channels: ${list(channels)}
327
352
  ${auth ? `- Auth: ${auth.id}\n` : ""}- Tool plugins: ${list(tools)}
328
-
353
+ ${directory ? `- Directory: ${directory.id}\n` : ""}
329
354
  ## Layout
330
355
 
331
356
  - \`mercury.config.ts\`: what the app is made of (tool plugins, channels, how their lists read) and the assistant's persona.
package/src/wizard.ts CHANGED
@@ -19,6 +19,8 @@ export type Answers = {
19
19
  plugins: string[];
20
20
  /** The HTTP channel's auth provider, there exactly when that channel is. */
21
21
  auth?: string;
22
+ /** The user directory, when there's one. */
23
+ directory?: string;
22
24
  gitRemote?: string;
23
25
  };
24
26
 
@@ -87,6 +89,17 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
87
89
  }
88
90
  const plugins = await pick("tool", "Tool plugins (space to select)", args.plugins ?? []);
89
91
  if (plugins === undefined) return cancelled();
92
+ const NO_DIRECTORY = "";
93
+ const pickedDirectory = await p.select({
94
+ message: "User directory (who the people are and their roles; none: whoever the channel says, with no roles)",
95
+ options: [
96
+ { value: NO_DIRECTORY, label: "none" },
97
+ ...CATALOG.filter((e) => e.kind === "directory").map((e) => ({ value: e.id, label: e.id, hint: e.package })),
98
+ ],
99
+ initialValue: args.directory ?? NO_DIRECTORY,
100
+ });
101
+ if (p.isCancel(pickedDirectory)) return cancelled();
102
+ const directory = pickedDirectory === NO_DIRECTORY ? undefined : pickedDirectory;
90
103
  // Asked only when there will be a repository and the flag didn't say.
91
104
  let gitRemote = args.gitRemote;
92
105
  if (args.git && gitRemote === undefined) {
@@ -105,6 +118,7 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
105
118
  `Channels: ${channels.length > 0 ? channels.join(", ") : "none"}`,
106
119
  ...(auth !== undefined ? [`Auth: ${auth}`] : []),
107
120
  `Tool plugins: ${plugins.length > 0 ? plugins.join(", ") : "none"}`,
121
+ `Directory: ${directory ?? "none"}`,
108
122
  ...(args.git ? [`Origin: ${gitRemote ?? "none"}`] : []),
109
123
  ].join("\n"),
110
124
  "Summary",
@@ -118,6 +132,7 @@ export async function askAnswers(args: CreateArgs, dir: string): Promise<Answers
118
132
  channels,
119
133
  plugins,
120
134
  ...(auth !== undefined ? { auth } : {}),
135
+ ...(directory !== undefined ? { directory } : {}),
121
136
  ...(gitRemote !== undefined ? { gitRemote } : {}),
122
137
  };
123
138
  }