Add plugin framework and settings UI

This commit is contained in:
Dotta
2026-03-13 16:22:34 -05:00
parent 7e288d20fc
commit 80cdbdbd47
103 changed files with 31760 additions and 35 deletions

View File

@@ -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 plugins 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).

View File

@@ -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"
}
}

View File

@@ -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",
});

View File

@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as worker } from "./worker.js";

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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 the
// commentAnnotationMode setting and hide themselves when disabled.
ctx.data.register("plugin-config", async () => {
const config = await ctx.state.get({ scopeKind: "instance", stateKey: "config" }) as Record<string, unknown> | null;
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);

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"]
}

View File

@@ -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`.

View File

@@ -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"
}
}

View File

@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as worker } from "./worker.js";

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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);

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"lib": ["ES2023", "DOM"],
"jsx": "react-jsx"
},
"include": ["src"]
}