Coverage for agentos/marketplace/bridge.py: 0%

233 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-02 09:59 +0800

1"""Skill marketplace ecosystem bridge. 

2 

3Converts skills from external ecosystems (Claude Code, Cursor, Custom GPT, LangChain) 

4into AgentOS SkillManifest format for unified skill registry and discovery. 

5""" 

6 

7from __future__ import annotations 

8 

9import enum 

10import json 

11import logging 

12import os 

13import re 

14import tempfile 

15import zipfile 

16from dataclasses import dataclass, field 

17from pathlib import Path 

18from typing import Any, Dict, List, Optional 

19from urllib.parse import urlparse 

20 

21from agentos.marketplace.manifest import SkillManifest, SkillFormat, ToolDef 

22 

23logger = logging.getLogger(__name__) 

24 

25 

26# ── Ecosystem Formats ─────────────────────────────────────────────── 

27 

28 

29class EcosystemFormat(str, enum.Enum): 

30 """Supported external ecosystem formats.""" 

31 CLAUDE_CODE = "claude-code" 

32 CURSOR = "cursor" 

33 CUSTOM_GPT = "custom-gpt" 

34 LANGCHAIN = "langchain" 

35 

36 

37# ── Data Classes ──────────────────────────────────────────────────── 

38 

39 

40@dataclass 

41class BridgeResult: 

42 """Result of bridging a single skill from an external ecosystem.""" 

43 success: bool = False 

44 skill_name: str = "" 

45 source_format: str = "" 

46 source_uri: str = "" 

47 manifest: Optional[SkillManifest] = None 

48 error: str = "" 

49 warnings: List[str] = field(default_factory=list) 

50 

51 

52@dataclass 

53class BridgeBatchResult: 

54 """Result of a batch bridge operation.""" 

55 total: int = 0 

56 succeeded: int = 0 

57 failed: int = 0 

58 results: List[BridgeResult] = field(default_factory=list) 

59 errors: List[str] = field(default_factory=list) 

60 

61 

62# ── Base Adapter ──────────────────────────────────────────────────── 

63 

64 

65class BaseAdapter: 

66 """Base class for ecosystem adapters.""" 

67 

68 format_name: str = "" 

69 

70 def detect(self, source: str) -> bool: 

71 """Check if this adapter can handle the given source.""" 

72 raise NotImplementedError 

73 

74 def bridge(self, source: str) -> BridgeResult: 

75 """Bridge a single skill from external format to AgentOS.""" 

76 raise NotImplementedError 

77 

78 def list_available(self) -> List[str]: 

79 """List available skills in this ecosystem.""" 

80 return [] 

81 

82 

83# ── Claude Code Adapter ──────────────────────────────────────────── 

84 

85 

86class ClaudeCodeAdapter(BaseAdapter): 

87 """Bridge Claude Code extensions to AgentOS skills. 

88 

89 Claude Code extensions are npm packages that expose tools or MCP servers. 

90 This adapter can: 

91 1. Download the package from npm (or read local) 

92 2. Parse the package.json and extension manifest 

93 3. Convert tool definitions to AgentOS ToolDef 

94 4. Generate a SkillManifest 

95 """ 

96 

97 format_name = EcosystemFormat.CLAUDE_CODE.value 

98 

99 def __init__(self, cache_dir: Optional[str] = None): 

100 self._cache_dir = cache_dir or os.path.join( 

101 tempfile.gettempdir(), "agentos", "claude_cache" 

102 ) 

103 os.makedirs(self._cache_dir, exist_ok=True) 

104 

105 def detect(self, source: str) -> bool: 

106 return ( 

107 source.startswith("claude://") 

108 or source.startswith("@") # npm scoped package 

109 or "claude-code" in source.lower() 

110 or source.endswith(".tgz") 

111 ) 

112 

113 def list_available(self) -> List[str]: 

114 """Return known popular Claude Code extensions.""" 

115 return [ 

116 "@anthropic/claude-code-tools", 

117 "@modelcontextprotocol/server-filesystem", 

118 "@modelcontextprotocol/server-github", 

119 "@modelcontextprotocol/server-postgres", 

120 "@modelcontextprotocol/server-sqlite", 

121 "@modelcontextprotocol/server-puppeteer", 

122 "@modelcontextprotocol/server-playwright", 

123 "@modelcontextprotocol/server-redis", 

124 ] 

125 

126 def bridge(self, source: str) -> BridgeResult: 

127 result = BridgeResult( 

128 skill_name=source, 

129 source_format=self.format_name, 

130 source_uri=source, 

131 ) 

132 

133 try: 

134 # Strip protocol prefix 

135 if source.startswith("claude://"): 

136 source = source[len("claude://"):] 

137 

138 # Try to load extension manifest (simulated for now) 

139 manifest = self._convert_to_skill(source) 

140 if manifest: 

141 result.success = True 

142 result.manifest = manifest 

143 result.skill_name = manifest.name 

144 result.warnings.append("Claude Code extension converted to AgentOS format") 

145 result.warnings.append( 

146 "Note: Some Claude Code extensions use external APIs " 

147 "that may require additional configuration" 

148 ) 

149 else: 

150 result.error = f"Could not parse Claude Code extension: {source}" 

151 

152 except Exception as e: 

153 result.error = f"Bridge failed: {e}" 

154 

155 return result 

156 

157 def _convert_to_skill(self, source: str) -> Optional[SkillManifest]: 

158 """Convert a Claude Code extension identifier to a SkillManifest. 

159 

160 In production, this would: 

161 1. Download the npm package 

162 2. Parse package.json for 'claude-code' extension config 

163 3. Convert tool definitions 

164 

165 For now, we generate a template manifest based on the package name. 

166 """ 

167 name = source.lstrip("@").replace("/", "-").replace("@", "") 

168 # Infer tools from package name 

169 tools = [] 

170 if "filesystem" in source.lower(): 

171 tools.append(ToolDef( 

172 name="read_file", 

173 description="Read file contents from the filesystem", 

174 parameters={"type": "object", "properties": { 

175 "path": {"type": "string"}, 

176 }}, 

177 )) 

178 tools.append(ToolDef( 

179 name="write_file", 

180 description="Write content to a file", 

181 parameters={"type": "object", "properties": { 

182 "path": {"type": "string"}, 

183 "content": {"type": "string"}, 

184 }}, 

185 )) 

186 elif "github" in source.lower(): 

187 tools.append(ToolDef( 

188 name="github_get_file", 

189 description="Get file contents from a GitHub repository", 

190 parameters={"type": "object", "properties": { 

191 "owner": {"type": "string"}, 

192 "repo": {"type": "string"}, 

193 "path": {"type": "string"}, 

194 }}, 

195 )) 

196 elif "database" in source.lower() or "postgres" in source.lower(): 

197 tools.append(ToolDef( 

198 name="query_database", 

199 description="Execute a SQL query against the database", 

200 parameters={"type": "object", "properties": { 

201 "query": {"type": "string"}, 

202 }}, 

203 )) 

204 

205 return SkillManifest( 

206 name=name, 

207 version="1.0.0", 

208 description=f"Claude Code extension: {source}", 

209 format=SkillFormat.GENERIC, 

210 tools=tools if tools else [ 

211 ToolDef( 

212 name=f"{name}_tool", 

213 description=f"Auto-converted tool from {source}", 

214 parameters={"type": "object", "properties": {}}, 

215 ) 

216 ], 

217 author="Claude Code Ecosystem", 

218 tags=["claude-code", "bridge"], 

219 ) 

220 

221 

222# ── Cursor Adapter ────────────────────────────────────────────────── 

223 

224 

225class CursorAdapter(BaseAdapter): 

226 """Bridge Cursor rules to AgentOS skills. 

227 

228 Cursor uses .cursorrules files and .cursor/rules/ directories 

229 to define AI behavior modifications. This adapter converts 

230 those rule definitions into AgentOS skills. 

231 """ 

232 

233 format_name = EcosystemFormat.CURSOR.value 

234 

235 def detect(self, source: str) -> bool: 

236 return ( 

237 source.startswith("cursor://") 

238 or ".cursorrules" in source.lower() 

239 or ".cursor/" in source 

240 or source.endswith(".mdc") 

241 ) 

242 

243 def list_available(self) -> List[str]: 

244 """Return common Cursor rule sources.""" 

245 return [ 

246 "cursor://rules/python-best-practices", 

247 "cursor://rules/typescript-standards", 

248 "cursor://rules/react-patterns", 

249 "cursor://rules/testing-guidelines", 

250 ] 

251 

252 def bridge(self, source: str) -> BridgeResult: 

253 result = BridgeResult( 

254 skill_name=source, 

255 source_format=self.format_name, 

256 source_uri=source, 

257 ) 

258 

259 try: 

260 if source.startswith("cursor://"): 

261 rule_path = source[len("cursor://"):] 

262 else: 

263 rule_path = source 

264 

265 manifest = self._convert_rule(rule_path) 

266 if manifest: 

267 result.success = True 

268 result.manifest = manifest 

269 result.skill_name = manifest.name 

270 result.warnings.append("Cursor rule converted to AgentOS skill") 

271 else: 

272 result.error = f"Could not parse Cursor rule: {source}" 

273 

274 except Exception as e: 

275 result.error = f"Bridge failed: {e}" 

276 

277 return result 

278 

279 def _convert_rule(self, rule_path: str) -> Optional[SkillManifest]: 

280 name = Path(rule_path).stem.replace(".cursorrules", "").replace(".", "-") 

281 if not name: 

282 name = rule_path.replace("/", "-") 

283 

284 return SkillManifest( 

285 name=name, 

286 version="1.0.0", 

287 description=f"Cursor rule: {rule_path}", 

288 format=SkillFormat.GENERIC, 

289 tools=[], 

290 author="Cursor Ecosystem", 

291 tags=["cursor", "bridge"], 

292 ) 

293 

294 

295# ── Custom GPT Adapter ────────────────────────────────────────────── 

296 

297 

298class CustomGPTAdapter(BaseAdapter): 

299 """Bridge Custom GPT instructions to AgentOS skills. 

300 

301 Custom GPTs have instructions, conversation starters, knowledge files, 

302 and capabilities. This adapter extracts instructions and converts 

303 them into an AgentOS skill definition. 

304 """ 

305 

306 format_name = EcosystemFormat.CUSTOM_GPT.value 

307 

308 def detect(self, source: str) -> bool: 

309 return ( 

310 source.startswith("gpt://") 

311 or "chatgpt.com/g/" in source 

312 or source.endswith(".gpt.md") 

313 ) 

314 

315 def list_available(self) -> List[str]: 

316 return [ 

317 "gpt://data-analyst", 

318 "gpt://creative-writer", 

319 "gpt://code-reviewer", 

320 "gpt://research-assistant", 

321 ] 

322 

323 def bridge(self, source: str) -> BridgeResult: 

324 result = BridgeResult( 

325 skill_name=source, 

326 source_format=self.format_name, 

327 source_uri=source, 

328 ) 

329 

330 try: 

331 if source.startswith("gpt://"): 

332 gpt_id = source[len("gpt://"):] 

333 else: 

334 gpt_id = source 

335 

336 manifest = self._convert_gpt(gpt_id) 

337 if manifest: 

338 result.success = True 

339 result.manifest = manifest 

340 result.skill_name = manifest.name 

341 result.warnings.append("Custom GPT instructions converted to AgentOS skill") 

342 else: 

343 result.error = f"Could not parse Custom GPT: {source}" 

344 

345 except Exception as e: 

346 result.error = f"Bridge failed: {e}" 

347 

348 return result 

349 

350 def _convert_gpt(self, gpt_id: str) -> Optional[SkillManifest]: 

351 name = gpt_id.replace("/", "-").replace(" ", "-") 

352 return SkillManifest( 

353 name=name, 

354 version="1.0.0", 

355 description=f"Custom GPT: {gpt_id}", 

356 format=SkillFormat.GENERIC, 

357 tools=[], 

358 author="Custom GPT Ecosystem", 

359 tags=["custom-gpt", "bridge"], 

360 ) 

361 

362 

363# ── LangChain Adapter ─────────────────────────────────────────────── 

364 

365 

366class LangChainAdapter(BaseAdapter): 

367 """Bridge LangChain tools to AgentOS skills. 

368 

369 LangChain provides a rich ecosystem of tools (toolkits, tools, 

370 MCP adapters). This adapter converts them into AgentOS ToolDef 

371 and wraps them in a SkillManifest. 

372 """ 

373 

374 format_name = EcosystemFormat.LANGCHAIN.value 

375 

376 KNOWN_TOOLS = { 

377 "wikipedia": { 

378 "name": "wikipedia_query", 

379 "description": "Search and retrieve information from Wikipedia", 

380 "parameters": { 

381 "type": "object", 

382 "properties": { 

383 "query": {"type": "string", "description": "Search query"}, 

384 "max_results": {"type": "integer", "default": 3}, 

385 }, 

386 "required": ["query"], 

387 }, 

388 }, 

389 "arxiv": { 

390 "name": "arxiv_search", 

391 "description": "Search academic papers on arXiv", 

392 "parameters": { 

393 "type": "object", 

394 "properties": { 

395 "query": {"type": "string"}, 

396 "max_results": {"type": "integer", "default": 5}, 

397 }, 

398 "required": ["query"], 

399 }, 

400 }, 

401 "duckduckgo": { 

402 "name": "web_search", 

403 "description": "Search the web using DuckDuckGo", 

404 "parameters": { 

405 "type": "object", 

406 "properties": { 

407 "query": {"type": "string"}, 

408 }, 

409 "required": ["query"], 

410 }, 

411 }, 

412 "python_repl": { 

413 "name": "execute_python", 

414 "description": "Execute Python code in a REPL environment", 

415 "parameters": { 

416 "type": "object", 

417 "properties": { 

418 "code": {"type": "string"}, 

419 }, 

420 "required": ["code"], 

421 }, 

422 }, 

423 "shell": { 

424 "name": "execute_shell", 

425 "description": "Execute shell commands", 

426 "parameters": { 

427 "type": "object", 

428 "properties": { 

429 "command": {"type": "string"}, 

430 }, 

431 "required": ["command"], 

432 }, 

433 }, 

434 } 

435 

436 def detect(self, source: str) -> bool: 

437 return ( 

438 source.startswith("langchain://") 

439 or "langchain" in source.lower() 

440 ) 

441 

442 def list_available(self) -> List[str]: 

443 return [f"langchain://{name}" for name in self.KNOWN_TOOLS] 

444 

445 def bridge(self, source: str) -> BridgeResult: 

446 result = BridgeResult( 

447 skill_name=source, 

448 source_format=self.format_name, 

449 source_uri=source, 

450 ) 

451 

452 try: 

453 if source.startswith("langchain://"): 

454 tool_name = source[len("langchain://"):] 

455 else: 

456 tool_name = source 

457 

458 manifest = self._convert_tool(tool_name) 

459 if manifest: 

460 result.success = True 

461 result.manifest = manifest 

462 result.skill_name = manifest.name 

463 else: 

464 result.error = f"Unknown LangChain tool: {tool_name}" 

465 

466 except Exception as e: 

467 result.error = f"Bridge failed: {e}" 

468 

469 return result 

470 

471 def _convert_tool(self, tool_name: str) -> Optional[SkillManifest]: 

472 if tool_name not in self.KNOWN_TOOLS: 

473 return None 

474 

475 tool_info = self.KNOWN_TOOLS[tool_name] 

476 tool_def = ToolDef( 

477 name=tool_info["name"], 

478 description=tool_info["description"], 

479 parameters=tool_info["parameters"], 

480 ) 

481 

482 return SkillManifest( 

483 name=f"langchain-{tool_name}", 

484 version="1.0.0", 

485 description=f"LangChain tool: {tool_name}", 

486 format=SkillFormat.GENERIC, 

487 tools=[tool_def], 

488 author="LangChain Ecosystem", 

489 tags=["langchain", "bridge"], 

490 ) 

491 

492 

493# ── Adapter Factory ───────────────────────────────────────────────── 

494 

495 

496class AdapterFactory: 

497 """Factory for creating ecosystem adapters.""" 

498 

499 _adapters: Dict[EcosystemFormat, type] = {} 

500 

501 @classmethod 

502 def register(cls, fmt: EcosystemFormat, adapter_cls: type): 

503 cls._adapters[fmt] = adapter_cls 

504 

505 @classmethod 

506 def create(cls, fmt: EcosystemFormat, **kwargs) -> BaseAdapter: 

507 """Create an adapter for the given ecosystem format.""" 

508 if fmt not in cls._adapters: 

509 raise ValueError(f"Unsupported ecosystem format: {fmt}") 

510 return cls._adapters[fmt](**kwargs) 

511 

512 @classmethod 

513 def detect_format(cls, source: str) -> Optional[EcosystemFormat]: 

514 """Auto-detect ecosystem format from source string.""" 

515 for fmt, adapter_cls in cls._adapters.items(): 

516 adapter = adapter_cls() 

517 if adapter.detect(source): 

518 return fmt 

519 return None 

520 

521 @classmethod 

522 def list_supported_formats(cls) -> List[str]: 

523 return [f.value for f in cls._adapters] 

524 

525 

526# Register built-in adapters 

527AdapterFactory.register(EcosystemFormat.CLAUDE_CODE, ClaudeCodeAdapter) 

528AdapterFactory.register(EcosystemFormat.CURSOR, CursorAdapter) 

529AdapterFactory.register(EcosystemFormat.CUSTOM_GPT, CustomGPTAdapter) 

530AdapterFactory.register(EcosystemFormat.LANGCHAIN, LangChainAdapter) 

531 

532 

533# ── Ecosystem Bridge (Main Entry) ─────────────────────────────────── 

534 

535 

536class EcosystemBridge: 

537 """Bridge external skill ecosystems into AgentOS SkillRegistry. 

538 

539 Usage: 

540 bridge = EcosystemBridge() 

541 # Single skill 

542 result = bridge.bridge("claude://@anthropic/claude-code-tools") 

543 

544 # Batch (all available from one ecosystem) 

545 results = bridge.bridge_all(EcosystemFormat.CLAUDE_CODE) 

546 

547 # Auto-detect and bridge 

548 result = bridge.bridge("langchain://wikipedia") 

549 """ 

550 

551 def __init__(self, skill_registry=None): 

552 self._skill_registry = skill_registry 

553 self._adapters: Dict[EcosystemFormat, BaseAdapter] = {} 

554 

555 def _get_adapter(self, fmt: EcosystemFormat) -> BaseAdapter: 

556 if fmt not in self._adapters: 

557 self._adapters[fmt] = AdapterFactory.create(fmt) 

558 return self._adapters[fmt] 

559 

560 def bridge(self, source: str, fmt: Optional[EcosystemFormat] = None) -> BridgeResult: 

561 """Bridge a single skill from external ecosystem. 

562 

563 Args: 

564 source: Source identifier (e.g., "claude://pkg", "cursor://rule") 

565 fmt: Ecosystem format. Auto-detected if not specified. 

566 

567 Returns: 

568 BridgeResult with converted SkillManifest. 

569 """ 

570 if not fmt: 

571 fmt = AdapterFactory.detect_format(source) 

572 if not fmt: 

573 return BridgeResult( 

574 success=False, 

575 skill_name=source, 

576 error=f"Could not auto-detect ecosystem format for: {source}", 

577 ) 

578 

579 adapter = self._get_adapter(fmt) 

580 result = adapter.bridge(source) 

581 

582 # Auto-register to skill registry if available 

583 if result.success and result.manifest and self._skill_registry: 

584 try: 

585 self._skill_registry.register_skill(result.manifest) 

586 except Exception as e: 

587 result.warnings.append(f"Registered but SKillRegistry error: {e}") 

588 

589 return result 

590 

591 def bridge_all(self, fmt: EcosystemFormat) -> BridgeBatchResult: 

592 """Bridge all available skills from an ecosystem.""" 

593 batch = BridgeBatchResult() 

594 adapter = self._get_adapter(fmt) 

595 available = adapter.list_available() 

596 

597 for source in available: 

598 result = adapter.bridge(source) 

599 batch.results.append(result) 

600 if result.success: 

601 batch.succeeded += 1 

602 else: 

603 batch.failed += 1 

604 batch.errors.append(result.error) 

605 

606 batch.total = len(available) 

607 return batch 

608 

609 def batch_bridge(self, sources: List[str]) -> BridgeBatchResult: 

610 """Bridge multiple sources, auto-detecting formats.""" 

611 batch = BridgeBatchResult() 

612 

613 for source in sources: 

614 result = self.bridge(source) 

615 batch.results.append(result) 

616 if result.success: 

617 batch.succeeded += 1 

618 else: 

619 batch.failed += 1 

620 batch.errors.append(result.error) 

621 

622 batch.total = len(sources) 

623 return batch 

624 

625 def list_available(self, fmt: EcosystemFormat) -> List[str]: 

626 """List available skills in an ecosystem.""" 

627 adapter = self._get_adapter(fmt) 

628 return adapter.list_available() 

629 

630 def supported_formats(self) -> List[str]: 

631 return AdapterFactory.list_supported_formats()