@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 CHANGED
@@ -4,14 +4,15 @@
4
4
 
5
5
  两个入口:
6
6
 
7
- 1. **设置 → 插件 → 插件市场 tab**(浏览器 UI,dsh web 模式):搜索、看详情、点安装、看进度与已装列表
8
- 2. **会话内 4 个 agent 工具**(所有模式可用)
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
- 注册 4 个 agent 工具(进 global 层,所有会话可见,与 MCP 工具同理);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1e0zj/dsh-plugin-mall",
3
- "version": "0.1.2",
3
+ "version": "0.1.6",
4
4
  "description": "dsh 插件市场:搜索 GitHub dsh-plugin 话题下的插件仓库,一键安装到本地 dsh profile(agent 工具 + 设置页插件市场 tab)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
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:960px}",
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
- ".mkt_columns{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}",
33
- "@media (max-width:860px){.mkt_columns{grid-template-columns:1fr}}",
34
- ".mkt_list{display:flex;flex-direction:column;gap:10px;min-width:0}",
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
- ".mkt_rail{display:flex;flex-direction:column;gap:10px;min-width:0}",
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("p", { className: "mkt_panelTitle" }, "仓库详情"),
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" }, "✓ 安装完成 — 重启 dsh 后生效")
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 话题插件 · 安装后需重启 dsh 生效")
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("div", { className: "mkt_columns" },
359
- h("div", { className: "mkt_list" },
360
- results == null
361
- ? h("div", { className: "mkt_meta" }, loading ? "正在加载最热插件…" : "—")
362
- : results.items.length === 0
363
- ? h("div", { className: "mkt_meta" }, "没有匹配的仓库。")
364
- : h(React.Fragment, null,
365
- h("div", { className: "mkt_meta" }, "共 " + results.total + " 个仓库(显示 " + results.items.length + " 个)"),
366
- results.items.map(function (item) {
367
- return h(RepoCard, {
368
- key: item.fullName,
369
- item: item,
370
- installing: installing[item.fullName] === true,
371
- onInstall: doInstall,
372
- onInfo: doInfo,
373
- });
374
- })
375
- )
376
- ),
377
- h("div", { className: "mkt_rail" },
378
- h(InstalledPanel, { installed: installed }),
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 four tools:
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, createInstallTracker } from "./installer.js";
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> (then restart dsh).`);
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 = createInstallTracker();
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
- /** Create a tracker for browser-started installs (see runInstall). */
254
- export function createInstallTracker() {
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 producer = runInstall({ profile, spec });
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
- label: `dsh plugin --profile ${profile} add ${spec}`,
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: "dsh-plugin-install",
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
+ }