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.
- tweek/__init__.py +16 -0
- tweek/cli.py +3390 -0
- tweek/cli_helpers.py +193 -0
- tweek/config/__init__.py +13 -0
- tweek/config/allowed_dirs.yaml +23 -0
- tweek/config/manager.py +1064 -0
- tweek/config/patterns.yaml +751 -0
- tweek/config/tiers.yaml +129 -0
- tweek/diagnostics.py +589 -0
- tweek/hooks/__init__.py +1 -0
- tweek/hooks/pre_tool_use.py +861 -0
- tweek/integrations/__init__.py +3 -0
- tweek/integrations/moltbot.py +243 -0
- tweek/licensing.py +398 -0
- tweek/logging/__init__.py +9 -0
- tweek/logging/bundle.py +350 -0
- tweek/logging/json_logger.py +150 -0
- tweek/logging/security_log.py +745 -0
- tweek/mcp/__init__.py +24 -0
- tweek/mcp/approval.py +456 -0
- tweek/mcp/approval_cli.py +356 -0
- tweek/mcp/clients/__init__.py +37 -0
- tweek/mcp/clients/chatgpt.py +112 -0
- tweek/mcp/clients/claude_desktop.py +203 -0
- tweek/mcp/clients/gemini.py +178 -0
- tweek/mcp/proxy.py +667 -0
- tweek/mcp/screening.py +175 -0
- tweek/mcp/server.py +317 -0
- tweek/platform/__init__.py +131 -0
- tweek/plugins/__init__.py +835 -0
- tweek/plugins/base.py +1080 -0
- tweek/plugins/compliance/__init__.py +30 -0
- tweek/plugins/compliance/gdpr.py +333 -0
- tweek/plugins/compliance/gov.py +324 -0
- tweek/plugins/compliance/hipaa.py +285 -0
- tweek/plugins/compliance/legal.py +322 -0
- tweek/plugins/compliance/pci.py +361 -0
- tweek/plugins/compliance/soc2.py +275 -0
- tweek/plugins/detectors/__init__.py +30 -0
- tweek/plugins/detectors/continue_dev.py +206 -0
- tweek/plugins/detectors/copilot.py +254 -0
- tweek/plugins/detectors/cursor.py +192 -0
- tweek/plugins/detectors/moltbot.py +205 -0
- tweek/plugins/detectors/windsurf.py +214 -0
- tweek/plugins/git_discovery.py +395 -0
- tweek/plugins/git_installer.py +491 -0
- tweek/plugins/git_lockfile.py +338 -0
- tweek/plugins/git_registry.py +503 -0
- tweek/plugins/git_security.py +482 -0
- tweek/plugins/providers/__init__.py +30 -0
- tweek/plugins/providers/anthropic.py +181 -0
- tweek/plugins/providers/azure_openai.py +289 -0
- tweek/plugins/providers/bedrock.py +248 -0
- tweek/plugins/providers/google.py +197 -0
- tweek/plugins/providers/openai.py +230 -0
- tweek/plugins/scope.py +130 -0
- tweek/plugins/screening/__init__.py +26 -0
- tweek/plugins/screening/llm_reviewer.py +149 -0
- tweek/plugins/screening/pattern_matcher.py +273 -0
- tweek/plugins/screening/rate_limiter.py +174 -0
- tweek/plugins/screening/session_analyzer.py +159 -0
- tweek/proxy/__init__.py +302 -0
- tweek/proxy/addon.py +223 -0
- tweek/proxy/interceptor.py +313 -0
- tweek/proxy/server.py +315 -0
- tweek/sandbox/__init__.py +71 -0
- tweek/sandbox/executor.py +382 -0
- tweek/sandbox/linux.py +278 -0
- tweek/sandbox/profile_generator.py +323 -0
- tweek/screening/__init__.py +13 -0
- tweek/screening/context.py +81 -0
- tweek/security/__init__.py +22 -0
- tweek/security/llm_reviewer.py +348 -0
- tweek/security/rate_limiter.py +682 -0
- tweek/security/secret_scanner.py +506 -0
- tweek/security/session_analyzer.py +600 -0
- tweek/vault/__init__.py +40 -0
- tweek/vault/cross_platform.py +251 -0
- tweek/vault/keychain.py +288 -0
- tweek-0.1.0.dist-info/METADATA +335 -0
- tweek-0.1.0.dist-info/RECORD +85 -0
- tweek-0.1.0.dist-info/WHEEL +5 -0
- tweek-0.1.0.dist-info/entry_points.txt +25 -0
- tweek-0.1.0.dist-info/licenses/LICENSE +190 -0
- 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
|