@1e0zj/dsh-plugin-mall 0.1.2 → 0.1.6
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/README.md +8 -3
- package/package.json +1 -1
- package/src/client.js +82 -39
- package/src/index.js +76 -6
- package/src/installer.js +123 -6
package/README.md
CHANGED
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
两个入口:
|
|
6
6
|
|
|
7
|
-
1. **设置 → 插件 → 插件市场 tab**(浏览器 UI,dsh web
|
|
8
|
-
2. **会话内
|
|
7
|
+
1. **设置 → 插件 → 插件市场 tab**(浏览器 UI,dsh web 模式):搜索、看详情、点安装/卸载、看进度与已装列表
|
|
8
|
+
2. **会话内 5 个 agent 工具**(所有模式可用)
|
|
9
9
|
|
|
10
10
|
| 工具 | 作用 |
|
|
11
11
|
|---|---|
|
|
12
12
|
| `market_search` | 搜索 GitHub 上带 `topic:dsh-plugin` 标签的仓库(按 star 排序,可按关键词过滤) |
|
|
13
13
|
| `market_info` | 查看单个仓库详情:star、license、package.json、是否声明 `dsh.bundle.patch` / `dsh.client` |
|
|
14
14
|
| `market_install` | 把插件装进某个 profile(后台任务,可用 `job_output` 查看进度) |
|
|
15
|
+
| `market_uninstall` | 把插件从某个 profile 卸载(后台任务:`pnpm remove` + 剔除 bundle 层 + 删除客户端加载行) |
|
|
15
16
|
| `market_installed` | 列出某 profile 已装的插件及其 bundle 状态 |
|
|
16
17
|
|
|
17
18
|
## 安装
|
|
@@ -36,7 +37,7 @@ dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
|
|
|
36
37
|
## 工作原理
|
|
37
38
|
|
|
38
39
|
- 双面包(dual-face)插件:`dsh.bundle` 半边挂在 **host 平面**(profile bundle 层),
|
|
39
|
-
注册
|
|
40
|
+
注册 5 个 agent 工具(进 global 层,所有会话可见,与 MCP 工具同理);
|
|
40
41
|
`dsh.client` 半边是浏览器插件,往设置页插件区注册「插件市场」tab
|
|
41
42
|
(`settings.plugins.tab` slot;手写无构建,经 `window.__ModuleLoader__` 加载)。
|
|
42
43
|
- 浏览器 → 服务端走 Connection 服务的独立 RPC 通道 `/market`
|
|
@@ -46,6 +47,10 @@ dsh plugin --profile web add link:C:\path\to\dsh-plugin-mall
|
|
|
46
47
|
`pnpm add <spec>`,成功后把声明了 `dsh.bundle.patch` 的依赖登记进
|
|
47
48
|
`dsh.profile.bundles`(layer 列表),声明了 `dsh.client` 的依赖自动在
|
|
48
49
|
profile 的 `cordis.patch.yml` 注册加载行,与官方 reconcile 逻辑一致。
|
|
50
|
+
- `market_uninstall` 复刻官方 `dsh plugin remove` 的流程:在 profile 目录跑
|
|
51
|
+
`pnpm remove <package>`,成功后从 `dsh.profile.bundles` 剔除该依赖的
|
|
52
|
+
bundle 条目,并删掉 `cordis.patch.yml` 里由安装流程注册的客户端加载行
|
|
53
|
+
(文本级精准移除,用户手写的行不受影响)。
|
|
49
54
|
- GitHub 源的插件安装时要跑 prepare 构建脚本,pnpm 默认拦截;本插件检测到
|
|
50
55
|
拦截后会把包名自动合并进 profile 的 `pnpm-workspace.yaml` 的 `allowBuilds`
|
|
51
56
|
并自动重试一次。
|
package/package.json
CHANGED
package/src/client.js
CHANGED
|
@@ -15,7 +15,7 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
|
|
16
16
|
// ── styles ──────────────────────────────────────────────────────────────
|
|
17
17
|
var css = [
|
|
18
|
-
".mkt_root{display:flex;flex-direction:column;gap:12px;max-width:
|
|
18
|
+
".mkt_root{display:flex;flex-direction:column;gap:12px;max-width:1040px}",
|
|
19
19
|
".mkt_head{display:flex;flex-direction:column;gap:2px}",
|
|
20
20
|
".mkt_title{font-size:16px;font-weight:600;color:var(--dsw-alias-label-primary)}",
|
|
21
21
|
".mkt_sub{font-size:12px;color:var(--dsw-alias-label-tertiary)}",
|
|
@@ -28,24 +28,28 @@ window.__ModuleLoader__.load({
|
|
|
28
28
|
".mkt_btnPrimary{background:var(--dsw-alias-state-business-primary,#2b6cb0);border-color:transparent;color:#fff}",
|
|
29
29
|
".mkt_btnPrimary:hover:not(:disabled){color:#fff;opacity:.92}",
|
|
30
30
|
".mkt_btnDanger:hover:not(:disabled){border-color:var(--dsw-alias-state-error-primary);color:var(--dsw-alias-state-error-primary)}",
|
|
31
|
+
".mkt_btnSm{padding:3px 10px;font-size:12px}",
|
|
31
32
|
".mkt_error{color:var(--dsw-alias-state-error-primary);font-size:12px;line-height:18px}",
|
|
32
|
-
".
|
|
33
|
-
"@media (max-width:
|
|
34
|
-
".
|
|
33
|
+
".mkt_list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;align-items:stretch;min-width:0}",
|
|
34
|
+
"@media (max-width:820px){.mkt_list{grid-template-columns:minmax(0,1fr)}}",
|
|
35
|
+
".mkt_listHead{grid-column:1/-1;font-size:12px;color:var(--dsw-alias-label-tertiary)}",
|
|
35
36
|
".mkt_card{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px 12px;background:var(--dsw-alias-bg-primary,#fff);display:flex;flex-direction:column;gap:6px;min-width:0}",
|
|
36
37
|
".mkt_cardHead{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}",
|
|
37
38
|
".mkt_name{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary);overflow-wrap:anywhere}",
|
|
38
39
|
".mkt_meta{font-size:12px;color:var(--dsw-alias-label-tertiary)}",
|
|
39
40
|
".mkt_desc{font-size:12.5px;color:var(--dsw-alias-label-secondary);overflow-wrap:anywhere}",
|
|
40
|
-
".mkt_cardActions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}",
|
|
41
|
-
".
|
|
41
|
+
".mkt_cardActions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:auto;padding-top:4px}",
|
|
42
|
+
".mkt_infoCard{max-width:640px}",
|
|
42
43
|
".mkt_panelTitle{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);margin:0}",
|
|
44
|
+
".mkt_panelRow{display:flex;align-items:center;gap:8px}",
|
|
45
|
+
".mkt_panelRow .mkt_link{margin-left:auto}",
|
|
43
46
|
".mkt_pre{font-family:Consolas,Monaco,monospace;font-size:11.5px;line-height:16px;color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-secondary,#f6f7f8);border-radius:6px;padding:8px;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all}",
|
|
44
47
|
".mkt_ok{color:var(--dsw-alias-state-success-primary,#2f855a)}",
|
|
45
48
|
".mkt_badge{display:inline-block;border-radius:999px;padding:1px 8px;font-size:11px;border:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary)}",
|
|
46
49
|
".mkt_link{color:var(--dsw-alias-state-business-primary);font-size:12px;text-decoration:none;cursor:pointer}",
|
|
47
50
|
".mkt_installedHead{display:flex;align-items:center;gap:8px;width:100%;background:none;border:0;padding:0;margin:0;cursor:pointer;font:inherit;text-align:left}",
|
|
48
51
|
".mkt_installedHead:hover .mkt_panelTitle{color:var(--dsw-alias-state-business-primary)}",
|
|
52
|
+
".mkt_installedToggle{margin-left:auto}",
|
|
49
53
|
".mkt_depList{display:flex;flex-direction:column;gap:6px;margin-top:8px}",
|
|
50
54
|
".mkt_depRow{display:flex;justify-content:space-between;align-items:center;gap:8px}",
|
|
51
55
|
].join("\n");
|
|
@@ -150,8 +154,11 @@ window.__ModuleLoader__.load({
|
|
|
150
154
|
var info = props.info;
|
|
151
155
|
if (!info) return null;
|
|
152
156
|
var data = info.data;
|
|
153
|
-
return h("div", { className: "mkt_card" },
|
|
154
|
-
h("
|
|
157
|
+
return h("div", { className: "mkt_card mkt_infoCard", ref: props.panelRef },
|
|
158
|
+
h("div", { className: "mkt_panelRow" },
|
|
159
|
+
h("p", { className: "mkt_panelTitle" }, "仓库详情"),
|
|
160
|
+
props.onClose ? h("span", { className: "mkt_link", onClick: props.onClose }, "✕ 关闭") : null
|
|
161
|
+
),
|
|
155
162
|
info.loading ? h("div", { className: "mkt_meta" }, "加载中…")
|
|
156
163
|
: info.error ? h("div", { className: "mkt_error" }, info.error)
|
|
157
164
|
: !data ? null
|
|
@@ -180,7 +187,7 @@ window.__ModuleLoader__.load({
|
|
|
180
187
|
var ids = Object.keys(props.jobs);
|
|
181
188
|
if (ids.length === 0) return null;
|
|
182
189
|
return h("div", { className: "mkt_card" },
|
|
183
|
-
h("p", { className: "mkt_panelTitle" }, "
|
|
190
|
+
h("p", { className: "mkt_panelTitle" }, "任务"),
|
|
184
191
|
ids.map(function (id) {
|
|
185
192
|
var job = props.jobs[id];
|
|
186
193
|
var done = job.status === "completed" || job.status === "failed" || job.status === "killed";
|
|
@@ -189,9 +196,9 @@ window.__ModuleLoader__.load({
|
|
|
189
196
|
id + " · " + clip(job.spec || "", 40) + " · " + (job.status || "running")),
|
|
190
197
|
job.detail ? h("div", { className: "mkt_desc" }, job.detail) : null,
|
|
191
198
|
done && job.status === "completed"
|
|
192
|
-
? h("div", { className: "mkt_ok" }, "✓
|
|
199
|
+
? h("div", { className: "mkt_ok" }, "✓ 完成 — 重启 dsh 后生效")
|
|
193
200
|
: job.status === "failed"
|
|
194
|
-
? h("div", { className: "mkt_error" }, "
|
|
201
|
+
? h("div", { className: "mkt_error" }, "失败,见下方输出")
|
|
195
202
|
: null,
|
|
196
203
|
job.output ? h("pre", { className: "mkt_pre" }, job.output.slice(-4000)) : null
|
|
197
204
|
);
|
|
@@ -216,7 +223,7 @@ window.__ModuleLoader__.load({
|
|
|
216
223
|
},
|
|
217
224
|
h("span", { className: "mkt_panelTitle" }, "已装插件"),
|
|
218
225
|
h("span", { className: "mkt_badge" }, count + " 个"),
|
|
219
|
-
h("span", { className: "mkt_meta" }, open ? "收起 ▲" : "展开 ▼")
|
|
226
|
+
h("span", { className: "mkt_meta mkt_installedToggle" }, open ? "收起 ▲" : "展开 ▼")
|
|
220
227
|
),
|
|
221
228
|
installed.error
|
|
222
229
|
? h("div", { className: "mkt_error" }, installed.error)
|
|
@@ -224,9 +231,15 @@ window.__ModuleLoader__.load({
|
|
|
224
231
|
: (installed.deps || []).length === 0
|
|
225
232
|
? h("div", { className: "mkt_meta" }, "还没有装过插件")
|
|
226
233
|
: h("div", { className: "mkt_depList" }, (installed.deps || []).map(function (dep) {
|
|
234
|
+
var busy = (props.removing || {})[dep.name] === true;
|
|
227
235
|
return h("div", { key: dep.name, className: "mkt_depRow" },
|
|
228
236
|
h("span", { className: "mkt_desc" }, dep.name + "@" + dep.version),
|
|
229
|
-
h("span", { className: "mkt_badge" }, kindLabel(dep.kind))
|
|
237
|
+
h("span", { className: "mkt_badge" }, kindLabel(dep.kind)),
|
|
238
|
+
h("button", {
|
|
239
|
+
className: "mkt_btn mkt_btnDanger mkt_btnSm",
|
|
240
|
+
disabled: busy,
|
|
241
|
+
onClick: function () { props.onUninstall(dep.name); },
|
|
242
|
+
}, busy ? "卸载中…" : "卸载"));
|
|
230
243
|
}))
|
|
231
244
|
);
|
|
232
245
|
}
|
|
@@ -258,6 +271,10 @@ window.__ModuleLoader__.load({
|
|
|
258
271
|
var _installing = useState({});
|
|
259
272
|
var installing = _installing[0];
|
|
260
273
|
var setInstalling = _installing[1];
|
|
274
|
+
var _removing = useState({});
|
|
275
|
+
var removing = _removing[0];
|
|
276
|
+
var setRemoving = _removing[1];
|
|
277
|
+
var infoRef = useRef(null);
|
|
261
278
|
|
|
262
279
|
var call = useCallback(function (endpoint, payload) {
|
|
263
280
|
return rpc.call("/market", endpoint, payload || {}).then(function (res) {
|
|
@@ -287,6 +304,17 @@ window.__ModuleLoader__.load({
|
|
|
287
304
|
});
|
|
288
305
|
}, [call]);
|
|
289
306
|
|
|
307
|
+
var doCloseInfo = useCallback(function () {
|
|
308
|
+
setInfo(null);
|
|
309
|
+
}, []);
|
|
310
|
+
|
|
311
|
+
// 详情面板在列表上方;点"详情"时平滑滚到面板,免得用户找不到。
|
|
312
|
+
useEffect(function () {
|
|
313
|
+
if (info && infoRef.current && typeof infoRef.current.scrollIntoView === "function") {
|
|
314
|
+
infoRef.current.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
315
|
+
}
|
|
316
|
+
}, [info]);
|
|
317
|
+
|
|
290
318
|
var refreshInstalled = useCallback(function () {
|
|
291
319
|
call("installed", {}).then(function (value) {
|
|
292
320
|
setInstalled(value);
|
|
@@ -325,6 +353,25 @@ window.__ModuleLoader__.load({
|
|
|
325
353
|
});
|
|
326
354
|
}, [call, track]);
|
|
327
355
|
|
|
356
|
+
var doUninstall = useCallback(function (name) {
|
|
357
|
+
setRemoving(function (prev) {
|
|
358
|
+
var next = Object.assign({}, prev, { [name]: true });
|
|
359
|
+
return next;
|
|
360
|
+
});
|
|
361
|
+
setError(null);
|
|
362
|
+
call("uninstall", { package: name }).then(function (value) {
|
|
363
|
+
track(value.jobId, name);
|
|
364
|
+
}).catch(function (e) {
|
|
365
|
+
setError(errorText(e));
|
|
366
|
+
}).finally(function () {
|
|
367
|
+
setRemoving(function (prev) {
|
|
368
|
+
var next = Object.assign({}, prev);
|
|
369
|
+
delete next[name];
|
|
370
|
+
return next;
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
}, [call, track]);
|
|
374
|
+
|
|
328
375
|
useEffect(function () {
|
|
329
376
|
refreshInstalled();
|
|
330
377
|
}, [refreshInstalled]);
|
|
@@ -332,7 +379,7 @@ window.__ModuleLoader__.load({
|
|
|
332
379
|
return h("div", { className: "mkt_root" },
|
|
333
380
|
h("div", { className: "mkt_head" },
|
|
334
381
|
h("div", { className: "mkt_title" }, "插件市场"),
|
|
335
|
-
h("div", { className: "mkt_sub" }, "搜索 GitHub dsh-plugin 话题插件 ·
|
|
382
|
+
h("div", { className: "mkt_sub" }, "搜索 GitHub dsh-plugin 话题插件 · 装/卸后需重启 dsh 生效")
|
|
336
383
|
),
|
|
337
384
|
h("div", { className: "mkt_row" },
|
|
338
385
|
h("input", {
|
|
@@ -355,31 +402,27 @@ window.__ModuleLoader__.load({
|
|
|
355
402
|
h("button", { className: "mkt_btn", onClick: refreshInstalled }, "刷新已装")
|
|
356
403
|
),
|
|
357
404
|
error ? h("div", { className: "mkt_error" }, error) : null,
|
|
358
|
-
h(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
})
|
|
375
|
-
)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
h(InfoPanel, { info: info, onInstall: doInstall }),
|
|
380
|
-
h(JobsPanel, { jobs: jobs })
|
|
381
|
-
)
|
|
382
|
-
)
|
|
405
|
+
h(InstalledPanel, { installed: installed, removing: removing, onUninstall: doUninstall }),
|
|
406
|
+
h(InfoPanel, { info: info, onInstall: doInstall, onClose: doCloseInfo, panelRef: infoRef }),
|
|
407
|
+
h("div", { className: "mkt_list" },
|
|
408
|
+
results == null
|
|
409
|
+
? h("div", { className: "mkt_meta mkt_listHead" }, loading ? "正在加载最热插件…" : "—")
|
|
410
|
+
: results.items.length === 0
|
|
411
|
+
? h("div", { className: "mkt_meta mkt_listHead" }, "没有匹配的仓库。")
|
|
412
|
+
: h(React.Fragment, null,
|
|
413
|
+
h("div", { className: "mkt_meta mkt_listHead" }, "共 " + results.total + " 个仓库(显示 " + results.items.length + " 个)"),
|
|
414
|
+
results.items.map(function (item) {
|
|
415
|
+
return h(RepoCard, {
|
|
416
|
+
key: item.fullName,
|
|
417
|
+
item: item,
|
|
418
|
+
installing: installing[item.fullName] === true,
|
|
419
|
+
onInstall: doInstall,
|
|
420
|
+
onInfo: doInfo,
|
|
421
|
+
});
|
|
422
|
+
})
|
|
423
|
+
)
|
|
424
|
+
),
|
|
425
|
+
h(JobsPanel, { jobs: jobs })
|
|
383
426
|
);
|
|
384
427
|
}
|
|
385
428
|
|
package/src/index.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
//
|
|
3
3
|
// A Cordis plugin mounted at the host plane (profile bundle layer), so its
|
|
4
4
|
// tools land in the tools registry's global layer and every session sees
|
|
5
|
-
// them. It exposes
|
|
5
|
+
// them. It exposes five tools:
|
|
6
6
|
// market_search search GitHub repositories tagged topic:dsh-plugin
|
|
7
7
|
// market_info inspect one repository (stars, license, package.json, dsh.bundle)
|
|
8
8
|
// market_install install a plugin into a local dsh profile (background job)
|
|
9
|
+
// market_uninstall remove a plugin from a local dsh profile (background job)
|
|
9
10
|
// market_installed list a profile's installed plugins
|
|
10
11
|
//
|
|
11
12
|
// Plugin contract (see @deepseek-ai/cordis-plugin-loader): the loader imports
|
|
@@ -18,7 +19,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
18
19
|
import { join } from "node:path";
|
|
19
20
|
import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
20
21
|
import { repoInfo, searchPlugins } from "./github.js";
|
|
21
|
-
import { ensureProfile, listInstalled, normalizeSpec, runInstall,
|
|
22
|
+
import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker } from "./installer.js";
|
|
22
23
|
|
|
23
24
|
export const name = "@1e0zj/dsh-plugin-mall";
|
|
24
25
|
export const inject = ["tools", "jobs", "systemPrompt"];
|
|
@@ -104,7 +105,7 @@ function renderInstalled(result, profile) {
|
|
|
104
105
|
lines.push(` ${dep.name}@${dep.version} ${markers[dep.kind] ?? markers.plain}`);
|
|
105
106
|
}
|
|
106
107
|
lines.push("");
|
|
107
|
-
lines.push(`Remove with: dsh plugin --profile ${profile} remove <name
|
|
108
|
+
lines.push(`Remove with: market_uninstall (package: "<name>"), or dsh plugin --profile ${profile} remove <name>; then restart dsh.`);
|
|
108
109
|
return lines.join("\n");
|
|
109
110
|
}
|
|
110
111
|
|
|
@@ -136,7 +137,7 @@ function rpcFail(error) {
|
|
|
136
137
|
* tools keep using ctx.jobs; the browser surface uses `tracker` because the
|
|
137
138
|
* web host plane has no job controller for ctx.jobs to serve.
|
|
138
139
|
* @param ctx - plugin context.
|
|
139
|
-
* @param endpoint - "search" | "info" | "installed" | "install" | "job" | "jobCancel".
|
|
140
|
+
* @param endpoint - "search" | "info" | "installed" | "install" | "uninstall" | "job" | "jobCancel".
|
|
140
141
|
* @param payload - endpoint arguments from the browser.
|
|
141
142
|
* @param config - the row config (defaultProfile, apiBase, perPageMax).
|
|
142
143
|
* @param token - GitHub token from the environment.
|
|
@@ -192,6 +193,25 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
192
193
|
return rpcFail(error);
|
|
193
194
|
}
|
|
194
195
|
}
|
|
196
|
+
case "uninstall": {
|
|
197
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
198
|
+
const packageName = String(payload?.package ?? "").trim();
|
|
199
|
+
if (packageName.length === 0) return rpcFail(new Error("uninstall: package name is required"));
|
|
200
|
+
try {
|
|
201
|
+
const profileDir = resolveProfileDir(profile);
|
|
202
|
+
if (!existsSync(join(profileDir, "package.json"))) {
|
|
203
|
+
return rpcFail(new Error(`profile "${profile}" has no package.json — nothing installed to remove`));
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const jobId = tracker.start({ profile, spec: packageName, verb: "remove" });
|
|
210
|
+
return rpcOk({ jobId, profile, package: packageName });
|
|
211
|
+
} catch (error) {
|
|
212
|
+
return rpcFail(error);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
195
215
|
case "job": {
|
|
196
216
|
try {
|
|
197
217
|
return rpcOk(tracker.get(payload?.jobId));
|
|
@@ -221,7 +241,7 @@ async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
|
221
241
|
* @param token - GitHub token from the environment.
|
|
222
242
|
*/
|
|
223
243
|
function registerRpcChannel(ctx, config, token) {
|
|
224
|
-
const tracker =
|
|
244
|
+
const tracker = createJobTracker();
|
|
225
245
|
ctx.inject(["connection"], (connectionCtx) => {
|
|
226
246
|
connectionCtx.connection.rpc.handle("/market", (endpoint, payload, signal) => rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker), { authority: "loopback" });
|
|
227
247
|
});
|
|
@@ -234,7 +254,7 @@ export function apply(ctx, config = {}) {
|
|
|
234
254
|
ctx.systemPrompt.section({
|
|
235
255
|
name: "tool:market",
|
|
236
256
|
order: 120,
|
|
237
|
-
text: "The dsh plugin marketplace tools are available: market_search discovers plugins on the GitHub dsh-plugin topic, market_info inspects one repository, market_install installs a plugin into a dsh profile as a background job (poll with job_output), and market_installed lists a profile's plugins. A successful market_install only takes effect after the dsh process restarts — remind the user to restart. Prefer plugins with meaningful stars and a dsh.bundle declaration (market_info shows both).",
|
|
257
|
+
text: "The dsh plugin marketplace tools are available: market_search discovers plugins on the GitHub dsh-plugin topic, market_info inspects one repository, market_install installs a plugin into a dsh profile as a background job (poll with job_output), market_uninstall removes an installed plugin from a dsh profile as a background job, and market_installed lists a profile's plugins. A successful market_install or market_uninstall only takes effect after the dsh process restarts — remind the user to restart. Prefer plugins with meaningful stars and a dsh.bundle declaration (market_info shows both).",
|
|
238
258
|
});
|
|
239
259
|
|
|
240
260
|
ctx.tools.register(defineTool({
|
|
@@ -363,6 +383,56 @@ export function apply(ctx, config = {}) {
|
|
|
363
383
|
}),
|
|
364
384
|
}));
|
|
365
385
|
|
|
386
|
+
ctx.tools.register(defineTool({
|
|
387
|
+
name: "market_uninstall",
|
|
388
|
+
description: "Remove a plugin from a local dsh profile by running `pnpm remove` in that profile's directory, dropping its entry from the profile's bundle layer list, and deleting its client loader row from cordis.patch.yml if one was registered. Same flow as `dsh plugin --profile <name> remove <package>`. ALWAYS runs as a background job: the call returns a job id immediately; poll with job_output and cancel with job_kill. A successful removal only takes effect after the dsh process restarts.",
|
|
389
|
+
parameters: {
|
|
390
|
+
package: {
|
|
391
|
+
type: "string",
|
|
392
|
+
required: true,
|
|
393
|
+
description: "Installed package name to remove, e.g. \"@1e0zj/dsh-plugin-mall\" or \"dsh-at-file\".",
|
|
394
|
+
},
|
|
395
|
+
profile: {
|
|
396
|
+
type: "string",
|
|
397
|
+
description: `Target profile under $DSH_HOME/profiles. Defaults to "${defaultProfile}".`,
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
output: {
|
|
401
|
+
schema: {
|
|
402
|
+
type: "object",
|
|
403
|
+
additionalProperties: false,
|
|
404
|
+
properties: BACKGROUND_OUTPUT_PROPERTIES,
|
|
405
|
+
},
|
|
406
|
+
render: (args, value) => [{
|
|
407
|
+
type: "text",
|
|
408
|
+
text: `started background job ${value.jobId} (${args.package} ← profile "${args.profile ?? defaultProfile}"); poll with job_output, cancel with job_kill. Restart dsh after a successful uninstall.`,
|
|
409
|
+
}],
|
|
410
|
+
},
|
|
411
|
+
async execute(args, exec) {
|
|
412
|
+
const profile = String(args.profile ?? defaultProfile).trim();
|
|
413
|
+
const packageName = String(args.package ?? "").trim();
|
|
414
|
+
if (packageName.length === 0) throw new Error("market_uninstall: package name is required");
|
|
415
|
+
try {
|
|
416
|
+
resolveProfileDir(profile);
|
|
417
|
+
} catch (error) {
|
|
418
|
+
throw new Error(`market_uninstall: invalid profile: ${error.message}`);
|
|
419
|
+
}
|
|
420
|
+
const jobId = ctx.jobs.start({
|
|
421
|
+
kind: "dsh-plugin-uninstall",
|
|
422
|
+
label: `dsh plugin --profile ${profile} remove ${packageName}`,
|
|
423
|
+
...exec.agent ? { owner: exec.agent } : {},
|
|
424
|
+
run: () => runRemove({ profile, packageName }),
|
|
425
|
+
});
|
|
426
|
+
return { kind: "background", jobId };
|
|
427
|
+
},
|
|
428
|
+
presentCall: (args) => ({
|
|
429
|
+
card: "generic",
|
|
430
|
+
title: `dsh plugin --profile ${args.profile ?? defaultProfile} remove ${args.package}`,
|
|
431
|
+
kind: "execute",
|
|
432
|
+
content: [{ type: "text", text: "Remove a plugin from a dsh profile (background job)" }],
|
|
433
|
+
}),
|
|
434
|
+
}));
|
|
435
|
+
|
|
366
436
|
ctx.tools.register(defineTool({
|
|
367
437
|
name: "market_installed",
|
|
368
438
|
description: "List the plugins installed in a local dsh profile: every dependency with its installed version and whether it declares a dsh bundle (i.e. is an active plugin layer).",
|
package/src/installer.js
CHANGED
|
@@ -187,6 +187,34 @@ export function ensureClientRow(profileDir, packageName) {
|
|
|
187
187
|
return { added: true, rowId };
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Remove the cordis.patch.yml loader row `ensureClientRow` registered for a
|
|
192
|
+
* package. Textual and idempotent: only the exact block the register emitted
|
|
193
|
+
* is spliced out, so user-authored rows survive byte-for-byte. An emptied
|
|
194
|
+
* file falls back to the stock `[]` template.
|
|
195
|
+
* @returns {{ removed: boolean, rowId?: string }}
|
|
196
|
+
*/
|
|
197
|
+
export function removeClientRow(profileDir, packageName) {
|
|
198
|
+
const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
|
|
199
|
+
if (!existsSync(patchPath)) return { removed: false };
|
|
200
|
+
const content = readFileSync(patchPath, "utf8");
|
|
201
|
+
const rowId = clientRowId(packageName);
|
|
202
|
+
const lines = content.split("\n");
|
|
203
|
+
let removed = false;
|
|
204
|
+
for (let index = 0; index < lines.length - 2; index++) {
|
|
205
|
+
if (lines[index] !== "- insert:") continue;
|
|
206
|
+
if (lines[index + 1] !== ` - id: ${rowId}`) continue;
|
|
207
|
+
if (lines[index + 2] !== ` name: '${packageName}'`) continue;
|
|
208
|
+
lines.splice(index, 3);
|
|
209
|
+
removed = true;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (!removed) return { removed: false };
|
|
213
|
+
const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
214
|
+
writeFileSync(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
|
|
215
|
+
return { removed: true, rowId };
|
|
216
|
+
}
|
|
217
|
+
|
|
190
218
|
// ── build-script allow-listing ──────────────────────────────────────────────
|
|
191
219
|
|
|
192
220
|
/** Extract package names from pnpm's "Ignored build scripts: ..." output. */
|
|
@@ -250,8 +278,12 @@ function ensureAllowBuilds(profileDir, names) {
|
|
|
250
278
|
|
|
251
279
|
let trackerCounter = 0;
|
|
252
280
|
|
|
253
|
-
/**
|
|
254
|
-
|
|
281
|
+
/**
|
|
282
|
+
* Create a tracker for browser-started install/uninstall jobs (see runInstall
|
|
283
|
+
* and runRemove). Same producer shape as the jobs registry ({cancel, done,
|
|
284
|
+
* readOutput}), just an independent registry.
|
|
285
|
+
*/
|
|
286
|
+
export function createJobTracker() {
|
|
255
287
|
const records = new Map();
|
|
256
288
|
const prune = () => {
|
|
257
289
|
const now = Date.now();
|
|
@@ -268,12 +300,14 @@ export function createInstallTracker() {
|
|
|
268
300
|
}
|
|
269
301
|
};
|
|
270
302
|
return {
|
|
271
|
-
start({ profile, spec }) {
|
|
303
|
+
start({ profile, spec, verb = "add" }) {
|
|
272
304
|
const id = `market-${++trackerCounter}`;
|
|
273
|
-
const
|
|
305
|
+
const kind = verb === "remove" ? "dsh-plugin-uninstall" : "dsh-plugin-install";
|
|
306
|
+
const producer = verb === "remove" ? runRemove({ profile, packageName: spec }) : runInstall({ profile, spec });
|
|
274
307
|
const record = {
|
|
275
308
|
id,
|
|
276
|
-
|
|
309
|
+
kind,
|
|
310
|
+
label: `dsh plugin --profile ${profile} ${verb} ${spec}`,
|
|
277
311
|
profile,
|
|
278
312
|
spec,
|
|
279
313
|
status: "running",
|
|
@@ -297,7 +331,7 @@ export function createInstallTracker() {
|
|
|
297
331
|
return {
|
|
298
332
|
snapshot: {
|
|
299
333
|
id: record.id,
|
|
300
|
-
kind:
|
|
334
|
+
kind: record.kind,
|
|
301
335
|
label: record.label,
|
|
302
336
|
status: record.status,
|
|
303
337
|
detail: record.detail,
|
|
@@ -427,3 +461,86 @@ export function runInstall({ profile, spec }) {
|
|
|
427
461
|
},
|
|
428
462
|
};
|
|
429
463
|
}
|
|
464
|
+
|
|
465
|
+
// ── the background uninstall job ────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
/** A terminal producer for fast-fail cases (no pnpm spawn needed). */
|
|
468
|
+
function failedNow(detail) {
|
|
469
|
+
return {
|
|
470
|
+
cancel: () => {},
|
|
471
|
+
done: Promise.resolve({ status: "failed", detail }),
|
|
472
|
+
readOutput: () => "",
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Run `pnpm remove <package>` in the profile directory as a job producer with
|
|
478
|
+
* the same shape as `runInstall`. On success it reconciles
|
|
479
|
+
* `dsh.profile.bundles` (the removed dependency's bundle entry drops out) and
|
|
480
|
+
* deletes the client loader row `ensureClientRow` had registered for it.
|
|
481
|
+
*/
|
|
482
|
+
export function runRemove({ profile, packageName }) {
|
|
483
|
+
let profileDir;
|
|
484
|
+
try {
|
|
485
|
+
profileDir = resolveProfileDir(profile);
|
|
486
|
+
} catch (error) {
|
|
487
|
+
return failedNow(`invalid profile: ${error.message}`);
|
|
488
|
+
}
|
|
489
|
+
const manifestPath = join(profileDir, "package.json");
|
|
490
|
+
if (!existsSync(manifestPath)) {
|
|
491
|
+
return failedNow(`profile "${profile}" has no package.json — nothing installed to remove`);
|
|
492
|
+
}
|
|
493
|
+
const beforeDeps = new Set(Object.keys(readManifest(profileDir).dependencies ?? {}));
|
|
494
|
+
if (!beforeDeps.has(packageName)) {
|
|
495
|
+
return failedNow(`"${packageName}" is not a dependency of profile "${profile}" (installed: ${[...beforeDeps].join(", ") || "none"})`);
|
|
496
|
+
}
|
|
497
|
+
const deltaQueue = [];
|
|
498
|
+
const push = (text) => {
|
|
499
|
+
deltaQueue.push(text);
|
|
500
|
+
};
|
|
501
|
+
let current = undefined;
|
|
502
|
+
|
|
503
|
+
const proc = spawn("pnpm", ["remove", packageName, "--reporter=append-only"], {
|
|
504
|
+
cwd: profileDir,
|
|
505
|
+
env: process.env,
|
|
506
|
+
shell: process.platform === "win32",
|
|
507
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
508
|
+
windowsHide: true,
|
|
509
|
+
});
|
|
510
|
+
current = proc;
|
|
511
|
+
const done = new Promise((resolve) => {
|
|
512
|
+
proc.on("error", (error) => resolve({ spawnError: error }));
|
|
513
|
+
proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
|
|
514
|
+
}).then((outcome) => {
|
|
515
|
+
if (outcome.spawnError !== undefined) {
|
|
516
|
+
const hint = outcome.spawnError.code === "ENOENT"
|
|
517
|
+
? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
|
|
518
|
+
: `could not start pnpm: ${outcome.spawnError.message}`;
|
|
519
|
+
return { status: "failed", detail: hint };
|
|
520
|
+
}
|
|
521
|
+
if (outcome.exitCode === null) {
|
|
522
|
+
return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
|
|
523
|
+
}
|
|
524
|
+
if (outcome.exitCode !== 0) {
|
|
525
|
+
return { status: "failed", detail: `pnpm remove ${packageName} failed (exit code ${outcome.exitCode}). See job output.` };
|
|
526
|
+
}
|
|
527
|
+
const bundles = reconcileBundles(profileDir, beforeDeps);
|
|
528
|
+
const clientRow = removeClientRow(profileDir, packageName);
|
|
529
|
+
const notes = [`bundle layer(s) now: ${bundles.join(", ") || "none (template only)"}`];
|
|
530
|
+
if (clientRow.removed) notes.push(`removed client loader row "${clientRow.rowId}" from cordis.patch.yml`);
|
|
531
|
+
return { status: "completed", detail: `removed ${packageName} from profile "${profile}" — ${notes.join("; ")}. Restart dsh for the change to take effect.` };
|
|
532
|
+
});
|
|
533
|
+
proc.stdout?.on("data", (data) => push(data.toString()));
|
|
534
|
+
proc.stderr?.on("data", (data) => push(data.toString()));
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
cancel: () => {
|
|
538
|
+
current?.kill();
|
|
539
|
+
},
|
|
540
|
+
done,
|
|
541
|
+
readOutput: () => {
|
|
542
|
+
if (deltaQueue.length === 0) return "";
|
|
543
|
+
return deltaQueue.splice(0).join("");
|
|
544
|
+
},
|
|
545
|
+
};
|
|
546
|
+
}
|