specify-cli 1.2.2__py3-none-any.whl → 1.2.4__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.
Files changed (66) hide show
  1. specify_cli/_install_ignore.py +89 -0
  2. specify_cli/_version.py +9 -2
  3. specify_cli/authentication/github.py +130 -9
  4. specify_cli/authentication/http.py +8 -3
  5. specify_cli/bundles/_commands.py +42 -1
  6. specify_cli/bundles/adapters.py +5 -4
  7. specify_cli/bundles/catalog_stack.py +35 -8
  8. specify_cli/bundles/catalogs.py +9 -1
  9. specify_cli/bundles/command_info.py +130 -6
  10. specify_cli/bundles/command_install.py +25 -6
  11. specify_cli/bundles/command_update.py +16 -3
  12. specify_cli/bundles/command_validate.py +72 -11
  13. specify_cli/bundles/component_catalog.py +2 -1
  14. specify_cli/bundles/conflict.py +76 -2
  15. specify_cli/bundles/expansion.py +601 -0
  16. specify_cli/bundles/installer.py +37 -12
  17. specify_cli/bundles/manifest.py +81 -3
  18. specify_cli/bundles/primitives.py +137 -38
  19. specify_cli/bundles/records.py +61 -1
  20. specify_cli/bundles/resolver.py +69 -9
  21. specify_cli/bundles/sources.py +105 -1
  22. specify_cli/bundles/validator.py +8 -3
  23. specify_cli/bundles/versioning.py +11 -0
  24. specify_cli/command_check.py +2 -2
  25. specify_cli/command_init.py +19 -1
  26. specify_cli/core_pack/extensions/github/README.md +2 -2
  27. specify_cli/core_pack/extensions/github/extension.yml +1 -1
  28. specify_cli/core_pack/scripts/powershell/check-prerequisites.ps1 +8 -8
  29. specify_cli/core_pack/scripts/powershell/common.ps1 +2 -2
  30. specify_cli/core_pack/scripts/powershell/setup-tasks.ps1 +6 -6
  31. specify_cli/core_pack/scripts/python/check_prerequisites.py +2 -1
  32. specify_cli/core_pack/scripts/python/resolve_template.py +2 -1
  33. specify_cli/core_pack/scripts/python/setup_plan.py +2 -1
  34. specify_cli/core_pack/scripts/python/setup_tasks.py +2 -1
  35. specify_cli/events/__init__.py +91 -165
  36. specify_cli/extensions/__init__.py +2 -62
  37. specify_cli/extensions/catalog/command_remove.py +8 -1
  38. specify_cli/integrations/__init__.py +2 -0
  39. specify_cli/integrations/base.py +25 -10
  40. specify_cli/integrations/command_list.py +1 -1
  41. specify_cli/integrations/command_upgrade.py +1 -0
  42. specify_cli/integrations/copilot/__init__.py +2 -1
  43. specify_cli/integrations/junie/__init__.py +29 -0
  44. specify_cli/integrations/kimi/__init__.py +29 -0
  45. specify_cli/integrations/none/__init__.py +29 -0
  46. specify_cli/integrations/pi/__init__.py +34 -0
  47. specify_cli/integrations/vibe/__init__.py +448 -5
  48. specify_cli/presets/_manager.py +5 -1
  49. specify_cli/presets/_resolver.py +1 -1
  50. specify_cli/workflows/_mcp.py +377 -0
  51. specify_cli/workflows/engine.py +9 -1
  52. specify_cli/workflows/expressions/__init__.py +63 -0
  53. specify_cli/workflows/expressions/api.py +70 -0
  54. specify_cli/workflows/expressions/diagnostics.py +323 -0
  55. specify_cli/workflows/expressions/engine.py +34 -0
  56. specify_cli/workflows/expressions/engine_current.py +548 -0
  57. specify_cli/workflows/expressions/filters.py +291 -0
  58. specify_cli/workflows/expressions/namespace.py +85 -0
  59. specify_cli/workflows/expressions/operators.py +71 -0
  60. specify_cli/workflows/expressions/template.py +322 -0
  61. {specify_cli-1.2.2.dist-info → specify_cli-1.2.4.dist-info}/METADATA +69 -48
  62. {specify_cli-1.2.2.dist-info → specify_cli-1.2.4.dist-info}/RECORD +65 -53
  63. specify_cli/workflows/expressions.py +0 -1581
  64. {specify_cli-1.2.2.dist-info → specify_cli-1.2.4.dist-info}/WHEEL +0 -0
  65. {specify_cli-1.2.2.dist-info → specify_cli-1.2.4.dist-info}/entry_points.txt +0 -0
  66. {specify_cli-1.2.2.dist-info → specify_cli-1.2.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,89 @@
1
+ """Shared source-directory installation filtering for extensions and presets."""
2
+
3
+ import re
4
+ from pathlib import Path
5
+ from typing import Callable, List, Optional, Set
6
+
7
+ import pathspec
8
+
9
+
10
+ def load_install_ignore(
11
+ source_dir: Path,
12
+ ignore_filename: str,
13
+ validation_error: type[Exception],
14
+ ) -> Optional[Callable[[str, List[str]], Set[str]]]:
15
+ """Load a UTF-8 ignore file as a copytree callback, or return None if absent.
16
+
17
+ Match the extension installer's Git-style patterns, including whitespace
18
+ trimming and backslash normalization. Only the named root ignore file is
19
+ read; there are no default exclusions except the ignore filename itself.
20
+ A non-file path, invalid encoding, or invalid patterns raise the caller's
21
+ domain validation error before copying.
22
+ Excluded directories are pruned by copytree, so child negations cannot
23
+ restore files inside them unless the parent directory is also re-included.
24
+ """
25
+ ignore_file = source_dir / ignore_filename
26
+ if not ignore_file.exists():
27
+ return None
28
+ if not ignore_file.is_file():
29
+ raise validation_error(f"{ignore_filename} is not a regular file: {ignore_file}")
30
+
31
+ # Accept UTF-8 with or without the BOM emitted by Windows PowerShell 5.1.
32
+ # Strip it after decoding so error offsets remain relative to the file.
33
+ try:
34
+ raw = ignore_file.read_text(encoding="utf-8").removeprefix("\ufeff")
35
+ except UnicodeDecodeError as e:
36
+ raise validation_error(
37
+ f"{ignore_filename} is not valid UTF-8: {ignore_file} "
38
+ f"({e.reason} at byte {e.start})"
39
+ )
40
+ lines: List[str] = raw.splitlines()
41
+
42
+ # Normalise backslashes in patterns so Windows-authored files work
43
+ normalised: List[str] = []
44
+ for line in lines:
45
+ stripped = line.strip()
46
+ if stripped and not stripped.startswith("#"):
47
+ normalised.append(stripped.replace("\\", "/"))
48
+ else:
49
+ # Preserve blanks/comments so pathspec line numbers stay stable
50
+ normalised.append(line)
51
+
52
+ # Always ignore the ignore file itself, even after a negation pattern.
53
+ normalised.append(ignore_filename)
54
+
55
+ try:
56
+ spec = pathspec.GitIgnoreSpec.from_lines(normalised)
57
+ except (ValueError, re.error) as e:
58
+ # Locate the first malformed source rule only on failure. Valid files
59
+ # compile once; the appended self-exclusion is not a source line.
60
+ location = ""
61
+ for line_number, pattern in enumerate(normalised[:len(lines)], start=1):
62
+ try:
63
+ pathspec.GitIgnoreSpec.from_lines([pattern])
64
+ except (ValueError, re.error):
65
+ location = f" at line {line_number}: {pattern!r}"
66
+ break
67
+ raise validation_error(
68
+ f"Invalid patterns in {ignore_filename}: {ignore_file}{location} ({e})"
69
+ ) from e
70
+
71
+ def _ignore(directory: str, entries: List[str]) -> Set[str]:
72
+ ignored: Set[str] = set()
73
+ rel_dir = Path(directory).relative_to(source_dir)
74
+ for entry in entries:
75
+ rel_path = str(rel_dir / entry) if str(rel_dir) != "." else entry
76
+ # Normalise to forward slashes for consistent matching
77
+ rel_path_fwd = rel_path.replace("\\", "/")
78
+
79
+ entry_full = Path(directory) / entry
80
+ if entry_full.is_dir():
81
+ # Append '/' so directory-only patterns (e.g. tests/) match
82
+ if spec.match_file(rel_path_fwd + "/"):
83
+ ignored.add(entry)
84
+ else:
85
+ if spec.match_file(rel_path_fwd):
86
+ ignored.add(entry)
87
+ return ignored
88
+
89
+ return _ignore
specify_cli/_version.py CHANGED
@@ -37,6 +37,7 @@ _RESOLUTION_FAILURE_RATE_LIMITED = (
37
37
  "rate limited (configure ~/.specify/auth.json with a GitHub token)"
38
38
  )
39
39
  _RESOLUTION_FAILURE_HTTP_PREFIX = "HTTP "
40
+ _RESOLUTION_FAILURE_AUTH_PREFIX = "authentication error: "
40
41
  _FAILURE_INSTALLER_MISSING = "installer-missing"
41
42
  _FAILURE_INSTALLER_INVALID = "installer-invalid"
42
43
  _FAILURE_TARGET_TAG_UNPARSEABLE = "target-tag-unparseable"
@@ -108,11 +109,12 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
108
109
  """Return (tag, failure_category). Exactly one outbound call, 5 s timeout.
109
110
 
110
111
  On success: (tag_name, None).
111
- On a documented network/HTTP failure (added in T029/T030): (None, category).
112
+ On a documented authentication/network/HTTP failure: (None, category).
112
113
  On anything else — including a malformed response body — the exception
113
114
  propagates; there is no catch-all (research D-006).
114
115
  """
115
116
  from .authentication.http import open_url
117
+ from .authentication.github import GitHubCliAuthError
116
118
 
117
119
  try:
118
120
  with open_url(
@@ -131,6 +133,8 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
131
133
  if not isinstance(tag, str) or not tag:
132
134
  raise ValueError("GitHub API response missing valid tag_name")
133
135
  return tag, None
136
+ except GitHubCliAuthError as exc:
137
+ return None, f"{_RESOLUTION_FAILURE_AUTH_PREFIX}{exc.reason}"
134
138
  except urllib.error.HTTPError as e:
135
139
  # Order matters: HTTPError is a subclass of URLError.
136
140
  # 403 (primary rate limit / abuse detection) and 429 (Too Many Requests /
@@ -1021,7 +1025,10 @@ def _emit_failure(
1021
1025
  """Render user-facing output for resolver, installer, or verification failures."""
1022
1026
  if (
1023
1027
  category in _RESOLUTION_FAILURE_CATEGORIES
1024
- or category.startswith(_RESOLUTION_FAILURE_HTTP_PREFIX)
1028
+ or category.startswith((
1029
+ _RESOLUTION_FAILURE_HTTP_PREFIX,
1030
+ _RESOLUTION_FAILURE_AUTH_PREFIX,
1031
+ ))
1025
1032
  ):
1026
1033
  console.print(f"Upgrade aborted: {category}", soft_wrap=True)
1027
1034
  return
@@ -2,23 +2,144 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
6
+ import shutil
7
+ import subprocess
8
+ from typing import TYPE_CHECKING
9
+ from urllib.error import URLError
10
+
5
11
  from .base import AuthProvider
6
12
 
13
+ if TYPE_CHECKING:
14
+ from .config import AuthConfigEntry
7
15
 
8
- class GitHubAuth(AuthProvider):
9
- """GitHub authentication provider.
10
16
 
11
- Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs,
12
- OAuth tokens, and GitHub App installation tokens.
13
- """
17
+ _GITHUB_CLI_TIMEOUT_SECONDS = 10
18
+
19
+
20
+ def _is_safe_cli_hostname(hostname: str) -> bool:
21
+ """Return whether *hostname* is a shell-safe DNS hostname."""
22
+ if not hostname or len(hostname) > 253 or not hostname.isascii():
23
+ return False
24
+ labels = hostname.split(".")
25
+ return all(
26
+ 0 < len(label) <= 63
27
+ and label[0].isalnum()
28
+ and label[-1].isalnum()
29
+ and all(char.isalnum() or char == "-" for char in label)
30
+ for label in labels
31
+ )
32
+
33
+
34
+ class GitHubCliAuthError(URLError):
35
+ """Raised when GitHub CLI cannot provide a configured credential."""
36
+
37
+
38
+ class GitHubAuth(AuthProvider):
39
+ """GitHub authentication provider."""
14
40
 
15
41
  key = "github"
16
- supported_auth_schemes = ("bearer",)
42
+ supported_auth_schemes = ("bearer", "github-cli")
43
+
44
+ def __init__(self) -> None:
45
+ self._github_cli_cache: dict[str, tuple[bool, str]] = {}
17
46
 
18
47
  def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
19
- """Return ``Authorization: Bearer <token>``."""
20
- if auth_scheme != "bearer":
48
+ """Return a bearer authorization header."""
49
+ if auth_scheme not in self.supported_auth_schemes:
21
50
  raise ValueError(
22
51
  f"GitHubAuth does not support auth scheme {auth_scheme!r}"
23
52
  )
24
- return {"Authorization": f"Bearer {token}"}
53
+ return {"Authorization": "Bearer " + token}
54
+
55
+ def resolve_token(self, entry: AuthConfigEntry) -> str | None:
56
+ """Resolve a configured token or acquire one from GitHub CLI."""
57
+ if entry.auth != "github-cli":
58
+ return super().resolve_token(entry)
59
+
60
+ credential_host = self._credential_hostname(entry)
61
+ cached = self._github_cli_cache.get(credential_host)
62
+ if cached is not None:
63
+ succeeded, value = cached
64
+ if succeeded:
65
+ return value
66
+ raise GitHubCliAuthError(value)
67
+
68
+ try:
69
+ token = self._acquire_via_gh_cli(credential_host)
70
+ except GitHubCliAuthError as exc:
71
+ message = str(exc.reason)
72
+ self._github_cli_cache[credential_host] = (False, message)
73
+ raise GitHubCliAuthError(message) from None
74
+
75
+ self._github_cli_cache[credential_host] = (True, token)
76
+ return token
77
+
78
+ @staticmethod
79
+ def _credential_hostname(entry: AuthConfigEntry) -> str:
80
+ """Use the first configured host for GitHub CLI credential acquisition."""
81
+ hostname = entry.hosts[0]
82
+ if "*" in hostname:
83
+ raise GitHubCliAuthError(
84
+ "GitHub authentication failed for provider 'github': "
85
+ "the first hostname in a github-cli entry must be an exact "
86
+ "hostname, not a wildcard."
87
+ )
88
+ return hostname
89
+
90
+ @staticmethod
91
+ def _acquire_via_gh_cli(hostname: str) -> str:
92
+ """Run the fixed GitHub CLI token command for *hostname*."""
93
+ if not _is_safe_cli_hostname(hostname):
94
+ raise GitHubCliAuthError(
95
+ "GitHub authentication failed for provider 'github': "
96
+ "the configured hostname is not valid for GitHub CLI authentication."
97
+ )
98
+
99
+ resolved = shutil.which("gh")
100
+ if not resolved or not os.path.isabs(resolved):
101
+ raise GitHubCliAuthError(
102
+ "GitHub authentication failed for provider 'github': "
103
+ "GitHub CLI ('gh') was not found."
104
+ )
105
+
106
+ try:
107
+ result = subprocess.run( # noqa: S603
108
+ [resolved, "auth", "token", "--hostname", hostname],
109
+ capture_output=True,
110
+ text=True,
111
+ timeout=_GITHUB_CLI_TIMEOUT_SECONDS,
112
+ check=False,
113
+ shell=False,
114
+ )
115
+ except subprocess.TimeoutExpired:
116
+ raise GitHubCliAuthError(
117
+ "GitHub authentication failed for provider 'github': "
118
+ f"GitHub CLI timed out while requesting a token for {hostname!r}."
119
+ ) from None
120
+ except (OSError, UnicodeDecodeError):
121
+ raise GitHubCliAuthError(
122
+ "GitHub authentication failed for provider 'github': "
123
+ f"GitHub CLI could not provide a token for {hostname!r}."
124
+ ) from None
125
+
126
+ if result.returncode != 0:
127
+ raise GitHubCliAuthError(
128
+ "GitHub authentication failed for provider 'github': "
129
+ f"GitHub CLI could not provide a token for {hostname!r}. "
130
+ f"Run 'gh auth login --hostname {hostname}'."
131
+ )
132
+
133
+ token = result.stdout.strip()
134
+ if not token:
135
+ raise GitHubCliAuthError(
136
+ "GitHub authentication failed for provider 'github': "
137
+ f"GitHub CLI returned an empty token for {hostname!r}. "
138
+ f"Run 'gh auth login --hostname {hostname}'."
139
+ )
140
+ if not token.isascii() or "\r" in token or "\n" in token:
141
+ raise GitHubCliAuthError(
142
+ "GitHub authentication failed for provider 'github': "
143
+ f"GitHub CLI returned a malformed token for {hostname!r}."
144
+ )
145
+ return token
@@ -133,6 +133,8 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll
133
133
 
134
134
  Uses the first matching entry from ``auth.json`` whose token resolves.
135
135
  Returns a plain request when no entry matches or the file doesn't exist.
136
+ A ``github-cli`` acquisition failure raises immediately, without trying
137
+ later matching entries or returning an unauthenticated request.
136
138
  """
137
139
  headers: dict[str, str] = {}
138
140
  if extra_headers:
@@ -174,9 +176,12 @@ def open_url(
174
176
  """Open *url* with config-driven auth, redirect stripping, and fallthrough.
175
177
 
176
178
  1. Find ``auth.json`` entries whose hosts match the URL.
177
- 2. For each entry, resolve the token and try the request.
178
- 3. On 401/403 move to the next matching entry.
179
- 4. After all entries exhausted (or none matched), try unauthenticated.
179
+ 2. For each entry, resolve the token and try the request. A ``github-cli``
180
+ acquisition failure raises immediately, with no credential or
181
+ unauthenticated fallback.
182
+ 3. On HTTP 401/403 after successful acquisition, try the next matching entry.
183
+ 4. After all entries exhausted without an acquisition failure (or none
184
+ matched), try unauthenticated.
180
185
  5. Non-auth errors (404, 500, network) raise immediately.
181
186
 
182
187
  *extra_headers* (e.g. ``Accept``) are merged into every attempt.
@@ -46,6 +46,13 @@ def _fail(message: str, *, as_json: bool = False) -> None:
46
46
  raise typer.Exit(code=1)
47
47
 
48
48
 
49
+ def _fail_with_notes(exc: BaseException) -> None:
50
+ """Exit with the original error first, then any recovery notes."""
51
+ notes = [str(note) for note in getattr(exc, "__notes__", ())]
52
+ message = str(exc) if not notes else f"{exc}\n" + "\n".join(notes)
53
+ _fail(message)
54
+
55
+
49
56
  def _render_install_warnings(result: InstallResult) -> None:
50
57
  for warning in result.warnings:
51
58
  err_console.print(f"[yellow]Warning:[/yellow] {_escape_markup(warning.message)}")
@@ -139,7 +146,40 @@ def _resolve_init_integration(override: str | None, manifest) -> str:
139
146
  return resolve_default_init_integration()
140
147
 
141
148
 
142
- def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str]:
149
+ def _expand_manifest(
150
+ project_root: Path,
151
+ manifest,
152
+ *,
153
+ offline: bool,
154
+ stack=None,
155
+ download,
156
+ ):
157
+ """Expand referenced bundles through the effective catalog stack.
158
+
159
+ Flat manifests deliberately bypass stack construction: local bundles with
160
+ no references must retain their existing offline, no-network behavior.
161
+ ``download`` is supplied by the calling command so its established
162
+ monkeypatch point also controls referenced-manifest downloads.
163
+ """
164
+ from .expansion import ExpandedManifest, expand_manifest
165
+
166
+ if not manifest.bundles:
167
+ return ExpandedManifest.flat(manifest)
168
+ from .sources import CatalogBundleLoader
169
+
170
+ if stack is None:
171
+ stack = _build_stack(project_root, offline=offline)
172
+ loader = CatalogBundleLoader(stack, offline=offline, download=download)
173
+ return expand_manifest(manifest, loader)
174
+
175
+
176
+ def _bundle_overlaps(
177
+ project_root: Path,
178
+ manifest,
179
+ *,
180
+ offline: bool,
181
+ components=None,
182
+ ) -> list[str]:
143
183
  """Return informational overlaps between *manifest* and installed bundles."""
144
184
  if manifest is None:
145
185
  return []
@@ -150,6 +190,7 @@ def _bundle_overlaps(project_root: Path, manifest, *, offline: bool) -> list[str
150
190
  manifest,
151
191
  active_integration(project_root),
152
192
  load_records(project_root),
193
+ components=components,
153
194
  )
154
195
  return [*report.overlaps, *report.version_clashes]
155
196
  except BundlerError:
@@ -159,7 +159,7 @@ def make_catalog_fetcher(*, allow_network: bool = True):
159
159
  ) from None
160
160
  scheme = parsed.scheme.lower()
161
161
 
162
- if scheme == "builtin":
162
+ if source.is_builtin:
163
163
  repository_url = _BUILTIN_REPOSITORY_URLS.get(url)
164
164
  if repository_url is None:
165
165
  raise BundlerError(f"Unknown built-in catalog '{url}'.")
@@ -228,6 +228,7 @@ def _http_get_json(source_id: str, url: str) -> dict:
228
228
  HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end
229
229
  rather than only on the initial URL.
230
230
  """
231
+ from ..authentication.github import GitHubCliAuthError
231
232
  from ..authentication.http import RedirectPolicyError, open_url
232
233
 
233
234
  def _validate_redirect(_old_url: str, new_url: str) -> None:
@@ -258,9 +259,9 @@ def _http_get_json(source_id: str, url: str) -> dict:
258
259
  raise BundlerError(
259
260
  f"Failed to fetch catalog from {url}: {exc}"
260
261
  ) from exc
261
- except RedirectPolicyError as exc:
262
- # Unsafe/malformed redirects are security failures and must surface as
263
- # a hard error instead of being downgraded to catalog unavailability.
262
+ except (GitHubCliAuthError, RedirectPolicyError) as exc:
263
+ # Authentication and redirect-policy failures are definitive, not
264
+ # transient catalog unavailability.
264
265
  raise BundlerError(
265
266
  f"Failed to fetch catalog from {url}: {exc}"
266
267
  ) from exc
@@ -22,6 +22,35 @@ from .catalogs import (
22
22
  CatalogFetcher = Callable[[CatalogSource], dict]
23
23
 
24
24
 
25
+ class BundleNotFoundError(BundlerError):
26
+ """A bundle ID is absent from every configured catalog."""
27
+
28
+ def __init__(self, bundle_id: str) -> None:
29
+ super().__init__(f"Bundle '{bundle_id}' was not found in any configured catalog.")
30
+
31
+
32
+ class ReleaseNotFoundError(BundlerError):
33
+ """A requested release is absent from the winning catalog entry."""
34
+
35
+ def __init__(
36
+ self,
37
+ bundle_id: str,
38
+ version: str,
39
+ source_id: str,
40
+ advertised: tuple[str, ...],
41
+ ) -> None:
42
+ self.bundle_id = bundle_id
43
+ self.version = version
44
+ self.source_id = source_id
45
+ self.advertised = advertised
46
+ offered = ", ".join(advertised)
47
+ super().__init__(
48
+ f"Bundle '{bundle_id}' has no release {version} in catalog source "
49
+ f"'{source_id}' (advertised: {offered}). Only the "
50
+ "highest-precedence source that lists a bundle is consulted."
51
+ )
52
+
53
+
25
54
  @dataclass
26
55
  class ResolvedBundle:
27
56
  entry: CatalogEntry
@@ -80,19 +109,17 @@ class CatalogStack:
80
109
  if entry is not None:
81
110
  selected = select_release(entry, version)
82
111
  if selected is None:
83
- advertised = ", ".join(available_versions(entry))
84
- raise BundlerError(
85
- f"Bundle '{bundle_id}' has no release {version} in catalog source "
86
- f"'{source.id}' (advertised: {advertised}). Only the "
87
- "highest-precedence source that lists a bundle is consulted."
112
+ raise ReleaseNotFoundError(
113
+ bundle_id=bundle_id,
114
+ version=version,
115
+ source_id=source.id,
116
+ advertised=tuple(available_versions(entry)),
88
117
  )
89
118
  return ResolvedBundle(
90
119
  entry=selected.with_provenance(source),
91
120
  source=source,
92
121
  )
93
- raise BundlerError(
94
- f"Bundle '{bundle_id}' was not found in any configured catalog."
95
- )
122
+ raise BundleNotFoundError(bundle_id)
96
123
 
97
124
  def search(self, query: str = "") -> list[ResolvedBundle]:
98
125
  """Return entries matching *query* (substring over id/name/role/tags/description).
@@ -10,7 +10,7 @@ from dataclasses import dataclass, field, replace
10
10
  from enum import Enum
11
11
  from pathlib import Path
12
12
  from typing import Any
13
-
13
+ from urllib.parse import urlparse
14
14
  from . import BundlerError
15
15
  from .manifest import _text
16
16
  from .yamlio import ensure_within, load_yaml
@@ -67,6 +67,14 @@ class CatalogSource:
67
67
  def install_allowed(self) -> bool:
68
68
  return self.install_policy is InstallPolicy.INSTALL_ALLOWED
69
69
 
70
+ @property
71
+ def is_builtin(self) -> bool:
72
+ """Whether this source is a ``builtin://`` catalog (packaged snapshot offline)."""
73
+ try:
74
+ return urlparse(self.url).scheme.lower() == "builtin"
75
+ except ValueError:
76
+ return False
77
+
70
78
  @classmethod
71
79
  def from_dict(cls, data: Any, scope: Scope) -> "CatalogSource":
72
80
  if not isinstance(data, dict):