From 1d6c160e96dfe4eb17b7377852ac30b93595cbf5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 21:47:29 +0000 Subject: [PATCH 01/20] Initial plan From cca182503b52f893e2c9d86d0c6ba4874d2eaabb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 13 May 2026 22:01:56 +0000 Subject: [PATCH 02/20] Extract agent context updates into bundled agent-context extension --- AGENTS.md | 21 +- extensions/agent-context/README.md | 44 +++ .../commands/speckit.agent-context.update.md | 25 ++ extensions/agent-context/extension.yml | 34 ++ .../scripts/bash/update-agent-context.sh | 126 +++++++ .../powershell/update-agent-context.ps1 | 113 +++++++ extensions/catalog.json | 14 + src/specify_cli/__init__.py | 47 ++- src/specify_cli/integrations/base.py | 106 +++++- .../test_extension_agent_context.py | 309 ++++++++++++++++++ 10 files changed, 823 insertions(+), 16 deletions(-) create mode 100644 extensions/agent-context/README.md create mode 100644 extensions/agent-context/commands/speckit.agent-context.update.md create mode 100644 extensions/agent-context/extension.yml create mode 100755 extensions/agent-context/scripts/bash/update-agent-context.sh create mode 100644 extensions/agent-context/scripts/powershell/update-agent-context.ps1 create mode 100644 tests/extensions/test_extension_agent_context.py diff --git a/AGENTS.md b/AGENTS.md index d711b4214d..720f9ff021 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,7 +177,24 @@ def _register_builtins() -> None: 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. -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. +The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through `.specify/init-options.json`: + +```json +{ + "context_file": "CLAUDE.md", + "context_markers": { + "start": "", + "end": "" + } +} +``` + +- `context_file` is written automatically from the integration's class attribute. +- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `init-options.json` 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. + +Users can opt out entirely with `specify extension disable agent-context`; while disabled, `setup()` and `teardown()` skip context-file creation, updates, and removal. + +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. ### 5. Test it @@ -382,7 +399,7 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): ## Common Pitfalls 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. -2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated. +2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/init-options.json`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally. 3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents. 4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents. 5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added. diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md new file mode 100644 index 0000000000..48b391939d --- /dev/null +++ b/extensions/agent-context/README.md @@ -0,0 +1,44 @@ +# Coding Agent Context Extension + +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. + +It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `` / ``). + +## Why an extension? + +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: + +- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file. +- **Customize the markers** by editing `.specify/init-options.json` — both the Python layer and the bundled scripts honor the same `context_markers` value. +- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). + +## Commands + +| Command | Description | +|---------|-------------| +| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. | + +## Configuration + +All configuration flows through `.specify/init-options.json`: + +```json +{ + "context_file": "CLAUDE.md", + "context_markers": { + "start": "", + "end": "" + } +} +``` + +- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. +- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. + +## Disable + +```bash +specify extension disable agent-context +``` + +When disabled, `IntegrationBase.setup()` and `IntegrationBase.teardown()` skip context file creation, updates, and removal. diff --git a/extensions/agent-context/commands/speckit.agent-context.update.md b/extensions/agent-context/commands/speckit.agent-context.update.md new file mode 100644 index 0000000000..19b6f4165e --- /dev/null +++ b/extensions/agent-context/commands/speckit.agent-context.update.md @@ -0,0 +1,25 @@ +--- +description: "Refresh the managed Spec Kit section in the coding agent context file" +--- + +# Update Coding Agent Context + +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`). + +## Behavior + +The script reads the project's `.specify/init-options.json` to discover: + +- `context_file` — the path of the coding agent context file to manage. +- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `` and `` when the field is missing. + +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//plan.md`). + +If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully. + +## Execution + +- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]` +- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]` + +When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`. diff --git a/extensions/agent-context/extension.yml b/extensions/agent-context/extension.yml new file mode 100644 index 0000000000..191069e32c --- /dev/null +++ b/extensions/agent-context/extension.yml @@ -0,0 +1,34 @@ +schema_version: "1.0" + +extension: + id: agent-context + name: "Coding Agent Context" + version: "1.0.0" + description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers" + author: spec-kit-core + repository: https://github.com/github/spec-kit + license: MIT + +requires: + speckit_version: ">=0.2.0" + +provides: + commands: + - name: speckit.agent-context.update + file: commands/speckit.agent-context.update.md + description: "Refresh the managed Spec Kit section in the coding agent context file" + +hooks: + after_specify: + command: speckit.agent-context.update + optional: true + description: "Refresh agent context after specification" + after_plan: + command: speckit.agent-context.update + optional: true + description: "Refresh agent context after planning" + +tags: + - "agent" + - "context" + - "core" diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh new file mode 100755 index 0000000000..f01687fa0f --- /dev/null +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# update-agent-context.sh +# +# Refresh the managed Spec Kit section in the coding agent's context file +# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). +# +# Reads `context_file` and `context_markers.{start,end}` from +# `.specify/init-options.json`. Falls back to the default markers when +# `context_markers` is absent. +# +# Usage: update-agent-context.sh [plan_path] +# +# When `plan_path` is omitted, the script picks the most recently modified +# `specs/*/plan.md` if any exist, otherwise emits the section without a +# concrete plan path. + +set -euo pipefail + +PROJECT_ROOT="$(pwd)" +INIT_OPTIONS="$PROJECT_ROOT/.specify/init-options.json" +DEFAULT_START="" +DEFAULT_END="" + +if [[ ! -f "$INIT_OPTIONS" ]]; then + echo "agent-context: $INIT_OPTIONS not found; nothing to do." >&2 + exit 0 +fi + +# Use python for JSON parsing (always available in spec-kit projects). +read_json_field() { + # $1 = jq-style dotted path, e.g. "context_markers.start" + python3 - "$INIT_OPTIONS" "$1" <<'PY' +import json, sys +path = sys.argv[1] +key = sys.argv[2] +try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) +except Exception: + sys.exit(0) +node = data +for part in key.split("."): + if isinstance(node, dict) and part in node: + node = node[part] + else: + sys.exit(0) +if isinstance(node, str): + sys.stdout.write(node) +PY +} + +CONTEXT_FILE="$(read_json_field 'context_file' || true)" +if [[ -z "$CONTEXT_FILE" ]]; then + echo "agent-context: context_file not set in init-options.json; nothing to do." >&2 + exit 0 +fi + +MARKER_START="$(read_json_field 'context_markers.start' || true)" +MARKER_END="$(read_json_field 'context_markers.end' || true)" +[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START" +[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END" + +PLAN_PATH="${1:-}" +if [[ -z "$PLAN_PATH" ]]; then + if compgen -G "$PROJECT_ROOT/specs/*/plan.md" > /dev/null; then + # Pick the most recently modified plan.md + PLAN_PATH="$(ls -1t "$PROJECT_ROOT"/specs/*/plan.md 2>/dev/null | head -1 | sed "s|$PROJECT_ROOT/||")" + 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 +{ + 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" + +python3 - "$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" diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 new file mode 100644 index 0000000000..da663a5ffa --- /dev/null +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -0,0 +1,113 @@ +#!/usr/bin/env pwsh +# update-agent-context.ps1 +# +# Refresh the managed Spec Kit section in the coding agent's context file +# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). +# +# Reads `context_file` and `context_markers.{start,end}` from +# `.specify/init-options.json`. Falls back to the default markers when +# `context_markers` is absent. +# +# Usage: update-agent-context.ps1 [plan_path] + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [string]$PlanPath +) + +$ErrorActionPreference = 'Stop' +$DefaultStart = '' +$DefaultEnd = '' +$ProjectRoot = (Get-Location).Path +$InitOptions = Join-Path $ProjectRoot '.specify/init-options.json' + +if (-not (Test-Path -LiteralPath $InitOptions)) { + Write-Host "agent-context: $InitOptions not found; nothing to do." + exit 0 +} + +try { + $Options = Get-Content -LiteralPath $InitOptions -Raw | ConvertFrom-Json +} catch { + Write-Host "agent-context: failed to parse init-options.json; nothing to do." + exit 0 +} + +$ContextFile = $Options.context_file +if (-not $ContextFile) { + Write-Host 'agent-context: context_file not set in init-options.json; nothing to do.' + exit 0 +} + +$MarkerStart = $DefaultStart +$MarkerEnd = $DefaultEnd +if ($Options.context_markers) { + if ($Options.context_markers.start -is [string] -and $Options.context_markers.start) { + $MarkerStart = $Options.context_markers.start + } + if ($Options.context_markers.end -is [string] -and $Options.context_markers.end) { + $MarkerEnd = $Options.context_markers.end + } +} + +if (-not $PlanPath) { + $candidate = Get-ChildItem -Path (Join-Path $ProjectRoot 'specs') -Filter 'plan.md' -Recurse -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($candidate) { + $PlanPath = $candidate.FullName.Substring($ProjectRoot.Length).TrimStart('/', '\').Replace('\','/') + } +} + +$CtxPath = Join-Path $ProjectRoot $ContextFile +$CtxDir = Split-Path -Parent $CtxPath +if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) { + New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null +} + +$lines = @($MarkerStart, + 'For additional context about technologies to be used, project structure,', + 'shell commands, and other important information, read the current plan') +if ($PlanPath) { + $lines += "at $PlanPath" +} +$lines += $MarkerEnd +$Section = ($lines -join "`n") + "`n" + +if (Test-Path -LiteralPath $CtxPath) { + $rawBytes = [System.IO.File]::ReadAllBytes($CtxPath) + # Strip UTF-8 BOM if present + if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) { + $content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3) + } else { + $content = [System.Text.Encoding]::UTF8.GetString($rawBytes) + } + + $s = $content.IndexOf($MarkerStart) + $e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) } + + if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) { + $endOfMarker = $e + $MarkerEnd.Length + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } + $newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker) + } elseif ($s -ge 0) { + $newContent = $content.Substring(0, $s) + $Section + } elseif ($e -ge 0) { + $endOfMarker = $e + $MarkerEnd.Length + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ } + if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ } + $newContent = $Section + $content.Substring($endOfMarker) + } else { + if ($content -and -not $content.EndsWith("`n")) { $content += "`n" } + if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section } + } +} else { + $newContent = $Section +} + +$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n") +[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false))) + +Write-Host "agent-context: updated $ContextFile" diff --git a/extensions/catalog.json b/extensions/catalog.json index de9372e2bc..284e9abe75 100644 --- a/extensions/catalog.json +++ b/extensions/catalog.json @@ -3,6 +3,20 @@ "updated_at": "2026-04-10T00:00:00Z", "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json", "extensions": { + "agent-context": { + "name": "Coding Agent Context", + "id": "agent-context", + "version": "1.0.0", + "description": "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers", + "author": "spec-kit-core", + "repository": "https://github.com/github/spec-kit", + "bundled": true, + "tags": [ + "agent", + "context", + "core" + ] + }, "git": { "name": "Git Branching Workflow", "id": "git", diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d4e8632215..354d922aaa 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -747,6 +747,7 @@ def init( ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), ("git", "Install git extension"), + ("agent-context", "Install agent-context extension"), ("workflow", "Install bundled workflow"), ("final", "Finalize"), ]: @@ -869,6 +870,31 @@ def init( else: tracker.skip("git", "--no-git flag") + # Install bundled agent-context extension (opt-out via + # `specify extension disable agent-context`). Owns the managed + # section in coding agent context files (CLAUDE.md, etc.). + tracker.start("agent-context") + try: + from .extensions import ExtensionManager as _AgentCtxMgr + bundled_ac = _locate_bundled_extension("agent-context") + if bundled_ac: + ac_manager = _AgentCtxMgr(project_path) + if ac_manager.registry.is_installed("agent-context"): + tracker.complete("agent-context", "already installed") + else: + ac_manager.install_from_directory( + bundled_ac, get_speckit_version() + ) + tracker.complete("agent-context", "installed") + else: + tracker.skip("agent-context", "bundled extension not found") + except Exception as ac_err: + sanitized_ac = str(ac_err).replace('\n', ' ').strip() + tracker.error( + "agent-context", + f"install failed: {sanitized_ac[:120]}", + ) + # Install bundled speckit workflow try: bundled_wf = _locate_bundled_workflow("speckit") @@ -906,11 +932,16 @@ def init( # Persist the CLI options so later operations (e.g. preset add) # can adapt their behaviour without re-scanning the filesystem. # Must be saved BEFORE preset install so _get_skills_dir() works. + from .integrations.base import IntegrationBase init_opts = { "ai": selected_ai, "integration": resolved_integration.key, "branch_numbering": branch_numbering or "sequential", "context_file": resolved_integration.context_file, + "context_markers": { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + }, "here": here, "script": selected_script, "speckit_version": get_speckit_version(), @@ -1441,6 +1472,7 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str opts.pop("ai", None) opts.pop("ai_skills", None) opts.pop("context_file", None) + opts.pop("context_markers", None) save_init_options(project_root, opts) @@ -1859,11 +1891,24 @@ def _update_init_options_for_integration( script_type: str | None = None, ) -> None: """Update ``init-options.json`` to reflect *integration* as the active one.""" - from .integrations.base import SkillsIntegration + from .integrations.base import IntegrationBase, SkillsIntegration opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key opts["context_file"] = integration.context_file + # Preserve any user-customized markers; only seed defaults if absent or invalid. + existing_markers = opts.get("context_markers") + if not isinstance(existing_markers, dict): + opts["context_markers"] = { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + } + else: + if not isinstance(existing_markers.get("start"), str) or not existing_markers.get("start"): + existing_markers["start"] = IntegrationBase.CONTEXT_MARKER_START + if not isinstance(existing_markers.get("end"), str) or not existing_markers.get("end"): + existing_markers["end"] = IntegrationBase.CONTEXT_MARKER_END + opts["context_markers"] = existing_markers if script_type: opts["script"] = script_type if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 7ce107caec..5d70bec1d7 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -482,6 +482,73 @@ def _build_context_section(plan_path: str = "") -> str: lines.append(f"at {plan_path}") return "\n".join(lines) + @staticmethod + def _agent_context_extension_enabled(project_root: Path) -> bool: + """Return whether the bundled ``agent-context`` extension is enabled. + + The extension is the single source of truth for managing coding + agent context/instruction files (e.g. ``CLAUDE.md``, + ``.github/copilot-instructions.md``). + + Returns ``True`` (enabled) when: + - the extension registry does not exist (legacy project, backwards + compatibility), or + - the registry has no ``agent-context`` entry (older project layout + predating the extension), or + - the entry is present and not explicitly disabled. + + Returns ``False`` only when an entry exists with ``enabled: false``. + """ + registry_path = ( + project_root / ".specify" / "extensions" / ".registry" + ) + if not registry_path.exists(): + return True + try: + import json as _json + + data = _json.loads(registry_path.read_text(encoding="utf-8")) + except (OSError, ValueError, UnicodeError): + return True + if not isinstance(data, dict): + return True + extensions = data.get("extensions") + if not isinstance(extensions, dict): + return True + entry = extensions.get("agent-context") + if not isinstance(entry, dict): + return True + return bool(entry.get("enabled", True)) + + def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]: + """Return the (start, end) context markers to use for *project_root*. + + Reads ``context_markers.start`` / ``context_markers.end`` from + ``.specify/init-options.json`` when present. Falls back to the + class-level constants ``CONTEXT_MARKER_START`` / + ``CONTEXT_MARKER_END`` when the file is missing, the section is + absent, or the values are not non-empty strings. + """ + start = self.CONTEXT_MARKER_START + end = self.CONTEXT_MARKER_END + try: + from .. import load_init_options # local import to avoid cycles + except Exception: + return start, end + try: + opts = load_init_options(project_root) + except Exception: + return start, end + markers = opts.get("context_markers") if isinstance(opts, dict) else None + if isinstance(markers, dict): + cm_start = markers.get("start") + cm_end = markers.get("end") + if isinstance(cm_start, str) and cm_start: + start = cm_start + if isinstance(cm_end, str) and cm_end: + end = cm_end + return start, end + def upsert_context_section( self, project_root: Path, @@ -490,9 +557,11 @@ def upsert_context_section( """Create or update the managed section in the agent context file. If the context file does not exist it is created with just the - managed section. If it exists, the content between - ```` and ```` markers - is replaced (or appended when no markers are found). + managed section. If it exists, the content between the configured + start/end markers (default ```` / + ````) is replaced, or appended when no markers + are found. Markers are read from ``.specify/init-options.json`` + when present, falling back to the class-level constants. Returns the path to the context file, or ``None`` when ``context_file`` is not set. @@ -500,24 +569,29 @@ def upsert_context_section( if not self.context_file: return None + if not self._agent_context_extension_enabled(project_root): + return None + + marker_start, marker_end = self._resolve_context_markers(project_root) + ctx_path = project_root / self.context_file section = ( - f"{self.CONTEXT_MARKER_START}\n" + f"{marker_start}\n" f"{self._build_context_section(plan_path)}\n" - f"{self.CONTEXT_MARKER_END}\n" + f"{marker_end}\n" ) if ctx_path.exists(): content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(self.CONTEXT_MARKER_START) + start_idx = content.find(marker_start) end_idx = content.find( - self.CONTEXT_MARKER_END, + marker_end, start_idx if start_idx != -1 else 0, ) if start_idx != -1 and end_idx != -1 and end_idx > start_idx: # Replace existing section (include the end marker + newline) - end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + end_of_marker = end_idx + len(marker_end) # Consume trailing line ending (CRLF or LF) if end_of_marker < len(content) and content[end_of_marker] == "\r": end_of_marker += 1 @@ -529,7 +603,7 @@ def upsert_context_section( new_content = content[:start_idx] + section elif end_idx != -1: # Corrupted: end marker without start — replace BOF through end marker - end_of_marker = end_idx + len(self.CONTEXT_MARKER_END) + end_of_marker = end_idx + len(marker_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": @@ -564,19 +638,25 @@ def remove_context_section(self, project_root: Path) -> bool: Returns ``True`` if the section was found and removed. If the file becomes empty (or whitespace-only) after removal it is - deleted. + deleted. Markers are read from ``.specify/init-options.json`` + when present, falling back to the class-level constants. """ if not self.context_file: return False + if not self._agent_context_extension_enabled(project_root): + return False + ctx_path = project_root / self.context_file if not ctx_path.exists(): return False + marker_start, marker_end = self._resolve_context_markers(project_root) + content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(self.CONTEXT_MARKER_START) + start_idx = content.find(marker_start) end_idx = content.find( - self.CONTEXT_MARKER_END, + marker_end, start_idx if start_idx != -1 else 0, ) @@ -587,7 +667,7 @@ def remove_context_section(self, project_root: Path) -> bool: return False removal_start = start_idx - removal_end = end_idx + len(self.CONTEXT_MARKER_END) + removal_end = end_idx + len(marker_end) # Consume trailing line ending (CRLF or LF) if removal_end < len(content) and content[removal_end] == "\r": diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py new file mode 100644 index 0000000000..7129ec0ea8 --- /dev/null +++ b/tests/extensions/test_extension_agent_context.py @@ -0,0 +1,309 @@ +"""Tests for the bundled ``agent-context`` extension and related plumbing.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from specify_cli import ( + INIT_OPTIONS_FILE, + load_init_options, + save_init_options, +) +from specify_cli.integrations.base import IntegrationBase +from specify_cli.integrations.claude import ClaudeIntegration + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context" + + +# ── Bundled extension layout ───────────────────────────────────────────────── + + +class TestExtensionLayout: + """The bundled agent-context extension ships a complete package.""" + + def test_extension_yml_exists(self): + assert (EXT_DIR / "extension.yml").is_file() + + def test_extension_yml_has_required_fields(self): + import yaml + + manifest = yaml.safe_load((EXT_DIR / "extension.yml").read_text()) + assert manifest["extension"]["id"] == "agent-context" + assert manifest["extension"]["name"] == "Coding Agent Context" + assert manifest["extension"]["author"] == "spec-kit-core" + # Provides at least the manual update command + commands = {c["name"] for c in manifest["provides"]["commands"]} + assert "speckit.agent-context.update" in commands + + def test_readme_exists(self): + readme = EXT_DIR / "README.md" + assert readme.is_file() + text = readme.read_text(encoding="utf-8") + assert "Coding Agent Context Extension" in text + + def test_command_file_exists(self): + cmd = EXT_DIR / "commands" / "speckit.agent-context.update.md" + assert cmd.is_file() + assert "init-options.json" in cmd.read_text(encoding="utf-8") + + def test_bundled_scripts_exist(self): + assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file() + assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file() + + def test_bash_script_reads_init_options(self): + text = (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").read_text( + encoding="utf-8" + ) + # The script must consult init-options.json — no agent-specific logic + assert "init-options.json" in text + assert "context_file" in text + assert "context_markers" in text + + +# ── Catalog registration ───────────────────────────────────────────────────── + + +class TestCatalogEntry: + def test_catalog_lists_agent_context_as_bundled(self): + catalog = json.loads( + (PROJECT_ROOT / "extensions" / "catalog.json").read_text(encoding="utf-8") + ) + entry = catalog["extensions"]["agent-context"] + assert entry["bundled"] is True + assert entry["id"] == "agent-context" + assert entry["author"] == "spec-kit-core" + + +# ── Marker resolution from init-options.json ───────────────────────────────── + + +class _CtxIntegration(ClaudeIntegration): + """Use Claude as a concrete integration with a context_file.""" + + +class TestContextMarkerResolution: + def _seed_options(self, project_root: Path, **overrides): + save_init_options(project_root, overrides) + + def test_defaults_when_init_options_missing(self, tmp_path): + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == IntegrationBase.CONTEXT_MARKER_START + assert end == IntegrationBase.CONTEXT_MARKER_END + + def test_defaults_when_field_missing(self, tmp_path): + self._seed_options(tmp_path, context_file="CLAUDE.md") + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == IntegrationBase.CONTEXT_MARKER_START + assert end == IntegrationBase.CONTEXT_MARKER_END + + def test_custom_markers_respected(self, tmp_path): + self._seed_options( + tmp_path, + context_markers={"start": "", "end": ""}, + ) + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == "" + assert end == "" + + def test_partial_override_falls_back_for_missing_side(self, tmp_path): + self._seed_options(tmp_path, context_markers={"start": ""}) + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == "" + assert end == IntegrationBase.CONTEXT_MARKER_END + + def test_invalid_markers_fall_back(self, tmp_path): + self._seed_options(tmp_path, context_markers={"start": 42, "end": ""}) + i = _CtxIntegration() + start, end = i._resolve_context_markers(tmp_path) + assert start == IntegrationBase.CONTEXT_MARKER_START + assert end == IntegrationBase.CONTEXT_MARKER_END + + +# ── upsert_context_section / remove_context_section honor markers ─────────── + + +class TestUpsertWithCustomMarkers: + def _setup(self, tmp_path: Path, markers: dict | None = None) -> _CtxIntegration: + opts: dict = {"context_file": "CLAUDE.md"} + if markers is not None: + opts["context_markers"] = markers + save_init_options(tmp_path, opts) + return _CtxIntegration() + + def test_upsert_uses_default_markers(self, tmp_path): + i = self._setup(tmp_path) + result = i.upsert_context_section(tmp_path) + assert result is not None + text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert IntegrationBase.CONTEXT_MARKER_START in text + assert IntegrationBase.CONTEXT_MARKER_END in text + + def test_upsert_uses_custom_markers(self, tmp_path): + i = self._setup( + tmp_path, {"start": "", "end": ""} + ) + i.upsert_context_section(tmp_path) + text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") + assert "" in text + assert "" in text + # Defaults must not appear + assert IntegrationBase.CONTEXT_MARKER_START not in text + assert IntegrationBase.CONTEXT_MARKER_END not in text + + def test_upsert_replaces_existing_custom_section(self, tmp_path): + i = self._setup( + tmp_path, {"start": "", "end": ""} + ) + ctx = tmp_path / "CLAUDE.md" + ctx.write_text( + "# header\n\n\nold body\n\n\nfooter\n", + encoding="utf-8", + ) + i.upsert_context_section(tmp_path, plan_path="specs/001-foo/plan.md") + text = ctx.read_text(encoding="utf-8") + assert "old body" not in text + assert "specs/001-foo/plan.md" in text + assert text.startswith("# header\n") + assert "footer" in text + + def test_remove_uses_custom_markers(self, tmp_path): + i = self._setup( + tmp_path, {"start": "", "end": ""} + ) + ctx = tmp_path / "CLAUDE.md" + ctx.write_text( + "preamble\n\n\nbody\n\nepilogue\n", + encoding="utf-8", + ) + removed = i.remove_context_section(tmp_path) + assert removed is True + remaining = ctx.read_text(encoding="utf-8") + assert "" not in remaining + assert "" not in remaining + assert "body" not in remaining + assert "preamble" in remaining + assert "epilogue" in remaining + + def test_remove_with_default_markers_unchanged_when_custom_in_file(self, tmp_path): + # init-options.json absent → default markers used. File contains only + # custom markers — nothing should be removed. + i = _CtxIntegration() + ctx = tmp_path / "CLAUDE.md" + original = "x\n\nbody\n\n" + ctx.write_text(original, encoding="utf-8") + assert i.remove_context_section(tmp_path) is False + assert ctx.read_text(encoding="utf-8") == original + + +# ── Extension disabled gates setup/teardown ────────────────────────────────── + + +def _write_registry(project_root: Path, *, enabled: bool) -> None: + registry = project_root / ".specify" / "extensions" / ".registry" + registry.parent.mkdir(parents=True, exist_ok=True) + registry.write_text( + json.dumps( + { + "schema_version": "1.0", + "extensions": { + "agent-context": { + "version": "1.0.0", + "enabled": enabled, + } + }, + } + ), + encoding="utf-8", + ) + + +class TestExtensionEnabledGate: + def test_enabled_helper_default_when_no_registry(self, tmp_path): + assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True + + def test_enabled_helper_when_entry_present(self, tmp_path): + _write_registry(tmp_path, enabled=True) + assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True + + def test_disabled_helper_when_entry_disabled(self, tmp_path): + _write_registry(tmp_path, enabled=False) + assert IntegrationBase._agent_context_extension_enabled(tmp_path) is False + + def test_upsert_skipped_when_disabled(self, tmp_path): + _write_registry(tmp_path, enabled=False) + i = _CtxIntegration() + result = i.upsert_context_section(tmp_path) + assert result is None + assert not (tmp_path / "CLAUDE.md").exists() + + def test_remove_skipped_when_disabled(self, tmp_path): + _write_registry(tmp_path, enabled=False) + i = _CtxIntegration() + ctx = tmp_path / "CLAUDE.md" + original = ( + f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" + f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n" + ) + ctx.write_text(original, encoding="utf-8") + assert i.remove_context_section(tmp_path) is False + # File must be unchanged when extension is disabled + assert ctx.read_text(encoding="utf-8") == original + + +# ── init-options writers seed default markers ──────────────────────────────── + + +class TestInitOptionsWriters: + def test_clear_init_options_pops_context_markers(self, tmp_path): + from specify_cli import _clear_init_options_for_integration + + save_init_options( + tmp_path, + { + "integration": "claude", + "ai": "claude", + "context_file": "CLAUDE.md", + "context_markers": { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + }, + }, + ) + _clear_init_options_for_integration(tmp_path, "claude") + opts = load_init_options(tmp_path) + assert "context_file" not in opts + assert "context_markers" not in opts + + def test_update_init_options_seeds_default_markers(self, tmp_path): + from specify_cli import _update_init_options_for_integration + + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i, script_type="sh") + opts = load_init_options(tmp_path) + assert opts["integration"] == i.key + assert opts["context_file"] == i.context_file + assert opts["context_markers"] == { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + } + + def test_update_init_options_preserves_custom_markers(self, tmp_path): + from specify_cli import _update_init_options_for_integration + + save_init_options( + tmp_path, + {"context_markers": {"start": "", "end": ""}}, + ) + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i) + opts = load_init_options(tmp_path) + assert opts["context_markers"] == {"start": "", "end": ""} From d79fd51b6f609ae0adab221e003f77e33545ee59 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 13 May 2026 17:15:03 -0500 Subject: [PATCH 03/20] 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> --- tests/extensions/test_extension_agent_context.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 7129ec0ea8..f32fa43a9f 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -5,8 +5,6 @@ import json from pathlib import Path -import pytest - from specify_cli import ( INIT_OPTIONS_FILE, load_init_options, From 8e0d40e6ab5b3a4d9424d7a21f04838b5e7c1915 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 13 May 2026 17:27:36 -0500 Subject: [PATCH 04/20] 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> --- tests/extensions/test_extension_agent_context.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index f32fa43a9f..5373bc9f71 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -6,7 +6,6 @@ from pathlib import Path from specify_cli import ( - INIT_OPTIONS_FILE, load_init_options, save_init_options, ) From 8512915ff3175fce37c7a37c662efa0e4643c7f6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:11:36 +0000 Subject: [PATCH 05/20] fix: address review comments on agent-context extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../scripts/bash/update-agent-context.sh | 48 +++++++------ .../powershell/update-agent-context.ps1 | 4 +- src/specify_cli/__init__.py | 69 ++++++++++--------- src/specify_cli/integrations/base.py | 4 +- 4 files changed, 67 insertions(+), 58 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index f01687fa0f..928345e72d 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -26,45 +26,49 @@ if [[ ! -f "$INIT_OPTIONS" ]]; then exit 0 fi -# Use python for JSON parsing (always available in spec-kit projects). -read_json_field() { - # $1 = jq-style dotted path, e.g. "context_markers.start" - python3 - "$INIT_OPTIONS" "$1" <<'PY' +# Parse init-options.json once; emit three newline-separated fields: +# context_file, context_markers.start, context_markers.end +_raw_opts="$(python3 - "$INIT_OPTIONS" <<'PY' import json, sys -path = sys.argv[1] -key = sys.argv[2] try: - with open(path, "r", encoding="utf-8") as fh: + with open(sys.argv[1], "r", encoding="utf-8") as fh: data = json.load(fh) except Exception: - sys.exit(0) -node = data -for part in key.split("."): - if isinstance(node, dict) and part in node: - node = node[part] - else: - sys.exit(0) -if isinstance(node, str): - sys.stdout.write(node) + 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 "" +print(get_str(data, "context_file")) +print(get_str(data, "context_markers", "start")) +print(get_str(data, "context_markers", "end")) PY -} +)" + +{ + IFS= read -r CONTEXT_FILE + IFS= read -r MARKER_START + IFS= read -r MARKER_END +} <<< "$_raw_opts" -CONTEXT_FILE="$(read_json_field 'context_file' || true)" if [[ -z "$CONTEXT_FILE" ]]; then echo "agent-context: context_file not set in init-options.json; nothing to do." >&2 exit 0 fi -MARKER_START="$(read_json_field 'context_markers.start' || true)" -MARKER_END="$(read_json_field 'context_markers.end' || true)" [[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START" [[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END" PLAN_PATH="${1:-}" if [[ -z "$PLAN_PATH" ]]; then if compgen -G "$PROJECT_ROOT/specs/*/plan.md" > /dev/null; then - # Pick the most recently modified plan.md - PLAN_PATH="$(ls -1t "$PROJECT_ROOT"/specs/*/plan.md 2>/dev/null | head -1 | sed "s|$PROJECT_ROOT/||")" + # Pick the most recently modified plan.md (one level deep: specs//plan.md) + _plan_abs="$(ls -1t "$PROJECT_ROOT"/specs/*/plan.md 2>/dev/null | head -1)" + PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" fi fi diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index da663a5ffa..eb0e009994 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -52,11 +52,11 @@ if ($Options.context_markers) { } if (-not $PlanPath) { - $candidate = Get-ChildItem -Path (Join-Path $ProjectRoot 'specs') -Filter 'plan.md' -Recurse -ErrorAction SilentlyContinue | + $candidate = Get-ChildItem -Path (Join-Path $ProjectRoot 'specs') -Filter 'plan.md' -Recurse -Depth 1 -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($candidate) { - $PlanPath = $candidate.FullName.Substring($ProjectRoot.Length).TrimStart('/', '\').Replace('\','/') + $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') } } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 354d922aaa..3761dd4b0d 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -870,31 +870,6 @@ def init( else: tracker.skip("git", "--no-git flag") - # Install bundled agent-context extension (opt-out via - # `specify extension disable agent-context`). Owns the managed - # section in coding agent context files (CLAUDE.md, etc.). - tracker.start("agent-context") - try: - from .extensions import ExtensionManager as _AgentCtxMgr - bundled_ac = _locate_bundled_extension("agent-context") - if bundled_ac: - ac_manager = _AgentCtxMgr(project_path) - if ac_manager.registry.is_installed("agent-context"): - tracker.complete("agent-context", "already installed") - else: - ac_manager.install_from_directory( - bundled_ac, get_speckit_version() - ) - tracker.complete("agent-context", "installed") - else: - tracker.skip("agent-context", "bundled extension not found") - except Exception as ac_err: - sanitized_ac = str(ac_err).replace('\n', ' ').strip() - tracker.error( - "agent-context", - f"install failed: {sanitized_ac[:120]}", - ) - # Install bundled speckit workflow try: bundled_wf = _locate_bundled_workflow("speckit") @@ -926,26 +901,26 @@ def init( sanitized_wf = str(wf_err).replace('\n', ' ').strip() tracker.error("workflow", f"install failed: {sanitized_wf[:120]}") - # Fix permissions after all installs (scripts + extensions) - ensure_executable_scripts(project_path, tracker=tracker) - # Persist the CLI options so later operations (e.g. preset add) # can adapt their behaviour without re-scanning the filesystem. # Must be saved BEFORE preset install so _get_skills_dir() works. + # Also saved BEFORE agent-context install so init-options.json is + # available when the extension's hooks run. from .integrations.base import IntegrationBase init_opts = { "ai": selected_ai, "integration": resolved_integration.key, "branch_numbering": branch_numbering or "sequential", "context_file": resolved_integration.context_file, - "context_markers": { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, - }, "here": here, "script": selected_script, "speckit_version": get_speckit_version(), } + if resolved_integration.context_file: + init_opts["context_markers"] = { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + } # Ensure ai_skills is set for SkillsIntegration so downstream # tools (extensions, presets) emit SKILL.md overrides correctly. # Also set for integrations running in skills mode (e.g. Copilot @@ -955,6 +930,36 @@ def init( init_opts["ai_skills"] = True save_init_options(project_path, init_opts) + # Install bundled agent-context extension (opt-out via + # `specify extension disable agent-context`). Owns the managed + # section in coding agent context files (CLAUDE.md, etc.). + # Installed AFTER init-options are saved so hooks can read + # context_file / context_markers from init-options.json. + tracker.start("agent-context") + try: + from .extensions import ExtensionManager as _AgentCtxMgr + bundled_ac = _locate_bundled_extension("agent-context") + if bundled_ac: + ac_manager = _AgentCtxMgr(project_path) + if ac_manager.registry.is_installed("agent-context"): + tracker.complete("agent-context", "already installed") + else: + ac_manager.install_from_directory( + bundled_ac, get_speckit_version() + ) + tracker.complete("agent-context", "installed") + else: + tracker.skip("agent-context", "bundled extension not found") + except Exception as ac_err: + sanitized_ac = str(ac_err).replace('\n', ' ').strip() + tracker.error( + "agent-context", + f"install failed: {sanitized_ac[:120]}", + ) + + # Fix permissions after all installs (scripts + extensions) + ensure_executable_scripts(project_path, tracker=tracker) + # Install preset if specified if preset: try: diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 5d70bec1d7..e3a644ee5c 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -533,11 +533,11 @@ def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]: end = self.CONTEXT_MARKER_END try: from .. import load_init_options # local import to avoid cycles - except Exception: + except ImportError: return start, end try: opts = load_init_options(project_root) - except Exception: + except (OSError, ValueError): return start, end markers = opts.get("context_markers") if isinstance(opts, dict) else None if isinstance(markers, dict): From 7bc560e19f361915a391e7d71a4b4a6a42c86a6c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:14:54 +0000 Subject: [PATCH 06/20] 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. --- src/specify_cli/__init__.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 3761dd4b0d..b94db42bbb 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1902,18 +1902,22 @@ def _update_init_options_for_integration( opts["ai"] = integration.key opts["context_file"] = integration.context_file # Preserve any user-customized markers; only seed defaults if absent or invalid. - existing_markers = opts.get("context_markers") - if not isinstance(existing_markers, dict): - opts["context_markers"] = { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, - } + # Only write context_markers when this integration actually has a context file. + if integration.context_file: + existing_markers = opts.get("context_markers") + if not isinstance(existing_markers, dict): + opts["context_markers"] = { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + } + else: + if not isinstance(existing_markers.get("start"), str) or not existing_markers.get("start"): + existing_markers["start"] = IntegrationBase.CONTEXT_MARKER_START + if not isinstance(existing_markers.get("end"), str) or not existing_markers.get("end"): + existing_markers["end"] = IntegrationBase.CONTEXT_MARKER_END + opts["context_markers"] = existing_markers else: - if not isinstance(existing_markers.get("start"), str) or not existing_markers.get("start"): - existing_markers["start"] = IntegrationBase.CONTEXT_MARKER_START - if not isinstance(existing_markers.get("end"), str) or not existing_markers.get("end"): - existing_markers["end"] = IntegrationBase.CONTEXT_MARKER_END - opts["context_markers"] = existing_markers + opts.pop("context_markers", None) if script_type: opts["script"] = script_type if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): From 1759d8d5ce292f0c13f314795b49c84a5500146e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:49:22 +0000 Subject: [PATCH 07/20] fix: move context_file/context_markers from init-options.json to agent-context extension config --- AGENTS.md | 24 +-- extensions/agent-context/README.md | 23 +-- .../agent-context/agent-context-config.yml | 15 ++ .../commands/speckit.agent-context.update.md | 3 +- .../scripts/bash/update-agent-context.sh | 62 +++++-- .../powershell/update-agent-context.ps1 | 67 ++++--- src/specify_cli/__init__.py | 171 ++++++++++++++---- src/specify_cli/agents.py | 13 +- src/specify_cli/integrations/base.py | 57 ++++-- .../test_extension_agent_context.py | 135 +++++++++----- tests/integrations/test_cli.py | 9 +- .../test_integration_base_markdown.py | 14 +- .../test_integration_base_skills.py | 13 +- .../test_integration_base_toml.py | 14 +- .../test_integration_base_yaml.py | 14 +- .../integrations/test_integration_copilot.py | 4 + .../integrations/test_integration_generic.py | 11 +- 17 files changed, 458 insertions(+), 191 deletions(-) create mode 100644 extensions/agent-context/agent-context-config.yml diff --git a/AGENTS.md b/AGENTS.md index 720f9ff021..33723e6990 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,22 +177,22 @@ def _register_builtins() -> None: 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. -The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through `.specify/init-options.json`: +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`: -```json -{ - "context_file": "CLAUDE.md", - "context_markers": { - "start": "", - "end": "" - } -} +```yaml +# Path to the coding agent context file managed by this extension +context_file: CLAUDE.md + +# Delimiters for the managed Spec Kit section +context_markers: + start: "" + end: "" ``` -- `context_file` is written automatically from the integration's class attribute. -- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `init-options.json` 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. +- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run. +- `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. -Users can opt out entirely with `specify extension disable agent-context`; while disabled, `setup()` and `teardown()` skip context-file creation, updates, and removal. +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()`). 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. diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md index 48b391939d..bce59590f8 100644 --- a/extensions/agent-context/README.md +++ b/extensions/agent-context/README.md @@ -9,7 +9,7 @@ It owns the lifecycle of the managed section delimited by the configurable start 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: - **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file. -- **Customize the markers** by editing `.specify/init-options.json` — both the Python layer and the bundled scripts honor the same `context_markers` value. +- **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. - **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). ## Commands @@ -20,16 +20,17 @@ Not every Spec Kit user wants Spec Kit to write into the coding agent's context ## Configuration -All configuration flows through `.specify/init-options.json`: +All configuration flows through the extension's own config file at +`.specify/extensions/agent-context/agent-context-config.yml`: -```json -{ - "context_file": "CLAUDE.md", - "context_markers": { - "start": "", - "end": "" - } -} +```yaml +# Path to the coding agent context file managed by this extension +context_file: CLAUDE.md + +# Delimiters for the managed Spec Kit section +context_markers: + start: "" + end: "" ``` - `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. @@ -41,4 +42,4 @@ All configuration flows through `.specify/init-options.json`: specify extension disable agent-context ``` -When disabled, `IntegrationBase.setup()` and `IntegrationBase.teardown()` skip context file creation, updates, and removal. +When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). diff --git a/extensions/agent-context/agent-context-config.yml b/extensions/agent-context/agent-context-config.yml new file mode 100644 index 0000000000..e0f31f731d --- /dev/null +++ b/extensions/agent-context/agent-context-config.yml @@ -0,0 +1,15 @@ +# Coding Agent Context Extension Configuration +# These values are populated automatically by `specify init` and +# `specify integration use` / `specify integration install`. + +# Path (relative to the project root) to the coding agent context file +# managed by this extension (e.g. CLAUDE.md, AGENTS.md, +# .github/copilot-instructions.md). Set automatically from the active +# integration; edit to override. +context_file: "" + +# Delimiters for the managed Spec Kit section. +# Edit these to use custom markers. +context_markers: + start: "" + end: "" diff --git a/extensions/agent-context/commands/speckit.agent-context.update.md b/extensions/agent-context/commands/speckit.agent-context.update.md index 19b6f4165e..02f1706926 100644 --- a/extensions/agent-context/commands/speckit.agent-context.update.md +++ b/extensions/agent-context/commands/speckit.agent-context.update.md @@ -8,7 +8,8 @@ Refresh the managed Spec Kit section inside the active coding agent's context/in ## Behavior -The script reads the project's `.specify/init-options.json` to discover: +The script reads the agent-context extension config at +`.specify/extensions/agent-context/agent-context-config.yml` to discover: - `context_file` — the path of the coding agent context file to manage. - `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `` and `` when the field is missing. diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 928345e72d..09105f1cbb 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -4,9 +4,9 @@ # Refresh the managed Spec Kit section in the coding agent's context file # (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). # -# Reads `context_file` and `context_markers.{start,end}` from -# `.specify/init-options.json`. Falls back to the default markers when -# `context_markers` is absent. +# Reads `context_file` and `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] # @@ -17,24 +17,43 @@ set -euo pipefail PROJECT_ROOT="$(pwd)" -INIT_OPTIONS="$PROJECT_ROOT/.specify/init-options.json" +EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml" DEFAULT_START="" DEFAULT_END="" -if [[ ! -f "$INIT_OPTIONS" ]]; then - echo "agent-context: $INIT_OPTIONS not found; nothing to do." >&2 +if [[ ! -f "$EXT_CONFIG" ]]; then + echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2 exit 0 fi -# Parse init-options.json once; emit three newline-separated fields: +# Locate a suitable Python interpreter (python3, then python). +_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 + +if [[ -z "$_python" ]]; then + echo "agent-context: Python 3 not found on PATH; cannot parse extension config." >&2 + exit 1 +fi + +# Parse extension config once; emit three newline-separated fields: # context_file, context_markers.start, context_markers.end -_raw_opts="$(python3 - "$INIT_OPTIONS" <<'PY' -import json, sys +_raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY' +import sys +try: + import yaml +except ImportError: + yaml = None try: with open(sys.argv[1], "r", encoding="utf-8") as fh: - data = json.load(fh) + data = yaml.safe_load(fh) if yaml else {} except Exception: data = {} +if not isinstance(data, dict): + data = {} def get_str(obj, *keys): node = obj for k in keys: @@ -56,7 +75,7 @@ PY } <<< "$_raw_opts" if [[ -z "$CONTEXT_FILE" ]]; then - echo "agent-context: context_file not set in init-options.json; nothing to do." >&2 + echo "agent-context: context_file not set in extension config; nothing to do." >&2 exit 0 fi @@ -65,9 +84,22 @@ fi PLAN_PATH="${1:-}" if [[ -z "$PLAN_PATH" ]]; then - if compgen -G "$PROJECT_ROOT/specs/*/plan.md" > /dev/null; then - # Pick the most recently modified plan.md (one level deep: specs//plan.md) - _plan_abs="$(ls -1t "$PROJECT_ROOT"/specs/*/plan.md 2>/dev/null | head -1)" + # Pick the most recently modified plan.md one level deep (specs//plan.md). + # Use find + sort by modification time to avoid ls/head fragility with + # spaces in paths or SIGPIPE from pipefail. + _plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY' +import sys, os +from pathlib import Path +specs = Path(sys.argv[1]) / "specs" +plans = sorted( + specs.glob("*/plan.md"), + key=lambda p: p.stat().st_mtime, + reverse=True, +) +print(plans[0] if plans else "") +PY +)" + if [[ -n "$_plan_abs" ]]; then PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}" fi fi @@ -88,7 +120,7 @@ trap 'rm -f "$TMP_SECTION"' EXIT echo "$MARKER_END" } > "$TMP_SECTION" -python3 - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' +"$_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: diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index eb0e009994..d939a41f20 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -4,9 +4,9 @@ # Refresh the managed Spec Kit section in the coding agent's context file # (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md). # -# Reads `context_file` and `context_markers.{start,end}` from -# `.specify/init-options.json`. Falls back to the default markers when -# `context_markers` is absent. +# Reads `context_file` and `context_markers.{start,end}` from the +# agent-context extension config: +# .specify/extensions/agent-context/agent-context-config.yml # # Usage: update-agent-context.ps1 [plan_path] @@ -20,43 +20,68 @@ $ErrorActionPreference = 'Stop' $DefaultStart = '' $DefaultEnd = '' $ProjectRoot = (Get-Location).Path -$InitOptions = Join-Path $ProjectRoot '.specify/init-options.json' +$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml' -if (-not (Test-Path -LiteralPath $InitOptions)) { - Write-Host "agent-context: $InitOptions not found; nothing to do." +if (-not (Test-Path -LiteralPath $ExtConfig)) { + Write-Host "agent-context: $ExtConfig not found; nothing to do." exit 0 } try { - $Options = Get-Content -LiteralPath $InitOptions -Raw | ConvertFrom-Json + $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop } catch { - Write-Host "agent-context: failed to parse init-options.json; nothing to do." - exit 0 + # ConvertFrom-Yaml may not be available on all systems; fall back to a + # simple line-by-line YAML parser for the keys we need. + $Options = @{} + $inMarkers = $false + foreach ($line in Get-Content -LiteralPath $ExtConfig) { + if ($line -match '^context_file:\s*(.*)$') { + $Options['context_file'] = $Matches[1].Trim().Trim('"').Trim("'") + } elseif ($line -match '^context_markers:') { + $inMarkers = $true + } elseif ($inMarkers -and $line -match '^\s+start:\s*(.+)$') { + if (-not $Options.ContainsKey('context_markers')) { $Options['context_markers'] = @{} } + $Options['context_markers']['start'] = $Matches[1].Trim().Trim('"').Trim("'") + } elseif ($inMarkers -and $line -match '^\s+end:\s*(.+)$') { + if (-not $Options.ContainsKey('context_markers')) { $Options['context_markers'] = @{} } + $Options['context_markers']['end'] = $Matches[1].Trim().Trim('"').Trim("'") + } elseif ($inMarkers -and $line -match '^[a-z]') { + $inMarkers = $false + } + } } -$ContextFile = $Options.context_file +$ContextFile = $Options['context_file'] if (-not $ContextFile) { - Write-Host 'agent-context: context_file not set in init-options.json; nothing to do.' + Write-Host 'agent-context: context_file not set in extension config; nothing to do.' exit 0 } $MarkerStart = $DefaultStart $MarkerEnd = $DefaultEnd -if ($Options.context_markers) { - if ($Options.context_markers.start -is [string] -and $Options.context_markers.start) { - $MarkerStart = $Options.context_markers.start +$cm = $Options['context_markers'] +if ($cm) { + if ($cm['start'] -is [string] -and $cm['start']) { + $MarkerStart = $cm['start'] } - if ($Options.context_markers.end -is [string] -and $Options.context_markers.end) { - $MarkerEnd = $Options.context_markers.end + if ($cm['end'] -is [string] -and $cm['end']) { + $MarkerEnd = $cm['end'] } } if (-not $PlanPath) { - $candidate = Get-ChildItem -Path (Join-Path $ProjectRoot 'specs') -Filter 'plan.md' -Recurse -Depth 1 -ErrorAction SilentlyContinue | - Sort-Object LastWriteTime -Descending | - Select-Object -First 1 - if ($candidate) { - $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + # Discover plan.md one level deep (specs//plan.md), matching the + # bash glob specs/*/plan.md. Wrap in try/catch so access errors under + # $ErrorActionPreference = 'Stop' don't abort the script. + try { + $candidate = Get-ChildItem -Path (Join-Path $ProjectRoot 'specs') -Filter 'plan.md' -Recurse -Depth 1 -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime -Descending | + Select-Object -First 1 + if ($candidate) { + $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + } + } catch { + # Non-fatal: continue without a plan path. } } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b94db42bbb..dfdff87ee6 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -412,6 +412,72 @@ def load_init_options(project_path: Path) -> dict[str, Any]: return {} +# --------------------------------------------------------------------------- +# Agent-context extension config helpers +# --------------------------------------------------------------------------- + +_AGENT_CTX_EXT_CONFIG = ( + Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml" +) + + +def _load_agent_context_config(project_root: Path) -> dict[str, Any]: + """Load the agent-context extension config, returning defaults on failure.""" + from .integrations.base import IntegrationBase + + defaults: dict[str, Any] = { + "context_file": "", + "context_markers": { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + }, + } + path = project_root / _AGENT_CTX_EXT_CONFIG + if not path.exists(): + return defaults + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except (OSError, yaml.YAMLError): + return defaults + if not isinstance(raw, dict): + return defaults + return raw + + +def _save_agent_context_config( + project_root: Path, config: dict[str, Any] +) -> None: + """Persist *config* to the agent-context extension config file.""" + path = project_root / _AGENT_CTX_EXT_CONFIG + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(yaml.safe_dump(config, default_flow_style=False), encoding="utf-8") + + +def _update_agent_context_config_file( + project_root: Path, + context_file: str | None, + *, + preserve_markers: bool = True, +) -> None: + """Update the agent-context extension config with *context_file*. + + When *preserve_markers* is True (default), any existing + ``context_markers`` values are kept unchanged so user customisations + survive integration changes and reinit. When False, the default + markers are written unconditionally. + """ + from .integrations.base import IntegrationBase + + cfg = _load_agent_context_config(project_root) + cfg["context_file"] = context_file or "" + if not preserve_markers or not isinstance(cfg.get("context_markers"), dict): + cfg["context_markers"] = { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + } + _save_agent_context_config(project_root, cfg) + + def _get_skills_dir(project_path: Path, selected_ai: str) -> Path: """Resolve the agent-specific skills directory. @@ -906,26 +972,17 @@ def init( # Must be saved BEFORE preset install so _get_skills_dir() works. # Also saved BEFORE agent-context install so init-options.json is # available when the extension's hooks run. - from .integrations.base import IntegrationBase + from .integrations.base import SkillsIntegration as _SkillsPersist init_opts = { "ai": selected_ai, "integration": resolved_integration.key, "branch_numbering": branch_numbering or "sequential", - "context_file": resolved_integration.context_file, "here": here, "script": selected_script, "speckit_version": get_speckit_version(), } - if resolved_integration.context_file: - init_opts["context_markers"] = { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, - } - # Ensure ai_skills is set for SkillsIntegration so downstream - # tools (extensions, presets) emit SKILL.md overrides correctly. - # Also set for integrations running in skills mode (e.g. Copilot - # with --skills). - from .integrations.base import SkillsIntegration as _SkillsPersist + # context_file and context_markers live in the agent-context + # extension config, not in init-options.json. if isinstance(resolved_integration, _SkillsPersist) or getattr(resolved_integration, "_skills_mode", False): init_opts["ai_skills"] = True save_init_options(project_path, init_opts) @@ -933,19 +990,21 @@ def init( # Install bundled agent-context extension (opt-out via # `specify extension disable agent-context`). Owns the managed # section in coding agent context files (CLAUDE.md, etc.). - # Installed AFTER init-options are saved so hooks can read - # context_file / context_markers from init-options.json. + # Installed AFTER init-options are saved so hooks can read from + # the project. After install, the extension config is updated + # with the active integration's context_file. tracker.start("agent-context") + _ac_bundled: Path | None = None try: from .extensions import ExtensionManager as _AgentCtxMgr - bundled_ac = _locate_bundled_extension("agent-context") - if bundled_ac: + _ac_bundled = _locate_bundled_extension("agent-context") + if _ac_bundled: ac_manager = _AgentCtxMgr(project_path) if ac_manager.registry.is_installed("agent-context"): tracker.complete("agent-context", "already installed") else: ac_manager.install_from_directory( - bundled_ac, get_speckit_version() + _ac_bundled, get_speckit_version() ) tracker.complete("agent-context", "installed") else: @@ -956,6 +1015,20 @@ def init( "agent-context", f"install failed: {sanitized_ac[:120]}", ) + finally: + # Always write context_file into the extension config so the + # shell scripts work even if the extension install itself + # failed (e.g. dev pre-release version mismatch in CI). + # User-customised markers are preserved. + if _ac_bundled is not None: + try: + _update_agent_context_config_file( + project_path, + resolved_integration.context_file, + preserve_markers=True, + ) + except Exception: + pass # Fix permissions after all installs (scripts + extensions) ensure_executable_scripts(project_path, tracker=tracker) @@ -1470,15 +1543,26 @@ def _write_integration_json( def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: - """Clear active integration keys from init-options.json when they match.""" + """Clear active integration keys from init-options.json when they match. + + Also clears ``context_file`` from the agent-context extension config so + no stale path is left behind when the integration is uninstalled. + """ opts = load_init_options(project_root) if opts.get("integration") == integration_key or opts.get("ai") == integration_key: opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) + # Remove legacy fields that older versions may have written. opts.pop("context_file", None) opts.pop("context_markers", None) save_init_options(project_root, opts) + # Clear context_file in the extension config too. + ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG + if ext_cfg_path.exists(): + _update_agent_context_config_file( + project_root, "", preserve_markers=True + ) def _remove_integration_json(project_root: Path) -> None: @@ -1895,29 +1979,22 @@ def _update_init_options_for_integration( integration: Any, script_type: str | None = None, ) -> None: - """Update ``init-options.json`` to reflect *integration* as the active one.""" - from .integrations.base import IntegrationBase, SkillsIntegration + """Update init-options.json and the agent-context extension config to + reflect *integration* as the active one. + + ``context_file`` and ``context_markers`` are stored in the agent-context + extension config (``.specify/extensions/agent-context/agent-context-config.yml``), + not in ``init-options.json``. Existing user-customised markers are + preserved; the start/end values are only seeded from defaults when they + are absent or invalid. + """ + from .integrations.base import SkillsIntegration opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key - opts["context_file"] = integration.context_file - # Preserve any user-customized markers; only seed defaults if absent or invalid. - # Only write context_markers when this integration actually has a context file. - if integration.context_file: - existing_markers = opts.get("context_markers") - if not isinstance(existing_markers, dict): - opts["context_markers"] = { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, - } - else: - if not isinstance(existing_markers.get("start"), str) or not existing_markers.get("start"): - existing_markers["start"] = IntegrationBase.CONTEXT_MARKER_START - if not isinstance(existing_markers.get("end"), str) or not existing_markers.get("end"): - existing_markers["end"] = IntegrationBase.CONTEXT_MARKER_END - opts["context_markers"] = existing_markers - else: - opts.pop("context_markers", None) + # Remove legacy fields if they were written by an older version. + opts.pop("context_file", None) + opts.pop("context_markers", None) if script_type: opts["script"] = script_type if isinstance(integration, SkillsIntegration) or getattr(integration, "_skills_mode", False): @@ -1926,6 +2003,24 @@ def _update_init_options_for_integration( opts.pop("ai_skills", None) save_init_options(project_root, opts) + # Update the agent-context extension config with the new context_file, + # preserving any user-customised markers. + ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG + if ext_cfg_path.exists(): + _update_agent_context_config_file( + project_root, + integration.context_file, + preserve_markers=True, + ) + elif integration.context_file: + # Extension config doesn't exist yet (extension not installed). + # Write defaults so scripts have something to read. + _update_agent_context_config_file( + project_root, + integration.context_file, + preserve_markers=False, + ) + @integration_app.command("use") def integration_use( diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index a1be34dcc2..0935662404 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -374,8 +374,17 @@ def resolve_skill_placeholders( body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) - # Resolve __CONTEXT_FILE__ from init-options - context_file = init_opts.get("context_file") or "" + # Resolve __CONTEXT_FILE__ from the agent-context extension config. + # Fall back to init-options.json for projects that haven't migrated. + context_file = "" + try: + from . import _load_agent_context_config + ac_cfg = _load_agent_context_config(project_root) + context_file = ac_cfg.get("context_file") or "" + except Exception: + pass + if not context_file: + context_file = init_opts.get("context_file") or "" body = body.replace("__CONTEXT_FILE__", context_file) return CommandRegistrar.rewrite_project_relative_paths(body) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index e3a644ee5c..e7013041b2 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -13,6 +13,7 @@ from __future__ import annotations +import json import re import shutil from abc import ABC @@ -505,9 +506,7 @@ def _agent_context_extension_enabled(project_root: Path) -> bool: if not registry_path.exists(): return True try: - import json as _json - - data = _json.loads(registry_path.read_text(encoding="utf-8")) + data = json.loads(registry_path.read_text(encoding="utf-8")) except (OSError, ValueError, UnicodeError): return True if not isinstance(data, dict): @@ -523,30 +522,50 @@ def _agent_context_extension_enabled(project_root: Path) -> bool: def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]: """Return the (start, end) context markers to use for *project_root*. - Reads ``context_markers.start`` / ``context_markers.end`` from - ``.specify/init-options.json`` when present. Falls back to the - class-level constants ``CONTEXT_MARKER_START`` / - ``CONTEXT_MARKER_END`` when the file is missing, the section is - absent, or the values are not non-empty strings. + Reads ``context_markers.start`` / ``context_markers.end`` from the + agent-context extension config + (``.specify/extensions/agent-context/agent-context-config.yml``) + when present. Falls back to the class-level constants + ``CONTEXT_MARKER_START`` / ``CONTEXT_MARKER_END`` when the file is + missing, the section is absent, or the values are not non-empty + strings. """ + from .._console import console # local import to avoid cycles + start = self.CONTEXT_MARKER_START end = self.CONTEXT_MARKER_END + config_path = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" + ) try: - from .. import load_init_options # local import to avoid cycles - except ImportError: - return start, end - try: - opts = load_init_options(project_root) - except (OSError, ValueError): + raw = config_path.read_text(encoding="utf-8") + cfg = yaml.safe_load(raw) + except (OSError, ValueError, yaml.YAMLError): return start, end - markers = opts.get("context_markers") if isinstance(opts, dict) else None + markers = cfg.get("context_markers") if isinstance(cfg, dict) else None if isinstance(markers, dict): cm_start = markers.get("start") cm_end = markers.get("end") - if isinstance(cm_start, str) and cm_start: - start = cm_start - if isinstance(cm_end, str) and cm_end: - end = cm_end + s_valid = isinstance(cm_start, str) and cm_start + e_valid = isinstance(cm_end, str) and cm_end + if not s_valid and cm_start is not None: + console.print( + f"[yellow]agent-context: ignoring invalid context_markers.start " + f"({cm_start!r}), using default[/yellow]" + ) + if not e_valid and cm_end is not None: + console.print( + f"[yellow]agent-context: ignoring invalid context_markers.end " + f"({cm_end!r}), using default[/yellow]" + ) + if s_valid: + start = cm_start # type: ignore[assignment] + if e_valid: + end = cm_end # type: ignore[assignment] return start, end def upsert_context_section( diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 5373bc9f71..347d0fe9ef 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -5,7 +5,11 @@ import json from pathlib import Path +import yaml + from specify_cli import ( + _load_agent_context_config, + _save_agent_context_config, load_init_options, save_init_options, ) @@ -16,6 +20,23 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context" +_EXT_CONFIG_REL = Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml" + + +def _write_ext_config(project_root: Path, **overrides: object) -> None: + """Write a minimal agent-context extension config.""" + cfg: dict = { + "context_file": overrides.get("context_file", ""), + "context_markers": overrides.get( + "context_markers", + { + "start": IntegrationBase.CONTEXT_MARKER_START, + "end": IntegrationBase.CONTEXT_MARKER_END, + }, + ), + } + _save_agent_context_config(project_root, cfg) + # ── Bundled extension layout ───────────────────────────────────────────────── @@ -27,8 +48,6 @@ def test_extension_yml_exists(self): assert (EXT_DIR / "extension.yml").is_file() def test_extension_yml_has_required_fields(self): - import yaml - manifest = yaml.safe_load((EXT_DIR / "extension.yml").read_text()) assert manifest["extension"]["id"] == "agent-context" assert manifest["extension"]["name"] == "Coding Agent Context" @@ -43,21 +62,28 @@ def test_readme_exists(self): text = readme.read_text(encoding="utf-8") assert "Coding Agent Context Extension" in text + def test_config_template_exists(self): + cfg = EXT_DIR / "agent-context-config.yml" + assert cfg.is_file() + parsed = yaml.safe_load(cfg.read_text(encoding="utf-8")) + assert "context_file" in parsed + assert "context_markers" in parsed + def test_command_file_exists(self): cmd = EXT_DIR / "commands" / "speckit.agent-context.update.md" assert cmd.is_file() - assert "init-options.json" in cmd.read_text(encoding="utf-8") + assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8") def test_bundled_scripts_exist(self): assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file() assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file() - def test_bash_script_reads_init_options(self): + def test_bash_script_reads_extension_config(self): text = (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").read_text( encoding="utf-8" ) - # The script must consult init-options.json — no agent-specific logic - assert "init-options.json" in text + # The script must consult the extension config, not init-options.json + assert "agent-context-config.yml" in text assert "context_file" in text assert "context_markers" in text @@ -76,7 +102,7 @@ def test_catalog_lists_agent_context_as_bundled(self): assert entry["author"] == "spec-kit-core" -# ── Marker resolution from init-options.json ───────────────────────────────── +# ── Marker resolution from extension config ────────────────────────────────── class _CtxIntegration(ClaudeIntegration): @@ -84,24 +110,21 @@ class _CtxIntegration(ClaudeIntegration): class TestContextMarkerResolution: - def _seed_options(self, project_root: Path, **overrides): - save_init_options(project_root, overrides) - - def test_defaults_when_init_options_missing(self, tmp_path): + def test_defaults_when_ext_config_missing(self, tmp_path): i = _CtxIntegration() start, end = i._resolve_context_markers(tmp_path) assert start == IntegrationBase.CONTEXT_MARKER_START assert end == IntegrationBase.CONTEXT_MARKER_END - def test_defaults_when_field_missing(self, tmp_path): - self._seed_options(tmp_path, context_file="CLAUDE.md") + def test_defaults_when_markers_field_missing(self, tmp_path): + _write_ext_config(tmp_path, context_file="CLAUDE.md") i = _CtxIntegration() start, end = i._resolve_context_markers(tmp_path) assert start == IntegrationBase.CONTEXT_MARKER_START assert end == IntegrationBase.CONTEXT_MARKER_END def test_custom_markers_respected(self, tmp_path): - self._seed_options( + _write_ext_config( tmp_path, context_markers={"start": "", "end": ""}, ) @@ -111,14 +134,14 @@ def test_custom_markers_respected(self, tmp_path): assert end == "" def test_partial_override_falls_back_for_missing_side(self, tmp_path): - self._seed_options(tmp_path, context_markers={"start": ""}) + _write_ext_config(tmp_path, context_markers={"start": ""}) i = _CtxIntegration() start, end = i._resolve_context_markers(tmp_path) assert start == "" assert end == IntegrationBase.CONTEXT_MARKER_END def test_invalid_markers_fall_back(self, tmp_path): - self._seed_options(tmp_path, context_markers={"start": 42, "end": ""}) + _write_ext_config(tmp_path, context_markers={"start": 42, "end": ""}) i = _CtxIntegration() start, end = i._resolve_context_markers(tmp_path) assert start == IntegrationBase.CONTEXT_MARKER_START @@ -130,10 +153,11 @@ def test_invalid_markers_fall_back(self, tmp_path): class TestUpsertWithCustomMarkers: def _setup(self, tmp_path: Path, markers: dict | None = None) -> _CtxIntegration: - opts: dict = {"context_file": "CLAUDE.md"} - if markers is not None: - opts["context_markers"] = markers - save_init_options(tmp_path, opts) + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + **({"context_markers": markers} if markers is not None else {}), + ) return _CtxIntegration() def test_upsert_uses_default_markers(self, tmp_path): @@ -191,7 +215,7 @@ def test_remove_uses_custom_markers(self, tmp_path): assert "epilogue" in remaining def test_remove_with_default_markers_unchanged_when_custom_in_file(self, tmp_path): - # init-options.json absent → default markers used. File contains only + # Extension config absent → default markers used. File contains only # custom markers — nothing should be removed. i = _CtxIntegration() ctx = tmp_path / "CLAUDE.md" @@ -256,51 +280,68 @@ def test_remove_skipped_when_disabled(self, tmp_path): assert ctx.read_text(encoding="utf-8") == original -# ── init-options writers seed default markers ──────────────────────────────── +# ── Extension config writers ───────────────────────────────────────────────── -class TestInitOptionsWriters: - def test_clear_init_options_pops_context_markers(self, tmp_path): +class TestExtensionConfigWriters: + def test_clear_init_options_clears_ext_config_context_file(self, tmp_path): from specify_cli import _clear_init_options_for_integration save_init_options( tmp_path, - { - "integration": "claude", - "ai": "claude", - "context_file": "CLAUDE.md", - "context_markers": { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, - }, - }, + {"integration": "claude", "ai": "claude"}, ) + _write_ext_config(tmp_path, context_file="CLAUDE.md") _clear_init_options_for_integration(tmp_path, "claude") - opts = load_init_options(tmp_path) - assert "context_file" not in opts - assert "context_markers" not in opts + cfg = _load_agent_context_config(tmp_path) + assert cfg.get("context_file") == "" - def test_update_init_options_seeds_default_markers(self, tmp_path): + def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): from specify_cli import _update_init_options_for_integration + # Pre-create the extension config so _update_init_options_for_integration + # updates it (rather than skipping it when ext config doesn't exist yet). + _write_ext_config(tmp_path, context_file="") i = _CtxIntegration() _update_init_options_for_integration(tmp_path, i, script_type="sh") + # init-options.json must NOT have context_file or context_markers opts = load_init_options(tmp_path) - assert opts["integration"] == i.key - assert opts["context_file"] == i.context_file - assert opts["context_markers"] == { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, - } + assert "context_file" not in opts + assert "context_markers" not in opts + # Extension config must have them + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_file"] == i.context_file + assert "context_markers" in cfg def test_update_init_options_preserves_custom_markers(self, tmp_path): from specify_cli import _update_init_options_for_integration - save_init_options( + _write_ext_config( tmp_path, - {"context_markers": {"start": "", "end": ""}}, + context_file="", + context_markers={"start": "", "end": ""}, ) i = _CtxIntegration() _update_init_options_for_integration(tmp_path, i) - opts = load_init_options(tmp_path) - assert opts["context_markers"] == {"start": "", "end": ""} + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_markers"] == {"start": "", "end": ""} + + def test_reinit_preserves_custom_markers(self, tmp_path): + """specify init (reinit) must not overwrite user-customised markers.""" + from specify_cli import _update_agent_context_config_file + + # Simulate existing project with custom markers + _write_ext_config( + tmp_path, + context_file="CLAUDE.md", + context_markers={"start": "", "end": ""}, + ) + # Re-running init updates context_file but must preserve markers + _update_agent_context_config_file( + tmp_path, "CLAUDE.md", preserve_markers=True + ) + cfg = _load_agent_context_config(tmp_path) + assert cfg["context_markers"] == { + "start": "", + "end": "", + } diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index de09205310..12fa6c7351 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -67,7 +67,14 @@ def test_integration_copilot_creates_files(self, tmp_path): opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) assert opts["integration"] == "copilot" - assert opts["context_file"] == ".github/copilot-instructions.md" + # context_file lives in the agent-context extension config, not init-options.json + assert "context_file" not in opts + + import yaml as _yaml + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + assert ext_cfg_path.exists(), "agent-context extension config must be created on init" + ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) + assert ext_cfg["context_file"] == ".github/copilot-instructions.md" assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index 0b74a6f1a9..1ba407a820 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -226,8 +226,8 @@ def test_integration_flag_creates_files(self, tmp_path): assert len(commands) > 0, f"No command files in {cmd_dir}" def test_init_options_includes_context_file(self, tmp_path): - """init-options.json must include context_file for the active integration.""" - import json + """agent-context extension config must include context_file for the active integration.""" + import yaml from typer.testing import CliRunner from specify_cli import app @@ -243,10 +243,11 @@ def test_init_options_includes_context_file(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - opts = json.loads((project / ".specify" / "init-options.json").read_text()) + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} i = get_integration(self.KEY) - assert opts.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + assert ext_cfg.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" ) # -- Complete file inventory ------------------------------------------ @@ -291,6 +292,9 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + # Agent context extension config + files.append(".specify/extensions/agent-context/agent-context-config.yml") + # Agent context file (if set) if i.context_file: files.append(i.context_file) diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index 89140de1c3..6fd1a0cf19 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -324,8 +324,8 @@ def test_integration_flag_creates_files(self, tmp_path): assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" def test_init_options_includes_context_file(self, tmp_path): - """init-options.json must include context_file for the active integration.""" - import json + """agent-context extension config must include context_file for the active integration.""" + import yaml from typer.testing import CliRunner from specify_cli import app @@ -341,10 +341,11 @@ def test_init_options_includes_context_file(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - opts = json.loads((project / ".specify" / "init-options.json").read_text()) + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} i = get_integration(self.KEY) - assert opts.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + assert ext_cfg.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" ) # -- IntegrationOption ------------------------------------------------ @@ -410,6 +411,8 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/workflows/speckit/workflow.yml", ".specify/workflows/workflow-registry.json", ] + # Agent context extension config + files.append(".specify/extensions/agent-context/agent-context-config.yml") # Agent context file (if set) if i.context_file: files.append(i.context_file) diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index 56862e534c..1dac629d02 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -457,8 +457,8 @@ def test_integration_flag_creates_files(self, tmp_path): assert len(commands) > 0, f"No command files in {cmd_dir}" def test_init_options_includes_context_file(self, tmp_path): - """init-options.json must include context_file for the active integration.""" - import json + """agent-context extension config must include context_file for the active integration.""" + import yaml from typer.testing import CliRunner from specify_cli import app @@ -474,10 +474,11 @@ def test_init_options_includes_context_file(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - opts = json.loads((project / ".specify" / "init-options.json").read_text()) + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} i = get_integration(self.KEY) - assert opts.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + assert ext_cfg.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" ) # -- Complete file inventory ------------------------------------------ @@ -543,6 +544,9 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + # Agent context extension config + files.append(".specify/extensions/agent-context/agent-context-config.yml") + # Agent context file (if set) if i.context_file: files.append(i.context_file) diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 956c7a796f..d0e808ef46 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -336,8 +336,8 @@ def test_integration_flag_creates_files(self, tmp_path): assert len(commands) > 0, f"No command files in {cmd_dir}" def test_init_options_includes_context_file(self, tmp_path): - """init-options.json must include context_file for the active integration.""" - import json + """agent-context extension config must include context_file for the active integration.""" + import yaml from typer.testing import CliRunner from specify_cli import app @@ -353,10 +353,11 @@ def test_init_options_includes_context_file(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - opts = json.loads((project / ".specify" / "init-options.json").read_text()) + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} i = get_integration(self.KEY) - assert opts.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}" + assert ext_cfg.get("context_file") == i.context_file, ( + f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" ) # -- Complete file inventory ------------------------------------------ @@ -422,6 +423,9 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") + # Agent context extension config + files.append(".specify/extensions/agent-context/agent-context-config.yml") + # Agent context file (if set) if i.context_file: files.append(i.context_file) diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index c6e9259b09..664ce0d59a 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -198,6 +198,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", ".github/copilot-instructions.md", + ".specify/extensions/agent-context/agent-context-config.yml", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -258,6 +259,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", ".github/copilot-instructions.md", + ".specify/extensions/agent-context/agent-context-config.yml", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -606,6 +608,8 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], # Context file ".github/copilot-instructions.md", + # Agent context extension config + ".specify/extensions/agent-context/agent-context-config.yml", # Integration metadata ".specify/init-options.json", ".specify/integration.json", diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index 4f515a01d2..9175ff1303 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -211,8 +211,8 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): assert result.exit_code != 0 def test_init_options_includes_context_file(self, tmp_path): - """init-options.json must include context_file for the generic integration.""" - import json + """agent-context extension config must include context_file for the generic integration.""" + import yaml from typer.testing import CliRunner from specify_cli import app @@ -229,8 +229,9 @@ def test_init_options_includes_context_file(self, tmp_path): finally: os.chdir(old_cwd) assert result.exit_code == 0 - opts = json.loads((project / ".specify" / "init-options.json").read_text()) - assert opts.get("context_file") == "AGENTS.md" + ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" + ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} + assert ext_cfg.get("context_file") == "AGENTS.md" def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration generic --ai-commands-dir ... --script sh.""" @@ -265,6 +266,7 @@ def test_complete_file_inventory_sh(self, tmp_path): ".myagent/commands/speckit.specify.md", ".myagent/commands/speckit.tasks.md", ".myagent/commands/speckit.taskstoissues.md", + ".specify/extensions/agent-context/agent-context-config.yml", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", @@ -321,6 +323,7 @@ def test_complete_file_inventory_ps(self, tmp_path): ".myagent/commands/speckit.specify.md", ".myagent/commands/speckit.tasks.md", ".myagent/commands/speckit.taskstoissues.md", + ".specify/extensions/agent-context/agent-context-config.yml", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", From c7c98123b13267f9b1929ad2f60c4c4757f5200c Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 14 May 2026 07:52:11 -0500 Subject: [PATCH 08/20] 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> --- tests/extensions/test_extension_agent_context.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 347d0fe9ef..0f60f8eb9a 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -20,8 +20,6 @@ PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context" -_EXT_CONFIG_REL = Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml" - def _write_ext_config(project_root: Path, **overrides: object) -> None: """Write a minimal agent-context extension config.""" From 2e822beefdea7dac0898d8bb64887885fd7a935a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 12:53:02 +0000 Subject: [PATCH 09/20] fix: clarify local import comment in agents.py --- src/specify_cli/agents.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 0935662404..cce1fee7d2 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -378,6 +378,8 @@ def resolve_skill_placeholders( # Fall back to init-options.json for projects that haven't migrated. context_file = "" try: + # Local import: _load_agent_context_config lives in __init__.py + # which imports agents.py, so a top-level import would be circular. from . import _load_agent_context_config ac_cfg = _load_agent_context_config(project_root) context_file = ac_cfg.get("context_file") or "" From 84df98271afeacb7469847eebca8713072da1b96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 13:00:03 +0000 Subject: [PATCH 10/20] Fix remaining agent-context review findings --- AGENTS.md | 2 +- extensions/agent-context/README.md | 1 + .../scripts/bash/update-agent-context.sh | 4 +- .../powershell/update-agent-context.ps1 | 67 +++++++++++++------ src/specify_cli/integrations/base.py | 8 ++- 5 files changed, 56 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 33723e6990..308348c6ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -399,7 +399,7 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): ## Common Pitfalls 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. -2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/init-options.json`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally. +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. 3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents. 4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents. 5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added. diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md index bce59590f8..9a3e83c087 100644 --- a/extensions/agent-context/README.md +++ b/extensions/agent-context/README.md @@ -35,6 +35,7 @@ context_markers: - `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. - `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. +- Runtime note: the bundled update scripts require Python 3 for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available). ## Disable diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 09105f1cbb..db04501b1f 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -35,8 +35,8 @@ elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Pyth fi if [[ -z "$_python" ]]; then - echo "agent-context: Python 3 not found on PATH; cannot parse extension config." >&2 - exit 1 + echo "agent-context: Python 3 not found on PATH; skipping update." >&2 + exit 0 fi # Parse extension config once; emit three newline-separated fields: diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index d939a41f20..af9417e56f 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -30,25 +30,49 @@ if (-not (Test-Path -LiteralPath $ExtConfig)) { try { $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop } catch { - # ConvertFrom-Yaml may not be available on all systems; fall back to a - # simple line-by-line YAML parser for the keys we need. - $Options = @{} - $inMarkers = $false - foreach ($line in Get-Content -LiteralPath $ExtConfig) { - if ($line -match '^context_file:\s*(.*)$') { - $Options['context_file'] = $Matches[1].Trim().Trim('"').Trim("'") - } elseif ($line -match '^context_markers:') { - $inMarkers = $true - } elseif ($inMarkers -and $line -match '^\s+start:\s*(.+)$') { - if (-not $Options.ContainsKey('context_markers')) { $Options['context_markers'] = @{} } - $Options['context_markers']['start'] = $Matches[1].Trim().Trim('"').Trim("'") - } elseif ($inMarkers -and $line -match '^\s+end:\s*(.+)$') { - if (-not $Options.ContainsKey('context_markers')) { $Options['context_markers'] = @{} } - $Options['context_markers']['end'] = $Matches[1].Trim().Trim('"').Trim("'") - } elseif ($inMarkers -and $line -match '^[a-z]') { - $inMarkers = $false + # ConvertFrom-Yaml may not be available on all systems. + # Fall back to Python+PyYAML for consistent parsing semantics. + $pythonCmd = $null + foreach ($candidate in @('python3', 'python')) { + if (Get-Command $candidate -ErrorAction SilentlyContinue) { + $pythonCmd = $candidate + break } } + + if ($pythonCmd) { + try { + $jsonOut = & $pythonCmd -c @' +import json +import sys +try: + import yaml +except Exception: + yaml = None + +try: + with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) if yaml else {} +except Exception: + data = {} + +if not isinstance(data, dict): + data = {} + +print(json.dumps(data)) +'@ $ExtConfig + if ($LASTEXITCODE -eq 0 -and $jsonOut) { + $Options = $jsonOut | ConvertFrom-Json -AsHashtable -ErrorAction Stop + } + } catch { + $Options = $null + } + } + + if (-not $Options) { + Write-Warning "agent-context: unable to parse $ExtConfig; skipping update." + exit 0 + } } $ContextFile = $Options['context_file'] @@ -70,11 +94,14 @@ if ($cm) { } if (-not $PlanPath) { - # Discover plan.md one level deep (specs//plan.md), matching the - # bash glob specs/*/plan.md. Wrap in try/catch so access errors under + # Discover plan.md exactly one level deep (specs//plan.md), + # matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under # $ErrorActionPreference = 'Stop' don't abort the script. try { - $candidate = Get-ChildItem -Path (Join-Path $ProjectRoot 'specs') -Filter 'plan.md' -Recurse -Depth 1 -ErrorAction SilentlyContinue | + $specsDir = Join-Path $ProjectRoot 'specs' + $candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } | + Where-Object { $_ } | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($candidate) { diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index e7013041b2..279bd2f65e 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -579,7 +579,8 @@ def upsert_context_section( managed section. If it exists, the content between the configured start/end markers (default ```` / ````) is replaced, or appended when no markers - are found. Markers are read from ``.specify/init-options.json`` + are found. Markers are read from the agent-context extension config + (``.specify/extensions/agent-context/agent-context-config.yml``) when present, falling back to the class-level constants. Returns the path to the context file, or ``None`` when @@ -656,8 +657,9 @@ def remove_context_section(self, project_root: Path) -> bool: """Remove the managed section from the agent context file. Returns ``True`` if the section was found and removed. If the - file becomes empty (or whitespace-only) after removal it is - deleted. Markers are read from ``.specify/init-options.json`` + file becomes empty (or whitespace-only) after removal it is deleted. + Markers are read from the agent-context extension config + (``.specify/extensions/agent-context/agent-context-config.yml``) when present, falling back to the class-level constants. """ if not self.context_file: From 22d6ef5e13e87c2078afaa4f2de5191d1c7a6a29 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 17:35:29 +0000 Subject: [PATCH 11/20] Fix follow-up agent-context review issues --- .../agent-context/agent-context-config.yml | 2 +- .../scripts/bash/update-agent-context.sh | 23 +++++--- .../powershell/update-agent-context.ps1 | 53 ++++++++++++++----- src/specify_cli/__init__.py | 10 ++-- .../test_extension_agent_context.py | 21 ++++++++ 5 files changed, 87 insertions(+), 22 deletions(-) diff --git a/extensions/agent-context/agent-context-config.yml b/extensions/agent-context/agent-context-config.yml index e0f31f731d..8c8d308b27 100644 --- a/extensions/agent-context/agent-context-config.yml +++ b/extensions/agent-context/agent-context-config.yml @@ -5,7 +5,7 @@ # Path (relative to the project root) to the coding agent context file # managed by this extension (e.g. CLAUDE.md, AGENTS.md, # .github/copilot-instructions.md). Set automatically from the active -# integration; edit to override. +# integration and regenerated during `specify init` or integration switches. context_file: "" # Delimiters for the managed Spec Kit section. diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index db04501b1f..a0d2ae5d4e 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -41,17 +41,25 @@ fi # Parse extension config once; emit three newline-separated fields: # context_file, context_markers.start, context_markers.end -_raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY' +if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY' import sys try: import yaml except ImportError: - yaml = None + print( + "agent-context: PyYAML is required to parse extension config; cannot update context.", + file=sys.stderr, + ) + sys.exit(2) try: with open(sys.argv[1], "r", encoding="utf-8") as fh: - data = yaml.safe_load(fh) if yaml else {} -except Exception: - data = {} + 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): @@ -66,7 +74,10 @@ print(get_str(data, "context_file")) print(get_str(data, "context_markers", "start")) print(get_str(data, "context_markers", "end")) PY -)" +)"; then + echo "agent-context: failed to read extension config; skipping update." >&2 + exit 0 +fi { IFS= read -r CONTEXT_FILE diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index af9417e56f..41bd3396e3 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -16,6 +16,25 @@ param( [string]$PlanPath ) +function Get-ConfigValue { + param( + [AllowNull()][object]$Object, + [Parameter(Mandatory = $true)][string]$Key + ) + + if ($null -eq $Object) { + return $null + } + if ($Object -is [System.Collections.IDictionary]) { + return $Object[$Key] + } + $prop = $Object.PSObject.Properties[$Key] + if ($prop) { + return $prop.Value + } + return $null +} + $ErrorActionPreference = 'Stop' $DefaultStart = '' $DefaultEnd = '' @@ -47,14 +66,22 @@ import json import sys try: import yaml -except Exception: - yaml = None +except ImportError: + print( + "agent-context: PyYAML is required to parse extension config; cannot update context.", + file=sys.stderr, + ) + sys.exit(2) try: with open(sys.argv[1], "r", encoding="utf-8") as fh: - data = yaml.safe_load(fh) if yaml else {} -except Exception: - data = {} + 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 = {} @@ -62,7 +89,7 @@ if not isinstance(data, dict): print(json.dumps(data)) '@ $ExtConfig if ($LASTEXITCODE -eq 0 -and $jsonOut) { - $Options = $jsonOut | ConvertFrom-Json -AsHashtable -ErrorAction Stop + $Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop } } catch { $Options = $null @@ -75,7 +102,7 @@ print(json.dumps(data)) } } -$ContextFile = $Options['context_file'] +$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file' if (-not $ContextFile) { Write-Host 'agent-context: context_file not set in extension config; nothing to do.' exit 0 @@ -83,13 +110,15 @@ if (-not $ContextFile) { $MarkerStart = $DefaultStart $MarkerEnd = $DefaultEnd -$cm = $Options['context_markers'] +$cm = Get-ConfigValue -Object $Options -Key 'context_markers' if ($cm) { - if ($cm['start'] -is [string] -and $cm['start']) { - $MarkerStart = $cm['start'] + $cmStart = Get-ConfigValue -Object $cm -Key 'start' + if ($cmStart -is [string] -and $cmStart) { + $MarkerStart = $cmStart } - if ($cm['end'] -is [string] -and $cm['end']) { - $MarkerEnd = $cm['end'] + $cmEnd = Get-ConfigValue -Object $cm -Key 'end' + if ($cmEnd -is [string] -and $cmEnd) { + $MarkerEnd = $cmEnd } } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index dfdff87ee6..7baa6d87e8 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1549,13 +1549,15 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str no stale path is left behind when the integration is uninstalled. """ opts = load_init_options(project_root) + has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts) + # Remove legacy fields that older versions may have written. + opts.pop("context_file", None) + opts.pop("context_markers", None) + if opts.get("integration") == integration_key or opts.get("ai") == integration_key: opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) - # Remove legacy fields that older versions may have written. - opts.pop("context_file", None) - opts.pop("context_markers", None) save_init_options(project_root, opts) # Clear context_file in the extension config too. ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG @@ -1563,6 +1565,8 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str _update_agent_context_config_file( project_root, "", preserve_markers=True ) + elif has_legacy_context_keys: + save_init_options(project_root, opts) def _remove_integration_json(project_root: Path) -> None: diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index 0f60f8eb9a..c7273c28a7 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -294,6 +294,27 @@ def test_clear_init_options_clears_ext_config_context_file(self, tmp_path): cfg = _load_agent_context_config(tmp_path) assert cfg.get("context_file") == "" + def test_clear_init_options_removes_legacy_context_keys_even_when_not_active( + self, tmp_path + ): + from specify_cli import _clear_init_options_for_integration + + save_init_options( + tmp_path, + { + "integration": "copilot", + "ai": "copilot", + "context_file": "CLAUDE.md", + "context_markers": {"start": "", "end": ""}, + }, + ) + _clear_init_options_for_integration(tmp_path, "claude") + opts = load_init_options(tmp_path) + assert opts["integration"] == "copilot" + assert opts["ai"] == "copilot" + assert "context_file" not in opts + assert "context_markers" not in opts + def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): from specify_cli import _update_init_options_for_integration From f0b5b0b798df3b49cfc799720cc4141752f197f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 17:45:50 +0000 Subject: [PATCH 12/20] Address review feedback: narrow except, improve PyYAML messaging, surface config-written note --- extensions/agent-context/README.md | 13 ++++++++++++- .../scripts/bash/update-agent-context.sh | 7 +++++-- src/specify_cli/__init__.py | 12 ++++++++---- src/specify_cli/agents.py | 2 +- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md index 9a3e83c087..dba004eb80 100644 --- a/extensions/agent-context/README.md +++ b/extensions/agent-context/README.md @@ -35,7 +35,18 @@ context_markers: - `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. - `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. -- Runtime note: the bundled update scripts require Python 3 for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available). + +## Requirements + +The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available). + +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: + +```bash +pip install pyyaml +# or target the specific interpreter Spec Kit uses: +/path/to/speckit-python -m pip install pyyaml +``` ## Disable diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index a0d2ae5d4e..04a7b58d28 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -47,7 +47,10 @@ try: import yaml except ImportError: print( - "agent-context: PyYAML is required to parse extension config; cannot update context.", + "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) @@ -75,7 +78,7 @@ print(get_str(data, "context_markers", "start")) print(get_str(data, "context_markers", "end")) PY )"; then - echo "agent-context: failed to read extension config; skipping update." >&2 + echo "agent-context: skipping update (see above for details)." >&2 exit 0 fi diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 7baa6d87e8..38d647fb53 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -995,6 +995,7 @@ def init( # with the active integration's context_file. tracker.start("agent-context") _ac_bundled: Path | None = None + _ac_err_msg: str | None = None try: from .extensions import ExtensionManager as _AgentCtxMgr _ac_bundled = _locate_bundled_extension("agent-context") @@ -1011,10 +1012,7 @@ def init( tracker.skip("agent-context", "bundled extension not found") except Exception as ac_err: sanitized_ac = str(ac_err).replace('\n', ' ').strip() - tracker.error( - "agent-context", - f"install failed: {sanitized_ac[:120]}", - ) + _ac_err_msg = f"install failed: {sanitized_ac[:120]}" finally: # Always write context_file into the extension config so the # shell scripts work even if the extension install itself @@ -1027,8 +1025,14 @@ def init( resolved_integration.context_file, preserve_markers=True, ) + if _ac_err_msg is not None: + # Config was written despite the failed install; + # the Python context-section plumbing remains active. + _ac_err_msg += "; config written, Python context plumbing active" except Exception: pass + if _ac_err_msg is not None: + tracker.error("agent-context", _ac_err_msg) # Fix permissions after all installs (scripts + extensions) ensure_executable_scripts(project_path, tracker=tracker) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index cce1fee7d2..01cbfd3be4 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -383,7 +383,7 @@ def resolve_skill_placeholders( from . import _load_agent_context_config ac_cfg = _load_agent_context_config(project_root) context_file = ac_cfg.get("context_file") or "" - except Exception: + except (ImportError, OSError, yaml.YAMLError): pass if not context_file: context_file = init_opts.get("context_file") or "" From 019c2630ed6ec1aa50935618748889a841cbeb14 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 17:47:14 +0000 Subject: [PATCH 13/20] Fix double-space in PyYAML install hint message --- extensions/agent-context/scripts/bash/update-agent-context.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 04a7b58d28..e6b1592a7d 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -49,7 +49,7 @@ 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" + " 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, ) From 404ba4d8477e2b3d39cef5f6011a9bc0a453eb4e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 14 May 2026 12:51:18 -0500 Subject: [PATCH 14/20] 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> --- src/specify_cli/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 38d647fb53..f7df90087a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1029,8 +1029,13 @@ def init( # Config was written despite the failed install; # the Python context-section plumbing remains active. _ac_err_msg += "; config written, Python context plumbing active" - except Exception: - pass + except Exception as cfg_err: + sanitized_cfg = str(cfg_err).replace('\n', ' ').strip() + cfg_msg = f"config update failed: {sanitized_cfg[:120]}" + if _ac_err_msg is not None: + _ac_err_msg += f"; {cfg_msg}" + else: + _ac_err_msg = cfg_msg if _ac_err_msg is not None: tracker.error("agent-context", _ac_err_msg) From d4a1754c69dd77bb9b0e2479da3bc62ad0c25151 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 14 May 2026 13:07:47 -0500 Subject: [PATCH 15/20] 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> --- src/specify_cli/agents.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 01cbfd3be4..ab611f6e69 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -384,7 +384,9 @@ def resolve_skill_placeholders( ac_cfg = _load_agent_context_config(project_root) context_file = ac_cfg.get("context_file") or "" except (ImportError, OSError, yaml.YAMLError): - pass + # Best-effort read: ignore extension config load/parse errors and + # fall back to init-options.json context_file below. + context_file = "" if not context_file: context_file = init_opts.get("context_file") or "" body = body.replace("__CONTEXT_FILE__", context_file) From 8073e08149cdf0ab4de37e4151f9159edaab05d7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 18:18:30 +0000 Subject: [PATCH 16/20] Address latest agent-context review feedback --- .../scripts/bash/update-agent-context.sh | 13 +++-- .../powershell/update-agent-context.ps1 | 22 +++++++++ src/specify_cli/__init__.py | 49 ++++++++++++++----- src/specify_cli/agents.py | 2 +- .../test_extension_agent_context.py | 11 +++++ 5 files changed, 78 insertions(+), 19 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index e6b1592a7d..1a887634a2 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -82,11 +82,14 @@ PY exit 0 fi -{ - IFS= read -r CONTEXT_FILE - IFS= read -r MARKER_START - IFS= read -r MARKER_END -} <<< "$_raw_opts" +mapfile -t _opts_lines <<< "$_raw_opts" +if (( ${#_opts_lines[@]} < 3 )); then + echo "agent-context: malformed config parser output; expected 3 lines, got ${#_opts_lines[@]}; skipping update." >&2 + exit 0 +fi +CONTEXT_FILE="${_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 diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 41bd3396e3..68d8bd2c81 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -35,6 +35,23 @@ function Get-ConfigValue { return $null } +function Test-ConfigObject { + param( + [AllowNull()][object]$Object + ) + + if ($null -eq $Object) { + return $false + } + if ($Object -is [System.Collections.IDictionary]) { + return $true + } + if ($Object -is [System.Management.Automation.PSCustomObject]) { + return $true + } + return $false +} + $ErrorActionPreference = 'Stop' $DefaultStart = '' $DefaultEnd = '' @@ -102,6 +119,11 @@ print(json.dumps(data)) } } +if (-not (Test-ConfigObject -Object $Options)) { + Write-Warning "agent-context: $ExtConfig must contain a YAML mapping; skipping update." + exit 0 +} + $ContextFile = Get-ConfigValue -Object $Options -Key 'context_file' if (-not $ContextFile) { Write-Host 'agent-context: context_file not set in extension config; nothing to do.' diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index f7df90087a..d533b66267 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -990,26 +990,35 @@ def init( # Install bundled agent-context extension (opt-out via # `specify extension disable agent-context`). Owns the managed # section in coding agent context files (CLAUDE.md, etc.). - # Installed AFTER init-options are saved so hooks can read from - # the project. After install, the extension config is updated - # with the active integration's context_file. + # Installed after init-options are saved so project metadata + # reflects the selected integration before extension setup. + # The extension config is then updated (in finally) with the + # active integration's context_file. tracker.start("agent-context") _ac_bundled: Path | None = None _ac_err_msg: str | None = None + _ac_terminal: str | None = None + _ac_terminal_detail = "" try: from .extensions import ExtensionManager as _AgentCtxMgr _ac_bundled = _locate_bundled_extension("agent-context") if _ac_bundled: ac_manager = _AgentCtxMgr(project_path) if ac_manager.registry.is_installed("agent-context"): - tracker.complete("agent-context", "already installed") + _ac_terminal = "complete" + _ac_terminal_detail = "already installed" + tracker.complete("agent-context", _ac_terminal_detail) else: ac_manager.install_from_directory( _ac_bundled, get_speckit_version() ) - tracker.complete("agent-context", "installed") + _ac_terminal = "complete" + _ac_terminal_detail = "installed" + tracker.complete("agent-context", _ac_terminal_detail) else: - tracker.skip("agent-context", "bundled extension not found") + _ac_terminal = "skip" + _ac_terminal_detail = "bundled extension not found" + tracker.skip("agent-context", _ac_terminal_detail) except Exception as ac_err: sanitized_ac = str(ac_err).replace('\n', ' ').strip() _ac_err_msg = f"install failed: {sanitized_ac[:120]}" @@ -1037,7 +1046,22 @@ def init( else: _ac_err_msg = cfg_msg if _ac_err_msg is not None: - tracker.error("agent-context", _ac_err_msg) + if _ac_terminal == "complete": + tracker.complete( + "agent-context", + f"{_ac_terminal_detail}; {_ac_err_msg}" + if _ac_terminal_detail + else _ac_err_msg, + ) + elif _ac_terminal == "skip": + tracker.skip( + "agent-context", + f"{_ac_terminal_detail}; {_ac_err_msg}" + if _ac_terminal_detail + else _ac_err_msg, + ) + else: + tracker.error("agent-context", _ac_err_msg) # Fix permissions after all installs (scripts + extensions) ensure_executable_scripts(project_path, tracker=tracker) @@ -1568,12 +1592,11 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str opts.pop("ai", None) opts.pop("ai_skills", None) save_init_options(project_root, opts) - # Clear context_file in the extension config too. - ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG - if ext_cfg_path.exists(): - _update_agent_context_config_file( - project_root, "", preserve_markers=True - ) + # Clear context_file in the extension config too. If the config file + # does not exist yet, create it so no stale target can persist. + _update_agent_context_config_file( + project_root, "", preserve_markers=True + ) elif has_legacy_context_keys: save_init_options(project_root, opts) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index ab611f6e69..6f85b44173 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -383,7 +383,7 @@ def resolve_skill_placeholders( from . import _load_agent_context_config ac_cfg = _load_agent_context_config(project_root) context_file = ac_cfg.get("context_file") or "" - except (ImportError, OSError, yaml.YAMLError): + except ImportError: # Best-effort read: ignore extension config load/parse errors and # fall back to init-options.json context_file below. context_file = "" diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index c7273c28a7..85981e64da 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -294,6 +294,17 @@ def test_clear_init_options_clears_ext_config_context_file(self, tmp_path): cfg = _load_agent_context_config(tmp_path) assert cfg.get("context_file") == "" + def test_clear_init_options_creates_ext_config_when_missing(self, tmp_path): + from specify_cli import _clear_init_options_for_integration + + save_init_options( + tmp_path, + {"integration": "claude", "ai": "claude"}, + ) + _clear_init_options_for_integration(tmp_path, "claude") + cfg = _load_agent_context_config(tmp_path) + assert cfg.get("context_file") == "" + def test_clear_init_options_removes_legacy_context_keys_even_when_not_active( self, tmp_path ): From 765afb28875d48446d37d3a816bb94d8550d7823 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 18:19:56 +0000 Subject: [PATCH 17/20] Harden bash config parse output handling --- .../agent-context/scripts/bash/update-agent-context.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 1a887634a2..62cc0a7281 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -82,9 +82,12 @@ PY exit 0 fi -mapfile -t _opts_lines <<< "$_raw_opts" +_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, got ${#_opts_lines[@]}; skipping update." >&2 + echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2 exit 0 fi CONTEXT_FILE="${_opts_lines[0]}" From b848b6216b0f5b0784a6a8398c12f8e00e32577f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 18:20:37 +0000 Subject: [PATCH 18/20] Clarify ImportError-only fallback comment --- src/specify_cli/agents.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 6f85b44173..37b1590e45 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -384,8 +384,8 @@ def resolve_skill_placeholders( ac_cfg = _load_agent_context_config(project_root) context_file = ac_cfg.get("context_file") or "" except ImportError: - # Best-effort read: ignore extension config load/parse errors and - # fall back to init-options.json context_file below. + # Best-effort read: if the helper cannot be imported (e.g. during + # circular import setup), fall back to init-options.json below. context_file = "" if not context_file: context_file = init_opts.get("context_file") or "" From 2849e6dc6f25e668c859eac215e01269e14b5905 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 18:31:12 +0000 Subject: [PATCH 19/20] Apply review feedback: drop dead try/except, guard ext-config creation, explicit ConvertFrom-Yaml check --- .../powershell/update-agent-context.ps1 | 20 ++++++++++++------- src/specify_cli/__init__.py | 13 +++++++----- src/specify_cli/agents.py | 16 +++++---------- 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 68d8bd2c81..32e8315f8c 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -59,15 +59,21 @@ $ProjectRoot = (Get-Location).Path $ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml' if (-not (Test-Path -LiteralPath $ExtConfig)) { - Write-Host "agent-context: $ExtConfig not found; nothing to do." + Write-Warning "agent-context: $ExtConfig not found; nothing to do." exit 0 } -try { - $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop -} catch { - # ConvertFrom-Yaml may not be available on all systems. - # Fall back to Python+PyYAML for consistent parsing semantics. +$Options = $null +if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { + try { + $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop + } catch { + $Options = $null # fall through to Python fallback + } +} + +if ($null -eq $Options) { + # ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML. $pythonCmd = $null foreach ($candidate in @('python3', 'python')) { if (Get-Command $candidate -ErrorAction SilentlyContinue) { @@ -126,7 +132,7 @@ if (-not (Test-ConfigObject -Object $Options)) { $ContextFile = Get-ConfigValue -Object $Options -Key 'context_file' if (-not $ContextFile) { - Write-Host 'agent-context: context_file not set in extension config; nothing to do.' + Write-Warning 'agent-context: context_file not set in extension config; nothing to do.' exit 0 } diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index d533b66267..25092a508a 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -1592,11 +1592,14 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str opts.pop("ai", None) opts.pop("ai_skills", None) save_init_options(project_root, opts) - # Clear context_file in the extension config too. If the config file - # does not exist yet, create it so no stale target can persist. - _update_agent_context_config_file( - project_root, "", preserve_markers=True - ) + # Clear context_file in the extension config if it already exists. + # Avoid creating the config (and parent dirs) in projects where the + # agent-context extension was never installed. + ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG + if ext_cfg_path.exists(): + _update_agent_context_config_file( + project_root, "", preserve_markers=True + ) elif has_legacy_context_keys: save_init_options(project_root, opts) diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index 37b1590e45..9f827640cc 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -376,17 +376,11 @@ def resolve_skill_placeholders( # Resolve __CONTEXT_FILE__ from the agent-context extension config. # Fall back to init-options.json for projects that haven't migrated. - context_file = "" - try: - # Local import: _load_agent_context_config lives in __init__.py - # which imports agents.py, so a top-level import would be circular. - from . import _load_agent_context_config - ac_cfg = _load_agent_context_config(project_root) - context_file = ac_cfg.get("context_file") or "" - except ImportError: - # Best-effort read: if the helper cannot be imported (e.g. during - # circular import setup), fall back to init-options.json below. - context_file = "" + # Local import: _load_agent_context_config lives in __init__.py which + # imports agents.py, so a top-level import would be circular. + from . import _load_agent_context_config + ac_cfg = _load_agent_context_config(project_root) + context_file = ac_cfg.get("context_file") or "" if not context_file: context_file = init_opts.get("context_file") or "" body = body.replace("__CONTEXT_FILE__", context_file) From 7402f3dbeb667e1964b39d5e098543bda00e6572 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 18:32:29 +0000 Subject: [PATCH 20/20] Remove redundant $Options = $null in PS1 catch block --- .../agent-context/scripts/powershell/update-agent-context.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index 32e8315f8c..db8180a5da 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -68,7 +68,7 @@ if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { try { $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop } catch { - $Options = $null # fall through to Python fallback + # fall through to Python fallback } }