From ed514e82d74e83d23a321b8232fbed6669f22f33 Mon Sep 17 00:00:00 2001 From: Alex Rudenko Date: Mon, 2 Mar 2026 16:17:51 +0100 Subject: [PATCH] chore: implement chrome-devtools CLI --- src/bin/chrome-devtools.ts | 148 ++++++++ src/bin/cliDefinitions.ts | 741 +++++++++++++++++++++++++++++++++++++ src/bin/customHelp.ts | 111 ++++++ src/daemon/client.ts | 6 +- src/daemon/types.ts | 6 + 5 files changed, 1010 insertions(+), 2 deletions(-) create mode 100644 src/bin/chrome-devtools.ts create mode 100644 src/bin/cliDefinitions.ts create mode 100644 src/bin/customHelp.ts diff --git a/src/bin/chrome-devtools.ts b/src/bin/chrome-devtools.ts new file mode 100644 index 000000000..eb967a418 --- /dev/null +++ b/src/bin/chrome-devtools.ts @@ -0,0 +1,148 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import process from 'node:process'; + +import yargs, {type Options, type PositionalOptions} from 'yargs'; +import {hideBin} from 'yargs/helpers'; + +import {startDaemon, stopDaemon, sendCommand} from '../daemon/client.js'; +import {isDaemonRunning} from '../daemon/utils.js'; +import {VERSION} from '../version.js'; + +import {commands} from './cliDefinitions.js'; +import {generateCustomHelp} from './customHelp.js'; + +const argv = hideBin(process.argv); + +if (argv.length === 0 || argv[0] === '--custom-help') { + console.log(generateCustomHelp(VERSION, commands)); + process.exit(0); +} + +const y = yargs(argv) + .scriptName('chrome-devtools') + .showHelpOnFail(true) + .demandCommand() + .version(VERSION) + .strict() + .help(true); + +y.command( + 'start', + 'Start or restart chrome-devtools-mcp', + y => y.help(false), // Disable help for start command to avoid parsing issues with passed args + async () => { + if (isDaemonRunning()) { + await stopDaemon(); + } + // Extract args after 'start' + const startIndex = process.argv.indexOf('start'); + const args = startIndex !== -1 ? process.argv.slice(startIndex + 1) : []; + await startDaemon([...args, '--via-cli']); + }, +); + +y.command('status', 'Checks if chrome-devtools-mcp is running', async () => { + if (isDaemonRunning()) { + console.log('chrome-devtools-mcp daemon is running.'); + } else { + console.log('chrome-devtools-mcp daemon is not running'); + } +}); + +y.command('stop', 'Stop chrome-devtools-mcp if any', async () => { + await stopDaemon(); +}); + +for (const [commandName, commandDef] of Object.entries(commands)) { + const args = commandDef.args; + const requiredArgNames = Object.keys(args).filter( + name => args[name].required, + ); + + let commandStr = commandName; + for (const arg of requiredArgNames) { + commandStr += ` <${arg}>`; + } + + y.command( + commandStr, + commandDef.description, + y => { + for (const [argName, opt] of Object.entries(args)) { + const type = + opt.type === 'integer' || opt.type === 'number' + ? 'number' + : opt.type === 'boolean' + ? 'boolean' + : opt.type === 'array' + ? 'array' + : 'string'; + + if (opt.required) { + const options: PositionalOptions = { + describe: opt.description, + type: type as PositionalOptions['type'], + }; + if (opt.default !== undefined) { + options.default = opt.default; + } + if (opt.enum) { + options.choices = opt.enum as Array; + } + y.positional(argName, options); + } else { + const options: Options = { + describe: opt.description, + type: type as Options['type'], + }; + if (opt.default !== undefined) { + options.default = opt.default; + } + if (opt.enum) { + options.choices = opt.enum as Array; + } + y.option(argName, options); + } + } + }, + async argv => { + try { + if (!isDaemonRunning()) { + await startDaemon(['--via-cli']); + } + + const commandArgs: Record = {}; + for (const argName of Object.keys(args)) { + if (argName in argv) { + commandArgs[argName] = argv[argName]; + } + } + + const response = await sendCommand({ + method: 'invoke_tool', + tool: commandName, + args: commandArgs, + }); + + if (response.success) { + console.log(response.result); + } else { + console.error('Error:', response.error); + process.exit(1); + } + } catch (error) { + console.error('Failed to execute command:', error); + process.exit(1); + } + }, + ); +} + +await y.parse(); diff --git a/src/bin/cliDefinitions.ts b/src/bin/cliDefinitions.ts new file mode 100644 index 000000000..82783bbde --- /dev/null +++ b/src/bin/cliDefinitions.ts @@ -0,0 +1,741 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// NOTE: do not edit manually. Auto-generated by 'npm run cli:generate'. + +export interface ArgDef { + name: string; + type: string; + description: string; + required: boolean; + default?: string | number | boolean; + enum?: ReadonlyArray; +} +export type Commands = Record< + string, + { + description: string; + category: string; + args: Record; + } +>; +export const commands: Commands = { + click: { + description: 'Clicks on the provided element', + category: 'Input automation', + args: { + uid: { + name: 'uid', + type: 'string', + description: + 'The uid of an element on the page from the page content snapshot', + required: true, + }, + dblClick: { + name: 'dblClick', + type: 'boolean', + description: 'Set to true for double clicks. Default is false.', + required: false, + }, + includeSnapshot: { + name: 'includeSnapshot', + type: 'boolean', + description: + 'Whether to include a snapshot in the response. Default is false.', + required: false, + }, + }, + }, + close_page: { + description: + 'Closes the page by its index. The last open page cannot be closed.', + category: 'Navigation automation', + args: { + pageId: { + name: 'pageId', + type: 'number', + description: + 'The ID of the page to close. Call list_pages to list pages.', + required: true, + }, + }, + }, + drag: { + description: 'Drag an element onto another element', + category: 'Input automation', + args: { + from_uid: { + name: 'from_uid', + type: 'string', + description: 'The uid of the element to drag', + required: true, + }, + to_uid: { + name: 'to_uid', + type: 'string', + description: 'The uid of the element to drop into', + required: true, + }, + includeSnapshot: { + name: 'includeSnapshot', + type: 'boolean', + description: + 'Whether to include a snapshot in the response. Default is false.', + required: false, + }, + }, + }, + emulate: { + description: 'Emulates various features on the selected page.', + category: 'Emulation', + args: { + networkConditions: { + name: 'networkConditions', + type: 'string', + description: 'Throttle network. Omit to disable throttling.', + required: false, + enum: ['Offline', 'Slow 3G', 'Fast 3G', 'Slow 4G', 'Fast 4G'], + }, + cpuThrottlingRate: { + name: 'cpuThrottlingRate', + type: 'number', + description: + 'Represents the CPU slowdown factor. Omit or set the rate to 1 to disable throttling', + required: false, + }, + geolocation: { + name: 'geolocation', + type: 'string', + description: + 'Geolocation (`x`) to emulate. Latitude between -90 and 90. Longitude between -180 and 180. Omit clear the geolocation override.', + required: false, + }, + userAgent: { + name: 'userAgent', + type: 'string', + description: + 'User agent to emulate. Set to empty string to clear the user agent override.', + required: false, + }, + colorScheme: { + name: 'colorScheme', + type: 'string', + description: + 'Emulate the dark or the light mode. Set to "auto" to reset to the default.', + required: false, + enum: ['dark', 'light', 'auto'], + }, + viewport: { + name: 'viewport', + type: 'string', + description: + "Emulate device viewports 'xx[,mobile][,touch][,landscape]'. 'touch' and 'mobile' to emulate mobile devices. 'landscape' to emulate landscape mode.", + required: false, + }, + }, + }, + evaluate_script: { + description: + 'Evaluate a JavaScript function inside the currently selected page. Returns the response as JSON,\nso returned values have to be JSON-serializable.', + category: 'Debugging', + args: { + function: { + name: 'function', + type: 'string', + description: + 'A JavaScript function declaration to be executed by the tool in the currently selected page.\nExample without arguments: `() => {\n return document.title\n}` or `async () => {\n return await fetch("example.com")\n}`.\nExample with arguments: `(el) => {\n return el.innerText;\n}`\n', + required: true, + }, + args: { + name: 'args', + type: 'array', + description: 'An optional list of arguments to pass to the function.', + required: false, + }, + }, + }, + fill: { + description: + 'Type text into a input, text area or select an option from a