Files
paseo/packages/cli/src/commands/plugin/scaffold.ts
T
Mohamed Boudra 2d85fe4e04 feat(plugins): declare display metadata in plugin manifests (#6263)
* feat(plugins): declare display metadata in plugin manifests

* style(website): format plugin page description

* fix(plugins): preserve default names and clarify registry follow-up

* test: fix stale plugin card selectors and terminal marker race
2026-10-06 23:18:24 +01:00

241 lines
8.0 KiB
TypeScript

import { mkdir, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { PluginIdSchema } from "@getpaseo/protocol/messages";
import { resolveCliVersion } from "../../version.js";
const OVERVIEW = `<!--
OVERVIEW.md is required to list your plugin in the registry. Keep it beside
paseo-plugin.json in your repository at the pinned commit and in your npm package.
Replace this comment with useful facts before publishing.
This overview is for people choosing a plugin in Paseo, on the plugin page under
its install command. README assumes a GitHub audience and includes technical
details, installation instructions, and badges. Long, AI-generated READMEs leave
people reading past that material to understand what the plugin does.
Write these parts in order, including only what helps someone decide to install:
1. Describe what your plugin is and does in plain terms first.
2. Explain how it works only when it is not obvious.
3. Explain setup when needed: settings, accounts, tokens, providers, tools, or
other plugins. Include applicable daemon version and operating system
requirements. Setup guidance is allowed; installation instructions are not.
4. Explain capabilities and settings worth understanding, what each option does,
what the plugin reads or sends and where, permissions, and known limits.
Length follows complexity; a theme needs one paragraph. Choose headings only
when they help. Use sentence case and plain factual language, with no em dashes.
Omit installation commands, badges, changelog, contributing or license sections,
marketing, and unsupported claims. Avoid implementation filler such as empty
cleanup functions, theme-token field lists, or lists of absent features.
-->
`;
const TSCONFIG = {
compilerOptions: {
target: "ES2020",
module: "ESNext",
moduleResolution: "Bundler",
lib: ["ES2023"],
types: ["react"],
jsx: "react-jsx",
strict: true,
skipLibCheck: true,
noEmit: true,
esModuleInterop: true,
allowSyntheticDefaultImports: true,
},
include: ["**/*.ts", "**/*.tsx"],
};
const CLIENT_ENTRY = `import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
import { SidebarRow } from "@getpaseo/plugin/client/ui";
import { GreetingScreen } from "./client/greeting";
function GreetingItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
return (
<SidebarRow
icon="MessageCircle"
active={currentScreen?.screenId === "greeting"}
onPress={() => openScreen({ screenId: "greeting" })}
/>
);
}
export default function contribute(client: PluginClientContext) {
client.addScreen({ id: "greeting", title: "Greeting", Component: GreetingScreen });
client.addSidebarHeaderItem({ id: "greeting", title: "Greeting", Component: GreetingItem });
return () => {};
}
`;
const SERVER_ENTRY = `import type { PluginServerContext } from "@getpaseo/plugin/server";
import { createGreeting } from "./server/greeting";
import { greetingRpc } from "./shared/greeting";
export default function contribute(server: PluginServerContext) {
server.handle(greetingRpc, createGreeting);
return () => {};
}
`;
const SHARED_GREETING = `import { defineRpc } from "@getpaseo/plugin";
import { z } from "zod";
export const greetingRpc = defineRpc({
name: "greeting.create",
input: z.object({ name: z.string() }),
output: z.object({ message: z.string() }),
});
`;
const SERVER_GREETING = `import type { RpcInput } from "@getpaseo/plugin";
import { greetingRpc } from "../shared/greeting";
export function createGreeting({ name }: RpcInput<typeof greetingRpc>) {
return { message: "Hello, " + name + "!" };
}
`;
const CLIENT_GREETING = `import type { PluginScreenProps } from "@getpaseo/plugin/client";
import { useRpc } from "@getpaseo/plugin/client";
import { useMutation } from "@tanstack/react-query";
import { useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { greetingRpc } from "../shared/greeting";
import { openExternal } from "./web";
export function GreetingScreen({ theme, layout }: PluginScreenProps) {
const createGreeting = useRpc(greetingRpc);
const greeting = useMutation({ mutationFn: createGreeting });
const styles = useMemo(
() => ({
screen: {
flex: 1,
padding: layout.compact ? 16 : 24,
backgroundColor: theme.colors.surface0,
},
text: { color: theme.colors.foreground },
button: { padding: 12, backgroundColor: theme.colors.accent },
buttonText: { color: theme.colors.accentForeground },
}),
[theme, layout.compact],
);
return (
<View style={styles.screen}>
<Text style={styles.text}>{greeting.data?.message ?? "Ask the daemon for a greeting."}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Create greeting"
style={styles.button}
onPress={() => greeting.mutate({ name: "Paseo" })}
>
<Text style={styles.buttonText}>Create greeting</Text>
</Pressable>
<Pressable
accessibilityRole="link"
accessibilityLabel="Open the Paseo website"
style={styles.button}
onPress={() => openExternal("https://paseo.sh")}
>
<Text style={styles.buttonText}>Open paseo.sh</Text>
</Pressable>
</View>
);
}
`;
const CLIENT_WEB = `import { Linking, Platform } from "react-native";
// This plugin typechecks without the DOM library. Declare only what this module uses.
declare const window: { open(url: string, target: string, features: string): unknown };
export async function openExternal(url: string): Promise<void> {
if (Platform.OS === "web") {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
await Linking.openURL(url);
}
`;
export interface PluginScaffold {
id: string;
directory: string;
}
export async function scaffoldPluginDirectory(
targetDirectory: string,
requestedId?: string,
): Promise<PluginScaffold> {
const directory = path.resolve(targetDirectory);
const id = PluginIdSchema.parse(requestedId ?? path.basename(directory));
await mkdir(directory, { recursive: true });
const existing = await readdir(directory);
if (existing.length > 0) {
throw new Error(`Plugin directory must be empty: ${directory}`);
}
const version = resolveCliVersion();
const packageJson = {
name: id,
private: true,
version: "0.0.0",
files: [
"paseo-plugin.json",
"OVERVIEW.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
"index.server.tsx",
"client/",
"server/",
"shared/",
],
scripts: { typecheck: "tsc --noEmit" },
devDependencies: {
"@getpaseo/plugin": version,
"@tanstack/react-query": "^5.90.11",
"@types/react": "~19.2.0",
react: "19.1.0",
"react-native": "0.81.5",
typescript: "^5.9.3",
zod: "^4.4.3",
},
};
const files = new Map<string, string>([
[
"paseo-plugin.json",
`${JSON.stringify(
{
id,
$comment:
'Add "name": "My plugin" to set a display name. Add "icon": "assets/icon.png" for a PNG inside the package. Add image or video paths (relative to this manifest) or HTTPS URLs to media. Include local assets in package.json files.',
media: [],
requirements: { paseo: `>=${version}` },
},
null,
2,
)}\n`,
],
["OVERVIEW.md", OVERVIEW],
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
["tsconfig.json", `${JSON.stringify(TSCONFIG, null, 2)}\n`],
["index.client.tsx", CLIENT_ENTRY],
["index.server.ts", SERVER_ENTRY],
["shared/greeting.ts", SHARED_GREETING],
["server/greeting.ts", SERVER_GREETING],
["client/greeting.tsx", CLIENT_GREETING],
["client/web.ts", CLIENT_WEB],
]);
await Promise.all(
["shared", "server", "client"].map((name) => mkdir(path.join(directory, name))),
);
await Promise.all(
[...files].map(([filename, contents]) =>
writeFile(path.join(directory, filename), contents, { flag: "wx" }),
),
);
return { id, directory };
}