-
+
- Human +
- 7,68936% +
- Agent +
- 13,43464% +
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; + } } - - - - +
- -