pi-web-ui 0.94.1 → 0.95.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.
Files changed (63) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +751 -206
  5. package/dist/server/agent-service.js +1869 -85
  6. package/dist/server/approval-rules.js +550 -0
  7. package/dist/server/attachment-store.js +112 -0
  8. package/dist/server/attachments.js +62 -171
  9. package/dist/server/client-state.js +4 -0
  10. package/dist/server/compact-context-tool.js +125 -0
  11. package/dist/server/context-budget.js +317 -0
  12. package/dist/server/dangling-tools.js +162 -0
  13. package/dist/server/dsh/dsh-agent-service.js +50 -60
  14. package/dist/server/edit-soft-tool.js +70 -5
  15. package/dist/server/eval-tool.js +535 -0
  16. package/dist/server/files-service.js +35 -7
  17. package/dist/server/goal-service.js +240 -68
  18. package/dist/server/hashline-engine.js +665 -0
  19. package/dist/server/host-metrics.js +26 -2
  20. package/dist/server/index.js +157 -9
  21. package/dist/server/lsp-tool.js +679 -0
  22. package/dist/server/model-admin.js +20 -0
  23. package/dist/server/office-parse.js +309 -0
  24. package/dist/server/patch-tool.js +91 -0
  25. package/dist/server/plan-manager.js +104 -0
  26. package/dist/server/plugin-api-catalog.js +296 -0
  27. package/dist/server/plugin-install-spec.js +196 -0
  28. package/dist/server/plugin-installer.js +73 -0
  29. package/dist/server/plugin-manifest-validate.js +305 -0
  30. package/dist/server/plugin-project.js +47 -11
  31. package/dist/server/plugin-tool-guard.js +120 -0
  32. package/dist/server/plugins.js +745 -161
  33. package/dist/server/process-utils.js +12 -6
  34. package/dist/server/scheduler-tasks.js +6 -0
  35. package/dist/server/serialize.js +13 -3
  36. package/dist/server/settings-service.js +108 -1
  37. package/dist/server/subagents.js +41 -0
  38. package/dist/server/terminals.js +69 -14
  39. package/dist/server/tool-approval.js +84 -0
  40. package/dist/server/tool-manager.js +214 -6
  41. package/dist/server/workspace-snapshot.js +113 -0
  42. package/package.json +2 -2
  43. package/plugin-sdk/README.md +25 -0
  44. package/plugin-sdk/index.d.ts +31 -38
  45. package/plugin-sdk/index.mjs +35 -19
  46. package/plugins/catalog.json +20 -0
  47. package/themes/ayu-light.css +6 -6
  48. package/themes/catppuccin-latte.css +6 -6
  49. package/themes/claude-code-dark.css +144 -0
  50. package/themes/codex.css +6 -6
  51. package/themes/everforest-light.css +6 -6
  52. package/themes/geist.css +6 -6
  53. package/themes/gruvbox-light.css +6 -6
  54. package/themes/kanagawa-lotus.css +6 -6
  55. package/themes/rose-pine-dawn.css +6 -6
  56. package/themes/solarized-light.css +6 -6
  57. package/themes/vs-code-dark.css +146 -0
  58. package/web/dist/assets/{TerminalPanel-MVoxpJOA.js → TerminalPanel-DlGBF2MD.js} +1 -1
  59. package/web/dist/assets/index-BXnCQL9p.js +364 -0
  60. package/web/dist/assets/index-RpIGFyi_.css +1 -0
  61. package/web/dist/index.html +2 -2
  62. package/web/dist/assets/index-B3S9MxnN.css +0 -1
  63. package/web/dist/assets/index-DVLrHI2E.js +0 -364
@@ -0,0 +1,296 @@
1
+ export const PLUGIN_API_CATALOG_VERSION = 1;
2
+ /** 每个 slot 的 manifest 最小例子(与 parseUiContributions 同口径,别名已展开写法也收)。 */
3
+ export const SLOT_EXAMPLES = {
4
+ "topbar.primary": `{ "topbar": [{ "id": "inbox", "label": "收件箱", "kind": "action", "action": "my:open" }] }`,
5
+ "topbar.overflow": `{ "topbar.more": [{ "id": "about", "label": "关于", "kind": "action", "action": "my:about" }] }`,
6
+ bottombar: `{ "ui": { "bottombar": [{ "id": "st", "label": "状态", "kind": "action", "action": "my:st" }] } }`,
7
+ "composer.leading": `{ "ui": { "composer.leading": [{ "id": "m", "label": "+", "kind": "action", "action": "my:m" }] } }`,
8
+ "composer.actions": `{ "composer": [{ "id": "pick", "label": "拾取", "kind": "action", "action": "my:pick" }] }`,
9
+ "message.actions": `{ "message": [{ "id": "send", "label": "转存", "kind": "action", "action": "my:send" }] }`,
10
+ "rightpanel.tabs": `{ "rightpanel": [{ "id": "mail", "label": "邮件", "kind": "view" }] }`,
11
+ "contextmenu.topbar": `{ "ui": { "contextmenu.topbar": [{ "id": "m", "label": "顶栏菜单", "kind": "action", "action": "my:m" }] } }`,
12
+ "contextmenu.message": `{ "ui": { "contextmenu.message": [{ "id": "m", "label": "消息菜单", "kind": "action", "action": "my:m" }] } }`,
13
+ "contextmenu.session": `{ "ui": { "contextmenu.session": [{ "id": "m", "label": "会话菜单", "kind": "action", "action": "my:m" }] } }`,
14
+ "contextmenu.file": `{ "contextmenu.file": [{ "id": "mail", "label": "发到邮箱", "kind": "action", "action": "my:send" }] }`,
15
+ "contextmenu.toolcall": `{ "ui": { "contextmenu.toolcall": [{ "id": "m", "label": "工具菜单", "kind": "action", "action": "my:m" }] } }`,
16
+ "settings.pages": `{ "settings": [{ "id": "conf", "label": "邮箱设置", "kind": "page" }] }`,
17
+ "leftpanel.sessions": `{ "ui": { "leftpanel.sessions": [{ "id": "m", "label": "会话区", "kind": "action", "action": "my:m" }] } }`,
18
+ "chat.header": `{ "ui": { "chat.header": [{ "id": "m", "label": "会话题头", "kind": "action", "action": "my:m" }] } }`,
19
+ "chat.empty": `{ "ui": { "chat.empty": [{ "id": "m", "label": "空态", "kind": "action", "action": "my:m" }] } }`,
20
+ "file.preview.toolbar": `{ "ui": { "file.preview.toolbar": [{ "id": "m", "label": "预览工具", "kind": "action", "action": "my:m" }] } }`,
21
+ "terminal.toolbar": `{ "ui": { "terminal.toolbar": [{ "id": "m", "label": "终端工具", "kind": "action", "action": "my:m" }] } }`,
22
+ "scm.toolbar": `{ "ui": { "scm.toolbar": [{ "id": "m", "label": "SCM 工具", "kind": "action", "action": "my:m" }] } }`,
23
+ "goalbar.actions": `{ "ui": { "goalbar.actions": [{ "id": "m", "label": "目标条", "kind": "action", "action": "my:m" }] } }`,
24
+ "notice.actions": `{ "ui": { "notice.actions": [{ "id": "m", "label": "通知动作", "kind": "action", "action": "my:m" }] } }`,
25
+ "modal.dialog": `{ "modal": [{ "id": "m", "label": "弹窗", "kind": "action", "action": "my:m" }] }`,
26
+ };
27
+ /** 宿主方法表(与 server/plugins.ts#PluginHost 同语义的精简版:只收发现用的注册/调用面)。
28
+ * needs="-" = 观察/基础设施类(无需能力声明);其余走 can() 门控。 */
29
+ export const HOST_METHODS = [
30
+ {
31
+ name: "ui.register",
32
+ needs: "ui",
33
+ summary: "运行时注册 UI 条目(与 manifest 同口径,别名映射+枚举校验)",
34
+ example: `host.ui.register({ slot: "topbar.primary", id: "btn", label: "按钮" })`,
35
+ },
36
+ {
37
+ name: "ui.update",
38
+ needs: "ui",
39
+ summary: "按 id 改已存在条目(label/badge/checked/value/progress)",
40
+ example: `host.ui.update("btn", { badge: "3" })`,
41
+ },
42
+ {
43
+ name: "ui.remove",
44
+ needs: "ui",
45
+ summary: "移除条目(manifest 声明的也能压住,直到 reload)",
46
+ example: `host.ui.remove("btn")`,
47
+ },
48
+ {
49
+ name: "ui.arrange",
50
+ needs: "ui",
51
+ summary: "整理任何条目(含 host:* 内置):hide/order/group/label",
52
+ example: `host.ui.arrange([{ id: "host:tasks", hide: true }])`,
53
+ },
54
+ {
55
+ name: "ui.list",
56
+ needs: "ui",
57
+ summary: "自查本插件的 items + arrange(调别的插件看不见)",
58
+ example: `host.ui.list()`,
59
+ },
60
+ {
61
+ name: "onUiAction",
62
+ needs: "ui",
63
+ summary: "订阅条目点击(action 名 → 回调,client bundle 侧)",
64
+ example: `__piWebUiHost.onUiAction("my:open", () => {})`,
65
+ },
66
+ {
67
+ name: "registerAgentTool",
68
+ needs: "tools",
69
+ summary: "给 AI 注册工具(插件给模型加能力,与 AI 写插件方向相反)",
70
+ example: `host.registerAgentTool({ name: "mail_list", description: "…", execute: async () => ({}) })`,
71
+ },
72
+ {
73
+ name: "onToolPre",
74
+ needs: "tools",
75
+ summary: "工具 pre 拦截(仅 bash/read):allow/deny/ask,首个阻断胜出",
76
+ example: `host.onToolPre((req) => req.params?.command?.includes("rm -rf") ? { decision: "deny" } : undefined)`,
77
+ },
78
+ {
79
+ name: "onToolPost",
80
+ needs: "tools",
81
+ summary: "工具 post 编辑(仅 bash/read):换 content / 补 additionalContext",
82
+ example: `host.onToolPost(({ result }) => ({ content: result.content }))`,
83
+ },
84
+ {
85
+ name: "onToolEvent",
86
+ needs: "-",
87
+ summary: "订阅工具执行事件(start/end 成对,只观测)",
88
+ example: `host.onToolEvent((ev) => {})`,
89
+ },
90
+ {
91
+ name: "bash",
92
+ needs: "tools",
93
+ summary: "受限 shell(无 piped shell,按词切分;cwd 锁工作区内,默认 60s 超时)",
94
+ example: `await host.bash("ls", { timeoutMs: 10000 })`,
95
+ },
96
+ {
97
+ name: "registerCommand",
98
+ needs: "-",
99
+ summary: "注册斜杠命令(/name 选择器 + prompt 拦截执行)",
100
+ example: `host.registerCommand({ name: "deploy", description: "部署", run: (args) => {} })`,
101
+ },
102
+ {
103
+ name: "route",
104
+ needs: "http",
105
+ summary: "挂载 HTTP 路由(/plugins-api/<id><path>)",
106
+ example: `host.route("GET", "/inbox", (req, res) => res.json([]))`,
107
+ },
108
+ {
109
+ name: "registerProxy",
110
+ needs: "http",
111
+ summary: "反向代理前缀(透传到 127.0.0.1:port,防 SSRF 锁回环)",
112
+ example: `host.registerProxy("/app", 3000)`,
113
+ },
114
+ {
115
+ name: "chat",
116
+ needs: "chat",
117
+ summary: "无头调用:把外部通道文本投给 agent(fire-and-forget)",
118
+ example: `await host.chat({ text: "hi" })`,
119
+ },
120
+ {
121
+ name: "chatWait",
122
+ needs: "chat",
123
+ summary: "等无头调用的 run_end(默认 120s,上下钳制)",
124
+ example: `await host.chatWait({ conversationId })`,
125
+ },
126
+ {
127
+ name: "llm.complete",
128
+ needs: "llm",
129
+ summary: "孤立无工具一次性补全(花用户模型额度)",
130
+ example: `await host.llm.complete({ prompt: "…" })`,
131
+ },
132
+ {
133
+ name: "requestPermission",
134
+ needs: "-",
135
+ summary: "申请能力授权(net 主机 / llm 模型作用域,用户确认)",
136
+ example: `await host.requestPermission({ family: "net", hosts: ["api.example.com"] })`,
137
+ },
138
+ {
139
+ name: "fs",
140
+ needs: "fs",
141
+ summary: "工作区文件(list/read/readText/write/remove…,锚定活 cwd,越界拒绝)",
142
+ example: `await host.fs.readText("notes/todo.md")`,
143
+ },
144
+ {
145
+ name: "fs.requestAccess",
146
+ needs: "fs",
147
+ summary: "申请工作区外目录授权(浏览器确认框,remember 记盘)",
148
+ example: `await host.fs.requestAccess("/data")`,
149
+ },
150
+ {
151
+ name: "project.create",
152
+ needs: "fs",
153
+ summary: "在已授权目录组装项目(mkdir/clone/写文件/git init)",
154
+ example: `await host.project.create({ dir: "/data/app", files: {} })`,
155
+ },
156
+ {
157
+ name: "schedule",
158
+ needs: "-",
159
+ summary: "定时任务(cron/延迟,持久化可重启不丢,远期分片防溢出)",
160
+ example: `host.schedule("0 9 * * *", () => {})`,
161
+ },
162
+ {
163
+ name: "registerBackgroundTask",
164
+ needs: "-",
165
+ summary: "常驻后台任务(并入后台任务面板,随反激活移除)",
166
+ example: `host.registerBackgroundTask({ id: "svc", label: "服务" })`,
167
+ },
168
+ {
169
+ name: "onRunEvent",
170
+ needs: "-",
171
+ summary: "订阅运行轨迹(run/message/tool…,时间线聚合用)",
172
+ example: `host.onRunEvent((ev) => {})`,
173
+ },
174
+ {
175
+ name: "getActiveConversation",
176
+ needs: "-",
177
+ summary: "读当前打开对话快照(只读引用,广播前抽摘要)",
178
+ example: `host.getActiveConversation()`,
179
+ },
180
+ {
181
+ name: "onConversationChanged",
182
+ needs: "-",
183
+ summary: "订阅切对话(轨迹插件重拉时间线)",
184
+ example: `host.onConversationChanged(() => {})`,
185
+ },
186
+ {
187
+ name: "storage",
188
+ needs: "-",
189
+ summary: "插件私有 KV(storage.json,原子写,卸载即删)",
190
+ example: `host.storage.get("k")`,
191
+ },
192
+ { name: "secrets", needs: "-", summary: "加密 secrets(明文永不落盘/下发)", example: `host.secrets.get("token")` },
193
+ {
194
+ name: "ensureDeps",
195
+ needs: "-",
196
+ summary: "依赖自动补装(单飞,npm 包)",
197
+ example: `await host.ensureDeps(["dayjs"])`,
198
+ },
199
+ {
200
+ name: "getSettings",
201
+ needs: "-",
202
+ summary: "读声明式设置值(manifest.settings 字段)",
203
+ example: `host.getSettings()`,
204
+ },
205
+ {
206
+ name: "onSettingsChanged",
207
+ needs: "-",
208
+ summary: "订阅设置保存(⚙ 面板保存后触发)",
209
+ example: `host.onSettingsChanged((v) => {})`,
210
+ },
211
+ { name: "onCwdChange", needs: "-", summary: "订阅工作区切换", example: `host.onCwdChange((cwd) => {})` },
212
+ {
213
+ name: "onAttach",
214
+ needs: "-",
215
+ summary: "订阅浏览器接入(主动推完整状态,服务端是唯一事实源)",
216
+ example: `host.onAttach((clientId) => {})`,
217
+ },
218
+ {
219
+ name: "broadcast",
220
+ needs: "-",
221
+ summary: "向所有浏览器广播本插件消息",
222
+ example: `host.broadcast({ kind: "state" })`,
223
+ },
224
+ { name: "notify", needs: "-", summary: "发系统通知条(notice toast)", example: `host.notify("info", "完成")` },
225
+ {
226
+ name: "effect",
227
+ needs: "-",
228
+ summary: "自建副作用挂进 effect 栈(反激活逆序回卷)",
229
+ example: `host.effect("timer", () => clearInterval(t))`,
230
+ },
231
+ {
232
+ name: "events.emit/on",
233
+ needs: "-",
234
+ summary: "插件间事件总线(topic 建议 <id>: 前缀,发送方不可伪造)",
235
+ example: `host.events.on("notes:changed", () => {})`,
236
+ },
237
+ { name: "models.list", needs: "-", summary: "列出已配置鉴权的模型(provider/id)", example: `host.models.list()` },
238
+ {
239
+ name: "scm",
240
+ needs: "-",
241
+ summary: "只读 git 查询(status/branches/history,不经过 shell)",
242
+ example: `await host.scm.status()`,
243
+ },
244
+ {
245
+ name: "net.fetch",
246
+ needs: "net",
247
+ summary: "出站网络(白名单命中才放行,失败回 {ok:false} 不抛)",
248
+ example: `await host.net.fetch("https://api.example.com/x")`,
249
+ },
250
+ {
251
+ name: "conversations",
252
+ needs: "-",
253
+ summary: "会话目录(list/get/search,只读组装+定向投递)",
254
+ example: `await host.conversations.list()`,
255
+ },
256
+ {
257
+ name: "prompt",
258
+ needs: "-",
259
+ summary: "向指定对话发一条用户消息(无注入回 ok:false)",
260
+ example: `await host.prompt(convId, { text: "hi" })`,
261
+ },
262
+ { name: "steer", needs: "-", summary: "插队指定对话的当前运行", example: `await host.steer(convId, "换方向")` },
263
+ { name: "abortRun", needs: "-", summary: "中止指定对话的运行", example: `await host.abortRun(convId)` },
264
+ ];
265
+ /** 装配目录:静态表(本模块)+ 动态占用(调用方现算)。调用方保证入参即真相(别名/枚举与解析层同源)。 */
266
+ export function buildPluginApiCatalog(opts) {
267
+ const aliasByTarget = new Map();
268
+ for (const [alias, target] of Object.entries(opts.aliases)) {
269
+ const list = aliasByTarget.get(target) ?? [];
270
+ list.push(alias);
271
+ aliasByTarget.set(target, list);
272
+ }
273
+ const slots = [...opts.slots].sort().map((slot) => {
274
+ const example = SLOT_EXAMPLES[slot] ??
275
+ `{ "ui": { "${slot}": [{ "id": "m", "label": "条目", "kind": "action", "action": "my:m" }] } }`;
276
+ return {
277
+ slot,
278
+ aliases: [...(aliasByTarget.get(slot) ?? [])].sort(),
279
+ kinds: [...opts.kinds].sort(),
280
+ occupants: [...opts.occupantsOf(slot)].sort((a, b) => (a.pluginId < b.pluginId ? -1 : 1)),
281
+ replaceRisk: "插件 arrange 可改任何条目(含 host:* 内置),用户偏好最后说话(布局页可恢复)",
282
+ example,
283
+ };
284
+ });
285
+ return {
286
+ version: PLUGIN_API_CATALOG_VERSION,
287
+ slots,
288
+ agentTools: opts.agentTools.map((t) => ({
289
+ name: t.name,
290
+ group: t.group,
291
+ defaultOn: t.defaultOn,
292
+ dshVisible: t.dshVisible,
293
+ })),
294
+ hostMethods: HOST_METHODS.map((m) => ({ ...m })),
295
+ };
296
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * plugin-install-spec — 「安装前先读 spec」(DSH 对照清单 P0-3 / 引导式安装)。
3
+ *
4
+ * 背景:设置面板「添加到列表 / 从市场安装」原样把用户填的来源交给 CLI。打错字、
5
+ * 已装过、根本不是插件包,全都要等 pnpm/git 跑完、用户在输出里自己找原因。
6
+ * 这里在**动 CLI 之前**先做一次可解释的检查,把失败归到七种 problem 之一,
7
+ * 每种都给一句人话 +(能给的)修复建议。
8
+ *
9
+ * 纪律:
10
+ * - 本模块**不联网、不写盘**(纯分类 + 本地文件系统探测);真正的远端探测
11
+ * (raw.githubusercontent 读 manifest.json)在 installer 的 inspect 里做,
12
+ * 这样本文件可以离线单测、也可以被前端复用同一套文案 key。
13
+ * - 分类结果只是**建议**,不是硬门禁:installer 仍会在真正安装前再校验一次,
14
+ * 未知/疑似可用一律放行(宁可让 CLI 说话,也不要因为探测失败把能装的挡掉)。
15
+ */
16
+ import { existsSync, readFileSync, statSync } from "node:fs";
17
+ import { isAbsolute, join } from "node:path";
18
+ import { isValidSource } from "./plugin-catalog.js";
19
+ /** npm 包名形状(宽松):可带 @scope/ 前缀与 @version 后缀;不含 `/` 之外的路径段。 */
20
+ const NPM_RE = /^(?:@[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\/)?[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?:@[^\s/]+)?$/i;
21
+ /**
22
+ * 解析并按形状分类一个安装来源。纯函数、不碰网络与磁盘(除 `path` 分支的
23
+ * 存在性判断——那是刻意让「本机路径 vs 形状不对」可区分)。
24
+ */
25
+ export function parseInstallSpec(rawSpec) {
26
+ const raw = String(rawSpec ?? "").trim();
27
+ if (!raw)
28
+ return { kind: "invalid", normalized: "" };
29
+ // 1) 本机路径:绝对路径、file:// 前缀、或当前存在的相对目录(CLI 自己的判据)。
30
+ const localCandidate = raw.replace(/^file:\/\//i, "");
31
+ if (isAbsolute(localCandidate) || safeExists(localCandidate))
32
+ return { kind: "path", normalized: localCandidate };
33
+ // 2) 完整 URL(git 仓库 / 目录文档)。
34
+ if (/^https?:\/\//i.test(raw)) {
35
+ // GitHub 的网页 URL 仍归 github(能拿 owner/repo/ref/subpath,可做远端探测)。
36
+ const gh = parseGithubUrl(raw);
37
+ if (gh)
38
+ return gh;
39
+ return { kind: "url", normalized: raw };
40
+ }
41
+ // 3) GitHub 的 owner/repo[/sub][#ref] 简写。
42
+ const short = parseGithubShort(raw);
43
+ if (short)
44
+ return short;
45
+ // 4) npm 包名。
46
+ if (NPM_RE.test(raw))
47
+ return { kind: "npm", normalized: raw };
48
+ return { kind: "invalid", normalized: raw };
49
+ }
50
+ function safeExists(p) {
51
+ try {
52
+ return existsSync(p);
53
+ }
54
+ catch {
55
+ // 非法路径字符(Windows 上的 `<` `>` 等)→ 当不存在。
56
+ return false;
57
+ }
58
+ }
59
+ /** 解析 https://github.com/owner/repo[/tree/<ref>[/<sub>]] 或 `owner/repo` 简写。 */
60
+ function parseGithubUrl(raw) {
61
+ const m = raw.match(/^https?:\/\/(?:www\.)?github\.com\/([^/]+)\/([^/#?]+)(?:\/(?:tree|blob)\/([^/]+)((?:\/[^#?]+)*))?/i);
62
+ if (!m)
63
+ return null;
64
+ const [, owner, repoRaw, refRaw, rest] = m;
65
+ const repo = String(repoRaw ?? "").replace(/\.git$/i, "");
66
+ const subpath = String(rest ?? "")
67
+ .replace(/^\/+/, "")
68
+ .replace(/\/+$/, "");
69
+ const hashRef = raw.includes("#") ? raw.slice(raw.indexOf("#") + 1).trim() : "";
70
+ const ref = hashRef || String(refRaw ?? "");
71
+ return {
72
+ kind: "github",
73
+ normalized: [owner, repo, subpath].filter(Boolean).join("/") + (ref ? `#${ref}` : ""),
74
+ owner,
75
+ repo,
76
+ ...(ref ? { ref } : {}),
77
+ ...(subpath ? { subpath } : {}),
78
+ };
79
+ }
80
+ /** 解析 owner/repo[/sub][#ref](cli 与 marketplace 的主写法)。 */
81
+ function parseGithubShort(raw) {
82
+ // 带 @ 前缀 = npm scope(@scope/pkg),GitHub 的 owner 不允许 @ —— 先摘出去,
83
+ // 免得 `@scope/pkg` 被当成 owner=`@scope` 的仓库(分类错会让 UI 给错建议)。
84
+ if (raw.startsWith("@"))
85
+ return null;
86
+ const hash = raw.indexOf("#");
87
+ const ref = hash >= 0 ? raw.slice(hash + 1).trim() : "";
88
+ const body = (hash >= 0 ? raw.slice(0, hash) : raw).replace(/\/+$/, "");
89
+ if (!isValidSource(body))
90
+ return null;
91
+ const segs = body.split("/").filter(Boolean);
92
+ const [owner, repo, ...rest] = segs;
93
+ if (!owner || !repo)
94
+ return null;
95
+ // URL 的 tree/blob 写法也可简写过来(owner/repo/tree/<ref>/<sub>)。
96
+ let subpath = rest.join("/");
97
+ let effectiveRef = ref;
98
+ if (rest[0] === "tree" || rest[0] === "blob") {
99
+ effectiveRef = rest[1] ?? ref;
100
+ subpath = rest.slice(2).join("/");
101
+ }
102
+ return {
103
+ kind: "github",
104
+ normalized: body + (effectiveRef ? `#${effectiveRef}` : ""),
105
+ owner,
106
+ repo,
107
+ ...(effectiveRef ? { ref: effectiveRef } : {}),
108
+ ...(subpath ? { subpath } : {}),
109
+ };
110
+ }
111
+ /** 粗略的 id 字符集(与 server/plugins.ts 的 ID_RE 一致)。 */
112
+ const ID_RE = /^[A-Za-z0-9_-]+$/;
113
+ /** 由来源推导落盘 id(与 CLI / plugin-catalog 同规则:子目录末段 > 仓库名 > 末段)。 */
114
+ export function suggestPluginId(spec, explicit) {
115
+ if (explicit && ID_RE.test(explicit))
116
+ return explicit;
117
+ const seg = spec.subpath
118
+ ? spec.subpath.split("/").filter(Boolean).pop()
119
+ : (spec.repo ?? lastSegment(spec.normalized));
120
+ const cleaned = String(seg ?? "")
121
+ .replace(/[^A-Za-z0-9_-]/g, "-")
122
+ .replace(/^-+|-+$/g, "");
123
+ return cleaned || "plugin";
124
+ }
125
+ function lastSegment(s) {
126
+ const body = s.split("#")[0].replace(/\/+$/, "");
127
+ const segs = body.split("/").filter(Boolean);
128
+ return segs[segs.length - 1] ?? "";
129
+ }
130
+ /**
131
+ * 本地检查:形状分类 + 已装判定。**不联网** —— 远端 manifest 探测由 installer
132
+ * 的 `inspectRemote()` 补上(本函数是它的第一步,也可单独用于「添加到列表」)。
133
+ */
134
+ export function inspectLocalInstallSpec(rawSpec, deps) {
135
+ const spec = parseInstallSpec(rawSpec);
136
+ const suggestedId = suggestPluginId(spec, deps.explicitId);
137
+ const installed = safeExists(join(deps.pluginsDir, suggestedId));
138
+ const base = { spec, installed, suggestedId };
139
+ if (spec.kind === "invalid")
140
+ return {
141
+ ...base,
142
+ problem: "invalid-spec",
143
+ detail: "Unrecognized source — expected owner/repo, owner/repo/subdir[#ref], a GitHub URL, or an npm package name.",
144
+ };
145
+ if (spec.kind === "path" && !safeExists(spec.normalized))
146
+ return { ...base, problem: "not-found", detail: `Local path does not exist: ${spec.normalized}` };
147
+ if (installed && !deps.force)
148
+ return {
149
+ ...base,
150
+ problem: "already-installed",
151
+ detail: `A plugin named "${suggestedId}" is already installed — use Update to replace it.`,
152
+ };
153
+ return base;
154
+ }
155
+ /** 读本地目录里的 manifest.json(本地路径源用;失败返回 null,不抛)。 */
156
+ export function readLocalManifest(dir) {
157
+ try {
158
+ const p = join(dir, "manifest.json");
159
+ if (!statSync(p).isFile())
160
+ return null;
161
+ const raw = JSON.parse(readFileSync(p, "utf8"));
162
+ return pickManifestFields(raw);
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ }
168
+ /** 从任意 manifest 对象里摘出可展示字段(顺带校验它是插件)。 */
169
+ export function pickManifestFields(raw) {
170
+ if (!raw || typeof raw !== "object")
171
+ return null;
172
+ const o = raw;
173
+ if (typeof o.id !== "string" && typeof o.name !== "string")
174
+ return null;
175
+ const permissions = Array.isArray(o.permissions)
176
+ ? o.permissions.filter((x) => typeof x === "string").slice(0, 32)
177
+ : undefined;
178
+ return {
179
+ ...(typeof o.id === "string" ? { id: o.id } : {}),
180
+ ...(typeof o.name === "string" ? { name: o.name } : {}),
181
+ ...(typeof o.version === "string" ? { version: o.version } : {}),
182
+ ...(typeof o.description === "string" ? { description: o.description } : {}),
183
+ ...(permissions ? { permissions } : {}),
184
+ };
185
+ }
186
+ /**
187
+ * 远端 manifest 的候选 raw URL(按 ref 有无/子目录有无各一条)。
188
+ * 只用 raw.githubusercontent.com —— 与 server/locales.ts 同一取用姿势,无需 API token。
189
+ */
190
+ export function manifestCandidateUrls(spec) {
191
+ if (spec.kind !== "github" || !spec.owner || !spec.repo)
192
+ return [];
193
+ const ref = spec.ref || "HEAD";
194
+ const base = `https://raw.githubusercontent.com/${spec.owner}/${spec.repo}/${ref}`;
195
+ return spec.subpath ? [`${base}/${spec.subpath}/manifest.json`] : [`${base}/manifest.json`];
196
+ }
@@ -22,6 +22,7 @@ import { existsSync } from "node:fs";
22
22
  import { join } from "node:path";
23
23
  import { pick } from "./i18n.js";
24
24
  import { isValidSource } from "./plugin-catalog.js";
25
+ import { inspectLocalInstallSpec, manifestCandidateUrls, readLocalManifest, suggestPluginId, } from "./plugin-install-spec.js";
25
26
  import { killPidTree } from "./process-utils.js";
26
27
  /** 插件 id 字符集(与 server/plugins.ts、plugin-catalog.ts 一致,防路径穿越)。 */
27
28
  const ID_RE = /^[A-Za-z0-9_-]+$/;
@@ -60,6 +61,78 @@ export function buildPluginJobArgs(spec, dataDir, lang) {
60
61
  args.push("--no-build");
61
62
  return { args };
62
63
  }
64
+ /** 安装前的 spec 检查(DSH P0-3 引导式安装)。
65
+ *
66
+ * 三层:① 形状分类(parseInstallSpec)+ 本地已装判定(inspectLocalInstallSpec);
67
+ * ② 本地路径源:直接读它的 manifest.json;
68
+ * ③ 远端 GitHub 源:一次 raw.githubusercontent 探测(超时 6s,失败不阻断)——
69
+ * 拿到 manifest 就归到 not-found / not-a-package / not-a-bundle 之一,
70
+ * 拿不到(网络/代理问题)回 network 但**不阻塞安装**(problem 只作提示)。
71
+ *
72
+ * 通一不联网:测试时传 fetchImpl 替身;生产走全局 fetch。
73
+ */
74
+ export async function inspectInstallSpec(rawSpec, deps) {
75
+ const local = inspectLocalInstallSpec(rawSpec, deps);
76
+ // 形状就不对 / 本地路径不存在 / 已装(且没 force):本地已经能给出结论,不再联网。
77
+ if (local.problem || local.spec.kind === "npm" || local.spec.kind === "url") {
78
+ // npm/url 不在本检查的覆盖范围(CLI/注册表自己会报)——原样返回,让 CLI 说话。
79
+ return local;
80
+ }
81
+ if (local.spec.kind === "path") {
82
+ const manifest = readLocalManifest(local.spec.normalized);
83
+ if (!manifest)
84
+ return {
85
+ ...local,
86
+ problem: "not-a-package",
87
+ detail: `No manifest.json found in ${local.spec.normalized} — this is not a pi-web-ui plugin.`,
88
+ };
89
+ return { ...local, manifest, suggestedId: suggestPluginId(local.spec, deps.explicitId ?? manifest.id) };
90
+ }
91
+ // github 简写:远端探测 best-effort(失败只补一条提示,不挡安装)。
92
+ for (const url of manifestCandidateUrls(local.spec)) {
93
+ try {
94
+ const res = await (deps.fetchImpl ?? fetch)(url, { signal: AbortSignal.timeout(deps.timeoutMs ?? 6000) });
95
+ if (res.status === 404)
96
+ continue;
97
+ if (!res.ok)
98
+ return { ...local, problem: "network", detail: `Remote probe failed: HTTP ${res.status}` };
99
+ const manifest = pickManifest(local, await res.json());
100
+ if (!manifest)
101
+ return {
102
+ ...local,
103
+ problem: "not-a-bundle",
104
+ detail: "The remote manifest.json is not a valid plugin manifest (missing id/name).",
105
+ };
106
+ return { ...local, manifest, suggestedId: suggestPluginId(local.spec, deps.explicitId ?? manifest.id) };
107
+ }
108
+ catch {
109
+ return { ...local, problem: "network", detail: "Could not reach the remote repository to verify the plugin." };
110
+ }
111
+ }
112
+ // 连 manifest 都没探到 —— 仓库/子目录/分支不存在,或根本不是插件包。
113
+ return {
114
+ ...local,
115
+ problem: "not-found",
116
+ detail: `No manifest.json at the remote source — check the repo/subdirectory and the #ref.`,
117
+ };
118
+ }
119
+ function pickManifest(local, raw) {
120
+ if (!raw || typeof raw !== "object")
121
+ return null;
122
+ const o = raw;
123
+ if (typeof o.id !== "string" && typeof o.name !== "string")
124
+ return null;
125
+ void local;
126
+ return {
127
+ ...(typeof o.id === "string" ? { id: o.id } : {}),
128
+ ...(typeof o.name === "string" ? { name: o.name } : {}),
129
+ ...(typeof o.version === "string" ? { version: o.version } : {}),
130
+ ...(typeof o.description === "string" ? { description: o.description } : {}),
131
+ ...(Array.isArray(o.permissions)
132
+ ? { permissions: o.permissions.filter((x) => typeof x === "string").slice(0, 32) }
133
+ : {}),
134
+ };
135
+ }
63
136
  export class PluginInstaller {
64
137
  deps;
65
138
  child = null;