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 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 = _find_free_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