mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user