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"