fix(cli): pass positional values starting with - through to tools (#3084)

`chrome-devtools fill 1 <uid> -` reported success but cleared the field,
`type_text 1 -` typed an empty string, and values like `--weird` could
not be passed at all, not even after `--`.

yargs re-parses command positionals as `--name value` pairs. In that
pass yargs-parser treats any value starting with `-` (other than a
negative number) as the next flag and substitutes the string default
`''`. yargs also never fills positionals from the tokens after `--`.
Neither can be fixed via configuration (`unknown-options-as-args` and
`nargs` hit the same re-parse), and the upstream fix for the lone `-`
(yargs/yargs-parser#472) is unreleased and only covers that one case.

The CLI now shields such tokens before yargs sees them: a lone `-` is
always shielded, and everything after the first `--` is shielded with
the separator dropped. Shielded tokens are replaced by opaque
placeholders and restored when building the tool arguments. Unknown
flags such as `--weird` or `--pageid` without `--` are still rejected by
strict mode; the error tip and docs/cli.md now explain the `--` form.

Related upstream issues:
- https://github.com/yargs/yargs/issues/1312
- https://github.com/yargs/yargs/issues/1821
- https://github.com/yargs/yargs-parser/pull/472

Fixes #2984
This commit is contained in:
Alex Rudenko authored and GitHub committed 2026-10-14 15:28:32 +00:00
1 parent e1703847fa
commit 9de05ae6e5
7 files changed
+720 -30

No files matched your search

+14 -2
View File
@@ -23,11 +23,19 @@ const OUTPUT_PATH = path.join(
'../src/config/cli-options.ts',
);
/**
* Optional args that intentionally remain positional for backwards-compatible
* CLI syntax. Every required arg is positional; every other optional arg is a
* flag.
*/
const OPTIONAL_POSITIONAL_ARGS = new Set(['evaluate_script:function']);
interface CliOption {
name: string;
type: string;
description: string;
required: boolean;
positional: boolean;
default?: unknown;
enum?: unknown[];
}
@@ -41,7 +49,7 @@ interface JsonSchema {
enum?: unknown[];
}
function schemaToCLIOptions(schema: JsonSchema): CliOption[] {
function schemaToCLIOptions(toolName: string, schema: JsonSchema): CliOption[] {
if (!schema || !schema.properties) {
return [];
}
@@ -60,6 +68,8 @@ function schemaToCLIOptions(schema: JsonSchema): CliOption[] {
type: prop.type,
description,
required: isRequired,
positional:
isRequired || OPTIONAL_POSITIONAL_ARGS.has(`${toolName}:${name}`),
default: prop.default,
enum: prop.enum,
};
@@ -109,7 +119,7 @@ async function generateCli() {
const inputSchema = zod.toJSONSchema(zod.object(tool.schema), {
io: 'input',
}) as JsonSchema;
const options = schemaToCLIOptions(inputSchema);
const options = schemaToCLIOptions(tool.name, inputSchema);
const args: Record<string, CliOption> = {};
for (const opt of options) {
args[opt.name] = opt;
@@ -171,6 +181,8 @@ export interface ArgDef {
type: string;
description: string;
required: boolean;
/** Whether the arg is a positional on the command line, otherwise a flag. */
positional: boolean;
default?: string | number | boolean;
enum?: ReadonlyArray<string | number>;
}
+16 -4
View File
@@ -28,7 +28,11 @@ import {hideBin, yargs, type CallToolResult} from '../third_party/index.js';
import {checkForUpdates} from '../utils/check-for-updates.js';
import {VERSION} from '../version.js';
import {buildCommand, isOptionalPositionalArg} from '../config/cli-commands.js';
import {
buildCommand,
getCommandArgSpec,
shieldLiteralArgs,
} from '../config/cli-commands.js';
import {commands} from '../config/cli-options.js';
import {mcpOptions, getCliOptions} from '../config/mcp-options.js';
@@ -61,7 +65,15 @@ async function start(
logDisclaimers(parsedArgs);
}
const y = yargs(hideBin(process.argv))
const {argv: cliArgs, restore: restoreLiteralArg} = shieldLiteralArgs(
hideBin(process.argv),
commandName => {
const commandDef = commands[commandName];
return commandDef ? getCommandArgSpec(commandDef.args) : undefined;
},
);
const y = yargs(cliArgs)
.locale('en') // Force English to ensure error string matching works in .fail, all custom messages we output are in English anyways
.scriptName('chrome-devtools')
.showHelpOnFail(true)
@@ -212,7 +224,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
? 'array'
: 'string';
if (opt.required || isOptionalPositionalArg(commandName, argName)) {
if (opt.positional) {
const options: PositionalOptions = {
describe: opt.description,
type: type as PositionalOptions['type'],
@@ -255,7 +267,7 @@ for (const [commandName, commandDef] of Object.entries(commands)) {
const commandArgs: Record<string, unknown> = {};
for (const argName of Object.keys(args)) {
if (argName in argv) {
commandArgs[argName] = argv[argName];
commandArgs[argName] = restoreLiteralArg(argv[argName]);
}
}
+193 -16
View File
@@ -6,22 +6,13 @@
import type {ArgDef} from './cli-options.js';
const OPTIONAL_POSITIONAL_ARGS = new Set(['evaluate_script:function']);
export function isOptionalPositionalArg(
commandName: string,
argName: string,
): boolean {
return OPTIONAL_POSITIONAL_ARGS.has(`${commandName}:${argName}`);
}
/**
* Builds the yargs command string and usage line for a CLI command.
*
* Optional flag args are rendered as `[--flag]` in the usage line only: as
* part of the command string yargs parses each one as a trailing positional,
* and a variadic positional has to be the last one. A small set of optional
* args intentionally remain positional for backwards-compatible CLI syntax.
* and a variadic positional has to be the last one. Which args are positional
* is decided at build time by `scripts/generate-cli.ts`.
*/
export function buildCommand(
commandName: string,
@@ -30,14 +21,200 @@ export function buildCommand(
let command = commandName;
let flags = '';
for (const [name, arg] of Object.entries(args)) {
if (arg.required) {
command += arg.type === 'array' ? ` <${name}..>` : ` <${name}>`;
} else if (isOptionalPositionalArg(commandName, name)) {
command += arg.type === 'array' ? ` [${name}..]` : ` [${name}]`;
} else {
if (!arg.positional) {
flags += ` [--${name}]`;
} else if (arg.required) {
command += arg.type === 'array' ? ` <${name}..>` : ` <${name}>`;
} else {
command += arg.type === 'array' ? ` [${name}..]` : ` [${name}]`;
}
}
return {command, usage: `$0 ${command}${flags}`};
}
export type OptionKind = 'boolean' | 'array' | 'value';
/**
* Options that are available on every tool command and are consumed by yargs
* rather than forwarded to the tool.
*/
export const GLOBAL_CLI_OPTIONS: ReadonlyMap<string, OptionKind> = new Map([
['sessionId', 'value'],
['outputFormat', 'value'],
['help', 'boolean'],
['version', 'boolean'],
]);
/**
* Describes what a CLI command accepts, as needed by `shieldLiteralArgs`.
*/
export interface CommandArgSpec {
/** Known option names (camelCase and aliases) and how they take values. */
options: Map<string, OptionKind>;
/** Number of positional arguments. */
positionalCount: number;
/** Whether the last positional is variadic. */
variadic: boolean;
}
/**
* Builds a `CommandArgSpec` for a tool command from its generated arg
* definitions and the options available on every command.
*/
export function getCommandArgSpec(
args: Record<string, ArgDef>,
globalOptions: ReadonlyMap<string, OptionKind> = GLOBAL_CLI_OPTIONS,
): CommandArgSpec {
const options = new Map(globalOptions);
let positionalCount = 0;
let variadic = false;
for (const [name, arg] of Object.entries(args)) {
if (arg.positional) {
positionalCount++;
variadic = arg.type === 'array';
} else {
options.set(name, toOptionKind(arg.type));
}
}
return {options, positionalCount, variadic};
}
export function toOptionKind(type: string | undefined): OptionKind {
if (type === 'boolean') {
return 'boolean';
}
if (type === 'array') {
return 'array';
}
return 'value';
}
const LITERAL_ARG_PREFIX = '\0literal:';
// Mirrors yargs-parser's negative number detection.
const NEGATIVE_NUMBER = /^-\d+(\.\d*)?(e-?\d+)?$/;
function optionName(token: string): string {
let name = token.replace(/^-+/, '');
const eq = name.indexOf('=');
if (eq !== -1) {
name = name.slice(0, eq);
}
if (name.startsWith('no-')) {
name = name.slice(3);
}
return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
/**
* Shields positional values that yargs cannot carry through its parsing.
*
* yargs re-parses command positionals as `--name value` pairs, and in that
* pass yargs-parser treats any value starting with `-` (other than a negative
* number) as the next flag, replacing the value with `''`. It also never fills
* positionals from the tokens after `--`. See
* https://github.com/yargs/yargs/issues/1312 and
* https://github.com/yargs/yargs/issues/1821.
*
* Shielded tokens are replaced by opaque placeholders that `restore` maps back
* to the original values. A token is shielded when:
*
* - it is a lone `-`, which can never be an option;
* - it follows the first `--`, which is dropped so the tokens populate
* positionals;
* - it starts with `-`, is not a known option of the command, and the command
* still has an unfilled positional. Known options and their values are
* skipped the way yargs consumes them, so a dash-prefixed value such as
* `--flag` in a positional slot is passed through as text, while an unknown
* flag after all positionals are filled is still rejected by yargs.
*
* `getSpec` resolves the spec of a command by name and returns `undefined`
* for tokens that are not a command.
*/
export function shieldLiteralArgs(
argv: readonly string[],
getSpec: (commandName: string) => CommandArgSpec | undefined,
): {
argv: string[];
restore: (value: unknown) => unknown;
} {
const literals = new Map<string, string>();
const result: string[] = [];
const shield = (token: string): void => {
const placeholder = `${LITERAL_ARG_PREFIX}${literals.size}`;
literals.set(placeholder, token);
result.push(placeholder);
};
let spec: CommandArgSpec | undefined;
let filled = 0;
let afterDoubleDash = false;
for (let i = 0; i < argv.length; i++) {
const token = argv[i];
if (afterDoubleDash) {
shield(token);
continue;
}
if (token === '--') {
afterDoubleDash = true;
continue;
}
if (token === '-') {
shield(token);
filled++;
continue;
}
if (!token.startsWith('-') || NEGATIVE_NUMBER.test(token)) {
result.push(token);
if (!spec) {
spec = getSpec(token);
} else {
filled++;
}
continue;
}
const kind = spec?.options.get(optionName(token));
if (kind !== undefined) {
result.push(token);
if (token.includes('=')) {
continue;
}
if (kind === 'boolean') {
if (/^(true|false)$/.test(argv[i + 1] ?? '')) {
result.push(argv[++i]);
}
} else if (kind === 'array') {
while (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
result.push(argv[++i]);
}
} else if (i + 1 < argv.length) {
result.push(argv[++i]);
}
continue;
}
const hasUnfilledPositional =
spec !== undefined &&
(filled < spec.positionalCount ||
(spec.variadic && spec.positionalCount > 0));
if (hasUnfilledPositional) {
shield(token);
filled++;
} else {
result.push(token);
}
}
const restore = (value: unknown): unknown => {
if (typeof value === 'string') {
return literals.get(value) ?? value;
}
if (Array.isArray(value)) {
return value.map(restore);
}
return value;
};
return {argv: result, restore};
}
File diff suppressed because it is too large. Load diff
+141 -7
View File
@@ -14,7 +14,11 @@ import {
mcpOptions,
DEFAULT_FILESYSTEM_ROOT,
} from '../src/config/mcp-options.js';
import {buildCommand} from '../src/config/cli-commands.js';
import {
buildCommand,
getCommandArgSpec,
shieldLiteralArgs,
} from '../src/config/cli-commands.js';
import {commands} from '../src/config/cli-options.js';
import {computeFlagUsage} from '../src/telemetry/flagUtils.js';
import {puppeteerOptions} from '../src/config/puppeteer-options.js';
@@ -1264,19 +1268,26 @@ describe('cli args parsing', () => {
});
describe('cli command strings', () => {
const required = {description: '', required: true, positional: true};
const dummyArgsVariadic = {
arg1: {name: 'arg1', type: 'string', description: '', required: true},
arrArg: {name: 'arrArg', type: 'array', description: '', required: true},
arg1: {name: 'arg1', type: 'string', ...required},
arrArg: {name: 'arrArg', type: 'array', ...required},
};
const dummyArgsPlain = {
arg1: {name: 'arg1', type: 'string', description: '', required: true},
arg2: {name: 'arg2', type: 'string', description: '', required: true},
arg1: {name: 'arg1', type: 'string', ...required},
arg2: {name: 'arg2', type: 'string', ...required},
};
const dummyArgsOptional = {
arg1: {name: 'arg1', type: 'string', description: '', required: true},
optArg: {name: 'optArg', type: 'boolean', description: '', required: false},
arg1: {name: 'arg1', type: 'string', ...required},
optArg: {
name: 'optArg',
type: 'boolean',
description: '',
required: false,
positional: false,
},
};
it('renders a required array arg as a variadic positional', () => {
@@ -1336,6 +1347,129 @@ describe('cli command strings', () => {
});
});
describe('cli literal args', () => {
const getSpec = (name: string) =>
name in commands ? getCommandArgSpec(commands[name].args) : undefined;
function shield(argv: string[]) {
const {argv: shielded, restore} = shieldLiteralArgs(argv, getSpec);
return {
shielded,
restored: shielded.map(restore),
literalIndexes: shielded.flatMap((t, i) => (t !== argv[i] ? [i] : [])),
};
}
it('passes regular args through unchanged', () => {
const input = ['fill', '1', '1_1', 'a-b', '-5', '--output-format', 'json'];
assert.deepStrictEqual(shield(input).shielded, input);
});
it('shields a lone dash', () => {
const {shielded, restored} = shield(['fill', '1', '1_1', '-']);
assert.ok(!shielded[3].startsWith('-'));
assert.deepStrictEqual(restored, ['fill', '1', '1_1', '-']);
});
it('shields unknown dash tokens in unfilled positional slots', () => {
for (const value of ['--weird', '-abc', '- item', '--pageId=2']) {
const {shielded, restored} = shield(['fill', '1', '1_1', value]);
assert.ok(!shielded[3].startsWith('-'), value);
assert.deepStrictEqual(restored, ['fill', '1', '1_1', value]);
}
});
it('leaves unknown dash tokens alone once positionals are filled', () => {
const input = ['fill', '1', '1_1', 'x', '--pageid', '2'];
assert.deepStrictEqual(shield(input).shielded, input);
});
it('skips known options and their values', () => {
const input = [
'--sessionId',
'abc',
'click',
'--dblClick',
'true',
'--output-format=json',
'1',
'1_1',
'--help',
];
assert.deepStrictEqual(shield(input).shielded, input);
const {restored, literalIndexes} = shield([
'evaluate_script',
'--pageId',
'1',
'--args',
'a',
'b',
'--weird',
]);
assert.deepStrictEqual(literalIndexes, [6]);
assert.strictEqual(restored[6], '--weird');
});
it('accepts kebab-case and negated forms of known options', () => {
const input = ['click', '1', '1_1', '--dbl-click', '--no-dblClick'];
assert.deepStrictEqual(shield(input).shielded, input);
});
it('fills a variadic positional with any dash tokens', () => {
const {restored, literalIndexes} = shield([
'upload_file',
'1',
'1_1',
'-',
'--a',
'-b',
]);
assert.deepStrictEqual(literalIndexes, [3, 4, 5]);
assert.deepStrictEqual(restored, [
'upload_file',
'1',
'1_1',
'-',
'--a',
'-b',
]);
});
it('shields every token after -- and drops the separator', () => {
const {argv, restore} = shieldLiteralArgs(
['fill', '1', '--', '--weird', '- item', '--', '-abc'],
getSpec,
);
assert.strictEqual(argv.length, 6);
assert.deepStrictEqual(argv.slice(0, 2), ['fill', '1']);
for (const token of argv.slice(2)) {
assert.ok(!token.startsWith('-'), `unexpected token ${token}`);
}
assert.deepStrictEqual(argv.slice(2).map(restore), [
'--weird',
'- item',
'--',
'-abc',
]);
});
it('does not shield unknown flags of non-tool commands', () => {
const input = ['start', '--weird', 'status', '-x'];
assert.deepStrictEqual(shield(input).shielded, input);
});
it('restores values nested in arrays and leaves other values alone', () => {
const {argv, restore} = shieldLiteralArgs(
['cmd', '--', '-a', '-b'],
getSpec,
);
assert.deepStrictEqual(restore([argv[1], argv[2], 'x']), ['-a', '-b', 'x']);
assert.strictEqual(restore(42), 42);
assert.strictEqual(restore(undefined), undefined);
});
});
describe('flag usage telemetry', () => {
it('reports the stable channel for a default launch', async () => {
const usage = computeFlagUsage(parseConfig([]), mcpOptions);
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import assert from 'node:assert';
import crypto from 'node:crypto';
import {describe, it, afterEach, beforeEach} from 'node:test';
import {assertDaemonIsNotRunning, runCli} from '../utils.js';
describe('chrome-devtools positional values starting with a hyphen', () => {
let sessionId: string;
beforeEach(async () => {
sessionId = crypto.randomUUID();
await runCli(['stop'], sessionId);
await assertDaemonIsNotRunning(sessionId);
});
afterEach(async () => {
await runCli(['stop'], sessionId);
await assertDaemonIsNotRunning(sessionId);
});
async function startDaemon(): Promise<void> {
const startResult = await runCli(['start'], sessionId);
assert.strictEqual(
startResult.status,
0,
`start command failed: ${startResult.stderr}`,
);
}
/** Starts the daemon, opens a page with a textarea and returns its uid. */
async function startWithTextarea(): Promise<string> {
await startDaemon();
const navigateResult = await runCli(
[
'navigate_page',
'1',
'--url',
'data:text/html,<textarea id="t">x</textarea>',
],
sessionId,
);
assert.strictEqual(
navigateResult.status,
0,
`navigate_page command failed: ${navigateResult.stderr}`,
);
const snapshotResult = await runCli(['take_snapshot', '1'], sessionId);
assert.strictEqual(
snapshotResult.status,
0,
`take_snapshot command failed: ${snapshotResult.stderr}`,
);
const uidMatch = snapshotResult.stdout.match(/uid=(\S+) textbox/);
assert.ok(
uidMatch,
'textarea uid not found in snapshot: ' + snapshotResult.stdout,
);
return uidMatch[1];
}
async function readTextareaValue(): Promise<string> {
const result = await runCli(
[
'evaluate_script',
'() => document.getElementById("t").value',
'--pageId',
'1',
],
sessionId,
);
assert.strictEqual(
result.status,
0,
`evaluate_script command failed: ${result.stderr}`,
);
const match = result.stdout.match(/"(.*)"/);
assert.ok(match, 'unexpected evaluate_script output: ' + result.stdout);
return JSON.parse(`"${match[1]}"`);
}
const fillCases: Array<{value: string[]; expected: string}> = [
{value: ['-'], expected: '-'},
{value: ['-5'], expected: '-5'},
{value: ['a-b'], expected: 'a-b'},
{value: ['--weird'], expected: '--weird'},
{value: ['- item'], expected: '- item'},
{value: ['-abc'], expected: '-abc'},
{value: ['--', '--weird'], expected: '--weird'},
];
for (const {value, expected} of fillCases) {
it(`fill passes ${JSON.stringify(value.join(' '))} through`, async () => {
const uid = await startWithTextarea();
const args = ['fill', '1', uid, ...value];
const result = await runCli(args, sessionId);
assert.strictEqual(
result.status,
0,
`${args.join(' ')} failed: ${result.stderr}`,
);
assert.strictEqual(await readTextareaValue(), expected);
});
}
it('type_text passes a lone hyphen through', async () => {
await startDaemon();
const result = await runCli(['type_text', '1', '-'], sessionId);
assert.strictEqual(
result.status,
0,
`type_text command failed: ${result.stderr}`,
);
assert.match(result.stdout, /Typed text "-"/);
});
it('press_key passes a lone hyphen through', async () => {
await startDaemon();
const result = await runCli(['press_key', '1', '-'], sessionId);
assert.strictEqual(
result.status,
0,
`press_key command failed: ${result.stderr}`,
);
assert.match(result.stdout, /Successfully pressed key: -/);
});
it('rejects unknown flags that are not preceded by --', async () => {
await startDaemon();
const result = await runCli(
['fill', '1', 'x', 'x', '--pageid', '2'],
sessionId,
);
assert.strictEqual(result.status, 1);
assert.match(result.stderr, /Unknown arguments?: .*pageid/);
});
});
+2 -1
View File
@@ -438,7 +438,8 @@ export async function runCli(
return await new Promise((resolve, reject) => {
const finalArgs = [...args];
if (sessionId) {
finalArgs.push('--sessionId', sessionId);
// Prepend so that the flag is not swallowed by a trailing `--`.
finalArgs.unshift('--sessionId', sessionId);
}
const child = spawn('node', [CLI_PATH, ...finalArgs], {
env: process.env,