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

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. 

14 

15"""Skill manager for lifecycle operations. 

16 

17This module handles skill installation, updates, and removal with git operations. 

18""" 

19 

20import gc 

21import logging 

22import shutil 

23import sys 

24from datetime import datetime 

25from pathlib import Path 

26from typing import Any 

27 

28from git import Repo 

29 

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 

34 

35logger = logging.getLogger(__name__) 

36 

37 

38class SkillManager: 

39 """Manage skill lifecycle: install, update, remove. 

40 

41 Handles git operations for skill installation from repositories. 

42 

43 Example: 

44 >>> manager = SkillManager() 

45 >>> manager.install("https://github.com/example/skill", trusted=True) 

46 """ 

47 

48 def __init__(self, skills_dir: Path | None = None): 

49 """Initialize skill manager. 

50 

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" 

56 

57 self.skills_dir = skills_dir 

58 

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) 

62 

63 # Ensure skills directory exists 

64 self.skills_dir.mkdir(parents=True, exist_ok=True) 

65 

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. 

75 

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) 

81 

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) 

88 

89 Returns: 

90 List of SkillRegistryEntry for installed skills (single-skill: 1 entry, monorepo/marketplace: multiple) 

91 

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() 

99 

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") 

105 

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}...") 

112 

113 # Clone repository 

114 clone_kwargs: dict[str, Any] = {"depth": 1} # Shallow clone for speed 

115 if branch: 

116 clone_kwargs["branch"] = branch 

117 

118 repo = Repo.clone_from(git_url, temp_dir, **clone_kwargs) 

119 

120 # Checkout tag if specified 

121 if tag: 

122 repo.git.checkout(tag) 

123 

124 # Get commit SHA 

125 commit_sha = pin_commit_sha(temp_dir) 

126 

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" 

131 

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 

154 

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 ) 

160 

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 ) 

166 

167 except Exception as e: 

168 logger.error(f"Failed to install skill: {e}") 

169 raise SkillError(f"Installation failed: {e}") 

170 

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() 

180 

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 ) 

194 

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. 

206 

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 

215 

216 Returns: 

217 List with single SkillRegistryEntry 

218 """ 

219 # Validate SKILL.md 

220 manifest_path = temp_dir / "SKILL.md" 

221 validate_manifest(manifest_path) 

222 

223 # Parse manifest 

224 manifest = parse_skill_manifest(temp_dir) 

225 

226 # Use manifest name or custom name 

227 final_name = skill_name or manifest.name 

228 canonical_name = normalize_skill_name(final_name) 

229 

230 # Check if already installed 

231 if self.registry.exists(canonical_name): 

232 raise SkillError(f"Skill '{canonical_name}' is already installed") 

233 

234 # Move to final location 

235 final_path = self.skills_dir / canonical_name 

236 if final_path.exists(): 

237 shutil.rmtree(final_path) 

238 

239 shutil.move(str(temp_dir), str(final_path)) 

240 

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 ) 

253 

254 self.registry.register(entry) 

255 

256 logger.info(f"Successfully installed skill '{final_name}' at {final_path}") 

257 return [entry] 

258 

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. 

269 

270 Scans for subdirectories containing SKILL.md and installs each as a separate skill. 

271 

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 

279 

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) 

290 

291 if not skill_dirs: 

292 raise SkillError( 

293 "No skills found in repository. Expected SKILL.md in root or subdirectories." 

294 ) 

295 

296 logger.info(f"Found {len(skill_dirs)} skills in monorepo") 

297 

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) 

305 

306 # Parse manifest 

307 manifest = parse_skill_manifest(skill_dir) 

308 canonical_name = normalize_skill_name(manifest.name) 

309 

310 # Check if already installed 

311 if self.registry.exists(canonical_name): 

312 logger.warning(f"Skill '{canonical_name}' already installed, skipping") 

313 continue 

314 

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) 

319 

320 shutil.copytree(skill_dir, final_path) 

321 

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 ) 

334 

335 self.registry.register(entry) 

336 installed_entries.append(entry) 

337 

338 logger.info(f"Successfully installed skill '{manifest.name}' at {final_path}") 

339 

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 

344 

345 if not installed_entries: 

346 raise SkillError("No skills were successfully installed from repository") 

347 

348 return installed_entries 

349 

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. 

360 

361 Scans plugins/{plugin-name}/skills/{skill-name}/SKILL.md pattern 

362 and installs each skill found. 

363 

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 

371 

372 Returns: 

373 List of SkillRegistryEntry for all installed skills 

374 """ 

375 plugins_dir = temp_dir / "plugins" 

376 skill_dirs = [] 

377 

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 

382 

383 skills_subdir = plugin_dir / "skills" 

384 if not skills_subdir.exists() or not skills_subdir.is_dir(): 

385 continue 

386 

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) 

393 

394 if not skill_dirs: 

395 raise SkillError( 

396 "No skills found in marketplace structure. " 

397 "Expected plugins/{plugin}/skills/{skill}/SKILL.md" 

398 ) 

399 

400 logger.info(f"Found {len(skill_dirs)} skills in marketplace structure") 

401 

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) 

409 

410 # Parse manifest 

411 manifest = parse_skill_manifest(skill_dir) 

412 canonical_name = normalize_skill_name(manifest.name) 

413 

414 # Check if already installed 

415 if self.registry.exists(canonical_name): 

416 logger.warning(f"Skill '{canonical_name}' already installed, skipping") 

417 continue 

418 

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) 

423 

424 shutil.copytree(skill_dir, final_path) 

425 

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 ) 

438 

439 self.registry.register(entry) 

440 installed_entries.append(entry) 

441 

442 logger.info(f"Successfully installed skill '{manifest.name}' at {final_path}") 

443 

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 

448 

449 if not installed_entries: 

450 raise SkillError("No skills were successfully installed from marketplace") 

451 

452 return installed_entries 

453 

454 def update(self, skill_name: str, confirm: bool = True) -> SkillRegistryEntry: 

455 """Update a skill to the latest version. 

456 

457 Uses uninstall + reinstall strategy to ensure clean updates. 

458 Works with all repository structures (single-skill, monorepo, marketplace). 

459 

460 Args: 

461 skill_name: Skill name to update 

462 confirm: Require confirmation for update (Phase 2) 

463 

464 Returns: 

465 Updated SkillRegistryEntry 

466 

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) 

473 

474 if entry.git_url is None: 

475 raise SkillError(f"Skill '{skill_name}' is a bundled skill and cannot be updated") 

476 

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 

483 

484 try: 

485 logger.info(f"Updating skill '{skill_name}' (uninstall + reinstall)") 

486 

487 # Step 1: Remove existing installation 

488 self.remove(skill_name) 

489 

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 ) 

494 

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") 

499 

500 new_sha = updated_entry.commit_sha 

501 

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" 

505 

506 logger.info( 

507 f"Successfully updated skill '{skill_name}' from {old_sha_short} to {new_sha_short}" 

508 ) 

509 return updated_entry 

510 

511 except Exception as e: 

512 logger.error(f"Failed to update skill: {e}") 

513 raise SkillError(f"Update failed: {e}") 

514 

515 def remove(self, skill_name: str) -> None: 

516 """Remove an installed skill. 

517 

518 Args: 

519 skill_name: Skill name to remove 

520 

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) 

527 

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}") 

533 

534 # Unregister 

535 self.registry.unregister(canonical_name) 

536 

537 logger.info(f"Successfully removed skill '{skill_name}'") 

538 

539 except Exception as e: 

540 logger.error(f"Failed to remove skill: {e}") 

541 raise SkillError(f"Removal failed: {e}") 

542 

543 def list_installed(self) -> list[SkillRegistryEntry]: 

544 """List all installed skills. 

545 

546 Returns: 

547 List of SkillRegistryEntry, sorted by canonical name 

548 """ 

549 return self.registry.list() 

550 

551 def info(self, skill_name: str) -> dict: 

552 """Get detailed information about a skill. 

553 

554 Args: 

555 skill_name: Skill name 

556 

557 Returns: 

558 Dict with skill metadata, manifest, scripts, and toolsets 

559 

560 Raises: 

561 SkillNotFoundError: If skill not found 

562 """ 

563 canonical_name = normalize_skill_name(skill_name) 

564 entry = self.registry.get(canonical_name) 

565 

566 # Parse manifest 

567 manifest = parse_skill_manifest(entry.installed_path) 

568 

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"))) 

574 

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 } 

593 

594 def _cleanup_temp_dirs(self) -> None: 

595 """Clean up temporary directories from previous runs. 

596 

597 On Windows, GitPython sometimes doesn't release file handles immediately, 

598 leaving temporary directories behind. This method attempts to clean them up. 

599 

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 

605 

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 

609 

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) 

616 

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 ) 

623 

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}")