diff --git a/scripts/bundle-runtime.mjs b/scripts/bundle-runtime.mjs index 6b6b451407..54c6ddc82b 100644 --- a/scripts/bundle-runtime.mjs +++ b/scripts/bundle-runtime.mjs @@ -24,6 +24,7 @@ export const bundleRuntime = async (root) => { { cli: "cli.js", cliOutput: "cliOutput.js", + cliBuiltinArguments: "cliBuiltinArguments.js", cliResultOutput: "cli/cliResultOutput.js", mcpDoctor: "mcpDoctor.js", }, diff --git a/scripts/prepack.mjs b/scripts/prepack.mjs index edf3072e59..4279aab88f 100644 --- a/scripts/prepack.mjs +++ b/scripts/prepack.mjs @@ -49,6 +49,7 @@ if (await pathExists(join(root, "src"))) { "dist/mcp/main.js", "dist/cli-runtime/cli.js", "dist/cli-runtime/cliOutput.js", + "dist/cli-runtime/cliBuiltinArguments.js", "dist/cli-runtime/cliResultOutput.js", "dist/cli-runtime/mcpDoctor.js", "dist/mcpDoctor.js", diff --git a/scripts/rea.mjs b/scripts/rea.mjs index c53f64b92c..041e3cc8c7 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -12,9 +12,15 @@ const { default: packageJson } = await import("../package.json", { with: { type: "json" }, }); 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 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`, // while `mcp -h --http` reaches the HTTP entry's own help. const isMcpHttpMode = @@ -25,8 +31,6 @@ const isMcpHttpMode = const SIGPIPE_EXIT_STATUS = 141; // Set once the reader of CLI output has closed the pipe. let stdoutClosed = false; -const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); - // Windows home lookup throws for an empty USERPROFILE. Dependencies resolve // home during import, before REA's own resolver can apply its account fallback. if (process.platform === "win32" && process.env.USERPROFILE === "") { @@ -47,8 +51,9 @@ if ( process.env.PLAYWRIGHT_BROWSERS_PATH = join(cacheRoot, "ms-playwright"); } -const runtimeFiles = - isMcpMode || isMcpHttpMode +const runtimeFiles = [ + builtinArgumentRuntime, + ...(isMcpMode || isMcpHttpMode ? ["dist/mcp/main.js"] : isMcpDoctorMode ? ["dist/mcp/main.js", "dist/cli-runtime/mcpDoctor.js"] @@ -56,7 +61,8 @@ const runtimeFiles = "dist/cli-runtime/cli.js", "dist/cli-runtime/cliOutput.js", "dist/cli-runtime/cliResultOutput.js", - ]; + ]), +]; if (!(await compiledRuntimeExists(runtimeFiles))) { process.stderr.write( @@ -73,7 +79,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { guardClosedStdout(); const { runProductionMcpDoctorCli } = await import("../dist/cli-runtime/mcpDoctor.js"); - const result = await runProductionMcpDoctorCli(args.slice(2), { + const result = await runProductionMcpDoctorCli(doctorArguments, { dispatcherPath: fileURLToPath(import.meta.url), packageRoot, stdoutIsTerminal: process.stdout.isTTY === true, diff --git a/src/cliBuiltinArguments.ts b/src/cliBuiltinArguments.ts new file mode 100644 index 0000000000..34fefb32ec --- /dev/null +++ b/src/cliBuiltinArguments.ts @@ -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(); + 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); +}; diff --git a/src/cliOutput.ts b/src/cliOutput.ts index 52405aa590..1bc200ebb6 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -1,3 +1,8 @@ +export { + normalizeCliArguments, + splitIncurBuiltinArguments, + type IncurBuiltinArgumentSplit, +} from "./cliBuiltinArguments.js"; import { formatCliOutput } from "./cli/cliOutputFormatter.js"; import { jsonOutputParts } from "./cli/jsonOutput.js"; @@ -40,36 +45,6 @@ export const renderCliDocument = ( format === "json" || format === "jsonl" ? [...jsonOutputParts(value, format, stdoutIsTerminal)].join("") : `${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 * output flag, or undefined when Incur accepts it. @@ -90,83 +65,6 @@ export const cliOutputValueRejection = ( 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(); - 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 = | { readonly ok: true } | { diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index 08a5496d5d..5e8234b6a1 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -19,6 +19,10 @@ const installCliRuntime = async ( await workspace.mkdir("dist/cli-runtime"); await Promise.all([ copyFile("scripts/rea.mjs", workspace.path("scripts/rea.mjs")), + copyFile( + "dist/cli-runtime/cliBuiltinArguments.js", + workspace.path("dist/cli-runtime/cliBuiltinArguments.js"), + ), workspace.write( "package.json", JSON.stringify({ type: "module", version: "1.0.0" }), @@ -119,6 +123,10 @@ describe("executable dispatcher", () => { await workspace.mkdir("dist/mcp"); await Promise.all([ copyFile("scripts/rea.mjs", join(scripts, "rea.mjs")), + copyFile( + "dist/cli-runtime/cliBuiltinArguments.js", + workspace.path("dist/cli-runtime/cliBuiltinArguments.js"), + ), workspace.write( "package.json", 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", "doctor", "--json"])).resolves.toEqual({ - adapter: "mcp-doctor", - args: ["--json"], - stdoutIsTerminal: false, + for (const args of [ + ["mcp", "doctor", "--json"], + ["--json", "mcp", "doctor"], + ["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({ adapter: "cli",