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
scc_cli/remote.py ADDED
@@ -0,0 +1,443 @@
1
+ """
2
+ Remote config fetching with auth and caching.
3
+
4
+ Handle all HTTP concerns for fetching org config:
5
+ - URL validation (HTTPS only)
6
+ - Auth resolution (env:VAR, command:CMD)
7
+ - ETag-based conditional fetching
8
+ - Local cache with TTL
9
+
10
+ Module Separation: This module does HTTP only, no business logic.
11
+ Business logic is in profiles.py, format knowledge is in claude_adapter.py.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ import stat
19
+ import sys
20
+ from datetime import datetime, timedelta, timezone
21
+ from pathlib import Path
22
+ from typing import TYPE_CHECKING, Any
23
+ from urllib.parse import urlparse
24
+
25
+ import requests
26
+
27
+ from scc_cli.auth import is_remote_command_allowed
28
+ from scc_cli.auth import resolve_auth as _resolve_auth_impl
29
+ from scc_cli.output_mode import print_human
30
+ from scc_cli.utils.locks import file_lock, lock_path
31
+
32
+ if TYPE_CHECKING:
33
+ pass
34
+
35
+
36
+ # ═══════════════════════════════════════════════════════════════════════════════
37
+ # XDG Base Directory Paths
38
+ # ═══════════════════════════════════════════════════════════════════════════════
39
+
40
+ # Cache directory: ~/.cache/scc/ (regenerable, safe to delete)
41
+ CACHE_DIR = Path.home() / ".cache" / "scc"
42
+
43
+
44
+ # ═══════════════════════════════════════════════════════════════════════════════
45
+ # Exceptions
46
+ # ═══════════════════════════════════════════════════════════════════════════════
47
+
48
+
49
+ class CacheNotFoundError(Exception):
50
+ """Raised when cache is required but not available."""
51
+
52
+ pass
53
+
54
+
55
+ class ConfigValidationError(Exception):
56
+ """Raised when org config fails validation.
57
+
58
+ This is raised when either:
59
+ - Structural validation fails (JSON Schema errors)
60
+ - Semantic validation fails (governance invariant violations)
61
+
62
+ Invalid configs are never cached to prevent polluting the cache.
63
+ """
64
+
65
+ pass
66
+
67
+
68
+ # ═══════════════════════════════════════════════════════════════════════════════
69
+ # URL Validation
70
+ # ═══════════════════════════════════════════════════════════════════════════════
71
+
72
+
73
+ def validate_org_config_url(url: str) -> str:
74
+ """Validate and normalize org config URL. HTTPS only.
75
+
76
+ Args:
77
+ url: URL to validate
78
+
79
+ Returns:
80
+ Validated and normalized URL
81
+
82
+ Raises:
83
+ ValueError for:
84
+ - http:// URLs (security risk)
85
+ - git@ or ssh:// URLs (not supported)
86
+ - Non-URL formats
87
+ """
88
+ url = url.strip()
89
+
90
+ # Reject SSH URLs early
91
+ if url.startswith("git@") or url.startswith("ssh://"):
92
+ raise ValueError(f"SSH URL not supported for org config: {url}")
93
+
94
+ parsed = urlparse(url)
95
+
96
+ # HTTPS only - reject http:// for security
97
+ if parsed.scheme == "http":
98
+ raise ValueError(f"HTTP not allowed (use HTTPS): {url}")
99
+
100
+ if parsed.scheme != "https":
101
+ raise ValueError(f"Invalid URL scheme (HTTPS required): {url}")
102
+
103
+ return url
104
+
105
+
106
+ # ═══════════════════════════════════════════════════════════════════════════════
107
+ # Auth Resolution
108
+ # ═══════════════════════════════════════════════════════════════════════════════
109
+
110
+
111
+ def resolve_auth(auth_spec: str | None, *, from_remote: bool = False) -> str | None:
112
+ """Resolve auth from 'env:VAR' or 'command:CMD' syntax.
113
+
114
+ SECURITY: Uses auth.py module with shell=False to prevent shell injection.
115
+
116
+ Args:
117
+ auth_spec: Auth specification string or None
118
+ from_remote: If True, applies trust model restrictions for remote org config.
119
+ command: auth requires SCC_ALLOW_REMOTE_COMMANDS=1 when from_remote=True.
120
+
121
+ Returns:
122
+ Token string or None if not available/configured
123
+
124
+ Raises:
125
+ ValueError: If command: auth is used from remote config without opt-in
126
+ """
127
+ if not auth_spec:
128
+ return None
129
+
130
+ # Determine if command: auth is allowed based on source
131
+ # User config (from_remote=False): Always allow command: auth
132
+ # Remote org config (from_remote=True): Require explicit opt-in
133
+ allow_command = not from_remote or is_remote_command_allowed()
134
+
135
+ try:
136
+ result = _resolve_auth_impl(auth_spec, allow_command=allow_command)
137
+ return result.token if result else None
138
+ except RuntimeError:
139
+ # Command execution failed - return None for backward compatibility
140
+ # (old behavior: failed commands returned None)
141
+ return None
142
+
143
+
144
+ # ═══════════════════════════════════════════════════════════════════════════════
145
+ # HTTP Fetching
146
+ # ═══════════════════════════════════════════════════════════════════════════════
147
+
148
+
149
+ def fetch_org_config(
150
+ url: str, auth: str | None, etag: str | None = None
151
+ ) -> tuple[dict[str, Any] | None, str | None, int]:
152
+ """Fetch org config from URL with ETag support.
153
+
154
+ Args:
155
+ url: HTTPS URL to fetch from (validated)
156
+ auth: Auth token for Authorization header
157
+ etag: Previous ETag for conditional request
158
+
159
+ Returns:
160
+ Tuple of (config_dict, new_etag, status_code)
161
+ - 200: new config returned
162
+ - 304: not modified, config is None (use cache)
163
+ - 401/403: auth error, config is None
164
+ - Other errors: config is None
165
+ """
166
+ # Validate URL (HTTPS enforcement)
167
+ url = validate_org_config_url(url)
168
+
169
+ headers = {}
170
+
171
+ # Add Authorization header if auth provided
172
+ if auth:
173
+ headers["Authorization"] = f"Bearer {auth}"
174
+
175
+ # Add If-None-Match header for conditional request
176
+ if etag:
177
+ headers["If-None-Match"] = etag
178
+
179
+ try:
180
+ response = requests.get(url, headers=headers, timeout=30)
181
+ status = response.status_code
182
+
183
+ # 304 Not Modified - use cached version
184
+ if status == 304:
185
+ return (None, etag, 304)
186
+
187
+ # Error responses
188
+ if status != 200:
189
+ return (None, None, status)
190
+
191
+ # Parse JSON response
192
+ try:
193
+ config = response.json()
194
+ except json.JSONDecodeError:
195
+ return (None, None, -1) # Invalid JSON
196
+
197
+ # Extract new ETag
198
+ new_etag = response.headers.get("ETag")
199
+
200
+ return (config, new_etag, 200)
201
+
202
+ except requests.RequestException:
203
+ return (None, None, -2) # Network error
204
+
205
+
206
+ # ═══════════════════════════════════════════════════════════════════════════════
207
+ # Cache Operations
208
+ # ═══════════════════════════════════════════════════════════════════════════════
209
+
210
+
211
+ def save_to_cache(
212
+ org_config: dict[str, Any], source_url: str, etag: str | None, ttl_hours: int
213
+ ) -> None:
214
+ """Save org config to cache with metadata.
215
+
216
+ Args:
217
+ org_config: Organization config dict to cache
218
+ source_url: URL the config was fetched from
219
+ etag: ETag from server response
220
+ ttl_hours: Cache time-to-live in hours
221
+ """
222
+ lock_file = lock_path("org-config-cache")
223
+ with file_lock(lock_file):
224
+ # Ensure cache directory exists
225
+ CACHE_DIR.mkdir(parents=True, exist_ok=True)
226
+
227
+ # Save org config with restrictive permissions (owner read/write only)
228
+ config_file = CACHE_DIR / "org_config.json"
229
+ config_content = json.dumps(org_config, indent=2)
230
+ config_file.write_text(config_content)
231
+ config_file.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600 - owner read/write only
232
+
233
+ # Calculate fingerprint (SHA256 of cached bytes)
234
+ fingerprint = hashlib.sha256(config_file.read_bytes()).hexdigest()
235
+
236
+ # Calculate expiry time
237
+ now = datetime.now(timezone.utc)
238
+ expires_at = now + timedelta(hours=ttl_hours)
239
+
240
+ # Save metadata
241
+ meta = {
242
+ "org_config": {
243
+ "source_url": source_url,
244
+ "fetched_at": now.isoformat(),
245
+ "expires_at": expires_at.isoformat(),
246
+ "etag": etag,
247
+ "fingerprint": fingerprint,
248
+ }
249
+ }
250
+ meta_file = CACHE_DIR / "cache_meta.json"
251
+ meta_file.write_text(json.dumps(meta, indent=2))
252
+ meta_file.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600 - owner read/write only
253
+
254
+
255
+ def load_from_cache() -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
256
+ """Load cached org config and metadata.
257
+
258
+ Returns:
259
+ Tuple of (config_dict, metadata_dict)
260
+ Both are None if cache doesn't exist or is corrupted
261
+ """
262
+ config_file = CACHE_DIR / "org_config.json"
263
+ meta_file = CACHE_DIR / "cache_meta.json"
264
+
265
+ if not config_file.exists() or not meta_file.exists():
266
+ return (None, None)
267
+
268
+ lock_file = lock_path("org-config-cache")
269
+ with file_lock(lock_file):
270
+ try:
271
+ config = json.loads(config_file.read_text())
272
+ meta = json.loads(meta_file.read_text())
273
+ return (config, meta)
274
+ except (json.JSONDecodeError, OSError):
275
+ return (None, None)
276
+
277
+
278
+ def is_cache_valid(meta: dict[str, Any] | None) -> bool:
279
+ """Check if cache is within TTL.
280
+
281
+ Args:
282
+ meta: Cache metadata dict
283
+
284
+ Returns:
285
+ True if cache is valid and within TTL
286
+ """
287
+ if not meta:
288
+ return False
289
+
290
+ org_config_meta = meta.get("org_config", {})
291
+ expires_at_str = org_config_meta.get("expires_at")
292
+
293
+ if not expires_at_str:
294
+ return False
295
+
296
+ try:
297
+ expires_at = datetime.fromisoformat(expires_at_str)
298
+ if expires_at.tzinfo is None:
299
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
300
+ now = datetime.now(timezone.utc)
301
+ return now < expires_at
302
+ except (ValueError, TypeError):
303
+ return False
304
+
305
+
306
+ # ═══════════════════════════════════════════════════════════════════════════════
307
+ # Validation Gate
308
+ # ═══════════════════════════════════════════════════════════════════════════════
309
+
310
+
311
+ def _validate_org_config(config: dict[str, Any]) -> None:
312
+ """Validate org config structurally and semantically.
313
+
314
+ This is the Validation Gate pattern - called BEFORE caching to ensure
315
+ invalid configs never pollute the cache.
316
+
317
+ Two-step validation:
318
+ 1. Structural: JSON Schema validation (required fields, types, patterns)
319
+ 2. Semantic: Governance invariants (enabled ⊆ allowed, enabled ∩ blocked = ∅)
320
+
321
+ Args:
322
+ config: Organization config dict to validate
323
+
324
+ Raises:
325
+ ConfigValidationError: If either validation step fails
326
+ """
327
+ # Import here to avoid circular dependencies at module load time
328
+ from scc_cli.validate import InvariantViolation, validate_config_invariants
329
+ from scc_cli.validate import validate_org_config as validate_schema
330
+
331
+ # Step 1: Structural validation (JSON Schema)
332
+ schema_errors = validate_schema(config)
333
+ if schema_errors:
334
+ # Format errors for user-friendly message
335
+ error_summary = "; ".join(schema_errors[:3]) # Show first 3 errors
336
+ if len(schema_errors) > 3:
337
+ error_summary += f" (+{len(schema_errors) - 3} more)"
338
+ raise ConfigValidationError(
339
+ f"Organization config failed schema validation: {error_summary}"
340
+ )
341
+
342
+ # Step 2: Semantic validation (governance invariants)
343
+ violations: list[InvariantViolation] = validate_config_invariants(config)
344
+ errors = [v for v in violations if v.severity == "error"]
345
+ if errors:
346
+ # Format violations for user-friendly message
347
+ error_messages = [v.message for v in errors[:3]] # Show first 3
348
+ error_summary = "; ".join(error_messages)
349
+ if len(errors) > 3:
350
+ error_summary += f" (+{len(errors) - 3} more)"
351
+ raise ConfigValidationError(
352
+ f"Organization config failed invariant validation: {error_summary}"
353
+ )
354
+
355
+
356
+ # ═══════════════════════════════════════════════════════════════════════════════
357
+ # Main Entry Point
358
+ # ═══════════════════════════════════════════════════════════════════════════════
359
+
360
+
361
+ def load_org_config(
362
+ user_config: dict[str, Any], force_refresh: bool = False, offline: bool = False
363
+ ) -> dict[str, Any] | None:
364
+ """Load organization config from cache or remote.
365
+
366
+ This is the main entry point for getting org config.
367
+
368
+ Args:
369
+ user_config: User config dict with organization_source
370
+ force_refresh: Bypass TTL check and always fetch from remote
371
+ offline: Use cache only, error if no cache available
372
+
373
+ Returns:
374
+ Organization config dict, or None for standalone mode
375
+
376
+ Raises:
377
+ CacheNotFoundError: In offline mode when no cache is available
378
+ """
379
+ # Standalone mode - no org config
380
+ if user_config.get("standalone"):
381
+ return None
382
+
383
+ # No organization source configured
384
+ org_source = user_config.get("organization_source")
385
+ if not org_source:
386
+ return None
387
+
388
+ url = org_source.get("url")
389
+ if not url:
390
+ return None
391
+
392
+ auth_spec = org_source.get("auth")
393
+
394
+ # Try to load from cache
395
+ cached_config, meta = load_from_cache()
396
+
397
+ # Offline mode: cache only
398
+ if offline:
399
+ if cached_config is not None:
400
+ return cached_config
401
+ raise CacheNotFoundError(f"No cached config available for offline mode. URL: {url}")
402
+
403
+ # Check if cache is valid and we don't need to refresh
404
+ if not force_refresh and cached_config is not None and is_cache_valid(meta):
405
+ return cached_config
406
+
407
+ # Need to fetch from remote
408
+ auth = resolve_auth(auth_spec)
409
+ etag = meta.get("org_config", {}).get("etag") if meta else None
410
+
411
+ config, new_etag, status = fetch_org_config(url, auth=auth, etag=etag)
412
+
413
+ # 304 Not Modified - use cached version
414
+ if status == 304 and cached_config is not None:
415
+ return cached_config
416
+
417
+ # Success - validate BEFORE caching (Validation Gate pattern)
418
+ if status == 200 and config is not None:
419
+ # Validate config - raises ConfigValidationError if invalid
420
+ # This prevents invalid configs from polluting the cache
421
+ _validate_org_config(config)
422
+ from scc_cli.validate import check_version_compatibility
423
+
424
+ compatibility = check_version_compatibility(config)
425
+ if not compatibility.compatible:
426
+ raise ConfigValidationError(compatibility.blocking_error or "Config incompatible")
427
+
428
+ # Only cache after validation passes
429
+ ttl_hours = config.get("defaults", {}).get("cache_ttl_hours", 24)
430
+ save_to_cache(config, url, new_etag, ttl_hours)
431
+ return config
432
+
433
+ # Fetch failed - return stale cache if available (with warning)
434
+ if cached_config is not None:
435
+ print_human(
436
+ "[yellow]Warning:[/yellow] Failed to refresh org config; using cached config.",
437
+ file=sys.stderr,
438
+ highlight=False,
439
+ )
440
+ return cached_config
441
+
442
+ # No cache and fetch failed - return None
443
+ return None
@@ -0,0 +1 @@
1
+ """Bundled JSON schemas for offline validation."""