Merge public-gh/master into paperclip-company-import-export
This commit is contained in:
@@ -1,5 +1,23 @@
|
||||
# paperclipai
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
- @paperclipai/adapter-claude-local@0.3.1
|
||||
- @paperclipai/adapter-codex-local@0.3.1
|
||||
- @paperclipai/adapter-cursor-local@0.3.1
|
||||
- @paperclipai/adapter-gemini-local@0.3.1
|
||||
- @paperclipai/adapter-openclaw-gateway@0.3.1
|
||||
- @paperclipai/adapter-opencode-local@0.3.1
|
||||
- @paperclipai/adapter-pi-local@0.3.1
|
||||
- @paperclipai/db@0.3.1
|
||||
- @paperclipai/shared@0.3.1
|
||||
- @paperclipai/server@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperclipai",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Paperclip CLI — orchestrate AI agent teams to run a business",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
374
cli/src/commands/client/plugin.ts
Normal file
374
cli/src/commands/client/plugin.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import pc from "picocolors";
|
||||
import {
|
||||
addCommonClientOptions,
|
||||
handleCommandError,
|
||||
printOutput,
|
||||
resolveCommandContext,
|
||||
type BaseClientOptions,
|
||||
} from "./common.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types mirroring server-side shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginRecord {
|
||||
id: string;
|
||||
pluginKey: string;
|
||||
packageName: string;
|
||||
version: string;
|
||||
status: string;
|
||||
displayName?: string;
|
||||
lastError?: string | null;
|
||||
installedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Option types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PluginListOptions extends BaseClientOptions {
|
||||
status?: string;
|
||||
}
|
||||
|
||||
interface PluginInstallOptions extends BaseClientOptions {
|
||||
local?: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
interface PluginUninstallOptions extends BaseClientOptions {
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve a local path argument to an absolute path so the server can find the
|
||||
* plugin on disk regardless of where the user ran the CLI.
|
||||
*/
|
||||
function resolvePackageArg(packageArg: string, isLocal: boolean): string {
|
||||
if (!isLocal) return packageArg;
|
||||
// Already absolute
|
||||
if (path.isAbsolute(packageArg)) return packageArg;
|
||||
// Expand leading ~ to home directory
|
||||
if (packageArg.startsWith("~")) {
|
||||
const home = process.env.HOME ?? process.env.USERPROFILE ?? "";
|
||||
return path.resolve(home, packageArg.slice(1).replace(/^[\\/]/, ""));
|
||||
}
|
||||
return path.resolve(process.cwd(), packageArg);
|
||||
}
|
||||
|
||||
function formatPlugin(p: PluginRecord): string {
|
||||
const statusColor =
|
||||
p.status === "ready"
|
||||
? pc.green(p.status)
|
||||
: p.status === "error"
|
||||
? pc.red(p.status)
|
||||
: p.status === "disabled"
|
||||
? pc.dim(p.status)
|
||||
: pc.yellow(p.status);
|
||||
|
||||
const parts = [
|
||||
`key=${pc.bold(p.pluginKey)}`,
|
||||
`status=${statusColor}`,
|
||||
`version=${p.version}`,
|
||||
`id=${pc.dim(p.id)}`,
|
||||
];
|
||||
|
||||
if (p.lastError) {
|
||||
parts.push(`error=${pc.red(p.lastError.slice(0, 80))}`);
|
||||
}
|
||||
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Command registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function registerPluginCommands(program: Command): void {
|
||||
const plugin = program.command("plugin").description("Plugin lifecycle management");
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin list
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("list")
|
||||
.description("List installed plugins")
|
||||
.option("--status <status>", "Filter by status (ready, error, disabled, installed, upgrade_pending)")
|
||||
.action(async (opts: PluginListOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const qs = opts.status ? `?status=${encodeURIComponent(opts.status)}` : "";
|
||||
const plugins = await ctx.api.get<PluginRecord[]>(`/api/plugins${qs}`);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(plugins, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = plugins ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log(pc.dim("No plugins installed."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const p of rows) {
|
||||
console.log(formatPlugin(p));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin install <package-or-path>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("install <package>")
|
||||
.description(
|
||||
"Install a plugin from a local path or npm package.\n" +
|
||||
" Examples:\n" +
|
||||
" paperclipai plugin install ./my-plugin # local path\n" +
|
||||
" paperclipai plugin install @acme/plugin-linear # npm package\n" +
|
||||
" paperclipai plugin install @acme/plugin-linear@1.2 # pinned version",
|
||||
)
|
||||
.option("-l, --local", "Treat <package> as a local filesystem path", false)
|
||||
.option("--version <version>", "Specific npm version to install (npm packages only)")
|
||||
.action(async (packageArg: string, opts: PluginInstallOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
|
||||
// Auto-detect local paths: starts with . or / or ~ or is an absolute path
|
||||
const isLocal =
|
||||
opts.local ||
|
||||
packageArg.startsWith("./") ||
|
||||
packageArg.startsWith("../") ||
|
||||
packageArg.startsWith("/") ||
|
||||
packageArg.startsWith("~");
|
||||
|
||||
const resolvedPackage = resolvePackageArg(packageArg, isLocal);
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
isLocal
|
||||
? `Installing plugin from local path: ${resolvedPackage}`
|
||||
: `Installing plugin: ${resolvedPackage}${opts.version ? `@${opts.version}` : ""}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const installedPlugin = await ctx.api.post<PluginRecord>("/api/plugins/install", {
|
||||
packageName: resolvedPackage,
|
||||
version: opts.version,
|
||||
isLocalPath: isLocal,
|
||||
});
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(installedPlugin, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!installedPlugin) {
|
||||
console.log(pc.dim("Install returned no plugin record."));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
pc.green(
|
||||
`✓ Installed ${pc.bold(installedPlugin.pluginKey)} v${installedPlugin.version} (${installedPlugin.status})`,
|
||||
),
|
||||
);
|
||||
|
||||
if (installedPlugin.lastError) {
|
||||
console.log(pc.red(` Warning: ${installedPlugin.lastError}`));
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin uninstall <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("uninstall <pluginKey>")
|
||||
.description(
|
||||
"Uninstall a plugin by its plugin key or database ID.\n" +
|
||||
" Use --force to hard-purge all state and config.",
|
||||
)
|
||||
.option("--force", "Purge all plugin state and config (hard delete)", false)
|
||||
.action(async (pluginKey: string, opts: PluginUninstallOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const purge = opts.force === true;
|
||||
const qs = purge ? "?purge=true" : "";
|
||||
|
||||
if (!ctx.json) {
|
||||
console.log(
|
||||
pc.dim(
|
||||
purge
|
||||
? `Uninstalling and purging plugin: ${pluginKey}`
|
||||
: `Uninstalling plugin: ${pluginKey}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const result = await ctx.api.delete<PluginRecord | null>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}${qs}`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.green(`✓ Uninstalled ${pc.bold(pluginKey)}${purge ? " (purged)" : ""}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin enable <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("enable <pluginKey>")
|
||||
.description("Enable a disabled or errored plugin")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.post<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}/enable`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.green(`✓ Enabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin disable <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("disable <pluginKey>")
|
||||
.description("Disable a running plugin without uninstalling it")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.post<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}/disable`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(pc.dim(`Disabled ${pc.bold(pluginKey)} — status: ${result?.status ?? "unknown"}`));
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin inspect <plugin-key-or-id>
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("inspect <pluginKey>")
|
||||
.description("Show full details for an installed plugin")
|
||||
.action(async (pluginKey: string, opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const result = await ctx.api.get<PluginRecord>(
|
||||
`/api/plugins/${encodeURIComponent(pluginKey)}`,
|
||||
);
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(result, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
console.log(pc.red(`Plugin not found: ${pluginKey}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(formatPlugin(result));
|
||||
if (result.lastError) {
|
||||
console.log(`\n${pc.red("Last error:")}\n${result.lastError}`);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// plugin examples
|
||||
// -------------------------------------------------------------------------
|
||||
addCommonClientOptions(
|
||||
plugin
|
||||
.command("examples")
|
||||
.description("List bundled example plugins available for local install")
|
||||
.action(async (opts: BaseClientOptions) => {
|
||||
try {
|
||||
const ctx = resolveCommandContext(opts);
|
||||
const examples = await ctx.api.get<
|
||||
Array<{
|
||||
packageName: string;
|
||||
pluginKey: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
localPath: string;
|
||||
tag: string;
|
||||
}>
|
||||
>("/api/plugins/examples");
|
||||
|
||||
if (ctx.json) {
|
||||
printOutput(examples, { json: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = examples ?? [];
|
||||
if (rows.length === 0) {
|
||||
console.log(pc.dim("No bundled examples available."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ex of rows) {
|
||||
console.log(
|
||||
`${pc.bold(ex.displayName)} ${pc.dim(ex.pluginKey)}\n` +
|
||||
` ${ex.description}\n` +
|
||||
` ${pc.cyan(`paperclipai plugin install ${ex.localPath}`)}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
handleCommandError(err);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { registerDashboardCommands } from "./commands/client/dashboard.js";
|
||||
import { applyDataDirOverride, type DataDirOptionLike } from "./config/data-dir.js";
|
||||
import { loadPaperclipEnvFile } from "./config/env.js";
|
||||
import { registerWorktreeCommands } from "./commands/worktree.js";
|
||||
import { registerPluginCommands } from "./commands/client/plugin.js";
|
||||
|
||||
const program = new Command();
|
||||
const DATA_DIR_OPTION_HELP =
|
||||
@@ -136,6 +137,7 @@ registerApprovalCommands(program);
|
||||
registerActivityCommands(program);
|
||||
registerDashboardCommands(program);
|
||||
registerWorktreeCommands(program);
|
||||
registerPluginCommands(program);
|
||||
|
||||
const auth = program.command("auth").description("Authentication and bootstrap utilities");
|
||||
|
||||
|
||||
@@ -330,6 +330,34 @@ Operational policy:
|
||||
- `asset_id` uuid fk not null
|
||||
- `issue_comment_id` uuid fk null
|
||||
|
||||
## 7.15 `documents` + `document_revisions` + `issue_documents`
|
||||
|
||||
- `documents` stores editable text-first documents:
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
- `title` text null
|
||||
- `format` text not null (`markdown`)
|
||||
- `latest_body` text not null
|
||||
- `latest_revision_id` uuid null
|
||||
- `latest_revision_number` int not null
|
||||
- `created_by_agent_id` uuid fk null
|
||||
- `created_by_user_id` uuid/text fk null
|
||||
- `updated_by_agent_id` uuid fk null
|
||||
- `updated_by_user_id` uuid/text fk null
|
||||
- `document_revisions` stores append-only history:
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
- `document_id` uuid fk not null
|
||||
- `revision_number` int not null
|
||||
- `body` text not null
|
||||
- `change_summary` text null
|
||||
- `issue_documents` links documents to issues with a stable workflow key:
|
||||
- `id` uuid pk
|
||||
- `company_id` uuid fk not null
|
||||
- `issue_id` uuid fk not null
|
||||
- `document_id` uuid fk not null
|
||||
- `key` text not null (`plan`, `design`, `notes`, etc.)
|
||||
|
||||
## 8. State Machines
|
||||
|
||||
## 8.1 Agent Status
|
||||
@@ -441,6 +469,11 @@ All endpoints are under `/api` and return JSON.
|
||||
- `POST /companies/:companyId/issues`
|
||||
- `GET /issues/:issueId`
|
||||
- `PATCH /issues/:issueId`
|
||||
- `GET /issues/:issueId/documents`
|
||||
- `GET /issues/:issueId/documents/:key`
|
||||
- `PUT /issues/:issueId/documents/:key`
|
||||
- `GET /issues/:issueId/documents/:key/revisions`
|
||||
- `DELETE /issues/:issueId/documents/:key`
|
||||
- `POST /issues/:issueId/checkout`
|
||||
- `POST /issues/:issueId/release`
|
||||
- `POST /issues/:issueId/comments`
|
||||
|
||||
699
doc/plans/2026-03-13-plugin-kitchen-sink-example.md
Normal file
699
doc/plans/2026-03-13-plugin-kitchen-sink-example.md
Normal file
@@ -0,0 +1,699 @@
|
||||
# Kitchen Sink Plugin Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Add a new first-party example plugin, `Kitchen Sink (Example)`, that demonstrates every currently implemented Paperclip plugin API surface in one place.
|
||||
|
||||
This plugin is meant to be:
|
||||
|
||||
- a living reference implementation for contributors
|
||||
- a manual test harness for the plugin runtime
|
||||
- a discoverable demo of what plugins can actually do today
|
||||
|
||||
It is not meant to be a polished end-user product plugin.
|
||||
|
||||
## Why
|
||||
|
||||
The current plugin system has a real API surface, but it is spread across:
|
||||
|
||||
- SDK docs
|
||||
- SDK types
|
||||
- plugin spec prose
|
||||
- two example plugins that each show only a narrow slice
|
||||
|
||||
That makes it hard to answer basic questions like:
|
||||
|
||||
- what can plugins render?
|
||||
- what can plugin workers actually do?
|
||||
- which surfaces are real versus aspirational?
|
||||
- how should a new plugin be structured in this repo?
|
||||
|
||||
The kitchen-sink plugin should answer those questions by example.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
The plugin is successful if a contributor can install it and, without reading the SDK first, discover and exercise the current plugin runtime surface area from inside Paperclip.
|
||||
|
||||
Concretely:
|
||||
|
||||
- it installs from the bundled examples list
|
||||
- it exposes at least one demo for every implemented worker API surface
|
||||
- it exposes at least one demo for every host-mounted UI surface
|
||||
- it clearly labels local-only / trusted-only demos
|
||||
- it is safe enough for local development by default
|
||||
- it doubles as a regression harness for plugin runtime changes
|
||||
|
||||
## Constraints
|
||||
|
||||
- Keep it instance-installed, not company-installed.
|
||||
- Treat this as a trusted/local example plugin.
|
||||
- Do not rely on cloud-safe runtime assumptions.
|
||||
- Avoid destructive defaults.
|
||||
- Avoid irreversible mutations unless they are clearly labeled and easy to undo.
|
||||
|
||||
## Source Of Truth For This Plan
|
||||
|
||||
This plan is based on the currently implemented SDK/types/runtime, not only the long-horizon spec.
|
||||
|
||||
Primary references:
|
||||
|
||||
- `packages/plugins/sdk/README.md`
|
||||
- `packages/plugins/sdk/src/types.ts`
|
||||
- `packages/plugins/sdk/src/ui/types.ts`
|
||||
- `packages/shared/src/constants.ts`
|
||||
- `packages/shared/src/types/plugin.ts`
|
||||
|
||||
## Current Surface Inventory
|
||||
|
||||
### Worker/runtime APIs to demonstrate
|
||||
|
||||
These are the concrete `ctx` clients currently exposed by the SDK:
|
||||
|
||||
- `ctx.config`
|
||||
- `ctx.events`
|
||||
- `ctx.jobs`
|
||||
- `ctx.launchers`
|
||||
- `ctx.http`
|
||||
- `ctx.secrets`
|
||||
- `ctx.assets`
|
||||
- `ctx.activity`
|
||||
- `ctx.state`
|
||||
- `ctx.entities`
|
||||
- `ctx.projects`
|
||||
- `ctx.companies`
|
||||
- `ctx.issues`
|
||||
- `ctx.agents`
|
||||
- `ctx.goals`
|
||||
- `ctx.data`
|
||||
- `ctx.actions`
|
||||
- `ctx.streams`
|
||||
- `ctx.tools`
|
||||
- `ctx.metrics`
|
||||
- `ctx.logger`
|
||||
|
||||
### UI surfaces to demonstrate
|
||||
|
||||
Surfaces defined in the SDK:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `detailTab`
|
||||
- `taskDetailView`
|
||||
- `projectSidebarItem`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
### Current host confidence
|
||||
|
||||
Confirmed or strongly indicated as mounted in the current app:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `detailTab`
|
||||
- `projectSidebarItem`
|
||||
- comment surfaces
|
||||
- launcher infrastructure
|
||||
|
||||
Need explicit validation before claiming full demo coverage:
|
||||
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `taskDetailView`
|
||||
- `toolbarButton` as direct slot, distinct from launcher placement
|
||||
- `contextMenuItem` as direct slot, distinct from comment menu and launcher placement
|
||||
|
||||
The implementation should keep a small validation checklist for these before we call the plugin "complete".
|
||||
|
||||
## Plugin Concept
|
||||
|
||||
The plugin should be named:
|
||||
|
||||
- display name: `Kitchen Sink (Example)`
|
||||
- package: `@paperclipai/plugin-kitchen-sink-example`
|
||||
- plugin id: `paperclip.kitchen-sink-example` or `paperclip-kitchen-sink-example`
|
||||
|
||||
Recommendation: use `paperclip-kitchen-sink-example` to match current in-repo example naming style.
|
||||
|
||||
Category mix:
|
||||
|
||||
- `ui`
|
||||
- `automation`
|
||||
- `workspace`
|
||||
- `connector`
|
||||
|
||||
That is intentionally broad because the point is coverage.
|
||||
|
||||
## UX Shape
|
||||
|
||||
The plugin should have one main full-page demo console plus smaller satellites on other surfaces.
|
||||
|
||||
### 1. Plugin page
|
||||
|
||||
Primary route: the plugin `page` surface should be the central dashboard for all demos.
|
||||
|
||||
Recommended page sections:
|
||||
|
||||
- `Overview`
|
||||
- what this plugin demonstrates
|
||||
- current capabilities granted
|
||||
- current host context
|
||||
- `UI Surfaces`
|
||||
- links explaining where each other surface should appear
|
||||
- `Data + Actions`
|
||||
- buttons and forms for bridge-driven worker demos
|
||||
- `Events + Streams`
|
||||
- emit event
|
||||
- watch event log
|
||||
- stream demo output
|
||||
- `Paperclip Domain APIs`
|
||||
- companies
|
||||
- projects/workspaces
|
||||
- issues
|
||||
- goals
|
||||
- agents
|
||||
- `Local Workspace + Process`
|
||||
- file listing
|
||||
- file read/write scratch area
|
||||
- child process demo
|
||||
- `Jobs + Webhooks + Tools`
|
||||
- job status
|
||||
- webhook URL and recent deliveries
|
||||
- declared tools
|
||||
- `State + Entities + Assets`
|
||||
- scoped state editor
|
||||
- plugin entity inspector
|
||||
- upload/generated asset demo
|
||||
- `Observability`
|
||||
- metrics written
|
||||
- activity log samples
|
||||
- latest worker logs
|
||||
|
||||
### 2. Dashboard widget
|
||||
|
||||
A compact widget on the main dashboard should show:
|
||||
|
||||
- plugin health
|
||||
- count of demos exercised
|
||||
- recent event/stream activity
|
||||
- shortcut to the full plugin page
|
||||
|
||||
### 3. Project sidebar item
|
||||
|
||||
Add a `Kitchen Sink` link under each project that deep-links into a project-scoped plugin tab.
|
||||
|
||||
### 4. Detail tabs
|
||||
|
||||
Use detail tabs to demonstrate entity-context rendering on:
|
||||
|
||||
- `project`
|
||||
- `issue`
|
||||
- `agent`
|
||||
- `goal`
|
||||
|
||||
Each tab should show:
|
||||
|
||||
- the host context it received
|
||||
- the relevant entity fetch via worker bridge
|
||||
- one small action scoped to that entity
|
||||
|
||||
### 5. Comment surfaces
|
||||
|
||||
Use issue comment demos to prove comment-specific extension points:
|
||||
|
||||
- `commentAnnotation`
|
||||
- render parsed metadata below each comment
|
||||
- show comment id, issue id, and a small derived status
|
||||
- `commentContextMenuItem`
|
||||
- add a menu action like `Copy Context To Kitchen Sink`
|
||||
- action writes a plugin entity or state record for later inspection
|
||||
|
||||
### 6. Settings page
|
||||
|
||||
Custom `settingsPage` should be intentionally simple and operational:
|
||||
|
||||
- `About`
|
||||
- `Danger / Trust Model`
|
||||
- demo toggles
|
||||
- local process defaults
|
||||
- workspace scratch-path behavior
|
||||
- secret reference inputs
|
||||
- event/job/webhook sample config
|
||||
|
||||
This plugin should also keep the generic plugin settings `Status` tab useful by writing health, logs, and metrics.
|
||||
|
||||
## Feature Matrix
|
||||
|
||||
Each implemented worker API should have a visible demo.
|
||||
|
||||
### `ctx.config`
|
||||
|
||||
Demo:
|
||||
|
||||
- read live config
|
||||
- show config JSON
|
||||
- react to config changes without restart where possible
|
||||
|
||||
### `ctx.events`
|
||||
|
||||
Demos:
|
||||
|
||||
- emit a plugin event
|
||||
- subscribe to plugin events
|
||||
- subscribe to a core Paperclip event such as `issue.created`
|
||||
- show recent received events in a timeline
|
||||
|
||||
### `ctx.jobs`
|
||||
|
||||
Demos:
|
||||
|
||||
- one scheduled heartbeat-style demo job
|
||||
- one manual run button from the UI if host supports manual job trigger
|
||||
- show last run result and timestamps
|
||||
|
||||
### `ctx.launchers`
|
||||
|
||||
Demos:
|
||||
|
||||
- declare launchers in manifest
|
||||
- optionally register one runtime launcher from the worker
|
||||
- show launcher metadata on the plugin page
|
||||
|
||||
### `ctx.http`
|
||||
|
||||
Demo:
|
||||
|
||||
- make a simple outbound GET request to a safe endpoint
|
||||
- show status code, latency, and JSON result
|
||||
|
||||
Recommendation: default to a Paperclip-local endpoint or a stable public echo endpoint to avoid flaky docs.
|
||||
|
||||
### `ctx.secrets`
|
||||
|
||||
Demo:
|
||||
|
||||
- operator enters a secret reference in config
|
||||
- plugin resolves it on demand
|
||||
- UI only shows masked result length / success status, never raw secret
|
||||
|
||||
### `ctx.assets`
|
||||
|
||||
Demos:
|
||||
|
||||
- generate a text asset from the UI
|
||||
- optionally upload a tiny JSON blob or screenshot-like text file
|
||||
- show returned asset URL
|
||||
|
||||
### `ctx.activity`
|
||||
|
||||
Demo:
|
||||
|
||||
- button to write a plugin activity log entry against current company/entity
|
||||
|
||||
### `ctx.state`
|
||||
|
||||
Demos:
|
||||
|
||||
- instance-scoped state
|
||||
- company-scoped state
|
||||
- project-scoped state
|
||||
- issue-scoped state
|
||||
- delete/reset controls
|
||||
|
||||
Use a small state inspector/editor on the plugin page.
|
||||
|
||||
### `ctx.entities`
|
||||
|
||||
Demos:
|
||||
|
||||
- create plugin-owned sample records
|
||||
- list/filter them
|
||||
- show one realistic use case such as "copied comments" or "demo sync records"
|
||||
|
||||
### `ctx.projects`
|
||||
|
||||
Demos:
|
||||
|
||||
- list projects
|
||||
- list project workspaces
|
||||
- resolve primary workspace
|
||||
- resolve workspace for issue
|
||||
|
||||
### `ctx.companies`
|
||||
|
||||
Demo:
|
||||
|
||||
- list companies and show current selected company
|
||||
|
||||
### `ctx.issues`
|
||||
|
||||
Demos:
|
||||
|
||||
- list issues in current company
|
||||
- create issue
|
||||
- update issue status/title
|
||||
- list comments
|
||||
- create comment
|
||||
|
||||
### `ctx.agents`
|
||||
|
||||
Demos:
|
||||
|
||||
- list agents
|
||||
- invoke one agent with a test prompt
|
||||
- pause/resume where safe
|
||||
|
||||
Agent mutation controls should be behind an explicit warning.
|
||||
|
||||
### `ctx.agents.sessions`
|
||||
|
||||
Demos:
|
||||
|
||||
- create agent chat session
|
||||
- send message
|
||||
- stream events back to the UI
|
||||
- close session
|
||||
|
||||
This is a strong candidate for the best "wow" demo on the plugin page.
|
||||
|
||||
### `ctx.goals`
|
||||
|
||||
Demos:
|
||||
|
||||
- list goals
|
||||
- create goal
|
||||
- update status/title
|
||||
|
||||
### `ctx.data`
|
||||
|
||||
Use throughout the plugin for all read-side bridge demos.
|
||||
|
||||
### `ctx.actions`
|
||||
|
||||
Use throughout the plugin for all mutation-side bridge demos.
|
||||
|
||||
### `ctx.streams`
|
||||
|
||||
Demos:
|
||||
|
||||
- live event log stream
|
||||
- token-style stream from an agent session relay
|
||||
- fake progress stream for a long-running action
|
||||
|
||||
### `ctx.tools`
|
||||
|
||||
Demos:
|
||||
|
||||
- declare 2-3 simple agent tools
|
||||
- tool 1: echo/diagnostics
|
||||
- tool 2: project/workspace summary
|
||||
- tool 3: create issue or write plugin state
|
||||
|
||||
The plugin page should list declared tools and show example input payloads.
|
||||
|
||||
### `ctx.metrics`
|
||||
|
||||
Demo:
|
||||
|
||||
- write a sample metric on each major demo action
|
||||
- surface a small recent metrics table in the plugin page
|
||||
|
||||
### `ctx.logger`
|
||||
|
||||
Demo:
|
||||
|
||||
- every action logs structured entries
|
||||
- plugin settings `Status` page then doubles as the log viewer
|
||||
|
||||
## Local Workspace And Process Demos
|
||||
|
||||
The plugin SDK intentionally leaves file/process operations to the plugin itself once it has workspace metadata.
|
||||
|
||||
The kitchen-sink plugin should demonstrate that explicitly.
|
||||
|
||||
### Workspace demos
|
||||
|
||||
- list files from a selected workspace
|
||||
- read a file
|
||||
- write to a plugin-owned scratch file
|
||||
- optionally search files with `rg` if available
|
||||
|
||||
### Process demos
|
||||
|
||||
- run a short-lived command like `pwd`, `ls`, or `git status`
|
||||
- stream stdout/stderr back to UI
|
||||
- show exit code and timing
|
||||
|
||||
Important safeguards:
|
||||
|
||||
- default commands must be read-only
|
||||
- no shell interpolation from arbitrary free-form input in v1
|
||||
- provide a curated command list or a strongly validated command form
|
||||
- clearly label this area as local-only and trusted-only
|
||||
|
||||
## Proposed Manifest Coverage
|
||||
|
||||
The plugin should aim to declare:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `detailTab` for `project`, `issue`, `agent`, `goal`
|
||||
- `projectSidebarItem`
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
Then, after host validation, add if supported:
|
||||
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `taskDetailView`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
|
||||
It should also declare one or more `ui.launchers` entries to exercise launcher behavior independently of slot rendering.
|
||||
|
||||
## Proposed Package Layout
|
||||
|
||||
New package:
|
||||
|
||||
- `packages/plugins/examples/plugin-kitchen-sink-example/`
|
||||
|
||||
Expected files:
|
||||
|
||||
- `package.json`
|
||||
- `README.md`
|
||||
- `tsconfig.json`
|
||||
- `src/index.ts`
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
- `src/ui/index.tsx`
|
||||
- `src/ui/components/...`
|
||||
- `src/ui/hooks/...`
|
||||
- `src/lib/...`
|
||||
- optional `scripts/build-ui.mjs` if UI bundling needs esbuild
|
||||
|
||||
## Proposed Internal Architecture
|
||||
|
||||
### Worker modules
|
||||
|
||||
Recommended split:
|
||||
|
||||
- `src/worker.ts`
|
||||
- plugin definition and wiring
|
||||
- `src/worker/data.ts`
|
||||
- `ctx.data.register(...)`
|
||||
- `src/worker/actions.ts`
|
||||
- `ctx.actions.register(...)`
|
||||
- `src/worker/events.ts`
|
||||
- event subscriptions and event log buffer
|
||||
- `src/worker/jobs.ts`
|
||||
- scheduled job handlers
|
||||
- `src/worker/tools.ts`
|
||||
- tool declarations and handlers
|
||||
- `src/worker/local-runtime.ts`
|
||||
- file/process demos
|
||||
- `src/worker/demo-store.ts`
|
||||
- helpers for state/entities/assets/metrics
|
||||
|
||||
### UI modules
|
||||
|
||||
Recommended split:
|
||||
|
||||
- `src/ui/index.tsx`
|
||||
- exported slot components
|
||||
- `src/ui/page/KitchenSinkPage.tsx`
|
||||
- `src/ui/settings/KitchenSinkSettingsPage.tsx`
|
||||
- `src/ui/widgets/KitchenSinkDashboardWidget.tsx`
|
||||
- `src/ui/tabs/ProjectKitchenSinkTab.tsx`
|
||||
- `src/ui/tabs/IssueKitchenSinkTab.tsx`
|
||||
- `src/ui/tabs/AgentKitchenSinkTab.tsx`
|
||||
- `src/ui/tabs/GoalKitchenSinkTab.tsx`
|
||||
- `src/ui/comments/KitchenSinkCommentAnnotation.tsx`
|
||||
- `src/ui/comments/KitchenSinkCommentMenuItem.tsx`
|
||||
- `src/ui/shared/...`
|
||||
|
||||
## Configuration Schema
|
||||
|
||||
The plugin should have a substantial but understandable `instanceConfigSchema`.
|
||||
|
||||
Recommended config fields:
|
||||
|
||||
- `enableDangerousDemos`
|
||||
- `enableWorkspaceDemos`
|
||||
- `enableProcessDemos`
|
||||
- `showSidebarEntry`
|
||||
- `showSidebarPanel`
|
||||
- `showProjectSidebarItem`
|
||||
- `showCommentAnnotation`
|
||||
- `showCommentContextMenuItem`
|
||||
- `showToolbarLauncher`
|
||||
- `defaultDemoCompanyId` optional
|
||||
- `secretRefExample`
|
||||
- `httpDemoUrl`
|
||||
- `processAllowedCommands`
|
||||
- `workspaceScratchSubdir`
|
||||
|
||||
Defaults should keep risky behavior off.
|
||||
|
||||
## Safety Defaults
|
||||
|
||||
Default posture:
|
||||
|
||||
- UI and read-only demos on
|
||||
- mutating domain demos on but explicitly labeled
|
||||
- process demos off by default
|
||||
- no arbitrary shell input by default
|
||||
- no raw secret rendering ever
|
||||
|
||||
## Phased Build Plan
|
||||
|
||||
### Phase 1: Core plugin skeleton
|
||||
|
||||
- scaffold package
|
||||
- add manifest, worker, UI entrypoints
|
||||
- add README
|
||||
- make it appear in bundled examples list
|
||||
|
||||
### Phase 2: Core, confirmed UI surfaces
|
||||
|
||||
- plugin page
|
||||
- settings page
|
||||
- dashboard widget
|
||||
- project sidebar item
|
||||
- detail tabs
|
||||
|
||||
### Phase 3: Core worker APIs
|
||||
|
||||
- config
|
||||
- state
|
||||
- entities
|
||||
- companies/projects/issues/goals
|
||||
- data/actions
|
||||
- metrics/logger/activity
|
||||
|
||||
### Phase 4: Real-time and automation APIs
|
||||
|
||||
- streams
|
||||
- events
|
||||
- jobs
|
||||
- webhooks
|
||||
- agent sessions
|
||||
- tools
|
||||
|
||||
### Phase 5: Local trusted runtime demos
|
||||
|
||||
- workspace file demos
|
||||
- child process demos
|
||||
- guarded by config
|
||||
|
||||
### Phase 6: Secondary UI surfaces
|
||||
|
||||
- comment annotation
|
||||
- comment context menu item
|
||||
- launchers
|
||||
|
||||
### Phase 7: Validation-only surfaces
|
||||
|
||||
Validate whether the current host truly mounts:
|
||||
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `taskDetailView`
|
||||
- direct-slot `toolbarButton`
|
||||
- direct-slot `contextMenuItem`
|
||||
|
||||
If mounted, add demos.
|
||||
If not mounted, document them as SDK-defined but host-pending.
|
||||
|
||||
## Documentation Deliverables
|
||||
|
||||
The plugin should ship with a README that includes:
|
||||
|
||||
- what it demonstrates
|
||||
- which surfaces are local-only
|
||||
- how to install it
|
||||
- where each UI surface should appear
|
||||
- a mapping from demo card to SDK API
|
||||
|
||||
It should also be referenced from plugin docs as the "reference everything plugin".
|
||||
|
||||
## Testing And Verification
|
||||
|
||||
Minimum verification:
|
||||
|
||||
- package typecheck/build
|
||||
- install from bundled example list
|
||||
- page loads
|
||||
- widget appears
|
||||
- project tab appears
|
||||
- comment surfaces render
|
||||
- settings page loads
|
||||
- key actions succeed
|
||||
|
||||
Recommended manual checklist:
|
||||
|
||||
- create issue from plugin
|
||||
- create goal from plugin
|
||||
- emit and receive plugin event
|
||||
- stream action output
|
||||
- open agent session and receive streamed reply
|
||||
- upload an asset
|
||||
- write plugin activity log
|
||||
- run a safe local process demo
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should the process demo remain curated-command-only in the first pass?
|
||||
Recommendation: yes.
|
||||
|
||||
2. Should the plugin create throwaway "kitchen sink demo" issues/goals automatically?
|
||||
Recommendation: no. Make creation explicit.
|
||||
|
||||
3. Should we expose unsupported-but-typed surfaces in the UI even if host mounting is not wired?
|
||||
Recommendation: yes, but label them as `SDK-defined / host validation pending`.
|
||||
|
||||
4. Should agent mutation demos include pause/resume by default?
|
||||
Recommendation: probably yes, but behind a warning block.
|
||||
|
||||
5. Should this plugin be treated as a supported regression harness in CI later?
|
||||
Recommendation: yes. Long term, this should be the plugin-runtime smoke test package.
|
||||
|
||||
## Recommended Next Step
|
||||
|
||||
If this plan looks right, the next implementation pass should start by building only:
|
||||
|
||||
- package skeleton
|
||||
- page
|
||||
- settings page
|
||||
- dashboard widget
|
||||
- one project detail tab
|
||||
- one issue detail tab
|
||||
- the basic worker/action/data/state/event scaffolding
|
||||
|
||||
That is enough to lock the architecture before filling in every demo surface.
|
||||
155
doc/plugins/PLUGIN_AUTHORING_GUIDE.md
Normal file
155
doc/plugins/PLUGIN_AUTHORING_GUIDE.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Plugin Authoring Guide
|
||||
|
||||
This guide describes the current, implemented way to create a Paperclip plugin in this repo.
|
||||
|
||||
It is intentionally narrower than [PLUGIN_SPEC.md](./PLUGIN_SPEC.md). The spec includes future ideas; this guide only covers the alpha surface that exists now.
|
||||
|
||||
## Current reality
|
||||
|
||||
- Treat plugin workers and plugin UI as trusted code.
|
||||
- Plugin UI runs as same-origin JavaScript inside the main Paperclip app.
|
||||
- Worker-side host APIs are capability-gated.
|
||||
- Plugin UI is not sandboxed by manifest capabilities.
|
||||
- There is no host-provided shared React component kit for plugins yet.
|
||||
- `ctx.assets` is not supported in the current runtime.
|
||||
|
||||
## Scaffold a plugin
|
||||
|
||||
Use the scaffold package:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name --output ./packages/plugins/examples
|
||||
```
|
||||
|
||||
For a plugin that lives outside the Paperclip repo:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/create-paperclip-plugin build
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @yourscope/plugin-name \
|
||||
--output /absolute/path/to/plugin-repos \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That creates a package with:
|
||||
|
||||
- `src/manifest.ts`
|
||||
- `src/worker.ts`
|
||||
- `src/ui/index.tsx`
|
||||
- `tests/plugin.spec.ts`
|
||||
- `esbuild.config.mjs`
|
||||
- `rollup.config.mjs`
|
||||
|
||||
Inside this monorepo, the scaffold uses `workspace:*` for `@paperclipai/plugin-sdk`.
|
||||
|
||||
Outside this monorepo, the scaffold snapshots `@paperclipai/plugin-sdk` from the local Paperclip checkout into a `.paperclip-sdk/` tarball so you can build and test a plugin without publishing anything to npm first.
|
||||
|
||||
## Recommended local workflow
|
||||
|
||||
From the generated plugin folder:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For local development, install it into Paperclip from an absolute local path through the plugin manager or API. The server supports local filesystem installs and watches local-path plugins for file changes so worker restarts happen automatically after rebuilds.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"packageName":"/absolute/path/to/your-plugin","isLocalPath":true}'
|
||||
```
|
||||
|
||||
## Supported alpha surface
|
||||
|
||||
Worker:
|
||||
|
||||
- config
|
||||
- events
|
||||
- jobs
|
||||
- launchers
|
||||
- http
|
||||
- secrets
|
||||
- activity
|
||||
- state
|
||||
- entities
|
||||
- projects and project workspaces
|
||||
- companies
|
||||
- issues and comments
|
||||
- agents and agent sessions
|
||||
- goals
|
||||
- data/actions
|
||||
- streams
|
||||
- tools
|
||||
- metrics
|
||||
- logger
|
||||
|
||||
UI:
|
||||
|
||||
- `usePluginData`
|
||||
- `usePluginAction`
|
||||
- `usePluginStream`
|
||||
- `usePluginToast`
|
||||
- `useHostContext`
|
||||
- typed slot props from `@paperclipai/plugin-sdk/ui`
|
||||
|
||||
Mount surfaces currently wired in the host include:
|
||||
|
||||
- `page`
|
||||
- `settingsPage`
|
||||
- `dashboardWidget`
|
||||
- `sidebar`
|
||||
- `sidebarPanel`
|
||||
- `detailTab`
|
||||
- `taskDetailView`
|
||||
- `projectSidebarItem`
|
||||
- `globalToolbarButton`
|
||||
- `toolbarButton`
|
||||
- `contextMenuItem`
|
||||
- `commentAnnotation`
|
||||
- `commentContextMenuItem`
|
||||
|
||||
## Company routes
|
||||
|
||||
Plugins may declare a `page` slot with `routePath` to own a company route like:
|
||||
|
||||
```text
|
||||
/:companyPrefix/<routePath>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `routePath` must be a single lowercase slug
|
||||
- it cannot collide with reserved host routes
|
||||
- it cannot duplicate another installed plugin page route
|
||||
|
||||
## Publishing guidance
|
||||
|
||||
- Use npm packages as the deployment artifact.
|
||||
- Treat repo-local example installs as a development workflow only.
|
||||
- Prefer keeping plugin UI self-contained inside the package.
|
||||
- Do not rely on host design-system components or undocumented app internals.
|
||||
- GitHub repository installs are not a first-class workflow today. For local development, use a checked-out local path. For production, publish to npm or a private npm-compatible registry.
|
||||
|
||||
## Verification before handoff
|
||||
|
||||
At minimum:
|
||||
|
||||
```bash
|
||||
pnpm --filter <your-plugin-package> typecheck
|
||||
pnpm --filter <your-plugin-package> test
|
||||
pnpm --filter <your-plugin-package> build
|
||||
```
|
||||
|
||||
If you changed host integration too, also run:
|
||||
|
||||
```bash
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
@@ -8,6 +8,29 @@ It expands the brief plugin notes in [doc/SPEC.md](../SPEC.md) and should be rea
|
||||
This is not part of the V1 implementation contract in [doc/SPEC-implementation.md](../SPEC-implementation.md).
|
||||
It is the full target architecture for the plugin system that should follow V1.
|
||||
|
||||
## Current implementation caveats
|
||||
|
||||
The code in this repo now includes an early plugin runtime and admin UI, but it does not yet deliver the full deployment model described in this spec.
|
||||
|
||||
Today, the practical deployment model is:
|
||||
|
||||
- single-tenant
|
||||
- self-hosted
|
||||
- single-node or otherwise filesystem-persistent
|
||||
|
||||
Current limitations to keep in mind:
|
||||
|
||||
- Plugin UI bundles currently run as same-origin JavaScript inside the main Paperclip app. Treat plugin UI as trusted code, not a sandboxed frontend capability boundary.
|
||||
- Manifest capabilities currently gate worker-side host RPC calls. They do not prevent plugin UI code from calling ordinary Paperclip HTTP APIs directly.
|
||||
- Runtime installs assume a writable local filesystem for the plugin package directory and plugin data directory.
|
||||
- Runtime npm installs assume `npm` is available in the running environment and that the host can reach the configured package registry.
|
||||
- Published npm packages are the intended install artifact for deployed plugins.
|
||||
- The repo example plugins under `packages/plugins/examples/` are development conveniences. They work from a source checkout and should not be assumed to exist in a generic published build unless they are explicitly shipped with that build.
|
||||
- Dynamic plugin install is not yet cloud-ready for horizontally scaled or ephemeral deployments. There is no shared artifact store, install coordination, or cross-node distribution layer yet.
|
||||
- The current runtime does not yet ship a real host-provided plugin UI component kit, and it does not support plugin asset uploads/reads. Treat those as future-scope ideas in this spec, not current implementation promises.
|
||||
|
||||
In practice, that means the current implementation is a good fit for local development and self-hosted persistent deployments, but not yet for multi-instance cloud plugin distribution.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
This spec covers:
|
||||
@@ -212,6 +235,8 @@ Suggested layout:
|
||||
|
||||
The package install directory and the plugin data directory are separate.
|
||||
|
||||
This on-disk model is the reason the current implementation expects a persistent writable host filesystem. Cloud-safe artifact replication is future work.
|
||||
|
||||
## 8.2 Operator Commands
|
||||
|
||||
Paperclip should add CLI commands:
|
||||
@@ -237,6 +262,8 @@ The install process is:
|
||||
7. Start plugin worker and run health/validation.
|
||||
8. Mark plugin `ready` or `error`.
|
||||
|
||||
For the current implementation, this install flow should be read as a single-host workflow. A successful install writes packages to the local host, and other app nodes will not automatically receive that plugin unless a future shared distribution mechanism is added.
|
||||
|
||||
## 9. Load Order And Precedence
|
||||
|
||||
Load order must be deterministic.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Issues
|
||||
summary: Issue CRUD, checkout/release, comments, and attachments
|
||||
summary: Issue CRUD, checkout/release, comments, documents, and attachments
|
||||
---
|
||||
|
||||
Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, and file attachments.
|
||||
Issues are the unit of work in Paperclip. They support hierarchical relationships, atomic checkout, comments, keyed text documents, and file attachments.
|
||||
|
||||
## List Issues
|
||||
|
||||
@@ -29,6 +29,12 @@ GET /api/issues/{issueId}
|
||||
|
||||
Returns the issue with `project`, `goal`, and `ancestors` (parent chain with their projects and goals).
|
||||
|
||||
The response also includes:
|
||||
|
||||
- `planDocument`: the full text of the issue document with key `plan`, when present
|
||||
- `documentSummaries`: metadata for all linked issue documents
|
||||
- `legacyPlanDocument`: a read-only fallback when the description still contains an old `<plan>` block
|
||||
|
||||
## Create Issue
|
||||
|
||||
```
|
||||
@@ -100,6 +106,54 @@ POST /api/issues/{issueId}/comments
|
||||
|
||||
@-mentions (`@AgentName`) in comments trigger heartbeats for the mentioned agent.
|
||||
|
||||
## Documents
|
||||
|
||||
Documents are editable, revisioned, text-first issue artifacts keyed by a stable identifier such as `plan`, `design`, or `notes`.
|
||||
|
||||
### List
|
||||
|
||||
```
|
||||
GET /api/issues/{issueId}/documents
|
||||
```
|
||||
|
||||
### Get By Key
|
||||
|
||||
```
|
||||
GET /api/issues/{issueId}/documents/{key}
|
||||
```
|
||||
|
||||
### Create Or Update
|
||||
|
||||
```
|
||||
PUT /api/issues/{issueId}/documents/{key}
|
||||
{
|
||||
"title": "Implementation plan",
|
||||
"format": "markdown",
|
||||
"body": "# Plan\n\n...",
|
||||
"baseRevisionId": "{latestRevisionId}"
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- omit `baseRevisionId` when creating a new document
|
||||
- provide the current `baseRevisionId` when updating an existing document
|
||||
- stale `baseRevisionId` returns `409 Conflict`
|
||||
|
||||
### Revision History
|
||||
|
||||
```
|
||||
GET /api/issues/{issueId}/documents/{key}/revisions
|
||||
```
|
||||
|
||||
### Delete
|
||||
|
||||
```
|
||||
DELETE /api/issues/{issueId}/documents/{key}
|
||||
```
|
||||
|
||||
Delete is board-only in the current implementation.
|
||||
|
||||
## Attachments
|
||||
|
||||
### Upload
|
||||
|
||||
569
docs/plans/2026-03-13-issue-documents-plan.md
Normal file
569
docs/plans/2026-03-13-issue-documents-plan.md
Normal file
@@ -0,0 +1,569 @@
|
||||
# Issue Documents Plan
|
||||
|
||||
Status: Draft
|
||||
Owner: Backend + UI + Agent Protocol
|
||||
Date: 2026-03-13
|
||||
Primary issue: `PAP-448`
|
||||
|
||||
## Summary
|
||||
|
||||
Add first-class **documents** to Paperclip as editable, revisioned, company-scoped text artifacts that can be linked to issues.
|
||||
|
||||
The first required convention is a document with key `plan`.
|
||||
|
||||
This solves the immediate workflow problem in `PAP-448`:
|
||||
|
||||
- plans should stop living inside issue descriptions as `<plan>` blocks
|
||||
- agents and board users should be able to create/update issue documents directly
|
||||
- `GET /api/issues/:id` should include the full `plan` document and expose the other available documents
|
||||
- issue detail should render documents under the description
|
||||
|
||||
This should be built as the **text-document slice** of the broader artifact system, not as a replacement for attachments/assets.
|
||||
|
||||
## Recommended Product Shape
|
||||
|
||||
### Documents vs attachments vs artifacts
|
||||
|
||||
- **Documents**: editable text content with stable keys and revision history.
|
||||
- **Attachments**: uploaded/generated opaque files backed by storage (`assets` + `issue_attachments`).
|
||||
- **Artifacts**: later umbrella/read-model that can unify documents, attachments, previews, and workspace files.
|
||||
|
||||
Recommendation:
|
||||
|
||||
- implement **issue documents now**
|
||||
- keep existing attachments as-is
|
||||
- defer full artifact unification until there is a second real consumer beyond issue documents + attachments
|
||||
|
||||
This keeps `PAP-448` focused while still fitting the larger artifact direction.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Give issues first-class keyed documents, starting with `plan`.
|
||||
2. Make documents editable by board users and same-company agents with issue access.
|
||||
3. Preserve change history with append-only revisions.
|
||||
4. Make the `plan` document automatically available in the normal issue fetch used by agents/heartbeats.
|
||||
5. Replace the current `<plan>`-in-description convention in skills/docs.
|
||||
6. Keep the design compatible with a future artifact/deliverables layer.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- full collaborative doc editing
|
||||
- binary-file version history
|
||||
- browser IDE or workspace editor
|
||||
- full artifact-system implementation in the same change
|
||||
- generalized polymorphic relations for every entity type on day one
|
||||
|
||||
## Product Decisions
|
||||
|
||||
### 1. Keyed issue documents
|
||||
|
||||
Each issue can have multiple documents. Each document relation has a stable key:
|
||||
|
||||
- `plan`
|
||||
- `design`
|
||||
- `notes`
|
||||
- `report`
|
||||
- custom keys later
|
||||
|
||||
Key rules:
|
||||
|
||||
- unique per issue, case-insensitive
|
||||
- normalized to lowercase slug form
|
||||
- machine-oriented and stable
|
||||
- title is separate and user-facing
|
||||
|
||||
The `plan` key is conventional and reserved by Paperclip workflow/docs.
|
||||
|
||||
### 2. Text-first v1
|
||||
|
||||
V1 documents should be text-first, not arbitrary blobs.
|
||||
|
||||
Recommended supported formats:
|
||||
|
||||
- `markdown`
|
||||
- `plain_text`
|
||||
- `json`
|
||||
- `html`
|
||||
|
||||
Recommendation:
|
||||
|
||||
- optimize UI for `markdown`
|
||||
- allow raw editing for the others
|
||||
- keep PDFs/images/CSVs/etc as attachments/artifacts, not editable documents
|
||||
|
||||
### 3. Revision model
|
||||
|
||||
Every document update creates a new immutable revision.
|
||||
|
||||
The current document row stores the latest snapshot for fast reads.
|
||||
|
||||
### 4. Concurrency model
|
||||
|
||||
Do not use silent last-write-wins.
|
||||
|
||||
Updates should include `baseRevisionId`:
|
||||
|
||||
- create: no base revision required
|
||||
- update: `baseRevisionId` must match current latest revision
|
||||
- mismatch: return `409 Conflict`
|
||||
|
||||
This is important because both board users and agents may edit the same document.
|
||||
|
||||
### 5. Issue fetch behavior
|
||||
|
||||
`GET /api/issues/:id` should include:
|
||||
|
||||
- full `planDocument` when a `plan` document exists
|
||||
- `documentSummaries` for all linked documents
|
||||
|
||||
It should not inline every document body by default.
|
||||
|
||||
This keeps issue fetches useful for agents without making every issue payload unbounded.
|
||||
|
||||
### 6. Legacy `<plan>` compatibility
|
||||
|
||||
If an issue has no `plan` document but its description contains a legacy `<plan>` block:
|
||||
|
||||
- expose that as a legacy read-only fallback in API/UI
|
||||
- mark it as legacy/synthetic
|
||||
- prefer a real `plan` document when both exist
|
||||
|
||||
Recommendation:
|
||||
|
||||
- do not auto-rewrite old issue descriptions in the first rollout
|
||||
- provide an explicit import/migrate path later
|
||||
|
||||
## Proposed Data Model
|
||||
|
||||
Recommendation: make documents first-class, but keep issue linkage explicit via a join table.
|
||||
|
||||
This preserves foreign keys today and gives a clean path to future `project_documents` or `company_documents` tables later.
|
||||
|
||||
## Tables
|
||||
|
||||
### `documents`
|
||||
|
||||
Canonical text document record.
|
||||
|
||||
Suggested columns:
|
||||
|
||||
- `id`
|
||||
- `company_id`
|
||||
- `title`
|
||||
- `format`
|
||||
- `latest_body`
|
||||
- `latest_revision_id`
|
||||
- `latest_revision_number`
|
||||
- `created_by_agent_id`
|
||||
- `created_by_user_id`
|
||||
- `updated_by_agent_id`
|
||||
- `updated_by_user_id`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
### `document_revisions`
|
||||
|
||||
Append-only history.
|
||||
|
||||
Suggested columns:
|
||||
|
||||
- `id`
|
||||
- `company_id`
|
||||
- `document_id`
|
||||
- `revision_number`
|
||||
- `body`
|
||||
- `change_summary`
|
||||
- `created_by_agent_id`
|
||||
- `created_by_user_id`
|
||||
- `created_at`
|
||||
|
||||
Constraints:
|
||||
|
||||
- unique `(document_id, revision_number)`
|
||||
|
||||
### `issue_documents`
|
||||
|
||||
Issue relation + workflow key.
|
||||
|
||||
Suggested columns:
|
||||
|
||||
- `id`
|
||||
- `company_id`
|
||||
- `issue_id`
|
||||
- `document_id`
|
||||
- `key`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
Constraints:
|
||||
|
||||
- unique `(company_id, issue_id, key)`
|
||||
- unique `(document_id)` to keep one issue relation per document in v1
|
||||
|
||||
## Why not use `assets` for this?
|
||||
|
||||
Because `assets` solves blob storage, not:
|
||||
|
||||
- stable keyed semantics like `plan`
|
||||
- inline text editing
|
||||
- revision history
|
||||
- optimistic concurrency
|
||||
- cheap inclusion in `GET /issues/:id`
|
||||
|
||||
Documents and attachments should remain separate primitives, then meet later in a deliverables/artifact read-model.
|
||||
|
||||
## Shared Types and API Contract
|
||||
|
||||
## New shared types
|
||||
|
||||
Add:
|
||||
|
||||
- `DocumentFormat`
|
||||
- `IssueDocument`
|
||||
- `IssueDocumentSummary`
|
||||
- `DocumentRevision`
|
||||
|
||||
Recommended `IssueDocument` shape:
|
||||
|
||||
```ts
|
||||
type DocumentFormat = "markdown" | "plain_text" | "json" | "html";
|
||||
|
||||
interface IssueDocument {
|
||||
id: string;
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
key: string;
|
||||
title: string | null;
|
||||
format: DocumentFormat;
|
||||
body: string;
|
||||
latestRevisionId: string;
|
||||
latestRevisionNumber: number;
|
||||
createdByAgentId: string | null;
|
||||
createdByUserId: string | null;
|
||||
updatedByAgentId: string | null;
|
||||
updatedByUserId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
Recommended `IssueDocumentSummary` shape:
|
||||
|
||||
```ts
|
||||
interface IssueDocumentSummary {
|
||||
id: string;
|
||||
key: string;
|
||||
title: string | null;
|
||||
format: DocumentFormat;
|
||||
latestRevisionId: string;
|
||||
latestRevisionNumber: number;
|
||||
updatedAt: Date;
|
||||
}
|
||||
```
|
||||
|
||||
## Issue type enrichment
|
||||
|
||||
Extend `Issue` with:
|
||||
|
||||
```ts
|
||||
interface Issue {
|
||||
...
|
||||
planDocument?: IssueDocument | null;
|
||||
documentSummaries?: IssueDocumentSummary[];
|
||||
legacyPlanDocument?: {
|
||||
key: "plan";
|
||||
body: string;
|
||||
source: "issue_description";
|
||||
} | null;
|
||||
}
|
||||
```
|
||||
|
||||
This directly satisfies the `PAP-448` requirement for heartbeat/API issue fetches.
|
||||
|
||||
## API endpoints
|
||||
|
||||
Recommended endpoints:
|
||||
|
||||
- `GET /api/issues/:issueId/documents`
|
||||
- `GET /api/issues/:issueId/documents/:key`
|
||||
- `PUT /api/issues/:issueId/documents/:key`
|
||||
- `GET /api/issues/:issueId/documents/:key/revisions`
|
||||
- `DELETE /api/issues/:issueId/documents/:key` optionally board-only in v1
|
||||
|
||||
Recommended `PUT` body:
|
||||
|
||||
```ts
|
||||
{
|
||||
title?: string | null;
|
||||
format: "markdown" | "plain_text" | "json" | "html";
|
||||
body: string;
|
||||
changeSummary?: string | null;
|
||||
baseRevisionId?: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
|
||||
- missing document + no `baseRevisionId`: create
|
||||
- existing document + matching `baseRevisionId`: update
|
||||
- existing document + stale `baseRevisionId`: `409`
|
||||
|
||||
## Authorization and invariants
|
||||
|
||||
- all document records are company-scoped
|
||||
- issue relation must belong to same company
|
||||
- board access follows existing issue access rules
|
||||
- agent access follows existing same-company issue access rules
|
||||
- every mutation writes activity log entries
|
||||
|
||||
Recommended delete rule for v1:
|
||||
|
||||
- board can delete documents
|
||||
- agents can create/update, but not delete
|
||||
|
||||
That keeps automated systems from removing canonical docs too easily.
|
||||
|
||||
## UI Plan
|
||||
|
||||
## Issue detail
|
||||
|
||||
Add a new **Documents** section directly under the issue description.
|
||||
|
||||
Recommended behavior:
|
||||
|
||||
- show `plan` first when present
|
||||
- show other documents below it
|
||||
- render a gist-like header:
|
||||
- key
|
||||
- title
|
||||
- last updated metadata
|
||||
- revision number
|
||||
- support inline edit
|
||||
- support create new document by key
|
||||
- support revision history drawer or sheet
|
||||
|
||||
Recommended presentation order:
|
||||
|
||||
1. Description
|
||||
2. Documents
|
||||
3. Attachments
|
||||
4. Comments / activity / sub-issues
|
||||
|
||||
This matches the request that documents live under the description while still leaving attachments available.
|
||||
|
||||
## Editing UX
|
||||
|
||||
Recommendation:
|
||||
|
||||
- use markdown preview + raw edit toggle for markdown docs
|
||||
- use raw textarea editor for non-markdown docs in v1
|
||||
- show explicit save conflicts on `409`
|
||||
- show a clear empty state: "No documents yet"
|
||||
|
||||
## Legacy plan rendering
|
||||
|
||||
If there is no stored `plan` document but legacy `<plan>` exists:
|
||||
|
||||
- show it in the Documents section
|
||||
- mark it `Legacy plan from description`
|
||||
- offer create/import in a later pass
|
||||
|
||||
## Agent Protocol and Skills
|
||||
|
||||
Update the Paperclip agent workflow so planning no longer edits the issue description.
|
||||
|
||||
Required changes:
|
||||
|
||||
- update `skills/paperclip/SKILL.md`
|
||||
- replace the `<plan>` instructions with document creation/update instructions
|
||||
- document the new endpoints in `docs/api/issues.md`
|
||||
- update any internal planning docs that still teach inline `<plan>` blocks
|
||||
|
||||
New rule:
|
||||
|
||||
- when asked to make a plan for an issue, create or update the issue document with key `plan`
|
||||
- leave a comment that the plan document was created/updated
|
||||
- do not mark the issue done
|
||||
|
||||
## Relationship to the Artifact Plan
|
||||
|
||||
This work should explicitly feed the broader artifact/deliverables direction.
|
||||
|
||||
Recommendation:
|
||||
|
||||
- keep documents as their own primitive in this change
|
||||
- add `document` to any future `ArtifactKind`
|
||||
- later build a deliverables read-model that aggregates:
|
||||
- issue documents
|
||||
- issue attachments
|
||||
- preview URLs
|
||||
- workspace-file references
|
||||
|
||||
The artifact proposal currently has no explicit `document` kind. It should.
|
||||
|
||||
Recommended future shape:
|
||||
|
||||
```ts
|
||||
type ArtifactKind =
|
||||
| "document"
|
||||
| "attachment"
|
||||
| "workspace_file"
|
||||
| "preview"
|
||||
| "report_link";
|
||||
```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
## Phase 1: Shared contract and schema
|
||||
|
||||
Files:
|
||||
|
||||
- `packages/db/src/schema/documents.ts`
|
||||
- `packages/db/src/schema/document_revisions.ts`
|
||||
- `packages/db/src/schema/issue_documents.ts`
|
||||
- `packages/db/src/schema/index.ts`
|
||||
- `packages/db/src/migrations/*`
|
||||
- `packages/shared/src/types/issue.ts`
|
||||
- `packages/shared/src/validators/issue.ts` or new document validator file
|
||||
- `packages/shared/src/index.ts`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- schema enforces one key per issue
|
||||
- revisions are append-only
|
||||
- shared types expose plan/document fields on issue fetch
|
||||
|
||||
## Phase 2: Server services and routes
|
||||
|
||||
Files:
|
||||
|
||||
- `server/src/services/issues.ts` or `server/src/services/documents.ts`
|
||||
- `server/src/routes/issues.ts`
|
||||
- `server/src/services/activity.ts` callsites
|
||||
|
||||
Behavior:
|
||||
|
||||
- list/get/upsert/delete documents
|
||||
- revision listing
|
||||
- `GET /issues/:id` returns `planDocument` + `documentSummaries`
|
||||
- company boundary checks match issue routes
|
||||
|
||||
Acceptance:
|
||||
|
||||
- agents and board can fetch/update same-company issue documents
|
||||
- stale edits return `409`
|
||||
- activity timeline shows document changes
|
||||
|
||||
## Phase 3: UI issue documents surface
|
||||
|
||||
Files:
|
||||
|
||||
- `ui/src/api/issues.ts`
|
||||
- `ui/src/lib/queryKeys.ts`
|
||||
- `ui/src/pages/IssueDetail.tsx`
|
||||
- new reusable document UI component if needed
|
||||
|
||||
Behavior:
|
||||
|
||||
- render plan + documents under description
|
||||
- create/update by key
|
||||
- open revision history
|
||||
- show conflicts/errors clearly
|
||||
|
||||
Acceptance:
|
||||
|
||||
- board can create a `plan` doc from issue detail
|
||||
- updated plan appears immediately
|
||||
- issue detail no longer depends on description-embedded `<plan>`
|
||||
|
||||
## Phase 4: Skills/docs migration
|
||||
|
||||
Files:
|
||||
|
||||
- `skills/paperclip/SKILL.md`
|
||||
- `docs/api/issues.md`
|
||||
- `doc/SPEC-implementation.md`
|
||||
- relevant plan/docs that mention `<plan>`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- planning guidance references issue documents, not inline issue description tags
|
||||
- API docs describe the new document endpoints and issue payload additions
|
||||
|
||||
## Phase 5: Legacy compatibility and follow-up
|
||||
|
||||
Behavior:
|
||||
|
||||
- read legacy `<plan>` blocks as fallback
|
||||
- optionally add explicit import/migration command later
|
||||
|
||||
Follow-up, not required for first merge:
|
||||
|
||||
- deliverables/artifact read-model
|
||||
- project/company documents
|
||||
- comment-linked documents
|
||||
- diff view between revisions
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Server
|
||||
|
||||
- document create/read/update/delete lifecycle
|
||||
- revision numbering
|
||||
- `baseRevisionId` conflict handling
|
||||
- company boundary enforcement
|
||||
- agent vs board authorization
|
||||
- issue fetch includes `planDocument` and document summaries
|
||||
- legacy `<plan>` fallback behavior
|
||||
- activity log mutation coverage
|
||||
|
||||
### UI
|
||||
|
||||
- issue detail shows plan document
|
||||
- create/update flows invalidate queries correctly
|
||||
- conflict and validation errors are surfaced
|
||||
- legacy plan fallback renders correctly
|
||||
|
||||
### Verification
|
||||
|
||||
Run before implementation is declared complete:
|
||||
|
||||
```sh
|
||||
pnpm -r typecheck
|
||||
pnpm test:run
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should v1 documents be markdown-only, with `json/html/plain_text` deferred?
|
||||
Recommendation: allow all four in API, optimize UI for markdown only.
|
||||
|
||||
2. Should agents be allowed to create arbitrary keys, or only conventional keys?
|
||||
Recommendation: allow arbitrary keys with normalized validation; reserve `plan` as special behavior only.
|
||||
|
||||
3. Should delete exist in v1?
|
||||
Recommendation: yes, but board-only.
|
||||
|
||||
4. Should legacy `<plan>` blocks ever be auto-migrated?
|
||||
Recommendation: no automatic mutation in the first rollout.
|
||||
|
||||
5. Should documents appear inside a future Deliverables section or remain a top-level Issue section?
|
||||
Recommendation: keep a dedicated Documents section now; later also expose them in Deliverables if an aggregated artifact view is added.
|
||||
|
||||
## Final Recommendation
|
||||
|
||||
Ship **issue documents** as a focused, text-first primitive now.
|
||||
|
||||
Do not try to solve full artifact unification in the same implementation.
|
||||
|
||||
Use:
|
||||
|
||||
- first-class document tables
|
||||
- issue-level keyed linkage
|
||||
- append-only revisions
|
||||
- `planDocument` embedded in normal issue fetches
|
||||
- legacy `<plan>` fallback
|
||||
- skill/docs migration away from description-embedded plans
|
||||
|
||||
This addresses the real planning workflow problem immediately and leaves the artifact system room to grow cleanly afterward.
|
||||
@@ -1,5 +1,11 @@
|
||||
# @paperclipai/adapter-utils
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-utils",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-claude-local
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-claude-local",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -115,6 +115,7 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "") || null;
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "") || null;
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "") || null;
|
||||
const agentHome = asString(workspaceContext.agentHome, "") || null;
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -209,6 +210,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-codex-local
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-codex-local",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -194,6 +194,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const workspaceBranch = asString(workspaceContext.branchName, "");
|
||||
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -308,6 +309,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceWorktreePath) {
|
||||
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-cursor-local
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-cursor-local",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -158,6 +158,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -238,6 +239,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceRepoRef) {
|
||||
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
}
|
||||
if (agentHome) {
|
||||
env.AGENT_HOME = agentHome;
|
||||
}
|
||||
if (workspaceHints.length > 0) {
|
||||
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-gemini-local",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -149,6 +149,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -205,6 +206,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-openclaw-gateway
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-openclaw-gateway",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-opencode-local
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-opencode-local",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -106,6 +106,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -161,6 +162,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/adapter-pi-local
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/adapter-utils@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/adapter-pi-local",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
@@ -123,6 +123,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
const workspaceId = asString(workspaceContext.workspaceId, "");
|
||||
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
||||
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
||||
const agentHome = asString(workspaceContext.agentHome, "");
|
||||
const workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
||||
? context.paperclipWorkspaces.filter(
|
||||
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
||||
@@ -187,6 +188,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
||||
if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
||||
if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
||||
if (agentHome) env.AGENT_HOME = agentHome;
|
||||
if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
||||
|
||||
for (const [key, value] of Object.entries(envConfig)) {
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# @paperclipai/db
|
||||
|
||||
## 0.3.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Stable release preparation for 0.3.1
|
||||
- Updated dependencies
|
||||
- @paperclipai/shared@0.3.1
|
||||
|
||||
## 0.3.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/db",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
|
||||
54
packages/db/src/migrations/0028_harsh_goliath.sql
Normal file
54
packages/db/src/migrations/0028_harsh_goliath.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
CREATE TABLE "document_revisions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"revision_number" integer NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"change_summary" text,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"title" text,
|
||||
"format" text DEFAULT 'markdown' NOT NULL,
|
||||
"latest_body" text NOT NULL,
|
||||
"latest_revision_id" uuid,
|
||||
"latest_revision_number" integer DEFAULT 1 NOT NULL,
|
||||
"created_by_agent_id" uuid,
|
||||
"created_by_user_id" text,
|
||||
"updated_by_agent_id" uuid,
|
||||
"updated_by_user_id" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "issue_documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"issue_id" uuid NOT NULL,
|
||||
"document_id" uuid NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "document_revisions" ADD CONSTRAINT "document_revisions_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "documents" ADD CONSTRAINT "documents_updated_by_agent_id_agents_id_fk" FOREIGN KEY ("updated_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_issue_id_issues_id_fk" FOREIGN KEY ("issue_id") REFERENCES "public"."issues"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "issue_documents" ADD CONSTRAINT "issue_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "document_revisions_document_revision_uq" ON "document_revisions" USING btree ("document_id","revision_number");--> statement-breakpoint
|
||||
CREATE INDEX "document_revisions_company_document_created_idx" ON "document_revisions" USING btree ("company_id","document_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "documents_company_updated_idx" ON "documents" USING btree ("company_id","updated_at");--> statement-breakpoint
|
||||
CREATE INDEX "documents_company_created_idx" ON "documents" USING btree ("company_id","created_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "issue_documents_company_issue_key_uq" ON "issue_documents" USING btree ("company_id","issue_id","key");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "issue_documents_document_uq" ON "issue_documents" USING btree ("document_id");--> statement-breakpoint
|
||||
CREATE INDEX "issue_documents_company_issue_updated_idx" ON "issue_documents" USING btree ("company_id","issue_id","updated_at");
|
||||
177
packages/db/src/migrations/0029_plugin_tables.sql
Normal file
177
packages/db/src/migrations/0029_plugin_tables.sql
Normal file
@@ -0,0 +1,177 @@
|
||||
-- Rollback:
|
||||
-- DROP INDEX IF EXISTS "plugin_logs_level_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_logs_plugin_time_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_company_settings_company_plugin_uq";
|
||||
-- DROP INDEX IF EXISTS "plugin_company_settings_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_company_settings_company_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_key_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_status_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_webhook_deliveries_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_job_runs_status_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_job_runs_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_job_runs_job_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_jobs_unique_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_jobs_next_run_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_jobs_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_external_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_scope_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_type_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_entities_plugin_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_state_plugin_scope_idx";
|
||||
-- DROP INDEX IF EXISTS "plugin_config_plugin_id_idx";
|
||||
-- DROP INDEX IF EXISTS "plugins_status_idx";
|
||||
-- DROP INDEX IF EXISTS "plugins_plugin_key_idx";
|
||||
-- DROP TABLE IF EXISTS "plugin_logs";
|
||||
-- DROP TABLE IF EXISTS "plugin_company_settings";
|
||||
-- DROP TABLE IF EXISTS "plugin_webhook_deliveries";
|
||||
-- DROP TABLE IF EXISTS "plugin_job_runs";
|
||||
-- DROP TABLE IF EXISTS "plugin_jobs";
|
||||
-- DROP TABLE IF EXISTS "plugin_entities";
|
||||
-- DROP TABLE IF EXISTS "plugin_state";
|
||||
-- DROP TABLE IF EXISTS "plugin_config";
|
||||
-- DROP TABLE IF EXISTS "plugins";
|
||||
|
||||
CREATE TABLE "plugins" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_key" text NOT NULL,
|
||||
"package_name" text NOT NULL,
|
||||
"package_path" text,
|
||||
"version" text NOT NULL,
|
||||
"api_version" integer DEFAULT 1 NOT NULL,
|
||||
"categories" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"manifest_json" jsonb NOT NULL,
|
||||
"status" text DEFAULT 'installed' NOT NULL,
|
||||
"install_order" integer,
|
||||
"last_error" text,
|
||||
"installed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_config" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"config_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_state" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"scope_kind" text NOT NULL,
|
||||
"scope_id" text,
|
||||
"namespace" text DEFAULT 'default' NOT NULL,
|
||||
"state_key" text NOT NULL,
|
||||
"value_json" jsonb NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "plugin_state_unique_entry_idx" UNIQUE NULLS NOT DISTINCT("plugin_id","scope_kind","scope_id","namespace","state_key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_entities" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"entity_type" text NOT NULL,
|
||||
"scope_kind" text NOT NULL,
|
||||
"scope_id" text,
|
||||
"external_id" text,
|
||||
"title" text,
|
||||
"status" text,
|
||||
"data" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_jobs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"job_key" text NOT NULL,
|
||||
"schedule" text NOT NULL,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"last_run_at" timestamp with time zone,
|
||||
"next_run_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_job_runs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"job_id" uuid NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"trigger" text NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"duration_ms" integer,
|
||||
"error" text,
|
||||
"logs" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"started_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_webhook_deliveries" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"webhook_key" text NOT NULL,
|
||||
"external_id" text,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"duration_ms" integer,
|
||||
"error" text,
|
||||
"payload" jsonb NOT NULL,
|
||||
"headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"started_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_company_settings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"settings_json" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"last_error" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"enabled" boolean DEFAULT true NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "plugin_logs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"plugin_id" uuid NOT NULL,
|
||||
"level" text NOT NULL DEFAULT 'info',
|
||||
"message" text NOT NULL,
|
||||
"meta" jsonb,
|
||||
"created_at" timestamp with time zone NOT NULL DEFAULT now()
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "plugin_config" ADD CONSTRAINT "plugin_config_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_state" ADD CONSTRAINT "plugin_state_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_entities" ADD CONSTRAINT "plugin_entities_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_jobs" ADD CONSTRAINT "plugin_jobs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_job_id_plugin_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."plugin_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_job_runs" ADD CONSTRAINT "plugin_job_runs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_webhook_deliveries" ADD CONSTRAINT "plugin_webhook_deliveries_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_company_settings" ADD CONSTRAINT "plugin_company_settings_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "plugin_logs" ADD CONSTRAINT "plugin_logs_plugin_id_plugins_id_fk" FOREIGN KEY ("plugin_id") REFERENCES "public"."plugins"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugins_plugin_key_idx" ON "plugins" USING btree ("plugin_key");--> statement-breakpoint
|
||||
CREATE INDEX "plugins_status_idx" ON "plugins" USING btree ("status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_config_plugin_id_idx" ON "plugin_config" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_state_plugin_scope_idx" ON "plugin_state" USING btree ("plugin_id","scope_kind");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_entities_plugin_idx" ON "plugin_entities" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_entities_type_idx" ON "plugin_entities" USING btree ("entity_type");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_entities_scope_idx" ON "plugin_entities" USING btree ("scope_kind","scope_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_entities_external_idx" ON "plugin_entities" USING btree ("plugin_id","entity_type","external_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_jobs_plugin_idx" ON "plugin_jobs" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_jobs_next_run_idx" ON "plugin_jobs" USING btree ("next_run_at");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_jobs_unique_idx" ON "plugin_jobs" USING btree ("plugin_id","job_key");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_job_runs_job_idx" ON "plugin_job_runs" USING btree ("job_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_job_runs_plugin_idx" ON "plugin_job_runs" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_job_runs_status_idx" ON "plugin_job_runs" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_webhook_deliveries_plugin_idx" ON "plugin_webhook_deliveries" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_webhook_deliveries_status_idx" ON "plugin_webhook_deliveries" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_webhook_deliveries_key_idx" ON "plugin_webhook_deliveries" USING btree ("webhook_key");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_company_settings_company_idx" ON "plugin_company_settings" USING btree ("company_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_company_settings_plugin_idx" ON "plugin_company_settings" USING btree ("plugin_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "plugin_company_settings_company_plugin_uq" ON "plugin_company_settings" USING btree ("company_id","plugin_id");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_logs_plugin_time_idx" ON "plugin_logs" USING btree ("plugin_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "plugin_logs_level_idx" ON "plugin_logs" USING btree ("level");
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "a0621514-3564-4e98-b8bb-e920a9039390",
|
||||
"id": "6fe59d88-aadc-4acb-acf4-ea60b7dbc7dc",
|
||||
"prevId": "8186209d-f7ec-4048-bd4f-c96530f45304",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -2657,173 +2657,6 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.company_skills": {
|
||||
"name": "company_skills",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"markdown": {
|
||||
"name": "markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"source_type": {
|
||||
"name": "source_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'local_path'"
|
||||
},
|
||||
"source_locator": {
|
||||
"name": "source_locator",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"source_ref": {
|
||||
"name": "source_ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"trust_level": {
|
||||
"name": "trust_level",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'markdown_only'"
|
||||
},
|
||||
"compatibility": {
|
||||
"name": "compatibility",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'compatible'"
|
||||
},
|
||||
"file_inventory": {
|
||||
"name": "file_inventory",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'[]'::jsonb"
|
||||
},
|
||||
"metadata": {
|
||||
"name": "metadata",
|
||||
"type": "jsonb",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"company_skills_company_slug_idx": {
|
||||
"name": "company_skills_company_slug_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "slug",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"company_skills_company_name_idx": {
|
||||
"name": "company_skills_company_name_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "name",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"company_skills_company_id_companies_id_fk": {
|
||||
"name": "company_skills_company_id_companies_id_fk",
|
||||
"tableFrom": "company_skills",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.cost_events": {
|
||||
"name": "cost_events",
|
||||
"schema": "",
|
||||
@@ -3040,6 +2873,343 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.document_revisions": {
|
||||
"name": "document_revisions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"document_id": {
|
||||
"name": "document_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revision_number": {
|
||||
"name": "revision_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"body": {
|
||||
"name": "body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"change_summary": {
|
||||
"name": "change_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_by_agent_id": {
|
||||
"name": "created_by_agent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_by_user_id": {
|
||||
"name": "created_by_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"document_revisions_document_revision_uq": {
|
||||
"name": "document_revisions_document_revision_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "document_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "revision_number",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"document_revisions_company_document_created_idx": {
|
||||
"name": "document_revisions_company_document_created_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "document_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"document_revisions_company_id_companies_id_fk": {
|
||||
"name": "document_revisions_company_id_companies_id_fk",
|
||||
"tableFrom": "document_revisions",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"document_revisions_document_id_documents_id_fk": {
|
||||
"name": "document_revisions_document_id_documents_id_fk",
|
||||
"tableFrom": "document_revisions",
|
||||
"tableTo": "documents",
|
||||
"columnsFrom": [
|
||||
"document_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"document_revisions_created_by_agent_id_agents_id_fk": {
|
||||
"name": "document_revisions_created_by_agent_id_agents_id_fk",
|
||||
"tableFrom": "document_revisions",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"created_by_agent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.documents": {
|
||||
"name": "documents",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"format": {
|
||||
"name": "format",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'markdown'"
|
||||
},
|
||||
"latest_body": {
|
||||
"name": "latest_body",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"latest_revision_id": {
|
||||
"name": "latest_revision_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"latest_revision_number": {
|
||||
"name": "latest_revision_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 1
|
||||
},
|
||||
"created_by_agent_id": {
|
||||
"name": "created_by_agent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_by_user_id": {
|
||||
"name": "created_by_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_by_agent_id": {
|
||||
"name": "updated_by_agent_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_by_user_id": {
|
||||
"name": "updated_by_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"documents_company_updated_idx": {
|
||||
"name": "documents_company_updated_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "updated_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"documents_company_created_idx": {
|
||||
"name": "documents_company_created_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "created_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"documents_company_id_companies_id_fk": {
|
||||
"name": "documents_company_id_companies_id_fk",
|
||||
"tableFrom": "documents",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"documents_created_by_agent_id_agents_id_fk": {
|
||||
"name": "documents_created_by_agent_id_agents_id_fk",
|
||||
"tableFrom": "documents",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"created_by_agent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"documents_updated_by_agent_id_agents_id_fk": {
|
||||
"name": "documents_updated_by_agent_id_agents_id_fk",
|
||||
"tableFrom": "documents",
|
||||
"tableTo": "agents",
|
||||
"columnsFrom": [
|
||||
"updated_by_agent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.goals": {
|
||||
"name": "goals",
|
||||
"schema": "",
|
||||
@@ -4371,6 +4541,174 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.issue_documents": {
|
||||
"name": "issue_documents",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"company_id": {
|
||||
"name": "company_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"issue_id": {
|
||||
"name": "issue_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"document_id": {
|
||||
"name": "document_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"issue_documents_company_issue_key_uq": {
|
||||
"name": "issue_documents_company_issue_key_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "issue_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "key",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"issue_documents_document_uq": {
|
||||
"name": "issue_documents_document_uq",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "document_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"issue_documents_company_issue_updated_idx": {
|
||||
"name": "issue_documents_company_issue_updated_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "issue_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "updated_at",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"issue_documents_company_id_companies_id_fk": {
|
||||
"name": "issue_documents_company_id_companies_id_fk",
|
||||
"tableFrom": "issue_documents",
|
||||
"tableTo": "companies",
|
||||
"columnsFrom": [
|
||||
"company_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"issue_documents_issue_id_issues_id_fk": {
|
||||
"name": "issue_documents_issue_id_issues_id_fk",
|
||||
"tableFrom": "issue_documents",
|
||||
"tableTo": "issues",
|
||||
"columnsFrom": [
|
||||
"issue_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"issue_documents_document_id_documents_id_fk": {
|
||||
"name": "issue_documents_document_id_documents_id_fk",
|
||||
"tableFrom": "issue_documents",
|
||||
"tableTo": "documents",
|
||||
"columnsFrom": [
|
||||
"document_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.issue_labels": {
|
||||
"name": "issue_labels",
|
||||
"schema": "",
|
||||
|
||||
7899
packages/db/src/migrations/meta/0029_snapshot.json
Normal file
7899
packages/db/src/migrations/meta/0029_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
8066
packages/db/src/migrations/meta/0030_snapshot.json
Normal file
8066
packages/db/src/migrations/meta/0030_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -201,8 +201,22 @@
|
||||
{
|
||||
"idx": 28,
|
||||
"version": "7",
|
||||
"when": 1773503397855,
|
||||
"tag": "0028_bent_eternals",
|
||||
"when": 1773432085646,
|
||||
"tag": "0028_harsh_goliath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1773417600000,
|
||||
"tag": "0029_plugin_tables",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 30,
|
||||
"version": "7",
|
||||
"when": 1773542603948,
|
||||
"tag": "0030_friendly_loners",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
30
packages/db/src/schema/document_revisions.ts
Normal file
30
packages/db/src/schema/document_revisions.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, text, integer, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
import { documents } from "./documents.js";
|
||||
|
||||
export const documentRevisions = pgTable(
|
||||
"document_revisions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
revisionNumber: integer("revision_number").notNull(),
|
||||
body: text("body").notNull(),
|
||||
changeSummary: text("change_summary"),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
documentRevisionUq: uniqueIndex("document_revisions_document_revision_uq").on(
|
||||
table.documentId,
|
||||
table.revisionNumber,
|
||||
),
|
||||
companyDocumentCreatedIdx: index("document_revisions_company_document_created_idx").on(
|
||||
table.companyId,
|
||||
table.documentId,
|
||||
table.createdAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
26
packages/db/src/schema/documents.ts
Normal file
26
packages/db/src/schema/documents.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { pgTable, uuid, text, integer, timestamp, index } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { agents } from "./agents.js";
|
||||
|
||||
export const documents = pgTable(
|
||||
"documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
title: text("title"),
|
||||
format: text("format").notNull().default("markdown"),
|
||||
latestBody: text("latest_body").notNull(),
|
||||
latestRevisionId: uuid("latest_revision_id"),
|
||||
latestRevisionNumber: integer("latest_revision_number").notNull().default(1),
|
||||
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
createdByUserId: text("created_by_user_id"),
|
||||
updatedByAgentId: uuid("updated_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
|
||||
updatedByUserId: text("updated_by_user_id"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyUpdatedIdx: index("documents_company_updated_idx").on(table.companyId, table.updatedAt),
|
||||
companyCreatedIdx: index("documents_company_created_idx").on(table.companyId, table.createdAt),
|
||||
}),
|
||||
);
|
||||
@@ -24,6 +24,9 @@ export { issueComments } from "./issue_comments.js";
|
||||
export { issueReadStates } from "./issue_read_states.js";
|
||||
export { assets } from "./assets.js";
|
||||
export { issueAttachments } from "./issue_attachments.js";
|
||||
export { documents } from "./documents.js";
|
||||
export { documentRevisions } from "./document_revisions.js";
|
||||
export { issueDocuments } from "./issue_documents.js";
|
||||
export { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
export { heartbeatRunEvents } from "./heartbeat_run_events.js";
|
||||
export { costEvents } from "./cost_events.js";
|
||||
@@ -33,3 +36,11 @@ export { activityLog } from "./activity_log.js";
|
||||
export { companySecrets } from "./company_secrets.js";
|
||||
export { companySecretVersions } from "./company_secret_versions.js";
|
||||
export { companySkills } from "./company_skills.js";
|
||||
export { plugins } from "./plugins.js";
|
||||
export { pluginConfig } from "./plugin_config.js";
|
||||
export { pluginCompanySettings } from "./plugin_company_settings.js";
|
||||
export { pluginState } from "./plugin_state.js";
|
||||
export { pluginEntities } from "./plugin_entities.js";
|
||||
export { pluginJobs, pluginJobRuns } from "./plugin_jobs.js";
|
||||
export { pluginWebhookDeliveries } from "./plugin_webhooks.js";
|
||||
export { pluginLogs } from "./plugin_logs.js";
|
||||
|
||||
30
packages/db/src/schema/issue_documents.ts
Normal file
30
packages/db/src/schema/issue_documents.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, text, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { documents } from "./documents.js";
|
||||
|
||||
export const issueDocuments = pgTable(
|
||||
"issue_documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
issueId: uuid("issue_id").notNull().references(() => issues.id, { onDelete: "cascade" }),
|
||||
documentId: uuid("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
|
||||
key: text("key").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIssueKeyUq: uniqueIndex("issue_documents_company_issue_key_uq").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.key,
|
||||
),
|
||||
documentUq: uniqueIndex("issue_documents_document_uq").on(table.documentId),
|
||||
companyIssueUpdatedIdx: index("issue_documents_company_issue_updated_idx").on(
|
||||
table.companyId,
|
||||
table.issueId,
|
||||
table.updatedAt,
|
||||
),
|
||||
}),
|
||||
);
|
||||
41
packages/db/src/schema/plugin_company_settings.ts
Normal file
41
packages/db/src/schema/plugin_company_settings.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex, boolean } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_company_settings` table — stores operator-managed plugin settings
|
||||
* scoped to a specific company.
|
||||
*
|
||||
* This is distinct from `plugin_config`, which stores instance-wide plugin
|
||||
* configuration. Each company can have at most one settings row per plugin.
|
||||
*
|
||||
* Rows represent explicit overrides from the default company behavior:
|
||||
* - no row => plugin is enabled for the company by default
|
||||
* - row with `enabled = false` => plugin is disabled for that company
|
||||
* - row with `enabled = true` => plugin remains enabled and stores company settings
|
||||
*/
|
||||
export const pluginCompanySettings = pgTable(
|
||||
"plugin_company_settings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id")
|
||||
.notNull()
|
||||
.references(() => companies.id, { onDelete: "cascade" }),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
settingsJson: jsonb("settings_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("plugin_company_settings_company_idx").on(table.companyId),
|
||||
pluginIdx: index("plugin_company_settings_plugin_idx").on(table.pluginId),
|
||||
companyPluginUq: uniqueIndex("plugin_company_settings_company_plugin_uq").on(
|
||||
table.companyId,
|
||||
table.pluginId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
30
packages/db/src/schema/plugin_config.ts
Normal file
30
packages/db/src/schema/plugin_config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_config` table — stores operator-provided instance configuration
|
||||
* for each plugin (one row per plugin, enforced by a unique index on
|
||||
* `plugin_id`).
|
||||
*
|
||||
* The `config_json` column holds the values that the operator enters in the
|
||||
* plugin settings UI. These values are validated at runtime against the
|
||||
* plugin's `instanceConfigSchema` from the manifest.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const pluginConfig = pgTable(
|
||||
"plugin_config",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
configJson: jsonb("config_json").$type<Record<string, unknown>>().notNull().default({}),
|
||||
lastError: text("last_error"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdIdx: uniqueIndex("plugin_config_plugin_id_idx").on(table.pluginId),
|
||||
}),
|
||||
);
|
||||
54
packages/db/src/schema/plugin_entities.ts
Normal file
54
packages/db/src/schema/plugin_entities.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_entities` table — persistent high-level mapping between Paperclip
|
||||
* objects and external plugin-defined entities.
|
||||
*
|
||||
* This table is used by plugins (e.g. `linear`, `github`) to store pointers
|
||||
* to their respective external IDs for projects, issues, etc. and to store
|
||||
* their custom data.
|
||||
*
|
||||
* Unlike `plugin_state`, which is for raw K-V persistence, `plugin_entities`
|
||||
* is intended for structured object mappings that the host can understand
|
||||
* and query for cross-plugin UI integration.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const pluginEntities = pgTable(
|
||||
"plugin_entities",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||
scopeId: text("scope_id"), // NULL for global scope (text to match plugin_state.scope_id)
|
||||
externalId: text("external_id"), // ID in the external system
|
||||
title: text("title"),
|
||||
status: text("status"),
|
||||
data: jsonb("data").$type<Record<string, unknown>>().notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_entities_plugin_idx").on(table.pluginId),
|
||||
typeIdx: index("plugin_entities_type_idx").on(table.entityType),
|
||||
scopeIdx: index("plugin_entities_scope_idx").on(table.scopeKind, table.scopeId),
|
||||
externalIdx: uniqueIndex("plugin_entities_external_idx").on(
|
||||
table.pluginId,
|
||||
table.entityType,
|
||||
table.externalId,
|
||||
),
|
||||
}),
|
||||
);
|
||||
102
packages/db/src/schema/plugin_jobs.ts
Normal file
102
packages/db/src/schema/plugin_jobs.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginJobStatus, PluginJobRunStatus, PluginJobRunTrigger } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_jobs` table — registration and runtime configuration for
|
||||
* scheduled jobs declared by plugins in their manifests.
|
||||
*
|
||||
* Each row represents one scheduled job entry for a plugin. The
|
||||
* `job_key` matches the key declared in the manifest's `jobs` array.
|
||||
* The `schedule` column stores the cron expression or interval string
|
||||
* used by the job scheduler to decide when to fire the job.
|
||||
*
|
||||
* Status values:
|
||||
* - `active` — job is enabled and will run on schedule
|
||||
* - `paused` — job is temporarily disabled by the operator
|
||||
* - `error` — job has been disabled due to repeated failures
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs`
|
||||
*/
|
||||
export const pluginJobs = pgTable(
|
||||
"plugin_jobs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Identifier matching the key in the plugin manifest's `jobs` array. */
|
||||
jobKey: text("job_key").notNull(),
|
||||
/** Cron expression (e.g. `"0 * * * *"`) or interval string. */
|
||||
schedule: text("schedule").notNull(),
|
||||
/** Current scheduling state. */
|
||||
status: text("status").$type<PluginJobStatus>().notNull().default("active"),
|
||||
/** Timestamp of the most recent successful execution. */
|
||||
lastRunAt: timestamp("last_run_at", { withTimezone: true }),
|
||||
/** Pre-computed timestamp of the next scheduled execution. */
|
||||
nextRunAt: timestamp("next_run_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_jobs_plugin_idx").on(table.pluginId),
|
||||
nextRunIdx: index("plugin_jobs_next_run_idx").on(table.nextRunAt),
|
||||
uniqueJobIdx: uniqueIndex("plugin_jobs_unique_idx").on(table.pluginId, table.jobKey),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* `plugin_job_runs` table — immutable execution history for plugin-owned jobs.
|
||||
*
|
||||
* Each row is created when a job run begins and updated when it completes.
|
||||
* Rows are never modified after `status` reaches a terminal value
|
||||
* (`succeeded` | `failed` | `cancelled`).
|
||||
*
|
||||
* Trigger values:
|
||||
* - `scheduled` — fired automatically by the cron/interval scheduler
|
||||
* - `manual` — triggered by an operator via the admin UI or API
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_job_runs`
|
||||
*/
|
||||
export const pluginJobRuns = pgTable(
|
||||
"plugin_job_runs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the parent job definition. Cascades on delete. */
|
||||
jobId: uuid("job_id")
|
||||
.notNull()
|
||||
.references(() => pluginJobs.id, { onDelete: "cascade" }),
|
||||
/** Denormalized FK to the owning plugin for efficient querying. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** What caused this run to start (`"scheduled"` or `"manual"`). */
|
||||
trigger: text("trigger").$type<PluginJobRunTrigger>().notNull(),
|
||||
/** Current lifecycle state of this run. */
|
||||
status: text("status").$type<PluginJobRunStatus>().notNull().default("pending"),
|
||||
/** Wall-clock duration in milliseconds. Null until the run finishes. */
|
||||
durationMs: integer("duration_ms"),
|
||||
/** Error message if `status === "failed"`. */
|
||||
error: text("error"),
|
||||
/** Ordered list of log lines emitted during this run. */
|
||||
logs: jsonb("logs").$type<string[]>().notNull().default([]),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
jobIdx: index("plugin_job_runs_job_idx").on(table.jobId),
|
||||
pluginIdx: index("plugin_job_runs_plugin_idx").on(table.pluginId),
|
||||
statusIdx: index("plugin_job_runs_status_idx").on(table.status),
|
||||
}),
|
||||
);
|
||||
43
packages/db/src/schema/plugin_logs.ts
Normal file
43
packages/db/src/schema/plugin_logs.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_logs` table — structured log storage for plugin workers.
|
||||
*
|
||||
* Each row stores a single log entry emitted by a plugin worker via
|
||||
* `ctx.logger.info(...)` etc. Logs are queryable by plugin, level, and
|
||||
* time range to support the operator logs panel and debugging workflows.
|
||||
*
|
||||
* Rows are inserted by the host when handling `log` notifications from
|
||||
* the worker process. A capped retention policy can be applied via
|
||||
* periodic cleanup (e.g. delete rows older than 7 days).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §26 — Observability
|
||||
*/
|
||||
export const pluginLogs = pgTable(
|
||||
"plugin_logs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
level: text("level").notNull().default("info"),
|
||||
message: text("message").notNull(),
|
||||
meta: jsonb("meta").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginTimeIdx: index("plugin_logs_plugin_time_idx").on(
|
||||
table.pluginId,
|
||||
table.createdAt,
|
||||
),
|
||||
levelIdx: index("plugin_logs_level_idx").on(table.level),
|
||||
}),
|
||||
);
|
||||
90
packages/db/src/schema/plugin_state.ts
Normal file
90
packages/db/src/schema/plugin_state.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
unique,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { PluginStateScopeKind } from "@paperclipai/shared";
|
||||
import { plugins } from "./plugins.js";
|
||||
|
||||
/**
|
||||
* `plugin_state` table — scoped key-value storage for plugin workers.
|
||||
*
|
||||
* Each row stores a single JSON value identified by
|
||||
* `(plugin_id, scope_kind, scope_id, namespace, state_key)`. Plugins use
|
||||
* this table through `ctx.state.get()`, `ctx.state.set()`, and
|
||||
* `ctx.state.delete()` in the SDK.
|
||||
*
|
||||
* Scope kinds determine the granularity of isolation:
|
||||
* - `instance` — one value shared across the whole Paperclip instance
|
||||
* - `company` — one value per company
|
||||
* - `project` — one value per project
|
||||
* - `project_workspace` — one value per project workspace
|
||||
* - `agent` — one value per agent
|
||||
* - `issue` — one value per issue
|
||||
* - `goal` — one value per goal
|
||||
* - `run` — one value per agent run
|
||||
*
|
||||
* The `namespace` column defaults to `"default"` and can be used to
|
||||
* logically group keys without polluting the root namespace.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
|
||||
*/
|
||||
export const pluginState = pgTable(
|
||||
"plugin_state",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Granularity of the scope (e.g. `"instance"`, `"project"`, `"issue"`). */
|
||||
scopeKind: text("scope_kind").$type<PluginStateScopeKind>().notNull(),
|
||||
/**
|
||||
* UUID or text identifier for the scoped object.
|
||||
* Null for `instance` scope (which has no associated entity).
|
||||
*/
|
||||
scopeId: text("scope_id"),
|
||||
/**
|
||||
* Sub-namespace to avoid key collisions within a scope.
|
||||
* Defaults to `"default"` if the plugin does not specify one.
|
||||
*/
|
||||
namespace: text("namespace").notNull().default("default"),
|
||||
/** The key identifying this state entry within the namespace. */
|
||||
stateKey: text("state_key").notNull(),
|
||||
/** JSON-serializable value stored by the plugin. */
|
||||
valueJson: jsonb("value_json").notNull(),
|
||||
/** Timestamp of the most recent write. */
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
/**
|
||||
* Unique constraint enforces that there is at most one value per
|
||||
* (plugin, scope kind, scope id, namespace, key) tuple.
|
||||
*
|
||||
* `nullsNotDistinct()` is required so that `scope_id IS NULL` entries
|
||||
* (used by `instance` scope) are treated as equal by PostgreSQL rather
|
||||
* than as distinct nulls — otherwise the upsert target in `set()` would
|
||||
* fail to match existing rows and create duplicates.
|
||||
*
|
||||
* Requires PostgreSQL 15+.
|
||||
*/
|
||||
uniqueEntry: unique("plugin_state_unique_entry_idx")
|
||||
.on(
|
||||
table.pluginId,
|
||||
table.scopeKind,
|
||||
table.scopeId,
|
||||
table.namespace,
|
||||
table.stateKey,
|
||||
)
|
||||
.nullsNotDistinct(),
|
||||
/** Speed up lookups by plugin + scope kind (most common access pattern). */
|
||||
pluginScopeIdx: index("plugin_state_plugin_scope_idx").on(
|
||||
table.pluginId,
|
||||
table.scopeKind,
|
||||
),
|
||||
}),
|
||||
);
|
||||
65
packages/db/src/schema/plugin_webhooks.ts
Normal file
65
packages/db/src/schema/plugin_webhooks.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { plugins } from "./plugins.js";
|
||||
import type { PluginWebhookDeliveryStatus } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugin_webhook_deliveries` table — inbound webhook delivery history for plugins.
|
||||
*
|
||||
* When an external system sends an HTTP POST to a plugin's registered webhook
|
||||
* endpoint (e.g. `/api/plugins/:pluginKey/webhooks/:webhookKey`), the server
|
||||
* creates a row in this table before dispatching the payload to the plugin
|
||||
* worker. This provides an auditable log of every delivery attempt.
|
||||
*
|
||||
* The `webhook_key` matches the key declared in the plugin manifest's
|
||||
* `webhooks` array. `external_id` is an optional identifier supplied by the
|
||||
* remote system (e.g. a GitHub delivery GUID) that can be used to detect
|
||||
* and reject duplicate deliveries.
|
||||
*
|
||||
* Status values:
|
||||
* - `pending` — received but not yet dispatched to the worker
|
||||
* - `processing` — currently being handled by the plugin worker
|
||||
* - `succeeded` — worker processed the payload successfully
|
||||
* - `failed` — worker returned an error or timed out
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_webhook_deliveries`
|
||||
*/
|
||||
export const pluginWebhookDeliveries = pgTable(
|
||||
"plugin_webhook_deliveries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** FK to the owning plugin. Cascades on delete. */
|
||||
pluginId: uuid("plugin_id")
|
||||
.notNull()
|
||||
.references(() => plugins.id, { onDelete: "cascade" }),
|
||||
/** Identifier matching the key in the plugin manifest's `webhooks` array. */
|
||||
webhookKey: text("webhook_key").notNull(),
|
||||
/** Optional de-duplication ID provided by the external system. */
|
||||
externalId: text("external_id"),
|
||||
/** Current delivery state. */
|
||||
status: text("status").$type<PluginWebhookDeliveryStatus>().notNull().default("pending"),
|
||||
/** Wall-clock processing duration in milliseconds. Null until delivery finishes. */
|
||||
durationMs: integer("duration_ms"),
|
||||
/** Error message if `status === "failed"`. */
|
||||
error: text("error"),
|
||||
/** Raw JSON body of the inbound HTTP request. */
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
/** Relevant HTTP headers from the inbound request (e.g. signature headers). */
|
||||
headers: jsonb("headers").$type<Record<string, string>>().notNull().default({}),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }),
|
||||
finishedAt: timestamp("finished_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginIdx: index("plugin_webhook_deliveries_plugin_idx").on(table.pluginId),
|
||||
statusIdx: index("plugin_webhook_deliveries_status_idx").on(table.status),
|
||||
keyIdx: index("plugin_webhook_deliveries_key_idx").on(table.webhookKey),
|
||||
}),
|
||||
);
|
||||
45
packages/db/src/schema/plugins.ts
Normal file
45
packages/db/src/schema/plugins.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
pgTable,
|
||||
uuid,
|
||||
text,
|
||||
integer,
|
||||
timestamp,
|
||||
jsonb,
|
||||
index,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { PluginCategory, PluginStatus, PaperclipPluginManifestV1 } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* `plugins` table — stores one row per installed plugin.
|
||||
*
|
||||
* Each plugin is uniquely identified by `plugin_key` (derived from
|
||||
* the manifest `id`). The full manifest is persisted as JSONB in
|
||||
* `manifest_json` so the host can reconstruct capability and UI
|
||||
* slot information without loading the plugin package.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3
|
||||
*/
|
||||
export const plugins = pgTable(
|
||||
"plugins",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
pluginKey: text("plugin_key").notNull(),
|
||||
packageName: text("package_name").notNull(),
|
||||
version: text("version").notNull(),
|
||||
apiVersion: integer("api_version").notNull().default(1),
|
||||
categories: jsonb("categories").$type<PluginCategory[]>().notNull().default([]),
|
||||
manifestJson: jsonb("manifest_json").$type<PaperclipPluginManifestV1>().notNull(),
|
||||
status: text("status").$type<PluginStatus>().notNull().default("installed"),
|
||||
installOrder: integer("install_order"),
|
||||
/** Resolved package path for local-path installs; used to find worker entrypoint. */
|
||||
packagePath: text("package_path"),
|
||||
lastError: text("last_error"),
|
||||
installedAt: timestamp("installed_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
pluginKeyIdx: uniqueIndex("plugins_plugin_key_idx").on(table.pluginKey),
|
||||
statusIdx: index("plugins_status_idx").on(table.status),
|
||||
}),
|
||||
);
|
||||
52
packages/plugins/create-paperclip-plugin/README.md
Normal file
52
packages/plugins/create-paperclip-plugin/README.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# @paperclipai/create-paperclip-plugin
|
||||
|
||||
Scaffolding tool for creating new Paperclip plugins.
|
||||
|
||||
```bash
|
||||
npx @paperclipai/create-paperclip-plugin my-plugin
|
||||
```
|
||||
|
||||
Or with options:
|
||||
|
||||
```bash
|
||||
npx @paperclipai/create-paperclip-plugin @acme/my-plugin \
|
||||
--template connector \
|
||||
--category connector \
|
||||
--display-name "Acme Connector" \
|
||||
--description "Syncs Acme data into Paperclip" \
|
||||
--author "Acme Inc"
|
||||
```
|
||||
|
||||
Supported templates: `default`, `connector`, `workspace`
|
||||
Supported categories: `connector`, `workspace`, `automation`, `ui`
|
||||
|
||||
Generates:
|
||||
- typed manifest + worker entrypoint
|
||||
- example UI widget using the supported `@paperclipai/plugin-sdk/ui` hooks
|
||||
- test file using `@paperclipai/plugin-sdk/testing`
|
||||
- `esbuild` and `rollup` config files using SDK bundler presets
|
||||
- dev server script for hot-reload (`paperclip-plugin-dev-server`)
|
||||
|
||||
The scaffold intentionally uses plain React elements rather than host-provided UI kit components, because the current plugin runtime does not ship a stable shared component library yet.
|
||||
|
||||
Inside this repo, the generated package uses `@paperclipai/plugin-sdk` via `workspace:*`.
|
||||
|
||||
Outside this repo, the scaffold snapshots `@paperclipai/plugin-sdk` from your local Paperclip checkout into a `.paperclip-sdk/` tarball and points the generated package at that local file by default. You can override the SDK source explicitly:
|
||||
|
||||
```bash
|
||||
node packages/plugins/create-paperclip-plugin/dist/index.js @acme/my-plugin \
|
||||
--output /absolute/path/to/plugins \
|
||||
--sdk-path /absolute/path/to/paperclip/packages/plugins/sdk
|
||||
```
|
||||
|
||||
That gives you an outside-repo local development path before the SDK is published to npm.
|
||||
|
||||
## Workflow after scaffolding
|
||||
|
||||
```bash
|
||||
cd my-plugin
|
||||
pnpm install
|
||||
pnpm dev # watch worker + manifest + ui bundles
|
||||
pnpm dev:ui # local UI preview server with hot-reload events
|
||||
pnpm test
|
||||
```
|
||||
40
packages/plugins/create-paperclip-plugin/package.json
Normal file
40
packages/plugins/create-paperclip-plugin/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "@paperclipai/create-paperclip-plugin",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"create-paperclip-plugin": "./dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"bin": {
|
||||
"create-paperclip-plugin": "./dist/index.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
496
packages/plugins/create-paperclip-plugin/src/index.ts
Normal file
496
packages/plugins/create-paperclip-plugin/src/index.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const VALID_TEMPLATES = ["default", "connector", "workspace"] as const;
|
||||
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
|
||||
const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui"] as const);
|
||||
|
||||
export interface ScaffoldPluginOptions {
|
||||
pluginName: string;
|
||||
outputDir: string;
|
||||
template?: PluginTemplate;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
category?: "connector" | "workspace" | "automation" | "ui";
|
||||
sdkPath?: string;
|
||||
}
|
||||
|
||||
/** Validate npm-style plugin package names (scoped or unscoped). */
|
||||
export function isValidPluginName(name: string): boolean {
|
||||
const scopedPattern = /^@[a-z0-9_-]+\/[a-z0-9._-]+$/;
|
||||
const unscopedPattern = /^[a-z0-9._-]+$/;
|
||||
return scopedPattern.test(name) || unscopedPattern.test(name);
|
||||
}
|
||||
|
||||
/** Convert `@scope/name` to an output directory basename (`name`). */
|
||||
function packageToDirName(pluginName: string): string {
|
||||
return pluginName.replace(/^@[^/]+\//, "");
|
||||
}
|
||||
|
||||
/** Convert an npm package name into a manifest-safe plugin id. */
|
||||
function packageToManifestId(pluginName: string): string {
|
||||
if (!pluginName.startsWith("@")) {
|
||||
return pluginName;
|
||||
}
|
||||
|
||||
return pluginName.slice(1).replace("/", ".");
|
||||
}
|
||||
|
||||
/** Build a human-readable display name from package name tokens. */
|
||||
function makeDisplayName(pluginName: string): string {
|
||||
const raw = packageToDirName(pluginName).replace(/[._-]+/g, " ").trim();
|
||||
return raw
|
||||
.split(/\s+/)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function writeFile(target: string, content: string) {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
}
|
||||
|
||||
function quote(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function toPosixPath(value: string): string {
|
||||
return value.split(path.sep).join("/");
|
||||
}
|
||||
|
||||
function formatFileDependency(absPath: string): string {
|
||||
return `file:${toPosixPath(path.resolve(absPath))}`;
|
||||
}
|
||||
|
||||
function getLocalSdkPackagePath(): string {
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "sdk");
|
||||
}
|
||||
|
||||
function getRepoRootFromSdkPath(sdkPath: string): string {
|
||||
return path.resolve(sdkPath, "..", "..", "..");
|
||||
}
|
||||
|
||||
function getLocalSharedPackagePath(sdkPath: string): string {
|
||||
return path.resolve(getRepoRootFromSdkPath(sdkPath), "packages", "shared");
|
||||
}
|
||||
|
||||
function isInsideDir(targetPath: string, parentPath: string): boolean {
|
||||
const relative = path.relative(parentPath, targetPath);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function packLocalPackage(packagePath: string, outputDir: string): string {
|
||||
const packageJsonPath = path.join(packagePath, "package.json");
|
||||
if (!fs.existsSync(packageJsonPath)) {
|
||||
throw new Error(`Package package.json not found at ${packageJsonPath}`);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as {
|
||||
name?: string;
|
||||
version?: string;
|
||||
};
|
||||
const packageName = packageJson.name ?? path.basename(packagePath);
|
||||
const packageVersion = packageJson.version ?? "0.0.0";
|
||||
const tarballFileName = `${packageName.replace(/^@/, "").replace("/", "-")}-${packageVersion}.tgz`;
|
||||
const sdkBundleDir = path.join(outputDir, ".paperclip-sdk");
|
||||
|
||||
fs.mkdirSync(sdkBundleDir, { recursive: true });
|
||||
execFileSync("pnpm", ["build"], { cwd: packagePath, stdio: "pipe" });
|
||||
execFileSync("pnpm", ["pack", "--pack-destination", sdkBundleDir], { cwd: packagePath, stdio: "pipe" });
|
||||
|
||||
const tarballPath = path.join(sdkBundleDir, tarballFileName);
|
||||
if (!fs.existsSync(tarballPath)) {
|
||||
throw new Error(`Packed tarball was not created at ${tarballPath}`);
|
||||
}
|
||||
|
||||
return tarballPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete Paperclip plugin starter project.
|
||||
*
|
||||
* Output includes manifest/worker/UI entries, SDK harness tests, bundler presets,
|
||||
* and a local dev server script for hot-reload workflow.
|
||||
*/
|
||||
export function scaffoldPluginProject(options: ScaffoldPluginOptions): string {
|
||||
const template = options.template ?? "default";
|
||||
if (!VALID_TEMPLATES.includes(template)) {
|
||||
throw new Error(`Invalid template '${template}'. Expected one of: ${VALID_TEMPLATES.join(", ")}`);
|
||||
}
|
||||
|
||||
if (!isValidPluginName(options.pluginName)) {
|
||||
throw new Error("Invalid plugin name. Must be lowercase and may include scope, dots, underscores, or hyphens.");
|
||||
}
|
||||
|
||||
if (options.category && !VALID_CATEGORIES.has(options.category)) {
|
||||
throw new Error(`Invalid category '${options.category}'. Expected one of: ${[...VALID_CATEGORIES].join(", ")}`);
|
||||
}
|
||||
|
||||
const outputDir = path.resolve(options.outputDir);
|
||||
if (fs.existsSync(outputDir)) {
|
||||
throw new Error(`Directory already exists: ${outputDir}`);
|
||||
}
|
||||
|
||||
const displayName = options.displayName ?? makeDisplayName(options.pluginName);
|
||||
const description = options.description ?? "A Paperclip plugin";
|
||||
const author = options.author ?? "Plugin Author";
|
||||
const category = options.category ?? (template === "workspace" ? "workspace" : "connector");
|
||||
const manifestId = packageToManifestId(options.pluginName);
|
||||
const localSdkPath = path.resolve(options.sdkPath ?? getLocalSdkPackagePath());
|
||||
const localSharedPath = getLocalSharedPackagePath(localSdkPath);
|
||||
const repoRoot = getRepoRootFromSdkPath(localSdkPath);
|
||||
const useWorkspaceSdk = isInsideDir(outputDir, repoRoot);
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const packedSharedTarball = useWorkspaceSdk ? null : packLocalPackage(localSharedPath, outputDir);
|
||||
const sdkDependency = useWorkspaceSdk
|
||||
? "workspace:*"
|
||||
: `file:${toPosixPath(path.relative(outputDir, packLocalPackage(localSdkPath, outputDir)))}`;
|
||||
|
||||
const packageJson = {
|
||||
name: options.pluginName,
|
||||
version: "0.1.0",
|
||||
type: "module",
|
||||
private: true,
|
||||
description,
|
||||
scripts: {
|
||||
build: "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
dev: "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
||||
test: "vitest run --config ./vitest.config.ts",
|
||||
typecheck: "tsc --noEmit"
|
||||
},
|
||||
paperclipPlugin: {
|
||||
manifest: "./dist/manifest.js",
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui/"
|
||||
},
|
||||
keywords: ["paperclip", "plugin", category],
|
||||
author,
|
||||
license: "MIT",
|
||||
...(packedSharedTarball
|
||||
? {
|
||||
pnpm: {
|
||||
overrides: {
|
||||
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
devDependencies: {
|
||||
...(packedSharedTarball
|
||||
? {
|
||||
"@paperclipai/shared": `file:${toPosixPath(path.relative(outputDir, packedSharedTarball))}`,
|
||||
}
|
||||
: {}),
|
||||
"@paperclipai/plugin-sdk": sdkDependency,
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
esbuild: "^0.27.3",
|
||||
rollup: "^4.38.0",
|
||||
tslib: "^2.8.1",
|
||||
typescript: "^5.7.3",
|
||||
vitest: "^3.0.5"
|
||||
},
|
||||
peerDependencies: {
|
||||
react: ">=18"
|
||||
}
|
||||
};
|
||||
|
||||
writeFile(path.join(outputDir, "package.json"), `${JSON.stringify(packageJson, null, 2)}\n`);
|
||||
|
||||
const tsconfig = {
|
||||
compilerOptions: {
|
||||
target: "ES2022",
|
||||
module: "NodeNext",
|
||||
moduleResolution: "NodeNext",
|
||||
lib: ["ES2022", "DOM"],
|
||||
jsx: "react-jsx",
|
||||
strict: true,
|
||||
skipLibCheck: true,
|
||||
declaration: true,
|
||||
declarationMap: true,
|
||||
sourceMap: true,
|
||||
outDir: "dist",
|
||||
rootDir: "."
|
||||
},
|
||||
include: ["src", "tests"],
|
||||
exclude: ["dist", "node_modules"]
|
||||
};
|
||||
|
||||
writeFile(path.join(outputDir, "tsconfig.json"), `${JSON.stringify(tsconfig, null, 2)}\n`);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "esbuild.config.mjs"),
|
||||
`import esbuild from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
||||
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
||||
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
||||
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
||||
} else {
|
||||
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
||||
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "rollup.config.mjs"),
|
||||
`import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
|
||||
function withPlugins(config) {
|
||||
if (!config) return null;
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
withPlugins(presets.rollup.manifest),
|
||||
withPlugins(presets.rollup.worker),
|
||||
withPlugins(presets.rollup.ui),
|
||||
].filter(Boolean);
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "vitest.config.ts"),
|
||||
`import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "manifest.ts"),
|
||||
`import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: ${quote(manifestId)},
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: ${quote(displayName)},
|
||||
description: ${quote(description)},
|
||||
author: ${quote(author)},
|
||||
categories: [${quote(category)}],
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "health-widget",
|
||||
displayName: ${quote(`${displayName} Health`)},
|
||||
exportName: "DashboardWidget"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "worker.ts"),
|
||||
`import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.events.on("issue.created", async (event) => {
|
||||
const issueId = event.entityId ?? "unknown";
|
||||
await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true);
|
||||
ctx.logger.info("Observed issue.created", { issueId });
|
||||
});
|
||||
|
||||
ctx.data.register("health", async () => {
|
||||
return { status: "ok", checkedAt: new Date().toISOString() };
|
||||
});
|
||||
|
||||
ctx.actions.register("ping", async () => {
|
||||
ctx.logger.info("Ping action invoked");
|
||||
return { pong: true, at: new Date().toISOString() };
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Plugin worker is running" };
|
||||
}
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "src", "ui", "index.tsx"),
|
||||
`import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
type HealthData = {
|
||||
status: "ok" | "degraded" | "error";
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export function DashboardWidget(_props: PluginWidgetProps) {
|
||||
const { data, loading, error } = usePluginData<HealthData>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
|
||||
if (loading) return <div>Loading plugin health...</div>;
|
||||
if (error) return <div>Plugin error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<strong>${displayName}</strong>
|
||||
<div>Health: {data?.status ?? "unknown"}</div>
|
||||
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
||||
<button onClick={() => void ping()}>Ping Worker</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "tests", "plugin.spec.ts"),
|
||||
`import { describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("registers data + actions and handles events", async () => {
|
||||
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||||
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
|
||||
|
||||
const data = await harness.getData<{ status: string }>("health");
|
||||
expect(data.status).toBe("ok");
|
||||
|
||||
const action = await harness.performAction<{ pong: boolean }>("ping");
|
||||
expect(action.pong).toBe(true);
|
||||
});
|
||||
});
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(
|
||||
path.join(outputDir, "README.md"),
|
||||
`# ${displayName}
|
||||
|
||||
${description}
|
||||
|
||||
## Development
|
||||
|
||||
\`\`\`bash
|
||||
pnpm install
|
||||
pnpm dev # watch builds
|
||||
pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
\`\`\`
|
||||
|
||||
${sdkDependency.startsWith("file:")
|
||||
? `This scaffold snapshots \`@paperclipai/plugin-sdk\` and \`@paperclipai/shared\` from a local Paperclip checkout at:\n\n\`${toPosixPath(localSdkPath)}\`\n\nThe packed tarballs live in \`.paperclip-sdk/\` for local development. Before publishing this plugin, switch those dependencies to published package versions once they are available on npm.\n\n`
|
||||
: ""}
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
\`\`\`bash
|
||||
curl -X POST http://127.0.0.1:3100/api/plugins/install \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"packageName":"${toPosixPath(outputDir)}","isLocalPath":true}'
|
||||
\`\`\`
|
||||
|
||||
## Build Options
|
||||
|
||||
- \`pnpm build\` uses esbuild presets from \`@paperclipai/plugin-sdk/bundlers\`.
|
||||
- \`pnpm build:rollup\` uses rollup presets from the same SDK.
|
||||
`,
|
||||
);
|
||||
|
||||
writeFile(path.join(outputDir, ".gitignore"), "dist\nnode_modules\n.paperclip-sdk\n");
|
||||
|
||||
return outputDir;
|
||||
}
|
||||
|
||||
function parseArg(name: string): string | undefined {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index === -1) return undefined;
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
/** CLI wrapper for `scaffoldPluginProject`. */
|
||||
function runCli() {
|
||||
const pluginName = process.argv[2];
|
||||
if (!pluginName) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Usage: create-paperclip-plugin <name> [--template default|connector|workspace] [--output <dir>] [--sdk-path <paperclip-sdk-path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const template = (parseArg("--template") ?? "default") as PluginTemplate;
|
||||
const outputRoot = parseArg("--output") ?? process.cwd();
|
||||
const targetDir = path.resolve(outputRoot, packageToDirName(pluginName));
|
||||
|
||||
const out = scaffoldPluginProject({
|
||||
pluginName,
|
||||
outputDir: targetDir,
|
||||
template,
|
||||
displayName: parseArg("--display-name"),
|
||||
description: parseArg("--description"),
|
||||
author: parseArg("--author"),
|
||||
category: parseArg("--category") as ScaffoldPluginOptions["category"] | undefined,
|
||||
sdkPath: parseArg("--sdk-path"),
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Created plugin scaffold at ${out}`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
runCli();
|
||||
}
|
||||
9
packages/plugins/create-paperclip-plugin/tsconfig.json
Normal file
9
packages/plugins/create-paperclip-plugin/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
2
packages/plugins/examples/plugin-authoring-smoke-example/.gitignore
vendored
Normal file
2
packages/plugins/examples/plugin-authoring-smoke-example/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -0,0 +1,23 @@
|
||||
# Plugin Authoring Smoke Example
|
||||
|
||||
A Paperclip plugin
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # watch builds
|
||||
pnpm dev:ui # local dev server with hot-reload events
|
||||
pnpm test
|
||||
```
|
||||
|
||||
## Install Into Paperclip
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin install ./
|
||||
```
|
||||
|
||||
## Build Options
|
||||
|
||||
- `pnpm build` uses esbuild presets from `@paperclipai/plugin-sdk/bundlers`.
|
||||
- `pnpm build:rollup` uses rollup presets from the same SDK.
|
||||
@@ -0,0 +1,17 @@
|
||||
import esbuild from "esbuild";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
const watch = process.argv.includes("--watch");
|
||||
|
||||
const workerCtx = await esbuild.context(presets.esbuild.worker);
|
||||
const manifestCtx = await esbuild.context(presets.esbuild.manifest);
|
||||
const uiCtx = await esbuild.context(presets.esbuild.ui);
|
||||
|
||||
if (watch) {
|
||||
await Promise.all([workerCtx.watch(), manifestCtx.watch(), uiCtx.watch()]);
|
||||
console.log("esbuild watch mode enabled for worker, manifest, and ui");
|
||||
} else {
|
||||
await Promise.all([workerCtx.rebuild(), manifestCtx.rebuild(), uiCtx.rebuild()]);
|
||||
await Promise.all([workerCtx.dispose(), manifestCtx.dispose(), uiCtx.dispose()]);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-authoring-smoke-example",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "A Paperclip plugin",
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "node ./esbuild.config.mjs",
|
||||
"build:rollup": "rollup -c",
|
||||
"dev": "node ./esbuild.config.mjs --watch",
|
||||
"dev:ui": "paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177",
|
||||
"test": "vitest run --config ./vitest.config.ts",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"keywords": [
|
||||
"paperclip",
|
||||
"plugin",
|
||||
"connector"
|
||||
],
|
||||
"author": "Plugin Author",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-node-resolve": "^16.0.1",
|
||||
"@rollup/plugin-typescript": "^12.1.2",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"esbuild": "^0.27.3",
|
||||
"rollup": "^4.38.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
||||
import typescript from "@rollup/plugin-typescript";
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
|
||||
function withPlugins(config) {
|
||||
if (!config) return null;
|
||||
return {
|
||||
...config,
|
||||
plugins: [
|
||||
nodeResolve({
|
||||
extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs"],
|
||||
}),
|
||||
typescript({
|
||||
tsconfig: "./tsconfig.json",
|
||||
declaration: false,
|
||||
declarationMap: false,
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
withPlugins(presets.rollup.manifest),
|
||||
withPlugins(presets.rollup.worker),
|
||||
withPlugins(presets.rollup.ui),
|
||||
].filter(Boolean);
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: "paperclipai.plugin-authoring-smoke-example",
|
||||
apiVersion: 1,
|
||||
version: "0.1.0",
|
||||
displayName: "Plugin Authoring Smoke Example",
|
||||
description: "A Paperclip plugin",
|
||||
author: "Plugin Author",
|
||||
categories: ["connector"],
|
||||
capabilities: [
|
||||
"events.subscribe",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write"
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui"
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: "health-widget",
|
||||
displayName: "Plugin Authoring Smoke Example Health",
|
||||
exportName: "DashboardWidget"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { usePluginAction, usePluginData, type PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
type HealthData = {
|
||||
status: "ok" | "degraded" | "error";
|
||||
checkedAt: string;
|
||||
};
|
||||
|
||||
export function DashboardWidget(_props: PluginWidgetProps) {
|
||||
const { data, loading, error } = usePluginData<HealthData>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
|
||||
if (loading) return <div>Loading plugin health...</div>;
|
||||
if (error) return <div>Plugin error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div style={{ display: "grid", gap: "0.5rem" }}>
|
||||
<strong>Plugin Authoring Smoke Example</strong>
|
||||
<div>Health: {data?.status ?? "unknown"}</div>
|
||||
<div>Checked: {data?.checkedAt ?? "never"}</div>
|
||||
<button onClick={() => void ping()}>Ping Worker</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.events.on("issue.created", async (event) => {
|
||||
const issueId = event.entityId ?? "unknown";
|
||||
await ctx.state.set({ scopeKind: "issue", scopeId: issueId, stateKey: "seen" }, true);
|
||||
ctx.logger.info("Observed issue.created", { issueId });
|
||||
});
|
||||
|
||||
ctx.data.register("health", async () => {
|
||||
return { status: "ok", checkedAt: new Date().toISOString() };
|
||||
});
|
||||
|
||||
ctx.actions.register("ping", async () => {
|
||||
ctx.logger.info("Ping action invoked");
|
||||
return { pong: true, at: new Date().toISOString() };
|
||||
});
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: "Plugin worker is running" };
|
||||
}
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import manifest from "../src/manifest.js";
|
||||
import plugin from "../src/worker.js";
|
||||
|
||||
describe("plugin scaffold", () => {
|
||||
it("registers data + actions and handles events", async () => {
|
||||
const harness = createTestHarness({ manifest, capabilities: [...manifest.capabilities, "events.emit"] });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
|
||||
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||||
expect(harness.getState({ scopeKind: "issue", scopeId: "iss_1", stateKey: "seen" })).toBe(true);
|
||||
|
||||
const data = await harness.getData<{ status: string }>("health");
|
||||
expect(data.status).toBe("ok");
|
||||
|
||||
const action = await harness.performAction<{ pong: boolean }>("ping");
|
||||
expect(action.pong).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM"
|
||||
],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"tests"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/**/*.spec.ts"],
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
# File Browser Example Plugin
|
||||
|
||||
Example Paperclip plugin that demonstrates:
|
||||
|
||||
- **projectSidebarItem** — An optional "Files" link under each project in the sidebar that opens the project detail with this plugin’s tab selected. This is controlled by plugin settings and defaults to off.
|
||||
- **detailTab** (entityType project) — A project detail tab with a workspace-path selector, a desktop two-column layout (file tree left, editor right), and a mobile one-panel flow with a back button from editor to file tree, including save support.
|
||||
|
||||
This is a repo-local example plugin for development. It should not be assumed to ship in a generic production build unless it is explicitly included.
|
||||
|
||||
## Slots
|
||||
|
||||
| Slot | Type | Description |
|
||||
|---------------------|---------------------|--------------------------------------------------|
|
||||
| Files (sidebar) | `projectSidebarItem`| Optional link under each project → project detail + tab. |
|
||||
| Files (tab) | `detailTab` | Responsive tree/editor layout with save support.|
|
||||
|
||||
## Settings
|
||||
|
||||
- `Show Files in Sidebar` — toggles the project sidebar link on or off. Defaults to off.
|
||||
- `Comment File Links` — controls whether comment annotations and the comment context-menu action are shown.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- `ui.sidebar.register` — project sidebar item
|
||||
- `ui.detailTab.register` — project detail tab
|
||||
- `projects.read` — resolve project
|
||||
- `project.workspaces.read` — list workspaces and read paths for file access
|
||||
|
||||
## Worker
|
||||
|
||||
- **getData `workspaces`** — `ctx.projects.listWorkspaces(projectId, companyId)` (ordered, primary first).
|
||||
- **getData `fileList`** — `{ projectId, workspaceId, directoryPath? }` → list directory entries for the workspace root or a subdirectory (Node `fs`).
|
||||
- **getData `fileContent`** — `{ projectId, workspaceId, filePath }` → read file content using workspace-relative paths (Node `fs`).
|
||||
- **performAction `writeFile`** — `{ projectId, workspaceId, filePath, content }` → write the current editor buffer back to disk.
|
||||
|
||||
## Local Install (Dev)
|
||||
|
||||
From the repo root, build the plugin and install it by local path:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-file-browser-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-file-browser-example
|
||||
```
|
||||
|
||||
To uninstall:
|
||||
|
||||
```bash
|
||||
pnpm paperclipai plugin uninstall paperclip-file-browser-example --force
|
||||
```
|
||||
|
||||
**Local development notes:**
|
||||
|
||||
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
|
||||
- **Dev-only install path.** This local-path install flow assumes this monorepo checkout is present on disk. For deployed installs, publish an npm package instead of depending on `packages/plugins/examples/...` existing on the host.
|
||||
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin.
|
||||
- Optional: use `paperclip-plugin-dev-server` for UI hot-reload with `devUiUrl` in plugin config.
|
||||
|
||||
## Structure
|
||||
|
||||
- `src/manifest.ts` — manifest with `projectSidebarItem` and `detailTab` (entityTypes `["project"]`).
|
||||
- `src/worker.ts` — data handlers for workspaces, file list, file content.
|
||||
- `src/ui/index.tsx` — `FilesLink` (sidebar) and `FilesTab` (workspace path selector + two-panel file tree/editor).
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-file-browser-example",
|
||||
"version": "0.1.0",
|
||||
"description": "Example plugin: project sidebar Files link + project detail tab with workspace selector and file browser",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/language": "^6.11.0",
|
||||
"@codemirror/state": "^6.4.0",
|
||||
"@codemirror/view": "^6.28.0",
|
||||
"@lezer/highlight": "^1.2.1",
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"codemirror": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"esbuild": "^0.27.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import esbuild from "esbuild";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const packageRoot = path.resolve(__dirname, "..");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
|
||||
outfile: path.join(packageRoot, "dist/ui/index.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
external: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
],
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_ID = "paperclip-file-browser-example";
|
||||
const FILES_SIDEBAR_SLOT_ID = "files-link";
|
||||
const FILES_TAB_SLOT_ID = "files-tab";
|
||||
const COMMENT_FILE_LINKS_SLOT_ID = "comment-file-links";
|
||||
const COMMENT_OPEN_FILES_SLOT_ID = "comment-open-files";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: "0.2.0",
|
||||
displayName: "File Browser (Example)",
|
||||
description: "Example plugin that adds a Files link under each project in the sidebar, a file browser + editor tab on the project detail page, and per-comment file link annotations with a context menu action to open referenced files.",
|
||||
author: "Paperclip",
|
||||
categories: ["workspace", "ui"],
|
||||
capabilities: [
|
||||
"ui.sidebar.register",
|
||||
"ui.detailTab.register",
|
||||
"ui.commentAnnotation.register",
|
||||
"ui.action.register",
|
||||
"projects.read",
|
||||
"project.workspaces.read",
|
||||
"issue.comments.read",
|
||||
"plugin.state.read",
|
||||
],
|
||||
instanceConfigSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
showFilesInSidebar: {
|
||||
type: "boolean",
|
||||
title: "Show Files in Sidebar",
|
||||
default: false,
|
||||
description: "Adds the Files link under each project in the sidebar.",
|
||||
},
|
||||
commentAnnotationMode: {
|
||||
type: "string",
|
||||
title: "Comment File Links",
|
||||
enum: ["annotation", "contextMenu", "both", "none"],
|
||||
default: "both",
|
||||
description: "Controls which comment extensions are active: 'annotation' shows file links below each comment, 'contextMenu' adds an \"Open in Files\" action to the comment menu, 'both' enables both, 'none' disables comment features.",
|
||||
},
|
||||
},
|
||||
},
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "projectSidebarItem",
|
||||
id: FILES_SIDEBAR_SLOT_ID,
|
||||
displayName: "Files",
|
||||
exportName: "FilesLink",
|
||||
entityTypes: ["project"],
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
type: "detailTab",
|
||||
id: FILES_TAB_SLOT_ID,
|
||||
displayName: "Files",
|
||||
exportName: "FilesTab",
|
||||
entityTypes: ["project"],
|
||||
order: 10,
|
||||
},
|
||||
{
|
||||
type: "commentAnnotation",
|
||||
id: COMMENT_FILE_LINKS_SLOT_ID,
|
||||
displayName: "File Links",
|
||||
exportName: "CommentFileLinks",
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
{
|
||||
type: "commentContextMenuItem",
|
||||
id: COMMENT_OPEN_FILES_SLOT_ID,
|
||||
displayName: "Open in Files",
|
||||
exportName: "CommentOpenFiles",
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,815 @@
|
||||
import type {
|
||||
PluginProjectSidebarItemProps,
|
||||
PluginDetailTabProps,
|
||||
PluginCommentAnnotationProps,
|
||||
PluginCommentContextMenuItemProps,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
import { useMemo, useState, useEffect, useRef, type MouseEvent, type RefObject } from "react";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { javascript } from "@codemirror/lang-javascript";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
const PLUGIN_KEY = "paperclip-file-browser-example";
|
||||
const FILES_TAB_SLOT_ID = "files-tab";
|
||||
|
||||
const editorBaseTheme = {
|
||||
"&": {
|
||||
height: "100%",
|
||||
},
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
fontFamily:
|
||||
"ui-monospace, SFMono-Regular, SF Mono, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
||||
fontSize: "13px",
|
||||
lineHeight: "1.6",
|
||||
},
|
||||
".cm-content": {
|
||||
padding: "12px 14px 18px",
|
||||
},
|
||||
};
|
||||
|
||||
const editorDarkTheme = EditorView.theme({
|
||||
...editorBaseTheme,
|
||||
"&": {
|
||||
...editorBaseTheme["&"],
|
||||
backgroundColor: "oklch(0.23 0.02 255)",
|
||||
color: "oklch(0.93 0.01 255)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "oklch(0.25 0.015 255)",
|
||||
color: "oklch(0.74 0.015 255)",
|
||||
borderRight: "1px solid oklch(0.34 0.01 255)",
|
||||
},
|
||||
".cm-activeLine, .cm-activeLineGutter": {
|
||||
backgroundColor: "oklch(0.30 0.012 255 / 0.55)",
|
||||
},
|
||||
".cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "oklch(0.42 0.02 255 / 0.45)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "oklch(0.47 0.025 255 / 0.5)",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "oklch(0.93 0.01 255)",
|
||||
},
|
||||
".cm-matchingBracket": {
|
||||
backgroundColor: "oklch(0.37 0.015 255 / 0.5)",
|
||||
color: "oklch(0.95 0.01 255)",
|
||||
outline: "none",
|
||||
},
|
||||
".cm-nonmatchingBracket": {
|
||||
color: "oklch(0.70 0.08 24)",
|
||||
},
|
||||
}, { dark: true });
|
||||
|
||||
const editorLightTheme = EditorView.theme({
|
||||
...editorBaseTheme,
|
||||
"&": {
|
||||
...editorBaseTheme["&"],
|
||||
backgroundColor: "color-mix(in oklab, var(--card) 92%, var(--background))",
|
||||
color: "var(--foreground)",
|
||||
},
|
||||
".cm-content": {
|
||||
...editorBaseTheme[".cm-content"],
|
||||
caretColor: "var(--foreground)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "color-mix(in oklab, var(--card) 96%, var(--background))",
|
||||
color: "var(--muted-foreground)",
|
||||
borderRight: "1px solid var(--border)",
|
||||
},
|
||||
".cm-activeLine, .cm-activeLineGutter": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 52%, transparent)",
|
||||
},
|
||||
".cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 72%, transparent)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 84%, transparent)",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "color-mix(in oklab, var(--foreground) 88%, transparent)",
|
||||
},
|
||||
".cm-matchingBracket": {
|
||||
backgroundColor: "color-mix(in oklab, var(--accent) 45%, transparent)",
|
||||
color: "var(--foreground)",
|
||||
outline: "none",
|
||||
},
|
||||
".cm-nonmatchingBracket": {
|
||||
color: "var(--destructive)",
|
||||
},
|
||||
});
|
||||
|
||||
const editorDarkHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "oklch(0.78 0.025 265)" },
|
||||
{ tag: [tags.name, tags.variableName], color: "oklch(0.88 0.01 255)" },
|
||||
{ tag: [tags.string, tags.special(tags.string)], color: "oklch(0.80 0.02 170)" },
|
||||
{ tag: [tags.number, tags.bool, tags.null], color: "oklch(0.79 0.02 95)" },
|
||||
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.64 0.01 255)" },
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.84 0.018 220)" },
|
||||
{ tag: [tags.typeName, tags.className], color: "oklch(0.82 0.02 245)" },
|
||||
{ tag: [tags.operator, tags.punctuation], color: "oklch(0.77 0.01 255)" },
|
||||
{ tag: [tags.invalid, tags.deleted], color: "oklch(0.70 0.08 24)" },
|
||||
]);
|
||||
|
||||
const editorLightHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "oklch(0.45 0.07 270)" },
|
||||
{ tag: [tags.name, tags.variableName], color: "oklch(0.28 0.01 255)" },
|
||||
{ tag: [tags.string, tags.special(tags.string)], color: "oklch(0.45 0.06 165)" },
|
||||
{ tag: [tags.number, tags.bool, tags.null], color: "oklch(0.48 0.08 90)" },
|
||||
{ tag: [tags.comment, tags.lineComment, tags.blockComment], color: "oklch(0.53 0.01 255)" },
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: "oklch(0.42 0.07 220)" },
|
||||
{ tag: [tags.typeName, tags.className], color: "oklch(0.40 0.06 245)" },
|
||||
{ tag: [tags.operator, tags.punctuation], color: "oklch(0.36 0.01 255)" },
|
||||
{ tag: [tags.invalid, tags.deleted], color: "oklch(0.55 0.16 24)" },
|
||||
]);
|
||||
|
||||
type Workspace = { id: string; projectId: string; name: string; path: string; isPrimary: boolean };
|
||||
type FileEntry = { name: string; path: string; isDirectory: boolean };
|
||||
type FileTreeNodeProps = {
|
||||
entry: FileEntry;
|
||||
companyId: string | null;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
const PathLikePattern = /[\\/]/;
|
||||
const WindowsDrivePathPattern = /^[A-Za-z]:[\\/]/;
|
||||
const UuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function isLikelyPath(pathValue: string): boolean {
|
||||
const trimmed = pathValue.trim();
|
||||
return PathLikePattern.test(trimmed) || WindowsDrivePathPattern.test(trimmed);
|
||||
}
|
||||
|
||||
function workspaceLabel(workspace: Workspace): string {
|
||||
const pathLabel = workspace.path.trim();
|
||||
const nameLabel = workspace.name.trim();
|
||||
const hasPathLabel = isLikelyPath(pathLabel) && !UuidPattern.test(pathLabel);
|
||||
const hasNameLabel = nameLabel.length > 0 && !UuidPattern.test(nameLabel);
|
||||
const baseLabel = hasPathLabel ? pathLabel : hasNameLabel ? nameLabel : "";
|
||||
if (!baseLabel) {
|
||||
return workspace.isPrimary ? "(no workspace path) (primary)" : "(no workspace path)";
|
||||
}
|
||||
|
||||
return workspace.isPrimary ? `${baseLabel} (primary)` : baseLabel;
|
||||
}
|
||||
|
||||
function useIsMobile(breakpointPx = 768): boolean {
|
||||
const [isMobile, setIsMobile] = useState(() =>
|
||||
typeof window !== "undefined" ? window.innerWidth < breakpointPx : false,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const mediaQuery = window.matchMedia(`(max-width: ${breakpointPx - 1}px)`);
|
||||
const update = () => setIsMobile(mediaQuery.matches);
|
||||
update();
|
||||
mediaQuery.addEventListener("change", update);
|
||||
return () => mediaQuery.removeEventListener("change", update);
|
||||
}, [breakpointPx]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
function useIsDarkMode(): boolean {
|
||||
const [isDarkMode, setIsDarkMode] = useState(() =>
|
||||
typeof document !== "undefined" && document.documentElement.classList.contains("dark"),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
const root = document.documentElement;
|
||||
const update = () => setIsDarkMode(root.classList.contains("dark"));
|
||||
update();
|
||||
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(root, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDarkMode;
|
||||
}
|
||||
|
||||
function useAvailableHeight(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
options?: { bottomPadding?: number; minHeight?: number },
|
||||
): number | null {
|
||||
const bottomPadding = options?.bottomPadding ?? 24;
|
||||
const minHeight = options?.minHeight ?? 384;
|
||||
const [height, setHeight] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const update = () => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const rect = element.getBoundingClientRect();
|
||||
const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - rect.top - bottomPadding));
|
||||
setHeight(nextHeight);
|
||||
};
|
||||
|
||||
update();
|
||||
window.addEventListener("resize", update);
|
||||
window.addEventListener("orientationchange", update);
|
||||
|
||||
const observer = typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(() => update())
|
||||
: null;
|
||||
if (observer && ref.current) observer.observe(ref.current);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", update);
|
||||
window.removeEventListener("orientationchange", update);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [bottomPadding, minHeight, ref]);
|
||||
|
||||
return height;
|
||||
}
|
||||
|
||||
function FileTreeNode({
|
||||
entry,
|
||||
companyId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth = 0,
|
||||
}: FileTreeNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const isSelected = selectedPath === entry.path;
|
||||
|
||||
if (entry.isDirectory) {
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-none px-2 py-1.5 text-left text-sm text-foreground hover:bg-accent/60"
|
||||
style={{ paddingLeft: `${depth * 14 + 8}px` }}
|
||||
onClick={() => setIsExpanded((value) => !value)}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
<span className="w-3 text-xs text-muted-foreground">{isExpanded ? "▾" : "▸"}</span>
|
||||
<span className="truncate font-medium">{entry.name}</span>
|
||||
</button>
|
||||
{isExpanded ? (
|
||||
<ExpandedDirectoryChildren
|
||||
directoryPath={entry.path}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
workspaceId={workspaceId}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth}
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className={`block w-full rounded-none px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
isSelected ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 14 + 23}px` }}
|
||||
onClick={() => onSelect(entry.path)}
|
||||
>
|
||||
<span className="truncate">{entry.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedDirectoryChildren({
|
||||
directoryPath,
|
||||
companyId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
directoryPath: string;
|
||||
companyId: string | null;
|
||||
projectId: string;
|
||||
workspaceId: string;
|
||||
selectedPath: string | null;
|
||||
onSelect: (path: string) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const { data: childData } = usePluginData<{ entries: FileEntry[] }>("fileList", {
|
||||
companyId,
|
||||
projectId,
|
||||
workspaceId,
|
||||
directoryPath,
|
||||
});
|
||||
const children = childData?.entries ?? [];
|
||||
|
||||
if (children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{children.map((child) => (
|
||||
<FileTreeNode
|
||||
key={child.path}
|
||||
entry={child}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
workspaceId={workspaceId}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project sidebar item: link "Files" that opens the project detail with the Files plugin tab.
|
||||
*/
|
||||
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
|
||||
const { data: config, loading: configLoading } = usePluginData<PluginConfig>("plugin-config", {});
|
||||
const showFilesInSidebar = config?.showFilesInSidebar ?? false;
|
||||
|
||||
if (configLoading || !showFilesInSidebar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const projectId = context.entityId;
|
||||
const projectRef = (context as PluginProjectSidebarItemProps["context"] & { projectRef?: string | null })
|
||||
.projectRef
|
||||
?? projectId;
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
|
||||
const href = `${prefix}/projects/${projectRef}?tab=${encodeURIComponent(tabValue)}`;
|
||||
const isActive = typeof window !== "undefined" && (() => {
|
||||
const pathname = window.location.pathname.replace(/\/+$/, "");
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const projectsIndex = segments.indexOf("projects");
|
||||
const activeProjectRef = projectsIndex >= 0 ? segments[projectsIndex + 1] ?? null : null;
|
||||
const activeTab = new URLSearchParams(window.location.search).get("tab");
|
||||
if (activeTab !== tabValue) return false;
|
||||
if (!activeProjectRef) return false;
|
||||
return activeProjectRef === projectId || activeProjectRef === projectRef;
|
||||
})();
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.button !== 0
|
||||
|| event.metaKey
|
||||
|| event.ctrlKey
|
||||
|| event.altKey
|
||||
|| event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
window.history.pushState({}, "", href);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
onClick={handleClick}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={`block px-3 py-1 text-[12px] truncate transition-colors ${
|
||||
isActive
|
||||
? "bg-accent text-foreground font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
Files
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Project detail tab: workspace selector, file tree, and CodeMirror editor.
|
||||
*/
|
||||
export function FilesTab({ context }: PluginDetailTabProps) {
|
||||
const companyId = context.companyId;
|
||||
const projectId = context.entityId;
|
||||
const isMobile = useIsMobile();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const panesRef = useRef<HTMLDivElement | null>(null);
|
||||
const availableHeight = useAvailableHeight(panesRef, {
|
||||
bottomPadding: isMobile ? 16 : 24,
|
||||
minHeight: isMobile ? 320 : 420,
|
||||
});
|
||||
const { data: workspacesData } = usePluginData<Workspace[]>("workspaces", {
|
||||
projectId,
|
||||
companyId,
|
||||
});
|
||||
const workspaces = workspacesData ?? [];
|
||||
const workspaceSelectKey = workspaces.map((w) => `${w.id}:${workspaceLabel(w)}`).join("|");
|
||||
const [workspaceId, setWorkspaceId] = useState<string | null>(null);
|
||||
const resolvedWorkspaceId = workspaceId ?? workspaces[0]?.id ?? null;
|
||||
const selectedWorkspace = useMemo(
|
||||
() => workspaces.find((w) => w.id === resolvedWorkspaceId) ?? null,
|
||||
[workspaces, resolvedWorkspaceId],
|
||||
);
|
||||
|
||||
const fileListParams = useMemo(
|
||||
() => (selectedWorkspace ? { projectId, companyId, workspaceId: selectedWorkspace.id } : {}),
|
||||
[companyId, projectId, selectedWorkspace],
|
||||
);
|
||||
const { data: fileListData, loading: fileListLoading } = usePluginData<{ entries: FileEntry[] }>(
|
||||
"fileList",
|
||||
fileListParams,
|
||||
);
|
||||
const entries = fileListData?.entries ?? [];
|
||||
|
||||
// Track the `?file=` query parameter across navigations (popstate).
|
||||
const [urlFilePath, setUrlFilePath] = useState<string | null>(() => {
|
||||
if (typeof window === "undefined") return null;
|
||||
return new URLSearchParams(window.location.search).get("file") || null;
|
||||
});
|
||||
const lastConsumedFileRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onNav = () => {
|
||||
const next = new URLSearchParams(window.location.search).get("file") || null;
|
||||
setUrlFilePath(next);
|
||||
};
|
||||
window.addEventListener("popstate", onNav);
|
||||
return () => window.removeEventListener("popstate", onNav);
|
||||
}, []);
|
||||
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
setSelectedPath(null);
|
||||
setMobileView("browser");
|
||||
lastConsumedFileRef.current = null;
|
||||
}, [selectedWorkspace?.id]);
|
||||
|
||||
// When a file path appears (or changes) in the URL and workspace is ready, select it.
|
||||
useEffect(() => {
|
||||
if (!urlFilePath || !selectedWorkspace) return;
|
||||
if (lastConsumedFileRef.current === urlFilePath) return;
|
||||
lastConsumedFileRef.current = urlFilePath;
|
||||
setSelectedPath(urlFilePath);
|
||||
setMobileView("editor");
|
||||
}, [urlFilePath, selectedWorkspace]);
|
||||
|
||||
const fileContentParams = useMemo(
|
||||
() =>
|
||||
selectedPath && selectedWorkspace
|
||||
? { projectId, companyId, workspaceId: selectedWorkspace.id, filePath: selectedPath }
|
||||
: null,
|
||||
[companyId, projectId, selectedWorkspace, selectedPath],
|
||||
);
|
||||
const fileContentResult = usePluginData<{ content: string | null; error?: string }>(
|
||||
"fileContent",
|
||||
fileContentParams ?? {},
|
||||
);
|
||||
const { data: fileContentData, refresh: refreshFileContent } = fileContentResult;
|
||||
const writeFile = usePluginAction("writeFile");
|
||||
const editorRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const loadedContentRef = useRef("");
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [mobileView, setMobileView] = useState<"browser" | "editor">("browser");
|
||||
|
||||
useEffect(() => {
|
||||
if (!editorRef.current) return;
|
||||
const content = fileContentData?.content ?? "";
|
||||
loadedContentRef.current = content;
|
||||
setIsDirty(false);
|
||||
setSaveMessage(null);
|
||||
setSaveError(null);
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
const view = new EditorView({
|
||||
doc: content,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
javascript(),
|
||||
isDarkMode ? editorDarkTheme : editorLightTheme,
|
||||
syntaxHighlighting(isDarkMode ? editorDarkHighlightStyle : editorLightHighlightStyle),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged) return;
|
||||
const nextValue = update.state.doc.toString();
|
||||
setIsDirty(nextValue !== loadedContentRef.current);
|
||||
setSaveMessage(null);
|
||||
setSaveError(null);
|
||||
}),
|
||||
],
|
||||
parent: editorRef.current,
|
||||
});
|
||||
viewRef.current = view;
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
}, [fileContentData?.content, selectedPath, isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "s") {
|
||||
return;
|
||||
}
|
||||
if (!selectedWorkspace || !selectedPath || !isDirty || isSaving) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
void handleSave();
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
return () => window.removeEventListener("keydown", handleKeydown);
|
||||
}, [selectedWorkspace, selectedPath, isDirty, isSaving]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!selectedWorkspace || !selectedPath || !viewRef.current) {
|
||||
return;
|
||||
}
|
||||
const content = viewRef.current.state.doc.toString();
|
||||
setIsSaving(true);
|
||||
setSaveError(null);
|
||||
setSaveMessage(null);
|
||||
try {
|
||||
await writeFile({
|
||||
projectId,
|
||||
companyId,
|
||||
workspaceId: selectedWorkspace.id,
|
||||
filePath: selectedPath,
|
||||
content,
|
||||
});
|
||||
loadedContentRef.current = content;
|
||||
setIsDirty(false);
|
||||
setSaveMessage("Saved");
|
||||
refreshFileContent();
|
||||
} catch (error) {
|
||||
setSaveError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<label className="text-sm font-medium text-muted-foreground">Workspace</label>
|
||||
<select
|
||||
key={workspaceSelectKey}
|
||||
className="mt-2 block w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
value={resolvedWorkspaceId ?? ""}
|
||||
onChange={(e) => setWorkspaceId(e.target.value || null)}
|
||||
>
|
||||
{workspaces.map((w) => {
|
||||
const label = workspaceLabel(w);
|
||||
return (
|
||||
<option key={`${w.id}:${label}`} value={w.id} label={label} title={label}>
|
||||
{label}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={panesRef}
|
||||
className="min-h-0"
|
||||
style={{
|
||||
display: isMobile ? "block" : "grid",
|
||||
gap: "1rem",
|
||||
gridTemplateColumns: isMobile ? undefined : "320px minmax(0, 1fr)",
|
||||
height: availableHeight ? `${availableHeight}px` : undefined,
|
||||
minHeight: isMobile ? "20rem" : "26rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||
style={{ display: isMobile && mobileView === "editor" ? "none" : "flex" }}
|
||||
>
|
||||
<div className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">
|
||||
File Tree
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{selectedWorkspace ? (
|
||||
fileListLoading ? (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">Loading files...</p>
|
||||
) : entries.length > 0 ? (
|
||||
<ul className="space-y-0.5">
|
||||
{entries.map((entry) => (
|
||||
<FileTreeNode
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
companyId={companyId}
|
||||
projectId={projectId}
|
||||
workspaceId={selectedWorkspace.id}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={(path) => {
|
||||
setSelectedPath(path);
|
||||
setMobileView("editor");
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">No files found in this workspace.</p>
|
||||
)
|
||||
) : (
|
||||
<p className="px-2 py-3 text-sm text-muted-foreground">Select a workspace to browse files.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-card"
|
||||
style={{ display: isMobile && mobileView === "browser" ? "none" : "flex" }}
|
||||
>
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between gap-3 border-b border-border bg-card px-4 py-2">
|
||||
<div className="min-w-0">
|
||||
<button
|
||||
type="button"
|
||||
className="mb-2 inline-flex rounded-md border border-input bg-background px-2 py-1 text-xs font-medium text-muted-foreground"
|
||||
style={{ display: isMobile ? "inline-flex" : "none" }}
|
||||
onClick={() => setMobileView("browser")}
|
||||
>
|
||||
Back to files
|
||||
</button>
|
||||
<div className="text-xs font-medium uppercase tracking-[0.12em] text-muted-foreground">Editor</div>
|
||||
<div className="truncate text-sm text-foreground">{selectedPath ?? "No file selected"}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm font-medium text-foreground transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!selectedWorkspace || !selectedPath || !isDirty || isSaving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isDirty || saveMessage || saveError ? (
|
||||
<div className="border-b border-border px-4 py-2 text-xs">
|
||||
{saveError ? (
|
||||
<span className="text-destructive">{saveError}</span>
|
||||
) : saveMessage ? (
|
||||
<span className="text-emerald-600">{saveMessage}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedPath && fileContentData?.error && fileContentData.error !== "Missing file context" ? (
|
||||
<div className="border-b border-border px-4 py-2 text-xs text-destructive">{fileContentData.error}</div>
|
||||
) : null}
|
||||
<div ref={editorRef} className="min-h-0 flex-1 overflow-auto overscroll-contain" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment Annotation: renders detected file links below each comment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type PluginConfig = {
|
||||
showFilesInSidebar?: boolean;
|
||||
commentAnnotationMode: "annotation" | "contextMenu" | "both" | "none";
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-comment annotation showing file-path-like links extracted from the
|
||||
* comment body. Each link navigates to the project Files tab with the
|
||||
* matching path pre-selected.
|
||||
*
|
||||
* Respects the `commentAnnotationMode` instance config — hidden when mode
|
||||
* is `"contextMenu"` or `"none"`.
|
||||
*/
|
||||
function buildFileBrowserHref(prefix: string, projectId: string | null, filePath: string): string {
|
||||
if (!projectId) return "#";
|
||||
const tabValue = `plugin:${PLUGIN_KEY}:${FILES_TAB_SLOT_ID}`;
|
||||
return `${prefix}/projects/${projectId}?tab=${encodeURIComponent(tabValue)}&file=${encodeURIComponent(filePath)}`;
|
||||
}
|
||||
|
||||
function navigateToFileBrowser(href: string, event: MouseEvent<HTMLAnchorElement>) {
|
||||
if (
|
||||
event.defaultPrevented
|
||||
|| event.button !== 0
|
||||
|| event.metaKey
|
||||
|| event.ctrlKey
|
||||
|| event.altKey
|
||||
|| event.shiftKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
window.history.pushState({}, "", href);
|
||||
window.dispatchEvent(new PopStateEvent("popstate"));
|
||||
}
|
||||
|
||||
export function CommentFileLinks({ context }: PluginCommentAnnotationProps) {
|
||||
const { data: config } = usePluginData<PluginConfig>("plugin-config", {});
|
||||
const mode = config?.commentAnnotationMode ?? "both";
|
||||
|
||||
const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
|
||||
commentId: context.entityId,
|
||||
issueId: context.parentEntityId,
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (mode === "contextMenu" || mode === "none") return null;
|
||||
if (!data?.links?.length) return null;
|
||||
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const projectId = context.projectId;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">Files:</span>
|
||||
{data.links.map((link) => {
|
||||
const href = buildFileBrowserHref(prefix, projectId, link);
|
||||
return (
|
||||
<a
|
||||
key={link}
|
||||
href={href}
|
||||
onClick={(e) => navigateToFileBrowser(href, e)}
|
||||
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-1.5 py-0.5 text-xs font-mono text-primary hover:bg-accent/60 hover:underline transition-colors"
|
||||
title={`Open ${link} in file browser`}
|
||||
>
|
||||
{link}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment Context Menu Item: "Open in Files" action per comment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-comment context menu item that appears in the comment "more" (⋮) menu.
|
||||
* Extracts file paths from the comment body and, if any are found, renders
|
||||
* a button to open the first file in the project Files tab.
|
||||
*
|
||||
* Respects the `commentAnnotationMode` instance config — hidden when mode
|
||||
* is `"annotation"` or `"none"`.
|
||||
*/
|
||||
export function CommentOpenFiles({ context }: PluginCommentContextMenuItemProps) {
|
||||
const { data: config } = usePluginData<PluginConfig>("plugin-config", {});
|
||||
const mode = config?.commentAnnotationMode ?? "both";
|
||||
|
||||
const { data } = usePluginData<{ links: string[] }>("comment-file-links", {
|
||||
commentId: context.entityId,
|
||||
issueId: context.parentEntityId,
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (mode === "annotation" || mode === "none") return null;
|
||||
if (!data?.links?.length) return null;
|
||||
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const projectId = context.projectId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Files
|
||||
</div>
|
||||
{data.links.map((link) => {
|
||||
const href = buildFileBrowserHref(prefix, projectId, link);
|
||||
const fileName = link.split("/").pop() ?? link;
|
||||
return (
|
||||
<a
|
||||
key={link}
|
||||
href={href}
|
||||
onClick={(e) => navigateToFileBrowser(href, e)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs text-foreground hover:bg-accent transition-colors"
|
||||
title={`Open ${link} in file browser`}
|
||||
>
|
||||
<span className="truncate font-mono">{fileName}</span>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
|
||||
const PLUGIN_NAME = "file-browser-example";
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const PATH_LIKE_PATTERN = /[\\/]/;
|
||||
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
||||
|
||||
function looksLikePath(value: string): boolean {
|
||||
const normalized = value.trim();
|
||||
return (PATH_LIKE_PATTERN.test(normalized) || WINDOWS_DRIVE_PATH_PATTERN.test(normalized))
|
||||
&& !UUID_PATTERN.test(normalized);
|
||||
}
|
||||
|
||||
function sanitizeWorkspacePath(pathValue: string): string {
|
||||
return looksLikePath(pathValue) ? pathValue.trim() : "";
|
||||
}
|
||||
|
||||
function resolveWorkspace(workspacePath: string, requestedPath?: string): string | null {
|
||||
const root = path.resolve(workspacePath);
|
||||
const resolved = requestedPath ? path.resolve(root, requestedPath) : root;
|
||||
const relative = path.relative(root, resolved);
|
||||
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
||||
return null;
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex that matches file-path-like tokens in comment text.
|
||||
* Captures tokens that either start with `.` `/` `~` or contain a `/`
|
||||
* (directory separator), plus bare words that could be filenames with
|
||||
* extensions (e.g. `README.md`). The file-extension check in
|
||||
* `extractFilePaths` filters out non-file matches.
|
||||
*/
|
||||
const FILE_PATH_REGEX = /(?:^|[\s(`"'])([^\s,;)}`"'>\]]*\/[^\s,;)}`"'>\]]+|[.\/~][^\s,;)}`"'>\]]+|[a-zA-Z0-9_-]+\.[a-zA-Z0-9]{1,10}(?:\/[^\s,;)}`"'>\]]+)?)/g;
|
||||
|
||||
/** Common file extensions to recognise path-like tokens as actual file references. */
|
||||
const FILE_EXTENSION_REGEX = /\.[a-zA-Z0-9]{1,10}$/;
|
||||
|
||||
/**
|
||||
* Tokens that look like paths but are almost certainly URL route segments
|
||||
* (e.g. `/projects/abc`, `/settings`, `/dashboard`).
|
||||
*/
|
||||
const URL_ROUTE_PATTERN = /^\/(?:projects|issues|agents|settings|dashboard|plugins|api|auth|admin)\b/i;
|
||||
|
||||
function extractFilePaths(body: string): string[] {
|
||||
const paths = new Set<string>();
|
||||
for (const match of body.matchAll(FILE_PATH_REGEX)) {
|
||||
const raw = match[1];
|
||||
// Strip trailing punctuation that isn't part of a path
|
||||
const cleaned = raw.replace(/[.:,;!?)]+$/, "");
|
||||
if (cleaned.length <= 1) continue;
|
||||
// Must have a file extension (e.g. .ts, .json, .md)
|
||||
if (!FILE_EXTENSION_REGEX.test(cleaned)) continue;
|
||||
// Skip things that look like URL routes
|
||||
if (URL_ROUTE_PATTERN.test(cleaned)) continue;
|
||||
paths.add(cleaned);
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
|
||||
|
||||
// Expose the current plugin config so UI components can read operator
|
||||
// settings from the canonical instance config store.
|
||||
ctx.data.register("plugin-config", async () => {
|
||||
const config = await ctx.config.get();
|
||||
return {
|
||||
showFilesInSidebar: config?.showFilesInSidebar === true,
|
||||
commentAnnotationMode: config?.commentAnnotationMode ?? "both",
|
||||
};
|
||||
});
|
||||
|
||||
// Fetch a comment by ID and extract file-path-like tokens from its body.
|
||||
ctx.data.register("comment-file-links", async (params: Record<string, unknown>) => {
|
||||
const commentId = typeof params.commentId === "string" ? params.commentId : "";
|
||||
const issueId = typeof params.issueId === "string" ? params.issueId : "";
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
if (!commentId || !issueId || !companyId) return { links: [] };
|
||||
try {
|
||||
const comments = await ctx.issues.listComments(issueId, companyId);
|
||||
const comment = comments.find((c) => c.id === commentId);
|
||||
if (!comment?.body) return { links: [] };
|
||||
return { links: extractFilePaths(comment.body) };
|
||||
} catch (err) {
|
||||
ctx.logger.warn("Failed to fetch comment for file link extraction", { commentId, error: String(err) });
|
||||
return { links: [] };
|
||||
}
|
||||
});
|
||||
|
||||
ctx.data.register("workspaces", async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
if (!projectId || !companyId) return [];
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
return workspaces.map((w) => ({
|
||||
id: w.id,
|
||||
projectId: w.projectId,
|
||||
name: w.name,
|
||||
path: sanitizeWorkspacePath(w.path),
|
||||
isPrimary: w.isPrimary,
|
||||
}));
|
||||
});
|
||||
|
||||
ctx.data.register(
|
||||
"fileList",
|
||||
async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const directoryPath = typeof params.directoryPath === "string" ? params.directoryPath : "";
|
||||
if (!projectId || !companyId || !workspaceId) return { entries: [] };
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) return { entries: [] };
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) return { entries: [] };
|
||||
const dirPath = resolveWorkspace(workspacePath, directoryPath);
|
||||
if (!dirPath) {
|
||||
return { entries: [] };
|
||||
}
|
||||
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
|
||||
return { entries: [] };
|
||||
}
|
||||
const names = fs.readdirSync(dirPath).sort((a, b) => a.localeCompare(b));
|
||||
const entries = names.map((name) => {
|
||||
const full = path.join(dirPath, name);
|
||||
const stat = fs.lstatSync(full);
|
||||
const relativePath = path.relative(workspacePath, full);
|
||||
return {
|
||||
name,
|
||||
path: relativePath,
|
||||
isDirectory: stat.isDirectory(),
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
return { entries };
|
||||
},
|
||||
);
|
||||
|
||||
ctx.data.register(
|
||||
"fileContent",
|
||||
async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const filePath = params.filePath as string;
|
||||
if (!projectId || !companyId || !workspaceId || !filePath) {
|
||||
return { content: null, error: "Missing file context" };
|
||||
}
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) return { content: null, error: "Workspace not found" };
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) return { content: null, error: "Workspace has no path" };
|
||||
const fullPath = resolveWorkspace(workspacePath, filePath);
|
||||
if (!fullPath) {
|
||||
return { content: null, error: "Path outside workspace" };
|
||||
}
|
||||
try {
|
||||
const content = fs.readFileSync(fullPath, "utf-8");
|
||||
return { content };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { content: null, error: message };
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
ctx.actions.register(
|
||||
"writeFile",
|
||||
async (params: Record<string, unknown>) => {
|
||||
const projectId = params.projectId as string;
|
||||
const companyId = typeof params.companyId === "string" ? params.companyId : "";
|
||||
const workspaceId = params.workspaceId as string;
|
||||
const filePath = typeof params.filePath === "string" ? params.filePath.trim() : "";
|
||||
if (!filePath) {
|
||||
throw new Error("filePath must be a non-empty string");
|
||||
}
|
||||
const content = typeof params.content === "string" ? params.content : null;
|
||||
if (!projectId || !companyId || !workspaceId) {
|
||||
throw new Error("Missing workspace context");
|
||||
}
|
||||
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
|
||||
const workspace = workspaces.find((w) => w.id === workspaceId);
|
||||
if (!workspace) {
|
||||
throw new Error("Workspace not found");
|
||||
}
|
||||
const workspacePath = sanitizeWorkspacePath(workspace.path);
|
||||
if (!workspacePath) {
|
||||
throw new Error("Workspace has no path");
|
||||
}
|
||||
if (content === null) {
|
||||
throw new Error("Missing file content");
|
||||
}
|
||||
const fullPath = resolveWorkspace(workspacePath, filePath);
|
||||
if (!fullPath) {
|
||||
throw new Error("Path outside workspace");
|
||||
}
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error("Selected path is not a file");
|
||||
}
|
||||
fs.writeFileSync(fullPath, content, "utf-8");
|
||||
return {
|
||||
ok: true,
|
||||
path: filePath,
|
||||
bytes: Buffer.byteLength(content, "utf-8"),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async onHealth() {
|
||||
return { status: "ok", message: `${PLUGIN_NAME} ready` };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# @paperclipai/plugin-hello-world-example
|
||||
|
||||
First-party reference plugin showing the smallest possible UI extension.
|
||||
|
||||
## What It Demonstrates
|
||||
|
||||
- a manifest with a `dashboardWidget` UI slot
|
||||
- `entrypoints.ui` wiring for plugin UI bundles
|
||||
- a minimal React widget rendered in the Paperclip dashboard
|
||||
- reading host context (`companyId`) from `PluginWidgetProps`
|
||||
- worker lifecycle hooks (`setup`, `onHealth`) for basic runtime observability
|
||||
|
||||
## API Surface
|
||||
|
||||
- This example does not add custom HTTP endpoints.
|
||||
- The widget is discovered/rendered through host-managed plugin APIs (for example `GET /api/plugins/ui-contributions`).
|
||||
|
||||
## Notes
|
||||
|
||||
This is intentionally simple and is designed as the quickest "hello world" starting point for UI plugin authors.
|
||||
It is a repo-local example plugin for development, not a plugin that should be assumed to ship in generic production builds.
|
||||
|
||||
## Local Install (Dev)
|
||||
|
||||
From the repo root, build the plugin and install it by local path:
|
||||
|
||||
```bash
|
||||
pnpm --filter @paperclipai/plugin-hello-world-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example
|
||||
```
|
||||
|
||||
**Local development notes:**
|
||||
|
||||
- **Build first.** The host resolves the worker from the manifest `entrypoints.worker` (e.g. `./dist/worker.js`). Run `pnpm build` in the plugin directory before installing so the worker file exists.
|
||||
- **Dev-only install path.** This local-path install flow assumes a source checkout with this example package present on disk. For deployed installs, publish an npm package instead of relying on the monorepo example path.
|
||||
- **Reinstall after pulling.** If you installed a plugin by local path before the server stored `package_path`, the plugin may show status **error** (worker not found). Uninstall and install again so the server persists the path and can activate the plugin:
|
||||
`pnpm paperclipai plugin uninstall paperclip.hello-world-example --force` then
|
||||
`pnpm paperclipai plugin install ./packages/plugins/examples/plugin-hello-world-example`.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-hello-world-example",
|
||||
"version": "0.1.0",
|
||||
"description": "First-party reference plugin that adds a Hello World dashboard widget",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
|
||||
/**
|
||||
* Stable plugin ID used by host registration and namespacing.
|
||||
*/
|
||||
const PLUGIN_ID = "paperclip.hello-world-example";
|
||||
const PLUGIN_VERSION = "0.1.0";
|
||||
const DASHBOARD_WIDGET_SLOT_ID = "hello-world-dashboard-widget";
|
||||
const DASHBOARD_WIDGET_EXPORT_NAME = "HelloWorldDashboardWidget";
|
||||
|
||||
/**
|
||||
* Minimal manifest demonstrating a UI-only plugin with one dashboard widget slot.
|
||||
*/
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Hello World Widget (Example)",
|
||||
description: "Reference UI plugin that adds a simple Hello World widget to the Paperclip dashboard.",
|
||||
author: "Paperclip",
|
||||
categories: ["ui"],
|
||||
capabilities: ["ui.dashboardWidget.register"],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: DASHBOARD_WIDGET_SLOT_ID,
|
||||
displayName: "Hello World",
|
||||
exportName: DASHBOARD_WIDGET_EXPORT_NAME,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
const WIDGET_LABEL = "Hello world plugin widget";
|
||||
|
||||
/**
|
||||
* Example dashboard widget showing the smallest possible UI contribution.
|
||||
*/
|
||||
export function HelloWorldDashboardWidget({ context }: PluginWidgetProps) {
|
||||
return (
|
||||
<section aria-label={WIDGET_LABEL}>
|
||||
<strong>Hello world</strong>
|
||||
<div>This widget was added by @paperclipai/plugin-hello-world-example.</div>
|
||||
{/* Include host context so authors can see where scoped IDs come from. */}
|
||||
<div>Company context: {context.companyId}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const PLUGIN_NAME = "hello-world-example";
|
||||
const HEALTH_MESSAGE = "Hello World example plugin ready";
|
||||
|
||||
/**
|
||||
* Worker lifecycle hooks for the Hello World reference plugin.
|
||||
* This stays intentionally small so new authors can copy the shape quickly.
|
||||
*/
|
||||
const plugin = definePlugin({
|
||||
/**
|
||||
* Called when the host starts the plugin worker.
|
||||
*/
|
||||
async setup(ctx) {
|
||||
ctx.logger.info(`${PLUGIN_NAME} plugin setup complete`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Called by the host health probe endpoint.
|
||||
*/
|
||||
async onHealth() {
|
||||
return { status: "ok", message: HEALTH_MESSAGE };
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# @paperclipai/plugin-kitchen-sink-example
|
||||
|
||||
Kitchen Sink is the first-party reference plugin that demonstrates nearly the full currently implemented Paperclip plugin surface in one package.
|
||||
|
||||
It is intentionally broad:
|
||||
|
||||
- full plugin page
|
||||
- dashboard widget
|
||||
- project and issue surfaces
|
||||
- comment surfaces
|
||||
- sidebar surfaces
|
||||
- settings page
|
||||
- worker bridge data/actions
|
||||
- events, jobs, webhooks, tools, streams
|
||||
- state, entities, assets, metrics, activity
|
||||
- local workspace and process demos
|
||||
|
||||
This plugin is for local development, contributor onboarding, and runtime regression testing. It is not meant as a production plugin template to ship unchanged.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/plugin-kitchen-sink-example build
|
||||
pnpm paperclipai plugin install ./packages/plugins/examples/plugin-kitchen-sink-example
|
||||
```
|
||||
|
||||
Or install it from the Paperclip plugin manager as a bundled example once this repo is built.
|
||||
|
||||
## Notes
|
||||
|
||||
- Local workspace and process demos are trusted-only and default to safe, curated commands.
|
||||
- The plugin settings page lets you toggle optional demo surfaces and local runtime behavior.
|
||||
- Some SDK-defined host surfaces still depend on the Paperclip host wiring them visibly; this package aims to exercise the currently mounted ones and make the rest obvious.
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-kitchen-sink-example",
|
||||
"version": "0.1.0",
|
||||
"description": "Reference plugin that demonstrates the full Paperclip plugin surface area in one package",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"paperclipPlugin": {
|
||||
"manifest": "./dist/manifest.js",
|
||||
"worker": "./dist/worker.js",
|
||||
"ui": "./dist/ui/"
|
||||
},
|
||||
"scripts": {
|
||||
"prebuild": "node ../../../../scripts/ensure-plugin-build-deps.mjs",
|
||||
"build": "tsc && node ./scripts/build-ui.mjs",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/plugin-sdk build && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/plugin-sdk": "workspace:*",
|
||||
"@paperclipai/shared": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.27.3",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import esbuild from "esbuild";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const packageRoot = path.resolve(__dirname, "..");
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
|
||||
outfile: path.join(packageRoot, "dist/ui/index.js"),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: ["es2022"],
|
||||
sourcemap: true,
|
||||
external: [
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
],
|
||||
logLevel: "info",
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { PluginLauncherRegistration } from "@paperclipai/plugin-sdk";
|
||||
|
||||
export const PLUGIN_ID = "paperclip-kitchen-sink-example";
|
||||
export const PLUGIN_VERSION = "0.1.0";
|
||||
export const PAGE_ROUTE = "kitchensink";
|
||||
|
||||
export const SLOT_IDS = {
|
||||
page: "kitchen-sink-page",
|
||||
settingsPage: "kitchen-sink-settings-page",
|
||||
dashboardWidget: "kitchen-sink-dashboard-widget",
|
||||
sidebar: "kitchen-sink-sidebar-link",
|
||||
sidebarPanel: "kitchen-sink-sidebar-panel",
|
||||
projectSidebarItem: "kitchen-sink-project-link",
|
||||
projectTab: "kitchen-sink-project-tab",
|
||||
issueTab: "kitchen-sink-issue-tab",
|
||||
taskDetailView: "kitchen-sink-task-detail",
|
||||
toolbarButton: "kitchen-sink-toolbar-action",
|
||||
contextMenuItem: "kitchen-sink-context-action",
|
||||
commentAnnotation: "kitchen-sink-comment-annotation",
|
||||
commentContextMenuItem: "kitchen-sink-comment-action",
|
||||
} as const;
|
||||
|
||||
export const EXPORT_NAMES = {
|
||||
page: "KitchenSinkPage",
|
||||
settingsPage: "KitchenSinkSettingsPage",
|
||||
dashboardWidget: "KitchenSinkDashboardWidget",
|
||||
sidebar: "KitchenSinkSidebarLink",
|
||||
sidebarPanel: "KitchenSinkSidebarPanel",
|
||||
projectSidebarItem: "KitchenSinkProjectSidebarItem",
|
||||
projectTab: "KitchenSinkProjectTab",
|
||||
issueTab: "KitchenSinkIssueTab",
|
||||
taskDetailView: "KitchenSinkTaskDetailView",
|
||||
toolbarButton: "KitchenSinkToolbarButton",
|
||||
contextMenuItem: "KitchenSinkContextMenuItem",
|
||||
commentAnnotation: "KitchenSinkCommentAnnotation",
|
||||
commentContextMenuItem: "KitchenSinkCommentContextMenuItem",
|
||||
launcherModal: "KitchenSinkLauncherModal",
|
||||
} as const;
|
||||
|
||||
export const JOB_KEYS = {
|
||||
heartbeat: "demo-heartbeat",
|
||||
} as const;
|
||||
|
||||
export const WEBHOOK_KEYS = {
|
||||
demo: "demo-ingest",
|
||||
} as const;
|
||||
|
||||
export const TOOL_NAMES = {
|
||||
echo: "echo",
|
||||
companySummary: "company-summary",
|
||||
createIssue: "create-issue",
|
||||
} as const;
|
||||
|
||||
export const STREAM_CHANNELS = {
|
||||
progress: "progress",
|
||||
agentChat: "agent-chat",
|
||||
} as const;
|
||||
|
||||
export const SAFE_COMMANDS = [
|
||||
{
|
||||
key: "pwd",
|
||||
label: "Print workspace path",
|
||||
command: "pwd",
|
||||
args: [] as string[],
|
||||
description: "Prints the current workspace directory.",
|
||||
},
|
||||
{
|
||||
key: "ls",
|
||||
label: "List workspace files",
|
||||
command: "ls",
|
||||
args: ["-la"] as string[],
|
||||
description: "Lists files in the selected workspace.",
|
||||
},
|
||||
{
|
||||
key: "git-status",
|
||||
label: "Git status",
|
||||
command: "git",
|
||||
args: ["status", "--short", "--branch"] as string[],
|
||||
description: "Shows git status for the selected workspace.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type SafeCommandKey = (typeof SAFE_COMMANDS)[number]["key"];
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
showSidebarEntry: true,
|
||||
showSidebarPanel: true,
|
||||
showProjectSidebarItem: true,
|
||||
showCommentAnnotation: true,
|
||||
showCommentContextMenuItem: true,
|
||||
enableWorkspaceDemos: true,
|
||||
enableProcessDemos: false,
|
||||
secretRefExample: "",
|
||||
httpDemoUrl: "https://httpbin.org/anything",
|
||||
allowedCommands: SAFE_COMMANDS.map((command) => command.key),
|
||||
workspaceScratchFile: ".paperclip-kitchen-sink-demo.txt",
|
||||
} as const;
|
||||
|
||||
export const RUNTIME_LAUNCHER: PluginLauncherRegistration = {
|
||||
id: "kitchen-sink-runtime-launcher",
|
||||
displayName: "Kitchen Sink Modal",
|
||||
description: "Demonstrates runtime launcher registration from the worker.",
|
||||
placementZone: "toolbarButton",
|
||||
entityTypes: ["project", "issue"],
|
||||
action: {
|
||||
type: "openModal",
|
||||
target: EXPORT_NAMES.launcherModal,
|
||||
},
|
||||
render: {
|
||||
environment: "hostOverlay",
|
||||
bounds: "wide",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as manifest } from "./manifest.js";
|
||||
export { default as worker } from "./worker.js";
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
|
||||
import {
|
||||
DEFAULT_CONFIG,
|
||||
EXPORT_NAMES,
|
||||
JOB_KEYS,
|
||||
PAGE_ROUTE,
|
||||
PLUGIN_ID,
|
||||
PLUGIN_VERSION,
|
||||
SLOT_IDS,
|
||||
TOOL_NAMES,
|
||||
WEBHOOK_KEYS,
|
||||
} from "./constants.js";
|
||||
|
||||
const manifest: PaperclipPluginManifestV1 = {
|
||||
id: PLUGIN_ID,
|
||||
apiVersion: 1,
|
||||
version: PLUGIN_VERSION,
|
||||
displayName: "Kitchen Sink (Example)",
|
||||
description: "Reference plugin that demonstrates the current Paperclip plugin API surface, UI surfaces, bridge actions, events, jobs, webhooks, tools, local workspace access, and runtime diagnostics in one place.",
|
||||
author: "Paperclip",
|
||||
categories: ["ui", "automation", "workspace", "connector"],
|
||||
capabilities: [
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"project.workspaces.read",
|
||||
"issues.read",
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.read",
|
||||
"issue.comments.create",
|
||||
"agents.read",
|
||||
"agents.pause",
|
||||
"agents.resume",
|
||||
"agents.invoke",
|
||||
"agent.sessions.create",
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
"agent.sessions.close",
|
||||
"goals.read",
|
||||
"goals.create",
|
||||
"goals.update",
|
||||
"activity.log.write",
|
||||
"metrics.write",
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
"events.subscribe",
|
||||
"events.emit",
|
||||
"jobs.schedule",
|
||||
"webhooks.receive",
|
||||
"http.outbound",
|
||||
"secrets.read-ref",
|
||||
"agent.tools.register",
|
||||
"instance.settings.register",
|
||||
"ui.sidebar.register",
|
||||
"ui.page.register",
|
||||
"ui.detailTab.register",
|
||||
"ui.dashboardWidget.register",
|
||||
"ui.commentAnnotation.register",
|
||||
"ui.action.register",
|
||||
],
|
||||
entrypoints: {
|
||||
worker: "./dist/worker.js",
|
||||
ui: "./dist/ui",
|
||||
},
|
||||
instanceConfigSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
showSidebarEntry: {
|
||||
type: "boolean",
|
||||
title: "Show Sidebar Entry",
|
||||
default: DEFAULT_CONFIG.showSidebarEntry,
|
||||
},
|
||||
showSidebarPanel: {
|
||||
type: "boolean",
|
||||
title: "Show Sidebar Panel",
|
||||
default: DEFAULT_CONFIG.showSidebarPanel,
|
||||
},
|
||||
showProjectSidebarItem: {
|
||||
type: "boolean",
|
||||
title: "Show Project Sidebar Item",
|
||||
default: DEFAULT_CONFIG.showProjectSidebarItem,
|
||||
},
|
||||
showCommentAnnotation: {
|
||||
type: "boolean",
|
||||
title: "Show Comment Annotation",
|
||||
default: DEFAULT_CONFIG.showCommentAnnotation,
|
||||
},
|
||||
showCommentContextMenuItem: {
|
||||
type: "boolean",
|
||||
title: "Show Comment Action",
|
||||
default: DEFAULT_CONFIG.showCommentContextMenuItem,
|
||||
},
|
||||
enableWorkspaceDemos: {
|
||||
type: "boolean",
|
||||
title: "Enable Workspace Demos",
|
||||
default: DEFAULT_CONFIG.enableWorkspaceDemos,
|
||||
},
|
||||
enableProcessDemos: {
|
||||
type: "boolean",
|
||||
title: "Enable Process Demos",
|
||||
default: DEFAULT_CONFIG.enableProcessDemos,
|
||||
description: "Allows curated local child-process demos in project workspaces.",
|
||||
},
|
||||
secretRefExample: {
|
||||
type: "string",
|
||||
title: "Secret Reference Example",
|
||||
default: DEFAULT_CONFIG.secretRefExample,
|
||||
},
|
||||
httpDemoUrl: {
|
||||
type: "string",
|
||||
title: "HTTP Demo URL",
|
||||
default: DEFAULT_CONFIG.httpDemoUrl,
|
||||
},
|
||||
allowedCommands: {
|
||||
type: "array",
|
||||
title: "Allowed Process Commands",
|
||||
items: {
|
||||
type: "string",
|
||||
enum: DEFAULT_CONFIG.allowedCommands,
|
||||
},
|
||||
default: DEFAULT_CONFIG.allowedCommands,
|
||||
},
|
||||
workspaceScratchFile: {
|
||||
type: "string",
|
||||
title: "Workspace Scratch File",
|
||||
default: DEFAULT_CONFIG.workspaceScratchFile,
|
||||
},
|
||||
},
|
||||
},
|
||||
jobs: [
|
||||
{
|
||||
jobKey: JOB_KEYS.heartbeat,
|
||||
displayName: "Demo Heartbeat",
|
||||
description: "Periodic demo job that records plugin runtime activity.",
|
||||
schedule: "*/15 * * * *",
|
||||
},
|
||||
],
|
||||
webhooks: [
|
||||
{
|
||||
endpointKey: WEBHOOK_KEYS.demo,
|
||||
displayName: "Demo Ingest",
|
||||
description: "Accepts arbitrary webhook payloads and records the latest delivery in plugin state.",
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: TOOL_NAMES.echo,
|
||||
displayName: "Kitchen Sink Echo",
|
||||
description: "Returns the provided message and the current run context.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
},
|
||||
required: ["message"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.companySummary,
|
||||
displayName: "Kitchen Sink Company Summary",
|
||||
description: "Summarizes the current company using the Paperclip domain APIs.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_NAMES.createIssue,
|
||||
displayName: "Kitchen Sink Create Issue",
|
||||
description: "Creates an issue in the current project from an agent tool call.",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
],
|
||||
ui: {
|
||||
slots: [
|
||||
{
|
||||
type: "page",
|
||||
id: SLOT_IDS.page,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.page,
|
||||
routePath: PAGE_ROUTE,
|
||||
},
|
||||
{
|
||||
type: "settingsPage",
|
||||
id: SLOT_IDS.settingsPage,
|
||||
displayName: "Kitchen Sink Settings",
|
||||
exportName: EXPORT_NAMES.settingsPage,
|
||||
},
|
||||
{
|
||||
type: "dashboardWidget",
|
||||
id: SLOT_IDS.dashboardWidget,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.dashboardWidget,
|
||||
},
|
||||
{
|
||||
type: "sidebar",
|
||||
id: SLOT_IDS.sidebar,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.sidebar,
|
||||
},
|
||||
{
|
||||
type: "sidebarPanel",
|
||||
id: SLOT_IDS.sidebarPanel,
|
||||
displayName: "Kitchen Sink Panel",
|
||||
exportName: EXPORT_NAMES.sidebarPanel,
|
||||
},
|
||||
{
|
||||
type: "projectSidebarItem",
|
||||
id: SLOT_IDS.projectSidebarItem,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.projectSidebarItem,
|
||||
entityTypes: ["project"],
|
||||
},
|
||||
{
|
||||
type: "detailTab",
|
||||
id: SLOT_IDS.projectTab,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.projectTab,
|
||||
entityTypes: ["project"],
|
||||
},
|
||||
{
|
||||
type: "detailTab",
|
||||
id: SLOT_IDS.issueTab,
|
||||
displayName: "Kitchen Sink",
|
||||
exportName: EXPORT_NAMES.issueTab,
|
||||
entityTypes: ["issue"],
|
||||
},
|
||||
{
|
||||
type: "taskDetailView",
|
||||
id: SLOT_IDS.taskDetailView,
|
||||
displayName: "Kitchen Sink Task View",
|
||||
exportName: EXPORT_NAMES.taskDetailView,
|
||||
entityTypes: ["issue"],
|
||||
},
|
||||
{
|
||||
type: "toolbarButton",
|
||||
id: SLOT_IDS.toolbarButton,
|
||||
displayName: "Kitchen Sink Action",
|
||||
exportName: EXPORT_NAMES.toolbarButton,
|
||||
entityTypes: ["project", "issue"],
|
||||
},
|
||||
{
|
||||
type: "contextMenuItem",
|
||||
id: SLOT_IDS.contextMenuItem,
|
||||
displayName: "Kitchen Sink Context",
|
||||
exportName: EXPORT_NAMES.contextMenuItem,
|
||||
entityTypes: ["project", "issue"],
|
||||
},
|
||||
{
|
||||
type: "commentAnnotation",
|
||||
id: SLOT_IDS.commentAnnotation,
|
||||
displayName: "Kitchen Sink Comment Annotation",
|
||||
exportName: EXPORT_NAMES.commentAnnotation,
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
{
|
||||
type: "commentContextMenuItem",
|
||||
id: SLOT_IDS.commentContextMenuItem,
|
||||
displayName: "Kitchen Sink Comment Action",
|
||||
exportName: EXPORT_NAMES.commentContextMenuItem,
|
||||
entityTypes: ["comment"],
|
||||
},
|
||||
],
|
||||
launchers: [
|
||||
{
|
||||
id: "kitchen-sink-launcher",
|
||||
displayName: "Kitchen Sink Modal",
|
||||
placementZone: "toolbarButton",
|
||||
entityTypes: ["project", "issue"],
|
||||
action: {
|
||||
type: "openModal",
|
||||
target: EXPORT_NAMES.launcherModal,
|
||||
},
|
||||
render: {
|
||||
environment: "hostOverlay",
|
||||
bounds: "wide",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default manifest;
|
||||
@@ -0,0 +1,363 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
const CHARS = [" ", ".", "·", "▪", "▫", "○"] as const;
|
||||
const TARGET_FPS = 24;
|
||||
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
|
||||
|
||||
const PAPERCLIP_SPRITES = [
|
||||
[
|
||||
" ╭────╮ ",
|
||||
" ╭╯╭──╮│ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ │ ││ ",
|
||||
" │ ╰──╯│ ",
|
||||
" ╰─────╯ ",
|
||||
],
|
||||
[
|
||||
" ╭─────╮ ",
|
||||
" │╭──╮╰╮ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" ││ │ │ ",
|
||||
" │╰──╯ │ ",
|
||||
" ╰────╯ ",
|
||||
],
|
||||
] as const;
|
||||
|
||||
type PaperclipSprite = (typeof PAPERCLIP_SPRITES)[number];
|
||||
|
||||
interface Clip {
|
||||
x: number;
|
||||
y: number;
|
||||
vx: number;
|
||||
vy: number;
|
||||
life: number;
|
||||
maxLife: number;
|
||||
drift: number;
|
||||
sprite: PaperclipSprite;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
function measureChar(container: HTMLElement): { w: number; h: number } {
|
||||
const span = document.createElement("span");
|
||||
span.textContent = "M";
|
||||
span.style.cssText =
|
||||
"position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;";
|
||||
container.appendChild(span);
|
||||
const rect = span.getBoundingClientRect();
|
||||
container.removeChild(span);
|
||||
return { w: rect.width, h: rect.height };
|
||||
}
|
||||
|
||||
function spriteSize(sprite: PaperclipSprite): { width: number; height: number } {
|
||||
let width = 0;
|
||||
for (const row of sprite) width = Math.max(width, row.length);
|
||||
return { width, height: sprite.length };
|
||||
}
|
||||
|
||||
export function AsciiArtAnimation() {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preRef.current) return;
|
||||
const preEl: HTMLPreElement = preRef.current;
|
||||
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
let isVisible = document.visibilityState !== "hidden";
|
||||
let loopActive = false;
|
||||
let lastRenderAt = 0;
|
||||
let tick = 0;
|
||||
let cols = 0;
|
||||
let rows = 0;
|
||||
let charW = 7;
|
||||
let charH = 11;
|
||||
let trail = new Float32Array(0);
|
||||
let colWave = new Float32Array(0);
|
||||
let rowWave = new Float32Array(0);
|
||||
let clipMask = new Uint16Array(0);
|
||||
let clips: Clip[] = [];
|
||||
let lastOutput = "";
|
||||
|
||||
function toGlyph(value: number): string {
|
||||
const clamped = Math.max(0, Math.min(0.999, value));
|
||||
const idx = Math.floor(clamped * CHARS.length);
|
||||
return CHARS[idx] ?? " ";
|
||||
}
|
||||
|
||||
function rebuildGrid() {
|
||||
const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW)));
|
||||
const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH)));
|
||||
if (nextCols === cols && nextRows === rows) return;
|
||||
|
||||
cols = nextCols;
|
||||
rows = nextRows;
|
||||
const cellCount = cols * rows;
|
||||
trail = new Float32Array(cellCount);
|
||||
colWave = new Float32Array(cols);
|
||||
rowWave = new Float32Array(rows);
|
||||
clipMask = new Uint16Array(cellCount);
|
||||
clips = clips.filter((clip) => {
|
||||
return (
|
||||
clip.x > -clip.width - 2 &&
|
||||
clip.x < cols + 2 &&
|
||||
clip.y > -clip.height - 2 &&
|
||||
clip.y < rows + 2
|
||||
);
|
||||
});
|
||||
lastOutput = "";
|
||||
}
|
||||
|
||||
function drawStaticFrame() {
|
||||
if (cols <= 0 || rows <= 0) {
|
||||
preEl.textContent = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const ambient = (Math.sin(c * 0.11 + r * 0.04) + Math.cos(r * 0.08 - c * 0.02)) * 0.18 + 0.22;
|
||||
grid[r]![c] = toGlyph(ambient);
|
||||
}
|
||||
}
|
||||
|
||||
const gapX = 18;
|
||||
const gapY = 13;
|
||||
for (let baseRow = 1; baseRow < rows - 9; baseRow += gapY) {
|
||||
const startX = Math.floor(baseRow / gapY) % 2 === 0 ? 2 : 10;
|
||||
for (let baseCol = startX; baseCol < cols - 10; baseCol += gapX) {
|
||||
const sprite = PAPERCLIP_SPRITES[(baseCol + baseRow) % PAPERCLIP_SPRITES.length]!;
|
||||
for (let sr = 0; sr < sprite.length; sr++) {
|
||||
const line = sprite[sr]!;
|
||||
for (let sc = 0; sc < line.length; sc++) {
|
||||
const ch = line[sc] ?? " ";
|
||||
if (ch === " ") continue;
|
||||
const row = baseRow + sr;
|
||||
const col = baseCol + sc;
|
||||
if (row < 0 || row >= rows || col < 0 || col >= cols) continue;
|
||||
grid[row]![col] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const output = grid.map((line) => line.join("")).join("\n");
|
||||
preEl.textContent = output;
|
||||
lastOutput = output;
|
||||
}
|
||||
|
||||
function spawnClip() {
|
||||
const sprite = PAPERCLIP_SPRITES[Math.floor(Math.random() * PAPERCLIP_SPRITES.length)]!;
|
||||
const size = spriteSize(sprite);
|
||||
const edge = Math.random();
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vx = 0;
|
||||
let vy = 0;
|
||||
|
||||
if (edge < 0.68) {
|
||||
x = Math.random() < 0.5 ? -size.width - 1 : cols + 1;
|
||||
y = Math.random() * Math.max(1, rows - size.height);
|
||||
vx = x < 0 ? 0.04 + Math.random() * 0.05 : -(0.04 + Math.random() * 0.05);
|
||||
vy = (Math.random() - 0.5) * 0.014;
|
||||
} else {
|
||||
x = Math.random() * Math.max(1, cols - size.width);
|
||||
y = Math.random() < 0.5 ? -size.height - 1 : rows + 1;
|
||||
vx = (Math.random() - 0.5) * 0.014;
|
||||
vy = y < 0 ? 0.028 + Math.random() * 0.034 : -(0.028 + Math.random() * 0.034);
|
||||
}
|
||||
|
||||
clips.push({
|
||||
x,
|
||||
y,
|
||||
vx,
|
||||
vy,
|
||||
life: 0,
|
||||
maxLife: 260 + Math.random() * 220,
|
||||
drift: (Math.random() - 0.5) * 1.2,
|
||||
sprite,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
});
|
||||
}
|
||||
|
||||
function stampClip(clip: Clip, alpha: number) {
|
||||
const baseCol = Math.round(clip.x);
|
||||
const baseRow = Math.round(clip.y);
|
||||
for (let sr = 0; sr < clip.sprite.length; sr++) {
|
||||
const line = clip.sprite[sr]!;
|
||||
const row = baseRow + sr;
|
||||
if (row < 0 || row >= rows) continue;
|
||||
for (let sc = 0; sc < line.length; sc++) {
|
||||
const ch = line[sc] ?? " ";
|
||||
if (ch === " ") continue;
|
||||
const col = baseCol + sc;
|
||||
if (col < 0 || col >= cols) continue;
|
||||
const idx = row * cols + col;
|
||||
const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92;
|
||||
trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke);
|
||||
clipMask[idx] = ch.charCodeAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function step(time: number) {
|
||||
if (!loopActive) return;
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return;
|
||||
|
||||
const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667);
|
||||
lastRenderAt = time;
|
||||
tick += delta;
|
||||
|
||||
const cellCount = cols * rows;
|
||||
const targetCount = Math.max(3, Math.floor(cellCount / 2200));
|
||||
while (clips.length < targetCount) spawnClip();
|
||||
|
||||
for (let i = 0; i < trail.length; i++) trail[i] *= 0.92;
|
||||
clipMask.fill(0);
|
||||
|
||||
for (let i = clips.length - 1; i >= 0; i--) {
|
||||
const clip = clips[i]!;
|
||||
clip.life += delta;
|
||||
|
||||
const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018;
|
||||
const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014;
|
||||
clip.vx = (clip.vx + wobbleX) * 0.998;
|
||||
clip.vy = (clip.vy + wobbleY) * 0.998;
|
||||
|
||||
clip.x += clip.vx * delta;
|
||||
clip.y += clip.vy * delta;
|
||||
|
||||
if (
|
||||
clip.life >= clip.maxLife ||
|
||||
clip.x < -clip.width - 2 ||
|
||||
clip.x > cols + 2 ||
|
||||
clip.y < -clip.height - 2 ||
|
||||
clip.y > rows + 2
|
||||
) {
|
||||
clips.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const life = clip.life / clip.maxLife;
|
||||
const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1;
|
||||
stampClip(clip, alpha);
|
||||
}
|
||||
|
||||
for (let c = 0; c < cols; c++) colWave[c] = Math.sin(c * 0.08 + tick * 0.06);
|
||||
for (let r = 0; r < rows; r++) rowWave[r] = Math.cos(r * 0.1 - tick * 0.05);
|
||||
|
||||
let output = "";
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const idx = r * cols + c;
|
||||
const clipChar = clipMask[idx];
|
||||
if (clipChar > 0) {
|
||||
output += String.fromCharCode(clipChar);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ambient = 0.2 + colWave[c]! * 0.08 + rowWave[r]! * 0.06 + Math.sin((c + r) * 0.1 + tick * 0.035) * 0.05;
|
||||
output += toGlyph((trail[idx] ?? 0) + ambient);
|
||||
}
|
||||
if (r < rows - 1) output += "\n";
|
||||
}
|
||||
|
||||
if (output !== lastOutput) {
|
||||
preEl.textContent = output;
|
||||
lastOutput = output;
|
||||
}
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
const measured = measureChar(preEl);
|
||||
charW = measured.w || 7;
|
||||
charH = measured.h || 11;
|
||||
rebuildGrid();
|
||||
if (motionMedia.matches || !isVisible) {
|
||||
drawStaticFrame();
|
||||
}
|
||||
});
|
||||
|
||||
function startLoop() {
|
||||
if (loopActive) return;
|
||||
loopActive = true;
|
||||
lastRenderAt = 0;
|
||||
frameRef.current = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function stopLoop() {
|
||||
loopActive = false;
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function syncMode() {
|
||||
if (motionMedia.matches || !isVisible) {
|
||||
stopLoop();
|
||||
drawStaticFrame();
|
||||
} else {
|
||||
startLoop();
|
||||
}
|
||||
}
|
||||
|
||||
function handleVisibility() {
|
||||
isVisible = document.visibilityState !== "hidden";
|
||||
syncMode();
|
||||
}
|
||||
|
||||
const measured = measureChar(preEl);
|
||||
charW = measured.w || 7;
|
||||
charH = measured.h || 11;
|
||||
rebuildGrid();
|
||||
resizeObserver.observe(preEl);
|
||||
motionMedia.addEventListener("change", syncMode);
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
syncMode();
|
||||
|
||||
return () => {
|
||||
stopLoop();
|
||||
resizeObserver.disconnect();
|
||||
motionMedia.removeEventListener("change", syncMode);
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: "320px",
|
||||
minHeight: "320px",
|
||||
maxHeight: "350px",
|
||||
background: "#1d1d1d",
|
||||
color: "#f2efe6",
|
||||
overflow: "hidden",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid color-mix(in srgb, var(--border) 75%, transparent)",
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
ref={preRef}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
margin: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
padding: "14px",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, monospace",
|
||||
fontSize: "11px",
|
||||
lineHeight: 1,
|
||||
whiteSpace: "pre",
|
||||
userSelect: "none",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1041
packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts
Normal file
1041
packages/plugins/examples/plugin-kitchen-sink-example/src/worker.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
888
packages/plugins/sdk/README.md
Normal file
888
packages/plugins/sdk/README.md
Normal file
@@ -0,0 +1,888 @@
|
||||
# `@paperclipai/plugin-sdk`
|
||||
|
||||
Official TypeScript SDK for Paperclip plugin authors.
|
||||
|
||||
- **Worker SDK:** `@paperclipai/plugin-sdk` — `definePlugin`, context, lifecycle
|
||||
- **UI SDK:** `@paperclipai/plugin-sdk/ui` — React hooks and slot props
|
||||
- **Testing:** `@paperclipai/plugin-sdk/testing` — in-memory host harness
|
||||
- **Bundlers:** `@paperclipai/plugin-sdk/bundlers` — esbuild/rollup presets
|
||||
- **Dev server:** `@paperclipai/plugin-sdk/dev-server` — static UI server + SSE reload
|
||||
|
||||
Reference: `doc/plugins/PLUGIN_SPEC.md`
|
||||
|
||||
## Package surface
|
||||
|
||||
| Import | Purpose |
|
||||
|--------|--------|
|
||||
| `@paperclipai/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |
|
||||
| `@paperclipai/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types |
|
||||
| `@paperclipai/plugin-sdk/ui/hooks` | Hooks only |
|
||||
| `@paperclipai/plugin-sdk/ui/types` | UI types and slot prop interfaces |
|
||||
| `@paperclipai/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |
|
||||
| `@paperclipai/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |
|
||||
| `@paperclipai/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` |
|
||||
| `@paperclipai/plugin-sdk/protocol` | JSON-RPC protocol types and helpers (advanced) |
|
||||
| `@paperclipai/plugin-sdk/types` | Worker context and API types (advanced) |
|
||||
|
||||
## Manifest entrypoints
|
||||
|
||||
In your plugin manifest you declare:
|
||||
|
||||
- **`entrypoints.worker`** (required) — Path to the worker bundle (e.g. `dist/worker.js`). The host loads this and calls `setup(ctx)`.
|
||||
- **`entrypoints.ui`** (required if you use UI) — Path to the UI bundle directory. The host loads components from here for slots and launchers.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add @paperclipai/plugin-sdk
|
||||
```
|
||||
|
||||
## Current deployment caveats
|
||||
|
||||
The SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early.
|
||||
|
||||
- Plugin workers and plugin UI should both be treated as trusted code today.
|
||||
- Plugin UI bundles run as same-origin JavaScript inside the main Paperclip app. They can call ordinary Paperclip HTTP APIs with the board session, so manifest capabilities are not a frontend sandbox.
|
||||
- Local-path installs and the repo example plugins are development workflows. They assume the plugin source checkout exists on disk.
|
||||
- For deployed plugins, publish an npm package and install that package into the Paperclip instance at runtime.
|
||||
- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.
|
||||
- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.
|
||||
- The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS.
|
||||
- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.
|
||||
|
||||
If you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience.
|
||||
|
||||
## Worker quick start
|
||||
|
||||
```ts
|
||||
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
|
||||
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.events.on("issue.created", async (event) => {
|
||||
ctx.logger.info("Issue created", { issueId: event.entityId });
|
||||
});
|
||||
|
||||
ctx.data.register("health", async () => ({ status: "ok" }));
|
||||
ctx.actions.register("ping", async () => ({ pong: true }));
|
||||
|
||||
ctx.tools.register("calculator", {
|
||||
displayName: "Calculator",
|
||||
description: "Basic math",
|
||||
parametersSchema: {
|
||||
type: "object",
|
||||
properties: { a: { type: "number" }, b: { type: "number" } },
|
||||
required: ["a", "b"]
|
||||
}
|
||||
}, async (params) => {
|
||||
const { a, b } = params as { a: number; b: number };
|
||||
return { content: `Result: ${a + b}`, data: { result: a + b } };
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
runWorker(plugin, import.meta.url);
|
||||
```
|
||||
|
||||
**Note:** `runWorker(plugin, import.meta.url)` must be called so that when the host runs your worker (e.g. `node dist/worker.js`), the RPC host starts and the process stays alive. When the file is imported (e.g. for tests), the main-module check prevents the host from starting.
|
||||
|
||||
### Worker lifecycle and context
|
||||
|
||||
**Lifecycle (definePlugin):**
|
||||
|
||||
| Hook | Purpose |
|
||||
|------|--------|
|
||||
| `setup(ctx)` | **Required.** Called once at startup. Register event handlers, jobs, data/actions/tools, etc. |
|
||||
| `onHealth?()` | Optional. Return `{ status, message?, details? }` for health dashboard. |
|
||||
| `onConfigChanged?(newConfig)` | Optional. Apply new config without restart; if omitted, host restarts worker. |
|
||||
| `onShutdown?()` | Optional. Clean up before process exit (limited time window). |
|
||||
| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |
|
||||
| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |
|
||||
|
||||
**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.
|
||||
|
||||
**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.
|
||||
|
||||
**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`.
|
||||
|
||||
## Events
|
||||
|
||||
Subscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`).
|
||||
|
||||
**Core domain events (subscribe with `events.subscribe`):**
|
||||
|
||||
| Event | Typical entity |
|
||||
|-------|-----------------|
|
||||
| `company.created`, `company.updated` | company |
|
||||
| `project.created`, `project.updated` | project |
|
||||
| `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace |
|
||||
| `issue.created`, `issue.updated`, `issue.comment.created` | issue |
|
||||
| `agent.created`, `agent.updated`, `agent.status_changed` | agent |
|
||||
| `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run |
|
||||
| `goal.created`, `goal.updated` | goal |
|
||||
| `approval.created`, `approval.decided` | approval |
|
||||
| `cost_event.created` | cost |
|
||||
| `activity.logged` | activity |
|
||||
|
||||
**Plugin-to-plugin:** Subscribe to `plugin.<pluginId>.<eventName>` (e.g. `plugin.acme.linear.sync-done`). Emit with `ctx.events.emit("sync-done", companyId, payload)`; the host namespaces it automatically.
|
||||
|
||||
**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.
|
||||
|
||||
**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.
|
||||
|
||||
## Scheduled (recurring) jobs
|
||||
|
||||
Plugins can declare **scheduled jobs** that the host runs on a cron schedule. Use this for recurring tasks like syncs, digest reports, or cleanup.
|
||||
|
||||
1. **Capability:** Add `jobs.schedule` to `manifest.capabilities`.
|
||||
2. **Declare jobs** in `manifest.jobs`: each entry has `jobKey`, `displayName`, optional `description`, and `schedule` (a 5-field cron expression).
|
||||
3. **Register a handler** in `setup()` with `ctx.jobs.register(jobKey, async (job) => { ... })`.
|
||||
|
||||
**Cron format** (5 fields: minute, hour, day-of-month, month, day-of-week):
|
||||
|
||||
| Field | Values | Example |
|
||||
|-------------|----------|---------|
|
||||
| minute | 0–59 | `0`, `*/15` |
|
||||
| hour | 0–23 | `2`, `*` |
|
||||
| day of month | 1–31 | `1`, `*` |
|
||||
| month | 1–12 | `*` |
|
||||
| day of week | 0–6 (Sun=0) | `*`, `1-5` |
|
||||
|
||||
Examples: `"0 * * * *"` = every hour at minute 0; `"*/5 * * * *"` = every 5 minutes; `"0 2 * * *"` = daily at 2:00.
|
||||
|
||||
**Job handler context** (`PluginJobContext`):
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------------|----------|-------------|
|
||||
| `jobKey` | string | Matches the manifest declaration. |
|
||||
| `runId` | string | UUID for this run. |
|
||||
| `trigger` | `"schedule" \| "manual" \| "retry"` | What caused this run. |
|
||||
| `scheduledAt` | string | ISO 8601 time when the run was scheduled. |
|
||||
|
||||
Runs can be triggered by the **schedule**, **manually** from the UI/API, or as a **retry** (when an operator re-runs a job after a failure). Re-throw from the handler to mark the run as failed; the host records the failure. The host does not automatically retry—operators can trigger another run manually from the UI or API.
|
||||
|
||||
Example:
|
||||
|
||||
**Manifest** — include `jobs.schedule` and declare the job:
|
||||
|
||||
```ts
|
||||
// In your manifest (e.g. manifest.ts):
|
||||
const manifest = {
|
||||
// ...
|
||||
capabilities: ["jobs.schedule", "plugin.state.write"],
|
||||
jobs: [
|
||||
{
|
||||
jobKey: "heartbeat",
|
||||
displayName: "Heartbeat",
|
||||
description: "Runs every 5 minutes",
|
||||
schedule: "*/5 * * * *",
|
||||
},
|
||||
],
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
**Worker** — register the handler in `setup()`:
|
||||
|
||||
```ts
|
||||
ctx.jobs.register("heartbeat", async (job) => {
|
||||
ctx.logger.info("Heartbeat run", { runId: job.runId, trigger: job.trigger });
|
||||
await ctx.state.set({ scopeKind: "instance", stateKey: "last-heartbeat" }, new Date().toISOString());
|
||||
});
|
||||
```
|
||||
|
||||
## UI slots and launchers
|
||||
|
||||
Slots are mount points for plugin React components. Launchers are host-rendered entry points (buttons, menu items) that open plugin UI. Declare slots in `manifest.ui.slots` with `type`, `id`, `displayName`, `exportName`; for context-sensitive slots add `entityTypes`. Declare launchers in `manifest.ui.launchers` (or legacy `manifest.launchers`).
|
||||
|
||||
### Slot types / launcher placement zones
|
||||
|
||||
The same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy:
|
||||
|
||||
| Slot type / placement zone | Scope | Entity types (when context-sensitive) |
|
||||
|----------------------------|-------|---------------------------------------|
|
||||
| `page` | Global | — |
|
||||
| `sidebar` | Global | — |
|
||||
| `sidebarPanel` | Global | — |
|
||||
| `settingsPage` | Global | — |
|
||||
| `dashboardWidget` | Global | — |
|
||||
| `globalToolbarButton` | Global | — |
|
||||
| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |
|
||||
| `taskDetailView` | Entity | (task/issue context) |
|
||||
| `commentAnnotation` | Entity | `comment` |
|
||||
| `commentContextMenuItem` | Entity | `comment` |
|
||||
| `projectSidebarItem` | Entity | `project` |
|
||||
| `toolbarButton` | Entity | varies by host surface |
|
||||
| `contextMenuItem` | Entity | varies by host surface |
|
||||
|
||||
**Scope** describes whether the slot requires an entity to render. **Global** slots render without a specific entity but still receive the active `companyId` through `PluginHostContext` — use it to scope data fetches to the current company. **Entity** slots additionally require `entityId` and `entityType` (e.g. a detail tab on a specific issue).
|
||||
|
||||
**Entity types** (for `entityTypes` on slots): `project` \| `issue` \| `agent` \| `goal` \| `run` \| `comment`. Full list: import `PLUGIN_UI_SLOT_TYPES` and `PLUGIN_UI_SLOT_ENTITY_TYPES` from `@paperclipai/plugin-sdk`.
|
||||
|
||||
### Slot component descriptions
|
||||
|
||||
#### `page`
|
||||
|
||||
A full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plugins/:pluginId` (company-context route). Use this for rich, standalone plugin experiences such as dashboards, configuration wizards, or multi-step workflows. Receives `PluginPageProps` with `context.companyId` set to the active company. Requires the `ui.page.register` capability.
|
||||
|
||||
#### `sidebar`
|
||||
|
||||
Adds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability.
|
||||
|
||||
#### `sidebarPanel`
|
||||
|
||||
Renders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability.
|
||||
|
||||
#### `settingsPage`
|
||||
|
||||
Replaces the auto-generated JSON Schema settings form with a custom React component. Use this when the default form is insufficient — for example, when your plugin needs multi-step configuration, OAuth flows, "Test Connection" buttons, or rich input controls. Receives `PluginSettingsPageProps` with `context.companyId` set to the active company. The component is responsible for reading and writing config through the bridge (via `usePluginData` and `usePluginAction`).
|
||||
|
||||
#### `dashboardWidget`
|
||||
|
||||
A card or section rendered on the main dashboard. Use this for at-a-glance metrics, status indicators, or summary views that surface plugin data alongside core Paperclip information. Receives `PluginWidgetProps` with `context.companyId` set to the active company. Requires the `ui.dashboardWidget.register` capability.
|
||||
|
||||
#### `detailTab`
|
||||
|
||||
An additional tab on a project, issue, agent, goal, or run detail page. Rendered when the user navigates to that entity's detail view. Receives `PluginDetailTabProps` with `context.companyId` set to the active company and `context.entityId` / `context.entityType` guaranteed to be non-null, so you can immediately scope data fetches to the relevant entity. Specify which entity types the tab applies to via the `entityTypes` array in the manifest slot declaration. Requires the `ui.detailTab.register` capability.
|
||||
|
||||
#### `taskDetailView`
|
||||
|
||||
A specialized slot rendered in the context of a task or issue detail view. Similar to `detailTab` but designed for inline content within the task detail layout rather than a separate tab. Receives `context.companyId`, `context.entityId`, and `context.entityType` like `detailTab`. Requires the `ui.detailTab.register` capability.
|
||||
|
||||
#### `projectSidebarItem`
|
||||
|
||||
A link or small component rendered **once per project** under that project's row in the sidebar Projects list. Use this to add project-scoped navigation entries (e.g. "Files", "Linear Sync") that deep-link into a plugin detail tab: `/:company/projects/:projectRef?tab=plugin:<key>:<slotId>`. Receives `PluginProjectSidebarItemProps` with `context.companyId` set to the active company, `context.entityId` set to the project id, and `context.entityType` set to `"project"`. Use the optional `order` field in the manifest slot to control sort position. Requires the `ui.sidebar.register` capability.
|
||||
|
||||
#### `globalToolbarButton`
|
||||
|
||||
A button rendered in the global top bar (breadcrumb bar) that appears on every page. Use this for company-wide actions that are not scoped to a specific entity — for example, a universal search trigger, a global sync status indicator, or a floating action that applies across the whole workspace. Receives only `context.companyId` and `context.companyPrefix`; no entity context is available. Requires the `ui.action.register` capability.
|
||||
|
||||
#### `toolbarButton`
|
||||
|
||||
A button rendered in the toolbar of an entity page (e.g. project detail, issue detail). Use this for short-lived, contextual actions scoped to the current entity — like triggering a project sync, opening a picker, or running a quick command on that entity. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId`, `context.entityId`, and `context.entityType`; declare `entityTypes` in the manifest to control which entity pages the button appears on. Requires the `ui.action.register` capability.
|
||||
|
||||
#### `contextMenuItem`
|
||||
|
||||
An entry added to a right-click or overflow context menu on a host surface. Use this for secondary actions that apply to the entity under the cursor (e.g. "Copy to Linear", "Re-run analysis"). Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.
|
||||
|
||||
#### `commentAnnotation`
|
||||
|
||||
A per-comment annotation region rendered below each individual comment in the issue detail timeline. Use this to augment comments with parsed file links, sentiment badges, inline actions, or any per-comment metadata. Receives `PluginCommentAnnotationProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Requires the `ui.commentAnnotation.register` capability.
|
||||
|
||||
#### `commentContextMenuItem`
|
||||
|
||||
A per-comment context menu item rendered in the "more" dropdown menu (⋮) on each comment in the issue detail timeline. Use this to add per-comment actions such as "Create sub-issue from comment", "Translate", "Flag for review", or custom plugin actions. Receives `PluginCommentContextMenuItemProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `"comment"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Plugins can open drawers, modals, or popovers scoped to that comment. The ⋮ menu button only appears on comments where at least one plugin renders visible content. Requires the `ui.action.register` capability.
|
||||
|
||||
### Launcher actions and render options
|
||||
|
||||
| Launcher action | Description |
|
||||
|-----------------|-------------|
|
||||
| `navigate` | Navigate to a route (plugin or host). |
|
||||
| `openModal` | Open a modal. |
|
||||
| `openDrawer` | Open a drawer. |
|
||||
| `openPopover` | Open a popover. |
|
||||
| `performAction` | Run an action (e.g. call plugin). |
|
||||
| `deepLink` | Deep link to plugin or external URL. |
|
||||
|
||||
| Render option | Values | Description |
|
||||
|---------------|--------|-------------|
|
||||
| `environment` | `hostInline`, `hostOverlay`, `hostRoute`, `external`, `iframe` | Container the launcher expects after activation. |
|
||||
| `bounds` | `inline`, `compact`, `default`, `wide`, `full` | Size hint for overlays/drawers. |
|
||||
|
||||
### Capabilities
|
||||
|
||||
Declare in `manifest.capabilities`. Grouped by scope:
|
||||
|
||||
| Scope | Capability |
|
||||
|-------|------------|
|
||||
| **Company** | `companies.read` |
|
||||
| | `projects.read` |
|
||||
| | `project.workspaces.read` |
|
||||
| | `issues.read` |
|
||||
| | `issue.comments.read` |
|
||||
| | `agents.read` |
|
||||
| | `goals.read` |
|
||||
| | `goals.create` |
|
||||
| | `goals.update` |
|
||||
| | `activity.read` |
|
||||
| | `costs.read` |
|
||||
| | `issues.create` |
|
||||
| | `issues.update` |
|
||||
| | `issue.comments.create` |
|
||||
| | `activity.log.write` |
|
||||
| | `metrics.write` |
|
||||
| **Instance** | `instance.settings.register` |
|
||||
| | `plugin.state.read` |
|
||||
| | `plugin.state.write` |
|
||||
| **Runtime** | `events.subscribe` |
|
||||
| | `events.emit` |
|
||||
| | `jobs.schedule` |
|
||||
| | `webhooks.receive` |
|
||||
| | `http.outbound` |
|
||||
| | `secrets.read-ref` |
|
||||
| **Agent** | `agent.tools.register` |
|
||||
| | `agents.invoke` |
|
||||
| | `agent.sessions.create` |
|
||||
| | `agent.sessions.list` |
|
||||
| | `agent.sessions.send` |
|
||||
| | `agent.sessions.close` |
|
||||
| **UI** | `ui.sidebar.register` |
|
||||
| | `ui.page.register` |
|
||||
| | `ui.detailTab.register` |
|
||||
| | `ui.dashboardWidget.register` |
|
||||
| | `ui.commentAnnotation.register` |
|
||||
| | `ui.action.register` |
|
||||
|
||||
Full list in code: import `PLUGIN_CAPABILITIES` from `@paperclipai/plugin-sdk`.
|
||||
|
||||
## UI quick start
|
||||
|
||||
```tsx
|
||||
import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function DashboardWidget() {
|
||||
const { data } = usePluginData<{ status: string }>("health");
|
||||
const ping = usePluginAction("ping");
|
||||
return (
|
||||
<div style={{ display: "grid", gap: 8 }}>
|
||||
<strong>Health</strong>
|
||||
<div>{data?.status ?? "unknown"}</div>
|
||||
<button onClick={() => void ping()}>Ping</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Hooks reference
|
||||
|
||||
#### `usePluginData<T>(key, params?)`
|
||||
|
||||
Fetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.
|
||||
|
||||
```tsx
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
interface SyncStatus {
|
||||
lastSyncAt: string;
|
||||
syncedCount: number;
|
||||
healthy: boolean;
|
||||
}
|
||||
|
||||
export function SyncStatusWidget({ context }: PluginWidgetProps) {
|
||||
const { data, loading, error, refresh } = usePluginData<SyncStatus>("sync-status", {
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (error) return <div>Error: {error.message}</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Status: {data!.healthy ? "Healthy" : "Unhealthy"}</p>
|
||||
<p>Synced {data!.syncedCount} items</p>
|
||||
<p>Last sync: {data!.lastSyncAt}</p>
|
||||
<button onClick={refresh}>Refresh</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### `usePluginAction(key)`
|
||||
|
||||
Returns an async function that calls the worker's `performAction` handler. Throws `PluginBridgeError` on failure.
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { usePluginAction, type PluginBridgeError } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function ResyncButton({ context }: PluginWidgetProps) {
|
||||
const resync = usePluginAction("resync");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleClick() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await resync({ companyId: context.companyId });
|
||||
} catch (err) {
|
||||
setError((err as PluginBridgeError).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={handleClick} disabled={busy}>
|
||||
{busy ? "Syncing..." : "Resync Now"}
|
||||
</button>
|
||||
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### `useHostContext()`
|
||||
|
||||
Reads the active company, project, entity, and user context. Use this to scope data fetches and actions.
|
||||
|
||||
```tsx
|
||||
import { useHostContext, usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function IssueLinearLink({ context }: PluginDetailTabProps) {
|
||||
const { companyId, entityId, entityType } = context;
|
||||
const { data } = usePluginData<{ url: string }>("linear-link", {
|
||||
companyId,
|
||||
issueId: entityId,
|
||||
});
|
||||
|
||||
if (!data?.url) return <p>No linked Linear issue.</p>;
|
||||
return <a href={data.url} target="_blank" rel="noopener">View in Linear</a>;
|
||||
}
|
||||
```
|
||||
|
||||
#### `usePluginStream<T>(channel, options?)`
|
||||
|
||||
Subscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`.
|
||||
|
||||
```tsx
|
||||
import { usePluginStream } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
interface ChatToken {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function ChatMessages({ context }: PluginWidgetProps) {
|
||||
const { events, connected, close } = usePluginStream<ChatToken>("chat-stream", {
|
||||
companyId: context.companyId ?? undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{events.map((e, i) => <span key={i}>{e.text}</span>)}
|
||||
{connected && <span className="pulse" />}
|
||||
<button onClick={close}>Stop</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`. The host bridge manages the EventSource lifecycle; `close()` terminates the connection.
|
||||
|
||||
### UI authoring note
|
||||
|
||||
The current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package.
|
||||
|
||||
### Slot component props
|
||||
|
||||
Each slot type receives a typed props object with `context: PluginHostContext`. Import from `@paperclipai/plugin-sdk/ui`.
|
||||
|
||||
| Slot type | Props interface | `context` extras |
|
||||
|-----------|----------------|------------------|
|
||||
| `page` | `PluginPageProps` | — |
|
||||
| `sidebar` | `PluginSidebarProps` | — |
|
||||
| `settingsPage` | `PluginSettingsPageProps` | — |
|
||||
| `dashboardWidget` | `PluginWidgetProps` | — |
|
||||
| `globalToolbarButton` | `PluginGlobalToolbarButtonProps` | — |
|
||||
| `detailTab` | `PluginDetailTabProps` | `entityId: string`, `entityType: string` |
|
||||
| `toolbarButton` | `PluginToolbarButtonProps` | `entityId: string`, `entityType: string` |
|
||||
| `commentAnnotation` | `PluginCommentAnnotationProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
|
||||
| `commentContextMenuItem` | `PluginCommentContextMenuItemProps` | `entityId: string`, `entityType: "comment"`, `parentEntityId: string`, `projectId`, `companyPrefix` |
|
||||
| `projectSidebarItem` | `PluginProjectSidebarItemProps` | `entityId: string`, `entityType: "project"` |
|
||||
|
||||
Example detail tab with entity context:
|
||||
|
||||
```tsx
|
||||
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
|
||||
import { usePluginData } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function AgentMetricsTab({ context }: PluginDetailTabProps) {
|
||||
const { data, loading } = usePluginData<Record<string, string>>("agent-metrics", {
|
||||
agentId: context.entityId,
|
||||
companyId: context.companyId,
|
||||
});
|
||||
|
||||
if (loading) return <div>Loading…</div>;
|
||||
if (!data) return <p>No metrics available.</p>;
|
||||
|
||||
return (
|
||||
<dl>
|
||||
{Object.entries(data).map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Launcher surfaces and modals
|
||||
|
||||
V1 does not provide a dedicated `modal` slot. Plugins can either:
|
||||
|
||||
- declare concrete UI mount points in `ui.slots`
|
||||
- declare host-rendered entry points in `ui.launchers`
|
||||
|
||||
Supported launcher placement zones currently mirror the major host surfaces such as `projectSidebarItem`, `globalToolbarButton`, `toolbarButton`, `detailTab`, `settingsPage`, and `contextMenuItem`. Plugins may still open their own local modal from those entry points when needed.
|
||||
|
||||
Declarative launcher example:
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"launchers": [
|
||||
{
|
||||
"id": "sync-project",
|
||||
"displayName": "Sync",
|
||||
"placementZone": "toolbarButton",
|
||||
"entityTypes": ["project"],
|
||||
"action": {
|
||||
"type": "openDrawer",
|
||||
"target": "sync-project"
|
||||
},
|
||||
"render": {
|
||||
"environment": "hostOverlay",
|
||||
"bounds": "wide"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The host returns launcher metadata from `GET /api/plugins/ui-contributions` alongside slot declarations.
|
||||
|
||||
When a launcher opens a host-owned overlay or page, `useHostContext()`,
|
||||
`usePluginData()`, and `usePluginAction()` receive the current
|
||||
`renderEnvironment` through the bridge. Use that to tailor compact modal UI vs.
|
||||
full-page layouts without adding custom route parsing in the plugin.
|
||||
|
||||
## Project sidebar item
|
||||
|
||||
Plugins can add a link under each project in the sidebar via the `projectSidebarItem` slot. This is the recommended slot-based launcher pattern for project-scoped workflows because it can deep-link into a richer plugin tab. The component is rendered once per project with that project’s id in `context.entityId`. Declare the slot and capability in your manifest:
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"slots": [
|
||||
{
|
||||
"type": "projectSidebarItem",
|
||||
"id": "files",
|
||||
"displayName": "Files",
|
||||
"exportName": "FilesLink",
|
||||
"entityTypes": ["project"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": ["ui.sidebar.register", "ui.detailTab.register"]
|
||||
}
|
||||
```
|
||||
|
||||
Minimal React component that links to the project’s plugin tab (see project detail tabs in the spec):
|
||||
|
||||
```tsx
|
||||
import type { PluginProjectSidebarItemProps } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function FilesLink({ context }: PluginProjectSidebarItemProps) {
|
||||
const projectId = context.entityId;
|
||||
const prefix = context.companyPrefix ? `/${context.companyPrefix}` : "";
|
||||
const projectRef = projectId; // or resolve from host; entityId is project id
|
||||
return (
|
||||
<a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>
|
||||
Files
|
||||
</a>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Use optional `order` in the slot to sort among other project sidebar items. See §19.5.1 in the plugin spec and project detail plugin tabs (§19.3) for the full flow.
|
||||
|
||||
## Toolbar launcher with a local modal
|
||||
|
||||
Two toolbar slot types are available depending on where the button should appear:
|
||||
|
||||
- **`globalToolbarButton`** — renders in the top bar on every page, scoped to the company. No entity context. Use for workspace-wide actions.
|
||||
- **`toolbarButton`** — renders on entity detail pages (project, issue, etc.). Receives `entityId` and `entityType`. Declare `entityTypes` to control which pages the button appears on.
|
||||
|
||||
For short-lived actions, mount the appropriate slot type and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or entity.
|
||||
|
||||
Project-scoped example (appears only on project detail pages):
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"slots": [
|
||||
{
|
||||
"type": "toolbarButton",
|
||||
"id": "sync-toolbar-button",
|
||||
"displayName": "Sync",
|
||||
"exportName": "SyncToolbarButton",
|
||||
"entityTypes": ["project"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": ["ui.action.register"]
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import {
|
||||
useHostContext,
|
||||
usePluginAction,
|
||||
} from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
export function SyncToolbarButton() {
|
||||
const context = useHostContext();
|
||||
const syncProject = usePluginAction("sync-project");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
async function confirm() {
|
||||
if (!context.projectId) return;
|
||||
setSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
await syncProject({ projectId: context.projectId });
|
||||
setOpen(false);
|
||||
} catch (err) {
|
||||
setErrorMessage(err instanceof Error ? err.message : "Sync failed");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
Sync
|
||||
</button>
|
||||
{open ? (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
|
||||
onClick={() => !submitting && setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-lg bg-background p-4 shadow-xl"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-base font-semibold">Sync this project?</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Queue a sync for <code>{context.projectId}</code>.
|
||||
</p>
|
||||
{errorMessage ? (
|
||||
<p className="mt-2 text-sm text-destructive">{errorMessage}</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onClick={() => void confirm()} disabled={submitting}>
|
||||
{submitting ? "Running…" : "Run sync"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Prefer deep-linkable tabs and pages for primary workflows. Reserve plugin-owned modals for confirmations, pickers, and compact editors.
|
||||
|
||||
## Real-time streaming (`ctx.streams`)
|
||||
|
||||
Plugins can push real-time events from the worker to the UI using server-sent events (SSE). This is useful for streaming LLM tokens, live sync progress, or any push-based data.
|
||||
|
||||
### Worker side
|
||||
|
||||
In `setup()`, use `ctx.streams` to open a channel, emit events, and close when done:
|
||||
|
||||
```ts
|
||||
const plugin = definePlugin({
|
||||
async setup(ctx) {
|
||||
ctx.actions.register("chat", async (params) => {
|
||||
const companyId = params.companyId as string;
|
||||
ctx.streams.open("chat-stream", companyId);
|
||||
|
||||
for await (const token of streamFromLLM(params.prompt as string)) {
|
||||
ctx.streams.emit("chat-stream", { text: token });
|
||||
}
|
||||
|
||||
ctx.streams.close("chat-stream");
|
||||
return { ok: true };
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**API:**
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `ctx.streams.open(channel, companyId)` | Open a named stream channel and associate it with a company. Sends a `streams.open` notification to the host. |
|
||||
| `ctx.streams.emit(channel, event)` | Push an event to the channel. The `companyId` is automatically resolved from the prior `open()` call. |
|
||||
| `ctx.streams.close(channel)` | Close the channel and clear the company mapping. Sends a `streams.close` notification. |
|
||||
|
||||
Stream notifications are fire-and-forget JSON-RPC messages (no `id` field). They are sent via `notifyHost()` synchronously during handler execution.
|
||||
|
||||
### UI side
|
||||
|
||||
Use the `usePluginStream` hook (see [Hooks reference](#usepluginstreamtchannel-options) above) to subscribe to events from the UI.
|
||||
|
||||
### Host-side architecture
|
||||
|
||||
The host maintains an in-memory `PluginStreamBus` that fans out worker notifications to connected SSE clients:
|
||||
|
||||
1. Worker emits `streams.emit` notification via stdout
|
||||
2. Host (`plugin-worker-manager`) receives the notification and publishes to `PluginStreamBus`
|
||||
3. SSE endpoint (`GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`) subscribes to the bus and writes events to the response
|
||||
|
||||
The bus is keyed by `pluginId:channel:companyId`, so multiple UI clients can subscribe to the same stream independently.
|
||||
|
||||
### Streaming agent responses to the UI
|
||||
|
||||
`ctx.streams` and `ctx.agents.sessions` are complementary. The worker sits between them, relaying agent events to the browser in real time:
|
||||
|
||||
```
|
||||
UI ──usePluginAction──▶ Worker ──sessions.sendMessage──▶ Agent
|
||||
UI ◀──usePluginStream── Worker ◀──onEvent callback────── Agent
|
||||
```
|
||||
|
||||
The agent doesn't know about streams — the worker decides what to relay. Encode the agent ID in the channel name to scope streams per agent.
|
||||
|
||||
**Worker:**
|
||||
|
||||
```ts
|
||||
ctx.actions.register("ask-agent", async (params) => {
|
||||
const { agentId, companyId, prompt } = params as {
|
||||
agentId: string; companyId: string; prompt: string;
|
||||
};
|
||||
|
||||
const channel = `agent:${agentId}`;
|
||||
ctx.streams.open(channel, companyId);
|
||||
|
||||
const session = await ctx.agents.sessions.create(agentId, companyId);
|
||||
|
||||
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
|
||||
prompt,
|
||||
onEvent: (event) => {
|
||||
ctx.streams.emit(channel, {
|
||||
type: event.eventType, // "chunk" | "done" | "error"
|
||||
text: event.message ?? "",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
ctx.streams.close(channel);
|
||||
return { sessionId: session.sessionId };
|
||||
});
|
||||
```
|
||||
|
||||
**UI:**
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import { usePluginAction, usePluginStream } from "@paperclipai/plugin-sdk/ui";
|
||||
|
||||
interface AgentEvent {
|
||||
type: "chunk" | "done" | "error";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function AgentChat({ agentId, companyId }: { agentId: string; companyId: string }) {
|
||||
const askAgent = usePluginAction("ask-agent");
|
||||
const { events, connected, close } = usePluginStream<AgentEvent>(`agent:${agentId}`, { companyId });
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
||||
async function send() {
|
||||
setPrompt("");
|
||||
await askAgent({ agentId, companyId, prompt });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>{events.filter(e => e.type === "chunk").map((e, i) => <span key={i}>{e.text}</span>)}</div>
|
||||
<input value={prompt} onChange={(e) => setPrompt(e.target.value)} />
|
||||
<button onClick={send}>Send</button>
|
||||
{connected && <button onClick={close}>Stop</button>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Agent sessions (two-way chat)
|
||||
|
||||
Plugins can hold multi-turn conversational sessions with agents:
|
||||
|
||||
```ts
|
||||
// Create a session
|
||||
const session = await ctx.agents.sessions.create(agentId, companyId);
|
||||
|
||||
// Send a message and stream the response
|
||||
await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {
|
||||
prompt: "Help me triage this issue",
|
||||
onEvent: (event) => {
|
||||
if (event.eventType === "chunk") console.log(event.message);
|
||||
if (event.eventType === "done") console.log("Stream complete");
|
||||
},
|
||||
});
|
||||
|
||||
// List active sessions
|
||||
const sessions = await ctx.agents.sessions.list(agentId, companyId);
|
||||
|
||||
// Close when done
|
||||
await ctx.agents.sessions.close(session.sessionId, companyId);
|
||||
```
|
||||
|
||||
Requires capabilities: `agent.sessions.create`, `agent.sessions.list`, `agent.sessions.send`, `agent.sessions.close`.
|
||||
|
||||
Exported types: `AgentSession`, `AgentSessionEvent`, `AgentSessionSendResult`, `PluginAgentSessionsClient`.
|
||||
|
||||
## Testing utilities
|
||||
|
||||
```ts
|
||||
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
|
||||
import plugin from "../src/worker.js";
|
||||
import manifest from "../src/manifest.js";
|
||||
|
||||
const harness = createTestHarness({ manifest });
|
||||
await plugin.definition.setup(harness.ctx);
|
||||
await harness.emit("issue.created", { issueId: "iss_1" }, { entityId: "iss_1", entityType: "issue" });
|
||||
```
|
||||
|
||||
## Bundler presets
|
||||
|
||||
```ts
|
||||
import { createPluginBundlerPresets } from "@paperclipai/plugin-sdk/bundlers";
|
||||
|
||||
const presets = createPluginBundlerPresets({ uiEntry: "src/ui/index.tsx" });
|
||||
// presets.esbuild.worker / presets.esbuild.manifest / presets.esbuild.ui
|
||||
// presets.rollup.worker / presets.rollup.manifest / presets.rollup.ui
|
||||
```
|
||||
|
||||
## Local dev server (hot-reload events)
|
||||
|
||||
```bash
|
||||
paperclip-plugin-dev-server --root . --ui-dir dist/ui --port 4177
|
||||
```
|
||||
|
||||
Or programmatically:
|
||||
|
||||
```ts
|
||||
import { startPluginDevServer } from "@paperclipai/plugin-sdk/dev-server";
|
||||
const server = await startPluginDevServer({ rootDir: process.cwd() });
|
||||
```
|
||||
|
||||
Dev server endpoints:
|
||||
- `GET /__paperclip__/health` returns `{ ok, rootDir, uiDir }`
|
||||
- `GET /__paperclip__/events` streams `reload` SSE events on UI build changes
|
||||
116
packages/plugins/sdk/package.json
Normal file
116
packages/plugins/sdk/package.json
Normal file
@@ -0,0 +1,116 @@
|
||||
{
|
||||
"name": "@paperclipai/plugin-sdk",
|
||||
"version": "1.0.0",
|
||||
"description": "Stable public API for Paperclip plugins — worker-side context and UI bridge hooks",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./protocol": {
|
||||
"types": "./dist/protocol.d.ts",
|
||||
"import": "./dist/protocol.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/types.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./ui/hooks": {
|
||||
"types": "./dist/ui/hooks.d.ts",
|
||||
"import": "./dist/ui/hooks.js"
|
||||
},
|
||||
"./ui/types": {
|
||||
"types": "./dist/ui/types.d.ts",
|
||||
"import": "./dist/ui/types.js"
|
||||
},
|
||||
"./testing": {
|
||||
"types": "./dist/testing.d.ts",
|
||||
"import": "./dist/testing.js"
|
||||
},
|
||||
"./bundlers": {
|
||||
"types": "./dist/bundlers.d.ts",
|
||||
"import": "./dist/bundlers.js"
|
||||
},
|
||||
"./dev-server": {
|
||||
"types": "./dist/dev-server.d.ts",
|
||||
"import": "./dist/dev-server.js"
|
||||
}
|
||||
},
|
||||
"bin": {
|
||||
"paperclip-plugin-dev-server": "./dist/dev-cli.js"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./protocol": {
|
||||
"types": "./dist/protocol.d.ts",
|
||||
"import": "./dist/protocol.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"import": "./dist/types.js"
|
||||
},
|
||||
"./ui": {
|
||||
"types": "./dist/ui/index.d.ts",
|
||||
"import": "./dist/ui/index.js"
|
||||
},
|
||||
"./ui/hooks": {
|
||||
"types": "./dist/ui/hooks.d.ts",
|
||||
"import": "./dist/ui/hooks.js"
|
||||
},
|
||||
"./ui/types": {
|
||||
"types": "./dist/ui/types.d.ts",
|
||||
"import": "./dist/ui/types.js"
|
||||
},
|
||||
"./testing": {
|
||||
"types": "./dist/testing.d.ts",
|
||||
"import": "./dist/testing.js"
|
||||
},
|
||||
"./bundlers": {
|
||||
"types": "./dist/bundlers.d.ts",
|
||||
"import": "./dist/bundlers.js"
|
||||
},
|
||||
"./dev-server": {
|
||||
"types": "./dist/dev-server.d.ts",
|
||||
"import": "./dist/dev-server.js"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm --filter @paperclipai/shared build && tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "pnpm --filter @paperclipai/shared build && tsc --noEmit",
|
||||
"dev:server": "tsx src/dev-cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@paperclipai/shared": "workspace:*",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
160
packages/plugins/sdk/src/bundlers.ts
Normal file
160
packages/plugins/sdk/src/bundlers.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Bundling presets for Paperclip plugins.
|
||||
*
|
||||
* These helpers return plain config objects so plugin authors can use them
|
||||
* with esbuild or rollup without re-implementing host contract defaults.
|
||||
*/
|
||||
|
||||
export interface PluginBundlerPresetInput {
|
||||
pluginRoot?: string;
|
||||
manifestEntry?: string;
|
||||
workerEntry?: string;
|
||||
uiEntry?: string;
|
||||
outdir?: string;
|
||||
sourcemap?: boolean;
|
||||
minify?: boolean;
|
||||
}
|
||||
|
||||
export interface EsbuildLikeOptions {
|
||||
entryPoints: string[];
|
||||
outdir: string;
|
||||
bundle: boolean;
|
||||
format: "esm";
|
||||
platform: "node" | "browser";
|
||||
target: string;
|
||||
sourcemap?: boolean;
|
||||
minify?: boolean;
|
||||
external?: string[];
|
||||
}
|
||||
|
||||
export interface RollupLikeConfig {
|
||||
input: string;
|
||||
output: {
|
||||
dir: string;
|
||||
format: "es";
|
||||
sourcemap?: boolean;
|
||||
entryFileNames?: string;
|
||||
};
|
||||
external?: string[];
|
||||
plugins?: unknown[];
|
||||
}
|
||||
|
||||
export interface PluginBundlerPresets {
|
||||
esbuild: {
|
||||
worker: EsbuildLikeOptions;
|
||||
ui?: EsbuildLikeOptions;
|
||||
manifest: EsbuildLikeOptions;
|
||||
};
|
||||
rollup: {
|
||||
worker: RollupLikeConfig;
|
||||
ui?: RollupLikeConfig;
|
||||
manifest: RollupLikeConfig;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build esbuild/rollup baseline configs for plugin worker, manifest, and UI bundles.
|
||||
*
|
||||
* The presets intentionally externalize host/runtime deps (`react`, SDK packages)
|
||||
* to match the Paperclip plugin loader contract.
|
||||
*/
|
||||
export function createPluginBundlerPresets(input: PluginBundlerPresetInput = {}): PluginBundlerPresets {
|
||||
const uiExternal = [
|
||||
"@paperclipai/plugin-sdk/ui",
|
||||
"@paperclipai/plugin-sdk/ui/hooks",
|
||||
"react",
|
||||
"react-dom",
|
||||
"react/jsx-runtime",
|
||||
];
|
||||
|
||||
const outdir = input.outdir ?? "dist";
|
||||
const workerEntry = input.workerEntry ?? "src/worker.ts";
|
||||
const manifestEntry = input.manifestEntry ?? "src/manifest.ts";
|
||||
const uiEntry = input.uiEntry;
|
||||
const sourcemap = input.sourcemap ?? true;
|
||||
const minify = input.minify ?? false;
|
||||
|
||||
const esbuildWorker: EsbuildLikeOptions = {
|
||||
entryPoints: [workerEntry],
|
||||
outdir,
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
sourcemap,
|
||||
minify,
|
||||
external: ["react", "react-dom"],
|
||||
};
|
||||
|
||||
const esbuildManifest: EsbuildLikeOptions = {
|
||||
entryPoints: [manifestEntry],
|
||||
outdir,
|
||||
bundle: false,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
sourcemap,
|
||||
};
|
||||
|
||||
const esbuildUi = uiEntry
|
||||
? {
|
||||
entryPoints: [uiEntry],
|
||||
outdir: `${outdir}/ui`,
|
||||
bundle: true,
|
||||
format: "esm" as const,
|
||||
platform: "browser" as const,
|
||||
target: "es2022",
|
||||
sourcemap,
|
||||
minify,
|
||||
external: uiExternal,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const rollupWorker: RollupLikeConfig = {
|
||||
input: workerEntry,
|
||||
output: {
|
||||
dir: outdir,
|
||||
format: "es",
|
||||
sourcemap,
|
||||
entryFileNames: "worker.js",
|
||||
},
|
||||
external: ["react", "react-dom"],
|
||||
};
|
||||
|
||||
const rollupManifest: RollupLikeConfig = {
|
||||
input: manifestEntry,
|
||||
output: {
|
||||
dir: outdir,
|
||||
format: "es",
|
||||
sourcemap,
|
||||
entryFileNames: "manifest.js",
|
||||
},
|
||||
external: ["@paperclipai/plugin-sdk"],
|
||||
};
|
||||
|
||||
const rollupUi = uiEntry
|
||||
? {
|
||||
input: uiEntry,
|
||||
output: {
|
||||
dir: `${outdir}/ui`,
|
||||
format: "es" as const,
|
||||
sourcemap,
|
||||
entryFileNames: "index.js",
|
||||
},
|
||||
external: uiExternal,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
esbuild: {
|
||||
worker: esbuildWorker,
|
||||
manifest: esbuildManifest,
|
||||
...(esbuildUi ? { ui: esbuildUi } : {}),
|
||||
},
|
||||
rollup: {
|
||||
worker: rollupWorker,
|
||||
manifest: rollupManifest,
|
||||
...(rollupUi ? { ui: rollupUi } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
255
packages/plugins/sdk/src/define-plugin.ts
Normal file
255
packages/plugins/sdk/src/define-plugin.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* `definePlugin` — the top-level helper for authoring a Paperclip plugin.
|
||||
*
|
||||
* Plugin authors call `definePlugin()` and export the result as the default
|
||||
* export from their worker entrypoint. The host imports the worker module,
|
||||
* calls `setup()` with a `PluginContext`, and from that point the plugin
|
||||
* responds to events, jobs, webhooks, and UI requests through the context.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // dist/worker.ts
|
||||
* import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* export default definePlugin({
|
||||
* async setup(ctx) {
|
||||
* ctx.logger.info("Linear sync plugin starting");
|
||||
*
|
||||
* // Subscribe to events
|
||||
* ctx.events.on("issue.created", async (event) => {
|
||||
* const config = await ctx.config.get();
|
||||
* await ctx.http.fetch(`https://api.linear.app/...`, {
|
||||
* method: "POST",
|
||||
* headers: { Authorization: `Bearer ${await ctx.secrets.resolve(config.apiKeyRef as string)}` },
|
||||
* body: JSON.stringify({ title: event.payload.title }),
|
||||
* });
|
||||
* });
|
||||
*
|
||||
* // Register a job handler
|
||||
* ctx.jobs.register("full-sync", async (job) => {
|
||||
* ctx.logger.info("Running full-sync job", { runId: job.runId });
|
||||
* // ... sync logic
|
||||
* });
|
||||
*
|
||||
* // Register data for the UI
|
||||
* ctx.data.register("sync-health", async ({ companyId }) => {
|
||||
* const state = await ctx.state.get({
|
||||
* scopeKind: "company",
|
||||
* scopeId: String(companyId),
|
||||
* stateKey: "last-sync",
|
||||
* });
|
||||
* return { lastSync: state };
|
||||
* });
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { PluginContext } from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Optional plugin-reported diagnostics returned from the `health()` RPC method.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.2 — `health`
|
||||
*/
|
||||
export interface PluginHealthDiagnostics {
|
||||
/** Machine-readable status: `"ok"` | `"degraded"` | `"error"`. */
|
||||
status: "ok" | "degraded" | "error";
|
||||
/** Human-readable description of the current health state. */
|
||||
message?: string;
|
||||
/** Plugin-reported key-value diagnostics (e.g. connection status, queue depth). */
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Config validation result
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Result returned from the `validateConfig()` RPC method.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
|
||||
*/
|
||||
export interface PluginConfigValidationResult {
|
||||
/** Whether the config is valid. */
|
||||
ok: boolean;
|
||||
/** Non-fatal warnings about the config. */
|
||||
warnings?: string[];
|
||||
/** Validation errors (populated when `ok` is `false`). */
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Webhook handler input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Input received by the plugin worker's `handleWebhook` handler.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
|
||||
*/
|
||||
export interface PluginWebhookInput {
|
||||
/** Endpoint key matching the manifest declaration. */
|
||||
endpointKey: string;
|
||||
/** Inbound request headers. */
|
||||
headers: Record<string, string | string[]>;
|
||||
/** Raw request body as a UTF-8 string. */
|
||||
rawBody: string;
|
||||
/** Parsed JSON body (if applicable and parseable). */
|
||||
parsedBody?: unknown;
|
||||
/** Unique request identifier for idempotency checks. */
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The plugin definition shape passed to `definePlugin()`.
|
||||
*
|
||||
* The only required field is `setup`, which receives the `PluginContext` and
|
||||
* is where the plugin registers its handlers (events, jobs, data, actions,
|
||||
* tools, etc.).
|
||||
*
|
||||
* All other lifecycle hooks are optional. If a hook is not implemented the
|
||||
* host applies default behaviour (e.g. restarting the worker on config change
|
||||
* instead of calling `onConfigChanged`).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
|
||||
*/
|
||||
export interface PluginDefinition {
|
||||
/**
|
||||
* Called once when the plugin worker starts up, after `initialize` completes.
|
||||
*
|
||||
* This is where the plugin registers all its handlers: event subscriptions,
|
||||
* job handlers, data/action handlers, and tool registrations. Registration
|
||||
* must be synchronous after `setup` resolves — do not register handlers
|
||||
* inside async callbacks that may resolve after `setup` returns.
|
||||
*
|
||||
* @param ctx - The full plugin context provided by the host
|
||||
*/
|
||||
setup(ctx: PluginContext): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when the host wants to know if the plugin is healthy.
|
||||
*
|
||||
* The host polls this on a regular interval and surfaces the result in the
|
||||
* plugin health dashboard. If not implemented, the host infers health from
|
||||
* worker process liveness.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.2 — `health`
|
||||
*/
|
||||
onHealth?(): Promise<PluginHealthDiagnostics>;
|
||||
|
||||
/**
|
||||
* Called when the operator updates the plugin's instance configuration at
|
||||
* runtime, without restarting the worker.
|
||||
*
|
||||
* If not implemented, the host restarts the worker to apply the new config.
|
||||
*
|
||||
* @param newConfig - The newly resolved configuration
|
||||
* @see PLUGIN_SPEC.md §13.4 — `configChanged`
|
||||
*/
|
||||
onConfigChanged?(newConfig: Record<string, unknown>): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called when the host is about to shut down the plugin worker.
|
||||
*
|
||||
* The worker has at most 10 seconds (configurable via plugin config) to
|
||||
* finish in-flight work and resolve this promise. After the deadline the
|
||||
* host sends SIGTERM, then SIGKILL.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §12.5 — Graceful Shutdown Policy
|
||||
*/
|
||||
onShutdown?(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Called to validate the current plugin configuration.
|
||||
*
|
||||
* The host calls this:
|
||||
* - after the plugin starts (to surface config errors immediately)
|
||||
* - after the operator saves a new config (to validate before persisting)
|
||||
* - via the "Test Connection" button in the settings UI
|
||||
*
|
||||
* @param config - The configuration to validate
|
||||
* @see PLUGIN_SPEC.md §13.3 — `validateConfig`
|
||||
*/
|
||||
onValidateConfig?(config: Record<string, unknown>): Promise<PluginConfigValidationResult>;
|
||||
|
||||
/**
|
||||
* Called to handle an inbound webhook delivery.
|
||||
*
|
||||
* The host routes `POST /api/plugins/:pluginId/webhooks/:endpointKey` to
|
||||
* this handler. The plugin is responsible for signature verification using
|
||||
* a resolved secret ref.
|
||||
*
|
||||
* If not implemented but webhooks are declared in the manifest, the host
|
||||
* returns HTTP 501 for webhook deliveries.
|
||||
*
|
||||
* @param input - Webhook delivery metadata and payload
|
||||
* @see PLUGIN_SPEC.md §13.7 — `handleWebhook`
|
||||
*/
|
||||
onWebhook?(input: PluginWebhookInput): Promise<void>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PaperclipPlugin — the sealed object returned by definePlugin()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The sealed plugin object returned by `definePlugin()`.
|
||||
*
|
||||
* Plugin authors export this as the default export from their worker
|
||||
* entrypoint. The host imports it and calls the lifecycle methods.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14 — SDK Surface
|
||||
*/
|
||||
export interface PaperclipPlugin {
|
||||
/** The original plugin definition passed to `definePlugin()`. */
|
||||
readonly definition: PluginDefinition;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// definePlugin — top-level factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Define a Paperclip plugin.
|
||||
*
|
||||
* Call this function in your worker entrypoint and export the result as the
|
||||
* default export. The host will import the module and call lifecycle methods
|
||||
* on the returned object.
|
||||
*
|
||||
* @param definition - Plugin lifecycle handlers
|
||||
* @returns A sealed `PaperclipPlugin` object for the host to consume
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { definePlugin } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* export default definePlugin({
|
||||
* async setup(ctx) {
|
||||
* ctx.logger.info("Plugin started");
|
||||
* ctx.events.on("issue.created", async (event) => {
|
||||
* // handle event
|
||||
* });
|
||||
* },
|
||||
*
|
||||
* async onHealth() {
|
||||
* return { status: "ok" };
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||
*/
|
||||
export function definePlugin(definition: PluginDefinition): PaperclipPlugin {
|
||||
return Object.freeze({ definition });
|
||||
}
|
||||
54
packages/plugins/sdk/src/dev-cli.ts
Normal file
54
packages/plugins/sdk/src/dev-cli.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
import path from "node:path";
|
||||
import { startPluginDevServer } from "./dev-server.js";
|
||||
|
||||
function parseArg(flag: string): string | undefined {
|
||||
const index = process.argv.indexOf(flag);
|
||||
if (index < 0) return undefined;
|
||||
return process.argv[index + 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI entrypoint for the local plugin UI preview server.
|
||||
*
|
||||
* This is intentionally minimal and delegates all serving behavior to
|
||||
* `startPluginDevServer` so tests and programmatic usage share one path.
|
||||
*/
|
||||
async function main() {
|
||||
const rootDir = parseArg("--root") ?? process.cwd();
|
||||
const uiDir = parseArg("--ui-dir") ?? "dist/ui";
|
||||
const host = parseArg("--host") ?? "127.0.0.1";
|
||||
const rawPort = parseArg("--port") ?? "4177";
|
||||
const port = Number.parseInt(rawPort, 10);
|
||||
if (!Number.isFinite(port) || port <= 0 || port > 65535) {
|
||||
throw new Error(`Invalid --port value: ${rawPort}`);
|
||||
}
|
||||
|
||||
const server = await startPluginDevServer({
|
||||
rootDir: path.resolve(rootDir),
|
||||
uiDir,
|
||||
host,
|
||||
port,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Paperclip plugin dev server listening at ${server.url}`);
|
||||
|
||||
const shutdown = async () => {
|
||||
await server.close();
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
void shutdown();
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
void shutdown();
|
||||
});
|
||||
}
|
||||
|
||||
void main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
228
packages/plugins/sdk/src/dev-server.ts
Normal file
228
packages/plugins/sdk/src/dev-server.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { createReadStream, existsSync, statSync, watch } from "node:fs";
|
||||
import { mkdir, readdir, stat } from "node:fs/promises";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
export interface PluginDevServerOptions {
|
||||
/** Plugin project root. Defaults to `process.cwd()`. */
|
||||
rootDir?: string;
|
||||
/** Relative path from root to built UI assets. Defaults to `dist/ui`. */
|
||||
uiDir?: string;
|
||||
/** Bind port for local preview server. Defaults to `4177`. */
|
||||
port?: number;
|
||||
/** Bind host. Defaults to `127.0.0.1`. */
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export interface PluginDevServer {
|
||||
url: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface Closeable {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
function contentType(filePath: string): string {
|
||||
if (filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
|
||||
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
|
||||
if (filePath.endsWith(".json")) return "application/json; charset=utf-8";
|
||||
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
|
||||
if (filePath.endsWith(".svg")) return "image/svg+xml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function normalizeFilePath(baseDir: string, reqPath: string): string {
|
||||
const pathname = reqPath.split("?")[0] || "/";
|
||||
const resolved = pathname === "/" ? "/index.js" : pathname;
|
||||
const absolute = path.resolve(baseDir, `.${resolved}`);
|
||||
const normalizedBase = `${path.resolve(baseDir)}${path.sep}`;
|
||||
if (!absolute.startsWith(normalizedBase) && absolute !== path.resolve(baseDir)) {
|
||||
throw new Error("path traversal blocked");
|
||||
}
|
||||
return absolute;
|
||||
}
|
||||
|
||||
function send404(res: ServerResponse) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify({ error: "Not found" }));
|
||||
}
|
||||
|
||||
function sendJson(res: ServerResponse, value: unknown) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
async function ensureUiDir(uiDir: string): Promise<void> {
|
||||
if (existsSync(uiDir)) return;
|
||||
await mkdir(uiDir, { recursive: true });
|
||||
}
|
||||
|
||||
async function listFilesRecursive(dir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const abs = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
out.push(...await listFilesRecursive(abs));
|
||||
} else if (entry.isFile()) {
|
||||
out.push(abs);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function snapshotSignature(rows: Array<{ file: string; mtimeMs: number }>): string {
|
||||
return rows.map((row) => `${row.file}:${Math.trunc(row.mtimeMs)}`).join("|");
|
||||
}
|
||||
|
||||
async function startUiWatcher(uiDir: string, onReload: (filePath: string) => void): Promise<Closeable> {
|
||||
try {
|
||||
// macOS/Windows support recursive native watching.
|
||||
const watcher = watch(uiDir, { recursive: true }, (_eventType, filename) => {
|
||||
if (!filename) return;
|
||||
onReload(path.join(uiDir, filename));
|
||||
});
|
||||
return watcher;
|
||||
} catch {
|
||||
// Linux may reject recursive watch. Fall back to polling snapshots.
|
||||
let previous = snapshotSignature(
|
||||
(await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir))).map((row) => ({
|
||||
file: row.file,
|
||||
mtimeMs: row.mtimeMs,
|
||||
})),
|
||||
);
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const nextRows = await getUiBuildSnapshot(path.dirname(uiDir), path.basename(uiDir));
|
||||
const next = snapshotSignature(nextRows);
|
||||
if (next === previous) return;
|
||||
previous = next;
|
||||
onReload("__snapshot__");
|
||||
} catch {
|
||||
// Ignore transient read errors while bundlers are writing files.
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return {
|
||||
close() {
|
||||
clearInterval(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local static server for plugin UI assets with SSE reload events.
|
||||
*
|
||||
* Endpoint summary:
|
||||
* - `GET /__paperclip__/health` for diagnostics
|
||||
* - `GET /__paperclip__/events` for hot-reload stream
|
||||
* - Any other path serves files from the configured UI build directory
|
||||
*/
|
||||
export async function startPluginDevServer(options: PluginDevServerOptions = {}): Promise<PluginDevServer> {
|
||||
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
||||
const uiDir = path.resolve(rootDir, options.uiDir ?? "dist/ui");
|
||||
const host = options.host ?? "127.0.0.1";
|
||||
const port = options.port ?? 4177;
|
||||
|
||||
await ensureUiDir(uiDir);
|
||||
|
||||
const sseClients = new Set<ServerResponse>();
|
||||
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = req.url ?? "/";
|
||||
|
||||
if (url === "/__paperclip__/health") {
|
||||
sendJson(res, { ok: true, rootDir, uiDir });
|
||||
return;
|
||||
}
|
||||
|
||||
if (url === "/__paperclip__/events") {
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
});
|
||||
res.write(`event: connected\ndata: {"ok":true}\n\n`);
|
||||
sseClients.add(res);
|
||||
req.on("close", () => {
|
||||
sseClients.delete(res);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const filePath = normalizeFilePath(uiDir, url);
|
||||
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
||||
send404(res);
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", contentType(filePath));
|
||||
createReadStream(filePath).pipe(res);
|
||||
} catch {
|
||||
send404(res);
|
||||
}
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
void handleRequest(req, res);
|
||||
});
|
||||
|
||||
const notifyReload = (filePath: string) => {
|
||||
const rel = path.relative(uiDir, filePath);
|
||||
const payload = JSON.stringify({ type: "reload", file: rel, at: new Date().toISOString() });
|
||||
for (const client of sseClients) {
|
||||
client.write(`event: reload\ndata: ${payload}\n\n`);
|
||||
}
|
||||
};
|
||||
|
||||
const watcher = await startUiWatcher(uiDir, notifyReload);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, host, () => resolve());
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
const actualPort = address && typeof address === "object" ? (address as AddressInfo).port : port;
|
||||
|
||||
return {
|
||||
url: `http://${host}:${actualPort}`,
|
||||
async close() {
|
||||
watcher.close();
|
||||
for (const client of sseClients) {
|
||||
client.end();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a stable file+mtime snapshot for a built plugin UI directory.
|
||||
*
|
||||
* Used by the polling watcher fallback and useful for tests that need to assert
|
||||
* whether a UI build has changed between runs.
|
||||
*/
|
||||
export async function getUiBuildSnapshot(rootDir: string, uiDir = "dist/ui"): Promise<Array<{ file: string; mtimeMs: number }>> {
|
||||
const baseDir = path.resolve(rootDir, uiDir);
|
||||
if (!existsSync(baseDir)) return [];
|
||||
const files = await listFilesRecursive(baseDir);
|
||||
const rows = await Promise.all(files.map(async (filePath) => {
|
||||
const fileStat = await stat(filePath);
|
||||
return {
|
||||
file: path.relative(baseDir, filePath),
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
};
|
||||
}));
|
||||
return rows.sort((a, b) => a.file.localeCompare(b.file));
|
||||
}
|
||||
545
packages/plugins/sdk/src/host-client-factory.ts
Normal file
545
packages/plugins/sdk/src/host-client-factory.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* Host-side client factory — creates capability-gated handler maps for
|
||||
* servicing worker→host JSON-RPC calls.
|
||||
*
|
||||
* When a plugin worker calls `ctx.state.get(...)` inside its process, the
|
||||
* SDK serializes the call as a JSON-RPC request over stdio. On the host side,
|
||||
* the `PluginWorkerManager` receives the request and dispatches it to the
|
||||
* handler registered for that method. This module provides a factory that
|
||||
* creates those handlers for all `WorkerToHostMethods`, with automatic
|
||||
* capability enforcement.
|
||||
*
|
||||
* ## Design
|
||||
*
|
||||
* 1. **Capability gating**: Each handler checks the plugin's declared
|
||||
* capabilities before executing. If the plugin lacks a required capability,
|
||||
* the handler throws a `CapabilityDeniedError` (which the worker manager
|
||||
* translates into a JSON-RPC error response with code
|
||||
* `CAPABILITY_DENIED`).
|
||||
*
|
||||
* 2. **Service adapters**: The caller provides a `HostServices` object with
|
||||
* concrete implementations of each platform service. The factory wires
|
||||
* each handler to the appropriate service method.
|
||||
*
|
||||
* 3. **Type safety**: The returned handler map is typed as
|
||||
* `WorkerToHostHandlers` (from `plugin-worker-manager.ts`) so it plugs
|
||||
* directly into `WorkerStartOptions.hostHandlers`.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const handlers = createHostClientHandlers({
|
||||
* pluginId: "acme.linear",
|
||||
* capabilities: manifest.capabilities,
|
||||
* services: {
|
||||
* config: { get: () => registry.getConfig(pluginId) },
|
||||
* state: { get: ..., set: ..., delete: ... },
|
||||
* entities: { upsert: ..., list: ... },
|
||||
* // ... all services
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* await workerManager.startWorker("acme.linear", {
|
||||
* // ...
|
||||
* hostHandlers: handlers,
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13 — Host-Worker Protocol
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
|
||||
import type { PluginCapability } from "@paperclipai/shared";
|
||||
import type { WorkerToHostMethods, WorkerToHostMethodName } from "./protocol.js";
|
||||
import { PLUGIN_RPC_ERROR_CODES } from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Error types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Thrown when a plugin calls a host method it does not have the capability for.
|
||||
*
|
||||
* The `code` field is set to `PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED` so
|
||||
* the worker manager can propagate it as the correct JSON-RPC error code.
|
||||
*/
|
||||
export class CapabilityDeniedError extends Error {
|
||||
override readonly name = "CapabilityDeniedError";
|
||||
readonly code = PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED;
|
||||
|
||||
constructor(pluginId: string, method: string, capability: PluginCapability) {
|
||||
super(
|
||||
`Plugin "${pluginId}" is missing required capability "${capability}" for method "${method}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host service interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Service adapters that the host must provide. Each property maps to a group
|
||||
* of `WorkerToHostMethods`. The factory wires JSON-RPC params to these
|
||||
* function signatures.
|
||||
*
|
||||
* All methods return promises to support async I/O (database, HTTP, etc.).
|
||||
*/
|
||||
export interface HostServices {
|
||||
/** Provides `config.get`. */
|
||||
config: {
|
||||
get(): Promise<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
/** Provides `state.get`, `state.set`, `state.delete`. */
|
||||
state: {
|
||||
get(params: WorkerToHostMethods["state.get"][0]): Promise<WorkerToHostMethods["state.get"][1]>;
|
||||
set(params: WorkerToHostMethods["state.set"][0]): Promise<void>;
|
||||
delete(params: WorkerToHostMethods["state.delete"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `entities.upsert`, `entities.list`. */
|
||||
entities: {
|
||||
upsert(params: WorkerToHostMethods["entities.upsert"][0]): Promise<WorkerToHostMethods["entities.upsert"][1]>;
|
||||
list(params: WorkerToHostMethods["entities.list"][0]): Promise<WorkerToHostMethods["entities.list"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `events.emit`. */
|
||||
events: {
|
||||
emit(params: WorkerToHostMethods["events.emit"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `http.fetch`. */
|
||||
http: {
|
||||
fetch(params: WorkerToHostMethods["http.fetch"][0]): Promise<WorkerToHostMethods["http.fetch"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `secrets.resolve`. */
|
||||
secrets: {
|
||||
resolve(params: WorkerToHostMethods["secrets.resolve"][0]): Promise<string>;
|
||||
};
|
||||
|
||||
/** Provides `activity.log`. */
|
||||
activity: {
|
||||
log(params: {
|
||||
companyId: string;
|
||||
message: string;
|
||||
entityType?: string;
|
||||
entityId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `metrics.write`. */
|
||||
metrics: {
|
||||
write(params: WorkerToHostMethods["metrics.write"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `log`. */
|
||||
logger: {
|
||||
log(params: WorkerToHostMethods["log"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `companies.list`, `companies.get`. */
|
||||
companies: {
|
||||
list(params: WorkerToHostMethods["companies.list"][0]): Promise<WorkerToHostMethods["companies.list"][1]>;
|
||||
get(params: WorkerToHostMethods["companies.get"][0]): Promise<WorkerToHostMethods["companies.get"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `projects.list`, `projects.get`, `projects.listWorkspaces`, `projects.getPrimaryWorkspace`, `projects.getWorkspaceForIssue`. */
|
||||
projects: {
|
||||
list(params: WorkerToHostMethods["projects.list"][0]): Promise<WorkerToHostMethods["projects.list"][1]>;
|
||||
get(params: WorkerToHostMethods["projects.get"][0]): Promise<WorkerToHostMethods["projects.get"][1]>;
|
||||
listWorkspaces(params: WorkerToHostMethods["projects.listWorkspaces"][0]): Promise<WorkerToHostMethods["projects.listWorkspaces"][1]>;
|
||||
getPrimaryWorkspace(params: WorkerToHostMethods["projects.getPrimaryWorkspace"][0]): Promise<WorkerToHostMethods["projects.getPrimaryWorkspace"][1]>;
|
||||
getWorkspaceForIssue(params: WorkerToHostMethods["projects.getWorkspaceForIssue"][0]): Promise<WorkerToHostMethods["projects.getWorkspaceForIssue"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `issues.list`, `issues.get`, `issues.create`, `issues.update`, `issues.listComments`, `issues.createComment`. */
|
||||
issues: {
|
||||
list(params: WorkerToHostMethods["issues.list"][0]): Promise<WorkerToHostMethods["issues.list"][1]>;
|
||||
get(params: WorkerToHostMethods["issues.get"][0]): Promise<WorkerToHostMethods["issues.get"][1]>;
|
||||
create(params: WorkerToHostMethods["issues.create"][0]): Promise<WorkerToHostMethods["issues.create"][1]>;
|
||||
update(params: WorkerToHostMethods["issues.update"][0]): Promise<WorkerToHostMethods["issues.update"][1]>;
|
||||
listComments(params: WorkerToHostMethods["issues.listComments"][0]): Promise<WorkerToHostMethods["issues.listComments"][1]>;
|
||||
createComment(params: WorkerToHostMethods["issues.createComment"][0]): Promise<WorkerToHostMethods["issues.createComment"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `agents.list`, `agents.get`, `agents.pause`, `agents.resume`, `agents.invoke`. */
|
||||
agents: {
|
||||
list(params: WorkerToHostMethods["agents.list"][0]): Promise<WorkerToHostMethods["agents.list"][1]>;
|
||||
get(params: WorkerToHostMethods["agents.get"][0]): Promise<WorkerToHostMethods["agents.get"][1]>;
|
||||
pause(params: WorkerToHostMethods["agents.pause"][0]): Promise<WorkerToHostMethods["agents.pause"][1]>;
|
||||
resume(params: WorkerToHostMethods["agents.resume"][0]): Promise<WorkerToHostMethods["agents.resume"][1]>;
|
||||
invoke(params: WorkerToHostMethods["agents.invoke"][0]): Promise<WorkerToHostMethods["agents.invoke"][1]>;
|
||||
};
|
||||
|
||||
/** Provides `agents.sessions.create`, `agents.sessions.list`, `agents.sessions.sendMessage`, `agents.sessions.close`. */
|
||||
agentSessions: {
|
||||
create(params: WorkerToHostMethods["agents.sessions.create"][0]): Promise<WorkerToHostMethods["agents.sessions.create"][1]>;
|
||||
list(params: WorkerToHostMethods["agents.sessions.list"][0]): Promise<WorkerToHostMethods["agents.sessions.list"][1]>;
|
||||
sendMessage(params: WorkerToHostMethods["agents.sessions.sendMessage"][0]): Promise<WorkerToHostMethods["agents.sessions.sendMessage"][1]>;
|
||||
close(params: WorkerToHostMethods["agents.sessions.close"][0]): Promise<void>;
|
||||
};
|
||||
|
||||
/** Provides `goals.list`, `goals.get`, `goals.create`, `goals.update`. */
|
||||
goals: {
|
||||
list(params: WorkerToHostMethods["goals.list"][0]): Promise<WorkerToHostMethods["goals.list"][1]>;
|
||||
get(params: WorkerToHostMethods["goals.get"][0]): Promise<WorkerToHostMethods["goals.get"][1]>;
|
||||
create(params: WorkerToHostMethods["goals.create"][0]): Promise<WorkerToHostMethods["goals.create"][1]>;
|
||||
update(params: WorkerToHostMethods["goals.update"][0]): Promise<WorkerToHostMethods["goals.update"][1]>;
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Options for `createHostClientHandlers`.
|
||||
*/
|
||||
export interface HostClientFactoryOptions {
|
||||
/** The plugin ID. Used for error messages and logging. */
|
||||
pluginId: string;
|
||||
|
||||
/**
|
||||
* The capabilities declared by the plugin in its manifest. The factory
|
||||
* enforces these at runtime before delegating to the service adapter.
|
||||
*/
|
||||
capabilities: readonly PluginCapability[];
|
||||
|
||||
/**
|
||||
* Concrete implementations of host platform services. Each handler in the
|
||||
* returned map delegates to the corresponding service method.
|
||||
*/
|
||||
services: HostServices;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Handler map type (compatible with WorkerToHostHandlers from worker manager)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A handler function for a specific worker→host method.
|
||||
*/
|
||||
type HostHandler<M extends WorkerToHostMethodName> = (
|
||||
params: WorkerToHostMethods[M][0],
|
||||
) => Promise<WorkerToHostMethods[M][1]>;
|
||||
|
||||
/**
|
||||
* A complete map of all worker→host method handlers.
|
||||
*
|
||||
* This type matches `WorkerToHostHandlers` from `plugin-worker-manager.ts`
|
||||
* but makes every handler required (the factory always provides all handlers).
|
||||
*/
|
||||
export type HostClientHandlers = {
|
||||
[M in WorkerToHostMethodName]: HostHandler<M>;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Capability → method mapping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Maps each worker→host RPC method to the capability required to invoke it.
|
||||
* Methods without a capability requirement (e.g. `config.get`, `log`) are
|
||||
* mapped to `null`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | null> = {
|
||||
// Config — always allowed
|
||||
"config.get": null,
|
||||
|
||||
// State
|
||||
"state.get": "plugin.state.read",
|
||||
"state.set": "plugin.state.write",
|
||||
"state.delete": "plugin.state.write",
|
||||
|
||||
// Entities — no specific capability required (plugin-scoped by design)
|
||||
"entities.upsert": null,
|
||||
"entities.list": null,
|
||||
|
||||
// Events
|
||||
"events.emit": "events.emit",
|
||||
|
||||
// HTTP
|
||||
"http.fetch": "http.outbound",
|
||||
|
||||
// Secrets
|
||||
"secrets.resolve": "secrets.read-ref",
|
||||
|
||||
// Activity
|
||||
"activity.log": "activity.log.write",
|
||||
|
||||
// Metrics
|
||||
"metrics.write": "metrics.write",
|
||||
|
||||
// Logger — always allowed
|
||||
"log": null,
|
||||
|
||||
// Companies
|
||||
"companies.list": "companies.read",
|
||||
"companies.get": "companies.read",
|
||||
|
||||
// Projects
|
||||
"projects.list": "projects.read",
|
||||
"projects.get": "projects.read",
|
||||
"projects.listWorkspaces": "project.workspaces.read",
|
||||
"projects.getPrimaryWorkspace": "project.workspaces.read",
|
||||
"projects.getWorkspaceForIssue": "project.workspaces.read",
|
||||
|
||||
// Issues
|
||||
"issues.list": "issues.read",
|
||||
"issues.get": "issues.read",
|
||||
"issues.create": "issues.create",
|
||||
"issues.update": "issues.update",
|
||||
"issues.listComments": "issue.comments.read",
|
||||
"issues.createComment": "issue.comments.create",
|
||||
|
||||
// Agents
|
||||
"agents.list": "agents.read",
|
||||
"agents.get": "agents.read",
|
||||
"agents.pause": "agents.pause",
|
||||
"agents.resume": "agents.resume",
|
||||
"agents.invoke": "agents.invoke",
|
||||
|
||||
// Agent Sessions
|
||||
"agents.sessions.create": "agent.sessions.create",
|
||||
"agents.sessions.list": "agent.sessions.list",
|
||||
"agents.sessions.sendMessage": "agent.sessions.send",
|
||||
"agents.sessions.close": "agent.sessions.close",
|
||||
|
||||
// Goals
|
||||
"goals.list": "goals.read",
|
||||
"goals.get": "goals.read",
|
||||
"goals.create": "goals.create",
|
||||
"goals.update": "goals.update",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Create a complete handler map for all worker→host JSON-RPC methods.
|
||||
*
|
||||
* Each handler:
|
||||
* 1. Checks the plugin's declared capabilities against the required capability
|
||||
* for the method (if any).
|
||||
* 2. Delegates to the corresponding service adapter method.
|
||||
* 3. Returns the service result, which is serialized as the JSON-RPC response
|
||||
* by the worker manager.
|
||||
*
|
||||
* If a capability check fails, the handler throws a `CapabilityDeniedError`
|
||||
* with code `CAPABILITY_DENIED`. The worker manager catches this and sends a
|
||||
* JSON-RPC error response to the worker, which surfaces as a `JsonRpcCallError`
|
||||
* in the plugin's SDK client.
|
||||
*
|
||||
* @param options - Plugin ID, capabilities, and service adapters
|
||||
* @returns A handler map suitable for `WorkerStartOptions.hostHandlers`
|
||||
*/
|
||||
export function createHostClientHandlers(
|
||||
options: HostClientFactoryOptions,
|
||||
): HostClientHandlers {
|
||||
const { pluginId, services } = options;
|
||||
const capabilitySet = new Set<PluginCapability>(options.capabilities);
|
||||
|
||||
/**
|
||||
* Assert that the plugin has the required capability for a method.
|
||||
* Throws `CapabilityDeniedError` if the capability is missing.
|
||||
*/
|
||||
function requireCapability(
|
||||
method: WorkerToHostMethodName,
|
||||
): void {
|
||||
const required = METHOD_CAPABILITY_MAP[method];
|
||||
if (required === null) return; // No capability required
|
||||
if (capabilitySet.has(required)) return;
|
||||
throw new CapabilityDeniedError(pluginId, method, required);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a capability-gated proxy handler for a method.
|
||||
*
|
||||
* @param method - The RPC method name (used for capability lookup)
|
||||
* @param handler - The actual handler implementation
|
||||
* @returns A wrapper that checks capabilities before delegating
|
||||
*/
|
||||
function gated<M extends WorkerToHostMethodName>(
|
||||
method: M,
|
||||
handler: HostHandler<M>,
|
||||
): HostHandler<M> {
|
||||
return async (params: WorkerToHostMethods[M][0]) => {
|
||||
requireCapability(method);
|
||||
return handler(params);
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Build the complete handler map
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
return {
|
||||
// Config
|
||||
"config.get": gated("config.get", async () => {
|
||||
return services.config.get();
|
||||
}),
|
||||
|
||||
// State
|
||||
"state.get": gated("state.get", async (params) => {
|
||||
return services.state.get(params);
|
||||
}),
|
||||
"state.set": gated("state.set", async (params) => {
|
||||
return services.state.set(params);
|
||||
}),
|
||||
"state.delete": gated("state.delete", async (params) => {
|
||||
return services.state.delete(params);
|
||||
}),
|
||||
|
||||
// Entities
|
||||
"entities.upsert": gated("entities.upsert", async (params) => {
|
||||
return services.entities.upsert(params);
|
||||
}),
|
||||
"entities.list": gated("entities.list", async (params) => {
|
||||
return services.entities.list(params);
|
||||
}),
|
||||
|
||||
// Events
|
||||
"events.emit": gated("events.emit", async (params) => {
|
||||
return services.events.emit(params);
|
||||
}),
|
||||
|
||||
// HTTP
|
||||
"http.fetch": gated("http.fetch", async (params) => {
|
||||
return services.http.fetch(params);
|
||||
}),
|
||||
|
||||
// Secrets
|
||||
"secrets.resolve": gated("secrets.resolve", async (params) => {
|
||||
return services.secrets.resolve(params);
|
||||
}),
|
||||
|
||||
// Activity
|
||||
"activity.log": gated("activity.log", async (params) => {
|
||||
return services.activity.log(params);
|
||||
}),
|
||||
|
||||
// Metrics
|
||||
"metrics.write": gated("metrics.write", async (params) => {
|
||||
return services.metrics.write(params);
|
||||
}),
|
||||
|
||||
// Logger
|
||||
"log": gated("log", async (params) => {
|
||||
return services.logger.log(params);
|
||||
}),
|
||||
|
||||
// Companies
|
||||
"companies.list": gated("companies.list", async (params) => {
|
||||
return services.companies.list(params);
|
||||
}),
|
||||
"companies.get": gated("companies.get", async (params) => {
|
||||
return services.companies.get(params);
|
||||
}),
|
||||
|
||||
// Projects
|
||||
"projects.list": gated("projects.list", async (params) => {
|
||||
return services.projects.list(params);
|
||||
}),
|
||||
"projects.get": gated("projects.get", async (params) => {
|
||||
return services.projects.get(params);
|
||||
}),
|
||||
"projects.listWorkspaces": gated("projects.listWorkspaces", async (params) => {
|
||||
return services.projects.listWorkspaces(params);
|
||||
}),
|
||||
"projects.getPrimaryWorkspace": gated("projects.getPrimaryWorkspace", async (params) => {
|
||||
return services.projects.getPrimaryWorkspace(params);
|
||||
}),
|
||||
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
|
||||
return services.projects.getWorkspaceForIssue(params);
|
||||
}),
|
||||
|
||||
// Issues
|
||||
"issues.list": gated("issues.list", async (params) => {
|
||||
return services.issues.list(params);
|
||||
}),
|
||||
"issues.get": gated("issues.get", async (params) => {
|
||||
return services.issues.get(params);
|
||||
}),
|
||||
"issues.create": gated("issues.create", async (params) => {
|
||||
return services.issues.create(params);
|
||||
}),
|
||||
"issues.update": gated("issues.update", async (params) => {
|
||||
return services.issues.update(params);
|
||||
}),
|
||||
"issues.listComments": gated("issues.listComments", async (params) => {
|
||||
return services.issues.listComments(params);
|
||||
}),
|
||||
"issues.createComment": gated("issues.createComment", async (params) => {
|
||||
return services.issues.createComment(params);
|
||||
}),
|
||||
|
||||
// Agents
|
||||
"agents.list": gated("agents.list", async (params) => {
|
||||
return services.agents.list(params);
|
||||
}),
|
||||
"agents.get": gated("agents.get", async (params) => {
|
||||
return services.agents.get(params);
|
||||
}),
|
||||
"agents.pause": gated("agents.pause", async (params) => {
|
||||
return services.agents.pause(params);
|
||||
}),
|
||||
"agents.resume": gated("agents.resume", async (params) => {
|
||||
return services.agents.resume(params);
|
||||
}),
|
||||
"agents.invoke": gated("agents.invoke", async (params) => {
|
||||
return services.agents.invoke(params);
|
||||
}),
|
||||
|
||||
// Agent Sessions
|
||||
"agents.sessions.create": gated("agents.sessions.create", async (params) => {
|
||||
return services.agentSessions.create(params);
|
||||
}),
|
||||
"agents.sessions.list": gated("agents.sessions.list", async (params) => {
|
||||
return services.agentSessions.list(params);
|
||||
}),
|
||||
"agents.sessions.sendMessage": gated("agents.sessions.sendMessage", async (params) => {
|
||||
return services.agentSessions.sendMessage(params);
|
||||
}),
|
||||
"agents.sessions.close": gated("agents.sessions.close", async (params) => {
|
||||
return services.agentSessions.close(params);
|
||||
}),
|
||||
|
||||
// Goals
|
||||
"goals.list": gated("goals.list", async (params) => {
|
||||
return services.goals.list(params);
|
||||
}),
|
||||
"goals.get": gated("goals.get", async (params) => {
|
||||
return services.goals.get(params);
|
||||
}),
|
||||
"goals.create": gated("goals.create", async (params) => {
|
||||
return services.goals.create(params);
|
||||
}),
|
||||
"goals.update": gated("goals.update", async (params) => {
|
||||
return services.goals.update(params);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility: getRequiredCapability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the capability required for a given worker→host method, or `null` if
|
||||
* no capability is required.
|
||||
*
|
||||
* Useful for inspecting capability requirements without calling the factory.
|
||||
*
|
||||
* @param method - The worker→host method name
|
||||
* @returns The required capability, or `null`
|
||||
*/
|
||||
export function getRequiredCapability(
|
||||
method: WorkerToHostMethodName,
|
||||
): PluginCapability | null {
|
||||
return METHOD_CAPABILITY_MAP[method];
|
||||
}
|
||||
286
packages/plugins/sdk/src/index.ts
Normal file
286
packages/plugins/sdk/src/index.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* `@paperclipai/plugin-sdk` — Paperclip plugin worker-side SDK.
|
||||
*
|
||||
* This is the main entrypoint for plugin worker code. For plugin UI bundles,
|
||||
* import from `@paperclipai/plugin-sdk/ui` instead.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Plugin worker entrypoint (dist/worker.ts)
|
||||
* import { definePlugin, runWorker, z } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* const plugin = definePlugin({
|
||||
* async setup(ctx) {
|
||||
* ctx.logger.info("Plugin starting up");
|
||||
*
|
||||
* ctx.events.on("issue.created", async (event) => {
|
||||
* ctx.logger.info("Issue created", { issueId: event.entityId });
|
||||
* });
|
||||
*
|
||||
* ctx.jobs.register("full-sync", async (job) => {
|
||||
* ctx.logger.info("Starting full sync", { runId: job.runId });
|
||||
* // ... sync implementation
|
||||
* });
|
||||
*
|
||||
* ctx.data.register("sync-health", async ({ companyId }) => {
|
||||
* const state = await ctx.state.get({
|
||||
* scopeKind: "company",
|
||||
* scopeId: String(companyId),
|
||||
* stateKey: "last-sync-at",
|
||||
* });
|
||||
* return { lastSync: state };
|
||||
* });
|
||||
* },
|
||||
*
|
||||
* async onHealth() {
|
||||
* return { status: "ok" };
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* export default plugin;
|
||||
* runWorker(plugin, import.meta.url);
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14 — SDK Surface
|
||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export { definePlugin } from "./define-plugin.js";
|
||||
export { createTestHarness } from "./testing.js";
|
||||
export { createPluginBundlerPresets } from "./bundlers.js";
|
||||
export { startPluginDevServer, getUiBuildSnapshot } from "./dev-server.js";
|
||||
export { startWorkerRpcHost, runWorker } from "./worker-rpc-host.js";
|
||||
export {
|
||||
createHostClientHandlers,
|
||||
getRequiredCapability,
|
||||
CapabilityDeniedError,
|
||||
} from "./host-client-factory.js";
|
||||
|
||||
// JSON-RPC protocol helpers and constants
|
||||
export {
|
||||
JSONRPC_VERSION,
|
||||
JSONRPC_ERROR_CODES,
|
||||
PLUGIN_RPC_ERROR_CODES,
|
||||
HOST_TO_WORKER_REQUIRED_METHODS,
|
||||
HOST_TO_WORKER_OPTIONAL_METHODS,
|
||||
MESSAGE_DELIMITER,
|
||||
createRequest,
|
||||
createSuccessResponse,
|
||||
createErrorResponse,
|
||||
createNotification,
|
||||
isJsonRpcRequest,
|
||||
isJsonRpcNotification,
|
||||
isJsonRpcResponse,
|
||||
isJsonRpcSuccessResponse,
|
||||
isJsonRpcErrorResponse,
|
||||
serializeMessage,
|
||||
parseMessage,
|
||||
JsonRpcParseError,
|
||||
JsonRpcCallError,
|
||||
_resetIdCounter,
|
||||
} from "./protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Type exports
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Plugin definition and lifecycle types
|
||||
export type {
|
||||
PluginDefinition,
|
||||
PaperclipPlugin,
|
||||
PluginHealthDiagnostics,
|
||||
PluginConfigValidationResult,
|
||||
PluginWebhookInput,
|
||||
} from "./define-plugin.js";
|
||||
export type {
|
||||
TestHarness,
|
||||
TestHarnessOptions,
|
||||
TestHarnessLogEntry,
|
||||
} from "./testing.js";
|
||||
export type {
|
||||
PluginBundlerPresetInput,
|
||||
PluginBundlerPresets,
|
||||
EsbuildLikeOptions,
|
||||
RollupLikeConfig,
|
||||
} from "./bundlers.js";
|
||||
export type { PluginDevServer, PluginDevServerOptions } from "./dev-server.js";
|
||||
export type {
|
||||
WorkerRpcHostOptions,
|
||||
WorkerRpcHost,
|
||||
RunWorkerOptions,
|
||||
} from "./worker-rpc-host.js";
|
||||
export type {
|
||||
HostServices,
|
||||
HostClientFactoryOptions,
|
||||
HostClientHandlers,
|
||||
} from "./host-client-factory.js";
|
||||
|
||||
// JSON-RPC protocol types
|
||||
export type {
|
||||
JsonRpcId,
|
||||
JsonRpcRequest,
|
||||
JsonRpcSuccessResponse,
|
||||
JsonRpcError,
|
||||
JsonRpcErrorResponse,
|
||||
JsonRpcResponse,
|
||||
JsonRpcNotification,
|
||||
JsonRpcMessage,
|
||||
JsonRpcErrorCode,
|
||||
PluginRpcErrorCode,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
ConfigChangedParams,
|
||||
ValidateConfigParams,
|
||||
OnEventParams,
|
||||
RunJobParams,
|
||||
GetDataParams,
|
||||
PerformActionParams,
|
||||
ExecuteToolParams,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
HostToWorkerMethods,
|
||||
HostToWorkerMethodName,
|
||||
WorkerToHostMethods,
|
||||
WorkerToHostMethodName,
|
||||
HostToWorkerRequest,
|
||||
HostToWorkerResponse,
|
||||
WorkerToHostRequest,
|
||||
WorkerToHostResponse,
|
||||
WorkerToHostNotifications,
|
||||
WorkerToHostNotificationName,
|
||||
} from "./protocol.js";
|
||||
|
||||
// Plugin context and all client interfaces
|
||||
export type {
|
||||
PluginContext,
|
||||
PluginConfigClient,
|
||||
PluginEventsClient,
|
||||
PluginJobsClient,
|
||||
PluginLaunchersClient,
|
||||
PluginHttpClient,
|
||||
PluginSecretsClient,
|
||||
PluginActivityClient,
|
||||
PluginActivityLogEntry,
|
||||
PluginStateClient,
|
||||
PluginEntitiesClient,
|
||||
PluginProjectsClient,
|
||||
PluginCompaniesClient,
|
||||
PluginIssuesClient,
|
||||
PluginAgentsClient,
|
||||
PluginAgentSessionsClient,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
AgentSessionSendResult,
|
||||
PluginGoalsClient,
|
||||
PluginDataClient,
|
||||
PluginActionsClient,
|
||||
PluginStreamsClient,
|
||||
PluginToolsClient,
|
||||
PluginMetricsClient,
|
||||
PluginLogger,
|
||||
} from "./types.js";
|
||||
|
||||
// Supporting types for context clients
|
||||
export type {
|
||||
ScopeKey,
|
||||
EventFilter,
|
||||
PluginEvent,
|
||||
PluginJobContext,
|
||||
PluginLauncherRegistration,
|
||||
ToolRunContext,
|
||||
ToolResult,
|
||||
PluginEntityUpsert,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginWorkspace,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "./types.js";
|
||||
|
||||
// Manifest and constant types re-exported from @paperclipai/shared
|
||||
// Plugin authors import manifest types from here so they have a single
|
||||
// dependency (@paperclipai/plugin-sdk) for all plugin authoring needs.
|
||||
export type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginUiDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
PluginLauncherDeclaration,
|
||||
PluginMinimumHostVersion,
|
||||
PluginRecord,
|
||||
PluginConfig,
|
||||
JsonSchema,
|
||||
PluginStatus,
|
||||
PluginCategory,
|
||||
PluginCapability,
|
||||
PluginUiSlotType,
|
||||
PluginUiSlotEntityType,
|
||||
PluginLauncherPlacementZone,
|
||||
PluginLauncherAction,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
PluginStateScopeKind,
|
||||
PluginJobStatus,
|
||||
PluginJobRunStatus,
|
||||
PluginJobRunTrigger,
|
||||
PluginWebhookDeliveryStatus,
|
||||
PluginEventType,
|
||||
PluginBridgeErrorCode,
|
||||
} from "./types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zod re-export
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zod is re-exported for plugin authors to use when defining their
|
||||
* `instanceConfigSchema` and tool `parametersSchema`.
|
||||
*
|
||||
* Plugin authors do not need to add a separate `zod` dependency.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §14.1 — Example SDK Shape
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { z } from "@paperclipai/plugin-sdk";
|
||||
*
|
||||
* const configSchema = z.object({
|
||||
* apiKey: z.string().describe("Your API key"),
|
||||
* workspace: z.string().optional(),
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export { z } from "zod";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants re-exports (for plugin code that needs to check values at runtime)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
PLUGIN_API_VERSION,
|
||||
PLUGIN_STATUSES,
|
||||
PLUGIN_CATEGORIES,
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_STATE_SCOPE_KINDS,
|
||||
PLUGIN_JOB_STATUSES,
|
||||
PLUGIN_JOB_RUN_STATUSES,
|
||||
PLUGIN_JOB_RUN_TRIGGERS,
|
||||
PLUGIN_WEBHOOK_DELIVERY_STATUSES,
|
||||
PLUGIN_EVENT_TYPES,
|
||||
PLUGIN_BRIDGE_ERROR_CODES,
|
||||
} from "@paperclipai/shared";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user