Ctrl K
ComunidadGuías…

connect.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
Connect an AI agent to Squidbrake in one command.
(Installed with pip? Type `squidbrake connect ...` wherever this says `python connect.py ...`.)

  python connect.py all                    every agent on this computer at once: Claude Code, Cursor, Codex,
                                           Gemini CLI, VS Code, Antigravity, Qwen Code, Factory Droid, Kiro, and
                                           the MCP servers they use (--remove undoes it)
  python connect.py status                 which agents go through Squidbrake, and whether each will run the hook
  python connect.py doctor                 check everything end to end and say what to fix (squidbrake doctor)
                                           (Codex runs a new hook only after you trust it in /hooks)
  python connect.py claude-code            Claude Code: every tool call goes through the gateway (hook)
                                           + database tools (MCP)
  python connect.py claude-code --remove   undo it
  python connect.py mcp --name antigravity Antigravity, Claude Desktop, Cursor, ...: prints the MCP config to paste
                                           (--install writes it into Antigravity's or Cursor's config for you)

Guard every MCP server your agents already use (Cursor, Windsurf, VS Code, Gemini CLI, Antigravity, Claude Desktop):
  python connect.py guard --agent all          (--remove puts the originals back)

Guard any app (Stripe, GitHub, Gmail, Slack, ... anything with an MCP server) for an agent:
  python connect.py wrap --agent claude-code --app stripe --env STRIPE_SECRET_KEY=sk_... -- npx -y @stripe/mcp --tools=all
  python connect.py wrap --agent antigravity --install --sandbox      (the built-in Acme sandbox company)

Options:
  --url URL      gateway address (default http://localhost:8080)
  --key KEY      use an existing agent key (default: create a new key named after the agent;
                 only works on the machine where the gateway runs)
  --project DIR  Claude Code / Cursor: install for one project folder instead of for all your projects
  --yes          don't ask before changing Claude Code settings
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import shlex
import shutil
import subprocess
import sys
from urllib.parse import urlparse
import threading
import time
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(BASE_DIR))
import offline  # noqa: E402  (where the hooks' agent keys live)
PYTHON = sys.executable
HOOK = BASE_DIR / "claude_hook.py"
MCP_SERVER = BASE_DIR / "gateway_mcp.py"
MCP_NAME = "gateway-db"  # claude_hook.py skips mcp__gateway-db__* so these calls aren't recorded twice


LOCAL_URLS = ("http://localhost", "http://127.0.0.1", "http://[::1]")


def new_key(name: str, url: str = "http://localhost:8080") -> str:
    if not url.startswith(LOCAL_URLS):
        sys.exit(f"This gateway ({url}) runs on another machine, so its keys are made there.\n"
                 f"Ask its admin to add '{name}' as an AI agent in the dashboard's Team tab, then run this again with --key gw_...")
    import server  # the gateway's key store (data/keys.json next to it)
    if server.keystore.from_env:
        sys.exit("Keys come from GATEWAY_API_KEYS on this gateway. Add one there and pass it with --key.")
    # connecting before the gateway's first start: make its first keys now, or the admin key would never be shown
    if created := server.keystore.ensure_initialized():
        server.print_banner(None, created)
    existing = {k["name"] for k in server.keystore.listing()}
    final, n = name, 2
    while final in existing:
        final, n = f"{name}-{n}", n + 1
    key = server.keystore.add(final)
    with server.audited_tx() as conn:
        server.audit(conn, "command-line", "team.added", final, kind="agent", approver=False, roles=[], via="connect.py")
    print(f"Created gateway key '{final}' for this agent.")
    return key


def mcp_entry(url: str, key: str, source: str) -> dict:
    return {"command": PYTHON, "args": [str(MCP_SERVER)],
            "env": {"GATEWAY_URL": url, "GATEWAY_API_KEY": key, "GATEWAY_SOURCE": source}}


# --------------------------------------------------------------------------- claude code

def settings_path(project: str | None) -> Path:
    return (Path(project).resolve() / ".claude" / "settings.json") if project else Path.home() / ".claude" / "settings.json"


def strip_ours(settings: dict) -> dict:
    """Remove hook entries this script added earlier (so re-running doesn't duplicate them)."""
    hooks = settings.get("hooks", {})
    for event in ("PreToolUse", "PostToolUse", "PostToolUseFailure", "UserPromptSubmit"):
        groups = []
        for g in hooks.get(event, []):
            g["hooks"] = [h for h in g.get("hooks", []) if "claude_hook.py" not in json.dumps(h)]
            if g["hooks"]:
                groups.append(g)
        if groups:
            hooks[event] = groups
        else:
            hooks.pop(event, None)
    if not hooks:
        settings.pop("hooks", None)
    return settings


def previous_key(settings: dict, url: str) -> str | None:
    """Re-running connect.py reuses the key it installed last time instead of piling up new ones."""
    for g in settings.get("hooks", {}).get("PreToolUse", []):
        for h in g.get("hooks", []):
            a = h.get("args", [])
            if any("claude_hook.py" in x for x in a) and "--url" in a and a[a.index("--url") + 1] == url:
                return a[a.index("--key") + 1] if "--key" in a else (offline.load_key(url) or None)
    return None


def confirm(question: str, yes: bool) -> bool:
    if yes:
        return True
    try:
        return input(f"{question} [y/N] ").strip().lower() in ("y", "yes")
    except EOFError:      # no one to answer (a script, CI): a no, said plainly instead of a traceback
        print("\nNo answer (not run in a terminal). Add --yes to go ahead without asking.")
        return False


def claude_code(args) -> None:
    path = settings_path(args.project)
    try:
        settings = _read_json(path)
    except ValueError as e:     # never overwrite a file we couldn't read: say which one, and stop
        raise SystemExit(f"{path} isn't valid JSON ({e}). Fix it (or move it away), then run this again.")
    claude = shutil.which("claude")
    scope = ["--scope", "project"] if args.project else ["--scope", "user"]

    if args.remove:
        if not confirm(f"Remove Squidbrake hooks from {path}?", args.yes):
            return
        path.write_text(json.dumps(strip_ours(settings), indent=2), encoding="utf-8")
        if claude:
            subprocess.run([claude, "mcp", "remove", MCP_NAME, *scope], cwd=args.project or None)
        print("Removed. Restart Claude Code to apply.")
        return

    brief = getattr(args, "brief", False)      # inside `connect all`: one line per agent, the rest is said once
    where = f"the project {Path(args.project).resolve()}" if args.project else "ALL your Claude Code projects"
    tools = "" if args.hook_only else ",\nand adds database tools"
    if not brief:
        print(f"This routes every Claude Code tool call in {where} through the gateway at {args.url}{tools}.\n"
              f"It changes {path} (a backup is kept).")
        if not confirm("Continue?", args.yes):
            return
    key = args.key or previous_key(settings, args.url) or new_key("claude-code", args.url)

    offline.save_key(args.url, key)          # the key stays out of settings.json, where the agent could read it
    hook_cmd = {"type": "command", "command": PYTHON,
                "args": [str(HOOK), "--url", args.url, "--source", "claude-code"]}
    settings = strip_ours(settings)
    hooks = settings.setdefault("hooks", {})
    hooks.setdefault("PreToolUse", []).append({"matcher": "*", "hooks": [{**hook_cmd, "timeout": 600}]})
    hooks.setdefault("PostToolUse", []).append({"matcher": "*", "hooks": [{**hook_cmd, "timeout": 30}]})
    # a tool that errored (e.g. a command that exited non-zero) reports here instead of PostToolUse
    hooks.setdefault("PostToolUseFailure", []).append({"matcher": "*", "hooks": [{**hook_cmd, "timeout": 30}]})
    # what you ask, so the gateway can tell what came from you and what came from a web page or an email
    hooks.setdefault("UserPromptSubmit", []).append({"hooks": [{**hook_cmd, "timeout": 15}]})
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.exists():
        shutil.copy2(path, path.with_name(f"settings.json.bak-{time.strftime('%Y%m%d-%H%M%S')}"))
    path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
    if brief:
        print(f"claude-code: hook added (every tool call) in {path}, backup kept")
        return
    print(f"Added the hook to {path}")
    if args.hook_only:
        print(f"\nDone. Restart Claude Code; every tool call now goes through {args.url}/dashboard")
        return

    entry = mcp_entry(args.url, key, "claude-code")
    cmd = [claude or "claude", "mcp", "add", "--transport", "stdio", MCP_NAME, *scope,
           *[x for k, v in entry["env"].items() for x in ("--env", f"{k}={v}")], "--", entry["command"], *entry["args"]]
    if claude:
        subprocess.run([claude, "mcp", "remove", MCP_NAME, *scope], cwd=args.project or None,
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        ok = subprocess.run(cmd, cwd=args.project or None).returncode == 0
        print("Added the database tools (MCP server 'gateway-db')." if ok else "Adding the MCP server failed; see above.")
    else:
        print("The `claude` command wasn't found, so add the database tools yourself with:\n  " + subprocess.list2cmdline(cmd))
    print("\nDone. Restart Claude Code, then try asking it:\n"
          '  "Using the gateway-db tools, show me the top 5 customers by revenue"\n'
          '  "Give every customer on the team plan a 15% discount"   <- waits for your approval\n'
          '  "Drop the orders table"                                <- blocked\n'
          f"Watch it all at {args.url}/dashboard")


# --------------------------------------------------------------------------- other MCP clients

WHERE = {
    "antigravity": "Antigravity: in the Agent panel open the '...' menu > MCP Servers > Manage MCP Servers > "
                   "View raw config, and merge this into mcp_config.json",
    "claude-desktop": "Claude Desktop: Settings > Developer > Edit Config, and merge this into claude_desktop_config.json",
    "cursor": "Cursor: merge this into ~/.cursor/mcp.json (or Settings > MCP > Add new server)",
}


def mcp(args) -> None:
    name = args.name
    key = args.key or existing_mcp_key(name, args.url, getattr(args, "project", None)) or new_key(name, args.url)
    deliver(name, MCP_NAME, mcp_entry(args.url, key, name), args,
            f"The agent then has list_tables / describe_table / query / execute tools; watch at {args.url}/dashboard")


# --------------------------------------------------------------------------- guard any app

def antigravity_config() -> Path:          # looked up each time, so tests (and a changed HOME) see the right one
    return Path.home() / ".gemini" / "antigravity" / "mcp_config.json"


def cursor_config(project: str | None = None) -> Path:
    return (Path(project).resolve() / ".cursor" / "mcp.json") if project else Path.home() / ".cursor" / "mcp.json"


PROXY = BASE_DIR / "gateway_proxy.py"
SANDBOX = BASE_DIR / "demo_apps_mcp.py"


def agent_configs() -> dict[str, Path]:
    """Where each agent keeps its MCP servers (only read here, to find a key we gave it before)."""
    appdata = Path(os.getenv("APPDATA") or Path.home() / "AppData" / "Roaming")
    desktop = (appdata / "Claude" if sys.platform == "win32" else
               Path.home() / "Library" / "Application Support" / "Claude" if sys.platform == "darwin" else
               Path.home() / ".config" / "Claude")
    return {"antigravity": antigravity_config(), "cursor": cursor_config(),
            "claude-desktop": desktop / "claude_desktop_config.json"}


def existing_mcp_key(agent: str, url: str, project: str | None = None) -> str | None:
    """Reuse the key this agent already has (from its MCP config) instead of making a new one."""
    paths = [cursor_config(project), cursor_config()] if agent == "cursor" and project else [agent_configs().get(agent)]
    for path in paths:
        if path and path.exists():
            try:
                servers = json.loads(path.read_text(encoding="utf-8") or "{}").get("mcpServers", {})
            except ValueError:
                continue
            for s in servers.values():
                env = s.get("env", {})
                if env.get("GATEWAY_URL") == url and env.get("GATEWAY_API_KEY"):
                    return env["GATEWAY_API_KEY"]
    return None


def install_json(path: Path, server_name: str, entry: dict) -> None:
    text = path.read_text(encoding="utf-8").strip() if path.exists() else ""
    data = json.loads(text) if text else {}
    if path.exists():
        shutil.copy2(path, path.with_name(f"{path.name}.bak-{time.strftime('%Y%m%d-%H%M%S')}"))
    data.setdefault("mcpServers", {})[server_name] = entry
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2), encoding="utf-8")


def deliver(agent: str, server_name: str, entry: dict, args, done: str) -> None:
    """Install the MCP server for the agent, or print the config to paste."""
    if agent == "claude-code":
        claude = shutil.which("claude")
        scope = ["--scope", "project"] if args.project else ["--scope", "user"]
        cmd = [claude or "claude", "mcp", "add", "--transport", "stdio", server_name, *scope,
               *[x for k, v in entry["env"].items() for x in ("--env", f"{k}={v}")], "--", entry["command"], *entry["args"]]
        if not claude:
            print("The `claude` command wasn't found; add it yourself with:\n  " + subprocess.list2cmdline(cmd))
            return
        subprocess.run([claude, "mcp", "remove", server_name, *scope], cwd=args.project or None,
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        if subprocess.run(cmd, cwd=args.project or None).returncode == 0:
            print(f"Added MCP server '{server_name}' to Claude Code. Restart Claude Code. {done}")
        return
    if agent in ("antigravity", "cursor") and getattr(args, "install", False):
        path = cursor_config(getattr(args, "project", None)) if agent == "cursor" else antigravity_config()
        install_json(path, server_name, entry)
        label = "Cursor" if agent == "cursor" else "Antigravity"
        print(f"Added '{server_name}' to {path} (backup kept). Restart {label}. {done}")
        return
    print("\n" + WHERE.get(agent, "Add this to your agent's MCP configuration") + ", then restart the agent:\n")
    print(json.dumps({"mcpServers": {server_name: entry}}, indent=2))
    print("\n" + done)


def wrap(args) -> None:
    if args.sandbox:
        app, command = args.app or "acme", [PYTHON, str(SANDBOX)]
    else:
        command = [c for c in args.command if c != "--"]
        app = args.app
        if not app or not (command or args.app_url):
            sys.exit("give --app NAME and the app's MCP command after --  (or --app-url URL), or use --sandbox")
    key = args.key
    if not key and args.agent == "claude-code":
        path = settings_path(args.project)
        key = previous_key(json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}, args.url)
    key = key or existing_mcp_key(args.agent, args.url, getattr(args, "project", None)) or new_key(args.agent, args.url)
    env = {"GATEWAY_URL": args.url, "GATEWAY_API_KEY": key, "GATEWAY_SOURCE": args.agent}
    for kv in args.env or []:  # the app's own credentials, e.g. STRIPE_SECRET_KEY=sk_live_...
        k, _, v = kv.partition("=")
        env[k] = v
    target = ["--url", args.app_url] if args.app_url else ["--", *command]
    entry = {"command": PYTHON, "args": [str(PROXY), "--app", app, *target], "env": env}
    # Claude Code's hook skips mcp__gw-* tools: the proxy already records them.
    deliver(args.agent, f"gw-{app}", entry, args,
            f"Its {app} tools now go through the gateway (shown as {app}.<tool>); watch at {args.url}/dashboard")


# --------------------------------------------------------------------------- guard an agent's own MCP servers

def _user_dir() -> Path:
    appdata = Path(os.getenv("APPDATA") or Path.home() / "AppData" / "Roaming")
    return (appdata if sys.platform == "win32" else
            Path.home() / "Library" / "Application Support" if sys.platform == "darwin" else Path.home() / ".config")


def mcp_configs() -> dict[str, list[tuple[Path, str]]]:
    """agent -> its user-level MCP config files, each with the key its servers live under."""
    home = Path.home()
    devin = (Path(os.getenv("APPDATA") or home) / "devin" if sys.platform == "win32" else
             Path(os.getenv("XDG_CONFIG_HOME") or home / ".config") / "devin")
    return {
        "cursor": [(home / ".cursor" / "mcp.json", "mcpServers")],
        "windsurf": [(devin / "mcp_config.json", "mcpServers"), (home / ".codeium" / "windsurf" / "mcp_config.json", "mcpServers")],
        "vscode": [(_user_dir() / "Code" / "User" / "mcp.json", "servers"), (home / ".copilot" / "mcp-config.json", "mcpServers")],
        "gemini-cli": [(home / ".gemini" / "settings.json", "mcpServers")],
        "antigravity": [(home / ".gemini" / "config" / "mcp_config.json", "mcpServers"), (antigravity_config(), "mcpServers")],
        "kiro": [(home / ".kiro" / "settings" / "mcp.json", "mcpServers")],
        "claude-desktop": [(agent_configs()["claude-desktop"], "mcpServers")],
        "qwen-code": [(Path(os.path.expanduser(os.getenv("QWEN_HOME") or home / ".qwen")) / "settings.json", "mcpServers")],
        "cline": [(_user_dir() / "Code" / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings"
                   / "cline_mcp_settings.json", "mcpServers")],
    }


def _originals_path(agent: str, path: Path) -> Path:
    tag = hashlib.sha1(str(path).encode()).hexdigest()[:8]
    return Path.home() / ".squidbrake" / "guarded" / f"{agent}-{tag}.json"


def _ours(entry: dict) -> bool:
    text = json.dumps(entry)
    return "gateway_proxy.py" in text or "gateway_mcp.py" in text or "demo_apps_mcp.py" in text


def guard(args) -> None:
    """Route every MCP server an agent already uses through Squidbrake (or put them back with --remove)."""
    configs = mcp_configs()
    if args.agent != "all" and args.agent not in configs:
        sys.exit(f"don't know where {args.agent} keeps its MCP servers; known: {', '.join(configs)}")
    targets = [(a, path, key_name) for a, files in configs.items() if args.agent in ("all", a)
               for path, key_name in files if path.exists()]
    if not targets:
        print("mcp servers: none of your agents has any configured, nothing to guard" if getattr(args, "brief", False)
              else "No MCP configs found for: " + ", ".join(configs if args.agent == "all" else [args.agent]))
        return
    keys: dict[str, str] = {}
    for agent, path, key_name in targets:
        text = path.read_text(encoding="utf-8").strip() if path.exists() else ""
        try:
            data = json.loads(text) if text else {}
        except ValueError:
            print(f"{agent}: {path} isn't plain JSON (comments?); skipped. Use `connect wrap` for single servers.")
            continue
        servers = data.get(key_name) or {}
        saved_path = _originals_path(agent, path)
        saved = json.loads(saved_path.read_text(encoding="utf-8")) if saved_path.exists() else {}
        if args.remove:
            for name, original in saved.items():
                if name in servers:
                    servers[name] = original
            changed, note = list(saved), "restored"
        else:
            if any(not _ours(e) for e in servers.values() if isinstance(e, dict) and not e.get("disabled")):
                keys[agent] = keys.get(agent) or args.key or existing_mcp_key(agent, args.url) or new_key(agent, args.url)
            key = keys.get(agent, "")
            changed, skipped = [], []
            for name, entry in servers.items():
                if not isinstance(entry, dict) or _ours(entry) or entry.get("disabled"):
                    continue
                remote = entry.get("url") or entry.get("serverUrl") or entry.get("httpUrl")
                headers = entry.get("headers") if isinstance(entry.get("headers"), dict) else {}
                # a remote server's own headers (its auth) go along to it through the proxy
                target = (["--url", remote, *[a for k, v in headers.items() for a in ("--header", f"{k}: {v}")]]
                          if remote else ["--", entry.get("command", ""), *entry.get("args", [])])
                if not remote and not entry.get("command"):
                    skipped.append(name)
                    continue
                app = re.sub(r"[^a-z0-9_-]+", "-", name.lower()).strip("-") or "app"
                env = {**(entry.get("env") or {}), "GATEWAY_URL": args.url, "GATEWAY_API_KEY": key, "GATEWAY_SOURCE": agent}
                saved[name] = entry
                servers[name] = {**({"type": "stdio"} if key_name == "servers" else {}),
                                 "command": PYTHON, "args": [str(PROXY), "--app", app, *target], "env": env}
                changed.append(name)
            note = "now go through Squidbrake" + (f" (skipped: {', '.join(skipped)})" if skipped else "")
        if not changed:
            print(f"{agent}: nothing to {'restore' if args.remove else 'guard'} in {path}")
            continue
        if not args.yes and not confirm(f"{agent}: change {len(changed)} MCP server(s) in {path} (a backup is kept)?", False):
            continue
        shutil.copy2(path, path.with_name(f"{path.name}.bak-{time.strftime('%Y%m%d-%H%M%S')}"))
        data[key_name] = servers
        path.write_text(json.dumps(data, indent=2), encoding="utf-8")
        saved_path.parent.mkdir(parents=True, exist_ok=True)
        if args.remove:
            saved_path.unlink(missing_ok=True)
        else:
            saved_path.write_text(json.dumps(saved, indent=2), encoding="utf-8")
        print(f"{agent}: {', '.join(changed)} {note}." + ("" if getattr(args, "brief", False) else f" Restart {agent} to apply."))


# --------------------------------------------------------------------------- hooks for the other coding agents

AGENT_HOOK = BASE_DIR / "agent_hook.py"


def _q(s: str) -> str:
    return f'"{s}"' if " " in s else s


def _short_path(path: str) -> str:
    """Windows' short (8.3) name for a path, which has no spaces: C:/Users/Rahul Kumar -> C:/Users/RAHULK~1."""
    try:
        import ctypes
        buf = ctypes.create_unicode_buffer(1024)
        n = ctypes.windll.kernel32.GetShortPathNameW(path, buf, len(buf))
        return buf.value if 0 < n < len(buf) else path
    except (AttributeError, OSError):
        return path


def _word(path: str) -> str:
    """A path as one word that cmd, PowerShell and bash all read the same way. Agents run hook commands through
    different shells (Cursor on Windows: PowerShell, or bash when started from Git Bash): to PowerShell a quoted first
    word is a string, not a program to run, and bash drops backslashes. So on Windows: forward slashes, and the short
    name when the path has a space. Only if the drive has no short names does it stay quoted."""
    if os.name == "nt":
        if " " in path:
            path = _short_path(path)
        path = path.replace("\\", "/")
    return _q(path)


def _get(base: str, url: str, **kw):
    """GET from the gateway. One on this computer is reached directly: a system proxy (common on company Windows
    laptops) often doesn't bypass 127.0.0.1, and would make a running gateway look down."""
    import httpx
    host = urlparse(base).hostname or ""
    return httpx.get(url, trust_env=host not in ("localhost", "127.0.0.1", "::1"), **kw)


def _read_json(path: Path) -> dict:
    # utf-8-sig: Notepad and PowerShell 5.1 save JSON with a byte-order mark; an empty file is an empty config
    text = path.read_text(encoding="utf-8-sig").strip() if path.exists() else ""
    return json.loads(text) if text else {}


def _write_json(path: Path, data: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    if path.exists():
        shutil.copy2(path, path.with_name(f"{path.name}.bak-{time.strftime('%Y%m%d-%H%M%S')}"))
    path.write_text(json.dumps(data, indent=2), encoding="utf-8")


def _without_ours(entries: list) -> list:
    return [e for e in entries or [] if "agent_hook.py" not in json.dumps(e)]


def hook_agents() -> dict[str, dict]:
    """agent -> how to tell it's installed, and how to add / remove Squidbrake's hook in its config."""
    home = Path.home()

    def cursor(data, cmd):
        data.setdefault("version", 1)
        hooks = data.setdefault("hooks", {})
        # shell, reads and MCP calls have their own hooks; preToolUse (newer Cursor) adds file edits and deletes
        for ev, matcher in (("beforeShellExecution", None), ("beforeReadFile", None), ("beforeMCPExecution", None),
                            ("preToolUse", "Write|Delete")):
            ours = {"command": cmd, "timeout": 600, "failClosed": True, **({"matcher": matcher} if matcher else {})}
            hooks[ev] = _without_ours(hooks.get(ev)) + ([ours] if cmd else [])
            if not hooks[ev]:
                hooks.pop(ev)

    def grouped(event, matcher, timeout):
        def edit(data, cmd):
            hooks = data.setdefault("hooks", {})
            hooks[event] = _without_ours(hooks.get(event)) + (
                [{"matcher": matcher, "hooks": [{"name": "squidbrake", "type": "command", "command": cmd, "timeout": timeout}]}]
                if cmd else [])
            if not hooks[event]:
                hooks.pop(event)
            if not hooks:
                data.pop("hooks")
        return edit

    def vscode(data, cmd):
        data.clear()
        if cmd:
            data["hooks"] = {"PreToolUse": [{"type": "command", "command": cmd, "timeout": 600}]}

    def antigravity(data, cmd):
        data.pop("squidbrake", None)
        if cmd:
            data["squidbrake"] = {"PreToolUse": [{"matcher": "*", "hooks": [{"type": "command", "command": cmd, "timeout": 600}]}]}

    # Droid reads ~/.factory/hooks.json (events at the top) and, only when that's absent, settings.json's "hooks" key
    factory_dir = home / ".factory"
    factory_file = factory_dir / ("hooks.json" if (factory_dir / "hooks.json").exists() else "settings.json")
    factory_edit = grouped("PreToolUse", "Execute|Read|Edit|Create|ApplyPatch|mcp__.*", 600)

    def factory(data, cmd):
        if factory_file.name == "settings.json":
            return factory_edit(data, cmd)
        wrapped = {"hooks": dict(data)}
        factory_edit(wrapped, cmd)
        data.clear()
        data.update(wrapped.get("hooks", {}))

    def kiro(data, cmd):
        data.clear()                  # our own file in a folder Kiro loads every .json from
        if cmd:                       # one hook per category tag: shell is execute_bash / execute_pwsh
            data.update({"version": "v1", "hooks": [
                {"name": f"squidbrake-{m}", "trigger": "PreToolUse", "matcher": m, "timeout": 600,
                 "action": {"type": "command", "command": cmd}} for m in ("shell", "write", "read")]})

    qwen_dir = Path(os.path.expanduser(os.getenv("QWEN_HOME") or home / ".qwen"))
    kiro_dir = Path(os.path.expanduser(os.getenv("KIRO_HOME") or home / ".kiro"))

    return {
        "cursor": {"present": (home / ".cursor").exists(), "file": home / ".cursor" / "hooks.json", "edit": cursor,
                   "covers": "terminal commands, file reads, edits and deletes, and MCP tools",
                   "misses": "edits on Cursor versions without preToolUse; Cursor's cloud background agents"},
        "gemini-cli": {"present": bool(shutil.which("gemini")) or (home / ".gemini" / "settings.json").exists(),
                       "file": home / ".gemini" / "settings.json",
                       "edit": grouped("BeforeTool", "run_shell_command|write_file|replace|read_file|read_many_files", 600000),
                       "covers": "shell commands, file reads, writes and edits",
                       "misses": "MCP tools (unless connect wrapped the server), web_fetch, memory"},
        "codex": {"present": bool(shutil.which("codex")) or (home / ".codex").exists(), "file": home / ".codex" / "hooks.json",
                  "edit": grouped("PreToolUse", "Bash|shell|apply_patch|Edit|Write|mcp__.*", 600),
                  "covers": "shell commands, edits and MCP tools",
                  "misses": "everything until the hook is trusted in /hooks; Codex cloud tasks"},
        "vscode": {"present": (_user_dir() / "Code" / "User").exists() or (home / ".copilot").exists(),
                   "file": home / ".copilot" / "hooks" / "squidbrake.json", "edit": vscode,
                   "covers": "Copilot agent mode: terminal commands, reads and edits",
                   "misses": "the Copilot coding agent that runs on GitHub"},
        "antigravity": {"present": (home / ".gemini" / "antigravity").exists() or (home / ".gemini" / "config").exists(),
                        "file": home / ".gemini" / "config" / "hooks.json", "edit": antigravity,
                        "covers": "terminal commands, file reads and writes",
                        "misses": "nothing known"},
        "qwen-code": {"present": bool(shutil.which("qwen")) or qwen_dir.exists(), "file": qwen_dir / "settings.json",
                      "edit": grouped("PreToolUse", "^(run_shell_command|write_file|edit|read_file)$", 600),
                      "covers": "shell commands, file reads, writes and edits",
                      "misses": "MCP tools (unless connect wrapped the server); everything when disableAllHooks is on"},
        "factory": {"present": bool(shutil.which("droid")) or factory_dir.exists(), "file": factory_file, "edit": factory,
                    "covers": "commands, file reads, edits and new files, and MCP tools",
                    "misses": "Factory's cloud (remote) sessions"},
        "kiro": {"present": bool(shutil.which("kiro-cli")) or kiro_dir.exists(), "file": kiro_dir / "hooks" / "squidbrake.json",
                 "edit": kiro, "covers": "Kiro CLI: shell commands, file reads and writes",
                 "misses": "the Kiro IDE and Kiro Web, which read hooks from the project's .kiro/hooks only"},
    }


def agents(args) -> None:
    """Add (or with --remove, take out) Squidbrake's pre-execution hook in every coding agent installed here."""
    table = hook_agents()
    chosen = [a for a in table if args.agent in ("all", a)]
    if args.agent != "all" and not chosen:
        sys.exit(f"no hook support for {args.agent} yet; supported: {', '.join(table)}")
    key = None
    for name in chosen:
        t = table[name]
        if not (t["present"] or args.agent == name or args.remove):
            continue
        try:
            data = _read_json(t["file"])
        except ValueError:
            print(f"{name}: {t['file']} isn't plain JSON (comments?); skipped")
            continue
        if args.remove:
            if not t["file"].exists():
                continue
            before = json.dumps(data)
            t["edit"](data, None)
            if json.dumps(data) != before:
                if data:
                    _write_json(t["file"], data)
                else:
                    t["file"].unlink(missing_ok=True)
                print(f"{name}: hook removed")
            continue
        key = key or args.key or new_key("agents", args.url)
        offline.save_key(args.url, key)
        cmd = " ".join([_word(PYTHON), _word(str(AGENT_HOOK)), name, "--url", args.url])
        t["edit"](data, cmd)
        _write_json(t["file"], data)
        print(f"{name}: hook added ({t['covers']})" + ("" if getattr(args, "brief", False) else f". Restart {name} to apply."))
        if name == "codex" and codex_hook_trust() != "trusted":
            print(CODEX_UNTRUSTED)


CODEX_UNTRUSTED = ("  !! Codex won't run it yet: it skips a new hook until you approve it. Until then NOTHING Codex does is\n"
                   "     checked, in any mode. Open Codex, type /hooks and trust the Squidbrake hook. Check with:\n"
                   "     squidbrake connect status")


def codex_hook_trust(timeout: float = 20) -> str | None:
    """Ask Codex itself whether it will run Squidbrake's hook: "trusted", "untrusted" or "modified" (what /hooks
    shows, from its app-server's hooks/list). None if Codex isn't installed, has no Squidbrake hook, or didn't answer."""
    codex = shutil.which("codex")
    if not codex:
        return None
    requests = [{"jsonrpc": "2.0", "id": 1, "method": "initialize",
                 "params": {"clientInfo": {"name": "squidbrake", "version": "1"}}},
                {"jsonrpc": "2.0", "method": "initialized"},
                {"jsonrpc": "2.0", "id": 2, "method": "hooks/list", "params": {"cwds": [str(Path.home())]}}]
    try:
        proc = subprocess.Popen([codex, "app-server"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
                                stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace")
    except OSError:
        return None
    answer: list[dict] = []

    def read() -> None:
        for line in proc.stdout:
            try:
                msg = json.loads(line)
            except ValueError:
                continue
            if msg.get("id") == 2:
                answer.append(msg)
                return

    reader = threading.Thread(target=read, daemon=True)
    reader.start()
    try:
        proc.stdin.write("".join(json.dumps(r) + "\n" for r in requests))
        proc.stdin.flush()
    except OSError:
        pass
    reader.join(timeout)
    proc.kill()
    if not answer:
        return None
    statuses = [h.get("trustStatus") for d in (answer[0].get("result") or {}).get("data", [])
                for h in d.get("hooks", []) if "agent_hook.py" in (h.get("command") or "")]
    if not statuses:
        return None
    return next((s for s in statuses if s not in ("trusted", "managed")), "trusted")


def _hooked_gateway() -> tuple[str, str] | None:
    """(url, key) the installed hooks point at, so status checks a hosted dashboard, not localhost."""
    files = [settings_path(None)] + [t["file"] for t in hook_agents().values()]
    for f in files:
        try:
            cmds = _hook_commands(json.loads(f.read_text(encoding="utf-8", errors="replace"))) if f.exists() else []
        except (OSError, ValueError):
            continue
        for c in cmds:
            if m := re.search(r"--url\s+\"?(\S+?)\"?\s+--key\s+\"?(gw_[A-Za-z0-9_\-]+)", c):
                return m.group(1), m.group(2)
            if (m := re.search(r"--url\s+\"?([^\s\"]+)", c)) and (key := offline.load_key(m.group(1))):
                return m.group(1), key
    return None


AGENT_LABELS = {"claude-code": "Claude Code", "cursor": "Cursor", "codex": "Codex", "gemini-cli": "Gemini CLI",
                "vscode": "VS Code Copilot", "antigravity": "Antigravity", "qwen-code": "Qwen Code",
                "factory": "Factory Droid", "kiro": "Kiro"}


def local_agents() -> list[dict]:
    """Every agent this computer has, and whether Squidbrake's hook is in it (the dashboard's toggles)."""
    on = set(connected_agents())
    found = [{"name": "claude-code", "present": bool(shutil.which("claude")) or (Path.home() / ".claude").exists()}]
    found += [{"name": n, "present": t["present"]} for n, t in hook_agents().items()]
    covered = {"claude-code": ("every tool: commands, reads, edits, web and MCP tools", "nothing known")} |         {n: (t["covers"], t["misses"]) for n, t in hook_agents().items()}
    return [{**a, "label": AGENT_LABELS.get(a["name"], a["name"]), "connected": a["name"] in on,
             "checks": covered[a["name"]][0], "not_checked": covered[a["name"]][1]} for a in found]


def mcp_servers() -> list[dict]:
    """Every MCP server the agents here have configured, and whether it goes through Squidbrake's proxy.
    Names only; commands, URLs and their env (where keys live) never leave this function."""
    found = []
    for agent, files in mcp_configs().items():
        for path, key_name in files:
            try:
                servers = json.loads(path.read_text(encoding="utf-8-sig") or "{}").get(key_name) or {}
            except (OSError, ValueError, AttributeError):
                continue
            if isinstance(servers, dict):
                found += [{"agent": agent, "name": str(n)[:80], "guarded": _ours(e) if isinstance(e, dict) else False}
                          for n, e in servers.items()]
    return found


def report_inventory() -> list[str] | None:
    """Tell the team's gateway which agents this computer has and which go through Squidbrake, so the dashboard can
    show the ones that don't yet. Only names and connected or not; nothing else leaves. Off with
    SQUIDBRAKE_NO_INVENTORY=1. -> the agents not connected, or None if it couldn't be sent."""
    if os.getenv("SQUIDBRAKE_NO_INVENTORY", "").lower() in ("1", "true", "yes"):
        return None
    hooked = _hooked_gateway()
    if not hooked:
        return None
    import socket
    url, key = hooked
    try:
        import httpx
        host = httpx.URL(url).host
        r = httpx.post(f"{url.rstrip('/')}/v1/agents/inventory", headers={"X-Gateway-Key": key}, timeout=5,
                       trust_env=host not in ("localhost", "127.0.0.1", "::1"),
                       json={"machine": socket.gethostname()[:100] or "unknown", "agents": local_agents(),
                             "mcp": mcp_servers()[:200]})
        r.raise_for_status()
        return r.json().get("not_connected") or []
    except Exception:
        return None


def set_agent(name: str, on: bool, url: str) -> None:
    """Connect (or take out) one agent, the way `connect all --agent NAME` does, with the key the other hooks use."""
    if name not in AGENT_LABELS:
        raise ValueError(f"unknown agent {name}")
    hooked = _hooked_gateway()
    key = hooked[1] if hooked else None
    args = argparse.Namespace(url=url, key=key, remove=not on, yes=True, agent=name, project=None, hook_only=True)
    if name == "claude-code":
        claude_code(args)
    else:
        agents(args)
        if name in ("cursor", "vscode", "gemini-cli", "antigravity", "qwen-code"):
            guard(args)
    remember_off([name], not on)


def _off_path() -> Path:
    import service
    return Path(os.getenv("SQUIDBRAKE_AGENTS_OFF") or service.state_dir() / "agents-off.json")


def agents_off() -> set[str]:
    """Agents a person took out on purpose: auto_connect leaves them alone."""
    try:
        return set(json.loads(_off_path().read_text(encoding="utf-8")))
    except (OSError, ValueError, TypeError):
        return set()


def remember_off(names: list[str], off: bool) -> None:
    now = agents_off() | set(names) if off else agents_off() - set(names)
    try:
        _off_path().parent.mkdir(parents=True, exist_ok=True)
        _off_path().write_text(json.dumps(sorted(now)), encoding="utf-8")
    except OSError:
        pass


def auto(_args=None) -> None:
    """`connect.py auto`: auto_connect, then the dashboard opens saying which agents joined. A hook starts it once a
    day where the dashboard is hosted (hooklog.daily); a gateway on this computer does it itself (server.py)."""
    if added := auto_connect():
        import onboard
        onboard.open_dashboard(onboard.dashboard_url() + "#connected=" + ",".join(added))


def auto_connect() -> list[str]:
    """Install once, covered everywhere: an agent installed after setup (Cursor next week) gets the hook the way setup
    would have given it. Only where Squidbrake is set up (another agent's hook points at a gateway), never an agent a
    person took out. -> the agents connected now."""
    import contextlib
    import io
    hooked = _hooked_gateway()
    if not hooked:
        return []
    off = agents_off()
    new = [a["name"] for a in local_agents() if a["present"] and not a["connected"] and a["name"] not in off]
    for name in new:
        with contextlib.redirect_stdout(io.StringIO()):
            set_agent(name, True, hooked[0])
    return new


def connected_agents() -> list[str]:
    """The agents on this computer whose config has Squidbrake's hook in it (no network, nothing run)."""
    found = []
    claude = settings_path(None)
    try:
        text = claude.read_text(encoding="utf-8", errors="replace") if claude.exists() else ""
        if "claude_hook.py" in text or ("squidbrake" in text and re.search(r"\bhook\b", text)):   # or the plugin's
            found.append("claude-code")
    except OSError:
        pass
    for name, t in hook_agents().items():
        try:
            text = t["file"].read_text(encoding="utf-8", errors="replace") if t["file"].exists() else ""
        except OSError:
            continue
        if "agent_hook.py" in text or "agent-hook" in text:
            found.append(name)
    return found


def status(args) -> int:
    """Which agents here go through Squidbrake, and whether each one will really run the hook."""
    hooked = _hooked_gateway()
    url = args.url or (hooked[0] if hooked else "http://localhost:8080")
    key = args.key or (hooked[1] if hooked and url == hooked[0] else None)
    hosted = not url.startswith(("http://localhost", "http://127.0.0.1"))
    try:
        import httpx
        up = _get(url, f"{url}/health", timeout=8).status_code == 200
        accepted = None if not key else _get(url, f"{url}/v1/me", headers={"X-Gateway-Key": key}, timeout=8).status_code == 200
    except Exception:
        up, accepted = False, None
    print(f"gateway       {url}: {'running' if up else 'NOT REACHABLE' + ('' if hosted else ' (start it: squidbrake)')}")
    if accepted is False:
        print("              the key in your agents' hooks is REJECTED: run the install command from your start page "
              "again with your current agent key")
    problems = 0 if up and accepted is not False else 1
    claude = settings_path(None)
    if (Path.home() / ".claude").exists() or shutil.which("claude"):
        hooked = claude.exists() and "claude_hook.py" in claude.read_text(encoding="utf-8", errors="replace")
        print(f"{'claude-code':13} {'connected' if hooked else 'no hook in settings.json (fine if you use the plugin)'}")
    for name, t in hook_agents().items():
        if not t["present"]:
            continue
        text = t["file"].read_text(encoding="utf-8", errors="replace") if t["file"].exists() else ""
        if "agent_hook.py" not in text:
            print(f"{name:13} not connected (squidbrake connect agents --agent {name})")
            problems += 1
            continue
        if name == "codex":
            trust = codex_hook_trust()
            if trust == "trusted":
                print(f"{name:13} connected (hook trusted in Codex)")
            else:
                print(f"{name:13} hook added, but {('NOT TRUSTED' if trust else 'trust unknown (Codex did not answer)')}")
                print(CODEX_UNTRUSTED)
                problems += 1
            continue
        print(f"{name:13} connected")
    return 1 if problems else 0


# --------------------------------------------------------------------------- doctor: is it all really working?

DOCTOR_COMMAND = "echo squidbrake doctor check"     # read-only: allowed without anyone approving
DOCTOR_EVENTS = {
    "claude-code": {"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_input": {"command": DOCTOR_COMMAND},
                    "session_id": "squidbrake-doctor"},
    "cursor": {"hook_event_name": "beforeShellExecution", "command": DOCTOR_COMMAND, "conversation_id": "squidbrake-doctor"},
    "antigravity": {"toolCall": {"name": "run_command", "args": {"CommandLine": DOCTOR_COMMAND}},
                    "conversationId": "squidbrake-doctor"},
}
RESTART = {"cursor": "Quit Cursor completely (Cmd+Q on a Mac, File > Exit on Windows) and open it again",
           "claude-code": "Close every Claude Code window and start it again",
           "codex": "Quit Codex and start it again", "gemini-cli": "Quit Gemini CLI and start it again",
           "vscode": "Quit VS Code completely and open it again", "antigravity": "Quit Antigravity and open it again",
           "qwen-code": "Quit Qwen Code and start it again", "factory": "Quit Droid and start it again",
           "kiro": "Start a new Kiro CLI session"}


def _join(parts: list[str]) -> str:
    return subprocess.list2cmdline(parts) if os.name == "nt" else " ".join(shlex.quote(p) for p in parts)


def _hook_commands(data) -> list[str]:
    """Every Squidbrake hook command in an agent's config, wherever that agent nests it, as one command line
    (Claude Code keeps the program in "command" and its arguments in "args")."""
    found = []
    if isinstance(data, dict):
        cmd, args = data.get("command"), data.get("args")
        if isinstance(cmd, str):
            full = _join([cmd, *map(str, args)]) if isinstance(args, list) and args else cmd
            if re.search(r"agent_hook\.py|claude_hook\.py|squidbrake\S* (agent-)?hook", full):
                found.append(full)
        for k, v in data.items():
            if k not in ("command", "args"):
                found += _hook_commands(v)
    elif isinstance(data, list):
        for v in data:
            found += _hook_commands(v)
    return list(dict.fromkeys(found))


def _run_hook(cmd: str, event: dict, powershell: bool = True) -> tuple[bool, str]:
    """Run the hook exactly as the agent would, with a harmless command. -> (allowed, what it said)
    On Windows also through PowerShell, which is what Cursor runs hooks with: a line cmd runs can fail there.
    Not for Claude Code: it runs "command" with "args" directly, no shell (a quoted path is fine there)."""
    if powershell and os.name == "nt" and (ps := shutil.which("powershell") or shutil.which("pwsh")):
        ok, said = _run_hook_in(cmd, event, True)
        if not ok:
            return ok, said
        ok, said = _run_hook_in([ps, "-NoProfile", "-NonInteractive", "-Command", cmd], event, False)
        return ok, (f"in PowerShell (how Cursor runs it): {said}" if not ok else said)
    return _run_hook_in(cmd, event, True)


def _run_hook_in(cmd, event: dict, shell: bool) -> tuple[bool, str]:
    env = {**os.environ, "SQUIDBRAKE_DOCTOR": "1"}
    try:
        p = subprocess.run(cmd, shell=shell, input=json.dumps({**event, "cwd": str(Path.home())}), capture_output=True,
                           text=True, timeout=45, env=env, cwd=str(Path.home()))
    except (OSError, subprocess.SubprocessError) as e:
        return False, str(e)
    out = (p.stdout or "") + (p.stderr or "")
    denied = re.search(r'"(permission|decision|permissionDecision)"\s*:\s*"deny"', p.stdout or "")
    if p.returncode != 0 or denied:
        m = re.search(r'"(?:user_message|reason|permissionDecisionReason)"\s*:\s*"([^"]+)"', out)
        return False, (m.group(1) if m else out.strip()[-300:] or f"exit code {p.returncode}")
    return True, ""


def _cursor_version() -> str | None:
    import plistlib
    for app in (Path("/Applications/Cursor.app"), Path.home() / "Applications" / "Cursor.app"):
        try:
            return plistlib.loads((app / "Contents" / "Info.plist").read_bytes()).get("CFBundleShortVersionString")
        except (OSError, ValueError):
            pass
    pkg = Path(os.getenv("LOCALAPPDATA", "")) / "Programs" / "cursor" / "resources" / "app" / "package.json"
    try:
        return json.loads(pkg.read_text(encoding="utf-8")).get("version")
    except (OSError, ValueError):
        return None


def _cursor_hooks_log() -> dict:
    """What Cursor's own hooks log (on disk) says: did it load Squidbrake's hook, has it run it, real errors.
    -> {"loaded": "beforeShellExecution, beforeReadFile" | "" | None, "ran": bool, "errors": [...]}"""
    roots = [Path.home() / "Library" / "Application Support" / "Cursor" / "logs",
             Path(os.getenv("APPDATA", "")) / "Cursor" / "logs", Path.home() / ".config" / "Cursor" / "logs"]
    for root in roots:
        try:
            sessions = sorted((d for d in root.iterdir() if d.is_dir()), key=lambda d: d.stat().st_mtime)[-2:]
        except OSError:
            continue
        logs = sorted((f for s in sessions for f in s.rglob("*.log") if "hook" in f.name.lower()),
                      key=lambda f: f.stat().st_mtime, reverse=True)
        if not logs:
            continue
        lines = logs[0].read_text(encoding="utf-8", errors="replace").splitlines()
        loaded = None
        for l in lines:
            if m := re.search(r"Loaded (\d+) user hook\(s\) for steps:\s*(.*)$", l):
                loaded = m.group(2).strip() if int(m.group(1)) else ""
        ran = any(re.search(r"Hook step requested: (beforeShellExecution|beforeReadFile)", l) for l in lines)
        # not real problems: a project without its own hooks file, and Claude Code events Cursor doesn't know
        noise = re.compile(r"Failed to parse project hooks configuration|No project hooks|\[Claude\] Unknown", re.I)
        errors = [l.strip()[:200] for l in lines if re.search(r"error|fail|invalid", l, re.I) and not noise.search(l)]
        return {"loaded": loaded, "ran": ran, "errors": errors[-3:]}
    return {"loaded": None, "ran": False, "errors": []}


def doctor(args) -> int:
    """Everything a person would otherwise check by hand, in one go, with what to do about each problem."""
    from datetime import timedelta

    import httpx
    import hooklog
    bad = warn = used = 0
    unused: list[tuple[str, str, str, list[str]]] = []

    def say(mark, text, fix=None):
        nonlocal bad, warn
        bad += mark == "X"
        warn += mark == "!"
        print(f"  [{mark if mark != 'OK' else 'OK'}] {text}")
        if fix:
            print(f"       -> {fix}")

    print("\nSquidbrake doctor\n")
    try:
        from squidbrake import __version__ as mine
    except Exception:
        try:
            from importlib.metadata import version as _v
            mine = _v("squidbrake")
        except Exception:
            mine = "?"
    latest = None
    no_check = os.getenv("SQUIDBRAKE_NO_UPDATE_CHECK") or os.getenv("DO_NOT_TRACK", "").lower() in ("1", "true", "yes")
    try:     # the same switches as the daily update check turn this off (README)
        latest = None if no_check else httpx.get("https://pypi.org/pypi/squidbrake/json", timeout=6).json()["info"]["version"]
    except Exception:
        latest = None
    if latest and mine != "?" and tuple(int(x) for x in re.findall(r"\d+", mine)[:3]) < tuple(int(x) for x in re.findall(r"\d+", latest)[:3]):
        say("!", f"Squidbrake {mine} (latest is {latest})", "run the install command from your start page again to update")
    else:
        say("OK", f"Squidbrake {mine}")

    hooked = _hooked_gateway()
    url = args.url or (hooked[0] if hooked else "http://localhost:8080")
    key = args.key or (hooked[1] if hooked and url == hooked[0] else None)
    local = url.startswith(("http://localhost", "http://127.0.0.1"))
    try:
        up = _get(url, f"{url}/health", timeout=8).status_code == 200
    except Exception:
        up = False
    if up:
        say("OK", f"Dashboard answers: {url}")
    else:
        say("X", f"Dashboard doesn't answer: {url}",
            "start it with: squidbrake" if local else "it may have been deleted; ask whoever sent you the link for a new one")
    if up and key:
        try:
            ok = _get(url, f"{url}/v1/me", headers={"X-Gateway-Key": key}, timeout=8).status_code == 200
        except Exception:
            ok = None
        if ok is False:
            say("X", "The agents' key is rejected by the dashboard",
                "run the install command from your start page again, with your current AGENT key")
        elif ok:
            say("OK", "The agents' key works")

    agents = {}
    claude = settings_path(None)
    if claude.exists() or (Path.home() / ".claude").exists() or shutil.which("claude"):
        agents["claude-code"] = claude
    for name, t in hook_agents().items():
        if t["present"]:
            agents[name] = t["file"]
    if not agents:
        say("X", "No coding agent found on this computer (Claude Code, Cursor, Codex, Gemini CLI, VS Code, Antigravity, Qwen Code, Factory Droid, Kiro)")
    for name, f in agents.items():
        try:
            cmds = _hook_commands(json.loads(f.read_text(encoding="utf-8-sig"))) if f.exists() else []
        except (OSError, ValueError):
            cmds = []
        if not cmds:
            say("X", f"{name}: not connected", "run the install command from your start page again (or: squidbrake connect all)")
            continue
        exe = cmds[0].strip()
        try:     # the program, quoted or not, with spaces in its path (C:\Users\Rahul Kumar\..., /Users/a b/...)
            exe = shlex.split(exe, posix=os.name != "nt")[0].strip('"')
        except ValueError:
            exe = exe.split()[0]
        if not Path(exe).exists() and not shutil.which(exe):
            say("X", f"{name}: its hook points to a Squidbrake that isn't installed any more ({exe})",
                "run the install command from your start page again")
            continue
        allowed, said = _run_hook(cmds[0], DOCTOR_EVENTS.get(name, {"hook_event_name": "PreToolUse", "tool_name": "Bash",
                                                                      "tool_input": {"command": DOCTOR_COMMAND},
                                                                      "session_id": "squidbrake-doctor"}),
                                  powershell=name != "claude-code")
        if not allowed:
            say("X", f"{name}: the hook runs but answered: {said[:240]}",
                "fix the dashboard or key line above, then run: squidbrake doctor")
            continue
        if name == "codex" and codex_hook_trust() != "trusted":
            say("X", "codex: hook works, but Codex hasn't trusted it yet, so Codex skips it",
                "open Codex, type /hooks, and trust the Squidbrake hook")
            continue
        last = hooklog.last_call(name) or (hooklog.last_call("cursor-via-claude-code") if name == "cursor" else None)
        since = datetime_from_mtime(f)
        if last and last >= since - timedelta(seconds=2):   # timestamps and file times differ slightly
            say("OK", f"{name}: connected, and it used the hook {ago(last)}")
            used += 1
        elif args.quick:
            say("OK", f"{name}: connected; the hook works", RESTART.get(name, f"restart {name}") + ", then use it as usual")
        elif name == "cursor" and (log := _cursor_hooks_log())["loaded"]:
            # Cursor says it loaded the hook: it just hasn't had a terminal command or file read to check yet
            unused.append((name, f"cursor: connected, and Cursor has loaded the hook ({log['loaded']}), but its agent "
                                 "hasn't run a terminal command since",
                           "in Cursor's Agent chat (Ctrl+I / Cmd+I), type: Run git status in the terminal. Commands you "
                           "type into the terminal yourself aren't sent to the hook. Then run: squidbrake doctor", []))
        else:
            extra, log = "", (_cursor_hooks_log() if name == "cursor" else None)
            if name == "cursor":
                v = _cursor_version()
                extra = f" (Cursor {v})" if v else ""
                if log["loaded"] == "":
                    extra += "; Cursor's log says it loaded no user hooks"
            unused.append((name, f"{name}: connected and the hook works, but {name} hasn't run it since it was connected{extra}",
                           RESTART.get(name, f"restart {name}") + ", then ask its agent (in its chat, not by typing in "
                           "the terminal yourself) to run a terminal command, like: Run git status in the terminal. "
                           "Then run: squidbrake doctor", (log or {}).get("errors", [])))
    covered = {"claude-code": ("every tool: commands, reads, edits, web and MCP tools", "nothing known")} |         {n: (t["covers"], t["misses"]) for n, t in hook_agents().items()}
    for name, f in agents.items():
        if f.exists() and name in covered:
            print(f"  [-] {name} checks: {covered[name][0]}. Not checked: {covered[name][1]}")
    if local:
        try:
            import integrity
            import server
            # the gateway's own snapshot: without the key files and version, every key file looks removed
            changes = integrity.compare(server.KEYS_PATH.parent, server.integrity_now())
            if changes:
                say("X", f"Squidbrake's own files changed: {integrity.describe(changes)}",
                    "if you did this: squidbrake integrity accept. If not, look at what your agents did just before")
            else:
                say("OK", "Hooks, rules and installed files are as they were (integrity)")
        except Exception as e:
            say("!", f"Couldn't check Squidbrake's own files ({e})")
    open_mcp = [f"{s['name']} ({s['agent']})" for s in mcp_servers() if not s["guarded"]]
    if open_mcp:
        say("!", f"MCP servers that don't go through Squidbrake: {', '.join(open_mcp[:10])}"
                 + (f" and {len(open_mcp) - 10} more" if len(open_mcp) > 10 else ""), "squidbrake connect guard --agent all")
    missing = report_inventory()          # the team's dashboard lists agents that don't go through Squidbrake yet
    if missing:
        say("!", f"Not connected on this computer: {', '.join(missing)}. Their actions aren't checked or recorded",
            "squidbrake connect all")
    for name, text, fix, errors in unused:
        if used:   # another agent already works through it: one you don't open isn't a problem
            print(f"  [-] {name}: connected, not used yet (fine if you don't use {name})")
            continue
        say("!", text, fix)
        for line in errors:
            print(f"       Cursor's hooks log: {line}")
    print()
    if bad:
        print("Fix the [X] lines above, then run: squidbrake doctor\n")
    elif warn:
        print("Almost there: do what the [!] lines say, then run: squidbrake doctor\n")
    else:
        print("Everything works.\n")
    return 1 if bad else 0


def datetime_from_mtime(f: Path):
    from datetime import datetime, timezone
    return datetime.fromtimestamp(f.stat().st_mtime, timezone.utc)


def ago(t) -> str:
    from datetime import datetime, timezone
    s = int((datetime.now(timezone.utc) - t).total_seconds())
    return "just now" if s < 60 else f"{s // 60} min ago" if s < 3600 else f"{s // 3600} h ago" if s < 86400 else f"{s // 86400} days ago"


# --------------------------------------------------------------------------- everything at once

def connect_all(args) -> None:
    """Claude Code, the other coding agents' hooks, and the MCP servers they already use, in one go."""
    only = [a.strip().lower() for x in (getattr(args, "only", None) or []) for a in x.split(",") if a.strip()]
    known = {"claude-code", *hook_agents()}
    if bad := [a for a in only if a not in known]:
        sys.exit(f"unknown agent {', '.join(bad)}; pick from: {', '.join(sorted(known))}")
    claude = (bool(shutil.which("claude")) or (Path.home() / ".claude").exists()) and (not only or "claude-code" in only)
    others = [a for a in only if a != "claude-code"]
    remember_off(only or sorted(known), args.remove)      # taken out by a person: the gateway won't put it back
    steps = getattr(args, "steps", True)       # `squidbrake setup` runs this as one of its own steps: no header
    if not args.remove:
        if steps:
            import flow
            flow.header(2)
        if only:
            print(f"Only: {', '.join(only)} (the other agents here are left as they are)")
        print(f"\nThis routes what every AI agent on this computer does through the gateway at {args.url}:\n"
              f"  - Claude Code: {'every tool call' if claude else 'not found, skipped'}\n"
              "  - Cursor, Codex, Gemini CLI, VS Code Copilot, Antigravity, Qwen Code, Factory Droid, Kiro (the ones\n"
              "    installed): commands, reads and edits\n"
              "  - the MCP servers those agents already use\n"
              "Every config is backed up first; `connect all --remove` undoes it all.")
        if not confirm("Continue?", args.yes):
            return
        print()
    if getattr(args, "mode", None) and not args.remove:
        set_mode(args.url, args.mode)
    # The first keys are made now and shown at the end, after the agents, where they're easy to find and copy
    created = None if args.remove else _first_keys(args.url)
    each = argparse.Namespace(url=args.url, key=args.key, remove=args.remove, yes=True, agent="all",
                              project=None, hook_only=True, brief=not args.remove)
    try:
        if claude:
            claude_code(each)
        if not only:
            agents(each)
            guard(each)
        for name in others:
            agents(argparse.Namespace(**{**vars(each), "agent": name}))
            if name in ("cursor", "vscode", "gemini-cli", "antigravity", "qwen-code"):         # its MCP servers too
                guard(argparse.Namespace(**{**vars(each), "agent": name}))
    finally:
        if created:                              # whatever stopped it, the keys are shown: they're shown only once
            import server
            server.print_banner(None, created)
    if args.remove:
        return
    print(f"\nUsing Squidbrake with a team? Tell us about it and we'll help you set it up: {TEAM_FORM}?from=cli"
          f"\n(or 15 minutes with the founder: {FOUNDER_CALL})")
    if steps:
        _next_step(args.url)
    if steps and gateway_up(args.url):    # what is and isn't checked now, per agent (`setup` runs its own doctor)
        doctor(argparse.Namespace(url=args.url, key=args.key, quick=True))


# Only printed, never opened or sent: the team can't see who installs, so this is how people can find us
FOUNDER_CALL = "https://calendly.com/pulkitbatra2024/new-meeting-1"
TEAM_FORM = "https://pilots.squidbrake.com/team"


def set_mode(url: str, mode: str) -> None:
    """`mode: observe | enforce` in this computer's gateway rules.yaml (hot-reloaded). A gateway elsewhere: say how."""
    if not url.startswith(LOCAL_URLS):
        print(f"The gateway at {url} isn't on this computer: set `mode: {mode}` in its rules.yaml.")
        return
    import server
    path = server.RULES_PATH
    text = path.read_text(encoding="utf-8") if path.exists() else ""
    line = f"mode: {'shadow' if mode == 'observe' else 'enforce'}"
    new, n = re.subn(r"(?m)^mode:[ \t]*\S+", line, text)
    path.write_text(new if n else f"{line}\n{text}", encoding="utf-8")
    print("Observe mode: nothing is blocked or held; what would have been is recorded (squidbrake report --week)."
          if mode == "observe" else "Enforce mode: blocks and holds for real.")


def _first_keys(url: str) -> dict[str, str] | None:
    """Connecting before the gateway's first start: make its first keys now (new_key would otherwise print them
    in the middle of the agents). None when they exist, or are made elsewhere (a hosted gateway, GATEWAY_API_KEYS)."""
    if not url.startswith(LOCAL_URLS):
        return None
    import server
    if server.keystore.from_env or server.keystore.disabled:
        return None
    return server.keystore.ensure_initialized()


def gateway_up(url: str) -> bool:
    try:
        return _get(url, f"{url}/health", timeout=3).status_code == 200
    except Exception:
        return False


def _next_step(url: str) -> None:
    """The last thing `connect all` prints: the agents are connected, but blocked until the gateway runs.
    The step people forget is starting it, so that's said last, on its own."""
    import flow
    restart = "Then quit and reopen your agents (Claude Code, Cursor, Codex, ...): they pick the hook up on start."
    if gateway_up(url):
        flow.next_step(["Your agents are connected, and the gateway is running at " + url + ".", "",
                        "Last step: quit and reopen your agents (Claude Code, Cursor, Codex, ...), then work as usual.",
                        "Check everything end to end:  " + flow.doctor_command()])
    elif url.startswith(LOCAL_URLS):
        flow.next_step(["Your agents are connected. The gateway isn't running yet: until it is, their tool calls are",
                        "blocked.", "",
                        "Next, step 3 of 3, in this window:   " + flow.start_command(), "", restart])
    else:
        flow.next_step([f"Your agents are connected, but the gateway at {url} isn't answering: until it is, their",
                        "tool calls are blocked. Ask whoever runs it.", "", restart])


def main(argv: list[str] | None = None) -> None:
    p = argparse.ArgumentParser(prog="squidbrake connect" if os.getenv("SQUIDBRAKE_CLI") else None,
                                description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)
    for name in ("all", "status", "doctor", "claude-code", "mcp", "wrap", "guard", "agents", "auto"):
        s = sub.add_parser(name)
        found = name in ("status", "doctor")   # these find the gateway the hooks use
        s.add_argument("--url", default=None if found else "http://localhost:8080",
                       help="the gateway's address" + (" (default: the one your agents' hooks use)" if found else ""))
        s.add_argument("--key")
        if name == "status":
            pass
        elif name == "doctor":
            s.add_argument("--quick", action="store_true", help="right after installing: don't expect the agents "
                                                                 "to have used the hook yet")
        elif name == "all":
            s.add_argument("--remove", action="store_true", help="undo it for every agent")
            s.add_argument("--yes", action="store_true")
            s.add_argument("--agent", dest="only", action="append", metavar="AGENT",
                           help="only this one (repeat, or comma-separated): claude-code, cursor, codex, gemini-cli, "
                                "vscode, antigravity")
            s.add_argument("--mode", choices=["observe", "enforce"],
                           help="observe: never block or hold, only record what would have happened (a trial week); "
                                "enforce: block and hold for real. Sets `mode:` in the gateway's rules.yaml")
        elif name == "agents":
            s.add_argument("--agent", default="all", help="cursor, gemini-cli, codex, vscode, antigravity, or all (every one installed)")
            s.add_argument("--remove", action="store_true", help="take Squidbrake's hook out again")
            s.add_argument("--yes", action="store_true")
        elif name == "guard":
            s.add_argument("--agent", default="all", help="cursor, windsurf, vscode, gemini-cli, antigravity, "
                                                           "claude-desktop, or all (every one installed)")
            s.add_argument("--remove", action="store_true", help="put the original MCP servers back")
            s.add_argument("--yes", action="store_true")
        elif name == "claude-code":
            s.add_argument("--project")
            s.add_argument("--remove", action="store_true")
            s.add_argument("--yes", action="store_true")
            s.add_argument("--hook-only", action="store_true", help="just the hook, without the demo database tools")
        elif name == "mcp":
            s.add_argument("--name", default="mcp-agent", help="antigravity, claude-desktop, cursor, or any label")
            s.add_argument("--install", action="store_true", help="antigravity, cursor: write it into its config")
            s.add_argument("--project", help="cursor: only for this project folder (.cursor/mcp.json)")
        else:
            s.add_argument("--agent", required=True, help="claude-code, antigravity, claude-desktop, cursor, or any label")
            s.add_argument("--app", help="short name for the app, e.g. stripe, github, gmail")
            s.add_argument("--app-url", help="the app's remote MCP server URL (instead of a command)")
            s.add_argument("--sandbox", action="store_true", help="use the built-in Acme sandbox company")
            s.add_argument("--env", action="append", help="KEY=VALUE for the app's own credentials (repeatable)")
            s.add_argument("--project", help="claude-code, cursor: only for this project folder")
            s.add_argument("--install", action="store_true", help="antigravity, cursor: write it into its config")
            s.add_argument("command", nargs=argparse.REMAINDER, help="-- then the app's MCP server command")
    args = p.parse_args(argv)
    args.url = args.url.rstrip("/") if args.url else None
    if args.cmd == "mcp":
        args.agent = args.name
    if args.cmd == "status":
        sys.exit(status(args))
    if args.cmd == "doctor":
        sys.exit(doctor(args))
    {"all": connect_all, "claude-code": claude_code, "mcp": mcp, "wrap": wrap, "guard": guard, "agents": agents,
     "auto": auto}[args.cmd](args)


if __name__ == "__main__":
    main()