Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 65cc85cad2 test(claude): reproduce progress updates landing in reasoning (#6199) 2026-10-06 19:01:35 +02:00
Mohamed Boudra 7fd469ae19 Enable plugin registry installs by default (#6224)
* feat(plugins): enable registry installs by default

* fix(plugins): explain explicit Git source for missing registry IDs

* fix(plugins): tailor missing registry guidance to installation

* test(app): sample footer geometry in one browser frame

* fix(plugins): make registry source resolution deterministic

Directory existence must not change the meaning of a registry identifier shared by CLI, RPC, and UI installs. Classify explicit host paths in the source parser and explain recovery from registry transport and revision-selection failures.

* fix(plugins): recognize dot and Windows directory sources

* test(cli): wait for readable daemon ownership before cancellation

The cancellation test raced PID-file creation against its first write and parsed empty JSON in CI. Use the public daemon-instance reader to wait for and capture the supervisor PID before asserting cancellation cleanup.

* test(app): expect missing directory install guidance
2026-10-06 16:52:02 +01:00
paseo-bot[bot] 56d5680742 fix(highlight): keep Astro expressions colored through nested template strings (#6219)
An Astro { } expression containing a template string inside ${} closed at
the first } in that nested string, leaving the rest of the expression
uncolored. Astro and Vue now share one scanner for embedded JavaScript,
which already skips template substitutions.

Closes #6209

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-06 17:03:17 +02:00
Mohamed Boudra 06bde1e57a Scaffold author overviews for plugin listings (#6226)
* feat(plugins): scaffold author listing overviews

* docs(plugins): refine the author overview contract

* docs(plugins): require repository overviews for registry listings

* docs(plugins): keep listing guidance focused on authors
2026-10-06 15:58:26 +01:00
Mohamed Boudra 396a2bc0b2 fix(website): count a plugin install once per IP per hour (#6230)
Repeated install-intent resolves inflated plugin rankings. Deduplicate reports by plugin and hashed Cloudflare IP for one hour, and skip counting when the IP header is absent.
2026-10-06 15:34:15 +01:00
36 changed files with 895 additions and 301 deletions
+2
View File
@@ -27,6 +27,8 @@
### Changed
- Enabled the plugin registry by default: `paseo plugin add owner/slug` installs a reviewed artifact even when a local directory matches; use explicit local paths (including `.`, `..`, and Windows drive or UNC paths), and use `github:owner/repository` for direct GitHub installs. Registry connection, missing-ID, and `--ref` errors include recovery steps.
- Changed the context window details to show usage for the account the agent runs under, including custom `CLAUDE_CONFIG_DIR` and `CODEX_HOME` homes ([#5465](https://github.com/getpaseo/paseo/pull/5465))
- Changed the context window details on desktop to a hover card with Refresh ([#5975](https://github.com/getpaseo/paseo/pull/5975))
- Changed the context window meter to show before an agent's first turn ([#6089](https://github.com/getpaseo/paseo/pull/6089))
+7 -10
View File
@@ -117,12 +117,9 @@ the bundled integration; an entry with `extends` shadows it with a custom provid
## Install from a registry
Registry installs are off by default. Set `pluginRegistryEnabled: true` in daemon config or
`PASEO_PLUGIN_REGISTRY_ENABLED=1`, then restart the daemon. While they are off, bare
`owner/repo` is GitHub shorthand and the daemon never contacts a registry.
With registry installs on, `paseo plugin install owner/slug` installs the registry's reviewed
artifact, and GitHub shorthand requires `github:`. Registry installs
`paseo plugin add owner/slug` installs the registry's reviewed artifact by default.
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
Use `git:owner/repository` or a full Git URL for a Git source. Registry installs
keep the registry URL and ID, so update checks use its approved pin. Explicit version/ref
selection is unavailable for registry installs; install an explicit source to select your own.
@@ -138,13 +135,13 @@ owns static hosting, record shapes, pins, and advisory install counts.
## Install a Git source
GitHub repositories use `owner/repository` or `github:owner/repository`. Other hosts use a Git
URL. An existing directory still wins over source resolution.
GitHub repositories use `git:owner/repository` or `github:owner/repository`. Other hosts use a Git
URL.
```bash
paseo plugin install owner/repository
paseo plugin install git:owner/repository
paseo plugin install https://gitlab.com/group/repository.git
paseo plugin install owner/monorepo:plugins/review
paseo plugin install git:owner/monorepo:plugins/review
paseo plugin install github:owner/repository --ref main
```
@@ -557,7 +557,7 @@ async function retryGitInstallAndInspectRows(
await installPlugin(page, missingSource);
await expect(page.getByTestId("plugin-management-feedback")).toContainText(
"Plugin source is neither an existing directory nor a Git URL",
`Plugin directory does not exist: ${missingSource}`,
);
await expect(page.getByLabel("Plugin source")).toHaveValue(missingSource);
await capturePluginInstallForm(page, testInfo, "compact-error");
@@ -244,13 +244,16 @@ const FOOTER_GEOMETRY_TOLERANCE = 0.01;
* Settings together at the end.
*/
export async function expectFooterIconRow(page: Page): Promise<void> {
const boxes = (
await Promise.all(
FOOTER_ICON_TEST_IDS.map((testID) =>
page.locator(`[data-testid="${testID}"]:visible`).first().boundingBox(),
),
)
).map((box) => box!);
// Read one browser-frame snapshot: the compact drawer can translate between separate RPCs.
const boxes = await page
.locator(FOOTER_ICON_TEST_IDS.map((testID) => `[data-testid="${testID}"]:visible`).join(","))
.evaluateAll((elements) =>
elements.map((element) => {
const { x, y, width, height } = element.getBoundingClientRect();
return { testID: element.getAttribute("data-testid"), x, y, width, height };
}),
);
expect(boxes.map((box) => box.testID)).toEqual(FOOTER_ICON_TEST_IDS);
const [first] = boxes;
for (const box of boxes) {
expect(Math.abs(box.y + box.height / 2 - first!.y - first!.height / 2)).toBeLessThan(2);
+1 -1
View File
@@ -2160,7 +2160,7 @@ export const en = {
globalTitle: "Enable plugins",
globalHint: "Global switch for every configured plugin",
sourceLabel: "Plugin source",
sourcePlaceholder: "Directory, Git URL, or npm package",
sourcePlaceholder: "owner/slug, directory, Git URL, or npm package",
docs: "Docs",
install: "Install plugin",
installing: "Installing…",
@@ -27,7 +27,7 @@ export const pluginSettings = {
globalTitle: "تفعيل الإضافات",
globalHint: "مفتاح عام لكل الإضافات المضبوطة",
sourceLabel: "مصدر الإضافة",
sourcePlaceholder: "مجلد أو رابط Git أو حزمة npm",
sourcePlaceholder: "owner/slug, مجلد أو رابط Git أو حزمة npm",
docs: "الوثائق",
install: "تثبيت الإضافة",
installing: "جارٍ التثبيت…",
@@ -82,7 +82,7 @@ export const pluginSettings = {
globalTitle: "Activar plugins",
globalHint: "Interruptor global para todos los plugins configurados",
sourceLabel: "Fuente del plugin",
sourcePlaceholder: "Directorio, URL de Git o paquete npm",
sourcePlaceholder: "owner/slug, Directorio, URL de Git o paquete npm",
docs: "Documentación",
install: "Instalar plugin",
installing: "Instalando…",
@@ -137,7 +137,7 @@ export const pluginSettings = {
globalTitle: "Activer les plugins",
globalHint: "Interrupteur global pour tous les plugins configurés",
sourceLabel: "Source du plugin",
sourcePlaceholder: "Dossier, URL Git ou paquet npm",
sourcePlaceholder: "owner/slug, Dossier, URL Git ou paquet npm",
docs: "Documentation",
install: "Installer le plugin",
installing: "Installation…",
@@ -192,7 +192,7 @@ export const pluginSettings = {
globalTitle: "プラグインを有効化",
globalHint: "設定済みプラグインすべての共通スイッチ",
sourceLabel: "プラグインソース",
sourcePlaceholder: "ディレクトリ、Git URL、npm パッケージ",
sourcePlaceholder: "owner/slug, ディレクトリ、Git URL、npm パッケージ",
docs: "ドキュメント",
install: "プラグインをインストール",
installing: "インストール中…",
@@ -246,7 +246,7 @@ export const pluginSettings = {
globalTitle: "플러그인 활성화",
globalHint: "설정된 모든 플러그인의 전역 스위치",
sourceLabel: "플러그인 소스",
sourcePlaceholder: "디렉터리, Git URL 또는 npm 패키지",
sourcePlaceholder: "owner/slug, 디렉터리, Git URL 또는 npm 패키지",
docs: "문서",
install: "플러그인 설치",
installing: "설치 중…",
@@ -301,7 +301,7 @@ export const pluginSettings = {
globalTitle: "Ativar plugins",
globalHint: "Controle global para todos os plugins configurados",
sourceLabel: "Fonte do plugin",
sourcePlaceholder: "Diretório, URL Git ou pacote npm",
sourcePlaceholder: "owner/slug, Diretório, URL Git ou pacote npm",
docs: "Documentação",
install: "Instalar plugin",
installing: "Instalando…",
@@ -356,7 +356,7 @@ export const pluginSettings = {
globalTitle: "Включить плагины",
globalHint: "Общий переключатель для всех настроенных плагинов",
sourceLabel: "Источник плагина",
sourcePlaceholder: "Каталог, URL Git или пакет npm",
sourcePlaceholder: "owner/slug, Каталог, URL Git или пакет npm",
docs: "Документация",
install: "Установить плагин",
installing: "Установка…",
@@ -410,7 +410,7 @@ export const pluginSettings = {
globalTitle: "启用插件",
globalHint: "所有已配置插件的全局开关",
sourceLabel: "插件来源",
sourcePlaceholder: "目录、Git URL 或 npm 包",
sourcePlaceholder: "owner/slug, 目录、Git URL 或 npm 包",
docs: "文档",
install: "安装插件",
installing: "正在安装…",
@@ -358,14 +358,21 @@ test.skipIf(process.platform === "win32").each([["start"], ["daemon", "run"]])(
stdio: "ignore",
});
const exited = new Promise((resolve) => child!.once("exit", resolve));
// The lock file exists before its contents identify the supervisor.
let supervisorPid: number | undefined;
await expect
.poll(async () => existsSync(path.join(home, "paseo.pid")), { timeout: 10_000 })
.toBe(true);
const lock = JSON.parse(await readFile(path.join(home, "paseo.pid"), "utf8"));
.poll(
async () => {
supervisorPid = (await readDaemonInstance(home))?.pid;
return supervisorPid;
},
{ timeout: 10_000 },
)
.toBeTypeOf("number");
await f.ok(["status", "--home", home]);
child.kill("SIGINT");
await exited;
expect(() => process.kill(lock.pid, 0)).toThrow();
expect(() => process.kill(supervisorPid!, 0)).toThrow();
expect((await f.ok(["status", "--home", home])).localDaemon).toBe("stopped");
} finally {
child?.kill("SIGTERM");
@@ -255,12 +255,12 @@ describe("plugin management commands", () => {
const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
const command = createPluginCommand();
await command.parseAsync(["install", "owner/monorepo", "--path", "plugins/review"], {
await command.parseAsync(["install", "git:owner/monorepo", "--path", "plugins/review"], {
from: "user",
});
expect(installPluginSource).toHaveBeenCalledWith({
source: "owner/monorepo:plugins/review",
source: "git:owner/monorepo:plugins/review",
});
stderr.mockRestore();
});
+4 -2
View File
@@ -215,10 +215,12 @@ export function createPluginCommand(): Command {
plugin
.command("install")
.alias("add")
.description("Trust and install a plugin from a directory, Git repository, or npm package")
.description(
"Trust and install a plugin from a registry, directory, Git repository, or npm package",
)
.argument(
"<source>",
"Host directory, GitHub owner/repo, git: or npm: source, optionally followed by :plugin/path",
"Registry owner/slug or host/owner/slug, host directory, explicit git:owner/repo or Git URL, or npm: source",
)
.option("--id <id>", "Runtime plugin ID (defaults to paseo-plugin.json id)")
.option("--ref <ref>", "Git branch, tag, or commit")
@@ -1,4 +1,4 @@
import { execFile } from "node:child_process";
import { exec, execFile } from "node:child_process";
import { mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
@@ -9,6 +9,7 @@ import { scaffoldPluginDirectory } from "./scaffold.js";
const directories: string[] = [];
const execFileAsync = promisify(execFile);
const execAsync = promisify(exec);
const tscPath = createRequire(import.meta.url).resolve("typescript/bin/tsc");
async function typecheckPlugin(directory: string): Promise<void> {
@@ -28,6 +29,47 @@ afterEach(async () => {
});
describe("plugin scaffold", () => {
it("creates an author overview beside the manifest", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "hello-plugin");
await scaffoldPluginDirectory(directory);
const overview = await readFile(path.join(directory, "OVERVIEW.md"), "utf8");
const comments = overview.match(/<!--[\s\S]*?-->/g) ?? [];
expect(comments).toHaveLength(1);
expect(overview.replace(/<!--[\s\S]*?-->/g, "").trim()).toBe("");
expect(overview).toContain("required to list");
expect(overview).toContain("README");
expect(overview).toContain("choosing a plugin in Paseo");
expect(overview).toContain("Length follows complexity");
expect(overview).toContain("1. Describe what your plugin is and does");
expect(overview).toContain("2. Explain how it works only when it is not obvious");
expect(overview).toContain("3. Explain setup when needed");
expect(overview).toContain("4. Explain capabilities and settings");
expect(overview).toContain("permissions");
expect(overview).toContain("known limits");
expect(overview).not.toContain("This plugin entry was imported");
const packageJson = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8"));
expect(packageJson.files).toContain("OVERVIEW.md");
expect(await readdir(directory)).toContain("paseo-plugin.json");
});
it("includes the author overview in the packed npm artifact", async () => {
const parent = await mkdtemp(path.join(tmpdir(), "paseo-plugin-scaffold-"));
directories.push(parent);
const directory = path.join(parent, "packed-plugin");
await scaffoldPluginDirectory(directory);
const { stdout } = await execAsync("npm pack --dry-run --ignore-scripts --json", {
cwd: directory,
});
const [artifact] = JSON.parse(stdout) as { files: { path: string }[] }[];
expect(artifact.files.map((file) => file.path)).toEqual(
expect.arrayContaining(["OVERVIEW.md", "paseo-plugin.json"]),
);
});
it(
"creates a standalone split-runtime project that typechecks",
{ timeout: 15_000 },
@@ -60,6 +102,7 @@ describe("plugin scaffold", () => {
version: "0.0.0",
files: [
"paseo-plugin.json",
"OVERVIEW.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
@@ -3,6 +3,34 @@ 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",
@@ -155,6 +183,7 @@ export async function scaffoldPluginDirectory(
version: "0.0.0",
files: [
"paseo-plugin.json",
"OVERVIEW.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
@@ -179,6 +208,7 @@ export async function scaffoldPluginDirectory(
"paseo-plugin.json",
`${JSON.stringify({ id, 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],
@@ -144,6 +144,19 @@ const title = "Hello";
);
});
it("keeps an Astro expression open through nested template strings", () => {
const code = "<p>{`a ${`}`} b`}</p>";
const tokens = highlightCode(code, "Nested.astro").flat();
expect(tokens).toEqual(
expect.arrayContaining([
{ text: "`}`", style: "string" },
{ text: " b`", style: "string" },
]),
);
});
it("highlights Vue SFCs across script, template, and style", () => {
const code = [
'<script lang="ts">',
+2 -78
View File
@@ -14,6 +14,7 @@ import {
import { parser as cssParser } from "@lezer/css";
import { parser as htmlParser } from "@lezer/html";
import { parser as jsParser } from "@lezer/javascript";
import { findClosingBrace } from "../js-scanner.js";
interface Range {
from: number;
@@ -23,88 +24,11 @@ interface Range {
const jsxParser = jsParser.configure({ dialect: "ts jsx" });
const typescriptParser = jsParser.configure({ dialect: "ts" });
function isSpace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13;
}
function isRegexStart(text: string, position: number): boolean {
let previous = position - 1;
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
if (previous < 0) return true;
const character = text[previous];
if (/[)\]}<"'`\d]/.test(character)) return false;
const code = text.charCodeAt(previous);
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
if (!/[A-Za-z_$]/.test(character)) return true;
let start = previous;
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
const keyword = text.slice(start + 1, previous + 1);
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
keyword,
);
}
function skipQuotedText(text: string, opening: number): number {
const quote = text.charCodeAt(opening);
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 92) position++;
else if (code === quote) return position;
}
return text.length - 1;
}
function skipLineComment(text: string, opening: number): number {
const newline = text.indexOf("\n", opening + 2);
return newline >= 0 ? newline : text.length - 1;
}
function skipBlockComment(text: string, opening: number): number {
const closing = text.indexOf("*/", opening + 2);
return closing >= 0 ? closing + 1 : text.length - 1;
}
function skipRegex(text: string, opening: number): number {
let isInCharacterClass = false;
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 10 || code === 13) return position;
if (code === 92) position++;
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
else if (!isInCharacterClass && code === 47) return position;
}
return text.length - 1;
}
function findClosingBrace(text: string, opening: number): number {
let depth = 0;
for (let position = opening; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 47 && text.charCodeAt(position + 1) === 47) {
position = skipLineComment(text, position);
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
position = skipBlockComment(text, position);
} else if (code === 47 && isRegexStart(text, position)) {
position = skipRegex(text, position);
} else if (code === 34 || code === 39 || code === 96) {
position = skipQuotedText(text, position);
} else if (code === 123) {
depth++;
} else if (code === 125 && --depth === 0) {
return position;
}
}
return -1;
}
function findExpressions(text: string): Range[] {
const ranges: Range[] = [];
for (let position = 0; position < text.length; position++) {
if (text.charCodeAt(position) !== 123) continue;
const closing = findClosingBrace(text, position);
const closing = findClosingBrace(text, position + 1);
if (closing < 0) break;
ranges.push({ from: position, to: closing });
position = closing;
+100
View File
@@ -0,0 +1,100 @@
// Scans JavaScript embedded in a template (Astro `{ }` expressions, Vue `{{ }}`
// interpolations) for the brace that closes it, without parsing the code.
function isSpace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13;
}
function isRegexStart(text: string, position: number): boolean {
let previous = position - 1;
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
if (previous < 0) return true;
const character = text[previous];
if (/[)\]}<"'`\d]/.test(character)) return false;
const code = text.charCodeAt(previous);
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
if (!/[A-Za-z_$]/.test(character)) return true;
let start = previous;
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
const keyword = text.slice(start + 1, previous + 1);
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
keyword,
);
}
// Template strings nested deeper than this are treated as running to the end.
const MAX_TEMPLATE_NESTING = 32;
function skipQuotedText(text: string, opening: number, nesting = 0): number {
const quote = text.charCodeAt(opening);
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 92) position++;
else if (code === quote) return position;
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
position = skipTemplateSubstitution(text, position + 2, nesting);
}
}
return text.length - 1;
}
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
const closing = findClosingBrace(text, start, () => true, nesting + 1);
return closing >= 0 ? closing : text.length - 1;
}
function skipLineComment(text: string, opening: number): number {
const newline = text.indexOf("\n", opening + 2);
return newline >= 0 ? newline : text.length - 1;
}
function skipBlockComment(text: string, opening: number): number {
const closing = text.indexOf("*/", opening + 2);
return closing >= 0 ? closing + 1 : text.length - 1;
}
function skipRegex(text: string, opening: number): number {
let isInCharacterClass = false;
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 10 || code === 13) return position;
if (code === 92) position++;
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
else if (!isInCharacterClass && code === 47) return position;
}
return text.length - 1;
}
// Finds the first `}` outside nested braces, strings, comments, and regexes
// for which `isEnd` holds.
export function findClosingBrace(
text: string,
start: number,
isEnd: (position: number) => boolean = () => true,
nesting = 0,
): number {
let depth = 0;
for (let position = start; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 47 && text.charCodeAt(position + 1) === 47) {
position = skipLineComment(text, position);
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
position = skipBlockComment(text, position);
} else if (code === 47 && isRegexStart(text, position)) {
position = skipRegex(text, position);
} else if (code === 34 || code === 39 || code === 96) {
position = skipQuotedText(text, position, nesting);
} else if (code === 123) {
depth++;
} else if (code === 125 && depth === 0) {
if (isEnd(position)) return position;
} else if (code === 125) {
depth--;
}
}
return -1;
}
+1 -98
View File
@@ -8,6 +8,7 @@ import {
import { parser as cssParser } from "@lezer/css";
import { parser as htmlParser } from "@lezer/html";
import { parser as jsParser } from "@lezer/javascript";
import { findClosingBrace } from "../js-scanner.js";
interface Range {
from: number;
@@ -17,104 +18,6 @@ interface Range {
const typescriptParser = jsParser.configure({ dialect: "ts" });
const expressionParser = jsParser.configure({ top: "SingleExpression" });
function isSpace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13;
}
function isRegexStart(text: string, position: number): boolean {
let previous = position - 1;
while (previous >= 0 && isSpace(text.charCodeAt(previous))) previous--;
if (previous < 0) return true;
const character = text[previous];
if (/[)\]}<"'`\d]/.test(character)) return false;
const code = text.charCodeAt(previous);
if (code === 62) return previous > 0 && text.charCodeAt(previous - 1) === 61;
if (!/[A-Za-z_$]/.test(character)) return true;
let start = previous;
while (start >= 0 && /[A-Za-z0-9_$]/.test(text[start])) start--;
const keyword = text.slice(start + 1, previous + 1);
return /^(return|typeof|instanceof|in|of|new|void|delete|yield|await|case|do|else|throw|extends|assert|with)$/.test(
keyword,
);
}
// Template strings nested deeper than this are treated as running to the end.
const MAX_TEMPLATE_NESTING = 32;
function skipQuotedText(text: string, opening: number, nesting = 0): number {
const quote = text.charCodeAt(opening);
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 92) position++;
else if (code === quote) return position;
else if (quote === 96 && code === 36 && text.charCodeAt(position + 1) === 123) {
position = skipTemplateSubstitution(text, position + 2, nesting);
}
}
return text.length - 1;
}
function skipTemplateSubstitution(text: string, start: number, nesting: number): number {
if (nesting >= MAX_TEMPLATE_NESTING) return text.length - 1;
const closing = findClosingBrace(text, start, () => true, nesting + 1);
return closing >= 0 ? closing : text.length - 1;
}
function skipLineComment(text: string, opening: number): number {
const newline = text.indexOf("\n", opening + 2);
return newline >= 0 ? newline : text.length - 1;
}
function skipBlockComment(text: string, opening: number): number {
const closing = text.indexOf("*/", opening + 2);
return closing >= 0 ? closing + 1 : text.length - 1;
}
function skipRegex(text: string, opening: number): number {
let isInCharacterClass = false;
for (let position = opening + 1; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 10 || code === 13) return position;
if (code === 92) position++;
else if (isInCharacterClass && code === 93) isInCharacterClass = false;
else if (!isInCharacterClass && code === 91) isInCharacterClass = true;
else if (!isInCharacterClass && code === 47) return position;
}
return text.length - 1;
}
// Finds the first `}` outside nested braces, strings, comments, and regexes
// for which `isEnd` holds.
function findClosingBrace(
text: string,
start: number,
isEnd: (position: number) => boolean,
nesting = 0,
): number {
let depth = 0;
for (let position = start; position < text.length; position++) {
const code = text.charCodeAt(position);
if (code === 47 && text.charCodeAt(position + 1) === 47) {
position = skipLineComment(text, position);
} else if (code === 47 && text.charCodeAt(position + 1) === 42) {
position = skipBlockComment(text, position);
} else if (code === 47 && isRegexStart(text, position)) {
position = skipRegex(text, position);
} else if (code === 34 || code === 39 || code === 96) {
position = skipQuotedText(text, position, nesting);
} else if (code === 123) {
depth++;
} else if (code === 125 && depth === 0) {
if (isEnd(position)) return position;
} else if (code === 125) {
depth--;
}
}
return -1;
}
function findInterpolationEnd(text: string, start: number): number {
return findClosingBrace(text, start, (position) => text.charCodeAt(position + 1) === 125);
}
@@ -3,6 +3,35 @@ import { describe, expect, it } from "vitest";
import { parsePluginSourceReference } from "./plugin-source-reference.js";
describe("plugin source references", () => {
it.each([
".",
"..",
"./owner/slug",
"../owner/slug",
"/owner/slug",
"~/owner/slug",
"~",
"C:\\",
"C:/",
"D:\\plugins\\repository",
"D:/plugins/repository",
"\\\\server\\share",
"\\\\server\\share\\plugins\\review",
".\\plugins\\review",
"..\\plugins\\review",
])("recognizes the explicit directory %s by shape", (source) => {
expect(parsePluginSourceReference(source)).toEqual({
kind: "directory",
source,
pluginPath: undefined,
});
expect(parsePluginSourceReference(`${source}:plugins/review`)).toEqual({
kind: "directory",
source,
pluginPath: "plugins/review",
});
});
it.each([
["npm:review@1.2.0:nested", "npm:review@1.2.0", "nested"],
["npm:review@^1.2.0", "npm:review@^1.2.0", undefined],
@@ -16,6 +45,7 @@ describe("plugin source references", () => {
["git:file:///repo:plugins/main", "git:file:///repo", "plugins/main"],
["plugins.example.com:8443/owner/slug", "plugins.example.com:8443/owner/slug", undefined],
["owner/repository", "owner/repository", undefined],
["plugins/foo", "plugins/foo", undefined],
["owner/repository:plugins/review", "owner/repository", "plugins/review"],
[
"https://example.test:8443/owner/repository.git",
@@ -39,10 +69,8 @@ describe("plugin source references", () => {
"file:///D:/plugins/repository",
"plugins/review",
],
["D:\\plugins\\repository", "D:\\plugins\\repository", undefined],
["D:\\plugins\\repository:plugins/review", "D:\\plugins\\repository", "plugins/review"],
])("parses %s", (reference, source, pluginPath) => {
expect(parsePluginSourceReference(reference)).toEqual({ source, pluginPath });
expect(parsePluginSourceReference(reference)).toEqual({ kind: "managed", source, pluginPath });
});
});
@@ -1,19 +1,27 @@
import { parsePluginRegistryReference } from "./plugin-registry.js";
import type { PluginInstallation } from "./messages.js";
export interface PluginSourceReference {
kind: "directory" | "managed";
source: string;
pluginPath: string | undefined;
}
export function parsePluginSourceReference(reference: string): PluginSourceReference {
if (parsePluginRegistryReference(reference)) return { source: reference, pluginPath: undefined };
const prefix = /^(npm:|github:|git:(?!\/\/))/.exec(reference)?.[0];
const source = prefix ? reference.slice(prefix.length) : reference;
const parsed = splitPluginPath(source, { scp: prefix !== "npm:" });
return { ...parsed, source: `${prefix ?? ""}${parsed.source}` };
if (!prefix && /^(\.\.?(?:[/\\]|$)|\/|~|[A-Za-z]:[/\\]|\\\\)/.test(parsed.source)) {
return { kind: "directory", ...parsed };
}
if (parsePluginRegistryReference(reference))
return { kind: "managed", source: reference, pluginPath: undefined };
return { kind: "managed", ...parsed, source: `${prefix ?? ""}${parsed.source}` };
}
function splitPluginPath(reference: string, options: { scp: boolean }): PluginSourceReference {
function splitPluginPath(
reference: string,
options: { scp: boolean },
): Pick<PluginSourceReference, "source" | "pluginPath"> {
const separator = reference.lastIndexOf(":");
if (separator === -1) return { source: reference, pluginPath: undefined };
@@ -0,0 +1,80 @@
import { afterEach, expect, test } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import type { AgentSession, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
import { ClaudeAgentClient } from "./agent.js";
// Reproduces #6199 on the logged-in Claude Code path. Claude Opus 5.5 writes the
// text it means for the user between tool calls as a "progress update": a
// thinking block that Claude Code marks with `narration_block_indexes`.
const PROMPT =
"There are three plan files in this directory: plan-a.txt, plan-b.txt and plan-c.txt. Read each one, tell me how they compare on cost, time and risk, and then ask me which plan to pick using your AskUserQuestion tool. Keep me posted as you go.";
const handles: Array<{ cwd: string; session: AgentSession }> = [];
afterEach(async () => {
for (const handle of handles.splice(0)) {
await handle.session.close().catch(() => undefined);
rmSync(handle.cwd, { recursive: true, force: true });
}
});
function writePlans(cwd: string): void {
writeFileSync(
path.join(cwd, "plan-a.txt"),
"Plan A\nCost: 3 credits\nTime: 2 days\nRisk: needs a manual review step\n",
);
writeFileSync(
path.join(cwd, "plan-b.txt"),
"Plan B\nCost: 5 credits\nTime: 1 day\nRisk: none known\n",
);
writeFileSync(
path.join(cwd, "plan-c.txt"),
"Plan C\nCost: 2 credits\nTime: 4 days\nRisk: depends on an external vendor\n",
);
}
test("the comparison written before AskUserQuestion reaches the timeline as assistant text", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "claude-progress-updates-"));
writePlans(cwd);
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
const session = await client.createSession({
provider: "claude",
cwd,
model: "claude-opus-5-5",
thinkingOptionId: "xhigh",
modeId: "bypassPermissions",
});
handles.push({ cwd, session });
const items: AgentTimelineItem[] = [];
await new Promise<void>((resolve) => {
session.subscribe((event: AgentStreamEvent) => {
if (event.type === "timeline") items.push(event.item);
if (
event.type === "permission_requested" ||
event.type === "turn_completed" ||
event.type === "turn_failed"
) {
resolve();
}
});
void session.startTurn(PROMPT);
});
const lastReadIndex = items.findLastIndex(
(item) => item.type === "tool_call" && item.name !== "AskUserQuestion",
);
const askIndex = items.findIndex(
(item) => item.type === "tool_call" && item.name === "AskUserQuestion",
);
expect(askIndex).toBeGreaterThan(lastReadIndex);
const between = items.slice(lastReadIndex + 1, askIndex);
const assistantText = between
.flatMap((item) => (item.type === "assistant_message" ? [item.text] : []))
.join("");
expect(assistantText).toMatch(/plan/i);
}, 280_000);
-2
View File
@@ -414,7 +414,6 @@ export interface PaseoDaemonConfig {
plugins?: Record<string, PluginSource>;
pluginRegistries?: PluginRegistries;
pluginRegistryUrl?: string;
pluginRegistryEnabled?: boolean;
staticDir: string;
mcpDebug: boolean;
isDev?: boolean;
@@ -625,7 +624,6 @@ export async function createPaseoDaemon(
usageSession: (id) => agentManager.usageSession(id),
},
managedSources: new ManagedPluginSources(config.paseoHome, {
enabled: config.pluginRegistryEnabled ?? false,
registries: config.pluginRegistries,
defaultUrl: config.pluginRegistryUrl,
}),
@@ -30,7 +30,6 @@ export const DAEMON_SETTING_ENV_KEYS = [
"PASEO_LOG_ROTATE_SIZE",
"PASEO_PASSWORD",
"PASEO_PLUGIN_REGISTRY",
"PASEO_PLUGIN_REGISTRY_ENABLED",
"PASEO_RELAY_ENABLED",
"PASEO_RELAY_ENDPOINT",
"PASEO_RELAY_PUBLIC_ENDPOINT",
+9 -11
View File
@@ -205,21 +205,19 @@ test("loads private plugin registry settings through the configuration boundary"
const home = await mkdtemp(path.join(os.tmpdir(), "paseo-registry-config-"));
try {
const pluginRegistries = { "plugins.example.test": { authorization: "Bearer fixture" } };
await writeFile(path.join(home, "config.json"), JSON.stringify({ pluginRegistries }));
await writeFile(
path.join(home, "config.json"),
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: false }),
);
const config = loadConfig(home, {
env: { PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal" },
env: {
PASEO_PLUGIN_REGISTRY: "https://plugins.example.test/internal",
PASEO_PLUGIN_REGISTRY_ENABLED: "false",
},
});
expect(config.pluginRegistryUrl).toBe("https://plugins.example.test/internal");
expect(config.pluginRegistries).toEqual(pluginRegistries);
expect(config.pluginRegistryEnabled).toBe(false);
await writeFile(
path.join(home, "config.json"),
JSON.stringify({ pluginRegistries, pluginRegistryEnabled: true }),
);
expect(loadConfig(home, { env: {} }).pluginRegistryEnabled).toBe(true);
expect(
loadConfig(home, { env: { PASEO_PLUGIN_REGISTRY_ENABLED: "false" } }).pluginRegistryEnabled,
).toBe(false);
expect(config).not.toHaveProperty("pluginRegistryEnabled");
} finally {
await rm(home, { recursive: true, force: true });
}
-4
View File
@@ -628,10 +628,6 @@ export function resolveConfigFromPersisted(
plugins: persisted.plugins,
pluginRegistries: persisted.pluginRegistries,
pluginRegistryUrl: env.PASEO_PLUGIN_REGISTRY,
pluginRegistryEnabled:
parseBooleanEnv(env.PASEO_PLUGIN_REGISTRY_ENABLED) ??
persisted.pluginRegistryEnabled ??
false,
mcpDebug: env.MCP_DEBUG === "1",
isDev: resolvePaseoNodeEnv(env) === "development",
agentStoragePath: path.join(paseoHome, "agents"),
@@ -308,6 +308,7 @@ export const PersistedConfigSchema = z
providers: ProvidersSchema.optional(),
pluginRegistries: PluginRegistriesSchema.optional(),
// COMPAT(plugin-registry-gate): added in v0.11.0; accept the ignored startup key until 2027-04-06.
pluginRegistryEnabled: z.boolean().optional(),
pluginsEnabled: z.boolean().optional(),
plugins: z.record(PluginIdSchema, PluginSourceSchema).optional(),
@@ -10,6 +10,7 @@ import {
stat,
writeFile,
} from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -451,7 +452,102 @@ describe("PluginService", () => {
await service.stopAllPlugins();
});
it("prefers an existing directory and installs its selected plugin subdirectory", async () => {
it("resolves a bare registry id even when a matching directory exists in the daemon cwd", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
const owner = path.join(process.cwd(), `registry-shadow-${randomUUID()}`);
await mkdir(owner);
roots.push(home, owner);
const source = `${path.basename(owner)}/example`;
const repository = await createPlugin("registry-example", "export default () => () => {};\n");
await mkdir(path.join(owner, "example"));
await cp(repository, path.join(owner, "example"), { recursive: true });
await runGitCommand(["init", "-b", "main"], { cwd: repository });
await runGitCommand(["add", "-A"], { cwd: repository });
await runGitCommand(
[
"-c",
"user.name=Paseo Tests",
"-c",
"user.email=paseo@example.test",
"commit",
"-m",
"fixture",
],
{ cwd: repository },
);
const { stdout } = await runGitCommand(["rev-parse", "HEAD"], { cwd: repository });
const requests: Array<string | undefined> = [];
const server = createServer((request, response) => {
requests.push(request.url);
response.setHeader("Content-Type", "application/json");
response.end(
JSON.stringify({
id: source,
name: "Example",
description: "Test",
categories: [],
author: { github: path.basename(owner) },
repository: { url: "https://github.com/fixture/example" },
artifact: {
kind: "git",
remote: pathToFileURL(repository).href,
commit: stdout.trim(),
pluginPath: ".",
},
screenshots: [],
submittedAt: "2026-10-03",
reviewedAt: "2026-10-03",
updatedAt: "2026-10-03",
publishedAt: "2026-10-03",
readme: "# Test",
}),
);
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const url = `http://127.0.0.1:${address.port}`;
const store = createStore(home);
store.patch({ pluginsEnabled: false });
const service = bindTestSessionHost(
new PluginService(pino({ level: "silent" }), store, "0.4.0", {
managedSources: new ManagedPluginSources(home, { defaultUrl: url }),
}),
);
try {
await service.start();
await expect(service.installSource({ source })).resolves.toMatchObject({
id: "registry-example",
status: "disabled",
installation: {
identity: { kind: "git", registry: { url, id: source } },
currentRevision: stdout.trim(),
},
});
expect(requests).toEqual([`/plugins/${source}.json`]);
await expect(
service.installSource({ source: `./${source}`, id: "local-example" }),
).resolves.toMatchObject({
installation: { identity: { kind: "directory", path: path.join(owner, "example") } },
});
expect(requests).toEqual([`/plugins/${source}.json`]);
} finally {
await service.stopAllPlugins();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}, 30_000);
it("names a missing explicit directory instead of trying a managed source", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const directory = path.join(home, "missing");
const service = createService(home);
await expect(service.installSource({ source: directory })).rejects.toThrow(
`Plugin directory does not exist: ${directory}`,
);
});
it("installs an explicit directory and its selected plugin subdirectory", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const repository = await mkdtemp(path.join(tmpdir(), "owner-repository-"));
+6 -10
View File
@@ -21,6 +21,7 @@ import { BUILTIN_PROVIDER_IDS } from "@getpaseo/protocol/provider-manifest";
import type { DaemonConfigStore } from "../daemon-config-store.js";
import { type ManagedPluginCandidate, ManagedPluginSources } from "./managed-source.js";
import { readPluginManifest } from "./manifest.js";
import { expandTilde } from "../../utils/path.js";
import { runPluginBuild } from "./preparation.js";
import { PluginRuntime } from "./runtime.js";
import { BuiltinPluginLoader, type BuiltinPlugin } from "./builtin/index.js";
@@ -286,16 +287,11 @@ export class PluginService {
if (input.id && this.builtinPluginIds.has(input.id)) {
throw new Error(`Plugin ID "${input.id}" is reserved for a built-in plugin`);
}
const directDirectory = path.resolve(input.source);
const explicit = /^(npm:|github:|git:(?!\/\/))/.test(input.source);
const directInfo = await stat(directDirectory).catch(() => null);
const reference = directInfo?.isDirectory()
? { source: input.source, pluginPath: undefined }
: parsePluginSourceReference(input.source);
const directory = path.resolve(reference.source);
let info = directInfo;
if (!info?.isDirectory() && !explicit) info = await stat(directory).catch(() => null);
if (info?.isDirectory()) {
const reference = parsePluginSourceReference(input.source);
if (reference.kind === "directory") {
const directory = path.resolve(expandTilde(reference.source));
const info = await stat(directory).catch(() => null);
if (!info?.isDirectory()) throw new Error(`Plugin directory does not exist: ${directory}`);
if (input.ref) throw new Error("Plugin --ref is only valid for Git sources");
const pluginDirectory = resolveLocalPluginPath(directory, reference.pluginPath);
return this.installDirectory({ path: pluginDirectory, id: input.id });
@@ -280,7 +280,60 @@ describe("managed Git plugin sources", () => {
});
describe("registry plugin sources", () => {
it("treats owner/repository as GitHub shorthand without contacting the registry while registry installs are off", async () => {
it("explains how to choose a revision when --ref is used with a registry id", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-ref-home-"));
roots.push(home);
const sources = new ManagedPluginSources(home);
await expect(
sources.prepareInstall({ source: "fixture/example", ref: "main" }),
).rejects.toThrow(
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
);
});
it("names the registry URL and recovery steps when the registry fetch fails", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-offline-home-"));
roots.push(home);
const server = createServer((request) => request.socket.destroy());
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const url = `http://127.0.0.1:${address.port}`;
try {
const sources = new ManagedPluginSources(home, { defaultUrl: url });
await expect(sources.prepareInstall({ source: "fixture/example" })).rejects.toThrow(
`Could not reach plugin registry ${url}/plugins/fixture/example.json. Plugin fixture/example was not installed. Retry or use an explicit npm: or github: source.`,
);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("explains explicit local and GitHub sources when a registry ID is missing", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-missing-home-"));
roots.push(home);
const requests: Array<string | undefined> = [];
const server = createServer((request, response) => {
requests.push(request.url);
response.writeHead(404).end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
try {
const sources = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}`,
});
await expect(sources.prepareInstall({ source: "fixture/missing" })).rejects.toThrow(
"If you intended a local directory, use ./fixture/missing. If you intended a GitHub source, use git:fixture/missing, or a full Git URL for another Git host.",
);
expect(requests).toEqual(["/plugins/fixture/missing.json"]);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
it("keeps explicit Git sources and persisted Git installs independent of the registry", async () => {
const repository = await createRepository();
const home = await mkdtemp(path.join(tmpdir(), "paseo-registry-off-home-"));
roots.push(home);
@@ -298,12 +351,34 @@ describe("registry plugin sources", () => {
const sources = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}`,
});
const candidate = await sources.prepareInstall({ source: "fixture/repository" });
expect(candidate.record).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
await sources.discard(candidate);
for (const source of [
"git:fixture/repository",
"https://github.com/fixture/repository.git",
]) {
const candidate = await sources.place(
"managed-example",
await sources.prepareInstall({ source }),
);
expect(candidate.record).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
});
sources.commit("managed-example", candidate.record);
const restarted = new ManagedPluginSources(home, {
defaultUrl: `http://127.0.0.1:${address.port}/changed`,
});
expect((await restarted.preview("managed-example", candidate.directory)).outcome).toBe(
"current",
);
expect(
(await restarted.describe("managed-example", candidate.directory)).identity,
).toEqual({
kind: "git",
remote: "https://github.com/fixture/repository.git",
pluginPath: ".",
});
await sources.discard(candidate);
}
});
expect(requests).toEqual([]);
} finally {
@@ -332,12 +407,17 @@ describe("registry plugin sources", () => {
intent: string | string[] | undefined;
authorization: string | undefined;
}> = [];
let missing = false;
const server = createServer((req, res) => {
requests.push({
path: req.url,
intent: req.headers["x-paseo-install"],
authorization: req.headers.authorization,
});
if (missing) {
res.writeHead(404).end();
return;
}
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify({
@@ -369,7 +449,6 @@ describe("registry plugin sources", () => {
const url = `http://${host}/internal`;
try {
const sources = new ManagedPluginSources(home, {
enabled: true,
defaultUrl: url,
registries: { [host]: { authorization: "Bearer test" } },
});
@@ -380,7 +459,11 @@ describe("registry plugin sources", () => {
).toBe("managed-example");
await sources.verifyCandidate("managed-example", candidate);
sources.commit("managed-example", candidate.record);
const preview = await sources.preview("managed-example", candidate.directory);
const restarted = new ManagedPluginSources(home, {
defaultUrl: "http://127.0.0.1:1/changed",
registries: { [host]: { authorization: "Bearer test" } },
});
const preview = await restarted.preview("managed-example", candidate.directory);
expect(preview.outcome).toBe("current");
expect(preview.current?.identity).toEqual({
kind: "git",
@@ -388,6 +471,10 @@ describe("registry plugin sources", () => {
pluginPath: "packages/example",
registry: { url, id: "acme/example" },
});
missing = true;
await expect(restarted.preview("managed-example", candidate.directory)).rejects.toThrow(
"Check that the installed plugin is still published in this registry before updating",
);
expect(requests).toEqual([
{ path: "/internal/plugins/acme/example.json", intent: "1", authorization: "Bearer test" },
{
@@ -395,6 +482,11 @@ describe("registry plugin sources", () => {
intent: undefined,
authorization: "Bearer test",
},
{
path: "/internal/plugins/acme/example.json",
intent: undefined,
authorization: "Bearer test",
},
]);
} finally {
await new Promise<void>((resolve, reject) =>
@@ -444,7 +536,7 @@ it("installs and updates only the npm artifacts pinned by the plugin registry",
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Missing registry address");
const options = { enabled: true, defaultUrl: `http://127.0.0.1:${address.port}` };
const options = { defaultUrl: `http://127.0.0.1:${address.port}` };
try {
const sources = new ManagedPluginSources(home, options);
const candidate = await sources.place(
@@ -147,11 +147,13 @@ export class ManagedPluginSources {
input: InstallInput,
target?: PluginUpdateTarget,
): Promise<ManagedPluginCandidate> {
const registry = this.registryOptions.enabled
? parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl)
: null;
const registry = parsePluginRegistryReference(input.source, this.registryOptions.defaultUrl);
if (registry) {
if (input.ref || input.pluginPath)
if (input.ref)
throw new Error(
"Registry installs pin the reviewed revision. Drop --ref, or install from an explicit github:owner/repository source to choose a revision.",
);
if (input.pluginPath)
throw new Error("Registry sources use their reviewed artifact path and revision");
const resolved = await resolveRegistryPlugin(registry, this.registryOptions, true);
const candidate = await this.prepareInstall(resolved.input, resolved.target);
@@ -175,7 +177,7 @@ export class ManagedPluginSources {
sourceRoot = path.join(versionRoot, "node_modules", artifact.packageName);
record = { kind: "npm" };
} else {
const remote = normalizeGitSource(input.source, !this.registryOptions.enabled);
const remote = normalizeGitSource(input.source);
sourceRoot = path.join(versionRoot, "checkout");
await clone(remote, sourceRoot);
const commit = await resolveRequestedRef(sourceRoot, input.ref);
@@ -227,7 +229,7 @@ export class ManagedPluginSources {
const location = this.locate(pluginId, configuredPath);
let target: PluginUpdateTarget;
let outcome: PluginUpdatePreview["outcome"];
if (this.registryOptions.enabled && current.identity.registry) {
if (current.identity.registry) {
if (selection) throw new Error("Registry updates use only the reviewed registry pin");
const resolved = await resolveRegistryPlugin(
current.identity.registry,
@@ -304,7 +306,7 @@ export class ManagedPluginSources {
await this.assertCurrent(proposal, configuredPath);
const identity = this.locate(proposal.id, configuredPath).identity;
const record = this.records[proposal.id];
if (this.registryOptions.enabled && identity.kind !== "directory" && identity.registry) {
if (identity.kind !== "directory" && identity.registry) {
const resolved = await resolveRegistryPlugin(identity.registry, this.registryOptions, false);
assertRegistryArtifactIdentity(identity, resolved.artifact);
if (!isDeepStrictEqual(resolved.target, proposal.target))
@@ -436,10 +438,9 @@ function reviewLinks(current: PluginInstallation, target: PluginUpdateTarget): s
}
return [];
}
/** Bare owner/repo is GitHub shorthand unless registry references own that syntax. */
function normalizeGitSource(source: string, bareGitHubShorthand: boolean): string {
const expandsShorthand =
bareGitHubShorthand || source.startsWith("github:") || source.startsWith("git:");
/** Only explicit Git prefixes expand GitHub shorthand. */
function normalizeGitSource(source: string): string {
const expandsShorthand = source.startsWith("github:") || source.startsWith("git:");
if (source.startsWith("github:")) {
const shorthand = source.slice(7);
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(shorthand)) {
@@ -6,8 +6,6 @@ import {
import type { PluginUpdateTarget } from "@getpaseo/protocol/messages";
export interface RegistryOptions {
/** Off: bare owner/repo is GitHub shorthand and no registry is contacted. */
enabled?: boolean;
defaultUrl?: string;
registries?: PluginRegistries;
}
@@ -29,11 +27,26 @@ export async function resolveRegistryPlugin(
const headers: Record<string, string> = {};
if (authorization) headers.Authorization = authorization;
if (install) headers["X-Paseo-Install"] = "1";
const response = await fetch(`${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`, {
headers,
redirect: "error",
signal: AbortSignal.timeout(30000),
});
const url = `${identity.url.replace(/\/+$/, "")}/plugins/${identity.id}.json`;
let response: Response;
try {
response = await fetch(url, {
headers,
redirect: "error",
signal: AbortSignal.timeout(30000),
});
} catch (cause) {
const guidance = install
? `Plugin ${identity.id} was not installed. Retry or use an explicit npm: or github: source.`
: `Could not check updates for ${identity.id}. Retry later.`;
throw new Error(`Could not reach plugin registry ${url}. ${guidance}`, { cause });
}
if (response.status === 404) {
const guidance = install
? `If you intended a local directory, use ./${identity.id}. If you intended a GitHub source, use git:${identity.id}, or a full Git URL for another Git host.`
: "Check that the installed plugin is still published in this registry before updating.";
throw new Error(`Plugin ${identity.id} was not found in registry ${base.host}. ${guidance}`);
}
if (!response.ok)
throw new Error(`Registry ${base.host} returned ${response.status} for ${identity.id}`);
const plugin = PublishedPluginDetailSchema.parse(await response.json());
+22
View File
@@ -0,0 +1,22 @@
# Paseo website
## Plugin install counts
Clients report an install by resolving a plugin with `X-Paseo-Install: 1` or
`?intent=install`, using `/api/plugins/resolve/<owner>/<slug>` or
`/plugins/<owner>/<slug>.json`. Ordinary resolves do not count. These numbers
measure install intent, not successful artifact installation or npm downloads.
The worker requires Cloudflare's `CF-Connecting-IP` header to count a report.
It stores the plugin ID plus a SHA-256 hash of the IP in a `WEBSITE_CACHE` KV key
with a one hour TTL, never the raw IP. An existing key skips the count; repeats
do not extend the hour. Missing or empty IP headers do not count.
Accepted reports increment the existing all-time total and UTC daily buckets in
`WEBSITE_CACHE`. Daily buckets retain 60 days and supply the website's seven-day
and thirty-day rankings. `/api/plugins/installs` still returns plugin IDs mapped
to all-time totals. The [registry build](https://github.com/getpaseo/plugins/blob/main/scripts/build.ts)
reads that endpoint into the published plugin details and index; the website
reads windowed counts from KV through its five-minute cache. Existing counts
remain intact. The KV guard is advisory: concurrent requests and KV propagation
are not serialized.
+20
View File
@@ -12,6 +12,26 @@ const WINDOW_DAYS = { week: 7, month: 30 } as const;
const totalKey = (id: string) => `plugin-installs:${id}`;
const dailyKey = (id: string) => `plugin-installs-daily:${id}`;
export async function recordClientInstall({
cache,
id,
ip,
}: {
cache: KVNamespace;
id: string;
ip: string | null;
}): Promise<void> {
if (!ip) return;
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(ip));
const hash = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
const key = `plugin-installs-client:${id}:${hash}`;
if ((await cache.get(key)) !== null) return;
await cache.put(key, "1", { expirationTtl: 3600 });
await recordInstall(cache, id, new Date());
}
export async function recordInstall(cache: KVNamespace, id: string, now: Date): Promise<void> {
const [total, daily] = await Promise.all([cache.get(totalKey(id)), readDaily(cache, id)]);
await Promise.all([
+7 -2
View File
@@ -5,7 +5,7 @@ import {
} from "@getpaseo/protocol/plugin-registry";
import { getBlockingColdCache, type WebsiteCacheContext } from "../github-cache";
import { CATEGORIES } from "./categories";
import { type InstallCounts, readInstallCounts, recordInstall } from "./installs";
import { type InstallCounts, readInstallCounts, recordClientInstall } from "./installs";
export interface RegistryEnvironment {
PLUGINS_REGISTRY_URL?: string;
WEBSITE_CACHE?: KVNamespace;
@@ -111,7 +111,12 @@ export async function handlePluginRegistryRequest(
url.searchParams.get("intent") === "install"
) {
const cache = context.cache;
if (cache) context.waitUntil(recordInstall(cache, id, new Date()).catch(() => undefined));
if (cache)
context.waitUntil(
recordClientInstall({ cache, id, ip: request.headers.get("CF-Connecting-IP") }).catch(
() => undefined,
),
);
}
return Response.json(plugin);
}
+107 -4
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { createServer } from "node:http";
import { describe, expect, it } from "vitest";
import {
@@ -10,7 +11,7 @@ import {
searchPlugins,
} from "./registry";
import { readInstallCounts, recordInstall } from "./installs";
import { loadRegistryIndex, loadRegistryPlugin } from "./published";
import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published";
const plugin: Plugin = {
id: "acme/example",
name: "Example",
@@ -94,6 +95,81 @@ describe("plugin registry", () => {
Object.keys(JSON.parse((await cache.get("plugin-installs-daily:acme/example")) ?? "{}")),
).toEqual(["2026-09-10", "2026-09-30", "2026-10-04"]);
});
it("counts repeated install reports from the same IP once per hour", async () => {
let now = Date.now();
const cache = memoryKv(() => now);
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
await resolvePlugin(cache, headers);
now += 3_599_000;
await resolvePlugin(cache, headers);
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 1, month: 1, all: 1 },
});
now += 1_000;
await resolvePlugin(cache, headers);
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 2, month: 2, all: 2 },
});
});
it("counts different IPs and plugins independently, including query install intent", async () => {
const cache = memoryKv();
const first = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
await resolvePlugin(cache, first);
await resolvePlugin(cache, { "CF-Connecting-IP": "2001:db8::1" }, { query: "?intent=install" });
await resolvePlugin(cache, first, { id: "acme/other" });
await resolvePlugin(cache, first, { query: "?intent=install" });
await resolvePlugin(cache, first, { id: "acme/other", staticDetail: true });
expect(await readInstallCounts(cache, [plugin.id, "acme/other"], new Date())).toEqual({
[plugin.id]: { week: 2, month: 2, all: 2 },
"acme/other": { week: 1, month: 1, all: 1 },
});
const hash = createHash("sha256").update(first["CF-Connecting-IP"]).digest("hex");
expect(await cache.get(`plugin-installs-client:${plugin.id}:${hash}`)).toBe("1");
expect(JSON.stringify([...cache.values])).not.toContain("192.0.2.1");
expect(JSON.stringify([...cache.values])).not.toContain("2001:db8::1");
});
it("does not count without a Cloudflare IP or install intent", async () => {
const cache = memoryKv();
await resolvePlugin(cache, { "X-Paseo-Install": "1", "X-Forwarded-For": "192.0.2.1" });
await resolvePlugin(cache, {}, { query: "?intent=install" });
await resolvePlugin(cache, { "X-Paseo-Install": "1", "CF-Connecting-IP": "" });
await resolvePlugin(cache, { "CF-Connecting-IP": "192.0.2.1" });
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 0, month: 0, all: 0 },
});
expect([...cache.values.keys()].filter((key) => key.startsWith("plugin-installs"))).toEqual([]);
});
it("preserves historical totals and the installs endpoint response", async () => {
const cache = memoryKv();
await cache.put(`plugin-installs:${plugin.id}`, "40");
const headers = { "X-Paseo-Install": "1", "CF-Connecting-IP": "192.0.2.1" };
await resolvePlugin(cache, headers);
await resolvePlugin(cache, headers);
const base = "https://registry.example.test";
await cache.put(
`plugins:index:v1:${base}`,
JSON.stringify({
fetchedAt: Date.now(),
value: {
schemaVersion: 1,
registry: { name: "Internal", url: base },
categories: [],
plugins: [plugin],
generatedAt: "2026-10-03",
},
}),
);
const response = await handlePluginRegistryRequest(
new Request("https://paseo.sh/api/plugins/installs"),
{ PLUGINS_REGISTRY_URL: base },
{ cache, waitUntil: () => undefined },
);
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual({ [plugin.id]: 41 });
expect(await readInstallCounts(cache, [plugin.id], new Date())).toEqual({
[plugin.id]: { week: 1, month: 1, all: 41 },
});
});
it("searches name, description, ID, and author, ignoring case", () => {
const other = {
...plugin,
@@ -125,16 +201,43 @@ describe("plugin registry", () => {
});
/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */
function memoryKv(): KVNamespace {
function memoryKv(now = Date.now): KVNamespace & { values: Map<string, string> } {
const values = new Map<string, string>();
const expiresAt = new Map<string, number>();
const kv = {
values,
get: async (key: string, options?: { type?: string }) => {
if (now() >= (expiresAt.get(key) ?? Infinity)) values.delete(key);
const value = values.get(key) ?? null;
return options?.type === "json" && value !== null ? JSON.parse(value) : value;
},
put: async (key: string, value: string) => {
put: async (key: string, value: string, options?: { expirationTtl?: number }) => {
values.set(key, value);
expiresAt.set(key, options?.expirationTtl ? now() + options.expirationTtl * 1000 : Infinity);
},
};
return kv as unknown as KVNamespace;
return kv as unknown as KVNamespace & { values: Map<string, string> };
}
async function resolvePlugin(
cache: KVNamespace,
headers: HeadersInit,
{ id = plugin.id, query = "", staticDetail = false } = {},
): Promise<void> {
const base = "https://registry.example.test";
const detail = { ...plugin, id, readme: "# Example" };
await cache.put(
`plugins:detail:v1:${base}:${id}`,
JSON.stringify({ fetchedAt: Date.now(), value: detail }),
);
const pending: Promise<unknown>[] = [];
const path = staticDetail ? `/plugins/${id}.json` : `/api/plugins/resolve/${id}`;
const response = await handlePluginRegistryRequest(
new Request(`https://paseo.sh${path}${query}`, { headers }),
{ PLUGINS_REGISTRY_URL: base },
{ cache, waitUntil: (promise) => pending.push(promise) },
);
expect(response?.status).toBe(200);
expect(await response?.json()).toEqual(detail);
await Promise.all(pending);
}
+14 -4
View File
@@ -21,6 +21,16 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add
[connect a coding agent as a provider](/docs/plugins/providers). Client
contributions run on every Paseo client connected to that daemon, including mobile.
Browse [published plugins](https://paseo.sh/plugins), then install one on your daemon:
```bash
paseo plugin add owner/slug
```
Or paste `owner/slug` into **Settings → Plugins → Plugin source** and select **Install plugin**.
Paseo installs the registry's reviewed artifact. Use `git:owner/repository` or a full Git URL
when you want to install directly from a Git repository.
This guide scaffolds a plugin, runs it, and adds a workspace panel to it.
## Create a plugin
@@ -246,13 +256,13 @@ reload use the installed files.
See [plugin sources](/docs/plugins/reference#plugin-sources) for identifier syntax. To share your own plugin, follow
[Publish a plugin](/docs/plugins/publishing).
Plugins published in a Git repository install by shorthand or URL:
Plugins published in a Git repository install by explicit Git shorthand or URL:
```bash
paseo plugin add owner/repository
paseo plugin add git:owner/repository
paseo plugin add https://gitlab.com/group/repository.git
paseo plugin add owner/monorepo:plugins/workspace
paseo plugin add owner/repository --ref main
paseo plugin add git:owner/monorepo:plugins/workspace
paseo plugin add git:owner/repository --ref main
```
Append `:relative/path` when the plugin lives below the repository root. `--ref` selects the initial
+57 -1
View File
@@ -11,9 +11,24 @@ category: Plugins
Publish your plugin so other people can install and use it in Paseo. Start with a working
[plugin project](/docs/plugins), then choose where to share it:
- [Plugin registry](#plugin-registry): share a reviewed artifact users install by `owner/slug`.
- [npm](#publish-on-npm): publish a package on the public npm registry.
- [GitHub or Git](#share-through-github-or-git): let users install from a repository.
## Plugin registry
Browse published plugins at [paseo.sh/plugins](https://paseo.sh/plugins).
Users install the reviewed artifact with:
```bash
paseo plugin add owner/slug
```
Publish your artifact through npm or Git, then follow the
[registry submission guide](https://github.com/getpaseo/plugins) to submit it for review.
Registry installations and updates use the approved revision and plugin path.
Direct Git installs use explicit `git:owner/repository` shorthand or a full Git URL.
## Publish on npm
The scaffold prepares the package files and development dependencies. You choose the package name
@@ -56,6 +71,7 @@ The scaffold includes this `files` list in `package.json`:
{
"files": [
"paseo-plugin.json",
"OVERVIEW.md",
"index.client.ts",
"index.client.tsx",
"index.server.ts",
@@ -136,12 +152,52 @@ In the app, enter only the source identifier.
:::
## Your listing page
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
It helps someone decide whether to install your plugin; the install command is already at
the top of that page. A README assumes a GitHub audience and carries installation
instructions, technical details, and badges. Long, AI-generated READMEs make people read
past that material to understand what a plugin does.
`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside
`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it
relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm
package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment
with useful facts before publishing.
The repository overview takes precedence over a registry import stopgap. Online validation
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. 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.
1. Explain what the plugin is and does in plain terms first.
2. Explain how it works only when that is not obvious.
3. Explain setup when needed: settings, accounts, tokens, providers, external 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.
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, catalogs of theme-token fields,
or lists of absent features. Keep only what helps someone choose the plugin.
If your plugin is an unchanged imported record, you can propose an author-written overview
to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`.
## Share through GitHub or Git
Push the plugin project to a repository. Users can install it with:
```bash
paseo plugin install github:acme/paseo-review
paseo plugin add git:acme/paseo-review
```
For another Git host:
+20 -15
View File
@@ -2259,15 +2259,16 @@ failures stay inside the plugin error boundary.
Paste one of these source identifiers into **Settings → Plugins**, or pass it to
`paseo plugin install`. `paseo plugin add <source>` and `paseo plugin install <source>` are aliases.
Absolute host paths are recommended because relative paths resolve against the daemon's working
directory. The app does not expand `~`; your shell may expand it before the CLI runs.
directory. The daemon expands `~` to its home directory.
| Source | Accepted form | Example |
| -------------------------- | -------------------------------------------------------------------------- | --------------------------------------------- |
| Plugin registry | `owner/slug` or `host/owner/slug` | `acme/review` |
| Host directory | Absolute or relative path on the daemon host | `/srv/paseo/plugins/review` |
| GitHub repository | `github:owner/repository` or `owner/repository` | `github:acme/paseo-review` |
| GitHub repository | `git:owner/repository` or `github:owner/repository` | `github:acme/paseo-review` |
| Git repository | `git:<URL or SCP source>`; the prefix is optional for URLs and SCP sources | `git:https://git.example.com/acme/review.git` |
| npm package | `npm:<name>[@<version, tag, or range>]`; `npm:` is optional | `npm:@acme/paseo-review@^1.2.0` |
| Plugin below a source root | Append `:relative/plugin/path` to any source | `github:acme/monorepo:plugins/review` |
| Plugin below a source root | Append `:relative/plugin/path` to a directory, Git, or npm source | `github:acme/monorepo:plugins/review` |
Git URLs use `https://`, `http://`, `ssh://`, `git://`, or `file://`. SCP sources use
`user@host:path`. `file://` selects Git acquisition, not directory installation.
@@ -2283,24 +2284,27 @@ The package registry validates the selected version, tag, or range.
Paseo resolves an identifier in this order:
1. An existing directory matching the complete identifier on the daemon host wins, including a
literal directory containing `:`.
1. A bare `owner/slug` is a registry id; local directory sources are `.` or `..`, or start with `./`, `../`, `.\`, `..\`, `/`, `~`, a Windows drive (`C:\` or `C:/`), or a UNC (`\\server\share`) prefix.
2. Otherwise, recognize `npm:`, `github:`, or `git:` before interpreting a subdirectory suffix.
`git://` is a Git URL scheme. An explicit prefix selects acquisition of that kind.
3. Recognize a final `:relative/plugin/path` only when its suffix contains no empty, `.` or `..`
segments. A lone `.` selects the source root. Both `/` and `\` separate suffix segments; use `/`
across hosts. URL ports and the separator in an SCP source stay part of the source. A suffix
that does not satisfy these rules stays part of the identifier.
4. Without an explicit prefix, an existing directory matching the remaining source wins.
5. Resolve Git URLs and SCP sources as Git; expand exact `owner/repository` shorthand to GitHub
HTTPS. `github:` requires that shorthand; `git:` accepts it as well as URLs and SCP sources.
6. Resolve a remaining npm package name with its optional selector through the host's registry.
4. Resolve bare `owner/slug` through the default plugin registry and `host/owner/slug` through
that registry host. Resolve Git URLs and SCP sources as Git. `github:` requires
`owner/repository` shorthand; `git:` accepts it as well as URLs and SCP sources.
5. Resolve a remaining npm package name with its optional selector through the host's registry.
Reject anything else.
Plugin registry installs are off by default. When the daemon enables them with
`pluginRegistryEnabled: true` or `PASEO_PLUGIN_REGISTRY_ENABLED=1`, bare `owner/slug` and
`host/owner/slug` resolve through the plugin registry instead of GitHub, the registry record owns
the revision and plugin path, and GitHub shorthand requires `github:`.
The default registry is `https://plugins.paseo.sh`. Browse [published plugins](https://paseo.sh/plugins)
and install with `paseo plugin add owner/slug`. Registry records own the revision and plugin path.
Use `git:owner/repository` for explicit GitHub shorthand or a full Git URL.
Set `PASEO_PLUGIN_REGISTRY` to use a self-hosted default URL, including a path prefix.
Private registry credentials use `pluginRegistries: { "host": { "authorization": "Bearer token" } }`
in daemon config. Restart the daemon after changing these startup settings. Credentials go only
to that registry, never to artifact hosts or redirects. Installed plugins retain their recorded
source and registry URL when defaults change.
Directory lookup happens on the daemon host. The app uses the `paseo-plugin.json` ID; the CLI
accepts `--id <runtime-id>` to override it. An existing installation ID is rejected without changing
@@ -2347,9 +2351,10 @@ deletes its managed files; removing a directory plugin keeps your source directo
paseo plugin init /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin
paseo plugin install /absolute/path/to/plugin --id another-runtime-id
paseo plugin add owner/repository
paseo plugin add owner/slug
paseo plugin add git:owner/repository
paseo plugin add https://git.example.com/owner/repository.git --ref main
paseo plugin add owner/monorepo:plugins/review
paseo plugin add git:owner/monorepo:plugins/review
paseo plugin ls [id]
paseo plugin update <id>
paseo plugin update --all --check
+43
View File
@@ -64,6 +64,7 @@ The generated project contains:
```text
my-plugin/
paseo-plugin.json
OVERVIEW.md
package.json
tsconfig.json
index.client.tsx
@@ -638,6 +639,48 @@ Do not edit a local config when the target is a remote daemon. Perform the edit
When the same screen or sidebar item exists on several connected hosts, Paseo shows it once. The screen header has a host picker; a sidebar item uses the host of the screen on display, else the host last picked in one of the plugin's screens. The selected host owns the bundle, SDK calls, RPCs, and query cache. An offline selected host does not fall through to another host. Attachment sources stay scoped to the composer's host.
## Publishing a listing
Write `OVERVIEW.md` beside `paseo-plugin.json` for the plugin page inside Paseo.
It helps someone decide whether to install your plugin; the install command is already at
the top of that page. A README assumes a GitHub audience and carries installation
instructions, technical details, and badges. Long, AI-generated READMEs make people read
past that material to understand what a plugin does.
`OVERVIEW.md` is required to list your plugin in the registry. Commit it beside
`paseo-plugin.json` in the source repository at the pinned commit. The registry resolves it
relative to the manifest, under `pluginPath` for monorepos. Include it in the published npm
package too; the scaffold's `files` list includes it. Replace the scaffold's guidance comment
with useful facts before publishing.
The repository overview takes precedence over a registry import stopgap. Online validation
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. 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.
1. Explain what the plugin is and does in plain terms first.
2. Explain how it works only when that is not obvious.
3. Explain setup when needed: settings, accounts, tokens, providers, external 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.
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, catalogs of theme-token fields,
or lists of absent features. Keep only what helps someone choose the plugin.
If your plugin is an unchanged imported record, you can propose an author-written overview
to replace the registry stopgap. Do not add import credit to your own `OVERVIEW.md`.
See [Your listing page](https://paseo.sh/docs/plugins/publishing.md#your-listing-page).
## Typecheck and manage
When editing a plugin, typecheck its source before install or reload: