tweek 0.1.0__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 (85) hide show
  1. tweek/__init__.py +16 -0
  2. tweek/cli.py +3390 -0
  3. tweek/cli_helpers.py +193 -0
  4. tweek/config/__init__.py +13 -0
  5. tweek/config/allowed_dirs.yaml +23 -0
  6. tweek/config/manager.py +1064 -0
  7. tweek/config/patterns.yaml +751 -0
  8. tweek/config/tiers.yaml +129 -0
  9. tweek/diagnostics.py +589 -0
  10. tweek/hooks/__init__.py +1 -0
  11. tweek/hooks/pre_tool_use.py +861 -0
  12. tweek/integrations/__init__.py +3 -0
  13. tweek/integrations/moltbot.py +243 -0
  14. tweek/licensing.py +398 -0
  15. tweek/logging/__init__.py +9 -0
  16. tweek/logging/bundle.py +350 -0
  17. tweek/logging/json_logger.py +150 -0
  18. tweek/logging/security_log.py +745 -0
  19. tweek/mcp/__init__.py +24 -0
  20. tweek/mcp/approval.py +456 -0
  21. tweek/mcp/approval_cli.py +356 -0
  22. tweek/mcp/clients/__init__.py +37 -0
  23. tweek/mcp/clients/chatgpt.py +112 -0
  24. tweek/mcp/clients/claude_desktop.py +203 -0
  25. tweek/mcp/clients/gemini.py +178 -0
  26. tweek/mcp/proxy.py +667 -0
  27. tweek/mcp/screening.py +175 -0
  28. tweek/mcp/server.py +317 -0
  29. tweek/platform/__init__.py +131 -0
  30. tweek/plugins/__init__.py +835 -0
  31. tweek/plugins/base.py +1080 -0
  32. tweek/plugins/compliance/__init__.py +30 -0
  33. tweek/plugins/compliance/gdpr.py +333 -0
  34. tweek/plugins/compliance/gov.py +324 -0
  35. tweek/plugins/compliance/hipaa.py +285 -0
  36. tweek/plugins/compliance/legal.py +322 -0
  37. tweek/plugins/compliance/pci.py +361 -0
  38. tweek/plugins/compliance/soc2.py +275 -0
  39. tweek/plugins/detectors/__init__.py +30 -0
  40. tweek/plugins/detectors/continue_dev.py +206 -0
  41. tweek/plugins/detectors/copilot.py +254 -0
  42. tweek/plugins/detectors/cursor.py +192 -0
  43. tweek/plugins/detectors/moltbot.py +205 -0
  44. tweek/plugins/detectors/windsurf.py +214 -0
  45. tweek/plugins/git_discovery.py +395 -0
  46. tweek/plugins/git_installer.py +491 -0
  47. tweek/plugins/git_lockfile.py +338 -0
  48. tweek/plugins/git_registry.py +503 -0
  49. tweek/plugins/git_security.py +482 -0
  50. tweek/plugins/providers/__init__.py +30 -0
  51. tweek/plugins/providers/anthropic.py +181 -0
  52. tweek/plugins/providers/azure_openai.py +289 -0
  53. tweek/plugins/providers/bedrock.py +248 -0
  54. tweek/plugins/providers/google.py +197 -0
  55. tweek/plugins/providers/openai.py +230 -0
  56. tweek/plugins/scope.py +130 -0
  57. tweek/plugins/screening/__init__.py +26 -0
  58. tweek/plugins/screening/llm_reviewer.py +149 -0
  59. tweek/plugins/screening/pattern_matcher.py +273 -0
  60. tweek/plugins/screening/rate_limiter.py +174 -0
  61. tweek/plugins/screening/session_analyzer.py +159 -0
  62. tweek/proxy/__init__.py +302 -0
  63. tweek/proxy/addon.py +223 -0
  64. tweek/proxy/interceptor.py +313 -0
  65. tweek/proxy/server.py +315 -0
  66. tweek/sandbox/__init__.py +71 -0
  67. tweek/sandbox/executor.py +382 -0
  68. tweek/sandbox/linux.py +278 -0
  69. tweek/sandbox/profile_generator.py +323 -0
  70. tweek/screening/__init__.py +13 -0
  71. tweek/screening/context.py +81 -0
  72. tweek/security/__init__.py +22 -0
  73. tweek/security/llm_reviewer.py +348 -0
  74. tweek/security/rate_limiter.py +682 -0
  75. tweek/security/secret_scanner.py +506 -0
  76. tweek/security/session_analyzer.py +600 -0
  77. tweek/vault/__init__.py +40 -0
  78. tweek/vault/cross_platform.py +251 -0
  79. tweek/vault/keychain.py +288 -0
  80. tweek-0.1.0.dist-info/METADATA +335 -0
  81. tweek-0.1.0.dist-info/RECORD +85 -0
  82. tweek-0.1.0.dist-info/WHEEL +5 -0
  83. tweek-0.1.0.dist-info/entry_points.txt +25 -0
  84. tweek-0.1.0.dist-info/licenses/LICENSE +190 -0
  85. tweek-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Tweek Plugin Registry Client
4
+
5
+ Manages the curated plugin registry:
6
+ - Fetches registry from remote URL
7
+ - Caches locally with configurable TTL
8
+ - Verifies registry HMAC signature
9
+ - Searches available plugins
10
+ - Falls back to local cache when offline
11
+
12
+ Registry Format:
13
+ {
14
+ "schema_version": 1,
15
+ "updated_at": "2026-01-29T00:00:00Z",
16
+ "registry_signature": "<hmac>",
17
+ "plugins": [
18
+ {
19
+ "name": "tweek-plugin-cursor-detector",
20
+ "category": "detectors",
21
+ "repo_url": "https://github.com/gettweek/tweek-plugin-cursor-detector.git",
22
+ "latest_version": "1.2.0",
23
+ "requires_license_tier": "free",
24
+ "verified": true,
25
+ "deprecated": false,
26
+ "versions": {
27
+ "1.2.0": {"git_ref": "v1.2.0", "checksums": {"plugin.py": "sha256:..."}}
28
+ }
29
+ }
30
+ ]
31
+ }
32
+ """
33
+
34
+ import hashlib
35
+ import hmac
36
+ import json
37
+ import logging
38
+ import os
39
+ import time
40
+ from datetime import datetime, timezone
41
+ from pathlib import Path
42
+ from typing import Any, Dict, List, Optional, Tuple
43
+ from urllib.error import URLError
44
+ from urllib.request import Request, urlopen
45
+
46
+ from tweek.plugins.git_security import TWEEK_SIGNING_KEY
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+ # Default registry URL
51
+ DEFAULT_REGISTRY_URL = "https://registry.gettweek.com/v1/plugins.json"
52
+
53
+ # Default cache TTL: 1 hour
54
+ DEFAULT_CACHE_TTL_SECONDS = 3600
55
+
56
+ # Current schema version this client understands
57
+ SUPPORTED_SCHEMA_VERSIONS = {1}
58
+
59
+ # Tweek plugins directory
60
+ TWEEK_HOME = Path.home() / ".tweek"
61
+ PLUGINS_DIR = TWEEK_HOME / "plugins"
62
+ REGISTRY_CACHE_PATH = TWEEK_HOME / "registry.json"
63
+ REGISTRY_META_PATH = TWEEK_HOME / "registry_meta.json"
64
+
65
+
66
+ class RegistryError(Exception):
67
+ """Raised when registry operations fail."""
68
+ pass
69
+
70
+
71
+ class RegistryEntry:
72
+ """Represents a single plugin entry in the registry."""
73
+
74
+ def __init__(self, data: dict):
75
+ self._data = data
76
+
77
+ @property
78
+ def name(self) -> str:
79
+ return self._data.get("name", "")
80
+
81
+ @property
82
+ def category(self) -> str:
83
+ return self._data.get("category", "")
84
+
85
+ @property
86
+ def repo_url(self) -> str:
87
+ return self._data.get("repo_url", "")
88
+
89
+ @property
90
+ def latest_version(self) -> str:
91
+ return self._data.get("latest_version", "")
92
+
93
+ @property
94
+ def requires_license_tier(self) -> str:
95
+ return self._data.get("requires_license_tier", "free")
96
+
97
+ @property
98
+ def verified(self) -> bool:
99
+ return self._data.get("verified", False)
100
+
101
+ @property
102
+ def deprecated(self) -> bool:
103
+ return self._data.get("deprecated", False)
104
+
105
+ @property
106
+ def description(self) -> str:
107
+ return self._data.get("description", "")
108
+
109
+ @property
110
+ def author(self) -> str:
111
+ return self._data.get("author", "Tweek")
112
+
113
+ @property
114
+ def tags(self) -> List[str]:
115
+ return self._data.get("tags", [])
116
+
117
+ @property
118
+ def versions(self) -> Dict[str, dict]:
119
+ return self._data.get("versions", {})
120
+
121
+ def get_version_info(self, version: str) -> Optional[dict]:
122
+ """Get info for a specific version."""
123
+ return self.versions.get(version)
124
+
125
+ def get_git_ref(self, version: Optional[str] = None) -> str:
126
+ """Get the git ref (tag/branch) for a version."""
127
+ ver = version or self.latest_version
128
+ version_info = self.versions.get(ver, {})
129
+ return version_info.get("git_ref", f"v{ver}")
130
+
131
+ def get_checksums(self, version: Optional[str] = None) -> Dict[str, str]:
132
+ """Get checksums for a specific version."""
133
+ ver = version or self.latest_version
134
+ version_info = self.versions.get(ver, {})
135
+ return version_info.get("checksums", {})
136
+
137
+ def to_dict(self) -> dict:
138
+ return dict(self._data)
139
+
140
+ def __repr__(self) -> str:
141
+ return f"RegistryEntry(name={self.name!r}, version={self.latest_version!r})"
142
+
143
+
144
+ class PluginRegistryClient:
145
+ """
146
+ Client for the Tweek plugin registry.
147
+
148
+ Fetches, caches, and searches the curated plugin registry.
149
+ Verifies registry signature before trusting data.
150
+ Falls back to local cache when network is unavailable.
151
+ """
152
+
153
+ def __init__(
154
+ self,
155
+ registry_url: Optional[str] = None,
156
+ cache_ttl: int = DEFAULT_CACHE_TTL_SECONDS,
157
+ cache_path: Optional[Path] = None,
158
+ signing_key: Optional[str] = None,
159
+ ):
160
+ self._registry_url = registry_url or os.environ.get(
161
+ "TWEEK_REGISTRY_URL", DEFAULT_REGISTRY_URL
162
+ )
163
+ self._cache_ttl = cache_ttl
164
+ self._cache_path = cache_path or REGISTRY_CACHE_PATH
165
+ self._meta_path = self._cache_path.parent / "registry_meta.json"
166
+ self._signing_key = signing_key or TWEEK_SIGNING_KEY
167
+ self._registry_data: Optional[dict] = None
168
+ self._entries: Optional[Dict[str, RegistryEntry]] = None
169
+ self._last_fetch_time: Optional[float] = None
170
+
171
+ @property
172
+ def registry_url(self) -> str:
173
+ return self._registry_url
174
+
175
+ def fetch(self, force_refresh: bool = False) -> Dict[str, RegistryEntry]:
176
+ """
177
+ Fetch the plugin registry.
178
+
179
+ Uses cached data if available and not expired, unless force_refresh=True.
180
+ Falls back to local cache if network is unavailable.
181
+
182
+ Args:
183
+ force_refresh: If True, bypass cache TTL and fetch from network
184
+
185
+ Returns:
186
+ Dict mapping plugin name to RegistryEntry
187
+
188
+ Raises:
189
+ RegistryError: If no registry data is available (no network + no cache)
190
+ """
191
+ # Check if cached data is still valid
192
+ if not force_refresh and self._is_cache_valid():
193
+ if self._entries is not None:
194
+ return self._entries
195
+
196
+ # Try loading from disk cache
197
+ cached = self._load_cache()
198
+ if cached is not None:
199
+ self._registry_data = cached
200
+ self._entries = self._parse_entries(cached)
201
+ return self._entries
202
+
203
+ # Try fetching from network
204
+ try:
205
+ raw_data = self._fetch_remote()
206
+ registry = json.loads(raw_data)
207
+
208
+ # Verify schema version
209
+ schema_version = registry.get("schema_version", 0)
210
+ if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
211
+ raise RegistryError(
212
+ f"Unsupported registry schema version {schema_version}. "
213
+ f"Supported: {SUPPORTED_SCHEMA_VERSIONS}. "
214
+ f"Please update Tweek."
215
+ )
216
+
217
+ # Verify signature
218
+ if not self._verify_registry_signature(registry):
219
+ raise RegistryError("Registry signature verification failed")
220
+
221
+ # Cache to disk
222
+ self._save_cache(raw_data)
223
+
224
+ self._registry_data = registry
225
+ self._entries = self._parse_entries(registry)
226
+ self._last_fetch_time = time.time()
227
+
228
+ logger.info(f"Fetched registry: {len(self._entries)} plugins available")
229
+ return self._entries
230
+
231
+ except (URLError, OSError, json.JSONDecodeError) as e:
232
+ logger.warning(f"Failed to fetch registry from network: {e}")
233
+
234
+ # Fall back to local cache
235
+ cached = self._load_cache()
236
+ if cached is not None:
237
+ logger.info("Using cached registry (network unavailable)")
238
+ self._registry_data = cached
239
+ self._entries = self._parse_entries(cached)
240
+ return self._entries
241
+
242
+ raise RegistryError(
243
+ "No registry data available. Network is unavailable and no local cache exists. "
244
+ "Connect to the internet and run 'tweek plugins registry --refresh'."
245
+ ) from e
246
+
247
+ def search(
248
+ self,
249
+ query: Optional[str] = None,
250
+ category: Optional[str] = None,
251
+ tier: Optional[str] = None,
252
+ include_deprecated: bool = False,
253
+ ) -> List[RegistryEntry]:
254
+ """
255
+ Search the plugin registry.
256
+
257
+ Args:
258
+ query: Text search (matches name, description, tags)
259
+ category: Filter by category
260
+ tier: Filter by license tier
261
+ include_deprecated: Include deprecated plugins
262
+
263
+ Returns:
264
+ List of matching RegistryEntry objects
265
+ """
266
+ entries = self.fetch()
267
+ results = []
268
+
269
+ for entry in entries.values():
270
+ # Skip unverified plugins
271
+ if not entry.verified:
272
+ continue
273
+
274
+ # Skip deprecated unless requested
275
+ if entry.deprecated and not include_deprecated:
276
+ continue
277
+
278
+ # Filter by category
279
+ if category and entry.category != category:
280
+ continue
281
+
282
+ # Filter by tier
283
+ if tier and entry.requires_license_tier != tier:
284
+ continue
285
+
286
+ # Text search
287
+ if query:
288
+ query_lower = query.lower()
289
+ searchable = (
290
+ entry.name.lower() + " " +
291
+ entry.description.lower() + " " +
292
+ " ".join(t.lower() for t in entry.tags)
293
+ )
294
+ if query_lower not in searchable:
295
+ continue
296
+
297
+ results.append(entry)
298
+
299
+ return results
300
+
301
+ def get_plugin(self, name: str) -> Optional[RegistryEntry]:
302
+ """
303
+ Get a specific plugin entry by name.
304
+
305
+ Args:
306
+ name: Plugin name (e.g., "tweek-plugin-cursor-detector")
307
+
308
+ Returns:
309
+ RegistryEntry if found, None otherwise
310
+ """
311
+ entries = self.fetch()
312
+ entry = entries.get(name)
313
+
314
+ if entry and not entry.verified:
315
+ logger.warning(f"Plugin {name} exists but is not verified - refusing to load")
316
+ return None
317
+
318
+ return entry
319
+
320
+ def is_plugin_available(self, name: str) -> bool:
321
+ """Check if a plugin is available in the registry."""
322
+ entry = self.get_plugin(name)
323
+ return entry is not None and entry.verified and not entry.deprecated
324
+
325
+ def get_update_available(
326
+ self,
327
+ name: str,
328
+ current_version: str,
329
+ ) -> Optional[str]:
330
+ """
331
+ Check if an update is available for a plugin.
332
+
333
+ Args:
334
+ name: Plugin name
335
+ current_version: Currently installed version
336
+
337
+ Returns:
338
+ New version string if update available, None otherwise
339
+ """
340
+ entry = self.get_plugin(name)
341
+ if entry is None:
342
+ return None
343
+
344
+ latest = entry.latest_version
345
+ if latest and latest != current_version:
346
+ # Simple version comparison
347
+ if self._version_gt(latest, current_version):
348
+ return latest
349
+
350
+ return None
351
+
352
+ def _fetch_remote(self) -> bytes:
353
+ """Fetch registry from remote URL."""
354
+ req = Request(
355
+ self._registry_url,
356
+ headers={
357
+ "User-Agent": "Tweek-Plugin-Client/1.0",
358
+ "Accept": "application/json",
359
+ },
360
+ )
361
+ with urlopen(req, timeout=15) as response:
362
+ return response.read()
363
+
364
+ def _verify_registry_signature(self, registry: dict) -> bool:
365
+ """Verify the HMAC signature of the registry."""
366
+ signature = registry.get("registry_signature", "")
367
+ if not signature:
368
+ logger.warning("Registry has no signature - skipping verification")
369
+ return True # Allow unsigned registries in development
370
+
371
+ # Sign the plugins array (the payload)
372
+ plugins_json = json.dumps(
373
+ registry.get("plugins", []),
374
+ sort_keys=True,
375
+ separators=(",", ":"),
376
+ ).encode()
377
+
378
+ key = self._signing_key.encode()
379
+ expected_sig = hmac.new(key, plugins_json, hashlib.sha256).hexdigest()
380
+ return hmac.compare_digest(expected_sig, signature)
381
+
382
+ def _is_cache_valid(self) -> bool:
383
+ """Check if in-memory or disk cache is still within TTL."""
384
+ # Check in-memory cache
385
+ if self._last_fetch_time is not None:
386
+ elapsed = time.time() - self._last_fetch_time
387
+ return elapsed < self._cache_ttl
388
+
389
+ # Check disk cache metadata
390
+ if self._meta_path.exists():
391
+ try:
392
+ with open(self._meta_path) as f:
393
+ meta = json.load(f)
394
+ fetched_at = meta.get("fetched_at", 0)
395
+ elapsed = time.time() - fetched_at
396
+ return elapsed < self._cache_ttl
397
+ except (json.JSONDecodeError, IOError):
398
+ pass
399
+
400
+ return False
401
+
402
+ def _load_cache(self) -> Optional[dict]:
403
+ """Load registry from disk cache."""
404
+ if not self._cache_path.exists():
405
+ return None
406
+
407
+ try:
408
+ with open(self._cache_path) as f:
409
+ data = json.load(f)
410
+
411
+ # Verify cached registry signature
412
+ if not self._verify_registry_signature(data):
413
+ logger.warning("Cached registry signature invalid - ignoring cache")
414
+ return None
415
+
416
+ return data
417
+ except (json.JSONDecodeError, IOError) as e:
418
+ logger.warning(f"Failed to load registry cache: {e}")
419
+ return None
420
+
421
+ def _save_cache(self, raw_data: bytes) -> None:
422
+ """Save registry data to disk cache."""
423
+ try:
424
+ # Ensure directory exists
425
+ self._cache_path.parent.mkdir(parents=True, exist_ok=True)
426
+
427
+ # Write registry data
428
+ with open(self._cache_path, "wb") as f:
429
+ f.write(raw_data)
430
+
431
+ # Write metadata
432
+ meta = {
433
+ "fetched_at": time.time(),
434
+ "fetched_from": self._registry_url,
435
+ "fetched_at_iso": datetime.now(timezone.utc).isoformat(),
436
+ }
437
+ with open(self._meta_path, "w") as f:
438
+ json.dump(meta, f, indent=2)
439
+
440
+ except IOError as e:
441
+ logger.warning(f"Failed to save registry cache: {e}")
442
+
443
+ def _parse_entries(self, registry: dict) -> Dict[str, RegistryEntry]:
444
+ """Parse registry data into RegistryEntry objects."""
445
+ entries = {}
446
+ for plugin_data in registry.get("plugins", []):
447
+ name = plugin_data.get("name", "")
448
+ if name:
449
+ entries[name] = RegistryEntry(plugin_data)
450
+ return entries
451
+
452
+ @staticmethod
453
+ def _version_gt(v1: str, v2: str) -> bool:
454
+ """Check if version v1 is greater than v2 (simple semver comparison)."""
455
+ try:
456
+ parts1 = [int(p) for p in v1.split(".")]
457
+ parts2 = [int(p) for p in v2.split(".")]
458
+ # Pad shorter version with zeros
459
+ while len(parts1) < 3:
460
+ parts1.append(0)
461
+ while len(parts2) < 3:
462
+ parts2.append(0)
463
+ return tuple(parts1) > tuple(parts2)
464
+ except (ValueError, AttributeError):
465
+ return False
466
+
467
+ def clear_cache(self) -> None:
468
+ """Clear both in-memory and disk caches."""
469
+ self._registry_data = None
470
+ self._entries = None
471
+ self._last_fetch_time = None
472
+
473
+ for path in (self._cache_path, self._meta_path):
474
+ if path.exists():
475
+ try:
476
+ path.unlink()
477
+ except IOError:
478
+ pass
479
+
480
+ def get_registry_info(self) -> Dict[str, Any]:
481
+ """Get metadata about the registry state."""
482
+ info = {
483
+ "url": self._registry_url,
484
+ "cache_path": str(self._cache_path),
485
+ "cache_ttl_seconds": self._cache_ttl,
486
+ "cache_valid": self._is_cache_valid(),
487
+ "last_fetch_time": self._last_fetch_time,
488
+ }
489
+
490
+ if self._registry_data:
491
+ info["schema_version"] = self._registry_data.get("schema_version")
492
+ info["updated_at"] = self._registry_data.get("updated_at")
493
+ info["total_plugins"] = len(self._registry_data.get("plugins", []))
494
+
495
+ if self._meta_path.exists():
496
+ try:
497
+ with open(self._meta_path) as f:
498
+ meta = json.load(f)
499
+ info["cache_fetched_at"] = meta.get("fetched_at_iso")
500
+ except (json.JSONDecodeError, IOError):
501
+ pass
502
+
503
+ return info