Skip to content

Commit 1a44a6a

Browse files
fix(presets): skip an unreadable restore source in preset remove (#4020)
* fix(presets): skip an unreadable restore source in `preset remove` `_unregister_skills_in_dir` restores each preset-owned SKILL.md from a core command template or an extension source. Both of those reads were bare `read_text(encoding="utf-8")` calls, so a project-owned override in `.specify/templates/commands/` that exists but cannot be read or decoded raised a raw `UnicodeDecodeError`/`OSError` straight out of `PresetManager.remove()`, which has no handler for it — `specify preset remove` dies with a traceback. Every other failure in this loop degrades with `continue`: an unsafe registry name, a missing skill subdirectory, a foreign owner. Sibling reads of the very same directory are already guarded — `_infer_legacy_skill_ provenance` and `_delete_agent_preset_skills` both wrap their SKILL.md read in `except (OSError, UnicodeDecodeError): continue`, and the read inside `_substitute_core_template` was just given the same boundary in #3961. The two restore reads were the remaining gap. `continue` is the right recovery here rather than falling through: the `else` branch below removes the skill outright, so treating an unreadable source as "no source" would delete a user's skill at exactly the moment its replacement cannot be generated. Skipping leaves the skill in place and keeps it out of the returned `mutated_names`, so callers don't record a restore that never happened. Two regression tests, one per exception arm: a non-UTF-8 core template, and a mocked `PermissionError` so the `OSError` half is also covered under privileged CI where permission bits aren't enforced. Both assert the skill survives untouched and is not reported as mutated. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(presets): warn when a skill keeps preset content after a failed restore Review follow-up on two points. Surface the skipped restore. Skipping is still the correct recovery — the alternative branch deletes the skill — but it was silent, and it is a partial removal: `remove()` goes on to delete the preset directory and the registry entry, while this `SKILL.md` keeps the removed preset's content, and leaving the name out of `mutated_names` also keeps it out of reconciliation, so nothing retries it. Both arms now emit a warning naming the skill, the unreadable source, and the exception, and pointing at the re-run that refreshes it once the file is fixed. `warnings.warn` matches how the surrounding code reports non-fatal degradation (the reconciliation failures in `remove()`/`install_from_directory`, the unreadable core template in `_substitute_core_template` from #3961). Cover the extension arm. A skill backed by an installed extension never reaches the core-template read, so the two branches can regress independently and both prior tests exercised only the core one. `test_unregister_skills_in_dir_unreadable_extension_source_skips` installs an extension whose command file is non-UTF-8 and asserts the skill survives byte-for-byte and is absent from `mutated_names`. Verified it raises the raw `UnicodeDecodeError` against unpatched source. The two existing tests now assert the warning via `pytest.warns` so dropping it fails the suite. pytest tests/test_presets.py -> 583 passed, 2 skipped, 7 failed; the 7 are the pre-existing Windows symlink tests that need elevation, unchanged from main. ruff check passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f0ee1fc commit 1a44a6a

2 files changed

Lines changed: 197 additions & 3 deletions

File tree

‎src/specify_cli/presets/__init__.py‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3268,6 +3268,30 @@ def _delete_agent_preset_skills(
32683268
if source in owned_sources:
32693269
shutil.rmtree(skill_subdir)
32703270

3271+
@staticmethod
3272+
def _warn_unrestored_skill(
3273+
skill_name: str, source_file: Path, exc: BaseException
3274+
) -> None:
3275+
"""Warn that a skill kept preset content because its restore source is unreadable.
3276+
3277+
Skipping the restore is the safe recovery — the alternative branch
3278+
deletes the skill outright — but it is still a partial removal: the
3279+
preset directory and registry entry go away while this ``SKILL.md``
3280+
keeps the removed preset's content, and reconciliation never revisits
3281+
it because the name is left out of ``mutated_names``. Name the skill
3282+
and the source so the condition is actionable instead of silent.
3283+
"""
3284+
import warnings
3285+
3286+
warnings.warn(
3287+
f"Skill '{skill_name}' still contains the removed preset's content: "
3288+
f"its restore source '{source_file}' could not be read "
3289+
f"({exc.__class__.__name__}: {exc}). The skill was left in place "
3290+
f"rather than deleted. Fix or remove that file and re-run "
3291+
f"'specify preset add'/'specify preset remove' to refresh it.",
3292+
stacklevel=2,
3293+
)
3294+
32713295
def _unregister_skills_in_dir(
32723296
self,
32733297
skill_names: List[str],
@@ -3394,8 +3418,20 @@ def _unregister_skills_in_dir(
33943418
core_file = None
33953419

33963420
if core_file:
3397-
# Restore from core template
3398-
content = core_file.read_text(encoding="utf-8")
3421+
# Restore from core template. An unreadable/undecodable
3422+
# source cannot produce restored content, so leave the
3423+
# existing skill untouched rather than leaking a raw
3424+
# OSError/UnicodeDecodeError out of `preset remove` — and
3425+
# rather than falling through to the rmtree below, which
3426+
# would delete a skill precisely when its replacement
3427+
# cannot be generated. Matches the `continue` guards above
3428+
# (unsafe name, missing subdir, foreign owner), which also
3429+
# skip without recording the name as mutated.
3430+
try:
3431+
content = core_file.read_text(encoding="utf-8")
3432+
except (OSError, UnicodeDecodeError) as exc:
3433+
self._warn_unrestored_skill(skill_name, core_file, exc)
3434+
continue
33993435
frontmatter, body = registrar.parse_frontmatter(content)
34003436
if isinstance(selected_ai, str):
34013437
body = registrar.resolve_skill_placeholders(
@@ -3436,7 +3472,16 @@ def _unregister_skills_in_dir(
34363472
continue
34373473

34383474
if extension_restore:
3439-
content = extension_restore["source_file"].read_text(encoding="utf-8")
3475+
# Same boundary as the core-template branch above: an
3476+
# unreadable extension source leaves the skill in place
3477+
# instead of crashing or being deleted.
3478+
try:
3479+
content = extension_restore["source_file"].read_text(encoding="utf-8")
3480+
except (OSError, UnicodeDecodeError) as exc:
3481+
self._warn_unrestored_skill(
3482+
skill_name, extension_restore["source_file"], exc
3483+
)
3484+
continue
34403485
frontmatter, body = registrar.parse_frontmatter(content)
34413486
# Mirror the register-time rewrite (#2101): resolve
34423487
# extension-relative subdir references (agents/,

‎tests/test_presets.py‎

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9452,6 +9452,155 @@ def test_unregister_legacy_fallback_skips_non_owned_skill(
94529452
"---\nname: speckit-specify\n---\n\nuser-owned content\n"
94539453
)
94549454

9455+
def test_unregister_skills_in_dir_unreadable_core_template_skips(
9456+
self, project_dir
9457+
):
9458+
"""An undecodable core template must not crash `preset remove`.
9459+
9460+
Every other failure in the restore loop — an unsafe registry name,
9461+
a missing skill subdirectory, a foreign owner — skips the skill
9462+
with ``continue``. The core-template read was outside that
9463+
boundary, so one non-UTF-8 project-owned override in
9464+
``.specify/templates/commands/`` raised a raw ``UnicodeDecodeError``
9465+
straight out of ``PresetManager.remove()``, which has no handler
9466+
for it. Sibling reads of the very same directory are already
9467+
guarded (``_substitute_core_template``, the provenance reads in
9468+
``_infer_legacy_skill_provenance``).
9469+
"""
9470+
self._write_init_options(project_dir, ai="claude", ai_skills=True)
9471+
skills_dir = project_dir / ".claude" / "skills"
9472+
skill_dir = self._create_skill(
9473+
skills_dir, "speckit-specify", "installed content"
9474+
)
9475+
core_commands = project_dir / ".specify" / "templates" / "commands"
9476+
core_commands.mkdir(parents=True, exist_ok=True)
9477+
(core_commands / "specify.md").write_bytes(
9478+
b"---\ndescription: \xff\xfe not utf-8\n---\n\nCore body\n"
9479+
)
9480+
9481+
manager = PresetManager(project_dir)
9482+
with pytest.warns(UserWarning, match="speckit-specify"):
9483+
mutated = manager._unregister_skills_in_dir(
9484+
["speckit-specify"], skills_dir, "claude"
9485+
)
9486+
9487+
assert mutated == [], (
9488+
"a skill whose restore source could not be read was not "
9489+
"restored, so it must not be reported as mutated"
9490+
)
9491+
assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == (
9492+
"---\nname: speckit-specify\n---\n\ninstalled content\n"
9493+
), (
9494+
"an unreadable core template must leave the skill untouched — "
9495+
"falling through to the rmtree branch would delete it exactly "
9496+
"when its replacement cannot be generated"
9497+
)
9498+
9499+
def test_unregister_skills_in_dir_unreadable_core_template_oserror_skips(
9500+
self, project_dir, monkeypatch
9501+
):
9502+
"""The same boundary must cover ``OSError`` (e.g. permission denied).
9503+
9504+
Mocked rather than chmod-based so the case also holds under
9505+
privileged CI, where permission bits are not enforced.
9506+
"""
9507+
self._write_init_options(project_dir, ai="claude", ai_skills=True)
9508+
skills_dir = project_dir / ".claude" / "skills"
9509+
skill_dir = self._create_skill(
9510+
skills_dir, "speckit-specify", "installed content"
9511+
)
9512+
core_commands = project_dir / ".specify" / "templates" / "commands"
9513+
core_commands.mkdir(parents=True, exist_ok=True)
9514+
core_template = core_commands / "specify.md"
9515+
core_template.write_text(
9516+
"---\ndescription: Core specify\n---\n\nCore body\n",
9517+
encoding="utf-8",
9518+
)
9519+
9520+
original_read_text = Path.read_text
9521+
9522+
def failing_read_text(self_path, *args, **kwargs):
9523+
if self_path == core_template:
9524+
raise PermissionError(13, "Permission denied")
9525+
return original_read_text(self_path, *args, **kwargs)
9526+
9527+
monkeypatch.setattr(Path, "read_text", failing_read_text)
9528+
9529+
manager = PresetManager(project_dir)
9530+
with pytest.warns(UserWarning, match="speckit-specify"):
9531+
mutated = manager._unregister_skills_in_dir(
9532+
["speckit-specify"], skills_dir, "claude"
9533+
)
9534+
9535+
monkeypatch.undo()
9536+
9537+
assert mutated == []
9538+
assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == (
9539+
"---\nname: speckit-specify\n---\n\ninstalled content\n"
9540+
)
9541+
9542+
def test_unregister_skills_in_dir_unreadable_extension_source_skips(
9543+
self, project_dir
9544+
):
9545+
"""The extension-restore arm needs the same boundary as the core arm.
9546+
9547+
The two restore reads are independent branches — a skill backed by an
9548+
installed extension never reaches the core-template read — so this
9549+
half of the guard can regress on its own. An undecodable extension
9550+
command file must warn, leave the skill byte-for-byte intact, and stay
9551+
out of ``mutated_names``.
9552+
"""
9553+
self._write_init_options(project_dir, ai="claude", ai_skills=True)
9554+
skills_dir = project_dir / ".claude" / "skills"
9555+
skill_dir = self._create_skill(
9556+
skills_dir, "speckit-fakeext-cmd", "installed content"
9557+
)
9558+
9559+
extension_dir = project_dir / ".specify" / "extensions" / "fakeext"
9560+
(extension_dir / "commands").mkdir(parents=True, exist_ok=True)
9561+
(extension_dir / "commands" / "cmd.md").write_bytes(
9562+
b"---\ndescription: \xff\xfe not utf-8\n---\n\nExtension body\n"
9563+
)
9564+
extension_manifest = {
9565+
"schema_version": "1.0",
9566+
"extension": {
9567+
"id": "fakeext",
9568+
"name": "Fake Extension",
9569+
"version": "1.0.0",
9570+
"description": "Test",
9571+
},
9572+
"requires": {"speckit_version": ">=0.1.0"},
9573+
"provides": {
9574+
"commands": [
9575+
{
9576+
"name": "speckit.fakeext.cmd",
9577+
"file": "commands/cmd.md",
9578+
"description": "Fake extension command",
9579+
}
9580+
]
9581+
},
9582+
}
9583+
with open(extension_dir / "extension.yml", "w") as f:
9584+
yaml.dump(extension_manifest, f)
9585+
9586+
manager = PresetManager(project_dir)
9587+
with pytest.warns(UserWarning, match="speckit-fakeext-cmd"):
9588+
mutated = manager._unregister_skills_in_dir(
9589+
["speckit-fakeext-cmd"], skills_dir, "claude"
9590+
)
9591+
9592+
assert mutated == [], (
9593+
"a skill whose extension restore source could not be read was "
9594+
"not restored, so it must not be reported as mutated"
9595+
)
9596+
assert (skill_dir / "SKILL.md").read_text(encoding="utf-8") == (
9597+
"---\nname: speckit-fakeext-cmd\n---\n\ninstalled content\n"
9598+
), (
9599+
"an unreadable extension source must leave the skill untouched — "
9600+
"falling through to the rmtree branch would delete it exactly "
9601+
"when its replacement cannot be generated"
9602+
)
9603+
94559604
def test_unregister_skills_in_dir_rejects_absolute_registry_name(
94569605
self, project_dir
94579606
):

0 commit comments

Comments
 (0)