From aac1f2cdaecc666737e1c57800874a52c07a0728 Mon Sep 17 00:00:00 2001 From: nkgotcode Date: Sat, 6 Jun 2026 09:20:43 -0500 Subject: [PATCH 1/4] fix: self-heal agent-context extension --- src/specify_cli/__init__.py | 56 +++++++++++++++ src/specify_cli/integrations/_helpers.py | 11 +-- .../test_extension_agent_context.py | 70 +++++++++++++++++++ .../test_integration_subcommand.py | 69 +++++++++++++++++- 4 files changed, 195 insertions(+), 11 deletions(-) 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" From 3196cbc00c2a197317f99e86999f5acd7671d0a3 Mon Sep 17 00:00:00 2001 From: nkgotcode Date: Wed, 17 Jun 2026 09:09:05 -0500 Subject: [PATCH 2/4] fix: address agent-context review feedback --- src/specify_cli/__init__.py | 13 ++- src/specify_cli/integrations/_helpers.py | 7 +- .../test_extension_agent_context.py | 79 +++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 477599c087..bcdb514672 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -109,9 +109,10 @@ 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": + help_requested = "--help" in sys.argv or "-h" in sys.argv + if ctx.invoked_subcommand != "init" and not help_requested: _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: + if ctx.invoked_subcommand is None and not help_requested: show_banner() console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]")) console.print() @@ -350,7 +351,12 @@ def _agent_context_self_heal_enabled(project_root: Path) -> bool: def _self_heal_agent_context_extension(project_root: Path) -> None: - if not (project_root / ".specify").is_dir(): + specify_dir = project_root / ".specify" + extensions_dir = specify_dir / "extensions" + ext_dir = extensions_dir / "agent-context" + if specify_dir.is_symlink() or extensions_dir.is_symlink() or ext_dir.is_symlink(): + return + if not specify_dir.is_dir(): return if not _agent_context_self_heal_enabled(project_root): return @@ -358,7 +364,6 @@ def _self_heal_agent_context_extension(project_root: Path) -> None: 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() diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index 1334ef0b4c..4a22d4c895 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -306,8 +306,11 @@ def _update_init_options_for_integration( opts.pop("ai_skills", None) # Update the agent-context extension config BEFORE init-options.json - # so a failure here doesn't leave init-options partially updated. - if integration.context_file: + # so a failure here doesn't leave init-options partially updated. Only + # recreate the config when the extension is actually installed. + ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG + ext_installed = (ext_cfg_path.parent / "extension.yml").is_file() + if ext_cfg_path.exists() or ext_installed: _update_agent_context_config_file( project_root, integration.context_file, diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index f0e871b141..60f0c69f76 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -112,6 +112,10 @@ class _CtxIntegration(ClaudeIntegration): """Use Claude as a concrete integration with a context_file.""" +class _NoCtxIntegration(ClaudeIntegration): + context_file = None + + class TestContextMarkerResolution: def test_defaults_when_ext_config_missing(self, tmp_path): i = _CtxIntegration() @@ -353,6 +357,42 @@ def test_cli_invocation_restores_missing_extension_and_preserves_config( assert cfg["context_file"] == "CLAUDE.md" assert cfg["context_markers"] == custom_markers + def test_cli_help_does_not_self_heal_missing_agent_context_extension(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + save_init_options(project, {"integration": "claude", "ai": "claude"}) + (project / ".specify" / "extensions").mkdir(parents=True) + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["--help"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert not (project / ".specify" / "extensions" / "agent-context").exists() + + def test_self_heal_skips_symlinked_agent_context_extension(self, tmp_path): + project = tmp_path / "proj" + project.mkdir() + save_init_options(project, {"integration": "claude", "ai": "claude"}) + ext_parent = project / ".specify" / "extensions" + ext_parent.mkdir(parents=True) + target = tmp_path / "outside-agent-context" + target.mkdir() + (ext_parent / "agent-context").symlink_to(target, target_is_directory=True) + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["version"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert not (target / "extension.yml").exists() + # ── Extension config writers ───────────────────────────────────────────────── @@ -419,6 +459,45 @@ def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): assert cfg["context_file"] == i.context_file assert "context_markers" in cfg + def test_update_init_options_clears_existing_ext_config_for_no_context_file( + self, tmp_path + ): + from specify_cli import _update_init_options_for_integration + + _write_ext_config(tmp_path, context_file="CLAUDE.md") + i = _NoCtxIntegration() + _update_init_options_for_integration(tmp_path, i) + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_file"] == "" + + def test_update_init_options_does_not_create_config_when_extension_absent( + self, tmp_path + ): + from specify_cli import _update_init_options_for_integration + + i = _NoCtxIntegration() + _update_init_options_for_integration(tmp_path, i) + assert not ( + tmp_path / ".specify" / "extensions" / "agent-context" + / "agent-context-config.yml" + ).exists() + + def test_update_init_options_recreates_missing_config_for_installed_extension( + self, tmp_path + ): + from specify_cli import _update_init_options_for_integration + + ext_dir = tmp_path / ".specify" / "extensions" / "agent-context" + ext_dir.mkdir(parents=True) + (ext_dir / "extension.yml").write_text( + "extension:\n id: agent-context\n", encoding="utf-8" + ) + + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i) + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_file"] == i.context_file + def test_update_init_options_preserves_custom_markers(self, tmp_path): from specify_cli import _update_init_options_for_integration From dc58c8937411e8cd7003a79248a09ca779bfe0bf Mon Sep 17 00:00:00 2001 From: Khanh Le Date: Wed, 17 Jun 2026 10:14:38 -0500 Subject: [PATCH 3/4] test: scope Path.exists monkeypatch --- .../test_integration_subcommand.py | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/integrations/test_integration_subcommand.py b/tests/integrations/test_integration_subcommand.py index 649dc17e8c..af4524e8fd 100644 --- a/tests/integrations/test_integration_subcommand.py +++ b/tests/integrations/test_integration_subcommand.py @@ -49,6 +49,21 @@ def _write_invalid_manifest(project, key): manifest.write_bytes(b"\xff\xfe\x00") return manifest + +def _fail_exists_under(root: Path): + original_exists = Path.exists + root_resolved = root.resolve() + + def fail_exists(self): + try: + self.resolve(strict=False).relative_to(root_resolved) + except (OSError, RuntimeError, ValueError): + return original_exists(self) + raise AssertionError(f"Path.exists() should not be used for {self}") + + return fail_exists + + def _copy_project_template(tmp_path, template): project = tmp_path / "proj" shutil.copytree(template, project) @@ -516,10 +531,7 @@ def test_status_does_not_use_exists_precheck_for_managed_files(self, tmp_path, m manifest = IntegrationManifest("test", project, version="test") manifest.record_existing("tracked.md") - def fail_exists(self): - raise AssertionError(f"Path.exists() should not be used for {self}") - - monkeypatch.setattr(Path, "exists", fail_exists) + monkeypatch.setattr(Path, "exists", _fail_exists_under(project)) missing, modified, invalid, valid = _manifest_file_status( manifest, @@ -532,10 +544,11 @@ def fail_exists(self): assert valid == ["tracked.md"] def test_status_does_not_use_exists_precheck_for_manifest_load(self, copilot_project, monkeypatch): - def fail_exists(self): - raise AssertionError(f"Path.exists() should not be used for {self}") - - monkeypatch.setattr(Path, "exists", fail_exists) + monkeypatch.setattr( + Path, + "exists", + _fail_exists_under(copilot_project / ".specify" / "integrations"), + ) result = _run_in_project(copilot_project, ["integration", "status", "--json"]) From b6d77beb2549ca8de7d8b98f96816fd6d528fa8e Mon Sep 17 00:00:00 2001 From: Khanh Le Date: Wed, 17 Jun 2026 10:22:41 -0500 Subject: [PATCH 4/4] fix: keep agent-context self-heal best effort --- src/specify_cli/__init__.py | 17 +++-- .../test_extension_agent_context.py | 68 +++++++++++++++++++ 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index bcdb514672..2caeea53bd 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -377,13 +377,18 @@ def _self_heal_agent_context_extension(project_root: Path) -> None: bundled_ac = _locate_bundled_extension("agent-context") if bundled_ac is None: - raise ValueError("bundled agent-context extension not found") + return - ext_mgr.install_from_directory( - bundled_ac, - get_speckit_version(), - force=ext_mgr.registry.is_installed("agent-context"), - ) + try: + ext_mgr.install_from_directory( + bundled_ac, + get_speckit_version(), + force=ext_mgr.registry.is_installed("agent-context"), + ) + except Exception: + if existing_cfg is not None: + _save_agent_context_config(project_root, existing_cfg) + return if existing_cfg is not None: _save_agent_context_config(project_root, existing_cfg) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 60f0c69f76..41fdf0675a 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -393,6 +393,74 @@ def test_self_heal_skips_symlinked_agent_context_extension(self, tmp_path): assert result.exit_code == 0, result.output assert not (target / "extension.yml").exists() + def test_self_heal_skips_when_bundled_agent_context_extension_missing( + self, tmp_path, monkeypatch + ): + import specify_cli + + project = tmp_path / "proj" + project.mkdir() + save_init_options(project, {"integration": "claude", "ai": "claude"}) + (project / ".specify" / "extensions").mkdir(parents=True) + monkeypatch.setattr(specify_cli, "_locate_bundled_extension", lambda _id: None) + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["version"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + assert not (project / ".specify" / "extensions" / "agent-context").exists() + + def test_self_heal_restores_existing_config_when_install_fails( + self, tmp_path, monkeypatch + ): + from specify_cli.extensions import ExtensionManager + + project = tmp_path / "proj" + project.mkdir() + save_init_options(project, {"integration": "claude", "ai": "claude"}) + ext_dir = project / ".specify" / "extensions" / "agent-context" + ext_dir.mkdir(parents=True) + custom_markers = { + "start": "", + "end": "", + } + _write_ext_config( + project, + context_file="CLAUDE.md", + context_markers=custom_markers, + ) + + def fail_install(self, *_args, **_kwargs): + _save_agent_context_config( + self.project_root, + { + "context_file": "BROKEN.md", + "context_markers": { + "start": "", + "end": "", + }, + }, + ) + raise RuntimeError("install failed") + + monkeypatch.setattr(ExtensionManager, "install_from_directory", fail_install) + + old_cwd = os.getcwd() + try: + os.chdir(project) + result = runner.invoke(app, ["version"], catch_exceptions=False) + finally: + os.chdir(old_cwd) + + assert result.exit_code == 0, result.output + cfg = _load_agent_context_config(project) + assert cfg["context_file"] == "CLAUDE.md" + assert cfg["context_markers"] == custom_markers + # ── Extension config writers ─────────────────────────────────────────────────