llms-py 2.0.20__py3-none-any.whl → 3.0.18__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 (207) hide show
  1. llms/__init__.py +3 -1
  2. llms/db.py +359 -0
  3. llms/{ui/Analytics.mjs → extensions/analytics/ui/index.mjs} +254 -327
  4. llms/extensions/app/README.md +20 -0
  5. llms/extensions/app/__init__.py +588 -0
  6. llms/extensions/app/db.py +540 -0
  7. llms/{ui → extensions/app/ui}/Recents.mjs +99 -73
  8. llms/{ui/Sidebar.mjs → extensions/app/ui/index.mjs} +139 -68
  9. llms/extensions/app/ui/threadStore.mjs +440 -0
  10. llms/extensions/computer/README.md +96 -0
  11. llms/extensions/computer/__init__.py +59 -0
  12. llms/extensions/computer/base.py +80 -0
  13. llms/extensions/computer/bash.py +185 -0
  14. llms/extensions/computer/computer.py +523 -0
  15. llms/extensions/computer/edit.py +299 -0
  16. llms/extensions/computer/filesystem.py +542 -0
  17. llms/extensions/computer/platform.py +461 -0
  18. llms/extensions/computer/run.py +37 -0
  19. llms/extensions/core_tools/CALCULATOR.md +32 -0
  20. llms/extensions/core_tools/__init__.py +599 -0
  21. llms/extensions/core_tools/ui/codemirror/addon/edit/closebrackets.js +201 -0
  22. llms/extensions/core_tools/ui/codemirror/addon/edit/closetag.js +185 -0
  23. llms/extensions/core_tools/ui/codemirror/addon/edit/continuelist.js +101 -0
  24. llms/extensions/core_tools/ui/codemirror/addon/edit/matchbrackets.js +160 -0
  25. llms/extensions/core_tools/ui/codemirror/addon/edit/matchtags.js +66 -0
  26. llms/extensions/core_tools/ui/codemirror/addon/edit/trailingspace.js +27 -0
  27. llms/extensions/core_tools/ui/codemirror/addon/selection/active-line.js +72 -0
  28. llms/extensions/core_tools/ui/codemirror/addon/selection/mark-selection.js +119 -0
  29. llms/extensions/core_tools/ui/codemirror/addon/selection/selection-pointer.js +98 -0
  30. llms/extensions/core_tools/ui/codemirror/codemirror.css +344 -0
  31. llms/extensions/core_tools/ui/codemirror/codemirror.js +9884 -0
  32. llms/extensions/core_tools/ui/codemirror/doc/docs.css +225 -0
  33. llms/extensions/core_tools/ui/codemirror/doc/source_sans.woff +0 -0
  34. llms/extensions/core_tools/ui/codemirror/mode/clike/clike.js +942 -0
  35. llms/extensions/core_tools/ui/codemirror/mode/javascript/index.html +118 -0
  36. llms/extensions/core_tools/ui/codemirror/mode/javascript/javascript.js +962 -0
  37. llms/extensions/core_tools/ui/codemirror/mode/javascript/typescript.html +62 -0
  38. llms/extensions/core_tools/ui/codemirror/mode/python/python.js +402 -0
  39. llms/extensions/core_tools/ui/codemirror/theme/dracula.css +40 -0
  40. llms/extensions/core_tools/ui/codemirror/theme/mocha.css +135 -0
  41. llms/extensions/core_tools/ui/index.mjs +650 -0
  42. llms/extensions/gallery/README.md +61 -0
  43. llms/extensions/gallery/__init__.py +63 -0
  44. llms/extensions/gallery/db.py +243 -0
  45. llms/extensions/gallery/ui/index.mjs +482 -0
  46. llms/extensions/katex/README.md +39 -0
  47. llms/extensions/katex/__init__.py +6 -0
  48. llms/extensions/katex/ui/README.md +125 -0
  49. llms/extensions/katex/ui/contrib/auto-render.js +338 -0
  50. llms/extensions/katex/ui/contrib/auto-render.min.js +1 -0
  51. llms/extensions/katex/ui/contrib/auto-render.mjs +244 -0
  52. llms/extensions/katex/ui/contrib/copy-tex.js +127 -0
  53. llms/extensions/katex/ui/contrib/copy-tex.min.js +1 -0
  54. llms/extensions/katex/ui/contrib/copy-tex.mjs +105 -0
  55. llms/extensions/katex/ui/contrib/mathtex-script-type.js +109 -0
  56. llms/extensions/katex/ui/contrib/mathtex-script-type.min.js +1 -0
  57. llms/extensions/katex/ui/contrib/mathtex-script-type.mjs +24 -0
  58. llms/extensions/katex/ui/contrib/mhchem.js +3213 -0
  59. llms/extensions/katex/ui/contrib/mhchem.min.js +1 -0
  60. llms/extensions/katex/ui/contrib/mhchem.mjs +3109 -0
  61. llms/extensions/katex/ui/contrib/render-a11y-string.js +887 -0
  62. llms/extensions/katex/ui/contrib/render-a11y-string.min.js +1 -0
  63. llms/extensions/katex/ui/contrib/render-a11y-string.mjs +800 -0
  64. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.ttf +0 -0
  65. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff +0 -0
  66. llms/extensions/katex/ui/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  67. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  68. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  69. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  70. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  71. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  72. llms/extensions/katex/ui/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  73. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  74. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  75. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  76. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  77. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  78. llms/extensions/katex/ui/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  79. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.ttf +0 -0
  80. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff +0 -0
  81. llms/extensions/katex/ui/fonts/KaTeX_Main-Bold.woff2 +0 -0
  82. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  83. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  84. llms/extensions/katex/ui/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  85. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.ttf +0 -0
  86. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff +0 -0
  87. llms/extensions/katex/ui/fonts/KaTeX_Main-Italic.woff2 +0 -0
  88. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.ttf +0 -0
  89. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff +0 -0
  90. llms/extensions/katex/ui/fonts/KaTeX_Main-Regular.woff2 +0 -0
  91. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  92. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  93. llms/extensions/katex/ui/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  94. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.ttf +0 -0
  95. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff +0 -0
  96. llms/extensions/katex/ui/fonts/KaTeX_Math-Italic.woff2 +0 -0
  97. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  98. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  99. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  100. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  101. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  102. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  103. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  104. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  105. llms/extensions/katex/ui/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  106. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.ttf +0 -0
  107. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff +0 -0
  108. llms/extensions/katex/ui/fonts/KaTeX_Script-Regular.woff2 +0 -0
  109. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.ttf +0 -0
  110. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff +0 -0
  111. llms/extensions/katex/ui/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  112. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.ttf +0 -0
  113. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff +0 -0
  114. llms/extensions/katex/ui/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  115. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.ttf +0 -0
  116. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff +0 -0
  117. llms/extensions/katex/ui/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  118. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.ttf +0 -0
  119. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff +0 -0
  120. llms/extensions/katex/ui/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  121. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  122. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  123. llms/extensions/katex/ui/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  124. llms/extensions/katex/ui/index.mjs +92 -0
  125. llms/extensions/katex/ui/katex-swap.css +1230 -0
  126. llms/extensions/katex/ui/katex-swap.min.css +1 -0
  127. llms/extensions/katex/ui/katex.css +1230 -0
  128. llms/extensions/katex/ui/katex.js +19080 -0
  129. llms/extensions/katex/ui/katex.min.css +1 -0
  130. llms/extensions/katex/ui/katex.min.js +1 -0
  131. llms/extensions/katex/ui/katex.min.mjs +1 -0
  132. llms/extensions/katex/ui/katex.mjs +18547 -0
  133. llms/extensions/providers/__init__.py +22 -0
  134. llms/extensions/providers/anthropic.py +260 -0
  135. llms/extensions/providers/cerebras.py +36 -0
  136. llms/extensions/providers/chutes.py +153 -0
  137. llms/extensions/providers/google.py +559 -0
  138. llms/extensions/providers/nvidia.py +103 -0
  139. llms/extensions/providers/openai.py +154 -0
  140. llms/extensions/providers/openrouter.py +74 -0
  141. llms/extensions/providers/zai.py +182 -0
  142. llms/extensions/skills/LICENSE +202 -0
  143. llms/extensions/skills/__init__.py +130 -0
  144. llms/extensions/skills/errors.py +25 -0
  145. llms/extensions/skills/models.py +39 -0
  146. llms/extensions/skills/parser.py +178 -0
  147. llms/extensions/skills/ui/index.mjs +376 -0
  148. llms/extensions/skills/ui/skills/create-plan/SKILL.md +74 -0
  149. llms/extensions/skills/validator.py +177 -0
  150. llms/extensions/system_prompts/README.md +22 -0
  151. llms/extensions/system_prompts/__init__.py +45 -0
  152. llms/extensions/system_prompts/ui/index.mjs +276 -0
  153. llms/extensions/system_prompts/ui/prompts.json +1067 -0
  154. llms/extensions/tools/__init__.py +67 -0
  155. llms/extensions/tools/ui/index.mjs +837 -0
  156. llms/index.html +36 -62
  157. llms/llms.json +180 -879
  158. llms/main.py +4009 -912
  159. llms/providers-extra.json +394 -0
  160. llms/providers.json +1 -0
  161. llms/ui/App.mjs +176 -8
  162. llms/ui/ai.mjs +156 -20
  163. llms/ui/app.css +3768 -321
  164. llms/ui/ctx.mjs +459 -0
  165. llms/ui/index.mjs +131 -0
  166. llms/ui/lib/chart.js +14 -0
  167. llms/ui/lib/charts.mjs +16 -0
  168. llms/ui/lib/color.js +14 -0
  169. llms/ui/lib/highlight.min.mjs +1243 -0
  170. llms/ui/lib/idb.min.mjs +8 -0
  171. llms/ui/lib/marked.min.mjs +8 -0
  172. llms/ui/lib/servicestack-client.mjs +1 -0
  173. llms/ui/lib/servicestack-vue.mjs +37 -0
  174. llms/ui/lib/vue-router.min.mjs +6 -0
  175. llms/ui/lib/vue.min.mjs +13 -0
  176. llms/ui/lib/vue.mjs +18530 -0
  177. llms/ui/markdown.mjs +25 -14
  178. llms/ui/modules/chat/ChatBody.mjs +1156 -0
  179. llms/ui/{SettingsDialog.mjs → modules/chat/SettingsDialog.mjs} +74 -74
  180. llms/ui/modules/chat/index.mjs +995 -0
  181. llms/ui/modules/icons.mjs +46 -0
  182. llms/ui/modules/layout.mjs +271 -0
  183. llms/ui/modules/model-selector.mjs +811 -0
  184. llms/ui/tailwind.input.css +560 -78
  185. llms/ui/typography.css +54 -36
  186. llms/ui/utils.mjs +221 -92
  187. llms_py-3.0.18.dist-info/METADATA +49 -0
  188. llms_py-3.0.18.dist-info/RECORD +194 -0
  189. {llms_py-2.0.20.dist-info → llms_py-3.0.18.dist-info}/WHEEL +1 -1
  190. {llms_py-2.0.20.dist-info → llms_py-3.0.18.dist-info}/licenses/LICENSE +1 -2
  191. llms/ui/Avatar.mjs +0 -28
  192. llms/ui/Brand.mjs +0 -34
  193. llms/ui/ChatPrompt.mjs +0 -443
  194. llms/ui/Main.mjs +0 -740
  195. llms/ui/ModelSelector.mjs +0 -60
  196. llms/ui/ProviderIcon.mjs +0 -29
  197. llms/ui/ProviderStatus.mjs +0 -105
  198. llms/ui/SignIn.mjs +0 -64
  199. llms/ui/SystemPromptEditor.mjs +0 -31
  200. llms/ui/SystemPromptSelector.mjs +0 -36
  201. llms/ui/Welcome.mjs +0 -8
  202. llms/ui/threadStore.mjs +0 -524
  203. llms/ui.json +0 -1069
  204. llms_py-2.0.20.dist-info/METADATA +0 -931
  205. llms_py-2.0.20.dist-info/RECORD +0 -36
  206. {llms_py-2.0.20.dist-info → llms_py-3.0.18.dist-info}/entry_points.txt +0 -0
  207. {llms_py-2.0.20.dist-info → llms_py-3.0.18.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,130 @@
1
+ import os
2
+ import shutil
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import aiohttp
7
+
8
+ from .parser import read_properties
9
+
10
+ g_skills = {}
11
+
12
+
13
+ def sanitize(name: str) -> str:
14
+ return name.replace(" ", "").replace("_", "").replace("-", "").lower()
15
+
16
+
17
+ def skill(name: Annotated[str, "skill name"], file: Annotated[str | None, "skill file"] = None):
18
+ """Get the content of a skill or a specific file within a skill."""
19
+ skill = g_skills.get(name)
20
+
21
+ if not skill:
22
+ sanitized_name = sanitize(name)
23
+ for k, v in g_skills.items():
24
+ if sanitize(k) == sanitized_name:
25
+ skill = v
26
+ break
27
+
28
+ if not skill:
29
+ return f"Error: Skill {name} not found. Available skills: {', '.join(g_skills.keys())}"
30
+ location = skill.get("location")
31
+ if not location or not os.path.exists(location):
32
+ return f"Error: Skill {name} not found at location {location}"
33
+
34
+ if file:
35
+ if file.startswith(location):
36
+ file = file[len(location) + 1 :]
37
+ if not os.path.exists(os.path.join(location, file)):
38
+ return f"Error: File {file} not found in skill {name}. Available files: {', '.join(skill.get('files', []))}"
39
+ with open(os.path.join(location, file)) as f:
40
+ return f.read()
41
+
42
+ with open(os.path.join(location, "SKILL.md")) as f:
43
+ content = f.read()
44
+
45
+ files = skill.get("files")
46
+ if files and len(files) > 1:
47
+ content += "\n\n## Skill Files:\n```\n"
48
+ for file in files:
49
+ content += f"{file}\n"
50
+ content += "```\n"
51
+ return content
52
+
53
+
54
+ def install(ctx):
55
+ global g_skills
56
+ home_skills = ctx.get_home_path(os.path.join(".agent", "skills"))
57
+ # if not folder exists
58
+ if not os.path.exists(home_skills):
59
+ os.makedirs(ctx.get_home_path(os.path.join(".agent")), exist_ok=True)
60
+ ctx.log(f"Creating initial skills folder: {home_skills}")
61
+ # os.makedirs(home_skills)
62
+ # copy ui/skills to home_skills
63
+ ui_skills = os.path.join(ctx.path, "ui", "skills")
64
+ shutil.copytree(ui_skills, home_skills)
65
+
66
+ skill_roots = {}
67
+
68
+ # add .claude skills first, so they can be overridden by .agent skills
69
+ if os.path.exists("~/.claude/skills"):
70
+ skill_roots["~/.claude/skills"] = "~/.claude/skills"
71
+
72
+ if os.path.exists(os.path.join(".claude", "skills")):
73
+ skill_roots[".claude/skills"] = os.path.join(".claude", "skills")
74
+
75
+ skill_roots["~/.llms/.agents"] = home_skills
76
+
77
+ if os.path.exists(os.path.join(".agent", "skills")):
78
+ skill_roots[".agents"] = os.path.join(".agent", "skills")
79
+
80
+ g_skills = {}
81
+ for group, root in skill_roots.items():
82
+ if not os.path.exists(root):
83
+ continue
84
+ try:
85
+ for entry in os.scandir(root):
86
+ if (
87
+ entry.is_dir()
88
+ and os.path.exists(os.path.join(entry.path, "SKILL.md"))
89
+ or os.path.exists(os.path.join(entry.path, "skill.md"))
90
+ ):
91
+ skill_dir = Path(entry.path).resolve()
92
+ props = read_properties(skill_dir)
93
+
94
+ # recursivly list all files in this directory
95
+ files = []
96
+ for file in skill_dir.glob("**/*"):
97
+ if file.is_file():
98
+ full_path = str(file)
99
+ rel_path = full_path[len(str(skill_dir)) + 1 :]
100
+ files.append(rel_path)
101
+
102
+ skill_props = props.to_dict()
103
+ skill_props.update(
104
+ {
105
+ "group": group,
106
+ "location": str(skill_dir),
107
+ "files": files,
108
+ }
109
+ )
110
+ g_skills[props.name] = skill_props
111
+
112
+ except OSError:
113
+ pass
114
+
115
+ async def get_skills(request):
116
+ return aiohttp.web.json_response(g_skills)
117
+
118
+ ctx.add_get("", get_skills)
119
+
120
+ async def get_skill(request):
121
+ name = request.match_info.get("name")
122
+ file = request.query.get("file")
123
+ return aiohttp.web.Response(text=skill(name, file))
124
+
125
+ ctx.add_get("contents/{name}", get_skill)
126
+
127
+ ctx.register_tool(skill, group="core_tools")
128
+
129
+
130
+ __install__ = install
@@ -0,0 +1,25 @@
1
+ """Skill-related exceptions."""
2
+
3
+
4
+ class SkillError(Exception):
5
+ """Base exception for all skill-related errors."""
6
+
7
+ pass
8
+
9
+
10
+ class ParseError(SkillError):
11
+ """Raised when SKILL.md parsing fails."""
12
+
13
+ pass
14
+
15
+
16
+ class ValidationError(SkillError):
17
+ """Raised when skill properties are invalid.
18
+
19
+ Attributes:
20
+ errors: List of validation error messages (may contain just one)
21
+ """
22
+
23
+ def __init__(self, message: str, errors: list[str] | None = None):
24
+ super().__init__(message)
25
+ self.errors = errors if errors is not None else [message]
@@ -0,0 +1,39 @@
1
+ """Data models for Agent Skills."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Optional
5
+
6
+
7
+ @dataclass
8
+ class SkillProperties:
9
+ """Properties parsed from a skill's SKILL.md frontmatter.
10
+
11
+ Attributes:
12
+ name: Skill name in kebab-case (required)
13
+ description: What the skill does and when the model should use it (required)
14
+ license: License for the skill (optional)
15
+ compatibility: Compatibility information for the skill (optional)
16
+ allowed_tools: Tool patterns the skill requires (optional, experimental)
17
+ metadata: Key-value pairs for client-specific properties (defaults to
18
+ empty dict; omitted from to_dict() output when empty)
19
+ """
20
+
21
+ name: str
22
+ description: str
23
+ license: Optional[str] = None
24
+ compatibility: Optional[str] = None
25
+ allowed_tools: Optional[str] = None
26
+ metadata: dict[str, str] = field(default_factory=dict)
27
+
28
+ def to_dict(self) -> dict:
29
+ """Convert to dictionary, excluding None values."""
30
+ result = {"name": self.name, "description": self.description}
31
+ if self.license is not None:
32
+ result["license"] = self.license
33
+ if self.compatibility is not None:
34
+ result["compatibility"] = self.compatibility
35
+ if self.allowed_tools is not None:
36
+ result["allowed-tools"] = self.allowed_tools
37
+ if self.metadata:
38
+ result["metadata"] = self.metadata
39
+ return result
@@ -0,0 +1,178 @@
1
+ """YAML frontmatter parsing for SKILL.md files."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ from .errors import ParseError, ValidationError
7
+ from .models import SkillProperties
8
+
9
+
10
+ def load_yaml(content: str) -> dict:
11
+ """Simple YAML parser for skill frontmatter.
12
+
13
+ Supports:
14
+ - Key-value pairs: key: "value"
15
+ - Comments: # comment
16
+ - Simple nesting (indentation-based)
17
+ """
18
+ result = {}
19
+ stack = [result]
20
+ indents = [-1]
21
+ last_key = None
22
+
23
+ for line in content.splitlines():
24
+ # Skip empty lines or full comments
25
+ stripped = line.strip()
26
+ if not stripped or stripped.startswith("#"):
27
+ continue
28
+
29
+ indent = len(line) - len(line.lstrip())
30
+
31
+ # Handle indent levels
32
+ while indent <= indents[-1]:
33
+ indents.pop()
34
+ stack.pop()
35
+
36
+ # If we have a nested block under last key
37
+ if indent > indents[-1] and last_key and isinstance(stack[-1], dict) and stack[-1].get(last_key) is None:
38
+ # This branch is tricky with the simple look-behind.
39
+ # Better approach: check if line is a key-value or array item
40
+ pass
41
+
42
+ # Parse key: value
43
+ if ":" in stripped:
44
+ key, val = stripped.split(":", 1)
45
+ key = key.strip()
46
+ val = val.strip()
47
+
48
+ # Handle quotes
49
+ if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
50
+ val = val[1:-1]
51
+ elif val.lower() == "true":
52
+ val = True
53
+ elif val.lower() == "false":
54
+ val = False
55
+ elif val == "":
56
+ val = None # Could be start of nested object
57
+
58
+ current_dict = stack[-1]
59
+
60
+ if val is None:
61
+ # Prepare for nested object
62
+ new_dict = {}
63
+ current_dict[key] = new_dict
64
+ stack.append(new_dict)
65
+ indents.append(indent)
66
+ else:
67
+ current_dict[key] = val
68
+
69
+ last_key = key
70
+ else:
71
+ # Handle continuation lines or unknown format if needed,
72
+ # but for our simple use case we might error or ignore.
73
+ pass
74
+
75
+ return result
76
+
77
+
78
+ def find_skill_md(skill_dir: Path) -> Optional[Path]:
79
+ """Find the SKILL.md file in a skill directory.
80
+
81
+ Prefers SKILL.md (uppercase) but accepts skill.md (lowercase).
82
+
83
+ Args:
84
+ skill_dir: Path to the skill directory
85
+
86
+ Returns:
87
+ Path to the SKILL.md file, or None if not found
88
+ """
89
+ for name in ("SKILL.md", "skill.md"):
90
+ path = skill_dir / name
91
+ if path.exists():
92
+ return path
93
+ return None
94
+
95
+
96
+ def parse_frontmatter(content: str) -> tuple[dict, str]:
97
+ """Parse YAML frontmatter from SKILL.md content.
98
+
99
+ Args:
100
+ content: Raw content of SKILL.md file
101
+
102
+ Returns:
103
+ Tuple of (metadata dict, markdown body)
104
+
105
+ Raises:
106
+ ParseError: If frontmatter is missing or invalid
107
+ """
108
+ if not content.startswith("---"):
109
+ raise ParseError("SKILL.md must start with YAML frontmatter (---)")
110
+
111
+ parts = content.split("---", 2)
112
+ if len(parts) < 3:
113
+ raise ParseError("SKILL.md frontmatter not properly closed with ---")
114
+
115
+ frontmatter_str = parts[1]
116
+ body = parts[2].strip()
117
+
118
+ try:
119
+ metadata = load_yaml(frontmatter_str)
120
+ except Exception as e:
121
+ raise ParseError(f"Invalid YAML in frontmatter: {e}") from e
122
+
123
+ if not isinstance(metadata, dict):
124
+ raise ParseError("SKILL.md frontmatter must be a YAML mapping")
125
+
126
+ # Clean up metadata values if necessary (simple parser already handles basics)
127
+ if "metadata" in metadata and isinstance(metadata["metadata"], dict):
128
+ metadata["metadata"] = {str(k): str(v) for k, v in metadata["metadata"].items()}
129
+
130
+ return metadata, body
131
+
132
+
133
+ def read_properties(skill_dir: Path) -> SkillProperties:
134
+ """Read skill properties from SKILL.md frontmatter.
135
+
136
+ This function parses the frontmatter and returns properties.
137
+ It does NOT perform full validation. Use validate() for that.
138
+
139
+ Args:
140
+ skill_dir: Path to the skill directory
141
+
142
+ Returns:
143
+ SkillProperties with parsed metadata
144
+
145
+ Raises:
146
+ ParseError: If SKILL.md is missing or has invalid YAML
147
+ ValidationError: If required fields (name, description) are missing
148
+ """
149
+ skill_dir = Path(skill_dir)
150
+ skill_md = find_skill_md(skill_dir)
151
+
152
+ if skill_md is None:
153
+ raise ParseError(f"SKILL.md not found in {skill_dir}")
154
+
155
+ content = skill_md.read_text()
156
+ metadata, _ = parse_frontmatter(content)
157
+
158
+ if "name" not in metadata:
159
+ raise ValidationError("Missing required field in frontmatter: name")
160
+ if "description" not in metadata:
161
+ raise ValidationError("Missing required field in frontmatter: description")
162
+
163
+ name = metadata["name"]
164
+ description = metadata["description"]
165
+
166
+ if not isinstance(name, str) or not name.strip():
167
+ raise ValidationError("Field 'name' must be a non-empty string")
168
+ if not isinstance(description, str) or not description.strip():
169
+ raise ValidationError("Field 'description' must be a non-empty string")
170
+
171
+ return SkillProperties(
172
+ name=name.strip(),
173
+ description=description.strip(),
174
+ license=metadata.get("license"),
175
+ compatibility=metadata.get("compatibility"),
176
+ allowed_tools=metadata.get("allowed-tools"),
177
+ metadata=metadata.get("metadata"),
178
+ )