import { existsSync } from 'node:fs'; import { join } from 'node:path'; import dotenv from 'dotenv'; import type { BaseMidsceneTools } from '../agent-tools/base-tools'; import { TOOL_BEHAVIOR_FLAGS, stripBehaviorFlags, } from '../agent-tools/tool-defaults'; import type { ToolDefinition, ToolResult, ToolResultContent, } from '../agent-tools/types'; import { getDebug } from '../logger'; import { canonicalizeCliArgKeys, formatCliValidationError, getCliOptionDisplay, parseCliArgs, } from './cli-args'; import { CLIError } from './cli-error'; import { writeCliScreenshotFile } from './screenshot-file'; import { cliVerboseErrorMessage, cliVerboseFlag, compactCliVerboseArgs, emitCliVerboseEvent, stripVerboseFlag, withCliVerboseContext, } from './verbose'; const debug = getDebug('cli-runner'); interface CLICommand { name: string; def: ToolDefinition; hidden?: boolean; } export interface CLIExtraCommand { name: string; def: ToolDefinition; aliases?: string[]; hidden?: boolean; } export interface CLIRunnerOptions { stripPrefix?: string; argv?: string[]; version?: string; extraCommands?: CLIExtraCommand[]; } export { parseCliArgs, parseValue } from './cli-args'; export { CLIError, reportCLIError } from './cli-error'; function outputContentItem(item: ToolResultContent, isError: boolean): void { switch (item.type) { case 'text': if (isError) { console.error(item.text); } else { console.log(item.text); } break; case 'image': { const filepath = writeCliScreenshotFile(item.data, { mimeType: item.mimeType, filenamePrefix: 'screenshot', }); console.log(`Screenshot saved: ${filepath}`); emitCliVerboseEvent({ event: 'artifact', kind: 'screenshot', path: filepath, mimeType: item.mimeType, }); break; } default: console.log(`[${item.type} content not displayed in CLI]`); } } function outputResult(result: ToolResult): void { for (const item of result.content) { outputContentItem(item, result.isError ?? false); } } export function removePrefix(name: string, prefix?: string): string { if (prefix && name.startsWith(prefix)) { return name.slice(prefix.length); } return name; } function printCommandHelp(scriptName: string, cmd: CLICommand): void { const { def } = cmd; const positionalUsage = (def.cli?.positionals ?? []) .map((name) => `<${name}>`) .join(' '); console.log( `\nUsage: ${scriptName} ${cmd.name}${positionalUsage ? ` ${positionalUsage}` : ''} [options]\n`, ); console.log(def.description); const globalOptionFlags = new Set( getGlobalOptions().map((option) => option.flag), ); const schemaEntries = Object.entries(def.schema).filter(([key]) => { if ( def.cli?.positionals?.includes(key) || def.cli?.options?.[key]?.hidden ) { return false; } const { label } = getCliOptionDisplay(key, def.cli?.options?.[key]); return !globalOptionFlags.has(label); }); if (schemaEntries.length > 0) { const optionWidth = Math.max( 22, ...schemaEntries.map( ([key]) => getCliOptionDisplay(key, def.cli?.options?.[key]).label.length, ), ); console.log('\nOptions:'); for (const [key, zodType] of schemaEntries) { const { label, aliases } = getCliOptionDisplay( key, def.cli?.options?.[key], ); const desc = zodType.description ?? ''; const aliasText = aliases.length > 0 ? ` (aliases: ${aliases.join(', ')})` : ''; console.log(` ${label.padEnd(optionWidth)} ${desc}${aliasText}`); } } printGlobalOptions(); } function printVersion(scriptName: string, version: string): void { console.log(`${scriptName} v${version}`); } function printHelp( scriptName: string, commands: CLICommand[], version?: string, ): void { if (version) { printVersion(scriptName, version); console.log(''); } console.log(`\nUsage: ${scriptName} [options]\n`); console.log('Commands:'); for (const { name, def } of commands.filter((command) => !command.hidden)) { console.log(` ${name.padEnd(30)} ${def.description}`); } console.log(` ${'version'.padEnd(30)} Show CLI version`); printGlobalOptions(); console.log(`\nRun "${scriptName} --help" for more info.`); } function printGlobalOptions(): void { const options = getGlobalOptions(); const optionWidth = Math.max(...options.map((option) => option.flag.length)); console.log('\nGlobal Options:'); for (const option of options) { console.log(` ${option.flag.padEnd(optionWidth)} ${option.description}`); } } function getGlobalOptions(): Array<{ flag: string; description: string }> { return [ { flag: `--${cliVerboseFlag}`, description: 'Print progress while the command is running. act and record print readable progress by default. Use --verbose=jsonl for structured events.', }, ...TOOL_BEHAVIOR_FLAGS.map((flag) => ({ flag: `--${flag.cli}`, description: flag.description, })), ]; } type AnyMidsceneTools = BaseMidsceneTools; export async function runToolsCLI( tools: AnyMidsceneTools, scriptName: string, options?: CLIRunnerOptions, ): Promise { const inputArgs = options?.argv ?? process.argv.slice(2); debug('CLI invoked: %s %s', scriptName, inputArgs.join(' ')); const { rawArgs: argsWithoutVerbose, verbose, format: verboseFormat, } = stripVerboseFlag(inputArgs); // Global behavior flags (e.g. `--deep-locate` / `--deep-think`) apply // regardless of which command runs. `stripBehaviorFlags` is the single place // that knows how they look on the command line: it resolves their defaults // and returns the remaining args so the per-command parser never sees them. // See https://github.com/web-infra-dev/midscene/issues/2446. const { rawArgs, toolDefaults } = stripBehaviorFlags(argsWithoutVerbose); if (Object.keys(toolDefaults).length > 0) { tools.setToolDefaults?.(toolDefaults); } // Load .env from cwd before any tool initialization const envFile = join(process.cwd(), '.env'); if (existsSync(envFile)) { dotenv.config({ path: envFile }); } await tools.initTools(); const toolDefinitions = [ ...tools.getToolDefinitions(), ...(tools.getCliToolDefinitions?.() ?? []), ]; const commands: CLICommand[] = toolDefinitions.map((def) => ({ name: removePrefix(def.name, options?.stripPrefix).toLowerCase(), def, })); if (options?.extraCommands?.length) { commands.push( ...options.extraCommands.flatMap((cmd) => [ { name: cmd.name.toLowerCase(), def: cmd.def, hidden: cmd.hidden, }, ...(cmd.aliases ?? []).map((alias) => ({ name: alias.toLowerCase(), def: cmd.def, hidden: true, })), ]), ); } const cliVersion = options?.version; const [commandName, ...restArgs] = rawArgs; if (!commandName || commandName === '--help' || commandName === '-h') { debug('showing help (no command or --help flag)'); printHelp(scriptName, commands, cliVersion); return; } if ( commandName === '--version' || commandName === '-v' || commandName.toLowerCase() === 'version' ) { if (!cliVersion) { throw new CLIError('Failed to determine CLI version'); } printVersion(scriptName, cliVersion); return; } const match = commands.find( (c) => c.name.toLowerCase() === commandName.toLowerCase(), ); if (!match) { debug('unknown command: %s', commandName); console.error(`Unknown command: ${commandName}`); printHelp(scriptName, commands, cliVersion); throw new CLIError(`Unknown command: ${commandName}`); } const positionalNames = match.def.cli?.positionals ?? []; const positionalArgs: Record = {}; let optionStartIndex = 0; while ( optionStartIndex < restArgs.length && optionStartIndex < positionalNames.length && !restArgs[optionStartIndex].startsWith('-') ) { positionalArgs[positionalNames[optionStartIndex]] = restArgs[optionStartIndex]; optionStartIndex += 1; } const parsedArgs = { ...positionalArgs, ...parseCliArgs(restArgs.slice(optionStartIndex), match.def), }; if (parsedArgs.help === true) { debug('showing command help for: %s', match.name); printCommandHelp(scriptName, match); return; } const cliValidationError = formatCliValidationError( scriptName, match.name, match.def, parsedArgs, ); if (cliValidationError) { throw new CLIError(cliValidationError); } const handlerArgs = canonicalizeCliArgKeys( scriptName, match.name, match.def, parsedArgs, ); debug('command: %s, args: %s', match.name, JSON.stringify(handlerArgs)); const verboseEnabled = verbose || match.name === 'act' || match.name === 'record'; await withCliVerboseContext( { enabled: verboseEnabled, format: verboseFormat, scriptName, commandName: match.name, startedAt: Date.now(), }, async () => { const startedAt = Date.now(); emitCliVerboseEvent({ event: 'command_start', args: compactCliVerboseArgs(handlerArgs), }); try { const result = await match.def.handler(handlerArgs); debug( 'command %s completed, isError: %s', match.name, result.isError ?? false, ); outputResult(result); emitCliVerboseEvent({ event: 'command_done', status: result.isError ? 'error' : 'ok', durationMs: Date.now() - startedAt, }); if (result.isError) { throw new CLIError('Command failed', 1); } } catch (error) { if ( !(error instanceof CLIError && error.message === 'Command failed') ) { emitCliVerboseEvent({ event: 'command_done', status: 'error', durationMs: Date.now() - startedAt, error: cliVerboseErrorMessage(error), }); } throw error; } finally { await tools.destroy(); } }, ); }