diff --git a/AGENTS.md b/AGENTS.md index 3d5ea32377..68d8641e4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,7 +75,6 @@ class WindsurfIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".windsurf/rules/specify-rules.md" ``` **TOML agent (Gemini):** @@ -101,7 +100,6 @@ class GeminiIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "GEMINI.md" ``` **Skills agent (Codex):** @@ -129,7 +127,6 @@ class CodexIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -150,7 +147,6 @@ class CodexIntegration(SkillsIntegration): | `key` | Class attribute | Unique identifier; for CLI-based integrations (`requires_cli: True`), must match the CLI executable name | | `config` | Class attribute (dict) | Agent metadata: `name`, `folder`, `commands_subdir`, `install_url`, `requires_cli` | | `registrar_config` | Class attribute (dict) | Command output config: `dir`, `format`, `args` placeholder, file `extension` | -| `context_file` | Class attribute (str or None) | Path to agent context/instructions file (e.g., `"CLAUDE.md"`, `".github/copilot-instructions.md"`) | **Key design rule:** For CLI-based integrations (`requires_cli: True`), `key` must be the actual executable name (e.g., `"cursor-agent"` not `"cursor"`). This ensures `shutil.which(key)` works for CLI-tool checks without special-case mappings. IDE-based integrations (`requires_cli: False`) should use their canonical identifier (e.g., `"windsurf"`, `"copilot"`). @@ -175,9 +171,11 @@ def _register_builtins() -> None: ### 4. Context file behavior -Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate. +The Specify CLI carries **no agent-context state whatsoever**. Integration classes do **not** declare a `context_file`, and the CLI never creates, updates, removes, resolves, or migrates a context/instruction file (`CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`, …). New integrations add nothing for context handling. -The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml`: +Managing the "Spec Kit" section in the context file is fully owned by the bundled `agent-context` extension (`extensions/agent-context/`), which is a **full opt-in**: `specify init` does not install it. A user adds/enables it through the standard extension verbs, after which the extension's own bundled scripts maintain the context section. When the extension is absent or disabled, nothing in Spec Kit touches the context file. + +The extension reads its own config file at `.specify/extensions/agent-context/agent-context-config.yml`: ```yaml # Path to the coding agent context file managed by this extension @@ -189,10 +187,10 @@ context_markers: end: "" ``` -- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run. -- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly — both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth. +- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension. +- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly. -Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). +Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run. Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts — the `agent-context` extension is fully generic. @@ -401,7 +399,6 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`): 2. Extracts title and description from frontmatter 3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt) 4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping -5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there ## Branch Naming Convention @@ -466,7 +463,7 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag ## Common Pitfalls 1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint. -2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally. +2. **Reintroducing context handling into the CLI**: The opt-in `agent-context` extension owns everything about context files — including the per-agent default mapping in `agent-context-defaults.json`. Integration classes must **not** declare a `context_file`, and no CLI code should read, write, resolve, or migrate context files. All context-file logic lives in `.specify/extensions/agent-context/` and its bundled scripts. 3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents. 4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents. 5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added. diff --git a/CHANGELOG.md b/CHANGELOG.md index cbac946304..2bb40a42c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ +### Changed + +- feat!: make the `agent-context` extension a full opt-in. `specify init` no longer installs the extension or writes `agent-context-config.yml`, and the Specify CLI no longer creates, updates, removes, resolves, or migrates the managed Spec Kit section in agent context files (e.g. `CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`). All agent-context knowledge has been removed from the CLI — integration classes no longer declare a `context_file`, and the per-agent default mapping now ships with the extension itself as `agent-context-defaults.json`. The bundled `agent-context` extension fully owns this lifecycle and self-seeds from its own defaults map; install/enable it to manage the context section. Removed the obsolete inline agent-context deprecation warning. Existing projects keep working: previously written sections and config files are left intact and only updated by the extension. + ## [0.11.4] - 2026-06-22 ### Changed diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md index 091e2b4802..232bcfbeea 100644 --- a/extensions/agent-context/README.md +++ b/extensions/agent-context/README.md @@ -6,10 +6,10 @@ 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`). @@ -40,7 +40,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 +62,4 @@ pip install pyyaml specify extension disable agent-context ``` -When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`). -Disabled projects also ignore stale `context_files` values during command rendering so disabling the extension remains a complete opt-out. +When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal — the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out. diff --git a/extensions/agent-context/agent-context-defaults.json b/extensions/agent-context/agent-context-defaults.json new file mode 100644 index 0000000000..e5f4e92cb1 --- /dev/null +++ b/extensions/agent-context/agent-context-defaults.json @@ -0,0 +1,40 @@ +{ + "_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", + "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": "KIMI.md", + "kiro-cli": "AGENTS.md", + "lingma": ".lingma/rules/specify-rules.md", + "opencode": "AGENTS.md", + "pi": "AGENTS.md", + "qodercli": "QODER.md", + "qwen": "QWEN.md", + "roo": ".roo/rules/specify-rules.md", + "rovodev": "AGENTS.md", + "shai": "SHAI.md", + "tabnine": "TABNINE.md", + "trae": ".trae/rules/project_rules.md", + "vibe": "AGENTS.md", + "windsurf": ".windsurf/rules/specify-rules.md", + "zcode": "ZCODE.md", + "zed": "AGENTS.md" + } +} diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh index 9d57b08cf5..83b893c26f 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,64 @@ 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): + integration_key = opts.get("integration") or opts.get("ai") or "" + 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: + mapping = (json.load(fh) or {}).get("agents", {}) + 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")) diff --git a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 index d31fcd64c0..13881568ce 100644 --- a/extensions/agent-context/scripts/powershell/update-agent-context.ps1 +++ b/extensions/agent-context/scripts/powershell/update-agent-context.ps1 @@ -234,6 +234,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.$integrationKey + } + 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 diff --git a/specs/001-agent-context-full-optin/checklists/requirements.md b/specs/001-agent-context-full-optin/checklists/requirements.md new file mode 100644 index 0000000000..fb206aa4bd --- /dev/null +++ b/specs/001-agent-context-full-optin/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Agent-Context Extension Full Opt-In + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-06-22 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- Items marked incomplete require spec updates before `/speckit.clarify` or `/speckit.plan`. +- The spec intentionally keeps file/symbol-level removals out of the requirements; those belong to the planning phase. FR-007 and the Assumptions section settle the one genuine design choice — `context_file` is removed entirely from the CLI and the per-agent defaults map is relocated to the extension. diff --git a/specs/001-agent-context-full-optin/contracts/cli-behavior.md b/specs/001-agent-context-full-optin/contracts/cli-behavior.md new file mode 100644 index 0000000000..09ceab8596 --- /dev/null +++ b/specs/001-agent-context-full-optin/contracts/cli-behavior.md @@ -0,0 +1,92 @@ +# Phase 1 Contracts: CLI Behavioral Contracts + +**Feature**: 001-agent-context-full-optin | **Date**: 2026-06-22 + +This is a CLI tool, so the externally observable "contract" is the behavior of `specify` commands with respect to agent context files and the extension config. Each contract below is a testable assertion that the implementation and test suite must satisfy. + +## C1: `specify init` — extension absent/not selected + +**Given** a user runs `specify init` with an integration and does not select the `agent-context` extension +**When** initialization and integration setup complete +**Then**: +- No managed Spec Kit section is created in the agent context file by the CLI. +- `.specify/extensions/agent-context/agent-context-config.yml` is not created or written by the CLI. +- No deprecation message is printed. + +*Maps to*: FR-001, FR-002, FR-005, FR-006 · SC-001, SC-003 + +## C2: `specify init` — extension selected (opt-in) + +**Given** a user runs `specify init` and opts into the `agent-context` extension +**When** initialization completes +**Then**: +- The extension is installed via the normal extension mechanism. +- Any seeding of `agent-context-config.yml` is performed by the extension's own install logic, not by CLI agent-context helpers. +- Running the extension's update command produces a correct managed section. + +*Maps to*: FR-004, FR-005 · SC-005 + +## C3: Integration setup never writes the context section or resolves a context file + +**Given** any integration's `setup()` runs (regardless of extension state) +**When** command files are installed +**Then**: +- The CLI performs no `__CONTEXT_FILE__` substitution; the placeholder is no longer present in any core template and integration classes declare no `context_file`. Should a literal `__CONTEXT_FILE__` appear in a template, the CLI leaves it untouched. +- No call creates, updates, or removes a managed section in the agent context file. + +*Maps to*: FR-001, FR-003, FR-007 + +## C4: Integration teardown/uninstall never touches the context file or ext config + +**Given** an integration is uninstalled or switched +**When** the operation completes +**Then**: +- The CLI does not remove or rewrite any managed section. +- The CLI does not clear or rewrite `agent-context-config.yml`. + +*Maps to*: FR-001, FR-002 + +## C5: No agent-context logic remains in the base/init/switch layers + +**Given** the Specify CLI source after this feature +**When** inspected (e.g. by grep/CI check) +**Then** there are zero references to: +- `upsert_context_section`, `remove_context_section` +- `_agent_context_extension_enabled`, `_resolve_context_markers` +- `_resolve_context_files`, `_resolve_context_file_values`, `_format_context_file_values` +- the `context_file` class attribute, the `__CONTEXT_FILE__` placeholder, and `_context_file_display` +- the plural `context_files` config-key consumption +- `_AGENT_CTX_EXT_CONFIG`, `_load_agent_context_config`, `_save_agent_context_config`, `_update_agent_context_config_file` +- the v0.12.0 deprecation string + +(outside the `extensions/agent-context/` directory and this `specs/` artifact). + +*Maps to*: FR-002, FR-003, FR-006 · SC-002, SC-003 + +## C6: Backward compatibility + +**Given** a project created by a previous Spec Kit version (already has a managed section and/or `agent-context-config.yml`) +**When** the user runs `specify init`, an integration switch, or an uninstall +**Then** the commands complete without error and leave the pre-existing files intact (unmanaged by the CLI). + +*Maps to*: FR-008 · SC-006 + +## C7: Extension remains self-contained + +**Given** the `extensions/agent-context/` directory +**When** the extension's update command/script runs in an opt-in project +**Then** it reads its own `agent-context-config.yml` and updates the context file independently of any CLI agent-context code. + +*Maps to*: FR-004 · SC-005 + +## Contract Test Matrix + +| Contract | Test location (target) | Type | +|----------|------------------------|------| +| C1 | `tests/integrations/` + init tests | integration | +| C2 | `tests/extensions/test_extension_agent_context.py` | integration | +| C3 | `tests/integrations/test_integration_base_*.py` | unit | +| C4 | `tests/integrations/` switch/uninstall tests | unit/integration | +| C5 | new static/grep guard test (or CI check) | static | +| C6 | new backward-compat test | integration | +| C7 | `tests/extensions/test_extension_agent_context.py` (layout/script) | integration | diff --git a/specs/001-agent-context-full-optin/data-model.md b/specs/001-agent-context-full-optin/data-model.md new file mode 100644 index 0000000000..cf967a38a0 --- /dev/null +++ b/specs/001-agent-context-full-optin/data-model.md @@ -0,0 +1,106 @@ +# Phase 1 Data Model: Agent-Context Extension Full Opt-In + +**Feature**: 001-agent-context-full-optin | **Date**: 2026-06-22 + +This feature has no database or persistent domain model. The relevant "entities" are filesystem artifacts and the ownership boundary between the Specify CLI and the `agent-context` extension. This document captures those entities, their fields, and the ownership transition the feature enforces. + +## Entities + +### 1. Agent context file + +The per-agent instruction file that may contain a delimited managed section. + +| Field | Description | +|-------|-------------| +| `path` | Project-relative path, e.g. `CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`, `.cursor/rules/specify-rules.mdc` | +| `managed_section` | Text delimited by start/end markers, holding the Spec Kit plan reference | +| `user_content` | Any content outside the managed section (must never be touched) | + +**Ownership transition**: +- *Before*: written/updated/removed by the CLI (`upsert_context_section` / `remove_context_section`) **and** by the extension. +- *After*: written/updated/removed **only** by the `agent-context` extension's scripts. + +**Validation rules**: +- The CLI must make zero modifications to this file in any flow (FR-001, SC-001). +- The extension only manages the region between its configured markers; user content is preserved. + +### 2. Agent-context extension configuration + +`.specify/extensions/agent-context/agent-context-config.yml` + +| Field | Description | +|-------|-------------| +| `context_file` | Target context file path the extension manages | +| `context_markers.start` | Start delimiter (default ``) | +| `context_markers.end` | End delimiter (default ``) | + +**Ownership transition**: +- *Before*: read by CLI (`_load_agent_context_config`, marker resolution, `__CONTEXT_FILE__`) and written by CLI (`_save/_update_agent_context_config_file`) during init/switch/uninstall. +- *After*: read and written **only** by the extension (its scripts/install logic). + +**Validation rules**: +- No CLI code path reads or writes this file (FR-002, SC-002). + +### 3. Extension registry entry + +`.specify/extensions/.registry` → `extensions["agent-context"]` + +| Field | Description | +|-------|-------------| +| `version` | Installed extension version | +| `enabled` | Whether the extension is active | + +**Ownership transition**: +- *Before*: CLI gating (`_agent_context_extension_enabled`) read this to decide whether to run inline context updates. +- *After*: the registry still records install/enabled state (managed by the generic extension subsystem), but **no agent-context-specific gating logic** reads it. Whether the extension runs is governed by the normal extension/hook mechanism. + +**Validation rules**: +- No agent-context-specific enabled-gate helper remains in `base.py` (FR-003, SC-002). + +### 4. Integration definition + +`src/specify_cli/integrations//__init__.py` + +| Field | Description | +|-------|-------------| +| `key` | Integration identifier | +| `registrar_config`, `config` | Unchanged command/output metadata | + +**Ownership transition**: +- *Before*: `context_file` was a class attribute that triggered CLI context-section management via inherited `setup()`/`teardown()`. +- *After*: `context_file` is removed from integration classes entirely. The CLI holds no per-agent context-file knowledge. The extension ships its own `agent-context-defaults.json` (key→context_file) and self-seeds from it. + +**Validation rules**: +- No `context_file` field exists on any integration class; the CLI never reads, declares, resolves, or migrates a context file (FR-007). + +## State Transition: Section Ownership + +```text + BEFORE AFTER + ┌─────────────────────────────┐ ┌─────────────────────────────┐ + │ specify init / setup │ │ specify init / setup │ + │ → CLI upserts section │ │ → CLI does nothing to │ + │ → CLI writes ext config │ │ context file/ext config │ + │ → prints deprecation msg │ │ (no message) │ + ├─────────────────────────────┤ ├─────────────────────────────┤ + │ extension update (opt-in) │ │ extension update (opt-in) │ + │ → extension upserts │ │ → extension upserts │ ← sole owner + ├─────────────────────────────┤ ├─────────────────────────────┤ + │ teardown / uninstall │ │ teardown / uninstall │ + │ → CLI removes section │ │ → CLI does nothing │ + │ → CLI clears ext config │ │ (extension owns cleanup) │ + └─────────────────────────────┘ └─────────────────────────────┘ +``` + +## No-op / Removed Constructs + +These cease to exist (or cease to be referenced) after the feature: + +- `IntegrationBase.upsert_context_section`, `remove_context_section` (and their per-file loops over `context_files`) +- `IntegrationBase._agent_context_extension_enabled`, `_resolve_context_markers` +- `IntegrationBase._resolve_context_files`, `_resolve_context_file_values`, `_format_context_file_values`, and the `__CONTEXT_FILE__` substitution — removed entirely; the CLI no longer resolves or formats any context file +- The plural `context_files` config key consumption in the CLI +- The `context_file` class attribute on every integration; the per-agent default mapping now lives in `extensions/agent-context/agent-context-defaults.json` +- `_AGENT_CTX_EXT_CONFIG`, `_load_agent_context_config`, `_save_agent_context_config`, `_update_agent_context_config_file` +- Auto-install + config-write of `agent-context` in `commands/init.py` +- The v0.12.0 deprecation warning diff --git a/specs/001-agent-context-full-optin/plan.md b/specs/001-agent-context-full-optin/plan.md new file mode 100644 index 0000000000..a5f80baf95 --- /dev/null +++ b/specs/001-agent-context-full-optin/plan.md @@ -0,0 +1,97 @@ +# Implementation Plan: Agent-Context Extension Full Opt-In + +**Branch**: `001-agent-context-full-optin` | **Date**: 2026-06-22 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/001-agent-context-full-optin/spec.md` + +## Summary + +Make the bundled `agent-context` extension the sole owner of agent context/instruction file management. Remove every agent-context concern from the Specify CLI (Python) source: config-file I/O, context-section upsert/remove (including the upstream plural `context_files` support and its `_resolve_context_files` / `_resolve_context_file_values` / `_format_context_file_values` helpers), marker resolution, extension-enabled gating, the `__CONTEXT_FILE__` config lookup, the auto-install + config-write during `specify init`, and the inline deprecation warning. The extension already ships self-contained bash/PowerShell scripts and its own `agent-context-config.yml`, so removing the CLI-side logic makes the extension a true opt-in without losing end-user functionality. Existing projects keep working: previously written files are left intact and simply unmanaged by the CLI. + +## Technical Context + +**Language/Version**: Python 3.11+ (Specify CLI), plus bash/PowerShell extension scripts (unchanged) + +**Primary Dependencies**: Typer/Click CLI, `rich` console, PyYAML; no new dependencies + +**Storage**: Filesystem only — project files (`CLAUDE.md`, `AGENTS.md`, etc.), `.specify/extensions/agent-context/agent-context-config.yml`, `.specify/extensions/.registry` + +**Testing**: pytest (`tests/`), existing suite including `tests/extensions/test_extension_agent_context.py` and `tests/integrations/` + +**Target Platform**: Cross-platform CLI (macOS/Linux/Windows) + +**Project Type**: Single project — Python CLI with bundled extensions + +**Performance Goals**: N/A (no runtime-perf-sensitive paths; this is a removal/refactor) + +**Constraints**: No behavior change when the extension is installed+enabled and its update command runs; backward compatible with existing projects; no orphaned references that break imports or tests + +**Scale/Scope**: ~6 CLI source files touched, ~40 integration subclasses share the inherited base behavior, 1 deprecation message removed, test suite updated/relocated + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +Evaluated against **Spec Kit Constitution v1.0.0** (`.specify/memory/constitution.md`, present on `upstream/main`). All five binding principles pass; the feature is a net reduction in complexity and surface area. + +| Principle | Verdict | Notes | +|-----------|---------|-------| +| **I. Code Quality & Architectural Discipline** | ✅ PASS | Preserves the registry + base-class pattern. R1 removes the `context_file` class attribute entirely and relocates the per-agent defaults map into the `agent-context` extension, which becomes the sole owner of all context-file knowledge. This strengthens the single-source-of-truth rule: the CLI carries no agent-context state. No new cross-boundary `_`-private imports. | +| **II. Test-Backed Change (NON-NEGOTIABLE)** | ✅ PASS | Behavioral change ships with tests (FR-009, contracts C1–C7, quickstart). **Parity invariant guard**: every integration MUST keep its registry entry + `tests/integrations/test_integration_.py`; removing upsert/remove from the base layer MUST NOT delete or weaken those parity tests — only the agent-context-specific assertions are pruned/relocated. Security/idempotency suites (path-traversal, manifest, no-clobber) are untouched. Network stays mocked. Must pass the ubuntu+windows × py3.11/3.12/3.13 matrix. | +| **III. CLI & UX Consistency** | ✅ PASS | Making `agent-context` opt-in routes it through the standard extension verbs (`add`/`install`, `enable`/`disable`) instead of a bespoke auto-install — *more* consistent, not less. `init` stays idempotent ("already present" → exit 0). Removing the deprecation line keeps output grammar clean. User-facing behavior change → update `docs/` (FR-010). | +| **IV. Offline-First Performance & Resource Discipline** | ✅ PASS | No network paths involved. Removing config-file I/O during setup/teardown *reduces* filesystem writes; remaining writes stay idempotent and hash-tracked. No import-time cost added. | +| **V. Minimal Dependencies & Safe, Idempotent File Operations** | ✅ PASS | Zero new dependencies (net deletion). Backward-compat (FR-008) leaves pre-existing files intact — no clobber, no traversal. User-visible behavior change is called out for SemVer/changelog. | + +**Gate result: PASS** (no violations → Complexity Tracking table stays empty). + +**Note on environment**: This worktree branched from the fork's `main`, which is **16 commits behind `upstream/main`** and therefore lacks the `.specify/` directory locally. The constitution above is the authoritative `upstream/main` version. Implementation work (`/speckit.tasks`, `/speckit.implement`) should be done on a branch synced with `upstream/main` so `.specify/memory/constitution.md`, the security suites, and CI gates referenced here are actually present. + +## Project Structure + +### Documentation (this feature) + +```text +specs/001-agent-context-full-optin/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output (CLI behavioral contracts) +│ └── cli-behavior.md +├── checklists/ +│ └── requirements.md # From /speckit.specify +└── spec.md +``` + +### Source Code (repository root) + +```text +src/specify_cli/ +├── __init__.py # REMOVE: _AGENT_CTX_EXT_CONFIG + _load/_save/_update_agent_context_config helpers +├── agents.py # CHANGE: __CONTEXT_FILE__ resolution no longer reads extension config +├── commands/ +│ └── init.py # CHANGE: stop auto-installing agent-context + stop writing its config +└── integrations/ + ├── base.py # REMOVE: upsert/remove_context_section, _agent_context_extension_enabled, + │ # _resolve_context_markers, _resolve_context_file(s)/_format_context_file_values, + │ # marker constants usage, deprecation msg, and all setup()/teardown() call sites + ├── _helpers.py # REMOVE: agent-context config clear/update on switch + uninstall + └── /__init__.py # REMOVE context_file attribute (defaults map now lives in the extension) + +extensions/agent-context/ # ADD agent-context-defaults.json (key→context_file); scripts self-seed from it + +tests/ +├── extensions/test_extension_agent_context.py # RELOCATE/PRUNE: drop CLI-side gating + deprecation tests; +│ # keep extension-layout/script-driven tests +└── integrations/ # UPDATE: drop assertions that CLI writes context sections +``` + +**Structure Decision**: Single-project Python CLI. Changes are concentrated in the integration base layer and the init/switch/uninstall flows. The extension directory and its scripts are deliberately untouched — they are the new single owner. + +## Complexity Tracking + +No constitution violations. The work is a net *reduction* in complexity (deleting dual-ownership code paths). Table intentionally empty. + +| Violation | Why Needed | Simpler Alternative Rejected Because | +|-----------|------------|-------------------------------------| +| (none) | — | — | diff --git a/specs/001-agent-context-full-optin/quickstart.md b/specs/001-agent-context-full-optin/quickstart.md new file mode 100644 index 0000000000..f1ae009ae5 --- /dev/null +++ b/specs/001-agent-context-full-optin/quickstart.md @@ -0,0 +1,85 @@ +# Quickstart: Validating Agent-Context Full Opt-In + +**Feature**: 001-agent-context-full-optin | **Date**: 2026-06-22 + +This guide describes how to validate, end-to-end, that the `agent-context` extension is the sole owner of agent context files and that the Specify CLI contains no agent-context logic. It references the contracts in [contracts/cli-behavior.md](./contracts/cli-behavior.md) rather than duplicating assertions. + +## Prerequisites + +- Repo checked out on branch `001-agent-context-full-optin` +- Python environment for the Specify CLI (per `DEVELOPMENT.md`) +- `pytest` available + +## Setup + +```bash +# From the repo root +pip install -e . # or the project's documented dev install +``` + +## Validation 1 — Full test suite passes (SC-004) + +```bash +pytest -q +``` + +**Expected**: All tests pass. Tests covering removed CLI behavior (context-section upsert/remove, config writers, enabled gating, deprecation warning) have been pruned or relocated to the extension; no test asserts the removed deprecation message (C5, C1). + +## Validation 2 — No agent-context logic remains in the CLI (SC-002, SC-003) + +```bash +# Should produce NO matches outside extensions/agent-context/ and specs/ +grep -rn -E \ + 'upsert_context_section|remove_context_section|_agent_context_extension_enabled|_resolve_context_markers|_resolve_context_files|_AGENT_CTX_EXT_CONFIG|_load_agent_context_config|_save_agent_context_config|_update_agent_context_config_file' \ + src/specify_cli/ + +# Should produce NO matches (CLI no longer reads the extension's context_files list) +grep -rn "agent-context-config" src/specify_cli/ + +# Should produce NO matches (deprecation string removed) +grep -rn 'Inline agent-context updates' src/specify_cli/ +``` + +**Expected**: Both commands return nothing for `src/specify_cli/`. (Contract C5.) + +## Validation 3 — Init without the extension makes no context changes (SC-001, C1) + +```bash +tmp=$(mktemp -d) +# Initialize a project with an integration, WITHOUT opting into agent-context +specify init "$tmp/demo" --integration claude # adjust to the real non-interactive flags + +# The agent context file must have NO managed section written by the CLI +test ! -f "$tmp/demo/CLAUDE.md" || ! grep -q 'SPECKIT START' "$tmp/demo/CLAUDE.md" +# The extension config must NOT be written by the CLI +test ! -f "$tmp/demo/.specify/extensions/agent-context/agent-context-config.yml" +``` + +**Expected**: No managed section; no extension config written by the CLI; no deprecation output during init. (Contracts C1, C3.) + +## Validation 4 — Opt-in path still works end-to-end (SC-005, C2, C7) + +```bash +# In a project where the user opted into agent-context, run the extension update +# (slash command in an agent session, or the bundled script directly) +bash extensions/agent-context/scripts/bash/update-agent-context.sh # adjust args/cwd as documented +``` + +**Expected**: The extension reads its own `agent-context-config.yml` and creates/refreshes the managed Spec Kit section in the configured context file — proving no loss of functionality and that the extension is self-contained. (Contracts C2, C7.) + +## Validation 5 — Backward compatibility (SC-006, C6) + +```bash +# Simulate a legacy project: pre-existing managed section + ext config +# Then run init / integration switch / uninstall and confirm success + files untouched +``` + +**Expected**: Commands complete without error; pre-existing files remain intact and unmodified by the CLI. (Contract C6.) + +## Done When + +- [ ] `pytest -q` passes (Validation 1) +- [ ] Grep guards return no CLI matches (Validation 2) +- [ ] Init without extension makes no context/config changes (Validation 3) +- [ ] Extension update still manages the section (Validation 4) +- [ ] Legacy projects keep working (Validation 5) diff --git a/specs/001-agent-context-full-optin/research.md b/specs/001-agent-context-full-optin/research.md new file mode 100644 index 0000000000..b2f62f97d1 --- /dev/null +++ b/specs/001-agent-context-full-optin/research.md @@ -0,0 +1,92 @@ +# Phase 0 Research: Agent-Context Extension Full Opt-In + +**Feature**: 001-agent-context-full-optin | **Date**: 2026-06-22 + +This feature is a removal/refactor inside a known codebase, so "research" here resolves the design decisions implied by the spec (notably FR-007 and the Assumptions) rather than evaluating external technologies. Each item records the current state, the decision, the rationale, and rejected alternatives. + +## R1: Fate of the `context_file` class attribute on integrations + +**Current state**: All ~40 integration subclasses declare `context_file = "..."` (e.g. `claude → CLAUDE.md`). The base layer uses it to (a) drive `upsert_context_section()`/`remove_context_section()` and (b) substitute `__CONTEXT_FILE__` in templates via `process_template(..., context_file=...)`. + +**Decision**: **Remove `context_file` entirely** from all integration classes. The CLI keeps no per-agent context-file knowledge of any kind. The per-agent default mapping (key → context file) is relocated to the extension, which ships it as `agent-context-defaults.json` and self-seeds from it. The `__CONTEXT_FILE__` substitution and its supporting `process_template(..., context_file=...)` path are deleted, and the placeholder is removed from the core templates that used it (`templates/commands/plan.md`). + +**Rationale**: The hardened requirement (FR-007, revised) is that the CLI carry *no* context-file state — not even inert metadata the CLI can "see, handle, or migrate." Leaving the attribute or the `__CONTEXT_FILE__` resolver in place would keep agent-context concerns coupled to the CLI. Moving the default mapping into the extension makes the extension fully self-contained: it no longer depends on the CLI registry to discover an agent's context file. + +**Alternatives considered**: +- *Keep `context_file` as inert metadata (earlier Phase 1 decision)*: rejected on review — the goal is zero agent-context state in the CLI, so even unused metadata and placeholder resolution must go. +- *Keep the mapping in the CLI but stop using it*: rejected — the extension must own the mapping so it works regardless of CLI internals. + +## R2: How `__CONTEXT_FILE__` is resolved in `agents.py` + +**Current state** (post-sync with upstream/main): `agents.py` resolves `__CONTEXT_FILE__` by importing `_load_agent_context_config`, reading the extension's `agent-context-config.yml`, and passing it through `IntegrationBase._resolve_context_file_values(...)` / `_format_context_file_values(...)`. Upstream generalized the single `context_file` into a **plural `context_files`** concept with extension-config-driven resolution. + +**Decision**: **Remove `__CONTEXT_FILE__` resolution from the CLI entirely.** Delete the resolution block in `agents.py` and the `_resolve_context_file_values` / `_format_context_file_values` / `_resolve_context_files` helpers in `base.py`. The CLI no longer substitutes the placeholder; the core templates that referenced it are updated to drop it. Any stray `__CONTEXT_FILE__` that might appear in a template is passed through literally rather than resolved. + +**Rationale**: FR-002 forbids the CLI reading the extension config, and the hardened FR-007 forbids the CLI resolving context files at all. With the placeholder removed from the templates the CLI renders, no substitution is needed, so the entire resolver path is dead and is deleted. + +**Alternatives considered**: +- *Resolve from integration metadata instead of extension config (earlier Phase 1 decision)*: rejected — that still keeps the `context_file` attribute and a CLI-side resolver, which the hardened requirement disallows. +- *Leave the placeholder unresolved in templates*: avoided by removing the placeholder from the core templates outright, so no literal `__CONTEXT_FILE__` ships to users. + + +## R3: Auto-install of the `agent-context` extension during `specify init` + +**Current state**: `commands/init.py` (~lines 510–539) auto-installs the bundled `agent-context` extension during init, then writes its config (`context_file`) via `_update_agent_context_config_file` (~lines 541–549). It also appears as a tracker step and in `init.py:379` as a selectable install item. + +**Decision**: Make installation **opt-in**. Remove the unconditional auto-install + config-write. The extension is offered through the normal extension-selection mechanism (the user chooses it); when chosen, the extension's own install logic seeds its config. The CLI no longer writes `agent-context-config.yml`. + +**Rationale**: FR-005 requires the extension be opt-in; FR-002 forbids the CLI writing the extension config. Seeding config is the extension's responsibility (its bundled template `agent-context-config.yml` ships with the path/markers, and its install flow can populate `context_file`). + +**Open implementation detail (defer to tasks)**: Exactly how the extension seeds `context_file` at install (static template vs. install hook reading the selected integration) is an implementation choice for `/speckit.tasks`. The spec only requires the CLI not do it. Default assumption: the extension ships a template config and its update script tolerates an empty/defaulted `context_file` by deriving from the active integration, consistent with the existing self-contained scripts. + +**Alternatives considered**: +- *Keep auto-install but stop writing config*: rejected — auto-install still makes the extension non-opt-in, violating FR-005. + +## R4: Extension-enabled gating + marker resolution in `base.py` + +**Current state** (post-sync): `base.py` has `_agent_context_extension_enabled()` (~line 605, reads `.specify/extensions/.registry`) and `_resolve_context_markers()` (~line 645, reads `agent-context-config.yml`), used by `upsert_context_section()` (~line 895) and `remove_context_section()` (~line 948). Upstream additionally added `_resolve_context_file_values()` (~line 738), `_format_context_file_values()` (~line 787), and `_resolve_context_files()` (~line 791); upsert/remove now **iterate over a list of context files** (`for context_file in context_files:`) read from the extension config's plural `context_files` key. + +**Decision**: Remove the gating helper, the marker resolver, and the `upsert_context_section()` / `remove_context_section()` methods (and their per-file loops) along with all their call sites in `setup()`/`teardown()` across the base classes (call sites at ~lines 1181, 1200, 1309, 1518, 1725, 1959). Also remove `_resolve_context_files()`, `_resolve_context_file_values()`, and `_format_context_file_values()` outright — with `__CONTEXT_FILE__` resolution gone (R2), no metadata formatter survives. + +**Rationale**: FR-001/FR-003 — the base layer must not manage the section, gate on the extension, resolve markers, or read the extension's `context_files` list. With the upsert/remove methods gone, the gating, marker, and config-reading helpers are dead code. + +**Alternatives considered**: +- *Keep helpers "just in case"*: rejected — FR-002/FR-003 and SC-002 require zero such references remaining; dead code with config I/O still violates the spec's intent. + +## R5: The deprecation warning + +**Current state**: `upsert_context_section()` prints a `rich` "Deprecation: …v0.12.0… run `specify extension disable agent-context`" warning every time it runs (base.py ~line 924). A test asserts its presence. + +**Decision**: Remove the warning entirely (it disappears with the method) and remove/replace the asserting test. + +**Rationale**: FR-006 + SC-003 — the message must never be emitted. Since the inline behavior is removed (not merely deprecated), the message is obsolete. + +## R6: Backward compatibility for existing projects + +**Current state**: Existing projects may already contain a managed section in their context file and an `agent-context-config.yml`. + +**Decision**: Leave existing files untouched. The CLI performs no migration, cleanup, or rewrite. Further updates happen only when the user runs the extension's update command. + +**Rationale**: FR-008 + SC-006 — existing projects must keep working without errors. "Unmanaged by the CLI" is sufficient; no migration is required by the spec (stated explicitly in Assumptions). + +## R7: Test suite strategy + +**Current state**: `tests/extensions/test_extension_agent_context.py` covers CLI-side gating, marker resolution, config writers, and the deprecation warning. `tests/integrations/*` assert that setup writes context sections. + +**Decision**: Prune CLI-side tests that exercise removed behavior (gating, upsert/remove, config writers, deprecation). Keep/extend tests that validate the extension is self-contained (layout, scripts read their own config, catalog entry). Update integration tests to assert the CLI no longer writes context sections. + +**Rationale**: FR-009 + SC-004 — tests for removed behavior must be removed or relocated, and the suite must pass. + +## Summary of Decisions + +| ID | Decision | +|----|----------| +| R1 | Remove `context_file` from integrations; extension owns the defaults map | +| R2 | Remove `__CONTEXT_FILE__` resolution from the CLI; drop placeholder from templates | +| R3 | Extension install becomes opt-in; CLI stops writing its config | +| R4 | Remove gating + marker-resolution + upsert/remove from base layer | +| R5 | Remove the deprecation warning and its test | +| R6 | Leave existing files intact; no CLI migration | +| R7 | Prune/relocate tests for removed CLI behavior | + +All NEEDS CLARIFICATION resolved. No blocking unknowns remain for design. diff --git a/specs/001-agent-context-full-optin/spec.md b/specs/001-agent-context-full-optin/spec.md new file mode 100644 index 0000000000..844b5814bd --- /dev/null +++ b/specs/001-agent-context-full-optin/spec.md @@ -0,0 +1,107 @@ +# Feature Specification: Agent-Context Extension Full Opt-In + +**Feature Branch**: `001-agent-context-full-optin` + +**Created**: 2026-06-22 + +**Status**: Draft + +**Input**: User description: "Make the agent-context extension a full opt-in and have no configuration in the Python codebase that deals with any of it. The agent-context extension must fully own its own lifecycle and support should not be coming from the Specify CLI. We also need to make sure the deprecation message is removed if any." + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Agent context management is fully owned by the extension (Priority: P1) + +A Spec Kit user runs `specify init` for a project and selects a coding agent. Today, the Specify CLI itself writes and maintains the managed "Spec Kit" section inside the agent's context/instruction file (for example `CLAUDE.md`, `AGENTS.md`, or `.github/copilot-instructions.md`) during integration setup, in addition to the bundled `agent-context` extension. The user wants a single, predictable owner: the `agent-context` extension. When the extension is present and enabled, it manages the context section; when it is absent or disabled, nothing in the core CLI touches the context file. + +**Why this priority**: This is the core intent of the feature. Dual ownership (CLI + extension) creates ambiguity about who writes the context section, produces duplicate or conflicting updates, and blocks the extension from being a true opt-in. Establishing the extension as the sole owner is the foundational change everything else depends on. + +**Independent Test**: Initialize a project with any integration while the `agent-context` extension is NOT installed (or is disabled). Verify the core CLI creates no managed context section and writes no agent-context configuration. Then install/enable the extension and run its update command, and verify the context section is created and maintained by the extension alone. + +**Acceptance Scenarios**: + +1. **Given** a project initialized with an integration and the `agent-context` extension not installed, **When** integration setup runs, **Then** no managed Spec Kit section is created in the agent context file by the Specify CLI. +2. **Given** a project with the `agent-context` extension installed and enabled, **When** the extension's update command runs, **Then** the managed Spec Kit section in the agent context file is created or refreshed by the extension. +3. **Given** an integration is uninstalled or switched, **When** the operation completes, **Then** the core CLI performs no agent-context-related reads, writes, or cleanup. + +--- + +### User Story 2 - No agent-context configuration lives in the Python codebase (Priority: P1) + +A Spec Kit maintainer auditing the Specify CLI codebase wants to confirm that the CLI contains no agent-context-specific configuration logic: no reading or writing of the extension's config file, no `context_file` plumbing used to drive context-section updates, no marker constants used by core flows, and no enabled/disabled gating logic for the extension. All such concerns must live within the `agent-context` extension (its own scripts and config). + +**Why this priority**: The user explicitly requires that "no configuration in the Python codebase deals with any of it" and that "support should not be coming from the Specify CLI." Without removing this code, the extension cannot fully own its lifecycle and the separation remains incomplete. + +**Independent Test**: Search the Specify CLI source for agent-context concerns (config read/write helpers, context-section upsert/remove methods, extension-enabled gates, marker resolution). Confirm none remain wired into init, integration setup/teardown, or integration switching, and that the test suite still passes. + +**Acceptance Scenarios**: + +1. **Given** the Specify CLI source, **When** a maintainer inspects integration setup and teardown, **Then** there are no calls that create, update, or remove the managed agent context section. +2. **Given** the Specify CLI source, **When** a maintainer inspects `specify init` and integration switch/uninstall flows, **Then** there is no code that reads or writes the extension's `agent-context-config.yml`. +3. **Given** the Specify CLI source, **When** a maintainer inspects the integration base layer, **Then** there is no extension-enabled gating, marker-resolution, or context-file management logic for the agent-context extension. + +--- + +### User Story 3 - The deprecation message is removed (Priority: P2) + +A user running `specify init` or switching integrations currently sees a deprecation warning stating that inline agent-context updates during integration setup "will be disabled in v0.12.0" and pointing them to `specify extension disable agent-context`. Because the inline behavior is being removed entirely (not merely deprecated), this message is no longer accurate and must be removed so users are not warned about behavior that no longer exists. + +**Why this priority**: The user explicitly asked to remove the deprecation message "if any." It is a user-visible cleanup that depends on the removal of the inline behavior in User Stories 1 and 2, so it is sequenced after them. + +**Independent Test**: Run the flows that previously emitted the deprecation warning (integration setup) and confirm no agent-context deprecation message is printed. Confirm any test asserting the message's presence is removed or updated. + +**Acceptance Scenarios**: + +1. **Given** an integration setup that previously emitted the agent-context deprecation warning, **When** setup runs, **Then** no agent-context deprecation message is shown. +2. **Given** the test suite, **When** it runs, **Then** no test expects or asserts the removed deprecation message. + +--- + +### Edge Cases + +- **Extension disabled or absent**: When the `agent-context` extension is not installed or is explicitly disabled, the core CLI must do nothing to the agent context file — neither create, update, nor remove the managed section. +- **Pre-existing managed section from older versions**: A project initialized by a previous Spec Kit version may already contain a managed Spec Kit section in its context file. After this change, the core CLI must leave that section untouched; only the extension (when run) may update or remove it. +- **Pre-existing extension config file**: Projects that already have `.specify/extensions/agent-context/agent-context-config.yml` written by the old CLI must continue to work; the extension reads its own config, and the CLI must no longer overwrite it. +- **Integration switch with custom markers**: If a user customized the context markers in the extension config, switching integrations must not reset or rewrite those markers from the CLI side. +- **Documentation references**: Project documentation (for example `AGENTS.md`) that describes the CLI writing `context_file` "automatically" must be updated to reflect that the extension owns this behavior. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The Specify CLI MUST NOT create, update, or remove the managed Spec Kit section in any agent context/instruction file during integration setup, teardown, switching, or initialization. +- **FR-002**: The Specify CLI MUST NOT read from or write to the agent-context extension configuration file (`agent-context-config.yml`) in any code path (init, integration setup/teardown, integration switch/uninstall). +- **FR-003**: The integration base layer MUST NOT contain logic that resolves context markers, determines whether the agent-context extension is enabled, or manages the agent context section. +- **FR-004**: The `agent-context` extension MUST remain the sole owner of agent context file management, performing all reads, writes, marker resolution, and enable/disable behavior through its own bundled scripts and configuration. +- **FR-005**: The `agent-context` extension MUST be opt-in: when it is not installed or is disabled, no agent context section management occurs anywhere in Spec Kit. +- **FR-006**: The deprecation warning related to inline agent-context updates MUST be removed from the CLI so it is never emitted. +- **FR-007**: Integration definitions MUST NOT declare a context file. All agent→context-file knowledge (including the per-agent default mapping) MUST live within the `agent-context` extension. The CLI MUST contain no `context_file` field, plumbing, or default mapping in any form. +- **FR-008**: The change MUST NOT break existing projects: projects with previously-created managed sections or extension config files MUST continue to function, with the extension responsible for any further updates. +- **FR-009**: The automated test suite MUST be updated so that tests covering removed CLI behavior (context-section upsert/remove, config read/write, enabled gating, deprecation warning) are removed or relocated to the extension, and the suite passes. +- **FR-010**: Project documentation describing CLI-owned agent-context behavior MUST be updated to state that the `agent-context` extension fully owns this lifecycle. + +### Key Entities + +- **Agent context file**: The per-agent instruction file (e.g., `CLAUDE.md`, `AGENTS.md`, `.github/copilot-instructions.md`) containing a delimited "managed Spec Kit section." +- **Agent-context extension**: The bundled extension (`extensions/agent-context/`) that provides the update command, hooks, scripts, and configuration responsible for maintaining the managed section. +- **Agent-context configuration**: The extension's config file (`agent-context-config.yml`) holding the target context file path and section markers — read and written only by the extension. +- **Integration**: A Spec Kit agent integration whose setup/teardown previously triggered CLI-side context management. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: After integration setup with the extension absent or disabled, 0 changes are made to any agent context file by the Specify CLI. +- **SC-002**: 0 references to agent-context configuration read/write, context-section management, extension-enabled gating, or any `context_file` declaration/plumbing remain in the Specify CLI source (outside the extension itself). +- **SC-003**: The agent-context deprecation message is emitted 0 times across all CLI flows. +- **SC-004**: 100% of the existing automated test suite passes after the relevant tests are removed or relocated to the extension. +- **SC-005**: With the extension installed and enabled, running its update command still produces a correct managed section, demonstrating no loss of end-user functionality. +- **SC-006**: Existing projects created before this change continue to operate without errors when running init, integration switch, or uninstall. + +## Assumptions + +- The `agent-context` extension's bundled scripts (bash and PowerShell) are already self-contained — they read their own configuration and update the context file independently — and therefore require no changes to keep functioning after CLI-side logic is removed. +- The extension is "bundled" with Spec Kit but treated as opt-in: its presence/enabled state governs whether agent context management happens at all. +- Integration classes MUST NOT declare a context file. The per-agent default mapping is removed from the CLI entirely and ships with the extension as `agent-context-defaults.json`; the extension self-seeds from its own map with no dependency on the CLI registry. +- "No configuration in the Python codebase" refers to logic and configuration handling that drives agent-context behavior (config file I/O, marker resolution, enabled gating, section upsert/remove, and the related calls), not to incidental string constants that may remain only if unused by any active code path. +- Backward compatibility means existing files are left intact and unmanaged by the CLI; it does not require the CLI to migrate or clean up previously written artifacts. diff --git a/specs/001-agent-context-full-optin/tasks.md b/specs/001-agent-context-full-optin/tasks.md new file mode 100644 index 0000000000..d67bfda0a8 --- /dev/null +++ b/specs/001-agent-context-full-optin/tasks.md @@ -0,0 +1,196 @@ +# Tasks: Agent-Context Extension Full Opt-In + +**Input**: Design documents from `/specs/001-agent-context-full-optin/` + +**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/cli-behavior.md, quickstart.md + +**Tests**: INCLUDED. FR-009 requires test updates and Constitution Principle II (Test-Backed Change) is NON-NEGOTIABLE, so test tasks are mandatory here. + +**Organization**: Grouped by the three user stories. Note: US1 (remove section management) and US2 (remove config I/O) are both P1 and tightly coupled — US1's `upsert/remove` methods are the only callers of US2's config readers, and US3 (deprecation removal) falls out of US1 because the warning lives inside `upsert_context_section`. The foundational phase makes `__CONTEXT_FILE__` resolution config-independent so the config helpers can be deleted cleanly. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: US1, US2, US3 +- All paths are repository-relative. + +## Path Conventions + +Single project: Specify CLI source under `src/specify_cli/`, tests under `tests/`, bundled extension under `extensions/agent-context/`, docs at repo root and `docs/`. + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: Establish baseline and the static guard that defines "done". + +- [ ] T001 Run `pytest -q` from repo root and record the baseline pass state and the exact set of currently-passing agent-context tests that this feature will modify (`tests/extensions/test_extension_agent_context.py`, `tests/integrations/test_integration_{claude,codex,cursor_agent}.py`, `tests/integrations/test_registry.py`, `tests/integrations/test_integration_base_{markdown,skills}.py`). +- [ ] T002 [P] Add a static guard test `tests/extensions/test_agent_context_cli_free.py` that asserts `src/specify_cli/**` contains **zero** references to `upsert_context_section`, `remove_context_section`, `_agent_context_extension_enabled`, `_resolve_context_markers`, `_resolve_context_files`, `_AGENT_CTX_EXT_CONFIG`, `_load_agent_context_config`, `_save_agent_context_config`, `_update_agent_context_config_file`, the string `agent-context-config`, and the string `Inline agent-context updates`. This test is EXPECTED TO FAIL now and pass at the end (maps to contract C5). + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Remove `__CONTEXT_FILE__` resolution from the CLI and drop the placeholder from the core templates, so the config helpers can be deleted without leaving unresolved placeholders. + +**⚠️ CRITICAL**: T003–T004 MUST complete before US2 (config-helper deletion). + +- [ ] T003 In `src/specify_cli/agents.py`, delete the `__CONTEXT_FILE__` resolution block entirely (including the local `from . import _load_agent_context_config` import). The CLI no longer substitutes the placeholder. +- [ ] T004 In `src/specify_cli/integrations/base.py`, delete `_resolve_context_file_values`, `_format_context_file_values`, and `_resolve_context_files` outright — no context-file helper survives (R1/R2). Remove the `__CONTEXT_FILE__` placeholder from `templates/commands/plan.md`. +- [ ] T005 [P] Add/extend a unit test in `tests/integrations/test_integration_base_markdown.py` asserting that no `__CONTEXT_FILE__` placeholder survives in rendered command files and that the CLI never reads `agent-context-config.yml`. + +**Checkpoint**: Template rendering no longer depends on the extension config. + +--- + +## Phase 3: User Story 1 - Extension is sole owner of context management (Priority: P1) 🎯 MVP + +**Goal**: The CLI no longer creates/updates/removes the managed context section; the `agent-context` extension owns it entirely and self-seeds its own config. + +**Independent Test**: Run an integration `setup()`/`teardown()` in a `tmp_path` project with no extension installed → no managed section is written/removed by the CLI. With the extension installed+enabled, its update command still produces a correct section. + +### Tests for User Story 1 + +- [ ] T006 [P] [US1] Update `tests/integrations/test_integration_claude.py` to assert `setup()` does NOT create a `CLAUDE.md` managed section (remove the upsert assertion); keep the command-file install assertions. +- [ ] T007 [P] [US1] Update `tests/integrations/test_integration_codex.py` similarly (drop context-section assertions; keep skill/command assertions). +- [ ] T008 [P] [US1] Update `tests/integrations/test_integration_cursor_agent.py` similarly (drop `.mdc` managed-section + frontmatter assertions tied to upsert/remove). +- [ ] T009 [P] [US1] Update `tests/integrations/test_registry.py` to drop the unique-`context_file`-per-integration assertion if it exercises section management; keep the registry parity assertions (Principle II parity invariant MUST remain). +- [ ] T010 [P] [US1] In `tests/extensions/test_extension_agent_context.py`, prune the CLI-side section-management classes (`TestContextMarkerResolution`, `TestUpsertWithCustomMarkers`, `TestExtensionEnabledGate`) and relocate any still-valid behavior to extension-script-driven tests; keep `TestExtensionLayout` and `TestCatalogEntry`. +- [ ] T011 [P] [US1] Add an extension-driven test (in `tests/extensions/test_extension_agent_context.py`) that runs the bundled `extensions/agent-context/scripts/bash/update-agent-context.sh` in a `tmp_path` project (guarded with `@requires_bash`) and asserts it creates/refreshes the managed section from its own config (maps to contracts C2, C7). + +### Implementation for User Story 1 + +- [ ] T012 [US1] Remove `upsert_context_section()` and `remove_context_section()` methods from `src/specify_cli/integrations/base.py` (these contain the per-file `for context_file in context_files:` loops and the deprecation warning). +- [ ] T013 [US1] Remove all `self.upsert_context_section(project_root)` / `self.remove_context_section(project_root)` call sites in `setup()`/`teardown()` across the base classes in `src/specify_cli/integrations/base.py` (the 6 call sites: IntegrationBase, MarkdownIntegration, plus the Toml/Yaml/Skills/Copilot-style setups, and the teardown). +- [ ] T014 [US1] Remove now-dead helpers `_agent_context_extension_enabled()`, `_resolve_context_markers()`, `_resolve_context_files()`, `_build_context_section()`, and the `CONTEXT_MARKER_START`/`CONTEXT_MARKER_END` constants from `src/specify_cli/integrations/base.py` if no remaining code references them (verify with grep). +- [ ] T015 [US1] Make the `agent-context` extension self-seed its target context file so it no longer depends on the CLI writing `context_file`/`context_files`. Update `extensions/agent-context/scripts/bash/update-agent-context.sh` and `extensions/agent-context/scripts/powershell/update-agent-context.ps1` (and, if needed, `extensions/agent-context/agent-context-config.yml` defaults) so the script derives the context file from the active integration/registry when the config value is empty (R3, contract C7). + +**Checkpoint**: CLI setup/teardown touches no context file; extension manages it end-to-end. + +--- + +## Phase 4: User Story 2 - No agent-context configuration in the Python codebase (Priority: P1) + +**Goal**: Remove every agent-context config read/write from the CLI source. + +**Independent Test**: `grep` of `src/specify_cli/` for the config helper symbols and `agent-context-config` returns nothing (T002 guard passes); `specify init` writes no `agent-context-config.yml`. + +### Tests for User Story 2 + +- [ ] T016 [P] [US2] In `tests/extensions/test_extension_agent_context.py`, remove `TestExtensionConfigWriters` (it asserts CLI writes/clears the extension config) and replace with a test asserting the CLI does NOT create `.specify/extensions/agent-context/agent-context-config.yml` during integration switch/uninstall. +- [ ] T017 [P] [US2] Add an init-level test (in the appropriate `tests/` location, e.g. `tests/test_init*.py` or `tests/integrations/`) asserting that `specify init` WITHOUT selecting the extension creates no managed section and no extension config (contract C1, SC-001), and that init remains idempotent. + +### Implementation for User Story 2 + +- [ ] T018 [US2] Remove `_AGENT_CTX_EXT_CONFIG`, `_load_agent_context_config()`, `_save_agent_context_config()`, and `_update_agent_context_config_file()` from `src/specify_cli/__init__.py` (~lines 269–328, including the `context_files`/`preserve_context_files` handling). +- [ ] T019 [US2] In `src/specify_cli/commands/init.py`, remove the `_update_agent_context_config_file` import (~line 174), the agent-context auto-install block (~lines 510–539), and the config-write block (~lines 541–549). Ensure the extension is offered only through the normal opt-in extension-selection mechanism (R3, contract C2); remove the dedicated tracker step if it forced installation. +- [ ] T020 [US2] In `src/specify_cli/integrations/_helpers.py`, remove the agent-context config clearing on uninstall (~lines 108–134) and the config updating on integration switch (~lines 280–324), including the `_AGENT_CTX_EXT_CONFIG` / `_update_agent_context_config_file` imports and `context_file`/`context_files` popping that exists solely to feed the extension config. +- [ ] T021 [US2] Grep `src/specify_cli/` to confirm no remaining import or reference to the removed `__init__.py` helpers; fix any stragglers (e.g. `agents.py` should already be clean from T003). + +**Checkpoint**: T002 guard test passes; CLI is config-free for agent-context. + +--- + +## Phase 5: User Story 3 - Deprecation message removed (Priority: P2) + +**Goal**: The v0.12.0 deprecation warning is never emitted. + +**Independent Test**: Integration setup prints no agent-context deprecation message; no test asserts it. + +### Tests for User Story 3 + +- [ ] T022 [US3] In `tests/extensions/test_extension_agent_context.py`, remove `TestDeprecationWarning` (asserts the message is/ isn't emitted). The behavior it tested no longer exists. + +### Implementation for User Story 3 + +- [ ] T023 [US3] Verify the deprecation `console.print("…Inline agent-context updates…")` block is gone (it was removed with `upsert_context_section` in T012). Grep `src/specify_cli/` for `Inline agent-context updates` and `v0.12.0` to confirm zero matches (contract C5, SC-003). + +**Checkpoint**: No deprecation output anywhere. + +--- + +## Phase 6: Polish & Cross-Cutting Concerns + +**Purpose**: Docs, backward-compat verification, lint, and full validation. + +- [ ] T024 [P] Update `AGENTS.md` "Context file behavior" section to state the `agent-context` extension fully owns context-file creation/update/removal; remove the "`context_file` is written automatically … when `specify init` or `specify integration use` is run" language and the references to `upsert_context_section()` / `remove_context_section()` as Python-layer gates (FR-010). +- [ ] T025 [P] Update user-facing docs under `docs/` and `extensions/agent-context/README.md` to describe the extension as opt-in and the sole owner; add a `CHANGELOG.md` entry noting the behavior change (SemVer / Principle V). +- [ ] T026 [P] Update `src/specify_cli/integration_scaffold.py` (and `tests/` for it): remove the `context_file = "AGENTS.md"` line from the scaffold template and any comment/assertion referencing it, so newly scaffolded integrations declare no context file. +- [ ] T027 Add a backward-compatibility test: a `tmp_path` project pre-seeded with a legacy managed section and an `agent-context-config.yml` survives `init` / integration switch / uninstall unchanged by the CLI (contract C6, SC-006). +- [ ] T028 [P] Run `ruff check src/` and `markdownlint-cli2` on changed docs; fix violations (Security & Cross-Platform Constraints gate). +- [ ] T029 Run the full `pytest -q` suite and confirm green, including the T002 guard and the new extension-driven test (SC-004). Cross-platform note: bash-script test (T011) auto-skips on Windows via `@requires_bash`. +- [ ] T030 Execute `specs/001-agent-context-full-optin/quickstart.md` Validations 1–5 and confirm each passes (grep guards empty, init makes no context changes, extension update still works, legacy projects unaffected). + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies. T002 establishes the failing guard. +- **Foundational (Phase 2)**: Depends on Setup. BLOCKS US2 (must decouple `__CONTEXT_FILE__` from the config before deleting config helpers). +- **US1 (Phase 3)**: Depends on Foundational. Removing `upsert/remove` also removes the deprecation warning (enables US3) and the only callers of the config readers (enables US2). +- **US2 (Phase 4)**: Depends on Foundational + US1 (the config readers are dead once US1 removes their callers). +- **US3 (Phase 5)**: Depends on US1 (warning removed with `upsert`); mostly verification + test cleanup. +- **Polish (Phase 6)**: Depends on US1–US3 complete. + +### User Story Dependencies + +- **US1 (P1)**: Foundational only. The MVP slice — delivers "extension is sole owner". +- **US2 (P1)**: Builds on US1 (shared base.py file; sequence US1 → US2 to avoid churn). +- **US3 (P2)**: Effectively completed by US1; isolated here for traceability and test cleanup. + +### Within Each User Story + +- Update/relocate tests alongside the code change in the same file to keep the suite green per task group. +- `base.py` edits (T012–T014) are sequential (same file). `__init__.py`, `commands/init.py`, `_helpers.py` edits (T018–T020) touch different files and can parallelize once US1 lands. + +### Parallel Opportunities + +- T002 ‖ T001 follow-up. +- Test updates T006–T011 are different files → all `[P]`. +- US2 implementation T018 ‖ T019 ‖ T020 (different files), then T021 verification. +- Polish docs T024 ‖ T025 ‖ T026 ‖ T028. + +--- + +## Parallel Example: User Story 1 test updates + +```bash +# Different test files — safe to run/edit in parallel: +Task: "Update tests/integrations/test_integration_claude.py (T006)" +Task: "Update tests/integrations/test_integration_codex.py (T007)" +Task: "Update tests/integrations/test_integration_cursor_agent.py (T008)" +Task: "Update tests/integrations/test_registry.py (T009)" +``` + +--- + +## Implementation Strategy + +### MVP First (User Story 1) + +1. Phase 1 Setup → Phase 2 Foundational → Phase 3 US1. +2. **STOP and VALIDATE**: CLI setup/teardown touches no context file; extension still manages it. This is a shippable, behavior-correct increment. + +### Incremental Delivery + +1. US1 → extension is sole owner (MVP). +2. US2 → CLI is fully config-free (T002 guard goes green). +3. US3 → confirm/clean deprecation removal. +4. Polish → docs, backward-compat, lint, full suite, quickstart. + +### Constitution Gates (must hold throughout) + +- Principle II parity: every integration keeps its registry entry + `test_integration_.py`; do not delete parity tests (only context-section assertions). +- Network mocked; security/idempotency suites untouched. +- `ruff` + `markdownlint` + full pytest matrix green before merge. + +--- + +## Notes + +- `[P]` = different files, no dependencies. +- Remove `context_file` class attributes from all integrations — the CLI holds no context-file state; the per-agent defaults map lives in the extension (`agent-context-defaults.json`). +- Commit after each task or logical group; keep the suite green at every checkpoint. +- The bundled extension scripts are the new single owner — T015 is the one place extension behavior changes (self-seeding); all other extension files stay intact. diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py index 9f9efa09b1..7e48ddcd4e 100644 --- a/src/specify_cli/__init__.py +++ b/src/specify_cli/__init__.py @@ -263,85 +263,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 b1a5a932c2..7467a99cf7 100644 --- a/src/specify_cli/agents.py +++ b/src/specify_cli/agents.py @@ -426,37 +426,6 @@ def resolve_skill_placeholders( body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name) - # Resolve __CONTEXT_FILE__ from the agent-context extension config. - # When disabled, ignore stale context_files but keep the singular - # context_file value so generated commands still point at the agent - # context file managed before the extension was disabled. - from .integrations.base import IntegrationBase - - # Local import: _load_agent_context_config lives in __init__.py which - # imports agents.py, so a top-level import would be circular. - from . import _load_agent_context_config - - ac_cfg = _load_agent_context_config(project_root) - extension_enabled = IntegrationBase._agent_context_extension_enabled( - project_root - ) - if extension_enabled: - context_files = IntegrationBase._resolve_context_file_values( - project_root, - ac_cfg, - legacy_context_file=init_opts.get("context_file"), - ) - else: - context_files = IntegrationBase._resolve_context_file_values( - project_root, - ac_cfg, - legacy_context_file=init_opts.get("context_file"), - include_context_files=False, - validate=False, - ) - context_file = IntegrationBase._format_context_file_values(context_files) - body = body.replace("__CONTEXT_FILE__", context_file) - return CommandRegistrar.rewrite_project_relative_paths(body) def _convert_argument_placeholder( diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py index fc82334da2..dd815b8c5d 100644 --- a/src/specify_cli/commands/init.py +++ b/src/specify_cli/commands/init.py @@ -18,7 +18,6 @@ SCRIPT_TYPE_CHOICES, ) from .._assets import ( - _locate_bundled_extension, _locate_bundled_preset, _locate_bundled_workflow, get_speckit_version, @@ -171,7 +170,6 @@ def init( from .. import ( _install_shared_infra_or_exit, _print_cli_warning, - _update_agent_context_config_file, ensure_executable_scripts, save_init_options, ) @@ -376,7 +374,6 @@ def init( ("chmod", "Ensure scripts executable"), ("constitution", "Constitution setup"), ("workflow", "Install bundled workflow"), - ("agent-context", "Install agent-context extension"), ("final", "Finalize"), ]: tracker.add(key, label) @@ -507,47 +504,6 @@ def init( init_opts["ai_skills"] = True save_init_options(project_path, init_opts) - # --- agent-context extension (bundled, auto-installed) --- - # Installed after init-options.json is written so that skill - # registration can read ai_skills + integration key. - try: - from ..extensions import ExtensionManager as _ExtMgr - - bundled_ac = _locate_bundled_extension("agent-context") - if bundled_ac: - ac_mgr = _ExtMgr(project_path) - if ac_mgr.registry.is_installed("agent-context"): - tracker.complete("agent-context", "already installed") - else: - ac_mgr.install_from_directory( - bundled_ac, get_speckit_version() - ) - tracker.complete("agent-context", "extension installed") - else: - from ..extensions import REINSTALL_COMMAND as _ac_reinstall - - tracker.error( - "agent-context", - f"bundled extension not found — installation may be " - f"incomplete. Run: {_ac_reinstall}", - ) - except Exception as ac_err: - sanitized_ac = str(ac_err).replace("\n", " ").strip() - tracker.error( - "agent-context", - f"extension install failed: {sanitized_ac[:120]}", - ) - - # Write context_file to the agent-context extension config - # AFTER the extension install (which copies the template config - # with an empty context_file). - if resolved_integration.context_file: - _update_agent_context_config_file( - project_path, - resolved_integration.context_file, - preserve_markers=True, - ) - ensure_executable_scripts(project_path, tracker=tracker) if preset: diff --git a/src/specify_cli/integration_scaffold.py b/src/specify_cli/integration_scaffold.py index e4c4b83b3d..f0ed210332 100644 --- a/src/specify_cli/integration_scaffold.py +++ b/src/specify_cli/integration_scaffold.py @@ -117,11 +117,6 @@ class {class_name}({template.base_class}): "args": "{template.args}", "extension": "{template.extension}", }} - context_file = "AGENTS.md" - # Default to False so the generated boilerplate passes the registry - # contract out of the box: multi-install-safe integrations must each have a - # distinct context_file, and the placeholder above ("AGENTS.md") collides - # with the existing codex integration. Opt in once you pick a unique one. multi_install_safe = False ''' @@ -155,7 +150,6 @@ def test_metadata(): assert integration.registrar_config["format"] == "{template.registrar_format}" assert integration.registrar_config["args"] == "{template.args}" assert integration.registrar_config["extension"] == "{template.extension}" - assert integration.context_file == "AGENTS.md" assert integration.multi_install_safe is False ''' @@ -274,7 +268,7 @@ def scaffold_integration( next_steps = ( f"Register {class_name} in src/specify_cli/integrations/__init__.py.", - "Review config metadata, install_url, requires_cli, context_file, and multi_install_safe.", + "Review config metadata, install_url, requires_cli, and multi_install_safe.", f"Run pytest tests/integrations/test_integration_{package_name}.py -v.", ) return IntegrationScaffoldResult( diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py index c48cbad125..1490877e64 100644 --- a/src/specify_cli/integrations/_helpers.py +++ b/src/specify_cli/integrations/_helpers.py @@ -103,38 +103,17 @@ def _refresh_init_options_speckit_version(project_root: Path) -> None: def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None: - """Clear active integration keys from init-options.json when they match. - - Also clears ``context_file`` from the agent-context extension config so - no stale path is left behind when the integration is uninstalled. - """ + """Clear active integration keys from init-options.json when they match.""" from .. import ( - _AGENT_CTX_EXT_CONFIG, - _update_agent_context_config_file, load_init_options, save_init_options, ) opts = load_init_options(project_root) - has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts) - # Remove legacy fields that older versions may have written. - opts.pop("context_file", None) - opts.pop("context_markers", None) - if opts.get("integration") == integration_key or opts.get("ai") == integration_key: opts.pop("integration", None) opts.pop("ai", None) opts.pop("ai_skills", None) save_init_options(project_root, opts) - # Clear context_file in the extension config if it already exists. - # Avoid creating the config (and parent dirs) in projects where the - # agent-context extension was never installed. - ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG - if ext_cfg_path.exists(): - _update_agent_context_config_file( - project_root, "", preserve_markers=True, preserve_context_files=False - ) - elif has_legacy_context_keys: - save_init_options(project_root, opts) def _remove_integration_json(project_root: Path) -> None: @@ -274,21 +253,13 @@ def _update_init_options_for_integration( integration: Any, script_type: str | None = None, ) -> None: - """Update init-options.json and the agent-context extension config to - reflect *integration* as the active one. - - ``context_file``, ``context_files``, and ``context_markers`` are stored in the agent-context - extension config (``.specify/extensions/agent-context/agent-context-config.yml``), - not in ``init-options.json``. Existing user-customised markers are - always preserved when the config already exists. Existing ``context_files`` - lists are also preserved so projects can keep multi-agent context anchors - during integration switches. Invalid marker values are - silently ignored at runtime by ``_resolve_context_markers()`` which falls - back to the class-level defaults. + """Update init-options.json to reflect *integration* as the active one. + + Agent context/instruction files are owned entirely by the opt-in + agent-context extension, so this function never touches the extension + or its config. """ from .. import ( - _AGENT_CTX_EXT_CONFIG, - _update_agent_context_config_file, load_init_options, save_init_options, ) @@ -296,9 +267,6 @@ def _update_init_options_for_integration( opts = load_init_options(project_root) opts["integration"] = integration.key opts["ai"] = integration.key - # Remove legacy fields if they were written by an older version. - opts.pop("context_file", None) - opts.pop("context_markers", None) opts["speckit_version"] = _get_speckit_version() if script_type: opts["script"] = script_type @@ -307,24 +275,6 @@ def _update_init_options_for_integration( else: opts.pop("ai_skills", None) - # Update the agent-context extension config BEFORE init-options.json - # so a failure here doesn't leave init-options partially updated. - ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG - if ext_cfg_path.exists(): - _update_agent_context_config_file( - project_root, - integration.context_file, - preserve_markers=True, - ) - elif integration.context_file: - # Extension config doesn't exist yet (extension not installed). - # Write defaults so scripts have something to read. - _update_agent_context_config_file( - project_root, - integration.context_file, - preserve_markers=False, - ) - save_init_options(project_root, opts) diff --git a/src/specify_cli/integrations/agy/__init__.py b/src/specify_cli/integrations/agy/__init__.py index 6ed69e1e0e..33f8d17a91 100644 --- a/src/specify_cli/integrations/agy/__init__.py +++ b/src/specify_cli/integrations/agy/__init__.py @@ -42,7 +42,6 @@ class AgyIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @staticmethod def _inject_hook_command_note(content: str) -> str: diff --git a/src/specify_cli/integrations/amp/__init__.py b/src/specify_cli/integrations/amp/__init__.py index 39df0a9bbf..5d9d14250d 100644 --- a/src/specify_cli/integrations/amp/__init__.py +++ b/src/specify_cli/integrations/amp/__init__.py @@ -18,4 +18,3 @@ class AmpIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/auggie/__init__.py b/src/specify_cli/integrations/auggie/__init__.py index 08e20fbc25..e6fd702fa3 100644 --- a/src/specify_cli/integrations/auggie/__init__.py +++ b/src/specify_cli/integrations/auggie/__init__.py @@ -18,5 +18,4 @@ class AuggieIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".augment/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py index 3798778cce..9a8b65d76a 100644 --- a/src/specify_cli/integrations/base.py +++ b/src/specify_cli/integrations/base.py @@ -13,14 +13,13 @@ from __future__ import annotations -import json import os import re import shlex import shutil from abc import ABC from dataclasses import dataclass -from pathlib import Path, PureWindowsPath +from pathlib import Path from typing import TYPE_CHECKING, Any import yaml @@ -91,13 +90,9 @@ class IntegrationBase(ABC): And may optionally set: - * ``context_file`` — path (relative to project root) of the agent - context/instructions file (e.g. ``"CLAUDE.md"``) - - Projects may additionally opt into managing multiple context files by - setting ``context_files`` in the agent-context extension config. The - integration class still declares one default ``context_file`` for backwards - compatibility and command-template rendering. + * ``invoke_separator`` — slash-command separator (defaults to ``"."``) + * ``multi_install_safe`` — declare the integration safe to install + alongside others (defaults to ``False``) """ # -- Must be set by every subclass ------------------------------------ @@ -113,25 +108,17 @@ 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``).""" 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 @@ -530,498 +517,6 @@ def install_scripts( return created - # -- Agent context file management ------------------------------------ - - @staticmethod - def _ensure_mdc_frontmatter(content: str) -> str: - """Ensure ``.mdc`` content has YAML frontmatter with ``alwaysApply: true``. - - If frontmatter is missing, prepend it. If frontmatter exists but - ``alwaysApply`` is absent or not ``true``, inject/fix it. - - Uses string/regex manipulation to preserve comments and formatting - in existing frontmatter. - """ - import re as _re - - leading_ws = len(content) - len(content.lstrip()) - leading = content[:leading_ws] - stripped = content[leading_ws:] - - if not stripped.startswith("---"): - return "---\nalwaysApply: true\n---\n\n" + content - - # Match frontmatter block: ---\n...\n--- - match = _re.match( - r"^(---[ \t]*\r?\n)(.*?)(\r?\n---[ \t]*)(\r?\n|$)(.*)", - stripped, - _re.DOTALL, - ) - if not match: - return "---\nalwaysApply: true\n---\n\n" + content - - opening, fm_text, closing, sep, rest = match.groups() - newline = "\r\n" if "\r\n" in opening else "\n" - - # Already correct? - if _re.search( - r"(?m)^[ \t]*alwaysApply[ \t]*:[ \t]*true[ \t]*(?:#.*)?$", fm_text - ): - return content - - # alwaysApply exists but wrong value — fix in place while preserving - # indentation and any trailing inline comment. - if _re.search(r"(?m)^[ \t]*alwaysApply[ \t]*:", fm_text): - fm_text = _re.sub( - r"(?m)^([ \t]*)alwaysApply[ \t]*:.*?([ \t]*(?:#.*)?)$", - r"\1alwaysApply: true\2", - fm_text, - count=1, - ) - elif fm_text.strip(): - fm_text = fm_text + newline + "alwaysApply: true" - else: - fm_text = "alwaysApply: true" - - return f"{leading}{opening}{fm_text}{closing}{sep}{rest}" - - @staticmethod - def _build_context_section(plan_path: str = "") -> str: - """Build the content for the managed section between markers. - - *plan_path* is the project-relative path to the current plan - (e.g. ``"specs//plan.md"``). When empty, the section - contains only the generic directive without a concrete path. - """ - lines = [ - "For additional context about technologies to be used, project structure,", - "shell commands, and other important information, read the current plan", - ] - if plan_path: - lines.append(f"at {plan_path}") - return "\n".join(lines) - - @staticmethod - def _agent_context_extension_enabled(project_root: Path) -> bool: - """Return whether the bundled ``agent-context`` extension is enabled. - - The extension is the single source of truth for managing coding - agent context/instruction files (e.g. ``CLAUDE.md``, - ``.github/copilot-instructions.md``). - - Returns ``True`` (enabled) when: - - the extension registry does not exist (legacy project, backwards - compatibility), or - - the registry has no ``agent-context`` entry (older project layout - predating the extension), or - - the entry is present and not explicitly disabled. - - Returns ``False`` only when an entry exists with ``enabled: false``. - """ - registry_path = ( - project_root / ".specify" / "extensions" / ".registry" - ) - if not registry_path.exists(): - return True - try: - data = json.loads(registry_path.read_text(encoding="utf-8")) - except (OSError, ValueError, UnicodeError): - return True - if not isinstance(data, dict): - return True - extensions = data.get("extensions") - if not isinstance(extensions, dict): - return True - entry = extensions.get("agent-context") - if not isinstance(entry, dict): - return True - return entry.get("enabled", True) is not False - - @staticmethod - def _context_file_dedupe_key(path: str) -> str: - """Return the comparison key for context file de-duplication.""" - return path.casefold() if os.name == "nt" else path - - def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]: - """Return the (start, end) context markers to use for *project_root*. - - Reads ``context_markers.start`` / ``context_markers.end`` from the - agent-context extension config - (``.specify/extensions/agent-context/agent-context-config.yml``) - when present. Falls back to the class-level constants - ``CONTEXT_MARKER_START`` / ``CONTEXT_MARKER_END`` when the file is - missing, the section is absent, or the values are not non-empty - strings. - """ - from .._console import console # local import to avoid cycles - - start = self.CONTEXT_MARKER_START - end = self.CONTEXT_MARKER_END - config_path = ( - project_root - / ".specify" - / "extensions" - / "agent-context" - / "agent-context-config.yml" - ) - try: - raw = config_path.read_text(encoding="utf-8") - cfg = yaml.safe_load(raw) - except (OSError, UnicodeError, ValueError, yaml.YAMLError): - return start, end - markers = cfg.get("context_markers") if isinstance(cfg, dict) else None - if isinstance(markers, dict): - cm_start = markers.get("start") - cm_end = markers.get("end") - s_valid = isinstance(cm_start, str) and cm_start - e_valid = isinstance(cm_end, str) and cm_end - if not s_valid and cm_start is not None: - console.print( - f"[yellow]agent-context: ignoring invalid context_markers.start " - f"({cm_start!r}), using default[/yellow]" - ) - if not e_valid and cm_end is not None: - console.print( - f"[yellow]agent-context: ignoring invalid context_markers.end " - f"({cm_end!r}), using default[/yellow]" - ) - if s_valid: - start = cm_start # type: ignore[assignment] - if e_valid: - end = cm_end # type: ignore[assignment] - return start, end - - @staticmethod - def _validate_context_file_path(project_root: Path, context_file: str) -> str: - """Return a safe project-relative context file path. - - The agent-context scripts reject paths that can escape the project - root; the Python integration path must apply the same guard before - setup or teardown touches context files. - """ - candidate = context_file.strip() - if not candidate: - raise ValueError("agent-context: context file path must not be empty") - - win_path = PureWindowsPath(candidate) - if Path(candidate).is_absolute() or win_path.drive or win_path.root: - raise ValueError( - "agent-context: context files must be project-relative paths; " - f"got {candidate!r}" - ) - if "\\" in candidate: - raise ValueError( - "agent-context: context files must not contain backslash " - f"separators; got {candidate!r}" - ) - - parts = [part for part in re.split(r"[\\/]+", candidate) if part] - if ".." in parts: - raise ValueError( - "agent-context: context files must not contain '..' path " - f"segments; got {candidate!r}" - ) - - root = project_root.resolve() - target = (root / candidate).resolve(strict=False) - try: - target.relative_to(root) - except ValueError as exc: - raise ValueError( - "agent-context: context file path resolves outside the project " - f"root; got {candidate!r}" - ) from exc - - return candidate - - @classmethod - def _resolve_context_file_values( - cls, - project_root: Path, - cfg: dict[str, Any] | None, - *, - fallback_context_file: Any = None, - legacy_context_file: Any = None, - include_context_files: bool = True, - validate: bool = True, - ) -> list[str]: - """Resolve context file config with shared precedence and de-duplication.""" - files: list[str] = [] - seen: set[str] = set() - - def add_context_file(value: Any) -> None: - if not isinstance(value, str): - return - candidate = value.strip() - if not candidate: - return - if validate: - candidate = cls._validate_context_file_path(project_root, candidate) - key = cls._context_file_dedupe_key(candidate) - if key in seen: - return - files.append(candidate) - seen.add(key) - - if isinstance(cfg, dict) and include_context_files: - configured = cfg.get("context_files") - if isinstance(configured, list): - for value in configured: - add_context_file(value) - if files: - return files - - if isinstance(cfg, dict): - add_context_file(cfg.get("context_file")) - if files: - return files - - add_context_file(fallback_context_file) - if files: - return files - - add_context_file(legacy_context_file) - return files - - @staticmethod - def _format_context_file_values(context_files: list[str]) -> str: - """Return context file targets as the template display string.""" - return ", ".join(context_files) - - def _resolve_context_files(self, project_root: Path) -> list[str]: - """Return project-relative context files managed for *project_root*. - - ``context_files`` in the agent-context extension config, when present - and non-empty, takes precedence over the config's singular - ``context_file``. The integration class default is used only when the - extension config has no context file target. - Raises ``ValueError`` when a configured path can escape the project - root. - """ - config_path = ( - project_root - / ".specify" - / "extensions" - / "agent-context" - / "agent-context-config.yml" - ) - try: - raw = config_path.read_text(encoding="utf-8") - cfg = yaml.safe_load(raw) - except (OSError, UnicodeError, ValueError, yaml.YAMLError): - cfg = None - return self._resolve_context_file_values( - project_root, - cfg, - fallback_context_file=self.context_file, - ) - - def _context_file_display(self, project_root: Path) -> str: - """Return human-readable context file target(s) for templates.""" - if not self._agent_context_extension_enabled(project_root): - from .. import _load_agent_context_config - - context_files = self._resolve_context_file_values( - project_root, - _load_agent_context_config(project_root), - fallback_context_file=self.context_file, - include_context_files=False, - validate=False, - ) - return context_files[0] if context_files else "" - return self._format_context_file_values( - self._resolve_context_files(project_root) - ) - - @staticmethod - def _upsert_context_file( - ctx_path: Path, - section: str, - marker_start: str, - marker_end: str, - ) -> None: - """Create or update one managed context section.""" - if ctx_path.exists(): - content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(marker_start) - end_idx = content.find( - marker_end, - start_idx if start_idx != -1 else 0, - ) - - if start_idx != -1 and end_idx != -1 and end_idx > start_idx: - # Replace existing section (include the end marker + newline) - end_of_marker = end_idx + len(marker_end) - # Consume trailing line ending (CRLF or LF) - if end_of_marker < len(content) and content[end_of_marker] == "\r": - end_of_marker += 1 - if end_of_marker < len(content) and content[end_of_marker] == "\n": - end_of_marker += 1 - new_content = content[:start_idx] + section + content[end_of_marker:] - elif start_idx != -1: - # Corrupted: start marker without end — replace from start through EOF - new_content = content[:start_idx] + section - elif end_idx != -1: - # Corrupted: end marker without start — replace BOF through end marker - end_of_marker = end_idx + len(marker_end) - if end_of_marker < len(content) and content[end_of_marker] == "\r": - end_of_marker += 1 - if end_of_marker < len(content) and content[end_of_marker] == "\n": - end_of_marker += 1 - new_content = section + content[end_of_marker:] - else: - # No markers found — append - if content: - if not content.endswith("\n"): - content += "\n" - new_content = content + "\n" + section - else: - new_content = section - - # Ensure .mdc files have required YAML frontmatter - if ctx_path.suffix == ".mdc": - new_content = IntegrationBase._ensure_mdc_frontmatter(new_content) - else: - ctx_path.parent.mkdir(parents=True, exist_ok=True) - # Cursor .mdc files require YAML frontmatter to be loaded - if ctx_path.suffix == ".mdc": - new_content = IntegrationBase._ensure_mdc_frontmatter(section) - else: - new_content = section - - normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") - ctx_path.write_bytes(normalized.encode("utf-8")) - - def upsert_context_section( - self, - project_root: Path, - plan_path: str = "", - ) -> Path | None: - """Create or update the managed section in the agent context file. - - If the context file does not exist it is created with just the - managed section. If it exists, the content between the configured - start/end markers (default ```` / - ````) is replaced, or appended when no markers - are found. Markers are read from the agent-context extension config - (``.specify/extensions/agent-context/agent-context-config.yml``) - when present, falling back to the class-level constants. - - Returns the path to the first context file, or ``None`` when no context - files are configured or the ``agent-context`` extension is - disabled. - """ - if not self._agent_context_extension_enabled(project_root): - return None - - context_files = self._resolve_context_files(project_root) - if not context_files: - return None - - from .._console import console # local import to avoid cycles - - console.print( - "[yellow]Deprecation:[/yellow] Inline agent-context updates during " - "integration setup will be disabled in v0.12.0. Context file " - "management has moved to the bundled [bold]agent-context[/bold] " - "extension. Run [cyan]specify extension disable agent-context[/cyan] " - "to opt out early.", - highlight=False, - ) - - marker_start, marker_end = self._resolve_context_markers(project_root) - - section = ( - f"{marker_start}\n" - f"{self._build_context_section(plan_path)}\n" - f"{marker_end}\n" - ) - - first_path: Path | None = None - for context_file in context_files: - ctx_path = project_root / context_file - self._upsert_context_file(ctx_path, section, marker_start, marker_end) - if first_path is None: - first_path = ctx_path - return first_path - - def remove_context_section(self, project_root: Path) -> bool: - """Remove the managed section from the agent context file. - - Returns ``True`` if the section was found and removed. If the - file becomes empty (or whitespace-only) after removal it is deleted. - Markers are read from the agent-context extension config - (``.specify/extensions/agent-context/agent-context-config.yml``) - when present, falling back to the class-level constants. - """ - if not self._agent_context_extension_enabled(project_root): - return False - - context_files = self._resolve_context_files(project_root) - if not context_files: - return False - - marker_start, marker_end = self._resolve_context_markers(project_root) - removed_any = False - - for context_file in context_files: - ctx_path = project_root / context_file - if not ctx_path.exists(): - continue - - content = ctx_path.read_text(encoding="utf-8-sig") - start_idx = content.find(marker_start) - end_idx = content.find( - marker_end, - start_idx if start_idx != -1 else 0, - ) - - # Only remove a complete, well-ordered managed section. If either - # marker is missing, leave the file unchanged to avoid deleting - # unrelated user-authored content. - if start_idx == -1 or end_idx == -1 or end_idx <= start_idx: - continue - - removal_start = start_idx - removal_end = end_idx + len(marker_end) - - # Consume trailing line ending (CRLF or LF) - if removal_end < len(content) and content[removal_end] == "\r": - removal_end += 1 - if removal_end < len(content) and content[removal_end] == "\n": - removal_end += 1 - - # Also strip a blank line before the section if present - if removal_start > 0 and content[removal_start - 1] == "\n": - if removal_start > 1 and content[removal_start - 2] == "\n": - removal_start -= 1 - - new_content = content[:removal_start] + content[removal_end:] - - # Normalize line endings before comparisons - normalized = new_content.replace("\r\n", "\n").replace("\r", "\n") - - # For .mdc files, treat Speckit-generated frontmatter-only content as empty - if ctx_path.suffix == ".mdc": - import re - - # Delete the file if only YAML frontmatter remains (no body content) - frontmatter_only = re.match( - r"^---\n.*?\n---\s*$", normalized, re.DOTALL - ) - if not normalized.strip() or frontmatter_only: - ctx_path.unlink() - removed_any = True - continue - - if not normalized.strip(): - ctx_path.unlink() - else: - ctx_path.write_bytes(normalized.encode("utf-8")) - removed_any = True - - return removed_any - @staticmethod def resolve_command_refs(content: str, separator: str = ".") -> str: """Replace ``__SPECKIT_COMMAND___`` placeholders with invocations. @@ -1046,7 +541,6 @@ def process_template( agent_name: str, script_type: str, arg_placeholder: str = "$ARGUMENTS", - context_file: str = "", invoke_separator: str = ".", ) -> str: """Process a raw command template into agent-ready content. @@ -1057,9 +551,8 @@ def process_template( 3. Strip ``scripts:`` section from frontmatter 4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder* 5. Replace ``__AGENT__`` with *agent_name* - 6. Replace ``__CONTEXT_FILE__`` with *context_file* - 7. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. - 8. Replace ``__SPECKIT_COMMAND___`` with invocation strings + 6. Rewrite paths: ``scripts/`` → ``.specify/scripts/`` etc. + 7. Replace ``__SPECKIT_COMMAND___`` with invocation strings """ # 1. Extract script command from frontmatter script_command = "" @@ -1119,10 +612,7 @@ def process_template( # 5. Replace __AGENT__ content = content.replace("__AGENT__", agent_name) - # 6. Replace __CONTEXT_FILE__ - content = content.replace("__CONTEXT_FILE__", context_file) - - # 7. Rewrite paths — delegate to the shared implementation in + # 6. Rewrite paths — delegate to the shared implementation in # CommandRegistrar so extension-local paths are preserved and # boundary rules stay consistent across the codebase. from specify_cli.agents import CommandRegistrar @@ -1177,8 +667,6 @@ def setup( self.record_file_in_manifest(dst_file, project_root, manifest) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1193,11 +681,9 @@ def teardown( Delegates to ``manifest.uninstall()`` which only removes files whose hash still matches the recorded value (unless *force*). - Also removes the managed context section from the agent file. Returns ``(removed, skipped)`` file lists. """ - self.remove_context_section(project_root) return manifest.uninstall(project_root, force=force) # -- Convenience helpers for subclasses ------------------------------- @@ -1231,12 +717,11 @@ def uninstall( class MarkdownIntegration(IntegrationBase): """Concrete base for integrations that use standard Markdown commands. - Subclasses only need to set ``key``, ``config``, ``registrar_config`` - (and optionally ``context_file``). Everything else is inherited. + Subclasses only need to set ``key``, ``config``, ``registrar_config``. + Everything else is inherited. ``setup()`` processes command templates (replacing ``{SCRIPT}``, - ``{ARGS}``, ``__AGENT__``, rewriting paths) and upserts the - managed context section into the agent context file. + ``{ARGS}``, ``__AGENT__``, rewriting paths). """ def build_exec_args( @@ -1291,13 +776,11 @@ def setup( else "$ARGUMENTS" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -1305,8 +788,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1320,8 +801,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 @@ -1497,14 +977,12 @@ def setup( else "{{args}}" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") description = self._extract_description(raw) processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) _, body = self._split_frontmatter(processed) toml_content = self._render_toml(description, body) @@ -1514,8 +992,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1529,8 +1005,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 @@ -1693,7 +1168,6 @@ def setup( else "{{args}}" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -1709,7 +1183,6 @@ def setup( processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) _, body = self._split_frontmatter(processed) yaml_content = self._render_yaml( @@ -1721,8 +1194,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created @@ -1738,8 +1209,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``). @@ -1884,7 +1355,6 @@ def setup( else "$ARGUMENTS" ) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -1908,7 +1378,6 @@ def setup( # Process body through the standard template pipeline processed_body = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. @@ -1955,7 +1424,5 @@ def _quote(v: str) -> str: ) created.append(dst) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py index 78f2df0379..b953151bd2 100644 --- a/src/specify_cli/integrations/bob/__init__.py +++ b/src/specify_cli/integrations/bob/__init__.py @@ -18,4 +18,3 @@ class BobIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py index 0df388172d..f7a3687313 100644 --- a/src/specify_cli/integrations/claude/__init__.py +++ b/src/specify_cli/integrations/claude/__init__.py @@ -48,7 +48,6 @@ class ClaudeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "CLAUDE.md" multi_install_safe = True @staticmethod diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py index c269a16042..ab839b9b56 100644 --- a/src/specify_cli/integrations/cline/__init__.py +++ b/src/specify_cli/integrations/cline/__init__.py @@ -70,7 +70,6 @@ class ClineIntegration(MarkdownIntegration): "format_name": format_cline_command_name, "invoke_separator": "-", } - context_file = ".clinerules/specify-rules.md" invoke_separator = "-" multi_install_safe = True diff --git a/src/specify_cli/integrations/codebuddy/__init__.py b/src/specify_cli/integrations/codebuddy/__init__.py index 980ac7fed7..6b40e10eaf 100644 --- a/src/specify_cli/integrations/codebuddy/__init__.py +++ b/src/specify_cli/integrations/codebuddy/__init__.py @@ -18,5 +18,4 @@ class CodebuddyIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "CODEBUDDY.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py index 1f7dbc601f..eb45c834ff 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" multi_install_safe = True def build_exec_args( diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py index 2659b3f252..5cc34d2b1d 100644 --- a/src/specify_cli/integrations/copilot/__init__.py +++ b/src/specify_cli/integrations/copilot/__init__.py @@ -4,7 +4,6 @@ - Commands use ``.agent.md`` extension (not ``.md``) - Each command gets a companion ``.prompt.md`` file in ``.github/prompts/`` - Installs ``.vscode/settings.json`` with prompt file recommendations -- Context file lives at ``.github/copilot-instructions.md`` When ``--skills`` is passed via ``--integration-options``, Copilot scaffolds commands as ``speckit-/SKILL.md`` directories under ``.github/skills/`` @@ -79,7 +78,6 @@ class _CopilotSkillsHelper(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".github/copilot-instructions.md" class CopilotIntegration(IntegrationBase): @@ -108,7 +106,6 @@ class CopilotIntegration(IntegrationBase): "args": "$ARGUMENTS", "extension": ".agent.md", } - context_file = ".github/copilot-instructions.md" # Mutable flag set by setup() — indicates the active scaffolding mode. _skills_mode: bool = False @@ -354,14 +351,12 @@ def _setup_default( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "$ARGUMENTS") - context_file_display = self._context_file_display(project_root) # 1. Process and write command files as .agent.md for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -396,8 +391,6 @@ def _setup_default( self.record_file_in_manifest(dst_settings, project_root, manifest) created.append(dst_settings) - # 4. Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py index b83ee42e54..2c328b2fda 100644 --- a/src/specify_cli/integrations/cursor_agent/__init__.py +++ b/src/specify_cli/integrations/cursor_agent/__init__.py @@ -36,7 +36,6 @@ class CursorAgentIntegration(SkillsIntegration): "extension": "/SKILL.md", } - context_file = ".cursor/rules/specify-rules.mdc" multi_install_safe = True def build_exec_args( diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py index b3b21b8526..18c1fc8d6d 100644 --- a/src/specify_cli/integrations/devin/__init__.py +++ b/src/specify_cli/integrations/devin/__init__.py @@ -30,7 +30,6 @@ class DevinIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" def build_exec_args( self, diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py index d1cd7a49a8..8c21353fec 100644 --- a/src/specify_cli/integrations/forge/__init__.py +++ b/src/specify_cli/integrations/forge/__init__.py @@ -89,7 +89,6 @@ class ForgeIntegration(MarkdownIntegration): "format_name": format_forge_command_name, # Custom name formatter "invoke_separator": "-", } - context_file = "AGENTS.md" invoke_separator = "-" def setup( @@ -128,14 +127,12 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = self.registrar_config.get("args", "{{parameters}}") created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") # Process template with standard MarkdownIntegration logic processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) @@ -152,8 +149,6 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py index 7c6fe159c7..9a459862af 100644 --- a/src/specify_cli/integrations/gemini/__init__.py +++ b/src/specify_cli/integrations/gemini/__init__.py @@ -18,5 +18,4 @@ class GeminiIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "GEMINI.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py index 3d6dd19d44..d874273559 100644 --- a/src/specify_cli/integrations/generic/__init__.py +++ b/src/specify_cli/integrations/generic/__init__.py @@ -31,7 +31,6 @@ class GenericIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: @@ -119,13 +118,11 @@ def setup( script_type = opts.get("script_type", "sh") arg_placeholder = "$ARGUMENTS" created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") processed = self.process_template( raw, self.key, script_type, arg_placeholder, - context_file=context_file_display, ) dst_name = self.command_filename(src_file.stem) dst_file = self.write_file_and_record( @@ -133,7 +130,5 @@ def setup( ) created.append(dst_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) return created diff --git a/src/specify_cli/integrations/goose/__init__.py b/src/specify_cli/integrations/goose/__init__.py index 0fc4d9d57a..77d4e0f837 100644 --- a/src/specify_cli/integrations/goose/__init__.py +++ b/src/specify_cli/integrations/goose/__init__.py @@ -18,4 +18,3 @@ class GooseIntegration(YamlIntegration): "args": "{{args}}", "extension": ".yaml", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/hermes/__init__.py b/src/specify_cli/integrations/hermes/__init__.py index 1d475c72e2..e094dcfcfe 100644 --- a/src/specify_cli/integrations/hermes/__init__.py +++ b/src/specify_cli/integrations/hermes/__init__.py @@ -50,7 +50,6 @@ class HermesIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" # -- Helpers ----------------------------------------------------------- @@ -114,7 +113,6 @@ def setup( global_skills_dir.mkdir(parents=True, exist_ok=True) created: list[Path] = [] - context_file_display = self._context_file_display(project_root) for src_file in templates: raw = src_file.read_text(encoding="utf-8") @@ -141,7 +139,6 @@ def setup( self.key, script_type, arg_placeholder, - context_file=context_file_display, invoke_separator=self.invoke_separator, ) # Strip the processed frontmatter — we rebuild it for skills. @@ -183,8 +180,6 @@ def _quote(v: str) -> str: skill_file.write_bytes(normalized.encode("utf-8")) created.append(skill_file) - # Upsert managed context section into the agent context file - self.upsert_context_section(project_root) # Create project-local marker directory so extension commands # (e.g. git) can detect Hermes as an active integration. @@ -204,8 +199,7 @@ def teardown( ) -> tuple[list[Path], list[Path]]: """Uninstall integration files including global Hermes skills. - Removes the managed context section from AGENTS.md, removes the - project-local marker directory (if empty), delegates to + Removes the project-local marker directory (if empty), delegates to ``manifest.uninstall()`` for project-local tracked files, and removes all ``speckit-*`` skills under ``~/.hermes/skills/``. @@ -213,8 +207,6 @@ def teardown( standard integration behaviour where all files created by the integration are removed on ``specify integration uninstall``. """ - # Remove managed context section from AGENTS.md - self.remove_context_section(project_root) # Delegate to manifest for project-local tracked files (scripts, # templates, context entries tracked in the manifest). diff --git a/src/specify_cli/integrations/iflow/__init__.py b/src/specify_cli/integrations/iflow/__init__.py index 65d4d21c63..c6b5447bb1 100644 --- a/src/specify_cli/integrations/iflow/__init__.py +++ b/src/specify_cli/integrations/iflow/__init__.py @@ -18,5 +18,4 @@ class IflowIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "IFLOW.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/junie/__init__.py b/src/specify_cli/integrations/junie/__init__.py index 98d0494a8a..e1e8a9addb 100644 --- a/src/specify_cli/integrations/junie/__init__.py +++ b/src/specify_cli/integrations/junie/__init__.py @@ -18,5 +18,4 @@ class JunieIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".junie/AGENTS.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py index 11674dd9f1..0924843286 100644 --- a/src/specify_cli/integrations/kilocode/__init__.py +++ b/src/specify_cli/integrations/kilocode/__init__.py @@ -18,5 +18,4 @@ class KilocodeIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".kilocode/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py index 3b257768e2..1d5247fcba 100644 --- a/src/specify_cli/integrations/kimi/__init__.py +++ b/src/specify_cli/integrations/kimi/__init__.py @@ -35,7 +35,6 @@ class KimiIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "KIMI.md" multi_install_safe = True @classmethod diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py index 4571b54f90..4c176e5127 100644 --- a/src/specify_cli/integrations/kiro_cli/__init__.py +++ b/src/specify_cli/integrations/kiro_cli/__init__.py @@ -26,4 +26,3 @@ class KiroCliIntegration(MarkdownIntegration): "args": _KIRO_ARG_FALLBACK, "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/lingma/__init__.py b/src/specify_cli/integrations/lingma/__init__.py index b5cd036033..2cb74b2192 100644 --- a/src/specify_cli/integrations/lingma/__init__.py +++ b/src/specify_cli/integrations/lingma/__init__.py @@ -27,7 +27,6 @@ class LingmaIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".lingma/rules/specify-rules.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py index abd97ab2ae..0f734b7f41 100644 --- a/src/specify_cli/integrations/opencode/__init__.py +++ b/src/specify_cli/integrations/opencode/__init__.py @@ -19,7 +19,6 @@ class OpencodeIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" def build_exec_args( self, diff --git a/src/specify_cli/integrations/pi/__init__.py b/src/specify_cli/integrations/pi/__init__.py index 8a25f326ba..fd65a439b0 100644 --- a/src/specify_cli/integrations/pi/__init__.py +++ b/src/specify_cli/integrations/pi/__init__.py @@ -18,4 +18,3 @@ class PiIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "AGENTS.md" diff --git a/src/specify_cli/integrations/qodercli/__init__.py b/src/specify_cli/integrations/qodercli/__init__.py index ee2d4b6255..13535203cf 100644 --- a/src/specify_cli/integrations/qodercli/__init__.py +++ b/src/specify_cli/integrations/qodercli/__init__.py @@ -18,5 +18,4 @@ class QodercliIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "QODER.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py index 2506a57681..1e8c15bf91 100644 --- a/src/specify_cli/integrations/qwen/__init__.py +++ b/src/specify_cli/integrations/qwen/__init__.py @@ -18,5 +18,4 @@ class QwenIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "QWEN.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/roo/__init__.py b/src/specify_cli/integrations/roo/__init__.py index f610a3cc63..2042c09339 100644 --- a/src/specify_cli/integrations/roo/__init__.py +++ b/src/specify_cli/integrations/roo/__init__.py @@ -18,5 +18,4 @@ class RooIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".roo/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/rovodev/__init__.py b/src/specify_cli/integrations/rovodev/__init__.py index f8879424ac..01aa870c66 100644 --- a/src/specify_cli/integrations/rovodev/__init__.py +++ b/src/specify_cli/integrations/rovodev/__init__.py @@ -39,7 +39,6 @@ class RovodevIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" # -- CLI dispatch ------------------------------------------------------ @@ -228,8 +227,7 @@ def setup( ) -> list[Path]: """Install RovoDev skills, then generate prompt wrappers and manifest. - 1. ``SkillsIntegration.setup()`` generates skill files and - upserts the context section. + 1. ``SkillsIntegration.setup()`` generates the skill files. 2. Generates prompt wrappers and ``prompts.yml`` for each skill created in step 1. """ diff --git a/src/specify_cli/integrations/shai/__init__.py b/src/specify_cli/integrations/shai/__init__.py index 123953da72..8be9596bf1 100644 --- a/src/specify_cli/integrations/shai/__init__.py +++ b/src/specify_cli/integrations/shai/__init__.py @@ -18,5 +18,4 @@ class ShaiIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "SHAI.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py index 0d0076bc56..9edf1e1607 100644 --- a/src/specify_cli/integrations/tabnine/__init__.py +++ b/src/specify_cli/integrations/tabnine/__init__.py @@ -18,5 +18,4 @@ class TabnineIntegration(TomlIntegration): "args": "{{args}}", "extension": ".toml", } - context_file = "TABNINE.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/trae/__init__.py b/src/specify_cli/integrations/trae/__init__.py index 4556487d07..03a628d422 100644 --- a/src/specify_cli/integrations/trae/__init__.py +++ b/src/specify_cli/integrations/trae/__init__.py @@ -26,7 +26,6 @@ class TraeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = ".trae/rules/project_rules.md" multi_install_safe = True @classmethod diff --git a/src/specify_cli/integrations/vibe/__init__.py b/src/specify_cli/integrations/vibe/__init__.py index 7922aa8418..136dec8674 100644 --- a/src/specify_cli/integrations/vibe/__init__.py +++ b/src/specify_cli/integrations/vibe/__init__.py @@ -28,7 +28,6 @@ class VibeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/src/specify_cli/integrations/windsurf/__init__.py b/src/specify_cli/integrations/windsurf/__init__.py index ae5c3301f4..eba38fd1e5 100644 --- a/src/specify_cli/integrations/windsurf/__init__.py +++ b/src/specify_cli/integrations/windsurf/__init__.py @@ -18,5 +18,4 @@ class WindsurfIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = ".windsurf/rules/specify-rules.md" multi_install_safe = True diff --git a/src/specify_cli/integrations/zcode/__init__.py b/src/specify_cli/integrations/zcode/__init__.py index ea47f31555..46d93c5ca2 100644 --- a/src/specify_cli/integrations/zcode/__init__.py +++ b/src/specify_cli/integrations/zcode/__init__.py @@ -28,7 +28,6 @@ class ZcodeIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "ZCODE.md" multi_install_safe = True @classmethod diff --git a/src/specify_cli/integrations/zed/__init__.py b/src/specify_cli/integrations/zed/__init__.py index 882d83cc59..441e9e36f9 100644 --- a/src/specify_cli/integrations/zed/__init__.py +++ b/src/specify_cli/integrations/zed/__init__.py @@ -27,7 +27,6 @@ class ZedIntegration(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "AGENTS.md" @classmethod def options(cls) -> list[IntegrationOption]: diff --git a/templates/commands/plan.md b/templates/commands/plan.md index 44ab8403ac..1a699d4cef 100644 --- a/templates/commands/plan.md +++ b/templates/commands/plan.md @@ -154,14 +154,11 @@ Command ends after Phase 2 planning. Report branch, IMPL_PLAN path, and generate - Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites - Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase -4. **Agent context update**: - - Update the plan reference between the `` and `` markers in `__CONTEXT_FILE__` to point to the plan file created in step 1 (the IMPL_PLAN path) - -**Output**: data-model.md, /contracts/*, quickstart.md, updated agent context file +**Output**: data-model.md, /contracts/*, quickstart.md ## Key rules -- Use absolute paths for filesystem operations; use project-relative paths for references in documentation and agent context files +- Use absolute paths for filesystem operations; use project-relative paths for references in documentation - ERROR on gate failures or unresolved clarifications ## Done When diff --git a/tests/extensions/test_agent_context_cli_free.py b/tests/extensions/test_agent_context_cli_free.py new file mode 100644 index 0000000000..0262996053 --- /dev/null +++ b/tests/extensions/test_agent_context_cli_free.py @@ -0,0 +1,58 @@ +"""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", + "context_file", + "Inline agent-context updates", + "v0.12.0", +] + + +@pytest.fixture(scope="module") +def cli_source_texts() -> list[tuple[str, str]]: + """Read every CLI source file once, shared across all parametrized cases.""" + return [ + (str(path.relative_to(PROJECT_ROOT)), path.read_text(encoding="utf-8")) + for path in SRC_ROOT.rglob("*.py") + ] + + +@pytest.mark.parametrize("symbol", FORBIDDEN_SYMBOLS) +def test_symbol_absent_from_cli_source(symbol, cli_source_texts): + offenders = [rel for rel, text in cli_source_texts if symbol in text] + assert not offenders, ( + f"Forbidden agent-context symbol {symbol!r} still present in: {offenders}" + ) diff --git a/tests/extensions/test_extension_agent_context.py b/tests/extensions/test_extension_agent_context.py index ab4194efd8..bc1dd81494 100644 --- a/tests/extensions/test_extension_agent_context.py +++ b/tests/extensions/test_extension_agent_context.py @@ -13,14 +13,9 @@ import yaml from specify_cli import ( - _load_agent_context_config, - _save_agent_context_config, - load_init_options, save_init_options, ) from specify_cli.agents import CommandRegistrar -from specify_cli.integrations.base import IntegrationBase -from specify_cli.integrations.claude import ClaudeIntegration from tests.conftest import requires_bash @@ -33,19 +28,34 @@ def _write_ext_config(project_root: Path, **overrides: object) -> None: - """Write a minimal agent-context extension config.""" + """Write a minimal agent-context extension config directly. + + The CLI no longer owns the extension config — the bundled extension does — + so tests write it themselves rather than going through any CLI helper. + """ cfg: dict = { "context_file": overrides.get("context_file", ""), "context_files": overrides.get("context_files", []), "context_markers": overrides.get( "context_markers", { - "start": IntegrationBase.CONTEXT_MARKER_START, - "end": IntegrationBase.CONTEXT_MARKER_END, + "start": "", + "end": "", }, ), } - _save_agent_context_config(project_root, cfg) + path = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(cfg, default_flow_style=False, sort_keys=False), + encoding="utf-8", + ) # ── Bundled extension layout ───────────────────────────────────────────────── @@ -120,19 +130,27 @@ def test_catalog_lists_agent_context_as_bundled(self): assert entry["author"] == "spec-kit-core" -# ── Marker resolution from extension config ────────────────────────────────── - - -class _CtxIntegration(ClaudeIntegration): - """Use Claude as a concrete integration with a context_file.""" - - -class _NoContextIntegration(IntegrationBase): - """Minimal integration with no context_file for base-class fallback tests.""" def _install_agent_context_config(project_root: Path, **overrides: object) -> None: _write_ext_config(project_root, **overrides) + # Mirror the real install layout: the extension ships its own + # agent->context-file defaults map alongside the config. Self-seeding + # tests depend on it, so require it to exist and always copy it rather + # than silently skipping when it is missing. + defaults_src = EXT_DIR / "agent-context-defaults.json" + assert defaults_src.is_file(), ( + f"bundled agent-context defaults map missing: {defaults_src}" + ) + defaults_dst = ( + project_root + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-defaults.json" + ) + defaults_dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(defaults_src, defaults_dst) def _bash_posix_path(path: Path) -> str: @@ -305,482 +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: @@ -1005,231 +547,214 @@ def test_powershell_script_rejects_junction_escape(self, tmp_path): assert not (outside / "out.md").exists() -# ── Extension config writers ───────────────────────────────────────────────── - +# ── CLI does not resolve agent context placeholders ────────────────────────── -class TestExtensionConfigWriters: - def test_clear_init_options_clears_ext_config_context_file(self, tmp_path): - from specify_cli import _clear_init_options_for_integration - save_init_options( - tmp_path, - {"integration": "claude", "ai": "claude"}, - ) - _write_ext_config(tmp_path, context_file="CLAUDE.md") - _clear_init_options_for_integration(tmp_path, "claude") - cfg = _load_agent_context_config(tmp_path) - assert cfg.get("context_file") == "" +class TestSkillPlaceholderContextResolution: + """The CLI no longer resolves any ``__CONTEXT_FILE__`` placeholder. - def test_clear_init_options_creates_ext_config_when_missing(self, tmp_path): - from specify_cli import _clear_init_options_for_integration + 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. + """ - save_init_options( + def test_cli_does_not_resolve_context_placeholder(self, tmp_path): + content = CommandRegistrar.resolve_skill_placeholders( + "codex", + {}, + "Read __CONTEXT_FILE__", tmp_path, - {"integration": "claude", "ai": "claude"}, ) - _clear_init_options_for_integration(tmp_path, "claude") - cfg = _load_agent_context_config(tmp_path) - assert cfg.get("context_file") == "" + assert content == "Read __CONTEXT_FILE__" - def test_clear_init_options_removes_legacy_context_keys_even_when_not_active( - self, tmp_path - ): - from specify_cli import _clear_init_options_for_integration - - save_init_options( + def test_extension_config_does_not_influence_resolution(self, tmp_path): + # Even a populated extension config must not influence resolution. + _write_ext_config( tmp_path, - { - "integration": "copilot", - "ai": "copilot", - "context_file": "CLAUDE.md", - "context_markers": {"start": "", "end": ""}, - }, + context_file="FROM_CONFIG.md", + context_files=["ALSO_CONFIG.md"], ) - _clear_init_options_for_integration(tmp_path, "claude") - opts = load_init_options(tmp_path) - assert opts["integration"] == "copilot" - assert opts["ai"] == "copilot" - assert "context_file" not in opts - assert "context_markers" not in opts - - def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path): - from specify_cli import _update_init_options_for_integration - - # Pre-create the extension config so _update_init_options_for_integration - # updates it (rather than skipping it when ext config doesn't exist yet). - _write_ext_config(tmp_path, context_file="") - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - # init-options.json must NOT have context_file or context_markers - opts = load_init_options(tmp_path) - assert "context_file" not in opts - assert "context_markers" not in opts - # Extension config must have them - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert "context_markers" in cfg - - def test_update_init_options_preserves_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - _write_ext_config( + content = CommandRegistrar.resolve_skill_placeholders( + "claude", + {}, + "Read __CONTEXT_FILE__", tmp_path, - context_file="AGENTS.md", - context_files=["AGENTS.md", "CLAUDE.md"], ) - i = _CtxIntegration() - _update_init_options_for_integration(tmp_path, i, script_type="sh") - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_file"] == i.context_file - assert cfg["context_files"] == ["AGENTS.md", "CLAUDE.md"] + assert "FROM_CONFIG.md" not in content + assert "ALSO_CONFIG.md" not in content + assert content == "Read __CONTEXT_FILE__" - def test_update_init_options_preserves_empty_context_files(self, tmp_path): - from specify_cli import _update_init_options_for_integration - _write_ext_config( - tmp_path, - context_file="AGENTS.md", - context_files=[], - ) - 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"}, +# ── 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, ) - _write_ext_config( - tmp_path, - context_file="CLAUDE.md", - context_files=["AGENTS.md", "CLAUDE.md"], + + _update_init_options_for_integration( + tmp_path, INTEGRATION_REGISTRY["claude"], script_type="sh" ) - _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 + cfg = ( + tmp_path + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" + ) + assert not cfg.exists() - _write_ext_config( - tmp_path, - context_file="", - context_markers={"start": "", "end": ""}, + def test_clear_init_options_does_not_create_ext_config(self, tmp_path): + from specify_cli.integrations._helpers import ( + _clear_init_options_for_integration, ) - 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 + save_init_options(tmp_path, {"integration": "claude", "ai": "claude"}) + _clear_init_options_for_integration(tmp_path, "claude") - # 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 = ( + tmp_path + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - cfg = _load_agent_context_config(tmp_path) - assert cfg["context_markers"] == { - "start": "", - "end": "", - } + assert not cfg.exists() -# ── Deprecation warning on upsert ──────────────────────────────────────────── +# ── Extension self-seeds its target from the active integration ────────────── -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 +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).""" - 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 + @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"}) - 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 + 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") -# ── Corrupt / invalid extension config ─────────────────────────────────────── + @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) + + +_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 TestCorruptExtensionConfig: - def test_marker_resolution_with_corrupt_yaml(self, tmp_path): - """Corrupt YAML in agent-context-config.yml falls back to defaults.""" +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 = ( - tmp_path / ".specify" / "extensions" / "agent-context" + project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text(": invalid: yaml: {{{\n", encoding="utf-8") - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END - - def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path): - """upsert_context_section still works when config YAML is corrupt.""" - cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" - / "agent-context-config.yml" + before_ctx = ctx.read_text(encoding="utf-8") + before_cfg = cfg_path.read_text(encoding="utf-8") + + integration = INTEGRATION_REGISTRY["claude"] + m = IntegrationManifest("claude", project) + integration.setup(project, m) + + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg + + def test_integration_switch_and_uninstall_leave_legacy_artifacts_untouched( + self, tmp_path + ): + from specify_cli.integrations import INTEGRATION_REGISTRY + from specify_cli.integrations._helpers import ( + _clear_init_options_for_integration, + _update_init_options_for_integration, ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("not valid yaml: {{{\n", encoding="utf-8") - i = _CtxIntegration() - result = i.upsert_context_section(tmp_path) - assert result is not None - text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8") - assert IntegrationBase.CONTEXT_MARKER_START in text - assert IntegrationBase.CONTEXT_MARKER_END in text - - def test_marker_resolution_with_non_dict_yaml(self, tmp_path): - """Config file containing a scalar (not a dict) falls back to defaults.""" + + project = tmp_path / "legacy" + project.mkdir() + ctx = self._seed_legacy_project(project) cfg_path = ( - tmp_path / ".specify" / "extensions" / "agent-context" + project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" ) - cfg_path.parent.mkdir(parents=True, exist_ok=True) - cfg_path.write_text("just a string\n", encoding="utf-8") - i = _CtxIntegration() - start, end = i._resolve_context_markers(tmp_path) - assert start == IntegrationBase.CONTEXT_MARKER_START - assert end == IntegrationBase.CONTEXT_MARKER_END + before_ctx = ctx.read_text(encoding="utf-8") + before_cfg = cfg_path.read_text(encoding="utf-8") + + # Switch to a different integration. + _update_init_options_for_integration( + project, INTEGRATION_REGISTRY["gemini"], script_type="sh" + ) + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg + + # Uninstall. + _clear_init_options_for_integration(project, "gemini") + assert ctx.read_text(encoding="utf-8") == before_ctx + assert cfg_path.read_text(encoding="utf-8") == before_cfg diff --git a/tests/integrations/conftest.py b/tests/integrations/conftest.py index 54f59e23a7..833e272b27 100644 --- a/tests/integrations/conftest.py +++ b/tests/integrations/conftest.py @@ -20,4 +20,3 @@ class StubIntegration(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "STUB.md" diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py index 47f9d09059..9ec7d236c1 100644 --- a/tests/integrations/test_base.py +++ b/tests/integrations/test_base.py @@ -43,7 +43,6 @@ def test_key_and_config(self): assert i.key == "stub" assert i.config["name"] == "Stub Agent" assert i.registrar_config["format"] == "markdown" - assert i.context_file == "STUB.md" def test_options_default_empty(self): assert StubIntegration.options() == [] diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py index 30bcb015d1..957a0277ff 100644 --- a/tests/integrations/test_cli.py +++ b/tests/integrations/test_cli.py @@ -77,23 +77,17 @@ def test_integration_copilot_creates_files(self, tmp_path): opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8")) assert opts["integration"] == "copilot" - # context_file lives in the agent-context extension config, not init-options.json + # init must not leave any legacy agent-context keys in init-options.json assert "context_file" not in opts - import yaml as _yaml + # agent-context is fully opt-in: init must not install it or write its config ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - assert ext_cfg_path.exists(), "agent-context extension config must be created on init" - ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) - assert ext_cfg["context_file"] == ".github/copilot-instructions.md" + assert not ext_cfg_path.exists(), "init must not create the agent-context extension config" assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists() - # Context section should be upserted into the copilot instructions file - ctx_file = project / ".github" / "copilot-instructions.md" - assert ctx_file.exists() - ctx_content = ctx_file.read_text(encoding="utf-8") - assert "" in ctx_content - assert "" in ctx_content + # init must not create or manage the agent context file + assert not (project / ".github" / "copilot-instructions.md").exists() shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json" assert shared_manifest.exists() @@ -1070,7 +1064,6 @@ class BrokenIntegration(IntegrationBase): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "BROKEN.md" def setup(self, project_root, manifest, **kwargs): raise OSError("setup exploded\nwith context") diff --git a/tests/integrations/test_extra_args.py b/tests/integrations/test_extra_args.py index d192e140fb..e329c88801 100644 --- a/tests/integrations/test_extra_args.py +++ b/tests/integrations/test_extra_args.py @@ -37,7 +37,6 @@ class _ClaudeStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": "/SKILL.md", } - context_file = "CLAUDE.md" class _KiroCliStub(SkillsIntegration): @@ -58,7 +57,6 @@ class _KiroCliStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "KIRO.md" class _NoCliStub(SkillsIntegration): @@ -79,7 +77,6 @@ class _NoCliStub(SkillsIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "NOCLI.md" class _MarkdownAgentStub(MarkdownIntegration): @@ -102,7 +99,6 @@ class _MarkdownAgentStub(MarkdownIntegration): "args": "$ARGUMENTS", "extension": ".md", } - context_file = "MDAGENT.md" class _TomlAgentStub(TomlIntegration): @@ -124,7 +120,6 @@ class _TomlAgentStub(TomlIntegration): "args": "$ARGUMENTS", "extension": ".toml", } - context_file = "TOMLAGENT.md" @pytest.fixture(autouse=True) diff --git a/tests/integrations/test_integration_agy.py b/tests/integrations/test_integration_agy.py index b64a609e15..6ab66a0cbe 100644 --- a/tests/integrations/test_integration_agy.py +++ b/tests/integrations/test_integration_agy.py @@ -10,7 +10,6 @@ class TestAgyIntegration(SkillsIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".agents/skills" - CONTEXT_FILE = "AGENTS.md" def test_options_include_skills_flag(self): """Override inherited test: AgyIntegration should not expose a --skills flag because .agents/ is its only layout.""" diff --git a/tests/integrations/test_integration_amp.py b/tests/integrations/test_integration_amp.py index a36dd47136..f0689c21f5 100644 --- a/tests/integrations/test_integration_amp.py +++ b/tests/integrations/test_integration_amp.py @@ -8,4 +8,3 @@ class TestAmpIntegration(MarkdownIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".agents/commands" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_auggie.py b/tests/integrations/test_integration_auggie.py index e4033a23e8..3cf4d09bbc 100644 --- a/tests/integrations/test_integration_auggie.py +++ b/tests/integrations/test_integration_auggie.py @@ -8,4 +8,3 @@ class TestAuggieIntegration(MarkdownIntegrationTests): FOLDER = ".augment/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".augment/commands" - CONTEXT_FILE = ".augment/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_base_markdown.py b/tests/integrations/test_integration_base_markdown.py index b0b408a995..886dfb912f 100644 --- a/tests/integrations/test_integration_base_markdown.py +++ b/tests/integrations/test_integration_base_markdown.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard MarkdownIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``MarkdownIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``MarkdownIntegrationTests``. """ import os @@ -21,14 +21,12 @@ class MarkdownIntegrationTests: FOLDER: str — e.g. ".claude/" COMMANDS_SUBDIR: str — e.g. "commands" REGISTRAR_DIR: str — e.g. ".claude/commands" - CONTEXT_FILE: str — e.g. "CLAUDE.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -56,10 +54,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "$ARGUMENTS" assert i.registrar_config["extension"] == ".md" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -101,19 +95,18 @@ def test_templates_are_processed(self, tmp_path): assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__" assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference this integration's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") assert plan_file.exists(), f"Plan file {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" - ) assert "__CONTEXT_FILE__" not in content, ( f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" ) @@ -149,35 +142,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - # Add user content around the section - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -225,35 +215,10 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*")) assert len(commands) > 0, f"No command files in {cmd_dir}" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / f"opts-{self.KEY}" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", "converge", "implement", "plan", "checklist", "specify", "tasks", "taskstoissues", ] @@ -293,19 +258,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py index e903d918e2..d88b786757 100644 --- a/tests/integrations/test_integration_base_skills.py +++ b/tests/integrations/test_integration_base_skills.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard SkillsIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``SkillsIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``SkillsIntegrationTests``. Mirrors ``MarkdownIntegrationTests`` / ``TomlIntegrationTests`` closely, adapted for the ``speckit-/SKILL.md`` skills layout. @@ -26,14 +26,12 @@ class SkillsIntegrationTests: FOLDER: str — e.g. ".agents/" COMMANDS_SUBDIR: str — e.g. "skills" REGISTRAR_DIR: str — e.g. ".agents/skills" - CONTEXT_FILE: str — e.g. "AGENTS.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -61,10 +59,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "$ARGUMENTS" assert i.registrar_config["extension"] == "/SKILL.md" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -222,19 +216,18 @@ def test_skill_body_has_content(self, tmp_path): body = parts[2].strip() if len(parts) >= 3 else "" assert len(body) > 0, f"{f} has empty body" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan skill must reference this integration's context file.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path): + """The generated plan skill must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan skill must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md" assert plan_file.exists(), f"Plan skill {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan skill should reference {i.context_file!r} but it was not found" - ) assert "__CONTEXT_FILE__" not in content, ( "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" ) @@ -283,34 +276,32 @@ def test_pre_existing_skills_not_removed(self, tmp_path): assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed" - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -356,9 +347,9 @@ def test_integration_flag_creates_files(self, tmp_path): skills_dir = i.skills_dest(project) assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml + def test_init_does_not_create_agent_context_config(self, tmp_path): + """agent-context is opt-in: init must not auto-install the extension + or write its config.""" from typer.testing import CliRunner from specify_cli import app @@ -375,11 +366,7 @@ def test_init_options_includes_context_file(self, tmp_path): os.chdir(old_cwd) assert result.exit_code == 0 ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) + assert not ext_cfg_path.exists() # -- IntegrationOption ------------------------------------------------ @@ -406,8 +393,6 @@ def _expected_files(self, script_variant: str) -> list[str]: # Skill files (core commands) for cmd in self._SKILL_COMMANDS: files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md") - # Extension-installed skill (agent-context) - files.append(f"{skills_prefix}/speckit-agent-context-update/SKILL.md") # Integration metadata files += [ ".specify/init-options.json", @@ -446,18 +431,6 @@ def _expected_files(self, script_variant: str) -> list[str]: ".specify/workflows/speckit/workflow.yml", ".specify/workflows/workflow-registry.json", ] - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) return sorted(files) def test_complete_file_inventory_sh(self, tmp_path): diff --git a/tests/integrations/test_integration_base_toml.py b/tests/integrations/test_integration_base_toml.py index a9b933875a..68f5fd075a 100644 --- a/tests/integrations/test_integration_base_toml.py +++ b/tests/integrations/test_integration_base_toml.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard TomlIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``TomlIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``TomlIntegrationTests``. Mirrors ``MarkdownIntegrationTests`` closely — same test structure, adapted for TOML output format. @@ -27,14 +27,12 @@ class TomlIntegrationTests: FOLDER: str — e.g. ".gemini/" COMMANDS_SUBDIR: str — e.g. "commands" REGISTRAR_DIR: str — e.g. ".gemini/commands" - CONTEXT_FILE: str — e.g. "GEMINI.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -62,10 +60,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "{{args}}" assert i.registrar_config["extension"] == ".toml" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -311,19 +305,18 @@ def test_toml_is_valid(self, tmp_path): raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference this integration's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") assert plan_file.exists(), f"Plan file {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" - ) assert "__CONTEXT_FILE__" not in content, ( f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" ) @@ -359,34 +352,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -454,35 +445,10 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.toml")) assert len(commands) > 0, f"No command files in {cmd_dir}" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / f"opts-{self.KEY}" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", @@ -544,19 +510,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_base_yaml.py b/tests/integrations/test_integration_base_yaml.py index 646e21607d..74cdab2d7d 100644 --- a/tests/integrations/test_integration_base_yaml.py +++ b/tests/integrations/test_integration_base_yaml.py @@ -1,8 +1,8 @@ """Reusable test mixin for standard YamlIntegration subclasses. Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``, -``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification -logic from ``YamlIntegrationTests``. +and ``REGISTRAR_DIR``, then inherits all verification logic from +``YamlIntegrationTests``. Mirrors ``TomlIntegrationTests`` closely — same test structure, adapted for YAML recipe output format. @@ -26,14 +26,12 @@ class YamlIntegrationTests: FOLDER: str — e.g. ".goose/" COMMANDS_SUBDIR: str — e.g. "recipes" REGISTRAR_DIR: str — e.g. ".goose/recipes" - CONTEXT_FILE: str — e.g. "AGENTS.md" """ KEY: str FOLDER: str COMMANDS_SUBDIR: str REGISTRAR_DIR: str - CONTEXT_FILE: str # -- Registration ----------------------------------------------------- @@ -61,10 +59,6 @@ def test_registrar_config(self): assert i.registrar_config["args"] == "{{args}}" assert i.registrar_config["extension"] == ".yaml" - def test_context_file(self): - i = get_integration(self.KEY) - assert i.context_file == self.CONTEXT_FILE - # -- Setup / teardown ------------------------------------------------- def test_setup_creates_files(self, tmp_path): @@ -190,19 +184,18 @@ def test_yaml_prompt_excludes_frontmatter(self, tmp_path, monkeypatch): assert "scripts:" not in parsed["prompt"] assert "---" not in parsed["prompt"] - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference this integration's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The generated plan command must not carry a context-file placeholder. + + Agent context files are owned entirely by the opt-in agent-context + extension, so the core plan command must not reference one. + """ i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) plan_file = i.commands_dest(tmp_path) / i.command_filename("plan") assert plan_file.exists(), f"Plan file {plan_file} not created" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}" - ) assert "__CONTEXT_FILE__" not in content, ( f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}" ) @@ -238,34 +231,32 @@ def test_modified_file_survives_uninstall(self, tmp_path): assert modified_file.exists() assert modified_file in skipped - # -- Context section --------------------------------------------------- + # -- Context file ownership (extension-owned, opt-in) ----------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """Setup must not create or manage any agent context file — that is + owned entirely by the opt-in agent-context extension.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}" - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content - - def test_teardown_removes_context_section(self, tmp_path): + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text, ( + f"Setup wrote a managed context section into {path} for {self.KEY}" + ) + + def test_teardown_leaves_existing_context_file_intact(self, tmp_path): + """A user-authored context file must survive setup + teardown untouched.""" i = get_integration(self.KEY) m = IntegrationManifest(self.KEY, tmp_path) + ctx_path = tmp_path / "AGENTS.md" + original = "# My Rules\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") i.setup(tmp_path, m) m.save() - if i.context_file: - ctx_path = tmp_path / i.context_file - content = ctx_path.read_text(encoding="utf-8") - ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8") - i.teardown(tmp_path, m) - remaining = ctx_path.read_text(encoding="utf-8") - assert "" not in remaining - assert "" not in remaining - assert "# My Rules" in remaining + i.teardown(tmp_path, m) + assert ctx_path.read_text(encoding="utf-8") == original # -- CLI integration flag ------------------------------------------------- @@ -333,35 +324,10 @@ def test_integration_flag_creates_files(self, tmp_path): commands = sorted(cmd_dir.glob("speckit.*.yaml")) assert len(commands) > 0, f"No command files in {cmd_dir}" - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the active integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / f"opts-{self.KEY}" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", self.KEY, "--script", "sh", - "--ignore-agent-tools", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - i = get_integration(self.KEY) - assert ext_cfg.get("context_file") == i.context_file, ( - f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}" - ) # -- Complete file inventory ------------------------------------------ COMMAND_STEMS = [ - "agent-context.update", "analyze", "clarify", "constitution", @@ -423,19 +389,7 @@ def _expected_files(self, script_variant: str) -> list[str]: files.append(".specify/workflows/speckit/workflow.yml") files.append(".specify/workflows/workflow-registry.json") - # Bundled agent-context extension - files.append(".specify/extensions.yml") - files.append(".specify/extensions/.registry") - files.append(".specify/extensions/agent-context/README.md") - files.append(".specify/extensions/agent-context/agent-context-config.yml") - files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md") - files.append(".specify/extensions/agent-context/extension.yml") - files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh") - files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1") - - # Agent context file (if set) - if i.context_file: - files.append(i.context_file) + return sorted(files) diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py index 1562f0100c..8e0e72f0bd 100644 --- a/tests/integrations/test_integration_bob.py +++ b/tests/integrations/test_integration_bob.py @@ -8,4 +8,3 @@ class TestBobIntegration(MarkdownIntegrationTests): FOLDER = ".bob/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".bob/commands" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_claude.py b/tests/integrations/test_integration_claude.py index c7ecef95d0..01ef2662b5 100644 --- a/tests/integrations/test_integration_claude.py +++ b/tests/integrations/test_integration_claude.py @@ -1,6 +1,5 @@ """Tests for ClaudeIntegration.""" -import codecs import json import os from pathlib import Path @@ -34,10 +33,6 @@ def test_registrar_config_uses_skill_layout(self): assert integration.registrar_config["args"] == "$ARGUMENTS" assert integration.registrar_config["extension"] == "/SKILL.md" - def test_context_file(self): - integration = get_integration("claude") - assert integration.context_file == "CLAUDE.md" - def test_setup_creates_skill_files(self, tmp_path): integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) @@ -76,57 +71,30 @@ def test_render_skill_unicode(self): ) assert "Prüfe Konformität" in rendered - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): + """The CLI no longer manages the agent context file — that is owned by + the opt-in agent-context extension. Setup must not create or touch it.""" integration = get_integration("claude") manifest = IntegrationManifest("claude", tmp_path) integration.setup(tmp_path, manifest, script_type="sh") - ctx_path = tmp_path / integration.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - assert "read the current plan" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text - def test_upsert_context_section_strips_bom(self, tmp_path): - """Existing context file with UTF-8 BOM must be cleaned up on upsert.""" + def test_teardown_does_not_touch_existing_context_file(self, tmp_path): + """A user-authored context file is left intact on teardown.""" integration = get_integration("claude") - ctx_path = tmp_path / integration.context_file - - # Write a file that starts with a UTF-8 BOM (as the old PowerShell script did) - bom = codecs.BOM_UTF8 - ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n") + ctx_path = tmp_path / "CLAUDE.md" + original = "# CLAUDE.md\n\nUser content.\n" + ctx_path.write_text(original, encoding="utf-8") - integration.upsert_context_section(tmp_path) - - result = ctx_path.read_bytes() - assert not result.startswith(bom), "BOM must be stripped after upsert" - content = result.decode("utf-8") - assert "" in content - assert "Some existing content." in content - - def test_remove_context_section_strips_bom(self, tmp_path): - """remove_context_section must clean BOM from context file on Windows-authored files.""" - integration = get_integration("claude") - ctx_path = tmp_path / integration.context_file - - marker_content = ( - "# CLAUDE.md\n\n" - "\n" - "For additional context about technologies to be used, project structure,\n" - "shell commands, and other important information, read the current plan\n" - "\n" - ) - ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8")) - - result = integration.remove_context_section(tmp_path) + manifest = IntegrationManifest("claude", tmp_path) + integration.setup(tmp_path, manifest, script_type="sh") + integration.teardown(tmp_path, manifest) - assert result is True - assert ctx_path.exists(), "File should exist (non-empty content remains)" - remaining = ctx_path.read_bytes() - assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove" - assert b"", - "end": "", - }, - }, - ) integration = get_integration("codex") manifest = IntegrationManifest("codex", target) @@ -53,43 +40,31 @@ def test_plan_skill_references_configured_context_files(self, tmp_path): plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" content = plan_skill.read_text(encoding="utf-8") - assert "AGENTS.md, CLAUDE.md" in content assert "__CONTEXT_FILE__" not in content - def test_plan_skill_ignores_context_files_when_agent_context_disabled( - self, tmp_path - ): - """Disabled agent-context must not leak stale context_files into commands.""" - from specify_cli import _save_agent_context_config + def test_plan_skill_ignores_extension_config(self, tmp_path): + """The extension config must not influence rendered commands: the CLI + no longer reads any context-file metadata when rendering.""" + import yaml target = tmp_path / "test-proj" target.mkdir() - registry = target / ".specify" / "extensions" / ".registry" - registry.parent.mkdir(parents=True, exist_ok=True) - registry.write_text( - """ -{ - "schema_version": "1.0", - "extensions": { - "agent-context": { - "version": "1.0.0", - "enabled": false - } - } -} -""".strip(), - encoding="utf-8", + ext_cfg = ( + target + / ".specify" + / "extensions" + / "agent-context" + / "agent-context-config.yml" ) - _save_agent_context_config( - target, - { - "context_file": "AGENTS.md", - "context_files": ["../outside.md", "CLAUDE.md"], - "context_markers": { - "start": "", - "end": "", - }, - }, + ext_cfg.parent.mkdir(parents=True, exist_ok=True) + ext_cfg.write_text( + yaml.safe_dump( + { + "context_file": "FROM_CONFIG.md", + "context_files": ["FROM_CONFIG.md", "ALSO_CONFIG.md"], + } + ), + encoding="utf-8", ) integration = get_integration("codex") @@ -98,9 +73,8 @@ def test_plan_skill_ignores_context_files_when_agent_context_disabled( plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md" content = plan_skill.read_text(encoding="utf-8") - assert "AGENTS.md, CLAUDE.md" not in content - assert "../outside.md" not in content - assert "AGENTS.md" in content + assert "FROM_CONFIG.md" not in content + assert "ALSO_CONFIG.md" not in content assert "__CONTEXT_FILE__" not in content diff --git a/tests/integrations/test_integration_copilot.py b/tests/integrations/test_integration_copilot.py index 6b7cc7c13f..8a7c8ec995 100644 --- a/tests/integrations/test_integration_copilot.py +++ b/tests/integrations/test_integration_copilot.py @@ -17,7 +17,6 @@ def test_copilot_key_and_config(self): assert copilot.config["folder"] == ".github/" assert copilot.config["commands_subdir"] == "agents" assert copilot.registrar_config["extension"] == ".agent.md" - assert copilot.context_file == ".github/copilot-instructions.md" def test_command_filename_agent_md(self): copilot = get_integration("copilot") @@ -162,8 +161,9 @@ def test_specify_agent_resolves_active_spec_template(self, tmp_path): assert "Copy `.specify/templates/spec-template.md`" not in content assert "Load `.specify/templates/spec-template.md`" not in content - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference copilot's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" from specify_cli.integrations.copilot import CopilotIntegration copilot = CopilotIntegration() m = IntegrationManifest("copilot", tmp_path) @@ -171,9 +171,6 @@ def test_plan_references_correct_context_file(self, tmp_path): plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert copilot.context_file in content, ( - f"Plan command should reference {copilot.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_complete_file_inventory_sh(self, tmp_path): @@ -193,7 +190,6 @@ def test_complete_file_inventory_sh(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - ".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", @@ -204,7 +200,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/agents/speckit.specify.agent.md", ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", - ".github/prompts/speckit.agent-context.update.prompt.md", ".github/prompts/speckit.analyze.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", @@ -216,15 +211,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", - ".github/copilot-instructions.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -265,7 +251,6 @@ def test_complete_file_inventory_ps(self, tmp_path): assert result.exit_code == 0 actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - ".github/agents/speckit.agent-context.update.agent.md", ".github/agents/speckit.analyze.agent.md", ".github/agents/speckit.checklist.agent.md", ".github/agents/speckit.clarify.agent.md", @@ -276,7 +261,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/agents/speckit.specify.agent.md", ".github/agents/speckit.tasks.agent.md", ".github/agents/speckit.taskstoissues.agent.md", - ".github/prompts/speckit.agent-context.update.prompt.md", ".github/prompts/speckit.analyze.prompt.md", ".github/prompts/speckit.checklist.prompt.md", ".github/prompts/speckit.clarify.prompt.md", @@ -288,15 +272,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".github/prompts/speckit.tasks.prompt.md", ".github/prompts/speckit.taskstoissues.prompt.md", ".vscode/settings.json", - ".github/copilot-instructions.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/integration.json", ".specify/init-options.json", ".specify/integrations/copilot.manifest.json", @@ -537,14 +512,14 @@ def test_skill_body_has_content(self, tmp_path): body = parts[2].strip() if len(parts) >= 3 else "" assert len(body) > 0, f"{f} has empty body" - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan skill must reference copilot's context file.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path): + """The core plan skill must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" copilot = self._make_copilot() self._setup_skills(copilot, tmp_path) plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert copilot.context_file in content assert "__CONTEXT_FILE__" not in content # -- Manifest tracking ------------------------------------------------ @@ -603,14 +578,13 @@ def test_build_command_invocation_default_mode(self): # -- Context section --------------------------------------------------- - def test_skills_setup_upserts_context_section(self, tmp_path): + def test_skills_setup_does_not_write_context_section(self, tmp_path): copilot = self._make_copilot() self._setup_skills(copilot, tmp_path) - ctx_path = tmp_path / copilot.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text # -- CLI integration test --------------------------------------------- @@ -659,20 +633,8 @@ def test_complete_file_inventory_skills_sh(self, tmp_path): assert result.exit_code == 0, f"init failed: {result.output}" actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts) expected = sorted([ - # Skill files (core + extension-installed agent-context command) + # Skill files (core commands) *[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS], - ".github/skills/speckit-agent-context-update/SKILL.md", - # Context file - ".github/copilot-instructions.md", - # Bundled agent-context extension - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", # Integration metadata ".specify/init-options.json", ".specify/integration.json", diff --git a/tests/integrations/test_integration_cursor_agent.py b/tests/integrations/test_integration_cursor_agent.py index 8165464655..32318dc90f 100644 --- a/tests/integrations/test_integration_cursor_agent.py +++ b/tests/integrations/test_integration_cursor_agent.py @@ -1,10 +1,8 @@ """Tests for CursorAgentIntegration.""" -from pathlib import Path from urllib.parse import urlparse from specify_cli.integrations import get_integration -from specify_cli.integrations.manifest import IntegrationManifest from .test_integration_base_skills import SkillsIntegrationTests @@ -14,82 +12,6 @@ class TestCursorAgentIntegration(SkillsIntegrationTests): FOLDER = ".cursor/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".cursor/skills" - CONTEXT_FILE = ".cursor/rules/specify-rules.mdc" - - -class TestCursorMdcFrontmatter: - """Verify .mdc frontmatter handling in upsert/remove context section.""" - - def _setup(self, tmp_path: Path): - i = get_integration("cursor-agent") - m = IntegrationManifest("cursor-agent", tmp_path) - return i, m - - def test_new_mdc_gets_frontmatter(self, tmp_path): - """A freshly created .mdc file includes alwaysApply: true.""" - i, m = self._setup(tmp_path) - i.setup(tmp_path, m) - ctx = (tmp_path / i.context_file).read_text(encoding="utf-8") - assert ctx.startswith("---\n") - assert "alwaysApply: true" in ctx - - def test_existing_mdc_without_frontmatter_gets_it(self, tmp_path): - """An existing .mdc without frontmatter gets it added.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text("# User rules\n", encoding="utf-8") - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert content.lstrip().startswith("---") - assert "alwaysApply: true" in content - assert "# User rules" in content - - def test_existing_mdc_with_frontmatter_preserves_it(self, tmp_path): - """An existing .mdc with custom frontmatter is preserved.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text( - "---\nalwaysApply: true\ncustomKey: hello\n---\n\n# Rules\n", - encoding="utf-8", - ) - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert "alwaysApply: true" in content - assert "customKey: hello" in content - assert "" in content - - def test_existing_mdc_wrong_alwaysapply_fixed(self, tmp_path): - """An .mdc with alwaysApply: false gets corrected.""" - i, m = self._setup(tmp_path) - ctx_path = tmp_path / i.context_file - ctx_path.parent.mkdir(parents=True, exist_ok=True) - ctx_path.write_text( - "---\nalwaysApply: false\n---\n\n# Rules\n", - encoding="utf-8", - ) - i.upsert_context_section(tmp_path) - content = ctx_path.read_text(encoding="utf-8") - assert "alwaysApply: true" in content - assert "alwaysApply: false" not in content - - def test_upsert_idempotent_no_duplicate_frontmatter(self, tmp_path): - """Repeated upserts don't duplicate frontmatter.""" - i, m = self._setup(tmp_path) - i.upsert_context_section(tmp_path) - i.upsert_context_section(tmp_path) - content = (tmp_path / i.context_file).read_text(encoding="utf-8") - assert content.count("alwaysApply") == 1 - - def test_remove_deletes_mdc_with_only_frontmatter(self, tmp_path): - """Removing the section from a Speckit-only .mdc deletes the file.""" - i, m = self._setup(tmp_path) - i.upsert_context_section(tmp_path) - ctx_path = tmp_path / i.context_file - assert ctx_path.exists() - i.remove_context_section(tmp_path) - assert not ctx_path.exists() class TestCursorAgentInitFlow: diff --git a/tests/integrations/test_integration_devin.py b/tests/integrations/test_integration_devin.py index 4acbdac618..52c2981bf1 100644 --- a/tests/integrations/test_integration_devin.py +++ b/tests/integrations/test_integration_devin.py @@ -8,7 +8,6 @@ class TestDevinIntegration(SkillsIntegrationTests): FOLDER = ".devin/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".devin/skills" - CONTEXT_FILE = "AGENTS.md" class TestDevinBuildExecArgs: diff --git a/tests/integrations/test_integration_forge.py b/tests/integrations/test_integration_forge.py index f63afb71e2..26ac7a9931 100644 --- a/tests/integrations/test_integration_forge.py +++ b/tests/integrations/test_integration_forge.py @@ -55,7 +55,6 @@ def test_forge_key_and_config(self): assert forge.config["requires_cli"] is True assert forge.registrar_config["args"] == "{{parameters}}" assert forge.registrar_config["extension"] == ".md" - assert forge.context_file == "AGENTS.md" def test_command_filename_md(self): forge = get_integration("forge") @@ -73,16 +72,15 @@ def test_setup_creates_md_files(self, tmp_path): for f in command_files: assert f.name.endswith(".md") - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) forge.setup(tmp_path, m) - ctx_path = tmp_path / forge.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text def test_all_created_files_tracked_in_manifest(self, tmp_path): from specify_cli.integrations.forge import ForgeIntegration @@ -164,8 +162,9 @@ def test_templates_are_processed(self, tmp_path): "Forge requires hyphen notation (/speckit-) for ZSH compatibility" ) - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference forge's context file.""" + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" from specify_cli.integrations.forge import ForgeIntegration forge = ForgeIntegration() m = IntegrationManifest("forge", tmp_path) @@ -173,9 +172,6 @@ def test_plan_references_correct_context_file(self, tmp_path): plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert forge.context_file in content, ( - f"Plan command should reference {forge.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_forge_specific_transformations(self, tmp_path): diff --git a/tests/integrations/test_integration_gemini.py b/tests/integrations/test_integration_gemini.py index 9be5985e29..1649b4f7c3 100644 --- a/tests/integrations/test_integration_gemini.py +++ b/tests/integrations/test_integration_gemini.py @@ -8,4 +8,3 @@ class TestGeminiIntegration(TomlIntegrationTests): FOLDER = ".gemini/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".gemini/commands" - CONTEXT_FILE = "GEMINI.md" diff --git a/tests/integrations/test_integration_generic.py b/tests/integrations/test_integration_generic.py index fe935cc98b..1c5edc2efc 100644 --- a/tests/integrations/test_integration_generic.py +++ b/tests/integrations/test_integration_generic.py @@ -31,10 +31,6 @@ def test_config_requires_cli_false(self): i = get_integration("generic") assert i.config["requires_cli"] is False - def test_context_file_is_agents_md(self): - i = get_integration("generic") - assert i.context_file == "AGENTS.md" - # -- Options ---------------------------------------------------------- def test_options_include_commands_dir(self): @@ -161,28 +157,24 @@ def test_different_commands_dirs(self, tmp_path): # -- Context section --------------------------------------------------- - def test_setup_upserts_context_section(self, tmp_path): + def test_setup_does_not_write_context_section(self, tmp_path): i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) - if i.context_file: - ctx_path = tmp_path / i.context_file - assert ctx_path.exists() - content = ctx_path.read_text(encoding="utf-8") - assert "" in content - assert "" in content - - def test_plan_references_correct_context_file(self, tmp_path): - """The generated plan command must reference generic's context file.""" + for path in tmp_path.rglob("*"): + if path.is_file(): + text = path.read_text(encoding="utf-8", errors="ignore") + assert "" not in text + + def test_plan_command_has_no_context_placeholder(self, tmp_path): + """The core plan command must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" i = get_integration("generic") m = IntegrationManifest("generic", tmp_path) i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"}) plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md" assert plan_file.exists() content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan command should reference {i.context_file!r}" - ) assert "__CONTEXT_FILE__" not in content def test_plan_defines_quickstart_as_validation_guide(self, tmp_path): @@ -256,28 +248,6 @@ def test_cli_generic_without_commands_dir_fails(self, tmp_path): # Generic requires --commands-dir via --integration-options assert result.exit_code != 0 - def test_init_options_includes_context_file(self, tmp_path): - """agent-context extension config must include context_file for the generic integration.""" - import yaml - from typer.testing import CliRunner - from specify_cli import app - - project = tmp_path / "opts-generic" - project.mkdir() - old_cwd = os.getcwd() - try: - os.chdir(project) - result = CliRunner().invoke(app, [ - "init", "--here", "--integration", "generic", - "--integration-options=--commands-dir .myagent/commands", - "--script", "sh", - ], catch_exceptions=False) - finally: - os.chdir(old_cwd) - assert result.exit_code == 0 - ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml" - ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {} - assert ext_cfg.get("context_file") == "AGENTS.md" def test_complete_file_inventory_sh(self, tmp_path): """Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script sh.""" @@ -302,7 +272,6 @@ def test_complete_file_inventory_sh(self, tmp_path): for p in project.rglob("*") if p.is_file() and ".git" not in p.parts ) expected = sorted([ - "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -313,14 +282,6 @@ def test_complete_file_inventory_sh(self, tmp_path): ".myagent/commands/speckit.specify.md", ".myagent/commands/speckit.tasks.md", ".myagent/commands/speckit.taskstoissues.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", @@ -367,7 +328,6 @@ def test_complete_file_inventory_ps(self, tmp_path): for p in project.rglob("*") if p.is_file() and ".git" not in p.parts ) expected = sorted([ - "AGENTS.md", ".myagent/commands/speckit.analyze.md", ".myagent/commands/speckit.checklist.md", ".myagent/commands/speckit.clarify.md", @@ -378,14 +338,6 @@ def test_complete_file_inventory_ps(self, tmp_path): ".myagent/commands/speckit.specify.md", ".myagent/commands/speckit.tasks.md", ".myagent/commands/speckit.taskstoissues.md", - ".specify/extensions.yml", - ".specify/extensions/.registry", - ".specify/extensions/agent-context/README.md", - ".specify/extensions/agent-context/agent-context-config.yml", - ".specify/extensions/agent-context/commands/speckit.agent-context.update.md", - ".specify/extensions/agent-context/extension.yml", - ".specify/extensions/agent-context/scripts/bash/update-agent-context.sh", - ".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1", ".specify/init-options.json", ".specify/integration.json", ".specify/integrations/generic.manifest.json", diff --git a/tests/integrations/test_integration_goose.py b/tests/integrations/test_integration_goose.py index 8415081d53..104b7188d0 100644 --- a/tests/integrations/test_integration_goose.py +++ b/tests/integrations/test_integration_goose.py @@ -12,7 +12,6 @@ class TestGooseIntegration(YamlIntegrationTests): FOLDER = ".goose/" COMMANDS_SUBDIR = "recipes" REGISTRAR_DIR = ".goose/recipes" - CONTEXT_FILE = "AGENTS.md" def test_setup_declares_args_parameter_for_args_prompt(self, tmp_path): # “If a generated Goose recipe uses {{args}} in its prompt, it diff --git a/tests/integrations/test_integration_hermes.py b/tests/integrations/test_integration_hermes.py index 89e74c2b38..521a310cb8 100644 --- a/tests/integrations/test_integration_hermes.py +++ b/tests/integrations/test_integration_hermes.py @@ -30,7 +30,6 @@ class TestHermesIntegration(SkillsIntegrationTests): FOLDER = ".hermes/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = "~/.hermes/skills" - CONTEXT_FILE = "AGENTS.md" # -- Hermes-specific setup: skills go to ~/.hermes/skills/ ------------- @@ -72,23 +71,19 @@ def test_setup_writes_to_correct_directory(self, tmp_path, monkeypatch): """Override: Hermes writes to global, not project-local.""" self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch) - def test_plan_references_correct_context_file(self, tmp_path, monkeypatch): - """Plan skill goes to global dir, but we check it still references AGENTS.md.""" + def test_plan_skill_has_no_context_placeholder(self, tmp_path, monkeypatch): + """The core plan skill must not carry a context-file placeholder — + agent context files are owned by the opt-in agent-context extension.""" home = _fake_home(tmp_path) monkeypatch.setattr(Path, "home", lambda: home) i = get_integration(self.KEY) - if not i.context_file: - return m = IntegrationManifest(self.KEY, tmp_path) i.setup(tmp_path, m) # Find the plan skill in global ~/.hermes/skills/ plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md" assert plan_file.exists(), f"Plan skill {plan_file} not created globally" content = plan_file.read_text(encoding="utf-8") - assert i.context_file in content, ( - f"Plan skill should reference {i.context_file!r} but it was not found" - ) assert "__CONTEXT_FILE__" not in content, ( "Plan skill has unprocessed __CONTEXT_FILE__ placeholder" ) diff --git a/tests/integrations/test_integration_iflow.py b/tests/integrations/test_integration_iflow.py index ea2f5ef97a..89501f8edf 100644 --- a/tests/integrations/test_integration_iflow.py +++ b/tests/integrations/test_integration_iflow.py @@ -8,4 +8,3 @@ class TestIflowIntegration(MarkdownIntegrationTests): FOLDER = ".iflow/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".iflow/commands" - CONTEXT_FILE = "IFLOW.md" diff --git a/tests/integrations/test_integration_junie.py b/tests/integrations/test_integration_junie.py index 2b924ce434..2226e3d544 100644 --- a/tests/integrations/test_integration_junie.py +++ b/tests/integrations/test_integration_junie.py @@ -8,4 +8,3 @@ class TestJunieIntegration(MarkdownIntegrationTests): FOLDER = ".junie/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".junie/commands" - CONTEXT_FILE = ".junie/AGENTS.md" diff --git a/tests/integrations/test_integration_kilocode.py b/tests/integrations/test_integration_kilocode.py index 8e441c0833..86e6520a50 100644 --- a/tests/integrations/test_integration_kilocode.py +++ b/tests/integrations/test_integration_kilocode.py @@ -8,4 +8,3 @@ class TestKilocodeIntegration(MarkdownIntegrationTests): FOLDER = ".kilocode/" COMMANDS_SUBDIR = "workflows" REGISTRAR_DIR = ".kilocode/workflows" - CONTEXT_FILE = ".kilocode/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_kimi.py b/tests/integrations/test_integration_kimi.py index 112baf0301..a1a44c464b 100644 --- a/tests/integrations/test_integration_kimi.py +++ b/tests/integrations/test_integration_kimi.py @@ -12,7 +12,6 @@ class TestKimiIntegration(SkillsIntegrationTests): FOLDER = ".kimi/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".kimi/skills" - CONTEXT_FILE = "KIMI.md" class TestKimiOptions: diff --git a/tests/integrations/test_integration_kiro_cli.py b/tests/integrations/test_integration_kiro_cli.py index c1a029a55f..29adb0a4a6 100644 --- a/tests/integrations/test_integration_kiro_cli.py +++ b/tests/integrations/test_integration_kiro_cli.py @@ -41,7 +41,6 @@ class TestKiroCliIntegration(MarkdownIntegrationTests): FOLDER = ".kiro/" COMMANDS_SUBDIR = "prompts" REGISTRAR_DIR = ".kiro/prompts" - CONTEXT_FILE = "AGENTS.md" def test_registrar_config(self): """Override base assertion: kiro-cli uses a prose fallback for args diff --git a/tests/integrations/test_integration_lingma.py b/tests/integrations/test_integration_lingma.py index 959de8d657..e3d338d540 100644 --- a/tests/integrations/test_integration_lingma.py +++ b/tests/integrations/test_integration_lingma.py @@ -8,4 +8,3 @@ class TestLingmaIntegration(SkillsIntegrationTests): FOLDER = ".lingma/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".lingma/skills" - CONTEXT_FILE = ".lingma/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_opencode.py b/tests/integrations/test_integration_opencode.py index ba2d15711f..b9464fdea3 100644 --- a/tests/integrations/test_integration_opencode.py +++ b/tests/integrations/test_integration_opencode.py @@ -14,7 +14,6 @@ class TestOpencodeIntegration(MarkdownIntegrationTests): FOLDER = ".opencode/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".opencode/commands" - CONTEXT_FILE = "AGENTS.md" def test_build_exec_args_uses_run_command_dispatch(self): integration = get_integration(self.KEY) diff --git a/tests/integrations/test_integration_pi.py b/tests/integrations/test_integration_pi.py index 5ac5676501..5dde4a4294 100644 --- a/tests/integrations/test_integration_pi.py +++ b/tests/integrations/test_integration_pi.py @@ -8,4 +8,3 @@ class TestPiIntegration(MarkdownIntegrationTests): FOLDER = ".pi/" COMMANDS_SUBDIR = "prompts" REGISTRAR_DIR = ".pi/prompts" - CONTEXT_FILE = "AGENTS.md" diff --git a/tests/integrations/test_integration_qodercli.py b/tests/integrations/test_integration_qodercli.py index 1dbee480a0..29a6d16d29 100644 --- a/tests/integrations/test_integration_qodercli.py +++ b/tests/integrations/test_integration_qodercli.py @@ -8,4 +8,3 @@ class TestQodercliIntegration(MarkdownIntegrationTests): FOLDER = ".qoder/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".qoder/commands" - CONTEXT_FILE = "QODER.md" diff --git a/tests/integrations/test_integration_qwen.py b/tests/integrations/test_integration_qwen.py index 10a3c083f4..3de85d3888 100644 --- a/tests/integrations/test_integration_qwen.py +++ b/tests/integrations/test_integration_qwen.py @@ -8,4 +8,3 @@ class TestQwenIntegration(MarkdownIntegrationTests): FOLDER = ".qwen/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".qwen/commands" - CONTEXT_FILE = "QWEN.md" diff --git a/tests/integrations/test_integration_roo.py b/tests/integrations/test_integration_roo.py index 69d859c42f..b713f96362 100644 --- a/tests/integrations/test_integration_roo.py +++ b/tests/integrations/test_integration_roo.py @@ -8,4 +8,3 @@ class TestRooIntegration(MarkdownIntegrationTests): FOLDER = ".roo/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".roo/commands" - CONTEXT_FILE = ".roo/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_rovodev.py b/tests/integrations/test_integration_rovodev.py index 8e992476fb..5bdafc25f9 100644 --- a/tests/integrations/test_integration_rovodev.py +++ b/tests/integrations/test_integration_rovodev.py @@ -52,7 +52,6 @@ class TestRovodevIntegration: which violates the base mixin's pure-skills assumptions).""" KEY = "rovodev" - CONTEXT_FILE = "AGENTS.md" # -- ACLI dispatch ----------------------------------------------------- @@ -218,12 +217,8 @@ def test_init_inventory(self, rovodev_init_project): # Prompts: exactly the core template set. assert prompt_stems == core_skill_names - # Skills: core ∪ extension-installed. - assert core_skill_names.issubset(skill_names) - extension_skills = skill_names - core_skill_names - assert extension_skills, ( - "Expected at least one extension-installed skill (e.g. agent-context)" - ) + # Skills: exactly the core template set (no extension auto-install). + assert skill_names == core_skill_names # prompts.yml mirrors the prompt files exactly. prompts_manifest = project / ".rovodev" / "prompts.yml" @@ -266,10 +261,6 @@ def test_init_skill_files_well_formed(self, rovodev_init_project): f"{skill_file} body contains dot-notation /speckit. reference" ) - # The plan skill must reference the agent's context file. - plan_content = (skills_dir / "speckit-plan" / "SKILL.md").read_text(encoding="utf-8") - assert self.CONTEXT_FILE in plan_content - # -- Full-CLI init: integration metadata ------------------------------- def test_init_writes_integration_manifest_and_options(self, rovodev_init_project): diff --git a/tests/integrations/test_integration_shai.py b/tests/integrations/test_integration_shai.py index 74f93396b1..fc2b60c3f2 100644 --- a/tests/integrations/test_integration_shai.py +++ b/tests/integrations/test_integration_shai.py @@ -8,4 +8,3 @@ class TestShaiIntegration(MarkdownIntegrationTests): FOLDER = ".shai/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".shai/commands" - CONTEXT_FILE = "SHAI.md" diff --git a/tests/integrations/test_integration_tabnine.py b/tests/integrations/test_integration_tabnine.py index 95eb47cc16..71bf398862 100644 --- a/tests/integrations/test_integration_tabnine.py +++ b/tests/integrations/test_integration_tabnine.py @@ -8,4 +8,3 @@ class TestTabnineIntegration(TomlIntegrationTests): FOLDER = ".tabnine/agent/" COMMANDS_SUBDIR = "commands" REGISTRAR_DIR = ".tabnine/agent/commands" - CONTEXT_FILE = "TABNINE.md" diff --git a/tests/integrations/test_integration_trae.py b/tests/integrations/test_integration_trae.py index 74b8b41c3f..2805263b3d 100644 --- a/tests/integrations/test_integration_trae.py +++ b/tests/integrations/test_integration_trae.py @@ -8,4 +8,3 @@ class TestTraeIntegration(SkillsIntegrationTests): FOLDER = ".trae/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".trae/skills" - CONTEXT_FILE = ".trae/rules/project_rules.md" diff --git a/tests/integrations/test_integration_vibe.py b/tests/integrations/test_integration_vibe.py index bab4539f1e..98c9fdf06d 100644 --- a/tests/integrations/test_integration_vibe.py +++ b/tests/integrations/test_integration_vibe.py @@ -13,7 +13,6 @@ class TestVibeIntegration(SkillsIntegrationTests): FOLDER = ".vibe/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".vibe/skills" - CONTEXT_FILE = "AGENTS.md" class TestVibeUserInvocable: diff --git a/tests/integrations/test_integration_windsurf.py b/tests/integrations/test_integration_windsurf.py index fa8d1e622a..4cdfaa94a3 100644 --- a/tests/integrations/test_integration_windsurf.py +++ b/tests/integrations/test_integration_windsurf.py @@ -8,4 +8,3 @@ class TestWindsurfIntegration(MarkdownIntegrationTests): FOLDER = ".windsurf/" COMMANDS_SUBDIR = "workflows" REGISTRAR_DIR = ".windsurf/workflows" - CONTEXT_FILE = ".windsurf/rules/specify-rules.md" diff --git a/tests/integrations/test_integration_zcode.py b/tests/integrations/test_integration_zcode.py index 3eb82ed4f2..f431d3e4a0 100644 --- a/tests/integrations/test_integration_zcode.py +++ b/tests/integrations/test_integration_zcode.py @@ -8,7 +8,6 @@ class TestZcodeIntegration(SkillsIntegrationTests): FOLDER = ".zcode/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".zcode/skills" - CONTEXT_FILE = "ZCODE.md" class TestZcodeInvocation: diff --git a/tests/integrations/test_integration_zed.py b/tests/integrations/test_integration_zed.py index 0172e6b275..739fdbf23b 100644 --- a/tests/integrations/test_integration_zed.py +++ b/tests/integrations/test_integration_zed.py @@ -14,7 +14,6 @@ class TestZedIntegration(SkillsIntegrationTests): FOLDER = ".agents/" COMMANDS_SUBDIR = "skills" REGISTRAR_DIR = ".agents/skills" - CONTEXT_FILE = "AGENTS.md" def test_options_include_skills_flag(self): """Not applicable to Zed — Zed is always skills-based with no --skills flag.""" diff --git a/tests/integrations/test_registry.py b/tests/integrations/test_registry.py index 7582bd6717..7513a9d407 100644 --- a/tests/integrations/test_registry.py +++ b/tests/integrations/test_registry.py @@ -164,17 +164,12 @@ class TestMultiInstallSafeContracts: @pytest.mark.parametrize("key", _multi_install_safe_keys()) def test_safe_integrations_have_static_isolated_paths(self, key): - integration = INTEGRATION_REGISTRY[key] - assert _integration_root_dir(key), ( f"{key} is declared multi-install safe but has no static root directory" ) assert _integration_commands_dir(key), ( f"{key} is declared multi-install safe but has no static commands directory" ) - assert integration.context_file, ( - f"{key} is declared multi-install safe but has no context file" - ) @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) def test_safe_integrations_have_distinct_agent_roots(self, first, second): @@ -192,44 +187,6 @@ def test_safe_integrations_have_distinct_command_dirs(self, first, second): f"{_integration_commands_dir(second)!r}" ) - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) - def test_safe_integrations_have_distinct_context_files(self, first, second): - first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) - second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) - - assert first_context != second_context, ( - f"{first} and {second} are declared multi-install safe but share " - f"context file {first_context!r}" - ) - - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) - def test_safe_context_files_do_not_overlap_other_agent_roots(self, first, second): - first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) - second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) - - assert not _path_is_inside(first_context, _integration_root_dir(second)), ( - f"{first} context file {first_context!r} lives under {second} " - f"agent root {_integration_root_dir(second)!r}" - ) - assert not _path_is_inside(second_context, _integration_root_dir(first)), ( - f"{second} context file {second_context!r} lives under {first} " - f"agent root {_integration_root_dir(first)!r}" - ) - - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) - def test_safe_context_files_do_not_overlap_other_command_dirs(self, first, second): - first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file) - second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file) - - assert not _path_is_inside(first_context, _integration_commands_dir(second)), ( - f"{first} context file {first_context!r} lives under {second} " - f"commands directory {_integration_commands_dir(second)!r}" - ) - assert not _path_is_inside(second_context, _integration_commands_dir(first)), ( - f"{second} context file {second_context!r} lives under {first} " - f"commands directory {_integration_commands_dir(first)!r}" - ) - @pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs()) def test_safe_integrations_have_disjoint_manifests( self,