scc-cli 1.4.0__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 (112) 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 +683 -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 +1400 -0
  16. scc_cli/cli_org.py +1433 -0
  17. scc_cli/cli_support.py +322 -0
  18. scc_cli/cli_team.py +858 -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 +603 -0
  30. scc_cli/doctor/__init__.py +99 -0
  31. scc_cli/doctor/checks.py +1082 -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 +1405 -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/compute.py +377 -0
  46. scc_cli/marketplace/constants.py +87 -0
  47. scc_cli/marketplace/managed.py +135 -0
  48. scc_cli/marketplace/materialize.py +723 -0
  49. scc_cli/marketplace/normalize.py +548 -0
  50. scc_cli/marketplace/render.py +238 -0
  51. scc_cli/marketplace/resolve.py +459 -0
  52. scc_cli/marketplace/schema.py +502 -0
  53. scc_cli/marketplace/sync.py +257 -0
  54. scc_cli/marketplace/team_cache.py +195 -0
  55. scc_cli/marketplace/team_fetch.py +688 -0
  56. scc_cli/marketplace/trust.py +244 -0
  57. scc_cli/models/__init__.py +41 -0
  58. scc_cli/models/exceptions.py +273 -0
  59. scc_cli/models/plugin_audit.py +434 -0
  60. scc_cli/org_templates.py +269 -0
  61. scc_cli/output_mode.py +167 -0
  62. scc_cli/panels.py +113 -0
  63. scc_cli/platform.py +350 -0
  64. scc_cli/profiles.py +1034 -0
  65. scc_cli/remote.py +443 -0
  66. scc_cli/schemas/__init__.py +1 -0
  67. scc_cli/schemas/org-v1.schema.json +456 -0
  68. scc_cli/schemas/team-config.v1.schema.json +163 -0
  69. scc_cli/sessions.py +425 -0
  70. scc_cli/setup.py +582 -0
  71. scc_cli/source_resolver.py +470 -0
  72. scc_cli/stats.py +378 -0
  73. scc_cli/stores/__init__.py +13 -0
  74. scc_cli/stores/exception_store.py +251 -0
  75. scc_cli/subprocess_utils.py +88 -0
  76. scc_cli/teams.py +339 -0
  77. scc_cli/templates/__init__.py +2 -0
  78. scc_cli/templates/org/__init__.py +0 -0
  79. scc_cli/templates/org/minimal.json +19 -0
  80. scc_cli/templates/org/reference.json +74 -0
  81. scc_cli/templates/org/strict.json +38 -0
  82. scc_cli/templates/org/teams.json +42 -0
  83. scc_cli/templates/statusline.sh +75 -0
  84. scc_cli/theme.py +348 -0
  85. scc_cli/ui/__init__.py +124 -0
  86. scc_cli/ui/branding.py +68 -0
  87. scc_cli/ui/chrome.py +395 -0
  88. scc_cli/ui/dashboard/__init__.py +62 -0
  89. scc_cli/ui/dashboard/_dashboard.py +669 -0
  90. scc_cli/ui/dashboard/loaders.py +369 -0
  91. scc_cli/ui/dashboard/models.py +184 -0
  92. scc_cli/ui/dashboard/orchestrator.py +337 -0
  93. scc_cli/ui/formatters.py +443 -0
  94. scc_cli/ui/gate.py +350 -0
  95. scc_cli/ui/help.py +157 -0
  96. scc_cli/ui/keys.py +521 -0
  97. scc_cli/ui/list_screen.py +431 -0
  98. scc_cli/ui/picker.py +700 -0
  99. scc_cli/ui/prompts.py +200 -0
  100. scc_cli/ui/wizard.py +490 -0
  101. scc_cli/update.py +680 -0
  102. scc_cli/utils/__init__.py +39 -0
  103. scc_cli/utils/fixit.py +264 -0
  104. scc_cli/utils/fuzzy.py +124 -0
  105. scc_cli/utils/locks.py +101 -0
  106. scc_cli/utils/ttl.py +376 -0
  107. scc_cli/validate.py +455 -0
  108. scc_cli-1.4.0.dist-info/METADATA +369 -0
  109. scc_cli-1.4.0.dist-info/RECORD +112 -0
  110. scc_cli-1.4.0.dist-info/WHEEL +4 -0
  111. scc_cli-1.4.0.dist-info/entry_points.txt +2 -0
  112. scc_cli-1.4.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,548 @@
1
+ """
2
+ Plugin reference normalization and pattern matching utilities.
3
+
4
+ This module provides:
5
+ - normalize_plugin(): Convert plugin references to canonical form
6
+ - matches_pattern(): Glob pattern matching for plugin filtering
7
+
8
+ Plugin Reference Formats:
9
+ - `name@marketplace`: Standard format (canonical)
10
+ - `@marketplace/name`: npm-style format (normalized to standard)
11
+ - `name`: Auto-resolved based on org marketplaces
12
+
13
+ Auto-Resolution Rules:
14
+ - 0 org marketplaces → resolves to `claude-plugins-official`
15
+ - 1 org marketplace → auto-resolves to that marketplace
16
+ - 2+ marketplaces → explicit `@marketplace` required (raises AmbiguousMarketplaceError)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import fnmatch
22
+ from typing import TYPE_CHECKING, Any
23
+
24
+ if TYPE_CHECKING:
25
+ from scc_cli.marketplace.schema import MarketplaceSource
26
+
27
+
28
+ # ─────────────────────────────────────────────────────────────────────────────
29
+ # Exceptions
30
+ # ─────────────────────────────────────────────────────────────────────────────
31
+
32
+
33
+ class InvalidPluginRefError(ValueError):
34
+ """Raised when a plugin reference is malformed."""
35
+
36
+ def __init__(self, ref: str, reason: str) -> None:
37
+ self.ref = ref
38
+ self.reason = reason
39
+ super().__init__(f"Invalid plugin reference '{ref}': {reason}")
40
+
41
+
42
+ class AmbiguousMarketplaceError(ValueError):
43
+ """Raised when plugin ref needs explicit marketplace qualifier."""
44
+
45
+ def __init__(self, plugin_name: str, available_marketplaces: list[str]) -> None:
46
+ self.plugin_name = plugin_name
47
+ self.available_marketplaces = available_marketplaces
48
+ marketplaces_str = ", ".join(sorted(available_marketplaces))
49
+ super().__init__(
50
+ f"Ambiguous plugin reference '{plugin_name}': "
51
+ f"specify marketplace explicitly (available: {marketplaces_str}). "
52
+ f"Use '{plugin_name}@<marketplace>' or '@<marketplace>/{plugin_name}'."
53
+ )
54
+
55
+
56
+ # ─────────────────────────────────────────────────────────────────────────────
57
+ # Normalization
58
+ # ─────────────────────────────────────────────────────────────────────────────
59
+
60
+
61
+ def normalize_plugin(
62
+ ref: str,
63
+ org_marketplaces: dict[str, Any],
64
+ ) -> str:
65
+ """Normalize a plugin reference to canonical 'name@marketplace' format.
66
+
67
+ Supports three input formats:
68
+ - `name@marketplace`: Already canonical, returned as-is
69
+ - `@marketplace/name`: npm-style, converted to canonical
70
+ - `name`: Auto-resolved based on org marketplace count
71
+
72
+ Args:
73
+ ref: Plugin reference in any supported format
74
+ org_marketplaces: Dict of org-defined marketplaces (keys are marketplace names)
75
+
76
+ Returns:
77
+ Canonical plugin reference in 'name@marketplace' format
78
+
79
+ Raises:
80
+ InvalidPluginRefError: If reference is malformed
81
+ AmbiguousMarketplaceError: If 2+ org marketplaces and no explicit qualifier
82
+
83
+ Examples:
84
+ >>> normalize_plugin("code-review@internal", {})
85
+ 'code-review@internal'
86
+
87
+ >>> normalize_plugin("@internal/code-review", {})
88
+ 'code-review@internal'
89
+
90
+ >>> normalize_plugin("tool", {"internal": {...}})
91
+ 'tool@internal'
92
+
93
+ >>> normalize_plugin("tool", {}) # No org marketplaces
94
+ 'tool@claude-plugins-official'
95
+ """
96
+ # Strip whitespace
97
+ ref = ref.strip()
98
+
99
+ # Validate not empty
100
+ if not ref:
101
+ raise InvalidPluginRefError(ref, "plugin reference cannot be empty")
102
+
103
+ # Check for double @@ which is always invalid
104
+ if "@@" in ref:
105
+ raise InvalidPluginRefError(ref, "invalid double '@' in reference")
106
+
107
+ # Parse the reference format
108
+ if ref.startswith("@"):
109
+ # npm-style: @marketplace/name
110
+ return _parse_npm_style(ref)
111
+ elif "@" in ref:
112
+ # Standard format: name@marketplace
113
+ return _parse_standard_format(ref)
114
+ else:
115
+ # Bare name: auto-resolve marketplace
116
+ return _auto_resolve_marketplace(ref, org_marketplaces)
117
+
118
+
119
+ def _parse_npm_style(ref: str) -> str:
120
+ """Parse @marketplace/name format to canonical form.
121
+
122
+ Args:
123
+ ref: Plugin reference starting with @
124
+
125
+ Returns:
126
+ Canonical 'name@marketplace' format
127
+
128
+ Raises:
129
+ InvalidPluginRefError: If format is invalid
130
+ """
131
+ # Remove leading @
132
+ without_at = ref[1:]
133
+
134
+ # Split on first /
135
+ if "/" not in without_at:
136
+ raise InvalidPluginRefError(ref, "npm-style format requires '/' separator")
137
+
138
+ parts = without_at.split("/", 1)
139
+ marketplace = parts[0]
140
+ name = parts[1]
141
+
142
+ # Validate parts
143
+ if not marketplace:
144
+ raise InvalidPluginRefError(ref, "marketplace name cannot be empty")
145
+ if not name:
146
+ raise InvalidPluginRefError(ref, "plugin name cannot be empty")
147
+
148
+ return f"{name}@{marketplace}"
149
+
150
+
151
+ def _parse_standard_format(ref: str) -> str:
152
+ """Parse name@marketplace format, validating structure.
153
+
154
+ Args:
155
+ ref: Plugin reference containing @
156
+
157
+ Returns:
158
+ Validated canonical format (same as input if valid)
159
+
160
+ Raises:
161
+ InvalidPluginRefError: If format is invalid
162
+ """
163
+ # Split on first @ only
164
+ parts = ref.split("@", 1)
165
+ name = parts[0]
166
+ marketplace = parts[1]
167
+
168
+ # Validate parts
169
+ if not name:
170
+ raise InvalidPluginRefError(ref, "plugin name cannot be empty")
171
+ if not marketplace:
172
+ raise InvalidPluginRefError(ref, "marketplace name cannot be empty")
173
+
174
+ return f"{name}@{marketplace}"
175
+
176
+
177
+ def _auto_resolve_marketplace(
178
+ plugin_name: str,
179
+ org_marketplaces: dict[str, Any],
180
+ ) -> str:
181
+ """Auto-resolve marketplace for bare plugin name.
182
+
183
+ Resolution rules:
184
+ - 0 org marketplaces → claude-plugins-official
185
+ - 1 org marketplace → that marketplace
186
+ - 2+ marketplaces → raise AmbiguousMarketplaceError
187
+
188
+ Args:
189
+ plugin_name: Bare plugin name without marketplace
190
+ org_marketplaces: Dict of org-defined marketplaces
191
+
192
+ Returns:
193
+ Canonical plugin reference with resolved marketplace
194
+
195
+ Raises:
196
+ AmbiguousMarketplaceError: If 2+ org marketplaces defined
197
+ """
198
+ # Validate plugin name
199
+ if not plugin_name:
200
+ raise InvalidPluginRefError(plugin_name, "plugin name cannot be empty")
201
+
202
+ # Count org marketplaces (implicit marketplaces don't count)
203
+ marketplace_count = len(org_marketplaces)
204
+
205
+ if marketplace_count == 0:
206
+ # No org marketplaces → use implicit official
207
+ return f"{plugin_name}@claude-plugins-official"
208
+ elif marketplace_count == 1:
209
+ # Exactly one org marketplace → auto-resolve to it
210
+ marketplace_name = next(iter(org_marketplaces.keys()))
211
+ return f"{plugin_name}@{marketplace_name}"
212
+ else:
213
+ # 2+ org marketplaces → ambiguous, require explicit
214
+ raise AmbiguousMarketplaceError(
215
+ plugin_name,
216
+ list(org_marketplaces.keys()),
217
+ )
218
+
219
+
220
+ # ─────────────────────────────────────────────────────────────────────────────
221
+ # Pattern Matching
222
+ # ─────────────────────────────────────────────────────────────────────────────
223
+
224
+
225
+ def matches_pattern(plugin_ref: str, pattern: str) -> bool:
226
+ """Check if a plugin reference matches a glob pattern.
227
+
228
+ Uses fnmatch for glob-style pattern matching:
229
+ - `*` matches any sequence of characters
230
+ - `?` matches any single character
231
+ - `[seq]` matches any character in seq
232
+ - `[!seq]` matches any character not in seq
233
+
234
+ Args:
235
+ plugin_ref: Canonical plugin reference (name@marketplace)
236
+ pattern: Glob pattern to match against
237
+
238
+ Returns:
239
+ True if plugin matches pattern, False otherwise
240
+
241
+ Examples:
242
+ >>> matches_pattern("code-review@internal", "*@internal")
243
+ True
244
+
245
+ >>> matches_pattern("tool-v1@org", "tool-v?@*")
246
+ True
247
+
248
+ >>> matches_pattern("other@external", "*@internal")
249
+ False
250
+ """
251
+ # Handle empty cases
252
+ if not plugin_ref or not pattern:
253
+ return False
254
+
255
+ # Use fnmatch for glob-style matching with case-insensitive comparison
256
+ # Documentation requires Unicode-aware casefolding for security patterns
257
+ # to prevent bypass attempts using case variations (e.g., "MALICIOUS-*" vs "malicious-*")
258
+ return fnmatch.fnmatch(plugin_ref.casefold(), pattern.casefold())
259
+
260
+
261
+ def matches_any_pattern(plugin_ref: str, patterns: list[str]) -> str | None:
262
+ """Check if a plugin reference matches any pattern in a list.
263
+
264
+ Args:
265
+ plugin_ref: Canonical plugin reference (name@marketplace)
266
+ patterns: List of glob patterns to match against
267
+
268
+ Returns:
269
+ First matching pattern, or None if no match
270
+
271
+ Examples:
272
+ >>> matches_any_pattern("tool@internal", ["*@external", "*@internal"])
273
+ '*@internal'
274
+
275
+ >>> matches_any_pattern("tool@other", ["*@internal"])
276
+ None
277
+ """
278
+ for pattern in patterns:
279
+ if matches_pattern(plugin_ref, pattern):
280
+ return pattern
281
+ return None
282
+
283
+
284
+ # ─────────────────────────────────────────────────────────────────────────────
285
+ # URL Pattern Matching (Phase 2: Federated Team Configs)
286
+ # ─────────────────────────────────────────────────────────────────────────────
287
+
288
+
289
+ def normalize_url_for_matching(url: str) -> str:
290
+ """Normalize a URL for pattern matching.
291
+
292
+ Normalization steps:
293
+ 1. Remove protocol (https://, http://)
294
+ 2. Convert git@host:path format to host/path
295
+ 3. Lowercase the host portion (path case preserved)
296
+ 4. Remove trailing .git suffix
297
+
298
+ Args:
299
+ url: URL in any format (HTTPS, HTTP, SSH git@)
300
+
301
+ Returns:
302
+ Normalized URL string for pattern matching
303
+
304
+ Examples:
305
+ >>> normalize_url_for_matching("https://github.com/sundsvall/plugins")
306
+ 'github.com/sundsvall/plugins'
307
+
308
+ >>> normalize_url_for_matching("git@github.com:sundsvall/plugins.git")
309
+ 'github.com/sundsvall/plugins'
310
+ """
311
+ if not url:
312
+ return ""
313
+
314
+ normalized = url
315
+
316
+ # Remove protocol prefix
317
+ if normalized.startswith("https://"):
318
+ normalized = normalized[8:]
319
+ elif normalized.startswith("http://"):
320
+ normalized = normalized[7:]
321
+
322
+ # Convert git@host:path to host/path
323
+ if normalized.startswith("git@"):
324
+ normalized = normalized[4:]
325
+ # Replace first : with / (the colon separates host from path in SSH URLs)
326
+ if ":" in normalized:
327
+ normalized = normalized.replace(":", "/", 1)
328
+
329
+ # Remove trailing .git
330
+ if normalized.endswith(".git"):
331
+ normalized = normalized[:-4]
332
+
333
+ # Lowercase the host portion only (preserve path case)
334
+ if "/" in normalized:
335
+ parts = normalized.split("/", 1)
336
+ host = parts[0].lower()
337
+ path = parts[1]
338
+ normalized = f"{host}/{path}"
339
+ else:
340
+ normalized = normalized.lower()
341
+
342
+ return normalized
343
+
344
+
345
+ def fnmatch_with_globstar(text: str, pattern: str) -> bool:
346
+ """Extended fnmatch supporting globstar (**) for recursive matching.
347
+
348
+ Globstar rules:
349
+ - ** matches zero or more path segments (including /)
350
+ - * matches within a single segment (no /)
351
+ - Other fnmatch patterns work normally
352
+
353
+ Args:
354
+ text: Text to match against
355
+ pattern: Glob pattern with optional ** support
356
+
357
+ Returns:
358
+ True if text matches pattern
359
+
360
+ Examples:
361
+ >>> fnmatch_with_globstar("github.com/a/b/c/repo", "github.com/**/repo")
362
+ True
363
+
364
+ >>> fnmatch_with_globstar("github.com/org/repo", "github.com/*/repo")
365
+ True
366
+ """
367
+ if not text or not pattern:
368
+ return False
369
+
370
+ import re
371
+
372
+ # Apply Unicode-aware casefolding for case-insensitive matching
373
+ # This prevents bypass attempts using case variations in URL patterns
374
+ text = text.casefold()
375
+ pattern = pattern.casefold()
376
+
377
+ # Convert glob pattern to regex
378
+ # Key insight: * must NOT cross / boundaries, ** CAN cross them
379
+ regex = ""
380
+ i = 0
381
+
382
+ while i < len(pattern):
383
+ # Check for ** (globstar)
384
+ if pattern[i : i + 2] == "**":
385
+ # Lookahead/behind to determine context
386
+ has_slash_before = i > 0 and pattern[i - 1] == "/"
387
+ has_slash_after = i + 2 < len(pattern) and pattern[i + 2] == "/"
388
+
389
+ if has_slash_before and has_slash_after:
390
+ # /**/ - matches zero or more segments
391
+ # The preceding / is already in regex, consume the following /
392
+ # Pattern: (?:[^/]+/)* matches x/ or x/y/ or empty
393
+ regex += "(?:[^/]+/)*"
394
+ i += 3 # Skip ** and the following /
395
+ elif has_slash_before:
396
+ # /** at end - matches zero or more segments after /
397
+ regex += "(?:[^/]+(?:/[^/]+)*)?$"
398
+ i += 2
399
+ elif has_slash_after:
400
+ # **/ at start - matches zero or more segments before /
401
+ regex += "(?:[^/]+/)*"
402
+ i += 3 # Skip ** and the following /
403
+ else:
404
+ # Standalone ** - matches anything
405
+ regex += ".*"
406
+ i += 2
407
+
408
+ elif pattern[i] == "*":
409
+ # Single * - matches within segment (no /)
410
+ regex += "[^/]*"
411
+ i += 1
412
+
413
+ elif pattern[i] == "?":
414
+ # ? - matches single char except /
415
+ regex += "[^/]"
416
+ i += 1
417
+
418
+ elif pattern[i] == "[":
419
+ # Character class - find matching ]
420
+ end = pattern.find("]", i + 1)
421
+ if end != -1:
422
+ regex += pattern[i : end + 1]
423
+ i = end + 1
424
+ else:
425
+ regex += re.escape(pattern[i])
426
+ i += 1
427
+
428
+ else:
429
+ # Literal character
430
+ regex += re.escape(pattern[i])
431
+ i += 1
432
+
433
+ try:
434
+ return bool(re.match(f"^{regex}$", text))
435
+ except re.error:
436
+ return False
437
+
438
+
439
+ def matches_url_pattern(url: str, pattern: str) -> bool:
440
+ """Check if a URL matches a pattern with globstar support.
441
+
442
+ Combines URL normalization with globstar matching:
443
+ 1. Normalizes the URL (removes protocol, handles SSH format)
444
+ 2. Uses fnmatch_with_globstar for pattern matching
445
+
446
+ Args:
447
+ url: URL to match (HTTPS, HTTP, or SSH format)
448
+ pattern: Glob pattern (host/path format, supports ** and *)
449
+
450
+ Returns:
451
+ True if normalized URL matches pattern
452
+
453
+ Examples:
454
+ >>> matches_url_pattern("https://github.com/sundsvall/plugins", "github.com/sundsvall/**")
455
+ True
456
+
457
+ >>> matches_url_pattern("git@github.com:org/repo.git", "github.com/org/**")
458
+ True
459
+ """
460
+ if not url or not pattern:
461
+ return False
462
+
463
+ normalized = normalize_url_for_matching(url)
464
+ return fnmatch_with_globstar(normalized, pattern)
465
+
466
+
467
+ def matches_any_url_pattern(url: str, patterns: list[str]) -> str | None:
468
+ """Check if a URL matches any pattern in a list.
469
+
470
+ Args:
471
+ url: URL to match
472
+ patterns: List of glob patterns
473
+
474
+ Returns:
475
+ First matching pattern, or None if no match
476
+
477
+ Examples:
478
+ >>> matches_any_url_pattern("https://github.com/org/repo", ["github.com/**"])
479
+ 'github.com/**'
480
+
481
+ >>> matches_any_url_pattern("https://other.com/repo", ["github.com/**"])
482
+ None
483
+ """
484
+ for pattern in patterns:
485
+ if matches_url_pattern(url, pattern):
486
+ return pattern
487
+ return None
488
+
489
+
490
+ # ─────────────────────────────────────────────────────────────────────────────
491
+ # Source URL Extraction (Phase 2: Federated Team Configs)
492
+ # ─────────────────────────────────────────────────────────────────────────────
493
+
494
+
495
+ def get_source_url(source: MarketplaceSource) -> str:
496
+ """Extract URL from any MarketplaceSource type for pattern matching.
497
+
498
+ Converts the various source types to a normalized URL format suitable
499
+ for matching against trust grant patterns.
500
+
501
+ Args:
502
+ source: MarketplaceSource of any type (GitHub, Git, URL, Directory)
503
+
504
+ Returns:
505
+ URL string for pattern matching:
506
+ - GitHub: github.com/{owner}/{repo}
507
+ - Git: the url field directly
508
+ - URL: the url field directly
509
+ - Directory: the path field (for local sources)
510
+
511
+ Examples:
512
+ >>> from scc_cli.marketplace.schema import MarketplaceSourceGitHub
513
+ >>> source = MarketplaceSourceGitHub(source="github", owner="org", repo="plugins")
514
+ >>> get_source_url(source)
515
+ 'https://github.com/org/plugins'
516
+
517
+ >>> from scc_cli.marketplace.schema import MarketplaceSourceGit
518
+ >>> source = MarketplaceSourceGit(source="git", url="https://gitlab.example.se/ai/plugins.git")
519
+ >>> get_source_url(source)
520
+ 'https://gitlab.example.se/ai/plugins.git'
521
+ """
522
+ # Import here to avoid circular imports
523
+ from scc_cli.marketplace.schema import (
524
+ MarketplaceSourceDirectory,
525
+ MarketplaceSourceGit,
526
+ MarketplaceSourceGitHub,
527
+ MarketplaceSourceURL,
528
+ )
529
+
530
+ if isinstance(source, MarketplaceSourceGitHub):
531
+ # Build GitHub URL from owner/repo
532
+ return f"https://github.com/{source.owner}/{source.repo}"
533
+
534
+ elif isinstance(source, MarketplaceSourceGit):
535
+ # Use the Git URL directly
536
+ return source.url
537
+
538
+ elif isinstance(source, MarketplaceSourceURL):
539
+ # Use the URL directly
540
+ return source.url
541
+
542
+ elif isinstance(source, MarketplaceSourceDirectory):
543
+ # Use the path for local directories
544
+ return source.path
545
+
546
+ else:
547
+ # Fallback for unknown types - should not happen
548
+ return ""