scc-cli 1.5.3__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.

Files changed (153) hide show
  1. scc_cli/__init__.py +15 -0
  2. scc_cli/audit/__init__.py +37 -0
  3. scc_cli/audit/parser.py +191 -0
  4. scc_cli/audit/reader.py +180 -0
  5. scc_cli/auth.py +145 -0
  6. scc_cli/claude_adapter.py +485 -0
  7. scc_cli/cli.py +311 -0
  8. scc_cli/cli_common.py +190 -0
  9. scc_cli/cli_helpers.py +244 -0
  10. scc_cli/commands/__init__.py +20 -0
  11. scc_cli/commands/admin.py +708 -0
  12. scc_cli/commands/audit.py +246 -0
  13. scc_cli/commands/config.py +528 -0
  14. scc_cli/commands/exceptions.py +696 -0
  15. scc_cli/commands/init.py +272 -0
  16. scc_cli/commands/launch/__init__.py +73 -0
  17. scc_cli/commands/launch/app.py +1247 -0
  18. scc_cli/commands/launch/render.py +309 -0
  19. scc_cli/commands/launch/sandbox.py +135 -0
  20. scc_cli/commands/launch/workspace.py +339 -0
  21. scc_cli/commands/org/__init__.py +49 -0
  22. scc_cli/commands/org/_builders.py +264 -0
  23. scc_cli/commands/org/app.py +41 -0
  24. scc_cli/commands/org/import_cmd.py +267 -0
  25. scc_cli/commands/org/init_cmd.py +269 -0
  26. scc_cli/commands/org/schema_cmd.py +76 -0
  27. scc_cli/commands/org/status_cmd.py +157 -0
  28. scc_cli/commands/org/update_cmd.py +330 -0
  29. scc_cli/commands/org/validate_cmd.py +138 -0
  30. scc_cli/commands/support.py +323 -0
  31. scc_cli/commands/team.py +910 -0
  32. scc_cli/commands/worktree/__init__.py +72 -0
  33. scc_cli/commands/worktree/_helpers.py +57 -0
  34. scc_cli/commands/worktree/app.py +170 -0
  35. scc_cli/commands/worktree/container_commands.py +385 -0
  36. scc_cli/commands/worktree/context_commands.py +61 -0
  37. scc_cli/commands/worktree/session_commands.py +128 -0
  38. scc_cli/commands/worktree/worktree_commands.py +734 -0
  39. scc_cli/config.py +647 -0
  40. scc_cli/confirm.py +20 -0
  41. scc_cli/console.py +562 -0
  42. scc_cli/contexts.py +394 -0
  43. scc_cli/core/__init__.py +68 -0
  44. scc_cli/core/constants.py +101 -0
  45. scc_cli/core/errors.py +297 -0
  46. scc_cli/core/exit_codes.py +91 -0
  47. scc_cli/core/workspace.py +57 -0
  48. scc_cli/deprecation.py +54 -0
  49. scc_cli/deps.py +189 -0
  50. scc_cli/docker/__init__.py +127 -0
  51. scc_cli/docker/core.py +467 -0
  52. scc_cli/docker/credentials.py +726 -0
  53. scc_cli/docker/launch.py +595 -0
  54. scc_cli/doctor/__init__.py +105 -0
  55. scc_cli/doctor/checks/__init__.py +166 -0
  56. scc_cli/doctor/checks/cache.py +314 -0
  57. scc_cli/doctor/checks/config.py +107 -0
  58. scc_cli/doctor/checks/environment.py +182 -0
  59. scc_cli/doctor/checks/json_helpers.py +157 -0
  60. scc_cli/doctor/checks/organization.py +264 -0
  61. scc_cli/doctor/checks/worktree.py +278 -0
  62. scc_cli/doctor/render.py +365 -0
  63. scc_cli/doctor/types.py +66 -0
  64. scc_cli/evaluation/__init__.py +27 -0
  65. scc_cli/evaluation/apply_exceptions.py +207 -0
  66. scc_cli/evaluation/evaluate.py +97 -0
  67. scc_cli/evaluation/models.py +80 -0
  68. scc_cli/git.py +84 -0
  69. scc_cli/json_command.py +166 -0
  70. scc_cli/json_output.py +159 -0
  71. scc_cli/kinds.py +65 -0
  72. scc_cli/marketplace/__init__.py +123 -0
  73. scc_cli/marketplace/adapter.py +74 -0
  74. scc_cli/marketplace/compute.py +377 -0
  75. scc_cli/marketplace/constants.py +87 -0
  76. scc_cli/marketplace/managed.py +135 -0
  77. scc_cli/marketplace/materialize.py +846 -0
  78. scc_cli/marketplace/normalize.py +548 -0
  79. scc_cli/marketplace/render.py +281 -0
  80. scc_cli/marketplace/resolve.py +459 -0
  81. scc_cli/marketplace/schema.py +506 -0
  82. scc_cli/marketplace/sync.py +279 -0
  83. scc_cli/marketplace/team_cache.py +195 -0
  84. scc_cli/marketplace/team_fetch.py +689 -0
  85. scc_cli/marketplace/trust.py +244 -0
  86. scc_cli/models/__init__.py +41 -0
  87. scc_cli/models/exceptions.py +273 -0
  88. scc_cli/models/plugin_audit.py +434 -0
  89. scc_cli/org_templates.py +269 -0
  90. scc_cli/output_mode.py +167 -0
  91. scc_cli/panels.py +113 -0
  92. scc_cli/platform.py +350 -0
  93. scc_cli/profiles.py +960 -0
  94. scc_cli/remote.py +443 -0
  95. scc_cli/schemas/__init__.py +1 -0
  96. scc_cli/schemas/org-v1.schema.json +456 -0
  97. scc_cli/schemas/team-config.v1.schema.json +163 -0
  98. scc_cli/services/__init__.py +1 -0
  99. scc_cli/services/git/__init__.py +79 -0
  100. scc_cli/services/git/branch.py +151 -0
  101. scc_cli/services/git/core.py +216 -0
  102. scc_cli/services/git/hooks.py +108 -0
  103. scc_cli/services/git/worktree.py +444 -0
  104. scc_cli/services/workspace/__init__.py +36 -0
  105. scc_cli/services/workspace/resolver.py +223 -0
  106. scc_cli/services/workspace/suspicious.py +200 -0
  107. scc_cli/sessions.py +425 -0
  108. scc_cli/setup.py +589 -0
  109. scc_cli/source_resolver.py +470 -0
  110. scc_cli/stats.py +378 -0
  111. scc_cli/stores/__init__.py +13 -0
  112. scc_cli/stores/exception_store.py +251 -0
  113. scc_cli/subprocess_utils.py +88 -0
  114. scc_cli/teams.py +383 -0
  115. scc_cli/templates/__init__.py +2 -0
  116. scc_cli/templates/org/__init__.py +0 -0
  117. scc_cli/templates/org/minimal.json +19 -0
  118. scc_cli/templates/org/reference.json +74 -0
  119. scc_cli/templates/org/strict.json +38 -0
  120. scc_cli/templates/org/teams.json +42 -0
  121. scc_cli/templates/statusline.sh +75 -0
  122. scc_cli/theme.py +348 -0
  123. scc_cli/ui/__init__.py +154 -0
  124. scc_cli/ui/branding.py +68 -0
  125. scc_cli/ui/chrome.py +401 -0
  126. scc_cli/ui/dashboard/__init__.py +62 -0
  127. scc_cli/ui/dashboard/_dashboard.py +794 -0
  128. scc_cli/ui/dashboard/loaders.py +452 -0
  129. scc_cli/ui/dashboard/models.py +185 -0
  130. scc_cli/ui/dashboard/orchestrator.py +735 -0
  131. scc_cli/ui/formatters.py +444 -0
  132. scc_cli/ui/gate.py +350 -0
  133. scc_cli/ui/git_interactive.py +869 -0
  134. scc_cli/ui/git_render.py +176 -0
  135. scc_cli/ui/help.py +157 -0
  136. scc_cli/ui/keys.py +615 -0
  137. scc_cli/ui/list_screen.py +437 -0
  138. scc_cli/ui/picker.py +763 -0
  139. scc_cli/ui/prompts.py +201 -0
  140. scc_cli/ui/quick_resume.py +116 -0
  141. scc_cli/ui/wizard.py +576 -0
  142. scc_cli/update.py +680 -0
  143. scc_cli/utils/__init__.py +39 -0
  144. scc_cli/utils/fixit.py +264 -0
  145. scc_cli/utils/fuzzy.py +124 -0
  146. scc_cli/utils/locks.py +114 -0
  147. scc_cli/utils/ttl.py +376 -0
  148. scc_cli/validate.py +455 -0
  149. scc_cli-1.5.3.dist-info/METADATA +401 -0
  150. scc_cli-1.5.3.dist-info/RECORD +153 -0
  151. scc_cli-1.5.3.dist-info/WHEEL +4 -0
  152. scc_cli-1.5.3.dist-info/entry_points.txt +2 -0
  153. scc_cli-1.5.3.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,279 @@
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
+ "canonical_name": result.canonical_name, # Critical for alias → canonical translation
188
+ }
189
+ except MaterializationError as e:
190
+ warnings.append(f"Failed to materialize '{marketplace_name}': {e}")
191
+
192
+ # ── Step 3b: Check for canonical name collisions ────────────────────────
193
+ # Multiple aliases resolving to the same canonical name is a configuration error
194
+ canonical_to_aliases: dict[str, list[str]] = {}
195
+ for alias_name, data in materialized.items():
196
+ canonical = data.get("canonical_name", alias_name)
197
+ if canonical not in canonical_to_aliases:
198
+ canonical_to_aliases[canonical] = []
199
+ canonical_to_aliases[canonical].append(alias_name)
200
+
201
+ for canonical, aliases in canonical_to_aliases.items():
202
+ if len(aliases) > 1:
203
+ raise SyncError(
204
+ f"Canonical name collision: marketplace.json name '{canonical}' "
205
+ f"is used by multiple org config entries: {', '.join(aliases)}. "
206
+ f"Each marketplace must have a unique canonical name.",
207
+ details={"canonical_name": canonical, "conflicting_aliases": aliases},
208
+ )
209
+
210
+ # ── Step 4: Render settings ──────────────────────────────────────────────
211
+ effective_dict = {
212
+ "enabled": effective.enabled,
213
+ "extra_marketplaces": effective.extra_marketplaces,
214
+ }
215
+ rendered = render_settings(effective_dict, materialized)
216
+
217
+ # ── Step 5: Merge with existing settings ─────────────────────────────────
218
+ merged = merge_settings(project_dir, rendered)
219
+
220
+ # ── Step 6: Prepare managed state ────────────────────────────────────────
221
+ managed_state = ManagedState(
222
+ managed_plugins=list(effective.enabled),
223
+ managed_marketplaces=[m.get("relative_path", "") for m in materialized.values()],
224
+ last_sync=datetime.now(timezone.utc),
225
+ org_config_url=org_config_url,
226
+ team_id=team_id,
227
+ )
228
+
229
+ # ── Step 7: Write files (unless dry_run) ─────────────────────────────────
230
+ settings_path = project_dir / ".claude" / "settings.local.json"
231
+
232
+ if not dry_run:
233
+ # Ensure .claude directory exists
234
+ claude_dir = project_dir / ".claude"
235
+ claude_dir.mkdir(parents=True, exist_ok=True)
236
+
237
+ # Write settings
238
+ settings_path.write_text(json.dumps(merged, indent=2))
239
+
240
+ # Save managed state
241
+ save_managed_state(project_dir, managed_state)
242
+
243
+ return SyncResult(
244
+ success=True,
245
+ plugins_enabled=list(effective.enabled),
246
+ marketplaces_materialized=list(materialized.keys()),
247
+ warnings=warnings,
248
+ settings_path=settings_path if not dry_run else None,
249
+ )
250
+
251
+
252
+ def _load_existing_plugins(project_dir: Path) -> list[str]:
253
+ """Load existing plugins from settings.local.json."""
254
+ settings_path = project_dir / ".claude" / "settings.local.json"
255
+ if not settings_path.exists():
256
+ return []
257
+
258
+ try:
259
+ data: dict[str, Any] = json.loads(settings_path.read_text())
260
+ plugins = data.get("enabledPlugins", [])
261
+ if isinstance(plugins, list):
262
+ return [str(p) for p in plugins]
263
+ return []
264
+ except (json.JSONDecodeError, OSError):
265
+ return []
266
+
267
+
268
+ def _find_marketplace_source(
269
+ org_config: OrganizationConfig, marketplace_name: str
270
+ ) -> MarketplaceSource | None:
271
+ """Find marketplace source configuration by name."""
272
+ if org_config.marketplaces is None:
273
+ return None
274
+
275
+ for name, source in org_config.marketplaces.items():
276
+ if name == marketplace_name:
277
+ return source
278
+
279
+ 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"