Skip to content

Commit 50da3a0

Browse files
Copilotmnriemgithub-code-quality[bot]
authored
Extract agent context updates into bundled agent-context extension (#2546)
* Initial plan * Extract agent context updates into bundled agent-context extension * Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Potential fix for pull request finding 'Unused import' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: address review comments on agent-context extension - bash: parse init-options.json with a single python3 invocation instead of three separate read_json_field calls, for parity with the PowerShell ConvertFrom-Json approach and to avoid divergent error semantics - bash: use parameter expansion to strip PROJECT_ROOT prefix from plan path instead of sed interpolation, avoiding special-character fragility - powershell: limit Get-ChildItem to -Depth 1 so plan.md discovery matches the bash glob specs/*/plan.md (one level deep) — fixes cross-platform inconsistency with nested plan.md files - powershell: replace Substring+Length relative-path with [System.IO.Path]::GetRelativePath for robustness across case/PSDrive differences - __init__.py: move agent-context extension install to after save_init_options so init-options.json is present when hooks run - __init__.py: seed context_markers in init-options only when context_file is truthy; avoids noise for integrations without a context file - integrations/base.py: narrow blanket except Exception in _resolve_context_markers to ImportError / (OSError, ValueError) so unexpected bugs surface instead of being silently swallowed * fix: gate context_markers in _update_init_options_for_integration on context_file Apply the same gating logic used during `specify init`: only write context_markers to init-options.json when the integration actually has a context_file set. When switching to an integration without a context file the stale markers are removed, keeping the two init paths consistent. * fix: move context_file/context_markers from init-options.json to agent-context extension config * Potential fix for pull request finding 'Unused global variable' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * fix: clarify local import comment in agents.py * Fix remaining agent-context review findings * Fix follow-up agent-context review issues * Address review feedback: narrow except, improve PyYAML messaging, surface config-written note * Fix double-space in PyYAML install hint message * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> * Address latest agent-context review feedback * Harden bash config parse output handling * Clarify ImportError-only fallback comment * Apply review feedback: drop dead try/except, guard ext-config creation, explicit ConvertFrom-Yaml check * Remove redundant $Options = $null in PS1 catch block * Add constitution directives, deprecation warning, agent-context auto-install, and init flow fix - Add constitution-loading directive to specify, clarify, tasks, checklist, taskstoissues commands - Add deprecation warning (v0.12.0) in upsert_context_section() - Auto-install agent-context extension during specify init - Move context_file from init-options.json to agent-context extension config - Add tests: deprecation warning, corrupt config, constitution directives - Update file inventories across all integration tests * Address review: fix init ordering, test coverage, and hermes inventory - Move agent-context extension install after init-options.json is saved so skill registration can read ai_skills + integration key - Write extension config after install (avoids template overwriting context_file) - Fix test_defaults_when_markers_field_missing to truly test missing markers key - Update hermes tests to allow extension-installed agent-context skill * Address review: chmod ordering, preserve markers, PS1 Python check, YAML key order - Move ensure_executable_scripts after agent-context extension install so extension scripts get execute bits set - Use preserve_markers=True on reinit to keep user-customized markers - Add Python 3 version check in PowerShell fallback (matching bash behavior) - Add sort_keys=False to yaml.safe_dump for stable config output * Address review: path traversal guards and docstring fix - Reject absolute paths and '..' segments in context_file in both bash and PowerShell scripts to prevent writes outside the project root - Fix docstring in _update_init_options_for_integration to accurately describe marker preservation behavior * Address review: strict enabled check, docstring, segment-level path traversal - Use 'is not False' for enabled check so only literal False disables - Update upsert_context_section docstring to mention disabled-extension return - Fix path traversal guards to check actual path segments, not substrings (allows filenames like 'notes..md' while rejecting '../' traversal) * Address review: UnicodeError handling, missing extension warning - Add UnicodeError to exception tuples in _load_agent_context_config and _resolve_context_markers so garbled UTF-8 config files fall back to defaults - Emit error (with reinstall command) instead of silent skip when bundled agent-context extension is not found during init * Address review: bash backslash traversal guard, wheel packaging - Reject backslash separators and Windows drive-letter paths in bash context_file validation (prevents traversal on Git-Bash/Windows) - Add extensions/agent-context to pyproject.toml force-include so the bundled extension is included in wheel builds * Address review: write extension config before init-options.json - Reorder writes in _update_init_options_for_integration so the agent-context extension config is updated first; if it fails, init-options.json remains consistent with the previous state --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Manfred Riem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
1 parent cd8a39f commit 50da3a0

28 files changed

Lines changed: 1574 additions & 81 deletions

AGENTS.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,24 @@ def _register_builtins() -> None:
177177

178178
Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate.
179179

180-
Only add custom setup logic when the agent needs non-standard behavior. Most integrations do not need wrapper scripts or separate context-update dispatch code.
180+
The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`:
181+
182+
```yaml
183+
# Path to the coding agent context file managed by this extension
184+
context_file: CLAUDE.md
185+
186+
# Delimiters for the managed Spec Kit section
187+
context_markers:
188+
start: "<!-- SPECKIT START -->"
189+
end: "<!-- SPECKIT END -->"
190+
```
191+
192+
- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run.
193+
- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly — both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth.
194+
195+
Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
196+
197+
Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts — the `agent-context` extension is fully generic.
181198

182199
### 5. Test it
183200

@@ -409,7 +426,7 @@ When an issue exists, include its number immediately after the prefix — this i
409426
## Common Pitfalls
410427

411428
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
412-
2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated.
429+
2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally.
413430
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
414431
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.
415432
5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added.

extensions/agent-context/README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# Coding Agent Context Extension
2+
3+
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration.
4+
5+
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
6+
7+
## Why an extension?
8+
9+
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users:
10+
11+
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
12+
- **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+
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
14+
15+
## Commands
16+
17+
| Command | Description |
18+
|---------|-------------|
19+
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
20+
21+
## Configuration
22+
23+
All configuration flows through the extension's own config file at
24+
`.specify/extensions/agent-context/agent-context-config.yml`:
25+
26+
```yaml
27+
# Path to the coding agent context file managed by this extension
28+
context_file: CLAUDE.md
29+
30+
# Delimiters for the managed Spec Kit section
31+
context_markers:
32+
start: "<!-- SPECKIT START -->"
33+
end: "<!-- SPECKIT END -->"
34+
```
35+
36+
- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`.
37+
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
38+
39+
## Requirements
40+
41+
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
42+
43+
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
44+
45+
```bash
46+
pip install pyyaml
47+
# or target the specific interpreter Spec Kit uses:
48+
/path/to/speckit-python -m pip install pyyaml
49+
```
50+
51+
## Disable
52+
53+
```bash
54+
specify extension disable agent-context
55+
```
56+
57+
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Coding Agent Context Extension Configuration
2+
# These values are populated automatically by `specify init` and
3+
# `specify integration use` / `specify integration install`.
4+
5+
# Path (relative to the project root) to the coding agent context file
6+
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
7+
# .github/copilot-instructions.md). Set automatically from the active
8+
# integration and regenerated during `specify init` or integration switches.
9+
context_file: ""
10+
11+
# Delimiters for the managed Spec Kit section.
12+
# Edit these to use custom markers.
13+
context_markers:
14+
start: "<!-- SPECKIT START -->"
15+
end: "<!-- SPECKIT END -->"
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
description: "Refresh the managed Spec Kit section in the coding agent context file"
3+
---
4+
5+
# Update Coding Agent Context
6+
7+
Refresh the managed Spec Kit section inside the active coding agent's context/instruction file (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`).
8+
9+
## Behavior
10+
11+
The script reads the agent-context extension config at
12+
`.specify/extensions/agent-context/agent-context-config.yml` to discover:
13+
14+
- `context_file` — the path of the coding agent context file to manage.
15+
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
16+
17+
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`).
18+
19+
If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.
20+
21+
## Execution
22+
23+
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
24+
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
25+
26+
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
schema_version: "1.0"
2+
3+
extension:
4+
id: agent-context
5+
name: "Coding Agent Context"
6+
version: "1.0.0"
7+
description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers"
8+
author: spec-kit-core
9+
repository: https://github.com/github/spec-kit
10+
license: MIT
11+
12+
requires:
13+
speckit_version: ">=0.2.0"
14+
15+
provides:
16+
commands:
17+
- name: speckit.agent-context.update
18+
file: commands/speckit.agent-context.update.md
19+
description: "Refresh the managed Spec Kit section in the coding agent context file"
20+
21+
hooks:
22+
after_specify:
23+
command: speckit.agent-context.update
24+
optional: true
25+
description: "Refresh agent context after specification"
26+
after_plan:
27+
command: speckit.agent-context.update
28+
optional: true
29+
description: "Refresh agent context after planning"
30+
31+
tags:
32+
- "agent"
33+
- "context"
34+
- "core"
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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

Comments
 (0)