diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 8952f208..44989796 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2622,7 +2622,34 @@ struct CMUXCLI { throw CLIError(message: "browser requires a subcommand") } - let (surfaceOpt, argsWithoutSurfaceFlag) = parseOption(commandArgs, name: "--surface") + var effectiveJSONOutput = jsonOutput + var effectiveIDFormat = idFormat + var browserArgs = commandArgs + + // Browser-skill examples often place output flags at the end of the command. + // Strip trailing display flags so they don't become part of a URL or selector. + while !browserArgs.isEmpty { + if browserArgs.last == "--json" { + effectiveJSONOutput = true + browserArgs.removeLast() + continue + } + + if browserArgs.count >= 2, + browserArgs[browserArgs.count - 2] == "--id-format" { + let raw = browserArgs.last! + guard let parsed = try CLIIDFormat.parse(raw) else { + throw CLIError(message: "--id-format must be one of: refs, uuids, both") + } + effectiveIDFormat = parsed + browserArgs.removeLast(2) + continue + } + + break + } + + let (surfaceOpt, argsWithoutSurfaceFlag) = parseOption(browserArgs, name: "--surface") var surfaceRaw = surfaceOpt var args = argsWithoutSurfaceFlag @@ -2651,8 +2678,8 @@ struct CMUXCLI { } func output(_ payload: [String: Any], fallback: String) { - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) return } print(fallback) @@ -2808,8 +2835,8 @@ struct CMUXCLI { } } let payload = try client.sendV2(method: "browser.open_split", params: params) - let surfaceText = formatHandle(payload, kind: "surface", idFormat: idFormat) ?? "unknown" - let paneText = formatHandle(payload, kind: "pane", idFormat: idFormat) ?? "unknown" + let surfaceText = formatHandle(payload, kind: "surface", idFormat: effectiveIDFormat) ?? "unknown" + let paneText = formatHandle(payload, kind: "pane", idFormat: effectiveIDFormat) ?? "unknown" let placement = ((payload["created_split"] as? Bool) == true) ? "split" : "reuse" output(payload, fallback: "OK surface=\(surfaceText) pane=\(paneText) placement=\(placement)") return @@ -2854,8 +2881,8 @@ struct CMUXCLI { if subcommand == "url" || subcommand == "get-url" { let sid = try requireSurface() let payload = try client.sendV2(method: "browser.url.get", params: ["surface_id": sid]) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else { print((payload["url"] as? String) ?? "") } @@ -2872,8 +2899,8 @@ struct CMUXCLI { if ["is-webview-focused", "is_webview_focused"].contains(subcommand) { let sid = try requireSurface() let payload = try client.sendV2(method: "browser.is_webview_focused", params: ["surface_id": sid]) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else { print((payload["focused"] as? Bool) == true ? "true" : "false") } @@ -2906,8 +2933,8 @@ struct CMUXCLI { } let payload = try client.sendV2(method: "browser.snapshot", params: params) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else { print(displaySnapshotText(payload)) } @@ -3118,7 +3145,7 @@ struct CMUXCLI { let sid = try requireSurface() let (outPathOpt, _) = parseOption(subArgs, name: "--out") let localJSONOutput = hasFlag(subArgs, name: "--json") - let outputAsJSON = jsonOutput || localJSONOutput + let outputAsJSON = effectiveJSONOutput || localJSONOutput var payload = try client.sendV2(method: "browser.screenshot", params: ["surface_id": sid]) func fileURL(fromPath rawPath: String) -> URL { @@ -3233,7 +3260,7 @@ struct CMUXCLI { } if outputAsJSON { - let formattedPayload = formatIDs(payload, mode: idFormat) + let formattedPayload = formatIDs(payload, mode: effectiveIDFormat) if var outputPayload = formattedPayload as? [String: Any] { if hasText(screenshotPath) || hasText(screenshotURL) { outputPayload.removeValue(forKey: "png_base64") @@ -3307,8 +3334,8 @@ struct CMUXCLI { "styles": "browser.get.styles", ] let payload = try client.sendV2(method: methodMap[getVerb]!, params: params) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else if let value = payload["value"] { if let str = value as? String { print(str) @@ -3347,8 +3374,8 @@ struct CMUXCLI { throw CLIError(message: "Unsupported browser is subcommand: \(isVerb)") } let payload = try client.sendV2(method: method, params: ["surface_id": sid, "selector": selector]) - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else if let value = payload["value"] { print("\(value)") } else { diff --git a/tests_v2/test_browser_cli_agent_port.py b/tests_v2/test_browser_cli_agent_port.py index 53e40a52..d3cbdf99 100644 --- a/tests_v2/test_browser_cli_agent_port.py +++ b/tests_v2/test_browser_cli_agent_port.py @@ -91,6 +91,32 @@ def _run_cli_text(cli: str, args: list[str], retries: int = 3) -> str: raise cmuxError(f"CLI failed ({' '.join(args)}): {last_merged}") + +def _run_cli_tail_json(cli: str, args: list[str], retries: int = 3) -> dict: + last_merged = "" + for attempt in range(1, retries + 1): + proc = subprocess.run( + [cli, "--socket", SOCKET_PATH] + args, + capture_output=True, + text=True, + check=False, + ) + if proc.returncode == 0: + try: + return json.loads(proc.stdout or "{}") + except Exception as exc: # noqa: BLE001 + raise cmuxError(f"Invalid CLI JSON output for {' '.join(args)}: {proc.stdout!r} ({exc})") + + merged = f"{proc.stdout}\n{proc.stderr}".strip() + last_merged = merged + if "Command timed out" in merged and attempt < retries: + time.sleep(0.2) + continue + raise cmuxError(f"CLI failed ({' '.join(args)}): {merged}") + + raise cmuxError(f"CLI failed ({' '.join(args)}): {last_merged}") + + def _run_cli_expect_failure(cli: str, args: list[str], needles: list[str]) -> None: proc = subprocess.run( [cli, "--socket", SOCKET_PATH, "--json"] + args, @@ -144,15 +170,6 @@ def main() -> int: cli = _find_cli_binary() with _local_test_server() as page_url: - opened = _run_cli_json(cli, ["browser", "open", page_url]) - surface = str(opened.get("surface_ref") or opened.get("surface_id") or "") - _must(bool(surface), f"browser open returned no surface handle: {opened}") - _must(surface.startswith("surface:"), f"Expected short surface ref from browser open, got: {opened}") - - _run_cli_json(cli, ["browser", surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"]) - snapshot_text = _run_cli_text(cli, ["browser", surface, "snapshot", "--interactive"]) - _must("ref=e" in snapshot_text, f"Expected snapshot text with refs from CLI: {snapshot_text!r}") - identify = _run_cli_json(cli, ["identify"]) focused = identify.get("focused") or {} workspace = str( @@ -163,6 +180,28 @@ def main() -> int: or "" ) _must(bool(workspace), f"Expected workspace handle from identify: {identify}") + os.environ["CMUX_WORKSPACE_ID"] = workspace + + opened_tail_json = _run_cli_tail_json( + cli, + ["browser", "open", page_url, "--workspace", workspace, "--id-format", "both", "--json"], + ) + tail_surface = str(opened_tail_json.get("surface_ref") or "") + _must(tail_surface.startswith("surface:"), f"Expected trailing --json browser open to return surface_ref: {opened_tail_json}") + _must(bool(opened_tail_json.get("surface_id")), f"Expected trailing --id-format both to preserve surface_id: {opened_tail_json}") + _must("--json" not in str(opened_tail_json.get("url") or ""), f"Trailing output flags leaked into browser open URL: {opened_tail_json}") + _run_cli_json(cli, ["browser", tail_surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"]) + tail_url_payload = _run_cli_json(cli, ["browser", tail_surface, "url"]) + _must(str(tail_url_payload.get("url") or "").startswith(page_url), f"Expected trailing --json browser open to navigate: {tail_url_payload}") + + opened = _run_cli_json(cli, ["browser", "open", page_url]) + surface = str(opened.get("surface_ref") or opened.get("surface_id") or "") + _must(bool(surface), f"browser open returned no surface handle: {opened}") + _must(surface.startswith("surface:"), f"Expected short surface ref from browser open, got: {opened}") + + _run_cli_json(cli, ["browser", surface, "wait", "--load-state", "complete", "--timeout-ms", "15000"]) + snapshot_text = _run_cli_text(cli, ["browser", surface, "snapshot", "--interactive"]) + _must("ref=e" in snapshot_text, f"Expected snapshot text with refs from CLI: {snapshot_text!r}") blank_opened = _run_cli_json(cli, ["browser", "open", "about:blank", "--workspace", workspace]) blank_surface = str(blank_opened.get("surface_ref") or blank_opened.get("surface_id") or "")