Skip to content
Open
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
47 changes: 39 additions & 8 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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)

Expand Down
69 changes: 69 additions & 0 deletions tests/integrations/test_integration_subcommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
import os
import shutil

from typer.testing import CliRunner

Expand Down Expand Up @@ -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}"))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Expand Down