16cfab7724
* feat(presets): resolve constitutions at command time Gate install-time constitution materialization behind the constitution-sync preset while preserving one-time init seeding and authored-file safeguards. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 * fix(presets): emit composed template content Add a machine-readable preset resolve mode backed by PresetResolver.resolve_content and require the constitution command to consume it. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 * fix(presets): unify runtime template composition Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 * fix(presets): secure runtime template resolution Align runtime resolution across script variants, validate registry path components, and honor canonical extension ordering and convention paths. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): align runtime priority semantics Normalize and tie-break preset priorities consistently across script variants, and preserve template bytes when Python materializes generated files. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): stop at effective template base Avoid parsing irrelevant lower layers once resolution reaches a replace base, and decode raw bytes so Python preserves source line endings. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): align extension template resolution Support root-level extension templates across runtime resolvers, fail safely when Bash cannot parse an extension registry, and validate requested templates in every prerequisite output mode. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(presets): resolve dotted command identifiers Route safe dotted names through command resolution, correct traversal coverage, and make Windows CI text decoding explicit. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Avoid orphan feature directories on template errors Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Align malformed preset manifest handling Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Complete runtime resolver parity Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Fail closed on resolver input errors Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Force UTF-8 and full manifest validation Force UTF-8 decoding for registry and manifest reads in the Bash and PowerShell embedded-Python parsers so resolution no longer depends on the process locale, and validate every manifest template entry's required fields, type, and strategy consistent with the canonical PresetManifest. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * Fail closed on empty manifests and corrupt registries Reject manifests missing the provides/templates sections or declaring an empty template list in all three runtime resolvers, matching the canonical PresetManifest which treats those as invalid instead of silently degrading a composing layer to a convention `replace` lookup. Make a corrupt or unreadable extension registry fail closed in Bash, PowerShell, and Python instead of swallowing the error and treating every on-disk extension directory as unregistered-and-enabled, which could activate a disabled extension. Read the preset and extension registries as explicit UTF-8 in the PowerShell resolver so priority/enabled-state decoding no longer depends on the process code page under Windows PowerShell 5.1. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * fix(presets): fail closed when extension registry is not a regular file The Bash and Python resolvers used is_file()/`-f` to gate reading the extension `.registry`, which returns false for a directory or a broken symlink at that path. In those cases the resolvers treated the registry as absent and scanned every on-disk extension directory as unregistered and enabled — a fail-open path. Detect any filesystem entry at the registry path (including broken symlinks) and reject unless it is a readable regular file. PowerShell now rejects a non-leaf entry explicitly for parity. Adds directory- and broken-symlink parity regressions. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * fix(presets): fail closed on corrupt registry in canonical resolver and PowerShell Two remaining fail-open paths for an invalid extension registry: - The canonical PresetResolver enumerated extensions through ExtensionRegistry, whose _load() normalizes a corrupt or unreadable registry to an empty mapping. The directory scan then admitted every on-disk extension directory as unregistered-and-enabled, so a corrupt registry could still supply constitution content at init and through constitution-sync materialization. Add a non-invasive is_corrupt() probe (recovery behavior for install/enable/disable is unchanged) and raise from _get_all_extensions_by_priority() when the registry exists but is invalid. _load() now also recovers from OSError/UnicodeDecodeError so a directory or unreadable registry no longer crashes construction. - The PowerShell resolver gated the registry read with Test-Path, which returns false for a dangling symlink on Windows, letting a broken .registry symlink bypass the guard and enable every on-disk extension. Detect the entry via directory enumeration (which observes a broken symlink) and reject it unless it is a readable regular file. Adds canonical corrupt/directory-registry regressions and extends the broken-symlink parity test to PowerShell. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c * fix(presets): detect dangling registry symlink in ExtensionRegistry.is_corrupt is_corrupt() gated on Path.exists(), which follows symlinks and returns False for a dangling .registry symlink — so the canonical PresetResolver treated it as an absent registry and fell back to scanning every on-disk extension directory as unregistered-and-enabled, reopening the fail-open path this guard closes. Detect lexical existence with os.path.lexists and require a regular file before parsing, so a broken symlink (or directory) is reported corrupt and resolution fails closed. Adds a canonical broken-symlink regression alongside the directory case. Assisted-by: GitHub Copilot (model: claude-opus-4.8, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7dbce70f-80c6-4e14-a30d-78cb358bcb84 Copilot-Session: 3158e06f-95df-4e3a-843f-f159a35aa30c
979 lines
44 KiB
Python
979 lines
44 KiB
Python
"""Tests for CopilotIntegration."""
|
|
|
|
import json
|
|
import os
|
|
|
|
import pytest
|
|
import typer
|
|
import yaml
|
|
|
|
from specify_cli.integrations import get_integration
|
|
from specify_cli.integrations.manifest import IntegrationManifest
|
|
|
|
|
|
class TestCopilotCommandsMode:
|
|
def test_copilot_key_and_config(self):
|
|
copilot = get_integration("copilot")
|
|
assert copilot is not None
|
|
assert copilot.key == "copilot"
|
|
assert copilot.config["folder"] == ".github/"
|
|
assert copilot.config["commands_subdir"] == "agents"
|
|
assert copilot.registrar_config["extension"] == ".agent.md"
|
|
|
|
def test_command_filename_agent_md(self):
|
|
copilot = get_integration("copilot")
|
|
assert copilot.command_filename("plan") == "speckit.plan.agent.md"
|
|
|
|
def test_setup_creates_agent_md_files(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
assert len(created) > 0
|
|
agent_files = [f for f in created if ".agent." in f.name]
|
|
assert len(agent_files) > 0
|
|
for f in agent_files:
|
|
assert f.parent == tmp_path / ".github" / "agents"
|
|
assert f.name.endswith(".agent.md")
|
|
|
|
def test_setup_creates_companion_prompts(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
prompt_files = [f for f in created if f.parent.name == "prompts"]
|
|
assert len(prompt_files) > 0
|
|
for f in prompt_files:
|
|
assert f.name.endswith(".prompt.md")
|
|
content = f.read_text(encoding="utf-8")
|
|
assert content.startswith("---\nagent: speckit.")
|
|
|
|
def test_agent_and_prompt_counts_match(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
agents = [f for f in created if ".agent.md" in f.name]
|
|
prompts = [f for f in created if ".prompt.md" in f.name]
|
|
assert len(agents) == len(prompts)
|
|
|
|
def test_setup_creates_vscode_settings_new(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
assert copilot._vscode_settings_path() is not None
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
settings = tmp_path / ".vscode" / "settings.json"
|
|
assert settings.exists()
|
|
assert settings in created
|
|
assert any("settings.json" in k for k in m.files)
|
|
|
|
def test_setup_merges_existing_vscode_settings(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
vscode_dir = tmp_path / ".vscode"
|
|
vscode_dir.mkdir(parents=True)
|
|
existing = {"editor.fontSize": 14, "custom.setting": True}
|
|
(vscode_dir / "settings.json").write_text(json.dumps(existing, indent=4), encoding="utf-8")
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
settings = tmp_path / ".vscode" / "settings.json"
|
|
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
assert data["editor.fontSize"] == 14
|
|
assert data["custom.setting"] is True
|
|
assert settings not in created
|
|
assert not any("settings.json" in k for k in m.files)
|
|
|
|
def test_setup_preserves_non_utf8_vscode_settings(self, tmp_path, caplog):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
vscode_dir = tmp_path / ".vscode"
|
|
vscode_dir.mkdir(parents=True)
|
|
settings = vscode_dir / "settings.json"
|
|
original = b'{"editor.fontSize": 14}\xff'
|
|
settings.write_bytes(original)
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
|
|
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
|
|
assert settings.read_bytes() == original
|
|
assert "Could not parse" in caplog.text
|
|
|
|
def test_all_created_files_tracked_in_manifest(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
for f in created:
|
|
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
|
assert rel in m.files, f"Created file {rel} not tracked in manifest"
|
|
|
|
def test_install_uninstall_roundtrip(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.install(
|
|
tmp_path, m, parsed_options={"commands": True}
|
|
)
|
|
assert len(created) > 0
|
|
m.save()
|
|
for f in created:
|
|
assert f.exists()
|
|
removed, skipped = copilot.uninstall(tmp_path, m)
|
|
assert len(removed) == len(created)
|
|
assert skipped == []
|
|
|
|
def test_modified_file_survives_uninstall(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.install(
|
|
tmp_path, m, parsed_options={"commands": True}
|
|
)
|
|
m.save()
|
|
modified_file = created[0]
|
|
modified_file.write_text("user modified this", encoding="utf-8")
|
|
removed, skipped = copilot.uninstall(tmp_path, m)
|
|
assert modified_file.exists()
|
|
assert modified_file in skipped
|
|
|
|
def test_directory_structure(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
agents_dir = tmp_path / ".github" / "agents"
|
|
assert agents_dir.is_dir()
|
|
agent_files = sorted(agents_dir.glob("speckit.*.agent.md"))
|
|
assert len(agent_files) == 10
|
|
expected_commands = {
|
|
"analyze", "clarify", "constitution", "converge", "implement",
|
|
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
|
}
|
|
actual_commands = {f.name.removeprefix("speckit.").removesuffix(".agent.md") for f in agent_files}
|
|
assert actual_commands == expected_commands
|
|
|
|
def test_templates_are_processed(self, tmp_path):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
agents_dir = tmp_path / ".github" / "agents"
|
|
for agent_file in agents_dir.glob("speckit.*.agent.md"):
|
|
content = agent_file.read_text(encoding="utf-8")
|
|
assert "{SCRIPT}" not in content, f"{agent_file.name} has unprocessed {{SCRIPT}}"
|
|
assert "__AGENT__" not in content, f"{agent_file.name} has unprocessed __AGENT__"
|
|
assert "{ARGS}" not in content, f"{agent_file.name} has unprocessed {{ARGS}}"
|
|
assert "__SPECKIT_COMMAND_" not in content, f"{agent_file.name} has unprocessed __SPECKIT_COMMAND_*__"
|
|
assert "\nscripts:\n" not in content
|
|
|
|
def test_specify_agent_resolves_active_spec_template(self, tmp_path):
|
|
"""Generated specify agent must not hardcode the core spec template."""
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
|
|
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
|
|
content = specify_file.read_text(encoding="utf-8")
|
|
|
|
assert "specify preset resolve spec-template" in content
|
|
assert "resolved active `spec-template`" in content
|
|
assert "Copy `.specify/templates/spec-template.md`" not in content
|
|
assert "Load `.specify/templates/spec-template.md`" not in content
|
|
|
|
def test_setup_falls_back_to_bundled_command_template_without_preset_override(self, tmp_path):
|
|
"""Copilot should keep using the bundled specify command template when no preset override exists."""
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
|
|
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
|
|
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
|
|
content = specify_file.read_text(encoding="utf-8")
|
|
assert "Create or update the feature specification" in content
|
|
assert "preset override content" not in content
|
|
|
|
def test_setup_uses_preset_command_override_when_present(self, tmp_path):
|
|
"""Copilot should prefer a preset-provided command template over the bundled one."""
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
copilot = CopilotIntegration()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
|
|
preset_dir = tmp_path / ".specify" / "presets" / "demo"
|
|
(preset_dir / "commands").mkdir(parents=True, exist_ok=True)
|
|
(preset_dir / "commands" / "speckit.specify.md").write_text(
|
|
"preset override content\n",
|
|
encoding="utf-8",
|
|
)
|
|
(tmp_path / ".specify" / "presets" / ".registry").write_text(
|
|
'{"schema_version": "1.0", "presets": {"demo": {"version": "1.0.0", "source": "local", "enabled": true, "priority": 10}}}',
|
|
encoding="utf-8",
|
|
)
|
|
|
|
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
|
|
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
|
|
content = specify_file.read_text(encoding="utf-8")
|
|
assert "preset override content" in content
|
|
assert "Create or update the feature specification" not in content
|
|
|
|
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)
|
|
copilot.setup(tmp_path, m, parsed_options={"commands": True})
|
|
plan_file = tmp_path / ".github" / "agents" / "speckit.plan.agent.md"
|
|
assert plan_file.exists()
|
|
content = plan_file.read_text(encoding="utf-8")
|
|
assert "__CONTEXT_FILE__" not in content
|
|
|
|
def test_complete_file_inventory_sh(self, tmp_path):
|
|
"""Every file produced by Copilot commands mode with shell scripts."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
project = tmp_path / "inventory-sh"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(app, [
|
|
"init", "--here", "--integration", "copilot",
|
|
"--integration-options", "--commands", "--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
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.analyze.agent.md",
|
|
".github/agents/speckit.checklist.agent.md",
|
|
".github/agents/speckit.clarify.agent.md",
|
|
".github/agents/speckit.constitution.agent.md",
|
|
".github/agents/speckit.converge.agent.md",
|
|
".github/agents/speckit.implement.agent.md",
|
|
".github/agents/speckit.plan.agent.md",
|
|
".github/agents/speckit.specify.agent.md",
|
|
".github/agents/speckit.tasks.agent.md",
|
|
".github/agents/speckit.taskstoissues.agent.md",
|
|
".github/prompts/speckit.analyze.prompt.md",
|
|
".github/prompts/speckit.checklist.prompt.md",
|
|
".github/prompts/speckit.clarify.prompt.md",
|
|
".github/prompts/speckit.constitution.prompt.md",
|
|
".github/prompts/speckit.converge.prompt.md",
|
|
".github/prompts/speckit.implement.prompt.md",
|
|
".github/prompts/speckit.plan.prompt.md",
|
|
".github/prompts/speckit.specify.prompt.md",
|
|
".github/prompts/speckit.tasks.prompt.md",
|
|
".github/prompts/speckit.taskstoissues.prompt.md",
|
|
".vscode/settings.json",
|
|
".specify/integration.json",
|
|
".specify/init-options.json",
|
|
".specify/integrations/copilot.manifest.json",
|
|
".specify/integrations/speckit.manifest.json",
|
|
".specify/.gitignore",
|
|
".specify/scripts/bash/check-prerequisites.sh",
|
|
".specify/scripts/bash/common.sh",
|
|
".specify/scripts/bash/create-new-feature.sh",
|
|
".specify/scripts/bash/resolve-template.sh",
|
|
".specify/scripts/bash/setup-plan.sh",
|
|
".specify/scripts/bash/setup-tasks.sh",
|
|
".specify/templates/checklist-template.md",
|
|
".specify/templates/constitution-template.md",
|
|
".specify/templates/plan-template.md",
|
|
".specify/templates/spec-template.md",
|
|
".specify/templates/tasks-template.md",
|
|
".specify/memory/.constitution-template.json",
|
|
".specify/memory/constitution.md",
|
|
".specify/workflows/speckit/workflow.yml",
|
|
".specify/workflows/workflow-registry.json",
|
|
])
|
|
assert actual == expected, (
|
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
|
f"Extra: {sorted(set(actual) - set(expected))}"
|
|
)
|
|
|
|
def test_complete_file_inventory_ps(self, tmp_path):
|
|
"""Every file produced by Copilot commands mode with PowerShell scripts."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
project = tmp_path / "inventory-ps"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(app, [
|
|
"init", "--here", "--integration", "copilot",
|
|
"--integration-options", "--commands", "--script", "ps",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
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.analyze.agent.md",
|
|
".github/agents/speckit.checklist.agent.md",
|
|
".github/agents/speckit.clarify.agent.md",
|
|
".github/agents/speckit.constitution.agent.md",
|
|
".github/agents/speckit.converge.agent.md",
|
|
".github/agents/speckit.implement.agent.md",
|
|
".github/agents/speckit.plan.agent.md",
|
|
".github/agents/speckit.specify.agent.md",
|
|
".github/agents/speckit.tasks.agent.md",
|
|
".github/agents/speckit.taskstoissues.agent.md",
|
|
".github/prompts/speckit.analyze.prompt.md",
|
|
".github/prompts/speckit.checklist.prompt.md",
|
|
".github/prompts/speckit.clarify.prompt.md",
|
|
".github/prompts/speckit.constitution.prompt.md",
|
|
".github/prompts/speckit.converge.prompt.md",
|
|
".github/prompts/speckit.implement.prompt.md",
|
|
".github/prompts/speckit.plan.prompt.md",
|
|
".github/prompts/speckit.specify.prompt.md",
|
|
".github/prompts/speckit.tasks.prompt.md",
|
|
".github/prompts/speckit.taskstoissues.prompt.md",
|
|
".vscode/settings.json",
|
|
".specify/integration.json",
|
|
".specify/init-options.json",
|
|
".specify/integrations/copilot.manifest.json",
|
|
".specify/integrations/speckit.manifest.json",
|
|
".specify/.gitignore",
|
|
".specify/scripts/powershell/check-prerequisites.ps1",
|
|
".specify/scripts/powershell/common.ps1",
|
|
".specify/scripts/powershell/create-new-feature.ps1",
|
|
".specify/scripts/powershell/resolve-template.ps1",
|
|
".specify/scripts/powershell/setup-plan.ps1",
|
|
".specify/scripts/powershell/setup-tasks.ps1",
|
|
".specify/templates/checklist-template.md",
|
|
".specify/templates/constitution-template.md",
|
|
".specify/templates/plan-template.md",
|
|
".specify/templates/spec-template.md",
|
|
".specify/templates/tasks-template.md",
|
|
".specify/memory/.constitution-template.json",
|
|
".specify/memory/constitution.md",
|
|
".specify/workflows/speckit/workflow.yml",
|
|
".specify/workflows/workflow-registry.json",
|
|
])
|
|
assert actual == expected, (
|
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
|
f"Extra: {sorted(set(actual) - set(expected))}"
|
|
)
|
|
|
|
class TestCopilotSkillsMode:
|
|
"""Tests for Copilot's default skills mode."""
|
|
|
|
_SKILL_COMMANDS = [
|
|
"analyze", "clarify", "constitution", "converge", "implement",
|
|
"plan", "checklist", "specify", "tasks", "taskstoissues",
|
|
]
|
|
|
|
def _make_copilot(self):
|
|
from specify_cli.integrations.copilot import CopilotIntegration
|
|
return CopilotIntegration()
|
|
|
|
def _setup_skills(self, copilot, tmp_path):
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.setup(tmp_path, m)
|
|
return created, m
|
|
|
|
# -- Options ----------------------------------------------------------
|
|
|
|
def test_options_include_skills_flag(self):
|
|
copilot = get_integration("copilot")
|
|
opts = copilot.options()
|
|
skills_opts = [o for o in opts if o.name == "--skills"]
|
|
assert len(skills_opts) == 1
|
|
assert skills_opts[0].is_flag is True
|
|
assert skills_opts[0].default is False
|
|
|
|
def test_options_include_commands_flag(self):
|
|
copilot = get_integration("copilot")
|
|
commands_opts = [o for o in copilot.options() if o.name == "--commands"]
|
|
assert len(commands_opts) == 1
|
|
assert commands_opts[0].is_flag is True
|
|
assert commands_opts[0].default is False
|
|
|
|
def test_default_is_skills_mode(self):
|
|
copilot = self._make_copilot()
|
|
assert copilot.is_skills_mode() is True
|
|
assert copilot.is_skills_mode({}) is True
|
|
|
|
def test_commands_flag_disables_skills_mode(self):
|
|
copilot = self._make_copilot()
|
|
assert copilot.is_skills_mode({"commands": True}) is False
|
|
|
|
def test_existing_commands_layout_is_preserved(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
agents_dir = tmp_path / ".github" / "agents"
|
|
agents_dir.mkdir(parents=True)
|
|
(agents_dir / "speckit.plan.agent.md").write_text(
|
|
"# plan\n", encoding="utf-8"
|
|
)
|
|
assert copilot.is_skills_mode(project_root=tmp_path) is False
|
|
|
|
def test_setup_preserves_existing_commands_without_stored_options(
|
|
self, tmp_path
|
|
):
|
|
copilot = self._make_copilot()
|
|
agents_dir = tmp_path / ".github" / "agents"
|
|
agents_dir.mkdir(parents=True)
|
|
(agents_dir / "speckit.plan.agent.md").write_text(
|
|
"# old plan\n", encoding="utf-8"
|
|
)
|
|
manifest = IntegrationManifest("copilot", tmp_path)
|
|
|
|
created = copilot.setup(tmp_path, manifest)
|
|
|
|
assert any(path.name.endswith(".agent.md") for path in created)
|
|
assert not (tmp_path / ".github" / "skills").exists()
|
|
assert copilot._skills_mode is False
|
|
|
|
def test_existing_skills_layout_stays_in_skills_mode(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
(tmp_path / ".github" / "skills" / "speckit-plan").mkdir(parents=True)
|
|
assert copilot.is_skills_mode(project_root=tmp_path) is True
|
|
|
|
def test_commands_manifest_wins_over_untracked_skill(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
manifest = IntegrationManifest("copilot", tmp_path)
|
|
copilot.setup(
|
|
tmp_path, manifest, parsed_options={"commands": True}
|
|
)
|
|
manifest.save()
|
|
stale_skill = (
|
|
tmp_path
|
|
/ ".github"
|
|
/ "skills"
|
|
/ "speckit-plan"
|
|
/ "SKILL.md"
|
|
)
|
|
stale_skill.parent.mkdir(parents=True)
|
|
stale_skill.write_text("# user-authored skill\n", encoding="utf-8")
|
|
|
|
assert copilot.is_skills_mode(project_root=tmp_path) is False
|
|
|
|
def test_skills_manifest_wins_over_untracked_command(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
manifest = IntegrationManifest("copilot", tmp_path)
|
|
copilot.setup(tmp_path, manifest)
|
|
manifest.save()
|
|
stale_agent = (
|
|
tmp_path
|
|
/ ".github"
|
|
/ "agents"
|
|
/ "speckit.plan.agent.md"
|
|
)
|
|
stale_agent.parent.mkdir(parents=True)
|
|
stale_agent.write_text("# stale command\n", encoding="utf-8")
|
|
|
|
assert copilot.is_skills_mode(project_root=tmp_path) is True
|
|
|
|
def test_explicit_skills_forces_migration_from_commands(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
agents_dir = tmp_path / ".github" / "agents"
|
|
agents_dir.mkdir(parents=True)
|
|
(agents_dir / "speckit.plan.agent.md").write_text(
|
|
"# plan\n", encoding="utf-8"
|
|
)
|
|
assert (
|
|
copilot.is_skills_mode({"skills": True}, project_root=tmp_path)
|
|
is True
|
|
)
|
|
|
|
def test_skills_and_commands_flags_are_mutually_exclusive(self):
|
|
copilot = self._make_copilot()
|
|
with pytest.raises(typer.Exit):
|
|
copilot.is_skills_mode({"skills": True, "commands": True})
|
|
|
|
def test_cli_rejects_skills_and_commands_together(self, tmp_path):
|
|
from typer.testing import CliRunner
|
|
|
|
from specify_cli import app
|
|
|
|
project = tmp_path / "conflicting-modes"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(
|
|
app,
|
|
[
|
|
"init",
|
|
"--here",
|
|
"--integration",
|
|
"copilot",
|
|
"--integration-options",
|
|
"--skills --commands",
|
|
"--script",
|
|
"sh",
|
|
],
|
|
catch_exceptions=False,
|
|
)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
|
|
assert result.exit_code == 1
|
|
assert "--skills and --commands are mutually exclusive" in result.output
|
|
assert not (project / ".github" / "skills").exists()
|
|
assert not (project / ".github" / "agents").exists()
|
|
|
|
# -- Skills directory structure ---------------------------------------
|
|
|
|
def test_skills_creates_skill_files(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
created, _ = self._setup_skills(copilot, tmp_path)
|
|
assert len(created) > 0
|
|
skill_files = [f for f in created if f.name == "SKILL.md"]
|
|
assert len(skill_files) > 0
|
|
for f in skill_files:
|
|
assert f.exists()
|
|
assert f.parent.name.startswith("speckit-")
|
|
|
|
def test_skills_directory_under_github_skills(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
created, _ = self._setup_skills(copilot, tmp_path)
|
|
skills_dir = tmp_path / ".github" / "skills"
|
|
assert skills_dir.is_dir()
|
|
skill_files = [f for f in created if f.name == "SKILL.md"]
|
|
for f in skill_files:
|
|
assert f.resolve().parent.parent == skills_dir.resolve(), (
|
|
f"{f} is not under {skills_dir}"
|
|
)
|
|
|
|
def test_skills_directory_structure(self, tmp_path):
|
|
"""Each command produces speckit-<name>/SKILL.md."""
|
|
copilot = self._make_copilot()
|
|
created, _ = self._setup_skills(copilot, tmp_path)
|
|
skill_files = [f for f in created if f.name == "SKILL.md"]
|
|
expected_commands = set(self._SKILL_COMMANDS)
|
|
actual_commands = set()
|
|
for f in skill_files:
|
|
skill_dir_name = f.parent.name
|
|
assert skill_dir_name.startswith("speckit-")
|
|
actual_commands.add(skill_dir_name.removeprefix("speckit-"))
|
|
assert actual_commands == expected_commands
|
|
|
|
# -- No companion files in skills mode --------------------------------
|
|
|
|
def test_skills_no_prompt_md_companions(self, tmp_path):
|
|
"""Skills mode must not generate .prompt.md companion files."""
|
|
copilot = self._make_copilot()
|
|
created, _ = self._setup_skills(copilot, tmp_path)
|
|
prompt_files = [f for f in created if f.name.endswith(".prompt.md")]
|
|
assert prompt_files == []
|
|
prompts_dir = tmp_path / ".github" / "prompts"
|
|
if prompts_dir.exists():
|
|
assert list(prompts_dir.iterdir()) == []
|
|
|
|
def test_skills_no_vscode_settings(self, tmp_path):
|
|
"""Skills mode must not create or merge .vscode/settings.json."""
|
|
copilot = self._make_copilot()
|
|
self._setup_skills(copilot, tmp_path)
|
|
settings = tmp_path / ".vscode" / "settings.json"
|
|
assert not settings.exists()
|
|
|
|
def test_skills_no_agent_md_files(self, tmp_path):
|
|
"""Skills mode must not produce .agent.md files."""
|
|
copilot = self._make_copilot()
|
|
created, _ = self._setup_skills(copilot, tmp_path)
|
|
agent_files = [f for f in created if f.name.endswith(".agent.md")]
|
|
assert agent_files == []
|
|
|
|
# -- Frontmatter structure --------------------------------------------
|
|
|
|
def test_skill_frontmatter_structure(self, tmp_path):
|
|
"""SKILL.md must have name, description, compatibility, metadata."""
|
|
copilot = self._make_copilot()
|
|
created, _ = self._setup_skills(copilot, tmp_path)
|
|
skill_files = [f for f in created if f.name == "SKILL.md"]
|
|
for f in skill_files:
|
|
content = f.read_text(encoding="utf-8")
|
|
assert content.startswith("---\n"), f"{f} missing frontmatter"
|
|
parts = content.split("---", 2)
|
|
fm = yaml.safe_load(parts[1])
|
|
assert "name" in fm, f"{f} frontmatter missing 'name'"
|
|
assert "description" in fm, f"{f} frontmatter missing 'description'"
|
|
assert "compatibility" in fm, f"{f} frontmatter missing 'compatibility'"
|
|
assert "metadata" in fm, f"{f} frontmatter missing 'metadata'"
|
|
assert fm["metadata"]["author"] == "github-spec-kit"
|
|
|
|
# -- Copilot-specific post-processing ---------------------------------
|
|
|
|
def test_post_process_skill_content_does_not_inject_mode(self):
|
|
"""post_process_skill_content() must NOT inject mode: — VS Code Copilot does not support it."""
|
|
copilot = self._make_copilot()
|
|
content = (
|
|
"---\n"
|
|
'name: "speckit-plan"\n'
|
|
'description: "Plan workflow"\n'
|
|
"---\n"
|
|
"\nBody content\n"
|
|
)
|
|
updated = copilot.post_process_skill_content(content)
|
|
assert "mode:" not in updated
|
|
|
|
def test_post_process_skill_content_injects_hook_note(self):
|
|
"""post_process_skill_content() should inject shared hook guidance but not mode:."""
|
|
copilot = self._make_copilot()
|
|
content = (
|
|
"---\n"
|
|
'name: "speckit-specify"\n'
|
|
'description: "Specify workflow"\n'
|
|
"---\n"
|
|
"\n- For each executable hook, output the following\n"
|
|
)
|
|
updated = copilot.post_process_skill_content(content)
|
|
assert "replace dots" in updated
|
|
assert "mode:" not in updated
|
|
|
|
def test_post_process_idempotent(self):
|
|
"""post_process_skill_content() must be idempotent."""
|
|
copilot = self._make_copilot()
|
|
content = (
|
|
"---\n"
|
|
'name: "speckit-plan"\n'
|
|
'description: "Plan workflow"\n'
|
|
"---\n"
|
|
"\nBody content\n"
|
|
)
|
|
first = copilot.post_process_skill_content(content)
|
|
second = copilot.post_process_skill_content(first)
|
|
assert first == second
|
|
|
|
def test_skills_do_not_have_mode_in_frontmatter(self, tmp_path):
|
|
"""Generated SKILL.md files must NOT contain mode: — VS Code Copilot does not support it."""
|
|
copilot = self._make_copilot()
|
|
created, _ = self._setup_skills(copilot, tmp_path)
|
|
skill_files = [f for f in created if f.name == "SKILL.md"]
|
|
assert len(skill_files) > 0
|
|
for f in skill_files:
|
|
content = f.read_text(encoding="utf-8")
|
|
parts = content.split("---", 2)
|
|
fm = yaml.safe_load(parts[1])
|
|
assert "mode" not in fm, f"{f} frontmatter must not contain unsupported 'mode' field"
|
|
|
|
def test_skills_hook_sections_explain_dotted_command_conversion(self, tmp_path):
|
|
"""Generated skills with hook sections should include shared hook guidance."""
|
|
copilot = self._make_copilot()
|
|
self._setup_skills(copilot, tmp_path)
|
|
specify_skill = tmp_path / ".github" / "skills" / "speckit-specify" / "SKILL.md"
|
|
content = specify_skill.read_text(encoding="utf-8")
|
|
assert "replace dots" in content
|
|
|
|
# -- Template processing ----------------------------------------------
|
|
|
|
def test_skills_templates_are_processed(self, tmp_path):
|
|
"""Skill body must have placeholders replaced."""
|
|
copilot = self._make_copilot()
|
|
created, _ = self._setup_skills(copilot, tmp_path)
|
|
skill_files = [f for f in created if f.name == "SKILL.md"]
|
|
assert len(skill_files) > 0
|
|
for f in skill_files:
|
|
content = f.read_text(encoding="utf-8")
|
|
assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
|
|
assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
|
|
assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
|
|
assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
|
|
|
|
def test_skills_command_refs_use_hyphen(self, tmp_path):
|
|
"""Copilot skills mode must use /speckit-<name> not /speckit.<name>."""
|
|
copilot = self._make_copilot()
|
|
created, _ = self._setup_skills(copilot, tmp_path)
|
|
skill_files = [f for f in created if f.name == "SKILL.md"]
|
|
assert len(skill_files) > 0
|
|
for f in skill_files:
|
|
content = f.read_text(encoding="utf-8")
|
|
assert "/speckit." not in content, (
|
|
f"{f.name} contains dot-notation /speckit. reference; "
|
|
f"skills mode must use /speckit-<name>"
|
|
)
|
|
|
|
def test_skills_mode_invoke_separator(self):
|
|
"""Copilot effective_invoke_separator should reflect skills mode."""
|
|
copilot = self._make_copilot()
|
|
assert copilot.effective_invoke_separator() == "-"
|
|
assert copilot.effective_invoke_separator({"skills": True}) == "-"
|
|
assert copilot.effective_invoke_separator({"commands": True}) == "."
|
|
|
|
def test_invoke_separator_for_mode_tracks_persisted_state(self):
|
|
"""Regression (review #3415): registration paths (preset/extension
|
|
command refs) must resolve the separator from the persisted ai_skills
|
|
state. A Copilot skills project renders ``/speckit-<cmd>`` (hyphen),
|
|
matching ``build_command_invocation``; commands mode renders
|
|
``/speckit.<cmd>`` (dot).
|
|
"""
|
|
copilot = self._make_copilot()
|
|
assert copilot.invoke_separator_for_mode(True) == "-"
|
|
assert copilot.invoke_separator_for_mode(False) == "."
|
|
|
|
def test_skill_body_has_content(self, tmp_path):
|
|
"""Each SKILL.md body should contain template content."""
|
|
copilot = self._make_copilot()
|
|
created, _ = self._setup_skills(copilot, tmp_path)
|
|
skill_files = [f for f in created if f.name == "SKILL.md"]
|
|
for f in skill_files:
|
|
content = f.read_text(encoding="utf-8")
|
|
parts = content.split("---", 2)
|
|
body = parts[2].strip() if len(parts) >= 3 else ""
|
|
assert len(body) > 0, f"{f} has empty body"
|
|
|
|
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 "__CONTEXT_FILE__" not in content
|
|
|
|
# -- Manifest tracking ------------------------------------------------
|
|
|
|
def test_all_files_tracked_in_manifest(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
created, m = self._setup_skills(copilot, tmp_path)
|
|
for f in created:
|
|
rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
|
|
assert rel in m.files, f"{rel} not tracked in manifest"
|
|
|
|
# -- Install/uninstall roundtrip --------------------------------------
|
|
|
|
def test_install_uninstall_roundtrip(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.install(tmp_path, m)
|
|
assert len(created) > 0
|
|
m.save()
|
|
for f in created:
|
|
assert f.exists()
|
|
removed, skipped = copilot.uninstall(tmp_path, m)
|
|
assert len(removed) == len(created)
|
|
assert skipped == []
|
|
|
|
def test_modified_file_survives_uninstall(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
m = IntegrationManifest("copilot", tmp_path)
|
|
created = copilot.install(tmp_path, m)
|
|
m.save()
|
|
modified_file = created[0]
|
|
modified_file.write_text("user modified this", encoding="utf-8")
|
|
removed, skipped = copilot.uninstall(tmp_path, m)
|
|
assert modified_file.exists()
|
|
assert modified_file in skipped
|
|
|
|
# -- build_command_invocation -----------------------------------------
|
|
|
|
def test_build_command_invocation_skills_mode(self):
|
|
copilot = self._make_copilot()
|
|
copilot._skills_mode = True
|
|
assert copilot.build_command_invocation("speckit.plan") == "/speckit-plan"
|
|
assert copilot.build_command_invocation("plan") == "/speckit-plan"
|
|
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
|
|
|
|
def test_build_command_invocation_skills_extension_command(self):
|
|
copilot = self._make_copilot()
|
|
copilot._skills_mode = True
|
|
assert copilot.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
|
|
assert copilot.build_command_invocation("git.commit") == "/speckit-git-commit"
|
|
|
|
def test_build_command_invocation_default_mode(self):
|
|
copilot = self._make_copilot()
|
|
assert copilot.build_command_invocation("plan", "my args") == "/speckit-plan my args"
|
|
assert copilot.build_command_invocation("plan") == "/speckit-plan"
|
|
|
|
def test_build_command_invocation_commands_mode(self):
|
|
copilot = self._make_copilot()
|
|
copilot._skills_mode = False
|
|
assert copilot.build_command_invocation("plan", "my args") == "my args"
|
|
assert copilot.build_command_invocation("plan") == ""
|
|
|
|
# -- Context section ---------------------------------------------------
|
|
|
|
def test_skills_setup_does_not_write_context_section(self, tmp_path):
|
|
copilot = self._make_copilot()
|
|
self._setup_skills(copilot, tmp_path)
|
|
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 ---------------------------------------------
|
|
|
|
def test_init_defaults_to_skills(self, tmp_path):
|
|
"""specify init --integration copilot scaffolds skills by default."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
project = tmp_path / "copilot-skills"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(app, [
|
|
"init", "--here", "--integration", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
skills_dir = project / ".github" / "skills"
|
|
assert skills_dir.is_dir(), "Skills directory was not created"
|
|
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
|
|
assert plan_skill.exists(), "speckit-plan/SKILL.md not found"
|
|
# Verify no default-mode artifacts
|
|
assert not (project / ".github" / "agents").exists()
|
|
assert not (project / ".github" / "prompts").exists()
|
|
assert not (project / ".vscode" / "settings.json").exists()
|
|
|
|
def test_complete_file_inventory_skills_sh(self, tmp_path):
|
|
"""Every file produced by default Copilot init with shell scripts."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
project = tmp_path / "inventory-skills-sh"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(app, [
|
|
"init", "--here", "--integration", "copilot",
|
|
"--script", "sh",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
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 commands)
|
|
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
|
# Integration metadata
|
|
".specify/init-options.json",
|
|
".specify/integration.json",
|
|
".specify/integrations/copilot.manifest.json",
|
|
".specify/integrations/speckit.manifest.json",
|
|
".specify/.gitignore",
|
|
# Scripts (sh)
|
|
".specify/scripts/bash/check-prerequisites.sh",
|
|
".specify/scripts/bash/common.sh",
|
|
".specify/scripts/bash/create-new-feature.sh",
|
|
".specify/scripts/bash/resolve-template.sh",
|
|
".specify/scripts/bash/setup-plan.sh",
|
|
".specify/scripts/bash/setup-tasks.sh",
|
|
# Templates
|
|
".specify/templates/checklist-template.md",
|
|
".specify/templates/constitution-template.md",
|
|
".specify/templates/plan-template.md",
|
|
".specify/templates/spec-template.md",
|
|
".specify/templates/tasks-template.md",
|
|
".specify/memory/.constitution-template.json",
|
|
".specify/memory/constitution.md",
|
|
# Bundled workflow
|
|
".specify/workflows/speckit/workflow.yml",
|
|
".specify/workflows/workflow-registry.json",
|
|
])
|
|
assert actual == expected, (
|
|
f"Missing: {sorted(set(expected) - set(actual))}\n"
|
|
f"Extra: {sorted(set(actual) - set(expected))}"
|
|
)
|
|
|
|
# -- Singleton leak: _skills_mode must reset --------------------------
|
|
|
|
def test_skills_mode_resets_between_layouts(self, tmp_path):
|
|
"""setup() must reset the singleton mode for each selected layout."""
|
|
copilot = self._make_copilot()
|
|
|
|
# First call: default skills mode
|
|
(tmp_path / "proj1").mkdir()
|
|
m1 = IntegrationManifest("copilot", tmp_path / "proj1")
|
|
copilot.setup(tmp_path / "proj1", m1)
|
|
assert copilot._skills_mode is True
|
|
|
|
# Second call: explicit commands mode
|
|
(tmp_path / "proj2").mkdir()
|
|
m2 = IntegrationManifest("copilot", tmp_path / "proj2")
|
|
copilot.setup(
|
|
tmp_path / "proj2", m2, parsed_options={"commands": True}
|
|
)
|
|
assert copilot._skills_mode is False
|
|
assert copilot.build_command_invocation("plan", "args") == "args"
|
|
|
|
# Third call: a fresh default project must switch back to skills.
|
|
(tmp_path / "proj3").mkdir()
|
|
m3 = IntegrationManifest("copilot", tmp_path / "proj3")
|
|
copilot.setup(tmp_path / "proj3", m3)
|
|
assert copilot._skills_mode is True
|
|
assert copilot.build_command_invocation("plan") == "/speckit-plan"
|
|
|
|
# -- Auto-detection must preserve managed commands --------------------
|
|
|
|
def test_dispatch_preserves_commands_with_unrelated_skills(self, tmp_path):
|
|
"""Unrelated skills must not migrate a managed commands layout."""
|
|
copilot = self._make_copilot()
|
|
agents_dir = tmp_path / ".github" / "agents"
|
|
agents_dir.mkdir(parents=True)
|
|
(agents_dir / "speckit.plan.agent.md").write_text(
|
|
"# plan\n", encoding="utf-8"
|
|
)
|
|
unrelated = tmp_path / ".github" / "skills" / "introduction-to-github"
|
|
unrelated.mkdir(parents=True)
|
|
(unrelated / "README.md").write_text("# GitHub Skills training\n")
|
|
|
|
import unittest.mock as mock
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
|
|
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
|
|
call_args = mock_run.call_args[0][0]
|
|
assert "--agent" in call_args, (
|
|
f"Expected --agent in cli_args but got: {call_args}"
|
|
)
|
|
assert "speckit.plan" in call_args
|
|
|
|
def test_dispatch_detects_speckit_skills_layout(self, tmp_path):
|
|
"""dispatch_command() detects speckit-*/SKILL.md as skills mode."""
|
|
copilot = self._make_copilot()
|
|
skill_dir = tmp_path / ".github" / "skills" / "speckit-plan"
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text("---\nname: speckit-plan\n---\n")
|
|
|
|
import unittest.mock as mock
|
|
with mock.patch("subprocess.run") as mock_run:
|
|
mock_run.return_value = mock.Mock(returncode=0, stdout="", stderr="")
|
|
copilot.dispatch_command("plan", "my args", project_root=tmp_path, stream=False)
|
|
call_args = mock_run.call_args[0][0]
|
|
assert "--agent" not in call_args, (
|
|
f"Skills mode should not use --agent, got: {call_args}"
|
|
)
|
|
prompt = call_args[call_args.index("-p") + 1]
|
|
assert "/speckit-plan" in prompt, (
|
|
f"Skills mode prompt should invoke /speckit-plan, got: {prompt}"
|
|
)
|
|
assert "my args" in prompt, (
|
|
f"Skills mode prompt should preserve user args, got: {prompt}"
|
|
)
|
|
|
|
# -- Next-steps display for Copilot skills mode -----------------------
|
|
|
|
def test_init_skills_next_steps_show_skill_syntax(self, tmp_path):
|
|
"""Default Copilot init shows /speckit-plan, not /speckit.plan."""
|
|
from typer.testing import CliRunner
|
|
from specify_cli import app
|
|
project = tmp_path / "copilot-nextsteps"
|
|
project.mkdir()
|
|
old_cwd = os.getcwd()
|
|
try:
|
|
os.chdir(project)
|
|
result = CliRunner().invoke(app, [
|
|
"init", "--here", "--integration", "copilot",
|
|
], catch_exceptions=False)
|
|
finally:
|
|
os.chdir(old_cwd)
|
|
assert result.exit_code == 0, f"init failed: {result.output}"
|
|
# Skills mode should show /speckit-plan (hyphenated)
|
|
assert "/speckit-plan" in result.output, (
|
|
f"Expected /speckit-plan in next steps but got:\n{result.output}"
|
|
)
|
|
# Must NOT show the dotted /speckit.plan form
|
|
assert "/speckit.plan" not in result.output, (
|
|
f"Should not show /speckit.plan in skills mode:\n{result.output}"
|
|
)
|