VCPr0j3k7/dsh-plugin-market

dsh-plugin-market

Discover and install DSH plugins from the GitHub topic dsh-plugin — browse, read READMEs with optional Chinese translation, install in one click

Uncategorizedcordisdeepseek-harnessdsh

dsh-plugin-market

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).


Contents

Background

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:

  1. This plugin does not replace the official plugin manager. Write operations (install, remove, enable/disable) keep the same profile semantics — they rewrite dsh.profile.bundles. This plugin only brings the entry point onto the same page.
  2. Discovery relies entirely on the public GitHub API. No intermediary service is involved.

Requirements

ItemRequirement
Node.js^22.19.0 || >=24
DeepSeek HarnessOfficial desktop shell (uses the desktop profile; the plugin was developed and verified against it)
pnpmNot required separately — the pnpm bundled with the desktop shell is used
DeepL API keyOptional; only machine translation of READMEs needs it. Everything else works without it

Installation

# 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"

Registering the bundle is mandatory

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.

Uninstallation

pnpm remove dsh-plugin-market

If an entry remains in dsh.profile.bundles, remove it manually, then restart the harness.

How It Works

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:

SlotContent
sidebar.panellistSidebar entry "插件市场"
mainThe plugin discovery page itself
shell.overlayThe 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.

Route Table

Prefix: /dsh-plugin-market/api. All paths below are relative to it.

Common routes (host/router.mjs, shared by plugins):

MethodPathPurpose
GET/infoHost state and plugin id (the client uses this to validate a base URL)
GET/configRead the shared configuration
POST/configDeep-merge and write back the shared configuration
POST/open-externalOpen an http/https URL with the system default handler
POST/pick-directoryPick a directory (prefers the official directoryPicker)
GET/restart-pendingWhether the profile has changes that are not in effect yet
POST/restart-hostRefuses honestly (a plugin cannot restart the host)
GET/eventsFetch event increments via ?since=

Routes of this plugin (index.js):

MethodPathPurpose
POST/market/listList repositories under the topic (1-hour disk cache)
POST/market/detailsFetch README / translation / keywords for one repository
POST/market/installDownload and install into the current profile (cancellable)
POST/market/cancelCancel an in-flight download for a given repository
GET/market/translate-statusCurrent 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.

README Preview and Translation

Expanding a card performs four steps in order:

  1. Look for an author-provided Chinese README: list the repository root and match common file names (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.
  2. Fetch the README body: through GitHub's readme endpoint (which handles .rst, files under docs/, and case differences) instead of guessing the path of README.md.
  3. Extract Chinese keywords: purely local dictionary matching, consuming no quota and working even when translation is disabled.
  4. Translate (optional): DeepL is called only when the body is essentially free of Chinese. The criterion is a CJK character ratio below 8% and fewer than 30 CJK characters in absolute terms.

The README drawer offers three views:

ViewContentLimit
Full originalRaw Markdown, rendered by the built-in renderer400,000 characters (truncated and labelled beyond that)
Chinese translationDeepL output as plain text6,000 characters (README_LIMIT, bounded by translation quota)
Markdown sourceThe raw MarkdownSame 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.

GitHub Acceleration Proxy

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:

  1. Disabled by default, and the address must be entered explicitly. When off, all requests use the official addresses and behaviour is identical to not having the feature at all.
  2. Only the address changes, not the semantics. Path prefixes map one-to-one onto upstream domains, and tokens, Accept headers and request methods are passed through unchanged, so the response body is the same whether the proxy is on or off.
  3. Redirects are followed manually. GitHub redirects download-style requests to another domain (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:

PrefixUpstream domain
/apiapi.github.com
/rawraw.githubusercontent.com
/codeloadcodeload.github.com
/ghgithub.com
/objectsobjects.githubusercontent.com
/release-assetsrelease-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.

Configuration

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.

FieldDefaultDescription
appName"DeepSeek Harness"Display name (the desktop shell manages the window title itself)
githubProxy.enabledfalseWhether 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.enabledtrueWhether to machine-translate English READMEs
translate.endpointhttps://api-free.deepl.com/v2/translateDeepL 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.characterCount0Locally accumulated translated character count, used only to show rough usage
translate.maxCharacters4000Per-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.

Activation Criteria

Two conditions must hold for the plugin to be loaded:

  1. The package appears in the profile's dependencies (it resolves inside node_modules);
  2. The package name appears in the profile 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.

Testing

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:

  • the dsh.bundle.patch / dsh.client / exports['./client'] declarations in package.json and whether the files they point to really exist;
  • that the host half can be imported and exports name / inject / apply;
  • that apply() does not throw, registers its route under the expected prefix with prefix matching, and hands cleanup to ctx.effect;
  • route alignment: every route the client calls is extracted from 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;
  • that the shared configuration defaults include a 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.

Known Limitations

  • A restart is required after installing. Plugins are assembled at startup and a running instance does not hot-reload; POST /restart-host refuses honestly rather than restarting.
  • GitHub API rate limits apply. Unauthenticated, the search endpoint allows roughly 10 requests/minute and the rest 60/hour. The list is cached for 1 hour on disk, details are cached in-process for up to 60 repositories, and expanding details costs at most two API requests.
  • Translations cover only the first 6,000 characters (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.
  • The full-text preview caps at 400,000 characters (README_MARKDOWN_LIMIT); anything beyond is truncated and labelled in the UI.
  • The built-in Markdown renderer is a subset implementation. Footnotes, definition lists, maths and task-list checkboxes are not supported and are shown as plain text. HTML tags outside the whitelist are stripped while their text is preserved.
  • Relative image paths in READMEs depend on GitHub raw URLs. Repositories using Git LFS or private assets may still show broken images.
  • The proxy covers only the six GitHub domains listed above. Avatars and README images are not proxied.
  • The host half reads 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.
  • Utilities such as host/yaml-libs.mjs currently have no call sites in this package and are kept as host-layer tools.

License

MIT