Skip to content

Commit 58b3cad

Browse files
fix(extensions): parse SKILL.md on the --- delimiter line during removal (#3634)
* fix(extensions): parse SKILL.md on the --- delimiter line during removal ExtensionManager._unregister_extension_skills verified an installed skill before deleting it by reading metadata.source back from its SKILL.md with a raw split("---", 2). That substring split stops at the first "---" anywhere after the opening delimiter, including one embedded in a command description (e.g. "Separate sections with --- markers"). The frontmatter was then truncated mid-value, metadata.source parsed empty, the skill looked unrelated, and its directory was left orphaned on uninstall. Parse on the "---" delimiter *line* instead, reusing CommandRegistrar. parse_frontmatter (the line-anchored parser from #3590) in both the fast (registry-driven) and fallback (directory-scan) removal paths. Add a regression test that installs an extension whose command description contains "---", removes it, and asserts the skill directory is gone. Fails before the fix (dir orphaned), passes after. * test: cover the fallback scan branch for the --- SKILL.md parse Copilot noted the new regression test only exercised the fast removal path (skills_project keeps ai_skills enabled, so remove() resolves the skills dir directly). Add test_skills_removed_with_dashes_via_fallback_scan, which deletes init-options.json after install so _get_skills_dir() returns None and removal takes the fallback directory-scan branch. That branch re-reads metadata.source with an independently duplicated parser; reverting it to the old substring split now fails this test (dir orphaned) while the fast-path test still passes.
1 parent cce47f6 commit 58b3cad

2 files changed

Lines changed: 135 additions & 22 deletions

File tree

src/specify_cli/extensions/__init__.py

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1330,19 +1330,20 @@ def _unregister_extension_skills(
13301330
if not skill_md.is_file():
13311331
continue
13321332
try:
1333-
import yaml as _yaml
1333+
from ..agents import CommandRegistrar as _Registrar
13341334

13351335
raw = skill_md.read_text(encoding="utf-8")
1336-
source = ""
1337-
if raw.startswith("---"):
1338-
parts = raw.split("---", 2)
1339-
if len(parts) >= 3:
1340-
fm = _yaml.safe_load(parts[1]) or {}
1341-
source = (
1342-
fm.get("metadata", {}).get("source", "")
1343-
if isinstance(fm, dict)
1344-
else ""
1345-
)
1336+
# Parse on the ``---`` delimiter *line*, not any ``---``
1337+
# substring: a description containing ``---`` would trip a
1338+
# raw ``split("---", 2)`` and hide metadata.source, so this
1339+
# extension's own skill would look unrelated and be left
1340+
# orphaned. Mirrors the #3590 parse_frontmatter fix.
1341+
fm, _ = _Registrar.parse_frontmatter(raw)
1342+
source = (
1343+
fm.get("metadata", {}).get("source", "")
1344+
if isinstance(fm, dict)
1345+
else ""
1346+
)
13461347
if source != f"extension:{extension_id}":
13471348
continue
13481349
except (OSError, UnicodeDecodeError, Exception):
@@ -1386,19 +1387,20 @@ def _unregister_extension_skills(
13861387
if not skill_md.is_file():
13871388
continue
13881389
try:
1389-
import yaml as _yaml
1390+
from ..agents import CommandRegistrar as _Registrar
13901391

13911392
raw = skill_md.read_text(encoding="utf-8")
1392-
source = ""
1393-
if raw.startswith("---"):
1394-
parts = raw.split("---", 2)
1395-
if len(parts) >= 3:
1396-
fm = _yaml.safe_load(parts[1]) or {}
1397-
source = (
1398-
fm.get("metadata", {}).get("source", "")
1399-
if isinstance(fm, dict)
1400-
else ""
1401-
)
1393+
# Parse on the ``---`` delimiter *line*, not any ``---``
1394+
# substring: a description containing ``---`` would trip
1395+
# a raw ``split("---", 2)`` and hide metadata.source, so
1396+
# this extension's own skill would look unrelated and be
1397+
# left orphaned. Mirrors the #3590 parse_frontmatter fix.
1398+
fm, _ = _Registrar.parse_frontmatter(raw)
1399+
source = (
1400+
fm.get("metadata", {}).get("source", "")
1401+
if isinstance(fm, dict)
1402+
else ""
1403+
)
14021404
# Only remove skills explicitly created by this extension
14031405
if source != f"extension:{extension_id}":
14041406
continue

tests/test_extension_skills.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,58 @@ def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Pa
163163
return ext_dir
164164

165165

166+
def _create_dashed_description_extension_dir(
167+
temp_dir: Path, ext_id: str = "dash-ext"
168+
) -> Path:
169+
"""Create an extension whose command description contains a ``---`` run.
170+
171+
A ``---`` inside the description survives into the generated SKILL.md
172+
frontmatter and exercises the delimiter-line parsing used when reading
173+
metadata.source back during removal (regression guard for the
174+
split("---", 2) substring bug, mirroring #3590).
175+
"""
176+
ext_dir = temp_dir / ext_id
177+
ext_dir.mkdir()
178+
description = "Separate sections with --- markers"
179+
180+
manifest_data = {
181+
"schema_version": "1.0",
182+
"extension": {
183+
"id": ext_id,
184+
"name": "Dashed Extension",
185+
"version": "1.0.0",
186+
"description": description,
187+
},
188+
"requires": {"speckit_version": ">=0.1.0"},
189+
"provides": {
190+
"commands": [
191+
{
192+
"name": f"speckit.{ext_id}.hello",
193+
"file": "commands/hello.md",
194+
"description": description,
195+
},
196+
]
197+
},
198+
}
199+
200+
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
201+
yaml.safe_dump(manifest_data, f, allow_unicode=True)
202+
203+
commands_dir = ext_dir / "commands"
204+
commands_dir.mkdir()
205+
(commands_dir / "hello.md").write_text(
206+
"---\n"
207+
f'description: "{description}"\n'
208+
"---\n"
209+
"\n"
210+
"# Hello\n"
211+
"\n"
212+
"Body.\n",
213+
encoding="utf-8",
214+
)
215+
return ext_dir
216+
217+
166218
def _can_create_symlink(temp_dir: Path) -> bool:
167219
"""Return True when the current platform/user can create file symlinks."""
168220
target = temp_dir / "symlink-target.txt"
@@ -1658,6 +1710,65 @@ def test_skills_removed_on_extension_remove(self, skills_project, extension_dir)
16581710
assert not (skills_dir / "speckit-test-ext-hello").exists()
16591711
assert not (skills_dir / "speckit-test-ext-world").exists()
16601712

1713+
def test_skills_removed_when_description_contains_dashes(
1714+
self, skills_project, temp_dir
1715+
):
1716+
"""A ``---`` in the command description must not orphan the skill dir.
1717+
1718+
The removal safety check reads metadata.source back from the generated
1719+
SKILL.md. A raw ``split("---", 2)`` stopped at the ``---`` embedded in
1720+
the description, so metadata.source parsed empty, the skill looked
1721+
unrelated, and its directory was left behind. Regression guard for the
1722+
delimiter-line fix (mirrors #3590).
1723+
"""
1724+
project_dir, skills_dir = skills_project
1725+
ext_dir = _create_dashed_description_extension_dir(temp_dir)
1726+
manager = ExtensionManager(project_dir)
1727+
manifest = manager.install_from_directory(
1728+
ext_dir, "0.1.0", register_commands=False
1729+
)
1730+
1731+
skill_dir = skills_dir / "speckit-dash-ext-hello"
1732+
skill_md = skill_dir / "SKILL.md"
1733+
assert skill_md.exists()
1734+
# The dashed description must have survived into the frontmatter.
1735+
assert "--- markers" in skill_md.read_text(encoding="utf-8")
1736+
1737+
result = manager.remove(manifest.id, keep_config=False)
1738+
assert result is True
1739+
1740+
# The extension's own skill must be recognised and removed, not orphaned.
1741+
assert not skill_dir.exists()
1742+
1743+
def test_skills_removed_with_dashes_via_fallback_scan(
1744+
self, skills_project, temp_dir
1745+
):
1746+
"""Same ``---`` guard, but exercised through the fallback scan branch.
1747+
1748+
The fast path resolves the skills dir from init-options; the fallback
1749+
branch scans every candidate agent dir when that resolution returns
1750+
None, and it re-reads metadata.source with an independently duplicated
1751+
parser. Deleting init-options.json after install forces removal down
1752+
the fallback path so a substring-split regression there is caught too.
1753+
"""
1754+
project_dir, skills_dir = skills_project
1755+
ext_dir = _create_dashed_description_extension_dir(temp_dir)
1756+
manager = ExtensionManager(project_dir)
1757+
manifest = manager.install_from_directory(
1758+
ext_dir, "0.1.0", register_commands=False
1759+
)
1760+
1761+
skill_dir = skills_dir / "speckit-dash-ext-hello"
1762+
assert (skill_dir / "SKILL.md").exists()
1763+
1764+
# Drop init-options so _get_skills_dir() returns None and removal takes
1765+
# the fallback directory-scan branch instead of the fast path.
1766+
(project_dir / ".specify" / "init-options.json").unlink()
1767+
1768+
result = manager.remove(manifest.id, keep_config=False)
1769+
assert result is True
1770+
assert not skill_dir.exists()
1771+
16611772
def test_other_skills_preserved_on_remove(self, skills_project, extension_dir):
16621773
"""Non-extension skills should not be affected by extension removal."""
16631774
project_dir, skills_dir = skills_project

0 commit comments

Comments
 (0)