Repository navigation
Conversation
Preset skill cleanup removed the whole skill directory, so files the user kept next to a generated SKILL.md (for example references/) were deleted by `preset remove` and `integration switch`. Remove only the generated SKILL.md and drop the directory when it is empty. Assisted-by: Codex CLI (model: gpt-6.1-sol, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
There was a problem hiding this comment.
🟡 Changes recommended
Rollback recreates deleted skill directories with default permissions, potentially exposing restored skill files to local users previously blocked by restrictive directory permissions.
1 open issue
What changed in this PR
This PR limits preset skill cleanup to generated files, preserving user content during preset removal and integration switching.
Changes:
- Delete only
SKILL.md, then remove its directory if empty. - Keep generated-file deletions journaled and update cleanup comments.
- Add four regression cases covering both cleanup operations, with and without user files.
| File | Description |
|---|---|
tests/specify_cli/presets/test_command_remove.py |
Tests byte-for-byte preservation of user files and removal of empty skill directories. |
src/specify_cli/presets/_manager_skills.py |
Replaces recursive directory deletion with journaled file deletion and empty-directory cleanup. |
Review effort: Balanced (auto)
Removing only SKILL.md and then the empty directory left the directory removal out of the lifecycle journal, so a rollback recreated it with default permissions. Journal the directory once it is empty, so its mode comes back without snapshotting any user files. Assisted-by: Codex CLI (model: gpt-6.1-sol, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
|
Addressed the Copilot finding in ccf78c7. The empty skill directory is now removed through a new
Full suite on Linux, Python 3.13: 13845 passed, 445 skipped, 1 failed. The failure is the same Fix and test by Codex CLI (model: gpt-6.1-sol, autonomous); reviewed, verified and drafted on behalf of @kartsan03 by Claude Code (model: claude-opus-5-5, autonomous). |
|
Please address Copilot feedback |
Removing SKILL.md by path followed a skill directory that was replaced by a link after validation, so the cleanup could delete a SKILL.md outside the project. Delete it, and the empty directory, relative to directory handles opened without following links below the project (or global skills) root, and keep the journal calls on the same paths. Without dir_fd, re-check each directory below the root before each delete. Assisted-by: Codex CLI (model: gpt-6.1-sol, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
|
Addressed the second finding in 2e1dcf4. Both cleanup sites now call a shared Links above the root are left alone, so a project under a linked directory (macOS The new tests in Full suite on Linux, Python 3.13: 13887 passed, 445 skipped, 1 failed, the same local-only Fix and tests by Codex CLI (model: gpt-6.1-sol, autonomous); reviewed, verified and drafted on behalf of @kartsan03 by Claude Code (model: claude-opus-5-5, autonomous). |
|
Please address Copilot feedback |
unlink_in_directory() caught every OSError and ValueError, which also hid journal failures (IntegrationInstallError is a ValueError) and real delete errors such as PermissionError. Catch only the expected cases at the operation that raises them and let everything else propagate, as it did before this change. Assisted-by: Codex CLI (model: gpt-6.1-sol, autonomous) Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
|
Addressed the third finding in c9bb159.
Full suite on Linux, Python 3.13: 13899 passed, 445 skipped, 1 failed, the same local-only Fix and tests by Codex CLI (model: gpt-6.1-sol, autonomous); reviewed, verified and drafted on behalf of @kartsan03 by Claude Code (model: claude-opus-5-5, autonomous). |
There was a problem hiding this comment.
🟡 Changes recommended
Direct helper and manager tests are misplaced in the command suite and need to move to the integration and manager test modules required by the documented test structure.
2 open issues
2 resolved since last review
Review effort: Max (auto)
|
Please address Copilot feedback |
The four tests that call unlink_in_directory() directly now live in tests/specify_cli/integrations/test_file_changes.py, renamed after the helper, and the global skills root test that calls PresetManager methods moved to test_manager_skills.py. test_command_remove.py keeps the tests that go through the CLI, as design/cli.md asks. No test changed otherwise. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
|
Addressed the fourth finding in d1f2183. The four tests that call The directory-mode thread from the first review still shows as open. It was fixed in ccf78c7, and the later rounds kept the fix. I answered in that thread. Full suite on Linux, Python 3.13: 13899 passed, 445 skipped, 1 failed, the same local-only Test move, checks and this comment by Claude Code (model: claude-opus-5-5, autonomous), on behalf of @kartsan03. |
There was a problem hiding this comment.
🟡 Changes recommended
The directory identity check occurs before journaling, allowing a replacement directory introduced during before_file_change() to be deleted by rmdir without detection.
2 open issues
1 resolved since last review
Review effort: Balanced (auto)
|
Please address Copilot feedback |
The descriptor path compared the open skill directory with the entry in its parent before journaling the removal, then removed the entry by name. If the directory was replaced with another empty directory while the journal ran, rmdir removed the replacement. Compare again right after journaling, as the fallback path already does, and keep the entry when it is no longer the directory that was emptied. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
|
Please address Copilot feedback. If not applicable, please explain why |
… suite The skill cleanup checks ran `preset remove` and `integration switch` from the preset remove suite. Their bodies now live in tests/specify_cli/presets/_skill_cleanup_helpers.py, and each command suite runs them for its own command, as design/cli.md asks. No check changed. Assisted-by: Claude Code (model: claude-opus-5-5, autonomous)
|
Addressed the latest Copilot pass. c87c111 moves the Drafted on behalf of @kartsan03 by Claude Code (model: claude-opus-5-5, autonomous). |
There was a problem hiding this comment.
🔵 Needs a closer look
Human assessment is needed to evaluate a subtle symlink-replacement risk in home-scoped cleanup that could delete an unrelated SKILL.md even on the descriptor-based path.
No open issues 🎉
3 resolved since last review
Review effort: Balanced (auto)



Description
When a preset's skill is cleaned up,
_PresetSkillMethodsremoves the whole skill directory withshutil.rmtree(), although Spec Kit only generated theSKILL.mdin it. Skills can keep extra files next toSKILL.md(references/,scripts/,assets/), so anything the user put there is deleted along with it. Two paths do this:specify preset remove(_unregister_skills_in_dir, when no core or extension template takes the skill back);specify integration switchaway from an agent (_delete_agent_preset_skills).Both now remove only the generated
SKILL.mdand then remove the skill directory only if it is empty. They go through_file_changes.unlink_in_directory(), which keeps the deletions journaled and deletes relative to directory handles below the project root, so a skill directory replaced by a link isn't followed. A directory with nothing else in it is still removed, as before. On a later switch back, the preset'sSKILL.mdis generated again next to the user's files.Repro with the bundled
scaffoldpreset (it overridesspeckit.myext.myextcmd, which becomes thespeckit-myext-myextcmdskill for Copilot):main(a227e38)preset remove my-presetnotes.mddeleted with the whole directorynotes.mdkept,SKILL.mdremovedintegration switch claudenotes.mddeleted with the whole directorynotes.mdkept,SKILL.mdremovedintegration switch copilotSKILL.mdback,notes.mdgoneSKILL.mdback next tonotes.mdExtensionManager._unregister_extension_skillshas the samermtree()for extension skills, and I left it out on purpose. If extension removal kept the user's files, the directory would stay without aSKILL.md. On the nextextension add,_register_extension_skillsskips such a directory as user-owned (test_existing_skill_directory_without_marker_is_not_modified, from #3141), so re-adding the extension would silently drop that skill. How that should work is a design question, so this PR only covers presets, where re-adding regenerates the skill.Testing
uv run specify --helpuv sync && uv run pytestNew test
tests/specify_cli/presets/test_command_remove.py::test_preset_skill_cleanup_preserves_user_files, through the CLI with the bundledscaffoldpreset:preset removeandintegration switch claude, each with and without a user file (references/user.md, BOM and CRLF, compared byte for byte) in the skill directory. With the user file, it must survive unchanged andSKILL.mdmust be gone. Without it, the whole directory must be gone, as onmain. Onmainthe two user-file cases fail and the two cases without a user file pass; all four pass with this change.Full suite on Linux, Python 3.13: 13841 passed, 445 skipped, 1 failed (
uv sync --extra test, thenpytest -n 2, 7 min). The failure,test_version_execution.py::TestWindowsUvToolEnvironment::test_non_windows_uv_tool_dry_run_still_previews_installer, fails the same way onmainhere: Rich wraps the dry-run output, and my checkout's venv path is long enough to be split mid-path. Ruff 0.15.0 (ruff check src tests) passes. I haven't run it on Windows.AI Disclosure
AI disclosure: Codex CLI (model: gpt-6.1-sol, reasoning effort xhigh, autonomous, offline sandbox) found the bug while checking how Spec Kit edits files users own, reproduced it on
mainand wrote the fix and the test. Claude Code (model: claude-opus-5-5, autonomous) narrowed the change to presets after finding the extension re-add problem, reran the repro with the real CLI onmainand the branch, ran the full suite and lint, and drafted this description. The commit carriesAssisted-by:trailers for both.