diff --git a/package.json b/package.json index d9bc43929..29da4f1f6 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "bin": "./build/src/index.js", "main": "./build/src/server.js", "scripts": { + "cli:generate": "node --experimental-strip-types scripts/generate-cli.ts", "clean": "node -e \"require('fs').rmSync('build', {recursive: true, force: true})\"", "bundle": "npm run clean && npm run build && rollup -c rollup.config.mjs && node -e \"require('fs').rmSync('build/node_modules', {recursive: true, force: true})\" && node --experimental-strip-types scripts/append-lighthouse-notices.ts", "build": "tsc && node --experimental-strip-types --no-warnings=ExperimentalWarning scripts/post-build.ts", diff --git a/scripts/generate-cli.ts b/scripts/generate-cli.ts new file mode 100644 index 000000000..297a473e6 --- /dev/null +++ b/scripts/generate-cli.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +import {Client} from '@modelcontextprotocol/sdk/client/index.js'; +import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js'; + +import {parseArguments} from '../build/src/cli.js'; +import {labels} from '../build/src/tools/categories.js'; +import {createTools} from '../build/src/tools/tools.js'; + +const OUTPUT_PATH = path.join( + import.meta.dirname, + '../src/bin/cliDefinitions.ts', +); + +async function fetchTools() { + console.log('Connecting to chrome-devtools-mcp to fetch tools...'); + // Use the local build of the server + const serverPath = path.join(import.meta.dirname, '../build/src/index.js'); + + const transport = new StdioClientTransport({ + command: 'node', + args: [serverPath], + env: {...process.env, CHROME_DEVTOOLS_MCP_NO_USAGE_STATISTICS: 'true'}, + }); + + const client = new Client( + { + name: 'chrome-devtools-cli-generator', + version: '0.1.0', + }, + { + capabilities: {}, + }, + ); + + await client.connect(transport); + try { + const toolsResponse = await client.listTools(); + if (!toolsResponse.tools?.length) { + throw new Error(`No tools were fetched`); + } + const tools = toolsResponse.tools || []; + console.log(`Fetched ${tools.length} tools`); + return tools; + } finally { + await client.close(); + } +} + +interface CliOption { + name: string; + type: string; + description: string; + required: boolean; + default?: unknown; + enum?: unknown[]; +} + +interface JsonSchema { + type?: string | string[]; + description?: string; + properties?: Record; + required?: string[]; + default?: unknown; + enum?: unknown[]; +} + +function schemaToCLIOptions(schema: JsonSchema): CliOption[] { + if (!schema || !schema.properties) { + return []; + } + const required = schema.required || []; + const properties = schema.properties; + return Object.entries(properties).map(([name, prop]) => { + const isRequired = required.includes(name); + const description = prop.description || ''; + if (typeof prop.type !== 'string') { + throw new Error( + `Property ${name} has a complex type not supported by CLI.`, + ); + } + return { + name, + type: prop.type, + description, + required: isRequired, + default: prop.default, + enum: prop.enum, + }; + }); +} + +async function generateCli() { + const tools = await fetchTools(); + // Sort tools by name + const sortedTools = tools.sort((a, b) => a.name.localeCompare(b.name)); + + const staticTools = createTools(parseArguments()); + const toolNameToCategory = new Map(); + for (const tool of staticTools) { + toolNameToCategory.set( + tool.name, + labels[tool.annotations.category as keyof typeof labels], + ); + } + + const commands: Record< + string, + {description: string; category: string; args: Record} + > = {}; + + for (const tool of sortedTools) { + const options = schemaToCLIOptions(tool.inputSchema); + const args: Record = {}; + for (const opt of options) { + args[opt.name] = opt; + } + const category = toolNameToCategory.get(tool.name); + if (!category) { + throw new Error(`Tool ${tool.name} has no category.`); + } + if (!tool.description) { + throw new Error(`Tool ${tool.name} is missing descripttion`); + } + commands[tool.name] = { + description: tool.description, + category, + args, + }; + } + + const lines: string[] = []; + lines.push(`/** + * @license + * Copyright ${new Date().getFullYear()} 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 = ${JSON.stringify(commands, null, 2)} as const; +`); + + fs.mkdirSync(path.dirname(OUTPUT_PATH), {recursive: true}); + fs.writeFileSync(OUTPUT_PATH, lines.join('')); + console.log(`Generated CLI at ${OUTPUT_PATH}`); +} + +generateCli().catch(err => { + console.error('Error during generation:', err); + process.exit(1); +});