diff --git a/AGENTS.md b/AGENTS.md index 3d5ea32377..68d8641e4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,6 @@ class WindsurfIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".windsurf/rules/specify-rules.md" ``` **TOML agent (Gemini):** @@ -101,7 +100,6 @@ class GeminiIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "GEMINI.md" ``` **Skills agent (Codex):** @@ -129,7 +127,6 @@ class CodexIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -150,7 +147,6 @@ class CodexIntegration(SkillsIntegration): | `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name | | `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` | | `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` | -| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) | **Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`). @@ -175,9 +171,11 @@ def _register_builtins() -> None: ### 4. Context file behavior -Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate. +The Specify CLI carries **no agent-context state whatsoever**. Integration classes do **not** declare a `context_file`, and the CLI never creates, updates, removes, resolves, or migrates a context/instruction file (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`, …). New integrations add nothing for context handling. -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`: +Managing the "Spec Kit" section in the context file is fully owned by the bundled `agent-context` extension (`extensions/agent-context/`), which is a **full opt-in**: `specify init` does not install it. A user adds/enables it through the standard extension verbs, after which the extension's own bundled scripts maintain the context section. When the extension is absent or disabled, nothing in Spec Kit touches the context file. + +The extension reads its own config file at `.specify/extensions/agent-context/agent-context-config.yml`: ```yaml # Path to the coding agent context file managed by this extension @@ -189,10 +187,10 @@ context_markers: 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. +- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension. +- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly. -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()`). +Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run. 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. @@ -401,7 +399,6 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): 2. Extracts title and description from frontmatter 3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt) 4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping -5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there ## Branch Naming Convention @@ -466,7 +463,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag ## Common Pitfalls 1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint. -2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/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. +2. **Reintroducing context handling into the CLI**: The opt-in `agent-context` extension owns everything about context files — including the per-agent default mapping in `agent-context-defaults.json`. Integration classes must **not** declare a `context_file`, and no CLI code should read, write, resolve, or migrate context files. All context-file logic lives in `.specify/extensions/agent-context/` and its bundled scripts. 3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents. 4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents. 5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added. diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md index 091e2b4802..adc13e31e2 100644 --- a/extensions/agent-context/README.md +++ b/extensions/agent-context/README.md @@ -6,15 +6,17 @@ It owns the lifecycle of the managed section delimited by the configurable start ## 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: +Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** 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. +- **Choose whether to install it at all** — `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file. +- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — the bundled scripts honor the `context_markers` value. - **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`. -- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). +- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator — `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline). ## Commands +The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline). + | Command | Description | |---------|-------------| | `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. | @@ -40,7 +42,7 @@ context_markers: end: "" ``` -- `context_file` — the project-relative path to the coding agent context file, written by `specify init` and `specify integration install`. +- `context_file` — the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted. - `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected. - `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers. @@ -62,5 +64,4 @@ pip install pyyaml 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()`). -Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out. +When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal — the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out. diff --git a/extensions/agent-context/agent-context-defaults.json b/extensions/agent-context/agent-context-defaults.json new file mode 100644 index 0000000000..120c348acb --- /dev/null +++ b/extensions/agent-context/agent-context-defaults.json @@ -0,0 +1,42 @@ +{ + "_comment": "Default coding agent context file per integration, owned by the agent-context extension. Used to self-seed agent-context-config.yml when it declares no context_file/context_files. Keyed by the Spec Kit integration key recorded in .specify/init-options.json. This mapping is independent of the Specify CLI by design.", + "agents": { + "agy": "AGENTS.md", + "amp": "AGENTS.md", + "auggie": ".augment/rules/specify-rules.md", + "bob": "AGENTS.md", + "claude": "CLAUDE.md", + "cline": ".clinerules/specify-rules.md", + "codebuddy": "CODEBUDDY.md", + "codex": "AGENTS.md", + "copilot": ".github/copilot-instructions.md", + "cursor-agent": ".cursor/rules/specify-rules.mdc", + "devin": "AGENTS.md", + "firebender": ".firebender/rules/specify-rules.mdc", + "forge": "AGENTS.md", + "gemini": "GEMINI.md", + "generic": "AGENTS.md", + "goose": "AGENTS.md", + "hermes": "AGENTS.md", + "iflow": "IFLOW.md", + "junie": ".junie/AGENTS.md", + "kilocode": ".kilocode/rules/specify-rules.md", + "kimi": "AGENTS.md", + "kiro-cli": "AGENTS.md", + "lingma": ".lingma/rules/specify-rules.md", + "omp": "AGENTS.md", + "opencode": "AGENTS.md", + "pi": "AGENTS.md", + "qodercli": "QODER.md", + "qwen": "QWEN.md", + "roo": ".roo/rules/specify-rules.md", + "rovodev": "AGENTS.md", + "shai": "SHAI.md", + "tabnine": "TABNINE.md", + "trae": ".trae/rules/project_rules.md", + "vibe": "AGENTS.md", + "windsurf": ".windsurf/rules/specify-rules.md", + "zcode": "ZCODE.md", + "zed": "AGENTS.md" + } +} diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 9d57b08cf5..8dbd47f378 100755 --- a/extensions/agent-context/scripts/bash/update-agent-context.sh +++ b/extensions/agent-context/scripts/bash/update-agent-context.sh @@ -59,7 +59,7 @@ case "$(uname -s 2>/dev/null || true)" in esac # Parse extension config once; emit context files as JSON, followed by marker strings. -if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" <<'PY' +if ! _raw_opts="$("$_python" - "$EXT_CONFIG" "$_case_insensitive_context_files" "$PROJECT_ROOT" <<'PY' import json import sys try: @@ -95,24 +95,67 @@ def get_str(obj, *keys): context_files = [] seen_context_files = set() case_insensitive = sys.argv[2] == "1" or sys.platform.startswith(("win32", "cygwin")) +def add_context_file(value): + if not isinstance(value, str): + return + candidate = value.strip() + if not candidate: + return + key = candidate.casefold() if case_insensitive else candidate + if key in seen_context_files: + return + context_files.append(candidate) + seen_context_files.add(key) raw_files = data.get("context_files") if isinstance(raw_files, list): for value in raw_files: - if not isinstance(value, str): - continue - candidate = value.strip() - if not candidate: - continue - key = candidate.casefold() if case_insensitive else candidate - if key in seen_context_files: - continue - context_files.append(candidate) - seen_context_files.add(key) + add_context_file(value) if not context_files: - raw_file = get_str(data, "context_file") - candidate = raw_file.strip() - if candidate: - context_files.append(candidate) + add_context_file(get_str(data, "context_file")) +if not context_files: + # Self-seed: the agent-context extension owns its lifecycle, so when its + # own config declares no target it derives one from the active integration + # recorded in init-options.json, using the extension's OWN bundled mapping + # (agent-context-defaults.json). This is independent of the Specify CLI by + # design — nothing here imports specify_cli. + project_root = sys.argv[3] if len(sys.argv) > 3 else "." + integration_key = "" + try: + with open( + f"{project_root}/.specify/init-options.json", "r", encoding="utf-8" + ) as fh: + opts = json.load(fh) + if isinstance(opts, dict): + value = opts.get("integration") or opts.get("ai") or "" + integration_key = value if isinstance(value, str) else "" + except Exception: + integration_key = "" + if integration_key: + defaults_path = ( + f"{project_root}/.specify/extensions/agent-context/" + "agent-context-defaults.json" + ) + mapping = {} + try: + with open(defaults_path, "r", encoding="utf-8") as fh: + loaded = json.load(fh) + agents = loaded.get("agents", {}) if isinstance(loaded, dict) else {} + mapping = agents if isinstance(agents, dict) else {} + except Exception: + print( + "agent-context: unable to read %s; cannot self-seed the context " + "file. Set 'context_file' in the extension config." % defaults_path, + file=sys.stderr, + ) + mapping = {} + add_context_file(mapping.get(integration_key, "") or "") + if not context_files: + print( + "agent-context: no default context file is known for integration " + "'%s'. Set 'context_file' in the extension config to choose one." + % integration_key, + file=sys.stderr, + ) print(json.dumps(context_files)) print(get_str(data, "context_markers", "start")) print(get_str(data, "context_markers", "end")) @@ -240,11 +283,58 @@ for CONTEXT_FILE in "${CONTEXT_FILES[@]}"; do mkdir -p "$(dirname "$CTX_PATH")" "$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY' -import sys, os +import os +import re +import sys + 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" + +def ensure_mdc_frontmatter(content): + """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. + + Cursor only auto-loads ``.mdc`` rule files that carry frontmatter with + ``alwaysApply: true``. Prepend it when missing, or repair the value while + preserving any existing frontmatter comments/formatting. + """ + 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 = 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" + + if re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text): + return content + + 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}" + + if os.path.exists(ctx_path): with open(ctx_path, "r", encoding="utf-8-sig") as fh: content = fh.read() @@ -274,6 +364,8 @@ else: new_content = section new_content = new_content.replace("\r\n", "\n").replace("\r", "\n") +if ctx_path.casefold().endswith(".mdc"): + new_content = ensure_mdc_frontmatter(new_content) with open(ctx_path, "wb") as fh: fh.write(new_content.encode("utf-8")) PY diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index d31fcd64c0..a9df338d3a 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -16,6 +16,56 @@ param( [string]$PlanPath ) +function Add-MdcFrontmatter { + <# + Ensure .mdc content has YAML frontmatter with alwaysApply: true. + + Cursor only auto-loads .mdc rule files that carry frontmatter with + alwaysApply: true. Prepend it when missing, or repair the value while + preserving any existing frontmatter comments/formatting. + #> + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Content) + + $leading = '' + $stripped = $Content + $m = [regex]::Match($Content, '^\s*') + if ($m.Success) { + $leading = $m.Value + $stripped = $Content.Substring($m.Length) + } + + if (-not $stripped.StartsWith('---')) { + return "---`nalwaysApply: true`n---`n`n" + $Content + } + + $fm = [regex]::Match($stripped, '^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)', [System.Text.RegularExpressions.RegexOptions]::Singleline) + if (-not $fm.Success) { + return "---`nalwaysApply: true`n---`n`n" + $Content + } + + $opening = $fm.Groups[1].Value + $fmText = $fm.Groups[2].Value + $closing = $fm.Groups[3].Value + $sep = $fm.Groups[4].Value + $rest = $fm.Groups[5].Value + $newline = if ($opening.Contains("`r`n")) { "`r`n" } else { "`n" } + + if ([regex]::IsMatch($fmText, '(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$')) { + return $Content + } + + if ([regex]::IsMatch($fmText, '(?m)^[ \t]*alwaysApply[ \t]*:')) { + $alwaysApplyRegex = [regex]'(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$' + $fmText = $alwaysApplyRegex.Replace($fmText, '${1}alwaysApply: true${2}', 1) + } elseif ($fmText.Trim()) { + $fmText = $fmText + $newline + 'alwaysApply: true' + } else { + $fmText = 'alwaysApply: true' + } + + return "$leading$opening$fmText$closing$sep$rest" +} + function Get-ConfigValue { param( [AllowNull()][object]$Object, @@ -234,6 +284,43 @@ foreach ($ContextFile in $ContextFiles) { } } $ContextFiles = $dedupedContextFiles +if ($ContextFiles.Count -eq 0) { + # Self-seed: the agent-context extension owns its lifecycle, so when its + # own config declares no target it derives one from the active integration + # recorded in init-options.json, using the extension's OWN bundled mapping + # (agent-context-defaults.json). Independent of the Specify CLI by design. + $initOptionsPath = Join-Path $ProjectRoot '.specify/init-options.json' + if (Test-Path -LiteralPath $initOptionsPath) { + try { + $initOpts = Get-Content -LiteralPath $initOptionsPath -Raw | ConvertFrom-Json -ErrorAction Stop + $integrationKey = $null + if ($initOpts.PSObject.Properties['integration'] -and $initOpts.integration) { + $integrationKey = [string]$initOpts.integration + } elseif ($initOpts.PSObject.Properties['ai'] -and $initOpts.ai) { + $integrationKey = [string]$initOpts.ai + } + if ($integrationKey) { + $defaultsPath = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-defaults.json' + if (Test-Path -LiteralPath $defaultsPath) { + $defaults = Get-Content -LiteralPath $defaultsPath -Raw | ConvertFrom-Json -ErrorAction Stop + $derived = $null + if ($defaults.PSObject.Properties['agents'] -and $defaults.agents.PSObject.Properties[$integrationKey]) { + $derived = [string]$defaults.agents.PSObject.Properties[$integrationKey].Value + } + if ($derived -and -not [string]::IsNullOrWhiteSpace($derived)) { + $ContextFiles += $derived.Trim() + } else { + Write-Warning ("agent-context: no default context file is known for integration '{0}'; set 'context_file' in the extension config to choose one." -f $integrationKey) + } + } else { + Write-Warning ("agent-context: unable to read {0}; cannot self-seed the context file. Set 'context_file' in the extension config." -f $defaultsPath) + } + } + } catch { + # Non-fatal: fall through to the nothing-to-do guard below. + } + } +} if ($ContextFiles.Count -eq 0) { Write-Warning 'agent-context: context_files/context_file not set in extension config; nothing to do.' exit 0 @@ -347,6 +434,9 @@ foreach ($ContextFile in $ContextFiles) { } $newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n") + if ($ContextFile -match '\.mdc$') { + $newContent = Add-MdcFrontmatter -Content $newContent + } [System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false))) Write-Host "agent-context: updated $ContextFile" diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index b2e8defb18..9208a711e0 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -262,85 +262,9 @@ def ensure_executable_scripts(project_path: Path, tracker: StepTracker | None = console.print(f" - {f}") # --------------------------------------------------------------------------- -# Agent-context extension config helpers +# Skills directory 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_files": [], - "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, UnicodeError, 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, sort_keys=False), encoding="utf-8") - - -def _update_agent_context_config_file( - project_root: Path, - context_file: str | None, - *, - preserve_markers: bool = True, - preserve_context_files: 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. - - When *preserve_context_files* is True (default), an existing - ``context_files`` list is kept unchanged, including an empty list. This - lets projects opt into updating multiple agent context files while still - preserving the legacy singular ``context_file`` value for compatibility. - """ - from .integrations.base import IntegrationBase - - cfg = _load_agent_context_config(project_root) - cfg["context_file"] = context_file or "" - existing_context_files = cfg.get("context_files") - if preserve_context_files: - cfg["context_files"] = ( - existing_context_files if isinstance(existing_context_files, list) else [] - ) - else: - cfg.pop("context_files", None) - 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. diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py index da3ca49fa6..7864260a99 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -433,37 +433,6 @@ def resolve_skill_placeholders( body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) - # Resolve __CONTEXT_FILE__ from the agent-context extension config. - # When disabled, ignore stale context_files but keep the singular - # context_file value so generated commands still point at the agent - # context file managed before the extension was disabled. - from .integrations.base import IntegrationBase - - # 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) - extension_enabled = IntegrationBase._agent_context_extension_enabled( - project_root - ) - if extension_enabled: - context_files = IntegrationBase._resolve_context_file_values( - project_root, - ac_cfg, - legacy_context_file=init_opts.get("context_file"), - ) - else: - context_files = IntegrationBase._resolve_context_file_values( - project_root, - ac_cfg, - legacy_context_file=init_opts.get("context_file"), - include_context_files=False, - validate=False, - ) - context_file = IntegrationBase._format_context_file_values(context_files) - body = body.replace("__CONTEXT_FILE__", context_file) - return CommandRegistrar.rewrite_project_relative_paths(body) def _convert_argument_placeholder( diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index fc82334da2..dd815b8c5d 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -18,7 +18,6 @@ SCRIPT_TYPE_CHOICES, ) from .._assets import ( - _locate_bundled_extension, _locate_bundled_preset, _locate_bundled_workflow, get_speckit_version, @@ -171,7 +170,6 @@ def init( from .. import ( _install_shared_infra_or_exit, _print_cli_warning, - _update_agent_context_config_file, ensure_executable_scripts, save_init_options, ) @@ -376,7 +374,6 @@ def init( ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), ("workflow", "Install bundled workflow"), - ("agent-context", "Install agent-context extension"), ("final", "Finalize"), ]: tracker.add(key, label) @@ -507,47 +504,6 @@ def init( init_opts["ai_skills"] = True save_init_options(project_path, init_opts) - # --- agent-context extension (bundled, auto-installed) --- - # Installed after init-options.json is written so that skill - # registration can read ai_skills + integration key. - try: - from ..extensions import ExtensionManager as _ExtMgr - - bundled_ac = _locate_bundled_extension("agent-context") - if bundled_ac: - ac_mgr = _ExtMgr(project_path) - if ac_mgr.registry.is_installed("agent-context"): - tracker.complete("agent-context", "already installed") - else: - ac_mgr.install_from_directory( - bundled_ac, get_speckit_version() - ) - tracker.complete("agent-context", "extension installed") - else: - from ..extensions import REINSTALL_COMMAND as _ac_reinstall - - tracker.error( - "agent-context", - f"bundled extension not found — installation may be " - f"incomplete. Run: {_ac_reinstall}", - ) - except Exception as ac_err: - sanitized_ac = str(ac_err).replace("\n", " ").strip() - tracker.error( - "agent-context", - f"extension install failed: {sanitized_ac[:120]}", - ) - - # Write context_file to the agent-context extension config - # AFTER the extension install (which copies the template config - # with an empty context_file). - if resolved_integration.context_file: - _update_agent_context_config_file( - project_path, - resolved_integration.context_file, - preserve_markers=True, - ) - ensure_executable_scripts(project_path, tracker=tracker) if preset: diff --git a/src/specify_cli/integration_scaffold.py b/src/specify_cli/integration_scaffold.py index e4c4b83b3d..f0ed210332 100644 --- a/src/specify_cli/integration_scaffold.py +++ b/src/specify_cli/integration_scaffold.py @@ -117,11 +117,6 @@ class {class_name}({template.base_class}): "args": "{template.args}", "extension": "{template.extension}", }} - context_file = "AGENTS.md" - # Default to False so the generated boilerplate passes the registry - # contract out of the box: multi-install-safe integrations must each have a - # distinct context_file, and the placeholder above ("AGENTS.md") collides - # with the existing codex integration. Opt in once you pick a unique one. multi_install_safe = False ''' @@ -155,7 +150,6 @@ def test_metadata(): assert integration.registrar_config["format"] == "{template.registrar_format}" assert integration.registrar_config["args"] == "{template.args}" assert integration.registrar_config["extension"] == "{template.extension}" - assert integration.context_file == "AGENTS.md" assert integration.multi_install_safe is False ''' @@ -274,7 +268,7 @@ def scaffold_integration( next_steps = ( f"Register {class_name} in src/specify_cli/integrations/__init__.py.", - "Review config metadata, install_url, requires_cli, context_file, and multi_install_safe.", + "Review config metadata, install_url, requires_cli, and multi_install_safe.", f"Run pytest tests/integrations/test_integration_{package_name}.py -v.", ) return IntegrationScaffoldResult( diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index f8a696a866..d1bf051f77 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -103,38 +103,17 @@ def _refresh_init_options_speckit_version(project_root: Path) -> None: def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: - """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. - """ + """Clear active integration keys from init-options.json when they match.""" from .. import ( - _AGENT_CTX_EXT_CONFIG, - _update_agent_context_config_file, load_init_options, save_init_options, ) 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) 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, preserve_context_files=False - ) - elif has_legacy_context_keys: - save_init_options(project_root, opts) def _remove_integration_json(project_root: Path) -> None: @@ -274,21 +253,13 @@ def _update_init_options_for_integration( integration: Any, script_type: str | None = None, ) -> None: - """Update init-options.json and the agent-context extension config to - reflect *integration* as the active one. - - ``context_file``, ``context_files``, 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 - always preserved when the config already exists. Existing ``context_files`` - lists are also preserved so projects can keep multi-agent context anchors - during integration switches. Invalid marker values are - silently ignored at runtime by ``_resolve_context_markers()`` which falls - back to the class-level defaults. + """Update init-options.json to reflect *integration* as the active one. + + Agent context/instruction files are owned entirely by the opt-in + agent-context extension, so this function never touches the extension + or its config. """ from .. import ( - _AGENT_CTX_EXT_CONFIG, - _update_agent_context_config_file, load_init_options, save_init_options, ) @@ -296,9 +267,6 @@ def _update_init_options_for_integration( opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key - # Remove legacy fields if they were written by an older version. - opts.pop("context_file", None) - opts.pop("context_markers", None) opts["speckit_version"] = _get_speckit_version() if script_type: opts["script"] = script_type @@ -307,24 +275,6 @@ def _update_init_options_for_integration( else: opts.pop("ai_skills", None) - # Update the agent-context extension config BEFORE init-options.json - # so a failure here doesn't leave init-options partially updated. - 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, - ) - save_init_options(project_root, opts) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 6ed69e1e0e..33f8d17a91 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -42,7 +42,6 @@ class AgyIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @staticmethod def _inject_hook_command_note(content: str) -> str: diff --git a/src/specify_cli/integrations/amp/__init__.py b/src/specify_cli/integrations/amp/__init__.py index 39df0a9bbf..5d9d14250d 100644 --- a/src/specify_cli/integrations/amp/__init__.py +++ b/src/specify_cli/integrations/amp/__init__.py @@ -18,4 +18,3 @@ class AmpIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/auggie/__init__.py b/src/specify_cli/integrations/auggie/__init__.py index 08e20fbc25..e6fd702fa3 100644 --- a/src/specify_cli/integrations/auggie/__init__.py +++ b/src/specify_cli/integrations/auggie/__init__.py @@ -18,5 +18,4 @@ class AuggieIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".augment/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index be3ab7133d..c820fd4eed 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -13,14 +13,13 @@ 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 = "" - CONTEXT_MARKER_END = "" - # -- Public API ------------------------------------------------------- @classmethod @@ -533,498 +520,6 @@ def install_scripts( 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//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 ```` / - ````) 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___`` placeholders with invocations. @@ -1049,7 +544,6 @@ def process_template( 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 @@ def process_template( 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___`` with invocation strings + 6. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. + 7. Replace ``__SPECKIT_COMMAND___`` with invocation strings """ # 1. Extract script command from frontmatter script_command = "" @@ -1122,10 +615,7 @@ def process_template( # 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 @@ def setup( 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 @@ def teardown( 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 @@ def uninstall( 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 @@ def setup( 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 @@ def setup( ) 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 @@ def setup( 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 @@ def setup( ) 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 @@ def setup( 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 @@ def setup( 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 @@ def setup( ) 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-/SKILL.md`` directory layout following the `agentskills.io `_ 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 @@ def setup( 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 @@ def setup( # 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 @@ def _quote(v: str) -> str: ) created.append(dst) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index 78f2df0379..b953151bd2 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -18,4 +18,3 @@ class BobIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 0df388172d..f7a3687313 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -48,7 +48,6 @@ class ClaudeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "CLAUDE.md" multi_install_safe = True @staticmethod diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index c269a16042..ab839b9b56 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -70,7 +70,6 @@ class ClineIntegration(MarkdownIntegration): "format_name": format_cline_command_name, "invoke_separator": "-", } - context_file = ".clinerules/specify-rules.md" invoke_separator = "-" multi_install_safe = True diff --git a/src/specify_cli/integrations/codebuddy/__init__.py b/src/specify_cli/integrations/codebuddy/__init__.py index 980ac7fed7..6b40e10eaf 100644 --- a/src/specify_cli/integrations/codebuddy/__init__.py +++ b/src/specify_cli/integrations/codebuddy/__init__.py @@ -18,5 +18,4 @@ class CodebuddyIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "CODEBUDDY.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 4dd79da493..7d1ff86e27 100644 --- a/src/specify_cli/integrations/codex/__init__.py +++ b/src/specify_cli/integrations/codex/__init__.py @@ -26,7 +26,6 @@ class CodexIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" dev_no_symlink = True multi_install_safe = True diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 2659b3f252..5cc34d2b1d 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -4,7 +4,6 @@ - Commands use ``.agent.md`` extension (not ``.md``) - Each command gets a companion ``.prompt.md`` file in ``.github/prompts/`` - Installs ``.vscode/settings.json`` with prompt file recommendations -- Context file lives at ``.github/copilot-instructions.md`` When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds commands as ``speckit-/SKILL.md`` directories under ``.github/skills/`` @@ -79,7 +78,6 @@ class _CopilotSkillsHelper(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".github/copilot-instructions.md" class CopilotIntegration(IntegrationBase): @@ -108,7 +106,6 @@ class CopilotIntegration(IntegrationBase): "args": "$ARGUMENTS", "extension": ".agent.md", } - context_file = ".github/copilot-instructions.md" # Mutable flag set by setup() — indicates the active scaffolding mode. _skills_mode: bool = False @@ -354,14 +351,12 @@ def _setup_default( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") - context_file_display = self._context_file_display(project_root) # 1. Process and write command files as .agent.md 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( @@ -396,8 +391,6 @@ def _setup_default( self.record_file_in_manifest(dst_settings, project_root, manifest) created.append(dst_settings) - # 4. Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index b83ee42e54..2c328b2fda 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -36,7 +36,6 @@ class CursorAgentIntegration(SkillsIntegration): "extension": "/SKILL.md", } - context_file = ".cursor/rules/specify-rules.mdc" multi_install_safe = True def build_exec_args( diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index b3b21b8526..18c1fc8d6d 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -30,7 +30,6 @@ class DevinIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" def build_exec_args( self, diff --git a/src/specify_cli/integrations/firebender/__init__.py b/src/specify_cli/integrations/firebender/__init__.py index b49140b1f8..eb0cec02d5 100644 --- a/src/specify_cli/integrations/firebender/__init__.py +++ b/src/specify_cli/integrations/firebender/__init__.py @@ -3,8 +3,8 @@ Firebender (https://firebender.com/) is an AI coding agent for Android Studio and IntelliJ. It reads project-local custom slash commands from ``.firebender/commands/*.mdc`` and project rules from ``.firebender/rules/*.mdc``, -so Spec Kit installs its command templates as ``.mdc`` command files and writes -the managed context section into a ``.firebender/rules/`` rule file. +so Spec Kit installs its command templates as ``.mdc`` command files. The managed +context section (when used) is owned by the ``agent-context`` extension. """ from ..base import MarkdownIntegration @@ -25,7 +25,6 @@ class FirebenderIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".mdc", } - context_file = ".firebender/rules/specify-rules.mdc" multi_install_safe = True def command_filename(self, template_name: str) -> str: diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index d1cd7a49a8..8c21353fec 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -89,7 +89,6 @@ class ForgeIntegration(MarkdownIntegration): "format_name": format_forge_command_name, # Custom name formatter "invoke_separator": "-", } - context_file = "AGENTS.md" invoke_separator = "-" def setup( @@ -128,14 +127,12 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "{{parameters}}") 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") # Process template with standard MarkdownIntegration logic processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) @@ -152,8 +149,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py index 7c6fe159c7..9a459862af 100644 --- a/src/specify_cli/integrations/gemini/__init__.py +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -18,5 +18,4 @@ class GeminiIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "GEMINI.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index 3d6dd19d44..d874273559 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -31,7 +31,6 @@ class GenericIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -119,13 +118,11 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = "$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( @@ -133,7 +130,5 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py index 0fc4d9d57a..77d4e0f837 100644 --- a/src/specify_cli/integrations/goose/__init__.py +++ b/src/specify_cli/integrations/goose/__init__.py @@ -18,4 +18,3 @@ class GooseIntegration(YamlIntegration): "args": "{{args}}", "extension": ".yaml", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index 1d475c72e2..e094dcfcfe 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -50,7 +50,6 @@ class HermesIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" # -- Helpers ----------------------------------------------------------- @@ -114,7 +113,6 @@ def setup( global_skills_dir.mkdir(parents=True, exist_ok=True) 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") @@ -141,7 +139,6 @@ def setup( 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. @@ -183,8 +180,6 @@ def _quote(v: str) -> str: skill_file.write_bytes(normalized.encode("utf-8")) created.append(skill_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) # Create project-local marker directory so extension commands # (e.g. git) can detect Hermes as an active integration. @@ -204,8 +199,7 @@ def teardown( ) -> tuple[list[Path], list[Path]]: """Uninstall integration files including global Hermes skills. - Removes the managed context section from AGENTS.md, removes the - project-local marker directory (if empty), delegates to + Removes the project-local marker directory (if empty), delegates to ``manifest.uninstall()`` for project-local tracked files, and removes all ``speckit-*`` skills under ``~/.hermes/skills/``. @@ -213,8 +207,6 @@ def teardown( standard integration behaviour where all files created by the integration are removed on ``specify integration uninstall``. """ - # Remove managed context section from AGENTS.md - self.remove_context_section(project_root) # Delegate to manifest for project-local tracked files (scripts, # templates, context entries tracked in the manifest). diff --git a/src/specify_cli/integrations/iflow/__init__.py b/src/specify_cli/integrations/iflow/__init__.py index 65d4d21c63..c6b5447bb1 100644 --- a/src/specify_cli/integrations/iflow/__init__.py +++ b/src/specify_cli/integrations/iflow/__init__.py @@ -18,5 +18,4 @@ class IflowIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "IFLOW.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 98d0494a8a..e1e8a9addb 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -18,5 +18,4 @@ class JunieIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".junie/AGENTS.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py index 11674dd9f1..0924843286 100644 --- a/src/specify_cli/integrations/kilocode/__init__.py +++ b/src/specify_cli/integrations/kilocode/__init__.py @@ -18,5 +18,4 @@ class KilocodeIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".kilocode/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 9c28855c02..3320935a03 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -5,8 +5,7 @@ Legacy migration covers projects created before Kimi Code CLI moved to this layout and handles two distinct changes: the directory move from -``.kimi/`` to ``.kimi-code/`` (including the ``KIMI.md`` → ``AGENTS.md`` -context file), and the dotted-to-hyphenated skill naming +``.kimi/`` to ``.kimi-code/``, and the dotted-to-hyphenated skill naming (``speckit.xxx`` → ``speckit-xxx``). """ @@ -16,7 +15,7 @@ from pathlib import Path from typing import Any -from ..base import IntegrationBase, IntegrationOption, SkillsIntegration +from ..base import IntegrationOption, SkillsIntegration from ..manifest import IntegrationManifest @@ -37,7 +36,6 @@ class KimiIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" multi_install_safe = False def build_command_invocation(self, command_name: str, args: str = "") -> str: @@ -79,9 +77,7 @@ def options(cls) -> list[IntegrationOption]: default=False, help=( "Migrate legacy Kimi installations: " - ".kimi/skills/ → .kimi-code/skills/, speckit.xxx → speckit-xxx, " - "and (when the agent-context extension is enabled) " - "KIMI.md user content → AGENTS.md" + ".kimi/skills/ → .kimi-code/skills/ and speckit.xxx → speckit-xxx" ), ), ] @@ -128,14 +124,6 @@ def setup( _is_safe_legacy_dir(new_skills_dir, project_root) ): _migrate_legacy_kimi_skills_dir(old_skills_dir, new_skills_dir) - # Mirror upsert/remove_context_section: a disabled agent-context - # extension is a full opt-out, so skip the KIMI.md → AGENTS.md - # migration entirely and leave both files untouched. - if self._agent_context_extension_enabled(project_root): - marker_start, marker_end = self._resolve_context_markers(project_root) - _migrate_legacy_kimi_context_file( - project_root, marker_start=marker_start, marker_end=marker_end - ) return created @@ -363,112 +351,6 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool: ) -def _migrate_legacy_kimi_context_file( - project_root: Path, - *, - marker_start: str = IntegrationBase.CONTEXT_MARKER_START, - marker_end: str = IntegrationBase.CONTEXT_MARKER_END, -) -> bool: - """Migrate user content from legacy ``KIMI.md`` to ``AGENTS.md``. - - The Speckit managed section is stripped from ``KIMI.md`` before the - remaining content is appended to ``AGENTS.md``. The legacy file is - deleted if it becomes empty. Returns ``True`` if ``KIMI.md`` was - migrated, ``False`` when the migration is skipped. - - The migration is skipped (leaving ``KIMI.md`` untouched) in any of these - cases, so a best-effort legacy cleanup never aborts ``setup()`` or - corrupts ``AGENTS.md``: - - - ``KIMI.md`` is a symlink, missing, or unreadable (its target could be - read from outside the project, or it may not be valid UTF-8). - - ``AGENTS.md`` is a symlink (it could redirect the write to a file - outside the project root), exists as a non-file (e.g. a directory), - or is unreadable/unwritable. - - ``KIMI.md`` has a corrupted managed section — only one marker is - present, or the end marker precedes the start. Stripping is only done - when both markers are present and well-ordered, so a partial managed - block is never copied into ``AGENTS.md``; the user repairs it manually. - """ - legacy_path = project_root / "KIMI.md" - if legacy_path.is_symlink() or not legacy_path.is_file(): - return False - - target_path = project_root / "AGENTS.md" - # Never follow a symlinked target, and never treat an existing non-file - # (e.g. a directory) as a writable context file. - if target_path.is_symlink() or (target_path.exists() and not target_path.is_file()): - return False - - try: - content = legacy_path.read_text(encoding="utf-8-sig") - except (OSError, UnicodeDecodeError): - return False - - marker_pairs = [(marker_start, marker_end)] - default_pair = ( - IntegrationBase.CONTEXT_MARKER_START, - IntegrationBase.CONTEXT_MARKER_END, - ) - if default_pair not in marker_pairs: - marker_pairs.append(default_pair) - - start_idx = -1 - end_idx = -1 - has_start = False - has_end = False - for s, e in marker_pairs: - s_idx = content.find(s) - e_idx = content.find(e, s_idx if s_idx != -1 else 0) - has_s = s_idx != -1 - has_e = e_idx != -1 - if not has_s and not has_e: - continue - # Refuse to migrate a corrupted managed section: exactly one marker, or - # an end marker that does not follow the start. - if has_s != has_e or e_idx <= s_idx: - return False - marker_start, marker_end = s, e - start_idx, end_idx = s_idx, e_idx - has_start = True - has_end = True - break - if has_start and has_end: - removal_start = start_idx - removal_end = end_idx + len(marker_end) - 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 - if removal_start > 0 and content[removal_start - 1] == "\n": - if removal_start > 1 and content[removal_start - 2] == "\n": - removal_start -= 1 - content = content[:removal_start] + content[removal_end:] - - user_content = content.replace("\r\n", "\n").replace("\r", "\n").strip() - if not user_content: - legacy_path.unlink() - return True - - try: - if target_path.is_file(): - existing = target_path.read_text(encoding="utf-8-sig") - existing = existing.replace("\r\n", "\n").replace("\r", "\n") - if not existing.endswith("\n"): - existing += "\n" - new_content = existing + "\n" + user_content + "\n" - else: - new_content = user_content + "\n" - - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_bytes(new_content.encode("utf-8")) - except (OSError, UnicodeDecodeError): - return False - - legacy_path.unlink() - return True - - def _migrate_legacy_kimi_dotted_skills(skills_dir: Path) -> tuple[int, int]: """Compatibility shim — migrate legacy dotted skill dirs in place. diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index 4571b54f90..4c176e5127 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -26,4 +26,3 @@ class KiroCliIntegration(MarkdownIntegration): "args": _KIRO_ARG_FALLBACK, "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/lingma/__init__.py b/src/specify_cli/integrations/lingma/__init__.py index b5cd036033..2cb74b2192 100644 --- a/src/specify_cli/integrations/lingma/__init__.py +++ b/src/specify_cli/integrations/lingma/__init__.py @@ -27,7 +27,6 @@ class LingmaIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".lingma/rules/specify-rules.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/omp/__init__.py b/src/specify_cli/integrations/omp/__init__.py index 73f95a4f2c..1565832989 100644 --- a/src/specify_cli/integrations/omp/__init__.py +++ b/src/specify_cli/integrations/omp/__init__.py @@ -20,7 +20,6 @@ class OmpIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" def build_exec_args( self, diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index abd97ab2ae..0f734b7f41 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -19,7 +19,6 @@ class OpencodeIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" def build_exec_args( self, diff --git a/src/specify_cli/integrations/pi/__init__.py b/src/specify_cli/integrations/pi/__init__.py index 8a25f326ba..fd65a439b0 100644 --- a/src/specify_cli/integrations/pi/__init__.py +++ b/src/specify_cli/integrations/pi/__init__.py @@ -18,4 +18,3 @@ class PiIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py index ee2d4b6255..13535203cf 100644 --- a/src/specify_cli/integrations/qodercli/__init__.py +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -18,5 +18,4 @@ class QodercliIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "QODER.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py index 2506a57681..1e8c15bf91 100644 --- a/src/specify_cli/integrations/qwen/__init__.py +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -18,5 +18,4 @@ class QwenIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "QWEN.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/roo/__init__.py b/src/specify_cli/integrations/roo/__init__.py index f610a3cc63..2042c09339 100644 --- a/src/specify_cli/integrations/roo/__init__.py +++ b/src/specify_cli/integrations/roo/__init__.py @@ -18,5 +18,4 @@ class RooIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".roo/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/rovodev/__init__.py b/src/specify_cli/integrations/rovodev/__init__.py index f8879424ac..01aa870c66 100644 --- a/src/specify_cli/integrations/rovodev/__init__.py +++ b/src/specify_cli/integrations/rovodev/__init__.py @@ -39,7 +39,6 @@ class RovodevIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" # -- CLI dispatch ------------------------------------------------------ @@ -228,8 +227,7 @@ def setup( ) -> list[Path]: """Install RovoDev skills, then generate prompt wrappers and manifest. - 1. ``SkillsIntegration.setup()`` generates skill files and - upserts the context section. + 1. ``SkillsIntegration.setup()`` generates the skill files. 2. Generates prompt wrappers and ``prompts.yml`` for each skill created in step 1. """ diff --git a/src/specify_cli/integrations/shai/__init__.py b/src/specify_cli/integrations/shai/__init__.py index 123953da72..8be9596bf1 100644 --- a/src/specify_cli/integrations/shai/__init__.py +++ b/src/specify_cli/integrations/shai/__init__.py @@ -18,5 +18,4 @@ class ShaiIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "SHAI.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py index 0d0076bc56..9edf1e1607 100644 --- a/src/specify_cli/integrations/tabnine/__init__.py +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -18,5 +18,4 @@ class TabnineIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "TABNINE.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py index 4556487d07..03a628d422 100644 --- a/src/specify_cli/integrations/trae/__init__.py +++ b/src/specify_cli/integrations/trae/__init__.py @@ -26,7 +26,6 @@ class TraeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".trae/rules/project_rules.md" multi_install_safe = True @classmethod diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 7922aa8418..136dec8674 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -28,7 +28,6 @@ class VibeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/windsurf/__init__.py b/src/specify_cli/integrations/windsurf/__init__.py index ae5c3301f4..eba38fd1e5 100644 --- a/src/specify_cli/integrations/windsurf/__init__.py +++ b/src/specify_cli/integrations/windsurf/__init__.py @@ -18,5 +18,4 @@ class WindsurfIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".windsurf/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/zcode/__init__.py b/src/specify_cli/integrations/zcode/__init__.py index ea47f31555..46d93c5ca2 100644 --- a/src/specify_cli/integrations/zcode/__init__.py +++ b/src/specify_cli/integrations/zcode/__init__.py @@ -28,7 +28,6 @@ class ZcodeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "ZCODE.md" multi_install_safe = True @classmethod diff --git a/src/specify_cli/integrations/zed/__init__.py b/src/specify_cli/integrations/zed/__init__.py index 882d83cc59..441e9e36f9 100644 --- a/src/specify_cli/integrations/zed/__init__.py +++ b/src/specify_cli/integrations/zed/__init__.py @@ -27,7 +27,6 @@ class ZedIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 44ab8403ac..1a699d4cef 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -154,14 +154,11 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate - Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites - Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase -4. **Agent context update**: - - Update the plan reference between the `` and `` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path) - -**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file +**Output**: data-model.md, /contracts/*, quickstart.md ## Key rules -- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files +- Use absolute paths for filesystem operations; use project-relative paths for references in documentation - ERROR on gate failures or unresolved clarifications ## Done When diff --git a/tests/extensions/test_agent_context_cli_free.py b/tests/extensions/test_agent_context_cli_free.py new file mode 100644 index 0000000000..9bba8087c0 --- /dev/null +++ b/tests/extensions/test_agent_context_cli_free.py @@ -0,0 +1,57 @@ +"""Static guard: the Specify CLI source must contain no agent-context lifecycle code. + +The ``agent-context`` extension is a full opt-in and owns its own lifecycle. The +Python codebase (``src/specify_cli/**``) must therefore not reference any of the +removed context-section management helpers, the extension config helpers, the +context markers, or the obsolete deprecation message. + +Maps to contract C5 / FR-002 / FR-003 / FR-006 / SC-002 / SC-003. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SRC_ROOT = PROJECT_ROOT / "src" / "specify_cli" + +FORBIDDEN_SYMBOLS = [ + "upsert_context_section", + "remove_context_section", + "_agent_context_extension_enabled", + "_resolve_context_markers", + "_resolve_context_files", + "_resolve_context_file_values", + "_build_context_section", + "_AGENT_CTX_EXT_CONFIG", + "_load_agent_context_config", + "_save_agent_context_config", + "_update_agent_context_config_file", + "CONTEXT_MARKER_START", + "CONTEXT_MARKER_END", + "agent-context-config", + "agent_context_config", + "__CONTEXT_FILE__", + "_context_file_display", + "Inline agent-context updates", + "v0.12.0", +] + + +@pytest.fixture(scope="module") +def cli_source_texts() -> list[tuple[str, str]]: + """Read every CLI source file once, shared across all parametrized cases.""" + return [ + (str(path.relative_to(PROJECT_ROOT)), path.read_text(encoding="utf-8")) + for path in SRC_ROOT.rglob("*.py") + ] + + +@pytest.mark.parametrize("symbol", FORBIDDEN_SYMBOLS) +def test_symbol_absent_from_cli_source(symbol, cli_source_texts): + offenders = [rel for rel, text in cli_source_texts if symbol in text] + assert not offenders, ( + f"Forbidden agent-context symbol {symbol!r} still present in: {offenders}" + ) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index ab4194efd8..f99d449401 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -13,14 +13,9 @@ import yaml from specify_cli import ( - _load_agent_context_config, - _save_agent_context_config, - load_init_options, save_init_options, ) from specify_cli.agents import CommandRegistrar -from specify_cli.integrations.base import IntegrationBase -from specify_cli.integrations.claude import ClaudeIntegration from tests.conftest import requires_bash @@ -33,19 +28,34 @@ def _write_ext_config(project_root: Path, **overrides: object) -> None: - """Write a minimal agent-context extension config.""" + """Write a minimal agent-context extension config directly. + + The CLI no longer owns the extension config — the bundled extension does — + so tests write it themselves rather than going through any CLI helper. + """ cfg: dict = { "context_file": overrides.get("context_file", ""), "context_files": overrides.get("context_files", []), "context_markers": overrides.get( "context_markers", { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, + "start": "", + "end": "", }, ), } - _save_agent_context_config(project_root, cfg) + path = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(cfg, default_flow_style=False, sort_keys=False), + encoding="utf-8", + ) # ── Bundled extension layout ───────────────────────────────────────────────── @@ -120,19 +130,27 @@ def test_catalog_lists_agent_context_as_bundled(self): 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 _NoContextIntegration(IntegrationBase): - """Minimal integration with no context_file for base-class fallback tests.""" def _install_agent_context_config(project_root: Path, **overrides: object) -> None: _write_ext_config(project_root, **overrides) + # Mirror the real install layout: the extension ships its own + # agent->context-file defaults map alongside the config. Self-seeding + # tests depend on it, so require it to exist and always copy it rather + # than silently skipping when it is missing. + defaults_src = EXT_DIR / "agent-context-defaults.json" + assert defaults_src.is_file(), ( + f"bundled agent-context defaults map missing: {defaults_src}" + ) + defaults_dst = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-defaults.json" + ) + defaults_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(defaults_src, defaults_dst) def _bash_posix_path(path: Path) -> str: @@ -305,484 +323,6 @@ def _run_powershell_agent_context_script_with_env( ) -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): - """Config file exists with context_file but no context_markers key.""" - cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" - / "agent-context-config.yml" - ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("context_file: CLAUDE.md\n", encoding="utf-8") - 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_upsert_uses_configured_context_files(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], - ) - i = _CtxIntegration() - result = i.upsert_context_section( - tmp_path, plan_path="specs/001-foo/plan.md" - ) - assert result == tmp_path / "AGENTS.md" - for name in ("AGENTS.md", "CLAUDE.md"): - text = (tmp_path / name).read_text(encoding="utf-8") - assert IntegrationBase.CONTEXT_MARKER_START in text - assert "specs/001-foo/plan.md" in text - - def test_context_files_deduplicate_with_platform_semantics(self, tmp_path): - duplicate = "agents.md" if os.name == "nt" else "AGENTS.md" - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md", duplicate], - ) - - files = _CtxIntegration()._resolve_context_files(tmp_path) - - assert files == ["AGENTS.md", "CLAUDE.md"] - - def test_empty_context_files_falls_back_to_config_context_file(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=[], - ) - - files = _CtxIntegration()._resolve_context_files(tmp_path) - - assert files == ["AGENTS.md"] - - def test_config_context_file_takes_precedence_over_class_default(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - ) - - i = _CtxIntegration() - result = i.upsert_context_section( - tmp_path, plan_path="specs/001-foo/plan.md" - ) - - assert result == tmp_path / "AGENTS.md" - assert (tmp_path / "AGENTS.md").exists() - assert not (tmp_path / "CLAUDE.md").exists() - - def test_config_context_file_fallback_rejects_invalid_path(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="../outside.md", - context_files=[], - ) - - with pytest.raises(ValueError, match="project-relative|must not contain"): - _CtxIntegration()._resolve_context_files(tmp_path) - - def test_remove_uses_configured_context_files(self, tmp_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], - ) - i = _CtxIntegration() - for name in ("AGENTS.md", "CLAUDE.md"): - (tmp_path / name).write_text( - f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n", - encoding="utf-8", - ) - assert i.remove_context_section(tmp_path) is True - for name in ("AGENTS.md", "CLAUDE.md"): - text = (tmp_path / name).read_text(encoding="utf-8") - assert "body" not in text - assert "head" in text - assert "tail" in text - - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "nested/../../outside.md", - "nested\\outside.md", - str(Path("/tmp/outside.md")), - "C:/tmp/outside.md", - "C:tmp/outside.md", - ], - ) - def test_upsert_rejects_context_files_outside_project(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", bad_path], - ) - i = _CtxIntegration() - with pytest.raises(ValueError, match="project-relative|must not contain"): - i.upsert_context_section(tmp_path) - - assert not (tmp_path / "AGENTS.md").exists() - assert not (tmp_path.parent / "outside.md").exists() - - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "nested\\outside.md", - str(Path("/tmp/outside.md")), - "C:/tmp/outside.md", - "C:tmp/outside.md", - ], - ) - def test_remove_rejects_context_files_outside_project(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", bad_path], - ) - outside = tmp_path.parent / "outside.md" - outside.write_text( - f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\n", - encoding="utf-8", - ) - i = _CtxIntegration() - with pytest.raises(ValueError, match="project-relative|must not contain"): - i.remove_context_section(tmp_path) - - assert "body" in outside.read_text(encoding="utf-8") - - 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_upsert_disabled_ignores_bad_context_files_config(self, tmp_path): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["../disabled-upsert-outside.md"], - ) - i = _CtxIntegration() - assert i.upsert_context_section(tmp_path) is None - assert not (tmp_path.parent / "disabled-upsert-outside.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 - - def test_remove_disabled_ignores_bad_context_files_config(self, tmp_path): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["../disabled-remove-outside.md"], - ) - outside = tmp_path.parent / "disabled-remove-outside.md" - outside.write_text( - f"{IntegrationBase.CONTEXT_MARKER_START}\nbody\n" - f"{IntegrationBase.CONTEXT_MARKER_END}\n", - encoding="utf-8", - ) - i = _CtxIntegration() - assert i.remove_context_section(tmp_path) is False - assert "body" in outside.read_text(encoding="utf-8") - - def test_context_file_display_disabled_uses_config_context_file( - self, tmp_path - ): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["../outside.md"], - ) - i = _CtxIntegration() - assert i._context_file_display(tmp_path) == "AGENTS.md" - - def test_context_file_display_disabled_without_context_file_returns_string( - self, tmp_path - ): - _write_registry(tmp_path, enabled=False) - i = _NoContextIntegration() - assert i._context_file_display(tmp_path) == "" - - -class TestSkillPlaceholderContextValidation: - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "nested/../../outside.md", - "nested\\outside.md", - str(Path("/tmp/outside.md")), - "C:/tmp/outside.md", - "C:tmp/outside.md", - ], - ) - def test_context_files_reject_invalid_config_paths(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["AGENTS.md", bad_path], - ) - - with pytest.raises(ValueError, match="project-relative|must not contain"): - CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - @pytest.mark.parametrize( - "bad_path", - [ - "../outside.md", - "C:tmp/outside.md", - ], - ) - def test_context_file_rejects_invalid_config_path(self, tmp_path, bad_path): - _write_ext_config( - tmp_path, - context_file=bad_path, - context_files=[], - ) - - with pytest.raises(ValueError, match="project-relative|must not contain"): - CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - def test_enabled_extension_rejects_invalid_legacy_init_options_path( - self, tmp_path - ): - save_init_options(tmp_path, {"context_file": "../outside.md"}) - - with pytest.raises(ValueError, match="must not contain"): - CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - def test_disabled_extension_ignores_invalid_context_files(self, tmp_path): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["../outside.md"], - ) - save_init_options(tmp_path, {"context_file": "AGENTS.md"}) - - content = CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - assert content == "Read AGENTS.md" - - def test_disabled_extension_uses_extension_context_file_before_init_options( - self, tmp_path - ): - _write_registry(tmp_path, enabled=False) - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["CLAUDE.md"], - ) - save_init_options(tmp_path, {"context_file": "LEGACY.md"}) - - content = CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - assert content == "Read AGENTS.md" - - def test_context_files_deduplicate_with_platform_semantics(self, tmp_path): - duplicate = "agents.md" if os.name == "nt" else "AGENTS.md" - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=["AGENTS.md", "CLAUDE.md", duplicate], - ) - - content = CommandRegistrar.resolve_skill_placeholders( - "codex", - {}, - "Read __CONTEXT_FILE__", - tmp_path, - ) - - assert content == "Read AGENTS.md, CLAUDE.md" - - class TestBundledUpdaterPathValidation: def test_bundled_script_env_makes_yaml_importable(self, tmp_path): env = _bundled_script_env(tmp_path) @@ -1005,231 +545,329 @@ def test_powershell_script_rejects_junction_escape(self, tmp_path): assert not (outside / "out.md").exists() -# ── Extension config writers ───────────────────────────────────────────────── +# ── CLI does not resolve agent context placeholders ────────────────────────── -class TestExtensionConfigWriters: - def test_clear_init_options_clears_ext_config_context_file(self, tmp_path): - from specify_cli import _clear_init_options_for_integration +class TestSkillPlaceholderContextResolution: + """The CLI no longer resolves any ``__CONTEXT_FILE__`` placeholder. - 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") == "" + Agent context files are owned entirely by the opt-in agent-context + extension, so the CLI neither reads integration metadata nor the + extension config when rendering commands/skills. + """ - 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( + def test_cli_does_not_resolve_context_placeholder(self, tmp_path): + content = CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", 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 + assert content == "Read __CONTEXT_FILE__" - save_init_options( + def test_extension_config_does_not_influence_resolution(self, tmp_path): + # Even a populated extension config must not influence resolution. + _write_ext_config( tmp_path, - { - "integration": "copilot", - "ai": "copilot", - "context_file": "CLAUDE.md", - "context_markers": {"start": "", "end": ""}, - }, + context_file="FROM_CONFIG.md", + context_files=["ALSO_CONFIG.md"], ) - _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_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - _write_ext_config( + content = CommandRegistrar.resolve_skill_placeholders( + "claude", + {}, + "Read __CONTEXT_FILE__", tmp_path, - context_file="AGENTS.md", - context_files=["AGENTS.md", "CLAUDE.md"], ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert cfg["context_files"] == ["AGENTS.md", "CLAUDE.md"] + assert "FROM_CONFIG.md" not in content + assert "ALSO_CONFIG.md" not in content + assert content == "Read __CONTEXT_FILE__" - def test_update_init_options_preserves_empty_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=[], +# ── CLI no longer owns the agent-context extension config ──────────────────── + + +class TestCliDoesNotManageExtensionConfig: + """The Python codebase must not read or write the extension config.""" + + def test_config_helpers_are_removed(self): + import specify_cli + + for name in ( + "_load_agent_context_config", + "_save_agent_context_config", + "_update_agent_context_config_file", + "_AGENT_CTX_EXT_CONFIG", + ): + assert not hasattr(specify_cli, name), name + + def test_no_agent_context_config_symbols_in_source(self): + src = PROJECT_ROOT / "src" / "specify_cli" + offenders = [] + for path in src.rglob("*.py"): + text = path.read_text(encoding="utf-8") + if "agent-context-config" in text or "agent_context_config" in text: + offenders.append(str(path.relative_to(PROJECT_ROOT))) + assert not offenders, offenders + + def test_update_init_options_does_not_create_ext_config(self, tmp_path): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations._helpers import ( + _update_init_options_for_integration, ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert cfg["context_files"] == [] - - def test_update_init_options_normalizes_invalid_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - - _write_ext_config(tmp_path, context_file="AGENTS.md") - cfg = _load_agent_context_config(tmp_path) - cfg["context_files"] = "AGENTS.md" - _save_agent_context_config(tmp_path, cfg) - - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert cfg["context_files"] == [] - - def test_clear_init_options_clears_context_files(self, tmp_path): - from specify_cli import _clear_init_options_for_integration - - save_init_options( - tmp_path, - {"integration": "claude", "ai": "claude"}, + + _update_init_options_for_integration( + tmp_path, INTEGRATION_REGISTRY["claude"], script_type="sh" ) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], + + cfg = ( + tmp_path + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - _clear_init_options_for_integration(tmp_path, "claude") - cfg = _load_agent_context_config(tmp_path) - assert cfg.get("context_file") == "" - assert "context_files" not in cfg + assert not cfg.exists() - def test_update_init_options_preserves_custom_markers(self, tmp_path): - from specify_cli import _update_init_options_for_integration + def test_clear_init_options_does_not_create_ext_config(self, tmp_path): + from specify_cli.integrations._helpers import ( + _clear_init_options_for_integration, + ) - _write_ext_config( - tmp_path, - context_file="", - context_markers={"start": "", "end": ""}, + save_init_options(tmp_path, {"integration": "claude", "ai": "claude"}) + _clear_init_options_for_integration(tmp_path, "claude") + + cfg = ( + tmp_path + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i) - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_markers"] == {"start": "", "end": ""} + assert not cfg.exists() - 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": ""}, +# ── Extension self-seeds its target from the active integration ────────────── + + +class TestExtensionSelfSeed: + """When its own config declares no target, the bundled extension derives + the context file from the active integration using its OWN bundled + agent->context-file defaults map (no Specify CLI dependency).""" + + @requires_bash + def test_bash_script_self_seeds_from_active_integration(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + # Config present but empty — no context_file / context_files. + _install_agent_context_config(project, context_file="", context_files=[]) + # Active integration recorded in init-options.json (codex -> AGENTS.md). + save_init_options(project, {"integration": "codex", "ai": "codex"}) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + assert "agent-context: updated AGENTS.md" in (result.stderr + result.stdout) + assert (project / "AGENTS.md").exists() + assert "" in ( + project / "AGENTS.md" + ).read_text(encoding="utf-8") + + @requires_bash + def test_bash_script_nothing_to_do_without_integration(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file="", context_files=[]) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + assert "nothing to do" in (result.stderr + result.stdout) + + +_MDC_CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" + + +class TestMdcFrontmatter: + """Cursor-style ``.mdc`` targets must carry ``alwaysApply: true`` frontmatter + so the rule file is auto-loaded; non-``.mdc`` targets must not gain any.""" + + @requires_bash + def test_bash_script_prepends_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8") + assert text.startswith("---\nalwaysApply: true\n---\n") + assert "" in text + + @requires_bash + def test_bash_script_mdc_frontmatter_is_idempotent(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + + _run_bash_agent_context_script(project) + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8") + assert text.count("alwaysApply: true") == 1 + + @requires_bash + def test_bash_script_repairs_existing_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + target = project / _MDC_CONTEXT_FILE + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + "---\ndescription: My rules\nalwaysApply: false\n---\n\nUser notes\n", + encoding="utf-8", ) - # Re-running init updates context_file but must preserve markers - _update_agent_context_config_file( - tmp_path, "CLAUDE.md", preserve_markers=True + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = target.read_text(encoding="utf-8") + assert "alwaysApply: true" in text + assert "alwaysApply: false" not in text + assert "description: My rules" in text + assert "User notes" in text + + @requires_bash + def test_bash_script_skips_frontmatter_for_non_mdc(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file="AGENTS.md") + + result = _run_bash_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / "AGENTS.md").read_text(encoding="utf-8") + assert "alwaysApply" not in text + assert text.startswith("") + + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_prepends_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + + result = _run_powershell_agent_context_script(project) + + assert result.returncode == 0, result.stderr + result.stdout + text = (project / _MDC_CONTEXT_FILE).read_text(encoding="utf-8") + assert text.startswith("---\nalwaysApply: true\n---\n") + assert "" in text + + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_repairs_existing_mdc_frontmatter(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file=_MDC_CONTEXT_FILE) + target = project / _MDC_CONTEXT_FILE + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + "---\ndescription: My rules\nalwaysApply: false\n---\n\nUser notes\n", + encoding="utf-8", ) - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_markers"] == { - "start": "", - "end": "", - } + result = _run_powershell_agent_context_script(project) -# ── Deprecation warning on upsert ──────────────────────────────────────────── + assert result.returncode == 0, result.stderr + result.stdout + text = target.read_text(encoding="utf-8") + assert "alwaysApply: true" in text + assert "alwaysApply: false" not in text + assert "description: My rules" in text + assert "User notes" in text + @pytest.mark.skipif(POWERSHELL is None, reason="PowerShell not available") + def test_powershell_script_skips_frontmatter_for_non_mdc(self, tmp_path): + project = tmp_path / "project" + project.mkdir() + _install_agent_context_config(project, context_file="AGENTS.md") -class TestDeprecationWarning: - def test_upsert_emits_deprecation_warning(self, tmp_path, capsys): - """upsert_context_section must emit a deprecation notice on stdout.""" - from tests.conftest import strip_ansi + result = _run_powershell_agent_context_script(project) - i = _CtxIntegration() - _write_ext_config(tmp_path, context_file="CLAUDE.md") - i.upsert_context_section(tmp_path) - captured = capsys.readouterr() - plain = strip_ansi(captured.out) - assert "Deprecation" in plain - assert "v0.12.0" in plain - assert "agent-context" in plain + assert result.returncode == 0, result.stderr + result.stdout + text = (project / "AGENTS.md").read_text(encoding="utf-8") + assert "alwaysApply" not in text + assert text.startswith("") + + +_LEGACY_CONTEXT = ( + "# CLAUDE.md\n\n" + "Some user notes.\n\n" + "\n" + "Legacy managed section written by an older Spec Kit version.\n" + "\n\n" + "More user notes.\n" +) - def test_upsert_no_warning_when_disabled(self, tmp_path, capsys): - """No deprecation warning when agent-context extension is disabled.""" - _write_registry(tmp_path, enabled=False) - i = _CtxIntegration() - i.upsert_context_section(tmp_path) - captured = capsys.readouterr() - assert "Deprecation" not in captured.out +class TestBackwardCompatibility: + """Legacy projects must keep working; the CLI never touches their artifacts.""" -# ── Corrupt / invalid extension config ─────────────────────────────────────── + def _seed_legacy_project(self, project_root: Path) -> Path: + ctx = project_root / "CLAUDE.md" + ctx.write_text(_LEGACY_CONTEXT, encoding="utf-8") + _write_ext_config(project_root, context_file="CLAUDE.md") + save_init_options(project_root, {"integration": "claude", "ai": "claude"}) + return ctx + def test_integration_setup_leaves_legacy_artifacts_untouched(self, tmp_path): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations.manifest import IntegrationManifest -class TestCorruptExtensionConfig: - def test_marker_resolution_with_corrupt_yaml(self, tmp_path): - """Corrupt YAML in agent-context-config.yml falls back to defaults.""" + project = tmp_path / "legacy" + project.mkdir() + ctx = self._seed_legacy_project(project) cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" + project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text(": invalid: yaml: {{{\n", encoding="utf-8") - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END - - def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path): - """upsert_context_section still works when config YAML is corrupt.""" - cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" - / "agent-context-config.yml" + before_ctx = ctx.read_text(encoding="utf-8") + before_cfg = cfg_path.read_text(encoding="utf-8") + + integration = INTEGRATION_REGISTRY["claude"] + m = IntegrationManifest("claude", project) + integration.setup(project, m) + + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg + + def test_integration_switch_and_uninstall_leave_legacy_artifacts_untouched( + self, tmp_path + ): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations._helpers import ( + _clear_init_options_for_integration, + _update_init_options_for_integration, ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("not valid yaml: {{{\n", encoding="utf-8") - i = _CtxIntegration() - 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_marker_resolution_with_non_dict_yaml(self, tmp_path): - """Config file containing a scalar (not a dict) falls back to defaults.""" + + project = tmp_path / "legacy" + project.mkdir() + ctx = self._seed_legacy_project(project) cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" + project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("just a string\n", encoding="utf-8") - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END + before_ctx = ctx.read_text(encoding="utf-8") + before_cfg = cfg_path.read_text(encoding="utf-8") + + # Switch to a different integration. + _update_init_options_for_integration( + project, INTEGRATION_REGISTRY["gemini"], script_type="sh" + ) + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg + + # Uninstall. + _clear_init_options_for_integration(project, "gemini") + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py index 54f59e23a7..833e272b27 100644 --- a/tests/integrations/conftest.py +++ b/tests/integrations/conftest.py @@ -20,4 +20,3 @@ class StubIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "STUB.md" diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 47f9d09059..9ec7d236c1 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -43,7 +43,6 @@ def test_key_and_config(self): assert i.key == "stub" assert i.config["name"] == "Stub Agent" assert i.registrar_config["format"] == "markdown" - assert i.context_file == "STUB.md" def test_options_default_empty(self): assert StubIntegration.options() == [] diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index be8aad2326..25d4a7c16a 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -77,23 +77,17 @@ 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" - # context_file lives in the agent-context extension config, not init-options.json + # init must not leave any legacy agent-context keys in init-options.json assert "context_file" not in opts - import yaml as _yaml + # agent-context is fully opt-in: init must not install it or write its config 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 not ext_cfg_path.exists(), "init must not create the agent-context extension config" assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() - # Context section should be upserted into the copilot instructions file - ctx_file = project / ".github" / "copilot-instructions.md" - assert ctx_file.exists() - ctx_content = ctx_file.read_text(encoding="utf-8") - assert "" in ctx_content - assert "" in ctx_content + # init must not create or manage the agent context file + assert not (project / ".github" / "copilot-instructions.md").exists() shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" assert shared_manifest.exists() @@ -1270,7 +1264,6 @@ class BrokenIntegration(IntegrationBase): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "BROKEN.md" def setup(self, project_root, manifest, **kwargs): raise OSError("setup exploded\nwith context") diff --git a/tests/integrations/test_extra_args.py b/tests/integrations/test_extra_args.py index d192e140fb..e329c88801 100644 --- a/tests/integrations/test_extra_args.py +++ b/tests/integrations/test_extra_args.py @@ -37,7 +37,6 @@ class _ClaudeStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "CLAUDE.md" class _KiroCliStub(SkillsIntegration): @@ -58,7 +57,6 @@ class _KiroCliStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "KIRO.md" class _NoCliStub(SkillsIntegration): @@ -79,7 +77,6 @@ class _NoCliStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "NOCLI.md" class _MarkdownAgentStub(MarkdownIntegration): @@ -102,7 +99,6 @@ class _MarkdownAgentStub(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "MDAGENT.md" class _TomlAgentStub(TomlIntegration): @@ -124,7 +120,6 @@ class _TomlAgentStub(TomlIntegration): "args": "$ARGUMENTS", "extension": ".toml", } - context_file = "TOMLAGENT.md" @pytest.fixture(autouse=True) diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index b64a609e15..6ab66a0cbe 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -10,7 +10,6 @@ class TestAgyIntegration(SkillsIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".agents/skills" - CONTEXT_FILE = "AGENTS.md" def test_options_include_skills_flag(self): """Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout.""" diff --git a/tests/integrations/test_integration_amp.py b/tests/integrations/test_integration_amp.py index a36dd47136..f0689c21f5 100644 --- a/tests/integrations/test_integration_amp.py +++ b/tests/integrations/test_integration_amp.py @@ -8,4 +8,3 @@ class TestAmpIntegration(MarkdownIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".agents/commands" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_auggie.py b/tests/integrations/test_integration_auggie.py index e4033a23e8..3cf4d09bbc 100644 --- a/tests/integrations/test_integration_auggie.py +++ b/tests/integrations/test_integration_auggie.py @@ -8,4 +8,3 @@ class TestAuggieIntegration(MarkdownIntegrationTests): FOLDER = ".augment/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".augment/commands" - CONTEXT_FILE = ".augment/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index b0b408a995..886dfb912f 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard MarkdownIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``MarkdownIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``MarkdownIntegrationTests``. """ import os @@ -21,14 +21,12 @@ class MarkdownIntegrationTests: FOLDER: str — e.g. ".claude/" COMMANDS_SUBDIR: str — e.g. "commands" REGISTRAR_DIR: str — e.g. ".claude/commands" - CONTEXT_FILE: str — e.g. "CLAUDE.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -56,10 +54,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "$ARGUMENTS" assert i.registrar_config["extension"] == ".md" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -101,19 +95,18 @@ def test_templates_are_processed(self, tmp_path): assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__" assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference this integration's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") assert plan_file.exists(), f"Plan file {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" - ) assert "__CONTEXT_FILE__" not in content, ( f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" ) @@ -149,35 +142,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - # Add user content around the section - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -225,35 +215,10 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*")) assert len(commands) > 0, f"No command files in {cmd_dir}" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / f"opts-{self.KEY}" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - 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 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 ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", ] @@ -293,19 +258,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index e903d918e2..d88b786757 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard SkillsIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``SkillsIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``SkillsIntegrationTests``. Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely, adapted for the ``speckit-/SKILL.md`` skills layout. @@ -26,14 +26,12 @@ class SkillsIntegrationTests: FOLDER: str — e.g. ".agents/" COMMANDS_SUBDIR: str — e.g. "skills" REGISTRAR_DIR: str — e.g. ".agents/skills" - CONTEXT_FILE: str — e.g. "AGENTS.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -61,10 +59,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "$ARGUMENTS" assert i.registrar_config["extension"] == "/SKILL.md" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -222,19 +216,18 @@ def test_skill_body_has_content(self, tmp_path): body = parts[2].strip() if len(parts) >= 3 else "" assert len(body) > 0, f"{f} has empty body" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan skill must reference this integration's context file.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path): + """The generated plan skill must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan skill must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md" assert plan_file.exists(), f"Plan skill {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan skill should reference {i.context_file!r} but it was not found" - ) assert "__CONTEXT_FILE__" not in content, ( "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" ) @@ -283,34 +276,32 @@ def test_pre_existing_skills_not_removed(self, tmp_path): assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -356,9 +347,9 @@ def test_integration_flag_creates_files(self, tmp_path): skills_dir = i.skills_dest(project) assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml + def test_init_does_not_create_agent_context_config(self, tmp_path): + """agent-context is opt-in: init must not auto-install the extension + or write its config.""" from typer.testing import CliRunner from specify_cli import app @@ -375,11 +366,7 @@ def test_init_options_includes_context_file(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 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 ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) + assert not ext_cfg_path.exists() # -- IntegrationOption ------------------------------------------------ @@ -406,8 +393,6 @@ def _expected_files(self, script_variant: str) -> list[str]: # Skill files (core commands) for cmd in self._SKILL_COMMANDS: files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md") - # Extension-installed skill (agent-context) - files.append(f"{skills_prefix}/speckit-agent-context-update/SKILL.md") # Integration metadata files += [ ".specify/init-options.json", @@ -446,18 +431,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/workflows/speckit/workflow.yml", ".specify/workflows/workflow-registry.json", ] - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index a9b933875a..68f5fd075a 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard TomlIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``TomlIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``TomlIntegrationTests``. Mirrors ``MarkdownIntegrationTests`` closely — same test structure, adapted for TOML output format. @@ -27,14 +27,12 @@ class TomlIntegrationTests: FOLDER: str — e.g. ".gemini/" COMMANDS_SUBDIR: str — e.g. "commands" REGISTRAR_DIR: str — e.g. ".gemini/commands" - CONTEXT_FILE: str — e.g. "GEMINI.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -62,10 +60,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "{{args}}" assert i.registrar_config["extension"] == ".toml" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -311,19 +305,18 @@ def test_toml_is_valid(self, tmp_path): raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference this integration's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") assert plan_file.exists(), f"Plan file {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" - ) assert "__CONTEXT_FILE__" not in content, ( f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" ) @@ -359,34 +352,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -454,35 +445,10 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.toml")) assert len(commands) > 0, f"No command files in {cmd_dir}" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / f"opts-{self.KEY}" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - 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 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 ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", @@ -544,19 +510,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 646e21607d..74cdab2d7d 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard YamlIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``YamlIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``YamlIntegrationTests``. Mirrors ``TomlIntegrationTests`` closely — same test structure, adapted for YAML recipe output format. @@ -26,14 +26,12 @@ class YamlIntegrationTests: FOLDER: str — e.g. ".goose/" COMMANDS_SUBDIR: str — e.g. "recipes" REGISTRAR_DIR: str — e.g. ".goose/recipes" - CONTEXT_FILE: str — e.g. "AGENTS.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -61,10 +59,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "{{args}}" assert i.registrar_config["extension"] == ".yaml" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -190,19 +184,18 @@ def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "scripts:" not in parsed["prompt"] assert "---" not in parsed["prompt"] - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference this integration's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") assert plan_file.exists(), f"Plan file {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" - ) assert "__CONTEXT_FILE__" not in content, ( f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" ) @@ -238,34 +231,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -333,35 +324,10 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.yaml")) assert len(commands) > 0, f"No command files in {cmd_dir}" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / f"opts-{self.KEY}" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - 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 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 ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", @@ -423,19 +389,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py index 1562f0100c..8e0e72f0bd 100644 --- a/tests/integrations/test_integration_bob.py +++ b/tests/integrations/test_integration_bob.py @@ -8,4 +8,3 @@ class TestBobIntegration(MarkdownIntegrationTests): FOLDER = ".bob/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".bob/commands" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index c7ecef95d0..01ef2662b5 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,6 +1,5 @@ """Tests for ClaudeIntegration.""" -import codecs import json import os from pathlib import Path @@ -34,10 +33,6 @@ def test_registrar_config_uses_skill_layout(self): assert integration.registrar_config["args"] == "$ARGUMENTS" assert integration.registrar_config["extension"] == "/SKILL.md" - def test_context_file(self): - integration = get_integration("claude") - assert integration.context_file == "CLAUDE.md" - def test_setup_creates_skill_files(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) @@ -76,57 +71,30 @@ def test_render_skill_unicode(self): ) assert "Prüfe Konformität" in rendered - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """The CLI no longer manages the agent context file — that is owned by + the opt-in agent-context extension. Setup must not create or touch it.""" integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) integration.setup(tmp_path, manifest, script_type="sh") - ctx_path = tmp_path / integration.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text - def test_upsert_context_section_strips_bom(self, tmp_path): - """Existing context file with UTF-8 BOM must be cleaned up on upsert.""" + def test_teardown_does_not_touch_existing_context_file(self, tmp_path): + """A user-authored context file is left intact on teardown.""" integration = get_integration("claude") - ctx_path = tmp_path / integration.context_file - - # Write a file that starts with a UTF-8 BOM (as the old PowerShell script did) - bom = codecs.BOM_UTF8 - ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n") + ctx_path = tmp_path / "CLAUDE.md" + original = "# CLAUDE.md\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") - integration.upsert_context_section(tmp_path) - - result = ctx_path.read_bytes() - assert not result.startswith(bom), "BOM must be stripped after upsert" - content = result.decode("utf-8") - assert "" in content - assert "Some existing content." in content - - def test_remove_context_section_strips_bom(self, tmp_path): - """remove_context_section must clean BOM from context file on Windows-authored files.""" - integration = get_integration("claude") - ctx_path = tmp_path / integration.context_file - - marker_content = ( - "# CLAUDE.md\n\n" - "\n" - "For additional context about technologies to be used, project structure,\n" - "shell commands, and other important information, read the current plan\n" - "\n" - ) - ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8")) - - result = integration.remove_context_section(tmp_path) + manifest = IntegrationManifest("claude", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + integration.teardown(tmp_path, manifest) - assert result is True - assert ctx_path.exists(), "File should exist (non-empty content remains)" - remaining = ctx_path.read_bytes() - assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove" - assert b"", - "end": "", - }, - }, - ) integration = get_integration("codex") manifest = IntegrationManifest("codex", target) @@ -53,43 +40,31 @@ def test_plan_skill_references_configured_context_files(self, tmp_path): plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" content = plan_skill.read_text(encoding="utf-8") - assert "AGENTS.md, CLAUDE.md" in content assert "__CONTEXT_FILE__" not in content - def test_plan_skill_ignores_context_files_when_agent_context_disabled( - self, tmp_path - ): - """Disabled agent-context must not leak stale context_files into commands.""" - from specify_cli import _save_agent_context_config + def test_plan_skill_ignores_extension_config(self, tmp_path): + """The extension config must not influence rendered commands: the CLI + no longer reads any context-file metadata when rendering.""" + import yaml target = tmp_path / "test-proj" target.mkdir() - registry = target / ".specify" / "extensions" / ".registry" - registry.parent.mkdir(parents=True, exist_ok=True) - registry.write_text( - """ -{ - "schema_version": "1.0", - "extensions": { - "agent-context": { - "version": "1.0.0", - "enabled": false - } - } -} -""".strip(), - encoding="utf-8", + ext_cfg = ( + target + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - _save_agent_context_config( - target, - { - "context_file": "AGENTS.md", - "context_files": ["../outside.md", "CLAUDE.md"], - "context_markers": { - "start": "", - "end": "", - }, - }, + ext_cfg.parent.mkdir(parents=True, exist_ok=True) + ext_cfg.write_text( + yaml.safe_dump( + { + "context_file": "FROM_CONFIG.md", + "context_files": ["FROM_CONFIG.md", "ALSO_CONFIG.md"], + } + ), + encoding="utf-8", ) integration = get_integration("codex") @@ -98,9 +73,8 @@ def test_plan_skill_ignores_context_files_when_agent_context_disabled( plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" content = plan_skill.read_text(encoding="utf-8") - assert "AGENTS.md, CLAUDE.md" not in content - assert "../outside.md" not in content - assert "AGENTS.md" in content + assert "FROM_CONFIG.md" not in content + assert "ALSO_CONFIG.md" not in content assert "__CONTEXT_FILE__" not in content diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 6b7cc7c13f..8a7c8ec995 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -17,7 +17,6 @@ def test_copilot_key_and_config(self): assert copilot.config["folder"] == ".github/" assert copilot.config["commands_subdir"] == "agents" assert copilot.registrar_config["extension"] == ".agent.md" - assert copilot.context_file == ".github/copilot-instructions.md" def test_command_filename_agent_md(self): copilot = get_integration("copilot") @@ -162,8 +161,9 @@ def test_specify_agent_resolves_active_spec_template(self, tmp_path): assert "Copy `.specify/templates/spec-template.md`" not in content assert "Load `.specify/templates/spec-template.md`" not in content - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference copilot's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" from specify_cli.integrations.copilot import CopilotIntegration copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) @@ -171,9 +171,6 @@ def test_plan_references_correct_context_file(self, tmp_path): plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert copilot.context_file in content, ( - f"Plan command should reference {copilot.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_complete_file_inventory_sh(self, tmp_path): @@ -193,7 +190,6 @@ def test_complete_file_inventory_sh(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - ".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", @@ -204,7 +200,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/agents/speckit.specify.agent.md", ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", - ".github/prompts/speckit.agent-context.update.prompt.md", ".github/prompts/speckit.analyze.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", @@ -216,15 +211,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", - ".github/copilot-instructions.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -265,7 +251,6 @@ def test_complete_file_inventory_ps(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - ".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", @@ -276,7 +261,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/agents/speckit.specify.agent.md", ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", - ".github/prompts/speckit.agent-context.update.prompt.md", ".github/prompts/speckit.analyze.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", @@ -288,15 +272,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", - ".github/copilot-instructions.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -537,14 +512,14 @@ def test_skill_body_has_content(self, tmp_path): body = parts[2].strip() if len(parts) >= 3 else "" assert len(body) > 0, f"{f} has empty body" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan skill must reference copilot's context file.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path): + """The core plan skill must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" copilot = self._make_copilot() self._setup_skills(copilot, tmp_path) plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert copilot.context_file in content assert "__CONTEXT_FILE__" not in content # -- Manifest tracking ------------------------------------------------ @@ -603,14 +578,13 @@ def test_build_command_invocation_default_mode(self): # -- Context section --------------------------------------------------- - def test_skills_setup_upserts_context_section(self, tmp_path): + def test_skills_setup_does_not_write_context_section(self, tmp_path): copilot = self._make_copilot() self._setup_skills(copilot, tmp_path) - ctx_path = tmp_path / copilot.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text # -- CLI integration test --------------------------------------------- @@ -659,20 +633,8 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): assert result.exit_code == 0, f"init failed: {result.output}" actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - # Skill files (core + extension-installed agent-context command) + # Skill files (core commands) *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], - ".github/skills/speckit-agent-context-update/SKILL.md", - # Context file - ".github/copilot-instructions.md", - # Bundled agent-context extension - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", # Integration metadata ".specify/init-options.json", ".specify/integration.json", diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 8165464655..32318dc90f 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -1,10 +1,8 @@ """Tests for CursorAgentIntegration.""" -from pathlib import Path from urllib.parse import urlparse from specify_cli.integrations import get_integration -from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_skills import SkillsIntegrationTests @@ -14,82 +12,6 @@ class TestCursorAgentIntegration(SkillsIntegrationTests): FOLDER = ".cursor/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".cursor/skills" - CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" - - -class TestCursorMdcFrontmatter: - """Verify .mdc frontmatter handling in upsert/remove context section.""" - - def _setup(self, tmp_path: Path): - i = get_integration("cursor-agent") - m = IntegrationManifest("cursor-agent", tmp_path) - return i, m - - def test_new_mdc_gets_frontmatter(self, tmp_path): - """A freshly created .mdc file includes alwaysApply: true.""" - i, m = self._setup(tmp_path) - i.setup(tmp_path, m) - ctx = (tmp_path / i.context_file).read_text(encoding="utf-8") - assert ctx.startswith("---\n") - assert "alwaysApply: true" in ctx - - def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path): - """An existing .mdc without frontmatter gets it added.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text("# User rules\n", encoding="utf-8") - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert content.lstrip().startswith("---") - assert "alwaysApply: true" in content - assert "# User rules" in content - - def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path): - """An existing .mdc with custom frontmatter is preserved.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text( - "---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n", - encoding="utf-8", - ) - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert "alwaysApply: true" in content - assert "customKey: hello" in content - assert "" in content - - def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path): - """An .mdc with alwaysApply: false gets corrected.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text( - "---\nalwaysApply: false\n---\n\n# Rules\n", - encoding="utf-8", - ) - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert "alwaysApply: true" in content - assert "alwaysApply: false" not in content - - def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path): - """Repeated upserts don't duplicate frontmatter.""" - i, m = self._setup(tmp_path) - i.upsert_context_section(tmp_path) - i.upsert_context_section(tmp_path) - content = (tmp_path / i.context_file).read_text(encoding="utf-8") - assert content.count("alwaysApply") == 1 - - def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path): - """Removing the section from a Speckit-only .mdc deletes the file.""" - i, m = self._setup(tmp_path) - i.upsert_context_section(tmp_path) - ctx_path = tmp_path / i.context_file - assert ctx_path.exists() - i.remove_context_section(tmp_path) - assert not ctx_path.exists() class TestCursorAgentInitFlow: diff --git a/tests/integrations/test_integration_devin.py b/tests/integrations/test_integration_devin.py index 4acbdac618..52c2981bf1 100644 --- a/tests/integrations/test_integration_devin.py +++ b/tests/integrations/test_integration_devin.py @@ -8,7 +8,6 @@ class TestDevinIntegration(SkillsIntegrationTests): FOLDER = ".devin/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".devin/skills" - CONTEXT_FILE = "AGENTS.md" class TestDevinBuildExecArgs: diff --git a/tests/integrations/test_integration_firebender.py b/tests/integrations/test_integration_firebender.py index b42d2fbf9d..6de66f4d07 100644 --- a/tests/integrations/test_integration_firebender.py +++ b/tests/integrations/test_integration_firebender.py @@ -11,7 +11,6 @@ class TestFirebenderIntegration(MarkdownIntegrationTests): FOLDER = ".firebender/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".firebender/commands" - CONTEXT_FILE = ".firebender/rules/specify-rules.mdc" # Firebender reads custom slash commands from ``.firebender/commands/*.mdc``, # so this integration uses the ``.mdc`` extension instead of the ``.md`` diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index f63afb71e2..26ac7a9931 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -55,7 +55,6 @@ def test_forge_key_and_config(self): assert forge.config["requires_cli"] is True assert forge.registrar_config["args"] == "{{parameters}}" assert forge.registrar_config["extension"] == ".md" - assert forge.context_file == "AGENTS.md" def test_command_filename_md(self): forge = get_integration("forge") @@ -73,16 +72,15 @@ def test_setup_creates_md_files(self, tmp_path): for f in command_files: assert f.name.endswith(".md") - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) forge.setup(tmp_path, m) - ctx_path = tmp_path / forge.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text def test_all_created_files_tracked_in_manifest(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration @@ -164,8 +162,9 @@ def test_templates_are_processed(self, tmp_path): "Forge requires hyphen notation (/speckit-) for ZSH compatibility" ) - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference forge's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) @@ -173,9 +172,6 @@ def test_plan_references_correct_context_file(self, tmp_path): plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert forge.context_file in content, ( - f"Plan command should reference {forge.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_forge_specific_transformations(self, tmp_path): diff --git a/tests/integrations/test_integration_gemini.py b/tests/integrations/test_integration_gemini.py index 9be5985e29..1649b4f7c3 100644 --- a/tests/integrations/test_integration_gemini.py +++ b/tests/integrations/test_integration_gemini.py @@ -8,4 +8,3 @@ class TestGeminiIntegration(TomlIntegrationTests): FOLDER = ".gemini/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".gemini/commands" - CONTEXT_FILE = "GEMINI.md" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index fe935cc98b..1c5edc2efc 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -31,10 +31,6 @@ def test_config_requires_cli_false(self): i = get_integration("generic") assert i.config["requires_cli"] is False - def test_context_file_is_agents_md(self): - i = get_integration("generic") - assert i.context_file == "AGENTS.md" - # -- Options ---------------------------------------------------------- def test_options_include_commands_dir(self): @@ -161,28 +157,24 @@ def test_different_commands_dirs(self, tmp_path): # -- Context section --------------------------------------------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference generic's context file.""" + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text + + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_plan_defines_quickstart_as_validation_guide(self, tmp_path): @@ -256,28 +248,6 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): # Generic requires --commands-dir via --integration-options assert result.exit_code != 0 - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the generic integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / "opts-generic" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", "generic", - "--integration-options=--commands-dir .myagent/commands", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - 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 --integration-options=--commands-dir ... --script sh.""" @@ -302,7 +272,6 @@ def test_complete_file_inventory_sh(self, tmp_path): for p in project.rglob("*") if p.is_file() and ".git" not in p.parts ) expected = sorted([ - "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -313,14 +282,6 @@ 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.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", @@ -367,7 +328,6 @@ def test_complete_file_inventory_ps(self, tmp_path): for p in project.rglob("*") if p.is_file() and ".git" not in p.parts ) expected = sorted([ - "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -378,14 +338,6 @@ 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.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py index 8415081d53..104b7188d0 100644 --- a/tests/integrations/test_integration_goose.py +++ b/tests/integrations/test_integration_goose.py @@ -12,7 +12,6 @@ class TestGooseIntegration(YamlIntegrationTests): FOLDER = ".goose/" COMMANDS_SUBDIR = "recipes" REGISTRAR_DIR = ".goose/recipes" - CONTEXT_FILE = "AGENTS.md" def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path): # “If a generated Goose recipe uses {{args}} in its prompt, it diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py index 89e74c2b38..521a310cb8 100644 --- a/tests/integrations/test_integration_hermes.py +++ b/tests/integrations/test_integration_hermes.py @@ -30,7 +30,6 @@ class TestHermesIntegration(SkillsIntegrationTests): FOLDER = ".hermes/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = "~/.hermes/skills" - CONTEXT_FILE = "AGENTS.md" # -- Hermes-specific setup: skills go to ~/.hermes/skills/ ------------- @@ -72,23 +71,19 @@ def test_setup_writes_to_correct_directory(self, tmp_path, monkeypatch): """Override: Hermes writes to global, not project-local.""" self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch) - def test_plan_references_correct_context_file(self, tmp_path, monkeypatch): - """Plan skill goes to global dir, but we check it still references AGENTS.md.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path, monkeypatch): + """The core plan skill must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" home = _fake_home(tmp_path) monkeypatch.setattr(Path, "home", lambda: home) i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) # Find the plan skill in global ~/.hermes/skills/ plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md" assert plan_file.exists(), f"Plan skill {plan_file} not created globally" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan skill should reference {i.context_file!r} but it was not found" - ) assert "__CONTEXT_FILE__" not in content, ( "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" ) diff --git a/tests/integrations/test_integration_iflow.py b/tests/integrations/test_integration_iflow.py index ea2f5ef97a..89501f8edf 100644 --- a/tests/integrations/test_integration_iflow.py +++ b/tests/integrations/test_integration_iflow.py @@ -8,4 +8,3 @@ class TestIflowIntegration(MarkdownIntegrationTests): FOLDER = ".iflow/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".iflow/commands" - CONTEXT_FILE = "IFLOW.md" diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index 2b924ce434..2226e3d544 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -8,4 +8,3 @@ class TestJunieIntegration(MarkdownIntegrationTests): FOLDER = ".junie/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".junie/commands" - CONTEXT_FILE = ".junie/AGENTS.md" diff --git a/tests/integrations/test_integration_kilocode.py b/tests/integrations/test_integration_kilocode.py index 8e441c0833..86e6520a50 100644 --- a/tests/integrations/test_integration_kilocode.py +++ b/tests/integrations/test_integration_kilocode.py @@ -8,4 +8,3 @@ class TestKilocodeIntegration(MarkdownIntegrationTests): FOLDER = ".kilocode/" COMMANDS_SUBDIR = "workflows" REGISTRAR_DIR = ".kilocode/workflows" - CONTEXT_FILE = ".kilocode/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 2f752f66e1..48e4daa553 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -6,7 +6,6 @@ from specify_cli.integrations import get_integration from specify_cli.integrations.kimi import ( - _migrate_legacy_kimi_context_file, _migrate_legacy_kimi_dotted_skills, _migrate_legacy_kimi_skills_dir, ) @@ -36,7 +35,6 @@ class TestKimiIntegration(SkillsIntegrationTests): FOLDER = ".kimi-code/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".kimi-code/skills" - CONTEXT_FILE = "AGENTS.md" class TestKimiOptions: @@ -165,168 +163,6 @@ def test_setup_with_migrate_legacy_option(self, tmp_path): assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists() -class TestKimiContextFileMigration: - """KIMI.md → AGENTS.md migration under --migrate-legacy.""" - - def test_setup_migrate_legacy_moves_kimi_md_user_content(self, tmp_path): - i = get_integration("kimi") - - kimi_md = tmp_path / "KIMI.md" - kimi_md.write_text( - "# Project context\n\n" - "\n" - "old managed section\n" - "\n\n" - "Keep this user note.\n" - ) - - m = IntegrationManifest("kimi", tmp_path) - i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) - - agents_md = tmp_path / "AGENTS.md" - assert agents_md.exists() - content = agents_md.read_text(encoding="utf-8") - assert "Keep this user note." in content - assert "old managed section" not in content - assert "" in content - assert not kimi_md.exists() - - def test_setup_migrate_legacy_removes_empty_kimi_md(self, tmp_path): - i = get_integration("kimi") - - kimi_md = tmp_path / "KIMI.md" - kimi_md.write_text( - "\n" - "only managed section\n" - "\n" - ) - - m = IntegrationManifest("kimi", tmp_path) - i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) - - assert (tmp_path / "AGENTS.md").exists() - assert not kimi_md.exists() - - def test_setup_migrate_legacy_appends_to_existing_agents_md(self, tmp_path): - i = get_integration("kimi") - - agents_md = tmp_path / "AGENTS.md" - agents_md.write_text("# Existing AGENTS.md\n\nExisting note.\n") - - kimi_md = tmp_path / "KIMI.md" - kimi_md.write_text("# Kimi context\n\nKimi-specific note.\n") - - m = IntegrationManifest("kimi", tmp_path) - i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) - - content = agents_md.read_text(encoding="utf-8") - assert "Existing note." in content - assert "Kimi-specific note." in content - assert "" in content - assert not kimi_md.exists() - - def test_setup_migrate_legacy_uses_custom_context_markers(self, tmp_path): - """Migration respects context_markers from agent-context extension config.""" - i = get_integration("kimi") - - config_dir = tmp_path / ".specify" / "extensions" / "agent-context" - config_dir.mkdir(parents=True) - (config_dir / "agent-context-config.yml").write_text( - "context_file: AGENTS.md\n" - "context_markers:\n" - " start: ''\n" - " end: ''\n" - ) - - kimi_md = tmp_path / "KIMI.md" - kimi_md.write_text( - "# Project context\n\n" - "\n" - "old managed section\n" - "\n\n" - "Keep this user note.\n" - ) - - m = IntegrationManifest("kimi", tmp_path) - i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) - - agents_md = tmp_path / "AGENTS.md" - assert agents_md.exists() - content = agents_md.read_text(encoding="utf-8") - assert "Keep this user note." in content - assert "old managed section" not in content - assert "" in content - assert "" in content - assert "" not in content - assert not kimi_md.exists() - - def test_setup_migrate_legacy_skipped_when_agent_context_disabled( - self, tmp_path - ): - """A disabled agent-context extension opts out of KIMI.md migration.""" - i = get_integration("kimi") - - registry = tmp_path / ".specify" / "extensions" / ".registry" - registry.parent.mkdir(parents=True) - registry.write_text('{"extensions": {"agent-context": {"enabled": false}}}') - - kimi_md = tmp_path / "KIMI.md" - kimi_md.write_text("# Kimi context\n\nKeep this user note.\n") - - m = IntegrationManifest("kimi", tmp_path) - i.setup(tmp_path, m, parsed_options={"migrate_legacy": True}) - - # Opted-out project: KIMI.md is left untouched and AGENTS.md is not - # created/modified by the migration. - assert kimi_md.is_file() - assert kimi_md.read_text() == "# Kimi context\n\nKeep this user note.\n" - assert not (tmp_path / "AGENTS.md").exists() - - def test_context_migration_skips_corrupted_single_marker(self, tmp_path): - """A KIMI.md with only a start marker is left untouched (no leak).""" - project = tmp_path - kimi_md = project / "KIMI.md" - kimi_md.write_text( - "# Notes\n\n" - "\n" - "dangling managed content\n" - ) - - result = _migrate_legacy_kimi_context_file(project) - - assert result is False - # KIMI.md untouched; managed block never copied into AGENTS.md. - assert kimi_md.is_file() - assert "dangling managed content" in kimi_md.read_text() - assert not (project / "AGENTS.md").exists() - - def test_context_migration_skips_unreadable_kimi_md(self, tmp_path): - """Non-UTF-8 KIMI.md is skipped instead of raising during setup.""" - project = tmp_path - kimi_md = project / "KIMI.md" - kimi_md.write_bytes(b"\xff\xfe invalid utf-8 \xa6\n") - - result = _migrate_legacy_kimi_context_file(project) - - assert result is False - assert kimi_md.is_file() - assert not (project / "AGENTS.md").exists() - - def test_context_migration_skips_when_agents_md_is_directory(self, tmp_path): - """An AGENTS.md that exists as a directory is skipped, not written to.""" - project = tmp_path - (project / "AGENTS.md").mkdir() - kimi_md = project / "KIMI.md" - kimi_md.write_text("# Notes\n\nKeep this.\n") - - result = _migrate_legacy_kimi_context_file(project) - - assert result is False - # KIMI.md is preserved and the directory is untouched. - assert kimi_md.is_file() - assert (project / "AGENTS.md").is_dir() - - class TestKimiTeardownLegacyCleanup: """teardown() removes leftover legacy .kimi/skills/ directories.""" @@ -522,49 +358,6 @@ def test_migrate_skips_symlinked_target_dir(self, tmp_path): assert (legacy / "SKILL.md").exists() assert (outside / "SKILL.md").exists() - def test_context_migration_does_not_write_through_symlinked_agents_md( - self, tmp_path - ): - # A sensitive file outside the project that a malicious AGENTS.md - # symlink points at. Migration must never overwrite it. - outside = tmp_path / "outside" - outside.mkdir() - secret = outside / "secret.txt" - secret.write_text("original secret\n") - - project = tmp_path / "project" - project.mkdir() - _symlink_or_skip(project / "AGENTS.md", secret) - (project / "KIMI.md").write_text("# Notes\n\nKeep this.\n") - - result = _migrate_legacy_kimi_context_file(project) - - # The outside file must not be overwritten through the symlink. - assert secret.read_text() == "original secret\n" - # KIMI.md is preserved so the user can migrate manually. - assert (project / "KIMI.md").is_file() - assert result is False - - def test_context_migration_does_not_follow_symlinked_kimi_md(self, tmp_path): - # A symlinked KIMI.md (source) must not be followed/consumed. - outside = tmp_path / "outside" - outside.mkdir() - external = outside / "external.md" - external.write_text("# external\n") - - project = tmp_path / "project" - project.mkdir() - _symlink_or_skip(project / "KIMI.md", external) - - result = _migrate_legacy_kimi_context_file(project) - - assert result is False - # The external file and the symlink are left intact. - assert external.read_text() == "# external\n" - assert (project / "KIMI.md").is_symlink() - assert not (project / "AGENTS.md").exists() - - class TestKimiNextSteps: """CLI output tests for kimi next-steps display.""" diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index c1a029a55f..29adb0a4a6 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -41,7 +41,6 @@ class TestKiroCliIntegration(MarkdownIntegrationTests): FOLDER = ".kiro/" COMMANDS_SUBDIR = "prompts" REGISTRAR_DIR = ".kiro/prompts" - CONTEXT_FILE = "AGENTS.md" def test_registrar_config(self): """Override base assertion: kiro-cli uses a prose fallback for args diff --git a/tests/integrations/test_integration_lingma.py b/tests/integrations/test_integration_lingma.py index 959de8d657..e3d338d540 100644 --- a/tests/integrations/test_integration_lingma.py +++ b/tests/integrations/test_integration_lingma.py @@ -8,4 +8,3 @@ class TestLingmaIntegration(SkillsIntegrationTests): FOLDER = ".lingma/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".lingma/skills" - CONTEXT_FILE = ".lingma/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_omp.py b/tests/integrations/test_integration_omp.py index f0c5efa490..5b30b76075 100644 --- a/tests/integrations/test_integration_omp.py +++ b/tests/integrations/test_integration_omp.py @@ -10,7 +10,6 @@ class TestOmpIntegration(MarkdownIntegrationTests): FOLDER = ".omp/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".omp/commands" - CONTEXT_FILE = "AGENTS.md" def test_build_exec_args_uses_omp_json_mode(self): i = get_integration(self.KEY) diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index ba2d15711f..b9464fdea3 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -14,7 +14,6 @@ class TestOpencodeIntegration(MarkdownIntegrationTests): FOLDER = ".opencode/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".opencode/commands" - CONTEXT_FILE = "AGENTS.md" def test_build_exec_args_uses_run_command_dispatch(self): integration = get_integration(self.KEY) diff --git a/tests/integrations/test_integration_pi.py b/tests/integrations/test_integration_pi.py index 5ac5676501..5dde4a4294 100644 --- a/tests/integrations/test_integration_pi.py +++ b/tests/integrations/test_integration_pi.py @@ -8,4 +8,3 @@ class TestPiIntegration(MarkdownIntegrationTests): FOLDER = ".pi/" COMMANDS_SUBDIR = "prompts" REGISTRAR_DIR = ".pi/prompts" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py index 1dbee480a0..29a6d16d29 100644 --- a/tests/integrations/test_integration_qodercli.py +++ b/tests/integrations/test_integration_qodercli.py @@ -8,4 +8,3 @@ class TestQodercliIntegration(MarkdownIntegrationTests): FOLDER = ".qoder/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".qoder/commands" - CONTEXT_FILE = "QODER.md" diff --git a/tests/integrations/test_integration_qwen.py b/tests/integrations/test_integration_qwen.py index 10a3c083f4..3de85d3888 100644 --- a/tests/integrations/test_integration_qwen.py +++ b/tests/integrations/test_integration_qwen.py @@ -8,4 +8,3 @@ class TestQwenIntegration(MarkdownIntegrationTests): FOLDER = ".qwen/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".qwen/commands" - CONTEXT_FILE = "QWEN.md" diff --git a/tests/integrations/test_integration_roo.py b/tests/integrations/test_integration_roo.py index 69d859c42f..b713f96362 100644 --- a/tests/integrations/test_integration_roo.py +++ b/tests/integrations/test_integration_roo.py @@ -8,4 +8,3 @@ class TestRooIntegration(MarkdownIntegrationTests): FOLDER = ".roo/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".roo/commands" - CONTEXT_FILE = ".roo/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_rovodev.py b/tests/integrations/test_integration_rovodev.py index 8e992476fb..5bdafc25f9 100644 --- a/tests/integrations/test_integration_rovodev.py +++ b/tests/integrations/test_integration_rovodev.py @@ -52,7 +52,6 @@ class TestRovodevIntegration: which violates the base mixin's pure-skills assumptions).""" KEY = "rovodev" - CONTEXT_FILE = "AGENTS.md" # -- ACLI dispatch ----------------------------------------------------- @@ -218,12 +217,8 @@ def test_init_inventory(self, rovodev_init_project): # Prompts: exactly the core template set. assert prompt_stems == core_skill_names - # Skills: core ∪ extension-installed. - assert core_skill_names.issubset(skill_names) - extension_skills = skill_names - core_skill_names - assert extension_skills, ( - "Expected at least one extension-installed skill (e.g. agent-context)" - ) + # Skills: exactly the core template set (no extension auto-install). + assert skill_names == core_skill_names # prompts.yml mirrors the prompt files exactly. prompts_manifest = project / ".rovodev" / "prompts.yml" @@ -266,10 +261,6 @@ def test_init_skill_files_well_formed(self, rovodev_init_project): f"{skill_file} body contains dot-notation /speckit. reference" ) - # The plan skill must reference the agent's context file. - plan_content = (skills_dir / "speckit-plan" / "SKILL.md").read_text(encoding="utf-8") - assert self.CONTEXT_FILE in plan_content - # -- Full-CLI init: integration metadata ------------------------------- def test_init_writes_integration_manifest_and_options(self, rovodev_init_project): diff --git a/tests/integrations/test_integration_shai.py b/tests/integrations/test_integration_shai.py index 74f93396b1..fc2b60c3f2 100644 --- a/tests/integrations/test_integration_shai.py +++ b/tests/integrations/test_integration_shai.py @@ -8,4 +8,3 @@ class TestShaiIntegration(MarkdownIntegrationTests): FOLDER = ".shai/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".shai/commands" - CONTEXT_FILE = "SHAI.md" diff --git a/tests/integrations/test_integration_tabnine.py b/tests/integrations/test_integration_tabnine.py index 95eb47cc16..71bf398862 100644 --- a/tests/integrations/test_integration_tabnine.py +++ b/tests/integrations/test_integration_tabnine.py @@ -8,4 +8,3 @@ class TestTabnineIntegration(TomlIntegrationTests): FOLDER = ".tabnine/agent/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".tabnine/agent/commands" - CONTEXT_FILE = "TABNINE.md" diff --git a/tests/integrations/test_integration_trae.py b/tests/integrations/test_integration_trae.py index 74b8b41c3f..2805263b3d 100644 --- a/tests/integrations/test_integration_trae.py +++ b/tests/integrations/test_integration_trae.py @@ -8,4 +8,3 @@ class TestTraeIntegration(SkillsIntegrationTests): FOLDER = ".trae/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".trae/skills" - CONTEXT_FILE = ".trae/rules/project_rules.md" diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index bab4539f1e..98c9fdf06d 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -13,7 +13,6 @@ class TestVibeIntegration(SkillsIntegrationTests): FOLDER = ".vibe/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".vibe/skills" - CONTEXT_FILE = "AGENTS.md" class TestVibeUserInvocable: diff --git a/tests/integrations/test_integration_windsurf.py b/tests/integrations/test_integration_windsurf.py index fa8d1e622a..4cdfaa94a3 100644 --- a/tests/integrations/test_integration_windsurf.py +++ b/tests/integrations/test_integration_windsurf.py @@ -8,4 +8,3 @@ class TestWindsurfIntegration(MarkdownIntegrationTests): FOLDER = ".windsurf/" COMMANDS_SUBDIR = "workflows" REGISTRAR_DIR = ".windsurf/workflows" - CONTEXT_FILE = ".windsurf/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_zcode.py b/tests/integrations/test_integration_zcode.py index 3eb82ed4f2..f431d3e4a0 100644 --- a/tests/integrations/test_integration_zcode.py +++ b/tests/integrations/test_integration_zcode.py @@ -8,7 +8,6 @@ class TestZcodeIntegration(SkillsIntegrationTests): FOLDER = ".zcode/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".zcode/skills" - CONTEXT_FILE = "ZCODE.md" class TestZcodeInvocation: diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index 0172e6b275..739fdbf23b 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -14,7 +14,6 @@ class TestZedIntegration(SkillsIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".agents/skills" - CONTEXT_FILE = "AGENTS.md" def test_options_include_skills_flag(self): """Not applicable to Zed — Zed is always skills-based with no --skills flag.""" diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 0110e19ec7..f22f7e1048 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -164,17 +164,12 @@ class TestMultiInstallSafeContracts: @pytest.mark.parametrize("key", _multi_install_safe_keys()) def test_safe_integrations_have_static_isolated_paths(self, key): - integration = INTEGRATION_REGISTRY[key] - assert _integration_root_dir(key), ( f"{key} is declared multi-install safe but has no static root directory" ) assert _integration_commands_dir(key), ( f"{key} is declared multi-install safe but has no static commands directory" ) - assert integration.context_file, ( - f"{key} is declared multi-install safe but has no context file" - ) @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) def test_safe_integrations_have_distinct_agent_roots(self, first, second): @@ -192,44 +187,6 @@ def test_safe_integrations_have_distinct_command_dirs(self, first, second): f"{_integration_commands_dir(second)!r}" ) - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) - def test_safe_integrations_have_distinct_context_files(self, first, second): - first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) - second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) - - assert first_context != second_context, ( - f"{first} and {second} are declared multi-install safe but share " - f"context file {first_context!r}" - ) - - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) - def test_safe_context_files_do_not_overlap_other_agent_roots(self, first, second): - first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) - second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) - - assert not _path_is_inside(first_context, _integration_root_dir(second)), ( - f"{first} context file {first_context!r} lives under {second} " - f"agent root {_integration_root_dir(second)!r}" - ) - assert not _path_is_inside(second_context, _integration_root_dir(first)), ( - f"{second} context file {second_context!r} lives under {first} " - f"agent root {_integration_root_dir(first)!r}" - ) - - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) - def test_safe_context_files_do_not_overlap_other_command_dirs(self, first, second): - first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) - second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) - - assert not _path_is_inside(first_context, _integration_commands_dir(second)), ( - f"{first} context file {first_context!r} lives under {second} " - f"commands directory {_integration_commands_dir(second)!r}" - ) - assert not _path_is_inside(second_context, _integration_commands_dir(first)), ( - f"{second} context file {second_context!r} lives under {first} " - f"commands directory {_integration_commands_dir(first)!r}" - ) - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) def test_safe_integrations_have_disjoint_manifests( self,