@mercury-fw/cli-engine 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # @mercury-fw/cli-engine
2
+
3
+ ## 0.25.0
4
+
5
+ ### Patch Changes
6
+
7
+ - @mercury-fw/plugin-types@0.25.0
package/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # @mercury-fw/cli-engine
2
+
3
+ For [Mercury](https://github.com/lucabro81/mercury-fw) plugins that work through a command-line tool: it turns the command line the model writes into an argv array of its own (never a shell), runs it only when it matches the plugin's allowlist, and stages the commands the allowlist marks as needing confirmation behind a one-time token instead of running them.
4
+
5
+ The allowlist is a JSON file the plugin ships and validates when it loads:
6
+
7
+ ```json
8
+ {
9
+ "binary": "bitbucket",
10
+ "commands": [
11
+ { "prefix": ["pr", "list"], "confirm": false, "mutating": false },
12
+ { "prefix": ["pr", "merge"], "confirm": true, "mutating": true }
13
+ ],
14
+ "globalFlags": [{ "flag": "--select", "takesValue": true }]
15
+ }
16
+ ```
17
+
18
+ A command runs when its arguments start with one of the `prefix`es (`--help` always runs); `confirm: true` stages it until the user sends the token back. In `build()` the plugin validates the file with `parseCliConfig` and builds its tool with `createCliTool(runCli, configs, …)`; [`@mercury-fw/plugin-bitbucket`](https://github.com/lucabro81/mercury-fw/tree/main/packages/tools/plugin-bitbucket/index.ts) does exactly that.
19
+
20
+ MIT
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Validates a maintainer-authored CLI allowlist into the internal `CliConfig`
3
+ * that `cli-tool.ts` consumes. A plugin owns its allowlist as data and hands
4
+ * the already-parsed object over (via `loadCliConfigFromObject` or the
5
+ * synchronous `parseCliConfig`); the safety barrier is a strict Zod schema and,
6
+ * when `minVersion` is declared, a `--version` check. Every failure mode is
7
+ * fail-closed: a schema violation or an unmet `minVersion` returns
8
+ * `{ ok: false, reason }`, never a thrown exception or a partially-applied
9
+ * config. The maintainer who declares a plugin is already fully trusted with
10
+ * the machine, so the config is data to validate, not a policy to second-guess
11
+ * beyond schema/version checks.
12
+ */
13
+ import { CliConfigFileSchema, type CliConfigFile } from "./cli-config-schema.ts";
14
+ import { checkCliVersion } from "./cli-version-check.ts";
15
+ import type { runCli } from "./cli-executor.ts";
16
+ import type { CliConfig } from "./cli-tool.ts";
17
+
18
+ /** Maps the external file shape into the internal runtime `CliConfig`
19
+ * shape `src/tools/cli-tool.ts` consumes. */
20
+ export function toCliConfig(raw: CliConfigFile): CliConfig {
21
+ return {
22
+ allowedPrefixes: raw.commands.map((c) => ({ prefix: c.prefix, confirm: c.confirm, mutating: c.mutating })),
23
+ globalFlags: raw.globalFlags,
24
+ };
25
+ }
26
+
27
+ export type CliConfigFromObjectResult =
28
+ | { ok: true; binary: string; config: CliConfig }
29
+ | { ok: false; reason: string };
30
+
31
+ /**
32
+ * Validates a plugin's already-parsed allowlist object: the same `.strict()` Zod
33
+ * schema and, when `minVersion` is declared, a `--version` check, so a plugin's
34
+ * config reaches `runCommand`'s allowlist through the full validation. No
35
+ * requested-vs-declared binary match: the plugin declares its own binary and
36
+ * there's no separate name to reconcile it against, so the validated `binary` is
37
+ * returned for the caller to key its map by. Never throws.
38
+ */
39
+ export async function loadCliConfigFromObject(
40
+ raw: unknown,
41
+ opts: { runCliFn: typeof runCli },
42
+ ): Promise<CliConfigFromObjectResult> {
43
+ const validated = CliConfigFileSchema.safeParse(raw);
44
+ if (!validated.success) {
45
+ const issues = validated.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
46
+ return { ok: false, reason: `plugin config does not match the expected schema: ${issues}` };
47
+ }
48
+
49
+ if (validated.data.minVersion) {
50
+ const versionResult = await checkCliVersion(validated.data.binary, validated.data.minVersion, opts.runCliFn);
51
+ if (!versionResult.ok) {
52
+ return { ok: false, reason: versionResult.reason };
53
+ }
54
+ }
55
+
56
+ return { ok: true, binary: validated.data.binary, config: toCliConfig(validated.data) };
57
+ }
58
+
59
+ /**
60
+ * The synchronous, no-spawn sibling of `loadCliConfigFromObject`: schema
61
+ * validation only, skipping the `minVersion` `--version` check. It's what a
62
+ * plugin uses on its own allowlist in `build()` — a plugin ships its pinned CLI
63
+ * binary alongside its allowlist, so the two are co-versioned by construction
64
+ * and the runtime version check earns nothing there. Never throws.
65
+ */
66
+ export function parseCliConfig(raw: unknown): CliConfigFromObjectResult {
67
+ const validated = CliConfigFileSchema.safeParse(raw);
68
+ if (!validated.success) {
69
+ const issues = validated.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
70
+ return { ok: false, reason: `plugin config does not match the expected schema: ${issues}` };
71
+ }
72
+ return { ok: true, binary: validated.data.binary, config: toCliConfig(validated.data) };
73
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Zod schema for the externally-configured, maintainer-authored CLI
3
+ * config file (e.g. `cli-configs/jira.json`), consumed by
4
+ * `src/tools/cli-config-loader.ts`. Kept in its own module, separate
5
+ * from the loader, so the schema itself is unit-testable without any
6
+ * file I/O.
7
+ *
8
+ * `.strict()` on every object level: a maintainer typo (e.g.
9
+ * `"prefixes"` instead of `"prefix"`) must fail validation loudly, not
10
+ * silently pass through as an ignored extra key — the whole point of
11
+ * this schema is to be the one thing standing between a maintainer's
12
+ * config and what the model can execute, so silent tolerance of
13
+ * malformed input is exactly what it must not do.
14
+ */
15
+ import { z } from "zod";
16
+
17
+ export const CliCommandSchema = z
18
+ .object({
19
+ prefix: z.array(z.string().min(1)).min(1),
20
+ confirm: z.boolean(),
21
+ /** Whether this command changes state on the external service (Jira,
22
+ * etc.) rather than just reading it — distinct from `confirm`: a
23
+ * command can mutate without requiring confirmation (e.g. create). */
24
+ mutating: z.boolean(),
25
+ })
26
+ .strict();
27
+
28
+ export const CliGlobalFlagSchema = z
29
+ .object({
30
+ flag: z.string().min(1),
31
+ takesValue: z.boolean(),
32
+ })
33
+ .strict();
34
+
35
+ export const CliConfigFileSchema = z
36
+ .object({
37
+ binary: z.string().min(1),
38
+ minVersion: z.string().min(1).optional(),
39
+ commands: z.array(CliCommandSchema).min(1),
40
+ globalFlags: z.array(CliGlobalFlagSchema).optional(),
41
+ })
42
+ .strict();
43
+
44
+ export type CliConfigFile = z.infer<typeof CliConfigFileSchema>;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Generic subprocess runner for the external CLI binaries Mercury talks
3
+ * to (jira, google-chat, ...). Every external integration is a separate
4
+ * CLI binary invoked as a subprocess — never an arbitrary shell string,
5
+ * never MCP. This file is the one and only place that spawns a process,
6
+ * in the one shape Mercury needs: a one-shot command that exits and
7
+ * produces a single parsed output (`runCli`).
8
+ *
9
+ * Nothing imports `runCli` to call it directly except the composition root:
10
+ * every consumer declares it as a `typeof runCli` dependency and receives
11
+ * it injected, so a test can supply a fake without spawning a subprocess.
12
+ * `cli-tool.ts` (`runCommand`'s `execute`) and
13
+ * `@mercury-fw/confirm-engine`'s `confirm-flow.ts` (the second half of the confirm
14
+ * flow) are the two that actually run model-requested commands; the rest use it for
15
+ * version checks and diagnostics. The registered
16
+ * Google Chat provider (`@mercury-fw/channel-google-chat`) does not use this module at all — it talks to
17
+ * the Chat REST API and Pub/Sub directly over HTTPS, never through a CLI
18
+ * subprocess (see `google-chat-app-client.ts`).
19
+ */
20
+
21
+ // `CliResult` is part of the plugin contract (a post-processor receives and
22
+ // returns one), so it lives in `@mercury-fw/plugin-types` and is re-exported here
23
+ // for the many core callers that import it from this module. Never throws —
24
+ // callers branch on `ok` instead of catching.
25
+ import type { CliResult } from "@mercury-fw/plugin-types";
26
+ export type { CliResult };
27
+
28
+ /**
29
+ * Thin wrapper around `Bun.spawn` with both stdout and stderr piped.
30
+ * Extracted into its own function (rather than calling `Bun.spawn`
31
+ * inline in `runCli`) so its return type carries the literal `"pipe"`
32
+ * option through to the caller — assigning the spawn call to a
33
+ * pre-declared `ReturnType<typeof Bun.spawn>` variable would otherwise
34
+ * widen `stdout`/`stderr` to a generic union TypeScript can't narrow.
35
+ */
36
+ function spawnPiped(binary: string, args: string[]) {
37
+ return Bun.spawn([binary, ...args], { stdout: "pipe", stderr: "pipe" });
38
+ }
39
+
40
+ /**
41
+ * Runs `binary` with `args`, waits for it to exit, and parses its
42
+ * stdout as JSON when possible.
43
+ *
44
+ * Resolves to `{ ok: false, error }` — never rejects/throws — for the
45
+ * 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:
47
+ * if stdout happens to be valid JSON it's parsed into `data`, otherwise
48
+ * the raw trimmed text is `data` instead. Non-JSON stdout on a 0 exit is
49
+ * not a parse failure — `--help` output is exactly this shape (plain
50
+ * text, exit 0), and treating it as `ok: false` meant the model saw a
51
+ * "failed" tool call for what was actually a successful discovery call —
52
+ * observed live to send it into a confused, apologetic retry spiral.
53
+ */
54
+ export async function runCli(
55
+ binary: string,
56
+ args: string[],
57
+ ): Promise<CliResult> {
58
+ let proc: ReturnType<typeof spawnPiped>;
59
+ try {
60
+ proc = spawnPiped(binary, args);
61
+ } catch (err) {
62
+ return { ok: false, error: `failed to spawn ${binary}: ${String(err)}` };
63
+ }
64
+
65
+ const [stdout, stderr, exitCode] = await Promise.all([
66
+ new Response(proc.stdout).text(),
67
+ new Response(proc.stderr).text(),
68
+ proc.exited,
69
+ ]);
70
+
71
+ if (exitCode !== 0) {
72
+ return {
73
+ ok: false,
74
+ error: `${binary} exited with code ${exitCode}: ${stderr.trim()}`,
75
+ };
76
+ }
77
+
78
+ try {
79
+ return { ok: true, data: JSON.parse(stdout) };
80
+ } catch {
81
+ return { ok: true, data: stdout.trim() };
82
+ }
83
+ }
package/cli-status.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Builds the `runCommand` status describer the composition root injects into
3
+ * the core's tool-start hook. It parses the command, computes whether it
4
+ * mutates from the same allowlist that gates execution (via `matchCommand`, so
5
+ * the label can never drift from what actually runs), and hands that to the
6
+ * command's plugin describer — or `defaultStatusLabel` when the plugin doesn't
7
+ * override it (and for file-based CLIs, which aren't plugins). The core no
8
+ * longer classifies read vs write itself; it only transports whatever the
9
+ * describer returns. This is CLI-specific, so it lives with the CLI mechanism,
10
+ * not in the core session layer.
11
+ */
12
+ import { defaultStatusLabel, type StatusDescriber } from "@mercury-fw/plugin-types";
13
+ import { parseCommand } from "./command-parser.ts";
14
+ import { matchCommand, type CliConfig } from "./cli-tool.ts";
15
+
16
+ export function createCliStatusDescriber(
17
+ configs: Record<string, CliConfig>,
18
+ describers: Record<string, StatusDescriber>,
19
+ ): (command: string) => string {
20
+ return (command) => {
21
+ const parsed = parseCommand(command);
22
+ if (!parsed.ok) return "esecuzione di un comando";
23
+ const config = configs[parsed.binary];
24
+ const match = config ? matchCommand(parsed.args, config) : undefined;
25
+ const mutating = match !== undefined && match.kind !== "not-allowed" ? match.mutating : false;
26
+ const describe = describers[parsed.binary] ?? defaultStatusLabel;
27
+ return describe({ binary: parsed.binary, args: parsed.args, mutating });
28
+ };
29
+ }
package/cli-tool.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * The generic, cross-CLI model-invocable tool for the experimental
3
+ * command-string execution model: the model writes an entire CLI
4
+ * invocation as one free-text string (see `src/tools/command-parser.ts`
5
+ * for how that string becomes a binary + argv), and this module validates
6
+ * it — first that the binary is one this Mercury instance actually has a
7
+ * config for, then that the argv shape matches that binary's own
8
+ * allowed-prefix allowlist — before ever executing anything.
9
+ *
10
+ * `CliConfig` is built from a maintainer-authored external config file
11
+ * (see `src/tools/cli-config-loader.ts`), not hand-written TypeScript —
12
+ * `allowedPrefixes` is the default-deny prefix-matching data (each entry
13
+ * also declares whether it needs a confirmation step that doesn't exist
14
+ * yet, see `matchCommand` below), and `globalFlags` is declarative data
15
+ * for CLI-specific pre-processing (e.g. jira's global `--select` flag,
16
+ * which can appear before the subcommand) consumed generically by
17
+ * `stripGlobalFlags` — no more hand-written per-CLI stripping function.
18
+ *
19
+ * Used by: `src/index.ts` (wiring), which builds the `Record<string,
20
+ * CliConfig>` (via `cli-config-loader.ts`) from whichever CLIs are
21
+ * enabled on a given instance and passes it into `createCliTool`
22
+ * alongside the real `runCli`.
23
+ */
24
+ import { tool, type JSONValue } from "ai";
25
+ import { z } from "zod";
26
+ import { parseCommand } from "./command-parser.ts";
27
+ import type { runCli, CliResult } from "./cli-executor.ts";
28
+ import type { StageConfirmation, ExecutableTool } from "@mercury-fw/plugin-types";
29
+
30
+ // `CliPostProcessor` is part of the plugin contract (a plugin's `build()`
31
+ // returns one) — it lives in `@mercury-fw/plugin-types` and is re-exported here
32
+ // for the core callers that import it from this module. It runs after every
33
+ // allowed command and is told which allowlist prefix matched, so the plugin
34
+ // decides what to touch; cli-tool.ts never knows what it does.
35
+ import type { CliPostProcessor } from "@mercury-fw/plugin-types";
36
+ export type { CliPostProcessor };
37
+
38
+ export type AllowedCommand = {
39
+ prefix: string[];
40
+ confirm: boolean;
41
+ mutating: boolean;
42
+ };
43
+ export type GlobalFlag = { flag: string; takesValue: boolean };
44
+
45
+ export type CliConfig = {
46
+ allowedPrefixes: AllowedCommand[];
47
+ /** Optional declarative global flags (can appear anywhere in argv, not
48
+ * just after the prefix) to strip before prefix-matching. */
49
+ globalFlags?: GlobalFlag[];
50
+ };
51
+
52
+ /**
53
+ * Removes every occurrence of any flag listed in `globalFlags` from
54
+ * `args` (and its value too, if `takesValue`), wherever it appears —
55
+ * generic replacement for what used to be a hand-written per-CLI
56
+ * function (jira's old `stripSelectFlag`). Used only to build a
57
+ * throwaway copy for prefix-matching in `matchCommand`; the original
58
+ * `args` (flags included) is always what actually gets executed.
59
+ */
60
+ export function stripGlobalFlags(args: string[], globalFlags: GlobalFlag[]): string[] {
61
+ const result: string[] = [];
62
+ for (let i = 0; i < args.length; i++) {
63
+ const match = globalFlags.find((gf) => gf.flag === args[i]);
64
+ if (match) {
65
+ if (match.takesValue) i++; // also skip its value
66
+ continue;
67
+ }
68
+ result.push(args[i] as string);
69
+ }
70
+ return result;
71
+ }
72
+
73
+ export type CommandMatch =
74
+ | { kind: "allowed"; prefix: string[]; mutating: boolean }
75
+ | { kind: "confirm-required"; prefix: string[]; mutating: boolean }
76
+ | { kind: "not-allowed" };
77
+
78
+ /**
79
+ * Classifies `args` under `config`: `--help` is always `allowed`
80
+ * (discovery, not execution); otherwise `args` (after `config.globalFlags`
81
+ * stripping, if any) is matched positionally against `config.allowedPrefixes`
82
+ * — no match is `not-allowed`, a match with `confirm: false` is `allowed`,
83
+ * a match with `confirm: true` is `confirm-required` (the shape is
84
+ * recognized, but there's no confirmation mechanism to gate it on yet).
85
+ * `mutating` is carried through independently of `confirm` — a command can
86
+ * change external state (Jira, etc.) without requiring confirmation (e.g.
87
+ * create), so the two flags are never derived from one another. An allowed
88
+ * match reports the prefix it matched (`[]` for `--help`), which is what a
89
+ * plugin's post-processor decides on.
90
+ */
91
+ export function matchCommand(args: string[], config: CliConfig): CommandMatch {
92
+ if (args[args.length - 1] === "--help") {
93
+ return { kind: "allowed", prefix: [], mutating: false };
94
+ }
95
+ const stripped = config.globalFlags ? stripGlobalFlags(args, config.globalFlags) : args;
96
+ const match = config.allowedPrefixes.find((c) => c.prefix.every((part, i) => stripped[i] === part));
97
+ if (!match) {
98
+ return { kind: "not-allowed" };
99
+ }
100
+ return match.confirm
101
+ ? { kind: "confirm-required", prefix: match.prefix, mutating: match.mutating }
102
+ : { kind: "allowed", prefix: match.prefix, mutating: match.mutating };
103
+ }
104
+
105
+ /** Renders a list of prefixes as a comma-separated string for a
106
+ * model-readable rejection message, e.g. `"issue search, issue get"`. */
107
+ export function formatPrefixes(prefixes: string[][]): string {
108
+ return prefixes.map((p) => p.join(" ")).join(", ");
109
+ }
110
+
111
+ /**
112
+ * Removes the top-level `display` channel (see `ToolDisplay`) from a tool
113
+ * result before it reaches the model. `display` is the user-facing channel —
114
+ * a deterministic artifact stashed in the display store and shown only when the
115
+ * model calls `present` (see `display-store.ts`/`present-tool.ts`), never
116
+ * something the model needs to read or reproduce. The model channel is `data`
117
+ * plus the `displayRef` pointer (kept, so the model can `present` it), so this
118
+ * is a structural drop of one known top-level key — not a heuristic removal of
119
+ * a field nested inside `data`. Model-facing notes a plugin adds inside `data`
120
+ * (e.g. `formattedListNote`, telling the model why formatting couldn't happen
121
+ * and how to retry) are on the model channel and pass through.
122
+ */
123
+ export function omitDisplayForModel(output: unknown): unknown {
124
+ if (typeof output !== "object" || output === null || !("display" in output)) return output;
125
+ const { display: _display, ...rest } = output as Record<string, unknown>;
126
+ return rest;
127
+ }
128
+
129
+ /**
130
+ * Builds the `runCommand` tool: the model writes a whole CLI invocation as
131
+ * one string, `execute` parses it (`parseCommand`), checks the binary
132
+ * against `configs`, then classifies the argv via `matchCommand` — each
133
+ * failure mode (unparseable / unknown binary / confirm-required /
134
+ * not-allowed) gets a distinct, self-correctable error message — before
135
+ * ever calling `runCliFn`. `runCliFn` is injected (defaulting to the real
136
+ * `runCli` in production) so tests can supply a fake without spawning a
137
+ * real subprocess.
138
+ *
139
+ * `opts.stageConfirmation` scopes the confirm-required branch: a confirm-gated
140
+ * command is staged (see `confirmation-staging.ts`) instead of running, and the
141
+ * result carries a structured `token` — how the user is actually told to
142
+ * confirm is channel-specific (a card button on Google Chat, a bare token typed
143
+ * on the terminal), not dictated here or by the model (see `confirm-flow.ts` for
144
+ * the other half — actually running it once that token comes back). Staging is
145
+ * inherently per-session (the closure is bound to one sessionKey), so callers
146
+ * must build a fresh tool per turn, scoped to that turn's own session — not a
147
+ * tool meant to be built once and reused across sessions.
148
+ */
149
+ export function createCliTool(
150
+ runCliFn: typeof runCli,
151
+ configs: Record<string, CliConfig>,
152
+ opts: {
153
+ /** Stages a confirm-required command's execution behind a token, and writes
154
+ * its paper-trail note (see `StageConfirmation`). Pre-bound to this turn's
155
+ * session/user by the composition root — `cli-tool.ts` never touches the
156
+ * confirmation store or the wiki itself. */
157
+ stageConfirmation: StageConfirmation;
158
+ /** The plugin's post-processor (see `CliPostProcessor`), run on every
159
+ * allowed command's result with the matched prefix — `cli-tool.ts` itself
160
+ * never knows what it does. */
161
+ postProcess?: CliPostProcessor;
162
+ /** Stashes a post-processor's rendered `display` artifact and returns a ref
163
+ * the model can `present`. Pre-bound to this turn's session by the
164
+ * composition root. Optional: with none wired the inline `display` is left
165
+ * untouched and no `displayRef` is minted. */
166
+ stashDisplay?: (artifact: string) => string;
167
+ },
168
+ ): { runCommand: ExecutableTool } {
169
+ // Anchor the example on a binary actually enabled on this instance rather than
170
+ // a hardcoded one: a fixed `jira …` example misleads the model on an instance
171
+ // without Jira. The concrete, CLI-specific example (real subcommand + flags)
172
+ // belongs to that CLI's own skill, not to this plugin-agnostic tool.
173
+ const exampleBinary = Object.keys(configs)[0] ?? "<binary>";
174
+ const runCommand = tool({
175
+ description:
176
+ "Run a CLI command. Write the whole invocation as one string, exactly as you would type it in a terminal, " +
177
+ `e.g. \`${exampleBinary} <subcommand> --flag value\`. Quote values that contain spaces.`,
178
+ inputSchema: z.object({ command: z.string().min(1) }),
179
+ execute: async ({ command }) => {
180
+ const parsed = parseCommand(command);
181
+ if (!parsed.ok) {
182
+ return { ok: false, error: `could not parse "${command}": ${parsed.error}` };
183
+ }
184
+
185
+ const config = configs[parsed.binary];
186
+ if (!config) {
187
+ const known = Object.keys(configs).join(", ") || "(none configured)";
188
+ return {
189
+ ok: false,
190
+ error: `unknown or disabled CLI "${parsed.binary}" on this Mercury instance. Available: ${known}.`,
191
+ };
192
+ }
193
+
194
+ const match = matchCommand(parsed.args, config);
195
+ if (match.kind === "not-allowed") {
196
+ const validPrefixes = formatPrefixes(
197
+ config.allowedPrefixes.filter((c) => !c.confirm).map((c) => c.prefix),
198
+ );
199
+ return {
200
+ ok: false,
201
+ error: `not permitted on this Mercury instance. Valid commands: ${validPrefixes}. If "${parsed.args.join(" ")}" doesn't match one of these, it's not a recognized command shape — try again with the right prefix, or run --help to check.`,
202
+ };
203
+ }
204
+ if (match.kind === "confirm-required") {
205
+ // The underlying CLI has its own, separate --confirm safety flag
206
+ // (jira-cli/google-chat-cli both refuse a delete without it) —
207
+ // independent of Mercury's own token. Once a human confirms
208
+ // through Mercury, that flag must actually be there when the
209
+ // staged args run, regardless of whether the model remembered to
210
+ // include it on the first attempt (observed live: it usually
211
+ // doesn't).
212
+ const argsToStage = parsed.args.includes("--confirm") ? parsed.args : [...parsed.args, "--confirm"];
213
+ // Stage the doing as an opaque thunk — the core confirmation subsystem
214
+ // never learns this is a CLI command. `describe` (the normalized argv)
215
+ // is the paper-trail text; `summary` on the result (the raw command the
216
+ // model wrote) is what a channel shows in its confirmation UI.
217
+ const token = await opts.stageConfirmation({
218
+ run: () => runCliFn(parsed.binary, argsToStage),
219
+ describe: [parsed.binary, ...argsToStage].join(" "),
220
+ });
221
+ return {
222
+ ok: false,
223
+ pendingConfirmation: true,
224
+ token,
225
+ summary: command,
226
+ error: `"${match.prefix.join(" ")}" is irreversible and requires explicit confirmation before it can run. Tell the user this action is staged and awaiting their confirmation. Never mention the token value in your reply, in any form — do not tell them how to confirm it, the channel handles that on its own.`,
227
+ };
228
+ }
229
+
230
+ const result = await runCliFn(parsed.binary, parsed.args);
231
+ const processed = opts.postProcess
232
+ ? opts.postProcess({ binary: parsed.binary, args: parsed.args, prefix: match.prefix }, result)
233
+ : result;
234
+
235
+ // A rendered display artifact is stashed, not returned to be
236
+ // force-appended: the model gets only a `displayRef` and decides whether
237
+ // to `present` it. Only string items (what the formatter renders) are
238
+ // showable; a display still carrying structured items (no formatter
239
+ // applied) has nothing to stash. With no `stashDisplay` wired, the result
240
+ // is left exactly as-is.
241
+ if (opts.stashDisplay && processed.ok && processed.display) {
242
+ const stringItems = processed.display.items.filter((i): i is string => typeof i === "string");
243
+ if (stringItems.length > 0) {
244
+ const displayRef = opts.stashDisplay(stringItems.join("\n\n"));
245
+ return { ...processed, displayRef };
246
+ }
247
+ }
248
+ return processed;
249
+ },
250
+ toModelOutput: ({ output }) => ({ type: "json", value: omitDisplayForModel(output) as JSONValue }),
251
+ });
252
+
253
+ return { runCommand };
254
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Checks an installed CLI binary's version against a maintainer-declared
3
+ * `minVersion` (from an externally-configured CLI config file, see
4
+ * `src/tools/cli-config-loader.ts`). No version-check convention existed
5
+ * anywhere in this codebase before this — confirmed by searching
6
+ * `scripts/install-clis.sh` (only resolves GitHub release *tags* to pick
7
+ * a download asset, never validates an already-installed binary) and the
8
+ * rest of `src/`.
9
+ *
10
+ * Assumes `--version` is the right flag to invoke — the CLIs are
11
+ * Rust/clap-based, and `--version` is clap's near-universal default, but
12
+ * this isn't verified against the real CLI-monorepo binaries from here.
13
+ * If wrong, the failure mode is still safe: `checkCliVersion` fails
14
+ * closed (that CLI just never activates), not silently permissive.
15
+ *
16
+ * Version comparison is hand-rolled rather than a new dependency —
17
+ * comparing well-formed `X.Y.Z` triples numerically isn't the kind of
18
+ * genuinely tricky parsing problem that justified pulling in
19
+ * `shell-quote` for command tokenizing.
20
+ */
21
+ import { runCli } from "./cli-executor.ts";
22
+
23
+ export type ParsedVersion = { major: number; minor: number; patch: number };
24
+
25
+ const VERSION_PATTERN = /(\d+)\.(\d+)\.(\d+)/;
26
+
27
+ /** Extracts the first `major.minor.patch` triple found in `text`, or
28
+ * `null` if none is present. */
29
+ export function parseVersion(text: string): ParsedVersion | null {
30
+ const match = VERSION_PATTERN.exec(text);
31
+ if (!match) {
32
+ return null;
33
+ }
34
+ return {
35
+ major: Number(match[1]),
36
+ minor: Number(match[2]),
37
+ patch: Number(match[3]),
38
+ };
39
+ }
40
+
41
+ /** Numeric major/minor/patch comparison: -1 if `a` < `b`, 0 if equal, 1
42
+ * if `a` > `b`. */
43
+ export function compareVersions(a: ParsedVersion, b: ParsedVersion): -1 | 0 | 1 {
44
+ if (a.major !== b.major) return a.major > b.major ? 1 : -1;
45
+ if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
46
+ if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
47
+ return 0;
48
+ }
49
+
50
+ export type VersionCheckResult = { ok: true } | { ok: false; reason: string };
51
+
52
+ /**
53
+ * Runs `<binary> --version` (via the injected `runCliFn`) and checks the
54
+ * result against `minVersion`. Never throws — every failure mode
55
+ * (malformed `minVersion`, the CLI invocation failing, unparseable
56
+ * output, a version below the minimum) resolves to `{ ok: false, reason
57
+ * }` instead.
58
+ */
59
+ export async function checkCliVersion(
60
+ binary: string,
61
+ minVersion: string,
62
+ runCliFn: typeof runCli,
63
+ ): Promise<VersionCheckResult> {
64
+ const required = parseVersion(minVersion);
65
+ if (!required) {
66
+ return { ok: false, reason: `configured minVersion "${minVersion}" is not a valid version` };
67
+ }
68
+
69
+ const result = await runCliFn(binary, ["--version"]);
70
+ if (!result.ok) {
71
+ return { ok: false, reason: `could not determine ${binary}'s version: ${result.error}` };
72
+ }
73
+
74
+ const output = typeof result.data === "string" ? result.data : String(result.data);
75
+ const installed = parseVersion(output);
76
+ if (!installed) {
77
+ return { ok: false, reason: `could not parse a version number out of "${output}"` };
78
+ }
79
+
80
+ if (compareVersions(installed, required) < 0) {
81
+ return {
82
+ ok: false,
83
+ reason: `${binary} version ${output.trim()} is below the required minVersion ${minVersion}`,
84
+ };
85
+ }
86
+
87
+ return { ok: true };
88
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Parses a single free-text command string (as the model would type it in
3
+ * a terminal, e.g. `jira issue search --jql "project = KAN"`) into a
4
+ * binary name and an argv array, for the experimental command-string
5
+ * execution model (see `src/tools/cli-tool.ts`, which consumes this).
6
+ *
7
+ * Quoting/escaping itself is delegated to `shell-quote`'s `parse()` — a
8
+ * widely used, actively maintained tokenizer — rather than hand-rolled,
9
+ * since getting POSIX-ish quote handling right from scratch is exactly the
10
+ * kind of finicky, security-relevant code better left to a library with
11
+ * far more scrutiny than this file would get on its own.
12
+ *
13
+ * `shell-quote` on its own isn't a safe-enough boundary by itself, though —
14
+ * confirmed empirically against the installed version:
15
+ * - It happily recognizes real shell syntax (`;`, `|`, `&&`, globs,
16
+ * comments) and returns non-string entries for them; a naive "keep only
17
+ * the strings" filter would silently drop the metacharacter and merge
18
+ * the rest of the command as if it had been benign.
19
+ * - With no `env` argument it still interpolates `$VAR`/`${VAR}` against
20
+ * an empty environment, silently resolving every variable to `""`
21
+ * (`parse("echo $HOME")` -> `["echo", ""]`) instead of leaving the text
22
+ * alone — quiet data loss inside what looked like a literal value.
23
+ * - It does not error on an unterminated quote; it silently treats the
24
+ * rest of the string as if the quote had closed at end-of-input, which
25
+ * for a missing closing quote around a multi-word value means it
26
+ * silently reverts to word-splitting instead of failing loudly.
27
+ * - A trailing lone backslash is silently dropped rather than flagged.
28
+ *
29
+ * This module closes those gaps itself: reject unbalanced
30
+ * quotes/escapes and any literal `$` before ever calling `parse()`, then
31
+ * reject any non-string entry `parse()` returns.
32
+ */
33
+ import { parse } from "shell-quote";
34
+
35
+ export type ParsedCommand =
36
+ | { ok: true; binary: string; args: string[] }
37
+ | { ok: false; error: string };
38
+
39
+ /**
40
+ * Walks `command` tracking quote state to find an unterminated quote or a
41
+ * dangling trailing escape — the failure modes `shell-quote`'s `parse()`
42
+ * doesn't itself surface as errors (see this file's header comment).
43
+ * Mirrors the quoting rules `parse()` actually implements (confirmed
44
+ * empirically): backslash escapes the next character outside quotes and
45
+ * inside double quotes, but is a literal character inside single quotes.
46
+ */
47
+ function findUnbalancedQuoteOrEscape(command: string): string | null {
48
+ let state: "bare" | "single" | "double" = "bare";
49
+ for (let i = 0; i < command.length; i++) {
50
+ const ch = command[i];
51
+ if (state === "single") {
52
+ if (ch === "'") state = "bare";
53
+ continue;
54
+ }
55
+ if (ch === "\\") {
56
+ if (i === command.length - 1) {
57
+ return "command ends with a dangling escape character (\\)";
58
+ }
59
+ i++; // the escaped character is consumed as literal, whatever it is
60
+ continue;
61
+ }
62
+ if (state === "double") {
63
+ if (ch === '"') state = "bare";
64
+ continue;
65
+ }
66
+ // state === "bare"
67
+ if (ch === "'") state = "single";
68
+ else if (ch === '"') state = "double";
69
+ }
70
+ if (state === "single") return "command has an unterminated single quote (')";
71
+ if (state === "double") return 'command has an unterminated double quote (")';
72
+ return null;
73
+ }
74
+
75
+ /**
76
+ * Parses `command` into `{ binary, args }`. Never throws — every failure
77
+ * mode (unbalanced quoting, a `$`, a shell operator/glob/comment, or an
78
+ * empty result) resolves to `{ ok: false, error }` instead.
79
+ */
80
+ export function parseCommand(command: string): ParsedCommand {
81
+ const quoteError = findUnbalancedQuoteOrEscape(command);
82
+ if (quoteError) {
83
+ return { ok: false, error: quoteError };
84
+ }
85
+
86
+ if (command.includes("$")) {
87
+ return {
88
+ ok: false,
89
+ error:
90
+ "variable interpolation ('$') is not supported in commands — shell-quote would silently resolve it to an empty string",
91
+ };
92
+ }
93
+
94
+ let entries: ReturnType<typeof parse>;
95
+ try {
96
+ entries = parse(command);
97
+ } catch (err) {
98
+ return { ok: false, error: `could not parse command: ${String(err)}` };
99
+ }
100
+
101
+ const tokens: string[] = [];
102
+ for (const entry of entries) {
103
+ if (typeof entry !== "string") {
104
+ return {
105
+ ok: false,
106
+ error: `shell operators, globs, and comments are not supported in commands (found ${JSON.stringify(entry)})`,
107
+ };
108
+ }
109
+ tokens.push(entry);
110
+ }
111
+
112
+ const [binary, ...args] = tokens;
113
+ if (!binary) {
114
+ return { ok: false, error: "empty command" };
115
+ }
116
+ return { ok: true, binary, args };
117
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Validates a maintainer-authored CLI allowlist into the internal `CliConfig`
3
+ * that `cli-tool.ts` consumes. A plugin owns its allowlist as data and hands
4
+ * the already-parsed object over (via `loadCliConfigFromObject` or the
5
+ * synchronous `parseCliConfig`); the safety barrier is a strict Zod schema and,
6
+ * when `minVersion` is declared, a `--version` check. Every failure mode is
7
+ * fail-closed: a schema violation or an unmet `minVersion` returns
8
+ * `{ ok: false, reason }`, never a thrown exception or a partially-applied
9
+ * config. The maintainer who declares a plugin is already fully trusted with
10
+ * the machine, so the config is data to validate, not a policy to second-guess
11
+ * beyond schema/version checks.
12
+ */
13
+ import { type CliConfigFile } from "./cli-config-schema.ts";
14
+ import type { runCli } from "./cli-executor.ts";
15
+ import type { CliConfig } from "./cli-tool.ts";
16
+ /** Maps the external file shape into the internal runtime `CliConfig`
17
+ * shape `src/tools/cli-tool.ts` consumes. */
18
+ export declare function toCliConfig(raw: CliConfigFile): CliConfig;
19
+ export type CliConfigFromObjectResult = {
20
+ ok: true;
21
+ binary: string;
22
+ config: CliConfig;
23
+ } | {
24
+ ok: false;
25
+ reason: string;
26
+ };
27
+ /**
28
+ * Validates a plugin's already-parsed allowlist object: the same `.strict()` Zod
29
+ * schema and, when `minVersion` is declared, a `--version` check, so a plugin's
30
+ * config reaches `runCommand`'s allowlist through the full validation. No
31
+ * requested-vs-declared binary match: the plugin declares its own binary and
32
+ * there's no separate name to reconcile it against, so the validated `binary` is
33
+ * returned for the caller to key its map by. Never throws.
34
+ */
35
+ export declare function loadCliConfigFromObject(raw: unknown, opts: {
36
+ runCliFn: typeof runCli;
37
+ }): Promise<CliConfigFromObjectResult>;
38
+ /**
39
+ * The synchronous, no-spawn sibling of `loadCliConfigFromObject`: schema
40
+ * validation only, skipping the `minVersion` `--version` check. It's what a
41
+ * plugin uses on its own allowlist in `build()` — a plugin ships its pinned CLI
42
+ * binary alongside its allowlist, so the two are co-versioned by construction
43
+ * and the runtime version check earns nothing there. Never throws.
44
+ */
45
+ export declare function parseCliConfig(raw: unknown): CliConfigFromObjectResult;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Zod schema for the externally-configured, maintainer-authored CLI
3
+ * config file (e.g. `cli-configs/jira.json`), consumed by
4
+ * `src/tools/cli-config-loader.ts`. Kept in its own module, separate
5
+ * from the loader, so the schema itself is unit-testable without any
6
+ * file I/O.
7
+ *
8
+ * `.strict()` on every object level: a maintainer typo (e.g.
9
+ * `"prefixes"` instead of `"prefix"`) must fail validation loudly, not
10
+ * silently pass through as an ignored extra key — the whole point of
11
+ * this schema is to be the one thing standing between a maintainer's
12
+ * config and what the model can execute, so silent tolerance of
13
+ * malformed input is exactly what it must not do.
14
+ */
15
+ import { z } from "zod";
16
+ export declare const CliCommandSchema: z.ZodObject<{
17
+ prefix: z.ZodArray<z.ZodString>;
18
+ confirm: z.ZodBoolean;
19
+ mutating: z.ZodBoolean;
20
+ }, z.core.$strict>;
21
+ export declare const CliGlobalFlagSchema: z.ZodObject<{
22
+ flag: z.ZodString;
23
+ takesValue: z.ZodBoolean;
24
+ }, z.core.$strict>;
25
+ export declare const CliConfigFileSchema: z.ZodObject<{
26
+ binary: z.ZodString;
27
+ minVersion: z.ZodOptional<z.ZodString>;
28
+ commands: z.ZodArray<z.ZodObject<{
29
+ prefix: z.ZodArray<z.ZodString>;
30
+ confirm: z.ZodBoolean;
31
+ mutating: z.ZodBoolean;
32
+ }, z.core.$strict>>;
33
+ globalFlags: z.ZodOptional<z.ZodArray<z.ZodObject<{
34
+ flag: z.ZodString;
35
+ takesValue: z.ZodBoolean;
36
+ }, z.core.$strict>>>;
37
+ }, z.core.$strict>;
38
+ export type CliConfigFile = z.infer<typeof CliConfigFileSchema>;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Generic subprocess runner for the external CLI binaries Mercury talks
3
+ * to (jira, google-chat, ...). Every external integration is a separate
4
+ * CLI binary invoked as a subprocess — never an arbitrary shell string,
5
+ * never MCP. This file is the one and only place that spawns a process,
6
+ * in the one shape Mercury needs: a one-shot command that exits and
7
+ * produces a single parsed output (`runCli`).
8
+ *
9
+ * Nothing imports `runCli` to call it directly except the composition root:
10
+ * every consumer declares it as a `typeof runCli` dependency and receives
11
+ * it injected, so a test can supply a fake without spawning a subprocess.
12
+ * `cli-tool.ts` (`runCommand`'s `execute`) and
13
+ * `@mercury-fw/confirm-engine`'s `confirm-flow.ts` (the second half of the confirm
14
+ * flow) are the two that actually run model-requested commands; the rest use it for
15
+ * version checks and diagnostics. The registered
16
+ * Google Chat provider (`@mercury-fw/channel-google-chat`) does not use this module at all — it talks to
17
+ * the Chat REST API and Pub/Sub directly over HTTPS, never through a CLI
18
+ * subprocess (see `google-chat-app-client.ts`).
19
+ */
20
+ import type { CliResult } from "@mercury-fw/plugin-types";
21
+ export type { CliResult };
22
+ /**
23
+ * Runs `binary` with `args`, waits for it to exit, and parses its
24
+ * stdout as JSON when possible.
25
+ *
26
+ * Resolves to `{ ok: false, error }` — never rejects/throws — for the
27
+ * 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:
29
+ * if stdout happens to be valid JSON it's parsed into `data`, otherwise
30
+ * the raw trimmed text is `data` instead. Non-JSON stdout on a 0 exit is
31
+ * not a parse failure — `--help` output is exactly this shape (plain
32
+ * text, exit 0), and treating it as `ok: false` meant the model saw a
33
+ * "failed" tool call for what was actually a successful discovery call —
34
+ * observed live to send it into a confused, apologetic retry spiral.
35
+ */
36
+ export declare function runCli(binary: string, args: string[]): Promise<CliResult>;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Builds the `runCommand` status describer the composition root injects into
3
+ * the core's tool-start hook. It parses the command, computes whether it
4
+ * mutates from the same allowlist that gates execution (via `matchCommand`, so
5
+ * the label can never drift from what actually runs), and hands that to the
6
+ * command's plugin describer — or `defaultStatusLabel` when the plugin doesn't
7
+ * override it (and for file-based CLIs, which aren't plugins). The core no
8
+ * longer classifies read vs write itself; it only transports whatever the
9
+ * describer returns. This is CLI-specific, so it lives with the CLI mechanism,
10
+ * not in the core session layer.
11
+ */
12
+ import { type StatusDescriber } from "@mercury-fw/plugin-types";
13
+ import { type CliConfig } from "./cli-tool.ts";
14
+ export declare function createCliStatusDescriber(configs: Record<string, CliConfig>, describers: Record<string, StatusDescriber>): (command: string) => string;
@@ -0,0 +1,107 @@
1
+ import type { runCli } from "./cli-executor.ts";
2
+ import type { StageConfirmation, ExecutableTool } from "@mercury-fw/plugin-types";
3
+ import type { CliPostProcessor } from "@mercury-fw/plugin-types";
4
+ export type { CliPostProcessor };
5
+ export type AllowedCommand = {
6
+ prefix: string[];
7
+ confirm: boolean;
8
+ mutating: boolean;
9
+ };
10
+ export type GlobalFlag = {
11
+ flag: string;
12
+ takesValue: boolean;
13
+ };
14
+ export type CliConfig = {
15
+ allowedPrefixes: AllowedCommand[];
16
+ /** Optional declarative global flags (can appear anywhere in argv, not
17
+ * just after the prefix) to strip before prefix-matching. */
18
+ globalFlags?: GlobalFlag[];
19
+ };
20
+ /**
21
+ * Removes every occurrence of any flag listed in `globalFlags` from
22
+ * `args` (and its value too, if `takesValue`), wherever it appears —
23
+ * generic replacement for what used to be a hand-written per-CLI
24
+ * function (jira's old `stripSelectFlag`). Used only to build a
25
+ * throwaway copy for prefix-matching in `matchCommand`; the original
26
+ * `args` (flags included) is always what actually gets executed.
27
+ */
28
+ export declare function stripGlobalFlags(args: string[], globalFlags: GlobalFlag[]): string[];
29
+ export type CommandMatch = {
30
+ kind: "allowed";
31
+ prefix: string[];
32
+ mutating: boolean;
33
+ } | {
34
+ kind: "confirm-required";
35
+ prefix: string[];
36
+ mutating: boolean;
37
+ } | {
38
+ kind: "not-allowed";
39
+ };
40
+ /**
41
+ * Classifies `args` under `config`: `--help` is always `allowed`
42
+ * (discovery, not execution); otherwise `args` (after `config.globalFlags`
43
+ * stripping, if any) is matched positionally against `config.allowedPrefixes`
44
+ * — no match is `not-allowed`, a match with `confirm: false` is `allowed`,
45
+ * a match with `confirm: true` is `confirm-required` (the shape is
46
+ * recognized, but there's no confirmation mechanism to gate it on yet).
47
+ * `mutating` is carried through independently of `confirm` — a command can
48
+ * change external state (Jira, etc.) without requiring confirmation (e.g.
49
+ * create), so the two flags are never derived from one another. An allowed
50
+ * match reports the prefix it matched (`[]` for `--help`), which is what a
51
+ * plugin's post-processor decides on.
52
+ */
53
+ export declare function matchCommand(args: string[], config: CliConfig): CommandMatch;
54
+ /** Renders a list of prefixes as a comma-separated string for a
55
+ * model-readable rejection message, e.g. `"issue search, issue get"`. */
56
+ export declare function formatPrefixes(prefixes: string[][]): string;
57
+ /**
58
+ * Removes the top-level `display` channel (see `ToolDisplay`) from a tool
59
+ * result before it reaches the model. `display` is the user-facing channel —
60
+ * a deterministic artifact stashed in the display store and shown only when the
61
+ * model calls `present` (see `display-store.ts`/`present-tool.ts`), never
62
+ * something the model needs to read or reproduce. The model channel is `data`
63
+ * plus the `displayRef` pointer (kept, so the model can `present` it), so this
64
+ * is a structural drop of one known top-level key — not a heuristic removal of
65
+ * a field nested inside `data`. Model-facing notes a plugin adds inside `data`
66
+ * (e.g. `formattedListNote`, telling the model why formatting couldn't happen
67
+ * and how to retry) are on the model channel and pass through.
68
+ */
69
+ export declare function omitDisplayForModel(output: unknown): unknown;
70
+ /**
71
+ * Builds the `runCommand` tool: the model writes a whole CLI invocation as
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 /
75
+ * not-allowed) gets a distinct, self-correctable error message — before
76
+ * ever calling `runCliFn`. `runCliFn` is injected (defaulting to the real
77
+ * `runCli` in production) so tests can supply a fake without spawning a
78
+ * real subprocess.
79
+ *
80
+ * `opts.stageConfirmation` scopes the confirm-required branch: a confirm-gated
81
+ * command is staged (see `confirmation-staging.ts`) instead of running, and the
82
+ * result carries a structured `token` — how the user is actually told to
83
+ * confirm is channel-specific (a card button on Google Chat, a bare token typed
84
+ * on the terminal), not dictated here or by the model (see `confirm-flow.ts` for
85
+ * the other half — actually running it once that token comes back). Staging is
86
+ * inherently per-session (the closure is bound to one sessionKey), so callers
87
+ * must build a fresh tool per turn, scoped to that turn's own session — not a
88
+ * tool meant to be built once and reused across sessions.
89
+ */
90
+ export declare function createCliTool(runCliFn: typeof runCli, configs: Record<string, CliConfig>, opts: {
91
+ /** Stages a confirm-required command's execution behind a token, and writes
92
+ * its paper-trail note (see `StageConfirmation`). Pre-bound to this turn's
93
+ * session/user by the composition root — `cli-tool.ts` never touches the
94
+ * confirmation store or the wiki itself. */
95
+ stageConfirmation: StageConfirmation;
96
+ /** The plugin's post-processor (see `CliPostProcessor`), run on every
97
+ * allowed command's result with the matched prefix — `cli-tool.ts` itself
98
+ * never knows what it does. */
99
+ postProcess?: CliPostProcessor;
100
+ /** Stashes a post-processor's rendered `display` artifact and returns a ref
101
+ * the model can `present`. Pre-bound to this turn's session by the
102
+ * composition root. Optional: with none wired the inline `display` is left
103
+ * untouched and no `displayRef` is minted. */
104
+ stashDisplay?: (artifact: string) => string;
105
+ }): {
106
+ runCommand: ExecutableTool;
107
+ };
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Checks an installed CLI binary's version against a maintainer-declared
3
+ * `minVersion` (from an externally-configured CLI config file, see
4
+ * `src/tools/cli-config-loader.ts`). No version-check convention existed
5
+ * anywhere in this codebase before this — confirmed by searching
6
+ * `scripts/install-clis.sh` (only resolves GitHub release *tags* to pick
7
+ * a download asset, never validates an already-installed binary) and the
8
+ * rest of `src/`.
9
+ *
10
+ * Assumes `--version` is the right flag to invoke — the CLIs are
11
+ * Rust/clap-based, and `--version` is clap's near-universal default, but
12
+ * this isn't verified against the real CLI-monorepo binaries from here.
13
+ * If wrong, the failure mode is still safe: `checkCliVersion` fails
14
+ * closed (that CLI just never activates), not silently permissive.
15
+ *
16
+ * Version comparison is hand-rolled rather than a new dependency —
17
+ * comparing well-formed `X.Y.Z` triples numerically isn't the kind of
18
+ * genuinely tricky parsing problem that justified pulling in
19
+ * `shell-quote` for command tokenizing.
20
+ */
21
+ import { runCli } from "./cli-executor.ts";
22
+ export type ParsedVersion = {
23
+ major: number;
24
+ minor: number;
25
+ patch: number;
26
+ };
27
+ /** Extracts the first `major.minor.patch` triple found in `text`, or
28
+ * `null` if none is present. */
29
+ export declare function parseVersion(text: string): ParsedVersion | null;
30
+ /** Numeric major/minor/patch comparison: -1 if `a` < `b`, 0 if equal, 1
31
+ * if `a` > `b`. */
32
+ export declare function compareVersions(a: ParsedVersion, b: ParsedVersion): -1 | 0 | 1;
33
+ export type VersionCheckResult = {
34
+ ok: true;
35
+ } | {
36
+ ok: false;
37
+ reason: string;
38
+ };
39
+ /**
40
+ * Runs `<binary> --version` (via the injected `runCliFn`) and checks the
41
+ * result against `minVersion`. Never throws — every failure mode
42
+ * (malformed `minVersion`, the CLI invocation failing, unparseable
43
+ * output, a version below the minimum) resolves to `{ ok: false, reason
44
+ * }` instead.
45
+ */
46
+ export declare function checkCliVersion(binary: string, minVersion: string, runCliFn: typeof runCli): Promise<VersionCheckResult>;
@@ -0,0 +1,14 @@
1
+ export type ParsedCommand = {
2
+ ok: true;
3
+ binary: string;
4
+ args: string[];
5
+ } | {
6
+ ok: false;
7
+ error: string;
8
+ };
9
+ /**
10
+ * Parses `command` into `{ binary, args }`. Never throws — every failure
11
+ * mode (unbalanced quoting, a `$`, a shell operator/glob/comment, or an
12
+ * empty result) resolves to `{ ok: false, error }` instead.
13
+ */
14
+ export declare function parseCommand(command: string): ParsedCommand;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The CLI-execution plugin's public surface. It owns the whole "run a CLI
3
+ * command written as one free-text string, against a maintainer-authored
4
+ * allowlist" mechanism that used to sit in the core: the command parser, the
5
+ * subprocess executor, the allowlist schema/loader/version-check, the
6
+ * allowlist-matching `runCommand` tool, and the per-command status describer.
7
+ *
8
+ * The core imports what it needs from here; a CLI-based plugin (Jira,
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
11
+ * external `ai`/`zod`/`shell-quote` packages — so the mechanism is a self
12
+ * contained unit that an instance with no CLI plugin never pulls in.
13
+ */
14
+ export { parseCommand, type ParsedCommand } from "./command-parser.ts";
15
+ export { runCli, type CliResult } from "./cli-executor.ts";
16
+ export { createCliTool, matchCommand, stripGlobalFlags, formatPrefixes, omitDisplayForModel, type CliConfig, type AllowedCommand, type GlobalFlag, type CommandMatch, type CliPostProcessor, } from "./cli-tool.ts";
17
+ export { CliConfigFileSchema, type CliConfigFile } from "./cli-config-schema.ts";
18
+ export { toCliConfig, loadCliConfigFromObject, parseCliConfig, type CliConfigFromObjectResult, } from "./cli-config-loader.ts";
19
+ export { checkCliVersion, parseVersion, compareVersions, type ParsedVersion, type VersionCheckResult, } from "./cli-version-check.ts";
20
+ export { createCliStatusDescriber } from "./cli-status.ts";
package/index.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The CLI-execution plugin's public surface. It owns the whole "run a CLI
3
+ * command written as one free-text string, against a maintainer-authored
4
+ * allowlist" mechanism that used to sit in the core: the command parser, the
5
+ * subprocess executor, the allowlist schema/loader/version-check, the
6
+ * allowlist-matching `runCommand` tool, and the per-command status describer.
7
+ *
8
+ * The core imports what it needs from here; a CLI-based plugin (Jira,
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
11
+ * external `ai`/`zod`/`shell-quote` packages — so the mechanism is a self
12
+ * contained unit that an instance with no CLI plugin never pulls in.
13
+ */
14
+ export { parseCommand, type ParsedCommand } from "./command-parser.ts";
15
+ export { runCli, type CliResult } from "./cli-executor.ts";
16
+ export {
17
+ createCliTool,
18
+ matchCommand,
19
+ stripGlobalFlags,
20
+ formatPrefixes,
21
+ omitDisplayForModel,
22
+ type CliConfig,
23
+ type AllowedCommand,
24
+ type GlobalFlag,
25
+ type CommandMatch,
26
+ type CliPostProcessor,
27
+ } from "./cli-tool.ts";
28
+ export { CliConfigFileSchema, type CliConfigFile } from "./cli-config-schema.ts";
29
+ export {
30
+ toCliConfig,
31
+ loadCliConfigFromObject,
32
+ parseCliConfig,
33
+ type CliConfigFromObjectResult,
34
+ } from "./cli-config-loader.ts";
35
+ export {
36
+ checkCliVersion,
37
+ parseVersion,
38
+ compareVersions,
39
+ type ParsedVersion,
40
+ type VersionCheckResult,
41
+ } from "./cli-version-check.ts";
42
+ export { createCliStatusDescriber } from "./cli-status.ts";
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@mercury-fw/cli-engine",
3
+ "version": "0.25.0",
4
+ "license": "MIT",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/lucabro81/mercury-fw.git",
8
+ "directory": "packages/libs/cli-engine"
9
+ },
10
+ "files": [
11
+ "*.ts",
12
+ "dist",
13
+ "CHANGELOG.md"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "exports": {
19
+ ".": {
20
+ "mercury-fw-source": "./index.ts",
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./index.ts"
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "@mercury-fw/plugin-types": "0.25.0",
27
+ "ai": "^7.0.77",
28
+ "shell-quote": "^1.10.0",
29
+ "zod": "^4.4.3"
30
+ },
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "devDependencies": {
35
+ "@mercury-fw/typescript-config": "*",
36
+ "@types/bun": "^1.4.0",
37
+ "typescript": "^6.0.3"
38
+ }
39
+ }