diff --git a/.agents/skills/create-kandev-plugin/SKILL.md b/.agents/skills/create-kandev-plugin/SKILL.md index ba6d283445..55e5054baa 100644 --- a/.agents/skills/create-kandev-plugin/SKILL.md +++ b/.agents/skills/create-kandev-plugin/SKILL.md @@ -9,6 +9,12 @@ Build Kandev runtime plugins from the official template and current public contracts. Keep plugin source outside the Kandev monorepo and prove the packaged artifact against a disposable development instance before publishing it. +Start with the [canonical plugin authoring guide](../../../docs/public/plugins-authoring.md). +Use this skill for the repository workflow after choosing a recipe there: +choose recipe → edit manifest → implement → validate → package → smoke test. +The guide owns the complete hook/Host matrix; this skill keeps the repository +and verification procedure concise. + ## Establish The Boundary 1. Invoke this skill only when the user intends to create, change, fix, test, @@ -95,7 +101,7 @@ Choose the narrowest surface that satisfies the behavior: | Store small structured data | Host state | Values are JSON objects keyed by scope and key; there is no transaction or compare-and-swap API. | | Store files or use a plugin-managed database | `KANDEV_PLUGIN_DATA_DIR` | The plugin owns schema, locking, migrations, and recovery. | | Read Kandev entities | Typed Host readers plus `api_read` | Use opaque pagination cursors and stable SDK DTOs; never query Kandev's database or internal HTTP API. | -| Mutate Kandev entities | Native host UI commands where available | `api_write` is reserved and SDK write methods are not implemented. A missing mutation requires a separate Host API change. | +| Mutate Kandev entities | Typed Host writers | `api_write:tasks` gates `Tasks().Create/Update`; `api_write:messages` gates `Messages().Send`. A missing mutation requires a separate Host API change. | | Notify another plugin | `Host.EmitEvent` | Events are published as `plugin..`; keep names and payloads versionable. | | Add native interface | UI registry and `host.ui` | Use host-owned React and components so themes, contexts, portals, and mobile behavior remain compatible. | @@ -104,7 +110,7 @@ Choose the narrowest surface that satisfies the behavior: Read these sources before designing the plugin: 1. `docs/public/plugins-authoring.md` for the supported backend, Host API, - native UI, packaging, install, and iteration workflow. + native UI, recipes, packaging, install, and iteration workflow. 2. `docs/public/plugins-manifest.md` for the authoritative manifest fields, capability gates, and event vocabulary. 3. `docs/public/plugins-marketplace.md` when publishing or updating a catalog @@ -112,10 +118,19 @@ Read these sources before designing the plugin: 4. The current `kdlbs/kandev-plugin-template` repository, including its `README.md`, `Makefile`, tests, and release workflow. -Prefer the public authoring docs and current template over old examples or -implementation plans. Read `docs/plans/plugins/GRPC-CONTRACT.md` and -`docs/plans/plugins/PLUGIN-API.md` only when changing the host contract or when -the public docs do not answer a low-level compatibility question. +Prefer the public authoring docs and current template over old examples. The +frontend contract pair is `docs/plans/plugins/PLUGIN-API.md` plus +`apps/web/lib/plugins/types.ts`; concrete UI exports are in +`apps/web/lib/plugins/host-api.ts`. The backend contract is +`apps/backend/pkg/pluginsdk` plus `apps/backend/proto/kandev/plugin/v1/plugin.proto`. +Read `docs/plans/plugins/GRPC-CONTRACT.md` when changing the wire contract or +when the public docs do not answer a low-level compatibility question. + +The current frontend branch does not expose `registerTaskPanel`, +`registerTaskMenuAction`, `host.storage`, `RichTextEditor`, +`RichTextReadOnly`, or a Kanban-card injection hook. Use the supported slots, +routes, Host state, and shared store documented in the guide; do not invent +future signatures. When debugging a contract discrepancy, verify it at the implementation boundary: manifest and package rules live under @@ -123,6 +138,42 @@ boundary: manifest and package rules live under and delivery behavior live under `apps/backend/internal/plugins`; native UI loading and registration live under `apps/web/lib/plugins`. +## When Adding Or Changing A Hook + +Treat a new hook, Host method, capability, manifest field, or mounted UI slot as +a contract change. Do not implement the runtime surface and leave author docs +for later. In the same change: + +1. Update the implementation boundary and its focused tests. +2. Update the authoritative contract source: + - frontend: `docs/plans/plugins/PLUGIN-API.md` plus + `apps/web/lib/plugins/types.ts`; update `host-api.ts`, `registry.ts`, or + the mounted component when the concrete surface changes; + - backend: `apps/backend/pkg/pluginsdk`, + `apps/backend/proto/kandev/plugin/v1/plugin.proto`, and the manifest model + or validator when applicable; update `docs/plans/plugins/GRPC-CONTRACT.md` + for wire-level changes. +3. Update `docs/public/plugins-authoring.md` in the same change: add the hook to + the frontend or backend matrix, document inputs/props, capability and + lifecycle/cleanup behavior, and add a copy-pasteable recipe or maintained + fixture link. Remove it from the unavailable-API list when it becomes real. +4. Update `docs/public/plugins-manifest.md` for capability/manifest changes and + update `docs/public/plugins.md`, `docs/plugins-example.md`, or the relevant + ADR when their claims or links change. Keep the public guide as a summary; + never create a second schema or type definition in prose. +5. Recheck the root/backend/web `AGENTS.md` authority pointers and this skill if + the source-of-truth locations or author workflow changed. +6. Run the focused implementation tests plus + `node --test scripts/validate-public-docs.test.mjs`, + `node scripts/validate-public-docs.mjs`, and a stale-reference search. Report + the exact commands and results. + +If a proposed hook is not implemented on the current branch, document it as +unavailable with the nearest supported recipe; do not publish a speculative +signature. If the hook is implemented but the matrix, recipe, fixture, or +authoritative contract is missing, the plugin change is not documentation +complete. + ## Create A New Repository Skip this section for changes to an existing plugin after resolving its owning @@ -188,8 +239,8 @@ request. Do not silently substitute a directory in the Kandev monorepo. Run the plugin repository's own formatting, tests, and lint commands first, then verify the artifact rather than only the source tree: -1. Build a host-only package with the template's `make package-host` target for - the local loop. +1. Run the plugin repository's tests, vet/lint, and build. Build a host-only + package with the template's `make package-host` target for the local loop. 2. Confirm the archive contains `manifest.yaml`, the current host executable, optional UI assets, and the generated internal `checksums.txt`. Never author the internal checksum file by hand. @@ -210,6 +261,12 @@ then verify the artifact rather than only the source tree: 7. Before release, run the repository's full cross-platform package workflow and confirm its release asset name is `-.tar.gz`. +There is no standalone exhaustive package checker in this branch. `plugin-pack` +stages files and generates checksums; install-time `pkgtar.Install` validates the +manifest, archive safety, checksums, managed runtime, and host executable. These +checks do not execute plugin code or prove browser/module behavior, so the +disposable-instance smoke test remains required. + Do not test with a developer's primary instance, database, or credentials. Report commands run, artifact name, host platform tested, and any platform or integration path not exercised. diff --git a/AGENTS.md b/AGENTS.md index c17e6d0f60..6aece6d80a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -183,7 +183,9 @@ Jira and Linear are the model (per-workspace credentials, 90s auth-health poller ### Kandev plugins -Production Kandev plugins live in dedicated repositories, not in this monorepo. Official plugins use public `kdlbs/kandev-plugin-` repositories and start from [`kdlbs/kandev-plugin-template`](https://github.com/kdlbs/kandev-plugin-template). Use `/create-kandev-plugin` for plugin creation, modification, bug fixes, packaging, release, and marketplace work. When a Kandev Worktree task needs the plugin repository, attach it with `add_branch_to_task_kandev` instead of cloning inside this worktree. Keep host API, SDK, loader, registry, and the in-tree test fixture in this repository. +Production Kandev plugins live in dedicated repositories, not in this monorepo. Start at the [canonical plugin authoring guide](docs/public/plugins-authoring.md), then use `/create-kandev-plugin` for plugin creation, modification, bug fixes, packaging, release, and marketplace work. Official plugins use public `kdlbs/kandev-plugin-` repositories and start from [`kdlbs/kandev-plugin-template`](https://github.com/kdlbs/kandev-plugin-template). The recommended workflow is: choose recipe → edit manifest → implement → validate → package → smoke test. When the user explicitly requests a persistent Kandev task, attach the plugin repository through that task's workflow; otherwise do not use Kandev task/session APIs as repository-work mechanisms. + +Contract authority is intentionally split by implementation boundary: frontend authors use [`docs/plans/plugins/PLUGIN-API.md`](docs/plans/plugins/PLUGIN-API.md) plus [`apps/web/lib/plugins/types.ts`](apps/web/lib/plugins/types.ts), with concrete Host UI exports in `apps/web/lib/plugins/host-api.ts`; backend authors use `apps/backend/pkg/pluginsdk`, the wire contract in `apps/backend/proto/kandev/plugin/v1/plugin.proto`, and manifest/package rules in `apps/backend/internal/plugins/manifest` and `apps/backend/internal/plugins/pkgtar`. The in-tree fixture under `apps/backend/cmd/plugin-fixture` is test support, not a production plugin starter. ### Runtime profiles (prod / dev / e2e) diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 9f17e5e465..646197aa6d 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -1,6 +1,6 @@ # Backend (Go) — architecture and conventions -Scoped guidance for `apps/backend/`. Repo-wide rules (commit format, code-quality limits, etc.) live in the root `AGENTS.md`. +Scoped guidance for `apps/backend/`. Repo-wide rules (commit format, code-quality limits, etc.) live in the root `AGENTS.md`. For plugin work, start at the [canonical plugin authoring guide](../../docs/public/plugins-authoring.md), follow choose recipe → edit `manifest.yaml` → implement → validate → package → smoke test, and treat `pkg/pluginsdk`, `proto/kandev/plugin/v1/plugin.proto`, `internal/plugins/manifest`, and `internal/plugins/pkgtar` as authoritative; the fixture is test support, and plugins must not access databases or `internal/...` packages. ## Package Structure diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js index dbd25a45e9..952c4d75f6 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js +++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js @@ -6,7 +6,7 @@ * registers a nav item, a top-level route, a `task-sidebar` slot component, * a `main-top-bar` slot component, a `task.created` WS handler, and the * `open-demo` keybinding (declared in manifest.yaml's `ui.keybindings`, - * default `mod+shift+k`) which opens a `host.openModal(...)` demo modal. + * default `mod+shift+j`) which opens a `host.openModal(...)` demo modal. * Uses only host.React/host.jsx. * * The task-created counter lives in module scope (not component state) with diff --git a/apps/backend/cmd/plugin-fixture/plugin.go b/apps/backend/cmd/plugin-fixture/plugin.go index f3db7637b0..b57f423d2a 100644 --- a/apps/backend/cmd/plugin-fixture/plugin.go +++ b/apps/backend/cmd/plugin-fixture/plugin.go @@ -152,8 +152,8 @@ type writeProbeRecord struct { // snapshotWriteProbeBestEffort exercises the write RPCs end to end: Host // CreateTask then, on success, Host SendMessage to the new task, writing the // result (task id, dispatch status, or the error) to write-probe.json. -// Best-effort — the Host may deny the write when the fixture's manifest lacks -// api_write, which is itself useful evidence. +// Best-effort — the fixture manifest exercises the current api_write contract; +// any permission or service error is still recorded as useful evidence. func (p *fixturePlugin) snapshotWriteProbeBestEffort(ctx context.Context) { host := p.Host() if host == nil { diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 8cd47324c4..7961f30f20 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -2,6 +2,10 @@ Scoped guidance for `apps/web/`. Repo-wide rules (commit format, code-quality limits, etc.) live in the root `AGENTS.md`. +## Plugin authoring + +For plugin UI work, begin with the [canonical plugin authoring guide](../../docs/public/plugins-authoring.md). Follow: choose recipe → edit `manifest.yaml` → implement → validate → package → smoke test. The frontend contract pair is `../../docs/plans/plugins/PLUGIN-API.md` plus `lib/plugins/types.ts`; concrete shared Host UI exports are in `lib/plugins/host-api.ts`, and registration/cleanup behavior is in `lib/plugins/registry.ts` and `lib/plugins/host.ts`. Keep the guide and that contract pair synchronized; do not invent hooks such as task panels, task-menu actions, per-user `host.storage`, rich-text components, or Kanban-card injection when they are absent from the current source. + ## UI Components **Shadcn Components:** Import from `@kandev/ui` package: diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts index 717e9c7ea6..b2fb3f95b5 100644 --- a/apps/web/lib/plugins/types.ts +++ b/apps/web/lib/plugins/types.ts @@ -71,7 +71,7 @@ export interface PluginRouteOptions { /** * Named slot the host renders via ``. Initial slots: - * "task-sidebar", "settings-nav", "main-nav-footer", "chat-input-actions" + * "task-sidebar", "settings-nav", "chat-input-actions" * (icon buttons in the chat composer toolbar, beside the model picker / mic / * send — receives `{ taskId, taskTitle, activeSessionId, sessionIds }` as * `slotProps`), "chat-top-bar" (status in the session top bar, beside the diff --git a/docs/plans/plugin-authoring-experience/plan.md b/docs/plans/plugin-authoring-experience/plan.md new file mode 100644 index 0000000000..9d563b581a --- /dev/null +++ b/docs/plans/plugin-authoring-experience/plan.md @@ -0,0 +1,123 @@ +--- +spec: docs/specs/plugins/authoring-experience.md +created: 2026-08-02 +status: complete +--- + +# Implementation Plan: Plugin Authoring Experience + +## Overview + +Make the existing public authoring page the single discovery entry point, +reconcile nearby documentation against the current frontend and backend +contracts, and route agents there from scoped guidance and the existing plugin +skill. The change is documentation-first and adds no plugin runtime or +validation code. + +## Canonical developer guide + +Rewrite `docs/public/plugins-authoring.md` around the author workflow while +keeping its stable public slug: + +1. lifecycle diagram and package/data layout; +2. plugin shapes plus security/capability boundaries; +3. storage decision table; +4. complete current frontend and backend matrices linked to authoritative + source; +5. six current-contract recipes; +6. exact build/test/package/install/smoke sequence and validation scope; +7. common mistakes and explicit unavailable-surface notes. + +The guide will use small copy-pasteable snippets and link larger examples to the +in-tree fixture and official template. Broken references to absent example +repositories will be removed. + +The workflow will document existing validation honestly: + +- plugin repository formatting, unit tests, vet/lint, and builds; +- `plugin-pack` package creation and generated `checksums.txt`; +- package/archive/manifest/host-executable checks performed by installation; +- `make -C apps/backend e2e-plugin-package` and the focused `plugin-pack` test + as the maintained in-tree package smoke validation; +- install and capability smoke tests in a disposable development instance. + +It will state that there is no standalone exhaustive package checker in this +branch and distinguish build-time, pack-time, install-time, and runtime checks. + +## Contract and example reconciliation + +Update the public plugin overview, manifest guide, marketplace cross-links, +`docs/plugins-example.md`, the frozen plugin contract notes, and plugin spec +where they contradict the current implementation. Key corrections are: + +- implemented Host task/message writes are not described as reserved; +- `ui.bundle` examples include the required leading slash; +- only actually mounted component slots are listed as supported; +- missing example repositories are not presented as maintained code; +- unsupported task-panel, task-menu, per-user storage, rich-text, and Kanban + card APIs are identified as unavailable rather than assigned speculative + signatures. + +Prefer documentation edits. Change source files only if a stale documentation +comment inside a fixture or type file would otherwise remain directly +misleading; do not change runtime types, behavior, schemas, or tests. + +## Agent and contributor discoverability + +Update `AGENTS.md`, `apps/backend/AGENTS.md`, and `apps/web/AGENTS.md` with a +direct canonical-guide link, the authority map appropriate to each scope, and +the workflow: choose recipe → edit manifest → implement → validate → package → +smoke test. + +Update `.agents/skills/create-kandev-plugin/SKILL.md` so agents read the same +canonical guide and contract map, use implemented Host writes correctly, and +follow the documented validation boundaries. Keep the skill procedural and the +public page explanatory; do not duplicate full matrices in the skill. + +## Tests and validation + +- Public-doc structure and local links: + `node --test scripts/validate-public-docs.test.mjs` and + `node scripts/validate-public-docs.mjs`. +- Backend package/example evidence: + `make -C apps/backend e2e-plugin-package` and focused tests for + `cmd/plugin-pack`, `cmd/plugin-fixture`, `internal/plugins/manifest`, + `internal/plugins/pkgtar`, and `pkg/pluginsdk`. +- Frontend contract/lifecycle evidence: focused plugin host, API, registry, and + WebSocket bridge tests. +- Harness/doc hygiene: targeted stale-reference searches plus + `git diff --check`. + +No new browser E2E is planned because no product UI behavior changes. + +## Implementation waves + +Wave 1: +- [x] [task-01-canonical-guide](task-01-canonical-guide.md) + +Wave 2: +- [x] [task-02-contract-reconciliation](task-02-contract-reconciliation.md) + +Wave 3: +- [x] [task-03-agent-discoverability](task-03-agent-discoverability.md) + +## Risks + +- A summary matrix can drift into a competing contract. Keep signatures + abbreviated, link every section to authoritative files, and avoid copied DTO + field inventories. +- The external template can drift independently. Treat it as scaffold/build + workflow, not as the API source of truth. +- Install-time checks must not be described as a standalone authoring validator; + explicitly state which command checks which layer. +- The system is still experimental according to public coverage evidence, so + wording must not imply a stability promotion. + +## Verification results + +- `node --test scripts/validate-public-docs.test.mjs` — 58 passed. +- `node scripts/validate-public-docs.mjs` — validated 41 published docs pages. +- `(cd apps/backend && go test ./cmd/plugin-pack ./cmd/plugin-fixture ./internal/plugins/manifest ./internal/plugins/pkgtar ./pkg/pluginsdk)` — 167 passed in 5 packages. +- `make -C apps/backend e2e-plugin-package` — produced `.build/kandev-plugin-e2e-1.0.0.tar.gz` for linux-amd64. +- `(cd apps && pnpm --filter @kandev/web test -- lib/plugins/host-api.test.ts lib/plugins/host.test.ts lib/plugins/registry.test.ts lib/ws/plugin-bridge.test.ts)` — 4 files and 50 tests passed. Happy-DOM emitted expected external-stylesheet warnings. +- `git diff --check` — passed. diff --git a/docs/plans/plugin-authoring-experience/task-01-canonical-guide.md b/docs/plans/plugin-authoring-experience/task-01-canonical-guide.md new file mode 100644 index 0000000000..9b60e3a352 --- /dev/null +++ b/docs/plans/plugin-authoring-experience/task-01-canonical-guide.md @@ -0,0 +1,53 @@ +--- +id: "01-canonical-guide" +title: "Canonical plugin guide" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/plugins/authoring-experience.md" +--- + +# Task 01: Canonical plugin guide + +## Acceptance + +1. `docs/public/plugins-authoring.md` contains the lifecycle, layouts, plugin + shapes, security/capabilities, storage table, complete current matrices, six + recipes, exact validation/package/test workflow, and common mistakes. +2. The guide links to authoritative source rather than redefining it, identifies + unavailable APIs, and references only real maintained files/repositories. +3. The workflow clearly separates source checks, packaging/checksum generation, + install-time validation, and disposable-instance smoke tests. + +## Verification + +```sh +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +rg -n "registerTaskPanel|registerTaskMenuAction|host.storage|RichText(Editor|ReadOnly)|Kanban" docs/public/plugins-authoring.md +rg -n "kandev-plugin-(hello|github)" docs/public/plugins*.md docs/plugins-example.md +``` + +The searches are audits: future-surface matches must be explicit +unavailability notes, and missing-repository matches should be absent. + +## Files likely touched + +- `docs/public/plugins-authoring.md` +- `docs/public/plugins.md` +- `docs/public/plugins-manifest.md` +- `docs/public/plugins-marketplace.md` +- `docs/public/extending-kandev.md` +- `docs/public/coverage.json` only if required for navigation/evidence, without + changing experimental status + +## Results + +- Rewrote the canonical public guide with lifecycle, package/data layout, + current frontend/backend matrices, six recipes, layered validation workflow, + and common mistakes. +- Removed references to absent external example repositories and kept the + plugin status experimental. +- `node --test scripts/validate-public-docs.test.mjs` — 58 passed. +- `node scripts/validate-public-docs.mjs` — validated 41 published docs pages. diff --git a/docs/plans/plugin-authoring-experience/task-02-contract-reconciliation.md b/docs/plans/plugin-authoring-experience/task-02-contract-reconciliation.md new file mode 100644 index 0000000000..742f5163e5 --- /dev/null +++ b/docs/plans/plugin-authoring-experience/task-02-contract-reconciliation.md @@ -0,0 +1,45 @@ +--- +id: "02-contract-reconciliation" +title: "Plugin contract reconciliation" +status: completed +wave: 2 +depends_on: ["01-canonical-guide"] +plan: "plan.md" +spec: "../../specs/plugins/authoring-experience.md" +--- + +# Task 02: Plugin contract reconciliation + +## Acceptance + +1. Frozen contract notes, the plugin spec, and example docs agree with source on + Host writes, UI path rules, mounted slots, and authoritative ownership. +2. `docs/plugins-example.md` and the in-tree fixture references describe only + real current examples and commands. +3. Runtime source is unchanged except for optional comment-only corrections + that directly prevent a maintained example from lying. + +## Verification + +```sh +(cd apps/backend && go test ./cmd/plugin-pack ./cmd/plugin-fixture ./internal/plugins/manifest ./internal/plugins/pkgtar ./pkg/pluginsdk) +make -C apps/backend e2e-plugin-package +(cd apps && pnpm --filter @kandev/web test -- lib/plugins/host-api.test.ts lib/plugins/host.test.ts lib/plugins/registry.test.ts lib/ws/plugin-bridge.test.ts) +node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `docs/plans/plugins/PLUGIN-API.md` +- `docs/plans/plugins/GRPC-CONTRACT.md` +- `docs/specs/plugins/spec.md` +- `docs/plugins-example.md` +- maintained fixture/type comments only if necessary + +## Results + +- Reconciled `api_write` task/message writes, root-relative UI paths, mounted + slots, fixture comments, and example documentation against current source. +- `(cd apps/backend && go test ./cmd/plugin-pack ./cmd/plugin-fixture ./internal/plugins/manifest ./internal/plugins/pkgtar ./pkg/pluginsdk)` — 167 passed in 5 packages. +- `make -C apps/backend e2e-plugin-package` — produced the linux-amd64 fixture archive. +- Frontend-focused plugin tests passed with 50 tests across 4 files. diff --git a/docs/plans/plugin-authoring-experience/task-03-agent-discoverability.md b/docs/plans/plugin-authoring-experience/task-03-agent-discoverability.md new file mode 100644 index 0000000000..6f6aed4531 --- /dev/null +++ b/docs/plans/plugin-authoring-experience/task-03-agent-discoverability.md @@ -0,0 +1,50 @@ +--- +id: "03-agent-discoverability" +title: "Agent plugin discoverability" +status: completed +wave: 3 +depends_on: ["02-contract-reconciliation"] +plan: "plan.md" +spec: "../../specs/plugins/authoring-experience.md" +--- + +# Task 03: Agent plugin discoverability + +## Acceptance + +1. Root, backend, and web `AGENTS.md` each link directly to the canonical guide + and identify the contract authorities relevant to their scope. +2. The existing `create-kandev-plugin` skill follows the canonical guide, + reflects implemented Host writes, and sends agents through choose recipe → + edit manifest → implement → validate → package → smoke test. +3. All focused documentation, harness, backend package, and frontend plugin-host + checks pass, with exact results recorded in the plan. + +## Verification + +```sh +rg -n "docs/public/plugins-authoring.md|PLUGIN-API.md|apps/web/lib/plugins/types.ts|pkg/pluginsdk|plugin.proto|internal/plugins/manifest" AGENTS.md apps/backend/AGENTS.md apps/web/AGENTS.md .agents/skills/create-kandev-plugin/SKILL.md +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +make -C apps/backend e2e-plugin-package +(cd apps/backend && go test ./cmd/plugin-pack ./cmd/plugin-fixture ./internal/plugins/manifest ./internal/plugins/pkgtar ./pkg/pluginsdk) +(cd apps && pnpm --filter @kandev/web test -- lib/plugins/host-api.test.ts lib/plugins/host.test.ts lib/plugins/registry.test.ts lib/ws/plugin-bridge.test.ts) +git diff --check +``` + +## Files likely touched + +- `AGENTS.md` +- `apps/backend/AGENTS.md` +- `apps/web/AGENTS.md` +- `.agents/skills/create-kandev-plugin/SKILL.md` +- this plan and its task files + +## Results + +- Root, backend, and web AGENTS now link directly to the canonical guide and + identify the frontend pair, backend SDK, proto, manifest, and package + authorities. +- `create-kandev-plugin` now routes through the same guide, current Host write + behavior, unavailable-API notes, and layered validation scope. +- Stale-reference audit and `git diff --check` passed. diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md index e4f3360db3..f4342bcc7a 100644 --- a/docs/plans/plugins/PLUGIN-API.md +++ b/docs/plans/plugins/PLUGIN-API.md @@ -140,7 +140,7 @@ interface PluginRegistry { // Named slot injection. Host renders all components registered for a slot via // . Initial slots: "task-sidebar", - // "settings-nav", "main-nav-footer", "chat-input-actions", "chat-top-bar", + // "settings-nav", "chat-input-actions", "chat-top-bar", // "main-top-bar", "app-status-bar-left", "app-status-bar-right", and // "plugin-settings". // "chat-input-actions" renders icon buttons in the chat composer toolbar diff --git a/docs/plugins-example.md b/docs/plugins-example.md index 82bdbdf0a0..445551e734 100644 --- a/docs/plugins-example.md +++ b/docs/plugins-example.md @@ -1,13 +1,14 @@ -# Example plugin +# Plugin examples -`kandev-plugin-hello` is a standalone, importable reference implementation of -the kandev native-UI plugin contract (manifest + Go SDK backend, spawned by -kandev as a gRPC subprocess via `pkg/pluginsdk` + hand-written, no-build ES -module UI bundle) — it registers a "Hello" nav item and route, a -task-sidebar slot widget, a live WebSocket-driven counter, and a backend -tool/event/webhook, and is meant to be copied as the starting point for a -new plugin. It lives in its own git repository, sibling to this one, at -`../kandev-plugin-hello/` (see that repo's `README.md` for how to build, -package, and install it against a running kandev instance); see -`docs/plans/plugins/GRPC-CONTRACT.md` for the backend/transport contract and -`docs/plans/plugins/PLUGIN-API.md` for the frontend contract it implements. +The canonical authoring guide is [Authoring a Plugin](public/plugins-authoring.md). +It documents the current frontend hooks, backend Host surfaces, six recipes, +package layout, and the reproducible build/package/install/test workflow. + +For a maintained scaffold, start from +[`kdlbs/kandev-plugin-template`](https://github.com/kdlbs/kandev-plugin-template). +For an in-tree package smoke test, use the fixture and commands in +`apps/backend/cmd/plugin-fixture` and `make -C apps/backend e2e-plugin-package`. + +The fixture is test support, not a production plugin repository. Do not rely on +old external example links that are not present in the maintained source +catalog; use the template and fixture paths above. diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index 010d306b1a..54b7b15d39 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -1,847 +1,578 @@ --- title: "Authoring a Plugin" -description: "Build a kandev plugin backend and optional native UI, package it, and iterate against a local kandev instance." +description: "Discover the current Kandev plugin contract, choose a recipe, build a package, and smoke-test it safely." status: experimental --- # Authoring a Plugin -This is a build tutorial for a kandev plugin: a Go backend spawned by kandev -over gRPC, with an optional native frontend bundle. See [Plugins](plugins.md) -for the operator-facing install/enable/disable flow, and the [Plugin manifest -reference](plugins-manifest.md) for the full `manifest.yaml` schema. +This is the canonical developer entry point for Kandev runtime plugins. Use it +to choose a supported surface, edit manifest.yaml, implement the backend or +native UI, package it, and test it against a disposable development instance. +The [plugin manifest reference](plugins-manifest.md) owns field-by-field schema +details; [Plugins](plugins.md) covers operator installation and lifecycle. -The fastest way to start is the -[`kdlbs/kandev-plugin-template`](https://github.com/kdlbs/kandev-plugin-template) -starter repo — click **Use this template** on GitHub for a minimal, working -plugin with the layout, build/package `Makefile`, tests, and a tag-triggered -release workflow already wired up. This guide explains what that scaffold -contains and how to extend it. +The official scaffold is +[kdlbs/kandev-plugin-template](https://github.com/kdlbs/kandev-plugin-template). +Production plugins live in their own repositories. The in-tree fixture is test +support, not a starter repository. -Two more example plugins go deeper on specific surfaces: +## Quick workflow -- `kandev-plugin-hello` — a nav item, native route, sidebar slot component, a - WS-driven counter, a Host-state-backed event handler, and a webhook. -- `kandev-plugin-github` — a connector PoC that shells out to the `gh` CLI - from a webhook handler used as a request relay, reads live kanban data from - the host store, and opens kandev's real create-task dialog - (`host.ui.TaskCreateDialog`) prefilled from a pull request. +1. Choose the closest recipe in this page. +2. Copy the template and edit manifest.yaml: identity, runtime executable, UI + paths, capabilities, events, webhooks, and config. +3. Implement only through the Go pluginsdk and the frontend registry/Host API. +4. Run the plugin repository tests, vet/lint, and build. +5. Stage the package, run plugin-pack, inspect the archive and generated + checksums, then install it in a disposable Kandev instance. +6. Smoke-test every declared hook and capability, then bump the version for + the next iteration. -## Quick path +## Lifecycle -1. Start from the plugin template. -2. Implement the backend and manifest first. -3. Add native UI, webhooks, or Host API capabilities only when needed. -4. Run focused tests, package the plugin, and install it locally. +```mermaid +flowchart LR + A[Package install] --> B[Manifest and package validation] + B --> C[Safe extraction] + C --> D[Runtime subprocess] + D <-->|Host gRPC over go-plugin| E[Go plugin backend] + C --> F[Frontend bundle and styles] + F --> G[Browser bundle loading] + G --> H[registerKandevPlugin initialize] +``` -## Prerequisites +Installation validates the manifest, archive paths, checksums, managed runtime, +and the current host executable before extraction. Kandev then supervises the +declared subprocess and injects a Host connection. The UI bundle is static +package content loaded by the browser; it does not run inside the backend +subprocess. On disable or uninstall, the host calls destroy when present and +bulk-revokes registrations, styles, routes, handlers, and navigation. -- Go (matching the version in `apps/backend/go.mod`). -- The kandev plugin SDK, `github.com/kandev/kandev/pkg/pluginsdk`. It is - **not yet published as a standalone Go module** — it lives inside the - kandev monorepo (`apps/backend/pkg/pluginsdk`), so a plugin repo develops - against a local checkout via a `replace` directive: +## Package and data layout - ```go - // go.mod - module my-plugin +```text +-.tar.gz +├── manifest.yaml # required, validated first +├── server/ +│ └── plugin--[.exe] # one or more declared binaries +├── ui/ +│ ├── bundle.js # optional native ES module +│ ├── plugin.css # optional styles +│ └── assets/ # optional files referenced by UI +├── assets/ # optional manifest icon/assets +├── checksums.txt # generated by plugin-pack +└── checksums.txt.sig # optional signature +``` - require github.com/kandev/kandev v0.0.0-00010101000000-000000000000 +runtime.executables maps a platform key such as linux-amd64 to a clean +package-relative path such as server/plugin-linux-amd64. Windows values must +end in .exe. The host platform's key must be present at install time. A +UI-focused plugin still ships a managed backend executable because the current +installer requires runtime.type: binary; that executable can be a no-op +pluginsdk.UnimplementedPlugin server. - replace github.com/kandev/kandev => ../kandev/apps/backend - ``` +Kandev injects KANDEV_PLUGIN_DATA_DIR into the subprocess. It is the durable, +per-plugin directory for arbitrary files or a plugin-owned database: - Adjust the relative path to wherever you've checked out the kandev repo. - This is what both example plugins do; see their `go.mod` for the exact - pinned dependency versions. +```text +~/.kandev/plugins//data/ +``` -## Backend: minimal plugin +The directory is shared across installed versions, survives restarts and +upgrades, and is removed on uninstall. Keep small structured values in Host +state instead, and never write arbitrary files elsewhere in the Kandev data +directory. -A plugin backend implements `pluginsdk.Plugin` and calls `pluginsdk.Serve`, -which owns the entire go-plugin/gRPC transport — handshake, plugin map, and -Host injection. There is no HTTP server, no listen address, and no -credentials to configure. +## Choose a plugin shape -> **Language.** The backend is any native executable that speaks the -> hashicorp/go-plugin gRPC protocol (magic-cookie handshake, AutoMTLS, the -> `kandev.plugin.v1` services) — kandev just spawns the platform binary and -> talks to it over that wire. Go is the only SDK kandev ships today, so this -> guide is Go; another language means implementing the handshake, mTLS, and -> proto yourself. +| Shape | Package contents | Typical contract | Minimal capability set | +| --- | --- | --- | --- | +| Backend plugin | Go binary and manifest | events, webhooks, Host data, state, secrets, or utility-agent calls | only the capabilities used by the backend | +| UI-focused plugin | no-op managed binary, ui/bundle.js, optional styles/assets | routes, nav, named slots, WebSocket handlers, keybindings, shared store | ui.bundle; add ui.keybindings when declaring shortcuts | +| Combined plugin | Go binary plus UI bundle | UI calls a declared webhook or backend API; backend uses Host | union of the two surfaces, kept least-privilege | + +There is no separate HTTP server to launch. pluginsdk.Serve owns the +go-plugin/gRPC handshake and Host injection. The backend implements +pluginsdk.Plugin (OnEvent and/or HandleWebhook) and embeds +pluginsdk.UnimplementedPlugin for no-op defaults. + +## Security and capabilities + +Plugins are privileged installed code. The capability list gates Host RPCs; it +is not an operating-system sandbox. A backend subprocess inherits the Kandev +process environment, and a UI bundle runs as same-origin JavaScript with the +curated React, UI, and app-store surface. + +- Plugins do not receive direct database access. Use typed Host readers/writers, + Host state, secrets, events, and the data directory. Do not import Kandev + internal/... packages or call undocumented REST endpoints. +- state gates Host state; secrets gates RevealSecret and plugin-owned secret + methods; each api_read resource gates its reader; api_write gates + task/message mutations; agent_invoke gates utility-agent calls; and + capabilities.events controls event delivery. +- GetConfig and EmitEvent are ungated. GetConfig returns this plugin's own + config, including cleartext secret fields, so do not log or commit it. +- Declared webhook keys are routable, but Kandev does not authenticate callers + or enforce webhooks[].method. Validate the method, provider signature, and + replay/timestamp rules inside the plugin before side effects. +- capabilities.auth is the highest-risk capability. A webhook response may + assert a verified external identity with X-Kandev-Auth-Login; only assert an + email the IdP verified as owned by the subject. See [ADR 0050](../decisions/0050-plugin-external-auth-capability.md). + +## Storage decision table + +| Need | Use | Scope/lifecycle | Capability or rule | +| --- | --- | --- | --- | +| Small JSON object owned by this plugin | Host state: GetState, SetState, DeleteState, ListState | instance, workspace, task, or agent; survives restart/upgrade and is included in Kandev state backups | capabilities.state: true; values are JSON objects, not bare scalars | +| Per-user browser/plugin storage | No current host.storage API | Unavailable on this branch; do not invent a signature or use browser storage as a Host contract | Store server-side with an explicit entity scope, or ask for a future Host API | +| Operator configuration | Host.GetConfig and manifest config_schema | Plugin-owned settings; config changes restart an active subprocess | Ungated GetConfig; secret fields arrive cleartext in the subprocess | +| Plugin-owned credentials | Host.GetSecret/SetSecret/DeleteSecret, or secret: true config fields | Encrypted Kandev vault, namespaced to this plugin | capabilities.secrets: true; never log values | +| Files, caches, or plugin-managed database | KANDEV_PLUGIN_DATA_DIR | Shared across versions, removed on uninstall | Write only below the injected directory; own schema, locking, and migrations | + +Host state is not host.storage: it is plugin-scoped backend state, not a +per-user frontend store. There is no transaction or compare-and-swap operation; +design updates to be idempotent. + +## Authoritative contracts + +The guide summarizes the contract for discovery. These files remain the source +of truth and must be updated together when the contract changes: + +- Frontend contract pair: docs/plans/plugins/PLUGIN-API.md and apps/web/lib/plugins/types.ts. +- Concrete frontend Host exports: apps/web/lib/plugins/host-api.ts. +- Backend author API: apps/backend/pkg/pluginsdk. +- Wire contract: apps/backend/proto/kandev/plugin/v1/plugin.proto. +- Manifest model and semantic validation: apps/backend/internal/plugins/manifest. +- Package integrity and installation: apps/backend/internal/plugins/pkgtar. +- Durable decisions: [ADR 0043](../decisions/0043-plugin-host-data-api.md), [ADR 0047](../decisions/0047-plugin-host-conversation-reads.md), [ADR 0048](../decisions/0048-plugin-host-utility-agent-invoke.md), and [ADR 0050](../decisions/0050-plugin-external-auth-capability.md). + +## Frontend contract + +### Loading and cleanup + +The package's ui.bundle is a hand-written ES module. When active, Kandev loads +it and the bundle calls: + +```js +window.registerKandevPlugin("my-plugin", { + initialize(registry, host) {}, + destroy() {}, +}); +``` + +initialize receives a registry scoped to this plugin and a PluginHostApi. The +host may call initialize more than once across disable/enable cycles. Make it +repeatable; return a cleanup function from subscriptions and timers, and use +destroy to remove side effects that are not registrations. The host revokes all +registry entries, closes plugin modals, and removes plugin styles on +disable/uninstall. + +### Frontend hook/API matrix + +| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example | +| --- | --- | --- | --- | --- | +| registerRoute | registry.registerRoute(path, Component, options?); exact SPA path; options.topbar defaults to host chrome or false for full-bleed | Active ui.bundle | Route is removed on disable/uninstall; use destroy for subscriptions | registry.registerRoute("/acme", Page, { topbar: { title: "Acme" } }) | +| registerNavItem | { id, label, path, icon?, section? }; section is main, integrations, or accepted-but-not-rendered settings | Active ui.bundle | Nav item is revoked and removed from desktop/phone navigation | registry.registerNavItem({ id: "home", label: "Acme", path: "/acme", icon: "chart" }) | +| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins//...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) | +| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) | +| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) | +| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; editable targets/core shortcuts win | registry.registerKeybinding("open-panel", () => host.openModal(...)) | +| host.React / host.jsx | Shared React instance and React.createElement alias | Active ui.bundle | No cleanup; never bundle a second React/Radix runtime | const h = host.jsx | +| host.store | Curated Zustand { getState, setState, subscribe } for the live app store | Active ui.bundle | Unsubscribe in destroy; setState mutates the whole SPA and is not a plugin database | const stop = host.store.subscribe(render) | +| host.api.fetch / baseUrl | fetch(path, init?) is scoped to /api/plugins//...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Abort/ignore requests after destroy; do not assume a webhook authenticates callers | host.api.fetch("webhooks/inbound", { method: "POST" }) | +| host.ui | Curated host instances: Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, plus Combobox, PageTopbar, TaskCreateDialog | Active ui.bundle | Host owns contexts/portals; render with host React and let modal/slot cleanup run | const Button = host.ui.Button | +| host.theme | Current "light" or "dark" theme | Active ui.bundle | Read during render; subscribe through host/app patterns if theme-sensitive | host.theme === "dark" | +| host.navigate | Soft SPA navigation navigate(href, { replace? }) | Active ui.bundle | No registry cleanup; avoid navigating to undeclared external origins | host.navigate("/t/" + taskId) | +| host.openModal | Host-owned modal: { title?, content, size?, dismissible? } -> { close() } | Active ui.bundle | Modal auto-closes on disable/uninstall; close handles are idempotent | const modal = host.openModal({ content: Panel }) | + +### Supported named slots + +registerComponent currently has these mounted slots. The source type is open +to strings, but an unmounted name renders nowhere. + +| Slot | Mounted location | slotProps | +| --- | --- | --- | +| task-sidebar | Bottom of task-detail sidebar | none | +| settings-nav | Settings navigation tree | none | +| chat-input-actions | Chat composer toolbar | { taskId, taskTitle?, activeSessionId, sessionIds } | +| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } | +| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } | +| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps | +| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps | +| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed | + +AppStatusBarSlotProps is { placement, presentation, density, pathname, +activeWorkspaceId, activeTaskId, activeSessionId }. Desktop presentation is a +compact 24px bar; mobile presentation is an in-flow drawer, so render a +touch-usable row. Status items can be reordered by the host; plugins do not get +an ordering API. + +### APIs not available on this branch + +Do not copy examples that assume these hooks exist: + +- registerTaskPanel and registerTaskMenuAction are not implemented. Use a + task-sidebar component, a route, or a webhook-backed UI action. +- host.storage (per-user frontend storage) is not implemented. Use backend Host + state with an explicit task, workspace, agent, or instance scope. +- host.ui.RichTextEditor and host.ui.RichTextReadOnly are not exported. +- There is no Kanban-card contribution hook. Use main-top-bar, a route that + reads host.store, or a task-sidebar contribution. + +These are branch facts, not reserved signatures. When a future branch adds one, +update the authoritative contract pair and this matrix together. + +## Backend contract + +### Plugin lifecycle surface + +| Surface | Input/output | Manifest capability or declaration | Lifecycle/cleanup | Example | +| --- | --- | --- | --- | --- | +| Plugin.OnEvent | OnEvent(ctx, *pluginsdk.Event) error; event has EventID, EventType, OccurredAt, WorkspaceID, Payload | Matching capabilities.events subject/pattern | Delivery is sequential per plugin with bounded queue and retries; make handlers idempotent by EventID and reconcile critical state | if event.EventType == "task.created" { ... } | +| Plugin.HandleWebhook | HandleWebhook(ctx, *pluginsdk.WebhookRequest) (*WebhookResponse, error); request includes key, method, path, query, headers, body | webhooks[].key; auth additionally required for login assertion header | Undeclared keys are 404; GET/POST are relayed; validate method/signature and bound side effects | if req.WebhookKey == "inbound" { ... } | +| Host.EmitEvent | EmitEvent(ctx, name, payload) publishes plugin.. | None; intentionally ungated | Subscribers must declare the emitted subject; use versioned names/payloads | host.EmitEvent(ctx, "sync.completed", payload-map) | + +Event delivery is best-effort: Kandev makes the initial attempt plus retries +with 5s, 15s, and 45s delays, and a bounded in-memory queue/error buffer can +drop events during sustained overload or restart. Use EventID for deduplication +and a source-of-truth reconciliation path when loss matters. The full +subscription vocabulary and wildcard rules are in the +[manifest reference](plugins-manifest.md#event-subscription-vocabulary). + +### Host API matrix + +| Host surface | Methods | Required manifest capability | Notes | +| --- | --- | --- | --- | +| State | GetState, SetState, DeleteState, ListState | state: true | Plugin-scoped JSON objects keyed by scope/scopeID/key; no transactions | +| Config | GetConfig | None | Reads this plugin's validated config_schema; secret fields are cleartext in the subprocess; config updates restart active plugins | +| Secrets | RevealSecret, GetSecret, SetSecret, DeleteSecret | secrets: true | Encrypted vault; plugin-owned keys are namespaced; never log values | +| Tasks | Tasks().List, Tasks().Get | api_read: tasks | Typed DTOs and opaque pagination cursor | +| Tasks writes | Tasks().Create, Tasks().Update | api_write: tasks | Implemented; routed through Kandev services so events/WS updates fire | +| Sessions | Sessions().List, Sessions().CodeStats | api_read: sessions | Typed session and computed code-stat records | +| Workspaces | Workspaces().List | api_read: workspaces | Instance-visible workspaces | +| Workflows | Workflows().List, Workflows().ListSteps | api_read: workflows | List steps by workflow id | +| Agent profiles | AgentProfiles().List | api_read: agent_profiles | Global agent profiles exposed by the Host data API | +| Repositories | Repositories().List | api_read: repositories | List by workspace id | +| Messages | Messages().List | api_read: messages | Historical user/agent content; Kandev system blocks are stripped | +| Message send | Messages().Send | api_write: messages | Sends a prompt to a task session and records plugin: author | +| Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the operator-selected utility agent; missing selection is FailedPrecondition | + +The Go signatures, filters, DTOs, and pagination types live in +apps/backend/pkg/pluginsdk/host.go and data_types.go. api_write task/message +methods are implemented in the current branch; do not repeat older docs that +call them reserved. + +Host calls are request-scoped rather than registrations: pass the handler +context, handle cancellation, and close any files or external clients created +by the plugin. There is no Host cleanup callback. Typical calls are +`host.SetState(ctx, "task", taskID, "note", value)`, +`host.Tasks().List(ctx, filter, page)`, +`host.Tasks().Create(ctx, pluginsdk.CreateTaskInput{...})`, +`host.Messages().Send(ctx, taskID, sessionID, text)`, and +`host.InvokeUtilityAgent(ctx, prompt)`; each fails with `PermissionDenied` +when its manifest capability is absent. + +## Task-oriented recipes + +The snippets below are deliberately small. Keep the full template Makefile and +release workflow from the official scaffold. + +The maintained in-tree fixture shows the same surfaces end to end: its +[Go backend](../../apps/backend/cmd/plugin-fixture/plugin.go), +[manifest](../../apps/backend/cmd/plugin-fixture/fixture-package/manifest.yaml), +and [native bundle](../../apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js) +are the source examples used by the package and browser tests. + +### 1. UI-focused plugin + +Use a no-op backend because the managed installer requires a host executable. + +```yaml +# manifest.yaml +id: "acme-dashboard" +api_version: 1 +version: "0.1.0" +display_name: "Acme Dashboard" +runtime: + type: binary + executables: + linux-amd64: server/plugin-linux-amd64 +ui: + bundle: "/ui/bundle.js" +``` ```go -// main.go +// server/main.go package main import "github.com/kandev/kandev/pkg/pluginsdk" -func main() { - pluginsdk.Serve(&myPlugin{}) -} +func main() { pluginsdk.Serve(&pluginsdk.UnimplementedPlugin{}) } ``` -```go -// plugin.go -package main - -import ( - "context" - - "github.com/kandev/kandev/pkg/pluginsdk" -) - -type myPlugin struct { - pluginsdk.UnimplementedPlugin // embed to get HostSetter + no-op defaults -} - -var _ pluginsdk.Plugin = (*myPlugin)(nil) +```js +// ui/bundle.js +window.registerKandevPlugin("acme-dashboard", { + initialize(registry, host) { + function Page() { + return host.jsx("main", null, "Acme dashboard"); + } + registry.registerRoute("/acme-dashboard", Page); + registry.registerNavItem({ id: "home", label: "Acme", path: "/acme-dashboard", icon: "chart" }); + }, +}); ``` -`pluginsdk.Plugin` is the interface kandev calls into: +### 2. Go backend with Host state -```go -type Plugin interface { - // OnEvent handles a single bus event delivery. A non-nil error causes - // kandev to retry (3 retries, 5s/15s/45s backoff). - OnEvent(ctx context.Context, e *Event) error - - // HandleWebhook handles an inbound request relayed from - // POST /api/plugins/{id}/webhooks/{key}. - HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error) -} -``` - -Embed `pluginsdk.UnimplementedPlugin` and override only the methods you need -— it's a no-op base that also implements `HostSetter`, so `Serve` injects a -live `Host` into your plugin once the broker connection back to kandev is -established (retrieve it later via `p.Host()`). - -## Webhooks - -Declare each webhook key in `manifest.yaml`. Kandev relays `GET` and `POST` -requests for `/api/plugins//webhooks/` to `HandleWebhook`; undeclared -keys return `404`. Request bodies are limited to **4 MiB** and requests that -exceed the limit return `413` before reaching the plugin. Kandev does not -authenticate webhook callers or enforce the manifest's informational `method`, -so validate the HTTP method and the upstream provider's signature before any -side effect. - -## The Host API - -> **Auth capability:** Only assert email addresses the identity provider verified as owned by the subject. An unverified or user-controlled email claim can take over an account; grant this capability only to trusted plugins. - -
-Host API and native UI details - -A plugin calls back into kandev through the injected `Host`: - -```go -type Host interface { - GetState(ctx context.Context, scope, scopeID, key string) (value map[string]any, found bool, err error) - SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error - DeleteState(ctx context.Context, scope, scopeID, key string) error - ListState(ctx context.Context, scope, scopeID string) ([]StateEntry, error) - - // GetConfig returns the plugin's own operator-editable config (set via - // Settings > Plugins > , generated from manifest config_schema). - // Ungated — always readable, secret values included. Kandev restarts a - // running plugin when its config changes, so re-read at startup. - GetConfig(ctx context.Context) (map[string]any, error) - - // GetSecret/SetSecret/DeleteSecret manage a plugin-owned secret in - // kandev's encrypted vault, namespaced to this plugin. Require the - // `secrets` capability. - GetSecret(ctx context.Context, key string) (value string, found bool, err error) - SetSecret(ctx context.Context, key, value string) error - DeleteSecret(ctx context.Context, key string) error - - // RevealSecret resolves an operator-provided secret reference (e.g. a - // config value pointing at a shared kandev secret) to its cleartext - // value. For secrets the plugin itself owns, use GetSecret/SetSecret. - RevealSecret(ctx context.Context, ref string) (string, error) - - EmitEvent(ctx context.Context, name string, payload map[string]any) error - - // Tasks/Sessions/Workspaces/Workflows/AgentProfiles/Repositories return - // read-only accessors for the Host data API (ADR 0043), each gated on - // its own capabilities.api_read entry (e.g. "tasks", "sessions"). - // Write RPCs (capabilities.api_write) are reserved and not yet - // implemented. - Tasks() TaskReader - Sessions() SessionReader - Workspaces() WorkspaceReader - Workflows() WorkflowReader - AgentProfiles() AgentProfileReader - Repositories() RepositoryReader - - // Messages reads historical user/agent conversation content - // (capability api_read:messages). kandev-injected system blocks are - // stripped; raw system prompts are never returned. - Messages() MessageReader - - // InvokeUtilityAgent runs a one-shot completion using this plugin's - // selected utility agent (capability agent_invoke). No API key of your - // own; FailedPrecondition when no valid enabled agent is selected. - InvokeUtilityAgent(ctx context.Context, prompt string) (string, error) -} -``` - -**Host state** is a small key/value store kandev keeps for your plugin in -its own database. Each entry is addressed by a `(scope, scopeID, key)` -triple and holds a JSON object (`map[string]any`): `SetState` upserts one, -`GetState` reads it back (`found` is `false` when the key was never set), -`DeleteState` removes it, and `ListState` returns every entry under a -`(scope, scopeID)`. Values are JSON objects, not bare scalars — wrap a -number as `map[string]any{"n": 3}`. State is namespaced per plugin (kandev -injects your plugin id server-side, so no plugin can read or write -another's), survives restarts, is captured by kandev's database backups, and -is deleted when the plugin is uninstalled. Reach for it when you want kandev -to durably remember something small and structured for you; use the writable -data directory below for arbitrary files you'd rather manage yourself. - -`scope` partitions that store by what a value belongs to: `instance` (the -whole kandev instance; `scopeID` empty), or `workspace` / `task` / `agent` -with `scopeID` set to that entity's id. A per-task counter, for example, is -`SetState(ctx, "task", taskID, "count", map[string]any{"n": 3})` — kept -separate from every other task's. - -`EmitEvent` publishes `plugin..` on kandev's internal -event bus for delivery to any subscriber (including other plugins). - -The data-reader accessors return typed, paginated readers — e.g. -`host.Tasks().List(ctx, TaskFilter{...}, Page{Limit: 50})` returns -`([]Task, *PageInfo, error)` with an opaque `PageInfo.NextCursor` for the -next page. See `pkg/pluginsdk/data_types.go` for the full `Task`, -`Workspace`, `Workflow`, `WorkflowStep`, `AgentProfile`, `Repository`, -`Session`, `Message`, and filter/page types. - -`host.Messages().List(ctx, MessageFilter{...}, Page{...})` reads historical -conversation content (capability `api_read:messages`). Filter by `SessionIDs`, -`TaskIDs`, a `Since`/`Until` `created_at` window (RFC3339; `Since` inclusive, -`Until` exclusive — the natural way to fetch "yesterday"), and message -`Types`. Each `Message` carries `id`, `session_id`, `task_id`, `turn_id`, -`author_type` (`user` or `agent`), `content`, `type`, and `created_at`. -`content` has kandev's injected `` blocks stripped — a plugin -never sees raw system prompts. - -`host.InvokeUtilityAgent(ctx, prompt)` runs a one-shot, non-interactive LLM -completion using the utility agent selected for this plugin in **Settings > -Plugins > ``** (capability `agent_invoke`), and returns its text. Declare -the selector in `manifest.yaml`: +Declare state, embed the no-op base, and read the Host at handling time because +the broker connection may not exist during construction. ```yaml capabilities: - agent_invoke: true - -config_schema: - type: object - properties: - utility_agent: - type: string - format: utility-agent - title: Utility Agent - description: Agent used for this plugin's LLM calls - required: ["utility_agent"] + events: ["task.created", "task.state_changed"] + state: true ``` -The picker displays configured built-in and custom agent names but stores the -selected agent's stable ID. Omit `utility_agent` from `required` only when the -plugin supports operating without LLM delegation; optional selectors include a -**Not set** choice. The plugin needs no provider API key because it delegates to -a kandev-configured agent. A missing, deleted, or disabled selection returns -gRPC `FailedPrecondition`, so handle that as "ask the operator to configure -one" rather than a transient failure. This is the LLM step behind, e.g., a -"summarize yesterday" plugin: read the conversation with `host.Messages()`, -then summarize it with `host.InvokeUtilityAgent(...)`. - -**Capability gating.** Every Host RPC except `GetConfig` and `EmitEvent` is -checked against your manifest's `capabilities` before the handler runs: -`GetState`/`SetState`/`DeleteState`/`ListState` require -`capabilities.state: true`; `GetSecret`/`SetSecret`/`DeleteSecret`/ -`RevealSecret` require `capabilities.secrets: true`; `InvokeUtilityAgent` -requires `capabilities.agent_invoke: true`; each data-reader accessor requires -its resource in `capabilities.api_read` (e.g. `tasks`, `sessions`, `messages`, -`workspaces`, `workflows`, `agent_profiles`, `repositories`). -Calling one without the declared capability returns gRPC `PermissionDenied` -with a message naming the missing capability — declare what you use. - -**External login (`capabilities.auth`).** An auth-capable plugin can log a -visitor in against an external IdP (OIDC/SAML). Handle the IdP callback / SAML -ACS in a `webhook`, validate the token yourself, then set the reserved -`X-Kandev-Auth-Login` response header to a JSON object -`{"provider","subject","email","display_name"}`. Kandev maps it to a user -(link-by-email or just-in-time member provisioning), mints the session, and sets -the `kandev_session` cookie itself — your plugin never handles the raw token, -and any `Set-Cookie` you return is dropped. Requires authentication enabled; -emitting the header without `capabilities.auth` returns 403. - -**You MUST only assert an `email` the IdP has verified as owned by `subject`.** -Kandev auto-links that email to (or provisions) an account, so an unverified or -user-settable email claim is an account-takeover vector. Kandev refuses to -auto-link to an admin account as defense-in-depth, but it cannot tell a verified -email from an unverified one — that is on your plugin. See ADR 0050. - -**Writable data directory.** Kandev injects `KANDEV_PLUGIN_DATA_DIR` into -every spawned plugin subprocess — a per-plugin writable directory -(`~/.kandev/plugins//data`) for anything you'd rather keep on disk than -in `Host` state. - ```go -func (p *myPlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error { - host := p.Host() - if host == nil { - return nil // broker dial still in progress - } - // Read the current count (found == false the first time), then write it back + 1. - value, _, err := host.GetState(ctx, "instance", "", "count") - if err != nil { - return err - } - n, _ := value["n"].(float64) // JSON numbers decode as float64 - return host.SetState(ctx, "instance", "", "count", map[string]any{"n": n + 1}) +import ( + "context" + "errors" +) + +type plugin struct{ pluginsdk.UnimplementedPlugin } + +func (p *plugin) OnEvent(ctx context.Context, event *pluginsdk.Event) error { + if event == nil || event.EventID == "" { return errors.New("event ID is required") } + host := p.Host() + if host == nil { return errors.New("plugin host unavailable") } + value, found, err := host.GetState(ctx, "instance", "", "events") + if err != nil { return err } + if !found { value = map[string]any{} } + processed, _ := value["processed_event_ids"].(map[string]any) + if processed == nil { processed = map[string]any{} } + if _, seen := processed[event.EventID]; seen { return nil } + count := 0.0 + if found { count, _ = value["count"].(float64) } + processed[event.EventID] = true + value["processed_event_ids"] = processed + value["count"] = count + 1 + return host.SetState(ctx, "instance", "", "events", value) } ``` -## Optional: native UI +The durable EventID claims make retries no-ops. Delivery is sequential per +plugin, while Host state itself has no transaction primitive; keep the claim +and counter in one state update and bound or prune old claims in a long-lived +plugin. -A plugin may ship `ui.bundle` in its manifest: a **hand-written, no-build** -plain-JS ES module. There is no bundler step — kandev serves the file -verbatim from the extracted package directory, so you edit the bundle and -repackage. +### 3. Task panel with task-scoped Host state -![A native plugin route rendered inside kandev: a sidebar nav item the plugin registered, the host's page title bar, and page content reading live task data from the shared app store.](../screenshots/plugin-native-page.png) - -The single entry point: the bundle, once evaluated, calls -`window.registerKandevPlugin(id, { initialize(registry, host), destroy?() })`. -Kandev's frontend host imports the bundle on boot (or on runtime enable), -then calls `initialize(registry, host)`. On disable/uninstall it calls -`destroy?.()` and bulk-revokes every registration the plugin made. - -**Registry surface** (`registry: PluginRegistry`, passed to `initialize`): - -```ts -interface PluginRegistry { - // Top-level SPA route, exact-match against window.location path. The host - // wraps the page in kandev's title bar by default; configure or opt out - // via options.topbar. - registerRoute(path: string, Component: React.ComponentType, options?: PluginRouteOptions): void; - // Sidebar/main nav entry, rendered by . - registerNavItem(item: NavItem): void; - // Route under /settings/plugins/{id}/..., rendered inside the settings shell. - registerSettingsRoute(path: string, Component: React.ComponentType): void; - // Named slot injection. Initial slots: "task-sidebar", "settings-nav", - // "main-nav-footer", "chat-input-actions", "chat-top-bar", "main-top-bar", - // "app-status-bar-left", "app-status-bar-right", and "plugin-settings" - // (see "Named slots" below). - registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void; - // WS action handler, bridged into the existing lib/ws dispatch. - registerWsHandler(action: string, handler: (payload: unknown) => void): void; - // Bind a handler to a keybinding declared in this plugin's manifest - // (ui.keybindings[].id). See "Keybindings" below. - registerKeybinding(id: string, handler: (event: KeyboardEvent) => void): void; -} - -interface NavItem { - id: string; - label: string; - path: string; - // Curated icon name (see lib/plugins/icons.ts); unknown names render the puzzle glyph. - icon?: string; - // "main" (default): top-level sidebar entry. "integrations": renders inside - // the sidebar's Integrations section alongside first-party integration links. - // Both also render in the phone menu sheet, so plugin pages stay reachable - // when the desktop sidebar is hidden. - // "settings" is accepted by the type but not rendered by any sidebar section - // today — for a settings page use registerSettingsRoute instead. - section?: "main" | "settings" | "integrations"; -} - -interface PluginRouteOptions { - // Kandev-style title bar above the page. Default: enabled with a derived - // title (from the matching nav item, else the plugin's display name). - // Pass a PluginPageChrome to configure it, or `false` for a full-bleed - // page that owns its own chrome (e.g. with host.ui.PageTopbar). - topbar?: boolean | PluginPageChrome; -} - -interface PluginPageChrome { - title?: string; - subtitle?: string; - icon?: string; // same curated set as NavItem.icon - backHref?: string; // default "/" - backLabel?: string; // default "Kandev" - actions?: React.ComponentType; // rendered on the right side of the topbar -} -``` - -**Host API** (`host: PluginHostApi`, passed to `initialize`): - -```ts -interface PluginHostApi { - pluginId: string; - React: typeof import("react"); // shared host React instance — MUST use this, never bundle your own React - jsx: typeof React.createElement; // convenience alias - store: { // kandev's live app store — read access is the common - getState(): AppState; // case; setState is exposed but writes affect the whole SPA - setState(partial): void; - subscribe(listener): () => void; - }; - api: { - // fetch scoped to /api/plugins/{id}/...; relayed to your webhook handler. - fetch(path: string, init?: RequestInit): Promise; - // Backend API origin ("" when the SPA and API share an origin) — lets a - // plugin reach first-party kandev REST endpoints directly. - baseUrl: string; - }; - ui: Record; // curated @kandev/ui subset — see below - theme: "light" | "dark"; - // Soft SPA navigation (history push/replace), same as the app's own router. - navigate(href: string, options?: { replace?: boolean }): void; - // Imperatively opens a modal window. See "Modal windows" below. - openModal(options: PluginModalOptions): PluginModalHandle; -} - -interface PluginModalOptions { - title?: string; // rendered in DialogHeader/DialogTitle; omit for no title - content: React.ComponentType; // reuses the slot-component contract - size?: "sm" | "md" | "lg" | "xl"; // default "md" - dismissible?: boolean; // overlay click / Escape; default true -} - -interface PluginModalHandle { - close(): void; // no-op if already closed -} -``` - -A plugin bundle must render with `host.React` / `host.jsx` — bundling your -own React copy breaks hook identity against the host tree. - -`host.ui` is a curated `@kandev/ui` subset — Alert, Badge, Button, Card, -Checkbox, Dialog, DropdownMenu, Input, Label, Pagination, ScrollArea, Select, -Separator, Sheet, Skeleton, Spinner, Switch, Table, Tabs, Textarea, Tooltip -(each with their compound sub-parts, e.g. `DialogContent`, `TableRow`) — plus -first-party app UI: `Combobox` (the app's picker), `PageTopbar` (the title -bar a route gets by default via `registerRoute`'s `options.topbar`, exposed -here for routes that opt out and render their own chrome), and -`TaskCreateDialog`, so a plugin can hand off task creation to kandev's real -create-task flow (repo/branch/agent pickers, validation) instead of POSTing -directly. See `apps/web/lib/plugins/host-api.ts` for the exact current list. - -### Modal windows - -`host.openModal(options)` imperatively opens a host-owned Dialog — rendered -by a `` mounted once at the app root, isolated behind its -own error boundary, and auto-closed if the plugin is disabled or uninstalled -while it's open. It's independent of keybindings: call it from a keybinding -handler, a nav route, a slot component, or a WS handler. It complements the -declarative `host.ui.Dialog` — reach for `host.ui.Dialog` when a dialog is -embedded in a slot's own render tree, and `host.openModal` when you need to -pop one open imperatively from anywhere in your plugin's code. - -```js -const handle = host.openModal({ - title: "Acme settings", - content: SettingsPanel, - size: "lg", -}); - -// later, e.g. after the panel calls back on save: -handle.close(); -``` - -
- -## Named slots - -
-Named slot details - -`registerComponent(slot, Component)` injects a component into a host-defined -slot. The host renders every plugin's component for that slot (each isolated -behind an error boundary), so a slot may hold contributions from several -plugins at once. Available slots: - -| Slot | Where it renders | `slotProps` | -| --- | --- | --- | -| `task-sidebar` | Bottom of the task-detail sidebar | — | -| `settings-nav` | Settings navigation tree | — | -| `main-nav-footer` | Footer of the main sidebar | — | -| `chat-input-actions` | Chat composer toolbar, beside the model picker, mic, and send button | `{ taskId, taskTitle, activeSessionId, sessionIds }` | -| `chat-top-bar` | Session top bar, beside the CPU/DB metrics and the document/editor/debug controls | `{ taskId, taskTitle, workspaceId, activeSessionId, sessionIds }` | -| `main-top-bar` | Default app top bar (Home / Kanban / Tasks), beside the CPU/DB metrics and the view/display controls | `{ workspaceId, workspaceLabel, currentPage }` | -| `app-status-bar-left` | Default-left item in the global status surface | `AppStatusBarSlotProps` | -| `app-status-bar-right` | Default-right item in the global status surface | `AppStatusBarSlotProps` | -| `plugin-settings` | A plugin's own settings page (**Settings > Plugins > ``**), at the top above the settings form | `{ pluginId, status }` | - -`plugin-settings` is the one exception to "every plugin's component renders": -it is **owner-scoped**, so the host renders only the component registered by the -plugin whose settings page is being viewed. See "Plugin settings page" below. - -### Chat toolbar actions - -Register a `chat-input-actions` component to add an icon button to the chat -composer toolbar. The host passes the current context as `slotProps`: - -```ts -type ChatInputActionsSlotProps = { - taskId: string | null; - taskTitle?: string; - activeSessionId: string | null; // session the composer is bound to - sessionIds: string[]; // every kandev session id on the task -}; -``` - -A task can hold several sessions, so both the active session and the full -`sessionIds` list are provided. These are **kandev** session ids — resolving one -to an agent/ACP transcript id (for example, to key per-session cost data from a -tool like tokscale) is your plugin's job. Do that **server-side in your plugin -backend** via the Host data API (`host.Sessions()` exposes each session's -`ACPSessionID`), not in the bundle: propagate the ids from the UI to your -backend over `host.api.fetch(...)`, and let the backend do the matching. - -```js -function makeChatAction(host) { - const { jsx: h, ui } = host; - const { Button, Tooltip, TooltipTrigger, TooltipContent } = ui; - - return function ChatAction({ slotProps }) { - const { taskId } = slotProps ?? {}; - return h( - Tooltip, - null, - h( - TooltipTrigger, - { asChild: true }, - h( - Button, - { - type: "button", - variant: "ghost", - size: "icon", - className: "h-7 w-7 cursor-pointer hover:bg-muted/40", - "aria-label": "Open plugin page", - onClick: () => host.navigate("/hello-world"), - }, - /* an icon element built with host.jsx */ myIcon(h), - ), - ), - h(TooltipContent, null, taskId ? `Task: ${taskId}` : "Plugin action"), - ); - }; -} - -// inside initialize(registry, host): -registry.registerComponent("chat-input-actions", makeChatAction(host)); -``` - -Match the first-party toolbar buttons: `Button` from `host.ui` with -`variant="ghost"`, `size="icon"`, `h-7 w-7`, `cursor-pointer`, and a 16px -(`h-4 w-4`) icon. Wrap it in `host.ui.Tooltip` so it reads like the native -mic/attach controls. `kandev-plugin-hello/ui/bundle.js` ships a working -example. - -### Session top bar - -Register a `chat-top-bar` component to surface at-a-glance status in the -session top bar, beside first-party document/editor/debug controls. The host passes the current context as -`slotProps`: - -```ts -type ChatTopBarSlotProps = { - taskId: string | null; - taskTitle?: string; - workspaceId: string | null; - activeSessionId: string | null; // session the top bar is bound to - sessionIds: string[]; // every kandev session id on the task -}; -``` - -Like `chat-input-actions`, both the active session and the full `sessionIds` -list are provided (see the note above about resolving kandev session ids to -ACP transcript ids server-side). The top bar is a compact horizontal strip, so -keep contributions to small badges or `h-7` buttons that match the native -metric chips. - -```js -// inside initialize(registry, host): -registry.registerComponent("chat-top-bar", makeTopBarStatus(host)); -``` - -### Default app top bar - -Register a `main-top-bar` component to add status or a small action to the -**default app top bar** — the strip across the Home, Kanban, and Tasks views, -beside the CPU/DB metrics and the view/display controls. This is the app-wide, -task-agnostic counterpart to `chat-top-bar`: use it for something that isn't -tied to one session (a workspace-level indicator, a global quick action). The -host passes: - -```ts -type MainTopBarSlotProps = { - workspaceId: string | null; // workspace the top bar is showing, null on global home - workspaceLabel?: string; // human-readable workspace name, when known - currentPage: "kanban" | "tasks"; -}; -``` - -Because the bar is not scoped to a task, no task/session ids are provided. Like -`chat-top-bar` it is a compact horizontal strip, so keep contributions to small -badges or `h-7` buttons that match the native metric chips. - -```js -// inside initialize(registry, host): -registry.registerComponent("main-top-bar", makeAppBarStatus(host)); -``` - -### Plugin settings page - -Register a `plugin-settings` component to render your own UI inline on your -plugin's settings page (**Settings > Plugins > ``**), at the top above -the schema-driven settings form. Use it for live integration health — e.g. "CLI -installed ✅ v0.45.2" or "API token ✅ authenticated" — or any custom controls -alongside the config form. The host passes: - -```ts -type PluginSettingsSlotProps = { - pluginId: string; // the plugin whose settings page is being viewed (always yours) - status: "registered" | "active" | "error" | "disabled" | "uninstalled"; -}; -``` - -Unlike the other slots, `plugin-settings` is **owner-scoped**: the host renders -only the component registered by the plugin currently being viewed, so your card -appears on your own settings page and never on another plugin's — you do **not** -need to gate on `slotProps.pluginId` yourself. The host provides no wrapper card, -so your component owns its own card and can render `null` when it has nothing to -show. - -```js -// inside initialize(registry, host): -registry.registerComponent("plugin-settings", makeSettingsStatus(host)); -``` - -### Global Status bar - -Register `app-status-bar-left` or `app-status-bar-right` for app-wide, compact -status UI. Kandev mounts exactly one presentation: a 24 px bar on tablet and -desktop, or an in-flow Status drawer section on phone. Keep bar content small; -render a touch-usable row when `presentation` is `"mobile-drawer"`. - -```js -function StatusContribution({ slotProps }) { - const { placement, presentation, activeTaskId } = slotProps ?? {}; - return host.jsx( - "span", - { className: presentation === "bar" ? "truncate text-xs" : "block min-h-11 px-3 py-2" }, - `${placement}: ${activeTaskId ?? "no active task"}`, - ); -} - -registry.registerComponent("app-status-bar-left", StatusContribution); -registry.registerComponent("app-status-bar-right", StatusContribution); -``` - -Each contribution receives this exact context: - -```ts -type AppStatusBarSlotProps = { - placement: "left" | "right"; - presentation: "bar" | "mobile-drawer"; - density: "full" | "compact"; - pathname: string; - activeWorkspaceId: string | null; - activeTaskId: string | null; - activeSessionId: string | null; -}; -``` - -The IDs are hints; use `host.store` for full records. Each component registration -is one opaque item: Kandev does not inspect or separately reorder its children. -The slot chooses the default side. A user can Cmd-drag (macOS) or Ctrl-drag -(other desktop platforms) with a mouse across the full bar, and Kandev preserves -that backend-owned order across reloads, restarts, and plugin disable/enable. -Phone lists the saved left sequence followed by the saved right sequence and does -not offer drag ordering. There is no keyboard-arrow, touch, or plugin-priority -ordering API. Enable, disable, and uninstall update the live surface without a -reload, and each contribution has its own error boundary. A full-bleed route -(`topbar: false`) owns its own chrome; mount the host Status trigger there if that -route should expose Status. - -## Keybindings - -Declare each keybinding in `manifest.yaml`'s `ui.keybindings` (requires -`ui.bundle`), then bind a handler at runtime with `registry.registerKeybinding`: +There is no registerTaskPanel or per-user host.storage today. Use the mounted +task-sidebar slot for the UI, read the active task id from host.store, send it to +the Go backend, and store the value under the task scope. ```yaml -ui: - bundle: "/ui/bundle.js" - keybindings: - - id: "open-panel" # plugin-local slug: ^[a-z0-9][a-z0-9-]*$ - default: "mod+shift+j" # combo grammar, see below - description: "Open the Acme panel" +capabilities: + state: true +webhooks: + - key: "task-note" + method: "POST" ``` ```js -registry.registerKeybinding("open-panel", () => { - host.openModal({ title: "Acme panel", content: AcmePanel }); +registry.registerComponent("task-sidebar", () => { + const taskId = host.store.getState().tasks.activeTaskId; + return host.jsx("button", { + onClick: () => host.api.fetch("webhooks/task-note", { + method: "POST", + body: JSON.stringify({ taskId }), + headers: { "content-type": "application/json" }, + }), + }, "Save task note"); }); ``` -The `default` combo is `+`-separated: zero or more modifiers from -`mod | ctrl | cmd | meta | alt | option | shift` (`mod` resolves to ⌘ on -macOS and Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not -combine with a digit or symbol key — the browser reports the shifted glyph -for those keys (e.g. `shift+1` reports `!`), so the combo could never match. - -A plugin keybinding is **user-overridable** at **Settings > Keyboard -Shortcuts**, namespaced `plugin:{pluginId}:{id}` so two plugins can each -declare their own `open-panel` id without colliding. If a plugin's effective -combo conflicts with a core kandev shortcut, the core shortcut always wins — -the plugin handler is skipped for that combo — and the conflict is surfaced -in that same settings page so the user can rebind it. - -
- -## Three integration patterns - -Both example repos are full, working plugins — read them rather than -copy-pasting fragments: - -1. **Event handling with Host state** (`kandev-plugin-hello/server/plugin.go`). - `OnEvent` receives `task.created` deliveries (declared in - `capabilities.events`) and increments a persistent counter via - `Host.GetState`/`SetState`, scoped `instance`. Demonstrates the full - Host state round trip and idempotent-by-design event handling. - -2. **Live host-store reads from native UI** - (`kandev-plugin-hello/ui/bundle.js`, `useKanbanTasks`). A plugin page - subscribes directly to `host.store` and re-renders on every store change - — reading the exact same live state the first-party kanban UI renders - from, no polling and no extra API calls. - -3. **Webhook-as-relay + `TaskCreateDialog`** - (`kandev-plugin-github/server/plugin.go` and `ui/bundle.js`). The backend - declares a `prs` webhook and, on `HandleWebhook`, shells out to the `gh` - CLI (inheriting the plugin subprocess's own environment for GitHub auth) - and returns JSON. The UI page calls `host.api.fetch("webhooks/prs")` — - which kandev relays over gRPC `HandleWebhook` to the same handler — to use - an external CLI as a same-origin data source without a webhook secret. - Each PR row opens `host.ui.TaskCreateDialog` prefilled from the PR, - reading `workspaceId`/`workflowId`/`steps` from the live host store. - -## Packaging - -
-Packaging, publishing, and iteration - -A plugin ships as `-.tar.gz`: - -``` -manifest.yaml # authoritative; read BEFORE any code runs -server/plugin--[.exe] # any subset of platforms declared in runtime.executables -ui/bundle.js # optional -ui/*.css / assets/icon.svg # optional -checksums.txt # generated — do not author this file -checksums.txt.sig # optional ed25519 signature +```go +// In HandleWebhook, after authenticating/validating the request: +host := p.Host() +if host == nil { return &pluginsdk.WebhookResponse{Status: http.StatusServiceUnavailable}, nil } +return &pluginsdk.WebhookResponse{Status: http.StatusNoContent}, host.SetState( + ctx, "task", taskID, "note", map[string]any{"text": note}, +) ``` -Cross-compile one executable per platform you declare in -`runtime.executables`, then pack the staged directory with -`github.com/kandev/kandev/cmd/plugin-pack`: +This is task-scoped plugin state, not a per-user browser preference. If the +behavior truly needs user identity, request a future Host API rather than +guessing at one. -``` -plugin-pack -dir -out -.tar.gz [-platform-only] +### 4. Webhook receiver + +Declare the route, validate the method and provider signature inside the +backend, and return a bounded status/body. Kandev caps incoming bodies at 4 MiB +but does not authenticate the caller. + +```yaml +webhooks: + - key: "provider-events" + description: "Provider event callback" + method: "POST" # informational; enforce it in HandleWebhook ``` -`plugin-pack` walks the directory, requires `manifest.yaml` to be present, -and **generates `checksums.txt`** covering every other file — pre-supplying -either checksum file yourself is a packaging error. `-platform-only` -restricts the package to the current host's platform, for faster local -iteration than a full multi-platform build. - -A sample `Makefile` (trimmed from `kandev-plugin-hello/Makefile`): - -```makefile -STAGE := .build/stage -VERSION := 1.0.0 -PKG_OUT := my-plugin-$(VERSION).tar.gz - -package: - rm -rf $(STAGE) - mkdir -p $(STAGE)/server - cp manifest.yaml $(STAGE)/manifest.yaml - cp -r ui $(STAGE)/ui - GOOS=linux GOARCH=amd64 go build -o $(STAGE)/server/plugin-linux-amd64 ./server - GOOS=linux GOARCH=arm64 go build -o $(STAGE)/server/plugin-linux-arm64 ./server - GOOS=darwin GOARCH=amd64 go build -o $(STAGE)/server/plugin-darwin-amd64 ./server - GOOS=darwin GOARCH=arm64 go build -o $(STAGE)/server/plugin-darwin-arm64 ./server - GOOS=windows GOARCH=amd64 go build -o $(STAGE)/server/plugin-windows-amd64.exe ./server - go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) - rm -rf $(STAGE) - -# Faster local loop: host platform only. -package-host: - rm -rf $(STAGE) - mkdir -p $(STAGE)/server - cp manifest.yaml $(STAGE)/manifest.yaml - cp -r ui $(STAGE)/ui - go build -o $(STAGE)/server/plugin-$$(go env GOOS)-$$(go env GOARCH)$$(go env GOEXE) ./server - go run github.com/kandev/kandev/cmd/plugin-pack -dir $(STAGE) -out $(PKG_OUT) -platform-only - rm -rf $(STAGE) +```go +func (p *plugin) HandleWebhook(ctx context.Context, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) { + if req.WebhookKey != "provider-events" || req.Method != http.MethodPost { + return &pluginsdk.WebhookResponse{Status: http.StatusNotFound}, nil + } + if !validProviderSignature(req.Headers, req.Body) { + return &pluginsdk.WebhookResponse{Status: http.StatusUnauthorized}, nil + } + // Decode, deduplicate, and enqueue the event before returning. + return &pluginsdk.WebhookResponse{Status: http.StatusAccepted}, nil +} ``` -Install the resulting tarball via **Settings > Plugins** (upload) or: +### 5. Event subscriber + +Subscribe to exact subjects or one-segment wildcards and make processing +idempotent by EventID. + +```yaml +capabilities: + events: ["task.created", "task.state_changed"] +``` + +```go +func (p *plugin) OnEvent(ctx context.Context, event *pluginsdk.Event) error { + // Apply the durable EventID claim pattern from the Host-state recipe before + // performing side effects. + if event.EventType == "task.created" { return p.handleTask(ctx, event.Payload) } + return nil +} +``` + +For critical workflows, periodically reconcile from host.Tasks() or another +Host reader because event queues are bounded and delivery is best-effort. + +### 6. Kanban-aware contribution + +There is no Kanban-card injection hook in this branch. Use a mounted top-bar +contribution or a route that reads the live app store, and link to a task route +when a card is selected. + +```js +registry.registerComponent("main-top-bar", ({ slotProps }) => { + const state = host.store.getState(); + const count = state.kanban && state.kanban.tasks ? state.kanban.tasks.length : 0; + return host.jsx("span", { title: "View: " + (slotProps && slotProps.currentPage) }, count + " tasks"); +}); +``` + +For a card-specific action, use task-sidebar on the task detail or a route with +host.store.subscribe; do not patch first-party Kanban components. + +## Build, package, install, and test + +### Plugin repository + +From the plugin repository created from the official template: ```bash -curl -F "package=@my-plugin-1.0.0.tar.gz" http://localhost:38429/api/plugins/install +go test ./... +go vet ./... +make package-host # local platform, fastest loop +make package # all platforms declared by the template ``` -To show a custom card icon in the marketplace, ship an image in the package -(e.g. `assets/icon.svg`) and point the manifest's [`icon` -field](plugins-manifest.md#field-reference) at its package-relative path. +The template's package target stages manifest.yaml, server/, ui/, and assets, +then runs the monorepo plugin-pack command. If using a custom Makefile, the +equivalent is: -## Publishing to the marketplace +```bash +rm -rf .build/stage +mkdir -p .build/stage/server +cp manifest.yaml .build/stage/manifest.yaml +if [ -d ui ]; then cp -R ui .build/stage/ui; fi +if [ -d assets ]; then cp -R assets .build/stage/assets; fi +go build -o .build/stage/server/plugin-$(go env GOOS)-$(go env GOARCH) ./server +go run github.com/kandev/kandev/cmd/plugin-pack \ + -dir .build/stage \ + -out acme-dashboard-0.1.0.tar.gz \ + -platform-only +``` -To make your plugin discoverable and one-click installable from inside kandev, -publish the `-.tar.gz` as a GitHub **Release** asset and either -open a PR listing your repo in the official catalog or host your own source. A -separate release-level `checksums.txt` is optional; the checksum manifest inside -the package is mandatory. See [Plugin marketplace → -Publishing](plugins-marketplace.md#publishing-a-plugin). +plugin-pack requires manifest.yaml, rejects pre-supplied checksum files, and +generates checksums.txt. It is a packager, not an exhaustive semantic or +JavaScript validator: it does not execute the plugin or prove every referenced +UI asset is valid. -## Iterate loop +### In-tree fixture validation -- **UI-only or backend-only change:** edit the source, repackage - (`make package-host` for a fast host-only build), then reinstall. There is - no kandev rebuild needed for plugin-only changes — the extracted package - and running subprocess are entirely separate from the kandev binary. -- **`registry`/`host.ui`/host-core changes:** these live inside kandev - itself (`apps/web/lib/plugins/`), not the plugin package — changing them - requires a kandev build (`make build-web` / `make build-backend`), not a - plugin repackage. -- **Reinstalling the same version is rejected.** `pkgtar.Install` fails with - `ErrVersionExists` (HTTP 409) if `~/.kandev/plugins///` - already exists. To pick up a rebuilt package during iteration, either bump - `version` in `manifest.yaml` or uninstall the plugin first. +The monorepo provides a maintained package smoke path without a production +instance: -## Gotchas +```bash +make -C apps/backend e2e-plugin-package +cd apps/backend +go test ./cmd/plugin-pack ./cmd/plugin-fixture ./internal/plugins/manifest \ + ./internal/plugins/pkgtar ./pkg/pluginsdk +``` -- **`ui.bundle` and `ui.styles` are root-relative paths**, e.g. - `/ui/bundle.js` — not `ui/bundle.js`. Kandev serves them from - `GET /api/plugins/{id}/bundle` and `GET /api/plugins/{id}/ui/*`, resolved - against the extracted package directory. -- **Plugin pages are "unknown" SPA routes, but still get the full app - shell.** `registerRoute` paths are resolved after every static/nested - first-class route and before the kanban catch-all — a plugin can't shadow - a built-in route, but its page still renders inside the normal boot-payload-hydrated - app shell (workspaces, settings, theme), not a bare unstyled page. -- **Version bump or uninstall required to reinstall** — see "Iterate loop" - above. -- **Windows executables need the `.exe` suffix** in both the built file name - and the `runtime.executables` manifest value (e.g. - `windows-amd64: server/plugin-windows-amd64.exe`) — kandev does not append - it for you. +The fixture build proves the current SDK/package shape and produces a +host-platform archive. pkgtar.Install is the install-time authority for manifest +validity, safe archive paths, checksums, managed runtime, and the host +executable. It also extracts the package; it does not run the plugin or perform +a full browser/module build. -Related: [Plugins](plugins.md), [Plugin manifest reference](plugins-manifest.md). +### Disposable-instance smoke test -
+Install the archive through Settings > Plugins or the local API: + +```bash +curl -F "package=@acme-dashboard-0.1.0.tar.gz" \ + http://localhost:38429/api/plugins/install +``` + +Then enable it, open each registered route, exercise every declared webhook or +event, verify denied capabilities fail as expected, and disable/re-enable to +confirm destroy and state preservation. Uninstall only in a disposable +instance; it removes the plugin package and KANDEV_PLUGIN_DATA_DIR. + +Reinstalling the same id/version is rejected. Bump version or uninstall before +repackaging. + +| Layer | Command/source | Checks | Does not check | +| --- | --- | --- | --- | +| Source | go test, go vet, template lint/build | Backend compilation and tests | Package paths or browser runtime | +| Package | plugin-pack | Manifest presence, safe staging, generated checksums | Full manifest semantics, JS execution, live Host calls | +| Install | pkgtar.Install via upload/sideload | Manifest validation, archive safety, checksums, managed runtime, host executable | Plugin behavior or UI rendering | +| Runtime | Disposable instance smoke test | Subprocess, Host permissions, events, webhooks, UI cleanup | Production-scale load/provenance | + +## Common mistakes + +- **Missing the leading slash:** ui.bundle and ui.styles must be root-relative + (/ui/bundle.js, /ui/plugin.css), not ui/bundle.js. +- **Forgetting capabilities:** every Host state, secret, data-reader, write, or + utility-agent call fails with PermissionDenied unless its manifest capability + is present. +- **Confusing plugin_state with host.storage:** Host state is backend, + plugin-scoped JSON; host.storage does not exist in this branch. +- **Writing outside KANDEV_PLUGIN_DATA_DIR:** arbitrary files belong below the + injected per-plugin directory; do not write beside the Kandev database or into + another plugin's directory. +- **Leaving stale frontend registrations:** use destroy, unsubscribe store/WS + listeners, clear timers, and rely on host bulk revocation after disable or + uninstall. +- **Importing direct DB/internal packages:** use apps/backend/pkg/pluginsdk and + typed Host methods. Direct database access is outside the plugin contract. +- **Trusting webhook metadata:** webhooks[].method is informational and the route + is not authenticated by Kandev. Validate method, signature, timestamp, replay + protection, and body before side effects. +- **Bundling React:** use host.React, host.jsx, and host.ui; a second React or + Radix copy breaks shared contexts and portals. +- **Shipping the wrong binary name:** every declared executable must be under + server/, match its - key, and include .exe on Windows. + +Related: [Plugins](plugins.md), [Plugin manifest reference](plugins-manifest.md), +[Plugin marketplace](plugins-marketplace.md), and [Extending Kandev](extending-kandev.md). diff --git a/docs/public/plugins-manifest.md b/docs/public/plugins-manifest.md index 7c008d84c2..65d1488f3d 100644 --- a/docs/public/plugins-manifest.md +++ b/docs/public/plugins-manifest.md @@ -46,7 +46,7 @@ min_kandev_version: "0.78.0" # optional capabilities: events: ["task.created", "task.state_changed", "agent.completed"] api_read: ["tasks", "agent_profiles"] # gates the Host data-reader RPCs - api_write: ["tasks"] # reserved, no Host RPC enforces this yet + api_write: ["tasks"] # gates Tasks().Create/Update state: true secrets: true agent_invoke: true # gates Host.InvokeUtilityAgent @@ -103,7 +103,7 @@ ui: # optional native frontend plugin | `min_kandev_version` | no | string | Optional advisory; not currently enforced by the installer. | | `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. | | `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. | -| `capabilities.api_write` | no | string[] | **Reserved for future Host RPCs.** Declared but not enforced by anything today — no Host RPC currently writes kandev's own data. | +| `capabilities.api_write` | no | string[] | Gates Host data writes. Current resources are `tasks` (`Tasks().Create/Update`) and `messages` (`Messages().Send`); reads and writes are gated independently. | | `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | | `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. | | `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent` — a one-shot completion run by the utility agent selected for this plugin. Declare a `utility_agent` config property with `type: string` and `format: utility-agent`; Settings > Plugins renders the picker. Calling without this capability returns gRPC `PermissionDenied`; calling without a valid enabled selection returns gRPC `FailedPrecondition`. See ADR 0048. | @@ -115,19 +115,20 @@ ui: # optional native frontend plugin | `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > ``** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. | | `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. | | `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `` tags on load. | -| `ui.pages` | no | object[] | Optional declarative page metadata. Secondary to `ui.bundle` — a native bundle registers its own routes/nav at runtime, so most plugins omit `ui.pages`. | +| `ui.pages` | no | object[] | Optional declarative metadata accepted by the manifest model. The current frontend does not render these entries; a native bundle registers its supported routes/nav/slots at runtime, so most plugins omit `ui.pages`. | | `ui.pages[].key` | yes* | string | Stable identifier for the page (*required when a page entry is present). | | `ui.pages[].title` | yes* | string | Display title. | | `ui.pages[].path` | yes* | string | Route path for the page. | -| `ui.pages[].surface` | yes* | string | Where the page mounts. Enum, one of: `settings` · `task-panel` · `main-nav`. Any other value is a validation error. | +| `ui.pages[].surface` | yes* | string | Metadata enum (`settings` · `task-panel` · `main-nav`) validated by the manifest parser. It is not a current frontend mount; use the registry hooks in the authoring guide. | | `ui.keybindings` | no | object[] | Declares plugin keybindings bound at runtime via `registerKeybinding`. Requires `ui.bundle`. | | `ui.keybindings[].id` | yes* | string | Stable, plugin-local slug (*required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]*$` and be unique within this plugin's own `ui.keybindings` list — not globally; the effective shortcut is namespaced `plugin:{pluginId}:{id}`. | | `ui.keybindings[].default` | yes* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key — the browser reports the shifted glyph for those keys, so the combo could never match. | | `ui.keybindings[].description` | yes* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. | -`ui.pages` is declarative manifest metadata only. A native bundle's runtime -nav items, icons, and per-route title-bar chrome (`registerNavItem`, -`registerRoute`'s `options.topbar`) are a separate JS SDK surface with no +`ui.pages` is declarative manifest metadata only and is not currently rendered +by the frontend. A native bundle's runtime nav items, icons, routes, named +slots, and per-route title-bar chrome (`registerNavItem`, `registerRoute`, and +`registerComponent`) are the supported JS SDK surface with no corresponding `manifest.yaml` field — see [Authoring a plugin](plugins-authoring.md). ## Managed vs. legacy manifests @@ -157,8 +158,10 @@ resource grants the matching `Host` accessor (`Tasks()`, `Sessions()`, `Messages()`, `Workspaces()`, `Workflows()`, `AgentProfiles()`, `Repositories()` — see [Authoring a plugin](plugins-authoring.md)); calling an accessor for an undeclared resource returns gRPC `PermissionDenied`. -`capabilities.api_write` reserves the same resource names for a future write -path — no write RPC exists yet, so declaring it currently has no effect. +`capabilities.api_write` gates the current Host data writes: `tasks` gates +`Tasks().Create`/`Update`, and `messages` gates `Messages().Send`. A plugin may +declare a read resource without its write capability, or vice versa. Calling a +write without the matching declaration returns gRPC `PermissionDenied`. `messages` reads historical **conversation content** (`Messages().List`): one user/agent message per row (`id`, `session_id`, `task_id`, `turn_id`, diff --git a/docs/public/plugins.md b/docs/public/plugins.md index 7ffe5c08ad..6ec4afd046 100644 --- a/docs/public/plugins.md +++ b/docs/public/plugins.md @@ -192,8 +192,8 @@ A plugin can also render its own UI inline on this page — at the top, above th settings form — via the `plugin-settings` slot, for example a live integration-health card ("CLI installed ✅ v0.45.2", "API token ✅ authenticated"). This is owner-scoped, so a plugin's card only ever appears on -its own settings page. See [Named -slots](plugins-authoring.md#named-slots) in the authoring guide. +its own settings page. See [supported named +slots](plugins-authoring.md#supported-named-slots) in the authoring guide.
Package signing, storage, and security details @@ -235,8 +235,8 @@ identically to an unsigned one; signing is not required in v1. it declared in its manifest: `state` gates the state RPCs, `secrets` gates the plugin-owned secret RPCs, and each read-only data accessor (tasks, sessions, workspaces, workflows, agent profiles, repositories) is gated - individually via `api_read:` (write access, `api_write:`, - is reserved but not yet implemented). An undeclared capability returns gRPC + individually via `api_read:` and `api_write:` for the + implemented task/message Host writes. An undeclared capability returns gRPC `PermissionDenied` with a message naming the missing capability, checked by a server interceptor before the handler runs. `GetConfig` and `EmitEvent` are the only ungated RPCs — a plugin can always read its own config diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 3d454ac901..eac62db2c4 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -40,6 +40,7 @@ Product-wide capabilities that are not tied to a single feature area. | [background-work-liveness](platform/background-work-liveness.md) | shipped | | [i18n](platform/i18n.md) | building | | [plugins](plugins/spec.md) | draft | +| [plugins — authoring experience](plugins/authoring-experience.md) | draft | | [plugins — marketplace](plugins/marketplace.md) | building | | [semantic-notifications](platform/notifications.md) | shipped | | [workspace-git-status](platform/workspace-git-status.md) | shipped | diff --git a/docs/specs/plugins/authoring-experience.md b/docs/specs/plugins/authoring-experience.md new file mode 100644 index 0000000000..a5069f1349 --- /dev/null +++ b/docs/specs/plugins/authoring-experience.md @@ -0,0 +1,126 @@ +--- +status: draft +created: 2026-08-02 +owner: kandev +--- + +# Plugin Authoring Experience + +## Why + +Plugin authors and coding agents cannot currently move from discovery to a +tested package without reconciling public tutorials, frozen contract notes, +frontend types, the Go SDK, the manifest implementation, packaging code, and +ADRs. Some of those references have drifted behind the shipped contract, which +makes otherwise copy-pasteable guidance unsafe. + +## What + +- `docs/public/plugins-authoring.md` is the canonical developer entry point and + is linked directly from the root, backend, and web `AGENTS.md` files. +- The entry point teaches one workflow: choose a supported recipe, edit + `manifest.yaml`, implement against the authoritative contract, run the + available validation, package, install, and smoke test. +- It explains the install-to-load lifecycle, package and per-plugin data layout, + backend/UI-focused/combined plugin shapes, capability boundary, and the rule + that plugins use Host APIs rather than Kandev databases or internal packages. +- A storage decision table distinguishes plugin-scoped Host state, the absence + of per-user `host.storage` in this branch, operator config and vault-backed + secrets, and files rooted under `KANDEV_PLUGIN_DATA_DIR`. +- A frontend matrix covers every registration and Host value currently exposed + by `apps/web/lib/plugins/types.ts` and `apps/web/lib/plugins/host-api.ts`. It + names every mounted component slot and records input, manifest requirement, + lifecycle/cleanup, and an example or authoritative source link. +- A backend matrix covers `Plugin.OnEvent`, `Plugin.HandleWebhook`, every + `pluginsdk.Host` method/resource accessor, the required capability, and the + relevant SDK/proto source. +- The guide explicitly says that `registerTaskPanel`, `registerTaskMenuAction`, + per-user `host.storage`, `RichTextEditor`, `RichTextReadOnly`, and direct + Kanban-card injection are not part of the current branch. It does not invent + future signatures. +- At least six current-contract recipes are runnable or link to maintained + source: a UI-focused plugin with the required no-op managed binary; a Go + backend using Host state; a `task-sidebar` contribution backed by task-scoped + Host state; a webhook receiver; an event subscriber; and a Kanban-aware route + that reads `host.store` without injecting into first-party cards. +- The reproducible workflow documents the exact checks already available in + the plugin template and monorepo, including their limits. Package creation + generates checksums; the install path performs manifest, checksum, archive, + extraction, and host-binary checks; the in-tree fixture package tests provide + the closest no-production-instance validation. This change does not add a + second validator or new runtime behavior. +- Existing plugin pages, contract notes, relevant README-style example docs, + and the `create-kandev-plugin` skill link back to the canonical entry point + instead of duplicating or contradicting the API. +- The plugin system remains marked experimental unless a separate product + stability decision changes the public status evidence. + +## Authoritative contracts + +This feature does not add plugin runtime hooks or Host RPCs. It documents the +current contract and points readers to: + +- Frontend pair: `docs/plans/plugins/PLUGIN-API.md` and + `apps/web/lib/plugins/types.ts`. +- Concrete frontend Host exports: `apps/web/lib/plugins/host-api.ts`. +- Backend author API: `apps/backend/pkg/pluginsdk`. +- Wire API: `apps/backend/proto/kandev/plugin/v1/plugin.proto`. +- Manifest model and semantics: `apps/backend/internal/plugins/manifest`. +- Package integrity and install semantics: + `apps/backend/internal/plugins/pkgtar`. + +The canonical guide summarizes those sources for discovery, but does not become +another type, proto, or schema definition. + +## Permissions and storage + +- Frontend bundles run in the Kandev origin and receive the current shared + React instance and curated host/store surface. Frontend registrations are not + individually capability-gated; `ui.bundle` and `ui.keybindings` declare the + relevant UI entry points. +- Host state requires `state`; secrets require `secrets`; reads require the + matching `api_read` resource; task/message writes require the matching + `api_write` resource; utility-agent invocation requires `agent_invoke`; and + event delivery requires a matching event subscription. +- `Host.GetConfig` and `Host.EmitEvent` are ungated. Webhook keys must be + declared. The external-auth response directive additionally requires `auth`. +- Plugins do not receive sanctioned direct database access. Domain reads and + writes go through typed Host RPCs and Kandev's service layer. +- Host state is plugin-scoped structured data and is not per-user browser + storage. Arbitrary plugin files belong below `KANDEV_PLUGIN_DATA_DIR`. + +## Scenarios + +- **GIVEN** an agent starts at a relevant `AGENTS.md`, **WHEN** it follows the + plugin-authoring link, **THEN** it reaches the canonical guide in one step and + sees the contract map and supported workflow. +- **GIVEN** a UI-focused plugin author, **WHEN** they follow the recipe, **THEN** + they include the no-op managed executable required by the current installer + and a native UI bundle without inventing unsupported backend behavior. +- **GIVEN** an author needs durable task-related state, **WHEN** they consult the + storage table and sidebar recipe, **THEN** they use task-scoped Host state and + do not mistake it for unavailable per-user `host.storage`. +- **GIVEN** a manifest declares `api_write: ["tasks"]`, **WHEN** an author reads + the capability matrix, **THEN** it documents `Tasks().Create/Update` as + implemented and links to the SDK/proto. +- **GIVEN** a package whose `ui.bundle` omits the leading slash, **WHEN** an + author follows the workflow, **THEN** the guide identifies that error before + install and explains which remaining checks happen during packaging and + install. +- **GIVEN** a disabled or uninstalled UI plugin, **WHEN** an author consults the + lifecycle matrix, **THEN** it explains `destroy`, bulk unregistration, modal + closure, stylesheet removal, and plugin-owned side-effect cleanup. +- **GIVEN** a request for a future task-panel, task-menu, per-user storage, + rich-text, or Kanban-card hook, **WHEN** an author checks the canonical page, + **THEN** it says the API is unavailable in this branch and points to the + closest supported recipe. + +## Out of scope + +- Adding a package checker or changing manifest, packaging, installation, + runtime supervision, capability enforcement, signing, or database behavior. +- Adding frontend hooks, per-user plugin storage, rich-text components, or + Kanban-card injection. +- Publishing or modifying external plugin repositories. +- Creating a second manifest, proto, TypeScript, or Go SDK definition in docs. +- Promoting the plugin system from experimental to stable. diff --git a/docs/specs/plugins/spec.md b/docs/specs/plugins/spec.md index bfb7629501..2acd4f1f15 100644 --- a/docs/specs/plugins/spec.md +++ b/docs/specs/plugins/spec.md @@ -126,8 +126,8 @@ config_schema: required: ["bot_token_secret", "default_channel"] ui: # Native frontend plugin (see "Frontend plugin runtime") - bundle: "ui/bundle.js" # ES module extracted alongside the package - styles: ["ui/plugin.css"] # optional stylesheets + bundle: "/ui/bundle.js" # root-relative ES module path + styles: ["/ui/plugin.css"] # optional root-relative stylesheets # Runtime fields managed by kandev (not authored): status: "active" @@ -506,8 +506,8 @@ components, and WebSocket handlers that run inside the kandev frontend (the Mattermost-webapp model), not iframes. The full contract lives in `docs/plans/plugins/PLUGIN-API.md`; summary: -- **Manifest:** a plugin declares `ui.bundle` (a path inside the extracted package, - e.g. `ui/bundle.js`) and optional `ui.styles`. +- **Manifest:** a plugin declares `ui.bundle` (a root-relative path inside the + extracted package, e.g. `/ui/bundle.js`) and optional root-relative `ui.styles`. - **Bundle delivery:** kandev serves the bundle at `GET /api/plugins/{id}/bundle` (and any assets under `GET /api/plugins/{id}/ui/*`) directly from the extracted package directory on local disk, forcing `Content-Type: text/javascript` and