# Writing a plugin

A plugin is a folder with a `plugin.json` in it, dropped into `plugins/`
next to the app. It is loaded at startup — installing one never runs code
until the next boot, and a plugin that throws is marked broken and skipped
rather than taking the app down with it.

Plugins are local code running at the app's trust level. There is no
sandbox. That is a deliberate, documented limit: curation is the safety
model, not isolation.

```
plugins/my-pack/
  plugin.json
  plugin.py          # omitted for data-only plugins (themes)
  assets/            # optional, served at /plugin-assets/my-pack/
```

## plugin.json

```json
{
  "id": "my-pack",
  "name": "My Game Pack",
  "version": "1.0.0",
  "api_version": 1,
  "min_app_version": "0.1.0",
  "capabilities": ["game_adapter"],
  "entry": "plugin.py",
  "settings_schema": { "demo_dir": { "type": "path", "default": "" } },
  "author": "you",
  "homepage": "https://…"
}
```

`capabilities` is any of `game_adapter`, `media_provider`, `publisher`,
`theme`. `id` should match the folder name. `min_app_version` is checked
before the entry module is imported.

Settings declared in `settings_schema` appear in Settings → Plugins and are
readable from your module.

---

## game_adapter

The most common kind: turn a game's events into marks on the recording's
timeline. **This is timestamping, not editing** — the product never cuts
automatically, so an adapter's only job is to say *what happened and when*.

```python
GAME = "mygame"                 # slug; must be unique
LABEL = "My Game"               # shown in the UI
PROCESS_NAMES = ["mygame.exe"]  # optional: adds to game detection

def parse(payload, state):
    """Called for each POST to /api/gsi/mygame?token=…

    Return a list of events. `state` is a dict that persists between calls
    for this game, so you can diff against the previous payload.
    """
    events = []
    if payload.get("round", {}).get("phase") == "over":
        events.append({
            "type": "round_end",        # any string; unknown types get a
            "title": "round over",      # neutral marker and a tooltip
            "detail": {"map": payload.get("map", {}).get("name", "")},
        })
    return events
```

Optional extras, all discovered by name:

| name | what it does |
|---|---|
| `CONFIG_FILENAME` / `config_text()` | a config file the user downloads and drops into the game, so it starts POSTing |
| `CONFIG_HINT` | one line telling them where to put it |
| `MARKER_STYLE` | `{event_type: {"color": "#f80", "icon": "💀", "label": "kill"}}` — how marks are drawn |
| `scan(path, t0, t1)` | a vision scanner: read frames from a recording between two timestamps and return the same event shape. For games with no telemetry. |
| `ASSETS_DIR` | folder served at `/plugin-assets/<id>/` |

An adapter that raises is logged and counted, never fatal: ingest keeps
working and the other adapters are unaffected.

**No integration at all?** Anything can POST to `/api/gsi/<anything>` with
the install token and a `{"events": [...]}` body. That path needs no
plugin — it is the zero-code integration, and it is registered last so a
real adapter always wins.

## media_provider

Search and import media into the editor's bin.

```python
ID = "my-sounds"
KIND = "sound"                  # sound | gif | image | music

def search(q):
    return [{"name": "boom", "url": "https://…/boom.mp3", "thumb": None}]

def import_item(item):
    """Download it, return a local path."""
    return "/path/to/boom.mp3"

def preview(item):              # optional
    return item["url"]
```

Providers that need an API key should return an empty list when the key is
missing, rather than raising — the pane then shows its empty state.

## publisher

Somewhere a finished clip can go.

```python
ID = "my-host"
LABEL = "My Host"

def publish(clip_path, meta):
    return {"url": "https://…/abc", "id": "abc"}

def list_published():
    return [{"id": "abc", "url": "https://…/abc", "title": "…"}]

def delete(pid):
    ...
```

## theme

Data only — no `entry`, no Python.

```
plugins/my-theme/
  plugin.json      # "capabilities": ["theme"]
  theme.json       # { "id": "my-theme", "label": "My Theme", "dark": true }
  theme.css
```

The CSS redefines the token block. Copy `frontend/themes/liquid-dark.css`
and change the values; only tokens, no structural rules.

Renders are deliberately theme-independent — the render engine forces a
fixed theme, and a test asserts the same project renders identically under
every theme. A theme cannot change what an export looks like.

---

## Testing yours

Drop the folder in `plugins/` and restart. Settings → Plugins lists what
loaded, what it registered, and the traceback if it failed.

The app ships fixture plugins used by its own test suite —
`tests/fixtures/plugins/` has a working adapter, one that throws on import,
one that throws inside `parse`, and a data-only theme. They are the
shortest examples of each shape, and they exist to prove that a broken
plugin cannot take the app with it.
