From 9772946b2bc6a86a679ceda05ab136db893def3a Mon Sep 17 00:00:00 2001 From: nkgotcode Date: Sat, 6 Jun 2026 09:20:43 -0500 Subject: [PATCH] fix: backfill agent-context extension for integrations --- src/specify_cli/integrations/_helpers.py | 47 ++++++++++--- .../test_integration_subcommand.py | 69 +++++++++++++++++++ 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index a95f36563a..c7cf57ff66 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -144,6 +144,42 @@ def _remove_integration_json(project_root: Path) -> None: path.unlink() +def _ensure_agent_context_extension(project_root: Path) -> None: + """Install the bundled agent-context extension if its config will be used.""" + from .. import ( + _AGENT_CTX_EXT_CONFIG, + _load_agent_context_config, + _save_agent_context_config, + ) + from .._assets import _locate_bundled_extension + from ..extensions import ExtensionManager + + ext_mgr = ExtensionManager(project_root) + ext_dir = project_root / ".specify" / "extensions" / "agent-context" + if ( + ext_mgr.registry.is_installed("agent-context") and + (ext_dir / "extension.yml").is_file() + ): + return + + existing_cfg = None + if (project_root / _AGENT_CTX_EXT_CONFIG).exists(): + existing_cfg = _load_agent_context_config(project_root) + + bundled_ac = _locate_bundled_extension("agent-context") + if bundled_ac is None: + raise ValueError("bundled agent-context extension not found") + + ext_mgr.install_from_directory( + bundled_ac, + _get_speckit_version(), + force=ext_mgr.registry.is_installed("agent-context"), + ) + + if existing_cfg is not None: + _save_agent_context_config(project_root, existing_cfg) + + # --------------------------------------------------------------------------- # Error sentinels # --------------------------------------------------------------------------- @@ -308,20 +344,15 @@ def _update_init_options_for_integration( # Update the agent-context extension config BEFORE init-options.json # so a failure here doesn't leave init-options partially updated. ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG + if integration.context_file: + _ensure_agent_context_extension(project_root) + if ext_cfg_path.exists(): _update_agent_context_config_file( project_root, integration.context_file, preserve_markers=True, ) - elif integration.context_file: - # Extension config doesn't exist yet (extension not installed). - # Write defaults so scripts have something to read. - _update_agent_context_config_file( - project_root, - integration.context_file, - preserve_markers=False, - ) save_init_options(project_root, opts) diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index fd9eada5cc..fe5ecb89c8 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -2,6 +2,7 @@ import json import os +import shutil from typer.testing import CliRunner @@ -48,6 +49,34 @@ def _write_invalid_manifest(project, key): return manifest +def _remove_agent_context_extension(project): + ext_dir = project / ".specify" / "extensions" / "agent-context" + if ext_dir.exists(): + shutil.rmtree(ext_dir) + + registry = project / ".specify" / "extensions" / ".registry" + if registry.exists(): + data = json.loads(registry.read_text(encoding="utf-8")) + data.get("extensions", {}).pop("agent-context", None) + registry.write_text(json.dumps(data), encoding="utf-8") + + +def _assert_agent_context_installed(project, context_file): + ext_dir = project / ".specify" / "extensions" / "agent-context" + assert (ext_dir / "extension.yml").is_file() + assert (ext_dir / "commands" / "speckit.agent-context.update.md").is_file() + assert (ext_dir / "scripts" / "bash" / "update-agent-context.sh").is_file() + + registry = project / ".specify" / "extensions" / ".registry" + data = json.loads(registry.read_text(encoding="utf-8")) + assert "agent-context" in data["extensions"] + + from specify_cli import _load_agent_context_config + + cfg = _load_agent_context_config(project) + assert cfg["context_file"] == context_file + + def _integration_list_row_cells(output: str, key: str) -> list[str]: plain = strip_ansi(output) row = next(line for line in plain.splitlines() if line.startswith(f"│ {key}")) @@ -191,6 +220,21 @@ def test_install_already_installed_non_default_guides_use(self, tmp_path): assert "specify integration upgrade codex" in normalized assert "specify integration uninstall codex" not in normalized + def test_install_backfills_agent_context_extension_when_missing(self, tmp_path): + project = _init_project(tmp_path, "copilot") + _remove_agent_context_extension(project) + (project / ".specify" / "integration.json").unlink() + (project / ".specify" / "integrations" / "copilot.manifest.json").unlink() + shutil.rmtree(project / ".github") + + result = _run_in_project(project, [ + "integration", "install", "copilot", + "--script", "sh", + ]) + + assert result.exit_code == 0, result.output + _assert_agent_context_installed(project, ".github/copilot-instructions.md") + def test_install_different_when_one_exists(self, tmp_path): project = _init_project(tmp_path, "copilot") old_cwd = os.getcwd() @@ -1155,6 +1199,18 @@ def test_switch_from_nothing(self, tmp_path): data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8")) assert data["integration"] == "claude" + def test_switch_backfills_agent_context_extension_when_missing(self, tmp_path): + project = _init_project(tmp_path, "claude") + _remove_agent_context_extension(project) + + result = _run_in_project(project, [ + "integration", "switch", "copilot", + "--script", "sh", + ]) + + assert result.exit_code == 0, result.output + _assert_agent_context_installed(project, ".github/copilot-instructions.md") + def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path): project = _init_project(tmp_path, "claude") old_cwd = os.getcwd() @@ -1308,6 +1364,19 @@ def test_upgrade_default_refreshes_shared_script_refs_for_option_separator_chang assert "/speckit.specify" not in managed_content assert customized_script.read_text(encoding="utf-8") == customized_before + def test_upgrade_backfills_agent_context_extension_when_missing(self, tmp_path): + project = _init_project(tmp_path, "copilot") + _remove_agent_context_extension(project) + + result = _run_in_project(project, [ + "integration", "upgrade", "copilot", + "--script", "sh", + "--force", + ]) + + assert result.exit_code == 0, result.output + _assert_agent_context_installed(project, ".github/copilot-instructions.md") + def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path): project = _init_project(tmp_path, "gemini") template = project / ".specify" / "templates" / "plan-template.md"