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.
Files changed (112) hide show
  1. pythonclaw/__init__.py +17 -0
  2. pythonclaw/__main__.py +6 -0
  3. pythonclaw/channels/discord_bot.py +231 -0
  4. pythonclaw/channels/telegram_bot.py +236 -0
  5. pythonclaw/config.py +190 -0
  6. pythonclaw/core/__init__.py +25 -0
  7. pythonclaw/core/agent.py +773 -0
  8. pythonclaw/core/compaction.py +220 -0
  9. pythonclaw/core/knowledge/rag.py +93 -0
  10. pythonclaw/core/llm/anthropic_client.py +107 -0
  11. pythonclaw/core/llm/base.py +26 -0
  12. pythonclaw/core/llm/gemini_client.py +139 -0
  13. pythonclaw/core/llm/openai_compatible.py +39 -0
  14. pythonclaw/core/llm/response.py +57 -0
  15. pythonclaw/core/memory/manager.py +120 -0
  16. pythonclaw/core/memory/storage.py +164 -0
  17. pythonclaw/core/persistent_agent.py +103 -0
  18. pythonclaw/core/retrieval/__init__.py +6 -0
  19. pythonclaw/core/retrieval/chunker.py +78 -0
  20. pythonclaw/core/retrieval/dense.py +152 -0
  21. pythonclaw/core/retrieval/fusion.py +51 -0
  22. pythonclaw/core/retrieval/reranker.py +112 -0
  23. pythonclaw/core/retrieval/retriever.py +166 -0
  24. pythonclaw/core/retrieval/sparse.py +69 -0
  25. pythonclaw/core/session_store.py +269 -0
  26. pythonclaw/core/skill_loader.py +322 -0
  27. pythonclaw/core/skillhub.py +290 -0
  28. pythonclaw/core/tools.py +622 -0
  29. pythonclaw/core/utils.py +64 -0
  30. pythonclaw/daemon.py +221 -0
  31. pythonclaw/init.py +61 -0
  32. pythonclaw/main.py +489 -0
  33. pythonclaw/onboard.py +290 -0
  34. pythonclaw/scheduler/cron.py +310 -0
  35. pythonclaw/scheduler/heartbeat.py +178 -0
  36. pythonclaw/server.py +145 -0
  37. pythonclaw/session_manager.py +104 -0
  38. pythonclaw/templates/persona/demo_persona.md +2 -0
  39. pythonclaw/templates/skills/communication/CATEGORY.md +4 -0
  40. pythonclaw/templates/skills/communication/email/SKILL.md +54 -0
  41. pythonclaw/templates/skills/communication/email/__pycache__/send_email.cpython-311.pyc +0 -0
  42. pythonclaw/templates/skills/communication/email/send_email.py +88 -0
  43. pythonclaw/templates/skills/data/CATEGORY.md +4 -0
  44. pythonclaw/templates/skills/data/csv_analyzer/SKILL.md +51 -0
  45. pythonclaw/templates/skills/data/csv_analyzer/__pycache__/analyze.cpython-311.pyc +0 -0
  46. pythonclaw/templates/skills/data/csv_analyzer/analyze.py +138 -0
  47. pythonclaw/templates/skills/data/finance/SKILL.md +41 -0
  48. pythonclaw/templates/skills/data/finance/__pycache__/fetch_quote.cpython-311.pyc +0 -0
  49. pythonclaw/templates/skills/data/finance/fetch_quote.py +118 -0
  50. pythonclaw/templates/skills/data/news/SKILL.md +39 -0
  51. pythonclaw/templates/skills/data/news/__pycache__/search_news.cpython-311.pyc +0 -0
  52. pythonclaw/templates/skills/data/news/search_news.py +57 -0
  53. pythonclaw/templates/skills/data/pdf_reader/SKILL.md +40 -0
  54. pythonclaw/templates/skills/data/pdf_reader/__pycache__/read_pdf.cpython-311.pyc +0 -0
  55. pythonclaw/templates/skills/data/pdf_reader/read_pdf.py +113 -0
  56. pythonclaw/templates/skills/data/scraper/SKILL.md +39 -0
  57. pythonclaw/templates/skills/data/scraper/__pycache__/scrape.cpython-311.pyc +0 -0
  58. pythonclaw/templates/skills/data/scraper/scrape.py +92 -0
  59. pythonclaw/templates/skills/data/weather/SKILL.md +42 -0
  60. pythonclaw/templates/skills/data/weather/__pycache__/weather.cpython-311.pyc +0 -0
  61. pythonclaw/templates/skills/data/weather/weather.py +142 -0
  62. pythonclaw/templates/skills/data/youtube/SKILL.md +43 -0
  63. pythonclaw/templates/skills/data/youtube/__pycache__/youtube_info.cpython-311.pyc +0 -0
  64. pythonclaw/templates/skills/data/youtube/youtube_info.py +167 -0
  65. pythonclaw/templates/skills/dev/CATEGORY.md +4 -0
  66. pythonclaw/templates/skills/dev/code_runner/SKILL.md +46 -0
  67. pythonclaw/templates/skills/dev/code_runner/__pycache__/run_code.cpython-311.pyc +0 -0
  68. pythonclaw/templates/skills/dev/code_runner/run_code.py +117 -0
  69. pythonclaw/templates/skills/dev/github/SKILL.md +52 -0
  70. pythonclaw/templates/skills/dev/github/__pycache__/gh.cpython-311.pyc +0 -0
  71. pythonclaw/templates/skills/dev/github/gh.py +165 -0
  72. pythonclaw/templates/skills/dev/http_request/SKILL.md +40 -0
  73. pythonclaw/templates/skills/dev/http_request/__pycache__/request.cpython-311.pyc +0 -0
  74. pythonclaw/templates/skills/dev/http_request/request.py +90 -0
  75. pythonclaw/templates/skills/google/CATEGORY.md +4 -0
  76. pythonclaw/templates/skills/google/workspace/SKILL.md +98 -0
  77. pythonclaw/templates/skills/google/workspace/check_setup.sh +52 -0
  78. pythonclaw/templates/skills/meta/CATEGORY.md +4 -0
  79. pythonclaw/templates/skills/meta/skill_creator/SKILL.md +151 -0
  80. pythonclaw/templates/skills/system/CATEGORY.md +4 -0
  81. pythonclaw/templates/skills/system/change_persona/SKILL.md +41 -0
  82. pythonclaw/templates/skills/system/change_setting/SKILL.md +65 -0
  83. pythonclaw/templates/skills/system/change_setting/__pycache__/update_config.cpython-311.pyc +0 -0
  84. pythonclaw/templates/skills/system/change_setting/update_config.py +129 -0
  85. pythonclaw/templates/skills/system/change_soul/SKILL.md +41 -0
  86. pythonclaw/templates/skills/system/onboarding/SKILL.md +63 -0
  87. pythonclaw/templates/skills/system/onboarding/__pycache__/write_identity.cpython-311.pyc +0 -0
  88. pythonclaw/templates/skills/system/onboarding/write_identity.py +218 -0
  89. pythonclaw/templates/skills/system/random/SKILL.md +33 -0
  90. pythonclaw/templates/skills/system/random/__pycache__/random_util.cpython-311.pyc +0 -0
  91. pythonclaw/templates/skills/system/random/random_util.py +45 -0
  92. pythonclaw/templates/skills/system/time/SKILL.md +33 -0
  93. pythonclaw/templates/skills/system/time/__pycache__/time_util.cpython-311.pyc +0 -0
  94. pythonclaw/templates/skills/system/time/time_util.py +81 -0
  95. pythonclaw/templates/skills/text/CATEGORY.md +4 -0
  96. pythonclaw/templates/skills/text/translator/SKILL.md +47 -0
  97. pythonclaw/templates/skills/text/translator/__pycache__/translate.cpython-311.pyc +0 -0
  98. pythonclaw/templates/skills/text/translator/translate.py +66 -0
  99. pythonclaw/templates/skills/web/CATEGORY.md +4 -0
  100. pythonclaw/templates/skills/web/tavily/SKILL.md +61 -0
  101. pythonclaw/templates/soul/SOUL.md +54 -0
  102. pythonclaw/web/__init__.py +1 -0
  103. pythonclaw/web/app.py +585 -0
  104. pythonclaw/web/static/favicon.png +0 -0
  105. pythonclaw/web/static/index.html +1318 -0
  106. pythonclaw/web/static/logo.png +0 -0
  107. pythonclaw-0.2.0.dist-info/METADATA +410 -0
  108. pythonclaw-0.2.0.dist-info/RECORD +112 -0
  109. pythonclaw-0.2.0.dist-info/WHEEL +5 -0
  110. pythonclaw-0.2.0.dist-info/entry_points.txt +2 -0
  111. pythonclaw-0.2.0.dist-info/licenses/LICENSE +21 -0
  112. 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("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")
183
+ raw = raw.replace("&quot;", '"').replace("&#39;", "'")
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)