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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The script reads the agent-context extension config at
- `context_file` — the path of the coding agent context file to manage.
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.

It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/**/plan.md`).

If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.

Expand All @@ -23,4 +23,4 @@ If `context_file` is empty or the file cannot be located, the command reports no
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`

When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.
When `plan_path` is omitted, the script auto-detects the most recently modified `plan.md` anywhere under `specs/`.
10 changes: 5 additions & 5 deletions extensions/agent-context/scripts/bash/update-agent-context.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# Usage: update-agent-context.sh [plan_path]
#
# When `plan_path` is omitted, the script picks the most recently modified
# `specs/*/plan.md` if any exist, otherwise emits the section without a
# `specs/**/plan.md` if any exist, otherwise emits the section without a
# concrete plan path.

set -euo pipefail
Expand Down Expand Up @@ -122,15 +122,15 @@ unset _cf_parts _seg

PLAN_PATH="${1:-}"
if [[ -z "$PLAN_PATH" ]]; then
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
# Use find + sort by modification time to avoid ls/head fragility with
# spaces in paths or SIGPIPE from pipefail.
# Pick the most recently modified plan.md anywhere under specs/.
# Use Python pathlib + stat sorting to avoid shell glob portability issues,
# ls/head fragility with spaces, and SIGPIPE from pipefail.
_plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
import sys, os
from pathlib import Path
specs = Path(sys.argv[1]) / "specs"
plans = sorted(
specs.glob("*/plan.md"),
specs.glob("**/plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
Comment on lines 128 to 136
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,12 @@ if ($cm) {
}

if (-not $PlanPath) {
# Discover plan.md exactly one level deep (specs/<feature>/plan.md),
# matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
# $ErrorActionPreference = 'Stop' don't abort the script.
# Discover plan.md anywhere under specs/, picking the most recently modified file.
# Wrap in try/catch so access errors under $ErrorActionPreference = 'Stop' don't
# abort the script.
try {
$specsDir = Join-Path $ProjectRoot 'specs'
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
$candidate = Get-ChildItem -Path $specsDir -Recurse -Filter 'plan.md' -File -ErrorAction SilentlyContinue |
Where-Object { $_ } |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
Expand Down
114 changes: 97 additions & 17 deletions tests/extensions/test_extension_agent_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
from __future__ import annotations

import json
import os
import shutil
import subprocess
from pathlib import Path

import pytest
import yaml

from specify_cli import (
Expand All @@ -15,10 +19,17 @@
)
from specify_cli.integrations.base import IntegrationBase
from specify_cli.integrations.claude import ClaudeIntegration
from tests.conftest import requires_bash


PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context"
HAS_PWSH = shutil.which("pwsh") is not None
_WINDOWS_POWERSHELL = (
(shutil.which("powershell.exe") or shutil.which("powershell"))
if os.name == "nt"
else None
)


def _write_ext_config(project_root: Path, **overrides: object) -> None:
Expand All @@ -36,6 +47,22 @@ def _write_ext_config(project_root: Path, **overrides: object) -> None:
_save_agent_context_config(project_root, cfg)


def _prepare_agent_context_project(project_root: Path) -> None:
(project_root / ".specify" / "extensions" / "agent-context").mkdir(
parents=True,
exist_ok=True,
)
_write_ext_config(project_root, context_file="CLAUDE.md")


def _clean_env() -> dict[str, str]:
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env


# ── Bundled extension layout ─────────────────────────────────────────────────


Expand Down Expand Up @@ -74,7 +101,9 @@ def test_command_file_exists(self):

def test_bundled_scripts_exist(self):
assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file()
assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file()
assert (
EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
).is_file()

def test_bash_script_reads_extension_config(self):
text = (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").read_text(
Expand Down Expand Up @@ -117,7 +146,10 @@ def test_defaults_when_ext_config_missing(self, tmp_path):
def test_defaults_when_markers_field_missing(self, tmp_path):
"""Config file exists with context_file but no context_markers key."""
cfg_path = (
tmp_path / ".specify" / "extensions" / "agent-context"
tmp_path
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
cfg_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -173,9 +205,7 @@ def test_upsert_uses_default_markers(self, tmp_path):
assert IntegrationBase.CONTEXT_MARKER_END in text

def test_upsert_uses_custom_markers(self, tmp_path):
i = self._setup(
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
)
i = self._setup(tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"})
i.upsert_context_section(tmp_path)
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "<!-- BEGIN -->" in text
Expand All @@ -185,9 +215,7 @@ def test_upsert_uses_custom_markers(self, tmp_path):
assert IntegrationBase.CONTEXT_MARKER_END not in text

def test_upsert_replaces_existing_custom_section(self, tmp_path):
i = self._setup(
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
)
i = self._setup(tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"})
ctx = tmp_path / "CLAUDE.md"
ctx.write_text(
"# header\n\n<!-- BEGIN -->\nold body\n<!-- END -->\n\nfooter\n",
Expand All @@ -201,9 +229,7 @@ def test_upsert_replaces_existing_custom_section(self, tmp_path):
assert "footer" in text

def test_remove_uses_custom_markers(self, tmp_path):
i = self._setup(
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
)
i = self._setup(tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"})
ctx = tmp_path / "CLAUDE.md"
ctx.write_text(
"preamble\n\n<!-- BEGIN -->\nbody\n<!-- END -->\nepilogue\n",
Expand Down Expand Up @@ -373,9 +399,7 @@ def test_reinit_preserves_custom_markers(self, tmp_path):
context_markers={"start": "<!-- CUSTOM -->", "end": "<!-- /CUSTOM -->"},
)
# Re-running init updates context_file but must preserve markers
_update_agent_context_config_file(
tmp_path, "CLAUDE.md", preserve_markers=True
)
_update_agent_context_config_file(tmp_path, "CLAUDE.md", preserve_markers=True)
cfg = _load_agent_context_config(tmp_path)
assert cfg["context_markers"] == {
"start": "<!-- CUSTOM -->",
Expand Down Expand Up @@ -416,7 +440,10 @@ class TestCorruptExtensionConfig:
def test_marker_resolution_with_corrupt_yaml(self, tmp_path):
"""Corrupt YAML in agent-context-config.yml falls back to defaults."""
cfg_path = (
tmp_path / ".specify" / "extensions" / "agent-context"
tmp_path
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
cfg_path.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -426,10 +453,60 @@ def test_marker_resolution_with_corrupt_yaml(self, tmp_path):
assert start == IntegrationBase.CONTEXT_MARKER_START
assert end == IntegrationBase.CONTEXT_MARKER_END


# ── Script discovery for nested plan paths ──────────────────────────────────


class TestNestedPlanDiscovery:
@requires_bash
def test_bash_script_resolves_nested_plan(self, tmp_path):
_prepare_agent_context_project(tmp_path)
nested_plan = tmp_path / "specs" / "scope" / "feature" / "plan.md"
nested_plan.parent.mkdir(parents=True, exist_ok=True)
nested_plan.write_text("# plan\n", encoding="utf-8")
script = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
result = subprocess.run(
["bash", str(script)],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/scope/feature/plan.md" in text
Comment on lines +476 to +478

@pytest.mark.skipif(
not (HAS_PWSH or _WINDOWS_POWERSHELL),
reason="no PowerShell available",
)
def test_powershell_script_resolves_nested_plan(self, tmp_path):
_prepare_agent_context_project(tmp_path)
nested_plan = tmp_path / "specs" / "scope" / "feature" / "plan.md"
nested_plan.parent.mkdir(parents=True, exist_ok=True)
nested_plan.write_text("# plan\n", encoding="utf-8")
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script)],
cwd=tmp_path,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
assert "specs/scope/feature/plan.md" in text
Comment on lines +499 to +501

def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path):
"""upsert_context_section still works when config YAML is corrupt."""
cfg_path = (
tmp_path / ".specify" / "extensions" / "agent-context"
tmp_path
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
cfg_path.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -444,7 +521,10 @@ def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path):
def test_marker_resolution_with_non_dict_yaml(self, tmp_path):
"""Config file containing a scalar (not a dict) falls back to defaults."""
cfg_path = (
tmp_path / ".specify" / "extensions" / "agent-context"
tmp_path
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
cfg_path.parent.mkdir(parents=True, exist_ok=True)
Expand Down