Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 919700c6a2 test(opencode): reproduce #6006, a model OpenCode 2 dropped stays in the picker and fails to apply
Run with OPENCODE_V2_BIN pointing at an OpenCode 2.x binary. Fails on main.
2026-10-06 22:59:55 +02:00
29 changed files with 294 additions and 536 deletions
+1 -6
View File
@@ -58,12 +58,7 @@ async function probeDaemonStatus(
let live: Record<string, unknown> = {};
let client: Awaited<ReturnType<typeof connectToDaemon>> | undefined;
try {
// An explicit host may sit behind SSH or a relay, so it gets the usual connect budget.
client = await connectToDaemon({
target,
instance: instance ?? undefined,
timeout: target.kind === "endpoint" ? undefined : 1_500,
});
client = await connectToDaemon({ target, instance: instance ?? undefined, timeout: 1_500 });
} catch (error) {
const failure = buildDaemonConnectionCommandError({ target, error });
if (target.kind === "endpoint") throw failure;
@@ -93,8 +93,6 @@ describe("plugin scaffold", () => {
expect(JSON.parse(await readFile(path.join(directory, "paseo-plugin.json"), "utf8"))).toEqual(
{
id: "hello-plugin",
$comment: expect.stringContaining('"icon": "assets/icon.png"'),
media: [],
requirements: { paseo: `>=${cliPackageJson.version}` },
},
);
+1 -11
View File
@@ -206,17 +206,7 @@ export async function scaffoldPluginDirectory(
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`,
`${JSON.stringify({ id, requirements: { paseo: `>=${version}` } }, null, 2)}\n`,
],
["OVERVIEW.md", OVERVIEW],
["package.json", `${JSON.stringify(packageJson, null, 2)}\n`],
@@ -1,57 +0,0 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { runLocalPaseo } from "./helpers/local-cli.ts";
import { startTestDaemon } from "./helpers/test-daemon.ts";
console.log("=== Daemon Status over SSH ===\n");
// The stub stands in for OpenSSH; Windows cannot run it as `ssh` from PATH.
if (process.platform === "win32") {
console.log("Skipped on Windows\n");
process.exit(0);
}
const SSH_SETUP_MS = 2500;
const root = await mkdtemp(join(tmpdir(), "paseo-status-ssh-"));
let daemon: Awaited<ReturnType<typeof startTestDaemon>> | undefined;
try {
daemon = await startTestDaemon();
// Opens the -W stream only after the delay a real SSH handshake takes.
await writeFile(
join(root, "ssh"),
`#!${process.execPath}
const net = require("node:net");
const args = process.argv.slice(2);
const [host, port] = args[args.indexOf("-W") + 1].split(":");
setTimeout(() => {
const socket = net.connect(Number(port), host);
process.stdin.pipe(socket);
socket.pipe(process.stdout);
socket.on("close", () => process.exit(0));
}, ${SSH_SETUP_MS});
`,
{ mode: 0o700 },
);
console.log("Test 1: status reaches a daemon whose SSH stream takes 2.5s to open");
const result = await runLocalPaseo(
["daemon", "status", "--host", `ssh://build-box?daemonPort=${daemon.port}`, "--json"],
{ PATH: `${root}${delimiter}${process.env.PATH ?? ""}` },
);
assert.strictEqual(result.exitCode, 0, result.stderr);
const status = JSON.parse(result.stdout);
assert.strictEqual(status.connectedDaemon, "reachable", JSON.stringify(status));
assert.strictEqual(typeof status.serverId, "string");
console.log("✓ status waits for the SSH stream like other commands\n");
} finally {
await daemon?.stop();
await rm(root, { recursive: true, force: true });
}
console.log("=== Daemon Status over SSH Tests Passed ===");
@@ -23,27 +23,6 @@ describe("plugin protocol compatibility", () => {
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", () => {
expect(
SessionInboundMessageSchema.parse({
-3
View File
@@ -6632,9 +6632,6 @@ export const PluginNpmInstallationSchema = z.object({
export const PluginListItemSchema = z.object({
id: PluginIdSchema,
name: z.string().optional(),
icon: z.string().optional(),
media: z.array(z.string()).optional(),
description: z.string().optional(),
path: z.string(),
enabled: z.boolean(),
@@ -0,0 +1,115 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
// Reproduction for #6006. Needs an OpenCode 2.x binary in OPENCODE_V2_BIN.
const OPENCODE_V2_BIN = process.env.OPENCODE_V2_BIN;
const PROVIDER = "paseo-catalog-drift";
const KEPT_MODEL = "kept-model";
const RETIRED_MODEL = "retired-model";
function catalog(modelIds: string[]) {
return {
[PROVIDER]: {
id: PROVIDER,
env: ["PASEO_CATALOG_DRIFT_KEY"],
npm: "@ai-sdk/openai-compatible",
api: "http://127.0.0.1:9/v1",
name: "Catalog drift",
models: Object.fromEntries(
modelIds.map((id) => [
id,
{
id,
name: id,
attachment: false,
reasoning: false,
tool_call: true,
temperature: true,
release_date: "2025-06-17",
last_updated: "2025-06-17",
modalities: { input: ["text"], output: ["text"] },
open_weights: false,
limit: { context: 128_000, output: 8_000 },
cost: { input: 0, output: 0 },
},
]),
),
},
};
}
test.runIf(OPENCODE_V2_BIN)(
"a model OpenCode dropped from its catalog stays in the picker and fails to apply",
async () => {
const logger = pino({ level: "silent" });
const root = realpathSync(mkdtempSync(path.join(tmpdir(), "opencode-v2-catalog-drift-")));
const modelsPath = path.join(root, "models.json");
const cwd = path.join(root, "work");
mkdirSync(cwd);
writeFileSync(modelsPath, JSON.stringify(catalog([KEPT_MODEL, RETIRED_MODEL])));
const provider = new OpenCodeV2AgentClient({
logger,
settings: {
command: { mode: "replace", argv: [OPENCODE_V2_BIN!] },
env: {
OPENCODE_MODELS_PATH: modelsPath,
OPENCODE_DISABLE_MODELS_FETCH: "1",
OPENCODE_DISABLE_AUTO_UPDATE: "1",
PASEO_CATALOG_DRIFT_KEY: "test",
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_CACHE_HOME: path.join(root, "cache"),
},
},
});
const daemon = await createTestPaseoDaemon({ agentClients: { opencode: provider }, logger });
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const before = await client.listProviderModels("opencode", { cwd });
expect(before.models?.map((entry) => entry.id)).toContain(`${PROVIDER}/${RETIRED_MODEL}`);
// OpenCode's catalog drops the model while Paseo sits idle.
writeFileSync(modelsPath, JSON.stringify(catalog([KEPT_MODEL])));
const agent = await client.createAgent({
provider: "opencode",
cwd,
title: "catalog drift",
model: `${PROVIDER}/${KEPT_MODEL}`,
modeId: "build",
});
const listed = await client.listProviderModels("opencode", { cwd });
const offered = listed.models?.map((entry) => entry.id) ?? [];
let switchError: string | null = null;
if (offered.includes(`${PROVIDER}/${RETIRED_MODEL}`)) {
try {
await client.setAgentModel(agent.id, `${PROVIDER}/${RETIRED_MODEL}`);
} catch (error) {
switchError = error instanceof Error ? error.message : String(error);
}
}
// Every model the picker offers must be one OpenCode accepts.
expect({ offered, switchError }).toEqual({
offered: [`${PROVIDER}/${KEPT_MODEL}`],
switchError: null,
});
} finally {
await client.close().catch(() => undefined);
await daemon.close().catch(() => undefined);
rmSync(root, { recursive: true, force: true });
}
},
180_000,
);
@@ -419,7 +419,7 @@ describe("PluginService", () => {
await service.stopAllPlugins();
}, 20_000);
it("lists manifest metadata for running and disabled plugins without hiding malformed entries", async () => {
it("lists manifest descriptions for running and disabled plugins without hiding malformed entries", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const running = await createPlugin("running", "export default () => () => {};");
@@ -427,13 +427,7 @@ describe("PluginService", () => {
const malformed = await createPlugin("malformed", "export default () => () => {};");
await writeFile(
path.join(running, "paseo-plugin.json"),
JSON.stringify({
id: "running",
description: "Runs checks",
name: "Checks",
icon: "icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
}),
JSON.stringify({ id: "running", description: "Runs checks" }),
);
await writeFile(
path.join(disabled, "paseo-plugin.json"),
@@ -449,23 +443,11 @@ describe("PluginService", () => {
await service.start();
expect(
(await service.listPlugins()).map(({ id, description, name, icon, media }) => ({
id,
description,
name,
icon,
media,
})),
(await service.listPlugins()).map(({ id, description }) => ({ id, description })),
).toEqual([
{ id: "disabled", description: "Waits until enabled" },
{ id: "malformed", description: undefined },
{
id: "running",
description: "Runs checks",
name: "Checks",
icon: "icon.png",
media: ["screenshot.png", "https://example.com/demo.mp4"],
},
{ id: "running", description: "Runs checks" },
]);
await service.stopAllPlugins();
});
+1 -6
View File
@@ -216,12 +216,7 @@ export class PluginService {
}),
};
const manifest = await readPluginManifest(path.resolve(source.path)).catch(() => null);
if (manifest) {
item.name = manifest.name;
item.description = manifest.description;
item.icon = manifest.icon;
item.media = manifest.media;
}
if (manifest?.description) item.description = manifest.description;
item.installation = await this.managedSources
?.describe(id, source.path)
.catch(() => undefined);
@@ -24,69 +24,6 @@ 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 () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-plugin-manifest-"));
directories.push(directory);
+8 -27
View File
@@ -8,33 +8,14 @@ const MANIFEST_FILENAME = "paseo-plugin.json";
const PluginBuildCommandSchema = z
.array(z.string().refine((argument) => argument.trim().length > 0))
.min(1);
// Manifest asset paths use forward slashes and stay inside the plugin package.
const PluginAssetPathSchema = z
.string()
.min(1)
.refine(
(value) =>
value === value.trim() &&
!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(),
});
const PluginManifestSchema = z
.object({
id: PluginIdSchema,
description: z.string().trim().min(1).optional(),
requirements: PluginRequirementsSchema.strict().optional(),
build: z.array(PluginBuildCommandSchema).min(1).optional(),
})
.strict();
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
@@ -510,7 +510,9 @@ it("starts the default shell through the worker and accepts quoted commands", as
session.send({ type: "input", data: `${command}\r` });
await expect.poll(() => readFileSync(markerPath, "utf8"), { timeout: 10000 }).toBe("shell-ok");
await waitForCondition(() => existsSync(markerPath), 10000);
expect(readFileSync(markerPath, "utf8")).toBe("shell-ok");
});
it("lists subdirectory terminals when querying the workspace root", async () => {
+8 -14
View File
@@ -8,13 +8,6 @@ async function openPlugins(page: Page) {
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 ({
page,
context,
@@ -515,7 +508,11 @@ test.describe("registry fixture layout", () => {
await expect(page.getByRole("heading", { level: 2 }).first()).toHaveText("Featured");
const featured = page.getByRole("region", { name: "Featured" });
await expect(featured).toContainText("A selection of hand picked plugins");
await expect(pluginCards(page, "Featured")).toHaveText([/Dracula/, /Herald/, /launchd Jobs/]);
await expect(featured.getByRole("link", { name: /Added/ })).toHaveText([
/Dracula/,
/Herald/,
/launchd Jobs/,
]);
});
test("lists the nine categories in order with counts, and the newest plugins first", async ({
@@ -527,12 +524,9 @@ test.describe("registry fixture layout", () => {
CATEGORIES.map((category) => new RegExp(`^${category.label}\\s*\\d+$`)),
);
await expect(categories.getByRole("link", { name: /Extras/ })).toContainText("0");
await expect(pluginCards(page, "What’s new")).toHaveText([
/Base2Tone/,
/Sayr/,
/PromptKit/,
/Defer/,
]);
await expect(
page.getByRole("region", { name: "What’s new" }).getByRole("link", { name: /Added/ }),
).toHaveText([/Base2Tone/, /Sayr/, /PromptKit/, /Defer/]);
});
});
@@ -3,7 +3,6 @@ import { type ReactNode, useMemo } from "react";
import { type Author, authorAvatarUrl } from "./registry";
const SIZE_CLASS = {
xs: "h-4 w-4 text-[8px]",
sm: "h-5 w-5 text-[10px]",
lg: "h-14 w-14 text-xl",
} as const;
+11 -13
View File
@@ -3,7 +3,6 @@ import { SiteShell } from "~/components/site-shell";
import { ContributeLinks } from "./contribute-links";
import type { InstallCounts } from "./installs";
import { type BrowseQuery, browseHref } from "./links";
import { InstallCount } from "./install-count";
import { PLUGIN_GRID_CLASS, PluginCard } from "./plugin-card";
import { PluginSearch } from "./plugin-search";
import { PluginSection } from "./section";
@@ -47,11 +46,10 @@ export function BrowsePage({
query: BrowseQuery;
}) {
const category = query.category ? getCategory(query.category) : null;
const ordered =
query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window);
// Search ranks by relevance; the tab's order breaks ties.
const matches = query.q ? searchPlugins(ordered, query.q) : ordered;
const results = category ? getPluginsInCategory(matches, category.slug) : matches;
const matches = query.q ? searchPlugins(plugins, query.q) : plugins;
const scoped = category ? getPluginsInCategory(matches, category.slug) : matches;
const results =
query.sort === "new" ? newestFirst(scoped) : mostInstalled(scoped, installs, query.window);
const title = category?.label ?? "All plugins";
const clearHref = browseHref({ ...query, q: undefined });
return (
@@ -66,7 +64,7 @@ export function BrowsePage({
<h1 className="text-3xl font-medium tracking-tight">
{query.q ? `Results for “${query.q}”${category ? ` in ${title}` : ""}` : title}
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
{results.length}
{scoped.length}
</span>
</h1>
{query.q && (
@@ -95,13 +93,13 @@ export function BrowsePage({
<div className={PLUGIN_GRID_CLASS}>
{results.map((plugin) =>
query.sort === "new" ? (
<PluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</PluginCard>
<PluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
) : (
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />
</PluginCard>
<PluginCard
key={plugin.id}
plugin={plugin}
installs={installs[plugin.id]?.[query.window] ?? 0}
/>
),
)}
</div>
@@ -21,21 +21,7 @@ export function ContributeLinks() {
);
}
/** "Submit a plugin" and "Read the docs", as a primary and a secondary button. */
export function ContributeButtons() {
return (
<>
<a href={SUBMIT_URL} className={PRIMARY_BUTTON_CLASS}>
Submit a plugin
</a>
<a href={DOCS_URL} className={SECONDARY_BUTTON_CLASS}>
Read the docs
</a>
</>
);
}
/** Closing section of the directory: what a plugin can do, with Submit and Read the docs buttons. */
/** Closing section of the directory: what a plugin can do, with Read the docs and Submit buttons. */
export function ContributeSection() {
return (
<section
@@ -50,7 +36,12 @@ export function ContributeSection() {
with the plugin docs, then submit it to get it listed here.
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
<ContributeButtons />
<a href={DOCS_URL} className={PRIMARY_BUTTON_CLASS}>
Read the docs
</a>
<a href={SUBMIT_URL} className={SECONDARY_BUTTON_CLASS}>
Submit a plugin
</a>
</div>
</section>
);
-37
View File
@@ -1,37 +0,0 @@
import { ContributeButtons } from "./contribute-links";
import type { BrowseQuery } from "./links";
import { PluginSearch } from "./plugin-search";
interface PluginsHeroProps {
pluginCount: number;
authorCount: number;
/** Where the search box sends its term. */
searchScope: BrowseQuery;
}
function plural(count: number, word: string): string {
return `${count} ${word}${count === 1 ? "" : "s"}`;
}
/** Header of the plugin directory: title, counts, and copy, then the contribute buttons and search. */
export function PluginsHero({ pluginCount, authorCount, searchScope }: PluginsHeroProps) {
return (
<header>
<h1 className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="text-3xl font-medium tracking-tight">Plugins</span>
<span className="text-sm font-normal tabular-nums text-extra-muted-foreground">
{plural(pluginCount, "plugin")} by {plural(authorCount, "author")}
</span>
</h1>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-muted-foreground">
Browse community plugins that extend Paseo with new functionality.
</p>
<div className="mt-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-3">
<ContributeButtons />
</div>
<PluginSearch scope={searchScope} className="w-full sm:w-64" />
</div>
</header>
);
}
@@ -1,13 +0,0 @@
import { Download } from "lucide-react";
import { formatInstalls } from "./registry";
/** An install count as a download icon and the number; inherits the surrounding text style. */
export function InstallCount({ count }: { count: number }) {
return (
<span className="inline-flex items-center gap-1 tabular-nums">
<Download aria-hidden className="h-3 w-3" />
{formatInstalls(count)}
<span className="sr-only">installs</span>
</span>
);
}
+42 -38
View File
@@ -1,10 +1,8 @@
import type { ReactNode } from "react";
import { AuthorAvatar } from "./author-link";
import { InstallCount } from "./install-count";
import { Download } from "lucide-react";
import { pluginCardScreenshot } from "./thumbnails";
import { pluginHref } from "./links";
import { PluginTile } from "./plugin-tile";
import { firstMediaImage, getAuthor, getCategory, type Plugin } from "./registry";
import { firstMediaImage, formatInstalls, getCategory, type Plugin } from "./registry";
export const PLUGIN_GRID_CLASS =
"grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4";
@@ -34,49 +32,57 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
);
}
/**
* What's new and Featured card, in three rows under the screenshot: tile and name, description,
* then author and `children`, such as when the plugin was added or its install count.
*/
export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
const author = getAuthor(plugin);
/** What's new card: screenshot, name, and when it was added. */
export function NewPluginCard({ plugin, added }: { plugin: Plugin; added: string }) {
return (
<a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p>
<div className="mt-3 flex items-center gap-2.5">
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
<p className="truncate text-xs text-extra-muted-foreground">Added {added}</p>
</div>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
<p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground">
<AuthorAvatar author={author} size="xs" />
<span className="truncate">{author.name}</span>
{children !== undefined && (
<>
<span aria-hidden>·</span>
<span className="flex-shrink-0">{children}</span>
</>
)}
</p>
</a>
);
}
/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
/** Grid card: screenshot, name, one muted stat, and a two-line description. */
export function PluginCard({
plugin,
installs,
added,
}: {
plugin: Plugin;
/** Shown with a download icon; otherwise `added` is shown. */
installs?: number;
added?: string;
}) {
return (
<a href={pluginHref(plugin.id)} className="group block">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{plugin.name}</p>
<span className="flex-shrink-0 text-xs text-extra-muted-foreground">{children}</span>
<div className="mt-3 flex items-start gap-2.5">
<PluginTile plugin={plugin} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<p className="truncate text-sm font-medium text-white">{plugin.name}</p>
<span className="inline-flex flex-shrink-0 items-center gap-1 text-xs tabular-nums text-extra-muted-foreground">
{installs === undefined ? (
added
) : (
<>
<Download className="h-3 w-3" />
{formatInstalls(installs)}
</>
)}
</span>
</div>
<p className="mt-0.5 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
</div>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
</a>
);
}
@@ -104,9 +110,7 @@ export function PluginRankRow({
{getCategory(plugin.categories[0])?.label}
</p>
</div>
<span className="text-xs text-muted-foreground">
<InstallCount count={installs} />
</span>
<span className="text-xs tabular-nums text-muted-foreground">{formatInstalls(installs)}</span>
</a>
);
}
@@ -2,7 +2,6 @@ import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import type { Plugin } from "./registry";
const SIZE_CLASS = {
xs: "h-6 w-6 rounded-md text-xs",
sm: "h-9 w-9 rounded-lg text-sm",
lg: "h-14 w-14 rounded-xl text-xl",
} as const;
@@ -228,32 +228,6 @@ describe("plugin registry", () => {
expect(searchPlugins(plugins, " ")).toEqual(plugins);
expect(searchPlugins(plugins, "nothing")).toEqual([]);
});
it("ranks name, then author, then description matches, keeping the given order on ties", () => {
const named = (id: string, name: string, description: string, github = "acme"): Plugin => ({
...plugin,
id,
name,
description,
author: { github },
});
// Given in install order: description-only matches first, the exact name last.
const plugins = [
named("acme/editor", "Remote Editor", "Composer pill to open a workspace"),
named("acme/kit", "PromptKit", "Rewrite prompts"),
named("omp/tools", "Tools", "Helpers", "omp"),
named("acme/history", "History", "Inspect raw composer records"),
named("acme/omp", "OMP", "Paseo integration for OMP"),
];
expect(searchPlugins(plugins, "omp").map((p) => p.id)).toEqual([
"acme/omp",
"acme/kit",
"omp/tools",
"acme/editor",
"acme/history",
]);
expect(searchPlugins(plugins, "prompt").map((p) => p.id)).toEqual(["acme/kit"]);
expect(searchPlugins(plugins, "tools omp").map((p) => p.id)).toEqual(["omp/tools"]);
});
it("strips the README title and quoted description that the page already shows", () => {
expect(readmeBody("# Example\n\n> An example\n> plugin\n\n## Usage\n")).toBe("## Usage\n");
+9 -33
View File
@@ -1,7 +1,6 @@
import type { z } from "zod";
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
import { scoreTextFields } from "@getpaseo/protocol/search/text-match";
import { CATEGORIES, type Category, type CategorySlug } from "./categories";
import type { InstallCounts, InstallWindow } from "./installs";
export { CATEGORIES, type Category, type CategorySlug };
@@ -67,39 +66,16 @@ export function formatInstalls(count: number): string {
const k = count / 1000;
return `${k % 1 === 0 ? k.toFixed(0) : k.toFixed(1)}k`;
}
/** Fields a search term can hit, best first: a name match outranks an author match, which outranks a description match. */
function searchFieldGroups(plugin: Plugin): string[][] {
const author = getAuthor(plugin);
const name = [plugin.name, plugin.id.split("/")[1]];
const owner = [author.name, author.github, plugin.id];
return [name, owner, [plugin.description], [...name, ...owner, plugin.description]];
}
interface SearchRank {
field: number;
tier: number;
}
function searchRank(plugin: Plugin, term: string): SearchRank | null {
const groups = searchFieldGroups(plugin);
for (let field = 0; field < groups.length; field += 1) {
const score = scoreTextFields(term, groups[field], { subsequence: false });
if (score) return { field, tier: score.tier };
}
return null;
}
/**
* Plugins whose name, author, ID, or description contains every word of the term, ignoring case.
* Name matches come first, then author, then description; within each, exact words beat partial
* ones, and equal matches keep the given order.
*/
/** Plugins whose name, description, ID, or author contains the term, case-insensitively. */
export function searchPlugins(plugins: Plugin[], term: string): Plugin[] {
if (!term.trim()) return plugins;
return plugins
.flatMap((plugin) => {
const rank = searchRank(plugin, term);
return rank ? [{ plugin, rank }] : [];
})
.sort((a, b) => a.rank.field - b.rank.field || a.rank.tier - b.rank.tier)
.map(({ plugin }) => plugin);
const needle = term.trim().toLowerCase();
if (!needle) return plugins;
return plugins.filter((plugin) =>
[plugin.name, plugin.description, plugin.id, getAuthor(plugin).name]
.join(" ")
.toLowerCase()
.includes(needle),
);
}
/** The featured IDs that name a listed plugin, in the registry's order, each once. */
export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[] {
+14 -14
View File
@@ -56,20 +56,20 @@ describe("plugin card thumbnails", () => {
expect(html).not.toContain("/plugins/thumb/");
});
it.each([
["PluginCard", () => createElement(PluginCard, { plugin }, "today")],
["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")],
])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => {
const html = renderToStaticMarkup(card());
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
expect(html).toContain(
`srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`,
);
expect(html).toContain('loading="lazy"');
expect(html).toContain('decoding="async"');
});
it.each([PluginCard, NewPluginCard])(
"renders a thumbnail instead of downloading the original screenshot (%s)",
(Card) => {
const html = renderToStaticMarkup(createElement(Card, { plugin, added: "today" }));
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
expect(html).toContain(
`srcSet="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample 1x, /plugins/thumb/1184/${encodeURIComponent(source)}?plugin=acme%2Fexample 2x"`,
);
expect(html).toContain('loading="lazy"');
expect(html).toContain('decoding="async"');
},
);
});
function thumbnailRequest(
@@ -16,7 +16,6 @@ import {
} from "~/plugins";
import { AuthorAvatar } from "~/plugins/author-link";
import { PluginsNotFound } from "~/plugins/not-found";
import { InstallCount } from "~/plugins/install-count";
import { PluginCard } from "~/plugins/plugin-card";
import "~/styles.css";
@@ -83,9 +82,7 @@ function AuthorPage() {
<div className="mt-12 grid gap-x-4 gap-y-8 sm:grid-cols-2 lg:grid-cols-3">
{plugins.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</PluginCard>
<PluginCard key={plugin.id} plugin={plugin} installs={installs[plugin.id]?.all ?? 0} />
))}
</div>
</SiteShell>
@@ -6,6 +6,7 @@ import { SiteShell } from "~/components/site-shell";
import { pageMeta } from "~/meta";
import {
firstMediaImage,
formatInstalls,
getAuthor,
getCategory,
getRegistry,
@@ -15,8 +16,7 @@ import {
pluginVersion,
readmeBody,
} from "~/plugins";
import { AuthorAvatar, AuthorLink } from "~/plugins/author-link";
import { InstallCount } from "~/plugins/install-count";
import { AuthorLink } from "~/plugins/author-link";
import { categoryHref } from "~/plugins/links";
import { MediaGallery } from "~/plugins/media-gallery";
import { PluginsNotFound } from "~/plugins/not-found";
@@ -48,8 +48,6 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
const META_LINK_CLASS =
"inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
const AUTHOR_LINK_CLASS =
"inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
function PluginPage() {
const { plugin, installs } = Route.useLoaderData();
@@ -59,42 +57,43 @@ function PluginPage() {
return (
<SiteShell width="default">
<a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground">
← Plugins
</a>
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
<div className="mt-6 w-fit max-w-full">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
<AuthorLink author={author} className={AUTHOR_LINK_CLASS}>
<AuthorAvatar author={author} size="xs" />
{author.name}
</AuthorLink>
{category && (
<a href={categoryHref(category.slug)} className={META_LINK_CLASS}>
{category.label}
</a>
)}
<InstallCount count={installs} />
<span className="font-mono">{pluginVersion(plugin)}</span>
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
Source
<ExternalLink className="h-3 w-3" />
</PluginContentLink>
{npm && (
<PluginContentLink href={npm} className={META_LINK_CLASS}>
npm
<div className="max-w-3xl">
<a href="/plugins" className="text-sm text-muted-foreground hover:text-foreground">
← Plugins
</a>
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
<div className="mt-6">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
<AuthorLink author={author} className={META_LINK_CLASS}>
{author.name}
</AuthorLink>
{category && (
<a href={categoryHref(category.slug)} className={META_LINK_CLASS}>
{category.label}
</a>
)}
<span className="tabular-nums">{formatInstalls(installs)} installs</span>
<span className="font-mono">{pluginVersion(plugin)}</span>
<PluginContentLink href={plugin.repository.url} className={META_LINK_CLASS}>
Source
<ExternalLink className="h-3 w-3" />
</PluginContentLink>
)}
</div>
{npm && (
<PluginContentLink href={npm} className={META_LINK_CLASS}>
npm
<ExternalLink className="h-3 w-3" />
</PluginContentLink>
)}
</div>
<MediaGallery name={plugin.name} media={plugin.media} />
<MediaGallery name={plugin.name} media={plugin.media} />
<div className="mt-10 border-t border-white/10 pt-10">
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
<div className="mt-10 border-t border-white/10 pt-10">
<PluginOverview>{readmeBody(plugin.readme)}</PluginOverview>
</div>
</div>
</SiteShell>
);
+24 -18
View File
@@ -26,10 +26,8 @@ import {
type InstallWindow,
mostInstalled,
newestFirst,
pluginOwner,
} from "~/plugins";
import { ContributeSection } from "~/plugins/contribute-links";
import { PluginsHero } from "~/plugins/hero";
import {
type BrowseQuery,
browseHref,
@@ -39,9 +37,10 @@ import {
parseSearchTerm,
parseSort,
parseWindow,
SUBMIT_URL,
} from "~/plugins/links";
import { InstallCount } from "~/plugins/install-count";
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginSearch } from "~/plugins/plugin-search";
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
import { WindowSwitch } from "~/plugins/window-switch";
import "~/styles.css";
@@ -61,6 +60,7 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
utils: Wrench,
};
const SUBMIT_CLASS = "text-sm text-muted-foreground transition-colors hover:text-foreground";
// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up.
const CARD_ROW_CLASS =
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4";
@@ -110,37 +110,45 @@ function PluginsPage() {
[],
);
const searchScope = useMemo<BrowseQuery>(() => ({ sort: "installs", window }), [window]);
const authorCount = new Set(plugins.map(pluginOwner)).size;
return (
<SiteShell width="default">
<PluginsHero
pluginCount={plugins.length}
authorCount={authorCount}
searchScope={searchScope}
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-3xl font-medium tracking-tight">
Plugins
<span className="ml-3 align-middle text-sm font-normal tabular-nums text-extra-muted-foreground">
{plugins.length}
</span>
</h1>
<div className="flex flex-wrap items-center gap-x-5 gap-y-3">
<a href={SUBMIT_URL} className={SUBMIT_CLASS}>
Submit a plugin
</a>
<PluginSearch scope={searchScope} className="w-full sm:w-56" />
</div>
</div>
<div className="mt-16 flex flex-col gap-14">
<div className="mt-10 flex flex-col gap-14">
{featured.length > 0 && (
<PluginSection labelledBy="featured">
<PluginSectionHeader>
<div>
<PluginSectionTitle id="featured">Featured</PluginSectionTitle>
<p className="text-sm text-muted-foreground">A selection of hand picked plugins</p>
<p className="mt-1 text-sm text-muted-foreground">
A selection of hand picked plugins
</p>
</div>
<a
href={browseHref({ sort: "installs", window: DEFAULT_WINDOW })}
className={SEE_ALL_CLASS}
>
See all
See all plugins
<ChevronRight className="h-3.5 w-3.5" />
</a>
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{featured.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</NewPluginCard>
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
))}
</div>
</PluginSection>
@@ -156,9 +164,7 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{newest.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</NewPluginCard>
<NewPluginCard key={plugin.id} plugin={plugin} added={addedAgo(plugin, now)} />
))}
</div>
</PluginSection>
+2 -34
View File
@@ -152,39 +152,6 @@ 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
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
@@ -203,7 +170,8 @@ 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
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
repository overview fails validation. README files do not supply the overview.
repository overview fails validation. Neither README files nor a `paseo-listing.json`
`readme` override supplies the overview.
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.
+6 -18
View File
@@ -52,24 +52,12 @@ The required root manifest is `paseo-plugin.json`:
}
```
| Field | Required | Behavior |
| -------------- | -------- | ----------------------------------------------------------------------------------------- |
| `id` | Yes | Default installation ID. |
| `name` | No | Non-empty display name for the registry and website; independent of the installation ID. |
| `icon` | No | Relative path to a PNG inside the plugin package. |
| `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.
| Field | Required | Behavior |
| -------------- | -------- | ---------------------------------------------------------------------- |
| `id` | Yes | Default installation ID. |
| `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. |
### Requirements
+2 -1
View File
@@ -657,7 +657,8 @@ 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
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
repository overview fails validation. README files do not supply the overview.
repository overview fails validation. Neither README files nor a `paseo-listing.json`
`readme` override supplies the overview.
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.