Skip to content
Closed
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
68 changes: 67 additions & 1 deletion src/specify_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +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 is None and "--help" not in sys.argv and "-h" not in sys.argv:
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 not help_requested:
show_banner()
console.print(Align.center("[dim]Run 'specify --help' for usage information[/dim]"))
console.print()
Expand Down Expand Up @@ -328,6 +331,69 @@ 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:
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

from .extensions import ExtensionManager

ext_mgr = ExtensionManager(project_root)
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:
return

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)


def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
"""Resolve the agent-specific skills directory.

Expand Down
14 changes: 4 additions & 10 deletions src/specify_cli/integrations/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -306,22 +306,16 @@ 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.
# 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
if ext_cfg_path.exists():
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,
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
217 changes: 217 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 @@ -107,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()
Expand Down Expand Up @@ -284,6 +293,175 @@ 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

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()

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": "<!-- CUSTOM START -->",
"end": "<!-- CUSTOM 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": "<!-- BROKEN -->",
"end": "<!-- /BROKEN -->",
},
},
)
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 ─────────────────────────────────────────────────


Expand Down Expand Up @@ -349,6 +527,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

Expand Down
Loading