mirror of
https://github.com/HKUDS/CLI-Anything.git
synced 2026-09-17 17:38:11 +08:00
Merge pull request #308 from apireno/fix/browser-domshell-2.0.0-migration
fix(browser): migrate DOMShell MCP integration to @apireno/domshell 2.0.0
This commit is contained in:
@@ -37,30 +37,61 @@ DOMShell is an npm package that exposes Chrome's Accessibility Tree via MCP:
|
||||
|
||||
### Installation
|
||||
|
||||
**Requires `@apireno/domshell` 2.0.2 or newer.** The harness uses
|
||||
`group_id="new"` to declare lane intent explicitly on the first call of
|
||||
each session and reuses the captured lane id on subsequent calls (silences
|
||||
the deprecation warning that 2.0.2 added for omitted `group_id`; will
|
||||
become a hard error in DOMShell 3.0.0). For direct daemon-mode callers
|
||||
without a harness `Session`, the same scheme runs at module level — the
|
||||
first call captures a lane id into `_daemon_lane_id` and subsequent calls
|
||||
reuse it, preserving browser state across daemon-mode no-session calls
|
||||
(see `domshell_backend.py`'s `_daemon_lane_id` declaration for the
|
||||
stale-lane failure mode).
|
||||
|
||||
```bash
|
||||
# Verify DOMShell is available
|
||||
npx @apireno/domshell --version
|
||||
npx @apireno/domshell --version # should report 2.0.2 or higher
|
||||
|
||||
# Install Chrome extension
|
||||
# https://chromewebstore.google.com/detail/domshell
|
||||
```
|
||||
|
||||
### MCP Tools
|
||||
The standard `npx @apireno/domshell` invocation pulls the latest published
|
||||
version automatically; no manual pinning is required.
|
||||
|
||||
DOMShell exposes these MCP tools:
|
||||
DOMShell 2.0.0 (May 2026) consolidated the MCP tool surface from 38
|
||||
per-command tools to a single `domshell_execute` tool. The harness targets
|
||||
this consolidated tool, so no opt-in `--granular` server flag is required.
|
||||
|
||||
| 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` |
|
||||
### MCP Tool
|
||||
|
||||
DOMShell 2.0.2+ exposes a single MCP tool:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `domshell_execute` | Runs a shell-style command string. Multi-line input is supported — each line runs in order in the same shell state. |
|
||||
|
||||
The harness builds command strings from the public CLI commands. Harness
|
||||
absolute paths (leading `/`) are anchored at the tab root via
|
||||
`cd %here%` since DOMShell's lane cwd may have drifted; relative paths
|
||||
are passed through unchanged.
|
||||
|
||||
| CLI Command (path is absolute) | Command string sent to `domshell_execute` |
|
||||
|--------------------------------|--------------------------------------------|
|
||||
| `fs ls /<sub>` | `cd %here%/<sub>` then bare `ls`, then `cd <restore>` (single multi-line call) |
|
||||
| `fs cd /<sub>` | `cd %here%/<sub>` (single line — `cd` is the desired new state) |
|
||||
| `fs cat /<sub>` | `cd %here%`, `cat <sub>`, `cd <restore>` (single multi-line call) |
|
||||
| `fs grep <pat>` | `grep <pat>` (operates on lane cwd) |
|
||||
| `fs grep <pat> /<sub>` | `cd %here%/<sub>`, `grep <pat>`, `cd <restore>` (single multi-line call) |
|
||||
| `act click /<sub>` | `cd %here%`, `click <sub>`, `cd <restore>` (single multi-line call) |
|
||||
| `act type /<sub> <text>` | `cd %here%`, `focus <sub>`, `cd <restore>` — then, on success, `type <text>` (two calls, shared lane via `group_id`) |
|
||||
| `page open <url>` | `open <url>` |
|
||||
| `page reload` | `refresh` |
|
||||
| `page back` | `back` |
|
||||
| `page forward` | `forward` |
|
||||
|
||||
`<restore>` resolves to `cd %here%/<harness-working-dir>` (or `cd %here%`
|
||||
when the harness is at the tab root) so the lane's cwd ends up where
|
||||
the harness expects.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
|
||||
@@ -226,6 +226,13 @@ def fs_ls(path):
|
||||
if _json_output:
|
||||
output(result)
|
||||
else:
|
||||
# Surface DOMShell errors (e.g. `fs ls /nonexistent`) before
|
||||
# falling into the empty-entries "No elements" branch — without
|
||||
# this, an error from the kernel was hidden behind a misleading
|
||||
# "No elements at …" message. (Codex P2 R4 on commit 5790651.)
|
||||
if "error" in result:
|
||||
click.echo(result["error"], err=True)
|
||||
return
|
||||
entries = result.get("entries", [])
|
||||
if not entries:
|
||||
click.echo(f"No elements at {path or sess.working_dir}")
|
||||
@@ -273,6 +280,13 @@ def fs_grep(pattern, path):
|
||||
if _json_output:
|
||||
output(result)
|
||||
else:
|
||||
# Surface DOMShell errors before the empty-matches "No matches"
|
||||
# branch — same fix shape as fs_ls. Without this, an anchor cd
|
||||
# failure on rooted grep was hidden behind "No matches".
|
||||
# (Codex P2 R4 on commit 5790651.)
|
||||
if "error" in result:
|
||||
click.echo(result["error"], err=True)
|
||||
return
|
||||
matches = result.get("matches", [])
|
||||
if not matches:
|
||||
click.echo(f"No matches for '{pattern}'")
|
||||
@@ -304,7 +318,7 @@ 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)
|
||||
result = backend.click(path, use_daemon=use_daemon, session=sess)
|
||||
output(result, f"Clicked: {path}")
|
||||
|
||||
|
||||
@@ -316,7 +330,7 @@ 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)
|
||||
result = backend.type_text(path, text, use_daemon=use_daemon, session=sess)
|
||||
output(result, f"Typed into: {path}")
|
||||
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ def list_elements(session: "Session", path: str = "") -> dict:
|
||||
"""
|
||||
target_path = path if path else session.working_dir
|
||||
use_daemon = session.daemon_mode
|
||||
return backend.ls(target_path, use_daemon=use_daemon)
|
||||
return backend.ls(target_path, use_daemon=use_daemon, session=session)
|
||||
|
||||
|
||||
def change_directory(session: "Session", path: str) -> dict:
|
||||
@@ -69,7 +69,7 @@ def change_directory(session: "Session", path: str) -> dict:
|
||||
path = session.working_dir.rstrip("/") + "/" + path
|
||||
|
||||
use_daemon = session.daemon_mode
|
||||
result = backend.cd(path, use_daemon=use_daemon)
|
||||
result = backend.cd(path, use_daemon=use_daemon, session=session)
|
||||
# Only update working_dir if backend succeeded
|
||||
if isinstance(result, dict) and "error" not in result:
|
||||
new_working_dir = result.get("path", path)
|
||||
@@ -93,7 +93,7 @@ def read_element(session: "Session", path: str = "") -> dict:
|
||||
"""
|
||||
target_path = path if path else session.working_dir
|
||||
use_daemon = session.daemon_mode
|
||||
return backend.cat(target_path, use_daemon=use_daemon)
|
||||
return backend.cat(target_path, use_daemon=use_daemon, session=session)
|
||||
|
||||
|
||||
def grep_elements(session: "Session", pattern: str, path: str = "") -> dict:
|
||||
@@ -113,16 +113,11 @@ def grep_elements(session: "Session", pattern: str, path: str = "") -> dict:
|
||||
"""
|
||||
target_path = path if path else session.working_dir
|
||||
use_daemon = session.daemon_mode
|
||||
|
||||
# DOMShell's grep searches from the server-side CWD. To root the search
|
||||
# at the requested path, cd there first, grep, then restore.
|
||||
if target_path and target_path != "/":
|
||||
cd_result = backend.cd(target_path, use_daemon=use_daemon)
|
||||
if hasattr(cd_result, 'isError') and cd_result.isError:
|
||||
return cd_result
|
||||
|
||||
try:
|
||||
return backend.grep(pattern, use_daemon=use_daemon)
|
||||
finally:
|
||||
if target_path and target_path != "/":
|
||||
backend.cd(session.working_dir or "/", use_daemon=use_daemon)
|
||||
prev = session.working_dir or "/"
|
||||
return backend.grep(
|
||||
pattern,
|
||||
path=target_path,
|
||||
prev=prev,
|
||||
use_daemon=use_daemon,
|
||||
session=session,
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ def open_page(session: "Session", url: str) -> dict:
|
||||
raise ValueError(error_msg)
|
||||
|
||||
use_daemon = session.daemon_mode
|
||||
result = backend.open_url(url, use_daemon=use_daemon)
|
||||
result = backend.open_url(url, use_daemon=use_daemon, session=session)
|
||||
session.set_url(url)
|
||||
session.set_working_dir("/") # Reset to root on new page
|
||||
return result
|
||||
@@ -62,7 +62,7 @@ def reload_page(session: "Session") -> dict:
|
||||
{"status": "reloaded", "url": "https://example.com"}
|
||||
"""
|
||||
use_daemon = session.daemon_mode
|
||||
result = backend.reload(use_daemon=use_daemon)
|
||||
result = backend.reload(use_daemon=use_daemon, session=session)
|
||||
return result
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ def go_back(session: "Session") -> dict:
|
||||
{"url": "https://previous.com", "status": "navigated"}
|
||||
"""
|
||||
use_daemon = session.daemon_mode
|
||||
result = backend.back(use_daemon=use_daemon)
|
||||
result = backend.back(use_daemon=use_daemon, session=session)
|
||||
|
||||
# Update session state if backend returned a URL
|
||||
if isinstance(result, dict) and "url" in result:
|
||||
@@ -103,7 +103,7 @@ def go_forward(session: "Session") -> dict:
|
||||
{"url": "https://next.com", "status": "navigated"}
|
||||
"""
|
||||
use_daemon = session.daemon_mode
|
||||
result = backend.forward(use_daemon=use_daemon)
|
||||
result = backend.forward(use_daemon=use_daemon, session=session)
|
||||
|
||||
# Update session state if backend returned a URL
|
||||
if isinstance(result, dict) and "url" in result:
|
||||
|
||||
@@ -21,6 +21,11 @@ class Session:
|
||||
- history: Stack of URLs for back navigation
|
||||
- forward_stack: Stack of URLs for forward navigation
|
||||
- daemon_mode: Whether persistent daemon connection is active
|
||||
- domshell_lane_id: The DOMShell 2.x lane (Chrome tab-group) the harness
|
||||
has been pinned to. ``None`` until the first ``_call_execute`` reply
|
||||
arrives carrying a ``[lane: <id>]`` marker; thereafter passed as
|
||||
``group_id`` on every subsequent call so browser state (current tab,
|
||||
cwd, focus) persists across REPL commands in non-daemon mode.
|
||||
"""
|
||||
|
||||
current_url: str = ""
|
||||
@@ -28,6 +33,7 @@ class Session:
|
||||
history: list[str] = field(default_factory=list)
|
||||
forward_stack: list[str] = field(default_factory=list)
|
||||
daemon_mode: bool = False
|
||||
domshell_lane_id: Optional[str] = None
|
||||
|
||||
def set_url(self, url: str, record_history: bool = True) -> None:
|
||||
"""Set the current URL and update history.
|
||||
@@ -95,4 +101,5 @@ class Session:
|
||||
"history_length": len(self.history),
|
||||
"forward_stack_length": len(self.forward_stack),
|
||||
"daemon_mode": self.daemon_mode,
|
||||
"domshell_lane_id": self.domshell_lane_id,
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ class TestFsModule:
|
||||
|
||||
result = fs.list_elements(sess)
|
||||
|
||||
mock_ls.assert_called_once_with("/main", use_daemon=False)
|
||||
mock_ls.assert_called_once_with("/main", use_daemon=False, session=sess)
|
||||
|
||||
def test_list_elements_with_path(self):
|
||||
"""Listing elements with explicit path overrides working_dir."""
|
||||
@@ -228,7 +228,7 @@ class TestFsModule:
|
||||
|
||||
result = fs.list_elements(sess, "/div")
|
||||
|
||||
mock_ls.assert_called_once_with("/div", use_daemon=False)
|
||||
mock_ls.assert_called_once_with("/div", use_daemon=False, session=sess)
|
||||
|
||||
def test_list_elements_empty_path_uses_working_dir(self):
|
||||
"""Listing with empty path uses session working_dir."""
|
||||
@@ -240,7 +240,7 @@ class TestFsModule:
|
||||
|
||||
result = fs.list_elements(sess, "")
|
||||
|
||||
mock_ls.assert_called_once_with("/main", use_daemon=False)
|
||||
mock_ls.assert_called_once_with("/main", use_daemon=False, session=sess)
|
||||
|
||||
def test_change_directory_absolute_path(self):
|
||||
"""Changing to absolute path updates working_dir."""
|
||||
@@ -252,7 +252,7 @@ class TestFsModule:
|
||||
result = fs.change_directory(sess, "/main")
|
||||
|
||||
assert sess.working_dir == "/main"
|
||||
mock_cd.assert_called_once_with("/main", use_daemon=False)
|
||||
mock_cd.assert_called_once_with("/main", use_daemon=False, session=sess)
|
||||
|
||||
def test_change_directory_relative_parent(self):
|
||||
"""Changing to .. goes up one level."""
|
||||
@@ -265,7 +265,7 @@ class TestFsModule:
|
||||
result = fs.change_directory(sess, "..")
|
||||
|
||||
assert sess.working_dir == "/main"
|
||||
mock_cd.assert_called_once_with("/main", use_daemon=False)
|
||||
mock_cd.assert_called_once_with("/main", use_daemon=False, session=sess)
|
||||
|
||||
def test_change_directory_parent_from_root(self):
|
||||
"""Changing to .. from root stays at root."""
|
||||
@@ -300,7 +300,7 @@ class TestFsModule:
|
||||
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)
|
||||
mock_cd.assert_called_once_with("/main/div[0]", use_daemon=False, session=sess)
|
||||
|
||||
def test_read_element(self):
|
||||
"""Reading element calls backend."""
|
||||
@@ -315,7 +315,7 @@ class TestFsModule:
|
||||
|
||||
result = fs.read_element(sess, "/main/button[0]")
|
||||
|
||||
mock_cat.assert_called_once_with("/main/button[0]", use_daemon=False)
|
||||
mock_cat.assert_called_once_with("/main/button[0]", use_daemon=False, session=sess)
|
||||
|
||||
def test_read_element_empty_path_uses_working_dir(self):
|
||||
"""Reading with empty path uses session working_dir."""
|
||||
@@ -327,11 +327,11 @@ class TestFsModule:
|
||||
|
||||
result = fs.read_element(sess, "")
|
||||
|
||||
mock_cat.assert_called_once_with("/main", use_daemon=False)
|
||||
mock_cat.assert_called_once_with("/main", use_daemon=False, session=sess)
|
||||
|
||||
def test_grep_elements(self):
|
||||
"""Grepping calls backend with pattern."""
|
||||
sess = Session()
|
||||
"""Grepping calls backend with pattern and session cwd as path."""
|
||||
sess = Session() # working_dir defaults to "/"
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.grep") as mock_grep:
|
||||
mock_grep.return_value = {
|
||||
@@ -340,23 +340,22 @@ class TestFsModule:
|
||||
|
||||
result = fs.grep_elements(sess, "Login")
|
||||
|
||||
mock_grep.assert_called_once_with("Login", use_daemon=False)
|
||||
mock_grep.assert_called_once_with(
|
||||
"Login", path="/", prev="/", use_daemon=False, session=sess
|
||||
)
|
||||
|
||||
def test_grep_elements_with_path(self):
|
||||
"""Grepping with path cds to that path first, then restores."""
|
||||
"""Grepping with an explicit path forwards it to backend.grep."""
|
||||
sess = Session()
|
||||
|
||||
with patch("cli_anything.browser.core.fs.backend.grep") as mock_grep, \
|
||||
patch("cli_anything.browser.core.fs.backend.cd") as mock_cd:
|
||||
with patch("cli_anything.browser.core.fs.backend.grep") as mock_grep:
|
||||
mock_grep.return_value = {"matches": ["/main/button[0]"]}
|
||||
mock_cd.return_value = {"path": "/main"}
|
||||
|
||||
result = fs.grep_elements(sess, "Login", "/main")
|
||||
|
||||
mock_grep.assert_called_once_with("Login", use_daemon=False)
|
||||
assert mock_cd.call_count == 2
|
||||
mock_cd.assert_any_call("/main", use_daemon=False)
|
||||
mock_cd.assert_any_call("/", use_daemon=False)
|
||||
mock_grep.assert_called_once_with(
|
||||
"Login", path="/main", prev="/", use_daemon=False, session=sess
|
||||
)
|
||||
|
||||
|
||||
# ── Daemon Mode Tests ────────────────────────────────────────────
|
||||
@@ -374,7 +373,7 @@ class TestDaemonMode:
|
||||
|
||||
result = fs.list_elements(sess)
|
||||
|
||||
mock_ls.assert_called_once_with("/", use_daemon=True)
|
||||
mock_ls.assert_called_once_with("/", use_daemon=True, session=sess)
|
||||
|
||||
def test_normal_mode_does_not_use_daemon(self):
|
||||
"""Commands don't use daemon mode when session.daemon_mode is False."""
|
||||
@@ -386,4 +385,57 @@ class TestDaemonMode:
|
||||
|
||||
result = fs.list_elements(sess)
|
||||
|
||||
mock_ls.assert_called_once_with("/", use_daemon=False)
|
||||
mock_ls.assert_called_once_with("/", use_daemon=False, session=sess)
|
||||
|
||||
|
||||
# ── CLI-layer error surfacing (Codex P2 R4) ─────────────────────────
|
||||
|
||||
|
||||
class TestCLIErrorSurfacing:
|
||||
"""The non-JSON branches of `fs ls` and `fs grep` previously fell
|
||||
straight into ``result.get("entries"/"matches", [])`` and surfaced
|
||||
"No elements at …" / "No matches for …" for DOMShell errors. Codex
|
||||
P2 R4 on PR #308 commit 5790651 required surfacing the error
|
||||
message instead.
|
||||
"""
|
||||
|
||||
def _invoke(self, mod_target, error_result, argv):
|
||||
"""Mock the dependency check + the fs_mod target, invoke CLI."""
|
||||
from click.testing import CliRunner
|
||||
from cli_anything.browser.browser_cli import cli
|
||||
|
||||
with patch(
|
||||
"cli_anything.browser.browser_cli.backend.is_available",
|
||||
return_value=(True, "ok"),
|
||||
), patch(
|
||||
f"cli_anything.browser.browser_cli.fs_mod.{mod_target}",
|
||||
return_value=error_result,
|
||||
):
|
||||
return CliRunner().invoke(cli, argv)
|
||||
|
||||
def test_fs_ls_surfaces_error_in_non_json_output(self):
|
||||
"""`fs ls` on a path DOMShell errors against should display the
|
||||
error message — not the misleading "No elements at <path>".
|
||||
"""
|
||||
error_result = {
|
||||
"error": "ls: nonexistent: No such directory",
|
||||
"output": "ls: nonexistent: No such directory",
|
||||
}
|
||||
result = self._invoke(
|
||||
"list_elements", error_result, ["fs", "ls", "/nonexistent"],
|
||||
)
|
||||
# click.echo(err=True) goes to stderr; CliRunner captures both.
|
||||
assert "No such directory" in result.output
|
||||
assert "No elements" not in result.output
|
||||
|
||||
def test_fs_grep_surfaces_error_in_non_json_output(self):
|
||||
"""Mirror for `fs grep`. Codex P2 R4 regression test."""
|
||||
error_result = {
|
||||
"error": "cd: /nonexistent: No such directory",
|
||||
"output": "cd: /nonexistent: No such directory",
|
||||
}
|
||||
result = self._invoke(
|
||||
"grep_elements", error_result, ["fs", "grep", "Login", "/nonexistent"],
|
||||
)
|
||||
assert "No such directory" in result.output
|
||||
assert "No matches" not in result.output
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user