codebee 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +392 -0
- package/app/__init__.py +0 -0
- package/app/core/__init__.py +0 -0
- package/app/core/attachments.py +322 -0
- package/app/core/automation.py +585 -0
- package/app/core/bookmeta.py +296 -0
- package/app/core/capability.py +130 -0
- package/app/core/catalog.py +319 -0
- package/app/core/compaction.py +186 -0
- package/app/core/diagnostics.py +115 -0
- package/app/core/env_scrub.py +84 -0
- package/app/core/error_codes.py +65 -0
- package/app/core/flows.py +328 -0
- package/app/core/gitmod.py +949 -0
- package/app/core/goal_service.py +159 -0
- package/app/core/health.py +294 -0
- package/app/core/history.py +32 -0
- package/app/core/jobs.py +424 -0
- package/app/core/manager.py +1415 -0
- package/app/core/market.py +299 -0
- package/app/core/market_remote.py +896 -0
- package/app/core/mocks.py +64 -0
- package/app/core/modelhub.py +2750 -0
- package/app/core/paths.py +60 -0
- package/app/core/pipeline.py +2161 -0
- package/app/core/planner.py +493 -0
- package/app/core/registry.py +105 -0
- package/app/core/remote.py +303 -0
- package/app/core/repeat_guard.py +124 -0
- package/app/core/router.py +120 -0
- package/app/core/runner.py +856 -0
- package/app/core/selfupdate.py +170 -0
- package/app/core/session_log.py +162 -0
- package/app/core/sessions.py +312 -0
- package/app/core/settings.py +85 -0
- package/app/core/settings_schema.py +250 -0
- package/app/core/skillpacks/fanqie-novel.md +80 -0
- package/app/core/skillpacks/market/character-bible.md +66 -0
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -0
- package/app/core/skillpacks/market/git-workflow.md +57 -0
- package/app/core/skillpacks/market/release-notes.md +72 -0
- package/app/core/skillpacks/market/weekly-report.md +71 -0
- package/app/core/skillpacks/market/worldview-consistency.md +70 -0
- package/app/core/skillpacks/qimao-signing.md +105 -0
- package/app/core/skills.py +649 -0
- package/app/core/step_runner.py +61 -0
- package/app/core/store.py +1321 -0
- package/app/core/token_meter.py +130 -0
- package/app/core/usage.py +450 -0
- package/app/main.py +1448 -0
- package/app/ui/app.js +8021 -0
- package/app/ui/i18n.js +1709 -0
- package/app/ui/icons/brand-horizontal.png +0 -0
- package/app/ui/icons/brand-square.png +0 -0
- package/app/ui/icons/icon-192.png +0 -0
- package/app/ui/icons/icon-512.png +0 -0
- package/app/ui/icons/logo-horizontal.png +0 -0
- package/app/ui/icons/logo-mark.png +0 -0
- package/app/ui/index.html +864 -0
- package/app/ui/manifest.json +16 -0
- package/app/ui/qrcode.js +2297 -0
- package/app/ui/style.css +2733 -0
- package/bin/tutti.js +121 -0
- package/package.json +39 -0
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""外部插件目录(market_remote):把公开生态的市场清单接入插件市场。
|
|
3
|
+
|
|
4
|
+
分工:market.py 负责安装机制(避让/标记/记账),本模块只做三件事:
|
|
5
|
+
1) 拉取:从公开市场清单(marketplace.json,ZCode CDN 与 Anthropic 生态同一份
|
|
6
|
+
格式契约)拉目录,缓存到 data/market_remote/<source_id>.json。只在用户点
|
|
7
|
+
「拉取更新」时联网,页面加载只读缓存,绝不后台偷跑;
|
|
8
|
+
2) 甄别:目录阶段先按元数据做「不适配」预分类(含 MCP/钩子/命令组件的灰显);
|
|
9
|
+
安装阶段下载插件包(zip+sha256 或 git 子目录)后做权威的「纯技能类」白名单
|
|
10
|
+
检查——技能文本会被注入给智能体当守则,任何可执行件(脚本/钩子/MCP 配置)
|
|
11
|
+
都等于供应链注入面,一律拒绝;
|
|
12
|
+
3) 落地:把 skills/*/SKILL.md 重写 frontmatter(加 market 安装标记)转成
|
|
13
|
+
skillpack,复用 market.install_files 的安装通道(避让、记账、卸载全沿用)。
|
|
14
|
+
|
|
15
|
+
网络边界(安全约束):仅 https;请求前解析 host 并拒绝环回/私有/保留地址;
|
|
16
|
+
响应体、解包总量、文本体量都有上限,防炸弹。清单格式两家略有差异(zcode 是
|
|
17
|
+
zip 直链,Anthropic 生态是 git 仓库子目录),解析时归一成同一种条目。
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import io
|
|
23
|
+
import os
|
|
24
|
+
import ipaddress
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
import shutil
|
|
28
|
+
import socket
|
|
29
|
+
import subprocess
|
|
30
|
+
import tarfile
|
|
31
|
+
import tempfile
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
import zipfile
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from urllib.parse import urlparse
|
|
38
|
+
|
|
39
|
+
from . import market
|
|
40
|
+
|
|
41
|
+
_LOCK = market._LOCK # 安装/记账与 market 共用一把锁,避免交叉写 market.json
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------- 目录来源
|
|
44
|
+
|
|
45
|
+
# 公开市场清单;urls 按序试(raw.githubusercontent 在部分网络不可达,镜像优先)。
|
|
46
|
+
# kind: marketplace=标准 marketplace.json;clawhub=ClawHub 注册表(列表+trending
|
|
47
|
+
# 合成目录,zip 直下)。repo=条目用相对路径指子目录时,克隆这个仓库。
|
|
48
|
+
SOURCES = [
|
|
49
|
+
{"id": "zcode", "name": "ZCode 官方", "kind": "marketplace",
|
|
50
|
+
"urls": ["https://cdn-zcode.z.ai/zcode/official-plugin/marketplace.json"]},
|
|
51
|
+
{"id": "anthropic", "name": "Anthropic 生态", "kind": "marketplace",
|
|
52
|
+
"urls": [
|
|
53
|
+
"https://cdn.jsdelivr.net/gh/anthropics/claude-plugins-official@main/.claude-plugin/marketplace.json",
|
|
54
|
+
"https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json",
|
|
55
|
+
]},
|
|
56
|
+
{"id": "anthropic-skills", "name": "Anthropic 官方技能", "kind": "marketplace",
|
|
57
|
+
"repo": "https://github.com/anthropics/skills.git",
|
|
58
|
+
"urls": [
|
|
59
|
+
"https://cdn.jsdelivr.net/gh/anthropics/skills@main/.claude-plugin/marketplace.json",
|
|
60
|
+
"https://raw.githubusercontent.com/anthropics/skills/main/.claude-plugin/marketplace.json",
|
|
61
|
+
]},
|
|
62
|
+
{"id": "claude-skills", "name": "社区技能库(Codex/Gemini 兼容)", "kind": "marketplace",
|
|
63
|
+
"repo": "https://github.com/alirezarezvani/claude-skills.git",
|
|
64
|
+
"urls": [
|
|
65
|
+
"https://cdn.jsdelivr.net/gh/alirezarezvani/claude-skills@main/.claude-plugin/marketplace.json",
|
|
66
|
+
"https://raw.githubusercontent.com/alirezarezvani/claude-skills/main/.claude-plugin/marketplace.json",
|
|
67
|
+
]},
|
|
68
|
+
{"id": "clawhub", "name": "ClawHub(OpenClaw 生态)", "kind": "clawhub",
|
|
69
|
+
"urls": ["https://clawhub.ai/api/v1/skills?limit=50",
|
|
70
|
+
"https://clawhub.ai/api/v1/trending"]},
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
# 外部目录视图:服务端对全量缓存过滤后分页下发(缓存全在本地,翻页零成本)。
|
|
74
|
+
# 单页默认 60,UI 滚动到底自动续下一页。
|
|
75
|
+
_PAGE_LIMIT = 60
|
|
76
|
+
|
|
77
|
+
_CLAWHUB_BASE = "https://clawhub.ai/api/v1"
|
|
78
|
+
_CLAWHUB_PAGES = 4 # 技能列表最多翻页数(50/页,覆盖最新 ~200 个)
|
|
79
|
+
_CLAWHUB_TRENDING = 20 # trending 榕入目录的条数
|
|
80
|
+
|
|
81
|
+
SOURCES_BY_ID = {s["id"]: s for s in SOURCES}
|
|
82
|
+
|
|
83
|
+
# 体量上限(防炸弹):清单 5MB、插件包下载 80MB、解包总量 120MB、文件数 500、
|
|
84
|
+
# 单文本文件 512KB、转成 skillpack 的文本总量 4MB
|
|
85
|
+
_CAP_MANIFEST = 5 * 1024 * 1024
|
|
86
|
+
_CAP_DOWNLOAD = 80 * 1024 * 1024
|
|
87
|
+
_CAP_UNPACKED = 120 * 1024 * 1024
|
|
88
|
+
_CAP_FILES = 500
|
|
89
|
+
_CAP_FILE_TEXT = 512 * 1024
|
|
90
|
+
_CAP_TOTAL_TEXT = 4 * 1024 * 1024
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _cache_dir():
|
|
94
|
+
"""清单缓存目录:随 paths.DATA_DIR 现取(测试重定向后自动跟随)。"""
|
|
95
|
+
return market.paths.DATA_DIR / "market_remote"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _cache_file(source_id):
|
|
99
|
+
return _cache_dir() / ("%s.json" % source_id)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ---------------------------------------------------------------- 网络边界
|
|
103
|
+
|
|
104
|
+
def assert_public_url(url):
|
|
105
|
+
"""SSRF 防护:仅 https;host 解析出的所有 IP 都不得是环回/私有/保留地址。
|
|
106
|
+
返回 (host, port),不合法直接抛 ValueError。"""
|
|
107
|
+
u = urlparse(url or "")
|
|
108
|
+
if u.scheme != "https":
|
|
109
|
+
raise ValueError("仅允许 https 地址: %s" % url)
|
|
110
|
+
host = u.hostname
|
|
111
|
+
if not host:
|
|
112
|
+
raise ValueError("URL 缺少主机名: %s" % url)
|
|
113
|
+
if u.port is not None and not (0 < u.port < 65536):
|
|
114
|
+
raise ValueError("端口非法: %s" % url)
|
|
115
|
+
port = u.port or 443
|
|
116
|
+
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
|
117
|
+
if not infos:
|
|
118
|
+
raise ValueError("主机无法解析: %s" % host)
|
|
119
|
+
for info in infos:
|
|
120
|
+
ip = ipaddress.ip_address(info[4][0])
|
|
121
|
+
if (ip.is_loopback or ip.is_private or ip.is_link_local
|
|
122
|
+
or ip.is_reserved or ip.is_multicast or ip.is_unspecified):
|
|
123
|
+
raise ValueError("拒绝非公网地址 %s(host=%s)" % (ip, host))
|
|
124
|
+
return host, port
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _fetch(url, cap=_CAP_MANIFEST):
|
|
128
|
+
"""带体量上限的 https GET;返回 bytes。
|
|
129
|
+
|
|
130
|
+
两段式网络策略:先走默认通道(Windows 上 urllib 会读注册表系统代理,
|
|
131
|
+
依赖代理上网的环境靠它),失败再自动直连重试一次(实测本机代理对部分
|
|
132
|
+
CDN 文件返回 404/篡改,直连正常;反过来需要代理的网络第一段就成功)。
|
|
133
|
+
体量超限属确定性错误,不重试。"""
|
|
134
|
+
assert_public_url(url)
|
|
135
|
+
req = urllib.request.Request(url, headers={
|
|
136
|
+
"User-Agent": "CodeBee-Market/1.0",
|
|
137
|
+
"Accept": "*/*",
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
def _read(open_fn):
|
|
141
|
+
with open_fn() as resp:
|
|
142
|
+
chunks, total = [], 0
|
|
143
|
+
while True:
|
|
144
|
+
chunk = resp.read(256 * 1024)
|
|
145
|
+
if not chunk:
|
|
146
|
+
break
|
|
147
|
+
total += len(chunk)
|
|
148
|
+
if total > cap:
|
|
149
|
+
raise ValueError("响应超过体量上限(%d MB): %s" % (cap // 1048576, url))
|
|
150
|
+
chunks.append(chunk)
|
|
151
|
+
return b"".join(chunks)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
return _read(lambda: urllib.request.urlopen(req, timeout=30)) # 默认 opener:含系统代理
|
|
155
|
+
except (urllib.error.HTTPError, urllib.error.URLError):
|
|
156
|
+
# 直连重试:ProxyHandler({}) 显式清空代理
|
|
157
|
+
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
158
|
+
return _read(lambda: opener.open(req, timeout=30))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------- 清单解析
|
|
162
|
+
|
|
163
|
+
def _pick_i18n(entry, *keys):
|
|
164
|
+
"""zcode 条目的 *_i18n.zh-CN 优先,缺省回落同名字段。"""
|
|
165
|
+
for k in keys:
|
|
166
|
+
i18n = entry.get(k + "_i18n") or {}
|
|
167
|
+
if isinstance(i18n, dict) and i18n.get("zh-CN"):
|
|
168
|
+
return str(i18n["zh-CN"]).strip()
|
|
169
|
+
if entry.get(k):
|
|
170
|
+
return str(entry[k]).strip()
|
|
171
|
+
return ""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _normalize(source_meta, entry):
|
|
175
|
+
"""各家清单条目 → 统一形态;id 直接用 market 记账 id(remote-<源>-<名>),
|
|
176
|
+
同时是安装标记 market_id(标记字符集只允许 [A-Za-z0-9_.-],正好兼容)。"""
|
|
177
|
+
name = str(entry.get("name") or "").strip()
|
|
178
|
+
if not name:
|
|
179
|
+
return None
|
|
180
|
+
src = entry.get("source")
|
|
181
|
+
if isinstance(src, dict):
|
|
182
|
+
src_d = dict(src)
|
|
183
|
+
else:
|
|
184
|
+
# 仓库相对路径("./engineering" 或 "./"):克隆清单所属仓库取子目录;
|
|
185
|
+
# entry.skills 显式列出本插件包含的技能目录(安装时按名过滤)
|
|
186
|
+
src_d = {"source": "repo-relative", "path": str(src or "./")}
|
|
187
|
+
if src_d.get("type") == "zip" and src_d.get("url"):
|
|
188
|
+
install = {"kind": "zip", "url": str(src_d["url"]),
|
|
189
|
+
"sha256": str(src_d.get("sha256") or ""), "path": str(src_d.get("path") or "")}
|
|
190
|
+
elif src_d.get("source") == "git-subdir" and src_d.get("url"):
|
|
191
|
+
install = {"kind": "git", "url": str(src_d["url"]),
|
|
192
|
+
"sha256": "", "path": str(src_d.get("path") or ""),
|
|
193
|
+
"ref": str(src_d.get("ref") or "")}
|
|
194
|
+
elif src_d.get("source") == "repo-relative":
|
|
195
|
+
if not source_meta.get("repo"):
|
|
196
|
+
install = {"kind": "unsupported"}
|
|
197
|
+
else:
|
|
198
|
+
install = {"kind": "git", "url": source_meta["repo"], "sha256": "",
|
|
199
|
+
"path": str(src_d.get("path") or "").lstrip("./"),
|
|
200
|
+
"ref": "",
|
|
201
|
+
"skills": _skill_names(entry.get("skills"))}
|
|
202
|
+
elif src_d.get("source") == "clawhub":
|
|
203
|
+
install = {"kind": "clawhub", "slug": str(src_d.get("slug") or ""),
|
|
204
|
+
"reference": str(src_d.get("reference") or ""),
|
|
205
|
+
"url": src_d.get("url") or ""}
|
|
206
|
+
else:
|
|
207
|
+
install = {"kind": "unsupported"}
|
|
208
|
+
author = entry.get("author") or {}
|
|
209
|
+
if isinstance(author, dict):
|
|
210
|
+
author = author.get("name") or ""
|
|
211
|
+
return {
|
|
212
|
+
"id": "remote-%s-%s" % (source_meta["id"], re.sub(r"[^A-Za-z0-9_.-]", "-", name).strip("-")),
|
|
213
|
+
"name": name,
|
|
214
|
+
"title": _pick_i18n(entry, "displayName") or name,
|
|
215
|
+
"desc": _pick_i18n(entry, "description"),
|
|
216
|
+
"category": str(entry.get("category") or "").strip(),
|
|
217
|
+
"author": str(author or "").strip(),
|
|
218
|
+
"version": str(entry.get("version") or "").strip(),
|
|
219
|
+
"homepage": str(entry.get("homepage") or "").strip(),
|
|
220
|
+
"keywords": [str(k).lower() for k in (entry.get("keywords") or []) if isinstance(k, str)],
|
|
221
|
+
"source_id": source_meta["id"],
|
|
222
|
+
"source_name": source_meta["name"],
|
|
223
|
+
"install": install,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _skill_names(skills_list):
|
|
228
|
+
"""entry.skills 的 ["./skills/xlsx", ...] → 技能目录名 ["xlsx"]。"""
|
|
229
|
+
out = []
|
|
230
|
+
for s in skills_list or []:
|
|
231
|
+
parts = [p for p in str(s).replace("\\", "/").split("/") if p and p != "."]
|
|
232
|
+
if parts:
|
|
233
|
+
out.append(parts[-1])
|
|
234
|
+
return out
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# 不适配预分类:只看元数据给 UI 灰显(权威判定在安装时逐文件检查)。
|
|
238
|
+
# 宽松些没关系——灰显条目装不了只是少个入口,误灰可点不进来但安装期还会拦。
|
|
239
|
+
_MCP_RE = re.compile(r"\bMCP\b")
|
|
240
|
+
_HOOK_RE = re.compile(r"\bhooks?\b", re.IGNORECASE)
|
|
241
|
+
_CMD_WORDS = {"commands", "slash-commands", "hooks", "mcp", "scripts"}
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _compat_block(entry):
|
|
245
|
+
"""返回 None=预检通过,或不适配原因字符串。
|
|
246
|
+
剥离式安装后,脚本/钩子/MCP 组件在安装时自动剔除,不再作为灰显依据——
|
|
247
|
+
只有来源类型本身不支持(无法下载)才预灰显。"""
|
|
248
|
+
if entry["install"]["kind"] == "unsupported":
|
|
249
|
+
return "来源类型不支持(仅支持 zip 直链、git 子目录与 ClawHub)"
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _load_cache():
|
|
254
|
+
"""读全部来源缓存 → {source_id: {"fetched_at","url","catalog"(原始 dict)}}。"""
|
|
255
|
+
out = {}
|
|
256
|
+
try:
|
|
257
|
+
for p in _cache_dir().glob("*.json"):
|
|
258
|
+
try:
|
|
259
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
260
|
+
except Exception:
|
|
261
|
+
continue
|
|
262
|
+
if isinstance(d, dict) and d.get("catalog"):
|
|
263
|
+
out[p.stem] = d
|
|
264
|
+
except OSError:
|
|
265
|
+
pass
|
|
266
|
+
return out
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _entries_from_cache(cache=None):
|
|
270
|
+
"""缓存清单 → 统一条目列表(附 installed / compat)。"""
|
|
271
|
+
cache = cache if cache is not None else _load_cache()
|
|
272
|
+
src_meta = {s["id"]: s for s in SOURCES}
|
|
273
|
+
installed = market.installed_ids()
|
|
274
|
+
entries = []
|
|
275
|
+
for sid in [s["id"] for s in SOURCES]:
|
|
276
|
+
c = cache.get(sid)
|
|
277
|
+
if not c:
|
|
278
|
+
continue
|
|
279
|
+
sm = src_meta.get(sid) or {"id": sid, "name": sid}
|
|
280
|
+
for raw in (c["catalog"].get("plugins") or []):
|
|
281
|
+
e = _normalize(sm, raw if isinstance(raw, dict) else {})
|
|
282
|
+
if not e:
|
|
283
|
+
continue
|
|
284
|
+
e["compat"] = "blocked" if _compat_block(e) else "ok"
|
|
285
|
+
e["block_reason"] = _compat_block(e) or ""
|
|
286
|
+
e["installed"] = e["id"] in installed
|
|
287
|
+
e["installable"] = e["compat"] == "ok"
|
|
288
|
+
entries.append(e)
|
|
289
|
+
entries.sort(key=lambda x: (x["source_id"], x["name"].lower()))
|
|
290
|
+
return entries
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def view(offset=0, limit=_PAGE_LIMIT, source=None, q=None):
|
|
294
|
+
"""外部目录视图(服务端过滤 + 分页)。缓存全在本地,对全量条目过滤再切页
|
|
295
|
+
零成本——搜索/来源筛选不再受「已加载页」限制,滚动翻页逛完全部目录。
|
|
296
|
+
返回 {sources, entries(本页), total(过滤后全量), offset, has_more,
|
|
297
|
+
categories}。"""
|
|
298
|
+
cache = _load_cache()
|
|
299
|
+
entries = _entries_from_cache(cache)
|
|
300
|
+
sources = []
|
|
301
|
+
for s in SOURCES:
|
|
302
|
+
c = cache.get(s["id"]) or {}
|
|
303
|
+
sources.append({"id": s["id"], "name": s["name"],
|
|
304
|
+
"fetched_at": c.get("fetched_at") or "",
|
|
305
|
+
"count": len((c.get("catalog") or {}).get("plugins") or [])})
|
|
306
|
+
cats = []
|
|
307
|
+
for e in entries:
|
|
308
|
+
if e["category"] and e["category"] not in cats:
|
|
309
|
+
cats.append(e["category"])
|
|
310
|
+
if source:
|
|
311
|
+
entries = [e for e in entries if e["source_id"] == source]
|
|
312
|
+
qq = str(q or "").strip().lower()
|
|
313
|
+
if qq:
|
|
314
|
+
entries = [e for e in entries if qq in (e["name"] or "").lower()
|
|
315
|
+
or qq in (e["title"] or "").lower()
|
|
316
|
+
or qq in (e["desc"] or "").lower()]
|
|
317
|
+
offset = max(0, int(offset or 0))
|
|
318
|
+
limit = max(1, min(int(limit or _PAGE_LIMIT), 500))
|
|
319
|
+
page = entries[offset:offset + limit]
|
|
320
|
+
return {"sources": sources, "entries": page, "total": len(entries),
|
|
321
|
+
"offset": offset, "has_more": offset + limit < len(entries),
|
|
322
|
+
"categories": cats}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _fetch_clawhub_catalog():
|
|
326
|
+
"""ClawHub 注册表 → 合成标准 plugins 目录:最新技能列表翻几页 +
|
|
327
|
+
trending 榜(打 trending 关键词),按 slug 去重。"""
|
|
328
|
+
seen, plugins = set(), []
|
|
329
|
+
|
|
330
|
+
def add(item, extra_keywords=()):
|
|
331
|
+
slug = str(item.get("slug") or "").strip()
|
|
332
|
+
if not slug or slug in seen:
|
|
333
|
+
return
|
|
334
|
+
seen.add(slug)
|
|
335
|
+
stats = item.get("stats") or {}
|
|
336
|
+
latest = item.get("latestVersion") or {}
|
|
337
|
+
tags = item.get("tags") or {}
|
|
338
|
+
# reference:合成条目带在 source 里,原生 skills 列表带在 install 里
|
|
339
|
+
ref = (str((item.get("source") or {}).get("reference") or "")
|
|
340
|
+
or str((item.get("install") or {}).get("reference") or "")
|
|
341
|
+
or slug)
|
|
342
|
+
plugins.append({
|
|
343
|
+
"name": slug,
|
|
344
|
+
"displayName": str(item.get("displayName") or slug),
|
|
345
|
+
"description": str(item.get("summary") or item.get("description")
|
|
346
|
+
or item.get("displayName") or slug)[:400],
|
|
347
|
+
"version": str(tags.get("latest") or latest.get("version") or ""),
|
|
348
|
+
"keywords": [str(t) for t in (item.get("topics") or [])][:8] + list(extra_keywords),
|
|
349
|
+
"stats": {"downloads": stats.get("downloads")},
|
|
350
|
+
"source": {"source": "clawhub", "slug": slug, "reference": ref},
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
url = SOURCES_BY_ID["clawhub"]["urls"][0]
|
|
354
|
+
cursor = ""
|
|
355
|
+
for _page in range(_CLAWHUB_PAGES):
|
|
356
|
+
page_url = url + ("&cursor=" + cursor if cursor else "")
|
|
357
|
+
d = json.loads(_fetch(page_url).decode("utf-8", errors="replace"))
|
|
358
|
+
for it in (d.get("items") or []):
|
|
359
|
+
add(it if isinstance(it, dict) else {})
|
|
360
|
+
cursor = str(d.get("nextCursor") or "")
|
|
361
|
+
if not cursor:
|
|
362
|
+
break
|
|
363
|
+
try:
|
|
364
|
+
d = json.loads(_fetch(SOURCES_BY_ID["clawhub"]["urls"][1]).decode("utf-8", errors="replace"))
|
|
365
|
+
for it in (d.get("items") or [])[:_CLAWHUB_TRENDING]:
|
|
366
|
+
if isinstance(it, dict):
|
|
367
|
+
ref = str((it.get("install") or {}).get("reference") or "")
|
|
368
|
+
add({"slug": ref.split("/")[-1] if ref else "",
|
|
369
|
+
"displayName": it.get("displayName"),
|
|
370
|
+
"summary": it.get("summary"),
|
|
371
|
+
"source": {"source": "clawhub", "slug": ref.split("/")[-1] if ref else "",
|
|
372
|
+
"reference": ref}}, ["trending"])
|
|
373
|
+
except Exception: # trending 拉不到不影响主列表
|
|
374
|
+
pass
|
|
375
|
+
return {"name": "clawhub", "description": "ClawHub skills", "plugins": plugins}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def refresh(source_id=None):
|
|
379
|
+
"""拉取目录清单(全部或指定来源),成功即写缓存。返回 view()。"""
|
|
380
|
+
results = []
|
|
381
|
+
for s in SOURCES:
|
|
382
|
+
if source_id and s["id"] != source_id:
|
|
383
|
+
continue
|
|
384
|
+
err, raw, used = None, None, ""
|
|
385
|
+
if s["kind"] == "clawhub":
|
|
386
|
+
try:
|
|
387
|
+
raw = _fetch_clawhub_catalog()
|
|
388
|
+
used = s["urls"][0]
|
|
389
|
+
except Exception as e:
|
|
390
|
+
err = "%s: %s" % ("clawhub.ai", e)
|
|
391
|
+
else:
|
|
392
|
+
for url in s["urls"]:
|
|
393
|
+
try:
|
|
394
|
+
raw = json.loads(_fetch(url).decode("utf-8", errors="replace"))
|
|
395
|
+
used = url
|
|
396
|
+
break
|
|
397
|
+
except Exception as e: # 换下一个镜像
|
|
398
|
+
err = "%s: %s" % (url.split("/")[2], e)
|
|
399
|
+
if raw is None or not isinstance(raw, dict):
|
|
400
|
+
results.append({"id": s["id"], "ok": False, "error": err or "响应不是 JSON 对象"})
|
|
401
|
+
continue
|
|
402
|
+
plugins = raw.get("plugins")
|
|
403
|
+
if not isinstance(plugins, list):
|
|
404
|
+
results.append({"id": s["id"], "ok": False, "error": "清单缺 plugins 数组"})
|
|
405
|
+
continue
|
|
406
|
+
f = _cache_file(s["id"])
|
|
407
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
408
|
+
tmp = f.with_suffix(".tmp")
|
|
409
|
+
tmp.write_text(json.dumps({
|
|
410
|
+
"fetched_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
411
|
+
"url": used,
|
|
412
|
+
"catalog": {"name": raw.get("name") or s["id"],
|
|
413
|
+
"description": raw.get("description") or "",
|
|
414
|
+
"plugins": plugins},
|
|
415
|
+
}, ensure_ascii=False), encoding="utf-8")
|
|
416
|
+
tmp.replace(f)
|
|
417
|
+
results.append({"id": s["id"], "ok": True, "count": len(plugins)})
|
|
418
|
+
v = view()
|
|
419
|
+
v["refresh"] = results
|
|
420
|
+
return v
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
# ---------------------------------------------------------------- 纯技能类检查
|
|
424
|
+
|
|
425
|
+
# 目录黑名单:这些组件需要宿主执行环境或外部服务,本平台没有对应通道
|
|
426
|
+
_BLOCK_DIRS = {"scripts", "hooks", "commands", "agents"}
|
|
427
|
+
# 可执行扩展名(出现即拒):技能文本会诱导智能体执行,等于供应链注入
|
|
428
|
+
_EXEC_EXT = {".py", ".sh", ".bash", ".js", ".mjs", ".cjs", ".ts", ".exe", ".bat",
|
|
429
|
+
".cmd", ".ps1", ".dll", ".so", ".dylib", ".bin", ".jar", ".php",
|
|
430
|
+
".rb", ".pl", ".lua", ".com", ".scr", ".vbs", ".wsf"}
|
|
431
|
+
# 允许的文本扩展(会被转成 skillpack 内容或随包资料)
|
|
432
|
+
_TEXT_EXT = {".md", ".markdown", ".txt", ".csv", ".json", ".yaml", ".yml"}
|
|
433
|
+
# 允许的图片扩展(随包资料;注入通道只带文本,图片仅落 assets)
|
|
434
|
+
_IMG_EXT = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico"}
|
|
435
|
+
# 无扩展名放行名单(仓库常见元文件);其余无扩展名文件(多为二进制/可执行)拒收
|
|
436
|
+
_NO_EXT_OK = {".gitignore", ".gitattributes", ".editorconfig", ".npmignore",
|
|
437
|
+
"license", "notice", "readme", "changelog", "codeowners", "authors"}
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _rel_parts(rel):
|
|
441
|
+
return [p for p in rel.replace("\\", "/").split("/") if p not in ("", ".")]
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _is_junk(rel):
|
|
445
|
+
"""zip 常见垃圾:macOS 元数据等,直接忽略不算违规。"""
|
|
446
|
+
parts = _rel_parts(rel)
|
|
447
|
+
return (not parts or "__MACOSX" in parts
|
|
448
|
+
or any(p in (".DS_Store", "Thumbs.db") for p in parts)
|
|
449
|
+
or any(p.startswith("._") for p in parts))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def inspect_tree(root, whitelist=None):
|
|
453
|
+
"""对插件根目录做剥离式安全检查:可执行件与脚本/钩子/命令/agents 目录、
|
|
454
|
+
MCP 配置不拒绝而是**剔除**(本平台不执行它们,它们只是供应链攻击面),
|
|
455
|
+
纯技能内容(文本/图片)保留。白名单给定时(多插件同仓库的 repo-relative
|
|
456
|
+
来源)范围收敛到白名单技能目录——同仓库其他插件的内容不参与。
|
|
457
|
+
返回 (files [(rel, abs)], stripped [rel]);硬错误(文件数超限)抛 ValueError。"""
|
|
458
|
+
files, stripped = [], []
|
|
459
|
+
wl = set(whitelist or ())
|
|
460
|
+
scanned = 0
|
|
461
|
+
for p in sorted(Path(root).rglob("*")):
|
|
462
|
+
if p.is_dir():
|
|
463
|
+
continue
|
|
464
|
+
scanned += 1
|
|
465
|
+
if scanned > _CAP_FILES * 4:
|
|
466
|
+
raise ValueError("文件数超过上限(%d)" % (_CAP_FILES * 4))
|
|
467
|
+
rel = p.relative_to(root).as_posix()
|
|
468
|
+
if _is_junk(rel):
|
|
469
|
+
continue
|
|
470
|
+
parts = _rel_parts(rel)
|
|
471
|
+
if wl and not any(seg in wl for seg in parts[:-1]):
|
|
472
|
+
continue # 白名单外的内容不参与本插件的检查与安装
|
|
473
|
+
if parts[0].lower() in _BLOCK_DIRS:
|
|
474
|
+
stripped.append(rel)
|
|
475
|
+
continue
|
|
476
|
+
if parts[-1].lower() == ".mcp.json":
|
|
477
|
+
stripped.append(rel)
|
|
478
|
+
continue
|
|
479
|
+
ext = Path(parts[-1]).suffix.lower()
|
|
480
|
+
if (ext in _EXEC_EXT
|
|
481
|
+
or (not ext and parts[-1].lower() not in _NO_EXT_OK)
|
|
482
|
+
or (ext and ext not in _TEXT_EXT and ext not in _IMG_EXT)):
|
|
483
|
+
stripped.append(rel) # 可执行/未知类型一律剥离
|
|
484
|
+
continue
|
|
485
|
+
files.append((rel, p))
|
|
486
|
+
if len(files) > _CAP_FILES:
|
|
487
|
+
raise ValueError("文件数超过上限(%d)" % _CAP_FILES)
|
|
488
|
+
return files, stripped
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def find_skills(root, files):
|
|
492
|
+
"""从文件清单里找技能:任何 SKILL.md 都算一个技能(名=所在目录名),
|
|
493
|
+
根布局 SKILL.md 记为 main——适配 skills/<名>/SKILL.md、社区库的
|
|
494
|
+
<分类>/<名>/SKILL.md 与 ClawHub 的根布局等多种形态。"""
|
|
495
|
+
skills = []
|
|
496
|
+
for rel, _abs in files:
|
|
497
|
+
parts = _rel_parts(rel)
|
|
498
|
+
if parts[-1].lower() != "skill.md":
|
|
499
|
+
continue
|
|
500
|
+
if len(parts) == 1:
|
|
501
|
+
skills.append(("main", rel))
|
|
502
|
+
else:
|
|
503
|
+
skills.append((parts[-2], rel))
|
|
504
|
+
out = []
|
|
505
|
+
for name, rel in skills:
|
|
506
|
+
prefix = "/".join(_rel_parts(rel)[:-1]) + "/"
|
|
507
|
+
extras = [(r, a) for r, a in files
|
|
508
|
+
if r.startswith(prefix) and _rel_parts(r)[-1].lower() != "skill.md"
|
|
509
|
+
and Path(r).suffix.lower() in _TEXT_EXT]
|
|
510
|
+
out.append({"name": name, "skill_md": rel, "extras": extras})
|
|
511
|
+
return out
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ---------------------------------------------------------------- 下载
|
|
515
|
+
|
|
516
|
+
def _safe_extract(zf, dest):
|
|
517
|
+
"""防炸弹解包:先校验成员名与总量,再逐成员手动写出(不用 extractall——
|
|
518
|
+
Windows 打包的 zip 成员名常带反斜杠 fork_core\\x.py,extractall 按「/」
|
|
519
|
+
建父目录在 Windows 上会 FileNotFoundError)。落盘三重防线:段字符白名单、
|
|
520
|
+
resolve 收容校验、落点复查。"""
|
|
521
|
+
members = []
|
|
522
|
+
total, names = 0, 0
|
|
523
|
+
for zi in zf.infolist():
|
|
524
|
+
if zi.is_dir():
|
|
525
|
+
continue
|
|
526
|
+
name = zi.filename.replace("\\", "/")
|
|
527
|
+
if name.startswith("/") or ":" in name.split("/")[0] or ".." in _rel_parts(name):
|
|
528
|
+
raise ValueError("zip 内路径可疑: %s" % zi.filename)
|
|
529
|
+
total += zi.file_size
|
|
530
|
+
names += 1
|
|
531
|
+
if total > _CAP_UNPACKED:
|
|
532
|
+
raise ValueError("解包总量超过上限")
|
|
533
|
+
if names > _CAP_FILES * 4:
|
|
534
|
+
raise ValueError("zip 内文件数过多")
|
|
535
|
+
members.append((zi, name))
|
|
536
|
+
dest = Path(dest)
|
|
537
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
538
|
+
base = dest.resolve()
|
|
539
|
+
seg_ok = re.compile(r"^\.{0,2}[A-Za-z0-9_][A-Za-z0-9_. ()\[\]-]*$")
|
|
540
|
+
for zi, name in members:
|
|
541
|
+
parts = _rel_parts(name)
|
|
542
|
+
if not parts or any(seg in ("..", ".") or not seg_ok.match(seg) for seg in parts):
|
|
543
|
+
raise ValueError("zip 内路径可疑: %s" % zi.filename)
|
|
544
|
+
p = Path(os.path.join(dest, *parts)).resolve()
|
|
545
|
+
if base != p and base not in p.parents:
|
|
546
|
+
raise ValueError("zip 内路径可疑: %s" % zi.filename)
|
|
547
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
548
|
+
with zf.open(zi) as src:
|
|
549
|
+
p.write_bytes(src.read())
|
|
550
|
+
for p in dest.rglob("*"):
|
|
551
|
+
rp = p.resolve()
|
|
552
|
+
if base != rp and base not in rp.parents:
|
|
553
|
+
raise ValueError("解包落点越界: %s" % p)
|
|
554
|
+
return dest
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _download_zip(entry, tmp):
|
|
558
|
+
"""下载 zip(校验 sha256)→ 安全解包 → 返回插件根目录。"""
|
|
559
|
+
data = _fetch(entry["install"]["url"], cap=_CAP_DOWNLOAD)
|
|
560
|
+
want = (entry["install"].get("sha256") or "").lower()
|
|
561
|
+
if want:
|
|
562
|
+
got = hashlib.sha256(data).hexdigest()
|
|
563
|
+
if got != want:
|
|
564
|
+
raise ValueError("sha256 校验不符(期望 %s,实际 %s)" % (want[:12], got[:12]))
|
|
565
|
+
dest = _safe_extract(zipfile.ZipFile(io.BytesIO(data)), tmp / "unzip")
|
|
566
|
+
return _plugin_root(dest)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _gh_split(url):
|
|
570
|
+
"""github 仓库地址 → (owner, repo);非 github 地址返回 None。"""
|
|
571
|
+
m = re.match(r"(?i)^https://[^/]*github\.com/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+?)(\.git)?/?$",
|
|
572
|
+
str(url or "").strip())
|
|
573
|
+
return (m.group(1), m.group(2)) if m else None
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _safe_extract_tar(data, dest):
|
|
577
|
+
"""防炸弹 tar.gz 解包:成员名校验(拒绝绝对路径/穿越/盘符/链接)、体量上限;
|
|
578
|
+
逐成员以字节写出(不用 extractall,绕开链接与权限面)。返回解包目录。"""
|
|
579
|
+
dest = Path(dest)
|
|
580
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
581
|
+
tf = tarfile.open(fileobj=io.BytesIO(data), mode="r:gz")
|
|
582
|
+
total = 0
|
|
583
|
+
with tf:
|
|
584
|
+
members = [m for m in tf.getmembers() if m.isfile() and not (m.issym() or m.islnk())]
|
|
585
|
+
if len(members) > _CAP_FILES * 4:
|
|
586
|
+
raise ValueError("包内文件数过多")
|
|
587
|
+
# codeload 形态:所有成员共享同一个顶层目录段(<repo>-<ref>)→ 剥掉它;
|
|
588
|
+
# 不是统一包裹的 tar 就原样保留
|
|
589
|
+
firsts = {_rel_parts(m.name)[0] for m in members if len(_rel_parts(m.name)) > 1}
|
|
590
|
+
strip_top = len(firsts) == 1 and all(len(_rel_parts(m.name)) > 1 for m in members)
|
|
591
|
+
for m in members:
|
|
592
|
+
name = m.name.replace("\\", "/")
|
|
593
|
+
if name.startswith("/") or ":" in name.split("/")[0] or ".." in _rel_parts(name):
|
|
594
|
+
raise ValueError("包内路径可疑: %s" % m.name)
|
|
595
|
+
total += m.size
|
|
596
|
+
if total > _CAP_UNPACKED:
|
|
597
|
+
raise ValueError("解包总量超过上限")
|
|
598
|
+
parts = _rel_parts(name)
|
|
599
|
+
rel = "/".join(parts[1:]) if (strip_top and len(parts) > 1) else name
|
|
600
|
+
if not rel:
|
|
601
|
+
continue
|
|
602
|
+
src = tf.extractfile(m)
|
|
603
|
+
if src is None:
|
|
604
|
+
continue
|
|
605
|
+
p = dest / rel
|
|
606
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
607
|
+
p.write_bytes(src.read())
|
|
608
|
+
return dest
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _gh_tarball(entry, tmp):
|
|
612
|
+
"""git 类来源的主通道:codeload.github.com 的 tar.gz 单请求拉整树。
|
|
613
|
+
完整、与上游最新一致(jsdelivr 的分支快照会滞后,树索引甚至列已删文件)。
|
|
614
|
+
_fetch 自带「系统代理优先→直连重试」,覆盖 git 协议被干扰的网络。"""
|
|
615
|
+
gh = _gh_split(entry["install"].get("url"))
|
|
616
|
+
if not gh:
|
|
617
|
+
raise ValueError("not-github")
|
|
618
|
+
owner, repo = gh
|
|
619
|
+
ref = str(entry["install"].get("ref") or "").strip()
|
|
620
|
+
tries = (["refs/heads/%s" % ref, "refs/tags/%s" % ref] if ref else ["HEAD"])
|
|
621
|
+
last = None
|
|
622
|
+
for spec in tries:
|
|
623
|
+
url = "https://codeload.github.com/%s/%s/tar.gz/%s" % (owner, repo, urllib.parse.quote(spec, safe="/"))
|
|
624
|
+
try:
|
|
625
|
+
data = _fetch(url, cap=_CAP_DOWNLOAD)
|
|
626
|
+
except (urllib.error.HTTPError, urllib.error.URLError) as e:
|
|
627
|
+
last = e
|
|
628
|
+
continue
|
|
629
|
+
try:
|
|
630
|
+
dest = _safe_extract_tar(data, tmp / "tarball")
|
|
631
|
+
except tarfile.TarError as e: # 不是有效 tar.gz(被劫持/截断/空响应)
|
|
632
|
+
raise ValueError("响应不是有效的 tar.gz: %s" % e)
|
|
633
|
+
# 顶层包裹段已在解包时剥掉,dest 即仓库根;按条目 path 取子目录
|
|
634
|
+
root = dest
|
|
635
|
+
prefix = (entry["install"].get("path") or "").strip("/")
|
|
636
|
+
if prefix:
|
|
637
|
+
root = dest / prefix
|
|
638
|
+
if dest.resolve() not in root.resolve().parents:
|
|
639
|
+
raise ValueError("插件子目录路径可疑: %s" % prefix)
|
|
640
|
+
if not root.is_dir():
|
|
641
|
+
raise ValueError("插件子目录不存在: %s" % prefix)
|
|
642
|
+
return root
|
|
643
|
+
return root
|
|
644
|
+
raise ValueError("codeload 拉取失败: %s/%s(%s)" % (owner, repo, last))
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _gh_fetch_files(entry, tmp):
|
|
648
|
+
"""git 类来源的 jsdelivr 文件级兜底通道:目录树走 data.jsdelivr,
|
|
649
|
+
逐文件走 cdn.jsdelivr,返回插件根目录。注意 jsdelivr 的分支快照可能
|
|
650
|
+
滞后于上游(树列着已删文件、新文件 301 到被墙的 raw),单文件缺失跳过、
|
|
651
|
+
缺失过多判坏包;仅支持 github 仓库。"""
|
|
652
|
+
gh = _gh_split(entry["install"].get("url"))
|
|
653
|
+
if not gh:
|
|
654
|
+
raise ValueError("not-github") # 调用方据此回落 git clone
|
|
655
|
+
owner, repo = gh
|
|
656
|
+
prefix = (entry["install"].get("path") or "").strip("/")
|
|
657
|
+
prefix = prefix + "/" if prefix else ""
|
|
658
|
+
refs = []
|
|
659
|
+
r0 = str(entry["install"].get("ref") or "").strip()
|
|
660
|
+
if r0:
|
|
661
|
+
refs.append(r0)
|
|
662
|
+
refs += ["main", "master"]
|
|
663
|
+
last_err = None
|
|
664
|
+
for ref in refs:
|
|
665
|
+
try:
|
|
666
|
+
tree = json.loads(_fetch(
|
|
667
|
+
"https://data.jsdelivr.com/v1/packages/gh/%s/%s@%s?structure=flat"
|
|
668
|
+
% (owner, repo, urllib.parse.quote(ref, safe="")),
|
|
669
|
+
cap=_CAP_MANIFEST).decode("utf-8", errors="replace"))
|
|
670
|
+
except Exception as e:
|
|
671
|
+
last_err = e
|
|
672
|
+
continue
|
|
673
|
+
files = [f for f in (tree.get("files") or [])
|
|
674
|
+
if isinstance(f, dict) and not f.get("is_dir")
|
|
675
|
+
and str(f.get("name") or "").startswith("/" + prefix)]
|
|
676
|
+
if not files:
|
|
677
|
+
continue # 该 ref 拉不到或子目录不存在,换下一个 ref
|
|
678
|
+
total = sum(int(f.get("size") or 0) for f in files)
|
|
679
|
+
if total > _CAP_UNPACKED or len(files) > _CAP_FILES * 4:
|
|
680
|
+
raise ValueError("插件体量超上限(%d 文件 / %d MB)" % (len(files), total // 1048576))
|
|
681
|
+
dest = tmp / "gh"
|
|
682
|
+
skipped = 0
|
|
683
|
+
for f in files:
|
|
684
|
+
rel = str(f["name"]).lstrip("/")
|
|
685
|
+
if int(f.get("size") or 0) > _CAP_FILE_TEXT * 8:
|
|
686
|
+
continue # 单文件超大的(多为二进制产物)跳过,检查器会兜底
|
|
687
|
+
try:
|
|
688
|
+
data = _fetch("https://cdn.jsdelivr.net/gh/%s/%s@%s%s"
|
|
689
|
+
% (owner, repo, urllib.parse.quote(ref, safe=""),
|
|
690
|
+
urllib.parse.quote("/" + rel)),
|
|
691
|
+
cap=_CAP_FILE_TEXT * 8)
|
|
692
|
+
except urllib.error.HTTPError:
|
|
693
|
+
# jsdelivr 的目录树索引与 CDN 缓存偶有不同步(列了但取不到),
|
|
694
|
+
# 单文件缺失跳过;缺得过多按坏包处理
|
|
695
|
+
skipped += 1
|
|
696
|
+
continue
|
|
697
|
+
p = dest / rel
|
|
698
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
699
|
+
p.write_bytes(data)
|
|
700
|
+
if skipped > max(3, len(files) // 5):
|
|
701
|
+
raise ValueError("jsdelivr 文件缺失过多(%d/%d),包不完整" % (skipped, len(files)))
|
|
702
|
+
root = (dest / prefix[:-1]).resolve() if prefix else dest.resolve()
|
|
703
|
+
if dest.resolve() != root and dest.resolve() not in root.parents:
|
|
704
|
+
raise ValueError("插件子目录路径可疑: %s" % prefix)
|
|
705
|
+
return root
|
|
706
|
+
raise ValueError("jsdelivr 拉取失败: %s/%s(%s)" % (owner, repo, last_err))
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _download_git(entry, tmp):
|
|
710
|
+
"""git 浅克隆取子目录(Anthropic 生态的 git-subdir 来源)。"""
|
|
711
|
+
if shutil.which("git") is None:
|
|
712
|
+
raise ValueError("本机没有 git,无法安装 git-subdir 来源的插件")
|
|
713
|
+
inst = entry["install"]
|
|
714
|
+
dst = tmp / "git"
|
|
715
|
+
sub = str(inst.get("path") or "").replace("\\", "/")
|
|
716
|
+
if sub.startswith("/") or ":" in sub or ".." in _rel_parts(sub):
|
|
717
|
+
raise ValueError("插件子目录路径可疑: %s" % inst.get("path"))
|
|
718
|
+
cmd = ["git", "clone", "--depth", "1", "--single-branch", "--quiet"]
|
|
719
|
+
if inst.get("ref"):
|
|
720
|
+
cmd += ["--branch", inst["ref"]]
|
|
721
|
+
cmd += [inst["url"], str(dst)]
|
|
722
|
+
try:
|
|
723
|
+
proc = subprocess.run(cmd, capture_output=True, timeout=300,
|
|
724
|
+
text=True, encoding="utf-8", errors="replace")
|
|
725
|
+
except subprocess.TimeoutExpired:
|
|
726
|
+
raise ValueError("git 克隆超时: %s" % inst["url"])
|
|
727
|
+
if proc.returncode != 0:
|
|
728
|
+
raise ValueError("git 克隆失败: %s" % (proc.stderr or "").strip()[-200:])
|
|
729
|
+
root = dst / sub if sub else dst
|
|
730
|
+
# 双保险:解析后必须仍在克隆目录内(防符号链接等绕过)
|
|
731
|
+
if dst.resolve() != root.resolve() and dst.resolve() not in root.resolve().parents:
|
|
732
|
+
raise ValueError("插件子目录路径可疑: %s" % inst.get("path"))
|
|
733
|
+
if not root.is_dir():
|
|
734
|
+
raise ValueError("插件子目录不存在: %s" % inst["path"])
|
|
735
|
+
return root
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _download_git_any(entry, tmp):
|
|
739
|
+
"""git 类来源安装:codeload tarball 优先(完整、最新)→ jsdelivr 逐文件
|
|
740
|
+
兜底(codeload 不可达时)→ git 浅克隆收尾(非 github 托管 / 精确 ref)。"""
|
|
741
|
+
try:
|
|
742
|
+
return _gh_tarball(entry, tmp)
|
|
743
|
+
except ValueError as e:
|
|
744
|
+
if "not-github" in str(e):
|
|
745
|
+
return _download_git(entry, tmp)
|
|
746
|
+
first = str(e)
|
|
747
|
+
try:
|
|
748
|
+
return _gh_fetch_files(entry, tmp)
|
|
749
|
+
except ValueError as e:
|
|
750
|
+
if "not-github" in str(e):
|
|
751
|
+
return _download_git(entry, tmp)
|
|
752
|
+
raise ValueError("%s;%s" % (first, e))
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _download_clawhub(entry, tmp):
|
|
756
|
+
"""ClawHub 注册表 zip 直下(/api/v1/download?slug=&reference=)。"""
|
|
757
|
+
inst = entry["install"]
|
|
758
|
+
q = urllib.parse.quote
|
|
759
|
+
url = "%s/download?slug=%s&reference=%s" % (
|
|
760
|
+
_CLAWHUB_BASE, q(inst["slug"], safe=""), q(inst["reference"] or inst["slug"], safe=""))
|
|
761
|
+
data = _fetch(url, cap=_CAP_DOWNLOAD)
|
|
762
|
+
dest = _safe_extract(zipfile.ZipFile(io.BytesIO(data)), tmp / "clawhub")
|
|
763
|
+
return _plugin_root(dest)
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _plugin_root(base):
|
|
767
|
+
"""定位插件根:有 plugin.json 标记就用;否则单一顶层目录时下钻。"""
|
|
768
|
+
for marker in (".claude-plugin/plugin.json", ".zcode-plugin/plugin.json",
|
|
769
|
+
"plugin.json"):
|
|
770
|
+
if (base / marker).is_file():
|
|
771
|
+
return base
|
|
772
|
+
entries = list(base.iterdir())
|
|
773
|
+
dirs = [p for p in entries if p.is_dir()]
|
|
774
|
+
if len(dirs) == 1 and not any(p.is_file() for p in entries):
|
|
775
|
+
return _plugin_root(dirs[0])
|
|
776
|
+
return base
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# ---------------------------------------------------------------- 转换与安装
|
|
780
|
+
|
|
781
|
+
def _read_text(p):
|
|
782
|
+
try:
|
|
783
|
+
if p.stat().st_size > _CAP_FILE_TEXT:
|
|
784
|
+
return None
|
|
785
|
+
return p.read_text(encoding="utf-8", errors="replace")
|
|
786
|
+
except OSError:
|
|
787
|
+
return None
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _one_line(s, cap=100):
|
|
791
|
+
s = re.sub(r"\s+", " ", str(s or "")).strip()
|
|
792
|
+
return s[:cap]
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _rewrite_skill_md(content, title, note, pack_id):
|
|
796
|
+
"""SKILL.md → skillpack:重写 frontmatter(带 market 安装标记),正文原样保留。"""
|
|
797
|
+
body = content
|
|
798
|
+
m = re.match(r"(?s)\A---\s*\n(.*?)\n---\s*\n?", content)
|
|
799
|
+
if m:
|
|
800
|
+
body = content[m.end():]
|
|
801
|
+
fm = m.group(1)
|
|
802
|
+
m_name = re.search(r"(?m)^name:\s*(.+)$", fm)
|
|
803
|
+
m_desc = re.search(r"(?m)^description:\s*(.+)$", fm)
|
|
804
|
+
title = _one_line(m_name.group(1)) if m_name else title
|
|
805
|
+
note = _one_line(m_desc.group(1)) if m_desc else note
|
|
806
|
+
head = ('---\nname: %s\nnote: %s\nscopes:\n- "*"\nsource: market\n'
|
|
807
|
+
"market_id: %s\n---\n\n" % (_one_line(title, 60), _one_line(note), pack_id))
|
|
808
|
+
return head + body.lstrip("\r\n")
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def build_files(entry, root):
|
|
812
|
+
"""插件树 → market.install_files 的 files dict:
|
|
813
|
+
主 SKILL.md 落顶层(注入件),其余文本附件落 market-assets/<id>/ 原相对路径。
|
|
814
|
+
条目显式声明了 skills 白名单(如 anthropics/skills 的 skills: [...])时,
|
|
815
|
+
检查与打包都只看白名单技能目录——同仓库其他插件的内容不越界。
|
|
816
|
+
返回 (files dict, 错误, stripped 剔除清单)。"""
|
|
817
|
+
whitelist = (entry.get("install") or {}).get("skills") or []
|
|
818
|
+
try:
|
|
819
|
+
files, stripped = inspect_tree(root, whitelist=whitelist or None)
|
|
820
|
+
except ValueError as e:
|
|
821
|
+
return None, str(e), []
|
|
822
|
+
skills = find_skills(root, files)
|
|
823
|
+
if not skills:
|
|
824
|
+
return None, "未找到技能(该插件剔除脚本/钩子/MCP 组件后没有纯技能内容)", stripped
|
|
825
|
+
if whitelist:
|
|
826
|
+
skills = [s for s in skills if s["name"] in set(whitelist)]
|
|
827
|
+
if not skills:
|
|
828
|
+
return None, "白名单技能与包内容不匹配: %s" % ",".join(whitelist[:5]), stripped
|
|
829
|
+
pack_id = entry["id"]
|
|
830
|
+
out, total = {}, 0
|
|
831
|
+
for i, sk in enumerate(skills):
|
|
832
|
+
content = _read_text(root / sk["skill_md"])
|
|
833
|
+
if not content or not content.strip():
|
|
834
|
+
continue
|
|
835
|
+
if i == 0:
|
|
836
|
+
key = "market-%s.md" % pack_id
|
|
837
|
+
else:
|
|
838
|
+
key = "market-%s__%s.md" % (pack_id, re.sub(r"[^A-Za-z0-9_.-]", "-", sk["name"]))
|
|
839
|
+
out[key] = _rewrite_skill_md(content, entry["title"], entry["desc"], pack_id)
|
|
840
|
+
total += len(out[key])
|
|
841
|
+
for rel, abs_p in sk["extras"]:
|
|
842
|
+
text = _read_text(abs_p)
|
|
843
|
+
if text is None: # 超限/读不到的附件直接舍弃(图片本就不带)
|
|
844
|
+
continue
|
|
845
|
+
total += len(text)
|
|
846
|
+
if total > _CAP_TOTAL_TEXT:
|
|
847
|
+
return None, "插件文本总量超过上限(%d KB)" % (_CAP_TOTAL_TEXT // 1024), stripped
|
|
848
|
+
out[rel] = text
|
|
849
|
+
if not out:
|
|
850
|
+
return None, "技能内容为空", stripped
|
|
851
|
+
return out, None, stripped
|
|
852
|
+
|
|
853
|
+
|
|
854
|
+
def install_remote(entry_id):
|
|
855
|
+
"""安装外部目录插件:下载 → 纯技能检查 → 转换 → 复用 market 安装通道。
|
|
856
|
+
返回 (结果 dict, 错误)。"""
|
|
857
|
+
entries = _entries_from_cache()
|
|
858
|
+
entry = next((e for e in entries if e["id"] == entry_id), None)
|
|
859
|
+
if not entry:
|
|
860
|
+
return None, "外部目录中没有这个插件(先「拉取更新」再试): %s" % entry_id
|
|
861
|
+
if entry["compat"] == "blocked":
|
|
862
|
+
return None, entry["block_reason"] or "该插件不适配 CodeBee"
|
|
863
|
+
if entry["install"]["kind"] == "unsupported":
|
|
864
|
+
return None, "来源类型不支持(仅支持 zip 直链、git 子目录与 ClawHub)"
|
|
865
|
+
tmp = Path(tempfile.mkdtemp(prefix="codebee-mkt-"))
|
|
866
|
+
try:
|
|
867
|
+
kind = entry["install"]["kind"]
|
|
868
|
+
if kind == "zip":
|
|
869
|
+
root = _download_zip(entry, tmp)
|
|
870
|
+
elif kind == "clawhub":
|
|
871
|
+
root = _download_clawhub(entry, tmp)
|
|
872
|
+
else:
|
|
873
|
+
root = _download_git_any(entry, tmp)
|
|
874
|
+
files, blocked, stripped = build_files(entry, root)
|
|
875
|
+
if blocked:
|
|
876
|
+
return None, blocked
|
|
877
|
+
res, err = market.install_files(entry_id, entry["title"], files, extra={
|
|
878
|
+
"remote": {"source": entry["source_id"], "name": entry["name"],
|
|
879
|
+
"version": entry["version"], "homepage": entry["homepage"]},
|
|
880
|
+
})
|
|
881
|
+
if err:
|
|
882
|
+
return None, err
|
|
883
|
+
res["skills"] = sum(1 for k in files if k.endswith(".md") and "/" not in k)
|
|
884
|
+
res["stripped"] = sorted(stripped)
|
|
885
|
+
return res, None
|
|
886
|
+
except ValueError as e:
|
|
887
|
+
return None, str(e)
|
|
888
|
+
except Exception as e: # 网络/解包等意外错误也要兜成用户可读的一句话
|
|
889
|
+
return None, "下载或解析插件失败: %s" % e
|
|
890
|
+
finally:
|
|
891
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def remove_remote(entry_id):
|
|
895
|
+
"""卸载外部插件:记账与标记齐全时走 market 既有卸载;否则报错。"""
|
|
896
|
+
return market.remove(entry_id)
|