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
This commit is contained in:
Mohamed Boudra
2026-10-06 23:18:24 +01:00
committed by GitHub
parent 1463396ebc
commit 2d85fe4e04
14 changed files with 224 additions and 38 deletions
@@ -93,6 +93,8 @@ describe("plugin scaffold", () => {
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual( expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
{ {
id: "hello-plugin", id: "hello-plugin",
$comment: expect.stringContaining('"icon": "assets/icon.png"'),
media: [],
requirements: { paseo: `>=${cliPackageJson.version}` }, requirements: { paseo: `>=${cliPackageJson.version}` },
}, },
); );
+11 -1
View File
@@ -206,7 +206,17 @@ export async function scaffoldPluginDirectory(
const files = new Map<string, string>([ const files = new Map<string, string>([
[ [
"paseo-plugin.json", "paseo-plugin.json",
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`, `${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], ["OVERVIEW.md", OVERVIEW],
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`], ["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
@@ -23,6 +23,27 @@ describe("plugin protocol compatibility", () => {
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message); expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
}, },
); );
it("preserves manifest metadata in plugin list responses", () => {
const message = {
type: "plugin.list.response",
payload: {
requestId: "metadata",
plugins: [
{
id: "review",
name: "Review tools",
icon: "assets/icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
path: "/plugins/review",
enabled: false,
status: "disabled",
},
],
},
};
expect(SessionOutboundMessageSchema.parse(message)).toEqual(message);
});
it("parses plugin timeline append messages and advertises the capability", () => { it("parses plugin timeline append messages and advertises the capability", () => {
expect( expect(
SessionInboundMessageSchema.parse({ SessionInboundMessageSchema.parse({
+3
View File
@@ -6632,6 +6632,9 @@ export const PluginNpmInstallationSchema = z.object({
export const PluginListItemSchema = z.object({ export const PluginListItemSchema = z.object({
id: PluginIdSchema, id: PluginIdSchema,
name: z.string().optional(),
icon: z.string().optional(),
media: z.array(z.string()).optional(),
description: z.string().optional(), description: z.string().optional(),
path: z.string(), path: z.string(),
enabled: z.boolean(), enabled: z.boolean(),
@@ -419,7 +419,7 @@ describe("PluginService", () => {
await service.stopAllPlugins(); await service.stopAllPlugins();
}, 20_000); }, 20_000);
it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => { it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-")); const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home); roots.push(home);
const running = await createPlugin("running", "export default () => () => {};"); const running = await createPlugin("running", "export default () => () => {};");
@@ -427,7 +427,13 @@ describe("PluginService", () => {
const malformed = await createPlugin("malformed", "export default () => () => {};"); const malformed = await createPlugin("malformed", "export default () => () => {};");
await writeFile( await writeFile(
path.join(running, "paseo-plugin.json"), path.join(running, "paseo-plugin.json"),
JSON.stringify({ id: "running", description: "Runs checks" }), JSON.stringify({
id: "running",
description: "Runs checks",
name: "Checks",
icon: "icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
}),
); );
await writeFile( await writeFile(
path.join(disabled, "paseo-plugin.json"), path.join(disabled, "paseo-plugin.json"),
@@ -443,11 +449,23 @@ describe("PluginService", () => {
await service.start(); await service.start();
expect( expect(
(await service.listPlugins()).map(({ id, description }) => ({ id, description })), (await service.listPlugins()).map(({ id, description, name, icon, media }) => ({
id,
description,
name,
icon,
media,
})),
).toEqual([ ).toEqual([
{ id: "disabled", description: "Waits until enabled" }, { id: "disabled", description: "Waits until enabled" },
{ id: "malformed", description: undefined }, { id: "malformed", description: undefined },
{ id: "running", description: "Runs checks" }, {
id: "running",
description: "Runs checks",
name: "Checks",
icon: "icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
},
]); ]);
await service.stopAllPlugins(); await service.stopAllPlugins();
}); });
+6 -1
View File
@@ -216,7 +216,12 @@ export class PluginService {
}), }),
}; };
const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null); const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null);
if (manifest?.description) item.description = manifest.description; if (manifest) {
item.name = manifest.name;
item.description = manifest.description;
item.icon = manifest.icon;
item.media = manifest.media;
}
item.installation = await this.managedSources item.installation = await this.managedSources
?.describe(id, source.path) ?.describe(id, source.path)
.catch(() => undefined); .catch(() => undefined);
@@ -24,6 +24,69 @@ describe("plugin manifest", () => {
}); });
}); });
it("reads display metadata and ignores future top-level fields", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
const manifest = {
id: "review",
name: "Review tools",
icon: "assets/icon.png",
media: ["assets/screenshot.webp", "demo.mp4", "https://example.com/media?id=1"],
};
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ ...manifest, futureField: { enabled: true } }),
);
await expect(readPluginManifest(directory)).resolves.toEqual(manifest);
});
it.each([
{ name: " " },
{ name: 42 },
{ icon: "" },
{ icon: 42 },
{ icon: "icon.svg" },
{ icon: "/icon.png" },
{ icon: "../icon.png" },
{ icon: "assets/../../icon.png" },
{ icon: "https://example.com/icon.png" },
{ icon: "C:/icon.png" },
{ icon: "assets\\icon.png" },
{ icon: "icon.png?query" },
{ media: "screenshot.png" },
{ media: [42] },
{ media: [""] },
{ media: ["../demo.mp4"] },
{ media: ["/demo.mp4"] },
{ media: ["http://example.com/demo.mp4"] },
{ media: ["https://"] },
{ media: ["//example.com/demo.mp4"] },
{ media: ["file:///demo.mp4"] },
{ media: ["data:image/png;base64,abc"] },
{ media: ["."] },
{ media: ["assets/"] },
{ id: "INVALID" },
{ description: 42 },
{ requirements: { paseoo: ">=0.11.0" } },
])("rejects invalid known fields: %j", async (fields) => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: "example", futureField: true, ...fields }),
);
await expect(readPluginManifest(directory)).rejects.toThrow();
});
it("accepts omitted metadata and empty media", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
for (const manifest of [{ id: "example" }, { id: "example", media: [] }]) {
await writeFile(path.join(directory, "paseo-plugin.json"), JSON.stringify(manifest));
await expect(readPluginManifest(directory)).resolves.toEqual(manifest);
}
});
it("reads and validates requirements before any plugin code runs", async () => { it("reads and validates requirements before any plugin code runs", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-")); const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory); directories.push(directory);
+27 -8
View File
@@ -8,14 +8,33 @@ const MANIFEST_FILENAME = "paseo-plugin.json";
const PluginBuildCommandSchema = z const PluginBuildCommandSchema = z
.array(z.string().refine((argument) => argument.trim().length > 0)) .array(z.string().refine((argument) => argument.trim().length > 0))
.min(1); .min(1);
const PluginManifestSchema = z // Manifest asset paths use forward slashes and stay inside the plugin package.
.object({ const PluginAssetPathSchema = z
id: PluginIdSchema, .string()
description: z.string().trim().min(1).optional(), .min(1)
requirements: PluginRequirementsSchema.strict().optional(), .refine(
build: z.array(PluginBuildCommandSchema).min(1).optional(), (value) =>
}) value === value.trim() &&
.strict(); !value.startsWith("/") &&
!/[\\:?#\p{Cc}]/u.test(value) &&
!value.split("/").includes("..") &&
!value.endsWith("/") &&
value.split("/").at(-1) !== ".",
"Expected a relative file path inside the plugin package",
);
const PluginManifestSchema = z.object({
id: PluginIdSchema,
name: z.string().trim().min(1).optional(),
description: z.string().trim().min(1).optional(),
icon: PluginAssetPathSchema.refine(
(value) => /\.png$/i.test(value),
"Expected a relative path to a PNG",
).optional(),
media: z.array(z.union([PluginAssetPathSchema, z.url({ protocol: /^https$/ })])).optional(),
// A misspelled requirement must not silently disable compatibility checks.
requirements: PluginRequirementsSchema.strict().optional(),
build: z.array(PluginBuildCommandSchema).min(1).optional(),
});
export type PluginManifest = z.infer<typeof PluginManifestSchema>; export type PluginManifest = z.infer<typeof PluginManifestSchema>;
@@ -510,9 +510,7 @@ it("starts the default shell through the worker and accepts quoted commands", as
session.send({ type: "input", data: `${command}\r` }); session.send({ type: "input", data: `${command}\r` });
await waitForCondition(() => existsSync(markerPath), 10000); await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok");
expect(readFileSync(markerPath, "utf8")).toBe("shell-ok");
}); });
it("lists subdirectory terminals when querying the workspace root", async () => { it("lists subdirectory terminals when querying the workspace root", async () => {
+14 -8
View File
@@ -8,6 +8,13 @@ async function openPlugins(page: Page) {
await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible(); await expect(page.getByRole("heading", { level: 1, name: /^Plugins/ })).toBeVisible();
} }
function pluginCards(page: Page, section: string): Locator {
return page
.getByRole("region", { name: section })
.getByRole("link")
.filter({ hasNotText: /^See all$/ });
}
test("browses from the directory into a category, a plugin, and its author", async ({ test("browses from the directory into a category, a plugin, and its author", async ({
page, page,
context, context,
@@ -508,11 +515,7 @@ test.describe("registry fixture layout", () => {
await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured"); await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured");
const featured = page.getByRole("region", { name: "Featured" }); const featured = page.getByRole("region", { name: "Featured" });
await expect(featured).toContainText("A selection of hand picked plugins"); await expect(featured).toContainText("A selection of hand picked plugins");
await expect(featured.getByRole("link", { name: /Added/ })).toHaveText([ await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]);
/Dracula/,
/Herald/,
/launchd Jobs/,
]);
}); });
test("lists the nine categories in order with counts, and the newest plugins first", async ({ test("lists the nine categories in order with counts, and the newest plugins first", async ({
@@ -524,9 +527,12 @@ test.describe("registry fixture layout", () => {
CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)), CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)),
); );
await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0"); await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0");
await expect( await expect(pluginCards(page, "What’s new")).toHaveText([
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }), /Base2Tone/,
).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]); /Sayr/,
/PromptKit/,
/Defer/,
]);
}); });
}); });
@@ -126,9 +126,7 @@ function PluginsPage() {
<PluginSectionHeader> <PluginSectionHeader>
<div> <div>
<PluginSectionTitle id="featured">Featured</PluginSectionTitle> <PluginSectionTitle id="featured">Featured</PluginSectionTitle>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">A selection of hand picked plugins</p>
A selection of hand picked plugins
</p>
</div> </div>
<a <a
href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })} href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })}
+34 -2
View File
@@ -152,6 +152,39 @@ In the app, enter only the source identifier.
::: :::
## Icons and screenshots
Declare your display name, icon, screenshots, and demo videos in `paseo-plugin.json`:
```json
{
"id": "review-tools",
"name": "Review tools",
"description": "Reviews changes before merge",
"icon": "assets/icon.png",
"media": ["assets/screenshot.png", "https://example.com/review-demo.mp4"],
"requirements": { "paseo": ">=0.11.0" }
}
```
All three fields are optional. `name` gives the registry and website a display name without
changing the installation ID. When omitted, the registry falls back to a humanized ID.
Registry and website display requires the registry builder to read these fields from the pinned
manifest. That update is a separate follow-up; until it lands, these fields are exposed only
through the daemon's installed-plugin list.
`icon` must point to a PNG inside the plugin package. `media` accepts paths to images and videos
inside the package or HTTPS URLs, in display order. Paths are relative to the manifest; use
forward slashes and keep them inside the package. See the [manifest reference](/docs/plugins/reference#project-files)
for validation rules.
Commit local assets alongside the manifest at the pinned revision. For npm, add their directory
(for example, `assets/`) to `package.json`'s `files` list and verify it with `npm pack --dry-run`.
The scaffold documents these fields in its manifest's `$comment`; add your display name and
assets before publishing.
Manifests using these fields fail to install on daemons before 0.11.0. Set
`requirements.paseo` to `>=0.11.0` or a narrower supported range.
## Your listing page ## Your listing page
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo. Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
@@ -170,8 +203,7 @@ The repository overview takes precedence over a registry import stopgap. Online
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
requires a repository overview and removes the stopgap in the same PR. A bump without the requires a repository overview and removes the stopgap in the same PR. A bump without the
repository overview fails validation. Neither README files nor a `paseo-listing.json` repository overview fails validation. README files do not supply the overview.
`readme` override supplies the overview.
Author overviews and registry stopgaps follow the same content contract, in this order. Author overviews and registry stopgaps follow the same content contract, in this order.
Choose headings only when they help; length follows complexity. A theme needs one paragraph. Choose headings only when they help; length follows complexity. A theme needs one paragraph.
+18 -6
View File
@@ -52,12 +52,24 @@ The required root manifest is `paseo-plugin.json`:
} }
``` ```
| Field | Required | Behavior | | Field | Required | Behavior |
| -------------- | -------- | ---------------------------------------------------------------------- | | -------------- | -------- | ----------------------------------------------------------------------------------------- |
| `id` | Yes | Default installation ID. | | `id` | Yes | Default installation ID. |
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. | | `name` | No | Non-empty display name for the registry and website; independent of the installation ID. |
| `requirements` | No | Supported Paseo versions, described below. | | `icon` | No | Relative path to a PNG inside the plugin package. |
| `build` | No | Preparation commands, described in the CLI reference. | | `media` | No | Array of image or video paths inside the package, or HTTPS URLs. An empty array is valid. |
| `description` | No | Non-empty summary shown below the plugin ID in **Settings → Plugins**. |
| `requirements` | No | Supported Paseo versions, described below. |
| `build` | No | Preparation commands, described in the CLI reference. |
Paths are relative to `paseo-plugin.json`, use forward slashes, and cannot contain `..`
segments. See [icons and screenshots](/docs/plugins/publishing#icons-and-screenshots) for an example.
Paseo validates the references without opening local assets or fetching URLs.
Unknown top-level fields are ignored. Known fields still validate, and unknown keys inside
`requirements` are rejected so a misspelled constraint cannot silently skip a compatibility check.
Manifests using `name`, `icon`, or `media` require Paseo 0.11.0 or later; older daemons reject
these fields during installation.
### Requirements ### Requirements
+1 -2
View File
@@ -657,8 +657,7 @@ The repository overview takes precedence over a registry import stopgap. Online
fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records fails when the pinned commit has no `OVERVIEW.md`, except for unchanged imported records
that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump that already carry `plugins/<owner>/<slug>.md` in the registry repository. Every version bump
requires a repository overview and removes the stopgap in the same PR. A bump without the requires a repository overview and removes the stopgap in the same PR. A bump without the
repository overview fails validation. Neither README files nor a `paseo-listing.json` repository overview fails validation. README files do not supply the overview.
`readme` override supplies the overview.
Author overviews and registry stopgaps follow the same content contract, in this order. Author overviews and registry stopgaps follow the same content contract, in this order.
Choose headings only when they help; length follows complexity. A theme needs one paragraph. Choose headings only when they help; length follows complexity. A theme needs one paragraph.