-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathtest_cli_setup_cmd_remove.py
More file actions
368 lines (293 loc) · 14 KB
/
Copy pathtest_cli_setup_cmd_remove.py
File metadata and controls
368 lines (293 loc) · 14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
"""Unit tests for ``mureo.cli.setup_cmd.remove_skills`` — RED phase (TDD).
These tests pin the symmetric ``remove_skills`` counterpart of
``install_skills``. Per CTO decision #2 in the planner HANDOFF
``feat-web-config-ui-phase1-uninstall.md``: removal MUST be
bundle-driven (allow-list from ``_get_data_path("skills")``), NOT
prefix-based. A prefix-based remove would leave non-prefixed bundle
skills (``daily-check``, ``onboard``, etc.) behind on uninstall.
Acceptance criteria pinned here:
- Removes only directories whose names match the install bundle.
- User-installed skills outside the bundle (e.g. ``my-custom/``) survive.
- Idempotent — second call returns ``(0, dest)``.
- Tolerates missing destination directory.
- Symlinked skill dirs are ``unlink``-ed, not ``rmtree``-ed (the link's
target survives) — mirrors ``_replace_dest`` symlink-aware logic.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------
def _make_skill_dir(parent: Path, name: str, content: str = "skill body") -> Path:
"""Create a directory with a ``SKILL.md`` file inside ``parent``."""
d = parent / name
d.mkdir(parents=True, exist_ok=True)
(d / "SKILL.md").write_text(content, encoding="utf-8")
return d
# Bundle children present in ``mureo/_data/skills/`` (per AGENTS.md
# architecture overview L116-L119). Pinned here so the test does not
# depend on filesystem inspection at runtime.
_BUNDLE_NAMES = (
"_mureo-amazon-ads",
"_mureo-google-ads",
"_mureo-learning",
"_mureo-meta-ads",
"_mureo-shared",
"_mureo-strategy",
"ad-fatigue-check",
"audience-review",
"budget-pacing",
"budget-rebalance",
"competitive-scan",
"creative-generate",
"creative-refresh",
"daily-check",
"experiment",
"goal-review",
"incident-postmortem",
"lead-form-create",
"learn",
"monthly-report",
"onboard",
"rescue",
"search-term-cleanup",
"sync-state",
"tracking-health",
"weekly-report",
)
# ---------------------------------------------------------------------------
# Public surface
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRemoveSkillsBasics:
def test_removes_bundle_children_only(self, tmp_path: Path) -> None:
"""All bundle skill dirs are removed; a user-created custom skill
``my-custom/`` survives (CTO decision #2, acceptance criteria
L128-L130)."""
from mureo.cli.setup_cmd import install_skills, remove_skills
target = tmp_path / "skills"
install_skills(target_dir=target)
# User-installed custom skill — not in the bundle.
custom = _make_skill_dir(target, "my-custom", "user-owned")
assert custom.exists()
count, dest = remove_skills(target_dir=target)
assert dest == target
# Every bundle child is gone.
for name in _BUNDLE_NAMES:
assert not (target / name).exists(), f"bundle child {name} survived"
# User-installed skill still present.
assert (target / "my-custom" / "SKILL.md").read_text(encoding="utf-8") == (
"user-owned"
)
# count == number of bundle skills actually removed.
assert count == len(_BUNDLE_NAMES)
def test_idempotent_when_destination_empty(self, tmp_path: Path) -> None:
"""Calling on an empty target returns ``(0, target)`` without
error (acceptance criteria L131)."""
from mureo.cli.setup_cmd import remove_skills
target = tmp_path / "skills"
target.mkdir()
count, dest = remove_skills(target_dir=target)
assert count == 0
assert dest == target
def test_idempotent_second_call(self, tmp_path: Path) -> None:
"""A second remove on an already-removed state returns ``(0, dest)``."""
from mureo.cli.setup_cmd import install_skills, remove_skills
target = tmp_path / "skills"
install_skills(target_dir=target)
first_count, _ = remove_skills(target_dir=target)
second_count, _ = remove_skills(target_dir=target)
assert first_count == len(_BUNDLE_NAMES)
assert second_count == 0
def test_dest_dir_absent_is_graceful_noop(self, tmp_path: Path) -> None:
"""A missing destination dir returns ``(0, target)`` without raising
(acceptance criteria L131 — "tolerates the destination directory
being absent")."""
from mureo.cli.setup_cmd import remove_skills
target = tmp_path / "absent" # never created
assert not target.exists()
count, dest = remove_skills(target_dir=target)
assert count == 0
assert dest == target
def test_default_path_uses_path_home(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Omitting ``target_dir`` resolves to ``~/.claude/skills``."""
from mureo.cli.setup_cmd import install_skills, remove_skills
monkeypatch.setattr("mureo.cli.setup_cmd.Path.home", lambda: tmp_path)
install_skills()
count, dest = remove_skills()
assert dest == tmp_path / ".claude" / "skills"
assert count == len(_BUNDLE_NAMES)
@pytest.mark.unit
class TestRemoveSkillsSafety:
def test_returns_tuple_of_int_and_path(self, tmp_path: Path) -> None:
"""Return type contract: ``tuple[int, Path]``."""
from mureo.cli.setup_cmd import remove_skills
target = tmp_path / "skills"
target.mkdir()
result = remove_skills(target_dir=target)
assert isinstance(result, tuple)
assert len(result) == 2
count, dest = result
assert isinstance(count, int)
assert isinstance(dest, Path)
def test_preserves_non_skill_dir_with_matching_name(self, tmp_path: Path) -> None:
"""If a bundle-named entry lacks ``SKILL.md`` (a user dir that
happens to share a bundle name), the implementer must still
remove it because the allow-list is bundle-membership, not
SKILL.md presence in the destination. A bundle-named dir is FROM
the install bundle by definition."""
from mureo.cli.setup_cmd import remove_skills
target = tmp_path / "skills"
target.mkdir()
# User-created dir whose name matches a bundle skill but has no SKILL.md.
oddball = target / "onboard"
oddball.mkdir()
(oddball / "stray-file.md").write_text("user-added", encoding="utf-8")
count, _ = remove_skills(target_dir=target)
# Bundle-named dir is removed (it matched the allow-list).
assert not oddball.exists()
assert count == 1
def test_does_not_remove_unrelated_user_dirs(self, tmp_path: Path) -> None:
"""A user dir not in the bundle (and not even shaped like a skill)
survives untouched, including its contents."""
from mureo.cli.setup_cmd import install_skills, remove_skills
target = tmp_path / "skills"
install_skills(target_dir=target)
user_dir = target / "totally-custom"
user_dir.mkdir()
(user_dir / "scratch.txt").write_text("important", encoding="utf-8")
(user_dir / "sub").mkdir()
(user_dir / "sub" / "nested.txt").write_text("nested-data", encoding="utf-8")
remove_skills(target_dir=target)
assert user_dir.exists()
assert (user_dir / "scratch.txt").read_text(encoding="utf-8") == "important"
assert (user_dir / "sub" / "nested.txt").read_text(encoding="utf-8") == (
"nested-data"
)
def test_handles_symlinked_bundle_skill(self, tmp_path: Path) -> None:
"""A symlinked bundle skill dir is ``unlink``-ed (not ``rmtree``-ed);
the link's target survives. Mirrors ``_replace_dest`` (planner
HANDOFF L276-L279)."""
from mureo.cli.setup_cmd import remove_skills
external = tmp_path / "external" / "_mureo-shared"
external.mkdir(parents=True)
(external / "SKILL.md").write_text("dev copy", encoding="utf-8")
(external / "precious.txt").write_text("do not delete", encoding="utf-8")
target = tmp_path / "skills"
target.mkdir()
link = target / "_mureo-shared"
link.symlink_to(external, target_is_directory=True)
count, _ = remove_skills(target_dir=target)
# Symlink itself is gone.
assert not link.is_symlink()
assert not link.exists()
# External target untouched.
assert external.exists()
assert (external / "SKILL.md").read_text(encoding="utf-8") == "dev copy"
assert (external / "precious.txt").read_text(encoding="utf-8") == (
"do not delete"
)
assert count == 1
def test_no_glob_removal(self, tmp_path: Path) -> None:
"""A user-created skill whose name starts with ``_mureo-`` prefix
but is NOT a bundle skill (e.g. ``_mureo-custom``) must NOT be
removed. The remove allow-list is bundle-driven (CTO decision
#2), not prefix-based."""
from mureo.cli.setup_cmd import remove_skills
target = tmp_path / "skills"
target.mkdir()
# User-installed skill that shares the ``_mureo-`` prefix.
user_skill = _make_skill_dir(target, "_mureo-custom", "user content")
count, _ = remove_skills(target_dir=target)
assert user_skill.exists(), "_mureo-custom must NOT be removed"
assert (user_skill / "SKILL.md").read_text(encoding="utf-8") == ("user content")
assert count == 0
def test_count_matches_actually_removed(self, tmp_path: Path) -> None:
"""When only a subset of bundle skills are present, the returned
count == number of dirs actually removed (not total bundle size)."""
from mureo.cli.setup_cmd import remove_skills
target = tmp_path / "skills"
# Only two of the bundle skills are present.
_make_skill_dir(target, "daily-check", "x")
_make_skill_dir(target, "_mureo-shared", "y")
count, _ = remove_skills(target_dir=target)
assert count == 2
assert not (target / "daily-check").exists()
assert not (target / "_mureo-shared").exists()
@pytest.mark.unit
class TestRemoveSkillsAllowListSource:
def test_uses_get_data_path_skills_for_allow_list(self, tmp_path: Path) -> None:
"""The remove allow-list is derived from
``_get_data_path("skills")`` — verify by patching
``_get_data_path`` to return a synthetic bundle dir."""
from mureo.cli.setup_cmd import remove_skills
# Synthetic install-bundle source containing only one skill.
fake_src = tmp_path / "fake_bundle"
_make_skill_dir(fake_src, "only-one", "bundle-shape")
target = tmp_path / "skills"
# Both a "bundle" dir and an unrelated dir are present.
_make_skill_dir(target, "only-one", "installed-bundle")
_make_skill_dir(target, "third-party", "user-installed")
with patch("mureo.cli.setup_cmd._get_data_path", return_value=fake_src):
count, _ = remove_skills(target_dir=target)
# Only the bundle member is removed.
assert not (target / "only-one").exists()
# Non-bundle dir survives.
assert (target / "third-party").exists()
assert (target / "third-party" / "SKILL.md").read_text(encoding="utf-8") == (
"user-installed"
)
assert count == 1
def test_bundle_dir_missing_skill_md_is_skipped(self, tmp_path: Path) -> None:
"""A child in the bundle source dir that lacks ``SKILL.md`` is
NOT part of the allow-list — symmetric with ``install_skills``
which only copies ``is_dir() and (SKILL.md).exists()`` children."""
from mureo.cli.setup_cmd import remove_skills
fake_src = tmp_path / "fake_bundle"
_make_skill_dir(fake_src, "real-skill", "good")
# A bundle-source dir without SKILL.md → not allow-listed.
(fake_src / "no-skill-md").mkdir()
(fake_src / "no-skill-md" / "README.md").write_text(
"no SKILL.md here", encoding="utf-8"
)
target = tmp_path / "skills"
target.mkdir()
# A user-owned dir whose name happens to match the bundle's
# non-allow-listed entry.
no_skill_dir = _make_skill_dir(
target, "no-skill-md", "user-owned-bundle-name-match"
)
bundle_skill = _make_skill_dir(target, "real-skill", "installed")
with patch("mureo.cli.setup_cmd._get_data_path", return_value=fake_src):
count, _ = remove_skills(target_dir=target)
# Only ``real-skill`` is in the allow-list.
assert not bundle_skill.exists()
# ``no-skill-md`` lacks SKILL.md in the bundle source → NOT in the
# allow-list → NOT removed from the destination.
assert no_skill_dir.exists()
assert (no_skill_dir / "SKILL.md").read_text(encoding="utf-8") == (
"user-owned-bundle-name-match"
)
assert count == 1
def test_bundle_source_missing_is_graceful_noop(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""If ``_get_data_path("skills")`` cannot resolve (rare — broken
install), the remove function MUST no-op gracefully (planner
HANDOFF L172 — "bundle dir absent at import resolution →
graceful skip"). Crucially: the destination dir is left alone."""
from mureo.cli.setup_cmd import remove_skills
def _missing(_: str) -> Path:
raise FileNotFoundError("synthetic missing bundle")
monkeypatch.setattr("mureo.cli.setup_cmd._get_data_path", _missing)
target = tmp_path / "skills"
_make_skill_dir(target, "daily-check", "user-orphan")
count, dest = remove_skills(target_dir=target)
assert count == 0
assert dest == target
# Leftover skill is NOT removed (no allow-list available).
assert (target / "daily-check" / "SKILL.md").exists()