"""Import and normalize skills from multiple sources for immediate project use.""" from __future__ import annotations import asyncio import re import shutil from dataclasses import dataclass, field from datetime import datetime, timezone from pathlib import Path from typing import Any, Awaitable, Callable import yaml from loguru import logger from opc.layer5_memory.secretary_policy import SecretaryPolicyManager from opc.layer5_memory.skill_library import SkillLibrary ALLOWED_FRONTMATTER_KEYS = { "name", "description", "metadata", "always", "license", "allowed-tools", "homepage", } ALLOWED_RESOURCE_DIRS = {"scripts", "references", "assets"} MAX_SKILL_NAME_LENGTH = 64 _SLUG_RE = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") _PLACEHOLDER_MARKERS = ("[todo", "todo:") _STOPWORDS = { "clawhub", "skill", "skills", "search", "install", "update", "results", "latest", } CommandRunner = Callable[[list[str], Path], Awaitable[tuple[int, str, str]]] class SkillImportError(RuntimeError): """Raised when importing or normalizing a skill fails.""" @dataclass class SkillImportResult: skill_name: str skill_path: str source_slug: str validation_message: str available: bool = True enabled_domains: list[str] = field(default_factory=list) search_output: str = "" warnings: list[str] = field(default_factory=list) class ExternalSkillImporter: """Imports skills from external or local sources and normalizes them.""" def __init__( self, skill_library: SkillLibrary, policies: SecretaryPolicyManager | None = None, command_runner: CommandRunner | None = None, ) -> None: self.skill_library = skill_library self.policies = policies self.opc_home = skill_library.projects_dir.parent self.command_runner = command_runner or self._run_command async def import_skill( self, *, project_id: str, source: str = "clawhub", query: str = "", slug: str = "", path: str = "", domains: list[str] | None = None, enable: bool = True, ) -> SkillImportResult: if not project_id: raise SkillImportError("Skill import requires a project context.") cleaned_query = str(query).strip() cleaned_slug = self.normalize_skill_name(slug) cleaned_source = str(source or "clawhub").strip().lower() source_path = str(path).strip() search_output = "" project_root = self.opc_home / "projects" / project_id skills_root = project_root / "skills" project_root.mkdir(parents=True, exist_ok=True) skills_root.mkdir(parents=True, exist_ok=True) imported_dir: Path if cleaned_source == "clawhub": if not cleaned_slug: cleaned_slug, search_output = await self._resolve_slug(cleaned_query) imported_dir = await self._install_from_clawhub( project_root=project_root, skills_root=skills_root, slug=cleaned_slug, ) elif cleaned_source in {"path", "directory", "local"}: imported_dir = self._prepare_local_source( skills_root=skills_root, source_path=source_path, suggested_name=cleaned_slug or Path(source_path or "imported-skill").name, ) cleaned_slug = cleaned_slug or self.normalize_skill_name(Path(source_path).name) else: raise SkillImportError( f"Unsupported skill source '{cleaned_source}'. Supported sources: clawhub, path." ) final_dir, final_name, warnings = self._normalize_imported_dir( imported_dir, source=cleaned_source, source_slug=cleaned_slug, query=cleaned_query, source_path=source_path, ) valid, validation_message = validate_skill_directory(final_dir) if not valid: raise SkillImportError(validation_message) self.skill_library.load_all(project_id) available = self.skill_library.get(final_name) is not None if not available: raise SkillImportError(f"Imported skill '{final_name}' was normalized but did not load into the skill library.") enabled_domains = self._enable_domains(final_name, project_id, domains or [], enable=enable) return SkillImportResult( skill_name=final_name, skill_path=str(final_dir), source_slug=cleaned_slug, validation_message=validation_message, available=True, enabled_domains=enabled_domains, search_output=search_output, warnings=warnings, ) async def _install_from_clawhub(self, *, project_root: Path, skills_root: Path, slug: str) -> Path: before = {child.name for child in skills_root.iterdir() if child.is_dir()} exit_code, stdout, stderr = await self.command_runner( ["npx", "--yes", "clawhub@latest", "install", slug, "--workdir", str(project_root)], self.opc_home, ) if exit_code != 0: message = (stderr or stdout).strip() if "Unsupported engine" in message or "Node.js v" in message: raise SkillImportError( "ClawHub install failed. This environment needs Node.js >= 20 to run " "`npx clawhub@latest`." ) raise SkillImportError(message or f"ClawHub install failed for '{slug}'.") return self._locate_imported_dir(skills_root, before, slug) def _prepare_local_source(self, *, skills_root: Path, source_path: str, suggested_name: str) -> Path: if not source_path: raise SkillImportError("Path-based skill import requires a `path`.") resolved = Path(source_path).expanduser().resolve(strict=False) if not resolved.exists() or not resolved.is_dir(): raise SkillImportError(f"Local skill source does not exist or is not a directory: {resolved}") prepared_name = self.normalize_skill_name(suggested_name) or "imported-skill" prepared_dir = self._build_prepare_dir(skills_root, prepared_name) shutil.copytree(resolved, prepared_dir, dirs_exist_ok=False) return prepared_dir async def _resolve_slug(self, query: str) -> tuple[str, str]: cleaned = str(query).strip().strip("`\"'") if not cleaned: raise SkillImportError("Need a skill query or exact slug to import.") if self._looks_like_slug(cleaned): return self.normalize_skill_name(cleaned), "" exit_code, stdout, stderr = await self.command_runner( ["npx", "--yes", "clawhub@latest", "search", cleaned, "--limit", "5"], self.opc_home, ) if exit_code != 0: message = (stderr or stdout).strip() if "Unsupported engine" in message or "Node.js v" in message: raise SkillImportError( "ClawHub search failed. This environment needs Node.js >= 20 to run " "`npx clawhub@latest`." ) raise SkillImportError(message or f"ClawHub search failed for '{cleaned}'.") candidates = self._extract_slug_candidates(stdout) if not candidates: raise SkillImportError( "Could not determine a ClawHub slug from search results. Please specify the exact skill slug." ) return candidates[0], stdout.strip() def _locate_imported_dir(self, skills_root: Path, before: set[str], slug: str) -> Path: preferred = skills_root / self.normalize_skill_name(slug) if preferred.exists(): return preferred after_dirs = [child for child in skills_root.iterdir() if child.is_dir()] new_dirs = [child for child in after_dirs if child.name not in before] if len(new_dirs) == 1: return new_dirs[0] matches = [child for child in after_dirs if self.normalize_skill_name(slug) in child.name] if len(matches) == 1: return matches[0] if after_dirs: after_dirs.sort(key=lambda item: item.stat().st_mtime, reverse=True) return after_dirs[0] raise SkillImportError(f"Could not locate the installed skill directory for '{slug}'.") def _normalize_imported_dir( self, skill_dir: Path, *, source: str, source_slug: str, query: str, source_path: str, ) -> tuple[Path, str, list[str]]: source_skill_md = skill_dir / "SKILL.md" warnings: list[str] = [] if not source_skill_md.exists(): matches = sorted(skill_dir.rglob("SKILL.md")) if not matches: raise SkillImportError(f"Installed skill at {skill_dir} does not contain a SKILL.md file.") source_skill_md = matches[0] if source_skill_md.parent != skill_dir: warnings.append("Imported skill had nested content; normalized from the nested SKILL.md root.") frontmatter, body = _load_skill_document(source_skill_md.read_text(encoding="utf-8")) proposed_name = ( str(frontmatter.get("name", "")).strip() or source_slug or skill_dir.name or "imported-skill" ) desired_name = self.normalize_skill_name(proposed_name) or self.normalize_skill_name(source_slug) or "imported-skill" final_name = self._dedupe_skill_name(skill_dir.parent, desired_name, current_dir=skill_dir) description = self._normalize_description(frontmatter.get("description"), body, final_name) normalized_frontmatter: dict[str, Any] = { "name": final_name, "description": description, } if isinstance(frontmatter.get("always"), bool): normalized_frontmatter["always"] = frontmatter["always"] metadata: dict[str, Any] = {} if isinstance(frontmatter.get("metadata"), dict): metadata.update(frontmatter["metadata"]) imported_extra_frontmatter: dict[str, Any] = {} for key, value in frontmatter.items(): if key in {"name", "description", "always", "metadata"}: continue if key in {"license", "allowed-tools", "homepage"} and value not in (None, ""): normalized_frontmatter[key] = value else: imported_extra_frontmatter[key] = value metadata.setdefault("imported_from", { "source": source, "slug": source_slug, "query": query, "path": source_path, "imported_at": datetime.now(timezone.utc).isoformat(), }) if imported_extra_frontmatter: metadata["imported_frontmatter"] = imported_extra_frontmatter if metadata: normalized_frontmatter["metadata"] = metadata normalized_body = body.strip() if not normalized_body: normalized_body = ( f"# {final_name}\n\n" f"Imported and normalized from {source}. Fill in more guidance if this skill needs project-specific detail.\n" ) warnings.append("Imported skill had no body content; inserted a minimal placeholder body.") prepare_dir = self._build_prepare_dir(skill_dir.parent, final_name) prepare_dir.mkdir(parents=True, exist_ok=False) try: skill_md = prepare_dir / "SKILL.md" skill_md.write_text( _render_skill_document(normalized_frontmatter, normalized_body), encoding="utf-8", ) source_root = source_skill_md.parent for child in source_root.iterdir(): if child.name == "SKILL.md": continue if child.is_dir() and child.name in ALLOWED_RESOURCE_DIRS: shutil.copytree(child, prepare_dir / child.name, dirs_exist_ok=True) continue fallback = prepare_dir / "assets" / "imported-root" / child.name _copy_path(child, fallback) final_dir = skill_dir.parent / final_name backup_dir = self._build_backup_dir(skill_dir.parent, skill_dir.name) if backup_dir.exists(): shutil.rmtree(backup_dir, ignore_errors=True) if skill_dir.exists(): skill_dir.rename(backup_dir) prepare_dir.rename(final_dir) shutil.rmtree(backup_dir, ignore_errors=True) return final_dir, final_name, warnings except Exception: shutil.rmtree(prepare_dir, ignore_errors=True) raise def _enable_domains(self, skill_name: str, project_id: str, domains: list[str], *, enable: bool) -> list[str]: if not enable or not self.policies: return [] normalized_domains: list[str] = [] for item in domains: value = str(item).strip() if value and value not in normalized_domains: normalized_domains.append(value) if not normalized_domains: return [] existing_rules = self.policies.load_project(project_id).get("skill_injection_rules", []) for rule in existing_rules: if not rule.get("enabled", True): continue current_domains = [str(item).strip() for item in rule.get("domains", []) if str(item).strip()] current_skills = [str(item).strip() for item in rule.get("skill_names", []) if str(item).strip()] if current_domains == normalized_domains and skill_name in current_skills: return normalized_domains self.policies.add_rule( "skill_injection_rules", { "domains": normalized_domains, "skill_names": [skill_name], "rationale": "Imported by the secretary and enabled for immediate project use.", }, project_id=project_id, ) return normalized_domains def _extract_slug_candidates(self, output: str) -> list[str]: candidates: list[str] = [] for raw_line in output.splitlines(): line = raw_line.strip() if not line: continue matches = [ re.search(r"`([a-z0-9]+(?:-[a-z0-9]+)*)`", line), re.search(r"^\d+[\).\s-]+([a-z0-9]+(?:-[a-z0-9]+)*)\b", line), re.search(r"^\|\s*([a-z0-9]+(?:-[a-z0-9]+)*)\s*\|", line), re.search(r"^[-*]\s*([a-z0-9]+(?:-[a-z0-9]+)*)\b", line), ] chosen = next((match.group(1) for match in matches if match), "") if not chosen: for token in _SLUG_RE.findall(line): if token in _STOPWORDS: continue if "-" not in token and len(token) < 6: continue chosen = token break if chosen and chosen not in candidates: candidates.append(chosen) return candidates @staticmethod def normalize_skill_name(raw: str) -> str: normalized = raw.strip().lower() normalized = re.sub(r"[^a-z0-9]+", "-", normalized) normalized = normalized.strip("-") normalized = re.sub(r"-{2,}", "-", normalized) return normalized[:MAX_SKILL_NAME_LENGTH] @staticmethod def _looks_like_slug(value: str) -> bool: return bool(re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", value.strip().lower())) def _normalize_description(self, description: Any, body: str, fallback_name: str) -> str: if isinstance(description, str) and _validate_description(description.strip()) is None: return description.strip() heading_match = re.search(r"^\s*#\s+(.+?)\s*$", body, re.MULTILINE) first_sentence = "" for line in body.splitlines(): stripped = line.strip().strip("#").strip() if not stripped: continue if stripped.lower().startswith("description:"): continue first_sentence = stripped break fallback = heading_match.group(1).strip() if heading_match else first_sentence fallback = fallback.strip() if not fallback: fallback = f"Imported skill `{fallback_name}`." if len(fallback) > 1024: fallback = fallback[:1021].rstrip() + "..." if _validate_description(fallback) is not None: return f"Imported skill `{fallback_name}` for project use." return fallback def _dedupe_skill_name(self, parent: Path, desired_name: str, *, current_dir: Path) -> str: candidate = desired_name[:MAX_SKILL_NAME_LENGTH] if not candidate: candidate = "imported-skill" if not (parent / candidate).exists() or current_dir.name == candidate: return candidate for index in range(2, 100): suffix = f"-{index}" trimmed = candidate[: MAX_SKILL_NAME_LENGTH - len(suffix)].rstrip("-") attempt = f"{trimmed}{suffix}" if not (parent / attempt).exists(): return attempt raise SkillImportError(f"Could not find an available normalized name for imported skill '{desired_name}'.") @staticmethod def _build_prepare_dir(parent: Path, final_name: str) -> Path: for index in range(1, 100): temp_dir = parent / f".{final_name}.normalize-{index}" if not temp_dir.exists(): return temp_dir raise SkillImportError(f"Could not allocate a temporary normalization directory for '{final_name}'.") @staticmethod def _build_backup_dir(parent: Path, original_name: str) -> Path: for index in range(1, 100): backup = parent / f".{original_name}.backup-{index}" if not backup.exists(): return backup raise SkillImportError(f"Could not allocate a backup directory for '{original_name}'.") @staticmethod async def _run_command(argv: list[str], cwd: Path) -> tuple[int, str, str]: proc = await asyncio.create_subprocess_exec( *argv, cwd=str(cwd), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout_bytes, stderr_bytes = await proc.communicate() return proc.returncode or 0, stdout_bytes.decode("utf-8", errors="replace"), stderr_bytes.decode("utf-8", errors="replace") def validate_skill_directory(skill_path: Path) -> tuple[bool, str]: skill_path = Path(skill_path).resolve() if not skill_path.exists(): return False, f"Skill folder not found: {skill_path}" if not skill_path.is_dir(): return False, f"Path is not a directory: {skill_path}" skill_md = skill_path / "SKILL.md" if not skill_md.exists(): return False, "SKILL.md not found" frontmatter, _ = _load_skill_document(skill_md.read_text(encoding="utf-8")) unexpected_keys = sorted(set(frontmatter.keys()) - ALLOWED_FRONTMATTER_KEYS) if unexpected_keys: allowed = ", ".join(sorted(ALLOWED_FRONTMATTER_KEYS)) return False, ( f"Unexpected key(s) in SKILL.md frontmatter: {', '.join(unexpected_keys)}. " f"Allowed properties are: {allowed}" ) name = frontmatter.get("name") if not isinstance(name, str): return False, "Missing or invalid 'name' in frontmatter" name_error = _validate_skill_name(name.strip(), skill_path.name) if name_error: return False, name_error description = frontmatter.get("description") if not isinstance(description, str): return False, "Missing or invalid 'description' in frontmatter" description_error = _validate_description(description.strip()) if description_error: return False, description_error always = frontmatter.get("always") if always is not None and not isinstance(always, bool): return False, f"'always' must be a boolean, got {type(always).__name__}" metadata = frontmatter.get("metadata") if metadata is not None and not isinstance(metadata, dict): return False, f"'metadata' must be a dictionary, got {type(metadata).__name__}" for child in skill_path.iterdir(): if child.name == "SKILL.md": continue if child.is_dir() and child.name in ALLOWED_RESOURCE_DIRS: continue if child.is_symlink(): continue return ( False, f"Unexpected file or directory in skill root: {child.name}. " "Only SKILL.md, scripts/, references/, and assets/ are allowed.", ) return True, "Skill is valid!" def _load_skill_document(text: str) -> tuple[dict[str, Any], str]: if text.startswith("---"): parts = text.split("\n") for index in range(1, len(parts)): if parts[index].strip() == "---": frontmatter_text = "\n".join(parts[1:index]) body = "\n".join(parts[index + 1 :]).lstrip("\n") try: frontmatter = yaml.safe_load(frontmatter_text) or {} except yaml.YAMLError as exc: logger.warning(f"Failed to parse skill frontmatter: {exc}") frontmatter = {} return frontmatter if isinstance(frontmatter, dict) else {}, body return {}, text def _render_skill_document(frontmatter: dict[str, Any], body: str) -> str: fm = yaml.dump(frontmatter, default_flow_style=False, allow_unicode=True, sort_keys=False).strip() return f"---\n{fm}\n---\n\n{body.rstrip()}\n" def _copy_path(source: Path, destination: Path) -> None: destination.parent.mkdir(parents=True, exist_ok=True) if source.is_dir(): shutil.copytree(source, destination, dirs_exist_ok=True) else: shutil.copy2(source, destination) def _validate_skill_name(name: str, folder_name: str) -> str | None: if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name): return ( f"Name '{name}' should be hyphen-case " "(lowercase letters, digits, and single hyphens only)" ) if len(name) > MAX_SKILL_NAME_LENGTH: return ( f"Name is too long ({len(name)} characters). Maximum is {MAX_SKILL_NAME_LENGTH} characters." ) if name != folder_name: return f"Skill name '{name}' must match directory name '{folder_name}'" return None def _validate_description(description: str) -> str | None: trimmed = description.strip() if not trimmed: return "Description cannot be empty" lowered = trimmed.lower() if any(marker in lowered for marker in _PLACEHOLDER_MARKERS): return "Description still contains TODO placeholder text" if "<" in trimmed or ">" in trimmed: return "Description cannot contain angle brackets (< or >)" if len(trimmed) > 1024: return f"Description is too long ({len(trimmed)} characters). Maximum is 1024 characters." return None