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
56 changes: 56 additions & 0 deletions src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]"))
Expand Down Expand Up @@ -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.

Expand Down
11 changes: 1 addition & 10 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
70 changes: 70 additions & 0 deletions tests/extensions/test_extension_agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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:
Expand Down Expand Up @@ -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": "<!-- CUSTOM START -->",
"end": "<!-- CUSTOM 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 ─────────────────────────────────────────────────


Expand Down
69 changes: 68 additions & 1 deletion tests/integrations/test_integration_subcommand.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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}"))
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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"
Expand Down