项目文件夹

文件
2026-07-13 12:55:23 +08:00

449 行
14 KiB
Python

#!/usr/bin/env python3
"""Browser CLI — A command-line interface for browser automation via DOMShell MCP.
This CLI provides filesystem-first browser automation using Chrome's Accessibility Tree.
Navigate web pages using familiar shell commands: ls, cd, cat, grep, click.
Usage:
# One-shot commands
cli-anything-browser page open https://example.com
cli-anything-browser fs ls /
cli-anything-browser act click /main/button[0]
cli-anything-browser --json fs cat /main/title
# Interactive REPL
cli-anything-browser
"""
import sys
import json
import shlex
import click
from typing import Optional
from cli_anything.browser.core.session import Session
from cli_anything.browser.core import page as page_mod
from cli_anything.browser.core import fs as fs_mod
from cli_anything.browser.utils import domshell_backend as backend
# Global state
_session: Optional[Session] = None
_json_output = False
_repl_mode = False
_availability_cached: Optional[tuple[bool, str]] = None # Cache for REPL mode
def get_session() -> Session:
global _session
if _session is None:
_session = Session()
return _session
def output(data, message: str = ""):
if _json_output:
click.echo(json.dumps(data, indent=2, default=str))
else:
if message:
click.echo(message)
if isinstance(data, dict):
_print_dict(data)
elif isinstance(data, list):
_print_list(data)
else:
click.echo(str(data))
def _print_dict(d: dict, indent: int = 0):
prefix = " " * indent
for k, v in d.items():
if isinstance(v, dict):
click.echo(f"{prefix}{k}:")
_print_dict(v, indent + 1)
elif isinstance(v, list):
click.echo(f"{prefix}{k}:")
_print_list(v, indent + 1)
else:
click.echo(f"{prefix}{k}: {v}")
def _print_list(items: list, indent: int = 0):
prefix = " " * indent
for i, item in enumerate(items):
if isinstance(item, dict):
click.echo(f"{prefix}[{i}]")
_print_dict(item, indent + 1)
else:
click.echo(f"{prefix}- {item}")
def handle_error(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except RuntimeError as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "runtime_error"}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
except (ValueError, IndexError) as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
wrapper.__name__ = func.__name__
wrapper.__doc__ = func.__doc__
return wrapper
# ── Main CLI Group ──────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--daemon", "use_daemon", is_flag=True,
help="Use persistent daemon mode (faster for interactive use)")
@click.pass_context
def cli(ctx, use_json, use_daemon):
"""Browser CLI — Filesystem-first browser automation via DOMShell.
Run without a subcommand to enter interactive REPL mode.
"""
global _json_output, _session, _availability_cached
_json_output = use_json
# Check DOMShell availability (skip for help/version to allow viewing docs without DOMShell)
# Cache the result for REPL mode to avoid repeated npx subprocess spawns
if '--help' not in sys.argv and '--version' not in sys.argv:
if _availability_cached is None:
_availability_cached = backend.is_available()
available, msg = _availability_cached
if not available:
if _json_output:
click.echo(json.dumps({"error": msg, "type": "dependency_error"}))
else:
click.echo(f"Error: {msg}", err=True)
click.echo(
"\nInstall DOMShell Chrome extension:\n"
" https://chromewebstore.google.com/detail/domshell"
)
sys.exit(1)
# Initialize session with daemon mode
_session = get_session()
if use_daemon:
try:
backend.start_daemon()
_session.enable_daemon()
if not _json_output:
click.echo("Daemon mode: persistent MCP connection active")
except RuntimeError as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "daemon_error"}))
else:
click.echo(f"Daemon start failed: {e}", err=True)
click.echo("Falling back to per-command mode", err=True)
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
# ── Page Commands ───────────────────────────────────────────────
@cli.group()
def page():
"""Page navigation commands."""
pass
@page.command("open")
@click.argument("url")
@handle_error
def page_open(url):
"""Open a URL in Chrome."""
sess = get_session()
result = page_mod.open_page(sess, url)
output(result, f"Opened: {url}")
@page.command("reload")
@handle_error
def page_reload():
"""Reload the current page."""
sess = get_session()
result = page_mod.reload_page(sess)
output(result, "Page reloaded")
@page.command("back")
@handle_error
def page_back():
"""Navigate back in history."""
sess = get_session()
result = page_mod.go_back(sess)
if "error" in result:
output(result, result["error"])
else:
output(result, "Navigated back")
@page.command("forward")
@handle_error
def page_forward():
"""Navigate forward in history."""
sess = get_session()
result = page_mod.go_forward(sess)
if "error" in result:
output(result, result["error"])
else:
output(result, "Navigated forward")
@page.command("info")
@handle_error
def page_info():
"""Show current page information."""
sess = get_session()
result = page_mod.get_page_info(sess)
output(result)
# ── Filesystem Commands ──────────────────────────────────────────
@cli.group()
def fs():
"""Filesystem navigation commands (Accessibility Tree)."""
pass
@fs.command("ls")
@click.argument("path", default="", required=False)
@handle_error
def fs_ls(path):
"""List elements at a path in the accessibility tree."""
sess = get_session()
result = fs_mod.list_elements(sess, path)
if _json_output:
output(result)
else:
# Surface DOMShell errors (e.g. `fs ls /nonexistent`) before
# falling into the empty-entries "No elements" branch — without
# this, an error from the kernel was hidden behind a misleading
# "No elements at …" message. (Codex P2 R4 on commit 5790651.)
if "error" in result:
click.echo(result["error"], err=True)
return
entries = result.get("entries", [])
if not entries:
click.echo(f"No elements at {path or sess.working_dir}")
return
click.echo(f"{'NAME':<40} {'ROLE':<20} {'PATH'}")
click.echo("─" * 80)
for entry in entries:
name = entry.get("name", "")
role = entry.get("role", "")
entry_path = entry.get("path", "")
click.echo(f"{name:<40} {role:<20} {entry_path}")
@fs.command("cd")
@click.argument("path")
@handle_error
def fs_cd(path):
"""Change directory in the accessibility tree."""
sess = get_session()
result = fs_mod.change_directory(sess, path)
if "error" in result:
output(result, result["error"])
else:
output(result, f"Changed to: {sess.working_dir}")
@fs.command("cat")
@click.argument("path", default="", required=False)
@handle_error
def fs_cat(path):
"""Read element content from the accessibility tree."""
sess = get_session()
result = fs_mod.read_element(sess, path)
output(result)
@fs.command("grep")
@click.argument("pattern")
@click.argument("path", default="", required=False)
@handle_error
def fs_grep(pattern, path):
"""Search for pattern in the accessibility tree."""
sess = get_session()
result = fs_mod.grep_elements(sess, pattern, path)
if _json_output:
output(result)
else:
# Surface DOMShell errors before the empty-matches "No matches"
# branch — same fix shape as fs_ls. Without this, an anchor cd
# failure on rooted grep was hidden behind "No matches".
# (Codex P2 R4 on commit 5790651.)
if "error" in result:
click.echo(result["error"], err=True)
return
matches = result.get("matches", [])
if not matches:
click.echo(f"No matches for '{pattern}'")
return
click.echo(f"Matches for '{pattern}':")
for match in matches:
click.echo(f" {match}")
@fs.command("pwd")
@handle_error
def fs_pwd():
"""Print current working directory in accessibility tree."""
sess = get_session()
click.echo(sess.working_dir)
# ── Action Commands ──────────────────────────────────────────────
@cli.group()
def act():
"""Action commands on elements."""
pass
@act.command("click")
@click.argument("path")
@handle_error
def act_click(path):
"""Click an element at the given path."""
sess = get_session()
use_daemon = sess.daemon_mode
result = backend.click(path, use_daemon=use_daemon, session=sess)
output(result, f"Clicked: {path}")
@act.command("type")
@click.argument("path")
@click.argument("text")
@handle_error
def act_type(path, text):
"""Type text into an input element."""
sess = get_session()
use_daemon = sess.daemon_mode
result = backend.type_text(path, text, use_daemon=use_daemon, session=sess)
output(result, f"Typed into: {path}")
# ── Session Commands ─────────────────────────────────────────────
@cli.group()
def session():
"""Session management commands."""
pass
@session.command("status")
@handle_error
def session_status():
"""Show current session status."""
sess = get_session()
status = sess.status()
output(status)
@session.command("daemon-start")
@handle_error
def session_daemon_start():
"""Start persistent daemon mode."""
try:
backend.start_daemon()
get_session().enable_daemon()
output({"daemon": "started"}, "Daemon mode started")
except RuntimeError as e:
output({"error": str(e)}, str(e))
@session.command("daemon-stop")
@handle_error
def session_daemon_stop():
"""Stop persistent daemon mode."""
backend.stop_daemon()
get_session().disable_daemon()
output({"daemon": "stopped"}, "Daemon mode stopped")
# ── REPL ─────────────────────────────────────────────────────────
@cli.command()
@handle_error
def repl():
"""Start interactive REPL session."""
from cli_anything.browser.utils.repl_skin import ReplSkin
global _repl_mode
_repl_mode = True
skin = ReplSkin("browser", version="1.0.0")
skin.print_banner()
pt_session = skin.create_prompt_session()
_repl_commands = {
"page": "open|reload|back|forward|info",
"fs": "ls|cd|cat|grep|pwd",
"act": "click|type",
"session": "status|daemon-start|daemon-stop",
"help": "Show this help",
"quit": "Exit REPL",
}
while True:
try:
sess = get_session()
# Show URL and working dir in prompt
context = sess.working_dir if sess.working_dir != "/" else "/"
if sess.current_url:
# Truncate long URLs for prompt
url_display = sess.current_url[:40] + "..." if len(sess.current_url) > 40 else sess.current_url
context = f"{url_display} {context}"
line = skin.get_input(pt_session, context=context)
if not line:
continue
if line.lower() in ("quit", "exit", "q"):
skin.print_goodbye()
break
if line.lower() == "help":
skin.help(_repl_commands)
continue
# Parse and execute command (preserve quoted arguments)
try:
args = shlex.split(line)
except ValueError:
args = line.split() # Fallback for unbalanced quotes
# Propagate --json from top-level to subcommands in REPL
if _json_output and '--json' not in args and not any(a.startswith('--json') for a in args):
args = ['--json'] + args
try:
cli.main(args, standalone_mode=False)
except SystemExit:
pass
except click.exceptions.UsageError as e:
skin.warning(f"Usage error: {e}")
except Exception as e:
skin.error(f"{e}")
except (EOFError, KeyboardInterrupt):
skin.print_goodbye()
break
_repl_mode = False
# ── Entry Point ──────────────────────────────────────────────────
def main():
cli()
if __name__ == "__main__":
main()