Skip to main content

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.

Reviewed

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 what a plugin declares see the manifest and contribution points; for what it can do at runtime see the Host API.

The toolchain

PackageWhat it is
daintree-pluginThe CLI. Scaffolds, validates, packages, installs, uninstalls, and runs the hot-reload dev loop.
@daintreehq/plugin-sdkThe 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-viteThe Vite preset. Externalizes React and React DOM so a view bundle resolves to the host's single React instance, and strips Node built-ins from browser output.
@daintreehq/plugin-testingThe mock host: a faithful PluginHostApi with in-memory state that records every call, for unit-testing activate() without launching Electron.
create-daintree-pluginThe npm create entry point onto the same scaffolder the CLI's new command uses.

The SDK's 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

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/
TemplateWhat it scaffolds
commandA single command. src/index.ts exports activate(host) and registers imperatively, which is the path that has access to the live host.
viewA panel plus its React component: src/index.ts and src/panel.tsx.
mcpA skeleton MCP server and the manifest wiring that supervises it.
fullCommand, 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:

  1. Validates the manifest: the same check validate runs. A manifest error aborts before anything is linked.
  2. Builds once so the entry exists before Daintree loads it. --skip-build skips this initial pass; the watcher still rebuilds on every save.
  3. 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.
  4. Asks the running app to load and activate the plugin.
  5. 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.

Note
Hot reload 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. The Host API page explains why, under module generations.

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 react import from an externalized plugin bundle failed to load in every packaged build.

The preset also bundles the SDK's React hooks into 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.

There is no full-lifecycle end-to-end harness yet: nothing spins up a headless Daintree to assert that contributions registered and an MCP server spawned. Cover handler logic against the mock host and verify the rest by hand.

Validating

$ npx daintree-plugin validate
✓ plugin.json is valid
⚠  engines.daintree omitted: consider pinning a range, e.g. ^0.32.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, so a manifest that passes here loads in the app. 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.

Packaging

npx daintree-plugin package [--verbose] [--dry-run] [--sourcemaps] [--skip-build]

Packaging validates the manifest, builds with Vite unless --skip-build, then copies the build output, referenced assets, and manifest into a zip named for the plugin id and version. --verbose lists everything included; --dry-run previews without writing.

Excluded from every archive: node_modules/, .git/, gitignored entries, 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.

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.

ParameterValue
ContainerPKZIP 2.0, DEFLATE at level 9.
Size cap30 MB. Daintree rejects anything larger at install.
Entry cap4096 entries, a zip-bomb-by-count guard.
EncryptionNot supported. Encrypted entries are rejected.
TimestampsFixed at the MS-DOS epoch, so no filesystem timestamps leak in.
OrderingLexicographic by byte-level path comparison, except that plugin.json is always first.
PathsForward 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:

  1. 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.
  2. Compute a SHA-256 hash of the archive bytes.
  3. Validate the manifest against the schema, and check engines.daintree against the running app version.
  4. Extract into a temporary directory on the same filesystem as the destination, so the swap can be atomic.
  5. Swap into ~/.daintree/plugins/publisher.name/.
  6. 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 same-name install replaces unconditionally. There is no semver comparison between installed and incoming, no downgrade gate, and no identical-version block: the install always wins. 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 installs add a fetch stage with its own limits: a 30 MB / 30 s cap, a content-type allowlist with a .dntr-suffix fallback, manual redirect following capped at five hops with per-hop HTTPS, SSRF, and DNS re-validation, and a confirm prompt on plaintext HTTP that shows the original URL.

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 and loads every entry with a valid plugin.json.

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

The directory name must match the manifest name. 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:

  1. 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.
  2. Check engines.daintree against the running version. An incompatible plugin is skipped with a visible toast; an omitted range loads with a console warning.
  3. 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.
  4. Resolve entry paths, confirming main does not escape the plugin directory.
  5. 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.
  6. Defer activate() until a contribution is first used, unless the manifest opted into eager activation. Implementations bind during activation.
  7. Validate action ids once the renderer's action registry populates. An unrecognized actionId on a toolbar button or menu item logs a warning; the plugin still loads and the button still renders.

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

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. The host bridges every host.* call and registration over a MessagePort, which is why the API is fully asynchronous.

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. There is no ESM module-cache leak and no state surviving a reload, which is exactly why hot reload works. Keep teardown-able work inside activate() and its returned cleanup anyway (that is the contract), but you are not paying a per-reload memory penalty for getting it wrong.

Built-in plugins are the exception: they stay on the in-process loader because they are app-bundled and never unloaded. That is also why registerForgeProvider works only for built-ins: a forge provider's synchronous methods cannot cross the worker's async port.

Publishing

There is no marketplace and no central registry. Authors host their own archives.

  • GitHub Releases is the default recommendation. Archives are small, releases are free, and versioning maps onto tags. 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.daintree honestly. Pin to the minor you have tested against. 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.
# .github/workflows/release.yml
name: Release plugin
on:
  push:
    tags: ["v*"]
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: "22" }
      - run: npm ci
      - run: npx daintree-plugin validate
      - run: npx daintree-plugin package
      - uses: softprops/action-gh-release@v2
        with:
          files: "*.dntr"

Daintree does not auto-update installed plugins. A user can right-click an installed plugin and Check for update, which re-fetches the original URL and compares hashes, or drain every available update at once from the manager. Auto-update is planned and will be gated behind per-plugin consent.

Private and team distribution

Fully supported, with no cloud dependency on Daintree:

  • Host archives behind your own auth (a VPN-only URL, a signed object-store link, an internal artifact registry) and have people install from URL. Cookies are sent for same-origin requests.
  • 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.