codebee 0.1.69 → 0.1.71
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/CHANGELOG.md +589 -543
- package/LICENSE +21 -21
- package/README.md +509 -508
- package/app/.playwright-cli/console-2026-09-23T08-26-03-635Z.log +88 -0
- package/app/.playwright-cli/page-2026-09-23T08-26-03-845Z.yml +126 -0
- package/app/.playwright-cli/page-2026-09-23T08-29-44-203Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-35-42-046Z.yml +210 -0
- package/app/.playwright-cli/page-2026-09-23T08-36-52-858Z.png +0 -0
- package/app/.playwright-cli/page-2026-09-23T08-37-33-938Z.png +0 -0
- package/app/.playwright-cli/run-detail-after.png +0 -0
- package/app/browser-final-1440.png +0 -0
- package/app/browser-final-390.png +0 -0
- package/app/browser-google-fallback.png +0 -0
- package/app/browser-google-test.png +0 -0
- package/app/browser-workbench-1440.png +0 -0
- package/app/browser-workbench-390.png +0 -0
- package/app/browser-workspace-google.png +0 -0
- package/app/browser-workspace-smoke.png +0 -0
- package/app/browser-workspace.png +0 -0
- package/app/core/acceptance.py +100 -100
- package/app/core/aiflavor.py +197 -197
- package/app/core/artifacts.py +148 -148
- package/app/core/assets.py +110 -110
- package/app/core/attachments.py +623 -623
- package/app/core/audit.py +30 -0
- package/app/core/automation.py +107 -12
- package/app/core/backup.py +470 -470
- package/app/core/beekeeper.py +183 -183
- package/app/core/benchstore.py +95 -0
- package/app/core/board.py +209 -209
- package/app/core/bookmeta.py +935 -930
- package/app/core/bookmeta_catalog.py +180 -180
- package/app/core/branching.py +137 -137
- package/app/core/builtin_agent.py +1673 -1234
- package/app/core/capability.py +144 -144
- package/app/core/catalog.py +7 -1
- package/app/core/chaptersafe.py +32 -32
- package/app/core/cleanup.py +303 -303
- package/app/core/compaction.py +201 -201
- package/app/core/covergen.py +356 -356
- package/app/core/defectretro.py +43 -43
- package/app/core/diagnostics.py +114 -114
- package/app/core/dispatch.py +190 -173
- package/app/core/dispatch_log.py +128 -128
- package/app/core/env_scrub.py +85 -85
- package/app/core/error_codes.py +11 -2
- package/app/core/errorlog.py +137 -137
- package/app/core/evalbench.py +720 -0
- package/app/core/evaluation.py +174 -174
- package/app/core/failure_notify.py +290 -290
- package/app/core/flows.py +234 -9
- package/app/core/goal_service.py +167 -167
- package/app/core/health.py +374 -374
- package/app/core/history.py +38 -38
- package/app/core/hooks.py +162 -162
- package/app/core/jobs.py +22 -1
- package/app/core/manager.py +2143 -2122
- package/app/core/market_remote.py +10 -0
- package/app/core/mcp_client.py +287 -0
- package/app/core/mocks.py +64 -64
- package/app/core/modelhub.py +185 -2
- package/app/core/notify.py +201 -106
- package/app/core/novel_quality.py +189 -0
- package/app/core/operations.py +228 -228
- package/app/core/paihang.py +138 -138
- package/app/core/paths.py +4 -0
- package/app/core/pipeline.py +426 -28
- package/app/core/planner.py +243 -22
- package/app/core/plugins.py +525 -0
- package/app/core/portguard.py +111 -111
- package/app/core/preview.py +293 -293
- package/app/core/publish/__init__.py +7 -7
- package/app/core/publish/auto.py +367 -367
- package/app/core/publish/browser.py +655 -539
- package/app/core/publish/fanqie.py +165 -159
- package/app/core/publish/flow.py +589 -440
- package/app/core/publish/flows-fanqie-calibrated.json +336 -332
- package/app/core/publish/ledger.py +229 -203
- package/app/core/publish/manager.py +824 -692
- package/app/core/publish/qimao.py +146 -141
- package/app/core/publish/ws.py +139 -139
- package/app/core/redact.py +38 -38
- package/app/core/remotesync.py +121 -0
- package/app/core/repeat_guard.py +129 -129
- package/app/core/router.py +398 -398
- package/app/core/runner.py +2142 -2067
- package/app/core/selfupdate.py +384 -384
- package/app/core/session_log.py +161 -161
- package/app/core/sessions.py +312 -312
- package/app/core/settings.py +42 -4
- package/app/core/settings_schema.py +270 -262
- package/app/core/share_page.py +114 -114
- package/app/core/skill_scan.py +122 -122
- package/app/core/skillpacks/fanqie-novel.md +80 -80
- package/app/core/skillpacks/market/character-bible.md +66 -66
- package/app/core/skillpacks/market/code-risk-checklist.md +58 -58
- package/app/core/skillpacks/market/git-workflow.md +57 -57
- package/app/core/skillpacks/market/release-notes.md +72 -72
- package/app/core/skillpacks/market/weekly-report.md +71 -71
- package/app/core/skillpacks/market/worldview-consistency.md +70 -70
- package/app/core/step_runner.py +132 -132
- package/app/core/store.py +30 -8
- package/app/core/task_compile.py +199 -199
- package/app/core/telemetry.py +292 -292
- package/app/core/tlsctx.py +64 -64
- package/app/core/token_meter.py +147 -147
- package/app/core/tracing.py +144 -144
- package/app/core/upstream.py +41 -41
- package/app/core/usage.py +1035 -862
- package/app/core/volumes.py +389 -389
- package/app/core/wxdigest.py +858 -858
- package/app/core/zentao.py +2614 -2465
- package/app/current-browser.png +0 -0
- package/app/main.py +316 -17
- package/app/mcp_server.py +175 -0
- package/app/pet.py +83 -22
- package/app/pick_dialog.py +110 -110
- package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
- package/app/plugins/code-review-helper/skills/review.md +16 -0
- package/app/ui/app.js +1391 -77
- package/app/ui/artdiff.js +74 -74
- package/app/ui/board.html +162 -162
- package/app/ui/board.js +305 -305
- package/app/ui/i18n.js +249 -7
- package/app/ui/icons/bee.svg +79 -79
- package/app/ui/index.html +277 -13
- package/app/ui/manifest.json +16 -16
- package/app/ui/qrcode.js +2297 -2297
- package/app/ui/style.css +845 -12
- package/app//344/275/234/345/223/201/344/277/241/346/201/257-/344/270/203/347/214/253.md +18 -0
- package/bin/tutti.js +147 -147
- package/package.json +47 -46
- package/tools/wx_reader.py +261 -261
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""Safe local plugin lifecycle for CodeBee.
|
|
3
|
+
|
|
4
|
+
Plugins use the Codex ``.codex-plugin/plugin.json`` contract. This module owns
|
|
5
|
+
discovery and installation metadata; skills are still installed through the
|
|
6
|
+
existing ``market``/``skills`` path and MCP servers are only exposed as
|
|
7
|
+
declarations to the existing stdio MCP client. Plugin scripts, hooks and app
|
|
8
|
+
bundles are never executed by this module.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from . import market, paths, skills
|
|
21
|
+
|
|
22
|
+
_LOCK = threading.RLock()
|
|
23
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$")
|
|
24
|
+
_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
|
25
|
+
_MAX_MANIFEST_BYTES = 1024 * 1024
|
|
26
|
+
_MAX_SKILL_FILES = 128
|
|
27
|
+
_MAX_SKILL_BYTES = 1024 * 1024
|
|
28
|
+
_MAX_TOTAL_SKILL_BYTES = 8 * 1024 * 1024
|
|
29
|
+
_MAX_PLUGIN_FILES = 512
|
|
30
|
+
_MAX_PLUGIN_BYTES = 32 * 1024 * 1024
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PluginError(ValueError):
|
|
34
|
+
"""A user-actionable plugin validation or lifecycle error."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _registry_file():
|
|
38
|
+
return Path(paths.DATA_DIR) / "plugins.json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _install_root():
|
|
42
|
+
return Path(paths.DATA_DIR) / "plugins"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _now():
|
|
46
|
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_registry():
|
|
50
|
+
try:
|
|
51
|
+
data = json.loads(_registry_file().read_text(encoding="utf-8"))
|
|
52
|
+
except (OSError, ValueError, TypeError):
|
|
53
|
+
return {"installed": {}}
|
|
54
|
+
if not isinstance(data, dict):
|
|
55
|
+
return {"installed": {}}
|
|
56
|
+
installed = data.get("installed")
|
|
57
|
+
data["installed"] = installed if isinstance(installed, dict) else {}
|
|
58
|
+
return data
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _save_registry(data):
|
|
62
|
+
target = _registry_file()
|
|
63
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
tmp = target.with_suffix(".tmp")
|
|
65
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
66
|
+
tmp.replace(target)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _invalidate_mcp_cache():
|
|
70
|
+
try:
|
|
71
|
+
from . import mcp_client
|
|
72
|
+
mcp_client.invalidate_tools_cache()
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _inside(root, value, *, must_exist=False):
|
|
78
|
+
"""Resolve a manifest-relative path without allowing traversal or symlinks."""
|
|
79
|
+
root = Path(root).resolve()
|
|
80
|
+
raw = str(value or "").strip()
|
|
81
|
+
if not raw or Path(raw).is_absolute():
|
|
82
|
+
raise PluginError("插件路径必须是非空相对路径: %s" % raw)
|
|
83
|
+
candidate = (root / raw).resolve()
|
|
84
|
+
try:
|
|
85
|
+
candidate.relative_to(root)
|
|
86
|
+
except ValueError:
|
|
87
|
+
raise PluginError("插件路径越界: %s" % raw)
|
|
88
|
+
if must_exist and not candidate.exists():
|
|
89
|
+
raise PluginError("插件文件不存在: %s" % raw)
|
|
90
|
+
return candidate
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_json(path):
|
|
94
|
+
try:
|
|
95
|
+
if path.stat().st_size > _MAX_MANIFEST_BYTES:
|
|
96
|
+
raise PluginError("插件 manifest 过大")
|
|
97
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
98
|
+
except PluginError:
|
|
99
|
+
raise
|
|
100
|
+
except (OSError, UnicodeError, ValueError) as exc:
|
|
101
|
+
raise PluginError("无法读取插件 manifest: %s" % exc)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _path_values(value):
|
|
105
|
+
if value is None:
|
|
106
|
+
return []
|
|
107
|
+
if isinstance(value, str):
|
|
108
|
+
return [value]
|
|
109
|
+
if isinstance(value, list) and all(isinstance(x, str) for x in value):
|
|
110
|
+
return list(value)
|
|
111
|
+
raise PluginError("manifest 路径字段必须是字符串或字符串数组")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _validate_manifest(plugin_dir, manifest):
|
|
115
|
+
if not isinstance(manifest, dict):
|
|
116
|
+
raise PluginError("plugin.json 顶层必须是对象")
|
|
117
|
+
name = manifest.get("name")
|
|
118
|
+
if not isinstance(name, str) or not _NAME_RE.fullmatch(name):
|
|
119
|
+
raise PluginError("插件 name 非法")
|
|
120
|
+
if name != Path(plugin_dir).name:
|
|
121
|
+
raise PluginError("插件 name 必须与目录名一致")
|
|
122
|
+
version = manifest.get("version")
|
|
123
|
+
if not isinstance(version, str) or not _VERSION_RE.fullmatch(version):
|
|
124
|
+
raise PluginError("插件 version 必须是语义化版本号")
|
|
125
|
+
if not str(manifest.get("description") or "").strip():
|
|
126
|
+
raise PluginError("插件缺少 description")
|
|
127
|
+
author = manifest.get("author")
|
|
128
|
+
if not isinstance(author, dict) or not str(author.get("name") or "").strip():
|
|
129
|
+
raise PluginError("插件缺少 author.name")
|
|
130
|
+
interface = manifest.get("interface")
|
|
131
|
+
if not isinstance(interface, dict):
|
|
132
|
+
raise PluginError("插件缺少 interface")
|
|
133
|
+
for key in ("displayName", "shortDescription", "longDescription", "developerName", "category"):
|
|
134
|
+
if not str(interface.get(key) or "").strip():
|
|
135
|
+
raise PluginError("插件 interface 缺少 %s" % key)
|
|
136
|
+
if not interface.get("defaultPrompt"):
|
|
137
|
+
raise PluginError("插件 interface 缺少 defaultPrompt")
|
|
138
|
+
if "capabilities" in interface and not isinstance(interface["capabilities"], list):
|
|
139
|
+
raise PluginError("interface.capabilities 必须是数组")
|
|
140
|
+
|
|
141
|
+
skill_paths = []
|
|
142
|
+
for rel in _path_values(manifest.get("skills")):
|
|
143
|
+
path = _inside(plugin_dir, rel, must_exist=True)
|
|
144
|
+
if path.is_dir():
|
|
145
|
+
skill_paths.append(path)
|
|
146
|
+
elif path.suffix.lower() == ".md":
|
|
147
|
+
skill_paths.append(path)
|
|
148
|
+
else:
|
|
149
|
+
raise PluginError("skills 路径必须指向目录或 Markdown 文件: %s" % rel)
|
|
150
|
+
|
|
151
|
+
mcp = manifest.get("mcpServers")
|
|
152
|
+
mcp_path = None
|
|
153
|
+
if isinstance(mcp, str):
|
|
154
|
+
mcp_path = _inside(plugin_dir, mcp, must_exist=True)
|
|
155
|
+
if not mcp_path.is_file():
|
|
156
|
+
raise PluginError("mcpServers 文件不存在")
|
|
157
|
+
elif mcp is not None and not isinstance(mcp, dict):
|
|
158
|
+
raise PluginError("mcpServers 必须是对象或 JSON 文件路径")
|
|
159
|
+
if "apps" in manifest:
|
|
160
|
+
for rel in _path_values(manifest.get("apps")):
|
|
161
|
+
_inside(plugin_dir, rel, must_exist=True)
|
|
162
|
+
if "hooks" in manifest:
|
|
163
|
+
raise PluginError("暂不支持 hooks 插件;请使用 MCP 或技能声明")
|
|
164
|
+
return {
|
|
165
|
+
"manifest": manifest,
|
|
166
|
+
"dir": str(Path(plugin_dir).resolve()),
|
|
167
|
+
"name": name,
|
|
168
|
+
"version": version,
|
|
169
|
+
"display_name": str(interface["displayName"]),
|
|
170
|
+
"description": str(manifest["description"]),
|
|
171
|
+
"short_description": str(interface.get("shortDescription") or ""),
|
|
172
|
+
"long_description": str(interface.get("longDescription") or ""),
|
|
173
|
+
"author": str((author or {}).get("name") or ""),
|
|
174
|
+
"category": str(interface.get("category") or "Productivity"),
|
|
175
|
+
"capabilities": [str(x) for x in (interface.get("capabilities") or [])],
|
|
176
|
+
"skill_paths": skill_paths,
|
|
177
|
+
"mcp_path": str(mcp_path) if mcp_path else "",
|
|
178
|
+
"mcp_inline": mcp if isinstance(mcp, dict) else {},
|
|
179
|
+
"unsupported": [],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def load_manifest(plugin_dir):
|
|
184
|
+
"""Load and validate a plugin directory, returning a normalized descriptor."""
|
|
185
|
+
root = Path(plugin_dir).expanduser().resolve()
|
|
186
|
+
if not root.is_dir():
|
|
187
|
+
raise PluginError("插件目录不存在: %s" % plugin_dir)
|
|
188
|
+
manifest_path = root / ".codex-plugin" / "plugin.json"
|
|
189
|
+
if not manifest_path.is_file():
|
|
190
|
+
raise PluginError("缺少 .codex-plugin/plugin.json")
|
|
191
|
+
return _validate_manifest(root, _read_json(manifest_path))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _default_roots():
|
|
195
|
+
# app/plugins is the repo/team location; ~/plugins follows the Codex
|
|
196
|
+
# personal plugin convention; data/plugins contains installed copies.
|
|
197
|
+
return [Path(paths.APP_DIR) / "plugins", Path.home() / "plugins", _install_root()]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _iter_plugin_dirs(roots):
|
|
201
|
+
seen = set()
|
|
202
|
+
if isinstance(roots, (str, os.PathLike)):
|
|
203
|
+
roots = [roots]
|
|
204
|
+
for raw in roots:
|
|
205
|
+
root = Path(raw).expanduser()
|
|
206
|
+
if not root.exists():
|
|
207
|
+
continue
|
|
208
|
+
candidates = [root] if (root / ".codex-plugin" / "plugin.json").is_file() else list(root.iterdir())
|
|
209
|
+
for item in candidates:
|
|
210
|
+
try:
|
|
211
|
+
key = str(item.resolve())
|
|
212
|
+
except OSError:
|
|
213
|
+
continue
|
|
214
|
+
if key in seen or not item.is_dir():
|
|
215
|
+
continue
|
|
216
|
+
seen.add(key)
|
|
217
|
+
if (item / ".codex-plugin" / "plugin.json").is_file():
|
|
218
|
+
yield item
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def discover(roots=None):
|
|
222
|
+
"""Discover valid plugins. Invalid entries are returned as blocked catalog rows."""
|
|
223
|
+
out = []
|
|
224
|
+
for item in _iter_plugin_dirs(_default_roots() if roots is None else roots):
|
|
225
|
+
try:
|
|
226
|
+
out.append(_catalog_row(load_manifest(item)))
|
|
227
|
+
except PluginError as exc:
|
|
228
|
+
out.append({"id": item.name, "name": item.name, "state": "blocked",
|
|
229
|
+
"available": True, "installed": False, "error": str(exc),
|
|
230
|
+
"source": str(item.resolve())})
|
|
231
|
+
return out
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _skill_files(desc):
|
|
235
|
+
files = []
|
|
236
|
+
for base in desc["skill_paths"]:
|
|
237
|
+
if base.is_file():
|
|
238
|
+
files.append(base)
|
|
239
|
+
continue
|
|
240
|
+
for path in sorted(base.rglob("*.md")):
|
|
241
|
+
if path.is_file() and not path.is_symlink():
|
|
242
|
+
files.append(path)
|
|
243
|
+
if len(files) > _MAX_SKILL_FILES:
|
|
244
|
+
raise PluginError("插件技能文件超过 %d 个" % _MAX_SKILL_FILES)
|
|
245
|
+
total = 0
|
|
246
|
+
out = {}
|
|
247
|
+
package_id = "plugin-" + desc["name"]
|
|
248
|
+
for index, path in enumerate(files):
|
|
249
|
+
size = path.stat().st_size
|
|
250
|
+
if size > _MAX_SKILL_BYTES:
|
|
251
|
+
raise PluginError("技能文件过大: %s" % path.name)
|
|
252
|
+
total += size
|
|
253
|
+
if total > _MAX_TOTAL_SKILL_BYTES:
|
|
254
|
+
raise PluginError("插件技能总量过大")
|
|
255
|
+
stem = re.sub(r"[^A-Za-z0-9_.-]+", "-", path.stem).strip("-") or str(index)
|
|
256
|
+
rel = "market-%s%s.md" % (package_id, "" if index == 0 else "-%d-%s" % (index, stem))
|
|
257
|
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
|
258
|
+
out[rel] = _mark_skill(raw, desc, path.stem, package_id)
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _mark_skill(raw, desc, stem, package_id):
|
|
263
|
+
"""Add market metadata while preserving a skill's existing frontmatter."""
|
|
264
|
+
if raw.startswith("---"):
|
|
265
|
+
end = raw.find("\n---", 3)
|
|
266
|
+
if end >= 0:
|
|
267
|
+
head = raw[3:end]
|
|
268
|
+
body = raw[end + 4:]
|
|
269
|
+
lines = head.splitlines()
|
|
270
|
+
keys = {line.split(":", 1)[0].strip() for line in lines if ":" in line}
|
|
271
|
+
if "name" not in keys:
|
|
272
|
+
lines.insert(0, "name: %s - %s" % (desc["display_name"], stem))
|
|
273
|
+
if "scopes" not in keys:
|
|
274
|
+
lines.extend(["scopes:", '- "*"'])
|
|
275
|
+
lines.extend(["source: market", "market_id: " + package_id,
|
|
276
|
+
"plugin_id: " + desc["name"]])
|
|
277
|
+
return "---\n" + "\n".join(lines) + "\n---" + body
|
|
278
|
+
return ("---\nname: %s - %s\nnote: %s\nscopes:\n- \"*\"\n"
|
|
279
|
+
"source: market\nmarket_id: %s\nplugin_id: %s\n---\n\n%s"
|
|
280
|
+
% (desc["display_name"], stem, desc["short_description"], package_id,
|
|
281
|
+
desc["name"], raw))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _mcp_for_desc(desc):
|
|
285
|
+
data = desc.get("mcp_inline") or {}
|
|
286
|
+
if desc.get("mcp_path"):
|
|
287
|
+
raw = _read_json(Path(desc["mcp_path"]))
|
|
288
|
+
data = raw.get("mcpServers") if isinstance(raw, dict) else {}
|
|
289
|
+
if isinstance(data, dict) and isinstance(data.get("mcpServers"), dict):
|
|
290
|
+
data = data["mcpServers"]
|
|
291
|
+
if not isinstance(data, dict):
|
|
292
|
+
raise PluginError("MCP 配置必须是对象")
|
|
293
|
+
out = []
|
|
294
|
+
for name, cfg in data.items():
|
|
295
|
+
if not isinstance(cfg, dict):
|
|
296
|
+
continue
|
|
297
|
+
item = dict(cfg)
|
|
298
|
+
item["name"] = str(name)
|
|
299
|
+
item["plugin_id"] = desc["name"]
|
|
300
|
+
out.append(item)
|
|
301
|
+
return out
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def mcp_servers(plugin_id, roots=None):
|
|
305
|
+
"""Return declared stdio-compatible MCP configs for a plugin."""
|
|
306
|
+
desc = _descriptor_for(plugin_id, roots=roots)
|
|
307
|
+
if not desc:
|
|
308
|
+
return []
|
|
309
|
+
return _mcp_for_desc(desc)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _assert_no_symlinks(root):
|
|
313
|
+
count = 0
|
|
314
|
+
total = 0
|
|
315
|
+
for base, dirs, files in os.walk(root, followlinks=False):
|
|
316
|
+
for name in list(dirs) + list(files):
|
|
317
|
+
if (Path(base) / name).is_symlink():
|
|
318
|
+
raise PluginError("插件包不允许包含符号链接: %s" % name)
|
|
319
|
+
for name in files:
|
|
320
|
+
count += 1
|
|
321
|
+
if count > _MAX_PLUGIN_FILES:
|
|
322
|
+
raise PluginError("插件文件超过 %d 个" % _MAX_PLUGIN_FILES)
|
|
323
|
+
try:
|
|
324
|
+
total += (Path(base) / name).stat().st_size
|
|
325
|
+
except OSError as exc:
|
|
326
|
+
raise PluginError("无法读取插件文件: %s" % exc)
|
|
327
|
+
if total > _MAX_PLUGIN_BYTES:
|
|
328
|
+
raise PluginError("插件包超过 %d MB" % (_MAX_PLUGIN_BYTES // 1048576))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _copy_plugin(source, target):
|
|
332
|
+
_assert_no_symlinks(source)
|
|
333
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
if target.exists():
|
|
335
|
+
raise PluginError("安装目标已存在: %s" % target.name)
|
|
336
|
+
shutil.copytree(source, target, symlinks=False)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _descriptor_for(plugin_id, roots=None):
|
|
340
|
+
for row in discover(roots):
|
|
341
|
+
if row.get("id") == plugin_id and row.get("source"):
|
|
342
|
+
try:
|
|
343
|
+
return load_manifest(row["source"])
|
|
344
|
+
except PluginError:
|
|
345
|
+
return None
|
|
346
|
+
rec = _load_registry().get("installed", {}).get(plugin_id) or {}
|
|
347
|
+
path = rec.get("install_dir")
|
|
348
|
+
if path:
|
|
349
|
+
try:
|
|
350
|
+
return load_manifest(path)
|
|
351
|
+
except PluginError:
|
|
352
|
+
return None
|
|
353
|
+
return None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _installed_record(plugin_id):
|
|
357
|
+
return _load_registry().get("installed", {}).get(plugin_id)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def install(plugin_id, roots=None):
|
|
361
|
+
"""Install a discovered plugin and its skills without executing code."""
|
|
362
|
+
plugin_id = str(plugin_id or "").strip()
|
|
363
|
+
desc = _descriptor_for(plugin_id, roots=roots)
|
|
364
|
+
if not desc:
|
|
365
|
+
return None, "插件不存在或 manifest 无效: %s" % plugin_id
|
|
366
|
+
with _LOCK:
|
|
367
|
+
old = _installed_record(plugin_id)
|
|
368
|
+
if old and Path(str(old.get("install_dir") or "")).is_dir():
|
|
369
|
+
return {"ok": True, "id": plugin_id, "already": True,
|
|
370
|
+
"skill_count": len(old.get("skill_pack_ids") or [])}, None
|
|
371
|
+
source = Path(desc["dir"]).resolve()
|
|
372
|
+
target = (_install_root() / plugin_id).resolve()
|
|
373
|
+
try:
|
|
374
|
+
target.relative_to(_install_root().resolve())
|
|
375
|
+
_copy_plugin(source, target)
|
|
376
|
+
installed_desc = load_manifest(target)
|
|
377
|
+
files = _skill_files(installed_desc)
|
|
378
|
+
market_id = "plugin-" + plugin_id
|
|
379
|
+
skill_result = None
|
|
380
|
+
market_written = False
|
|
381
|
+
if files:
|
|
382
|
+
skill_result, err = market.install_files(
|
|
383
|
+
market_id, installed_desc["display_name"], files,
|
|
384
|
+
extra={"plugin_id": plugin_id, "plugin_version": installed_desc["version"]})
|
|
385
|
+
if err:
|
|
386
|
+
raise PluginError(err)
|
|
387
|
+
market_written = not bool((skill_result or {}).get("already"))
|
|
388
|
+
skill_pack_ids = []
|
|
389
|
+
if files:
|
|
390
|
+
names = set((skill_result or {}).get("files") or files.keys())
|
|
391
|
+
for pack in skills.user_packs():
|
|
392
|
+
if Path(str(pack.get("file") or "")).name in names:
|
|
393
|
+
skill_pack_ids.append(pack["id"])
|
|
394
|
+
reg = _load_registry()
|
|
395
|
+
reg.setdefault("installed", {})[plugin_id] = {
|
|
396
|
+
"install_dir": str(target), "source": str(source),
|
|
397
|
+
"market_id": market_id if files else "", "skill_pack_ids": skill_pack_ids,
|
|
398
|
+
"enabled": False, "installed_at": _now(),
|
|
399
|
+
"has_mcp": bool(installed_desc.get("mcp_path") or installed_desc.get("mcp_inline")),
|
|
400
|
+
}
|
|
401
|
+
_save_registry(reg)
|
|
402
|
+
for pack_id in skill_pack_ids:
|
|
403
|
+
err = skills.pack_op(pack_id, "disable")
|
|
404
|
+
if err:
|
|
405
|
+
raise PluginError(err)
|
|
406
|
+
_invalidate_mcp_cache()
|
|
407
|
+
except (OSError, PluginError) as exc:
|
|
408
|
+
if market_written:
|
|
409
|
+
try:
|
|
410
|
+
market.remove(market_id)
|
|
411
|
+
except Exception:
|
|
412
|
+
pass
|
|
413
|
+
if target.exists() and target != source:
|
|
414
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
415
|
+
return None, str(exc)
|
|
416
|
+
return {"ok": True, "id": plugin_id, "already": False,
|
|
417
|
+
"skill_count": len(skill_pack_ids),
|
|
418
|
+
"has_mcp": bool(installed_desc.get("mcp_path") or installed_desc.get("mcp_inline"))}, None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def toggle(plugin_id, enabled):
|
|
422
|
+
with _LOCK:
|
|
423
|
+
reg = _load_registry()
|
|
424
|
+
rec = (reg.get("installed") or {}).get(str(plugin_id))
|
|
425
|
+
if not rec:
|
|
426
|
+
return "插件尚未安装"
|
|
427
|
+
pack_ids = list(rec.get("skill_pack_ids") or [])
|
|
428
|
+
changed = []
|
|
429
|
+
action = "enable" if enabled else "disable"
|
|
430
|
+
for pack_id in pack_ids:
|
|
431
|
+
err = skills.pack_op(pack_id, action)
|
|
432
|
+
if err:
|
|
433
|
+
rollback = "disable" if enabled else "enable"
|
|
434
|
+
for changed_id in reversed(changed):
|
|
435
|
+
skills.pack_op(changed_id, rollback)
|
|
436
|
+
return err
|
|
437
|
+
changed.append(pack_id)
|
|
438
|
+
rec["enabled"] = bool(enabled)
|
|
439
|
+
_save_registry(reg)
|
|
440
|
+
_invalidate_mcp_cache()
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def remove(plugin_id, roots=None):
|
|
445
|
+
plugin_id = str(plugin_id or "").strip()
|
|
446
|
+
with _LOCK:
|
|
447
|
+
reg = _load_registry()
|
|
448
|
+
rec = (reg.get("installed") or {}).get(plugin_id)
|
|
449
|
+
if not rec:
|
|
450
|
+
return "插件尚未安装"
|
|
451
|
+
market_id = str(rec.get("market_id") or "")
|
|
452
|
+
if market_id:
|
|
453
|
+
err = market.remove(market_id)
|
|
454
|
+
if err:
|
|
455
|
+
return err
|
|
456
|
+
target = Path(str(rec.get("install_dir") or "")).resolve()
|
|
457
|
+
try:
|
|
458
|
+
target.relative_to(_install_root().resolve())
|
|
459
|
+
except ValueError:
|
|
460
|
+
return "插件安装路径非法,拒绝删除"
|
|
461
|
+
if target.is_dir():
|
|
462
|
+
shutil.rmtree(target)
|
|
463
|
+
reg["installed"].pop(plugin_id, None)
|
|
464
|
+
_save_registry(reg)
|
|
465
|
+
_invalidate_mcp_cache()
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _catalog_row(desc):
|
|
470
|
+
skill_names = []
|
|
471
|
+
for base in desc.get("skill_paths") or []:
|
|
472
|
+
if base.is_file():
|
|
473
|
+
skill_names.append(base.name)
|
|
474
|
+
elif base.is_dir():
|
|
475
|
+
skill_names.extend(p.relative_to(base).as_posix() for p in base.rglob("*.md") if p.is_file())
|
|
476
|
+
return {
|
|
477
|
+
"id": desc["name"], "name": desc["name"],
|
|
478
|
+
"display_name": desc["display_name"], "description": desc["description"],
|
|
479
|
+
"short_description": desc["short_description"], "version": desc["version"],
|
|
480
|
+
"author": desc["author"], "category": desc["category"],
|
|
481
|
+
"capabilities": desc["capabilities"], "skills": sorted(skill_names),
|
|
482
|
+
"skill_count": len(skill_names), "has_mcp": bool(desc.get("mcp_path") or desc.get("mcp_inline")),
|
|
483
|
+
"unsupported": desc["unsupported"], "source": desc["dir"],
|
|
484
|
+
"available": True, "installed": False, "state": "available",
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def view(roots=None):
|
|
489
|
+
rows = {x.get("id"): x for x in discover(roots) if x.get("id")}
|
|
490
|
+
reg = _load_registry().get("installed") or {}
|
|
491
|
+
for plugin_id, rec in reg.items():
|
|
492
|
+
target_raw = str(rec.get("install_dir") or "").strip()
|
|
493
|
+
target = Path(target_raw) if target_raw else None
|
|
494
|
+
if plugin_id not in rows and target is not None and target.is_dir():
|
|
495
|
+
try:
|
|
496
|
+
rows[plugin_id] = _catalog_row(load_manifest(target))
|
|
497
|
+
except PluginError as exc:
|
|
498
|
+
rows[plugin_id] = {"id": plugin_id, "name": plugin_id, "state": "broken",
|
|
499
|
+
"available": False, "installed": True, "error": str(exc)}
|
|
500
|
+
row = rows.get(plugin_id)
|
|
501
|
+
if not row:
|
|
502
|
+
continue
|
|
503
|
+
row["installed"] = bool(target is not None and target.is_dir())
|
|
504
|
+
row["state"] = "enabled" if rec.get("enabled", True) else "disabled"
|
|
505
|
+
row["enabled"] = bool(rec.get("enabled", True))
|
|
506
|
+
row["install_dir"] = str(target) if target is not None else ""
|
|
507
|
+
row["skill_pack_ids"] = list(rec.get("skill_pack_ids") or [])
|
|
508
|
+
items = sorted(rows.values(), key=lambda x: (str(x.get("category") or ""), str(x.get("display_name") or x.get("name") or "")))
|
|
509
|
+
cats = sorted({str(x.get("category")) for x in items if x.get("category")})
|
|
510
|
+
return {"plugins": items, "categories": cats, "total": len(items)}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def active_mcp_servers():
|
|
514
|
+
"""Return enabled plugin MCP declarations in existing client format."""
|
|
515
|
+
out = []
|
|
516
|
+
for plugin_id, rec in (_load_registry().get("installed") or {}).items():
|
|
517
|
+
if not rec.get("enabled", True):
|
|
518
|
+
continue
|
|
519
|
+
path = Path(str(rec.get("install_dir") or ""))
|
|
520
|
+
try:
|
|
521
|
+
out.extend(mcp_servers(plugin_id, roots=[path]))
|
|
522
|
+
except PluginError:
|
|
523
|
+
continue
|
|
524
|
+
return [x for x in out if isinstance(x, dict) and x.get("command")]
|
|
525
|
+
|