Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64744a213d | ||
|
|
04886d73f5 | ||
|
|
aa9888700d | ||
|
|
437bd1c1ee | ||
|
|
0fd228b962 | ||
|
|
ad893e20d5 | ||
|
|
079226ea43 | ||
|
|
633592b9b0 | ||
|
|
7b44ceafdc |
@@ -27,6 +27,16 @@
|
|||||||
"name": "humhub",
|
"name": "humhub",
|
||||||
"source": "./plugins/humhub",
|
"source": "./plugins/humhub",
|
||||||
"description": "HumHub via REST-API: Wiki-Seiten und Posts auflisten, anzeigen, anlegen und aktualisieren (Token lokal)."
|
"description": "HumHub via REST-API: Wiki-Seiten und Posts auflisten, anzeigen, anlegen und aktualisieren (Token lokal)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mediawiki",
|
||||||
|
"source": "./plugins/mediawiki",
|
||||||
|
"description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (Instanzen wiki & devwiki; Zugangsdaten lokal)."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "grill-me",
|
||||||
|
"source": "./plugins/grill-me",
|
||||||
|
"description": "Löchert dich gnadenlos mit Fragen zu einem Plan, einer Entscheidung oder Idee, bis ein gemeinsames Verständnis steht."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,28 @@ und kann einzeln installiert werden — niemand bekommt alles auf einmal.
|
|||||||
|
|
||||||
## Einrichtung (einmalig)
|
## Einrichtung (einmalig)
|
||||||
|
|
||||||
`/plugin marketplace add` akzeptiert nur `owner/repo` (immer GitHub!), `https://…`
|
Dieses Repo ist öffentlich lesbar. Der Marketplace lässt sich deshalb direkt über
|
||||||
oder einen lokalen `./path` — **kein** `ssh://`. Da dieses Repo privat ist und der
|
seine HTTPS-URL hinzufügen — ohne Zugangsdaten und ohne git-Konfiguration:
|
||||||
Plugin-Clone nicht interaktiv nach Zugangsdaten fragen kann, muss git HTTPS-Zugriffe
|
|
||||||
auf das Gitea transparent auf SSH umleiten. Einmalig pro Rechner:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. git so konfigurieren, dass https auf dieses Gitea über SSH (Key) läuft
|
|
||||||
git config --global url."ssh://gitea@gitea.inmedias.it:2222/".insteadOf "https://gitea.inmedias.it/"
|
|
||||||
|
|
||||||
# 2. Marketplace mit der HTTPS-URL hinzufügen (klont real über SSH)
|
|
||||||
/plugin marketplace add https://gitea.inmedias.it/inmedias.it/claude-plugins.git
|
/plugin marketplace add https://gitea.inmedias.it/inmedias.it/claude-plugins.git
|
||||||
```
|
```
|
||||||
|
|
||||||
Voraussetzung: dein Gitea-SSH-Key ist auf Port 2222 eingerichtet.
|
Zum **Pushen** ist weiterhin ein Gitea-SSH-Key auf Port 2222 nötig.
|
||||||
|
|
||||||
> Hinweis: Die `insteadOf`-Regel gilt für **alle** git-Operationen auf
|
> Hinweis: `/plugin marketplace add` akzeptiert nur `owner/repo` (immer GitHub!),
|
||||||
> `gitea.inmedias.it`, nicht nur für Plugins.
|
> `https://…` oder einen lokalen `./path` — **kein** `ssh://`.
|
||||||
>
|
>
|
||||||
> Alternative ohne git-Config: Repo `inmedias.it/claude-plugins` intern/öffentlich
|
> Falls das Repo wieder auf privat gestellt wird, kann der Plugin-Clone nicht
|
||||||
> lesbar machen — dann reicht die HTTPS-URL direkt (SSH-Key trotzdem für Push nötig).
|
> interaktiv nach Zugangsdaten fragen. Dann muss git HTTPS-Zugriffe auf das Gitea
|
||||||
|
> transparent auf SSH umleiten:
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> git config --global url."ssh://gitea@gitea.inmedias.it:2222/".insteadOf "https://gitea.inmedias.it/"
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> Diese Regel gilt für **alle** git-Operationen auf `gitea.inmedias.it`, nicht nur
|
||||||
|
> für Plugins.
|
||||||
|
|
||||||
## Plugin installieren
|
## Plugin installieren
|
||||||
|
|
||||||
@@ -75,6 +77,52 @@ natürlicher klingen. Basiert auf Wikipedias „Signs of AI writing".
|
|||||||
/plugin install humanizer@inmedias-claude-plugins
|
/plugin install humanizer@inmedias-claude-plugins
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### humhub
|
||||||
|
|
||||||
|
HumHub (humhub.inmedias.it) über die REST-API: Wiki-Seiten und Posts auflisten,
|
||||||
|
anzeigen, anlegen und aktualisieren — via mitgelieferter, self-contained
|
||||||
|
`humhub.py` (`wiki-list/get/update/create`, `post-list/create/update`).
|
||||||
|
|
||||||
|
- Voraussetzung: `HUMHUB_API_TOKEN` als Umgebungsvariable bzw. dotenv-Datei
|
||||||
|
(kein Token im Plugin). Token nur als berechtigter Benutzer unter
|
||||||
|
https://humhub.inmedias.it/rest/admin/index.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install humhub@inmedias-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### mediawiki
|
||||||
|
|
||||||
|
MediaWiki-Seiten/Templates lesen, rendern und schreiben — via mitgelieferter,
|
||||||
|
self-contained `wiki.py` (`fetch`/`render`/`search`/`update`). Instanzen `wiki`
|
||||||
|
(Prod, wiki.inmedias.it) und `devwiki` per `-w`.
|
||||||
|
|
||||||
|
- Voraussetzung: eigene Zugangsdaten (`MEDIAWIKI_USER`/`MEDIAWIKI_PASSWORD`,
|
||||||
|
optional `DEVMEDIAWIKI_*`) als Umgebungsvariable bzw. dotenv-Datei (kein Secret
|
||||||
|
im Plugin).
|
||||||
|
- Regel: Wiki-Seiten nur über `wiki.py`/MCP bearbeiten, nicht per
|
||||||
|
Maintenance-Skript am Server vorbei.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install mediawiki@inmedias-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
|
### grill-me
|
||||||
|
|
||||||
|
Löchert dich gnadenlos mit Fragen zu einem Plan, einer Entscheidung oder Idee —
|
||||||
|
geht den Entscheidungsbaum Zweig für Zweig durch, eine Frage nach der anderen (je
|
||||||
|
mit Empfehlung), bis ein gemeinsames Verständnis steht. Startet die Umsetzung erst
|
||||||
|
nach deiner Bestätigung.
|
||||||
|
|
||||||
|
- Kein Setup, keine Zugangsdaten. Auslösen mit „grill mich" bzw. wenn du deinen
|
||||||
|
Plan stresstesten willst.
|
||||||
|
- Deutsche Adaption des englischen Originals von Matt Pocock:
|
||||||
|
[mattpocock/skills · grill-me](https://github.com/mattpocock/skills/blob/main/skills/productivity/grill-me/SKILL.md).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin install grill-me@inmedias-claude-plugins
|
||||||
|
```
|
||||||
|
|
||||||
## Updates
|
## Updates
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "grill-me",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Löchert dich gnadenlos mit Fragen zu einem Plan, einer Entscheidung oder Idee, bis ein gemeinsames Verständnis steht.",
|
||||||
|
"author": {
|
||||||
|
"name": "inmedias.it"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
name: grill-me
|
||||||
|
description: Löchert dich gnadenlos mit Fragen zu einem Plan, einer Entscheidung oder Idee, bis ein gemeinsames Verständnis steht. Nutzen, wenn der User seinen Plan stresstesten will oder "grill mich" sagt.
|
||||||
|
---
|
||||||
|
|
||||||
|
Interviewe mich gnadenlos zu jedem Aspekt dieses Vorhabens, bis wir ein gemeinsames Verständnis erreicht haben. Gehe jeden Zweig des Entscheidungsbaums durch und löse die Abhängigkeiten zwischen den Entscheidungen nacheinander auf. Gib mir zu jeder Frage deine empfohlene Antwort dazu.
|
||||||
|
|
||||||
|
Stelle die Fragen einzeln und warte auf meine Antwort, bevor du weitermachst. Mehrere Fragen auf einmal sind verwirrend.
|
||||||
|
|
||||||
|
Wenn sich ein Fakt durch Erkunden der Umgebung (Dateien, Tools usw.) herausfinden lässt, schlag ihn selbst nach, statt mich zu fragen. Die Entscheidungen aber gehören mir: Leg mir jede einzeln vor und warte auf meine Antwort.
|
||||||
|
|
||||||
|
Fang nicht mit der Umsetzung an, bevor ich bestätigt habe, dass wir ein gemeinsames Verständnis erreicht haben.
|
||||||
|
|
||||||
|
<!-- Deutsche Adaption des englischen Originals von Matt Pocock:
|
||||||
|
https://github.com/mattpocock/skills/blob/main/skills/productivity/grill-me/SKILL.md -->
|
||||||
|
|
||||||
@@ -19,6 +19,29 @@ mitgelieferte, self-contained `humhub.py`.
|
|||||||
./humhub.py <befehl> ...
|
./humhub.py <befehl> ...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Perma-Link / Content-ID holen (empfohlener Einstieg)
|
||||||
|
|
||||||
|
HumHub-Links der Form `…/content/perma?id=NNNN` tragen eine **Content-ID** — nicht
|
||||||
|
die Wiki-Seiten- oder Post-ID, die die anderen Befehle erwarten. `fetch` löst das
|
||||||
|
selbst auf (erkennt WikiPage vs. Post) und braucht keine Vorab-Recherche:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./humhub.py fetch <content-id|perma-url> # Inhalt + Herkunfts-Kopf auf stdout
|
||||||
|
./humhub.py fetch <content-id|perma-url> --save # ablegen unter resources/humhub-<id>-<slug>.md
|
||||||
|
./humhub.py fetch <content-id|perma-url> --save --out <dir>
|
||||||
|
```
|
||||||
|
|
||||||
|
`--save` schreibt Markdown mit einem Herkunfts-Kopf (Quelle, Titel, Typ, Topics,
|
||||||
|
Stand, Abrufdatum). Zielverzeichnis ist standardmäßig `resources/` im aktuellen
|
||||||
|
Projekt — dieses gehört üblicherweise in `.gitignore` (abgerufene Fremdinhalte
|
||||||
|
werden nicht committet).
|
||||||
|
|
||||||
|
### Spaces
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./humhub.py space-list # Spaces auflisten mit contentcontainer_id
|
||||||
|
```
|
||||||
|
|
||||||
### Wiki
|
### Wiki
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -37,9 +60,10 @@ mitgelieferte, self-contained `humhub.py`.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Inhalte (Markdown) aus Datei oder `-` (stdin) übergeben — nicht als langes
|
Inhalte (Markdown) aus Datei oder `-` (stdin) übergeben — nicht als langes
|
||||||
Shell-Argument (Sonderzeichen). `container_id` ist die Space-/Profil-Container-ID
|
Shell-Argument (Sonderzeichen). `container_id` akzeptiert eine numerische
|
||||||
in HumHub (z. B. ein User- oder Space-Container). Mit `wiki-list` /
|
Container-ID, einen **Space-Slug** oder eine **Space-URL** (`resolve_container`
|
||||||
`post-list` die passende ID/Seite ermitteln.
|
löst Slug/URL selbst auf). Passende Container per `space-list` ermitteln, Seiten
|
||||||
|
per `wiki-list` / `post-list`.
|
||||||
|
|
||||||
## Zugangsdaten
|
## Zugangsdaten
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,11 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from dotenv import load_dotenv, find_dotenv
|
from dotenv import load_dotenv, find_dotenv
|
||||||
@@ -52,6 +55,30 @@ def api_post(session: requests.Session, path: str, payload: dict) -> dict:
|
|||||||
return r.json()
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_container(session: requests.Session, value: str) -> int:
|
||||||
|
"""Resolve a container ID, space slug, or space URL to a contentcontainer_id."""
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
# Strip full URL down to slug
|
||||||
|
slug = value.rstrip("/").split("/s/")[-1].rstrip("/") if "/s/" in value else value.strip("/")
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
data = api_get(session, f"/space?limit=50&page={page}")
|
||||||
|
results = data.get("results", [])
|
||||||
|
if not results:
|
||||||
|
break
|
||||||
|
for space in results:
|
||||||
|
space_slug = space.get("url", "").rstrip("/").split("/s/")[-1].rstrip("/")
|
||||||
|
if space_slug == slug or space.get("name", "").lower() == slug.lower():
|
||||||
|
return space["contentcontainer_id"]
|
||||||
|
if len(results) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
sys.exit(f"Error: Space '{value}' nicht gefunden. Verfügbare Spaces: uv run humhub.py space-list")
|
||||||
|
|
||||||
|
|
||||||
def parse_topics(topics_str: str | None) -> list[dict]:
|
def parse_topics(topics_str: str | None) -> list[dict]:
|
||||||
if not topics_str:
|
if not topics_str:
|
||||||
return []
|
return []
|
||||||
@@ -126,6 +153,7 @@ def cmd_wiki_update(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
def cmd_wiki_create(args: argparse.Namespace) -> None:
|
def cmd_wiki_create(args: argparse.Namespace) -> None:
|
||||||
session, _ = get_session()
|
session, _ = get_session()
|
||||||
|
container_id = resolve_container(session, args.container_id)
|
||||||
content = read_file(args.file) if args.file else ""
|
content = read_file(args.file) if args.file else ""
|
||||||
|
|
||||||
wiki_page: dict = {"title": args.title}
|
wiki_page: dict = {"title": args.title}
|
||||||
@@ -140,7 +168,7 @@ def cmd_wiki_create(args: argparse.Namespace) -> None:
|
|||||||
if topics:
|
if topics:
|
||||||
payload["content"] = {"topics": topics}
|
payload["content"] = {"topics": topics}
|
||||||
|
|
||||||
data = api_post(session, f"/wiki/container/{args.container_id}", payload)
|
data = api_post(session, f"/wiki/container/{container_id}", payload)
|
||||||
page_id = data.get("id")
|
page_id = data.get("id")
|
||||||
url = data.get("content", {}).get("metadata", {}).get("url", "")
|
url = data.get("content", {}).get("metadata", {}).get("url", "")
|
||||||
print(f"Erstellt: '{data.get('title')}' (ID {page_id})")
|
print(f"Erstellt: '{data.get('title')}' (ID {page_id})")
|
||||||
@@ -152,7 +180,8 @@ def cmd_wiki_create(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
def cmd_post_list(args: argparse.Namespace) -> None:
|
def cmd_post_list(args: argparse.Namespace) -> None:
|
||||||
session, _ = get_session()
|
session, _ = get_session()
|
||||||
data = api_get(session, f"/post/container/{args.container_id}?limit={args.limit}")
|
container_id = resolve_container(session, args.container_id)
|
||||||
|
data = api_get(session, f"/post/container/{container_id}?limit={args.limit}")
|
||||||
results = data.get("results", [])
|
results = data.get("results", [])
|
||||||
if not results:
|
if not results:
|
||||||
print("(keine Posts)")
|
print("(keine Posts)")
|
||||||
@@ -167,6 +196,7 @@ def cmd_post_list(args: argparse.Namespace) -> None:
|
|||||||
|
|
||||||
def cmd_post_create(args: argparse.Namespace) -> None:
|
def cmd_post_create(args: argparse.Namespace) -> None:
|
||||||
session, _ = get_session()
|
session, _ = get_session()
|
||||||
|
container_id = resolve_container(session, args.container_id)
|
||||||
message = read_file(args.file)
|
message = read_file(args.file)
|
||||||
topics = parse_topics(args.topics)
|
topics = parse_topics(args.topics)
|
||||||
|
|
||||||
@@ -174,7 +204,7 @@ def cmd_post_create(args: argparse.Namespace) -> None:
|
|||||||
if topics:
|
if topics:
|
||||||
payload["data"]["content"] = {"topics": topics}
|
payload["data"]["content"] = {"topics": topics}
|
||||||
|
|
||||||
data = api_post(session, f"/post/container/{args.container_id}", payload)
|
data = api_post(session, f"/post/container/{container_id}", payload)
|
||||||
pid = data.get("id")
|
pid = data.get("id")
|
||||||
saved_topics = [t["name"] for t in data.get("content", {}).get("topics", [])]
|
saved_topics = [t["name"] for t in data.get("content", {}).get("topics", [])]
|
||||||
print(f"Post erstellt (ID {pid})")
|
print(f"Post erstellt (ID {pid})")
|
||||||
@@ -199,12 +229,137 @@ def cmd_post_update(args: argparse.Namespace) -> None:
|
|||||||
print(f"Topics: {', '.join(saved_topics)}")
|
print(f"Topics: {', '.join(saved_topics)}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_space_list(args: argparse.Namespace) -> None:
|
||||||
|
session, _ = get_session()
|
||||||
|
page = 1
|
||||||
|
found = False
|
||||||
|
while True:
|
||||||
|
data = api_get(session, f"/space?limit=50&page={page}")
|
||||||
|
results = data.get("results", [])
|
||||||
|
if not results:
|
||||||
|
break
|
||||||
|
for space in results:
|
||||||
|
slug = space.get("url", "").rstrip("/").split("/s/")[-1].rstrip("/")
|
||||||
|
ccid = space.get("contentcontainer_id", "?")
|
||||||
|
name = space.get("name", "")
|
||||||
|
print(f"ccid={ccid:>4} slug={slug:<30} {name}")
|
||||||
|
found = True
|
||||||
|
if len(results) < 50:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
if not found:
|
||||||
|
print("(keine Spaces gefunden)")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fetch (perma-/content-id-agnostisch) ─────────────────────────────────────
|
||||||
|
|
||||||
|
def parse_content_ref(ref: str) -> int:
|
||||||
|
"""Akzeptiert eine blanke Content-ID oder einen Perma-Link
|
||||||
|
(…/content/perma?id=NNNN) und liefert die Content-ID."""
|
||||||
|
ref = ref.strip()
|
||||||
|
if ref.isdigit():
|
||||||
|
return int(ref)
|
||||||
|
qs = parse_qs(urlparse(ref).query)
|
||||||
|
if "id" in qs and qs["id"][0].isdigit():
|
||||||
|
return int(qs["id"][0])
|
||||||
|
sys.exit(f"Error: keine Content-ID erkennbar in '{ref}'")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(text: str) -> str:
|
||||||
|
text = text.lower()
|
||||||
|
for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")):
|
||||||
|
text = text.replace(a, b)
|
||||||
|
text = re.sub(r"[^a-z0-9]+", "-", text).strip("-")
|
||||||
|
return text[:60] or "inhalt"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_content(session: requests.Session, content_id: int) -> dict:
|
||||||
|
"""Löst eine Content-ID auf den zugrundeliegenden Datensatz auf
|
||||||
|
(WikiPage oder Post) und liefert Titel, Inhalt und Metadaten."""
|
||||||
|
envelope = api_get(session, f"/content/{content_id}")
|
||||||
|
meta = envelope.get("metadata", {})
|
||||||
|
model = meta.get("object_model", "")
|
||||||
|
object_id = meta.get("object_id")
|
||||||
|
page_url = meta.get("url", "")
|
||||||
|
topics = [t["name"] for t in envelope.get("topics", [])]
|
||||||
|
updated_at = meta.get("updated_at", "")
|
||||||
|
|
||||||
|
if model.endswith("WikiPage"):
|
||||||
|
data = api_get(session, f"/wiki/page/{object_id}")
|
||||||
|
title = data.get("title", "") or f"Wiki-Seite {object_id}"
|
||||||
|
content = data.get("latest_revision", {}).get("content", "")
|
||||||
|
kind = f"WikiPage (page {object_id})"
|
||||||
|
seg = page_url.rstrip("/").split("/")[-1]
|
||||||
|
slug = seg if seg and not seg.isdigit() else slugify(title)
|
||||||
|
elif model.endswith("Post"):
|
||||||
|
data = api_get(session, f"/post/{object_id}")
|
||||||
|
content = data.get("data", {}).get("message") or data.get("message", "")
|
||||||
|
first = content.splitlines()[0].lstrip("# ").strip() if content else ""
|
||||||
|
title = first or f"Post {object_id}"
|
||||||
|
kind = f"Post (id {object_id})"
|
||||||
|
slug = slugify(title)
|
||||||
|
else:
|
||||||
|
sys.exit(f"Error: Objekttyp nicht unterstützt: {model or '(unbekannt)'}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"content_id": content_id,
|
||||||
|
"title": title,
|
||||||
|
"content": content,
|
||||||
|
"kind": kind,
|
||||||
|
"topics": topics,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"page_url": f"https://humhub.inmedias.it{page_url}" if page_url else "",
|
||||||
|
"slug": slug,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_resource(info: dict) -> str:
|
||||||
|
"""Inhalt mit Herkunfts-Kopf als Markdown-Kommentar."""
|
||||||
|
perma = f"https://humhub.inmedias.it/content/perma?id={info['content_id']}"
|
||||||
|
header = (
|
||||||
|
"<!--\n"
|
||||||
|
f"Quelle: {perma}\n"
|
||||||
|
f"Seite: {info['page_url']}\n"
|
||||||
|
f"Titel: {info['title']}\n"
|
||||||
|
f"Typ: {info['kind']}\n"
|
||||||
|
f"Topics: {', '.join(info['topics']) or '(keine)'}\n"
|
||||||
|
f"Stand: {info['updated_at']}\n"
|
||||||
|
f"Abgerufen: {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
|
||||||
|
"-->\n\n"
|
||||||
|
)
|
||||||
|
return header + info["content"].rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_fetch(args: argparse.Namespace) -> None:
|
||||||
|
session, _ = get_session()
|
||||||
|
content_id = parse_content_ref(args.ref)
|
||||||
|
info = resolve_content(session, content_id)
|
||||||
|
body = render_resource(info)
|
||||||
|
|
||||||
|
if args.save:
|
||||||
|
outdir = Path(args.out)
|
||||||
|
outdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = outdir / f"humhub-{content_id}-{info['slug']}.md"
|
||||||
|
path.write_text(body, encoding="utf-8")
|
||||||
|
print(f"Gespeichert: {path}")
|
||||||
|
print(f" {info['kind']} — '{info['title']}' ({len(info['content'])} Zeichen)")
|
||||||
|
else:
|
||||||
|
sys.stdout.write(body)
|
||||||
|
|
||||||
|
|
||||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
parser = argparse.ArgumentParser(description="HumHub REST API CLI")
|
parser = argparse.ArgumentParser(description="HumHub REST API CLI")
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
# fetch
|
||||||
|
p = sub.add_parser("fetch", help="Content-ID/Perma-Link auflösen und Inhalt holen (WikiPage oder Post)")
|
||||||
|
p.add_argument("ref", help="Content-ID oder Perma-Link (…/content/perma?id=NNNN)")
|
||||||
|
p.add_argument("--save", action="store_true", help="In Datei ablegen statt auf stdout")
|
||||||
|
p.add_argument("--out", default="resources", help="Zielverzeichnis für --save (Standard: resources/)")
|
||||||
|
p.set_defaults(func=cmd_fetch)
|
||||||
|
|
||||||
# wiki-list
|
# wiki-list
|
||||||
p = sub.add_parser("wiki-list", help="Alle Wiki-Seiten auflisten")
|
p = sub.add_parser("wiki-list", help="Alle Wiki-Seiten auflisten")
|
||||||
p.add_argument("filter", nargs="?", help="Filter nach Titel (case-insensitiv)")
|
p.add_argument("filter", nargs="?", help="Filter nach Titel (case-insensitiv)")
|
||||||
@@ -224,9 +379,13 @@ def main() -> None:
|
|||||||
p.add_argument("--parent-id", type=int, default=None, dest="parent_id", help="ID der Oberseite")
|
p.add_argument("--parent-id", type=int, default=None, dest="parent_id", help="ID der Oberseite")
|
||||||
p.set_defaults(func=cmd_wiki_update)
|
p.set_defaults(func=cmd_wiki_update)
|
||||||
|
|
||||||
|
# space-list
|
||||||
|
p = sub.add_parser("space-list", help="Spaces auflisten mit contentcontainer_id")
|
||||||
|
p.set_defaults(func=cmd_space_list)
|
||||||
|
|
||||||
# wiki-create
|
# wiki-create
|
||||||
p = sub.add_parser("wiki-create", help="Neue Wiki-Seite anlegen")
|
p = sub.add_parser("wiki-create", help="Neue Wiki-Seite anlegen")
|
||||||
p.add_argument("container_id", type=int, help="Container-ID (z.B. 37 für user serge)")
|
p.add_argument("container_id", help="Space-Slug, URL oder numerische Container-ID")
|
||||||
p.add_argument("title", help="Seitentitel")
|
p.add_argument("title", help="Seitentitel")
|
||||||
p.add_argument("file", nargs="?", default=None, help="Markdown-Datei oder '-' für stdin (optional)")
|
p.add_argument("file", nargs="?", default=None, help="Markdown-Datei oder '-' für stdin (optional)")
|
||||||
p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen")
|
p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen")
|
||||||
@@ -235,13 +394,13 @@ def main() -> None:
|
|||||||
|
|
||||||
# post-list
|
# post-list
|
||||||
p = sub.add_parser("post-list", help="Posts eines Containers auflisten")
|
p = sub.add_parser("post-list", help="Posts eines Containers auflisten")
|
||||||
p.add_argument("container_id", type=int, help="Container-ID")
|
p.add_argument("container_id", help="Space-Slug, URL oder numerische Container-ID")
|
||||||
p.add_argument("--limit", type=int, default=10, help="Max. Anzahl Posts (Standard: 10)")
|
p.add_argument("--limit", type=int, default=10, help="Max. Anzahl Posts (Standard: 10)")
|
||||||
p.set_defaults(func=cmd_post_list)
|
p.set_defaults(func=cmd_post_list)
|
||||||
|
|
||||||
# post-create
|
# post-create
|
||||||
p = sub.add_parser("post-create", help="Neuen Post erstellen")
|
p = sub.add_parser("post-create", help="Neuen Post erstellen")
|
||||||
p.add_argument("container_id", type=int, help="Container-ID")
|
p.add_argument("container_id", help="Space-Slug, URL oder numerische Container-ID")
|
||||||
p.add_argument("file", help="Markdown-Datei oder '-' für stdin")
|
p.add_argument("file", help="Markdown-Datei oder '-' für stdin")
|
||||||
p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen")
|
p.add_argument("--topics", default=None, help="Komma-getrennte Topic-Namen")
|
||||||
p.set_defaults(func=cmd_post_create)
|
p.set_defaults(func=cmd_post_create)
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "mediawiki",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "MediaWiki-Seiten/Templates lesen, rendern und schreiben via wiki.py (fetch/update/render/search, Instanzen wiki & devwiki).",
|
||||||
|
"author": {
|
||||||
|
"name": "inmedias.it"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Zugangsdaten fuer wiki.py — kopieren nach ~/.config/dokumentierer/.env
|
||||||
|
# oder .env im Arbeitsverzeichnis, oder als Umgebungsvariablen setzen.
|
||||||
|
# NICHT committen. Am besten ein eigener Bot-/Service-Account mit Schreibrechten.
|
||||||
|
|
||||||
|
# Prod-Instanz (wiki -> https://wiki.inmedias.it)
|
||||||
|
MEDIAWIKI_USER=botuser
|
||||||
|
MEDIAWIKI_PASSWORD=change-me
|
||||||
|
# MEDIAWIKI_URL=https://wiki.inmedias.it # nur falls abweichend
|
||||||
|
|
||||||
|
# Test-Instanz (devwiki -> https://devwiki.inmedias.it)
|
||||||
|
# Faellt auf MEDIAWIKI_USER/PASSWORD zurueck, wenn nicht gesetzt.
|
||||||
|
DEVMEDIAWIKI_USER=botuser
|
||||||
|
DEVMEDIAWIKI_PASSWORD=change-me
|
||||||
|
# DEVMEDIAWIKI_URL=https://devwiki.inmedias.it # nur falls abweichend
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: mediawiki
|
||||||
|
description: MediaWiki-Seiten und -Templates lesen, rendern und schreiben. Use when editing/fetching wiki pages (wiki.inmedias.it, devwiki.inmedias.it) or when the user mentions "wiki", "Vorlage/Template", "Kundenseite".
|
||||||
|
version: 0.1.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# MediaWiki (wiki.py)
|
||||||
|
|
||||||
|
Liest, rendert und schreibt MediaWiki-Seiten/Templates über die mitgelieferte
|
||||||
|
CLI `wiki.py` (mwclient + Login). Unterstützt mehrere Instanzen.
|
||||||
|
|
||||||
|
## Grundregel
|
||||||
|
|
||||||
|
Wiki-Seiten/Templates werden **ausschließlich** über `wiki.py` (oder ein
|
||||||
|
MediaWiki-MCP) bearbeitet — **niemals** per `ssh <host> php .../maintenance/edit.php`
|
||||||
|
oder anderen direkten Server-Eingriffen. Reine Lese-/Render-Checks
|
||||||
|
(`curl ...?action=render`) sind ok; nur das **Schreiben** läuft über `wiki.py`.
|
||||||
|
|
||||||
|
## Ausführen
|
||||||
|
|
||||||
|
`wiki.py` ist ein eigenständiges `uv run --script` (Inline-Deps: `mwclient`,
|
||||||
|
`python-dotenv`). Aus dem Skill-Verzeichnis ausführen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./wiki.py -w <instanz> <befehl> ...
|
||||||
|
```
|
||||||
|
|
||||||
|
`-w` / `--wiki` wählt die Instanz (Default: `wiki`):
|
||||||
|
- `wiki` → https://wiki.inmedias.it (Prod)
|
||||||
|
- `devwiki` → https://devwiki.inmedias.it (Test)
|
||||||
|
|
||||||
|
### Befehle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./wiki.py -w devwiki fetch "Kunde:Beispiel GmbH" # Wikitext -> stdout
|
||||||
|
./wiki.py -w devwiki render "Kunde:Beispiel GmbH" # gerendertes HTML -> stdout
|
||||||
|
./wiki.py -w devwiki search "Suchbegriff" [-n <namespace>] # Titelsuche
|
||||||
|
./wiki.py -w devwiki update "Kunde:Beispiel GmbH" datei.wiki --summary "..." # speichern
|
||||||
|
./wiki.py -w wiki update "Titel" - --summary "..." # Inhalt von stdin
|
||||||
|
```
|
||||||
|
|
||||||
|
Tipp: Beim Schreiben den Inhalt aus einer Datei (`datei.wiki`) oder `-` (stdin)
|
||||||
|
übergeben — nicht als langes Shell-Argument (Sonderzeichen/Backticks!).
|
||||||
|
|
||||||
|
## Zugangsdaten
|
||||||
|
|
||||||
|
`wiki.py` bringt **keine** Zugangsdaten mit. Jede/r hinterlegt eigene Creds als
|
||||||
|
Umgebungsvariablen oder in einer `.env` (Reihenfolge: `~/.config/dokumentierer/.env`,
|
||||||
|
dann `.env` im aktuellen Verzeichnis, dann bereits gesetzte Env-Variablen). Siehe
|
||||||
|
`.env.example`:
|
||||||
|
|
||||||
|
- `MEDIAWIKI_USER`, `MEDIAWIKI_PASSWORD` — für die Prod-Instanz (`wiki`)
|
||||||
|
- `DEVMEDIAWIKI_USER`, `DEVMEDIAWIKI_PASSWORD` — für `devwiki` (fällt auf die
|
||||||
|
`MEDIAWIKI_*`-Werte zurück, wenn nicht gesetzt)
|
||||||
|
- optional `MEDIAWIKI_URL` / `DEVMEDIAWIKI_URL` — nur nötig, wenn die
|
||||||
|
Default-URLs abweichen
|
||||||
|
|
||||||
|
Es empfiehlt sich ein eigener Bot-/Service-Account mit den nötigen Schreibrechten.
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
- `search` liefert Titel; `render` ist nützlich, um vor/nach dem Schreiben das
|
||||||
|
Ergebnis zu prüfen, ohne die Seite im Browser zu öffnen.
|
||||||
|
- Für neue Instanzen: `WIKI_ALIASES` oben in `wiki.py` erweitern.
|
||||||
Executable
+181
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# requires-python = ">=3.11"
|
||||||
|
# dependencies = [
|
||||||
|
# "mwclient",
|
||||||
|
# "python-dotenv",
|
||||||
|
# ]
|
||||||
|
# ///
|
||||||
|
"""CLI for fetching and updating MediaWiki pages."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import mwclient
|
||||||
|
|
||||||
|
|
||||||
|
WIKI_ALIASES = {
|
||||||
|
"wiki": ("MEDIAWIKI_URL", "https://wiki.inmedias.it"),
|
||||||
|
"devwiki": ("DEVMEDIAWIKI_URL", "https://devwiki.inmedias.it"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_site(wiki: str = "wiki") -> mwclient.Site:
|
||||||
|
load_dotenv(Path.home() / ".config/dokumentierer/.env")
|
||||||
|
load_dotenv() # current dir, doesn't override already-set vars
|
||||||
|
|
||||||
|
if wiki not in WIKI_ALIASES:
|
||||||
|
sys.exit(f"Error: unknown wiki '{wiki}'. Known: {', '.join(WIKI_ALIASES)}")
|
||||||
|
|
||||||
|
url_env, url_default = WIKI_ALIASES[wiki]
|
||||||
|
url = os.environ.get(url_env, url_default).rstrip("/")
|
||||||
|
|
||||||
|
prefix = "DEVMEDIAWIKI_" if wiki == "devwiki" else "MEDIAWIKI_"
|
||||||
|
user = os.environ.get(f"{prefix}USER") or os.environ.get("MEDIAWIKI_USER", "")
|
||||||
|
password = os.environ.get(f"{prefix}PASSWORD") or os.environ.get("MEDIAWIKI_PASSWORD", "")
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
sys.exit(f"Error: MEDIAWIKI_USER is not set in .env")
|
||||||
|
if not password:
|
||||||
|
sys.exit(f"Error: MEDIAWIKI_PASSWORD is not set in .env")
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
host = parsed.netloc
|
||||||
|
# mwclient expects path to end with '/'
|
||||||
|
path = (parsed.path or "/") + "/"
|
||||||
|
path = path.replace("//", "/")
|
||||||
|
scheme = parsed.scheme or "https"
|
||||||
|
|
||||||
|
site = mwclient.Site(host, path=path, scheme=scheme)
|
||||||
|
site.login(user, password)
|
||||||
|
return site
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_fetch(args: argparse.Namespace) -> None:
|
||||||
|
site = get_site(args.wiki)
|
||||||
|
page = site.pages[args.title]
|
||||||
|
if not page.exists:
|
||||||
|
sys.exit(f"Error: page '{args.title}' does not exist")
|
||||||
|
print(page.text(), end="")
|
||||||
|
|
||||||
|
|
||||||
|
NAMESPACES = {
|
||||||
|
"main": 0, "talk": 1, "user": 2, "project": 4,
|
||||||
|
"file": 6, "template": 10, "category": 14,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_html(text: str) -> str:
|
||||||
|
"""Remove HTML tags (used to clean search snippets)."""
|
||||||
|
import re
|
||||||
|
return re.sub(r"<[^>]+>", "", text)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_search(args: argparse.Namespace) -> None:
|
||||||
|
site = get_site(args.wiki)
|
||||||
|
ns_key = args.namespace
|
||||||
|
if ns_key is None or ns_key.lower() == "all":
|
||||||
|
ns = "*"
|
||||||
|
elif ns_key.isdigit():
|
||||||
|
ns = int(ns_key)
|
||||||
|
else:
|
||||||
|
ns = NAMESPACES.get(ns_key.lower())
|
||||||
|
if ns is None:
|
||||||
|
sys.exit(f"Error: unknown namespace '{ns_key}'. "
|
||||||
|
f"Known: all, {', '.join(NAMESPACES)}")
|
||||||
|
|
||||||
|
result = site.api(
|
||||||
|
"query", list="search",
|
||||||
|
srsearch=args.query,
|
||||||
|
srnamespace=ns,
|
||||||
|
srlimit=50,
|
||||||
|
srprop="title|snippet",
|
||||||
|
)
|
||||||
|
hits = result["query"]["search"]
|
||||||
|
if not hits:
|
||||||
|
print("(keine Ergebnisse)", file=sys.stderr)
|
||||||
|
for hit in hits:
|
||||||
|
print(hit["title"])
|
||||||
|
if hit.get("snippet"):
|
||||||
|
print(f" {_strip_html(hit['snippet']).strip()}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_render(args: argparse.Namespace) -> None:
|
||||||
|
site = get_site(args.wiki)
|
||||||
|
result = site.api("parse", page=args.title, prop="text")
|
||||||
|
print(result["parse"]["text"]["*"], end="")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_upload(args: argparse.Namespace) -> None:
|
||||||
|
path = Path(args.file_path)
|
||||||
|
if not path.exists():
|
||||||
|
sys.exit(f"Error: file not found: {path}")
|
||||||
|
site = get_site(args.wiki)
|
||||||
|
with path.open("rb") as fh:
|
||||||
|
site.upload(fh, filename=args.wiki_filename,
|
||||||
|
description=args.description or "", ignore=True)
|
||||||
|
print(f"Uploaded '{args.wiki_filename}'")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_update(args: argparse.Namespace) -> None:
|
||||||
|
if args.file == "-":
|
||||||
|
content = sys.stdin.read()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
with open(args.file, "r", encoding="utf-8") as fh:
|
||||||
|
content = fh.read()
|
||||||
|
except OSError as exc:
|
||||||
|
sys.exit(f"Error reading file: {exc}")
|
||||||
|
|
||||||
|
site = get_site(args.wiki)
|
||||||
|
page = site.pages[args.title]
|
||||||
|
page.save(content, summary=args.summary or "")
|
||||||
|
print(f"Saved '{args.title}'")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="MediaWiki page fetch/update tool")
|
||||||
|
parser.add_argument(
|
||||||
|
"--wiki", "-w",
|
||||||
|
choices=list(WIKI_ALIASES),
|
||||||
|
default="wiki",
|
||||||
|
help="Wiki-Instanz (Standard: wiki = wiki.inmedias.it)",
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
fetch_p = sub.add_parser("fetch", help="Print page wikitext to stdout")
|
||||||
|
fetch_p.add_argument("title", help="Page title")
|
||||||
|
fetch_p.set_defaults(func=cmd_fetch)
|
||||||
|
|
||||||
|
search_p = sub.add_parser("search", help="Search for pages by title")
|
||||||
|
search_p.add_argument("query", help="Search term")
|
||||||
|
search_p.add_argument("--namespace", "-n", default=None,
|
||||||
|
help="Namespace filter: all (default), main, template, category, file, ... or numeric ID")
|
||||||
|
search_p.set_defaults(func=cmd_search)
|
||||||
|
|
||||||
|
render_p = sub.add_parser("render", help="Print rendered HTML of a page to stdout")
|
||||||
|
render_p.add_argument("title", help="Page title")
|
||||||
|
render_p.set_defaults(func=cmd_render)
|
||||||
|
|
||||||
|
upload_p = sub.add_parser("upload", help="Upload a local file to the wiki file store")
|
||||||
|
upload_p.add_argument("file_path", help="Absolute path to the local file")
|
||||||
|
upload_p.add_argument("wiki_filename", help="Target filename on the wiki (without 'File:' prefix)")
|
||||||
|
upload_p.add_argument("--description", default="", help="Wikitext for the file description page")
|
||||||
|
upload_p.set_defaults(func=cmd_upload)
|
||||||
|
|
||||||
|
update_p = sub.add_parser("update", help="Save wikitext to a page")
|
||||||
|
update_p.add_argument("title", help="Page title")
|
||||||
|
update_p.add_argument("file", help="File containing wikitext, or '-' for stdin")
|
||||||
|
update_p.add_argument("--summary", default="", help="Edit summary")
|
||||||
|
update_p.set_defaults(func=cmd_update)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user