From 0f89ca2f38f83cde490166a5cadbe4a4136f0af7 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 6 Jun 2026 19:53:07 +0200 Subject: [PATCH 1/4] fix(extensions): back-fill agent-context extension in integration install/switch/upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `specify init` was the only path that installed the bundled agent-context extension. `specify integration install/switch/upgrade` wrote/refreshed its config file without installing it, leaving projects that predate the extension with an inert config — and, once inline updates are removed in v0.12.0, with frozen context files. Extract init's install logic into a shared, idempotent `ensure_agent_context_extension()` helper and call it from init, integration install, switch, and upgrade so re-running any of these back-fills the extension. Fixes #2881 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/commands/init.py | 31 ++++------------ src/specify_cli/extensions.py | 36 +++++++++++++++++++ .../integrations/_install_commands.py | 7 ++++ .../integrations/_migrate_commands.py | 15 ++++++++ .../test_extension_agent_context.py | 30 ++++++++++++++++ 5 files changed, 94 insertions(+), 25 deletions(-) diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index 68f5bed31f..dffbb8ea53 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -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 diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index bddf637cbc..eae7249d1b 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1695,6 +1695,42 @@ 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}", + ) + manager.install_from_directory(bundled, version or get_speckit_version()) + 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. diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py index 66fd2b2d26..82c7e2cd58 100644 --- a/src/specify_cli/integrations/_install_commands.py +++ b/src/specify_cli/integrations/_install_commands.py @@ -162,6 +162,13 @@ 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). + from ..extensions import ensure_agent_context_extension + ac_status, ac_detail = ensure_agent_context_extension(project_root) + if ac_status not in ("installed", "already-installed"): + console.print(f"[yellow]agent-context: {ac_detail}[/yellow]") + except Exception as exc: # Attempt rollback of any files written by setup try: diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 01cb51d687..cccd4da7ad 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -269,6 +269,14 @@ def integration_switch( parsed_options=parsed_options, ) + # Back-fill the bundled agent-context extension for projects that + # predate it, before re-registering so its command is wired to the + # new agent too. + from ..extensions import ensure_agent_context_extension + ac_status, ac_detail = ensure_agent_context_extension(project_root) + if ac_status not in ("installed", "already-installed"): + console.print(f"[yellow]agent-context: {ac_detail}[/yellow]") + # Re-register extension commands for the new agent so that # previously-installed extensions are available in the new integration. try: @@ -467,6 +475,13 @@ 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). + from ..extensions import ensure_agent_context_extension + ac_status, ac_detail = ensure_agent_context_extension(project_root) + if ac_status not in ("installed", "already-installed"): + console.print(f"[yellow]agent-context: {ac_detail}[/yellow]") except Exception as exc: # Don't teardown — setup overwrites in-place, so teardown would # delete files that were working before the upgrade. Just report. diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 61ecab91af..e3d708937b 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -453,3 +453,33 @@ 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) From fecf47b368548bd5ec7dffa952be2a8c64a3fed6 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 6 Jun 2026 21:43:33 +0200 Subject: [PATCH 2/4] fix(extensions): make agent-context back-fill clobber-safe install_from_directory rmtree's the extension dir and copies the bundled template (empty context_file, default markers). When a caller wrote the config before the back-fill (integration install/switch/upgrade via _update_init_options_for_integration), the real context_file was reset to "". Preserve and restore any pre-existing config around the install, in a finally so it survives even if install_from_directory raises after its internal rmtree (otherwise the config was silently destroyed while the command reported success). Add regression tests for both the success and failure paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/extensions.py | 21 ++++++++++- .../test_extension_agent_context.py | 35 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/extensions.py b/src/specify_cli/extensions.py index eae7249d1b..5135ca7e63 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -1724,7 +1724,26 @@ def ensure_agent_context_extension( f"bundled extension not found — installation may be incomplete. " f"Run: {REINSTALL_COMMAND}", ) - manager.install_from_directory(bundled, version or get_speckit_version()) + # 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() diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index e3d708937b..6e15ef3aab 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -483,3 +483,38 @@ def test_idempotent_when_already_installed(self, 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": "", "end": ""}, + ) + 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": "", "end": ""} + + 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" From 6de8f923f9e223a770688576c6b1241301b7549b Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 6 Jun 2026 21:52:19 +0200 Subject: [PATCH 3/4] refactor(integrations): extract _backfill_agent_context + end-to-end test Address code-review cleanup findings on the back-fill change: - Replace the copy-pasted call+warn snippet at the install/switch/upgrade sites with a shared `_backfill_agent_context()` helper so the status set and warning format live in one place. - Add an end-to-end test driving `specify integration install` on a project that predates the extension: asserts the extension is actually installed and registered (not just configured) and that context_file is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/integrations/_helpers.py | 14 ++++++ .../integrations/_install_commands.py | 6 +-- .../integrations/_migrate_commands.py | 11 ++--- .../test_integration_subcommand.py | 45 +++++++++++++++++++ 4 files changed, 64 insertions(+), 12 deletions(-) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index a95f36563a..133f6e30b4 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -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, diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py index 82c7e2cd58..7e624aad04 100644 --- a/src/specify_cli/integrations/_install_commands.py +++ b/src/specify_cli/integrations/_install_commands.py @@ -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, @@ -164,10 +165,7 @@ def integration_install( # Back-fill the bundled agent-context extension for projects that # predate it (init is otherwise the only path that installs it). - from ..extensions import ensure_agent_context_extension - ac_status, ac_detail = ensure_agent_context_extension(project_root) - if ac_status not in ("installed", "already-installed"): - console.print(f"[yellow]agent-context: {ac_detail}[/yellow]") + _backfill_agent_context(project_root) except Exception as exc: # Attempt rollback of any files written by setup diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index cccd4da7ad..806e64d672 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -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, @@ -272,10 +273,7 @@ def integration_switch( # Back-fill the bundled agent-context extension for projects that # predate it, before re-registering so its command is wired to the # new agent too. - from ..extensions import ensure_agent_context_extension - ac_status, ac_detail = ensure_agent_context_extension(project_root) - if ac_status not in ("installed", "already-installed"): - console.print(f"[yellow]agent-context: {ac_detail}[/yellow]") + _backfill_agent_context(project_root) # Re-register extension commands for the new agent so that # previously-installed extensions are available in the new integration. @@ -478,10 +476,7 @@ def integration_upgrade( # Back-fill the bundled agent-context extension for projects that # predate it (init is otherwise the only path that installs it). - from ..extensions import ensure_agent_context_extension - ac_status, ac_detail = ensure_agent_context_extension(project_root) - if ac_status not in ("installed", "already-installed"): - console.print(f"[yellow]agent-context: {ac_detail}[/yellow]") + _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. diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index fd9eada5cc..5ff963afb3 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1549,3 +1549,48 @@ 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": "", "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" From ab5ad248a31bf48ccc49178da7b10703577d1a81 Mon Sep 17 00:00:00 2001 From: Pascal Date: Sat, 6 Jun 2026 22:53:48 +0200 Subject: [PATCH 4/4] fix(integrations): back-fill agent-context on switch early-return paths Cover the integration_switch early returns ("already default", shared-infra refresh) and _set_default_integration so switching to / refreshing an already-installed integration also back-fills the bundled agent-context extension. Add tests for both switch paths. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/specify_cli/integrations/_helpers.py | 1 + .../integrations/_migrate_commands.py | 6 +-- .../test_integration_subcommand.py | 39 +++++++++++++++++++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 133f6e30b4..45f1b13df7 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -389,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: diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 806e64d672..ad34647cc0 100644 --- a/src/specify_cli/integrations/_migrate_commands.py +++ b/src/specify_cli/integrations/_migrate_commands.py @@ -93,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) @@ -270,11 +271,6 @@ def integration_switch( parsed_options=parsed_options, ) - # Back-fill the bundled agent-context extension for projects that - # predate it, before re-registering so its command is wired to the - # new agent too. - _backfill_agent_context(project_root) - # Re-register extension commands for the new agent so that # previously-installed extensions are available in the new integration. try: diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 5ff963afb3..d62e11d383 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -1594,3 +1594,42 @@ def test_install_backfills_extension_and_preserves_config(self, tmp_path): 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"