From dcb4b6320da5120816459239a4e8b6fab6c98f86 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Thu, 6 Aug 2026 21:57:18 +0000 Subject: [PATCH 1/4] Keep plugin SDK declarations current so agents stop reading bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bb plugin new` seeds types/*.d.ts once and nothing ever refreshes them, so every plugin scaffolded against an older bb typechecks green against an API that has since moved. Local copies here ranged from 130 to 1,900 lines behind the current 12,868-line surface. With no trustworthy local source of truth, an agent looking for an exact signature falls back to grepping minified build output. Add `bb plugin types [path]`: it writes the running bb's @bb/plugin-sdk declarations into the plugin's types/, creating the directory when absent, and needs no server. `--check` reports staleness and exits non-zero without writing, for CI. `bb plugin build` and `bb plugin dev` run the same sync automatically; dev refreshes before its watcher starts so the write cannot feed the loop its own change event, and a failure warns rather than failing a build. The shared syncPluginTypes sits next to the scaffold that seeds the same files. It compares before writing, so a current plugin reports `unchanged` and keeps its mtime, and a headless plugin never gets a frontend declaration it did not ask for. The bb-plugin-authoring skill gains a "Looking up the exact API" section: run `bb plugin types`, read the .d.ts, clone the repo — with an explicit rule never to answer an API question from a built bundle. Guide chapter, bb-cli skill, and scaffold README updated to match. Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/src/commands/plugin.ts | 133 ++++++++++++++---- .../skills/builtin-skills/bb-cli/SKILL.md | 6 + .../bb-plugin-authoring/SKILL.md | 50 ++++++- .../src/generated/templates.generated.ts | 2 +- packages/templates/src/plugin-scaffold.ts | 97 ++++++++++++- .../src/templates/bb-guide-plugins.md | 11 +- .../templates/test/plugin-sync-types.test.ts | 104 ++++++++++++++ 7 files changed, 365 insertions(+), 38 deletions(-) create mode 100644 packages/templates/test/plugin-sync-types.test.ts diff --git a/apps/cli/src/commands/plugin.ts b/apps/cli/src/commands/plugin.ts index a9e86bb1f..b335c859a 100644 --- a/apps/cli/src/commands/plugin.ts +++ b/apps/cli/src/commands/plugin.ts @@ -14,11 +14,8 @@ import type { } from "@bb/server-contract"; import { installedPluginSchema } from "@bb/server-contract"; import { BbHttpError } from "@bb/sdk"; -import { - parseDataDirEnvValue, - resolveProdDataDir, -} from "@bb/config/runtime"; -import { scaffoldPlugin } from "@bb/templates/plugin-scaffold"; +import { parseDataDirEnvValue, resolveProdDataDir } from "@bb/config/runtime"; +import { scaffoldPlugin, syncPluginTypes } from "@bb/templates/plugin-scaffold"; import { action } from "../action.js"; import { cliFetch, createCliBbSdk } from "../client.js"; import { @@ -119,6 +116,52 @@ const pluginManifestSchema = z.object({ }); const secretSettingValueSchema = z.object({ set: z.boolean().optional() }); +/** + * Read a plugin directory's manifest. Returns null when the directory has no + * readable `package.json`, so callers can print their own guidance. + */ +async function readPluginManifest( + rootDir: string, +): Promise | null> { + try { + const raw: unknown = JSON.parse( + await readFile(join(rootDir, "package.json"), "utf8"), + ); + return pluginManifestSchema.parse(raw); + } catch { + return null; + } +} + +/** + * Refresh `types/*.d.ts` against this CLI's bundled SDK declarations and + * report each file that actually changed. + * + * `bb plugin build` and `bb plugin dev` call this so an author never + * typechecks against declarations older than the bb they run. A failure here + * is reported and swallowed: a read-only or otherwise unwritable `types/` + * must not fail a build. + */ +async function refreshPluginTypes( + rootDir: string, + hasApp: boolean, +): Promise { + let files: Awaited>; + try { + files = await syncPluginTypes({ rootDir, app: hasApp }); + } catch (error) { + console.warn( + `Could not refresh types/ — ${error instanceof Error ? error.message : String(error)}`, + ); + return; + } + const written = files.filter((file) => file.outcome === "written"); + if (written.length === 0) return; + console.log( + `Refreshed SDK declarations: ${written.map((file) => file.path).join(", ")}`, + ); +} + type PluginSettingDescriptor = z.infer; type PluginSettingsResult = z.infer; @@ -735,6 +778,52 @@ export function registerPluginCommands( }), ); + plugin + .command("types [path]") + .description( + "Write this bb's @bb/plugin-sdk declarations into the plugin's types/ directory (default: cwd); the authoritative, readable API surface for editors, tsc, and agents", + ) + .option("--check", "Report whether types/ is current; write nothing") + .action( + action(async (path: string | undefined, opts: { check?: boolean }) => { + const rootDir = resolve(process.cwd(), path ?? "."); + const manifest = await readPluginManifest(rootDir); + if (!manifest) { + console.error( + `No readable package.json in ${rootDir} — run from a plugin directory or pass its path.`, + ); + process.exit(1); + } + if (typeof manifest.bb?.server !== "string") { + console.error( + `${rootDir} is not a bb plugin — package.json has no "bb.server" entry.`, + ); + process.exit(1); + } + const hasApp = typeof manifest.bb.app === "string"; + const files = await syncPluginTypes({ + rootDir, + app: hasApp, + check: opts.check ?? false, + }); + for (const file of files) { + console.log(`${file.path} ${file.outcome}`); + } + if (opts.check) { + if (files.some((file) => file.outcome === "stale")) { + console.error( + "Declarations are out of date — run `bb plugin types` to refresh them.", + ); + process.exit(1); + } + return; + } + console.log( + "These declarations are the full plugin API — read them for exact signatures.", + ); + }), + ); + plugin .command("build [path]") .description( @@ -744,22 +833,18 @@ export function registerPluginCommands( action(async (path: string | undefined) => { const rootDir = resolve(process.cwd(), path ?? "."); const bbVersion = resolveBbCliVersion(); - // buildPluginServer errors legibly on a missing/invalid bb.server — - // every plugin has one, so a headless plugin succeeds with just the - // backend bundle (prebuilt distribution, design §6). + // Read the manifest before building: buildPluginServer errors legibly + // on a missing/invalid bb.server, so a null manifest here is only the + // unreachable case where that read also fails. + const manifest = await readPluginManifest(rootDir); + const hasApp = typeof manifest?.bb?.app === "string"; + // Keep the local declarations tracking the bb doing the build, so a + // plugin scaffolded against an older SDK never typechecks green + // against an API this bb no longer has. + if (manifest) await refreshPluginTypes(rootDir, hasApp); const toolchain = await cliBuildToolchain(); const server = await buildPluginServer(rootDir, bbVersion, toolchain); const files = [server.jsPath, server.mapPath, server.metaPath]; - let hasApp = false; - try { - const raw: unknown = JSON.parse( - await readFile(join(rootDir, "package.json"), "utf8"), - ); - const pkg = pluginManifestSchema.parse(raw); - hasApp = typeof pkg.bb?.app === "string"; - } catch { - // Unreachable in practice: buildPluginServer already read it. - } if (hasApp) { const app = await buildPluginApp(rootDir, bbVersion, toolchain); files.push(app.jsPath, app.cssPath, app.metaPath); @@ -778,13 +863,8 @@ export function registerPluginCommands( .action( action(async (path: string | undefined) => { const rootDir = resolve(process.cwd(), path ?? "."); - let manifest: z.infer; - try { - const raw: unknown = JSON.parse( - await readFile(join(rootDir, "package.json"), "utf8"), - ); - manifest = pluginManifestSchema.parse(raw); - } catch { + const manifest = await readPluginManifest(rootDir); + if (!manifest) { console.error( `No readable package.json in ${rootDir} — run from a plugin directory or pass its path.`, ); @@ -797,6 +877,9 @@ export function registerPluginCommands( process.exit(1); } const hasApp = typeof manifest.bb.app === "string"; + // Refresh before the watcher starts, so writing types/ cannot feed + // the loop its own change event. + await refreshPluginTypes(rootDir, hasApp); // The dev loop drives an *installed* plugin; match this directory // against the server's installed rows (realpath tolerates symlinked // checkouts). diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index ef5218dd5..4491817d0 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -666,6 +666,12 @@ them by mixing ink into canvas), the `--primary` accent, the secondary text tier `server.meta.json` stamped with SDK/identity metadata; preferred by git/npm installs over source) and, when `bb.app` is declared, `app.js` + `app.css` + `app.meta.json`. Neither needs the server. + - `bb plugin types [path]` — rewrite the plugin's `types/*.d.ts` from the + running bb's `@bb/plugin-sdk` declarations, creating `types/` when absent. + Run it in a cloned or older plugin: the scaffold seeds those files once and + the SDK surface grows every release. `--check` reports staleness and exits + non-zero without writing (for CI). `bb plugin build` and `bb plugin dev` + refresh them automatically. Needs no server. - `bb plugin dev [path]` — watch loop for an installed plugin (default: cwd): on every change it rebuilds the frontend bundle (when `bb.app` is declared) and reloads the plugin; open app pages pick the new UI up live. diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index c14091799..5f0a42ea3 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -116,13 +116,7 @@ The manifest is `package.json`: `custom-instructions`, `inline-vis`, `secrets`) cannot be installed from a non-`builtin:` source — use `builtin:` instead. -The scaffold ships the full API as bundled type declarations in `types/` -(`bb-plugin-sdk.d.ts`, plus `bb-plugin-sdk-app.d.ts` for `--app`); its -`tsconfig.json` maps `@bb/plugin-sdk` to them, so `npm install && npx tsc ---noEmit` typechecks anywhere — no bb checkout required. Those `.d.ts` files -are the authoritative, exhaustive surface: read them (or the source at -, cloned) when you need an exact signature or -a symbol this skill doesn't cover. Backend API imports normally stay type-only; +Backend API imports normally stay type-only; the root runtime exports are `defineRpcContract`, supplied by BB for shared schema contracts, and the numeric `PLUGIN_CLI_OUTPUT_MAX_BYTES` ceiling: `import { defineRpcContract, type BbPluginApi } from @@ -134,6 +128,44 @@ On-disk state per plugin: `/plugins//data.db` (its SQLite), rotated at 5MB). Settings edits never auto-reload — `bb plugin reload ` after configuring. +## Looking up the exact API + +This skill is a guide, not the contract. When you need an exact signature, a +field name, or a symbol this skill does not cover, work down this list and +stop at the first step that answers the question. + +1. **Run `bb plugin types` in the plugin directory.** It writes this bb's + `@bb/plugin-sdk` declarations into `types/`, creating the directory when it + is absent, and needs no running server. Do this before trusting an + unfamiliar checkout: `bb plugin new` seeds those files once, so a plugin + cloned from git or scaffolded against an older bb carries a frozen copy + that can be thousands of lines behind the API you are writing against. + `bb plugin build` and `bb plugin dev` refresh them too, and + `bb plugin types --check` reports staleness without writing. +2. **Read `types/bb-plugin-sdk.d.ts`** (plus `types/bb-plugin-sdk-app.d.ts` + for frontend symbols). This is the authoritative, exhaustive surface — + roughly 13,000 lines of ordinary formatted declarations with doc comments. + It is source, not build output: open it and grep it. The scaffold's + `tsconfig.json` maps `@bb/plugin-sdk` to these files, so + `npm install && npx tsc --noEmit` typechecks anywhere with no bb checkout. +3. **Clone the repo** when the declarations tell you _what_ but you need + _how_ — host behavior, a reference implementation, or an internal symbol + that never reaches the public surface: + + ```sh + git clone --depth 1 https://github.com/get-bb/bb /tmp/bb + ``` + + Read `packages/plugin-sdk/src/` for the SDK itself, + `apps/server/src/services/plugins/` for the host that runs your factory, + and `plugins/` for the official plugins that use every surface here. + +**Never read a built bundle to answer an API question.** `dist/server.js`, +`dist/app.js`, and the installed bb app's own JavaScript are minified build +output: they burn context and answer worse than the declarations. If you find +yourself grepping minified JavaScript for a plugin API detail, stop and go +back to step 1. + ## Distributing a plugin Users can install third-party plugins directly from a local path, npm package, @@ -1602,3 +1634,7 @@ Remaining reference examples in `examples/plugins/`: `defineRpcContract` plus `PLUGIN_CLI_OUTPUT_MAX_BYTES`; validator imports are plugin dependencies. The scaffold tsconfig typechecks both `server.ts` and `app.tsx`. +- `types/*.d.ts` is a per-plugin copy, not a live view of the SDK: it can be + thousands of lines behind in a cloned or older plugin. Run `bb plugin types` + before trusting it, and never fall back to reading a minified `dist/` bundle + — see "Looking up the exact API". diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index f2851d935..a978acafe 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -83,7 +83,7 @@ export const templateDefinitions = [ }, { "id": "bbGuidePlugins", - "body": "Plugin commands\n\nA bb plugin is a TypeScript package that extends the bb server in-process:\nbackground services, cron schedules, HTTP/RPC endpoints, thread lifecycle\nhandlers, settings, storage — and `bb` CLI subcommands that agents and humans\nrun like any other command. Plugins are full-trust code inside the server.\n\nPlugins are on by default. Builtin plugins (`builtin:`) ship with bb;\nuser-installed plugins come from `bb plugin install` or the official store.\nPlugin state lives under `/plugins//` (per-plugin SQLite file,\nsecrets, logs).\n\nThe builtin Custom instructions plugin adds a multiline editor under Settings\n→ Custom instructions. Saved text is persisted on this bb host and included in\nagent task instructions; blank text contributes nothing.\n\nThe builtin Workflows plugin runs durable provider-independent JavaScript\norchestration. It is disabled on fresh installations; enable `workflows` under\nExtensions → Plugins or run `bb plugin enable workflows` before using:\n\n bb workflows validate (--script ''|--source ''|\n --file |--name )\n bb workflows run (--script ''|--source ''|\n --file |--name )\n [--args ''] [--resume ]\n bb workflows status \n bb workflows history [--cursor ] [--limit <1-100>]\n bb workflows list [--limit <1-50>]\n bb workflows stop \n\nCommands must run from a BB project thread. Workflows has six plugin\nsettings, configurable with `bb plugin config workflows set `:\n`maxActiveRuns` (default 4, range 1–32), `maxConcurrentAgents` (8, 1–64),\n`maxAgentCalls` (100, 1–1000), `totalRunTimeoutMs` (86400000, 60000–604800000),\n`retentionDays` (30, 1–3650), and `maxNotificationBytes` (16384,\n1024–262144). `maxActiveRuns` applies live; the other five are snapshotted for\neach new run. Settings changes do not require a plugin reload.\n\n`status` is a bounded polling summary, and `list` returns only compact run\nsummaries. Detailed run and call records are paged JSONL: redirect `history`\ninto `$BB_THREAD_STORAGE` before inspecting it, and continue with the final\npage record's `nextCursor`. The invoking shell writes\nthat file on the thread's execution host, so this works the same on local and\nremote hosts without granting the plugin arbitrary filesystem access. Use `bb\nprovider list --environment \"$BB_ENVIRONMENT_ID\" --json` and then `bb provider\nmodels --environment \"$BB_ENVIRONMENT_ID\" --json` before writing\nan explicit selection; never guess ACP model IDs.\n\nThe Memory plugin is an opt-in install, bundled with the app:\n`bb plugin install memory`. Once installed, it injects a compact global and\ncurrent-project memory index into agent context and progressively discloses\nfull records through CLI-only commands. Because its store works across\nproviders, we recommend disabling provider-native memory under Settings →\nProviders to avoid duplicate or conflicting stores. Settings → Memory lists\nevery global and project memory and supports version-checked edits and soft\ndeletion.\n\n bb memory catalog [--scope project|global|all] [--json]\n bb memory search [--scope project|global|all] [--json]\n bb memory get [--scope project|global|all] [--json]\n bb memory add --scope project|global --name --summary \n --details --reason [--kind ]\n [--tag ]... [--importance <0-100>] [--pinned] [--json]\n bb memory update --expected-version [fields...] [--json]\n bb memory forget --expected-version --reason [--json]\n bb memory history [--scope project|global|all] [--limit 1-100] [--json]\n\nProject writes use the invoking CLI's current project. Global writes require\nthe explicit `--scope global` flag.\n\nThe Docs plugin is an opt-in official plugin bundled with the app:\n`bb plugin install docs`. Read-only discovery remains direct, while edits use\na manifest-backed local workspace:\n\n bb docs vaults [--json]\n bb docs list [--vault ] [--json]\n bb docs read [--vault ]\n bb docs pull [--folder] [--vault ] [--into ]\n bb docs pull --all [--vault ] [--into ]\n bb docs status [workspace-dir] [--delete] [--diff] [--json]\n bb docs push [workspace-dir] [--delete] [--dry-run] [--diff] [--json]\n\nPull preserves vault-relative paths and writes `.bb-docs-state.json`; edit the\nordinary files and leave that state file untouched. Push uses pulled SHA-256\nversions as compare-and-swap guards. Concurrent changes stop with exit 3.\nLocal file and empty-directory deletions are warnings unless `--delete` is\nexplicit; a pulled folder root is retained, so pull its parent or the whole\nvault to remove that folder. Use `--workspace-host ` when a standalone\nCLI's working directory is on a non-primary host. Direct `write`, `mkdir`,\n`move`, and `remove` remain only as deprecated compatibility commands.\n\nThe Tasks plugin is an opt-in official plugin bundled with the app:\n`bb plugin install tasks`. It adds a task tracker, agent delegation,\nand the `bb tasks` command. Common agent operations are:\n\n bb tasks show [--json]\n bb tasks list [--project ] [filters...] [--sort manual|priority|due] [--limit 1-500] [--cursor ] [--json]\n bb tasks comment (--body | --body-file ) [--json]\n bb tasks attachment add --file [--json]\n bb tasks attachment get --out [--json]\n bb tasks attach [--json]\n bb tasks update --status in_review [--json]\n bb tasks update (--parent | --no-parent) [--json]\n\nRun `bb tasks --help` for project, folder, task, label, attachment, and demo-data\ncommands, plus preset management, delegation, and attached-thread inspection.\nDelegated threads are attached automatically; use `bb tasks attach` only when\nwork started outside Tasks. Task update resolves both task keys and IDs for\n`--parent`; use `--no-parent` to promote a subtask to the top level. File paths\nin tasks commands resolve on the invoking machine (the thread's machine inside\nan agent thread, otherwise the server's); pass `--machine ` to\ntarget another enrolled machine.\nTask lists default to 100 rows. JSON pages include `nextCursor`; human pages\nprint the exact continuation option when more rows exist. Cursors are bound to\nthe filters, sort, and task-list revision. Any add, removal, reorder, update,\nlabel-link/name change, active-thread change, or project-prefix change invalidates an\noutstanding cursor; restart without `--cursor` instead of accepting a mixed\nsnapshot.\n\nThe builtin Secrets plugin provides a secure credential form and guarded\ndotenv reconciliation:\n\n bb secret request --write-env \n [--purpose ] [--describe ]...\n\nThe command blocks until the user submits or cancels the form. Secret values\nnever appear in command arguments, model-visible output, or persisted\ninteraction data; success prints only the path, variable names, and\nadded/updated/unchanged counts.\n\n bb plugin search Search BB's official plugins (bundled with\n the app)\n bb plugin install Install a bundled official plugin by name\n (github, docs, memory, tasks), a local\n path, builtin:,\n git:@, or\n npm:[@]\n (npm: needs npm on PATH; installs prompt —\n pass --yes to skip). Managed git:/npm:\n installs refuse engines.bb / engines.bbPluginSdk\n mismatches, manifest/artifact identity\n mismatches, and ids reserved by bundled plugins\n Omitted npm specs, ranges, dist-tags, and git\n branches track; exact npm versions, git tags,\n and git commits are pinned\n bb plugin outdated Check installed plugins for compatible\n updates (table; --json for raw results).\n Columns: installed, latest compatible,\n blocked newer (incompatible releases not\n selected), status. Dev builds (bb 0.0.0)\n annotate that engines.bb is not enforced\n bb plugin update | --all Apply compatible updates for one plugin or\n every tracking plugin with an update. Same\n full-trust confirmation as\n install (--yes skips; non-TTY refuses without\n --yes). Use outdated to preview; pinned\n installs stay put\n bb plugin list Status, services, schedules, handler timings\n bb plugin source [--json] Show requested/resolved source, engine ranges,\n install time, and recent activation history\n bb plugin enable|disable Load or unload an installed plugin\n bb plugin reload [id] Re-run factories against current sources\n bb plugin config [set | unset ]\n Show or change a plugin's declared settings\n bb plugin logs [-n N] [-f] Print (or follow) a plugin's bb.log output\n bb plugin run [args...] Run the plugin's CLI command explicitly\n bb plugin token [--rotate] Print the token for auth:\"token\" HTTP\n routes; --rotate generates a new token,\n invalidating the old one\n bb plugin remove Uninstall (managed git:/npm: files deleted;\n builtin removals are remembered)\n bb plugin new [--app] Scaffold a new plugin (no server required;\n --app adds a frontend entry, app.tsx, plus a\n typecheck-only tsconfig.json)\n bb plugin build [path] Compile the plugin into dist/ — the backend\n bundle (server.js, server.meta.json) and,\n when bb.app is declared, the frontend bundle\n (app.js, app.css, app.meta.json). Each\n *.meta.json is stamped with SDK major/version,\n artifactFormatVersion, pluginId, pluginVersion,\n and builtWith (bb + plugin SDK versions); no\n server required\n bb plugin dev [path] Watch a plugin's sources (default: cwd) and\n on every change rebuild its frontend bundle\n (if it declares bb.app) and reload the\n plugin; Ctrl+C to stop\n\nBB Official plugins\n\nBB's official plugins — GitHub, Docs, Memory, and Tasks — ship bundled inside\nthe app itself. They appear in Extensions → Plugins → Browse\nand install with one click from the local bundled copy: no network, no\ndownload, no separate release. Install from the CLI by bare name\n(`bb plugin install github`, `bb plugin install docs`, `bb plugin install\nmemory`, or `bb plugin install tasks`). Installed official plugins are pinned\nto the bundled copy and update automatically when the BB app updates.\n\nFor direct git:/npm: installs, updates are manual: `bb plugin outdated`\nchecks tracking sources and `bb plugin update` applies compatible candidates.\nReinstalling an already-installed managed plugin is refused — use\n`bb plugin update`. A failed activation restores the pre-update snapshot and\nleaves the latest failure visible as needing attention. Exact npm versions,\ngit tags and commits, path sources, and bundled official plugins are pinned;\nnpm ranges/omitted specs/dist-tags and git branches track compatible updates.\n\n`bb plugin search ` matches id, display name, description, and\ncategory across the bundled official plugins (status: installed / compatible\n/ requires newer bb). Install an official plugin by its bare name. Direct\n`path:`, `npm:`, `git:`, and `builtin:` sources—and path-like\nsyntax—continue to bypass official-plugin resolution.\n\nBuilds are automatic once installed. Git installs run `npm install`\n(lifecycle scripts disabled), then compile both bundles — so a git plugin may\ndepend on third-party packages. node_modules is kept, because bundling cannot\ninline data files a dependency reads at runtime. A committed dist/ is always\nreplaced by the bundles bb builds. Path installs compile dist/ at install time\nfrom dependencies you have already installed. A build failure fails the\ninstall. npm packages must ship a metadata-validated prebuilt app or the\ninstall is refused. The server rebuilds source-built apps after a bb upgrade.\n\nInstalling or updating a git plugin requires `npm` on PATH. Checking for\nupdates does not: a check reads the candidate's manifest and stops, so\npolling never resolves a dependency tree or builds. A candidate that fails to\nbuild is reported as available and fails when you apply it.\n\nbb ships no build toolchain. The first time a git or path plugin is built on\na machine, bb downloads a pinned esbuild + Tailwind set into\n`/plugins/toolchain-/` and reuses it afterwards. Installing\na prebuilt npm plugin never triggers that download.\n\nTo build a plugin yourself — in CI, or to check it compiles without a running\nbb — depend on the published `bb-app` package and call the CLI:\n\n```jsonc\n// your plugin's package.json\n\"devDependencies\": { \"bb-app\": \"^0.35.1\" },\n\"scripts\": { \"build\": \"bb plugin build\" }\n```\n\n`bb plugin build` talks to no server. Depending on `bb-app@X` builds with\nexactly that release's shim configuration, so the bundle cannot be built\nagainst a mismatched host runtime. Cache the toolchain directory in CI to skip\nthe download on later runs. Only `bb plugin dev` needs a running bb, because\nit reloads the installed plugin after each rebuild.\n\nThe backend half is prebuilt too: when a builtin/official/git/npm install\nships a dist/server.js built for the running SDK major, the server loads it\ninstead of the TypeScript source. Path installs always load server.ts from\nsource, so `bb plugin dev`/reload see edits immediately.\n\n`bb plugin dev` is the edit loop: it requires the directory to already be\ninstalled as a plugin (`bb plugin install .` first), ignores dist/,\nnode_modules/, and .git/, batches saves, and prints one line per cycle. A\nbuild or reload failure prints the error and keeps watching (a failed build\nskips that cycle's reload). Reloads reach open app pages live — changed\nfrontend bundles re-import and their UI slots remount without a page\nrefresh.\n\nFrontend entries (app.tsx) default-export `definePluginApp` from\n`@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose),\nsettingsSection (per-plugin settings page below the host-rendered settings\nform; no props in V1, optional host-rendered title),\nnavPanel (own sidebar entry + /plugins///* route; the remainder\narrives as the component's subPath prop for panel-internal deep links; the\nhost always renders the shared plugin title bar and the component owns a\nzero-padding full-bleed body, including its scrolling),\nthreadPanelAction\n(an entry in the thread right panel's new-tab Actions list whose run() can\nopen closable panel tabs with recursive `JsonValue` params; restored\ncomponents read `JsonValue | null`), pendingInteraction (temporarily replace a thread composer with a\nplugin form), fileOpener (register as a per-extension file viewer/editor;\nusers pick defaults under Settings → File openers and can right-click a\nfile link for a one-off choice), and messageDirective (replace a leaf\n`::name{k=\"v\"}` block inside assistant / nested-agent Markdown with a plugin\ncomponent; unknown, disabled, incomplete, code-fenced, or crashing\ndirectives fall back to the original source; components receive a nullable\nopenWorkspaceFile(path) callback for opening a worktree-relative file in the\nhost workspace viewer and a nullable\nopenThreadPanel({ actionId, title?, params? }) callback for opening one of the\nsame plugin's thread-panel actions). Hooks:\nuseRpc, useRealtime, useRealtimeConnectionState (the shared realtime socket's\nconnecting/connected/reconnecting lifecycle; reconcile on later connected\ntransitions, not the initial connection), useSettings (secrets excluded),\nuseBbContext,\nuseBbNavigate, useComposer (read/replace/update/clear scoped composer text,\napply a class-based text effect, lock input, quote selections, insert mention\npills, and focus the composer), and useComposerView (reactive bound scope,\nlayout, draft, and run state). Plain-text edits preserve attachments and\nreconcile only inline mentions overlapped by the edit. Define RPC methods with `defineRpcContract`\nand Standard Schema-compatible input/output validators (Zod works directly),\nregister via `bb.rpc.register(contract, handlers)`, then use a type-only\nbackend contract import with `useRpc()` for exact frontend\nmethod/input/result inference. The server validates both schemas and rejects\nnon-JSON results (including cyclic and non-finite values) with structured\nerror codes. Components are vendored shadcn source the plugin owns (the\nshadcn model): `bb plugin new --app` pre-vendors a starter set into\ncomponents/ui/ and `npx shadcn add @bb/` pulls more from the BB\ncomponent registry (the full stock shadcn set, version-matched to the\nrunning BB via the pinned ref in components.json). `import { toast } from\n\"sonner\"` reaches the host toaster; react, the portaling radix families,\nsonner, vaul, and @pierre/diffs (the app's syntax-highlighted diff\nrenderer) are runtime-shimmed (never bundled), everything else\nbundles from the plugin's node_modules (`npm install` for authors; BB installs\nrelease packages with their declared production dependencies). A crashing slot collapses to a\n\"plugin crashed\" chip without\ntouching the rest of the app. Installed plugins and their declared settings\n(same data as `bb plugin config`) also appear under Extensions → Plugins.\n\nPlugin CLI commands: a plugin can register one top-level subcommand (for\nexample `bb github …`). Unknown `bb` commands are looked up against installed\nplugins and proxied to the server, so plugin commands work exactly like core\ncommands; core command names always win. Inside agent threads the generated\n`plugin-commands` skill lists the available plugin commands.\n\nSettings changes do not auto-reload a plugin — run `bb plugin reload `\nafter configuring. Add --json to plugin commands for machine-readable output.\nPlugin CLI stdout plus stderr is capped at 1,048,576 UTF-8 bytes from the\nshared `@bb/plugin-sdk` constant. Results above the ceiling are rejected in\nfull with a structured `plugin_cli_output_too_large` error; output is never\nsilently clipped. Page growing collections and use file/streaming commands for\nlarge content.\n\nAuthoring a plugin\n\nThe loop: `bb plugin new ` scaffolds `./bb-plugin-` (add --app\nfor a frontend entry); `bb plugin install .` registers it; `bb plugin dev`\nwatches and reloads on every save. The manifest is package.json: required\n`bb.name` and `bb.description` human identity, required `bb.branding` with at\nleast `icon` or `logo.light`, `bb.server`\n(backend entry, loaded as TypeScript — no build step), optional `bb.app`\n(frontend entry), optional `bb.skills` (static skill directories auto-imported\ninto agent threads unless filtered by `bb.agents.configure`; default\n`skills/`), `engines.bb` (supported bb range),\nand optional `engines.bbPluginSdk` (supported plugin SDK range; scaffold\nwrites `\"^0.4.1\"` for SDK 0.4.1). The plugin id is the package name minus\n`bb-plugin-`.\n\nPlugins can contribute palettes with `bb.themes`: an array of\n`{ id, name, description?, css }`, where `css` is a plugin-relative `.css`\nfile. Loaded plugin palettes appear in Settings → Appearance and `bb theme\nlist`; their selectable id is `plugin::`. Disabling or\nremoving the owning plugin makes bb fall back to the default palette.\n\nBranding is explicit. Declare `bb.branding.icon` as either the plugin's\ncanonical BB icon name or a plugin-relative compact SVG such as\n`./assets/icon.svg`. BB validates and hash-serves path-shaped SVGs, then\nrenders them as masks that inherit the surrounding text color. Compact chrome\nprefers the manifest icon, then a contribution's local icon hint, and finally\nZap. Roomy surfaces reuse the same icon when no logo override is declared.\n\nAdd `bb.branding.logo.light` only for intentionally different rich/full-size\nidentity artwork; optional `bb.branding.logo.dark` is preferred in dark mode.\nLogo paths must be plugin-relative `.svg`, `.png`, or `.webp` files. Root logo\nfiles are not auto-detected, and a dark logo requires a light logo. Logo-only\nmanifests remain supported for compatibility, so at least an icon or light logo\nis required. Do not duplicate the same artwork across fields. BB rejects nulls,\nempty strings, missing or escaping assets, and unsupported extensions. Reload\nthe plugin to pick up branding changes.\n\nThe backend entry default-exports a factory receiving the full plugin API:\n\n import type { BbPluginApi } from \"@bb/plugin-sdk\";\n export default async function plugin(bb: BbPluginApi) { ... }\n\nThe import is type-only and erased at load; the scaffold ships the full API\nas bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so\n`npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout\nneeded. Confused, or need a symbol the types don't explain? Clone the repo:\nhttps://github.com/get-bb/bb. The API in\none line each — bb.log (plugin-scoped logger behind `bb plugin logs`);\nbb.settings.define (declarative settings incl. secrets, editable via\n`bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and\nbb.storage.database()+migrate (the plugin's own database); bb.sdk (the full\nbb SDK — handlers/services only, not the factory; spawned threads are\nattributed to the plugin; `visibility: \"hidden\"` creates directly addressable\nbackground workers omitted from sidebar organization and unread/pending\nfavicon attention, with other behavior unchanged; a child thread inherits\nits parent's visibility and still notifies that parent);\nbb.events.on (observe thread.created/idle/failed/deleted);\nbb.http.route (routes under /api/v1/plugins//http/* with\nlocal/token/none auth); defineRpcContract + bb.rpc.register (Standard\nSchema-validated frontend data plane with inferred backend handlers and\ntype-only frontend method/input/result inference);\nbb.realtime.publish (ephemeral signals to open app pages);\nbb.background.service (long-lived, AbortSignal, restart w/ backoff) and\nbb.background.schedule (durable cron rows); bb.cli.register (a top-level\n`bb ` command agents run through bash, with a shared 1 MiB combined\nstdout/stderr ceiling and atomic structured over-limit errors); bb.agents.registerTool\n(static native tools with zod or JSON-schema parameters) and\nbb.agents.configure (one synchronous per-resolution callback selecting this\nplugin's own tool/skill ids and optional dynamic instructions; tools apply on\nthe next provider session start/resume, while busy skill runtimes defer catalog\nchanges); bb.ui\nregisterMentionProvider (host-rendered UI — no\nfrontend bundle needed); bb.status.needsConfiguration (report\n\"unconfigured\" instead of crashing); bb.onDispose (LIFO cleanup on\nreload/disable/shutdown).\n\nFrontend entries register React slots (homepageSection, settingsSection,\nnavPanel, threadPanelAction, fileOpener, messageDirective) and composer\ncustomizations via `app.composer.customize({ actions, plusMenu, banners,\nrichText })`; action/banner components use `useComposer()` and\n`useComposerView()`, while the host renders plus-menu rows and editor\ndecorations. The deprecated pre-1.0 `slots.composerAccessory` footer API was\nremoved; migrate controls to actions or the plus menu and larger content to\nbanners. Register all frontend surfaces via\ndefinePluginApp, use the hooks\nlisted above, and render vendored components; styling is Tailwind against\nthe host theme's tokens only (semantic classes like bg-background and\ntw-animate-css utilities compile in plugin builds).\n\nFor the complete authoring reference — exact signatures, working snippets\nfor every surface, the reload lifecycle, testing tips, and gotchas — use\nthe built-in `bb-plugin-authoring` skill (agents: it loads on demand;\nhumans: apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/\nin a checkout). The builtin `inline-vis` plugin renders\n`::inline-vis{file=\"demo.html\" height=\"480\"}` through the sidebar's\npath-shaped, sandboxed worktree HTML iframe preview; `height` is optional.\nIts card header includes an open-in-sidebar action for the source HTML file.\nThe `plugins/` directory contains every bundled plugin: the auto-installed\nbuiltins and the store-only BB Official GitHub, Docs, Memory, and Tasks\nplugins. The `examples/plugins/` reference plugins cover slack-bot (webhook\nbot), agent-enrichment (agent surfaces), composer-customization (all composer\nregions), and t3sidebar (a replacement sidebar thread list).", + "body": "Plugin commands\n\nA bb plugin is a TypeScript package that extends the bb server in-process:\nbackground services, cron schedules, HTTP/RPC endpoints, thread lifecycle\nhandlers, settings, storage — and `bb` CLI subcommands that agents and humans\nrun like any other command. Plugins are full-trust code inside the server.\n\nPlugins are on by default. Builtin plugins (`builtin:`) ship with bb;\nuser-installed plugins come from `bb plugin install` or the official store.\nPlugin state lives under `/plugins//` (per-plugin SQLite file,\nsecrets, logs).\n\nThe builtin Custom instructions plugin adds a multiline editor under Settings\n→ Custom instructions. Saved text is persisted on this bb host and included in\nagent task instructions; blank text contributes nothing.\n\nThe builtin Workflows plugin runs durable provider-independent JavaScript\norchestration. It is disabled on fresh installations; enable `workflows` under\nExtensions → Plugins or run `bb plugin enable workflows` before using:\n\n bb workflows validate (--script ''|--source ''|\n --file |--name )\n bb workflows run (--script ''|--source ''|\n --file |--name )\n [--args ''] [--resume ]\n bb workflows status \n bb workflows history [--cursor ] [--limit <1-100>]\n bb workflows list [--limit <1-50>]\n bb workflows stop \n\nCommands must run from a BB project thread. Workflows has six plugin\nsettings, configurable with `bb plugin config workflows set `:\n`maxActiveRuns` (default 4, range 1–32), `maxConcurrentAgents` (8, 1–64),\n`maxAgentCalls` (100, 1–1000), `totalRunTimeoutMs` (86400000, 60000–604800000),\n`retentionDays` (30, 1–3650), and `maxNotificationBytes` (16384,\n1024–262144). `maxActiveRuns` applies live; the other five are snapshotted for\neach new run. Settings changes do not require a plugin reload.\n\n`status` is a bounded polling summary, and `list` returns only compact run\nsummaries. Detailed run and call records are paged JSONL: redirect `history`\ninto `$BB_THREAD_STORAGE` before inspecting it, and continue with the final\npage record's `nextCursor`. The invoking shell writes\nthat file on the thread's execution host, so this works the same on local and\nremote hosts without granting the plugin arbitrary filesystem access. Use `bb\nprovider list --environment \"$BB_ENVIRONMENT_ID\" --json` and then `bb provider\nmodels --environment \"$BB_ENVIRONMENT_ID\" --json` before writing\nan explicit selection; never guess ACP model IDs.\n\nThe Memory plugin is an opt-in install, bundled with the app:\n`bb plugin install memory`. Once installed, it injects a compact global and\ncurrent-project memory index into agent context and progressively discloses\nfull records through CLI-only commands. Because its store works across\nproviders, we recommend disabling provider-native memory under Settings →\nProviders to avoid duplicate or conflicting stores. Settings → Memory lists\nevery global and project memory and supports version-checked edits and soft\ndeletion.\n\n bb memory catalog [--scope project|global|all] [--json]\n bb memory search [--scope project|global|all] [--json]\n bb memory get [--scope project|global|all] [--json]\n bb memory add --scope project|global --name --summary \n --details --reason [--kind ]\n [--tag ]... [--importance <0-100>] [--pinned] [--json]\n bb memory update --expected-version [fields...] [--json]\n bb memory forget --expected-version --reason [--json]\n bb memory history [--scope project|global|all] [--limit 1-100] [--json]\n\nProject writes use the invoking CLI's current project. Global writes require\nthe explicit `--scope global` flag.\n\nThe Docs plugin is an opt-in official plugin bundled with the app:\n`bb plugin install docs`. Read-only discovery remains direct, while edits use\na manifest-backed local workspace:\n\n bb docs vaults [--json]\n bb docs list [--vault ] [--json]\n bb docs read [--vault ]\n bb docs pull [--folder] [--vault ] [--into ]\n bb docs pull --all [--vault ] [--into ]\n bb docs status [workspace-dir] [--delete] [--diff] [--json]\n bb docs push [workspace-dir] [--delete] [--dry-run] [--diff] [--json]\n\nPull preserves vault-relative paths and writes `.bb-docs-state.json`; edit the\nordinary files and leave that state file untouched. Push uses pulled SHA-256\nversions as compare-and-swap guards. Concurrent changes stop with exit 3.\nLocal file and empty-directory deletions are warnings unless `--delete` is\nexplicit; a pulled folder root is retained, so pull its parent or the whole\nvault to remove that folder. Use `--workspace-host ` when a standalone\nCLI's working directory is on a non-primary host. Direct `write`, `mkdir`,\n`move`, and `remove` remain only as deprecated compatibility commands.\n\nThe Tasks plugin is an opt-in official plugin bundled with the app:\n`bb plugin install tasks`. It adds a task tracker, agent delegation,\nand the `bb tasks` command. Common agent operations are:\n\n bb tasks show [--json]\n bb tasks list [--project ] [filters...] [--sort manual|priority|due] [--limit 1-500] [--cursor ] [--json]\n bb tasks comment (--body | --body-file ) [--json]\n bb tasks attachment add --file [--json]\n bb tasks attachment get --out [--json]\n bb tasks attach [--json]\n bb tasks update --status in_review [--json]\n bb tasks update (--parent | --no-parent) [--json]\n\nRun `bb tasks --help` for project, folder, task, label, attachment, and demo-data\ncommands, plus preset management, delegation, and attached-thread inspection.\nDelegated threads are attached automatically; use `bb tasks attach` only when\nwork started outside Tasks. Task update resolves both task keys and IDs for\n`--parent`; use `--no-parent` to promote a subtask to the top level. File paths\nin tasks commands resolve on the invoking machine (the thread's machine inside\nan agent thread, otherwise the server's); pass `--machine ` to\ntarget another enrolled machine.\nTask lists default to 100 rows. JSON pages include `nextCursor`; human pages\nprint the exact continuation option when more rows exist. Cursors are bound to\nthe filters, sort, and task-list revision. Any add, removal, reorder, update,\nlabel-link/name change, active-thread change, or project-prefix change invalidates an\noutstanding cursor; restart without `--cursor` instead of accepting a mixed\nsnapshot.\n\nThe builtin Secrets plugin provides a secure credential form and guarded\ndotenv reconciliation:\n\n bb secret request --write-env \n [--purpose ] [--describe ]...\n\nThe command blocks until the user submits or cancels the form. Secret values\nnever appear in command arguments, model-visible output, or persisted\ninteraction data; success prints only the path, variable names, and\nadded/updated/unchanged counts.\n\n bb plugin search Search BB's official plugins (bundled with\n the app)\n bb plugin install Install a bundled official plugin by name\n (github, docs, memory, tasks), a local\n path, builtin:,\n git:@, or\n npm:[@]\n (npm: needs npm on PATH; installs prompt —\n pass --yes to skip). Managed git:/npm:\n installs refuse engines.bb / engines.bbPluginSdk\n mismatches, manifest/artifact identity\n mismatches, and ids reserved by bundled plugins\n Omitted npm specs, ranges, dist-tags, and git\n branches track; exact npm versions, git tags,\n and git commits are pinned\n bb plugin outdated Check installed plugins for compatible\n updates (table; --json for raw results).\n Columns: installed, latest compatible,\n blocked newer (incompatible releases not\n selected), status. Dev builds (bb 0.0.0)\n annotate that engines.bb is not enforced\n bb plugin update | --all Apply compatible updates for one plugin or\n every tracking plugin with an update. Same\n full-trust confirmation as\n install (--yes skips; non-TTY refuses without\n --yes). Use outdated to preview; pinned\n installs stay put\n bb plugin list Status, services, schedules, handler timings\n bb plugin source [--json] Show requested/resolved source, engine ranges,\n install time, and recent activation history\n bb plugin enable|disable Load or unload an installed plugin\n bb plugin reload [id] Re-run factories against current sources\n bb plugin config [set | unset ]\n Show or change a plugin's declared settings\n bb plugin logs [-n N] [-f] Print (or follow) a plugin's bb.log output\n bb plugin run [args...] Run the plugin's CLI command explicitly\n bb plugin token [--rotate] Print the token for auth:\"token\" HTTP\n routes; --rotate generates a new token,\n invalidating the old one\n bb plugin remove Uninstall (managed git:/npm: files deleted;\n builtin removals are remembered)\n bb plugin new [--app] Scaffold a new plugin (no server required;\n --app adds a frontend entry, app.tsx, plus a\n typecheck-only tsconfig.json)\n bb plugin types [path] Write this bb's @bb/plugin-sdk declarations\n into the plugin's types/ (default: cwd);\n --check reports staleness and writes nothing\n bb plugin build [path] Compile the plugin into dist/ — the backend\n bundle (server.js, server.meta.json) and,\n when bb.app is declared, the frontend bundle\n (app.js, app.css, app.meta.json). Each\n *.meta.json is stamped with SDK major/version,\n artifactFormatVersion, pluginId, pluginVersion,\n and builtWith (bb + plugin SDK versions); no\n server required\n bb plugin dev [path] Watch a plugin's sources (default: cwd) and\n on every change rebuild its frontend bundle\n (if it declares bb.app) and reload the\n plugin; Ctrl+C to stop\n\nBB Official plugins\n\nBB's official plugins — GitHub, Docs, Memory, and Tasks — ship bundled inside\nthe app itself. They appear in Extensions → Plugins → Browse\nand install with one click from the local bundled copy: no network, no\ndownload, no separate release. Install from the CLI by bare name\n(`bb plugin install github`, `bb plugin install docs`, `bb plugin install\nmemory`, or `bb plugin install tasks`). Installed official plugins are pinned\nto the bundled copy and update automatically when the BB app updates.\n\nFor direct git:/npm: installs, updates are manual: `bb plugin outdated`\nchecks tracking sources and `bb plugin update` applies compatible candidates.\nReinstalling an already-installed managed plugin is refused — use\n`bb plugin update`. A failed activation restores the pre-update snapshot and\nleaves the latest failure visible as needing attention. Exact npm versions,\ngit tags and commits, path sources, and bundled official plugins are pinned;\nnpm ranges/omitted specs/dist-tags and git branches track compatible updates.\n\n`bb plugin search ` matches id, display name, description, and\ncategory across the bundled official plugins (status: installed / compatible\n/ requires newer bb). Install an official plugin by its bare name. Direct\n`path:`, `npm:`, `git:`, and `builtin:` sources—and path-like\nsyntax—continue to bypass official-plugin resolution.\n\nBuilds are automatic once installed. Git installs run `npm install`\n(lifecycle scripts disabled), then compile both bundles — so a git plugin may\ndepend on third-party packages. node_modules is kept, because bundling cannot\ninline data files a dependency reads at runtime. A committed dist/ is always\nreplaced by the bundles bb builds. Path installs compile dist/ at install time\nfrom dependencies you have already installed. A build failure fails the\ninstall. npm packages must ship a metadata-validated prebuilt app or the\ninstall is refused. The server rebuilds source-built apps after a bb upgrade.\n\nInstalling or updating a git plugin requires `npm` on PATH. Checking for\nupdates does not: a check reads the candidate's manifest and stops, so\npolling never resolves a dependency tree or builds. A candidate that fails to\nbuild is reported as available and fails when you apply it.\n\nbb ships no build toolchain. The first time a git or path plugin is built on\na machine, bb downloads a pinned esbuild + Tailwind set into\n`/plugins/toolchain-/` and reuses it afterwards. Installing\na prebuilt npm plugin never triggers that download.\n\nTo build a plugin yourself — in CI, or to check it compiles without a running\nbb — depend on the published `bb-app` package and call the CLI:\n\n```jsonc\n// your plugin's package.json\n\"devDependencies\": { \"bb-app\": \"^0.35.1\" },\n\"scripts\": { \"build\": \"bb plugin build\" }\n```\n\n`bb plugin build` talks to no server. Depending on `bb-app@X` builds with\nexactly that release's shim configuration, so the bundle cannot be built\nagainst a mismatched host runtime. Cache the toolchain directory in CI to skip\nthe download on later runs. Only `bb plugin dev` needs a running bb, because\nit reloads the installed plugin after each rebuild.\n\nThe backend half is prebuilt too: when a builtin/official/git/npm install\nships a dist/server.js built for the running SDK major, the server loads it\ninstead of the TypeScript source. Path installs always load server.ts from\nsource, so `bb plugin dev`/reload see edits immediately.\n\n`bb plugin dev` is the edit loop: it requires the directory to already be\ninstalled as a plugin (`bb plugin install .` first), ignores dist/,\nnode_modules/, and .git/, batches saves, and prints one line per cycle. A\nbuild or reload failure prints the error and keeps watching (a failed build\nskips that cycle's reload). Reloads reach open app pages live — changed\nfrontend bundles re-import and their UI slots remount without a page\nrefresh.\n\nFrontend entries (app.tsx) default-export `definePluginApp` from\n`@bb/plugin-sdk/app` and register UI slots: homepageSection (root compose),\nsettingsSection (per-plugin settings page below the host-rendered settings\nform; no props in V1, optional host-rendered title),\nnavPanel (own sidebar entry + /plugins///* route; the remainder\narrives as the component's subPath prop for panel-internal deep links; the\nhost always renders the shared plugin title bar and the component owns a\nzero-padding full-bleed body, including its scrolling),\nthreadPanelAction\n(an entry in the thread right panel's new-tab Actions list whose run() can\nopen closable panel tabs with recursive `JsonValue` params; restored\ncomponents read `JsonValue | null`), pendingInteraction (temporarily replace a thread composer with a\nplugin form), fileOpener (register as a per-extension file viewer/editor;\nusers pick defaults under Settings → File openers and can right-click a\nfile link for a one-off choice), and messageDirective (replace a leaf\n`::name{k=\"v\"}` block inside assistant / nested-agent Markdown with a plugin\ncomponent; unknown, disabled, incomplete, code-fenced, or crashing\ndirectives fall back to the original source; components receive a nullable\nopenWorkspaceFile(path) callback for opening a worktree-relative file in the\nhost workspace viewer and a nullable\nopenThreadPanel({ actionId, title?, params? }) callback for opening one of the\nsame plugin's thread-panel actions). Hooks:\nuseRpc, useRealtime, useRealtimeConnectionState (the shared realtime socket's\nconnecting/connected/reconnecting lifecycle; reconcile on later connected\ntransitions, not the initial connection), useSettings (secrets excluded),\nuseBbContext,\nuseBbNavigate, useComposer (read/replace/update/clear scoped composer text,\napply a class-based text effect, lock input, quote selections, insert mention\npills, and focus the composer), and useComposerView (reactive bound scope,\nlayout, draft, and run state). Plain-text edits preserve attachments and\nreconcile only inline mentions overlapped by the edit. Define RPC methods with `defineRpcContract`\nand Standard Schema-compatible input/output validators (Zod works directly),\nregister via `bb.rpc.register(contract, handlers)`, then use a type-only\nbackend contract import with `useRpc()` for exact frontend\nmethod/input/result inference. The server validates both schemas and rejects\nnon-JSON results (including cyclic and non-finite values) with structured\nerror codes. Components are vendored shadcn source the plugin owns (the\nshadcn model): `bb plugin new --app` pre-vendors a starter set into\ncomponents/ui/ and `npx shadcn add @bb/` pulls more from the BB\ncomponent registry (the full stock shadcn set, version-matched to the\nrunning BB via the pinned ref in components.json). `import { toast } from\n\"sonner\"` reaches the host toaster; react, the portaling radix families,\nsonner, vaul, and @pierre/diffs (the app's syntax-highlighted diff\nrenderer) are runtime-shimmed (never bundled), everything else\nbundles from the plugin's node_modules (`npm install` for authors; BB installs\nrelease packages with their declared production dependencies). A crashing slot collapses to a\n\"plugin crashed\" chip without\ntouching the rest of the app. Installed plugins and their declared settings\n(same data as `bb plugin config`) also appear under Extensions → Plugins.\n\nPlugin CLI commands: a plugin can register one top-level subcommand (for\nexample `bb github …`). Unknown `bb` commands are looked up against installed\nplugins and proxied to the server, so plugin commands work exactly like core\ncommands; core command names always win. Inside agent threads the generated\n`plugin-commands` skill lists the available plugin commands.\n\nSettings changes do not auto-reload a plugin — run `bb plugin reload `\nafter configuring. Add --json to plugin commands for machine-readable output.\nPlugin CLI stdout plus stderr is capped at 1,048,576 UTF-8 bytes from the\nshared `@bb/plugin-sdk` constant. Results above the ceiling are rejected in\nfull with a structured `plugin_cli_output_too_large` error; output is never\nsilently clipped. Page growing collections and use file/streaming commands for\nlarge content.\n\nAuthoring a plugin\n\nThe loop: `bb plugin new ` scaffolds `./bb-plugin-` (add --app\nfor a frontend entry); `bb plugin install .` registers it; `bb plugin dev`\nwatches and reloads on every save. The manifest is package.json: required\n`bb.name` and `bb.description` human identity, required `bb.branding` with at\nleast `icon` or `logo.light`, `bb.server`\n(backend entry, loaded as TypeScript — no build step), optional `bb.app`\n(frontend entry), optional `bb.skills` (static skill directories auto-imported\ninto agent threads unless filtered by `bb.agents.configure`; default\n`skills/`), `engines.bb` (supported bb range),\nand optional `engines.bbPluginSdk` (supported plugin SDK range; scaffold\nwrites `\"^0.4.1\"` for SDK 0.4.1). The plugin id is the package name minus\n`bb-plugin-`.\n\nPlugins can contribute palettes with `bb.themes`: an array of\n`{ id, name, description?, css }`, where `css` is a plugin-relative `.css`\nfile. Loaded plugin palettes appear in Settings → Appearance and `bb theme\nlist`; their selectable id is `plugin::`. Disabling or\nremoving the owning plugin makes bb fall back to the default palette.\n\nBranding is explicit. Declare `bb.branding.icon` as either the plugin's\ncanonical BB icon name or a plugin-relative compact SVG such as\n`./assets/icon.svg`. BB validates and hash-serves path-shaped SVGs, then\nrenders them as masks that inherit the surrounding text color. Compact chrome\nprefers the manifest icon, then a contribution's local icon hint, and finally\nZap. Roomy surfaces reuse the same icon when no logo override is declared.\n\nAdd `bb.branding.logo.light` only for intentionally different rich/full-size\nidentity artwork; optional `bb.branding.logo.dark` is preferred in dark mode.\nLogo paths must be plugin-relative `.svg`, `.png`, or `.webp` files. Root logo\nfiles are not auto-detected, and a dark logo requires a light logo. Logo-only\nmanifests remain supported for compatibility, so at least an icon or light logo\nis required. Do not duplicate the same artwork across fields. BB rejects nulls,\nempty strings, missing or escaping assets, and unsupported extensions. Reload\nthe plugin to pick up branding changes.\n\nThe backend entry default-exports a factory receiving the full plugin API:\n\n import type { BbPluginApi } from \"@bb/plugin-sdk\";\n export default async function plugin(bb: BbPluginApi) { ... }\n\nThe import is type-only and erased at load; the scaffold ships the full API\nas bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so\n`npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout\nneeded. Those files are ordinary readable declarations, not a minified\nbundle: read them for an exact signature. The SDK surface grows every\nrelease, so `bb plugin types` rewrites them from the running bb — run it in a\ncloned or older plugin, and `bb plugin types --check` in CI. `bb plugin\nbuild` and `bb plugin dev` refresh them for you. Need a symbol the types\ndon't explain? Clone the repo: https://github.com/get-bb/bb. The API in\none line each — bb.log (plugin-scoped logger behind `bb plugin logs`);\nbb.settings.define (declarative settings incl. secrets, editable via\n`bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and\nbb.storage.database()+migrate (the plugin's own database); bb.sdk (the full\nbb SDK — handlers/services only, not the factory; spawned threads are\nattributed to the plugin; `visibility: \"hidden\"` creates directly addressable\nbackground workers omitted from sidebar organization and unread/pending\nfavicon attention, with other behavior unchanged; a child thread inherits\nits parent's visibility and still notifies that parent);\nbb.events.on (observe thread.created/idle/failed/deleted);\nbb.http.route (routes under /api/v1/plugins//http/* with\nlocal/token/none auth); defineRpcContract + bb.rpc.register (Standard\nSchema-validated frontend data plane with inferred backend handlers and\ntype-only frontend method/input/result inference);\nbb.realtime.publish (ephemeral signals to open app pages);\nbb.background.service (long-lived, AbortSignal, restart w/ backoff) and\nbb.background.schedule (durable cron rows); bb.cli.register (a top-level\n`bb ` command agents run through bash, with a shared 1 MiB combined\nstdout/stderr ceiling and atomic structured over-limit errors); bb.agents.registerTool\n(static native tools with zod or JSON-schema parameters) and\nbb.agents.configure (one synchronous per-resolution callback selecting this\nplugin's own tool/skill ids and optional dynamic instructions; tools apply on\nthe next provider session start/resume, while busy skill runtimes defer catalog\nchanges); bb.ui\nregisterMentionProvider (host-rendered UI — no\nfrontend bundle needed); bb.status.needsConfiguration (report\n\"unconfigured\" instead of crashing); bb.onDispose (LIFO cleanup on\nreload/disable/shutdown).\n\nFrontend entries register React slots (homepageSection, settingsSection,\nnavPanel, threadPanelAction, fileOpener, messageDirective) and composer\ncustomizations via `app.composer.customize({ actions, plusMenu, banners,\nrichText })`; action/banner components use `useComposer()` and\n`useComposerView()`, while the host renders plus-menu rows and editor\ndecorations. The deprecated pre-1.0 `slots.composerAccessory` footer API was\nremoved; migrate controls to actions or the plus menu and larger content to\nbanners. Register all frontend surfaces via\ndefinePluginApp, use the hooks\nlisted above, and render vendored components; styling is Tailwind against\nthe host theme's tokens only (semantic classes like bg-background and\ntw-animate-css utilities compile in plugin builds).\n\nFor the complete authoring reference — exact signatures, working snippets\nfor every surface, the reload lifecycle, testing tips, and gotchas — use\nthe built-in `bb-plugin-authoring` skill (agents: it loads on demand;\nhumans: apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/\nin a checkout). The builtin `inline-vis` plugin renders\n`::inline-vis{file=\"demo.html\" height=\"480\"}` through the sidebar's\npath-shaped, sandboxed worktree HTML iframe preview; `height` is optional.\nIts card header includes an open-in-sidebar action for the source HTML file.\nThe `plugins/` directory contains every bundled plugin: the auto-installed\nbuiltins and the store-only BB Official GitHub, Docs, Memory, and Tasks\nplugins. The `examples/plugins/` reference plugins cover slack-bot (webhook\nbot), agent-enrichment (agent surfaces), composer-customization (all composer\nregions), and t3sidebar (a replacement sidebar thread list).", "fileName": "bb-guide-plugins.md", "kind": "instruction", "title": "bb Guide — Plugins", diff --git a/packages/templates/src/plugin-scaffold.ts b/packages/templates/src/plugin-scaffold.ts index 6d80b9801..f10600ca0 100644 --- a/packages/templates/src/plugin-scaffold.ts +++ b/packages/templates/src/plugin-scaffold.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { PLUGIN_SDK_VERSION } from "@bb/domain"; import { @@ -30,6 +30,86 @@ export interface ScaffoldPluginArgs { app?: boolean; } +/** Arguments for {@link syncPluginTypes}. */ +export interface SyncPluginTypesArgs { + /** Plugin root directory (the one holding `package.json`). */ + rootDir: string; + /** + * Also refresh the frontend declaration. Callers pass whether the manifest + * declares `bb.app`; an existing `bb-plugin-sdk-app.d.ts` refreshes either + * way, so a headless read of the manifest never strands a stale copy. + */ + app: boolean; + /** + * Report what a write would do and touch nothing (`bb plugin types + * --check`, CI). Stale or missing files come back as `stale`. + */ + check?: boolean; +} + +/** One declaration file considered by {@link syncPluginTypes}. */ +export interface SyncedPluginTypeFile { + /** Path relative to the plugin root, e.g. `types/bb-plugin-sdk.d.ts`. */ + path: string; + /** + * `written` when the file was created or its contents changed, `stale` when + * a check found it missing or outdated, `unchanged` when it already matches. + */ + outcome: "written" | "unchanged" | "stale"; +} + +/** + * Write this build's bundled `@bb/plugin-sdk` declarations into a plugin's + * `types/` directory, creating it when absent. + * + * `bb plugin new` seeds these once, but the SDK surface grows with every BB + * release, so a copy scaffolded months ago silently under-reports the API. + * `bb plugin types`, `bb plugin build`, and `bb plugin dev` all call this so + * the local declarations track the bb that is actually running the plugin. + * Files are compared before writing, so an already-current plugin reports + * `unchanged` and keeps its mtime. + */ +export async function syncPluginTypes( + args: SyncPluginTypesArgs, +): Promise { + const { rootDir, app, check = false } = args; + const typesDir = join(rootDir, "types"); + const candidates: { name: string; content: string; optional: boolean }[] = [ + { name: "bb-plugin-sdk.d.ts", content: PLUGIN_SDK_DTS, optional: false }, + { + name: "bb-plugin-sdk-app.d.ts", + content: PLUGIN_SDK_APP_DTS, + // Refresh a frontend declaration the plugin already has even when the + // caller did not detect bb.app; never create one it never asked for. + optional: !app, + }, + ]; + const results: SyncedPluginTypeFile[] = []; + for (const candidate of candidates) { + const filePath = join(typesDir, candidate.name); + let current: string | null = null; + try { + current = await readFile(filePath, "utf8"); + } catch { + current = null; + } + if (current === null && candidate.optional) continue; + const relativePath = `types/${candidate.name}`; + if (current === candidate.content) { + results.push({ path: relativePath, outcome: "unchanged" }); + continue; + } + if (check) { + results.push({ path: relativePath, outcome: "stale" }); + continue; + } + await mkdir(typesDir, { recursive: true }); + await writeFile(filePath, candidate.content); + results.push({ path: relativePath, outcome: "written" }); + } + return results; +} + /** "bb-plugin-hello" → "hello" (mirrors the server's id derivation). */ function pluginIdOf(packageName: string): string { return packageName.replace(/^bb-plugin-/, ""); @@ -355,8 +435,19 @@ bb plugin config ${id} set greeting hi \`types/bb-plugin-sdk.d.ts\` (and \`types/bb-plugin-sdk-app.d.ts\` for the frontend) are the full, bundled BB plugin API — \`tsconfig.json\` maps \`@bb/plugin-sdk\` to them, so your editor and \`tsc\` see real types with no extra -install. Ask BB to write plugins for you: the \`bb-plugin-authoring\` skill -documents the whole surface with examples. +install. They are readable declarations: open them for an exact signature. + +The SDK surface grows with every BB release, and these are a copy. Refresh +them from the BB you are running: + +\`\`\` +bb plugin types # rewrite types/ from this BB +bb plugin types --check # CI: fail when they are out of date +\`\`\` + +\`bb plugin build\` and \`bb plugin dev\` refresh them for you. Ask BB to write +plugins for you: the \`bb-plugin-authoring\` skill documents the whole surface +with examples. Confused by the API, or need something the types don't explain? Clone the BB repo and read the source: . diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index ddd9add6f..f93923a1d 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -179,6 +179,9 @@ added/updated/unchanged counts. bb plugin new [--app] Scaffold a new plugin (no server required; --app adds a frontend entry, app.tsx, plus a typecheck-only tsconfig.json) + bb plugin types [path] Write this bb's @bb/plugin-sdk declarations + into the plugin's types/ (default: cwd); + --check reports staleness and writes nothing bb plugin build [path] Compile the plugin into dist/ — the backend bundle (server.js, server.meta.json) and, when bb.app is declared, the frontend bundle @@ -372,8 +375,12 @@ The backend entry default-exports a factory receiving the full plugin API: The import is type-only and erased at load; the scaffold ships the full API as bundled .d.ts in types/ (tsconfig maps @bb/plugin-sdk to them), so `npm install && npx tsc --noEmit` typechecks anywhere — no bb checkout -needed. Confused, or need a symbol the types don't explain? Clone the repo: -https://github.com/get-bb/bb. The API in +needed. Those files are ordinary readable declarations, not a minified +bundle: read them for an exact signature. The SDK surface grows every +release, so `bb plugin types` rewrites them from the running bb — run it in a +cloned or older plugin, and `bb plugin types --check` in CI. `bb plugin +build` and `bb plugin dev` refresh them for you. Need a symbol the types +don't explain? Clone the repo: https://github.com/get-bb/bb. The API in one line each — bb.log (plugin-scoped logger behind `bb plugin logs`); bb.settings.define (declarative settings incl. secrets, editable via `bb plugin config`); bb.storage.kv (JSON rows ≤256KB) and diff --git a/packages/templates/test/plugin-sync-types.test.ts b/packages/templates/test/plugin-sync-types.test.ts new file mode 100644 index 000000000..ef765be11 --- /dev/null +++ b/packages/templates/test/plugin-sync-types.test.ts @@ -0,0 +1,104 @@ +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { syncPluginTypes } from "../src/plugin-scaffold.js"; + +/** + * `bb plugin new` seeds types/ once, but the SDK surface grows every release, + * so a plugin scaffolded months ago typechecks against declarations that no + * longer describe the running bb. syncPluginTypes is the refresh; these guard + * the behavior the CLI (`bb plugin types`, build, dev) depends on. + */ +describe("syncPluginTypes", () => { + let rootDir: string; + + beforeEach(async () => { + rootDir = await mkdtemp(join(tmpdir(), "bb-sync-types-")); + }); + + afterEach(async () => { + await rm(rootDir, { recursive: true, force: true }); + }); + + it("replaces a stale declaration and creates a missing types/", async () => { + const results = await syncPluginTypes({ rootDir, app: false }); + + expect(results).toEqual([ + { path: "types/bb-plugin-sdk.d.ts", outcome: "written" }, + ]); + const written = await readFile( + join(rootDir, "types", "bb-plugin-sdk.d.ts"), + "utf8", + ); + expect(written).toContain("interface BbPluginApi"); + + await writeFile(join(rootDir, "types", "bb-plugin-sdk.d.ts"), "// stale\n"); + const refreshed = await syncPluginTypes({ rootDir, app: false }); + expect(refreshed[0]?.outcome).toBe("written"); + expect( + await readFile(join(rootDir, "types", "bb-plugin-sdk.d.ts"), "utf8"), + ).toContain("interface BbPluginApi"); + }); + + it("reports unchanged instead of rewriting a current declaration", async () => { + await syncPluginTypes({ rootDir, app: false }); + const before = await stat(join(rootDir, "types", "bb-plugin-sdk.d.ts")); + + const results = await syncPluginTypes({ rootDir, app: false }); + + expect(results).toEqual([ + { path: "types/bb-plugin-sdk.d.ts", outcome: "unchanged" }, + ]); + const after = await stat(join(rootDir, "types", "bb-plugin-sdk.d.ts")); + expect(after.mtimeMs).toBe(before.mtimeMs); + }); + + it("never creates app types a headless plugin did not ask for", async () => { + await syncPluginTypes({ rootDir, app: false }); + + await expect( + readFile(join(rootDir, "types", "bb-plugin-sdk-app.d.ts"), "utf8"), + ).rejects.toThrow(); + }); + + it("refreshes existing app types even when the caller reports no bb.app", async () => { + // A manifest read can fail or predate the frontend entry; an app + // declaration already on disk must not be left stale because of it. + await mkdir(join(rootDir, "types"), { recursive: true }); + await writeFile( + join(rootDir, "types", "bb-plugin-sdk-app.d.ts"), + "// stale\n", + ); + + const results = await syncPluginTypes({ rootDir, app: false }); + + expect(results).toContainEqual({ + path: "types/bb-plugin-sdk-app.d.ts", + outcome: "written", + }); + expect( + await readFile(join(rootDir, "types", "bb-plugin-sdk-app.d.ts"), "utf8"), + ).toContain("definePluginApp"); + }); + + it("check mode reports stale files and writes nothing", async () => { + const missing = await syncPluginTypes({ rootDir, app: true, check: true }); + expect(missing).toEqual([ + { path: "types/bb-plugin-sdk.d.ts", outcome: "stale" }, + { path: "types/bb-plugin-sdk-app.d.ts", outcome: "stale" }, + ]); + await expect(stat(join(rootDir, "types"))).rejects.toThrow(); + + await syncPluginTypes({ rootDir, app: true }); + const current = await syncPluginTypes({ rootDir, app: true, check: true }); + expect(current.every((file) => file.outcome === "unchanged")).toBe(true); + }); +}); From aa3af6fcee83d4c833bb660d92c802973bf8519c Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 7 Aug 2026 14:17:38 +0000 Subject: [PATCH 2/4] Tighten the API-lookup section Same ladder and the same prohibition, roughly half the words. Co-Authored-By: Claude Opus 5 (1M context) --- .../bb-plugin-authoring/SKILL.md | 59 +++++++------------ 1 file changed, 20 insertions(+), 39 deletions(-) diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 5f0a42ea3..daa6a2a47 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -130,41 +130,23 @@ after configuring. ## Looking up the exact API -This skill is a guide, not the contract. When you need an exact signature, a -field name, or a symbol this skill does not cover, work down this list and -stop at the first step that answers the question. - -1. **Run `bb plugin types` in the plugin directory.** It writes this bb's - `@bb/plugin-sdk` declarations into `types/`, creating the directory when it - is absent, and needs no running server. Do this before trusting an - unfamiliar checkout: `bb plugin new` seeds those files once, so a plugin - cloned from git or scaffolded against an older bb carries a frozen copy - that can be thousands of lines behind the API you are writing against. - `bb plugin build` and `bb plugin dev` refresh them too, and - `bb plugin types --check` reports staleness without writing. -2. **Read `types/bb-plugin-sdk.d.ts`** (plus `types/bb-plugin-sdk-app.d.ts` - for frontend symbols). This is the authoritative, exhaustive surface — - roughly 13,000 lines of ordinary formatted declarations with doc comments. - It is source, not build output: open it and grep it. The scaffold's - `tsconfig.json` maps `@bb/plugin-sdk` to these files, so - `npm install && npx tsc --noEmit` typechecks anywhere with no bb checkout. -3. **Clone the repo** when the declarations tell you _what_ but you need - _how_ — host behavior, a reference implementation, or an internal symbol - that never reaches the public surface: - - ```sh - git clone --depth 1 https://github.com/get-bb/bb /tmp/bb - ``` - - Read `packages/plugin-sdk/src/` for the SDK itself, - `apps/server/src/services/plugins/` for the host that runs your factory, - and `plugins/` for the official plugins that use every surface here. - -**Never read a built bundle to answer an API question.** `dist/server.js`, -`dist/app.js`, and the installed bb app's own JavaScript are minified build -output: they burn context and answer worse than the declarations. If you find -yourself grepping minified JavaScript for a plugin API detail, stop and go -back to step 1. +This skill is a guide, not the contract. For an exact signature or a symbol it +does not cover: + +1. **`bb plugin types`** rewrites `types/*.d.ts` from the running bb (no + server needed). The scaffold seeds them once, so a cloned or older plugin + can be thousands of lines behind. `--check` reports staleness without + writing; `bb plugin build` and `bb plugin dev` refresh them too. +2. **Read `types/bb-plugin-sdk.d.ts`** (`-app.d.ts` for frontend symbols) — + the authoritative surface, ~13,000 lines of readable declarations with doc + comments, and what the scaffold `tsconfig.json` maps `@bb/plugin-sdk` to. +3. **`git clone --depth 1 https://github.com/get-bb/bb`** for host behavior or + a reference implementation: `packages/plugin-sdk/src/`, + `apps/server/src/services/plugins/`, `plugins/`. + +Never answer an API question from a built bundle — `dist/*.js` and the bb app's +own JavaScript are minified. If you are grepping minified JavaScript, go back +to step 1. ## Distributing a plugin @@ -1634,7 +1616,6 @@ Remaining reference examples in `examples/plugins/`: `defineRpcContract` plus `PLUGIN_CLI_OUTPUT_MAX_BYTES`; validator imports are plugin dependencies. The scaffold tsconfig typechecks both `server.ts` and `app.tsx`. -- `types/*.d.ts` is a per-plugin copy, not a live view of the SDK: it can be - thousands of lines behind in a cloned or older plugin. Run `bb plugin types` - before trusting it, and never fall back to reading a minified `dist/` bundle - — see "Looking up the exact API". +- `types/*.d.ts` is a per-plugin copy, not a live view of the SDK: run + `bb plugin types` before trusting it, and never fall back to a minified + `dist/` bundle — see "Looking up the exact API". From 596e996ad93888308c7d8656e83638dfe81a84c2 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 7 Aug 2026 14:20:36 +0000 Subject: [PATCH 3/4] Say where bb plugin types writes Co-Authored-By: Claude Opus 5 (1M context) --- .../skills/builtin-skills/bb-plugin-authoring/SKILL.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index daa6a2a47..7cf0a97c3 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -133,10 +133,11 @@ after configuring. This skill is a guide, not the contract. For an exact signature or a symbol it does not cover: -1. **`bb plugin types`** rewrites `types/*.d.ts` from the running bb (no - server needed). The scaffold seeds them once, so a cloned or older plugin - can be thousands of lines behind. `--check` reports staleness without - writing; `bb plugin build` and `bb plugin dev` refresh them too. +1. **`bb plugin types`**, run in the plugin directory (or given its path), + rewrites that plugin's `types/*.d.ts` from the running bb — no server + needed. The scaffold seeds them once, so a cloned or older plugin can be + thousands of lines behind. `--check` reports staleness without writing; + `bb plugin build` and `bb plugin dev` refresh them too. 2. **Read `types/bb-plugin-sdk.d.ts`** (`-app.d.ts` for frontend symbols) — the authoritative surface, ~13,000 lines of readable declarations with doc comments, and what the scaffold `tsconfig.json` maps `@bb/plugin-sdk` to. From d2d96a40a58fc376b7b843e5c12b07818f260642 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Fri, 7 Aug 2026 14:41:55 +0000 Subject: [PATCH 4/4] Refuse to write declarations through a symbolic link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes from the SlopCop review of this PR. `writeFile` follows links, so a plugin that shipped `types/` — or a declaration inside it — as a symbolic link redirected the write onto whatever it pointed at. That was already reachable through `bb plugin types`, and the new automatic refresh made `bb plugin build` and `bb plugin dev` do it without the author asking. Building a plugin never runs its code, so cloning an untrusted plugin and building it must not write anywhere but that plugin. I reproduced the clobber before fixing it. syncPluginTypes now lstats without following, refuses either link form, verifies the resolved `types/` still sits inside the resolved plugin root, and writes through a temporary regular file it renames into place. Tests cover both link forms and assert the target survives. Also: scaffoldPlugin now seeds declarations through syncPluginTypes instead of duplicating the writes, so a scaffolded plugin and a refreshed one cannot diverge; and `bb plugin build` gates the refresh on a string `bb.server`, so a directory it is about to reject is never written to first. Co-Authored-By: Claude Opus 5 (1M context) --- apps/cli/src/commands/plugin.ts | 7 +- packages/templates/src/plugin-scaffold.ts | 110 +++++++++++++++--- .../templates/test/plugin-sync-types.test.ts | 43 +++++++ 3 files changed, 141 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/commands/plugin.ts b/apps/cli/src/commands/plugin.ts index b335c859a..bb18d5326 100644 --- a/apps/cli/src/commands/plugin.ts +++ b/apps/cli/src/commands/plugin.ts @@ -840,8 +840,11 @@ export function registerPluginCommands( const hasApp = typeof manifest?.bb?.app === "string"; // Keep the local declarations tracking the bb doing the build, so a // plugin scaffolded against an older SDK never typechecks green - // against an API this bb no longer has. - if (manifest) await refreshPluginTypes(rootDir, hasApp); + // against an API this bb no longer has. Gate on bb.server so a + // directory this command is about to reject is never written to first. + if (typeof manifest?.bb?.server === "string") { + await refreshPluginTypes(rootDir, hasApp); + } const toolchain = await cliBuildToolchain(); const server = await buildPluginServer(rootDir, bbVersion, toolchain); const files = [server.jsPath, server.mapPath, server.metaPath]; diff --git a/packages/templates/src/plugin-scaffold.ts b/packages/templates/src/plugin-scaffold.ts index f10600ca0..99635216d 100644 --- a/packages/templates/src/plugin-scaffold.ts +++ b/packages/templates/src/plugin-scaffold.ts @@ -1,5 +1,13 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { + lstat, + mkdir, + readFile, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { dirname, isAbsolute, join, relative } from "node:path"; import { PLUGIN_SDK_VERSION } from "@bb/domain"; import { PLUGIN_SDK_APP_DTS, @@ -84,17 +92,17 @@ export async function syncPluginTypes( optional: !app, }, ]; + await assertWritableTypesDir(rootDir, typesDir); const results: SyncedPluginTypeFile[] = []; for (const candidate of candidates) { const filePath = join(typesDir, candidate.name); - let current: string | null = null; - try { - current = await readFile(filePath, "utf8"); - } catch { - current = null; + const relativePath = `types/${candidate.name}`; + const existing = await statNoFollow(filePath, relativePath); + if (existing !== null && !existing.isFile()) { + throw new Error(`${relativePath} is not a regular file`); } + const current = existing === null ? null : await readFile(filePath, "utf8"); if (current === null && candidate.optional) continue; - const relativePath = `types/${candidate.name}`; if (current === candidate.content) { results.push({ path: relativePath, outcome: "unchanged" }); continue; @@ -104,12 +112,84 @@ export async function syncPluginTypes( continue; } await mkdir(typesDir, { recursive: true }); - await writeFile(filePath, candidate.content); + await writeDeclarationAtomically(filePath, relativePath, candidate.content); results.push({ path: relativePath, outcome: "written" }); } return results; } +/** + * `lstat` that never follows the final path component. Returns null when the + * path does not exist, and refuses a symbolic link. + * + * `bb plugin build` and `bb plugin dev` refresh declarations automatically, so + * a plugin that ships `types/` — or a declaration inside it — as a link would + * otherwise redirect that write onto a file outside the plugin. Building a + * plugin does not run its code, so cloning an untrusted plugin and building it + * must not write anywhere but that plugin. + */ +async function statNoFollow( + path: string, + label: string, +): Promise> | null> { + let stats: Awaited>; + try { + stats = await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + if (stats.isSymbolicLink()) { + throw new Error(`refusing to write through the symbolic link ${label}`); + } + return stats; +} + +/** + * Reject a `types/` that is a link, is not a directory, or resolves outside + * the plugin. Resolving both sides keeps a plugin inside a symlinked checkout + * (a bb worktree, for example) working. + */ +async function assertWritableTypesDir( + rootDir: string, + typesDir: string, +): Promise { + const stats = await statNoFollow(typesDir, "types"); + if (stats === null) return; + if (!stats.isDirectory()) + throw new Error("types exists but is not a directory"); + const [realRoot, realTypes] = await Promise.all([ + realpath(rootDir), + realpath(typesDir), + ]); + const rel = relative(realRoot, realTypes); + if (rel.length === 0 || rel.startsWith("..") || isAbsolute(rel)) { + throw new Error(`types resolves outside the plugin (${realTypes})`); + } +} + +/** + * Write through a temporary regular file and rename it into place, so a + * concurrent reader never sees a partial declaration file and the rename + * replaces the entry itself rather than following anything at the destination. + */ +async function writeDeclarationAtomically( + filePath: string, + label: string, + content: string, +): Promise { + const tempPath = `${filePath}.bb-tmp`; + await statNoFollow(tempPath, `${label}.bb-tmp`); + await rm(tempPath, { force: true }); + await writeFile(tempPath, content, { flag: "wx" }); + try { + await rename(tempPath, filePath); + } catch (error) { + await rm(tempPath, { force: true }); + throw error; + } +} + /** "bb-plugin-hello" → "hello" (mirrors the server's id derivation). */ function pluginIdOf(packageName: string): string { return packageName.replace(/^bb-plugin-/, ""); @@ -519,16 +599,12 @@ export async function scaffoldPlugin(args: ScaffoldPluginArgs): Promise { await writeFile(join(targetDir, "tsconfig.json"), tsconfigSource(app)); // Bundled root/app declarations keep normal plugin source self-contained. // Tests that use @bb/plugin-sdk/testing install the published package; the - // exact root/app paths below intentionally continue to resolve here. - const typesDir = join(targetDir, "types"); - await mkdir(typesDir, { recursive: true }); - await writeFile(join(typesDir, "bb-plugin-sdk.d.ts"), PLUGIN_SDK_DTS); + // exact root/app paths syncPluginTypes writes intentionally keep resolving + // here. Seeding through the same function `bb plugin types` uses is what + // stops a scaffolded plugin and a refreshed one from ever diverging. + await syncPluginTypes({ rootDir: targetDir, app }); if (app) { await writeFile(join(targetDir, "app.tsx"), appEntrySource(packageName)); - await writeFile( - join(typesDir, "bb-plugin-sdk-app.d.ts"), - PLUGIN_SDK_APP_DTS, - ); // Vendored starter components (shadcn model — the author owns and edits // them) + components.json so `npx shadcn add @bb/` pulls more from // the BB registry at the version tag matching this install. diff --git a/packages/templates/test/plugin-sync-types.test.ts b/packages/templates/test/plugin-sync-types.test.ts index ef765be11..0e91ac409 100644 --- a/packages/templates/test/plugin-sync-types.test.ts +++ b/packages/templates/test/plugin-sync-types.test.ts @@ -2,8 +2,10 @@ import { mkdir, mkdtemp, readFile, + readdir, rm, stat, + symlink, writeFile, } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -89,6 +91,47 @@ describe("syncPluginTypes", () => { ).toContain("definePluginApp"); }); + /** + * `bb plugin build` and `bb plugin dev` refresh declarations without being + * asked, and building a plugin never runs its code — so cloning an untrusted + * plugin and building it must not write outside that plugin. Both link forms + * redirected the write before this was guarded. + */ + describe("refuses to write through a symbolic link", () => { + it("rejects a linked declaration file and leaves the target intact", async () => { + const victim = join(rootDir, "victim.txt"); + await writeFile(victim, "PRECIOUS\n"); + await mkdir(join(rootDir, "types")); + await symlink(victim, join(rootDir, "types", "bb-plugin-sdk.d.ts")); + + await expect(syncPluginTypes({ rootDir, app: false })).rejects.toThrow( + /symbolic link/, + ); + expect(await readFile(victim, "utf8")).toBe("PRECIOUS\n"); + }); + + it("rejects a linked types directory and leaves the target intact", async () => { + const outside = join(rootDir, "outside"); + await mkdir(outside); + await writeFile(join(outside, "bb-plugin-sdk.d.ts"), "PRECIOUS\n"); + await symlink(outside, join(rootDir, "types")); + + await expect(syncPluginTypes({ rootDir, app: false })).rejects.toThrow( + /symbolic link/, + ); + expect(await readFile(join(outside, "bb-plugin-sdk.d.ts"), "utf8")).toBe( + "PRECIOUS\n", + ); + }); + }); + + it("leaves no temporary file behind after a successful write", async () => { + await syncPluginTypes({ rootDir, app: true }); + + const entries = await readdir(join(rootDir, "types")); + expect(entries.filter((name) => name.includes("bb-tmp"))).toEqual([]); + }); + it("check mode reports stale files and writes nothing", async () => { const missing = await syncPluginTypes({ rootDir, app: true, check: true }); expect(missing).toEqual([