wafer-cli 0.2.2__py3-none-any.whl → 0.2.3__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 +189 -4
- wafer/global_config.py +14 -3
- {wafer_cli-0.2.2.dist-info → wafer_cli-0.2.3.dist-info}/METADATA +2 -1
- {wafer_cli-0.2.2.dist-info → wafer_cli-0.2.3.dist-info}/RECORD +9 -8
- {wafer_cli-0.2.2.dist-info → wafer_cli-0.2.3.dist-info}/WHEEL +0 -0
- {wafer_cli-0.2.2.dist-info → wafer_cli-0.2.3.dist-info}/entry_points.txt +0 -0
- {wafer_cli-0.2.2.dist-info → wafer_cli-0.2.3.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
|
|
wafer/cli.py
CHANGED
|
@@ -18,9 +18,11 @@ Setup:
|
|
|
18
18
|
config CLI configuration and local GPU targets
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
|
+
import atexit
|
|
21
22
|
import json
|
|
22
23
|
import os
|
|
23
24
|
import sys
|
|
25
|
+
import time
|
|
24
26
|
from pathlib import Path
|
|
25
27
|
|
|
26
28
|
import trio
|
|
@@ -34,6 +36,98 @@ app = typer.Typer(
|
|
|
34
36
|
no_args_is_help=True,
|
|
35
37
|
)
|
|
36
38
|
|
|
39
|
+
# =============================================================================
|
|
40
|
+
# Analytics tracking
|
|
41
|
+
# =============================================================================
|
|
42
|
+
|
|
43
|
+
# Track command start time for duration calculation
|
|
44
|
+
_command_start_time: float | None = None
|
|
45
|
+
# Track command outcome (defaults to failure, set to success on clean exit)
|
|
46
|
+
_command_outcome: str = "failure"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _get_command_path(ctx: typer.Context) -> tuple[str, str | None]:
|
|
50
|
+
"""Extract command and subcommand from Typer context.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Tuple of (command, subcommand). subcommand may be None.
|
|
54
|
+
"""
|
|
55
|
+
# Build command path from invoked subcommand chain
|
|
56
|
+
invoked = ctx.invoked_subcommand
|
|
57
|
+
info_name = ctx.info_name or ""
|
|
58
|
+
|
|
59
|
+
# Get parent command if exists
|
|
60
|
+
parent_cmd = None
|
|
61
|
+
if ctx.parent and ctx.parent.info_name and ctx.parent.info_name != "wafer":
|
|
62
|
+
parent_cmd = ctx.parent.info_name
|
|
63
|
+
|
|
64
|
+
if parent_cmd:
|
|
65
|
+
return parent_cmd, info_name
|
|
66
|
+
return info_name or "unknown", invoked
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _mark_command_success() -> None:
|
|
70
|
+
"""Mark the current command as successful.
|
|
71
|
+
|
|
72
|
+
Call this at the end of successful command execution.
|
|
73
|
+
Commands that raise typer.Exit(1) or exceptions will remain marked as failures.
|
|
74
|
+
"""
|
|
75
|
+
global _command_outcome
|
|
76
|
+
_command_outcome = "success"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.callback()
|
|
80
|
+
def main_callback(ctx: typer.Context) -> None:
|
|
81
|
+
"""Initialize analytics and track command execution."""
|
|
82
|
+
global _command_start_time, _command_outcome
|
|
83
|
+
_command_start_time = time.time()
|
|
84
|
+
_command_outcome = "success" # Default to success, mark failure on exceptions
|
|
85
|
+
|
|
86
|
+
# Initialize analytics (lazy import to avoid slowing down --help)
|
|
87
|
+
from . import analytics
|
|
88
|
+
|
|
89
|
+
analytics.init_analytics()
|
|
90
|
+
|
|
91
|
+
# Install exception hook to catch SystemExit and mark failures
|
|
92
|
+
original_excepthook = sys.excepthook
|
|
93
|
+
|
|
94
|
+
def custom_excepthook(exc_type, exc_value, exc_traceback):
|
|
95
|
+
global _command_outcome
|
|
96
|
+
# Mark as failure if SystemExit with non-zero code, or any other exception
|
|
97
|
+
if exc_type is SystemExit:
|
|
98
|
+
exit_code = exc_value.code if hasattr(exc_value, 'code') else 1
|
|
99
|
+
if exit_code != 0 and exit_code is not None:
|
|
100
|
+
_command_outcome = "failure"
|
|
101
|
+
else:
|
|
102
|
+
_command_outcome = "failure"
|
|
103
|
+
# Call original excepthook
|
|
104
|
+
original_excepthook(exc_type, exc_value, exc_traceback)
|
|
105
|
+
|
|
106
|
+
sys.excepthook = custom_excepthook
|
|
107
|
+
|
|
108
|
+
# Register tracking at exit to capture command outcome
|
|
109
|
+
def track_on_exit() -> None:
|
|
110
|
+
command, subcommand = _get_command_path(ctx)
|
|
111
|
+
|
|
112
|
+
# Skip tracking for --help and --version
|
|
113
|
+
if ctx.resilient_parsing:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
# Calculate duration
|
|
117
|
+
duration_ms = None
|
|
118
|
+
if _command_start_time is not None:
|
|
119
|
+
duration_ms = int((time.time() - _command_start_time) * 1000)
|
|
120
|
+
|
|
121
|
+
# Track the command execution with the recorded outcome
|
|
122
|
+
analytics.track_command(
|
|
123
|
+
command=command,
|
|
124
|
+
subcommand=subcommand,
|
|
125
|
+
outcome=_command_outcome,
|
|
126
|
+
duration_ms=duration_ms,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
atexit.register(track_on_exit)
|
|
130
|
+
|
|
37
131
|
|
|
38
132
|
# =============================================================================
|
|
39
133
|
# Autocompletion helpers
|
|
@@ -1945,17 +2039,31 @@ def login(
|
|
|
1945
2039
|
token: str | None = typer.Option(
|
|
1946
2040
|
None, "--token", "-t", help="Access token (skip browser OAuth)"
|
|
1947
2041
|
),
|
|
2042
|
+
port: int | None = typer.Option(
|
|
2043
|
+
None, "--port", "-p", help="Port for OAuth callback server (default: 8765 for SSH, random for local)"
|
|
2044
|
+
),
|
|
1948
2045
|
) -> None:
|
|
1949
2046
|
"""Authenticate CLI with wafer-api via GitHub OAuth.
|
|
1950
2047
|
|
|
1951
2048
|
Opens browser for GitHub authentication. Use --token to skip browser.
|
|
1952
2049
|
Uses the API environment from config (see 'wafer config show').
|
|
1953
2050
|
|
|
2051
|
+
SSH Users:
|
|
2052
|
+
- Automatically uses port 8765 (just set up port forwarding once)
|
|
2053
|
+
- On local machine: ssh -L 8765:localhost:8765 user@host
|
|
2054
|
+
- On remote machine: wafer login
|
|
2055
|
+
- Browser opens locally, redirect works through tunnel
|
|
2056
|
+
|
|
2057
|
+
Manual token option:
|
|
2058
|
+
- Visit auth.wafer.ai, authenticate, copy token from URL
|
|
2059
|
+
- Run: wafer login --token <paste-token>
|
|
2060
|
+
|
|
1954
2061
|
Examples:
|
|
1955
|
-
wafer login
|
|
1956
|
-
wafer login --
|
|
2062
|
+
wafer login # auto-detects SSH, uses appropriate port
|
|
2063
|
+
wafer login --port 9000 # override port
|
|
2064
|
+
wafer login --token xyz # manual token (no browser)
|
|
1957
2065
|
|
|
1958
|
-
#
|
|
2066
|
+
# Change environment:
|
|
1959
2067
|
wafer config set api.environment staging
|
|
1960
2068
|
wafer login
|
|
1961
2069
|
"""
|
|
@@ -1971,11 +2079,21 @@ def login(
|
|
|
1971
2079
|
typer.echo(f"Auth: {get_supabase_url()}")
|
|
1972
2080
|
typer.echo("")
|
|
1973
2081
|
|
|
2082
|
+
# Auto-detect SSH and use fixed port
|
|
2083
|
+
if port is None:
|
|
2084
|
+
is_ssh = bool(os.environ.get("SSH_CONNECTION") or os.environ.get("SSH_CLIENT"))
|
|
2085
|
+
if is_ssh:
|
|
2086
|
+
port = 8765
|
|
2087
|
+
typer.echo("🔒 SSH session detected - using port 8765 for OAuth callback")
|
|
2088
|
+
typer.echo(" Make sure you have port forwarding set up:")
|
|
2089
|
+
typer.echo(" ssh -L 8765:localhost:8765 user@host")
|
|
2090
|
+
typer.echo("")
|
|
2091
|
+
|
|
1974
2092
|
# Browser OAuth if no token provided
|
|
1975
2093
|
refresh_token = None
|
|
1976
2094
|
if token is None:
|
|
1977
2095
|
try:
|
|
1978
|
-
token, refresh_token = browser_login()
|
|
2096
|
+
token, refresh_token = browser_login(port=port)
|
|
1979
2097
|
except TimeoutError as e:
|
|
1980
2098
|
typer.echo(f"Error: {e}", err=True)
|
|
1981
2099
|
raise typer.Exit(1) from None
|
|
@@ -2009,6 +2127,11 @@ def login(
|
|
|
2009
2127
|
# Save credentials (with refresh token if available)
|
|
2010
2128
|
save_credentials(token, refresh_token, user_info.email)
|
|
2011
2129
|
|
|
2130
|
+
# Track login event with analytics
|
|
2131
|
+
from . import analytics
|
|
2132
|
+
|
|
2133
|
+
analytics.track_login(user_info.user_id, user_info.email)
|
|
2134
|
+
|
|
2012
2135
|
if user_info.email:
|
|
2013
2136
|
typer.echo(f"Logged in as {user_info.email}")
|
|
2014
2137
|
else:
|
|
@@ -2021,6 +2144,13 @@ def logout() -> None:
|
|
|
2021
2144
|
"""Remove stored credentials."""
|
|
2022
2145
|
from .auth import clear_credentials
|
|
2023
2146
|
|
|
2147
|
+
from . import analytics
|
|
2148
|
+
|
|
2149
|
+
# Track logout event first (while credentials still exist for user identification)
|
|
2150
|
+
# Note: track_logout() handles the case where user is not logged in
|
|
2151
|
+
analytics.track_logout()
|
|
2152
|
+
|
|
2153
|
+
# Clear credentials and report result
|
|
2024
2154
|
if clear_credentials():
|
|
2025
2155
|
typer.echo("Logged out. Credentials removed.")
|
|
2026
2156
|
else:
|
|
@@ -4298,6 +4428,29 @@ autotuner_app = typer.Typer(help="Hyperparameter sweep for performance engineeri
|
|
|
4298
4428
|
app.add_typer(autotuner_app, name="autotuner", hidden=True)
|
|
4299
4429
|
|
|
4300
4430
|
|
|
4431
|
+
def _setup_wafer_core_env() -> None:
|
|
4432
|
+
"""Set environment variables for wafer-core to use.
|
|
4433
|
+
|
|
4434
|
+
Call this before using any wafer-core functions that need API access.
|
|
4435
|
+
|
|
4436
|
+
Respects explicit environment variable overrides:
|
|
4437
|
+
- WAFER_API_URL: If already set, uses that instead of config
|
|
4438
|
+
- WAFER_AUTH_TOKEN: If already set, uses that instead of cached token
|
|
4439
|
+
"""
|
|
4440
|
+
from .global_config import get_api_url
|
|
4441
|
+
from .auth import get_valid_token
|
|
4442
|
+
|
|
4443
|
+
# Set API URL (get_api_url already respects WAFER_API_URL env var)
|
|
4444
|
+
os.environ["WAFER_API_URL"] = get_api_url()
|
|
4445
|
+
|
|
4446
|
+
# Only set auth token if not explicitly provided in environment
|
|
4447
|
+
# This allows CI/service accounts to override with their own tokens
|
|
4448
|
+
if "WAFER_AUTH_TOKEN" not in os.environ:
|
|
4449
|
+
token = get_valid_token()
|
|
4450
|
+
if token:
|
|
4451
|
+
os.environ["WAFER_AUTH_TOKEN"] = token
|
|
4452
|
+
|
|
4453
|
+
|
|
4301
4454
|
@autotuner_app.command("list")
|
|
4302
4455
|
def autotuner_list(
|
|
4303
4456
|
show_all: bool = typer.Option(
|
|
@@ -4313,6 +4466,7 @@ def autotuner_list(
|
|
|
4313
4466
|
wafer autotuner list
|
|
4314
4467
|
wafer autotuner list --all
|
|
4315
4468
|
"""
|
|
4469
|
+
_setup_wafer_core_env()
|
|
4316
4470
|
from .autotuner import list_command
|
|
4317
4471
|
|
|
4318
4472
|
try:
|
|
@@ -4353,6 +4507,7 @@ def autotuner_delete(
|
|
|
4353
4507
|
wafer autotuner delete --all --status pending
|
|
4354
4508
|
wafer autotuner delete --all --status failed --yes
|
|
4355
4509
|
"""
|
|
4510
|
+
_setup_wafer_core_env()
|
|
4356
4511
|
from .autotuner import delete_all_command, delete_command
|
|
4357
4512
|
|
|
4358
4513
|
# Validate arguments
|
|
@@ -4419,6 +4574,7 @@ def autotuner_run(
|
|
|
4419
4574
|
wafer autotuner run --resume <sweep-id>
|
|
4420
4575
|
wafer autotuner run --resume <sweep-id> --parallel 8
|
|
4421
4576
|
"""
|
|
4577
|
+
_setup_wafer_core_env()
|
|
4422
4578
|
from .autotuner import run_sweep_command
|
|
4423
4579
|
|
|
4424
4580
|
# Validate arguments
|
|
@@ -4583,8 +4739,23 @@ def capture_command( # noqa: PLR0915
|
|
|
4583
4739
|
wafer capture grid-search "python train.py --lr {LR} --bs {BS}" --sweep "LR=0.001,0.01,0.1" --sweep "BS=16,32"
|
|
4584
4740
|
"""
|
|
4585
4741
|
import itertools
|
|
4742
|
+
import os
|
|
4586
4743
|
import tomllib
|
|
4587
4744
|
|
|
4745
|
+
from .global_config import get_api_url
|
|
4746
|
+
from .auth import get_valid_token
|
|
4747
|
+
|
|
4748
|
+
# Set environment variables for wafer-core BEFORE importing it
|
|
4749
|
+
# wafer-core backend.py reads WAFER_API_URL and WAFER_AUTH_TOKEN from env
|
|
4750
|
+
os.environ["WAFER_API_URL"] = get_api_url()
|
|
4751
|
+
|
|
4752
|
+
# Only set auth token if not explicitly provided in environment
|
|
4753
|
+
# This allows CI/service accounts to override with their own tokens
|
|
4754
|
+
if "WAFER_AUTH_TOKEN" not in os.environ:
|
|
4755
|
+
token = get_valid_token()
|
|
4756
|
+
if token:
|
|
4757
|
+
os.environ["WAFER_AUTH_TOKEN"] = token
|
|
4758
|
+
|
|
4588
4759
|
import trio
|
|
4589
4760
|
from wafer_core.tools.capture_tool import ( # pragma: no cover
|
|
4590
4761
|
CaptureConfig,
|
|
@@ -4774,6 +4945,20 @@ def capture_list_command(
|
|
|
4774
4945
|
# Pagination
|
|
4775
4946
|
wafer capture-list --limit 20 --offset 20
|
|
4776
4947
|
"""
|
|
4948
|
+
import os
|
|
4949
|
+
|
|
4950
|
+
from .global_config import get_api_url
|
|
4951
|
+
from .auth import get_valid_token
|
|
4952
|
+
|
|
4953
|
+
# Set environment variables for wafer-core BEFORE importing it
|
|
4954
|
+
os.environ["WAFER_API_URL"] = get_api_url()
|
|
4955
|
+
|
|
4956
|
+
# Only set auth token if not explicitly provided in environment
|
|
4957
|
+
# This allows CI/service accounts to override with their own tokens
|
|
4958
|
+
if "WAFER_AUTH_TOKEN" not in os.environ:
|
|
4959
|
+
token = get_valid_token()
|
|
4960
|
+
if token:
|
|
4961
|
+
os.environ["WAFER_AUTH_TOKEN"] = token
|
|
4777
4962
|
|
|
4778
4963
|
import trio
|
|
4779
4964
|
from wafer_core.utils.backend import list_captures # pragma: no cover
|
wafer/global_config.py
CHANGED
|
@@ -67,9 +67,12 @@ class Preferences:
|
|
|
67
67
|
|
|
68
68
|
mode: "implicit" (default) = quiet output, use -v for status messages
|
|
69
69
|
"explicit" = verbose output, shows [wafer] status messages
|
|
70
|
+
analytics_enabled: True (default) = send anonymous usage analytics to PostHog
|
|
71
|
+
False = disable all analytics tracking
|
|
70
72
|
"""
|
|
71
73
|
|
|
72
74
|
mode: Literal["explicit", "implicit"] = "implicit"
|
|
75
|
+
analytics_enabled: bool = True
|
|
73
76
|
|
|
74
77
|
|
|
75
78
|
@dataclass(frozen=True)
|
|
@@ -148,7 +151,9 @@ def _parse_config_file(path: Path) -> GlobalConfig:
|
|
|
148
151
|
pref_data = data.get("preferences", {})
|
|
149
152
|
mode = pref_data.get("mode", "explicit")
|
|
150
153
|
assert mode in ("explicit", "implicit"), f"mode must be 'explicit' or 'implicit', got '{mode}'"
|
|
151
|
-
|
|
154
|
+
analytics_enabled = pref_data.get("analytics_enabled", True)
|
|
155
|
+
assert isinstance(analytics_enabled, bool), f"analytics_enabled must be true or false, got '{analytics_enabled}'"
|
|
156
|
+
preferences = Preferences(mode=mode, analytics_enabled=analytics_enabled)
|
|
152
157
|
|
|
153
158
|
# Parse defaults
|
|
154
159
|
defaults_data = data.get("defaults", {})
|
|
@@ -301,10 +306,16 @@ def save_global_config(config: GlobalConfig) -> None:
|
|
|
301
306
|
lines.append(f"{key} = {value}")
|
|
302
307
|
lines.append("")
|
|
303
308
|
|
|
304
|
-
# Preferences section (only if non-default)
|
|
309
|
+
# Preferences section (only if non-default values)
|
|
310
|
+
pref_lines = []
|
|
305
311
|
if config.preferences.mode != "implicit":
|
|
312
|
+
pref_lines.append(f'mode = "{config.preferences.mode}"')
|
|
313
|
+
if not config.preferences.analytics_enabled:
|
|
314
|
+
pref_lines.append("analytics_enabled = false")
|
|
315
|
+
|
|
316
|
+
if pref_lines:
|
|
306
317
|
lines.append("[preferences]")
|
|
307
|
-
lines.
|
|
318
|
+
lines.extend(pref_lines)
|
|
308
319
|
lines.append("")
|
|
309
320
|
|
|
310
321
|
# Defaults section (only if non-default values)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wafer-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: CLI tool for running commands on remote GPUs and GPU kernel optimization agent
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: typer>=0.12.0
|
|
@@ -8,6 +8,7 @@ Requires-Dist: trio>=0.24.0
|
|
|
8
8
|
Requires-Dist: trio-asyncio>=0.15.0
|
|
9
9
|
Requires-Dist: wafer-core>=0.1.0
|
|
10
10
|
Requires-Dist: perfetto>=0.16.0
|
|
11
|
+
Requires-Dist: posthog>=3.0.0
|
|
11
12
|
Provides-Extra: dev
|
|
12
13
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
13
14
|
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
wafer/GUIDE.md,sha256=Z_jsSgHAS6bFa83VKhG9jxjUK1XpLjR1fEIKapDa_6g,3195
|
|
2
2
|
wafer/__init__.py,sha256=kBM_ONCpU6UUMBOH8Tmg4A88sNFnbaD59o61cJs-uYM,90
|
|
3
|
+
wafer/analytics.py,sha256=Xxw3bbY3XLgedSJPwzIOBJIjyycIiornWCpjoWbTKYU,8190
|
|
3
4
|
wafer/api_client.py,sha256=cPULiTxqOAYYSfDTNJgd-6Pqrt3IM4Gm9903U7yGIwY,6163
|
|
4
|
-
wafer/auth.py,sha256=
|
|
5
|
+
wafer/auth.py,sha256=ZLsXZ73GDLD8GL7Rij1ELtuLqyJ5EU_uPBUMPVKwExA,10703
|
|
5
6
|
wafer/autotuner.py,sha256=6gH0Ho7T58EFerMQcHQxshWe3DF4qU7fb5xthAh5SPM,44364
|
|
6
7
|
wafer/billing.py,sha256=jbLB2lI4_9f2KD8uEFDi_ixLlowe5hasC0TIZJyIXRg,7163
|
|
7
|
-
wafer/cli.py,sha256=
|
|
8
|
+
wafer/cli.py,sha256=kscK-JBiAu0H7roMdcaZ_lT7xlgR6mUJ1nBruidUdsE,184745
|
|
8
9
|
wafer/config.py,sha256=h5Eo9_yfWqWGoPNdVQikI9GoZVUeysunSYiixf1mKcw,3411
|
|
9
10
|
wafer/corpus.py,sha256=yTF3UA5bOa8BII2fmcXf-3WsIsM5DX4etysv0AzVknE,8912
|
|
10
11
|
wafer/evaluate.py,sha256=7E1BqVl0um7kfU2_4FkXZrNc-PQJIHe09G1-L07PXmg,114971
|
|
11
|
-
wafer/global_config.py,sha256=
|
|
12
|
+
wafer/global_config.py,sha256=fhaR_RU3ufMksDmOohH1OLeQ0JT0SDW1hEip_zaP75k,11345
|
|
12
13
|
wafer/gpu_run.py,sha256=gUbzMsMPsw3UHcn00bI-zTSHrU8c2FEpDvbcsczlDPo,9557
|
|
13
14
|
wafer/inference.py,sha256=tZCO5i05FKY27ewis3CSBHFBeFbXY3xwj0DSjdoMY9s,4314
|
|
14
15
|
wafer/ncu_analyze.py,sha256=rAWzKQRZEY6E_CL3gAWUaW3uZ4kvQVZskVCPDpsFJuE,24633
|
|
@@ -25,8 +26,8 @@ wafer/templates/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
|
25
26
|
wafer/templates/ask_docs.py,sha256=Lxs-faz9v5m4Qa4NjF2X_lE8KwM9ES9MNJkxo7ep56o,2256
|
|
26
27
|
wafer/templates/optimize_kernel.py,sha256=u6AL7Q3uttqlnBLzcoFdsiPq5lV2TV3bgqwCYYlK9gk,2357
|
|
27
28
|
wafer/templates/trace_analyze.py,sha256=XE1VqzVkIUsZbXF8EzQdDYgg-AZEYAOFpr6B_vnRELc,2880
|
|
28
|
-
wafer_cli-0.2.
|
|
29
|
-
wafer_cli-0.2.
|
|
30
|
-
wafer_cli-0.2.
|
|
31
|
-
wafer_cli-0.2.
|
|
32
|
-
wafer_cli-0.2.
|
|
29
|
+
wafer_cli-0.2.3.dist-info/METADATA,sha256=MKzRmMwc1zhKUVeu_NE4h5_17EFnX1bjLE4Jcox0AAk,559
|
|
30
|
+
wafer_cli-0.2.3.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
31
|
+
wafer_cli-0.2.3.dist-info/entry_points.txt,sha256=WqB7hB__WhtPY8y1cO2sZiUz7fCq6Ik-usAigpeFvWE,41
|
|
32
|
+
wafer_cli-0.2.3.dist-info/top_level.txt,sha256=2MK1IVMWfpLL8BZCQ3E9aG6L6L666gSA_teYlwan4fs,6
|
|
33
|
+
wafer_cli-0.2.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|