Coverage for integrations / openclaw / clawhub_adapter.py: 73.9%

226 statements  

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

1""" 

2ClawHub Adapter — Parse, install, and execute OpenClaw skills in HART OS. 

3 

4ClawHub skills are simple: a SKILL.md with YAML frontmatter + instructions. 

5We parse them into HART-compatible actions that agents can invoke. 

6 

7Flow: 

8 1. `clawhub install <slug>` downloads skill to ~/.hevolve/openclaw/skills/ 

9 2. SkillParser reads SKILL.md frontmatter (name, description, metadata.openclaw) 

10 3. Skill requirements are checked (bins, env vars, config) 

11 4. Instructions body is loaded as agent context (like a mini recipe) 

12 5. HART agents can invoke the skill as a tool via ClawHubToolProvider 

13""" 

14 

15import json 

16import logging 

17import os 

18import re 

19import shutil 

20import subprocess 

21from dataclasses import dataclass, field 

22from pathlib import Path 

23from typing import Any, Dict, List, Optional 

24 

25logger = logging.getLogger(__name__) 

26 

27# ── Paths ────────────────────────────────────────────────────────── 

28 

29OPENCLAW_HOME = Path(os.environ.get( 

30 'OPENCLAW_SKILLS_DIR', 

31 os.path.expanduser('~/.hevolve/openclaw/skills') 

32)) 

33 

34CLAWHUB_REGISTRY = 'https://registry.clawhub.ai' 

35 

36 

37# ── Skill Schema ─────────────────────────────────────────────────── 

38 

39@dataclass 

40class OpenClawRequirements: 

41 """Requirements declared in metadata.openclaw.requires.""" 

42 bins: List[str] = field(default_factory=list) 

43 any_bins: List[str] = field(default_factory=list) 

44 env: List[str] = field(default_factory=list) 

45 config: List[str] = field(default_factory=list) 

46 

47 

48@dataclass 

49class OpenClawInstallSpec: 

50 """An install directive (brew, node, go, uv, nix).""" 

51 id: str = '' 

52 kind: str = '' # brew, node, go, uv, nix, pip 

53 formula: str = '' # package name 

54 bins: List[str] = field(default_factory=list) 

55 label: str = '' 

56 os_filter: List[str] = field(default_factory=lambda: ['all']) 

57 

58 

59@dataclass 

60class OpenClawSkill: 

61 """Parsed representation of a SKILL.md file.""" 

62 name: str = '' 

63 description: str = '' 

64 version: str = '0.0.0' 

65 homepage: str = '' 

66 emoji: str = '' 

67 user_invocable: bool = True 

68 disable_model_invocation: bool = False 

69 command_dispatch: Optional[str] = None # 'tool' or None 

70 command_tool: Optional[str] = None 

71 command_arg_mode: str = 'raw' 

72 primary_env: str = '' 

73 os_filter: List[str] = field(default_factory=lambda: ['all']) 

74 requirements: OpenClawRequirements = field(default_factory=OpenClawRequirements) 

75 install_specs: List[OpenClawInstallSpec] = field(default_factory=list) 

76 instructions: str = '' # The body of SKILL.md 

77 skill_dir: str = '' # Local path 

78 source: str = 'clawhub' # 'clawhub', 'local', 'workspace' 

79 

80 

81# ── Parser ───────────────────────────────────────────────────────── 

82 

83_FRONTMATTER_RE = re.compile(r'^---\s*\n(.*?)\n---\s*\n', re.DOTALL) 

84 

85 

86def _parse_yaml_simple(text: str) -> Dict[str, Any]: 

87 """Minimal YAML-like parser for SKILL.md frontmatter. 

88 

89 ClawHub frontmatter is constrained: single-line keys, JSON metadata. 

90 We avoid a PyYAML dependency by parsing the subset we need. 

91 """ 

92 result = {} 

93 for line in text.split('\n'): 

94 line = line.strip() 

95 if not line or line.startswith('#'): 

96 continue 

97 colon_idx = line.find(':') 

98 if colon_idx < 0: 

99 continue 

100 key = line[:colon_idx].strip() 

101 value = line[colon_idx + 1:].strip() 

102 # Try JSON parse for metadata objects 

103 if value.startswith('{') or value.startswith('['): 

104 try: 

105 value = json.loads(value) 

106 except json.JSONDecodeError: 

107 pass 

108 elif value.lower() in ('true', 'yes'): 

109 value = True 

110 elif value.lower() in ('false', 'no'): 

111 value = False 

112 elif value.isdigit(): 

113 value = int(value) 

114 # Strip quotes 

115 elif isinstance(value, str) and len(value) >= 2: 

116 if (value[0] == '"' and value[-1] == '"') or \ 

117 (value[0] == "'" and value[-1] == "'"): 

118 value = value[1:-1] 

119 result[key] = value 

120 return result 

121 

122 

123def parse_skill_md(skill_path: str) -> OpenClawSkill: 

124 """Parse a SKILL.md file into an OpenClawSkill object.""" 

125 path = Path(skill_path) 

126 if path.is_dir(): 

127 path = path / 'SKILL.md' 

128 

129 content = path.read_text(encoding='utf-8') 

130 skill = OpenClawSkill(skill_dir=str(path.parent)) 

131 

132 # Parse frontmatter 

133 fm_match = _FRONTMATTER_RE.match(content) 

134 if fm_match: 

135 fm = _parse_yaml_simple(fm_match.group(1)) 

136 skill.name = fm.get('name', '') 

137 skill.description = fm.get('description', '') 

138 skill.version = str(fm.get('version', '0.0.0')) 

139 skill.homepage = fm.get('homepage', '') 

140 skill.user_invocable = fm.get('user-invocable', True) 

141 skill.disable_model_invocation = fm.get('disable-model-invocation', False) 

142 skill.command_dispatch = fm.get('command-dispatch') 

143 skill.command_tool = fm.get('command-tool') 

144 skill.command_arg_mode = fm.get('command-arg-mode', 'raw') 

145 

146 # Parse metadata.openclaw 

147 meta = fm.get('metadata', {}) 

148 if isinstance(meta, str): 

149 try: 

150 meta = json.loads(meta) 

151 except json.JSONDecodeError: 

152 meta = {} 

153 oc = meta.get('openclaw', {}) if isinstance(meta, dict) else {} 

154 

155 skill.emoji = oc.get('emoji', '') 

156 skill.primary_env = oc.get('primaryEnv', '') 

157 skill.os_filter = oc.get('os', ['all']) 

158 

159 # Requirements 

160 req = oc.get('requires', {}) 

161 if isinstance(req, dict): 

162 skill.requirements = OpenClawRequirements( 

163 bins=req.get('bins', []), 

164 any_bins=req.get('anyBins', []), 

165 env=req.get('env', []), 

166 config=req.get('config', []), 

167 ) 

168 

169 # Install specs 

170 for spec_data in oc.get('install', []): 

171 if isinstance(spec_data, dict): 

172 skill.install_specs.append(OpenClawInstallSpec( 

173 id=spec_data.get('id', ''), 

174 kind=spec_data.get('kind', ''), 

175 formula=spec_data.get('formula', ''), 

176 bins=spec_data.get('bins', []), 

177 label=spec_data.get('label', ''), 

178 os_filter=spec_data.get('os', ['all']), 

179 )) 

180 

181 # Instructions = everything after frontmatter 

182 skill.instructions = content[fm_match.end():].strip() 

183 else: 

184 # No frontmatter — entire file is instructions 

185 skill.instructions = content.strip() 

186 

187 return skill 

188 

189 

190# ── Requirements Check ───────────────────────────────────────────── 

191 

192def check_requirements(skill: OpenClawSkill) -> Dict[str, Any]: 

193 """Check if skill requirements are satisfied on this system. 

194 

195 Returns dict with 'satisfied' bool and 'missing' details. 

196 """ 

197 missing_bins = [] 

198 missing_env = [] 

199 req = skill.requirements 

200 

201 for b in req.bins: 

202 if not shutil.which(b): 

203 missing_bins.append(b) 

204 

205 if req.any_bins: 

206 if not any(shutil.which(b) for b in req.any_bins): 

207 missing_bins.append(f"any of: {', '.join(req.any_bins)}") 

208 

209 for e in req.env: 

210 if not os.environ.get(e): 

211 missing_env.append(e) 

212 

213 satisfied = len(missing_bins) == 0 and len(missing_env) == 0 

214 return { 

215 'satisfied': satisfied, 

216 'missing_bins': missing_bins, 

217 'missing_env': missing_env, 

218 } 

219 

220 

221# ── Install / Uninstall ─────────────────────────────────────────── 

222 

223def install_skill(slug: str, version: Optional[str] = None, 

224 force: bool = False) -> Optional[OpenClawSkill]: 

225 """Install a ClawHub skill by slug. 

226 

227 Downloads from registry or uses clawhub CLI if available. 

228 Skills are stored in ~/.hevolve/openclaw/skills/<slug>/ 

229 """ 

230 dest = OPENCLAW_HOME / slug 

231 if dest.exists() and not force: 

232 logger.info("Skill %s already installed at %s", slug, dest) 

233 return parse_skill_md(str(dest)) 

234 

235 dest.mkdir(parents=True, exist_ok=True) 

236 

237 # Strategy 1: Use clawhub CLI if available 

238 clawhub_bin = shutil.which('clawhub') 

239 if clawhub_bin: 

240 cmd = [clawhub_bin, 'install', slug] 

241 if version: 

242 cmd.extend(['--version', version]) 

243 if force: 

244 cmd.append('--force') 

245 try: 

246 result = subprocess.run( 

247 cmd, capture_output=True, text=True, timeout=60, 

248 env={**os.environ, 'CLAWHUB_SKILLS_DIR': str(OPENCLAW_HOME)} 

249 ) 

250 if result.returncode == 0: 

251 logger.info("Installed skill %s via clawhub CLI", slug) 

252 return parse_skill_md(str(dest)) 

253 logger.warning("clawhub install failed: %s", result.stderr) 

254 except (subprocess.TimeoutExpired, FileNotFoundError) as e: 

255 logger.warning("clawhub CLI error: %s", e) 

256 

257 # Strategy 2: Direct HTTP download from registry 

258 try: 

259 from core.http_pool import pooled_get 

260 except ImportError: 

261 import requests 

262 pooled_get = requests.get 

263 

264 url = f"{CLAWHUB_REGISTRY}/api/skills/{slug}" 

265 if version: 

266 url += f"/versions/{version}" 

267 url += "/download" 

268 

269 try: 

270 resp = pooled_get(url, timeout=30) 

271 if hasattr(resp, 'status_code'): 

272 status = resp.status_code 

273 else: 

274 status = getattr(resp, 'status', 200) 

275 

276 if status == 200: 

277 # Registry returns a tarball or SKILL.md content 

278 content_type = '' 

279 if hasattr(resp, 'headers'): 

280 content_type = resp.headers.get('content-type', '') 

281 

282 if 'application/json' in content_type: 

283 data = resp.json() if hasattr(resp, 'json') else json.loads(resp.text) 

284 skill_md = data.get('skill_md', data.get('content', '')) 

285 (dest / 'SKILL.md').write_text(skill_md, encoding='utf-8') 

286 else: 

287 text = resp.text if hasattr(resp, 'text') else str(resp) 

288 (dest / 'SKILL.md').write_text(text, encoding='utf-8') 

289 

290 logger.info("Installed skill %s from registry", slug) 

291 return parse_skill_md(str(dest)) 

292 else: 

293 logger.error("Registry returned %d for skill %s", status, slug) 

294 except Exception as e: 

295 logger.error("Failed to download skill %s: %s", slug, e) 

296 

297 # Clean up empty dir on failure 

298 if dest.exists() and not any(dest.iterdir()): 

299 dest.rmdir() 

300 return None 

301 

302 

303def uninstall_skill(slug: str) -> bool: 

304 """Remove an installed skill.""" 

305 dest = OPENCLAW_HOME / slug 

306 if dest.exists(): 

307 shutil.rmtree(dest) 

308 logger.info("Uninstalled skill %s", slug) 

309 return True 

310 return False 

311 

312 

313def list_installed_skills() -> List[OpenClawSkill]: 

314 """List all installed OpenClaw skills.""" 

315 skills = [] 

316 if not OPENCLAW_HOME.exists(): 

317 return skills 

318 for d in sorted(OPENCLAW_HOME.iterdir()): 

319 skill_md = d / 'SKILL.md' 

320 if d.is_dir() and skill_md.exists(): 

321 try: 

322 skills.append(parse_skill_md(str(d))) 

323 except Exception as e: 

324 logger.warning("Failed to parse skill at %s: %s", d, e) 

325 return skills 

326 

327 

328# ── Skill → HART Tool Conversion ────────────────────────────────── 

329 

330def skill_to_autogen_tool(skill: OpenClawSkill) -> Dict[str, Any]: 

331 """Convert an OpenClaw skill to an AutoGen-compatible tool definition. 

332 

333 The tool wraps the skill's instructions as a prompt-based action 

334 that HART agents can invoke like any other tool. 

335 """ 

336 tool_name = f"openclaw_{skill.name.replace('-', '_')}" 

337 

338 def tool_fn(command: str = '', **kwargs) -> str: 

339 """Execute an OpenClaw skill with the given command/args.""" 

340 # Replace {baseDir} placeholder with actual skill dir 

341 instructions = skill.instructions.replace('{baseDir}', skill.skill_dir) 

342 

343 # If command-dispatch: tool, forward directly 

344 if skill.command_dispatch == 'tool' and skill.command_tool: 

345 return json.dumps({ 

346 'tool': skill.command_tool, 

347 'command': command, 

348 'skill': skill.name, 

349 'instructions': instructions, 

350 }) 

351 

352 # Otherwise, return instructions as context for the agent 

353 return json.dumps({ 

354 'skill': skill.name, 

355 'description': skill.description, 

356 'instructions': instructions, 

357 'command': command, 

358 }) 

359 

360 return { 

361 'name': tool_name, 

362 'description': skill.description or f"OpenClaw skill: {skill.name}", 

363 'function': tool_fn, 

364 'parameters': { 

365 'command': { 

366 'type': 'string', 

367 'description': 'Command or input to pass to the skill', 

368 } 

369 }, 

370 'source': 'openclaw_clawhub', 

371 } 

372 

373 

374class ClawHubToolProvider: 

375 """Provides all installed ClawHub skills as HART agent tools. 

376 

377 Usage: 

378 provider = ClawHubToolProvider() 

379 tools = provider.get_tools() 

380 # Register with AutoGen agent 

381 for tool in tools: 

382 agent.register_function(tool['function'], tool['name'], tool['description']) 

383 """ 

384 

385 def __init__(self, skills_dir: Optional[str] = None): 

386 self._skills_dir = Path(skills_dir) if skills_dir else OPENCLAW_HOME 

387 self._cache: Optional[List[Dict[str, Any]]] = None 

388 

389 def get_tools(self, refresh: bool = False) -> List[Dict[str, Any]]: 

390 """Get all installed skills as AutoGen tool definitions.""" 

391 if self._cache is not None and not refresh: 

392 return self._cache 

393 

394 tools = [] 

395 if self._skills_dir.exists(): 

396 for d in sorted(self._skills_dir.iterdir()): 

397 skill_md = d / 'SKILL.md' 

398 if d.is_dir() and skill_md.exists(): 

399 try: 

400 skill = parse_skill_md(str(d)) 

401 if skill.disable_model_invocation: 

402 continue 

403 req_check = check_requirements(skill) 

404 if not req_check['satisfied']: 

405 logger.debug("Skipping skill %s: missing %s", 

406 skill.name, req_check) 

407 continue 

408 tools.append(skill_to_autogen_tool(skill)) 

409 except Exception as e: 

410 logger.warning("Failed to load skill %s: %s", d.name, e) 

411 

412 self._cache = tools 

413 return tools 

414 

415 def invalidate(self): 

416 """Clear cached tools (call after install/uninstall).""" 

417 self._cache = None 

418 

419 

420# ── Singleton ────────────────────────────────────────────────────── 

421 

422_provider: Optional[ClawHubToolProvider] = None 

423 

424 

425def get_clawhub_provider() -> ClawHubToolProvider: 

426 """Get the singleton ClawHub tool provider.""" 

427 global _provider 

428 if _provider is None: 

429 _provider = ClawHubToolProvider() 

430 return _provider