Files
2026-07-24 21:37:03 +02:00
..
2026-07-24 21:37:03 +02:00
2026-07-24 21:37:03 +02:00
2026-07-24 21:37:03 +02:00
2026-07-24 21:37:03 +02:00
2026-07-24 21:37:03 +02:00
2026-07-24 21:37:03 +02:00
2026-07-24 21:37:03 +02:00
2026-07-24 21:37:03 +02:00

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

  1. Nur nach ctx.data_dir schreiben. Der Plugin-Ordner ist Programmcode und im Container praktisch unveränderlich; nur data/ liegt im Volume. Bringt ein Tool Startdaten mit, werden sie beim ersten Start dorthin gespiegelt.
  2. Zugänge nicht selbst abfragen. Tandoor- und OpenAI-Daten kommen aus ctx.settings bzw. stehen als Umgebungsvariablen bereit.

6. Checkliste für ein neues Tool

  • Ordner plugins/<id>/ angelegt, Tool-Code unverändert hineinkopiert
  • plugin.json geschrieben
  • backend.py mit create_app(ctx)
  • index.html bindet boehmi.css + boehmi.js ein, data-bt-title gesetzt
  • alle API-Aufrufe laufen über BT.url() / BT.api()
  • Schreibzugriffe zeigen auf ctx.data_dir
  • neue Abhängigkeiten in der requirements.txt der 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:

  1. Vor jedem Schreibschritt den Zustand vorher ins Manifest schreiben, mit einem ehrlichen restore_level (voll / neue_id / teilweise / nein).
  2. 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.
  3. Im Backend backup_router einhängen und im Frontend BT.Backups einbinden. 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 id bleibt, 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 die id stehen.
  • Der Ordnername ist beliebig. Beim Import wird das vorhandene Plugin anhand der id gesucht 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.