From 8cae111a481e40a65db4b93a6ae17f901bfa9802 Mon Sep 17 00:00:00 2001 From: yuhao Date: Thu, 23 Apr 2026 16:27:14 +0000 Subject: [PATCH] feat(cli-hub): migrate analytics to PostHog, add stats pipeline and refined footer - Switch cli-hub analytics client from Umami to PostHog (us.i.posthog.com), with agent/human classification derived from env vars and parent-process names, and persistent analytics id at ~/.cli-hub/.analytics_id. - Add daily deploy-pages step running .github/scripts/update_hub_analytics_stats.py to pull PostHog totals into docs/hub/analytics-stats.json. - Redesign docs/hub footer analytics panel (index.html + index-modern.html) as a compact Apple/OpenAI-style card: live eyebrow, total, 2px ratio bar, and inline Human/Agent split with percentages computed client-side. - Expand cli-hub tests to cover the new analytics context fields and provider switch. --- .github/scripts/update_hub_analytics_stats.py | 149 ++++ .github/workflows/deploy-pages.yml | 8 + cli-hub/README.md | 2 +- cli-hub/cli_hub/analytics.py | 290 +++++++- cli-hub/cli_hub/cli.py | 19 +- cli-hub/tests/test_cli_hub.py | 191 ++++- docs/hub/analytics-stats.json | 37 + docs/hub/index-modern.html | 518 ++++++++++---- docs/hub/index.html | 674 ++++++++++++++---- 9 files changed, 1542 insertions(+), 346 deletions(-) create mode 100644 .github/scripts/update_hub_analytics_stats.py create mode 100644 docs/hub/analytics-stats.json diff --git a/.github/scripts/update_hub_analytics_stats.py b/.github/scripts/update_hub_analytics_stats.py new file mode 100644 index 000000000..6a41b8451 --- /dev/null +++ b/.github/scripts/update_hub_analytics_stats.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Generate docs/hub/analytics-stats.json from a fixed Umami baseline plus PostHog increments.""" + +from __future__ import annotations + +import json +import os +import sys +from datetime import UTC, datetime +from pathlib import Path +from urllib import error, request + + +REPO_ROOT = Path(__file__).resolve().parents[2] +OUTPUT_PATH = REPO_ROOT / "docs" / "hub" / "analytics-stats.json" + +SITE = "clianything.cc" +MIGRATION_STARTED_AT = "2026-04-23T09:06:38Z" +MIGRATION_STARTED_AT_MS = 1776935198505 +DEFAULT_POSTHOG_APP_HOST = "https://us.posthog.com" +DEFAULT_POSTHOG_PROJECT_ID = "393992" + +BASELINE = { + "provider": "umami", + "site": SITE, + "scope": "clianything.cc only", + "queried_at": "2026-04-23T09:06:38.505000+00:00", + "total": 21123, + "human": 7689, + "agent": 13434, + "cli_hub_total": 16361, + "cli_hub_human": 3237, + "cli_hub_agent": 13124, +} + + +def _posthog_increment(): + api_key = os.environ.get("POSTHOG_PERSONAL_API_KEY", "").strip() + app_host = os.environ.get("POSTHOG_APP_HOST", DEFAULT_POSTHOG_APP_HOST).rstrip("/") + project_id = os.environ.get("POSTHOG_PROJECT_ID", DEFAULT_POSTHOG_PROJECT_ID).strip() + + if not api_key: + return { + "status": "missing_api_key", + "total": 0, + "human": 0, + "agent": 0, + "cli_hub_total": 0, + "cli_hub_human": 0, + "cli_hub_agent": 0, + } + + hogql = ( + "select " + "countIf(event = 'visit-human' and properties.source = 'web' and properties.site = 'clianything.cc') as human, " + "countIf(event = 'visit-agent' and properties.source = 'web' and properties.site = 'clianything.cc') as agent, " + "countIf(event = 'cli-hub call' and properties.source = 'cli' and properties.channel = 'cli-hub' and properties.is_agent = false) as cli_hub_human, " + "countIf(event = 'cli-hub call' and properties.source = 'cli' and properties.channel = 'cli-hub' and properties.is_agent = true) as cli_hub_agent " + "from events " + f"where timestamp >= parseDateTimeBestEffort('{MIGRATION_STARTED_AT}') " + ) + payload = { + "query": { + "kind": "HogQLQuery", + "query": hogql, + }, + "name": "cli-anything hub public website stats", + } + req = request.Request( + f"{app_host}/api/projects/{project_id}/query/", + data=json.dumps(payload).encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + + try: + with request.urlopen(req, timeout=30) as response: + data = json.loads(response.read().decode("utf-8")) + except error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace").strip() + print(f"Warning: PostHog stats query failed ({exc.code}): {detail}", file=sys.stderr) + return { + "status": f"http_{exc.code}", + "total": 0, + "human": 0, + "agent": 0, + "cli_hub_total": 0, + "cli_hub_human": 0, + "cli_hub_agent": 0, + } + except Exception as exc: # pragma: no cover - exercised in CI failure mode + print(f"Warning: PostHog stats query failed: {exc}", file=sys.stderr) + return { + "status": "error", + "total": 0, + "human": 0, + "agent": 0, + "cli_hub_total": 0, + "cli_hub_human": 0, + "cli_hub_agent": 0, + } + + results = data.get("results") or [[0, 0, 0, 0]] + row = results[0] if results else [0, 0, 0, 0] + human = int(row[0] or 0) + agent = int(row[1] or 0) + cli_hub_human = int(row[2] or 0) + cli_hub_agent = int(row[3] or 0) + return { + "status": "ok", + "app_host": app_host, + "project_id": project_id, + "human": human, + "agent": agent, + "total": human + agent, + "cli_hub_human": cli_hub_human, + "cli_hub_agent": cli_hub_agent, + "cli_hub_total": cli_hub_human + cli_hub_agent, + } + + +def main(): + posthog_increment = _posthog_increment() + totals = { + "total": BASELINE["total"] + posthog_increment["total"], + "human": BASELINE["human"] + posthog_increment["human"], + "agent": BASELINE["agent"] + posthog_increment["agent"], + "cli_hub_total": BASELINE["cli_hub_total"] + posthog_increment["cli_hub_total"], + "cli_hub_human": BASELINE["cli_hub_human"] + posthog_increment["cli_hub_human"], + "cli_hub_agent": BASELINE["cli_hub_agent"] + posthog_increment["cli_hub_agent"], + } + payload = { + "site": SITE, + "provider": "posthog", + "generated_at": datetime.now(UTC).isoformat(), + "migration_started_at": MIGRATION_STARTED_AT, + "baseline": BASELINE, + "posthog_increment": posthog_increment, + "totals": totals, + } + OUTPUT_PATH.write_text(json.dumps(payload, indent=2) + "\n") + print(json.dumps(payload, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index f47848ba8..96751310d 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -12,6 +12,7 @@ on: - 'docs/hub/**' - '.github/workflows/deploy-pages.yml' - '.github/scripts/update_registry_dates.py' + - '.github/scripts/update_hub_analytics_stats.py' - '.github/scripts/generate_meta_skill.py' workflow_dispatch: @@ -44,6 +45,13 @@ jobs: - name: Generate meta-skill run: python3 .github/scripts/generate_meta_skill.py + - name: Generate hub analytics stats + env: + POSTHOG_APP_HOST: https://us.posthog.com + POSTHOG_PROJECT_ID: "393992" + POSTHOG_PERSONAL_API_KEY: ${{ secrets.POSTHOG_PERSONAL_API_KEY }} + run: python3 .github/scripts/update_hub_analytics_stats.py + - name: Install AWS CLI run: pip install awscli diff --git a/cli-hub/README.md b/cli-hub/README.md index 3a86bb777..a43acceee 100644 --- a/cli-hub/README.md +++ b/cli-hub/README.md @@ -70,7 +70,7 @@ cli-hub search blender --json ## Analytics -cli-hub sends anonymous install/uninstall events to help track adoption (via [Umami](https://umami.is)). No personal data is collected. +cli-hub sends anonymous usage events to help track adoption. The default provider is [PostHog](https://posthog.com), while the legacy Umami path remains available via `CLI_HUB_ANALYTICS_PROVIDER=umami`. No personal data is collected. Opt out: diff --git a/cli-hub/cli_hub/analytics.py b/cli-hub/cli_hub/analytics.py index c3a962edb..6cd1afd33 100644 --- a/cli-hub/cli_hub/analytics.py +++ b/cli-hub/cli_hub/analytics.py @@ -1,22 +1,74 @@ -"""Lightweight, opt-out-able download event tracking via Umami.""" +"""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" -WEBSITE_ID = "a076c661-bed1-405c-a522-813794e688b4" +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.""" @@ -33,11 +85,179 @@ 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( - UMAMI_URL, json=payload, timeout=5, + _posthog_capture_url(), + json=payload, + timeout=5, headers={"User-Agent": USER_AGENT}, ) except Exception: @@ -45,20 +265,15 @@ def _send_event(payload): def track_event(event_name, url="/cli-anything-hub", data=None): - """Fire-and-forget event to Umami. Non-blocking, never raises.""" + """Fire-and-forget event to the active provider. Non-blocking, never raises.""" if not _is_enabled(): return - payload = { - "type": "event", - "payload": { - "website": WEBSITE_ID, - "hostname": HOSTNAME, - "url": url, - "name": event_name, - "data": data or {}, - }, - } + 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: @@ -82,19 +297,34 @@ def track_uninstall(cli_name): }) -def track_visit(is_agent=False): - """Track a visit-human or visit-agent event, matching the hub website's convention.""" - event_name = "visit-agent" if is_agent else "visit-human" - track_event(event_name, url="/cli-anything-hub", data={ - "source": "cli-anything-hub", +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.""" - from pathlib import Path - marker = Path.home() / ".cli-hub" / ".first_run_sent" + marker = _analytics_dir() / ".first_run_sent" if marker.exists(): return track_event("cli-anything-hub-installed", url="/cli-anything-hub/installed", data={ @@ -110,20 +340,4 @@ def track_first_run(): def _detect_is_agent(): """Detect if cli-hub is likely being invoked by an AI agent.""" - indicators = [ - "CLAUDE_CODE", # Claude Code - "CODEX", # OpenAI Codex - "CURSOR_SESSION", # Cursor - "CLINE_SESSION", # Cline - "COPILOT", # GitHub Copilot - "AIDER", # Aider - "CONTINUE_SESSION", # Continue.dev - ] - for var in indicators: - if os.environ.get(var): - return True - # Check if stdin is not a terminal (piped / scripted) - import sys - if not sys.stdin.isatty(): - return True - return False + return detect_invocation_context()["is_agent"] diff --git a/cli-hub/cli_hub/cli.py b/cli-hub/cli_hub/cli.py index 02684c30d..e97ebf13a 100644 --- a/cli-hub/cli_hub/cli.py +++ b/cli-hub/cli_hub/cli.py @@ -2,13 +2,28 @@ import os import shutil +import sys import click from cli_hub import __version__ from cli_hub.registry import fetch_all_clis, get_cli, search_clis, list_categories from cli_hub.installer import install_cli, uninstall_cli, get_installed, update_cli -from cli_hub.analytics import track_install, track_uninstall, track_visit, track_first_run, _detect_is_agent +from cli_hub.analytics import detect_invocation_context, track_install, track_uninstall, track_visit, track_first_run + + +def _invocation_command(ctx, version): + """Return a compact label for the current invocation.""" + argv = sys.argv[1:] + if version: + return "--version" + if ctx.invoked_subcommand: + return ctx.invoked_subcommand + if any(arg in ("--help", "-h") for arg in argv): + return "--help" + if argv: + return argv[0] + return "root" @click.group(invoke_without_command=True) @@ -17,7 +32,7 @@ from cli_hub.analytics import track_install, track_uninstall, track_visit, track def main(ctx, version): """cli-hub — Download and manage CLI-Anything harnesses and public CLIs.""" track_first_run() - track_visit(is_agent=_detect_is_agent()) + track_visit(command=_invocation_command(ctx, version), detection=detect_invocation_context()) if version: click.echo(f"cli-hub {__version__}") return diff --git a/cli-hub/tests/test_cli_hub.py b/cli-hub/tests/test_cli_hub.py index f18b113a3..d40be2c37 100644 --- a/cli-hub/tests/test_cli_hub.py +++ b/cli-hub/tests/test_cli_hub.py @@ -22,7 +22,7 @@ from cli_hub.installer import ( _install_strategy, _UV_INSTALL_HINT, ) -from cli_hub.analytics import _is_enabled, track_event, track_install, track_uninstall as analytics_track_uninstall, track_visit, track_first_run, _detect_is_agent +from cli_hub.analytics import _is_enabled, track_event, track_install, track_uninstall as analytics_track_uninstall, track_visit, track_first_run, _detect_is_agent, detect_invocation_context from cli_hub.cli import main @@ -510,8 +510,9 @@ class TestAnalytics: time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] - assert payload["payload"]["name"] == "test-event" - assert payload["payload"]["hostname"] == "clianything.cc" + assert payload["event"] == "test-event" + assert payload["properties"]["hostname"] == "clianything.cc" + assert payload["properties"]["source"] == "cli" @patch("cli_hub.analytics._send_event") def test_track_event_noop_when_disabled(self, mock_send): @@ -521,6 +522,17 @@ class TestAnalytics: time.sleep(0.2) mock_send.assert_not_called() + @patch("cli_hub.analytics._send_event") + def test_track_event_supports_umami_provider(self, mock_send): + with patch.dict(os.environ, {"CLI_HUB_ANALYTICS_PROVIDER": "umami"}, clear=False): + track_event("test-event") + import time + time.sleep(0.2) + mock_send.assert_called_once() + payload = mock_send.call_args[0][0] + assert payload["payload"]["name"] == "test-event" + assert payload["payload"]["hostname"] == "clianything.cc" + @patch("cli_hub.analytics._send_event") def test_track_install_event_name_includes_cli(self, mock_send): """cli-install event name must include CLI name for dashboard visibility.""" @@ -530,11 +542,11 @@ class TestAnalytics: time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] - assert payload["payload"]["name"] == "cli-install:gimp" - assert payload["payload"]["url"] == "/cli-anything-hub/install/gimp" - assert payload["payload"]["data"]["cli"] == "gimp" - assert payload["payload"]["data"]["version"] == "1.0.0" - assert "platform" in payload["payload"]["data"] + assert payload["event"] == "cli-install:gimp" + assert payload["properties"]["$current_url"] == "https://clianything.cc/cli-anything-hub/install/gimp" + assert payload["properties"]["cli"] == "gimp" + assert payload["properties"]["version"] == "1.0.0" + assert "platform" in payload["properties"] @patch("cli_hub.analytics._send_event") def test_track_uninstall_event_name_includes_cli(self, mock_send): @@ -545,33 +557,38 @@ class TestAnalytics: time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] - assert payload["payload"]["name"] == "cli-uninstall:blender" - assert payload["payload"]["url"] == "/cli-anything-hub/uninstall/blender" - assert payload["payload"]["data"]["cli"] == "blender" + assert payload["event"] == "cli-uninstall:blender" + assert payload["properties"]["$current_url"] == "https://clianything.cc/cli-anything-hub/uninstall/blender" + assert payload["properties"]["cli"] == "blender" @patch("cli_hub.analytics._send_event") def test_track_visit_human(self, mock_send): - """visit-human event sent when not detected as agent.""" + """cli-hub call event sent when not detected as agent.""" with patch.dict(os.environ, {}, clear=True): track_visit(is_agent=False) import time time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] - assert payload["payload"]["name"] == "visit-human" - assert payload["payload"]["url"] == "/cli-anything-hub" - assert payload["payload"]["data"]["source"] == "cli-anything-hub" + assert payload["event"] == "cli-hub call" + assert payload["properties"]["$current_url"] == "https://clianything.cc/cli-anything-hub/call" + assert payload["properties"]["command"] == "root" + assert payload["properties"]["is_agent"] is False + assert payload["properties"]["traffic_type"] == "human" @patch("cli_hub.analytics._send_event") def test_track_visit_agent(self, mock_send): - """visit-agent event sent when agent environment detected.""" + """cli-hub call event captures the agent flag.""" with patch.dict(os.environ, {}, clear=True): - track_visit(is_agent=True) + track_visit(is_agent=True, command="--version") import time time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] - assert payload["payload"]["name"] == "visit-agent" + assert payload["event"] == "cli-hub call" + assert payload["properties"]["command"] == "--version" + assert payload["properties"]["is_agent"] is True + assert payload["properties"]["traffic_type"] == "agent" def test_detect_agent_claude_code(self): with patch.dict(os.environ, {"CLAUDE_CODE": "1"}): @@ -581,13 +598,79 @@ class TestAnalytics: with patch.dict(os.environ, {"CODEX": "1"}): assert _detect_is_agent() is True - def test_detect_not_agent_clean_env(self): + @patch("cli_hub.analytics._parent_process_commands", return_value=["/usr/local/bin/codex --run"]) + def test_detect_agent_from_parent_process(self, mock_cmds): + with patch.dict(os.environ, {}, clear=True): + context = detect_invocation_context() + assert context["is_agent"] is True + assert context["reason"] == "codex-process" + assert "codex-process" in context["signals"] + + @pytest.mark.parametrize( + ("command", "expected_reason"), + [ + ("/usr/local/bin/gemini --prompt fix tests", "gemini-process"), + ("/usr/local/bin/copilot agent", "copilot-process"), + ("/usr/local/bin/auggie --print review", "auggie-process"), + ("/opt/augment/bin/augment", "augment-process"), + ("/usr/local/bin/ampcode fix build", "amp-process"), + ("/usr/local/bin/opencode agent create", "opencode-process"), + ("/usr/local/bin/kilo auth", "kilo-process"), + ("/usr/local/bin/qodo chat", "qodo-process"), + ("/usr/local/bin/kiro /agent create", "kiro-process"), + ], + ) + @patch("cli_hub.analytics._parent_process_commands") + def test_detect_agent_from_expanded_parent_process_names(self, mock_cmds, command, expected_reason): + mock_cmds.return_value = [command] + with patch.dict(os.environ, {}, clear=True): + context = detect_invocation_context() + assert context["is_agent"] is True + assert context["reason"] == expected_reason + assert expected_reason in context["signals"] + + @patch("cli_hub.analytics._parent_process_commands", return_value=[]) + def test_detect_not_agent_clean_env(self, mock_cmds): """Clean env with a tty should not detect as agent.""" with patch.dict(os.environ, {}, clear=True): with patch("sys.stdin") as mock_stdin: mock_stdin.isatty.return_value = True assert _detect_is_agent() is False + @patch("cli_hub.analytics._parent_process_commands", return_value=[]) + def test_detect_non_tty_is_agent(self, mock_cmds): + with patch.dict(os.environ, {}, clear=True): + with patch("sys.stdin") as mock_stdin: + mock_stdin.isatty.return_value = False + context = detect_invocation_context() + assert context["is_agent"] is True + assert context["traffic_type"] == "agent" + assert context["category"] == "scripted_client" + assert context["reason"] == "stdin-not-tty" + + @patch("cli_hub.analytics._send_event") + def test_track_visit_uses_detection_context(self, mock_send): + detection = { + "is_agent": True, + "traffic_type": "agent", + "category": "agent_tool", + "reason": "codex-process", + "signals": ["codex-process", "stdin-not-tty"], + "stdin_tty": False, + "is_interactive": False, + } + with patch.dict(os.environ, {}, clear=True): + track_visit(command="search", detection=detection) + import time + time.sleep(0.2) + payload = mock_send.call_args[0][0] + assert payload["properties"]["command"] == "search" + assert payload["properties"]["agent_reason"] == "codex-process" + assert payload["properties"]["agent_category"] == "agent_tool" + assert payload["properties"]["agent_signals"] == ["codex-process", "stdin-not-tty"] + assert payload["properties"]["stdin_tty"] is False + assert payload["properties"]["is_interactive"] is False + @patch("cli_hub.analytics._send_event") def test_first_run_sends_event(self, mock_send, tmp_path): """First invocation sends cli-hub-installed event.""" @@ -597,8 +680,8 @@ class TestAnalytics: time.sleep(0.2) mock_send.assert_called_once() payload = mock_send.call_args[0][0] - assert payload["payload"]["name"] == "cli-anything-hub-installed" - assert payload["payload"]["url"] == "/cli-anything-hub/installed" + assert payload["event"] == "cli-anything-hub-installed" + assert payload["properties"]["$current_url"] == "https://clianything.cc/cli-anything-hub/installed" # Marker file should now exist assert (tmp_path / ".cli-hub" / ".first_run_sent").exists() @@ -623,32 +706,53 @@ class TestCLI: def setup_method(self): self.runner = click.testing.CliRunner() + self.human_detection = { + "is_agent": False, + "traffic_type": "human", + "category": "human", + "reason": "human", + "signals": [], + "stdin_tty": True, + "is_interactive": True, + } + self.agent_detection = { + "is_agent": True, + "traffic_type": "agent", + "category": "agent_tool", + "reason": "codex-env", + "signals": ["codex-env"], + "stdin_tty": False, + "is_interactive": False, + } @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") def test_version(self, mock_detect, mock_visit, mock_first_run): + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["--version"]) assert __version__ in result.output assert result.exit_code == 0 - mock_visit.assert_called_once_with(is_agent=False) + mock_visit.assert_called_once_with(command="--version", detection=self.human_detection) mock_first_run.assert_called_once() @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") def test_help(self, mock_detect, mock_visit, mock_first_run): + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["--help"]) assert "cli-hub" in result.output assert result.exit_code == 0 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) @patch("cli_hub.cli.list_categories", return_value=["3d", "audio", "image"]) @patch("cli_hub.cli.get_installed", return_value={}) def test_list_command(self, mock_installed, mock_categories, mock_fetch, mock_detect, mock_visit, mock_first_run): + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["list"]) assert "gimp" in result.output assert "blender" in result.output @@ -656,31 +760,34 @@ class TestCLI: @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.fetch_all_clis", return_value=SAMPLE_REGISTRY["clis"]) @patch("cli_hub.cli.list_categories", return_value=["3d", "audio", "image"]) @patch("cli_hub.cli.get_installed", return_value={}) def test_list_with_category(self, mock_installed, mock_categories, mock_fetch, mock_detect, mock_visit, mock_first_run): + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["list", "-c", "image"]) assert "gimp" in result.output assert "blender" not in result.output @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.search_clis", return_value=[SAMPLE_REGISTRY["clis"][0]]) @patch("cli_hub.cli.get_installed", return_value={}) def test_search_command(self, mock_installed, mock_search, mock_detect, mock_visit, mock_first_run): + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["search", "gimp"]) assert "gimp" in result.output assert result.exit_code == 0 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.get_cli", return_value=SAMPLE_REGISTRY["clis"][0]) @patch("cli_hub.cli.get_installed", return_value={}) def test_info_command(self, mock_installed, mock_get, mock_detect, mock_visit, mock_first_run): + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["info", "gimp"]) assert "GIMP" in result.output assert "image" in result.output @@ -689,19 +796,21 @@ class TestCLI: @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.get_cli", return_value=None) def test_info_not_found(self, mock_get, mock_detect, mock_visit, mock_first_run): + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["info", "nonexistent"]) assert result.exit_code == 1 @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.track_install") @patch("cli_hub.cli.install_cli", return_value=(True, "Installed GIMP (cli-anything-gimp)")) @patch("cli_hub.cli.get_cli", return_value=SAMPLE_REGISTRY["clis"][0]) def test_install_command(self, mock_get, mock_install, mock_track, mock_detect, mock_visit, mock_first_run): + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["install", "gimp"]) assert result.exit_code == 0 assert "Installed" in result.output @@ -709,30 +818,33 @@ class TestCLI: @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.track_uninstall") @patch("cli_hub.cli.uninstall_cli", return_value=(True, "Uninstalled GIMP")) def test_uninstall_command(self, mock_uninstall, mock_track, mock_detect, mock_visit, mock_first_run): + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["uninstall", "gimp"]) assert result.exit_code == 0 mock_track.assert_called_once() @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=True) + @patch("cli_hub.cli.detect_invocation_context") def test_visit_agent_on_invocation(self, mock_detect, mock_visit, mock_first_run): - """When agent env detected, track_visit is called with is_agent=True.""" + """When agent env detected, track_visit is called with the new cli-hub call metadata.""" + mock_detect.return_value = self.agent_detection result = self.runner.invoke(main, ["--version"]) - mock_visit.assert_called_once_with(is_agent=True) + mock_visit.assert_called_once_with(command="--version", detection=self.agent_detection) @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.install_cli", return_value=(True, "Installed Jimeng / Dreamina CLI (dreamina)")) @patch("cli_hub.cli.get_cli", return_value={**SAMPLE_REGISTRY["clis"][0], "entry_point": "dreamina", "name": "jimeng", "display_name": "Jimeng / Dreamina CLI", "version": "latest", "_source": "public"}) @patch("cli_hub.cli.track_install") def test_install_shows_launch_hint(self, mock_track, mock_get, mock_install, mock_detect, mock_visit, mock_first_run): """Post-install output includes both entry point and cli-hub launch hint.""" + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["install", "jimeng"]) assert result.exit_code == 0 assert "dreamina" in result.output @@ -740,32 +852,35 @@ class TestCLI: @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.shutil.which", return_value="/usr/bin/dreamina") @patch("cli_hub.cli.os.execvp") @patch("cli_hub.cli.get_cli", return_value=JIMENG_CLI) def test_launch_execs_entry_point(self, mock_get, mock_execvp, mock_which, mock_detect, mock_visit, mock_first_run): """launch execs the CLI entry point, passing through extra args.""" + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["launch", "jimeng", "login"]) mock_execvp.assert_called_once_with("dreamina", ["dreamina", "login"]) @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.shutil.which", return_value=None) @patch("cli_hub.cli.get_cli", return_value=JIMENG_CLI) def test_launch_not_on_path_shows_install_hint(self, mock_get, mock_which, mock_detect, mock_visit, mock_first_run): """launch fails gracefully when entry point not on PATH.""" + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["launch", "jimeng"]) assert result.exit_code == 1 assert "cli-hub install jimeng" in result.output @patch("cli_hub.cli.track_first_run") @patch("cli_hub.cli.track_visit") - @patch("cli_hub.cli._detect_is_agent", return_value=False) + @patch("cli_hub.cli.detect_invocation_context") @patch("cli_hub.cli.get_cli", return_value=None) def test_launch_unknown_cli(self, mock_get, mock_detect, mock_visit, mock_first_run): """launch with an unknown CLI name exits with error.""" + mock_detect.return_value = self.human_detection result = self.runner.invoke(main, ["launch", "nonexistent"]) assert result.exit_code == 1 assert "not found" in result.output diff --git a/docs/hub/analytics-stats.json b/docs/hub/analytics-stats.json new file mode 100644 index 000000000..964fb134e --- /dev/null +++ b/docs/hub/analytics-stats.json @@ -0,0 +1,37 @@ +{ + "site": "clianything.cc", + "provider": "posthog", + "generated_at": "2026-04-23T15:31:59.837617+00:00", + "migration_started_at": "2026-04-23T09:06:38Z", + "baseline": { + "provider": "umami", + "site": "clianything.cc", + "scope": "clianything.cc only", + "queried_at": "2026-04-23T09:06:38.505000+00:00", + "total": 21123, + "human": 7689, + "agent": 13434, + "cli_hub_total": 16361, + "cli_hub_human": 3237, + "cli_hub_agent": 13124 + }, + "posthog_increment": { + "status": "ok", + "app_host": "https://us.posthog.com", + "project_id": "393992", + "human": 3, + "agent": 1, + "total": 4, + "cli_hub_human": 0, + "cli_hub_agent": 2, + "cli_hub_total": 2 + }, + "totals": { + "total": 21127, + "human": 7692, + "agent": 13435, + "cli_hub_total": 16363, + "cli_hub_human": 3237, + "cli_hub_agent": 13126 + } +} diff --git a/docs/hub/index-modern.html b/docs/hub/index-modern.html index 74a38dc49..a76f5626e 100644 --- a/docs/hub/index-modern.html +++ b/docs/hub/index-modern.html @@ -1487,8 +1487,8 @@ backdrop-filter: blur(18px); box-shadow: var(--shadow-md); display: grid; - grid-template-columns: minmax(0, 1.3fr) minmax(0, 0.8fr); - gap: 0.9rem; + grid-template-columns: 1fr; + gap: 0.8rem; } .footer-band, @@ -1538,45 +1538,89 @@ flex-direction: column; justify-content: center; gap: 0.55rem; + width: min(760px, 100%); + margin: 0 auto; } - .footer-analytics-link { + .footer-analytics-board { + border-radius: 20px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.03); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.04); + padding: 0.34rem 0.95rem; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif; + } + + .analytics-row { + display: grid; + grid-template-columns: minmax(0, 168px) repeat(3, minmax(0, 1fr)); + align-items: end; + gap: 0.7rem; + padding: 0.74rem 0; + } + + .analytics-row + .analytics-row { + border-top: 1px solid var(--border); + } + + .analytics-title { + font-size: 0.93rem; + font-weight: 600; + color: var(--text); + letter-spacing: -0.02em; + line-height: 1.2; + } + + .metric-cell { display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.18rem; + min-width: 0; + } + + .metric-label { + display: inline-flex; align-items: center; - flex-wrap: wrap; - gap: 0.55rem; - color: var(--text-secondary); - font-size: 0.9rem; - } - - .footer-analytics-link svg { - width: 15px; - height: 15px; + gap: 0.34rem; + min-width: 0; + font-size: 0.69rem; color: var(--text-tertiary); + letter-spacing: 0.01em; + white-space: nowrap; } - .stat-dot { - width: 0.45rem; - height: 0.45rem; + .metric-dot { + width: 0.42rem; + height: 0.42rem; border-radius: 999px; - display: inline-block; flex-shrink: 0; + background: rgba(129, 144, 171, 0.55); } - .dot-total { - background: var(--text); + .metric-value { + font-size: 1.05rem; + line-height: 1; + color: var(--text); + font-variant-numeric: tabular-nums; + letter-spacing: -0.03em; + white-space: nowrap; } - .dot-human { + .metric-cell.total .metric-value { + font-weight: 700; + } + + .metric-cell.human .metric-dot { background: var(--green); } - .dot-agent { - background: var(--amber); + .metric-cell.agent .metric-dot { + background: var(--accent); } - .analytics-sep { - color: var(--text-tertiary); + .metric-cell.total .metric-label { + color: var(--text-secondary); } .sr-only { @@ -1694,6 +1738,15 @@ .grid { grid-template-columns: 1fr; } + + .analytics-row { + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem 0.75rem; + } + + .analytics-title { + grid-column: 1 / -1; + } } @media (max-width: 640px) { @@ -1767,18 +1820,24 @@ padding-left: 0.9rem; padding-right: 0.9rem; } + + .footer-analytics-board { + padding: 0.3rem 0.78rem; + } + + .analytics-row { + padding: 0.58rem 0; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .analytics-title { + grid-column: 1 / -1; + } } - - - - + - -
@@ -2624,129 +2824,323 @@ // ── Visitor classification: human vs AI agent ── (function() { - const AI_UA_PATTERNS = [ - /claude/i, /anthropic/i, /openai/i, /gpt/i, /chatgpt/i, - /bingbot/i, /googlebot/i, /baiduspider/i, /yandexbot/i, - /slurp/i, /duckduckbot/i, /facebookexternalhit/i, - /twitterbot/i, /linkedinbot/i, /whatsapp/i, /telegrambot/i, - /applebot/i, /semrushbot/i, /ahrefsbot/i, /dotbot/i, - /petalbot/i, /mj12bot/i, /bytespider/i, - /headless/i, /phantom/i, /selenium/i, /puppeteer/i, /playwright/i, - /crawl/i, /spider/i, /bot\b/i, /scraper/i, - /python-requests/i, /axios/i, /node-fetch/i, /go-http/i, /curl/i, /wget/i, - /ccbot/i, /gptbot/i, /perplexity/i, /cohere/i, + const ANALYTICS_PROVIDER = 'posthog'; + const ANALYTICS_SITE = 'clianything.cc'; + const COUNTED_HOSTS = new Set(['clianything.cc', 'www.clianything.cc']); + const BASELINE_STATS = { + total: 21123, + human: 7689, + agent: 13434, + cli_hub_total: 16361, + cli_hub_human: 3237, + cli_hub_agent: 13124, + }; + const ANALYTICS_TEST_RUN = new URLSearchParams(location.search).get('analytics_test_run') || ''; + const POSTHOG_CONFIG = { + projectToken: 'phc_ovP8d5bmjpn8YZnTo7pb6rE3TikcAMgmNVt75o3Ywejz', + apiHost: 'https://us.i.posthog.com', + defaults: '2026-01-30', + personProfiles: 'identified_only', + }; + const UMAMI_CONFIG = { + sendUrl: 'https://cloud.umami.is/api/send', + websiteId: 'a076c661-bed1-405c-a522-813794e688b4', + }; + const OFFICIAL_AGENT_UA_RULES = [ + { id: 'openai-oai-searchbot', category: 'official_ai_agent', regex: /\bOAI-SearchBot\b/i }, + { id: 'openai-gptbot', category: 'official_ai_agent', regex: /\bGPTBot\b/i }, + { id: 'openai-chatgpt-user', category: 'official_ai_agent', regex: /\bChatGPT-User\b/i }, + { id: 'anthropic-claudebot', category: 'official_ai_agent', regex: /\bClaudeBot\b/i }, + { id: 'anthropic-claude-user', category: 'official_ai_agent', regex: /\bClaude-User\b/i }, + { id: 'anthropic-claude-searchbot', category: 'official_ai_agent', regex: /\bClaude-SearchBot\b/i }, + { id: 'perplexity-bot', category: 'official_ai_agent', regex: /\bPerplexityBot\b/i }, + { id: 'perplexity-user', category: 'official_ai_agent', regex: /\bPerplexity-User\b/i }, + { id: 'google-vertex-bot', category: 'official_ai_agent', regex: /\bGoogle-CloudVertexBot\b/i }, ]; - - const ua = navigator.userAgent || ''; - const isKnownAgent = AI_UA_PATTERNS.some(p => p.test(ua)); - const isWebdriver = navigator.webdriver === true; + const AGENT_TOOL_UA_RULES = [ + { id: 'claude-code', category: 'agent_tool', regex: /\bclaude(?:[ -]?code)\b/i }, + { id: 'codex', category: 'agent_tool', regex: /\bcodex\b/i }, + { id: 'cursor', category: 'agent_tool', regex: /\bcursor\b/i }, + { id: 'cline', category: 'agent_tool', regex: /\bcline\b/i }, + { id: 'aider', category: 'agent_tool', regex: /\baider\b/i }, + { id: 'continue', category: 'agent_tool', regex: /\bcontinue\b/i }, + { id: 'openhands', category: 'agent_tool', regex: /\bopenhands\b/i }, + { id: 'openclaw', category: 'agent_tool', regex: /\bopenclaw\b/i }, + { id: 'nanobot', category: 'agent_tool', regex: /\bnanobot\b/i }, + { id: 'browser-use', category: 'agent_tool', regex: /\bbrowser[- ]use\b/i }, + { id: 'stagehand', category: 'agent_tool', regex: /\bstagehand\b/i }, + ]; + const AUTOMATION_UA_RULES = [ + { id: 'headless-browser', category: 'automation_runtime', regex: /\bHeadlessChrome\b|\bHeadless\b/i }, + { id: 'playwright', category: 'automation_runtime', regex: /\bPlaywright\b/i }, + { id: 'puppeteer', category: 'automation_runtime', regex: /\bPuppeteer\b/i }, + { id: 'selenium', category: 'automation_runtime', regex: /\bSelenium\b/i }, + { id: 'phantomjs', category: 'automation_runtime', regex: /\bPhantomJS\b|\bPhantom\b/i }, + ]; + const SCRIPTED_CLIENT_UA_RULES = [ + { id: 'python-requests', category: 'scripted_client', regex: /\bpython-requests\b/i }, + { id: 'python-httpx', category: 'scripted_client', regex: /\bpython-httpx\b/i }, + { id: 'axios', category: 'scripted_client', regex: /\baxios\b/i }, + { id: 'node-fetch', category: 'scripted_client', regex: /\bnode-fetch\b/i }, + { id: 'undici', category: 'scripted_client', regex: /\bundici\b/i }, + { id: 'go-http-client', category: 'scripted_client', regex: /\bGo-http-client\b/i }, + { id: 'curl', category: 'scripted_client', regex: /\bcurl\b/i }, + { id: 'wget', category: 'scripted_client', regex: /\bwget\b/i }, + ]; + const CATEGORY_PRIORITY = ['official_ai_agent', 'agent_tool', 'automation_runtime', 'scripted_client']; let humanConfirmed = false; + let posthogReady = false; + const pendingPosthogEvents = []; - // ── Send event to BOTH Umami website IDs ── - // The global `umami` object only binds to one script tag, so we also - // POST directly to the send API for the second site. - const UMAMI_SEND = 'https://cloud.umami.is/api/send'; - const DUAL_WEBSITE_IDS = [ - '07082d05-efd3-4f85-a7a1-b426b0e8bfaa', - 'a076c661-bed1-405c-a522-813794e688b4', - ]; - function trackBoth(eventName, eventData) { - // POST directly to both sites — do NOT also call umami.track() - // because that would double-count on whichever site umami is bound to. - DUAL_WEBSITE_IDS.forEach(wid => { - const payload = { - type: 'event', - payload: { - website: wid, - hostname: location.hostname, - url: location.pathname, - name: eventName, - data: eventData || {}, - }, - }; - fetch(UMAMI_SEND, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - keepalive: true, - }).catch(() => {}); + function isTrackedHost() { + return COUNTED_HOSTS.has(location.hostname); + } + + function applyVisitorStats(stats) { + const setText = (id, val) => { + const el = document.getElementById(id); + if (el) el.textContent = (val || 0).toLocaleString(); + }; + const setSplit = (prefix, human, agent) => { + const total = (human || 0) + (agent || 0); + const humanPct = total > 0 ? (human / total) * 100 : 0; + const agentPct = total > 0 ? 100 - humanPct : 0; + const fmtPct = (n) => (n >= 10 || n === 0 ? Math.round(n) : n.toFixed(1)) + '%'; + const barH = document.getElementById('bar-' + prefix + '-human'); + const barA = document.getElementById('bar-' + prefix + '-agent'); + if (barH) barH.style.width = humanPct.toFixed(2) + '%'; + if (barA) barA.style.width = agentPct.toFixed(2) + '%'; + const pctH = document.getElementById('pct-' + prefix + '-human'); + const pctA = document.getElementById('pct-' + prefix + '-agent'); + if (pctH) pctH.textContent = fmtPct(humanPct); + if (pctA) pctA.textContent = fmtPct(agentPct); + }; + setText('stat-total', stats.total); + setText('stat-human', stats.human); + setText('stat-agent', stats.agent); + setText('stat-cli-total', stats.cli_hub_total); + setText('stat-cli-human', stats.cli_hub_human); + setText('stat-cli-agent', stats.cli_hub_agent); + setSplit('site', stats.human || 0, stats.agent || 0); + setSplit('cli', stats.cli_hub_human || 0, stats.cli_hub_agent || 0); + } + + function readUaBrands() { + const brands = navigator.userAgentData && Array.isArray(navigator.userAgentData.brands) + ? navigator.userAgentData.brands + : []; + return brands.map((brand) => brand.brand).join(' '); + } + + function collectRuleSignals(ua, rules, signals) { + rules.forEach((rule) => { + if (rule.regex.test(ua)) { + signals.push(rule); + } }); } - // Tag the visit type via Umami custom events - if (isKnownAgent || isWebdriver) { - trackBoth('visit-agent', { ua: ua.slice(0, 200) }); + function collectRuntimeSignals(brands, signals) { + if (navigator.webdriver === true) { + signals.push({ id: 'webdriver', category: 'automation_runtime' }); + } + if (/HeadlessChrome/i.test(brands)) { + signals.push({ id: 'ua-brand-headlesschrome', category: 'automation_runtime' }); + } + if ('__playwright__binding__' in window || '__pwManual' in window) { + signals.push({ id: 'playwright-runtime', category: 'automation_runtime' }); + } + if ('Cypress' in window) { + signals.push({ id: 'cypress-runtime', category: 'automation_runtime' }); + } + if ('_phantom' in window || 'callPhantom' in window) { + signals.push({ id: 'phantom-runtime', category: 'automation_runtime' }); + } + if ('domAutomation' in window || 'domAutomationController' in window) { + signals.push({ id: 'dom-automation', category: 'automation_runtime' }); + } } - // Wait for Umami to load, then tag - window.addEventListener('load', () => { - setTimeout(() => { - if (isKnownAgent || isWebdriver) { - trackBoth('visit-agent', { ua: ua.slice(0, 200) }); - } - }, 500); - }); + function classifyVisitor() { + const ua = navigator.userAgent || ''; + const brands = readUaBrands(); + const signals = []; + collectRuleSignals(ua, OFFICIAL_AGENT_UA_RULES, signals); + collectRuleSignals(ua, AGENT_TOOL_UA_RULES, signals); + collectRuleSignals(ua, AUTOMATION_UA_RULES, signals); + collectRuleSignals(ua, SCRIPTED_CLIENT_UA_RULES, signals); + collectRuntimeSignals(brands, signals); - // Real human interaction detection - function onHumanInteraction() { - if (humanConfirmed) return; + const seen = new Set(); + const uniqueSignals = signals.filter((signal) => { + if (seen.has(signal.id)) return false; + seen.add(signal.id); + return true; + }).sort((left, right) => CATEGORY_PRIORITY.indexOf(left.category) - CATEGORY_PRIORITY.indexOf(right.category)); + + const primary = uniqueSignals[0] || null; + const isAgent = uniqueSignals.length > 0; + return { + ua, + brands, + webdriver: navigator.webdriver === true, + isAgent, + isAutomation: !!primary && (primary.category === 'automation_runtime' || primary.category === 'scripted_client'), + trafficType: isAgent ? 'agent' : 'human', + reason: primary ? primary.id : 'human', + category: primary ? primary.category : 'human', + signals: uniqueSignals.map((signal) => signal.id), + }; + } + + const visitor = classifyVisitor(); + + function analyticsContext(eventData) { + const base = { + source: 'web', + site: ANALYTICS_SITE, + host: location.hostname, + visitor_type: visitor.trafficType, + is_agent: visitor.isAgent, + is_automation: visitor.isAutomation, + agent_category: visitor.category, + agent_reason: visitor.reason, + agent_signals: visitor.signals.slice(0, 12), + ua: visitor.ua.slice(0, 200), + ua_brands: visitor.brands.slice(0, 200), + webdriver: visitor.webdriver, + }; + if (ANALYTICS_TEST_RUN) { + base.test_run = ANALYTICS_TEST_RUN; + } + return { ...base, ...eventData }; + } + + function initPosthog() { + if (!isTrackedHost()) return; + !function(t,e){var o,n,p,r;e.__SV||(window.posthog&&window.posthog.__loaded)||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split('.');2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement('script')).type='text/javascript',p.crossOrigin='anonymous',p.async=!0,p.src=s.api_host.replace('.i.posthog.com','-assets.i.posthog.com')+'/static/array.js',(r=t.getElementsByTagName('script')[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a='posthog',u.people=u.people||[],u.toString=function(t){var e='posthog';return'posthog'!==a&&(e+='.'+a),t||(e+=' (stub)'),e},u.people.toString=function(){return u.toString(1)+'.people (stub)'},o='init capture register register_once register_for_session unregister unregister_for_session getFeatureFlag getFeatureFlagPayload isFeatureEnabled reloadFeatureFlags updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures on onFeatureFlags onSessionId getSurveys getActiveMatchingSurveys renderSurvey canRenderSurvey getNextSurveyStep identify setPersonProperties group resetGroups setPersonPropertiesForFlags resetPersonPropertiesForFlags setGroupPropertiesForFlags resetGroupPropertiesForFlags reset get_distinct_id getGroups get_session_id get_session_replay_url alias set_config startSessionRecording stopSessionRecording sessionRecordingStarted captureException loadToolbar get_property getSessionProperty createPersonProfile opt_in_capturing opt_out_capturing has_opted_in_capturing has_opted_out_capturing clear_opt_in_out_capturing debug'.split(' '),n=0;n {}); + } + + function trackAnalyticsEvent(eventName, eventData) { + if (ANALYTICS_PROVIDER === 'umami') { + trackWithUmami(eventName, eventData); + return; + } + trackWithPosthog(eventName, eventData); + } + + if (ANALYTICS_PROVIDER === 'umami') { + initUmami(); + } else { + initPosthog(); + } + + if (ANALYTICS_TEST_RUN) { + window.__CLI_ANYTHING_ANALYTICS__ = { + visitor: { + isAgent: visitor.isAgent, + isAutomation: visitor.isAutomation, + trafficType: visitor.trafficType, + category: visitor.category, + reason: visitor.reason, + signals: visitor.signals.slice(), + webdriver: visitor.webdriver, + ua: visitor.ua, + }, + }; + } + + if (isTrackedHost() && visitor.isAgent) { + trackAnalyticsEvent('visit-agent', { + classification_source: 'initial-load', + }); + } + + function onHumanInteraction(event) { + if (humanConfirmed || !isTrackedHost() || visitor.trafficType !== 'human') return; humanConfirmed = true; - trackBoth('visit-human'); - // Clean up listeners - ['mousemove', 'touchstart', 'scroll', 'keydown', 'click'].forEach(evt => { + trackAnalyticsEvent('visit-human', { + classification_source: 'user-interaction', + interaction: event.type, + }); + ['mousemove', 'touchstart', 'scroll', 'keydown', 'click'].forEach((evt) => { document.removeEventListener(evt, onHumanInteraction); }); } - ['mousemove', 'touchstart', 'scroll', 'keydown', 'click'].forEach(evt => { + ['mousemove', 'touchstart', 'scroll', 'keydown', 'click'].forEach((evt) => { document.addEventListener(evt, onHumanInteraction, { once: false, passive: true }); }); - // ── Fetch all-time stats from Umami Cloud API (both sites) ── - const UMAMI_API = 'https://api.umami.is/v1'; - const UMAMI_KEY = 'api_idAebMhzn6z0hsUQT7BSxRuCK2GUZvRY'; - const HKUDS_WEBSITE_ID = '07082d05-efd3-4f85-a7a1-b426b0e8bfaa'; - const CC_WEBSITE_ID = 'a076c661-bed1-405c-a522-813794e688b4'; - const headers = { 'Accept': 'application/json', 'x-umami-api-key': UMAMI_KEY }; - async function loadVisitorStats() { + applyVisitorStats(BASELINE_STATS); try { - const now = Date.now(); - let hkudsHumanVisits = 0; - let ccHumanVisits = 0; - let ccAgentVisits = 0; - - const [hkudsStatsResp, ccEventsResp] = await Promise.all([ - fetch(`${UMAMI_API}/websites/${HKUDS_WEBSITE_ID}/stats?startAt=0&endAt=${now}`, { headers }), - fetch(`${UMAMI_API}/websites/${CC_WEBSITE_ID}/events/series?startAt=0&endAt=${now}&unit=year&timezone=UTC`, { headers }) - ]); - - if (hkudsStatsResp.ok) { - const stats = await hkudsStatsResp.json(); - hkudsHumanVisits = stats.visits ?? 0; + const response = await fetch('./analytics-stats.json', { cache: 'no-store' }); + if (!response.ok) return; + const stats = await response.json(); + if (stats && stats.totals) { + applyVisitorStats(stats.totals); } - - if (ccEventsResp.ok) { - const events = await ccEventsResp.json(); - events.forEach(e => { - if (e.x === 'visit-human') ccHumanVisits += e.y || 0; - if (e.x === 'visit-agent') ccAgentVisits += e.y || 0; - }); - } - - const humanCount = hkudsHumanVisits + ccHumanVisits; - const totalVisits = humanCount + ccAgentVisits; - - document.getElementById('stat-total').textContent = totalVisits.toLocaleString(); - document.getElementById('stat-human').textContent = humanCount.toLocaleString(); - document.getElementById('stat-agent').textContent = ccAgentVisits.toLocaleString(); } catch (_) { - // Stats not available — leave dashes + // Fall back to the fixed baseline values. } } loadVisitorStats(); - loadGitHubStars(); + if (typeof loadGitHubStars === 'function') { + loadGitHubStars(); + } })();