this repo has no description

add readme and tests

+657 -18
+28
LICENSE
··· 1 + BSD 3-Clause License 2 + 3 + Copyright (c) 2026, Pavel Zwerschke 4 + 5 + Redistribution and use in source and binary forms, with or without 6 + modification, are permitted provided that the following conditions are met: 7 + 8 + 1. Redistributions of source code must retain the above copyright notice, this 9 + list of conditions and the following disclaimer. 10 + 11 + 2. Redistributions in binary form must reproduce the above copyright notice, 12 + this list of conditions and the following disclaimer in the documentation 13 + and/or other materials provided with the distribution. 14 + 15 + 3. Neither the name of the copyright holder nor the names of its 16 + contributors may be used to endorse or promote products derived from 17 + this software without specific prior written permission. 18 + 19 + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 + AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 + IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 + DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 + FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 + DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 + SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 + CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 + OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 + OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+89 -2
README.md
··· 4 4 [![conda-forge](https://img.shields.io/conda/vn/conda-forge/pixi-skills?logoColor=white&logo=conda-forge&style=flat-square)](https://prefix.dev/channels/conda-forge/packages/pixi-skills) 5 5 [![pypi-version](https://img.shields.io/pypi/v/pixi-skills.svg?logo=pypi&logoColor=white&style=flat-square)](https://pypi.org/project/pixi-skills) 6 6 [![python-version](https://img.shields.io/pypi/pyversions/pixi-skills?logoColor=white&logo=python&style=flat-square)](https://pypi.org/project/pixi-skills) 7 + [![conda-forge](https://img.shields.io/badge/prefix.dev%2Fskill--forge-F7CC49?style=flat-square)](https://prefix.dev/channels/skill-forge) 7 8 8 - Manage coding agent skills using pixi 9 + Manage and install coding agent skills across multiple LLM backends using [pixi](https://pixi.sh). 10 + 11 + pixi-skills discovers skills packaged in pixi environments and lets you install them into the configuration directories of various coding agents via symlinks. 9 12 10 13 ## Installation 11 14 15 + ```bash 16 + pixi global install pixi-skills 17 + # or use without installing 18 + pixi exec pixi-skills 19 + ``` 20 + 21 + ## Concepts 22 + 23 + ### Skills 24 + 25 + A skill is a directory containing a `SKILL.md` file with YAML frontmatter: 26 + 27 + ```markdown 28 + --- 29 + name: my-skill 30 + description: "Does something useful for the agent" 31 + --- 32 + 33 + Skill instructions go here as Markdown. 34 + The agent reads this file to understand what the skill does. 35 + ``` 36 + 37 + The `name` field is optional and defaults to the directory name. 38 + The `description` field is required. 39 + 40 + A collection of ready-to-use skills is available at [skill-forge](https://prefix.dev/channels/skill-forge) ([source](https://github.com/pavelzw/skill-forge)). 41 + 42 + ### Scopes 43 + 44 + - **Local** skills are discovered from the current project's pixi environment at `.pixi/envs/<env>/share/agent-skills/`. 45 + - **Global** skills are discovered from globally installed pixi packages at `~/.pixi/envs/agent-skill-*/share/agent-skills/`. 46 + 47 + ### Supported backends 48 + 49 + | Backend | Local directory | Global directory | 50 + |---------|----------------|-----------------| 51 + | Claude | `.claude/skills/` | `~/.claude/skills/` | 52 + | Codex | `.codex/skills/` | `~/.codex/skills/` | 53 + | Copilot | `.github/skills/` | `~/.github/skills/` | 54 + | Crush | `.crush/skills/` | `~/.crush/skills/` | 55 + | Cursor | `.cursor/skills/` | `~/.cursor/skills/` | 56 + | Gemini | `.gemini/skills/` | `~/.gemini/skills/` | 57 + | Opencode | `.opencode/skills/` | `~/.opencode/skills/` | 58 + 59 + Skills are installed as relative symlinks for portability. 60 + 61 + ## Usage 62 + 63 + ### List available skills 64 + 65 + ```bash 66 + # List all local and global skills 67 + pixi-skills list 68 + 69 + # List only local skills 70 + pixi-skills list --scope local 71 + 72 + # List skills from a specific pixi environment 73 + pixi-skills list --env myenv 74 + ``` 75 + 76 + ### Manage skills interactively 77 + 78 + ```bash 79 + # Interactive mode - prompts for backend and scope 80 + pixi-skills manage 81 + 82 + # Specify backend and scope directly 83 + pixi-skills manage --backend claude --scope local 84 + ``` 85 + 86 + This opens an interactive checkbox selector where you can choose which skills to install or uninstall. 87 + 88 + ### Show installed skills 89 + 90 + ```bash 91 + # Show installed skills across all backends 92 + pixi-skills status 93 + 94 + # Show installed skills for a specific backend 95 + pixi-skills status --backend claude 96 + ``` 97 + 98 + ## Development 99 + 12 100 This project is managed by [pixi](https://pixi.sh). 13 - You can install the package in development mode using: 14 101 15 102 ```bash 16 103 git clone https://github.com/pavelzw/pixi-skills
+1 -1
pixi.toml
··· 15 15 questionary = ">=2" 16 16 17 17 [feature.test.dependencies] 18 - pytest = ">=6" 18 + pytest = "*" 19 19 pytest-cov = "*" 20 20 ty = "*" 21 21 [feature.test.tasks]
+2 -2
pixi_skills/backend.py
··· 1 1 import os 2 2 from abc import ABC, abstractmethod 3 - from enum import Enum 3 + from enum import StrEnum 4 4 from pathlib import Path 5 5 6 6 from pixi_skills.skill import Scope, Skill 7 7 8 8 9 - class BackendName(str, Enum): 9 + class BackendName(StrEnum): 10 10 """Available backend names.""" 11 11 12 12 CLAUDE = "claude"
+7 -7
pixi_skills/skill.py
··· 2 2 import re 3 3 import warnings 4 4 from dataclasses import dataclass 5 - from enum import Enum 5 + from enum import StrEnum 6 6 from pathlib import Path 7 7 8 8 9 - class Scope(str, Enum): 9 + class Scope(StrEnum): 10 10 """Scope of a skill - local or global. 11 11 12 12 Ordered by definition order (LOCAL < GLOBAL). 13 13 """ 14 14 15 - def __lt__(self, other: Scope) -> bool: # type: ignore[invalid-method-override, override] 15 + def __lt__(self, other: "Scope") -> bool: # type: ignore[invalid-method-override, override] 16 16 if not isinstance(other, Scope): 17 17 return NotImplemented 18 18 members = list(Scope) 19 19 return members.index(self) < members.index(other) 20 20 21 - def __le__(self, other: Scope) -> bool: # type: ignore[invalid-method-override, override] 21 + def __le__(self, other: "Scope") -> bool: # type: ignore[invalid-method-override, override] 22 22 if not isinstance(other, Scope): 23 23 return NotImplemented 24 24 return self == other or self < other 25 25 26 - def __gt__(self, other: Scope) -> bool: # type: ignore[invalid-method-override, override] 26 + def __gt__(self, other: "Scope") -> bool: # type: ignore[invalid-method-override, override] 27 27 if not isinstance(other, Scope): 28 28 return NotImplemented 29 29 return other < self 30 30 31 - def __ge__(self, other: Scope) -> bool: # type: ignore[invalid-method-override, override] 31 + def __ge__(self, other: "Scope") -> bool: # type: ignore[invalid-method-override, override] 32 32 if not isinstance(other, Scope): 33 33 return NotImplemented 34 34 return self == other or self > other ··· 50 50 path: Path = dataclasses.field(compare=False) 51 51 52 52 @classmethod 53 - def from_directory(cls, path: Path, scope: Scope) -> Skill: 53 + def from_directory(cls, path: Path, scope: Scope) -> "Skill": 54 54 """Load a skill from a directory containing SKILL.md.""" 55 55 skill_md = path / "SKILL.md" 56 56 if not skill_md.exists():
+152
tests/test_backend.py
··· 1 + from pathlib import Path 2 + 3 + import pytest 4 + 5 + from pixi_skills.backend import ( 6 + BACKENDS, 7 + Backend, 8 + BackendName, 9 + ClaudeBackend, 10 + CodexBackend, 11 + CopilotBackend, 12 + CrushBackend, 13 + CursorBackend, 14 + GeminiBackend, 15 + OpencodeBackend, 16 + get_all_backends, 17 + get_backend, 18 + ) 19 + from pixi_skills.skill import Scope, Skill 20 + 21 + SetupFixture = tuple[Backend, Skill, Path] 22 + 23 + 24 + # --- Backend registry --- 25 + 26 + 27 + class TestBackendRegistry: 28 + def test_get_backend(self) -> None: 29 + backend = get_backend(BackendName.CLAUDE) 30 + assert isinstance(backend, ClaudeBackend) 31 + 32 + def test_get_all_backends(self) -> None: 33 + backends = get_all_backends() 34 + assert len(backends) == len(BACKENDS) 35 + 36 + def test_all_backend_names_registered(self) -> None: 37 + for name in BackendName: 38 + assert name in BACKENDS 39 + 40 + 41 + # --- get_skills_dir --- 42 + 43 + 44 + @pytest.mark.parametrize( 45 + "backend_cls,local_dir,global_suffix", 46 + [ 47 + (ClaudeBackend, ".claude/skills", ".claude/skills"), 48 + (CrushBackend, ".crush/skills", ".crush/skills"), 49 + (CursorBackend, ".cursor/skills", ".cursor/skills"), 50 + (CodexBackend, ".codex/skills", ".codex/skills"), 51 + (CopilotBackend, ".github/skills", ".github/skills"), 52 + (GeminiBackend, ".gemini/skills", ".gemini/skills"), 53 + (OpencodeBackend, ".opencode/skills", ".opencode/skills"), 54 + ], 55 + ) 56 + class TestGetSkillsDir: 57 + def test_local(self, backend_cls: type[Backend], local_dir: str, global_suffix: str) -> None: 58 + backend = backend_cls() 59 + assert backend.get_skills_dir(Scope.LOCAL) == Path(local_dir) 60 + 61 + def test_global(self, backend_cls: type[Backend], local_dir: str, global_suffix: str) -> None: 62 + backend = backend_cls() 63 + assert backend.get_skills_dir(Scope.GLOBAL) == Path.home() / global_suffix 64 + 65 + 66 + # --- Install / Uninstall / is_installed --- 67 + 68 + 69 + class TestBackendInstallation: 70 + @pytest.fixture() 71 + def setup(self, tmp_path: Path) -> SetupFixture: 72 + """Create a fake skill and a backend that uses tmp_path.""" 73 + skill_dir = tmp_path / "skills-source" / "my-skill" 74 + skill_dir.mkdir(parents=True) 75 + (skill_dir / "SKILL.md").write_text("---\ndescription: test\n---\n") 76 + skill = Skill(Scope.LOCAL, "my-skill", "test", skill_dir) 77 + 78 + # Patch ClaudeBackend to use tmp_path 79 + install_dir = tmp_path / "install" 80 + 81 + class TmpBackend(ClaudeBackend): 82 + def get_skills_dir(self, scope: Scope) -> Path: 83 + return install_dir 84 + 85 + backend = TmpBackend() 86 + return backend, skill, install_dir 87 + 88 + def test_install_creates_symlink(self, setup: SetupFixture) -> None: 89 + backend, skill, install_dir = setup 90 + result = backend.install(skill) 91 + assert result == install_dir / "my-skill" 92 + assert result.is_symlink() 93 + assert result.resolve() == skill.path.resolve() 94 + 95 + def test_install_idempotent(self, setup: SetupFixture) -> None: 96 + backend, skill, install_dir = setup 97 + first = backend.install(skill) 98 + second = backend.install(skill) 99 + assert first == second 100 + 101 + def test_install_replaces_different_symlink(self, setup: SetupFixture, tmp_path: Path) -> None: 102 + backend, skill, install_dir = setup 103 + install_dir.mkdir(parents=True, exist_ok=True) 104 + # Point at a real but different directory so .exists() returns True 105 + other = tmp_path / "other-skill" 106 + other.mkdir() 107 + existing = install_dir / "my-skill" 108 + existing.symlink_to(other) 109 + 110 + result = backend.install(skill) 111 + assert result.resolve() == skill.path.resolve() 112 + 113 + def test_install_fails_on_regular_file(self, setup: SetupFixture) -> None: 114 + backend, skill, install_dir = setup 115 + install_dir.mkdir(parents=True, exist_ok=True) 116 + (install_dir / "my-skill").write_text("not a symlink") 117 + 118 + with pytest.raises(ValueError, match="exists and is not a symlink"): 119 + backend.install(skill) 120 + 121 + def test_uninstall(self, setup: SetupFixture) -> None: 122 + backend, skill, install_dir = setup 123 + backend.install(skill) 124 + assert backend.uninstall("my-skill", Scope.LOCAL) 125 + assert not (install_dir / "my-skill").exists() 126 + 127 + def test_uninstall_not_found(self, setup: SetupFixture) -> None: 128 + backend, skill, install_dir = setup 129 + assert not backend.uninstall("nonexistent", Scope.LOCAL) 130 + 131 + def test_is_installed(self, setup: SetupFixture) -> None: 132 + backend, skill, install_dir = setup 133 + assert not backend.is_installed(skill) 134 + backend.install(skill) 135 + assert backend.is_installed(skill) 136 + 137 + def test_get_installed_skills(self, setup: SetupFixture) -> None: 138 + backend, skill, install_dir = setup 139 + assert backend.get_installed_skills(Scope.LOCAL) == [] 140 + 141 + backend.install(skill) 142 + installed = backend.get_installed_skills(Scope.LOCAL) 143 + assert len(installed) == 1 144 + assert installed[0][0] == "my-skill" 145 + assert installed[0][1] == skill.path.resolve() 146 + 147 + def test_install_creates_relative_symlink(self, setup: SetupFixture) -> None: 148 + backend, skill, install_dir = setup 149 + result = backend.install(skill) 150 + # The raw symlink target should be relative, not absolute 151 + raw_target = result.readlink() 152 + assert not raw_target.is_absolute()
+127
tests/test_cli.py
··· 1 + from pathlib import Path 2 + 3 + import pytest 4 + from typer.testing import CliRunner 5 + 6 + from pixi_skills.cli import app 7 + 8 + runner = CliRunner() 9 + 10 + 11 + class TestVersion: 12 + def test_version_flag(self) -> None: 13 + result = runner.invoke(app, ["--version"]) 14 + assert result.exit_code == 0 15 + assert "pixi-skills" in result.output 16 + 17 + def test_short_version_flag(self) -> None: 18 + result = runner.invoke(app, ["-V"]) 19 + assert result.exit_code == 0 20 + assert "pixi-skills" in result.output 21 + 22 + 23 + class TestList: 24 + def test_list_no_skills(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 25 + monkeypatch.chdir(tmp_path) 26 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 27 + result = runner.invoke(app, ["list"]) 28 + assert result.exit_code == 0 29 + assert "no local skills found" in result.output.lower() 30 + 31 + def test_list_local_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 32 + skill_dir = tmp_path / ".pixi/envs/default/share/agent-skills/test-skill" 33 + skill_dir.mkdir(parents=True) 34 + (skill_dir / "SKILL.md").write_text("---\ndescription: A test\n---\nBody\n") 35 + monkeypatch.chdir(tmp_path) 36 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 37 + 38 + result = runner.invoke(app, ["list", "--scope", "local"]) 39 + assert result.exit_code == 0 40 + assert "test-skill" in result.output 41 + 42 + def test_list_global_scope(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 43 + monkeypatch.chdir(tmp_path) 44 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 45 + result = runner.invoke(app, ["list", "--scope", "global"]) 46 + assert result.exit_code == 0 47 + 48 + def test_list_env_with_global_scope_fails( 49 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 50 + ) -> None: 51 + monkeypatch.chdir(tmp_path) 52 + result = runner.invoke(app, ["list", "--scope", "global", "--env", "custom"]) 53 + assert result.exit_code == 1 54 + 55 + def test_list_custom_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 56 + skill_dir = tmp_path / ".pixi/envs/myenv/share/agent-skills/env-skill" 57 + skill_dir.mkdir(parents=True) 58 + (skill_dir / "SKILL.md").write_text("---\ndescription: env\n---\nBody\n") 59 + monkeypatch.chdir(tmp_path) 60 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 61 + 62 + result = runner.invoke(app, ["list", "--env", "myenv"]) 63 + assert result.exit_code == 0 64 + assert "env-skill" in result.output 65 + 66 + 67 + class TestStatus: 68 + def test_status_no_installed( 69 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 70 + ) -> None: 71 + monkeypatch.chdir(tmp_path) 72 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 73 + result = runner.invoke(app, ["status"]) 74 + assert result.exit_code == 0 75 + # All backends should be listed 76 + assert "claude" in result.output 77 + 78 + def test_status_specific_backend( 79 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 80 + ) -> None: 81 + monkeypatch.chdir(tmp_path) 82 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 83 + result = runner.invoke(app, ["status", "--backend", "claude"]) 84 + assert result.exit_code == 0 85 + assert "claude" in result.output 86 + 87 + def test_status_shows_installed_skill( 88 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 89 + ) -> None: 90 + monkeypatch.chdir(tmp_path) 91 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 92 + 93 + # Create a skill source directory 94 + skill_src = tmp_path / "skill-source" 95 + skill_src.mkdir() 96 + (skill_src / "SKILL.md").write_text("---\ndescription: x\n---\n") 97 + 98 + # Install it as a symlink in the Claude backend local dir 99 + claude_dir = tmp_path / ".claude" / "skills" 100 + claude_dir.mkdir(parents=True) 101 + (claude_dir / "my-skill").symlink_to(skill_src) 102 + 103 + result = runner.invoke(app, ["status", "--backend", "claude"]) 104 + assert result.exit_code == 0 105 + assert "my-skill" in result.output 106 + 107 + 108 + class TestManage: 109 + def test_manage_env_with_global_scope_fails( 110 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 111 + ) -> None: 112 + monkeypatch.chdir(tmp_path) 113 + result = runner.invoke( 114 + app, ["manage", "--backend", "claude", "--scope", "global", "--env", "x"] 115 + ) 116 + assert result.exit_code == 1 117 + 118 + def test_manage_no_skills_available( 119 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 120 + ) -> None: 121 + monkeypatch.chdir(tmp_path) 122 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 123 + result = runner.invoke( 124 + app, ["manage", "--backend", "claude", "--scope", "local"] 125 + ) 126 + assert result.exit_code == 1 127 + assert "no local skills available" in result.output.lower()
-6
tests/test_core.py
··· 1 - def test_hard(): 2 - import pixi_skills # noqa 3 - 4 - # this is just a demo that pytest can produce good error messages just by 5 - # parsing assert statements 6 - assert {"a": 1, "b": 2} == {"a": 1, "b": 2}
+15
tests/test_selector.py
··· 1 + from pixi_skills.selector import select_skills_interactively 2 + 3 + 4 + class TestSelectSkillsInteractively: 5 + def test_empty_skills_list(self) -> None: 6 + result = select_skills_interactively([], None) 7 + assert result == [] 8 + 9 + def test_empty_skills_list_with_installed(self) -> None: 10 + result = select_skills_interactively([], {"some-skill"}) 11 + assert result == [] 12 + 13 + def test_none_installed_defaults_to_empty(self) -> None: 14 + result = select_skills_interactively([], None) 15 + assert result == []
+236
tests/test_skill.py
··· 1 + import warnings 2 + from pathlib import Path 3 + 4 + import pytest 5 + 6 + from pixi_skills.skill import ( 7 + Scope, 8 + Skill, 9 + discover_global_skills, 10 + discover_local_skills, 11 + parse_skill_md, 12 + ) 13 + 14 + 15 + # --- Scope ordering --- 16 + 17 + 18 + class TestScope: 19 + def test_local_less_than_global(self) -> None: 20 + assert Scope.LOCAL < Scope.GLOBAL 21 + 22 + def test_global_greater_than_local(self) -> None: 23 + assert Scope.GLOBAL > Scope.LOCAL 24 + 25 + def test_equal(self) -> None: 26 + assert Scope.LOCAL == Scope.LOCAL 27 + assert Scope.GLOBAL == Scope.GLOBAL 28 + 29 + def test_le_ge(self) -> None: 30 + assert Scope.LOCAL <= Scope.GLOBAL 31 + assert Scope.LOCAL <= Scope.LOCAL 32 + assert Scope.GLOBAL >= Scope.LOCAL 33 + assert Scope.GLOBAL >= Scope.GLOBAL 34 + 35 + 36 + # --- parse_skill_md --- 37 + 38 + 39 + class TestParseSkillMd: 40 + def test_basic(self, tmp_path: Path) -> None: 41 + md = tmp_path / "SKILL.md" 42 + md.write_text('---\nname: my-skill\ndescription: "A test skill"\n---\nBody\n') 43 + name, desc = parse_skill_md(md) 44 + assert name == "my-skill" 45 + assert desc == "A test skill" 46 + 47 + def test_unquoted_description(self, tmp_path: Path) -> None: 48 + md = tmp_path / "SKILL.md" 49 + md.write_text("---\nname: foo\ndescription: some desc\n---\nBody\n") 50 + name, desc = parse_skill_md(md) 51 + assert name == "foo" 52 + assert desc == "some desc" 53 + 54 + def test_single_quoted_description(self, tmp_path: Path) -> None: 55 + md = tmp_path / "SKILL.md" 56 + md.write_text("---\nname: foo\ndescription: 'single quoted'\n---\nBody\n") 57 + name, desc = parse_skill_md(md) 58 + assert desc == "single quoted" 59 + 60 + def test_name_optional(self, tmp_path: Path) -> None: 61 + md = tmp_path / "SKILL.md" 62 + md.write_text("---\ndescription: no name\n---\nBody\n") 63 + name, desc = parse_skill_md(md) 64 + assert name is None 65 + assert desc == "no name" 66 + 67 + def test_multiline_description_pipe(self, tmp_path: Path) -> None: 68 + md = tmp_path / "SKILL.md" 69 + md.write_text("---\ndescription: |\n line1\n line2\n---\nBody\n") 70 + name, desc = parse_skill_md(md) 71 + assert desc == "" 72 + 73 + def test_multiline_description_folded(self, tmp_path: Path) -> None: 74 + md = tmp_path / "SKILL.md" 75 + md.write_text("---\ndescription: >\n line1\n line2\n---\nBody\n") 76 + name, desc = parse_skill_md(md) 77 + assert desc == "" 78 + 79 + def test_missing_frontmatter(self, tmp_path: Path) -> None: 80 + md = tmp_path / "SKILL.md" 81 + md.write_text("no frontmatter here\n") 82 + with pytest.raises(ValueError, match="must start with YAML frontmatter"): 83 + parse_skill_md(md) 84 + 85 + def test_missing_end_marker(self, tmp_path: Path) -> None: 86 + md = tmp_path / "SKILL.md" 87 + md.write_text("---\nname: foo\ndescription: bar\n") 88 + with pytest.raises(ValueError, match="Invalid YAML frontmatter"): 89 + parse_skill_md(md) 90 + 91 + def test_missing_description(self, tmp_path: Path) -> None: 92 + md = tmp_path / "SKILL.md" 93 + md.write_text("---\nname: foo\n---\nBody\n") 94 + with pytest.raises(ValueError, match="Missing 'description'"): 95 + parse_skill_md(md) 96 + 97 + def test_quoted_name(self, tmp_path: Path) -> None: 98 + md = tmp_path / "SKILL.md" 99 + md.write_text('---\nname: "quoted-name"\ndescription: desc\n---\nBody\n') 100 + name, desc = parse_skill_md(md) 101 + assert name == "quoted-name" 102 + 103 + 104 + # --- Skill dataclass --- 105 + 106 + 107 + class TestSkill: 108 + def test_from_directory(self, tmp_path: Path) -> None: 109 + skill_dir = tmp_path / "my-skill" 110 + skill_dir.mkdir() 111 + (skill_dir / "SKILL.md").write_text("---\ndescription: hello\n---\nBody\n") 112 + skill = Skill.from_directory(skill_dir, Scope.LOCAL) 113 + assert skill.name == "my-skill" 114 + assert skill.description == "hello" 115 + assert skill.scope == Scope.LOCAL 116 + assert skill.path == skill_dir 117 + 118 + def test_from_directory_with_name_override(self, tmp_path: Path) -> None: 119 + skill_dir = tmp_path / "dir-name" 120 + skill_dir.mkdir() 121 + (skill_dir / "SKILL.md").write_text( 122 + "---\nname: custom-name\ndescription: desc\n---\nBody\n" 123 + ) 124 + skill = Skill.from_directory(skill_dir, Scope.GLOBAL) 125 + assert skill.name == "custom-name" 126 + 127 + def test_from_directory_no_skill_md(self, tmp_path: Path) -> None: 128 + skill_dir = tmp_path / "empty" 129 + skill_dir.mkdir() 130 + with pytest.raises(ValueError, match="No SKILL.md found"): 131 + Skill.from_directory(skill_dir, Scope.LOCAL) 132 + 133 + def test_ordering(self, tmp_path: Path) -> None: 134 + a = Skill(Scope.LOCAL, "alpha", "desc", tmp_path) 135 + b = Skill(Scope.LOCAL, "beta", "desc", tmp_path) 136 + c = Skill(Scope.GLOBAL, "alpha", "desc", tmp_path) 137 + assert sorted([c, b, a]) == [a, b, c] 138 + 139 + def test_frozen(self, tmp_path: Path) -> None: 140 + skill = Skill(Scope.LOCAL, "test", "desc", tmp_path) 141 + with pytest.raises(AttributeError): 142 + skill.name = "other" # type: ignore[misc] 143 + 144 + 145 + # --- Skill discovery --- 146 + 147 + 148 + class TestDiscoverLocalSkills: 149 + def test_discovers_skills( 150 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 151 + ) -> None: 152 + skill_dir = tmp_path / ".pixi/envs/default/share/agent-skills/my-skill" 153 + skill_dir.mkdir(parents=True) 154 + (skill_dir / "SKILL.md").write_text("---\ndescription: local skill\n---\n") 155 + monkeypatch.chdir(tmp_path) 156 + 157 + skills = discover_local_skills("default") 158 + assert len(skills) == 1 159 + assert skills[0].name == "my-skill" 160 + assert skills[0].scope == Scope.LOCAL 161 + 162 + def test_empty_when_no_dir( 163 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 164 + ) -> None: 165 + monkeypatch.chdir(tmp_path) 166 + assert discover_local_skills("default") == [] 167 + 168 + def test_skips_invalid_skills( 169 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 170 + ) -> None: 171 + base = tmp_path / ".pixi/envs/default/share/agent-skills" 172 + # valid skill 173 + valid = base / "valid" 174 + valid.mkdir(parents=True) 175 + (valid / "SKILL.md").write_text("---\ndescription: good\n---\n") 176 + # invalid skill (no description) 177 + invalid = base / "invalid" 178 + invalid.mkdir(parents=True) 179 + (invalid / "SKILL.md").write_text("---\nname: bad\n---\n") 180 + 181 + monkeypatch.chdir(tmp_path) 182 + with warnings.catch_warnings(record=True) as w: 183 + warnings.simplefilter("always") 184 + skills = discover_local_skills("default") 185 + assert len(skills) == 1 186 + assert skills[0].name == "valid" 187 + assert len(w) == 1 188 + 189 + def test_custom_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 190 + skill_dir = tmp_path / ".pixi/envs/myenv/share/agent-skills/s1" 191 + skill_dir.mkdir(parents=True) 192 + (skill_dir / "SKILL.md").write_text("---\ndescription: env skill\n---\n") 193 + monkeypatch.chdir(tmp_path) 194 + 195 + skills = discover_local_skills("myenv") 196 + assert len(skills) == 1 197 + 198 + def test_skips_dirs_without_skill_md( 199 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 200 + ) -> None: 201 + base = tmp_path / ".pixi/envs/default/share/agent-skills/no-md" 202 + base.mkdir(parents=True) 203 + monkeypatch.chdir(tmp_path) 204 + assert discover_local_skills("default") == [] 205 + 206 + 207 + class TestDiscoverGlobalSkills: 208 + def test_discovers_skills( 209 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 210 + ) -> None: 211 + skill_dir = tmp_path / ".pixi/envs/agent-skill-typst/share/agent-skills/typst" 212 + skill_dir.mkdir(parents=True) 213 + (skill_dir / "SKILL.md").write_text("---\ndescription: typst skill\n---\n") 214 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 215 + 216 + skills = discover_global_skills() 217 + assert len(skills) == 1 218 + assert skills[0].name == "typst" 219 + assert skills[0].scope == Scope.GLOBAL 220 + 221 + def test_empty_when_no_dir( 222 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 223 + ) -> None: 224 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 225 + assert discover_global_skills() == [] 226 + 227 + def test_skips_non_agent_skill_envs( 228 + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch 229 + ) -> None: 230 + # This env doesn't match the agent-skill-* pattern 231 + skill_dir = tmp_path / ".pixi/envs/other-env/share/agent-skills/s1" 232 + skill_dir.mkdir(parents=True) 233 + (skill_dir / "SKILL.md").write_text("---\ndescription: other\n---\n") 234 + monkeypatch.setattr(Path, "home", lambda: tmp_path) 235 + 236 + assert discover_global_skills() == []