Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down
24 changes: 22 additions & 2 deletions tests/extensions/test_extension_agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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="",
Expand All @@ -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": "<!-- B -->", "end": "<!-- E -->"}

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
Expand Down
82 changes: 82 additions & 0 deletions tests/integrations/test_integration_subcommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"