golf-mcp 0.1.11__py3-none-any.whl → 0.1.13__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.

Potentially problematic release.


This version of golf-mcp might be problematic. Click here for more details.

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