diff --git a/AGENTS.md b/AGENTS.md index d711b4214d..308348c6ae 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 the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`: + +```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 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, 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. ### 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/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 new file mode 100644 index 0000000000..dba004eb80 --- /dev/null +++ b/extensions/agent-context/README.md @@ -0,0 +1,57 @@ +# 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/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 + +| 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 the extension's own config file at +`.specify/extensions/agent-context/agent-context-config.yml`: + +```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`. +- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. + +## 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 + +```bash +specify extension disable agent-context +``` + +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..8c8d308b27 --- /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 and regenerated during `specify init` or integration switches. +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 new file mode 100644 index 0000000000..02f1706926 --- /dev/null +++ b/extensions/agent-context/commands/speckit.agent-context.update.md @@ -0,0 +1,26 @@ +--- +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 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. + +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..62cc0a7281 --- /dev/null +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -0,0 +1,182 @@ +#!/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 the +# agent-context extension config: +# .specify/extensions/agent-context/agent-context-config.yml +# +# Usage: update-agent-context.sh [plan_path] +# +# When `plan_path` is omitted, the script 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)" +EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml" +DEFAULT_START="" +DEFAULT_END="" + +if [[ ! -f "$EXT_CONFIG" ]]; then + echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2 + exit 0 +fi + +# 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; skipping update." >&2 + exit 0 +fi + +# Parse extension config once; emit three newline-separated fields: +# context_file, context_markers.start, context_markers.end +if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY' +import sys +try: + import yaml +except ImportError: + print( + "agent-context: PyYAML is required to parse extension config but is not available " + "in the current Python environment.\n" + " To resolve: pip install pyyaml (or install it into the environment used by python3).\n" + " Context file will not be updated until PyYAML is importable.", + file=sys.stderr, + ) + sys.exit(2) +try: + with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) +except Exception as exc: + print( + f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.", + file=sys.stderr, + ) + sys.exit(2) +if not isinstance(data, dict): + data = {} +def get_str(obj, *keys): + node = obj + for k in keys: + if isinstance(node, dict) and k in node: + node = node[k] + else: + return "" + return node if isinstance(node, str) else "" +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: skipping update (see above for details)." >&2 + exit 0 +fi + +_opts_lines=() +while IFS= read -r _line || [[ -n "$_line" ]]; do + _opts_lines+=("$_line") +done < <(printf '%s\n' "$_raw_opts") +if (( ${#_opts_lines[@]} < 3 )); then + echo "agent-context: malformed config parser output; expected 3 lines (context_file, marker_start, marker_end), 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 + exit 0 +fi + +[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START" +[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END" + +PLAN_PATH="${1:-}" +if [[ -z "$PLAN_PATH" ]]; then + # 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 + +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" + +"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' +import sys, os +ctx_path, start, end, section_path = sys.argv[1:5] +with open(section_path, "r", encoding="utf-8") as fh: + section = fh.read().rstrip("\n") + "\n" + +if os.path.exists(ctx_path): + with open(ctx_path, "r", encoding="utf-8-sig") as fh: + content = fh.read() + s = content.find(start) + e = content.find(end, s if s != -1 else 0) + if s != -1 and e != -1 and e > s: + end_of_marker = e + len(end) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = content[:s] + section + content[end_of_marker:] + elif s != -1: + new_content = content[:s] + section + elif e != -1: + end_of_marker = e + len(end) + if end_of_marker < len(content) and content[end_of_marker] == "\r": + end_of_marker += 1 + if end_of_marker < len(content) and content[end_of_marker] == "\n": + end_of_marker += 1 + new_content = section + content[end_of_marker:] + else: + if content and not content.endswith("\n"): + content += "\n" + new_content = (content + "\n" + section) if content else section +else: + new_content = section + +new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") +with open(ctx_path, "wb") as fh: + fh.write(new_content.encode("utf-8")) +PY + +echo "agent-context: updated $CONTEXT_FILE" 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..db8180a5da --- /dev/null +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -0,0 +1,222 @@ +#!/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 the +# agent-context extension config: +# .specify/extensions/agent-context/agent-context-config.yml +# +# Usage: update-agent-context.ps1 [plan_path] + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [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 +} + +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 = '' +$ProjectRoot = (Get-Location).Path +$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml' + +if (-not (Test-Path -LiteralPath $ExtConfig)) { + Write-Warning "agent-context: $ExtConfig not found; nothing to do." + exit 0 +} + +$Options = $null +if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) { + try { + $Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop + } catch { + # 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) { + $pythonCmd = $candidate + break + } + } + + if ($pythonCmd) { + try { + $jsonOut = & $pythonCmd -c @' +import json +import sys +try: + import yaml +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) +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 = {} + +print(json.dumps(data)) +'@ $ExtConfig + if ($LASTEXITCODE -eq 0 -and $jsonOut) { + $Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop + } + } catch { + $Options = $null + } + } + + if (-not $Options) { + Write-Warning "agent-context: unable to parse $ExtConfig; skipping update." + exit 0 + } +} + +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-Warning 'agent-context: context_file not set in extension config; nothing to do.' + exit 0 +} + +$MarkerStart = $DefaultStart +$MarkerEnd = $DefaultEnd +$cm = Get-ConfigValue -Object $Options -Key 'context_markers' +if ($cm) { + $cmStart = Get-ConfigValue -Object $cm -Key 'start' + if ($cmStart -is [string] -and $cmStart) { + $MarkerStart = $cmStart + } + $cmEnd = Get-ConfigValue -Object $cm -Key 'end' + if ($cmEnd -is [string] -and $cmEnd) { + $MarkerEnd = $cmEnd + } +} + +if (-not $PlanPath) { + # 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 { + $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) { + $PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/') + } + } catch { + # Non-fatal: continue without a plan path. + } +} + +$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..25092a508a 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. @@ -747,6 +813,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"), ]: @@ -900,30 +967,105 @@ 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 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(), } - # 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) + # 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 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"): + _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() + ) + _ac_terminal = "complete" + _ac_terminal_detail = "installed" + tracker.complete("agent-context", _ac_terminal_detail) + else: + _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]}" + 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, + ) + 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 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: + 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) + # Install preset if specified if preset: try: @@ -1434,13 +1576,31 @@ 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) + 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) - opts.pop("context_file", None) + save_init_options(project_root, opts) + # 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) @@ -1858,12 +2018,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.""" + """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 + # 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): @@ -1872,6 +2042,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..9f827640cc 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -374,8 +374,15 @@ 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. + # 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) return CommandRegistrar.rewrite_project_relative_paths(body) diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 7ce107caec..279bd2f65e 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 @@ -482,6 +483,91 @@ 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: + 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 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: + raw = config_path.read_text(encoding="utf-8") + cfg = yaml.safe_load(raw) + except (OSError, ValueError, yaml.YAMLError): + return start, end + 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") + 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( self, project_root: Path, @@ -490,9 +576,12 @@ 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 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 ``context_file`` is not set. @@ -500,24 +589,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 +623,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": @@ -563,20 +657,27 @@ 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. + 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: 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 +688,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..85981e64da --- /dev/null +++ b/tests/extensions/test_extension_agent_context.py @@ -0,0 +1,377 @@ +"""Tests for the bundled ``agent-context`` extension and related plumbing.""" + +from __future__ import annotations + +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, +) +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" + + +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 ───────────────────────────────────────────────── + + +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): + 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_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 "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_extension_config(self): + text = (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").read_text( + encoding="utf-8" + ) + # 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 + + +# ── 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 extension config ────────────────────────────────── + + +class _CtxIntegration(ClaudeIntegration): + """Use Claude as a concrete integration with a context_file.""" + + +class TestContextMarkerResolution: + 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_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): + _write_ext_config( + 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): + _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): + _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 + 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: + _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): + 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): + # Extension config 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 + + +# ── Extension config writers ───────────────────────────────────────────────── + + +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"}, + ) + _write_ext_config(tmp_path, context_file="CLAUDE.md") + _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_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 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 + + # 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 "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 + + _write_ext_config( + tmp_path, + context_file="", + context_markers={"start": "", "end": ""}, + ) + i = _CtxIntegration() + _update_init_options_for_integration(tmp_path, i) + 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",