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",