@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 +7 -0
- package/README.md +20 -0
- package/cli-config-loader.ts +73 -0
- package/cli-config-schema.ts +44 -0
- package/cli-executor.ts +83 -0
- package/cli-status.ts +29 -0
- package/cli-tool.ts +254 -0
- package/cli-version-check.ts +88 -0
- package/command-parser.ts +117 -0
- package/dist/cli-config-loader.d.ts +45 -0
- package/dist/cli-config-schema.d.ts +38 -0
- package/dist/cli-executor.d.ts +36 -0
- package/dist/cli-status.d.ts +14 -0
- package/dist/cli-tool.d.ts +107 -0
- package/dist/cli-version-check.d.ts +46 -0
- package/dist/command-parser.d.ts +14 -0
- package/dist/index.d.ts +20 -0
- package/index.ts +42 -0
- package/package.json +39 -0
package/CHANGELOG.md
ADDED
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>;
|
package/cli-executor.ts
ADDED
|
@@ -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;
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|