mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
feat: make agent-context extension a full opt-in (#3097)
* docs: add Spec Kit spec for agent-context full opt-in Use Spec Kit's own specify workflow to author the spec that makes the agent-context extension a full opt-in, removing all agent-context configuration/support from the Python codebase and removing the deprecation message. Force-added despite specs/ being gitignored; the generated artifact will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit plan artifacts for agent-context full opt-in Phase 0/1 of the SDD plan workflow: plan.md, research.md, data-model.md, quickstart.md, and contracts/cli-behavior.md. Constitution Check is a documented no-op (repo has no ratified constitution). Force-added despite specs/ being gitignored; generated artifacts will be purged prior to merge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct Constitution Check against ratified v1.0.0 Earlier draft wrongly treated the gate as a no-op; the fork's main is 16 commits behind upstream/main, which carries .specify/memory/constitution.md. Re-evaluate the feature against Principles I-V (all PASS) and note that Principle I mandates keeping context_file as a declared class attribute, validating the R1 metadata decision. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: refresh plan artifacts against synced upstream/main After syncing fork main to upstream and rebasing, re-scan the current agent-context surface. Upstream generalized the single context_file into a plural context_files concept with new resolver helpers (_resolve_context_files, _resolve_context_file_values, _format_context_file_values) and upsert/remove now loop over multiple files. Update research.md, data-model.md, contracts, quickstart grep guards, and the plan summary to cover the expanded removal scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add Spec Kit tasks for agent-context full opt-in Phase 2 of SDD: dependency-ordered tasks.md (30 tasks) organized by the three user stories, with mandatory test tasks (Constitution Principle II) and a foundational phase decoupling __CONTEXT_FILE__ resolution from the extension config. Includes the extension self-seeding task (T015) and a static guard test (T002) enforcing zero agent-context references in the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove agent-context lifecycle from the Specify CLI Make the agent-context extension a full opt-in. The CLI no longer installs the extension during init, writes agent-context-config.yml, or creates/updates/removes the managed Spec Kit section in agent context files. Context-section upsert/remove, marker resolution, extension-enabled gating, the config helpers, and the obsolete inline deprecation warning are all removed. Integration context_file stays as inert metadata; __CONTEXT_FILE__ now resolves from registry metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): self-seed context file from the active integration When agent-context-config.yml has no context_file/context_files, the bundled bash and PowerShell update scripts now resolve the context file from the active integration in .specify/init-options.json via the integration registry, so the extension no longer depends on the CLI writing its config. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: update suite and docs for agent-context opt-in Update integration/extension tests to expect no agent-context install, config, or context-section writes during init. Add a static guard test (test_agent_context_cli_free.py) asserting the CLI source is free of agent-context lifecycle symbols, plus backward-compatibility tests for legacy projects. Refresh AGENTS.md, the extension README, and add a CHANGELOG entry describing the opt-in behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(agent-context): warn on self-seed failure, correct docs, speed up guard test Address PR review feedback: - Self-seed scripts (bash + PowerShell) now emit an actionable warning when an active integration is configured but specify_cli cannot be imported by the chosen Python (e.g. pipx installs), or when the integration declares no context file, instead of silently falling through to 'nothing to do'. - Correct the extension README disable note: command rendering never reads the extension config; __CONTEXT_FILE__ is always substituted from integration metadata, so a stale context_files value cannot affect rendering. - Cache CLI source reads in the static guard test via a module-scoped fixture so the directory walk happens once instead of once per forbidden symbol. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(agent-context): ship self-owned per-agent context-file defaults The extension now bundles agent-context-defaults.json (key→context_file map) and self-seeds from it, dropping any dependency on the Specify CLI registry. Both the bash and PowerShell update scripts read the bundled JSON map keyed by the active integration from init-options.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat!: remove all agent-context state from the Specify CLI Strip every context_file reference from the CLI: the field on all 35 integration classes, the IntegrationBase plumbing (process_template param/step, _context_file_display, docstrings), the __CONTEXT_FILE__ resolution in agents.py, the legacy context_file/context_markers popping in _helpers.py, and the context_file template in integration_scaffold.py. Also drop the Agent context update step and __CONTEXT_FILE__ placeholder from templates/commands/plan.md. The agent-context extension now solely owns all context-file knowledge, including the per-agent default mapping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: drop context_file coverage and guard against CLI reintroduction Remove CONTEXT_FILE attrs and context_file assertions across the base mixins, all 35 per-integration test files, shared integration tests, and conftest stubs. Rewrite the base-mixin context tests to assert no managed section is written and no __CONTEXT_FILE__ placeholder survives. Extend the CLI-free static guard to forbid context_file, __CONTEXT_FILE__, and _context_file_display in src/specify_cli, and have the extension tests copy the bundled defaults JSON so self-seed runs without the CLI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: reflect full removal of agent-context state from the CLI Update AGENTS.md (integration examples, required-fields table, context behavior section, pitfalls), CHANGELOG, and the SDD spec artifacts (FR-007, SC-002, data-model) to state that the CLI carries no context_file and the extension fully owns the per-agent default mapping via agent-context-defaults.json. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: align SDD artifacts with full context_file removal Update research.md (R1, R2, R4, summary table), contracts/cli-behavior.md (C3, C5), tasks.md (Phase 2, T026, notes), plan.md (Principle I, source map), and checklists/requirements.md so the spec artifacts reflect the implemented decision: the CLI carries no context_file attribute or __CONTEXT_FILE__ resolution, and the per-agent defaults map lives in the extension. Resolves PR review #4548130110. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: scrub stale context-file mentions from CLI docstrings Update the multi_install_safe docstring (drop the removed "context file" invariant), the RovoDev setup docstring (no longer upserts a context section), the Copilot module docstring (drop the context-file line), and tighten the _update_init_options_for_integration note. Pure docstring changes — no behavioral impact. Resolves PR review #4548237085. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test+docs: harden agent-context test helper and fix stale docs - base.py: document multi_install_safe as an optional subclass attribute in the IntegrationBase docstring. - test_cli.py: clarify the init-options assertion is guarding against leftover legacy agent-context keys, not relocation. - test_extension_agent_context.py: _install_agent_context_config now asserts the bundled agent-context-defaults.json exists and always copies it, so self-seeding tests fail loudly instead of silently skipping when the map is missing. - test_integration_cursor_agent.py: drop Path/IntegrationManifest imports left unused after removing the context-section frontmatter tests. Resolves PR review #4548293116. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: remove gitignored SDD artifacts from specs/ The specs/001-agent-context-full-optin/ artifacts were force-added for dogfooding visibility, but specs/ is gitignored and these were always intended to be purged before merge. Remove them so merging does not add an intentionally-untracked directory to repo history. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: keep CHANGELOG.md identical to upstream CHANGELOG.md is auto-generated at release time, so the branch should not carry a manual entry. Restore it to match upstream/main exactly. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: preserve Cursor .mdc frontmatter in agent-context updater scripts The bundled agent-context updater scripts wrote the managed section as plain text. For Cursor-style `.mdc` targets this dropped the required `---\nalwaysApply: true\n---` frontmatter, reintroducing the rule-loading bug originally fixed in #1699. Port the `_ensure_mdc_frontmatter` logic into both the bash and PowerShell updaters: prepend frontmatter when missing, repair `alwaysApply` when set to the wrong value, and leave non-`.mdc` targets untouched. Add regression tests covering both shells. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: scope CLI-free guard to agent-context-specific symbols Drop the bare "context_file" substring from FORBIDDEN_SYMBOLS so the guard no longer fails on unrelated future CLI fields named context_file. The list still covers agent-context-specific identifiers (__CONTEXT_FILE__, _context_file_display, _resolve_context_files, _resolve_context_file_values). Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden agent-context bash self-seed against malformed init JSON Two robustness fixes in the embedded Python self-seed logic: - Coerce the integration value from init-options.json to a string only when it is actually a string; otherwise treat it as unset so a corrupted dict/list value degrades to the existing nothing-to-do behavior instead of breaking the agents-map lookup. - Normalize agent-context-defaults.json: only use 'agents' when both the JSON root and the 'agents' value are dicts, so a wrong-shaped (but valid) JSON falls back to the warning path instead of raising on .get. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct PowerShell hyphenated key lookup and regex replace count - Self-seed now reads the defaults mapping via $defaults.agents.PSObject.Properties[$integrationKey].Value instead of member access ($defaults.agents.$integrationKey), which parsed hyphenated keys like 'cursor-agent'/'kiro-cli' as subtraction and failed to resolve. - Replace the static [regex]::Replace(..., 1) call, whose trailing 1 was interpreted as RegexOptions.IgnoreCase rather than a replacement count, with an instance Regex whose Replace(input, replacement, 1) limits to the first match as intended. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make bash .mdc frontmatter guard case-insensitive The bash updater only injected Cursor .mdc frontmatter when ctx_path ended in lowercase '.mdc', so a mixed/upper-case extension (e.g. specify-rules.MDC) was skipped and Cursor would not auto-load the rule file. Compare against the casefolded path. The PowerShell variant already uses -match, which is case-insensitive by default, so no change is needed there. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document separator-agnostic agent-context update invocation The README hard-coded the dot-notation slash command (/speckit.agent-context.update), which hyphen-separator agents like Forge and Cline do not recognize. Document the canonical command ID plus both slash invocations so users copy the form their agent accepts. Assisted-by: GitHub Copilot (model: Claude Opus 4.8, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
57
tests/extensions/test_agent_context_cli_free.py
Normal file
57
tests/extensions/test_agent_context_cli_free.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Static guard: the Specify CLI source must contain no agent-context lifecycle code.
|
||||
|
||||
The ``agent-context`` extension is a full opt-in and owns its own lifecycle. The
|
||||
Python codebase (``src/specify_cli/**``) must therefore not reference any of the
|
||||
removed context-section management helpers, the extension config helpers, the
|
||||
context markers, or the obsolete deprecation message.
|
||||
|
||||
Maps to contract C5 / FR-002 / FR-003 / FR-006 / SC-002 / SC-003.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
SRC_ROOT = PROJECT_ROOT / "src" / "specify_cli"
|
||||
|
||||
FORBIDDEN_SYMBOLS = [
|
||||
"upsert_context_section",
|
||||
"remove_context_section",
|
||||
"_agent_context_extension_enabled",
|
||||
"_resolve_context_markers",
|
||||
"_resolve_context_files",
|
||||
"_resolve_context_file_values",
|
||||
"_build_context_section",
|
||||
"_AGENT_CTX_EXT_CONFIG",
|
||||
"_load_agent_context_config",
|
||||
"_save_agent_context_config",
|
||||
"_update_agent_context_config_file",
|
||||
"CONTEXT_MARKER_START",
|
||||
"CONTEXT_MARKER_END",
|
||||
"agent-context-config",
|
||||
"agent_context_config",
|
||||
"__CONTEXT_FILE__",
|
||||
"_context_file_display",
|
||||
"Inline agent-context updates",
|
||||
"v0.12.0",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cli_source_texts() -> list[tuple[str, str]]:
|
||||
"""Read every CLI source file once, shared across all parametrized cases."""
|
||||
return [
|
||||
(str(path.relative_to(PROJECT_ROOT)), path.read_text(encoding="utf-8"))
|
||||
for path in SRC_ROOT.rglob("*.py")
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("symbol", FORBIDDEN_SYMBOLS)
|
||||
def test_symbol_absent_from_cli_source(symbol, cli_source_texts):
|
||||
offenders = [rel for rel, text in cli_source_texts if symbol in text]
|
||||
assert not offenders, (
|
||||
f"Forbidden agent-context symbol {symbol!r} still present in: {offenders}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,4 +20,3 @@ class StubIntegration(MarkdownIntegration):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "STUB.md"
|
||||
|
||||
@@ -43,7 +43,6 @@ class TestIntegrationBase:
|
||||
assert i.key == "stub"
|
||||
assert i.config["name"] == "Stub Agent"
|
||||
assert i.registrar_config["format"] == "markdown"
|
||||
assert i.context_file == "STUB.md"
|
||||
|
||||
def test_options_default_empty(self):
|
||||
assert StubIntegration.options() == []
|
||||
|
||||
@@ -77,23 +77,17 @@ class TestInitIntegrationFlag:
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts["integration"] == "copilot"
|
||||
# context_file lives in the agent-context extension config, not init-options.json
|
||||
# init must not leave any legacy agent-context keys in init-options.json
|
||||
assert "context_file" not in opts
|
||||
|
||||
import yaml as _yaml
|
||||
# agent-context is fully opt-in: init must not install it or write its config
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
assert ext_cfg_path.exists(), "agent-context extension config must be created on init"
|
||||
ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8"))
|
||||
assert ext_cfg["context_file"] == ".github/copilot-instructions.md"
|
||||
assert not ext_cfg_path.exists(), "init must not create the agent-context extension config"
|
||||
|
||||
assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
|
||||
|
||||
# Context section should be upserted into the copilot instructions file
|
||||
ctx_file = project / ".github" / "copilot-instructions.md"
|
||||
assert ctx_file.exists()
|
||||
ctx_content = ctx_file.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" in ctx_content
|
||||
assert "<!-- SPECKIT END -->" in ctx_content
|
||||
# init must not create or manage the agent context file
|
||||
assert not (project / ".github" / "copilot-instructions.md").exists()
|
||||
|
||||
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
|
||||
assert shared_manifest.exists()
|
||||
@@ -1270,7 +1264,6 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "BROKEN.md"
|
||||
|
||||
def setup(self, project_root, manifest, **kwargs):
|
||||
raise OSError("setup exploded\nwith context")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestAmpIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".agents/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".agents/commands"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestAuggieIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".augment/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".augment/commands"
|
||||
CONTEXT_FILE = ".augment/rules/specify-rules.md"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Reusable test mixin for standard MarkdownIntegration subclasses.
|
||||
|
||||
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
|
||||
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
|
||||
logic from ``MarkdownIntegrationTests``.
|
||||
and ``REGISTRAR_DIR``, then inherits all verification logic from
|
||||
``MarkdownIntegrationTests``.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -21,14 +21,12 @@ class MarkdownIntegrationTests:
|
||||
FOLDER: str — e.g. ".claude/"
|
||||
COMMANDS_SUBDIR: str — e.g. "commands"
|
||||
REGISTRAR_DIR: str — e.g. ".claude/commands"
|
||||
CONTEXT_FILE: str — e.g. "CLAUDE.md"
|
||||
"""
|
||||
|
||||
KEY: str
|
||||
FOLDER: str
|
||||
COMMANDS_SUBDIR: str
|
||||
REGISTRAR_DIR: str
|
||||
CONTEXT_FILE: str
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
@@ -56,10 +54,6 @@ class MarkdownIntegrationTests:
|
||||
assert i.registrar_config["args"] == "$ARGUMENTS"
|
||||
assert i.registrar_config["extension"] == ".md"
|
||||
|
||||
def test_context_file(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.context_file == self.CONTEXT_FILE
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
@@ -101,19 +95,18 @@ class MarkdownIntegrationTests:
|
||||
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
||||
assert "\nscripts:\n" not in content, f"{f.name} has unstripped scripts: block"
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
"""The generated plan command must reference this integration's context file."""
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
Agent context files are owned entirely by the opt-in agent-context
|
||||
extension, so the core plan command must not reference one.
|
||||
"""
|
||||
i = get_integration(self.KEY)
|
||||
if not i.context_file:
|
||||
return
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
|
||||
assert plan_file.exists(), f"Plan file {plan_file} not created"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert i.context_file in content, (
|
||||
f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}"
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
|
||||
)
|
||||
@@ -149,35 +142,32 @@ class MarkdownIntegrationTests:
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
# -- Context section ---------------------------------------------------
|
||||
# -- Context file ownership (extension-owned, opt-in) -----------------
|
||||
|
||||
def test_setup_upserts_context_section(self, tmp_path):
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""Setup must not create or manage any agent context file — that is
|
||||
owned entirely by the opt-in agent-context extension."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
if i.context_file:
|
||||
ctx_path = tmp_path / i.context_file
|
||||
assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}"
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert "<!-- SPECKIT END -->" 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 "<!-- SPECKIT START -->" not in text, (
|
||||
f"Setup wrote a managed context section into {path} for {self.KEY}"
|
||||
)
|
||||
|
||||
def test_teardown_removes_context_section(self, tmp_path):
|
||||
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
|
||||
"""A user-authored context file must survive setup + teardown untouched."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
ctx_path = tmp_path / "AGENTS.md"
|
||||
original = "# My Rules\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
i.setup(tmp_path, m)
|
||||
m.save()
|
||||
if i.context_file:
|
||||
ctx_path = tmp_path / i.context_file
|
||||
# Add user content around the section
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8")
|
||||
i.teardown(tmp_path, m)
|
||||
remaining = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" not in remaining
|
||||
assert "<!-- SPECKIT END -->" not in remaining
|
||||
assert "# My Rules" in remaining
|
||||
i.teardown(tmp_path, m)
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
@@ -225,35 +215,10 @@ class MarkdownIntegrationTests:
|
||||
commands = sorted(cmd_dir.glob("speckit.*"))
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"opts-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze", "clarify", "constitution", "converge", "implement",
|
||||
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
@@ -293,19 +258,7 @@ class MarkdownIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
return sorted(files)
|
||||
|
||||
|
||||
@@ -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-<name>/SKILL.md`` skills layout.
|
||||
@@ -26,14 +26,12 @@ class SkillsIntegrationTests:
|
||||
FOLDER: str — e.g. ".agents/"
|
||||
COMMANDS_SUBDIR: str — e.g. "skills"
|
||||
REGISTRAR_DIR: str — e.g. ".agents/skills"
|
||||
CONTEXT_FILE: str — e.g. "AGENTS.md"
|
||||
"""
|
||||
|
||||
KEY: str
|
||||
FOLDER: str
|
||||
COMMANDS_SUBDIR: str
|
||||
REGISTRAR_DIR: str
|
||||
CONTEXT_FILE: str
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
@@ -61,10 +59,6 @@ class SkillsIntegrationTests:
|
||||
assert i.registrar_config["args"] == "$ARGUMENTS"
|
||||
assert i.registrar_config["extension"] == "/SKILL.md"
|
||||
|
||||
def test_context_file(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.context_file == self.CONTEXT_FILE
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
@@ -222,19 +216,18 @@ class SkillsIntegrationTests:
|
||||
body = parts[2].strip() if len(parts) >= 3 else ""
|
||||
assert len(body) > 0, f"{f} has empty body"
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
"""The generated plan skill must reference this integration's context file."""
|
||||
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan skill must not carry a context-file placeholder.
|
||||
|
||||
Agent context files are owned entirely by the opt-in agent-context
|
||||
extension, so the core plan skill must not reference one.
|
||||
"""
|
||||
i = get_integration(self.KEY)
|
||||
if not i.context_file:
|
||||
return
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
plan_file = i.skills_dest(tmp_path) / "speckit-plan" / "SKILL.md"
|
||||
assert plan_file.exists(), f"Plan skill {plan_file} not created"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert i.context_file in content, (
|
||||
f"Plan skill should reference {i.context_file!r} but it was not found"
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
"Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
|
||||
)
|
||||
@@ -283,34 +276,32 @@ class SkillsIntegrationTests:
|
||||
|
||||
assert (foreign_dir / "SKILL.md").exists(), "Foreign skill was removed"
|
||||
|
||||
# -- Context section ---------------------------------------------------
|
||||
# -- Context file ownership (extension-owned, opt-in) -----------------
|
||||
|
||||
def test_setup_upserts_context_section(self, tmp_path):
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""Setup must not create or manage any agent context file — that is
|
||||
owned entirely by the opt-in agent-context extension."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
if i.context_file:
|
||||
ctx_path = tmp_path / i.context_file
|
||||
assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}"
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert "<!-- SPECKIT END -->" 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 "<!-- SPECKIT START -->" not in text, (
|
||||
f"Setup wrote a managed context section into {path} for {self.KEY}"
|
||||
)
|
||||
|
||||
def test_teardown_removes_context_section(self, tmp_path):
|
||||
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
|
||||
"""A user-authored context file must survive setup + teardown untouched."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
ctx_path = tmp_path / "AGENTS.md"
|
||||
original = "# My Rules\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
i.setup(tmp_path, m)
|
||||
m.save()
|
||||
if i.context_file:
|
||||
ctx_path = tmp_path / i.context_file
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8")
|
||||
i.teardown(tmp_path, m)
|
||||
remaining = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" not in remaining
|
||||
assert "<!-- SPECKIT END -->" not in remaining
|
||||
assert "# My Rules" in remaining
|
||||
i.teardown(tmp_path, m)
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
@@ -356,9 +347,9 @@ class SkillsIntegrationTests:
|
||||
skills_dir = i.skills_dest(project)
|
||||
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
def test_init_does_not_create_agent_context_config(self, tmp_path):
|
||||
"""agent-context is opt-in: init must not auto-install the extension
|
||||
or write its config."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -375,11 +366,7 @@ class SkillsIntegrationTests:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
assert not ext_cfg_path.exists()
|
||||
|
||||
# -- IntegrationOption ------------------------------------------------
|
||||
|
||||
@@ -406,8 +393,6 @@ class SkillsIntegrationTests:
|
||||
# Skill files (core commands)
|
||||
for cmd in self._SKILL_COMMANDS:
|
||||
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
|
||||
# Extension-installed skill (agent-context)
|
||||
files.append(f"{skills_prefix}/speckit-agent-context-update/SKILL.md")
|
||||
# Integration metadata
|
||||
files += [
|
||||
".specify/init-options.json",
|
||||
@@ -446,18 +431,6 @@ class SkillsIntegrationTests:
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
]
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
return sorted(files)
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Reusable test mixin for standard TomlIntegration subclasses.
|
||||
|
||||
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
|
||||
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
|
||||
logic from ``TomlIntegrationTests``.
|
||||
and ``REGISTRAR_DIR``, then inherits all verification logic from
|
||||
``TomlIntegrationTests``.
|
||||
|
||||
Mirrors ``MarkdownIntegrationTests`` closely — same test structure,
|
||||
adapted for TOML output format.
|
||||
@@ -27,14 +27,12 @@ class TomlIntegrationTests:
|
||||
FOLDER: str — e.g. ".gemini/"
|
||||
COMMANDS_SUBDIR: str — e.g. "commands"
|
||||
REGISTRAR_DIR: str — e.g. ".gemini/commands"
|
||||
CONTEXT_FILE: str — e.g. "GEMINI.md"
|
||||
"""
|
||||
|
||||
KEY: str
|
||||
FOLDER: str
|
||||
COMMANDS_SUBDIR: str
|
||||
REGISTRAR_DIR: str
|
||||
CONTEXT_FILE: str
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
@@ -62,10 +60,6 @@ class TomlIntegrationTests:
|
||||
assert i.registrar_config["args"] == "{{args}}"
|
||||
assert i.registrar_config["extension"] == ".toml"
|
||||
|
||||
def test_context_file(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.context_file == self.CONTEXT_FILE
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
@@ -311,19 +305,18 @@ class TomlIntegrationTests:
|
||||
raise AssertionError(f"{f.name} is not valid TOML: {exc}") from exc
|
||||
assert "prompt" in parsed, f"{f.name} parsed TOML has no 'prompt' key"
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
"""The generated plan command must reference this integration's context file."""
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
Agent context files are owned entirely by the opt-in agent-context
|
||||
extension, so the core plan command must not reference one.
|
||||
"""
|
||||
i = get_integration(self.KEY)
|
||||
if not i.context_file:
|
||||
return
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
|
||||
assert plan_file.exists(), f"Plan file {plan_file} not created"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert i.context_file in content, (
|
||||
f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}"
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
|
||||
)
|
||||
@@ -359,34 +352,32 @@ class TomlIntegrationTests:
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
# -- Context section ---------------------------------------------------
|
||||
# -- Context file ownership (extension-owned, opt-in) -----------------
|
||||
|
||||
def test_setup_upserts_context_section(self, tmp_path):
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""Setup must not create or manage any agent context file — that is
|
||||
owned entirely by the opt-in agent-context extension."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
if i.context_file:
|
||||
ctx_path = tmp_path / i.context_file
|
||||
assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}"
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert "<!-- SPECKIT END -->" 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 "<!-- SPECKIT START -->" not in text, (
|
||||
f"Setup wrote a managed context section into {path} for {self.KEY}"
|
||||
)
|
||||
|
||||
def test_teardown_removes_context_section(self, tmp_path):
|
||||
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
|
||||
"""A user-authored context file must survive setup + teardown untouched."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
ctx_path = tmp_path / "AGENTS.md"
|
||||
original = "# My Rules\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
i.setup(tmp_path, m)
|
||||
m.save()
|
||||
if i.context_file:
|
||||
ctx_path = tmp_path / i.context_file
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8")
|
||||
i.teardown(tmp_path, m)
|
||||
remaining = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" not in remaining
|
||||
assert "<!-- SPECKIT END -->" not in remaining
|
||||
assert "# My Rules" in remaining
|
||||
i.teardown(tmp_path, m)
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
@@ -454,35 +445,10 @@ class TomlIntegrationTests:
|
||||
commands = sorted(cmd_dir.glob("speckit.*.toml"))
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"opts-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"clarify",
|
||||
"constitution",
|
||||
@@ -544,19 +510,7 @@ class TomlIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
return sorted(files)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Reusable test mixin for standard YamlIntegration subclasses.
|
||||
|
||||
Each per-agent test file sets ``KEY``, ``FOLDER``, ``COMMANDS_SUBDIR``,
|
||||
``REGISTRAR_DIR``, and ``CONTEXT_FILE``, then inherits all verification
|
||||
logic from ``YamlIntegrationTests``.
|
||||
and ``REGISTRAR_DIR``, then inherits all verification logic from
|
||||
``YamlIntegrationTests``.
|
||||
|
||||
Mirrors ``TomlIntegrationTests`` closely — same test structure,
|
||||
adapted for YAML recipe output format.
|
||||
@@ -26,14 +26,12 @@ class YamlIntegrationTests:
|
||||
FOLDER: str — e.g. ".goose/"
|
||||
COMMANDS_SUBDIR: str — e.g. "recipes"
|
||||
REGISTRAR_DIR: str — e.g. ".goose/recipes"
|
||||
CONTEXT_FILE: str — e.g. "AGENTS.md"
|
||||
"""
|
||||
|
||||
KEY: str
|
||||
FOLDER: str
|
||||
COMMANDS_SUBDIR: str
|
||||
REGISTRAR_DIR: str
|
||||
CONTEXT_FILE: str
|
||||
|
||||
# -- Registration -----------------------------------------------------
|
||||
|
||||
@@ -61,10 +59,6 @@ class YamlIntegrationTests:
|
||||
assert i.registrar_config["args"] == "{{args}}"
|
||||
assert i.registrar_config["extension"] == ".yaml"
|
||||
|
||||
def test_context_file(self):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.context_file == self.CONTEXT_FILE
|
||||
|
||||
# -- Setup / teardown -------------------------------------------------
|
||||
|
||||
def test_setup_creates_files(self, tmp_path):
|
||||
@@ -190,19 +184,18 @@ class YamlIntegrationTests:
|
||||
assert "scripts:" not in parsed["prompt"]
|
||||
assert "---" not in parsed["prompt"]
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
"""The generated plan command must reference this integration's context file."""
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The generated plan command must not carry a context-file placeholder.
|
||||
|
||||
Agent context files are owned entirely by the opt-in agent-context
|
||||
extension, so the core plan command must not reference one.
|
||||
"""
|
||||
i = get_integration(self.KEY)
|
||||
if not i.context_file:
|
||||
return
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
plan_file = i.commands_dest(tmp_path) / i.command_filename("plan")
|
||||
assert plan_file.exists(), f"Plan file {plan_file} not created"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert i.context_file in content, (
|
||||
f"Plan command should reference {i.context_file!r} but it was not found in {plan_file.name}"
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
f"Plan command has unprocessed __CONTEXT_FILE__ placeholder in {plan_file.name}"
|
||||
)
|
||||
@@ -238,34 +231,32 @@ class YamlIntegrationTests:
|
||||
assert modified_file.exists()
|
||||
assert modified_file in skipped
|
||||
|
||||
# -- Context section ---------------------------------------------------
|
||||
# -- Context file ownership (extension-owned, opt-in) -----------------
|
||||
|
||||
def test_setup_upserts_context_section(self, tmp_path):
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""Setup must not create or manage any agent context file — that is
|
||||
owned entirely by the opt-in agent-context extension."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
if i.context_file:
|
||||
ctx_path = tmp_path / i.context_file
|
||||
assert ctx_path.exists(), f"Context file {i.context_file} not created for {self.KEY}"
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert "<!-- SPECKIT END -->" 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 "<!-- SPECKIT START -->" not in text, (
|
||||
f"Setup wrote a managed context section into {path} for {self.KEY}"
|
||||
)
|
||||
|
||||
def test_teardown_removes_context_section(self, tmp_path):
|
||||
def test_teardown_leaves_existing_context_file_intact(self, tmp_path):
|
||||
"""A user-authored context file must survive setup + teardown untouched."""
|
||||
i = get_integration(self.KEY)
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
ctx_path = tmp_path / "AGENTS.md"
|
||||
original = "# My Rules\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
i.setup(tmp_path, m)
|
||||
m.save()
|
||||
if i.context_file:
|
||||
ctx_path = tmp_path / i.context_file
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
ctx_path.write_text("# My Rules\n\n" + content + "\n# Footer\n", encoding="utf-8")
|
||||
i.teardown(tmp_path, m)
|
||||
remaining = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" not in remaining
|
||||
assert "<!-- SPECKIT END -->" not in remaining
|
||||
assert "# My Rules" in remaining
|
||||
i.teardown(tmp_path, m)
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
# -- CLI integration flag -------------------------------------------------
|
||||
|
||||
@@ -333,35 +324,10 @@ class YamlIntegrationTests:
|
||||
commands = sorted(cmd_dir.glob("speckit.*.yaml"))
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / f"opts-{self.KEY}"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", self.KEY, "--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"clarify",
|
||||
"constitution",
|
||||
@@ -423,19 +389,7 @@ class YamlIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
return sorted(files)
|
||||
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestBobIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".bob/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".bob/commands"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Tests for ClaudeIntegration."""
|
||||
|
||||
import codecs
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -34,10 +33,6 @@ class TestClaudeIntegration:
|
||||
assert integration.registrar_config["args"] == "$ARGUMENTS"
|
||||
assert integration.registrar_config["extension"] == "/SKILL.md"
|
||||
|
||||
def test_context_file(self):
|
||||
integration = get_integration("claude")
|
||||
assert integration.context_file == "CLAUDE.md"
|
||||
|
||||
def test_setup_creates_skill_files(self, tmp_path):
|
||||
integration = get_integration("claude")
|
||||
manifest = IntegrationManifest("claude", tmp_path)
|
||||
@@ -76,57 +71,30 @@ class TestClaudeIntegration:
|
||||
)
|
||||
assert "Prüfe Konformität" in rendered
|
||||
|
||||
def test_setup_upserts_context_section(self, tmp_path):
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""The CLI no longer manages the agent context file — that is owned by
|
||||
the opt-in agent-context extension. Setup must not create or touch it."""
|
||||
integration = get_integration("claude")
|
||||
manifest = IntegrationManifest("claude", tmp_path)
|
||||
integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
ctx_path = tmp_path / integration.context_file
|
||||
assert ctx_path.exists()
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert "<!-- SPECKIT END -->" 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 "<!-- SPECKIT START -->" not in text
|
||||
|
||||
def test_upsert_context_section_strips_bom(self, tmp_path):
|
||||
"""Existing context file with UTF-8 BOM must be cleaned up on upsert."""
|
||||
def test_teardown_does_not_touch_existing_context_file(self, tmp_path):
|
||||
"""A user-authored context file is left intact on teardown."""
|
||||
integration = get_integration("claude")
|
||||
ctx_path = tmp_path / integration.context_file
|
||||
ctx_path = tmp_path / "CLAUDE.md"
|
||||
original = "# CLAUDE.md\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
|
||||
# Write a file that starts with a UTF-8 BOM (as the old PowerShell script did)
|
||||
bom = codecs.BOM_UTF8
|
||||
ctx_path.write_bytes(bom + b"# CLAUDE.md\n\nSome existing content.\n")
|
||||
manifest = IntegrationManifest("claude", tmp_path)
|
||||
integration.setup(tmp_path, manifest, script_type="sh")
|
||||
integration.teardown(tmp_path, manifest)
|
||||
|
||||
integration.upsert_context_section(tmp_path)
|
||||
|
||||
result = ctx_path.read_bytes()
|
||||
assert not result.startswith(bom), "BOM must be stripped after upsert"
|
||||
content = result.decode("utf-8")
|
||||
assert "<!-- SPECKIT START -->" 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"
|
||||
"<!-- SPECKIT START -->\n"
|
||||
"For additional context about technologies to be used, project structure,\n"
|
||||
"shell commands, and other important information, read the current plan\n"
|
||||
"<!-- SPECKIT END -->\n"
|
||||
)
|
||||
ctx_path.write_bytes(codecs.BOM_UTF8 + marker_content.encode("utf-8"))
|
||||
|
||||
result = integration.remove_context_section(tmp_path)
|
||||
|
||||
assert result is True
|
||||
assert ctx_path.exists(), "File should exist (non-empty content remains)"
|
||||
remaining = ctx_path.read_bytes()
|
||||
assert not remaining.startswith(codecs.BOM_UTF8), "BOM must be stripped after remove"
|
||||
assert b"<!-- SPECKIT" not in remaining
|
||||
assert b"# CLAUDE.md" in remaining
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
def test_integration_flag_creates_skill_files_cli(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -47,7 +47,6 @@ class TestClineIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".clinerules/"
|
||||
COMMANDS_SUBDIR = "workflows"
|
||||
REGISTRAR_DIR = ".clinerules/workflows"
|
||||
CONTEXT_FILE = ".clinerules/specify-rules.md"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cmd_name, expected_filename",
|
||||
@@ -105,7 +104,6 @@ class TestClineIntegration(MarkdownIntegrationTests):
|
||||
for f in created
|
||||
if "scripts" not in f.parts
|
||||
and f.suffix == ".md"
|
||||
and f.name != i.context_file
|
||||
]
|
||||
for f in cmd_files:
|
||||
assert f.exists()
|
||||
@@ -205,18 +203,4 @@ class TestClineIntegration(MarkdownIntegrationTests):
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
return sorted(files)
|
||||
|
||||
@@ -10,7 +10,6 @@ class TestCodebuddyIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".codebuddy/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".codebuddy/commands"
|
||||
CONTEXT_FILE = "CODEBUDDY.md"
|
||||
|
||||
def test_install_url_points_to_official_cli_install_docs(self):
|
||||
integration = get_integration(self.KEY)
|
||||
|
||||
@@ -11,7 +11,6 @@ class TestCodexIntegration(SkillsIntegrationTests):
|
||||
FOLDER = ".agents/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".agents/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
|
||||
class TestCodexInitFlow:
|
||||
@@ -29,23 +28,11 @@ class TestCodexInitFlow:
|
||||
assert result.exit_code == 0, f"init --integration codex failed: {result.output}"
|
||||
assert (target / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
|
||||
def test_plan_skill_references_configured_context_files(self, tmp_path):
|
||||
"""Plan skill should render all configured agent context files."""
|
||||
from specify_cli import _save_agent_context_config
|
||||
|
||||
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."""
|
||||
target = tmp_path / "test-proj"
|
||||
target.mkdir()
|
||||
_save_agent_context_config(
|
||||
target,
|
||||
{
|
||||
"context_file": "AGENTS.md",
|
||||
"context_files": ["AGENTS.md", "CLAUDE.md"],
|
||||
"context_markers": {
|
||||
"start": "<!-- SPECKIT START -->",
|
||||
"end": "<!-- SPECKIT END -->",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
integration = get_integration("codex")
|
||||
manifest = IntegrationManifest("codex", target)
|
||||
@@ -53,43 +40,31 @@ class TestCodexInitFlow:
|
||||
|
||||
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
content = plan_skill.read_text(encoding="utf-8")
|
||||
assert "AGENTS.md, CLAUDE.md" in content
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_plan_skill_ignores_context_files_when_agent_context_disabled(
|
||||
self, tmp_path
|
||||
):
|
||||
"""Disabled agent-context must not leak stale context_files into commands."""
|
||||
from specify_cli import _save_agent_context_config
|
||||
def test_plan_skill_ignores_extension_config(self, tmp_path):
|
||||
"""The extension config must not influence rendered commands: the CLI
|
||||
no longer reads any context-file metadata when rendering."""
|
||||
import yaml
|
||||
|
||||
target = tmp_path / "test-proj"
|
||||
target.mkdir()
|
||||
registry = target / ".specify" / "extensions" / ".registry"
|
||||
registry.parent.mkdir(parents=True, exist_ok=True)
|
||||
registry.write_text(
|
||||
"""
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"agent-context": {
|
||||
"version": "1.0.0",
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
ext_cfg = (
|
||||
target
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
_save_agent_context_config(
|
||||
target,
|
||||
{
|
||||
"context_file": "AGENTS.md",
|
||||
"context_files": ["../outside.md", "CLAUDE.md"],
|
||||
"context_markers": {
|
||||
"start": "<!-- SPECKIT START -->",
|
||||
"end": "<!-- SPECKIT END -->",
|
||||
},
|
||||
},
|
||||
ext_cfg.parent.mkdir(parents=True, exist_ok=True)
|
||||
ext_cfg.write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"context_file": "FROM_CONFIG.md",
|
||||
"context_files": ["FROM_CONFIG.md", "ALSO_CONFIG.md"],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
integration = get_integration("codex")
|
||||
@@ -98,9 +73,8 @@ class TestCodexInitFlow:
|
||||
|
||||
plan_skill = target / ".agents" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
content = plan_skill.read_text(encoding="utf-8")
|
||||
assert "AGENTS.md, CLAUDE.md" not in content
|
||||
assert "../outside.md" not in content
|
||||
assert "AGENTS.md" in content
|
||||
assert "FROM_CONFIG.md" not in content
|
||||
assert "ALSO_CONFIG.md" not in content
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ class TestCopilotIntegration:
|
||||
assert copilot.config["folder"] == ".github/"
|
||||
assert copilot.config["commands_subdir"] == "agents"
|
||||
assert copilot.registrar_config["extension"] == ".agent.md"
|
||||
assert copilot.context_file == ".github/copilot-instructions.md"
|
||||
|
||||
def test_command_filename_agent_md(self):
|
||||
copilot = get_integration("copilot")
|
||||
@@ -162,8 +161,9 @@ class TestCopilotIntegration:
|
||||
assert "Copy `.specify/templates/spec-template.md`" not in content
|
||||
assert "Load `.specify/templates/spec-template.md`" not in content
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
"""The generated plan command must reference copilot's context file."""
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The core plan command must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
@@ -171,9 +171,6 @@ class TestCopilotIntegration:
|
||||
plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert copilot.context_file in content, (
|
||||
f"Plan command should reference {copilot.context_file!r}"
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
@@ -193,7 +190,6 @@ class TestCopilotIntegration:
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
".github/agents/speckit.checklist.agent.md",
|
||||
".github/agents/speckit.clarify.agent.md",
|
||||
@@ -204,7 +200,6 @@ class TestCopilotIntegration:
|
||||
".github/agents/speckit.specify.agent.md",
|
||||
".github/agents/speckit.tasks.agent.md",
|
||||
".github/agents/speckit.taskstoissues.agent.md",
|
||||
".github/prompts/speckit.agent-context.update.prompt.md",
|
||||
".github/prompts/speckit.analyze.prompt.md",
|
||||
".github/prompts/speckit.checklist.prompt.md",
|
||||
".github/prompts/speckit.clarify.prompt.md",
|
||||
@@ -216,15 +211,6 @@ class TestCopilotIntegration:
|
||||
".github/prompts/speckit.tasks.prompt.md",
|
||||
".github/prompts/speckit.taskstoissues.prompt.md",
|
||||
".vscode/settings.json",
|
||||
".github/copilot-instructions.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/integration.json",
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
@@ -265,7 +251,6 @@ class TestCopilotIntegration:
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
".github/agents/speckit.checklist.agent.md",
|
||||
".github/agents/speckit.clarify.agent.md",
|
||||
@@ -276,7 +261,6 @@ class TestCopilotIntegration:
|
||||
".github/agents/speckit.specify.agent.md",
|
||||
".github/agents/speckit.tasks.agent.md",
|
||||
".github/agents/speckit.taskstoissues.agent.md",
|
||||
".github/prompts/speckit.agent-context.update.prompt.md",
|
||||
".github/prompts/speckit.analyze.prompt.md",
|
||||
".github/prompts/speckit.checklist.prompt.md",
|
||||
".github/prompts/speckit.clarify.prompt.md",
|
||||
@@ -288,15 +272,6 @@ class TestCopilotIntegration:
|
||||
".github/prompts/speckit.tasks.prompt.md",
|
||||
".github/prompts/speckit.taskstoissues.prompt.md",
|
||||
".vscode/settings.json",
|
||||
".github/copilot-instructions.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/integration.json",
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
@@ -537,14 +512,14 @@ class TestCopilotSkillsMode:
|
||||
body = parts[2].strip() if len(parts) >= 3 else ""
|
||||
assert len(body) > 0, f"{f} has empty body"
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
"""The generated plan skill must reference copilot's context file."""
|
||||
def test_plan_skill_has_no_context_placeholder(self, tmp_path):
|
||||
"""The core plan skill must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
plan_file = tmp_path / ".github" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert copilot.context_file in content
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
# -- Manifest tracking ------------------------------------------------
|
||||
@@ -603,14 +578,13 @@ class TestCopilotSkillsMode:
|
||||
|
||||
# -- Context section ---------------------------------------------------
|
||||
|
||||
def test_skills_setup_upserts_context_section(self, tmp_path):
|
||||
def test_skills_setup_does_not_write_context_section(self, tmp_path):
|
||||
copilot = self._make_copilot()
|
||||
self._setup_skills(copilot, tmp_path)
|
||||
ctx_path = tmp_path / copilot.context_file
|
||||
assert ctx_path.exists()
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert "<!-- SPECKIT END -->" in content
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text
|
||||
|
||||
# -- CLI integration test ---------------------------------------------
|
||||
|
||||
@@ -659,20 +633,8 @@ class TestCopilotSkillsMode:
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file() and ".git" not in p.parts)
|
||||
expected = sorted([
|
||||
# Skill files (core + extension-installed agent-context command)
|
||||
# Skill files (core commands)
|
||||
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
||||
".github/skills/speckit-agent-context-update/SKILL.md",
|
||||
# Context file
|
||||
".github/copilot-instructions.md",
|
||||
# Bundled agent-context extension
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
# Integration metadata
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
|
||||
@@ -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 "<!-- SPECKIT START -->" 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:
|
||||
|
||||
@@ -8,7 +8,6 @@ class TestDevinIntegration(SkillsIntegrationTests):
|
||||
FOLDER = ".devin/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".devin/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
|
||||
class TestDevinBuildExecArgs:
|
||||
|
||||
@@ -11,7 +11,6 @@ class TestFirebenderIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".firebender/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".firebender/commands"
|
||||
CONTEXT_FILE = ".firebender/rules/specify-rules.mdc"
|
||||
|
||||
# Firebender reads custom slash commands from ``.firebender/commands/*.mdc``,
|
||||
# so this integration uses the ``.mdc`` extension instead of the ``.md``
|
||||
|
||||
@@ -55,7 +55,6 @@ class TestForgeIntegration:
|
||||
assert forge.config["requires_cli"] is True
|
||||
assert forge.registrar_config["args"] == "{{parameters}}"
|
||||
assert forge.registrar_config["extension"] == ".md"
|
||||
assert forge.context_file == "AGENTS.md"
|
||||
|
||||
def test_command_filename_md(self):
|
||||
forge = get_integration("forge")
|
||||
@@ -73,16 +72,15 @@ class TestForgeIntegration:
|
||||
for f in command_files:
|
||||
assert f.name.endswith(".md")
|
||||
|
||||
def test_setup_upserts_context_section(self, tmp_path):
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
forge.setup(tmp_path, m)
|
||||
ctx_path = tmp_path / forge.context_file
|
||||
assert ctx_path.exists()
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert "<!-- SPECKIT END -->" in content
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text
|
||||
|
||||
def test_all_created_files_tracked_in_manifest(self, tmp_path):
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
@@ -164,8 +162,9 @@ class TestForgeIntegration:
|
||||
"Forge requires hyphen notation (/speckit-<cmd>) for ZSH compatibility"
|
||||
)
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
"""The generated plan command must reference forge's context file."""
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The core plan command must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
from specify_cli.integrations.forge import ForgeIntegration
|
||||
forge = ForgeIntegration()
|
||||
m = IntegrationManifest("forge", tmp_path)
|
||||
@@ -173,9 +172,6 @@ class TestForgeIntegration:
|
||||
plan_file = tmp_path / ".forge" / "commands" / "speckit.plan.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert forge.context_file in content, (
|
||||
f"Plan command should reference {forge.context_file!r}"
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_forge_specific_transformations(self, tmp_path):
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestGeminiIntegration(TomlIntegrationTests):
|
||||
FOLDER = ".gemini/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".gemini/commands"
|
||||
CONTEXT_FILE = "GEMINI.md"
|
||||
|
||||
@@ -31,10 +31,6 @@ class TestGenericIntegration:
|
||||
i = get_integration("generic")
|
||||
assert i.config["requires_cli"] is False
|
||||
|
||||
def test_context_file_is_agents_md(self):
|
||||
i = get_integration("generic")
|
||||
assert i.context_file == "AGENTS.md"
|
||||
|
||||
# -- Options ----------------------------------------------------------
|
||||
|
||||
def test_options_include_commands_dir(self):
|
||||
@@ -161,28 +157,24 @@ class TestGenericIntegration:
|
||||
|
||||
# -- Context section ---------------------------------------------------
|
||||
|
||||
def test_setup_upserts_context_section(self, tmp_path):
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
if i.context_file:
|
||||
ctx_path = tmp_path / i.context_file
|
||||
assert ctx_path.exists()
|
||||
content = ctx_path.read_text(encoding="utf-8")
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert "<!-- SPECKIT END -->" in content
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path):
|
||||
"""The generated plan command must reference generic's context file."""
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The core plan command must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
plan_file = tmp_path / ".custom" / "cmds" / "speckit.plan.md"
|
||||
assert plan_file.exists()
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert i.context_file in content, (
|
||||
f"Plan command should reference {i.context_file!r}"
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content
|
||||
|
||||
def test_plan_defines_quickstart_as_validation_guide(self, tmp_path):
|
||||
@@ -256,28 +248,6 @@ class TestGenericIntegration:
|
||||
# Generic requires --commands-dir via --integration-options
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""agent-context extension config must include context_file for the generic integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "opts-generic"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(app, [
|
||||
"init", "--here", "--integration", "generic",
|
||||
"--integration-options=--commands-dir .myagent/commands",
|
||||
"--script", "sh",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
assert ext_cfg.get("context_file") == "AGENTS.md"
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration generic --integration-options=--commands-dir ... --script sh."""
|
||||
@@ -302,7 +272,6 @@ class TestGenericIntegration:
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = sorted([
|
||||
"AGENTS.md",
|
||||
".myagent/commands/speckit.analyze.md",
|
||||
".myagent/commands/speckit.checklist.md",
|
||||
".myagent/commands/speckit.clarify.md",
|
||||
@@ -313,14 +282,6 @@ class TestGenericIntegration:
|
||||
".myagent/commands/speckit.specify.md",
|
||||
".myagent/commands/speckit.tasks.md",
|
||||
".myagent/commands/speckit.taskstoissues.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
@@ -367,7 +328,6 @@ class TestGenericIntegration:
|
||||
for p in project.rglob("*") if p.is_file() and ".git" not in p.parts
|
||||
)
|
||||
expected = sorted([
|
||||
"AGENTS.md",
|
||||
".myagent/commands/speckit.analyze.md",
|
||||
".myagent/commands/speckit.checklist.md",
|
||||
".myagent/commands/speckit.clarify.md",
|
||||
@@ -378,14 +338,6 @@ class TestGenericIntegration:
|
||||
".myagent/commands/speckit.specify.md",
|
||||
".myagent/commands/speckit.tasks.md",
|
||||
".myagent/commands/speckit.taskstoissues.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,6 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
FOLDER = ".hermes/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = "~/.hermes/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
# -- Hermes-specific setup: skills go to ~/.hermes/skills/ -------------
|
||||
|
||||
@@ -72,23 +71,19 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
"""Override: Hermes writes to global, not project-local."""
|
||||
self.test_setup_writes_to_global_skills_dir(tmp_path, monkeypatch)
|
||||
|
||||
def test_plan_references_correct_context_file(self, tmp_path, monkeypatch):
|
||||
"""Plan skill goes to global dir, but we check it still references AGENTS.md."""
|
||||
def test_plan_skill_has_no_context_placeholder(self, tmp_path, monkeypatch):
|
||||
"""The core plan skill must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
home = _fake_home(tmp_path)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
if not i.context_file:
|
||||
return
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
i.setup(tmp_path, m)
|
||||
# Find the plan skill in global ~/.hermes/skills/
|
||||
plan_file = home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert plan_file.exists(), f"Plan skill {plan_file} not created globally"
|
||||
content = plan_file.read_text(encoding="utf-8")
|
||||
assert i.context_file in content, (
|
||||
f"Plan skill should reference {i.context_file!r} but it was not found"
|
||||
)
|
||||
assert "__CONTEXT_FILE__" not in content, (
|
||||
"Plan skill has unprocessed __CONTEXT_FILE__ placeholder"
|
||||
)
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestIflowIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".iflow/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".iflow/commands"
|
||||
CONTEXT_FILE = "IFLOW.md"
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestJunieIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".junie/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".junie/commands"
|
||||
CONTEXT_FILE = ".junie/AGENTS.md"
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestKilocodeIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".kilocode/"
|
||||
COMMANDS_SUBDIR = "workflows"
|
||||
REGISTRAR_DIR = ".kilocode/workflows"
|
||||
CONTEXT_FILE = ".kilocode/rules/specify-rules.md"
|
||||
|
||||
@@ -6,7 +6,6 @@ import pytest
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.kimi import (
|
||||
_migrate_legacy_kimi_context_file,
|
||||
_migrate_legacy_kimi_dotted_skills,
|
||||
_migrate_legacy_kimi_skills_dir,
|
||||
)
|
||||
@@ -36,7 +35,6 @@ class TestKimiIntegration(SkillsIntegrationTests):
|
||||
FOLDER = ".kimi-code/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".kimi-code/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
|
||||
class TestKimiOptions:
|
||||
@@ -165,168 +163,6 @@ class TestKimiLegacyMigration:
|
||||
assert (new_skills_dir / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestKimiContextFileMigration:
|
||||
"""KIMI.md → AGENTS.md migration under --migrate-legacy."""
|
||||
|
||||
def test_setup_migrate_legacy_moves_kimi_md_user_content(self, tmp_path):
|
||||
i = get_integration("kimi")
|
||||
|
||||
kimi_md = tmp_path / "KIMI.md"
|
||||
kimi_md.write_text(
|
||||
"# Project context\n\n"
|
||||
"<!-- SPECKIT START -->\n"
|
||||
"old managed section\n"
|
||||
"<!-- SPECKIT END -->\n\n"
|
||||
"Keep this user note.\n"
|
||||
)
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
|
||||
|
||||
agents_md = tmp_path / "AGENTS.md"
|
||||
assert agents_md.exists()
|
||||
content = agents_md.read_text(encoding="utf-8")
|
||||
assert "Keep this user note." in content
|
||||
assert "old managed section" not in content
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert not kimi_md.exists()
|
||||
|
||||
def test_setup_migrate_legacy_removes_empty_kimi_md(self, tmp_path):
|
||||
i = get_integration("kimi")
|
||||
|
||||
kimi_md = tmp_path / "KIMI.md"
|
||||
kimi_md.write_text(
|
||||
"<!-- SPECKIT START -->\n"
|
||||
"only managed section\n"
|
||||
"<!-- SPECKIT END -->\n"
|
||||
)
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
|
||||
|
||||
assert (tmp_path / "AGENTS.md").exists()
|
||||
assert not kimi_md.exists()
|
||||
|
||||
def test_setup_migrate_legacy_appends_to_existing_agents_md(self, tmp_path):
|
||||
i = get_integration("kimi")
|
||||
|
||||
agents_md = tmp_path / "AGENTS.md"
|
||||
agents_md.write_text("# Existing AGENTS.md\n\nExisting note.\n")
|
||||
|
||||
kimi_md = tmp_path / "KIMI.md"
|
||||
kimi_md.write_text("# Kimi context\n\nKimi-specific note.\n")
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
|
||||
|
||||
content = agents_md.read_text(encoding="utf-8")
|
||||
assert "Existing note." in content
|
||||
assert "Kimi-specific note." in content
|
||||
assert "<!-- SPECKIT START -->" in content
|
||||
assert not kimi_md.exists()
|
||||
|
||||
def test_setup_migrate_legacy_uses_custom_context_markers(self, tmp_path):
|
||||
"""Migration respects context_markers from agent-context extension config."""
|
||||
i = get_integration("kimi")
|
||||
|
||||
config_dir = tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
config_dir.mkdir(parents=True)
|
||||
(config_dir / "agent-context-config.yml").write_text(
|
||||
"context_file: AGENTS.md\n"
|
||||
"context_markers:\n"
|
||||
" start: '<!-- CUSTOM START -->'\n"
|
||||
" end: '<!-- CUSTOM END -->'\n"
|
||||
)
|
||||
|
||||
kimi_md = tmp_path / "KIMI.md"
|
||||
kimi_md.write_text(
|
||||
"# Project context\n\n"
|
||||
"<!-- CUSTOM START -->\n"
|
||||
"old managed section\n"
|
||||
"<!-- CUSTOM END -->\n\n"
|
||||
"Keep this user note.\n"
|
||||
)
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
|
||||
|
||||
agents_md = tmp_path / "AGENTS.md"
|
||||
assert agents_md.exists()
|
||||
content = agents_md.read_text(encoding="utf-8")
|
||||
assert "Keep this user note." in content
|
||||
assert "old managed section" not in content
|
||||
assert "<!-- CUSTOM START -->" in content
|
||||
assert "<!-- CUSTOM END -->" in content
|
||||
assert "<!-- SPECKIT START -->" not in content
|
||||
assert not kimi_md.exists()
|
||||
|
||||
def test_setup_migrate_legacy_skipped_when_agent_context_disabled(
|
||||
self, tmp_path
|
||||
):
|
||||
"""A disabled agent-context extension opts out of KIMI.md migration."""
|
||||
i = get_integration("kimi")
|
||||
|
||||
registry = tmp_path / ".specify" / "extensions" / ".registry"
|
||||
registry.parent.mkdir(parents=True)
|
||||
registry.write_text('{"extensions": {"agent-context": {"enabled": false}}}')
|
||||
|
||||
kimi_md = tmp_path / "KIMI.md"
|
||||
kimi_md.write_text("# Kimi context\n\nKeep this user note.\n")
|
||||
|
||||
m = IntegrationManifest("kimi", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"migrate_legacy": True})
|
||||
|
||||
# Opted-out project: KIMI.md is left untouched and AGENTS.md is not
|
||||
# created/modified by the migration.
|
||||
assert kimi_md.is_file()
|
||||
assert kimi_md.read_text() == "# Kimi context\n\nKeep this user note.\n"
|
||||
assert not (tmp_path / "AGENTS.md").exists()
|
||||
|
||||
def test_context_migration_skips_corrupted_single_marker(self, tmp_path):
|
||||
"""A KIMI.md with only a start marker is left untouched (no leak)."""
|
||||
project = tmp_path
|
||||
kimi_md = project / "KIMI.md"
|
||||
kimi_md.write_text(
|
||||
"# Notes\n\n"
|
||||
"<!-- SPECKIT START -->\n"
|
||||
"dangling managed content\n"
|
||||
)
|
||||
|
||||
result = _migrate_legacy_kimi_context_file(project)
|
||||
|
||||
assert result is False
|
||||
# KIMI.md untouched; managed block never copied into AGENTS.md.
|
||||
assert kimi_md.is_file()
|
||||
assert "dangling managed content" in kimi_md.read_text()
|
||||
assert not (project / "AGENTS.md").exists()
|
||||
|
||||
def test_context_migration_skips_unreadable_kimi_md(self, tmp_path):
|
||||
"""Non-UTF-8 KIMI.md is skipped instead of raising during setup."""
|
||||
project = tmp_path
|
||||
kimi_md = project / "KIMI.md"
|
||||
kimi_md.write_bytes(b"\xff\xfe invalid utf-8 \xa6\n")
|
||||
|
||||
result = _migrate_legacy_kimi_context_file(project)
|
||||
|
||||
assert result is False
|
||||
assert kimi_md.is_file()
|
||||
assert not (project / "AGENTS.md").exists()
|
||||
|
||||
def test_context_migration_skips_when_agents_md_is_directory(self, tmp_path):
|
||||
"""An AGENTS.md that exists as a directory is skipped, not written to."""
|
||||
project = tmp_path
|
||||
(project / "AGENTS.md").mkdir()
|
||||
kimi_md = project / "KIMI.md"
|
||||
kimi_md.write_text("# Notes\n\nKeep this.\n")
|
||||
|
||||
result = _migrate_legacy_kimi_context_file(project)
|
||||
|
||||
assert result is False
|
||||
# KIMI.md is preserved and the directory is untouched.
|
||||
assert kimi_md.is_file()
|
||||
assert (project / "AGENTS.md").is_dir()
|
||||
|
||||
|
||||
class TestKimiTeardownLegacyCleanup:
|
||||
"""teardown() removes leftover legacy .kimi/skills/ directories."""
|
||||
|
||||
@@ -522,49 +358,6 @@ class TestKimiLegacySymlinkSafety:
|
||||
assert (legacy / "SKILL.md").exists()
|
||||
assert (outside / "SKILL.md").exists()
|
||||
|
||||
def test_context_migration_does_not_write_through_symlinked_agents_md(
|
||||
self, tmp_path
|
||||
):
|
||||
# A sensitive file outside the project that a malicious AGENTS.md
|
||||
# symlink points at. Migration must never overwrite it.
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
secret = outside / "secret.txt"
|
||||
secret.write_text("original secret\n")
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_symlink_or_skip(project / "AGENTS.md", secret)
|
||||
(project / "KIMI.md").write_text("# Notes\n\nKeep this.\n")
|
||||
|
||||
result = _migrate_legacy_kimi_context_file(project)
|
||||
|
||||
# The outside file must not be overwritten through the symlink.
|
||||
assert secret.read_text() == "original secret\n"
|
||||
# KIMI.md is preserved so the user can migrate manually.
|
||||
assert (project / "KIMI.md").is_file()
|
||||
assert result is False
|
||||
|
||||
def test_context_migration_does_not_follow_symlinked_kimi_md(self, tmp_path):
|
||||
# A symlinked KIMI.md (source) must not be followed/consumed.
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
external = outside / "external.md"
|
||||
external.write_text("# external\n")
|
||||
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
_symlink_or_skip(project / "KIMI.md", external)
|
||||
|
||||
result = _migrate_legacy_kimi_context_file(project)
|
||||
|
||||
assert result is False
|
||||
# The external file and the symlink are left intact.
|
||||
assert external.read_text() == "# external\n"
|
||||
assert (project / "KIMI.md").is_symlink()
|
||||
assert not (project / "AGENTS.md").exists()
|
||||
|
||||
|
||||
class TestKimiNextSteps:
|
||||
"""CLI output tests for kimi next-steps display."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestLingmaIntegration(SkillsIntegrationTests):
|
||||
FOLDER = ".lingma/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".lingma/skills"
|
||||
CONTEXT_FILE = ".lingma/rules/specify-rules.md"
|
||||
|
||||
@@ -10,7 +10,6 @@ class TestOmpIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".omp/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".omp/commands"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
def test_build_exec_args_uses_omp_json_mode(self):
|
||||
i = get_integration(self.KEY)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestPiIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".pi/"
|
||||
COMMANDS_SUBDIR = "prompts"
|
||||
REGISTRAR_DIR = ".pi/prompts"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestQodercliIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".qoder/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".qoder/commands"
|
||||
CONTEXT_FILE = "QODER.md"
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestQwenIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".qwen/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".qwen/commands"
|
||||
CONTEXT_FILE = "QWEN.md"
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestRooIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".roo/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".roo/commands"
|
||||
CONTEXT_FILE = ".roo/rules/specify-rules.md"
|
||||
|
||||
@@ -52,7 +52,6 @@ class TestRovodevIntegration:
|
||||
which violates the base mixin's pure-skills assumptions)."""
|
||||
|
||||
KEY = "rovodev"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
# -- ACLI dispatch -----------------------------------------------------
|
||||
|
||||
@@ -218,12 +217,8 @@ class TestRovodevIntegration:
|
||||
# Prompts: exactly the core template set.
|
||||
assert prompt_stems == core_skill_names
|
||||
|
||||
# Skills: core ∪ extension-installed.
|
||||
assert core_skill_names.issubset(skill_names)
|
||||
extension_skills = skill_names - core_skill_names
|
||||
assert extension_skills, (
|
||||
"Expected at least one extension-installed skill (e.g. agent-context)"
|
||||
)
|
||||
# Skills: exactly the core template set (no extension auto-install).
|
||||
assert skill_names == core_skill_names
|
||||
|
||||
# prompts.yml mirrors the prompt files exactly.
|
||||
prompts_manifest = project / ".rovodev" / "prompts.yml"
|
||||
@@ -266,10 +261,6 @@ class TestRovodevIntegration:
|
||||
f"{skill_file} body contains dot-notation /speckit. reference"
|
||||
)
|
||||
|
||||
# The plan skill must reference the agent's context file.
|
||||
plan_content = (skills_dir / "speckit-plan" / "SKILL.md").read_text(encoding="utf-8")
|
||||
assert self.CONTEXT_FILE in plan_content
|
||||
|
||||
# -- Full-CLI init: integration metadata -------------------------------
|
||||
|
||||
def test_init_writes_integration_manifest_and_options(self, rovodev_init_project):
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestShaiIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".shai/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".shai/commands"
|
||||
CONTEXT_FILE = "SHAI.md"
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestTabnineIntegration(TomlIntegrationTests):
|
||||
FOLDER = ".tabnine/agent/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".tabnine/agent/commands"
|
||||
CONTEXT_FILE = "TABNINE.md"
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestTraeIntegration(SkillsIntegrationTests):
|
||||
FOLDER = ".trae/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".trae/skills"
|
||||
CONTEXT_FILE = ".trae/rules/project_rules.md"
|
||||
|
||||
@@ -13,7 +13,6 @@ class TestVibeIntegration(SkillsIntegrationTests):
|
||||
FOLDER = ".vibe/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".vibe/skills"
|
||||
CONTEXT_FILE = "AGENTS.md"
|
||||
|
||||
|
||||
class TestVibeUserInvocable:
|
||||
|
||||
@@ -8,4 +8,3 @@ class TestWindsurfIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".windsurf/"
|
||||
COMMANDS_SUBDIR = "workflows"
|
||||
REGISTRAR_DIR = ".windsurf/workflows"
|
||||
CONTEXT_FILE = ".windsurf/rules/specify-rules.md"
|
||||
|
||||
@@ -8,7 +8,6 @@ class TestZcodeIntegration(SkillsIntegrationTests):
|
||||
FOLDER = ".zcode/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".zcode/skills"
|
||||
CONTEXT_FILE = "ZCODE.md"
|
||||
|
||||
|
||||
class TestZcodeInvocation:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -164,17 +164,12 @@ class TestMultiInstallSafeContracts:
|
||||
|
||||
@pytest.mark.parametrize("key", _multi_install_safe_keys())
|
||||
def test_safe_integrations_have_static_isolated_paths(self, key):
|
||||
integration = INTEGRATION_REGISTRY[key]
|
||||
|
||||
assert _integration_root_dir(key), (
|
||||
f"{key} is declared multi-install safe but has no static root directory"
|
||||
)
|
||||
assert _integration_commands_dir(key), (
|
||||
f"{key} is declared multi-install safe but has no static commands directory"
|
||||
)
|
||||
assert integration.context_file, (
|
||||
f"{key} is declared multi-install safe but has no context file"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_integrations_have_distinct_agent_roots(self, first, second):
|
||||
@@ -192,44 +187,6 @@ class TestMultiInstallSafeContracts:
|
||||
f"{_integration_commands_dir(second)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_integrations_have_distinct_context_files(self, first, second):
|
||||
first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file)
|
||||
second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file)
|
||||
|
||||
assert first_context != second_context, (
|
||||
f"{first} and {second} are declared multi-install safe but share "
|
||||
f"context file {first_context!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_context_files_do_not_overlap_other_agent_roots(self, first, second):
|
||||
first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file)
|
||||
second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file)
|
||||
|
||||
assert not _path_is_inside(first_context, _integration_root_dir(second)), (
|
||||
f"{first} context file {first_context!r} lives under {second} "
|
||||
f"agent root {_integration_root_dir(second)!r}"
|
||||
)
|
||||
assert not _path_is_inside(second_context, _integration_root_dir(first)), (
|
||||
f"{second} context file {second_context!r} lives under {first} "
|
||||
f"agent root {_integration_root_dir(first)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_context_files_do_not_overlap_other_command_dirs(self, first, second):
|
||||
first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file)
|
||||
second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file)
|
||||
|
||||
assert not _path_is_inside(first_context, _integration_commands_dir(second)), (
|
||||
f"{first} context file {first_context!r} lives under {second} "
|
||||
f"commands directory {_integration_commands_dir(second)!r}"
|
||||
)
|
||||
assert not _path_is_inside(second_context, _integration_commands_dir(first)), (
|
||||
f"{second} context file {second_context!r} lives under {first} "
|
||||
f"commands directory {_integration_commands_dir(first)!r}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
|
||||
def test_safe_integrations_have_disjoint_manifests(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user