Coverage for integrations / skills / registry.py: 0.0%

222 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1""" 

2Skill Registry - follows ServiceToolRegistry / MCPToolRegistry pattern. 

3 

4Ingests agent skills (Claude Code SKILL.md, plain Markdown, JSON) into the 

5Hevolve pipeline so any HART agent can execute them during thought experiments. 

6 

7Design: 

8- SkillInfo describes a skill's metadata, instructions, and capabilities 

9- SkillRegistry manages discovery, storage, and LangChain tool generation 

10- Global singleton: skill_registry (mirrors service_tool_registry) 

11- Persists to skills.json for startup reload 

12""" 

13 

14import json 

15import logging 

16import os 

17import re 

18import threading 

19from dataclasses import dataclass, field 

20from datetime import datetime 

21from typing import Dict, List, Any, Optional 

22 

23logger = logging.getLogger(__name__) 

24 

25try: 

26 from core.labeled_tool import labeled_tool 

27except ImportError: # cx_Freeze / degraded test env 

28 def labeled_tool(name, func, description, *, ui_label): # type: ignore 

29 from langchain.agents import Tool as _Tool 

30 return _Tool(name=name, func=func, description=description) 

31 

32 

33# --------------------------------------------------------------------------- 

34# YAML frontmatter parser (no PyYAML dependency - keep it self-contained) 

35# --------------------------------------------------------------------------- 

36 

37def _parse_frontmatter(text: str) -> tuple: 

38 """Parse YAML frontmatter from SKILL.md content. 

39 

40 Returns (frontmatter_dict, body_markdown). 

41 If no frontmatter found, returns ({}, full_text). 

42 """ 

43 text = text.lstrip() 

44 if not text.startswith("---"): 

45 return {}, text 

46 

47 end = text.find("---", 3) 

48 if end == -1: 

49 return {}, text 

50 

51 yaml_block = text[3:end].strip() 

52 body = text[end + 3:].strip() 

53 

54 # Minimal YAML parser - handles key: value, key: [list], key: "quoted" 

55 meta: Dict[str, Any] = {} 

56 for line in yaml_block.splitlines(): 

57 line = line.strip() 

58 if not line or line.startswith("#"): 

59 continue 

60 m = re.match(r'^([A-Za-z_][\w-]*)\s*:\s*(.*)', line) 

61 if not m: 

62 continue 

63 key = m.group(1).strip() 

64 val = m.group(2).strip() 

65 

66 # Strip quotes 

67 if (val.startswith('"') and val.endswith('"')) or \ 

68 (val.startswith("'") and val.endswith("'")): 

69 val = val[1:-1] 

70 

71 # Inline list [a, b, c] 

72 if val.startswith("[") and val.endswith("]"): 

73 val = [v.strip().strip("'\"") for v in val[1:-1].split(",") if v.strip()] 

74 

75 # Boolean 

76 if isinstance(val, str): 

77 if val.lower() in ("true", "yes"): 

78 val = True 

79 elif val.lower() in ("false", "no"): 

80 val = False 

81 

82 meta[key] = val 

83 

84 return meta, body 

85 

86 

87# --------------------------------------------------------------------------- 

88# SkillInfo 

89# --------------------------------------------------------------------------- 

90 

91@dataclass 

92class SkillInfo: 

93 """Metadata + content for an ingested skill.""" 

94 name: str 

95 description: str 

96 instructions: str # Markdown body - the actual skill prompt 

97 source: str = "local" # local | github | http | builtin 

98 source_path: str = "" # File path or URL it was loaded from 

99 allowed_tools: List[str] = field(default_factory=list) 

100 tags: List[str] = field(default_factory=list) 

101 user_invocable: bool = True 

102 context: str = "inline" # inline | fork 

103 version: str = "1.0.0" 

104 author: str = "" 

105 registered_at: Optional[str] = None 

106 

107 def to_dict(self) -> Dict[str, Any]: 

108 return { 

109 "name": self.name, 

110 "description": self.description, 

111 "instructions": self.instructions, 

112 "source": self.source, 

113 "source_path": self.source_path, 

114 "allowed_tools": self.allowed_tools, 

115 "tags": self.tags, 

116 "user_invocable": self.user_invocable, 

117 "context": self.context, 

118 "version": self.version, 

119 "author": self.author, 

120 "registered_at": self.registered_at, 

121 } 

122 

123 @classmethod 

124 def from_dict(cls, data: Dict[str, Any]) -> 'SkillInfo': 

125 return cls( 

126 name=data["name"], 

127 description=data.get("description", ""), 

128 instructions=data.get("instructions", ""), 

129 source=data.get("source", "local"), 

130 source_path=data.get("source_path", ""), 

131 allowed_tools=data.get("allowed_tools", []), 

132 tags=data.get("tags", []), 

133 user_invocable=data.get("user_invocable", True), 

134 context=data.get("context", "inline"), 

135 version=data.get("version", "1.0.0"), 

136 author=data.get("author", ""), 

137 registered_at=data.get("registered_at"), 

138 ) 

139 

140 @classmethod 

141 def from_skill_md(cls, content: str, source: str = "local", 

142 source_path: str = "") -> 'SkillInfo': 

143 """Parse a SKILL.md file into a SkillInfo.""" 

144 meta, body = _parse_frontmatter(content) 

145 

146 name = meta.get("name", "") 

147 if not name and source_path: 

148 # Derive name from path: .claude/skills/my-skill/SKILL.md → my-skill 

149 parts = source_path.replace("\\", "/").split("/") 

150 for i, p in enumerate(parts): 

151 if p == "skills" and i + 1 < len(parts): 

152 name = parts[i + 1] 

153 break 

154 if not name: 

155 name = os.path.splitext(os.path.basename(source_path))[0] 

156 

157 allowed = meta.get("allowed-tools", meta.get("allowed_tools", [])) 

158 if isinstance(allowed, str): 

159 allowed = [t.strip() for t in allowed.split(",")] 

160 

161 tags_raw = meta.get("tags", []) 

162 if isinstance(tags_raw, str): 

163 tags_raw = [t.strip() for t in tags_raw.split(",")] 

164 

165 return cls( 

166 name=name, 

167 description=meta.get("description", body[:120].replace("\n", " ").strip()), 

168 instructions=body, 

169 source=source, 

170 source_path=source_path, 

171 allowed_tools=allowed, 

172 tags=tags_raw, 

173 user_invocable=not meta.get("disable-model-invocation", False), 

174 context=meta.get("context", "inline"), 

175 version=meta.get("version", "1.0.0"), 

176 author=meta.get("author", ""), 

177 ) 

178 

179 

180# --------------------------------------------------------------------------- 

181# SkillRegistry 

182# --------------------------------------------------------------------------- 

183 

184class SkillRegistry: 

185 """ 

186 Registry for agent skills - any skill definition becomes a HART tool. 

187 

188 Mirrors ServiceToolRegistry pattern: 

189 - register_skill / unregister_skill 

190 - discover_local / discover_github 

191 - get_langchain_tools → plugs into hart_intelligence get_tools() 

192 - Global singleton: skill_registry 

193 """ 

194 

195 def __init__(self, config_file: str = "skills.json"): 

196 self._skills: Dict[str, SkillInfo] = {} 

197 self._config_file = config_file 

198 self._lock = threading.Lock() 

199 

200 # ---- Registration ---- 

201 

202 def register_skill(self, skill: SkillInfo) -> bool: 

203 """Register a skill. Returns True if new, False if already exists.""" 

204 with self._lock: 

205 if skill.name in self._skills: 

206 logger.debug(f"Skill '{skill.name}' already registered, updating") 

207 skill.registered_at = datetime.now().isoformat() 

208 self._skills[skill.name] = skill 

209 logger.info(f"Registered skill: {skill.name} (source={skill.source})") 

210 return True 

211 

212 def unregister_skill(self, name: str) -> bool: 

213 with self._lock: 

214 if name in self._skills: 

215 del self._skills[name] 

216 logger.info(f"Unregistered skill: {name}") 

217 return True 

218 return False 

219 

220 def get_skill(self, name: str) -> Optional[SkillInfo]: 

221 return self._skills.get(name) 

222 

223 def list_skills(self) -> List[Dict[str, Any]]: 

224 """List all registered skills (summary view).""" 

225 return [ 

226 { 

227 "name": s.name, 

228 "description": s.description, 

229 "source": s.source, 

230 "tags": s.tags, 

231 "user_invocable": s.user_invocable, 

232 "registered_at": s.registered_at, 

233 } 

234 for s in self._skills.values() 

235 ] 

236 

237 # ---- Discovery ---- 

238 

239 def discover_local(self, search_paths: Optional[List[str]] = None) -> int: 

240 """Discover skills from local filesystem. 

241 

242 Searches: 

243 - ~/.claude/skills/*/SKILL.md (Claude Code user skills) 

244 - .claude/skills/*/SKILL.md (project skills) 

245 - Custom paths 

246 """ 

247 if search_paths is None: 

248 home = os.path.expanduser("~") 

249 search_paths = [ 

250 os.path.join(home, ".claude", "skills"), 

251 os.path.join(os.getcwd(), ".claude", "skills"), 

252 ] 

253 

254 count = 0 

255 for base in search_paths: 

256 if not os.path.isdir(base): 

257 continue 

258 for entry in os.listdir(base): 

259 skill_dir = os.path.join(base, entry) 

260 skill_md = os.path.join(skill_dir, "SKILL.md") 

261 if not os.path.isfile(skill_md): 

262 # Also check for skill.md (lowercase) 

263 skill_md = os.path.join(skill_dir, "skill.md") 

264 if not os.path.isfile(skill_md): 

265 continue 

266 try: 

267 with open(skill_md, "r", encoding="utf-8") as f: 

268 content = f.read() 

269 skill = SkillInfo.from_skill_md(content, source="local", 

270 source_path=skill_md) 

271 if skill.name: 

272 self.register_skill(skill) 

273 count += 1 

274 except Exception as e: 

275 logger.warning(f"Failed to parse {skill_md}: {e}") 

276 

277 logger.info(f"Discovered {count} local skills from {len(search_paths)} paths") 

278 return count 

279 

280 def discover_github(self, repo_url: str, branch: str = "main", 

281 skills_path: str = ".claude/skills") -> int: 

282 """Discover skills from a GitHub repository. 

283 

284 Fetches the repo tree and downloads SKILL.md files. 

285 """ 

286 import urllib.request 

287 import urllib.error 

288 

289 # Parse owner/repo from URL 

290 # Supports: https://github.com/owner/repo or owner/repo 

291 repo_url = repo_url.rstrip("/") 

292 parts = repo_url.replace("https://github.com/", "").split("/") 

293 if len(parts) < 2: 

294 logger.error(f"Invalid repo URL: {repo_url}") 

295 return 0 

296 owner, repo = parts[0], parts[1] 

297 

298 # Fetch directory listing via GitHub API 

299 api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{skills_path}?ref={branch}" 

300 try: 

301 req = urllib.request.Request(api_url, headers={ 

302 "Accept": "application/vnd.github.v3+json", 

303 "User-Agent": "Hevolve-HARTmind/1.0", 

304 }) 

305 with urllib.request.urlopen(req, timeout=10) as resp: 

306 entries = json.loads(resp.read().decode()) 

307 except Exception as e: 

308 logger.warning(f"GitHub discovery failed for {repo_url}: {e}") 

309 return 0 

310 

311 count = 0 

312 for entry in entries: 

313 if entry.get("type") != "dir": 

314 continue 

315 skill_name = entry["name"] 

316 raw_url = (f"https://raw.githubusercontent.com/{owner}/{repo}" 

317 f"/{branch}/{skills_path}/{skill_name}/SKILL.md") 

318 try: 

319 req = urllib.request.Request(raw_url, headers={ 

320 "User-Agent": "Hevolve-HARTmind/1.0" 

321 }) 

322 with urllib.request.urlopen(req, timeout=10) as resp: 

323 content = resp.read().decode("utf-8") 

324 skill = SkillInfo.from_skill_md(content, source="github", 

325 source_path=raw_url) 

326 if skill.name: 

327 self.register_skill(skill) 

328 count += 1 

329 except Exception as e: 

330 logger.debug(f"Skipping {skill_name}: {e}") 

331 

332 logger.info(f"Discovered {count} skills from GitHub {owner}/{repo}") 

333 return count 

334 

335 def ingest_markdown(self, name: str, markdown: str, 

336 description: str = "", tags: Optional[List[str]] = None) -> bool: 

337 """Ingest a raw Markdown string as a skill (for API / UI uploads).""" 

338 # Check if it has frontmatter 

339 if markdown.lstrip().startswith("---"): 

340 skill = SkillInfo.from_skill_md(markdown, source="api") 

341 if not skill.name: 

342 skill.name = name 

343 else: 

344 skill = SkillInfo( 

345 name=name, 

346 description=description or markdown[:120].replace("\n", " ").strip(), 

347 instructions=markdown, 

348 source="api", 

349 tags=tags or [], 

350 ) 

351 return self.register_skill(skill) 

352 

353 # ---- LangChain integration ---- 

354 

355 def get_langchain_tools(self) -> list: 

356 """ 

357 Get all skills as LangChain Tool objects. 

358 

359 Plugs into hart_intelligence get_tools() alongside service_tool_registry. 

360 Each skill becomes a tool that returns the skill's instructions with the 

361 user's query injected, letting the LLM execute the skill as a thought experiment. 

362 """ 

363 tools = [] 

364 for name, skill in self._skills.items(): 

365 if not skill.user_invocable: 

366 continue 

367 

368 # Capture in closure 

369 _skill = skill 

370 

371 def execute_skill(query: str, _s=_skill) -> str: 

372 """Execute a HART skill by applying its instructions to the query.""" 

373 # Build the skill execution prompt 

374 result = f"## Skill: {_s.name}\n\n" 

375 result += f"{_s.instructions}\n\n" 

376 result += f"## User Request\n\n{query}\n" 

377 return result 

378 

379 tool_name = f"hart_skill_{name}" 

380 # Friendly UI status label per #508 option C — registry-driven labels. 

381 # Source hints: github → "Running Claude Code skill…", local SKILL.md → "Markdown", 

382 # builtin → use description fragment. Keep ≤60 chars. 

383 _src = (skill.source or "local").lower() 

384 if _src == "github": 

385 _label = f"Running {skill.name} (Claude Code skill)…" 

386 elif _src == "http": 

387 _label = f"Running {skill.name} skill from web…" 

388 elif _src == "builtin": 

389 _label = f"Running {skill.name} (built-in skill)…" 

390 else: 

391 _label = f"Running {skill.name} skill…" 

392 

393 tools.append(labeled_tool( 

394 name=tool_name, 

395 func=execute_skill, 

396 description=( 

397 f"[HART Skill] {skill.description}. " 

398 f"Use this skill to help with: {', '.join(skill.tags) if skill.tags else skill.name}" 

399 ), 

400 ui_label=_label[:60], 

401 )) 

402 

403 return tools 

404 

405 def get_autogen_tools(self) -> List[Dict[str, Any]]: 

406 """Get all skills as autogen function descriptions. 

407 

408 Mirrors ServiceToolRegistry.get_all_tool_functions() for autogen registration. 

409 """ 

410 functions = {} 

411 for name, skill in self._skills.items(): 

412 if not skill.user_invocable: 

413 continue 

414 

415 _skill = skill 

416 

417 def execute(query: str, _s=_skill) -> str: 

418 return f"## Skill: {_s.name}\n\n{_s.instructions}\n\n## User Request\n\n{query}\n" 

419 

420 func_name = f"hart_skill_{name}" 

421 execute.__name__ = func_name 

422 execute.__doc__ = skill.description 

423 functions[func_name] = execute 

424 

425 return functions 

426 

427 # ---- Persistence ---- 

428 

429 def save_config(self) -> None: 

430 """Persist registry to JSON.""" 

431 data = {name: skill.to_dict() for name, skill in self._skills.items()} 

432 try: 

433 with open(self._config_file, "w", encoding="utf-8") as f: 

434 json.dump(data, f, indent=2, ensure_ascii=False) 

435 logger.debug(f"Saved {len(data)} skills to {self._config_file}") 

436 except Exception as e: 

437 logger.warning(f"Failed to save skills config: {e}") 

438 

439 def load_config(self) -> int: 

440 """Load registry from JSON. Returns number of skills loaded.""" 

441 if not os.path.exists(self._config_file): 

442 return 0 

443 try: 

444 with open(self._config_file, "r", encoding="utf-8") as f: 

445 data = json.load(f) 

446 count = 0 

447 for name, skill_data in data.items(): 

448 skill = SkillInfo.from_dict(skill_data) 

449 self._skills[name] = skill 

450 count += 1 

451 logger.info(f"Loaded {count} skills from {self._config_file}") 

452 return count 

453 except Exception as e: 

454 logger.warning(f"Failed to load skills config: {e}") 

455 return 0 

456 

457 @property 

458 def count(self) -> int: 

459 return len(self._skills) 

460 

461 

462# --------------------------------------------------------------------------- 

463# Global singleton 

464# --------------------------------------------------------------------------- 

465 

466skill_registry = SkillRegistry()