scc-cli 1.4.1__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 scc-cli might be problematic. Click here for more details.
- scc_cli/__init__.py +15 -0
- scc_cli/audit/__init__.py +37 -0
- scc_cli/audit/parser.py +191 -0
- scc_cli/audit/reader.py +180 -0
- scc_cli/auth.py +145 -0
- scc_cli/claude_adapter.py +485 -0
- scc_cli/cli.py +259 -0
- scc_cli/cli_admin.py +706 -0
- scc_cli/cli_audit.py +245 -0
- scc_cli/cli_common.py +166 -0
- scc_cli/cli_config.py +527 -0
- scc_cli/cli_exceptions.py +705 -0
- scc_cli/cli_helpers.py +244 -0
- scc_cli/cli_init.py +272 -0
- scc_cli/cli_launch.py +1454 -0
- scc_cli/cli_org.py +1428 -0
- scc_cli/cli_support.py +322 -0
- scc_cli/cli_team.py +892 -0
- scc_cli/cli_worktree.py +865 -0
- scc_cli/config.py +583 -0
- scc_cli/console.py +562 -0
- scc_cli/constants.py +79 -0
- scc_cli/contexts.py +377 -0
- scc_cli/deprecation.py +54 -0
- scc_cli/deps.py +189 -0
- scc_cli/docker/__init__.py +127 -0
- scc_cli/docker/core.py +466 -0
- scc_cli/docker/credentials.py +726 -0
- scc_cli/docker/launch.py +604 -0
- scc_cli/doctor/__init__.py +99 -0
- scc_cli/doctor/checks.py +1074 -0
- scc_cli/doctor/render.py +346 -0
- scc_cli/doctor/types.py +66 -0
- scc_cli/errors.py +288 -0
- scc_cli/evaluation/__init__.py +27 -0
- scc_cli/evaluation/apply_exceptions.py +207 -0
- scc_cli/evaluation/evaluate.py +97 -0
- scc_cli/evaluation/models.py +80 -0
- scc_cli/exit_codes.py +55 -0
- scc_cli/git.py +1521 -0
- scc_cli/json_command.py +166 -0
- scc_cli/json_output.py +96 -0
- scc_cli/kinds.py +62 -0
- scc_cli/marketplace/__init__.py +123 -0
- scc_cli/marketplace/adapter.py +74 -0
- scc_cli/marketplace/compute.py +377 -0
- scc_cli/marketplace/constants.py +87 -0
- scc_cli/marketplace/managed.py +135 -0
- scc_cli/marketplace/materialize.py +723 -0
- scc_cli/marketplace/normalize.py +548 -0
- scc_cli/marketplace/render.py +257 -0
- scc_cli/marketplace/resolve.py +459 -0
- scc_cli/marketplace/schema.py +506 -0
- scc_cli/marketplace/sync.py +260 -0
- scc_cli/marketplace/team_cache.py +195 -0
- scc_cli/marketplace/team_fetch.py +688 -0
- scc_cli/marketplace/trust.py +244 -0
- scc_cli/models/__init__.py +41 -0
- scc_cli/models/exceptions.py +273 -0
- scc_cli/models/plugin_audit.py +434 -0
- scc_cli/org_templates.py +269 -0
- scc_cli/output_mode.py +167 -0
- scc_cli/panels.py +113 -0
- scc_cli/platform.py +350 -0
- scc_cli/profiles.py +960 -0
- scc_cli/remote.py +443 -0
- scc_cli/schemas/__init__.py +1 -0
- scc_cli/schemas/org-v1.schema.json +456 -0
- scc_cli/schemas/team-config.v1.schema.json +163 -0
- scc_cli/sessions.py +425 -0
- scc_cli/setup.py +588 -0
- scc_cli/source_resolver.py +470 -0
- scc_cli/stats.py +378 -0
- scc_cli/stores/__init__.py +13 -0
- scc_cli/stores/exception_store.py +251 -0
- scc_cli/subprocess_utils.py +88 -0
- scc_cli/teams.py +382 -0
- scc_cli/templates/__init__.py +2 -0
- scc_cli/templates/org/__init__.py +0 -0
- scc_cli/templates/org/minimal.json +19 -0
- scc_cli/templates/org/reference.json +74 -0
- scc_cli/templates/org/strict.json +38 -0
- scc_cli/templates/org/teams.json +42 -0
- scc_cli/templates/statusline.sh +75 -0
- scc_cli/theme.py +348 -0
- scc_cli/ui/__init__.py +124 -0
- scc_cli/ui/branding.py +68 -0
- scc_cli/ui/chrome.py +395 -0
- scc_cli/ui/dashboard/__init__.py +62 -0
- scc_cli/ui/dashboard/_dashboard.py +677 -0
- scc_cli/ui/dashboard/loaders.py +395 -0
- scc_cli/ui/dashboard/models.py +184 -0
- scc_cli/ui/dashboard/orchestrator.py +390 -0
- scc_cli/ui/formatters.py +443 -0
- scc_cli/ui/gate.py +350 -0
- scc_cli/ui/help.py +157 -0
- scc_cli/ui/keys.py +538 -0
- scc_cli/ui/list_screen.py +431 -0
- scc_cli/ui/picker.py +700 -0
- scc_cli/ui/prompts.py +200 -0
- scc_cli/ui/wizard.py +675 -0
- scc_cli/update.py +680 -0
- scc_cli/utils/__init__.py +39 -0
- scc_cli/utils/fixit.py +264 -0
- scc_cli/utils/fuzzy.py +124 -0
- scc_cli/utils/locks.py +101 -0
- scc_cli/utils/ttl.py +376 -0
- scc_cli/validate.py +455 -0
- scc_cli-1.4.1.dist-info/METADATA +369 -0
- scc_cli-1.4.1.dist-info/RECORD +113 -0
- scc_cli-1.4.1.dist-info/WHEEL +4 -0
- scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
- scc_cli-1.4.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Marketplace sync orchestration for Claude Code integration.
|
|
3
|
+
|
|
4
|
+
This module provides the high-level sync_marketplace_settings() function that
|
|
5
|
+
orchestrates the full pipeline:
|
|
6
|
+
1. Parse org config
|
|
7
|
+
2. Compute effective plugins for team
|
|
8
|
+
3. Materialize required marketplaces
|
|
9
|
+
4. Render settings to Claude format
|
|
10
|
+
5. Merge with existing user settings (non-destructive)
|
|
11
|
+
6. Save managed state tracking
|
|
12
|
+
7. Write settings.local.json
|
|
13
|
+
|
|
14
|
+
This is the main entry point for integrating marketplace functionality
|
|
15
|
+
into the start command.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from scc_cli.marketplace.adapter import translate_org_config
|
|
26
|
+
from scc_cli.marketplace.managed import ManagedState, save_managed_state
|
|
27
|
+
from scc_cli.marketplace.materialize import MaterializationError, materialize_marketplace
|
|
28
|
+
from scc_cli.marketplace.normalize import matches_pattern
|
|
29
|
+
from scc_cli.marketplace.render import check_conflicts, merge_settings, render_settings
|
|
30
|
+
from scc_cli.marketplace.resolve import resolve_effective_config
|
|
31
|
+
from scc_cli.marketplace.schema import (
|
|
32
|
+
MarketplaceSource,
|
|
33
|
+
OrganizationConfig,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SyncError(Exception):
|
|
38
|
+
"""Error during marketplace sync operation."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, details: dict[str, Any] | None = None) -> None:
|
|
41
|
+
self.details = details or {}
|
|
42
|
+
super().__init__(message)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SyncResult:
|
|
46
|
+
"""Result of a marketplace sync operation."""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
success: bool,
|
|
51
|
+
plugins_enabled: list[str] | None = None,
|
|
52
|
+
marketplaces_materialized: list[str] | None = None,
|
|
53
|
+
warnings: list[str] | None = None,
|
|
54
|
+
settings_path: Path | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self.success = success
|
|
57
|
+
self.plugins_enabled = plugins_enabled or []
|
|
58
|
+
self.marketplaces_materialized = marketplaces_materialized or []
|
|
59
|
+
self.warnings = warnings or []
|
|
60
|
+
self.settings_path = settings_path
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sync_marketplace_settings(
|
|
64
|
+
project_dir: Path,
|
|
65
|
+
org_config_data: dict[str, Any],
|
|
66
|
+
team_id: str | None = None,
|
|
67
|
+
org_config_url: str | None = None,
|
|
68
|
+
force_refresh: bool = False,
|
|
69
|
+
dry_run: bool = False,
|
|
70
|
+
) -> SyncResult:
|
|
71
|
+
"""Sync marketplace settings for a project.
|
|
72
|
+
|
|
73
|
+
Orchestrates the full pipeline:
|
|
74
|
+
1. Parse and validate org config
|
|
75
|
+
2. Compute effective plugins for team
|
|
76
|
+
3. Materialize required marketplaces
|
|
77
|
+
4. Render settings to Claude format
|
|
78
|
+
5. Merge with existing user settings (non-destructive)
|
|
79
|
+
6. Save managed state tracking
|
|
80
|
+
7. Write settings.local.json (unless dry_run)
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
project_dir: Project root directory
|
|
84
|
+
org_config_data: Parsed org config dictionary
|
|
85
|
+
team_id: Team profile ID (uses defaults if None)
|
|
86
|
+
org_config_url: URL where org config was fetched (for tracking)
|
|
87
|
+
force_refresh: Force re-materialization of marketplaces
|
|
88
|
+
dry_run: If True, compute but don't write files
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
SyncResult with success status and details
|
|
92
|
+
|
|
93
|
+
Raises:
|
|
94
|
+
SyncError: On validation or processing errors
|
|
95
|
+
TeamNotFoundError: If team_id not found in config
|
|
96
|
+
"""
|
|
97
|
+
warnings: list[str] = []
|
|
98
|
+
|
|
99
|
+
# ── Step 1: Parse org config ─────────────────────────────────────────────
|
|
100
|
+
# Translate external format (JSON Schema) to internal format (Pydantic)
|
|
101
|
+
try:
|
|
102
|
+
internal_data = translate_org_config(org_config_data)
|
|
103
|
+
org_config = OrganizationConfig.model_validate(internal_data)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
raise SyncError(f"Invalid org config: {e}") from e
|
|
106
|
+
|
|
107
|
+
# ── Step 2: Resolve effective config (federation-aware) ────────────────────
|
|
108
|
+
if team_id is None:
|
|
109
|
+
raise SyncError("team_id is required for marketplace sync")
|
|
110
|
+
|
|
111
|
+
# Use resolve_effective_config for federation support (T2a-24)
|
|
112
|
+
# This handles both inline and federated teams uniformly
|
|
113
|
+
effective_config = resolve_effective_config(org_config, team_id=team_id)
|
|
114
|
+
|
|
115
|
+
# Convert to Phase 1 format for backward compatibility
|
|
116
|
+
effective, effective_marketplaces = effective_config.to_phase1_format()
|
|
117
|
+
|
|
118
|
+
# Check for blocked plugins that user has installed
|
|
119
|
+
# First, check if org-enabled plugins were blocked
|
|
120
|
+
if effective.blocked:
|
|
121
|
+
existing = _load_existing_plugins(project_dir)
|
|
122
|
+
conflict_warnings = check_conflicts(
|
|
123
|
+
existing_plugins=existing,
|
|
124
|
+
blocked_plugins=[
|
|
125
|
+
{"plugin_id": b.plugin_id, "reason": b.reason, "pattern": b.pattern}
|
|
126
|
+
for b in effective.blocked
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
warnings.extend(conflict_warnings)
|
|
130
|
+
|
|
131
|
+
# Also check user's existing plugins against security.blocked_plugins patterns
|
|
132
|
+
security = org_config.security
|
|
133
|
+
if security and security.blocked_plugins:
|
|
134
|
+
existing = _load_existing_plugins(project_dir)
|
|
135
|
+
blocked_reason = security.blocked_reason or "Blocked by organization policy"
|
|
136
|
+
for plugin in existing:
|
|
137
|
+
for pattern in security.blocked_plugins:
|
|
138
|
+
if matches_pattern(plugin, pattern):
|
|
139
|
+
warnings.append(
|
|
140
|
+
f"⚠️ Plugin '{plugin}' is blocked by team policy: {blocked_reason} "
|
|
141
|
+
f"(matched pattern: {pattern})"
|
|
142
|
+
)
|
|
143
|
+
break # Only one warning per plugin
|
|
144
|
+
|
|
145
|
+
# ── Step 3: Materialize required marketplaces ────────────────────────────
|
|
146
|
+
materialized: dict[str, Any] = {}
|
|
147
|
+
marketplaces_used = set()
|
|
148
|
+
|
|
149
|
+
# Determine which marketplaces are needed
|
|
150
|
+
for plugin_ref in effective.enabled:
|
|
151
|
+
if "@" in plugin_ref:
|
|
152
|
+
marketplace_name = plugin_ref.split("@")[1]
|
|
153
|
+
marketplaces_used.add(marketplace_name)
|
|
154
|
+
|
|
155
|
+
# Also include any extra marketplaces from the effective result
|
|
156
|
+
for marketplace_name in effective.extra_marketplaces:
|
|
157
|
+
marketplaces_used.add(marketplace_name)
|
|
158
|
+
|
|
159
|
+
# Materialize each marketplace
|
|
160
|
+
for marketplace_name in marketplaces_used:
|
|
161
|
+
# Skip implicit marketplaces (claude-plugins-official)
|
|
162
|
+
from scc_cli.marketplace.constants import IMPLICIT_MARKETPLACES
|
|
163
|
+
|
|
164
|
+
if marketplace_name in IMPLICIT_MARKETPLACES:
|
|
165
|
+
continue
|
|
166
|
+
|
|
167
|
+
# Find source configuration from effective marketplaces (includes team sources for federated)
|
|
168
|
+
# This is the key change for T2a-24: effective_marketplaces comes from resolve_effective_config
|
|
169
|
+
source = effective_marketplaces.get(marketplace_name)
|
|
170
|
+
if source is None:
|
|
171
|
+
# Fallback to org config lookup for backwards compatibility
|
|
172
|
+
source = _find_marketplace_source(org_config, marketplace_name)
|
|
173
|
+
if source is None:
|
|
174
|
+
warnings.append(f"Marketplace '{marketplace_name}' not found in org config")
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
result = materialize_marketplace(
|
|
179
|
+
name=marketplace_name,
|
|
180
|
+
source=source,
|
|
181
|
+
project_dir=project_dir,
|
|
182
|
+
force_refresh=force_refresh,
|
|
183
|
+
)
|
|
184
|
+
materialized[marketplace_name] = {
|
|
185
|
+
"relative_path": result.relative_path,
|
|
186
|
+
"source_type": result.source_type,
|
|
187
|
+
}
|
|
188
|
+
except MaterializationError as e:
|
|
189
|
+
warnings.append(f"Failed to materialize '{marketplace_name}': {e}")
|
|
190
|
+
|
|
191
|
+
# ── Step 4: Render settings ──────────────────────────────────────────────
|
|
192
|
+
effective_dict = {
|
|
193
|
+
"enabled": effective.enabled,
|
|
194
|
+
"extra_marketplaces": effective.extra_marketplaces,
|
|
195
|
+
}
|
|
196
|
+
rendered = render_settings(effective_dict, materialized)
|
|
197
|
+
|
|
198
|
+
# ── Step 5: Merge with existing settings ─────────────────────────────────
|
|
199
|
+
merged = merge_settings(project_dir, rendered)
|
|
200
|
+
|
|
201
|
+
# ── Step 6: Prepare managed state ────────────────────────────────────────
|
|
202
|
+
managed_state = ManagedState(
|
|
203
|
+
managed_plugins=list(effective.enabled),
|
|
204
|
+
managed_marketplaces=[m.get("relative_path", "") for m in materialized.values()],
|
|
205
|
+
last_sync=datetime.now(timezone.utc),
|
|
206
|
+
org_config_url=org_config_url,
|
|
207
|
+
team_id=team_id,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# ── Step 7: Write files (unless dry_run) ─────────────────────────────────
|
|
211
|
+
settings_path = project_dir / ".claude" / "settings.local.json"
|
|
212
|
+
|
|
213
|
+
if not dry_run:
|
|
214
|
+
# Ensure .claude directory exists
|
|
215
|
+
claude_dir = project_dir / ".claude"
|
|
216
|
+
claude_dir.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
|
|
218
|
+
# Write settings
|
|
219
|
+
settings_path.write_text(json.dumps(merged, indent=2))
|
|
220
|
+
|
|
221
|
+
# Save managed state
|
|
222
|
+
save_managed_state(project_dir, managed_state)
|
|
223
|
+
|
|
224
|
+
return SyncResult(
|
|
225
|
+
success=True,
|
|
226
|
+
plugins_enabled=list(effective.enabled),
|
|
227
|
+
marketplaces_materialized=list(materialized.keys()),
|
|
228
|
+
warnings=warnings,
|
|
229
|
+
settings_path=settings_path if not dry_run else None,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _load_existing_plugins(project_dir: Path) -> list[str]:
|
|
234
|
+
"""Load existing plugins from settings.local.json."""
|
|
235
|
+
settings_path = project_dir / ".claude" / "settings.local.json"
|
|
236
|
+
if not settings_path.exists():
|
|
237
|
+
return []
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
data: dict[str, Any] = json.loads(settings_path.read_text())
|
|
241
|
+
plugins = data.get("enabledPlugins", [])
|
|
242
|
+
if isinstance(plugins, list):
|
|
243
|
+
return [str(p) for p in plugins]
|
|
244
|
+
return []
|
|
245
|
+
except (json.JSONDecodeError, OSError):
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _find_marketplace_source(
|
|
250
|
+
org_config: OrganizationConfig, marketplace_name: str
|
|
251
|
+
) -> MarketplaceSource | None:
|
|
252
|
+
"""Find marketplace source configuration by name."""
|
|
253
|
+
if org_config.marketplaces is None:
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
for name, source in org_config.marketplaces.items():
|
|
257
|
+
if name == marketplace_name:
|
|
258
|
+
return source
|
|
259
|
+
|
|
260
|
+
return None
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Team config caching for federated configurations (Phase 2).
|
|
2
|
+
|
|
3
|
+
This module provides:
|
|
4
|
+
- TeamCacheMeta: Metadata about cached team configurations
|
|
5
|
+
- DEFAULT_TTL: How long cached configs are considered fresh (24h)
|
|
6
|
+
- MAX_STALE_AGE: Maximum age for fallback to stale cache (7d)
|
|
7
|
+
- Cache path utilities for team config storage
|
|
8
|
+
|
|
9
|
+
Cache Design:
|
|
10
|
+
Team configs are cached under ~/.cache/scc/team_configs/
|
|
11
|
+
Each team has two files:
|
|
12
|
+
- {team_name}.json: The actual team config
|
|
13
|
+
- {team_name}.meta.json: Metadata (source, timestamps, SHA)
|
|
14
|
+
|
|
15
|
+
Freshness Model:
|
|
16
|
+
- Fresh (age < DEFAULT_TTL): Use cached config directly
|
|
17
|
+
- Stale (TTL < age < MAX_STALE_AGE): Try refresh, fallback to cache
|
|
18
|
+
- Expired (age > MAX_STALE_AGE): Must fetch, no fallback allowed
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import datetime, timedelta, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
29
|
+
# Constants
|
|
30
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
# How long cached team configs are considered fresh
|
|
33
|
+
DEFAULT_TTL: timedelta = timedelta(hours=24)
|
|
34
|
+
|
|
35
|
+
# Maximum age for fallback to stale cache when fetch fails
|
|
36
|
+
MAX_STALE_AGE: timedelta = timedelta(days=7)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
40
|
+
# Cache Metadata
|
|
41
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class TeamCacheMeta:
|
|
46
|
+
"""Metadata about a cached team configuration.
|
|
47
|
+
|
|
48
|
+
Tracks when and how a team config was fetched, enabling:
|
|
49
|
+
- Cache freshness decisions (is_fresh)
|
|
50
|
+
- Fallback policy (is_within_max_stale_age)
|
|
51
|
+
- Conditional fetching with ETags/commit SHAs
|
|
52
|
+
|
|
53
|
+
Attributes:
|
|
54
|
+
team_name: Team identifier
|
|
55
|
+
source_type: How config was fetched (github, git, url)
|
|
56
|
+
source_url: Where config was fetched from
|
|
57
|
+
fetched_at: When config was last fetched
|
|
58
|
+
commit_sha: Git commit SHA (for git/github sources)
|
|
59
|
+
etag: HTTP ETag (for URL sources)
|
|
60
|
+
branch: Git branch (for git/github sources)
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
team_name: str
|
|
64
|
+
source_type: str
|
|
65
|
+
source_url: str
|
|
66
|
+
fetched_at: datetime
|
|
67
|
+
commit_sha: str | None = None
|
|
68
|
+
etag: str | None = None
|
|
69
|
+
branch: str | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def age(self) -> timedelta:
|
|
73
|
+
"""Calculate how old this cached config is.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Time elapsed since fetched_at
|
|
77
|
+
"""
|
|
78
|
+
now = datetime.now(timezone.utc)
|
|
79
|
+
# Handle timezone-naive datetimes
|
|
80
|
+
fetched = self.fetched_at
|
|
81
|
+
if fetched.tzinfo is None:
|
|
82
|
+
fetched = fetched.replace(tzinfo=timezone.utc)
|
|
83
|
+
return now - fetched
|
|
84
|
+
|
|
85
|
+
def is_fresh(self, ttl: timedelta = DEFAULT_TTL) -> bool:
|
|
86
|
+
"""Check if cached config is still fresh.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
ttl: Time-to-live threshold
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
True if age < ttl, meaning cache can be used without refresh
|
|
93
|
+
"""
|
|
94
|
+
return self.age < ttl
|
|
95
|
+
|
|
96
|
+
def is_within_max_stale_age(self, max_age: timedelta = MAX_STALE_AGE) -> bool:
|
|
97
|
+
"""Check if stale cache can be used as fallback.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
max_age: Maximum acceptable age for fallback
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
True if age < max_age, meaning cache can be used when fetch fails
|
|
104
|
+
"""
|
|
105
|
+
return self.age < max_age
|
|
106
|
+
|
|
107
|
+
def to_dict(self) -> dict[str, Any]:
|
|
108
|
+
"""Serialize to dictionary for JSON storage.
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Dict with all fields, datetime as ISO format string
|
|
112
|
+
"""
|
|
113
|
+
return {
|
|
114
|
+
"team_name": self.team_name,
|
|
115
|
+
"source_type": self.source_type,
|
|
116
|
+
"source_url": self.source_url,
|
|
117
|
+
"fetched_at": self.fetched_at.isoformat(),
|
|
118
|
+
"commit_sha": self.commit_sha,
|
|
119
|
+
"etag": self.etag,
|
|
120
|
+
"branch": self.branch,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@classmethod
|
|
124
|
+
def from_dict(cls, data: dict[str, Any]) -> TeamCacheMeta:
|
|
125
|
+
"""Deserialize from dictionary loaded from JSON.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
data: Dict with serialized cache metadata
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Restored TeamCacheMeta instance
|
|
132
|
+
"""
|
|
133
|
+
fetched_at = data.get("fetched_at")
|
|
134
|
+
if isinstance(fetched_at, str):
|
|
135
|
+
fetched_at = datetime.fromisoformat(fetched_at)
|
|
136
|
+
else:
|
|
137
|
+
fetched_at = datetime.now(timezone.utc)
|
|
138
|
+
|
|
139
|
+
return cls(
|
|
140
|
+
team_name=data["team_name"],
|
|
141
|
+
source_type=data["source_type"],
|
|
142
|
+
source_url=data["source_url"],
|
|
143
|
+
fetched_at=fetched_at,
|
|
144
|
+
commit_sha=data.get("commit_sha"),
|
|
145
|
+
etag=data.get("etag"),
|
|
146
|
+
branch=data.get("branch"),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
151
|
+
# Cache Paths
|
|
152
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_team_cache_dir(cache_root: Path | None = None) -> Path:
|
|
156
|
+
"""Get the directory for team config caches.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
cache_root: Base cache directory (defaults to XDG cache path)
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
Path to team_configs subdirectory
|
|
163
|
+
"""
|
|
164
|
+
if cache_root is None:
|
|
165
|
+
from scc_cli.config import get_cache_dir
|
|
166
|
+
|
|
167
|
+
cache_root = get_cache_dir()
|
|
168
|
+
|
|
169
|
+
return cache_root / "team_configs"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_team_config_cache_path(team_name: str, cache_root: Path | None = None) -> Path:
|
|
173
|
+
"""Get the cache file path for a team's config.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
team_name: Team identifier
|
|
177
|
+
cache_root: Base cache directory
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Path to {team_name}.json file
|
|
181
|
+
"""
|
|
182
|
+
return get_team_cache_dir(cache_root) / f"{team_name}.json"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def get_team_meta_cache_path(team_name: str, cache_root: Path | None = None) -> Path:
|
|
186
|
+
"""Get the cache metadata file path for a team.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
team_name: Team identifier
|
|
190
|
+
cache_root: Base cache directory
|
|
191
|
+
|
|
192
|
+
Returns:
|
|
193
|
+
Path to {team_name}.meta.json file
|
|
194
|
+
"""
|
|
195
|
+
return get_team_cache_dir(cache_root) / f"{team_name}.meta.json"
|