Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion .github/workflows/custom-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ jobs:
plugin_dir = Path('my-plugin')
manifest = json.loads((plugin_dir / 'plugin.json').read_text(encoding='utf-8'))
assert manifest['id'] == plugin_dir.name
for key in ('script', 'styles', 'routes'):
for key in ('script', 'screen', 'styles', 'routes'):
assert (plugin_dir / manifest[key]).is_file(), f'missing {key}: {manifest[key]}'
assert (plugin_dir / manifest['settings']['html']).is_file()
# The Host mounts every screen from `/api/plugins/<id>/screen.html`,
# ignoring the manifest path, and creates the container as
# `plugin-<id>`; a mismatch leaves a dead sidebar entry.
assert manifest['screen'] == 'screen.html', manifest['screen']
assert manifest['nav']['screen'] == f"plugin-{manifest['id']}", manifest['nav']
for section in ('settings', 'diagnostics'):
for entry in (manifest.get(section, {}) or {}).get('server_files', []) or []:
parts = entry.split('/')
Expand All @@ -42,3 +47,7 @@ jobs:
- name: Reject print calls in routes
run: |
! grep -nE '(^|[^A-Za-z0-9_])(print|traceback\.print_exc)[[:space:]]*\(' my-plugin/routes.py
- name: Install test dependencies
run: pip install -r requirements-dev.txt
- name: Run plugin tests
run: pytest -q
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Python bytecode and pytest caches
__pycache__/
*.py[cod]
.pytest_cache/
17 changes: 15 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@ A reference implementation demonstrating the fundamental feedBack plugin contrac

| File | Purpose |
|---|---|
| `my-plugin/plugin.json` | Manifest — id `my-plugin`, nav entry, script/styles/settings/routes, type, icon, minHost |
| `my-plugin/plugin.json` | Manifest — id `my-plugin`, nav entry, script/screen/styles/settings/routes, type, icon, minHost |
| `my-plugin/screen.html` | Client screen markup — mounted into `plugin-my-plugin` by the Host, holds the counter and settings hooks `screen.js` queries |
| `my-plugin/screen.js` | Client screen logic — finds its root by `plugin-<id>`, wires up an example counter, loads persisted settings |
| `my-plugin/settings.html` | Settings panel markup, grouped under `settings.category` |
| `my-plugin/routes.py` | FastAPI `setup(app, context)` — registers GET/POST `/api/plugins/my-plugin/settings` with schema validation and persistence |
| `my-plugin/assets/plugin.css` | Styling scoped to the plugin |
| `my-plugin/README.md` | Per-plugin documentation for whoever copies this template |
| `tests/` | pytest suite for the manifest/screen contract and the settings routes |
| `requirements-dev.txt` | Test-only dependencies (`fastapi`, `httpx`, `pytest`) |

## Actual API shape

Expand All @@ -28,12 +31,13 @@ A reference implementation demonstrating the fundamental feedBack plugin contrac

- **plugin.json id must match directory name** — `my-plugin` == `my-plugin/`
- **Nav screen** uses `"nav": { "label": "My Plugin", "screen": "plugin-my-plugin" }`
- **Screen markup** is declared with a top-level `screen` key and lives in `screen.html`; the Host creates the `plugin-<id>` container from that key and injects the markup, then runs `script`. The current Host hardcodes the fetch path (`/api/plugins/<id>/screen.html`) and ignores the manifest value, so the file must be named `screen.html`.
- **screen.js finds its own root** via `document.getElementById('plugin-<id>')` — the Host injects markup into that container
- **Settings persist server-side**, not in browser storage — always go through the plugin's own routes
- **routes.py exports `setup(app, context)`** — receives the FastAPI app and a context dict with `config_dir` and `log`
- **Configuration validation happens once in `setup()`**, before any route is registered, so a broken config fails plugin load loudly rather than failing silently on first request
- **API path** is `/api/plugins/<plugin_id>/<route>`
- **No build step** — vanilla JS, no npm
- **No build step** — vanilla JS, no npm; the Python tests run under `pytest` (see `requirements-dev.txt`)

## Known issues / code notes

Expand All @@ -43,6 +47,15 @@ A reference implementation demonstrating the fundamental feedBack plugin contrac

## Verification checklist

Automated (the same checks `custom-checks.yml` runs):

```bash
pip install -r requirements-dev.txt
pytest -q
```

Against a running Host with `my-plugin/` installed:

1. Plugin loads without server errors (`setup()` validates config before registering routes)
2. Nav entry "My Plugin" appears in sidebar
3. Clicking the counter button increments the on-screen counter
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `my-plugin/screen.html` and a top-level `screen` manifest key, so the example screen actually mounts — the Host only creates the `plugin-my-plugin` container when `screen` is declared.
- `tests/` and `requirements-dev.txt` — pytest coverage for the manifest/screen contract (declared files exist, the screen path the Host fetches, the nav container id) and the settings routes (GET defaults, valid-subset POST/persistence, invalid/unknown values, malformed JSON, and the 16 KiB body limit).
- `custom-checks.yml` now fails when the `screen` file or nav container reference is missing and runs `pytest`.
- `nav` field in `my-plugin/plugin.json` (`{ "label": "My Plugin", "screen": "plugin-my-plugin" }`) so the plugin registers a sidebar entry.
- `AGENTS.md` — reference guide for AI assistants/contributors covering the actual API shape, plugin conventions, known code notes, and a verification checklist.
- `CLAUDE.md` — agent guide covering the template-vs-plugin distinction, feedBack app context, and two corrections to `AGENTS.md` (the `screen`-key container gate, and the spec-vs-Host folder/id mismatch behavior).

### Changed

- `my-plugin/plugin.json` `type` is now `"utility"` instead of `"visualization"`, matching what the example actually is; the `visualization` type is reserved for plugins that implement the `setRenderer` note-highway contract.


<!-- Add entries under Added, Changed, Deprecated, Removed, Fixed, or Security as changes land. -->
45 changes: 23 additions & 22 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

## This repo is a template, not a plugin

**Nothing in `my-plugin/` runs as-is inside feedBack.** `my-plugin` is not
a real plugin id — it's a placeholder directory meant to be copied,
renamed, and rewritten. Until that happens:
`my-plugin` is not a real plugin id — it's a placeholder directory meant to
be copied, renamed, and rewritten. As shipped it does mount as a working
demo (the counter screen, settings panel, and settings routes all function),
so you can install it and watch it run before renaming. Treat every
identifier as a placeholder regardless:

- `my-plugin/plugin.json`'s `id: "my-plugin"` and the folder name
`my-plugin/` are meant to be changed together. The plugin-spec (see
Expand All @@ -20,12 +22,13 @@ renamed, and rewritten. Until that happens:
of the mechanics (client screen ↔ server-persisted settings ↔ namespaced
routes), not a feature worth keeping — replace it with whatever the real
plugin actually does.
- `plugin.json` declares `"type": "visualization"`, but nothing here
implements the `setRenderer` viz-renderer contract (no
`window.feedBackViz_<id>` factory, no `contextType`/`init`/`draw`/
`resize`/`destroy`). If the real plugin isn't a highway visualization,
drop or change `type`; if it is one, the renderer contract has to be
built from scratch — this template doesn't demonstrate it.
- `plugin.json` declares `"type": "utility"` to match what the example
actually is (a settings screen, not a note-highway renderer). The
`visualization` type is reserved for plugins implementing the
`setRenderer` contract (a `window.feedBackViz_<id>` factory with
`contextType`/`init`/`draw`/`resize`/`destroy`); this template doesn't
demonstrate that, so set `type` back to `visualization` only if your real
plugin builds that renderer from scratch.

If you're asked to "fix" or "improve" something in `my-plugin/` in the
abstract, without a rename already having happened, the most useful thing
Expand Down Expand Up @@ -77,19 +80,17 @@ Two corrections to it, both load-bearing enough to call out here rather
than silently fix in place:

- **The container-mount convention** ("screen.js finds its own root via
`document.getElementById('plugin-<id>')`") is incomplete: the Host only
creates that container when the manifest declares a top-level `screen`
key (its `has_screen` gate — `plugins/__init__.py`'s
`"has_screen": bool(manifest.get("screen"))`, consumed by
`static/js/plugin-loader.js`'s container-creation site). `script` is
injected independently of `has_screen`, so a script-only manifest still
runs `screen.js` against a DOM where that container was never created.
`my-plugin/plugin.json` in this template has **no** `screen` key (and
no `screen.html` file), so `plugin-my-plugin` is never created as
shipped, `screen.js`'s root resolves to `null`, and the checklist's
"clicking the counter button" step doesn't apply until a real plugin
adds both `"screen": "screen.html"` to its manifest **and** the file
itself.
`document.getElementById('plugin-<id>')`") is gated on the manifest
declaring a top-level `screen` key (the Host's `has_screen` gate —
`plugins/__init__.py`'s `"has_screen": bool(manifest.get("screen"))`,
consumed by `static/js/plugin-loader.js`'s container-creation site).
`script` is injected independently of `has_screen`, so a script-only
manifest runs `screen.js` against a DOM where that container was never
created. This template now declares `"screen": "screen.html"` and ships
the file, so `plugin-my-plugin` is created and the example mounts. Note
the current Host hardcodes the fetch path — it always requests
`/api/plugins/<id>/screen.html` and ignores the manifest value — so the
markup file has to be named `screen.html`.
- **The folder/id mismatch consequence** ("a mismatch is a silent skip at
plugin discovery") is spec language, not what the shipped
`get-flashbacks/feedBack` Host actually does — see the note in "This
Expand Down
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ A reference template for creating [feedBack plugins](https://github.com/got-feed
2. **Update the manifest:** Edit `your-plugin/plugin.json`:
- Change `"id"` to match your folder name (e.g., `"your-plugin"`)
- Update `name`, `version`, `description`, `type`, `icon`
- Update the hard-coded `PLUGIN_ID` in `screen.js`, `settings.html`, and `routes.py` (including the `nav.screen` value in `plugin.json`) so the screen lookup and `/api/plugins/<id>/...` routes follow the new id

3. **Customize the plugin:** Edit the files in your plugin directory:
- `screen.html` — Client screen markup (the counter and settings display)
- `screen.js` — Client-side screen logic
- `settings.html` — Settings panel markup and behavior
- `routes.py` — Server-side logic and persistence
Expand All @@ -38,13 +40,29 @@ A reference template for creating [feedBack plugins](https://github.com/got-feed

5. **Reload plugins:** Restart feedBack or use the plugin manager to reload. Your plugin should appear in the plugin list.

## Verify the example runs

The automated checks (the same ones `custom-checks.yml` runs in CI):

```bash
pip install -r requirements-dev.txt # test-only deps: fastapi, httpx, pytest
pytest -q
```

The suite covers the manifest/screen mount contract and the settings routes (GET defaults, POST/persistence, invalid and unknown values, malformed JSON, and the 16 KiB body limit).

To see the example live, install `my-plugin/` into feedBack (step 4 above), reload, and check the "My Plugin" sidebar entry:

1. Click **Increment** — the counter climbs.
2. Open the plugin's settings panel, change the color, click **Save Settings**, then reopen the screen — the "Active color" display reflects the saved value, served from `/api/plugins/my-plugin/settings`.

## What's included

### `my-plugin/`

A complete, working plugin that demonstrates:

- A **client screen** (`screen.js`) with lifecycle management
- A **client screen** (`screen.html` markup + `screen.js` logic) with lifecycle management
- **Settings persistence** (HTML form + server routes)
- **Server routes** (`routes.py`) for reading/writing settings
- **Styling** (`assets/plugin.css`) scoped to the plugin
Expand Down
22 changes: 21 additions & 1 deletion my-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ This template demonstrates a complete, working feedBack plugin that includes:

| Surface | File | Manifest key |
|---|---|---|
| Client screen markup | `screen.html` | `screen` |
| Client screen | `screen.js` | `script` |
| Styles | `assets/plugin.css` | `styles` |
| Settings panel | `settings.html` | `settings` |
Expand All @@ -25,6 +26,11 @@ Before anything else, choose a unique `id` for your plugin. The folder name **mu

- **Rename** the `my-plugin/` folder to your chosen name (e.g., `tuner`, `drum-highway-3d`).
- **Update** `plugin.json` to set `"id": "<your-new-id>"` to match the folder name.
- **Update the hard-coded id everywhere it appears** — the example hard-codes `my-plugin` in several places, and every one has to move to the new id or the screen container lookup and the `/api/plugins/<id>/...` routes will point at the old name:
- `plugin.json` → `"id"` (plus `name`, `description`, `icon` in the same file)
- `screen.js` → the `PLUGIN_ID` constant
- `settings.html` → the `PLUGIN_ID` constant
- `routes.py` → the `PLUGIN_ID` constant (it also derives the route namespace)
- Use **lowercase alphanumeric with `-` or `_` separators** (e.g., `^[a-z0-9][a-z0-9_-]*$`).

**Important:** The `id` is permanent. Changing it orphans users' saved settings and breaks derived names across the app. Choose it carefully and don't rename later.
Expand All @@ -41,7 +47,10 @@ Open `plugin.json` and customize:

### 3. Edit the screen

Update `screen.js` to implement your plugin's functionality. The script:
The screen has two halves:

- **`screen.html`** — the markup. The Host creates a container `<div id="plugin-<id>" class="screen">` and sets its innerHTML from this file, so it must exist and be named `screen.html` (the current Host hardcodes that fetch path and ignores the manifest `screen` value). The example markup exposes `data-role="increment-button"`, `data-role="counter"`, and `data-role="color-display"` hooks that `screen.js` wires up.
- **`screen.js`** — the behavior. Implement your plugin's functionality here. The script:

- **Runs on load:** The Host executes it once when the screen is first displayed.
- **Is self-executing:** You don't export a function for the Host to call. Set up everything you need when the script runs.
Expand Down Expand Up @@ -89,6 +98,17 @@ Customize `assets/plugin.css` for your plugin's visual appearance. Remember:
- **Don't mutate the app shell.** Use the Host's contribution registries to add to shared surfaces (spec §6.3).
- The Host applies these styles only while your screen is active, then unloads them.

## Testing the example

The template ships a pytest suite for the manifest/screen contract and the settings routes:

```bash
pip install -r requirements-dev.txt # from the template repo root
pytest -q
```

The tests discover the plugin by scanning for `plugin.json`, so they keep working after you rename the folder (keep them, they're yours).

## How settings work

1. **Client loads settings:** Call `GET /api/plugins/<id>/settings` when the settings panel or screen mount.
Expand Down
3 changes: 2 additions & 1 deletion my-plugin/plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
"name": "My Plugin",
"version": "0.1.0",
"description": "A feedBack plugin template with screen, settings, and server routes.",
"type": "visualization",
"type": "utility",
"nav": { "label": "My Plugin", "screen": "plugin-my-plugin" },
"script": "screen.js",
"scriptType": "classic",
"screen": "screen.html",
"styles": "assets/plugin.css",
"settings": {
"html": "settings.html",
Expand Down
30 changes: 30 additions & 0 deletions my-plugin/screen.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!--
SPDX-License-Identifier: AGPL-3.0-or-later
Client screen markup for my-plugin.

The Host creates the container <div id="plugin-my-plugin" class="screen"> and
sets its innerHTML from this file, then runs screen.js (spec §6.1). screen.js
finds the container by id and wires up the counter and the persisted-settings
display from the data-role hooks below.

Keep everything scoped under the plugin's own class (.my-plugin) so nothing
leaks into the rest of the app (spec §6.3, §6.4).
-->
<div class="my-plugin">
<h2>My Plugin</h2>

<section>
<h3>Counter</h3>
<p class="my-plugin__row">
<button type="button" data-role="increment-button">Increment</button>
<span data-role="counter">0</span>
</p>
</section>

<section>
<h3>Persisted settings</h3>
<p class="my-plugin__row">
Active color: <span data-role="color-display">indigo</span>
</p>
</section>
</div>
5 changes: 5 additions & 0 deletions requirements-dev.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Test-only dependencies. The Host provides FastAPI at runtime; these are for
# running `pytest` against the example's routes locally and in CI.
fastapi>=0.110
httpx>=0.27
pytest>=8
59 changes: 59 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Shared fixtures for the template's plugin-contract tests.

The suite discovers the plugin directory by scanning for a ``plugin.json``
instead of hard-coding ``my-plugin``, so a copy that has already been renamed
still runs. Everything else — the manifest, the routes path, the route prefix —
is derived from the manifest.
"""

import importlib.util
import json
import logging
from pathlib import Path

import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient

REPO_ROOT = Path(__file__).resolve().parent.parent


def _plugin_dirs() -> list[Path]:
return sorted(p.parent for p in REPO_ROOT.glob("*/plugin.json"))


@pytest.fixture(scope="session")
def plugin_dir() -> Path:
dirs = _plugin_dirs()
assert len(dirs) == 1, f"expected exactly one plugin directory, found {dirs}"
return dirs[0]


@pytest.fixture(scope="session")
def manifest(plugin_dir: Path) -> dict:
return json.loads((plugin_dir / "plugin.json").read_text(encoding="utf-8"))


@pytest.fixture()
def routes_module(plugin_dir: Path, manifest: dict):
spec = importlib.util.spec_from_file_location(
"plugin_under_test_routes", plugin_dir / manifest["routes"]
)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module


@pytest.fixture()
def client(plugin_dir: Path, manifest: dict, routes_module, tmp_path: Path) -> TestClient:
app = FastAPI()
routes_module.setup(
app, {"config_dir": tmp_path, "log": logging.getLogger("plugin-tests")}
)
return TestClient(app)


@pytest.fixture()
def settings_url(manifest: dict) -> str:
return f"/api/plugins/{manifest['id']}/settings"
Loading
Loading