diff --git a/src/webwright/run/cli.py b/src/webwright/run/cli.py
index cc27632..343ab70 100644
--- a/src/webwright/run/cli.py
+++ b/src/webwright/run/cli.py
@@ -12,6 +12,7 @@
from webwright.environments import get_environment
from webwright.models import get_model
from webwright.utils.serialize import UNSET, recursive_merge
+from webwright.run.doctor import run_doctor
DEFAULT_CONFIGS = ["base.yaml", "model_openai.yaml"]
@@ -125,7 +126,9 @@ def run_one(
result["_output_dir"] = str(resolved_output_dir)
if close_exception is not None:
result["_close_exception"] = str(close_exception)
- console.print(result.get("final_response") or result.get("submission") or "Task finished.")
+ console.print(
+ result.get("final_response") or result.get("submission") or "Task finished."
+ )
if run_exception is not None:
raise run_exception
return result
@@ -133,12 +136,22 @@ def run_one(
@app.command()
def main(
- task: str = typer.Option(..., "-t", "--task", help="Natural language task description."),
- task_id: str | None = typer.Option(None, "--task-id", help="Optional identifier used in the output directory name."),
- start_url: str | None = typer.Option(None, "--start-url", help="Optional starting URL for the task."),
+ task: str = typer.Option(
+ ..., "-t", "--task", help="Natural language task description."
+ ),
+ task_id: str | None = typer.Option(
+ None, "--task-id", help="Optional identifier used in the output directory name."
+ ),
+ start_url: str | None = typer.Option(
+ None, "--start-url", help="Optional starting URL for the task."
+ ),
config_spec: list[str] = typer.Option(DEFAULT_CONFIGS, "-c", "--config"),
output_dir: Path | None = typer.Option(None, "-o", "--output-dir"),
- debug: bool = typer.Option(False, "--debug", help="Launch headed local Playwright with devtools and keep it open for inspection."),
+ debug: bool = typer.Option(
+ False,
+ "--debug",
+ help="Launch headed local Playwright with devtools and keep it open for inspection.",
+ ),
) -> Any:
return run_one(
task=task,
@@ -150,5 +163,13 @@ def main(
)
+@app.command()
+def doctor():
+ """
+ Validate local Webwright setup.
+ """
+ run_doctor()
+
+
if __name__ == "__main__":
app()
diff --git a/src/webwright/run/doctor.py b/src/webwright/run/doctor.py
new file mode 100644
index 0000000..d64d5ed
--- /dev/null
+++ b/src/webwright/run/doctor.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from importlib.util import find_spec
+from pathlib import Path
+from rich.console import Console
+from rich.table import Table
+
+console = Console()
+
+
+def check_python():
+ version = sys.version_info
+
+ if version >= (3, 10):
+ return True, f"Python {version.major}.{version.minor}"
+
+ return False, ("Python 3.10+ required\nFix: install Python 3.10 or newer")
+
+
+def check_playwright():
+ if find_spec("playwright") is not None:
+ return True, "playwright installed"
+
+ return False, ("playwright not installed\nFix: pip install playwright")
+
+
+def check_chromium():
+ try:
+ result = subprocess.run(
+ ["playwright", "install", "--dry-run"],
+ capture_output=True,
+ text=True,
+ )
+
+ if result.returncode == 0:
+ return True, "chromium available"
+
+ return False, ("chromium missing\nFix: playwright install chromium")
+
+ except Exception as e:
+ return False, str(e)
+
+
+def check_screenshot():
+ try:
+ from playwright.sync_api import sync_playwright
+
+ screenshot_path = Path("doctor_test.png")
+
+ with sync_playwright() as p:
+ browser = p.chromium.launch(headless=True)
+
+ page = browser.new_page()
+
+ page.set_content("
Webwright Doctor
")
+
+ page.screenshot(path=str(screenshot_path))
+
+ browser.close()
+
+ if screenshot_path.exists():
+ screenshot_path.unlink(missing_ok=True)
+
+ return True, "screenshot capture working"
+
+ return False, "screenshot file was not created"
+
+ except Exception:
+ return False, (
+ "unable to launch Chromium for screenshot validation\n"
+ "Fix: playwright install"
+ )
+
+
+def check_openai_key():
+ if os.getenv("OPENAI_API_KEY"):
+ return True, "OPENAI_API_KEY found"
+
+ return False, (
+ "OPENAI_API_KEY missing\nFix: set the OPENAI_API_KEY environment variable"
+ )
+
+
+def check_plugin_manifests():
+ claude = Path(".claude-plugin/plugin.json")
+ codex = Path(".codex-plugin/plugin.json")
+
+ missing = []
+
+ if not claude.exists():
+ missing.append("Claude")
+
+ if not codex.exists():
+ missing.append("Codex")
+
+ if not missing:
+ return True, "plugin manifests found"
+
+ return False, (
+ f"missing plugin manifests: {', '.join(missing)}\n"
+ "Fix: configure Claude/Codex plugins"
+ )
+
+
+CHECKS = [
+ ("Python", check_python),
+ ("Playwright", check_playwright),
+ ("Chromium", check_chromium),
+ ("Screenshot", check_screenshot),
+ ("OpenAI Key", check_openai_key),
+ ("Plugins", check_plugin_manifests),
+]
+
+
+def run_doctor():
+ table = Table(title="Webwright Doctor")
+
+ table.add_column("Check")
+ table.add_column("Status")
+ table.add_column("Details")
+
+ passed = 0
+
+ for name, fn in CHECKS:
+ ok, message = fn()
+
+ status = "PASS" if ok else "FAIL"
+
+ table.add_row(
+ name,
+ status,
+ message,
+ )
+
+ if ok:
+ passed += 1
+
+ console.print(table)
+
+ console.print(f"\n{passed}/{len(CHECKS)} checks passed")
+
+
+if __name__ == "__main__":
+ run_doctor()
diff --git a/tests/unit/test_doctor.py b/tests/unit/test_doctor.py
new file mode 100644
index 0000000..af2cdd1
--- /dev/null
+++ b/tests/unit/test_doctor.py
@@ -0,0 +1,94 @@
+from pathlib import Path
+
+from webwright.run.doctor import (
+ check_chromium,
+ check_openai_key,
+ check_playwright,
+ check_plugin_manifests,
+ check_python,
+ check_screenshot,
+)
+
+
+def test_check_python():
+ ok, message = check_python()
+
+ assert isinstance(ok, bool)
+ assert isinstance(message, str)
+
+
+def test_check_playwright():
+ ok, message = check_playwright()
+
+ assert isinstance(ok, bool)
+ assert isinstance(message, str)
+
+
+def test_check_chromium():
+ ok, message = check_chromium()
+
+ assert isinstance(ok, bool)
+ assert isinstance(message, str)
+
+
+def test_check_screenshot():
+ ok, message = check_screenshot()
+
+ assert isinstance(ok, bool)
+ assert isinstance(message, str)
+
+
+def test_check_openai_key_exists(monkeypatch):
+ monkeypatch.setenv("OPENAI_API_KEY", "test-key")
+
+ ok, message = check_openai_key()
+
+ assert ok is True
+ assert "found" in message
+
+
+def test_check_openai_key_missing(monkeypatch):
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+
+ ok, message = check_openai_key()
+
+ assert ok is False
+ assert "missing" in message
+
+
+def test_plugin_manifests_exist(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+
+ claude_dir = tmp_path / ".claude-plugin"
+ codex_dir = tmp_path / ".codex-plugin"
+
+ claude_dir.mkdir()
+ codex_dir.mkdir()
+
+ (claude_dir / "plugin.json").write_text("{}")
+ (codex_dir / "plugin.json").write_text("{}")
+
+ ok, message = check_plugin_manifests()
+
+ assert ok is True
+ assert "found" in message
+
+
+def test_plugin_manifests_missing(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+
+ ok, message = check_plugin_manifests()
+
+ assert ok is False
+ assert "missing" in message
+
+
+def test_screenshot_file_cleanup():
+ screenshot_path = Path("doctor_test.png")
+
+ if screenshot_path.exists():
+ screenshot_path.unlink()
+
+ check_screenshot()
+
+ assert not screenshot_path.exists()