9eac727cf1
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies through company-scoped control-plane workflows. > - Agents need reusable, inspectable skills that can be installed, reset, audited, exported, and assigned without bespoke local setup. > - The existing skill truth model needed cleanup so bundled skills, optional catalog skills, runtime skills, and adapter-provided skills have clear provenance. > - Operators also need a practical CLI and board UI for discovering and managing company skills. > - This pull request adds the skills CLI, packaged skills catalog, company skills APIs, and catalog-aware board UI. > - The benefit is a more reusable Paperclip company setup where skills are portable, auditable, and easier for operators and agents to manage. ## What Changed - Added `paperclipai skills` CLI commands and coverage for catalog listing, installing, resetting, and inspecting company skills. - Added a packaged `@paperclipai/skills-catalog` workspace with bundled and optional skill content plus validation/build tests. - Added shared company-skill types and validators used across CLI, server, and UI contracts. - Added server catalog APIs/services for company skill catalog operations, reset semantics, audit behavior, and portability provenance. - Updated adapter skill handling so runtime/catalog provenance remains explicit across local adapters. - Added board UI support for browsing and managing catalog-backed company skills. - Updated docs for the skills CLI/catalog flow and the company skills Paperclip skill reference. - Rebased the branch onto current `paperclipai/paperclip:master`; no `pnpm-lock.yaml`, `.github/workflows`, or migration files are included in the final PR diff. ## Verification - Passed: `pnpm run preflight:workspace-links && pnpm exec vitest run cli/src/__tests__/skills.test.ts packages/skills-catalog/src/catalog-builder.test.ts packages/skills-catalog/src/shipped-catalog.test.ts packages/shared/src/validators/company-skill.test.ts packages/adapter-utils/src/server-utils.test.ts packages/plugins/create-paperclip-plugin/src/entrypoints.test.ts server/src/__tests__/company-skills-catalog-service.test.ts server/src/__tests__/company-skills-routes.test.ts server/src/__tests__/company-portability.test.ts`. - Passed: `pnpm exec vitest run server/src/__tests__/workspace-runtime.test.ts -t "default branch|origin/master|symbolic-ref"`. - Attempted: full `server/src/__tests__/workspace-runtime.test.ts`. Four provisioning tests failed while seeding an isolated worktree database from the local Paperclip instance because the local plugin schema dump contains a duplicate-column foreign key (`plugin_content_machine_18a7bc327b.content_case_signals`). The default-branch tests touched by the rebase conflict passed in the focused run above. - Checked final diff: no `pnpm-lock.yaml`, no `.github/workflows`, and no migration-file changes relative to `master`. ## Risks - Medium: this is a broad skills/catalog change touching CLI, server APIs, shared contracts, adapter skill sync, and UI. - Catalog validation and reset semantics need careful reviewer attention because they affect reusable company setup and portability. - No database migrations are included in this PR, so there is no migration ordering/idempotency risk in the final diff. - No lockfile is included by design; dependency resolution will be handled by the repository lockfile workflow. ## Model Used - OpenAI Codex coding agent based on GPT-5, running in Paperclip via the `codex_local` adapter with shell, git, GitHub CLI, and code-editing tool access. Exact hosted model build/context-window metadata is not exposed in this runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run targeted tests locally and documented the local workspace-runtime seed failure above - [x] I have added or updated tests where applicable - [x] If this change affects the UI, screenshots were intentionally omitted per PAP-10124 instructions; UI behavior is covered by tests and reviewer inspection - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
701 lines
22 KiB
TypeScript
701 lines
22 KiB
TypeScript
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", "environment"] as const;
|
|
type PluginTemplate = (typeof VALID_TEMPLATES)[number];
|
|
const VALID_CATEGORIES = new Set(["connector", "workspace", "automation", "ui", "environment"] as const);
|
|
|
|
export interface ScaffoldPluginOptions {
|
|
pluginName: string;
|
|
outputDir: string;
|
|
template?: PluginTemplate;
|
|
displayName?: string;
|
|
description?: string;
|
|
author?: string;
|
|
category?: "connector" | "workspace" | "automation" | "ui" | "environment";
|
|
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("/");
|
|
}
|
|
|
|
export function shellQuote(value: string): string {
|
|
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) return value;
|
|
return `'${value.replace(/'/g, "'\"'\"'")}'`;
|
|
}
|
|
|
|
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" : template === "environment" ? "environment" : "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",
|
|
},
|
|
});
|
|
`,
|
|
);
|
|
|
|
if (template === "environment") {
|
|
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: [
|
|
"environment.drivers.register",
|
|
"plugin.state.read",
|
|
"plugin.state.write",
|
|
"ui.dashboardWidget.register"
|
|
],
|
|
entrypoints: {
|
|
worker: "./dist/worker.js",
|
|
ui: "./dist/ui"
|
|
},
|
|
environmentDrivers: [
|
|
{
|
|
driverKey: ${quote(manifestId + "-driver")},
|
|
displayName: ${quote(displayName + " Driver")}
|
|
}
|
|
],
|
|
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";
|
|
import type {
|
|
PluginEnvironmentValidateConfigParams,
|
|
PluginEnvironmentProbeParams,
|
|
PluginEnvironmentAcquireLeaseParams,
|
|
PluginEnvironmentResumeLeaseParams,
|
|
PluginEnvironmentReleaseLeaseParams,
|
|
PluginEnvironmentDestroyLeaseParams,
|
|
PluginEnvironmentRealizeWorkspaceParams,
|
|
PluginEnvironmentExecuteParams,
|
|
} from "@paperclipai/plugin-sdk";
|
|
|
|
const plugin = definePlugin({
|
|
async setup(ctx) {
|
|
ctx.data.register("health", async () => {
|
|
return { status: "ok", checkedAt: new Date().toISOString() };
|
|
});
|
|
},
|
|
|
|
async onHealth() {
|
|
return { status: "ok", message: "Environment plugin worker is running" };
|
|
},
|
|
|
|
async onEnvironmentValidateConfig(params: PluginEnvironmentValidateConfigParams) {
|
|
if (!params.config || typeof params.config !== "object") {
|
|
return { ok: false, errors: ["Config must be a non-null object"] };
|
|
}
|
|
return { ok: true, normalizedConfig: params.config };
|
|
},
|
|
|
|
async onEnvironmentProbe(_params: PluginEnvironmentProbeParams) {
|
|
return { ok: true, summary: "Environment is reachable" };
|
|
},
|
|
|
|
async onEnvironmentAcquireLease(params: PluginEnvironmentAcquireLeaseParams) {
|
|
const providerLeaseId = \`lease-\${params.runId}-\${Date.now()}\`;
|
|
return {
|
|
providerLeaseId,
|
|
metadata: { acquiredAt: new Date().toISOString() },
|
|
};
|
|
},
|
|
|
|
async onEnvironmentResumeLease(params: PluginEnvironmentResumeLeaseParams) {
|
|
return {
|
|
providerLeaseId: params.providerLeaseId,
|
|
metadata: { ...params.leaseMetadata, resumed: true },
|
|
};
|
|
},
|
|
|
|
async onEnvironmentReleaseLease(_params: PluginEnvironmentReleaseLeaseParams) {
|
|
// Release provider-side resources here
|
|
},
|
|
|
|
async onEnvironmentDestroyLease(_params: PluginEnvironmentDestroyLeaseParams) {
|
|
// Destroy provider-side resources here
|
|
},
|
|
|
|
async onEnvironmentRealizeWorkspace(params: PluginEnvironmentRealizeWorkspaceParams) {
|
|
const cwd = params.workspace.remotePath ?? params.workspace.localPath ?? "/tmp/workspace";
|
|
return { cwd, metadata: { realized: true } };
|
|
},
|
|
|
|
async onEnvironmentExecute(params: PluginEnvironmentExecuteParams) {
|
|
// Replace this with real command execution against your provider
|
|
return {
|
|
exitCode: 0,
|
|
timedOut: false,
|
|
stdout: \`Executed: \${params.command}\`,
|
|
stderr: "",
|
|
};
|
|
},
|
|
});
|
|
|
|
export default plugin;
|
|
runWorker(plugin, import.meta.url);
|
|
`,
|
|
);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "src", "ui", "index.tsx"),
|
|
`import { 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");
|
|
|
|
if (loading) return <div>Loading environment 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>
|
|
</div>
|
|
);
|
|
}
|
|
`,
|
|
);
|
|
|
|
writeFile(
|
|
path.join(outputDir, "tests", "plugin.spec.ts"),
|
|
`import { describe, expect, it } from "vitest";
|
|
import {
|
|
createEnvironmentTestHarness,
|
|
createFakeEnvironmentDriver,
|
|
assertEnvironmentEventOrder,
|
|
assertLeaseLifecycle,
|
|
} from "@paperclipai/plugin-sdk/testing";
|
|
import manifest from "../src/manifest.js";
|
|
import plugin from "../src/worker.js";
|
|
|
|
const ENV_ID = "env-test-1";
|
|
const BASE_PARAMS = {
|
|
driverKey: manifest.environmentDrivers![0].driverKey,
|
|
companyId: "co-1",
|
|
environmentId: ENV_ID,
|
|
config: {},
|
|
};
|
|
|
|
describe("environment plugin scaffold", () => {
|
|
it("declares capabilities for its manifest features", () => {
|
|
expect(manifest.capabilities).toContain("environment.drivers.register");
|
|
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
|
|
});
|
|
|
|
it("validates config", async () => {
|
|
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
|
|
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
const result = await plugin.definition.onEnvironmentValidateConfig!({
|
|
driverKey: BASE_PARAMS.driverKey,
|
|
config: { host: "test" },
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("probes the environment", async () => {
|
|
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
|
|
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
const result = await plugin.definition.onEnvironmentProbe!(BASE_PARAMS);
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("runs a full lease lifecycle through the harness", async () => {
|
|
const driver = createFakeEnvironmentDriver({ driverKey: BASE_PARAMS.driverKey });
|
|
const harness = createEnvironmentTestHarness({ manifest, environmentDriver: driver });
|
|
await plugin.definition.setup(harness.ctx);
|
|
|
|
const lease = await harness.acquireLease({ ...BASE_PARAMS, runId: "run-1" });
|
|
expect(lease.providerLeaseId).toBeTruthy();
|
|
|
|
await harness.realizeWorkspace({
|
|
...BASE_PARAMS,
|
|
lease,
|
|
workspace: { localPath: "/tmp/test" },
|
|
});
|
|
|
|
await harness.releaseLease({
|
|
...BASE_PARAMS,
|
|
providerLeaseId: lease.providerLeaseId,
|
|
});
|
|
|
|
assertEnvironmentEventOrder(harness.environmentEvents, [
|
|
"acquireLease",
|
|
"realizeWorkspace",
|
|
"releaseLease",
|
|
]);
|
|
assertLeaseLifecycle(harness.environmentEvents, ENV_ID);
|
|
});
|
|
});
|
|
`,
|
|
);
|
|
} else {
|
|
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",
|
|
"ui.dashboardWidget.register"
|
|
],
|
|
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("declares capabilities for its manifest features", () => {
|
|
expect(manifest.capabilities).toContain("events.subscribe");
|
|
expect(manifest.capabilities).toContain("ui.dashboardWidget.register");
|
|
});
|
|
|
|
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
|
|
\`\`\`
|
|
|
|
\`pnpm dev\` rebuilds the worker, manifest, and UI bundles into \`dist/\`.
|
|
When this package is installed from a local path, Paperclip watches that rebuilt
|
|
output and reloads the plugin worker. Local installs run trusted code from this
|
|
folder on your machine.
|
|
|
|
${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
|
|
paperclipai plugin install ${shellQuote(toPosixPath(outputDir))}
|
|
\`\`\`
|
|
|
|
## 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;
|
|
}
|