Skip to content

Commit bbdf1b8

Browse files
authored
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>
1 parent cac16dd commit bbdf1b8

15 files changed

Lines changed: 1480 additions & 197 deletions

File tree

extensions/agent-context/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Not every Spec Kit user wants Spec Kit to write into the coding agent's context
1010

1111
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
1212
- **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.
13+
- **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`.
1314
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
1415

1516
## Commands
@@ -27,13 +28,20 @@ All configuration flows through the extension's own config file at
2728
# Path to the coding agent context file managed by this extension
2829
context_file: CLAUDE.md
2930

31+
# Optional list of coding agent context files to manage together.
32+
# When non-empty, this takes precedence over context_file.
33+
context_files:
34+
- AGENTS.md
35+
- CLAUDE.md
36+
3037
# Delimiters for the managed Spec Kit section
3138
context_markers:
3239
start: "<!-- SPECKIT START -->"
3340
end: "<!-- SPECKIT END -->"
3441
```
3542
3643
- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
44+
- `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.
3745
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
3846

3947
## Requirements
@@ -55,3 +63,4 @@ specify extension disable agent-context
5563
```
5664

5765
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
66+
Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out.

extensions/agent-context/agent-context-config.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,17 @@
22
# These values are populated automatically by `specify init` and
33
# `specify integration use` / `specify integration install`.
44

5-
# Path (relative to the project root) to the coding agent context file
5+
# Path (relative to the project root) to the default coding agent context file
66
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
77
# .github/copilot-instructions.md). Set automatically from the active
88
# integration and regenerated during `specify init` or integration switches.
99
context_file: ""
1010

11+
# Optional list of project-relative coding agent context files managed by this
12+
# extension. When non-empty, this list takes precedence over `context_file`.
13+
# Use this for projects that intentionally keep multiple agent anchors in sync.
14+
context_files: []
15+
1116
# Delimiters for the managed Spec Kit section.
1217
# Edit these to use custom markers.
1318
context_markers:

extensions/agent-context/commands/speckit.agent-context.update.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: "Refresh the managed Spec Kit section in the coding agent context file"
2+
description: "Refresh the managed Spec Kit section in coding agent context file(s)"
33
---
44

55
# Update Coding Agent Context
@@ -12,11 +12,12 @@ The script reads the agent-context extension config at
1212
`.specify/extensions/agent-context/agent-context-config.yml` to discover:
1313

1414
- `context_file` — the path of the coding agent context file to manage.
15+
- `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`.
1516
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
1617

1718
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`).
1819

19-
If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.
20+
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.
2021

2122
## Execution
2223

extensions/agent-context/scripts/bash/update-agent-context.sh

Lines changed: 116 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
#!/usr/bin/env bash
22
# update-agent-context.sh
33
#
4-
# Refresh the managed Spec Kit section in the coding agent's context file
4+
# Refresh the managed Spec Kit section in the coding agent's context file(s)
55
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
66
#
7-
# Reads `context_file` and `context_markers.{start,end}` from the
7+
# Reads `context_files` or `context_file`, plus `context_markers.{start,end}`, from the
88
# agent-context extension config:
99
# .specify/extensions/agent-context/agent-context-config.yml
1010
#
@@ -26,22 +26,41 @@ if [[ ! -f "$EXT_CONFIG" ]]; then
2626
exit 0
2727
fi
2828

29-
# Locate a suitable Python interpreter (python3, then python).
29+
# Locate a Python 3 interpreter with PyYAML available.
3030
_python=""
31-
if command -v python3 >/dev/null 2>&1; then
32-
_python="python3"
33-
elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then
34-
_python="python"
35-
fi
31+
_python_candidates=()
32+
[[ -n "${SPECKIT_PYTHON:-}" ]] && _python_candidates+=("$SPECKIT_PYTHON")
33+
_python_candidates+=("python3" "python")
34+
for _candidate in "${_python_candidates[@]}"; do
35+
if command -v "$_candidate" >/dev/null 2>&1 \
36+
&& "$_candidate" - <<'PY' >/dev/null 2>&1
37+
import sys
38+
try:
39+
import yaml # noqa: F401
40+
except ImportError:
41+
sys.exit(1)
42+
sys.exit(0 if sys.version_info[0] == 3 else 1)
43+
PY
44+
then
45+
_python="$_candidate"
46+
break
47+
fi
48+
done
49+
unset _candidate _python_candidates
3650

3751
if [[ -z "$_python" ]]; then
38-
echo "agent-context: Python 3 not found on PATH; skipping update." >&2
52+
echo "agent-context: Python 3 with PyYAML not found on PATH; skipping update." >&2
53+
echo " To resolve: pip install pyyaml (or install it into the environment used by python3)." >&2
3954
exit 0
4055
fi
56+
_case_insensitive_context_files=0
57+
case "$(uname -s 2>/dev/null || true)" in
58+
MINGW*|MSYS*|CYGWIN*) _case_insensitive_context_files=1 ;;
59+
esac
4160

42-
# Parse extension config once; emit three newline-separated fields:
43-
# context_file, context_markers.start, context_markers.end
44-
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY'
61+
# Parse extension config once; emit context files as JSON, followed by marker strings.
62+
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY'
63+
import json
4564
import sys
4665
try:
4766
import yaml
@@ -73,7 +92,28 @@ def get_str(obj, *keys):
7392
else:
7493
return ""
7594
return node if isinstance(node, str) else ""
76-
print(get_str(data, "context_file"))
95+
context_files = []
96+
seen_context_files = set()
97+
case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin"))
98+
raw_files = data.get("context_files")
99+
if isinstance(raw_files, list):
100+
for value in raw_files:
101+
if not isinstance(value, str):
102+
continue
103+
candidate = value.strip()
104+
if not candidate:
105+
continue
106+
key = candidate.casefold() if case_insensitive else candidate
107+
if key in seen_context_files:
108+
continue
109+
context_files.append(candidate)
110+
seen_context_files.add(key)
111+
if not context_files:
112+
raw_file = get_str(data, "context_file")
113+
candidate = raw_file.strip()
114+
if candidate:
115+
context_files.append(candidate)
116+
print(json.dumps(context_files))
77117
print(get_str(data, "context_markers", "start"))
78118
print(get_str(data, "context_markers", "end"))
79119
PY
@@ -87,31 +127,71 @@ while IFS= read -r _line || [[ -n "$_line" ]]; do
87127
_opts_lines+=("$_line")
88128
done < <(printf '%s\n' "$_raw_opts")
89129
if (( ${#_opts_lines[@]} < 3 )); then
90-
echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
130+
echo "agent-context: malformed config parser output; expected 3 lines (context_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
91131
exit 0
92132
fi
93-
CONTEXT_FILE="${_opts_lines[0]}"
133+
CONTEXT_FILES_JSON="${_opts_lines[0]}"
94134
MARKER_START="${_opts_lines[1]}"
95135
MARKER_END="${_opts_lines[2]}"
96136

97-
if [[ -z "$CONTEXT_FILE" ]]; then
98-
echo "agent-context: context_file not set in extension config; nothing to do." >&2
137+
if ! _context_files_raw="$("$_python" - "$CONTEXT_FILES_JSON" <<'PY'
138+
import json
139+
import sys
140+
try:
141+
data = json.loads(sys.argv[1])
142+
except Exception:
143+
data = []
144+
if not isinstance(data, list):
145+
data = []
146+
for value in data:
147+
if isinstance(value, str) and value:
148+
print(value)
149+
PY
150+
)"; then
151+
echo "agent-context: malformed context_files parser output; skipping update." >&2
99152
exit 0
100153
fi
101154

102-
# Reject absolute paths, backslash separators, and '..' path segments in context_file
103-
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
104-
echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2
105-
exit 1
106-
fi
107-
if [[ "$CONTEXT_FILE" == *\\* ]]; then
108-
echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2
109-
exit 1
155+
CONTEXT_FILES=()
156+
while IFS= read -r _line || [[ -n "$_line" ]]; do
157+
[[ -n "$_line" ]] && CONTEXT_FILES+=("$_line")
158+
done < <(printf '%s\n' "$_context_files_raw")
159+
160+
if (( ${#CONTEXT_FILES[@]} == 0 )); then
161+
echo "agent-context: context_files/context_file not set in extension config; nothing to do." >&2
162+
exit 0
110163
fi
111-
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
112-
for _seg in "${_cf_parts[@]}"; do
113-
if [[ "$_seg" == ".." ]]; then
114-
echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
164+
165+
for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
166+
# Reject absolute paths, backslash separators, and '..' path segments in context files
167+
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
168+
echo "agent-context: context files must be project-relative paths; got '$CONTEXT_FILE'." >&2
169+
exit 1
170+
fi
171+
if [[ "$CONTEXT_FILE" == *\\* ]]; then
172+
echo "agent-context: context files must not contain backslash separators; got '$CONTEXT_FILE'." >&2
173+
exit 1
174+
fi
175+
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
176+
for _seg in "${_cf_parts[@]}"; do
177+
if [[ "$_seg" == ".." ]]; then
178+
echo "agent-context: context files must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
179+
exit 1
180+
fi
181+
done
182+
if ! "$_python" - "$PROJECT_ROOT" "$CONTEXT_FILE" <<'PY'
183+
import sys
184+
from pathlib import Path
185+
186+
root = Path(sys.argv[1]).resolve()
187+
target = (root / sys.argv[2]).resolve(strict=False)
188+
try:
189+
target.relative_to(root)
190+
except ValueError:
191+
sys.exit(1)
192+
PY
193+
then
194+
echo "agent-context: context file path resolves outside the project root; got '$CONTEXT_FILE'." >&2
115195
exit 1
116196
fi
117197
done
@@ -142,9 +222,6 @@ PY
142222
fi
143223
fi
144224

145-
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
146-
mkdir -p "$(dirname "$CTX_PATH")"
147-
148225
# Build the managed section
149226
TMP_SECTION="$(mktemp)"
150227
trap 'rm -f "$TMP_SECTION"' EXIT
@@ -158,7 +235,11 @@ trap 'rm -f "$TMP_SECTION"' EXIT
158235
echo "$MARKER_END"
159236
} > "$TMP_SECTION"
160237

161-
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
238+
for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do
239+
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
240+
mkdir -p "$(dirname "$CTX_PATH")"
241+
242+
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
162243
import sys, os
163244
ctx_path, start, end, section_path = sys.argv[1:5]
164245
with open(section_path, "r", encoding="utf-8") as fh:
@@ -197,4 +278,5 @@ with open(ctx_path, "wb") as fh:
197278
fh.write(new_content.encode("utf-8"))
198279
PY
199280

200-
echo "agent-context: updated $CONTEXT_FILE"
281+
echo "agent-context: updated $CONTEXT_FILE"
282+
done

0 commit comments

Comments
 (0)