Coverage for tests / test_installer.py: 100%
211 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-03 20:19 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-03 20:19 +0000
1from pathlib import Path 1abcdefghi
3import pytest 1abcdefghi
5import library_skills.installer as installer 1abcdefghi
6from library_skills.installer import ( 1abcdefghi
7 CLAUDE_SKILLS_DIR,
8 TOOL_SKILL_KIND,
9 TOOL_SKILL_MARKER,
10 TOOL_SKILL_NAME,
11 UNIVERSAL_SKILLS_DIR,
12 InstallError,
13 ToolSkillError,
14 _get_symlink_target,
15 get_default_install_target_dirs,
16 get_existing_target_dirs,
17 get_target_dirs,
18 get_tool_skill_template,
19 get_tool_skill_version,
20 inspect_tool_skill,
21 install_skill,
22 install_tool_skill,
23 list_installed_skills,
24 uninstall_skill,
25)
26from library_skills.scanner import Skill 1abcdefghi
29def make_skill(tmp_path: Path, name: str = "demo-skill") -> Skill: 1abcdefghi
30 skill_dir = tmp_path / "source" / ".agents" / "skills" / name 1KjZLsyvklMm0NtzwnoOp1PuAxqr
31 skill_dir.mkdir(parents=True) 1KjZLsyvklMm0NtzwnoOp1PuAxqr
32 skill_md = skill_dir / "SKILL.md" 1KjZLsyvklMm0NtzwnoOp1PuAxqr
33 skill_md.write_text( 1KjZLsyvklMm0NtzwnoOp1PuAxqr
34 f"---\nname: {name}\ndescription: Demo skill.\n---\n",
35 encoding="utf-8",
36 )
37 return Skill( 1KjZLsyvklMm0NtzwnoOp1PuAxqr
38 name=name,
39 description="Demo skill.",
40 path=skill_md,
41 package_name="demo-package",
42 package_version="1.0.0",
43 skill_dir=skill_dir,
44 )
47def test_get_target_dirs_always_includes_agents_and_optionally_claude(tmp_path): 1abcdefghi
48 default_targets = get_target_dirs(tmp_path) 2ebfbgbhbibjbkblbmb
49 assert len(default_targets) == 1 2ebfbgbhbibjbkblbmb
50 assert default_targets[0].name == "universal" 2ebfbgbhbibjbkblbmb
51 assert default_targets[0].path == tmp_path / UNIVERSAL_SKILLS_DIR 2ebfbgbhbibjbkblbmb
53 targets = get_target_dirs(tmp_path, include_claude=True) 2ebfbgbhbibjbkblbmb
55 assert [target.name for target in targets] == ["universal", "claude-compatible"] 2ebfbgbhbibjbkblbmb
56 assert targets[0].path == tmp_path / UNIVERSAL_SKILLS_DIR 2ebfbgbhbibjbkblbmb
57 assert targets[1].path == tmp_path / CLAUDE_SKILLS_DIR 2ebfbgbhbibjbkblbmb
60def test_default_install_target_dirs_follow_project_state(tmp_path): 1abcdefghi
61 assert [target.name for target in get_default_install_target_dirs(tmp_path)] == [ 1QRSTUVWXY
62 "universal"
63 ]
65 agents_project = tmp_path / "agents-project" 1QRSTUVWXY
66 (agents_project / ".agents").mkdir(parents=True) 1QRSTUVWXY
67 assert [ 1QRSTUVWXY
68 target.name for target in get_default_install_target_dirs(agents_project)
69 ] == ["universal"]
71 claude_project = tmp_path / "claude-project" 1QRSTUVWXY
72 (claude_project / ".claude").mkdir(parents=True) 1QRSTUVWXY
73 assert [ 1QRSTUVWXY
74 target.name for target in get_default_install_target_dirs(claude_project)
75 ] == ["claude-compatible"]
77 both_project = tmp_path / "both-project" 1QRSTUVWXY
78 (both_project / ".agents").mkdir(parents=True) 1QRSTUVWXY
79 (both_project / ".claude").mkdir(parents=True) 1QRSTUVWXY
80 assert [ 1QRSTUVWXY
81 target.name for target in get_default_install_target_dirs(both_project)
82 ] == [
83 "universal",
84 "claude-compatible",
85 ]
88def test_existing_target_dirs_only_include_concrete_skills_dirs(tmp_path): 1abcdefghi
89 (tmp_path / ".agents").mkdir() 2UbVbWbXbYbZb0b1b2b
90 (tmp_path / ".claude").mkdir() 2UbVbWbXbYbZb0b1b2b
92 assert get_existing_target_dirs(tmp_path) == [] 2UbVbWbXbYbZb0b1b2b
94 (tmp_path / ".claude" / "skills").mkdir() 2UbVbWbXbYbZb0b1b2b
96 assert [target.name for target in get_existing_target_dirs(tmp_path)] == [ 2UbVbWbXbYbZb0b1b2b
97 "claude-compatible"
98 ]
99 assert [ 2UbVbWbXbYbZb0b1b2b
100 target.name
101 for target in get_existing_target_dirs(tmp_path, include_claude=True)
102 ] == ["universal", "claude-compatible"]
105def test_install_skill_creates_symlink_and_list_installed_skills_reports_it(tmp_path): 1abcdefghi
106 skill = make_skill(tmp_path) 1jklmnopqr
107 target_dir = tmp_path / ".agents" / "skills" 1jklmnopqr
109 installed_path = install_skill(skill, target_dir) 1jklmnopqr
111 assert installed_path.is_symlink() 1jklmnopqr
112 assert installed_path.resolve() == skill.skill_dir.resolve() 1jklmnopqr
114 installed = list_installed_skills(target_dir) 1jklmnopqr
115 assert len(installed) == 1 1jklmnopqr
116 assert installed[0].name == "demo-skill" 1jklmnopqr
117 assert installed[0].type == "symlink" 1jklmnopqr
118 assert installed[0].target == skill.skill_dir.resolve() 1jklmnopqr
119 assert installed[0].has_skill_md is True 1jklmnopqr
122def test_install_skill_copy_mode_copies_directory(tmp_path): 1abcdefghi
123 skill = make_skill(tmp_path) 2K 3b4bM 5b6bO 7b8b
124 target_dir = tmp_path / ".agents" / "skills" 2K 3b4bM 5b6bO 7b8b
126 installed_path = install_skill(skill, target_dir, copy=True) 2K 3b4bM 5b6bO 7b8b
128 assert installed_path.is_dir() 2K 3b4bM 5b6bO 7b8b
129 assert not installed_path.is_symlink() 2K 3b4bM 5b6bO 7b8b
130 assert (installed_path / "SKILL.md").is_file() 2K 3b4bM 5b6bO 7b8b
133def test_install_skill_symlink_failure_raises_install_error(tmp_path, monkeypatch): 1abcdefghi
134 skill = make_skill(tmp_path) 2y FbGbz HbIbA JbKb
135 target_dir = tmp_path / ".agents" / "skills" 2y FbGbz HbIbA JbKb
137 def fail_symlink(*args, **kwargs): 2y FbGbz HbIbA JbKb
138 raise OSError("symlink blocked") 2y FbGbz HbIbA JbKb
140 monkeypatch.setattr(Path, "symlink_to", fail_symlink) 2y FbGbz HbIbA JbKb
142 with pytest.raises(InstallError, match="Use --copy"): 2y FbGbz HbIbA JbKb
143 install_skill(skill, target_dir) 2y FbGbz HbIbA JbKb
146def test_install_skill_refuses_to_overwrite_non_symlink_directory(tmp_path): 1abcdefghi
147 skill = make_skill(tmp_path) 2Z fcgc0 hcic1 jckc
148 target_dir = tmp_path / ".agents" / "skills" 2Z fcgc0 hcic1 jckc
149 (target_dir / skill.name).mkdir(parents=True) 2Z fcgc0 hcic1 jckc
151 with pytest.raises(InstallError, match="Cannot overwrite non-symlink directory"): 2Z fcgc0 hcic1 jckc
152 install_skill(skill, target_dir) 2Z fcgc0 hcic1 jckc
155def test_install_skill_refuses_to_overwrite_non_symlink_file(tmp_path): 1abcdefghi
156 skill = make_skill(tmp_path) 2L 9b!bN #b$bP %b'b
157 target_dir = tmp_path / ".agents" / "skills" 2L 9b!bN #b$bP %b'b
158 target_dir.mkdir(parents=True) 2L 9b!bN #b$bP %b'b
159 (target_dir / skill.name).write_text("not managed", encoding="utf-8") 2L 9b!bN #b$bP %b'b
161 with pytest.raises(InstallError, match="Cannot overwrite non-symlink file"): 2L 9b!bN #b$bP %b'b
162 install_skill(skill, target_dir) 2L 9b!bN #b$bP %b'b
165def test_install_skill_replaces_existing_symlink(tmp_path): 1abcdefghi
166 skill = make_skill(tmp_path) 1s23t45u67
167 target_dir = tmp_path / ".agents" / "skills" 1s23t45u67
168 old_target = tmp_path / "old" 1s23t45u67
169 old_target.mkdir() 1s23t45u67
170 target_dir.mkdir(parents=True) 1s23t45u67
171 dest = target_dir / skill.name 1s23t45u67
172 dest.symlink_to(old_target, target_is_directory=True) 1s23t45u67
174 install_skill(skill, target_dir) 1s23t45u67
176 assert dest.is_symlink() 1s23t45u67
177 assert dest.resolve() == skill.skill_dir.resolve() 1s23t45u67
180def test_get_symlink_target_falls_back_to_absolute_on_relpath_error( 1abcdefghi
181 tmp_path,
182 monkeypatch,
183):
184 source = tmp_path / "source" 2nbobpbqbrbsbtbubvb
185 dest = tmp_path / "dest" / "demo-skill" 2nbobpbqbrbsbtbubvb
186 source.mkdir() 2nbobpbqbrbsbtbubvb
187 dest.parent.mkdir() 2nbobpbqbrbsbtbubvb
189 def raise_value_error(_source: Path, *, start: Path) -> str: 2nbobpbqbrbsbtbubvb
190 raise ValueError 2nbobpbqbrbsbtbubvb
192 monkeypatch.setattr("library_skills.installer.os.path.relpath", raise_value_error) 2nbobpbqbrbsbtbubvb
194 assert _get_symlink_target(source=source, dest=dest) == source 2nbobpbqbrbsbtbubvb
197def test_get_symlink_target_preserves_native_relative_targets( 1abcdefghi
198 tmp_path,
199 monkeypatch,
200):
201 source = tmp_path / "node_modules" / "pkg" / ".agents" / "skills" / "demo-skill" 2ucvcwcxcyczcAcBcCc
202 dest = tmp_path / ".agents" / "skills" / "demo-skill" 2ucvcwcxcyczcAcBcCc
204 monkeypatch.setattr( 2ucvcwcxcyczcAcBcCc
205 "library_skills.installer.os.path.relpath",
206 lambda _source, *, start: r"..\..\node_modules\pkg\.agents\skills\demo-skill",
207 )
209 assert _get_symlink_target(source=source, dest=dest) == ( 2ucvcwcxcyczcAcBcCc
210 r"..\..\node_modules\pkg\.agents\skills\demo-skill"
211 )
214def test_list_installed_skills_handles_missing_target_and_directories(tmp_path): 1abcdefghi
215 target_dir = tmp_path / ".agents" / "skills" 1BCDEFGHIJ
217 assert list_installed_skills(target_dir) == [] 1BCDEFGHIJ
219 target_dir.mkdir(parents=True) 1BCDEFGHIJ
220 (target_dir / "ignored.txt").write_text("ignored", encoding="utf-8") 1BCDEFGHIJ
221 hand_authored = target_dir / "hand-authored" 1BCDEFGHIJ
222 hand_authored.mkdir() 1BCDEFGHIJ
224 installed = list_installed_skills(target_dir) 1BCDEFGHIJ
226 assert len(installed) == 1 1BCDEFGHIJ
227 assert installed[0].name == "hand-authored" 1BCDEFGHIJ
228 assert installed[0].type == "directory" 1BCDEFGHIJ
229 assert installed[0].target is None 1BCDEFGHIJ
230 assert installed[0].has_skill_md is False 1BCDEFGHIJ
233def test_list_installed_skills_reports_dangling_symlink(tmp_path): 1abcdefghi
234 target_dir = tmp_path / ".agents" / "skills" 189!#$%'()
235 target_dir.mkdir(parents=True) 189!#$%'()
236 dangling = target_dir / "dangling-skill" 189!#$%'()
237 dangling.symlink_to(tmp_path / "missing-target", target_is_directory=True) 189!#$%'()
239 installed = list_installed_skills(target_dir) 189!#$%'()
241 assert len(installed) == 1 189!#$%'()
242 assert installed[0].name == "dangling-skill" 189!#$%'()
243 assert installed[0].type == "symlink" 189!#$%'()
244 assert installed[0].target == (tmp_path / "missing-target").resolve() 189!#$%'()
245 assert installed[0].has_skill_md is False 189!#$%'()
248def test_uninstall_skill_only_removes_symlinks(tmp_path): 1abcdefghi
249 skill = make_skill(tmp_path) 1v*+w,-x./
250 target_dir = tmp_path / ".agents" / "skills" 1v*+w,-x./
251 installed_path = install_skill(skill, target_dir) 1v*+w,-x./
252 hand_authored = target_dir / "hand-authored" 1v*+w,-x./
253 hand_authored.mkdir() 1v*+w,-x./
255 assert uninstall_skill(skill.name, target_dir) is True 1v*+w,-x./
256 assert not installed_path.exists() 1v*+w,-x./
257 assert uninstall_skill("hand-authored", target_dir) is False 1v*+w,-x./
258 assert hand_authored.is_dir() 1v*+w,-x./
261def test_uninstall_skill_removes_dangling_symlink(tmp_path): 1abcdefghi
262 target_dir = tmp_path / ".agents" / "skills" 2(b)b*b+b,b-b.b/b:b
263 target_dir.mkdir(parents=True) 2(b)b*b+b,b-b.b/b:b
264 dangling = target_dir / "dangling-skill" 2(b)b*b+b,b-b.b/b:b
265 dangling.symlink_to(tmp_path / "missing-target", target_is_directory=True) 2(b)b*b+b,b-b.b/b:b
267 assert uninstall_skill("dangling-skill", target_dir) is True 2(b)b*b+b,b-b.b/b:b
268 assert not dangling.is_symlink() 2(b)b*b+b,b-b.b/b:b
271def test_install_tool_skill_copies_template_and_marker(tmp_path): 1abcdefghi
272 target_dir = tmp_path / ".agents" / "skills" 2wbxbybzbAbBbCbDbEb
274 installed = install_tool_skill(target_dir) 2wbxbybzbAbBbCbDbEb
276 assert installed == target_dir / TOOL_SKILL_NAME 2wbxbybzbAbBbCbDbEb
277 assert installed.is_dir() 2wbxbybzbAbBbCbDbEb
278 assert (installed / "SKILL.md").read_text(encoding="utf-8") == ( 2wbxbybzbAbBbCbDbEb
279 get_tool_skill_template()
280 )
281 marker = (installed / TOOL_SKILL_MARKER).read_text(encoding="utf-8") 2wbxbybzbAbBbCbDbEb
282 assert f'"kind": "{TOOL_SKILL_KIND}"' in marker 2wbxbybzbAbBbCbDbEb
283 assert inspect_tool_skill(target_dir).status == "tool skill: up to date" 2wbxbybzbAbBbCbDbEb
286def test_install_tool_skill_updates_managed_stale_copy(tmp_path): 1abcdefghi
287 target_dir = tmp_path / ".agents" / "skills" 2;b=b?b@b[b]b^b_b`b
288 installed = install_tool_skill(target_dir) 2;b=b?b@b[b]b^b_b`b
289 installed.joinpath("SKILL.md").write_text("stale", encoding="utf-8") 2;b=b?b@b[b]b^b_b`b
291 assert inspect_tool_skill(target_dir).status == "tool skill: stale" 2;b=b?b@b[b]b^b_b`b
293 install_tool_skill(target_dir) 2;b=b?b@b[b]b^b_b`b
295 assert installed.joinpath("SKILL.md").read_text(encoding="utf-8") == ( 2;b=b?b@b[b]b^b_b`b
296 get_tool_skill_template()
297 )
300def test_tool_skill_version_falls_back_when_distribution_is_missing(monkeypatch): 1abcdefghi
301 def missing_version(_distribution_name: str) -> str: 2DcEcFcGcHcIcJcKcLc
302 raise installer.metadata.PackageNotFoundError 2DcEcFcGcHcIcJcKcLc
304 monkeypatch.setattr(installer.metadata, "version", missing_version) 2DcEcFcGcHcIcJcKcLc
306 assert get_tool_skill_version() == "0.0.0" 2DcEcFcGcHcIcJcKcLc
309def test_inspect_tool_skill_reports_file_blocker(tmp_path): 1abcdefghi
310 target_dir = tmp_path / ".agents" / "skills" 2lcmcncocpcqcrcsctc
311 target_dir.mkdir(parents=True) 2lcmcncocpcqcrcsctc
312 (target_dir / TOOL_SKILL_NAME).write_text("not a directory", encoding="utf-8") 2lcmcncocpcqcrcsctc
314 status = inspect_tool_skill(target_dir) 2lcmcncocpcqcrcsctc
316 assert status.status == "tool skill: blocked by hand-authored directory" 2lcmcncocpcqcrcsctc
319def test_inspect_tool_skill_classifies_claude_target(tmp_path): 1abcdefghi
320 target_dir = tmp_path / ".claude" / "skills" 2McNcOcPcQcRcScTcUc
322 status = inspect_tool_skill(target_dir) 2McNcOcPcQcRcScTcUc
324 assert status.target.name == "claude-compatible" 2McNcOcPcQcRcScTcUc
327def test_inspect_tool_skill_reports_stale_when_managed_skill_md_is_missing(tmp_path): 1abcdefghi
328 target_dir = tmp_path / ".agents" / "skills" 2{b|b}b~bacbcccdcec
329 installed = target_dir / TOOL_SKILL_NAME 2{b|b}b~bacbcccdcec
330 installed.mkdir(parents=True) 2{b|b}b~bacbcccdcec
331 installed.joinpath(TOOL_SKILL_MARKER).write_text( 2{b|b}b~bacbcccdcec
332 '{"kind":"tool-skill"}', encoding="utf-8"
333 )
335 status = inspect_tool_skill(target_dir) 2{b|b}b~bacbcccdcec
337 assert status.status == "tool skill: stale" 2{b|b}b~bacbcccdcec
340def test_inspect_tool_skill_reports_stale_when_managed_skill_md_is_invalid_utf8( 1abcdefghi
341 tmp_path,
342):
343 target_dir = tmp_path / ".agents" / "skills" 2LbMbNbObPbQbRbSbTb
344 installed = target_dir / TOOL_SKILL_NAME 2LbMbNbObPbQbRbSbTb
345 installed.mkdir(parents=True) 2LbMbNbObPbQbRbSbTb
346 installed.joinpath(TOOL_SKILL_MARKER).write_text( 2LbMbNbObPbQbRbSbTb
347 '{"kind":"tool-skill"}', encoding="utf-8"
348 )
349 installed.joinpath("SKILL.md").write_bytes(b"\xff") 2LbMbNbObPbQbRbSbTb
351 status = inspect_tool_skill(target_dir) 2LbMbNbObPbQbRbSbTb
353 assert status.status == "tool skill: stale" 2LbMbNbObPbQbRbSbTb
356def test_install_tool_skill_refuses_hand_authored_directory(tmp_path): 1abcdefghi
357 target_dir = tmp_path / ".agents" / "skills" 1:;=?@[]^_
358 hand_authored = target_dir / TOOL_SKILL_NAME 1:;=?@[]^_
359 hand_authored.mkdir(parents=True) 1:;=?@[]^_
360 hand_authored.joinpath("SKILL.md").write_text("mine", encoding="utf-8") 1:;=?@[]^_
362 status = inspect_tool_skill(target_dir) 1:;=?@[]^_
364 assert status.status == "tool skill: blocked by hand-authored directory" 1:;=?@[]^_
365 with pytest.raises(ToolSkillError, match="Cannot overwrite"): 1:;=?@[]^_
366 install_tool_skill(target_dir) 1:;=?@[]^_
367 assert hand_authored.joinpath("SKILL.md").read_text(encoding="utf-8") == "mine" 1:;=?@[]^_
370def test_install_tool_skill_refuses_invalid_marker(tmp_path): 1abcdefghi
371 target_dir = tmp_path / ".agents" / "skills" 2` { | } ~ abbbcbdb
372 installed = target_dir / TOOL_SKILL_NAME 2` { | } ~ abbbcbdb
373 installed.mkdir(parents=True) 2` { | } ~ abbbcbdb
374 installed.joinpath("SKILL.md").write_text("old", encoding="utf-8") 2` { | } ~ abbbcbdb
375 installed.joinpath(TOOL_SKILL_MARKER).write_text( 2` { | } ~ abbbcbdb
376 '{"kind":"other"}', encoding="utf-8"
377 )
379 status = inspect_tool_skill(target_dir) 2` { | } ~ abbbcbdb
381 assert status.status == "tool skill: invalid marker" 2` { | } ~ abbbcbdb
382 with pytest.raises(ToolSkillError, match="Cannot overwrite"): 2` { | } ~ abbbcbdb
383 install_tool_skill(target_dir) 2` { | } ~ abbbcbdb