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/utils/ttl.py ADDED
@@ -0,0 +1,376 @@
1
+ """Provide TTL parsing and expiration utilities for SCC Phase 2.1.
2
+
3
+ Handle parsing of time-bounded exception durations:
4
+ - TTL format: 30m, 2h, 8h, 1d
5
+ - RFC3339 timestamps: 2025-12-21T17:00:00+01:00
6
+ - Time-of-day: HH:MM (next occurrence)
7
+
8
+ Key behaviors:
9
+ - All internal timestamps are UTC
10
+ - --until uses local timezone, always schedules next occurrence
11
+ - DST edge cases (missing/ambiguous times) raise errors with guidance
12
+ - Hard max limit of 24h (configurable in future phases)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ from datetime import date, datetime, timedelta, timezone
19
+ from zoneinfo import ZoneInfo
20
+
21
+ # ═══════════════════════════════════════════════════════════════════════════════
22
+ # Constants
23
+ # ═══════════════════════════════════════════════════════════════════════════════
24
+
25
+ DEFAULT_TTL = timedelta(hours=8)
26
+ MAX_TTL = timedelta(hours=24)
27
+
28
+ # Pattern for TTL format: 30m, 8h, 1d
29
+ TTL_PATTERN = re.compile(r"^(\d+)([mhdMHD])$")
30
+
31
+ # Pattern for HH:MM format
32
+ UNTIL_PATTERN = re.compile(r"^(\d{2}):(\d{2})$")
33
+
34
+
35
+ # ═══════════════════════════════════════════════════════════════════════════════
36
+ # Internal Helpers (for test mocking)
37
+ # ═══════════════════════════════════════════════════════════════════════════════
38
+
39
+
40
+ def _get_now() -> datetime:
41
+ """Get current UTC time. Extracted for test mocking."""
42
+ return datetime.now(timezone.utc)
43
+
44
+
45
+ def _get_local_tz() -> ZoneInfo | timezone:
46
+ """Get local timezone. Extracted for test mocking."""
47
+ try:
48
+ import os
49
+
50
+ # Try to get proper timezone from TZ environment variable
51
+ tz_env = os.environ.get("TZ")
52
+ if tz_env:
53
+ return ZoneInfo(tz_env)
54
+ # Fallback: use UTC offset from system
55
+ local_offset = datetime.now().astimezone().utcoffset()
56
+ if local_offset is not None:
57
+ return timezone(local_offset)
58
+ except Exception:
59
+ pass
60
+ return timezone.utc
61
+
62
+
63
+ # ═══════════════════════════════════════════════════════════════════════════════
64
+ # TTL Parsing (--ttl)
65
+ # ═══════════════════════════════════════════════════════════════════════════════
66
+
67
+
68
+ def parse_ttl(ttl_string: str) -> timedelta:
69
+ """Parse a TTL duration string like '30m', '8h', or '1d'.
70
+
71
+ Args:
72
+ ttl_string: Duration in format like "30m", "8h", "1d"
73
+
74
+ Returns:
75
+ timedelta representing the duration
76
+
77
+ Raises:
78
+ ValueError: If format is invalid or duration is non-positive
79
+ """
80
+ if not ttl_string:
81
+ raise ValueError("TTL cannot be empty")
82
+
83
+ match = TTL_PATTERN.match(ttl_string)
84
+ if not match:
85
+ raise ValueError(
86
+ f"Invalid TTL '{ttl_string}'. "
87
+ f"Use: --ttl 8h, --ttl 30m, or --expires-at 2025-12-21T17:00:00+01:00"
88
+ )
89
+
90
+ value = int(match.group(1))
91
+ unit = match.group(2).lower()
92
+
93
+ if value <= 0:
94
+ raise ValueError("TTL must be positive")
95
+
96
+ if unit == "m":
97
+ return timedelta(minutes=value)
98
+ elif unit == "h":
99
+ return timedelta(hours=value)
100
+ elif unit == "d":
101
+ return timedelta(days=value)
102
+ else:
103
+ raise ValueError(f"Unknown TTL unit: {unit}")
104
+
105
+
106
+ def validate_ttl_duration(duration: timedelta) -> None:
107
+ """Validate that a TTL duration is within allowed limits.
108
+
109
+ Args:
110
+ duration: The duration to validate
111
+
112
+ Raises:
113
+ ValueError: If duration exceeds MAX_TTL
114
+ """
115
+ if duration > MAX_TTL:
116
+ max_hours = int(MAX_TTL.total_seconds() // 3600)
117
+ raise ValueError(f"TTL exceeds maximum allowed duration of {max_hours} hours")
118
+
119
+
120
+ # ═══════════════════════════════════════════════════════════════════════════════
121
+ # RFC3339 Parsing (--expires-at)
122
+ # ═══════════════════════════════════════════════════════════════════════════════
123
+
124
+
125
+ def parse_expires_at(timestamp: str) -> datetime:
126
+ """Parse an RFC3339 timestamp for expiration.
127
+
128
+ Args:
129
+ timestamp: RFC3339 formatted timestamp like "2025-12-21T17:00:00Z"
130
+
131
+ Returns:
132
+ datetime with timezone info (converted to UTC internally)
133
+
134
+ Raises:
135
+ ValueError: If format is invalid or time is in the past
136
+ """
137
+ try:
138
+ # Handle Z suffix (UTC)
139
+ if timestamp.endswith("Z"):
140
+ timestamp = timestamp[:-1] + "+00:00"
141
+
142
+ dt = datetime.fromisoformat(timestamp)
143
+
144
+ # Ensure timezone-aware
145
+ if dt.tzinfo is None:
146
+ raise ValueError(
147
+ "Timestamp must include timezone. "
148
+ "Format: --expires-at 2025-12-21T17:00:00+01:00 or 2025-12-21T17:00:00Z"
149
+ )
150
+
151
+ except ValueError as e:
152
+ if "Invalid isoformat" in str(e) or "fromisoformat" in str(e):
153
+ raise ValueError(
154
+ "Invalid timestamp format. Use RFC3339: --expires-at 2025-12-21T17:00:00+01:00"
155
+ ) from e
156
+ raise
157
+
158
+ # Check not in the past
159
+ now = _get_now()
160
+ if dt.astimezone(timezone.utc) <= now:
161
+ raise ValueError("Expiration time is in the past. Provide a future timestamp.")
162
+
163
+ return dt.astimezone(timezone.utc)
164
+
165
+
166
+ # ═══════════════════════════════════════════════════════════════════════════════
167
+ # Time-of-Day Parsing (--until)
168
+ # ═══════════════════════════════════════════════════════════════════════════════
169
+
170
+
171
+ def parse_until(time_string: str) -> datetime:
172
+ """Parse a time-of-day string like '17:00' for next occurrence.
173
+
174
+ Args:
175
+ time_string: Time in HH:MM format
176
+
177
+ Returns:
178
+ datetime (in local timezone) for next occurrence of that time
179
+
180
+ Raises:
181
+ ValueError: If format is invalid, time doesn't exist (DST spring-forward),
182
+ or time is ambiguous (DST fall-back)
183
+ """
184
+ match = UNTIL_PATTERN.match(time_string)
185
+ if not match:
186
+ raise ValueError(
187
+ f"Invalid time format '{time_string}'. Use HH:MM format like: --until 17:00"
188
+ )
189
+
190
+ hour = int(match.group(1))
191
+ minute = int(match.group(2))
192
+
193
+ if hour > 23:
194
+ raise ValueError(f"Invalid hour: {hour}. Must be 00-23.")
195
+ if minute > 59:
196
+ raise ValueError(f"Invalid minute: {minute}. Must be 00-59.")
197
+
198
+ now = _get_now()
199
+ local_tz = _get_local_tz()
200
+
201
+ # Convert current time to local timezone
202
+ now_local = now.astimezone(local_tz)
203
+
204
+ # Start with today's date at the specified time
205
+ target_date = now_local.date()
206
+ target_time_today = _make_local_datetime(target_date, hour, minute, local_tz)
207
+
208
+ # If time has passed or is exactly now, schedule for tomorrow
209
+ if target_time_today is None or target_time_today <= now_local:
210
+ tomorrow = target_date + timedelta(days=1)
211
+ target_time_tomorrow = _make_local_datetime(tomorrow, hour, minute, local_tz)
212
+ if target_time_tomorrow is None:
213
+ raise ValueError(
214
+ f"Time {time_string} does not exist tomorrow due to DST transition. "
215
+ f"Use --expires-at with explicit UTC offset instead."
216
+ )
217
+ return target_time_tomorrow.astimezone(timezone.utc)
218
+
219
+ return target_time_today.astimezone(timezone.utc)
220
+
221
+
222
+ def _make_local_datetime(
223
+ target_date: date, hour: int, minute: int, tz: ZoneInfo | timezone
224
+ ) -> datetime | None:
225
+ """Create a datetime in local timezone, handling DST edge cases.
226
+
227
+ Returns:
228
+ datetime if valid, None if time doesn't exist (spring-forward)
229
+
230
+ Raises:
231
+ ValueError: If time is ambiguous (fall-back)
232
+ """
233
+ try:
234
+ # Create naive datetime first
235
+ naive_dt = datetime(target_date.year, target_date.month, target_date.day, hour, minute, 0)
236
+
237
+ # For ZoneInfo timezones, check for DST issues
238
+ if isinstance(tz, ZoneInfo):
239
+ # Try to localize - this can raise for ambiguous times
240
+ try:
241
+ # Use fold=0 first, then check if fold=1 gives different result
242
+ dt_fold0 = naive_dt.replace(tzinfo=tz, fold=0)
243
+ dt_fold1 = naive_dt.replace(tzinfo=tz, fold=1)
244
+
245
+ # If the UTC offsets differ, time is ambiguous (fall-back)
246
+ if dt_fold0.utcoffset() != dt_fold1.utcoffset():
247
+ raise ValueError(
248
+ f"Time {hour:02d}:{minute:02d} is ambiguous due to DST transition. "
249
+ f"Use --expires-at with explicit UTC offset instead."
250
+ )
251
+
252
+ # Check if time exists (spring-forward case)
253
+ # After localization, convert back to naive and check if it matches
254
+ utc_time = dt_fold0.astimezone(timezone.utc)
255
+ back_to_local = utc_time.astimezone(tz)
256
+ if back_to_local.hour != hour or back_to_local.minute != minute:
257
+ # Time doesn't exist (was skipped in spring-forward)
258
+ return None
259
+
260
+ return dt_fold0
261
+
262
+ except Exception as e:
263
+ if "ambiguous" in str(e).lower():
264
+ raise ValueError(
265
+ f"Time {hour:02d}:{minute:02d} is ambiguous due to DST transition. "
266
+ f"Use --expires-at with explicit UTC offset instead."
267
+ ) from e
268
+ raise
269
+ else:
270
+ # Simple timezone (like UTC or fixed offset)
271
+ return naive_dt.replace(tzinfo=tz)
272
+
273
+ except ValueError:
274
+ raise
275
+
276
+
277
+ # ═══════════════════════════════════════════════════════════════════════════════
278
+ # Expiration Calculation
279
+ # ═══════════════════════════════════════════════════════════════════════════════
280
+
281
+
282
+ def calculate_expiration(
283
+ ttl: str | None = None,
284
+ expires_at: str | None = None,
285
+ until: str | None = None,
286
+ ) -> datetime:
287
+ """Calculate expiration datetime from one of the timing options.
288
+
289
+ Args:
290
+ ttl: Duration string like "8h"
291
+ expires_at: RFC3339 timestamp
292
+ until: Time-of-day like "17:00"
293
+
294
+ Returns:
295
+ datetime in UTC for expiration
296
+
297
+ Raises:
298
+ ValueError: If multiple options specified (mutually exclusive)
299
+ """
300
+ # Count how many options were provided
301
+ provided = sum(1 for opt in [ttl, expires_at, until] if opt is not None)
302
+
303
+ if provided > 1:
304
+ raise ValueError(
305
+ "Only one of --ttl, --expires-at, or --until can be specified. "
306
+ "These options are mutually exclusive."
307
+ )
308
+
309
+ if ttl is not None:
310
+ duration = parse_ttl(ttl)
311
+ validate_ttl_duration(duration)
312
+ return _get_now() + duration
313
+
314
+ if expires_at is not None:
315
+ return parse_expires_at(expires_at)
316
+
317
+ if until is not None:
318
+ return parse_until(until)
319
+
320
+ # Default: use DEFAULT_TTL
321
+ return _get_now() + DEFAULT_TTL
322
+
323
+
324
+ # ═══════════════════════════════════════════════════════════════════════════════
325
+ # Formatting
326
+ # ═══════════════════════════════════════════════════════════════════════════════
327
+
328
+
329
+ def format_expiration(dt: datetime) -> str:
330
+ """Format a datetime as RFC3339 string.
331
+
332
+ Args:
333
+ dt: datetime to format (should be timezone-aware)
334
+
335
+ Returns:
336
+ RFC3339 formatted string like "2025-12-21T17:00:00Z"
337
+ """
338
+ # Convert to UTC and format with Z suffix
339
+ utc_dt = dt.astimezone(timezone.utc)
340
+ return utc_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
341
+
342
+
343
+ def format_relative(expires: datetime) -> str:
344
+ """Format remaining time until expiration as human-readable string.
345
+
346
+ Args:
347
+ expires: expiration datetime
348
+
349
+ Returns:
350
+ String like "7h45m", "30m", "1d", or "expired"
351
+ """
352
+ now = _get_now()
353
+ delta = expires - now
354
+
355
+ if delta.total_seconds() <= 0:
356
+ return "expired"
357
+
358
+ total_seconds = int(delta.total_seconds())
359
+ days = total_seconds // 86400
360
+ hours = (total_seconds % 86400) // 3600
361
+ minutes = (total_seconds % 3600) // 60
362
+
363
+ if days > 0:
364
+ if hours == 0 and minutes == 0:
365
+ return f"{days}d"
366
+ elif hours > 0:
367
+ return f"{days}d{hours}h"
368
+ else:
369
+ return f"{days}d{minutes}m"
370
+
371
+ if hours > 0:
372
+ if minutes > 0:
373
+ return f"{hours}h{minutes}m"
374
+ return f"{hours}h"
375
+
376
+ return f"{minutes}m"