mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(cli): share builtin argument ownership with MCP routing
This commit is contained in:
1 parent
254439750a
commit
cd128795eb
6 files changed
+170
-118
No files matched your search
@@ -24,6 +24,7 @@ export const bundleRuntime = async (root) => {
|
|||||||
{
|
{
|
||||||
cli: "cli.js",
|
cli: "cli.js",
|
||||||
cliOutput: "cliOutput.js",
|
cliOutput: "cliOutput.js",
|
||||||
|
cliBuiltinArguments: "cliBuiltinArguments.js",
|
||||||
cliResultOutput: "cli/cliResultOutput.js",
|
cliResultOutput: "cli/cliResultOutput.js",
|
||||||
mcpDoctor: "mcpDoctor.js",
|
mcpDoctor: "mcpDoctor.js",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ if (await pathExists(join(root, "src"))) {
|
|||||||
"dist/mcp/main.js",
|
"dist/mcp/main.js",
|
||||||
"dist/cli-runtime/cli.js",
|
"dist/cli-runtime/cli.js",
|
||||||
"dist/cli-runtime/cliOutput.js",
|
"dist/cli-runtime/cliOutput.js",
|
||||||
|
"dist/cli-runtime/cliBuiltinArguments.js",
|
||||||
"dist/cli-runtime/cliResultOutput.js",
|
"dist/cli-runtime/cliResultOutput.js",
|
||||||
"dist/cli-runtime/mcpDoctor.js",
|
"dist/cli-runtime/mcpDoctor.js",
|
||||||
"dist/mcpDoctor.js",
|
"dist/mcpDoctor.js",
|
||||||
|
|||||||
+13
-7
@@ -12,9 +12,15 @@ const { default: packageJson } = await import("../package.json", {
|
|||||||
with: { type: "json" },
|
with: { type: "json" },
|
||||||
});
|
});
|
||||||
process.env.REA_PACKAGE_VERSION = packageJson.version;
|
process.env.REA_PACKAGE_VERSION = packageJson.version;
|
||||||
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const builtinArgumentRuntime = "dist/cli-runtime/cliBuiltinArguments.js";
|
||||||
|
const builtinArguments = (await compiledRuntimeExists([builtinArgumentRuntime]))
|
||||||
|
? await import("../dist/cli-runtime/cliBuiltinArguments.js")
|
||||||
|
: undefined;
|
||||||
|
const doctorArguments = builtinArguments?.productionMcpDoctorArguments(args);
|
||||||
const isMcpEntry = args[0] === "--mcp" || args[0] === "mcp";
|
const isMcpEntry = args[0] === "--mcp" || args[0] === "mcp";
|
||||||
const isMcpMode = isMcpEntry && args.length === 1;
|
const isMcpMode = isMcpEntry && args.length === 1;
|
||||||
const isMcpDoctorMode = args[0] === "mcp" && args[1] === "doctor";
|
const isMcpDoctorMode = doctorArguments !== undefined;
|
||||||
// Only flags may follow the entry: `mcp add --http` stays Incur's `mcp add`,
|
// Only flags may follow the entry: `mcp add --http` stays Incur's `mcp add`,
|
||||||
// while `mcp -h --http` reaches the HTTP entry's own help.
|
// while `mcp -h --http` reaches the HTTP entry's own help.
|
||||||
const isMcpHttpMode =
|
const isMcpHttpMode =
|
||||||
@@ -25,8 +31,6 @@ const isMcpHttpMode =
|
|||||||
const SIGPIPE_EXIT_STATUS = 141;
|
const SIGPIPE_EXIT_STATUS = 141;
|
||||||
// Set once the reader of CLI output has closed the pipe.
|
// Set once the reader of CLI output has closed the pipe.
|
||||||
let stdoutClosed = false;
|
let stdoutClosed = false;
|
||||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
||||||
|
|
||||||
// Windows home lookup throws for an empty USERPROFILE. Dependencies resolve
|
// Windows home lookup throws for an empty USERPROFILE. Dependencies resolve
|
||||||
// home during import, before REA's own resolver can apply its account fallback.
|
// home during import, before REA's own resolver can apply its account fallback.
|
||||||
if (process.platform === "win32" && process.env.USERPROFILE === "") {
|
if (process.platform === "win32" && process.env.USERPROFILE === "") {
|
||||||
@@ -47,8 +51,9 @@ if (
|
|||||||
process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright");
|
process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright");
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeFiles =
|
const runtimeFiles = [
|
||||||
isMcpMode || isMcpHttpMode
|
builtinArgumentRuntime,
|
||||||
|
...(isMcpMode || isMcpHttpMode
|
||||||
? ["dist/mcp/main.js"]
|
? ["dist/mcp/main.js"]
|
||||||
: isMcpDoctorMode
|
: isMcpDoctorMode
|
||||||
? ["dist/mcp/main.js", "dist/cli-runtime/mcpDoctor.js"]
|
? ["dist/mcp/main.js", "dist/cli-runtime/mcpDoctor.js"]
|
||||||
@@ -56,7 +61,8 @@ const runtimeFiles =
|
|||||||
"dist/cli-runtime/cli.js",
|
"dist/cli-runtime/cli.js",
|
||||||
"dist/cli-runtime/cliOutput.js",
|
"dist/cli-runtime/cliOutput.js",
|
||||||
"dist/cli-runtime/cliResultOutput.js",
|
"dist/cli-runtime/cliResultOutput.js",
|
||||||
];
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
if (!(await compiledRuntimeExists(runtimeFiles))) {
|
if (!(await compiledRuntimeExists(runtimeFiles))) {
|
||||||
process.stderr.write(
|
process.stderr.write(
|
||||||
@@ -73,7 +79,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) {
|
|||||||
guardClosedStdout();
|
guardClosedStdout();
|
||||||
const { runProductionMcpDoctorCli } =
|
const { runProductionMcpDoctorCli } =
|
||||||
await import("../dist/cli-runtime/mcpDoctor.js");
|
await import("../dist/cli-runtime/mcpDoctor.js");
|
||||||
const result = await runProductionMcpDoctorCli(args.slice(2), {
|
const result = await runProductionMcpDoctorCli(doctorArguments, {
|
||||||
dispatcherPath: fileURLToPath(import.meta.url),
|
dispatcherPath: fileURLToPath(import.meta.url),
|
||||||
packageRoot,
|
packageRoot,
|
||||||
stdoutIsTerminal: process.stdout.isTTY === true,
|
stdoutIsTerminal: process.stdout.isTTY === true,
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/** Global output flags that take a value. */
|
||||||
|
const VALUED_OUTPUT_FLAGS = new Set([
|
||||||
|
"--format",
|
||||||
|
"--filter-output",
|
||||||
|
"--token-limit",
|
||||||
|
"--token-offset",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ROOT_UPDATE_OUTPUT_FLAGS = new Set([
|
||||||
|
"--json",
|
||||||
|
"--full-output",
|
||||||
|
"--token-count",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Valueless flags Incur removes from every position before command parsing. */
|
||||||
|
const INCUR_BUILTIN_FLAGS = new Set([
|
||||||
|
"--full-output",
|
||||||
|
"--llms",
|
||||||
|
"--llms-full",
|
||||||
|
"--mcp",
|
||||||
|
"--help",
|
||||||
|
"-h",
|
||||||
|
"--update",
|
||||||
|
// Incur's internal update-check flag.
|
||||||
|
"--incur-update-check",
|
||||||
|
"--schema",
|
||||||
|
"--json",
|
||||||
|
"--token-count",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Command tokens and output-flag values as Incur's builtin pass assigns them. */
|
||||||
|
export interface IncurBuiltinArgumentSplit {
|
||||||
|
/** Tokens left for command resolution and command option parsing. */
|
||||||
|
readonly command: readonly string[];
|
||||||
|
/** Positions of nonempty values consumed by valued output flags. */
|
||||||
|
readonly outputValues: readonly {
|
||||||
|
readonly flag: string;
|
||||||
|
readonly index: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirror Incur's builtin pass over normalized arguments without stopping at a
|
||||||
|
* rejected value, so callers can still see every later token. REA configures
|
||||||
|
* neither a config flag nor global options, which Incur would also remove.
|
||||||
|
*/
|
||||||
|
export const splitIncurBuiltinArguments = (
|
||||||
|
arguments_: readonly string[],
|
||||||
|
): IncurBuiltinArgumentSplit => {
|
||||||
|
const command: string[] = [];
|
||||||
|
const outputValues: { readonly flag: string; readonly index: number }[] = [];
|
||||||
|
for (let index = 0; index < arguments_.length; index += 1) {
|
||||||
|
const argument = arguments_[index]!;
|
||||||
|
const next = arguments_[index + 1];
|
||||||
|
if (
|
||||||
|
INCUR_BUILTIN_FLAGS.has(argument) ||
|
||||||
|
// A following value belongs to a command-local `--version` option.
|
||||||
|
(argument === "--version" && (next === undefined || next.startsWith("-")))
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
if (VALUED_OUTPUT_FLAGS.has(argument) && next) {
|
||||||
|
outputValues.push({ flag: argument, index: index + 1 });
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
command.push(argument);
|
||||||
|
}
|
||||||
|
return { command, outputValues };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route the root update alias and split `--flag=value` output forms. Incur reads these
|
||||||
|
* flags only in spaced form, so `--format=json` otherwise reaches the command
|
||||||
|
* parser and fails as an unknown flag. Command options accept both forms.
|
||||||
|
*/
|
||||||
|
export const normalizeCliArguments = (
|
||||||
|
arguments_: readonly string[],
|
||||||
|
): string[] => {
|
||||||
|
const normalized: string[] = [];
|
||||||
|
const updates = new Set<number>();
|
||||||
|
let rootUpdate = true;
|
||||||
|
for (let index = 0; index < arguments_.length; index += 1) {
|
||||||
|
const argument = arguments_[index]!;
|
||||||
|
if (VALUED_OUTPUT_FLAGS.has(argument) && arguments_[index + 1]) {
|
||||||
|
// Incur consumes a nonempty spaced value before looking for more flags.
|
||||||
|
// Its contents belong to that option, even when they look like a flag.
|
||||||
|
normalized.push(argument, arguments_[index + 1]!);
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const separator = argument.indexOf("=");
|
||||||
|
const flag = argument.slice(0, separator);
|
||||||
|
if (separator >= 0 && VALUED_OUTPUT_FLAGS.has(flag))
|
||||||
|
normalized.push(flag, argument.slice(separator + 1));
|
||||||
|
else {
|
||||||
|
if (argument === "--update") updates.add(normalized.length);
|
||||||
|
else if (!ROOT_UPDATE_OUTPUT_FLAGS.has(argument)) rootUpdate = false;
|
||||||
|
normalized.push(argument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Output option values were consumed above; flag-shaped data is never an alias.
|
||||||
|
// Help/check/command/unknown tokens retain Incur's parsing and precedence.
|
||||||
|
return rootUpdate && updates.size > 0
|
||||||
|
? ["update", ...normalized.filter((_, index) => !updates.has(index))]
|
||||||
|
: normalized;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Select production doctor using the same normalized builtin argument owner as CLI dispatch. */
|
||||||
|
export const productionMcpDoctorArguments = (
|
||||||
|
arguments_: readonly string[],
|
||||||
|
): readonly string[] | undefined => {
|
||||||
|
const normalized = normalizeCliArguments(arguments_);
|
||||||
|
const split = splitIncurBuiltinArguments(normalized);
|
||||||
|
if (split.command[0] !== "mcp" || split.command[1] !== "doctor")
|
||||||
|
return undefined;
|
||||||
|
const values = new Set(split.outputValues.map(({ index }) => index));
|
||||||
|
const mcp = normalized.findIndex(
|
||||||
|
(token, index) => token === "mcp" && !values.has(index),
|
||||||
|
);
|
||||||
|
const doctor = normalized.findIndex(
|
||||||
|
(token, index) => index > mcp && token === "doctor" && !values.has(index),
|
||||||
|
);
|
||||||
|
return normalized.filter((_, index) => index !== mcp && index !== doctor);
|
||||||
|
};
|
||||||
+5
-107
@@ -1,3 +1,8 @@
|
|||||||
|
export {
|
||||||
|
normalizeCliArguments,
|
||||||
|
splitIncurBuiltinArguments,
|
||||||
|
type IncurBuiltinArgumentSplit,
|
||||||
|
} from "./cliBuiltinArguments.js";
|
||||||
import { formatCliOutput } from "./cli/cliOutputFormatter.js";
|
import { formatCliOutput } from "./cli/cliOutputFormatter.js";
|
||||||
|
|
||||||
import { jsonOutputParts } from "./cli/jsonOutput.js";
|
import { jsonOutputParts } from "./cli/jsonOutput.js";
|
||||||
@@ -40,36 +45,6 @@ export const renderCliDocument = (
|
|||||||
format === "json" || format === "jsonl"
|
format === "json" || format === "jsonl"
|
||||||
? [...jsonOutputParts(value, format, stdoutIsTerminal)].join("")
|
? [...jsonOutputParts(value, format, stdoutIsTerminal)].join("")
|
||||||
: `${formatCliOutput(value, format)}\n`;
|
: `${formatCliOutput(value, format)}\n`;
|
||||||
/** Global output flags that take a value. */
|
|
||||||
const VALUED_OUTPUT_FLAGS = new Set([
|
|
||||||
"--format",
|
|
||||||
"--filter-output",
|
|
||||||
"--token-limit",
|
|
||||||
"--token-offset",
|
|
||||||
]);
|
|
||||||
|
|
||||||
const ROOT_UPDATE_OUTPUT_FLAGS = new Set([
|
|
||||||
"--json",
|
|
||||||
"--full-output",
|
|
||||||
"--token-count",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Valueless flags Incur removes from every position before command parsing. */
|
|
||||||
const INCUR_BUILTIN_FLAGS = new Set([
|
|
||||||
"--full-output",
|
|
||||||
"--llms",
|
|
||||||
"--llms-full",
|
|
||||||
"--mcp",
|
|
||||||
"--help",
|
|
||||||
"-h",
|
|
||||||
"--update",
|
|
||||||
// Incur's internal update-check flag.
|
|
||||||
"--incur-update-check",
|
|
||||||
"--schema",
|
|
||||||
"--json",
|
|
||||||
"--token-count",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Incur's message when its builtin pass rejects this spaced value of a valued
|
* Incur's message when its builtin pass rejects this spaced value of a valued
|
||||||
* output flag, or undefined when Incur accepts it.
|
* output flag, or undefined when Incur accepts it.
|
||||||
@@ -90,83 +65,6 @@ export const cliOutputValueRejection = (
|
|||||||
return undefined;
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Command tokens and output-flag values as Incur's builtin pass assigns them. */
|
|
||||||
export interface IncurBuiltinArgumentSplit {
|
|
||||||
/** Tokens left for command resolution and command option parsing. */
|
|
||||||
readonly command: readonly string[];
|
|
||||||
/** Positions of nonempty values consumed by valued output flags. */
|
|
||||||
readonly outputValues: readonly {
|
|
||||||
readonly flag: string;
|
|
||||||
readonly index: number;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirror Incur's builtin pass over normalized arguments without stopping at a
|
|
||||||
* rejected value, so callers can still see every later token. REA configures
|
|
||||||
* neither a config flag nor global options, which Incur would also remove.
|
|
||||||
*/
|
|
||||||
export const splitIncurBuiltinArguments = (
|
|
||||||
arguments_: readonly string[],
|
|
||||||
): IncurBuiltinArgumentSplit => {
|
|
||||||
const command: string[] = [];
|
|
||||||
const outputValues: { readonly flag: string; readonly index: number }[] = [];
|
|
||||||
for (let index = 0; index < arguments_.length; index += 1) {
|
|
||||||
const argument = arguments_[index]!;
|
|
||||||
const next = arguments_[index + 1];
|
|
||||||
if (
|
|
||||||
INCUR_BUILTIN_FLAGS.has(argument) ||
|
|
||||||
// A following value belongs to a command-local `--version` option.
|
|
||||||
(argument === "--version" && (next === undefined || next.startsWith("-")))
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
if (VALUED_OUTPUT_FLAGS.has(argument) && next) {
|
|
||||||
outputValues.push({ flag: argument, index: index + 1 });
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
command.push(argument);
|
|
||||||
}
|
|
||||||
return { command, outputValues };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route the root update alias and split `--flag=value` output forms. Incur reads these
|
|
||||||
* flags only in spaced form, so `--format=json` otherwise reaches the command
|
|
||||||
* parser and fails as an unknown flag. Command options accept both forms.
|
|
||||||
*/
|
|
||||||
export const normalizeCliArguments = (
|
|
||||||
arguments_: readonly string[],
|
|
||||||
): string[] => {
|
|
||||||
const normalized: string[] = [];
|
|
||||||
const updates = new Set<number>();
|
|
||||||
let rootUpdate = true;
|
|
||||||
for (let index = 0; index < arguments_.length; index += 1) {
|
|
||||||
const argument = arguments_[index]!;
|
|
||||||
if (VALUED_OUTPUT_FLAGS.has(argument) && arguments_[index + 1]) {
|
|
||||||
// Incur consumes a nonempty spaced value before looking for more flags.
|
|
||||||
// Its contents belong to that option, even when they look like a flag.
|
|
||||||
normalized.push(argument, arguments_[index + 1]!);
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const separator = argument.indexOf("=");
|
|
||||||
const flag = argument.slice(0, separator);
|
|
||||||
if (separator >= 0 && VALUED_OUTPUT_FLAGS.has(flag))
|
|
||||||
normalized.push(flag, argument.slice(separator + 1));
|
|
||||||
else {
|
|
||||||
if (argument === "--update") updates.add(normalized.length);
|
|
||||||
else if (!ROOT_UPDATE_OUTPUT_FLAGS.has(argument)) rootUpdate = false;
|
|
||||||
normalized.push(argument);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Output option values were consumed above; flag-shaped data is never an alias.
|
|
||||||
// Help/check/command/unknown tokens retain Incur's parsing and precedence.
|
|
||||||
return rootUpdate && updates.size > 0
|
|
||||||
? ["update", ...normalized.filter((_, index) => !updates.has(index))]
|
|
||||||
: normalized;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CliOutputArgumentValidation =
|
export type CliOutputArgumentValidation =
|
||||||
| { readonly ok: true }
|
| { readonly ok: true }
|
||||||
| {
|
| {
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ const installCliRuntime = async (
|
|||||||
await workspace.mkdir("dist/cli-runtime");
|
await workspace.mkdir("dist/cli-runtime");
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
copyFile("scripts/rea.mjs", workspace.path("scripts/rea.mjs")),
|
copyFile("scripts/rea.mjs", workspace.path("scripts/rea.mjs")),
|
||||||
|
copyFile(
|
||||||
|
"dist/cli-runtime/cliBuiltinArguments.js",
|
||||||
|
workspace.path("dist/cli-runtime/cliBuiltinArguments.js"),
|
||||||
|
),
|
||||||
workspace.write(
|
workspace.write(
|
||||||
"package.json",
|
"package.json",
|
||||||
JSON.stringify({ type: "module", version: "1.0.0" }),
|
JSON.stringify({ type: "module", version: "1.0.0" }),
|
||||||
@@ -119,6 +123,10 @@ describe("executable dispatcher", () => {
|
|||||||
await workspace.mkdir("dist/mcp");
|
await workspace.mkdir("dist/mcp");
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")),
|
copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")),
|
||||||
|
copyFile(
|
||||||
|
"dist/cli-runtime/cliBuiltinArguments.js",
|
||||||
|
workspace.path("dist/cli-runtime/cliBuiltinArguments.js"),
|
||||||
|
),
|
||||||
workspace.write(
|
workspace.write(
|
||||||
"package.json",
|
"package.json",
|
||||||
JSON.stringify({ type: "module", version: "1.0.0" }),
|
JSON.stringify({ type: "module", version: "1.0.0" }),
|
||||||
@@ -154,10 +162,24 @@ describe("executable dispatcher", () => {
|
|||||||
|
|
||||||
await expect(invoke(["mcp"])).resolves.toEqual({ adapter: "mcp" });
|
await expect(invoke(["mcp"])).resolves.toEqual({ adapter: "mcp" });
|
||||||
await expect(invoke(["--mcp"])).resolves.toEqual({ adapter: "mcp" });
|
await expect(invoke(["--mcp"])).resolves.toEqual({ adapter: "mcp" });
|
||||||
await expect(invoke(["mcp", "doctor", "--json"])).resolves.toEqual({
|
for (const args of [
|
||||||
adapter: "mcp-doctor",
|
["mcp", "doctor", "--json"],
|
||||||
args: ["--json"],
|
["--json", "mcp", "doctor"],
|
||||||
stdoutIsTerminal: false,
|
["mcp", "--json", "doctor"],
|
||||||
|
["--format", "json", "mcp", "doctor"],
|
||||||
|
["mcp", "--format=json", "doctor"],
|
||||||
|
]) {
|
||||||
|
await expect(invoke(args)).resolves.toEqual({
|
||||||
|
adapter: "mcp-doctor",
|
||||||
|
args: args.includes("--json") ? ["--json"] : ["--format", "json"],
|
||||||
|
stdoutIsTerminal: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await expect(
|
||||||
|
invoke(["--filter-output", "mcp", "doctor"]),
|
||||||
|
).resolves.toEqual({
|
||||||
|
adapter: "cli",
|
||||||
|
args: ["--filter-output", "mcp", "doctor"],
|
||||||
});
|
});
|
||||||
await expect(invoke(["mcp", "add"])).resolves.toEqual({
|
await expect(invoke(["mcp", "add"])).resolves.toEqual({
|
||||||
adapter: "cli",
|
adapter: "cli",
|
||||||
|
|||||||
Reference in new issue
Block a user