Files
rea/src/cliBuiltinArguments.ts
T
김준호andmorluto f1bfa1cba2 fix(cli): honor the selected format for unknown mcp subcommands (#7802)
* fix(cli): honor the selected format for unknown mcp subcommands

Incur 0.6.1 renders its unknown mcp subcommand rejection as TOON
regardless of --format or --json. Report the same COMMAND_NOT_FOUND
envelope in the explicitly selected format before dispatching to
Incur, share the pinned mcp subcommand table with completions, and
redact explicitly declared sensitive values from the diagnostic.

Fixes #7691

* fix(cli): correct only the subcommand in the mcp suggestion

The suggested command replaced every token equal to the unknown
subcommand, including option values such as --command. Record each
command token's argument position in the shared builtin split and
replace only the subcommand position.

* fix(cli): leave earlier Incur builtins and caller output flags intact

Incur serves --llms, --llms-full and its update check before the mcp
subcommand branch, so defer those invocations to Incur as well. Build
the suggested command from the caller's arguments rather than the
arguments adapted for Incur, so a delimited format and its columns
remain in the suggestion.

* fix(cli): ignore output values spelled like earlier Incur builtins

The earlier-builtin check matched raw tokens, so an output flag value
such as --columns --llms or --filter-output --llms deferred the
rejection to Incur's TOON path. Check the arguments Incur receives and
skip positions its builtin pass consumes as output values.

* fix(cli): omit mcp suggestions that would not replay caller input

Joining the corrected tokens with spaces re-tokenizes a value with
whitespace or shell metacharacters when the suggestion is copied, and
quoting differs between POSIX shells, cmd and PowerShell. Offer the
corrected command only when every token is a plain shell word and no
declared sensitive value was redacted; the message still names the
suggested subcommand.

* fix(cli): preserve safe MCP diagnostic output

* fix(cli): omit mcp suggestions with words zsh expands

zsh expands a word that starts with = to an executable path by
default, so a copied suggestion such as --command =node would replace
the caller's literal value. Treat =-leading words as non-replayable.

* fix(cli): render mcp diagnostics through the document renderer

Markdown output went through Incur's formatter, so caller text such as
*oops* rendered as emphasis instead of literal text. Use REA's document
renderer for every format, and redact declared sensitive literals from
the fixed call-to-action strings as well as the message.

* test(cli): keep the mcp correction on the subcommand after timing globals

---------

Co-authored-by: morluto <[email protected]>
2026-11-06 17:20:56 +08:00

358 lines
13 KiB
TypeScript

/** Global output flags that take a value. */
export const VALUED_OUTPUT_FLAGS: ReadonlySet<string> = new Set([
"--format",
"--filter-output",
"--columns",
"--token-limit",
"--token-offset",
]);
export const OUTPUT_SWITCHES: ReadonlySet<string> = new Set([
"--json",
"--full-output",
"--token-count",
"--timings",
]);
/** Valueless flags Incur removes from every position before command parsing. */
export const INCUR_BUILTIN_FLAGS: ReadonlySet<string> = new Set([
"--full-output",
"--llms",
"--llms-full",
"--mcp",
"--help",
"-h",
"--update",
// Incur's internal update-check flag.
"--incur-update-check",
"--schema",
"--json",
"--token-count",
"--timings",
]);
/** Supported boolean spelling under the pinned Incur global parser. */
const cliTimingFlagValue = (token: string): boolean | undefined =>
token === "--timings"
? true
: token === "--no-timings"
? false
: token.startsWith("--timings=")
? token.slice("--timings=".length) === "true"
: undefined;
/** Remove only timing globals, preserving valued output data and the -- boundary. */
export const splitCliTimingArguments = (
arguments_: readonly string[],
): { arguments: string[]; enabled: boolean } => {
const values = new Set(
splitIncurBuiltinArguments(arguments_).outputValues.map(
({ index }) => index,
),
);
let enabled = false;
let ended = false;
const remaining = arguments_.filter((token, index) => {
if (values.has(index)) return true;
if (token === "--") ended = true;
const value = ended ? undefined : cliTimingFlagValue(token);
if (value === undefined) return true;
enabled = value;
return false;
});
return { arguments: remaining, enabled };
};
/** 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[];
/** Argument position of each `command` token. */
readonly commandIndices: readonly number[];
/** Valueless global flags extracted by Incur before command dispatch. */
readonly flags: ReadonlySet<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. Then mirror the
* boolean timing global pass over the remaining tokens; no config is registered.
*/
export const splitIncurBuiltinArguments = (
arguments_: readonly string[],
): IncurBuiltinArgumentSplit => {
const command: string[] = [];
const commandIndices: number[] = [];
const flags = new Set<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) && argument !== "--timings") ||
// A following value belongs to a command-local `--version` option.
(argument === "--version" && (next === undefined || next.startsWith("-")))
) {
flags.add(argument);
continue;
}
if (VALUED_OUTPUT_FLAGS.has(argument) && next) {
outputValues.push({ flag: argument, index: index + 1 });
index += 1;
continue;
}
command.push(argument);
commandIndices.push(index);
}
// Incur extracts builtin output values first, then parses globals until `--`.
// A timing-shaped output value is data; a command option's value is still
// subject to this global pass before the registration builtin consumes it.
let globalsEnded = false;
const remaining: string[] = [];
const remainingIndices: number[] = [];
command.forEach((token, position) => {
if (token === "--") globalsEnded = true;
if (!globalsEnded && cliTimingFlagValue(token) !== undefined) {
flags.add(token);
return;
}
remaining.push(token);
remainingIndices.push(commandIndices[position]!);
});
return {
command: remaining,
commandIndices: remainingIndices,
flags,
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 (
!OUTPUT_SWITCHES.has(argument) &&
cliTimingFlagValue(argument) === undefined
)
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;
};
/** Keep REA-owned delimited formats out of Incur's narrower formatter surface. */
export const adaptCliOutputArgumentsForIncur = (
arguments_: readonly string[],
): string[] => {
const adapted: string[] = [];
for (let index = 0; index < arguments_.length; index += 1) {
const argument = arguments_[index]!;
const value = arguments_[index + 1];
if (VALUED_OUTPUT_FLAGS.has(argument)) {
if (argument === "--columns") {
if (value !== undefined) index += 1;
continue;
}
if (argument === "--format" && (value === "csv" || value === "tsv")) {
adapted.push("--format", "json");
index += 1;
continue;
}
adapted.push(argument);
if (value) {
adapted.push(value);
index += 1;
}
continue;
}
adapted.push(argument);
}
return adapted;
};
/**
* Expand equals-form `mcp add` valued options into spaced forms. Single
* owner: the project-scope guard and the `mcp add` dispatch in `cli.ts`
* both consume this, so `--agent=amp` and `--agent amp` select the same
* agent in both places. An empty `--flag=` form is left intact for the
* caller's missing-value handling.
*/
export const expandMcpAddEqualsArguments = (
arguments_: readonly string[],
): string[] =>
arguments_.flatMap((token) => {
if (token.startsWith("--agent=") || token.startsWith("--command=")) {
const separator = token.indexOf("=");
const value = token.slice(separator + 1);
return value === "" ? [token] : [token.slice(0, separator), value];
}
return [token];
});
/**
* Agent selection for an `mcp add` command, after equals-form expansion.
* Single owner: the project-scope guard and the `mcp add` dispatch in
* `cli.ts` both consume this. Incur consumes command values before agent
* flags, so a `--command` value is never read as an agent.
*/
export const mcpAddAgents = (
command: IncurBuiltinArgumentSplit["command"],
): string[] => {
const agents: string[] = [];
for (let index = 0; index < command.length; index += 1) {
if (
(command[index] === "--command" || command[index] === "-c") &&
command[index + 1]
)
index += 1;
else if (command[index] === "--agent" && command[index + 1])
agents.push(command[++index]!);
}
return agents;
};
/**
* Detect a project-scoped Amp registration before Incur dispatches it.
* Incur's `mcp add` parses `--no-global` into `global: false` and honors it
* for its package-runner agents, but its Amp writer always writes the global
* settings file and ignores the flag. The guard reads the agent selection
* dispatch reads: equals forms expand to spaced forms first through
* `expandMcpAddEqualsArguments`, `--command`/`-c` consumes its value, and
* Amp registers by default when no agent is given, so a bare project-scoped
* request would still write global Amp settings. Discovery requests are not
* registrations: `classifyMcpAddDiscovery` below is the single owner of the
* discovery classification, so the guard cannot drift from the routing it
* mirrors.
*/
// ponytail: reject instead of implementing project-scoped Amp registration.
// Ceiling: `mcp add --no-global` with Amp fails until Incur supports Amp
// project scope. Upgrade: drop this guard and its rea.mjs wiring once the
// dependency writes project Amp settings, and cover that path instead.
/**
* How `mcp add` answers a request instead of registering it. Single owner
* for the discovery flags: the Amp-scope guard and the `mcp add` routing in
* `cli.ts` both consume this classification, so a new discovery switch
* cannot diverge between the guard and the routing. Precedence matches the
* routing: bypass flags skip the `mcp add` special-casing entirely, and
* version beats schema inside it.
*/
export type McpAddDiscoveryHandling = "bypass" | "version" | "schema";
export const classifyMcpAddDiscovery = (
flags: ReadonlySet<string>,
): McpAddDiscoveryHandling | undefined => {
if (
flags.has("--help") ||
flags.has("-h") ||
flags.has("--llms") ||
flags.has("--llms-full")
)
return "bypass";
if (flags.has("--version")) return "version";
if (flags.has("--schema")) return "schema";
return undefined;
};
export const isProjectScopedAmpRegistration = (
arguments_: readonly string[],
): boolean => {
const { command, flags } = splitIncurBuiltinArguments(
expandMcpAddEqualsArguments(arguments_),
);
if (command[0] !== "mcp" || command[1] !== "add") return false;
if (classifyMcpAddDiscovery(flags) !== undefined) return false;
const rest = command.slice(2);
if (!rest.includes("--no-global")) return false;
const agents = mcpAddAgents(rest);
return agents.length === 0 || agents.includes("amp");
};
/** 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);
};
/** Incur accepts the schema key and its kebab-case spelling. */
const FLAGS = ["--sensitive-value", "--sensitiveValue"] as const;
/**
* Read declarations from the tokens Incur leaves for command parsing. Incur
* rejects a malformed token before it reaches later declarations, so this scan
* is independent of parse success. A token following a declaration flag counts
* as declared even where the command parser would bind it to another option.
*/
export const declaredSensitiveValues = (
command: IncurBuiltinArgumentSplit["command"],
): readonly string[] => {
const values = new Set<string>();
for (let index = 0; index < command.length; index += 1) {
const token = command[index]!;
const flag = FLAGS.find(
(candidate) => token === candidate || token.startsWith(`${candidate}=`),
);
if (flag === undefined) continue;
if (token !== flag) values.add(token.slice(flag.length + 1));
else if (index + 1 < command.length) {
values.add(command[index + 1]!);
index += 1;
}
}
values.delete("");
return [...values];
};
/** Shell completion is a transport protocol; its words are not CLI invocations. */
export const CLI_COMPLETION_SHELLS = [
"bash",
"zsh",
"fish",
"nushell",
] as const;
export const cliCompletionShell = (environment: Readonly<NodeJS.ProcessEnv>) =>
CLI_COMPLETION_SHELLS.find((shell) => shell === environment.COMPLETE);