Coverage for tests / test_cli.py: 100%
853 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
1import importlib 1abcdefghi
2import json 1abcdefghi
3import sys 1abcdefghi
4from pathlib import Path 1abcdefghi
5from unittest.mock import patch 1abcdefghi
7from rich.console import Console 1abcdefghi
8from typer.testing import CliRunner 1abcdefghi
10import library_skills.cli as cli 1abcdefghi
11import library_skills.ui as ui 1abcdefghi
12from library_skills.cli import app 1abcdefghi
13from library_skills.installer import ( 1abcdefghi
14 TOOL_SKILL_MARKER,
15 TOOL_SKILL_NAME,
16 get_tool_skill_template,
17)
19runner = CliRunner() 1abcdefghi
22class FakeToolkit: 1abcdefghi
23 def __init__(self, value): 1abcdefghi
24 self.value = value 2cc?d@ddc[d]dec^d_d
26 def ask(self, *args, **kwargs): 1abcdefghi
27 return self.value 2cc?d@ddc[d]dec^d_d
30def write_project(tmp_path: Path, dependencies: list[str]) -> Path: 1abcdefghi
31 project = tmp_path / "project" 2$bmdfcVcsb3 ( { 4cSbgc| } tbTbUbVbndhc) * od+ %bpd, ~ abubqdN p ;bObE - O P 'brdicWcvb4 . bb5cWbjccbdbwbXbYbZbsdkc/ : td; (bud= ebfbxbvdQ q =bPbF ? R S )bwdlcXcgbT 8 hb6c0bmcib@ jbQb1b2bxdnc[ ] yd^ *bzd_ kblbybAdU r ?bRbG ` V W
32 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2$bmdfcVcsb3 ( { 4cSbgc| } tbTbUbVbndhc) * od+ %bpd, ~ abubqdN p ;bObE - O P 'brdicWcvb4 . bb5cWbjccbdbwbXbYbZbsdkc/ : td; (bud= ebfbxbvdQ q =bPbF ? R S )bwdlcXcgbT 8 hb6c0bmcib@ jbQb1b2bxdnc[ ] yd^ *bzd_ kblbybAdU r ?bRbG ` V W
33 site_packages.mkdir(parents=True) 2$bmdfcVcsb3 ( { 4cSbgc| } tbTbUbVbndhc) * od+ %bpd, ~ abubqdN p ;bObE - O P 'brdicWcvb4 . bb5cWbjccbdbwbXbYbZbsdkc/ : td; (bud= ebfbxbvdQ q =bPbF ? R S )bwdlcXcgbT 8 hb6c0bmcib@ jbQb1b2bxdnc[ ] yd^ *bzd_ kblbybAdU r ?bRbG ` V W
34 (project / ".venv" / "pyvenv.cfg").write_text( 2$bmdfcVcsb3 ( { 4cSbgc| } tbTbUbVbndhc) * od+ %bpd, ~ abubqdN p ;bObE - O P 'brdicWcvb4 . bb5cWbjccbdbwbXbYbZbsdkc/ : td; (bud= ebfbxbvdQ q =bPbF ? R S )bwdlcXcgbT 8 hb6c0bmcib@ jbQb1b2bxdnc[ ] yd^ *bzd_ kblbybAdU r ?bRbG ` V W
35 "home = /usr/bin\n",
36 encoding="utf-8",
37 )
38 quoted_deps = ", ".join(f'"{dependency}"' for dependency in dependencies) 2$bmdfcVcsb3 ( { 4cSbgc| } tbTbUbVbndhc) * od+ %bpd, ~ abubqdN p ;bObE - O P 'brdicWcvb4 . bb5cWbjccbdbwbXbYbZbsdkc/ : td; (bud= ebfbxbvdQ q =bPbF ? R S )bwdlcXcgbT 8 hb6c0bmcib@ jbQb1b2bxdnc[ ] yd^ *bzd_ kblbybAdU r ?bRbG ` V W
39 (project / "pyproject.toml").write_text( 2$bmdfcVcsb3 ( { 4cSbgc| } tbTbUbVbndhc) * od+ %bpd, ~ abubqdN p ;bObE - O P 'brdicWcvb4 . bb5cWbjccbdbwbXbYbZbsdkc/ : td; (bud= ebfbxbvdQ q =bPbF ? R S )bwdlcXcgbT 8 hb6c0bmcib@ jbQb1b2bxdnc[ ] yd^ *bzd_ kblbybAdU r ?bRbG ` V W
40 f"""
41[project]
42name = "demo"
43version = "0.1.0"
44dependencies = [{quoted_deps}]
45""",
46 encoding="utf-8",
47 )
48 return project 2$bmdfcVcsb3 ( { 4cSbgc| } tbTbUbVbndhc) * od+ %bpd, ~ abubqdN p ;bObE - O P 'brdicWcvb4 . bb5cWbjccbdbwbXbYbZbsdkc/ : td; (bud= ebfbxbvdQ q =bPbF ? R S )bwdlcXcgbT 8 hb6c0bmcib@ jbQb1b2bxdnc[ ] yd^ *bzd_ kblbybAdU r ?bRbG ` V W
51def write_workspace(tmp_path: Path) -> Path: 1abcdefghi
52 project = tmp_path / "workspace" 1msjHBIvwntkJCKxyoulLDMzA
53 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 1msjHBIvwntkJCKxyoulLDMzA
54 site_packages.mkdir(parents=True) 1msjHBIvwntkJCKxyoulLDMzA
55 (project / ".venv" / "pyvenv.cfg").write_text( 1msjHBIvwntkJCKxyoulLDMzA
56 "home = /usr/bin\n",
57 encoding="utf-8",
58 )
59 (project / "pyproject.toml").write_text( 1msjHBIvwntkJCKxyoulLDMzA
60 """
61[project]
62name = "workspace-root"
63version = "0.1.0"
64dependencies = ["root-pkg>=1"]
66[tool.uv.workspace]
67members = ["packages/*"]
68""",
69 encoding="utf-8",
70 )
71 api = project / "packages" / "api" 1msjHBIvwntkJCKxyoulLDMzA
72 worker = project / "packages" / "worker" 1msjHBIvwntkJCKxyoulLDMzA
73 api.mkdir(parents=True) 1msjHBIvwntkJCKxyoulLDMzA
74 worker.mkdir(parents=True) 1msjHBIvwntkJCKxyoulLDMzA
75 (api / "pyproject.toml").write_text( 1msjHBIvwntkJCKxyoulLDMzA
76 """
77[project]
78name = "api"
79version = "0.1.0"
80dependencies = ["api-pkg>=1", "workspace-lib"]
82[tool.uv.sources]
83workspace-lib = { workspace = true }
84""",
85 encoding="utf-8",
86 )
87 (worker / "pyproject.toml").write_text( 1msjHBIvwntkJCKxyoulLDMzA
88 """
89[project]
90name = "worker"
91version = "0.1.0"
92dependencies = ["worker-pkg>=1"]
93""",
94 encoding="utf-8",
95 )
96 return project 1msjHBIvwntkJCKxyoulLDMzA
99def write_node_workspace(tmp_path: Path) -> Path: 1abcdefghi
100 project = tmp_path / "node-workspace" 2zbX mb5 nb+b,bAbY ob6 pb-b.bBbZ qb7 rb/b:b
101 (project / "node_modules").mkdir(parents=True) 2zbX mb5 nb+b,bAbY ob6 pb-b.bBbZ qb7 rb/b:b
102 (project / "package.json").write_text( 2zbX mb5 nb+b,bAbY ob6 pb-b.bBbZ qb7 rb/b:b
103 json.dumps(
104 {
105 "name": "workspace-root",
106 "workspaces": ["packages/*"],
107 "dependencies": {"root-pkg": "^1.0.0"},
108 }
109 ),
110 encoding="utf-8",
111 )
112 api = project / "packages" / "api" 2zbX mb5 nb+b,bAbY ob6 pb-b.bBbZ qb7 rb/b:b
113 worker = project / "packages" / "worker" 2zbX mb5 nb+b,bAbY ob6 pb-b.bBbZ qb7 rb/b:b
114 api.mkdir(parents=True) 2zbX mb5 nb+b,bAbY ob6 pb-b.bBbZ qb7 rb/b:b
115 worker.mkdir(parents=True) 2zbX mb5 nb+b,bAbY ob6 pb-b.bBbZ qb7 rb/b:b
116 (api / "package.json").write_text( 2zbX mb5 nb+b,bAbY ob6 pb-b.bBbZ qb7 rb/b:b
117 json.dumps(
118 {
119 "name": "api",
120 "dependencies": {"api-pkg": "^1.0.0"},
121 "devDependencies": {"workspace-lib": "workspace:*"},
122 }
123 ),
124 encoding="utf-8",
125 )
126 (worker / "package.json").write_text( 2zbX mb5 nb+b,bAbY ob6 pb-b.bBbZ qb7 rb/b:b
127 json.dumps(
128 {
129 "name": "worker",
130 "optionalDependencies": {"worker-pkg": "^1.0.0"},
131 }
132 ),
133 encoding="utf-8",
134 )
135 return project 2zbX mb5 nb+b,bAbY ob6 pb-b.bBbZ qb7 rb/b:b
138def add_node_workspace_files(project: Path) -> None: 1abcdefghi
139 (project / "node_modules").mkdir(parents=True, exist_ok=True) 1ms9!nt#$ou%'
140 (project / "package.json").write_text( 1ms9!nt#$ou%'
141 json.dumps(
142 {
143 "name": "workspace-root",
144 "workspaces": ["packages/*"],
145 "dependencies": {"root-node-pkg": "^1.0.0"},
146 }
147 ),
148 encoding="utf-8",
149 )
150 api = project / "packages" / "api" 1ms9!nt#$ou%'
151 worker = project / "packages" / "worker" 1ms9!nt#$ou%'
152 (api / "package.json").write_text( 1ms9!nt#$ou%'
153 json.dumps(
154 {
155 "name": "api",
156 "dependencies": {"api-node-pkg": "^1.0.0"},
157 }
158 ),
159 encoding="utf-8",
160 )
161 (worker / "package.json").write_text( 1ms9!nt#$ou%'
162 json.dumps(
163 {
164 "name": "worker",
165 "dependencies": {"worker-node-pkg": "^1.0.0"},
166 }
167 ),
168 encoding="utf-8",
169 )
172def write_distribution_skill( 1abcdefghi
173 site_packages: Path,
174 *,
175 dist_name: str,
176 package_dir: str,
177 skill_name: str,
178) -> Path:
179 skill_dir = site_packages / package_dir / ".agents" / "skills" / skill_name 2$bm s sb3 ( { Sb| } tbTbUbVb) * + %b, ~ abubN p j H B I E - O P 'bn t vb4 . bbWbcbdbwbXbYbZb/ : ; (b= ebfbxbQ q k J C K F ? R S )bo u gbT 8 hb0bib@ jbQb1b2b[ ] ^ *b_ kblbybU r l L D M G ` V W
180 skill_dir.mkdir(parents=True) 2$bm s sb3 ( { Sb| } tbTbUbVb) * + %b, ~ abubN p j H B I E - O P 'bn t vb4 . bbWbcbdbwbXbYbZb/ : ; (b= ebfbxbQ q k J C K F ? R S )bo u gbT 8 hb0bib@ jbQb1b2b[ ] ^ *b_ kblbybU r l L D M G ` V W
181 skill_md = skill_dir / "SKILL.md" 2$bm s sb3 ( { Sb| } tbTbUbVb) * + %b, ~ abubN p j H B I E - O P 'bn t vb4 . bbWbcbdbwbXbYbZb/ : ; (b= ebfbxbQ q k J C K F ? R S )bo u gbT 8 hb0bib@ jbQb1b2b[ ] ^ *b_ kblbybU r l L D M G ` V W
182 skill_md.write_text( 2$bm s sb3 ( { Sb| } tbTbUbVb) * + %b, ~ abubN p j H B I E - O P 'bn t vb4 . bbWbcbdbwbXbYbZb/ : ; (b= ebfbxbQ q k J C K F ? R S )bo u gbT 8 hb0bib@ jbQb1b2b[ ] ^ *b_ kblbybU r l L D M G ` V W
183 f"---\nname: {skill_name}\ndescription: Demo skill.\n---\n",
184 encoding="utf-8",
185 )
187 dist_info = site_packages / f"{package_dir}-1.0.0.dist-info" 2$bm s sb3 ( { Sb| } tbTbUbVb) * + %b, ~ abubN p j H B I E - O P 'bn t vb4 . bbWbcbdbwbXbYbZb/ : ; (b= ebfbxbQ q k J C K F ? R S )bo u gbT 8 hb0bib@ jbQb1b2b[ ] ^ *b_ kblbybU r l L D M G ` V W
188 dist_info.mkdir(exist_ok=True) 2$bm s sb3 ( { Sb| } tbTbUbVb) * + %b, ~ abubN p j H B I E - O P 'bn t vb4 . bbWbcbdbwbXbYbZb/ : ; (b= ebfbxbQ q k J C K F ? R S )bo u gbT 8 hb0bib@ jbQb1b2b[ ] ^ *b_ kblbybU r l L D M G ` V W
189 dist_info.joinpath("METADATA").write_text( 2$bm s sb3 ( { Sb| } tbTbUbVb) * + %b, ~ abubN p j H B I E - O P 'bn t vb4 . bbWbcbdbwbXbYbZb/ : ; (b= ebfbxbQ q k J C K F ? R S )bo u gbT 8 hb0bib@ jbQb1b2b[ ] ^ *b_ kblbybU r l L D M G ` V W
190 f"Metadata-Version: 2.4\nName: {dist_name}\nVersion: 1.0.0\n",
191 encoding="utf-8",
192 )
193 dist_info.joinpath("RECORD").write_text( 2$bm s sb3 ( { Sb| } tbTbUbVb) * + %b, ~ abubN p j H B I E - O P 'bn t vb4 . bbWbcbdbwbXbYbZb/ : ; (b= ebfbxbQ q k J C K F ? R S )bo u gbT 8 hb0bib@ jbQb1b2b[ ] ^ *b_ kblbybU r l L D M G ` V W
194 f"{skill_md.relative_to(site_packages).as_posix()},,\n",
195 encoding="utf-8",
196 )
197 return skill_dir 2$bm s sb3 ( { Sb| } tbTbUbVb) * + %b, ~ abubN p j H B I E - O P 'bn t vb4 . bbWbcbdbwbXbYbZb/ : ; (b= ebfbxbQ q k J C K F ? R S )bo u gbT 8 hb0bib@ jbQb1b2b[ ] ^ *b_ kblbybU r l L D M G ` V W
200def write_node_package_skill( 1abcdefghi
201 project: Path,
202 *,
203 package_name: str,
204 skill_name: str,
205) -> Path:
206 package_root = project / "node_modules" / package_name 2m s zbX mb5 nbp 9 ! n t AbY ob6 pbq # $ o u BbZ qb7 rbr % '
207 return write_node_package_skill_at( 2m s zbX mb5 nbp 9 ! n t AbY ob6 pbq # $ o u BbZ qb7 rbr % '
208 package_root,
209 package_name=package_name,
210 skill_name=skill_name,
211 )
214def write_node_package_skill_at( 1abcdefghi
215 package_root: Path,
216 *,
217 package_name: str,
218 skill_name: str,
219) -> Path:
220 skill_dir = package_root / ".agents" / "skills" / skill_name 2m s zbX mb5 nbp 9 ! n t AbY ob6 pbq # $ o u BbZ qb7 rbr % '
221 skill_dir.mkdir(parents=True) 2m s zbX mb5 nbp 9 ! n t AbY ob6 pbq # $ o u BbZ qb7 rbr % '
222 skill_dir.joinpath("SKILL.md").write_text( 2m s zbX mb5 nbp 9 ! n t AbY ob6 pbq # $ o u BbZ qb7 rbr % '
223 f"---\nname: {skill_name}\ndescription: Node skill.\n---\n",
224 encoding="utf-8",
225 )
226 package_root.joinpath("package.json").write_text( 2m s zbX mb5 nbp 9 ! n t AbY ob6 pbq # $ o u BbZ qb7 rbr % '
227 json.dumps({"name": package_name, "version": "2.0.0"}),
228 encoding="utf-8",
229 )
230 return skill_dir 2m s zbX mb5 nbp 9 ! n t AbY ob6 pbq # $ o u BbZ qb7 rbr % '
233def write_distribution( 1abcdefghi
234 site_packages: Path,
235 *,
236 dist_name: str,
237 package_dir: str,
238 skill_names: list[str],
239) -> list[Path]:
240 skill_dirs = [ 2E {b|bF }b~bG acbc
241 write_distribution_skill(
242 site_packages,
243 dist_name=dist_name,
244 package_dir=package_dir,
245 skill_name=skill_name,
246 )
247 for skill_name in skill_names
248 ]
249 dist_info = site_packages / f"{package_dir}-1.0.0.dist-info" 2E {b|bF }b~bG acbc
250 record_paths = [ 2E {b|bF }b~bG acbc
251 (skill_dir / "SKILL.md").relative_to(site_packages).as_posix()
252 for skill_dir in skill_dirs
253 ]
254 dist_info.joinpath("RECORD").write_text( 2E {b|bF }b~bG acbc
255 "".join(f"{record_path},,\n" for record_path in record_paths),
256 encoding="utf-8",
257 )
258 return skill_dirs 2E {b|bF }b~bG acbc
261def make_cli_skill(tmp_path: Path, name: str = "demo-skill") -> cli.Skill: 1abcdefghi
262 skill_dir = tmp_path / "source" / ".agents" / "skills" / name 2IeJe3b0 cc4b5bKeLe6b1 dc7b8bMeNe9b2 ec!b#b
263 skill_dir.mkdir(parents=True) 2IeJe3b0 cc4b5bKeLe6b1 dc7b8bMeNe9b2 ec!b#b
264 skill_md = skill_dir / "SKILL.md" 2IeJe3b0 cc4b5bKeLe6b1 dc7b8bMeNe9b2 ec!b#b
265 skill_md.write_text( 2IeJe3b0 cc4b5bKeLe6b1 dc7b8bMeNe9b2 ec!b#b
266 f"---\nname: {name}\ndescription: Demo skill.\n---\n",
267 encoding="utf-8",
268 )
269 return cli.Skill( 2IeJe3b0 cc4b5bKeLe6b1 dc7b8bMeNe9b2 ec!b#b
270 name=name,
271 description="Demo skill.",
272 path=skill_md,
273 package_name="demo-pkg",
274 package_version="1.0.0",
275 skill_dir=skill_dir,
276 )
279def test_scan_filters_to_top_level_dependencies_by_default(tmp_path, monkeypatch): 1abcdefghi
280 project = write_project(tmp_path, dependencies=["top-pkg>=1"]) 1NOPQRSUVW
281 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 1NOPQRSUVW
282 write_distribution_skill( 1NOPQRSUVW
283 site_packages,
284 dist_name="top-pkg",
285 package_dir="top_pkg",
286 skill_name="top-skill",
287 )
288 write_distribution_skill( 1NOPQRSUVW
289 site_packages,
290 dist_name="transitive-pkg",
291 package_dir="transitive_pkg",
292 skill_name="transitive-skill",
293 )
294 monkeypatch.chdir(project) 1NOPQRSUVW
296 result = runner.invoke(app, ["scan"]) 1NOPQRSUVW
297 all_result = runner.invoke(app, ["scan", "--all"]) 1NOPQRSUVW
299 assert result.exit_code == 0 1NOPQRSUVW
300 assert "Target Python environment" in result.output 1NOPQRSUVW
301 assert ".venv" in result.output 1NOPQRSUVW
302 assert "Site-packages" in result.output 1NOPQRSUVW
303 assert "top-skill" in result.output 1NOPQRSUVW
304 assert "transitive-skill" not in result.output 1NOPQRSUVW
305 assert all_result.exit_code == 0 1NOPQRSUVW
306 assert "top-skill" in all_result.output 1NOPQRSUVW
307 assert "transitive-skill" in all_result.output 1NOPQRSUVW
310def test_workspace_member_scan_uses_workspace_venv_and_member_deps( 1abcdefghi
311 tmp_path,
312 monkeypatch,
313):
314 project = write_workspace(tmp_path) 1jvwkxylzA
315 api = project / "packages" / "api" 1jvwkxylzA
316 member_venv = api / ".venv" 1jvwkxylzA
317 member_venv.mkdir() 1jvwkxylzA
318 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 1jvwkxylzA
319 api_skill = write_distribution_skill( 1jvwkxylzA
320 site_packages,
321 dist_name="api-pkg",
322 package_dir="api_pkg",
323 skill_name="api-skill",
324 )
325 write_distribution_skill( 1jvwkxylzA
326 site_packages,
327 dist_name="workspace-lib",
328 package_dir="workspace_lib",
329 skill_name="workspace-lib-skill",
330 )
331 write_distribution_skill( 1jvwkxylzA
332 site_packages,
333 dist_name="root-pkg",
334 package_dir="root_pkg",
335 skill_name="root-skill",
336 )
337 write_distribution_skill( 1jvwkxylzA
338 site_packages,
339 dist_name="worker-pkg",
340 package_dir="worker_pkg",
341 skill_name="worker-skill",
342 )
343 write_distribution_skill( 1jvwkxylzA
344 site_packages,
345 dist_name="transitive-pkg",
346 package_dir="transitive_pkg",
347 skill_name="transitive-skill",
348 )
349 (api / "src").mkdir() 1jvwkxylzA
350 monkeypatch.chdir(api / "src") 1jvwkxylzA
352 result = runner.invoke(app, ["scan", "--json"]) 1jvwkxylzA
353 install_result = runner.invoke( 1jvwkxylzA
354 app, ["install", "--skill", "api-skill", "--yes", "--claude"]
355 )
357 payload = json.loads(result.output) 1jvwkxylzA
358 assert result.exit_code == 0 1jvwkxylzA
359 assert payload["project_root"] == str(project) 1jvwkxylzA
360 assert payload["workspace_root"] == str(project) 1jvwkxylzA
361 assert payload["workspace_member"] == str(api.resolve()) 1jvwkxylzA
362 assert payload["dependency_files"] == [str(api.resolve() / "pyproject.toml")] 1jvwkxylzA
363 assert payload["target_environment"] == str(project / ".venv") 1jvwkxylzA
364 assert "Ignoring member-local .venv" in "\n".join(payload["warnings"]) 1jvwkxylzA
365 assert [skill["name"] for skill in payload["skills"]] == [ 1jvwkxylzA
366 "api-skill",
367 "workspace-lib-skill",
368 ]
369 assert install_result.exit_code == 0 1jvwkxylzA
370 installed = project / ".agents" / "skills" / "api-skill" 1jvwkxylzA
371 claude_installed = project / ".claude" / "skills" / "api-skill" 1jvwkxylzA
372 assert installed.is_symlink() 1jvwkxylzA
373 assert installed.resolve() == api_skill.resolve() 1jvwkxylzA
374 assert claude_installed.is_symlink() 1jvwkxylzA
375 assert claude_installed.resolve() == api_skill.resolve() 1jvwkxylzA
376 assert not (api / ".agents" / "skills" / "api-skill").exists() 1jvwkxylzA
379def test_combined_uv_and_node_workspace_member_scan_uses_both_member_deps( 1abcdefghi
380 tmp_path,
381 monkeypatch,
382):
383 project = write_workspace(tmp_path) 1m9!n#$o%'
384 add_node_workspace_files(project) 1m9!n#$o%'
385 api = project / "packages" / "api" 1m9!n#$o%'
386 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 1m9!n#$o%'
387 write_distribution_skill( 1m9!n#$o%'
388 site_packages,
389 dist_name="api-pkg",
390 package_dir="api_pkg",
391 skill_name="api-skill",
392 )
393 write_distribution_skill( 1m9!n#$o%'
394 site_packages,
395 dist_name="root-pkg",
396 package_dir="root_pkg",
397 skill_name="root-skill",
398 )
399 write_node_package_skill( 1m9!n#$o%'
400 project,
401 package_name="api-node-pkg",
402 skill_name="api-node-skill",
403 )
404 write_node_package_skill( 1m9!n#$o%'
405 project,
406 package_name="root-node-pkg",
407 skill_name="root-node-skill",
408 )
409 (api / "src").mkdir() 1m9!n#$o%'
410 monkeypatch.chdir(api / "src") 1m9!n#$o%'
412 result = runner.invoke(app, ["scan", "--json"]) 1m9!n#$o%'
414 payload = json.loads(result.output) 1m9!n#$o%'
415 assert result.exit_code == 0 1m9!n#$o%'
416 assert payload["workspace_root"] == str(project) 1m9!n#$o%'
417 assert payload["workspace_member"] == str(api.resolve()) 1m9!n#$o%'
418 assert payload["dependency_files"] == [ 1m9!n#$o%'
419 str(api.resolve() / "pyproject.toml"),
420 str(api.resolve() / "package.json"),
421 ]
422 assert [skill["name"] for skill in payload["skills"]] == [ 1m9!n#$o%'
423 "api-skill",
424 "api-node-skill",
425 ]
428def test_workspace_root_scan_uses_root_and_all_member_deps(tmp_path, monkeypatch): 1abcdefghi
429 project = write_workspace(tmp_path) 2B ocpcC qcrcD sctc
430 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2B ocpcC qcrcD sctc
431 for dist_name, package_dir, skill_name in [ 2B ocpcC qcrcD sctc
432 ("api-pkg", "api_pkg", "api-skill"),
433 ("workspace-lib", "workspace_lib", "workspace-lib-skill"),
434 ("root-pkg", "root_pkg", "root-skill"),
435 ("worker-pkg", "worker_pkg", "worker-skill"),
436 ("transitive-pkg", "transitive_pkg", "transitive-skill"),
437 ]:
438 write_distribution_skill( 2B ocpcC qcrcD sctc
439 site_packages,
440 dist_name=dist_name,
441 package_dir=package_dir,
442 skill_name=skill_name,
443 )
444 monkeypatch.chdir(project) 2B ocpcC qcrcD sctc
446 result = runner.invoke(app, ["scan", "--json"]) 2B ocpcC qcrcD sctc
447 all_result = runner.invoke(app, ["scan", "--json", "--all"]) 2B ocpcC qcrcD sctc
449 payload = json.loads(result.output) 2B ocpcC qcrcD sctc
450 all_payload = json.loads(all_result.output) 2B ocpcC qcrcD sctc
451 assert result.exit_code == 0 2B ocpcC qcrcD sctc
452 assert payload["workspace_member"] == "" 2B ocpcC qcrcD sctc
453 assert payload["dependency_files"] == [ 2B ocpcC qcrcD sctc
454 str(project / "pyproject.toml"),
455 str((project / "packages" / "api").resolve() / "pyproject.toml"),
456 str((project / "packages" / "worker").resolve() / "pyproject.toml"),
457 ]
458 assert [skill["name"] for skill in payload["skills"]] == [ 2B ocpcC qcrcD sctc
459 "api-skill",
460 "root-skill",
461 "worker-skill",
462 "workspace-lib-skill",
463 ]
464 assert "transitive-skill" not in [skill["name"] for skill in payload["skills"]] 2B ocpcC qcrcD sctc
465 assert "transitive-skill" in [skill["name"] for skill in all_payload["skills"]] 2B ocpcC qcrcD sctc
468def test_combined_uv_and_node_workspace_root_scan_uses_all_deps( 1abcdefghi
469 tmp_path,
470 monkeypatch,
471):
472 project = write_workspace(tmp_path) 2s ucvct wcxcu yczc
473 add_node_workspace_files(project) 2s ucvct wcxcu yczc
474 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2s ucvct wcxcu yczc
475 write_distribution_skill( 2s ucvct wcxcu yczc
476 site_packages,
477 dist_name="api-pkg",
478 package_dir="api_pkg",
479 skill_name="api-skill",
480 )
481 write_distribution_skill( 2s ucvct wcxcu yczc
482 site_packages,
483 dist_name="root-pkg",
484 package_dir="root_pkg",
485 skill_name="root-skill",
486 )
487 write_distribution_skill( 2s ucvct wcxcu yczc
488 site_packages,
489 dist_name="worker-pkg",
490 package_dir="worker_pkg",
491 skill_name="worker-skill",
492 )
493 for package_name, skill_name in [ 2s ucvct wcxcu yczc
494 ("api-node-pkg", "api-node-skill"),
495 ("root-node-pkg", "root-node-skill"),
496 ("worker-node-pkg", "worker-node-skill"),
497 ]:
498 write_node_package_skill( 2s ucvct wcxcu yczc
499 project, package_name=package_name, skill_name=skill_name
500 )
501 monkeypatch.chdir(project) 2s ucvct wcxcu yczc
503 result = runner.invoke(app, ["scan", "--json"]) 2s ucvct wcxcu yczc
505 payload = json.loads(result.output) 2s ucvct wcxcu yczc
506 assert result.exit_code == 0 2s ucvct wcxcu yczc
507 assert payload["workspace_member"] == "" 2s ucvct wcxcu yczc
508 assert payload["dependency_files"] == [ 2s ucvct wcxcu yczc
509 str(project / "pyproject.toml"),
510 str((project / "packages" / "api").resolve() / "pyproject.toml"),
511 str((project / "packages" / "worker").resolve() / "pyproject.toml"),
512 str(project / "package.json"),
513 str((project / "packages" / "api").resolve() / "package.json"),
514 str((project / "packages" / "worker").resolve() / "package.json"),
515 ]
516 assert [skill["name"] for skill in payload["skills"]] == [ 2s ucvct wcxcu yczc
517 "api-skill",
518 "root-skill",
519 "worker-skill",
520 "api-node-skill",
521 "root-node-skill",
522 "worker-node-skill",
523 ]
526def test_workspace_non_member_subdir_uses_root_and_all_member_deps( 1abcdefghi
527 tmp_path,
528 monkeypatch,
529):
530 project = write_workspace(tmp_path) 2H BdCdJ DdEdL FdGd
531 docs = project / "docs" 2H BdCdJ DdEdL FdGd
532 docs.mkdir() 2H BdCdJ DdEdL FdGd
533 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2H BdCdJ DdEdL FdGd
534 for dist_name, package_dir, skill_name in [ 2H BdCdJ DdEdL FdGd
535 ("api-pkg", "api_pkg", "api-skill"),
536 ("root-pkg", "root_pkg", "root-skill"),
537 ("worker-pkg", "worker_pkg", "worker-skill"),
538 ]:
539 write_distribution_skill( 2H BdCdJ DdEdL FdGd
540 site_packages,
541 dist_name=dist_name,
542 package_dir=package_dir,
543 skill_name=skill_name,
544 )
545 monkeypatch.chdir(docs) 2H BdCdJ DdEdL FdGd
547 result = runner.invoke(app, ["scan", "--json"]) 2H BdCdJ DdEdL FdGd
549 payload = json.loads(result.output) 2H BdCdJ DdEdL FdGd
550 assert result.exit_code == 0 2H BdCdJ DdEdL FdGd
551 assert payload["workspace_member"] == "" 2H BdCdJ DdEdL FdGd
552 assert [skill["name"] for skill in payload["skills"]] == [ 2H BdCdJ DdEdL FdGd
553 "api-skill",
554 "root-skill",
555 "worker-skill",
556 ]
559def test_workspace_scan_prints_workspace_context(tmp_path, monkeypatch): 1abcdefghi
560 project = write_workspace(tmp_path) 2I HdIdK JdKdM LdMd
561 api = project / "packages" / "api" 2I HdIdK JdKdM LdMd
562 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2I HdIdK JdKdM LdMd
563 write_distribution_skill( 2I HdIdK JdKdM LdMd
564 site_packages,
565 dist_name="api-pkg",
566 package_dir="api_pkg",
567 skill_name="api-skill",
568 )
569 monkeypatch.chdir(api) 2I HdIdK JdKdM LdMd
571 with patch.object(cli, "console", Console(width=1000)): 2I HdIdK JdKdM LdMd
572 result = runner.invoke(app, ["scan"]) 2I HdIdK JdKdM LdMd
574 assert result.exit_code == 0 2I HdIdK JdKdM LdMd
575 assert "Workspace root" in result.output 2I HdIdK JdKdM LdMd
576 assert str(project) in result.output 2I HdIdK JdKdM LdMd
577 assert "Workspace member" in result.output 2I HdIdK JdKdM LdMd
578 assert str(api.resolve()) in result.output 2I HdIdK JdKdM LdMd
581def test_node_workspace_scan_prints_workspace_context(tmp_path, monkeypatch): 1abcdefghi
582 project = write_node_workspace(tmp_path) 2nb+b,bpb-b.brb/b:b
583 api = project / "packages" / "api" 2nb+b,bpb-b.brb/b:b
584 write_node_package_skill(project, package_name="api-pkg", skill_name="api-skill") 2nb+b,bpb-b.brb/b:b
585 monkeypatch.chdir(api) 2nb+b,bpb-b.brb/b:b
587 with patch.object(cli, "console", Console(width=1000)): 2nb+b,bpb-b.brb/b:b
588 result = runner.invoke(app, ["scan"]) 2nb+b,bpb-b.brb/b:b
590 assert result.exit_code == 0 2nb+b,bpb-b.brb/b:b
591 assert "Workspace root" in result.output 2nb+b,bpb-b.brb/b:b
592 assert str(project) in result.output 2nb+b,bpb-b.brb/b:b
593 assert "Workspace member" in result.output 2nb+b,bpb-b.brb/b:b
594 assert str(api.resolve()) in result.output 2nb+b,bpb-b.brb/b:b
597def test_node_workspace_member_scan_uses_member_deps_and_root_node_modules( 1abcdefghi
598 tmp_path,
599 monkeypatch,
600):
601 project = write_node_workspace(tmp_path) 2X AcBcY CcDcZ EcFc
602 api = project / "packages" / "api" 2X AcBcY CcDcZ EcFc
603 for package_name, skill_name in [ 2X AcBcY CcDcZ EcFc
604 ("api-pkg", "api-skill"),
605 ("workspace-lib", "workspace-lib-skill"),
606 ("root-pkg", "root-skill"),
607 ("worker-pkg", "worker-skill"),
608 ("transitive-node-pkg", "transitive-node-skill"),
609 ]:
610 write_node_package_skill( 2X AcBcY CcDcZ EcFc
611 project, package_name=package_name, skill_name=skill_name
612 )
613 (api / "src").mkdir() 2X AcBcY CcDcZ EcFc
614 monkeypatch.chdir(api / "src") 2X AcBcY CcDcZ EcFc
616 result = runner.invoke(app, ["scan", "--json"]) 2X AcBcY CcDcZ EcFc
618 payload = json.loads(result.output) 2X AcBcY CcDcZ EcFc
619 assert result.exit_code == 0 2X AcBcY CcDcZ EcFc
620 assert payload["project_root"] == str(project) 2X AcBcY CcDcZ EcFc
621 assert payload["workspace_root"] == str(project) 2X AcBcY CcDcZ EcFc
622 assert payload["workspace_member"] == str(api.resolve()) 2X AcBcY CcDcZ EcFc
623 assert payload["dependency_files"] == [str(api.resolve() / "package.json")] 2X AcBcY CcDcZ EcFc
624 assert payload["node_modules"] == str(project / "node_modules") 2X AcBcY CcDcZ EcFc
625 assert [skill["name"] for skill in payload["skills"]] == [ 2X AcBcY CcDcZ EcFc
626 "api-skill",
627 "workspace-lib-skill",
628 ]
631def test_node_workspace_root_scan_uses_root_and_all_member_deps(tmp_path, monkeypatch): 1abcdefghi
632 project = write_node_workspace(tmp_path) 25 YcZc6 0c1c7 2c3c
633 for package_name, skill_name in [ 25 YcZc6 0c1c7 2c3c
634 ("api-pkg", "api-skill"),
635 ("workspace-lib", "workspace-lib-skill"),
636 ("root-pkg", "root-skill"),
637 ("worker-pkg", "worker-skill"),
638 ("transitive-node-pkg", "transitive-node-skill"),
639 ]:
640 write_node_package_skill( 25 YcZc6 0c1c7 2c3c
641 project, package_name=package_name, skill_name=skill_name
642 )
643 monkeypatch.chdir(project) 25 YcZc6 0c1c7 2c3c
645 result = runner.invoke(app, ["scan", "--json"]) 25 YcZc6 0c1c7 2c3c
646 all_result = runner.invoke(app, ["scan", "--json", "--all"]) 25 YcZc6 0c1c7 2c3c
648 payload = json.loads(result.output) 25 YcZc6 0c1c7 2c3c
649 all_payload = json.loads(all_result.output) 25 YcZc6 0c1c7 2c3c
650 assert result.exit_code == 0 25 YcZc6 0c1c7 2c3c
651 assert payload["workspace_member"] == "" 25 YcZc6 0c1c7 2c3c
652 assert payload["dependency_files"] == [ 25 YcZc6 0c1c7 2c3c
653 str(project / "package.json"),
654 str((project / "packages" / "api").resolve() / "package.json"),
655 str((project / "packages" / "worker").resolve() / "package.json"),
656 ]
657 assert [skill["name"] for skill in payload["skills"]] == [ 25 YcZc6 0c1c7 2c3c
658 "api-skill",
659 "root-skill",
660 "worker-skill",
661 "workspace-lib-skill",
662 ]
663 assert "transitive-node-skill" not in [skill["name"] for skill in payload["skills"]] 25 YcZc6 0c1c7 2c3c
664 assert "transitive-node-skill" in [skill["name"] for skill in all_payload["skills"]] 25 YcZc6 0c1c7 2c3c
667def test_node_workspace_non_member_subdir_uses_root_and_all_member_deps( 1abcdefghi
668 tmp_path,
669 monkeypatch,
670):
671 project = write_node_workspace(tmp_path) 2mb`d{dob|d}dqb~dae
672 docs = project / "docs" 2mb`d{dob|d}dqb~dae
673 docs.mkdir() 2mb`d{dob|d}dqb~dae
674 for package_name, skill_name in [ 2mb`d{dob|d}dqb~dae
675 ("api-pkg", "api-skill"),
676 ("root-pkg", "root-skill"),
677 ("worker-pkg", "worker-skill"),
678 ]:
679 write_node_package_skill( 2mb`d{dob|d}dqb~dae
680 project, package_name=package_name, skill_name=skill_name
681 )
682 monkeypatch.chdir(docs) 2mb`d{dob|d}dqb~dae
684 result = runner.invoke(app, ["scan", "--json"]) 2mb`d{dob|d}dqb~dae
686 payload = json.loads(result.output) 2mb`d{dob|d}dqb~dae
687 assert result.exit_code == 0 2mb`d{dob|d}dqb~dae
688 assert payload["workspace_member"] == "" 2mb`d{dob|d}dqb~dae
689 assert [skill["name"] for skill in payload["skills"]] == [ 2mb`d{dob|d}dqb~dae
690 "api-skill",
691 "root-skill",
692 "worker-skill",
693 ]
696def test_node_workspace_member_local_node_modules_is_not_ignored(tmp_path, monkeypatch): 1abcdefghi
697 project = write_node_workspace(tmp_path) 2zbCeDeAbEeFeBbGeHe
698 api = project / "packages" / "api" 2zbCeDeAbEeFeBbGeHe
699 write_node_package_skill( 2zbCeDeAbEeFeBbGeHe
700 project, package_name="api-pkg", skill_name="root-api-skill"
701 )
702 write_node_package_skill_at( 2zbCeDeAbEeFeBbGeHe
703 api / "node_modules" / "api-pkg",
704 package_name="api-pkg",
705 skill_name="member-api-skill",
706 )
707 monkeypatch.chdir(api) 2zbCeDeAbEeFeBbGeHe
709 result = runner.invoke(app, ["scan", "--json"]) 2zbCeDeAbEeFeBbGeHe
711 payload = json.loads(result.output) 2zbCeDeAbEeFeBbGeHe
712 assert result.exit_code == 0 2zbCeDeAbEeFeBbGeHe
713 assert payload["node_modules"] == str(api / "node_modules") 2zbCeDeAbEeFeBbGeHe
714 assert [skill["name"] for skill in payload["skills"]] == ["member-api-skill"] 2zbCeDeAbEeFeBbGeHe
717def test_scan_includes_python_and_node_package_skills(tmp_path, monkeypatch): 1abcdefghi
718 project = write_project(tmp_path, dependencies=["top-pkg>=1"]) 2p CbDbq EbFbr GbHb
719 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2p CbDbq EbFbr GbHb
720 write_distribution_skill( 2p CbDbq EbFbr GbHb
721 site_packages,
722 dist_name="top-pkg",
723 package_dir="top_pkg",
724 skill_name="python-skill",
725 )
726 write_distribution_skill( 2p CbDbq EbFbr GbHb
727 site_packages,
728 dist_name="transitive-pkg",
729 package_dir="transitive_pkg",
730 skill_name="transitive-skill",
731 )
732 write_node_package_skill( 2p CbDbq EbFbr GbHb
733 project,
734 package_name="@scope/node-pkg",
735 skill_name="node-skill",
736 )
737 write_node_package_skill( 2p CbDbq EbFbr GbHb
738 project,
739 package_name="transitive-node-pkg",
740 skill_name="transitive-node-skill",
741 )
742 project.joinpath("package.json").write_text( 2p CbDbq EbFbr GbHb
743 json.dumps({"devDependencies": {"@scope/node-pkg": "^2.0.0"}}),
744 encoding="utf-8",
745 )
746 monkeypatch.chdir(project) 2p CbDbq EbFbr GbHb
748 result = runner.invoke(app, ["scan"]) 2p CbDbq EbFbr GbHb
749 all_result = runner.invoke(app, ["scan", "--all"]) 2p CbDbq EbFbr GbHb
750 json_result = runner.invoke(app, ["scan", "--json", "--all"]) 2p CbDbq EbFbr GbHb
752 assert result.exit_code == 0 2p CbDbq EbFbr GbHb
753 assert "Target Python environment" in result.output 2p CbDbq EbFbr GbHb
754 assert ".venv" in result.output 2p CbDbq EbFbr GbHb
755 assert "node_modules" in result.output 2p CbDbq EbFbr GbHb
756 assert "python-skill" in result.output 2p CbDbq EbFbr GbHb
757 assert "node-skill" in result.output 2p CbDbq EbFbr GbHb
758 assert "transitive-skill" not in result.output 2p CbDbq EbFbr GbHb
759 assert "transitive-node-skill" not in result.output 2p CbDbq EbFbr GbHb
760 assert all_result.exit_code == 0 2p CbDbq EbFbr GbHb
761 assert "transitive-skill" in all_result.output 2p CbDbq EbFbr GbHb
762 assert "transitive-node-skill" in all_result.output 2p CbDbq EbFbr GbHb
763 assert json_result.exit_code == 0 2p CbDbq EbFbr GbHb
764 payload = json.loads(json_result.output) 2p CbDbq EbFbr GbHb
765 assert payload["project_root"] == str(project) 2p CbDbq EbFbr GbHb
766 assert "installed" not in payload 2p CbDbq EbFbr GbHb
767 assert [skill["name"] for skill in payload["skills"]] == [ 2p CbDbq EbFbr GbHb
768 "python-skill",
769 "transitive-skill",
770 "node-skill",
771 "transitive-node-skill",
772 ]
775def test_install_skill_can_explicitly_select_transitive_dependency( 1abcdefghi
776 tmp_path, monkeypatch
777):
778 project = write_project(tmp_path, dependencies=["top-pkg>=1"]) 2UbOePeYbQeRe1bSeTe
779 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2UbOePeYbQeRe1bSeTe
780 transitive_skill = write_distribution_skill( 2UbOePeYbQeRe1bSeTe
781 site_packages,
782 dist_name="transitive-pkg",
783 package_dir="transitive_pkg",
784 skill_name="transitive-skill",
785 )
786 monkeypatch.chdir(project) 2UbOePeYbQeRe1bSeTe
788 result = runner.invoke(app, ["install", "--skill", "transitive-skill", "--yes"]) 2UbOePeYbQeRe1bSeTe
790 installed = project / ".agents" / "skills" / "transitive-skill" 2UbOePeYbQeRe1bSeTe
791 assert result.exit_code == 0 2UbOePeYbQeRe1bSeTe
792 assert installed.is_symlink() 2UbOePeYbQeRe1bSeTe
793 assert installed.resolve() == transitive_skill.resolve() 2UbOePeYbQeRe1bSeTe
796def test_yes_repairs_broken_managed_symlink_without_installing_new_skills( 1abcdefghi
797 tmp_path,
798 monkeypatch,
799):
800 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2E {b|bF }b~bG acbc
801 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2E {b|bF }b~bG acbc
802 repaired_skill, _new_skill = write_distribution( 2E {b|bF }b~bG acbc
803 site_packages,
804 dist_name="demo-pkg",
805 package_dir="demo_pkg",
806 skill_names=["repaired-skill", "new-skill"],
807 )
808 installed_dir = project / ".agents" / "skills" 2E {b|bF }b~bG acbc
809 installed_dir.mkdir(parents=True) 2E {b|bF }b~bG acbc
810 broken_link = installed_dir / "repaired-skill" 2E {b|bF }b~bG acbc
811 broken_link.symlink_to(project / "missing") 2E {b|bF }b~bG acbc
812 monkeypatch.chdir(project) 2E {b|bF }b~bG acbc
814 result = runner.invoke(app, ["--yes"]) 2E {b|bF }b~bG acbc
816 assert result.exit_code == 0 2E {b|bF }b~bG acbc
817 assert broken_link.is_symlink() 2E {b|bF }b~bG acbc
818 assert broken_link.resolve() == repaired_skill.resolve() 2E {b|bF }b~bG acbc
819 assert not (installed_dir / "new-skill").exists() 2E {b|bF }b~bG acbc
822def test_yes_removes_orphaned_and_unrecoverable_broken_symlinks( 1abcdefghi
823 tmp_path,
824 monkeypatch,
825):
826 project = write_project(tmp_path, dependencies=[]) 2Ob@b[bPb]b^bRb_b`b
827 installed_dir = project / ".agents" / "skills" 2Ob@b[bPb]b^bRb_b`b
828 installed_dir.mkdir(parents=True) 2Ob@b[bPb]b^bRb_b`b
829 orphan_target = project / "old-target" 2Ob@b[bPb]b^bRb_b`b
830 orphan_target.mkdir() 2Ob@b[bPb]b^bRb_b`b
831 orphan_link = installed_dir / "orphan-skill" 2Ob@b[bPb]b^bRb_b`b
832 orphan_link.symlink_to(orphan_target, target_is_directory=True) 2Ob@b[bPb]b^bRb_b`b
833 broken_link = installed_dir / "broken-skill" 2Ob@b[bPb]b^bRb_b`b
834 broken_link.symlink_to(project / "missing-target", target_is_directory=True) 2Ob@b[bPb]b^bRb_b`b
835 hand_authored = installed_dir / "hand-authored" 2Ob@b[bPb]b^bRb_b`b
836 hand_authored.mkdir() 2Ob@b[bPb]b^bRb_b`b
837 monkeypatch.chdir(project) 2Ob@b[bPb]b^bRb_b`b
839 result = runner.invoke(app, ["--yes"]) 2Ob@b[bPb]b^bRb_b`b
841 assert result.exit_code == 0 2Ob@b[bPb]b^bRb_b`b
842 assert "Removed orphaned symlink" in result.output 2Ob@b[bPb]b^bRb_b`b
843 assert "Removed broken symlink" in result.output 2Ob@b[bPb]b^bRb_b`b
844 assert not orphan_link.is_symlink() 2Ob@b[bPb]b^bRb_b`b
845 assert not broken_link.is_symlink() 2Ob@b[bPb]b^bRb_b`b
846 assert hand_authored.is_dir() 2Ob@b[bPb]b^bRb_b`b
849def test_yes_repairs_existing_claude_target_without_claude_flag( 1abcdefghi
850 tmp_path,
851 monkeypatch,
852):
853 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2- 7c8c? 9c!c` #c$c
854 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2- 7c8c? 9c!c` #c$c
855 repaired_skill = write_distribution_skill( 2- 7c8c? 9c!c` #c$c
856 site_packages,
857 dist_name="demo-pkg",
858 package_dir="demo_pkg",
859 skill_name="demo-skill",
860 )
861 claude_dir = project / ".claude" / "skills" 2- 7c8c? 9c!c` #c$c
862 claude_dir.mkdir(parents=True) 2- 7c8c? 9c!c` #c$c
863 installed = claude_dir / "demo-skill" 2- 7c8c? 9c!c` #c$c
864 installed.symlink_to(project / "missing-target", target_is_directory=True) 2- 7c8c? 9c!c` #c$c
865 monkeypatch.chdir(project) 2- 7c8c? 9c!c` #c$c
867 result = runner.invoke(app, ["--yes"]) 2- 7c8c? 9c!c` #c$c
869 assert result.exit_code == 0 2- 7c8c? 9c!c` #c$c
870 assert "claude-compatible" in result.output 2- 7c8c? 9c!c` #c$c
871 assert installed.is_symlink() 2- 7c8c? 9c!c` #c$c
872 assert installed.resolve() == repaired_skill.resolve() 2- 7c8c? 9c!c` #c$c
875def test_check_reports_fixable_drift_without_modifying_files(tmp_path, monkeypatch): 1abcdefghi
876 project = write_project(tmp_path, dependencies=[]) 2fcUeVeicWeXelcYeZe
877 installed_dir = project / ".agents" / "skills" 2fcUeVeicWeXelcYeZe
878 installed_dir.mkdir(parents=True) 2fcUeVeicWeXelcYeZe
879 broken_link = installed_dir / "broken-skill" 2fcUeVeicWeXelcYeZe
880 broken_link.symlink_to(project / "missing-target", target_is_directory=True) 2fcUeVeicWeXelcYeZe
881 monkeypatch.chdir(project) 2fcUeVeicWeXelcYeZe
883 result = runner.invoke(app, ["--check"]) 2fcUeVeicWeXelcYeZe
885 assert result.exit_code == 1 2fcUeVeicWeXelcYeZe
886 assert broken_link.is_symlink() 2fcUeVeicWeXelcYeZe
889def test_check_ignores_tool_skill_drift_unless_requested(tmp_path, monkeypatch): 1abcdefghi
890 project = write_project(tmp_path, dependencies=[]) 2md!f#frd$f%fwd'f(f
891 monkeypatch.chdir(project) 2md!f#frd$f%fwd'f(f
893 result = runner.invoke(app, ["--check"]) 2md!f#frd$f%fwd'f(f
895 assert result.exit_code == 0 2md!f#frd$f%fwd'f(f
896 assert not (project / ".agents" / "skills" / TOOL_SKILL_NAME).exists() 2md!f#frd$f%fwd'f(f
899def test_check_tool_skill_reports_missing_default_and_claude_targets( 1abcdefghi
900 tmp_path,
901 monkeypatch,
902):
903 project = write_project(tmp_path, dependencies=[]) 2VcufvfWcwfxfXcyfzf
904 monkeypatch.chdir(project) 2VcufvfWcwfxfXcyfzf
906 result = runner.invoke(app, ["--check", "--tool-skill", "--claude"]) 2VcufvfWcwfxfXcyfzf
908 assert result.exit_code == 1 2VcufvfWcwfxfXcyfzf
909 assert "tool skill: missing" in result.output 2VcufvfWcwfxfXcyfzf
910 assert str(Path(".agents") / "skills" / "library-skills") in result.output 2VcufvfWcwfxfXcyfzf
911 assert str(Path(".claude") / "skills" / "library-skills") in result.output 2VcufvfWcwfxfXcyfzf
914def test_tool_skill_installs_and_updates_copied_skill(tmp_path, monkeypatch): 1abcdefghi
915 project = write_project(tmp_path, dependencies=[]) 2;b%c'c=b(c)c?b*c+c
916 monkeypatch.chdir(project) 2;b%c'c=b(c)c?b*c+c
918 result = runner.invoke(app, ["--tool-skill"]) 2;b%c'c=b(c)c?b*c+c
920 installed = project / ".agents" / "skills" / TOOL_SKILL_NAME 2;b%c'c=b(c)c?b*c+c
921 assert result.exit_code == 0 2;b%c'c=b(c)c?b*c+c
922 assert installed.joinpath("SKILL.md").read_text(encoding="utf-8") == ( 2;b%c'c=b(c)c?b*c+c
923 get_tool_skill_template()
924 )
925 assert installed.joinpath(TOOL_SKILL_MARKER).is_file() 2;b%c'c=b(c)c?b*c+c
927 installed.joinpath("SKILL.md").write_text("stale", encoding="utf-8") 2;b%c'c=b(c)c?b*c+c
928 update_result = runner.invoke(app, ["--tool-skill"]) 2;b%c'c=b(c)c?b*c+c
930 assert update_result.exit_code == 0 2;b%c'c=b(c)c?b*c+c
931 assert "tool skill: stale" in update_result.output 2;b%c'c=b(c)c?b*c+c
932 assert "hand-authored" not in update_result.output 2;b%c'c=b(c)c?b*c+c
933 assert installed.joinpath("SKILL.md").read_text(encoding="utf-8") == ( 2;b%c'c=b(c)c?b*c+c
934 get_tool_skill_template()
935 )
938def test_no_tool_skill_skips_management(tmp_path, monkeypatch): 1abcdefghi
939 project = write_project(tmp_path, dependencies=[]) 2pd)f*fud+f,fzd-f.f
940 monkeypatch.chdir(project) 2pd)f*fud+f,fzd-f.f
942 result = runner.invoke(app, ["--no-tool-skill"]) 2pd)f*fud+f,fzd-f.f
944 assert result.exit_code == 0 2pd)f*fud+f,fzd-f.f
945 assert not (project / ".agents" / "skills" / TOOL_SKILL_NAME).exists() 2pd)f*fud+f,fzd-f.f
948def test_explicit_tool_skill_fails_on_hand_authored_directory(tmp_path, monkeypatch): 1abcdefghi
949 project = write_project(tmp_path, dependencies=[]) 2gc0e1ejc2e3emc4e5e
950 hand_authored = project / ".agents" / "skills" / TOOL_SKILL_NAME 2gc0e1ejc2e3emc4e5e
951 hand_authored.mkdir(parents=True) 2gc0e1ejc2e3emc4e5e
952 hand_authored.joinpath("SKILL.md").write_text("mine", encoding="utf-8") 2gc0e1ejc2e3emc4e5e
953 monkeypatch.chdir(project) 2gc0e1ejc2e3emc4e5e
955 result = runner.invoke(app, ["--tool-skill"]) 2gc0e1ejc2e3emc4e5e
957 assert result.exit_code == 1 2gc0e1ejc2e3emc4e5e
958 assert "blocked by hand-authored" in result.output 2gc0e1ejc2e3emc4e5e
959 assert hand_authored.joinpath("SKILL.md").read_text(encoding="utf-8") == "mine" 2gc0e1ejc2e3emc4e5e
962def test_default_sync_with_no_changes_reports_no_changes_needed( 1abcdefghi
963 tmp_path,
964 monkeypatch,
965):
966 project = write_project(tmp_path, dependencies=[]) 24c4f5f5c6f7f6c8f9f
967 monkeypatch.chdir(project) 24c4f5f5c6f7f6c8f9f
969 with patch.object(cli, "_select_tool_skill_interactive", lambda: False): 24c4f5f5c6f7f6c8f9f
970 result = runner.invoke(app) 24c4f5f5c6f7f6c8f9f
972 assert result.exit_code == 0 24c4f5f5c6f7f6c8f9f
973 assert "No changes needed." in result.output 24c4f5f5c6f7f6c8f9f
976def test_default_sync_prompts_for_missing_tool_skill_without_new_skills( 1abcdefghi
977 tmp_path,
978 monkeypatch,
979):
980 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2{ NdOdbbPdQdhbRdSd
981 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2{ NdOdbbPdQdhbRdSd
982 skill_dir = write_distribution_skill( 2{ NdOdbbPdQdhbRdSd
983 site_packages,
984 dist_name="demo-pkg",
985 package_dir="demo_pkg",
986 skill_name="demo-skill",
987 )
988 installed_dir = project / ".agents" / "skills" 2{ NdOdbbPdQdhbRdSd
989 installed_dir.mkdir(parents=True) 2{ NdOdbbPdQdhbRdSd
990 (installed_dir / "demo-skill").symlink_to(skill_dir, target_is_directory=True) 2{ NdOdbbPdQdhbRdSd
991 monkeypatch.chdir(project) 2{ NdOdbbPdQdhbRdSd
993 with patch.object(cli, "_select_tool_skill_interactive", lambda: True): 2{ NdOdbbPdQdhbRdSd
994 result = runner.invoke(app) 2{ NdOdbbPdQdhbRdSd
996 tool_skill = project / ".agents" / "skills" / TOOL_SKILL_NAME 2{ NdOdbbPdQdhbRdSd
997 assert result.exit_code == 0 2{ NdOdbbPdQdhbRdSd
998 assert tool_skill.joinpath("SKILL.md").read_text(encoding="utf-8") == ( 2{ NdOdbbPdQdhbRdSd
999 get_tool_skill_template()
1000 )
1003def test_list_json_reports_discovered_and_installed_skills(tmp_path, monkeypatch): 1abcdefghi
1004 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2+ ,c-c; .c/c^ :c;c
1005 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2+ ,c-c; .c/c^ :c;c
1006 skill_dir = write_distribution_skill( 2+ ,c-c; .c/c^ :c;c
1007 site_packages,
1008 dist_name="demo-pkg",
1009 package_dir="demo_pkg",
1010 skill_name="demo-skill",
1011 )
1012 installed_dir = project / ".agents" / "skills" 2+ ,c-c; .c/c^ :c;c
1013 installed_dir.mkdir(parents=True) 2+ ,c-c; .c/c^ :c;c
1014 (installed_dir / "demo-skill").symlink_to(skill_dir, target_is_directory=True) 2+ ,c-c; .c/c^ :c;c
1015 monkeypatch.chdir(project) 2+ ,c-c; .c/c^ :c;c
1017 with patch.object(cli, "console", Console(width=1000)): 2+ ,c-c; .c/c^ :c;c
1018 result = runner.invoke(app, ["list", "--json"]) 2+ ,c-c; .c/c^ :c;c
1020 payload = json.loads(result.output) 2+ ,c-c; .c/c^ :c;c
1021 assert result.exit_code == 0 2+ ,c-c; .c/c^ :c;c
1022 assert payload["skills"][0]["name"] == "demo-skill" 2+ ,c-c; .c/c^ :c;c
1023 assert payload["installed"][0]["status"] == "up to date" 2+ ,c-c; .c/c^ :c;c
1026def test_list_installed_prints_no_skills_when_target_is_empty(tmp_path, monkeypatch): 1abcdefghi
1027 project = write_project(tmp_path, dependencies=[]) 2od/f:ftd;f=fyd?f@f
1028 monkeypatch.chdir(project) 2od/f:ftd;f=fyd?f@f
1030 result = runner.invoke(app, ["list", "--installed"]) 2od/f:ftd;f=fyd?f@f
1032 assert result.exit_code == 0 2od/f:ftd;f=fyd?f@f
1033 assert "No skills installed." in result.output 2od/f:ftd;f=fyd?f@f
1036def test_list_installed_prints_installed_statuses(tmp_path, monkeypatch): 1abcdefghi
1037 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2* =c?c: @c[c] ]c^c
1038 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2* =c?c: @c[c] ]c^c
1039 skill_dir = write_distribution_skill( 2* =c?c: @c[c] ]c^c
1040 site_packages,
1041 dist_name="demo-pkg",
1042 package_dir="demo_pkg",
1043 skill_name="demo-skill",
1044 )
1045 installed_dir = project / ".agents" / "skills" 2* =c?c: @c[c] ]c^c
1046 installed_dir.mkdir(parents=True) 2* =c?c: @c[c] ]c^c
1047 (installed_dir / "demo-skill").symlink_to(skill_dir, target_is_directory=True) 2* =c?c: @c[c] ]c^c
1048 monkeypatch.chdir(project) 2* =c?c: @c[c] ]c^c
1050 with patch.object(cli, "console", Console(width=1000)): 2* =c?c: @c[c] ]c^c
1051 result = runner.invoke(app, ["list", "--installed"]) 2* =c?c: @c[c] ]c^c
1053 assert result.exit_code == 0 2* =c?c: @c[c] ]c^c
1054 assert "up to date" in result.output 2* =c?c: @c[c] ]c^c
1055 assert ".agents" in result.output 2* =c?c: @c[c] ]c^c
1056 assert str(project / ".agents") not in result.output 2* =c?c: @c[c] ]c^c
1059def test_install_all_copy_mode_installs_to_agents_and_claude(tmp_path, monkeypatch): 1abcdefghi
1060 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2| TdUdcbVdWdibXdYd
1061 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2| TdUdcbVdWdibXdYd
1062 write_distribution_skill( 2| TdUdcbVdWdibXdYd
1063 site_packages,
1064 dist_name="demo-pkg",
1065 package_dir="demo_pkg",
1066 skill_name="demo-skill",
1067 )
1068 monkeypatch.chdir(project) 2| TdUdcbVdWdibXdYd
1070 result = runner.invoke(app, ["install", "--all", "--yes", "--copy", "--claude"]) 2| TdUdcbVdWdibXdYd
1072 agents_skill = project / ".agents" / "skills" / "demo-skill" 2| TdUdcbVdWdibXdYd
1073 claude_skill = project / ".claude" / "skills" / "demo-skill" 2| TdUdcbVdWdibXdYd
1074 assert result.exit_code == 0 2| TdUdcbVdWdibXdYd
1075 assert agents_skill.is_dir() 2| TdUdcbVdWdibXdYd
1076 assert not agents_skill.is_symlink() 2| TdUdcbVdWdibXdYd
1077 assert claude_skill.is_dir() 2| TdUdcbVdWdibXdYd
1078 assert not claude_skill.is_symlink() 2| TdUdcbVdWdibXdYd
1081def test_install_interactive_can_choose_claude_only(tmp_path, monkeypatch): 1abcdefghi
1082 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2tbbecewbdeeejbfege
1083 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2tbbecewbdeeejbfege
1084 skill_dir = write_distribution_skill( 2tbbecewbdeeejbfege
1085 site_packages,
1086 dist_name="demo-pkg",
1087 package_dir="demo_pkg",
1088 skill_name="demo-skill",
1089 )
1090 monkeypatch.chdir(project) 2tbbecewbdeeejbfege
1092 with ( 2jb
1093 patch.object(cli, "_select_skills_interactive", lambda skills: skills),
1094 patch.object(
1095 cli,
1096 "_select_targets_interactive",
1097 lambda project_root, default_targets: [
1098 cli.InstallTarget(
1099 "claude-compatible", project_root / ".claude" / "skills"
1100 )
1101 ],
1102 ),
1103 ):
1104 result = runner.invoke(app, ["install"]) 2tbbecewbdeeejbfege
1106 agents_skill = project / ".agents" / "skills" / "demo-skill" 2tbbecewbdeeejbfege
1107 claude_skill = project / ".claude" / "skills" / "demo-skill" 2tbbecewbdeeejbfege
1108 assert result.exit_code == 0 2tbbecewbdeeejbfege
1109 assert not agents_skill.exists() 2tbbecewbdeeejbfege
1110 assert claude_skill.is_symlink() 2tbbecewbdeeejbfege
1111 assert claude_skill.resolve() == skill_dir.resolve() 2tbbecewbdeeejbfege
1114def test_install_interactive_can_choose_both_targets(tmp_path, monkeypatch): 1abcdefghi
1115 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2} Zd0ddb1d2d@ 3d4d
1116 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2} Zd0ddb1d2d@ 3d4d
1117 skill_dir = write_distribution_skill( 2} Zd0ddb1d2d@ 3d4d
1118 site_packages,
1119 dist_name="demo-pkg",
1120 package_dir="demo_pkg",
1121 skill_name="demo-skill",
1122 )
1123 monkeypatch.chdir(project) 2} Zd0ddb1d2d@ 3d4d
1125 with ( 1@
1126 patch.object(cli, "_select_skills_interactive", lambda skills: skills),
1127 patch.object(
1128 cli,
1129 "_select_targets_interactive",
1130 lambda project_root, default_targets: cli.get_all_target_dirs(project_root),
1131 ),
1132 ):
1133 result = runner.invoke(app, ["install"]) 2} Zd0ddb1d2d@ 3d4d
1135 agents_skill = project / ".agents" / "skills" / "demo-skill" 2} Zd0ddb1d2d@ 3d4d
1136 claude_skill = project / ".claude" / "skills" / "demo-skill" 2} Zd0ddb1d2d@ 3d4d
1137 assert result.exit_code == 0 2} Zd0ddb1d2d@ 3d4d
1138 assert agents_skill.is_symlink() 2} Zd0ddb1d2d@ 3d4d
1139 assert agents_skill.resolve() == skill_dir.resolve() 2} Zd0ddb1d2d@ 3d4d
1140 assert claude_skill.is_symlink() 2} Zd0ddb1d2d@ 3d4d
1141 assert claude_skill.resolve() == skill_dir.resolve() 2} Zd0ddb1d2d@ 3d4d
1144def test_install_with_no_selected_targets_cancels(tmp_path, monkeypatch): 1abcdefghi
1145 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2Vb6e7eZb8e9e2b!e#e
1146 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2Vb6e7eZb8e9e2b!e#e
1147 write_distribution_skill( 2Vb6e7eZb8e9e2b!e#e
1148 site_packages,
1149 dist_name="demo-pkg",
1150 package_dir="demo_pkg",
1151 skill_name="demo-skill",
1152 )
1153 monkeypatch.chdir(project) 2Vb6e7eZb8e9e2b!e#e
1155 with patch.object( 2Vb6e7eZb8e9e2b!e#e
1156 cli, "_select_targets_interactive", lambda project_root, default_targets: []
1157 ):
1158 result = runner.invoke(app, ["install", "--all"]) 2Vb6e7eZb8e9e2b!e#e
1160 assert result.exit_code == 0 2Vb6e7eZb8e9e2b!e#e
1161 assert "No installation targets selected." in result.output 2Vb6e7eZb8e9e2b!e#e
1162 assert not (project / ".agents" / "skills" / "demo-skill").exists() 2Vb6e7eZb8e9e2b!e#e
1165def test_default_sync_with_no_selected_targets_cancels(tmp_path, monkeypatch): 1abcdefghi
1166 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2Sb$e%eWb'e(e0b)e*e
1167 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2Sb$e%eWb'e(e0b)e*e
1168 write_distribution_skill( 2Sb$e%eWb'e(e0b)e*e
1169 site_packages,
1170 dist_name="demo-pkg",
1171 package_dir="demo_pkg",
1172 skill_name="demo-skill",
1173 )
1174 monkeypatch.chdir(project) 2Sb$e%eWb'e(e0b)e*e
1176 with patch.object( 2Sb$e%eWb'e(e0b)e*e
1177 cli, "_select_targets_interactive", lambda project_root, default_targets: []
1178 ):
1179 result = runner.invoke(app, ["--all"]) 2Sb$e%eWb'e(e0b)e*e
1181 assert result.exit_code == 0 2Sb$e%eWb'e(e0b)e*e
1182 assert "No installation targets selected." in result.output 2Sb$e%eWb'e(e0b)e*e
1183 assert not (project / ".agents" / "skills" / "demo-skill").exists() 2Sb$e%eWb'e(e0b)e*e
1186def test_install_without_selection_and_yes_prints_no_skills_selected( 1abcdefghi
1187 tmp_path,
1188 monkeypatch,
1189):
1190 project = write_project(tmp_path, dependencies=[]) 2nd[f]fsd^f_fxd`f{f
1191 monkeypatch.chdir(project) 2nd[f]fsd^f_fxd`f{f
1193 result = runner.invoke(app, ["install", "--yes"]) 2nd[f]fsd^f_fxd`f{f
1195 assert result.exit_code == 0 2nd[f]fsd^f_fxd`f{f
1196 assert "No skills selected." in result.output 2nd[f]fsd^f_fxd`f{f
1199def test_remove_named_symlink(tmp_path, monkeypatch): 1abcdefghi
1200 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2ab5d6dfb7d8dlb9d!d
1201 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2ab5d6dfb7d8dlb9d!d
1202 skill_dir = write_distribution_skill( 2ab5d6dfb7d8dlb9d!d
1203 site_packages,
1204 dist_name="demo-pkg",
1205 package_dir="demo_pkg",
1206 skill_name="demo-skill",
1207 )
1208 installed_dir = project / ".agents" / "skills" 2ab5d6dfb7d8dlb9d!d
1209 installed_dir.mkdir(parents=True) 2ab5d6dfb7d8dlb9d!d
1210 installed = installed_dir / "demo-skill" 2ab5d6dfb7d8dlb9d!d
1211 installed.symlink_to(skill_dir, target_is_directory=True) 2ab5d6dfb7d8dlb9d!d
1212 monkeypatch.chdir(project) 2ab5d6dfb7d8dlb9d!d
1214 result = runner.invoke(app, ["remove", "demo-skill"]) 2ab5d6dfb7d8dlb9d!d
1216 assert result.exit_code == 0 2ab5d6dfb7d8dlb9d!d
1217 assert "Removed:" in result.output 2ab5d6dfb7d8dlb9d!d
1218 assert not installed.exists() 2ab5d6dfb7d8dlb9d!d
1221def test_list_installed_includes_existing_claude_target_without_flag( 1abcdefghi
1222 tmp_path, monkeypatch
1223):
1224 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2) _c`c/ {c|c[ }c~c
1225 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2) _c`c/ {c|c[ }c~c
1226 skill_dir = write_distribution_skill( 2) _c`c/ {c|c[ }c~c
1227 site_packages,
1228 dist_name="demo-pkg",
1229 package_dir="demo_pkg",
1230 skill_name="demo-skill",
1231 )
1232 claude_dir = project / ".claude" / "skills" 2) _c`c/ {c|c[ }c~c
1233 claude_dir.mkdir(parents=True) 2) _c`c/ {c|c[ }c~c
1234 (claude_dir / "demo-skill").symlink_to(skill_dir, target_is_directory=True) 2) _c`c/ {c|c[ }c~c
1235 monkeypatch.chdir(project) 2) _c`c/ {c|c[ }c~c
1237 with patch.object(cli, "console", Console(width=1000)): 2) _c`c/ {c|c[ }c~c
1238 result = runner.invoke(app, ["list", "--installed"]) 2) _c`c/ {c|c[ }c~c
1240 assert result.exit_code == 0 2) _c`c/ {c|c[ }c~c
1241 assert "claude-compatible" in result.output 2) _c`c/ {c|c[ }c~c
1242 assert ".claude" in result.output 2) _c`c/ {c|c[ }c~c
1243 assert "up to date" in result.output 2) _c`c/ {c|c[ }c~c
1246def test_remove_includes_existing_claude_target_without_flag(tmp_path, monkeypatch): 1abcdefghi
1247 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2, adbd= cddd_ edfd
1248 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2, adbd= cddd_ edfd
1249 skill_dir = write_distribution_skill( 2, adbd= cddd_ edfd
1250 site_packages,
1251 dist_name="demo-pkg",
1252 package_dir="demo_pkg",
1253 skill_name="demo-skill",
1254 )
1255 claude_dir = project / ".claude" / "skills" 2, adbd= cddd_ edfd
1256 claude_dir.mkdir(parents=True) 2, adbd= cddd_ edfd
1257 installed = claude_dir / "demo-skill" 2, adbd= cddd_ edfd
1258 installed.symlink_to(skill_dir, target_is_directory=True) 2, adbd= cddd_ edfd
1259 monkeypatch.chdir(project) 2, adbd= cddd_ edfd
1261 result = runner.invoke(app, ["remove", "demo-skill"]) 2, adbd= cddd_ edfd
1263 assert result.exit_code == 0 2, adbd= cddd_ edfd
1264 assert "Removed:" in result.output 2, adbd= cddd_ edfd
1265 assert "claude-compatible" in result.output 2, adbd= cddd_ edfd
1266 assert not installed.exists() 2, adbd= cddd_ edfd
1269def test_list_installed_and_remove_do_not_create_missing_target_dirs( 1abcdefghi
1270 tmp_path, monkeypatch
1271):
1272 project = write_project(tmp_path, dependencies=[]) 2hc+e,ekc-e.enc/e:e
1273 (project / ".claude").mkdir() 2hc+e,ekc-e.enc/e:e
1274 monkeypatch.chdir(project) 2hc+e,ekc-e.enc/e:e
1276 list_result = runner.invoke(app, ["list", "--installed"]) 2hc+e,ekc-e.enc/e:e
1277 remove_result = runner.invoke(app, ["remove", "--yes"]) 2hc+e,ekc-e.enc/e:e
1279 assert list_result.exit_code == 0 2hc+e,ekc-e.enc/e:e
1280 assert remove_result.exit_code == 0 2hc+e,ekc-e.enc/e:e
1281 assert not (project / ".agents" / "skills").exists() 2hc+e,ekc-e.enc/e:e
1282 assert not (project / ".claude" / "skills").exists() 2hc+e,ekc-e.enc/e:e
1285def test_remove_with_yes_and_no_names_does_not_prompt(tmp_path, monkeypatch): 1abcdefghi
1286 project = write_project(tmp_path, dependencies=[]) 2qd|f}fvd~fagAdbgcg
1287 monkeypatch.chdir(project) 2qd|f}fvd~fagAdbgcg
1289 result = runner.invoke(app, ["remove", "--yes"]) 2qd|f}fvd~fagAdbgcg
1291 assert result.exit_code == 0 2qd|f}fvd~fagAdbgcg
1292 assert "No skills selected." in result.output 2qd|f}fvd~fagAdbgcg
1295def test_check_exits_non_zero_for_colliding_skill_names(tmp_path, monkeypatch): 1abcdefghi
1296 project = write_project(tmp_path, dependencies=["first-pkg>=1", "second-pkg>=1"]) 2$bAfBf'bCfDf)bEfFf
1297 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2$bAfBf'bCfDf)bEfFf
1298 write_distribution_skill( 2$bAfBf'bCfDf)bEfFf
1299 site_packages,
1300 dist_name="first-pkg",
1301 package_dir="first_pkg",
1302 skill_name="duplicate-skill",
1303 )
1304 write_distribution_skill( 2$bAfBf'bCfDf)bEfFf
1305 site_packages,
1306 dist_name="second-pkg",
1307 package_dir="second_pkg",
1308 skill_name="duplicate-skill",
1309 )
1310 monkeypatch.chdir(project) 2$bAfBf'bCfDf)bEfFf
1312 result = runner.invoke(app, ["--check"]) 2$bAfBf'bCfDf)bEfFf
1314 assert result.exit_code == 1 2$bAfBf'bCfDf)bEfFf
1317def test_scan_without_target_environment_prints_warning(tmp_path, monkeypatch): 1abcdefghi
1318 project = tmp_path / "project" 2heiejekelemeneoepe
1319 project.mkdir() 2heiejekelemeneoepe
1320 monkeypatch.chdir(project) 2heiejekelemeneoepe
1321 monkeypatch.delenv("UV_PROJECT_ENVIRONMENT", raising=False) 2heiejekelemeneoepe
1322 monkeypatch.delenv("VIRTUAL_ENV", raising=False) 2heiejekelemeneoepe
1323 monkeypatch.delenv("CONDA_PREFIX", raising=False) 2heiejekelemeneoepe
1325 result = runner.invoke(app, ["scan"]) 2heiejekelemeneoepe
1327 assert result.exit_code == 0 2heiejekelemeneoepe
1328 assert "Run from a project root after installing dependencies" in result.output 2heiejekelemeneoepe
1329 assert "uv sync" in result.output 2heiejekelemeneoepe
1330 assert "npm install" in result.output 2heiejekelemeneoepe
1333def test_default_check_without_environment_reports_no_discovered_skills( 1abcdefghi
1334 tmp_path,
1335 monkeypatch,
1336):
1337 project = tmp_path / "project" 2;e=e?e@e[e]e^e_e`e
1338 project.mkdir() 2;e=e?e@e[e]e^e_e`e
1339 monkeypatch.chdir(project) 2;e=e?e@e[e]e^e_e`e
1340 monkeypatch.delenv("UV_PROJECT_ENVIRONMENT", raising=False) 2;e=e?e@e[e]e^e_e`e
1341 monkeypatch.delenv("VIRTUAL_ENV", raising=False) 2;e=e?e@e[e]e^e_e`e
1342 monkeypatch.delenv("CONDA_PREFIX", raising=False) 2;e=e?e@e[e]e^e_e`e
1344 result = runner.invoke(app, ["--check"]) 2;e=e?e@e[e]e^e_e`e
1346 assert result.exit_code == 0 2;e=e?e@e[e]e^e_e`e
1347 assert "No installed or discovered skills found." in result.output 2;e=e?e@e[e]e^e_e`e
1350def test_list_without_installed_option_prints_discovered_skills(tmp_path, monkeypatch): 1abcdefghi
1351 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2%bGfHf(bIfJf*bKfLf
1352 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2%bGfHf(bIfJf*bKfLf
1353 write_distribution_skill( 2%bGfHf(bIfJf*bKfLf
1354 site_packages,
1355 dist_name="demo-pkg",
1356 package_dir="demo_pkg",
1357 skill_name="demo-skill",
1358 )
1359 monkeypatch.chdir(project) 2%bGfHf(bIfJf*bKfLf
1361 result = runner.invoke(app, ["list"]) 2%bGfHf(bIfJf*bKfLf
1363 assert result.exit_code == 0 2%bGfHf(bIfJf*bKfLf
1364 assert "demo-skill" in result.output 2%bGfHf(bIfJf*bKfLf
1367def test_install_interactive_selection_installs_selected_skill( 1abcdefghi
1368 tmp_path,
1369 monkeypatch,
1370):
1371 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2Tb{e|eXb}e~eQbafbf
1372 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2Tb{e|eXb}e~eQbafbf
1373 skill_dir = write_distribution_skill( 2Tb{e|eXb}e~eQbafbf
1374 site_packages,
1375 dist_name="demo-pkg",
1376 package_dir="demo_pkg",
1377 skill_name="demo-skill",
1378 )
1379 monkeypatch.chdir(project) 2Tb{e|eXb}e~eQbafbf
1381 with ( 2Qb
1382 patch.object(cli, "_select_skills_interactive", lambda skills: skills),
1383 patch.object(
1384 cli,
1385 "_select_targets_interactive",
1386 lambda project_root, default_targets: default_targets,
1387 ),
1388 ):
1389 result = runner.invoke(app, ["install"]) 2Tb{e|eXb}e~eQbafbf
1391 installed = project / ".agents" / "skills" / "demo-skill" 2Tb{e|eXb}e~eQbafbf
1392 assert result.exit_code == 0 2Tb{e|eXb}e~eQbafbf
1393 assert installed.is_symlink() 2Tb{e|eXb}e~eQbafbf
1394 assert installed.resolve() == skill_dir.resolve() 2Tb{e|eXb}e~eQbafbf
1397def test_default_command_interactive_selection_installs_selected_skill( 1abcdefghi
1398 tmp_path,
1399 monkeypatch,
1400):
1401 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2( gdhd. idjd8 kdld
1402 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2( gdhd. idjd8 kdld
1403 skill_dir = write_distribution_skill( 2( gdhd. idjd8 kdld
1404 site_packages,
1405 dist_name="demo-pkg",
1406 package_dir="demo_pkg",
1407 skill_name="demo-skill",
1408 )
1409 monkeypatch.chdir(project) 2( gdhd. idjd8 kdld
1411 with ( 18
1412 patch.object(cli, "_select_skills_interactive", lambda skills: skills),
1413 patch.object(
1414 cli,
1415 "_select_targets_interactive",
1416 lambda project_root, default_targets: default_targets,
1417 ),
1418 patch.object(cli, "_select_tool_skill_interactive", lambda: True),
1419 ):
1420 result = runner.invoke(app) 2( gdhd. idjd8 kdld
1422 installed = project / ".agents" / "skills" / "demo-skill" 2( gdhd. idjd8 kdld
1423 tool_skill = project / ".agents" / "skills" / TOOL_SKILL_NAME 2( gdhd. idjd8 kdld
1424 assert result.exit_code == 0 2( gdhd. idjd8 kdld
1425 assert "Installed 1 skill target(s)." in result.output 2( gdhd. idjd8 kdld
1426 assert "can be committed to Git" in result.output 2( gdhd. idjd8 kdld
1427 assert installed.is_symlink() 2( gdhd. idjd8 kdld
1428 assert installed.resolve() == skill_dir.resolve() 2( gdhd. idjd8 kdld
1429 assert tool_skill.joinpath("SKILL.md").is_file() 2( gdhd. idjd8 kdld
1432def test_default_command_copy_installs_selected_skill_as_directory( 1abcdefghi
1433 tmp_path,
1434 monkeypatch,
1435):
1436 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2sbqerevbsetegbueve
1437 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2sbqerevbsetegbueve
1438 write_distribution_skill( 2sbqerevbsetegbueve
1439 site_packages,
1440 dist_name="demo-pkg",
1441 package_dir="demo_pkg",
1442 skill_name="demo-skill",
1443 )
1444 monkeypatch.chdir(project) 2sbqerevbsetegbueve
1446 with ( 2gb
1447 patch.object(cli, "_select_skills_interactive", lambda skills: skills),
1448 patch.object(
1449 cli,
1450 "_select_targets_interactive",
1451 lambda project_root, default_targets: default_targets,
1452 ),
1453 patch.object(cli, "_select_tool_skill_interactive", lambda: False),
1454 ):
1455 result = runner.invoke(app, ["--copy"]) 2sbqerevbsetegbueve
1457 installed = project / ".agents" / "skills" / "demo-skill" 2sbqerevbsetegbueve
1458 assert result.exit_code == 0 2sbqerevbsetegbueve
1459 assert "Installed 1 skill target(s)." in result.output 2sbqerevbsetegbueve
1460 assert "can be committed to Git" not in result.output 2sbqerevbsetegbueve
1461 assert installed.is_dir() 2sbqerevbsetegbueve
1462 assert not installed.is_symlink() 2sbqerevbsetegbueve
1465def test_default_command_deduplicates_new_skills_across_targets( 1abcdefghi
1466 tmp_path,
1467 monkeypatch,
1468):
1469 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 23 GcHc4 IcJcT KcLc
1470 (project / ".agents").mkdir() 23 GcHc4 IcJcT KcLc
1471 (project / ".claude").mkdir() 23 GcHc4 IcJcT KcLc
1472 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 23 GcHc4 IcJcT KcLc
1473 skill_dir = write_distribution_skill( 23 GcHc4 IcJcT KcLc
1474 site_packages,
1475 dist_name="demo-pkg",
1476 package_dir="demo_pkg",
1477 skill_name="demo-skill",
1478 )
1479 prompt_counts: list[int] = [] 23 GcHc4 IcJcT KcLc
1480 monkeypatch.chdir(project) 23 GcHc4 IcJcT KcLc
1482 def select_skills(skills: list[cli.Skill]) -> list[cli.Skill]: 23 GcHc4 IcJcT KcLc
1483 prompt_counts.append(len(skills)) 23 GcHc4 IcJcT KcLc
1484 return skills 23 GcHc4 IcJcT KcLc
1486 with ( 1T
1487 patch.object(cli, "_select_skills_interactive", select_skills),
1488 patch.object(
1489 cli,
1490 "_select_targets_interactive",
1491 lambda project_root, default_targets: default_targets,
1492 ),
1493 patch.object(cli, "_select_tool_skill_interactive", lambda: False),
1494 ):
1495 result = runner.invoke(app) 23 GcHc4 IcJcT KcLc
1497 assert result.exit_code == 0 23 GcHc4 IcJcT KcLc
1498 assert prompt_counts == [1] 23 GcHc4 IcJcT KcLc
1499 assert (project / ".agents" / "skills" / "demo-skill").resolve() == skill_dir 23 GcHc4 IcJcT KcLc
1500 assert (project / ".claude" / "skills" / "demo-skill").resolve() == skill_dir 23 GcHc4 IcJcT KcLc
1503def test_remove_interactive_selection_removes_selected_skill(tmp_path, monkeypatch): 1abcdefghi
1504 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2~ #d$deb%d'dkb(d)d
1505 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2~ #d$deb%d'dkb(d)d
1506 skill_dir = write_distribution_skill( 2~ #d$deb%d'dkb(d)d
1507 site_packages,
1508 dist_name="demo-pkg",
1509 package_dir="demo_pkg",
1510 skill_name="demo-skill",
1511 )
1512 installed_dir = project / ".agents" / "skills" 2~ #d$deb%d'dkb(d)d
1513 installed_dir.mkdir(parents=True) 2~ #d$deb%d'dkb(d)d
1514 installed = installed_dir / "demo-skill" 2~ #d$deb%d'dkb(d)d
1515 installed.symlink_to(skill_dir, target_is_directory=True) 2~ #d$deb%d'dkb(d)d
1516 monkeypatch.chdir(project) 2~ #d$deb%d'dkb(d)d
1518 with patch.object( 2~ #d$deb%d'dkb(d)d
1519 cli, "_select_installed_skills_interactive", lambda statuses: statuses
1520 ):
1521 result = runner.invoke(app, ["remove"]) 2~ #d$deb%d'dkb(d)d
1523 assert result.exit_code == 0 2~ #d$deb%d'dkb(d)d
1524 assert not installed.exists() 2~ #d$deb%d'dkb(d)d
1527def test_remove_prints_not_found_when_uninstall_returns_false(tmp_path, monkeypatch): 1abcdefghi
1528 project = write_project(tmp_path, dependencies=["demo-pkg>=1"]) 2ubwexexbyezeybAeBe
1529 site_packages = project / ".venv" / "lib" / "python3.12" / "site-packages" 2ubwexexbyezeybAeBe
1530 skill_dir = write_distribution_skill( 2ubwexexbyezeybAeBe
1531 site_packages,
1532 dist_name="demo-pkg",
1533 package_dir="demo_pkg",
1534 skill_name="demo-skill",
1535 )
1536 installed_dir = project / ".agents" / "skills" 2ubwexexbyezeybAeBe
1537 installed_dir.mkdir(parents=True) 2ubwexexbyezeybAeBe
1538 (installed_dir / "demo-skill").symlink_to(skill_dir, target_is_directory=True) 2ubwexexbyezeybAeBe
1539 monkeypatch.chdir(project) 2ubwexexbyezeybAeBe
1541 with patch.object(cli, "uninstall_skill", lambda skill_name, target_dir: False): 2ubwexexbyezeybAeBe
1542 result = runner.invoke(app, ["remove", "demo-skill"]) 2ubwexexbyezeybAeBe
1544 assert result.exit_code == 0 2ubwexexbyezeybAeBe
1545 assert "Not found:" in result.output 2ubwexexbyezeybAeBe
1548def test_installed_statuses_classifies_name_mismatch_outdated_and_orphaned(tmp_path): 1abcdefghi
1549 skill = make_cli_skill(tmp_path) 23b4b5b6b7b8b9b!b#b
1550 target = cli.InstallTarget("universal", tmp_path / ".agents" / "skills") 23b4b5b6b7b8b9b!b#b
1551 target.path.mkdir(parents=True) 23b4b5b6b7b8b9b!b#b
1552 (target.path / "wrong-name").symlink_to(skill.skill_dir, target_is_directory=True) 23b4b5b6b7b8b9b!b#b
1553 old_target = tmp_path / "old-target" 23b4b5b6b7b8b9b!b#b
1554 old_target.mkdir() 23b4b5b6b7b8b9b!b#b
1555 (target.path / skill.name).symlink_to(old_target, target_is_directory=True) 23b4b5b6b7b8b9b!b#b
1556 (target.path / "broken-skill").symlink_to( 23b4b5b6b7b8b9b!b#b
1557 tmp_path / "missing-target", target_is_directory=True
1558 )
1559 orphan_target = tmp_path / "orphan-target" 23b4b5b6b7b8b9b!b#b
1560 orphan_target.mkdir() 23b4b5b6b7b8b9b!b#b
1561 (target.path / "orphan-skill").symlink_to(orphan_target, target_is_directory=True) 23b4b5b6b7b8b9b!b#b
1562 hand_authored = target.path / "hand-authored" 23b4b5b6b7b8b9b!b#b
1563 hand_authored.mkdir() 23b4b5b6b7b8b9b!b#b
1565 statuses = { 23b4b5b6b7b8b9b!b#b
1566 status.name: status.status
1567 for status in cli._installed_statuses(targets=[target], skills=[skill])
1568 }
1570 assert statuses["wrong-name"] == "name mismatch" 23b4b5b6b7b8b9b!b#b
1571 assert statuses[skill.name] == "outdated" 23b4b5b6b7b8b9b!b#b
1572 assert statuses["broken-skill"] == "broken" 23b4b5b6b7b8b9b!b#b
1573 assert statuses["orphan-skill"] == "orphaned" 23b4b5b6b7b8b9b!b#b
1574 assert statuses["hand-authored"] == "hand-authored" 23b4b5b6b7b8b9b!b#b
1577def test_filter_installable_skills_skips_collisions(tmp_path): 1abcdefghi
1578 first = make_cli_skill(tmp_path / "first", name="duplicate-skill") 2IedgegKefgggMehgig
1579 second = make_cli_skill(tmp_path / "second", name="duplicate-skill") 2IedgegKefgggMehgig
1581 selected = cli._filter_installable_skills( 2IedgegKefgggMehgig
1582 [first, second],
1583 selected_names=[],
1584 include_all=True,
1585 )
1587 assert selected == [] 2IedgegKefgggMehgig
1590def test_select_helpers_use_rich_toolkit(monkeypatch, tmp_path, capsys): 1abcdefghi
1591 skill = make_cli_skill(tmp_path) 2cc?d@ddc[d]dec^d_d
1592 target = cli.InstallTarget("universal", tmp_path / ".agents" / "skills") 2cc?d@ddc[d]dec^d_d
1593 status = cli.InstalledStatus( 2cc?d@ddc[d]dec^d_d
1594 target=target,
1595 name=skill.name,
1596 type="symlink",
1597 path=target.path / skill.name,
1598 target_path=skill.skill_dir,
1599 status="up to date",
1600 skill=skill,
1601 )
1602 monkeypatch.setattr(cli, "_get_rich_toolkit", lambda: FakeToolkit([skill])) 2cc?d@ddc[d]dec^d_d
1604 assert cli._select_skills_interactive([skill]) == [skill] 2cc?d@ddc[d]dec^d_d
1605 assert "install" in capsys.readouterr().out 2cc?d@ddc[d]dec^d_d
1607 monkeypatch.setattr(cli, "_get_rich_toolkit", lambda: FakeToolkit([status])) 2cc?d@ddc[d]dec^d_d
1609 assert cli._select_installed_skills_interactive([status]) == [status] 2cc?d@ddc[d]dec^d_d
1610 assert ( 2cc?d@ddc[d]dec^d_d
1611 cli._select_installed_skills_interactive(
1612 [
1613 cli.InstalledStatus(
1614 target,
1615 "dir",
1616 "directory",
1617 target.path / "dir",
1618 None,
1619 "hand-authored",
1620 )
1621 ]
1622 )
1623 == []
1624 )
1627def test_reconcile_helpers_cover_selection_skip_and_not_found( 1abcdefghi
1628 monkeypatch, tmp_path, capsys
1629):
1630 skill = make_cli_skill(tmp_path) 20 IbJb1 KbLb2 MbNb
1631 target = cli.InstallTarget("universal", tmp_path / ".agents" / "skills") 20 IbJb1 KbLb2 MbNb
1632 target.path.mkdir(parents=True) 20 IbJb1 KbLb2 MbNb
1633 status = cli.InstalledStatus( 20 IbJb1 KbLb2 MbNb
1634 target=target,
1635 name=skill.name,
1636 type="symlink",
1637 path=target.path / skill.name,
1638 target_path=skill.skill_dir,
1639 status="broken",
1640 skill=skill,
1641 )
1643 class FakeToolkitMenu: 20 IbJb1 KbLb2 MbNb
1644 style = None 20 IbJb1 KbLb2 MbNb
1645 console = None 20 IbJb1 KbLb2 MbNb
1647 class FakeMenu: 20 IbJb1 KbLb2 MbNb
1648 def __init__(self, label, *, options, **kwargs): 20 IbJb1 KbLb2 MbNb
1649 assert "repair" in label 20 IbJb1 KbLb2 MbNb
1650 assert kwargs["multiple"] is True 20 IbJb1 KbLb2 MbNb
1651 assert options[0]["name"] == "demo-skill [Agents]" 20 IbJb1 KbLb2 MbNb
1652 self.options = options 20 IbJb1 KbLb2 MbNb
1653 self.checked = set() 20 IbJb1 KbLb2 MbNb
1655 def ask(self): 20 IbJb1 KbLb2 MbNb
1656 assert self.checked == {0} 20 IbJb1 KbLb2 MbNb
1657 return [self.options[index]["value"] for index in sorted(self.checked)] 20 IbJb1 KbLb2 MbNb
1659 monkeypatch.setattr(cli, "_get_rich_toolkit", lambda: FakeToolkitMenu()) 20 IbJb1 KbLb2 MbNb
1660 monkeypatch.setattr(cli, "Menu", FakeMenu) 20 IbJb1 KbLb2 MbNb
1662 assert cli._select_statuses_interactive([status], action="repair") == [status] 20 IbJb1 KbLb2 MbNb
1663 assert "repair" in capsys.readouterr().out 20 IbJb1 KbLb2 MbNb
1664 assert cli._select_statuses_interactive([], action="repair") == [] 20 IbJb1 KbLb2 MbNb
1666 skipped = cli.InstalledStatus( 20 IbJb1 KbLb2 MbNb
1667 target=target,
1668 name="missing-skill",
1669 type="symlink",
1670 path=target.path / "missing-skill",
1671 target_path=None,
1672 status="broken",
1673 skill=None,
1674 )
1675 assert cli._repair_selected(statuses=[skipped], project_root=tmp_path) == 0 20 IbJb1 KbLb2 MbNb
1677 monkeypatch.setattr(cli, "uninstall_skill", lambda skill_name, target_dir: False) 20 IbJb1 KbLb2 MbNb
1678 assert cli._remove_selected(statuses=[status], project_root=tmp_path) == 0 20 IbJb1 KbLb2 MbNb
1679 assert "Not found:" in capsys.readouterr().out 20 IbJb1 KbLb2 MbNb
1682def test_select_targets_interactive_preselects_defaults(monkeypatch, tmp_path): 1abcdefghi
1683 default_target = cli.InstallTarget( 2*d+d,d-d.d/d:d;d=d
1684 "claude-compatible", tmp_path / ".claude" / "skills"
1685 )
1687 class FakeMenu: 2*d+d,d-d.d/d:d;d=d
1688 def __init__(self, _label, *, options, **_kwargs): 2*d+d,d-d.d/d:d;d=d
1689 assert [option["name"] for option in options] == [ 2*d+d,d-d.d/d:d;d=d
1690 "Agents (.agents/skills)",
1691 "Claude Code (.claude/skills)",
1692 ]
1693 self.options = options 2*d+d,d-d.d/d:d;d=d
1694 self.checked = set() 2*d+d,d-d.d/d:d;d=d
1696 def ask(self): 2*d+d,d-d.d/d:d;d=d
1697 assert self.checked == {1} 2*d+d,d-d.d/d:d;d=d
1698 return [self.options[index]["value"] for index in sorted(self.checked)] 2*d+d,d-d.d/d:d;d=d
1700 monkeypatch.setattr(cli, "Menu", FakeMenu) 2*d+d,d-d.d/d:d;d=d
1702 selected = cli._select_targets_interactive( 2*d+d,d-d.d/d:d;d=d
1703 project_root=tmp_path, default_targets=[default_target]
1704 )
1706 assert [target.name for target in selected] == ["claude-compatible"] 2*d+d,d-d.d/d:d;d=d
1709def test_select_tool_skill_interactive_uses_checked_menu(monkeypatch): 1abcdefghi
1710 class FakeToolkitMenu: 2McNcOcPcQcRcScTcUc
1711 style = None 2McNcOcPcQcRcScTcUc
1712 console = None 2McNcOcPcQcRcScTcUc
1714 class FakeMenu: 2McNcOcPcQcRcScTcUc
1715 def __init__(self, label, *, options, **kwargs): 2McNcOcPcQcRcScTcUc
1716 assert "Library Skills tool skill" in label 2McNcOcPcQcRcScTcUc
1717 assert kwargs["multiple"] is True 2McNcOcPcQcRcScTcUc
1718 self.options = options 2McNcOcPcQcRcScTcUc
1719 self.checked = set() 2McNcOcPcQcRcScTcUc
1721 def ask(self): 2McNcOcPcQcRcScTcUc
1722 assert self.checked == {0} 2McNcOcPcQcRcScTcUc
1723 return [self.options[index]["value"] for index in sorted(self.checked)] 2McNcOcPcQcRcScTcUc
1725 monkeypatch.setattr(cli, "_get_rich_toolkit", lambda: FakeToolkitMenu()) 2McNcOcPcQcRcScTcUc
1726 monkeypatch.setattr(cli, "Menu", FakeMenu) 2McNcOcPcQcRcScTcUc
1728 assert cli._select_tool_skill_interactive() is True 2McNcOcPcQcRcScTcUc
1731def test_get_rich_toolkit_returns_toolkit_instance(): 1abcdefghi
1732 assert cli._get_rich_toolkit() is not None 2ygzgAgBgCgDgEgFgGg
1735def test_ui_badge_fallback_status_and_summary(capsys): 1abcdefghi
1736 assert ui._badge("unknown", "custom") == "[tag] custom [/]" 2MfNfOfPfQfRfSfTfUf
1737 assert ui._styled_status("custom") == "custom" 2MfNfOfPfQfRfSfTfUf
1739 ui.print_summary({"repaired": 1, "removed": 2}) 2MfNfOfPfQfRfSfTfUf
1741 output = capsys.readouterr().out 2MfNfOfPfQfRfSfTfUf
1742 assert "Summary:" in output 2MfNfOfPfQfRfSfTfUf
1743 assert "repaired: 1" in output 2MfNfOfPfQfRfSfTfUf
1744 assert "removed: 2" in output 2MfNfOfPfQfRfSfTfUf
1747def test_install_selected_continues_after_install_error(tmp_path): 1abcdefghi
1748 skill = make_cli_skill(tmp_path) 2JejgkgLelgmgNengog
1749 target = cli.InstallTarget("universal", tmp_path / ".agents" / "skills") 2JejgkgLelgmgNengog
1750 (target.path / skill.name).mkdir(parents=True) 2JejgkgLelgmgNengog
1752 assert ( 2JejgkgLelgmgNengog
1753 cli._install_selected(skills=[skill], targets=[target], project_root=tmp_path)
1754 == 0
1755 )
1758def test_display_path_prefers_project_relative_paths(tmp_path): 1abcdefghi
1759 project = tmp_path / "project" 2lfmfnfofpfqfrfsftf
1760 inside = project / ".agents" / "skills" / "demo" 2lfmfnfofpfqfrfsftf
1761 outside = tmp_path / "outside" 2lfmfnfofpfqfrfsftf
1762 inside.mkdir(parents=True) 2lfmfnfofpfqfrfsftf
1763 outside.mkdir() 2lfmfnfofpfqfrfsftf
1765 assert cli._display_path(inside, project) == str( 2lfmfnfofpfqfrfsftf
1766 Path(".agents") / "skills" / "demo"
1767 )
1768 assert cli._display_path(outside, project) == str(outside) 2lfmfnfofpfqfrfsftf
1769 assert cli._display_path(None, project) == "" 2lfmfnfofpfqfrfsftf
1772def test_target_prompt_labels_are_user_facing(tmp_path): 1abcdefghi
1773 agents = cli.InstallTarget("universal", tmp_path / ".agents" / "skills") 2cfdfefffgfhfifjfkf
1774 claude = cli.InstallTarget("claude-compatible", tmp_path / ".claude" / "skills") 2cfdfefffgfhfifjfkf
1775 custom = cli.InstallTarget("custom", tmp_path / "custom") 2cfdfefffgfhfifjfkf
1777 assert cli._target_prompt_name(agents) == "Agents (.agents/skills)" 2cfdfefffgfhfifjfkf
1778 assert cli._target_prompt_name(claude) == "Claude Code (.claude/skills)" 2cfdfefffgfhfifjfkf
1779 assert cli._target_prompt_name(custom) == "custom" 2cfdfefffgfhfifjfkf
1780 assert cli._target_short_name(agents) == "Agents" 2cfdfefffgfhfifjfkf
1781 assert cli._target_short_name(claude) == "Claude Code" 2cfdfefffgfhfifjfkf
1782 assert cli._target_short_name(custom) == "custom" 2cfdfefffgfhfifjfkf
1785def test_main_module_invokes_cli_main(): 1abcdefghi
1786 import library_skills.cli as cli 2VfWfXfYfZf0f1f2f3f
1788 called = [] 2VfWfXfYfZf0f1f2f3f
1789 sys.modules.pop("library_skills.__main__", None) 2VfWfXfYfZf0f1f2f3f
1791 with patch.object(cli, "main", lambda: called.append(True)): 2VfWfXfYfZf0f1f2f3f
1792 importlib.import_module("library_skills.__main__") 2VfWfXfYfZf0f1f2f3f
1794 assert called == [True] 2VfWfXfYfZf0f1f2f3f
1795 sys.modules.pop("library_skills.__main__", None) 2VfWfXfYfZf0f1f2f3f
1798def test_main_invokes_typer_app(): 1abcdefghi
1799 called = [] 2pgqgrgsgtgugvgwgxg
1801 with patch.object(cli, "app", lambda: called.append(True)): 2pgqgrgsgtgugvgwgxg
1802 cli.main() 2pgqgrgsgtgugvgwgxg
1804 assert called == [True] 2pgqgrgsgtgugvgwgxg