c889a57b6b
Test Suites / Build CI Environment (push) Has been cancelled
Test Suites / Basic Tests (push) Has been cancelled
Test Suites / End-to-End Tests (push) Has been cancelled
Test Suites / CLI Tests (push) Has been cancelled
Test Suites / Slow End-to-End Tests (push) Has been cancelled
Test Suites / Graph Database Tests (push) Has been cancelled
Test Suites / Vector DB Tests (push) Has been cancelled
Test Suites / Temporal Graph Test (push) Has been cancelled
Test Suites / Search Test on Different DBs (push) Has been cancelled
Test Suites / Example Tests (push) Has been cancelled
Test Suites / Notebook Tests (push) Has been cancelled
Test Suites / OS and Python Tests Ubuntu (push) Has been cancelled
Test Suites / OS and Python Tests Extended (push) Has been cancelled
Test Suites / LLM Test Suite (push) Has been cancelled
Test Suites / S3 File Storage Test (push) Has been cancelled
Test Suites / Run Integration Tests (push) Has been cancelled
Test Suites / MCP Tests (push) Has been cancelled
Test Suites / Docker Compose Test (push) Has been cancelled
Test Suites / Docker CI test (push) Has been cancelled
Test Suites / Relational DB Migration Tests (push) Has been cancelled
Test Suites / Distributed Cognee Test (push) Has been cancelled
Test Suites / DB Examples Tests (push) Has been cancelled
Test Suites / Test Completion Status (push) Has been cancelled
Test Suites / Claude Code Review (push) Has been cancelled
Test Suites / basic checks (push) Has been cancelled
build | Build and Push Cognee MCP Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
build | Build and Push Docker Image to dockerhub / docker-build-and-push (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.11) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Core Functionality (3.12) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (kuzu, kuzu) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges with Different Graph Databases (neo4j, neo4j) (push) Has been cancelled
Weighted Edges Tests / Test Weighted Edges Examples (push) Has been cancelled
Weighted Edges Tests / Code Quality for Weighted Edges (push) Has been cancelled
197 lines
6.5 KiB
Python
197 lines
6.5 KiB
Python
"""Small SKILL.md parser for the v1 agentic-skills flow."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
from uuid import UUID, uuid5
|
|
|
|
import yaml
|
|
|
|
from cognee.modules.engine.models import Skill
|
|
from cognee.modules.tools.path_safety import (
|
|
trusted_is_dir,
|
|
trusted_is_file,
|
|
trusted_read_text,
|
|
trusted_rglob,
|
|
)
|
|
from cognee.shared.logging_utils import get_logger
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
NAMESPACE = UUID("a1b2c3d4-e5f6-7890-abcd-ef1234567890")
|
|
|
|
_DESCRIPTION_ALIASES = ("description", "summary", "short_description", "about")
|
|
_TOOLS_ALIASES = ("allowed-tools", "allowed_tools", "declared_tools", "tools")
|
|
_MAINTAINER_ALIASES = ("maintainer", "company", "author", "publisher", "vendor", "org")
|
|
_MAINTAINER_URL_ALIASES = ("maintainer_url", "maintainer-url", "url", "homepage", "website")
|
|
_VERSION_ALIASES = ("version", "ver")
|
|
_TAGS_ALIASES = ("tags", "categories", "labels")
|
|
_LICENSE_ALIASES = ("license", "licence")
|
|
|
|
|
|
def _deterministic_id(namespace_key: str) -> UUID:
|
|
return uuid5(NAMESPACE, namespace_key)
|
|
|
|
|
|
def _content_hash(text: str) -> str:
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
|
|
|
|
|
def _normalize_path(path: Path) -> str:
|
|
return os.path.normpath(os.path.realpath(os.path.abspath(os.fspath(path))))
|
|
|
|
|
|
def _is_relative_to(path: Path, base_dir: Path) -> bool:
|
|
try:
|
|
path_str = _normalize_path(path)
|
|
base_str = _normalize_path(base_dir)
|
|
except (OSError, RuntimeError, ValueError):
|
|
return False
|
|
base_prefix = base_str if base_str.endswith(os.sep) else f"{base_str}{os.sep}"
|
|
return path_str == base_str or path_str.startswith(base_prefix)
|
|
|
|
|
|
def _parse_frontmatter(text: str) -> tuple[Dict[str, Any], str]:
|
|
match = re.match(r"^---\s*\r?\n(.*?)\r?\n---\s*\r?\n?(.*)", text, re.DOTALL)
|
|
if not match:
|
|
return {}, text.strip()
|
|
|
|
try:
|
|
frontmatter = yaml.safe_load(match.group(1)) or {}
|
|
except yaml.YAMLError as exc:
|
|
logger.warning("Failed to parse SKILL.md frontmatter: %s", exc)
|
|
frontmatter = {}
|
|
|
|
if not isinstance(frontmatter, dict):
|
|
frontmatter = {}
|
|
|
|
return frontmatter, match.group(2).strip()
|
|
|
|
|
|
def _pop_first(data: Dict[str, Any], aliases: tuple[str, ...]) -> Any:
|
|
for key in aliases:
|
|
if key in data:
|
|
return data.pop(key)
|
|
return None
|
|
|
|
|
|
def _extract_description(frontmatter: Dict[str, Any], body: str) -> str:
|
|
raw = _pop_first(frontmatter, _DESCRIPTION_ALIASES)
|
|
if raw:
|
|
return str(raw).strip()
|
|
|
|
for paragraph in re.split(r"\n{2,}", body):
|
|
paragraph = paragraph.strip()
|
|
if paragraph and not paragraph.startswith("#"):
|
|
return re.sub(r"[`*_~]", "", paragraph)[:500]
|
|
|
|
return ""
|
|
|
|
|
|
def _extract_tools(frontmatter: Dict[str, Any]) -> List[str]:
|
|
raw = _pop_first(frontmatter, _TOOLS_ALIASES)
|
|
if raw is None:
|
|
return []
|
|
if isinstance(raw, list):
|
|
return [str(tool).strip() for tool in raw if str(tool).strip()]
|
|
return [tool.strip() for tool in re.split(r"[\s,]+", str(raw)) if tool.strip()]
|
|
|
|
|
|
def _extract_str(frontmatter: Dict[str, Any], aliases: tuple[str, ...]) -> str:
|
|
raw = _pop_first(frontmatter, aliases)
|
|
return str(raw).strip() if raw is not None else ""
|
|
|
|
|
|
def _extract_list(frontmatter: Dict[str, Any], aliases: tuple[str, ...]) -> List[str]:
|
|
raw = _pop_first(frontmatter, aliases)
|
|
if raw is None:
|
|
return []
|
|
if isinstance(raw, list):
|
|
return [str(item).strip() for item in raw if str(item).strip()]
|
|
return [item.strip() for item in re.split(r"[\s,]+", str(raw)) if item.strip()]
|
|
|
|
|
|
def _skill_slug(skill_file: Path) -> str:
|
|
return skill_file.parent.name
|
|
|
|
|
|
def _build_search_text(name: str, description: str, procedure: str) -> str:
|
|
return "\n\n".join(part for part in (name, description, procedure) if part)
|
|
|
|
|
|
def parse_skill_file(
|
|
skill_md: Path,
|
|
source_repo: str = "",
|
|
skill_key: Optional[str] = None,
|
|
base_dir: Optional[Path] = None,
|
|
) -> Optional[Skill]:
|
|
"""Parse one concrete SKILL.md file into one Skill node."""
|
|
skill_md = Path(skill_md)
|
|
if base_dir is not None and not _is_relative_to(skill_md, base_dir):
|
|
raise ValueError(f"Skill file is outside the allowed base directory: {skill_md}")
|
|
if not trusted_is_file(skill_md) or skill_md.name.lower() != "skill.md":
|
|
return None
|
|
|
|
raw_text = trusted_read_text(skill_md, encoding="utf-8")
|
|
if not raw_text.strip():
|
|
return None
|
|
|
|
frontmatter, body = _parse_frontmatter(raw_text)
|
|
name = skill_key or _skill_slug(skill_md)
|
|
description = _extract_description(frontmatter, body)
|
|
declared_tools = _extract_tools(frontmatter)
|
|
maintainer = _extract_str(frontmatter, _MAINTAINER_ALIASES)
|
|
maintainer_url = _extract_str(frontmatter, _MAINTAINER_URL_ALIASES)
|
|
skill_version = _extract_str(frontmatter, _VERSION_ALIASES)
|
|
tags = _extract_list(frontmatter, _TAGS_ALIASES)
|
|
license_name = _extract_str(frontmatter, _LICENSE_ALIASES)
|
|
source_file = _normalize_path(skill_md)
|
|
source_dir = _normalize_path(skill_md.parent)
|
|
skill_text = _build_search_text(name, description, body)
|
|
|
|
return Skill(
|
|
id=_deterministic_id(f"skill:{source_dir}:{name}"),
|
|
name=name,
|
|
description=description,
|
|
procedure=body,
|
|
declared_tools=declared_tools,
|
|
maintainer=maintainer,
|
|
maintainer_url=maintainer_url,
|
|
skill_version=skill_version,
|
|
tags=tags,
|
|
license=license_name,
|
|
source_repo_url=source_repo,
|
|
source_file=source_file,
|
|
source_dir=source_dir,
|
|
content_hash=_content_hash(raw_text),
|
|
skill_text=skill_text,
|
|
search_text=skill_text,
|
|
belongs_to_set=["skills"],
|
|
)
|
|
|
|
|
|
def parse_skills_folder(
|
|
skills_root: str | Path,
|
|
source_repo: str = "",
|
|
base_dir: Optional[Path] = None,
|
|
) -> List[Skill]:
|
|
"""Parse every SKILL.md under a directory. Removed files are ignored in v1."""
|
|
skills_root = Path(skills_root)
|
|
if not trusted_is_dir(skills_root):
|
|
raise FileNotFoundError(f"Skills directory not found: {skills_root}")
|
|
base_dir = Path(base_dir) if base_dir is not None else skills_root
|
|
|
|
skills: List[Skill] = []
|
|
for skill_file in sorted(trusted_rglob(skills_root, "*")):
|
|
if not trusted_is_file(skill_file) or skill_file.name.lower() != "skill.md":
|
|
continue
|
|
skill = parse_skill_file(skill_file, source_repo=source_repo, base_dir=base_dir)
|
|
if skill is not None:
|
|
skills.append(skill)
|
|
return skills
|