pythonclaw 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.
- pythonclaw/__init__.py +17 -0
- pythonclaw/__main__.py +6 -0
- pythonclaw/channels/discord_bot.py +231 -0
- pythonclaw/channels/telegram_bot.py +236 -0
- pythonclaw/config.py +190 -0
- pythonclaw/core/__init__.py +25 -0
- pythonclaw/core/agent.py +773 -0
- pythonclaw/core/compaction.py +220 -0
- pythonclaw/core/knowledge/rag.py +93 -0
- pythonclaw/core/llm/anthropic_client.py +107 -0
- pythonclaw/core/llm/base.py +26 -0
- pythonclaw/core/llm/gemini_client.py +139 -0
- pythonclaw/core/llm/openai_compatible.py +39 -0
- pythonclaw/core/llm/response.py +57 -0
- pythonclaw/core/memory/manager.py +120 -0
- pythonclaw/core/memory/storage.py +164 -0
- pythonclaw/core/persistent_agent.py +103 -0
- pythonclaw/core/retrieval/__init__.py +6 -0
- pythonclaw/core/retrieval/chunker.py +78 -0
- pythonclaw/core/retrieval/dense.py +152 -0
- pythonclaw/core/retrieval/fusion.py +51 -0
- pythonclaw/core/retrieval/reranker.py +112 -0
- pythonclaw/core/retrieval/retriever.py +166 -0
- pythonclaw/core/retrieval/sparse.py +69 -0
- pythonclaw/core/session_store.py +269 -0
- pythonclaw/core/skill_loader.py +322 -0
- pythonclaw/core/skillhub.py +290 -0
- pythonclaw/core/tools.py +622 -0
- pythonclaw/core/utils.py +64 -0
- pythonclaw/daemon.py +221 -0
- pythonclaw/init.py +61 -0
- pythonclaw/main.py +489 -0
- pythonclaw/onboard.py +290 -0
- pythonclaw/scheduler/cron.py +310 -0
- pythonclaw/scheduler/heartbeat.py +178 -0
- pythonclaw/server.py +145 -0
- pythonclaw/session_manager.py +104 -0
- pythonclaw/templates/persona/demo_persona.md +2 -0
- pythonclaw/templates/skills/communication/CATEGORY.md +4 -0
- pythonclaw/templates/skills/communication/email/SKILL.md +54 -0
- pythonclaw/templates/skills/communication/email/__pycache__/send_email.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/communication/email/send_email.py +88 -0
- pythonclaw/templates/skills/data/CATEGORY.md +4 -0
- pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +51 -0
- pythonclaw/templates/skills/data/csv_analyzer/__pycache__/analyze.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/csv_analyzer/analyze.py +138 -0
- pythonclaw/templates/skills/data/finance/SKILL.md +41 -0
- pythonclaw/templates/skills/data/finance/__pycache__/fetch_quote.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/finance/fetch_quote.py +118 -0
- pythonclaw/templates/skills/data/news/SKILL.md +39 -0
- pythonclaw/templates/skills/data/news/__pycache__/search_news.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/news/search_news.py +57 -0
- pythonclaw/templates/skills/data/pdf_reader/SKILL.md +40 -0
- pythonclaw/templates/skills/data/pdf_reader/__pycache__/read_pdf.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/pdf_reader/read_pdf.py +113 -0
- pythonclaw/templates/skills/data/scraper/SKILL.md +39 -0
- pythonclaw/templates/skills/data/scraper/__pycache__/scrape.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/scraper/scrape.py +92 -0
- pythonclaw/templates/skills/data/weather/SKILL.md +42 -0
- pythonclaw/templates/skills/data/weather/__pycache__/weather.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/weather/weather.py +142 -0
- pythonclaw/templates/skills/data/youtube/SKILL.md +43 -0
- pythonclaw/templates/skills/data/youtube/__pycache__/youtube_info.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/data/youtube/youtube_info.py +167 -0
- pythonclaw/templates/skills/dev/CATEGORY.md +4 -0
- pythonclaw/templates/skills/dev/code_runner/SKILL.md +46 -0
- pythonclaw/templates/skills/dev/code_runner/__pycache__/run_code.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/code_runner/run_code.py +117 -0
- pythonclaw/templates/skills/dev/github/SKILL.md +52 -0
- pythonclaw/templates/skills/dev/github/__pycache__/gh.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/github/gh.py +165 -0
- pythonclaw/templates/skills/dev/http_request/SKILL.md +40 -0
- pythonclaw/templates/skills/dev/http_request/__pycache__/request.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/dev/http_request/request.py +90 -0
- pythonclaw/templates/skills/google/CATEGORY.md +4 -0
- pythonclaw/templates/skills/google/workspace/SKILL.md +98 -0
- pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
- pythonclaw/templates/skills/meta/CATEGORY.md +4 -0
- pythonclaw/templates/skills/meta/skill_creator/SKILL.md +151 -0
- pythonclaw/templates/skills/system/CATEGORY.md +4 -0
- pythonclaw/templates/skills/system/change_persona/SKILL.md +41 -0
- pythonclaw/templates/skills/system/change_setting/SKILL.md +65 -0
- pythonclaw/templates/skills/system/change_setting/__pycache__/update_config.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/change_setting/update_config.py +129 -0
- pythonclaw/templates/skills/system/change_soul/SKILL.md +41 -0
- pythonclaw/templates/skills/system/onboarding/SKILL.md +63 -0
- pythonclaw/templates/skills/system/onboarding/__pycache__/write_identity.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/onboarding/write_identity.py +218 -0
- pythonclaw/templates/skills/system/random/SKILL.md +33 -0
- pythonclaw/templates/skills/system/random/__pycache__/random_util.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/random/random_util.py +45 -0
- pythonclaw/templates/skills/system/time/SKILL.md +33 -0
- pythonclaw/templates/skills/system/time/__pycache__/time_util.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/system/time/time_util.py +81 -0
- pythonclaw/templates/skills/text/CATEGORY.md +4 -0
- pythonclaw/templates/skills/text/translator/SKILL.md +47 -0
- pythonclaw/templates/skills/text/translator/__pycache__/translate.cpython-311.pyc +0 -0
- pythonclaw/templates/skills/text/translator/translate.py +66 -0
- pythonclaw/templates/skills/web/CATEGORY.md +4 -0
- pythonclaw/templates/skills/web/tavily/SKILL.md +61 -0
- pythonclaw/templates/soul/SOUL.md +54 -0
- pythonclaw/web/__init__.py +1 -0
- pythonclaw/web/app.py +585 -0
- pythonclaw/web/static/favicon.png +0 -0
- pythonclaw/web/static/index.html +1318 -0
- pythonclaw/web/static/logo.png +0 -0
- pythonclaw-0.2.0.dist-info/METADATA +410 -0
- pythonclaw-0.2.0.dist-info/RECORD +112 -0
- pythonclaw-0.2.0.dist-info/WHEEL +5 -0
- pythonclaw-0.2.0.dist-info/entry_points.txt +2 -0
- pythonclaw-0.2.0.dist-info/licenses/LICENSE +21 -0
- pythonclaw-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Skill discovery and loading for PythonClaw.
|
|
3
|
+
|
|
4
|
+
Three-tier progressive disclosure
|
|
5
|
+
----------------------------------
|
|
6
|
+
Inspired by Claude's Agent Skills architecture, skills are loaded on demand
|
|
7
|
+
in three tiers to minimise context-window usage:
|
|
8
|
+
|
|
9
|
+
Level 1 — Metadata (always loaded at startup, ~100 tokens per skill)
|
|
10
|
+
YAML frontmatter from every SKILL.md (``name`` + ``description``).
|
|
11
|
+
Injected into the system prompt as a "skill catalog" so the LLM
|
|
12
|
+
knows what capabilities exist and when to activate them.
|
|
13
|
+
|
|
14
|
+
Level 2 — Core instructions (loaded when the LLM triggers ``use_skill``)
|
|
15
|
+
The body of SKILL.md: workflows, rules, step-by-step guidance.
|
|
16
|
+
|
|
17
|
+
Level 3 — Extended resources (loaded as needed via ``read_file`` / ``run_command``)
|
|
18
|
+
Scripts, schemas, reference docs, templates, CSV data — anything
|
|
19
|
+
bundled in the skill folder that SKILL.md references.
|
|
20
|
+
|
|
21
|
+
SKILL.md format (Claude-compatible)
|
|
22
|
+
--------------------------------------
|
|
23
|
+
---
|
|
24
|
+
name: calculator
|
|
25
|
+
description: >
|
|
26
|
+
Performs basic arithmetic. Use when the user asks to calculate
|
|
27
|
+
math expressions, additions, multiplications, etc.
|
|
28
|
+
---
|
|
29
|
+
# Calculator
|
|
30
|
+
|
|
31
|
+
## Instructions
|
|
32
|
+
Run `python {skill_path}/calc.py "expression"` ...
|
|
33
|
+
|
|
34
|
+
## Resources
|
|
35
|
+
- `calc.py` — arithmetic script
|
|
36
|
+
|
|
37
|
+
Directory layout (both flat and categorised)
|
|
38
|
+
----------------------------------------------
|
|
39
|
+
<skills_dir>/
|
|
40
|
+
<skill_name>/ — flat (Claude-style)
|
|
41
|
+
SKILL.md
|
|
42
|
+
*.py / *.sh
|
|
43
|
+
|
|
44
|
+
<skills_dir>/
|
|
45
|
+
<category>/ — categorised (PythonClaw-style)
|
|
46
|
+
CATEGORY.md — optional category description
|
|
47
|
+
<skill_name>/
|
|
48
|
+
SKILL.md
|
|
49
|
+
*.py / *.sh
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import logging
|
|
55
|
+
import os
|
|
56
|
+
|
|
57
|
+
from .utils import parse_frontmatter
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Data classes ─────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
class SkillMetadata:
|
|
65
|
+
"""Level 1 — lightweight metadata for a single skill."""
|
|
66
|
+
|
|
67
|
+
__slots__ = ("name", "description", "path", "category")
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
name: str,
|
|
72
|
+
description: str,
|
|
73
|
+
path: str,
|
|
74
|
+
category: str = "",
|
|
75
|
+
) -> None:
|
|
76
|
+
self.name = name
|
|
77
|
+
self.description = description
|
|
78
|
+
self.path = path
|
|
79
|
+
self.category = category
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class Skill:
|
|
83
|
+
"""Level 2 — a fully loaded skill including its instruction text."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, metadata: SkillMetadata, instructions: str) -> None:
|
|
86
|
+
self.metadata = metadata
|
|
87
|
+
self.instructions = instructions
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def name(self) -> str:
|
|
91
|
+
return self.metadata.name
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def description(self) -> str:
|
|
95
|
+
return self.metadata.description
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── Registry ─────────────────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
class SkillRegistry:
|
|
101
|
+
"""
|
|
102
|
+
Scans skill directories and provides three-tier progressive loading.
|
|
103
|
+
|
|
104
|
+
Supports both flat layouts (``skills/calculator/SKILL.md``) and
|
|
105
|
+
categorised layouts (``skills/math/calculator/SKILL.md``).
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def __init__(self, skills_dirs: list[str] | None = None) -> None:
|
|
109
|
+
self.skills_dirs: list[str] = list(skills_dirs) if skills_dirs else []
|
|
110
|
+
self._cache: list[SkillMetadata] | None = None
|
|
111
|
+
|
|
112
|
+
def invalidate(self) -> None:
|
|
113
|
+
"""Clear the discovery cache so new skills are picked up on next call."""
|
|
114
|
+
self._cache = None
|
|
115
|
+
|
|
116
|
+
# ── Level 1: Metadata discovery ──────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def discover(self) -> list[SkillMetadata]:
|
|
119
|
+
"""
|
|
120
|
+
Scan all configured directories and return metadata for every skill.
|
|
121
|
+
|
|
122
|
+
Results are cached after the first call. This is the **Level 1**
|
|
123
|
+
operation — only YAML frontmatter is read (name + description).
|
|
124
|
+
"""
|
|
125
|
+
if self._cache is not None:
|
|
126
|
+
return self._cache
|
|
127
|
+
|
|
128
|
+
skills: list[SkillMetadata] = []
|
|
129
|
+
seen_names: set[str] = set()
|
|
130
|
+
|
|
131
|
+
for s_dir in self.skills_dirs:
|
|
132
|
+
if not os.path.isdir(s_dir):
|
|
133
|
+
continue
|
|
134
|
+
self._scan_dir(s_dir, skills, seen_names)
|
|
135
|
+
|
|
136
|
+
self._cache = skills
|
|
137
|
+
return skills
|
|
138
|
+
|
|
139
|
+
def _scan_dir(
|
|
140
|
+
self,
|
|
141
|
+
base_dir: str,
|
|
142
|
+
out: list[SkillMetadata],
|
|
143
|
+
seen: set[str],
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Recursively find SKILL.md files up to 2 levels deep."""
|
|
146
|
+
for entry in sorted(os.listdir(base_dir)):
|
|
147
|
+
if entry.startswith(("__", ".")):
|
|
148
|
+
continue
|
|
149
|
+
entry_path = os.path.join(base_dir, entry)
|
|
150
|
+
if not os.path.isdir(entry_path):
|
|
151
|
+
continue
|
|
152
|
+
|
|
153
|
+
skill_md = os.path.join(entry_path, "SKILL.md")
|
|
154
|
+
if os.path.isfile(skill_md):
|
|
155
|
+
# Flat layout: skills/<skill>/SKILL.md
|
|
156
|
+
meta = self._read_metadata(skill_md, entry_path, category="")
|
|
157
|
+
if meta and meta.name not in seen:
|
|
158
|
+
out.append(meta)
|
|
159
|
+
seen.add(meta.name)
|
|
160
|
+
else:
|
|
161
|
+
# Categorised layout: skills/<category>/<skill>/SKILL.md
|
|
162
|
+
category_name = entry
|
|
163
|
+
for sub_entry in sorted(os.listdir(entry_path)):
|
|
164
|
+
if sub_entry.startswith(("__", ".")):
|
|
165
|
+
continue
|
|
166
|
+
sub_path = os.path.join(entry_path, sub_entry)
|
|
167
|
+
sub_md = os.path.join(sub_path, "SKILL.md")
|
|
168
|
+
if os.path.isdir(sub_path) and os.path.isfile(sub_md):
|
|
169
|
+
meta = self._read_metadata(
|
|
170
|
+
sub_md, sub_path, category=category_name
|
|
171
|
+
)
|
|
172
|
+
if meta and meta.name not in seen:
|
|
173
|
+
out.append(meta)
|
|
174
|
+
seen.add(meta.name)
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _read_metadata(
|
|
178
|
+
md_path: str,
|
|
179
|
+
skill_dir: str,
|
|
180
|
+
category: str,
|
|
181
|
+
) -> SkillMetadata | None:
|
|
182
|
+
try:
|
|
183
|
+
with open(md_path, "r", encoding="utf-8") as f:
|
|
184
|
+
content = f.read()
|
|
185
|
+
meta, _ = parse_frontmatter(content)
|
|
186
|
+
name = meta.get("name", os.path.basename(skill_dir))
|
|
187
|
+
description = meta.get("description", "No description.")
|
|
188
|
+
return SkillMetadata(
|
|
189
|
+
name=name,
|
|
190
|
+
description=description,
|
|
191
|
+
path=os.path.abspath(skill_dir),
|
|
192
|
+
category=category,
|
|
193
|
+
)
|
|
194
|
+
except OSError as exc:
|
|
195
|
+
logger.warning("Could not read skill at '%s': %s", md_path, exc)
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
# ── Level 2: Full instruction loading ────────────────────────────────
|
|
199
|
+
|
|
200
|
+
def load_skill(self, name: str) -> Skill | None:
|
|
201
|
+
"""
|
|
202
|
+
Load the full SKILL.md body for a skill by name (**Level 2**).
|
|
203
|
+
|
|
204
|
+
The ``{skill_path}`` placeholder in the instruction text is
|
|
205
|
+
replaced with the skill's absolute directory path.
|
|
206
|
+
"""
|
|
207
|
+
for meta in self.discover():
|
|
208
|
+
if meta.name != name:
|
|
209
|
+
continue
|
|
210
|
+
md_path = os.path.join(meta.path, "SKILL.md")
|
|
211
|
+
try:
|
|
212
|
+
with open(md_path, "r", encoding="utf-8") as f:
|
|
213
|
+
content = f.read()
|
|
214
|
+
_, instructions = parse_frontmatter(content)
|
|
215
|
+
instructions = instructions.replace("{skill_path}", meta.path)
|
|
216
|
+
return Skill(metadata=meta, instructions=instructions)
|
|
217
|
+
except OSError as exc:
|
|
218
|
+
logger.error("Error loading skill '%s': %s", name, exc)
|
|
219
|
+
return None
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
# ── Level 3: Resource discovery ──────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
def list_resources(self, name: str) -> list[str]:
|
|
225
|
+
"""
|
|
226
|
+
List bundled resource files for a skill (**Level 3** discovery).
|
|
227
|
+
|
|
228
|
+
Returns relative filenames (e.g. ``["calc.py", "REFERENCE.md"]``)
|
|
229
|
+
excluding SKILL.md itself.
|
|
230
|
+
"""
|
|
231
|
+
for meta in self.discover():
|
|
232
|
+
if meta.name != name:
|
|
233
|
+
continue
|
|
234
|
+
try:
|
|
235
|
+
return sorted(
|
|
236
|
+
f
|
|
237
|
+
for f in os.listdir(meta.path)
|
|
238
|
+
if f != "SKILL.md"
|
|
239
|
+
and not f.startswith(("__", "."))
|
|
240
|
+
and os.path.isfile(os.path.join(meta.path, f))
|
|
241
|
+
)
|
|
242
|
+
except OSError:
|
|
243
|
+
return []
|
|
244
|
+
return []
|
|
245
|
+
|
|
246
|
+
def get_resource_path(self, skill_name: str, resource: str) -> str | None:
|
|
247
|
+
"""Return the absolute path to a resource file inside a skill folder."""
|
|
248
|
+
for meta in self.discover():
|
|
249
|
+
if meta.name != skill_name:
|
|
250
|
+
continue
|
|
251
|
+
full = os.path.join(meta.path, resource)
|
|
252
|
+
if os.path.isfile(full):
|
|
253
|
+
return full
|
|
254
|
+
return None
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
# ── Catalog builder (for system prompt injection) ────────────────────
|
|
258
|
+
|
|
259
|
+
def build_catalog(self) -> str:
|
|
260
|
+
"""
|
|
261
|
+
Build a compact skill catalog string for the system prompt.
|
|
262
|
+
|
|
263
|
+
Groups skills by category and formats them as a bulleted list.
|
|
264
|
+
This is what the LLM sees at **Level 1** — all available skills
|
|
265
|
+
and their descriptions, so it can decide when to trigger them.
|
|
266
|
+
"""
|
|
267
|
+
skills = self.discover()
|
|
268
|
+
if not skills:
|
|
269
|
+
return "(no skills installed)"
|
|
270
|
+
|
|
271
|
+
# Group by category
|
|
272
|
+
groups: dict[str, list[SkillMetadata]] = {}
|
|
273
|
+
for s in skills:
|
|
274
|
+
groups.setdefault(s.category or "general", []).append(s)
|
|
275
|
+
|
|
276
|
+
lines: list[str] = []
|
|
277
|
+
for cat in sorted(groups):
|
|
278
|
+
if cat != "general":
|
|
279
|
+
lines.append(f"\n **{cat}**")
|
|
280
|
+
for s in groups[cat]:
|
|
281
|
+
lines.append(f" - `{s.name}`: {s.description}")
|
|
282
|
+
|
|
283
|
+
return "\n".join(lines)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ── Module-level convenience functions ───────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
def load_skill_by_name(
|
|
289
|
+
skill_name: str,
|
|
290
|
+
skills_dirs: list[str] | None = None,
|
|
291
|
+
) -> Skill | None:
|
|
292
|
+
"""Load a skill by name (Level 2)."""
|
|
293
|
+
return SkillRegistry(skills_dirs).load_skill(skill_name)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def search_skills(
|
|
297
|
+
query: str,
|
|
298
|
+
skills_dirs: list[str] | None = None,
|
|
299
|
+
) -> list[dict]:
|
|
300
|
+
"""Search skills by keyword match in name or description."""
|
|
301
|
+
q = query.lower()
|
|
302
|
+
return [
|
|
303
|
+
{"name": s.name, "description": s.description, "category": s.category}
|
|
304
|
+
for s in SkillRegistry(skills_dirs).discover()
|
|
305
|
+
if q in s.name.lower() or q in s.description.lower()
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def list_skills_in_category(
|
|
310
|
+
category: str,
|
|
311
|
+
skills_dirs: list[str] | None = None,
|
|
312
|
+
) -> list[dict]:
|
|
313
|
+
"""List skills in a specific category (backward compat)."""
|
|
314
|
+
return [
|
|
315
|
+
{
|
|
316
|
+
"name": s.name,
|
|
317
|
+
"description": s.description,
|
|
318
|
+
"path_name": os.path.basename(s.path),
|
|
319
|
+
}
|
|
320
|
+
for s in SkillRegistry(skills_dirs).discover()
|
|
321
|
+
if s.category == category
|
|
322
|
+
]
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SkillHub marketplace client for PythonClaw.
|
|
3
|
+
|
|
4
|
+
Integrates with https://www.skillhub.club/ API to search, browse,
|
|
5
|
+
and install community skills directly into the local skill directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import ssl
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
API_BASE = "https://www.skillhub.club/api/v1"
|
|
22
|
+
SKILL_PAGE_BASE = "https://www.skillhub.club/skills"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_ssl_ctx() -> ssl.SSLContext:
|
|
26
|
+
"""Build an SSL context; use unverified fallback for macOS cert issues."""
|
|
27
|
+
try:
|
|
28
|
+
import certifi
|
|
29
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
30
|
+
except ImportError:
|
|
31
|
+
pass
|
|
32
|
+
ctx = ssl.create_default_context()
|
|
33
|
+
ctx.check_hostname = False
|
|
34
|
+
ctx.verify_mode = ssl.CERT_NONE
|
|
35
|
+
return ctx
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _api_key() -> str:
|
|
39
|
+
"""Read SkillHub API key from config or environment."""
|
|
40
|
+
from .. import config
|
|
41
|
+
return config.get_str("skillhub", "apiKey", env="SKILLHUB_API_KEY")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _api_request(
|
|
45
|
+
method: str,
|
|
46
|
+
path: str,
|
|
47
|
+
*,
|
|
48
|
+
body: dict | None = None,
|
|
49
|
+
params: dict | None = None,
|
|
50
|
+
) -> dict:
|
|
51
|
+
"""Make an authenticated request to the SkillHub API."""
|
|
52
|
+
api_key = _api_key()
|
|
53
|
+
|
|
54
|
+
url = f"{API_BASE}{path}"
|
|
55
|
+
if params:
|
|
56
|
+
qs = "&".join(f"{k}={urllib.request.quote(str(v))}" for k, v in params.items() if v is not None)
|
|
57
|
+
if qs:
|
|
58
|
+
url = f"{url}?{qs}"
|
|
59
|
+
|
|
60
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
61
|
+
if api_key:
|
|
62
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
63
|
+
|
|
64
|
+
data = json.dumps(body).encode("utf-8") if body else None
|
|
65
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(req, timeout=15, context=_get_ssl_ctx()) as resp:
|
|
69
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
70
|
+
except urllib.error.HTTPError as exc:
|
|
71
|
+
err_body = exc.read().decode("utf-8", errors="replace")
|
|
72
|
+
logger.warning("SkillHub API error %s: %s", exc.code, err_body)
|
|
73
|
+
raise RuntimeError(f"SkillHub API error ({exc.code}): {err_body}") from exc
|
|
74
|
+
except Exception as exc:
|
|
75
|
+
raise RuntimeError(f"SkillHub request failed: {exc}") from exc
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def search(query: str, *, limit: int = 10, category: str | None = None) -> list[dict]:
|
|
79
|
+
"""Search SkillHub for skills matching a query."""
|
|
80
|
+
body: dict[str, Any] = {"query": query, "limit": limit, "method": "hybrid"}
|
|
81
|
+
if category:
|
|
82
|
+
body["category"] = category
|
|
83
|
+
result = _api_request("POST", "/skills/search", body=body)
|
|
84
|
+
return result.get("results", result.get("skills", []))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def browse(*, limit: int = 20, sort: str = "score", category: str | None = None) -> list[dict]:
|
|
88
|
+
"""Browse the SkillHub catalog."""
|
|
89
|
+
params: dict[str, Any] = {"limit": limit, "sort": sort}
|
|
90
|
+
if category:
|
|
91
|
+
params["category"] = category
|
|
92
|
+
result = _api_request("GET", "/skills/catalog", params=params)
|
|
93
|
+
return result.get("results", result.get("skills", []))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def get_skill_detail(skill_id: str) -> dict | None:
|
|
97
|
+
"""Fetch full detail for a skill, including SKILL.md content.
|
|
98
|
+
|
|
99
|
+
Tries the API first, falls back to scraping the skill page.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
result = _api_request("GET", f"/skills/{skill_id}")
|
|
103
|
+
skill = result.get("skill", result)
|
|
104
|
+
return {
|
|
105
|
+
"id": skill.get("slug", skill.get("id", skill_id)),
|
|
106
|
+
"name": skill.get("name", ""),
|
|
107
|
+
"description": skill.get("description", ""),
|
|
108
|
+
"skill_md": skill.get("skill_md_raw", skill.get("skill_md", "")),
|
|
109
|
+
"category": skill.get("category", ""),
|
|
110
|
+
"author": skill.get("author", ""),
|
|
111
|
+
"score": skill.get("composite_score", skill.get("simple_score", "")),
|
|
112
|
+
"stars": skill.get("github_stars", ""),
|
|
113
|
+
"source_url": skill.get("repo_url", f"{SKILL_PAGE_BASE}/{skill_id}"),
|
|
114
|
+
}
|
|
115
|
+
except Exception:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
return _scrape_skill_page(skill_id)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _scrape_skill_page(skill_id: str) -> dict | None:
|
|
122
|
+
"""Fallback: scrape the skill page for SKILL.md content."""
|
|
123
|
+
url = f"{SKILL_PAGE_BASE}/{skill_id}"
|
|
124
|
+
req = urllib.request.Request(url, headers={"User-Agent": "PythonClaw/1.0"})
|
|
125
|
+
try:
|
|
126
|
+
with urllib.request.urlopen(req, timeout=15, context=_get_ssl_ctx()) as resp:
|
|
127
|
+
html = resp.read().decode("utf-8")
|
|
128
|
+
except Exception as exc:
|
|
129
|
+
logger.warning("Failed to fetch skill page %s: %s", url, exc)
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
skill_md = _extract_skill_md(html)
|
|
133
|
+
if not skill_md:
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
name_match = re.search(r"<h1[^>]*>(.*?)</h1>", html, re.DOTALL)
|
|
137
|
+
name = name_match.group(1).strip() if name_match else skill_id.split("-")[-1]
|
|
138
|
+
name = re.sub(r"<[^>]+>", "", name).strip()
|
|
139
|
+
|
|
140
|
+
desc = ""
|
|
141
|
+
fm_match = re.search(r"^description:\s*>?\s*(.+?)$", skill_md, re.MULTILINE)
|
|
142
|
+
if fm_match:
|
|
143
|
+
desc = fm_match.group(1).strip()
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"id": skill_id,
|
|
147
|
+
"name": name,
|
|
148
|
+
"description": desc,
|
|
149
|
+
"skill_md": skill_md,
|
|
150
|
+
"source_url": url,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _extract_skill_md(html: str) -> str | None:
|
|
155
|
+
"""Extract SKILL.md content from a skill page HTML."""
|
|
156
|
+
# SkillHub renders skill content inside a prose-skill container.
|
|
157
|
+
# The frontmatter appears as <hr/> then <h2>name: ... description: ...
|
|
158
|
+
# followed by the instruction body.
|
|
159
|
+
|
|
160
|
+
m = re.search(
|
|
161
|
+
r'class="[^"]*prose-skill[^"]*"[^>]*>(.*?)(?:</div>\s*<div|$)',
|
|
162
|
+
html,
|
|
163
|
+
re.DOTALL,
|
|
164
|
+
)
|
|
165
|
+
if not m:
|
|
166
|
+
m = re.search(r'<hr/>\s*<h2>name:\s*\S+', html)
|
|
167
|
+
if m:
|
|
168
|
+
start = m.start()
|
|
169
|
+
end_markers = ['Content curated', 'User Rating', 'USER RATING', 'Grade ']
|
|
170
|
+
end = len(html)
|
|
171
|
+
for marker in end_markers:
|
|
172
|
+
pos = html.find(marker, start)
|
|
173
|
+
if pos != -1 and pos < end:
|
|
174
|
+
end = pos
|
|
175
|
+
raw = html[start:end]
|
|
176
|
+
else:
|
|
177
|
+
return None
|
|
178
|
+
else:
|
|
179
|
+
raw = m.group(1)
|
|
180
|
+
|
|
181
|
+
raw = re.sub(r"<[^>]+>", "\n", raw)
|
|
182
|
+
raw = raw.replace("<", "<").replace(">", ">").replace("&", "&")
|
|
183
|
+
raw = raw.replace(""", '"').replace("'", "'")
|
|
184
|
+
|
|
185
|
+
lines = []
|
|
186
|
+
for line in raw.split("\n"):
|
|
187
|
+
stripped = line.strip()
|
|
188
|
+
if stripped:
|
|
189
|
+
lines.append(stripped)
|
|
190
|
+
|
|
191
|
+
text = "\n".join(lines)
|
|
192
|
+
|
|
193
|
+
# Reconstruct frontmatter
|
|
194
|
+
fm_match = re.search(r'name:\s*(\S+)\s+description:\s*(.+?)(?=\n\w|\n#|\Z)', text, re.DOTALL)
|
|
195
|
+
if fm_match:
|
|
196
|
+
name = fm_match.group(1).strip()
|
|
197
|
+
desc = fm_match.group(2).strip()
|
|
198
|
+
body_start = fm_match.end()
|
|
199
|
+
body = text[body_start:].strip()
|
|
200
|
+
return f"---\nname: {name}\ndescription: >\n {desc}\n---\n\n{body}"
|
|
201
|
+
|
|
202
|
+
return text if len(text) > 50 else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def install_skill(
|
|
206
|
+
skill_id: str,
|
|
207
|
+
*,
|
|
208
|
+
target_dir: str | None = None,
|
|
209
|
+
skill_md_override: str | None = None,
|
|
210
|
+
) -> str:
|
|
211
|
+
"""Download and install a skill from SkillHub into the local skills directory.
|
|
212
|
+
|
|
213
|
+
Returns the path to the installed skill directory.
|
|
214
|
+
"""
|
|
215
|
+
if target_dir is None:
|
|
216
|
+
target_dir = os.path.join("context", "skills")
|
|
217
|
+
|
|
218
|
+
detail = None
|
|
219
|
+
if not skill_md_override:
|
|
220
|
+
detail = get_skill_detail(skill_id)
|
|
221
|
+
if not detail:
|
|
222
|
+
raise RuntimeError(f"Could not fetch skill '{skill_id}' from SkillHub.")
|
|
223
|
+
|
|
224
|
+
skill_md = skill_md_override or detail.get("skill_md", "")
|
|
225
|
+
if not skill_md:
|
|
226
|
+
raise RuntimeError(f"No SKILL.md content found for '{skill_id}'.")
|
|
227
|
+
|
|
228
|
+
skill_name = _derive_skill_name(skill_id, skill_md, detail)
|
|
229
|
+
safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", skill_name).strip("_")
|
|
230
|
+
if not safe_name:
|
|
231
|
+
safe_name = "imported_skill"
|
|
232
|
+
|
|
233
|
+
category = "skillhub"
|
|
234
|
+
skill_dir = os.path.join(target_dir, category, safe_name)
|
|
235
|
+
os.makedirs(skill_dir, exist_ok=True)
|
|
236
|
+
|
|
237
|
+
md_path = os.path.join(skill_dir, "SKILL.md")
|
|
238
|
+
|
|
239
|
+
if not skill_md.startswith("---"):
|
|
240
|
+
skill_md = f"---\nname: {safe_name}\ndescription: Imported from SkillHub ({skill_id})\n---\n\n{skill_md}"
|
|
241
|
+
|
|
242
|
+
with open(md_path, "w", encoding="utf-8") as f:
|
|
243
|
+
f.write(skill_md + "\n")
|
|
244
|
+
|
|
245
|
+
source_url = detail.get("source_url", f"{SKILL_PAGE_BASE}/{skill_id}") if detail else f"{SKILL_PAGE_BASE}/{skill_id}"
|
|
246
|
+
meta_path = os.path.join(skill_dir, ".skillhub.json")
|
|
247
|
+
with open(meta_path, "w", encoding="utf-8") as f:
|
|
248
|
+
json.dump({"id": skill_id, "source": source_url, "installed_by": "pythonclaw"}, f, indent=2)
|
|
249
|
+
|
|
250
|
+
return skill_dir
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _derive_skill_name(skill_id: str, skill_md: str, detail: dict | None) -> str:
|
|
254
|
+
"""Extract a reasonable skill name from available data."""
|
|
255
|
+
name_match = re.search(r"^name:\s*(.+)$", skill_md, re.MULTILINE)
|
|
256
|
+
if name_match:
|
|
257
|
+
return name_match.group(1).strip()
|
|
258
|
+
if detail and detail.get("name"):
|
|
259
|
+
return detail["name"]
|
|
260
|
+
parts = skill_id.rsplit("-", 1)
|
|
261
|
+
return parts[-1] if parts else skill_id
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def format_search_results(results: list[dict]) -> str:
|
|
265
|
+
"""Format search results for CLI display."""
|
|
266
|
+
if not results:
|
|
267
|
+
return "No skills found."
|
|
268
|
+
|
|
269
|
+
lines = []
|
|
270
|
+
for i, r in enumerate(results, 1):
|
|
271
|
+
name = r.get("name", r.get("title", "???"))
|
|
272
|
+
desc = r.get("description", "")[:80]
|
|
273
|
+
sid = r.get("id", r.get("slug", ""))
|
|
274
|
+
score = r.get("score", r.get("ai_score", ""))
|
|
275
|
+
stars = r.get("stars", "")
|
|
276
|
+
|
|
277
|
+
header = f" {i}. {name}"
|
|
278
|
+
if score:
|
|
279
|
+
header += f" (score: {score})"
|
|
280
|
+
if stars:
|
|
281
|
+
header += f" ★{stars}"
|
|
282
|
+
|
|
283
|
+
lines.append(header)
|
|
284
|
+
if desc:
|
|
285
|
+
lines.append(f" {desc}")
|
|
286
|
+
if sid:
|
|
287
|
+
lines.append(f" ID: {sid}")
|
|
288
|
+
lines.append("")
|
|
289
|
+
|
|
290
|
+
return "\n".join(lines)
|