mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
* Replace shell-based context updates with marker-based upsert
Replace ~3500 lines of bash/PowerShell agent context update scripts
with a Python-based approach using <!-- SPECKIT START/END --> markers.
IntegrationBase now manages the agent context file directly:
- upsert_context_section(): creates or updates the marked section at
init/install/switch time with a directive to read the current plan
- remove_context_section(): removes the section at uninstall, deleting
the file only if it becomes empty
- __CONTEXT_FILE__ placeholder in command templates is resolved per
integration so the plan command references the correct agent file
- context_file is persisted in init-options.json for extension access
The plan command template instructs the LLM to update the plan
reference between the markers in the agent context file.
Removed:
- scripts/bash/update-agent-context.sh (857 lines)
- scripts/powershell/update-agent-context.ps1 (515 lines)
- 56 integration wrapper scripts (update-context.sh/.ps1)
- templates/agent-file-template.md
- agent_scripts frontmatter key and {AGENT_SCRIPT} replacement logic
- update-context reference from integration.json
- tests/test_cursor_frontmatter.py (tested deleted scripts)
Added:
- upsert/remove context section methods on IntegrationBase
- __CONTEXT_FILE__ placeholder support in process_template()
- context_file field in init-options.json (init/switch/uninstall)
- Per-integration tests: context file correctness, plan reference,
init-options persistence (78 new context_file tests)
- End-to-end CLI validation across all 28 integrations
* fix: search for end marker after start marker in context section methods
Address Copilot review: content.find(CONTEXT_MARKER_END) searched from
the start of the file rather than after the located start marker. If
the file contained a stray end marker before the start marker, the
wrong slice could be replaced.
Now both upsert_context_section() and remove_context_section() pass
start_idx as the second argument to find() and validate end_idx >
start_idx before performing the replacement.
* fix: address Copilot review feedback on context section handling
1. Fix grammar in _build_context_section() directive text — add commas
for a complete sentence.
2. Resolve __CONTEXT_FILE__ in resolve_skill_placeholders() — skills
generated via extensions/presets for codex/kimi now replace the
placeholder using the context_file value from init-options.json.
3. Handle Cursor .mdc frontmatter — when creating a new .mdc context
file, prepend alwaysApply: true YAML frontmatter so Cursor
auto-loads the rules.
4. Fix empty-file leading newline — when the context file exists but
is empty, write the section directly instead of prepending a blank
line.
* fix: address second round of Copilot review feedback
1. Ensure .mdc frontmatter on existing files — upsert_context_section()
now checks for missing YAML frontmatter on .mdc files during updates
(not just creation), so pre-existing Cursor files get alwaysApply.
2. Guard against context_file=None — use 'or ""' instead of a default
arg so explicit null values in init-options.json don't cause a
TypeError in str.replace().
3. Clean up .mdc files on removal — remove_context_section() treats
files containing only the Speckit-generated frontmatter block as
empty, deleting them rather than leaving orphaned frontmatter.
* fix: address third round of Copilot review feedback
1. CRLF-safe .mdc frontmatter check — use lstrip().startswith('---')
instead of startswith('---\n') so CRLF files don't get duplicate
frontmatter.
2. CRLF-safe .mdc removal check — normalize line endings before
comparing against the sentinel frontmatter string.
3. Call remove_context_section() during integration_uninstall() — the
manifest-only uninstall was leaving the managed SPECKIT markers
behind in the agent context file.
4. Fix stale docstring — remove 'agent_scripts' mention from
test_lean_commands_have_no_scripts().
* fix: address fourth round of Copilot review feedback
1. Remove unused script_type parameter from _write_integration_json()
and all 3 call sites — the parameter was no longer referenced after
the update-context script removal.
2. Fix _build_context_section() docstring — correct example path from
'.specify/plans/plan.md' to 'specs/<feature>/plan.md'.
3. Improve .mdc frontmatter-only detection in remove_context_section()
— use regex to match any YAML frontmatter block (not just the exact
Speckit-generated one), so .mdc files with additional frontmatter
keys are also cleaned up when no body content remains.
* fix: handle corrupted markers and parse .mdc frontmatter robustly
1. Handle partial/corrupted markers in upsert_context_section() —
if only the START marker exists (no END), replace from START
through EOF. If only the END marker exists, replace from BOF
through END. This keeps upsert idempotent even when a user
accidentally deletes one marker.
2. Parse .mdc YAML frontmatter properly — new _ensure_mdc_frontmatter()
helper parses existing frontmatter and ensures alwaysApply: true is
set, rather than just checking for the --- delimiter. Handles
missing frontmatter, existing frontmatter without alwaysApply, and
already-correct frontmatter.
* fix: preserve .mdc frontmatter, add tests, clean up on switch
1. Rewrite _ensure_mdc_frontmatter() with regex — preserves comments,
formatting, and custom keys in existing frontmatter instead of
destructively re-serializing via yaml.safe_dump(). Inserts or
fixes alwaysApply: true in place.
2. Add 6 focused .mdc frontmatter tests to cursor-agent test file:
new file creation, missing frontmatter, preserved custom keys,
wrong alwaysApply value, idempotent upserts, removal cleanup.
3. Call remove_context_section() during integration switch Phase 1 —
prevents stale SPECKIT markers from being left in the old
integration's context file. Also clear context_file from
init-options during the metadata reset.
* fix: remove unused MDC_FRONTMATTER, preserve inline comments, normalize bare CR
1. Remove unused MDC_FRONTMATTER class variable — dead code after
_ensure_mdc_frontmatter() was rewritten with regex.
2. Preserve inline comments when fixing alwaysApply — the regex
substitution now captures trailing '# comment' text and keeps it.
3. Normalize bare CR in upsert_context_section() — match the
behavior of remove_context_section() which already normalizes
both CRLF and bare CR.
4. Clarify .mdc removal comment — 'treat frontmatter-only as empty'
instead of misleading 'strip frontmatter'.
* fix: handle corrupted markers in remove, CRLF-safe end-marker consumption
1. Handle corrupted markers in remove_context_section() — mirror
upsert's behavior: start-only removes start→EOF, end-only removes
BOF→end. Previously bailed out leaving partial markers behind.
2. CRLF-safe end-marker consumption — both upsert and remove now
handle \r\n after the end marker, not just \n. Prevents extra
blank lines at replacement boundaries in CRLF files.
3. Clarify path rule in plan template — distinguish filesystem
operations (absolute paths) from documentation/agent context
references (project-relative paths).
* fix: only remove context section when both markers are well-ordered
remove_context_section() previously treated mismatched markers as
corruption and aggressively removed from BOF→end-marker or
start-marker→EOF, which could delete user-authored content if only
one marker remained. Now it only removes when both START and END
markers exist and are properly ordered, returning False otherwise.
738 lines
28 KiB
Python
738 lines
28 KiB
Python
"""
|
|
Unit tests for extension skill auto-registration.
|
|
|
|
Tests cover:
|
|
- SKILL.md generation when --ai-skills was used during init
|
|
- No skills created when ai_skills not active
|
|
- SKILL.md content correctness
|
|
- Existing user-modified skills not overwritten
|
|
- Skill cleanup on extension removal
|
|
- Registry metadata includes registered_skills
|
|
"""
|
|
|
|
import json
|
|
import pytest
|
|
import tempfile
|
|
import shutil
|
|
import yaml
|
|
from pathlib import Path
|
|
|
|
from specify_cli.extensions import (
|
|
ExtensionManifest,
|
|
ExtensionManager,
|
|
ExtensionError,
|
|
)
|
|
|
|
|
|
# ===== Helpers =====
|
|
|
|
def _create_init_options(project_root: Path, ai: str = "claude", ai_skills: bool = True):
|
|
"""Write a .specify/init-options.json file."""
|
|
opts_dir = project_root / ".specify"
|
|
opts_dir.mkdir(parents=True, exist_ok=True)
|
|
opts_file = opts_dir / "init-options.json"
|
|
opts_file.write_text(json.dumps({
|
|
"ai": ai,
|
|
"ai_skills": ai_skills,
|
|
"script": "sh",
|
|
}))
|
|
|
|
|
|
def _create_skills_dir(project_root: Path, ai: str = "claude") -> Path:
|
|
"""Create and return the expected skills directory for the given agent."""
|
|
# Match the logic in _get_skills_dir() from specify_cli
|
|
from specify_cli import AGENT_CONFIG
|
|
|
|
agent_config = AGENT_CONFIG.get(ai, {})
|
|
agent_folder = agent_config.get("folder", "")
|
|
if agent_folder:
|
|
skills_dir = project_root / agent_folder.rstrip("/") / "skills"
|
|
else:
|
|
skills_dir = project_root / ".agents" / "skills"
|
|
|
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
|
return skills_dir
|
|
|
|
|
|
def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
|
|
"""Create a complete extension directory with manifest and command files."""
|
|
ext_dir = temp_dir / ext_id
|
|
ext_dir.mkdir()
|
|
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": ext_id,
|
|
"name": "Test Extension",
|
|
"version": "1.0.0",
|
|
"description": "A test extension for skill registration",
|
|
},
|
|
"requires": {
|
|
"speckit_version": ">=0.1.0",
|
|
},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": f"speckit.{ext_id}.hello",
|
|
"file": "commands/hello.md",
|
|
"description": "Test hello command",
|
|
},
|
|
{
|
|
"name": f"speckit.{ext_id}.world",
|
|
"file": "commands/world.md",
|
|
"description": "Test world command",
|
|
},
|
|
]
|
|
},
|
|
}
|
|
|
|
with open(ext_dir / "extension.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
commands_dir = ext_dir / "commands"
|
|
commands_dir.mkdir()
|
|
|
|
(commands_dir / "hello.md").write_text(
|
|
"---\n"
|
|
"description: \"Test hello command\"\n"
|
|
"---\n"
|
|
"\n"
|
|
"# Hello Command\n"
|
|
"\n"
|
|
"Run this to say hello.\n"
|
|
"$ARGUMENTS\n"
|
|
)
|
|
|
|
(commands_dir / "world.md").write_text(
|
|
"---\n"
|
|
"description: \"Test world command\"\n"
|
|
"---\n"
|
|
"\n"
|
|
"# World Command\n"
|
|
"\n"
|
|
"Run this to greet the world.\n"
|
|
)
|
|
|
|
return ext_dir
|
|
|
|
|
|
# ===== Fixtures =====
|
|
|
|
@pytest.fixture
|
|
def temp_dir():
|
|
"""Create a temporary directory for tests."""
|
|
tmpdir = tempfile.mkdtemp()
|
|
yield Path(tmpdir)
|
|
shutil.rmtree(tmpdir)
|
|
|
|
|
|
@pytest.fixture
|
|
def project_dir(temp_dir):
|
|
"""Create a mock spec-kit project directory."""
|
|
proj_dir = temp_dir / "project"
|
|
proj_dir.mkdir()
|
|
|
|
# Create .specify directory
|
|
specify_dir = proj_dir / ".specify"
|
|
specify_dir.mkdir()
|
|
|
|
return proj_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def extension_dir(temp_dir):
|
|
"""Create a complete extension directory."""
|
|
return _create_extension_dir(temp_dir)
|
|
|
|
|
|
@pytest.fixture
|
|
def skills_project(project_dir):
|
|
"""Create a project with --ai-skills enabled and skills directory."""
|
|
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
|
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
|
return project_dir, skills_dir
|
|
|
|
|
|
@pytest.fixture
|
|
def no_skills_project(project_dir):
|
|
"""Create a project without --ai-skills."""
|
|
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
|
return project_dir
|
|
|
|
|
|
# ===== ExtensionManager._get_skills_dir Tests =====
|
|
|
|
class TestExtensionManagerGetSkillsDir:
|
|
"""Test _get_skills_dir() on ExtensionManager."""
|
|
|
|
def test_returns_skills_dir_when_active(self, skills_project):
|
|
"""Should return skills dir when ai_skills is true and dir exists."""
|
|
project_dir, skills_dir = skills_project
|
|
manager = ExtensionManager(project_dir)
|
|
result = manager._get_skills_dir()
|
|
assert result == skills_dir
|
|
|
|
def test_returns_none_when_no_ai_skills(self, no_skills_project):
|
|
"""Should return None when ai_skills is false."""
|
|
manager = ExtensionManager(no_skills_project)
|
|
result = manager._get_skills_dir()
|
|
assert result is None
|
|
|
|
def test_returns_none_when_no_init_options(self, project_dir):
|
|
"""Should return None when init-options.json is missing."""
|
|
manager = ExtensionManager(project_dir)
|
|
result = manager._get_skills_dir()
|
|
assert result is None
|
|
|
|
def test_returns_none_when_skills_dir_missing(self, project_dir):
|
|
"""Should return None when skills dir doesn't exist on disk."""
|
|
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
|
# Don't create the skills directory
|
|
manager = ExtensionManager(project_dir)
|
|
result = manager._get_skills_dir()
|
|
assert result is None
|
|
|
|
def test_returns_kimi_skills_dir_when_ai_skills_disabled(self, project_dir):
|
|
"""Kimi should still use its native skills dir when ai_skills is false."""
|
|
_create_init_options(project_dir, ai="kimi", ai_skills=False)
|
|
skills_dir = _create_skills_dir(project_dir, ai="kimi")
|
|
manager = ExtensionManager(project_dir)
|
|
result = manager._get_skills_dir()
|
|
assert result == skills_dir
|
|
|
|
def test_returns_none_for_non_dict_init_options(self, project_dir):
|
|
"""Corrupted-but-parseable init-options should not crash skill-dir lookup."""
|
|
opts_file = project_dir / ".specify" / "init-options.json"
|
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
|
opts_file.write_text("[]")
|
|
_create_skills_dir(project_dir, ai="claude")
|
|
manager = ExtensionManager(project_dir)
|
|
result = manager._get_skills_dir()
|
|
assert result is None
|
|
|
|
|
|
# ===== Extension Skill Registration Tests =====
|
|
|
|
class TestExtensionSkillRegistration:
|
|
"""Test _register_extension_skills() on ExtensionManager."""
|
|
|
|
def test_skills_created_when_ai_skills_active(self, skills_project, extension_dir):
|
|
"""Skills should be created when ai_skills is enabled."""
|
|
project_dir, skills_dir = skills_project
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
# Check that skill directories were created
|
|
skill_dirs = sorted([d.name for d in skills_dir.iterdir() if d.is_dir()])
|
|
assert "speckit-test-ext-hello" in skill_dirs
|
|
assert "speckit-test-ext-world" in skill_dirs
|
|
|
|
def test_skill_md_content_correct(self, skills_project, extension_dir):
|
|
"""SKILL.md should have correct agentskills.io structure."""
|
|
project_dir, skills_dir = skills_project
|
|
manager = ExtensionManager(project_dir)
|
|
manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
|
assert skill_file.exists()
|
|
content = skill_file.read_text()
|
|
|
|
# Check structure
|
|
assert content.startswith("---\n")
|
|
assert "name: speckit-test-ext-hello" in content
|
|
assert "description:" in content
|
|
assert "Test hello command" in content
|
|
assert "source: extension:test-ext" in content
|
|
assert "author: github-spec-kit" in content
|
|
assert "compatibility:" in content
|
|
assert "Run this to say hello." in content
|
|
|
|
def test_skill_md_has_parseable_yaml(self, skills_project, extension_dir):
|
|
"""Generated SKILL.md should contain valid, parseable YAML frontmatter."""
|
|
project_dir, skills_dir = skills_project
|
|
manager = ExtensionManager(project_dir)
|
|
manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
|
content = skill_file.read_text()
|
|
|
|
assert content.startswith("---\n")
|
|
parts = content.split("---", 2)
|
|
assert len(parts) >= 3
|
|
parsed = yaml.safe_load(parts[1])
|
|
assert isinstance(parsed, dict)
|
|
assert parsed["name"] == "speckit-test-ext-hello"
|
|
assert "description" in parsed
|
|
assert parsed["disable-model-invocation"] is False
|
|
|
|
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
|
|
"""No skills should be created when ai_skills is false."""
|
|
manager = ExtensionManager(no_skills_project)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
# Verify registry
|
|
metadata = manager.registry.get(manifest.id)
|
|
assert metadata["registered_skills"] == []
|
|
|
|
def test_no_skills_when_init_options_missing(self, project_dir, extension_dir):
|
|
"""No skills should be created when init-options.json is absent."""
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
metadata = manager.registry.get(manifest.id)
|
|
assert metadata["registered_skills"] == []
|
|
|
|
def test_existing_skill_not_overwritten(self, skills_project, extension_dir):
|
|
"""Pre-existing SKILL.md should not be overwritten."""
|
|
project_dir, skills_dir = skills_project
|
|
|
|
# Pre-create a custom skill
|
|
custom_dir = skills_dir / "speckit-test-ext-hello"
|
|
custom_dir.mkdir(parents=True)
|
|
custom_content = "# My Custom Hello Skill\nUser-modified content\n"
|
|
(custom_dir / "SKILL.md").write_text(custom_content)
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
# Custom skill should be untouched
|
|
assert (custom_dir / "SKILL.md").read_text() == custom_content
|
|
|
|
# But the other skill should still be created
|
|
metadata = manager.registry.get(manifest.id)
|
|
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
|
# The pre-existing one should NOT be in registered_skills (it was skipped)
|
|
assert "speckit-test-ext-hello" not in metadata["registered_skills"]
|
|
|
|
def test_registered_skills_in_registry(self, skills_project, extension_dir):
|
|
"""Registry should contain registered_skills list."""
|
|
project_dir, skills_dir = skills_project
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
metadata = manager.registry.get(manifest.id)
|
|
assert "registered_skills" in metadata
|
|
assert len(metadata["registered_skills"]) == 2
|
|
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
|
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
|
|
|
def test_kimi_uses_hyphenated_skill_names(self, project_dir, temp_dir):
|
|
"""Kimi agent should use the same hyphenated skill names as hooks."""
|
|
_create_init_options(project_dir, ai="kimi", ai_skills=True)
|
|
_create_skills_dir(project_dir, ai="kimi")
|
|
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
ext_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
metadata = manager.registry.get(manifest.id)
|
|
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
|
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
|
|
|
def test_kimi_creates_skills_when_ai_skills_disabled(self, project_dir, temp_dir):
|
|
"""Kimi should still auto-register extension skills in native-skills mode."""
|
|
_create_init_options(project_dir, ai="kimi", ai_skills=False)
|
|
skills_dir = _create_skills_dir(project_dir, ai="kimi")
|
|
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
ext_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
metadata = manager.registry.get(manifest.id)
|
|
assert "speckit-test-ext-hello" in metadata["registered_skills"]
|
|
assert "speckit-test-ext-world" in metadata["registered_skills"]
|
|
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
|
|
|
def test_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
|
|
"""Auto-registered extension skills should resolve script placeholders."""
|
|
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
|
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
|
|
|
ext_dir = temp_dir / "scripted-ext"
|
|
ext_dir.mkdir()
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "scripted-ext",
|
|
"name": "Scripted Extension",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.scripted-ext.plan",
|
|
"file": "commands/plan.md",
|
|
"description": "Scripted plan command",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(ext_dir / "extension.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
(ext_dir / "commands").mkdir()
|
|
(ext_dir / "commands" / "plan.md").write_text(
|
|
"---\n"
|
|
"description: Scripted plan command\n"
|
|
"scripts:\n"
|
|
" sh: ../../scripts/bash/setup-plan.sh --json \"{ARGS}\"\n"
|
|
"---\n\n"
|
|
"Run {SCRIPT}\n"
|
|
"Review templates/checklist.md and memory/constitution.md for __AGENT__.\n"
|
|
)
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
|
|
|
content = (skills_dir / "speckit-scripted-ext-plan" / "SKILL.md").read_text()
|
|
assert "{SCRIPT}" not in content
|
|
assert "{ARGS}" not in content
|
|
assert "__AGENT__" not in content
|
|
assert '.specify/scripts/bash/setup-plan.sh --json "$ARGUMENTS"' in content
|
|
assert ".specify/templates/checklist.md" in content
|
|
assert ".specify/memory/constitution.md" in content
|
|
|
|
def test_missing_command_file_skipped(self, skills_project, temp_dir):
|
|
"""Commands with missing source files should be skipped gracefully."""
|
|
project_dir, skills_dir = skills_project
|
|
|
|
ext_dir = temp_dir / "missing-cmd-ext"
|
|
ext_dir.mkdir()
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "missing-cmd-ext",
|
|
"name": "Missing Cmd Extension",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.missing-cmd-ext.exists",
|
|
"file": "commands/exists.md",
|
|
"description": "Exists",
|
|
},
|
|
{
|
|
"name": "speckit.missing-cmd-ext.ghost",
|
|
"file": "commands/ghost.md",
|
|
"description": "Does not exist",
|
|
},
|
|
]
|
|
},
|
|
}
|
|
with open(ext_dir / "extension.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
(ext_dir / "commands").mkdir()
|
|
(ext_dir / "commands" / "exists.md").write_text(
|
|
"---\ndescription: Exists\n---\n\n# Exists\n\nBody.\n"
|
|
)
|
|
# Intentionally do NOT create ghost.md
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
ext_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
metadata = manager.registry.get(manifest.id)
|
|
assert "speckit-missing-cmd-ext-exists" in metadata["registered_skills"]
|
|
assert "speckit-missing-cmd-ext-ghost" not in metadata["registered_skills"]
|
|
|
|
|
|
# ===== Extension Skill Unregistration Tests =====
|
|
|
|
class TestExtensionSkillUnregistration:
|
|
"""Test _unregister_extension_skills() on ExtensionManager."""
|
|
|
|
def test_skills_removed_on_extension_remove(self, skills_project, extension_dir):
|
|
"""Removing an extension should clean up its skill directories."""
|
|
project_dir, skills_dir = skills_project
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
# Verify skills exist
|
|
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
|
assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
|
|
|
|
# Remove extension
|
|
result = manager.remove(manifest.id, keep_config=False)
|
|
assert result is True
|
|
|
|
# Skills should be gone
|
|
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
|
assert not (skills_dir / "speckit-test-ext-world").exists()
|
|
|
|
def test_other_skills_preserved_on_remove(self, skills_project, extension_dir):
|
|
"""Non-extension skills should not be affected by extension removal."""
|
|
project_dir, skills_dir = skills_project
|
|
|
|
# Pre-create a custom skill
|
|
custom_dir = skills_dir / "my-custom-skill"
|
|
custom_dir.mkdir(parents=True)
|
|
(custom_dir / "SKILL.md").write_text("# My Custom Skill\n")
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
manager.remove(manifest.id, keep_config=False)
|
|
|
|
# Custom skill should still exist
|
|
assert (custom_dir / "SKILL.md").exists()
|
|
assert (custom_dir / "SKILL.md").read_text() == "# My Custom Skill\n"
|
|
|
|
def test_remove_handles_already_deleted_skills(self, skills_project, extension_dir):
|
|
"""Gracefully handle case where skill dirs were already deleted."""
|
|
project_dir, skills_dir = skills_project
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
# Manually delete skill dirs before calling remove
|
|
shutil.rmtree(skills_dir / "speckit-test-ext-hello")
|
|
shutil.rmtree(skills_dir / "speckit-test-ext-world")
|
|
|
|
# Should not raise
|
|
result = manager.remove(manifest.id, keep_config=False)
|
|
assert result is True
|
|
|
|
def test_remove_no_skills_when_not_active(self, no_skills_project, extension_dir):
|
|
"""Removal without active skills should not attempt skill cleanup."""
|
|
manager = ExtensionManager(no_skills_project)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
# Should not raise even though no skills exist
|
|
result = manager.remove(manifest.id, keep_config=False)
|
|
assert result is True
|
|
|
|
|
|
# ===== Command File Without Frontmatter =====
|
|
|
|
class TestExtensionSkillEdgeCases:
|
|
"""Test edge cases in extension skill registration."""
|
|
|
|
def test_install_with_non_dict_init_options_does_not_crash(self, project_dir, extension_dir):
|
|
"""Corrupted init-options payloads should disable skill registration, not crash install."""
|
|
opts_file = project_dir / ".specify" / "init-options.json"
|
|
opts_file.parent.mkdir(parents=True, exist_ok=True)
|
|
opts_file.write_text("[]")
|
|
_create_skills_dir(project_dir, ai="claude")
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
metadata = manager.registry.get(manifest.id)
|
|
assert metadata["registered_skills"] == []
|
|
|
|
def test_command_without_frontmatter(self, skills_project, temp_dir):
|
|
"""Commands without YAML frontmatter should still produce valid skills."""
|
|
project_dir, skills_dir = skills_project
|
|
|
|
ext_dir = temp_dir / "nofm-ext"
|
|
ext_dir.mkdir()
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "nofm-ext",
|
|
"name": "No Frontmatter Extension",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.nofm-ext.plain",
|
|
"file": "commands/plain.md",
|
|
"description": "Plain command",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(ext_dir / "extension.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
(ext_dir / "commands").mkdir()
|
|
(ext_dir / "commands" / "plain.md").write_text(
|
|
"# Plain Command\n\nBody without frontmatter.\n"
|
|
)
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
ext_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
skill_file = skills_dir / "speckit-nofm-ext-plain" / "SKILL.md"
|
|
assert skill_file.exists()
|
|
content = skill_file.read_text()
|
|
assert "name: speckit-nofm-ext-plain" in content
|
|
# Fallback description when no frontmatter description
|
|
assert "Extension command: speckit.nofm-ext.plain" in content
|
|
assert "Body without frontmatter." in content
|
|
|
|
def test_gemini_agent_skills(self, project_dir, temp_dir):
|
|
"""Gemini agent should use .gemini/skills/ for skill directory."""
|
|
_create_init_options(project_dir, ai="gemini", ai_skills=True)
|
|
_create_skills_dir(project_dir, ai="gemini")
|
|
ext_dir = _create_extension_dir(temp_dir, ext_id="test-ext")
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
ext_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
skills_dir = project_dir / ".gemini" / "skills"
|
|
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
|
assert (skills_dir / "speckit-test-ext-world" / "SKILL.md").exists()
|
|
|
|
def test_multiple_extensions_independent_skills(self, skills_project, temp_dir):
|
|
"""Installing and removing different extensions should be independent."""
|
|
project_dir, skills_dir = skills_project
|
|
|
|
ext_dir_a = _create_extension_dir(temp_dir, ext_id="ext-a")
|
|
ext_dir_b = _create_extension_dir(temp_dir, ext_id="ext-b")
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
manifest_a = manager.install_from_directory(
|
|
ext_dir_a, "0.1.0", register_commands=False
|
|
)
|
|
manifest_b = manager.install_from_directory(
|
|
ext_dir_b, "0.1.0", register_commands=False
|
|
)
|
|
|
|
# Both should have skills
|
|
assert (skills_dir / "speckit-ext-a-hello" / "SKILL.md").exists()
|
|
assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
|
|
|
|
# Remove ext-a
|
|
manager.remove("ext-a", keep_config=False)
|
|
|
|
# ext-a skills gone, ext-b skills preserved
|
|
assert not (skills_dir / "speckit-ext-a-hello").exists()
|
|
assert (skills_dir / "speckit-ext-b-hello" / "SKILL.md").exists()
|
|
|
|
def test_malformed_frontmatter_handled(self, skills_project, temp_dir):
|
|
"""Commands with invalid YAML frontmatter should still produce valid skills."""
|
|
project_dir, skills_dir = skills_project
|
|
|
|
ext_dir = temp_dir / "badfm-ext"
|
|
ext_dir.mkdir()
|
|
manifest_data = {
|
|
"schema_version": "1.0",
|
|
"extension": {
|
|
"id": "badfm-ext",
|
|
"name": "Bad Frontmatter Extension",
|
|
"version": "1.0.0",
|
|
"description": "Test",
|
|
},
|
|
"requires": {"speckit_version": ">=0.1.0"},
|
|
"provides": {
|
|
"commands": [
|
|
{
|
|
"name": "speckit.badfm-ext.broken",
|
|
"file": "commands/broken.md",
|
|
"description": "Broken frontmatter",
|
|
}
|
|
]
|
|
},
|
|
}
|
|
with open(ext_dir / "extension.yml", "w") as f:
|
|
yaml.dump(manifest_data, f)
|
|
|
|
(ext_dir / "commands").mkdir()
|
|
# Malformed YAML: invalid key-value syntax
|
|
(ext_dir / "commands" / "broken.md").write_text(
|
|
"---\n"
|
|
"description: [invalid yaml\n"
|
|
" unclosed: bracket\n"
|
|
"---\n"
|
|
"\n"
|
|
"# Broken Command\n"
|
|
"\n"
|
|
"This body should still be used.\n"
|
|
)
|
|
|
|
manager = ExtensionManager(project_dir)
|
|
# Should not raise
|
|
manifest = manager.install_from_directory(
|
|
ext_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
skill_file = skills_dir / "speckit-badfm-ext-broken" / "SKILL.md"
|
|
assert skill_file.exists()
|
|
content = skill_file.read_text()
|
|
# Fallback description since frontmatter was invalid
|
|
assert "Extension command: speckit.badfm-ext.broken" in content
|
|
assert "This body should still be used." in content
|
|
|
|
def test_remove_cleans_up_when_init_options_deleted(self, skills_project, extension_dir):
|
|
"""Skills should be cleaned up even if init-options.json is deleted after install."""
|
|
project_dir, skills_dir = skills_project
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
# Verify skills exist
|
|
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
|
|
|
# Delete init-options.json to simulate user change
|
|
init_opts = project_dir / ".specify" / "init-options.json"
|
|
init_opts.unlink()
|
|
|
|
# Remove should still clean up via fallback scan
|
|
result = manager.remove(manifest.id, keep_config=False)
|
|
assert result is True
|
|
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
|
assert not (skills_dir / "speckit-test-ext-world").exists()
|
|
|
|
def test_remove_cleans_up_when_ai_skills_toggled(self, skills_project, extension_dir):
|
|
"""Skills should be cleaned up even if ai_skills is toggled to false after install."""
|
|
project_dir, skills_dir = skills_project
|
|
manager = ExtensionManager(project_dir)
|
|
manifest = manager.install_from_directory(
|
|
extension_dir, "0.1.0", register_commands=False
|
|
)
|
|
|
|
# Verify skills exist
|
|
assert (skills_dir / "speckit-test-ext-hello" / "SKILL.md").exists()
|
|
|
|
# Toggle ai_skills to false
|
|
_create_init_options(project_dir, ai="claude", ai_skills=False)
|
|
|
|
# Remove should still clean up via fallback scan
|
|
result = manager.remove(manifest.id, keep_config=False)
|
|
assert result is True
|
|
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
|
assert not (skills_dir / "speckit-test-ext-world").exists()
|