|
| 1 | +#!/usr/bin/env bash |
| 2 | +# update-agent-context.sh |
| 3 | +# |
| 4 | +# Refresh the managed Spec Kit section in the coding agent's context file |
| 5 | +# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). |
| 6 | +# |
| 7 | +# Reads `context_file` and `context_markers.{start,end}` from the |
| 8 | +# agent-context extension config: |
| 9 | +# .specify/extensions/agent-context/agent-context-config.yml |
| 10 | +# |
| 11 | +# Usage: update-agent-context.sh [plan_path] |
| 12 | +# |
| 13 | +# When `plan_path` is omitted, the script picks the most recently modified |
| 14 | +# `specs/*/plan.md` if any exist, otherwise emits the section without a |
| 15 | +# concrete plan path. |
| 16 | + |
| 17 | +set -euo pipefail |
| 18 | + |
| 19 | +PROJECT_ROOT="$(pwd)" |
| 20 | +EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml" |
| 21 | +DEFAULT_START="<!-- SPECKIT START -->" |
| 22 | +DEFAULT_END="<!-- SPECKIT END -->" |
| 23 | + |
| 24 | +if [[ ! -f "$EXT_CONFIG" ]]; then |
| 25 | + echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2 |
| 26 | + exit 0 |
| 27 | +fi |
| 28 | + |
| 29 | +# Locate a suitable Python interpreter (python3, then python). |
| 30 | +_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 |
| 36 | + |
| 37 | +if [[ -z "$_python" ]]; then |
| 38 | + echo "agent-context: Python 3 not found on PATH; skipping update." >&2 |
| 39 | + exit 0 |
| 40 | +fi |
| 41 | + |
| 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' |
| 45 | +import sys |
| 46 | +try: |
| 47 | + import yaml |
| 48 | +except ImportError: |
| 49 | + print( |
| 50 | + "agent-context: PyYAML is required to parse extension config but is not available " |
| 51 | + "in the current Python environment.\n" |
| 52 | + " To resolve: pip install pyyaml (or install it into the environment used by python3).\n" |
| 53 | + " Context file will not be updated until PyYAML is importable.", |
| 54 | + file=sys.stderr, |
| 55 | + ) |
| 56 | + sys.exit(2) |
| 57 | +try: |
| 58 | + with open(sys.argv[1], "r", encoding="utf-8") as fh: |
| 59 | + data = yaml.safe_load(fh) |
| 60 | +except Exception as exc: |
| 61 | + print( |
| 62 | + f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.", |
| 63 | + file=sys.stderr, |
| 64 | + ) |
| 65 | + sys.exit(2) |
| 66 | +if not isinstance(data, dict): |
| 67 | + data = {} |
| 68 | +def get_str(obj, *keys): |
| 69 | + node = obj |
| 70 | + for k in keys: |
| 71 | + if isinstance(node, dict) and k in node: |
| 72 | + node = node[k] |
| 73 | + else: |
| 74 | + return "" |
| 75 | + return node if isinstance(node, str) else "" |
| 76 | +print(get_str(data, "context_file")) |
| 77 | +print(get_str(data, "context_markers", "start")) |
| 78 | +print(get_str(data, "context_markers", "end")) |
| 79 | +PY |
| 80 | +)"; then |
| 81 | + echo "agent-context: skipping update (see above for details)." >&2 |
| 82 | + exit 0 |
| 83 | +fi |
| 84 | + |
| 85 | +_opts_lines=() |
| 86 | +while IFS= read -r _line || [[ -n "$_line" ]]; do |
| 87 | + _opts_lines+=("$_line") |
| 88 | +done < <(printf '%s\n' "$_raw_opts") |
| 89 | +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 |
| 91 | + exit 0 |
| 92 | +fi |
| 93 | +CONTEXT_FILE="${_opts_lines[0]}" |
| 94 | +MARKER_START="${_opts_lines[1]}" |
| 95 | +MARKER_END="${_opts_lines[2]}" |
| 96 | + |
| 97 | +if [[ -z "$CONTEXT_FILE" ]]; then |
| 98 | + echo "agent-context: context_file not set in extension config; nothing to do." >&2 |
| 99 | + exit 0 |
| 100 | +fi |
| 101 | + |
| 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 |
| 110 | +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 |
| 115 | + exit 1 |
| 116 | + fi |
| 117 | +done |
| 118 | +unset _cf_parts _seg |
| 119 | + |
| 120 | +[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START" |
| 121 | +[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END" |
| 122 | + |
| 123 | +PLAN_PATH="${1:-}" |
| 124 | +if [[ -z "$PLAN_PATH" ]]; then |
| 125 | + # Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md). |
| 126 | + # Use find + sort by modification time to avoid ls/head fragility with |
| 127 | + # spaces in paths or SIGPIPE from pipefail. |
| 128 | + _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY' |
| 129 | +import sys, os |
| 130 | +from pathlib import Path |
| 131 | +specs = Path(sys.argv[1]) / "specs" |
| 132 | +plans = sorted( |
| 133 | + specs.glob("*/plan.md"), |
| 134 | + key=lambda p: p.stat().st_mtime, |
| 135 | + reverse=True, |
| 136 | +) |
| 137 | +print(plans[0] if plans else "") |
| 138 | +PY |
| 139 | +)" |
| 140 | + if [[ -n "$_plan_abs" ]]; then |
| 141 | + PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" |
| 142 | + fi |
| 143 | +fi |
| 144 | + |
| 145 | +CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE" |
| 146 | +mkdir -p "$(dirname "$CTX_PATH")" |
| 147 | + |
| 148 | +# Build the managed section |
| 149 | +TMP_SECTION="$(mktemp)" |
| 150 | +trap 'rm -f "$TMP_SECTION"' EXIT |
| 151 | +{ |
| 152 | + echo "$MARKER_START" |
| 153 | + echo "For additional context about technologies to be used, project structure," |
| 154 | + echo "shell commands, and other important information, read the current plan" |
| 155 | + if [[ -n "$PLAN_PATH" ]]; then |
| 156 | + echo "at $PLAN_PATH" |
| 157 | + fi |
| 158 | + echo "$MARKER_END" |
| 159 | +} > "$TMP_SECTION" |
| 160 | + |
| 161 | +"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' |
| 162 | +import sys, os |
| 163 | +ctx_path, start, end, section_path = sys.argv[1:5] |
| 164 | +with open(section_path, "r", encoding="utf-8") as fh: |
| 165 | + section = fh.read().rstrip("\n") + "\n" |
| 166 | +
|
| 167 | +if os.path.exists(ctx_path): |
| 168 | + with open(ctx_path, "r", encoding="utf-8-sig") as fh: |
| 169 | + content = fh.read() |
| 170 | + s = content.find(start) |
| 171 | + e = content.find(end, s if s != -1 else 0) |
| 172 | + if s != -1 and e != -1 and e > s: |
| 173 | + end_of_marker = e + len(end) |
| 174 | + if end_of_marker < len(content) and content[end_of_marker] == "\r": |
| 175 | + end_of_marker += 1 |
| 176 | + if end_of_marker < len(content) and content[end_of_marker] == "\n": |
| 177 | + end_of_marker += 1 |
| 178 | + new_content = content[:s] + section + content[end_of_marker:] |
| 179 | + elif s != -1: |
| 180 | + new_content = content[:s] + section |
| 181 | + elif e != -1: |
| 182 | + end_of_marker = e + len(end) |
| 183 | + if end_of_marker < len(content) and content[end_of_marker] == "\r": |
| 184 | + end_of_marker += 1 |
| 185 | + if end_of_marker < len(content) and content[end_of_marker] == "\n": |
| 186 | + end_of_marker += 1 |
| 187 | + new_content = section + content[end_of_marker:] |
| 188 | + else: |
| 189 | + if content and not content.endswith("\n"): |
| 190 | + content += "\n" |
| 191 | + new_content = (content + "\n" + section) if content else section |
| 192 | +else: |
| 193 | + new_content = section |
| 194 | +
|
| 195 | +new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") |
| 196 | +with open(ctx_path, "wb") as fh: |
| 197 | + fh.write(new_content.encode("utf-8")) |
| 198 | +PY |
| 199 | + |
| 200 | +echo "agent-context: updated $CONTEXT_FILE" |
0 commit comments