Merge pull request #118 from furkankoykiran/feat/browser-domshell-mcp-harness

feat: add Browser automation CLI via DOMShell MCP server
This commit is contained in:
Yuhao
2026-03-23 00:37:48 +08:00
committed by GitHub
21 changed files with 3095 additions and 1 deletions
+4
View File
@@ -46,6 +46,7 @@
!/adguardhome/
!/novita/
!/ollama/
!/browser/
# Step 5: Inside each software dir, ignore everything (including dotfiles)
/gimp/*
@@ -78,6 +79,8 @@
/adguardhome/.*
/ollama/*
/ollama/.*
/browser/*
/browser/.*
# Step 6: ...except agent-harness/
!/gimp/agent-harness/
@@ -96,6 +99,7 @@
!/adguardhome/agent-harness/
!/novita/agent-harness/
!/ollama/agent-harness/
!/browser/agent-harness/
# Step 7: Ignore build artifacts within allowed dirs
**/__pycache__/
+8 -1
View File
@@ -547,6 +547,13 @@ Each application received complete, production-ready CLI interfaces — not demo
<td align="center">✅ 161</td>
</tr>
<tr>
<td align="center"><strong>🌐 Browser</strong></td>
<td>Browser Automation</td>
<td><code>cli-anything-browser</code></td>
<td>DOMShell MCP + Accessibility Tree</td>
<td align="center">✅ <a href="browser/agent-harness/">New</a></td>
</tr>
<tr>
<td align="center"><strong>📄 LibreOffice</strong></td>
<td>Office Suite (Writer, Calc, Impress)</td>
<td><code>cli-anything-libreoffice</code></td>
@@ -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)
```
+303
View File
@@ -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 <url>` | 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 <path>` | Change directory | Sets `working_dir` |
| `cat [path]` | Read element | None |
| `grep <pat> [path]` | Search | None |
| `pwd` | Print working dir | None |
### Action Commands (`act`)
| Command | Description | State Impact |
|---------|-------------|--------------|
| `click <path>` | Click element | May trigger navigation |
| `type <path> <text>` | 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
@@ -0,0 +1,264 @@
# 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
```
**Note:** One-shot commands each start with a fresh session (no URL or working directory). For stateful workflows (like `cd` followed by `ls` without a path), use the REPL instead.
### 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 within a REPL session:
```bash
# Start REPL with daemon mode
cli-anything-browser --daemon
# Or start daemon within REPL
session daemon-start
# Run commands (uses persistent connection)
fs ls /
fs cd /main
# Stop daemon when done
session daemon-stop
```
**Note:** Daemon mode only works within a single running process (REPL or `--daemon` flag). State does not persist across separate CLI invocations.
### Interactive REPL
Run without arguments to enter interactive mode:
```bash
cli-anything-browser
```
REPL commands:
- `page open <url>` — Open a URL
- `fs ls [path]` — List elements
- `fs cd <path>` — Change directory
- `fs cat [path]` — Read element
- `fs grep <pattern>` — Search for text
- `fs pwd` — Print working directory
- `act click <path>` — Click element
- `act type <path> <text>` — Type text
- `session status` — Show session state
- `help` — Show commands
- `quit` — Exit REPL
## Command Groups
### `page` — Page Navigation
- `open <url>` — 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 <path>` — Change directory
- `cat [path]` — Read element content
- `grep <pattern> [path]` — Search for text
- `pwd` — Print working directory
### `act` — Action Commands
- `click <path>` — Click an element
- `type <path> <text>` — 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.
@@ -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"
@@ -0,0 +1,3 @@
"""Allow running as python -m cli_anything.browser"""
from cli_anything.browser.browser_cli import main
main()
@@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""Browser CLI — A command-line interface for browser automation via DOMShell MCP.
This CLI provides filesystem-first browser automation using Chrome's Accessibility Tree.
Navigate web pages using familiar shell commands: ls, cd, cat, grep, click.
Usage:
# One-shot commands
cli-anything-browser page open https://example.com
cli-anything-browser fs ls /
cli-anything-browser act click /main/button[0]
cli-anything-browser --json fs cat /main/title
# Interactive REPL
cli-anything-browser
"""
import sys
import json
import shlex
import click
from typing import Optional
from cli_anything.browser.core.session import Session
from cli_anything.browser.core import page as page_mod
from cli_anything.browser.core import fs as fs_mod
from cli_anything.browser.utils import domshell_backend as backend
# Global state
_session: Optional[Session] = None
_json_output = False
_repl_mode = False
_availability_cached: Optional[tuple[bool, str]] = None # Cache for REPL mode
def get_session() -> Session:
global _session
if _session is None:
_session = Session()
return _session
def output(data, message: str = ""):
if _json_output:
click.echo(json.dumps(data, indent=2, default=str))
else:
if message:
click.echo(message)
if isinstance(data, dict):
_print_dict(data)
elif isinstance(data, list):
_print_list(data)
else:
click.echo(str(data))
def _print_dict(d: dict, indent: int = 0):
prefix = " " * indent
for k, v in d.items():
if isinstance(v, dict):
click.echo(f"{prefix}{k}:")
_print_dict(v, indent + 1)
elif isinstance(v, list):
click.echo(f"{prefix}{k}:")
_print_list(v, indent + 1)
else:
click.echo(f"{prefix}{k}: {v}")
def _print_list(items: list, indent: int = 0):
prefix = " " * indent
for i, item in enumerate(items):
if isinstance(item, dict):
click.echo(f"{prefix}[{i}]")
_print_dict(item, indent + 1)
else:
click.echo(f"{prefix}- {item}")
def handle_error(func):
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except RuntimeError as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "runtime_error"}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
except (ValueError, IndexError) as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": type(e).__name__}))
else:
click.echo(f"Error: {e}", err=True)
if not _repl_mode:
sys.exit(1)
wrapper.__name__ = func.__name__
wrapper.__doc__ = func.__doc__
return wrapper
# ── Main CLI Group ──────────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.option("--json", "use_json", is_flag=True, help="Output as JSON")
@click.option("--daemon", "use_daemon", is_flag=True,
help="Use persistent daemon mode (faster for interactive use)")
@click.pass_context
def cli(ctx, use_json, use_daemon):
"""Browser CLI — Filesystem-first browser automation via DOMShell.
Run without a subcommand to enter interactive REPL mode.
"""
global _json_output, _session, _availability_cached
_json_output = use_json
# Check DOMShell availability (skip for help/version to allow viewing docs without DOMShell)
# Cache the result for REPL mode to avoid repeated npx subprocess spawns
if '--help' not in sys.argv and '--version' not in sys.argv:
if _availability_cached is None:
_availability_cached = backend.is_available()
available, msg = _availability_cached
if not available:
if _json_output:
click.echo(json.dumps({"error": msg, "type": "dependency_error"}))
else:
click.echo(f"Error: {msg}", err=True)
click.echo(
"\nInstall DOMShell Chrome extension:\n"
" https://chromewebstore.google.com/detail/domshell"
)
sys.exit(1)
# Initialize session with daemon mode
_session = get_session()
if use_daemon:
try:
backend.start_daemon()
_session.enable_daemon()
if not _json_output:
click.echo("Daemon mode: persistent MCP connection active")
except RuntimeError as e:
if _json_output:
click.echo(json.dumps({"error": str(e), "type": "daemon_error"}))
else:
click.echo(f"Daemon start failed: {e}", err=True)
click.echo("Falling back to per-command mode", err=True)
if ctx.invoked_subcommand is None:
ctx.invoke(repl)
# ── Page Commands ───────────────────────────────────────────────
@cli.group()
def page():
"""Page navigation commands."""
pass
@page.command("open")
@click.argument("url")
@handle_error
def page_open(url):
"""Open a URL in Chrome."""
sess = get_session()
result = page_mod.open_page(sess, url)
output(result, f"Opened: {url}")
@page.command("reload")
@handle_error
def page_reload():
"""Reload the current page."""
sess = get_session()
result = page_mod.reload_page(sess)
output(result, "Page reloaded")
@page.command("back")
@handle_error
def page_back():
"""Navigate back in history."""
sess = get_session()
result = page_mod.go_back(sess)
if "error" in result:
output(result, result["error"])
else:
output(result, "Navigated back")
@page.command("forward")
@handle_error
def page_forward():
"""Navigate forward in history."""
sess = get_session()
result = page_mod.go_forward(sess)
if "error" in result:
output(result, result["error"])
else:
output(result, "Navigated forward")
@page.command("info")
@handle_error
def page_info():
"""Show current page information."""
sess = get_session()
result = page_mod.get_page_info(sess)
output(result)
# ── Filesystem Commands ──────────────────────────────────────────
@cli.group()
def fs():
"""Filesystem navigation commands (Accessibility Tree)."""
pass
@fs.command("ls")
@click.argument("path", default="", required=False)
@handle_error
def fs_ls(path):
"""List elements at a path in the accessibility tree."""
sess = get_session()
result = fs_mod.list_elements(sess, path)
if _json_output:
output(result)
else:
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 into: {path}")
# ── Session Commands ─────────────────────────────────────────────
@cli.group()
def session():
"""Session management commands."""
pass
@session.command("status")
@handle_error
def session_status():
"""Show current session status."""
sess = get_session()
status = sess.status()
output(status)
@session.command("daemon-start")
@handle_error
def session_daemon_start():
"""Start persistent daemon mode."""
try:
backend.start_daemon()
get_session().enable_daemon()
output({"daemon": "started"}, "Daemon mode started")
except RuntimeError as e:
output({"error": str(e)}, str(e))
@session.command("daemon-stop")
@handle_error
def session_daemon_stop():
"""Stop persistent daemon mode."""
backend.stop_daemon()
get_session().disable_daemon()
output({"daemon": "stopped"}, "Daemon mode stopped")
# ── REPL ─────────────────────────────────────────────────────────
@cli.command()
@handle_error
def repl():
"""Start interactive REPL session."""
from cli_anything.browser.utils.repl_skin import ReplSkin
global _repl_mode
_repl_mode = True
skin = ReplSkin("browser", version="1.0.0")
skin.print_banner()
pt_session = skin.create_prompt_session()
_repl_commands = {
"page": "open|reload|back|forward|info",
"fs": "ls|cd|cat|grep|pwd",
"act": "click|type",
"session": "status|daemon-start|daemon-stop",
"help": "Show this help",
"quit": "Exit REPL",
}
while True:
try:
sess = get_session()
# Show URL and working dir in prompt
context = sess.working_dir if sess.working_dir != "/" else "/"
if sess.current_url:
# Truncate long URLs for prompt
url_display = sess.current_url[:40] + "..." if len(sess.current_url) > 40 else sess.current_url
context = f"{url_display} {context}"
line = skin.get_input(pt_session, context=context)
if not line:
continue
if line.lower() in ("quit", "exit", "q"):
skin.print_goodbye()
break
if line.lower() == "help":
skin.help(_repl_commands)
continue
# Parse and execute command (preserve quoted arguments)
try:
args = shlex.split(line)
except ValueError:
args = line.split() # Fallback for unbalanced quotes
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()
@@ -0,0 +1,6 @@
# Core modules
from . import session
from . import page
from . import fs
__all__ = ["session", "page", "fs"]
@@ -0,0 +1,116 @@
"""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)
# Only update working_dir if backend succeeded
if isinstance(result, dict) and "error" not in result:
new_working_dir = result.get("path", path)
session.set_working_dir(new_working_dir)
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)
@@ -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"}
"""
use_daemon = session.daemon_mode
result = backend.back(use_daemon=use_daemon)
# Update session state if backend returned a URL
if isinstance(result, dict) and "url" in result:
session.set_url(result["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"}
"""
use_daemon = session.daemon_mode
result = backend.forward(use_daemon=use_daemon)
# Update session state if backend returned a URL
if isinstance(result, dict) and "url" in result:
session.set_url(result["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,
}
@@ -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,
}
@@ -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 <url>` — 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 <path>` — Change directory
- `fs cat [path]` — Read element content
- `fs grep <pattern> [path]` — Search for text pattern
- `fs pwd` — Print working directory
### `act` — Action Commands
- `act click <path>` — Click an element
- `act type <path> <text>` — 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-anything-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)
@@ -0,0 +1 @@
# Tests
@@ -0,0 +1,384 @@
"""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.back") as mock_back:
mock_back.return_value = {"url": "https://first.com", "status": "navigated"}
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()
with patch("cli_anything.browser.core.page.backend.back") as mock_back:
mock_back.return_value = {"error": "No history"}
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.forward") as mock_forward:
mock_forward.return_value = {"url": "https://second.com", "status": "navigated"}
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)
@@ -0,0 +1,146 @@
"""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 os
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
# Control whether to run DOMShell E2E tests via an environment variable.
# This avoids invoking `npx` (inside is_available) during test collection
# in environments that have not explicitly opted in.
DOMSHELL_E2E_ENABLED = os.environ.get("DOMSHELL_E2E", "").lower() in {"1", "true", "yes"}
# Skip all tests if E2E is not enabled or DOMShell is not available.
# `is_available()` is only called when DOMSHELL_E2E_ENABLED is true.
pytestmark = pytest.mark.skipif(
(not DOMSHELL_E2E_ENABLED) or (not is_available()[0]),
reason=(
"DOMShell E2E tests are disabled or DOMShell MCP server not available. "
"Set DOMSHELL_E2E=1 to enable and ensure DOMShell is installed from the 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
data = json.loads(result.output)
assert "daemon" in data or "error" in data
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 handling
assert result.exception is None
assert result.exit_code in (0, 1)
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
@@ -0,0 +1,5 @@
# Utilities
from . import domshell_backend
from . import repl_skin
__all__ = ["domshell_backend", "repl_skin"]
@@ -0,0 +1,393 @@
"""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
_daemon_client_context: Optional[Any] = None # Store stdio_client context manager
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. Run `npx @apireno/domshell --version` once\n"
"Note: The 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
# NOTE: Known limitation - Daemon mode uses asyncio.run() per tool call (in sync wrappers).
# Each asyncio.run() creates a new event loop. Async IO objects created in one loop
# (like the daemon session) may have issues when accessed from subsequent calls that
# create new loops. This is a documented limitation for v1; future work should use
# a single long-lived event loop (e.g., background thread + run_coroutine_threadsafe).
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, _daemon_client_context
if _daemon_session is not None:
return True # Already running
server_params = StdioServerParameters(
command=DEFAULT_SERVER_CMD,
args=DEFAULT_SERVER_ARGS
)
try:
# Store the context manager so we can properly clean it up later
_daemon_client_context = stdio_client(server_params)
_daemon_read, _daemon_write = await _daemon_client_context.__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
_daemon_client_context = 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, _daemon_client_context
if _daemon_session is None:
return
try:
await _daemon_session.__aexit__(None, None, None)
if _daemon_client_context:
await _daemon_client_context.__aexit__(None, None, None)
except Exception:
pass # Ignore cleanup errors
finally:
_daemon_session = None
_daemon_read = None
_daemon_write = None
_daemon_client_context = 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())
@@ -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/<software>/utils/repl_skin.py
Usage:
from cli_anything.<software>.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-<software>/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
}
+58
View File
@@ -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,
)
+59
View File
@@ -424,6 +424,64 @@ 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/<service>_backend.py`):
```python
import asyncio
from typing import Any
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 +702,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) | `npm install -g npx` (if needed) + 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
+14
View File
@@ -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": "furkankoykiran",
"contributor_url": "https://github.com/furkankoykiran"
},
{
"name": "comfyui",
"display_name": "ComfyUI",