mirror of
https://github.com/github/spec-kit.git
synced 2026-07-09 08:13:19 +08:00
feat: make agent-context extension a full opt-in (#3097)
* docs: add Spec Kit spec for agent-context full opt-in Use Spec Kit's own specify workflow to author the spec that makes the agent-context extension a full opt-in, removing all agent-context configuration/support from the Python codebase and removing the deprecation message. Force-added despite specs/ being gitignored; the generated artifact will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit plan artifacts for agent-context full opt-in Phase 0/1 of the SDD plan workflow: plan.md, research.md, data-model.md, quickstart.md, and contracts/cli-behavior.md. Constitution Check is a documented no-op (repo has no ratified constitution). Force-added despite specs/ being gitignored; generated artifacts will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct Constitution Check against ratified v1.0.0 Earlier draft wrongly treated the gate as a no-op; the fork's main is 16 commits behind upstream/main, which carries .specify/memory/constitution.md. Re-evaluate the feature against Principles I-V (all PASS) and note that Principle I mandates keeping context_file as a declared class attribute, validating the R1 metadata decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: refresh plan artifacts against synced upstream/main After syncing fork main to upstream and rebasing, re-scan the current agent-context surface. Upstream generalized the single context_file into a plural context_files concept with new resolver helpers (_resolve_context_files, _resolve_context_file_values, _format_context_file_values) and upsert/remove now loop over multiple files. Update research.md, data-model.md, contracts, quickstart grep guards, and the plan summary to cover the expanded removal scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit tasks for agent-context full opt-in Phase 2 of SDD: dependency-ordered tasks.md (30 tasks) organized by the three user stories, with mandatory test tasks (Constitution Principle II) and a foundational phase decoupling __CONTEXT_FILE__ resolution from the extension config. Includes the extension self-seeding task (T015) and a static guard test (T002) enforcing zero agent-context references in the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove agent-context lifecycle from the Specify CLI Make the agent-context extension a full opt-in. The CLI no longer installs the extension during init, writes agent-context-config.yml, or creates/updates/removes the managed Spec Kit section in agent context files. Context-section upsert/remove, marker resolution, extension-enabled gating, the config helpers, and the obsolete inline deprecation warning are all removed. Integration context_file stays as inert metadata; __CONTEXT_FILE__ now resolves from registry metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): self-seed context file from the active integration When agent-context-config.yml has no context_file/context_files, the bundled bash and PowerShell update scripts now resolve the context file from the active integration in .specify/init-options.json via the integration registry, so the extension no longer depends on the CLI writing its config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: update suite and docs for agent-context opt-in Update integration/extension tests to expect no agent-context install, config, or context-section writes during init. Add a static guard test (test_agent_context_cli_free.py) asserting the CLI source is free of agent-context lifecycle symbols, plus backward-compatibility tests for legacy projects. Refresh AGENTS.md, the extension README, and add a CHANGELOG entry describing the opt-in behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(agent-context): warn on self-seed failure, correct docs, speed up guard test Address PR review feedback: - Self-seed scripts (bash + PowerShell) now emit an actionable warning when an active integration is configured but specify_cli cannot be imported by the chosen Python (e.g. pipx installs), or when the integration declares no context file, instead of silently falling through to 'nothing to do'. - Correct the extension README disable note: command rendering never reads the extension config; __CONTEXT_FILE__ is always substituted from integration metadata, so a stale context_files value cannot affect rendering. - Cache CLI source reads in the static guard test via a module-scoped fixture so the directory walk happens once instead of once per forbidden symbol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): ship self-owned per-agent context-file defaults The extension now bundles agent-context-defaults.json (key→context_file map) and self-seeds from it, dropping any dependency on the Specify CLI registry. Both the bash and PowerShell update scripts read the bundled JSON map keyed by the active integration from init-options.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove all agent-context state from the Specify CLI Strip every context_file reference from the CLI: the field on all 35 integration classes, the IntegrationBase plumbing (process_template param/step, _context_file_display, docstrings), the __CONTEXT_FILE__ resolution in agents.py, the legacy context_file/context_markers popping in _helpers.py, and the context_file template in integration_scaffold.py. Also drop the Agent context update step and __CONTEXT_FILE__ placeholder from templates/commands/plan.md. The agent-context extension now solely owns all context-file knowledge, including the per-agent default mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: drop context_file coverage and guard against CLI reintroduction Remove CONTEXT_FILE attrs and context_file assertions across the base mixins, all 35 per-integration test files, shared integration tests, and conftest stubs. Rewrite the base-mixin context tests to assert no managed section is written and no __CONTEXT_FILE__ placeholder survives. Extend the CLI-free static guard to forbid context_file, __CONTEXT_FILE__, and _context_file_display in src/specify_cli, and have the extension tests copy the bundled defaults JSON so self-seed runs without the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: reflect full removal of agent-context state from the CLI Update AGENTS.md (integration examples, required-fields table, context behavior section, pitfalls), CHANGELOG, and the SDD spec artifacts (FR-007, SC-002, data-model) to state that the CLI carries no context_file and the extension fully owns the per-agent default mapping via agent-context-defaults.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: align SDD artifacts with full context_file removal Update research.md (R1, R2, R4, summary table), contracts/cli-behavior.md (C3, C5), tasks.md (Phase 2, T026, notes), plan.md (Principle I, source map), and checklists/requirements.md so the spec artifacts reflect the implemented decision: the CLI carries no context_file attribute or __CONTEXT_FILE__ resolution, and the per-agent defaults map lives in the extension. Resolves PR review #4548130110. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: scrub stale context-file mentions from CLI docstrings Update the multi_install_safe docstring (drop the removed "context file" invariant), the RovoDev setup docstring (no longer upserts a context section), the Copilot module docstring (drop the context-file line), and tighten the _update_init_options_for_integration note. Pure docstring changes — no behavioral impact. Resolves PR review #4548237085. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: harden agent-context test helper and fix stale docs - base.py: document multi_install_safe as an optional subclass attribute in the IntegrationBase docstring. - test_cli.py: clarify the init-options assertion is guarding against leftover legacy agent-context keys, not relocation. - test_extension_agent_context.py: _install_agent_context_config now asserts the bundled agent-context-defaults.json exists and always copies it, so self-seeding tests fail loudly instead of silently skipping when the map is missing. - test_integration_cursor_agent.py: drop Path/IntegrationManifest imports left unused after removing the context-section frontmatter tests. Resolves PR review #4548293116. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove gitignored SDD artifacts from specs/ The specs/001-agent-context-full-optin/ artifacts were force-added for dogfooding visibility, but specs/ is gitignored and these were always intended to be purged before merge. Remove them so merging does not add an intentionally-untracked directory to repo history. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: keep CHANGELOG.md identical to upstream CHANGELOG.md is auto-generated at release time, so the branch should not carry a manual entry. Restore it to match upstream/main exactly. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: preserve Cursor .mdc frontmatter in agent-context updater scripts The bundled agent-context updater scripts wrote the managed section as plain text. For Cursor-style `.mdc` targets this dropped the required `---\nalwaysApply: true\n---` frontmatter, reintroducing the rule-loading bug originally fixed in #1699. Port the `_ensure_mdc_frontmatter` logic into both the bash and PowerShell updaters: prepend frontmatter when missing, repair `alwaysApply` when set to the wrong value, and leave non-`.mdc` targets untouched. Add regression tests covering both shells. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: scope CLI-free guard to agent-context-specific symbols Drop the bare "context_file" substring from FORBIDDEN_SYMBOLS so the guard no longer fails on unrelated future CLI fields named context_file. The list still covers agent-context-specific identifiers (__CONTEXT_FILE__, _context_file_display, _resolve_context_files, _resolve_context_file_values). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden agent-context bash self-seed against malformed init JSON Two robustness fixes in the embedded Python self-seed logic: - Coerce the integration value from init-options.json to a string only when it is actually a string; otherwise treat it as unset so a corrupted dict/list value degrades to the existing nothing-to-do behavior instead of breaking the agents-map lookup. - Normalize agent-context-defaults.json: only use 'agents' when both the JSON root and the 'agents' value are dicts, so a wrong-shaped (but valid) JSON falls back to the warning path instead of raising on .get. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct PowerShell hyphenated key lookup and regex replace count - Self-seed now reads the defaults mapping via $defaults.agents.PSObject.Properties[$integrationKey].Value instead of member access ($defaults.agents.$integrationKey), which parsed hyphenated keys like 'cursor-agent'/'kiro-cli' as subtraction and failed to resolve. - Replace the static [regex]::Replace(..., 1) call, whose trailing 1 was interpreted as RegexOptions.IgnoreCase rather than a replacement count, with an instance Regex whose Replace(input, replacement, 1) limits to the first match as intended. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make bash .mdc frontmatter guard case-insensitive The bash updater only injected Cursor .mdc frontmatter when ctx_path ended in lowercase '.mdc', so a mixed/upper-case extension (e.g. specify-rules.MDC) was skipped and Cursor would not auto-load the rule file. Compare against the casefolded path. The PowerShell variant already uses -match, which is case-insensitive by default, so no change is needed there. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document separator-agnostic agent-context update invocation The README hard-coded the dot-notation slash command (/speckit.agent-context.update), which hyphen-separator agents like Forge and Cline do not recognize. Document the canonical command ID plus both slash invocations so users copy the form their agent accepts. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -13,14 +13,13 @@ Provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import yaml
|
||||
@@ -91,13 +90,9 @@ class IntegrationBase(ABC):
|
||||
|
||||
And may optionally set:
|
||||
|
||||
* ``context_file`` — path (relative to project root) of the agent
|
||||
context/instructions file (e.g. ``"CLAUDE.md"``)
|
||||
|
||||
Projects may additionally opt into managing multiple context files by
|
||||
setting ``context_files`` in the agent-context extension config. The
|
||||
integration class still declares one default ``context_file`` for backwards
|
||||
compatibility and command-template rendering.
|
||||
* ``invoke_separator`` — slash-command separator (defaults to ``"."``)
|
||||
* ``multi_install_safe`` — declare the integration safe to install
|
||||
alongside others (defaults to ``False``)
|
||||
"""
|
||||
|
||||
# -- Must be set by every subclass ------------------------------------
|
||||
@@ -113,9 +108,6 @@ class IntegrationBase(ABC):
|
||||
|
||||
# -- Optional ---------------------------------------------------------
|
||||
|
||||
context_file: str | None = None
|
||||
"""Relative path to the agent context file (e.g. ``CLAUDE.md``)."""
|
||||
|
||||
invoke_separator: str = "."
|
||||
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
|
||||
|
||||
@@ -125,16 +117,11 @@ class IntegrationBase(ABC):
|
||||
multi_install_safe: bool = False
|
||||
"""Whether this integration is declared safe to install alongside others.
|
||||
|
||||
Safe integrations must use a static, unique agent root, command directory,
|
||||
and context file. Registry tests enforce those invariants for every
|
||||
Safe integrations must use a static, unique agent root and command
|
||||
directory. Registry tests enforce those invariants for every
|
||||
integration that sets this flag.
|
||||
"""
|
||||
|
||||
# -- Markers for managed context section ------------------------------
|
||||
|
||||
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"
|
||||
CONTEXT_MARKER_END = "<!-- SPECKIT END -->"
|
||||
|
||||
# -- Public API -------------------------------------------------------
|
||||
|
||||
@classmethod
|
||||
@@ -533,498 +520,6 @@ class IntegrationBase(ABC):
|
||||
|
||||
return created
|
||||
|
||||
# -- Agent context file management ------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _ensure_mdc_frontmatter(content: str) -> str:
|
||||
"""Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``.
|
||||
|
||||
If frontmatter is missing, prepend it. If frontmatter exists but
|
||||
``alwaysApply`` is absent or not ``true``, inject/fix it.
|
||||
|
||||
Uses string/regex manipulation to preserve comments and formatting
|
||||
in existing frontmatter.
|
||||
"""
|
||||
import re as _re
|
||||
|
||||
leading_ws = len(content) - len(content.lstrip())
|
||||
leading = content[:leading_ws]
|
||||
stripped = content[leading_ws:]
|
||||
|
||||
if not stripped.startswith("---"):
|
||||
return "---\nalwaysApply: true\n---\n\n" + content
|
||||
|
||||
# Match frontmatter block: ---\n...\n---
|
||||
match = _re.match(
|
||||
r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)",
|
||||
stripped,
|
||||
_re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return "---\nalwaysApply: true\n---\n\n" + content
|
||||
|
||||
opening, fm_text, closing, sep, rest = match.groups()
|
||||
newline = "\r\n" if "\r\n" in opening else "\n"
|
||||
|
||||
# Already correct?
|
||||
if _re.search(
|
||||
r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text
|
||||
):
|
||||
return content
|
||||
|
||||
# alwaysApply exists but wrong value — fix in place while preserving
|
||||
# indentation and any trailing inline comment.
|
||||
if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text):
|
||||
fm_text = _re.sub(
|
||||
r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$",
|
||||
r"\1alwaysApply: true\2",
|
||||
fm_text,
|
||||
count=1,
|
||||
)
|
||||
elif fm_text.strip():
|
||||
fm_text = fm_text + newline + "alwaysApply: true"
|
||||
else:
|
||||
fm_text = "alwaysApply: true"
|
||||
|
||||
return f"{leading}{opening}{fm_text}{closing}{sep}{rest}"
|
||||
|
||||
@staticmethod
|
||||
def _build_context_section(plan_path: str = "") -> str:
|
||||
"""Build the content for the managed section between markers.
|
||||
|
||||
*plan_path* is the project-relative path to the current plan
|
||||
(e.g. ``"specs/<feature>/plan.md"``). When empty, the section
|
||||
contains only the generic directive without a concrete path.
|
||||
"""
|
||||
lines = [
|
||||
"For additional context about technologies to be used, project structure,",
|
||||
"shell commands, and other important information, read the current plan",
|
||||
]
|
||||
if plan_path:
|
||||
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 entry.get("enabled", True) is not False
|
||||
|
||||
@staticmethod
|
||||
def _context_file_dedupe_key(path: str) -> str:
|
||||
"""Return the comparison key for context file de-duplication."""
|
||||
return path.casefold() if os.name == "nt" else path
|
||||
|
||||
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, UnicodeError, 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
|
||||
|
||||
@staticmethod
|
||||
def _validate_context_file_path(project_root: Path, context_file: str) -> str:
|
||||
"""Return a safe project-relative context file path.
|
||||
|
||||
The agent-context scripts reject paths that can escape the project
|
||||
root; the Python integration path must apply the same guard before
|
||||
setup or teardown touches context files.
|
||||
"""
|
||||
candidate = context_file.strip()
|
||||
if not candidate:
|
||||
raise ValueError("agent-context: context file path must not be empty")
|
||||
|
||||
win_path = PureWindowsPath(candidate)
|
||||
if Path(candidate).is_absolute() or win_path.drive or win_path.root:
|
||||
raise ValueError(
|
||||
"agent-context: context files must be project-relative paths; "
|
||||
f"got {candidate!r}"
|
||||
)
|
||||
if "\\" in candidate:
|
||||
raise ValueError(
|
||||
"agent-context: context files must not contain backslash "
|
||||
f"separators; got {candidate!r}"
|
||||
)
|
||||
|
||||
parts = [part for part in re.split(r"[\\/]+", candidate) if part]
|
||||
if ".." in parts:
|
||||
raise ValueError(
|
||||
"agent-context: context files must not contain '..' path "
|
||||
f"segments; got {candidate!r}"
|
||||
)
|
||||
|
||||
root = project_root.resolve()
|
||||
target = (root / candidate).resolve(strict=False)
|
||||
try:
|
||||
target.relative_to(root)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
"agent-context: context file path resolves outside the project "
|
||||
f"root; got {candidate!r}"
|
||||
) from exc
|
||||
|
||||
return candidate
|
||||
|
||||
@classmethod
|
||||
def _resolve_context_file_values(
|
||||
cls,
|
||||
project_root: Path,
|
||||
cfg: dict[str, Any] | None,
|
||||
*,
|
||||
fallback_context_file: Any = None,
|
||||
legacy_context_file: Any = None,
|
||||
include_context_files: bool = True,
|
||||
validate: bool = True,
|
||||
) -> list[str]:
|
||||
"""Resolve context file config with shared precedence and de-duplication."""
|
||||
files: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add_context_file(value: Any) -> None:
|
||||
if not isinstance(value, str):
|
||||
return
|
||||
candidate = value.strip()
|
||||
if not candidate:
|
||||
return
|
||||
if validate:
|
||||
candidate = cls._validate_context_file_path(project_root, candidate)
|
||||
key = cls._context_file_dedupe_key(candidate)
|
||||
if key in seen:
|
||||
return
|
||||
files.append(candidate)
|
||||
seen.add(key)
|
||||
|
||||
if isinstance(cfg, dict) and include_context_files:
|
||||
configured = cfg.get("context_files")
|
||||
if isinstance(configured, list):
|
||||
for value in configured:
|
||||
add_context_file(value)
|
||||
if files:
|
||||
return files
|
||||
|
||||
if isinstance(cfg, dict):
|
||||
add_context_file(cfg.get("context_file"))
|
||||
if files:
|
||||
return files
|
||||
|
||||
add_context_file(fallback_context_file)
|
||||
if files:
|
||||
return files
|
||||
|
||||
add_context_file(legacy_context_file)
|
||||
return files
|
||||
|
||||
@staticmethod
|
||||
def _format_context_file_values(context_files: list[str]) -> str:
|
||||
"""Return context file targets as the template display string."""
|
||||
return ", ".join(context_files)
|
||||
|
||||
def _resolve_context_files(self, project_root: Path) -> list[str]:
|
||||
"""Return project-relative context files managed for *project_root*.
|
||||
|
||||
``context_files`` in the agent-context extension config, when present
|
||||
and non-empty, takes precedence over the config's singular
|
||||
``context_file``. The integration class default is used only when the
|
||||
extension config has no context file target.
|
||||
Raises ``ValueError`` when a configured path can escape the project
|
||||
root.
|
||||
"""
|
||||
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, UnicodeError, ValueError, yaml.YAMLError):
|
||||
cfg = None
|
||||
return self._resolve_context_file_values(
|
||||
project_root,
|
||||
cfg,
|
||||
fallback_context_file=self.context_file,
|
||||
)
|
||||
|
||||
def _context_file_display(self, project_root: Path) -> str:
|
||||
"""Return human-readable context file target(s) for templates."""
|
||||
if not self._agent_context_extension_enabled(project_root):
|
||||
from .. import _load_agent_context_config
|
||||
|
||||
context_files = self._resolve_context_file_values(
|
||||
project_root,
|
||||
_load_agent_context_config(project_root),
|
||||
fallback_context_file=self.context_file,
|
||||
include_context_files=False,
|
||||
validate=False,
|
||||
)
|
||||
return context_files[0] if context_files else ""
|
||||
return self._format_context_file_values(
|
||||
self._resolve_context_files(project_root)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _upsert_context_file(
|
||||
ctx_path: Path,
|
||||
section: str,
|
||||
marker_start: str,
|
||||
marker_end: str,
|
||||
) -> None:
|
||||
"""Create or update one managed context section."""
|
||||
if ctx_path.exists():
|
||||
content = ctx_path.read_text(encoding="utf-8-sig")
|
||||
start_idx = content.find(marker_start)
|
||||
end_idx = content.find(
|
||||
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(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
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = content[:start_idx] + section + content[end_of_marker:]
|
||||
elif start_idx != -1:
|
||||
# Corrupted: start marker without end — replace from start through EOF
|
||||
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(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":
|
||||
end_of_marker += 1
|
||||
new_content = section + content[end_of_marker:]
|
||||
else:
|
||||
# No markers found — append
|
||||
if content:
|
||||
if not content.endswith("\n"):
|
||||
content += "\n"
|
||||
new_content = content + "\n" + section
|
||||
else:
|
||||
new_content = section
|
||||
|
||||
# Ensure .mdc files have required YAML frontmatter
|
||||
if ctx_path.suffix == ".mdc":
|
||||
new_content = IntegrationBase._ensure_mdc_frontmatter(new_content)
|
||||
else:
|
||||
ctx_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Cursor .mdc files require YAML frontmatter to be loaded
|
||||
if ctx_path.suffix == ".mdc":
|
||||
new_content = IntegrationBase._ensure_mdc_frontmatter(section)
|
||||
else:
|
||||
new_content = section
|
||||
|
||||
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
ctx_path.write_bytes(normalized.encode("utf-8"))
|
||||
|
||||
def upsert_context_section(
|
||||
self,
|
||||
project_root: Path,
|
||||
plan_path: str = "",
|
||||
) -> Path | None:
|
||||
"""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 the configured
|
||||
start/end markers (default ``<!-- SPECKIT START -->`` /
|
||||
``<!-- SPECKIT END -->``) 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 first context file, or ``None`` when no context
|
||||
files are configured or the ``agent-context`` extension is
|
||||
disabled.
|
||||
"""
|
||||
if not self._agent_context_extension_enabled(project_root):
|
||||
return None
|
||||
|
||||
context_files = self._resolve_context_files(project_root)
|
||||
if not context_files:
|
||||
return None
|
||||
|
||||
from .._console import console # local import to avoid cycles
|
||||
|
||||
console.print(
|
||||
"[yellow]Deprecation:[/yellow] Inline agent-context updates during "
|
||||
"integration setup will be disabled in v0.12.0. Context file "
|
||||
"management has moved to the bundled [bold]agent-context[/bold] "
|
||||
"extension. Run [cyan]specify extension disable agent-context[/cyan] "
|
||||
"to opt out early.",
|
||||
highlight=False,
|
||||
)
|
||||
|
||||
marker_start, marker_end = self._resolve_context_markers(project_root)
|
||||
|
||||
section = (
|
||||
f"{marker_start}\n"
|
||||
f"{self._build_context_section(plan_path)}\n"
|
||||
f"{marker_end}\n"
|
||||
)
|
||||
|
||||
first_path: Path | None = None
|
||||
for context_file in context_files:
|
||||
ctx_path = project_root / context_file
|
||||
self._upsert_context_file(ctx_path, section, marker_start, marker_end)
|
||||
if first_path is None:
|
||||
first_path = ctx_path
|
||||
return first_path
|
||||
|
||||
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 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._agent_context_extension_enabled(project_root):
|
||||
return False
|
||||
|
||||
context_files = self._resolve_context_files(project_root)
|
||||
if not context_files:
|
||||
return False
|
||||
|
||||
marker_start, marker_end = self._resolve_context_markers(project_root)
|
||||
removed_any = False
|
||||
|
||||
for context_file in context_files:
|
||||
ctx_path = project_root / context_file
|
||||
if not ctx_path.exists():
|
||||
continue
|
||||
|
||||
content = ctx_path.read_text(encoding="utf-8-sig")
|
||||
start_idx = content.find(marker_start)
|
||||
end_idx = content.find(
|
||||
marker_end,
|
||||
start_idx if start_idx != -1 else 0,
|
||||
)
|
||||
|
||||
# Only remove a complete, well-ordered managed section. If either
|
||||
# marker is missing, leave the file unchanged to avoid deleting
|
||||
# unrelated user-authored content.
|
||||
if start_idx == -1 or end_idx == -1 or end_idx <= start_idx:
|
||||
continue
|
||||
|
||||
removal_start = start_idx
|
||||
removal_end = end_idx + len(marker_end)
|
||||
|
||||
# Consume trailing line ending (CRLF or LF)
|
||||
if removal_end < len(content) and content[removal_end] == "\r":
|
||||
removal_end += 1
|
||||
if removal_end < len(content) and content[removal_end] == "\n":
|
||||
removal_end += 1
|
||||
|
||||
# Also strip a blank line before the section if present
|
||||
if removal_start > 0 and content[removal_start - 1] == "\n":
|
||||
if removal_start > 1 and content[removal_start - 2] == "\n":
|
||||
removal_start -= 1
|
||||
|
||||
new_content = content[:removal_start] + content[removal_end:]
|
||||
|
||||
# Normalize line endings before comparisons
|
||||
normalized = new_content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
# For .mdc files, treat Speckit-generated frontmatter-only content as empty
|
||||
if ctx_path.suffix == ".mdc":
|
||||
import re
|
||||
|
||||
# Delete the file if only YAML frontmatter remains (no body content)
|
||||
frontmatter_only = re.match(
|
||||
r"^---\n.*?\n---\s*$", normalized, re.DOTALL
|
||||
)
|
||||
if not normalized.strip() or frontmatter_only:
|
||||
ctx_path.unlink()
|
||||
removed_any = True
|
||||
continue
|
||||
|
||||
if not normalized.strip():
|
||||
ctx_path.unlink()
|
||||
else:
|
||||
ctx_path.write_bytes(normalized.encode("utf-8"))
|
||||
removed_any = True
|
||||
|
||||
return removed_any
|
||||
|
||||
@staticmethod
|
||||
def resolve_command_refs(content: str, separator: str = ".") -> str:
|
||||
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
|
||||
@@ -1049,7 +544,6 @@ class IntegrationBase(ABC):
|
||||
agent_name: str,
|
||||
script_type: str,
|
||||
arg_placeholder: str = "$ARGUMENTS",
|
||||
context_file: str = "",
|
||||
invoke_separator: str = ".",
|
||||
) -> str:
|
||||
"""Process a raw command template into agent-ready content.
|
||||
@@ -1060,9 +554,8 @@ class IntegrationBase(ABC):
|
||||
3. Strip ``scripts:`` section from frontmatter
|
||||
4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
|
||||
5. Replace ``__AGENT__`` with *agent_name*
|
||||
6. Replace ``__CONTEXT_FILE__`` with *context_file*
|
||||
7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
|
||||
8. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
|
||||
6. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc.
|
||||
7. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
|
||||
"""
|
||||
# 1. Extract script command from frontmatter
|
||||
script_command = ""
|
||||
@@ -1122,10 +615,7 @@ class IntegrationBase(ABC):
|
||||
# 5. Replace __AGENT__
|
||||
content = content.replace("__AGENT__", agent_name)
|
||||
|
||||
# 6. Replace __CONTEXT_FILE__
|
||||
content = content.replace("__CONTEXT_FILE__", context_file)
|
||||
|
||||
# 7. Rewrite paths — delegate to the shared implementation in
|
||||
# 6. Rewrite paths — delegate to the shared implementation in
|
||||
# CommandRegistrar so extension-local paths are preserved and
|
||||
# boundary rules stay consistent across the codebase.
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
@@ -1180,8 +670,6 @@ class IntegrationBase(ABC):
|
||||
self.record_file_in_manifest(dst_file, project_root, manifest)
|
||||
created.append(dst_file)
|
||||
|
||||
# Upsert managed context section into the agent context file
|
||||
self.upsert_context_section(project_root)
|
||||
|
||||
return created
|
||||
|
||||
@@ -1196,11 +684,9 @@ class IntegrationBase(ABC):
|
||||
|
||||
Delegates to ``manifest.uninstall()`` which only removes files
|
||||
whose hash still matches the recorded value (unless *force*).
|
||||
Also removes the managed context section from the agent file.
|
||||
|
||||
Returns ``(removed, skipped)`` file lists.
|
||||
"""
|
||||
self.remove_context_section(project_root)
|
||||
return manifest.uninstall(project_root, force=force)
|
||||
|
||||
# -- Convenience helpers for subclasses -------------------------------
|
||||
@@ -1234,12 +720,11 @@ class IntegrationBase(ABC):
|
||||
class MarkdownIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that use standard Markdown commands.
|
||||
|
||||
Subclasses only need to set ``key``, ``config``, ``registrar_config``
|
||||
(and optionally ``context_file``). Everything else is inherited.
|
||||
Subclasses only need to set ``key``, ``config``, ``registrar_config``.
|
||||
Everything else is inherited.
|
||||
|
||||
``setup()`` processes command templates (replacing ``{SCRIPT}``,
|
||||
``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the
|
||||
managed context section into the agent context file.
|
||||
``{ARGS}``, ``__AGENT__``, rewriting paths).
|
||||
"""
|
||||
|
||||
def build_exec_args(
|
||||
@@ -1294,13 +779,11 @@ class MarkdownIntegration(IntegrationBase):
|
||||
else "$ARGUMENTS"
|
||||
)
|
||||
created: list[Path] = []
|
||||
context_file_display = self._context_file_display(project_root)
|
||||
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
context_file=context_file_display,
|
||||
)
|
||||
dst_name = self.command_filename(src_file.stem)
|
||||
dst_file = self.write_file_and_record(
|
||||
@@ -1308,8 +791,6 @@ class MarkdownIntegration(IntegrationBase):
|
||||
)
|
||||
created.append(dst_file)
|
||||
|
||||
# Upsert managed context section into the agent context file
|
||||
self.upsert_context_section(project_root)
|
||||
|
||||
return created
|
||||
|
||||
@@ -1323,8 +804,7 @@ class TomlIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that use TOML command format.
|
||||
|
||||
Mirrors ``MarkdownIntegration`` closely: subclasses only need to set
|
||||
``key``, ``config``, ``registrar_config`` (and optionally
|
||||
``context_file``). Everything else is inherited.
|
||||
``key``, ``config``, ``registrar_config``. Everything else is inherited.
|
||||
|
||||
``setup()`` processes command templates through the same placeholder
|
||||
pipeline as ``MarkdownIntegration``, then converts the result to
|
||||
@@ -1500,14 +980,12 @@ class TomlIntegration(IntegrationBase):
|
||||
else "{{args}}"
|
||||
)
|
||||
created: list[Path] = []
|
||||
context_file_display = self._context_file_display(project_root)
|
||||
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
description = self._extract_description(raw)
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
context_file=context_file_display,
|
||||
)
|
||||
_, body = self._split_frontmatter(processed)
|
||||
toml_content = self._render_toml(description, body)
|
||||
@@ -1517,8 +995,6 @@ class TomlIntegration(IntegrationBase):
|
||||
)
|
||||
created.append(dst_file)
|
||||
|
||||
# Upsert managed context section into the agent context file
|
||||
self.upsert_context_section(project_root)
|
||||
|
||||
return created
|
||||
|
||||
@@ -1532,8 +1008,7 @@ class YamlIntegration(IntegrationBase):
|
||||
"""Concrete base for integrations that use YAML recipe format.
|
||||
|
||||
Mirrors ``TomlIntegration`` closely: subclasses only need to set
|
||||
``key``, ``config``, ``registrar_config`` (and optionally
|
||||
``context_file``). Everything else is inherited.
|
||||
``key``, ``config``, ``registrar_config``. Everything else is inherited.
|
||||
|
||||
``setup()`` processes command templates through the same placeholder
|
||||
pipeline as ``MarkdownIntegration``, then converts the result to
|
||||
@@ -1696,7 +1171,6 @@ class YamlIntegration(IntegrationBase):
|
||||
else "{{args}}"
|
||||
)
|
||||
created: list[Path] = []
|
||||
context_file_display = self._context_file_display(project_root)
|
||||
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
@@ -1712,7 +1186,6 @@ class YamlIntegration(IntegrationBase):
|
||||
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
context_file=context_file_display,
|
||||
)
|
||||
_, body = self._split_frontmatter(processed)
|
||||
yaml_content = self._render_yaml(
|
||||
@@ -1724,8 +1197,6 @@ class YamlIntegration(IntegrationBase):
|
||||
)
|
||||
created.append(dst_file)
|
||||
|
||||
# Upsert managed context section into the agent context file
|
||||
self.upsert_context_section(project_root)
|
||||
|
||||
return created
|
||||
|
||||
@@ -1741,8 +1212,8 @@ class SkillsIntegration(IntegrationBase):
|
||||
Skills use the ``speckit-<name>/SKILL.md`` directory layout following
|
||||
the `agentskills.io <https://agentskills.io/specification>`_ spec.
|
||||
|
||||
Subclasses set ``key``, ``config``, ``registrar_config`` (and
|
||||
optionally ``context_file``) like any integration. They may also
|
||||
Subclasses set ``key``, ``config``, ``registrar_config`` like any
|
||||
integration. They may also
|
||||
override ``options()`` to declare additional CLI flags (e.g.
|
||||
``--skills``, ``--migrate-legacy``).
|
||||
|
||||
@@ -1887,7 +1358,6 @@ class SkillsIntegration(IntegrationBase):
|
||||
else "$ARGUMENTS"
|
||||
)
|
||||
created: list[Path] = []
|
||||
context_file_display = self._context_file_display(project_root)
|
||||
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
@@ -1911,7 +1381,6 @@ class SkillsIntegration(IntegrationBase):
|
||||
# Process body through the standard template pipeline
|
||||
processed_body = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
context_file=context_file_display,
|
||||
invoke_separator=self.invoke_separator,
|
||||
)
|
||||
# Strip the processed frontmatter — we rebuild it for skills.
|
||||
@@ -1958,7 +1427,5 @@ class SkillsIntegration(IntegrationBase):
|
||||
)
|
||||
created.append(dst)
|
||||
|
||||
# Upsert managed context section into the agent context file
|
||||
self.upsert_context_section(project_root)
|
||||
|
||||
return created
|
||||
|
||||
Reference in New Issue
Block a user