fix(agent-context): support multiple context files safely (#2969)

* fix(agent-context): support multiple context files safely

* fix(agent-context): harden context file validation

* fix(agent-context): preserve disabled context target

* fix(agent-context): address review follow-ups

* fix(agent-context): dedupe PowerShell context files

* fix(agent-context): align context file dedupe

* fix(agent-context): align bash context file dedupe

* fix(agent-context): preserve disabled display target

* fix(agent-context): require yaml-capable updater python

* fix(agent-context): preserve context files config

* fix(agent-context): align context file fallbacks

* fix(agent-context): share context file resolution

---------

Co-authored-by: AustinZ21 <AustinZ21@users.noreply.github.com>
This commit is contained in:
Austin Z.
2026-06-22 10:10:55 -07:00
committed by GitHub
parent cac16dd1d7
commit bbdf1b8f40
15 changed files with 1486 additions and 203 deletions

View File

@@ -10,6 +10,7 @@ Not every Spec Kit user wants Spec Kit to write into the coding agent's context
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value.
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
## Commands
@@ -27,6 +28,12 @@ All configuration flows through the extension's own config file at
# Path to the coding agent context file managed by this extension
context_file: CLAUDE.md
# Optional list of coding agent context files to manage together.
# When non-empty, this takes precedence over context_file.
context_files:
- AGENTS.md
- CLAUDE.md
# Delimiters for the managed Spec Kit section
context_markers:
start: "<!-- SPECKIT START -->"
@@ -34,6 +41,7 @@ context_markers:
```
- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
## Requirements
@@ -55,3 +63,4 @@ specify extension disable agent-context
```
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out.

View File

@@ -2,12 +2,17 @@
# These values are populated automatically by `specify init` and
# `specify integration use` / `specify integration install`.
# Path (relative to the project root) to the coding agent context file
# Path (relative to the project root) to the default coding agent context file
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
# .github/copilot-instructions.md). Set automatically from the active
# integration and regenerated during `specify init` or integration switches.
context_file: ""
# Optional list of project-relative coding agent context files managed by this
# extension. When non-empty, this list takes precedence over `context_file`.
# Use this for projects that intentionally keep multiple agent anchors in sync.
context_files: []
# Delimiters for the managed Spec Kit section.
# Edit these to use custom markers.
context_markers:

View File

@@ -1,5 +1,5 @@
---
description: "Refresh the managed Spec Kit section in the coding agent context file"
description: "Refresh the managed Spec Kit section in coding agent context file(s)"
---
# Update Coding Agent Context
@@ -12,11 +12,12 @@ The script reads the agent-context extension config at
`.specify/extensions/agent-context/agent-context-config.yml` to discover:
- `context_file` — the path of the coding agent context file to manage.
- `context_files` — optional project-relative paths for multiple coding agent context files. When non-empty, the script updates each listed file and the list takes precedence over `context_file`.
- `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`).
If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.
If `context_files` and `context_file` are empty, the command reports nothing to do and exits successfully. Context file paths must stay project-relative; absolute paths, Windows drive paths, backslash separators, and `..` path segments are rejected.
## Execution

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# update-agent-context.sh
#
# Refresh the managed Spec Kit section in the coding agent's context file
# Refresh the managed Spec Kit section in the coding agent's context file(s)
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
#
# Reads `context_file` and `context_markers.{start,end}` from the
# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the
# agent-context extension config:
# .specify/extensions/agent-context/agent-context-config.yml
#
@@ -26,22 +26,41 @@ if [[ ! -f "$EXT_CONFIG" ]]; then
exit 0
fi
# Locate a suitable Python interpreter (python3, then python).
# Locate a Python 3 interpreter with PyYAML available.
_python=""
if command -v python3 >/dev/null 2>&1; then
_python="python3"
elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then
_python="python"
fi
_python_candidates=()
[[ -n "${SPECKIT_PYTHON:-}" ]] && _python_candidates+=("$SPECKIT_PYTHON")
_python_candidates+=("python3" "python")
for _candidate in "${_python_candidates[@]}"; do
if command -v "$_candidate" >/dev/null 2>&1 \
&& "$_candidate" - <<'PY' >/dev/null 2>&1
import sys
try:
import yaml # noqa: F401
except ImportError:
sys.exit(1)
sys.exit(0 if sys.version_info[0] == 3 else 1)
PY
then
_python="$_candidate"
break
fi
done
unset _candidate _python_candidates
if [[ -z "$_python" ]]; then
echo "agent-context: Python 3 not found on PATH; skipping update." >&2
echo "agent-context: Python 3 with PyYAML not found on PATH; skipping update." >&2
echo " To resolve: pip install pyyaml (or install it into the environment used by python3)." >&2
exit 0
fi
_case_insensitive_context_files=0
case "$(uname -s 2>/dev/null || true)" in
MINGW*|MSYS*|CYGWIN*) _case_insensitive_context_files=1 ;;
esac
# Parse extension config once; emit three newline-separated fields:
# context_file, context_markers.start, context_markers.end
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY'
# Parse extension config once; emit context files as JSON, followed by marker strings.
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY'
import json
import sys
try:
import yaml
@@ -73,7 +92,28 @@ def get_str(obj, *keys):
else:
return ""
return node if isinstance(node, str) else ""
print(get_str(data, "context_file"))
context_files = []
seen_context_files = set()
case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin"))
raw_files = data.get("context_files")
if isinstance(raw_files, list):
for value in raw_files:
if not isinstance(value, str):
continue
candidate = value.strip()
if not candidate:
continue
key = candidate.casefold() if case_insensitive else candidate
if key in seen_context_files:
continue
context_files.append(candidate)
seen_context_files.add(key)
if not context_files:
raw_file = get_str(data, "context_file")
candidate = raw_file.strip()
if candidate:
context_files.append(candidate)
print(json.dumps(context_files))
print(get_str(data, "context_markers", "start"))
print(get_str(data, "context_markers", "end"))
PY
@@ -87,31 +127,71 @@ while IFS= read -r _line || [[ -n "$_line" ]]; do
_opts_lines+=("$_line")
done < <(printf '%s\n' "$_raw_opts")
if (( ${#_opts_lines[@]} < 3 )); then
echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
echo "agent-context: malformed config parser output; expected 3 lines (context_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
exit 0
fi
CONTEXT_FILE="${_opts_lines[0]}"
CONTEXT_FILES_JSON="${_opts_lines[0]}"
MARKER_START="${_opts_lines[1]}"
MARKER_END="${_opts_lines[2]}"
if [[ -z "$CONTEXT_FILE" ]]; then
echo "agent-context: context_file not set in extension config; nothing to do." >&2
if ! _context_files_raw="$("$_python" - "$CONTEXT_FILES_JSON" <<'PY'
import json
import sys
try:
data = json.loads(sys.argv[1])
except Exception:
data = []
if not isinstance(data, list):
data = []
for value in data:
if isinstance(value, str) and value:
print(value)
PY
)"; then
echo "agent-context: malformed context_files parser output; skipping update." >&2
exit 0
fi
# Reject absolute paths, backslash separators, and '..' path segments in context_file
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2
exit 1
CONTEXT_FILES=()
while IFS= read -r _line || [[ -n "$_line" ]]; do
[[ -n "$_line" ]] && CONTEXT_FILES+=("$_line")
done < <(printf '%s\n' "$_context_files_raw")
if (( ${#CONTEXT_FILES[@]} == 0 )); then
echo "agent-context: context_files/context_file not set in extension config; nothing to do." >&2
exit 0
fi
if [[ "$CONTEXT_FILE" == *\\* ]]; then
echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2
exit 1
fi
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
for _seg in "${_cf_parts[@]}"; do
if [[ "$_seg" == ".." ]]; then
echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
# Reject absolute paths, backslash separators, and '..' path segments in context files
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
echo "agent-context: context files must be project-relative paths; got '$CONTEXT_FILE'." >&2
exit 1
fi
if [[ "$CONTEXT_FILE" == *\\* ]]; then
echo "agent-context: context files must not contain backslash separators; got '$CONTEXT_FILE'." >&2
exit 1
fi
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
for _seg in "${_cf_parts[@]}"; do
if [[ "$_seg" == ".." ]]; then
echo "agent-context: context files must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
exit 1
fi
done
if ! "$_python" - "$PROJECT_ROOT" "$CONTEXT_FILE" <<'PY'
import sys
from pathlib import Path
root = Path(sys.argv[1]).resolve()
target = (root / sys.argv[2]).resolve(strict=False)
try:
target.relative_to(root)
except ValueError:
sys.exit(1)
PY
then
echo "agent-context: context file path resolves outside the project root; got '$CONTEXT_FILE'." >&2
exit 1
fi
done
@@ -142,9 +222,6 @@ PY
fi
fi
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
mkdir -p "$(dirname "$CTX_PATH")"
# Build the managed section
TMP_SECTION="$(mktemp)"
trap 'rm -f "$TMP_SECTION"' EXIT
@@ -158,7 +235,11 @@ trap 'rm -f "$TMP_SECTION"' EXIT
echo "$MARKER_END"
} > "$TMP_SECTION"
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
mkdir -p "$(dirname "$CTX_PATH")"
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
import sys, os
ctx_path, start, end, section_path = sys.argv[1:5]
with open(section_path, "r", encoding="utf-8") as fh:
@@ -197,4 +278,5 @@ with open(ctx_path, "wb") as fh:
fh.write(new_content.encode("utf-8"))
PY
echo "agent-context: updated $CONTEXT_FILE"
echo "agent-context: updated $CONTEXT_FILE"
done

View File

@@ -1,10 +1,10 @@
#!/usr/bin/env pwsh
# update-agent-context.ps1
#
# Refresh the managed Spec Kit section in the coding agent's context file
# Refresh the managed Spec Kit section in the coding agent's context file(s)
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
#
# Reads `context_file` and `context_markers.{start,end}` from the
# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the
# agent-context extension config:
# .specify/extensions/agent-context/agent-context-config.yml
#
@@ -52,6 +52,66 @@ function Test-ConfigObject {
return $false
}
function Resolve-ContextPath {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$RelativePath
)
$rootFull = [System.IO.Path]::GetFullPath($Root)
$segments = $RelativePath -split '/'
$resolved = $rootFull
foreach ($segment in $segments) {
if ([string]::IsNullOrWhiteSpace($segment) -or $segment -eq '.') {
continue
}
$candidate = [System.IO.Path]::GetFullPath((Join-Path $resolved $segment))
if (Test-Path -LiteralPath $candidate) {
$item = Get-Item -LiteralPath $candidate -Force
if ($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) {
$target = $item.Target
if ($target -is [System.Array]) {
$target = $target[0]
}
if ($target) {
if ([System.IO.Path]::IsPathRooted($target)) {
$candidate = [System.IO.Path]::GetFullPath($target)
} else {
$candidate = [System.IO.Path]::GetFullPath(
(Join-Path (Split-Path -Parent $candidate) $target)
)
}
}
}
}
$resolved = $candidate
}
return $resolved
}
function Test-IsSubPath {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string]$Path
)
$comparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) {
[System.StringComparison]::OrdinalIgnoreCase
} else {
[System.StringComparison]::Ordinal
}
$rootFull = [System.IO.Path]::GetFullPath($Root).TrimEnd(
[System.IO.Path]::DirectorySeparatorChar,
[System.IO.Path]::AltDirectorySeparatorChar
)
$pathFull = [System.IO.Path]::GetFullPath($Path)
return $pathFull.Equals($rootFull, $comparison) -or
$pathFull.StartsWith($rootFull + [System.IO.Path]::DirectorySeparatorChar, $comparison)
}
$ErrorActionPreference = 'Stop'
$DefaultStart = '<!-- SPECKIT START -->'
$DefaultEnd = '<!-- SPECKIT END -->'
@@ -75,11 +135,16 @@ if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
if ($null -eq $Options) {
# ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
$pythonCmd = $null
foreach ($candidate in @('python3', 'python')) {
$pythonCandidates = @()
if ($env:SPECKIT_PYTHON) {
$pythonCandidates += $env:SPECKIT_PYTHON
}
$pythonCandidates += @('python3', 'python')
foreach ($candidate in $pythonCandidates) {
if (Get-Command $candidate -ErrorAction SilentlyContinue) {
# Verify it is Python 3
$verOut = & $candidate --version 2>&1
if ($verOut -match 'Python 3') {
# Verify it is Python 3 with PyYAML available.
$null = & $candidate -c "import sys; import yaml; sys.exit(0 if sys.version_info[0] == 3 else 1)" 2>$null
if ($LASTEXITCODE -eq 0) {
$pythonCmd = $candidate
break
}
@@ -87,8 +152,10 @@ if ($null -eq $Options) {
}
if ($pythonCmd) {
$pyScript = $null
try {
$jsonOut = & $pythonCmd -c @'
$pyScript = [System.IO.Path]::GetTempFileName()
Set-Content -LiteralPath $pyScript -Encoding UTF8 -Value @'
import json
import sys
try:
@@ -114,12 +181,17 @@ if not isinstance(data, dict):
data = {}
print(json.dumps(data))
'@ $ExtConfig
'@
$jsonOut = & $pythonCmd $pyScript $ExtConfig
if ($LASTEXITCODE -eq 0 -and $jsonOut) {
$Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop
}
} catch {
$Options = $null
} finally {
if ($pyScript -and (Test-Path -LiteralPath $pyScript)) {
Remove-Item -LiteralPath $pyScript -Force -ErrorAction SilentlyContinue
}
}
}
@@ -134,21 +206,63 @@ if (-not (Test-ConfigObject -Object $Options)) {
exit 0
}
$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file'
if (-not $ContextFile) {
Write-Warning 'agent-context: context_file not set in extension config; nothing to do.'
$ConfiguredContextFiles = Get-ConfigValue -Object $Options -Key 'context_files'
$ContextFiles = @()
if ($null -ne $ConfiguredContextFiles) {
foreach ($item in @($ConfiguredContextFiles)) {
if ($item -is [string] -and -not [string]::IsNullOrWhiteSpace($item)) {
$ContextFiles += $item.Trim()
}
}
}
if ($ContextFiles.Count -eq 0) {
$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file'
if ($ContextFile -is [string] -and -not [string]::IsNullOrWhiteSpace($ContextFile)) {
$ContextFiles += $ContextFile.Trim()
}
}
$pathComparison = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) {
[System.StringComparer]::OrdinalIgnoreCase
} else {
[System.StringComparer]::Ordinal
}
$seenContextFiles = [System.Collections.Generic.HashSet[string]]::new($pathComparison)
$dedupedContextFiles = @()
foreach ($ContextFile in $ContextFiles) {
if ($seenContextFiles.Add($ContextFile)) {
$dedupedContextFiles += $ContextFile
}
}
$ContextFiles = $dedupedContextFiles
if ($ContextFiles.Count -eq 0) {
Write-Warning 'agent-context: context_files/context_file not set in extension config; nothing to do.'
exit 0
}
# Reject absolute paths and '..' path segments in context_file
if ([System.IO.Path]::IsPathRooted($ContextFile)) {
Write-Warning "agent-context: context_file must be a project-relative path; got '$ContextFile'."
exit 1
}
$cfSegments = $ContextFile -split '[/\\]'
if ($cfSegments -contains '..') {
Write-Warning "agent-context: context_file must not contain '..' path segments; got '$ContextFile'."
exit 1
foreach ($ContextFile in $ContextFiles) {
# Reject absolute paths, drive-qualified paths, backslash separators, and '..' path segments in context files
if ($ContextFile -match '^[A-Za-z]:') {
Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'."
exit 1
}
if ([System.IO.Path]::IsPathRooted($ContextFile)) {
Write-Warning "agent-context: context files must be project-relative paths; got '$ContextFile'."
exit 1
}
if ($ContextFile.Contains('\')) {
Write-Warning "agent-context: context files must not contain backslash separators; got '$ContextFile'."
exit 1
}
$cfSegments = $ContextFile -split '[/\\]'
if ($cfSegments -contains '..') {
Write-Warning "agent-context: context files must not contain '..' path segments; got '$ContextFile'."
exit 1
}
$resolvedTarget = Resolve-ContextPath -Root $ProjectRoot -RelativePath $ContextFile
if (-not (Test-IsSubPath -Root $ProjectRoot -Path $resolvedTarget)) {
Write-Warning "agent-context: context file path resolves outside the project root; got '$ContextFile'."
exit 1
}
}
$MarkerStart = $DefaultStart
@@ -184,12 +298,6 @@ if (-not $PlanPath) {
}
}
$CtxPath = Join-Path $ProjectRoot $ContextFile
$CtxDir = Split-Path -Parent $CtxPath
if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) {
New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null
}
$lines = @($MarkerStart,
'For additional context about technologies to be used, project structure,',
'shell commands, and other important information, read the current plan')
@@ -199,39 +307,47 @@ if ($PlanPath) {
$lines += $MarkerEnd
$Section = ($lines -join "`n") + "`n"
if (Test-Path -LiteralPath $CtxPath) {
$rawBytes = [System.IO.File]::ReadAllBytes($CtxPath)
# Strip UTF-8 BOM if present
if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) {
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3)
} else {
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
foreach ($ContextFile in $ContextFiles) {
$CtxPath = Join-Path $ProjectRoot $ContextFile
$CtxDir = Split-Path -Parent $CtxPath
if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) {
New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null
}
$s = $content.IndexOf($MarkerStart)
$e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) }
if (Test-Path -LiteralPath $CtxPath) {
$rawBytes = [System.IO.File]::ReadAllBytes($CtxPath)
# Strip UTF-8 BOM if present
if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) {
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3)
} else {
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
}
if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) {
$endOfMarker = $e + $MarkerEnd.Length
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
$newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker)
} elseif ($s -ge 0) {
$newContent = $content.Substring(0, $s) + $Section
} elseif ($e -ge 0) {
$endOfMarker = $e + $MarkerEnd.Length
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
$newContent = $Section + $content.Substring($endOfMarker)
$s = $content.IndexOf($MarkerStart)
$e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) }
if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) {
$endOfMarker = $e + $MarkerEnd.Length
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
$newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker)
} elseif ($s -ge 0) {
$newContent = $content.Substring(0, $s) + $Section
} elseif ($e -ge 0) {
$endOfMarker = $e + $MarkerEnd.Length
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
$newContent = $Section + $content.Substring($endOfMarker)
} else {
if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section }
}
} else {
if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section }
$newContent = $Section
}
} else {
$newContent = $Section
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "agent-context: updated $ContextFile"
}
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "agent-context: updated $ContextFile"

View File

@@ -277,6 +277,7 @@ def _load_agent_context_config(project_root: Path) -> dict[str, Any]:
defaults: dict[str, Any] = {
"context_file": "",
"context_files": [],
"context_markers": {
"start": IntegrationBase.CONTEXT_MARKER_START,
"end": IntegrationBase.CONTEXT_MARKER_END,
@@ -308,6 +309,7 @@ def _update_agent_context_config_file(
context_file: str | None,
*,
preserve_markers: bool = True,
preserve_context_files: bool = True,
) -> None:
"""Update the agent-context extension config with *context_file*.
@@ -315,11 +317,23 @@ def _update_agent_context_config_file(
``context_markers`` values are kept unchanged so user customisations
survive integration changes and reinit. When False, the default
markers are written unconditionally.
When *preserve_context_files* is True (default), an existing
``context_files`` list is kept unchanged, including an empty list. This
lets projects opt into updating multiple agent context files while still
preserving the legacy singular ``context_file`` value for compatibility.
"""
from .integrations.base import IntegrationBase
cfg = _load_agent_context_config(project_root)
cfg["context_file"] = context_file or ""
existing_context_files = cfg.get("context_files")
if preserve_context_files:
cfg["context_files"] = (
existing_context_files if isinstance(existing_context_files, list) else []
)
else:
cfg.pop("context_files", None)
if not preserve_markers or not isinstance(cfg.get("context_markers"), dict):
cfg["context_markers"] = {
"start": IntegrationBase.CONTEXT_MARKER_START,

View File

@@ -427,14 +427,34 @@ class CommandRegistrar:
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
# Resolve __CONTEXT_FILE__ from the agent-context extension config.
# Fall back to init-options.json for projects that haven't migrated.
# When disabled, ignore stale context_files but keep the singular
# context_file value so generated commands still point at the agent
# context file managed before the extension was disabled.
from .integrations.base import IntegrationBase
# Local import: _load_agent_context_config lives in __init__.py which
# imports agents.py, so a top-level import would be circular.
from . import _load_agent_context_config
ac_cfg = _load_agent_context_config(project_root)
context_file = ac_cfg.get("context_file") or ""
if not context_file:
context_file = init_opts.get("context_file") or ""
extension_enabled = IntegrationBase._agent_context_extension_enabled(
project_root
)
if extension_enabled:
context_files = IntegrationBase._resolve_context_file_values(
project_root,
ac_cfg,
legacy_context_file=init_opts.get("context_file"),
)
else:
context_files = IntegrationBase._resolve_context_file_values(
project_root,
ac_cfg,
legacy_context_file=init_opts.get("context_file"),
include_context_files=False,
validate=False,
)
context_file = IntegrationBase._format_context_file_values(context_files)
body = body.replace("__CONTEXT_FILE__", context_file)
return CommandRegistrar.rewrite_project_relative_paths(body)

View File

@@ -131,7 +131,7 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
if ext_cfg_path.exists():
_update_agent_context_config_file(
project_root, "", preserve_markers=True
project_root, "", preserve_markers=True, preserve_context_files=False
)
elif has_legacy_context_keys:
save_init_options(project_root, opts)
@@ -277,12 +277,14 @@ def _update_init_options_for_integration(
"""Update init-options.json and the agent-context extension config to
reflect *integration* as the active one.
``context_file`` and ``context_markers`` are stored in the agent-context
``context_file``, ``context_files``, and ``context_markers`` are stored in the agent-context
extension config (``.specify/extensions/agent-context/agent-context-config.yml``),
not in ``init-options.json``. Existing user-customised markers are
always preserved when the config already exists; invalid marker values
are silently ignored at runtime by ``_resolve_context_markers()`` which
falls back to the class-level defaults.
always preserved when the config already exists. Existing ``context_files``
lists are also preserved so projects can keep multi-agent context anchors
during integration switches. Invalid marker values are
silently ignored at runtime by ``_resolve_context_markers()`` which falls
back to the class-level defaults.
"""
from .. import (
_AGENT_CTX_EXT_CONFIG,

View File

@@ -20,7 +20,7 @@ import shlex
import shutil
from abc import ABC
from dataclasses import dataclass
from pathlib import Path
from pathlib import Path, PureWindowsPath
from typing import TYPE_CHECKING, Any
import yaml
@@ -93,6 +93,11 @@ class IntegrationBase(ABC):
* ``context_file`` — path (relative to project root) of the agent
context/instructions file (e.g. ``"CLAUDE.md"``)
Projects may additionally opt into managing multiple context files by
setting ``context_files`` in the agent-context extension config. The
integration class still declares one default ``context_file`` for backwards
compatibility and command-template rendering.
"""
# -- Must be set by every subclass ------------------------------------
@@ -632,6 +637,11 @@ class IntegrationBase(ABC):
return True
return entry.get("enabled", True) is not False
@staticmethod
def _context_file_dedupe_key(path: str) -> str:
"""Return the comparison key for context file de-duplication."""
return path.casefold() if os.name == "nt" else path
def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]:
"""Return the (start, end) context markers to use for *project_root*.
@@ -681,51 +691,156 @@ class IntegrationBase(ABC):
end = cm_end # type: ignore[assignment]
return start, end
def upsert_context_section(
self,
project_root: Path,
plan_path: str = "",
) -> Path | None:
"""Create or update the managed section in the agent context file.
@staticmethod
def _validate_context_file_path(project_root: Path, context_file: str) -> str:
"""Return a safe project-relative context file path.
If the context file does not exist it is created with just the
managed section. If it exists, the content between the configured
start/end markers (default ``<!-- SPECKIT START -->`` /
``<!-- SPECKIT END -->``) is replaced, or appended when no markers
are found. Markers are read from the agent-context extension config
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present, falling back to the class-level constants.
Returns the path to the context file, or ``None`` when
``context_file`` is not set or the ``agent-context`` extension is
disabled.
The agent-context scripts reject paths that can escape the project
root; the Python integration path must apply the same guard before
setup or teardown touches context files.
"""
if not self.context_file:
return None
candidate = context_file.strip()
if not candidate:
raise ValueError("agent-context: context file path must not be empty")
win_path = PureWindowsPath(candidate)
if Path(candidate).is_absolute() or win_path.drive or win_path.root:
raise ValueError(
"agent-context: context files must be project-relative paths; "
f"got {candidate!r}"
)
if "\\" in candidate:
raise ValueError(
"agent-context: context files must not contain backslash "
f"separators; got {candidate!r}"
)
parts = [part for part in re.split(r"[\\/]+", candidate) if part]
if ".." in parts:
raise ValueError(
"agent-context: context files must not contain '..' path "
f"segments; got {candidate!r}"
)
root = project_root.resolve()
target = (root / candidate).resolve(strict=False)
try:
target.relative_to(root)
except ValueError as exc:
raise ValueError(
"agent-context: context file path resolves outside the project "
f"root; got {candidate!r}"
) from exc
return candidate
@classmethod
def _resolve_context_file_values(
cls,
project_root: Path,
cfg: dict[str, Any] | None,
*,
fallback_context_file: Any = None,
legacy_context_file: Any = None,
include_context_files: bool = True,
validate: bool = True,
) -> list[str]:
"""Resolve context file config with shared precedence and de-duplication."""
files: list[str] = []
seen: set[str] = set()
def add_context_file(value: Any) -> None:
if not isinstance(value, str):
return
candidate = value.strip()
if not candidate:
return
if validate:
candidate = cls._validate_context_file_path(project_root, candidate)
key = cls._context_file_dedupe_key(candidate)
if key in seen:
return
files.append(candidate)
seen.add(key)
if isinstance(cfg, dict) and include_context_files:
configured = cfg.get("context_files")
if isinstance(configured, list):
for value in configured:
add_context_file(value)
if files:
return files
if isinstance(cfg, dict):
add_context_file(cfg.get("context_file"))
if files:
return files
add_context_file(fallback_context_file)
if files:
return files
add_context_file(legacy_context_file)
return files
@staticmethod
def _format_context_file_values(context_files: list[str]) -> str:
"""Return context file targets as the template display string."""
return ", ".join(context_files)
def _resolve_context_files(self, project_root: Path) -> list[str]:
"""Return project-relative context files managed for *project_root*.
``context_files`` in the agent-context extension config, when present
and non-empty, takes precedence over the config's singular
``context_file``. The integration class default is used only when the
extension config has no context file target.
Raises ``ValueError`` when a configured path can escape the project
root.
"""
config_path = (
project_root
/ ".specify"
/ "extensions"
/ "agent-context"
/ "agent-context-config.yml"
)
try:
raw = config_path.read_text(encoding="utf-8")
cfg = yaml.safe_load(raw)
except (OSError, UnicodeError, ValueError, yaml.YAMLError):
cfg = None
return self._resolve_context_file_values(
project_root,
cfg,
fallback_context_file=self.context_file,
)
def _context_file_display(self, project_root: Path) -> str:
"""Return human-readable context file target(s) for templates."""
if not self._agent_context_extension_enabled(project_root):
return None
from .. import _load_agent_context_config
from .._console import console # local import to avoid cycles
console.print(
"[yellow]Deprecation:[/yellow] Inline agent-context updates during "
"integration setup will be disabled in v0.12.0. Context file "
"management has moved to the bundled [bold]agent-context[/bold] "
"extension. Run [cyan]specify extension disable agent-context[/cyan] "
"to opt out early.",
highlight=False,
)
marker_start, marker_end = self._resolve_context_markers(project_root)
ctx_path = project_root / self.context_file
section = (
f"{marker_start}\n"
f"{self._build_context_section(plan_path)}\n"
f"{marker_end}\n"
context_files = self._resolve_context_file_values(
project_root,
_load_agent_context_config(project_root),
fallback_context_file=self.context_file,
include_context_files=False,
validate=False,
)
return context_files[0] if context_files else ""
return self._format_context_file_values(
self._resolve_context_files(project_root)
)
@staticmethod
def _upsert_context_file(
ctx_path: Path,
section: str,
marker_start: str,
marker_end: str,
) -> None:
"""Create or update one managed context section."""
if ctx_path.exists():
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
@@ -765,18 +880,70 @@ class IntegrationBase(ABC):
# Ensure .mdc files have required YAML frontmatter
if ctx_path.suffix == ".mdc":
new_content = self._ensure_mdc_frontmatter(new_content)
new_content = IntegrationBase._ensure_mdc_frontmatter(new_content)
else:
ctx_path.parent.mkdir(parents=True, exist_ok=True)
# Cursor .mdc files require YAML frontmatter to be loaded
if ctx_path.suffix == ".mdc":
new_content = self._ensure_mdc_frontmatter(section)
new_content = IntegrationBase._ensure_mdc_frontmatter(section)
else:
new_content = section
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
ctx_path.write_bytes(normalized.encode("utf-8"))
return ctx_path
def upsert_context_section(
self,
project_root: Path,
plan_path: str = "",
) -> Path | None:
"""Create or update the managed section in the agent context file.
If the context file does not exist it is created with just the
managed section. If it exists, the content between the configured
start/end markers (default ``<!-- SPECKIT START -->`` /
``<!-- SPECKIT END -->``) is replaced, or appended when no markers
are found. Markers are read from the agent-context extension config
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present, falling back to the class-level constants.
Returns the path to the first context file, or ``None`` when no context
files are configured or the ``agent-context`` extension is
disabled.
"""
if not self._agent_context_extension_enabled(project_root):
return None
context_files = self._resolve_context_files(project_root)
if not context_files:
return None
from .._console import console # local import to avoid cycles
console.print(
"[yellow]Deprecation:[/yellow] Inline agent-context updates during "
"integration setup will be disabled in v0.12.0. Context file "
"management has moved to the bundled [bold]agent-context[/bold] "
"extension. Run [cyan]specify extension disable agent-context[/cyan] "
"to opt out early.",
highlight=False,
)
marker_start, marker_end = self._resolve_context_markers(project_root)
section = (
f"{marker_start}\n"
f"{self._build_context_section(plan_path)}\n"
f"{marker_end}\n"
)
first_path: Path | None = None
for context_file in context_files:
ctx_path = project_root / context_file
self._upsert_context_file(ctx_path, section, marker_start, marker_end)
if first_path is None:
first_path = ctx_path
return first_path
def remove_context_section(self, project_root: Path) -> bool:
"""Remove the managed section from the agent context file.
@@ -787,68 +954,73 @@ class IntegrationBase(ABC):
(``.specify/extensions/agent-context/agent-context-config.yml``)
when present, falling back to the class-level constants.
"""
if not self.context_file:
return False
if not self._agent_context_extension_enabled(project_root):
return False
ctx_path = project_root / self.context_file
if not ctx_path.exists():
context_files = self._resolve_context_files(project_root)
if not context_files:
return False
marker_start, marker_end = self._resolve_context_markers(project_root)
removed_any = False
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
end_idx = content.find(
marker_end,
start_idx if start_idx != -1 else 0,
)
for context_file in context_files:
ctx_path = project_root / context_file
if not ctx_path.exists():
continue
# Only remove a complete, well-ordered managed section. If either
# marker is missing, leave the file unchanged to avoid deleting
# unrelated user-authored content.
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
return False
removal_start = start_idx
removal_end = end_idx + len(marker_end)
# Consume trailing line ending (CRLF or LF)
if removal_end < len(content) and content[removal_end] == "\r":
removal_end += 1
if removal_end < len(content) and content[removal_end] == "\n":
removal_end += 1
# Also strip a blank line before the section if present
if removal_start > 0 and content[removal_start - 1] == "\n":
if removal_start > 1 and content[removal_start - 2] == "\n":
removal_start -= 1
new_content = content[:removal_start] + content[removal_end:]
# Normalize line endings before comparisons
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
if ctx_path.suffix == ".mdc":
import re
# Delete the file if only YAML frontmatter remains (no body content)
frontmatter_only = re.match(
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
content = ctx_path.read_text(encoding="utf-8-sig")
start_idx = content.find(marker_start)
end_idx = content.find(
marker_end,
start_idx if start_idx != -1 else 0,
)
if not normalized.strip() or frontmatter_only:
# Only remove a complete, well-ordered managed section. If either
# marker is missing, leave the file unchanged to avoid deleting
# unrelated user-authored content.
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
continue
removal_start = start_idx
removal_end = end_idx + len(marker_end)
# Consume trailing line ending (CRLF or LF)
if removal_end < len(content) and content[removal_end] == "\r":
removal_end += 1
if removal_end < len(content) and content[removal_end] == "\n":
removal_end += 1
# Also strip a blank line before the section if present
if removal_start > 0 and content[removal_start - 1] == "\n":
if removal_start > 1 and content[removal_start - 2] == "\n":
removal_start -= 1
new_content = content[:removal_start] + content[removal_end:]
# Normalize line endings before comparisons
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
if ctx_path.suffix == ".mdc":
import re
# Delete the file if only YAML frontmatter remains (no body content)
frontmatter_only = re.match(
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
)
if not normalized.strip() or frontmatter_only:
ctx_path.unlink()
removed_any = True
continue
if not normalized.strip():
ctx_path.unlink()
return True
else:
ctx_path.write_bytes(normalized.encode("utf-8"))
removed_any = True
if not normalized.strip():
ctx_path.unlink()
else:
ctx_path.write_bytes(normalized.encode("utf-8"))
return True
return removed_any
@staticmethod
def resolve_command_refs(content: str, separator: str = ".") -> str:
@@ -1119,12 +1291,13 @@ class MarkdownIntegration(IntegrationBase):
else "$ARGUMENTS"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(
@@ -1324,13 +1497,14 @@ class TomlIntegration(IntegrationBase):
else "{{args}}"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
description = self._extract_description(raw)
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
)
_, body = self._split_frontmatter(processed)
toml_content = self._render_toml(description, body)
@@ -1519,6 +1693,7 @@ class YamlIntegration(IntegrationBase):
else "{{args}}"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -1534,7 +1709,7 @@ class YamlIntegration(IntegrationBase):
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
)
_, body = self._split_frontmatter(processed)
yaml_content = self._render_yaml(
@@ -1709,6 +1884,7 @@ class SkillsIntegration(IntegrationBase):
else "$ARGUMENTS"
)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -1732,7 +1908,7 @@ class SkillsIntegration(IntegrationBase):
# Process body through the standard template pipeline
processed_body = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.

View File

@@ -354,13 +354,14 @@ class CopilotIntegration(IntegrationBase):
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS")
context_file_display = self._context_file_display(project_root)
# 1. Process and write command files as .agent.md
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(

View File

@@ -128,13 +128,14 @@ class ForgeIntegration(MarkdownIntegration):
script_type = opts.get("script_type", "sh")
arg_placeholder = self.registrar_config.get("args", "{{parameters}}")
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
# Process template with standard MarkdownIntegration logic
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)

View File

@@ -119,12 +119,13 @@ class GenericIntegration(MarkdownIntegration):
script_type = opts.get("script_type", "sh")
arg_placeholder = "$ARGUMENTS"
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
)
dst_name = self.command_filename(src_file.stem)
dst_file = self.write_file_and_record(

View File

@@ -114,6 +114,7 @@ class HermesIntegration(SkillsIntegration):
global_skills_dir.mkdir(parents=True, exist_ok=True)
created: list[Path] = []
context_file_display = self._context_file_display(project_root)
for src_file in templates:
raw = src_file.read_text(encoding="utf-8")
@@ -140,7 +141,7 @@ class HermesIntegration(SkillsIntegration):
self.key,
script_type,
arg_placeholder,
context_file=self.context_file or "",
context_file=context_file_display,
invoke_separator=self.invoke_separator,
)
# Strip the processed frontmatter — we rebuild it for skills.

View File

@@ -3,8 +3,13 @@
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
from specify_cli import (
@@ -13,18 +18,25 @@ from specify_cli import (
load_init_options,
save_init_options,
)
from specify_cli.agents import CommandRegistrar
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"
BASH = shutil.which("bash")
POWERSHELL = (
shutil.which("pwsh") or shutil.which("powershell.exe") or shutil.which("powershell")
)
def _write_ext_config(project_root: Path, **overrides: object) -> None:
"""Write a minimal agent-context extension config."""
cfg: dict = {
"context_file": overrides.get("context_file", ""),
"context_files": overrides.get("context_files", []),
"context_markers": overrides.get(
"context_markers",
{
@@ -72,6 +84,14 @@ class TestExtensionLayout:
assert cmd.is_file()
assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8")
def test_command_file_documents_context_file_constraints(self):
text = (
EXT_DIR / "commands" / "speckit.agent-context.update.md"
).read_text(encoding="utf-8")
assert "context file(s)" in text
assert "Windows drive paths" in text
assert "backslash separators" in text
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()
@@ -107,6 +127,184 @@ class _CtxIntegration(ClaudeIntegration):
"""Use Claude as a concrete integration with a context_file."""
class _NoContextIntegration(IntegrationBase):
"""Minimal integration with no context_file for base-class fallback tests."""
def _install_agent_context_config(project_root: Path, **overrides: object) -> None:
_write_ext_config(project_root, **overrides)
def _bash_posix_path(path: Path) -> str:
"""Convert a Windows path to the POSIX form used by the available bash."""
resolved = str(path.resolve())
if os.name != "nt":
return resolved
if BASH:
converted = subprocess.run(
[
BASH,
"-lc",
"command -v cygpath >/dev/null 2>&1 && cygpath -u \"$1\"",
"bash",
resolved,
],
capture_output=True,
text=True,
timeout=30,
)
if converted.returncode == 0 and converted.stdout.strip():
return converted.stdout.strip()
drive = path.drive.rstrip(":").lower()
posix = path.as_posix()
return f"/mnt/{drive}{posix[2:]}" if drive else posix
def _ensure_test_python_on_path(project_root: Path) -> Path:
"""Create python/python3 shims that run the current pytest interpreter."""
shim_dir = project_root / ".test-python-bin"
shim_dir.mkdir(exist_ok=True)
python_exe = Path(sys.executable).resolve()
shell_python = _bash_posix_path(python_exe)
for name in ("python", "python3"):
shell_shim = shim_dir / name
shell_shim.write_text(
f"#!/usr/bin/env sh\nexec {shlex_quote(shell_python)} \"$@\"\n",
encoding="utf-8",
newline="\n",
)
shell_shim.chmod(0o755)
if os.name == "nt":
cmd_shim = shim_dir / f"{name}.cmd"
cmd_shim.write_text(
f'@echo off\r\n"{python_exe}" %*\r\n',
encoding="utf-8",
)
return shim_dir
def _current_pythonpath() -> str:
"""Return sys.path entries needed by child script interpreters."""
entries = [
entry
for entry in sys.path
if isinstance(entry, str) and entry
]
existing = os.environ.get("PYTHONPATH")
if existing:
entries.extend(entry for entry in existing.split(os.pathsep) if entry)
return os.pathsep.join(dict.fromkeys(entries))
def _bundled_script_env(
project_root: Path,
*,
for_bash: bool = False,
speckit_python: str | None = None,
) -> dict[str, str]:
env = os.environ.copy()
shim_dir = _ensure_test_python_on_path(project_root)
env["PATH"] = str(shim_dir) + os.pathsep + env.get("PATH", "")
env["SPECKIT_PYTHON"] = (
speckit_python
if speckit_python is not None
else (_bash_posix_path(Path(sys.executable)) if for_bash else sys.executable)
)
pythonpath = _current_pythonpath()
if pythonpath:
env["PYTHONPATH"] = pythonpath
return env
def _run_bash_agent_context_script(
project_root: Path,
*,
speckit_python: str | None = None,
) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "bash" / "update-agent-context.sh"
env = _bundled_script_env(
project_root,
for_bash=True,
speckit_python=speckit_python,
)
if os.name == "nt":
root = _bash_posix_path(project_root)
script_path = _bash_posix_path(script)
shim_dir = _bash_posix_path(_ensure_test_python_on_path(project_root))
command = (
f"export PATH={shlex_quote(shim_dir)}:\"$PATH\"; "
f"cd {shlex_quote(root)} && {shlex_quote(script_path)}"
)
return subprocess.run(
[BASH, "-lc", command],
env=env,
capture_output=True,
text=True,
timeout=30,
)
return subprocess.run(
[BASH, str(script)],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=30,
)
def shlex_quote(value: str) -> str:
return "'" + value.replace("'", "'\"'\"'") + "'"
def _run_powershell_agent_context_script(project_root: Path) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
env = _bundled_script_env(project_root)
return subprocess.run(
[
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=30,
)
def _run_powershell_agent_context_script_with_env(
project_root: Path,
*,
speckit_python: str,
) -> subprocess.CompletedProcess:
script = EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1"
env = _bundled_script_env(project_root, speckit_python=speckit_python)
return subprocess.run(
[
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(script),
],
cwd=project_root,
env=env,
capture_output=True,
text=True,
timeout=30,
)
class TestContextMarkerResolution:
def test_defaults_when_ext_config_missing(self, tmp_path):
i = _CtxIntegration()
@@ -200,6 +398,142 @@ class TestUpsertWithCustomMarkers:
assert text.startswith("# header\n")
assert "footer" in text
def test_upsert_uses_configured_context_files(self, tmp_path):
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", "CLAUDE.md"],
)
i = _CtxIntegration()
result = i.upsert_context_section(
tmp_path, plan_path="specs/001-foo/plan.md"
)
assert result == tmp_path / "AGENTS.md"
for name in ("AGENTS.md", "CLAUDE.md"):
text = (tmp_path / name).read_text(encoding="utf-8")
assert IntegrationBase.CONTEXT_MARKER_START in text
assert "specs/001-foo/plan.md" in text
def test_context_files_deduplicate_with_platform_semantics(self, tmp_path):
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
)
files = _CtxIntegration()._resolve_context_files(tmp_path)
assert files == ["AGENTS.md", "CLAUDE.md"]
def test_empty_context_files_falls_back_to_config_context_file(self, tmp_path):
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=[],
)
files = _CtxIntegration()._resolve_context_files(tmp_path)
assert files == ["AGENTS.md"]
def test_config_context_file_takes_precedence_over_class_default(self, tmp_path):
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
)
i = _CtxIntegration()
result = i.upsert_context_section(
tmp_path, plan_path="specs/001-foo/plan.md"
)
assert result == tmp_path / "AGENTS.md"
assert (tmp_path / "AGENTS.md").exists()
assert not (tmp_path / "CLAUDE.md").exists()
def test_config_context_file_fallback_rejects_invalid_path(self, tmp_path):
_write_ext_config(
tmp_path,
context_file="../outside.md",
context_files=[],
)
with pytest.raises(ValueError, match="project-relative|must not contain"):
_CtxIntegration()._resolve_context_files(tmp_path)
def test_remove_uses_configured_context_files(self, tmp_path):
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", "CLAUDE.md"],
)
i = _CtxIntegration()
for name in ("AGENTS.md", "CLAUDE.md"):
(tmp_path / name).write_text(
f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n",
encoding="utf-8",
)
assert i.remove_context_section(tmp_path) is True
for name in ("AGENTS.md", "CLAUDE.md"):
text = (tmp_path / name).read_text(encoding="utf-8")
assert "body" not in text
assert "head" in text
assert "tail" in text
@pytest.mark.parametrize(
"bad_path",
[
"../outside.md",
"nested/../../outside.md",
"nested\\outside.md",
str(Path("/tmp/outside.md")),
"C:/tmp/outside.md",
"C:tmp/outside.md",
],
)
def test_upsert_rejects_context_files_outside_project(self, tmp_path, bad_path):
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", bad_path],
)
i = _CtxIntegration()
with pytest.raises(ValueError, match="project-relative|must not contain"):
i.upsert_context_section(tmp_path)
assert not (tmp_path / "AGENTS.md").exists()
assert not (tmp_path.parent / "outside.md").exists()
@pytest.mark.parametrize(
"bad_path",
[
"../outside.md",
"nested\\outside.md",
str(Path("/tmp/outside.md")),
"C:/tmp/outside.md",
"C:tmp/outside.md",
],
)
def test_remove_rejects_context_files_outside_project(self, tmp_path, bad_path):
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", bad_path],
)
outside = tmp_path.parent / "outside.md"
outside.write_text(
f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
f"{IntegrationBase.CONTEXT_MARKER_END}\n",
encoding="utf-8",
)
i = _CtxIntegration()
with pytest.raises(ValueError, match="project-relative|must not contain"):
i.remove_context_section(tmp_path)
assert "body" in outside.read_text(encoding="utf-8")
def test_remove_uses_custom_markers(self, tmp_path):
i = self._setup(
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
@@ -270,6 +604,17 @@ class TestExtensionEnabledGate:
assert result is None
assert not (tmp_path / "CLAUDE.md").exists()
def test_upsert_disabled_ignores_bad_context_files_config(self, tmp_path):
_write_registry(tmp_path, enabled=False)
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["../disabled-upsert-outside.md"],
)
i = _CtxIntegration()
assert i.upsert_context_section(tmp_path) is None
assert not (tmp_path.parent / "disabled-upsert-outside.md").exists()
def test_remove_skipped_when_disabled(self, tmp_path):
_write_registry(tmp_path, enabled=False)
i = _CtxIntegration()
@@ -283,6 +628,382 @@ class TestExtensionEnabledGate:
# File must be unchanged when extension is disabled
assert ctx.read_text(encoding="utf-8") == original
def test_remove_disabled_ignores_bad_context_files_config(self, tmp_path):
_write_registry(tmp_path, enabled=False)
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["../disabled-remove-outside.md"],
)
outside = tmp_path.parent / "disabled-remove-outside.md"
outside.write_text(
f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
f"{IntegrationBase.CONTEXT_MARKER_END}\n",
encoding="utf-8",
)
i = _CtxIntegration()
assert i.remove_context_section(tmp_path) is False
assert "body" in outside.read_text(encoding="utf-8")
def test_context_file_display_disabled_uses_config_context_file(
self, tmp_path
):
_write_registry(tmp_path, enabled=False)
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["../outside.md"],
)
i = _CtxIntegration()
assert i._context_file_display(tmp_path) == "AGENTS.md"
def test_context_file_display_disabled_without_context_file_returns_string(
self, tmp_path
):
_write_registry(tmp_path, enabled=False)
i = _NoContextIntegration()
assert i._context_file_display(tmp_path) == ""
class TestSkillPlaceholderContextValidation:
@pytest.mark.parametrize(
"bad_path",
[
"../outside.md",
"nested/../../outside.md",
"nested\\outside.md",
str(Path("/tmp/outside.md")),
"C:/tmp/outside.md",
"C:tmp/outside.md",
],
)
def test_context_files_reject_invalid_config_paths(self, tmp_path, bad_path):
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["AGENTS.md", bad_path],
)
with pytest.raises(ValueError, match="project-relative|must not contain"):
CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
@pytest.mark.parametrize(
"bad_path",
[
"../outside.md",
"C:tmp/outside.md",
],
)
def test_context_file_rejects_invalid_config_path(self, tmp_path, bad_path):
_write_ext_config(
tmp_path,
context_file=bad_path,
context_files=[],
)
with pytest.raises(ValueError, match="project-relative|must not contain"):
CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
def test_enabled_extension_rejects_invalid_legacy_init_options_path(
self, tmp_path
):
save_init_options(tmp_path, {"context_file": "../outside.md"})
with pytest.raises(ValueError, match="must not contain"):
CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
def test_disabled_extension_ignores_invalid_context_files(self, tmp_path):
_write_registry(tmp_path, enabled=False)
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["../outside.md"],
)
save_init_options(tmp_path, {"context_file": "AGENTS.md"})
content = CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
assert content == "Read AGENTS.md"
def test_disabled_extension_uses_extension_context_file_before_init_options(
self, tmp_path
):
_write_registry(tmp_path, enabled=False)
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["CLAUDE.md"],
)
save_init_options(tmp_path, {"context_file": "LEGACY.md"})
content = CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
assert content == "Read AGENTS.md"
def test_context_files_deduplicate_with_platform_semantics(self, tmp_path):
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
)
content = CommandRegistrar.resolve_skill_placeholders(
"codex",
{},
"Read __CONTEXT_FILE__",
tmp_path,
)
assert content == "Read AGENTS.md, CLAUDE.md"
class TestBundledUpdaterPathValidation:
def test_bundled_script_env_makes_yaml_importable(self, tmp_path):
env = _bundled_script_env(tmp_path)
result = subprocess.run(
[env["SPECKIT_PYTHON"], "-c", "import yaml"],
env=env,
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, result.stderr + result.stdout
@requires_bash
def test_bash_script_trims_context_file_fallback(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file=" AGENTS.md ",
context_files=[],
)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
assert (project / "AGENTS.md").exists()
assert not (project / " AGENTS.md ").exists()
@requires_bash
def test_bash_script_rejects_symlink_escape(self, tmp_path):
project = tmp_path / "project"
outside = tmp_path / "outside"
project.mkdir()
outside.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["link/out.md"],
)
if os.name == "nt":
root = _bash_posix_path(tmp_path)
create_link = subprocess.run(
[
BASH,
"-lc",
f"ln -s {shlex_quote(root + '/outside')} "
f"{shlex_quote(root + '/project/link')}",
],
capture_output=True,
text=True,
timeout=30,
)
if create_link.returncode != 0:
pytest.skip(f"symlink unavailable: {create_link.stderr}")
else:
try:
(project / "link").symlink_to(outside, target_is_directory=True)
except OSError as exc:
pytest.skip(f"symlink unavailable: {exc}")
result = _run_bash_agent_context_script(project)
assert result.returncode == 1
assert "resolves outside the project root" in result.stderr
assert not (outside / "out.md").exists()
@requires_bash
def test_bash_script_deduplicates_context_files_in_order(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
)
result = _run_bash_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
output = result.stderr + result.stdout
assert output.count("agent-context: updated AGENTS.md") == 1
assert output.count("agent-context: updated CLAUDE.md") == 1
assert "agent-context: updated agents.md" not in output
@requires_bash
def test_bash_script_falls_back_from_invalid_speckit_python(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
result = _run_bash_agent_context_script(
project,
speckit_python="/definitely/missing/python",
)
assert result.returncode == 0, result.stderr + result.stdout
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
assert (project / "AGENTS.md").exists()
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_rejects_backslash_context_files(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["nested\\AGENTS.md"],
)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 1
assert "must not contain backslash separators" in (
result.stderr + result.stdout
)
assert not (project / "nested" / "AGENTS.md").exists()
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_rejects_drive_qualified_context_files(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["C:tmp/outside.md"],
)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 1
assert "must be project-relative paths" in (result.stderr + result.stdout)
assert not (project / "tmp" / "outside.md").exists()
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_deduplicates_context_files_in_order(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
duplicate = "agents.md" if os.name == "nt" else "AGENTS.md"
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md", "CLAUDE.md", duplicate],
)
result = _run_powershell_agent_context_script(project)
assert result.returncode == 0, result.stderr + result.stdout
output = result.stderr + result.stdout
assert output.count("agent-context: updated AGENTS.md") == 1
assert output.count("agent-context: updated CLAUDE.md") == 1
assert "agent-context: updated agents.md" not in output
@pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available")
def test_powershell_script_falls_back_from_invalid_speckit_python(self, tmp_path):
project = tmp_path / "project"
project.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["AGENTS.md"],
)
result = _run_powershell_agent_context_script_with_env(
project,
speckit_python=str(project / "missing-python"),
)
assert result.returncode == 0, result.stderr + result.stdout
assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout)
assert (project / "AGENTS.md").exists()
@pytest.mark.skipif(
POWERSHELL is None or os.name != "nt",
reason="Windows PowerShell junction test requires Windows",
)
def test_powershell_script_rejects_junction_escape(self, tmp_path):
project = tmp_path / "project"
outside = tmp_path / "outside"
project.mkdir()
outside.mkdir()
_install_agent_context_config(
project,
context_file="AGENTS.md",
context_files=["link/out.md"],
)
create_link = subprocess.run(
[
POWERSHELL,
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
(
"New-Item -ItemType Junction "
f"-Path {str(project / 'link')!r} "
f"-Target {str(outside)!r} | Out-Null"
),
],
capture_output=True,
text=True,
timeout=30,
)
if create_link.returncode != 0:
pytest.skip(f"junction unavailable: {create_link.stderr}")
result = _run_powershell_agent_context_script(project)
assert result.returncode == 1
assert "resolves outside the project root" in (result.stderr + result.stdout)
assert not (outside / "out.md").exists()
# ── Extension config writers ─────────────────────────────────────────────────
@@ -349,6 +1070,65 @@ class TestExtensionConfigWriters:
assert cfg["context_file"] == i.context_file
assert "context_markers" in cfg
def test_update_init_options_preserves_context_files(self, tmp_path):
from specify_cli import _update_init_options_for_integration
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=["AGENTS.md", "CLAUDE.md"],
)
i = _CtxIntegration()
_update_init_options_for_integration(tmp_path, i, script_type="sh")
cfg = _load_agent_context_config(tmp_path)
assert cfg["context_file"] == i.context_file
assert cfg["context_files"] == ["AGENTS.md", "CLAUDE.md"]
def test_update_init_options_preserves_empty_context_files(self, tmp_path):
from specify_cli import _update_init_options_for_integration
_write_ext_config(
tmp_path,
context_file="AGENTS.md",
context_files=[],
)
i = _CtxIntegration()
_update_init_options_for_integration(tmp_path, i, script_type="sh")
cfg = _load_agent_context_config(tmp_path)
assert cfg["context_file"] == i.context_file
assert cfg["context_files"] == []
def test_update_init_options_normalizes_invalid_context_files(self, tmp_path):
from specify_cli import _update_init_options_for_integration
_write_ext_config(tmp_path, context_file="AGENTS.md")
cfg = _load_agent_context_config(tmp_path)
cfg["context_files"] = "AGENTS.md"
_save_agent_context_config(tmp_path, cfg)
i = _CtxIntegration()
_update_init_options_for_integration(tmp_path, i, script_type="sh")
cfg = _load_agent_context_config(tmp_path)
assert cfg["context_file"] == i.context_file
assert cfg["context_files"] == []
def test_clear_init_options_clears_context_files(self, tmp_path):
from specify_cli import _clear_init_options_for_integration
save_init_options(
tmp_path,
{"integration": "claude", "ai": "claude"},
)
_write_ext_config(
tmp_path,
context_file="CLAUDE.md",
context_files=["AGENTS.md", "CLAUDE.md"],
)
_clear_init_options_for_integration(tmp_path, "claude")
cfg = _load_agent_context_config(tmp_path)
assert cfg.get("context_file") == ""
assert "context_files" not in cfg
def test_update_init_options_preserves_custom_markers(self, tmp_path):
from specify_cli import _update_init_options_for_integration

View File

@@ -29,6 +29,80 @@ class TestCodexInitFlow:
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_plan_skill_references_configured_context_files(self, tmp_path):
"""Plan skill should render all configured agent context files."""
from specify_cli import _save_agent_context_config
target = tmp_path / "test-proj"
target.mkdir()
_save_agent_context_config(
target,
{
"context_file": "AGENTS.md",
"context_files": ["AGENTS.md", "CLAUDE.md"],
"context_markers": {
"start": "<!-- SPECKIT START -->",
"end": "<!-- SPECKIT END -->",
},
},
)
integration = get_integration("codex")
manifest = IntegrationManifest("codex", target)
integration.setup(target, manifest, script_type="sh")
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
content = plan_skill.read_text(encoding="utf-8")
assert "AGENTS.md, CLAUDE.md" in content
assert "__CONTEXT_FILE__" not in content
def test_plan_skill_ignores_context_files_when_agent_context_disabled(
self, tmp_path
):
"""Disabled agent-context must not leak stale context_files into commands."""
from specify_cli import _save_agent_context_config
target = tmp_path / "test-proj"
target.mkdir()
registry = target / ".specify" / "extensions" / ".registry"
registry.parent.mkdir(parents=True, exist_ok=True)
registry.write_text(
"""
{
"schema_version": "1.0",
"extensions": {
"agent-context": {
"version": "1.0.0",
"enabled": false
}
}
}
""".strip(),
encoding="utf-8",
)
_save_agent_context_config(
target,
{
"context_file": "AGENTS.md",
"context_files": ["../outside.md", "CLAUDE.md"],
"context_markers": {
"start": "<!-- SPECKIT START -->",
"end": "<!-- SPECKIT END -->",
},
},
)
integration = get_integration("codex")
manifest = IntegrationManifest("codex", target)
integration.setup(target, manifest, script_type="sh")
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
content = plan_skill.read_text(encoding="utf-8")
assert "AGENTS.md, CLAUDE.md" not in content
assert "../outside.md" not in content
assert "AGENTS.md" in content
assert "__CONTEXT_FILE__" not in content
class TestCodexHookCommandNote:
"""Verify dot-to-hyphen normalization note is injected in hook sections.