python-skills 1.0.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.
- python_skills/__init__.py +10 -0
- python_skills/__main__.py +6 -0
- python_skills/adapters/__init__.py +48 -0
- python_skills/adapters/agent_skills.py +415 -0
- python_skills/adapters/aider_adapter.py +226 -0
- python_skills/adapters/base.py +153 -0
- python_skills/adapters/claude.py +474 -0
- python_skills/adapters/cline.py +332 -0
- python_skills/adapters/codex.py +24 -0
- python_skills/adapters/continue_adapter.py +198 -0
- python_skills/adapters/cursor.py +327 -0
- python_skills/adapters/gemini.py +26 -0
- python_skills/adapters/goose.py +26 -0
- python_skills/adapters/junie.py +25 -0
- python_skills/adapters/kiro.py +382 -0
- python_skills/adapters/opencode.py +27 -0
- python_skills/adapters/roo.py +25 -0
- python_skills/adapters/universal.py +203 -0
- python_skills/adapters/vscode.py +27 -0
- python_skills/adapters/windsurf.py +26 -0
- python_skills/adapters/zed.py +27 -0
- python_skills/cli.py +326 -0
- python_skills/config.py +160 -0
- python_skills/detector.py +152 -0
- python_skills/installer.py +163 -0
- python_skills/markers.py +115 -0
- python_skills/skills/__init__.py +14 -0
- python_skills/skills/loader.py +171 -0
- python_skills/skills/metadata.py +152 -0
- python_skills/skills/registry.py +101 -0
- python_skills/state.py +204 -0
- python_skills-1.0.0.dist-info/METADATA +99 -0
- python_skills-1.0.0.dist-info/RECORD +105 -0
- python_skills-1.0.0.dist-info/WHEEL +4 -0
- python_skills-1.0.0.dist-info/entry_points.txt +2 -0
- python_skills-1.0.0.dist-info/licenses/LICENSE +21 -0
- skills/advanced_python.md +239 -0
- skills/anti_patterns/index.md +406 -0
- skills/comprehensions.md +167 -0
- skills/control_flow.md +175 -0
- skills/data_structures.md +243 -0
- skills/debugging/common_bugs.md +222 -0
- skills/debugging/inspection_techniques.md +249 -0
- skills/debugging/root_cause.md +203 -0
- skills/engineering/application_logging.md +195 -0
- skills/engineering/cli_apps.md +207 -0
- skills/engineering/configuration.md +218 -0
- skills/engineering/database.md +240 -0
- skills/engineering/dependency_management.md +205 -0
- skills/engineering/http_clients.md +267 -0
- skills/engineering/modules_packages.md +211 -0
- skills/engineering/packaging.md +197 -0
- skills/engineering/project_structure.md +155 -0
- skills/engineering/pyproject_toml.md +302 -0
- skills/engineering/virtual_environments.md +206 -0
- skills/functions.md +244 -0
- skills/generation/async_concurrency.md +291 -0
- skills/generation/error_handling.md +276 -0
- skills/generation/protocols_generics.md +243 -0
- skills/generation/type_hints.md +290 -0
- skills/generation/validation_pipeline.md +274 -0
- skills/generation/workflow.md +190 -0
- skills/oop.md +228 -0
- skills/quality/abstractions.md +154 -0
- skills/quality/comments.md +177 -0
- skills/quality/documentation.md +176 -0
- skills/quality/duplication.md +137 -0
- skills/quality/maintainability.md +142 -0
- skills/quality/naming.md +171 -0
- skills/quality/quality_functions.md +245 -0
- skills/quality/readability.md +239 -0
- skills/quality/type_annotations.md +192 -0
- skills/refactoring/behavior_preservation.md +157 -0
- skills/refactoring/incremental.md +187 -0
- skills/refactoring/interface_stability.md +199 -0
- skills/refactoring/safe_refactoring.md +206 -0
- skills/security/auth_boundaries.md +200 -0
- skills/security/command_injection.md +207 -0
- skills/security/dependency_risks.md +282 -0
- skills/security/file_handling.md +156 -0
- skills/security/input_validation.md +190 -0
- skills/security/path_traversal.md +172 -0
- skills/security/secrets.md +171 -0
- skills/security/sql_injection.md +188 -0
- skills/security/unsafe_deserialization.md +164 -0
- skills/stdlib/argparse.md +178 -0
- skills/stdlib/collections.md +212 -0
- skills/stdlib/datetime.md +187 -0
- skills/stdlib/functools.md +238 -0
- skills/stdlib/itertools.md +183 -0
- skills/stdlib/json.md +162 -0
- skills/stdlib/logging.md +185 -0
- skills/stdlib/os_sys.md +184 -0
- skills/stdlib/pathlib.md +218 -0
- skills/stdlib/re.md +171 -0
- skills/stdlib/statistics.md +112 -0
- skills/stdlib/subprocess.md +211 -0
- skills/testing/async_tests.md +249 -0
- skills/testing/coverage.md +168 -0
- skills/testing/edge_cases.md +197 -0
- skills/testing/fixtures_mocks.md +203 -0
- skills/testing/organization.md +205 -0
- skills/testing/parameterized.md +174 -0
- skills/testing/regression_tests.md +165 -0
- skills/variables_types.md +107 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Installer orchestration for python-skills."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from python_skills.adapters import get_adapter
|
|
7
|
+
from python_skills.adapters.base import InstallResult
|
|
8
|
+
from python_skills.config import InstallConfig, Scope, Target, get_adapter_capabilities
|
|
9
|
+
from python_skills.detector import DetectionSummary, EnvironmentDetector
|
|
10
|
+
from python_skills.skills.registry import get_registry
|
|
11
|
+
from python_skills.state import LockManager
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class InstallPlan:
|
|
16
|
+
"""Plan for installation."""
|
|
17
|
+
targets: dict[Target, Scope]
|
|
18
|
+
dry_run: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class SkillInstaller:
|
|
22
|
+
"""Orchestrates installation of python-skills across multiple agents."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, project_root: Path, config: InstallConfig):
|
|
25
|
+
self.project_root = Path(project_root).resolve()
|
|
26
|
+
self.config = config
|
|
27
|
+
self.detector = EnvironmentDetector(self.project_root)
|
|
28
|
+
self.skills_registry = get_registry()
|
|
29
|
+
self.lock_manager = LockManager(self.project_root)
|
|
30
|
+
|
|
31
|
+
def detect_environments(self) -> DetectionSummary:
|
|
32
|
+
"""Detect all AI coding agent environments."""
|
|
33
|
+
return self.detector.detect_all()
|
|
34
|
+
|
|
35
|
+
def plan_installation(self, targets: list[Target] | None = None,
|
|
36
|
+
scope: Scope = Scope.PROJECT,
|
|
37
|
+
auto: bool = False) -> InstallPlan:
|
|
38
|
+
"""Create installation plan based on detection and user selection."""
|
|
39
|
+
detection = self.detect_environments()
|
|
40
|
+
plan_targets = {}
|
|
41
|
+
|
|
42
|
+
if auto:
|
|
43
|
+
for target_name, result in detection.targets.items():
|
|
44
|
+
target = Target(target_name)
|
|
45
|
+
caps = get_adapter_capabilities(target)
|
|
46
|
+
if result.project_config_detected or result.application_detected:
|
|
47
|
+
if (scope == Scope.PROJECT and caps.supports_project) or \
|
|
48
|
+
(scope == Scope.GLOBAL and caps.supports_global):
|
|
49
|
+
plan_targets[target] = scope
|
|
50
|
+
elif targets:
|
|
51
|
+
for target in targets:
|
|
52
|
+
caps = get_adapter_capabilities(target)
|
|
53
|
+
if scope == Scope.PROJECT and caps.supports_project:
|
|
54
|
+
plan_targets[target] = scope
|
|
55
|
+
elif scope == Scope.GLOBAL and caps.supports_global:
|
|
56
|
+
plan_targets[target] = scope
|
|
57
|
+
else:
|
|
58
|
+
raise ValueError(f"Target {target} does not support scope {scope}")
|
|
59
|
+
else:
|
|
60
|
+
for target_name, result in detection.targets.items():
|
|
61
|
+
target = Target(target_name)
|
|
62
|
+
if result.project_config_detected:
|
|
63
|
+
caps = get_adapter_capabilities(target)
|
|
64
|
+
if caps.supports_project:
|
|
65
|
+
plan_targets[target] = scope
|
|
66
|
+
|
|
67
|
+
return InstallPlan(targets=plan_targets, dry_run=self.config.dry_run)
|
|
68
|
+
|
|
69
|
+
def execute_install(self, plan: InstallPlan) -> dict[Target, InstallResult]:
|
|
70
|
+
"""Execute installation according to plan."""
|
|
71
|
+
results = {}
|
|
72
|
+
|
|
73
|
+
for target, scope in plan.targets.items():
|
|
74
|
+
try:
|
|
75
|
+
adapter = get_adapter(target, self.project_root, self.skills_registry, self.lock_manager, self.config)
|
|
76
|
+
result = adapter.install(scope=scope.value, dry_run=plan.dry_run)
|
|
77
|
+
results[target] = result
|
|
78
|
+
|
|
79
|
+
status = "PASS" if result.success else "FAIL"
|
|
80
|
+
print(f" {target.value}: {status} ({len(result.files_created)} files, {len(result.errors)} errors)")
|
|
81
|
+
if result.errors:
|
|
82
|
+
for err in result.errors:
|
|
83
|
+
print(f" ERROR: {err}")
|
|
84
|
+
if result.warnings:
|
|
85
|
+
for warn in result.warnings:
|
|
86
|
+
print(f" WARNING: {warn}")
|
|
87
|
+
except Exception as e:
|
|
88
|
+
results[target] = InstallResult(
|
|
89
|
+
success=False, target=target.value, scope=scope.value,
|
|
90
|
+
errors=[str(e)]
|
|
91
|
+
)
|
|
92
|
+
print(f" {target.value}: FAIL ({e})")
|
|
93
|
+
|
|
94
|
+
return results
|
|
95
|
+
|
|
96
|
+
def run_sync(self, scope: Scope = Scope.PROJECT,
|
|
97
|
+
targets: list[Target] | None = None,
|
|
98
|
+
dry_run: bool = False) -> dict[Target, any]:
|
|
99
|
+
"""Run sync across targets."""
|
|
100
|
+
|
|
101
|
+
if targets is None:
|
|
102
|
+
targets = [Target(t) for t in self.lock_manager.get_all_targets().keys()
|
|
103
|
+
if t in [t.value for t in Target]]
|
|
104
|
+
|
|
105
|
+
results = {}
|
|
106
|
+
for target in targets:
|
|
107
|
+
try:
|
|
108
|
+
adapter = get_adapter(target, self.project_root, self.skills_registry, self.lock_manager, self.config)
|
|
109
|
+
result = adapter.sync(scope=scope.value, dry_run=dry_run)
|
|
110
|
+
results[target] = result
|
|
111
|
+
|
|
112
|
+
status = "PASS" if result.success else "FAIL"
|
|
113
|
+
print(f" {target.value}: {status} ({len(result.added)} added, {len(result.modified)} modified, {len(result.removed)} removed)")
|
|
114
|
+
if result.errors:
|
|
115
|
+
for err in result.errors:
|
|
116
|
+
print(f" ERROR: {err}")
|
|
117
|
+
except Exception as e:
|
|
118
|
+
print(f" {target.value}: FAIL ({e})")
|
|
119
|
+
|
|
120
|
+
return results
|
|
121
|
+
|
|
122
|
+
def run_uninstall(self, scope: Scope = Scope.PROJECT,
|
|
123
|
+
targets: list[Target] | None = None,
|
|
124
|
+
dry_run: bool = False) -> dict[Target, any]:
|
|
125
|
+
"""Run uninstall across targets."""
|
|
126
|
+
|
|
127
|
+
if targets is None:
|
|
128
|
+
targets = [Target(t) for t in self.lock_manager.get_all_targets().keys()
|
|
129
|
+
if t in [t.value for t in Target]]
|
|
130
|
+
|
|
131
|
+
results = {}
|
|
132
|
+
for target in targets:
|
|
133
|
+
try:
|
|
134
|
+
adapter = get_adapter(target, self.project_root, self.skills_registry, self.lock_manager, self.config)
|
|
135
|
+
result = adapter.uninstall(scope=scope.value, dry_run=dry_run)
|
|
136
|
+
results[target] = result
|
|
137
|
+
|
|
138
|
+
status = "PASS" if result.success else "FAIL"
|
|
139
|
+
print(f" {target.value}: {status} ({len(result.files_removed)} files)")
|
|
140
|
+
if result.errors:
|
|
141
|
+
for err in result.errors:
|
|
142
|
+
print(f" ERROR: {err}")
|
|
143
|
+
except Exception as e:
|
|
144
|
+
print(f" {target.value}: FAIL ({e})")
|
|
145
|
+
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
def get_status(self, scope: Scope = Scope.PROJECT) -> dict[Target, any]:
|
|
149
|
+
"""Get installation status for all targets."""
|
|
150
|
+
|
|
151
|
+
results = {}
|
|
152
|
+
for target in Target:
|
|
153
|
+
try:
|
|
154
|
+
adapter = get_adapter(target, self.project_root, self.skills_registry, self.lock_manager, self.config)
|
|
155
|
+
result = adapter.status(scope=scope.value)
|
|
156
|
+
results[target] = result
|
|
157
|
+
|
|
158
|
+
status = "Installed" if result.installed else "Not installed"
|
|
159
|
+
print(f" {target.value}: {status} ({len(result.files)} files)")
|
|
160
|
+
except Exception as e:
|
|
161
|
+
print(f" {target.value}: Error ({e})")
|
|
162
|
+
|
|
163
|
+
return results
|
python_skills/markers.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Ownership marker utilities for generated content."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
# Standard ownership markers
|
|
6
|
+
BEGIN_MARKER = "<!-- BEGIN PYTHON-SKILLS MANAGED -->"
|
|
7
|
+
END_MARKER = "<!-- END PYTHON-SKILLS MANAGED -->"
|
|
8
|
+
|
|
9
|
+
# Format-specific markers
|
|
10
|
+
MARKERS = {
|
|
11
|
+
"md": (BEGIN_MARKER, END_MARKER),
|
|
12
|
+
"mdc": (BEGIN_MARKER, END_MARKER),
|
|
13
|
+
"mdx": (BEGIN_MARKER, END_MARKER),
|
|
14
|
+
"txt": (BEGIN_MARKER, END_MARKER),
|
|
15
|
+
"yaml": ("# PYTHON-SKILLS MANAGED", "# END PYTHON-SKILLS MANAGED"),
|
|
16
|
+
"yml": ("# PYTHON-SKILLS MANAGED", "# END PYTHON-SKILLS MANAGED"),
|
|
17
|
+
"json": ("// PYTHON-SKILLS MANAGED", "// END PYTHON-SKILLS MANAGED"),
|
|
18
|
+
"py": ("# PYTHON-SKILLS MANAGED", "# END PYTHON-SKILLS MANAGED"),
|
|
19
|
+
"js": ("// PYTHON-SKILLS MANAGED", "// END PYTHON-SKILLS MANAGED"),
|
|
20
|
+
"ts": ("// PYTHON-SKILLS MANAGED", "// END PYTHON-SKILLS MANAGED"),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_markers(file_path: str) -> tuple[str, str]:
|
|
25
|
+
"""Get ownership markers for a file type."""
|
|
26
|
+
ext = file_path.split(".")[-1].lower() if "." in file_path else "md"
|
|
27
|
+
return MARKERS.get(ext, (BEGIN_MARKER, END_MARKER))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def wrap_managed_content(content: str, file_path: str) -> str:
|
|
31
|
+
"""Wrap content with ownership markers."""
|
|
32
|
+
begin, end = get_markers(file_path)
|
|
33
|
+
return f"{begin}\n{content}\n{end}"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def extract_managed_region(content: str, file_path: str) -> str | None:
|
|
37
|
+
"""Extract content between ownership markers."""
|
|
38
|
+
begin, end = get_markers(file_path)
|
|
39
|
+
|
|
40
|
+
start_idx = content.find(begin)
|
|
41
|
+
if start_idx == -1:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
start_idx += len(begin)
|
|
45
|
+
end_idx = content.find(end, start_idx)
|
|
46
|
+
if end_idx == -1:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
return content[start_idx:end_idx]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def replace_managed_region(content: str, new_content: str, file_path: str) -> tuple[str, bool]:
|
|
53
|
+
"""
|
|
54
|
+
Replace content between ownership markers.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Tuple of (new_content, was_replaced)
|
|
58
|
+
"""
|
|
59
|
+
begin, end = get_markers(file_path)
|
|
60
|
+
|
|
61
|
+
start_idx = content.find(begin)
|
|
62
|
+
if start_idx == -1:
|
|
63
|
+
# No existing managed region - append at end
|
|
64
|
+
if content.strip():
|
|
65
|
+
new_content_wrapped = f"\n{wrap_managed_content(new_content, file_path)}"
|
|
66
|
+
return content + new_content_wrapped, True
|
|
67
|
+
return wrap_managed_content(new_content, file_path), True
|
|
68
|
+
|
|
69
|
+
end_idx = content.find(end, start_idx)
|
|
70
|
+
if end_idx == -1:
|
|
71
|
+
# Malformed - no end marker
|
|
72
|
+
return content, False
|
|
73
|
+
|
|
74
|
+
# Replace region
|
|
75
|
+
new_content_full = content[:start_idx] + wrap_managed_content(new_content, file_path) + content[end_idx + len(end):]
|
|
76
|
+
return new_content_full, True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def remove_managed_region(content: str, file_path: str) -> tuple[str, bool]:
|
|
80
|
+
"""
|
|
81
|
+
Remove the managed region from content.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
Tuple of (new_content, was_removed)
|
|
85
|
+
"""
|
|
86
|
+
begin, end = get_markers(file_path)
|
|
87
|
+
|
|
88
|
+
start_idx = content.find(begin)
|
|
89
|
+
if start_idx == -1:
|
|
90
|
+
return content, False
|
|
91
|
+
|
|
92
|
+
end_idx = content.find(end, start_idx)
|
|
93
|
+
if end_idx == -1:
|
|
94
|
+
return content, False
|
|
95
|
+
|
|
96
|
+
# Remove region including markers
|
|
97
|
+
new_content = content[:start_idx] + content[end_idx + len(end):]
|
|
98
|
+
return new_content, True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def compute_region_hash(content: str, file_path: str) -> str | None:
|
|
102
|
+
"""Compute hash of managed region content."""
|
|
103
|
+
import hashlib
|
|
104
|
+
region = extract_managed_region(content, file_path)
|
|
105
|
+
if region is None:
|
|
106
|
+
return None
|
|
107
|
+
return hashlib.sha256(region.encode("utf-8")).hexdigest()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_managed_region_modified(content: str, file_path: str, expected_hash: str) -> bool:
|
|
111
|
+
"""Check if managed region has been modified by user."""
|
|
112
|
+
current_hash = compute_region_hash(content, file_path)
|
|
113
|
+
if current_hash is None:
|
|
114
|
+
return True # No region = modified/corrupted
|
|
115
|
+
return current_hash != expected_hash
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Skills package for python-skills."""
|
|
2
|
+
|
|
3
|
+
from .loader import SkillLoader
|
|
4
|
+
from .metadata import SkillMetadata, load_skill_metadata
|
|
5
|
+
from .registry import SkillRegistry, get_registry, reset_registry
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"SkillRegistry",
|
|
9
|
+
"get_registry",
|
|
10
|
+
"reset_registry",
|
|
11
|
+
"SkillMetadata",
|
|
12
|
+
"load_skill_metadata",
|
|
13
|
+
"SkillLoader",
|
|
14
|
+
]
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Skill loading utilities."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .metadata import SkillMetadata, load_skill_metadata
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SkillLoader:
|
|
9
|
+
"""Loads skills from the canonical skills directory.
|
|
10
|
+
|
|
11
|
+
Supports two directory layouts:
|
|
12
|
+
1. Flat files: skills/<category>/<skill-name>.md (current canonical)
|
|
13
|
+
2. Agent Skills: skills/<category>/<skill-name>/SKILL.md (Agent Skills spec)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
CATEGORIES = frozenset([
|
|
17
|
+
"core", "stdlib", "generation", "engineering",
|
|
18
|
+
"quality", "security", "testing", "refactoring",
|
|
19
|
+
"debugging", "anti_patterns",
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
def __init__(self, skills_root: Path):
|
|
23
|
+
self.skills_root = Path(skills_root).resolve()
|
|
24
|
+
|
|
25
|
+
def _is_category_dir(self, path: Path) -> bool:
|
|
26
|
+
"""Check if a directory is a valid skill category."""
|
|
27
|
+
return path.is_dir() and path.name in self.CATEGORIES
|
|
28
|
+
|
|
29
|
+
def _discover_skills_in_category(self, category_path: Path) -> list[Path]:
|
|
30
|
+
"""Discover skill paths in a category directory.
|
|
31
|
+
|
|
32
|
+
Returns list of Paths, each pointing to either:
|
|
33
|
+
- A .md file (flat format)
|
|
34
|
+
- A directory containing SKILL.md (Agent Skills format)
|
|
35
|
+
"""
|
|
36
|
+
skills = []
|
|
37
|
+
if not category_path.is_dir():
|
|
38
|
+
return skills
|
|
39
|
+
|
|
40
|
+
for item in category_path.iterdir():
|
|
41
|
+
if item.is_file() and item.suffix == ".md" and item.stem != "README":
|
|
42
|
+
skills.append(item)
|
|
43
|
+
elif item.is_dir() and (item / "SKILL.md").exists():
|
|
44
|
+
skills.append(item)
|
|
45
|
+
|
|
46
|
+
return skills
|
|
47
|
+
|
|
48
|
+
def _discover_root_skills(self) -> list[Path]:
|
|
49
|
+
"""Discover skill .md files at the skills/ root (e.g., core skills)."""
|
|
50
|
+
skills = []
|
|
51
|
+
for item in self.skills_root.iterdir():
|
|
52
|
+
if item.is_file() and item.suffix == ".md" and item.stem != "README":
|
|
53
|
+
skills.append(item)
|
|
54
|
+
return skills
|
|
55
|
+
|
|
56
|
+
def load_skill(self, skill_name: str) -> SkillMetadata | None:
|
|
57
|
+
"""Load a single skill by name."""
|
|
58
|
+
# Check root-level flat files first (core skills)
|
|
59
|
+
root_path = self.skills_root / f"{skill_name}.md"
|
|
60
|
+
if root_path.is_file():
|
|
61
|
+
return load_skill_metadata(root_path)
|
|
62
|
+
|
|
63
|
+
for category_dir in self.skills_root.iterdir():
|
|
64
|
+
if not self._is_category_dir(category_dir):
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
# Check flat file format: <name>.md
|
|
68
|
+
flat_path = category_dir / f"{skill_name}.md"
|
|
69
|
+
if flat_path.is_file():
|
|
70
|
+
return load_skill_metadata(flat_path)
|
|
71
|
+
|
|
72
|
+
# Check Agent Skills format: <name>/SKILL.md
|
|
73
|
+
dir_path = category_dir / skill_name
|
|
74
|
+
if dir_path.is_dir():
|
|
75
|
+
return load_skill_metadata(dir_path)
|
|
76
|
+
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def load_category(self, category: str) -> list:
|
|
80
|
+
"""Load all skills in a category."""
|
|
81
|
+
category_path = self.skills_root / category
|
|
82
|
+
if not self._is_category_dir(category_path):
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
skills = []
|
|
86
|
+
for skill_path in self._discover_skills_in_category(category_path):
|
|
87
|
+
metadata = load_skill_metadata(skill_path)
|
|
88
|
+
if metadata:
|
|
89
|
+
skills.append(metadata)
|
|
90
|
+
return skills
|
|
91
|
+
|
|
92
|
+
def load_all_skills(self) -> list:
|
|
93
|
+
"""Load all skills from all categories and root."""
|
|
94
|
+
skills = []
|
|
95
|
+
|
|
96
|
+
# Load root-level skills (core)
|
|
97
|
+
for skill_path in self._discover_root_skills():
|
|
98
|
+
metadata = load_skill_metadata(skill_path)
|
|
99
|
+
if metadata:
|
|
100
|
+
skills.append(metadata)
|
|
101
|
+
|
|
102
|
+
# Load category skills
|
|
103
|
+
for category_dir in self.skills_root.iterdir():
|
|
104
|
+
if self._is_category_dir(category_dir):
|
|
105
|
+
for skill_path in self._discover_skills_in_category(category_dir):
|
|
106
|
+
metadata = load_skill_metadata(skill_path)
|
|
107
|
+
if metadata:
|
|
108
|
+
skills.append(metadata)
|
|
109
|
+
return skills
|
|
110
|
+
|
|
111
|
+
def get_skill_content(self, skill_name: str) -> str | None:
|
|
112
|
+
"""Get the raw content of a skill."""
|
|
113
|
+
# Check root-level first
|
|
114
|
+
root_path = self.skills_root / f"{skill_name}.md"
|
|
115
|
+
if root_path.is_file():
|
|
116
|
+
return root_path.read_text(encoding="utf-8")
|
|
117
|
+
|
|
118
|
+
for category_dir in self.skills_root.iterdir():
|
|
119
|
+
if not self._is_category_dir(category_dir):
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
# Flat format
|
|
123
|
+
flat_path = category_dir / f"{skill_name}.md"
|
|
124
|
+
if flat_path.is_file():
|
|
125
|
+
return flat_path.read_text(encoding="utf-8")
|
|
126
|
+
|
|
127
|
+
# Agent Skills format
|
|
128
|
+
dir_path = category_dir / skill_name / "SKILL.md"
|
|
129
|
+
if dir_path.exists():
|
|
130
|
+
return dir_path.read_text(encoding="utf-8")
|
|
131
|
+
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def get_skill_path(self, skill_name: str) -> Path | None:
|
|
135
|
+
"""Get the filesystem path of a skill."""
|
|
136
|
+
# Check root-level first
|
|
137
|
+
root_path = self.skills_root / f"{skill_name}.md"
|
|
138
|
+
if root_path.is_file():
|
|
139
|
+
return root_path
|
|
140
|
+
|
|
141
|
+
for category_dir in self.skills_root.iterdir():
|
|
142
|
+
if not self._is_category_dir(category_dir):
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
flat_path = category_dir / f"{skill_name}.md"
|
|
146
|
+
if flat_path.is_file():
|
|
147
|
+
return flat_path
|
|
148
|
+
|
|
149
|
+
dir_path = category_dir / skill_name / "SKILL.md"
|
|
150
|
+
if dir_path.exists():
|
|
151
|
+
return dir_path
|
|
152
|
+
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
def get_all_skill_names(self) -> list[str]:
|
|
156
|
+
"""Get list of all skill names."""
|
|
157
|
+
names = []
|
|
158
|
+
|
|
159
|
+
# Root-level skills
|
|
160
|
+
for skill_path in self._discover_root_skills():
|
|
161
|
+
names.append(skill_path.stem)
|
|
162
|
+
|
|
163
|
+
# Category skills
|
|
164
|
+
for category_dir in self.skills_root.iterdir():
|
|
165
|
+
if self._is_category_dir(category_dir):
|
|
166
|
+
for skill_path in self._discover_skills_in_category(category_dir):
|
|
167
|
+
if skill_path.is_file():
|
|
168
|
+
names.append(skill_path.stem)
|
|
169
|
+
elif skill_path.is_dir():
|
|
170
|
+
names.append(skill_path.name)
|
|
171
|
+
return sorted(names)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Skill metadata handling."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class SkillMetadata:
|
|
9
|
+
"""Metadata for a skill from its SKILL.md frontmatter or .md file."""
|
|
10
|
+
name: str
|
|
11
|
+
category: str
|
|
12
|
+
path: Path
|
|
13
|
+
description: str = ""
|
|
14
|
+
triggers: list[str] = field(default_factory=list)
|
|
15
|
+
dependencies: list[str] = field(default_factory=list)
|
|
16
|
+
priority: str = "primary"
|
|
17
|
+
estimated_tokens: int = 1500
|
|
18
|
+
raw_frontmatter: dict = field(default_factory=dict)
|
|
19
|
+
raw_content: str = ""
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def skill_name(self) -> str:
|
|
23
|
+
"""Get the skill name (directory or file stem)."""
|
|
24
|
+
if self.path.is_file():
|
|
25
|
+
return self.path.stem
|
|
26
|
+
return self.path.name
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_skill_frontmatter(content: str) -> tuple[dict, str]:
|
|
30
|
+
"""
|
|
31
|
+
Parse YAML frontmatter from skill content.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Tuple of (frontmatter_dict, remaining_content)
|
|
35
|
+
"""
|
|
36
|
+
if not content.startswith("---"):
|
|
37
|
+
return {}, content
|
|
38
|
+
|
|
39
|
+
# Find the closing ---
|
|
40
|
+
end_idx = content.find("---", 3)
|
|
41
|
+
if end_idx == -1:
|
|
42
|
+
return {}, content
|
|
43
|
+
|
|
44
|
+
frontmatter_text = content[3:end_idx].strip()
|
|
45
|
+
remaining = content[end_idx + 3:].lstrip()
|
|
46
|
+
|
|
47
|
+
# Simple YAML parsing for our needs
|
|
48
|
+
frontmatter = {}
|
|
49
|
+
for line in frontmatter_text.split("\n"):
|
|
50
|
+
line = line.strip()
|
|
51
|
+
if not line or line.startswith("#"):
|
|
52
|
+
continue
|
|
53
|
+
if ":" in line:
|
|
54
|
+
key, value = line.split(":", 1)
|
|
55
|
+
key = key.strip()
|
|
56
|
+
value = value.strip().strip('"\'')
|
|
57
|
+
# Handle lists
|
|
58
|
+
if value.startswith("[") and value.endswith("]"):
|
|
59
|
+
value = [v.strip().strip('"\'') for v in value[1:-1].split(",")]
|
|
60
|
+
frontmatter[key] = value
|
|
61
|
+
|
|
62
|
+
return frontmatter, remaining
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_description_from_content(content: str) -> str:
|
|
66
|
+
"""Extract a description from markdown content without frontmatter.
|
|
67
|
+
|
|
68
|
+
Looks for patterns like:
|
|
69
|
+
- **Purpose**: ...
|
|
70
|
+
- # Title\n\nDescription
|
|
71
|
+
- First paragraph
|
|
72
|
+
"""
|
|
73
|
+
lines = content.strip().split("\n")
|
|
74
|
+
|
|
75
|
+
for line in lines:
|
|
76
|
+
line = line.strip()
|
|
77
|
+
# Look for **Purpose**: pattern
|
|
78
|
+
if line.lower().startswith("**purpose**:"):
|
|
79
|
+
return line.split(":", 1)[1].strip()
|
|
80
|
+
# Look for **When to use**: pattern
|
|
81
|
+
if line.lower().startswith("**when to use**:"):
|
|
82
|
+
return line.split(":", 1)[1].strip()
|
|
83
|
+
|
|
84
|
+
# Use first heading + next non-empty line
|
|
85
|
+
found_heading = False
|
|
86
|
+
for line in lines:
|
|
87
|
+
line = line.strip()
|
|
88
|
+
if not line:
|
|
89
|
+
continue
|
|
90
|
+
if line.startswith("#"):
|
|
91
|
+
found_heading = True
|
|
92
|
+
continue
|
|
93
|
+
if found_heading and line:
|
|
94
|
+
return line[:200]
|
|
95
|
+
|
|
96
|
+
return ""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_skill_metadata(skill_path: Path) -> SkillMetadata | None:
|
|
100
|
+
"""Load metadata from a skill path.
|
|
101
|
+
|
|
102
|
+
Supports two formats:
|
|
103
|
+
1. Flat file: skills/<category>/<name>.md
|
|
104
|
+
2. Agent Skills: skills/<category>/<name>/SKILL.md
|
|
105
|
+
"""
|
|
106
|
+
# Determine the actual skill file and content
|
|
107
|
+
if skill_path.is_file() and skill_path.suffix == ".md":
|
|
108
|
+
# Flat file format
|
|
109
|
+
content = skill_path.read_text(encoding="utf-8")
|
|
110
|
+
skill_name = skill_path.stem
|
|
111
|
+
skill_file = skill_path
|
|
112
|
+
elif skill_path.is_dir():
|
|
113
|
+
# Agent Skills format
|
|
114
|
+
skill_file = skill_path / "SKILL.md"
|
|
115
|
+
if not skill_file.exists():
|
|
116
|
+
return None
|
|
117
|
+
content = skill_file.read_text(encoding="utf-8")
|
|
118
|
+
skill_name = skill_path.name
|
|
119
|
+
else:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
frontmatter, content_body = parse_skill_frontmatter(content)
|
|
123
|
+
|
|
124
|
+
# Determine category from path
|
|
125
|
+
# For flat files: parent is the category
|
|
126
|
+
# For directories: grandparent is the category
|
|
127
|
+
if skill_path.is_file():
|
|
128
|
+
category = skill_path.parent.name
|
|
129
|
+
else:
|
|
130
|
+
category = skill_path.parent.name
|
|
131
|
+
|
|
132
|
+
from .loader import SkillLoader
|
|
133
|
+
if category not in SkillLoader.CATEGORIES:
|
|
134
|
+
category = "core"
|
|
135
|
+
|
|
136
|
+
# Get description from frontmatter or extract from content
|
|
137
|
+
description = frontmatter.get("description", "")
|
|
138
|
+
if not description:
|
|
139
|
+
description = _extract_description_from_content(content)
|
|
140
|
+
|
|
141
|
+
return SkillMetadata(
|
|
142
|
+
name=skill_name,
|
|
143
|
+
category=category,
|
|
144
|
+
path=skill_path,
|
|
145
|
+
description=description,
|
|
146
|
+
triggers=frontmatter.get("triggers", []) if isinstance(frontmatter.get("triggers"), list) else [],
|
|
147
|
+
dependencies=frontmatter.get("dependencies", []) if isinstance(frontmatter.get("dependencies"), list) else [],
|
|
148
|
+
priority=frontmatter.get("priority", "primary"),
|
|
149
|
+
estimated_tokens=frontmatter.get("estimated_tokens", 1500) if isinstance(frontmatter.get("estimated_tokens"), int) else 1500,
|
|
150
|
+
raw_frontmatter=frontmatter,
|
|
151
|
+
raw_content=content_body,
|
|
152
|
+
)
|