Merge pull request #821 from paperclipai/feature/plugin-runtime-instance-cleanup
WIP: Simplify plugin runtime and cleanup lifecycle
This commit is contained in:
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");
|
||||
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
@@ -204,6 +204,13 @@
|
||||
"when": 1773432085646,
|
||||
"tag": "0028_harsh_goliath",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 29,
|
||||
"version": "7",
|
||||
"when": 1773417600000,
|
||||
"tag": "0029_plugin_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,3 +35,11 @@ export { approvalComments } from "./approval_comments.js";
|
||||
export { activityLog } from "./activity_log.js";
|
||||
export { companySecrets } from "./company_secrets.js";
|
||||
export { companySecretVersions } from "./company_secret_versions.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";
|
||||
|
||||
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"]
|
||||
}
|
||||
873
packages/plugins/sdk/README.md
Normal file
873
packages/plugins/sdk/README.md
Normal file
@@ -0,0 +1,873 @@
|
||||
# `@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 | — |
|
||||
| `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.
|
||||
|
||||
#### `toolbarButton`
|
||||
|
||||
A button rendered in the toolbar of a host surface (e.g. project detail, issue detail). Use this for short-lived, contextual actions like triggering a sync, opening a picker, or running a quick command. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId` set to the active company; entity context varies by host surface. 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` | — |
|
||||
| `detailTab` | `PluginDetailTabProps` | `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`, `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
|
||||
|
||||
For short-lived actions, mount a `toolbarButton` and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or project.
|
||||
|
||||
```json
|
||||
{
|
||||
"ui": {
|
||||
"slots": [
|
||||
{
|
||||
"type": "toolbarButton",
|
||||
"id": "sync-toolbar-button",
|
||||
"displayName": "Sync",
|
||||
"exportName": "SyncToolbarButton"
|
||||
}
|
||||
]
|
||||
},
|
||||
"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";
|
||||
1028
packages/plugins/sdk/src/protocol.ts
Normal file
1028
packages/plugins/sdk/src/protocol.ts
Normal file
File diff suppressed because it is too large
Load Diff
705
packages/plugins/sdk/src/testing.ts
Normal file
705
packages/plugins/sdk/src/testing.ts
Normal file
@@ -0,0 +1,705 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
PaperclipPluginManifestV1,
|
||||
PluginCapability,
|
||||
PluginEventType,
|
||||
Company,
|
||||
Project,
|
||||
Issue,
|
||||
IssueComment,
|
||||
Agent,
|
||||
Goal,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
EventFilter,
|
||||
PluginContext,
|
||||
PluginEntityRecord,
|
||||
PluginEntityUpsert,
|
||||
PluginJobContext,
|
||||
PluginLauncherRegistration,
|
||||
PluginEvent,
|
||||
ScopeKey,
|
||||
ToolResult,
|
||||
ToolRunContext,
|
||||
PluginWorkspace,
|
||||
AgentSession,
|
||||
AgentSessionEvent,
|
||||
} from "./types.js";
|
||||
|
||||
export interface TestHarnessOptions {
|
||||
/** Plugin manifest used to seed capability checks and metadata. */
|
||||
manifest: PaperclipPluginManifestV1;
|
||||
/** Optional capability override. Defaults to `manifest.capabilities`. */
|
||||
capabilities?: PluginCapability[];
|
||||
/** Initial config returned by `ctx.config.get()`. */
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarnessLogEntry {
|
||||
level: "info" | "warn" | "error" | "debug";
|
||||
message: string;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface TestHarness {
|
||||
/** Fully-typed in-memory plugin context passed to `plugin.setup(ctx)`. */
|
||||
ctx: PluginContext;
|
||||
/** Seed host entities for `ctx.companies/projects/issues/agents/goals` reads. */
|
||||
seed(input: {
|
||||
companies?: Company[];
|
||||
projects?: Project[];
|
||||
issues?: Issue[];
|
||||
issueComments?: IssueComment[];
|
||||
agents?: Agent[];
|
||||
goals?: Goal[];
|
||||
}): void;
|
||||
setConfig(config: Record<string, unknown>): void;
|
||||
/** Dispatch a host or plugin event to registered handlers. */
|
||||
emit(eventType: PluginEventType | `plugin.${string}`, payload: unknown, base?: Partial<PluginEvent>): Promise<void>;
|
||||
/** Execute a previously-registered scheduled job handler. */
|
||||
runJob(jobKey: string, partial?: Partial<PluginJobContext>): Promise<void>;
|
||||
/** Invoke a `ctx.data.register(...)` handler by key. */
|
||||
getData<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Invoke a `ctx.actions.register(...)` handler by key. */
|
||||
performAction<T = unknown>(key: string, params?: Record<string, unknown>): Promise<T>;
|
||||
/** Execute a registered tool handler via `ctx.tools.execute(...)`. */
|
||||
executeTool<T = ToolResult>(name: string, params: unknown, runCtx?: Partial<ToolRunContext>): Promise<T>;
|
||||
/** Read raw in-memory state for assertions. */
|
||||
getState(input: ScopeKey): unknown;
|
||||
/** Simulate a streaming event arriving for an active session. */
|
||||
simulateSessionEvent(sessionId: string, event: Omit<AgentSessionEvent, "sessionId">): void;
|
||||
logs: TestHarnessLogEntry[];
|
||||
activity: Array<{ message: string; entityType?: string; entityId?: string; metadata?: Record<string, unknown> }>;
|
||||
metrics: Array<{ name: string; value: number; tags?: Record<string, string> }>;
|
||||
}
|
||||
|
||||
type EventRegistration = {
|
||||
name: PluginEventType | `plugin.${string}`;
|
||||
filter?: EventFilter;
|
||||
fn: (event: PluginEvent) => Promise<void>;
|
||||
};
|
||||
|
||||
function normalizeScope(input: ScopeKey): Required<Pick<ScopeKey, "scopeKind" | "stateKey">> & Pick<ScopeKey, "scopeId" | "namespace"> {
|
||||
return {
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId,
|
||||
namespace: input.namespace ?? "default",
|
||||
stateKey: input.stateKey,
|
||||
};
|
||||
}
|
||||
|
||||
function stateMapKey(input: ScopeKey): string {
|
||||
const normalized = normalizeScope(input);
|
||||
return `${normalized.scopeKind}|${normalized.scopeId ?? ""}|${normalized.namespace}|${normalized.stateKey}`;
|
||||
}
|
||||
|
||||
function allowsEvent(filter: EventFilter | undefined, event: PluginEvent): boolean {
|
||||
if (!filter) return true;
|
||||
if (filter.companyId && filter.companyId !== String((event.payload as Record<string, unknown> | undefined)?.companyId ?? "")) return false;
|
||||
if (filter.projectId && filter.projectId !== String((event.payload as Record<string, unknown> | undefined)?.projectId ?? "")) return false;
|
||||
if (filter.agentId && filter.agentId !== String((event.payload as Record<string, unknown> | undefined)?.agentId ?? "")) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function requireCapability(manifest: PaperclipPluginManifestV1, allowed: Set<PluginCapability>, capability: PluginCapability) {
|
||||
if (allowed.has(capability)) return;
|
||||
throw new Error(`Plugin '${manifest.id}' is missing required capability '${capability}' in test harness`);
|
||||
}
|
||||
|
||||
function requireCompanyId(companyId?: string): string {
|
||||
if (!companyId) throw new Error("companyId is required for this operation");
|
||||
return companyId;
|
||||
}
|
||||
|
||||
function isInCompany<T extends { companyId: string | null | undefined }>(
|
||||
record: T | null | undefined,
|
||||
companyId: string,
|
||||
): record is T {
|
||||
return Boolean(record && record.companyId === companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an in-memory host harness for plugin worker tests.
|
||||
*
|
||||
* The harness enforces declared capabilities and simulates host APIs, so tests
|
||||
* can validate plugin behavior without spinning up the Paperclip server runtime.
|
||||
*/
|
||||
export function createTestHarness(options: TestHarnessOptions): TestHarness {
|
||||
const manifest = options.manifest;
|
||||
const capabilitySet = new Set(options.capabilities ?? manifest.capabilities);
|
||||
let currentConfig = { ...(options.config ?? {}) };
|
||||
|
||||
const logs: TestHarnessLogEntry[] = [];
|
||||
const activity: TestHarness["activity"] = [];
|
||||
const metrics: TestHarness["metrics"] = [];
|
||||
|
||||
const state = new Map<string, unknown>();
|
||||
const entities = new Map<string, PluginEntityRecord>();
|
||||
const entityExternalIndex = new Map<string, string>();
|
||||
const companies = new Map<string, Company>();
|
||||
const projects = new Map<string, Project>();
|
||||
const issues = new Map<string, Issue>();
|
||||
const issueComments = new Map<string, IssueComment[]>();
|
||||
const agents = new Map<string, Agent>();
|
||||
const goals = new Map<string, Goal>();
|
||||
const projectWorkspaces = new Map<string, PluginWorkspace[]>();
|
||||
|
||||
const sessions = new Map<string, AgentSession>();
|
||||
const sessionEventCallbacks = new Map<string, (event: AgentSessionEvent) => void>();
|
||||
|
||||
const events: EventRegistration[] = [];
|
||||
const jobs = new Map<string, (job: PluginJobContext) => Promise<void>>();
|
||||
const launchers = new Map<string, PluginLauncherRegistration>();
|
||||
const dataHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const actionHandlers = new Map<string, (params: Record<string, unknown>) => Promise<unknown>>();
|
||||
const toolHandlers = new Map<string, (params: unknown, runCtx: ToolRunContext) => Promise<ToolResult>>();
|
||||
|
||||
const ctx: PluginContext = {
|
||||
manifest,
|
||||
config: {
|
||||
async get() {
|
||||
return { ...currentConfig };
|
||||
},
|
||||
},
|
||||
events: {
|
||||
on(name: PluginEventType | `plugin.${string}`, filterOrFn: EventFilter | ((event: PluginEvent) => Promise<void>), maybeFn?: (event: PluginEvent) => Promise<void>): () => void {
|
||||
requireCapability(manifest, capabilitySet, "events.subscribe");
|
||||
let registration: EventRegistration;
|
||||
if (typeof filterOrFn === "function") {
|
||||
registration = { name, fn: filterOrFn };
|
||||
} else {
|
||||
if (!maybeFn) throw new Error("event handler is required");
|
||||
registration = { name, filter: filterOrFn, fn: maybeFn };
|
||||
}
|
||||
events.push(registration);
|
||||
return () => {
|
||||
const idx = events.indexOf(registration);
|
||||
if (idx !== -1) events.splice(idx, 1);
|
||||
};
|
||||
},
|
||||
async emit(name, companyId, payload) {
|
||||
requireCapability(manifest, capabilitySet, "events.emit");
|
||||
await harness.emit(`plugin.${manifest.id}.${name}`, payload, { companyId });
|
||||
},
|
||||
},
|
||||
jobs: {
|
||||
register(key, fn) {
|
||||
requireCapability(manifest, capabilitySet, "jobs.schedule");
|
||||
jobs.set(key, fn);
|
||||
},
|
||||
},
|
||||
launchers: {
|
||||
register(launcher) {
|
||||
launchers.set(launcher.id, launcher);
|
||||
},
|
||||
},
|
||||
http: {
|
||||
async fetch(url, init) {
|
||||
requireCapability(manifest, capabilitySet, "http.outbound");
|
||||
return fetch(url, init);
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
async resolve(secretRef) {
|
||||
requireCapability(manifest, capabilitySet, "secrets.read-ref");
|
||||
return `resolved:${secretRef}`;
|
||||
},
|
||||
},
|
||||
activity: {
|
||||
async log(entry) {
|
||||
requireCapability(manifest, capabilitySet, "activity.log.write");
|
||||
activity.push(entry);
|
||||
},
|
||||
},
|
||||
state: {
|
||||
async get(input) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.read");
|
||||
return state.has(stateMapKey(input)) ? state.get(stateMapKey(input)) : null;
|
||||
},
|
||||
async set(input, value) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.write");
|
||||
state.set(stateMapKey(input), value);
|
||||
},
|
||||
async delete(input) {
|
||||
requireCapability(manifest, capabilitySet, "plugin.state.write");
|
||||
state.delete(stateMapKey(input));
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
async upsert(input: PluginEntityUpsert) {
|
||||
const externalKey = input.externalId
|
||||
? `${input.entityType}|${input.scopeKind}|${input.scopeId ?? ""}|${input.externalId}`
|
||||
: null;
|
||||
const existingId = externalKey ? entityExternalIndex.get(externalKey) : undefined;
|
||||
const existing = existingId ? entities.get(existingId) : undefined;
|
||||
const now = new Date().toISOString();
|
||||
const previousExternalKey = existing?.externalId
|
||||
? `${existing.entityType}|${existing.scopeKind}|${existing.scopeId ?? ""}|${existing.externalId}`
|
||||
: null;
|
||||
const record: PluginEntityRecord = existing
|
||||
? {
|
||||
...existing,
|
||||
entityType: input.entityType,
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId ?? null,
|
||||
externalId: input.externalId ?? null,
|
||||
title: input.title ?? null,
|
||||
status: input.status ?? null,
|
||||
data: input.data,
|
||||
updatedAt: now,
|
||||
}
|
||||
: {
|
||||
id: randomUUID(),
|
||||
entityType: input.entityType,
|
||||
scopeKind: input.scopeKind,
|
||||
scopeId: input.scopeId ?? null,
|
||||
externalId: input.externalId ?? null,
|
||||
title: input.title ?? null,
|
||||
status: input.status ?? null,
|
||||
data: input.data,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
entities.set(record.id, record);
|
||||
if (previousExternalKey && previousExternalKey !== externalKey) {
|
||||
entityExternalIndex.delete(previousExternalKey);
|
||||
}
|
||||
if (externalKey) entityExternalIndex.set(externalKey, record.id);
|
||||
return record;
|
||||
},
|
||||
async list(query) {
|
||||
let out = [...entities.values()];
|
||||
if (query.entityType) out = out.filter((r) => r.entityType === query.entityType);
|
||||
if (query.scopeKind) out = out.filter((r) => r.scopeKind === query.scopeKind);
|
||||
if (query.scopeId) out = out.filter((r) => r.scopeId === query.scopeId);
|
||||
if (query.externalId) out = out.filter((r) => r.externalId === query.externalId);
|
||||
if (query.offset) out = out.slice(query.offset);
|
||||
if (query.limit) out = out.slice(0, query.limit);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
projects: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...projects.values()];
|
||||
out = out.filter((project) => project.companyId === companyId);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "projects.read");
|
||||
const project = projects.get(projectId);
|
||||
return isInCompany(project, companyId) ? project : null;
|
||||
},
|
||||
async listWorkspaces(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return [];
|
||||
return projectWorkspaces.get(projectId) ?? [];
|
||||
},
|
||||
async getPrimaryWorkspace(projectId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return null;
|
||||
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||
},
|
||||
async getWorkspaceForIssue(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "project.workspaces.read");
|
||||
const issue = issues.get(issueId);
|
||||
if (!isInCompany(issue, companyId)) return null;
|
||||
const projectId = (issue as unknown as Record<string, unknown>)?.projectId as string | undefined;
|
||||
if (!projectId) return null;
|
||||
if (!isInCompany(projects.get(projectId), companyId)) return null;
|
||||
const workspaces = projectWorkspaces.get(projectId) ?? [];
|
||||
return workspaces.find((workspace) => workspace.isPrimary) ?? null;
|
||||
},
|
||||
},
|
||||
companies: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "companies.read");
|
||||
let out = [...companies.values()];
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(companyId) {
|
||||
requireCapability(manifest, capabilitySet, "companies.read");
|
||||
return companies.get(companyId) ?? null;
|
||||
},
|
||||
},
|
||||
issues: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "issues.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...issues.values()];
|
||||
out = out.filter((issue) => issue.companyId === companyId);
|
||||
if (input?.projectId) out = out.filter((issue) => issue.projectId === input.projectId);
|
||||
if (input?.assigneeAgentId) out = out.filter((issue) => issue.assigneeAgentId === input.assigneeAgentId);
|
||||
if (input?.status) out = out.filter((issue) => issue.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issues.read");
|
||||
const issue = issues.get(issueId);
|
||||
return isInCompany(issue, companyId) ? issue : null;
|
||||
},
|
||||
async create(input) {
|
||||
requireCapability(manifest, capabilitySet, "issues.create");
|
||||
const now = new Date();
|
||||
const record: Issue = {
|
||||
id: randomUUID(),
|
||||
companyId: input.companyId,
|
||||
projectId: input.projectId ?? null,
|
||||
goalId: input.goalId ?? null,
|
||||
parentId: input.parentId ?? null,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
status: "todo",
|
||||
priority: input.priority ?? "medium",
|
||||
assigneeAgentId: input.assigneeAgentId ?? null,
|
||||
assigneeUserId: null,
|
||||
checkoutRunId: null,
|
||||
executionRunId: null,
|
||||
executionAgentNameKey: null,
|
||||
executionLockedAt: null,
|
||||
createdByAgentId: null,
|
||||
createdByUserId: null,
|
||||
issueNumber: null,
|
||||
identifier: null,
|
||||
requestDepth: 0,
|
||||
billingCode: null,
|
||||
assigneeAdapterOverrides: null,
|
||||
executionWorkspaceSettings: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
cancelledAt: null,
|
||||
hiddenAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
issues.set(record.id, record);
|
||||
return record;
|
||||
},
|
||||
async update(issueId, patch, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issues.update");
|
||||
const record = issues.get(issueId);
|
||||
if (!isInCompany(record, companyId)) throw new Error(`Issue not found: ${issueId}`);
|
||||
const updated: Issue = {
|
||||
...record,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
issues.set(issueId, updated);
|
||||
return updated;
|
||||
},
|
||||
async listComments(issueId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.comments.read");
|
||||
if (!isInCompany(issues.get(issueId), companyId)) return [];
|
||||
return issueComments.get(issueId) ?? [];
|
||||
},
|
||||
async createComment(issueId, body, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "issue.comments.create");
|
||||
const parentIssue = issues.get(issueId);
|
||||
if (!isInCompany(parentIssue, companyId)) {
|
||||
throw new Error(`Issue not found: ${issueId}`);
|
||||
}
|
||||
const now = new Date();
|
||||
const comment: IssueComment = {
|
||||
id: randomUUID(),
|
||||
companyId: parentIssue.companyId,
|
||||
issueId,
|
||||
authorAgentId: null,
|
||||
authorUserId: null,
|
||||
body,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const current = issueComments.get(issueId) ?? [];
|
||||
current.push(comment);
|
||||
issueComments.set(issueId, current);
|
||||
return comment;
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "agents.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...agents.values()];
|
||||
out = out.filter((agent) => agent.companyId === companyId);
|
||||
if (input?.status) out = out.filter((agent) => agent.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.read");
|
||||
const agent = agents.get(agentId);
|
||||
return isInCompany(agent, companyId) ? agent : null;
|
||||
},
|
||||
async pause(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.pause");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (agent!.status === "terminated") throw new Error("Cannot pause terminated agent");
|
||||
const updated: Agent = { ...agent!, status: "paused", updatedAt: new Date() };
|
||||
agents.set(agentId, updated);
|
||||
return updated;
|
||||
},
|
||||
async resume(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agents.resume");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (agent!.status === "terminated") throw new Error("Cannot resume terminated agent");
|
||||
if (agent!.status === "pending_approval") throw new Error("Pending approval agents cannot be resumed");
|
||||
const updated: Agent = { ...agent!, status: "idle", updatedAt: new Date() };
|
||||
agents.set(agentId, updated);
|
||||
return updated;
|
||||
},
|
||||
async invoke(agentId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agents.invoke");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
if (
|
||||
agent!.status === "paused" ||
|
||||
agent!.status === "terminated" ||
|
||||
agent!.status === "pending_approval"
|
||||
) {
|
||||
throw new Error(`Agent is not invokable in its current state: ${agent!.status}`);
|
||||
}
|
||||
return { runId: randomUUID() };
|
||||
},
|
||||
sessions: {
|
||||
async create(agentId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.create");
|
||||
const cid = requireCompanyId(companyId);
|
||||
const agent = agents.get(agentId);
|
||||
if (!isInCompany(agent, cid)) throw new Error(`Agent not found: ${agentId}`);
|
||||
const session: AgentSession = {
|
||||
sessionId: randomUUID(),
|
||||
agentId,
|
||||
companyId: cid,
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
sessions.set(session.sessionId, session);
|
||||
return session;
|
||||
},
|
||||
async list(agentId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.list");
|
||||
const cid = requireCompanyId(companyId);
|
||||
return [...sessions.values()].filter(
|
||||
(s) => s.agentId === agentId && s.companyId === cid && s.status === "active",
|
||||
);
|
||||
},
|
||||
async sendMessage(sessionId, companyId, opts) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.send");
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.status !== "active") throw new Error(`Session not found or closed: ${sessionId}`);
|
||||
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
|
||||
if (opts.onEvent) {
|
||||
sessionEventCallbacks.set(sessionId, opts.onEvent);
|
||||
}
|
||||
return { runId: randomUUID() };
|
||||
},
|
||||
async close(sessionId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "agent.sessions.close");
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) throw new Error(`Session not found: ${sessionId}`);
|
||||
if (session.companyId !== companyId) throw new Error(`Session not found: ${sessionId}`);
|
||||
session.status = "closed";
|
||||
sessionEventCallbacks.delete(sessionId);
|
||||
},
|
||||
},
|
||||
},
|
||||
goals: {
|
||||
async list(input) {
|
||||
requireCapability(manifest, capabilitySet, "goals.read");
|
||||
const companyId = requireCompanyId(input?.companyId);
|
||||
let out = [...goals.values()];
|
||||
out = out.filter((goal) => goal.companyId === companyId);
|
||||
if (input?.level) out = out.filter((goal) => goal.level === input.level);
|
||||
if (input?.status) out = out.filter((goal) => goal.status === input.status);
|
||||
if (input?.offset) out = out.slice(input.offset);
|
||||
if (input?.limit) out = out.slice(0, input.limit);
|
||||
return out;
|
||||
},
|
||||
async get(goalId, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "goals.read");
|
||||
const goal = goals.get(goalId);
|
||||
return isInCompany(goal, companyId) ? goal : null;
|
||||
},
|
||||
async create(input) {
|
||||
requireCapability(manifest, capabilitySet, "goals.create");
|
||||
const now = new Date();
|
||||
const record: Goal = {
|
||||
id: randomUUID(),
|
||||
companyId: input.companyId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
level: input.level ?? "task",
|
||||
status: input.status ?? "planned",
|
||||
parentId: input.parentId ?? null,
|
||||
ownerAgentId: input.ownerAgentId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
goals.set(record.id, record);
|
||||
return record;
|
||||
},
|
||||
async update(goalId, patch, companyId) {
|
||||
requireCapability(manifest, capabilitySet, "goals.update");
|
||||
const record = goals.get(goalId);
|
||||
if (!isInCompany(record, companyId)) throw new Error(`Goal not found: ${goalId}`);
|
||||
const updated: Goal = {
|
||||
...record,
|
||||
...patch,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
goals.set(goalId, updated);
|
||||
return updated;
|
||||
},
|
||||
},
|
||||
data: {
|
||||
register(key, handler) {
|
||||
dataHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
register(key, handler) {
|
||||
actionHandlers.set(key, handler);
|
||||
},
|
||||
},
|
||||
streams: (() => {
|
||||
const channelCompanyMap = new Map<string, string>();
|
||||
return {
|
||||
open(channel: string, companyId: string) {
|
||||
channelCompanyMap.set(channel, companyId);
|
||||
},
|
||||
emit(_channel: string, _event: unknown) {
|
||||
// No-op in test harness — events are not forwarded
|
||||
},
|
||||
close(channel: string) {
|
||||
channelCompanyMap.delete(channel);
|
||||
},
|
||||
};
|
||||
})(),
|
||||
tools: {
|
||||
register(name, _decl, fn) {
|
||||
requireCapability(manifest, capabilitySet, "agent.tools.register");
|
||||
toolHandlers.set(name, fn);
|
||||
},
|
||||
},
|
||||
metrics: {
|
||||
async write(name, value, tags) {
|
||||
requireCapability(manifest, capabilitySet, "metrics.write");
|
||||
metrics.push({ name, value, tags });
|
||||
},
|
||||
},
|
||||
logger: {
|
||||
info(message, meta) {
|
||||
logs.push({ level: "info", message, meta });
|
||||
},
|
||||
warn(message, meta) {
|
||||
logs.push({ level: "warn", message, meta });
|
||||
},
|
||||
error(message, meta) {
|
||||
logs.push({ level: "error", message, meta });
|
||||
},
|
||||
debug(message, meta) {
|
||||
logs.push({ level: "debug", message, meta });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const harness: TestHarness = {
|
||||
ctx,
|
||||
seed(input) {
|
||||
for (const row of input.companies ?? []) companies.set(row.id, row);
|
||||
for (const row of input.projects ?? []) projects.set(row.id, row);
|
||||
for (const row of input.issues ?? []) issues.set(row.id, row);
|
||||
for (const row of input.issueComments ?? []) {
|
||||
const list = issueComments.get(row.issueId) ?? [];
|
||||
list.push(row);
|
||||
issueComments.set(row.issueId, list);
|
||||
}
|
||||
for (const row of input.agents ?? []) agents.set(row.id, row);
|
||||
for (const row of input.goals ?? []) goals.set(row.id, row);
|
||||
},
|
||||
setConfig(config) {
|
||||
currentConfig = { ...config };
|
||||
},
|
||||
async emit(eventType, payload, base) {
|
||||
const event: PluginEvent = {
|
||||
eventId: base?.eventId ?? randomUUID(),
|
||||
eventType,
|
||||
companyId: base?.companyId ?? "test-company",
|
||||
occurredAt: base?.occurredAt ?? new Date().toISOString(),
|
||||
actorId: base?.actorId,
|
||||
actorType: base?.actorType,
|
||||
entityId: base?.entityId,
|
||||
entityType: base?.entityType,
|
||||
payload,
|
||||
};
|
||||
|
||||
for (const handler of events) {
|
||||
const exactMatch = handler.name === event.eventType;
|
||||
const wildcardPluginAll = handler.name === "plugin.*" && String(event.eventType).startsWith("plugin.");
|
||||
const wildcardPluginOne = String(handler.name).endsWith(".*")
|
||||
&& String(event.eventType).startsWith(String(handler.name).slice(0, -1));
|
||||
if (!exactMatch && !wildcardPluginAll && !wildcardPluginOne) continue;
|
||||
if (!allowsEvent(handler.filter, event)) continue;
|
||||
await handler.fn(event);
|
||||
}
|
||||
},
|
||||
async runJob(jobKey, partial = {}) {
|
||||
const handler = jobs.get(jobKey);
|
||||
if (!handler) throw new Error(`No job handler registered for '${jobKey}'`);
|
||||
await handler({
|
||||
jobKey,
|
||||
runId: partial.runId ?? randomUUID(),
|
||||
trigger: partial.trigger ?? "manual",
|
||||
scheduledAt: partial.scheduledAt ?? new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
async getData<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
const handler = dataHandlers.get(key);
|
||||
if (!handler) throw new Error(`No data handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async performAction<T = unknown>(key: string, params: Record<string, unknown> = {}) {
|
||||
const handler = actionHandlers.get(key);
|
||||
if (!handler) throw new Error(`No action handler registered for '${key}'`);
|
||||
return await handler(params) as T;
|
||||
},
|
||||
async executeTool<T = ToolResult>(name: string, params: unknown, runCtx: Partial<ToolRunContext> = {}) {
|
||||
const handler = toolHandlers.get(name);
|
||||
if (!handler) throw new Error(`No tool handler registered for '${name}'`);
|
||||
const ctxToPass: ToolRunContext = {
|
||||
agentId: runCtx.agentId ?? "agent-test",
|
||||
runId: runCtx.runId ?? randomUUID(),
|
||||
companyId: runCtx.companyId ?? "company-test",
|
||||
projectId: runCtx.projectId ?? "project-test",
|
||||
};
|
||||
return await handler(params, ctxToPass) as T;
|
||||
},
|
||||
getState(input) {
|
||||
return state.get(stateMapKey(input));
|
||||
},
|
||||
simulateSessionEvent(sessionId, event) {
|
||||
const cb = sessionEventCallbacks.get(sessionId);
|
||||
if (!cb) throw new Error(`No active session event callback for session: ${sessionId}`);
|
||||
cb({ ...event, sessionId });
|
||||
},
|
||||
logs,
|
||||
activity,
|
||||
metrics,
|
||||
};
|
||||
|
||||
return harness;
|
||||
}
|
||||
1085
packages/plugins/sdk/src/types.ts
Normal file
1085
packages/plugins/sdk/src/types.ts
Normal file
File diff suppressed because it is too large
Load Diff
310
packages/plugins/sdk/src/ui/components.ts
Normal file
310
packages/plugins/sdk/src/ui/components.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Shared UI component declarations for plugin frontends.
|
||||
*
|
||||
* These components are exported from `@paperclipai/plugin-sdk/ui` and are
|
||||
* provided by the host at runtime. They match the host's design tokens and
|
||||
* visual language, reducing the boilerplate needed to build consistent plugin UIs.
|
||||
*
|
||||
* **Plugins are not required to use these components.** They exist to reduce
|
||||
* boilerplate and keep visual consistency. A plugin may render entirely custom
|
||||
* UI using any React component library.
|
||||
*
|
||||
* Component implementations are provided by the host — plugin bundles contain
|
||||
* only the type declarations; the runtime implementations are injected via the
|
||||
* host module registry.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components In `@paperclipai/plugin-sdk/ui`
|
||||
*/
|
||||
|
||||
import type React from "react";
|
||||
import { renderSdkUiComponent } from "./runtime.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component prop interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A trend value that can accompany a metric.
|
||||
* Positive values indicate upward trends; negative values indicate downward trends.
|
||||
*/
|
||||
export interface MetricTrend {
|
||||
/** Direction of the trend. */
|
||||
direction: "up" | "down" | "flat";
|
||||
/** Percentage change value (e.g. `12.5` for 12.5%). */
|
||||
percentage?: number;
|
||||
}
|
||||
|
||||
/** Props for `MetricCard`. */
|
||||
export interface MetricCardProps {
|
||||
/** Short label describing the metric (e.g. `"Synced Issues"`). */
|
||||
label: string;
|
||||
/** The metric value to display. */
|
||||
value: number | string;
|
||||
/** Optional trend indicator. */
|
||||
trend?: MetricTrend;
|
||||
/** Optional sparkline data (array of numbers, latest last). */
|
||||
sparkline?: number[];
|
||||
/** Optional unit suffix (e.g. `"%"`, `"ms"`). */
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/** Status variants for `StatusBadge`. */
|
||||
export type StatusBadgeVariant = "ok" | "warning" | "error" | "info" | "pending";
|
||||
|
||||
/** Props for `StatusBadge`. */
|
||||
export interface StatusBadgeProps {
|
||||
/** Human-readable label. */
|
||||
label: string;
|
||||
/** Visual variant determining colour. */
|
||||
status: StatusBadgeVariant;
|
||||
}
|
||||
|
||||
/** A single column definition for `DataTable`. */
|
||||
export interface DataTableColumn<T = Record<string, unknown>> {
|
||||
/** Column key, matching a field on the row object. */
|
||||
key: keyof T & string;
|
||||
/** Column header label. */
|
||||
header: string;
|
||||
/** Optional custom cell renderer. */
|
||||
render?: (value: unknown, row: T) => React.ReactNode;
|
||||
/** Whether this column is sortable. */
|
||||
sortable?: boolean;
|
||||
/** CSS width (e.g. `"120px"`, `"20%"`). */
|
||||
width?: string;
|
||||
}
|
||||
|
||||
/** Props for `DataTable`. */
|
||||
export interface DataTableProps<T = Record<string, unknown>> {
|
||||
/** Column definitions. */
|
||||
columns: DataTableColumn<T>[];
|
||||
/** Row data. Each row should have a stable `id` field. */
|
||||
rows: T[];
|
||||
/** Whether the table is currently loading. */
|
||||
loading?: boolean;
|
||||
/** Message shown when `rows` is empty. */
|
||||
emptyMessage?: string;
|
||||
/** Total row count for pagination (if different from `rows.length`). */
|
||||
totalCount?: number;
|
||||
/** Current page (0-based, for pagination). */
|
||||
page?: number;
|
||||
/** Rows per page (for pagination). */
|
||||
pageSize?: number;
|
||||
/** Callback when page changes. */
|
||||
onPageChange?: (page: number) => void;
|
||||
/** Callback when a column header is clicked to sort. */
|
||||
onSort?: (key: string, direction: "asc" | "desc") => void;
|
||||
}
|
||||
|
||||
/** A single data point for `TimeseriesChart`. */
|
||||
export interface TimeseriesDataPoint {
|
||||
/** ISO 8601 timestamp. */
|
||||
timestamp: string;
|
||||
/** Numeric value. */
|
||||
value: number;
|
||||
/** Optional label for the point. */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Props for `TimeseriesChart`. */
|
||||
export interface TimeseriesChartProps {
|
||||
/** Series data. */
|
||||
data: TimeseriesDataPoint[];
|
||||
/** Chart title. */
|
||||
title?: string;
|
||||
/** Y-axis label. */
|
||||
yLabel?: string;
|
||||
/** Chart type. Defaults to `"line"`. */
|
||||
type?: "line" | "bar";
|
||||
/** Height of the chart in pixels. Defaults to `200`. */
|
||||
height?: number;
|
||||
/** Whether the chart is currently loading. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** Props for `MarkdownBlock`. */
|
||||
export interface MarkdownBlockProps {
|
||||
/** Markdown content to render. */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** A single key-value pair for `KeyValueList`. */
|
||||
export interface KeyValuePair {
|
||||
/** Label for the key. */
|
||||
label: string;
|
||||
/** Value to display. May be a string, number, or a React node. */
|
||||
value: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Props for `KeyValueList`. */
|
||||
export interface KeyValueListProps {
|
||||
/** Pairs to render in the list. */
|
||||
pairs: KeyValuePair[];
|
||||
}
|
||||
|
||||
/** A single action button for `ActionBar`. */
|
||||
export interface ActionBarItem {
|
||||
/** Button label. */
|
||||
label: string;
|
||||
/** Action key to call via the plugin bridge. */
|
||||
actionKey: string;
|
||||
/** Optional parameters to pass to the action handler. */
|
||||
params?: Record<string, unknown>;
|
||||
/** Button variant. Defaults to `"default"`. */
|
||||
variant?: "default" | "primary" | "destructive";
|
||||
/** Whether to show a confirmation dialog before executing. */
|
||||
confirm?: boolean;
|
||||
/** Text for the confirmation dialog (used when `confirm` is true). */
|
||||
confirmMessage?: string;
|
||||
}
|
||||
|
||||
/** Props for `ActionBar`. */
|
||||
export interface ActionBarProps {
|
||||
/** Action definitions. */
|
||||
actions: ActionBarItem[];
|
||||
/** Called after an action succeeds. Use to trigger data refresh. */
|
||||
onSuccess?: (actionKey: string, result: unknown) => void;
|
||||
/** Called when an action fails. */
|
||||
onError?: (actionKey: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
/** A single log line for `LogView`. */
|
||||
export interface LogViewEntry {
|
||||
/** ISO 8601 timestamp. */
|
||||
timestamp: string;
|
||||
/** Log level. */
|
||||
level: "info" | "warn" | "error" | "debug";
|
||||
/** Log message. */
|
||||
message: string;
|
||||
/** Optional structured metadata. */
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Props for `LogView`. */
|
||||
export interface LogViewProps {
|
||||
/** Log entries to display. */
|
||||
entries: LogViewEntry[];
|
||||
/** Maximum height of the scrollable container (CSS value). Defaults to `"400px"`. */
|
||||
maxHeight?: string;
|
||||
/** Whether to auto-scroll to the latest entry. */
|
||||
autoScroll?: boolean;
|
||||
/** Whether the log is currently loading. */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** Props for `JsonTree`. */
|
||||
export interface JsonTreeProps {
|
||||
/** The data to render as a collapsible JSON tree. */
|
||||
data: unknown;
|
||||
/** Initial depth to expand. Defaults to `2`. */
|
||||
defaultExpandDepth?: number;
|
||||
}
|
||||
|
||||
/** Props for `Spinner`. */
|
||||
export interface SpinnerProps {
|
||||
/** Size of the spinner. Defaults to `"md"`. */
|
||||
size?: "sm" | "md" | "lg";
|
||||
/** Accessible label for the spinner (used as `aria-label`). */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/** Props for `ErrorBoundary`. */
|
||||
export interface ErrorBoundaryProps {
|
||||
/** Content to render inside the error boundary. */
|
||||
children: React.ReactNode;
|
||||
/** Optional custom fallback to render when an error is caught. */
|
||||
fallback?: React.ReactNode;
|
||||
/** Called when an error is caught, for logging or reporting. */
|
||||
onError?: (error: Error, info: React.ErrorInfo) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component declarations (provided by host at runtime)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// These are declared as ambient values so plugin TypeScript code can import
|
||||
// and use them with full type-checking. The host's module registry provides
|
||||
// the concrete React component implementations at bundle load time.
|
||||
|
||||
/**
|
||||
* Displays a single metric with an optional trend indicator and sparkline.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
function createSdkUiComponent<TProps>(name: string): React.ComponentType<TProps> {
|
||||
return function PaperclipSdkUiComponent(props: TProps) {
|
||||
return renderSdkUiComponent(name, props) as React.ReactNode;
|
||||
};
|
||||
}
|
||||
|
||||
export const MetricCard = createSdkUiComponent<MetricCardProps>("MetricCard");
|
||||
|
||||
/**
|
||||
* Displays an inline status badge (ok / warning / error / info / pending).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const StatusBadge = createSdkUiComponent<StatusBadgeProps>("StatusBadge");
|
||||
|
||||
/**
|
||||
* Sortable, paginated data table.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const DataTable = createSdkUiComponent<DataTableProps>("DataTable");
|
||||
|
||||
/**
|
||||
* Line or bar chart for time-series data.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const TimeseriesChart = createSdkUiComponent<TimeseriesChartProps>("TimeseriesChart");
|
||||
|
||||
/**
|
||||
* Renders Markdown text as HTML.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const MarkdownBlock = createSdkUiComponent<MarkdownBlockProps>("MarkdownBlock");
|
||||
|
||||
/**
|
||||
* Renders a definition-list of label/value pairs.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const KeyValueList = createSdkUiComponent<KeyValueListProps>("KeyValueList");
|
||||
|
||||
/**
|
||||
* Row of action buttons wired to the plugin bridge's `performAction` handlers.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const ActionBar = createSdkUiComponent<ActionBarProps>("ActionBar");
|
||||
|
||||
/**
|
||||
* Scrollable, timestamped log output viewer.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const LogView = createSdkUiComponent<LogViewProps>("LogView");
|
||||
|
||||
/**
|
||||
* Collapsible JSON tree for debugging or raw data inspection.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const JsonTree = createSdkUiComponent<JsonTreeProps>("JsonTree");
|
||||
|
||||
/**
|
||||
* Loading indicator.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Shared Components
|
||||
*/
|
||||
export const Spinner = createSdkUiComponent<SpinnerProps>("Spinner");
|
||||
|
||||
/**
|
||||
* React error boundary that prevents plugin rendering errors from crashing
|
||||
* the host page.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export const ErrorBoundary = createSdkUiComponent<ErrorBoundaryProps>("ErrorBoundary");
|
||||
174
packages/plugins/sdk/src/ui/hooks.ts
Normal file
174
packages/plugins/sdk/src/ui/hooks.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import type {
|
||||
PluginDataResult,
|
||||
PluginActionFn,
|
||||
PluginHostContext,
|
||||
PluginStreamResult,
|
||||
PluginToastFn,
|
||||
} from "./types.js";
|
||||
import { getSdkUiRuntimeValue } from "./runtime.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginData
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch data from the plugin worker's registered `getData` handler.
|
||||
*
|
||||
* Calls `ctx.data.register(key, handler)` in the worker and returns the
|
||||
* result as reactive state. Re-fetches when `params` changes.
|
||||
*
|
||||
* @template T The expected shape of the returned data
|
||||
* @param key - The data key matching the handler registered with `ctx.data.register()`
|
||||
* @param params - Optional parameters forwarded to the handler
|
||||
* @returns `PluginDataResult<T>` with `data`, `loading`, `error`, and `refresh`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function SyncWidget({ context }: PluginWidgetProps) {
|
||||
* const { data, loading, error } = usePluginData<SyncHealth>("sync-health", {
|
||||
* companyId: context.companyId,
|
||||
* });
|
||||
*
|
||||
* if (loading) return <div>Loading…</div>;
|
||||
* if (error) return <div>Error: {error.message}</div>;
|
||||
* return <div>Synced Issues: {data!.syncedCount}</div>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.8 — `getData`
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export function usePluginData<T = unknown>(
|
||||
key: string,
|
||||
params?: Record<string, unknown>,
|
||||
): PluginDataResult<T> {
|
||||
const impl = getSdkUiRuntimeValue<
|
||||
(nextKey: string, nextParams?: Record<string, unknown>) => PluginDataResult<T>
|
||||
>("usePluginData");
|
||||
return impl(key, params);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginAction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get a callable function that invokes the plugin worker's registered
|
||||
* `performAction` handler.
|
||||
*
|
||||
* The returned function is async and throws a `PluginBridgeError` on failure.
|
||||
*
|
||||
* @param key - The action key matching the handler registered with `ctx.actions.register()`
|
||||
* @returns An async function that sends the action to the worker and resolves with the result
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function ResyncButton({ context }: PluginWidgetProps) {
|
||||
* const resync = usePluginAction("resync");
|
||||
* const [error, setError] = useState<string | null>(null);
|
||||
*
|
||||
* async function handleClick() {
|
||||
* try {
|
||||
* await resync({ companyId: context.companyId });
|
||||
* } catch (err) {
|
||||
* setError((err as PluginBridgeError).message);
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* return <button onClick={handleClick}>Resync Now</button>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §13.9 — `performAction`
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export function usePluginAction(key: string): PluginActionFn {
|
||||
const impl = getSdkUiRuntimeValue<(nextKey: string) => PluginActionFn>("usePluginAction");
|
||||
return impl(key);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useHostContext
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read the current host context (active company, project, entity, user).
|
||||
*
|
||||
* Use this to know which context the plugin component is being rendered in
|
||||
* so you can scope data requests and actions accordingly.
|
||||
*
|
||||
* @returns The current `PluginHostContext`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function IssueTab() {
|
||||
* const { companyId, entityId } = useHostContext();
|
||||
* const { data } = usePluginData("linear-link", { issueId: entityId });
|
||||
* return <div>{data?.linearIssueUrl}</div>;
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export function useHostContext(): PluginHostContext {
|
||||
const impl = getSdkUiRuntimeValue<() => PluginHostContext>("useHostContext");
|
||||
return impl();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginStream
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Subscribe to a real-time event stream pushed from the plugin worker.
|
||||
*
|
||||
* Opens an SSE connection to `GET /api/plugins/:pluginId/bridge/stream/:channel`
|
||||
* and accumulates events as they arrive. The worker pushes events using
|
||||
* `ctx.streams.emit(channel, event)`.
|
||||
*
|
||||
* @template T The expected shape of each streamed event
|
||||
* @param channel - The stream channel name (must match what the worker uses in `ctx.streams.emit`)
|
||||
* @param options - Optional configuration for the stream
|
||||
* @returns `PluginStreamResult<T>` with `events`, `lastEvent`, connection status, and `close()`
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function ChatMessages() {
|
||||
* const { events, connected, close } = usePluginStream<ChatToken>("chat-stream");
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {events.map((e, i) => <span key={i}>{e.text}</span>)}
|
||||
* {connected && <span className="pulse" />}
|
||||
* <button onClick={close}>Stop</button>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
|
||||
*/
|
||||
export function usePluginStream<T = unknown>(
|
||||
channel: string,
|
||||
options?: { companyId?: string },
|
||||
): PluginStreamResult<T> {
|
||||
const impl = getSdkUiRuntimeValue<
|
||||
(nextChannel: string, nextOptions?: { companyId?: string }) => PluginStreamResult<T>
|
||||
>("usePluginStream");
|
||||
return impl(channel, options);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginToast
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Trigger a host toast notification from plugin UI.
|
||||
*
|
||||
* This lets plugin pages and widgets surface user-facing feedback through the
|
||||
* same toast system as the host app without reaching into host internals.
|
||||
*/
|
||||
export function usePluginToast(): PluginToastFn {
|
||||
const impl = getSdkUiRuntimeValue<() => PluginToastFn>("usePluginToast");
|
||||
return impl();
|
||||
}
|
||||
87
packages/plugins/sdk/src/ui/index.ts
Normal file
87
packages/plugins/sdk/src/ui/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* `@paperclipai/plugin-sdk/ui` — Paperclip plugin UI SDK.
|
||||
*
|
||||
* Import this subpath from plugin UI bundles (React components that run in
|
||||
* the host frontend). Do **not** import this from plugin worker code.
|
||||
*
|
||||
* The worker-side SDK is available from `@paperclipai/plugin-sdk` (root).
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK
|
||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Plugin UI bundle entry (dist/ui/index.tsx)
|
||||
* import { usePluginData, usePluginAction } from "@paperclipai/plugin-sdk/ui";
|
||||
* import type { PluginWidgetProps } from "@paperclipai/plugin-sdk/ui";
|
||||
*
|
||||
* export function DashboardWidget({ context }: PluginWidgetProps) {
|
||||
* const { data, loading, error } = usePluginData("sync-health", {
|
||||
* companyId: context.companyId,
|
||||
* });
|
||||
* const resync = usePluginAction("resync");
|
||||
*
|
||||
* if (loading) return <div>Loading…</div>;
|
||||
* if (error) return <div>Error: {error.message}</div>;
|
||||
*
|
||||
* return (
|
||||
* <div style={{ display: "grid", gap: 8 }}>
|
||||
* <strong>Synced Issues</strong>
|
||||
* <div>{data!.syncedCount}</div>
|
||||
* <button onClick={() => resync({ companyId: context.companyId })}>
|
||||
* Resync Now
|
||||
* </button>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bridge hooks for plugin UI components to communicate with the plugin worker.
|
||||
*
|
||||
* - `usePluginData(key, params)` — fetch data from the worker's `getData` handler
|
||||
* - `usePluginAction(key)` — get a callable that invokes the worker's `performAction` handler
|
||||
* - `useHostContext()` — read the current active company, project, entity, and user IDs
|
||||
* - `usePluginStream(channel)` — subscribe to real-time SSE events from the worker
|
||||
*/
|
||||
export {
|
||||
usePluginData,
|
||||
usePluginAction,
|
||||
useHostContext,
|
||||
usePluginStream,
|
||||
usePluginToast,
|
||||
} from "./hooks.js";
|
||||
|
||||
// Bridge error and host context types
|
||||
export type {
|
||||
PluginBridgeError,
|
||||
PluginBridgeErrorCode,
|
||||
PluginHostContext,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
PluginRenderCloseHandler,
|
||||
PluginRenderCloseLifecycle,
|
||||
PluginRenderEnvironmentContext,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
PluginDataResult,
|
||||
PluginActionFn,
|
||||
PluginStreamResult,
|
||||
PluginToastTone,
|
||||
PluginToastAction,
|
||||
PluginToastInput,
|
||||
PluginToastFn,
|
||||
} from "./types.js";
|
||||
|
||||
// Slot component prop interfaces
|
||||
export type {
|
||||
PluginPageProps,
|
||||
PluginWidgetProps,
|
||||
PluginDetailTabProps,
|
||||
PluginSidebarProps,
|
||||
PluginProjectSidebarItemProps,
|
||||
PluginCommentAnnotationProps,
|
||||
PluginCommentContextMenuItemProps,
|
||||
PluginSettingsPageProps,
|
||||
} from "./types.js";
|
||||
51
packages/plugins/sdk/src/ui/runtime.ts
Normal file
51
packages/plugins/sdk/src/ui/runtime.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
type PluginBridgeRegistry = {
|
||||
react?: {
|
||||
createElement?: (type: unknown, props?: Record<string, unknown> | null) => unknown;
|
||||
} | null;
|
||||
sdkUi?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
type GlobalBridge = typeof globalThis & {
|
||||
__paperclipPluginBridge__?: PluginBridgeRegistry;
|
||||
};
|
||||
|
||||
function getBridgeRegistry(): PluginBridgeRegistry | undefined {
|
||||
return (globalThis as GlobalBridge).__paperclipPluginBridge__;
|
||||
}
|
||||
|
||||
function missingBridgeValueError(name: string): Error {
|
||||
return new Error(
|
||||
`Paperclip plugin UI runtime is not initialized for "${name}". ` +
|
||||
'Ensure the host loaded the plugin bridge before rendering this UI module.',
|
||||
);
|
||||
}
|
||||
|
||||
export function getSdkUiRuntimeValue<T>(name: string): T {
|
||||
const value = getBridgeRegistry()?.sdkUi?.[name];
|
||||
if (value === undefined) {
|
||||
throw missingBridgeValueError(name);
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export function renderSdkUiComponent<TProps>(
|
||||
name: string,
|
||||
props: TProps,
|
||||
): unknown {
|
||||
const registry = getBridgeRegistry();
|
||||
const component = registry?.sdkUi?.[name];
|
||||
if (component === undefined) {
|
||||
throw missingBridgeValueError(name);
|
||||
}
|
||||
|
||||
const createElement = registry?.react?.createElement;
|
||||
if (typeof createElement === "function") {
|
||||
return createElement(component, props as Record<string, unknown>);
|
||||
}
|
||||
|
||||
if (typeof component === "function") {
|
||||
return component(props);
|
||||
}
|
||||
|
||||
throw new Error(`Paperclip plugin UI component "${name}" is not callable`);
|
||||
}
|
||||
381
packages/plugins/sdk/src/ui/types.ts
Normal file
381
packages/plugins/sdk/src/ui/types.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Paperclip plugin UI SDK — types for plugin frontend components.
|
||||
*
|
||||
* Plugin UI bundles import from `@paperclipai/plugin-sdk/ui`. This subpath
|
||||
* provides the bridge hooks, component prop interfaces, and error types that
|
||||
* plugin React components use to communicate with the host.
|
||||
*
|
||||
* Plugin UI bundles are loaded as ES modules into designated extension slots.
|
||||
* All communication with the plugin worker goes through the host bridge — plugin
|
||||
* components must NOT access host internals or call host APIs directly.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
* @see PLUGIN_SPEC.md §19.0.1 — Plugin UI SDK
|
||||
* @see PLUGIN_SPEC.md §29.2 — SDK Versioning
|
||||
*/
|
||||
|
||||
import type {
|
||||
PluginBridgeErrorCode,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
} from "@paperclipai/shared";
|
||||
import type {
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
} from "../protocol.js";
|
||||
|
||||
// Re-export PluginBridgeErrorCode for plugin UI authors
|
||||
export type {
|
||||
PluginBridgeErrorCode,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
} from "@paperclipai/shared";
|
||||
export type {
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginModalBoundsRequest,
|
||||
PluginRenderCloseEvent,
|
||||
} from "../protocol.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bridge error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Structured error returned by the bridge when a UI → worker call fails.
|
||||
*
|
||||
* Plugin components receive this in `usePluginData()` as the `error` field
|
||||
* and may encounter it as a thrown value from `usePluginAction()`.
|
||||
*
|
||||
* Error codes:
|
||||
* - `WORKER_UNAVAILABLE` — plugin worker is not running
|
||||
* - `CAPABILITY_DENIED` — plugin lacks the required capability
|
||||
* - `WORKER_ERROR` — worker returned an error from its handler
|
||||
* - `TIMEOUT` — worker did not respond within the configured timeout
|
||||
* - `UNKNOWN` — unexpected bridge-level failure
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export interface PluginBridgeError {
|
||||
/** Machine-readable error code. */
|
||||
code: PluginBridgeErrorCode;
|
||||
/** Human-readable error message. */
|
||||
message: string;
|
||||
/**
|
||||
* Original error details from the worker, if available.
|
||||
* Only present when `code === "WORKER_ERROR"`.
|
||||
*/
|
||||
details?: unknown;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host context available to all plugin components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Read-only host context passed to every plugin component via `useHostContext()`.
|
||||
*
|
||||
* Plugin components use this to know which company, project, or entity is
|
||||
* currently active so they can scope their data requests accordingly.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export interface PluginHostContext {
|
||||
/** UUID of the currently active company, if any. */
|
||||
companyId: string | null;
|
||||
/** URL prefix for the current company (e.g. `"my-company"`). */
|
||||
companyPrefix: string | null;
|
||||
/** UUID of the currently active project, if any. */
|
||||
projectId: string | null;
|
||||
/** UUID of the current entity (for detail tab contexts), if any. */
|
||||
entityId: string | null;
|
||||
/** Type of the current entity (e.g. `"issue"`, `"agent"`). */
|
||||
entityType: string | null;
|
||||
/**
|
||||
* UUID of the parent entity when rendering nested slots.
|
||||
* For `commentAnnotation` slots this is the issue ID containing the comment.
|
||||
*/
|
||||
parentEntityId?: string | null;
|
||||
/** UUID of the current authenticated user. */
|
||||
userId: string | null;
|
||||
/** Runtime metadata for the host container currently rendering this plugin UI. */
|
||||
renderEnvironment?: PluginRenderEnvironmentContext | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async-capable callback invoked during a host-managed close lifecycle.
|
||||
*/
|
||||
export type PluginRenderCloseHandler = (
|
||||
event: PluginRenderCloseEvent,
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Close lifecycle hooks available when the plugin UI is rendered inside a
|
||||
* host-managed launcher environment.
|
||||
*/
|
||||
export interface PluginRenderCloseLifecycle {
|
||||
/** Register a callback before the host closes the current environment. */
|
||||
onBeforeClose?(handler: PluginRenderCloseHandler): () => void;
|
||||
/** Register a callback after the host closes the current environment. */
|
||||
onClose?(handler: PluginRenderCloseHandler): () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runtime information about the host container currently rendering a plugin UI.
|
||||
*/
|
||||
export interface PluginRenderEnvironmentContext
|
||||
extends PluginLauncherRenderContextSnapshot {
|
||||
/** Optional host callback for requesting new bounds while a modal is open. */
|
||||
requestModalBounds?(request: PluginModalBoundsRequest): Promise<void>;
|
||||
/** Optional close lifecycle callbacks for host-managed overlays. */
|
||||
closeLifecycle?: PluginRenderCloseLifecycle | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slot component prop interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Props passed to a plugin page component.
|
||||
*
|
||||
* A page is a full-page extension at `/plugins/:pluginId` or `/:company/plugins/:pluginId`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.1 — Global Operator Routes
|
||||
* @see PLUGIN_SPEC.md §19.2 — Company-Context Routes
|
||||
*/
|
||||
export interface PluginPageProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin dashboard widget component.
|
||||
*
|
||||
* A dashboard widget is rendered as a card or section on the main dashboard.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.4 — Dashboard Widgets
|
||||
*/
|
||||
export interface PluginWidgetProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin detail tab component.
|
||||
*
|
||||
* A detail tab is rendered as an additional tab on a project, issue, agent,
|
||||
* goal, or run detail page.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.3 — Detail Tabs
|
||||
*/
|
||||
export interface PluginDetailTabProps {
|
||||
/** The current host context, always including `entityId` and `entityType`. */
|
||||
context: PluginHostContext & {
|
||||
entityId: string;
|
||||
entityType: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin sidebar component.
|
||||
*
|
||||
* A sidebar entry adds a link or section to the application sidebar.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.5 — Sidebar Entries
|
||||
*/
|
||||
export interface PluginSidebarProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin project sidebar item component.
|
||||
*
|
||||
* A project sidebar item is rendered **once per project** under that project's
|
||||
* row in the sidebar Projects list. The host passes the current project's id
|
||||
* in `context.entityId` and `context.entityType` is `"project"`.
|
||||
*
|
||||
* Use this slot to add a link (e.g. "Files", "Linear Sync") that navigates to
|
||||
* the project detail with a plugin tab selected: `/projects/:projectRef?tab=plugin:key:slotId`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.5.1 — Project sidebar items
|
||||
*/
|
||||
export interface PluginProjectSidebarItemProps {
|
||||
/** Host context plus entityId (project id) and entityType "project". */
|
||||
context: PluginHostContext & {
|
||||
entityId: string;
|
||||
entityType: "project";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin comment annotation component.
|
||||
*
|
||||
* A comment annotation is rendered below each individual comment in the
|
||||
* issue detail timeline. The host passes the comment ID as `entityId`
|
||||
* and `"comment"` as `entityType`, plus the parent issue ID as
|
||||
* `parentEntityId` so the plugin can scope data fetches to both.
|
||||
*
|
||||
* Use this slot to augment comments with parsed file links, sentiment
|
||||
* badges, inline actions, or any per-comment metadata.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.6 — Comment Annotations
|
||||
*/
|
||||
export interface PluginCommentAnnotationProps {
|
||||
/** Host context with comment and parent issue identifiers. */
|
||||
context: PluginHostContext & {
|
||||
/** UUID of the comment being annotated. */
|
||||
entityId: string;
|
||||
/** Always `"comment"` for comment annotation slots. */
|
||||
entityType: "comment";
|
||||
/** UUID of the parent issue containing this comment. */
|
||||
parentEntityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin comment context menu item component.
|
||||
*
|
||||
* A comment context menu item is rendered in a "more" dropdown menu on
|
||||
* each comment in the issue detail timeline. The host passes the comment
|
||||
* ID as `entityId` and `"comment"` as `entityType`, plus the parent
|
||||
* issue ID as `parentEntityId`.
|
||||
*
|
||||
* Use this slot to add per-comment actions such as "Create sub-issue from
|
||||
* comment", "Translate", "Flag for review", or any custom plugin action.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Comment Context Menu Items
|
||||
*/
|
||||
export interface PluginCommentContextMenuItemProps {
|
||||
/** Host context with comment and parent issue identifiers. */
|
||||
context: PluginHostContext & {
|
||||
/** UUID of the comment this menu item acts on. */
|
||||
entityId: string;
|
||||
/** Always `"comment"` for comment context menu item slots. */
|
||||
entityType: "comment";
|
||||
/** UUID of the parent issue containing this comment. */
|
||||
parentEntityId: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Props passed to a plugin settings page component.
|
||||
*
|
||||
* Overrides the auto-generated JSON Schema form when the plugin declares
|
||||
* a `settingsPage` UI slot. The component is responsible for reading and
|
||||
* writing config through the bridge.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.8 — Plugin Settings UI
|
||||
*/
|
||||
export interface PluginSettingsPageProps {
|
||||
/** The current host context. */
|
||||
context: PluginHostContext;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginData hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return value of `usePluginData(key, params)`.
|
||||
*
|
||||
* Mirrors a standard async data-fetching hook pattern:
|
||||
* exactly one of `data` or `error` is non-null at any time (unless `loading`).
|
||||
*
|
||||
* @template T The type of the data returned by the worker handler
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export interface PluginDataResult<T = unknown> {
|
||||
/** The data returned by the worker's `getData` handler. `null` while loading or on error. */
|
||||
data: T | null;
|
||||
/** `true` while the initial request or a refresh is in flight. */
|
||||
loading: boolean;
|
||||
/** Bridge error if the request failed. `null` on success or while loading. */
|
||||
error: PluginBridgeError | null;
|
||||
/**
|
||||
* Manually trigger a data refresh.
|
||||
* Useful for poll-based updates or post-action refreshes.
|
||||
*/
|
||||
refresh(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginToast hook types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PluginToastTone = "info" | "success" | "warn" | "error";
|
||||
|
||||
export interface PluginToastAction {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface PluginToastInput {
|
||||
id?: string;
|
||||
dedupeKey?: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
tone?: PluginToastTone;
|
||||
ttlMs?: number;
|
||||
action?: PluginToastAction;
|
||||
}
|
||||
|
||||
export type PluginToastFn = (input: PluginToastInput) => string | null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginAction hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginStream hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return value of `usePluginStream<T>(channel)`.
|
||||
*
|
||||
* Provides a growing array of events pushed from the plugin worker via SSE,
|
||||
* plus connection status metadata.
|
||||
*
|
||||
* @template T The type of each event emitted by the worker
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.8 — Real-Time Streaming
|
||||
*/
|
||||
export interface PluginStreamResult<T = unknown> {
|
||||
/** All events received so far, in arrival order. */
|
||||
events: T[];
|
||||
/** The most recently received event, or `null` if none yet. */
|
||||
lastEvent: T | null;
|
||||
/** `true` while the SSE connection is being established. */
|
||||
connecting: boolean;
|
||||
/** `true` once the SSE connection is open and receiving events. */
|
||||
connected: boolean;
|
||||
/** Error if the SSE connection failed or was interrupted. `null` otherwise. */
|
||||
error: Error | null;
|
||||
/** Close the SSE connection and stop receiving events. */
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// usePluginAction hook return type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return value of `usePluginAction(key)`.
|
||||
*
|
||||
* Returns an async function that, when called, sends an action request
|
||||
* to the worker's `performAction` handler and returns the result.
|
||||
*
|
||||
* On failure, the async function throws a `PluginBridgeError`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const resync = usePluginAction("resync");
|
||||
* <button onClick={() => resync({ companyId }).catch(err => console.error(err))}>
|
||||
* Resync Now
|
||||
* </button>
|
||||
* ```
|
||||
*/
|
||||
export type PluginActionFn = (params?: Record<string, unknown>) => Promise<unknown>;
|
||||
1201
packages/plugins/sdk/src/worker-rpc-host.ts
Normal file
1201
packages/plugins/sdk/src/worker-rpc-host.ts
Normal file
File diff suppressed because it is too large
Load Diff
9
packages/plugins/sdk/tsconfig.json
Normal file
9
packages/plugins/sdk/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"types": ["node", "react"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -26,7 +26,6 @@ export const AGENT_ADAPTER_TYPES = [
|
||||
"http",
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
"gemini_local",
|
||||
"opencode_local",
|
||||
"pi_local",
|
||||
"cursor",
|
||||
@@ -213,6 +212,9 @@ export const LIVE_EVENT_TYPES = [
|
||||
"heartbeat.run.log",
|
||||
"agent.status",
|
||||
"activity.logged",
|
||||
"plugin.ui.updated",
|
||||
"plugin.worker.crashed",
|
||||
"plugin.worker.restarted",
|
||||
] as const;
|
||||
export type LiveEventType = (typeof LIVE_EVENT_TYPES)[number];
|
||||
|
||||
@@ -246,3 +248,336 @@ export const PERMISSION_KEYS = [
|
||||
"joins:approve",
|
||||
] as const;
|
||||
export type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The current version of the Plugin API contract.
|
||||
*
|
||||
* Increment this value whenever a breaking change is made to the plugin API
|
||||
* so that the host can reject incompatible plugin manifests.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §4 — Versioning
|
||||
*/
|
||||
export const PLUGIN_API_VERSION = 1 as const;
|
||||
|
||||
/**
|
||||
* Lifecycle statuses for an installed plugin.
|
||||
*
|
||||
* State machine: installed → ready | error, ready → disabled | error | upgrade_pending | uninstalled,
|
||||
* disabled → ready | uninstalled, error → ready | uninstalled,
|
||||
* upgrade_pending → ready | error | uninstalled, uninstalled → installed (reinstall).
|
||||
*
|
||||
* @see {@link PluginStatus} — inferred union type
|
||||
* @see PLUGIN_SPEC.md §21.3 `plugins.status`
|
||||
*/
|
||||
export const PLUGIN_STATUSES = [
|
||||
"installed",
|
||||
"ready",
|
||||
"disabled",
|
||||
"error",
|
||||
"upgrade_pending",
|
||||
"uninstalled",
|
||||
] as const;
|
||||
export type PluginStatus = (typeof PLUGIN_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Plugin classification categories. A plugin declares one or more categories
|
||||
* in its manifest to describe its primary purpose.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §6.2
|
||||
*/
|
||||
export const PLUGIN_CATEGORIES = [
|
||||
"connector",
|
||||
"workspace",
|
||||
"automation",
|
||||
"ui",
|
||||
] as const;
|
||||
export type PluginCategory = (typeof PLUGIN_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* Named permissions the host grants to a plugin. Plugins declare required
|
||||
* capabilities in their manifest; the host enforces them at runtime via the
|
||||
* plugin capability validator.
|
||||
*
|
||||
* Grouped into: Data Read, Data Write, Plugin State, Runtime/Integration,
|
||||
* Agent Tools, and UI.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §15 — Capability Model
|
||||
*/
|
||||
export const PLUGIN_CAPABILITIES = [
|
||||
// Data Read
|
||||
"companies.read",
|
||||
"projects.read",
|
||||
"project.workspaces.read",
|
||||
"issues.read",
|
||||
"issue.comments.read",
|
||||
"agents.read",
|
||||
"goals.read",
|
||||
"goals.create",
|
||||
"goals.update",
|
||||
"activity.read",
|
||||
"costs.read",
|
||||
// Data Write
|
||||
"issues.create",
|
||||
"issues.update",
|
||||
"issue.comments.create",
|
||||
"agents.pause",
|
||||
"agents.resume",
|
||||
"agents.invoke",
|
||||
"agent.sessions.create",
|
||||
"agent.sessions.list",
|
||||
"agent.sessions.send",
|
||||
"agent.sessions.close",
|
||||
"activity.log.write",
|
||||
"metrics.write",
|
||||
// Plugin State
|
||||
"plugin.state.read",
|
||||
"plugin.state.write",
|
||||
// Runtime / Integration
|
||||
"events.subscribe",
|
||||
"events.emit",
|
||||
"jobs.schedule",
|
||||
"webhooks.receive",
|
||||
"http.outbound",
|
||||
"secrets.read-ref",
|
||||
// Agent Tools
|
||||
"agent.tools.register",
|
||||
// UI
|
||||
"instance.settings.register",
|
||||
"ui.sidebar.register",
|
||||
"ui.page.register",
|
||||
"ui.detailTab.register",
|
||||
"ui.dashboardWidget.register",
|
||||
"ui.commentAnnotation.register",
|
||||
"ui.action.register",
|
||||
] as const;
|
||||
export type PluginCapability = (typeof PLUGIN_CAPABILITIES)[number];
|
||||
|
||||
/**
|
||||
* UI extension slot types. Each slot type corresponds to a mount point in the
|
||||
* Paperclip UI where plugin components can be rendered.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export const PLUGIN_UI_SLOT_TYPES = [
|
||||
"page",
|
||||
"detailTab",
|
||||
"taskDetailView",
|
||||
"dashboardWidget",
|
||||
"sidebar",
|
||||
"sidebarPanel",
|
||||
"projectSidebarItem",
|
||||
"toolbarButton",
|
||||
"contextMenuItem",
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"settingsPage",
|
||||
] as const;
|
||||
export type PluginUiSlotType = (typeof PLUGIN_UI_SLOT_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Reserved company-scoped route segments that plugin page routes may not claim.
|
||||
*
|
||||
* These map to first-class host pages under `/:companyPrefix/...`.
|
||||
*/
|
||||
export const PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS = [
|
||||
"dashboard",
|
||||
"onboarding",
|
||||
"companies",
|
||||
"company",
|
||||
"settings",
|
||||
"plugins",
|
||||
"org",
|
||||
"agents",
|
||||
"projects",
|
||||
"issues",
|
||||
"goals",
|
||||
"approvals",
|
||||
"costs",
|
||||
"activity",
|
||||
"inbox",
|
||||
"design-guide",
|
||||
"tests",
|
||||
] as const;
|
||||
export type PluginReservedCompanyRouteSegment =
|
||||
(typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number];
|
||||
|
||||
/**
|
||||
* Launcher placement zones describe where a plugin-owned launcher can appear
|
||||
* in the host UI. These are intentionally aligned with current slot surfaces
|
||||
* so manifest authors can describe launch intent without coupling to a single
|
||||
* component implementation detail.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_PLACEMENT_ZONES = [
|
||||
"page",
|
||||
"detailTab",
|
||||
"taskDetailView",
|
||||
"dashboardWidget",
|
||||
"sidebar",
|
||||
"sidebarPanel",
|
||||
"projectSidebarItem",
|
||||
"toolbarButton",
|
||||
"contextMenuItem",
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"settingsPage",
|
||||
] as const;
|
||||
export type PluginLauncherPlacementZone = (typeof PLUGIN_LAUNCHER_PLACEMENT_ZONES)[number];
|
||||
|
||||
/**
|
||||
* Launcher action kinds describe what the launcher does when activated.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_ACTIONS = [
|
||||
"navigate",
|
||||
"openModal",
|
||||
"openDrawer",
|
||||
"openPopover",
|
||||
"performAction",
|
||||
"deepLink",
|
||||
] as const;
|
||||
export type PluginLauncherAction = (typeof PLUGIN_LAUNCHER_ACTIONS)[number];
|
||||
|
||||
/**
|
||||
* Optional size hints the host can use when rendering plugin-owned launcher
|
||||
* destinations such as overlays, drawers, or full page handoffs.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_BOUNDS = [
|
||||
"inline",
|
||||
"compact",
|
||||
"default",
|
||||
"wide",
|
||||
"full",
|
||||
] as const;
|
||||
export type PluginLauncherBounds = (typeof PLUGIN_LAUNCHER_BOUNDS)[number];
|
||||
|
||||
/**
|
||||
* Render environments describe the container a launcher expects after it is
|
||||
* activated. The current host may map these to concrete UI primitives.
|
||||
*/
|
||||
export const PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS = [
|
||||
"hostInline",
|
||||
"hostOverlay",
|
||||
"hostRoute",
|
||||
"external",
|
||||
"iframe",
|
||||
] as const;
|
||||
export type PluginLauncherRenderEnvironment =
|
||||
(typeof PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS)[number];
|
||||
|
||||
/**
|
||||
* Entity types that a `detailTab` UI slot can attach to.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.3 — Detail Tabs
|
||||
*/
|
||||
export const PLUGIN_UI_SLOT_ENTITY_TYPES = [
|
||||
"project",
|
||||
"issue",
|
||||
"agent",
|
||||
"goal",
|
||||
"run",
|
||||
"comment",
|
||||
] as const;
|
||||
export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Scope kinds for plugin state storage. Determines the granularity at which
|
||||
* a plugin stores key-value state data.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 `plugin_state.scope_kind`
|
||||
*/
|
||||
export const PLUGIN_STATE_SCOPE_KINDS = [
|
||||
"instance",
|
||||
"company",
|
||||
"project",
|
||||
"project_workspace",
|
||||
"agent",
|
||||
"issue",
|
||||
"goal",
|
||||
"run",
|
||||
] as const;
|
||||
export type PluginStateScopeKind = (typeof PLUGIN_STATE_SCOPE_KINDS)[number];
|
||||
|
||||
/** Statuses for a plugin's scheduled job definition. */
|
||||
export const PLUGIN_JOB_STATUSES = [
|
||||
"active",
|
||||
"paused",
|
||||
"failed",
|
||||
] as const;
|
||||
export type PluginJobStatus = (typeof PLUGIN_JOB_STATUSES)[number];
|
||||
|
||||
/** Statuses for individual job run executions. */
|
||||
export const PLUGIN_JOB_RUN_STATUSES = [
|
||||
"pending",
|
||||
"queued",
|
||||
"running",
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
] as const;
|
||||
export type PluginJobRunStatus = (typeof PLUGIN_JOB_RUN_STATUSES)[number];
|
||||
|
||||
/** What triggered a particular job run. */
|
||||
export const PLUGIN_JOB_RUN_TRIGGERS = [
|
||||
"schedule",
|
||||
"manual",
|
||||
"retry",
|
||||
] as const;
|
||||
export type PluginJobRunTrigger = (typeof PLUGIN_JOB_RUN_TRIGGERS)[number];
|
||||
|
||||
/** Statuses for inbound webhook deliveries. */
|
||||
export const PLUGIN_WEBHOOK_DELIVERY_STATUSES = [
|
||||
"pending",
|
||||
"success",
|
||||
"failed",
|
||||
] as const;
|
||||
export type PluginWebhookDeliveryStatus = (typeof PLUGIN_WEBHOOK_DELIVERY_STATUSES)[number];
|
||||
|
||||
/**
|
||||
* Core domain event types that plugins can subscribe to via the
|
||||
* `events.subscribe` capability.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §16 — Event System
|
||||
*/
|
||||
export const PLUGIN_EVENT_TYPES = [
|
||||
"company.created",
|
||||
"company.updated",
|
||||
"project.created",
|
||||
"project.updated",
|
||||
"project.workspace_created",
|
||||
"project.workspace_updated",
|
||||
"project.workspace_deleted",
|
||||
"issue.created",
|
||||
"issue.updated",
|
||||
"issue.comment.created",
|
||||
"agent.created",
|
||||
"agent.updated",
|
||||
"agent.status_changed",
|
||||
"agent.run.started",
|
||||
"agent.run.finished",
|
||||
"agent.run.failed",
|
||||
"agent.run.cancelled",
|
||||
"goal.created",
|
||||
"goal.updated",
|
||||
"approval.created",
|
||||
"approval.decided",
|
||||
"cost_event.created",
|
||||
"activity.logged",
|
||||
] as const;
|
||||
export type PluginEventType = (typeof PLUGIN_EVENT_TYPES)[number];
|
||||
|
||||
/**
|
||||
* Error codes returned by the plugin bridge when a UI → worker call fails.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge
|
||||
*/
|
||||
export const PLUGIN_BRIDGE_ERROR_CODES = [
|
||||
"WORKER_UNAVAILABLE",
|
||||
"CAPABILITY_DENIED",
|
||||
"WORKER_ERROR",
|
||||
"TIMEOUT",
|
||||
"UNKNOWN",
|
||||
] as const;
|
||||
export type PluginBridgeErrorCode = (typeof PLUGIN_BRIDGE_ERROR_CODES)[number];
|
||||
|
||||
@@ -31,6 +31,23 @@ export {
|
||||
JOIN_REQUEST_TYPES,
|
||||
JOIN_REQUEST_STATUSES,
|
||||
PERMISSION_KEYS,
|
||||
PLUGIN_API_VERSION,
|
||||
PLUGIN_STATUSES,
|
||||
PLUGIN_CATEGORIES,
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
|
||||
PLUGIN_LAUNCHER_ACTIONS,
|
||||
PLUGIN_LAUNCHER_BOUNDS,
|
||||
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
|
||||
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,
|
||||
type CompanyStatus,
|
||||
type DeploymentMode,
|
||||
type DeploymentExposure,
|
||||
@@ -61,6 +78,22 @@ export {
|
||||
type JoinRequestType,
|
||||
type JoinRequestStatus,
|
||||
type PermissionKey,
|
||||
type PluginStatus,
|
||||
type PluginCategory,
|
||||
type PluginCapability,
|
||||
type PluginUiSlotType,
|
||||
type PluginUiSlotEntityType,
|
||||
type PluginLauncherPlacementZone,
|
||||
type PluginLauncherAction,
|
||||
type PluginLauncherBounds,
|
||||
type PluginLauncherRenderEnvironment,
|
||||
type PluginStateScopeKind,
|
||||
type PluginJobStatus,
|
||||
type PluginJobRunStatus,
|
||||
type PluginJobRunTrigger,
|
||||
type PluginWebhookDeliveryStatus,
|
||||
type PluginEventType,
|
||||
type PluginBridgeErrorCode,
|
||||
} from "./constants.js";
|
||||
|
||||
export type {
|
||||
@@ -134,6 +167,26 @@ export type {
|
||||
AgentEnvConfig,
|
||||
CompanySecret,
|
||||
SecretProviderDescriptor,
|
||||
JsonSchema,
|
||||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginLauncherDeclaration,
|
||||
PluginMinimumHostVersion,
|
||||
PluginUiDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginStateRecord,
|
||||
PluginConfig,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
PluginJobRunRecord,
|
||||
PluginWebhookDeliveryRecord,
|
||||
} from "./types/index.js";
|
||||
|
||||
export {
|
||||
@@ -248,6 +301,39 @@ export {
|
||||
type CompanyPortabilityExport,
|
||||
type CompanyPortabilityPreview,
|
||||
type CompanyPortabilityImport,
|
||||
jsonSchemaSchema,
|
||||
pluginJobDeclarationSchema,
|
||||
pluginWebhookDeclarationSchema,
|
||||
pluginToolDeclarationSchema,
|
||||
pluginUiSlotDeclarationSchema,
|
||||
pluginLauncherActionDeclarationSchema,
|
||||
pluginLauncherRenderDeclarationSchema,
|
||||
pluginLauncherDeclarationSchema,
|
||||
pluginManifestV1Schema,
|
||||
installPluginSchema,
|
||||
upsertPluginConfigSchema,
|
||||
patchPluginConfigSchema,
|
||||
updatePluginStatusSchema,
|
||||
uninstallPluginSchema,
|
||||
pluginStateScopeKeySchema,
|
||||
setPluginStateSchema,
|
||||
listPluginStateSchema,
|
||||
type PluginJobDeclarationInput,
|
||||
type PluginWebhookDeclarationInput,
|
||||
type PluginToolDeclarationInput,
|
||||
type PluginUiSlotDeclarationInput,
|
||||
type PluginLauncherActionDeclarationInput,
|
||||
type PluginLauncherRenderDeclarationInput,
|
||||
type PluginLauncherDeclarationInput,
|
||||
type PluginManifestV1Input,
|
||||
type InstallPlugin,
|
||||
type UpsertPluginConfig,
|
||||
type PatchPluginConfig,
|
||||
type UpdatePluginStatus,
|
||||
type UninstallPlugin,
|
||||
type PluginStateScopeKey,
|
||||
type SetPluginState,
|
||||
type ListPluginState,
|
||||
} from "./validators/index.js";
|
||||
|
||||
export { API_PREFIX, API } from "./api.js";
|
||||
|
||||
@@ -84,3 +84,25 @@ export type {
|
||||
CompanyPortabilityImportResult,
|
||||
CompanyPortabilityExportRequest,
|
||||
} from "./company-portability.js";
|
||||
export type {
|
||||
JsonSchema,
|
||||
PluginJobDeclaration,
|
||||
PluginWebhookDeclaration,
|
||||
PluginToolDeclaration,
|
||||
PluginUiSlotDeclaration,
|
||||
PluginLauncherActionDeclaration,
|
||||
PluginLauncherRenderDeclaration,
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginLauncherDeclaration,
|
||||
PluginMinimumHostVersion,
|
||||
PluginUiDeclaration,
|
||||
PaperclipPluginManifestV1,
|
||||
PluginRecord,
|
||||
PluginStateRecord,
|
||||
PluginConfig,
|
||||
PluginEntityRecord,
|
||||
PluginEntityQuery,
|
||||
PluginJobRecord,
|
||||
PluginJobRunRecord,
|
||||
PluginWebhookDeliveryRecord,
|
||||
} from "./plugin.js";
|
||||
|
||||
489
packages/shared/src/types/plugin.ts
Normal file
489
packages/shared/src/types/plugin.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import type {
|
||||
PluginStatus,
|
||||
PluginCategory,
|
||||
PluginCapability,
|
||||
PluginUiSlotType,
|
||||
PluginUiSlotEntityType,
|
||||
PluginStateScopeKind,
|
||||
PluginLauncherPlacementZone,
|
||||
PluginLauncherAction,
|
||||
PluginLauncherBounds,
|
||||
PluginLauncherRenderEnvironment,
|
||||
} from "../constants.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – plugins declare config schemas as JSON Schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A JSON Schema object used for plugin config schemas and tool parameter schemas.
|
||||
* Plugins provide these as plain JSON Schema compatible objects.
|
||||
*/
|
||||
export type JsonSchema = Record<string, unknown>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest sub-types — nested declarations within PaperclipPluginManifestV1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Declares a scheduled job a plugin can run.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||
*/
|
||||
export interface PluginJobDeclaration {
|
||||
/** Stable identifier for this job, unique within the plugin. */
|
||||
jobKey: string;
|
||||
/** Human-readable name shown in the operator UI. */
|
||||
displayName: string;
|
||||
/** Optional description of what the job does. */
|
||||
description?: string;
|
||||
/** Cron expression for the schedule (e.g. "star/15 star star star star" or "0 * * * *"). */
|
||||
schedule?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a webhook endpoint the plugin can receive.
|
||||
* Route: `POST /api/plugins/:pluginId/webhooks/:endpointKey`
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §18 — Webhooks
|
||||
*/
|
||||
export interface PluginWebhookDeclaration {
|
||||
/** Stable identifier for this endpoint, unique within the plugin. */
|
||||
endpointKey: string;
|
||||
/** Human-readable name shown in the operator UI. */
|
||||
displayName: string;
|
||||
/** Optional description of what this webhook handles. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares an agent tool contributed by the plugin. Tools are namespaced
|
||||
* by plugin ID at runtime (e.g. `linear:search-issues`).
|
||||
*
|
||||
* Requires the `agent.tools.register` capability.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §11 — Agent Tools
|
||||
*/
|
||||
export interface PluginToolDeclaration {
|
||||
/** Tool name, unique within the plugin. Namespaced by plugin ID at runtime. */
|
||||
name: string;
|
||||
/** Human-readable name shown to agents and in the UI. */
|
||||
displayName: string;
|
||||
/** Description provided to the agent so it knows when to use this tool. */
|
||||
description: string;
|
||||
/** JSON Schema describing the tool's input parameters. */
|
||||
parametersSchema: JsonSchema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a UI extension slot the plugin fills with a React component.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export interface PluginUiSlotDeclaration {
|
||||
/** The type of UI mount point (page, detailTab, taskDetailView, toolbarButton, etc.). */
|
||||
type: PluginUiSlotType;
|
||||
/** Unique slot identifier within the plugin. */
|
||||
id: string;
|
||||
/** Human-readable name shown in navigation or tab labels. */
|
||||
displayName: string;
|
||||
/** Which export name in the UI bundle provides this component. */
|
||||
exportName: string;
|
||||
/**
|
||||
* Entity targets for context-sensitive slots.
|
||||
* Required for `detailTab`, `taskDetailView`, and `contextMenuItem`.
|
||||
*/
|
||||
entityTypes?: PluginUiSlotEntityType[];
|
||||
/**
|
||||
* Optional company-scoped route segment for page slots.
|
||||
* Example: `kitchensink` becomes `/:companyPrefix/kitchensink`.
|
||||
*/
|
||||
routePath?: string;
|
||||
/**
|
||||
* Optional ordering hint within a slot surface. Lower numbers appear first.
|
||||
* Defaults to host-defined ordering if omitted.
|
||||
*/
|
||||
order?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes the action triggered by a plugin launcher surface.
|
||||
*/
|
||||
export interface PluginLauncherActionDeclaration {
|
||||
/** What kind of launch behavior the host should perform. */
|
||||
type: PluginLauncherAction;
|
||||
/**
|
||||
* Stable target identifier or URL. The meaning depends on `type`
|
||||
* (for example a route, tab key, action key, or external URL).
|
||||
*/
|
||||
target: string;
|
||||
/** Optional arbitrary parameters passed along to the target. */
|
||||
params?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional render metadata for the destination opened by a launcher.
|
||||
*/
|
||||
export interface PluginLauncherRenderDeclaration {
|
||||
/** High-level container the launcher expects the host to use. */
|
||||
environment: PluginLauncherRenderEnvironment;
|
||||
/** Optional size hint for the destination surface. */
|
||||
bounds?: PluginLauncherBounds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable runtime snapshot of the host launcher/container environment.
|
||||
*/
|
||||
export interface PluginLauncherRenderContextSnapshot {
|
||||
/** The current launcher/container environment selected by the host. */
|
||||
environment: PluginLauncherRenderEnvironment | null;
|
||||
/** Launcher id that opened this surface, if any. */
|
||||
launcherId: string | null;
|
||||
/** Current host-applied bounds hint for the environment, if any. */
|
||||
bounds: PluginLauncherBounds | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a plugin launcher surface independent of the low-level slot
|
||||
* implementation that mounts it.
|
||||
*/
|
||||
export interface PluginLauncherDeclaration {
|
||||
/** Stable identifier for this launcher, unique within the plugin. */
|
||||
id: string;
|
||||
/** Human-readable label shown for the launcher. */
|
||||
displayName: string;
|
||||
/** Optional description for operator-facing docs or future UI affordances. */
|
||||
description?: string;
|
||||
/** Where in the host UI this launcher should be placed. */
|
||||
placementZone: PluginLauncherPlacementZone;
|
||||
/** Optional export name in the UI bundle when the launcher has custom UI. */
|
||||
exportName?: string;
|
||||
/**
|
||||
* Optional entity targeting for context-sensitive launcher zones.
|
||||
* Reuses the same entity union as UI slots for consistency.
|
||||
*/
|
||||
entityTypes?: PluginUiSlotEntityType[];
|
||||
/** Optional ordering hint within the placement zone. */
|
||||
order?: number;
|
||||
/** What should happen when the launcher is activated. */
|
||||
action: PluginLauncherActionDeclaration;
|
||||
/** Optional render/container hints for the launched destination. */
|
||||
render?: PluginLauncherRenderDeclaration;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-bound semver requirement for the Paperclip host.
|
||||
*
|
||||
* The host should reject installation when its running version is lower than
|
||||
* the declared minimum.
|
||||
*/
|
||||
export type PluginMinimumHostVersion = string;
|
||||
|
||||
/**
|
||||
* Groups plugin UI declarations that are served from the shared UI bundle
|
||||
* root declared in `entrypoints.ui`.
|
||||
*/
|
||||
export interface PluginUiDeclaration {
|
||||
/** UI extension slots this plugin fills. */
|
||||
slots?: PluginUiSlotDeclaration[];
|
||||
/** Declarative launcher metadata for host-mounted plugin entry points. */
|
||||
launchers?: PluginLauncherDeclaration[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Manifest V1
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The manifest shape every plugin package must export.
|
||||
* See PLUGIN_SPEC.md §10.1 for the normative definition.
|
||||
*/
|
||||
export interface PaperclipPluginManifestV1 {
|
||||
/** Globally unique plugin identifier (e.g. `"acme.linear-sync"`). Must be lowercase alphanumeric with dots, hyphens, or underscores. */
|
||||
id: string;
|
||||
/** Plugin API version. Must be `1` for the current spec. */
|
||||
apiVersion: 1;
|
||||
/** Semver version of the plugin package (e.g. `"1.2.0"`). */
|
||||
version: string;
|
||||
/** Human-readable name (max 100 chars). */
|
||||
displayName: string;
|
||||
/** Short description (max 500 chars). */
|
||||
description: string;
|
||||
/** Author name (max 200 chars). May include email in angle brackets, e.g. `"Jane Doe <jane@example.com>"`. */
|
||||
author: string;
|
||||
/** One or more categories classifying this plugin. */
|
||||
categories: PluginCategory[];
|
||||
/**
|
||||
* Minimum host version required (semver lower bound).
|
||||
* Preferred generic field for new manifests.
|
||||
*/
|
||||
minimumHostVersion?: PluginMinimumHostVersion;
|
||||
/**
|
||||
* Legacy alias for `minimumHostVersion`.
|
||||
* Kept for backwards compatibility with existing manifests and docs.
|
||||
*/
|
||||
minimumPaperclipVersion?: PluginMinimumHostVersion;
|
||||
/** Capabilities this plugin requires from the host. Enforced at runtime. */
|
||||
capabilities: PluginCapability[];
|
||||
/** Entrypoint paths relative to the package root. */
|
||||
entrypoints: {
|
||||
/** Path to the worker entrypoint (required). */
|
||||
worker: string;
|
||||
/** Path to the UI bundle directory (required when `ui.slots` is declared). */
|
||||
ui?: string;
|
||||
};
|
||||
/** JSON Schema for operator-editable instance configuration. */
|
||||
instanceConfigSchema?: JsonSchema;
|
||||
/** Scheduled jobs this plugin declares. Requires `jobs.schedule` capability. */
|
||||
jobs?: PluginJobDeclaration[];
|
||||
/** Webhook endpoints this plugin declares. Requires `webhooks.receive` capability. */
|
||||
webhooks?: PluginWebhookDeclaration[];
|
||||
/** Agent tools this plugin contributes. Requires `agent.tools.register` capability. */
|
||||
tools?: PluginToolDeclaration[];
|
||||
/**
|
||||
* Legacy top-level launcher declarations.
|
||||
* Prefer `ui.launchers` for new manifests.
|
||||
*/
|
||||
launchers?: PluginLauncherDeclaration[];
|
||||
/** UI bundle declarations. Requires `entrypoints.ui` when populated. */
|
||||
ui?: PluginUiDeclaration;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Record – represents a row in the `plugins` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for an installed plugin as persisted in the `plugins` table.
|
||||
* See PLUGIN_SPEC.md §21.3 for the schema definition.
|
||||
*/
|
||||
export interface PluginRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** Unique key derived from `manifest.id`. Used for lookups. */
|
||||
pluginKey: string;
|
||||
/** npm package name (e.g. `"@acme/plugin-linear"`). */
|
||||
packageName: string;
|
||||
/** Installed semver version. */
|
||||
version: string;
|
||||
/** Plugin API version from the manifest. */
|
||||
apiVersion: number;
|
||||
/** Plugin categories from the manifest. */
|
||||
categories: PluginCategory[];
|
||||
/** Full manifest snapshot persisted at install/upgrade time. */
|
||||
manifestJson: PaperclipPluginManifestV1;
|
||||
/** Current lifecycle status. */
|
||||
status: PluginStatus;
|
||||
/** Deterministic load order (null if not yet assigned). */
|
||||
installOrder: number | null;
|
||||
/** Resolved package path for local-path installs; used to find worker entrypoint. */
|
||||
packagePath: string | null;
|
||||
/** Most recent error message, or operator-provided disable reason. */
|
||||
lastError: string | null;
|
||||
/** Timestamp when the plugin was first installed. */
|
||||
installedAt: Date;
|
||||
/** Timestamp of the most recent status or metadata change. */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin State – represents a row in the `plugin_state` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for a single scoped key-value entry in the `plugin_state` table.
|
||||
* Plugins read and write these entries through `ctx.state` in the SDK.
|
||||
*
|
||||
* The five-part composite key `(pluginId, scopeKind, scopeId, namespace, stateKey)`
|
||||
* uniquely identifies a state entry.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 — `plugin_state`
|
||||
*/
|
||||
export interface PluginStateRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** Granularity of the scope. */
|
||||
scopeKind: PluginStateScopeKind;
|
||||
/**
|
||||
* UUID or text identifier for the scoped object.
|
||||
* `null` for `instance` scope (no associated entity).
|
||||
*/
|
||||
scopeId: string | null;
|
||||
/**
|
||||
* Sub-namespace within the scope to avoid key collisions.
|
||||
* Defaults to `"default"` if not explicitly set by the plugin.
|
||||
*/
|
||||
namespace: string;
|
||||
/** The key for this state entry within the namespace. */
|
||||
stateKey: string;
|
||||
/** Stored JSON value. May be any JSON-serializable type. */
|
||||
valueJson: unknown;
|
||||
/** Timestamp of the most recent write. */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Config – represents a row in the `plugin_config` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for a plugin's instance configuration as persisted in the
|
||||
* `plugin_config` table.
|
||||
* See PLUGIN_SPEC.md §21.3 for the schema definition.
|
||||
*/
|
||||
export interface PluginConfig {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. Unique — each plugin has at most one config row. */
|
||||
pluginId: string;
|
||||
/** Operator-provided configuration values (validated against `instanceConfigSchema`). */
|
||||
configJson: Record<string, unknown>;
|
||||
/** Most recent config validation error, if any. */
|
||||
lastError: string | null;
|
||||
/** Timestamp when the config row was created. */
|
||||
createdAt: Date;
|
||||
/** Timestamp of the most recent config update. */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Query filter for `ctx.entities.list`.
|
||||
*/
|
||||
export interface PluginEntityQuery {
|
||||
/** Optional filter by entity type (e.g. 'project', 'issue'). */
|
||||
entityType?: string;
|
||||
/** Optional filter by external system identifier. */
|
||||
externalId?: string;
|
||||
/** Maximum number of records to return. Defaults to 100. */
|
||||
limit?: number;
|
||||
/** Number of records to skip. Defaults to 0. */
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Entity – represents a row in the `plugin_entities` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for an external entity mapping as persisted in the `plugin_entities` table.
|
||||
*/
|
||||
export interface PluginEntityRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** Plugin-defined entity type. */
|
||||
entityType: string;
|
||||
/** Scope where this entity lives. */
|
||||
scopeKind: PluginStateScopeKind;
|
||||
/** UUID or text identifier for the scoped object. */
|
||||
scopeId: string | null;
|
||||
/** External identifier in the remote system. */
|
||||
externalId: string | null;
|
||||
/** Human-readable title. */
|
||||
title: string | null;
|
||||
/** Optional status string. */
|
||||
status: string | null;
|
||||
/** Full entity data blob. */
|
||||
data: Record<string, unknown>;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
createdAt: Date;
|
||||
/** ISO 8601 last-updated timestamp. */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Job – represents a row in the `plugin_jobs` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for a registered plugin job as persisted in the `plugin_jobs` table.
|
||||
*/
|
||||
export interface PluginJobRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** Job key matching the manifest declaration. */
|
||||
jobKey: string;
|
||||
/** Cron expression for the schedule. */
|
||||
schedule: string;
|
||||
/** Current job status. */
|
||||
status: "active" | "paused" | "failed";
|
||||
/** Last time the job was executed. */
|
||||
lastRunAt: Date | null;
|
||||
/** Next scheduled execution time. */
|
||||
nextRunAt: Date | null;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
createdAt: Date;
|
||||
/** ISO 8601 last-updated timestamp. */
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Job Run – represents a row in the `plugin_job_runs` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for a job execution history record.
|
||||
*/
|
||||
export interface PluginJobRunRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugin_jobs.id`. */
|
||||
jobId: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** What triggered this run. */
|
||||
trigger: "schedule" | "manual" | "retry";
|
||||
/** Current run status. */
|
||||
status: "pending" | "queued" | "running" | "succeeded" | "failed" | "cancelled";
|
||||
/** Run duration in milliseconds. */
|
||||
durationMs: number | null;
|
||||
/** Error message if the run failed. */
|
||||
error: string | null;
|
||||
/** Run logs. */
|
||||
logs: string[];
|
||||
/** ISO 8601 start timestamp. */
|
||||
startedAt: Date | null;
|
||||
/** ISO 8601 finish timestamp. */
|
||||
finishedAt: Date | null;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Webhook Delivery – represents a row in the `plugin_webhook_deliveries` table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Domain type for an inbound webhook delivery record.
|
||||
*/
|
||||
export interface PluginWebhookDeliveryRecord {
|
||||
/** UUID primary key. */
|
||||
id: string;
|
||||
/** FK to `plugins.id`. */
|
||||
pluginId: string;
|
||||
/** Webhook endpoint key matching the manifest. */
|
||||
webhookKey: string;
|
||||
/** External identifier from the remote system. */
|
||||
externalId: string | null;
|
||||
/** Delivery status. */
|
||||
status: "pending" | "success" | "failed";
|
||||
/** Processing duration in milliseconds. */
|
||||
durationMs: number | null;
|
||||
/** Error message if processing failed. */
|
||||
error: string | null;
|
||||
/** Webhook payload. */
|
||||
payload: Record<string, unknown>;
|
||||
/** Webhook headers. */
|
||||
headers: Record<string, string>;
|
||||
/** ISO 8601 start timestamp. */
|
||||
startedAt: Date | null;
|
||||
/** ISO 8601 finish timestamp. */
|
||||
finishedAt: Date | null;
|
||||
/** ISO 8601 creation timestamp. */
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -142,3 +142,39 @@ export {
|
||||
type UpdateMemberPermissions,
|
||||
type UpdateUserCompanyAccess,
|
||||
} from "./access.js";
|
||||
|
||||
export {
|
||||
jsonSchemaSchema,
|
||||
pluginJobDeclarationSchema,
|
||||
pluginWebhookDeclarationSchema,
|
||||
pluginToolDeclarationSchema,
|
||||
pluginUiSlotDeclarationSchema,
|
||||
pluginLauncherActionDeclarationSchema,
|
||||
pluginLauncherRenderDeclarationSchema,
|
||||
pluginLauncherDeclarationSchema,
|
||||
pluginManifestV1Schema,
|
||||
installPluginSchema,
|
||||
upsertPluginConfigSchema,
|
||||
patchPluginConfigSchema,
|
||||
updatePluginStatusSchema,
|
||||
uninstallPluginSchema,
|
||||
pluginStateScopeKeySchema,
|
||||
setPluginStateSchema,
|
||||
listPluginStateSchema,
|
||||
type PluginJobDeclarationInput,
|
||||
type PluginWebhookDeclarationInput,
|
||||
type PluginToolDeclarationInput,
|
||||
type PluginUiSlotDeclarationInput,
|
||||
type PluginLauncherActionDeclarationInput,
|
||||
type PluginLauncherRenderDeclarationInput,
|
||||
type PluginLauncherDeclarationInput,
|
||||
type PluginManifestV1Input,
|
||||
type InstallPlugin,
|
||||
type UpsertPluginConfig,
|
||||
type PatchPluginConfig,
|
||||
type UpdatePluginStatus,
|
||||
type UninstallPlugin,
|
||||
type PluginStateScopeKey,
|
||||
type SetPluginState,
|
||||
type ListPluginState,
|
||||
} from "./plugin.js";
|
||||
|
||||
670
packages/shared/src/validators/plugin.ts
Normal file
670
packages/shared/src/validators/plugin.ts
Normal file
@@ -0,0 +1,670 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
PLUGIN_STATUSES,
|
||||
PLUGIN_CATEGORIES,
|
||||
PLUGIN_CAPABILITIES,
|
||||
PLUGIN_UI_SLOT_TYPES,
|
||||
PLUGIN_UI_SLOT_ENTITY_TYPES,
|
||||
PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS,
|
||||
PLUGIN_LAUNCHER_PLACEMENT_ZONES,
|
||||
PLUGIN_LAUNCHER_ACTIONS,
|
||||
PLUGIN_LAUNCHER_BOUNDS,
|
||||
PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS,
|
||||
PLUGIN_STATE_SCOPE_KINDS,
|
||||
} from "../constants.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JSON Schema placeholder – a permissive validator for JSON Schema objects
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Permissive validator for JSON Schema objects. Accepts any `Record<string, unknown>`
|
||||
* that contains at least a `type`, `$ref`, or composition keyword (`oneOf`/`anyOf`/`allOf`).
|
||||
* Empty objects are also accepted.
|
||||
*
|
||||
* Used to validate `instanceConfigSchema` and `parametersSchema` fields in the
|
||||
* plugin manifest without fully parsing JSON Schema.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §10.1 — Manifest shape
|
||||
*/
|
||||
export const jsonSchemaSchema = z.record(z.unknown()).refine(
|
||||
(val) => {
|
||||
// Must have a "type" field if non-empty, or be a valid JSON Schema object
|
||||
if (Object.keys(val).length === 0) return true;
|
||||
return typeof val.type === "string" || val.$ref !== undefined || val.oneOf !== undefined || val.anyOf !== undefined || val.allOf !== undefined;
|
||||
},
|
||||
{ message: "Must be a valid JSON Schema object (requires at least a 'type', '$ref', or composition keyword)" },
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest sub-type schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginJobDeclaration} — a scheduled job declared in the
|
||||
* plugin manifest. Requires `jobKey` and `displayName`; `description` and
|
||||
* `schedule` (cron expression) are optional.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
|
||||
*/
|
||||
/**
|
||||
* Validates a cron expression has exactly 5 whitespace-separated fields,
|
||||
* each containing only valid cron characters (digits, *, /, -, ,).
|
||||
*
|
||||
* Valid tokens per field: *, N, N-M, N/S, * /S, N-M/S, and comma-separated lists.
|
||||
*/
|
||||
const CRON_FIELD_PATTERN = /^(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?)(?:,(\*(?:\/[0-9]+)?|[0-9]+(?:-[0-9]+)?(?:\/[0-9]+)?))*$/;
|
||||
|
||||
function isValidCronExpression(expression: string): boolean {
|
||||
const trimmed = expression.trim();
|
||||
if (!trimmed) return false;
|
||||
const fields = trimmed.split(/\s+/);
|
||||
if (fields.length !== 5) return false;
|
||||
return fields.every((f) => CRON_FIELD_PATTERN.test(f));
|
||||
}
|
||||
|
||||
export const pluginJobDeclarationSchema = z.object({
|
||||
jobKey: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
schedule: z.string().refine(
|
||||
(val) => isValidCronExpression(val),
|
||||
{ message: "schedule must be a valid 5-field cron expression (e.g. '*/15 * * * *')" },
|
||||
).optional(),
|
||||
});
|
||||
|
||||
export type PluginJobDeclarationInput = z.infer<typeof pluginJobDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginWebhookDeclaration} — a webhook endpoint declared
|
||||
* in the plugin manifest. Requires `endpointKey` and `displayName`.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §18 — Webhooks
|
||||
*/
|
||||
export const pluginWebhookDeclarationSchema = z.object({
|
||||
endpointKey: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type PluginWebhookDeclarationInput = z.infer<typeof pluginWebhookDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginToolDeclaration} — an agent tool contributed by the
|
||||
* plugin. Requires `name`, `displayName`, `description`, and a valid
|
||||
* `parametersSchema`. Requires the `agent.tools.register` capability.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §11 — Agent Tools
|
||||
*/
|
||||
export const pluginToolDeclarationSchema = z.object({
|
||||
name: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().min(1),
|
||||
parametersSchema: jsonSchemaSchema,
|
||||
});
|
||||
|
||||
export type PluginToolDeclarationInput = z.infer<typeof pluginToolDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates a {@link PluginUiSlotDeclaration} — a UI extension slot the plugin
|
||||
* fills with a React component. Includes `superRefine` checks for slot-specific
|
||||
* requirements such as `entityTypes` for context-sensitive slots.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §19 — UI Extension Model
|
||||
*/
|
||||
export const pluginUiSlotDeclarationSchema = z.object({
|
||||
type: z.enum(PLUGIN_UI_SLOT_TYPES),
|
||||
id: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
exportName: z.string().min(1),
|
||||
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
|
||||
routePath: z.string().regex(/^[a-z0-9][a-z0-9-]*$/, {
|
||||
message: "routePath must be a lowercase single-segment slug (letters, numbers, hyphens)",
|
||||
}).optional(),
|
||||
order: z.number().int().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
// context-sensitive slots require explicit entity targeting.
|
||||
const entityScopedTypes = ["detailTab", "taskDetailView", "contextMenuItem", "commentAnnotation", "commentContextMenuItem", "projectSidebarItem"];
|
||||
if (
|
||||
entityScopedTypes.includes(value.type)
|
||||
&& (!value.entityTypes || value.entityTypes.length === 0)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `${value.type} slots require at least one entityType`,
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
// projectSidebarItem only makes sense for entityType "project".
|
||||
if (value.type === "projectSidebarItem" && value.entityTypes && !value.entityTypes.includes("project")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "projectSidebarItem slots require entityTypes to include \"project\"",
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
// commentAnnotation only makes sense for entityType "comment".
|
||||
if (value.type === "commentAnnotation" && value.entityTypes && !value.entityTypes.includes("comment")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "commentAnnotation slots require entityTypes to include \"comment\"",
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
// commentContextMenuItem only makes sense for entityType "comment".
|
||||
if (value.type === "commentContextMenuItem" && value.entityTypes && !value.entityTypes.includes("comment")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "commentContextMenuItem slots require entityTypes to include \"comment\"",
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
if (value.routePath && value.type !== "page") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "routePath is only supported for page slots",
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
if (value.routePath && PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS.includes(value.routePath as (typeof PLUGIN_RESERVED_COMPANY_ROUTE_SEGMENTS)[number])) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `routePath "${value.routePath}" is reserved by the host`,
|
||||
path: ["routePath"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginUiSlotDeclarationInput = z.infer<typeof pluginUiSlotDeclarationSchema>;
|
||||
|
||||
const entityScopedLauncherPlacementZones = [
|
||||
"detailTab",
|
||||
"taskDetailView",
|
||||
"contextMenuItem",
|
||||
"commentAnnotation",
|
||||
"commentContextMenuItem",
|
||||
"projectSidebarItem",
|
||||
] as const;
|
||||
|
||||
const launcherBoundsByEnvironment: Record<
|
||||
(typeof PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS)[number],
|
||||
readonly (typeof PLUGIN_LAUNCHER_BOUNDS)[number][]
|
||||
> = {
|
||||
hostInline: ["inline", "compact", "default"],
|
||||
hostOverlay: ["compact", "default", "wide", "full"],
|
||||
hostRoute: ["default", "wide", "full"],
|
||||
external: [],
|
||||
iframe: ["compact", "default", "wide", "full"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the action payload for a declarative plugin launcher.
|
||||
*/
|
||||
export const pluginLauncherActionDeclarationSchema = z.object({
|
||||
type: z.enum(PLUGIN_LAUNCHER_ACTIONS),
|
||||
target: z.string().min(1),
|
||||
params: z.record(z.unknown()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.type === "performAction" && value.target.includes("/")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "performAction launchers must target an action key, not a route or URL",
|
||||
path: ["target"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.type === "navigate" && /^https?:\/\//.test(value.target)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "navigate launchers must target a host route, not an absolute URL",
|
||||
path: ["target"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginLauncherActionDeclarationInput =
|
||||
z.infer<typeof pluginLauncherActionDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates optional render hints for a plugin launcher destination.
|
||||
*/
|
||||
export const pluginLauncherRenderDeclarationSchema = z.object({
|
||||
environment: z.enum(PLUGIN_LAUNCHER_RENDER_ENVIRONMENTS),
|
||||
bounds: z.enum(PLUGIN_LAUNCHER_BOUNDS).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (!value.bounds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const supportedBounds = launcherBoundsByEnvironment[value.environment];
|
||||
if (!supportedBounds.includes(value.bounds)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `bounds "${value.bounds}" is not supported for render environment "${value.environment}"`,
|
||||
path: ["bounds"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginLauncherRenderDeclarationInput =
|
||||
z.infer<typeof pluginLauncherRenderDeclarationSchema>;
|
||||
|
||||
/**
|
||||
* Validates declarative launcher metadata in a plugin manifest.
|
||||
*/
|
||||
export const pluginLauncherDeclarationSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
placementZone: z.enum(PLUGIN_LAUNCHER_PLACEMENT_ZONES),
|
||||
exportName: z.string().min(1).optional(),
|
||||
entityTypes: z.array(z.enum(PLUGIN_UI_SLOT_ENTITY_TYPES)).optional(),
|
||||
order: z.number().int().optional(),
|
||||
action: pluginLauncherActionDeclarationSchema,
|
||||
render: pluginLauncherRenderDeclarationSchema.optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (
|
||||
entityScopedLauncherPlacementZones.some((zone) => zone === value.placementZone)
|
||||
&& (!value.entityTypes || value.entityTypes.length === 0)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `${value.placementZone} launchers require at least one entityType`,
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
value.placementZone === "projectSidebarItem"
|
||||
&& value.entityTypes
|
||||
&& !value.entityTypes.includes("project")
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "projectSidebarItem launchers require entityTypes to include \"project\"",
|
||||
path: ["entityTypes"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.action.type === "performAction" && value.render) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "performAction launchers cannot declare render hints",
|
||||
path: ["render"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
["openModal", "openDrawer", "openPopover"].includes(value.action.type)
|
||||
&& !value.render
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `${value.action.type} launchers require render metadata`,
|
||||
path: ["render"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.action.type === "openModal" && value.render?.environment === "hostInline") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "openModal launchers cannot use the hostInline render environment",
|
||||
path: ["render", "environment"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
value.action.type === "openDrawer"
|
||||
&& value.render
|
||||
&& !["hostOverlay", "iframe"].includes(value.render.environment)
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "openDrawer launchers must use hostOverlay or iframe render environments",
|
||||
path: ["render", "environment"],
|
||||
});
|
||||
}
|
||||
|
||||
if (value.action.type === "openPopover" && value.render?.environment === "hostRoute") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "openPopover launchers cannot use the hostRoute render environment",
|
||||
path: ["render", "environment"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginLauncherDeclarationInput = z.infer<typeof pluginLauncherDeclarationSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Manifest V1 schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Zod schema for {@link PaperclipPluginManifestV1} — the complete runtime
|
||||
* validator for plugin manifests read at install time.
|
||||
*
|
||||
* Field-level constraints (see PLUGIN_SPEC.md §10.1 for the normative rules):
|
||||
*
|
||||
* | Field | Type | Constraints |
|
||||
* |--------------------------|------------|----------------------------------------------|
|
||||
* | `id` | string | `^[a-z0-9][a-z0-9._-]*$` |
|
||||
* | `apiVersion` | literal 1 | must equal `PLUGIN_API_VERSION` |
|
||||
* | `version` | string | semver (`\d+\.\d+\.\d+`) |
|
||||
* | `displayName` | string | 1–100 chars |
|
||||
* | `description` | string | 1–500 chars |
|
||||
* | `author` | string | 1–200 chars |
|
||||
* | `categories` | enum[] | at least one; values from PLUGIN_CATEGORIES |
|
||||
* | `minimumHostVersion` | string? | semver lower bound if present, no leading `v`|
|
||||
* | `minimumPaperclipVersion`| string? | legacy alias of `minimumHostVersion` |
|
||||
* | `capabilities` | enum[] | at least one; values from PLUGIN_CAPABILITIES|
|
||||
* | `entrypoints.worker` | string | min 1 char |
|
||||
* | `entrypoints.ui` | string? | required when `ui.slots` is declared |
|
||||
*
|
||||
* Cross-field rules enforced via `superRefine`:
|
||||
* - `entrypoints.ui` required when `ui.slots` declared
|
||||
* - `agent.tools.register` capability required when `tools` declared
|
||||
* - `jobs.schedule` capability required when `jobs` declared
|
||||
* - `webhooks.receive` capability required when `webhooks` declared
|
||||
* - duplicate `jobs[].jobKey` values are rejected
|
||||
* - duplicate `webhooks[].endpointKey` values are rejected
|
||||
* - duplicate `tools[].name` values are rejected
|
||||
* - duplicate `ui.slots[].id` values are rejected
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §10.1 — Manifest shape
|
||||
* @see {@link PaperclipPluginManifestV1} — the inferred TypeScript type
|
||||
*/
|
||||
export const pluginManifestV1Schema = z.object({
|
||||
id: z.string().min(1).regex(
|
||||
/^[a-z0-9][a-z0-9._-]*$/,
|
||||
"Plugin id must start with a lowercase alphanumeric and contain only lowercase letters, digits, dots, hyphens, or underscores",
|
||||
),
|
||||
apiVersion: z.literal(1),
|
||||
version: z.string().min(1).regex(
|
||||
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
|
||||
"Version must follow semver (e.g. 1.0.0 or 1.0.0-beta.1)",
|
||||
),
|
||||
displayName: z.string().min(1).max(100),
|
||||
description: z.string().min(1).max(500),
|
||||
author: z.string().min(1).max(200),
|
||||
categories: z.array(z.enum(PLUGIN_CATEGORIES)).min(1),
|
||||
minimumHostVersion: z.string().regex(
|
||||
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
|
||||
"minimumHostVersion must follow semver (e.g. 1.0.0)",
|
||||
).optional(),
|
||||
minimumPaperclipVersion: z.string().regex(
|
||||
/^\d+\.\d+\.\d+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$/,
|
||||
"minimumPaperclipVersion must follow semver (e.g. 1.0.0)",
|
||||
).optional(),
|
||||
capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1),
|
||||
entrypoints: z.object({
|
||||
worker: z.string().min(1),
|
||||
ui: z.string().min(1).optional(),
|
||||
}),
|
||||
instanceConfigSchema: jsonSchemaSchema.optional(),
|
||||
jobs: z.array(pluginJobDeclarationSchema).optional(),
|
||||
webhooks: z.array(pluginWebhookDeclarationSchema).optional(),
|
||||
tools: z.array(pluginToolDeclarationSchema).optional(),
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
ui: z.object({
|
||||
slots: z.array(pluginUiSlotDeclarationSchema).min(1).optional(),
|
||||
launchers: z.array(pluginLauncherDeclarationSchema).optional(),
|
||||
}).optional(),
|
||||
}).superRefine((manifest, ctx) => {
|
||||
// ── Entrypoint ↔ UI slot consistency ──────────────────────────────────
|
||||
// Plugins that declare UI slots must also declare a UI entrypoint so the
|
||||
// host knows where to load the bundle from (PLUGIN_SPEC.md §10.1).
|
||||
const hasUiSlots = (manifest.ui?.slots?.length ?? 0) > 0;
|
||||
const hasUiLaunchers = (manifest.ui?.launchers?.length ?? 0) > 0;
|
||||
if ((hasUiSlots || hasUiLaunchers) && !manifest.entrypoints.ui) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "entrypoints.ui is required when ui.slots or ui.launchers are declared",
|
||||
path: ["entrypoints", "ui"],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
manifest.minimumHostVersion
|
||||
&& manifest.minimumPaperclipVersion
|
||||
&& manifest.minimumHostVersion !== manifest.minimumPaperclipVersion
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "minimumHostVersion and minimumPaperclipVersion must match when both are declared",
|
||||
path: ["minimumHostVersion"],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Capability ↔ feature declaration consistency ───────────────────────
|
||||
// The host enforces capabilities at install and runtime. A plugin must
|
||||
// declare every capability it needs up-front; silently having more features
|
||||
// than capabilities would cause runtime rejections.
|
||||
|
||||
// tools require agent.tools.register (PLUGIN_SPEC.md §11)
|
||||
if (manifest.tools && manifest.tools.length > 0) {
|
||||
if (!manifest.capabilities.includes("agent.tools.register")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'agent.tools.register' is required when tools are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// jobs require jobs.schedule (PLUGIN_SPEC.md §17)
|
||||
if (manifest.jobs && manifest.jobs.length > 0) {
|
||||
if (!manifest.capabilities.includes("jobs.schedule")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'jobs.schedule' is required when jobs are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// webhooks require webhooks.receive (PLUGIN_SPEC.md §18)
|
||||
if (manifest.webhooks && manifest.webhooks.length > 0) {
|
||||
if (!manifest.capabilities.includes("webhooks.receive")) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Capability 'webhooks.receive' is required when webhooks are declared",
|
||||
path: ["capabilities"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Uniqueness checks ──────────────────────────────────────────────────
|
||||
// Duplicate keys within a plugin's own manifest are always a bug. The host
|
||||
// would not know which declaration takes precedence, so we reject early.
|
||||
|
||||
// job keys must be unique within the plugin (used as identifiers in the DB)
|
||||
if (manifest.jobs) {
|
||||
const jobKeys = manifest.jobs.map((j) => j.jobKey);
|
||||
const duplicates = jobKeys.filter((key, i) => jobKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate job keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["jobs"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// webhook endpoint keys must be unique within the plugin (used in routes)
|
||||
if (manifest.webhooks) {
|
||||
const endpointKeys = manifest.webhooks.map((w) => w.endpointKey);
|
||||
const duplicates = endpointKeys.filter((key, i) => endpointKeys.indexOf(key) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate webhook endpoint keys: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["webhooks"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// tool names must be unique within the plugin (namespaced at runtime)
|
||||
if (manifest.tools) {
|
||||
const toolNames = manifest.tools.map((t) => t.name);
|
||||
const duplicates = toolNames.filter((name, i) => toolNames.indexOf(name) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate tool names: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["tools"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// UI slot ids must be unique within the plugin (namespaced at runtime)
|
||||
if (manifest.ui) {
|
||||
if (manifest.ui.slots) {
|
||||
const slotIds = manifest.ui.slots.map((s) => s.id);
|
||||
const duplicates = slotIds.filter((id, i) => slotIds.indexOf(id) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate UI slot ids: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: ["ui", "slots"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// launcher ids must be unique within the plugin
|
||||
const allLaunchers = [
|
||||
...(manifest.launchers ?? []),
|
||||
...(manifest.ui?.launchers ?? []),
|
||||
];
|
||||
if (allLaunchers.length > 0) {
|
||||
const launcherIds = allLaunchers.map((launcher) => launcher.id);
|
||||
const duplicates = launcherIds.filter((id, i) => launcherIds.indexOf(id) !== i);
|
||||
if (duplicates.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: `Duplicate launcher ids: ${[...new Set(duplicates)].join(", ")}`,
|
||||
path: manifest.ui?.launchers ? ["ui", "launchers"] : ["launchers"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type PluginManifestV1Input = z.infer<typeof pluginManifestV1Schema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin installation / registration request
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schema for installing (registering) a plugin.
|
||||
* The server receives the packageName and resolves the manifest from the
|
||||
* installed package.
|
||||
*/
|
||||
export const installPluginSchema = z.object({
|
||||
packageName: z.string().min(1),
|
||||
version: z.string().min(1).optional(),
|
||||
/** Set by loader for local-path installs so the worker can be resolved. */
|
||||
packagePath: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type InstallPlugin = z.infer<typeof installPluginSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin config (instance configuration) schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schema for creating or updating a plugin's instance configuration.
|
||||
* configJson is validated permissively here; runtime validation against
|
||||
* the plugin's instanceConfigSchema is done at the service layer.
|
||||
*/
|
||||
export const upsertPluginConfigSchema = z.object({
|
||||
configJson: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type UpsertPluginConfig = z.infer<typeof upsertPluginConfigSchema>;
|
||||
|
||||
/**
|
||||
* Schema for partially updating a plugin's instance configuration.
|
||||
* Allows a partial merge of config values.
|
||||
*/
|
||||
export const patchPluginConfigSchema = z.object({
|
||||
configJson: z.record(z.unknown()),
|
||||
});
|
||||
|
||||
export type PatchPluginConfig = z.infer<typeof patchPluginConfigSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin status update
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schema for updating a plugin's lifecycle status. Used by the lifecycle
|
||||
* manager to persist state transitions.
|
||||
*
|
||||
* @see {@link PLUGIN_STATUSES} for the valid status values
|
||||
*/
|
||||
export const updatePluginStatusSchema = z.object({
|
||||
status: z.enum(PLUGIN_STATUSES),
|
||||
lastError: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export type UpdatePluginStatus = z.infer<typeof updatePluginStatusSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin uninstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Schema for the uninstall request. `removeData` controls hard vs soft delete. */
|
||||
export const uninstallPluginSchema = z.object({
|
||||
removeData: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
export type UninstallPlugin = z.infer<typeof uninstallPluginSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin state (key-value storage) schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Schema for a plugin state scope key — identifies the exact location where
|
||||
* state is stored. Used by the `ctx.state.get()`, `ctx.state.set()`, and
|
||||
* `ctx.state.delete()` SDK methods.
|
||||
*
|
||||
* @see PLUGIN_SPEC.md §21.3 `plugin_state`
|
||||
*/
|
||||
export const pluginStateScopeKeySchema = z.object({
|
||||
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS),
|
||||
scopeId: z.string().min(1).optional(),
|
||||
namespace: z.string().min(1).optional(),
|
||||
stateKey: z.string().min(1),
|
||||
});
|
||||
|
||||
export type PluginStateScopeKey = z.infer<typeof pluginStateScopeKeySchema>;
|
||||
|
||||
/**
|
||||
* Schema for setting a plugin state value.
|
||||
*/
|
||||
export const setPluginStateSchema = z.object({
|
||||
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS),
|
||||
scopeId: z.string().min(1).optional(),
|
||||
namespace: z.string().min(1).optional(),
|
||||
stateKey: z.string().min(1),
|
||||
/** JSON-serializable value to store. */
|
||||
value: z.unknown(),
|
||||
});
|
||||
|
||||
export type SetPluginState = z.infer<typeof setPluginStateSchema>;
|
||||
|
||||
/**
|
||||
* Schema for querying plugin state entries. All fields are optional to allow
|
||||
* flexible list queries (e.g. all state for a plugin within a scope).
|
||||
*/
|
||||
export const listPluginStateSchema = z.object({
|
||||
scopeKind: z.enum(PLUGIN_STATE_SCOPE_KINDS).optional(),
|
||||
scopeId: z.string().min(1).optional(),
|
||||
namespace: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type ListPluginState = z.infer<typeof listPluginStateSchema>;
|
||||
Reference in New Issue
Block a user