main
John Lauer Add grok_chat_status and grok_chat_read plus hwnd find so the Grok Bot window is visible. 533d687 1mo ago
"""Detect Grok Bot — standalone Electron host app — on Windows / macOS / Linux.

Grok Bot has no public HTTP API. This bridge talks TO it the way kicad talks to
KiCad: detect the host app on disk, then drive windows via ab desktop_* callbacks.

Host app is Grok Bot.exe (window title "Grok Bot"). Cursor is a LAST-RESORT
fallback only if Grok Bot.exe is missing — never the default, never killed.

Never tell the user to download the host app themselves. grok_open offers to
launch it (via ab `launch` / `desktop_open`).
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from pathlib import Path

IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
IS_LINUX = sys.platform.startswith("linux")

CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)

HOST_APP = "Grok Bot"
APP_PATHS_EXE = "Grok Bot.exe"
WINDOWS_HINT = 'titleContains "Grok Bot" (exact; not bare Grok — that matches Chrome ai-grok-bot)'
PROCESS_FILTER = "Grok Bot"
TITLE_NEEDLES = ["Grok Bot"]

FALLBACK_HOST_APP = "Cursor"
FALLBACK_APP_PATHS_EXE = "Cursor.exe"


def _localappdata_roots() -> list[Path]:
    roots: list[Path] = []
    for cand in (
        os.environ.get("LOCALAPPDATA"),
        (os.path.join(os.environ["USERPROFILE"], "AppData", "Local")
         if os.environ.get("USERPROFILE") else None),
        str(Path.home() / "AppData" / "Local"),
    ):
        if cand:
            p = Path(cand)
            if p not in roots:
                roots.append(p)
    return roots


def _programfiles_roots() -> list[Path]:
    roots: list[Path] = []
    for env in ("ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"):
        base = os.environ.get(env)
        if base:
            p = Path(base)
            if p not in roots:
                roots.append(p)
    return roots


def _existing(paths: list[Path]) -> list[str]:
    seen = set()
    out = []
    for p in paths:
        try:
            if p.exists():
                s = str(p)
                if s not in seen:
                    seen.add(s)
                    out.append(s)
        except OSError:
            continue
    return out


def _grok_bot_candidate_exes() -> list[Path]:
    out: list[Path] = []
    if IS_WINDOWS:
        for root in _localappdata_roots():
            out.extend([
                root / "Programs" / "Grok Bot" / "Grok Bot.exe",
                root / "Programs" / "GrokBot" / "Grok Bot.exe",
                root / "Programs" / "grok-bot" / "Grok Bot.exe",
                root / "Grok Bot" / "Grok Bot.exe",
            ])
        for root in _programfiles_roots():
            out.extend([
                root / "Grok Bot" / "Grok Bot.exe",
                root / "GrokBot" / "Grok Bot.exe",
            ])
    elif IS_MACOS:
        out.extend([
            Path("/Applications/Grok Bot.app/Contents/MacOS/Grok Bot"),
            Path("/Applications/Grok Bot.app"),
            Path.home() / "Applications" / "Grok Bot.app",
        ])
    else:
        which = shutil.which("grok-bot") or shutil.which("grokbot")
        if which:
            out.append(Path(which))
        # `Grok Bot` as a PATH name is unusual; try anyway.
        which_space = shutil.which("Grok Bot")
        if which_space:
            out.append(Path(which_space))
        out.extend([
            Path("/usr/bin/grok-bot"),
            Path("/usr/local/bin/grok-bot"),
            Path("/opt/grok-bot/grok-bot"),
            Path("/opt/Grok Bot/Grok Bot"),
            Path.home() / ".local" / "bin" / "grok-bot",
        ])
    return out


def _cursor_candidate_exes() -> list[Path]:
    """Last-resort fallback if Grok Bot.exe is missing."""
    out: list[Path] = []
    if IS_WINDOWS:
        for root in _localappdata_roots():
            out.extend([
                root / "Programs" / "cursor" / "Cursor.exe",
                root / "Programs" / "Cursor" / "Cursor.exe",
                root / "cursor" / "Cursor.exe",
                root / "Cursor" / "Cursor.exe",
            ])
        for root in _programfiles_roots():
            out.extend([
                root / "cursor" / "Cursor.exe",
                root / "Cursor" / "Cursor.exe",
            ])
    elif IS_MACOS:
        out.extend([
            Path("/Applications/Cursor.app/Contents/MacOS/Cursor"),
            Path("/Applications/Cursor.app"),
            Path.home() / "Applications" / "Cursor.app",
        ])
    else:
        which = shutil.which("cursor")
        if which:
            out.append(Path(which))
        out.extend([
            Path("/usr/bin/cursor"),
            Path("/usr/local/bin/cursor"),
            Path("/opt/cursor/cursor"),
            Path("/opt/Cursor/cursor"),
            Path.home() / ".local" / "bin" / "cursor",
            Path("/usr/share/cursor/cursor"),
        ])
    return out


def _tasklist_running(image: str) -> bool:
    try:
        r = subprocess.run(
            ["tasklist", "/FI", f"IMAGENAME eq {image}", "/FO", "CSV", "/NH"],
            capture_output=True, text=True, timeout=8,
            creationflags=CREATE_NO_WINDOW,
        )
        out = (r.stdout or "") + (r.stderr or "")
        if "No tasks" in out or out.strip().startswith("INFO:"):
            return False
        return image.lower() in out.lower()
    except Exception:
        return False


def _pgrep_running(patterns: tuple[str, ...]) -> bool:
    try:
        for pat in patterns:
            r = subprocess.run(
                ["pgrep", "-f", pat],
                capture_output=True, text=True, timeout=5,
            )
            if r.returncode == 0 and (r.stdout or "").strip():
                return True
        return False
    except Exception:
        return False


def _grok_bot_running() -> bool:
    try:
        if IS_WINDOWS:
            return (
                _tasklist_running("Grok Bot.exe")
                or _tasklist_running("GrokBot.exe")
            )
        return _pgrep_running((
            r"[/]Grok Bot[.]app[/]",
            r"[/]Grok Bot$",
            r"grok-bot",
            r"^Grok Bot$",
        ))
    except Exception:
        return False


def _cursor_running() -> bool:
    try:
        if IS_WINDOWS:
            return (
                _tasklist_running("Cursor.exe")
                or _tasklist_running("cursor.exe")
            )
        return _pgrep_running((
            r"[/]Cursor[.]app[/]",
            r"[/][Cc]ursor$",
            r"^cursor$",
        ))
    except Exception:
        return False


def detect_host() -> dict:
    """Return host-app probe. Grok Bot first; Cursor only if Grok Bot.exe is missing."""
    grok_paths = _existing(_grok_bot_candidate_exes())
    grok_installed = bool(grok_paths)
    grok_running = _grok_bot_running()

    if grok_installed or grok_running:
        return {
            "hostApp": HOST_APP,
            "hostAppInstalled": grok_installed or grok_running,
            "hostAppRunning": grok_running,
            "installed": grok_installed or grok_running,
            "running": grok_running,
            "paths": grok_paths,
            "exe": grok_paths[0] if grok_paths else None,
            "appPathsExe": APP_PATHS_EXE,
            "windowsHint": WINDOWS_HINT,
            "processFilter": PROCESS_FILTER,
            "titleNeedles": list(TITLE_NEEDLES),
            "hostAppFallback": None,
            "platform": (
                "windows" if IS_WINDOWS else "macos" if IS_MACOS else "linux"
            ),
        }

    cursor_paths = _existing(_cursor_candidate_exes())
    cursor_running = _cursor_running()
    cursor_installed = bool(cursor_paths)
    fallback_used = cursor_installed or cursor_running
    return {
        "hostApp": HOST_APP,
        "hostAppInstalled": fallback_used,
        "hostAppRunning": cursor_running,
        "installed": fallback_used,
        "running": cursor_running,
        "paths": cursor_paths,
        "exe": cursor_paths[0] if cursor_paths else None,
        "appPathsExe": APP_PATHS_EXE,
        "windowsHint": WINDOWS_HINT + (
            '; Cursor last-resort fallback (Grok Bot.exe missing)'
            if fallback_used else ""
        ),
        "processFilter": "Cursor" if fallback_used else PROCESS_FILTER,
        "titleNeedles": (
            list(TITLE_NEEDLES) + ["Cursor"] if fallback_used else list(TITLE_NEEDLES)
        ),
        "hostAppFallback": "Cursor" if fallback_used else None,
        "fallbackApp": FALLBACK_HOST_APP if fallback_used else None,
        "fallbackExe": FALLBACK_APP_PATHS_EXE if fallback_used else None,
        "platform": (
            "windows" if IS_WINDOWS else "macos" if IS_MACOS else "linux"
        ),
    }


def detect_cursor() -> dict:
    """Back-compat alias — host app is Grok Bot; Cursor is last-resort fallback."""
    return detect_host()


def window_title_needles(info: dict | None = None) -> list[str]:
    info = info or detect_host()
    needles = info.get("titleNeedles")
    if isinstance(needles, list) and needles:
        return [str(n) for n in needles if n]
    return list(TITLE_NEEDLES)


def process_name_filter(info: dict | None = None) -> str:
    info = info or detect_host()
    return str(info.get("processFilter") or PROCESS_FILTER)


def status_chip(info: dict | None = None) -> dict:
    """led / summary / tooltip for GET /status and /health."""
    info = info or detect_host()
    installed = bool(info.get("hostAppInstalled"))
    running = bool(info.get("running"))
    fallback = info.get("hostAppFallback")
    if running and not fallback:
        return {
            "led": "green",
            "summary": "Grok Bot ready",
            "tooltip": (
                "Grok Bot is running. Drive it with grok_list_windows / "
                "grok_screenshot / grok_send / grok_resize / grok_record_start "
                "(background by default; don't steal the keyboard)."
            ),
        }
    if running and fallback:
        return {
            "led": "yellow",
            "summary": "Cursor fallback (Grok Bot.exe missing)",
            "tooltip": (
                "Grok Bot.exe was not found; falling back to Cursor as a last resort. "
                "Prefer installing Grok Bot (C:\\Users\\john\\AppData\\Local\\Programs\\"
                "Grok Bot\\Grok Bot.exe). Drive the current host with grok_open / "
                "grok_list_windows — never kill Cursor."
            ),
        }
    if installed and not fallback:
        return {
            "led": "yellow",
            "summary": "Grok Bot installed, not running",
            "tooltip": (
                "Grok Bot is on disk but not running. Call grok_open to launch it "
                "in the background — it will not steal focus."
            ),
        }
    if installed and fallback:
        return {
            "led": "yellow",
            "summary": "Cursor fallback installed (Grok Bot.exe missing)",
            "tooltip": (
                "Grok Bot.exe is missing. Cursor is a last-resort fallback host. "
                "Call grok_open to launch it in the background. Prefer Grok Bot."
            ),
        }
    return {
        "led": "yellow",
        "summary": "Grok Bot not detected",
        "tooltip": (
            "Grok Bot isn't installed — want me to open it if present? "
            "Call grok_open (it detects, then offers launch). Cursor is only a "
            "last-resort fallback if Grok Bot.exe is missing. Do not send the "
            "user to download the host app themselves."
        ),
    }