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,470 @@
1
+ """
2
+ Source resolver for organization config imports.
3
+
4
+ Resolves human-friendly source strings to fetchable URLs or file paths.
5
+ Supports GitHub/GitLab shorthands while keeping the runtime fetch-only (no git clone).
6
+
7
+ Resolution precedence (order matters to avoid collisions):
8
+ 1. Local file: exists on disk OR starts with ./ ../ / ~ OR matches Windows drive
9
+ 2. URL: starts with http:// or https://
10
+ 3. Shorthand: github: / gitlab: / <host>: patterns
11
+ 4. Error: unknown format with examples
12
+
13
+ Examples:
14
+ # Direct HTTPS
15
+ https://example.com/org-config.json
16
+
17
+ # Local file
18
+ ./org-config.json
19
+ file:./org-config.json
20
+
21
+ # GitHub shorthand
22
+ github:sundsvall/scc-org:org.json # default branch (floating)
23
+ github:sundsvall/scc-org@v1.2.0:org.json # tag (pinned)
24
+ github:sundsvall/scc-org@abc1234:org.json # SHA (pinned)
25
+
26
+ # GitLab shorthand
27
+ gitlab:myco/platform/scc@v1.0:org.json
28
+
29
+ # Self-hosted
30
+ gitlab.mycompany.com:team/config@main:org.json
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import os
36
+ import re
37
+ from dataclasses import dataclass
38
+ from pathlib import Path
39
+ from typing import Literal
40
+
41
+ # ═══════════════════════════════════════════════════════════════════════════════
42
+ # Data Types
43
+ # ═══════════════════════════════════════════════════════════════════════════════
44
+
45
+ SourceProvider = Literal["file", "https", "github", "gitlab", "custom"]
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class ResolvedSource:
50
+ """Result of resolving a source string.
51
+
52
+ Attributes:
53
+ original: The original source string as provided by user.
54
+ resolved_url: The resolved HTTPS URL or file path for fetching.
55
+ provider: The detected provider type.
56
+ host: The host for custom providers (e.g., gitlab.mycompany.com).
57
+ owner: Repository owner/org (for shorthand sources).
58
+ repo: Repository name (for shorthand sources).
59
+ ref: Git reference (tag, SHA, branch) if specified.
60
+ path: Path within the repository.
61
+ is_pinned: True if ref is a tag or SHA (not a branch or default).
62
+ is_file: True if this is a local file path.
63
+ """
64
+
65
+ original: str
66
+ resolved_url: str
67
+ provider: SourceProvider
68
+ host: str | None = None
69
+ owner: str | None = None
70
+ repo: str | None = None
71
+ ref: str | None = None
72
+ path: str | None = None
73
+ is_pinned: bool = False
74
+ is_file: bool = False
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class ResolveError:
79
+ """Error during source resolution.
80
+
81
+ Attributes:
82
+ message: Human-readable error message.
83
+ source: The source string that failed.
84
+ suggestion: Suggested fix or examples.
85
+ """
86
+
87
+ message: str
88
+ source: str
89
+ suggestion: str | None = None
90
+
91
+
92
+ # ═══════════════════════════════════════════════════════════════════════════════
93
+ # Resolution Patterns
94
+ # ═══════════════════════════════════════════════════════════════════════════════
95
+
96
+ # Windows drive pattern: C:\ or D:/
97
+ WINDOWS_DRIVE_PATTERN = re.compile(r"^[A-Za-z]:[/\\]")
98
+
99
+ # GitHub shorthand: github:owner/repo@ref:path or github:owner/repo:path
100
+ GITHUB_PATTERN = re.compile(
101
+ r"^github:(?P<owner>[^/@:]+)/(?P<repo>[^/@:]+)(?:@(?P<ref>[^:]+))?:(?P<path>.+)$"
102
+ )
103
+
104
+ # GitLab shorthand: gitlab:owner/repo@ref:path (supports nested groups)
105
+ GITLAB_PATTERN = re.compile(r"^gitlab:(?P<owner_repo>[^@:]+)(?:@(?P<ref>[^:]+))?:(?P<path>.+)$")
106
+
107
+ # Custom host shorthand: host.com:owner/repo@ref:path
108
+ # Must have at least one dot in host to distinguish from Windows paths
109
+ CUSTOM_HOST_PATTERN = re.compile(
110
+ r"^(?P<host>[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z0-9][-a-zA-Z0-9.]*)"
111
+ r":(?P<owner_repo>[^@:]+)(?:@(?P<ref>[^:]+))?:(?P<path>.+)$"
112
+ )
113
+
114
+ # Pinned ref detection: starts with v followed by number, or is hex SHA
115
+ TAG_PATTERN = re.compile(r"^v?\d+")
116
+ SHA_PATTERN = re.compile(r"^[0-9a-f]{7,40}$", re.IGNORECASE)
117
+
118
+
119
+ # ═══════════════════════════════════════════════════════════════════════════════
120
+ # Helper Functions
121
+ # ═══════════════════════════════════════════════════════════════════════════════
122
+
123
+
124
+ def _is_pinned_ref(ref: str | None) -> bool:
125
+ """Determine if a git ref is pinned (tag or SHA) vs floating (branch).
126
+
127
+ Pinned refs are:
128
+ - Tags: v1.0.0, v2, 1.0.0, etc.
129
+ - SHAs: 7+ character hex strings
130
+
131
+ Everything else (main, develop, feature/x) is considered floating.
132
+ """
133
+ if not ref:
134
+ return False
135
+
136
+ # Check if it's a SHA (7-40 hex characters)
137
+ if SHA_PATTERN.match(ref):
138
+ return True
139
+
140
+ # Check if it looks like a version tag
141
+ if TAG_PATTERN.match(ref):
142
+ return True
143
+
144
+ return False
145
+
146
+
147
+ def _is_local_file_path(source: str) -> bool:
148
+ r"""Check if source looks like a local file path.
149
+
150
+ Checks (in order):
151
+ 1. Starts with explicit file: prefix
152
+ 2. Starts with ./ or ../
153
+ 3. Starts with / (Unix absolute)
154
+ 4. Starts with ~ (home directory)
155
+ 5. Matches Windows drive pattern (C:\, D:/, etc.)
156
+ 6. Actually exists on disk
157
+ """
158
+ # Explicit file: prefix
159
+ if source.startswith("file:"):
160
+ return True
161
+
162
+ # Relative paths
163
+ if source.startswith("./") or source.startswith("../"):
164
+ return True
165
+
166
+ # Unix absolute path
167
+ if source.startswith("/"):
168
+ return True
169
+
170
+ # Home directory
171
+ if source.startswith("~"):
172
+ return True
173
+
174
+ # Windows drive pattern
175
+ if WINDOWS_DRIVE_PATTERN.match(source):
176
+ return True
177
+
178
+ # Check if file actually exists (catches bare filenames like "org.json")
179
+ try:
180
+ path = Path(source).expanduser()
181
+ if path.exists():
182
+ return True
183
+ except (OSError, ValueError):
184
+ pass
185
+
186
+ return False
187
+
188
+
189
+ def _resolve_file_source(source: str) -> ResolvedSource:
190
+ """Resolve a local file source."""
191
+ # Strip file: prefix if present
192
+ path_str = source[5:] if source.startswith("file:") else source
193
+
194
+ # Expand and resolve path
195
+ path = Path(path_str).expanduser().resolve()
196
+
197
+ return ResolvedSource(
198
+ original=source,
199
+ resolved_url=str(path),
200
+ provider="file",
201
+ path=str(path),
202
+ is_pinned=True, # Local files are considered "pinned" (deterministic)
203
+ is_file=True,
204
+ )
205
+
206
+
207
+ def _resolve_github_source(source: str) -> ResolvedSource | ResolveError:
208
+ """Resolve a GitHub shorthand source."""
209
+ match = GITHUB_PATTERN.match(source)
210
+ if not match:
211
+ return ResolveError(
212
+ message="Invalid GitHub source format",
213
+ source=source,
214
+ suggestion="Use: github:owner/repo@ref:path (e.g., github:acme/config@v1.0:org.json)",
215
+ )
216
+
217
+ owner = match.group("owner")
218
+ repo = match.group("repo")
219
+ ref = match.group("ref") or "HEAD" # Default to HEAD (main/master)
220
+ path = match.group("path")
221
+
222
+ # Build raw.githubusercontent.com URL
223
+ resolved_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{ref}/{path}"
224
+
225
+ return ResolvedSource(
226
+ original=source,
227
+ resolved_url=resolved_url,
228
+ provider="github",
229
+ host="github.com",
230
+ owner=owner,
231
+ repo=repo,
232
+ ref=ref if match.group("ref") else None,
233
+ path=path,
234
+ is_pinned=_is_pinned_ref(match.group("ref")),
235
+ is_file=False,
236
+ )
237
+
238
+
239
+ def _resolve_gitlab_source(source: str) -> ResolvedSource | ResolveError:
240
+ """Resolve a GitLab shorthand source."""
241
+ match = GITLAB_PATTERN.match(source)
242
+ if not match:
243
+ return ResolveError(
244
+ message="Invalid GitLab source format",
245
+ source=source,
246
+ suggestion="Use: gitlab:owner/repo@ref:path (e.g., gitlab:acme/config@v1.0:org.json)",
247
+ )
248
+
249
+ owner_repo = match.group("owner_repo") # Can include nested groups
250
+ ref = match.group("ref") or "main"
251
+ path = match.group("path")
252
+
253
+ # Split owner/repo (last segment is repo, rest is owner/group)
254
+ parts = owner_repo.split("/")
255
+ if len(parts) < 2:
256
+ return ResolveError(
257
+ message="GitLab source must include owner/repo",
258
+ source=source,
259
+ suggestion="Use: gitlab:owner/repo@ref:path",
260
+ )
261
+
262
+ owner = "/".join(parts[:-1])
263
+ repo = parts[-1]
264
+
265
+ # Build gitlab.com raw URL
266
+ resolved_url = f"https://gitlab.com/{owner_repo}/-/raw/{ref}/{path}"
267
+
268
+ return ResolvedSource(
269
+ original=source,
270
+ resolved_url=resolved_url,
271
+ provider="gitlab",
272
+ host="gitlab.com",
273
+ owner=owner,
274
+ repo=repo,
275
+ ref=ref if match.group("ref") else None,
276
+ path=path,
277
+ is_pinned=_is_pinned_ref(match.group("ref")),
278
+ is_file=False,
279
+ )
280
+
281
+
282
+ def _resolve_custom_host_source(source: str) -> ResolvedSource | ResolveError:
283
+ """Resolve a custom-hosted GitLab-style source."""
284
+ match = CUSTOM_HOST_PATTERN.match(source)
285
+ if not match:
286
+ return ResolveError(
287
+ message="Invalid custom host source format",
288
+ source=source,
289
+ suggestion="Use: host.com:owner/repo@ref:path",
290
+ )
291
+
292
+ host = match.group("host")
293
+ owner_repo = match.group("owner_repo")
294
+ ref = match.group("ref") or "main"
295
+ path = match.group("path")
296
+
297
+ # Split owner/repo
298
+ parts = owner_repo.split("/")
299
+ if len(parts) < 2:
300
+ return ResolveError(
301
+ message="Custom host source must include owner/repo",
302
+ source=source,
303
+ suggestion=f"Use: {host}:owner/repo@ref:path",
304
+ )
305
+
306
+ owner = "/".join(parts[:-1])
307
+ repo = parts[-1]
308
+
309
+ # Assume GitLab-style raw URL for custom hosts
310
+ resolved_url = f"https://{host}/{owner_repo}/-/raw/{ref}/{path}"
311
+
312
+ return ResolvedSource(
313
+ original=source,
314
+ resolved_url=resolved_url,
315
+ provider="custom",
316
+ host=host,
317
+ owner=owner,
318
+ repo=repo,
319
+ ref=ref if match.group("ref") else None,
320
+ path=path,
321
+ is_pinned=_is_pinned_ref(match.group("ref")),
322
+ is_file=False,
323
+ )
324
+
325
+
326
+ # ═══════════════════════════════════════════════════════════════════════════════
327
+ # Main Resolution Function
328
+ # ═══════════════════════════════════════════════════════════════════════════════
329
+
330
+
331
+ def resolve_source(source: str) -> ResolvedSource | ResolveError:
332
+ """Resolve a source string to a fetchable URL or file path.
333
+
334
+ Resolution precedence (order matters to avoid collisions):
335
+ 1. Local file: exists on disk OR starts with ./ ../ / ~ OR Windows drive
336
+ 2. URL: starts with http:// or https://
337
+ 3. Shorthand: github: / gitlab: / <host>: patterns
338
+ 4. Error: unknown format with examples
339
+
340
+ Args:
341
+ source: The source string to resolve.
342
+
343
+ Returns:
344
+ ResolvedSource on success, ResolveError on failure.
345
+
346
+ Examples:
347
+ >>> resolve_source("./org.json")
348
+ ResolvedSource(provider="file", is_file=True, ...)
349
+
350
+ >>> resolve_source("github:acme/config@v1.0:org.json")
351
+ ResolvedSource(provider="github", is_pinned=True, ...)
352
+
353
+ >>> resolve_source("https://example.com/org.json")
354
+ ResolvedSource(provider="https", ...)
355
+ """
356
+ source = source.strip()
357
+
358
+ if not source:
359
+ return ResolveError(
360
+ message="Empty source string",
361
+ source=source,
362
+ suggestion="Provide a URL, file path, or shorthand (github:owner/repo@ref:path)",
363
+ )
364
+
365
+ # 1. Check for local file path FIRST (prevents collisions)
366
+ if _is_local_file_path(source):
367
+ return _resolve_file_source(source)
368
+
369
+ # 2. Check for HTTPS URL
370
+ if source.startswith("https://"):
371
+ return ResolvedSource(
372
+ original=source,
373
+ resolved_url=source,
374
+ provider="https",
375
+ is_pinned=False, # Can't determine pinning for raw URLs
376
+ is_file=False,
377
+ )
378
+
379
+ # 2b. Reject HTTP (security)
380
+ if source.startswith("http://"):
381
+ return ResolveError(
382
+ message="HTTP not allowed (security risk)",
383
+ source=source,
384
+ suggestion="Use HTTPS: " + source.replace("http://", "https://"),
385
+ )
386
+
387
+ # 3. Check for shorthands
388
+ if source.startswith("github:"):
389
+ return _resolve_github_source(source)
390
+
391
+ if source.startswith("gitlab:"):
392
+ return _resolve_gitlab_source(source)
393
+
394
+ # 3b. Check for custom host shorthand (must have dot in host)
395
+ if ":" in source and "." in source.split(":")[0]:
396
+ result = _resolve_custom_host_source(source)
397
+ if not isinstance(result, ResolveError):
398
+ return result
399
+ # Fall through to unknown format error with better examples
400
+
401
+ # 4. Unknown format
402
+ return ResolveError(
403
+ message="Unknown source format",
404
+ source=source,
405
+ suggestion="""Valid formats:
406
+ • Local file: ./org.json, ~/config/org.json
407
+ • HTTPS URL: https://example.com/org.json
408
+ • GitHub: github:owner/repo@tag:path.json
409
+ • GitLab: gitlab:owner/repo@tag:path.json
410
+ • Custom host: gitlab.company.com:owner/repo@tag:path.json""",
411
+ )
412
+
413
+
414
+ # ═══════════════════════════════════════════════════════════════════════════════
415
+ # Auth Detection
416
+ # ═══════════════════════════════════════════════════════════════════════════════
417
+
418
+
419
+ def detect_auth_env_var(resolved: ResolvedSource) -> str | None:
420
+ """Detect appropriate auth environment variable for a resolved source.
421
+
422
+ Priority:
423
+ 1. SCC_ORG_TOKEN (SCC-specific, always checked first)
424
+ 2. GITHUB_TOKEN (for GitHub sources)
425
+ 3. GITLAB_TOKEN (for GitLab sources)
426
+
427
+ Args:
428
+ resolved: The resolved source to detect auth for.
429
+
430
+ Returns:
431
+ Environment variable name if found and set, None otherwise.
432
+ """
433
+ # SCC-specific token takes priority
434
+ if os.environ.get("SCC_ORG_TOKEN"):
435
+ return "SCC_ORG_TOKEN"
436
+
437
+ # Provider-specific tokens
438
+ if resolved.provider == "github" and os.environ.get("GITHUB_TOKEN"):
439
+ return "GITHUB_TOKEN"
440
+
441
+ if resolved.provider in ("gitlab", "custom") and os.environ.get("GITLAB_TOKEN"):
442
+ return "GITLAB_TOKEN"
443
+
444
+ return None
445
+
446
+
447
+ def build_auth_spec(resolved: ResolvedSource, explicit_auth: str | None = None) -> str | None:
448
+ """Build auth specification for a resolved source.
449
+
450
+ Args:
451
+ resolved: The resolved source.
452
+ explicit_auth: Explicitly provided auth spec (e.g., "env:MY_TOKEN").
453
+
454
+ Returns:
455
+ Auth spec string (e.g., "env:GITHUB_TOKEN") or None if no auth.
456
+ """
457
+ # Explicit auth takes priority
458
+ if explicit_auth:
459
+ return explicit_auth
460
+
461
+ # Local files don't need auth
462
+ if resolved.is_file:
463
+ return None
464
+
465
+ # Detect from environment
466
+ env_var = detect_auth_env_var(resolved)
467
+ if env_var:
468
+ return f"env:{env_var}"
469
+
470
+ return None