@1e0zj/dsh-plugin-mall 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/cordis.patch.yml +13 -0
- package/package.json +50 -0
- package/src/client.js +380 -0
- package/src/github.js +136 -0
- package/src/index.js +402 -0
- package/src/installer.js +429 -0
package/src/github.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// GitHub API helpers for the dsh plugin marketplace.
|
|
2
|
+
// Pure functions with no harness imports, so this module is unit-testable
|
|
3
|
+
// standalone (node src/github.js --self-test).
|
|
4
|
+
|
|
5
|
+
const SEARCH_TOPIC = "topic:dsh-plugin";
|
|
6
|
+
|
|
7
|
+
export function buildHeaders(token) {
|
|
8
|
+
const headers = {
|
|
9
|
+
"User-Agent": "dsh-plugin-mall",
|
|
10
|
+
Accept: "application/vnd.github+json",
|
|
11
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
12
|
+
};
|
|
13
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
14
|
+
return headers;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function apiUrl(apiBase, path) {
|
|
18
|
+
const base = apiBase.endsWith("/") ? apiBase : `${apiBase}/`;
|
|
19
|
+
return `${base}${path.replace(/^\//, "")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function requestJson(path, { apiBase, token, signal }) {
|
|
23
|
+
let response;
|
|
24
|
+
try {
|
|
25
|
+
response = await fetch(apiUrl(apiBase, path), {
|
|
26
|
+
headers: buildHeaders(token),
|
|
27
|
+
signal,
|
|
28
|
+
});
|
|
29
|
+
} catch (error) {
|
|
30
|
+
if (error?.name === "AbortError") throw error;
|
|
31
|
+
throw new Error(`GitHub API request failed: ${error?.message ?? String(error)}`);
|
|
32
|
+
}
|
|
33
|
+
const remaining = response.headers.get("x-ratelimit-remaining");
|
|
34
|
+
const resetAt = response.headers.get("x-ratelimit-reset");
|
|
35
|
+
const body = await response.json().catch(() => undefined);
|
|
36
|
+
if (response.status === 403 && remaining === "0" && resetAt !== null) {
|
|
37
|
+
const reset = new Date(Number(resetAt) * 1000).toISOString();
|
|
38
|
+
throw new Error(`GitHub API rate limit exceeded; resets at ${reset} (UTC). Set GITHUB_TOKEN or DSH_MARKET_GITHUB_TOKEN for a higher limit.`);
|
|
39
|
+
}
|
|
40
|
+
if (response.status === 404) {
|
|
41
|
+
throw new Error(`GitHub API 404: ${body?.message ?? "not found"}`);
|
|
42
|
+
}
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
throw new Error(`GitHub API ${response.status}: ${body?.message ?? response.statusText}`);
|
|
45
|
+
}
|
|
46
|
+
return body;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Pick the stable, compact fields the tools render. */
|
|
50
|
+
function pickRepo(item) {
|
|
51
|
+
return {
|
|
52
|
+
fullName: item.full_name ?? "",
|
|
53
|
+
htmlUrl: item.html_url ?? "",
|
|
54
|
+
description: item.description ?? "",
|
|
55
|
+
stars: item.stargazers_count ?? 0,
|
|
56
|
+
forks: item.forks_count ?? 0,
|
|
57
|
+
language: item.language,
|
|
58
|
+
license: item.license?.spdx_id,
|
|
59
|
+
topics: item.topics ?? [],
|
|
60
|
+
updatedAt: item.updated_at ?? "",
|
|
61
|
+
archived: item.archived ?? false,
|
|
62
|
+
defaultBranch: item.default_branch ?? "main",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Search repositories tagged `topic:dsh-plugin`, optionally narrowed by
|
|
68
|
+
* keywords (name/description/readme match), star-ranked by default.
|
|
69
|
+
*/
|
|
70
|
+
export async function searchPlugins({ query, sort = "stars", perPage = 10, page = 1, apiBase, token, signal }) {
|
|
71
|
+
const trimmed = typeof query === "string" ? query.trim() : "";
|
|
72
|
+
const q = trimmed.length > 0 ? `${SEARCH_TOPIC} ${trimmed}` : SEARCH_TOPIC;
|
|
73
|
+
const safePerPage = Math.min(Math.max(Math.trunc(perPage) || 10, 1), 100);
|
|
74
|
+
const safePage = Math.max(Math.trunc(page) || 1, 1);
|
|
75
|
+
const path = `/search/repositories?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}&order=desc&per_page=${safePerPage}&page=${safePage}`;
|
|
76
|
+
const body = await requestJson(path, { apiBase, token, signal });
|
|
77
|
+
return {
|
|
78
|
+
total: body.total_count ?? 0,
|
|
79
|
+
page: safePage,
|
|
80
|
+
perPage: safePerPage,
|
|
81
|
+
items: (body.items ?? []).map(pickRepo),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Fetch one repository's metadata plus its package.json (base64-decoded),
|
|
87
|
+
* which is what tells us whether it declares a dsh bundle patch.
|
|
88
|
+
*/
|
|
89
|
+
export async function repoInfo({ repo, apiBase, token, signal }) {
|
|
90
|
+
const trimmed = String(repo ?? "").trim();
|
|
91
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(trimmed) || trimmed.includes("..")) {
|
|
92
|
+
throw new Error(`market_info: repo must be "owner/name", got ${JSON.stringify(trimmed)}`);
|
|
93
|
+
}
|
|
94
|
+
let meta;
|
|
95
|
+
try {
|
|
96
|
+
meta = await requestJson(`/repos/${trimmed}`, { apiBase, token, signal });
|
|
97
|
+
} catch (error) {
|
|
98
|
+
throw new Error(`market_info: repository ${trimmed} not found on GitHub (${error.message})`);
|
|
99
|
+
}
|
|
100
|
+
let packageJson;
|
|
101
|
+
try {
|
|
102
|
+
const contents = await requestJson(`/repos/${trimmed}/contents/package.json`, { apiBase, token, signal });
|
|
103
|
+
if (typeof contents.content === "string") {
|
|
104
|
+
packageJson = JSON.parse(Buffer.from(contents.content, "base64").toString("utf8"));
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
packageJson = undefined; // no package.json at the repo root
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
meta: pickRepo(meta),
|
|
111
|
+
packageJson: packageJson === undefined ? undefined : {
|
|
112
|
+
name: packageJson.name,
|
|
113
|
+
version: packageJson.version,
|
|
114
|
+
description: packageJson.description,
|
|
115
|
+
type: packageJson.type,
|
|
116
|
+
dshBundlePatch: typeof packageJson.dsh?.bundle?.patch === "string" ? packageJson.dsh.bundle.patch : undefined,
|
|
117
|
+
dshClientPlatform: packageJson.dsh?.client?.platform,
|
|
118
|
+
dshClientInjectCount: Array.isArray(packageJson.dsh?.client?.inject) ? packageJson.dsh.client.inject.length : undefined,
|
|
119
|
+
dependencyCount: Object.keys(packageJson.dependencies ?? {}).length,
|
|
120
|
+
peerDependencyCount: Object.keys(packageJson.peerDependencies ?? {}).length,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Self-test entry: node src/github.js
|
|
126
|
+
if (process.argv[1]?.endsWith("github.js") && process.argv.includes("--self-test")) {
|
|
127
|
+
const apiBase = "https://api.github.com";
|
|
128
|
+
const result = await searchPlugins({ query: "", perPage: 3, apiBase });
|
|
129
|
+
console.log(`total=${result.total} page=${result.page} perPage=${result.perPage}`);
|
|
130
|
+
for (const item of result.items) console.log(`${item.fullName} ★${item.stars} ${item.language ?? ""}`);
|
|
131
|
+
if (result.items.length > 0) {
|
|
132
|
+
const info = await repoInfo({ repo: result.items[0].fullName, apiBase });
|
|
133
|
+
console.log(`repo=${info.meta.fullName} defaultBranch=${info.meta.defaultBranch} archived=${info.meta.archived}`);
|
|
134
|
+
console.log(`packageJson=${info.packageJson ? `${info.packageJson.name}@${info.packageJson.version} bundle=${info.packageJson.dshBundlePatch ?? "none"}` : "absent"}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
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 four 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_installed list a profile's installed plugins
|
|
10
|
+
//
|
|
11
|
+
// Plugin contract (see @deepseek-ai/cordis-plugin-loader): the loader imports
|
|
12
|
+
// this module and uses its `apply(ctx, config)`; `inject` declares required
|
|
13
|
+
// services, `Config` validates the row's config, `name` is the plugin name.
|
|
14
|
+
|
|
15
|
+
import z from "@deepseek-ai/schemastery";
|
|
16
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
17
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
20
|
+
import { repoInfo, searchPlugins } from "./github.js";
|
|
21
|
+
import { ensureProfile, listInstalled, normalizeSpec, runInstall, createInstallTracker } from "./installer.js";
|
|
22
|
+
|
|
23
|
+
export const name = "@1e0zj/dsh-plugin-mall";
|
|
24
|
+
export const inject = ["tools", "jobs", "systemPrompt"];
|
|
25
|
+
|
|
26
|
+
export const Config = z.object({
|
|
27
|
+
defaultProfile: z.string().default("web"),
|
|
28
|
+
apiBase: z.string().default("https://api.github.com"),
|
|
29
|
+
perPageMax: z.number().default(30),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** Clip long strings for compact model-facing output. */
|
|
33
|
+
function clip(text, max) {
|
|
34
|
+
const trimmed = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
35
|
+
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Markdown-free listing of search hits, one repo per block. */
|
|
39
|
+
function renderSearch(total, items, args) {
|
|
40
|
+
const narrowed = typeof args.query === "string" && args.query.trim().length > 0;
|
|
41
|
+
if (items.length === 0) {
|
|
42
|
+
return `No repositories tagged dsh-plugin${narrowed ? ` matching "${args.query.trim()}"` : ""}.`;
|
|
43
|
+
}
|
|
44
|
+
const lines = [`${total} repositories tagged dsh-plugin${narrowed ? ` matching "${args.query.trim()}"` : ""} — showing ${items.length}.\n`];
|
|
45
|
+
for (const [index, item] of items.entries()) {
|
|
46
|
+
const flags = [
|
|
47
|
+
`★${item.stars}`,
|
|
48
|
+
item.forks ? `fork ${item.forks}` : "",
|
|
49
|
+
item.language ?? "",
|
|
50
|
+
item.license ?? "",
|
|
51
|
+
item.archived ? "archived" : "",
|
|
52
|
+
].filter(Boolean).join(" | ");
|
|
53
|
+
lines.push(`${index + 1}. ${item.fullName} ${flags}`);
|
|
54
|
+
if (item.description) lines.push(` ${clip(item.description, 200)}`);
|
|
55
|
+
lines.push(` updated ${item.updatedAt} ${item.htmlUrl}`);
|
|
56
|
+
lines.push(` install spec: github:${item.fullName}`);
|
|
57
|
+
lines.push("");
|
|
58
|
+
}
|
|
59
|
+
lines.push(`Next: market_info "${items[0].fullName}" for details, or market_install with any spec above.`);
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function renderInfo(info) {
|
|
64
|
+
const { meta, packageJson } = info;
|
|
65
|
+
const lines = [
|
|
66
|
+
`${meta.fullName}`,
|
|
67
|
+
` url: ${meta.htmlUrl}`,
|
|
68
|
+
` stars: ${meta.stars} forks: ${meta.forks} language: ${meta.language ?? "—"} license: ${meta.license ?? "—"}${meta.archived ? " [ARCHIVED]" : ""}`,
|
|
69
|
+
` topics: ${meta.topics.join(", ") || "—"}`,
|
|
70
|
+
` updated: ${meta.updatedAt}`,
|
|
71
|
+
` branch: ${meta.defaultBranch}`,
|
|
72
|
+
meta.description ? ` about: ${clip(meta.description, 300)}` : "",
|
|
73
|
+
"",
|
|
74
|
+
];
|
|
75
|
+
if (packageJson === undefined) {
|
|
76
|
+
lines.push("package.json: not found at the repository root — likely not an npm-packaged dsh plugin.");
|
|
77
|
+
} else {
|
|
78
|
+
lines.push(`package.json (${packageJson.name ?? "no name"}@${packageJson.version ?? "?"}):`);
|
|
79
|
+
lines.push(` type: ${packageJson.type ?? "commonjs"} dependencies: ${packageJson.dependencyCount} peerDependencies: ${packageJson.peerDependencyCount}`);
|
|
80
|
+
if (packageJson.dshBundlePatch !== undefined) {
|
|
81
|
+
lines.push(` dsh.bundle.patch: ${packageJson.dshBundlePatch} — this IS a dsh bundle (host/agent plugin layer).`);
|
|
82
|
+
lines.push("");
|
|
83
|
+
lines.push(`Install: market_install with spec "github:${meta.fullName}"`);
|
|
84
|
+
lines.push(`npm install (if published): market_install with spec "${packageJson.name}"`);
|
|
85
|
+
} else if (packageJson.dshClientPlatform !== undefined || packageJson.dshClientInjectCount !== undefined) {
|
|
86
|
+
lines.push(` dsh.client: platform=${packageJson.dshClientPlatform ?? "?"}, injects ${packageJson.dshClientInjectCount ?? "?"} client services — a browser-side UI plugin.`);
|
|
87
|
+
lines.push(` market_install adds the dependency AND registers a loader row in the profile's cordis.patch.yml.`);
|
|
88
|
+
lines.push("");
|
|
89
|
+
lines.push(`Install: market_install with spec "github:${meta.fullName}"`);
|
|
90
|
+
} else {
|
|
91
|
+
lines.push(" dsh.bundle.patch: absent, dsh.client: absent — installing this adds a plain dependency, not a plugin layer.");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
lines.push(`Caution: community code — review the repository before installing.`);
|
|
95
|
+
return lines.join("\n");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function renderInstalled(result, profile) {
|
|
99
|
+
const { dir, deps } = result;
|
|
100
|
+
if (deps.length === 0) return `Profile "${profile}" (${dir}) has no installed plugins.`;
|
|
101
|
+
const markers = { bundle: "[bundle ✓ 宿主插件层]", client: "[client ✓ 浏览器UI插件]", plain: "[普通依赖]", missing: "[未解析]" };
|
|
102
|
+
const lines = [`Profile "${profile}" (${dir}) — ${deps.length} installed plugin(s):`];
|
|
103
|
+
for (const dep of deps) {
|
|
104
|
+
lines.push(` ${dep.name}@${dep.version} ${markers[dep.kind] ?? markers.plain}`);
|
|
105
|
+
}
|
|
106
|
+
lines.push("");
|
|
107
|
+
lines.push(`Remove with: dsh plugin --profile ${profile} remove <name> (then restart dsh).`);
|
|
108
|
+
return lines.join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Output schema for the background-acknowledgement shape (mirrors the bash tool). */
|
|
112
|
+
const BACKGROUND_OUTPUT_PROPERTIES = {
|
|
113
|
+
kind: { type: "string", required: true, const: "background" },
|
|
114
|
+
jobId: { type: "string", required: true },
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// ── browser RPC channel (/market) ───────────────────────────────────────────
|
|
118
|
+
//
|
|
119
|
+
// The web UI half (src/client.js) talks to this node half through the
|
|
120
|
+
// Connection service's generic RPC channels (`connection.rpc.handle`). The
|
|
121
|
+
// shared /api channel belongs to the api-gateway, so the marketplace owns its
|
|
122
|
+
// own loopback-only channel. Every endpoint answers `{ok:true,value}` or
|
|
123
|
+
// `{ok:false,error}` — the client unwraps this envelope itself.
|
|
124
|
+
|
|
125
|
+
function rpcOk(value) {
|
|
126
|
+
return { ok: true, value };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function rpcFail(error) {
|
|
130
|
+
return { ok: false, error: error?.message ?? String(error) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Dispatch one /market RPC endpoint. Runs inside the plugin fiber, so it
|
|
135
|
+
* shares the tools' GitHub helpers and the install tracker. The agent-plane
|
|
136
|
+
* tools keep using ctx.jobs; the browser surface uses `tracker` because the
|
|
137
|
+
* web host plane has no job controller for ctx.jobs to serve.
|
|
138
|
+
* @param ctx - plugin context.
|
|
139
|
+
* @param endpoint - "search" | "info" | "installed" | "install" | "job" | "jobCancel".
|
|
140
|
+
* @param payload - endpoint arguments from the browser.
|
|
141
|
+
* @param config - the row config (defaultProfile, apiBase, perPageMax).
|
|
142
|
+
* @param token - GitHub token from the environment.
|
|
143
|
+
* @param tracker - the in-process install tracker.
|
|
144
|
+
* @returns the {ok, value|error} envelope.
|
|
145
|
+
*/
|
|
146
|
+
async function rpcDispatch(ctx, endpoint, payload, config, token, tracker) {
|
|
147
|
+
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30 } = config;
|
|
148
|
+
switch (endpoint) {
|
|
149
|
+
case "search": {
|
|
150
|
+
const perPage = Math.min(Math.max(Math.trunc(payload?.perPage ?? 10) || 10, 1), Math.trunc(perPageMax) || 30);
|
|
151
|
+
const result = await searchPlugins({
|
|
152
|
+
query: payload?.query,
|
|
153
|
+
sort: payload?.sort ?? "stars",
|
|
154
|
+
perPage,
|
|
155
|
+
page: payload?.page ?? 1,
|
|
156
|
+
apiBase,
|
|
157
|
+
token,
|
|
158
|
+
});
|
|
159
|
+
return rpcOk(result);
|
|
160
|
+
}
|
|
161
|
+
case "info": {
|
|
162
|
+
const result = await repoInfo({ repo: payload?.repo, apiBase, token });
|
|
163
|
+
return rpcOk(result);
|
|
164
|
+
}
|
|
165
|
+
case "installed": {
|
|
166
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
167
|
+
try {
|
|
168
|
+
resolveProfileDir(profile);
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
171
|
+
}
|
|
172
|
+
return rpcOk(listInstalled(profile));
|
|
173
|
+
}
|
|
174
|
+
case "install": {
|
|
175
|
+
const profile = String(payload?.profile ?? defaultProfile).trim();
|
|
176
|
+
let spec;
|
|
177
|
+
try {
|
|
178
|
+
spec = normalizeSpec(payload?.spec);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
return rpcFail(error);
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const profileDir = resolveProfileDir(profile);
|
|
184
|
+
if (!existsSync(join(profileDir, "package.json"))) ensureProfile(profile);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return rpcFail(new Error(`invalid profile: ${error.message}`));
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const jobId = tracker.start({ profile, spec });
|
|
190
|
+
return rpcOk({ jobId, profile, spec });
|
|
191
|
+
} catch (error) {
|
|
192
|
+
return rpcFail(error);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
case "job": {
|
|
196
|
+
try {
|
|
197
|
+
return rpcOk(tracker.get(payload?.jobId));
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return rpcFail(error);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
case "jobCancel": {
|
|
203
|
+
try {
|
|
204
|
+
return rpcOk({ result: tracker.cancel(payload?.jobId) });
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return rpcFail(error);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
default:
|
|
210
|
+
return rpcFail(new Error(`unknown /market endpoint ${JSON.stringify(endpoint)}`));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Register the /market RPC channel once the Connection service exists (web
|
|
216
|
+
* profiles). `ctx.inject` defers the callback until the service is provided —
|
|
217
|
+
* activation order never races — and in headless/test profiles the callback
|
|
218
|
+
* simply never runs, so the agent tools remain the only surface there.
|
|
219
|
+
* @param ctx - plugin context.
|
|
220
|
+
* @param config - the row config.
|
|
221
|
+
* @param token - GitHub token from the environment.
|
|
222
|
+
*/
|
|
223
|
+
function registerRpcChannel(ctx, config, token) {
|
|
224
|
+
const tracker = createInstallTracker();
|
|
225
|
+
ctx.inject(["connection"], (connectionCtx) => {
|
|
226
|
+
connectionCtx.connection.rpc.handle("/market", (endpoint, payload, signal) => rpcDispatch(ctx, endpoint, payload ?? {}, config, token, tracker), { authority: "loopback" });
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function apply(ctx, config = {}) {
|
|
231
|
+
const { defaultProfile = "web", apiBase = "https://api.github.com", perPageMax = 30 } = config;
|
|
232
|
+
const token = process.env.GITHUB_TOKEN ?? process.env.DSH_MARKET_GITHUB_TOKEN;
|
|
233
|
+
|
|
234
|
+
ctx.systemPrompt.section({
|
|
235
|
+
name: "tool:market",
|
|
236
|
+
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).",
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
ctx.tools.register(defineTool({
|
|
241
|
+
name: "market_search",
|
|
242
|
+
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.",
|
|
243
|
+
parameters: {
|
|
244
|
+
query: {
|
|
245
|
+
type: "string",
|
|
246
|
+
description: "Optional keywords to filter results, e.g. \"theme\", \"ui\", \"mcp\", \"todo\". Omit to list the most-starred dsh-plugin repos.",
|
|
247
|
+
},
|
|
248
|
+
sort: {
|
|
249
|
+
type: "string",
|
|
250
|
+
enum: ["stars", "updated", "forks"],
|
|
251
|
+
description: "Sort key; order is always descending. Defaults to stars.",
|
|
252
|
+
},
|
|
253
|
+
perPage: {
|
|
254
|
+
type: "number",
|
|
255
|
+
description: "Number of results to return, 1-30. Defaults to 10.",
|
|
256
|
+
},
|
|
257
|
+
page: {
|
|
258
|
+
type: "number",
|
|
259
|
+
description: "1-based result page for browsing beyond the first page. Defaults to 1.",
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
output: {
|
|
263
|
+
schema: { type: "string" },
|
|
264
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
265
|
+
},
|
|
266
|
+
async execute(args, exec) {
|
|
267
|
+
const perPage = Math.min(Math.max(Math.trunc(args.perPage ?? 10) || 10, 1), Math.trunc(perPageMax) || 30);
|
|
268
|
+
const { total, items } = await searchPlugins({
|
|
269
|
+
query: args.query,
|
|
270
|
+
sort: args.sort ?? "stars",
|
|
271
|
+
perPage,
|
|
272
|
+
page: args.page ?? 1,
|
|
273
|
+
apiBase,
|
|
274
|
+
token,
|
|
275
|
+
signal: exec.signal,
|
|
276
|
+
});
|
|
277
|
+
return renderSearch(total, items, args);
|
|
278
|
+
},
|
|
279
|
+
presentCall: (args) => ({
|
|
280
|
+
card: "generic",
|
|
281
|
+
title: `market_search ${typeof args.query === "string" ? args.query : ""}`.trim(),
|
|
282
|
+
kind: "execute",
|
|
283
|
+
content: [{ type: "text", text: "Search the dsh-plugin marketplace on GitHub" }],
|
|
284
|
+
}),
|
|
285
|
+
}));
|
|
286
|
+
|
|
287
|
+
ctx.tools.register(defineTool({
|
|
288
|
+
name: "market_info",
|
|
289
|
+
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.",
|
|
290
|
+
parameters: {
|
|
291
|
+
repo: {
|
|
292
|
+
type: "string",
|
|
293
|
+
required: true,
|
|
294
|
+
description: "The repository as \"owner/name\", e.g. \"AwesomeHou/dsh-plugin-mallplace\".",
|
|
295
|
+
},
|
|
296
|
+
},
|
|
297
|
+
output: {
|
|
298
|
+
schema: { type: "string" },
|
|
299
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
300
|
+
},
|
|
301
|
+
async execute(args, exec) {
|
|
302
|
+
const info = await repoInfo({ repo: args.repo, apiBase, token, signal: exec.signal });
|
|
303
|
+
return renderInfo(info);
|
|
304
|
+
},
|
|
305
|
+
presentCall: (args) => ({
|
|
306
|
+
card: "generic",
|
|
307
|
+
title: `market_info ${args.repo}`,
|
|
308
|
+
kind: "execute",
|
|
309
|
+
content: [{ type: "text", text: "Inspect a dsh-plugin marketplace repository" }],
|
|
310
|
+
}),
|
|
311
|
+
}));
|
|
312
|
+
|
|
313
|
+
ctx.tools.register(defineTool({
|
|
314
|
+
name: "market_install",
|
|
315
|
+
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. GitHub-hosted installs whose build scripts pnpm blocks are retried once automatically after merging the names into the profile's allowBuilds. A successful install only takes effect after the dsh process restarts.",
|
|
316
|
+
parameters: {
|
|
317
|
+
spec: {
|
|
318
|
+
type: "string",
|
|
319
|
+
required: true,
|
|
320
|
+
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.",
|
|
321
|
+
},
|
|
322
|
+
profile: {
|
|
323
|
+
type: "string",
|
|
324
|
+
description: `Target profile under $DSH_HOME/profiles. Defaults to "${defaultProfile}".`,
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
output: {
|
|
328
|
+
schema: {
|
|
329
|
+
type: "object",
|
|
330
|
+
additionalProperties: false,
|
|
331
|
+
properties: BACKGROUND_OUTPUT_PROPERTIES,
|
|
332
|
+
},
|
|
333
|
+
render: (args, value) => [{
|
|
334
|
+
type: "text",
|
|
335
|
+
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.`,
|
|
336
|
+
}],
|
|
337
|
+
},
|
|
338
|
+
async execute(args, exec) {
|
|
339
|
+
const profile = String(args.profile ?? defaultProfile).trim();
|
|
340
|
+
const spec = normalizeSpec(args.spec);
|
|
341
|
+
let profileDir;
|
|
342
|
+
try {
|
|
343
|
+
profileDir = resolveProfileDir(profile);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
throw new Error(`market_install: invalid profile: ${error.message}`);
|
|
346
|
+
}
|
|
347
|
+
if (!existsSync(join(profileDir, "package.json"))) {
|
|
348
|
+
ensureProfile(profile);
|
|
349
|
+
}
|
|
350
|
+
const jobId = ctx.jobs.start({
|
|
351
|
+
kind: "dsh-plugin-install",
|
|
352
|
+
label: `dsh plugin --profile ${profile} add ${spec}`,
|
|
353
|
+
...exec.agent ? { owner: exec.agent } : {},
|
|
354
|
+
run: () => runInstall({ profile, spec }),
|
|
355
|
+
});
|
|
356
|
+
return { kind: "background", jobId };
|
|
357
|
+
},
|
|
358
|
+
presentCall: (args) => ({
|
|
359
|
+
card: "generic",
|
|
360
|
+
title: `dsh plugin --profile ${args.profile ?? defaultProfile} add ${args.spec}`,
|
|
361
|
+
kind: "execute",
|
|
362
|
+
content: [{ type: "text", text: "Install a plugin into a dsh profile (background job)" }],
|
|
363
|
+
}),
|
|
364
|
+
}));
|
|
365
|
+
|
|
366
|
+
ctx.tools.register(defineTool({
|
|
367
|
+
name: "market_installed",
|
|
368
|
+
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).",
|
|
369
|
+
parameters: {
|
|
370
|
+
profile: {
|
|
371
|
+
type: "string",
|
|
372
|
+
description: `The profile to inspect. Defaults to "${defaultProfile}".`,
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
output: {
|
|
376
|
+
schema: { type: "string" },
|
|
377
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
378
|
+
},
|
|
379
|
+
async execute(args) {
|
|
380
|
+
const profile = String(args.profile ?? defaultProfile).trim();
|
|
381
|
+
try {
|
|
382
|
+
resolveProfileDir(profile);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
throw new Error(`market_installed: invalid profile: ${error.message}`);
|
|
385
|
+
}
|
|
386
|
+
return renderInstalled(listInstalled(profile), profile);
|
|
387
|
+
},
|
|
388
|
+
presentCall: (args) => ({
|
|
389
|
+
card: "generic",
|
|
390
|
+
title: `market_installed ${args.profile ?? defaultProfile}`,
|
|
391
|
+
kind: "execute",
|
|
392
|
+
content: [{ type: "text", text: "List plugins installed in a dsh profile" }],
|
|
393
|
+
}),
|
|
394
|
+
}));
|
|
395
|
+
|
|
396
|
+
// Browser surface: the /market RPC channel backs the Settings → Plugins →
|
|
397
|
+
// 插件市场 tab shipped in src/client.js.
|
|
398
|
+
registerRpcChannel(ctx, config, token);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// NOTE: no `export default` — the cordis loader unwraps `exports.default ?? exports`,
|
|
402
|
+
// so a default export would drop `inject`/`Config`/`name` and leave a bare apply function.
|