Source code for neurocore.skills.loader
"""Skill discovery — directory scan + entry points.
Two discovery mechanisms, merged into a unified SkillRegistry:
1. **Directory scan** — walks a skills/ directory, imports .py files,
finds Skill subclasses.
2. **Entry points** — scans the `neurocore.skills` group from installed
packages (pyproject.toml [project.entry-points."neurocore.skills"]).
Entry points take precedence — a pip-installed skill wins over a
local copy with the same name.
Usage:
from neurocore.skills.loader import discover_skills
from neurocore.config import load_config
config = load_config()
registry = discover_skills(config)
print(registry.list_skills())
"""
from __future__ import annotations
import importlib
import importlib.util
import inspect
import sys
from importlib.metadata import entry_points
from pathlib import Path
from typing import TYPE_CHECKING
from neurocore.errors import SkillError
from neurocore.skills.base import Skill, SkillMeta
from neurocore.skills.registry import SkillRegistry
if TYPE_CHECKING:
from neurocore.config.schema import NeuroCoreConfig
# Entry point group name for skill discovery
ENTRY_POINT_GROUP = "neurocore.skills"
[docs]
def discover_directory(
skills_dir: Path,
*,
registry: SkillRegistry | None = None,
) -> SkillRegistry:
"""Discover skills by scanning a directory for .py files.
Walks the directory (non-recursive by default), imports each .py
file as a module, and finds all Skill subclasses with valid
skill_meta.
Args:
skills_dir: Path to the skills directory.
registry: Existing registry to add to. Creates a new one if None.
Returns:
SkillRegistry with discovered skills.
Raises:
SkillError: If a skill file cannot be imported (logged, not fatal).
"""
if registry is None:
registry = SkillRegistry()
if not skills_dir.is_dir():
return registry
for py_file in sorted(skills_dir.glob("*.py")):
if py_file.name.startswith("_"):
continue # Skip __init__.py, __pycache__, _private.py
skill_classes = _import_skills_from_file(py_file)
for skill_class in skill_classes:
try:
registry.register(skill_class)
except SkillError:
# Duplicate name — skip silently (entry points will override)
pass
return registry
def _import_skills_from_file(file_path: Path) -> list[type[Skill]]:
"""Import a Python file and extract Skill subclasses.
Args:
file_path: Path to the .py file.
Returns:
List of Skill subclasses found in the file.
Raises:
SkillError: If the file cannot be imported.
"""
module_name = f"neurocore_skills.{file_path.stem}"
try:
spec = importlib.util.spec_from_file_location(module_name, file_path)
if spec is None or spec.loader is None:
raise SkillError(f"Cannot create module spec for {file_path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
except Exception as e:
# Clean up sys.modules on failure
sys.modules.pop(module_name, None)
raise SkillError(f"Failed to import skill file {file_path}: {e}") from e
# Find all Skill subclasses defined in this module
skills: list[type[Skill]] = []
for _name, obj in inspect.getmembers(module, inspect.isclass):
if (
issubclass(obj, Skill)
and obj is not Skill
and obj.__module__ == module_name
and hasattr(obj, "skill_meta")
and isinstance(obj.skill_meta, SkillMeta)
):
skills.append(obj)
return skills
[docs]
def discover_entry_points(
*,
registry: SkillRegistry | None = None,
) -> SkillRegistry:
"""Discover skills from installed package entry points.
Scans the `neurocore.skills` entry point group. Each entry point
should point to a Skill subclass.
Entry point format in pyproject.toml:
[project.entry-points."neurocore.skills"]
neuroweave = "neurocore_skill_neuroweave:NeuroWeaveSkill"
Args:
registry: Existing registry to add to. Creates a new one if None.
Returns:
SkillRegistry with discovered skills.
"""
if registry is None:
registry = SkillRegistry()
eps = entry_points(group=ENTRY_POINT_GROUP)
for ep in eps:
try:
skill_class = ep.load()
except Exception:
# Failed to load entry point — skip
continue
if (
isinstance(skill_class, type)
and issubclass(skill_class, Skill)
and hasattr(skill_class, "skill_meta")
and isinstance(skill_class.skill_meta, SkillMeta)
):
try:
# Entry points get replace=True precedence
registry.register(skill_class, replace=True)
except SkillError:
pass
return registry
[docs]
def discover_skills(
config: NeuroCoreConfig,
*,
registry: SkillRegistry | None = None,
) -> SkillRegistry:
"""Discover all skills — directory scan + entry points.
Discovery order:
1. Directory scan (skills/ folder) — baseline
2. Entry points (pip-installed packages) — override duplicates
This means entry-point skills take precedence over local skills
with the same name.
Args:
config: NeuroCoreConfig with skills_dir path.
registry: Existing registry to add to. Creates a new one if None.
Returns:
SkillRegistry with all discovered skills.
"""
if registry is None:
registry = SkillRegistry()
# Phase 1: Directory scan (lower priority)
discover_directory(config.skills_dir, registry=registry)
# Phase 2: Entry points (higher priority — replace=True)
discover_entry_points(registry=registry)
return registry