From fc2d0a4993d0230c225e56a6ff1169c8d08436cd Mon Sep 17 00:00:00 2001 From: Pascal Date: Tue, 9 Jun 2026 13:59:48 +0200 Subject: [PATCH] fix(integrations): don't write an inert agent-context config when the extension is absent integration install/switch/upgrade go through _update_init_options_for_integration, which wrote agent-context-config.yml whenever the config file already existed and created it from defaults otherwise, leaving projects without the extension with an inert config nothing reads. Write it only when the extension is registered (registry.is_installed("agent-context")). No install or back-fill on any path; init is unchanged. Fixes #2881 --- src/specify_cli/integrations/_helpers.py | 19 ++--- .../test_extension_agent_context.py | 24 +++++- .../test_integration_subcommand.py | 82 +++++++++++++++++++ 3 files changed, 112 insertions(+), 13 deletions(-) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index a95f36563a..8979257047 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -285,11 +285,11 @@ def _update_init_options_for_integration( falls back to the class-level defaults. """ from .. import ( - _AGENT_CTX_EXT_CONFIG, _update_agent_context_config_file, load_init_options, save_init_options, ) + from ..extensions import ExtensionManager from .base import SkillsIntegration opts = load_init_options(project_root) opts["integration"] = integration.key @@ -307,21 +307,18 @@ 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 ext_cfg_path.exists(): + # + # Only touch the config when the agent-context extension is installed and + # registered. Keying off the registry (rather than the config file's + # presence) means a project without the extension isn't handed an inert + # config that nothing reads, and a stale file left by an older version + # isn't perpetuated (see #2881). + if ExtensionManager(project_root).registry.is_installed("agent-context"): _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/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 61ecab91af..1730db007d 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -335,8 +335,10 @@ def test_clear_init_options_removes_legacy_context_keys_even_when_not_active( def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): from specify_cli import _update_init_options_for_integration - # Pre-create the extension config so _update_init_options_for_integration - # updates it (rather than skipping it when ext config doesn't exist yet). + # The extension config is only managed when the extension is installed/ + # registered; register it and pre-create its config so the call updates + # it (an absent extension is left alone — see #2881). + _write_registry(tmp_path, enabled=True) _write_ext_config(tmp_path, context_file="") i = _CtxIntegration() _update_init_options_for_integration(tmp_path, i, script_type="sh") @@ -352,6 +354,7 @@ def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): def test_update_init_options_preserves_custom_markers(self, tmp_path): from specify_cli import _update_init_options_for_integration + _write_registry(tmp_path, enabled=True) _write_ext_config( tmp_path, context_file="", @@ -362,6 +365,23 @@ def test_update_init_options_preserves_custom_markers(self, tmp_path): cfg = _load_agent_context_config(tmp_path) assert cfg["context_markers"] == {"start": "", "end": ""} + def test_update_init_options_skips_ext_config_when_extension_absent(self, tmp_path): + """When the agent-context extension is not installed/registered, the + config is not written — projects must not be left with an inert file + that nothing reads (see #2881).""" + from specify_cli import ( + _AGENT_CTX_EXT_CONFIG, + _update_init_options_for_integration, + ) + + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i, script_type="sh") + # init-options.json is still updated... + opts = load_init_options(tmp_path) + assert opts["integration"] == i.key + # ...but no agent-context config is created. + assert not (tmp_path / _AGENT_CTX_EXT_CONFIG).exists() + def test_reinit_preserves_custom_markers(self, tmp_path): """specify init (reinit) must not overwrite user-customised markers.""" from specify_cli import _update_agent_context_config_file diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index fd9eada5cc..34e6413e90 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1549,3 +1549,85 @@ def test_metadata_cleared_between_phases(self, tmp_path): opts_json = project / ".specify" / "init-options.json" opts = json.loads(opts_json.read_text(encoding="utf-8")) assert opts.get("ai") == "copilot" + + +# ── agent-context: no inert config when the extension is absent ────── + + +class TestAgentContextNoInertConfig: + """The bundled agent-context extension is opt-in (single-agent install only) + and is not provisioned for multi-agent setups. Integration commands must + therefore NOT leave an inert ``agent-context-config.yml`` behind when the + extension is absent — a config that nothing reads (see #2881) — but must + still manage it when the extension IS installed. + """ + + EXT_CONFIG = (".specify", "extensions", "agent-context", "agent-context-config.yml") + + def _remove_agent_context_extension(self, project): + """Mimic a project without the agent-context extension: drop its + registry entry, package dir, and config file.""" + import shutil + + 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") + shutil.rmtree( + project / ".specify" / "extensions" / "agent-context", + ignore_errors=True, + ) + + def _config_path(self, project): + return project.joinpath(*self.EXT_CONFIG) + + def test_switch_writes_no_inert_config_when_extension_absent(self, tmp_path): + from specify_cli.extensions import ExtensionManager + + project = _init_project(tmp_path, "claude") + install = _run_in_project( + project, ["integration", "install", "codex", "--script", "sh"] + ) + assert install.exit_code == 0, install.output + self._remove_agent_context_extension(project) + assert not ExtensionManager(project).registry.is_installed("agent-context") + + # Switching the default to an already-installed integration runs + # _update_init_options_for_integration, which manages the config. + result = _run_in_project(project, ["integration", "switch", "codex"]) + assert result.exit_code == 0, result.output + assert not ExtensionManager(project).registry.is_installed("agent-context") + assert not self._config_path(project).exists() + + def test_upgrade_writes_no_inert_config_when_extension_absent(self, tmp_path): + from specify_cli.extensions import ExtensionManager + + project = _init_project(tmp_path, "claude") + self._remove_agent_context_extension(project) + assert not ExtensionManager(project).registry.is_installed("agent-context") + + result = _run_in_project( + project, ["integration", "upgrade", "claude", "--script", "sh"] + ) + assert result.exit_code == 0, result.output + assert not ExtensionManager(project).registry.is_installed("agent-context") + assert not self._config_path(project).exists() + + def test_switch_manages_config_when_extension_present(self, tmp_path): + from specify_cli import _load_agent_context_config + from specify_cli.extensions import ExtensionManager + + project = _init_project(tmp_path, "claude") + install = _run_in_project( + project, ["integration", "install", "codex", "--script", "sh"] + ) + assert install.exit_code == 0, install.output + assert ExtensionManager(project).registry.is_installed("agent-context") + + # Switching the default to codex re-points the (installed) extension's + # config — the gate's positive branch still manages it. + result = _run_in_project(project, ["integration", "switch", "codex"]) + assert result.exit_code == 0, result.output + assert self._config_path(project).exists() + assert _load_agent_context_config(project)["context_file"] == "AGENTS.md"