@1e0zj/dsh-plugin-mall 0.1.16 → 0.1.18
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/LICENSE +21 -21
- package/README.md +215 -194
- package/cordis.patch.yml +25 -25
- package/package.json +50 -50
- package/src/client.js +694 -610
- package/src/github.js +625 -541
- package/src/index.js +581 -568
- package/src/installer.js +912 -851
package/src/index.js
CHANGED
|
@@ -1,568 +1,581 @@
|
|
|
1
|
-
// dsh-plugin-mall — the dsh plugin marketplace.
|
|
2
|
-
//
|
|
3
|
-
// A Cordis plugin mounted at the host plane (profile bundle layer), so its
|
|
4
|
-
// tools land in the tools registry's global layer and every session sees
|
|
5
|
-
// them. It exposes five tools:
|
|
6
|
-
// market_search search GitHub repositories tagged topic:dsh-plugin
|
|
7
|
-
// market_info inspect one repository (stars, license, package.json, dsh.bundle)
|
|
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)
|
|
10
|
-
// market_installed list a profile's installed plugins
|
|
11
|
-
//
|
|
12
|
-
// Plugin contract (see @deepseek-ai/cordis-plugin-loader): the loader imports
|
|
13
|
-
// this module and uses its `apply(ctx, config)`; `inject` declares required
|
|
14
|
-
// services, `Config` validates the row's config, `name` is the plugin name.
|
|
15
|
-
|
|
16
|
-
import z from "@deepseek-ai/schemastery";
|
|
17
|
-
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
18
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
-
import { join } from "node:path";
|
|
20
|
-
import { spawn } from "node:child_process";
|
|
21
|
-
import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
22
|
-
import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
|
|
23
|
-
import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker, assertSafeSpec, resolveRegistry } from "./installer.js";
|
|
24
|
-
|
|
25
|
-
export const name = "@1e0zj/dsh-plugin-mall";
|
|
26
|
-
export const inject = ["tools", "jobs", "systemPrompt"];
|
|
27
|
-
|
|
28
|
-
export const Config = z.object({
|
|
29
|
-
defaultProfile: z.string().default("web"),
|
|
30
|
-
apiBase: z.string().default("https://api.github.com"),
|
|
31
|
-
npmRegistry: z.string().default(""),
|
|
32
|
-
rawSources: z.array(z.string()).default([]),
|
|
33
|
-
perPageMax: z.number().default(30),
|
|
34
|
-
allowRestart: z.boolean().default(true),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* The registry to query for a profile: an explicit `npmRegistry` config wins,
|
|
39
|
-
* otherwise follow whatever pnpm installs from (profile .npmrc → pnpm config →
|
|
40
|
-
* npmjs). Never npmjs-by-assumption: a mirror user would silently lose
|
|
41
|
-
* anti-squatting, update checks, and the host-shadow guard all at once.
|
|
42
|
-
*/
|
|
43
|
-
async function registryFor(profile, npmRegistry) {
|
|
44
|
-
const explicit = String(npmRegistry ?? "").trim();
|
|
45
|
-
return explicit.length > 0 ? explicit.replace(/\/+$/, "") : await resolveRegistry(profile);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Clip long strings for compact model-facing output. */
|
|
49
|
-
function clip(text, max) {
|
|
50
|
-
const trimmed = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
51
|
-
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** Markdown-free listing of search hits, one repo per block. */
|
|
55
|
-
function renderSearch(total, items, args) {
|
|
56
|
-
const narrowed = typeof args.query === "string" && args.query.trim().length > 0;
|
|
57
|
-
if (items.length === 0) {
|
|
58
|
-
return `No repositories tagged dsh-plugin${narrowed ? ` matching "${args.query.trim()}"` : ""}.`;
|
|
59
|
-
}
|
|
60
|
-
const lines = [`${total} repositories tagged dsh-plugin${narrowed ? ` matching "${args.query.trim()}"` : ""} — showing ${items.length}.\n`];
|
|
61
|
-
for (const [index, item] of items.entries()) {
|
|
62
|
-
const flags = [
|
|
63
|
-
`★${item.stars}`,
|
|
64
|
-
item.forks ? `fork ${item.forks}` : "",
|
|
65
|
-
item.language ?? "",
|
|
66
|
-
item.license ?? "",
|
|
67
|
-
item.archived ? "archived" : "",
|
|
68
|
-
].filter(Boolean).join(" | ");
|
|
69
|
-
lines.push(`${index + 1}. ${item.fullName} ${flags}`);
|
|
70
|
-
if (item.description) lines.push(` ${clip(item.description, 200)}`);
|
|
71
|
-
lines.push(` updated ${item.updatedAt} ${item.htmlUrl}`);
|
|
72
|
-
lines.push(` install spec: github:${item.fullName}`);
|
|
73
|
-
lines.push("");
|
|
74
|
-
}
|
|
75
|
-
lines.push(`Next: market_info "${items[0].fullName}" for details, or market_install with any spec above.`);
|
|
76
|
-
return lines.join("\n");
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function renderInfo(info) {
|
|
80
|
-
const { meta, packageJson } = info;
|
|
81
|
-
const lines = [
|
|
82
|
-
`${meta.fullName}`,
|
|
83
|
-
` url: ${meta.htmlUrl}`,
|
|
84
|
-
` stars: ${meta.stars} forks: ${meta.forks} language: ${meta.language ?? "—"} license: ${meta.license ?? "—"}${meta.archived ? " [ARCHIVED]" : ""}`,
|
|
85
|
-
` topics: ${meta.topics.join(", ") || "—"}`,
|
|
86
|
-
` updated: ${meta.updatedAt}`,
|
|
87
|
-
` branch: ${meta.defaultBranch}`,
|
|
88
|
-
meta.description ? ` about: ${clip(meta.description, 300)}` : "",
|
|
89
|
-
"",
|
|
90
|
-
];
|
|
91
|
-
if (packageJson === undefined) {
|
|
92
|
-
lines.push("package.json: not found at the repository root — likely not an npm-packaged dsh plugin.");
|
|
93
|
-
} else {
|
|
94
|
-
lines.push(`package.json (${packageJson.name ?? "no name"}@${packageJson.version ?? "?"}):`);
|
|
95
|
-
lines.push(` type: ${packageJson.type ?? "commonjs"} dependencies: ${packageJson.dependencyCount} peerDependencies: ${packageJson.peerDependencyCount}`);
|
|
96
|
-
if (packageJson.dshBundlePatch !== undefined) {
|
|
97
|
-
lines.push(` dsh.bundle.patch: ${packageJson.dshBundlePatch} — this IS a dsh bundle (host/agent plugin layer).`);
|
|
98
|
-
lines.push("");
|
|
99
|
-
lines.push(`Install: market_install with spec "github:${meta.fullName}"`);
|
|
100
|
-
lines.push(`npm install (if published): market_install with spec "${packageJson.name}"`);
|
|
101
|
-
} else if (packageJson.dshClientPlatform !== undefined || packageJson.dshClientInjectCount !== undefined) {
|
|
102
|
-
lines.push(` dsh.client: platform=${packageJson.dshClientPlatform ?? "?"}, injects ${packageJson.dshClientInjectCount ?? "?"} client services — a browser-side UI plugin.`);
|
|
103
|
-
lines.push(` market_install adds the dependency AND registers a loader row in the profile's cordis.patch.yml.`);
|
|
104
|
-
lines.push("");
|
|
105
|
-
lines.push(`Install: market_install with spec "github:${meta.fullName}"`);
|
|
106
|
-
} else {
|
|
107
|
-
lines.push(" dsh.bundle.patch: absent, dsh.client: absent — installing this adds a plain dependency, not a plugin layer.");
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
lines.push(`Caution: community code — review the repository before installing.`);
|
|
111
|
-
return lines.join("\n");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function renderInstalled(result, profile) {
|
|
115
|
-
const { dir, deps } = result;
|
|
116
|
-
if (deps.length === 0) return `Profile "${profile}" (${dir}) has no installed plugins.`;
|
|
117
|
-
const markers = { bundle: "[bundle ✓ 宿主插件层]", client: "[client ✓ 浏览器UI插件]", plain: "[普通依赖]", missing: "[未解析]" };
|
|
118
|
-
const lines = [`Profile "${profile}" (${dir}) — ${deps.length} installed plugin(s):`];
|
|
119
|
-
for (const dep of deps) {
|
|
120
|
-
lines.push(` ${dep.name}@${dep.version} ${markers[dep.kind] ?? markers.plain}`);
|
|
121
|
-
}
|
|
122
|
-
lines.push("");
|
|
123
|
-
lines.push(`Remove with: market_uninstall (package: "<name>"), or dsh plugin --profile ${profile} remove <name>; then restart dsh.`);
|
|
124
|
-
return lines.join("\n");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** Output schema for the background-acknowledgement shape (mirrors the bash tool). */
|
|
128
|
-
const BACKGROUND_OUTPUT_PROPERTIES = {
|
|
129
|
-
kind: { type: "string", required: true, const: "background" },
|
|
130
|
-
jobId: { type: "string", required: true },
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
// ── browser RPC channel (/market) ───────────────────────────────────────────
|
|
134
|
-
//
|
|
135
|
-
// The web UI half (src/client.js) talks to this node half through the
|
|
136
|
-
// Connection service's generic RPC channels (`connection.rpc.handle`). The
|
|
137
|
-
// shared /api channel belongs to the api-gateway, so the marketplace owns its
|
|
138
|
-
// own loopback-only channel. Every endpoint answers `{ok:true,value}` or
|
|
139
|
-
// `{ok:false,error}` — the client unwraps this envelope itself.
|
|
140
|
-
|
|
141
|
-
function rpcOk(value) {
|
|
142
|
-
return { ok: true, value };
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function rpcFail(error) {
|
|
146
|
-
// dsh connection RPC 的响应信封校验(dsh-client-connection rpcResultSchema)
|
|
147
|
-
// 要求 error 为 discriminated object:{code, message, details}。code 取
|
|
148
|
-
// 通用 "internal",否则整条错误会被 zod 以 invalid_union 吞掉。
|
|
149
|
-
return { ok: false, error: { code: "internal", message: error?.message ?? String(error), details: {} } };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Dispatch one /market RPC endpoint. Runs inside the plugin fiber, so it
|
|
154
|
-
* shares the tools' GitHub helpers and the install tracker. The agent-plane
|
|
155
|
-
* tools keep using ctx.jobs; the browser surface uses `tracker` because the
|
|
156
|
-
* web host plane has no job controller for ctx.jobs to serve.
|
|
157
|
-
* @param ctx - plugin context.
|
|
158
|
-
* @param endpoint - "search" | "info" | "installed" | "install" | "uninstall" | "job" | "jobCancel".
|
|
159
|
-
* @param payload - endpoint arguments from the browser.
|
|
160
|
-
* @param config - the row config (defaultProfile, apiBase, perPageMax).
|
|
161
|
-
* @param token - GitHub token from the environment.
|
|
162
|
-
* @param tracker - the in-process install tracker.
|
|
163
|
-
* @returns the {ok, value|error} envelope.
|
|
164
|
-
*/
|
|
165
|
-
async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
166
|
-
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, allowRestart = true, npmRegistry = "", rawSources = [] } = config;
|
|
167
|
-
switch (endpoint) {
|
|
168
|
-
case "search": {
|
|
169
|
-
const perPage = Math.min(Math.max(Math.trunc(payload?.perPage ?? 10) || 10, 1), Math.trunc(perPageMax) || 30);
|
|
170
|
-
const result = await searchPlugins({
|
|
171
|
-
query: payload?.query,
|
|
172
|
-
sort: payload?.sort ?? "stars",
|
|
173
|
-
perPage,
|
|
174
|
-
page: payload?.page ?? 1,
|
|
175
|
-
minStars: payload?.minStars,
|
|
176
|
-
apiBase,
|
|
177
|
-
token,
|
|
178
|
-
});
|
|
179
|
-
return rpcOk(result);
|
|
180
|
-
}
|
|
181
|
-
case "verify": {
|
|
182
|
-
const result = await verifyPlugins({ repos: payload?.repos, sources: rawSources });
|
|
183
|
-
return rpcOk(result);
|
|
184
|
-
}
|
|
185
|
-
case "updates": {
|
|
186
|
-
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
187
|
-
let deps;
|
|
188
|
-
try {
|
|
189
|
-
resolveProfileDir(profile);
|
|
190
|
-
deps = listInstalled(profile).deps;
|
|
191
|
-
} catch (error) {
|
|
192
|
-
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
193
|
-
}
|
|
194
|
-
const registry = await registryFor(profile, npmRegistry);
|
|
195
|
-
const results = {};
|
|
196
|
-
// 同 verifyPlugins 的 worker 池,而不是对所有依赖一次性扇出。
|
|
197
|
-
await mapLimit(deps, NETWORK_CONCURRENCY, async (dep) => {
|
|
198
|
-
if (dep.kind === "missing") { results[dep.name] = { latest: null }; return; }
|
|
199
|
-
const info = await npmPackageInfo(dep.name, { registry });
|
|
200
|
-
results[dep.name] = info === null
|
|
201
|
-
? { latest: null }
|
|
202
|
-
: { latest: info.latest, hasUpdate: compareVersions(info.latest, dep.version) > 0 };
|
|
203
|
-
});
|
|
204
|
-
return rpcOk(results);
|
|
205
|
-
}
|
|
206
|
-
case "info": {
|
|
207
|
-
const result = await repoInfo({ repo: payload?.repo, apiBase, token });
|
|
208
|
-
return rpcOk(result);
|
|
209
|
-
}
|
|
210
|
-
case "installed": {
|
|
211
|
-
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
212
|
-
try {
|
|
213
|
-
resolveProfileDir(profile);
|
|
214
|
-
} catch (error) {
|
|
215
|
-
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
216
|
-
}
|
|
217
|
-
return rpcOk(listInstalled(profile));
|
|
218
|
-
}
|
|
219
|
-
case "install": {
|
|
220
|
-
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
221
|
-
let spec;
|
|
222
|
-
try {
|
|
223
|
-
spec = normalizeSpec(payload?.spec);
|
|
224
|
-
assertSafeSpec(spec);
|
|
225
|
-
} catch (error) {
|
|
226
|
-
return rpcFail(error);
|
|
227
|
-
}
|
|
228
|
-
// npm tarball 优先(小而快、带 integrity);registry 条目不同源的包名
|
|
229
|
-
// 视为抢注,回退 github: 全仓库 spec。查的 registry 必须是 pnpm 实际
|
|
230
|
-
// 安装用的那个,否则镜像用户这里永远比对不上、次次退化成全仓库克隆。
|
|
231
|
-
const registry = await registryFor(profile, npmRegistry);
|
|
232
|
-
spec = await preferNpmSpec({ spec, registry, sources: rawSources });
|
|
233
|
-
// 宿主依赖硬拦:dependencies 里拖着 @deepseek-ai/* 的包装进 profile
|
|
234
|
-
// 就是双模块实例 + 全工具调度崩溃(宿主无任何护栏,市场是最后防线)。
|
|
235
|
-
try {
|
|
236
|
-
await assertSafeToInstall({ spec, registry, sources: rawSources });
|
|
237
|
-
} catch (error) {
|
|
238
|
-
return rpcFail(error);
|
|
239
|
-
}
|
|
240
|
-
try {
|
|
241
|
-
const profileDir = resolveProfileDir(profile);
|
|
242
|
-
if (!existsSync(join(profileDir, "package.json"))) ensureProfile(profile);
|
|
243
|
-
} catch (error) {
|
|
244
|
-
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
245
|
-
}
|
|
246
|
-
try {
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
try {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
return
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
description: "
|
|
363
|
-
},
|
|
364
|
-
|
|
365
|
-
type: "
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
type: "string",
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
},
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1
|
+
// dsh-plugin-mall — the dsh plugin marketplace.
|
|
2
|
+
//
|
|
3
|
+
// A Cordis plugin mounted at the host plane (profile bundle layer), so its
|
|
4
|
+
// tools land in the tools registry's global layer and every session sees
|
|
5
|
+
// them. It exposes five tools:
|
|
6
|
+
// market_search search GitHub repositories tagged topic:dsh-plugin
|
|
7
|
+
// market_info inspect one repository (stars, license, package.json, dsh.bundle)
|
|
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)
|
|
10
|
+
// market_installed list a profile's installed plugins
|
|
11
|
+
//
|
|
12
|
+
// Plugin contract (see @deepseek-ai/cordis-plugin-loader): the loader imports
|
|
13
|
+
// this module and uses its `apply(ctx, config)`; `inject` declares required
|
|
14
|
+
// services, `Config` validates the row's config, `name` is the plugin name.
|
|
15
|
+
|
|
16
|
+
import z from "@deepseek-ai/schemastery";
|
|
17
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
18
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { spawn } from "node:child_process";
|
|
21
|
+
import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
22
|
+
import { repoInfo, searchPlugins, verifyPlugins, preferNpmSpec, npmPackageInfo, compareVersions, assertSafeToInstall, mapLimit, NETWORK_CONCURRENCY } from "./github.js";
|
|
23
|
+
import { ensureProfile, listInstalled, normalizeSpec, runInstall, runRemove, createJobTracker, assertSafeSpec, resolveRegistry } from "./installer.js";
|
|
24
|
+
|
|
25
|
+
export const name = "@1e0zj/dsh-plugin-mall";
|
|
26
|
+
export const inject = ["tools", "jobs", "systemPrompt"];
|
|
27
|
+
|
|
28
|
+
export const Config = z.object({
|
|
29
|
+
defaultProfile: z.string().default("web"),
|
|
30
|
+
apiBase: z.string().default("https://api.github.com"),
|
|
31
|
+
npmRegistry: z.string().default(""),
|
|
32
|
+
rawSources: z.array(z.string()).default([]),
|
|
33
|
+
perPageMax: z.number().default(30),
|
|
34
|
+
allowRestart: z.boolean().default(true),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The registry to query for a profile: an explicit `npmRegistry` config wins,
|
|
39
|
+
* otherwise follow whatever pnpm installs from (profile .npmrc → pnpm config →
|
|
40
|
+
* npmjs). Never npmjs-by-assumption: a mirror user would silently lose
|
|
41
|
+
* anti-squatting, update checks, and the host-shadow guard all at once.
|
|
42
|
+
*/
|
|
43
|
+
async function registryFor(profile, npmRegistry) {
|
|
44
|
+
const explicit = String(npmRegistry ?? "").trim();
|
|
45
|
+
return explicit.length > 0 ? explicit.replace(/\/+$/, "") : await resolveRegistry(profile);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Clip long strings for compact model-facing output. */
|
|
49
|
+
function clip(text, max) {
|
|
50
|
+
const trimmed = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
51
|
+
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Markdown-free listing of search hits, one repo per block. */
|
|
55
|
+
function renderSearch(total, items, args) {
|
|
56
|
+
const narrowed = typeof args.query === "string" && args.query.trim().length > 0;
|
|
57
|
+
if (items.length === 0) {
|
|
58
|
+
return `No repositories tagged dsh-plugin${narrowed ? ` matching "${args.query.trim()}"` : ""}.`;
|
|
59
|
+
}
|
|
60
|
+
const lines = [`${total} repositories tagged dsh-plugin${narrowed ? ` matching "${args.query.trim()}"` : ""} — showing ${items.length}.\n`];
|
|
61
|
+
for (const [index, item] of items.entries()) {
|
|
62
|
+
const flags = [
|
|
63
|
+
`★${item.stars}`,
|
|
64
|
+
item.forks ? `fork ${item.forks}` : "",
|
|
65
|
+
item.language ?? "",
|
|
66
|
+
item.license ?? "",
|
|
67
|
+
item.archived ? "archived" : "",
|
|
68
|
+
].filter(Boolean).join(" | ");
|
|
69
|
+
lines.push(`${index + 1}. ${item.fullName} ${flags}`);
|
|
70
|
+
if (item.description) lines.push(` ${clip(item.description, 200)}`);
|
|
71
|
+
lines.push(` updated ${item.updatedAt} ${item.htmlUrl}`);
|
|
72
|
+
lines.push(` install spec: github:${item.fullName}`);
|
|
73
|
+
lines.push("");
|
|
74
|
+
}
|
|
75
|
+
lines.push(`Next: market_info "${items[0].fullName}" for details, or market_install with any spec above.`);
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function renderInfo(info) {
|
|
80
|
+
const { meta, packageJson } = info;
|
|
81
|
+
const lines = [
|
|
82
|
+
`${meta.fullName}`,
|
|
83
|
+
` url: ${meta.htmlUrl}`,
|
|
84
|
+
` stars: ${meta.stars} forks: ${meta.forks} language: ${meta.language ?? "—"} license: ${meta.license ?? "—"}${meta.archived ? " [ARCHIVED]" : ""}`,
|
|
85
|
+
` topics: ${meta.topics.join(", ") || "—"}`,
|
|
86
|
+
` updated: ${meta.updatedAt}`,
|
|
87
|
+
` branch: ${meta.defaultBranch}`,
|
|
88
|
+
meta.description ? ` about: ${clip(meta.description, 300)}` : "",
|
|
89
|
+
"",
|
|
90
|
+
];
|
|
91
|
+
if (packageJson === undefined) {
|
|
92
|
+
lines.push("package.json: not found at the repository root — likely not an npm-packaged dsh plugin.");
|
|
93
|
+
} else {
|
|
94
|
+
lines.push(`package.json (${packageJson.name ?? "no name"}@${packageJson.version ?? "?"}):`);
|
|
95
|
+
lines.push(` type: ${packageJson.type ?? "commonjs"} dependencies: ${packageJson.dependencyCount} peerDependencies: ${packageJson.peerDependencyCount}`);
|
|
96
|
+
if (packageJson.dshBundlePatch !== undefined) {
|
|
97
|
+
lines.push(` dsh.bundle.patch: ${packageJson.dshBundlePatch} — this IS a dsh bundle (host/agent plugin layer).`);
|
|
98
|
+
lines.push("");
|
|
99
|
+
lines.push(`Install: market_install with spec "github:${meta.fullName}"`);
|
|
100
|
+
lines.push(`npm install (if published): market_install with spec "${packageJson.name}"`);
|
|
101
|
+
} else if (packageJson.dshClientPlatform !== undefined || packageJson.dshClientInjectCount !== undefined) {
|
|
102
|
+
lines.push(` dsh.client: platform=${packageJson.dshClientPlatform ?? "?"}, injects ${packageJson.dshClientInjectCount ?? "?"} client services — a browser-side UI plugin.`);
|
|
103
|
+
lines.push(` market_install adds the dependency AND registers a loader row in the profile's cordis.patch.yml.`);
|
|
104
|
+
lines.push("");
|
|
105
|
+
lines.push(`Install: market_install with spec "github:${meta.fullName}"`);
|
|
106
|
+
} else {
|
|
107
|
+
lines.push(" dsh.bundle.patch: absent, dsh.client: absent — installing this adds a plain dependency, not a plugin layer.");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
lines.push(`Caution: community code — review the repository before installing.`);
|
|
111
|
+
return lines.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderInstalled(result, profile) {
|
|
115
|
+
const { dir, deps } = result;
|
|
116
|
+
if (deps.length === 0) return `Profile "${profile}" (${dir}) has no installed plugins.`;
|
|
117
|
+
const markers = { bundle: "[bundle ✓ 宿主插件层]", client: "[client ✓ 浏览器UI插件]", plain: "[普通依赖]", missing: "[未解析]" };
|
|
118
|
+
const lines = [`Profile "${profile}" (${dir}) — ${deps.length} installed plugin(s):`];
|
|
119
|
+
for (const dep of deps) {
|
|
120
|
+
lines.push(` ${dep.name}@${dep.version} ${markers[dep.kind] ?? markers.plain}`);
|
|
121
|
+
}
|
|
122
|
+
lines.push("");
|
|
123
|
+
lines.push(`Remove with: market_uninstall (package: "<name>"), or dsh plugin --profile ${profile} remove <name>; then restart dsh.`);
|
|
124
|
+
return lines.join("\n");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Output schema for the background-acknowledgement shape (mirrors the bash tool). */
|
|
128
|
+
const BACKGROUND_OUTPUT_PROPERTIES = {
|
|
129
|
+
kind: { type: "string", required: true, const: "background" },
|
|
130
|
+
jobId: { type: "string", required: true },
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// ── browser RPC channel (/market) ───────────────────────────────────────────
|
|
134
|
+
//
|
|
135
|
+
// The web UI half (src/client.js) talks to this node half through the
|
|
136
|
+
// Connection service's generic RPC channels (`connection.rpc.handle`). The
|
|
137
|
+
// shared /api channel belongs to the api-gateway, so the marketplace owns its
|
|
138
|
+
// own loopback-only channel. Every endpoint answers `{ok:true,value}` or
|
|
139
|
+
// `{ok:false,error}` — the client unwraps this envelope itself.
|
|
140
|
+
|
|
141
|
+
function rpcOk(value) {
|
|
142
|
+
return { ok: true, value };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function rpcFail(error) {
|
|
146
|
+
// dsh connection RPC 的响应信封校验(dsh-client-connection rpcResultSchema)
|
|
147
|
+
// 要求 error 为 discriminated object:{code, message, details}。code 取
|
|
148
|
+
// 通用 "internal",否则整条错误会被 zod 以 invalid_union 吞掉。
|
|
149
|
+
return { ok: false, error: { code: "internal", message: error?.message ?? String(error), details: {} } };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Dispatch one /market RPC endpoint. Runs inside the plugin fiber, so it
|
|
154
|
+
* shares the tools' GitHub helpers and the install tracker. The agent-plane
|
|
155
|
+
* tools keep using ctx.jobs; the browser surface uses `tracker` because the
|
|
156
|
+
* web host plane has no job controller for ctx.jobs to serve.
|
|
157
|
+
* @param ctx - plugin context.
|
|
158
|
+
* @param endpoint - "search" | "info" | "installed" | "install" | "uninstall" | "job" | "jobCancel".
|
|
159
|
+
* @param payload - endpoint arguments from the browser.
|
|
160
|
+
* @param config - the row config (defaultProfile, apiBase, perPageMax).
|
|
161
|
+
* @param token - GitHub token from the environment.
|
|
162
|
+
* @param tracker - the in-process install tracker.
|
|
163
|
+
* @returns the {ok, value|error} envelope.
|
|
164
|
+
*/
|
|
165
|
+
async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
166
|
+
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, allowRestart = true, npmRegistry = "", rawSources = [] } = config;
|
|
167
|
+
switch (endpoint) {
|
|
168
|
+
case "search": {
|
|
169
|
+
const perPage = Math.min(Math.max(Math.trunc(payload?.perPage ?? 10) || 10, 1), Math.trunc(perPageMax) || 30);
|
|
170
|
+
const result = await searchPlugins({
|
|
171
|
+
query: payload?.query,
|
|
172
|
+
sort: payload?.sort ?? "stars",
|
|
173
|
+
perPage,
|
|
174
|
+
page: payload?.page ?? 1,
|
|
175
|
+
minStars: payload?.minStars,
|
|
176
|
+
apiBase,
|
|
177
|
+
token,
|
|
178
|
+
});
|
|
179
|
+
return rpcOk(result);
|
|
180
|
+
}
|
|
181
|
+
case "verify": {
|
|
182
|
+
const result = await verifyPlugins({ repos: payload?.repos, sources: rawSources });
|
|
183
|
+
return rpcOk(result);
|
|
184
|
+
}
|
|
185
|
+
case "updates": {
|
|
186
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
187
|
+
let deps;
|
|
188
|
+
try {
|
|
189
|
+
resolveProfileDir(profile);
|
|
190
|
+
deps = listInstalled(profile).deps;
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
193
|
+
}
|
|
194
|
+
const registry = await registryFor(profile, npmRegistry);
|
|
195
|
+
const results = {};
|
|
196
|
+
// 同 verifyPlugins 的 worker 池,而不是对所有依赖一次性扇出。
|
|
197
|
+
await mapLimit(deps, NETWORK_CONCURRENCY, async (dep) => {
|
|
198
|
+
if (dep.kind === "missing") { results[dep.name] = { latest: null }; return; }
|
|
199
|
+
const info = await npmPackageInfo(dep.name, { registry });
|
|
200
|
+
results[dep.name] = info === null
|
|
201
|
+
? { latest: null }
|
|
202
|
+
: { latest: info.latest, hasUpdate: compareVersions(info.latest, dep.version) > 0 };
|
|
203
|
+
});
|
|
204
|
+
return rpcOk(results);
|
|
205
|
+
}
|
|
206
|
+
case "info": {
|
|
207
|
+
const result = await repoInfo({ repo: payload?.repo, apiBase, token });
|
|
208
|
+
return rpcOk(result);
|
|
209
|
+
}
|
|
210
|
+
case "installed": {
|
|
211
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
212
|
+
try {
|
|
213
|
+
resolveProfileDir(profile);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
216
|
+
}
|
|
217
|
+
return rpcOk(listInstalled(profile));
|
|
218
|
+
}
|
|
219
|
+
case "install": {
|
|
220
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
221
|
+
let spec;
|
|
222
|
+
try {
|
|
223
|
+
spec = normalizeSpec(payload?.spec);
|
|
224
|
+
assertSafeSpec(spec);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
return rpcFail(error);
|
|
227
|
+
}
|
|
228
|
+
// npm tarball 优先(小而快、带 integrity);registry 条目不同源的包名
|
|
229
|
+
// 视为抢注,回退 github: 全仓库 spec。查的 registry 必须是 pnpm 实际
|
|
230
|
+
// 安装用的那个,否则镜像用户这里永远比对不上、次次退化成全仓库克隆。
|
|
231
|
+
const registry = await registryFor(profile, npmRegistry);
|
|
232
|
+
spec = await preferNpmSpec({ spec, registry, sources: rawSources });
|
|
233
|
+
// 宿主依赖硬拦:dependencies 里拖着 @deepseek-ai/* 的包装进 profile
|
|
234
|
+
// 就是双模块实例 + 全工具调度崩溃(宿主无任何护栏,市场是最后防线)。
|
|
235
|
+
try {
|
|
236
|
+
await assertSafeToInstall({ spec, registry, sources: rawSources });
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return rpcFail(error);
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const profileDir = resolveProfileDir(profile);
|
|
242
|
+
if (!existsSync(join(profileDir, "package.json"))) ensureProfile(profile);
|
|
243
|
+
} catch (error) {
|
|
244
|
+
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
// 构建脚本的同意是「点名」的:只放行清单里这几个包。重试时被拦的集合
|
|
248
|
+
// 变了(依赖更新、换了版本),旧的同意不会顺延到新出现的包上。
|
|
249
|
+
const allowBuildScripts = Array.isArray(payload?.allowBuildScripts)
|
|
250
|
+
? payload.allowBuildScripts.map((name) => String(name))
|
|
251
|
+
: undefined;
|
|
252
|
+
const jobId = tracker.start({ profile, spec, allowBuildScripts });
|
|
253
|
+
return rpcOk({ jobId, profile, spec });
|
|
254
|
+
} catch (error) {
|
|
255
|
+
return rpcFail(error);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
case "uninstall": {
|
|
259
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
260
|
+
const packageName = String(payload?.package ?? "").trim();
|
|
261
|
+
if (packageName.length === 0) return rpcFail(new Error("uninstall: package name is required"));
|
|
262
|
+
try {
|
|
263
|
+
assertSafeSpec(packageName);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
return rpcFail(error);
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
const profileDir = resolveProfileDir(profile);
|
|
269
|
+
if (!existsSync(join(profileDir, "package.json"))) {
|
|
270
|
+
return rpcFail(new Error(`profile "${profile}" has no package.json — nothing installed to remove`));
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
274
|
+
}
|
|
275
|
+
try {
|
|
276
|
+
const jobId = tracker.start({ profile, spec: packageName, verb: "remove" });
|
|
277
|
+
return rpcOk({ jobId, profile, package: packageName });
|
|
278
|
+
} catch (error) {
|
|
279
|
+
return rpcFail(error);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
case "job": {
|
|
283
|
+
try {
|
|
284
|
+
return rpcOk(tracker.get(payload?.jobId));
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return rpcFail(error);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
case "restart": {
|
|
290
|
+
// 一键重启:detached 拉起新 dsh 进程(用当前进程的 argv 重建启动命令)
|
|
291
|
+
// 后退出自己。仅 loopback 直连可调(channel 级 authority 已限制);
|
|
292
|
+
// allowRestart:false 时禁用(进程由 systemd/pm2 等托管时接管重启)。
|
|
293
|
+
if (allowRestart !== true) return rpcFail(new Error("restart disabled by config (allowRestart: false)"));
|
|
294
|
+
const script = process.argv[1];
|
|
295
|
+
const scriptArgs = process.argv.slice(2);
|
|
296
|
+
if (typeof script !== "string" || script.length === 0 || !existsSync(script)) {
|
|
297
|
+
return rpcFail(new Error("cannot determine the dsh launch command for an automatic restart — please restart manually"));
|
|
298
|
+
}
|
|
299
|
+
const relaunch = `"${process.execPath}" "${script}"${scriptArgs.length > 0 ? ` ${scriptArgs.map((arg) => `"${arg}"`).join(" ")}` : ""}`;
|
|
300
|
+
const launcher = process.platform === "win32"
|
|
301
|
+
? `timeout /t 2 /nobreak >nul & ${relaunch}`
|
|
302
|
+
: `sleep 2 && ${relaunch}`;
|
|
303
|
+
const child = spawn(launcher, { shell: true, detached: true, stdio: "ignore", cwd: process.cwd(), windowsHide: true });
|
|
304
|
+
child.unref();
|
|
305
|
+
setTimeout(() => process.exit(0), 1500);
|
|
306
|
+
return rpcOk({ restarting: true });
|
|
307
|
+
}
|
|
308
|
+
case "jobCancel": {
|
|
309
|
+
try {
|
|
310
|
+
return rpcOk({ result: tracker.cancel(payload?.jobId) });
|
|
311
|
+
} catch (error) {
|
|
312
|
+
return rpcFail(error);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
default:
|
|
316
|
+
return rpcFail(new Error(`unknown /market endpoint ${JSON.stringify(endpoint)}`));
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Register the /market RPC channel once the Connection service exists (web
|
|
322
|
+
* profiles). `ctx.inject` defers the callback until the service is provided —
|
|
323
|
+
* activation order never races — and in headless/test profiles the callback
|
|
324
|
+
* simply never runs, so the agent tools remain the only surface there.
|
|
325
|
+
* @param ctx - plugin context.
|
|
326
|
+
* @param config - the row config.
|
|
327
|
+
* @param token - GitHub token from the environment.
|
|
328
|
+
*/
|
|
329
|
+
function registerRpcChannel(ctx, config, token) {
|
|
330
|
+
const tracker = createJobTracker();
|
|
331
|
+
ctx.inject(["connection"], (connectionCtx) => {
|
|
332
|
+
connectionCtx.connection.rpc.handle("/market", async (endpoint, payload, signal) => {
|
|
333
|
+
try {
|
|
334
|
+
return await rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker);
|
|
335
|
+
} catch (error) {
|
|
336
|
+
// 没有这层兜底时连接层只会回一个 HTTP 500 "transport failure",
|
|
337
|
+
// 真实异常既到不了浏览器也不留痕。透传错误文本,同时把堆栈
|
|
338
|
+
// 打进 dsh 进程的 stderr(前台运行时可见)。
|
|
339
|
+
console.error(`[dsh-plugin-mall] /market/${String(endpoint)} failed:`, error);
|
|
340
|
+
return rpcFail(error);
|
|
341
|
+
}
|
|
342
|
+
}, { authority: "loopback" });
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function apply(ctx, config = {}) {
|
|
347
|
+
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30, npmRegistry = "", rawSources = [] } = config;
|
|
348
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.DSH_MARKET_GITHUB_TOKEN;
|
|
349
|
+
|
|
350
|
+
ctx.systemPrompt.section({
|
|
351
|
+
name: "tool:market",
|
|
352
|
+
order: 120,
|
|
353
|
+
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). If market_install stops for install-script approval, that decision is the user's: show them the reported package names and commands and wait for an answer — never approve on their behalf.",
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
ctx.tools.register(defineTool({
|
|
357
|
+
name: "market_search",
|
|
358
|
+
description: "Search the dsh plugin marketplace: GitHub repositories tagged `topic:dsh-plugin` (DeepSeek Harness plugins), ranked by stars by default. Pass a query to narrow by keywords (matched against repo name/description/readme). Use market_info for one repo's details and market_install to install.",
|
|
359
|
+
parameters: {
|
|
360
|
+
query: {
|
|
361
|
+
type: "string",
|
|
362
|
+
description: "Optional keywords to filter results, e.g. \"theme\", \"ui\", \"mcp\", \"todo\". Omit to list the most-starred dsh-plugin repos.",
|
|
363
|
+
},
|
|
364
|
+
sort: {
|
|
365
|
+
type: "string",
|
|
366
|
+
enum: ["stars", "updated", "forks"],
|
|
367
|
+
description: "Sort key; order is always descending. Defaults to stars.",
|
|
368
|
+
},
|
|
369
|
+
perPage: {
|
|
370
|
+
type: "number",
|
|
371
|
+
description: "Number of results to return, 1-30. Defaults to 10.",
|
|
372
|
+
},
|
|
373
|
+
page: {
|
|
374
|
+
type: "number",
|
|
375
|
+
description: "1-based result page for browsing beyond the first page. Defaults to 1.",
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
output: {
|
|
379
|
+
schema: { type: "string" },
|
|
380
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
381
|
+
},
|
|
382
|
+
async execute(args, exec) {
|
|
383
|
+
const perPage = Math.min(Math.max(Math.trunc(args.perPage ?? 10) || 10, 1), Math.trunc(perPageMax) || 30);
|
|
384
|
+
const { total, items } = await searchPlugins({
|
|
385
|
+
query: args.query,
|
|
386
|
+
sort: args.sort ?? "stars",
|
|
387
|
+
perPage,
|
|
388
|
+
page: args.page ?? 1,
|
|
389
|
+
apiBase,
|
|
390
|
+
token,
|
|
391
|
+
signal: exec.signal,
|
|
392
|
+
});
|
|
393
|
+
return renderSearch(total, items, args);
|
|
394
|
+
},
|
|
395
|
+
presentCall: (args) => ({
|
|
396
|
+
card: "generic",
|
|
397
|
+
title: `market_search ${typeof args.query === "string" ? args.query : ""}`.trim(),
|
|
398
|
+
kind: "execute",
|
|
399
|
+
content: [{ type: "text", text: "Search the dsh-plugin marketplace on GitHub" }],
|
|
400
|
+
}),
|
|
401
|
+
}));
|
|
402
|
+
|
|
403
|
+
ctx.tools.register(defineTool({
|
|
404
|
+
name: "market_info",
|
|
405
|
+
description: "Inspect one repository from the dsh plugin marketplace: stars, language, license, topics, and its package.json — crucially whether it declares dsh.bundle.patch (i.e. is a real dsh plugin bundle) and what npm name it would install as.",
|
|
406
|
+
parameters: {
|
|
407
|
+
repo: {
|
|
408
|
+
type: "string",
|
|
409
|
+
required: true,
|
|
410
|
+
description: "The repository as \"owner/name\", e.g. \"AwesomeHou/dsh-plugin-mallplace\".",
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
output: {
|
|
414
|
+
schema: { type: "string" },
|
|
415
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
416
|
+
},
|
|
417
|
+
async execute(args, exec) {
|
|
418
|
+
const info = await repoInfo({ repo: args.repo, apiBase, token, signal: exec.signal });
|
|
419
|
+
return renderInfo(info);
|
|
420
|
+
},
|
|
421
|
+
presentCall: (args) => ({
|
|
422
|
+
card: "generic",
|
|
423
|
+
title: `market_info ${args.repo}`,
|
|
424
|
+
kind: "execute",
|
|
425
|
+
content: [{ type: "text", text: "Inspect a dsh-plugin marketplace repository" }],
|
|
426
|
+
}),
|
|
427
|
+
}));
|
|
428
|
+
|
|
429
|
+
ctx.tools.register(defineTool({
|
|
430
|
+
name: "market_install",
|
|
431
|
+
description: "Install a plugin into a local dsh profile by running `pnpm add` in that profile's directory, reconciling the profile's bundle layer list, and — for browser-side UI plugins (`dsh.client`) — registering a loader row in the profile's cordis.patch.yml. Same flow as `dsh plugin --profile <name> add <spec>`. ALWAYS runs as a background job: the call returns a job id immediately; poll with job_output and cancel with job_kill. If pnpm blocks a dependency's install scripts, the job STOPS and reports which packages want to run install-time code, what those commands are, and whether each is the plugin itself or a transitive dependency the user never chose — nothing is executed and the profile is left untouched. Relay that list to the user verbatim, and only call again with `allowBuildScripts` naming the packages they approved. A successful install only takes effect after the dsh process restarts.",
|
|
432
|
+
parameters: {
|
|
433
|
+
spec: {
|
|
434
|
+
type: "string",
|
|
435
|
+
required: true,
|
|
436
|
+
description: "What to install: \"owner/repo\" (a dsh-plugin topic repo), \"github:owner/repo\", a GitHub URL, an npm package name (e.g. \"dsh-ui-dafeng-customizer\"), or a tarball URL. file:/link: paths must be absolute.",
|
|
437
|
+
},
|
|
438
|
+
profile: {
|
|
439
|
+
type: "string",
|
|
440
|
+
description: `Target profile under $DSH_HOME/profiles. Defaults to "${defaultProfile}".`,
|
|
441
|
+
},
|
|
442
|
+
allowBuildScripts: {
|
|
443
|
+
type: "array",
|
|
444
|
+
items: { type: "string" },
|
|
445
|
+
description: "Package names whose install-time scripts the USER has approved. pnpm blocks dependency install scripts by default; when the job reports that approval is needed it lists exactly which packages want to run code and what those commands are. Show that list to the user, get their answer, and only then call again with the names they approved. Never fill this in on your own initiative.",
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
output: {
|
|
449
|
+
schema: {
|
|
450
|
+
type: "object",
|
|
451
|
+
additionalProperties: false,
|
|
452
|
+
properties: BACKGROUND_OUTPUT_PROPERTIES,
|
|
453
|
+
},
|
|
454
|
+
render: (args, value) => [{
|
|
455
|
+
type: "text",
|
|
456
|
+
text: `started background job ${value.jobId} (${args.spec} → profile "${args.profile ?? defaultProfile}"); poll with job_output, cancel with job_kill. Restart dsh after a successful install.`,
|
|
457
|
+
}],
|
|
458
|
+
},
|
|
459
|
+
async execute(args, exec) {
|
|
460
|
+
const profile = String(args.profile ?? defaultProfile).trim();
|
|
461
|
+
const normalized = normalizeSpec(args.spec);
|
|
462
|
+
assertSafeSpec(normalized);
|
|
463
|
+
const registry = await registryFor(profile, npmRegistry);
|
|
464
|
+
const spec = await preferNpmSpec({ spec: normalized, registry, sources: rawSources });
|
|
465
|
+
await assertSafeToInstall({ spec, registry, sources: rawSources });
|
|
466
|
+
let profileDir;
|
|
467
|
+
try {
|
|
468
|
+
profileDir = resolveProfileDir(profile);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
throw new Error(`market_install: invalid profile: ${error.message}`);
|
|
471
|
+
}
|
|
472
|
+
if (!existsSync(join(profileDir, "package.json"))) {
|
|
473
|
+
ensureProfile(profile);
|
|
474
|
+
}
|
|
475
|
+
const allowBuildScripts = Array.isArray(args.allowBuildScripts)
|
|
476
|
+
? args.allowBuildScripts.map((name) => String(name))
|
|
477
|
+
: undefined;
|
|
478
|
+
const jobId = ctx.jobs.start({
|
|
479
|
+
kind: "dsh-plugin-install",
|
|
480
|
+
label: `dsh plugin --profile ${profile} add ${spec}`,
|
|
481
|
+
...exec.agent ? { owner: exec.agent } : {},
|
|
482
|
+
run: () => runInstall({ profile, spec, allowBuildScripts }),
|
|
483
|
+
});
|
|
484
|
+
return { kind: "background", jobId };
|
|
485
|
+
},
|
|
486
|
+
presentCall: (args) => ({
|
|
487
|
+
card: "generic",
|
|
488
|
+
title: `dsh plugin --profile ${args.profile ?? defaultProfile} add ${args.spec}`,
|
|
489
|
+
kind: "execute",
|
|
490
|
+
content: [{ type: "text", text: "Install a plugin into a dsh profile (background job)" }],
|
|
491
|
+
}),
|
|
492
|
+
}));
|
|
493
|
+
|
|
494
|
+
ctx.tools.register(defineTool({
|
|
495
|
+
name: "market_uninstall",
|
|
496
|
+
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.",
|
|
497
|
+
parameters: {
|
|
498
|
+
package: {
|
|
499
|
+
type: "string",
|
|
500
|
+
required: true,
|
|
501
|
+
description: "Installed package name to remove, e.g. \"@1e0zj/dsh-plugin-mall\" or \"dsh-at-file\".",
|
|
502
|
+
},
|
|
503
|
+
profile: {
|
|
504
|
+
type: "string",
|
|
505
|
+
description: `Target profile under $DSH_HOME/profiles. Defaults to "${defaultProfile}".`,
|
|
506
|
+
},
|
|
507
|
+
},
|
|
508
|
+
output: {
|
|
509
|
+
schema: {
|
|
510
|
+
type: "object",
|
|
511
|
+
additionalProperties: false,
|
|
512
|
+
properties: BACKGROUND_OUTPUT_PROPERTIES,
|
|
513
|
+
},
|
|
514
|
+
render: (args, value) => [{
|
|
515
|
+
type: "text",
|
|
516
|
+
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.`,
|
|
517
|
+
}],
|
|
518
|
+
},
|
|
519
|
+
async execute(args, exec) {
|
|
520
|
+
const profile = String(args.profile ?? defaultProfile).trim();
|
|
521
|
+
const packageName = String(args.package ?? "").trim();
|
|
522
|
+
if (packageName.length === 0) throw new Error("market_uninstall: package name is required");
|
|
523
|
+
assertSafeSpec(packageName);
|
|
524
|
+
try {
|
|
525
|
+
resolveProfileDir(profile);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
throw new Error(`market_uninstall: invalid profile: ${error.message}`);
|
|
528
|
+
}
|
|
529
|
+
const jobId = ctx.jobs.start({
|
|
530
|
+
kind: "dsh-plugin-uninstall",
|
|
531
|
+
label: `dsh plugin --profile ${profile} remove ${packageName}`,
|
|
532
|
+
...exec.agent ? { owner: exec.agent } : {},
|
|
533
|
+
run: () => runRemove({ profile, packageName }),
|
|
534
|
+
});
|
|
535
|
+
return { kind: "background", jobId };
|
|
536
|
+
},
|
|
537
|
+
presentCall: (args) => ({
|
|
538
|
+
card: "generic",
|
|
539
|
+
title: `dsh plugin --profile ${args.profile ?? defaultProfile} remove ${args.package}`,
|
|
540
|
+
kind: "execute",
|
|
541
|
+
content: [{ type: "text", text: "Remove a plugin from a dsh profile (background job)" }],
|
|
542
|
+
}),
|
|
543
|
+
}));
|
|
544
|
+
|
|
545
|
+
ctx.tools.register(defineTool({
|
|
546
|
+
name: "market_installed",
|
|
547
|
+
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).",
|
|
548
|
+
parameters: {
|
|
549
|
+
profile: {
|
|
550
|
+
type: "string",
|
|
551
|
+
description: `The profile to inspect. Defaults to "${defaultProfile}".`,
|
|
552
|
+
},
|
|
553
|
+
},
|
|
554
|
+
output: {
|
|
555
|
+
schema: { type: "string" },
|
|
556
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
557
|
+
},
|
|
558
|
+
async execute(args) {
|
|
559
|
+
const profile = String(args.profile ?? defaultProfile).trim();
|
|
560
|
+
try {
|
|
561
|
+
resolveProfileDir(profile);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
throw new Error(`market_installed: invalid profile: ${error.message}`);
|
|
564
|
+
}
|
|
565
|
+
return renderInstalled(listInstalled(profile), profile);
|
|
566
|
+
},
|
|
567
|
+
presentCall: (args) => ({
|
|
568
|
+
card: "generic",
|
|
569
|
+
title: `market_installed ${args.profile ?? defaultProfile}`,
|
|
570
|
+
kind: "execute",
|
|
571
|
+
content: [{ type: "text", text: "List plugins installed in a dsh profile" }],
|
|
572
|
+
}),
|
|
573
|
+
}));
|
|
574
|
+
|
|
575
|
+
// Browser surface: the /market RPC channel backs the Settings → Plugins →
|
|
576
|
+
// 插件市场 tab shipped in src/client.js.
|
|
577
|
+
registerRpcChannel(ctx, config, token);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// NOTE: no `export default` — the cordis loader unwraps `exports.default ?? exports`,
|
|
581
|
+
// so a default export would drop `inject`/`Config`/`name` and leave a bare apply function.
|