dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
Discover and install DSH plugins from the GitHub topic dsh-plugin — browse, read READMEs with optional Chinese translation, install in one click
English | 简体中文
Adds third-party plugin discovery to the DeepSeek Harness (DSH) desktop shell: it lists plugins
from the GitHub topic dsh-plugin, supports browsing, README preview (optional DeepL translation
into Chinese plus local keyword extraction), and one-click installation into the current profile.
It also owns the client-side configuration reads for the GitHub acceleration proxy.
It installs as a standalone bundle into a DSH profile and ships both a host half (Node.js) and a client half (browser).
The plugin manager bundled with the official DSH desktop shell only covers plugins whose name you already know: you supply a package name or spec, and it installs or removes it. What it lacks is discovery — there is no way to learn which third-party plugins exist or what they do.
DSH has no official plugin marketplace. The plugin library the community actually uses is the
GitHub topic dsh-plugin: tagging a repository with it
makes the repository findable. This plugin is the browsing and installation UI built on top of
that topic.
Two clarifications:
dsh.profile.bundles. This
plugin only brings the entry point onto the same page.| Item | Requirement |
|---|---|
| Node.js | ^22.19.0 || >=24 |
| DeepSeek Harness | Official desktop shell (uses the desktop profile; the plugin was developed and verified against it) |
| pnpm | Not required separately — the pnpm bundled with the desktop shell is used |
| DeepL API key | Optional; only machine translation of READMEs needs it. Everything else works without it |
# Install into the profile with the pnpm bundled with the official desktop shell
pnpm add "file:<absolute path to the plugin directory>"
# Or install straight from GitHub
pnpm add "github:VCPr0j3k7/dsh-plugin-market"
pnpm add only writes the profile's dependencies; it does not register the package in the
bundle layer stack. DSH assembles the plugin tree from dsh.profile.bundles at startup, so you
must also add the package name to that array in the profile's package.json:
// ~/.dsh/profiles/desktop/package.json
{
"dependencies": {
"dsh-plugin-market": "file:/path/to/dsh-plugin-market"
},
"dsh": {
"profile": {
"bundles": [
// …official bundles…
"dsh-plugin-market"
]
}
}
}
If this step is skipped, the symptom is: the dependency is installed, the package is present in
node_modules, yet the UI stays empty and nothing is logged — the plugin was never loaded.
An alternative is to install through the official CLI, which runs reconcilePlugins() after pnpm
finishes and automatically appends every dependency declaring dsh.bundle.patch to
dsh.profile.bundles:
dsh plugin --profile desktop add github:VCPr0j3k7/dsh-plugin-market
Either way, the harness must be restarted afterwards: plugins are loaded at startup, and a running instance does not hot-reload them.
pnpm remove dsh-plugin-market
If an entry remains in dsh.profile.bundles, remove it manually, then restart the harness.
The plugin consists of two halves, both loaded independently.
Host half (index.js) is a Cordis plugin exporting name / inject / apply(ctx). It
registers exactly one kind: 'prefix' HTTP route on ctx.webServer —
/dsh-plugin-market/api — and dispatches inside that prefix via host/router.mjs. The benefit is
a single registration point that the framework cleans up on unload, leaving no half-registered
route table behind in the web server.
Every endpoint returns { ok, data | error }. Business failures still return HTTP 200
(ok: false); HTTP status codes are reserved for transport-level facts such as "route not found".
Client half (client.js) is loaded through window.__ModuleLoader__.load({ id, factory }) and
registers into three official slots:
| Slot | Content |
|---|---|
sidebar.panellist | Sidebar entry "插件市场" |
main | The plugin discovery page itself |
shell.overlay | The full README preview drawer |
The client does not hard-code a host base URL. The page origin can be one of three things — the
host's own HTTP address, the shell's custom scheme, or an opaque origin (location.origin === "null")
— so on the first request it probes ["", "http://dsh.internal"] in order and decides which one
actually works by checking whether /info returns data.plugin equal to this plugin's module id.
A plain "HTTP 200" is not sufficient: the SPA fallback route answers unknown paths with 200 plus
HTML. The chosen base URL is printed to the browser console.
Install progress travels over an event queue: the host half keeps a ring buffer of at most 400
entries with a monotonic seq, and the client polls GET /events?since=<seq> every 300ms. Polling
was chosen over SSE because whether the shell buffers response bodies when forwarding through its
custom scheme cannot be confirmed — polling has no such unknown, at the cost of 300ms granularity.
Cancellation is implemented with AbortController: POST /market/install registers one controller
per repository, and POST /market/cancel calls abort(), which interrupts the download loop.
Restarting the host is not possible. The plugin is part of what the host loads, so there is no
way for it to restart the host itself, and killing the process would also discard the user's
session. POST /restart-host therefore refuses honestly and returns a "please quit and reopen
manually" message; the "restart now" button surfaces that reason.
Prefix: /dsh-plugin-market/api. All paths below are relative to it.
Common routes (host/router.mjs, shared by plugins):
| Method | Path | Purpose |
|---|---|---|
| GET | /info | Host state and plugin id (the client uses this to validate a base URL) |
| GET | /config | Read the shared configuration |
| POST | /config | Deep-merge and write back the shared configuration |
| POST | /open-external | Open an http/https URL with the system default handler |
| POST | /pick-directory | Pick a directory (prefers the official directoryPicker) |
| GET | /restart-pending | Whether the profile has changes that are not in effect yet |
| POST | /restart-host | Refuses honestly (a plugin cannot restart the host) |
| GET | /events | Fetch event increments via ?since= |
Routes of this plugin (index.js):
| Method | Path | Purpose |
|---|---|---|
| POST | /market/list | List repositories under the topic (1-hour disk cache) |
| POST | /market/details | Fetch README / translation / keywords for one repository |
| POST | /market/install | Download and install into the current profile (cancellable) |
| POST | /market/cancel | Cancel an in-flight download for a given repository |
| GET | /market/translate-status | Current translation state (enabled? key configured?) |
The client half declares only the routes this page actually uses, leaving no dead routes behind.
test/check.mjs extracts every route the client calls from client.js and verifies that the host
route table implements each one — a missing route typically shows up as "some button never
responds", which is expensive to diagnose, so it is covered by the self-check.
Expanding a card performs four steps in order:
README.zh-CN.md, README_zh.md, README.zh.md, …). A hit is used directly — it is more
accurate than machine translation and saves a translation quota. The body is fetched from
raw.githubusercontent.com, which does not consume API quota..rst, files under
docs/, and case differences) instead of guessing the path of README.md.The README drawer offers three views:
| View | Content | Limit |
|---|---|---|
| Full original | Raw Markdown, rendered by the built-in renderer | 400,000 characters (truncated and labelled beyond that) |
| Chinese translation | DeepL output as plain text | 6,000 characters (README_LIMIT, bounded by translation quota) |
| Markdown source | The raw Markdown | Same as "full original" |
Translations are additionally cached per repository on disk, so reopening costs nothing. Details are cached in-process by repository name (at most 60 entries).
The README renderer is a self-contained subset implementation: fenced code blocks, ATX headings,
horizontal rules, blockquotes, ordered/unordered lists (including nesting), tables and paragraphs,
plus inline code, bold, italic, strikethrough, links and images. A separate HTML subset renderer
handles the inline markup common in READMEs (<table> navigation, <details> blocks, centred
logos, and so on).
The output is a React element tree rather than an HTML string: READMEs come from arbitrary
third-party repositories and are untrusted input, so dangerouslySetInnerHTML would amount to
letting XSS in the door. The HTML subset renderer applies tag and attribute whitelists — tags
outside the list are stripped while their text is kept, and attributes outside the list are dropped
— so even a README containing <script> or onclick= degrades into harmless text.
Connecting to GitHub directly is slow and failure-prone in some network environments; the bottleneck is usually TLS handshake and time-to-first-byte rather than bandwidth. This plugin can rewrite requests to GitHub so that they go through a user-provided reverse proxy instead.
Three design constraints:
Accept headers and request methods are passed through unchanged, so the
response body is the same whether the proxy is on or off.api.github.com → codeload.github.com; releases go to release-assets / objects). If fetch
followed them automatically it would connect to that domain directly, bypassing the proxy
entirely. The redirect is therefore followed here, rewriting GitHub domains in the Location
header back into proxy addresses.Path prefixes and their upstream domains:
| Prefix | Upstream domain |
|---|---|
/api | api.github.com |
/raw | raw.githubusercontent.com |
/codeload | codeload.github.com |
/gh | github.com |
/objects | objects.githubusercontent.com |
/release-assets | release-assets.githubusercontent.com |
This table must match the location blocks of the reverse proxy one-to-one. A mismatch does not
raise an error — it silently returns 404. Avatars (avatars.githubusercontent.com) and images
embedded in READMEs are deliberately excluded: they are decorative assets fetched directly by the
renderer, so proxying them adds no functionality while widening the exposed surface.
The "test connectivity" action in the settings page probes two things — the proxy root path and a
real raw request — and both must pass. Checking only the root path is not enough: it is answered
by the reverse proxy itself, and whether the upstream is reachable is a separate question
(misconfigured resolver, an expired upstream certificate, or a proxy_pass missing its rewrite
all leave the root path healthy while the body returns 502).
Note that a self-hosted reverse proxy may speak plain HTTP. With githubToken configured, the
Authorization header is sent through it in the clear; make sure the link is trustworthy before
enabling it.
This plugin shares one configuration file with the sibling desktop-settings plugin:
<DSH_HOME>/dsh-extras.json (by default ~/.dsh/dsh-extras.json). Using a single fixed path avoids
the "I changed it here but nothing happened" class of problems, which is invisible in the UI. A file
is used rather than plugin-to-plugin calls because a file has no "the other plugin has not started
yet" ordering problem.
| Field | Default | Description |
|---|---|---|
appName | "DeepSeek Harness" | Display name (the desktop shell manages the window title itself) |
githubProxy.enabled | false | Whether the GitHub acceleration proxy is enabled |
githubProxy.baseUrl | "" | Reverse proxy base URL; when empty, requests fall back to direct connections even if the switch is on |
githubToken | "" | GitHub API token; empty means anonymous requests (60/hour), filling it in uses the authenticated quota |
translate.enabled | true | Whether to machine-translate English READMEs |
translate.endpoint | https://api-free.deepl.com/v2/translate | DeepL endpoint; Free keys end with :fx and must use the api-free host |
translate.apiKey | "" | DeepL API key; no key is bundled |
translate.targetLang | "ZH" | Target language code (DeepL uses upper case) |
translate.characterCount | 0 | Locally accumulated translated character count, used only to show rough usage |
translate.maxCharacters | 4000 | Per-request character limit for translation |
On first run, when no configuration file exists yet, translate and githubProxy are migrated once
from the DSH-Desktop shell configuration (%APPDATA%/DSH-Desktop/config.json) so that keys and
addresses entered previously keep working.
The configuration is deliberately not cached in-process: the four plugins are four independent module instances, and per-instance caches would hide each other's writes. The file is only a few hundred bytes, so re-reading it costs nothing and buys immediate effect for edits.
Two conditions must hold for the plugin to be loaded:
dependencies (it resolves inside node_modules);package.json's dsh.profile.bundles array.The second one is easy to miss — see Installation.
Troubleshooting note: the plugin library toggle in the official desktop shell rewrites
dsh.profile.bundles (it calls sanitizeProfile(...) internally to strip non-official bundles). If
the plugin stops working after a period of normal operation, first check whether its entry is still
present in bundles.
npm test
# equivalent to: node test/check.mjs
test/check.mjs is a pure logic self-check (34 cases) that does not depend on the DSH runtime and
does not require installing the plugin into a profile. It covers:
dsh.bundle.patch / dsh.client / exports['./client'] declarations in package.json and
whether the files they point to really exist;name / inject / apply;apply() does not throw, registers its route under the expected prefix with prefix
matching, and hands cleanup to ctx.effect;client.js and verified
against the host route table; additionally, a real request confirms GET /info returns 200 with
data.plugin equal to this plugin's id, that unknown routes return 404, and that requests outside
the prefix do not match;githubToken field.The self-check points DSH_HOME at a temporary directory and never touches the user's real ~/.dsh.
End-to-end verification: after installing and restarting the desktop shell, a "插件市场" entry appears in the sidebar and the page loads a repository list; "README" slides a preview out from the right; "安装" downloads inside the application and, once finished, reports that a restart is needed.
POST /restart-host refuses honestly rather than restarting.README_LIMIT). This is a hard constraint
of DeepL's per-character billing; the UI labels the coverage honestly. Switch to "full original"
for the complete text.README_MARKDOWN_LIMIT); anything beyond is
truncated and labelled in the UI.process.argv at module load time to locate the profile directory and the
bundled pnpm (the layout is documented in host/env.mjs). Run outside the official host process,
those positions are unavailable and installation-related features do not work.host/yaml-libs.mjs currently have no call sites in this package and are
kept as host-layer tools.