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

1from pathlib import Path 1abcdefghi

2 

3import pytest 1abcdefghi

4 

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

27 

28 

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 ) 

45 

46 

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

52 

53 targets = get_target_dirs(tmp_path, include_claude=True) 2ebfbgbhbibjbkblbmb

54 

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

58 

59 

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 ] 

64 

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"] 

70 

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"] 

76 

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 ] 

86 

87 

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

91 

92 assert get_existing_target_dirs(tmp_path) == [] 2UbVbWbXbYbZb0b1b2b

93 

94 (tmp_path / ".claude" / "skills").mkdir() 2UbVbWbXbYbZb0b1b2b

95 

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"] 

103 

104 

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

108 

109 installed_path = install_skill(skill, target_dir) 1jklmnopqr

110 

111 assert installed_path.is_symlink() 1jklmnopqr

112 assert installed_path.resolve() == skill.skill_dir.resolve() 1jklmnopqr

113 

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

120 

121 

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

125 

126 installed_path = install_skill(skill, target_dir, copy=True) 2K 3b4bM 5b6bO 7b8b

127 

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

131 

132 

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

136 

137 def fail_symlink(*args, **kwargs): 2y FbGbz HbIbA JbKb

138 raise OSError("symlink blocked") 2y FbGbz HbIbA JbKb

139 

140 monkeypatch.setattr(Path, "symlink_to", fail_symlink) 2y FbGbz HbIbA JbKb

141 

142 with pytest.raises(InstallError, match="Use --copy"): 2y FbGbz HbIbA JbKb

143 install_skill(skill, target_dir) 2y FbGbz HbIbA JbKb

144 

145 

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

150 

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

153 

154 

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

160 

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

163 

164 

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

173 

174 install_skill(skill, target_dir) 1s23t45u67

175 

176 assert dest.is_symlink() 1s23t45u67

177 assert dest.resolve() == skill.skill_dir.resolve() 1s23t45u67

178 

179 

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

188 

189 def raise_value_error(_source: Path, *, start: Path) -> str: 2nbobpbqbrbsbtbubvb

190 raise ValueError 2nbobpbqbrbsbtbubvb

191 

192 monkeypatch.setattr("library_skills.installer.os.path.relpath", raise_value_error) 2nbobpbqbrbsbtbubvb

193 

194 assert _get_symlink_target(source=source, dest=dest) == source 2nbobpbqbrbsbtbubvb

195 

196 

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

203 

204 monkeypatch.setattr( 2ucvcwcxcyczcAcBcCc

205 "library_skills.installer.os.path.relpath", 

206 lambda _source, *, start: r"..\..\node_modules\pkg\.agents\skills\demo-skill", 

207 ) 

208 

209 assert _get_symlink_target(source=source, dest=dest) == ( 2ucvcwcxcyczcAcBcCc

210 r"..\..\node_modules\pkg\.agents\skills\demo-skill" 

211 ) 

212 

213 

214def test_list_installed_skills_handles_missing_target_and_directories(tmp_path): 1abcdefghi

215 target_dir = tmp_path / ".agents" / "skills" 1BCDEFGHIJ

216 

217 assert list_installed_skills(target_dir) == [] 1BCDEFGHIJ

218 

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

223 

224 installed = list_installed_skills(target_dir) 1BCDEFGHIJ

225 

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

231 

232 

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!#$%'()

238 

239 installed = list_installed_skills(target_dir) 189!#$%'()

240 

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!#$%'()

246 

247 

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./

254 

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./

259 

260 

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

266 

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

269 

270 

271def test_install_tool_skill_copies_template_and_marker(tmp_path): 1abcdefghi

272 target_dir = tmp_path / ".agents" / "skills" 2wbxbybzbAbBbCbDbEb

273 

274 installed = install_tool_skill(target_dir) 2wbxbybzbAbBbCbDbEb

275 

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

284 

285 

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

290 

291 assert inspect_tool_skill(target_dir).status == "tool skill: stale" 2;b=b?b@b[b]b^b_b`b

292 

293 install_tool_skill(target_dir) 2;b=b?b@b[b]b^b_b`b

294 

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 ) 

298 

299 

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

303 

304 monkeypatch.setattr(installer.metadata, "version", missing_version) 2DcEcFcGcHcIcJcKcLc

305 

306 assert get_tool_skill_version() == "0.0.0" 2DcEcFcGcHcIcJcKcLc

307 

308 

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

313 

314 status = inspect_tool_skill(target_dir) 2lcmcncocpcqcrcsctc

315 

316 assert status.status == "tool skill: blocked by hand-authored directory" 2lcmcncocpcqcrcsctc

317 

318 

319def test_inspect_tool_skill_classifies_claude_target(tmp_path): 1abcdefghi

320 target_dir = tmp_path / ".claude" / "skills" 2McNcOcPcQcRcScTcUc

321 

322 status = inspect_tool_skill(target_dir) 2McNcOcPcQcRcScTcUc

323 

324 assert status.target.name == "claude-compatible" 2McNcOcPcQcRcScTcUc

325 

326 

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 ) 

334 

335 status = inspect_tool_skill(target_dir) 2{b|b}b~bacbcccdcec

336 

337 assert status.status == "tool skill: stale" 2{b|b}b~bacbcccdcec

338 

339 

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

350 

351 status = inspect_tool_skill(target_dir) 2LbMbNbObPbQbRbSbTb

352 

353 assert status.status == "tool skill: stale" 2LbMbNbObPbQbRbSbTb

354 

355 

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:;=?@[]^_

361 

362 status = inspect_tool_skill(target_dir) 1:;=?@[]^_

363 

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:;=?@[]^_

368 

369 

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 ) 

378 

379 status = inspect_tool_skill(target_dir) 2` { | } ~ abbbcbdb

380 

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