diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4475f341..e9f8d40b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -2583,7 +2583,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 @@ -2612,8 +2639,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) @@ -2746,8 +2773,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 @@ -2787,8 +2814,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) ?? "") } @@ -2805,8 +2832,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") } @@ -2839,8 +2866,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 if let text = payload["snapshot"] as? String { print(text) } else { @@ -3059,8 +3086,8 @@ struct CMUXCLI { try data.write(to: URL(fileURLWithPath: outPathOpt)) } - if jsonOutput { - print(jsonString(formatIDs(payload, mode: idFormat))) + if effectiveJSONOutput { + print(jsonString(formatIDs(payload, mode: effectiveIDFormat))) } else if let outPathOpt { print("OK \(outPathOpt)") } else { @@ -3120,8 +3147,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) @@ -3160,8 +3187,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 d8266a66..5e33e9b7 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}") opened_routed = _run_cli_json(cli, ["browser", "open", page_url, "--workspace", workspace]) routed_surface = str(opened_routed.get("surface_ref") or opened_routed.get("surface_id") or "")