Spaces:
Build error
Build error
| # update-agent-context.sh | |
| # | |
| # 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_files` or `context_file`, plus `context_markers.{start,end}`, from the | |
| # agent-context extension config: | |
| # .specify/extensions/agent-context/agent-context-config.yml | |
| # | |
| # Usage: update-agent-context.sh [plan_path] | |
| # | |
| # When `plan_path` is omitted, the script derives it from `.specify/feature.json` | |
| # (written by /speckit-specify). Falls back to the most recently modified | |
| # `specs/*/plan.md` only when feature.json is absent or its plan does not exist yet. | |
| set -euo pipefail | |
| PROJECT_ROOT="$(pwd)" | |
| EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml" | |
| DEFAULT_START="<!-- SPECKIT START -->" | |
| DEFAULT_END="<!-- SPECKIT END -->" | |
| if [[ ! -f "$EXT_CONFIG" ]]; then | |
| echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2 | |
| exit 0 | |
| fi | |
| # Locate a Python 3 interpreter with PyYAML available. | |
| _python="" | |
| _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 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 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 | |
| except ImportError: | |
| print( | |
| "agent-context: PyYAML is required to parse extension config but is not available " | |
| "in the current Python environment.\n" | |
| " To resolve: pip install pyyaml (or install it into the environment used by python3).\n" | |
| " Context file will not be updated until PyYAML is importable.", | |
| file=sys.stderr, | |
| ) | |
| sys.exit(2) | |
| try: | |
| with open(sys.argv[1], "r", encoding="utf-8") as fh: | |
| data = yaml.safe_load(fh) | |
| except Exception as exc: | |
| print( | |
| f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.", | |
| file=sys.stderr, | |
| ) | |
| sys.exit(2) | |
| if not isinstance(data, dict): | |
| data = {} | |
| def get_str(obj, *keys): | |
| node = obj | |
| for k in keys: | |
| if isinstance(node, dict) and k in node: | |
| node = node[k] | |
| else: | |
| return "" | |
| return node if isinstance(node, str) else "" | |
| 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 | |
| )"; then | |
| echo "agent-context: skipping update (see above for details)." >&2 | |
| exit 0 | |
| fi | |
| _opts_lines=() | |
| 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_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2 | |
| exit 0 | |
| fi | |
| CONTEXT_FILES_JSON="${_opts_lines[0]}" | |
| MARKER_START="${_opts_lines[1]}" | |
| MARKER_END="${_opts_lines[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 | |
| 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 | |
| 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 | |
| unset _cf_parts _seg | |
| [[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START" | |
| [[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END" | |
| PLAN_PATH="${1:-}" | |
| if [[ -z "$PLAN_PATH" ]]; then | |
| # Prefer .specify/feature.json (written by /speckit-specify) over mtime heuristic. | |
| _feature_json="$PROJECT_ROOT/.specify/feature.json" | |
| if [[ -f "$_feature_json" ]]; then | |
| _feature_dir="$("$_python" - "$_feature_json" <<'PY' | |
| import sys, json | |
| try: | |
| with open(sys.argv[1], encoding="utf-8") as fh: | |
| d = json.load(fh) | |
| val = d.get("feature_directory", "") | |
| print(val if isinstance(val, str) else "") | |
| except Exception: | |
| print("") | |
| PY | |
| )" | |
| # Normalize backslashes (written by PS on Windows) to forward slashes before path ops. | |
| _feature_dir="$(printf '%s' "$_feature_dir" | tr '\\' '/')" | |
| _feature_dir="${_feature_dir%/}" | |
| if [[ -n "$_feature_dir" ]]; then | |
| # feature_directory may be relative or absolute (absolute paths outside PROJECT_ROOT | |
| # are preserved as-is by _persist_feature_json in common.sh). | |
| # Also match drive-qualified paths (C:/...) written by PowerShell on Windows. | |
| if [[ "$_feature_dir" == /* ]] || [[ "$_feature_dir" =~ ^[A-Za-z]:/ ]]; then | |
| _candidate="$_feature_dir/plan.md" | |
| else | |
| _candidate="$PROJECT_ROOT/$_feature_dir/plan.md" | |
| fi | |
| if [[ -f "$_candidate" ]]; then | |
| # Resolve symlinks before comparing so paths like /var/… vs /private/var/… | |
| # (macOS) are treated as equivalent. Mirrors the mtime-fallback approach. | |
| PLAN_PATH="$("$_python" - "$PROJECT_ROOT" "$_candidate" <<'PY' | |
| import sys | |
| from pathlib import Path | |
| root = Path(sys.argv[1]).resolve() | |
| cand = Path(sys.argv[2]).resolve() | |
| try: | |
| print(cand.relative_to(root).as_posix()) | |
| except ValueError: | |
| # Outside project root: emit the resolved path in POSIX form. | |
| # as_posix() converts backslashes correctly on native Windows Python. | |
| print(cand.as_posix()) | |
| PY | |
| )" | |
| fi | |
| fi | |
| fi | |
| # Fall back to mtime only when feature.json is absent or its plan does not exist yet. | |
| # Python emits a project-relative POSIX path directly to avoid bash prefix-strip | |
| # issues with backslash paths on Windows (Git bash / MSYS2). | |
| if [[ -z "$PLAN_PATH" ]]; then | |
| _plan_rel="$("$_python" - "$PROJECT_ROOT" <<'PY' | |
| import sys | |
| from pathlib import Path | |
| root = Path(sys.argv[1]).resolve() | |
| specs = root / "specs" | |
| plans = sorted( | |
| specs.glob("*/plan.md"), | |
| key=lambda p: p.stat().st_mtime, | |
| reverse=True, | |
| ) | |
| if plans: | |
| try: | |
| print(plans[0].relative_to(root).as_posix()) | |
| except ValueError: | |
| print("") | |
| else: | |
| print("") | |
| PY | |
| )" | |
| if [[ -n "$_plan_rel" ]]; then | |
| PLAN_PATH="$_plan_rel" | |
| fi | |
| fi | |
| fi | |
| # Build the managed section | |
| TMP_SECTION="$(mktemp)" | |
| trap 'rm -f "$TMP_SECTION"' EXIT | |
| { | |
| echo "$MARKER_START" | |
| echo "For additional context about technologies to be used, project structure," | |
| echo "shell commands, and other important information, read the current plan" | |
| if [[ -n "$PLAN_PATH" ]]; then | |
| echo "at $PLAN_PATH" | |
| fi | |
| echo "$MARKER_END" | |
| } > "$TMP_SECTION" | |
| 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: | |
| section = fh.read().rstrip("\n") + "\n" | |
| if os.path.exists(ctx_path): | |
| with open(ctx_path, "r", encoding="utf-8-sig") as fh: | |
| content = fh.read() | |
| s = content.find(start) | |
| e = content.find(end, s if s != -1 else 0) | |
| if s != -1 and e != -1 and e > s: | |
| end_of_marker = e + len(end) | |
| if end_of_marker < len(content) and content[end_of_marker] == "\r": | |
| end_of_marker += 1 | |
| if end_of_marker < len(content) and content[end_of_marker] == "\n": | |
| end_of_marker += 1 | |
| new_content = content[:s] + section + content[end_of_marker:] | |
| elif s != -1: | |
| new_content = content[:s] + section | |
| elif e != -1: | |
| end_of_marker = e + len(end) | |
| if end_of_marker < len(content) and content[end_of_marker] == "\r": | |
| end_of_marker += 1 | |
| if end_of_marker < len(content) and content[end_of_marker] == "\n": | |
| end_of_marker += 1 | |
| new_content = section + content[end_of_marker:] | |
| else: | |
| if content and not content.endswith("\n"): | |
| content += "\n" | |
| new_content = (content + "\n" + section) if content else section | |
| else: | |
| new_content = section | |
| new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") | |
| with open(ctx_path, "wb") as fh: | |
| fh.write(new_content.encode("utf-8")) | |
| PY | |
| echo "agent-context: updated $CONTEXT_FILE" | |
| done | |