this repo has no description

feat: add Pi backend and sync command

- Add PiBackend for pi.dev agent with local (.pi/skills) and global
(~/.pi/agent/skills) skill directories
- Add sync command to sync installed skill packages to configured agent
- Add config module to read agent from [tool.pixi-skills] in pixi.toml
- Add rattler-build recipe (recipe/recipe.yaml) for conda packaging
- Add comprehensive tests for config module and PiBackend

nandi d1ee17f2 9c9d00cd

+316
+3
pixi.toml
··· 3 3 channels = ["conda-forge"] 4 4 platforms = ["osx-arm64", "osx-64", "linux-64", "win-64"] 5 5 6 + [tool.pixi-skills] 7 + agent = "pi" 8 + 6 9 [tasks] 7 10 postinstall = "pip install --no-build-isolation --no-deps --disable-pip-version-check -e ." 8 11
+14
pixi_skills/backend.py
··· 19 19 KIRO = "kiro" 20 20 KILOCODE = "kilocode" 21 21 OPENCODE = "opencode" 22 + PI = "pi" 22 23 QODER = "qoder" 23 24 ROOCODE = "roocode" 24 25 TRAE = "trae" ··· 261 262 return Path.home() / ".roo/skills" 262 263 263 264 265 + class PiBackend(Backend): 266 + """Backend for pi.dev agent.""" 267 + 268 + name = "pi" 269 + 270 + def get_skills_dir(self, scope: Scope) -> Path: 271 + if scope == Scope.LOCAL: 272 + return Path(".pi/skills") 273 + else: 274 + return Path.home() / ".pi/agent/skills" 275 + 276 + 264 277 # Registry of available backends 265 278 BACKENDS: dict[BackendName, type[Backend]] = { 266 279 BackendName.CLAUDE: ClaudeBackend, ··· 273 286 BackendName.KIRO: KiroBackend, 274 287 BackendName.KILOCODE: KiloCodeBackend, 275 288 BackendName.OPENCODE: OpencodeBackend, 289 + BackendName.PI: PiBackend, 276 290 BackendName.QODER: QoderBackend, 277 291 BackendName.ROOCODE: RooCodeBackend, 278 292 BackendName.TRAE: TraeBackend,
+102
pixi_skills/cli.py
··· 7 7 from rich.table import Table 8 8 9 9 from pixi_skills.backend import BackendName, get_all_backends, get_backend 10 + from pixi_skills.config import Config, find_pixi_toml 10 11 from pixi_skills.selector import CUSTOM_STYLE, select_skills_interactively 11 12 from pixi_skills.skill import ( 12 13 Scope, ··· 262 263 console.print(table) 263 264 else: 264 265 console.print(f" [dim]No {scope.value} skills installed[/dim]") 266 + 267 + 268 + @app.command("sync") 269 + def sync( 270 + backend: Annotated[ 271 + BackendName | None, 272 + typer.Option( 273 + "--backend", 274 + "-b", 275 + help="Backend to sync skills to. Overrides pixi.toml config.", 276 + ), 277 + ] = None, 278 + env: Annotated[ 279 + str, 280 + typer.Option( 281 + "--env", 282 + "-e", 283 + help="Pixi environment to search for skills.", 284 + ), 285 + ] = "default", 286 + ) -> None: 287 + """Sync installed skill packages to the configured agent. 288 + 289 + Reads agent configuration from [tool.pixi-skills] in pixi.toml: 290 + [tool.pixi-skills] 291 + agent = "pi" 292 + 293 + Then installs all skills from .pixi/envs/{env}/share/agent-skills/ to the 294 + agent's local skills directory. 295 + """ 296 + # Find and parse config 297 + config_path = find_pixi_toml() 298 + 299 + if backend is None: 300 + if config_path is None: 301 + console.print( 302 + "[red]No pixi.toml found. " 303 + "Either run from a pixi project or specify --backend.[/red]" 304 + ) 305 + raise typer.Exit(1) 306 + 307 + try: 308 + config = Config.from_toml(config_path) 309 + except ValueError as e: 310 + console.print(f"[red]{e}[/red]") 311 + raise typer.Exit(1) from None 312 + 313 + if config.agent is None: 314 + console.print( 315 + f"[red]No agent configured in {config_path}. " 316 + "Add \\[tool.pixi-skills] section with 'agent' key.[/red]" 317 + ) 318 + console.print("\nExample pixi.toml:") 319 + console.print('[dim]\\[tool.pixi-skills]\nagent = "pi"[/dim]') 320 + raise typer.Exit(1) 321 + 322 + backend_name = config.agent 323 + else: 324 + backend_name = backend 325 + 326 + backend_instance = get_backend(backend_name) 327 + skills_dir = backend_instance.get_skills_dir(Scope.LOCAL) 328 + 329 + console.print( 330 + f"Syncing to [cyan]{backend_name}[/cyan] -> [dim]{skills_dir}[/dim]\n" 331 + ) 332 + 333 + # Discover available skills 334 + skills = discover_local_skills(env) 335 + 336 + if not skills: 337 + console.print( 338 + f"[yellow]No skills found in .pixi/envs/{env}/share/agent-skills/[/yellow]" 339 + ) 340 + raise typer.Exit(0) 341 + 342 + # Get currently installed skills 343 + installed_names = { 344 + name for name, _ in backend_instance.get_installed_skills(Scope.LOCAL) 345 + } 346 + 347 + # Determine what to install 348 + to_install = [s for s in skills if s.name not in installed_names] 349 + 350 + if not to_install: 351 + console.print(f"[dim]All {len(skills)} skill(s) already synced.[/dim]") 352 + raise typer.Exit(0) 353 + 354 + # Install skills 355 + installed_count = 0 356 + for skill in sorted(to_install): 357 + try: 358 + symlink_path = backend_instance.install(skill) 359 + console.print(f"[green]+[/green] {skill.name} -> {symlink_path}") 360 + installed_count += 1 361 + except ValueError as e: 362 + console.print(f"[red]✗ {skill.name}: {e}[/red]") 363 + 364 + console.print( 365 + f"\n[green]Synced {installed_count}/{len(to_install)} skill(s)[/green]" 366 + ) 265 367 266 368 267 369 if __name__ == "__main__":
+64
pixi_skills/config.py
··· 1 + """Configuration management for pixi-skills.""" 2 + 3 + import tomllib 4 + from pathlib import Path 5 + 6 + from pixi_skills.backend import BackendName 7 + 8 + 9 + class Config: 10 + """Configuration for pixi-skills.""" 11 + 12 + def __init__(self, agent: BackendName | None = None) -> None: 13 + self.agent = agent 14 + 15 + @classmethod 16 + def from_toml(cls, path: Path) -> "Config": 17 + """Load configuration from a TOML file.""" 18 + try: 19 + with open(path, "rb") as f: 20 + data = tomllib.load(f) 21 + except FileNotFoundError: 22 + return cls() 23 + 24 + # Check for [tool.pixi-skills] section 25 + tool_section = data.get("tool", {}) 26 + pixi_skills_section = tool_section.get("pixi-skills", {}) 27 + 28 + agent_str = pixi_skills_section.get("agent") 29 + agent = None 30 + if agent_str: 31 + try: 32 + agent = BackendName(agent_str) 33 + except ValueError: 34 + valid = [b.value for b in BackendName] 35 + raise ValueError( 36 + f"Invalid agent '{agent_str}' in {path}. " 37 + f"Valid agents: {', '.join(valid)}" 38 + ) from None 39 + 40 + return cls(agent=agent) 41 + 42 + 43 + def find_pixi_toml(start: Path | None = None) -> Path | None: 44 + """Find pixi.toml in current or parent directories. 45 + 46 + Args: 47 + start: Starting directory. Defaults to current working directory. 48 + 49 + Returns: 50 + Path to pixi.toml or None if not found. 51 + """ 52 + if start is None: 53 + start = Path.cwd() 54 + 55 + current = start.resolve() 56 + while True: 57 + candidate = current / "pixi.toml" 58 + if candidate.exists(): 59 + return candidate 60 + 61 + parent = current.parent 62 + if parent == current: # Reached filesystem root 63 + return None 64 + current = parent
+36
recipe/recipe.yaml
··· 1 + package: 2 + name: pixi-skills 3 + version: 0.1.3.dev1 4 + 5 + source: 6 + path: ../ 7 + 8 + build: 9 + number: 2 10 + script: 11 + - pip install . --no-build-isolation --no-deps -vv 12 + 13 + requirements: 14 + host: 15 + - python >=3.13 16 + - pip 17 + - hatchling 18 + run: 19 + - python >=3.13 20 + - typer >=0.12 21 + - rich >=13 22 + - questionary >=2 23 + - pyyaml >=6 24 + 25 + tests: 26 + - script: 27 + - pixi-skills --help 28 + - python: 29 + imports: 30 + - pixi_skills 31 + 32 + about: 33 + summary: Manage coding agent skills using pixi 34 + homepage: https://github.com/pavelzw/pixi-skills 35 + license: MIT 36 + license_file: LICENSE
+2
tests/test_backend.py
··· 16 16 KiloCodeBackend, 17 17 KiroBackend, 18 18 OpencodeBackend, 19 + PiBackend, 19 20 QoderBackend, 20 21 RooCodeBackend, 21 22 TraeBackend, ··· 61 62 (KiloCodeBackend, ".kilocode/skills", ".kilocode/skills"), 62 63 (KiroBackend, ".kiro/skills", ".kiro/skills"), 63 64 (OpencodeBackend, ".opencode/skills", ".opencode/skills"), 65 + (PiBackend, ".pi/skills", ".pi/agent/skills"), 64 66 (QoderBackend, ".qoder/skills", ".qoder/skills"), 65 67 (RooCodeBackend, ".roo/skills", ".roo/skills"), 66 68 (TraeBackend, ".trae/skills", ".trae/skills"),
+95
tests/test_config.py
··· 1 + """Tests for config module.""" 2 + 3 + import pytest 4 + 5 + from pixi_skills.backend import BackendName 6 + from pixi_skills.config import Config, find_pixi_toml 7 + 8 + 9 + class TestConfig: 10 + def test_from_toml_no_file(self, tmp_path): 11 + """Test loading config when file doesn't exist.""" 12 + config = Config.from_toml(tmp_path / "nonexistent.toml") 13 + assert config.agent is None 14 + 15 + def test_from_toml_empty_file(self, tmp_path): 16 + """Test loading config from empty file.""" 17 + config_file = tmp_path / "pixi.toml" 18 + config_file.write_text("") 19 + config = Config.from_toml(config_file) 20 + assert config.agent is None 21 + 22 + def test_from_toml_no_tool_section(self, tmp_path): 23 + """Test loading config from file without tool section.""" 24 + config_file = tmp_path / "pixi.toml" 25 + config_file.write_text('[workspace]\nname = "test"\n') 26 + config = Config.from_toml(config_file) 27 + assert config.agent is None 28 + 29 + def test_from_toml_no_pixi_skills_section(self, tmp_path): 30 + """Test loading config from file without pixi-skills section.""" 31 + config_file = tmp_path / "pixi.toml" 32 + config_file.write_text('[tool.other]\nkey = "value"\n') 33 + config = Config.from_toml(config_file) 34 + assert config.agent is None 35 + 36 + def test_from_toml_agent_configured(self, tmp_path): 37 + """Test loading config with agent set.""" 38 + config_file = tmp_path / "pixi.toml" 39 + config_file.write_text('[tool.pixi-skills]\nagent = "pi"\n') 40 + config = Config.from_toml(config_file) 41 + assert config.agent == BackendName.PI 42 + 43 + def test_from_toml_agent_claude(self, tmp_path): 44 + """Test loading config with claude agent.""" 45 + config_file = tmp_path / "pixi.toml" 46 + config_file.write_text('[tool.pixi-skills]\nagent = "claude"\n') 47 + config = Config.from_toml(config_file) 48 + assert config.agent == BackendName.CLAUDE 49 + 50 + def test_from_toml_invalid_agent(self, tmp_path): 51 + """Test loading config with invalid agent.""" 52 + config_file = tmp_path / "pixi.toml" 53 + config_file.write_text('[tool.pixi-skills]\nagent = "invalid"\n') 54 + with pytest.raises(ValueError, match="Invalid agent 'invalid'"): 55 + Config.from_toml(config_file) 56 + 57 + 58 + class TestFindPixiToml: 59 + def test_find_in_current_dir(self, tmp_path): 60 + """Test finding pixi.toml in current directory.""" 61 + config_file = tmp_path / "pixi.toml" 62 + config_file.write_text("") 63 + result = find_pixi_toml(tmp_path) 64 + assert result == config_file 65 + 66 + def test_find_in_parent_dir(self, tmp_path): 67 + """Test finding pixi.toml in parent directory.""" 68 + config_file = tmp_path / "pixi.toml" 69 + config_file.write_text("") 70 + subdir = tmp_path / "subdir" 71 + subdir.mkdir() 72 + result = find_pixi_toml(subdir) 73 + assert result == config_file 74 + 75 + def test_not_found(self, tmp_path): 76 + """Test when pixi.toml doesn't exist.""" 77 + result = find_pixi_toml(tmp_path) 78 + assert result is None 79 + 80 + def test_find_from_cwd(self, tmp_path, monkeypatch): 81 + """Test finding from current working directory.""" 82 + config_file = tmp_path / "pixi.toml" 83 + config_file.write_text("") 84 + monkeypatch.chdir(tmp_path) 85 + result = find_pixi_toml() 86 + assert result == config_file 87 + 88 + def test_nested_directory_search(self, tmp_path): 89 + """Test searching up through nested directories.""" 90 + config_file = tmp_path / "pixi.toml" 91 + config_file.write_text("") 92 + deep_dir = tmp_path / "a" / "b" / "c" 93 + deep_dir.mkdir(parents=True) 94 + result = find_pixi_toml(deep_dir) 95 + assert result == config_file