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

262 statements  

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

1""" 

2AgentOS Skill Marketplace — Registry。 

3 

4核心能力: 

5 - 本地注册表:~/.agentos/marketplace/installed.json 

6 - PyPI 发现:搜索 agentos-skill-* 前缀包 

7 - 安装/卸载/更新/搜索 

8 - 多格式兼容:agentos / openclaw / MCP / generic 

9 

10目录结构: 

11 ~/.agentos/marketplace/ 

12 installed.json # 已安装清单 

13 skills/ 

14 <name>/ # 每个 skill 一个目录 

15 manifest.yaml 

16 ... 

17""" 

18 

19from __future__ import annotations 

20 

21import json 

22import os 

23import shutil 

24import subprocess 

25import sys 

26import tempfile 

27import time 

28from dataclasses import dataclass, field 

29from pathlib import Path 

30from typing import Optional 

31 

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

33 

34MARKET_DIR = Path.home() / ".agentos" / "marketplace" 

35INSTALLED_JSON = MARKET_DIR / "installed.json" 

36SKILLS_DIR = MARKET_DIR / "skills" 

37PYPI_SKILL_PREFIX = "agentos-skill-" 

38 

39 

40class System: 

41 """简化后的 PyPI 搜索调用——用 pip 查询比 httpx 解析 JSON API 更可靠。""" 

42 

43 

44@dataclass 

45class SearchResult: 

46 """搜索结果。""" 

47 name: str 

48 version: str 

49 description: str 

50 source: str # pypi | github | local 

51 installable: bool = True 

52 pypi_package: str = "" 

53 skill_count: int = 0 

54 

55 

56@dataclass 

57class InstallResult: 

58 """安装结果。""" 

59 success: bool 

60 manifest: Optional[SkillManifest] = None 

61 error: str = "" 

62 pypi_package: str = "" 

63 install_type: str = "" # pypi_install | local_copy | git_clone 

64 dep_installed: list[str] = field(default_factory=list) 

65 

66 

67class SkillRegistry: 

68 """技能市场注册表。 

69 

70 支持三种安装源: 

71 1. PyPI 包(agentos-skill-* 前缀) 

72 2. 本地目录(含 manifest.yaml/json) 

73 3. GitHub 仓库(git clone + pip install) 

74 

75 每个 skill 安装后: 

76 - manifest 存入 installed.json 

77 - 源文件复制到 ~/.agentos/marketplace/skills/<name>/ 

78 - pip 依赖自动安装 

79 """ 

80 

81 def __init__(self): 

82 self._ensure_dirs() 

83 

84 # ── 搜索 ── 

85 

86 def search(self, query: str = "", max_results: int = 20) -> list[SearchResult]: 

87 """搜索技能市场。query 为空时返回热门。""" 

88 results: list[SearchResult] = [] 

89 

90 # 1. 搜索 PyPI(agentos-skill-*) 

91 try: 

92 r = subprocess.run( 

93 [sys.executable, "-m", "pip", "search", f"{PYPI_SKILL_PREFIX}{query}"] if query else 

94 [sys.executable, "-m", "pip", "search", PYPI_SKILL_PREFIX], 

95 capture_output=True, text=True, timeout=15, 

96 env={**os.environ, "PIP_DISABLE_PIP_VERSION_CHECK": "1"}, 

97 ) 

98 for line in r.stdout.split("\n"): 

99 line = line.strip() 

100 if line and PYPI_SKILL_PREFIX in line and not line.startswith("ERROR"): 

101 parts = line.split() 

102 pkg_name = parts[0] 

103 version = parts[1].lstrip("(").rstrip(")") if len(parts) > 1 else "?" 

104 desc = " ".join(parts[2:]) if len(parts) > 2 else "" 

105 skill_name = pkg_name.replace(PYPI_SKILL_PREFIX, "").replace("-", "_") 

106 # 去重 

107 if not any(r.name == skill_name for r in results): 

108 results.append(SearchResult( 

109 name=skill_name, 

110 version=version, 

111 description=desc, 

112 source="pypi", 

113 pypi_package=pkg_name, 

114 )) 

115 except Exception: 

116 pass 

117 

118 # 2. 如果 pip search 不可用,尝试直接查 PyPI JSON API 

119 if not results and query: 

120 try: 

121 import urllib.request 

122 url = f"https://pypi.org/pypi/{PYPI_SKILL_PREFIX}{query}/json" 

123 req = urllib.request.Request(url, headers={"User-Agent": "AgentOS-Marketplace/1.0"}) 

124 with urllib.request.urlopen(req, timeout=8) as resp: 

125 data = json.loads(resp.read()) 

126 info = data.get("info", {}) 

127 skill_name = query.replace("-", "_") 

128 results.append(SearchResult( 

129 name=skill_name, 

130 version=info.get("version", "?"), 

131 description=info.get("summary", ""), 

132 source="pypi", 

133 pypi_package=f"{PYPI_SKILL_PREFIX}{query}", 

134 )) 

135 except Exception: 

136 pass 

137 

138 # 3. 限制结果数 

139 return results[:max_results] 

140 

141 def list_installed(self) -> list[SkillManifest]: 

142 """列出所有已安装 skill。""" 

143 data = self._load_installed() 

144 manifests = [] 

145 for entry in data.get("skills", []): 

146 m = SkillManifest.from_dict(entry) 

147 if m.name: 

148 manifests.append(m) 

149 return sorted(manifests, key=lambda m: m.name) 

150 

151 def get_installed(self, name: str) -> Optional[SkillManifest]: 

152 """获取已安装 skill 的 manifest。""" 

153 for m in self.list_installed(): 

154 if m.name == name: 

155 return m 

156 return None 

157 

158 # ── 安装 ── 

159 

160 def install(self, name_or_path: str) -> InstallResult: 

161 """安装一个 skill。 

162 

163 自动判断安装源: 

164 1. 本地目录(包含 manifest.yaml/json 时复制安装) 

165 2. Git URL(含 github.com 时 git clone) 

166 3. PyPI 包(pip install agentos-skill-<name>) 

167 """ 

168 target = name_or_path.strip() 

169 

170 # ─ 源 1: 本地目录 ─ 

171 local_path = Path(name_or_path) 

172 if local_path.is_dir(): 

173 manifest_file = local_path / "skill.yaml" 

174 if not manifest_file.exists(): 

175 manifest_file = local_path / "skill.json" 

176 if not manifest_file.exists(): 

177 manifest_file = local_path / "manifest.yaml" 

178 if not manifest_file.exists(): 

179 manifest_file = local_path / "manifest.json" 

180 if manifest_file.exists(): 

181 return self._install_local(local_path, manifest_file) 

182 return InstallResult(success=False, error=f"No manifest (skill.yaml/json) found in {local_path}") 

183 

184 # ─ 源 2: GitHub URL ─ 

185 if "github.com" in name_or_path: 

186 return self._install_github(name_or_path) 

187 

188 # ─ 源 3: PyPI 包 ─ 

189 return self._install_pypi(target) 

190 

191 def uninstall(self, name: str) -> bool: 

192 """卸载一个 skill。""" 

193 existing = self.get_installed(name) 

194 if not existing: 

195 return False 

196 

197 # 删除 skill 目录 

198 skill_dir = SKILLS_DIR / name 

199 if skill_dir.exists(): 

200 shutil.rmtree(skill_dir) 

201 

202 # 更新 installed.json 

203 data = self._load_installed() 

204 data["skills"] = [s for s in data.get("skills", []) if s.get("name") != name] 

205 self._save_installed(data) 

206 return True 

207 

208 def update(self, name: str) -> InstallResult: 

209 """更新一个 skill 到最新版。""" 

210 existing = self.get_installed(name) 

211 if not existing: 

212 return InstallResult(success=False, error=f"Skill '{name}' not installed.") 

213 

214 # 先卸载再重新安装 

215 old_source = existing.source 

216 self.uninstall(name) 

217 

218 if old_source == "pypi": 

219 return self._install_pypi(name) 

220 elif old_source == "github": 

221 return self._install_github(existing.repository or f"https://github.com/{name}") 

222 elif old_source == "local": 

223 return self._install_local(Path(existing.install_path), Path(existing.install_path) / "skill.yaml") 

224 

225 return InstallResult(success=False, error=f"Unknown source: {old_source}") 

226 

227 def stats(self) -> dict: 

228 """市场统计。""" 

229 installed = self.list_installed() 

230 by_format = {} 

231 for m in installed: 

232 fmt = m.format.value 

233 by_format[fmt] = by_format.get(fmt, 0) + 1 

234 return { 

235 "total": len(installed), 

236 "by_format": by_format, 

237 "market_dir": str(MARKET_DIR), 

238 } 

239 

240 def _check_duplicate(self, name: str) -> Optional[InstallResult]: 

241 """如果 skill 已安装,返回失败结果;否则返回 None。""" 

242 existing = self.get_installed(name) 

243 if existing: 

244 return InstallResult( 

245 success=False, 

246 error=f"Skill '{name}' already installed (v{existing.version}). Use 'marketplace update {name}' to upgrade.", 

247 ) 

248 return None 

249 

250 def _install_pypi(self, name: str) -> InstallResult: 

251 """从 PyPI 安装 agentos-skill-<name>。""" 

252 pkg = f"{PYPI_SKILL_PREFIX}{name}" 

253 try: 

254 r = subprocess.run( 

255 [sys.executable, "-m", "pip", "install", pkg, "--quiet", "--disable-pip-version-check"], 

256 capture_output=True, text=True, timeout=120, 

257 ) 

258 if r.returncode != 0: 

259 return InstallResult(success=False, error=f"pip install failed: {r.stderr[:200]}") 

260 except subprocess.TimeoutExpired: 

261 return InstallResult(success=False, error="pip install timed out") 

262 

263 # 查找安装后的包位置,读取 manifest 

264 manifest = self._find_package_manifest(pkg) 

265 if not manifest: 

266 # 没有 manifest 的 PyPI 包也注册为 generic skill 

267 manifest = SkillManifest( 

268 name=name, 

269 version="?", 

270 description=f"PyPI package: {pkg}", 

271 format=SkillFormat.GENERIC, 

272 source="pypi", 

273 ) 

274 manifest.source = "pypi" 

275 

276 # 复制到 skills 目录 

277 self._copy_to_skills(name, manifest) 

278 

279 # 注册 

280 self._register_manifest(manifest) 

281 return InstallResult( 

282 success=True, 

283 manifest=manifest, 

284 install_type="pypi_install", 

285 pypi_package=pkg, 

286 ) 

287 

288 def _install_local(self, local_path: Path, manifest_file: Path) -> InstallResult: 

289 """从本地目录安装。""" 

290 raw = manifest_file.read_text(encoding="utf-8") 

291 if manifest_file.suffix in (".yaml", ".yml"): 

292 import yaml 

293 data = yaml.safe_load(raw) or {} 

294 else: 

295 data = json.loads(raw) 

296 

297 manifest = SkillManifest.from_dict(data, source="local", install_path=str(local_path)) 

298 name = manifest.name or local_path.name 

299 

300 dup = self._check_duplicate(name) 

301 if dup: 

302 return dup 

303 

304 # 安装依赖 

305 deps = self._install_deps(manifest) 

306 

307 # 复制到 skills 目录 

308 dest = SKILLS_DIR / name 

309 if dest.exists(): 

310 shutil.rmtree(dest) 

311 shutil.copytree(local_path, dest) 

312 

313 manifest.install_path = str(dest) 

314 manifest.source = "local" 

315 self._register_manifest(manifest) 

316 

317 return InstallResult( 

318 success=True, 

319 manifest=manifest, 

320 install_type="local_copy", 

321 dep_installed=deps, 

322 ) 

323 

324 def _install_github(self, url: str) -> InstallResult: 

325 """从 GitHub 克隆安装。""" 

326 name = url.rstrip("/").split("/")[-1].replace(".git", "") 

327 dest = SKILLS_DIR / name 

328 

329 if dest.exists(): 

330 shutil.rmtree(dest) 

331 

332 try: 

333 r = subprocess.run( 

334 ["git", "clone", "--depth=1", url, str(dest)], 

335 capture_output=True, text=True, timeout=60, 

336 ) 

337 if r.returncode != 0: 

338 return InstallResult(success=False, error=f"git clone failed: {r.stderr[:200]}") 

339 except FileNotFoundError: 

340 return InstallResult(success=False, error="git not found. Install git first.") 

341 except subprocess.TimeoutExpired: 

342 return InstallResult(success=False, error="git clone timed out") 

343 

344 # 查找 manifest 文件 

345 manifest_file = None 

346 for fname in ("skill.yaml", "skill.json", "manifest.yaml", "manifest.json"): 

347 candidate = dest / fname 

348 if candidate.exists(): 

349 manifest_file = candidate 

350 break 

351 

352 if manifest_file: 

353 raw = manifest_file.read_text(encoding="utf-8") 

354 if manifest_file.suffix in (".yaml", ".yml"): 

355 import yaml 

356 data = yaml.safe_load(raw) 

357 else: 

358 data = json.loads(raw) 

359 manifest = SkillManifest.from_dict(data, source="github", install_path=str(dest)) 

360 else: 

361 manifest = SkillManifest( 

362 name=name, 

363 version="0.1.0", 

364 description=f"GitHub skill: {url}", 

365 format=SkillFormat.GENERIC, 

366 source="github", 

367 install_path=str(dest), 

368 repository=url, 

369 ) 

370 

371 manifest.source = "github" 

372 manifest.repository = url 

373 if not manifest.name: 

374 manifest.name = name 

375 

376 deps = self._install_deps(manifest) 

377 self._register_manifest(manifest) 

378 

379 return InstallResult( 

380 success=True, 

381 manifest=manifest, 

382 install_type="git_clone", 

383 dep_installed=deps, 

384 ) 

385 

386 def _install_deps(self, manifest: SkillManifest) -> list[str]: 

387 """安装 skill 的 pip 依赖,返回成功安装的包名列表。""" 

388 installed = [] 

389 for dep in manifest.dependencies: 

390 try: 

391 subprocess.run( 

392 [sys.executable, "-m", "pip", "install", dep, "--quiet", "--disable-pip-version-check"], 

393 capture_output=True, timeout=60, 

394 ) 

395 installed.append(dep) 

396 except Exception: 

397 pass 

398 return installed 

399 

400 def _find_package_manifest(self, pkg_name: str) -> Optional[SkillManifest]: 

401 """从已安装的 PyPI 包中查找 manifest。""" 

402 try: 

403 r = subprocess.run( 

404 [sys.executable, "-m", "pip", "show", "-f", pkg_name], 

405 capture_output=True, text=True, timeout=10, 

406 ) 

407 if r.returncode != 0: 

408 return None 

409 

410 # 找 Location 行 

411 location = "" 

412 for line in r.stdout.split("\n"): 

413 if line.startswith("Location:"): 

414 location = line.split(":", 1)[1].strip() 

415 break 

416 

417 if not location: 

418 return None 

419 

420 # 尝试常见 manifest 路径 

421 pkg_name_clean = pkg_name.replace("-", "_") 

422 candidates = [ 

423 Path(location) / pkg_name_clean / "skill.yaml", 

424 Path(location) / pkg_name_clean / "skill.json", 

425 Path(location) / pkg_name_clean / "manifest.yaml", 

426 Path(location) / pkg_name_clean / "manifest.json", 

427 ] 

428 for p in candidates: 

429 if p.exists(): 

430 raw = p.read_text(encoding="utf-8") 

431 if p.suffix in (".yaml", ".yml"): 

432 import yaml 

433 data = yaml.safe_load(raw) 

434 else: 

435 data = json.loads(raw) 

436 return SkillManifest.from_dict(data, source="pypi") 

437 except Exception: 

438 pass 

439 return None 

440 

441 def _copy_to_skills(self, name: str, manifest: SkillManifest): 

442 """确保 skill 源文件在 skills 目录有一份。""" 

443 dest = SKILLS_DIR / name 

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

445 manifest_path = dest / "manifest.yaml" 

446 import yaml 

447 manifest_path.write_text( 

448 yaml.dump(manifest.to_dict(), allow_unicode=True, default_flow_style=False, sort_keys=False), 

449 encoding="utf-8", 

450 ) 

451 

452 def _register_manifest(self, manifest: SkillManifest): 

453 """将 manifest 注册到 installed.json。""" 

454 data = self._load_installed() 

455 # 去重 

456 data["skills"] = [s for s in data.get("skills", []) if s.get("name") != manifest.name] 

457 entry = manifest.to_dict() 

458 entry["installed_at"] = time.time() 

459 data["skills"].append(entry) 

460 self._save_installed(data) 

461 

462 def _load_installed(self) -> dict: 

463 MARKET_DIR.mkdir(parents=True, exist_ok=True) 

464 if not INSTALLED_JSON.exists(): 

465 return {"version": "1.0", "skills": []} 

466 try: 

467 return json.loads(INSTALLED_JSON.read_text(encoding="utf-8")) 

468 except (json.JSONDecodeError, Exception): 

469 return {"version": "1.0", "skills": []} 

470 

471 def _save_installed(self, data: dict): 

472 MARKET_DIR.mkdir(parents=True, exist_ok=True) 

473 INSTALLED_JSON.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") 

474 

475 def _ensure_dirs(self): 

476 MARKET_DIR.mkdir(parents=True, exist_ok=True) 

477 SKILLS_DIR.mkdir(parents=True, exist_ok=True)