From 53d9543355ad6a5b8f7bc8b96fe1ad041c0c6638 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:27:26 -0500 Subject: [PATCH] feat: make agent-context extension a full opt-in (#3097) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add Spec Kit spec for agent-context full opt-in Use Spec Kit's own specify workflow to author the spec that makes the agent-context extension a full opt-in, removing all agent-context configuration/support from the Python codebase and removing the deprecation message. Force-added despite specs/ being gitignored; the generated artifact will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit plan artifacts for agent-context full opt-in Phase 0/1 of the SDD plan workflow: plan.md, research.md, data-model.md, quickstart.md, and contracts/cli-behavior.md. Constitution Check is a documented no-op (repo has no ratified constitution). Force-added despite specs/ being gitignored; generated artifacts will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct Constitution Check against ratified v1.0.0 Earlier draft wrongly treated the gate as a no-op; the fork's main is 16 commits behind upstream/main, which carries .specify/memory/constitution.md. Re-evaluate the feature against Principles I-V (all PASS) and note that Principle I mandates keeping context_file as a declared class attribute, validating the R1 metadata decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: refresh plan artifacts against synced upstream/main After syncing fork main to upstream and rebasing, re-scan the current agent-context surface. Upstream generalized the single context_file into a plural context_files concept with new resolver helpers (_resolve_context_files, _resolve_context_file_values, _format_context_file_values) and upsert/remove now loop over multiple files. Update research.md, data-model.md, contracts, quickstart grep guards, and the plan summary to cover the expanded removal scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit tasks for agent-context full opt-in Phase 2 of SDD: dependency-ordered tasks.md (30 tasks) organized by the three user stories, with mandatory test tasks (Constitution Principle II) and a foundational phase decoupling __CONTEXT_FILE__ resolution from the extension config. Includes the extension self-seeding task (T015) and a static guard test (T002) enforcing zero agent-context references in the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove agent-context lifecycle from the Specify CLI Make the agent-context extension a full opt-in. The CLI no longer installs the extension during init, writes agent-context-config.yml, or creates/updates/removes the managed Spec Kit section in agent context files. Context-section upsert/remove, marker resolution, extension-enabled gating, the config helpers, and the obsolete inline deprecation warning are all removed. Integration context_file stays as inert metadata; __CONTEXT_FILE__ now resolves from registry metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): self-seed context file from the active integration When agent-context-config.yml has no context_file/context_files, the bundled bash and PowerShell update scripts now resolve the context file from the active integration in .specify/init-options.json via the integration registry, so the extension no longer depends on the CLI writing its config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: update suite and docs for agent-context opt-in Update integration/extension tests to expect no agent-context install, config, or context-section writes during init. Add a static guard test (test_agent_context_cli_free.py) asserting the CLI source is free of agent-context lifecycle symbols, plus backward-compatibility tests for legacy projects. Refresh AGENTS.md, the extension README, and add a CHANGELOG entry describing the opt-in behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(agent-context): warn on self-seed failure, correct docs, speed up guard test Address PR review feedback: - Self-seed scripts (bash + PowerShell) now emit an actionable warning when an active integration is configured but specify_cli cannot be imported by the chosen Python (e.g. pipx installs), or when the integration declares no context file, instead of silently falling through to 'nothing to do'. - Correct the extension README disable note: command rendering never reads the extension config; __CONTEXT_FILE__ is always substituted from integration metadata, so a stale context_files value cannot affect rendering. - Cache CLI source reads in the static guard test via a module-scoped fixture so the directory walk happens once instead of once per forbidden symbol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): ship self-owned per-agent context-file defaults The extension now bundles agent-context-defaults.json (key→context_file map) and self-seeds from it, dropping any dependency on the Specify CLI registry. Both the bash and PowerShell update scripts read the bundled JSON map keyed by the active integration from init-options.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove all agent-context state from the Specify CLI Strip every context_file reference from the CLI: the field on all 35 integration classes, the IntegrationBase plumbing (process_template param/step, _context_file_display, docstrings), the __CONTEXT_FILE__ resolution in agents.py, the legacy context_file/context_markers popping in _helpers.py, and the context_file template in integration_scaffold.py. Also drop the Agent context update step and __CONTEXT_FILE__ placeholder from templates/commands/plan.md. The agent-context extension now solely owns all context-file knowledge, including the per-agent default mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: drop context_file coverage and guard against CLI reintroduction Remove CONTEXT_FILE attrs and context_file assertions across the base mixins, all 35 per-integration test files, shared integration tests, and conftest stubs. Rewrite the base-mixin context tests to assert no managed section is written and no __CONTEXT_FILE__ placeholder survives. Extend the CLI-free static guard to forbid context_file, __CONTEXT_FILE__, and _context_file_display in src/specify_cli, and have the extension tests copy the bundled defaults JSON so self-seed runs without the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: reflect full removal of agent-context state from the CLI Update AGENTS.md (integration examples, required-fields table, context behavior section, pitfalls), CHANGELOG, and the SDD spec artifacts (FR-007, SC-002, data-model) to state that the CLI carries no context_file and the extension fully owns the per-agent default mapping via agent-context-defaults.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: align SDD artifacts with full context_file removal Update research.md (R1, R2, R4, summary table), contracts/cli-behavior.md (C3, C5), tasks.md (Phase 2, T026, notes), plan.md (Principle I, source map), and checklists/requirements.md so the spec artifacts reflect the implemented decision: the CLI carries no context_file attribute or __CONTEXT_FILE__ resolution, and the per-agent defaults map lives in the extension. Resolves PR review #4548130110. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: scrub stale context-file mentions from CLI docstrings Update the multi_install_safe docstring (drop the removed "context file" invariant), the RovoDev setup docstring (no longer upserts a context section), the Copilot module docstring (drop the context-file line), and tighten the _update_init_options_for_integration note. Pure docstring changes — no behavioral impact. Resolves PR review #4548237085. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: harden agent-context test helper and fix stale docs - base.py: document multi_install_safe as an optional subclass attribute in the IntegrationBase docstring. - test_cli.py: clarify the init-options assertion is guarding against leftover legacy agent-context keys, not relocation. - test_extension_agent_context.py: _install_agent_context_config now asserts the bundled agent-context-defaults.json exists and always copies it, so self-seeding tests fail loudly instead of silently skipping when the map is missing. - test_integration_cursor_agent.py: drop Path/IntegrationManifest imports left unused after removing the context-section frontmatter tests. Resolves PR review #4548293116. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove gitignored SDD artifacts from specs/ The specs/001-agent-context-full-optin/ artifacts were force-added for dogfooding visibility, but specs/ is gitignored and these were always intended to be purged before merge. Remove them so merging does not add an intentionally-untracked directory to repo history. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: keep CHANGELOG.md identical to upstream CHANGELOG.md is auto-generated at release time, so the branch should not carry a manual entry. Restore it to match upstream/main exactly. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: preserve Cursor .mdc frontmatter in agent-context updater scripts The bundled agent-context updater scripts wrote the managed section as plain text. For Cursor-style `.mdc` targets this dropped the required `---\nalwaysApply: true\n---` frontmatter, reintroducing the rule-loading bug originally fixed in #1699. Port the `_ensure_mdc_frontmatter` logic into both the bash and PowerShell updaters: prepend frontmatter when missing, repair `alwaysApply` when set to the wrong value, and leave non-`.mdc` targets untouched. Add regression tests covering both shells. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: scope CLI-free guard to agent-context-specific symbols Drop the bare "context_file" substring from FORBIDDEN_SYMBOLS so the guard no longer fails on unrelated future CLI fields named context_file. The list still covers agent-context-specific identifiers (__CONTEXT_FILE__, _context_file_display, _resolve_context_files, _resolve_context_file_values). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden agent-context bash self-seed against malformed init JSON Two robustness fixes in the embedded Python self-seed logic: - Coerce the integration value from init-options.json to a string only when it is actually a string; otherwise treat it as unset so a corrupted dict/list value degrades to the existing nothing-to-do behavior instead of breaking the agents-map lookup. - Normalize agent-context-defaults.json: only use 'agents' when both the JSON root and the 'agents' value are dicts, so a wrong-shaped (but valid) JSON falls back to the warning path instead of raising on .get. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct PowerShell hyphenated key lookup and regex replace count - Self-seed now reads the defaults mapping via $defaults.agents.PSObject.Properties[$integrationKey].Value instead of member access ($defaults.agents.$integrationKey), which parsed hyphenated keys like 'cursor-agent'/'kiro-cli' as subtraction and failed to resolve. - Replace the static [regex]::Replace(..., 1) call, whose trailing 1 was interpreted as RegexOptions.IgnoreCase rather than a replacement count, with an instance Regex whose Replace(input, replacement, 1) limits to the first match as intended. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make bash .mdc frontmatter guard case-insensitive The bash updater only injected Cursor .mdc frontmatter when ctx_path ended in lowercase '.mdc', so a mixed/upper-case extension (e.g. specify-rules.MDC) was skipped and Cursor would not auto-load the rule file. Compare against the casefolded path. The PowerShell variant already uses -match, which is case-insensitive by default, so no change is needed there. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document separator-agnostic agent-context update invocation The README hard-coded the dot-notation slash command (/speckit.agent-context.update), which hyphen-separator agents like Forge and Cline do not recognize. Document the canonical command ID plus both slash invocations so users copy the form their agent accepts. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 19 +- extensions/agent-context/README.md | 15 +- .../agent-context/agent-context-defaults.json | 42 + .../scripts/bash/update-agent-context.sh | 124 +- .../powershell/update-agent-context.ps1 | 90 ++ src/specify_cli/__init__.py | 78 +- src/specify_cli/agents.py | 31 - src/specify_cli/commands/init.py | 44 - src/specify_cli/integration_scaffold.py | 8 +- src/specify_cli/integrations/_helpers.py | 60 +- src/specify_cli/integrations/agy/__init__.py | 1 - src/specify_cli/integrations/amp/__init__.py | 1 - .../integrations/auggie/__init__.py | 1 - src/specify_cli/integrations/base.py | 565 +-------- src/specify_cli/integrations/bob/__init__.py | 1 - .../integrations/claude/__init__.py | 1 - .../integrations/cline/__init__.py | 1 - .../integrations/codebuddy/__init__.py | 1 - .../integrations/codex/__init__.py | 1 - .../integrations/copilot/__init__.py | 7 - .../integrations/cursor_agent/__init__.py | 1 - .../integrations/devin/__init__.py | 1 - .../integrations/firebender/__init__.py | 5 +- .../integrations/forge/__init__.py | 5 - .../integrations/gemini/__init__.py | 1 - .../integrations/generic/__init__.py | 5 - .../integrations/goose/__init__.py | 1 - .../integrations/hermes/__init__.py | 10 +- .../integrations/iflow/__init__.py | 1 - .../integrations/junie/__init__.py | 1 - .../integrations/kilocode/__init__.py | 1 - src/specify_cli/integrations/kimi/__init__.py | 124 +- .../integrations/kiro_cli/__init__.py | 1 - .../integrations/lingma/__init__.py | 1 - src/specify_cli/integrations/omp/__init__.py | 1 - .../integrations/opencode/__init__.py | 1 - src/specify_cli/integrations/pi/__init__.py | 1 - .../integrations/qodercli/__init__.py | 1 - src/specify_cli/integrations/qwen/__init__.py | 1 - src/specify_cli/integrations/roo/__init__.py | 1 - .../integrations/rovodev/__init__.py | 4 +- src/specify_cli/integrations/shai/__init__.py | 1 - .../integrations/tabnine/__init__.py | 1 - src/specify_cli/integrations/trae/__init__.py | 1 - src/specify_cli/integrations/vibe/__init__.py | 1 - .../integrations/windsurf/__init__.py | 1 - .../integrations/zcode/__init__.py | 1 - src/specify_cli/integrations/zed/__init__.py | 1 - templates/commands/plan.md | 7 +- .../extensions/test_agent_context_cli_free.py | 57 + .../test_extension_agent_context.py | 1048 ++++++----------- tests/integrations/conftest.py | 1 - tests/integrations/test_base.py | 1 - tests/integrations/test_cli.py | 17 +- tests/integrations/test_extra_args.py | 5 - tests/integrations/test_integration_agy.py | 1 - tests/integrations/test_integration_amp.py | 1 - tests/integrations/test_integration_auggie.py | 1 - .../test_integration_base_markdown.py | 97 +- .../test_integration_base_skills.py | 85 +- .../test_integration_base_toml.py | 96 +- .../test_integration_base_yaml.py | 96 +- tests/integrations/test_integration_bob.py | 1 - tests/integrations/test_integration_claude.py | 64 +- tests/integrations/test_integration_cline.py | 16 - .../test_integration_codebuddy.py | 1 - tests/integrations/test_integration_codex.py | 74 +- .../integrations/test_integration_copilot.py | 62 +- .../test_integration_cursor_agent.py | 78 -- tests/integrations/test_integration_devin.py | 1 - .../test_integration_firebender.py | 1 - tests/integrations/test_integration_forge.py | 20 +- tests/integrations/test_integration_gemini.py | 1 - .../integrations/test_integration_generic.py | 64 +- tests/integrations/test_integration_goose.py | 1 - tests/integrations/test_integration_hermes.py | 11 +- tests/integrations/test_integration_iflow.py | 1 - tests/integrations/test_integration_junie.py | 1 - .../integrations/test_integration_kilocode.py | 1 - tests/integrations/test_integration_kimi.py | 207 ---- .../integrations/test_integration_kiro_cli.py | 1 - tests/integrations/test_integration_lingma.py | 1 - tests/integrations/test_integration_omp.py | 1 - .../integrations/test_integration_opencode.py | 1 - tests/integrations/test_integration_pi.py | 1 - .../integrations/test_integration_qodercli.py | 1 - tests/integrations/test_integration_qwen.py | 1 - tests/integrations/test_integration_roo.py | 1 - .../integrations/test_integration_rovodev.py | 13 +- tests/integrations/test_integration_shai.py | 1 - .../integrations/test_integration_tabnine.py | 1 - tests/integrations/test_integration_trae.py | 1 - tests/integrations/test_integration_vibe.py | 1 - .../integrations/test_integration_windsurf.py | 1 - tests/integrations/test_integration_zcode.py | 1 - tests/integrations/test_integration_zed.py | 1 - tests/integrations/test_registry.py | 43 - 97 files changed, 870 insertions(+), 2585 deletions(-) create mode 100644 extensions/agent-context/agent-context-defaults.json create mode 100644 tests/extensions/test_agent_context_cli_free.py diff --git a/AGENTS.md b/AGENTS.md index 3d5ea3237..68d8641e4 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 091e2b480..adc13e31e 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 000000000..120c348ac --- /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 64e1bae89..c3e5c2020 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")) @@ -295,11 +338,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() @@ -329,6 +419,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 da9ff443c..98a55c55f 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -20,6 +20,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, @@ -250,6 +300,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 @@ -411,6 +498,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 6713549d3..5d5361cc8 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 da3ca49fa..7864260a9 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -433,37 +433,6 @@ class CommandRegistrar: 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 fc82334da..dd815b8c5 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -18,7 +18,6 @@ from .._agent_config import ( SCRIPT_TYPE_CHOICES, ) from .._assets import ( - _locate_bundled_extension, _locate_bundled_preset, _locate_bundled_workflow, get_speckit_version, @@ -171,7 +170,6 @@ def register(app: typer.Typer) -> None: 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 register(app: typer.Typer) -> None: ("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 register(app: typer.Typer) -> None: 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 e4c4b83b3..f0ed21033 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 f8a696a86..d1bf051f7 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. + """Update init-options.json 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. + 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 6ed69e1e0..33f8d17a9 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 39df0a9bb..5d9d14250 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 08e20fbc2..e6fd702fa 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 be3ab7133..c820fd4ee 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -13,14 +13,13 @@ Provides: from __future__ import annotations -import json import os import re import shlex import shutil from abc import ABC from dataclasses import dataclass -from pathlib import Path, PureWindowsPath +from pathlib import Path from typing import TYPE_CHECKING, Any import yaml @@ -91,13 +90,9 @@ class IntegrationBase(ABC): And may optionally set: - * ``context_file`` — path (relative to project root) of the agent - context/instructions file (e.g. ``"CLAUDE.md"``) - - Projects may additionally opt into managing multiple context files by - setting ``context_files`` in the agent-context extension config. The - integration class still declares one default ``context_file`` for backwards - compatibility and command-template rendering. + * ``invoke_separator`` — slash-command separator (defaults to ``"."``) + * ``multi_install_safe`` — declare the integration safe to install + alongside others (defaults to ``False``) """ # -- Must be set by every subclass ------------------------------------ @@ -113,9 +108,6 @@ class IntegrationBase(ABC): # -- Optional --------------------------------------------------------- - context_file: str | None = None - """Relative path to the agent context file (e.g. ``CLAUDE.md``).""" - invoke_separator: str = "." """Separator used in slash-command invocations (``"."`` → ``/speckit.plan``).""" @@ -125,16 +117,11 @@ class IntegrationBase(ABC): multi_install_safe: bool = False """Whether this integration is declared safe to install alongside others. - Safe integrations must use a static, unique agent root, command directory, - and context file. Registry tests enforce those invariants for every + Safe integrations must use a static, unique agent root and command + directory. Registry tests enforce those invariants for every integration that sets this flag. """ - # -- Markers for managed context section ------------------------------ - - CONTEXT_MARKER_START = "" - CONTEXT_MARKER_END = "" - # -- Public API ------------------------------------------------------- @classmethod @@ -533,498 +520,6 @@ class IntegrationBase(ABC): return created - # -- Agent context file management ------------------------------------ - - @staticmethod - def _ensure_mdc_frontmatter(content: str) -> str: - """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. - - If frontmatter is missing, prepend it. If frontmatter exists but - ``alwaysApply`` is absent or not ``true``, inject/fix it. - - Uses string/regex manipulation to preserve comments and formatting - in existing frontmatter. - """ - import re as _re - - leading_ws = len(content) - len(content.lstrip()) - leading = content[:leading_ws] - stripped = content[leading_ws:] - - if not stripped.startswith("---"): - return "---\nalwaysApply: true\n---\n\n" + content - - # Match frontmatter block: ---\n...\n--- - match = _re.match( - r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)", - stripped, - _re.DOTALL, - ) - if not match: - return "---\nalwaysApply: true\n---\n\n" + content - - opening, fm_text, closing, sep, rest = match.groups() - newline = "\r\n" if "\r\n" in opening else "\n" - - # Already correct? - if _re.search( - r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text - ): - return content - - # alwaysApply exists but wrong value — fix in place while preserving - # indentation and any trailing inline comment. - if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): - fm_text = _re.sub( - r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$", - r"\1alwaysApply: true\2", - fm_text, - count=1, - ) - elif fm_text.strip(): - fm_text = fm_text + newline + "alwaysApply: true" - else: - fm_text = "alwaysApply: true" - - return f"{leading}{opening}{fm_text}{closing}{sep}{rest}" - - @staticmethod - def _build_context_section(plan_path: str = "") -> str: - """Build the content for the managed section between markers. - - *plan_path* is the project-relative path to the current plan - (e.g. ``"specs//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 @@ class IntegrationBase(ABC): agent_name: str, script_type: str, arg_placeholder: str = "$ARGUMENTS", - context_file: str = "", invoke_separator: str = ".", ) -> str: """Process a raw command template into agent-ready content. @@ -1060,9 +554,8 @@ class IntegrationBase(ABC): 3. Strip ``scripts:`` section from frontmatter 4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* 5. Replace ``__AGENT__`` with *agent_name* - 6. Replace ``__CONTEXT_FILE__`` with *context_file* - 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. - 8. Replace ``__SPECKIT_COMMAND___`` 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 @@ class IntegrationBase(ABC): # 5. Replace __AGENT__ content = content.replace("__AGENT__", agent_name) - # 6. Replace __CONTEXT_FILE__ - content = content.replace("__CONTEXT_FILE__", context_file) - - # 7. Rewrite paths — delegate to the shared implementation in + # 6. Rewrite paths — delegate to the shared implementation in # CommandRegistrar so extension-local paths are preserved and # boundary rules stay consistent across the codebase. from specify_cli.agents import CommandRegistrar @@ -1180,8 +670,6 @@ class IntegrationBase(ABC): self.record_file_in_manifest(dst_file, project_root, manifest) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1196,11 +684,9 @@ class IntegrationBase(ABC): Delegates to ``manifest.uninstall()`` which only removes files whose hash still matches the recorded value (unless *force*). - Also removes the managed context section from the agent file. Returns ``(removed, skipped)`` file lists. """ - self.remove_context_section(project_root) return manifest.uninstall(project_root, force=force) # -- Convenience helpers for subclasses ------------------------------- @@ -1234,12 +720,11 @@ class IntegrationBase(ABC): class MarkdownIntegration(IntegrationBase): """Concrete base for integrations that use standard Markdown commands. - Subclasses only need to set ``key``, ``config``, ``registrar_config`` - (and optionally ``context_file``). Everything else is inherited. + Subclasses only need to set ``key``, ``config``, ``registrar_config``. + Everything else is inherited. ``setup()`` processes command templates (replacing ``{SCRIPT}``, - ``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the - managed context section into the agent context file. + ``{ARGS}``, ``__AGENT__``, rewriting paths). """ def build_exec_args( @@ -1294,13 +779,11 @@ class MarkdownIntegration(IntegrationBase): else "$ARGUMENTS" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -1308,8 +791,6 @@ class MarkdownIntegration(IntegrationBase): ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1323,8 +804,7 @@ class TomlIntegration(IntegrationBase): """Concrete base for integrations that use TOML command format. Mirrors ``MarkdownIntegration`` closely: subclasses only need to set - ``key``, ``config``, ``registrar_config`` (and optionally - ``context_file``). Everything else is inherited. + ``key``, ``config``, ``registrar_config``. Everything else is inherited. ``setup()`` processes command templates through the same placeholder pipeline as ``MarkdownIntegration``, then converts the result to @@ -1500,14 +980,12 @@ class TomlIntegration(IntegrationBase): else "{{args}}" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) @@ -1517,8 +995,6 @@ class TomlIntegration(IntegrationBase): ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1532,8 +1008,7 @@ class YamlIntegration(IntegrationBase): """Concrete base for integrations that use YAML recipe format. Mirrors ``TomlIntegration`` closely: subclasses only need to set - ``key``, ``config``, ``registrar_config`` (and optionally - ``context_file``). Everything else is inherited. + ``key``, ``config``, ``registrar_config``. Everything else is inherited. ``setup()`` processes command templates through the same placeholder pipeline as ``MarkdownIntegration``, then converts the result to @@ -1696,7 +1171,6 @@ class YamlIntegration(IntegrationBase): else "{{args}}" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -1712,7 +1186,6 @@ class YamlIntegration(IntegrationBase): processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) _, body = self._split_frontmatter(processed) yaml_content = self._render_yaml( @@ -1724,8 +1197,6 @@ class YamlIntegration(IntegrationBase): ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1741,8 +1212,8 @@ class SkillsIntegration(IntegrationBase): Skills use the ``speckit-/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 @@ class SkillsIntegration(IntegrationBase): else "$ARGUMENTS" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -1911,7 +1381,6 @@ class SkillsIntegration(IntegrationBase): # Process body through the standard template pipeline processed_body = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. @@ -1958,7 +1427,5 @@ class SkillsIntegration(IntegrationBase): ) created.append(dst) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index 78f2df037..b953151bd 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 41d5b14b1..923a77607 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -52,7 +52,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 c269a1604..ab839b9b5 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 c5b4503b6..148709690 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 4dd79da49..7d1ff86e2 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 2659b3f25..5cc34d2b1 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -4,7 +4,6 @@ Copilot has several unique behaviors compared to standard markdown agents: - 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 @@ class CopilotIntegration(IntegrationBase): 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 @@ class CopilotIntegration(IntegrationBase): 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 b83ee42e5..2c328b2fd 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 b3b21b852..18c1fc8d6 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 b49140b1f..eb0cec02d 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 d1cd7a49a..8c21353fe 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 @@ class ForgeIntegration(MarkdownIntegration): 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 @@ class ForgeIntegration(MarkdownIntegration): ) 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 7c6fe159c..9a459862a 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 3d6dd19d4..d87427355 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 @@ class GenericIntegration(MarkdownIntegration): 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 @@ class GenericIntegration(MarkdownIntegration): ) 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 0fc4d9d57..77d4e0f83 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 1d475c72e..e094dcfcf 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 @@ class HermesIntegration(SkillsIntegration): 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 @@ class HermesIntegration(SkillsIntegration): 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 @@ class HermesIntegration(SkillsIntegration): 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 @@ class HermesIntegration(SkillsIntegration): ) -> 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 @@ class HermesIntegration(SkillsIntegration): 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 65d4d21c6..c6b5447bb 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 98d0494a8..e1e8a9add 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 11674dd9f..092484328 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 9c28855c0..3320935a0 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -5,8 +5,7 @@ Kimi uses the ``.kimi-code/skills/speckit-/SKILL.md`` layout with 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 @@ import shutil 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 @@ class KimiIntegration(SkillsIntegration): 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 @@ class KimiIntegration(SkillsIntegration): _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 4571b54f9..4c176e512 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 b5cd03603..2cb74b219 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 73f95a4f2..156583298 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 abd97ab2a..0f734b7f4 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 2cb738e04..ceff628bd 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 ee2d4b625..13535203c 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 2506a5768..1e8c15bf9 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 f610a3cc6..2042c0933 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 f8879424a..01aa870c6 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 @@ class RovodevIntegration(SkillsIntegration): ) -> 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 123953da7..8be9596bf 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 0d0076bc5..9edf1e160 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 4556487d0..03a628d42 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 7922aa841..136dec867 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 ae5c3301f..eba38fd1e 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 ea47f3155..46d93c5ca 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 882d83cc5..441e9e36f 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 8e00e3ef9..e82bd4b30 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -156,14 +156,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 000000000..9bba8087c --- /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 ab4194efd..f99d44940 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -13,14 +13,9 @@ import pytest 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 @@ POWERSHELL = ( 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 @@ class TestCatalogEntry: 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 @@ class TestBundledUpdaterPathValidation: 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( + 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_cli_does_not_resolve_context_placeholder(self, tmp_path): + content = CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", 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") == "" + assert content == "Read __CONTEXT_FILE__" - def test_clear_init_options_creates_ext_config_when_missing(self, tmp_path): - from specify_cli import _clear_init_options_for_integration - - save_init_options( + 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": "claude", "ai": "claude"}, + context_file="FROM_CONFIG.md", + context_files=["ALSO_CONFIG.md"], ) - _clear_init_options_for_integration(tmp_path, "claude") - cfg = _load_agent_context_config(tmp_path) - assert cfg.get("context_file") == "" - def test_clear_init_options_removes_legacy_context_keys_even_when_not_active( + content = CommandRegistrar.resolve_skill_placeholders( + "claude", + {}, + "Read __CONTEXT_FILE__", + tmp_path, + ) + assert "FROM_CONFIG.md" not in content + assert "ALSO_CONFIG.md" not in content + assert content == "Read __CONTEXT_FILE__" + + +# ── 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, + ) + + _update_init_options_for_integration( + tmp_path, INTEGRATION_REGISTRY["claude"], script_type="sh" + ) + + cfg = ( + tmp_path + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" + ) + assert not cfg.exists() + + def test_clear_init_options_does_not_create_ext_config(self, tmp_path): + from specify_cli.integrations._helpers import ( + _clear_init_options_for_integration, + ) + + 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" + ) + assert not cfg.exists() + + +# ── 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", + ) + + 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", + ) + + result = _run_powershell_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 + + @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") + + result = _run_powershell_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("") + + +_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" +) + + +class TestBackwardCompatibility: + """Legacy projects must keep working; the CLI never touches their artifacts.""" + + 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 + + project = tmp_path / "legacy" + project.mkdir() + ctx = self._seed_legacy_project(project) + cfg_path = ( + project / ".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 import _clear_init_options_for_integration - - save_init_options( - tmp_path, - { - "integration": "copilot", - "ai": "copilot", - "context_file": "CLAUDE.md", - "context_markers": {"start": "", "end": ""}, - }, + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations._helpers import ( + _clear_init_options_for_integration, + _update_init_options_for_integration, ) - _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( - 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"] - - 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=[], - ) - 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"}, - ) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], - ) - _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 - - def test_update_init_options_preserves_custom_markers(self, tmp_path): - from specify_cli import _update_init_options_for_integration - - _write_ext_config( - tmp_path, - context_file="", - context_markers={"start": "", "end": ""}, - ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i) - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_markers"] == {"start": "", "end": ""} - - def test_reinit_preserves_custom_markers(self, tmp_path): - """specify init (reinit) must not overwrite user-customised markers.""" - from specify_cli import _update_agent_context_config_file - - # Simulate existing project with custom markers - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_markers={"start": "", "end": ""}, - ) - # Re-running init updates context_file but must preserve markers - _update_agent_context_config_file( - tmp_path, "CLAUDE.md", preserve_markers=True - ) - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_markers"] == { - "start": "", - "end": "", - } - - -# ── Deprecation warning on upsert ──────────────────────────────────────────── - - -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 - - 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 - - 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 - - -# ── Corrupt / invalid extension config ─────────────────────────────────────── - - -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 + before_ctx = ctx.read_text(encoding="utf-8") + before_cfg = cfg_path.read_text(encoding="utf-8") - 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" + # Switch to a different integration. + _update_init_options_for_integration( + project, INTEGRATION_REGISTRY["gemini"], script_type="sh" ) - 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 + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg - def test_marker_resolution_with_non_dict_yaml(self, tmp_path): - """Config file containing a scalar (not a dict) falls back to defaults.""" - 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("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 + # 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 54f59e23a..833e272b2 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 47f9d0905..9ec7d236c 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -43,7 +43,6 @@ class TestIntegrationBase: 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 be8aad232..25d4a7c16 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -77,23 +77,17 @@ class TestInitIntegrationFlag: 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 TestIntegrationCatalogDiscoveryCLI: "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 d192e140f..e329c8880 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 b64a609e1..6ab66a0cb 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 a36dd4713..f0689c21f 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 e4033a23e..3cf4d09bb 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 b0b408a99..886dfb912 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 @@ class MarkdownIntegrationTests: 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 @@ class MarkdownIntegrationTests: 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 @@ class MarkdownIntegrationTests: 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 + 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_removes_context_section(self, tmp_path): + 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 @@ class MarkdownIntegrationTests: 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 @@ class MarkdownIntegrationTests: 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 e903d918e..d88b78675 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 @@ class SkillsIntegrationTests: 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 @@ class SkillsIntegrationTests: 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 @@ class SkillsIntegrationTests: 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 + 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_removes_context_section(self, tmp_path): + 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 @@ class SkillsIntegrationTests: 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 @@ class SkillsIntegrationTests: 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 @@ class SkillsIntegrationTests: # 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 @@ class SkillsIntegrationTests: ".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 a9b933875..68f5fd075 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 @@ class TomlIntegrationTests: 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 @@ class TomlIntegrationTests: 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 @@ class TomlIntegrationTests: 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 + 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_removes_context_section(self, tmp_path): + 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 @@ class TomlIntegrationTests: 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 @@ class TomlIntegrationTests: 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 646e21607..74cdab2d7 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 @@ class YamlIntegrationTests: 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 @@ class YamlIntegrationTests: 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 @@ class YamlIntegrationTests: 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 + 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_removes_context_section(self, tmp_path): + 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 @@ class YamlIntegrationTests: 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 @@ class YamlIntegrationTests: 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 1562f0100..8e0e72f0b 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 8f96527b2..1b1b2308d 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 @@ class TestClaudeIntegration: 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 @@ class TestClaudeIntegration: ) 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 + ctx_path = tmp_path / "CLAUDE.md" + original = "# CLAUDE.md\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") - # 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") + manifest = IntegrationManifest("claude", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + integration.teardown(tmp_path, manifest) - 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) - - 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 @@ class TestCodexInitFlow: 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 @@ class TestCodexInitFlow: 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 6b7cc7c13..8a7c8ec99 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -17,7 +17,6 @@ class TestCopilotIntegration: 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 @@ class TestCopilotIntegration: 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 @@ class TestCopilotIntegration: 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 @@ class TestCopilotIntegration: 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 @@ class TestCopilotIntegration: ".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 @@ class TestCopilotIntegration: ".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 @@ class TestCopilotIntegration: 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 @@ class TestCopilotIntegration: ".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 @@ class TestCopilotIntegration: ".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 @@ class TestCopilotSkillsMode: 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 @@ class TestCopilotSkillsMode: # -- 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 @@ class TestCopilotSkillsMode: 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 816546465..32318dc90 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 4acbdac61..52c2981bf 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 b42d2fbf9..6de66f4d0 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 f63afb71e..26ac7a993 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -55,7 +55,6 @@ class TestForgeIntegration: 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 @@ class TestForgeIntegration: 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 @@ class TestForgeIntegration: "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 @@ class TestForgeIntegration: 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 9be5985e2..1649b4f7c 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 fe935cc98..1c5edc2ef 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -31,10 +31,6 @@ class TestGenericIntegration: 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 @@ class TestGenericIntegration: # -- 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 + 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_references_correct_context_file(self, tmp_path): - """The generated plan command must reference generic'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.""" 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 @@ class TestGenericIntegration: # 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 @@ class TestGenericIntegration: 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 @@ class TestGenericIntegration: ".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 @@ class TestGenericIntegration: 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 @@ class TestGenericIntegration: ".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 8415081d5..104b7188d 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 89e74c2b3..521a310cb 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 @@ class TestHermesIntegration(SkillsIntegrationTests): """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 ea2f5ef97..89501f8ed 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 2b924ce43..2226e3d54 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 8e441c083..86e6520a5 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 2f752f66e..48e4daa55 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -6,7 +6,6 @@ import pytest 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 @@ class TestKimiLegacyMigration: 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 @@ class TestKimiLegacySymlinkSafety: 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 c1a029a55..29adb0a4a 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 959de8d65..e3d338d54 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 f0c5efa49..5b30b7607 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 ba2d15711..b9464fdea 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 5ac567650..5dde4a429 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 1dbee480a..29a6d16d2 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 10a3c083f..3de85d388 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 69d859c42..b713f9636 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 8e992476f..5bdafc25f 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 @@ class TestRovodevIntegration: # 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 @@ class TestRovodevIntegration: 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 74f93396b..fc2b60c3f 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 95eb47cc1..71bf39886 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 74b8b41c3..2805263b3 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 bab4539f1..98c9fdf06 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 fa8d1e622..4cdfaa94a 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 3eb82ed4f..f431d3e4a 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 0172e6b27..739fdbf23 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 0110e19ec..f22f7e104 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 @@ class TestMultiInstallSafeContracts: 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,