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(); 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) => { 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) => { 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) => { 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) => { 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) => { 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);