← Zurück zum Blog
pullmdopen-sourceagplclaude-code4. Mai 2026 · 11 Min. Lesezeit

PullMD Teil 8: Vom Tool für mich zum OSS-Projekt

PRIVATE → PUBLIC OSS · AGPL-3.0KI-generiertes Bild
Editorial-Diagramm: PRIVATE links, PUBLIC OSS rechts, getrennt durch eine leuchtende Schwelle; AGPL-3.0-Lizenz-Boundary-Card und PullMD/MD-Knoten.Editorial-Diagramm: PRIVATE links, PUBLIC OSS rechts, getrennt durch eine leuchtende Schwelle; AGPL-3.0-Lizenz-Boundary-Card und PullMD/MD-Knoten.

Der Vormittag des 25. April hatte ich damit verbracht, den Trafilatura-Sidecar zu integrieren und den Bug zu fixen, der wochenlang in einem Default-Parameter schlummerte. Beides Thema von Teil 5.

Nachmittags stand dann die Sache an, die ich seit Wochen vor mir herschob: Public-Release auf GitHub. Der Code war bereit. Die README noch nicht. Und der Codename war im Source-Tree noch nicht durch den realen Namen ersetzt: aus zwei Gründen, die seit Anfang März im Kopf herumlagen.

Der Codename passt nicht mehr (Grund 1)

Der Codename, unter dem das Tool entstanden war, kam aus dem allerersten Konzept-Chat: ich hatte Claude gebeten, „such dir was aus“ für die Subdomain. Und Claude hatte einen Vorschlag geliefert, weil das Ding Reddit-Content „pullt“ und der Name kurz für ein App-Icon ist.

Ich habe ihn übernommen, weil ich zu dem Zeitpunkt nicht über Branding nachgedacht habe. Der Service lief im Homelab unter mir allein, nicht unter einer Marke.

Inzwischen war das Tool etwas anderes. Drei Extraction-Pfade: Cloudflare-nativer Markdown, der Reddit-JSON-Pfad, und der Readability-plus-Trafilatura-Stack für alles andere. Quality-Score. MCP-Server-Schnittstelle. Generierbares Skill-Bundle. PWA-Frontend mit Web Share Target.

Jeder dieser Pfade funktioniert für jede URL, nicht nur für Reddit.

Wenn jemand den Repo-Namen liest und dort eine Reddit-Referenz sieht, ist die erste Erwartung: Reddit-spezifisches Tool. Das stimmt nicht mehr.

Ein Name, der falsche Erwartungen setzt, ist am Ende weniger ein Branding-Problem als eine Support-Frage, die noch keiner gestellt hat. Allein schon der Klarheit halber musste ein neutraler Name her.

Der Codename war juristisch grenzwertig (Grund 2)

Das Markenrecht-Problem hatte ich nicht erst zum Push-Zeitpunkt im Kopf. Wenige Tage nach dem Konzept-Chat – in einem separaten KI-Gespräch, an dessen Wortlaut ich mich nicht mehr genau erinnere – wurde mir klar, dass die phonetische Nähe zu einer großen Marke aus dieser Konsum-Kategorie konkret riskant war.

Die Firma hinter der Marke ist dafür bekannt, auch gegen kleine Projekte vorzugehen, wenn ein Name phonetisch ähnlich klingt. Das ist dokumentierte Praxis, nicht meine Interpretation.

Die Wahrscheinlichkeit, dass das auf ein wachsendes Open-Source-Projekt unter ähnlichem Namen zukommt, war hoch genug, dass ich den Codenamen ab da nicht mehr als geplanten finalen Namen behandelt habe.

Schon am 8. März habe ich das Production-Deploy-Verzeichnis unter dem neuen Namen /home/user/pullmd/ angelegt. Der Brand-Wechsel war damit faktisch entschieden.

In Gesprächen mit Claude habe ich seit Mitte März über „PullMD“ geredet. Nur der Source-Code-Tree blieb pragmatisch unter dem Codenamen, weil’s intern erstmal egal war: ein einzelner User auf einem einzelnen Server, niemand sonst sieht die Repo-Struktur.

Vor dem Public-Push wurde aus „intern egal“ jetzt „muss aufgeräumt werden“. Die Wahl war einfach: 30 Minuten gründliche Source-Tree-Bereinigung, oder das Risiko unangenehmer Post von einer Markenrecht-Kanzlei.

Die 30 Minuten schienen sinnvoller.

PullMD: neutral, beschreibend, niemandem auf den Füßen

Pull und MD. Der Service zieht Inhalte von URLs herunter und gibt Markdown zurück.

Der Name beschreibt was das Tool tut, und nicht mehr. Kein Wortspiel, keine Pop-Kultur-Referenz, keine phonetische Nähe zu irgendeiner Marke in irgendeiner Kategorie.

PullMD ist boring in genau der richtigen Weise: jemand, der den Namen liest, weiß sofort, was er erwarten kann. Das ist alles, was ein Tool-Name leisten muss.

Verzeichnis-Migration

Auf meinem Ubuntu Docker-Host war damit nichts zu tun: Production lief seit dem 8. März unter /home/user/pullmd/, der Pfad war seit der Markenrecht-Erkenntnis schon der „echte“ Name.

Was offen war, war der Source-Code-Tree und alle internen Verweise dort: Repo-Name auf meinem Ubuntu-Entwicklungsserver, CLAUDE.md, Memory-Files, README-Snippets, Skill-Bundle-Config.

Plus die Frage, was mit der Commit-History passiert. Die war voll mit dem alten Codenamen.

Wir haben uns für den gründlichen Weg entschieden: Source-Repo umbenannt, Commit-History gerewritet, interne Notizen synchronisiert.

Im Grunde nur die längst fällige Bereinigung. Der reale Name war seit sieben Wochen unterwegs, der Source-Tree zog jetzt nach.

Commit-History umschreiben: git filter-repo

Ehrlich gesagt: Claude Code hat den Rename samt History-Rewrite einfach gemacht.

Welche Befehle dafür gelaufen sind, wie filter-repo eigentlich funktioniert, was schiefgeht wenn man’s falsch macht – hat mich zu dem Zeitpunkt nicht interessiert. Es war Migrations-Plumbing, ich wollte Public gehen.

Erst beim Schreiben dieses Artikels habe ich nachgeschaut, was tatsächlich gelaufen ist. Drei Operationen:

  • Author-Identitäten umschreiben: git filter-repo --mailmap mailmap.txt. Eine mailmap.txt mit Zeilen der Form New Name <new@email> = Old Name <old@email>, dann tauscht filter-repo in jedem alten Commit den Author entsprechend. In meinem Fall: meine persönliche Mail-Adresse aus den Codenamen-Tagen wurde durch die OSS-Persona ersetzt, damit die Public-History konsistent unter der einen Identität läuft.
  • Text in alten Commits ersetzen: git filter-repo --replace-text replacements.txt. Eine Datei mit Regeln der Form alter-string==>neuer-string, filter-repo geht durch jede Datei in jedem alten Commit. So verschwand der Codename auch aus Commit-Diffs, Code-Kommentaren und alten CLAUDE.md-Snapshots, die in der History noch herumlagen.
  • Anschließend Branches und Tags resetten: Nach filter-repo haben alle alten Commits neue SHAs, die History ist effektiv neu. Vor dem ersten Public-Push ist das harmlos: einmal sauber pushen, fertig. Bei einem bereits öffentlichen Repo bräuchte man --force-with-lease, und alle existierenden Forks und Clones würden divergieren. Daher der Standardrat: solche History-Rewrites möglichst vor dem ersten Public-Push machen.

Das Tool selbst ist nicht im Standard-Git enthalten, sondern ein separates Python-Paket: pip install git-filter-repo. Offizieller Nachfolger des alten git filter-branch, das so viele Stolpersteine hatte, dass die Git-Maintainer selbst auf filter-repo verweisen.

Jetzt weiß ich’s. Damals: zugesehen, gewartet, gepusht.

Wenn man Vertrauen in das Tool hat, ist das ein legitimer Modus. Die Operation ist deterministisch, vor dem ersten Public-Push gibt’s keinen Schaden, den man nicht zurücknehmen kann.

Wenn man kein Vertrauen hat, sollte man die Befehle vor dem Delegieren verstehen, nicht danach beim Bloggen.

Das kritischere Stück: das Session-Verzeichnis

Das kritischere Stück war das Claude-Code-Session-Verzeichnis. Die Sessions sind die Quelle, aus der diese Blogserie selbst geschrieben wird. Ohne sie keine archäologische Rekonstruktion von Teil 2 oder 5.

Claude Code legt JSONL-Transkripte in einem Pfad ab, der vom Working Directory abgeleitet wird: ~/.claude/projects/-home-user-projects-<verzeichnisname>/.

Wenn ich nach der Migration versehentlich aus dem alten Verzeichnis heraus Claude Code starte, landen neue Sessions im alten Pfad und sind nicht mehr zusammen mit der neuen Session-Historie. Als kleiner Schutz vor dem Fall: _archive/sync-sessions.sh, ein Helper-Script, das die neue Session-Historie in den aktualisierten Pfad spiegelt.

Und dann war da noch das Tar-Backup: das gesamte alte Session-Verzeichnis gesichert als _archive/claude-sessions-<codename>-2026-04-25.tar.gz (2,4 MB).

Dieses Archiv ist übrigens genau das, was diese Blogserie erst möglich macht. Die Sessions vom 2. bis 4. März, vom 20. März und 11. April sowie vom 25. April – auf denen die Teile 2 bis 7 und dieser Artikel hier aufbauen – wären ohne dieses Backup nicht mehr zitierbar gewesen. Das tar.gz liegt in _archive/, wird nie ins Repo committed, aber ist die Quelle für alles, was hier als „aus der Session zitiert“ erscheint.

AGPL-3.0, nicht MIT

Bei Open-Source-Projekten läuft die Lizenzwahl oft auf eine kurze Abwägung hinaus: MIT wenn man maximale Verbreitung will, GPL wenn man ideologisch überzeugt ist. Und irgendwas dazwischen wenn man nicht genau weiß was man will.

Das ist keine besonders hilfreiche Kategorisierung.

Die eigentliche Frage für PullMD war: was passiert, wenn jemand das forkt, Modifikationen einbaut, und als Cloud-Service anbietet – ohne die Modifikationen zu teilen? Bei MIT ist die Antwort: nichts.

Das ist die ganze Idee permissiver Lizenzen. Breite Adoption, niedrige Reibung, fertig.

Für einen selbst-gehosteten Service ist die Frage eine andere. PullMD ist kein passiv genutztes Paket: es ist ein Service, der läuft und Requests beantwortet.

Wenn jemand das als Cloud-Dienst betreibt und dabei Verbesserungen einbaut, die ich nie zu Gesicht bekomme, verliere ich den Anreiz zu verstehen, was die eigentlichen Probleme in meiner Codebasis sind.

AGPL-3.0-or-later ist genau für diesen Fall: Wer den Service über Netzwerk anbietet und Modifikationen gemacht hat, muss diese Modifikationen unter AGPL veröffentlichen.

Das ist keine ideologische Aussage über Free Software. Es ist eine Frage der Anreizstruktur.

CLI-Helper, Libraries, Toolchain-Pakete: MIT oder Apache-2.0 sind die richtigen Werkzeuge. Self-hosted Services, bei denen Netzwerknutzung der primäre Kanal ist: AGPL überlegen.

Die permissiven Dependencies tief im Stack – Express, cheerio, better-sqlite3 – bleiben frei wählbar; Lizenz-Layering ist kein binäres Entweder-Oder. Mehr Nuance zu den Konsequenzen einzelner Lizenzen ohne Anwalt: choosealicense.com.

Self-Host-fähig machen

Bis zu diesem Punkt lief der Service nur auf meiner Infrastruktur, mit meinen Hostnamen hartkodiert an verschiedenen Stellen. Für einen Public-Release musste das anders werden.

Die wichtigste Änderung war PUBLIC_URL als konfigurierbare Umgebungsvariable. Der Default-Wert ist https://${HOST_DOMAIN}. Wer HOST_DOMAIN für Traefik setzt, bekommt automatisch die korrekte URL.

Diese Variable wird in der /help-Seite substituiert und in das /web-reader.zip eingebettet, ein generierbares Skill-Bundle für Claude Code.

Ohne die Substitution würden Self-Hoster auf der Help-Seite meinen Hostnamen sehen, nicht ihren eigenen: eine schlechte Erfahrung und eine unnötige Abhängigkeit.

HOST_DOMAIN steuert das Traefik-Label in docker-compose.yml. Wer das auf einer eigenen Domain deployen will, setzt diese eine Variable, und Traefik routet und stellt Zertifikate aus.

Ein sauberes .env.example als Template. Die echte .env mit OAuth-Credentials bleibt außerhalb des Repos.

Die README wurde komplett für Public Consumption umgeschrieben: keine internen Notizen, kein Eval-Workspace, keine Briefing-Dokumente.

.gitignore deckt alles ab, was nicht nach außen soll: CLAUDE.md, SESSION_LOG.md, pullmd-improvements-briefing.md, docs/, .claude/, _archive/.

Der Push

gh repo create AeternaLabsHQ/pullmd --public --source=. --remote=origin --push

Der lokale Initial-Public-Release-Commit war am 25. April fertig. Bis der Push tatsächlich lief, vergingen aber noch zwei Tage Polish. v1.0.0 und v1.0.1 wurden lokal getagt, aber nicht gepusht.

v1.0.1 war ein Reddit-Comment-Cache-Bugfix, der erst auf meinem Ubuntu Docker-Host live musste, bevor der Repo öffentlich werden sollte.

Am 27. April lief gh repo create dann durch. v1.0.2 ist der erste Tag in der Public-History.

In den folgenden Tagen kamen UI-Polish-Commits: Footer, Version-Templating, Help-Page-Layout. Das sind die normalen Letzte-Schliff-Sachen, die einem immer erst auffallen, wenn man das erste Mal mit echtem Abstand auf den eigenen Service schaut.

Ein Tool, das im März als Reddit-Spielzeug begann, war acht Wochen später offiziell Open Source: für jede URL, unter github.com/AeternaLabsHQ/pullmd.

Was danach lief

Der Dienst läuft seitdem auf eigener Domain, und ich benutze ihn jeden Tag: für Reddit-Posts, für Artikel, für Dokumentationsseiten, für alles, was ich als sauberes Markdown in einen Chat-Kontext bringen will.

Darunter steckt alles, was seit März dazugekommen ist: die drei Extraction-Pfade mit Quality-Score, der MCP-Server für Claude Code, das generierbare Skill-Bundle, die PWA für den Android-Share-Workflow.

JavaScript-Seiten rendern: der Playwright-Sidecar

Seit dem 25. April sammelt P3.1 nebenbei Daten: eine SQLite-Tabelle mit Extraction-Log, dazu der /api/stats-Endpunkt. Der Plan war, eine Woche lang zuzuschauen und dann zu entscheiden, ob sich P3.2 lohnt: ein Playwright-Microservice für Seiten, die ohne JavaScript nichts hergeben.

Der Anlass war dann eine einzelne Seite. Die Preisliste von Mistral kam als 452 Kilobyte HTML zurück, in denen die Preise selbst gar nicht standen, weil der Browser sie erst beim Anzeigen zusammenbaut. Readability holte daraus 19 Überschriften und drei Absätze, Trafilatura ganze 283 Zeichen.

Dazu kam die Zahl aus dem Log. Von 29 Web-Abrufen war die statische Extraktion bei vieren durchgefallen, macht 14 Prozent. Die Grenze, ab der sich der Aufwand laut Briefing überhaupt lohnt, lag ja bei zehn.

Gebaut wurde daraus der zweite Sidecar, nach Trafilatura aus Teil 5. Ein eigener Container mit Python, FastAPI und einem Chromium ohne Fenster.

Er nimmt eine Adresse entgegen und gibt das fertig gerenderte HTML zurück, aus dem dann wieder Readability und Trafilatura ihr Bestes machen. Der Container hängt nur im internen Netz und ist deutlich schwerer als alles andere im Stapel.

Wann überhaupt gerendert wird, entscheidet ein kleines Prädikat in lib/render-decision.js. Es greift in drei Fällen:

  • Readability ist zurückgefallen und es bleiben keine 500 Zeichen übrig
  • auf viele Überschriften kommen kaum Absätze
  • der Quality-Score liegt unter 0,5

Trifft nichts davon zu, bleibt es eben bei der statischen Extraktion. Für die Fälle, in denen die Heuristik danebenliegt, gibt es render=force und render=skip.

Antwortet der Sidecar nicht oder läuft er in den Zeitdeckel von 20 Sekunden, kommt das schwache statische Ergebnis zurück, samt Notiz, woran es lag. Wer ihn gar nicht erst mitstartet, bekommt denselben Weg.

Rendern dauert halt spürbar länger als ein normaler Abruf. Deshalb kam in derselben Version eine Statuszeile dazu, die zeigt, ob gerade geholt, extrahiert oder gerendert wird.

Gehalten hat der Plan keine zwei Tage. Der Playwright-Sidecar stand schon am 27. April, ein paar Stunden nach dem gh repo create, und ging als v1.1.0 raus. Die Woche Zuschauen habe ich mir damit gespart, denn gebraucht hätte ich sie nur, wenn die Zahl knapp gewesen wäre.

Was dann noch fehlte, war das Naheliegendste: das Ding mal jemandem zu zeigen. Einen Tag nach dem Push habe ich dazu einen Post auf r/ClaudeAI geschrieben.

Was der ausgelöst hat, steht in Teil 9.


Teil 8 von 15 der PullMD Serie.

← PullMD Teil 7: Der MCP-Server | PullMD Teil 9: Reddit-Tag und die ersten Issues →

Kommentare

Zum Kommentieren genügt eine E-Mail-Bestätigung oder ein GitHub-Login. Die Kommentare laufen auf einem eigenen Server, nicht bei einem Werbeanbieter — Details in der Datenschutzerklärung.