wafer-cli 0.2.2__py3-none-any.whl → 0.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- wafer/analytics.py +307 -0
- wafer/auth.py +4 -2
- wafer/cli.py +661 -15
- wafer/evaluate.py +760 -268
- wafer/global_config.py +14 -3
- wafer/gpu_run.py +5 -1
- wafer/problems.py +357 -0
- wafer/wevin_cli.py +22 -2
- {wafer_cli-0.2.2.dist-info → wafer_cli-0.2.4.dist-info}/METADATA +2 -1
- {wafer_cli-0.2.2.dist-info → wafer_cli-0.2.4.dist-info}/RECORD +13 -11
- {wafer_cli-0.2.2.dist-info → wafer_cli-0.2.4.dist-info}/WHEEL +1 -1
- {wafer_cli-0.2.2.dist-info → wafer_cli-0.2.4.dist-info}/entry_points.txt +0 -0
- {wafer_cli-0.2.2.dist-info → wafer_cli-0.2.4.dist-info}/top_level.txt +0 -0
wafer/analytics.py
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"""PostHog analytics for Wafer CLI.
|
|
2
|
+
|
|
3
|
+
Tracks CLI command usage and user activity for product analytics.
|
|
4
|
+
Mirrors the analytics implementation in apps/wevin-extension/src/services/analytics.ts.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from .analytics import track_command, identify_user, shutdown_analytics
|
|
8
|
+
|
|
9
|
+
# Track a command execution
|
|
10
|
+
track_command("evaluate", {"subcommand": "kernelbench", "outcome": "success"})
|
|
11
|
+
|
|
12
|
+
# Identify user after login
|
|
13
|
+
identify_user("user-id", "user@example.com")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import atexit
|
|
17
|
+
import platform
|
|
18
|
+
import sys
|
|
19
|
+
import uuid
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
# PostHog configuration - same as wevin-extension
|
|
24
|
+
POSTHOG_API_KEY = "phc_9eDjkY72ud9o4l1mA1Gr1dnRT1yx71rP3XY9z66teFh"
|
|
25
|
+
POSTHOG_HOST = "https://us.i.posthog.com"
|
|
26
|
+
|
|
27
|
+
# Anonymous ID storage
|
|
28
|
+
ANONYMOUS_ID_FILE = Path.home() / ".wafer" / ".analytics_id"
|
|
29
|
+
|
|
30
|
+
# Global state
|
|
31
|
+
_posthog_client: Any = None
|
|
32
|
+
_distinct_id: str | None = None
|
|
33
|
+
_initialized: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_anonymous_id() -> str:
|
|
37
|
+
"""Get or create anonymous ID for users who aren't logged in."""
|
|
38
|
+
if ANONYMOUS_ID_FILE.exists():
|
|
39
|
+
return ANONYMOUS_ID_FILE.read_text().strip()
|
|
40
|
+
|
|
41
|
+
# Generate new anonymous ID
|
|
42
|
+
anonymous_id = f"anon_{uuid.uuid4().hex}"
|
|
43
|
+
ANONYMOUS_ID_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
ANONYMOUS_ID_FILE.write_text(anonymous_id)
|
|
45
|
+
return anonymous_id
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_user_id_from_credentials() -> tuple[str | None, str | None]:
|
|
49
|
+
"""Get user ID and email from stored credentials.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
Tuple of (user_id, email), both may be None if not logged in.
|
|
53
|
+
"""
|
|
54
|
+
# Import here to avoid circular imports
|
|
55
|
+
from .auth import load_credentials, verify_token
|
|
56
|
+
|
|
57
|
+
creds = load_credentials()
|
|
58
|
+
if not creds:
|
|
59
|
+
return None, None
|
|
60
|
+
|
|
61
|
+
# Try to get user info from token
|
|
62
|
+
try:
|
|
63
|
+
user_info = verify_token(creds.access_token)
|
|
64
|
+
return user_info.user_id, user_info.email or creds.email
|
|
65
|
+
except Exception:
|
|
66
|
+
# Token verification failed, use email from credentials if available
|
|
67
|
+
return None, creds.email
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _is_analytics_enabled() -> bool:
|
|
71
|
+
"""Check if analytics is enabled via preferences.
|
|
72
|
+
|
|
73
|
+
Returns True by default, respects user preference in config.
|
|
74
|
+
"""
|
|
75
|
+
from .global_config import get_preferences
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
prefs = get_preferences()
|
|
79
|
+
return getattr(prefs, "analytics_enabled", True)
|
|
80
|
+
except Exception:
|
|
81
|
+
# Default to enabled if we can't read preferences
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def init_analytics() -> bool:
|
|
86
|
+
"""Initialize PostHog client.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
True if initialization succeeded, False otherwise.
|
|
90
|
+
"""
|
|
91
|
+
global _posthog_client, _distinct_id, _initialized
|
|
92
|
+
|
|
93
|
+
if _initialized:
|
|
94
|
+
return _posthog_client is not None
|
|
95
|
+
|
|
96
|
+
_initialized = True
|
|
97
|
+
|
|
98
|
+
# Check if analytics is enabled
|
|
99
|
+
if not _is_analytics_enabled():
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
from posthog import Posthog
|
|
104
|
+
|
|
105
|
+
_posthog_client = Posthog(
|
|
106
|
+
api_key=POSTHOG_API_KEY,
|
|
107
|
+
host=POSTHOG_HOST,
|
|
108
|
+
# Flush immediately for CLI - commands are short-lived
|
|
109
|
+
flush_at=1,
|
|
110
|
+
flush_interval=1,
|
|
111
|
+
# Disable debug logging
|
|
112
|
+
debug=False,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Set up distinct ID - prefer authenticated user, fall back to anonymous
|
|
116
|
+
user_id, email = _get_user_id_from_credentials()
|
|
117
|
+
if user_id:
|
|
118
|
+
_distinct_id = user_id
|
|
119
|
+
# Identify the user with their email
|
|
120
|
+
if email:
|
|
121
|
+
_posthog_client.identify(
|
|
122
|
+
distinct_id=user_id,
|
|
123
|
+
properties={
|
|
124
|
+
"email": email,
|
|
125
|
+
"auth_provider": "github",
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
_distinct_id = _get_anonymous_id()
|
|
130
|
+
|
|
131
|
+
# Register shutdown handler to flush events
|
|
132
|
+
atexit.register(shutdown_analytics)
|
|
133
|
+
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
except ImportError:
|
|
137
|
+
# PostHog not installed - analytics disabled
|
|
138
|
+
return False
|
|
139
|
+
except Exception:
|
|
140
|
+
# Any other error - fail silently, don't break CLI
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def shutdown_analytics() -> None:
|
|
145
|
+
"""Shutdown PostHog client and flush pending events."""
|
|
146
|
+
global _posthog_client
|
|
147
|
+
|
|
148
|
+
if _posthog_client is not None:
|
|
149
|
+
try:
|
|
150
|
+
_posthog_client.flush()
|
|
151
|
+
_posthog_client.shutdown()
|
|
152
|
+
except Exception:
|
|
153
|
+
pass # Fail silently on shutdown
|
|
154
|
+
_posthog_client = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def identify_user(user_id: str, email: str | None = None) -> None:
|
|
158
|
+
"""Identify a user after login.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
user_id: Supabase user ID
|
|
162
|
+
email: User's email address
|
|
163
|
+
"""
|
|
164
|
+
global _distinct_id
|
|
165
|
+
|
|
166
|
+
if not init_analytics():
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
if _posthog_client is None:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
_distinct_id = user_id
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
properties: dict[str, Any] = {"auth_provider": "github"}
|
|
176
|
+
if email:
|
|
177
|
+
properties["email"] = email
|
|
178
|
+
|
|
179
|
+
_posthog_client.identify(
|
|
180
|
+
distinct_id=user_id,
|
|
181
|
+
properties=properties,
|
|
182
|
+
)
|
|
183
|
+
_posthog_client.flush()
|
|
184
|
+
except Exception:
|
|
185
|
+
pass # Fail silently
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def reset_user_identity() -> None:
|
|
189
|
+
"""Reset user identity after logout."""
|
|
190
|
+
global _distinct_id
|
|
191
|
+
|
|
192
|
+
_distinct_id = _get_anonymous_id()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def get_distinct_id() -> str:
|
|
196
|
+
"""Get current distinct ID for tracking."""
|
|
197
|
+
global _distinct_id
|
|
198
|
+
|
|
199
|
+
if _distinct_id is None:
|
|
200
|
+
user_id, _ = _get_user_id_from_credentials()
|
|
201
|
+
_distinct_id = user_id or _get_anonymous_id()
|
|
202
|
+
|
|
203
|
+
return _distinct_id
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _get_cli_version() -> str:
|
|
207
|
+
"""Get CLI version from package metadata."""
|
|
208
|
+
try:
|
|
209
|
+
from importlib.metadata import version
|
|
210
|
+
|
|
211
|
+
return version("wafer-cli")
|
|
212
|
+
except Exception:
|
|
213
|
+
return "unknown"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _get_base_properties() -> dict[str, Any]:
|
|
217
|
+
"""Get base properties included with all events."""
|
|
218
|
+
return {
|
|
219
|
+
"platform": "cli",
|
|
220
|
+
"tool_id": "cli",
|
|
221
|
+
"cli_version": _get_cli_version(),
|
|
222
|
+
"os": platform.system().lower(),
|
|
223
|
+
"os_version": platform.release(),
|
|
224
|
+
"python_version": platform.python_version(),
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def track_event(event_name: str, properties: dict[str, Any] | None = None) -> None:
|
|
229
|
+
"""Track a generic event.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
event_name: Name of the event to track
|
|
233
|
+
properties: Additional properties to include
|
|
234
|
+
"""
|
|
235
|
+
if not init_analytics():
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
if _posthog_client is None:
|
|
239
|
+
return
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
event_properties = _get_base_properties()
|
|
243
|
+
if properties:
|
|
244
|
+
event_properties.update(properties)
|
|
245
|
+
|
|
246
|
+
_posthog_client.capture(
|
|
247
|
+
distinct_id=get_distinct_id(),
|
|
248
|
+
event=event_name,
|
|
249
|
+
properties=event_properties,
|
|
250
|
+
)
|
|
251
|
+
except Exception:
|
|
252
|
+
pass # Fail silently
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def track_command(
|
|
256
|
+
command: str,
|
|
257
|
+
subcommand: str | None = None,
|
|
258
|
+
outcome: str = "success",
|
|
259
|
+
duration_ms: int | None = None,
|
|
260
|
+
properties: dict[str, Any] | None = None,
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Track a CLI command execution.
|
|
263
|
+
|
|
264
|
+
This event counts towards DAU in the internal dashboard.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
command: The main command name (e.g., "evaluate", "agent")
|
|
268
|
+
subcommand: Optional subcommand (e.g., "kernelbench")
|
|
269
|
+
outcome: "success" or "error"
|
|
270
|
+
duration_ms: Command execution time in milliseconds
|
|
271
|
+
properties: Additional properties to include
|
|
272
|
+
"""
|
|
273
|
+
event_properties: dict[str, Any] = {
|
|
274
|
+
"command": command,
|
|
275
|
+
"outcome": outcome,
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if subcommand:
|
|
279
|
+
event_properties["subcommand"] = subcommand
|
|
280
|
+
|
|
281
|
+
if duration_ms is not None:
|
|
282
|
+
event_properties["duration_ms"] = duration_ms
|
|
283
|
+
|
|
284
|
+
if properties:
|
|
285
|
+
event_properties.update(properties)
|
|
286
|
+
|
|
287
|
+
track_event("cli_command_executed", event_properties)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def track_login(user_id: str, email: str | None = None) -> None:
|
|
291
|
+
"""Track user login event.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
user_id: Supabase user ID
|
|
295
|
+
email: User's email address
|
|
296
|
+
"""
|
|
297
|
+
# First identify the user
|
|
298
|
+
identify_user(user_id, email)
|
|
299
|
+
|
|
300
|
+
# Then track the login event
|
|
301
|
+
track_event("cli_user_signed_in", {"user_id": user_id})
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def track_logout() -> None:
|
|
305
|
+
"""Track user logout event."""
|
|
306
|
+
track_event("cli_user_signed_out")
|
|
307
|
+
reset_user_identity()
|
wafer/auth.py
CHANGED
|
@@ -287,7 +287,7 @@ if (accessToken) {
|
|
|
287
287
|
self.end_headers()
|
|
288
288
|
|
|
289
289
|
|
|
290
|
-
def browser_login(timeout: int = 120) -> tuple[str, str | None]:
|
|
290
|
+
def browser_login(timeout: int = 120, port: int | None = None) -> tuple[str, str | None]:
|
|
291
291
|
"""Open browser for GitHub OAuth and return tokens.
|
|
292
292
|
|
|
293
293
|
Starts a local HTTP server, opens browser to Supabase OAuth,
|
|
@@ -295,6 +295,7 @@ def browser_login(timeout: int = 120) -> tuple[str, str | None]:
|
|
|
295
295
|
|
|
296
296
|
Args:
|
|
297
297
|
timeout: Seconds to wait for callback (default 120)
|
|
298
|
+
port: Port for callback server. If None, finds a free port (default None)
|
|
298
299
|
|
|
299
300
|
Returns:
|
|
300
301
|
Tuple of (access_token, refresh_token). refresh_token may be None.
|
|
@@ -303,7 +304,8 @@ def browser_login(timeout: int = 120) -> tuple[str, str | None]:
|
|
|
303
304
|
TimeoutError: If no callback received within timeout
|
|
304
305
|
RuntimeError: If OAuth flow failed
|
|
305
306
|
"""
|
|
306
|
-
port
|
|
307
|
+
if port is None:
|
|
308
|
+
port = _find_free_port()
|
|
307
309
|
redirect_uri = f"http://localhost:{port}/callback"
|
|
308
310
|
supabase_url = get_supabase_url()
|
|
309
311
|
|