golf-mcp 0.2.16__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.
Files changed (52) hide show
  1. golf/__init__.py +1 -0
  2. golf/auth/__init__.py +277 -0
  3. golf/auth/api_key.py +73 -0
  4. golf/auth/factory.py +360 -0
  5. golf/auth/helpers.py +175 -0
  6. golf/auth/providers.py +586 -0
  7. golf/auth/registry.py +256 -0
  8. golf/cli/__init__.py +1 -0
  9. golf/cli/branding.py +191 -0
  10. golf/cli/main.py +377 -0
  11. golf/commands/__init__.py +5 -0
  12. golf/commands/build.py +81 -0
  13. golf/commands/init.py +290 -0
  14. golf/commands/run.py +137 -0
  15. golf/core/__init__.py +1 -0
  16. golf/core/builder.py +1884 -0
  17. golf/core/builder_auth.py +209 -0
  18. golf/core/builder_metrics.py +221 -0
  19. golf/core/builder_telemetry.py +99 -0
  20. golf/core/config.py +199 -0
  21. golf/core/parser.py +1085 -0
  22. golf/core/telemetry.py +492 -0
  23. golf/core/transformer.py +231 -0
  24. golf/examples/__init__.py +0 -0
  25. golf/examples/basic/.env.example +4 -0
  26. golf/examples/basic/README.md +133 -0
  27. golf/examples/basic/auth.py +76 -0
  28. golf/examples/basic/golf.json +5 -0
  29. golf/examples/basic/prompts/welcome.py +27 -0
  30. golf/examples/basic/resources/current_time.py +34 -0
  31. golf/examples/basic/resources/info.py +28 -0
  32. golf/examples/basic/resources/weather/city.py +46 -0
  33. golf/examples/basic/resources/weather/client.py +48 -0
  34. golf/examples/basic/resources/weather/current.py +36 -0
  35. golf/examples/basic/resources/weather/forecast.py +36 -0
  36. golf/examples/basic/tools/calculator.py +94 -0
  37. golf/examples/basic/tools/say/hello.py +65 -0
  38. golf/metrics/__init__.py +10 -0
  39. golf/metrics/collector.py +320 -0
  40. golf/metrics/registry.py +12 -0
  41. golf/telemetry/__init__.py +23 -0
  42. golf/telemetry/instrumentation.py +1402 -0
  43. golf/utilities/__init__.py +12 -0
  44. golf/utilities/context.py +53 -0
  45. golf/utilities/elicitation.py +170 -0
  46. golf/utilities/sampling.py +221 -0
  47. golf_mcp-0.2.16.dist-info/METADATA +262 -0
  48. golf_mcp-0.2.16.dist-info/RECORD +52 -0
  49. golf_mcp-0.2.16.dist-info/WHEEL +5 -0
  50. golf_mcp-0.2.16.dist-info/entry_points.txt +2 -0
  51. golf_mcp-0.2.16.dist-info/licenses/LICENSE +201 -0
  52. golf_mcp-0.2.16.dist-info/top_level.txt +1 -0
golf/core/telemetry.py ADDED
@@ -0,0 +1,492 @@
1
+ """Telemetry module for anonymous usage tracking with PostHog."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import platform
7
+ import uuid
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import posthog
12
+ from rich.console import Console
13
+
14
+ from golf import __version__
15
+
16
+ console = Console()
17
+
18
+ # PostHog configuration
19
+ # This is a client-side API key, safe to be public
20
+ # Users can override with GOLF_POSTHOG_API_KEY environment variable
21
+ DEFAULT_POSTHOG_API_KEY = "phc_7ccsDDxoC5tK5hodlrs2moGC74cThRzcN63flRYPWGl"
22
+ POSTHOG_API_KEY = os.environ.get("GOLF_POSTHOG_API_KEY", DEFAULT_POSTHOG_API_KEY)
23
+ POSTHOG_HOST = "https://us.i.posthog.com"
24
+
25
+ # Telemetry state
26
+ _telemetry_enabled: bool | None = None
27
+ _anonymous_id: str | None = None
28
+ _user_identified: bool = False # Track if we've already identified the user
29
+
30
+
31
+ def _is_test_mode() -> bool:
32
+ """Check if we're in test mode."""
33
+ return os.environ.get("GOLF_TEST_MODE", "").lower() in ("1", "true", "yes", "on")
34
+
35
+
36
+ def _ensure_posthog_disabled_in_test_mode() -> None:
37
+ """Ensure PostHog is disabled when in test mode."""
38
+ if _is_test_mode() and not posthog.disabled:
39
+ posthog.disabled = True
40
+
41
+
42
+ def get_telemetry_config_path() -> Path:
43
+ """Get the path to the telemetry configuration file."""
44
+ return Path.home() / ".golf" / "telemetry.json"
45
+
46
+
47
+ def save_telemetry_preference(enabled: bool) -> None:
48
+ """Save telemetry preference to persistent storage."""
49
+ config_path = get_telemetry_config_path()
50
+ config_path.parent.mkdir(parents=True, exist_ok=True)
51
+
52
+ config = {"enabled": enabled, "version": 1}
53
+
54
+ try:
55
+ with open(config_path, "w") as f:
56
+ json.dump(config, f)
57
+ except Exception:
58
+ # Don't fail if we can't save the preference
59
+ pass
60
+
61
+
62
+ def load_telemetry_preference() -> bool | None:
63
+ """Load telemetry preference from persistent storage."""
64
+ config_path = get_telemetry_config_path()
65
+
66
+ if not config_path.exists():
67
+ return None
68
+
69
+ try:
70
+ with open(config_path) as f:
71
+ config = json.load(f)
72
+ return config.get("enabled")
73
+ except Exception:
74
+ return None
75
+
76
+
77
+ def is_telemetry_enabled() -> bool:
78
+ """Check if telemetry is enabled.
79
+
80
+ Checks in order:
81
+ 1. Cached value
82
+ 2. GOLF_TEST_MODE environment variable (always disabled in test mode)
83
+ 3. GOLF_TELEMETRY environment variable
84
+ 4. Persistent preference file
85
+ 5. Default to False (opt-in model)
86
+ """
87
+ global _telemetry_enabled
88
+
89
+ if _telemetry_enabled is not None:
90
+ return _telemetry_enabled
91
+
92
+ # Check if we're in test mode (highest priority after cache)
93
+ if _is_test_mode():
94
+ _telemetry_enabled = False
95
+ return False
96
+
97
+ # Check environment variables (second highest priority)
98
+ env_telemetry = os.environ.get("GOLF_TELEMETRY", "").lower()
99
+ if env_telemetry in ("0", "false", "no", "off"):
100
+ _telemetry_enabled = False
101
+ return False
102
+ elif env_telemetry in ("1", "true", "yes", "on"):
103
+ _telemetry_enabled = True
104
+ return True
105
+
106
+ # Check persistent preference
107
+ saved_preference = load_telemetry_preference()
108
+ if saved_preference is not None:
109
+ _telemetry_enabled = saved_preference
110
+ return saved_preference
111
+
112
+ # Default to disabled (opt-in model)
113
+ _telemetry_enabled = False
114
+ return False
115
+
116
+
117
+ def set_telemetry_enabled(enabled: bool, persist: bool = True) -> None:
118
+ """Set telemetry enabled state.
119
+
120
+ Args:
121
+ enabled: Whether telemetry should be enabled
122
+ persist: Whether to save this preference persistently
123
+ """
124
+ global _telemetry_enabled
125
+ _telemetry_enabled = enabled
126
+
127
+ if persist:
128
+ save_telemetry_preference(enabled)
129
+
130
+
131
+ def get_anonymous_id() -> str:
132
+ """Get or create a persistent anonymous ID for this machine.
133
+
134
+ The ID is stored in the user's home directory and is unique per installation.
135
+ """
136
+ global _anonymous_id
137
+
138
+ if _anonymous_id:
139
+ return _anonymous_id
140
+
141
+ # Try to load existing ID
142
+ id_file = Path.home() / ".golf" / "telemetry_id"
143
+
144
+ if id_file.exists():
145
+ try:
146
+ _anonymous_id = id_file.read_text().strip()
147
+ # Check if ID is in the old format (no hyphen between hash and
148
+ # random component)
149
+ # Old format: golf-[8 chars hash][8 chars random]
150
+ # New format: golf-[8 chars hash]-[8 chars random]
151
+ if _anonymous_id and _anonymous_id.startswith("golf-") and len(_anonymous_id) == 21:
152
+ # This is likely the old format, regenerate
153
+ _anonymous_id = None
154
+ elif _anonymous_id:
155
+ return _anonymous_id
156
+ except Exception:
157
+ pass
158
+
159
+ # Generate new ID with more unique data
160
+ # Use only non-identifying system information
161
+
162
+ # Combine non-identifying factors for uniqueness
163
+ machine_data = f"{platform.machine()}-{platform.system()}-{platform.python_version()}"
164
+ machine_hash = hashlib.sha256(machine_data.encode()).hexdigest()[:8]
165
+
166
+ # Add a random component to ensure uniqueness
167
+ random_component = str(uuid.uuid4()).split("-")[0] # First 8 chars of UUID
168
+
169
+ # Use hyphen separator for clarity and ensure PostHog treats these as different IDs
170
+ _anonymous_id = f"golf-{machine_hash}-{random_component}"
171
+
172
+ # Try to save for next time
173
+ try:
174
+ id_file.parent.mkdir(parents=True, exist_ok=True)
175
+ id_file.write_text(_anonymous_id)
176
+ except Exception:
177
+ # Not critical if we can't save
178
+ pass
179
+
180
+ return _anonymous_id
181
+
182
+
183
+ def initialize_telemetry() -> None:
184
+ """Initialize PostHog telemetry if enabled."""
185
+ # Ensure PostHog is disabled in test mode
186
+ _ensure_posthog_disabled_in_test_mode()
187
+
188
+ # Don't initialize if PostHog is disabled (test mode)
189
+ if posthog.disabled:
190
+ return
191
+
192
+ if not is_telemetry_enabled():
193
+ return
194
+
195
+ # Skip initialization if no valid API key (empty or placeholder)
196
+ if not POSTHOG_API_KEY or POSTHOG_API_KEY.startswith("phc_YOUR"):
197
+ return
198
+
199
+ try:
200
+ posthog.project_api_key = POSTHOG_API_KEY
201
+ posthog.host = POSTHOG_HOST
202
+
203
+ # Disable PostHog's own logging to avoid noise
204
+ posthog.disabled = False
205
+ posthog.debug = False
206
+
207
+ # Disable IP collection and GeoIP enrichment at the SDK level
208
+ posthog.set_global_event_properties(
209
+ {
210
+ "$ip": "0", # Override IP with dummy value to prevent collection
211
+ "$geoip_disable": True, # Disable all GeoIP enrichment
212
+ }
213
+ )
214
+
215
+ except Exception:
216
+ # Telemetry should never break the application
217
+ pass
218
+
219
+
220
+ def track_event(event_name: str, properties: dict[str, Any] | None = None) -> None:
221
+ """Track an anonymous event with NO IP address or geolocation data.
222
+
223
+ IP collection and GeoIP enrichment are disabled at the SDK level to ensure
224
+ complete privacy protection. No IP addresses or location data ever reach PostHog.
225
+
226
+ Args:
227
+ event_name: Name of the event (e.g., "cli_init", "cli_build")
228
+ properties: Optional properties to include with the event
229
+ """
230
+ global _user_identified
231
+
232
+ # Ensure PostHog is disabled in test mode
233
+ _ensure_posthog_disabled_in_test_mode()
234
+
235
+ # Early return if PostHog is disabled (test mode)
236
+ if posthog.disabled:
237
+ return
238
+
239
+ if not is_telemetry_enabled():
240
+ return
241
+
242
+ # Skip if no valid API key (empty or placeholder)
243
+ if not POSTHOG_API_KEY or POSTHOG_API_KEY.startswith("phc_YOUR"):
244
+ return
245
+
246
+ try:
247
+ # Initialize if needed
248
+ if posthog.project_api_key != POSTHOG_API_KEY:
249
+ initialize_telemetry()
250
+
251
+ # Get anonymous ID
252
+ anonymous_id = get_anonymous_id()
253
+
254
+ # Only identify the user once per session
255
+ if not _user_identified:
256
+ # Set person properties to differentiate installations
257
+ # Only include non-identifying information
258
+ person_properties = {
259
+ "$set": {
260
+ "golf_version": __version__,
261
+ "os": platform.system(),
262
+ "python_version": (f"{platform.python_version_tuple()[0]}.{platform.python_version_tuple()[1]}"),
263
+ }
264
+ }
265
+
266
+ # Identify the user with properties (IP tracking disabled)
267
+ posthog.identify(
268
+ distinct_id=anonymous_id,
269
+ properties={
270
+ **person_properties,
271
+ # Explicitly disable IP tracking in identify call
272
+ "$ip": "0",
273
+ "$geoip_disable": True,
274
+ },
275
+ )
276
+
277
+ _user_identified = True
278
+
279
+ # Only include minimal, non-identifying properties
280
+ safe_properties = {
281
+ "golf_version": __version__,
282
+ "python_version": (f"{platform.python_version_tuple()[0]}.{platform.python_version_tuple()[1]}"),
283
+ "os": platform.system(),
284
+ # Explicitly disable IP tracking and GeoIP enrichment
285
+ "$ip": "0", # Override IP to prevent collection
286
+ "$geoip_disable": True, # Disable GeoIP enrichment
287
+ }
288
+
289
+ # Filter properties to only include safe ones
290
+ if properties:
291
+ # Only include specific safe properties
292
+ safe_keys = {
293
+ "success",
294
+ "environment",
295
+ "template",
296
+ "command_type",
297
+ "error_type",
298
+ "error_message",
299
+ "shutdown_type",
300
+ "exit_code",
301
+ }
302
+ for key in safe_keys:
303
+ if key in properties:
304
+ safe_properties[key] = properties[key]
305
+
306
+ # Send event
307
+ posthog.capture(
308
+ distinct_id=anonymous_id,
309
+ event=event_name,
310
+ properties=safe_properties,
311
+ )
312
+
313
+ except Exception:
314
+ # Telemetry should never break the application
315
+ pass
316
+
317
+
318
+ def track_command(
319
+ command: str,
320
+ success: bool = True,
321
+ error_type: str | None = None,
322
+ error_message: str | None = None,
323
+ ) -> None:
324
+ """Track a CLI command execution with minimal info.
325
+
326
+ Args:
327
+ command: The command being executed (e.g., "init", "build", "run")
328
+ success: Whether the command was successful
329
+ error_type: Type of error if command failed (e.g., "ValueError",
330
+ "FileNotFoundError")
331
+ error_message: Sanitized error message (no sensitive data)
332
+ """
333
+ properties = {"success": success}
334
+
335
+ # Add error details if command failed
336
+ if not success and (error_type or error_message):
337
+ if error_type:
338
+ properties["error_type"] = error_type
339
+ if error_message:
340
+ # Sanitize error message - remove file paths and sensitive info
341
+ sanitized_message = _sanitize_error_message(error_message)
342
+ properties["error_message"] = sanitized_message
343
+
344
+ track_event(f"cli_{command}", properties)
345
+
346
+
347
+ def track_detailed_error(
348
+ event_name: str,
349
+ error: Exception,
350
+ context: str | None = None,
351
+ operation: str | None = None,
352
+ additional_props: dict[str, Any] | None = None,
353
+ ) -> None:
354
+ """Track a detailed error with enhanced debugging information.
355
+
356
+ Args:
357
+ event_name: Name of the error event (e.g., "cli_run_failed", "cli_build_failed")
358
+ error: The exception that occurred
359
+ context: Additional context about where the error occurred
360
+ operation: The specific operation that failed
361
+ additional_props: Additional properties to include
362
+ """
363
+ import traceback
364
+ import time
365
+
366
+ properties = {
367
+ "success": False,
368
+ "error_type": type(error).__name__,
369
+ "error_message": _sanitize_error_message(str(error)),
370
+ "timestamp": int(time.time()),
371
+ }
372
+
373
+ # Add operation context
374
+ if operation:
375
+ properties["operation"] = operation
376
+ if context:
377
+ properties["context"] = context
378
+
379
+ # Add sanitized stack trace for debugging
380
+ try:
381
+ tb_lines = traceback.format_exception(type(error), error, error.__traceback__)
382
+ # Get the last few frames (most relevant) and sanitize them
383
+ relevant_frames = tb_lines[-3:] if len(tb_lines) > 3 else tb_lines
384
+ sanitized_trace = []
385
+
386
+ for frame in relevant_frames:
387
+ # Sanitize file paths in stack trace
388
+ sanitized_frame = _sanitize_error_message(frame.strip())
389
+ # Further sanitize common traceback patterns
390
+ sanitized_frame = sanitized_frame.replace('File "[PATH]', 'File "[PATH]')
391
+ sanitized_trace.append(sanitized_frame)
392
+
393
+ properties["stack_trace"] = " | ".join(sanitized_trace)
394
+
395
+ # Add the specific line that caused the error if available
396
+ if hasattr(error, "__traceback__") and error.__traceback__:
397
+ tb = error.__traceback__
398
+ while tb.tb_next:
399
+ tb = tb.tb_next
400
+ properties["error_line"] = tb.tb_lineno
401
+
402
+ except Exception:
403
+ # Don't fail if we can't capture stack trace
404
+ pass
405
+
406
+ # Add system context for debugging
407
+ try:
408
+ properties["python_executable"] = _sanitize_error_message(platform.python_implementation())
409
+ properties["platform_detail"] = platform.platform()[:50] # Limit length
410
+ except Exception:
411
+ pass
412
+
413
+ # Merge additional properties
414
+ if additional_props:
415
+ # Only include safe additional properties
416
+ safe_additional_keys = {
417
+ "exit_code",
418
+ "shutdown_type",
419
+ "environment",
420
+ "template",
421
+ "build_env",
422
+ "transport",
423
+ "component_count",
424
+ "file_path",
425
+ "component_type",
426
+ "validation_error",
427
+ "config_error",
428
+ }
429
+ for key, value in additional_props.items():
430
+ if key in safe_additional_keys:
431
+ properties[key] = value
432
+
433
+ track_event(event_name, properties)
434
+
435
+
436
+ def _sanitize_error_message(message: str) -> str:
437
+ """Sanitize error messages to remove sensitive information."""
438
+ import re
439
+
440
+ # Remove file paths but preserve filenames
441
+ # Match paths with directories and capture the filename
442
+ # Unix style: /path/to/file.py -> file.py
443
+ message = re.sub(r"(/[^/\s]+)+/([^/\s]+)", r"\2", message)
444
+ # Windows style: C:\path\to\file.py -> file.py
445
+ message = re.sub(r"([A-Za-z]:\\[^\\]+\\)+([^\\]+)", r"\2", message)
446
+ # Remaining absolute paths without filename
447
+ message = re.sub(r"[/\\][^\s]*[/\\]", "[PATH]/", message)
448
+
449
+ # Remove potential API keys or tokens (common patterns)
450
+ # Generic API keys (20+ alphanumeric with underscores/hyphens)
451
+ message = re.sub(r"\b[a-zA-Z0-9_-]{32,}\b", "[REDACTED]", message)
452
+ # Bearer tokens
453
+ message = re.sub(r"Bearer\s+[a-zA-Z0-9_.-]+", "Bearer [REDACTED]", message)
454
+
455
+ # Remove email addresses
456
+ message = re.sub(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "[EMAIL]", message)
457
+
458
+ # Remove IP addresses
459
+ message = re.sub(r"\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b", "[IP]", message)
460
+
461
+ # Remove port numbers in URLs
462
+ message = re.sub(r":[0-9]{2,5}(?=/|$|\s)", ":[PORT]", message)
463
+
464
+ # Truncate to reasonable length
465
+ if len(message) > 200:
466
+ message = message[:197] + "..."
467
+
468
+ return message
469
+
470
+
471
+ def flush() -> None:
472
+ """Flush any pending telemetry events."""
473
+ if not is_telemetry_enabled():
474
+ return
475
+
476
+ try:
477
+ posthog.flush()
478
+ except Exception:
479
+ # Ignore flush errors
480
+ pass
481
+
482
+
483
+ def shutdown() -> None:
484
+ """Shutdown telemetry and flush pending events."""
485
+ if not is_telemetry_enabled():
486
+ return
487
+
488
+ try:
489
+ posthog.shutdown()
490
+ except Exception:
491
+ # Ignore shutdown errors
492
+ pass