diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 186593000c..477599c087 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -109,6 +109,8 @@ def callback( version: bool = typer.Option(False, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."), ): """Show banner when no subcommand is provided.""" + if ctx.invoked_subcommand != "init": + _self_heal_agent_context_extension(Path.cwd()) if ctx.invoked_subcommand is None and "--help" not in sys.argv and "-h" not in sys.argv: show_banner() console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]")) @@ -328,6 +330,60 @@ def _update_agent_context_config_file( _save_agent_context_config(project_root, cfg) +def _agent_context_self_heal_enabled(project_root: Path) -> bool: + registry_path = project_root / ".specify" / "extensions" / ".registry" + if not registry_path.exists(): + return True + try: + data = json.loads(registry_path.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeError): + return True + if not isinstance(data, dict): + return True + extensions = data.get("extensions") + if not isinstance(extensions, dict): + return True + entry = extensions.get("agent-context") + if not isinstance(entry, dict): + return True + return entry.get("enabled", True) is not False + + +def _self_heal_agent_context_extension(project_root: Path) -> None: + if not (project_root / ".specify").is_dir(): + return + if not _agent_context_self_heal_enabled(project_root): + return + + 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() + and (ext_dir / "agent-context-config.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) + + def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: """Resolve the agent-specific skills directory. diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index a95f36563a..1334ef0b4c 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -307,21 +307,12 @@ 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(): + if integration.context_file: _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) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 61ecab91af..f0e871b141 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -3,13 +3,17 @@ from __future__ import annotations import json +import os +import shutil from pathlib import Path import yaml +from typer.testing import CliRunner from specify_cli import ( _load_agent_context_config, _save_agent_context_config, + app, load_init_options, save_init_options, ) @@ -19,6 +23,7 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context" +runner = CliRunner() def _write_ext_config(project_root: Path, **overrides: object) -> None: @@ -284,6 +289,71 @@ def test_remove_skipped_when_disabled(self, tmp_path): assert ctx.read_text(encoding="utf-8") == original +class TestAgentContextSelfHeal: + def test_cli_invocation_restores_missing_extension_and_preserves_config( + self, tmp_path + ): + project = tmp_path / "proj" + project.mkdir() + old_cwd = os.getcwd() + try: + os.chdir(project) + init_result = runner.invoke( + app, + [ + "init", + "--here", + "--integration", + "claude", + "--script", + "sh", + "--ignore-agent-tools", + ], + catch_exceptions=False, + ) + assert init_result.exit_code == 0, init_result.output + + custom_markers = { + "start": "", + "end": "", + } + _write_ext_config( + project, + context_file="CLAUDE.md", + context_markers=custom_markers, + ) + ext_dir = project / ".specify" / "extensions" / "agent-context" + for child in ext_dir.iterdir(): + if child.name == "agent-context-config.yml": + continue + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() + registry = project / ".specify" / "extensions" / ".registry" + data = json.loads(registry.read_text(encoding="utf-8")) + data["extensions"].pop("agent-context", None) + registry.write_text(json.dumps(data), encoding="utf-8") + + result = runner.invoke(app, ["version"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + 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() + data = json.loads( + (project / ".specify" / "extensions" / ".registry").read_text( + encoding="utf-8" + ) + ) + assert data["extensions"]["agent-context"]["enabled"] is True + cfg = _load_agent_context_config(project) + assert cfg["context_file"] == "CLAUDE.md" + assert cfg["context_markers"] == custom_markers + + # ── Extension config writers ───────────────────────────────────────────────── diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 4c09a9163d..649dc17e8c 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -49,7 +49,6 @@ def _write_invalid_manifest(project, key): manifest.write_bytes(b"\xff\xfe\x00") return manifest - def _copy_project_template(tmp_path, template): project = tmp_path / "proj" shutil.copytree(template, project) @@ -76,6 +75,34 @@ def claude_project(tmp_path, status_claude_template): return _copy_project_template(tmp_path, status_claude_template) +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}")) @@ -1036,6 +1063,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() @@ -2039,6 +2081,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() @@ -2192,6 +2246,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"