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
31 changes: 6 additions & 25 deletions src/specify_cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -464,31 +464,12 @@ def init(
# --- agent-context extension (bundled, auto-installed) ---
# Installed after init-options.json is written so that skill
# registration can read ai_skills + integration key.
try:
from ..extensions import ExtensionManager as _ExtMgr
bundled_ac = _locate_bundled_extension("agent-context")
if bundled_ac:
ac_mgr = _ExtMgr(project_path)
if ac_mgr.registry.is_installed("agent-context"):
tracker.complete("agent-context", "already installed")
else:
ac_mgr.install_from_directory(
bundled_ac, get_speckit_version()
)
tracker.complete("agent-context", "extension installed")
else:
from ..extensions import REINSTALL_COMMAND as _ac_reinstall
tracker.error(
"agent-context",
f"bundled extension not found — installation may be "
f"incomplete. Run: {_ac_reinstall}",
)
except Exception as ac_err:
sanitized_ac = str(ac_err).replace('\n', ' ').strip()
tracker.error(
"agent-context",
f"extension install failed: {sanitized_ac[:120]}",
)
from ..extensions import ensure_agent_context_extension
ac_status, ac_detail = ensure_agent_context_extension(project_path)
if ac_status in ("installed", "already-installed"):
tracker.complete("agent-context", ac_detail)
else:
tracker.error("agent-context", ac_detail)

# Write context_file to the agent-context extension config
# AFTER the extension install (which copies the template config
Expand Down
55 changes: 55 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -1695,6 +1695,61 @@ def get_extension(self, extension_id: str) -> Optional[ExtensionManifest]:
return None


def ensure_agent_context_extension(
project_root: Path,
*,
version: str | None = None,
) -> tuple[str, str]:
"""Install and register the bundled ``agent-context`` extension if missing.

Idempotent back-fill shared by every command that manages the agent-context
config (``init`` and ``integration install``/``switch``/``upgrade``), so a
project that predates the extension — or where a prior install was skipped —
gets it registered instead of being left with an inert config file.

Returns a ``(status, detail)`` tuple where *status* is one of
``"already-installed"``, ``"installed"``, ``"missing-bundle"`` or
``"error"`` and *detail* is a short human-readable message. Never raises.
"""
from ._assets import _locate_bundled_extension, get_speckit_version

try:
manager = ExtensionManager(project_root)
if manager.registry.is_installed("agent-context"):
return "already-installed", "already installed"
bundled = _locate_bundled_extension("agent-context")
if bundled is None:
return (
"missing-bundle",
f"bundled extension not found — installation may be incomplete. "
f"Run: {REINSTALL_COMMAND}",
)
# Preserve any pre-existing config: install_from_directory rmtree's the
# extension dir and copies the bundled template (which has an empty
# context_file and default markers), so a config already written by a
# caller — e.g. _update_init_options_for_integration — would be reset.
from . import (
_AGENT_CTX_EXT_CONFIG,
_load_agent_context_config,
_save_agent_context_config,
)
cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
prev_config = _load_agent_context_config(project_root) if cfg_path.exists() else None
try:
manager.install_from_directory(bundled, version or get_speckit_version())
finally:
# Restore the pre-existing config even if install failed mid-way:
# install_from_directory rmtree's the extension dir before copying,
# so an error after that point would otherwise leave the caller's
# config permanently destroyed.
if prev_config is not None:
_save_agent_context_config(project_root, prev_config)
return "installed", "extension installed"
except Exception as exc:
detail = str(exc).replace("\n", " ").strip()
return "error", f"extension install failed: {detail[:120]}"


def version_satisfies(current: str, required: str) -> bool:
"""Check if current version satisfies required version specifier.

Expand Down
15 changes: 15 additions & 0 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,20 @@ def _resolve_integration_options(
)


def _backfill_agent_context(project_root: Path) -> None:
"""Install the bundled agent-context extension if it is missing.

Shared by integration install/switch/upgrade so projects that predate the
extension get it registered instead of being left with an inert config.
Non-fatal: a failure is surfaced as a warning, never raised.
"""
from ..extensions import ensure_agent_context_extension

status, detail = ensure_agent_context_extension(project_root)
if status not in ("installed", "already-installed"):
console.print(f"[yellow]agent-context: {detail}[/yellow]")


def _update_init_options_for_integration(
project_root: Path,
integration: Any,
Expand Down Expand Up @@ -375,6 +389,7 @@ def _set_default_integration(

_write_integration_json(project_root, key, installed_keys, settings)
_update_init_options_for_integration(project_root, integration, script_type=resolved_script)
_backfill_agent_context(project_root)


def _set_default_integration_or_exit(*args: Any, **kwargs: Any) -> None:
Expand Down
5 changes: 5 additions & 0 deletions src/specify_cli/integrations/_install_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from ._commands import integration_app
from ._helpers import (
_MANIFEST_READ_ERRORS,
_backfill_agent_context,
_clear_init_options_for_integration,
_cli_error_detail,
_cli_phase_label,
Expand Down Expand Up @@ -162,6 +163,10 @@ def integration_install(
else:
_refresh_init_options_speckit_version(project_root)

# Back-fill the bundled agent-context extension for projects that
# predate it (init is otherwise the only path that installs it).
_backfill_agent_context(project_root)

except Exception as exc:
# Attempt rollback of any files written by setup
try:
Expand Down
6 changes: 6 additions & 0 deletions src/specify_cli/integrations/_migrate_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from ._helpers import (
_MANIFEST_READ_ERRORS,
_SharedTemplateRefreshError,
_backfill_agent_context,
_clear_init_options_for_integration,
_cli_error_detail,
_cli_phase_label,
Expand Down Expand Up @@ -92,6 +93,7 @@ def integration_switch(
"shared infrastructure refreshed."
)
raise typer.Exit(0)
_backfill_agent_context(project_root)
console.print(f"[yellow]Integration '{target}' is already the default integration. Nothing to switch.[/yellow]")
raise typer.Exit(0)

Expand Down Expand Up @@ -467,6 +469,10 @@ def integration_upgrade(
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
else:
_refresh_init_options_speckit_version(project_root)

# Back-fill the bundled agent-context extension for projects that
# predate it (init is otherwise the only path that installs it).
_backfill_agent_context(project_root)
except Exception as exc:
# Don't teardown — setup overwrites in-place, so teardown would
# delete files that were working before the upgrade. Just report.
Expand Down
65 changes: 65 additions & 0 deletions tests/extensions/test_extension_agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,3 +453,68 @@ def test_marker_resolution_with_non_dict_yaml(self, tmp_path):
start, end = i._resolve_context_markers(tmp_path)
assert start == IntegrationBase.CONTEXT_MARKER_START
assert end == IntegrationBase.CONTEXT_MARKER_END


class TestEnsureAgentContextExtension:
"""The shared back-fill helper installs + registers the extension once."""

def _registered(self, project_root: Path) -> bool:
from specify_cli.extensions import ExtensionManager

return ExtensionManager(project_root).registry.is_installed("agent-context")

def test_installs_and_registers_when_missing(self, tmp_path):
from specify_cli.extensions import ensure_agent_context_extension

assert not self._registered(tmp_path)
status, _ = ensure_agent_context_extension(tmp_path)
assert status == "installed"
assert self._registered(tmp_path)
# The full package is materialized, not just the config file.
ext_dir = tmp_path / ".specify" / "extensions" / "agent-context"
assert (ext_dir / "extension.yml").is_file()

def test_idempotent_when_already_installed(self, tmp_path):
from specify_cli.extensions import ensure_agent_context_extension

first, _ = ensure_agent_context_extension(tmp_path)
assert first == "installed"
second, detail = ensure_agent_context_extension(tmp_path)
assert second == "already-installed"
assert detail == "already installed"
assert self._registered(tmp_path)

def test_preserves_existing_config_across_backfill(self, tmp_path):
"""A config written before the extension exists (e.g. by an integration
command) must survive install_from_directory's rmtree+copytree, which
would otherwise reset context_file to the bundled template's empty value.
"""
from specify_cli.extensions import ensure_agent_context_extension

_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_markers={"start": "<!-- A -->", "end": "<!-- B -->"},
)
status, _ = ensure_agent_context_extension(tmp_path)
assert status == "installed"
assert self._registered(tmp_path)
cfg = _load_agent_context_config(tmp_path)
assert cfg["context_file"] == "CLAUDE.md"
assert cfg["context_markers"] == {"start": "<!-- A -->", "end": "<!-- B -->"}

def test_existing_config_restored_when_install_fails(self, monkeypatch, tmp_path):
"""install_from_directory rmtree's the dir before copying; if it then
raises, the caller's config must still be restored, not destroyed.
"""
import specify_cli.extensions as ext

_write_ext_config(tmp_path, context_file="CLAUDE.md")
monkeypatch.setattr(
ext.ExtensionManager,
"install_from_directory",
lambda *a, **k: (_ for _ in ()).throw(OSError("disk full mid-copy")),
)
status, _ = ext.ensure_agent_context_extension(tmp_path)
assert status == "error"
assert _load_agent_context_config(tmp_path)["context_file"] == "CLAUDE.md"
84 changes: 84 additions & 0 deletions tests/integrations/test_integration_subcommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -1549,3 +1549,87 @@ 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 back-fill ──────────────────────────────────────────


class TestAgentContextBackfill:
"""Projects that predate the bundled agent-context extension must get it
installed (not just an inert config) when managed via integration commands.
"""

def _simulate_pre_extension(self, project):
"""Drop the agent-context extension to mimic a project created before it
existed: remove the registry entry + package dir, leave an inert config
pointing at the active agent's context file."""
import shutil

from specify_cli import _save_agent_context_config

registry = project / ".specify" / "extensions" / ".registry"
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)
_save_agent_context_config(project, {
"context_file": "CLAUDE.md",
"context_markers": {"start": "<!-- SPECKIT START -->", "end": "<!-- SPECKIT END -->"},
})

def test_install_backfills_extension_and_preserves_config(self, tmp_path):
from specify_cli import _load_agent_context_config
from specify_cli.extensions import ExtensionManager

project = _init_project(tmp_path, "claude")
self._simulate_pre_extension(project)
assert not ExtensionManager(project).registry.is_installed("agent-context")

result = _run_in_project(
project, ["integration", "install", "codex", "--script", "sh"]
)
assert result.exit_code == 0, result.output
# Extension is now actually installed/registered, not just configured.
assert ExtensionManager(project).registry.is_installed("agent-context")
assert (project / ".specify" / "extensions" / "agent-context" / "extension.yml").is_file()
# And the context_file the install wrote is not clobbered to "".
assert _load_agent_context_config(project)["context_file"] == "CLAUDE.md"

def test_switch_to_installed_target_backfills_extension_and_preserves_config(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
self._simulate_pre_extension(project)
assert not ExtensionManager(project).registry.is_installed("agent-context")

result = _run_in_project(project, ["integration", "switch", "codex"])
assert result.exit_code == 0, result.output
# Switching to an already-installed target takes an early-return path,
# but it still manages the agent-context config and must back-fill the
# actual bundled extension package.
assert ExtensionManager(project).registry.is_installed("agent-context")
assert (
project / ".specify" / "extensions" / "agent-context" / "extension.yml"
).is_file()
assert _load_agent_context_config(project)["context_file"] == "AGENTS.md"

def test_switch_same_default_backfills_extension(self, tmp_path):
from specify_cli import _load_agent_context_config
from specify_cli.extensions import ExtensionManager

project = _init_project(tmp_path, "claude")
self._simulate_pre_extension(project)
assert not ExtensionManager(project).registry.is_installed("agent-context")

result = _run_in_project(project, ["integration", "switch", "claude"])
assert result.exit_code == 0, result.output
assert ExtensionManager(project).registry.is_installed("agent-context")
assert (
project / ".specify" / "extensions" / "agent-context" / "extension.yml"
).is_file()
assert _load_agent_context_config(project)["context_file"] == "CLAUDE.md"