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.

Files changed (113) 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 +259 -0
  8. scc_cli/cli_admin.py +706 -0
  9. scc_cli/cli_audit.py +245 -0
  10. scc_cli/cli_common.py +166 -0
  11. scc_cli/cli_config.py +527 -0
  12. scc_cli/cli_exceptions.py +705 -0
  13. scc_cli/cli_helpers.py +244 -0
  14. scc_cli/cli_init.py +272 -0
  15. scc_cli/cli_launch.py +1454 -0
  16. scc_cli/cli_org.py +1428 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +892 -0
  19. scc_cli/cli_worktree.py +865 -0
  20. scc_cli/config.py +583 -0
  21. scc_cli/console.py +562 -0
  22. scc_cli/constants.py +79 -0
  23. scc_cli/contexts.py +377 -0
  24. scc_cli/deprecation.py +54 -0
  25. scc_cli/deps.py +189 -0
  26. scc_cli/docker/__init__.py +127 -0
  27. scc_cli/docker/core.py +466 -0
  28. scc_cli/docker/credentials.py +726 -0
  29. scc_cli/docker/launch.py +604 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1074 -0
  32. scc_cli/doctor/render.py +346 -0
  33. scc_cli/doctor/types.py +66 -0
  34. scc_cli/errors.py +288 -0
  35. scc_cli/evaluation/__init__.py +27 -0
  36. scc_cli/evaluation/apply_exceptions.py +207 -0
  37. scc_cli/evaluation/evaluate.py +97 -0
  38. scc_cli/evaluation/models.py +80 -0
  39. scc_cli/exit_codes.py +55 -0
  40. scc_cli/git.py +1521 -0
  41. scc_cli/json_command.py +166 -0
  42. scc_cli/json_output.py +96 -0
  43. scc_cli/kinds.py +62 -0
  44. scc_cli/marketplace/__init__.py +123 -0
  45. scc_cli/marketplace/adapter.py +74 -0
  46. scc_cli/marketplace/compute.py +377 -0
  47. scc_cli/marketplace/constants.py +87 -0
  48. scc_cli/marketplace/managed.py +135 -0
  49. scc_cli/marketplace/materialize.py +723 -0
  50. scc_cli/marketplace/normalize.py +548 -0
  51. scc_cli/marketplace/render.py +257 -0
  52. scc_cli/marketplace/resolve.py +459 -0
  53. scc_cli/marketplace/schema.py +506 -0
  54. scc_cli/marketplace/sync.py +260 -0
  55. scc_cli/marketplace/team_cache.py +195 -0
  56. scc_cli/marketplace/team_fetch.py +688 -0
  57. scc_cli/marketplace/trust.py +244 -0
  58. scc_cli/models/__init__.py +41 -0
  59. scc_cli/models/exceptions.py +273 -0
  60. scc_cli/models/plugin_audit.py +434 -0
  61. scc_cli/org_templates.py +269 -0
  62. scc_cli/output_mode.py +167 -0
  63. scc_cli/panels.py +113 -0
  64. scc_cli/platform.py +350 -0
  65. scc_cli/profiles.py +960 -0
  66. scc_cli/remote.py +443 -0
  67. scc_cli/schemas/__init__.py +1 -0
  68. scc_cli/schemas/org-v1.schema.json +456 -0
  69. scc_cli/schemas/team-config.v1.schema.json +163 -0
  70. scc_cli/sessions.py +425 -0
  71. scc_cli/setup.py +588 -0
  72. scc_cli/source_resolver.py +470 -0
  73. scc_cli/stats.py +378 -0
  74. scc_cli/stores/__init__.py +13 -0
  75. scc_cli/stores/exception_store.py +251 -0
  76. scc_cli/subprocess_utils.py +88 -0
  77. scc_cli/teams.py +382 -0
  78. scc_cli/templates/__init__.py +2 -0
  79. scc_cli/templates/org/__init__.py +0 -0
  80. scc_cli/templates/org/minimal.json +19 -0
  81. scc_cli/templates/org/reference.json +74 -0
  82. scc_cli/templates/org/strict.json +38 -0
  83. scc_cli/templates/org/teams.json +42 -0
  84. scc_cli/templates/statusline.sh +75 -0
  85. scc_cli/theme.py +348 -0
  86. scc_cli/ui/__init__.py +124 -0
  87. scc_cli/ui/branding.py +68 -0
  88. scc_cli/ui/chrome.py +395 -0
  89. scc_cli/ui/dashboard/__init__.py +62 -0
  90. scc_cli/ui/dashboard/_dashboard.py +677 -0
  91. scc_cli/ui/dashboard/loaders.py +395 -0
  92. scc_cli/ui/dashboard/models.py +184 -0
  93. scc_cli/ui/dashboard/orchestrator.py +390 -0
  94. scc_cli/ui/formatters.py +443 -0
  95. scc_cli/ui/gate.py +350 -0
  96. scc_cli/ui/help.py +157 -0
  97. scc_cli/ui/keys.py +538 -0
  98. scc_cli/ui/list_screen.py +431 -0
  99. scc_cli/ui/picker.py +700 -0
  100. scc_cli/ui/prompts.py +200 -0
  101. scc_cli/ui/wizard.py +675 -0
  102. scc_cli/update.py +680 -0
  103. scc_cli/utils/__init__.py +39 -0
  104. scc_cli/utils/fixit.py +264 -0
  105. scc_cli/utils/fuzzy.py +124 -0
  106. scc_cli/utils/locks.py +101 -0
  107. scc_cli/utils/ttl.py +376 -0
  108. scc_cli/validate.py +455 -0
  109. scc_cli-1.4.1.dist-info/METADATA +369 -0
  110. scc_cli-1.4.1.dist-info/RECORD +113 -0
  111. scc_cli-1.4.1.dist-info/WHEEL +4 -0
  112. scc_cli-1.4.1.dist-info/entry_points.txt +2 -0
  113. scc_cli-1.4.1.dist-info/licenses/LICENSE +21 -0
scc_cli/update.py ADDED
@@ -0,0 +1,680 @@
1
+ """
2
+ Check for updates to scc-cli CLI and organization config.
3
+
4
+ Two independent update mechanisms:
5
+ 1. CLI version: Check PyPI (public, always accessible), throttle to once/24h
6
+ 2. Org config: Check remote URL with ETag, throttle to TTL (1-6h typically)
7
+
8
+ Design principles:
9
+ - Non-blocking: Update checks do not delay CLI startup
10
+ - Graceful degradation: Offline = use cache silently
11
+ - Cache-first: Always prefer cached data over network errors
12
+ - UX-friendly: Clear, non-intrusive update notifications
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import shutil
19
+ import sys
20
+ import urllib.error
21
+ import urllib.request
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timedelta, timezone
24
+ from importlib.metadata import PackageNotFoundError
25
+ from importlib.metadata import version as get_installed_version
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING, Any, cast
28
+
29
+ from rich.console import Console
30
+ from rich.panel import Panel
31
+
32
+ if TYPE_CHECKING:
33
+ pass
34
+
35
+ # Package name on PyPI
36
+ PACKAGE_NAME = "scc-cli"
37
+
38
+ # PyPI JSON API endpoint
39
+ PYPI_URL = f"https://pypi.org/pypi/{PACKAGE_NAME}/json"
40
+
41
+ # Timeout for PyPI requests (kept short to avoid hanging CLI)
42
+ REQUEST_TIMEOUT = 3
43
+
44
+ # Throttling: Don't check CLI version more than once per day
45
+ CLI_CHECK_INTERVAL_HOURS = 24
46
+
47
+ # Throttling: Org config check interval (1 hour minimum between checks)
48
+ # Note: This is separate from cache TTL. TTL controls staleness,
49
+ # this controls how often we even attempt to check.
50
+ ORG_CONFIG_CHECK_INTERVAL_HOURS = 1
51
+
52
+ # Cache directory for update check timestamps
53
+ UPDATE_CHECK_CACHE_DIR = Path.home() / ".cache" / "scc"
54
+ UPDATE_CHECK_META_FILE = UPDATE_CHECK_CACHE_DIR / "update_check_meta.json"
55
+
56
+ # Pre-release tag ordering (lower = earlier in release cycle)
57
+ _PRERELEASE_ORDER = {"dev": 0, "a": 1, "alpha": 1, "b": 2, "beta": 2, "rc": 3, "c": 3}
58
+
59
+
60
+ @dataclass
61
+ class UpdateInfo:
62
+ """Information about available CLI updates."""
63
+
64
+ current: str
65
+ latest: str | None
66
+ update_available: bool
67
+ install_method: str # 'pip', 'pipx', 'uv', 'editable'
68
+
69
+
70
+ @dataclass
71
+ class OrgConfigUpdateResult:
72
+ """Result of org config update check."""
73
+
74
+ status: str # 'updated', 'unchanged', 'offline', 'auth_failed', 'no_cache', 'standalone'
75
+ message: str | None = None
76
+ cached_age_hours: float | None = None
77
+
78
+
79
+ @dataclass
80
+ class UpdateCheckResult:
81
+ """Combined result of all update checks."""
82
+
83
+ cli_update: UpdateInfo | None = None
84
+ org_config: OrgConfigUpdateResult | None = None
85
+
86
+
87
+ def check_for_updates() -> UpdateInfo:
88
+ """
89
+ Check PyPI for updates using stdlib urllib and return update info.
90
+
91
+ Returns:
92
+ UpdateInfo with current version, latest version, and update status
93
+ """
94
+ current = _get_current_version()
95
+ latest = _fetch_latest_from_pypi()
96
+ method = _detect_install_method()
97
+
98
+ update_available = False
99
+ if latest:
100
+ update_available = _compare_versions(current, latest) < 0
101
+
102
+ return UpdateInfo(
103
+ current=current,
104
+ latest=latest,
105
+ update_available=update_available,
106
+ install_method=method,
107
+ )
108
+
109
+
110
+ def _get_current_version() -> str:
111
+ """Return the currently installed version."""
112
+ try:
113
+ return get_installed_version(PACKAGE_NAME)
114
+ except PackageNotFoundError:
115
+ return "0.0.0"
116
+
117
+
118
+ def _fetch_latest_from_pypi() -> str | None:
119
+ """Fetch and return the latest version from the PyPI JSON API."""
120
+ try:
121
+ with urllib.request.urlopen(PYPI_URL, timeout=REQUEST_TIMEOUT) as response:
122
+ data = json.loads(response.read().decode("utf-8"))
123
+ return cast(str, data["info"]["version"])
124
+ except (urllib.error.URLError, json.JSONDecodeError, TimeoutError, OSError, KeyError):
125
+ # Network errors, invalid JSON, timeouts, or malformed response
126
+ return None
127
+
128
+
129
+ def _parse_version(v: str) -> tuple[tuple[int, ...], tuple[int, int] | None]:
130
+ """
131
+ Parse a version string into (numeric_parts, prerelease_info).
132
+
133
+ Examples:
134
+ "1.0.0" -> ((1, 0, 0), None)
135
+ "1.0.0rc1" -> ((1, 0, 0), (3, 1)) # rc=3 in order, number=1
136
+ "1.0.0a2" -> ((1, 0, 0), (1, 2)) # a=1 in order, number=2
137
+ "1.0.0.dev1" -> ((1, 0, 0), (0, 1)) # dev=0 in order, number=1
138
+ """
139
+ # Normalize: replace common separators
140
+ v = v.lower().replace("-", ".").replace("_", ".")
141
+
142
+ # Extract numeric parts and any pre-release suffix
143
+ # Pattern: digits optionally followed by prerelease tag
144
+ parts: list[int] = []
145
+ prerelease: tuple[int, int] | None = None
146
+
147
+ # Split by dots and process each segment
148
+ segments = v.split(".")[:4] # Limit to 4 segments
149
+
150
+ for segment in segments:
151
+ # Check for pre-release tag embedded in segment (e.g., "0rc1")
152
+ match = re.match(r"^(\d+)([a-z]+)(\d*)$", segment)
153
+ if match:
154
+ num, tag, tag_num = match.groups()
155
+ parts.append(int(num))
156
+ if tag in _PRERELEASE_ORDER:
157
+ prerelease = (_PRERELEASE_ORDER[tag], int(tag_num) if tag_num else 0)
158
+ break
159
+ elif segment.isdigit():
160
+ parts.append(int(segment))
161
+ elif segment in _PRERELEASE_ORDER:
162
+ # Standalone tag like ".dev1" after split
163
+ prerelease = (_PRERELEASE_ORDER[segment], 0)
164
+ break
165
+ elif re.match(r"^([a-z]+)(\d*)$", segment):
166
+ # Tag with optional number like "dev1"
167
+ m = re.match(r"^([a-z]+)(\d*)$", segment)
168
+ if m:
169
+ tag, tag_num = m.groups()
170
+ if tag in _PRERELEASE_ORDER:
171
+ prerelease = (_PRERELEASE_ORDER[tag], int(tag_num) if tag_num else 0)
172
+ break
173
+ else:
174
+ # Unknown format, try to extract leading digits
175
+ num_str = ""
176
+ for char in segment:
177
+ if char.isdigit():
178
+ num_str += char
179
+ else:
180
+ break
181
+ if num_str:
182
+ parts.append(int(num_str))
183
+
184
+ # Ensure at least 3 parts for comparison
185
+ while len(parts) < 3:
186
+ parts.append(0)
187
+
188
+ return (tuple(parts), prerelease)
189
+
190
+
191
+ def _compare_versions(v1: str, v2: str) -> int:
192
+ """
193
+ Compare two versions with proper pre-release handling.
194
+
195
+ Pre-release versions (dev, alpha, beta, rc) are LESS than the final release.
196
+ Example: 1.0.0rc1 < 1.0.0 < 1.0.1
197
+
198
+ Returns:
199
+ -1 if v1 < v2
200
+ 0 if v1 == v2
201
+ 1 if v1 > v2
202
+ """
203
+ parts1, pre1 = _parse_version(v1)
204
+ parts2, pre2 = _parse_version(v2)
205
+
206
+ # Compare numeric parts first
207
+ if parts1 != parts2:
208
+ return (parts1 > parts2) - (parts1 < parts2)
209
+
210
+ # Same numeric version - check pre-release status
211
+ # Final release (no prerelease) > any prerelease
212
+ if pre1 is None and pre2 is None:
213
+ return 0
214
+ if pre1 is None:
215
+ return 1 # v1 is final release, v2 is prerelease -> v1 > v2
216
+ if pre2 is None:
217
+ return -1 # v1 is prerelease, v2 is final release -> v1 < v2
218
+
219
+ # Both are prereleases - compare them
220
+ return (pre1 > pre2) - (pre1 < pre2)
221
+
222
+
223
+ def _detect_install_method() -> str:
224
+ """
225
+ Detect how the package was installed by checking the environment context.
226
+
227
+ Use sys.prefix, environment variables, and path patterns to determine
228
+ the actual install method, not just which tools exist on the system.
229
+
230
+ Returns one of: 'pipx', 'uv', 'pip', 'editable'
231
+ """
232
+ # Check for editable install first (development mode)
233
+ try:
234
+ from importlib.metadata import distribution
235
+
236
+ dist = distribution(PACKAGE_NAME)
237
+ # PEP 610: Check for editable install via direct_url.json
238
+ direct_url_text = dist.read_text("direct_url.json")
239
+ if direct_url_text:
240
+ import json as json_mod
241
+
242
+ direct_url = json_mod.loads(direct_url_text)
243
+ if direct_url.get("dir_info", {}).get("editable", False):
244
+ return "editable"
245
+ except Exception:
246
+ pass
247
+
248
+ # Get the prefix path where this Python is installed
249
+ prefix = sys.prefix.lower()
250
+
251
+ # Check for pipx environment (pipx creates venvs in specific locations)
252
+ # Common patterns: ~/.local/pipx/venvs/, ~/.local/share/pipx/venvs/
253
+ pipx_indicators = [
254
+ "pipx/venvs",
255
+ "pipx\\venvs", # Windows
256
+ os.environ.get("PIPX_HOME", ""),
257
+ os.environ.get("PIPX_LOCAL_VENVS", ""),
258
+ ]
259
+ if any(ind and ind.lower() in prefix for ind in pipx_indicators if ind):
260
+ return "pipx"
261
+
262
+ # Check for uv tool install (CLI tools installed via `uv tool install`)
263
+ # These are in ~/.local/share/uv/tools/ or $UV_TOOL_DIR
264
+ uv_tool_indicators = [
265
+ "uv/tools",
266
+ "uv\\tools", # Windows
267
+ os.environ.get("UV_TOOL_DIR", ""),
268
+ ]
269
+ if any(ind and ind.lower() in prefix for ind in uv_tool_indicators if ind):
270
+ return "uv_tool"
271
+
272
+ # Check for uv environment (regular uv pip install in venv)
273
+ # uv uses UV_PYTHON_INSTALL_DIR and creates venvs differently
274
+ uv_indicators = [
275
+ os.environ.get("UV_PYTHON_INSTALL_DIR", ""),
276
+ os.environ.get("UV_CACHE_DIR", ""),
277
+ ]
278
+ # uv environments often have .uv in the path or UV env vars set
279
+ if ".uv" in prefix or any(ind for ind in uv_indicators if ind):
280
+ return "uv"
281
+
282
+ # Check if uv is available and likely the preferred tool
283
+ # (only if we can't detect pipx context)
284
+ if shutil.which("uv"):
285
+ return "uv"
286
+
287
+ # Check if pipx is available as fallback
288
+ if shutil.which("pipx"):
289
+ return "pipx"
290
+
291
+ # Default to pip
292
+ return "pip"
293
+
294
+
295
+ def get_update_command(method: str) -> str:
296
+ """
297
+ Return the appropriate update command for the given install method.
298
+
299
+ Args:
300
+ method: One of 'pipx', 'uv_tool', 'uv', 'pip', 'editable'
301
+
302
+ Returns:
303
+ Shell command to run for updating
304
+ """
305
+ if method == "pipx":
306
+ return f"pipx upgrade {PACKAGE_NAME}"
307
+ elif method == "uv_tool":
308
+ return f"uv tool upgrade {PACKAGE_NAME}"
309
+ elif method == "uv":
310
+ return f"uv pip install --upgrade {PACKAGE_NAME}"
311
+ else:
312
+ return f"pip install --upgrade {PACKAGE_NAME}"
313
+
314
+
315
+ # ═══════════════════════════════════════════════════════════════════════════════
316
+ # Update Check Throttling
317
+ # ═══════════════════════════════════════════════════════════════════════════════
318
+
319
+
320
+ def _load_update_check_meta() -> dict[Any, Any]:
321
+ """Load and return update check metadata (timestamps for throttling)."""
322
+ if not UPDATE_CHECK_META_FILE.exists():
323
+ return {}
324
+ try:
325
+ return cast(dict[Any, Any], json.loads(UPDATE_CHECK_META_FILE.read_text()))
326
+ except (json.JSONDecodeError, OSError):
327
+ return {}
328
+
329
+
330
+ def _save_update_check_meta(meta: dict[str, Any]) -> None:
331
+ """Save update check metadata to disk."""
332
+ UPDATE_CHECK_CACHE_DIR.mkdir(parents=True, exist_ok=True)
333
+ UPDATE_CHECK_META_FILE.write_text(json.dumps(meta, indent=2))
334
+
335
+
336
+ def _should_check_cli_updates() -> bool:
337
+ """Return True if enough time has passed since last CLI update check."""
338
+ meta = _load_update_check_meta()
339
+ last_check_str = meta.get("cli_last_check")
340
+
341
+ if not last_check_str:
342
+ return True
343
+
344
+ try:
345
+ last_check = datetime.fromisoformat(last_check_str)
346
+ now = datetime.now(timezone.utc)
347
+ elapsed = now - last_check
348
+ return elapsed > timedelta(hours=CLI_CHECK_INTERVAL_HOURS)
349
+ except (ValueError, TypeError):
350
+ return True
351
+
352
+
353
+ def _mark_cli_check_done() -> None:
354
+ """Update the timestamp for CLI update check."""
355
+ meta = _load_update_check_meta()
356
+ meta["cli_last_check"] = datetime.now(timezone.utc).isoformat()
357
+ _save_update_check_meta(meta)
358
+
359
+
360
+ def _should_check_org_config() -> bool:
361
+ """Return True if enough time has passed since last org config check."""
362
+ meta = _load_update_check_meta()
363
+ last_check_str = meta.get("org_config_last_check")
364
+
365
+ if not last_check_str:
366
+ return True
367
+
368
+ try:
369
+ last_check = datetime.fromisoformat(last_check_str)
370
+ now = datetime.now(timezone.utc)
371
+ elapsed = now - last_check
372
+ return elapsed > timedelta(hours=ORG_CONFIG_CHECK_INTERVAL_HOURS)
373
+ except (ValueError, TypeError):
374
+ return True
375
+
376
+
377
+ def _mark_org_config_check_done() -> None:
378
+ """Update the timestamp for org config check."""
379
+ meta = _load_update_check_meta()
380
+ meta["org_config_last_check"] = datetime.now(timezone.utc).isoformat()
381
+ _save_update_check_meta(meta)
382
+
383
+
384
+ # ═══════════════════════════════════════════════════════════════════════════════
385
+ # Org Config Update Checking
386
+ # ═══════════════════════════════════════════════════════════════════════════════
387
+
388
+
389
+ def check_org_config_update(
390
+ user_config: dict[str, Any], force: bool = False
391
+ ) -> OrgConfigUpdateResult:
392
+ """
393
+ Check for org config updates using ETag conditional fetch.
394
+
395
+ Handle these scenarios:
396
+ - On corporate network: Fetch org config with auth token, update cache
397
+ - Off VPN (offline): Use cached config, skip update check silently
398
+ - Auth token expired/invalid: Use cached config, show warning
399
+ - Never fetched + offline: Return 'no_cache' status
400
+
401
+ Args:
402
+ user_config: User config dict with organization_source
403
+ force: Force check even if throttle interval hasn't elapsed
404
+
405
+ Returns:
406
+ OrgConfigUpdateResult with status and optional message
407
+ """
408
+ # Import here to avoid circular imports
409
+ from scc_cli import remote
410
+
411
+ # Standalone mode - no org config to update
412
+ if user_config.get("standalone"):
413
+ return OrgConfigUpdateResult(status="standalone")
414
+
415
+ # No organization source configured
416
+ org_source = user_config.get("organization_source")
417
+ if not org_source:
418
+ return OrgConfigUpdateResult(status="standalone")
419
+
420
+ url = org_source.get("url")
421
+ if not url:
422
+ return OrgConfigUpdateResult(status="standalone")
423
+
424
+ auth_spec = org_source.get("auth")
425
+
426
+ # Check throttle (unless forced)
427
+ if not force and not _should_check_org_config():
428
+ # Return early - too soon to check
429
+ return OrgConfigUpdateResult(status="throttled")
430
+
431
+ # Try to load existing cache
432
+ cached_config, meta = remote.load_from_cache()
433
+
434
+ # Calculate cache age if available
435
+ cached_age_hours = None
436
+ if meta and meta.get("org_config", {}).get("fetched_at"):
437
+ try:
438
+ fetched_at = datetime.fromisoformat(meta["org_config"]["fetched_at"])
439
+ now = datetime.now(timezone.utc)
440
+ cached_age_hours = (now - fetched_at).total_seconds() / 3600
441
+ except (ValueError, TypeError):
442
+ pass
443
+
444
+ # Resolve auth
445
+ auth = remote.resolve_auth(auth_spec) if auth_spec else None
446
+
447
+ # Get cached ETag for conditional request
448
+ etag = meta.get("org_config", {}).get("etag") if meta else None
449
+
450
+ # Attempt to fetch with ETag
451
+ try:
452
+ config, new_etag, status = remote.fetch_org_config(url, auth=auth, etag=etag)
453
+ except Exception:
454
+ # Network error - use cache silently
455
+ _mark_org_config_check_done()
456
+ if cached_config:
457
+ return OrgConfigUpdateResult(
458
+ status="offline",
459
+ cached_age_hours=cached_age_hours,
460
+ )
461
+ return OrgConfigUpdateResult(status="no_cache")
462
+
463
+ # Mark check as done
464
+ _mark_org_config_check_done()
465
+
466
+ # 304 Not Modified - cache is current
467
+ if status == 304:
468
+ return OrgConfigUpdateResult(
469
+ status="unchanged",
470
+ cached_age_hours=cached_age_hours,
471
+ )
472
+
473
+ # 200 OK - new config available
474
+ if status == 200 and config is not None:
475
+ # Save to cache
476
+ ttl_hours = config.get("defaults", {}).get("cache_ttl_hours", 24)
477
+ remote.save_to_cache(config, url, new_etag, ttl_hours)
478
+ return OrgConfigUpdateResult(
479
+ status="updated",
480
+ message="Organization config updated from remote",
481
+ )
482
+
483
+ # 401/403 - auth failed
484
+ if status in (401, 403):
485
+ if cached_config:
486
+ return OrgConfigUpdateResult(
487
+ status="auth_failed",
488
+ message="Auth failed for org config, using cached version",
489
+ cached_age_hours=cached_age_hours,
490
+ )
491
+ return OrgConfigUpdateResult(
492
+ status="auth_failed",
493
+ message="Auth failed and no cached config available",
494
+ )
495
+
496
+ # Other errors - use cache if available
497
+ if cached_config:
498
+ return OrgConfigUpdateResult(
499
+ status="offline",
500
+ cached_age_hours=cached_age_hours,
501
+ )
502
+
503
+ return OrgConfigUpdateResult(status="no_cache")
504
+
505
+
506
+ # ═══════════════════════════════════════════════════════════════════════════════
507
+ # Combined Update Check
508
+ # ═══════════════════════════════════════════════════════════════════════════════
509
+
510
+
511
+ def check_all_updates(user_config: dict[str, Any], force: bool = False) -> UpdateCheckResult:
512
+ """
513
+ Check for all available updates (CLI and org config).
514
+
515
+ Use this as the main entry point for update checking.
516
+
517
+ Args:
518
+ user_config: User config dict
519
+ force: Force checks even if throttle intervals haven't elapsed
520
+
521
+ Returns:
522
+ UpdateCheckResult with CLI and org config update info
523
+ """
524
+ result = UpdateCheckResult()
525
+
526
+ # Check CLI updates (throttled)
527
+ if force or _should_check_cli_updates():
528
+ result.cli_update = check_for_updates()
529
+ _mark_cli_check_done()
530
+
531
+ # Check org config updates (throttled)
532
+ result.org_config = check_org_config_update(user_config, force=force)
533
+
534
+ return result
535
+
536
+
537
+ # ═══════════════════════════════════════════════════════════════════════════════
538
+ # UX-Friendly Console Output
539
+ # ═══════════════════════════════════════════════════════════════════════════════
540
+
541
+
542
+ def render_update_notification(console: Console, result: UpdateCheckResult) -> None:
543
+ """
544
+ Render update notifications in a UX-friendly way.
545
+
546
+ Design principles:
547
+ - Non-intrusive: Use a single line for most cases
548
+ - Actionable: Show exact command to run
549
+ - Quiet on success: Produce no noise when everything is current
550
+
551
+ Args:
552
+ console: Rich Console instance
553
+ result: UpdateCheckResult from check_all_updates()
554
+ """
555
+ # CLI update notification
556
+ if result.cli_update and result.cli_update.update_available:
557
+ cli = result.cli_update
558
+ update_cmd = get_update_command(cli.install_method)
559
+ console.print(
560
+ f"[cyan]⬆ Update available:[/cyan] "
561
+ f"scc-cli [dim]{cli.current}[/dim] → [green]{cli.latest}[/green] "
562
+ f"[dim]Run: {update_cmd}[/dim]"
563
+ )
564
+
565
+ # Org config notifications (only show warnings/errors)
566
+ if result.org_config:
567
+ org = result.org_config
568
+
569
+ if org.status == "updated":
570
+ console.print("[green]✓[/green] Organization config updated")
571
+
572
+ elif org.status == "auth_failed" and org.cached_age_hours is not None:
573
+ age_str = _format_age(org.cached_age_hours)
574
+ console.print(
575
+ f"[yellow]⚠ Auth failed for org config, using cached version ({age_str} old)[/yellow]"
576
+ )
577
+
578
+ elif org.status == "auth_failed":
579
+ console.print("[red]✗ Auth failed and no cached config available. Run: scc setup[/red]")
580
+
581
+ elif org.status == "no_cache":
582
+ console.print(
583
+ "[yellow]⚠ No organization config cached. Run: scc setup when on network[/yellow]"
584
+ )
585
+
586
+ # Don't show anything for 'unchanged', 'offline', 'standalone', 'throttled'
587
+ # - These are normal states that don't need user attention
588
+
589
+
590
+ def render_update_status_panel(console: Console, result: UpdateCheckResult) -> None:
591
+ """
592
+ Render a detailed update status panel for the `scc update` command.
593
+
594
+ Args:
595
+ console: Rich Console instance
596
+ result: UpdateCheckResult from check_all_updates()
597
+ """
598
+ lines = []
599
+
600
+ # CLI Version section
601
+ lines.append("[bold]CLI Version[/bold]")
602
+ if result.cli_update:
603
+ cli = result.cli_update
604
+ if cli.update_available:
605
+ lines.append(f" Current: {cli.current}")
606
+ lines.append(f" Latest: [green]{cli.latest}[/green] [cyan](update available)[/cyan]")
607
+ lines.append(f" Update: [dim]{get_update_command(cli.install_method)}[/dim]")
608
+ else:
609
+ lines.append(f" [green]✓[/green] {cli.current} (up to date)")
610
+ else:
611
+ lines.append(" [dim]Not checked (throttled)[/dim]")
612
+
613
+ lines.append("")
614
+
615
+ # Org Config section
616
+ lines.append("[bold]Organization Config[/bold]")
617
+ if result.org_config:
618
+ org = result.org_config
619
+
620
+ if org.status == "standalone":
621
+ lines.append(" [dim]Standalone mode (no org config)[/dim]")
622
+
623
+ elif org.status == "updated":
624
+ lines.append(" [green]✓[/green] Updated from remote")
625
+
626
+ elif org.status == "unchanged":
627
+ if org.cached_age_hours is not None:
628
+ age_str = _format_age(org.cached_age_hours)
629
+ lines.append(f" [green]✓[/green] Current (cached {age_str} ago)")
630
+ else:
631
+ lines.append(" [green]✓[/green] Current (unchanged)")
632
+
633
+ elif org.status == "offline":
634
+ if org.cached_age_hours is not None:
635
+ age_str = _format_age(org.cached_age_hours)
636
+ lines.append(f" [yellow]⚠[/yellow] Using cached config ({age_str} old)")
637
+ lines.append(" [dim]Remote check failed (offline?)[/dim]")
638
+ else:
639
+ lines.append(" [yellow]⚠[/yellow] Offline, using cached config")
640
+
641
+ elif org.status == "auth_failed":
642
+ if org.cached_age_hours is not None:
643
+ age_str = _format_age(org.cached_age_hours)
644
+ lines.append(f" [yellow]⚠[/yellow] Auth failed, using cached ({age_str} old)")
645
+ else:
646
+ lines.append(" [red]✗[/red] Auth failed, no cache available")
647
+ lines.append(" [dim]Check your auth token or run: scc setup[/dim]")
648
+
649
+ elif org.status == "no_cache":
650
+ lines.append(" [red]✗[/red] No cached config available")
651
+ lines.append(" [dim]Run: scc setup when on network[/dim]")
652
+
653
+ elif org.status == "throttled":
654
+ lines.append(" [dim]Not checked (throttled)[/dim]")
655
+ else:
656
+ lines.append(" [dim]Not checked[/dim]")
657
+
658
+ panel = Panel(
659
+ "\n".join(lines),
660
+ title="[bold]Update Status[/bold]",
661
+ border_style="blue",
662
+ padding=(0, 1),
663
+ )
664
+
665
+ console.print()
666
+ console.print(panel)
667
+ console.print()
668
+
669
+
670
+ def _format_age(hours: float) -> str:
671
+ """Format an age in hours as a human-readable string."""
672
+ if hours < 1:
673
+ minutes = int(hours * 60)
674
+ return f"{minutes} minute{'s' if minutes != 1 else ''}"
675
+ elif hours < 24:
676
+ h = int(hours)
677
+ return f"{h} hour{'s' if h != 1 else ''}"
678
+ else:
679
+ days = int(hours / 24)
680
+ return f"{days} day{'s' if days != 1 else ''}"