Building & Distributing
The Daintree plugin authoring toolchain: the daintree-plugin CLI, project templates, the plugin SDK and Vite preset, the hot-reload dev loop, .dntr packaging and its archive spec, atomic install, and how to publish or privately distribute a plugin.
This page covers the author's side of the plugin system: the tools that build a plugin, the loop you develop in, the archive format you ship, and the paths a user installs it through. For repository distribution, use Building Project Plugins; that workflow commits loadable output and does not install an archive. For what a plugin declares see the manifest and contribution points; for what it can do at runtime see the Host API.
npm run packages:build, then invoke node /path/to/daintree/packages/daintree-plugin/dist/cli.js from your plugin directory. The npx daintree-plugin examples below describe that CLI; they require a locally available package until publication. See source toolchain setup.The toolchain
| Package | What it is |
|---|---|
daintree-plugin | The CLI. Scaffolds, validates, packages, installs, uninstalls, and runs the hot-reload dev loop. |
@daintreehq/plugin-sdk | The public type surface: manifest types, the host API, the activation contract, the forge and file-decoration provider shapes. Its /react subpath carries the renderer hooks. React is an optional peer dependency, so the SDK itself pulls nothing into your bundle. |
@daintreehq/plugin-vite | The Vite preset. Provides browser and Node build targets. The browser target externalizes supported React imports; the Node target externalizes Node built-ins and uses Node dependency resolution. |
@daintreehq/plugin-testing | The mock host: a faithful PluginHostApi with in-memory state that records every call, for testing plugin logic without launching Electron; it cannot prove host integration or process behavior. |
create-daintree-plugin | The npm create entry point onto the same scaffolder the CLI's new command uses. |
The SDK also provides a React-free /files subpath for file-tree, file-type, and Git-status helpers. Its public export boundary is deliberate: it re-exports the shared plugin types plus the few runtime constants an author genuinely needs as values, and nothing else. Daintree's internals are not reachable through it, which is what lets the app change shape underneath a plugin without breaking it.
Starting a project
npx daintree-plugin new my-plugin
npx daintree-plugin new my-plugin --publisher acme --template view --yes For a repository-owned plugin, add --project and choose the command or view template. new is interactive by default: it prompts for publisher, display name, and template. --yes makes it non-interactive, which needs a name and --publisher.
my-plugin/
├── plugin.json # starter manifest
├── package.json # dev deps: plugin-sdk, plugin-vite, Vite, TypeScript
├── vite.config.ts # pre-configured for plugin builds
├── tsconfig.json
├── src/ # starter code for the chosen template
└── .gitignore # excludes dist/, *.dntr, node_modules/ | Template | What it scaffolds |
|---|---|
command | A single command. src/index.ts exports activate(host) and registers imperatively, which is the path that has access to the live host. |
view | A panel plus its React component: src/index.ts and src/panel.tsx. |
mcp | A skeleton MCP server and the manifest wiring that supervises it. |
full | Command, view, and MCP server together. The largest, for exploring the surface. |
The development loop
daintree-plugin dev links the working directory into a running Daintree and rebuilds on every save.
npx daintree-plugin dev [--skip-build] What it does, in order:
- Validates the manifest: the same check
validateruns. A manifest error aborts before anything is linked. - Builds once so the entry exists before Daintree loads it.
--skip-buildskips this initial pass; the watcher still rebuilds on every save. - Symlinks the plugin directory into
~/.daintree/plugins/and writes a dev marker at the link root. The marker's presence is what routes the plugin through the hot-reload worker instead of the normal load path. A real directory already at that path is treated as an installed plugin and left alone. - Asks the running app to load and activate the plugin.
- Starts a watching build. Daintree watches the plugin's build output; on every rebuild it tears the worker down and re-imports the entry, so a save reloads the live plugin.
Dev-linked plugins carry a DEV badge in the plugin manager, so it is obvious which installed entries are pinned to a local folder. Ctrl-C tears everything down: the watcher is killed, the app is asked to unload the plugin, and the marker and symlink are removed. A second Ctrl-C exits immediately.
dev loop replaces the plugin's backend realm. activate() runs again against a fresh module graph, so main-side edits take effect on the next save, but the reload does not re-register contributions, so open views keep the module the renderer already has in memory. To pick up a view change mid-session, disable and re-enable the plugin, or force-reload the window. Project-folder hot reload uses a full reload and refreshes view generations automatically. The Host API page explains the distinction.The manual loop (package then install) is still the right choice when you want to exercise the exact production load path. Each install replaces the previous copy: Daintree unloads the old plugin, running the full disposal cascade, before loading the new one, so stale registrations never accumulate between iterations. Reinstalling does not preserve in-memory state; put anything that must survive an iteration in host.settings or host.storage.
Building views
Plugin views ship as pre-built ESM modules. Nothing compiles TypeScript or JSX at plugin-load time, which is why the build preset matters.
// vite.config.ts
import { defineConfig } from "vite";
import { daintreePlugin } from "@daintreehq/plugin-vite";
export default defineConfig({
plugins: [daintreePlugin()],
}); The preset externalizes every react and react-dom specifier: the regex form is deliberately broad, because a second React copy in the page produces an invalid-hook-call error at the first render. The stripped imports resolve at load time through the host's import map, which is backed by Daintree's single React chunk, so the host and every loaded plugin share one instance. Any React subpath the import map does not serve fails at build time rather than surfacing as an unresolved specifier at runtime.
Two things worth knowing if you are reading older material:
- The SDK no longer bundles React 19. React is an optional peer dependency, so pulling in the SDK does not drag a React copy into a main-process bundle that has no use for one.
- Third-party plugins can import React in packaged builds. The import map deliberately does not point at Daintree's code-split vendor chunk (a code-split chunk only exports its private cross-chunk interface), so it serves a facade module per specifier instead. Before that, a bare
reactimport from an externalized plugin bundle failed to load in every packaged build.
The normal bundle includes the SDK's React hooks in the plugin's output. The import map serves React specifiers and nothing else, so a raw, un-bundled view cannot bare-import the hooks and must use the window.electron.plugin bridge directly.
Testing
import { describe, it, expect } from "vitest";
import { createMockHost } from "@daintreehq/plugin-testing";
import { activate } from "./index";
describe("activate", () => {
it("registers the sync command", async () => {
const host = createMockHost({ capabilities: ["git:read"] });
await activate(host);
expect(host.registeredActions).toHaveLength(1);
await expect(host.sendToActiveAgent("hi")).rejects.toThrow(/PERMISSION_REQUIRED/);
});
}); The mock host mirrors production validation and capability gating: toast bounds, badge shape, channel format, quick-pick item arrays, and the PERMISSION_REQUIRED rejection a missing capability produces. Recording arrays capture every host call in order. Because the standalone package is unpublished, the mock currently lives in the Daintree repository and is imported by relative path from a plugin developed inside it.
The app repository includes focused integration tests for installation, project binding, hot reload, and worker behavior. They are useful implementation references, not a turnkey external-plugin certification harness. Test your handler logic with the mock host, then verify the built artifact in Daintree.
Validating
$ npx daintree-plugin validate
✓ plugin.json is valid
⚠ engines.daintree omitted — consider pinning a range, e.g. >=0.11.0
⚠ commands[0].keywords is empty: 2–3 terms help discoverability in the palette validate runs plugin.json through the same schema Daintree uses at load, to catch structural errors before installation. A valid manifest can still have missing artifacts, incompatible host versions, a blocked id, or code that fails during activation. Errors fail the command; warnings do not. --env additionally resolves settings tokens in MCP server commands against a local env file, which is the fastest way to catch a token that names a setting you never declared. package runs validate automatically. doctor <projectRoot> adds artifact, ESM, Git tracking, and live project-state checks; schema --project emits the generated project JSON Schema.
Packaging
npx daintree-plugin package [--verbose] [--dry-run] [--sourcemaps] [--skip-build] Packaging validates the manifest, builds with Vite unless --skip-build, collects runtime output and assets, writes the archive, then verifies it. Browser and server configs are built when present. --verbose lists files; --dry-run neither builds nor writes and therefore examines the output already on disk.
Excluded from every archive: node_modules/, .git/, source files, source maps unless --sourcemaps, and root-level dev metadata: package.json, lockfiles, tsconfig*.json, and root config files. The package.json exclusion is not cosmetic: it carries the author's full dependency layout, and in a monorepo or local-path setup it leaks an absolute home directory path into every distributed copy. The exclusion is scoped to the archive root, so a genuine runtime asset in the build output survives.
.gitignore shapes the ordinary candidate list, but dist/ and manifest-referenced output directories are preserved even when ignored by Git. Root .dntrignore is the shipping exclusion policy and can prune those directories too. Packaging refuses to omit a manifest-referenced main, view, or skill file; the manifest itself always ships.
The output is deterministic on the same OS: the same source tree and tool version produce a byte-identical archive. Cross-platform byte identity is not guaranteed (the zip "made by" header reflects the build platform), so build release archives in one canonical environment if you care about a stable hash.
The archive format
A .dntr file is a standard zip. The extension exists for OS file association: double-clicking it opens Daintree's install flow rather than the system archiver. Any zip tool can inspect one.
acme.my-plugin-0.1.0.dntr (zip archive)
├── plugin.json # always the first entry
├── dist/
│ └── index.js
├── skills/
│ └── tdd-workflow.md
└── icons/
└── logo.svg The spec is normative: every tool that produces or consumes .dntr files must conform, and a change to it is a breaking release.
| Parameter | Value |
|---|---|
| Container | PKZIP 2.0, DEFLATE at level 9. |
| Size cap | 30 MiB for the archive and aggregate extracted contents. Daintree rejects either limit being exceeded. |
| Entry cap | 4096 entries, a zip-bomb-by-count guard. |
| Encryption | Not supported. Encrypted entries are rejected. |
| Timestamps | Fixed at the MS-DOS epoch, so no filesystem timestamps leak in. |
| Ordering | Lexicographic by byte-level path comparison, except that plugin.json is always first. |
| Paths | Forward slashes only. No absolute paths, drive letters, backslashes, or .. segments. Directory entries are not emitted. |
plugin.json is first so the installer can read it by scanning the central directory rather than extracting the whole archive to find it.
Installing
Four paths reach the same pipeline, and none of them needs a restart: drag a .dntr onto the window, Install from file…, Install from URL…, or the CLI. A daintree://plugin/install deep link routes into the URL path from outside the app. The plugin hub covers the in-app side; the CLI side is:
npx daintree-plugin install ./acme.my-plugin-0.1.0.dntr
npx daintree-plugin install https://github.com/you/my-plugin/releases/latest/download/acme.my-plugin.dntr
npx daintree-plugin uninstall acme.my-plugin [--delete-settings] Atomic install
Every install runs the same sequence, with rollback on every failure branch:
- Acquire a cross-process install lock, so a second window blocks rather than races. The lock carries a short stale timeout so a crashed install cannot hold it forever.
- Compute a SHA-256 hash of the archive bytes.
- Validate the manifest against the schema, and check
engines.daintreeagainst the running app version. - Extract into a temporary directory on the same filesystem as the destination, so the swap can be atomic.
- Swap into
~/.daintree/plugins/publisher.name/. - Load the plugin.
The archive hash is persisted in the plugin's install provenance record alongside the source URL, installedAt, and updatedAt. It is what "Check for update" compares against after re-fetching the original URL, and it ties an installed plugin to a specific byte sequence in the audit trail. It establishes integrity, not authenticity: archives are unsigned, so the hash proves two fetches match, not who produced them.
A validated same-name archive replaces the installed copy without a version-order comparison. There is no downgrade or identical-version gate. Ordinary validation, compatibility, filesystem, and load failures can still stop the operation. The swap preserves the original installedAt and records updatedAt, and it revokes every consent the previous version held, so new code never inherits the old code's approvals.
URL installation requires an HTTPS destination that passes public-host and DNS checks. The download deadline is 30 seconds, the byte cap is 30 MiB, and redirects are followed manually for at most five hops. Embedded URL credentials and private or loopback targets are rejected. Although the UI still offers an HTTP warning, the shared fetch guard rejects non-HTTPS URLs; approving that warning does not make HTTP a supported download route.
Sideloading
The simplest path, and the one that works today without the CLI: put the plugin directory at ~/.daintree/plugins/publisher.name/. Daintree scans that directory at startup; enable state, compatibility, and blocklist checks still apply after parsing the manifest.
mkdir -p ~/.daintree/plugins
cd ~/.daintree/plugins
git clone https://github.com/you/my-plugin.git acme.my-plugin
cd acme.my-plugin
npm install
npm run build Name the directory after the manifest name so the installer and authoring tools address the expected path. Sideloading is right for plugins you are writing for yourself, team-internal plugins shared through a private repository, and anyone who wants to audit or modify a plugin before running it.
The loading lifecycle
What Daintree does with each plugin directory, in order:
- Parse and validate the manifest. The schema is strict: an unknown top-level key, or an unknown key inside
contributes, is a hard rejection rather than a silent drop. An invalid manifest skips the plugin entirely. - Check
engines.daintreeagainst the running version. An incompatible plugin is skipped with a visible toast; an omitted range loads with a console warning. - Check the blocklist. A plugin matching a remote kill-switch entry loads no code and appears in the manager with a reason. See Trust & Capabilities.
- Resolve entry paths, confirming
maindoes not escape the plugin directory. - Register static contributions: panel kinds, views, toolbar buttons, menu items, keybindings, context menus, settings schemas, skills, agents, process tools, and the forge and file-decoration descriptors. These register eagerly, so settings forms and routing tables are populated before any plugin code runs.
- Defer
activate()until a contribution is first used, unless the manifest opted into eager activation. Implementations bind during activation. - Resolve action references. Schema validation rejects known dangling or forbidden references; runtime checks handle actions whose registrations become available later. A manifest declaration alone does not implement a command.
Plugins load in parallel, so one failure never blocks the others. Unload is a LIFO disposal cascade: the plugin's own cleanup function, then subscriptions, IPC handlers, actions, menu items, toolbar buttons, panel kinds, and finally its MCP subprocesses.
Process isolation
The main entry of every user-installed plugin (sideloaded, file-installed, URL-installed, or dev-linked) runs out of process, in a utility worker with its own module realm and OS-level crash isolation. Host operations and registrations cross a MessagePort and return promises; identity data, panelKindId(), and logging are synchronous. React views run separately in Daintree's renderer.
The worker main entry and React renderer view have separate lifetimes. For an author, the practical consequence is teardown. Unloading runs the disposal cascade and then kills the worker, reclaiming the entire module realm: module-scope bindings, import-time singletons, stray timers and connections all go with it. Worker module state does not survive the worker being replaced; file-backed settings and storage can survive. Keep teardown-able work inside activate() and its returned cleanup anyway (that is the contract), and account separately for renderer modules: Chromium retains each imported view generation until that renderer is destroyed.
Built-in plugins are the exception: they stay on the in-process loader because they are app-bundled and use the in-process provider contract. They can still be disabled and unloaded. That is also why registerForgeProvider works only for built-ins: a forge provider's synchronous methods cannot cross the worker's async port.
main is still un-sandboxed Node and can call node:fs or child_process directly. Trust & Capabilities states the full contract, including what it deliberately does not guarantee.Publishing
There is no marketplace and no central registry. Authors host their own archives.
- Publish a stable archive URL. A GitHub release asset is one option. Publish a
releases/latest/download/…URL and users can paste it straight into Install from URL…. - Put the literal install URL in your README. That is what a user copies.
- Set
engines.daintreehonestly. Use a range covering the host APIs and releases you have actually tested. Setting it to a wildcard buys bug reports from versions you never supported. - Semver your releases, but know that Daintree uses semver only for the compatibility gate. Update detection compares the archive hash, so a rebuild is detected by content change regardless of the version string.
- Do not commit archives to the source repository. Build them in CI on a release tag.
- Pin the SDK tightly. Pre-1.0, minor versions can break APIs.
Daintree does not auto-update installed plugins. A user can choose Check for update, which re-fetches the original URL and compares hashes, or drain every available update at once from the manager. Optional background update checks detect availability; they do not install new code silently.
Private and team distribution
Fully supported, with no cloud dependency on Daintree:
- Use a reachable HTTPS artifact URL that passes the download guard, or distribute a local archive through your existing team channel and install from file. Private and VPN-only address ranges are rejected by the URL guard; do not assume browser sign-in or cookies provide an install authentication flow. Avoid credential-bearing URLs, which are recorded as provenance when accepted.
- For internal rollout, write directly to
~/.daintree/plugins/from MDM or a setup script. That is sideloading, and it needs no CLI.
Uninstall unloads the plugin, terminates its MCP subprocesses, revokes its consents, and deletes its directory. User-scope settings are kept by default so an API token survives a reinstall; --delete-settings (or the "also remove stored settings" checkbox) removes them. Project-scope settings are never touched: they are tracked per repository and removing them is the project's concern. There is no trash bin for plugins.
Read the actual CLI commands, packaging policy, archive reader and writer, and download guard. These references are pinned to the September 5, 2026 audit.