codebee 0.1.69 → 0.1.70

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -3
  3. package/app/browser-final-1440.png +0 -0
  4. package/app/browser-final-390.png +0 -0
  5. package/app/browser-google-fallback.png +0 -0
  6. package/app/browser-google-test.png +0 -0
  7. package/app/browser-workbench-1440.png +0 -0
  8. package/app/browser-workbench-390.png +0 -0
  9. package/app/browser-workspace-google.png +0 -0
  10. package/app/browser-workspace-smoke.png +0 -0
  11. package/app/browser-workspace.png +0 -0
  12. package/app/core/audit.py +30 -0
  13. package/app/core/automation.py +107 -12
  14. package/app/core/benchstore.py +95 -0
  15. package/app/core/bookmeta.py +9 -4
  16. package/app/core/builtin_agent.py +472 -33
  17. package/app/core/catalog.py +7 -1
  18. package/app/core/dispatch.py +20 -3
  19. package/app/core/error_codes.py +11 -2
  20. package/app/core/evalbench.py +720 -0
  21. package/app/core/flows.py +228 -3
  22. package/app/core/jobs.py +22 -1
  23. package/app/core/manager.py +21 -0
  24. package/app/core/market_remote.py +10 -0
  25. package/app/core/mcp_client.py +287 -0
  26. package/app/core/modelhub.py +185 -2
  27. package/app/core/notify.py +118 -23
  28. package/app/core/novel_quality.py +189 -0
  29. package/app/core/paths.py +4 -0
  30. package/app/core/pipeline.py +426 -28
  31. package/app/core/planner.py +243 -22
  32. package/app/core/plugins.py +525 -0
  33. package/app/core/publish/browser.py +129 -13
  34. package/app/core/publish/fanqie.py +6 -0
  35. package/app/core/publish/flow.py +154 -5
  36. package/app/core/publish/flows-fanqie-calibrated.json +11 -7
  37. package/app/core/publish/ledger.py +29 -3
  38. package/app/core/publish/manager.py +134 -2
  39. package/app/core/publish/qimao.py +5 -0
  40. package/app/core/remotesync.py +121 -0
  41. package/app/core/runner.py +80 -5
  42. package/app/core/settings.py +39 -2
  43. package/app/core/settings_schema.py +8 -0
  44. package/app/core/store.py +30 -8
  45. package/app/core/usage.py +187 -14
  46. package/app/core/zentao.py +153 -4
  47. package/app/current-browser.png +0 -0
  48. package/app/main.py +279 -15
  49. package/app/mcp_server.py +175 -0
  50. package/app/pet.py +82 -21
  51. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  52. package/app/plugins/code-review-helper/skills/review.md +16 -0
  53. package/app/ui/app.js +1380 -73
  54. package/app/ui/i18n.js +248 -6
  55. package/app/ui/index.html +277 -13
  56. package/app/ui/style.css +786 -0
  57. package/package.json +2 -1
@@ -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
+