diff --git a/docs/plugins.md b/docs/plugins.md index 17a2e8c80..6350badda 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -53,8 +53,9 @@ my-plugin/ ``` Paseo compiles TypeScript and TSX when loading the plugin, so these packages are development dependencies only. -The generated declaration file supplies `@paseo/plugin` types until the SDK is distributed as a -public package. Regenerate new plugins with the matching Paseo CLI when the SDK contract changes. +The generated declaration file supplies `@paseo/plugin` and `@paseo/plugin/server` types until the +SDK is distributed as a public package. Regenerate new plugins with the matching Paseo CLI when the +SDK contract changes. ```json { @@ -95,6 +96,16 @@ code lives behind filename boundaries: | `*.server.ts` | Node APIs, filesystem and process access, credentials, and handlers. | | `*.shared.ts` | Zod RPC contracts and plain values used by both runtimes. | +Shared files import contracts from `@paseo/plugin/server`. Client files import hooks from +`@paseo/plugin`. Plugin UI runs on desktop and mobile across multiple themes: color every +`Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from +`layout.compact`. See `public-docs/plugins/reference.md`. + +| Module | Use it for | +| ---------------------- | -------------------------------------------------------- | +| `@paseo/plugin` | hooks and UI types | +| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types | + The compiler removes client registrations and imports from the server entry point, and server registrations and imports from the client entry point. Importing a `*.server` module from a client module, or a `*.client` module from a server module, fails compilation. Top-level React Native calls diff --git a/packages/app/src/plugins/evaluate.test.ts b/packages/app/src/plugins/evaluate.test.ts index d78d7e35e..27288508b 100644 --- a/packages/app/src/plugins/evaluate.test.ts +++ b/packages/app/src/plugins/evaluate.test.ts @@ -162,6 +162,45 @@ describe("evaluatePluginClientBundle", () => { ).toThrow("must return a cleanup function"); }); + it("resolves @paseo/plugin/server for shared RPC contracts", () => { + const plugin = evaluatePluginClientBundle( + "example", + `(function(require) { + const { defineRpc, defineAttachmentSource } = require("@paseo/plugin/server"); + const search = defineRpc({ name: "issues.search", input: {}, output: {} }); + const module = { exports: {} }; + module.exports.default = function(plugin) { + plugin.addAttachmentSource(defineAttachmentSource({ + id: "issues", + title: "Issue", + icon: "CircleDot", + pickerTitle: "Attach issue", + searchPlaceholder: "Search", + search, + })); + return function() {}; + }; + return module.exports; + })`, + ); + + expect(plugin.attachmentSources.map((source) => source.search.name)).toEqual(["issues.search"]); + }); + + it("rejects modules that are not part of the client runtime", () => { + expect(() => + evaluatePluginClientBundle( + "example", + `(function(require) { + require("fs"); + const module = { exports: {} }; + module.exports.default = function() { return function() {}; }; + return module.exports; + })`, + ), + ).toThrow('Module "fs" is not available in plugin client code'); + }); + it("does not publish partial contributions when setup fails", () => { expect(() => evaluatePluginClientBundle( diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index 4ef4cf119..73ac93383 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -159,6 +159,9 @@ export function evaluatePluginClientBundle(id: string, bundle: string): Evaluate useRpc, }; } + if (name === "@paseo/plugin/server") { + return { defineAttachmentSource, defineRpc }; + } if (name === "@tanstack/react-query") return ReactQuery; if (name === "zod") return Zod; throw new Error(`Module "${name}" is not available in plugin client code`); diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index ad735156b..0ea1597a0 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -1,5 +1,5 @@ import { router, useLocalSearchParams } from "expo-router"; -import type { PluginSurfaceProps } from "@paseo/plugin"; +import type { PluginSurfaceProps, PluginTheme } from "@paseo/plugin"; import { ChevronDown, X } from "lucide-react-native"; import { useCallback, useMemo, useRef, useState, type ComponentType } from "react"; import { Platform, Pressable, Text, View } from "react-native"; @@ -14,6 +14,7 @@ import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; import type { ShortcutKey } from "@/utils/format-shortcut"; import { resolvePluginIcon } from "./icons"; +import { toPluginTheme } from "./theme"; import { useInstalledPlugin, usePluginInstallations } from "./registry"; import { buildPluginSurfaceRoute } from "./routes"; import { rememberPluginContributionHost } from "./sidebar-groups"; @@ -27,10 +28,9 @@ import { } from "./surface-contribution"; const EMPTY_SHORTCUT_KEYS: ShortcutKey[] = []; -const EMPTY_THEME_DTO: Record = {}; const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const pluginThemeMapping = (theme: Theme) => ({ - themeDto: JSON.parse(JSON.stringify(theme)) as Record, + theme: toPluginTheme(theme), }); const ThemedX = withUnistyles(X); const ThemedChevronDown = withUnistyles(ChevronDown); @@ -57,18 +57,18 @@ function SurfaceRenderer({ plugin, layout, host, - themeDto = EMPTY_THEME_DTO, + theme, }: { Surface: ComponentType; runtime: NonNullable>; plugin: NonNullable>; layout: PluginSurfaceProps["layout"]; host: PluginSurfaceProps["host"]; - themeDto?: Record; + theme: PluginTheme; }) { return ( - + ); } diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts new file mode 100644 index 000000000..1b9f3d86d --- /dev/null +++ b/packages/app/src/plugins/theme.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { lightTheme } from "@/styles/theme"; +import { toPluginTheme } from "./theme"; + +describe("toPluginTheme", () => { + it("maps the app theme into the plugin color tokens", () => { + expect(toPluginTheme(lightTheme)).toEqual({ + colors: { + surface0: lightTheme.colors.surface0, + foreground: lightTheme.colors.foreground, + foregroundMuted: lightTheme.colors.foregroundMuted, + accent: lightTheme.colors.accent, + accentForeground: lightTheme.colors.accentForeground, + statusDanger: lightTheme.colors.statusDanger, + }, + }); + }); +}); diff --git a/packages/app/src/plugins/theme.ts b/packages/app/src/plugins/theme.ts new file mode 100644 index 000000000..c006b85c9 --- /dev/null +++ b/packages/app/src/plugins/theme.ts @@ -0,0 +1,15 @@ +import type { PluginTheme } from "@paseo/plugin"; +import type { Theme } from "@/styles/theme"; + +export function toPluginTheme(theme: Theme): PluginTheme { + return { + colors: { + surface0: theme.colors.surface0, + foreground: theme.colors.foreground, + foregroundMuted: theme.colors.foregroundMuted, + accent: theme.colors.accent, + accentForeground: theme.colors.accentForeground, + statusDanger: theme.colors.statusDanger, + }, + }; +} diff --git a/packages/app/src/plugins/workspace-panels/panel.tsx b/packages/app/src/plugins/workspace-panels/panel.tsx index 0ddcfbf92..830bd0fee 100644 --- a/packages/app/src/plugins/workspace-panels/panel.tsx +++ b/packages/app/src/plugins/workspace-panels/panel.tsx @@ -1,6 +1,7 @@ import type { PluginAgentPanelProps, PluginHostProps, + PluginTheme, PluginWorkspacePanelProps, } from "@paseo/plugin"; import { PluginClientStateProvider } from "@paseo/plugin/host"; @@ -18,6 +19,7 @@ import { useWorkspaceExists } from "@/stores/session-store-hooks"; import type { Theme } from "@/styles/theme"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; import { createPluginClientStateSource } from "../client-state/source"; +import { toPluginTheme } from "../theme"; import { resolvePluginIcon } from "../icons"; import { useInstalledPlugin } from "../registry"; import { PluginRuntimeBoundary } from "../runtime-boundary"; @@ -25,9 +27,8 @@ import { createPluginSurfaceRuntime } from "../surface-runtime"; import { SurfaceErrorBoundary } from "../surface-error-boundary"; import { resolvePluginWorkspacePanel } from "./resolution"; -const EMPTY_THEME_DTO: Record = {}; const pluginThemeMapping = (theme: Theme) => ({ - themeDto: JSON.parse(JSON.stringify(theme)) as Record, + theme: toPluginTheme(theme), }); function resolvePlatform(): PluginHostProps["layout"]["platform"] { @@ -36,7 +37,7 @@ function resolvePlatform(): PluginHostProps["layout"]["platform"] { return "web"; } -function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record }) { +function PluginPanelBody({ theme }: { theme: PluginTheme }) { const { serverId, workspaceId, target } = usePaneContext(); invariant(target.kind === "plugin", "PluginPanel requires plugin target"); const plugin = useInstalledPlugin(serverId, target.pluginId); @@ -75,7 +76,7 @@ function PluginPanelBody({ themeDto = EMPTY_THEME_DTO }: { themeDto?: Record { await Promise.all([ writeFile( path.join(directory, "inspect.shared.ts"), - `import { defineRpc } from "@paseo/plugin"; + `import { defineRpc } from "@paseo/plugin/server"; import { z } from "zod"; export const inspect = defineRpc({ @@ -62,7 +62,7 @@ export const inspect = defineRpc({ ), writeFile( path.join(directory, "inspect.server.ts"), - `import type { PluginHandlerContext } from "@paseo/plugin"; + `import type { PluginHandlerContext } from "@paseo/plugin/server"; import type { output as ZodOutput } from "zod"; import { inspect } from "./inspect.shared"; diff --git a/packages/cli/src/commands/plugin/scaffold.ts b/packages/cli/src/commands/plugin/scaffold.ts index ffef026ff..b7151bb92 100644 --- a/packages/cli/src/commands/plugin/scaffold.ts +++ b/packages/cli/src/commands/plugin/scaffold.ts @@ -2,13 +2,95 @@ import { mkdir, readdir, writeFile } from "node:fs/promises"; import path from "node:path"; import { PluginIdSchema } from "@getpaseo/protocol/messages"; -const SDK_DECLARATIONS = `declare module "@paseo/plugin" { - import type { ComponentType } from "react"; +const SDK_DECLARATIONS = `declare module "@paseo/plugin/server" { import type { PaseoApi } from "@getpaseo/client"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; + export interface PluginRpcContract< + InputSchema extends ZodType = ZodType, + OutputSchema extends ZodType = ZodType, + > { + name: string; + input: InputSchema; + output: OutputSchema; + } + + export interface PluginAttachmentItem { + id: string; + identifier: string; + title: string; + subtitle?: string; + url: string; + text: string; + resourceType: string; + } + + export interface PluginAttachmentSearchPayload { + items: PluginAttachmentItem[]; + } + + export interface PluginAttachmentSourceContribution { + id: string; + title: string; + icon: string; + pickerTitle: string; + searchPlaceholder: string; + search: PluginRpcContract; + } + + export interface PluginHandlerContext { + paseo: PaseoApi; + } + + export function defineRpc(definition: { + name: string; + input: InputSchema; + output: OutputSchema; + }): PluginRpcContract; + + export function defineAttachmentSource( + definition: Definition, + ): Definition; + + export const PluginAttachmentItemSchema: import("zod").ZodType; + export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType; +} + +declare module "@paseo/plugin" { + import type { ComponentType } from "react"; + import type { PaseoApi } from "@getpaseo/client"; + import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; + import type { + PluginAttachmentSourceContribution, + PluginHandlerContext, + PluginRpcContract, + } from "@paseo/plugin/server"; + + export { + PluginAttachmentItemSchema, + PluginAttachmentSearchPayloadSchema, + defineAttachmentSource, + defineRpc, + type PluginAttachmentItem, + type PluginAttachmentSearchPayload, + type PluginAttachmentSourceContribution, + type PluginHandlerContext, + type PluginRpcContract, + } from "@paseo/plugin/server"; + + export interface PluginTheme { + readonly colors: { + readonly surface0: string; + readonly foreground: string; + readonly foregroundMuted: string; + readonly accent: string; + readonly accentForeground: string; + readonly statusDanger: string; + }; + } + export interface PluginHostProps { - theme: Record; + theme: PluginTheme; host: { id: string; label: string }; layout: { compact: boolean; platform: "ios" | "android" | "web" }; } @@ -65,38 +147,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" { | { id: string; title: string; icon: string; context: "workspace"; Component: ComponentType } | { id: string; title: string; icon: string; context: "agent"; Component: ComponentType }; - export interface PluginRpcContract< - InputSchema extends ZodType = ZodType, - OutputSchema extends ZodType = ZodType, - > { - name: string; - input: InputSchema; - output: OutputSchema; - } - - export interface PluginAttachmentItem { - id: string; - identifier: string; - title: string; - subtitle?: string; - url: string; - text: string; - resourceType: string; - } - - export interface PluginAttachmentSearchPayload { - items: PluginAttachmentItem[]; - } - - export interface PluginAttachmentSourceContribution { - id: string; - title: string; - icon: string; - pickerTitle: string; - searchPlaceholder: string; - search: PluginRpcContract; - } - export interface PluginSidebarContribution { id: string; title: string; @@ -140,10 +190,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" { | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "workspace"; onSelect(context: PluginWorkspaceCommandContext): void | Promise } | { id: string; title: string; icon: string; keywords?: readonly string[]; context: "agent"; onSelect(context: PluginAgentCommandContext): void | Promise }; - export interface PluginHandlerContext { - paseo: PaseoApi; - } - export interface PluginContext { handle( contract: PluginRpcContract, @@ -162,12 +208,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" { export type PluginCleanup = () => void | Promise; export type PluginContribution = (plugin: PluginContext) => PluginCleanup; - export function defineRpc(definition: { - name: string; - input: InputSchema; - output: OutputSchema; - }): PluginRpcContract; - export function useRpc( contract: PluginRpcContract, ): (input: ZodInput) => Promise>; @@ -183,13 +223,6 @@ const SDK_DECLARATIONS = `declare module "@paseo/plugin" { agentId: string, selector: (agent: PluginAgentSnapshot) => Selection, ): Selection | null; - - export function defineAttachmentSource( - definition: Definition, - ): Definition; - - export const PluginAttachmentItemSchema: import("zod").ZodType; - export const PluginAttachmentSearchPayloadSchema: import("zod").ZodType; } `; @@ -218,11 +251,27 @@ export default function contribute(plugin: PluginContext) { } `; -const CLIENT_SURFACE = `import React from "react"; -import { Text } from "react-native"; +const CLIENT_SURFACE = `import type { PluginSurfaceProps } from "@paseo/plugin"; +import React, { useMemo } from "react"; +import { Text, View } from "react-native"; -export function MainSurface() { - return Hello from my plugin; +export function MainSurface({ theme, layout }: PluginSurfaceProps) { + const styles = useMemo( + () => ({ + screen: { + flex: 1, + padding: layout.compact ? 16 : 24, + backgroundColor: theme.colors.surface0, + }, + text: { color: theme.colors.foreground }, + }), + [theme, layout.compact], + ); + return ( + + Hello from my plugin + + ); } `; diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 1a3a0cb65..411ff6f68 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -13,6 +13,11 @@ "source": "./src/index.ts", "default": "./dist/index.js" }, + "./server": { + "types": "./dist/server.d.ts", + "source": "./src/server.ts", + "default": "./dist/server.js" + }, "./host": { "types": "./dist/host.d.ts", "source": "./src/host.ts", diff --git a/packages/plugin/src/contracts.ts b/packages/plugin/src/contracts.ts index 8f1d87e29..2c4088a7d 100644 --- a/packages/plugin/src/contracts.ts +++ b/packages/plugin/src/contracts.ts @@ -3,8 +3,19 @@ import type { PaseoApi } from "@getpaseo/client"; import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { PluginRpcContract } from "./rpc.js"; +export interface PluginTheme { + readonly colors: { + readonly surface0: string; + readonly foreground: string; + readonly foregroundMuted: string; + readonly accent: string; + readonly accentForeground: string; + readonly statusDanger: string; + }; +} + export interface PluginHostProps { - theme: Record; + theme: PluginTheme; host: { id: string; label: string; diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index e28ac318a..cd2bf260b 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -1,9 +1,12 @@ export { PluginAttachmentItemSchema, PluginAttachmentSearchPayloadSchema, + defineAttachmentSource, + defineRpc, type PluginAttachmentItem, type PluginAttachmentSearchPayload, -} from "./attachments.js"; + type PluginRpcContract, +} from "./server.js"; export type { PluginAttachmentSourceContribution, PluginAgentCommandContext, @@ -17,6 +20,7 @@ export type { PluginGlobalCommandContext, PluginHandlerContext, PluginHostProps, + PluginTheme, PluginSidebarContribution, PluginSurfaceContribution, PluginSurfaceProps, @@ -27,12 +31,4 @@ export type { } from "./contracts.js"; export { usePaseo } from "./paseo-context.js"; export { useAgent, useWorkspace } from "./client-state.js"; -import type { PluginAttachmentSourceContribution } from "./contracts.js"; export { useRpc } from "./rpc-context.js"; -export { defineRpc, type PluginRpcContract } from "./rpc.js"; - -export function defineAttachmentSource( - definition: Definition, -): Definition { - return definition; -} diff --git a/packages/plugin/src/server.test.ts b/packages/plugin/src/server.test.ts new file mode 100644 index 000000000..c0eb35576 --- /dev/null +++ b/packages/plugin/src/server.test.ts @@ -0,0 +1,57 @@ +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const srcDir = path.dirname(fileURLToPath(import.meta.url)); +const forbiddenSpecifiers = new Set([ + "react", + "react/jsx-runtime", + "react-native", + "use-sync-external-store", + "use-sync-external-store/shim/with-selector", +]); + +function localFile(fromFile: string, specifier: string): string { + const withoutJs = specifier.replace(/\.js$/, ""); + for (const candidate of [`${withoutJs}.ts`, `${withoutJs}.tsx`, withoutJs]) { + const resolved = path.resolve(path.dirname(fromFile), candidate); + if (existsSync(resolved)) return resolved; + } + throw new Error(`Could not resolve ${specifier} from ${fromFile}`); +} + +function valueImportSpecifiers(source: string): string[] { + const specifiers: string[] = []; + const pattern = /(?:^|\n)(?:export|import)\s+(?!type\b)(?:[\s\S]*?)from\s+["']([^"']+)["']/g; + for (const match of source.matchAll(pattern)) { + if (match[1]) specifiers.push(match[1]); + } + return specifiers; +} + +function collectBareSpecifiers(entry: string): string[] { + const seen = new Set(); + const bare: string[] = []; + const queue = [entry]; + while (queue.length > 0) { + const file = queue.pop(); + if (!file || seen.has(file)) continue; + seen.add(file); + for (const specifier of valueImportSpecifiers(readFileSync(file, "utf8"))) { + if (specifier.startsWith(".")) { + queue.push(localFile(file, specifier)); + continue; + } + bare.push(specifier); + } + } + return bare; +} + +describe("@paseo/plugin/server", () => { + it("does not load react or the client hook graph", () => { + const specifiers = collectBareSpecifiers(path.join(srcDir, "server.ts")); + expect(specifiers.filter((specifier) => forbiddenSpecifiers.has(specifier))).toEqual([]); + }); +}); diff --git a/packages/plugin/src/server.ts b/packages/plugin/src/server.ts new file mode 100644 index 000000000..1260b6271 --- /dev/null +++ b/packages/plugin/src/server.ts @@ -0,0 +1,16 @@ +import type { PluginAttachmentSourceContribution } from "./contracts.js"; + +export { + PluginAttachmentItemSchema, + PluginAttachmentSearchPayloadSchema, + type PluginAttachmentItem, + type PluginAttachmentSearchPayload, +} from "./attachments.js"; +export type { PluginAttachmentSourceContribution, PluginHandlerContext } from "./contracts.js"; +export { defineRpc, type PluginRpcContract } from "./rpc.js"; + +export function defineAttachmentSource( + definition: Definition, +): Definition { + return definition; +} diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts new file mode 100644 index 000000000..545f29c50 --- /dev/null +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -0,0 +1,105 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + compilePlugin, + resolveExistingAsarUnpackedEsbuildBinary, + unpackedEsbuildBinaryFromPackageDir, +} from "./compiler.js"; + +const asarEsbuildDir = path.join("Resources", "app.asar", "node_modules", "esbuild"); + +describe("asar esbuild binary resolution", () => { + it("rewrites an asar package path to the unpacked platform binary", () => { + expect(unpackedEsbuildBinaryFromPackageDir(asarEsbuildDir, "darwin", "arm64")).toBe( + path.join( + "Resources", + "app.asar.unpacked", + "node_modules", + "@esbuild", + "darwin-arm64", + "bin", + "esbuild", + ), + ); + expect(unpackedEsbuildBinaryFromPackageDir(asarEsbuildDir, "win32", "x64")).toBe( + path.join( + "Resources", + "app.asar.unpacked", + "node_modules", + "@esbuild", + "win32-x64", + "esbuild.exe", + ), + ); + }); + + it("ignores package paths that are not inside app.asar", () => { + expect( + unpackedEsbuildBinaryFromPackageDir(path.join("node_modules", "esbuild"), "darwin", "arm64"), + ).toBeNull(); + }); + + it("returns null when the unpacked binary is missing", () => { + expect( + resolveExistingAsarUnpackedEsbuildBinary(asarEsbuildDir, "darwin", "arm64", () => false), + ).toBeNull(); + }); + + it("returns the unpacked path when the binary exists", () => { + expect( + resolveExistingAsarUnpackedEsbuildBinary(asarEsbuildDir, "linux", "x64", () => true), + ).toBe( + path.join( + "Resources", + "app.asar.unpacked", + "node_modules", + "@esbuild", + "linux-x64", + "bin", + "esbuild", + ), + ); + }); +}); + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true })), + ); +}); + +describe("plugin author module externals", () => { + it("leaves @paseo/plugin/server external in both bundles", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-compiler-")); + temporaryDirectories.push(directory); + const entryPath = path.join(directory, "index.ts"); + await writeFile( + entryPath, + `import type { PluginContext } from "@paseo/plugin"; +import { defineRpc } from "@paseo/plugin/server"; +import { z } from "zod"; + +const ping = defineRpc({ + name: "ping", + input: z.object({}), + output: z.object({ ok: z.boolean() }), +}); + +export default function contribute(plugin: PluginContext) { + plugin.handle(ping, async () => ({ ok: true })); + return () => undefined; +} +`, + ); + + const { clientBundle, serverBundle } = await compilePlugin(entryPath); + expect(clientBundle).toContain("@paseo/plugin/server"); + expect(serverBundle).toContain("@paseo/plugin/server"); + expect(clientBundle).not.toContain("Invalid plugin RPC method"); + expect(serverBundle).not.toContain("Invalid plugin RPC method"); + }); +}); diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index 86ac22ecf..f63888d3b 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -1,7 +1,57 @@ +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; import path from "node:path"; import { parse } from "@babel/parser"; -import { build, type Plugin } from "esbuild"; +import type { Plugin } from "esbuild"; + +// esbuild resolves its own platform binary via require.resolve() the first time its +// module is evaluated. Inside the packaged desktop app that resolves to a path under +// app.asar even though electron-builder unpacks the real binary to app.asar.unpacked. +// child_process.spawn bypasses Electron's asar fs shim, so the OS rejects that path +// with ENOTDIR. Point esbuild at the real unpacked binary before its module loads. +export function unpackedEsbuildBinaryFromPackageDir( + esbuildPackageDir: string, + platform: NodeJS.Platform, + arch: string, +): string | null { + const asarSegment = `${path.sep}app.asar${path.sep}`; + const asarIndex = esbuildPackageDir.indexOf(asarSegment); + if (asarIndex === -1) return null; + return path.join( + esbuildPackageDir.slice(0, asarIndex), + "app.asar.unpacked", + "node_modules", + `@esbuild/${platform}-${arch}`, + ...(platform === "win32" ? ["esbuild.exe"] : ["bin", "esbuild"]), + ); +} + +export function resolveExistingAsarUnpackedEsbuildBinary( + esbuildPackageDir: string, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, + exists: (file: string) => boolean = existsSync, +): string | null { + const binaryPath = unpackedEsbuildBinaryFromPackageDir(esbuildPackageDir, platform, arch); + return binaryPath && exists(binaryPath) ? binaryPath : null; +} + +function resolveAsarUnpackedEsbuildBinary(): string | null { + const require = createRequire(import.meta.url); + let esbuildDir: string; + try { + esbuildDir = path.dirname(require.resolve("esbuild/package.json")); + } catch { + return null; + } + return resolveExistingAsarUnpackedEsbuildBinary(esbuildDir); +} + +if (!process.env.ESBUILD_BINARY_PATH) { + const unpackedBinary = resolveAsarUnpackedEsbuildBinary(); + if (unpackedBinary) process.env.ESBUILD_BINARY_PATH = unpackedBinary; +} type PluginBuildTarget = "client" | "server"; @@ -228,6 +278,7 @@ function createUnusedPlatformModulePlugin(target: PluginBuildTarget): Plugin { } async function compileTarget(entryPath: string, target: PluginBuildTarget): Promise { + const { build } = await import("esbuild"); const source = await readFile(entryPath, "utf8"); const filteredSource = filterEntrypoint(source, target); const result = await build({ @@ -245,13 +296,14 @@ async function compileTarget(entryPath: string, target: PluginBuildTarget): Prom target === "client" ? [ "@paseo/plugin", + "@paseo/plugin/server", "@tanstack/react-query", "react", "react/jsx-runtime", "react-native", "zod", ] - : ["@paseo/plugin", "zod"], + : ["@paseo/plugin", "@paseo/plugin/server", "zod"], plugins: [createRuntimeBoundaryPlugin(target), createUnusedPlatformModulePlugin(target)], logLevel: "silent", treeShaking: true, diff --git a/packages/server/src/server/plugins/plugin-process.ts b/packages/server/src/server/plugins/plugin-process.ts index c905acee3..65a62540c 100644 --- a/packages/server/src/server/plugins/plugin-process.ts +++ b/packages/server/src/server/plugins/plugin-process.ts @@ -5,7 +5,7 @@ import { defineRpc, type PluginHandlerContext, type PluginRpcContract, -} from "@paseo/plugin"; +} from "@paseo/plugin/server"; import { createPaseoApi, type PaseoApi } from "@getpaseo/client"; import { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { createPluginDaemonTransportFactory } from "./daemon-transport.js"; @@ -61,8 +61,10 @@ function register(contract: PluginRpcContract, handler: RpcHandler): void { handlers.set(method, { contract: { ...contract, name: method }, handler }); } +const pluginAuthorRuntime = { defineAttachmentSource, defineRpc }; + function runtimeRequire(name: string): unknown { - if (name === "@paseo/plugin") return { defineAttachmentSource, defineRpc }; + if (name === "@paseo/plugin" || name === "@paseo/plugin/server") return pluginAuthorRuntime; return nodeRequire(name); } diff --git a/packages/server/src/server/plugins/runtime.posix.test.ts b/packages/server/src/server/plugins/runtime.posix.test.ts index 1492bec8d..850cca304 100644 --- a/packages/server/src/server/plugins/runtime.posix.test.ts +++ b/packages/server/src/server/plugins/runtime.posix.test.ts @@ -597,7 +597,7 @@ export function Surface() { ), writeFile( path.join(directory, "inspect.shared.ts"), - `import { defineRpc } from "@paseo/plugin"; + `import { defineRpc } from "@paseo/plugin/server"; import { z } from "zod"; export const inspectRpc = defineRpc({ diff --git a/plugin-examples/linear/issues.server.ts b/plugin-examples/linear/issues.server.ts index 97ccb2f94..d2f7ed273 100644 --- a/plugin-examples/linear/issues.server.ts +++ b/plugin-examples/linear/issues.server.ts @@ -1,4 +1,4 @@ -import type { PluginAttachmentSearchPayload } from "@paseo/plugin"; +import type { PluginAttachmentSearchPayload } from "@paseo/plugin/server"; import type { output as ZodOutput } from "zod"; import { searchIssuesRpc } from "./issues.shared"; import { createLinearIssueSearch } from "./linear"; diff --git a/plugin-examples/linear/issues.shared.ts b/plugin-examples/linear/issues.shared.ts index b20d21c58..f46ee50b1 100644 --- a/plugin-examples/linear/issues.shared.ts +++ b/plugin-examples/linear/issues.shared.ts @@ -1,4 +1,4 @@ -import { defineAttachmentSource, defineRpc } from "@paseo/plugin"; +import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server"; import { z } from "zod"; const SearchPayloadSchema = z.object({ diff --git a/plugin-examples/local-plugin/increment.shared.ts b/plugin-examples/local-plugin/increment.shared.ts index 2b3f940af..4f7116eee 100644 --- a/plugin-examples/local-plugin/increment.shared.ts +++ b/plugin-examples/local-plugin/increment.shared.ts @@ -1,4 +1,4 @@ -import { defineRpc } from "@paseo/plugin"; +import { defineRpc } from "@paseo/plugin/server"; import { z } from "zod"; export const incrementRpc = defineRpc({ diff --git a/plugin-examples/local-plugin/main.client.tsx b/plugin-examples/local-plugin/main.client.tsx index 5be6a9e6d..bfaf6bba7 100644 --- a/plugin-examples/local-plugin/main.client.tsx +++ b/plugin-examples/local-plugin/main.client.tsx @@ -2,36 +2,28 @@ import { useMutation } from "@tanstack/react-query"; import { type PluginWorkspacePanelProps, useRpc, useWorkspace } from "@paseo/plugin"; import React, { useCallback, useMemo } from "react"; import { Pressable, Text, View } from "react-native"; -import { z } from "zod"; import { incrementRpc } from "./increment.shared"; -const ExampleThemeSchema = z.object({ - colors: z.object({ - surface0: z.string(), - foreground: z.string(), - foregroundMuted: z.string(), - accent: z.string(), - accentForeground: z.string(), - statusDanger: z.string(), - }), -}); - -export function ExamplePanel({ theme, workspaceId }: PluginWorkspacePanelProps) { - const { colors } = ExampleThemeSchema.parse(theme); +export function ExamplePanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) { const workspace = useWorkspace(workspaceId, ({ name }) => ({ name })); const callIncrement = useRpc(incrementRpc); const { data, error, isPending, mutate } = useMutation({ mutationFn: callIncrement }); const value = data?.value ?? 0; const styles = useMemo( () => ({ - screen: { flex: 1, padding: 24, gap: 16, backgroundColor: colors.surface0 }, - title: { color: colors.foreground, fontSize: 24 }, - detail: { color: colors.foregroundMuted }, - button: { padding: 14, borderRadius: 10, backgroundColor: colors.accent }, - buttonText: { color: colors.accentForeground, textAlign: "center" as const }, - error: { color: colors.statusDanger }, + screen: { + flex: 1, + padding: layout.compact ? 16 : 24, + gap: 16, + backgroundColor: theme.colors.surface0, + }, + title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 }, + detail: { color: theme.colors.foregroundMuted }, + button: { padding: 14, borderRadius: 10, backgroundColor: theme.colors.accent }, + buttonText: { color: theme.colors.accentForeground, textAlign: "center" as const }, + error: { color: theme.colors.statusDanger }, }), - [colors], + [theme, layout.compact], ); const handleIncrement = useCallback(() => { mutate({ value }); diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index ed0992407..56ff48061 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -34,29 +34,43 @@ npm install `init` creates a strict TypeScript project. It does not run the package manager. `index.ts` registers contributions; client UI lives in `*.client.tsx` files. +Plugins run on desktop, browser, iOS, and Android. Paseo ships several themes. Color every `Text` from `theme.colors.foreground` or `theme.colors.foregroundMuted`, and size layout from `layout.compact`. Hardcoded black text fails in dark themes. + Replace `main.client.tsx` with: ```tsx import { type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin"; -import { StyleSheet, Text, View } from "react-native"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; -export function WorkspaceOverview({ workspaceId }: PluginWorkspacePanelProps) { +export function WorkspaceOverview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) { const workspace = useWorkspace(workspaceId, ({ name, directory }) => ({ name, directory, })); + const styles = useMemo( + () => ({ + screen: { + flex: 1, + padding: layout.compact ? 16 : 24, + gap: layout.compact ? 8 : 12, + backgroundColor: theme.colors.surface0, + }, + title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 }, + label: { color: theme.colors.foregroundMuted }, + detail: { color: theme.colors.foreground }, + }), + [theme, layout.compact], + ); + return ( {workspace?.name} - {workspace?.directory} + Directory + {workspace?.directory} ); } - -const styles = StyleSheet.create({ - screen: { flex: 1, padding: 24, gap: 8 }, - title: { fontSize: 24 }, -}); ``` Replace `index.ts` with: @@ -86,7 +100,7 @@ export default function contribute(plugin: PluginContext) { } ``` -The icon is a [Lucide](https://lucide.dev/icons/) icon name. `*.client.tsx` files can use React Native runtime APIs such as `StyleSheet.create`; Paseo excludes them from the daemon bundle. The panel works in the desktop, browser, iOS, and Android clients. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. +The icon is a [Lucide](https://lucide.dev/icons/) icon name. `*.client.tsx` files can use React Native runtime APIs; Paseo excludes them from the daemon bundle. Panel props contain stable IDs; `useWorkspace` selects the cached fields the component needs without fetching through RPC or re-rendering for unrelated workspace changes. See [Theme and layout](/docs/plugins/reference#theme-and-layout) for the required tokens. ## Check and install it @@ -96,7 +110,7 @@ paseo plugin install /absolute/path/to/workspace-plugin paseo plugin ls ``` -Open a workspace, open the Command Center, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it. +Open a workspace, press **⌘K** on macOS or **Ctrl+K** on Windows and Linux, and choose **Open workspace overview**. It opens as a normal workspace tab. If the item does not appear, confirm that **Enable plugins** is on, the plugin status is `running` in `paseo plugin ls`, and the client is viewing the host where you installed it. ## Edit and reload diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 1a8c2b9e5..43fa6c924 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -41,7 +41,7 @@ The manifest contains the default plugin ID: Plugin, surface, sidebar-item, workspace-panel, Command Center item, and attachment-source IDs start with a lowercase letter and contain lowercase letters, numbers, or hyphens. -The generated declaration file supplies `@paseo/plugin` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes. +The generated declaration file supplies `@paseo/plugin` and `@paseo/plugin/server` types for local typechecking. Paseo supplies the runtime modules. Regenerate a fresh project with the matching CLI when the plugin contract changes. Add runtime-specific files as the plugin grows: @@ -58,6 +58,13 @@ my-plugin/ | `*.server.ts` | Node APIs, local resources, credentials, and RPC handlers. | | `*.shared.ts` | Zod RPC contracts and plain values imported by both runtimes. | +## SDK modules + +| Module | Use it for | +| ---------------------- | -------------------------------------------------------- | +| `@paseo/plugin` | hooks and UI types | +| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, and handler types | + Paseo rejects imports from `*.server` files into client modules and imports from `*.client` files into server modules. Keep shared modules free of Node and React Native runtime code. ## Entry point and cleanup @@ -84,10 +91,28 @@ Register a component, then point a sidebar item at its surface ID: ```tsx import type { PluginSurfaceProps } from "@paseo/plugin"; -import { Text } from "react-native"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; -export function Main({ host, layout }: PluginSurfaceProps) { - return {`${host.label} · ${layout.platform}`}; +export function Main({ theme, host, layout }: PluginSurfaceProps) { + const styles = useMemo( + () => ({ + screen: { + flex: 1, + padding: layout.compact ? 16 : 24, + backgroundColor: theme.colors.surface0, + }, + title: { color: theme.colors.foreground }, + detail: { color: theme.colors.foregroundMuted }, + }), + [theme, layout.compact], + ); + return ( + + {host.label} + {layout.platform} + + ); } ``` @@ -111,15 +136,33 @@ export default function contribute(plugin: PluginContext) { `PluginSurfaceProps` contains: -| Field | Meaning | -| -------- | --------------------------------------------------------- | -| `theme` | Paseo theme values. Validate the keys your surface reads. | -| `host` | Selected host `id` and display `label`. | -| `layout` | `compact` and the `ios`, `android`, or `web` platform. | +| Field | Meaning | +| -------- | ------------------------------------------------------------ | +| `theme` | Typed `PluginTheme` color tokens for the active Paseo theme. | +| `host` | Selected host `id` and display `label`. | +| `layout` | `compact` and the `ios`, `android`, or `web` platform. | Paseo owns the route, header, close action, host picker, error boundary, and query client. The plugin owns the surface body. Icons use [Lucide](https://lucide.dev/icons/) names. -Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, and `@paseo/plugin`. Install them locally for typechecking; Paseo provides the client runtime instances. +## Theme and layout + +Plugin UI runs on desktop, browser, iOS, and Android, across every Paseo theme. `theme` is a typed `PluginTheme`. Color and spacing must come from those props. Unstyled `Text` is black and fails in dark themes. + +Recreate styles when `theme` or `layout.compact` changes. + +| Key | Required for | Use it for | +| ------------------------------ | -------------------------- | ----------------------------------- | +| `theme.colors.foreground` | Every primary `Text` | Titles and body copy | +| `theme.colors.foregroundMuted` | Secondary `Text` | Labels and supporting copy | +| `theme.colors.surface0` | Root view | Panel background | +| `layout.compact` | Padding and stacking | `true` on mobile and narrow windows | +| `layout.platform` | Platform-specific behavior | `ios`, `android`, or `web` | + +Do not hardcode `#000`, `#fff`, or React Native's default text color. Primary copy uses `foreground`. Labels use `foregroundMuted`. Tighten padding when `layout.compact` is true. + +Workspace and agent panels receive the same `theme` and `layout` fields. + +Client code can import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@paseo/plugin`, and `@paseo/plugin/server`. Install them locally for typechecking; Paseo provides the client runtime instances. ## Workspace panels @@ -129,12 +172,30 @@ Register one panel for workspace or agent context: ```tsx import { type PluginAgentPanelProps, useAgent, useWorkspace } from "@paseo/plugin"; -import { Text } from "react-native"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; -export function ReviewPanel({ workspaceId, agentId }: PluginAgentPanelProps) { +export function ReviewPanel({ theme, layout, workspaceId, agentId }: PluginAgentPanelProps) { const workspaceName = useWorkspace(workspaceId, (workspace) => workspace.name); const agent = useAgent(agentId, ({ id, title }) => ({ id, title })); - return {`${workspaceName} · ${agent?.title ?? agent?.id}`}; + const styles = useMemo( + () => ({ + screen: { + flex: 1, + padding: layout.compact ? 16 : 24, + backgroundColor: theme.colors.surface0, + }, + title: { color: theme.colors.foreground }, + detail: { color: theme.colors.foregroundMuted }, + }), + [theme, layout.compact], + ); + return ( + + {workspaceName} + {agent?.title ?? agent?.id} + + ); } ``` @@ -215,10 +276,12 @@ Paseo owns tab focus, splitting, closing, persistence, query state, the API/RPC ## Command Center items +Open the Command Center with **⌘K** on macOS or **Ctrl+K** on Windows and Linux, then search for the item title. + Register an action and open a panel from the callback: ```tsx -import { defineRpc } from "@paseo/plugin"; +import { defineRpc } from "@paseo/plugin/server"; import { z } from "zod"; const refreshReview = defineRpc({ @@ -314,7 +377,7 @@ Define one contract with Zod, handle it in the subprocess, and call it from the `greeting.shared.ts`: ```ts -import { defineRpc } from "@paseo/plugin"; +import { defineRpc } from "@paseo/plugin/server"; import { z } from "zod"; export const greeting = defineRpc({ @@ -411,7 +474,7 @@ An attachment source searches external resources and returns a stable text snaps `issues.shared.ts`: ```ts -import { defineAttachmentSource, defineRpc } from "@paseo/plugin"; +import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server"; import { z } from "zod"; export const searchIssues = defineRpc({ diff --git a/skills/paseo-plugin/SKILL.md b/skills/paseo-plugin/SKILL.md index fb1792588..c70cc329d 100644 --- a/skills/paseo-plugin/SKILL.md +++ b/skills/paseo-plugin/SKILL.md @@ -62,16 +62,34 @@ Cleanup can be async. Use it for timers, watchers, sockets, and other resources ## Add a workspace panel -Workspace panels live beside agents, terminals, files, and diffs. Their props contain stable IDs; -required-selector hooks read cached client state without subscribing to unrelated fields: +Workspace panels live beside agents, terminals, files, and diffs. Plugins run on desktop and +mobile, and Paseo has multiple themes. Every `Text` must take its color from `theme.colors`. +Use `layout.compact` for padding and stacking. Unstyled text is black and fails in dark themes. ```tsx import { type PluginContext, type PluginWorkspacePanelProps, useWorkspace } from "@paseo/plugin"; -import { Text } from "react-native"; +import { useMemo } from "react"; +import { Text, View } from "react-native"; -function Overview({ workspaceId }: PluginWorkspacePanelProps) { +function Overview({ theme, layout, workspaceId }: PluginWorkspacePanelProps) { const name = useWorkspace(workspaceId, (workspace) => workspace.name); - return {name}; + const styles = useMemo( + () => ({ + screen: { + flex: 1, + padding: layout.compact ? 16 : 24, + gap: layout.compact ? 8 : 12, + backgroundColor: theme.colors.surface0, + }, + title: { color: theme.colors.foreground, fontSize: layout.compact ? 20 : 24 }, + }), + [theme, layout.compact], + ); + return ( + + {name} + + ); } export default function contribute(plugin: PluginContext) { @@ -102,24 +120,39 @@ the active workspace or agent. Command callbacks receive the selected host's `pa ## Add a sidebar surface -Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item: +Plugin surfaces use React Native primitives and work across desktop, browser, iOS, and Android. Register the surface before its sidebar item. Color text from `theme.colors` and pad from `layout.compact`. ```tsx -import type { PluginContext } from "@paseo/plugin"; -import React, { useState } from "react"; +import type { PluginContext, PluginSurfaceProps } from "@paseo/plugin"; +import React, { useMemo, useState } from "react"; import { Pressable, Text, View } from "react-native"; -function Counter() { +function Counter({ theme, layout }: PluginSurfaceProps) { const [count, setCount] = useState(0); + const styles = useMemo( + () => ({ + screen: { + flex: 1, + padding: layout.compact ? 16 : 24, + gap: 16, + backgroundColor: theme.colors.surface0, + }, + count: { color: theme.colors.foreground, fontSize: layout.compact ? 36 : 48 }, + button: { padding: 14, borderRadius: 10, backgroundColor: theme.colors.accent }, + buttonText: { color: theme.colors.accentForeground, textAlign: "center" as const }, + }), + [theme, layout.compact], + ); return ( - - {count} + + {count} setCount((value) => value + 1)} + style={styles.button} > - Count me in + Count me in ); @@ -137,9 +170,14 @@ export default function contribute(plugin: PluginContext) { } ``` -Icons are Lucide icon names. `PluginSurfaceProps` provides `theme`, selected `host`, and `layout`. Validate the theme keys the surface reads. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client. +Icons are Lucide icon names. `theme` is a typed `PluginTheme` on every surface and panel. Primary text uses `theme.colors.foreground`; labels use `theme.colors.foregroundMuted`; the root view uses `theme.colors.surface0`. `layout.compact` is true on mobile and narrow windows. Paseo owns the route, header, host picker, close action, error boundary, and per-installation query client. -Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, and `@paseo/plugin`. Install dependencies locally for typechecking; Paseo supplies these runtime modules. +Client code may import `react`, `react-native`, `@tanstack/react-query`, `zod`, `@paseo/plugin`, and `@paseo/plugin/server`. Install dependencies locally for typechecking; Paseo supplies these runtime modules. + +| Module | Use it for | +| ---------------------- | ---------------------------------------------------- | +| `@paseo/plugin` | hooks and UI types | +| `@paseo/plugin/server` | `defineRpc`, `defineAttachmentSource`, handler types | ## Choose the correct API @@ -183,7 +221,9 @@ The API covers workspaces, agents, providers, and daemon config. It omits connec Define one Zod contract, register its subprocess handler, and call it with `useRpc()`: ```tsx -import { defineRpc, type PluginContext, useRpc } from "@paseo/plugin"; +import type { PluginContext } from "@paseo/plugin"; +import { useRpc } from "@paseo/plugin"; +import { defineRpc } from "@paseo/plugin/server"; import { z } from "zod"; const greeting = defineRpc({ @@ -235,7 +275,8 @@ daemon log. Never log credentials or other secrets. Define a search RPC and register a declarative source: ```tsx -import { defineAttachmentSource, defineRpc, type PluginContext } from "@paseo/plugin"; +import type { PluginContext } from "@paseo/plugin"; +import { defineAttachmentSource, defineRpc } from "@paseo/plugin/server"; import { z } from "zod"; const searchIssues = defineRpc({ @@ -328,7 +369,7 @@ After a change: 1. Run `npm run typecheck`. 2. Install or reload the exact runtime ID. 3. Run `paseo plugin ls` and require `running` with no error. -4. Confirm the contribution on the intended host. For UI work, check both a wide client and a compact/mobile client when available. +4. Confirm the contribution on the intended host. Open the Command Center with **⌘K** (macOS) or **Ctrl+K** (Windows/Linux). For UI work, check a wide desktop window and a compact/mobile client, and switch theme to confirm text still uses `foreground` / `foregroundMuted`. 5. Exercise the changed action or RPC, including its error state. Common failures: