"""Lightweight, opt-out-able analytics with switchable providers.""" import atexit import os import platform import re import sys import threading import uuid from pathlib import Path import requests from cli_hub import __version__ ANALYTICS_PROVIDER = "posthog" UMAMI_URL = "https://cloud.umami.is/api/send" UMAMI_WEBSITE_ID = "a076c661-bed1-405c-a522-813794e688b4" POSTHOG_API_HOST = "https://us.i.posthog.com" POSTHOG_PROJECT_TOKEN = "phc_ovP8d5bmjpn8YZnTo7pb6rE3TikcAMgmNVt75o3Ywejz" HOSTNAME = "clianything.cc" USER_AGENT = f"Mozilla/5.0 (compatible; cli-anything-hub/{__version__})" ANALYTICS_ID_FILE = ".analytics_id" _pending_threads = [] _lock = threading.Lock() _AGENT_ENV_RULES = ( ("CLAUDE_CODE", "agent_tool", "claude-code-env"), ("CLAUDECODE", "agent_tool", "claude-code-env-alt"), ("CODEX", "agent_tool", "codex-env"), ("OPENAI_CODEX", "agent_tool", "codex-env-alt"), ("CURSOR_SESSION", "agent_tool", "cursor-session-env"), ("CURSOR_TRACE_ID", "agent_tool", "cursor-trace-env"), ("CLINE_SESSION", "agent_tool", "cline-session-env"), ("AIDER", "agent_tool", "aider-env"), ("AIDER_SESSION_ID", "agent_tool", "aider-session-env"), ("CONTINUE_SESSION", "agent_tool", "continue-session-env"), ("OPENHANDS_AGENT", "agent_tool", "openhands-agent-env"), ("OPENHANDS_RUNTIME", "agent_tool", "openhands-runtime-env"), ("BROWSER_USE", "agent_tool", "browser-use-env"), ("STAGEHAND", "agent_tool", "stagehand-env"), ("GOOSE_AGENT", "agent_tool", "goose-agent-env"), ("ROO_CODE", "agent_tool", "roo-code-env"), ("WINDSURF_AGENT", "agent_tool", "windsurf-agent-env"), ) _PARENT_PROCESS_RULES = ( ("claude-code-process", "agent_tool", re.compile(r"\bclaude(?:[ -]?code)?\b", re.IGNORECASE)), ("codex-process", "agent_tool", re.compile(r"\bcodex(?:-cli)?\b", re.IGNORECASE)), ("copilot-process", "agent_tool", re.compile(r"\bcopilot(?:-cli)?\b", re.IGNORECASE)), ("cursor-process", "agent_tool", re.compile(r"\bcursor\b", re.IGNORECASE)), ("cline-process", "agent_tool", re.compile(r"\bcline\b", re.IGNORECASE)), ("aider-process", "agent_tool", re.compile(r"\baider\b", re.IGNORECASE)), ("continue-process", "agent_tool", re.compile(r"\bcontinue\b", re.IGNORECASE)), ("gemini-process", "agent_tool", re.compile(r"\bgemini(?:-cli)?\b", re.IGNORECASE)), ("auggie-process", "agent_tool", re.compile(r"\bauggie(?:-cli)?\b", re.IGNORECASE)), ("augment-process", "agent_tool", re.compile(r"\baugment(?:[ -]?agent)?\b", re.IGNORECASE)), ("amp-process", "agent_tool", re.compile(r"\bamp(?:code)?\b", re.IGNORECASE)), ("opencode-process", "agent_tool", re.compile(r"\bopencode\b", re.IGNORECASE)), ("kilo-process", "agent_tool", re.compile(r"\bkilo(?:code)?\b", re.IGNORECASE)), ("qodo-process", "agent_tool", re.compile(r"\bqodo\b", re.IGNORECASE)), ("kiro-process", "agent_tool", re.compile(r"\bkiro\b", re.IGNORECASE)), ("openhands-process", "agent_tool", re.compile(r"\bopenhands\b", re.IGNORECASE)), ("browser-use-process", "agent_tool", re.compile(r"\bbrowser[- ]use\b", re.IGNORECASE)), ("stagehand-process", "agent_tool", re.compile(r"\bstagehand\b", re.IGNORECASE)), ("roo-process", "agent_tool", re.compile(r"\broo(?:-code)?\b", re.IGNORECASE)), ("windsurf-process", "agent_tool", re.compile(r"\bwindsurf\b", re.IGNORECASE)), ("goose-process", "agent_tool", re.compile(r"\bgoose\b", re.IGNORECASE)), ) def _flush_pending(): """Wait for in-flight analytics requests before process exit.""" with _lock: threads = list(_pending_threads) for t in threads: t.join(timeout=3) atexit.register(_flush_pending) def _is_enabled(): return os.environ.get("CLI_HUB_NO_ANALYTICS", "").strip() not in ("1", "true", "yes") def _provider(): provider = os.environ.get("CLI_HUB_ANALYTICS_PROVIDER", ANALYTICS_PROVIDER).strip().lower() return provider if provider in {"posthog", "umami"} else ANALYTICS_PROVIDER def _analytics_dir(): return Path.home() / ".cli-hub" def _stdin_is_tty(): try: return sys.stdin.isatty() except Exception: return False def _read_parent_pid(pid): status_path = Path("/proc") / str(pid) / "status" try: for line in status_path.read_text().splitlines(): if line.startswith("PPid:"): parts = line.split() return int(parts[1]) if len(parts) > 1 else None except Exception: return None return None def _read_process_cmdline(pid): cmdline_path = Path("/proc") / str(pid) / "cmdline" try: raw = cmdline_path.read_bytes() except Exception: return "" return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip() def _parent_process_commands(max_depth=4): commands = [] pid = os.getpid() for _ in range(max_depth): pid = _read_parent_pid(pid) if not pid or pid <= 1: break cmd = _read_process_cmdline(pid) if cmd: commands.append(cmd) return commands def detect_invocation_context(): """Classify the current cli-hub invocation as human, agent, or scripted.""" signals = [] for env_name, category, signal_id in _AGENT_ENV_RULES: if os.environ.get(env_name): signals.append({"id": signal_id, "category": category}) for cmd in _parent_process_commands(): for signal_id, category, pattern in _PARENT_PROCESS_RULES: if pattern.search(cmd): signals.append({"id": signal_id, "category": category}) seen = set() unique_signals = [] for signal in signals: if signal["id"] in seen: continue seen.add(signal["id"]) unique_signals.append(signal) stdin_tty = _stdin_is_tty() if unique_signals: primary = unique_signals[0] return { "is_agent": True, "traffic_type": "agent", "category": primary["category"], "reason": primary["id"], "signals": [signal["id"] for signal in unique_signals], "stdin_tty": stdin_tty, "is_interactive": stdin_tty, } if not stdin_tty: return { "is_agent": True, "traffic_type": "agent", "category": "scripted_client", "reason": "stdin-not-tty", "signals": ["stdin-not-tty"], "stdin_tty": False, "is_interactive": False, } return { "is_agent": False, "traffic_type": "human", "category": "human", "reason": "human", "signals": [], "stdin_tty": True, "is_interactive": True, } def _get_distinct_id(): override = os.environ.get("CLI_HUB_ANALYTICS_DISTINCT_ID", "").strip() if override: return override marker = _analytics_dir() / ANALYTICS_ID_FILE try: if marker.exists(): value = marker.read_text().strip() if value: return value marker.parent.mkdir(parents=True, exist_ok=True) value = str(uuid.uuid4()) marker.write_text(value) return value except Exception: return f"cli-hub-anon-{uuid.uuid4()}" def _posthog_capture_url(): host = os.environ.get("CLI_HUB_POSTHOG_API_HOST", POSTHOG_API_HOST).rstrip("/") return f"{host}/capture/" def _build_umami_payload(event_name, url, data): return { "type": "event", "payload": { "website": UMAMI_WEBSITE_ID, "hostname": HOSTNAME, "url": url, "name": event_name, "data": data, }, } def _build_posthog_payload(event_name, url, data): return { "api_key": os.environ.get("CLI_HUB_POSTHOG_PROJECT_TOKEN", POSTHOG_PROJECT_TOKEN), "event": event_name, "distinct_id": _get_distinct_id(), "properties": { "$current_url": f"https://{HOSTNAME}{url}", "hostname": HOSTNAME, "source": "cli", "channel": "cli-hub", "hub_version": __version__, **(data or {}), }, } def _send_event(payload): """Send a single event payload. Blocking — callers should use threads.""" try: if _provider() == "umami": return requests.post( UMAMI_URL, json=payload, timeout=5, headers={"User-Agent": USER_AGENT}, ) return requests.post( _posthog_capture_url(), json=payload, timeout=5, headers={"User-Agent": USER_AGENT}, ) except Exception: return None # analytics must never break the user's workflow def track_event(event_name, url="/cli-anything-hub", data=None): """Fire-and-forget event to the active provider. Non-blocking, never raises.""" if not _is_enabled(): return event_data = data or {} if _provider() == "umami": payload = _build_umami_payload(event_name, url, event_data) else: payload = _build_posthog_payload(event_name, url, event_data) t = threading.Thread(target=_send_event, args=(payload,), daemon=True) with _lock: _pending_threads.append(t) t.start() def track_install(cli_name, version): """Track a CLI install event. CLI name goes in properties, not the event name, so the event catalog stays flat and dashboards can break down by properties.cli.""" track_event("cli-install", url=f"/cli-anything-hub/install/{cli_name}", data={ "cli": cli_name, "version": version, "platform": platform.system().lower(), }) def track_uninstall(cli_name): """Track a CLI uninstall event.""" track_event("cli-uninstall", url=f"/cli-anything-hub/uninstall/{cli_name}", data={ "cli": cli_name, "platform": platform.system().lower(), }) def track_launch(cli_name): """Track a CLI launch event — fires when a user runs `cli-hub launch `. Distinct from install: this is actual usage signal.""" track_event("cli-launch", url=f"/cli-anything-hub/launch/{cli_name}", data={ "cli": cli_name, "platform": platform.system().lower(), }) def track_matrix_install(matrix_name, scope="full", status="ok", installed=0, skipped=0, failed=0, dry_run=False): """Track a CLI-Matrix install. The headline conversion signal for matrices: which matrix, the install scope (capability/recipe/only/full), and the outcome. Matrix name and scope go in properties so the event catalog stays flat — dashboards break down by properties.matrix / properties.scope, mirroring track_install.""" track_event("matrix-install", url=f"/cli-anything-hub/matrix/install/{matrix_name}", data={ "matrix": matrix_name, "scope": scope, "status": status, "installed": installed, "skipped": skipped, "failed": failed, "dry_run": dry_run, "platform": platform.system().lower(), }) def track_matrix_preflight(matrix_name, scope="full", covered=0, capabilities=0, gaps=0): """Track a CLI-Matrix preflight (capability-coverage check) — consideration signal that precedes install. Captures how much of the matrix the environment already covers.""" track_event("matrix-preflight", url=f"/cli-anything-hub/matrix/preflight/{matrix_name}", data={ "matrix": matrix_name, "scope": scope, "covered": covered, "capabilities": capabilities, "gaps": gaps, "platform": platform.system().lower(), }) def track_matrix_discover(kind, query="", results=0): """Track CLI-Matrix discovery — `matrix list`, `matrix search`, `matrix recipes`, and `cli-hub can`. `kind` distinguishes the surface; `results` is the hit count.""" track_event("matrix-discover", url="/cli-anything-hub/matrix/discover", data={ "kind": kind, "query": (query or "")[:120], "results": results, "platform": platform.system().lower(), }) def track_matrix_info(matrix_name, kind="info"): """Track inspecting a single CLI-Matrix — `matrix info` and `matrix doctor`.""" track_event("matrix-info", url=f"/cli-anything-hub/matrix/info/{matrix_name}", data={ "matrix": matrix_name, "kind": kind, "platform": platform.system().lower(), }) def track_visit(is_agent=False, command="root", detection=None): """Track a cli-hub invocation using the new cli-hub call event.""" stdin_tty = _stdin_is_tty() context = detection or { "is_agent": is_agent, "traffic_type": "agent" if is_agent else "human", "category": "legacy-agent" if is_agent else "human", "reason": "legacy-flag" if is_agent else "human", "signals": ["legacy-flag"] if is_agent else [], "stdin_tty": stdin_tty, "is_interactive": stdin_tty, } track_event("cli-hub call", url="/cli-anything-hub/call", data={ "command": command, "is_agent": context["is_agent"], "traffic_type": context["traffic_type"], "agent_category": context["category"], "agent_reason": context["reason"], "agent_signals": context["signals"][:12], "stdin_tty": context["stdin_tty"], "is_interactive": context["is_interactive"], "platform": platform.system().lower(), }) def track_first_run(): """Send a one-time 'cli-hub-installed' event on first invocation.""" marker = _analytics_dir() / ".first_run_sent" if marker.exists(): return track_event("cli-anything-hub-installed", url="/cli-anything-hub/installed", data={ "version": __version__, "platform": platform.system().lower(), }) try: marker.parent.mkdir(parents=True, exist_ok=True) marker.write_text(__version__) except Exception: pass def _detect_is_agent(): """Detect if cli-hub is likely being invoked by an AI agent.""" return detect_invocation_context()["is_agent"]