mirror of
https://fastgit.cc/github.com/HKUDS/CLI-Anything
synced 2026-04-30 22:02:01 +08:00
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.
This commit is contained in:
149
.github/scripts/update_hub_analytics_stats.py
vendored
Normal file
149
.github/scripts/update_hub_analytics_stats.py
vendored
Normal file
@@ -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()
|
||||
8
.github/workflows/deploy-pages.yml
vendored
8
.github/workflows/deploy-pages.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
37
docs/hub/analytics-stats.json
Normal file
37
docs/hub/analytics-stats.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<!-- Umami Analytics — hkuds.github.io -->
|
||||
<script defer src="https://cloud.umami.is/script.js" data-website-id="07082d05-efd3-4f85-a7a1-b426b0e8bfaa"></script>
|
||||
<!-- Umami Analytics — clianything.cc -->
|
||||
<script defer src="https://cloud.umami.is/script.js" data-website-id="a076c661-bed1-405c-a522-813794e688b4"></script>
|
||||
<!-- Analytics provider is configured in the footer script. -->
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
<img src="https://cloud.umami.is/api/send?type=event&payload=%7B%22website%22%3A%2207082d05-efd3-4f85-a7a1-b426b0e8bfaa%22%2C%22name%22%3A%22noscript-visit%22%7D" style="position:absolute;left:-9999px" alt="" width="1" height="1">
|
||||
</noscript>
|
||||
|
||||
<div class="page-shell">
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="#">
|
||||
@@ -2033,16 +2092,37 @@
|
||||
</div>
|
||||
<div class="footer-stats">
|
||||
<div class="footer-label">Traffic Snapshot</div>
|
||||
<div class="footer-analytics-link" aria-label="Traffic snapshot">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
<span class="stat-dot dot-total"></span>
|
||||
<span id="stat-total">—</span> visits
|
||||
<span class="analytics-sep">·</span>
|
||||
<span class="stat-dot dot-human"></span>
|
||||
<span id="stat-human">—</span> human
|
||||
<span class="analytics-sep">·</span>
|
||||
<span class="stat-dot dot-agent"></span>
|
||||
<span id="stat-agent">—</span> AI agent
|
||||
<div class="footer-analytics-board" aria-label="Traffic snapshot">
|
||||
<div class="analytics-row">
|
||||
<div class="analytics-title">Website Visits</div>
|
||||
<div class="metric-cell total">
|
||||
<span class="metric-label">Total</span>
|
||||
<strong id="stat-total" class="metric-value">21,123</strong>
|
||||
</div>
|
||||
<div class="metric-cell human">
|
||||
<span class="metric-label"><span class="metric-dot" aria-hidden="true"></span>Human</span>
|
||||
<strong id="stat-human" class="metric-value">7,689</strong>
|
||||
</div>
|
||||
<div class="metric-cell agent">
|
||||
<span class="metric-label"><span class="metric-dot" aria-hidden="true"></span>Agent</span>
|
||||
<strong id="stat-agent" class="metric-value">13,434</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="analytics-row">
|
||||
<div class="analytics-title">CLI-Hub Calls</div>
|
||||
<div class="metric-cell total">
|
||||
<span class="metric-label">Total</span>
|
||||
<strong id="stat-cli-total" class="metric-value">16,361</strong>
|
||||
</div>
|
||||
<div class="metric-cell human">
|
||||
<span class="metric-label"><span class="metric-dot" aria-hidden="true"></span>Human</span>
|
||||
<strong id="stat-cli-human" class="metric-value">3,237</strong>
|
||||
</div>
|
||||
<div class="metric-cell agent">
|
||||
<span class="metric-label"><span class="metric-dot" aria-hidden="true"></span>Agent</span>
|
||||
<strong id="stat-cli-agent" class="metric-value">13,124</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -2394,7 +2474,7 @@
|
||||
return '<div class="card-command-block">' +
|
||||
'<div class="card-command-label"><span>Install Commands</span><span>' + esc(sourceLabel) + '</span></div>' +
|
||||
'<div class="card-command">' + display + '</div>' +
|
||||
'<div class="card-command-actions"><button class="card-copy-btn" onclick=\'copyCmd(this, ' + JSON.stringify(copyText) + ')'>Copy</button></div>' +
|
||||
'<div class="card-command-actions"><button class="card-copy-btn" onclick=\'copyCmd(this, ' + JSON.stringify(copyText) + ')\'>Copy</button></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -2550,67 +2630,276 @@
|
||||
loadRegistries().then(updateDeckPosition);
|
||||
|
||||
(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 = [];
|
||||
|
||||
const UMAMI_SEND = 'https://cloud.umami.is/api/send';
|
||||
const DUAL_WEBSITE_IDS = [
|
||||
'07082d05-efd3-4f85-a7a1-b426b0e8bfaa',
|
||||
'a076c661-bed1-405c-a522-813794e688b4',
|
||||
];
|
||||
function isTrackedHost() {
|
||||
return COUNTED_HOSTS.has(location.hostname);
|
||||
}
|
||||
|
||||
function trackBoth(eventName, eventData) {
|
||||
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 applyVisitorStats(stats) {
|
||||
document.getElementById('stat-total').textContent = (stats.total || 0).toLocaleString();
|
||||
document.getElementById('stat-human').textContent = (stats.human || 0).toLocaleString();
|
||||
document.getElementById('stat-agent').textContent = (stats.agent || 0).toLocaleString();
|
||||
document.getElementById('stat-cli-total').textContent = (stats.cli_hub_total || 0).toLocaleString();
|
||||
document.getElementById('stat-cli-human').textContent = (stats.cli_hub_human || 0).toLocaleString();
|
||||
document.getElementById('stat-cli-agent').textContent = (stats.cli_hub_agent || 0).toLocaleString();
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
window.posthog.init(POSTHOG_CONFIG.projectToken, {
|
||||
api_host: POSTHOG_CONFIG.apiHost,
|
||||
defaults: POSTHOG_CONFIG.defaults,
|
||||
person_profiles: POSTHOG_CONFIG.personProfiles,
|
||||
opt_out_useragent_filter: true,
|
||||
loaded: function(posthog) {
|
||||
posthogReady = true;
|
||||
while (pendingPosthogEvents.length) {
|
||||
const queued = pendingPosthogEvents.shift();
|
||||
posthog.capture(queued.eventName, analyticsContext(queued.eventData));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function initUmami() {
|
||||
if (!isTrackedHost()) return;
|
||||
const script = document.createElement('script');
|
||||
script.defer = true;
|
||||
script.src = 'https://cloud.umami.is/script.js';
|
||||
script.dataset.websiteId = UMAMI_CONFIG.websiteId;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function trackWithPosthog(eventName, eventData) {
|
||||
if (!isTrackedHost() || !window.posthog) return;
|
||||
if (!posthogReady || typeof window.posthog.capture !== 'function') {
|
||||
pendingPosthogEvents.push({ eventName, eventData });
|
||||
return;
|
||||
}
|
||||
window.posthog.capture(eventName, analyticsContext(eventData));
|
||||
}
|
||||
|
||||
function trackWithUmami(eventName, eventData) {
|
||||
if (!isTrackedHost()) return;
|
||||
const payload = {
|
||||
type: 'event',
|
||||
payload: {
|
||||
website: UMAMI_CONFIG.websiteId,
|
||||
hostname: ANALYTICS_SITE,
|
||||
url: location.pathname,
|
||||
name: eventName,
|
||||
data: analyticsContext(eventData),
|
||||
},
|
||||
};
|
||||
fetch(UMAMI_CONFIG.sendUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
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');
|
||||
trackAnalyticsEvent('visit-human', {
|
||||
classification_source: 'user-interaction',
|
||||
interaction: event.type,
|
||||
});
|
||||
['mousemove', 'touchstart', 'scroll', 'keydown', 'click'].forEach((evt) => {
|
||||
document.removeEventListener(evt, onHumanInteraction);
|
||||
});
|
||||
@@ -2620,49 +2909,24 @@
|
||||
document.addEventListener(evt, onHumanInteraction, { once: false, passive: true });
|
||||
});
|
||||
|
||||
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 (_) {
|
||||
// Leave fallback dashes.
|
||||
// Fall back to the fixed baseline values.
|
||||
}
|
||||
}
|
||||
|
||||
loadVisitorStats();
|
||||
loadGitHubStars();
|
||||
if (typeof loadGitHubStars === 'function') {
|
||||
loadGitHubStars();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -1578,47 +1578,199 @@
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 1.1rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
.footer-analytics-link {
|
||||
.analytics-report {
|
||||
width: min(780px, 100%);
|
||||
padding: 0.95rem 1.2rem 0.9rem;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(255,255,255,0.018), rgba(255,255,255,0));
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", Inter, sans-serif;
|
||||
transition: border-color 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] .analytics-report {
|
||||
background: linear-gradient(180deg, rgba(9,9,11,0.012), rgba(9,9,11,0));
|
||||
}
|
||||
|
||||
.analytics-report:hover {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.analytics-report-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.analytics-eyebrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.78rem;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-tertiary);
|
||||
padding: 0.35rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.footer-analytics-link svg { opacity: 0.6; flex-shrink: 0; }
|
||||
.analytics-eyebrow::before {
|
||||
content: "";
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--green);
|
||||
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18);
|
||||
animation: analyticsPulse 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.analytics-badge {
|
||||
@keyframes analyticsPulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.45; }
|
||||
}
|
||||
|
||||
.analytics-legend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
gap: 1rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary);
|
||||
margin-left: 0.15rem;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.analytics-sep { opacity: 0.4; }
|
||||
.analytics-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
}
|
||||
|
||||
.stat-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
.analytics-dot {
|
||||
width: 0.42rem;
|
||||
height: 0.42rem;
|
||||
border-radius: 999px;
|
||||
background: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.dot-total { background: var(--text-tertiary); }
|
||||
.dot-human { background: var(--green); }
|
||||
.dot-agent { background: var(--accent); }
|
||||
.analytics-dot.human { background: var(--green); }
|
||||
.analytics-dot.agent { background: var(--accent); }
|
||||
|
||||
.analytics-report-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.analytics-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.42rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.analytics-stat + .analytics-stat {
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.analytics-stat-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.analytics-stat-value {
|
||||
display: block;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.analytics-bar {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--border-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.analytics-bar > span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.analytics-bar .bar-human {
|
||||
background: var(--green);
|
||||
}
|
||||
|
||||
.analytics-bar .bar-agent {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.analytics-split {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem 1.1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.analytics-split-item {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.42rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.analytics-split-item dt {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.34rem;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-tertiary);
|
||||
letter-spacing: 0.01em;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.analytics-split-item dd {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.34rem;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.analytics-split-num {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.analytics-split-pct {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 640px) {
|
||||
@@ -1681,6 +1833,25 @@
|
||||
.empower-self .cmd-grid { grid-template-columns: 1fr; }
|
||||
.deck-nav { padding: 1.25rem 1.25rem 0; }
|
||||
.deck-arrow-btn span { display: none; }
|
||||
.analytics-report {
|
||||
padding: 0.85rem 1rem 0.8rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.analytics-report-head {
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
.analytics-report-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.analytics-stat + .analytics-stat {
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding-left: 0;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
.analytics-stat-value { font-size: 1.4rem; }
|
||||
}
|
||||
|
||||
@media (max-width: 1120px) {
|
||||
@@ -1711,16 +1882,9 @@
|
||||
}
|
||||
</style>
|
||||
<script>document.documentElement.classList.add('js');</script>
|
||||
<!-- Umami Analytics — hkuds.github.io -->
|
||||
<script defer src="https://cloud.umami.is/script.js" data-website-id="07082d05-efd3-4f85-a7a1-b426b0e8bfaa"></script>
|
||||
<!-- Umami Analytics — clianything.cc -->
|
||||
<script defer src="https://cloud.umami.is/script.js" data-website-id="a076c661-bed1-405c-a522-813794e688b4"></script>
|
||||
<!-- Analytics provider is configured in the footer script. -->
|
||||
</head>
|
||||
<body>
|
||||
<!-- noscript pixel: catches non-JS visitors (AI crawlers) -->
|
||||
<noscript>
|
||||
<img src="https://cloud.umami.is/api/send?type=event&payload=%7B%22website%22%3A%2207082d05-efd3-4f85-a7a1-b426b0e8bfaa%22%2C%22name%22%3A%22noscript-visit%22%7D" style="position:absolute;left:-9999px" alt="" width="1" height="1">
|
||||
</noscript>
|
||||
<!-- Nav -->
|
||||
<nav class="nav">
|
||||
<a class="nav-brand" href="#">CLI-Hub</a>
|
||||
@@ -1912,17 +2076,53 @@
|
||||
Powered by <a href="https://github.com/HKUDS/CLI-Anything" target="_blank">CLI-Anything</a>
|
||||
</div>
|
||||
<div class="footer-stats">
|
||||
<div class="footer-analytics-link" aria-label="Traffic snapshot">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
<span class="stat-dot dot-total"></span>
|
||||
<span id="stat-total">—</span> visits
|
||||
<span class="analytics-sep">·</span>
|
||||
<span class="stat-dot dot-human"></span>
|
||||
<span id="stat-human">—</span> human
|
||||
<span class="analytics-sep">·</span>
|
||||
<span class="stat-dot dot-agent"></span>
|
||||
<span id="stat-agent">—</span> AI agent
|
||||
</div>
|
||||
<section class="analytics-report" aria-label="Traffic snapshot">
|
||||
<header class="analytics-report-head">
|
||||
<span class="analytics-eyebrow">Live traffic</span>
|
||||
<span class="analytics-legend" aria-hidden="true">
|
||||
<span class="analytics-legend-item"><span class="analytics-dot human"></span>Human</span>
|
||||
<span class="analytics-legend-item"><span class="analytics-dot agent"></span>Agent</span>
|
||||
</span>
|
||||
</header>
|
||||
<div class="analytics-report-grid">
|
||||
<article class="analytics-stat">
|
||||
<span class="analytics-stat-label">Website visits</span>
|
||||
<strong id="stat-total" class="analytics-stat-value">21,123</strong>
|
||||
<div class="analytics-bar" aria-hidden="true">
|
||||
<span class="bar-human" id="bar-site-human" style="width: 36.4%"></span>
|
||||
<span class="bar-agent" id="bar-site-agent" style="width: 63.6%"></span>
|
||||
</div>
|
||||
<dl class="analytics-split">
|
||||
<div class="analytics-split-item">
|
||||
<dt><span class="analytics-dot human"></span>Human</dt>
|
||||
<dd><span id="stat-human" class="analytics-split-num">7,689</span><span id="pct-site-human" class="analytics-split-pct">36%</span></dd>
|
||||
</div>
|
||||
<div class="analytics-split-item">
|
||||
<dt><span class="analytics-dot agent"></span>Agent</dt>
|
||||
<dd><span id="stat-agent" class="analytics-split-num">13,434</span><span id="pct-site-agent" class="analytics-split-pct">64%</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
<article class="analytics-stat">
|
||||
<span class="analytics-stat-label">CLI-Hub calls</span>
|
||||
<strong id="stat-cli-total" class="analytics-stat-value">16,361</strong>
|
||||
<div class="analytics-bar" aria-hidden="true">
|
||||
<span class="bar-human" id="bar-cli-human" style="width: 19.8%"></span>
|
||||
<span class="bar-agent" id="bar-cli-agent" style="width: 80.2%"></span>
|
||||
</div>
|
||||
<dl class="analytics-split">
|
||||
<div class="analytics-split-item">
|
||||
<dt><span class="analytics-dot human"></span>Human</dt>
|
||||
<dd><span id="stat-cli-human" class="analytics-split-num">3,237</span><span id="pct-cli-human" class="analytics-split-pct">20%</span></dd>
|
||||
</div>
|
||||
<div class="analytics-split-item">
|
||||
<dt><span class="analytics-dot agent"></span>Agent</dt>
|
||||
<dd><span id="stat-cli-agent" class="analytics-split-num">13,124</span><span id="pct-cli-agent" class="analytics-split-pct">80%</span></dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -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<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
|
||||
window.posthog.init(POSTHOG_CONFIG.projectToken, {
|
||||
api_host: POSTHOG_CONFIG.apiHost,
|
||||
defaults: POSTHOG_CONFIG.defaults,
|
||||
person_profiles: POSTHOG_CONFIG.personProfiles,
|
||||
opt_out_useragent_filter: true,
|
||||
loaded: function(posthog) {
|
||||
posthogReady = true;
|
||||
while (pendingPosthogEvents.length) {
|
||||
const queued = pendingPosthogEvents.shift();
|
||||
posthog.capture(queued.eventName, analyticsContext(queued.eventData));
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function initUmami() {
|
||||
if (!isTrackedHost()) return;
|
||||
const script = document.createElement('script');
|
||||
script.defer = true;
|
||||
script.src = 'https://cloud.umami.is/script.js';
|
||||
script.dataset.websiteId = UMAMI_CONFIG.websiteId;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function trackWithPosthog(eventName, eventData) {
|
||||
if (!isTrackedHost() || !window.posthog) return;
|
||||
if (!posthogReady || typeof window.posthog.capture !== 'function') {
|
||||
pendingPosthogEvents.push({ eventName, eventData });
|
||||
return;
|
||||
}
|
||||
window.posthog.capture(eventName, analyticsContext(eventData));
|
||||
}
|
||||
|
||||
function trackWithUmami(eventName, eventData) {
|
||||
if (!isTrackedHost()) return;
|
||||
const payload = {
|
||||
type: 'event',
|
||||
payload: {
|
||||
website: UMAMI_CONFIG.websiteId,
|
||||
hostname: ANALYTICS_SITE,
|
||||
url: location.pathname,
|
||||
name: eventName,
|
||||
data: analyticsContext(eventData),
|
||||
},
|
||||
};
|
||||
fetch(UMAMI_CONFIG.sendUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user