From af0396f690279e7b80ca1fab1ff9086c8263b8e4 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:54:27 -0500 Subject: [PATCH 1/2] feat(integrations): look up versioned catalog metadata Preserve current integration catalog records while validating optional release history and exposing exact-version metadata and info --versions. Historical releases remain display-only. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/integrations.md | 60 +++++ src/specify_cli/integrations/__init__.py | 27 +- .../integrations/_catalog_versions.py | 149 +++++++++++ src/specify_cli/integrations/command_info.py | 31 +++ .../integrations/test_catalog_versions.py | 239 ++++++++++++++++++ .../integrations/test_command_info.py | 61 +++++ .../integrations/test_command_install.py | 7 + 7 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 src/specify_cli/integrations/_catalog_versions.py create mode 100644 tests/specify_cli/integrations/test_catalog_versions.py diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md index e4f2c4e57a..4bbbb4704e 100644 --- a/docs/reference/integrations.md +++ b/docs/reference/integrations.md @@ -100,9 +100,13 @@ Searches the active catalog stack for integrations matching the query. Without a ```bash specify integration info +specify integration info --versions ``` Shows catalog details for a single integration, including its description, author, license, tags, source catalog, repository (when available), and whether it is currently active. Must be run inside a Spec Kit project. +`--versions` lists the advertised current and historical versions from the +winning catalog, with the current release first and older releases in descending +version order. Discovery-only sources are labeled as such. ## Install an Integration @@ -118,6 +122,12 @@ specify integration install Installs the specified integration into the current project. If another integration is already installed, the command only proceeds automatically when all involved integrations are declared multi-install safe. Otherwise, use `switch` to replace the default integration or pass `--force` to explicitly opt in to multi-install. If the installation fails partway through, it automatically rolls back to a clean state. +**Catalog history is metadata only.** `integration install` still resolves +registered built-in implementations, not historical catalog records. There is +no `integration install --version` or catalog-based integration distribution +contract, even when a catalog source is marked install-allowed. Community +catalogs remain discovery-only. + Installing an additional integration does not change the default integration. Use `specify integration use ` to change the default. Installed extensions and presets are not registered for a non-default integration at install time — they follow the currently active (default) integration only. `specify integration use ` (or `switch `) is what rescaffolds them for the newly active integration. @@ -253,6 +263,56 @@ Catalogs are resolved in this order (first match wins): 3. **User config** — `~/.specify/integration-catalogs.yml` 4. **Built-in defaults** — official catalog + community catalog +### Historical Integration Metadata + +An integration entry may retain the existing top-level fields for its advertised +current release and add a `releases` mapping of historical version to metadata. +Single-version entries remain valid without a `releases` key. For example: + +```json +{ + "schema_version": "1.0", + "integrations": { + "my-agent": { + "id": "my-agent", + "name": "My Agent", + "author": "example", + "version": "2.0.0", + "description": "Current release", + "repository": "https://example.com/my-agent/current", + "releases": { + "1.0.0": { + "description": "Historical release", + "repository": "https://example.com/my-agent/v1", + "requires": {"speckit_version": ">=0.7"} + } + } + } + } +} +``` + +The top-level fields retain their original meaning for older clients and for +unqualified `search`, `info`, `list --catalog`, and `install` commands. Historical +records may contain `name`, `description`, `author`, `repository`, `license`, +`tags` (a list of strings), and `requires` (a mapping). Shared identity, name, +author, and source policy are retained; current-only metadata such as +description, repository, tags, and requirements is **not** inherited by an +older release unless explicitly supplied in its record. Release records cannot +override `id`, `version`, `releases`, or source policy, and cannot advertise +download URLs or archives. + +For programmatic metadata lookup, +`IntegrationCatalog.get_integration_info(id, version="1.0.0")` selects the +exact historical record from the winning source, or returns `None` if that +version is missing. `get_integration_versions(id)` returns the versions +advertised by that same source. PEP 440-equivalent spellings (such as `v1.0` +and `1.0.0`) match the same release while retaining its catalog spelling. +The current release must not be repeated in `releases`; malformed records and +duplicate equivalent versions are rejected. An absent version never falls +through to a lower-priority source or silently selects current. These lookups +do not install an integration. + ## Integration-Specific Options Some integrations accept additional options via `--integration-options`: diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py index 2e58e01628..38af83b116 100644 --- a/src/specify_cli/integrations/__init__.py +++ b/src/specify_cli/integrations/__init__.py @@ -184,6 +184,16 @@ def _catalog_shape_error(payload: Any) -> Optional[str]: return "missing required 'schema_version' or 'integrations' key" if not isinstance(payload.get("integrations"), dict): return "'integrations' must be a JSON object" + from ._catalog_versions import _validated_releases + + for integration_id, entry in payload["integrations"].items(): + if isinstance(entry, dict) and "releases" in entry: + if entry.get("id", integration_id) != integration_id: + return f"Integration '{integration_id}' has an inconsistent id." + try: + _validated_releases({**entry, "id": integration_id}) + except IntegrationCatalogError as exc: + return str(exc) return None @@ -464,14 +474,25 @@ def search( return results def get_integration_info( - self, integration_id: str + self, integration_id: str, version: str | None = None ) -> Optional[Dict[str, Any]]: - """Return catalog metadata for a single integration, or None.""" + """Return current or exact-version metadata from the winning catalog.""" + from ._catalog_versions import select_release + for item in self._get_merged_integrations(): if item["id"] == integration_id: - return item + return select_release(item, version) return None + def get_integration_versions(self, integration_id: str) -> list[str]: + """Return the current and historical versions from the winning catalog.""" + from ._catalog_versions import available_versions + + for item in self._get_merged_integrations(): + if item["id"] == integration_id: + return available_versions(item) + return [] + # -- Cache management ------------------------------------------------- def clear_cache(self) -> None: diff --git a/src/specify_cli/integrations/_catalog_versions.py b/src/specify_cli/integrations/_catalog_versions.py new file mode 100644 index 0000000000..a5ae06b873 --- /dev/null +++ b/src/specify_cli/integrations/_catalog_versions.py @@ -0,0 +1,149 @@ +"""Validate and select display-only integration catalog release metadata.""" + +from __future__ import annotations + +from typing import Any + +from packaging.version import InvalidVersion, Version + +from . import IntegrationCatalogError + +_RELEASE_FIELDS = frozenset( + { + "name", + "description", + "author", + "repository", + "license", + "tags", + "requires", + } +) +_CURRENT_ONLY = frozenset( + { + "version", + "description", + "repository", + "license", + "tags", + "requires", + "provides", + "download_url", + "sha256", + "bundled", + "verified", + "releases", + } +) + + +def _validated_releases(entry: dict[str, Any]) -> dict[str, dict[str, Any]]: + if "releases" not in entry: + return {} + integration_id = entry.get("id", "") + releases = entry["releases"] + if not isinstance(releases, dict): + raise IntegrationCatalogError( + f"Integration '{integration_id}' has an invalid releases mapping." + ) + + current = entry.get("version") + if not isinstance(current, str) or not current.strip(): + raise IntegrationCatalogError( + f"Integration '{integration_id}' has releases but no current version." + ) + try: + seen = {Version(current)} + except InvalidVersion: + raise IntegrationCatalogError( + f"Integration '{integration_id}' has an invalid current version '{current}'." + ) from None + + for advertised, metadata in releases.items(): + if not isinstance(advertised, str) or not advertised.strip(): + raise IntegrationCatalogError( + f"Integration '{integration_id}' has an invalid release version key." + ) + try: + normalized = Version(advertised) + except InvalidVersion: + raise IntegrationCatalogError( + f"Integration '{integration_id}' has invalid release version '{advertised}'." + ) from None + if normalized in seen: + raise IntegrationCatalogError( + f"Integration '{integration_id}' repeats release version '{advertised}'." + ) + seen.add(normalized) + if not isinstance(metadata, dict): + raise IntegrationCatalogError( + f"Integration '{integration_id}' release '{advertised}' must be an object." + ) + for field, value in metadata.items(): + if field not in _RELEASE_FIELDS: + label = ( + "reserved" + if field + in { + "id", + "version", + "releases", + "_catalog_name", + "_install_allowed", + } + else "unsupported" + ) + raise IntegrationCatalogError( + f"Integration '{integration_id}' release '{advertised}' has {label} field '{field}'." + ) + if field == "tags": + valid = isinstance(value, list) and all( + isinstance(tag, str) and tag.strip() for tag in value + ) + elif field == "requires": + valid = isinstance(value, dict) + else: + valid = isinstance(value, str) and ( + bool(value.strip()) or field == "description" + ) + if not valid: + raise IntegrationCatalogError( + f"Integration '{integration_id}' release '{advertised}' has invalid {field}." + ) + return releases + + +def select_release(entry: dict[str, Any], version: str | None) -> dict[str, Any] | None: + """Select from the winning source without inheriting current-only metadata.""" + releases = _validated_releases(entry) + current = entry.get("version") + if version is None or ( + isinstance(current, str) and current.strip() and version == current + ): + return entry + try: + requested = Version(version) + except (InvalidVersion, TypeError): + return None + if isinstance(current, str): + try: + if requested == Version(current): + return entry + except InvalidVersion: + pass # Legacy single-release catalogs may use non-PEP 440 versions. + for advertised, metadata in releases.items(): + if requested == Version(advertised): + shared = { + key: value for key, value in entry.items() if key not in _CURRENT_ONLY + } + return {**shared, **metadata, "version": advertised} + return None + + +def available_versions(entry: dict[str, Any]) -> list[str]: + """Return current first, then older release spellings in descending order.""" + releases = _validated_releases(entry) + current = entry.get("version") + if not isinstance(current, str) or not current: + return [] + return [current, *sorted(releases, key=Version, reverse=True)] diff --git a/src/specify_cli/integrations/command_info.py b/src/specify_cli/integrations/command_info.py index 2606d88bca..9ebdef06a8 100644 --- a/src/specify_cli/integrations/command_info.py +++ b/src/specify_cli/integrations/command_info.py @@ -16,6 +16,7 @@ @integration_app.command("info") def integration_info( integration_id: str = typer.Argument(..., help="Integration ID"), + versions: bool = typer.Option(False, "--versions", help="List catalog versions"), ): """Show catalog details for a single integration.""" from . import ( @@ -41,6 +42,36 @@ def integration_info( else: catalog_error = None + if versions is True: + if info: + from ._catalog_versions import available_versions + + try: + advertised = available_versions(info) + except IntegrationCatalogError as exc: + console.print(f"[red]Error:[/red] {_rich_escape(str(exc))}") + raise typer.Exit(1) from exc + if not advertised: + console.print( + f"[red]Error:[/red] No catalog versions advertised for {safe_integration_id}." + ) + raise typer.Exit(1) + console.print(f"Catalog versions for {safe_integration_id}:") + for index, advertised_version in enumerate(advertised): + label = " (current)" if index == 0 else "" + console.print(f" {_rich_escape(advertised_version)}{label}") + if not info.get("_install_allowed", True): + console.print("[yellow]Discovery only; catalog installation is disabled.[/yellow]") + console.print( + "[dim]Historical versions are metadata only; integration install supports registered built-in implementations, not catalog releases.[/dim]" + ) + return + if catalog_error: + console.print(f"[red]Error:[/red] Could not query integration catalog: {_rich_escape(str(catalog_error))}") + else: + console.print(f"[red]Error:[/red] No catalog versions found for {safe_integration_id}.") + raise typer.Exit(1) + if info: name = _rich_escape(str(info.get("name", integration_id))) version = _rich_escape(str(info.get("version", "?"))) diff --git a/tests/specify_cli/integrations/test_catalog_versions.py b/tests/specify_cli/integrations/test_catalog_versions.py new file mode 100644 index 0000000000..30cb846147 --- /dev/null +++ b/tests/specify_cli/integrations/test_catalog_versions.py @@ -0,0 +1,239 @@ +"""Versioned integration catalog metadata and exact-release lookup.""" + +from __future__ import annotations + +import copy + +import pytest + +from tests.http_helpers import route_opener_open_through_urlopen # noqa: F401 + +from specify_cli.integrations import ( + IntegrationCatalog, + IntegrationCatalogEntry, + IntegrationCatalogError, + _catalog_shape_error, +) + + +CURRENT = { + "id": "sample", + "name": "Sample Agent", + "author": "example", + "version": "2.0.0", + "description": "Current release", + "repository": "https://example.com/current", + "tags": ["current"], + "requires": {"speckit_version": ">=1.0"}, + "download_url": "https://example.com/current.zip", + "releases": { + "1.0.0": { + "description": "Historical release", + "repository": "https://example.com/old", + "tags": ["historical"], + "requires": {"speckit_version": ">=0.7"}, + }, + "1.5.0": {"description": "Intermediate release"}, + }, +} + + +def test_legacy_entry_keeps_current_fields_and_exact_lookup(tmp_path, monkeypatch): + legacy = {key: value for key, value in CURRENT.items() if key != "releases"} + monkeypatch.setattr( + IntegrationCatalog, + "_get_merged_integrations", + lambda self: [legacy], + ) + catalog = IntegrationCatalog(tmp_path) + assert catalog.get_integration_info("sample") is legacy + assert catalog.get_integration_info("sample", version="2.0.0") is legacy + assert catalog.get_integration_info("sample", version="1.0.0") is None + assert catalog.get_integration_versions("sample") == ["2.0.0"] + + +def test_missing_legacy_version_cannot_be_selected_as_empty_string( + tmp_path, monkeypatch +): + monkeypatch.setattr( + IntegrationCatalog, + "_get_merged_integrations", + lambda self: [{"id": "sample", "name": "Sample Agent", "version": ""}], + ) + catalog = IntegrationCatalog(tmp_path) + assert catalog.get_integration_info("sample")["name"] == "Sample Agent" + assert catalog.get_integration_info("sample", version="") is None + assert catalog.get_integration_versions("sample") == [] + + +def test_exact_release_returns_selected_metadata_not_current(tmp_path, monkeypatch): + entry = {**CURRENT, "_catalog_name": "primary", "_install_allowed": False} + monkeypatch.setattr( + IntegrationCatalog, + "_get_merged_integrations", + lambda self: [entry], + ) + catalog = IntegrationCatalog(tmp_path) + + assert catalog.get_integration_info("sample") is entry + selected = catalog.get_integration_info("sample", version="v1.0") + assert selected == { + "id": "sample", + "name": "Sample Agent", + "author": "example", + "version": "1.0.0", + "description": "Historical release", + "repository": "https://example.com/old", + "tags": ["historical"], + "requires": {"speckit_version": ">=0.7"}, + "_catalog_name": "primary", + "_install_allowed": False, + } + assert catalog.get_integration_info("sample", version="v2.0") is entry + assert catalog.get_integration_versions("sample") == ["2.0.0", "1.5.0", "1.0.0"] + assert "download_url" not in selected + + +def test_missing_version_does_not_fall_back_to_lower_priority(tmp_path, monkeypatch): + winning = {**CURRENT, "_catalog_name": "primary", "_install_allowed": False} + lower = { + **CURRENT, + "_catalog_name": "secondary", + "_install_allowed": True, + "releases": {"0.9.0": {"description": "Lower-priority version"}}, + } + monkeypatch.setattr( + IntegrationCatalog, + "_get_merged_integrations", + lambda self: [winning, lower], + ) + catalog = IntegrationCatalog(tmp_path) + assert catalog.get_integration_info("sample", version="0.9.0") is None + assert catalog.get_integration_info("sample", version="bad version") is None + assert catalog.get_integration_info("missing", version="2.0.0") is None + assert catalog.get_integration_versions("missing") == [] + + +@pytest.mark.parametrize( + ("change", "reason"), + [ + ({"releases": []}, "releases"), + ({"releases": {"1.0.0": []}}, "object"), + ({"releases": {"not-a-version": {}}}, "version"), + ({"version": "not-a-version"}, "current version"), + ({"version": None}, "current version"), + ({"releases": {"2.0.0": {}}}, "repeats"), + ({"releases": {"v2.0": {}}}, "repeats"), + ({"releases": {"1.0.0": {}, "v1.0": {}}}, "repeats"), + ({"releases": {"1.0.0": {"version": "0.9"}}}, "reserved"), + ({"releases": {"1.0.0": {"id": "different"}}}, "reserved"), + ({"releases": {"1.0.0": {"_install_allowed": True}}}, "reserved"), + ({"releases": {"1.0.0": {"releases": {}}}}, "reserved"), + ({"releases": {"1.0.0": {"tags": "cli"}}}, "tags"), + ({"releases": {"1.0.0": {"tags": [42]}}}, "tags"), + ({"releases": {"1.0.0": {"requires": []}}}, "requires"), + ({"releases": {"1.0.0": {"description": 42}}}, "description"), + ( + {"releases": {"1.0.0": {"download_url": "https://example.com/a.zip"}}}, + "unsupported", + ), + ({"id": "different"}, "inconsistent id"), + ({"releases": {"": {}}}, "version key"), + ({"releases": {"1.0.0": {"repository": None}}}, "repository"), + ({"releases": {"1.0.0": {"_catalog_name": "other"}}}, "reserved"), + ], +) +def test_rejects_malformed_release_history(change, reason): + entry = copy.deepcopy(CURRENT) + entry.update(change) + payload = {"schema_version": "1.0", "integrations": {"sample": entry}} + assert reason in _catalog_shape_error(payload) + + +def test_invalid_release_history_is_rejected_on_fetch_and_not_cached( + tmp_path, monkeypatch +): + catalog = IntegrationCatalog(tmp_path) + source = IntegrationCatalogEntry( + url="https://example.com/catalog.json", + name="custom", + priority=1, + install_allowed=True, + ) + from tests.specify_cli.integrations.test_catalog import TestCatalogFetch + + TestCatalogFetch()._patch_urlopen( + monkeypatch, + { + "schema_version": "1.0", + "integrations": {"sample": {**CURRENT, "releases": {"v2.0": {}}}}, + }, + ) + with pytest.raises(IntegrationCatalogError, match="repeats"): + catalog._fetch_single_catalog(source) + assert not list(catalog.cache_dir.glob("catalog-*.json")) + + +def test_poisoned_release_cache_is_refetched(tmp_path, monkeypatch): + source = IntegrationCatalogEntry( + url="https://example.com/catalog.json", + name="custom", + priority=1, + install_allowed=True, + ) + from tests.specify_cli.integrations.test_catalog import TestCatalogFetch + + TestCatalogFetch()._patch_urlopen( + monkeypatch, + {"schema_version": "1.0", "integrations": {"sample": CURRENT}}, + ) + catalog = IntegrationCatalog(tmp_path) + catalog._fetch_single_catalog(source) + cache_file = next( + file + for file in catalog.cache_dir.glob("catalog-*.json") + if not file.name.endswith("-metadata.json") + ) + cache_file.write_text( + '{"schema_version":"1.0","integrations":{"sample":' + '{"version":"2.0.0","releases":{"2.0.0":{}}}}}', + encoding="utf-8", + ) + assert catalog._fetch_single_catalog(source)["integrations"]["sample"] == CURRENT + + +def test_source_priority_and_current_search_are_unchanged(tmp_path, monkeypatch): + primary = IntegrationCatalogEntry( + url="https://example.com/primary.json", + name="primary", + priority=1, + install_allowed=False, + ) + secondary = IntegrationCatalogEntry( + url="https://example.com/secondary.json", + name="secondary", + priority=2, + install_allowed=True, + ) + catalog = IntegrationCatalog(tmp_path) + monkeypatch.setattr(catalog, "get_active_catalogs", lambda: [primary, secondary]) + monkeypatch.setattr( + catalog, + "_fetch_single_catalog", + lambda source, force_refresh=False: { + "integrations": { + "sample": CURRENT + if source.name == "primary" + else { + "version": "3.0.0", + "releases": {"0.9.0": {"description": "Only in secondary"}}, + } + } + }, + ) + assert [item["version"] for item in catalog.search()] == ["2.0.0"] + assert catalog.get_integration_info("sample", version="0.9.0") is None + assert ( + catalog.get_integration_info("sample", version="1.0.0")["_install_allowed"] + is False + ) diff --git a/tests/specify_cli/integrations/test_command_info.py b/tests/specify_cli/integrations/test_command_info.py index 6b973cb136..b0390331a6 100644 --- a/tests/specify_cli/integrations/test_command_info.py +++ b/tests/specify_cli/integrations/test_command_info.py @@ -24,6 +24,67 @@ def test_info_found(self, tmp_path, monkeypatch): assert "stellar-agent" in result.output assert "v1.3.0" in result.output + def test_info_versions_lists_current_and_history_without_install_hint( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch, integrations=[{ + **self.FAKE_INTEGRATIONS[0], + "releases": { + "1.0.0": {"description": "Original"}, + "1.5.0": {"description": "Previous"}, + }, + }]) + result = self._invoke(["integration", "info", "acme-coder", "--versions"], project) + assert result.exit_code == 0, result.output + output = _normalize_cli_output(result.output) + assert "2.0.0 (current)" in output + assert output.index("1.5.0") < output.index("1.0.0") + assert "Discovery only" in output + assert "install acme-coder" not in output + + def test_info_versions_legacy_and_not_found(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch) + result = self._invoke(["integration", "info", "stellar-agent", "--versions"], project) + assert result.exit_code == 0, result.output + assert "1.3.0 (current)" in _normalize_cli_output(result.output) + + result = self._invoke(["integration", "info", "absent", "--versions"], project) + assert result.exit_code == 1 + assert "No catalog versions found" in result.output + + def test_info_versions_requires_catalog_even_for_builtin(self, tmp_path, monkeypatch): + project = self._make_project(tmp_path) + self._patch_catalog(monkeypatch, integrations=[]) + result = self._invoke(["integration", "info", "copilot", "--versions"], project) + assert result.exit_code == 1 + assert "No catalog versions found" in result.output + + def test_info_versions_catalog_failure_is_not_success(self, tmp_path, monkeypatch): + from specify_cli.integrations import IntegrationCatalog, IntegrationCatalogError + + project = self._make_project(tmp_path) + + def fail_lookup(self, integration_id, version=None): + raise IntegrationCatalogError("catalog offline") + + monkeypatch.setattr(IntegrationCatalog, "get_integration_info", fail_lookup) + result = self._invoke(["integration", "info", "copilot", "--versions"], project) + assert result.exit_code == 1 + assert "catalog offline" in result.output + + def test_info_versions_without_advertised_version_is_error( + self, tmp_path, monkeypatch + ): + project = self._make_project(tmp_path) + self._patch_catalog( + monkeypatch, integrations=[{"id": "sample", "name": "Sample Agent"}] + ) + result = self._invoke(["integration", "info", "sample", "--versions"], project) + assert result.exit_code == 1 + assert "No catalog versions advertised" in result.output + def test_info_not_found(self, tmp_path, monkeypatch): project = self._make_project(tmp_path) self._patch_catalog(monkeypatch) diff --git a/tests/specify_cli/integrations/test_command_install.py b/tests/specify_cli/integrations/test_command_install.py index 3ff1f94e8b..b6a8085f54 100644 --- a/tests/specify_cli/integrations/test_command_install.py +++ b/tests/specify_cli/integrations/test_command_install.py @@ -26,6 +26,13 @@ ) class TestIntegrationInstall: + def test_historical_catalog_versions_are_not_install_options(self): + result = runner.invoke( + app, ["integration", "install", "copilot", "--version", "1.0.0"] + ) + assert result.exit_code == 2 + assert "No such option: --version" in result.output + def test_install_requires_speckit_project(self, tmp_path): old_cwd = os.getcwd() try: From a123ecdde52ad62accf476e241d21e7d8ff3d9c3 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:35:04 -0500 Subject: [PATCH 2/2] test(integrations): ignore ANSI styling in invalid option assertion Rich may colorize the option token in CliRunner output on macOS Python 3.14. Compare against the existing ANSI-stripping helper so the negative test checks the actual diagnostic. Assisted-by: GitHub Copilot (model: GPT-6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/specify_cli/integrations/test_command_install.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/specify_cli/integrations/test_command_install.py b/tests/specify_cli/integrations/test_command_install.py index b6a8085f54..a136f6d76e 100644 --- a/tests/specify_cli/integrations/test_command_install.py +++ b/tests/specify_cli/integrations/test_command_install.py @@ -31,7 +31,7 @@ def test_historical_catalog_versions_are_not_install_options(self): app, ["integration", "install", "copilot", "--version", "1.0.0"] ) assert result.exit_code == 2 - assert "No such option: --version" in result.output + assert "No such option: --version" in strip_ansi(result.output) def test_install_requires_speckit_project(self, tmp_path): old_cwd = os.getcwd()