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..5135ca7e63 100644 --- a/src/specify_cli/extensions.py +++ b/src/specify_cli/extensions.py @@ -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. diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index a95f36563a..45f1b13df7 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, @@ -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: diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py index 66fd2b2d26..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, @@ -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: diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py index 01cb51d687..ad34647cc0 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, @@ -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) @@ -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. diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 61ecab91af..6e15ef3aab 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -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": "", "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" diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index fd9eada5cc..d62e11d383 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -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": "", "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"