Coverage for src / agent / skills / registry.py: 100%
92 statements
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
« prev ^ index » next coverage.py v7.13.0, created at 2025-12-11 14:30 +0000
1# Copyright 2025-2026 Microsoft Corporation
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
15"""Skill registry for tracking installed skills.
17This module provides persistence and lookup for installed skills with
18atomic file operations and canonical name matching.
19"""
21import json
22import os
23import tempfile
24from datetime import datetime
25from pathlib import Path
26from typing import Any
28from agent.skills.errors import SkillNotFoundError
29from agent.skills.manifest import SkillRegistryEntry
30from agent.skills.security import normalize_skill_name
33class SkillRegistry:
34 """Registry for tracking installed skills with JSON persistence.
36 Provides atomic writes and case-insensitive lookups by canonical name.
38 Attributes:
39 registry_path: Path to registry.json file
41 Example:
42 >>> registry = SkillRegistry()
43 >>> entry = SkillRegistryEntry(
44 ... name="kalshi-markets",
45 ... name_canonical="kalshi-markets",
46 ... installed_path=Path("/path/to/skill"),
47 ... trusted=True
48 ... )
49 >>> registry.register(entry)
50 >>> skill = registry.get("kalshi-markets")
51 """
53 def __init__(self, registry_path: Path | None = None):
54 """Initialize skill registry.
56 Args:
57 registry_path: Custom registry path (defaults to ~/.osdu-agent/skills/registry.json)
58 """
59 if registry_path is None:
60 registry_path = Path.home() / ".osdu-agent" / "skills" / "registry.json"
62 self.registry_path = registry_path
63 self._ensure_registry_dir()
65 def _ensure_registry_dir(self) -> None:
66 """Ensure registry directory exists."""
67 self.registry_path.parent.mkdir(parents=True, exist_ok=True)
69 def _load_registry(self) -> dict[str, Any]:
70 """Load registry from JSON file.
72 Returns:
73 Dictionary mapping canonical names to skill data
74 """
75 if not self.registry_path.exists():
76 return {}
78 try:
79 with open(self.registry_path, encoding="utf-8") as f:
80 data: dict[str, Any] = json.load(f)
81 # Convert ISO datetime strings back to datetime objects
82 for entry in data.values():
83 if "installed_at" in entry:
84 entry["installed_at"] = datetime.fromisoformat(entry["installed_at"])
85 if "installed_path" in entry:
86 entry["installed_path"] = Path(entry["installed_path"])
87 return data
88 except (json.JSONDecodeError, KeyError):
89 # Corrupted registry, start fresh
90 return {}
92 def _save_registry(self, data: dict[str, Any]) -> None:
93 """Save registry to JSON file atomically.
95 Uses temp file + os.replace() for atomic writes to prevent corruption.
97 Args:
98 data: Registry data to save
99 """
100 # Convert to JSON-serializable format
101 serializable = {}
102 for canonical_name, entry in data.items():
103 entry_copy = entry.copy()
104 if "installed_at" in entry_copy and isinstance(entry_copy["installed_at"], datetime):
105 entry_copy["installed_at"] = entry_copy["installed_at"].isoformat()
106 if "installed_path" in entry_copy and isinstance(entry_copy["installed_path"], Path):
107 entry_copy["installed_path"] = str(entry_copy["installed_path"])
108 serializable[canonical_name] = entry_copy
110 # Atomic write via temp file + os.replace()
111 fd, temp_path = tempfile.mkstemp(
112 dir=self.registry_path.parent, prefix=".registry-", suffix=".tmp"
113 )
114 try:
115 with os.fdopen(fd, "w", encoding="utf-8") as f:
116 json.dump(serializable, f, indent=2)
117 # Atomic replace (cross-platform safe)
118 os.replace(temp_path, self.registry_path)
119 except Exception:
120 # Clean up temp file on error
121 try:
122 os.unlink(temp_path)
123 except FileNotFoundError:
124 # Safe to ignore if temp file doesn't exist (already deleted or never created)
125 pass
126 raise
128 def register(self, entry: SkillRegistryEntry) -> None:
129 """Register a new skill.
131 Args:
132 entry: SkillRegistryEntry to add
134 Raises:
135 ValueError: If skill with canonical name already exists
136 """
137 data = self._load_registry()
139 if entry.name_canonical in data:
140 raise ValueError(f"Skill '{entry.name_canonical}' already registered")
142 # Convert to dict for storage
143 entry_dict = entry.model_dump()
144 data[entry.name_canonical] = entry_dict
146 self._save_registry(data)
148 def unregister(self, canonical_name: str) -> None:
149 """Unregister a skill.
151 Args:
152 canonical_name: Canonical skill name
154 Raises:
155 SkillNotFoundError: If skill not found in registry
156 """
157 data = self._load_registry()
159 if canonical_name not in data:
160 raise SkillNotFoundError(f"Skill '{canonical_name}' not found in registry")
162 del data[canonical_name]
163 self._save_registry(data)
165 def get(self, name: str) -> SkillRegistryEntry:
166 """Get skill by name (case-insensitive).
168 Args:
169 name: Skill name (any case/format)
171 Returns:
172 SkillRegistryEntry for the skill
174 Raises:
175 SkillNotFoundError: If skill not found
176 """
177 canonical_name = normalize_skill_name(name)
178 data = self._load_registry()
180 if canonical_name not in data:
181 raise SkillNotFoundError(f"Skill '{name}' not found in registry")
183 return SkillRegistryEntry(**data[canonical_name])
185 def get_by_canonical_name(self, canonical_name: str) -> SkillRegistryEntry:
186 """Get skill by canonical name (already normalized).
188 Args:
189 canonical_name: Canonical skill name (lowercase, hyphens)
191 Returns:
192 SkillRegistryEntry for the skill
194 Raises:
195 SkillNotFoundError: If skill not found
196 """
197 data = self._load_registry()
199 if canonical_name not in data:
200 raise SkillNotFoundError(f"Skill '{canonical_name}' not found in registry")
202 return SkillRegistryEntry(**data[canonical_name])
204 def list(self) -> list[SkillRegistryEntry]:
205 """List all registered skills.
207 Returns:
208 List of SkillRegistryEntry, sorted by canonical name (stable order)
209 """
210 data = self._load_registry()
212 # Sort by canonical name for stable ordering
213 sorted_names = sorted(data.keys())
215 return [SkillRegistryEntry(**data[name]) for name in sorted_names]
217 def update_sha(self, canonical_name: str, commit_sha: str) -> None:
218 """Update commit SHA for a skill.
220 Args:
221 canonical_name: Canonical skill name
222 commit_sha: New commit SHA
224 Raises:
225 SkillNotFoundError: If skill not found
226 """
227 data = self._load_registry()
229 if canonical_name not in data:
230 raise SkillNotFoundError(f"Skill '{canonical_name}' not found in registry")
232 data[canonical_name]["commit_sha"] = commit_sha
233 self._save_registry(data)
235 def exists(self, name: str) -> bool:
236 """Check if skill exists in registry.
238 Args:
239 name: Skill name (any case/format)
241 Returns:
242 True if skill is registered, False otherwise
243 """
244 try:
245 canonical_name = normalize_skill_name(name)
246 data = self._load_registry()
247 return canonical_name in data
248 except Exception:
249 return False