Once your plugin works inside the source repository, the next step is packaging it into a reusable, distributable form. This tutorial explains the two core concepts of DeepSeek Harness packaging — bundles and profiles — and walks you through declaring a local plugin as a dsh.bundle, installing it into a profile with dsh plugin add, understanding how config layers compose, and the differences between local path, GitHub, npm, and tarball installs.
Two concepts: bundle and profile
Packaging involves two concepts. Both are package.json files — they simply carry different manifests under the dsh key:
- BUNDLE: an npm package that ships a configuration layer. Its manifest declares
dsh.bundleand answers “what does this package contribute?” — usually a patch file that inserts or overrides plugin rows; - PROFILE: a directory under
$DSH_HOME/profiles/(named after the profile) describing one runnable composition. Its manifest declaresdsh.profileand answers “which bundles compose this setup, and in what order?”.
Nothing is both: a package either contributes layers or composes them.
Bundle layout and manifest
Using hello-plugin as the example, a bundle package looks like this:
hello-plugin/
├── package.json
├── cordis.patch.yml
└── index.js
Alongside the usual npm fields, package.json declares this as a bundle with dsh.bundle:
{
"name": "dsh-hello-plugin",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}
Three things matter: files must include cordis.patch.yml, or the layer is missing after install; dsh.bundle.patch points at the layer file; main points at the plugin entry.
The plugin module itself is no different from a regular plugin — index.js exports name and apply:
export const name = 'hello'
export function apply(ctx) {
console.log('[hello] loaded from dsh-hello-plugin')
}
Writing cordis.patch.yml
cordis.patch.yml is the heart of a bundle: it declares which plugin rows the package inserts into (or overrides in) the configuration.
- insert:
- id: hello
name: dsh-hello-plugin
Note that name holds the package name dsh-hello-plugin, not a relative path. Once the bundle is installed into a profile, its files live inside the profile’s node_modules, and only the package name lets Node resolution find the installed code; a relative path would break after installation.
Conversely, a package lacking dsh.bundle still installs, but only as a plain dependency — dsh plugin warns and activates no layer. Libraries a plugin package imports follow this path: imported by plugins, but contributing no config themselves.
Install into a profile and verify
Run this from the directory that contains hello-plugin:
dsh plugin --profile demo add ./hello-plugin
The first time you use the demo profile, three things happen: the profile is initialized with @deepseek-ai/dsh-base as its first bundle; pnpm links your local checkout; and dsh appends the bundle to the profile’s dsh.profile.bundles list. Afterwards, the profile’s package.json looks roughly like this:
{
"dsh": {
"profile": {
"bundles": [
"@deepseek-ai/dsh-base",
"dsh-hello-plugin"
]
}
}
}
Inspect the merged config without booting
Use --dump-config to see the merged configuration and confirm the bundle’s layer is in effect:
dsh --profile demo --dump-config
A layer labeled with the package name in the output means the profile references the bundle:
# == dsh-hello-plugin
- insert:
- id: hello
name: dsh-hello-plugin
Boot and remove
Once it looks right, boot the profile — the plugin load line in the startup log means success:
dsh --profile demo
Removal is a single command too:
dsh plugin --profile demo remove dsh-hello-plugin
Layer composition and loading order
When a profile boots, configuration is layered in a fixed order, and later layers win per row:
- each bundle patch in the
dsh.profile.bundleslist, in list order (@deepseek-ai/dsh-basefirst, then each installed bundle in add order); - the profile’s own
cordis.patch.yml; - the machine-level
$DSH_HOME/cordis.patch.yml, shared across profiles; - each
--patchoverlay passed on the command line, in argv order.
The key semantic: a patch replaces a row whole rather than deep-merging keys. Your patch can override an earlier row by id, but it must restate every key the row needs — omitted keys are not kept from the old value.
Four install sources
dsh plugin add supports four sources — pick by distribution scenario:
Local path
dsh plugin --profile demo add ./hello-plugin
pnpm links your checkout — edit and restart to iterate, ideal for development.
Git host
dsh plugin --profile demo add github:you/hello-plugin
Fetches sources, not built artifacts — it needs a prepare script and build permission, covered in the next step.
npm registry
dsh plugin add your-package
Installs a prebuilt package with no build permission needed — ideal for consuming stable releases.
Tarball
dsh plugin add ./hello-plugin-0.1.0.tgz
The tarball comes from pnpm pack — also a prebuilt artifact, so no build permission is needed.
Installing from GitHub and build permission
Git fetches sources, not built artifacts, so two conditions apply. First, the package needs a prepare script that builds from source. Second, the profile’s pnpm-workspace.yaml needs an allowBuilds entry — pnpm 10 and newer refuse to run a git dependency’s prepare script until allowed:
allowBuilds:
dsh-hello-plugin: true
Pin a commit at the same time so you don’t fetch different code on every install:
dsh plugin --profile demo add github:you/hello-plugin#<sha>
Running from a source checkout
If you run dsh from a source checkout instead of an installed dsh (the common case for contributors), keep the plugin at the repository root and prefix every command with pnpm:
# from the root of the deepseek-harness source repository
pnpm dsh plugin --profile dev add ./hello-plugin
pnpm dsh --profile dev --dump-config
pnpm dsh --profile dev
Everything else stays identical: the bundle layout, the patch format, and the layer composition rules do not change.
Packaging and install FAQ
What happens if a package has no dsh.bundle?
It still installs, but only as a plain dependency — dsh plugin warns and activates no layer. This is exactly how libraries a plugin package imports should be shipped: imported by plugins, but contributing no config rows themselves.
Why must the patch name be the package name?
Once a bundle is installed, its files live inside the profile’s node_modules directory, and only the package name lets Node resolution find the installed code. A relative path would break as soon as the package is installed.
What if two bundles touch the same row id?
The later layer replaces the earlier row whole — there is no deep merge. Your patch can override an earlier row by id, but it must restate every key the row needs; omitted keys are not kept from the old value.
Why does installing from GitHub require allowBuilds?
Git fetches sources, not built artifacts, so the package needs a prepare script to build on the spot. pnpm 10 and newer refuse to run a git dependency’s prepare script until allowed — allowBuilds is that grant, and it amounts to permission to execute the package’s code on your machine at install time.
What is the difference between a local path install and an npm install?
A local path install uses pnpm to link your checkout — edit the plugin code and restart the profile to see changes, which suits development. npm installs the prebuilt artifact as published, with no build permission needed, which suits consuming stable releases.