import { Cli, z } from "incur"; import { captureWebScreenshot, compareWebCaptureEvidence, compareWebScreenshotEvidence, discoverWebMcpTools, } from "./application/BrowserObservationService.js"; import { loadConfiguredPermissionAuthority } from "./application/PermissionConfiguration.js"; import { CdpBrowserProvider } from "./browser/CdpBrowserProvider.js"; import { logCliCommand } from "./cliLogging.js"; import { parseConfig } from "./config.js"; import { AnalysisInputError, projectAnalysisError } from "./domain/errors.js"; import { compareWebCapturesInputSchema } from "./domain/webCaptureDiff.js"; import { discoverWebMcpToolsInputSchema } from "./domain/webMcpDiscovery.js"; import { captureWebScreenshotInputSchema, compareWebScreenshotsInputSchema, } from "./domain/webScreenshot.js"; import type { JsonValue } from "./domain/jsonValue.js"; import type { Logger } from "./logger.js"; import { CLI_COMMANDS } from "./cliCommandNames.js"; const scopeOptions = { allowedOrigins: z .array(z.string().min(1)) .optional() .describe( "Exact origins to observe; defaults to REA_BROWSER_ALLOWED_ORIGINS_JSON", ), approved: z.boolean().default(false).describe("Approve passive observation"), }; /** Register WebMCP, capture-diff, and screenshot CLI equivalents. */ export const registerAdvancedBrowserCommands = ( cli: ReturnType, logger: Logger, ): void => { registerWebMcp(cli, logger); registerCaptureDiff(cli, logger); registerScreenshot(cli, logger); registerScreenshotDiff(cli, logger); }; const registerWebMcp = ( cli: ReturnType, logger: Logger, ): void => { cli.command(CLI_COMMANDS.discoverWebMcpTools, { description: "Passively discover page-declared WebMCP tools", args: z.object({ endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), targetId: z.string().describe("Target ID from list-browser-targets"), }), options: z.object({ ...scopeOptions, observationMs: z .number() .int() .min(0) .max(10_000) .default(100) .describe("Observation duration in milliseconds"), maxTools: z .number() .int() .min(1) .max(5_000) .default(500) .describe("Maximum page-declared WebMCP tools to return"), maxSchemaBytes: z .number() .int() .min(1) .max(1_024 * 1_024) .default(256 * 1_024) .describe("Maximum serialized size of one declared tool schema"), maxSchemaNodes: z .number() .int() .min(1) .max(100_000) .default(5_000) .describe("Maximum nodes in one declared tool schema"), maxSchemaDepth: z .number() .int() .min(1) .max(100) .default(20) .describe("Maximum nesting depth in one declared tool schema"), }), run: ({ args, options }) => logCliCommand(logger, "discover-webmcp-tools", async () => { const context = await browserContext(); if (!context.ok) return context.error; const parsed = discoverWebMcpToolsInputSchema.safeParse({ cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins ?? context.allowedBrowserOrigins, target_id: args.targetId, approved: options.approved, observation_ms: options.observationMs, max_tools: options.maxTools, max_schema_bytes: options.maxSchemaBytes, max_schema_nodes: options.maxSchemaNodes, max_schema_depth: options.maxSchemaDepth, }); if (!parsed.success) return inputError("discover_webmcp_tools"); const result = await discoverWebMcpTools( context.provider, context.authority, parsed.data, ); return result.ok ? result.value : cliError(result.error); }), }); }; const registerCaptureDiff = ( cli: ReturnType, logger: Logger, ): void => { cli.command(CLI_COMMANDS.compareWebCaptures, { description: "Compare two normalized web capture JSON values", args: z.object({ beforeJson: z.string().describe("Earlier normalized web capture JSON"), afterJson: z.string().describe("Later normalized web capture JSON"), }), options: z.object({ maxChanges: z .number() .int() .min(1) .max(20_000) .default(2_000) .describe("Maximum normalized changes to return"), }), run: ({ args, options }) => logCliCommand(logger, "compare-web-captures", async () => { const before = parseJson(args.beforeJson); const after = parseJson(args.afterJson); const parsed = compareWebCapturesInputSchema.safeParse({ before, after, max_changes: options.maxChanges, }); if (!parsed.success) return inputError("compare_web_captures"); const result = await compareWebCaptureEvidence( new CdpBrowserProvider(), parsed.data, ); return result.ok ? result.value : cliError(result.error); }), }); }; const registerScreenshot = ( cli: ReturnType, logger: Logger, ): void => { cli.command(CLI_COMMANDS.captureWebScreenshot, { description: "Capture an explicitly approved visible page viewport", args: z.object({ endpoint: z.string().describe("Configured loopback CDP HTTP endpoint"), targetId: z.string().describe("Target ID from list-browser-targets"), }), options: z.object({ ...scopeOptions, screenshotApproved: z .boolean() .default(false) .describe("Approve capturing the visible page viewport"), maximumImageBytes: z .number() .int() .min(1) .max(8 * 1_024 * 1_024) .default(4 * 1_024 * 1_024) .describe("Maximum captured PNG size"), }), run: ({ args, options }) => logCliCommand(logger, "capture-web-screenshot", async () => { const context = await browserContext(); if (!context.ok) return context.error; const parsed = captureWebScreenshotInputSchema.safeParse({ cdp_endpoint: args.endpoint, allowed_origins: options.allowedOrigins ?? context.allowedBrowserOrigins, target_id: args.targetId, approved: options.approved, screenshot_approved: options.screenshotApproved, maximum_image_bytes: options.maximumImageBytes, }); if (!parsed.success) return inputError("capture_web_screenshot"); const result = await captureWebScreenshot( context.provider, context.authority, parsed.data, ); return result.ok ? result.value : cliError(result.error); }), }); }; const registerScreenshotDiff = ( cli: ReturnType, logger: Logger, ): void => { cli.command(CLI_COMMANDS.compareWebScreenshots, { description: "Compare two self-verifying PNG artifact JSON values", args: z.object({ beforeJson: z.string().describe("Earlier screenshot artifact JSON"), afterJson: z.string().describe("Later screenshot artifact JSON"), }), options: z.object({ channelThreshold: z .number() .int() .min(0) .max(255) .default(0) .describe("Per-channel difference threshold for changed pixels"), maximumPixels: z .number() .int() .min(1) .max(32_000_000) .default(16_000_000) .describe("Maximum decoded pixels per screenshot"), }), run: ({ args, options }) => logCliCommand(logger, "compare-web-screenshots", async () => { const parsed = compareWebScreenshotsInputSchema.safeParse({ before: parseJson(args.beforeJson), after: parseJson(args.afterJson), channel_threshold: options.channelThreshold, maximum_pixels: options.maximumPixels, }); if (!parsed.success) return inputError("compare_web_screenshots"); const result = await compareWebScreenshotEvidence( new CdpBrowserProvider(), parsed.data, ); return result.ok ? result.value : cliError(result.error); }), }); }; const browserContext = async () => { const config = parseConfig(process.env); if (!config.ok) return { ok: false as const, error: cliError(config.error) }; const authority = await loadConfiguredPermissionAuthority(config.value); if (!authority.ok) return { ok: false as const, error: cliError(authority.error) }; return { ok: true as const, authority: authority.value, provider: new CdpBrowserProvider(), allowedBrowserOrigins: config.value.browserAllowedOrigins, }; }; const parseJson = (value: string): unknown => { try { return JSON.parse(value); } catch { return undefined; } }; const inputError = (operation: string): JsonValue => cliError(new AnalysisInputError(operation)); const cliError = ( error: Parameters[0], ): JsonValue => ({ error: "Browser observation failed", ...projectAnalysisError(error), });