A starter template for dsh plugins covering config, tools, events, services, hooks, browser UI slots, and slash commands.
Install
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:kun2-5code/dsh-plugin-template
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time — pnpm blocks those until you allow them, so an install can stop with ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED or ERR_PNPM_IGNORED_BUILDS; dsh prints the exact key to add under allowBuilds in your profile’s pnpm-workspace.yaml, and the install works on the next run. Allowing a build is a trust decision: only install sources you trust, and pin a commit (github:owner/repo#sha).
README
English | 中文
A ready-to-run, ready-to-install starter template for DeepSeek Harness (dsh) plugins. It demonstrates the most common plugin shapes in one minimal installable bundle:
- Config — a
Configinterface plus a Schemastery schema whose live fields carry.volatile(), so the Plugins page can edit them without a restart (docs) - Tool —
ctx.tools.register(defineTool(...))registers a model-callable tool with acard-tagged render intent (docs) - Events —
ctx.on/ctx.emitwith declaration merging for typed events (docs) - Service — a class-form plugin that provides a service to other plugins (docs)
- Hook — a
tools/pre-executepermission gate that denies tool calls by config (docs) - Browser half (client) —
src/client/registers browser UI on fourteen surfaces (index: docs/ui-surfaces.md): a configuration form on the Plugins page, a sidebar footer action, an input dock strip above the composer, a shell overlay, a header utility badge, input tool-row buttons (left/right), a custom command row for/dsh-demo, a General settings row, a Plugins settings tab, a settings header action, a session header action, a composer dock strip, and per-message actions on AI replies — plus apresentResultrender intent on thegreettool.
The template follows the official bundle distribution model: the package declares dsh.bundle plus cordis.patch.yml, and dsh plugin add activates it as a config layer.
Directory structure
dsh-plugin-template/
├── package.json # npm manifest + dsh.bundle / dsh.client declarations + prepare build script
├── tsconfig.json # strict type-check configuration (tsc --noEmit)
├── tsdown.config.ts # build config: Node library (lib/) + client bundle (lib/client.js)
├── vitest.config.ts # unit test config (node by default; specs opt into jsdom)
├── cordis.patch.yml # bundle config layer: inserts the plugin rows
├── locale/ # plugin display metadata read by the Plugins page
│ ├── en.json # meta.title / meta.description (the discovery entry)
│ └── zh.json
├── icon.svg # optional bundle card artwork
├── dev/cordis.yml # local dev overlay (points at source; use with dsh web --patch)
├── docs/
│ ├── ui-surfaces.md # where the plugin registers UI + index of every slot (bilingual: ui-surfaces.zh.md)
├── src/
│ ├── index.ts # main plugin: Config + tool + events + effect
│ ├── commands.ts # host half: demo slash commands /hello (replies world) and /dsh-demo (custom row)
│ ├── service.ts # optional example: Service provider (disabled by default)
│ ├── hook.ts # optional example: hook permission gate (disabled by default)
│ └── client/ # browser half: one module per UI surface (see docs/ui-surfaces.md)
│ ├── index.ts # client entry: inject + apply, registers the locale dictionary and styles
│ ├── constants.ts # shared NAMESPACE + LOCALE_NAMESPACE + DEMO_COMMAND_NAME
│ ├── locales.ts # typed en/zh dictionaries (all user-visible copy lives here)
│ ├── styles.ts # one injected <style> with all dtpl-* classes (theme tokens only)
│ ├── config-card.tsx # plugins.bundle.config: the configuration form on the Plugins page
│ ├── sidebar-action.tsx # sidebar.footer.action
│ ├── input-dock.tsx # conversation.input.dock
│ ├── shell-overlay.tsx # shell.overlay
│ ├── header-utilities.tsx # conversation.session.header.utilities
│ ├── input-left.tsx # conversation.input.left
│ ├── input-right.tsx # conversation.input.right
│ ├── commandview.tsx # conversation.chat.commandview
│ ├── general-item.tsx # settings.general.item
│ ├── plugins-tab.tsx # settings.plugins.tab
│ ├── settings-action.tsx # settings.action
│ ├── header-actions.tsx # conversation.session.header.actions
│ ├── composer-dock.tsx # conversation.composer.dock
│ └── assistant-actions.tsx # conversation.chat.assistant-actions
└── test/smoke.mjs # smoke test on the build output
└── tests/ # unit tests
├── host-half.spec.ts # the host half on a real cordis Context
├── slot-registration.client.spec.ts # every surface registers, and leaves with the fiber
├── config-card.client.spec.tsx # the configuration form's user-visible behavior
├── surfaces.client.spec.tsx # command row, sidebar, input, per-message button
├── locale-and-styles.client.spec.ts # dictionary and stylesheet rules
└── support/ # test doubles: slot registry, locale
Quick start
Install as a bundle (for users)
From any directory, install this package (or your fork) into a dsh profile:
# local directory
dsh plugin --profile demo add /path/to/dsh-plugin-template
# or directly from GitHub (replace with your own repo after forking)
dsh plugin --profile demo add github:you/dsh-plugin-template
A GitHub install pulls source; pnpm runs prepare (i.e. tsdown) to build lib/. On pnpm ≥10 the first git-dependency prepare is refused; add the package name pnpm prints to the profile's pnpm-workspace.yaml and retry:
allowBuilds:
dsh-plugin-template: true
This allowlist authorizes executing that package's code at install time — only allow source you trust, and prefer pinning a commit:
github:you/dsh-plugin-template#<sha>.
Verify the config layer and boot:
dsh --profile demo --dump-config # should show a "# == dsh-plugin-template" layer
dsh --profile demo
Note: a custom-named profile (e.g.
demo) contains onlydsh-baseand is headless (no GUI). For the Web GUI and the configuration form below, use thewebprofile (= dsh-base+dsh-web-app) — see testing the form.
Local development (modifying the plugin)
From the root of a deepseek-harness source checkout, load this repo's source directly via an overlay (no install, no build):
pnpm dsh web --patch /absolute/path/to/dsh-plugin-template/dev/cordis.yml
Set name in dev/cordis.yml to this repo's path on your machine as a file:// URL, open http://127.0.0.1:3080, and ask the model to call the greet tool. A bare absolute path fails on Windows: the Loader hands an entry's name straight to import(), and D:\… parses as protocol d:, so the row dies with ERR_UNSUPPORTED_ESM_URL_SCHEME and the plugin never loads. Produce the URL with node -e "console.log(require('node:url').pathToFileURL('<path>').href)".
A
--patchoverlay only loads the plugin's host half (module resolution cannot reach package-level declarations). To test the browser half you must install into a profile (resolved byname: dsh-plugin-template) — see the next section.
Run the checks yourself during development:
pnpm install
pnpm typecheck
pnpm test:unit
pnpm build
pnpm smoke
pnpm test
typecheck runs tsc over the source, the tests, and the build config. test:unit runs the vitest specs; smoke runs against lib/; test runs all of it in that order.
If this repo sits INSIDE a
deepseek-harnesscheckout (nested, as in the harness repo root),pnpm installis captured by the parent workspace and installs nothing here — the template is not a workspace member. Usepnpm install --ignore-workspaceso the template installs its ownnode_modulesfrom its own lockfile; or clone the template standalone.
Testing the configuration form (in the GUI)
The form renders in the browser and depends on dsh's client-modules discovering the dsh.client declaration by package name, so the package must be installed into a profile (a --patch source path won't do):
# 1. Build (produces lib/index.js + lib/client.js)
cd /path/to/dsh-plugin-template && pnpm build
# 2. Install into the web profile (= dsh-base + dsh-web-app, full GUI)
dsh plugin --profile web add /path/to/dsh-plugin-template
# 3. Boot the web GUI (`dsh web` is equivalent to `dsh --profile web`)
dsh web
Open http://127.0.0.1:3080, go to the Plugins page in the sidebar, and select Plugin Template:
- The bundle's page renders a configuration form with
greeting,maxRetries, andverbose; - Change
greetingand click Save — the deployment accepts the values and the status line reports success; - Back in a session, ask the model to call the
greettool — it uses the new greeting (the host half readsconfig.greeting.get()on every call, no restart); - The change lands in the settings document under
$DSH_HOMEand survives restarts. Reset to default clears the field so it re-inherits the value fromcordis.patch.yml.
There is no allowlist to edit and no restart step: a plugin entry whose Config has at least one .volatile() field is served automatically, and the Plugins page passes this page its form (accepted values plus a revision-fenced mutate).
After editing the client half (src/client/), rerun pnpm build and refresh the page (the client bundle's rev query cache-busts).
Making it your own plugin
- Rename the package: keep
package.jsonname(npm name, e.g.dsh-my-plugin),src/index.tsname, andcordis.patch.ymlid/nameconsistent. Renaming also touches browser-half spots: the client bundleidintsdown.config.ts(__ModuleLoader__.load({ id })),NAMESPACEinsrc/client/constants.ts,dsh.client.injectinpackage.json, andNAMESPACEinsrc/client/constants.ts(the Plugins page keys off it), pluslocale/en.json. When renaming the./servicesubpath, updateexports/filestoo. - Change the
Configinterface and schema: anything two deployments should set differently must be a config field (design principles). Mark the fields a user should be able to change without a restart with.volatile(), and read them with.get()at the point of use. - Add a row to the form in
src/client/config-card.tsxfor each new editable field: a label key, a hint key, and a branch inFIELDS/buildOps/draftValue. The form is hand-written — it does not render itself from your schema. - Register your tool in
apply:ctx.tools.register(defineTool({...}));executereturns the canonical value declared byoutput.schema, andoutput.renderis the pure function for model-visible rendering whilepresentResultis the UI render intent (tool reference). - To provide capabilities to other plugins, enable
src/service.tsand uncomment its row incordis.patch.yml. - Remember to
declare module '@deepseek-ai/cordis'to mergeContext/Eventstypes — that is what keeps cross-package boundaries type-safe. Document each event's@modeand every payload@param. - To intercept tool calls or act as a permission gate, enable
src/hook.ts(uncomment itscordis.patch.ymlrow):ctx.on('tools/pre-execute', ...)returns{ kind: 'deny', reason }or callsnext()to allow (extension cookbook). - Add a locale key to
src/client/locales.tsfor every new user-visible string, in bothenandzh. Read it through thetseat the registration'slocaleoption provides; list-slot labels use a thunk (label: () => t('key')) so a locale switch needs no re-registration.
How the browser half works
package.jsondeclaresdsh.client: { platform: "web" }+exports["./client"]— dsh's client-modules discovers it and loadslib/client.jsas a browser plugin;lib/client.jsis a lazy-CJS factory in thewindow.__ModuleLoader__.load({ id, factory })format.tsdown.config.tsreproduces it by hand; the repo's own preset lives inpackages/client/tsdown.client.tsand is not published;- the client entry (
src/client/index.ts) registers the locale dictionary and the stylesheet throughctx.effect— both dispose with the plugin — then calls oneregister*per surface; - each surface registers through
ctx.slots.inject(name, () => ctx.slots.register(...)), which waits for the owning declaration, removes the contribution when that declaration collapses, and leaves with the plugin fiber; - at runtime the browser half depends only on
react, supplied by the browser platform module table. No@deepseek-aiclient package is imported at runtime — those appear only asimport type, which is erased. Keep that discipline when editing the template.
Tests
pnpm test:unit runs five specs. They use the real cordis Context, so fibers, effects, and disposal behave as they do in a profile:
tests/host-half.spec.ts— the host half on a real assembly: the greet tool and both commands register, the greeting is read on every call rather than once at load, and the tool leaves when the fiber is disposed.tests/slot-registration.client.spec.ts— all fourteen surfaces land on declared slots, the configuration form is keyed by the package name, the command row by the command name, the Plugins tab label is a locale-following thunk, and every contribution plus the stylesheet and the dictionaries are gone after disposal.tests/config-card.client.spec.tsx— the form's user-visible behavior: the summary and page views, loading/unavailable/read-only states, which writes it submits and with which revision, clearing a field back to the deployment default, validation that blocks saving and stays reachable to assistive technology, and both save-failure paths leaving the form usable.tests/surfaces.client.spec.tsx— the command row's three states (running, succeeded, failed), the sidebar button keeping an accessible name in rail mode, the input control being an explicit non-submit button, and the per-message button addressing its message without printing its id.tests/locale-and-styles.client.spec.ts— the two client rules that rot quietly: everyt('…')key exists in the dictionaries, and the stylesheet carries no literal colors and no font weight above 500.
test/smoke.mjs is separate and runs against lib/: it checks that the built artifact loads, the tool and commands work, and the permission gate denies and delegates. pnpm test runs typecheck, units, build, and smoke in that order.
The test doubles in tests/support/ stand in for the harness client services. They cannot be the real ones: the published client entries are browser bundles that call window.__ModuleLoader__.load(...) at import time, so materializing one inside a Node test would pull a second React into the process. The doubles are cordis services, so they keep the property that matters here — contributions are mounted on the caller's fiber and are torn down with it — and they enforce that an undeclared slot throws. Their comments say exactly what they do and do not model.
Publishing
- npm:
pnpm publish(filesalready includes the build output, the patch, the metadata, and the icon) - tarball:
pnpm pack, thendsh plugin --profile demo add ./dsh-plugin-template-0.2.0.tgz - git:
dsh plugin add github:you/dsh-plugin-template(combined with theallowBuildsstep above)
Related docs
- Live configuration forms: adding-a-settings-card.md
- Plugin development intro: basic/index.md
- Plugin config: basic/config.md
- Tool development: basic/tool.md
- Packaging & installation: basic/publish.md
- Plugins & lifecycle: framework/index.md
- Services & dependencies: framework/service.md
- Event system: framework/events.md
- Client UI slots: subsystems/slots.md
- Cordis tutorial: cordis-tutorial
Links
More in this category
yjh051108/dsh-routing-suite★ 6995
One repository, three parts: a runtime injector for DSH plugin packages (inject, hot-reload, unload, promote a dev staging tool to the front, route self-heal, plus a settings-page plugin manager that lists, unloads and drags folders in to internalize), a task-aware reasoning-mode router agent preset (router-standard / router-spec / router-react), and a graded two-level task protocol whose six tools (commit_star, lock_stage, revise_do, edit_plan, mark_task, redteam_verdict) pin task state to disk. The injector implementation ships in-tree, so the install carries its own behaviour rather than a dependency list.
strukto-ai/mirage#dsh★ 3667
Swaps the filesystem and bash providers for a mirage virtual workspace: file tools and shell commands run over mounted resources (RAM, S3, Redis, Slack, Gmail, Notion, Postgres) instead of the host disk, with per-mount read/write/exec modes, per-command sandbox routing (monty, pyodide, quickjs in process; docker, e2b, daytona remote), and installed CLIs (git, gh, slack, linear, ntn, gws, or one you register) as head words in the virtual terminal.
hust-open-atom-club/oh-dsh★ 325
Community distribution: TUI, desktop, and Web UI as one bundle with layered installation.
weijiafu14/pi2dsh★ 208
Pi Host ABI compatibility engine: after one install, unmodified Pi extensions from npm mount as native DSH plugins with `dsh plugin add <pi-package>`. Verified end to end on stock DSH with pi-mcp-adapter (full MCP manager: OAuth, resources, prompts, MCP Apps, elicitation, sampling), @tintinweb/pi-subagents, pi-code, pi-hermes-memory and pi-background-tasks; `pi2dsh inspect` reports a package's compatibility before installing.
lire1131/dsh-undo-savepoint★ 167
Undo/redo & rollback system for DSH: every config change is auto-snapshotted; undo/redo/restore to any version from the WebUI or the offline CLI/GUI tools (works even when DSH fails to boot).
Fishquito7/dsh-skill-mcp-panel★ 158
Manages DSH skills and MCP servers from the web settings: skill cards with hot enable/disable, workspace scopes, groups, batch migration and drag-and-drop import, plus stdio/HTTP MCP CRUD with connection tests, secret redaction and the unified dsh-panel CLI.
Community comments
Comments are public GitHub Discussions. Loading them connects to GitHub and Giscus; a GitHub account is required to post.