Plugins — die Bauanleitung
Ein Tool wird zum Plugin, indem sein Ordner hier abgelegt wird. Beim Start liest
die Suite jede plugin.json, lädt das Backend und hängt es unter /plugins/<id>
ein. Das Dashboard listet es danach automatisch auf. Es gibt keine zentrale
Liste, in die man ein Tool eintragen müsste.
plugins/
└── mein-tool/
├── plugin.json Pflicht — Metadaten
├── backend.py Pflicht — create_app(ctx) gibt eine ASGI-App zurück
├── static/
│ └── index.html die Oberfläche
└── … der Rest des Tools, unverändert
1. plugin.json
{
"id": "mein-tool",
"name": "Mein Tool",
"summary": "Eine Zeile für den Tool-Umschalter",
"description": "Zwei bis drei Zeilen für die Dashboard-Karte.",
"icon": "🔧",
"category": "Tandoor",
"version": "1.0.0",
"entrypoint": "backend:create_app",
"order": 50,
"requires": ["tandoor"],
"features": ["Stichpunkt eins", "Stichpunkt zwei"],
"docs": "TOOL-README.md",
"enabled": true
}
| Feld | Bedeutung |
|---|---|
id |
identisch zum Ordnernamen, bestimmt Mount-Pfad und Datenordner |
category |
gruppiert die Karten im Dashboard |
order |
Sortierung (kleiner = weiter oben) |
requires |
tandoor, openai — erscheint als Kennzeichen auf der Karte |
mount |
optional, Standard ist /plugins/<id> |
enabled |
auf false setzen, um ein Tool vorübergehend stillzulegen |
2. backend.py
from fastapi import FastAPI
from fastapi.responses import FileResponse
def create_app(ctx):
app = FastAPI(title=ctx.meta.name)
@app.get("/")
def index():
return FileResponse(ctx.path("static", "index.html"))
@app.get("/api/state")
def state():
return {"tandoor": ctx.settings.status()["tandoor"]}
return app
Der Kontext ctx:
| Attribut | Inhalt |
|---|---|
ctx.id, ctx.meta |
ID und Metadaten aus der plugin.json |
ctx.dir, ctx.path(*teile) |
Pfade im Plugin-Ordner (nur lesen) |
ctx.data_dir |
eigener Datenordner data/<id>/ (hier schreiben) |
ctx.settings |
zentrale Zugänge: .get("TANDOOR_URL"), .status(), .tool_env() |
ctx.jobs |
Job-Runner für Kommandozeilen-Tools |
ctx.mount |
Mount-Präfix, z. B. /plugins/mein-tool |
Zurückgeben lässt sich jede ASGI-App. Eine Flask-App wird eingepackt:
from a2wsgi import WSGIMiddleware
return WSGIMiddleware(flask_app)
Namenskollisionen vermeiden
Gewachsene Tools benutzen naheliegende Modulnamen (app.py, storage.py).
Damit sich zwei Plugins nicht ins Gehege kommen, werden ihre Module über
core.loader unter eindeutigem Namen geladen — statt mit import app:
from core.loader import load_module, load_package, load_submodule
modul = load_module(ctx.path("app.py"), f"btp_{ctx.id}_app") # app.py
paket = load_package(ctx.path("app"), f"btp_{ctx.id}") # app/
main = load_submodule(ctx.path("app"), f"btp_{ctx.id}", "main") # app/main.py
3. Die Oberfläche
Damit ein Tool aussieht wie der Rest der Suite, bindet seine index.html das
gemeinsame Design-System ein und bringt selbst nur noch Layout mit:
<link rel="stylesheet" href="/shared/boehmi.css">
<script src="/shared/boehmi.js"></script>
<style>
/* nur noch das Layout dieses Tools */
</style>
</head>
<body data-bt-title="Mein Tool" data-bt-icon="🔧">
<main class="bt-main"> … </main>
boehmi.js ergänzt die App-Bar mit Tool-Umschalter und Design-Wechsel und
stellt bereit:
| Aufruf | Zweck |
|---|---|
BT.url("/api/x") |
Plugin-Pfad → vollständige URL (Mount-Präfix) |
BT.api("/api/x", {method:"POST", body:…}) |
fetch mit JSON- und Fehlerbehandlung |
BT.toast("Text", "ok"|"err") |
Rückmeldung |
BT.escape(text) |
HTML-sicher ausgeben |
BT.plugins, BT.ready |
Liste aller Tools, Promise nach dem Aufbau |
Wichtig: API-Pfade nie fest verdrahten (fetch("/api/x")), sondern immer
über BT.url() bzw. BT.api() — sonst zeigt das Tool auf den Host statt auf
sich selbst.
Bausteine aus /shared/boehmi.css
bt-main (.wide, .narrow) · bt-pagehead · bt-card · bt-btnrow ·
bt-row / bt-grid2 / bt-grid3 · bt-tabs + bt-tab · bt-badge
(ok warn err info accent) · bt-notice (ok warn err info) · bt-status ·
bt-item · bt-table · bt-log · bt-empty · bt-muted · bt-hidden
Buttons und Formularfelder sind direkt gestylt — <button class="primary">,
<button class="ghost mini">, <button class="danger">.
4. Kommandozeilen-Tools einbinden
Ein bestehendes Skript wird nicht umgeschrieben. Das Backend baut die Argumentliste und startet es über den Job-Runner:
from core.jobs import job_router
@app.post("/api/run")
async def run(request: RunRequest): # pydantic-Modell = geprüfte Eingabe
argv = [sys.executable, str(ctx.path("tool", "skript.py"))]
if request.apply:
argv += ["--apply", "--yes"]
job = await ctx.jobs.start(
plugin=ctx.id, label="Dry-Run", argv=argv,
cwd=ctx.data_dir, env=ctx.settings.tool_env(),
)
return job.info()
app.include_router(job_router(ctx.jobs, ctx.id)) # /api/jobs inkl. Live-Stream
Die Oberfläche bekommt das Protokoll mit drei Zeilen:
<script src="/shared/boehmi-runner.js"></script>
<div id="status" class="bt-status">Bereit.</div>
<div id="log" class="bt-log"></div>
<script>
const runner = BT.Runner({ log: log, status: status });
runner.start({ apply: false });
</script>
Die Argumentliste baut immer das Backend aus geprüften Feldern. Es gibt bewusst keinen Endpunkt, der ein beliebiges Kommando entgegennimmt.
5. Zwei Regeln
- Nur nach
ctx.data_dirschreiben. Der Plugin-Ordner ist Programmcode und im Container praktisch unveränderlich; nurdata/liegt im Volume. Bringt ein Tool Startdaten mit, werden sie beim ersten Start dorthin gespiegelt. - Zugänge nicht selbst abfragen. Tandoor- und OpenAI-Daten kommen aus
ctx.settingsbzw. stehen als Umgebungsvariablen bereit.
6. Checkliste für ein neues Tool
- Ordner
plugins/<id>/angelegt, Tool-Code unverändert hineinkopiert plugin.jsongeschriebenbackend.pymitcreate_app(ctx)index.htmlbindetboehmi.css+boehmi.jsein,data-bt-titlegesetzt- alle API-Aufrufe laufen über
BT.url()/BT.api() - Schreibzugriffe zeigen auf
ctx.data_dir - neue Abhängigkeiten in der
requirements.txtder Suite ergänzt - Suite neu gestartet — die Karte erscheint im Dashboard
Geteilte Bausteine für neue Plugins
Die drei mitgebrachten Original-Tools bringen ihren eigenen Tandoor-Zugang mit und bleiben unangetastet. Neue Plugins nutzen stattdessen:
core/tandoor.py TandoorClient: Paginierung, Wiederholversuche,
get/patch/put/post/delete, merge().
Dazu food_property_map() und
merged_properties_payload() — Letzteres ist Pflicht,
wenn Nährwerte geschrieben werden: Tandoor ersetzt die
properties-Liste komplett, wer nur den neuen Wert
schickt, verliert den Bestand.
core/foodmatch.py Namensabgleich. duplicate_groups() nur für praktisch
Sicheres, similar_pairs() für Grenzfälle. Bewusst
konservativ — Zusammenführen ist nicht umkehrbar.
Ein CLI-Skript unter tool/ findet die Suite so:
SUITE_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(SUITE_ROOT))
from core.tandoor import TandoorClient
Damit bleibt es auch ohne laufende Suite direkt aufrufbar.
Fallstrick: DATA_DIR
Der Plugin-Ordner ist im Container schreibgeschützt. Wer ein Tool per
ctx.jobs.start() startet, muss ihm ausdrücklich sagen, wohin es schreiben
darf — ctx.settings.tool_env() enthält das nicht:
def tool_env():
env = ctx.settings.tool_env()
env["DATA_DIR"] = str(ctx.data_dir)
return env
Ohne das schreibt das Skript in seinen Rückfallpfad neben die Anwendung, und die Oberfläche findet das Ergebnis nie.
Sicherungen zurückspielen (core/backups.py)
Wer ein Plugin baut, das etwas in Tandoor verändert, sollte es auch zurücknehmen können. Dafür gibt es einen gemeinsamen Unterbau:
core/backups.py manifest schreiben/lesen, Läufe auflisten, einen
Lauf sicher auflösen, als „zurückgespielt“ markieren.
backup_router(ctx, "laeufe", restore_argv) liefert
fertig /api/backups, /api/backups/{id} und
/api/run/restore.
shared/boehmi-backups.js BT.Backups({mount, runner, detail, note}) baut
den immer gleichen Reiter „Sicherungen“.
Konvention für ein veränderndes Tool:
- Vor jedem Schreibschritt den Zustand vorher ins Manifest schreiben, mit
einem ehrlichen
restore_level(voll / neue_id / teilweise / nein). - Einen Unterbefehl
zurueck --lauf <id> [--apply] [--force]anbieten, der das Manifest liest und rückwärts abarbeitet. Zurückspielen läuft über dasselbe Skript wie das Ändern — kein zweiter, ungetesteter Pfad. - Im Backend
backup_routereinhängen und im FrontendBT.Backupseinbinden.restore_argv(run, apply, force)liefert die Kommandozeile.
Wichtig bei Tandoor: Ein per POST neu angelegter Eintrag kann eine neue ID bekommen, und Tandoor gibt bei Namensgleichheit einen vorhandenen Eintrag zurück (Food/Unit case-insensitiv und gegen den Plural, Keyword case-sensitiv). Wiederherstellen heißt deshalb: neu anlegen, prüfen was zurückkam, notfalls über einen Zwischennamen gehen, dann die Verweise zurückhängen.
Woran ein Plugin erkannt wird
Die Identität ist die id aus der plugin.json — nicht der Ordnername.
An ihr hängt alles Dauerhafte:
| Was | Wo |
|---|---|
| Datenordner | data/<id>/ |
| Adresse | /plugins/<id> |
| Modul-Namensraum | boehmitools_plugin_<id>_backend |
| Zuordnung laufender Aufgaben | über ctx.id |
Daraus folgen zwei Regeln:
- Die
idbleibt, was sie ist. Ändert man sie, gilt das Plugin als ein anderes: neuer Datenordner, neue Adresse, alte Sicherungen finden nicht mehr zurück. Wer wirklich umbenennen will, benennt Anzeigenamen und Ordner um und lässt dieidstehen. - Der Ordnername ist beliebig. Beim Import wird das vorhandene Plugin
anhand der
idgesucht und genau dessen Ordner aktualisiert — auch wenn das Archiv einen anderen Ordnernamen mitbringt. So entstehen keine Doppelgänger.
Erlaubte Zeichen für die id: Kleinbuchstaben, Ziffern, Punkt, Bindestrich und
Unterstrich; sie beginnt mit Buchstabe oder Ziffer. Sie wird zum Ordnernamen,
zum URL-Bestandteil und zum Modulnamen — deshalb bewusst eng gefasst.
Die version entscheidet, ob ein Upload als Aktualisierung durchgeht.
Gleiche oder ältere Version verlangt eine ausdrückliche Bestätigung.