@mercury-fw/cli-engine 0.38.1 → 0.40.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,46 @@
1
1
  # @mercury-fw/cli-engine
2
2
 
3
+ ## 0.40.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 0739f77: A plugin acts as the person Mercury is talking to, who logs in to the service through Mercury.
8
+
9
+ - Plugin contract version 4: a plugin declares `actsAs: "person" | "mercury"`, and one acting as the person contributes a `login`; its tools get the turn's `person` and `requireLogin` in their context.
10
+ - A person is offered only the plugins acting as the person: their prompt fragments, skills and tools. A plugin acting as Mercury, or declaring nothing, is offered on the terminal only until people can be allowed to make Mercury act as itself; the startup log names it.
11
+ - `createCliTool` adds `--user` with the person's id to every command, staged confirmations included, and when the CLI exits with code 3 (not logged in) returns their login instead. `createCliPersonLogin` builds a plugin's `login` on a CLI's two-step remote login. A failed `runCli` carries `exitCode`.
12
+ - Channel contract version 4: channels get `logins` (`accept` a callback URL, `complete` a login) and `detectLoginRequired`.
13
+ - The HTTP channel, with `HTTP_SURFACE_PUBLIC_URL` set, streams a `login` event with the link to open and takes the person back at `GET /login/callback`, public and protected by the single-use state.
14
+ - `mercury.cliCredentials` can declare `userSetup`, run by `mfw credentials setup <plugin> --user-app`: the app people log in through, set up without logging anyone in.
15
+ - plugin-jira and plugin-bitbucket act as the person, on CLIs 2.3.0: set up their people's app with `--user-app` and register `<HTTP_SURFACE_PUBLIC_URL>/login/callback` on it. Jira's skill now says `currentUser()` is the person.
16
+ - plugin-atlassian-admin acts as Mercury, on CLI 0.2.0, whose `init` asks for the key without echoing it: until permissions exist it's available on the terminal only.
17
+ - A third-party plugin needs `apiVersion: 4` to load, and `actsAs: "person"` with a `login` to be offered to people.
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies [0739f77]
22
+ - @mercury-fw/plugin-types@0.40.0
23
+ - @mercury-fw/utils@0.40.0
24
+
25
+ ## 0.39.0
26
+
27
+ ### Minor Changes
28
+
29
+ - 881835c: A CLI's login is set up inside the app's container, no longer carried in the env file.
30
+
31
+ - A plugin declares in `mercury.cliCredentials` the commands that set its CLI's login up (`setup`, required), check it (`check`) and log an identity out (`logout`), each a binary followed by its arguments.
32
+ - `mfw credentials setup <plugin>` runs the declared setup in a one-off container on your terminal, so the CLI writes its login straight onto the credentials volume; `mfw credentials check <plugin>` runs the declared check.
33
+ - `mfw credentials reset <plugin> [--user <key>]` runs the declared logout, of the identity Mercury runs as or of one person, instead of deleting the CLI's folder.
34
+ - `mfw credentials set` is removed, and the core no longer unpacks `*_CONFIG_TAR_B64` variables at startup: it reports a declared login that isn't set up yet, and still links a login kept outside `~/.config` to the volume.
35
+ - A command the model writes with `--user` is refused: which person a CLI acts as is never the model's to pick.
36
+ - `cliUserId` (`@mercury-fw/utils`) maps a user key to the id a CLI knows a person by.
37
+ - plugin-jira and plugin-bitbucket pin their CLIs at 2.1.0, which keep the service identity and each person apart and refuse the old login folder. After updating, remove the plugin's `*_CONFIG_TAR_B64` line from the env file and run `mfw credentials setup` once; on Jira, Mercury now runs as an Atlassian Service Account.
38
+ - plugin-atlassian-admin keeps its CLI at 0.1.2, whose `init` doesn't prompt for the organization key: until it does, write the key from `mfw shell` with `atlassian-admin init --api-key <KEY> --org-id <ORG_ID>` (the plugin's README says so).
39
+
40
+ ### Patch Changes
41
+
42
+ - @mercury-fw/plugin-types@0.39.0
43
+
3
44
  ## 0.38.1
4
45
 
5
46
  ### Patch Changes
package/cli-executor.ts CHANGED
@@ -25,6 +25,12 @@
25
25
  import type { CliResult } from "@mercury-fw/plugin-types";
26
26
  export type { CliResult };
27
27
 
28
+ /** The exit code the CLIs Mercury's plugins run use when the identity a
29
+ * command runs as (the service, or the person `--user` names) has no login,
30
+ * or one that has to be made again: what tells it apart from any other
31
+ * failure. */
32
+ export const CLI_NOT_LOGGED_IN_EXIT_CODE = 3;
33
+
28
34
  /**
29
35
  * Thin wrapper around `Bun.spawn` with both stdout and stderr piped.
30
36
  * Extracted into its own function (rather than calling `Bun.spawn`
@@ -43,7 +49,7 @@ function spawnPiped(binary: string, args: string[]) {
43
49
  *
44
50
  * Resolves to `{ ok: false, error }` — never rejects/throws — for the
45
51
  * binary not existing on `PATH` or a non-zero exit code (the error
46
- * includes the exit code and stderr). Success is exit code 0, full stop:
52
+ * includes the exit code and stderr, and `exitCode` carries the code). Success is exit code 0, full stop:
47
53
  * if stdout happens to be valid JSON it's parsed into `data`, otherwise
48
54
  * the raw trimmed text is `data` instead. Non-JSON stdout on a 0 exit is
49
55
  * not a parse failure — `--help` output is exactly this shape (plain
@@ -72,6 +78,7 @@ export async function runCli(
72
78
  return {
73
79
  ok: false,
74
80
  error: `${binary} exited with code ${exitCode}: ${stderr.trim()}`,
81
+ exitCode,
75
82
  };
76
83
  }
77
84
 
package/cli-tool.ts CHANGED
@@ -23,8 +23,9 @@
23
23
  import { tool, type JSONValue } from "ai";
24
24
  import { z } from "zod";
25
25
  import { parseCommand } from "./command-parser.ts";
26
- import type { runCli, CliResult } from "./cli-executor.ts";
27
- import type { StageConfirmation, ExecutableTool } from "@mercury-fw/plugin-types";
26
+ import { CLI_NOT_LOGGED_IN_EXIT_CODE, type runCli, type CliResult } from "./cli-executor.ts";
27
+ import type { StageConfirmation, ExecutableTool, LoginRequired } from "@mercury-fw/plugin-types";
28
+ import { cliUserId } from "@mercury-fw/utils";
28
29
 
29
30
  // `CliPostProcessor` is part of the plugin contract (a plugin's `build()`
30
31
  // returns one) — it lives in `@mercury-fw/plugin-types` and is re-exported here
@@ -125,11 +126,20 @@ export function omitDisplayForModel(output: unknown): unknown {
125
126
  return rest;
126
127
  }
127
128
 
129
+ /** Drops the link from a `loginRequired` result before it reaches the model:
130
+ * it carries the login's state, the only credential of the callback the
131
+ * person comes back to, so the channel shows it and the model never sees it. */
132
+ function omitLoginLinkForModel(output: unknown): unknown {
133
+ if (typeof output !== "object" || output === null || (output as { loginRequired?: unknown }).loginRequired !== true) return output;
134
+ const { authorizeUrl: _url, ...rest } = output as Record<string, unknown>;
135
+ return rest;
136
+ }
137
+
128
138
  /**
129
139
  * Builds the `runCommand` tool: the model writes a whole CLI invocation as
130
140
  * one string, `execute` parses it (`parseCommand`), checks the binary
131
- * against `configs`, then classifies the argv via `matchCommand` — each
132
- * failure mode (unparseable / unknown binary / confirm-required /
141
+ * against `configs`, refuses an identity flag (`--user`), then classifies the argv via `matchCommand` — each
142
+ * failure mode (unparseable / identity flag / unknown binary / confirm-required /
133
143
  * not-allowed) gets a distinct, self-correctable error message — before
134
144
  * ever calling `runCliFn`. `runCliFn` is injected (defaulting to the real
135
145
  * `runCli` in production) so tests can supply a fake without spawning a
@@ -163,8 +173,16 @@ export function createCliTool(
163
173
  * composition root. Optional: with none wired the inline `display` is left
164
174
  * untouched and no `displayRef` is minted. */
165
175
  stashDisplay?: (artifact: string) => string;
176
+ /** The person the turn is for (see `SessionToolContext.person`): every
177
+ * command runs as them, with `--user` and the id their CLI knows them by.
178
+ * Absent or `null`: the CLI's own service identity. */
179
+ person?: { key: string } | null;
180
+ /** What the tool hands back when the person's CLI says they aren't logged
181
+ * in (`CLI_NOT_LOGGED_IN_EXIT_CODE`): their login, started. */
182
+ requireLogin?: () => Promise<LoginRequired>;
166
183
  },
167
184
  ): { runCommand: ExecutableTool } {
185
+ const identity = opts.person ? ["--user", cliUserId(opts.person.key)] : [];
168
186
  // Anchor the example on a binary this tool actually runs rather than
169
187
  // a hardcoded one: a fixed `jira …` example misleads the model on an instance
170
188
  // without Jira. The concrete, CLI-specific example (real subcommand + flags)
@@ -181,6 +199,16 @@ export function createCliTool(
181
199
  return { ok: false, error: `could not parse "${command}": ${parsed.error}` };
182
200
  }
183
201
 
202
+ // The CLIs act as whoever `--user <id>` names: which identity a command
203
+ // runs as is Mercury's decision, never something the model writes.
204
+ if (parsed.args.some((a) => a === "--user" || a.startsWith("--user="))) {
205
+ return {
206
+ ok: false,
207
+ error:
208
+ "--user is not allowed: Mercury decides whose account a command runs as, never the command itself. Run it again without --user.",
209
+ };
210
+ }
211
+
184
212
  const config = configs[parsed.binary];
185
213
  if (!config) {
186
214
  const known = Object.keys(configs).join(", ") || "(none configured)";
@@ -208,7 +236,7 @@ export function createCliTool(
208
236
  // staged args run, regardless of whether the model remembered to
209
237
  // include it on the first attempt (observed live: it usually
210
238
  // doesn't).
211
- const argsToStage = parsed.args.includes("--confirm") ? parsed.args : [...parsed.args, "--confirm"];
239
+ const argsToStage = [...(parsed.args.includes("--confirm") ? parsed.args : [...parsed.args, "--confirm"]), ...identity];
212
240
  // Stage the doing as an opaque thunk — the core confirmation subsystem
213
241
  // never learns this is a CLI command. `describe` (the normalized argv)
214
242
  // is the paper-trail text; `summary` on the result (the raw command the
@@ -226,7 +254,12 @@ export function createCliTool(
226
254
  };
227
255
  }
228
256
 
229
- const result = await runCliFn(parsed.binary, parsed.args);
257
+ const result = await runCliFn(parsed.binary, [...parsed.args, ...identity]);
258
+ // The person has no login (or an expired one) with this CLI's service:
259
+ // nothing to retry, they log in first.
260
+ if (!result.ok && result.exitCode === CLI_NOT_LOGGED_IN_EXIT_CODE && opts.person && opts.requireLogin) {
261
+ return opts.requireLogin();
262
+ }
230
263
  const processed = opts.postProcess
231
264
  ? opts.postProcess({ binary: parsed.binary, args: parsed.args, prefix: match.prefix }, result)
232
265
  : result;
@@ -246,7 +279,7 @@ export function createCliTool(
246
279
  }
247
280
  return processed;
248
281
  },
249
- toModelOutput: ({ output }) => ({ type: "json", value: omitDisplayForModel(output) as JSONValue }),
282
+ toModelOutput: ({ output }) => ({ type: "json", value: omitLoginLinkForModel(omitDisplayForModel(output)) as JSONValue }),
250
283
  });
251
284
 
252
285
  return { runCommand };
@@ -19,13 +19,18 @@
19
19
  */
20
20
  import type { CliResult } from "@mercury-fw/plugin-types";
21
21
  export type { CliResult };
22
+ /** The exit code the CLIs Mercury's plugins run use when the identity a
23
+ * command runs as (the service, or the person `--user` names) has no login,
24
+ * or one that has to be made again: what tells it apart from any other
25
+ * failure. */
26
+ export declare const CLI_NOT_LOGGED_IN_EXIT_CODE = 3;
22
27
  /**
23
28
  * Runs `binary` with `args`, waits for it to exit, and parses its
24
29
  * stdout as JSON when possible.
25
30
  *
26
31
  * Resolves to `{ ok: false, error }` — never rejects/throws — for the
27
32
  * binary not existing on `PATH` or a non-zero exit code (the error
28
- * includes the exit code and stderr). Success is exit code 0, full stop:
33
+ * includes the exit code and stderr, and `exitCode` carries the code). Success is exit code 0, full stop:
29
34
  * if stdout happens to be valid JSON it's parsed into `data`, otherwise
30
35
  * the raw trimmed text is `data` instead. Non-JSON stdout on a 0 exit is
31
36
  * not a parse failure — `--help` output is exactly this shape (plain
@@ -1,5 +1,5 @@
1
- import type { runCli } from "./cli-executor.ts";
2
- import type { StageConfirmation, ExecutableTool } from "@mercury-fw/plugin-types";
1
+ import { type runCli } from "./cli-executor.ts";
2
+ import type { StageConfirmation, ExecutableTool, LoginRequired } from "@mercury-fw/plugin-types";
3
3
  import type { CliPostProcessor } from "@mercury-fw/plugin-types";
4
4
  export type { CliPostProcessor };
5
5
  export type AllowedCommand = {
@@ -70,8 +70,8 @@ export declare function omitDisplayForModel(output: unknown): unknown;
70
70
  /**
71
71
  * Builds the `runCommand` tool: the model writes a whole CLI invocation as
72
72
  * one string, `execute` parses it (`parseCommand`), checks the binary
73
- * against `configs`, then classifies the argv via `matchCommand` — each
74
- * failure mode (unparseable / unknown binary / confirm-required /
73
+ * against `configs`, refuses an identity flag (`--user`), then classifies the argv via `matchCommand` — each
74
+ * failure mode (unparseable / identity flag / unknown binary / confirm-required /
75
75
  * not-allowed) gets a distinct, self-correctable error message — before
76
76
  * ever calling `runCliFn`. `runCliFn` is injected (defaulting to the real
77
77
  * `runCli` in production) so tests can supply a fake without spawning a
@@ -102,6 +102,15 @@ export declare function createCliTool(runCliFn: typeof runCli, configs: Record<s
102
102
  * composition root. Optional: with none wired the inline `display` is left
103
103
  * untouched and no `displayRef` is minted. */
104
104
  stashDisplay?: (artifact: string) => string;
105
+ /** The person the turn is for (see `SessionToolContext.person`): every
106
+ * command runs as them, with `--user` and the id their CLI knows them by.
107
+ * Absent or `null`: the CLI's own service identity. */
108
+ person?: {
109
+ key: string;
110
+ } | null;
111
+ /** What the tool hands back when the person's CLI says they aren't logged
112
+ * in (`CLI_NOT_LOGGED_IN_EXIT_CODE`): their login, started. */
113
+ requireLogin?: () => Promise<LoginRequired>;
105
114
  }): {
106
115
  runCommand: ExecutableTool;
107
116
  };
package/dist/index.d.ts CHANGED
@@ -7,12 +7,14 @@
7
7
  *
8
8
  * The core imports what it needs from here; a CLI-based plugin (Jira,
9
9
  * Bitbucket) rides on this one. Nothing here imports the app — its only
10
- * dependencies are `@mercury-fw/plugin-types` (the shared contract) and the
10
+ * dependencies are `@mercury-fw/plugin-types` (the shared contract),
11
+ * `@mercury-fw/utils` (the id a CLI knows a person by) and the
11
12
  * external `ai`/`zod`/`shell-quote` packages — so the mechanism is a self
12
13
  * contained unit that an instance with no CLI plugin never pulls in.
13
14
  */
14
15
  export { parseCommand, type ParsedCommand } from "./command-parser.ts";
15
- export { runCli, type CliResult } from "./cli-executor.ts";
16
+ export { runCli, CLI_NOT_LOGGED_IN_EXIT_CODE, type CliResult } from "./cli-executor.ts";
17
+ export { createCliPersonLogin } from "./person-login.ts";
16
18
  export { createCliTool, matchCommand, stripGlobalFlags, formatPrefixes, omitDisplayForModel, type CliConfig, type AllowedCommand, type GlobalFlag, type CommandMatch, type CliPostProcessor, } from "./cli-tool.ts";
17
19
  export { CliConfigFileSchema, type CliConfigFile } from "./cli-config-schema.ts";
18
20
  export { toCliConfig, loadCliConfigFromObject, parseCliConfig, type CliConfigFromObjectResult, } from "./cli-config-loader.ts";
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A person's login through a CLI's two-step remote login, for a plugin acting
3
+ * as the person (`PersonLogin`): `auth login --user <id> --remote` prints the
4
+ * link the person opens and the `state` the provider sends back with the
5
+ * code; `auth login --user <id> --code <code> --state <state>` finishes it.
6
+ * The CLI keeps the pending login (verifier, state, expiry) itself, in the
7
+ * person's own folder, so nothing secret passes through here but the code.
8
+ */
9
+ import type { PersonLogin } from "@mercury-fw/plugin-types";
10
+ import type { runCli } from "./cli-executor.ts";
11
+ /**
12
+ * Builds `binary`'s `PersonLogin`. `redirectUri`: whether the CLI takes the
13
+ * callback as `--redirect-uri` (jira, confluence, zitadel), or the provider
14
+ * always sends the person back to the one callback registered on the app
15
+ * (bitbucket), in which case that has to be Mercury's.
16
+ */
17
+ export declare function createCliPersonLogin(runCliFn: typeof runCli, binary: string, { redirectUri }: {
18
+ redirectUri: boolean;
19
+ }): PersonLogin;
package/index.ts CHANGED
@@ -7,12 +7,14 @@
7
7
  *
8
8
  * The core imports what it needs from here; a CLI-based plugin (Jira,
9
9
  * Bitbucket) rides on this one. Nothing here imports the app — its only
10
- * dependencies are `@mercury-fw/plugin-types` (the shared contract) and the
10
+ * dependencies are `@mercury-fw/plugin-types` (the shared contract),
11
+ * `@mercury-fw/utils` (the id a CLI knows a person by) and the
11
12
  * external `ai`/`zod`/`shell-quote` packages — so the mechanism is a self
12
13
  * contained unit that an instance with no CLI plugin never pulls in.
13
14
  */
14
15
  export { parseCommand, type ParsedCommand } from "./command-parser.ts";
15
- export { runCli, type CliResult } from "./cli-executor.ts";
16
+ export { runCli, CLI_NOT_LOGGED_IN_EXIT_CODE, type CliResult } from "./cli-executor.ts";
17
+ export { createCliPersonLogin } from "./person-login.ts";
16
18
  export {
17
19
  createCliTool,
18
20
  matchCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mercury-fw/cli-engine",
3
- "version": "0.38.1",
3
+ "version": "0.40.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,7 +23,8 @@
23
23
  }
24
24
  },
25
25
  "dependencies": {
26
- "@mercury-fw/plugin-types": "0.38.1",
26
+ "@mercury-fw/plugin-types": "0.40.0",
27
+ "@mercury-fw/utils": "0.40.0",
27
28
  "ai": "^7.0.126",
28
29
  "shell-quote": "^1.11.0",
29
30
  "zod": "^4.6.5"
@@ -0,0 +1,40 @@
1
+ /**
2
+ * A person's login through a CLI's two-step remote login, for a plugin acting
3
+ * as the person (`PersonLogin`): `auth login --user <id> --remote` prints the
4
+ * link the person opens and the `state` the provider sends back with the
5
+ * code; `auth login --user <id> --code <code> --state <state>` finishes it.
6
+ * The CLI keeps the pending login (verifier, state, expiry) itself, in the
7
+ * person's own folder, so nothing secret passes through here but the code.
8
+ */
9
+ import type { PersonLogin } from "@mercury-fw/plugin-types";
10
+ import { cliUserId } from "@mercury-fw/utils";
11
+ import type { runCli } from "./cli-executor.ts";
12
+
13
+ /**
14
+ * Builds `binary`'s `PersonLogin`. `redirectUri`: whether the CLI takes the
15
+ * callback as `--redirect-uri` (jira, confluence, zitadel), or the provider
16
+ * always sends the person back to the one callback registered on the app
17
+ * (bitbucket), in which case that has to be Mercury's.
18
+ */
19
+ export function createCliPersonLogin(
20
+ runCliFn: typeof runCli,
21
+ binary: string,
22
+ { redirectUri }: { redirectUri: boolean },
23
+ ): PersonLogin {
24
+ return {
25
+ start: async (personKey, callbackUrl) => {
26
+ const args = ["auth", "login", "--user", cliUserId(personKey), "--remote"];
27
+ const result = await runCliFn(binary, redirectUri ? [...args, "--redirect-uri", callbackUrl] : args);
28
+ if (!result.ok) return { ok: false, error: result.error };
29
+ const { authorize_url: authorizeUrl, state } = (result.data ?? {}) as { authorize_url?: unknown; state?: unknown };
30
+ if (typeof authorizeUrl !== "string" || typeof state !== "string") {
31
+ return { ok: false, error: `${binary} auth login --remote didn't print an authorize_url and a state` };
32
+ }
33
+ return { ok: true, authorizeUrl, state };
34
+ },
35
+ complete: async (personKey, code, state) => {
36
+ const result = await runCliFn(binary, ["auth", "login", "--user", cliUserId(personKey), "--code", code, "--state", state]);
37
+ return result.ok ? { ok: true } : { ok: false, error: result.error };
38
+ },
39
+ };
40
+ }