Add first-party missions plugin package scaffold polish

This commit is contained in:
Dotta committed 2026-04-22 11:05:46 -05:00
1 parent b69b563aa8
commit e1f6a27949
11 files changed
+309

No files matched your search

@@ -0,0 +1,3 @@
dist
node_modules
.paperclip-sdk
@@ -0,0 +1,72 @@
# @paperclipai/plugin-missions
First-party Missions plugin package for Paperclip mission orchestration workflows.
This package is the packaging and installation home for the Missions plugin in the
current alpha plugin runtime. Install is instance-wide, but the workflow it owns
is company-scoped once the worker logic is invoked against company issues.
Today this package carries the plugin identity, local install path, verification
commands, and basic health surface. Mission advance/findings/waiver behavior
continues to land in the same package under the sibling implementation issues.
## Operator Workflow
Build the plugin, then install it into the local Paperclip instance by repo path:
```bash
pnpm --filter @paperclipai/plugin-missions typecheck
pnpm --filter @paperclipai/plugin-missions test
pnpm --filter @paperclipai/plugin-missions build
pnpm paperclipai plugin install ./packages/plugins/plugin-missions
```
The current first-party install path is a checked-out local package path. Do not
document npm publishing for this plugin until the release/distribution path is
ready.
API install is equivalent:
```bash
curl -X POST http://127.0.0.1:3100/api/plugins/install \
-H "Content-Type: application/json" \
-d '{"packageName":"/absolute/path/to/paperclip/packages/plugins/plugin-missions","isLocalPath":true}'
```
Build first. The host loads the worker and UI from the manifest entrypoints under
`dist/`, so a fresh install without a build will fail activation.
## Developer Workflow
From the repo root:
```bash
pnpm --filter @paperclipai/plugin-missions typecheck
pnpm --filter @paperclipai/plugin-missions test
pnpm --filter @paperclipai/plugin-missions build
```
For local iteration inside the package directory:
```bash
pnpm install
pnpm dev
pnpm dev:ui
```
## Maintenance Path
The main package files are:
- `src/manifest.ts` for plugin identity, capabilities, and mounted UI slots
- `src/worker.ts` for worker lifecycle, state, events, and orchestration logic
- `src/ui/index.tsx` for the currently mounted UI surface
- `tests/plugin.spec.ts` for package-local verification
When the Missions workflow changes, keep the package README and the targeted
verification commands in sync with the actual install path and runtime surface.
## 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,51 @@
{
"name": "@paperclipai/plugin-missions",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "First-party Missions plugin package for Paperclip mission orchestration workflows",
"scripts": {
"prebuild": "node ../../../scripts/ensure-plugin-build-deps.mjs",
"build": "node ./esbuild.config.mjs",
"build:rollup": "rollup -c",
"clean": "rm -rf dist",
"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",
"missions",
"orchestration"
],
"author": "Paperclip",
"license": "MIT",
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*",
"zod": "^3.24.2"
},
"devDependencies": {
"@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-typescript": "^12.1.2",
"@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",
"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,33 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.missions",
apiVersion: 1,
version: "0.1.0",
displayName: "Missions",
description: "First-party Missions plugin package for Paperclip mission orchestration workflows.",
author: "Paperclip",
categories: ["automation", "ui"],
capabilities: [
"events.subscribe",
"plugin.state.read",
"plugin.state.write",
"ui.dashboardWidget.register"
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui"
},
ui: {
slots: [
{
type: "dashboardWidget",
id: "missions-health-widget",
displayName: "Missions 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>Missions</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("Missions plugin 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: "Missions 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("missions plugin package", () => {
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",
},
});