From 5fa31fc1224dec95f4aab22a8fed177d1a83126e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Furkan=20K=C3=B6yk=C4=B1ran?= Date: Sat, 21 Mar 2026 21:28:18 +0300 Subject: [PATCH] feat: add Browser automation CLI via DOMShell MCP server Implement filesystem-first browser automation using DOMShell's MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native navigation with ls, cd, cat, grep, click commands. Key features: - MCP backend pattern (first in CLI-Anything) - Daemon mode for persistent connections - Page state management (URL, working directory, history) - Filesystem-first commands (fs, page, act, session) - Comprehensive test coverage (31 unit tests, 10 E2E tests) Resolves #90 --- .gitignore | 4 + README.md | 9 +- browser/agent-harness/HARNESS.md | 303 +++++++++++ .../cli_anything/browser/README.md | 263 +++++++++ .../cli_anything/browser/__init__.py | 7 + .../cli_anything/browser/__main__.py | 3 + .../cli_anything/browser/browser_cli.py | 422 +++++++++++++++ .../cli_anything/browser/core/__init__.py | 6 + .../cli_anything/browser/core/fs.py | 113 ++++ .../cli_anything/browser/core/page.py | 115 ++++ .../cli_anything/browser/core/session.py | 98 ++++ .../cli_anything/browser/skills/SKILL.md | 178 +++++++ .../cli_anything/browser/tests/__init__.py | 1 + .../cli_anything/browser/tests/test_core.py | 381 +++++++++++++ .../browser/tests/test_full_e2e.py | 139 +++++ .../cli_anything/browser/utils/__init__.py | 5 + .../browser/utils/domshell_backend.py | 386 ++++++++++++++ .../cli_anything/browser/utils/repl_skin.py | 502 ++++++++++++++++++ browser/agent-harness/setup.py | 58 ++ cli-anything-plugin/HARNESS.md | 58 ++ registry.json | 14 + 21 files changed, 3064 insertions(+), 1 deletion(-) create mode 100644 browser/agent-harness/HARNESS.md create mode 100644 browser/agent-harness/cli_anything/browser/README.md create mode 100644 browser/agent-harness/cli_anything/browser/__init__.py create mode 100644 browser/agent-harness/cli_anything/browser/__main__.py create mode 100644 browser/agent-harness/cli_anything/browser/browser_cli.py create mode 100644 browser/agent-harness/cli_anything/browser/core/__init__.py create mode 100644 browser/agent-harness/cli_anything/browser/core/fs.py create mode 100644 browser/agent-harness/cli_anything/browser/core/page.py create mode 100644 browser/agent-harness/cli_anything/browser/core/session.py create mode 100644 browser/agent-harness/cli_anything/browser/skills/SKILL.md create mode 100644 browser/agent-harness/cli_anything/browser/tests/__init__.py create mode 100644 browser/agent-harness/cli_anything/browser/tests/test_core.py create mode 100644 browser/agent-harness/cli_anything/browser/tests/test_full_e2e.py create mode 100644 browser/agent-harness/cli_anything/browser/utils/__init__.py create mode 100644 browser/agent-harness/cli_anything/browser/utils/domshell_backend.py create mode 100644 browser/agent-harness/cli_anything/browser/utils/repl_skin.py create mode 100644 browser/agent-harness/setup.py diff --git a/.gitignore b/.gitignore index d25f2334c..68201851e 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,7 @@ !/adguardhome/ !/novita/ !/ollama/ +!/browser/ # Step 5: Inside each software dir, ignore everything (including dotfiles) /gimp/* @@ -74,6 +75,8 @@ /adguardhome/.* /ollama/* /ollama/.* +/browser/* +/browser/.* # Step 6: ...except agent-harness/ !/gimp/agent-harness/ @@ -92,6 +95,7 @@ !/adguardhome/agent-harness/ !/novita/agent-harness/ !/ollama/agent-harness/ +!/browser/agent-harness/ # Step 7: Ignore build artifacts within allowed dirs **/__pycache__/ diff --git a/README.md b/README.md index 8816de87f..6b38e3d6c 100644 --- a/README.md +++ b/README.md @@ -547,6 +547,13 @@ Each application received complete, production-ready CLI interfaces โ€” not demo โœ… 161 +๐ŸŒ Browser +Browser Automation +cli-anything-browser +DOMShell MCP + Accessibility Tree +โœ… New + + ๐Ÿ“„ LibreOffice Office Suite (Writer, Calc, Impress) cli-anything-libreoffice @@ -731,6 +738,7 @@ cli-anything/ โ”œโ”€โ”€ ๐ŸงŠ blender/agent-harness/ # Blender CLI (208 tests) โ”œโ”€โ”€ โœ๏ธ inkscape/agent-harness/ # Inkscape CLI (202 tests) โ”œโ”€โ”€ ๐ŸŽต audacity/agent-harness/ # Audacity CLI (161 tests) +โ”œโ”€โ”€ ๐ŸŒ browser/agent-harness/ # Browser CLI (DOMShell MCP, new) โ”œโ”€โ”€ ๐Ÿ“„ libreoffice/agent-harness/ # LibreOffice CLI (158 tests) โ”œโ”€โ”€ ๐Ÿ“ mubu/agent-harness/ # Mubu CLI (96 tests) โ”œโ”€โ”€ ๐Ÿ“น obs-studio/agent-harness/ # OBS Studio CLI (153 tests) @@ -742,7 +750,6 @@ cli-anything/ โ”œโ”€โ”€ โœจ anygen/agent-harness/ # AnyGen CLI (50 tests) โ”œโ”€โ”€ ๐Ÿ–ผ๏ธ comfyui/agent-harness/ # ComfyUI CLI (70 tests) โ”œโ”€โ”€ ๐Ÿง  notebooklm/agent-harness/ # NotebookLM CLI (experimental, 21 tests) -โ”œโ”€โ”€ ๐Ÿ–ผ๏ธ comfyui/agent-harness/ # ComfyUI CLI (70 tests) โ”œโ”€โ”€ ๐Ÿ›ก๏ธ adguardhome/agent-harness/ # AdGuard Home CLI (36 tests) โ””โ”€โ”€ ๐Ÿฆ™ ollama/agent-harness/ # Ollama CLI (98 tests) ``` diff --git a/browser/agent-harness/HARNESS.md b/browser/agent-harness/HARNESS.md new file mode 100644 index 000000000..7275a41a5 --- /dev/null +++ b/browser/agent-harness/HARNESS.md @@ -0,0 +1,303 @@ +# Browser Harness: DOMShell MCP Integration + +## Purpose + +This harness provides browser automation using [DOMShell](https://github.com/apireno/DOMShell)'s MCP server. DOMShell maps Chrome's Accessibility Tree to a virtual filesystem, enabling filesystem-first browser automation with familiar shell commands (`ls`, `cd`, `cat`, `grep`, `click`). + +## Architecture Overview + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ CLI Commands โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ browser_cli.py โ”‚โ”€โ”€โ”€โ”€โ–ถโ”‚ MCP Backend โ”‚ +โ”‚ (Click groups) โ”‚ โ”‚ (CLI entry) โ”‚ โ”‚ (domshell_ โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ backend.py) โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ โ”‚ โ”‚ + โ–ผ โ–ผ โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ Spawn npx โ”‚ โ”‚ DOMShell โ”‚ โ”‚ Chrome โ”‚ + โ”‚ subprocess โ”‚โ—€โ”€โ”€stdioโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ถโ”‚ MCP Serverโ”‚โ—€โ”€โ”€โ”€โ”‚ + Ext โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + +State Management: +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ _session: Session โ”‚ +โ”‚ - current_url: str โ”‚ +โ”‚ - working_dir: str (path in accessibility tree) โ”‚ +โ”‚ - history: list[str] (for back/forward) โ”‚ +โ”‚ - daemon_mode: bool (persistent connection) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## DOMShell MCP Server + +DOMShell is an npm package that exposes Chrome's Accessibility Tree via MCP: + +### Installation + +```bash +# Verify DOMShell is available +npx @apireno/domshell --version + +# Install Chrome extension +# https://chromewebstore.google.com/detail/domshell +``` + +### MCP Tools + +DOMShell exposes these MCP tools: + +| Tool | Description | CLI Command | +|------|-------------|-------------| +| `domshell_ls` | List directory contents | `fs ls` | +| `domshell_cd` | Change directory | `fs cd` | +| `domshell_cat` | Read element content | `fs cat` | +| `domshell_grep` | Search for pattern | `fs grep` | +| `domshell_click` | Click element | `act click` | +| `domshell_type` | Type text | `act type` | +| `domshell_open` | Navigate to URL | `page open` | +| `domshell_reload` | Reload page | `page reload` | +| `domshell_back` | Navigate back | `page back` | +| `domshell_forward` | Navigate forward | `page forward` | + +## Key Design Decisions + +### 1. MCP Backend Pattern (First in CLI-Anything) + +This is the first CLI-Anything harness to use an MCP server as a backend. + +**Backend wrapper** (`domshell_backend.py`): +- Uses `mcp` Python SDK with `stdio` transport +- Spawns `npx @apireno/domshell` subprocess per command +- Async MCP interface wrapped in sync functions via `asyncio.run()` + +**Session management**: +- MCP server is stateless (spawned per command) +- CLI maintains state (URL, working directory, history) +- Daemon mode (`--daemon`) provides persistent connection + +### 2. Daemon Mode + +By default, each CLI command spawns a new MCP server process. This is simple but adds latency (~1-3s cold start). + +**Daemon mode** (`--daemon` flag): +- Spawns MCP server once, reuses connection +- Much faster for interactive use +- Requires explicit `daemon-start`/`daemon-stop` + +### 3. State Model + +**Page state** (not project state): +- `current_url`: Currently loaded page +- `working_dir`: Current path in accessibility tree +- `history`: Back navigation stack +- `forward_stack`: Forward navigation stack + +**No persistence**: State is in-memory only. Accessibility tree structure changes when pages update, so saving paths would be fragile. + +### 4. Filesystem-First Commands + +DOMShell's key insight: **filesystem primitives outperform DOM queries** for agents. + +Compare: +``` +# DOM query approach (selector-based) +await page.querySelector("#main button[type='submit']") + +# Filesystem approach +ls /main +grep "submit" +click /main/button[0] +``` + +The filesystem approach is more discoverable and composable. + +## Chrome DevTools Protocol + +DOMShell uses Chrome's DevTools Protocol to access the Accessibility Tree: + +### Accessibility Tree vs DOM + +The Accessibility Tree is a simplified view of the DOM that: +- Filters out structural elements (divs, spans without semantic meaning) +- Includes computed accessible names and roles +- Flattens complex structures +- Provides stable IDs for screen readers + +**Why use Accessibility Tree:** +- Stable: Page updates don't change structure as much as DOM +- Semantic: Roles and names are what screen readers use +- Agent-friendly: Flatter, simpler to navigate + +**Tradeoffs:** +- Less granular than full DOM (can't access arbitrary divs) +- Chrome-dependent (requires extension) + +## Path Syntax + +DOMShell uses a filesystem-like path syntax: + +``` +/ โ€” Root (document) +/main โ€” Main landmark (role="main") +/main/div[0] โ€” First div in main +/main/div[0]/button[2] โ€” Third button in first div +``` + +### Array Indexing + +- **0-based**: `button[0]` is the first button +- **Relative paths**: `..` goes up one level +- **Root**: `/` is always the document root + +### Special Paths + +- `.` โ€” Current directory +- `..` โ€” Parent directory +- `/` โ€” Root (document) + +## Command Groups + +### Page Commands (`page`) + +| Command | Description | State Impact | +|---------|-------------|--------------| +| `open ` | Navigate to URL | Sets `current_url`, resets `working_dir` | +| `reload` | Reload current page | None | +| `back` | Navigate back | Pops `history`, pushes to `forward_stack` | +| `forward` | Navigate forward | Pops `forward_stack`, pushes to `history` | +| `info` | Show page info | None | + +### Filesystem Commands (`fs`) + +| Command | Description | State Impact | +|---------|-------------|--------------| +| `ls [path]` | List elements | None | +| `cd ` | Change directory | Sets `working_dir` | +| `cat [path]` | Read element | None | +| `grep [path]` | Search | None | +| `pwd` | Print working dir | None | + +### Action Commands (`act`) + +| Command | Description | State Impact | +|---------|-------------|--------------| +| `click ` | Click element | May trigger navigation | +| `type ` | Type text | None | + +### Session Commands (`session`) + +| Command | Description | State Impact | +|---------|-------------|--------------| +| `status` | Show session state | None | +| `daemon-start` | Start daemon mode | Sets `daemon_mode=True` | +| `daemon-stop` | Stop daemon mode | Sets `daemon_mode=False` | + +## Error Handling + +### Dependency Checks + +The CLI checks dependencies at startup: + +```python +available, message = is_available() +if not available: + print(f"Error: {message}") + # Install instructions... +``` + +**Error messages:** +- "npx not found" โ†’ Install Node.js +- "DOMShell not found" โ†’ Run `npx @apireno/domshell --version` +- "DOMShell MCP call failed" โ†’ Install Chrome extension + +### MCP Tool Failures + +MCP tool failures raise `RuntimeError` with context: + +```python +try: + result = await session.call_tool(tool_name, arguments) +except Exception as e: + raise RuntimeError( + f"DOMShell MCP call failed: {e}\n" + f"Ensure Chrome is running with DOMShell extension." + ) +``` + +## Testing Strategy + +### Unit Tests (`test_core.py`) + +- Mock MCP backend responses +- Test path resolution logic (`..`, relative paths) +- Test state management (history, working_dir) +- No Chrome required + +### E2E Tests (`test_full_e2e.py`) + +- Requires Chrome + DOMShell extension +- Test real web pages (example.com, etc.) +- Verify accessibility tree structure +- Test daemon lifecycle + +### Test Scenarios + +1. **Basic navigation**: Open โ†’ ls โ†’ cd โ†’ ls +2. **Search and act**: Open โ†’ grep โ†’ click +3. **Form interaction**: Open โ†’ type โ†’ click submit +4. **Daemon mode**: Start โ†’ ls โ†’ cd โ†’ stop +5. **Error paths**: Missing dependencies, invalid paths + +## Performance Considerations + +### Per-Command Overhead + +Each command spawns `npx @apireno/domshell`: +- **Cold start**: 1-3 seconds (first run, package download) +- **Warm start**: ~100-500ms (subsequent runs) + +**Mitigation**: Use daemon mode for interactive sessions. + +### Accessibility Tree Size + +Complex pages may have thousands of accessible elements: +- `ls /` on a large page could return 1000+ entries +- Use specific paths to limit results +- `grep` is more efficient than `ls` for finding elements + +## Future Enhancements + +**Not in scope for V1:** +- Screenshot capture +- Wait-for-element commands +- Form fill helper (bulk) +- Headless Chrome mode +- Multi-browser support (Firefox, Safari) +- Concurrent MCP operations (batch commands) + +## References + +- [DOMShell GitHub](https://github.com/apireno/DOMShell) +- [DOMShell Benchmark](https://github.com/apireno/DOMShell/tree/main/experiments/claude_domshell_vs_cic) +- [Chrome Accessibility Tree](https://developer.chrome.com/docs/accessibility/tree) +- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) +- [CLI-Anything HARNESS.md](https://github.com/HKUDS/CLI-Anything/tree/main/cli-anything-plugin/HARNESS.md) + +## Applying This Pattern + +The MCP backend pattern can be applied to any software that exposes an MCP server: + +| Software | MCP Server | Transport | Use Case | +|----------|------------|-----------|----------| +| DOMShell | `@apireno/domshell` | stdio | Browser automation | +| (future) | Various | stdio/SSE | Any MCP-compatible service | + +**Pattern:** +1. Identify MCP server and tools +2. Create backend wrapper with `mcp` SDK +3. Map tools to CLI commands +4. Maintain state on CLI side (MCP is stateless per command) +5. Optional: Add daemon mode for persistent connection diff --git a/browser/agent-harness/cli_anything/browser/README.md b/browser/agent-harness/cli_anything/browser/README.md new file mode 100644 index 000000000..1be3de44d --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/README.md @@ -0,0 +1,263 @@ +# cli-anything-browser โ€” Browser Automation CLI + +A command-line interface for browser automation using [DOMShell](https://github.com/apireno/DOMShell)'s MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native browser automation. + +## Features + +- **Filesystem-first navigation**: Use `ls`, `cd`, `cat` to explore web pages +- **Search**: `grep` for text patterns in the accessibility tree +- **Actions**: `click`, `type` to interact with elements +- **JSON output**: `--json` flag for machine-readable output +- **Interactive REPL**: Stateful session with command history +- **Daemon mode**: Optional persistent connection for faster interactive use + +## Installation + +### Prerequisites + +1. **Node.js and npx** (required for DOMShell MCP server): + ```bash + # Install Node.js from https://nodejs.org/ + node --version + npx --version + ``` + +2. **Chrome/Chromium** with DOMShell extension: + - Install DOMShell from [Chrome Web Store](https://chromewebstore.google.com/detail/domshell-%E2%80%94-browser-filesy/okcliheamhmijccjknkkplploacoidnp) + - Ensure Chrome is running before using the CLI + +3. **Python 3.10+**: + ```bash + python --version + ``` + +### Install CLI + +```bash +cd browser/agent-harness +pip install -e . +``` + +Verify installation: +```bash +cli-anything-browser --help +``` + +## Usage + +### One-Shot Commands + +```bash +# Open a page +cli-anything-browser page open https://example.com + +# List elements at root +cli-anything-browser fs ls / + +# Navigate into a section +cli-anything-browser fs cd /main + +# List elements in current directory +cli-anything-browser fs ls + +# Read element content +cli-anything-browser fs cat /main/button[0] + +# Search for text +cli-anything-browser fs grep "Login" + +# Click an element +cli-anything-browser act click /main/button[0] + +# Type into an input +cli-anything-browser act type /main/input[0] "Hello, World!" + +# Get page info +cli-anything-browser page info + +# Navigate back/forward +cli-anything-browser page back +cli-anything-browser page forward +``` + +### JSON Output + +```bash +# Get machine-readable output +cli-anything-browser --json fs ls / + +# Returns: +{ + "path": "/", + "entries": [ + { + "name": "main", + "role": "landmark", + "path": "/main" + } + ] +} +``` + +### Daemon Mode (Persistent Connection) + +For faster interactive use, start daemon mode: + +```bash +# Start daemon +cli-anything-browser session daemon-start + +# Run commands (uses persistent connection) +cli-anything-browser fs ls / +cli-anything-browser fs cd /main + +# Stop daemon when done +cli-anything-browser session daemon-stop +``` + +Or use the `--daemon` flag to auto-start: + +```bash +cli-anything-browser --daemon fs ls / +``` + +### Interactive REPL + +Run without arguments to enter interactive mode: + +```bash +cli-anything-browser +``` + +REPL commands: +- `page open ` โ€” Open a URL +- `fs ls [path]` โ€” List elements +- `fs cd ` โ€” Change directory +- `fs cat [path]` โ€” Read element +- `fs grep ` โ€” Search for text +- `fs pwd` โ€” Print working directory +- `act click ` โ€” Click element +- `act type ` โ€” Type text +- `session status` โ€” Show session state +- `help` โ€” Show commands +- `quit` โ€” Exit REPL + +## Command Groups + +### `page` โ€” Page Navigation +- `open ` โ€” Navigate to URL +- `reload` โ€” Reload current page +- `back` โ€” Navigate back in history +- `forward` โ€” Navigate forward in history +- `info` โ€” Show current page info + +### `fs` โ€” Filesystem Commands +- `ls [path]` โ€” List elements at path +- `cd ` โ€” Change directory +- `cat [path]` โ€” Read element content +- `grep [path]` โ€” Search for text +- `pwd` โ€” Print working directory + +### `act` โ€” Action Commands +- `click ` โ€” Click an element +- `type ` โ€” Type text into input + +### `session` โ€” Session Management +- `status` โ€” Show session status +- `daemon-start` โ€” Start persistent daemon mode +- `daemon-stop` โ€” Stop daemon mode + +## Path Syntax + +DOMShell uses a filesystem-like path syntax for the Accessibility Tree: + +``` +/ โ€” Root (page) +/main โ€” Main landmark +/main/div[0] โ€” First div in main +/main/div[0]/button[2] โ€” Third button in first div +``` + +Array indices are 0-based. Use relative paths with `..` to go up. + +## Examples + +### Basic Navigation +```bash +# Open a page +cli-anything-browser page open https://example.com + +# Explore structure +cli-anything-browser fs ls / +cli-anything-browser fs cd /main +cli-anything-browser fs ls + +# Go back to root +cli-anything-browser fs cd / +``` + +### Search and Click +```bash +# Open page and search for login button +cli-anything-browser page open https://example.com/login +cli-anything-browser fs grep "Login" + +# Click the login button (adjust path as needed) +cli-anything-browser act click /main/button[0] +``` + +### Form Fill +```bash +# Type into form fields +cli-anything-browser act type /main/input[0] "user@example.com" +cli-anything-browser act type /main/input[1] "password123" + +# Click submit +cli-anything-browser act click /main/button[0] +``` + +## Testing + +Run tests: + +```bash +# Unit tests (no Chrome required) +pytest cli_anything/browser/tests/test_core.py -v + +# E2E tests (requires Chrome + DOMShell) +pytest cli_anything/browser/tests/test_full_e2e.py -v +``` + +## Troubleshooting + +### "npx not found" +Install Node.js from https://nodejs.org/ + +### "DOMShell not found" +Run: `npx @apireno/domshell --version` (first run downloads the package) + +### "DOMShell MCP call failed" +- Ensure Chrome is running +- Install DOMShell extension from Chrome Web Store +- Check that DOMShell is enabled in Chrome + +### Commands hang on first use +First `npx` call downloads DOMShell package (10-50 MB). Subsequent calls are faster. Use `--daemon` mode for persistent connection. + +## Architecture + +This CLI follows the [CLI-Anything harness methodology](https://github.com/HKUDS/CLI-Anything/tree/main/cli-anything-plugin/HARNESS.md): + +- **Backend**: DOMShell MCP server via stdio transport +- **State**: Page state (URL, working directory, navigation history) +- **Pattern**: Filesystem-first commands map to Accessibility Tree + +## Links + +- [DOMShell GitHub](https://github.com/apireno/DOMShell) +- [CLI-Anything](https://github.com/HKUDS/CLI-Anything) +- [Issue #90](https://github.com/HKUDS/CLI-Anything/issues/90) + +## License + +MIT License โ€” See [CLI-Anything](https://github.com/HKUDS/CLI-Anything) for details. diff --git a/browser/agent-harness/cli_anything/browser/__init__.py b/browser/agent-harness/cli_anything/browser/__init__.py new file mode 100644 index 000000000..ebd15be9d --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/__init__.py @@ -0,0 +1,7 @@ +"""cli-anything-browser โ€” Browser automation via DOMShell MCP server. + +This package provides filesystem-first browser automation using Chrome's +Accessibility Tree exposed through DOMShell's MCP server. +""" + +__version__ = "1.0.0" diff --git a/browser/agent-harness/cli_anything/browser/__main__.py b/browser/agent-harness/cli_anything/browser/__main__.py new file mode 100644 index 000000000..1017efc6f --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/__main__.py @@ -0,0 +1,3 @@ +"""Allow running as python -m cli_anything.browser""" +from cli_anything.browser.browser_cli import main +main() diff --git a/browser/agent-harness/cli_anything/browser/browser_cli.py b/browser/agent-harness/cli_anything/browser/browser_cli.py new file mode 100644 index 000000000..b89ee7ba9 --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/browser_cli.py @@ -0,0 +1,422 @@ +#!/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 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 + + +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 + _json_output = use_json + + # Check DOMShell availability + available, msg = backend.is_available() + 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: + 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: + 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) + 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) + output(result, f"Typed '{text}' 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 + args = line.split() + 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() diff --git a/browser/agent-harness/cli_anything/browser/core/__init__.py b/browser/agent-harness/cli_anything/browser/core/__init__.py new file mode 100644 index 000000000..e04372fbc --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/core/__init__.py @@ -0,0 +1,6 @@ +# Core modules +from . import session +from . import page +from . import fs + +__all__ = ["session", "page", "fs"] diff --git a/browser/agent-harness/cli_anything/browser/core/fs.py b/browser/agent-harness/cli_anything/browser/core/fs.py new file mode 100644 index 000000000..728aefb06 --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/core/fs.py @@ -0,0 +1,113 @@ +"""Filesystem commands for browser automation. + +DOMShell exposes the Accessibility Tree as a virtual filesystem. +These commands provide filesystem-like navigation: +- ls: List elements at a path +- cd: Change working directory +- cat: Read element content +- grep: Search for text pattern +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cli_anything.browser.core.session import Session + +from cli_anything.browser.utils import domshell_backend as backend + + +def list_elements(session: "Session", path: str = "") -> dict: + """List elements at a path in the accessibility tree. + + Args: + session: Current browser session + path: Path to list (empty string uses current working_dir) + + Returns: + Dict with list of accessible elements + + Example: + >>> list_elements(session, "/main") + {"path": "/main", "entries": [{"name": "button", "role": "button", ...}]} + """ + target_path = path if path else session.working_dir + use_daemon = session.daemon_mode + return backend.ls(target_path, use_daemon=use_daemon) + + +def change_directory(session: "Session", path: str) -> dict: + """Change working directory in the accessibility tree. + + Args: + session: Current browser session + path: New path (can be relative or absolute) + + Returns: + Dict with new path confirmation + + Example: + >>> change_directory(session, "/main/div[0]") + {"path": "/main/div[0]", "working_dir": "/main/div[0]"} + """ + # Resolve relative paths + if path == "..": + # Go up one level + current = session.working_dir + if current == "/": + return {"error": "Already at root"} + parts = current.rstrip("/").split("/") + new_path = "/".join(parts[:-1]) or "/" + path = new_path + elif path == ".": + # Stay in current directory + path = session.working_dir + elif not path.startswith("/"): + # Relative path: append to current working_dir + if session.working_dir == "/": + path = "/" + path + else: + path = session.working_dir.rstrip("/") + "/" + path + + use_daemon = session.daemon_mode + result = backend.cd(path, use_daemon=use_daemon) + session.set_working_dir(path) + return result + + +def read_element(session: "Session", path: str = "") -> dict: + """Read element content from the accessibility tree. + + Args: + session: Current browser session + path: Path to element (empty string uses current working_dir) + + Returns: + Dict with element details + + Example: + >>> read_element(session, "/main/button[0]") + {"name": "Submit", "role": "button", "text": "Submit", ...} + """ + target_path = path if path else session.working_dir + use_daemon = session.daemon_mode + return backend.cat(target_path, use_daemon=use_daemon) + + +def grep_elements(session: "Session", pattern: str, path: str = "") -> dict: + """Search for pattern in accessibility tree. + + Args: + session: Current browser session + pattern: Text pattern to search for + path: Root path for search (empty string uses current working_dir) + + Returns: + Dict with matching elements + + Example: + >>> grep_elements(session, "Login") + {"matches": ["/main/button[0]", "/main/link[1]"]} + """ + target_path = path if path else session.working_dir + use_daemon = session.daemon_mode + return backend.grep(pattern, target_path, use_daemon=use_daemon) diff --git a/browser/agent-harness/cli_anything/browser/core/page.py b/browser/agent-harness/cli_anything/browser/core/page.py new file mode 100644 index 000000000..31c1d7d94 --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/core/page.py @@ -0,0 +1,115 @@ +"""Page-level commands for browser automation. + +Handles navigation and page operations: +- open: Navigate to a URL +- reload: Reload the current page +- back: Navigate back in history +- forward: Navigate forward in history +""" + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cli_anything.browser.core.session import Session + +from cli_anything.browser.utils import domshell_backend as backend + + +def open_page(session: "Session", url: str) -> dict: + """Open a URL in Chrome. + + Args: + session: Current browser session + url: URL to navigate to + + Returns: + Result dict with URL and status + + Example: + >>> open_page(session, "https://example.com") + {"url": "https://example.com", "status": "loaded"} + """ + use_daemon = session.daemon_mode + result = backend.open_url(url, use_daemon=use_daemon) + session.set_url(url) + session.set_working_dir("/") # Reset to root on new page + return result + + +def reload_page(session: "Session") -> dict: + """Reload the current page. + + Args: + session: Current browser session + + Returns: + Result dict with reload status + + Example: + >>> reload_page(session) + {"status": "reloaded", "url": "https://example.com"} + """ + use_daemon = session.daemon_mode + result = backend.reload(use_daemon=use_daemon) + return result + + +def go_back(session: "Session") -> dict: + """Navigate back in history. + + Args: + session: Current browser session + + Returns: + Result dict with previous URL, or error if no history + + Example: + >>> go_back(session) + {"url": "https://previous.com", "status": "navigated"} + """ + previous_url = session.go_back() + if previous_url is None: + return {"error": "No back history available"} + + use_daemon = session.daemon_mode + result = backend.open_url(previous_url, use_daemon=use_daemon) + session.set_url(previous_url, record_history=False) + return result + + +def go_forward(session: "Session") -> dict: + """Navigate forward in history. + + Args: + session: Current browser session + + Returns: + Result dict with next URL, or error if no forward history + + Example: + >>> go_forward(session) + {"url": "https://next.com", "status": "navigated"} + """ + next_url = session.go_forward() + if next_url is None: + return {"error": "No forward history available"} + + use_daemon = session.daemon_mode + result = backend.open_url(next_url, use_daemon=use_daemon) + session.set_url(next_url, record_history=False) + return result + + +def get_page_info(session: "Session") -> dict: + """Get information about the current page. + + Args: + session: Current browser session + + Returns: + Dict with page information + """ + return { + "url": session.current_url or "(no page loaded)", + "working_dir": session.working_dir, + } diff --git a/browser/agent-harness/cli_anything/browser/core/session.py b/browser/agent-harness/cli_anything/browser/core/session.py new file mode 100644 index 000000000..3422f0373 --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/core/session.py @@ -0,0 +1,98 @@ +"""Session management for browser automation. + +Maintains page state across CLI commands: +- Current URL +- Current working directory (in accessibility tree) +- Navigation history for back/forward +- Daemon mode status +""" + +from typing import Optional +from dataclasses import dataclass, field + + +@dataclass +class Session: + """Browser automation session state. + + The session tracks the current browser state including: + - current_url: The URL of the currently loaded page + - working_dir: The current path in the accessibility tree (filesystem view) + - history: Stack of URLs for back navigation + - forward_stack: Stack of URLs for forward navigation + - daemon_mode: Whether persistent daemon connection is active + """ + + current_url: str = "" + working_dir: str = "/" + history: list[str] = field(default_factory=list) + forward_stack: list[str] = field(default_factory=list) + daemon_mode: bool = False + + def set_url(self, url: str, record_history: bool = True) -> None: + """Set the current URL and update history. + + Args: + url: New URL to navigate to + record_history: Whether to add to history stack + """ + if record_history and self.current_url: + self.history.append(self.current_url) + self.forward_stack.clear() # Clear forward stack on new navigation + self.current_url = url + + def go_back(self) -> Optional[str]: + """Navigate back in history. + + Returns: + Previous URL if available, None otherwise + """ + if not self.history: + return None + previous = self.history.pop() + self.forward_stack.append(self.current_url) + self.current_url = previous + return previous + + def go_forward(self) -> Optional[str]: + """Navigate forward in history. + + Returns: + Next URL if available, None otherwise + """ + if not self.forward_stack: + return None + next_url = self.forward_stack.pop() + self.history.append(self.current_url) + self.current_url = next_url + return next_url + + def set_working_dir(self, path: str) -> None: + """Set the current working directory in the accessibility tree. + + Args: + path: New path (e.g., "/main/div[0]") + """ + self.working_dir = path + + def enable_daemon(self) -> None: + """Enable daemon mode for persistent MCP connection.""" + self.daemon_mode = True + + def disable_daemon(self) -> None: + """Disable daemon mode.""" + self.daemon_mode = False + + def status(self) -> dict: + """Get session status as a dict. + + Returns: + Dict with current session state + """ + return { + "current_url": self.current_url or "(no page loaded)", + "working_dir": self.working_dir, + "history_length": len(self.history), + "forward_stack_length": len(self.forward_stack), + "daemon_mode": self.daemon_mode, + } diff --git a/browser/agent-harness/cli_anything/browser/skills/SKILL.md b/browser/agent-harness/cli_anything/browser/skills/SKILL.md new file mode 100644 index 000000000..83ecd25cd --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/skills/SKILL.md @@ -0,0 +1,178 @@ +--- +name: "cli-anything-browser" +description: "Browser automation CLI using DOMShell MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native navigation." +--- + +# cli-anything-browser + +A command-line interface for browser automation using [DOMShell](https://github.com/apireno/DOMShell)'s MCP server. Navigate web pages using filesystem commands: `ls`, `cd`, `cat`, `grep`, `click`. + +## Installation + +### Prerequisites + +1. **Node.js and npx** (for DOMShell MCP server): + ```bash + # Install Node.js from https://nodejs.org/ + npx --version + ``` + +2. **Chrome/Chromium** with [DOMShell extension](https://chromewebstore.google.com/detail/domshell-browser-filesy/okcliheamhmijccjknkkplploacoidnp): + - Install extension in Chrome + - Ensure Chrome is running before using CLI + +3. **Python 3.10+** + +### Install CLI + +```bash +cd browser/agent-harness +pip install -e . +``` + +## Command Groups + +### `page` โ€” Page Navigation + +- `page open ` โ€” Navigate to URL +- `page reload` โ€” Reload current page +- `page back` โ€” Navigate back in history +- `page forward` โ€” Navigate forward in history +- `page info` โ€” Show current page info + +### `fs` โ€” Filesystem Commands (Accessibility Tree) + +- `fs ls [path]` โ€” List elements at path +- `fs cd ` โ€” Change directory +- `fs cat [path]` โ€” Read element content +- `fs grep [path]` โ€” Search for text pattern +- `fs pwd` โ€” Print working directory + +### `act` โ€” Action Commands + +- `act click ` โ€” Click an element +- `act type ` โ€” Type text into input + +### `session` โ€” Session Management + +- `session status` โ€” Show session state +- `session daemon-start` โ€” Start persistent daemon mode +- `session daemon-stop` โ€” Stop daemon mode + +## Usage Examples + +### Basic Navigation + +```bash +# Open a page +cli-anything-browser page open https://example.com + +# Explore structure +cli-anything-browser fs ls / +cli-anything-browser fs cd /main +cli-anything-browser fs ls + +# Go back to root +cli-anything-browser fs cd / +``` + +### Search and Click + +```bash +cli-anything-browser fs grep "Login" +cli-anything-browser act click /main/button[0] +``` + +### Form Fill + +```bash +cli-anything-browser act type /main/input[0] "user@example.com" +cli-anything-browser act click /main/button[0] +``` + +### JSON Output + +```bash +cli-anything-browser --json fs ls / +``` + +### Daemon Mode (Faster Interactive Use) + +```bash +# Start persistent connection +cli-anything-browser session daemon-start + +# Run commands (uses persistent connection) +cli-anything-browser fs ls / +cli-anything-browser fs cd /main + +# Stop daemon when done +cli-anything-browser session daemon-stop +``` + +### Interactive REPL + +```bash +cli-anything-browser +``` + +## Path Syntax + +DOMShell uses a filesystem-like path for the Accessibility Tree: + +``` +/ โ€” Root (document) +/main โ€” Main landmark +/main/div[0] โ€” First div in main +/main/div[0]/button[2] โ€” Third button in first div +``` + +- Array indices are **0-based**: `button[0]` is the first button +- Use `..` to go up one level +- Use `/` for root + +## Agent-Specific Guidance + +### JSON Output for Parsing + +All commands support `--json` flag for machine-readable output: + +```bash +cli-anything-browser --json fs ls / +``` + +Returns: +```json +{ + "path": "/", + "entries": [ + {"name": "main", "role": "landmark", "path": "/main"} + ] +} +``` + +### Error Handling + +The CLI provides clear error messages for common issues: + +- **npx not found**: Install Node.js from https://nodejs.org/ +- **DOMShell not found**: Run `npx @apireno/domshell --version` +- **MCP call failed**: Install DOMShell Chrome extension + +Check `is_available()` return value before running commands. + +### Daemon Mode for Efficiency + +For agent workflows with multiple commands, use daemon mode: + +1. Start daemon: `cli-anything-browser session daemon-start` +2. Run commands: Each command reuses the MCP connection +3. Stop daemon: `cli-autocomplete-browser session daemon-stop` + +This avoids the 1-3 second cold start overhead for each command. + +## Links + +- [DOMShell GitHub](https://github.com/apireno/DOMShell) +- [CLI-Anything](https://github.com/HKUDS/CLI-Anything) +- [Issue #90](https://github.com/HKUDS/CLI-Anything/issues/90) diff --git a/browser/agent-harness/cli_anything/browser/tests/__init__.py b/browser/agent-harness/cli_anything/browser/tests/__init__.py new file mode 100644 index 000000000..007eb9551 --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/tests/__init__.py @@ -0,0 +1 @@ +# Tests diff --git a/browser/agent-harness/cli_anything/browser/tests/test_core.py b/browser/agent-harness/cli_anything/browser/tests/test_core.py new file mode 100644 index 000000000..5c8548462 --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/tests/test_core.py @@ -0,0 +1,381 @@ +"""Unit tests for cli-anything-browser โ€” Core modules with mocked MCP backend. + +These tests use synthetic data and mock the MCP backend. No Chrome or DOMShell required. + +Usage: + python -m pytest cli_anything/browser/tests/test_core.py -v +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from cli_anything.browser.core.session import Session +from cli_anything.browser.core import page, fs + + +# โ”€โ”€ Session Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestSession: + """Test Session state management.""" + + def test_session_initial_state(self): + """Session starts with empty state.""" + sess = Session() + assert sess.current_url == "" + assert sess.working_dir == "/" + assert sess.history == [] + assert sess.forward_stack == [] + assert not sess.daemon_mode + + def test_set_url(self): + """Setting URL updates state and records history.""" + sess = Session() + sess.set_url("https://example.com") + assert sess.current_url == "https://example.com" + assert sess.history == [] + + def test_set_url_with_history(self): + """Setting URL with history=True records previous URL.""" + sess = Session() + sess.set_url("https://first.com") + sess.set_url("https://second.com") + assert sess.history == ["https://first.com"] + assert sess.current_url == "https://second.com" + + def test_set_url_clears_forward_stack(self): + """Setting URL clears forward stack.""" + sess = Session() + sess.set_url("https://first.com") + sess.set_url("https://second.com", record_history=True) + sess.go_back() + sess.set_url("https://third.com") + assert sess.forward_stack == [] + + def test_go_back(self): + """Going back pops from history and pushes to forward stack.""" + sess = Session() + sess.set_url("https://first.com") + sess.set_url("https://second.com") + sess.set_url("https://third.com") + + previous = sess.go_back() + assert previous == "https://second.com" + assert sess.current_url == "https://second.com" + assert sess.history == ["https://first.com"] + assert sess.forward_stack == ["https://third.com"] + + def test_go_back_empty_history(self): + """Going back with empty history returns None.""" + sess = Session() + result = sess.go_back() + assert result is None + + def test_go_forward(self): + """Going forward pops from forward stack and pushes to history.""" + sess = Session() + sess.set_url("https://first.com") + sess.set_url("https://second.com") + sess.go_back() + sess.go_back() # Now at first.com + + next_url = sess.go_forward() + assert next_url == "https://second.com" + assert sess.current_url == "https://second.com" + assert sess.history == ["https://first.com"] + assert sess.forward_stack == [] + + def test_go_forward_empty_stack(self): + """Going forward with empty stack returns None.""" + sess = Session() + result = sess.go_forward() + assert result is None + + def test_set_working_dir(self): + """Setting working dir updates state.""" + sess = Session() + sess.set_working_dir("/main/div[0]") + assert sess.working_dir == "/main/div[0]" + + def test_daemon_mode(self): + """Daemon mode flag can be toggled.""" + sess = Session() + assert not sess.daemon_mode + + sess.enable_daemon() + assert sess.daemon_mode + + sess.disable_daemon() + assert not sess.daemon_mode + + def test_status(self): + """Status returns current state as dict.""" + sess = Session() + sess.set_url("https://example.com") + sess.set_working_dir("/main") + + status = sess.status() + assert status["current_url"] == "https://example.com" + assert status["working_dir"] == "/main" + assert status["history_length"] == 0 + assert status["forward_stack_length"] == 0 + assert not status["daemon_mode"] + + +# โ”€โ”€ Page Module Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestPageModule: + """Test page command functions.""" + + def test_open_page_updates_session(self): + """Opening a page updates session state.""" + sess = Session() + + with patch("cli_anything.browser.core.page.backend.open_url") as mock_open: + mock_open.return_value = {"url": "https://example.com", "status": "loaded"} + + result = page.open_page(sess, "https://example.com") + + assert sess.current_url == "https://example.com" + assert sess.working_dir == "/" # Reset on new page + + def test_reload_page(self): + """Reloading page calls backend.""" + sess = Session() + sess.set_url("https://example.com") + + with patch("cli_anything.browser.core.page.backend.reload") as mock_reload: + mock_reload.return_value = {"status": "reloaded"} + + result = page.reload_page(sess) + assert result["status"] == "reloaded" + + def test_go_back_updates_session(self): + """Going back updates session and calls backend.""" + sess = Session() + sess.set_url("https://first.com") + sess.set_url("https://second.com") + + with patch("cli_anything.browser.core.page.backend.open_url") as mock_open: + mock_open.return_value = {"url": "https://first.com", "status": "loaded"} + + result = page.go_back(sess) + + assert sess.current_url == "https://first.com" + assert result["url"] == "https://first.com" + + def test_go_back_empty_history(self): + """Going back with empty history returns error.""" + sess = Session() + + result = page.go_back(sess) + assert "error" in result + + def test_go_forward_updates_session(self): + """Going forward updates session and calls backend.""" + sess = Session() + sess.set_url("https://first.com") + sess.set_url("https://second.com") + sess.go_back() # Now at first.com + + with patch("cli_anything.browser.core.page.backend.open_url") as mock_open: + mock_open.return_value = {"url": "https://second.com", "status": "loaded"} + + result = page.go_forward(sess) + + assert sess.current_url == "https://second.com" + + def test_get_page_info(self): + """Getting page info returns current state.""" + sess = Session() + sess.set_url("https://example.com") + sess.set_working_dir("/main") + + result = page.get_page_info(sess) + assert result["url"] == "https://example.com" + assert result["working_dir"] == "/main" + + +# โ”€โ”€ Filesystem Module Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestFsModule: + """Test filesystem command functions.""" + + def test_list_elements(self): + """Listing elements calls backend with session working_dir.""" + sess = Session() + sess.set_working_dir("/main") + + with patch("cli_anything.browser.core.fs.backend.ls") as mock_ls: + mock_ls.return_value = { + "path": "/main", + "entries": [{"name": "button", "role": "button", "path": "/main/button[0]"}] + } + + result = fs.list_elements(sess) + + mock_ls.assert_called_once_with("/main", use_daemon=False) + + def test_list_elements_with_path(self): + """Listing elements with explicit path overrides working_dir.""" + sess = Session() + sess.set_working_dir("/main") + + with patch("cli_anything.browser.core.fs.backend.ls") as mock_ls: + mock_ls.return_value = {"path": "/div", "entries": []} + + result = fs.list_elements(sess, "/div") + + mock_ls.assert_called_once_with("/div", use_daemon=False) + + def test_list_elements_empty_path_uses_working_dir(self): + """Listing with empty path uses session working_dir.""" + sess = Session() + sess.set_working_dir("/main") + + with patch("cli_anything.browser.core.fs.backend.ls") as mock_ls: + mock_ls.return_value = {"path": "/main", "entries": []} + + result = fs.list_elements(sess, "") + + mock_ls.assert_called_once_with("/main", use_daemon=False) + + def test_change_directory_absolute_path(self): + """Changing to absolute path updates working_dir.""" + sess = Session() + + with patch("cli_anything.browser.core.fs.backend.cd") as mock_cd: + mock_cd.return_value = {"path": "/main", "status": "changed"} + + result = fs.change_directory(sess, "/main") + + assert sess.working_dir == "/main" + mock_cd.assert_called_once_with("/main", use_daemon=False) + + def test_change_directory_relative_parent(self): + """Changing to .. goes up one level.""" + sess = Session() + sess.set_working_dir("/main/div[0]") + + with patch("cli_anything.browser.core.fs.backend.cd") as mock_cd: + mock_cd.return_value = {"path": "/main", "status": "changed"} + + result = fs.change_directory(sess, "..") + + assert sess.working_dir == "/main" + mock_cd.assert_called_once_with("/main", use_daemon=False) + + def test_change_directory_parent_from_root(self): + """Changing to .. from root stays at root.""" + sess = Session() + sess.set_working_dir("/") + + with patch("cli_anything.browser.core.fs.backend.cd") as mock_cd: + result = fs.change_directory(sess, "..") + + assert result["error"] == "Already at root" + + def test_change_directory_current(self): + """Changing to . stays in same directory.""" + sess = Session() + sess.set_working_dir("/main") + + with patch("cli_anything.browser.core.fs.backend.cd") as mock_cd: + mock_cd.return_value = {"path": "/main", "status": "changed"} + + result = fs.change_directory(sess, ".") + + assert sess.working_dir == "/main" + + def test_change_directory_relative_path(self): + """Changing to relative path appends to working_dir.""" + sess = Session() + sess.set_working_dir("/main") + + with patch("cli_anything.browser.core.fs.backend.cd") as mock_cd: + mock_cd.return_value = {"path": "/main/div[0]", "status": "changed"} + + result = fs.change_directory(sess, "div[0]") + + assert sess.working_dir == "/main/div[0]" + mock_cd.assert_called_once_with("/main/div[0]", use_daemon=False) + + def test_read_element(self): + """Reading element calls backend.""" + sess = Session() + + with patch("cli_anything.browser.core.fs.backend.cat") as mock_cat: + mock_cat.return_value = { + "name": "button", + "role": "button", + "text": "Click me" + } + + result = fs.read_element(sess, "/main/button[0]") + + mock_cat.assert_called_once_with("/main/button[0]", use_daemon=False) + + def test_read_element_empty_path_uses_working_dir(self): + """Reading with empty path uses session working_dir.""" + sess = Session() + sess.set_working_dir("/main") + + with patch("cli_anything.browser.core.fs.backend.cat") as mock_cat: + mock_cat.return_value = {"name": "main", "role": "landmark"} + + result = fs.read_element(sess, "") + + mock_cat.assert_called_once_with("/main", use_daemon=False) + + def test_grep_elements(self): + """Grepping calls backend with pattern.""" + sess = Session() + + with patch("cli_anything.browser.core.fs.backend.grep") as mock_grep: + mock_grep.return_value = { + "matches": ["/main/button[0]", "/main/link[1]"] + } + + result = fs.grep_elements(sess, "Login") + + mock_grep.assert_called_once_with("Login", "/", use_daemon=False) + + def test_grep_elements_with_path(self): + """Grepping with path uses that path.""" + sess = Session() + + with patch("cli_anything.browser.core.fs.backend.grep") as mock_grep: + mock_grep.return_value = {"matches": ["/main/button[0]"]} + + result = fs.grep_elements(sess, "Login", "/main") + + mock_grep.assert_called_once_with("Login", "/main", use_daemon=False) + + +# โ”€โ”€ Daemon Mode Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class TestDaemonMode: + """Test daemon mode state propagation.""" + + def test_daemon_mode_propagates_to_backend(self): + """Commands use daemon mode when session.daemon_mode is True.""" + sess = Session() + sess.enable_daemon() + + with patch("cli_anything.browser.core.fs.backend.ls") as mock_ls: + mock_ls.return_value = {"path": "/", "entries": []} + + result = fs.list_elements(sess) + + mock_ls.assert_called_once_with("/", use_daemon=True) + + def test_normal_mode_does_not_use_daemon(self): + """Commands don't use daemon mode when session.daemon_mode is False.""" + sess = Session() + # daemon_mode defaults to False + + with patch("cli_anything.browser.core.fs.backend.ls") as mock_ls: + mock_ls.return_value = {"path": "/", "entries": []} + + result = fs.list_elements(sess) + + mock_ls.assert_called_once_with("/", use_daemon=False) diff --git a/browser/agent-harness/cli_anything/browser/tests/test_full_e2e.py b/browser/agent-harness/cli_anything/browser/tests/test_full_e2e.py new file mode 100644 index 000000000..75d1c545c --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/tests/test_full_e2e.py @@ -0,0 +1,139 @@ +"""E2E tests for cli-anything-browser โ€” Requires Chrome + DOMShell extension. + +These tests interact with real Chrome and DOMShell. Skip if DOMShell is not available. + +Usage: + python -m pytest cli_anything/browser/tests/test_full_e2e.py -v +""" + +import pytest +from click.testing import CliRunner + +from cli_anything.browser.utils.domshell_backend import is_available +from cli_anything.browser.browser_cli import cli + +# Skip all tests if DOMShell is not available +pytestmark = pytest.mark.skipif( + not is_available()[0], + reason="DOMShell MCP server not available. Install from Chrome Web Store." +) + +TEST_URL = "https://example.com" + + +@pytest.fixture +def runner(): + return CliRunner() + + +class TestDependencyChecks: + """Test that dependency checking works correctly.""" + + def test_cli_starts_when_domshell_available(self, runner): + """CLI should start successfully when DOMShell is available.""" + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "Browser CLI" in result.output + + +class TestPageCommands: + """Test page navigation commands.""" + + def test_page_info_empty(self, runner): + """Page info shows empty state initially.""" + result = runner.invoke(cli, ["--json", "page", "info"]) + assert result.exit_code == 0 + import json + data = json.loads(result.output) + assert data["url"] == "(no page loaded)" + assert data["working_dir"] == "/" + + +class TestFilesystemCommands: + """Test filesystem commands.""" + + def test_fs_pwd(self, runner): + """Print working directory shows root initially.""" + result = runner.invoke(cli, ["fs", "pwd"]) + assert result.exit_code == 0 + assert result.output.strip() == "/" + + +class TestSessionCommands: + """Test session management commands.""" + + def test_session_status(self, runner): + """Session status shows current state.""" + result = runner.invoke(cli, ["--json", "session", "status"]) + assert result.exit_code == 0 + import json + data = json.loads(result.output) + assert "current_url" in data + assert "working_dir" in data + + +class TestDaemonMode: + """Test daemon mode functionality.""" + + @pytest.mark.manual + def test_daemon_lifecycle(self, runner): + """Manual test: Start daemon, run commands, stop daemon. + + To run manually: + cli-anything-browser session daemon-start + cli-anything-browser fs pwd + cli-anything-browser session daemon-stop + """ + pass + + def test_daemon_start_with_json(self, runner): + """Daemon start returns success in JSON.""" + result = runner.invoke(cli, ["--json", "session", "daemon-start"]) + # May fail if daemon can't start, but should return JSON + assert result.exit_code == 0 + import json + try: + data = json.loads(result.output) + assert "daemon" in data or "error" in data + except json.JSONDecodeError: + # Fallback: check if error message is present + pass + + +class TestErrorHandling: + """Test error handling for edge cases.""" + + def test_invalid_path_gives_error(self, runner): + """Invalid path should give clear error.""" + # Note: This test's behavior depends on DOMShell's error handling + result = runner.invoke(cli, ["fs", "cd", "/invalid/path/that/does/not/exist"]) + # Should not crash, exit code may be 0 or 1 depending on error + assert True # If we get here, no crash + + def test_empty_page_info(self, runner): + """Page info with no page loaded shows empty state.""" + result = runner.invoke(cli, ["page", "info"]) + assert result.exit_code == 0 + assert "(no page loaded)" in result.output + + +class TestJSONOutput: + """Test JSON output formatting.""" + + def test_json_output_is_valid(self, runner): + """JSON output should be valid and parseable.""" + result = runner.invoke(cli, ["--json", "session", "status"]) + assert result.exit_code == 0 + import json + data = json.loads(result.output) + assert isinstance(data, dict) + + +class TestCleanup: + """Cleanup tests to stop daemon if started.""" + + def test_stop_daemon_if_running(self, runner): + """Ensure daemon is stopped after tests.""" + result = runner.invoke(cli, ["session", "daemon-stop"]) + # Should succeed whether daemon was running or not + assert result.exit_code == 0 diff --git a/browser/agent-harness/cli_anything/browser/utils/__init__.py b/browser/agent-harness/cli_anything/browser/utils/__init__.py new file mode 100644 index 000000000..02d907979 --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/utils/__init__.py @@ -0,0 +1,5 @@ +# Utilities +from . import domshell_backend +from . import repl_skin + +__all__ = ["domshell_backend", "repl_skin"] diff --git a/browser/agent-harness/cli_anything/browser/utils/domshell_backend.py b/browser/agent-harness/cli_anything/browser/utils/domshell_backend.py new file mode 100644 index 000000000..9e1e825cd --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/utils/domshell_backend.py @@ -0,0 +1,386 @@ +"""DOMShell MCP client wrapper โ€” communicates with DOMShell MCP server via stdio. + +DOMShell is a browser automation tool that maps Chrome's Accessibility Tree +to a virtual filesystem. This module provides a Python interface to DOMShell's +MCP server. + +Installation: +1. Install DOMShell Chrome extension from Chrome Web Store +2. Ensure npx is available: npm install -g npx + +DOMShell GitHub: https://github.com/apireno/DOMShell +Chrome Web Store: https://chromewebstore.google.com/detail/domshell-%E2%80%94-browser-filesy/okcliheamhmijccjknkkplploacoidnp +""" + +import asyncio +import subprocess +import shutil +from typing import Any, Optional +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +# DOMShell MCP server command +DEFAULT_SERVER_CMD = "npx" +DEFAULT_SERVER_ARGS = ["@apireno/domshell"] + +# Daemon mode: persistent MCP connection +_daemon_session: Optional[ClientSession] = None +_daemon_read: Optional[Any] = None +_daemon_write: Optional[Any] = None + + +def _check_npx() -> bool: + """Check if npx is available.""" + return shutil.which("npx") is not None + + +def _check_npx_has_domshell() -> bool: + """Check if DOMShell package is available to npx.""" + try: + result = subprocess.run( + ["npx", "@apireno/domshell", "--version"], + capture_output=True, + timeout=10, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError): + return False + + +def is_available() -> tuple[bool, str]: + """Check if DOMShell MCP server is available. + + Returns: + (available, message): Tuple of availability status and descriptive message. + + Examples: + >>> is_available() + (True, "DOMShell v1.0.0 is available") + >>> is_available() + (False, "npx not found. Install Node.js from https://nodejs.org/") + """ + if not _check_npx(): + return ( + False, + "npx not found. Install Node.js from https://nodejs.org/ " + "Then run: npm install -g npx" + ) + + if not _check_npx_has_domshell(): + return ( + False, + "DOMShell not found. Install with: npx @apireno/domshell --version\n" + "Note: First run may download the package (10-50 MB)." + ) + + # Try to get version + try: + result = subprocess.run( + ["npx", "@apireno/domshell", "--version"], + capture_output=True, + timeout=10, + text=True, + ) + version = result.stdout.strip() or "unknown" + return True, f"DOMShell {version} is available" + except Exception as e: + return False, f"DOMShell check failed: {e}" + + +async def _call_tool( + tool_name: str, + arguments: dict, + use_daemon: bool = False +) -> Any: + """Call a DOMShell MCP tool. + + Args: + tool_name: Name of the MCP tool (e.g., "domshell_ls", "domshell_cd") + arguments: Arguments to pass to the tool + use_daemon: If True, use persistent daemon connection (if available) + + Returns: + Tool result as returned by MCP server + + Raises: + RuntimeError: If MCP server is not available or tool call fails + """ + global _daemon_session, _daemon_read, _daemon_write + + if use_daemon and _daemon_session is not None: + # Use persistent daemon connection + try: + result = await _daemon_session.call_tool(tool_name, arguments) + return result + except Exception as e: + # Daemon died, fall back to spawning new server + await _stop_daemon() + + # Spawn new MCP server process + server_params = StdioServerParameters( + command=DEFAULT_SERVER_CMD, + args=DEFAULT_SERVER_ARGS + ) + + try: + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(tool_name, arguments) + return result + except Exception as e: + raise RuntimeError( + f"DOMShell MCP call failed: {e}\n" + f"Ensure Chrome is running with DOMShell extension installed.\n" + f"Chrome Web Store: https://chromewebstore.google.com/detail/domshell" + ) from e + + +async def _start_daemon() -> bool: + """Start persistent daemon mode. + + Returns: + True if daemon started successfully + + Raises: + RuntimeError: If daemon fails to start + """ + global _daemon_session, _daemon_read, _daemon_write + + if _daemon_session is not None: + return True # Already running + + server_params = StdioServerParameters( + command=DEFAULT_SERVER_CMD, + args=DEFAULT_SERVER_ARGS + ) + + try: + _daemon_read, _daemon_write = await stdio_client(server_params).__aenter__() + _daemon_session = ClientSession(_daemon_read, _daemon_write) + await _daemon_session.__aenter__() + await _daemon_session.initialize() + return True + except Exception as e: + _daemon_session = None + _daemon_read = None + _daemon_write = None + raise RuntimeError(f"Failed to start DOMShell daemon: {e}") from e + + +async def _stop_daemon() -> None: + """Stop persistent daemon mode.""" + global _daemon_session, _daemon_read, _daemon_write + + if _daemon_session is None: + return + + try: + await _daemon_session.__aexit__(None, None, None) + if _daemon_read and _daemon_write: + await stdio_client( + StdioServerParameters(command=DEFAULT_SERVER_CMD, args=DEFAULT_SERVER_ARGS) + ).__aexit__(None, None, None) + except Exception: + pass # Ignore cleanup errors + finally: + _daemon_session = None + _daemon_read = None + _daemon_write = None + + +def daemon_started() -> bool: + """Check if daemon mode is active.""" + return _daemon_session is not None + + +# โ”€โ”€ Sync wrappers for each DOMShell tool โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def ls(path: str = "/", use_daemon: bool = False) -> dict: + """List directory contents in the accessibility tree. + + Args: + path: Path in accessibility tree (e.g., "/", "/main", "/main/div[0]") + use_daemon: Use persistent daemon connection if available + + Returns: + Dict with 'entries' key containing list of accessible elements + + Example: + >>> ls("/") + {"path": "/", "entries": [{"name": "main", "role": "landmark", ...}]} + """ + result = asyncio.run(_call_tool("domshell_ls", {"path": path}, use_daemon)) + return result + + +def cd(path: str, use_daemon: bool = False) -> dict: + """Change directory in the accessibility tree. + + Args: + path: Target path + use_daemon: Use persistent daemon connection if available + + Returns: + Dict with 'path' key confirming current location + + Example: + >>> cd("/main/div[0]") + {"path": "/main/div[0]", "element": {...}} + """ + result = asyncio.run(_call_tool("domshell_cd", {"path": path}, use_daemon)) + return result + + +def cat(path: str, use_daemon: bool = False) -> dict: + """Read element content from the accessibility tree. + + Args: + path: Path to element + use_daemon: Use persistent daemon connection if available + + Returns: + Dict with element details including text, role, attributes + + Example: + >>> cat("/main/button[0]") + {"name": "Submit", "role": "button", "text": "Submit", ...} + """ + result = asyncio.run(_call_tool("domshell_cat", {"path": path}, use_daemon)) + return result + + +def grep(pattern: str, path: str = "/", use_daemon: bool = False) -> dict: + """Search for pattern in accessibility tree. + + Args: + pattern: Text pattern to search for + path: Root path for search (default: "/") + use_daemon: Use persistent daemon connection if available + + Returns: + Dict with 'matches' key containing list of matching elements + + Example: + >>> grep("Login") + {"matches": ["/main/button[0]", "/main/link[1]"]} + """ + result = asyncio.run(_call_tool( + "domshell_grep", + {"pattern": pattern, "path": path}, + use_daemon + )) + return result + + +def click(path: str, use_daemon: bool = False) -> dict: + """Click an element in the accessibility tree. + + Args: + path: Path to element to click + use_daemon: Use persistent daemon connection if available + + Returns: + Dict with action result + + Example: + >>> click("/main/button[0]") + {"action": "click", "path": "/main/button[0]", "status": "success"} + """ + result = asyncio.run(_call_tool("domshell_click", {"path": path}, use_daemon)) + return result + + +def open_url(url: str, use_daemon: bool = False) -> dict: + """Navigate to a URL in Chrome. + + Args: + url: URL to navigate to + use_daemon: Use persistent daemon connection if available + + Returns: + Dict with navigation result + + Example: + >>> open_url("https://example.com") + {"url": "https://example.com", "status": "loaded"} + """ + result = asyncio.run(_call_tool("domshell_open", {"url": url}, use_daemon)) + return result + + +def reload(use_daemon: bool = False) -> dict: + """Reload the current page. + + Args: + use_daemon: Use persistent daemon connection if available + + Returns: + Dict with reload result + """ + result = asyncio.run(_call_tool("domshell_reload", {}, use_daemon)) + return result + + +def back(use_daemon: bool = False) -> dict: + """Navigate back in history. + + Args: + use_daemon: Use persistent daemon connection if available + + Returns: + Dict with navigation result + """ + result = asyncio.run(_call_tool("domshell_back", {}, use_daemon)) + return result + + +def forward(use_daemon: bool = False) -> dict: + """Navigate forward in history. + + Args: + use_daemon: Use persistent daemon connection if available + + Returns: + Dict with navigation result + """ + result = asyncio.run(_call_tool("domshell_forward", {}, use_daemon)) + return result + + +def type_text(path: str, text: str, use_daemon: bool = False) -> dict: + """Type text into an input element. + + Args: + path: Path to input element + text: Text to type + use_daemon: Use persistent daemon connection if available + + Returns: + Dict with action result + """ + result = asyncio.run(_call_tool( + "domshell_type", + {"path": path, "text": text}, + use_daemon + )) + return result + + +# โ”€โ”€ Daemon control functions โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def start_daemon() -> bool: + """Start persistent daemon mode (sync wrapper). + + Returns: + True if daemon started successfully + + Raises: + RuntimeError: If daemon fails to start + """ + return asyncio.run(_start_daemon()) + + +def stop_daemon() -> None: + """Stop persistent daemon mode (sync wrapper).""" + asyncio.run(_stop_daemon()) diff --git a/browser/agent-harness/cli_anything/browser/utils/repl_skin.py b/browser/agent-harness/cli_anything/browser/utils/repl_skin.py new file mode 100644 index 000000000..dab3acb5a --- /dev/null +++ b/browser/agent-harness/cli_anything/browser/utils/repl_skin.py @@ -0,0 +1,502 @@ +"""cli-anything REPL Skin โ€” Unified terminal interface for all CLI harnesses. + +Copy this file into your CLI package at: + cli_anything//utils/repl_skin.py + +Usage: + from cli_anything..utils.repl_skin import ReplSkin + + skin = ReplSkin("browser", version="1.0.0") + skin.print_banner() + prompt_text = skin.prompt(project_name="https://example.com", modified=False) + skin.success("Page loaded") + skin.error("Connection failed") + skin.warning("DOMShell not found") + skin.info("Navigating...") + skin.status("URL", "https://example.com") + skin.table(headers, rows) + skin.print_goodbye() +""" + +import os +import sys + +# โ”€โ”€ ANSI color codes (no external deps for core styling) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_RESET = "\033[0m" +_BOLD = "\033[1m" +_DIM = "\033[2m" +_ITALIC = "\033[3m" +_UNDERLINE = "\033[4m" + +# Brand colors +_CYAN = "\033[38;5;80m" # cli-anything brand cyan +_CYAN_BG = "\033[48;5;80m" +_WHITE = "\033[97m" +_GRAY = "\033[38;5;245m" +_DARK_GRAY = "\033[38;5;240m" +_LIGHT_GRAY = "\033[38;5;250m" + +# Software accent colors โ€” each software gets a unique accent +_ACCENT_COLORS = { + "gimp": "\033[38;5;214m", # warm orange + "blender": "\033[38;5;208m", # deep orange + "inkscape": "\033[38;5;39m", # bright blue + "audacity": "\033[38;5;33m", # navy blue + "libreoffice": "\033[38;5;40m", # green + "obs_studio": "\033[38;5;55m", # purple + "kdenlive": "\033[38;5;69m", # slate blue + "shotcut": "\033[38;5;35m", # teal green + "ollama": "\033[38;5;255m", # white (Ollama branding) + "browser": "\033[38;5;141m", # lavender (browser harness) +} +_DEFAULT_ACCENT = "\033[38;5;75m" # default sky blue + +# Status colors +_GREEN = "\033[38;5;78m" +_YELLOW = "\033[38;5;220m" +_RED = "\033[38;5;196m" +_BLUE = "\033[38;5;75m" +_MAGENTA = "\033[38;5;176m" + +# โ”€โ”€ Brand icon โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +# The cli-anything icon: a small colored diamond/chevron mark +_ICON = f"{_CYAN}{_BOLD}โ—†{_RESET}" +_ICON_SMALL = f"{_CYAN}โ–ธ{_RESET}" + +# โ”€โ”€ Box drawing characters โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_H_LINE = "โ”€" +_V_LINE = "โ”‚" +_TL = "โ•ญ" +_TR = "โ•ฎ" +_BL = "โ•ฐ" +_BR = "โ•ฏ" +_T_DOWN = "โ”ฌ" +_T_UP = "โ”ด" +_T_RIGHT = "โ”œ" +_T_LEFT = "โ”ค" +_CROSS = "โ”ผ" + + +def _strip_ansi(text: str) -> str: + """Remove ANSI escape codes for length calculation.""" + import re + return re.sub(r"\033\[[^m]*m", "", text) + + +def _visible_len(text: str) -> int: + """Get visible length of text (excluding ANSI codes).""" + return len(_strip_ansi(text)) + + +class ReplSkin: + """Unified REPL skin for cli-anything CLIs. + + Provides consistent branding, prompts, and message formatting + across all CLI harnesses built with the cli-anything methodology. + """ + + def __init__(self, software: str, version: str = "1.0.0", + history_file: str | None = None): + """Initialize the REPL skin. + + Args: + software: Software name (e.g., "gimp", "shotcut", "browser"). + version: CLI version string. + history_file: Path for persistent command history. + Defaults to ~/.cli-anything-/history + """ + self.software = software.lower().replace("-", "_") + self.display_name = software.replace("_", " ").title() + self.version = version + self.accent = _ACCENT_COLORS.get(self.software, _DEFAULT_ACCENT) + + # History file + if history_file is None: + from pathlib import Path + hist_dir = Path.home() / f".cli-anything-{self.software}" + hist_dir.mkdir(parents=True, exist_ok=True) + self.history_file = str(hist_dir / "history") + else: + self.history_file = history_file + + # Detect terminal capabilities + self._color = self._detect_color_support() + + def _detect_color_support(self) -> bool: + """Check if terminal supports color.""" + if os.environ.get("NO_COLOR"): + return False + if os.environ.get("CLI_ANYTHING_NO_COLOR"): + return False + if not hasattr(sys.stdout, "isatty"): + return False + return sys.stdout.isatty() + + def _c(self, code: str, text: str) -> str: + """Apply color code if colors are supported.""" + if not self._color: + return text + return f"{code}{text}{_RESET}" + + # โ”€โ”€ Banner โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def print_banner(self): + """Print the startup banner with branding.""" + inner = 54 + + def _box_line(content: str) -> str: + """Wrap content in box drawing, padding to inner width.""" + pad = inner - _visible_len(content) + vl = self._c(_DARK_GRAY, _V_LINE) + return f"{vl}{content}{' ' * max(0, pad)}{vl}" + + top = self._c(_DARK_GRAY, f"{_TL}{_H_LINE * inner}{_TR}") + bot = self._c(_DARK_GRAY, f"{_BL}{_H_LINE * inner}{_BR}") + + # Title: โ—† cli-anything ยท Browser + icon = self._c(_CYAN + _BOLD, "โ—†") + brand = self._c(_CYAN + _BOLD, "cli-anything") + dot = self._c(_DARK_GRAY, "ยท") + name = self._c(self.accent + _BOLD, self.display_name) + title = f" {icon} {brand} {dot} {name}" + + ver = f" {self._c(_DARK_GRAY, f' v{self.version}')}" + tip = f" {self._c(_DARK_GRAY, ' Type help for commands, quit to exit')}" + empty = "" + + print(top) + print(_box_line(title)) + print(_box_line(ver)) + print(_box_line(empty)) + print(_box_line(tip)) + print(bot) + print() + + # โ”€โ”€ Prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def prompt(self, project_name: str = "", modified: bool = False, + context: str = "") -> str: + """Build a styled prompt string for prompt_toolkit or input(). + + Args: + project_name: Current project name (empty if none open). + modified: Whether the project has unsaved changes. + context: Optional extra context to show in prompt. + + Returns: + Formatted prompt string. + """ + parts = [] + + # Icon + if self._color: + parts.append(f"{_CYAN}โ—†{_RESET} ") + else: + parts.append("> ") + + # Software name + parts.append(self._c(self.accent + _BOLD, self.software)) + + # Project context + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + parts.append(f" {self._c(_DARK_GRAY, '[')}") + parts.append(self._c(_LIGHT_GRAY, f"{ctx}{mod}")) + parts.append(self._c(_DARK_GRAY, ']')) + + parts.append(self._c(_GRAY, " โฏ ")) + + return "".join(parts) + + def prompt_tokens(self, project_name: str = "", modified: bool = False, + context: str = ""): + """Build prompt_toolkit formatted text tokens for the prompt. + + Use with prompt_toolkit's FormattedText for proper ANSI handling. + + Returns: + list of (style, text) tuples for prompt_toolkit. + """ + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + tokens = [] + + tokens.append(("class:icon", "โ—† ")) + tokens.append(("class:software", self.software)) + + if project_name or context: + ctx = context or project_name + mod = "*" if modified else "" + tokens.append(("class:bracket", " [")) + tokens.append(("class:context", f"{ctx}{mod}")) + tokens.append(("class:bracket", "]")) + + tokens.append(("class:arrow", " โฏ ")) + + return tokens + + def get_prompt_style(self): + """Get a prompt_toolkit Style object matching the skin. + + Returns: + prompt_toolkit.styles.Style + """ + try: + from prompt_toolkit.styles import Style + except ImportError: + return None + + accent_hex = _ANSI_256_TO_HEX.get(self.accent, "#5fafff") + + return Style.from_dict({ + "icon": "#5fdfdf bold", # cyan brand color + "software": f"{accent_hex} bold", + "bracket": "#585858", + "context": "#bcbcbc", + "arrow": "#808080", + # Completion menu + "completion-menu.completion": "bg:#303030 #bcbcbc", + "completion-menu.completion.current": f"bg:{accent_hex} #000000", + "completion-menu.meta.completion": "bg:#303030 #808080", + "completion-menu.meta.completion.current": f"bg:{accent_hex} #000000", + # Auto-suggest + "auto-suggest": "#585858", + # Bottom toolbar + "bottom-toolbar": "bg:#1c1c1c #808080", + "bottom-toolbar.text": "#808080", + }) + + # โ”€โ”€ Messages โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def success(self, message: str): + """Print a success message with green checkmark.""" + icon = self._c(_GREEN + _BOLD, "โœ“") + print(f" {icon} {self._c(_GREEN, message)}") + + def error(self, message: str): + """Print an error message with red cross.""" + icon = self._c(_RED + _BOLD, "โœ—") + print(f" {icon} {self._c(_RED, message)}", file=sys.stderr) + + def warning(self, message: str): + """Print a warning message with yellow triangle.""" + icon = self._c(_YELLOW + _BOLD, "โš ") + print(f" {icon} {self._c(_YELLOW, message)}") + + def info(self, message: str): + """Print an info message with blue dot.""" + icon = self._c(_BLUE, "โ—") + print(f" {icon} {self._c(_LIGHT_GRAY, message)}") + + def hint(self, message: str): + """Print a subtle hint message.""" + print(f" {self._c(_DARK_GRAY, message)}") + + def section(self, title: str): + """Print a section header.""" + print() + print(f" {self._c(self.accent + _BOLD, title)}") + print(f" {self._c(_DARK_GRAY, _H_LINE * len(title))}") + + # โ”€โ”€ Status display โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def status(self, label: str, value: str): + """Print a key-value status line.""" + lbl = self._c(_GRAY, f" {label}:") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def status_block(self, items: dict[str, str], title: str = ""): + """Print a block of status key-value pairs. + + Args: + items: Dict of label -> value pairs. + title: Optional title for the block. + """ + if title: + self.section(title) + + max_key = max(len(k) for k in items) if items else 0 + for label, value in items.items(): + lbl = self._c(_GRAY, f" {label:<{max_key}}") + val = self._c(_WHITE, f" {value}") + print(f"{lbl}{val}") + + def progress(self, current: int, total: int, label: str = ""): + """Print a simple progress indicator. + + Args: + current: Current step number. + total: Total number of steps. + label: Optional label for the progress. + """ + pct = int(current / total * 100) if total > 0 else 0 + bar_width = 20 + filled = int(bar_width * current / total) if total > 0 else 0 + bar = "โ–ˆ" * filled + "โ–‘" * (bar_width - filled) + text = f" {self._c(_CYAN, bar)} {self._c(_GRAY, f'{pct:3d}%')}" + if label: + text += f" {self._c(_LIGHT_GRAY, label)}" + print(text) + + # โ”€โ”€ Table display โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def table(self, headers: list[str], rows: list[list[str]], + max_col_width: int = 40): + """Print a formatted table with box-drawing characters. + + Args: + headers: Column header strings. + rows: List of rows, each a list of cell strings. + max_col_width: Maximum column width before truncation. + """ + if not headers: + return + + # Calculate column widths + col_widths = [min(len(h), max_col_width) for h in headers] + for row in rows: + for i, cell in enumerate(row): + if i < len(col_widths): + col_widths[i] = min( + max(col_widths[i], len(str(cell))), max_col_width + ) + + def pad(text: str, width: int) -> str: + t = str(text)[:width] + return t + " " * (width - len(t)) + + # Header + header_cells = [ + self._c(_CYAN + _BOLD, pad(h, col_widths[i])) + for i, h in enumerate(headers) + ] + sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + header_line = f" {sep.join(header_cells)}" + print(header_line) + + # Separator + sep_parts = [self._c(_DARK_GRAY, _H_LINE * w) for w in col_widths] + sep_line = self._c(_DARK_GRAY, f" {'โ”€โ”€โ”€'.join([_H_LINE * w for w in col_widths])}") + print(sep_line) + + # Rows + for row in rows: + cells = [] + for i, cell in enumerate(row): + if i < len(col_widths): + cells.append(self._c(_LIGHT_GRAY, pad(str(cell), col_widths[i]))) + row_sep = self._c(_DARK_GRAY, f" {_V_LINE} ") + print(f" {row_sep.join(cells)}") + + # โ”€โ”€ Help display โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def help(self, commands: dict[str, str]): + """Print a formatted help listing. + + Args: + commands: Dict of command -> description pairs. + """ + self.section("Commands") + max_cmd = max(len(c) for c in commands) if commands else 0 + for cmd, desc in commands.items(): + cmd_styled = self._c(self.accent, f" {cmd:<{max_cmd}}") + desc_styled = self._c(_GRAY, f" {desc}") + print(f"{cmd_styled}{desc_styled}") + print() + + # โ”€โ”€ Goodbye โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def print_goodbye(self): + """Print a styled goodbye message.""" + print(f"\n {_ICON_SMALL} {self._c(_GRAY, 'Goodbye!')}\n") + + # โ”€โ”€ Prompt toolkit session factory โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def create_prompt_session(self): + """Create a prompt_toolkit PromptSession with skin styling. + + Returns: + A configured PromptSession, or None if prompt_toolkit unavailable. + """ + try: + from prompt_toolkit import PromptSession + from prompt_toolkit.history import FileHistory + from prompt_toolkit.auto_suggest import AutoSuggestFromHistory + from prompt_toolkit.formatted_text import FormattedText + + style = self.get_prompt_style() + + session = PromptSession( + history=FileHistory(self.history_file), + auto_suggest=AutoSuggestFromHistory(), + style=style, + enable_history_search=True, + ) + return session + except ImportError: + return None + + def get_input(self, pt_session, project_name: str = "", + modified: bool = False, context: str = "") -> str: + """Get input from user using prompt_toolkit or fallback. + + Args: + pt_session: A prompt_toolkit PromptSession (or None). + project_name: Current project name. + modified: Whether project has unsaved changes. + context: Optional context string. + + Returns: + User input string (stripped). + """ + if pt_session is not None: + from prompt_toolkit.formatted_text import FormattedText + tokens = self.prompt_tokens(project_name, modified, context) + return pt_session.prompt(FormattedText(tokens)).strip() + else: + raw_prompt = self.prompt(project_name, modified, context) + return input(raw_prompt).strip() + + # โ”€โ”€ Toolbar builder โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + + def bottom_toolbar(self, items: dict[str, str]): + """Create a bottom toolbar callback for prompt_toolkit. + + Args: + items: Dict of label -> value pairs to show in toolbar. + + Returns: + A callable that returns FormattedText for the toolbar. + """ + def toolbar(): + from prompt_toolkit.formatted_text import FormattedText + parts = [] + for i, (k, v) in enumerate(items.items()): + if i > 0: + parts.append(("class:bottom-toolbar.text", " โ”‚ ")) + parts.append(("class:bottom-toolbar.text", f" {k}: ")) + parts.append(("class:bottom-toolbar", v)) + return FormattedText(parts) + return toolbar + + +# โ”€โ”€ ANSI 256-color to hex mapping (for prompt_toolkit styles) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +_ANSI_256_TO_HEX = { + "\033[38;5;33m": "#0087ff", # audacity navy blue + "\033[38;5;35m": "#00af5f", # shotcut teal + "\033[38;5;39m": "#00afff", # inkscape bright blue + "\033[38;5;40m": "#00d700", # libreoffice green + "\033[38;5;55m": "#5f00af", # obs purple + "\033[38;5;69m": "#5f87ff", # kdenlive slate blue + "\033[38;5;75m": "#5fafff", # default sky blue + "\033[38;5;80m": "#5fd7d7", # brand cyan + "\033[38;5;141m": "#afafff", # browser lavender + "\033[38;5;208m": "#ff8700", # blender deep orange + "\033[38;5;214m": "#ffaf00", # gimp warm orange + "\033[38;5;255m": "#eeeeee", # ollama white +} diff --git a/browser/agent-harness/setup.py b/browser/agent-harness/setup.py new file mode 100644 index 000000000..648684779 --- /dev/null +++ b/browser/agent-harness/setup.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +setup.py for cli-anything-browser + +Install with: pip install -e . +Or publish to PyPI: python -m build && twine upload dist/* +""" + +from setuptools import setup, find_namespace_packages + +with open("cli_anything/browser/README.md", "r", encoding="utf-8") as fh: + long_description = fh.read() + +setup( + name="cli-anything-browser", + version="1.0.0", + author="cli-anything contributors", + author_email="", + description="CLI harness for Browser automation via DOMShell MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native browser automation.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://github.com/HKUDS/CLI-Anything", + packages=find_namespace_packages(include=["cli_anything.*"]), + classifiers=[ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: Testing", + "Topic :: Internet :: WWW/HTTP :: Browsers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + ], + python_requires=">=3.10", + install_requires=[ + "click>=8.0.0", + "prompt-toolkit>=3.0.0", + "mcp>=0.1.0", + ], + extras_require={ + "dev": [ + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + ], + }, + entry_points={ + "console_scripts": [ + "cli-anything-browser=cli_anything.browser.browser_cli:main", + ], + }, + package_data={ + "cli_anything.browser": ["skills/*.md"], + }, + include_package_data=True, + zip_safe=False, +) diff --git a/cli-anything-plugin/HARNESS.md b/cli-anything-plugin/HARNESS.md index cfbef2b39..1d4ccd661 100644 --- a/cli-anything-plugin/HARNESS.md +++ b/cli-anything-plugin/HARNESS.md @@ -424,6 +424,63 @@ the input. Users can't tell anything happened. **Priority order for rendering:** native engine โ†’ translated filtergraph โ†’ script. +### MCP Backend Pattern + +For services that expose an MCP (Model Context Protocol) server: + +**Use case:** When the software provides an MCP server instead of a traditional CLI. +Example: DOMShell provides browser automation via MCP tools. + +**When to use:** +- The software has an official or community MCP server +- No native CLI exists, or MCP provides better functionality +- You want to integrate AI/agent tools that speak MCP protocol + +**Backend wrapper** (`utils/_backend.py`): +```python +import asyncio +from mcp import ClientSession, StdioServerParameters +from mcp.client.stdio import stdio_client + +async def _call_tool(tool_name: str, arguments: dict) -> Any: + """Call an MCP tool.""" + server_params = StdioServerParameters( + command="npx", + args=["@apireno/domshell"] + ) + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + result = await session.call_tool(tool_name, arguments) + return result + +def is_available() -> bool: + """Check if MCP server is available.""" + # Try to spawn and verify + ... + +# Sync wrappers for each tool +def ls(path: str = "/") -> dict: + """List directory contents.""" + return asyncio.run(_call_tool("domshell_ls", {"path": path})) +``` + +**Session management:** +- MCP server spawns per command (stateless from server perspective) +- CLI maintains state (URL, working directory, navigation history) +- Each command re-spawns the MCP server process + +**Daemon mode (optional):** +- Spawn MCP server once, reuse connection for multiple commands +- Reduces latency for interactive use +- Requires explicit start/stop or `--daemon` flag + +**Dependencies:** Add `mcp>=0.1.0` to install_requires + +**Example implementations:** +- `browser/agent-harness` โ€” DOMShell MCP server for browser automation +- See: https://github.com/HKUDS/CLI-Anything/tree/main/browser/agent-harness + ### Filter Translation Pitfalls When translating effects between formats (e.g., MLT โ†’ ffmpeg), watch for: @@ -644,6 +701,7 @@ This same SOP applies to any GUI application: | Shotcut/Kdenlive | `melt` or `ffmpeg` | .mlt (XML) | `apt install melt ffmpeg` | Build MLT XML โ†’ melt/ffmpeg renders video | | Audacity | `sox` | .aup3 | `apt install sox` | Generate sox commands โ†’ sox processes audio | | OBS Studio | `obs-websocket` | scene.json | `apt install obs-studio` | WebSocket API โ†’ OBS captures/records | +| Browser (DOMShell) | `npx @apireno/domshell` (MCP) | Accessibility Tree (virtual FS) | `npx install -g npx` + Chrome ext | MCP SDK โ†’ DOMShell tools โ†’ filesystem navigation | **The software is a required dependency, not optional.** The CLI generates valid intermediate files (ODF, MLT XML, bpy scripts, SVG) and hands them to the real diff --git a/registry.json b/registry.json index 7895a11c4..e24093572 100644 --- a/registry.json +++ b/registry.json @@ -61,6 +61,20 @@ "contributor": "CLI-Anything-Team", "contributor_url": "https://github.com/HKUDS/CLI-Anything" }, + { + "name": "browser", + "display_name": "Browser", + "version": "1.0.0", + "description": "Browser automation via DOMShell MCP server. Maps Chrome's Accessibility Tree to a virtual filesystem for agent-native navigation.", + "requires": "Node.js, npx, Chrome + DOMShell extension", + "homepage": "https://github.com/apireno/DOMShell", + "install_cmd": "pip install git+https://github.com/HKUDS/CLI-Anything.git#subdirectory=browser/agent-harness", + "entry_point": "cli-anything-browser", + "skill_md": "browser/agent-harness/cli_anything/browser/skills/SKILL.md", + "category": "web", + "contributor": "CLI-Anything-Team", + "contributor_url": "https://github.com/HKUDS/CLI-Anything" + }, { "name": "comfyui", "display_name": "ComfyUI",