llmrix-skill 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- llmrix/__init__.py +0 -0
- llmrix/skill/__init__.py +70 -0
- llmrix/skill/core/__init__.py +26 -0
- llmrix/skill/core/config.py +36 -0
- llmrix/skill/core/exceptions.py +23 -0
- llmrix/skill/core/plugin.py +31 -0
- llmrix/skill/core/utils.py +38 -0
- llmrix/skill/git/__init__.py +6 -0
- llmrix/skill/git/repository.py +109 -0
- llmrix/skill/models/__init__.py +7 -0
- llmrix/skill/models/metadata.py +46 -0
- llmrix/skill/models/schema.py +27 -0
- llmrix/skill/services/__init__.py +14 -0
- llmrix/skill/services/manager.py +98 -0
- llmrix/skill/services/publisher.py +151 -0
- llmrix/skill/services/syncer.py +121 -0
- llmrix/skill/storage/__init__.py +8 -0
- llmrix/skill/storage/base.py +39 -0
- llmrix/skill/storage/mysql.py +100 -0
- llmrix/skill/storage/sqlalchemy_store.py +144 -0
- llmrix_skill-0.2.0.dist-info/METADATA +138 -0
- llmrix_skill-0.2.0.dist-info/RECORD +24 -0
- llmrix_skill-0.2.0.dist-info/WHEEL +5 -0
- llmrix_skill-0.2.0.dist-info/top_level.txt +1 -0
llmrix/__init__.py
ADDED
|
File without changes
|
llmrix/skill/__init__.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""
|
|
2
|
+
llmrix.skill — Git-backed Skill management library.
|
|
3
|
+
|
|
4
|
+
Package layout:
|
|
5
|
+
core/ Cross-cutting concerns (config, exceptions, utils)
|
|
6
|
+
models/ Domain models and data schemas
|
|
7
|
+
git/ Low-level Git repository driver
|
|
8
|
+
storage/ Abstract storage interface + concrete adapters
|
|
9
|
+
services/ High-level orchestration (sync, publish, manage)
|
|
10
|
+
"""
|
|
11
|
+
# --- Core ---
|
|
12
|
+
from .core.config import SkillConfig
|
|
13
|
+
from .core.exceptions import (
|
|
14
|
+
GitSkillError,
|
|
15
|
+
SkillNotFoundError,
|
|
16
|
+
VersionNotFoundError,
|
|
17
|
+
PermissionDeniedError,
|
|
18
|
+
ValidationError,
|
|
19
|
+
GitOperationError,
|
|
20
|
+
)
|
|
21
|
+
from .core.utils import build_file_tree
|
|
22
|
+
from .core.plugin import BaseSkill
|
|
23
|
+
|
|
24
|
+
# --- Models ---
|
|
25
|
+
from .models.schema import Skill, SkillVersion
|
|
26
|
+
from .models.metadata import MetadataParser
|
|
27
|
+
|
|
28
|
+
# --- Git ---
|
|
29
|
+
from .git.repository import GitRepository
|
|
30
|
+
|
|
31
|
+
# --- Storage ---
|
|
32
|
+
from .storage.base import BaseStorage
|
|
33
|
+
from .storage.mysql import MySQLStorage
|
|
34
|
+
from .storage.sqlalchemy_store import SQLAlchemyStorage
|
|
35
|
+
|
|
36
|
+
# --- Services ---
|
|
37
|
+
from .services.manager import GitSkillManager
|
|
38
|
+
from .services.publisher import SkillPublisher
|
|
39
|
+
from .services.syncer import SkillSyncer, RemoteSource, SyncResult
|
|
40
|
+
|
|
41
|
+
__version__ = "0.2.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
# Core
|
|
45
|
+
"SkillConfig",
|
|
46
|
+
"GitSkillError",
|
|
47
|
+
"SkillNotFoundError",
|
|
48
|
+
"VersionNotFoundError",
|
|
49
|
+
"PermissionDeniedError",
|
|
50
|
+
"ValidationError",
|
|
51
|
+
"GitOperationError",
|
|
52
|
+
"build_file_tree",
|
|
53
|
+
"BaseSkill",
|
|
54
|
+
# Models
|
|
55
|
+
"Skill",
|
|
56
|
+
"SkillVersion",
|
|
57
|
+
"MetadataParser",
|
|
58
|
+
# Git
|
|
59
|
+
"GitRepository",
|
|
60
|
+
# Storage
|
|
61
|
+
"BaseStorage",
|
|
62
|
+
"MySQLStorage",
|
|
63
|
+
"SQLAlchemyStorage",
|
|
64
|
+
# Services
|
|
65
|
+
"GitSkillManager",
|
|
66
|
+
"SkillPublisher",
|
|
67
|
+
"SkillSyncer",
|
|
68
|
+
"RemoteSource",
|
|
69
|
+
"SyncResult",
|
|
70
|
+
]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
core: Cross-cutting concerns — configuration, exceptions, and utilities.
|
|
3
|
+
"""
|
|
4
|
+
from .config import SkillConfig
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
GitSkillError,
|
|
7
|
+
SkillNotFoundError,
|
|
8
|
+
VersionNotFoundError,
|
|
9
|
+
PermissionDeniedError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
GitOperationError,
|
|
12
|
+
)
|
|
13
|
+
from .utils import build_file_tree
|
|
14
|
+
from .plugin import BaseSkill
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"SkillConfig",
|
|
18
|
+
"GitSkillError",
|
|
19
|
+
"SkillNotFoundError",
|
|
20
|
+
"VersionNotFoundError",
|
|
21
|
+
"PermissionDeniedError",
|
|
22
|
+
"ValidationError",
|
|
23
|
+
"GitOperationError",
|
|
24
|
+
"build_file_tree",
|
|
25
|
+
"BaseSkill",
|
|
26
|
+
]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class SkillConfig:
|
|
7
|
+
"""Configuration for the Skill management system."""
|
|
8
|
+
repo_url: str
|
|
9
|
+
workspace: str
|
|
10
|
+
branch: str = "main"
|
|
11
|
+
skills_path: str = "skills"
|
|
12
|
+
cache_ttl: int = 300
|
|
13
|
+
|
|
14
|
+
@classmethod
|
|
15
|
+
def create(
|
|
16
|
+
cls,
|
|
17
|
+
repo_url: str,
|
|
18
|
+
workspace: Optional[str] = None,
|
|
19
|
+
branch: str = "main"
|
|
20
|
+
) -> "SkillConfig":
|
|
21
|
+
if not workspace:
|
|
22
|
+
workspace = os.path.expanduser("~/llmrix/skills/remote")
|
|
23
|
+
|
|
24
|
+
return cls(
|
|
25
|
+
repo_url=repo_url,
|
|
26
|
+
workspace=os.path.abspath(os.path.expanduser(workspace)),
|
|
27
|
+
branch=branch
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def cache_root(self) -> str:
|
|
32
|
+
return os.path.dirname(self.workspace)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def skills_root(self) -> str:
|
|
36
|
+
return os.path.join(self.workspace, self.skills_path)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class GitSkillError(Exception):
|
|
2
|
+
"""Base exception for gitskill library"""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
class SkillNotFoundError(GitSkillError):
|
|
6
|
+
"""Raised when a skill is not found in storage"""
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
class VersionNotFoundError(GitSkillError):
|
|
10
|
+
"""Raised when a specific version of a skill is not found"""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
class PermissionDeniedError(GitSkillError):
|
|
14
|
+
"""Raised when a user lacks permission to modify a skill"""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
class ValidationError(GitSkillError):
|
|
18
|
+
"""Raised when skill metadata or code is invalid"""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
class GitOperationError(GitSkillError):
|
|
22
|
+
"""Raised when a git command fails"""
|
|
23
|
+
pass
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, Dict
|
|
3
|
+
|
|
4
|
+
class BaseSkill(ABC):
|
|
5
|
+
"""
|
|
6
|
+
Standard interface contract for LLMRix skills.
|
|
7
|
+
|
|
8
|
+
Plugin developers should inherit from this class in their `main.py`
|
|
9
|
+
to ensure seamless integration with the host Agent framework.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
@property
|
|
13
|
+
def name(self) -> str:
|
|
14
|
+
"""The identifier name of the skill."""
|
|
15
|
+
return self.__class__.__name__
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
async def execute(self, **kwargs: Any) -> Any:
|
|
19
|
+
"""
|
|
20
|
+
The main execution entry point for the skill.
|
|
21
|
+
All core business logic and external API calls should be implemented here.
|
|
22
|
+
"""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def setup(self, context: Dict[str, Any]) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Optional initialization hook.
|
|
28
|
+
The Agent framework can pass configuration, credentials, or runtime context
|
|
29
|
+
here before the skill is executed.
|
|
30
|
+
"""
|
|
31
|
+
pass
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any, Dict, List
|
|
3
|
+
|
|
4
|
+
def build_file_tree(base_dir: str, rel_prefix: str = "") -> List[Dict[str, Any]]:
|
|
5
|
+
"""
|
|
6
|
+
Recursively builds a nested file tree (directories first, sorted by name).
|
|
7
|
+
Returns structure:
|
|
8
|
+
[
|
|
9
|
+
{"name": "scripts", "path": "scripts", "type": "dir", "children": [...]},
|
|
10
|
+
{"name": "SKILL.md", "path": "SKILL.md", "type": "file", "size": 1024},
|
|
11
|
+
]
|
|
12
|
+
"""
|
|
13
|
+
items: List[Dict[str, Any]] = []
|
|
14
|
+
try:
|
|
15
|
+
entries = sorted(os.scandir(base_dir), key=lambda e: (e.is_file(), e.name))
|
|
16
|
+
except PermissionError:
|
|
17
|
+
return items
|
|
18
|
+
|
|
19
|
+
for entry in entries:
|
|
20
|
+
if entry.name.startswith("."):
|
|
21
|
+
continue
|
|
22
|
+
rel_path = f"{rel_prefix}{entry.name}" if rel_prefix else entry.name
|
|
23
|
+
if entry.is_dir(follow_symlinks=False):
|
|
24
|
+
children = build_file_tree(entry.path, rel_path + "/")
|
|
25
|
+
items.append({
|
|
26
|
+
"name": entry.name,
|
|
27
|
+
"path": rel_path,
|
|
28
|
+
"type": "dir",
|
|
29
|
+
"children": children,
|
|
30
|
+
})
|
|
31
|
+
else:
|
|
32
|
+
items.append({
|
|
33
|
+
"name": entry.name,
|
|
34
|
+
"path": rel_path,
|
|
35
|
+
"type": "file",
|
|
36
|
+
"size": entry.stat().st_size,
|
|
37
|
+
})
|
|
38
|
+
return items
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from typing import Any, Generator, List, Optional
|
|
6
|
+
from llmrix.skill.core.exceptions import GitOperationError
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class GitRepository:
|
|
11
|
+
"""
|
|
12
|
+
Handles low-level Git operations for skills.
|
|
13
|
+
Focuses on repository maintenance, state transitions, and file management.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
def __init__(self, root: str, sub_dir: str = "skills"):
|
|
17
|
+
self.root = os.path.abspath(root)
|
|
18
|
+
self.sub_dir = sub_dir
|
|
19
|
+
self._lock_dir = os.path.join(self.root, ".locks")
|
|
20
|
+
|
|
21
|
+
def get_skill_path(self, code: str) -> str:
|
|
22
|
+
"""Returns absolute path to a skill directory."""
|
|
23
|
+
return os.path.join(self.root, self.sub_dir, code)
|
|
24
|
+
|
|
25
|
+
def get_relative_path(self, code: str) -> str:
|
|
26
|
+
"""Returns relative path to a skill from repo root."""
|
|
27
|
+
return f"{self.sub_dir}/{code}"
|
|
28
|
+
|
|
29
|
+
@contextmanager
|
|
30
|
+
def lock(self, code: str, timeout: int = 30) -> Generator[None, None, None]:
|
|
31
|
+
"""Provides a distributed file lock for a specific skill code."""
|
|
32
|
+
try:
|
|
33
|
+
from filelock import FileLock
|
|
34
|
+
os.makedirs(self._lock_dir, exist_ok=True)
|
|
35
|
+
lock = FileLock(os.path.join(self._lock_dir, f"{code}.lock"), timeout=timeout)
|
|
36
|
+
with lock:
|
|
37
|
+
yield
|
|
38
|
+
except ImportError:
|
|
39
|
+
logger.warning("filelock not installed, running without concurrency protection")
|
|
40
|
+
yield
|
|
41
|
+
|
|
42
|
+
def _execute(self, command: List[str]) -> str:
|
|
43
|
+
"""Executes a git command and returns the output."""
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["git"] + command,
|
|
47
|
+
cwd=self.root,
|
|
48
|
+
stdout=subprocess.PIPE,
|
|
49
|
+
stderr=subprocess.PIPE,
|
|
50
|
+
text=True,
|
|
51
|
+
check=True
|
|
52
|
+
)
|
|
53
|
+
return result.stdout.strip()
|
|
54
|
+
except subprocess.CalledProcessError as e:
|
|
55
|
+
error_msg = (e.stdout + "\\n" + e.stderr).strip()
|
|
56
|
+
raise GitOperationError(f"Git command {' '.join(command)} failed: {error_msg}") from e
|
|
57
|
+
|
|
58
|
+
def ensure_initialized(self, remote_url: Optional[str] = None, branch: str = "main"):
|
|
59
|
+
"""Ensures the repository is initialized and tracking the correct branch."""
|
|
60
|
+
if not os.path.isdir(os.path.join(self.root, ".git")):
|
|
61
|
+
if remote_url:
|
|
62
|
+
logger.info(f"Cloning skill repository from {remote_url} (branch: {branch})")
|
|
63
|
+
os.makedirs(os.path.dirname(self.root), exist_ok=True)
|
|
64
|
+
subprocess.run(["git", "clone", "--branch", branch, remote_url, self.root], check=True)
|
|
65
|
+
else:
|
|
66
|
+
logger.info(f"Initializing local skill repository (branch: {branch})")
|
|
67
|
+
os.makedirs(self.root, exist_ok=True)
|
|
68
|
+
self._execute(["init", "-b", branch])
|
|
69
|
+
|
|
70
|
+
# Ensure skills directory exists
|
|
71
|
+
os.makedirs(os.path.join(self.root, self.sub_dir), exist_ok=True)
|
|
72
|
+
|
|
73
|
+
def fetch_latest(self, remote: str = "origin", branch: str = "main"):
|
|
74
|
+
"""Pulls the latest changes from the remote repository."""
|
|
75
|
+
try:
|
|
76
|
+
self._execute(["pull", remote, branch])
|
|
77
|
+
except GitOperationError as e:
|
|
78
|
+
logger.warning(f"Git pull failed, falling back to local state: {e}")
|
|
79
|
+
|
|
80
|
+
def push_changes(self, remote: str = "origin", branch: str = "main"):
|
|
81
|
+
"""Pushes local commits to the remote repository."""
|
|
82
|
+
try:
|
|
83
|
+
self._execute(["push", remote, branch])
|
|
84
|
+
except GitOperationError as e:
|
|
85
|
+
logger.warning(f"Git push failed: {e}")
|
|
86
|
+
|
|
87
|
+
def commit_skill(self, code: str, author_id: Any, message: Optional[str] = None) -> str:
|
|
88
|
+
"""Adds and commits changes for a specific skill."""
|
|
89
|
+
rel_path = self.get_relative_path(code)
|
|
90
|
+
self._execute(["add", rel_path])
|
|
91
|
+
|
|
92
|
+
# Check for staged changes
|
|
93
|
+
diff = self._execute(["diff", "--cached", "--name-only"])
|
|
94
|
+
if not diff:
|
|
95
|
+
return self._execute(["rev-parse", "HEAD"])
|
|
96
|
+
|
|
97
|
+
msg = message or f"Update skill: {code} by user: {author_id}"
|
|
98
|
+
self._execute(["commit", "-m", msg])
|
|
99
|
+
return self._execute(["rev-parse", "HEAD"])
|
|
100
|
+
|
|
101
|
+
def revert_to_commit(self, code: str, commit_hash: str, author_id: Any, message: Optional[str] = None) -> str:
|
|
102
|
+
"""Checkouts a specific commit for a skill and commits the reversal."""
|
|
103
|
+
rel_path = self.get_relative_path(code)
|
|
104
|
+
self._execute(["checkout", commit_hash, "--", rel_path])
|
|
105
|
+
self._execute(["add", rel_path])
|
|
106
|
+
|
|
107
|
+
msg = message or f"Revert skill: {code} to {commit_hash} by user: {author_id}"
|
|
108
|
+
self._execute(["commit", "-m", msg])
|
|
109
|
+
return self._execute(["rev-parse", "HEAD"])
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import yaml
|
|
2
|
+
import re
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
from llmrix.skill.core.exceptions import ValidationError
|
|
5
|
+
|
|
6
|
+
class MetadataParser:
|
|
7
|
+
"""Parses skill files and validates codes."""
|
|
8
|
+
|
|
9
|
+
@staticmethod
|
|
10
|
+
def parse_manifest(content: str) -> Dict[str, Any]:
|
|
11
|
+
"""Extracts frontmatter from SKILL.md."""
|
|
12
|
+
if not content.startswith("---"):
|
|
13
|
+
return {}
|
|
14
|
+
try:
|
|
15
|
+
match = re.match(r"^---\s*\\n(.*?)\\n---\s*\\n", content, re.DOTALL)
|
|
16
|
+
if match:
|
|
17
|
+
return yaml.safe_load(match.group(1)) or {}
|
|
18
|
+
except Exception as e:
|
|
19
|
+
raise ValidationError(f"Failed to parse metadata frontmatter: {e}") from e
|
|
20
|
+
return {}
|
|
21
|
+
|
|
22
|
+
@staticmethod
|
|
23
|
+
def validate_code(code: str) -> None:
|
|
24
|
+
"""Ensures skill code follows naming conventions."""
|
|
25
|
+
if not (code and re.match(r"^[a-z0-9_\\-]+$", code)):
|
|
26
|
+
raise ValidationError(
|
|
27
|
+
f"Invalid skill code '{code}'. Only lowercase, numbers, - and _ are allowed."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
@staticmethod
|
|
31
|
+
def detect_category(code: str, name: str, description: str) -> str:
|
|
32
|
+
"""Heuristically detects skill category based on keywords."""
|
|
33
|
+
text = " ".join([code, name or "", description or ""]).lower()
|
|
34
|
+
|
|
35
|
+
KEYWORDS = [
|
|
36
|
+
("Developer Tools", ["git", "code", "python", "javascript", "typescript", "compiler", "debug", "ide", "sdk", "api"]),
|
|
37
|
+
("Data Analytics", ["data", "sql", "database", "excel", "csv", "chart", "analytics", "bi", "统计", "数据", "分析"]),
|
|
38
|
+
("Content Creation", ["image", "photo", "video", "audio", "canvas", "design", "content", "图片", "图像", "设计"]),
|
|
39
|
+
("Web & Search", ["search", "web", "browser", "crawl", "scrape", "http", "url", "搜索", "网页"]),
|
|
40
|
+
("System Integration", ["shell", "bash", "deploy", "docker", "k8s", "ci", "cd", "monitor", "email", "notify", "自动化", "集成"]),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
for category, keywords in KEYWORDS:
|
|
44
|
+
if any(kw in text for kw in keywords):
|
|
45
|
+
return category
|
|
46
|
+
return "Other"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any, Dict, Optional
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Skill:
|
|
7
|
+
"""Represents the current state of a skill."""
|
|
8
|
+
code: str
|
|
9
|
+
name: str
|
|
10
|
+
version: int = 1
|
|
11
|
+
description: Optional[str] = None
|
|
12
|
+
category: str = "Other"
|
|
13
|
+
commit_hash: Optional[str] = None
|
|
14
|
+
file_path: Optional[str] = None
|
|
15
|
+
status: int = 0
|
|
16
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class SkillVersion:
|
|
20
|
+
"""Represents a specific point-in-time version of a skill."""
|
|
21
|
+
code: str
|
|
22
|
+
version: int
|
|
23
|
+
commit_hash: str
|
|
24
|
+
author_id: Any
|
|
25
|
+
file_path: Optional[str] = None
|
|
26
|
+
message: Optional[str] = None
|
|
27
|
+
created_at: datetime = field(default_factory=datetime.now)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""
|
|
2
|
+
services: High-level business logic — sync, publish, and orchestration.
|
|
3
|
+
"""
|
|
4
|
+
from .manager import GitSkillManager
|
|
5
|
+
from .publisher import SkillPublisher
|
|
6
|
+
from .syncer import SkillSyncer, RemoteSource, SyncResult
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"GitSkillManager",
|
|
10
|
+
"SkillPublisher",
|
|
11
|
+
"SkillSyncer",
|
|
12
|
+
"RemoteSource",
|
|
13
|
+
"SyncResult",
|
|
14
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any, Optional, List
|
|
4
|
+
from llmrix.skill.git.repository import GitRepository
|
|
5
|
+
from llmrix.skill.storage.base import BaseStorage
|
|
6
|
+
from llmrix.skill.models.metadata import MetadataParser
|
|
7
|
+
from llmrix.skill.services.syncer import SkillSyncer
|
|
8
|
+
from llmrix.skill.services.publisher import SkillPublisher
|
|
9
|
+
from llmrix.skill.core.config import SkillConfig
|
|
10
|
+
from llmrix.skill.models.schema import Skill, SkillVersion
|
|
11
|
+
from llmrix.skill.core.exceptions import GitSkillError
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
class GitSkillManager:
|
|
16
|
+
"""
|
|
17
|
+
Converged Orchestrator for SkillHub.
|
|
18
|
+
|
|
19
|
+
Acts as a high-level facade that decouples configuration from method calls,
|
|
20
|
+
providing a clean interface for both Agent Workers and Management APIs.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
repo_url: str,
|
|
26
|
+
workspace: Optional[str] = None,
|
|
27
|
+
branch: str = "main",
|
|
28
|
+
storage: Optional[BaseStorage] = None
|
|
29
|
+
):
|
|
30
|
+
# 1. Decouple Configuration
|
|
31
|
+
self.config = SkillConfig.create(repo_url, workspace, branch)
|
|
32
|
+
self.storage = storage
|
|
33
|
+
|
|
34
|
+
# 2. Initialize Internal Components
|
|
35
|
+
self.repository = GitRepository(root=self.config.workspace, sub_dir=self.config.skills_path)
|
|
36
|
+
self.parser = MetadataParser()
|
|
37
|
+
|
|
38
|
+
# 3. Read Responsibility (Syncing)
|
|
39
|
+
self.syncer = SkillSyncer(cache_dir=self.config.cache_root)
|
|
40
|
+
|
|
41
|
+
# 4. Write Responsibility (Publishing - Lazy)
|
|
42
|
+
self._publisher = None
|
|
43
|
+
if storage:
|
|
44
|
+
self._publisher = SkillPublisher(
|
|
45
|
+
git=self.repository,
|
|
46
|
+
storage=storage,
|
|
47
|
+
parser=self.parser,
|
|
48
|
+
default_branch=self.config.branch
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def publisher(self) -> SkillPublisher:
|
|
53
|
+
if not self._publisher:
|
|
54
|
+
raise GitSkillError("Storage adapter is required for publishing operations.")
|
|
55
|
+
return self._publisher
|
|
56
|
+
|
|
57
|
+
def sync(self) -> str:
|
|
58
|
+
"""
|
|
59
|
+
Worker Mode API: Synchronizes the local repository.
|
|
60
|
+
Uses the pre-configured branch and workspace.
|
|
61
|
+
"""
|
|
62
|
+
self.repository.ensure_initialized(
|
|
63
|
+
remote_url=self.config.repo_url,
|
|
64
|
+
branch=self.config.branch
|
|
65
|
+
)
|
|
66
|
+
self.repository.fetch_latest(branch=self.config.branch)
|
|
67
|
+
return self.repository.get_skill_path("")
|
|
68
|
+
|
|
69
|
+
def publish(self, **kwargs) -> Skill:
|
|
70
|
+
"""
|
|
71
|
+
Management Mode API: Deploys a new version.
|
|
72
|
+
Arguments like 'branch' are optional as the manager uses defaults.
|
|
73
|
+
"""
|
|
74
|
+
self.repository.ensure_initialized(
|
|
75
|
+
remote_url=self.config.repo_url,
|
|
76
|
+
branch=self.config.branch
|
|
77
|
+
)
|
|
78
|
+
return self.publisher.publish(**kwargs)
|
|
79
|
+
|
|
80
|
+
def rollback(self, **kwargs) -> Skill:
|
|
81
|
+
"""Management Mode API: Reverts to a previous version."""
|
|
82
|
+
self.repository.ensure_initialized(
|
|
83
|
+
remote_url=self.config.repo_url,
|
|
84
|
+
branch=self.config.branch
|
|
85
|
+
)
|
|
86
|
+
return self.publisher.rollback(**kwargs)
|
|
87
|
+
|
|
88
|
+
def get_history(self, code: str) -> List[SkillVersion]:
|
|
89
|
+
"""Retrieves release history from the connected database."""
|
|
90
|
+
if not self.storage:
|
|
91
|
+
raise GitSkillError("Storage adapter is required for history queries.")
|
|
92
|
+
return self.storage.get_history(code)
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def get_interim_path(uid: Any) -> str:
|
|
96
|
+
"""Static utility to resolve a standardized interim upload path for users."""
|
|
97
|
+
base = os.path.expanduser(f"~/llmrix/skills/interim/{uid}")
|
|
98
|
+
return os.path.abspath(base)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
from llmrix.skill.git.repository import GitRepository
|
|
6
|
+
from llmrix.skill.storage.base import BaseStorage
|
|
7
|
+
from llmrix.skill.models.metadata import MetadataParser
|
|
8
|
+
from llmrix.skill.models.schema import Skill, SkillVersion
|
|
9
|
+
from llmrix.skill.core.exceptions import PermissionDeniedError, VersionNotFoundError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
class SkillPublisher:
|
|
14
|
+
"""
|
|
15
|
+
Handles the deployment lifecycle of Skills.
|
|
16
|
+
Encapsulates permissions, filesystem updates, and persistence.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
git: GitRepository,
|
|
22
|
+
storage: BaseStorage,
|
|
23
|
+
parser: Optional[MetadataParser] = None,
|
|
24
|
+
default_branch: str = "main"
|
|
25
|
+
):
|
|
26
|
+
self.git = git
|
|
27
|
+
self.storage = storage
|
|
28
|
+
self.parser = parser or MetadataParser()
|
|
29
|
+
self.default_branch = default_branch
|
|
30
|
+
|
|
31
|
+
def publish(
|
|
32
|
+
self,
|
|
33
|
+
code: str,
|
|
34
|
+
source_dir: str,
|
|
35
|
+
user_id: Any,
|
|
36
|
+
name: Optional[str] = None,
|
|
37
|
+
description: Optional[str] = None,
|
|
38
|
+
category: Optional[str] = None,
|
|
39
|
+
message: Optional[str] = None,
|
|
40
|
+
branch: Optional[str] = None
|
|
41
|
+
) -> Skill:
|
|
42
|
+
"""Executes a full deployment workflow for a skill."""
|
|
43
|
+
target_branch = branch or self.default_branch
|
|
44
|
+
self.parser.validate_code(code)
|
|
45
|
+
|
|
46
|
+
with self.git.lock(code):
|
|
47
|
+
self.git.fetch_latest(branch=target_branch)
|
|
48
|
+
|
|
49
|
+
# 1. Authorization
|
|
50
|
+
existing = self.storage.get_skill(code)
|
|
51
|
+
if existing and not self.storage.can_modify(code, user_id):
|
|
52
|
+
raise PermissionDeniedError(f"User {user_id} lacks permission for skill {code}")
|
|
53
|
+
|
|
54
|
+
# 2. Filesystem Update
|
|
55
|
+
target_path = self.git.get_skill_path(code)
|
|
56
|
+
if os.path.exists(target_path):
|
|
57
|
+
shutil.rmtree(target_path)
|
|
58
|
+
shutil.copytree(source_dir, target_path)
|
|
59
|
+
|
|
60
|
+
# 3. Metadata Extraction
|
|
61
|
+
manifest_path = os.path.join(target_path, "SKILL.md")
|
|
62
|
+
manifest = {}
|
|
63
|
+
if os.path.exists(manifest_path):
|
|
64
|
+
with open(manifest_path, "r", encoding="utf-8") as f:
|
|
65
|
+
manifest = self.parser.parse_manifest(f.read())
|
|
66
|
+
|
|
67
|
+
final_name = name or manifest.get("name") or code
|
|
68
|
+
final_desc = description or manifest.get("description")
|
|
69
|
+
final_cat = (
|
|
70
|
+
category
|
|
71
|
+
or manifest.get("category")
|
|
72
|
+
or self.parser.detect_category(code, final_name, final_desc or "")
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# 4. Git Orchestration
|
|
76
|
+
commit_hash = self.git.commit_skill(code, user_id, message)
|
|
77
|
+
self.git.push_changes(branch=target_branch)
|
|
78
|
+
|
|
79
|
+
# 5. Database Persistence
|
|
80
|
+
new_version_num = (existing.version + 1) if existing else 1
|
|
81
|
+
skill = Skill(
|
|
82
|
+
code=code,
|
|
83
|
+
name=final_name,
|
|
84
|
+
version=new_version_num,
|
|
85
|
+
description=final_desc,
|
|
86
|
+
category=final_cat,
|
|
87
|
+
commit_hash=commit_hash,
|
|
88
|
+
file_path=self.git.get_relative_path(code),
|
|
89
|
+
status=0
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
self.storage.save_skill(skill)
|
|
93
|
+
self.storage.add_version(SkillVersion(
|
|
94
|
+
code=code,
|
|
95
|
+
version=new_version_num,
|
|
96
|
+
commit_hash=commit_hash,
|
|
97
|
+
author_id=user_id,
|
|
98
|
+
file_path=skill.file_path,
|
|
99
|
+
message=message
|
|
100
|
+
))
|
|
101
|
+
|
|
102
|
+
return skill
|
|
103
|
+
|
|
104
|
+
def rollback(
|
|
105
|
+
self,
|
|
106
|
+
code: str,
|
|
107
|
+
target_version: int,
|
|
108
|
+
user_id: Any,
|
|
109
|
+
message: Optional[str] = None,
|
|
110
|
+
branch: Optional[str] = None
|
|
111
|
+
) -> Skill:
|
|
112
|
+
"""Rolls back a skill to a specific historical version."""
|
|
113
|
+
target_branch = branch or self.default_branch
|
|
114
|
+
ver = self.storage.get_version(code, target_version)
|
|
115
|
+
if not ver:
|
|
116
|
+
raise VersionNotFoundError(f"Version {target_version} not found for {code}")
|
|
117
|
+
|
|
118
|
+
with self.git.lock(code):
|
|
119
|
+
self.git.fetch_latest(branch=target_branch)
|
|
120
|
+
|
|
121
|
+
if not self.storage.can_modify(code, user_id):
|
|
122
|
+
raise PermissionDeniedError(f"Access denied for skill {code}")
|
|
123
|
+
|
|
124
|
+
new_hash = self.git.revert_to_commit(code, ver.commit_hash, user_id, message)
|
|
125
|
+
self.git.push_changes(branch=target_branch)
|
|
126
|
+
|
|
127
|
+
existing = self.storage.get_skill(code)
|
|
128
|
+
new_version_num = (existing.version + 1) if existing else 1
|
|
129
|
+
|
|
130
|
+
skill = Skill(
|
|
131
|
+
code=code,
|
|
132
|
+
name=existing.name,
|
|
133
|
+
version=new_version_num,
|
|
134
|
+
description=existing.description,
|
|
135
|
+
category=existing.category,
|
|
136
|
+
commit_hash=new_hash,
|
|
137
|
+
file_path=existing.file_path,
|
|
138
|
+
status=existing.status
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
self.storage.save_skill(skill)
|
|
142
|
+
self.storage.add_version(SkillVersion(
|
|
143
|
+
code=code,
|
|
144
|
+
version=new_version_num,
|
|
145
|
+
commit_hash=new_hash,
|
|
146
|
+
author_id=user_id,
|
|
147
|
+
file_path=skill.file_path,
|
|
148
|
+
message=message or f"Rollback to v{target_version}"
|
|
149
|
+
))
|
|
150
|
+
|
|
151
|
+
return skill
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import hashlib
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import urllib.parse
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
from llmrix.skill.git.repository import GitRepository
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class RemoteSource:
|
|
17
|
+
"""Configuration for a remote skill repository source."""
|
|
18
|
+
url: str
|
|
19
|
+
branch: str = "main"
|
|
20
|
+
ssh_key: Optional[str] = None
|
|
21
|
+
username: Optional[str] = None
|
|
22
|
+
password: Optional[str] = None
|
|
23
|
+
cache_ttl: int = 300 # seconds
|
|
24
|
+
skills_path: str = "skills"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def identity(self) -> str:
|
|
28
|
+
"""Unique identifier based on URL and branch."""
|
|
29
|
+
return f"{self.url}:{self.branch}"
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SyncResult:
|
|
33
|
+
"""Result of a repository sync operation."""
|
|
34
|
+
source: RemoteSource
|
|
35
|
+
local_path: Path
|
|
36
|
+
success: bool
|
|
37
|
+
last_sync: datetime
|
|
38
|
+
error: Optional[str] = None
|
|
39
|
+
|
|
40
|
+
class SkillSyncer:
|
|
41
|
+
"""
|
|
42
|
+
Manages synchronization of multiple remote skill repositories.
|
|
43
|
+
Supports local caching with TTL and authentication.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, cache_dir: str):
|
|
47
|
+
self.cache_dir = Path(cache_dir).absolute()
|
|
48
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
self._locks: Dict[str, asyncio.Lock] = {}
|
|
50
|
+
self._sync_history: Dict[str, datetime] = {}
|
|
51
|
+
|
|
52
|
+
def _get_lock(self, identity: str) -> asyncio.Lock:
|
|
53
|
+
if identity not in self._locks:
|
|
54
|
+
self._locks[identity] = asyncio.Lock()
|
|
55
|
+
return self._locks[identity]
|
|
56
|
+
|
|
57
|
+
def _get_cache_path(self, source: RemoteSource) -> Path:
|
|
58
|
+
"""Generates a unique directory name for the repository hash."""
|
|
59
|
+
url_hash = hashlib.md5(source.identity.encode()).hexdigest()[:12]
|
|
60
|
+
return self.cache_dir / f"repo_{url_hash}"
|
|
61
|
+
|
|
62
|
+
def _build_authed_url(self, source: RemoteSource) -> str:
|
|
63
|
+
url = source.url
|
|
64
|
+
if source.username and source.password and url.startswith("https://"):
|
|
65
|
+
encoded_user = urllib.parse.quote(source.username, safe="")
|
|
66
|
+
encoded_pass = urllib.parse.quote(source.password, safe="")
|
|
67
|
+
url = url.replace("https://", f"https://{encoded_user}:{encoded_pass}@")
|
|
68
|
+
return url
|
|
69
|
+
|
|
70
|
+
async def sync_source(self, source: RemoteSource) -> SyncResult:
|
|
71
|
+
"""
|
|
72
|
+
Synchronizes a single remote source.
|
|
73
|
+
Returns the path to the skills directory within the repo.
|
|
74
|
+
"""
|
|
75
|
+
identity = source.identity
|
|
76
|
+
lock = self._get_lock(identity)
|
|
77
|
+
|
|
78
|
+
async with lock:
|
|
79
|
+
repo_path = self._get_cache_path(source)
|
|
80
|
+
repo = GitRepository(root=str(repo_path), sub_dir=source.skills_path)
|
|
81
|
+
|
|
82
|
+
# Check TTL
|
|
83
|
+
last_sync = self._sync_history.get(identity)
|
|
84
|
+
if last_sync and (datetime.now() - last_sync).total_seconds() < source.cache_ttl:
|
|
85
|
+
if repo_path.exists():
|
|
86
|
+
return SyncResult(source, Path(repo.get_skill_path("")), True, last_sync)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
authed_url = self._build_authed_url(source)
|
|
90
|
+
|
|
91
|
+
# We use a thread pool for blocking git operations since they are subprocess calls
|
|
92
|
+
loop = asyncio.get_event_loop()
|
|
93
|
+
|
|
94
|
+
if not repo_path.exists():
|
|
95
|
+
logger.info(f"Cloning remote skill source: {source.url}")
|
|
96
|
+
await loop.run_in_executor(None, repo.initialize, authed_url, source.branch)
|
|
97
|
+
else:
|
|
98
|
+
logger.debug(f"Syncing remote skill source: {source.url}")
|
|
99
|
+
await loop.run_in_executor(None, repo.sync, "origin", source.branch)
|
|
100
|
+
|
|
101
|
+
self._sync_history[identity] = datetime.now()
|
|
102
|
+
return SyncResult(source, Path(repo.get_skill_path("")), True, self._sync_history[identity])
|
|
103
|
+
|
|
104
|
+
except Exception as e:
|
|
105
|
+
logger.error(f"Failed to sync {source.url}: {e}")
|
|
106
|
+
if repo_path.exists():
|
|
107
|
+
logger.warning(f"Falling back to stale cache for {source.url}")
|
|
108
|
+
return SyncResult(source, Path(repo.get_skill_path("")), True, last_sync or datetime.min, str(e))
|
|
109
|
+
return SyncResult(source, repo_path, False, datetime.min, str(e))
|
|
110
|
+
|
|
111
|
+
async def sync_all(self, sources: List[RemoteSource]) -> List[SyncResult]:
|
|
112
|
+
"""Synchronizes multiple sources in parallel."""
|
|
113
|
+
tasks = [self.sync_source(s) for s in sources]
|
|
114
|
+
return await asyncio.gather(*tasks)
|
|
115
|
+
|
|
116
|
+
def clear_cache(self):
|
|
117
|
+
"""Deletes all cached repositories."""
|
|
118
|
+
if self.cache_dir.exists():
|
|
119
|
+
shutil.rmtree(self.cache_dir)
|
|
120
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
self._sync_history.clear()
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""
|
|
2
|
+
storage: Abstract storage interface and concrete adapter implementations.
|
|
3
|
+
"""
|
|
4
|
+
from .base import BaseStorage
|
|
5
|
+
from .mysql import MySQLStorage
|
|
6
|
+
from .sqlalchemy_store import SQLAlchemyStorage
|
|
7
|
+
|
|
8
|
+
__all__ = ["BaseStorage", "MySQLStorage", "SQLAlchemyStorage"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, List, Optional
|
|
3
|
+
from llmrix.skill.models.schema import Skill, SkillVersion
|
|
4
|
+
|
|
5
|
+
class BaseStorage(ABC):
|
|
6
|
+
"""
|
|
7
|
+
Abstract base class for skill persistence.
|
|
8
|
+
Implement this to support different databases.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
def get_skill(self, code: str) -> Optional[Skill]:
|
|
13
|
+
"""Retrieve skill by its unique code."""
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def save_skill(self, skill: Skill) -> None:
|
|
18
|
+
"""Create or update skill metadata."""
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def add_version(self, version: SkillVersion) -> None:
|
|
23
|
+
"""Record a new version in history."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
@abstractmethod
|
|
27
|
+
def get_history(self, code: str) -> List[SkillVersion]:
|
|
28
|
+
"""List all versions of a skill ordered by version descending."""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def get_version(self, code: str, version_number: int) -> Optional[SkillVersion]:
|
|
33
|
+
"""Retrieve a specific version record."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def can_modify(self, code: str, user_id: Any) -> bool:
|
|
38
|
+
"""Check if the user has permission to modify the skill."""
|
|
39
|
+
pass
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from typing import Any, List, Optional
|
|
2
|
+
from llmrix.skill.storage.base import BaseStorage
|
|
3
|
+
from llmrix.skill.models.schema import Skill, SkillVersion
|
|
4
|
+
|
|
5
|
+
class MySQLStorage(BaseStorage):
|
|
6
|
+
def __init__(self, connection_factory):
|
|
7
|
+
self.get_conn = connection_factory
|
|
8
|
+
|
|
9
|
+
def _to_skill(self, row: dict) -> Skill:
|
|
10
|
+
return Skill(
|
|
11
|
+
code=row["skill_code"],
|
|
12
|
+
name=row["skill_name"],
|
|
13
|
+
version=row["version"],
|
|
14
|
+
description=row["introduce"],
|
|
15
|
+
category=row["category"],
|
|
16
|
+
commit_hash=row["git_commit"],
|
|
17
|
+
file_path=row["git_path"],
|
|
18
|
+
status=row["status"]
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def get_skill(self, code: str) -> Optional[Skill]:
|
|
22
|
+
with self.get_conn() as conn:
|
|
23
|
+
cur = conn.cursor()
|
|
24
|
+
cur.execute("SELECT * FROM skill_info WHERE skill_code=%s AND deleted=0", (code,))
|
|
25
|
+
row = cur.fetchone()
|
|
26
|
+
return self._to_skill(row) if row else None
|
|
27
|
+
|
|
28
|
+
def save_skill(self, skill: Skill) -> None:
|
|
29
|
+
sql = """
|
|
30
|
+
INSERT INTO skill_info (skill_code, skill_name, introduce, version, git_commit, git_path, status, category)
|
|
31
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
32
|
+
ON DUPLICATE KEY UPDATE
|
|
33
|
+
skill_name = VALUES(skill_name),
|
|
34
|
+
introduce = VALUES(introduce),
|
|
35
|
+
version = VALUES(version),
|
|
36
|
+
git_commit = VALUES(git_commit),
|
|
37
|
+
git_path = VALUES(git_path),
|
|
38
|
+
category = VALUES(category),
|
|
39
|
+
update_time = NOW()
|
|
40
|
+
"""
|
|
41
|
+
with self.get_conn() as conn:
|
|
42
|
+
cur = conn.cursor()
|
|
43
|
+
cur.execute(sql, (
|
|
44
|
+
skill.code, skill.name, skill.description,
|
|
45
|
+
skill.version, skill.commit_hash, skill.file_path,
|
|
46
|
+
skill.status, skill.category
|
|
47
|
+
))
|
|
48
|
+
conn.commit()
|
|
49
|
+
|
|
50
|
+
def add_version(self, version: SkillVersion) -> None:
|
|
51
|
+
sql = """
|
|
52
|
+
INSERT INTO skill_version (skill_code, version, git_commit, git_path, user_id, remark)
|
|
53
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
54
|
+
"""
|
|
55
|
+
with self.get_conn() as conn:
|
|
56
|
+
cur = conn.cursor()
|
|
57
|
+
cur.execute(sql, (
|
|
58
|
+
version.code, version.version, version.commit_hash,
|
|
59
|
+
version.file_path, version.author_id, version.message
|
|
60
|
+
))
|
|
61
|
+
conn.commit()
|
|
62
|
+
|
|
63
|
+
def get_history(self, code: str) -> List[SkillVersion]:
|
|
64
|
+
with self.get_conn() as conn:
|
|
65
|
+
cur = conn.cursor()
|
|
66
|
+
cur.execute("SELECT * FROM skill_version WHERE skill_code=%s AND deleted=0 ORDER BY version DESC", (code,))
|
|
67
|
+
rows = cur.fetchall()
|
|
68
|
+
return [SkillVersion(
|
|
69
|
+
code=r["skill_code"],
|
|
70
|
+
version=r["version"],
|
|
71
|
+
commit_hash=r["git_commit"],
|
|
72
|
+
author_id=r["user_id"],
|
|
73
|
+
file_path=r["git_path"],
|
|
74
|
+
message=r["remark"],
|
|
75
|
+
created_at=r["create_time"]
|
|
76
|
+
) for r in rows]
|
|
77
|
+
|
|
78
|
+
def get_version(self, code: str, version_number: int) -> Optional[SkillVersion]:
|
|
79
|
+
with self.get_conn() as conn:
|
|
80
|
+
cur = conn.cursor()
|
|
81
|
+
cur.execute("SELECT * FROM skill_version WHERE skill_code=%s AND version=%s AND deleted=0",
|
|
82
|
+
(code, version_number))
|
|
83
|
+
r = cur.fetchone()
|
|
84
|
+
if not r: return None
|
|
85
|
+
return SkillVersion(
|
|
86
|
+
code=r["skill_code"],
|
|
87
|
+
version=r["version"],
|
|
88
|
+
commit_hash=r["git_commit"],
|
|
89
|
+
author_id=r["user_id"],
|
|
90
|
+
file_path=r["git_path"],
|
|
91
|
+
message=r["remark"],
|
|
92
|
+
created_at=r["create_time"]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def can_modify(self, code: str, user_id: Any) -> bool:
|
|
96
|
+
with self.get_conn() as conn:
|
|
97
|
+
cur = conn.cursor()
|
|
98
|
+
cur.execute("SELECT id FROM skill_info WHERE skill_code=%s AND user_id=%s AND deleted=0",
|
|
99
|
+
(code, user_id))
|
|
100
|
+
return cur.fetchone() is not None
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
from typing import Any, List, Optional
|
|
2
|
+
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime
|
|
3
|
+
from sqlalchemy.orm import declarative_base, sessionmaker
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
|
|
6
|
+
from llmrix.skill.storage.base import BaseStorage
|
|
7
|
+
from llmrix.skill.models.schema import Skill, SkillVersion
|
|
8
|
+
|
|
9
|
+
Base = declarative_base()
|
|
10
|
+
|
|
11
|
+
class SkillModel(Base):
|
|
12
|
+
__tablename__ = "skill_info"
|
|
13
|
+
|
|
14
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
15
|
+
skill_code = Column(String(100), unique=True, nullable=False, index=True)
|
|
16
|
+
skill_name = Column(String(200), nullable=False)
|
|
17
|
+
introduce = Column(Text, nullable=True) # Description
|
|
18
|
+
category = Column(String(100), nullable=True)
|
|
19
|
+
version = Column(Integer, default=1)
|
|
20
|
+
git_commit = Column(String(40), nullable=False)
|
|
21
|
+
git_path = Column(String(500), nullable=False)
|
|
22
|
+
status = Column(Integer, default=0)
|
|
23
|
+
user_id = Column(String(100), nullable=True) # Author/Owner
|
|
24
|
+
deleted = Column(Integer, default=0)
|
|
25
|
+
create_time = Column(DateTime, default=datetime.utcnow)
|
|
26
|
+
update_time = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
27
|
+
|
|
28
|
+
class SkillVersionModel(Base):
|
|
29
|
+
__tablename__ = "skill_version"
|
|
30
|
+
|
|
31
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
32
|
+
skill_code = Column(String(100), nullable=False, index=True)
|
|
33
|
+
version = Column(Integer, nullable=False)
|
|
34
|
+
git_commit = Column(String(40), nullable=False)
|
|
35
|
+
git_path = Column(String(500), nullable=False)
|
|
36
|
+
user_id = Column(String(100), nullable=False)
|
|
37
|
+
remark = Column(Text, nullable=True)
|
|
38
|
+
deleted = Column(Integer, default=0)
|
|
39
|
+
create_time = Column(DateTime, default=datetime.utcnow)
|
|
40
|
+
|
|
41
|
+
class SQLAlchemyStorage(BaseStorage):
|
|
42
|
+
"""
|
|
43
|
+
Database agnostic storage using SQLAlchemy ORM.
|
|
44
|
+
Supports MySQL, PostgreSQL, SQLite, etc.
|
|
45
|
+
"""
|
|
46
|
+
def __init__(self, engine_or_url: Any, auto_create_tables: bool = True):
|
|
47
|
+
if isinstance(engine_or_url, str):
|
|
48
|
+
self.engine = create_engine(engine_or_url)
|
|
49
|
+
else:
|
|
50
|
+
self.engine = engine_or_url
|
|
51
|
+
|
|
52
|
+
self.SessionLocal = sessionmaker(bind=self.engine)
|
|
53
|
+
|
|
54
|
+
if auto_create_tables:
|
|
55
|
+
Base.metadata.create_all(self.engine)
|
|
56
|
+
|
|
57
|
+
def _to_skill(self, model: SkillModel) -> Skill:
|
|
58
|
+
return Skill(
|
|
59
|
+
code=model.skill_code,
|
|
60
|
+
name=model.skill_name,
|
|
61
|
+
version=model.version,
|
|
62
|
+
description=model.introduce,
|
|
63
|
+
category=model.category,
|
|
64
|
+
commit_hash=model.git_commit,
|
|
65
|
+
file_path=model.git_path,
|
|
66
|
+
status=model.status
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
def get_skill(self, code: str) -> Optional[Skill]:
|
|
70
|
+
with self.SessionLocal() as db:
|
|
71
|
+
model = db.query(SkillModel).filter_by(skill_code=code, deleted=0).first()
|
|
72
|
+
return self._to_skill(model) if model else None
|
|
73
|
+
|
|
74
|
+
def save_skill(self, skill: Skill) -> None:
|
|
75
|
+
with self.SessionLocal() as db:
|
|
76
|
+
model = db.query(SkillModel).filter_by(skill_code=skill.code, deleted=0).first()
|
|
77
|
+
if model:
|
|
78
|
+
model.skill_name = skill.name
|
|
79
|
+
model.introduce = skill.description
|
|
80
|
+
model.version = skill.version
|
|
81
|
+
model.git_commit = skill.commit_hash
|
|
82
|
+
model.git_path = skill.file_path
|
|
83
|
+
model.status = skill.status
|
|
84
|
+
model.category = skill.category
|
|
85
|
+
else:
|
|
86
|
+
model = SkillModel(
|
|
87
|
+
skill_code=skill.code,
|
|
88
|
+
skill_name=skill.name,
|
|
89
|
+
introduce=skill.description,
|
|
90
|
+
version=skill.version,
|
|
91
|
+
git_commit=skill.commit_hash,
|
|
92
|
+
git_path=skill.file_path,
|
|
93
|
+
status=skill.status,
|
|
94
|
+
category=skill.category
|
|
95
|
+
)
|
|
96
|
+
db.add(model)
|
|
97
|
+
db.commit()
|
|
98
|
+
|
|
99
|
+
def add_version(self, version: SkillVersion) -> None:
|
|
100
|
+
with self.SessionLocal() as db:
|
|
101
|
+
model = SkillVersionModel(
|
|
102
|
+
skill_code=version.code,
|
|
103
|
+
version=version.version,
|
|
104
|
+
git_commit=version.commit_hash,
|
|
105
|
+
git_path=version.file_path,
|
|
106
|
+
user_id=str(version.author_id),
|
|
107
|
+
remark=version.message
|
|
108
|
+
)
|
|
109
|
+
db.add(model)
|
|
110
|
+
db.commit()
|
|
111
|
+
|
|
112
|
+
def get_history(self, code: str) -> List[SkillVersion]:
|
|
113
|
+
with self.SessionLocal() as db:
|
|
114
|
+
models = db.query(SkillVersionModel).filter_by(skill_code=code, deleted=0).order_by(SkillVersionModel.version.desc()).all()
|
|
115
|
+
return [
|
|
116
|
+
SkillVersion(
|
|
117
|
+
code=m.skill_code,
|
|
118
|
+
version=m.version,
|
|
119
|
+
commit_hash=m.git_commit,
|
|
120
|
+
author_id=m.user_id,
|
|
121
|
+
file_path=m.git_path,
|
|
122
|
+
message=m.remark,
|
|
123
|
+
created_at=m.create_time
|
|
124
|
+
) for m in models
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
def get_version(self, code: str, version_number: int) -> Optional[SkillVersion]:
|
|
128
|
+
with self.SessionLocal() as db:
|
|
129
|
+
m = db.query(SkillVersionModel).filter_by(skill_code=code, version=version_number, deleted=0).first()
|
|
130
|
+
if not m: return None
|
|
131
|
+
return SkillVersion(
|
|
132
|
+
code=m.skill_code,
|
|
133
|
+
version=m.version,
|
|
134
|
+
commit_hash=m.git_commit,
|
|
135
|
+
author_id=m.user_id,
|
|
136
|
+
file_path=m.git_path,
|
|
137
|
+
message=m.remark,
|
|
138
|
+
created_at=m.create_time
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
def can_modify(self, code: str, user_id: Any) -> bool:
|
|
142
|
+
with self.SessionLocal() as db:
|
|
143
|
+
model = db.query(SkillModel).filter_by(skill_code=code, user_id=str(user_id), deleted=0).first()
|
|
144
|
+
return model is not None
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: llmrix-skill
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A professional Git-based Skill management library for LLM agents.
|
|
5
|
+
Author-email: LLMRix Team <support@llmrix.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/llmrix/llmrix-skillhub
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: pyyaml>=6.0
|
|
11
|
+
Requires-Dist: filelock>=3.0.0
|
|
12
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
13
|
+
|
|
14
|
+
# LLMRix Skill Plugin Library
|
|
15
|
+
|
|
16
|
+
`llmrix-skill` is a professional Git-based Skill and plugin management framework designed for LLM Agents. It encapsulates the complex logic of publishing, versioning, and syncing code, prompts, and tools into an out-of-the-box plugin library, allowing developers to focus on building core Agent logic.
|
|
17
|
+
|
|
18
|
+
## Core Features
|
|
19
|
+
|
|
20
|
+
As the foundational plugin library for an Agent framework, it provides the following capabilities:
|
|
21
|
+
|
|
22
|
+
- **Git-Driven Plugin Market**: Uses Git repositories as the Single Source of Truth (SSOT) to achieve skill persistence, version tracing, and change tracking.
|
|
23
|
+
- **Out-of-the-box Dual Mode Architecture**:
|
|
24
|
+
- **Worker Mode (Execution)**: Pure lightweight synchronization, responsible for pulling skills from remote repositories to local storage for high-speed loading by Agent instances.
|
|
25
|
+
- **Management Mode (Admin)**: Integrates with databases to support multi-tenant authentication, skill publishing, metadata parsing, and version rollbacks.
|
|
26
|
+
- **Dynamic Metadata Parsing**: Automatically parses and validates `SKILL.md` (YAML Frontmatter) in the skill package to extract the plugin name, description, and category.
|
|
27
|
+
- **Concurrency & Safety**: Built-in distributed file lock mechanism to prevent file state race conditions during high-concurrency publishing and pulling.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Quick Usage Guide
|
|
32
|
+
|
|
33
|
+
### Installation
|
|
34
|
+
|
|
35
|
+
You can integrate this plugin library into your Agent project via pip:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install llmrix-skill
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Scene 1: Syncing Plugins on Agent Execution Node (Worker Mode)
|
|
42
|
+
|
|
43
|
+
In your Agent execution environment (such as background task nodes, or upon container startup), configure a specific branch to pull the latest skills for model invocation.
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from llmrix.skill import GitSkillManager
|
|
47
|
+
|
|
48
|
+
def sync_skills_for_agent():
|
|
49
|
+
# Initialize the Manager, configuring the remote repository and target branch
|
|
50
|
+
manager = GitSkillManager(
|
|
51
|
+
repo_url="https://github.com/your-org/skill-repo.git",
|
|
52
|
+
branch="develop", # Highly recommended: specify the branch to pull (e.g., main/develop/v1)
|
|
53
|
+
workspace="/path/to/local/cache" # Local cache path
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Execute sync, returning the absolute filesystem path where the skills are located
|
|
57
|
+
skills_path = manager.sync()
|
|
58
|
+
print(f"✅ Skill plugins synced to: {skills_path}")
|
|
59
|
+
|
|
60
|
+
# You can now dynamically load modules under skills_path in your Agent framework
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Scene 2: Web Server Management & Publishing (Management Mode)
|
|
64
|
+
|
|
65
|
+
In your Web API service (such as FastAPI/Django plugin market backend), use `GitSkillManager` to handle user uploads, publishing, and version rollbacks. It encapsulates database persistence and concurrent file locking.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from llmrix.skill import GitSkillManager
|
|
69
|
+
from llmrix.skill import MySQLStorage
|
|
70
|
+
|
|
71
|
+
# 1. Configure the database adapter
|
|
72
|
+
def get_db_connection():
|
|
73
|
+
# Return a pymysql/MySQLdb connection object
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
storage = MySQLStorage(connection_factory=get_db_connection)
|
|
77
|
+
|
|
78
|
+
# 2. Initialize the Manager, dedicated for the management end
|
|
79
|
+
manager = GitSkillManager(
|
|
80
|
+
repo_url="git@github.com:your-org/skill-repo.git",
|
|
81
|
+
storage=storage
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# 3. Publish user-uploaded skills
|
|
85
|
+
def publish_user_skill(user_id, uploaded_dir):
|
|
86
|
+
skill = manager.publish(
|
|
87
|
+
code="python_interpreter", # Unique skill code
|
|
88
|
+
source_dir=uploaded_dir, # Unzipped directory uploaded by the user
|
|
89
|
+
user_id=user_id, # Current operating user ID (for auth)
|
|
90
|
+
message="Initial release"
|
|
91
|
+
)
|
|
92
|
+
print(f"🚀 Publish successful: {skill.name} v{skill.version}")
|
|
93
|
+
|
|
94
|
+
# 4. Version Rollback
|
|
95
|
+
def rollback_skill(user_id):
|
|
96
|
+
skill = manager.rollback(
|
|
97
|
+
code="python_interpreter",
|
|
98
|
+
target_version=1,
|
|
99
|
+
user_id=user_id,
|
|
100
|
+
message="Revert due to bugs"
|
|
101
|
+
)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Skill Package Specification
|
|
107
|
+
|
|
108
|
+
A standard skill plugin package is a directory containing the following files:
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
my_awesome_skill/
|
|
112
|
+
├── SKILL.md # Skill description and metadata (Required)
|
|
113
|
+
├── main.py # Core logic (Recommended)
|
|
114
|
+
└── requirements.txt # Dependencies (Optional)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`SKILL.md` must contain a valid YAML Frontmatter header:
|
|
118
|
+
|
|
119
|
+
```markdown
|
|
120
|
+
---
|
|
121
|
+
name: Web Scraper Pro
|
|
122
|
+
description: Powerful web scraping tool, supports dynamic rendering.
|
|
123
|
+
category: Web & Search
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
Detailed Markdown documentation about this skill goes here...
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Module Architecture
|
|
132
|
+
|
|
133
|
+
You can directly import the required submodules for lower-level extensions:
|
|
134
|
+
|
|
135
|
+
- `llmrix.skill.services`: Contains `GitSkillManager`, `SkillPublisher`, and `SkillSyncer`.
|
|
136
|
+
- `llmrix.skill.storage`: Contains `BaseStorage` and `MySQLStorage`. Inherit `BaseStorage` to implement your own MongoDB or PostgreSQL adapters.
|
|
137
|
+
- `llmrix.skill.git`: Low-level Git driver library `GitRepository`.
|
|
138
|
+
- `llmrix.skill.models`: Data models `Skill` and `SkillVersion`.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
llmrix/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
llmrix/skill/__init__.py,sha256=2bTh79cvfDE6YJOutPbG6FwR1VD2LUulXV9ehq9fr1s,1707
|
|
3
|
+
llmrix/skill/core/__init__.py,sha256=9ugCN6KmPJLWdEeAx7OV_vdPSAUXRw1ownLaOvCSf3w,574
|
|
4
|
+
llmrix/skill/core/config.py,sha256=gdS5hZQ7AT_Fu7oVgDgQOF8DKbmTToreALCTdGbLlEE,913
|
|
5
|
+
llmrix/skill/core/exceptions.py,sha256=gVTrBNw0vo1FwE8B05i8fGcfCKlI7i3FFEyzi2d2dw4,624
|
|
6
|
+
llmrix/skill/core/plugin.py,sha256=y2yB4dHumRML9dvLthb_S9w-ItsfthfsK7A6DWKmSkQ,944
|
|
7
|
+
llmrix/skill/core/utils.py,sha256=Jn_Azb_LEzDMkb0PQnoaGMUzr-CnsDWmJLfhhnJsRzU,1296
|
|
8
|
+
llmrix/skill/git/__init__.py,sha256=nB06TtVJV5-HPlQ1GwSEBOH793KNyf9npn-QSJuZyUU,113
|
|
9
|
+
llmrix/skill/git/repository.py,sha256=UaD1LmHSOITO-TImjCoET6p1cl0NwAG22P_2IDnGHVE,4748
|
|
10
|
+
llmrix/skill/models/__init__.py,sha256=HowL3n_j1ecwRpRj8Q72zpYFWKIolIpocGtyZ3JQCsA,180
|
|
11
|
+
llmrix/skill/models/metadata.py,sha256=6F3pCZflZ-bOVwTBqya1Rz1HpPUfrorh4gjzYwuGbEs,2101
|
|
12
|
+
llmrix/skill/models/schema.py,sha256=Eaa29jwH7_Eu0FqzQQaDXOZ0NLs_04PCtMRp3_qDX5A,756
|
|
13
|
+
llmrix/skill/services/__init__.py,sha256=4Qu266WVFvqkIfnUMXLoa6gcjA2Uil_kRQKiSBc6PMk,332
|
|
14
|
+
llmrix/skill/services/manager.py,sha256=VucU_M781BYINSn5n5r_47RTJCZJXZTA-tSzFHcAUGc,3592
|
|
15
|
+
llmrix/skill/services/publisher.py,sha256=Sx43RaoOS2orx_sVXRM4QmnEZixZnAyPdTmctB_sy1k,5357
|
|
16
|
+
llmrix/skill/services/syncer.py,sha256=fJvyiqRCRJZsyhwnSQjKDPDlAa3bPk7ECHgkhzw_DNo,4765
|
|
17
|
+
llmrix/skill/storage/__init__.py,sha256=wsx-Tj003rFOQMSKUjeXz-brGYNyCe0k8CJqYkT8_Qc,256
|
|
18
|
+
llmrix/skill/storage/base.py,sha256=smIIDR6hQtpfpD0GWAWjjC_SVutuNO7uTShkjGU4VAo,1177
|
|
19
|
+
llmrix/skill/storage/mysql.py,sha256=V1Xc9HgfSzAv-XKOr8LygMZN9MHqj4Cf_O9TVIOIt-s,4054
|
|
20
|
+
llmrix/skill/storage/sqlalchemy_store.py,sha256=9MmZu4KfuZch-U8j9-fov--1pcoclhFFK--IpH0QYp8,5796
|
|
21
|
+
llmrix_skill-0.2.0.dist-info/METADATA,sha256=8B-yX0Iw-qr2wUMHVI_M5EOm9Ay3K61euVsnVX_d3Gc,5289
|
|
22
|
+
llmrix_skill-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
23
|
+
llmrix_skill-0.2.0.dist-info/top_level.txt,sha256=TAjGcnZd2kd6XpS1jGciyU_vDj6rQyhDqHmOfJVW1fw,7
|
|
24
|
+
llmrix_skill-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
llmrix
|