feat: stabilize Pi Web plugin API

This commit is contained in:
Federico Jaramillo Martinez
2026-05-17 22:46:44 +02:00
parent c77c47c1d5
commit 30995797ea
24 changed files with 764 additions and 496 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Document and tighten the Pi Web plugin API around explicit `piWeb.plugins` metadata, versioned browser modules, AI-oriented local plugin development, website plugin docs on pi-web.dev, feedback guidance, and resilient discovery that skips invalid plugins without hiding valid ones.
+25 -1
View File
@@ -6,7 +6,7 @@
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
[![Pi Coding Agent](https://img.shields.io/badge/Pi-Coding%20Agent-6f42c1)](https://github.com/earendil-works/pi/tree/main/packages/coding-agent)
Website: <https://jmfederico.github.io/pi-web/>
Website: <https://pi-web.dev/>
![Pi Web](docs/assets/pi-web-banner.png)
@@ -97,6 +97,30 @@ Pi Web keeps its own state intentionally small:
- Sessions and chat history: Pi's default JSONL session storage
- Active session runtimes and WebSockets: memory in the session daemon
## Plugins
Pi Web production installs can load trusted local UI plugins without rebuilding Pi Web. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata. They do not run in the session daemon and are not sandboxed.
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` plugin is the canonical real example.
A useful prompt for AI agents:
```text
Build a Pi Web plugin for this project. Goal: <describe the UI behavior>.
Before coding, read https://pi-web.dev/plugins.html and https://pi-web.dev/plugins.md.
Create it under ~/.pi-web/plugins/<plugin-id> using the documented Pi Web v1 plugin API.
Validate with /pi-web-plugins/manifest.json and explain reload/debug steps.
Do not modify Pi Web itself.
```
Reload the browser tab after adding or editing a plugin. If `PI_WEB_DATA_DIR` is set, use `$PI_WEB_DATA_DIR/plugins` instead of `~/.pi-web/plugins`. Check discovery with:
```bash
curl http://127.0.0.1:8504/pi-web-plugins/manifest.json
```
See the full [Plugin API](docs/plugins.md) for contribution types, package metadata, and troubleshooting.
## Install
Recommended install uses npm plus systemd user services:
+2
View File
@@ -30,6 +30,7 @@
<div class="nav-links">
<a href="/remote-first.html">Remote-first</a>
<a href="/install.html">Install</a>
<a href="/plugins.html">Plugins</a>
<a href="/faq.html">FAQ</a>
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="Pi Web on GitHub">
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
@@ -75,6 +76,7 @@
<span>Pi Web · remote control for persistent Pi Coding Agent sessions.</span>
<div class="footer-links">
<a href="/install.html">Install</a>
<a href="/plugins.html">Plugins</a>
<a href="/faq.html">FAQ</a>
<a href="https://www.npmjs.com/package/@jmfederico/pi-web">npm</a>
</div>
+13
View File
@@ -29,6 +29,7 @@
<div class="nav-links">
<a href="remote-first.html">Remote-first</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html" aria-current="page">FAQ</a>
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="Pi Web on GitHub">
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
@@ -71,6 +72,7 @@
<a href="#cannot-open">I cannot open the web UI</a>
<a href="#public-internet">Can I expose this publicly?</a>
<a href="#laptop-or-server">Laptop or server?</a>
<a href="#plugins">Can I use local plugins?</a>
<a href="#sessions-stop">Sessions stop unexpectedly</a>
<a href="#logs">Where are logs?</a>
</aside>
@@ -201,6 +203,16 @@
</p>
</article>
<article id="plugins" class="faq-item">
<h2>Can I use local plugins with production Pi Web?</h2>
<p>
Yes. Put trusted browser-side plugins in <code>~/.pi-web/plugins/&lt;plugin-id&gt;/</code>, or symlink a
development folder there. Reload the browser tab after edits. If <code>PI_WEB_DATA_DIR</code> is set, use
<code>$PI_WEB_DATA_DIR/plugins</code> instead.
</p>
<p><a href="plugins.html">Read the plugin guide →</a></p>
</article>
<article id="sessions-stop" class="faq-item">
<h2>Sessions stop unexpectedly</h2>
<p>
@@ -241,6 +253,7 @@
<a href="./">Home</a>
<a href="remote-first.html">Remote-first</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="https://github.com/jmfederico/pi-web/issues">Issues</a>
</div>
</div>
+10
View File
@@ -36,6 +36,7 @@
<div class="nav-links">
<a href="remote-first.html">Remote-first</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="Pi Web on GitHub">
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
@@ -236,6 +237,14 @@
</p>
<a href="faq.html#is-this-linux-only">Read compatibility notes →</a>
</article>
<article class="doc-card">
<h3>Local UI plugins?</h3>
<p>
Ask AI agents to add trusted local UI plugins from <code>~/.pi-web/plugins</code> without rebuilding or
restarting the session daemon.
</p>
<a href="plugins.html">Build a plugin →</a>
</article>
<article class="doc-card">
<h3>Node or tools not found?</h3>
<p>
@@ -254,6 +263,7 @@
<div class="footer-links">
<a href="remote-first.html">Remote-first</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="https://www.npmjs.com/package/@jmfederico/pi-web">npm</a>
</div>
+2
View File
@@ -29,6 +29,7 @@
<div class="nav-links">
<a href="remote-first.html">Remote-first</a>
<a href="install.html" aria-current="page">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="Pi Web on GitHub">
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
@@ -279,6 +280,7 @@
<div class="footer-links">
<a href="./">Home</a>
<a href="remote-first.html">Remote-first</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
</div>
+241
View File
@@ -0,0 +1,241 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Pi Web plugins</title>
<meta name="description" content="Use and develop trusted local Pi Web UI plugins." />
<meta property="og:title" content="Pi Web plugins" />
<meta property="og:image" content="assets/pi-web-banner.png" />
<link rel="icon" href="data:," />
<script>
(() => {
const theme = window.localStorage.getItem("pi-web-theme");
if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<header class="site-header">
<nav class="container nav" aria-label="Main navigation">
<a class="brand" href="./" aria-label="Pi Web home">Pi Web</a>
<div class="nav-links">
<a href="remote-first.html">Remote-first</a>
<a href="install.html">Install</a>
<a href="plugins.html" aria-current="page">Plugins</a>
<a href="faq.html">FAQ</a>
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="Pi Web on GitHub">
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
<path
fill="currentColor"
d="M8 0C3.58 0 0 3.67 0 8.2c0 3.63 2.29 6.7 5.47 7.79.4.08.55-.18.55-.4 0-.2-.01-.85-.01-1.55-2.01.38-2.53-.5-2.69-.96-.09-.24-.48-.96-.82-1.16-.28-.16-.68-.56-.01-.57.63-.01 1.08.59 1.23.84.72 1.24 1.87.89 2.33.68.07-.53.28-.89.51-1.09-1.78-.21-3.64-.91-3.64-4.04 0-.89.31-1.62.82-2.2-.08-.2-.36-1.03.08-2.16 0 0 .67-.22 2.2.84A7.4 7.4 0 0 1 8 3.94c.68 0 1.36.09 2 .28 1.53-1.06 2.2-.84 2.2-.84.44 1.13.16 1.96.08 2.16.51.58.82 1.31.82 2.2 0 3.14-1.87 3.83-3.65 4.04.29.26.54.76.54 1.53 0 1.1-.01 1.99-.01 2.27 0 .22.15.49.55.4A8.12 8.12 0 0 0 16 8.2C16 3.67 12.42 0 8 0Z"
/>
</svg>
<span>GitHub</span>
</a>
<button class="theme-toggle" type="button" data-theme-toggle aria-label="Toggle light and dark theme">
<span data-theme-icon aria-hidden="true"></span>
<span data-theme-label>Theme</span>
</button>
</div>
</nav>
</header>
<main>
<section class="page-hero">
<div class="container">
<p class="eyebrow"><span class="pulse"></span> Plugin development</p>
<h1>Customize Pi Web with local UI plugins.</h1>
<p>
Plugins are trusted browser-side ES modules. They can add actions, workspace panels, and compact workspace
labels to the Pi Web UI.
</p>
</div>
</section>
<section class="section compact">
<div class="container doc-layout">
<aside class="toc" aria-label="Plugin page contents">
<strong>On this page</strong>
<a href="#extend">What can be extended</a>
<a href="#ask-ai">What to ask AI to build</a>
<a href="#example">Canonical example</a>
<a href="#production">Production usage</a>
<a href="#agent-docs">AI-friendly docs</a>
<a href="#develop">Develop and debug</a>
<a href="#trust">Trust model</a>
</aside>
<div class="doc-content">
<section id="extend">
<h2>What can be extended</h2>
<p>Plugins can add small, focused UI extensions to Pi Web:</p>
<ul>
<li><strong>Action palette commands</strong> for custom workflows.</li>
<li><strong>Workspace tools and panels</strong> next to Files, Git, and Terminal.</li>
<li><strong>Workspace labels</strong> in the workspace list, header, and status bar.</li>
<li><strong>Static assets</strong> served from the plugin folder.</li>
<li><strong>Browser-side integrations</strong> using Pi Web HTTP/WebSocket APIs.</li>
</ul>
<p>
Plugins cannot extend the session daemon or add server-side hooks. They run in the browser UI only.
</p>
<div class="callout">
The plugin API is intentionally limited and actively developed. Feedback is appreciated: if an extension
point is missing, unclear, or awkward, please
<a href="https://github.com/jmfederico/pi-web/issues">open a GitHub issue</a>.
</div>
</section>
<section id="ask-ai">
<h2>What to ask AI to build</h2>
<p>
You do not need to hand-code plugins. Give an AI agent a clear goal, the workspace/project facts it
should use, and the Pi Web plugin rules.
</p>
<ul>
<li>Show a workspace badge from a repo file, environment file, branch name, container, or dev URL.</li>
<li>Add a workspace panel that summarizes project-specific health, commands, links, or runbooks.</li>
<li>Add action-palette commands for common project workflows or prompts.</li>
<li>Add links to local services, preview deployments, dashboards, CI, logs, or docs.</li>
</ul>
<div class="code-card">
<div class="copy-row">
<strong>Prompt: create a plugin</strong>
<button class="copy-button" data-copy="#plugin-create-prompt">Copy</button>
</div>
<pre id="plugin-create-prompt"><code>Build a Pi Web plugin for this project.
Goal: &lt;describe the UI behavior&gt;.
Before coding, read the Pi Web plugin docs:
https://pi-web.dev/plugins.html
Full API reference:
https://pi-web.dev/plugins.md
Create it as a local plugin under ~/.pi-web/plugins/&lt;plugin-id&gt;.
Use the appropriate extension points from the docs.
Validate by checking /pi-web-plugins/manifest.json and explain how to reload/debug it.
Do not modify Pi Web itself.</code></pre>
</div>
<div class="code-card">
<div class="copy-row">
<strong>Prompt: improve a plugin</strong>
<button class="copy-button" data-copy="#plugin-improve-prompt">Copy</button>
</div>
<pre id="plugin-improve-prompt"><code>Improve the Pi Web plugin at &lt;path&gt;.
Before coding, read the Pi Web plugin docs:
https://pi-web.dev/plugins.html
Full API reference:
https://pi-web.dev/plugins.md
Keep the plugin compatible with the documented v1 API.
After editing, check the manifest endpoint and browser-console failure cases.</code></pre>
</div>
</section>
<section id="example">
<h2>Canonical example</h2>
<p>
Pi Web ships a real bundled <strong>Info</strong> plugin. It is the reference example because it uses all
current contribution types: one action, one workspace label, and one workspace panel.
</p>
<ul>
<li><code>pi-web-plugins/info/package.json</code> shows the required metadata shape.</li>
<li><code>pi-web-plugins/info/pi-web-plugin.js</code> shows the browser module shape.</li>
</ul>
<p>
Read it on GitHub:
<a href="https://github.com/jmfederico/pi-web/tree/main/pi-web-plugins/info">pi-web-plugins/info</a>.
If you copy it, choose a new plugin id so it does not conflict with the bundled <code>info</code> plugin.
</p>
</section>
<section id="production">
<h2>Production usage</h2>
<p>
Local plugins work with the production npm/systemd install. Put each plugin under
<code>~/.pi-web/plugins/&lt;plugin-id&gt;/</code>, or symlink it there while developing. No Pi Web rebuild or
session-daemon restart is required.
</p>
<div class="callout warning">
If <code>PI_WEB_DATA_DIR</code> is set, use <code>$PI_WEB_DATA_DIR/plugins</code> instead of
<code>~/.pi-web/plugins</code>.
</div>
<div class="code-card">
<div class="copy-row">
<strong>Install a local plugin folder</strong>
<button class="copy-button" data-copy="#plugin-symlink">Copy</button>
</div>
<pre id="plugin-symlink"><code><span class="prompt">$</span> mkdir -p ~/.pi-web/plugins
<span class="prompt">$</span> ln -s /path/to/plugin-folder ~/.pi-web/plugins/plugin-id</code></pre>
</div>
</section>
<section id="agent-docs">
<h2>AI-friendly docs</h2>
<p>
The full plugin reference is available as Markdown for AI agents to read, fetch, and follow:
<a href="plugins.md">plugins.md</a>.
</p>
<p>
If you want to understand the API yourself, ask an agent to read that file and explain the relevant
extension points for the plugin you want. The Markdown reference also marks which context fields are
stable and which Pi Web internals should be avoided unless necessary.
</p>
</section>
<section id="develop">
<h2>Develop and debug</h2>
<ul>
<li>Keep the source folder outside <code>~/.pi-web/plugins</code> and symlink it in.</li>
<li>After edits, reload or hard-reload the browser tab.</li>
<li>Check discovery with the manifest endpoint.</li>
<li>Check the browser console if a plugin fails to import or activate.</li>
</ul>
<div class="code-card">
<div class="copy-row">
<strong>Check discovered plugins</strong>
<button class="copy-button" data-copy="#plugin-manifest">Copy</button>
</div>
<pre id="plugin-manifest"><code><span class="prompt">$</span> curl http://127.0.0.1:8504/pi-web-plugins/manifest.json</code></pre>
</div>
<p>
For all contribution types and package metadata, read the
<a href="plugins.md">Plugin API reference</a>.
</p>
</section>
<section id="trust">
<h2>Trust model</h2>
<p>
Plugins are not sandboxed. They run as JavaScript in the browser, can call Pi Web APIs available to the
current session, and can render arbitrary UI. Install only plugins you trust.
</p>
<p>
Plugins do not run inside the session daemon and do not provide server-side hooks.
</p>
</section>
</div>
</div>
</section>
</main>
<footer class="site-footer">
<div class="container footer-inner">
<span>Pi Web plugin docs</span>
<div class="footer-links">
<a href="./">Home</a>
<a href="remote-first.html">Remote-first</a>
<a href="install.html">Install</a>
<a href="faq.html">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
</div>
</div>
</footer>
<script src="site.js"></script>
</body>
</html>
+182 -280
View File
@@ -1,12 +1,12 @@
# Pi Web plugin API
Pi Web plugins are trusted browser-side ES modules that extend the Pi Web UI. They are intended to be simple enough that an LLM can create or modify them directly.
Pi Web plugins are trusted browser-side ES modules that extend the Pi Web UI. They are intended for personal, team, and project-local customization, and simple enough for an LLM to create or modify directly.
Plugins can currently:
- add actions to the action palette;
- add action-palette commands;
- add workspace tools/panels next to Files, Git, and Terminal;
- add compact items to workspace labels in the workspace list, panel header, and status bar;
- add compact workspace-label items in the workspace list, panel header, and status bar;
- call browser APIs and Pi Web HTTP/WebSocket APIs available to the current browser session;
- serve their own static assets from the plugin directory.
@@ -22,76 +22,97 @@ Plugins run as JavaScript in the browser app. Treat them as trusted code:
- they can render arbitrary Lit templates/custom elements in plugin contribution areas;
- they should not be installed from untrusted sources.
This is for personal, team, and project-local customization, not a sandboxed third-party marketplace.
## What to ask AI to build
## Quick start: local plugin
Humans should not need to hand-code plugins. Give an AI agent a concrete UI goal and ask it to create or modify a local plugin.
Create a folder with a `package.json` and a browser module:
Good plugin requests:
```bash
mkdir -p /srv/dev/my-pi-web-plugin
cat > /srv/dev/my-pi-web-plugin/package.json <<'JSON'
{
"private": true,
"piWeb": {
"id": "my-plugin",
"plugin": "pi-web-plugin.js"
}
}
JSON
cat > /srv/dev/my-pi-web-plugin/pi-web-plugin.js <<'JS'
const { html } = globalThis.piWebPluginApi;
- "Show a workspace badge with the dev server URL from `.env`."
- "Add a workspace panel with links to logs, dashboards, and local services for this repo."
- "Add an action-palette command that starts a standard code-review prompt."
- "Show whether the current workspace is a git worktree, main checkout, staging env, or feature branch."
- "Add a compact status badge based on a project health file or command output saved in the repo."
export default {
id: "my-plugin",
name: "My Plugin",
activate: () => ({
actions: [
{
id: "workspace.show-path",
title: "Show Current Workspace Path",
group: "My Plugin",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
window.alert(context.state.selectedWorkspace?.path ?? "No workspace selected");
},
},
],
workspacePanels: [
{
id: "workspace.info",
title: "Info",
order: 100,
render: ({ workspace }) => html`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p class="muted">${workspace.label}</p>
<p class="muted">${workspace.path}</p>
</section>
`,
},
],
workspaceLabelContributions: [
{
id: "workspace.kind",
order: 10,
items: ({ workspace }) => ({
type: "text",
text: workspace.isGitRepo ? "git" : "folder",
title: workspace.path,
}),
},
],
}),
};
JS
Copy-paste prompt for creating a plugin:
```text
Build a Pi Web plugin for this project.
Goal: <describe the UI behavior>.
Before coding, read the Pi Web plugin docs:
https://pi-web.dev/plugins.html
Full API reference:
https://pi-web.dev/plugins.md
Create it as a local plugin under ~/.pi-web/plugins/<plugin-id>.
Use the appropriate extension points from the docs.
Validate by checking /pi-web-plugins/manifest.json and explain how to reload/debug it.
Do not modify Pi Web itself.
```
Symlink it into Pi Web's local plugin directory:
Copy-paste prompt for modifying a plugin:
```text
Improve the Pi Web plugin at <path>.
Before coding, read the Pi Web plugin docs:
https://pi-web.dev/plugins.html
Full API reference:
https://pi-web.dev/plugins.md
Keep the plugin compatible with the documented v1 API.
After editing, check the manifest endpoint and browser-console failure cases.
```
## Canonical example: bundled Info plugin
Pi Web ships a real bundled `info` plugin. Use it as the reference example because it exercises all current contribution types: an action, a workspace label, and a workspace panel.
Files:
```text
pi-web-plugins/info/package.json
pi-web-plugins/info/pi-web-plugin.js
```
Package metadata:
```json
{
"name": "@pi-web/info-plugin",
"private": true,
"piWeb": {
"plugins": [
{ "id": "info", "module": "pi-web-plugin.js" }
]
}
}
```
Module shape excerpt:
```js
export default {
apiVersion: 1,
name: "Info Plugin",
activate: ({ html }) => ({
contributions: {
actions: [/* action definitions */],
workspaceLabels: [/* compact label definitions */],
workspacePanels: [/* panel definitions using html */],
},
}),
};
```
When copying the Info plugin, choose a new plugin id so it does not conflict with the bundled `info` plugin.
## Local plugin usage
This works with the production npm/systemd install. Pi Web discovers plugins from `~/.pi-web/plugins/<plugin-package>/` on the web/API side; no Pi Web rebuild or session-daemon restart is required. If `PI_WEB_DATA_DIR` is set, use `$PI_WEB_DATA_DIR/plugins` instead.
Symlink a plugin folder into Pi Web's local plugin directory:
```bash
mkdir -p ~/.pi-web/plugins
ln -s /srv/dev/my-pi-web-plugin ~/.pi-web/plugins/my-plugin
ln -s /path/to/plugin-folder ~/.pi-web/plugins/plugin-id
```
Reload the Pi Web browser tab. Pi Web serves plugin modules with an mtime-based `?v=` cache buster. After editing a plugin, hard reload the browser if you do not see changes.
@@ -103,46 +124,31 @@ Pi Web builds `/pi-web-plugins/manifest.json` from these sources:
1. Bundled plugins in the Pi Web package:
```text
pi-web-plugins/<plugin-id>/
pi-web-plugins/<plugin-package>/
```
2. User-local plugins:
```text
~/.pi-web/plugins/<plugin-id>/
~/.pi-web/plugins/<plugin-package>/
```
Entries may be real directories or symlinks. This is the recommended development workflow.
3. Installed Pi packages that expose Pi Web plugin metadata. Pi packages may be user or project scoped.
Plugin directory names and plugin ids should match:
Plugin package directory names and plugin ids must be valid identifiers:
```text
^[a-z][a-z0-9.-]*$
```
### `package.json` metadata
A plugin directory is normally configured with top-level `piWeb` metadata:
A package can expose one or more Pi Web plugin modules. There is exactly one supported `package.json` metadata shape:
```json
{
"private": true,
"piWeb": {
"id": "my-plugin",
"plugin": "pi-web-plugin.js"
}
}
```
For multiple plugin entries in one package, use `piWeb.plugins`:
```json
{
"private": true,
"piWeb": {
"id": "my-package",
"plugins": [
{ "id": "review", "module": "dist/review.js" },
{ "id": "dashboard", "module": "dist/dashboard.js" }
@@ -151,33 +157,14 @@ For multiple plugin entries in one package, use `piWeb.plugins`:
}
```
`piWeb.plugins` may also be an array of module paths:
Rules:
```json
{
"piWeb": {
"id": "my-package",
"plugins": ["dist/review.js", "dist/dashboard.js"]
}
}
```
Pi packages may nest the same metadata under `pi.piWeb`:
```json
{
"pi": {
"piWeb": {
"id": "my-plugin",
"plugin": "pi-web-plugin.js"
}
}
}
```
If a local plugin directory has no `package.json`, Pi Web falls back to `pi-web-plugin.js` in that directory.
Entry module paths must be safe relative paths inside the plugin root. Pi Web ignores empty, absolute, or `..` paths.
- `piWeb.plugins` must be an array of objects.
- Each entry must have an explicit `id` and `module`.
- `id` must match `^[a-z][a-z0-9.-]*$`.
- `module` must be a safe relative path inside the plugin package root.
- Duplicate plugin ids are not auto-renamed; later duplicates are skipped.
- Legacy shortcuts such as `piWeb.plugin`, string entries in `piWeb.plugins`, `piWeb.id` fallback ids, and no-`package.json` fallbacks are not supported.
### Manifest and assets
@@ -206,21 +193,25 @@ A plugin can fetch its own static assets with URLs under:
Pi Web prevents asset path traversal outside the plugin root. JavaScript, JSON, CSS, and HTML get appropriate content types; other files are served as octet-stream.
If two discovered plugins use the same id, the first keeps the id and later ones are renamed to `<id>.2`, `<id>.3`, and so on. Avoid relying on this; prefer unique ids.
## Plugin module shape
The entry module must default-export a `PiWebPlugin` object:
The entry module must default-export a plugin object:
```ts
interface PiWebPlugin {
id: string;
apiVersion: 1;
name: string;
activate: (context: PluginActivationContext) => PluginContributions;
activate: (context: PluginActivationContext) => PluginActivationResult;
}
interface PluginActivationContext {
apiVersion: 1;
pluginId: string;
html: typeof import("lit").html;
}
interface PluginActivationResult {
contributions: PluginContributions;
}
```
@@ -228,25 +219,21 @@ Example:
```js
export default {
id: "my-plugin",
apiVersion: 1,
name: "My Plugin",
activate: ({ apiVersion }) => ({
actions: [],
workspacePanels: [],
workspaceLabelContributions: [],
activate: ({ pluginId, html }) => ({
contributions: {
actions: [],
workspacePanels: [],
workspaceLabels: [],
},
}),
};
```
`activate()` is called once when the UI loads the plugin. Keep it cheap: define contributions there, but move expensive or async work into actions, custom elements, or explicit user interactions.
Plugin ids and contribution ids must match:
```text
^[a-z][a-z0-9.-]*$
```
Contribution ids are local to the plugin. Pi Web qualifies them internally as:
The plugin id comes from `package.json`, not from the JavaScript module. Contribution ids are local to the plugin and Pi Web qualifies them internally as:
```text
<plugin-id>:<local-contribution-id>
@@ -254,28 +241,15 @@ Contribution ids are local to the plugin. Pi Web qualifies them internally as:
For example, plugin `info` with action `workspace.show-path` becomes `info:workspace.show-path`.
## Browser global API
External plugins can access this global before they export their plugin:
```js
const { apiVersion, html } = globalThis.piWebPluginApi;
```
- `apiVersion`: currently `1`.
- `html`: Lit's `html` template tag. Use this instead of importing `lit` from an external plugin unless you bundle your own dependencies.
Pi Web does not currently expose typed helper clients to plugins. Use `fetch()` for Pi Web HTTP APIs and browser `WebSocket` for websocket endpoints if needed.
## Contributions
`activate()` returns any combination of these contribution arrays:
`activate()` returns a `contributions` object with any combination of these arrays:
```ts
interface PluginContributions {
actions?: PluginAction[];
workspacePanels?: WorkspacePanelContribution[];
workspaceLabelContributions?: WorkspaceLabelContribution[];
workspaceLabels?: WorkspaceLabelContribution[];
}
```
@@ -308,20 +282,24 @@ interface PluginAction {
description?: string;
shortcut?: string;
group?: string;
enabled?: boolean | ((context: PluginRuntimeContext) => boolean);
enabled?: (context: PluginRuntimeContext) => boolean;
run: (context: PluginRuntimeContext) => void | Promise<void>;
}
```
Runtime context:
Stable runtime context fields:
```ts
interface PluginRuntimeContext {
state: AppState;
state: {
selectedWorkspace?: Workspace;
selectedSession?: unknown;
};
openActionPalette: () => void;
focusPrompt: () => void;
addProject: () => void | Promise<void>;
selectMainView: (view: "navigation" | "chat" | QualifiedContributionId) => void;
configureAuth: () => void | Promise<void>;
logoutAuth: () => void | Promise<void>;
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
refreshFiles: () => void | Promise<void>;
refreshGit: () => void | Promise<void>;
@@ -334,6 +312,8 @@ interface PluginRuntimeContext {
Notes:
- `state` is a snapshot of current UI state when actions are built.
- Only `state.selectedWorkspace` and `state.selectedSession` are documented as stable for plugin authors.
- Other `state` fields may exist at runtime, but they are Pi Web internals and can change quickly.
- `enabled` is evaluated when the action palette asks for actions.
- `selectWorkspaceTool()` expects a qualified panel id such as `my-plugin:workspace.info`.
- `shortcut` is displayed/handled the same way app actions are; choose shortcuts carefully to avoid conflicts.
@@ -343,24 +323,17 @@ Notes:
Workspace panels add tools next to built-in workspace tools. They render inside the workspace side panel on desktop and as mobile tabs on smaller screens.
```js
const { html } = globalThis.piWebPluginApi;
workspacePanels: [
{
id: "workspace.info",
title: "Info",
order: 100,
visible: (workspace) => workspace.isGitRepo,
badge: ({ gitStatus }) => gitStatus?.files.length,
render: ({ workspace, gitStatus, onRefreshGit }) => html`
<section class="toolbar">
<strong>Info</strong>
<button @click=${onRefreshGit}>Refresh git</button>
</section>
visible: ({ workspace }) => workspace.isGitRepo,
render: ({ workspace }) => html`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p class="muted">${workspace.label}</p>
<p class="muted">${workspace.path}</p>
<p class="muted">Changed files: ${gitStatus?.files.length ?? 0}</p>
</section>
`,
},
@@ -374,35 +347,13 @@ interface WorkspacePanelContribution {
id: string;
title: string;
order?: number;
visible?: (workspace: Workspace) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
render: (context: WorkspacePanelContext) => TemplateResult;
visible?: (context: { workspace: Workspace }) => boolean;
badge?: (context: { workspace: Workspace }) => string | number | TemplateResult | undefined;
render: (context: { workspace: Workspace }) => TemplateResult;
}
```
Panel context:
```ts
interface WorkspacePanelContext {
workspace: Workspace;
fileTree: FileTreeEntry[];
expandedDirs: Record<string, FileTreeEntry[]>;
selectedFilePath: string | undefined;
selectedFileContent: FileContentResponse | undefined;
fileTreeStale: boolean;
gitStatus: GitStatusResponse | undefined;
selectedDiffPath: string | undefined;
selectedDiff: GitDiffResponse | undefined;
selectedStagedDiff: GitDiffResponse | undefined;
gitStale: boolean;
activeTerminalCount: number;
onRefreshFiles: () => void;
onExpandDir: (path: string) => void;
onSelectFile: (path: string) => void;
onRefreshGit: () => void;
onSelectDiff: (path: string) => void;
}
```
Only `workspace` is documented as stable for panel callbacks. Other fields may exist at runtime, but they are Pi Web internals and can change quickly. If a panel needs file, git, or session data, prefer explicit `fetch()` calls and keep them isolated.
Useful workspace shape:
@@ -421,28 +372,25 @@ interface Workspace {
Use existing classes such as `toolbar`, `viewer`, `empty`, and `muted` for panel content when possible. Do not assume a panel owns the whole page; keep layout contained.
### Workspace label contributions
### Workspace labels
Workspace label contributions add compact inline metadata wherever Pi Web displays a workspace label: workspace list, workspace panel header, and status bar.
Workspace labels add compact inline metadata wherever Pi Web displays a workspace label: workspace list, workspace panel header, and status bar.
Use them for short facts like project environment, local URL, branch status, container name, or health state.
```js
workspaceLabelContributions: [
workspaceLabels: [
{
id: "dev-url",
order: 10,
visible: ({ workspace, state }) => {
const project = state.projects.find((project) => project.id === workspace.projectId);
return project?.path === "/srv/dev/my-app";
},
items: () => ({
visible: ({ workspace }) => workspace.path.includes("my-app"),
items: () => [{
type: "link",
text: "web:5173",
href: "http://localhost:5173",
title: "Open dev server",
target: "_blank",
}),
}],
},
]
```
@@ -454,16 +402,17 @@ interface WorkspaceLabelContribution {
id: string;
order?: number;
visible?: (context: WorkspaceLabelContext) => boolean;
items: (context: WorkspaceLabelContext) => WorkspaceLabelItem | WorkspaceLabelItem[] | undefined;
items: (context: WorkspaceLabelContext) => WorkspaceLabelItem[];
}
interface WorkspaceLabelContext {
workspace: Workspace;
state: AppState;
}
```
Items are sorted by `order` and then id. Return `undefined` to render nothing.
Only `workspace` is documented as stable for label callbacks. Other fields may exist at runtime, but they are Pi Web internals and can change quickly.
Items are sorted by `order` and then id. Return an empty array to render nothing.
#### Text items
@@ -490,8 +439,6 @@ Pi Web renders the anchor and adds safe defaults such as `rel="noopener noreferr
Use render items when a label contribution needs custom UI, async data, or caching. Render items should stay compact and inline.
```js
const { html } = globalThis.piWebPluginApi;
class MyWorkspaceBadge extends HTMLElement {
set workspace(value) {
this._workspace = value;
@@ -504,19 +451,21 @@ if (!customElements.get("my-workspace-badge")) {
}
export default {
id: "my-plugin",
apiVersion: 1,
name: "My Plugin",
activate: () => ({
workspaceLabelContributions: [
{
id: "badge",
order: 10,
items: ({ workspace }) => ({
type: "render",
render: () => html`<my-workspace-badge .workspace=${workspace}></my-workspace-badge>`,
}),
},
],
activate: ({ html }) => ({
contributions: {
workspaceLabels: [
{
id: "badge",
order: 10,
items: ({ workspace }) => [{
type: "render",
render: () => html`<my-workspace-badge .workspace=${workspace}></my-workspace-badge>`,
}],
},
],
},
}),
};
```
@@ -538,26 +487,15 @@ async function readWorkspaceFile(workspace, path) {
}
```
Response shape:
```ts
interface FileContentResponse {
path: string;
language?: string;
encoding: "utf8";
size: number;
modifiedAt: string;
content: string;
truncated: boolean;
binary: boolean;
}
```
The file response includes fields such as `path`, `content`, `truncated`, and `binary`, but endpoint response shapes are private Pi Web implementation details for now and can change between releases.
Be careful with sensitive files such as `.env`: plugins are trusted browser code, and file contents are exposed to the plugin.
## Other useful Pi Web APIs
Plugins may call any endpoint available to the browser. Common read endpoints:
Plugins may call any endpoint available to the browser, but these HTTP endpoints are considered private Pi Web implementation APIs for now. They can change quickly between releases. Prefer plugin runtime context helpers when they cover the interaction, and keep any direct HTTP usage small and isolated.
Common read endpoints:
```text
GET /api/projects
@@ -582,7 +520,7 @@ POST /api/sessions/:id/archive
POST /api/sessions/:id/restore
```
Prefer runtime context helpers (`startSession`, `stopActiveWork`, `refreshFiles`, `refreshGit`, etc.) when they cover the interaction. Use direct HTTP calls for plugin-specific data or behavior.
Prefer runtime context helpers (`startSession`, `stopActiveWork`, `refreshFiles`, `refreshGit`, etc.) when they cover the interaction. Use direct HTTP calls only for plugin-specific data or behavior, and expect to update them as Pi Web evolves.
## Async data and caching
@@ -594,62 +532,25 @@ Pi Web does not provide a plugin cache/invalidation framework. Keep host callbac
- dedupe fetches and avoid unbounded polling;
- clean up intervals/event listeners in custom elements' `disconnectedCallback()`.
Example cache pattern:
## Agent implementation checklist
```js
const cache = new Map();
const loading = new Set();
If you are an AI agent building or editing a Pi Web plugin, follow this checklist:
class DevUrlBadge extends HTMLElement {
set workspace(value) {
this.workspaceValue = value;
void this.load();
}
async load() {
const workspace = this.workspaceValue;
if (!workspace) return;
if (cache.has(workspace.id)) {
this.renderUrl(cache.get(workspace.id));
return;
}
if (loading.has(workspace.id)) return;
loading.add(workspace.id);
try {
const file = await readWorkspaceFile(workspace, "docker/development.local.env");
const url = parseEnv(file.content).BASE_URL;
cache.set(workspace.id, url);
this.renderUrl(url);
} finally {
loading.delete(workspace.id);
}
}
renderUrl(url) {
this.textContent = url ?? "";
}
}
```
## LLM checklist for building a plugin
When asking an LLM to build a Pi Web plugin, give it this checklist:
1. Create a plugin folder with `package.json` and `pi-web-plugin.js`.
2. Use top-level `piWeb` metadata with `id` and `plugin`, or `piWeb.plugins` for multiple modules.
3. Default-export `{ id, name, activate }` from the module.
4. Use ids matching `^[a-z][a-z0-9.-]*$`.
5. Use `globalThis.piWebPluginApi.html` for Lit templates.
6. Keep `activate()` synchronous and cheap; return contribution definitions only.
7. Add actions for command-palette operations.
8. Add workspace panels for larger workspace UI.
9. Add workspace label contributions for compact inline metadata.
10. Use structured text/link label items when possible; use render items/custom elements for async or cached UI.
11. Use `fetch()` against Pi Web APIs for workspace files, git state, sessions, or plugin-specific behavior not provided by runtime context helpers.
12. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional.
13. After local edits, hard reload the browser and check the console for plugin errors.
1. Create or update a plugin folder with `package.json` and `pi-web-plugin.js`.
2. Use the single supported package metadata shape: `piWeb.plugins` array with `{ id, module }` entries.
3. Default-export `{ apiVersion: 1, name, activate }` from the module.
4. Return `{ contributions: { actions, workspacePanels, workspaceLabels } }` from `activate()`.
5. Use ids matching `^[a-z][a-z0-9.-]*$`.
6. Use the activation context's `html` function for Lit templates.
7. Keep `activate()` synchronous and cheap; return contribution definitions only.
8. Add actions for command-palette operations.
9. Add workspace panels for larger workspace UI.
10. Add workspace labels for compact inline metadata.
11. Return arrays from workspace label `items()`; return an empty array to render nothing.
12. Use stable context fields first; only `workspace`, `state.selectedWorkspace`, and `state.selectedSession` are documented as stable.
13. Use `fetch()` against Pi Web APIs only for plugin-specific behavior not provided by runtime context helpers, and isolate those calls because HTTP endpoints are private for now.
14. Treat plugins as trusted code and avoid reading or displaying secrets unless intentional.
15. After local edits, tell the user to hard reload the browser and check the console for plugin errors.
## Troubleshooting
@@ -669,10 +570,11 @@ Common issues:
- invalid plugin id or contribution id;
- missing default export;
- missing `name` or `activate` function;
- missing `package.json` or incorrect `piWeb.plugin` / `piWeb.plugins` metadata;
- missing `apiVersion: 1`, `name`, or `activate` function;
- missing `package.json` or incorrect `piWeb.plugins` metadata;
- legacy shortcuts such as `piWeb.plugin`, string plugin entries, or no-`package.json` fallback;
- duplicate plugin ids; later duplicates are skipped rather than renamed;
- entry module path points outside the plugin root or file does not exist;
- browser cache not refreshed after editing;
- plugin directory is not under `~/.pi-web/plugins` or symlinked there;
- duplicate plugin ids cause later plugins to be renamed in the manifest;
- plugin throws during module import, `activate()`, `visible()`, `enabled()`, `items()`, or `render()`; check the browser console.
+2
View File
@@ -32,6 +32,7 @@
<div class="nav-links">
<a href="remote-first.html" aria-current="page">Remote-first</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a class="github-link" href="https://github.com/jmfederico/pi-web" aria-label="Pi Web on GitHub">
<svg class="github-icon" viewBox="0 0 16 16" aria-hidden="true">
@@ -159,6 +160,7 @@
<div class="footer-links">
<a href="./">Home</a>
<a href="install.html">Install</a>
<a href="plugins.html">Plugins</a>
<a href="faq.html">FAQ</a>
<a href="https://github.com/jmfederico/pi-web">GitHub</a>
</div>
+26 -5
View File
@@ -619,7 +619,7 @@ code .comment,
.doc-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 18px;
}
@@ -1002,12 +1002,19 @@ html[data-theme="light"] .comment {
}
.nav {
align-items: flex-start;
position: relative;
align-items: stretch;
flex-direction: column;
gap: 12px;
padding: 14px 0;
}
.brand {
align-self: flex-start;
min-height: 38px;
max-width: calc(100% - 88px);
}
.nav-links {
flex-wrap: nowrap;
overflow-x: auto;
@@ -1015,26 +1022,40 @@ html[data-theme="light"] .comment {
scrollbar-width: none;
}
.nav-links a:not(.github-link) {
flex: 0 0 auto;
}
.nav-links::-webkit-scrollbar {
display: none;
}
.nav-links a,
.theme-toggle {
.nav-links .theme-toggle {
min-height: 38px;
padding: 0 10px;
}
.nav-links .github-link,
.theme-toggle {
.nav-links .theme-toggle {
position: absolute;
top: 14px;
justify-content: center;
width: 38px;
min-width: 38px;
padding: 0;
}
.nav-links .github-link {
right: 44px;
}
.nav-links .theme-toggle {
right: 0;
}
.nav-links .github-link span,
.theme-toggle [data-theme-label] {
.nav-links .theme-toggle [data-theme-label] {
display: none;
}
+2 -1
View File
@@ -17,6 +17,7 @@
"LICENSE",
"extensions",
"pi-web-plugins",
"docs/plugins.md",
"docs/assets"
],
"scripts": {
@@ -91,7 +92,7 @@
"bugs": {
"url": "https://github.com/jmfederico/pi-web/issues"
},
"homepage": "https://jmfederico.github.io/pi-web/",
"homepage": "https://pi-web.dev/",
"packageManager": "[email protected]",
"peerDependencies": {
"@earendil-works/pi-coding-agent": ">=0.74.0 <1",
+3 -2
View File
@@ -2,7 +2,8 @@
"name": "@pi-web/info-plugin",
"private": true,
"piWeb": {
"id": "info",
"plugin": "pi-web-plugin.js"
"plugins": [
{ "id": "info", "module": "pi-web-plugin.js" }
]
}
}
+37 -37
View File
@@ -1,42 +1,42 @@
const { html } = globalThis.piWebPluginApi;
export default {
id: "info",
apiVersion: 1,
name: "Info Plugin",
activate: () => ({
actions: [
{
id: "workspace.show-path",
title: "Show Current Workspace Path",
group: "Info",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
const path = context.state.selectedWorkspace?.path ?? "No workspace selected";
window.alert(path);
activate: ({ html }) => ({
contributions: {
actions: [
{
id: "workspace.show-path",
title: "Show Current Workspace Path",
group: "Info",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
const path = context.state.selectedWorkspace?.path ?? "No workspace selected";
window.alert(path);
},
},
},
],
workspaceLabelContributions: [
{
id: "workspace.path-label",
order: 100,
items: (context) => ({ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }),
},
],
workspacePanels: [
{
id: "workspace.info",
title: "Info",
order: 100,
render: (context) => html`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p><strong>Workspace</strong></p>
<p class="muted">${context.workspace.label}</p>
<p class="muted">${context.workspace.path}</p>
</section>
`,
},
],
],
workspaceLabels: [
{
id: "workspace.path-label",
order: 100,
items: (context) => [{ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }],
},
],
workspacePanels: [
{
id: "workspace.info",
title: "Info",
order: 100,
render: (context) => html`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p><strong>Workspace</strong></p>
<p class="muted">${context.workspace.label}</p>
<p class="muted">${context.workspace.path}</p>
</section>
`,
},
],
},
}),
};
+3 -3
View File
@@ -282,7 +282,7 @@ export class PiWebApp extends LitElement {
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
const workspace = this.state.selectedWorkspace;
return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.(workspace) ?? true));
return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.({ workspace, state: this.state }) ?? true));
}
private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
@@ -322,7 +322,7 @@ export class PiWebApp extends LitElement {
private async loadExternalPlugins(): Promise<void> {
try {
for (const plugin of await loadExternalPlugins()) this.plugins.register(plugin);
for (const registration of await loadExternalPlugins()) this.plugins.register(registration);
this.requestUpdate();
} catch (error) {
console.warn("Failed to load external Pi Web plugins", error);
@@ -436,7 +436,7 @@ export class PiWebApp extends LitElement {
function createPluginRegistry(): PluginRegistry {
const registry = new PluginRegistry();
registry.register(corePlugin);
registry.register({ id: "core", plugin: corePlugin });
return registry;
}
+1 -1
View File
@@ -34,7 +34,7 @@ export class WorkspacePanel extends LitElement {
override render() {
const workspace = this.workspace;
if (workspace === undefined) return html`<section class="empty">Select a workspace.</section>`;
const visiblePanels = this.panels.filter((panel) => panel.visible?.(workspace) ?? true);
const visiblePanels = this.panels;
const selectedPanel = visiblePanels.find((panel) => panel.id === this.tool) ?? visiblePanels[0];
const context = this.createPanelContext(workspace);
return html`
+5 -3
View File
@@ -3,10 +3,12 @@ import { createCoreActions } from "./actions";
import { createCoreWorkspacePanels } from "./panels";
export const corePlugin: PiWebPlugin = {
id: "core",
apiVersion: 1,
name: "Pi Web Core",
activate: () => ({
actions: createCoreActions(),
workspacePanels: createCoreWorkspacePanels(),
contributions: {
actions: createCoreActions(),
workspacePanels: createCoreWorkspacePanels(),
},
}),
};
+1 -1
View File
@@ -14,7 +14,7 @@ export function createCoreWorkspacePanels(): WorkspacePanelContribution[] {
id: "workspace.git",
title: "Git",
order: 20,
visible: (workspace) => workspace.isGitRepo,
visible: ({ workspace }) => workspace.isGitRepo,
render: renderGit,
},
{
+36 -34
View File
@@ -2,42 +2,44 @@ import { html } from "lit";
import type { PiWebPlugin } from "../types";
export const examplePlugin: PiWebPlugin = {
id: "example",
apiVersion: 1,
name: "Example Plugin",
activate: () => ({
actions: [
{
id: "workspace.show-path",
title: "Show Current Workspace Path",
group: "Example",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
const path = context.state.selectedWorkspace?.path ?? "No workspace selected";
window.alert(path);
contributions: {
actions: [
{
id: "workspace.show-path",
title: "Show Current Workspace Path",
group: "Example",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
const path = context.state.selectedWorkspace?.path ?? "No workspace selected";
window.alert(path);
},
},
},
],
workspaceLabelContributions: [
{
id: "workspace.example-label",
order: 100,
items: (context) => ({ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }),
},
],
workspacePanels: [
{
id: "workspace.info",
title: "Info",
order: 100,
render: (context) => html`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p><strong>Workspace</strong></p>
<p class="muted">${context.workspace.label}</p>
<p class="muted">${context.workspace.path}</p>
</section>
`,
},
],
],
workspaceLabels: [
{
id: "workspace.example-label",
order: 100,
items: (context) => [{ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }],
},
],
workspacePanels: [
{
id: "workspace.info",
title: "Info",
order: 100,
render: (context) => html`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p><strong>Workspace</strong></p>
<p class="muted">${context.workspace.label}</p>
<p class="muted">${context.workspace.path}</p>
</section>
`,
},
],
},
}),
};
+10 -21
View File
@@ -1,7 +1,7 @@
import { html } from "lit";
import type { PiWebPlugin } from "./types";
import type { PiWebPlugin, PiWebPluginRegistration } from "./types";
interface PluginManifestEntry {
id: string;
module: string;
}
@@ -9,33 +9,22 @@ interface PluginManifest {
plugins: PluginManifestEntry[];
}
declare global {
interface Window {
piWebPluginApi?: {
apiVersion: 1;
html: typeof html;
};
}
}
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json"): Promise<PiWebPlugin[]> {
window.piWebPluginApi = { apiVersion: 1, html };
export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifest.json"): Promise<PiWebPluginRegistration[]> {
const manifest = await fetchPluginManifest(manifestUrl);
if (manifest === undefined) return [];
const plugins: PiWebPlugin[] = [];
const registrations: PiWebPluginRegistration[] = [];
for (const entry of manifest.plugins) {
try {
const moduleUrl = new URL(entry.module, new URL(manifestUrl, window.location.href)).toString();
const module: unknown = await import(/* @vite-ignore */ moduleUrl);
const plugin = parsePluginModule(module, moduleUrl);
if (plugin !== undefined) plugins.push(plugin);
registrations.push({ id: entry.id, plugin });
} catch (error) {
console.warn(`Failed to load Pi Web plugin ${entry.module}`, error);
}
}
return plugins;
return registrations;
}
async function fetchPluginManifest(manifestUrl: string): Promise<PluginManifest | undefined> {
@@ -49,13 +38,13 @@ function parseManifest(value: unknown): PluginManifest {
if (!isRecord(value) || !Array.isArray(value["plugins"])) throw new Error("Invalid plugin manifest");
return {
plugins: value["plugins"].map((entry) => {
if (!isRecord(entry) || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry");
return { module: entry["module"] };
if (!isRecord(entry) || typeof entry["id"] !== "string" || entry["id"] === "" || typeof entry["module"] !== "string" || entry["module"] === "") throw new Error("Invalid plugin manifest entry");
return { id: entry["id"], module: entry["module"] };
}),
};
}
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin | undefined {
function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin {
if (!isRecord(module)) throw new Error(`Plugin module ${moduleUrl} did not export an object`);
const plugin = module["default"];
if (!isPiWebPlugin(plugin)) throw new Error(`Plugin module ${moduleUrl} default export is not a PiWebPlugin`);
@@ -63,7 +52,7 @@ function parsePluginModule(module: unknown, moduleUrl: string): PiWebPlugin | un
}
function isPiWebPlugin(value: unknown): value is PiWebPlugin {
return isRecord(value) && typeof value["id"] === "string" && typeof value["name"] === "string" && typeof value["activate"] === "function";
return isRecord(value) && value["apiVersion"] === 1 && typeof value["name"] === "string" && typeof value["activate"] === "function";
}
function isRecord(value: unknown): value is Record<string, unknown> {
+28 -18
View File
@@ -28,7 +28,7 @@ function createContext(statePatch: Partial<AppState> = {}) {
describe("PluginRegistry", () => {
it("namespaces contribution ids with the owning plugin id", () => {
const registry = new PluginRegistry();
registry.register(corePlugin);
registry.register({ id: "core", plugin: corePlugin });
expect(registry.getActions(createContext().context).some((action) => action.id === "core:actions.show")).toBe(true);
expect(registry.getWorkspacePanels().map((panel) => panel.id)).toEqual(["core:workspace.files", "core:workspace.git", "core:workspace.terminal"]);
@@ -40,20 +40,25 @@ describe("PluginRegistry", () => {
expect(() => {
registry.register({
id: "example",
name: "Example",
activate: () => ({
actions: [
{ id: "duplicate", title: "One", run: () => undefined },
{ id: "duplicate", title: "Two", run: () => undefined },
],
}),
plugin: {
apiVersion: 1,
name: "Example",
activate: () => ({
contributions: {
actions: [
{ id: "duplicate", title: "One", run: () => undefined },
{ id: "duplicate", title: "Two", run: () => undefined },
],
},
}),
},
});
}).toThrow("Duplicate contribution id: example:duplicate");
});
it("evaluates core action enablement against runtime state", () => {
const registry = new PluginRegistry();
registry.register(corePlugin);
registry.register({ id: "core", plugin: corePlugin });
const inactive = registry.getActions(createContext().context);
const active = registry.getActions(createContext({ selectedWorkspace: testWorkspace() }).context);
@@ -64,7 +69,7 @@ describe("PluginRegistry", () => {
it("routes refresh current to the active core workspace panel", () => {
const registry = new PluginRegistry();
registry.register(corePlugin);
registry.register({ id: "core", plugin: corePlugin });
const { context, calls } = createContext({
selectedWorkspace: testWorkspace(),
workspaceTool: "core:workspace.git",
@@ -81,14 +86,19 @@ describe("PluginRegistry", () => {
const workspace = testWorkspace();
registry.register({
id: "example",
name: "Example",
activate: () => ({
workspaceLabelContributions: [
{ id: "last", order: 20, items: () => ({ type: "text", text: "last" }) },
{ id: "hidden", order: 5, visible: () => false, items: () => ({ type: "text", text: "hidden" }) },
{ id: "first", order: 10, items: () => [{ type: "link", text: "web", href: "http://localhost:5173" }] },
],
}),
plugin: {
apiVersion: 1,
name: "Example",
activate: () => ({
contributions: {
workspaceLabels: [
{ id: "last", order: 20, items: () => [{ type: "text", text: "last" }] },
{ id: "hidden", order: 5, visible: () => false, items: () => [{ type: "text", text: "hidden" }] },
{ id: "first", order: 10, items: () => [{ type: "link", text: "web", href: "http://localhost:5173" }] },
],
},
}),
},
});
expect(registry.getWorkspaceLabelItems(initialAppState(), workspace)).toEqual([
+18 -15
View File
@@ -1,6 +1,7 @@
import { html } from "lit";
import type { AppState } from "../appState";
import type { Workspace } from "../api";
import type { PiWebPlugin, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types";
import type { PiWebPluginRegistration, PluginAction, PluginRuntimeContext, QualifiedContributionId, QualifiedPluginAction, QualifiedWorkspaceLabelContribution, QualifiedWorkspacePanelContribution, WorkspaceLabelContribution, WorkspaceLabelItem, WorkspacePanelContribution } from "./types";
const idPattern = /^[a-z][a-z0-9.-]*$/u;
const localIdPattern = /^[a-z][a-z0-9.-]*$/u;
@@ -14,24 +15,28 @@ type RegisteredPluginAction = Omit<PluginAction, "id"> & {
export class PluginRegistry {
private readonly actions: RegisteredPluginAction[] = [];
private readonly workspacePanels: QualifiedWorkspacePanelContribution[] = [];
private readonly workspaceLabelContributions: QualifiedWorkspaceLabelContribution[] = [];
private readonly workspaceLabels: QualifiedWorkspaceLabelContribution[] = [];
private readonly pluginIds = new Set<string>();
private readonly contributionIds = new Set<QualifiedContributionId>();
register(plugin: PiWebPlugin): void {
this.validatePluginId(plugin.id);
if (this.pluginIds.has(plugin.id)) throw new Error(`Duplicate plugin id: ${plugin.id}`);
this.pluginIds.add(plugin.id);
register(registration: PiWebPluginRegistration): void {
const { id, plugin } = registration;
this.validatePluginId(id);
if (this.pluginIds.has(id)) throw new Error(`Duplicate plugin id: ${id}`);
this.pluginIds.add(id);
const contributions = plugin.activate({ apiVersion: 1 });
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(plugin.id, action));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(plugin.id, panel));
for (const contribution of contributions.workspaceLabelContributions ?? []) this.workspaceLabelContributions.push(this.qualifyWorkspaceLabelContribution(plugin.id, contribution));
const apiVersion: unknown = plugin.apiVersion;
if (apiVersion !== 1) throw new Error(`Unsupported plugin API version for ${id}: ${String(apiVersion)}`);
const result = plugin.activate({ apiVersion: 1, pluginId: id, html });
const contributions = result.contributions;
for (const action of contributions.actions ?? []) this.actions.push(this.qualifyAction(id, action));
for (const panel of contributions.workspacePanels ?? []) this.workspacePanels.push(this.qualifyWorkspacePanel(id, panel));
for (const contribution of contributions.workspaceLabels ?? []) this.workspaceLabels.push(this.qualifyWorkspaceLabelContribution(id, contribution));
}
getActions(context: PluginRuntimeContext): QualifiedPluginAction[] {
return this.actions.map((action) => {
const enabled = typeof action.enabled === "function" ? action.enabled(context) : action.enabled;
const enabled = action.enabled?.(context);
const qualified: QualifiedPluginAction = {
id: action.id,
pluginId: action.pluginId,
@@ -53,13 +58,11 @@ export class PluginRegistry {
getWorkspaceLabelItems(state: AppState, workspace: Workspace): WorkspaceLabelItem[] {
const context = { state, workspace };
return [...this.workspaceLabelContributions]
return [...this.workspaceLabels]
.sort((left, right) => (left.order ?? 1000) - (right.order ?? 1000) || left.id.localeCompare(right.id))
.flatMap((contribution) => {
if (contribution.visible?.(context) === false) return [];
const items = contribution.items(context);
if (items === undefined) return [];
return Array.isArray(items) ? items : [items];
return contribution.items(context);
});
}
+23 -6
View File
@@ -6,21 +6,33 @@ import type { AppState } from "../appState";
export type PluginId = string;
export type LocalContributionId = string;
export type QualifiedContributionId = `${PluginId}:${LocalContributionId}`;
export type HtmlTemplateTag = (strings: TemplateStringsArray, ...values: unknown[]) => TemplateResult;
export interface PiWebPluginRegistration {
id: PluginId;
plugin: PiWebPlugin;
}
export interface PiWebPlugin {
id: PluginId;
apiVersion: 1;
name: string;
activate: (context: PluginActivationContext) => PluginContributions;
activate: (context: PluginActivationContext) => PluginActivationResult;
}
export interface PluginActivationContext {
apiVersion: 1;
pluginId: PluginId;
html: HtmlTemplateTag;
}
export interface PluginActivationResult {
contributions: PluginContributions;
}
export interface PluginContributions {
actions?: PluginAction[];
workspacePanels?: WorkspacePanelContribution[];
workspaceLabelContributions?: WorkspaceLabelContribution[];
workspaceLabels?: WorkspaceLabelContribution[];
}
export interface PluginRuntimeContext {
@@ -45,7 +57,7 @@ export interface PluginAction {
description?: string;
shortcut?: string;
group?: string;
enabled?: boolean | ((context: PluginRuntimeContext) => boolean);
enabled?: (context: PluginRuntimeContext) => boolean;
run: (context: PluginRuntimeContext) => void | Promise<void>;
}
@@ -54,6 +66,11 @@ export interface QualifiedPluginAction extends AppAction {
localId: LocalContributionId;
}
export interface WorkspacePanelVisibilityContext {
workspace: Workspace;
state: AppState;
}
export interface WorkspacePanelContext {
workspace: Workspace;
fileTree: FileTreeEntry[];
@@ -79,7 +96,7 @@ export interface WorkspacePanelContribution {
id: LocalContributionId;
title: string;
order?: number;
visible?: (workspace: Workspace) => boolean;
visible?: (context: WorkspacePanelVisibilityContext) => boolean;
badge?: (context: WorkspacePanelContext) => string | number | TemplateResult | undefined;
render: (context: WorkspacePanelContext) => TemplateResult;
}
@@ -120,7 +137,7 @@ export interface WorkspaceLabelContribution {
id: LocalContributionId;
order?: number;
visible?: (context: WorkspaceLabelContext) => boolean;
items: (context: WorkspaceLabelContext) => WorkspaceLabelItem | WorkspaceLabelItem[] | undefined;
items: (context: WorkspaceLabelContext) => WorkspaceLabelItem[];
}
export interface QualifiedWorkspaceLabelContribution extends WorkspaceLabelContribution {
+44 -14
View File
@@ -18,8 +18,8 @@ describe("PiWebPluginService", () => {
it("discovers local plugins and serves assets", async () => {
const pluginDir = join(tempDir, "plugins", "info");
await writePlugin(pluginDir, {
packageJson: { piWeb: { id: "info", plugin: "pi-web-plugin.js" } },
files: { "pi-web-plugin.js": "export default { id: 'info' };" },
packageJson: { piWeb: { plugins: [{ id: "info", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Info', activate: () => ({ contributions: {} }) };" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
@@ -38,8 +38,8 @@ describe("PiWebPluginService", () => {
it("discovers Pi package plugins through an injected package provider", async () => {
const packageDir = join(tempDir, "pkg");
await writePlugin(packageDir, {
packageJson: { pi: { piWeb: { plugins: [{ id: "review", module: "dist/review.js" }] } } },
files: { "dist/review.js": "export default { id: 'review' };" },
packageJson: { piWeb: { plugins: [{ id: "review", module: "dist/review.js" }] } },
files: { "dist/review.js": "export default { apiVersion: 1, name: 'Review', activate: () => ({ contributions: {} }) };" },
});
const packageProvider: PiPackageProvider = {
listPackages: () => [{ source: "npm:@acme/review", scope: "user", installedPath: packageDir }],
@@ -57,8 +57,8 @@ describe("PiWebPluginService", () => {
it("discovers local plugins through symlinks for development", async () => {
const pluginDir = join(tempDir, "dev-plugin");
await writePlugin(pluginDir, {
packageJson: { piWeb: { id: "dev", plugin: "pi-web-plugin.js" } },
files: { "pi-web-plugin.js": "export default { id: 'dev' };" },
packageJson: { piWeb: { plugins: [{ id: "dev", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default { apiVersion: 1, name: 'Dev', activate: () => ({ contributions: {} }) };" },
});
await mkdir(join(tempDir, "plugins"), { recursive: true });
await symlink(pluginDir, join(tempDir, "plugins", "dev"), "dir");
@@ -71,27 +71,58 @@ describe("PiWebPluginService", () => {
await expect(service.readAsset("dev", "pi-web-plugin.js")).resolves.toBeDefined();
});
it("keeps duplicate plugin ids addressable", async () => {
it("skips duplicate plugin ids", async () => {
await writePlugin(join(tempDir, "plugins", "one"), {
packageJson: { piWeb: { id: "duplicate", plugin: "pi-web-plugin.js" } },
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePlugin(join(tempDir, "plugins", "two"), {
packageJson: { piWeb: { id: "duplicate", plugin: "pi-web-plugin.js" } },
packageJson: { piWeb: { plugins: [{ id: "duplicate", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const manifest = await service.manifest();
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate", "duplicate.2"]);
await expect(service.readAsset("duplicate.2", "pi-web-plugin.js")).resolves.toBeDefined();
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["duplicate"]);
});
it("rejects unsafe plugin entries and asset traversal", async () => {
it("skips legacy metadata shortcuts and unsafe module paths", async () => {
const legacyRoot = join(tempDir, "legacy-root");
await writePlugin(join(legacyRoot, "legacy"), {
packageJson: { piWeb: { id: "legacy", plugin: "pi-web-plugin.js" } },
files: { "pi-web-plugin.js": "export default {};" },
});
const unsafeRoot = join(tempDir, "unsafe-root");
await writePlugin(join(unsafeRoot, "unsafe"), {
packageJson: { piWeb: { plugins: [{ id: "unsafe", module: "../escape.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await expect(new PiWebPluginService({ roots: [{ path: legacyRoot, source: "test", scope: "local" }], packageProvider: false }).manifest()).resolves.toEqual({ plugins: [] });
await expect(new PiWebPluginService({ roots: [{ path: unsafeRoot, source: "test", scope: "local" }], packageProvider: false }).manifest()).resolves.toEqual({ plugins: [] });
});
it("continues discovering valid plugins when another local plugin is invalid", async () => {
await writePlugin(join(tempDir, "plugins", "valid"), {
packageJson: { piWeb: { plugins: [{ id: "valid", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writePlugin(join(tempDir, "plugins", "legacy"), {
packageJson: { piWeb: { id: "legacy", plugin: "pi-web-plugin.js" } },
files: { "pi-web-plugin.js": "export default {};" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const manifest = await service.manifest();
expect(manifest.plugins.map((plugin) => plugin.id)).toEqual(["valid"]);
});
it("rejects unsafe asset traversal", async () => {
const pluginDir = join(tempDir, "plugins", "safe");
await writePlugin(pluginDir, {
packageJson: { piWeb: { id: "safe", plugins: ["../escape.js", "pi-web-plugin.js"] } },
packageJson: { piWeb: { plugins: [{ id: "safe", module: "pi-web-plugin.js" }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
await writeFile(join(tempDir, "plugins", "escape.js"), "nope");
@@ -100,7 +131,6 @@ describe("PiWebPluginService", () => {
const manifest = await service.manifest();
expect(manifest.plugins).toHaveLength(1);
expect(manifest.plugins[0]?.module).toContain("pi-web-plugin.js");
await expect(service.readAsset("safe", "../escape.js")).resolves.toBeUndefined();
});
});
+45 -54
View File
@@ -1,12 +1,11 @@
import { existsSync } from "node:fs";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import { piWebDataDir } from "../config.js";
const pluginIdPattern = /^[a-z][a-z0-9.-]*$/u;
const defaultEntryFile = "pi-web-plugin.js";
export interface PiWebPluginManifest {
plugins: { id: string; module: string; source: string; scope: PiWebPluginScope }[];
@@ -48,13 +47,12 @@ interface LocalPluginRoot {
}
interface PiWebPackageConfig {
id?: string;
plugins: PiWebPluginEntry[];
}
interface PiWebPluginEntry {
id?: string;
path: string;
id: string;
module: string;
}
type ArraylessPluginRecord = Omit<PluginRecord, "source" | "scope">;
@@ -140,7 +138,11 @@ export class PiWebPluginService {
for (const configuredPackage of packageProvider.listPackages()) {
const root = configuredPackage.installedPath ?? packageProvider.getInstalledPath(configuredPackage.source, configuredPackage.scope);
if (root === undefined) continue;
plugins.push(...await discoverPackageRoot(root, configuredPackage));
try {
plugins.push(...await discoverPackageRoot(root, configuredPackage));
} catch (error) {
warnInvalidPlugin(configuredPackage.source, error);
}
}
return plugins;
}
@@ -163,93 +165,82 @@ async function discoverLocalRoot(root: LocalPluginRoot): Promise<PluginRecord[]>
const pluginRoot = join(root.path, entry.name);
const pluginStat = entry.isDirectory() ? undefined : entry.isSymbolicLink() ? await stat(pluginRoot).catch(() => undefined) : undefined;
if (!entry.isDirectory() && pluginStat?.isDirectory() !== true) continue;
plugins.push(...await discoverLocalPlugin(pluginRoot, entry.name, root));
try {
plugins.push(...await discoverLocalPlugin(pluginRoot, root));
} catch (error) {
warnInvalidPlugin(pluginRoot, error);
}
}
return plugins;
}
async function discoverLocalPlugin(root: string, fallbackId: string, localRoot: LocalPluginRoot): Promise<PluginRecord[]> {
const config = await readPiWebPackageConfig(root) ?? { plugins: [{ path: defaultEntryFile }] };
const plugins = await discoverPluginEntries(root, config, fallbackId);
async function discoverLocalPlugin(root: string, localRoot: LocalPluginRoot): Promise<PluginRecord[]> {
const config = await readPiWebPackageConfig(root);
if (config === undefined) return [];
const plugins = await discoverPluginEntries(root, config);
return plugins.map((plugin) => ({ ...plugin, source: localRoot.source, scope: localRoot.scope }));
}
async function discoverPackageRoot(root: string, configuredPackage: ConfiguredPiPackage): Promise<PluginRecord[]> {
const config = await readPiWebPackageConfig(root);
if (config === undefined) return [];
const fallbackId = sanitizePluginId(config.id ?? configuredPackage.source);
const plugins = await discoverPluginEntries(root, config, fallbackId);
const plugins = await discoverPluginEntries(root, config);
return plugins.map((plugin) => ({ ...plugin, source: configuredPackage.source, scope: configuredPackage.scope }));
}
async function discoverPluginEntries(root: string, config: PiWebPackageConfig, fallbackId: string): Promise<ArraylessPluginRecord[]> {
async function discoverPluginEntries(root: string, config: PiWebPackageConfig): Promise<ArraylessPluginRecord[]> {
const plugins: ArraylessPluginRecord[] = [];
for (const [index, entry] of config.plugins.entries()) {
if (!isSafeRelativePath(entry.path)) continue;
const entryPath = join(root, entry.path);
for (const entry of config.plugins) {
if (!isSafeRelativePath(entry.module)) throw new Error(`Unsafe Pi Web plugin module path for ${entry.id}: ${entry.module}`);
const entryPath = join(root, entry.module);
const entryStat = await stat(entryPath).catch(() => undefined);
if (entryStat?.isFile() !== true) continue;
const id = pluginEntryId(config, entry, fallbackId, index);
plugins.push({ id, root, entryFile: entry.path, version: String(Math.floor(entryStat.mtimeMs)) });
if (entryStat?.isFile() !== true) throw new Error(`Pi Web plugin module not found for ${entry.id}: ${entry.module}`);
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)) });
}
return plugins;
}
function pluginEntryId(config: PiWebPackageConfig, entry: PiWebPluginEntry, fallbackId: string, index: number): string {
if (entry.id !== undefined) return sanitizePluginId(entry.id);
if (config.id !== undefined && config.plugins.length === 1) return sanitizePluginId(config.id);
if (config.id !== undefined) return sanitizePluginId(`${config.id}.${basename(entry.path, ".js")}`);
if (config.plugins.length === 1) return sanitizePluginId(fallbackId);
return sanitizePluginId(`${fallbackId}.${String(index + 1)}`);
}
async function readPiWebPackageConfig(root: string): Promise<PiWebPackageConfig | undefined> {
const packagePath = join(root, "package.json");
const content = await readFile(packagePath, "utf8").catch(() => undefined);
if (content === undefined) return undefined;
const parsed: unknown = JSON.parse(content);
if (!isRecord(parsed)) return undefined;
const pi = parsed["pi"];
const piWeb = isRecord(parsed["piWeb"]) ? parsed["piWeb"] : isRecord(pi) && isRecord(pi["piWeb"]) ? pi["piWeb"] : undefined;
const piWeb = parsed["piWeb"];
if (!isRecord(piWeb)) return undefined;
const plugins = parsePluginEntries(piWeb);
const plugins = parsePluginEntries(piWeb, packagePath);
if (plugins.length === 0) return undefined;
return {
...(typeof piWeb["id"] === "string" ? { id: piWeb["id"] } : {}),
plugins,
};
return { plugins };
}
function parsePluginEntries(piWeb: Record<string, unknown>): PiWebPluginEntry[] {
const plugin = piWeb["plugin"];
if (typeof plugin === "string") return [{ path: plugin }];
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported Pi Web plugin metadata in ${packagePath}: use piWeb.plugins with { id, module } entries`);
const plugins = piWeb["plugins"];
if (!Array.isArray(plugins)) return [];
return plugins.flatMap((entry): PiWebPluginEntry[] => {
if (typeof entry === "string" && entry !== "") return [{ path: entry }];
if (!isRecord(entry) || typeof entry["module"] !== "string" || entry["module"] === "") return [];
return [{ path: entry["module"], ...(typeof entry["id"] === "string" ? { id: entry["id"] } : {}) }];
if (plugins === undefined) return [];
if (!Array.isArray(plugins)) throw new Error(`Pi Web plugins must be an array in ${packagePath}`);
return plugins.map((entry, index): PiWebPluginEntry => {
if (!isRecord(entry)) throw new Error(`Pi Web plugin entry ${String(index + 1)} must be an object in ${packagePath}`);
const id = entry["id"];
const module = entry["module"];
if (typeof id !== "string" || !pluginIdPattern.test(id)) throw new Error(`Invalid Pi Web plugin id in ${packagePath}: ${String(id)}`);
if (typeof module !== "string" || module === "") throw new Error(`Invalid Pi Web plugin module for ${id} in ${packagePath}`);
return { id, module };
});
}
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
if (!records.has(plugin.id)) {
records.set(plugin.id, plugin);
if (records.has(plugin.id)) {
warnInvalidPlugin(plugin.source, `Duplicate Pi Web plugin id: ${plugin.id}`);
return;
}
for (let index = 2; ; index += 1) {
const id = sanitizePluginId(`${plugin.id}.${String(index)}`);
if (!records.has(id)) {
records.set(id, { ...plugin, id });
return;
}
}
records.set(plugin.id, plugin);
}
function sanitizePluginId(value: string): string {
const normalized = value.toLowerCase().replace(/^npm:/u, "").replace(/^git:/u, "").replace(/[^a-z0-9.-]+/gu, ".").replace(/^[^a-z]+/u, "").replace(/[.-]+$/u, "");
return pluginIdPattern.test(normalized) ? normalized : "plugin";
function warnInvalidPlugin(source: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Skipping Pi Web plugin from ${source}: ${message}`);
}
function isSafeRelativePath(path: string): boolean {