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.
Files changed (105) hide show
  1. python_skills/__init__.py +10 -0
  2. python_skills/__main__.py +6 -0
  3. python_skills/adapters/__init__.py +48 -0
  4. python_skills/adapters/agent_skills.py +415 -0
  5. python_skills/adapters/aider_adapter.py +226 -0
  6. python_skills/adapters/base.py +153 -0
  7. python_skills/adapters/claude.py +474 -0
  8. python_skills/adapters/cline.py +332 -0
  9. python_skills/adapters/codex.py +24 -0
  10. python_skills/adapters/continue_adapter.py +198 -0
  11. python_skills/adapters/cursor.py +327 -0
  12. python_skills/adapters/gemini.py +26 -0
  13. python_skills/adapters/goose.py +26 -0
  14. python_skills/adapters/junie.py +25 -0
  15. python_skills/adapters/kiro.py +382 -0
  16. python_skills/adapters/opencode.py +27 -0
  17. python_skills/adapters/roo.py +25 -0
  18. python_skills/adapters/universal.py +203 -0
  19. python_skills/adapters/vscode.py +27 -0
  20. python_skills/adapters/windsurf.py +26 -0
  21. python_skills/adapters/zed.py +27 -0
  22. python_skills/cli.py +326 -0
  23. python_skills/config.py +160 -0
  24. python_skills/detector.py +152 -0
  25. python_skills/installer.py +163 -0
  26. python_skills/markers.py +115 -0
  27. python_skills/skills/__init__.py +14 -0
  28. python_skills/skills/loader.py +171 -0
  29. python_skills/skills/metadata.py +152 -0
  30. python_skills/skills/registry.py +101 -0
  31. python_skills/state.py +204 -0
  32. python_skills-1.0.0.dist-info/METADATA +99 -0
  33. python_skills-1.0.0.dist-info/RECORD +105 -0
  34. python_skills-1.0.0.dist-info/WHEEL +4 -0
  35. python_skills-1.0.0.dist-info/entry_points.txt +2 -0
  36. python_skills-1.0.0.dist-info/licenses/LICENSE +21 -0
  37. skills/advanced_python.md +239 -0
  38. skills/anti_patterns/index.md +406 -0
  39. skills/comprehensions.md +167 -0
  40. skills/control_flow.md +175 -0
  41. skills/data_structures.md +243 -0
  42. skills/debugging/common_bugs.md +222 -0
  43. skills/debugging/inspection_techniques.md +249 -0
  44. skills/debugging/root_cause.md +203 -0
  45. skills/engineering/application_logging.md +195 -0
  46. skills/engineering/cli_apps.md +207 -0
  47. skills/engineering/configuration.md +218 -0
  48. skills/engineering/database.md +240 -0
  49. skills/engineering/dependency_management.md +205 -0
  50. skills/engineering/http_clients.md +267 -0
  51. skills/engineering/modules_packages.md +211 -0
  52. skills/engineering/packaging.md +197 -0
  53. skills/engineering/project_structure.md +155 -0
  54. skills/engineering/pyproject_toml.md +302 -0
  55. skills/engineering/virtual_environments.md +206 -0
  56. skills/functions.md +244 -0
  57. skills/generation/async_concurrency.md +291 -0
  58. skills/generation/error_handling.md +276 -0
  59. skills/generation/protocols_generics.md +243 -0
  60. skills/generation/type_hints.md +290 -0
  61. skills/generation/validation_pipeline.md +274 -0
  62. skills/generation/workflow.md +190 -0
  63. skills/oop.md +228 -0
  64. skills/quality/abstractions.md +154 -0
  65. skills/quality/comments.md +177 -0
  66. skills/quality/documentation.md +176 -0
  67. skills/quality/duplication.md +137 -0
  68. skills/quality/maintainability.md +142 -0
  69. skills/quality/naming.md +171 -0
  70. skills/quality/quality_functions.md +245 -0
  71. skills/quality/readability.md +239 -0
  72. skills/quality/type_annotations.md +192 -0
  73. skills/refactoring/behavior_preservation.md +157 -0
  74. skills/refactoring/incremental.md +187 -0
  75. skills/refactoring/interface_stability.md +199 -0
  76. skills/refactoring/safe_refactoring.md +206 -0
  77. skills/security/auth_boundaries.md +200 -0
  78. skills/security/command_injection.md +207 -0
  79. skills/security/dependency_risks.md +282 -0
  80. skills/security/file_handling.md +156 -0
  81. skills/security/input_validation.md +190 -0
  82. skills/security/path_traversal.md +172 -0
  83. skills/security/secrets.md +171 -0
  84. skills/security/sql_injection.md +188 -0
  85. skills/security/unsafe_deserialization.md +164 -0
  86. skills/stdlib/argparse.md +178 -0
  87. skills/stdlib/collections.md +212 -0
  88. skills/stdlib/datetime.md +187 -0
  89. skills/stdlib/functools.md +238 -0
  90. skills/stdlib/itertools.md +183 -0
  91. skills/stdlib/json.md +162 -0
  92. skills/stdlib/logging.md +185 -0
  93. skills/stdlib/os_sys.md +184 -0
  94. skills/stdlib/pathlib.md +218 -0
  95. skills/stdlib/re.md +171 -0
  96. skills/stdlib/statistics.md +112 -0
  97. skills/stdlib/subprocess.md +211 -0
  98. skills/testing/async_tests.md +249 -0
  99. skills/testing/coverage.md +168 -0
  100. skills/testing/edge_cases.md +197 -0
  101. skills/testing/fixtures_mocks.md +203 -0
  102. skills/testing/organization.md +205 -0
  103. skills/testing/parameterized.md +174 -0
  104. skills/testing/regression_tests.md +165 -0
  105. 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
@@ -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
+ )