Coverage for src / agent / skills / manager.py: 87%
232 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 manager for lifecycle operations.
17This module handles skill installation, updates, and removal with git operations.
18"""
20import gc
21import logging
22import shutil
23import sys
24from datetime import datetime
25from pathlib import Path
26from typing import Any
28from git import Repo
30from agent.skills.errors import SkillError
31from agent.skills.manifest import SkillRegistryEntry, parse_skill_manifest
32from agent.skills.registry import SkillRegistry
33from agent.skills.security import normalize_skill_name, pin_commit_sha, validate_manifest
35logger = logging.getLogger(__name__)
38class SkillManager:
39 """Manage skill lifecycle: install, update, remove.
41 Handles git operations for skill installation from repositories.
43 Example:
44 >>> manager = SkillManager()
45 >>> manager.install("https://github.com/example/skill", trusted=True)
46 """
48 def __init__(self, skills_dir: Path | None = None):
49 """Initialize skill manager.
51 Args:
52 skills_dir: Directory for installed skills (default: ~/.osdu-agent/skills)
53 """
54 if skills_dir is None:
55 skills_dir = Path.home() / ".osdu-agent" / "skills"
57 self.skills_dir = skills_dir
59 # Use registry in skills_dir to keep tests isolated
60 registry_path = self.skills_dir / "registry.json"
61 self.registry = SkillRegistry(registry_path=registry_path)
63 # Ensure skills directory exists
64 self.skills_dir.mkdir(parents=True, exist_ok=True)
66 def install(
67 self,
68 git_url: str,
69 skill_name: str | None = None,
70 branch: str | None = None,
71 tag: str | None = None,
72 trusted: bool = False,
73 ) -> list[SkillRegistryEntry]:
74 """Install skill(s) from a git repository.
76 Supports multiple repository structures:
77 - Single-skill: SKILL.md in repository root
78 - Single-skill subdirectory: SKILL.md in skill/ subdirectory
79 - Monorepo: Multiple subdirectories each containing SKILL.md
80 - Marketplace: plugins/{plugin-name}/skills/{skill-name}/SKILL.md (Claude Code compatible)
82 Args:
83 git_url: Git repository URL
84 skill_name: Custom skill name for single-skill repos (auto-detected if None)
85 branch: Git branch to clone (default: main/master)
86 tag: Git tag to checkout (takes precedence over branch)
87 trusted: Mark skill as trusted (skip confirmation prompt)
89 Returns:
90 List of SkillRegistryEntry for installed skills (single-skill: 1 entry, monorepo/marketplace: multiple)
92 Raises:
93 SkillError: If installation fails
94 SkillManifestError: If SKILL.md is invalid
95 """
96 # Clean up any leftover temporary directories from previous runs
97 # Do this at the start of install() to avoid interfering with tests
98 self._cleanup_temp_dirs()
100 # Early check for duplicate if skill_name is provided
101 if skill_name:
102 canonical_check = normalize_skill_name(skill_name)
103 if self.registry.exists(canonical_check):
104 raise SkillError(f"Skill '{canonical_check}' is already installed")
106 temp_dir = None
107 repo = None
108 try:
109 # Clone to temporary directory first
110 temp_dir = self.skills_dir / f".temp-{datetime.now().timestamp()}"
111 logger.info(f"Cloning skill from {git_url}...")
113 # Clone repository
114 clone_kwargs: dict[str, Any] = {"depth": 1} # Shallow clone for speed
115 if branch:
116 clone_kwargs["branch"] = branch
118 repo = Repo.clone_from(git_url, temp_dir, **clone_kwargs)
120 # Checkout tag if specified
121 if tag:
122 repo.git.checkout(tag)
124 # Get commit SHA
125 commit_sha = pin_commit_sha(temp_dir)
127 # Detect repository structure (priority order)
128 root_manifest = temp_dir / "SKILL.md"
129 skill_subdir_manifest = temp_dir / "skill" / "SKILL.md"
130 plugins_dir = temp_dir / "plugins"
132 if root_manifest.exists():
133 # Scenario 1: Single-skill repository (SKILL.md in root)
134 logger.info("Detected single-skill repo (SKILL.md at root)")
135 return self._install_single_skill(
136 temp_dir, git_url, commit_sha, branch, tag, trusted, skill_name
137 )
138 elif skill_subdir_manifest.exists():
139 # Scenario 2: Single-skill in skill/ subdirectory
140 # Common for repos with docs/tests at root, skill in subfolder
141 logger.info("Detected single-skill repo (SKILL.md in skill/ subdirectory)")
142 skill_dir = temp_dir / "skill"
143 return self._install_single_skill(
144 skill_dir, git_url, commit_sha, branch, tag, trusted, skill_name
145 )
146 elif plugins_dir.exists() and plugins_dir.is_dir():
147 # Scenario 3: Claude Code marketplace structure
148 # Check if plugins/ contains subdirectories with skills/ subdirectory
149 has_marketplace_structure = False
150 for item in plugins_dir.iterdir():
151 if item.is_dir() and (item / "skills").is_dir():
152 has_marketplace_structure = True
153 break
155 if has_marketplace_structure:
156 logger.info("Detected Claude Code marketplace structure")
157 return self._install_marketplace_plugins(
158 temp_dir, git_url, commit_sha, branch, tag, trusted
159 )
161 # Scenario 4: Monorepo - scan for subdirectories with SKILL.md
162 logger.info("Scanning for monorepo structure (multiple skills)")
163 return self._install_monorepo_skills(
164 temp_dir, git_url, commit_sha, branch, tag, trusted
165 )
167 except Exception as e:
168 logger.error(f"Failed to install skill: {e}")
169 raise SkillError(f"Installation failed: {e}")
171 finally:
172 # Close git repository to release file handles (important on Windows)
173 if repo is not None:
174 repo.close()
175 # On Windows, GitPython may not immediately release all file handles
176 # Force garbage collection to ensure cleanup
177 if sys.platform == "win32":
178 del repo
179 gc.collect()
181 # Cleanup temp directory if it still exists
182 if temp_dir and temp_dir.exists():
183 try:
184 shutil.rmtree(temp_dir)
185 except (PermissionError, FileNotFoundError) as e:
186 # On Windows, sometimes file handles aren't immediately released (PermissionError)
187 # or the directory was already moved (FileNotFoundError)
188 # Log the error but don't fail the installation if it succeeded
189 if isinstance(e, PermissionError):
190 logger.warning(
191 f"Could not delete temporary directory {temp_dir}: {e}. "
192 "This is harmless and will be cleaned up on next run."
193 )
195 def _install_single_skill(
196 self,
197 temp_dir: Path,
198 git_url: str,
199 commit_sha: str,
200 branch: str | None,
201 tag: str | None,
202 trusted: bool,
203 skill_name: str | None = None,
204 ) -> list[SkillRegistryEntry]:
205 """Install a single skill from repository root.
207 Args:
208 temp_dir: Temporary directory with cloned repo
209 git_url: Git repository URL
210 commit_sha: Commit SHA
211 branch: Git branch used
212 tag: Git tag used
213 trusted: Trusted flag
214 skill_name: Optional custom skill name
216 Returns:
217 List with single SkillRegistryEntry
218 """
219 # Validate SKILL.md
220 manifest_path = temp_dir / "SKILL.md"
221 validate_manifest(manifest_path)
223 # Parse manifest
224 manifest = parse_skill_manifest(temp_dir)
226 # Use manifest name or custom name
227 final_name = skill_name or manifest.name
228 canonical_name = normalize_skill_name(final_name)
230 # Check if already installed
231 if self.registry.exists(canonical_name):
232 raise SkillError(f"Skill '{canonical_name}' is already installed")
234 # Move to final location
235 final_path = self.skills_dir / canonical_name
236 if final_path.exists():
237 shutil.rmtree(final_path)
239 shutil.move(str(temp_dir), str(final_path))
241 # Register skill
242 entry = SkillRegistryEntry(
243 name=final_name,
244 name_canonical=canonical_name,
245 git_url=git_url,
246 commit_sha=commit_sha,
247 branch=branch,
248 tag=tag,
249 installed_path=final_path,
250 trusted=trusted,
251 installed_at=datetime.now(),
252 )
254 self.registry.register(entry)
256 logger.info(f"Successfully installed skill '{final_name}' at {final_path}")
257 return [entry]
259 def _install_monorepo_skills(
260 self,
261 temp_dir: Path,
262 git_url: str,
263 commit_sha: str,
264 branch: str | None,
265 tag: str | None,
266 trusted: bool,
267 ) -> list[SkillRegistryEntry]:
268 """Install multiple skills from a monorepo structure.
270 Scans for subdirectories containing SKILL.md and installs each as a separate skill.
272 Args:
273 temp_dir: Temporary directory with cloned repo
274 git_url: Git repository URL
275 commit_sha: Commit SHA
276 branch: Git branch used
277 tag: Git tag used
278 trusted: Trusted flag
280 Returns:
281 List of SkillRegistryEntry for all installed skills
282 """
283 # Scan for skill subdirectories
284 skill_dirs = []
285 for item in temp_dir.iterdir():
286 if item.is_dir() and not item.name.startswith("."):
287 skill_md = item / "SKILL.md"
288 if skill_md.exists() and skill_md.is_file():
289 skill_dirs.append(item)
291 if not skill_dirs:
292 raise SkillError(
293 "No skills found in repository. Expected SKILL.md in root or subdirectories."
294 )
296 logger.info(f"Found {len(skill_dirs)} skills in monorepo")
298 # Install each skill
299 installed_entries = []
300 for skill_dir in skill_dirs:
301 try:
302 # Validate SKILL.md
303 manifest_path = skill_dir / "SKILL.md"
304 validate_manifest(manifest_path)
306 # Parse manifest
307 manifest = parse_skill_manifest(skill_dir)
308 canonical_name = normalize_skill_name(manifest.name)
310 # Check if already installed
311 if self.registry.exists(canonical_name):
312 logger.warning(f"Skill '{canonical_name}' already installed, skipping")
313 continue
315 # Copy skill directory to final location
316 final_path = self.skills_dir / canonical_name
317 if final_path.exists():
318 shutil.rmtree(final_path)
320 shutil.copytree(skill_dir, final_path)
322 # Register skill
323 entry = SkillRegistryEntry(
324 name=manifest.name,
325 name_canonical=canonical_name,
326 git_url=git_url,
327 commit_sha=commit_sha,
328 branch=branch,
329 tag=tag,
330 installed_path=final_path,
331 trusted=trusted,
332 installed_at=datetime.now(),
333 )
335 self.registry.register(entry)
336 installed_entries.append(entry)
338 logger.info(f"Successfully installed skill '{manifest.name}' at {final_path}")
340 except Exception as e:
341 logger.error(f"Failed to install skill from {skill_dir.name}: {e}")
342 # Continue with other skills
343 continue
345 if not installed_entries:
346 raise SkillError("No skills were successfully installed from repository")
348 return installed_entries
350 def _install_marketplace_plugins(
351 self,
352 temp_dir: Path,
353 git_url: str,
354 commit_sha: str,
355 branch: str | None,
356 tag: str | None,
357 trusted: bool,
358 ) -> list[SkillRegistryEntry]:
359 """Install skills from Claude Code marketplace structure.
361 Scans plugins/{plugin-name}/skills/{skill-name}/SKILL.md pattern
362 and installs each skill found.
364 Args:
365 temp_dir: Temporary directory with cloned repo
366 git_url: Git repository URL
367 commit_sha: Commit SHA
368 branch: Git branch used
369 tag: Git tag used
370 trusted: Trusted flag
372 Returns:
373 List of SkillRegistryEntry for all installed skills
374 """
375 plugins_dir = temp_dir / "plugins"
376 skill_dirs = []
378 # Scan marketplace structure: plugins/*/skills/*/SKILL.md
379 for plugin_dir in plugins_dir.iterdir():
380 if not plugin_dir.is_dir() or plugin_dir.name.startswith("."):
381 continue
383 skills_subdir = plugin_dir / "skills"
384 if not skills_subdir.exists() or not skills_subdir.is_dir():
385 continue
387 # Scan for skills within this plugin's skills/ directory
388 for skill_dir in skills_subdir.iterdir():
389 if skill_dir.is_dir() and not skill_dir.name.startswith("."):
390 skill_md = skill_dir / "SKILL.md"
391 if skill_md.exists() and skill_md.is_file():
392 skill_dirs.append(skill_dir)
394 if not skill_dirs:
395 raise SkillError(
396 "No skills found in marketplace structure. "
397 "Expected plugins/{plugin}/skills/{skill}/SKILL.md"
398 )
400 logger.info(f"Found {len(skill_dirs)} skills in marketplace structure")
402 # Install each skill
403 installed_entries = []
404 for skill_dir in skill_dirs:
405 try:
406 # Validate SKILL.md
407 manifest_path = skill_dir / "SKILL.md"
408 validate_manifest(manifest_path)
410 # Parse manifest
411 manifest = parse_skill_manifest(skill_dir)
412 canonical_name = normalize_skill_name(manifest.name)
414 # Check if already installed
415 if self.registry.exists(canonical_name):
416 logger.warning(f"Skill '{canonical_name}' already installed, skipping")
417 continue
419 # Copy skill directory to final location
420 final_path = self.skills_dir / canonical_name
421 if final_path.exists():
422 shutil.rmtree(final_path)
424 shutil.copytree(skill_dir, final_path)
426 # Register skill
427 entry = SkillRegistryEntry(
428 name=manifest.name,
429 name_canonical=canonical_name,
430 git_url=git_url,
431 commit_sha=commit_sha,
432 branch=branch,
433 tag=tag,
434 installed_path=final_path,
435 trusted=trusted,
436 installed_at=datetime.now(),
437 )
439 self.registry.register(entry)
440 installed_entries.append(entry)
442 logger.info(f"Successfully installed skill '{manifest.name}' at {final_path}")
444 except Exception as e:
445 logger.error(f"Failed to install skill from {skill_dir.name}: {e}")
446 # Continue with other skills
447 continue
449 if not installed_entries:
450 raise SkillError("No skills were successfully installed from marketplace")
452 return installed_entries
454 def update(self, skill_name: str, confirm: bool = True) -> SkillRegistryEntry:
455 """Update a skill to the latest version.
457 Uses uninstall + reinstall strategy to ensure clean updates.
458 Works with all repository structures (single-skill, monorepo, marketplace).
460 Args:
461 skill_name: Skill name to update
462 confirm: Require confirmation for update (Phase 2)
464 Returns:
465 Updated SkillRegistryEntry
467 Raises:
468 SkillNotFoundError: If skill not found
469 SkillError: If update fails
470 """
471 canonical_name = normalize_skill_name(skill_name)
472 entry = self.registry.get(canonical_name)
474 if entry.git_url is None:
475 raise SkillError(f"Skill '{skill_name}' is a bundled skill and cannot be updated")
477 # Save installation parameters
478 git_url = entry.git_url
479 branch = entry.branch
480 tag = entry.tag
481 trusted = entry.trusted
482 old_sha = entry.commit_sha
484 try:
485 logger.info(f"Updating skill '{skill_name}' (uninstall + reinstall)")
487 # Step 1: Remove existing installation
488 self.remove(skill_name)
490 # Step 2: Reinstall from original source (use entry.name to preserve original casing)
491 entries = self.install(
492 git_url=git_url, branch=branch, tag=tag, trusted=trusted, skill_name=entry.name
493 )
495 # Find the updated entry (should be first for single-skill, or match name for monorepo)
496 updated_entry = next((e for e in entries if e.name_canonical == canonical_name), None)
497 if not updated_entry:
498 raise SkillError(f"Skill '{skill_name}' not found after reinstall")
500 new_sha = updated_entry.commit_sha
502 # Format SHAs for logging (should always exist for git skills, but handle None for type safety)
503 old_sha_short = old_sha[:8] if old_sha else "unknown"
504 new_sha_short = new_sha[:8] if new_sha else "unknown"
506 logger.info(
507 f"Successfully updated skill '{skill_name}' from {old_sha_short} to {new_sha_short}"
508 )
509 return updated_entry
511 except Exception as e:
512 logger.error(f"Failed to update skill: {e}")
513 raise SkillError(f"Update failed: {e}")
515 def remove(self, skill_name: str) -> None:
516 """Remove an installed skill.
518 Args:
519 skill_name: Skill name to remove
521 Raises:
522 SkillNotFoundError: If skill not found
523 SkillError: If removal fails
524 """
525 canonical_name = normalize_skill_name(skill_name)
526 entry = self.registry.get(canonical_name)
528 try:
529 # Remove directory
530 if entry.installed_path.exists():
531 shutil.rmtree(entry.installed_path)
532 logger.info(f"Removed skill directory: {entry.installed_path}")
534 # Unregister
535 self.registry.unregister(canonical_name)
537 logger.info(f"Successfully removed skill '{skill_name}'")
539 except Exception as e:
540 logger.error(f"Failed to remove skill: {e}")
541 raise SkillError(f"Removal failed: {e}")
543 def list_installed(self) -> list[SkillRegistryEntry]:
544 """List all installed skills.
546 Returns:
547 List of SkillRegistryEntry, sorted by canonical name
548 """
549 return self.registry.list()
551 def info(self, skill_name: str) -> dict:
552 """Get detailed information about a skill.
554 Args:
555 skill_name: Skill name
557 Returns:
558 Dict with skill metadata, manifest, scripts, and toolsets
560 Raises:
561 SkillNotFoundError: If skill not found
562 """
563 canonical_name = normalize_skill_name(skill_name)
564 entry = self.registry.get(canonical_name)
566 # Parse manifest
567 manifest = parse_skill_manifest(entry.installed_path)
569 # Count scripts
570 scripts_dir = entry.installed_path / "scripts"
571 script_count = 0
572 if scripts_dir.exists():
573 script_count = len(list(scripts_dir.glob("*.py")))
575 return {
576 "name": entry.name,
577 "canonical_name": entry.name_canonical,
578 "description": manifest.description,
579 "version": manifest.version,
580 "author": manifest.author,
581 "repository": manifest.repository,
582 "git_url": entry.git_url,
583 "commit_sha": entry.commit_sha,
584 "branch": entry.branch,
585 "tag": entry.tag,
586 "installed_path": str(entry.installed_path),
587 "trusted": entry.trusted,
588 "installed_at": entry.installed_at.isoformat(),
589 "toolsets_count": len(manifest.toolsets),
590 "scripts_count": script_count,
591 "toolsets": manifest.toolsets,
592 }
594 def _cleanup_temp_dirs(self) -> None:
595 """Clean up temporary directories from previous runs.
597 On Windows, GitPython sometimes doesn't release file handles immediately,
598 leaving temporary directories behind. This method attempts to clean them up.
600 Only removes temp directories that are "stale" - either older than 1 hour,
601 or from a timestamp that's clearly invalid (e.g., from test mocking in the past).
602 """
603 if not self.skills_dir.exists():
604 return
606 current_time = datetime.now().timestamp()
607 one_minute_ago = current_time - 60 # 1 minute in seconds
608 one_hour_ago = current_time - 3600 # 1 hour in seconds
610 for item in self.skills_dir.iterdir():
611 if item.is_dir() and item.name.startswith(".temp-"):
612 try:
613 # Extract timestamp from directory name: .temp-{timestamp}
614 timestamp_str = item.name[len(".temp-") :]
615 dir_timestamp = float(timestamp_str)
617 # Only remove if older than 1 hour OR if timestamp is in the far past (likely from tests)
618 # Directories created within the last minute are considered "in progress" and left alone
619 is_stale = (
620 dir_timestamp < one_hour_ago # Older than 1 hour
621 and dir_timestamp < one_minute_ago # Not brand new
622 )
624 if is_stale:
625 shutil.rmtree(item)
626 logger.debug(f"Cleaned up leftover temp directory: {item}")
627 except ValueError:
628 # Invalid timestamp format, skip
629 logger.debug(f"Skipping temp directory with invalid timestamp: {item}")
630 except Exception as e:
631 # If we can't delete it now, it might still be in use
632 # or have permission issues. Just log and continue.
633 logger.debug(f"Could not clean up temp directory {item}: {e}")