initial commit

This commit is contained in:
Tom 2026-10-27 08:33:21 +01:00
commit b42f9261a5
257 changed files with 39080 additions and 0 deletions

54
.gitignore vendored Normal file
View file

@ -0,0 +1,54 @@
# Hugin
#
# Nicht ignorieren: .claude/, .opencode/ und .hugin/ in contract/examples/bundles/*/files/
# (goldene Bundles) und in static/css-framework/ gehören ins Repo. COMPATIBILITY.md
# ebenfalls (Ergebnis von scripts/tool-smoke-test.sh).
# Betriebssystem und Editoren
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
*~
# Lokale Einstellungen und Geheimnisse
.env
.env.*
.claude/settings.local.json
/.claude/
/.opencode/
/CLAUDE.local.md
# Python (server/)
__pycache__/
*.py[cod]
.venv/
*.egg-info/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
.coverage.*
htmlcov/
# Node; OpenCode legt .opencode/node_modules/ selbst an
node_modules/
# Go (cli/); dist/ kommt von scripts/build-cli.sh und go build -o dist/…
/cli/dist/
*.test
*.out
# Lokale Datenbanken und Logs (z. B. HUGIN_DATABASE_URL=sqlite:///hugin.db)
*.db
*.db-shm
*.db-wal
*.db-journal
*.log
# Demo-Daten mit Keys (scripts/demo.sh)
.demo/
# Bildschirmfotos der CSP-Prüfung (scripts/csp/web_browser.py <ordner>)
/screenshots/

95
BETRIEB.md Normal file
View file

@ -0,0 +1,95 @@
# Hugin betreiben
Für den Betrieb: Container-Image, Datenbank, Sicherung. Deployment und Ingress liegen im
Template des Betriebs. Hintergrund in [KONZEPT.md](KONZEPT.md) Abschnitt 18.
## Konfiguration
Nur über Umgebungsvariablen:
| Umgebungsvariable | Bedeutung |
|---|---|
| `HUGIN_DATABASE_URL` | SQLite oder PostgreSQL |
| `HUGIN_ADMIN_TOKEN` | Plattform-Admin |
| `HUGIN_BASE_URL` | steht im Kopf verwalteter Dateien |
| `HUGIN_DOWNLOAD_DIR` | gebaute CLI-Programme für die Seite „Programm hugin“ |
| `HUGIN_CLI_MIN_VERSION` | ältere CLIs meldet `hugin check` |
| `HUGIN_RATE_LIMIT` | Anfragen je Key und Minute (Standard 600) |
| `HUGIN_FAILED_ATTEMPTS` | falsche Keys je IP in 5 Minuten, danach 429 (Standard 20) |
| `HUGIN_PROXY_IPS` | Router, deren `X-Forwarded-For` gilt (Standard `127.0.0.1`) |
| `HUGIN_MAX_UPLOAD_MB` | größter Request-Body (Standard 40) |
**Hinter dem OpenShift-Router** `HUGIN_PROXY_IPS` auf das Netz des Routers
setzen (z. B. `10.128.0.0/14`). Sonst sehen Sperre und Audit nur die Adresse des
Routers, und wenige falsche Keys sperren alle Nutzer für fünf Minuten.
## Container-Image
Im Hugin-Repo liegt `server/Dockerfile`: Red Hat UBI 9 minimal mit Python 3.12, die
Abhängigkeiten fest aus `uv.lock`. Das Image läuft als beliebiger Benutzer der Gruppe 0,
wie OpenShift es vorgibt, und auch mit schreibgeschütztem Dateisystem.
```sh
docker build -t hugin server/
docker build --build-arg UBI=<spiegel>/ubi9/ubi-minimal:9.7 -t hugin server/ # eigener Spiegel
```
| Was | Wie |
|---|---|
| Server starten | ohne Argument (Standard `run`), Port 8080 |
| Migrationen | dasselbe Image mit Argument `migrate`, als Job vor jedem Rollout |
| Liveness-Probe | `GET /healthz` |
| Readiness-Probe | `GET /readyz`: Datenbank erreichbar und Migrationen aktuell; ohne `migrate` wird der Pod nicht bereit |
| Konfiguration | Umgebungsvariablen (oben); `HUGIN_ADMIN_TOKEN` als Secret |
| Logs | stdout |
## Datenbank: SQLite oder PostgreSQL
Beides funktioniert gleich; der Wechsel ist nur `HUGIN_DATABASE_URL`.
| | SQLite | PostgreSQL |
|---|---|---|
| `HUGIN_DATABASE_URL` | nicht setzen: das Image nutzt `/data/hugin.db` | `postgresql+psycopg://user:pw@host:5432/hugin` |
| Volume | PVC auf `/data`, auch im Migrations-Job | keins |
| Pods | genau einer, Strategie `Recreate` | beliebig viele |
| Beim Ausrollen | kurz nicht erreichbar | ohne Unterbrechung |
| Sicherung | selbst: z. B. CronJob mit `sqlite3 /data/hugin.db ".backup /sicherung/hugin.db"`; die Datei nicht einfach kopieren, solange Hugin schreibt | übernimmt der Datenbankbetrieb |
| Gut für | Pilot, wenige Teams | breiter Einsatz, Hochverfügbarkeit |
Fällt Hugin aus, laufen alle Agenten weiter, denn die Dateien liegen in den Repos; nur
`hugin sync` geht dann nicht. Kurze Unterbrechungen beim Ausrollen sind deshalb
unkritisch.
Von SQLite nach PostgreSQL umziehen: Dafür gibt es noch kein Werkzeug. Der Export
enthält nur freigegebene Pakete, keine Keys, Personen oder Audit-Einträge.
Mehrere Pods: Das Rate-Limit zählt je Pod; mit drei Pods gelten also bis zu dreimal so
viele Anfragen und Fehlversuche.
## Export und CLI-Downloads
- **Export:** `hugin-server export <ordner> --commit` schreibt alle freigegebenen Pakete
im Quellformat in einen Git-Ordner, z. B. als täglicher CronJob. Ergibt eine lesbare
Historie und eine Notfall-Verteilung. Dasselbe als ZIP auf der Systemseite.
`--commit` braucht `git`, das im Image fehlt; für den Betrieb ist es nicht nötig, die
Datenbank-Sicherung enthält alles.
- **CLI-Downloads:** `scripts/build-cli.sh <version> <ordner>` baut das Programm `hugin`
für macOS, Linux und Windows mit `SHA256SUMS`. Den Ordner einhängen und
`HUGIN_DOWNLOAD_DIR` darauf setzen; ohne ihn zeigt die Seite „Programm hugin“
„Noch keine Programme hinterlegt“.
## Clients mit lokalen MCP-Servern
Pakete mit lokalem MCP-Server (Python) installiert `hugin sync` auf jedem Rechner. Der
Server braucht dafür nichts Zusätzliches (Upload bis `HUGIN_MAX_UPLOAD_MB`, Pakete bis
32 MB). Auf den Rechnern (Windows, Linux) muss bereitstehen:
| Was | Wozu |
|---|---|
| `hugin` im `PATH` | Werkzeuge starten den Server über `hugin mcp run`; ein Alias reicht nicht |
| Python 3 (Windows: mit dem Starter `py`) | venv je Server; abweichend mit `HUGIN_PYTHON` |
| `pip.conf` (Linux `~/.config/pip/pip.conf` oder `/etc/pip.conf`, Windows `%APPDATA%\pip\pip.ini`) mit dem Artifactory als `index-url` | pip lädt die Abhängigkeiten daraus, ohne Anmeldung |
| Schreibrecht auf `~/.local/share/mcp` (Windows `%USERPROFILE%\.local\share\mcp`) | Ablage; abweichend mit `HUGIN_MCP_HOME` |
Speicherplatz: je Server und Version eine venv (mit dem MCP-SDK etwa 50 MB). Alte
Versionen löscht `hugin mcp prune`.

37
COMPATIBILITY.md Normal file
View file

@ -0,0 +1,37 @@
# Kompatibilität
Erzeugt von `scripts/tool-smoke-test.sh` am 25.10.2026 (→ KONZEPT.md 17.2).
Modelle: Claude Code `haiku`, OpenCode `Standard`. Installiert mit `hugin sync`.
## Rauchtests
| Paket | Prüfung | Claude Code 2.1.307 | OpenCode 1.19.4 |
|---|---|---|---|
| `pub-smoke-skill` | Skill wird gefunden | ✓ | ✓ |
| `pub-smoke-skill` | Skill wird geladen | ✓ | ✓ |
| `pub-smoke-rules` | Regeln wirken | ✓ | ✓ |
| `pub-smoke-command` | Befehl mit zwei Argumenten | ✓ | ✓ |
| `pub-smoke-agent` | Subagent vorhanden | ✓ | ✓ |
| `pub-smoke-agent` | Subagent wirkt | ✓ | ✓ |
| `pub-smoke-mcp` | MCP eingetragen | ✓ | ✓ |
| `pub-smoke-mcp` | MCP verbunden | ✓ | ✓ |
| `pub-zeit-lokal` | Lokaler Server eingetragen | ✓ | – |
| `pub-zeit-lokal` | Lokaler Server verbunden | – | ✓ |
| `pub-zeit-lokal` | Lokaler Server antwortet | ✓ | ✓ |
| `pub-specht-plugin` | Plugin geladen | – | ✓ |
| `pub-specht-plugin` | Plugin wirkt | – | ✓ |
| `pub-smoke-command` | Installieren auf Bitte | ✓ | ✓ |
| `pub-smoke-command` | Ohne hugin nichts schreiben | ✓ | ✓ |
## Stufe 0
| Frage | Aufbau | erkannte Wörter | Folgerung |
|---|---|---|---|
| 4 | OpenCode, nur `CLAUDE.md` mit `@`-Verweis, keine `AGENTS.md`, keine `instructions` | DACHS-2207 | liest `CLAUDE.md`, löst `@` nicht auf: keine Doppel |
| 5 | Claude Code, `CLAUDE.md` und `AGENTS.md` | LUCHS-3318 | liest nur `CLAUDE.md` |
| autoinvoke | Claude Code 2.1.307, Skill mit `disable-model-invocation: true` | selbst: –, per `/`: IGEL-8140 | wirkt: Skill nur per `/name` |
| autoinvoke | OpenCode 1.19.4, Skill mit `metadata: opencode/autoinvoke: "false"` | selbst: IGEL-8140, per `/`: IGEL-8140 | wirkt **nicht**: das Modell lädt den Skill selbst |
| 8 | Claude Code 2.1.307, Befehl mit `arguments` und `$name` | – | benannte Argumente wirken |
✓ bestanden · ✗ fehlgeschlagen · – nicht geprüft (kein Modellzugang).
Modellantworten schwanken; ein einzelnes ✗ mit `KEEP=1` wiederholen und die Logs ansehen.

1909
KONZEPT.md Normal file

File diff suppressed because it is too large Load diff

165
README.md Normal file
View file

@ -0,0 +1,165 @@
# Hugin
Register für Skills, Regeln, Befehle, Subagenten und MCP-Anbindungen für
Claude Code und OpenCode. Konzept: [KONZEPT.md](KONZEPT.md).
| Ordner | Inhalt |
|---|---|
| `contract/` | Bundle-Format (JSON-Schema), Beispielpakete, goldene Bundles |
| `server/` | Python: `hugin_core` (Quellformat, Linter, Übersetzer, Auflösung, Bundles) und `hugin_app` (FastAPI, Datenbank, API, Weboberfläche) |
| `cli/` | Go: Programm `hugin` — Teams: `init`, `sync`, `check`, `status`, `search`, `login`; Autoren: `new`, `validate`, `try`, `upload`, `import` |
| `scripts/` | siehe „Skripte“ unten |
| `static/css-framework/` | CSS-Framework für die Weboberfläche |
| `BETRIEB.md` | Container-Image, SQLite oder PostgreSQL, Sicherung |
| `TESTEN.md` | Schritt für Schritt lokal testen: Demo, Test-Repo, Autorenablauf, alle Testskripte |
| `STUFE0.md` | Ergebnisse der Verhaltensprüfungen von Claude Code und OpenCode |
| `COMPATIBILITY.md` | Ergebnis des letzten Rauchtests mit den echten Werkzeugen (`scripts/tool-smoke-test.sh`) |
## Starten und ausprobieren
Voraussetzungen: `uv`, Go ≥ 1.26, `zip`, `curl`. Für die Browser-Prüfungen Google Chrome.
```sh
scripts/demo.sh # Server mit Beispieldaten auf http://127.0.0.1:8080; Keys stehen in .demo/keys.txt
```
In der Weboberfläche mit einem der Keys anmelden, z. B. als Bert (Prüfer): unter „Prüfungen“ wartet
`pub-munin-nutzen 1.5.0`. Als Anna (Autorin) hochladen, als Shop-Admin Personen und Keys anlegen,
als Plattform-Admin (`demo-admin`) die Systemseite.
Mit dem CLI ein Repo abgleichen:
```sh
(cd cli && go build -o dist/hugin ./cmd/hugin)
cd /path/to/test-repo
export HUGIN_KEY=<Key „Shop CI“ aus .demo/keys.txt>
<hugin>/cli/dist/hugin init --server http://127.0.0.1:8080 --project api
# in .hugin.yaml unter packages: z. B. - pub-munin-nutzen und - pub-release-notiz
<hugin>/cli/dist/hugin sync
```
MCP in Claude Code oder OpenCode: Server `http://127.0.0.1:8080/mcp` mit Kopfzeile
`Authorization: Bearer <Projekt-Key>`.
## Testen
```sh
cd server && uv run pytest # Server: Kern, API, Web, MCP, Stufe 4, Isolation der Tenants (SQLite)
scripts/postgres-test.sh # dieselbe Suite gegen PostgreSQL in Docker
cd server && uv run pytest --update # goldene Bundles neu schreiben (nach gewollter Änderung)
cd cli && go test ./... # CLI, wendet die goldenen Bundles an
```
Erst die Server-Tests, dann die CLI-Tests: Die CLI testet gegen die Bundles,
die der Server erzeugt.
Vor einem Release und nach größeren Updates von Claude Code oder OpenCode zusätzlich
mit den echten Werkzeugen (kostet Tokens, braucht für beide einen Modellzugang):
```sh
scripts/stage0.sh # Verhalten von OpenCode, ohne Modellaufruf
OPENCODE_MODEL=<anbieter/modell> scripts/tool-smoke-test.sh # schreibt COMPATIBILITY.md
```
## Stand
Umgesetzt sind die Stufen 0 bis 4 aus [KONZEPT.md](KONZEPT.md) (Abschnitt 20). Offen:
- Stufe 5: lokale MCP-Server mit Python und OpenCode-Plugins (ohne npm-Abhängigkeiten)
sind umgesetzt (Windows von Hand ungeprüft, `TESTEN.md` 5.11); offen sind fertige
Programme als lokale Server, npm-Abhängigkeiten für Plugins, Hooks (Claude Code),
Paare, Signaturen
- OpenCode 2 ist ungeprüft (STUFE0.md, Frage 10)
- Untergrenzen der Werkzeuge sind vorläufig: geprüft sind nur aktuelle Versionen
- `hugin-install.ps1` ist mit PowerShell 7 getestet, unter Windows PowerShell 5.1 noch nicht
## CLI bauen und ohne Server ausprobieren
```sh
cd cli && go build -o dist/hugin ./cmd/hugin
cd /path/zum/test-repo
hugin init --server https://hugin.intern --project shop
hugin sync --bundle <hugin>/contract/examples/bundles/first-install
hugin check --bundle <hugin>/contract/examples/bundles/first-install
```
## Server lokal starten
```sh
cd server
export HUGIN_DATABASE_URL=sqlite:///hugin.db HUGIN_ADMIN_TOKEN=geheim
uv run hugin-server migrate
uv run hugin-server run --port 8080 # API-Doku unter http://localhost:8080/docs
```
Erste Schritte mit dem Admin-Token: Tenant anlegen (`POST /api/v1/admin/tenants`),
Projekt (`…/tenants/{slug}/projects`), Keys (`…/tenants/{slug}/keys`).
`scripts/smoke-test-local.sh` macht genau das und gleicht danach ein Test-Repo ab.
| Umgebungsvariable | Bedeutung |
|---|---|
| `HUGIN_DATABASE_URL` | z. B. `sqlite:///hugin.db` oder `postgresql+psycopg://…` |
| `HUGIN_ADMIN_TOKEN` | Plattform-Admin |
| `HUGIN_BASE_URL` | steht im Kopf verwalteter Dateien |
| `HUGIN_DOWNLOAD_DIR` | Ordner mit gebauten CLI-Programmen (`scripts/build-cli.sh`), für die Seite „Programm hugin“ |
| `HUGIN_CLI_MIN_VERSION` | ältere CLIs meldet `hugin check` als Befund (Standard 0.1.0) |
| `HUGIN_RATE_LIMIT` | Anfragen je Key und Minute (Standard 600) |
| `HUGIN_FAILED_ATTEMPTS` | falsche Keys je IP in 5 Minuten, danach 429 (Standard 20) |
| `HUGIN_PROXY_IPS` | Router/Ingress, deren `X-Forwarded-For` gilt (Standard `127.0.0.1`; OpenShift: Router-Netz, z. B. `10.128.0.0/14`) |
| `HUGIN_MAX_UPLOAD_MB` | größter Request-Body, vor der Anmeldung geprüft (Standard 40) |
Die Weboberfläche liegt unter `/` (Anmeldung mit einem Key), die Online-Hilfe unter `/help` (ohne Anmeldung), die API-Doku unter `/api/docs`.
Mit PostgreSQL statt SQLite: `docker compose up -d`, dann
`HUGIN_DATABASE_URL=postgresql+psycopg://hugin:hugin@127.0.0.1:55432/hugin scripts/demo.sh`.
## Container-Image
`server/Dockerfile` baut auf Red Hat UBI 9 minimal mit Python 3.12 (ca. 90 MB), die
Abhängigkeiten kommen fest aus `uv.lock`. Das Image läuft als beliebiger Benutzer der
Gruppe 0 (OpenShift) und auch mit schreibgeschütztem Dateisystem.
```sh
docker build -t hugin server/
docker run --rm -e HUGIN_DATABASE_URL=… hugin migrate # Migrations-Job vor dem Rollout
docker run -p 8080:8080 -e HUGIN_DATABASE_URL=… -e HUGIN_ADMIN_TOKEN=… hugin # Server (CMD run)
```
Probes: `/healthz` (lebt), `/readyz` (Datenbank erreichbar, Migrationen aktuell), Port 8080.
Ohne `HUGIN_DATABASE_URL` nutzt das Image SQLite unter `/data/hugin.db`: dort ein Volume
einhängen, nur ein Pod, Strategie `Recreate`. Mit PostgreSQL braucht es kein Volume.
Ausführlich (SQLite oder PostgreSQL, Sicherung, Probes): [BETRIEB.md](BETRIEB.md).
## Skripte
| Skript | Zweck |
|---|---|
| `scripts/demo.sh` | Server mit Beispieldaten und Keys für alle Rollen |
| `scripts/integration-test.sh` | Auslieferung an Integrationen mit `hugin-install.ps1` (`PWSH=<pfad zu pwsh>`) |
| `scripts/integration-test.ps1` | dasselbe unter Windows PowerShell 5.1, gegen einen laufenden Hugin (siehe unten) |
| `scripts/postgres-test.sh [--compose]` | ganze Server-Testsuite gegen PostgreSQL 17.6 in Docker (Wegwerf-Container oder die aus `docker-compose.yml`) |
| `docker compose up -d` | dauerhafte PostgreSQL auf 127.0.0.1:55432 (Daten im Volume; `docker compose down -v` löscht sie) |
| `scripts/smoke-test-local.sh` | Server starten, Pakete veröffentlichen, mit dem CLI abgleichen; dazu der Ablauf für Autoren (`new`, `validate`, `try`, `upload`) |
| `scripts/stage0.sh` | Verhalten von OpenCode prüfen, ohne Modellaufruf (→ STUFE0.md) |
| `scripts/tool-smoke-test.sh` | Rauchtest mit echtem Claude Code und OpenCode, schreibt `COMPATIBILITY.md`; kostet Tokens (`TOOLS`, `CLAUDE_MODEL`, `OPENCODE_MODEL`, `KEEP=1`) |
| `scripts/csp-test.sh` | CSS-Framework unter strenger CSP: Verstöße zählen, alle `data-action` durchklicken (Chrome) |
| `uv run --no-project --with websockets --with httpx python scripts/csp/web_browser.py <ordner>` | Weboberfläche in Chrome: CSP-Verstöße je Seite, Bildschirmfotos hell und dunkel |
| `uv run --no-project --with websockets --with httpx python scripts/csp/web_actions.py` | Weboberfläche in Chrome bedienen: Live-Suche, Tabs, Kopieren, Rückfragen, Felder je Key-Art, Prüfungs-Dialog, Editor |
| `scripts/build-cli.sh <version> [download-ordner]` | CLI für macOS, Linux, Windows bauen, mit SHA256SUMS |
| `uv run hugin-server export <ordner> [--commit]` | freigegebene Pakete im Quellformat exportieren (in `server/`) |
| `scripts/sync-framework.sh` | gebautes CSS-Framework nach `server/src/hugin_app/static/` kopieren |
## Integration unter Windows PowerShell 5.1 testen
1. Auf dem Mac oder Linux-Rechner: `HOST=0.0.0.0 scripts/demo.sh`
2. `scripts/integration-test.ps1` auf den Windows-Rechner kopieren.
3. Dort in Windows PowerShell 5.1 (nicht `pwsh`):
```powershell
powershell -ExecutionPolicy Bypass -File integration-test.ps1 `
-Server http://<rechner>:8080 -Key <Key „Softwareverteilung“ aus .demo/keys.txt>
```
Das Skript lädt `hugin-install.ps1` vom Server, installiert in einen Temp-Ordner,
prüft Encoding, Zeilenenden, JSON, zweiten Lauf, Abbestellen, Handänderung und
JSONC und meldet je Prüfung OK oder FEHLER. Es ändert nichts außerhalb des Temp-Ordners.

50
STUFE0.md Normal file
View file

@ -0,0 +1,50 @@
# Stufe 0: Ergebnisse
Verhaltensfragen aus KONZEPT.md 20, Stufe 0: Wie lesen Claude Code und OpenCode die
Dateien, die Hugin schreibt? Stand 19.10.2026.
| Werkzeug | Version |
|---|---|
| Claude Code | 2.1.303 (Changelog-Auswertung mit 2.1.302) |
| OpenCode | 1.19.3; Fragen 1–3, 6, 7 zuerst mit 1.15.4, gleiches Ergebnis. OpenCode 2 nicht installiert |
**So geprüft** (Spalte „Wie“):
- **S0** – `scripts/stage0.sh`: nur `opencode debug …` und der Server von `opencode serve`, kein Modellaufruf, kostenlos.
- **RT** – `scripts/tool-smoke-test.sh`: echte Modellaufrufe (Claude Code mit `haiku`, OpenCode mit `openai/gpt-6-luna`). Ergebnis auch in `COMPATIBILITY.md`.
- **CL** – Changelog bzw. Release-Notes.
## Geklärt
| # | Frage | Ergebnis | Folge für Hugin | Wie |
|---|---|---|---|---|
| 1 | Unbekannte Felder in `SKILL.md` (`allowed-tools`, `paths`, `effort`)? | OpenCode übergeht sie still, der Skill lädt | Zusätze für Claude stehen in derselben Datei; der Skill bleibt `portable` (→ 7.1) | S0 |
| 2 | Gleicher Skill-Name in `.claude/skills/` und `.opencode/skills/`? | nur **eine** Fassung wird geladen, **welche, ist nicht verlässlich** | Hugin legt einen Skill nie an beiden Orten ab; `.opencode/skills/` nur bei `opencode_reads_claude: false` | S0 |
| 3 | Liest OpenCode `.claude/commands/` oder `.claude/agents/`? | nein, beide nicht | Befehle und Subagenten je Werkzeug am eigenen Ort, keine Doppel (→ 7.3, 7.4) | S0 |
| 3a | Liest OpenCode `.opencode/commands/` und `.opencode/agents/`? | ja; `permission` mit `deny` wirkt | Format der Übersetzer bestätigt | S0, RT |
| 4 | Liest OpenCode ohne `AGENTS.md` die `CLAUDE.md` und löst deren `@`-Verweise auf? | liest `CLAUDE.md`, löst `@` **nicht** auf | keine Doppel: Regeln kommen in OpenCode nur über `instructions` an; keine eigene `AGENTS.md` nötig | RT |
| 5 | Claude Code und `AGENTS.md` | liest `AGENTS.md` ab 2.1.277 nur ohne `CLAUDE.md`; mit beiden Dateien nur `CLAUDE.md` | unkritisch: Hugin legt `CLAUDE.md` an, Regeln kommen nicht doppelt | CL, RT |
| 6 | JSONC in `opencode.json`? | ja, Kommentare und Komma am Ende werden gelesen | das CLI erhält Kommentare beim Ändern (hujson) | S0 |
| 7 | Werkzeug- und Rechtenamen | OpenCode-Werkzeuge: `read`, `grep`, `glob`, `edit`, `write`, `bash`, `webfetch`, `websearch`, `task`, `skill`, `todowrite`, `question`; Rechte u. a. `edit`, `bash`, `webfetch`, `external_directory` | Fähigkeitsklasse `read` sperrt auch `websearch` (→ KONZEPT.md 7.4) | S0 |
| 8a | Benannte Argumente in Claude Code (`arguments`, `$name`)? | wirken in 2.1.303 | Übersetzer bestätigt; ab welcher Version, ist offen (siehe unten) | RT |
| 8b | Wirkt `metadata: opencode/slash`? | nein: jeder Skill ist ohnehin ein Slash-Befehl, auch mit `opencode/slash: "false"` | Feld nicht nötig; Linter-Hinweis H023 | S0 |
| 8c | Wirkt `metadata: opencode/autoinvoke: "false"`? | nein: das Modell lädt den Skill trotzdem selbst, auch mit `disable-model-invocation: true` in derselben Datei. Claude Code hält sich an `disable-model-invocation` | „nur von Hand“ geht in OpenCode 1 nicht; Linter-Hinweis H023, Ausweg `targets: [claude]` | RT |
| – | MCP: `type: remote`, `headers`, `{env:VAR}` | funktioniert; die Verbindung mit dem Key aus der Umgebungsvariable klappt | Übersetzer bestätigt. Ab 1.19 zeigt `debug config` Header nur maskiert | S0, RT |
| – | MCP in Claude Code aus `.mcp.json` | Server stehen auf „Pending approval“, bis jemand zustimmt; `settings.local.json` im Repo hilft dabei nicht | in der Hilfe für Teams beschrieben; der Rauchtest stimmt mit `--settings` zu | RT |
Wie die Slash-Liste geprüft wird: `opencode serve` starten und `GET /command`
abfragen. Das ist dieselbe Liste, die das TUI bei `/` anbietet.
## Offen
| # | Frage | So prüfen |
|---|---|---|
| 8a | Ab welcher Version kennt Claude Code benannte Argumente? Davon hängt die Untergrenze 2.1.105 ab | ältere Version installieren, dann `TOOLS=claude scripts/tool-smoke-test.sh` |
| 9 | Unterstützt OpenCode MCP-Prompts und -Ressourcen? | nur zur Information, Hugin baut nicht darauf |
| 10 | OpenCode 2: alte MCP-Form (`mcp.<name>`, `enabled`)? unbekannte Felder? Doppel bei Skills? `opencode/autoinvoke`? | `OPENCODE=<opencode-2> scripts/stage0.sh` und `OPENCODE=<opencode-2> OPENCODE_MODEL=… scripts/tool-smoke-test.sh` |
## Nebenbefund
Auf dem Entwicklungsrechner ist global ein OpenCode-Plugin `skill-registry`
installiert, das Skills, Agenten und Befehle von `http://localhost:8000`
synchronisieren will. Beide Skripte schalten es mit `--pure` ab.

354
TESTEN.md Normal file
View file

@ -0,0 +1,354 @@
# Hugin lokal testen
Schritt für Schritt: Demo-Server starten, Pakete in ein Test-Repo holen, als Autor ein
Paket ausprobieren, die automatischen Tests laufen lassen und die Funktionen der
Weboberfläche und des CLI durchgehen (Checkliste in Abschnitt 5).
Voraussetzungen: `uv`, Go ≥ 1.26, `zip`, `curl`; für Schritt 2, 3 und 5 außerdem
OpenCode (vorrangig) und/oder Claude Code; für die Browser-Prüfungen Google Chrome. Alle Pfade gehen vom Hugin-Repo aus (hier `~/repos/dev/hugin`).
## 1. Demo-Server mit Beispieldaten
```sh
cd ~/repos/dev/hugin
scripts/demo.sh # http://127.0.0.1:8080, Keys stehen in .demo/keys.txt
scripts/demo.sh --neu # Beispieldaten verwerfen und neu anlegen
```
Im Browser mit einem Key aus `.demo/keys.txt` anmelden:
| Key | Rolle | Was ausprobieren |
|---|---|---|
| Bert Prüfer | Prüfer | unter „Prüfungen“ wartet `pub-munin-nutzen 1.5.0` |
| Anna Autorin | Autorin | Pakete anlegen und hochladen |
| Shop-Admin | Tenant-Admin | Projekte, Personen und Keys anlegen |
| `demo-admin` | Plattform-Admin | Systemseite, Audit, alle Tenants |
Online-Hilfe: http://127.0.0.1:8080/help · API-Doku: http://127.0.0.1:8080/api/docs
## 2. Pakete in ein Test-Repo holen
In einem zweiten Terminal, während der Server läuft:
```sh
cd ~/repos/dev/hugin/cli && go build -o dist/hugin ./cmd/hugin
export PATH=~/repos/dev/hugin/cli/dist:$PATH # kein alias: OpenCode und Claude Code sehen nur den PATH
mkdir -p /tmp/test-repo && cd /tmp/test-repo && git init -q
export HUGIN_KEY=<Key „Shop CI“ aus .demo/keys.txt>
hugin init --server http://127.0.0.1:8080 --project api
```
In `.hugin.yaml` Werkzeuge und Pakete eintragen:
```yaml
tools: [claude, opencode] # fest eintragen: „auto“ erkennt im leeren Repo nichts Passendes
packages:
- pub-munin-nutzen # Skill
- pub-hausregeln # Regeln
- pub-release-notiz # Befehl mit Argumenten
- pub-code-review # Subagent
```
`pub-munin-anbindung` (MCP) kommt als Pflichtpaket von selbst mit.
```sh
hugin sync # schreibt die Dateien, legt hugin.lock an
hugin status # Abos, Versionen, erkannte Werkzeuge
hugin check # ändert nichts; Rückgabewert 1, wenn etwas zu tun ist
```
Dann im Werkzeug ausprobieren, z. B.:
```sh
opencode # oder: claude
> /pub-release-notiz 2.0 Kunden
```
**Was sich zu testen lohnt:**
- Eine verwaltete Datei von Hand ändern, dann `hugin sync` → Befund „Handänderung“;
`hugin sync --overwrite` setzt sie zurück.
- Als Bert `pub-munin-nutzen 1.5.0` freigeben, dann `hugin check` (meldet die neue
Version) und `hugin sync`.
- Ein Paket aus `packages` entfernen und `hugin sync` → seine Dateien verschwinden.
- Claude Code fragt beim ersten Start, ob es den MCP-Server aus `.mcp.json` nutzen darf.
## 3. Als Autor ein Paket ausprobieren
```sh
cd /tmp
export HUGIN_KEY=<Key von Anna Autorin> HUGIN_SERVER=http://127.0.0.1:8080
hugin new skill pub-mein-test
```
In `pub-mein-test/hugin.yaml` mindestens `owner: kern` und eine echte `description`
eintragen, in `SKILL.md` den Inhalt. Format und Beispiele: Hilfe → Quellformat.
```sh
hugin validate pub-mein-test # Linter und Vorschau je Werkzeug, speichert nichts
cd /tmp/test-repo
hugin try ../pub-mein-test # ins Repo installieren, ohne hugin.lock zu ändern
opencode # ausprobieren (oder claude)
hugin try --remove # wieder aufräumen
cd /tmp && hugin upload --submit pub-mein-test # hochladen und zur Prüfung einreichen
```
Danach als Bert im Browser unter „Prüfungen“ freigeben.
**Linter ausprobieren:**
| Änderung | Meldung |
|---|---|
| `description` aus der Vorlage stehen lassen | H003 (Fehler): noch der Platzhalter |
| unter `extra.opencode` z. B. `temperature: 0.1` bei einem Skill | H020 (Fehler): Feld unbekannt |
| `extra.claude.disable-model-invocation: true` | H023 (Hinweis): wirkt in OpenCode 1 nicht |
| „nutze Read“ im Text | H021 (Hinweis): neutral formulieren |
## 4. Automatische Tests
| Befehl | Prüft | Kosten |
|---|---|---|
| `cd server && uv run pytest` | Server: Linter, Übersetzer, API, Web, MCP, Tenants | frei |
| `scripts/postgres-test.sh` | dieselbe Suite gegen PostgreSQL (Docker) | frei |
| `cd cli && go test ./...` | CLI, gegen die goldenen Bundles des Servers | frei |
| `scripts/smoke-test-local.sh` | ganzer Ablauf Server → CLI, ohne echte Werkzeuge | frei |
| `scripts/stage0.sh` | wie OpenCode die Dateien liest, ohne Modellaufruf | frei |
| `uv run --no-project --with websockets --with httpx python scripts/csp/web_browser.py /tmp/shots` | Weboberfläche in Chrome: CSP-Verstöße je Seite, Bildschirmfotos | frei |
| `uv run --no-project --with websockets --with httpx python scripts/csp/web_actions.py` | Weboberfläche in Chrome bedienen: Topbar, Live-Suche, Kopieren, Rückfragen, Formulare, Editor, Tabs | frei |
| `OPENCODE_MODEL=openai/gpt-6-luna scripts/tool-smoke-test.sh` | echtes Claude Code und OpenCode, schreibt `COMPATIBILITY.md` | ca. 30 Modellaufrufe |
Reihenfolge: erst die Server-Tests, dann die CLI-Tests (die CLI testet gegen die
Bundles, die der Server erzeugt).
Optionen für `tool-smoke-test.sh`:
| Variable | Wirkung |
|---|---|
| `TOOLS=opencode` | nur ein Werkzeug prüfen |
| `CLAUDE_MODEL=sonnet` | anderes Modell für Claude Code (Standard `haiku`) |
| `OPENCODE_MODEL=anbieter/modell` | Modell für OpenCode; ohne Angabe sein Standard |
| `KEEP=1` | Arbeitsordner und Logs bleiben zum Nachsehen |
| `OUT=/pfad/datei.md` | Ergebnis woandershin statt nach `COMPATIBILITY.md` |
OpenCode braucht dafür einen Anbieter-Zugang (`opencode providers login`); die
kostenlosen Modelle lehnen `opencode run` ab.
## 5. Checkliste: Funktionen von Hand durchgehen
Server aus Abschnitt 1 und Test-Repo aus Abschnitt 2. Jede Zeile: was tun, was erwartet
wird. Was die Browser-Prüfung (`web_actions.py`) schon abdeckt, ist mit ⚙ markiert; von
Hand lohnt es trotzdem einmal, um das Verhalten zu kennen.
### 5.1 Weboberfläche: überall
| Schritt | Erwartung |
|---|---|
| Fenster schmal ziehen oder Handyansicht (Chrome: F12 → Gerätesymbol) | Topbar oben mit ☰, „Hilfe“, Hell/Dunkel, „Abmelden“; nichts abgeschnitten; „Programm hugin“ steht dann im Menü ⚙ |
| Lange Seite (als `demo-admin` „Tenants, Projekte, Keys“) ganz nach unten scrollen | Topbar mit „Abmelden“ bleibt sichtbar, die Sidebar bleibt stehen ⚙ |
| Mond/Sonne in der Topbar | Hell/Dunkel wechselt |
| Hilfe → „Arten von Paketen“ | jede Art erklärt (Skill, Regeln, Befehl, Subagent, MCP, Hook) mit „Welche Art passt?“ |
| Auf einer Paketseite das Abzeichen der Art (z. B. „Skill“) anklicken | führt zu „Arten von Paketen“ |
### 5.2 Als Shop CI (Team)
| Schritt | Erwartung |
|---|---|
| Katalog: ins Suchfeld „haus“ tippen | Liste filtert ohne Enter, Suche steht in der URL ⚙ |
| Katalog: Spalte „Paket“ anklicken | sortiert (nur, solange alles auf eine Seite passt) |
| Paket `pub-munin-nutzen` → „Im Repo nutzen“ → „Kopieren“ | Meldung „Kopiert“; in der Zwischenablage `packages:` mit dem Paket ⚙ |
| Version `1.4.0` öffnen, Tab „Inhalt“ anklicken, dann ← → Pos1 Ende | Tabs wechseln per Tastatur ⚙ |
| „Mein Projekt“ | Repos gruppiert; Filter „Alle“ / „Mit neuerer Version“; „Gemeldet“ als relative Zeit („vor 2 Tagen“), genaue Zeit im Tooltip |
### 5.3 Als Bert (Prüfer) und Carla (Prüferin + Tenant-Admin)
| Schritt | Erwartung |
|---|---|
| „Prüfungen“ | `pub-munin-nutzen 1.5.0` mit „eingereicht vor …“ |
| Version öffnen → „Prüfen“ → ohne Kommentar „Zurückgeben“ | Browser verlangt einen Kommentar, nichts wird abgeschickt ⚙ |
| Mit Kanal „stabil“ freigeben | zurück in „Prüfungen“; danach meldet im Test-Repo `hugin check` die neue Version |
| Danach als Shop-Admin „Überblick“ → Karte „Veraltete Installationen“ | springt zur Tabelle der Repos mit alter Version |
| Rechte-Paket: als Anna eine Skill-Version mit `extra: {claude: {allowed-tools: [Bash]}}` hochladen und einreichen, Bert gibt frei | Bert sieht im Dialog kein Kanal-Feld, sondern „Den Kanal wählt die letzte nötige Freigabe“; danach sieht Carla in „Prüfungen“ „Freigaben 1 von 2“ und „Tenant-Admin nötig“ |
| Carla gibt frei | Kanal-Feld mit Hinweis zum Risiko; danach freigegeben |
### 5.4 Als Anna (Autorin)
| Schritt | Erwartung |
|---|---|
| „Hochladen“ → „direkt im Browser anlegen“ | ID ist mit `pub-` vorausgefüllt |
| Version `pub-munin-nutzen 1.4.0` → „Neue Version auf dieser Basis“ | Editor öffnet mit 1.4.1 |
| Im Editor Tab drücken; Esc und dann Tab | Tab rückt zwei Leerzeichen ein; Esc+Tab springt zum nächsten Feld ⚙ |
| Etwas ändern und die Seite verlassen | Browser warnt vor ungespeicherten Änderungen ⚙ |
| In `SKILL.md` `$1` schreiben und speichern | Linter lehnt ab (H010), der Dateiname im Befund springt zur Datei |
| Alle Dateien unverändert speichern (nur `changes` ausfüllen) | Entwurf 1.4.1 mit denselben Dateien; nichts geht verloren |
| ZIP mit `hugin.yaml` (`kind: hook`) hochladen | klare Ablehnung H008 „Hooks kommen mit Stufe 5“, kein Serverfehler |
| „Zur Prüfung einreichen“ | Rückfrage „Danach lässt sich die Version nicht mehr ändern“ |
### 5.5 Als Shop-Admin (Verwaltung)
| Schritt | Erwartung |
|---|---|
| „Neuer Key“ → Art umschalten | Projekt-Key: nur Feld „Projekt“ (Pflicht); Integrations-Key: nur „Nur für diese Projekte“ ⚙ |
| Rollen ansehen | lesbare Namen („Autor“, „Prüfer (öffentlich)“, „Tenant-Admin“) mit Tooltip |
| Key anlegen → „Kopieren“ | Key in der Zwischenablage; er wird nur dieses eine Mal angezeigt |
| „Widerrufen“ bei einem Key | Rückfrage; „Abbrechen“ ändert nichts ⚙ |
| Als `demo-admin`: Tenant „plattform“ → Person „Sperren“; Pflichtpaket „Keine Pflicht mehr“ | jeweils Rückfrage (das Pflichtpaket gilt für alle Tenants, nur der Plattform-Admin kann es lösen) |
| Tabelle „Keys“ | „Zuletzt genutzt“ als „vor …“ |
| Als `demo-admin` „Audit“ → Aktion „Keys“ wählen | Liste filtert sofort; ab 50 Einträgen bleibt der Filter beim Blättern („Ältere“) erhalten ⚙ |
### 5.6 CLI im Test-Repo (OpenCode zuerst)
| Schritt | Erwartung |
|---|---|
| Frisches Repo, `hugin sync` | `opencode.json` beginnt mit `"$schema": "https://opencode.ai/config.json"`; `opencode debug config` läuft ohne Fehler |
| In `.hugin.yaml` `tools: [opencode]`, dann `hugin sync` | Skills unter `.opencode/skills/`; kein `.claude/`, keine `.mcp.json`, kein Block in `CLAUDE.md`; `opencode debug skill` findet den Skill |
| Zurück auf `tools: [claude, opencode]`, `hugin sync` | Skills wandern nach `.claude/skills/`, `.opencode/skills/` ist leer |
| `unset MUNIN_KEY; hugin sync` | `Warnung: pub-munin-anbindung braucht die Umgebungsvariable MUNIN_KEY; sie ist nicht gesetzt …` |
| `export MUNIN_KEY=x; hugin sync` | keine Warnung mehr |
| `unset MUNIN_KEY; hugin check; echo $?` | Warnung, aber Rückgabewert 0 (CI ohne Keys bleibt grün) |
| In `opencode.json` unter `mcp` von Hand `"jira": { "url": "https://jira.intern/mcp" }` eintragen, dann `hugin check; echo $?` | „OpenCode lehnt die Konfiguration ab“ mit `mcp.jira`, Rückgabewert 1, Text „das kann hugin sync nicht“ |
| Eintrag reparieren (`"type": "remote"`, `"enabled": true`), `hugin check` | „Alles aktuell.“ |
| `opencode` im Repo starten, `/pub-release-notiz 2.0 Kunden` | Befehl läuft; Regeln aus `pub-hausregeln` werden beachtet |
| In `opencode.json` einen Tippfehler wie `"modell": "x"` | wird **nicht** gemeldet: OpenCode 1.19.3 nimmt unbekannte Felder hin |
### 5.7 Agent installiert über Hugins MCP-Endpunkt (OpenCode)
Frisches Test-Repo, damit nichts vom Abschnitt 2 stört:
```sh
mkdir -p /tmp/mcp-repo && cd /tmp/mcp-repo && git init -q
export HUGIN_KEY=<Key „Shop CI“ aus .demo/keys.txt>
export MUNIN_KEY=x # nur gegen die Warnung; Munin selbst gibt es in der Demo nicht
hugin init --server http://127.0.0.1:8080 --project api
sed -i '' 's/^tools: auto/tools: [opencode]/' .hugin.yaml
hugin sync # nur das Pflichtpaket pub-munin-anbindung
```
In `opencode.json` unter `"mcp"` Hugin selbst als MCP-Server eintragen (von Hand; Hugin
lässt diesen Eintrag in Ruhe):
```json
"hugin": {
"type": "remote",
"url": "http://127.0.0.1:8080/mcp",
"enabled": true,
"headers": { "Authorization": "Bearer {env:HUGIN_KEY}" }
}
```
| Schritt | Erwartung |
|---|---|
| `opencode mcp list` | `hugin` steht auf „connected“ (`munin` verbindet in der Demo nicht, das ist erwartet) |
| `hugin check` | „Alles aktuell.“ (OpenCode akzeptiert den Eintrag) |
| `which hugin` in derselben Shell, aus der du `opencode` startest | zeigt `…/cli/dist/hugin`; sonst findet der Agent `hugin sync` nicht (Abschnitt 2: `PATH`, kein `alias`) |
| `opencode` starten: „Gibt es in Hugin einen Befehl für Release-Notizen?“ | ruft `search_packages` auf, nennt `pub-release-notiz` |
| „Zeig mir, was pub-release-notiz macht, ohne es zu installieren.“ | ruft `get_package` auf, erklärt den Inhalt; im Repo ändert sich nichts (`git status`) |
| „Richte pub-release-notiz dauerhaft in diesem Repo ein.“ | trägt `pub-release-notiz` in `.hugin.yaml` ein und führt `hugin sync` aus (OpenCode fragt vorher nach Erlaubnis für den Befehl); **schreibt nicht selbst** nach `.opencode/commands/` |
| Danach `hugin status` und `cat hugin.lock` | `pub-release-notiz` steht in beiden; die Datei `.opencode/commands/pub-release-notiz.md` hat Hugin geschrieben |
| OpenCode neu starten, `/pub-release-notiz 2.0 Kunden` | der Befehl läuft |
| Als Anna eine neue Version von `pub-release-notiz` hochladen, Bert gibt frei; dann `hugin check` | meldet die neue Version (das geht nur, weil `hugin sync` installiert hat) |
Gegenprobe ohne `hugin` im `PATH` (z. B. `env PATH=/usr/bin:/bin opencode`): Der Agent
soll melden, dass `hugin` fehlt, und **nichts selbst schreiben**. Schreibt er die Datei
doch selbst, fehlt `pub-release-notiz` in `hugin.lock`, und das nächste `hugin sync`
meldet „Datei gibt es schon, sie stammt nicht von Hugin (Konflikt)“. Dann bitte Modell
und Wortlaut notieren, das wäre ein Befund. Aufräumen: `hugin sync --overwrite`.
Der automatische Test dazu: `scripts/tool-smoke-test.sh`, Prüfungen „Installieren auf Bitte“ und „Ohne hugin nichts schreiben“.
### 5.8 Demo und Betrieb
| Schritt | Erwartung |
|---|---|
| `scripts/demo.sh` mit einer Datenbank aus einem alten Stand | statt Traceback: „Migration gescheitert …“ mit dem Hinweis auf `--neu` |
| `cd server && uv run pytest tests/test_migration.py` nach einer Änderung an `0001_initial_tables.py` | Fehler „ausgelieferte Migrationen nie ändern, sondern eine neue anlegen“ |
### 5.9 Lokaler MCP-Server (Python)
Das Beispiel `pub-zeit-lokal` braucht nur die Standardbibliothek. Als Anna im Test-Repo
aus Abschnitt 3 (mit `tools: [opencode]` oder `[claude, opencode]`):
```sh
export HUGIN_MCP_HOME=/tmp/hugin-mcp ZEIT_ZONE_HINWEIS=test # Ablage zum Wegwerfen
hugin try ~/repos/dev/hugin/contract/examples/packages/pub-zeit-lokal/1.0.0
```
| Schritt | Erwartung |
|---|---|
| Ausgabe von `hugin try` | „installiere pub-zeit-lokal 1.0.0 (lokaler Server) nach /tmp/hugin-mcp/…“ |
| `ls -a /tmp/hugin-mcp/pub-zeit-lokal/1.0.0` | `zeit_server.py`, `.venv`, `.hugin-install.json` |
| `grep -A6 zeit opencode.json` | `"command": ["hugin", "mcp", "run", "pub-zeit-lokal"]`, kein Pfad |
| `opencode mcp list` | `zeit connected` |
| in OpenCode: „Rufe das Werkzeug zeit auf“ | Uhrzeit mit `UHR-4711 (test)` |
| `rm -rf /tmp/hugin-mcp`, dann `opencode mcp list` | `zeit failed`; `hugin mcp run pub-zeit-lokal` sagt „nicht installiert; im Repo hugin sync ausführen“ |
| `hugin try` erneut | installiert wieder |
| `hugin try --remove`, dann `hugin mcp prune --dry-run` | „würde löschen pub-zeit-lokal 1.0.0“ |
| `mkdir -p /tmp/hugin-mcp/eigener && hugin mcp prune` | löscht nur das Hugin-Paket; `eigener` bleibt |
Freigabe und Verteilung: als Anna `hugin upload --submit` (persönlicher Key nötig, H043
beim Projekt-Key), Risiko „Skripte“, also zwei Freigaben (Bert und Carla). Die
Versionsseite zeigt „Lokaler Server“ mit Ablage. Danach im Repo `pub-zeit-lokal` unter
`packages` eintragen und `hugin sync`; `hugin check` meldet, wenn der Server auf dem
Rechner fehlt.
**Mit Abhängigkeiten:** `requirements.txt` mit Hashes erzeugen (Hilfe → Quellformat,
`uv pip compile --universal --generate-hashes`). pip nimmt den Index aus der
`pip.conf`; zum Test einen anderen Index: `PIP_CONFIG_FILE=/tmp/pip.conf`. Ohne Hash
lehnt der Linter mit H037 ab.
### 5.10 OpenCode-Plugin
Das Beispiel `pub-specht-plugin` gibt OpenCode das Werkzeug `specht_marke`. Als Anna im
Test-Repo (`tools: [opencode]` oder `[claude, opencode]`):
```sh
hugin validate ~/repos/dev/hugin/contract/examples/packages/pub-specht-plugin/1.0.0
hugin try ~/repos/dev/hugin/contract/examples/packages/pub-specht-plugin/1.0.0
```
| Schritt | Erwartung |
|---|---|
| Ausgabe von `hugin validate` | `Risiko: permissions`, `Kompatibilität: opencode-only`, keine Fehler |
| `find .opencode/plugins` | `pub-specht-plugin.ts` und `pub-specht-plugin/index.ts`, `…/lib/marke.ts`; nichts unter `.claude/` |
| `opencode debug config \| grep -A2 '"plugin"'` | `…/.opencode/plugins/pub-specht-plugin.ts` |
| in OpenCode: „Rufe das Werkzeug specht_marke auf“ | `SPECHT-5127` |
| dasselbe mit `opencode --pure` | Werkzeug unbekannt (so gewollt) |
| `git status` | `.opencode/plugins/…` neu; `node_modules`, `package.json` unter `.opencode/` nicht (eigene `.gitignore` von OpenCode) |
| in `index.ts` `import _ from "lodash"` ergänzen, `hugin validate` | H039 |
| `targets` auf `[claude, opencode]`, `hugin validate` | H038 |
| `hugin try --remove` | Ordner und Startdatei weg |
Freigabe: `hugin upload --submit`, dann Bert und **Carla** (Tenant-Admin); mit Bert und
Dora allein bleibt es bei „Tenant-Admin nötig“. Danach abonnieren und `hugin sync`.
### 5.11 Windows (von Hand)
Auf einem Windows-Rechner mit Python (Starter `py`), OpenCode und Zugang zu Hugin.
Programm bauen auf dem Mac: `cd cli && GOOS=windows GOARCH=amd64 go build -o dist/hugin.exe ./cmd/hugin`,
`hugin.exe` in einen Ordner im `PATH` legen. In PowerShell:
| Schritt | Erwartung |
|---|---|
| `hugin init …`, `.hugin.yaml` wie in Abschnitt 2 mit `tools: [opencode]`, `hugin sync` | Dateien unter `.opencode\…`, keine unter `.claude` |
| `opencode debug skill > s.txt; opencode agent list; opencode mcp list` | Skill, Subagent, MCP wie unter macOS |
| `hugin try …\pub-zeit-lokal\1.0.0` (mit `$env:ZEIT_ZONE_HINWEIS="test"`) | installiert nach `%USERPROFILE%\.local\share\mcp\pub-zeit-lokal\1.0.0`, venv unter `.venv\Scripts` |
| `opencode mcp list` | `zeit connected` |
| in OpenCode das Werkzeug zeit aufrufen | Antwort mit `UHR-4711`, Umlaute korrekt |
| aus einem Unterordner des Repos starten | lokaler Server verbindet trotzdem |
| ohne `py`, nur `python` aus python.org im `PATH` | wird gefunden; nur der Store-Platzhalter `python.exe`: Meldung „kein Python gefunden“ |
| `$env:HUGIN_PYTHON="C:\Pfad\python.exe"` | wird genommen |
| Paket mit `requirements.txt`, `pip.ini` unter `%APPDATA%\pip\` mit Artifactory | pip installiert aus dem Artifactory |
| `hugin mcp prune` | wie unter macOS |
| Repo unter einem langen Pfad (> 200 Zeichen), dann `hugin sync` | Befund notieren, falls Windows-Pfadgrenze greift |
| `hugin try …\pub-specht-plugin\1.0.0`, OpenCode neu starten, Werkzeug specht_marke aufrufen | `SPECHT-5127` |
| `hugin-install.ps1` mit einem Paket mit lokalem Server | Warnung „ist ein lokaler MCP-Server; installieren mit dem Programm hugin“, sonst läuft alles durch |
Befunde bitte mit Ausgabe und Windows-Version notieren.
## Noch nicht testbar
- Hooks, Paare, Signaturen, fertige Programme als lokale Server, npm-Abhängigkeiten für
Plugins: noch nicht gebaut (Stufe 5c–5e); ein Hook-Paket lehnt der Linter mit H008 ab.
- OpenCode 2: ungeprüft (STUFE0.md, Frage 10); `OPENCODE=<opencode-2> scripts/stage0.sh`.
- `hugin-install.ps1` unter Windows PowerShell 5.1: braucht einen Windows-Rechner,
siehe README „Integration unter Windows PowerShell 5.1 testen“.

313
cli/cmd/hugin/author.go Normal file
View file

@ -0,0 +1,313 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"github.com/spf13/cobra"
"hugin/cli/internal/api"
"hugin/cli/internal/bundle"
"hugin/cli/internal/config"
"hugin/cli/internal/keystore"
"hugin/cli/internal/localmcp"
"hugin/cli/internal/lock"
"hugin/cli/internal/pkgdir"
"hugin/cli/internal/reconcile"
)
// Lock-Datei von hugin try; hugin.lock bleibt dabei unberührt (→ KONZEPT.md 11.1).
const trialLock = localmcp.TrialLock
func newCommand() *cobra.Command {
var folder string
cmd := &cobra.Command{
Use: "new <kind> <id>",
Short: "legt einen Paketordner mit Manifest-Gerüst an (Art: skill, rules, command, subagent, mcp, plugin)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
target, err := pkgdir.New(folder, args[0], args[1])
if err != nil {
return err
}
fmt.Printf("%s angelegt. hugin.yaml ausfüllen, dann: hugin validate %s\n", target, target)
return nil
},
}
cmd.Flags().StringVar(&folder, "in", ".", "Ordner, in dem der Paketordner entsteht")
return cmd
}
// authorClient: Server aus --server, sonst aus .hugin.yaml, sonst HUGIN_SERVER.
func authorClient(server string) (*api.Client, error) {
if server == "" {
if k, err := config.Load(repoFolder); err == nil {
server = k.Server
}
}
if server == "" {
server = os.Getenv("HUGIN_SERVER")
}
if server == "" {
return nil, errors.New("kein Server: --server angeben, HUGIN_SERVER setzen oder in einem Repo mit .hugin.yaml arbeiten")
}
key, err := keystore.Get(server)
if err != nil {
return nil, err
}
return api.New(server, key)
}
func folderArg(args []string) string {
if len(args) == 1 {
return args[0]
}
return "."
}
// printFindings druckt Linter-Befunde; true, wenn ein Fehler dabei ist.
func printFindings(findings []api.Finding) bool {
errs := false
for _, b := range findings {
location := ""
if b.File != "" {
location = " (" + b.File + ")"
}
fmt.Printf(" %-7s %s %s%s\n", b.Severity, b.Id, b.Text, location)
errs = errs || b.Severity == "error"
}
return errs
}
// linterError macht aus einer 422-Antwort mit Befunden eine lesbare Ausgabe.
func linterError(err error) error {
var f *api.APIError
if errors.As(err, &f) && len(f.Findings) > 0 {
fmt.Println("Der Linter hat das Paket abgelehnt:")
printFindings(f.Findings)
return findingError{"Paket nicht angenommen"}
}
return err
}
func validateCommand() *cobra.Command {
var server string
cmd := &cobra.Command{
Use: "validate [folder]",
Short: "prüft einen Paketordner über den Server und zeigt, was in jedem Werkzeug ankommt; speichert nichts",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := authorClient(server)
if err != nil {
return err
}
raw, err := pkgdir.Zip(folderArg(args))
if err != nil {
return err
}
p, err := client.Validate(raw)
if err != nil {
return linterError(err)
}
if len(p.Findings) > 0 {
fmt.Println("Befunde:")
}
errs := printFindings(p.Findings)
fmt.Printf("Risiko: %s\n", p.Risk)
if p.Compatibility != nil {
fmt.Printf("Kompatibilität: %s\n", *p.Compatibility)
}
toolNames := make([]string, 0, len(p.Preview))
for w := range p.Preview {
toolNames = append(toolNames, w)
}
sort.Strings(toolNames)
for _, w := range toolNames {
fmt.Printf("\n%s:\n", w)
for _, op := range p.Preview[w] {
fmt.Printf(" %-10v %v\n", op["kind"], target(op))
}
}
if errs {
return findingError{"Paket hat Fehler"}
}
return nil
},
}
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
return cmd
}
func target(op map[string]any) string {
if path, ok := op["path"]; ok {
return fmt.Sprint(path)
}
text := fmt.Sprint(op["file"])
if s, ok := op["key"]; ok {
text += fmt.Sprintf(" %v", s)
}
if b, ok := op["block"]; ok {
text += fmt.Sprintf(" Block %v", b)
}
return text
}
func tryCommand() *cobra.Command {
var server string
var remove, overwrite bool
cmd := &cobra.Command{
Use: "try [folder]",
Short: "installiert einen Entwurf zum Ausprobieren ins Repo, ohne hugin.lock zu ändern (--remove räumt auf)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
lockPath := filepath.Join(repoFolder, trialLock)
old, err := lock.LoadFrom(lockPath)
if err != nil {
return err
}
b := bundle.Empty()
if !remove {
client, err := authorClient(server)
if err != nil {
return err
}
raw, err := pkgdir.Zip(folderArg(args))
if err != nil {
return err
}
k, _ := config.Load(repoFolder)
detected, _ := detectTools(k)
details := map[string]string{}
for _, w := range detected {
details[w.name] = w.version
}
zip, err := client.Trial(raw, details)
if err != nil {
return linterError(err)
}
if b, err = bundle.FromZip(zip); err != nil {
return err
}
}
plan, err := reconcile.MakePlan(repoFolder, b, old, reconcile.Options{Overwrite: overwrite})
if err != nil {
return err
}
for _, f := range plan.Findings {
fmt.Fprintln(os.Stderr, "Befund:", f)
}
if plan.Open() {
return findingError{"nichts geändert; mit --overwrite setzt try die Dateien trotzdem"}
}
if err := plan.RunWithLock(repoFolder, lockPath); err != nil {
return err
}
if !remove {
home, err := localmcp.Home()
if err != nil {
return err
}
if err := install(b, home); err != nil {
return fmt.Errorf("Probe geschrieben, aber lokaler Server nicht installiert: %w", err)
}
}
if remove {
os.Remove(lockPath)
fmt.Printf("Probe entfernt: %d Dateien gelöscht.\n", len(plan.Delete))
return nil
}
fmt.Printf("Probe installiert: %d Dateien geschrieben (%d neu).\n", len(plan.Write), len(plan.New))
for _, rel := range plan.New {
fmt.Println(" ", rel)
}
fmt.Println("Jetzt in Claude Code oder OpenCode ausprobieren. Aufräumen mit: hugin try --remove")
return nil
},
}
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
cmd.Flags().BoolVar(&remove, "remove", false, "Dateien der letzten Probe wieder entfernen")
cmd.Flags().BoolVar(&overwrite, "overwrite", false, "vorhandene Dateien überschreiben")
return cmd
}
func uploadCommand() *cobra.Command {
var server string
var submit bool
cmd := &cobra.Command{
Use: "upload [folder]",
Short: "lädt einen Paketordner als Entwurf hoch (mit --submit gleich zur Prüfung)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := authorClient(server)
if err != nil {
return err
}
raw, err := pkgdir.Zip(folderArg(args))
if err != nil {
return err
}
v, err := client.Upload(raw)
if err != nil {
return linterError(err)
}
printFindings(v.Linter)
fmt.Printf("Entwurf %s %s gespeichert (Risiko %s).\n", v.Package, v.Version, v.Risk)
if submit {
if v, err = client.Submit(v.Package, v.Version); err != nil {
return err
}
fmt.Printf("Eingereicht: %s %s ist jetzt %s. Eine zweite Person prüft.\n", v.Package, v.Version, v.Status)
}
return nil
},
}
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
cmd.Flags().BoolVar(&submit, "submit", false, "nach dem Hochladen zur Prüfung einreichen")
return cmd
}
func searchCommand() *cobra.Command {
var server, kind string
cmd := &cobra.Command{
Use: "search [text]",
Short: "sucht im Katalog in ID und Beschreibung (für Teams und Autoren)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := authorClient(server)
if err != nil {
return err
}
text := ""
if len(args) == 1 {
text = args[0]
}
matches, err := client.Search(text, kind)
if err != nil {
return err
}
if len(matches) == 0 {
fmt.Println("Keine Pakete gefunden.")
return nil
}
for _, t := range matches {
fmt.Printf("%-32s %-9s %-8s %s\n", t.ID, t.Kind, t.Version, shorten(t.Description, 80))
}
fmt.Printf("\n%d Paket(e). Abonnieren: in .hugin.yaml unter packages eintragen, dann hugin sync.\n", len(matches))
return nil
},
}
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
cmd.Flags().StringVar(&kind, "kind", "", "nur diese Art: skill, rules, command, subagent, mcp, plugin")
return cmd
}
func shorten(text string, n int) string {
r := []rune(text)
if len(r) <= n {
return text
}
return string(r[:n-1]) + "…"
}

68
cli/cmd/hugin/import.go Normal file
View file

@ -0,0 +1,68 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"hugin/cli/internal/config"
"hugin/cli/internal/importer"
)
func importCommand() *cobra.Command {
var opt importer.Options
cmd := &cobra.Command{
Use: "import",
Short: "wandelt vorhandene Skills, Agenten, Befehle, Regeln und MCP-Server des Repos in Hugin-Pakete um (OpenCode zuerst)",
Long: `Liest .opencode/, opencode.json, AGENTS.md und danach .claude/, .mcp.json, CLAUDE.md
und legt je Fund einen Paketordner im Quellformat an. Gibt es denselben Namen in
OpenCode und Claude Code, gilt die OpenCode-Fassung. Ändert im Repo nichts.
Danach jedes Paket prüfen (IMPORT.md im Zielordner listet Hinweise) und mit
hugin validate / hugin upload --submit einreichen.`,
Example: " hugin import --prefix shop- --target ../hugin-packages\n hugin import --prefix pub- --owner kern",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
opt.Repo = repoFolder
if opt.Owner == "" {
var k *config.Config
if _, err := os.Stat(filepath.Join(repoFolder, config.Filename)); err == nil {
if k, err = config.Load(repoFolder); err != nil {
return err
}
}
if k == nil || k.Project == "" {
return errors.New("--owner fehlt (Projekt, das die Pakete pflegt); ohne .hugin.yaml bitte angeben")
}
opt.Owner = k.Project
}
b, err := importer.Import(opt)
if err != nil {
return err
}
for _, p := range b.Packages {
fmt.Printf("%-9s %-36s ← %v\n", p.Kind, p.ID, p.Sources[0])
for _, h := range p.Hints {
fmt.Printf(" Hinweis: %s\n", h)
}
}
for _, g := range b.Skipped {
fmt.Printf("nicht übernommen: %s: %s\n", g.Source, g.Text)
}
fmt.Printf("\n%d Paket(e) in %s, Übersicht in %s/IMPORT.md\n", len(b.Packages), opt.Target, opt.Target)
if len(b.Packages) > 0 {
fmt.Printf("Weiter: hugin validate %s/<paket>, dann hugin upload --submit %s/<paket>\n", opt.Target, opt.Target)
}
return nil
},
}
cmd.Flags().StringVar(&opt.Prefix, "prefix", "", "Präfix der IDs: pub- (öffentlich) oder <tenant>- (Pflicht)")
cmd.Flags().StringVar(&opt.Owner, "owner", "", "Projekt, das die Pakete pflegt (Standard: project aus .hugin.yaml)")
cmd.Flags().StringVar(&opt.Target, "target", "hugin-import", "Ordner für die Pakete")
cmd.Flags().BoolVar(&opt.Overwrite, "overwrite", false, "vorhandene Paketordner im Ziel ersetzen")
cmd.MarkFlagRequired("prefix")
return cmd
}

45
cli/cmd/hugin/init.go Normal file
View file

@ -0,0 +1,45 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"hugin/cli/internal/config"
"hugin/cli/internal/tools"
)
func initCommand() *cobra.Command {
var server, project string
cmd := &cobra.Command{
Use: "init",
Short: "legt .hugin.yaml an",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if server == "" || project == "" {
return errors.New("--server und --project sind Pflicht")
}
if _, err := os.Stat(filepath.Join(repoFolder, config.Filename)); err == nil {
return fmt.Errorf("%s gibt es schon", config.Filename)
}
k := &config.Config{
Server: server,
Project: project,
Tools: config.Tools{Auto: true},
Packages: []string{},
}
if err := k.Save(repoFolder); err != nil {
return err
}
fmt.Printf("%s angelegt. Erkannte Werkzeuge: %v\n", config.Filename, tools.InRepo(repoFolder))
fmt.Println("Pakete unter packages: eintragen, dann hugin sync.")
return nil
},
}
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin, z. B. https://hugin.intern")
cmd.Flags().StringVar(&project, "project", "", "Projekt-Slug in Hugin")
return cmd
}

42
cli/cmd/hugin/main.go Normal file
View file

@ -0,0 +1,42 @@
// hugin ist das Kommandozeilenprogramm für Teams und Autoren (→ KONZEPT.md 11).
package main
import (
"errors"
"fmt"
"os"
"github.com/spf13/cobra"
)
// Version wird beim Bauen gesetzt: -ldflags "-X main.Version=…"
var Version = "dev"
// findingError bedeutet: Befehl lief, hat aber etwas gefunden (Rückgabewert 1).
type findingError struct{ text string }
func (e findingError) Error() string { return e.text }
func main() {
root := &cobra.Command{
Use: "hugin",
Short: "Skills, Regeln, Befehle, Subagenten und MCP-Anbindungen aus Hugin abgleichen",
Version: Version,
SilenceUsage: true,
SilenceErrors: true,
}
root.PersistentFlags().StringVarP(&repoFolder, "repo", "C", ".", "Wurzel des Repos")
root.AddCommand(initCommand(), syncCommand(), checkCommand(), statusCommand(), loginCommand(),
newCommand(), validateCommand(), tryCommand(), uploadCommand(), searchCommand(), importCommand(), mcpCommand())
if err := root.Execute(); err != nil {
fmt.Fprintln(os.Stderr, "hugin:", err)
var finding findingError
if errors.As(err, &finding) {
os.Exit(1)
}
os.Exit(2)
}
}
var repoFolder string

127
cli/cmd/hugin/mcp.go Normal file
View file

@ -0,0 +1,127 @@
package main
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"hugin/cli/internal/bundle"
"hugin/cli/internal/localmcp"
"hugin/cli/internal/lock"
)
func mcpCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "mcp",
Short: "lokale MCP-Server: starten und aufräumen",
}
cmd.AddCommand(mcpRunCommand(), mcpPruneCommand())
return cmd
}
// mcpRunCommand startet einen lokalen Server; so steht es in opencode.json bzw. .mcp.json.
// Die Version kommt aus hugin.lock des Repos, in dem das Werkzeug läuft; läuft dort eine
// Probe (hugin try), gilt deren Version.
func mcpRunCommand() *cobra.Command {
return &cobra.Command{
Use: "run <paket>",
Short: "lokalen MCP-Server in der Version aus hugin.lock starten (rufen OpenCode und Claude Code auf)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := args[0]
repo, err := localmcp.FindRepo(repoFolder)
if err != nil {
return err
}
p, ok, err := lockedVersion(repo, id)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("%s steht nicht in %s; im Repo hugin sync ausführen", id, lock.Filename)
}
home, err := localmcp.Home()
if err != nil {
return err
}
folder := localmcp.Folder(home, id, p.Version)
m, err := localmcp.Read(folder)
if err != nil || m.Checksum != p.Checksum {
return fmt.Errorf("lokaler Server %s %s ist auf diesem Rechner nicht installiert; im Repo hugin sync ausführen",
id, p.Version)
}
program, arguments, env := localmcp.Command(folder, m)
return localmcp.Exec(program, arguments, env)
},
}
}
func mcpPruneCommand() *cobra.Command {
var dryRun bool
cmd := &cobra.Command{
Use: "prune",
Short: "installierte lokale Server löschen, die kein bekanntes Repo mehr nutzt",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
home, err := localmcp.Home()
if err != nil {
return err
}
removed, err := localmcp.Prune(home, dryRun)
for _, r := range removed {
if dryRun {
fmt.Println(" würde löschen", r)
} else {
fmt.Println(" gelöscht ", r)
}
}
if err == nil && len(removed) == 0 {
fmt.Println("Nichts zu löschen.")
}
return err
},
}
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "nur anzeigen")
return cmd
}
func lockedVersion(repo, id string) (*lock.Package, bool, error) {
for _, path := range []string{filepath.Join(repo, filepath.FromSlash(localmcp.TrialLock)), filepath.Join(repo, lock.Filename)} {
l, err := lock.LoadFrom(path)
if err != nil {
return nil, false, err
}
if p, ok := l.Packages[id]; ok {
return p, true, nil
}
}
return nil, false, nil
}
// missingInstalls nennt die lokalen Server des Bundles, die auf diesem Rechner fehlen.
func missingInstalls(b *bundle.Bundle, home string) []bundle.Operation {
var missing []bundle.Operation
for _, op := range b.Installs() {
if !localmcp.Installed(home, op) {
missing = append(missing, op)
}
}
return missing
}
// install installiert fehlende lokale Server und merkt sich das Repo für prune.
func install(b *bundle.Bundle, home string) error {
for _, op := range missingInstalls(b, home) {
fmt.Printf(" installiere %s %s (lokaler Server) nach %s\n", op.Package, op.Version,
localmcp.Folder(home, op.Package, op.Version))
if err := localmcp.Install(home, b, op, os.Stderr); err != nil {
return err
}
}
if len(b.Installs()) > 0 {
return localmcp.Register(home, repoFolder)
}
return nil
}

106
cli/cmd/hugin/status.go Normal file
View file

@ -0,0 +1,106 @@
package main
import (
"bufio"
"errors"
"fmt"
"maps"
"os"
"slices"
"strings"
"github.com/spf13/cobra"
"hugin/cli/internal/config"
"hugin/cli/internal/keystore"
"hugin/cli/internal/lock"
"hugin/cli/internal/tools"
)
func statusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "listet Abos, installierte Versionen und Werkzeuge",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
k, err := config.Load(repoFolder)
if err != nil {
return err
}
l, err := lock.Load(repoFolder)
if err != nil {
return err
}
fmt.Printf("Server: %s\nProjekt: %s\n\nAbos:\n", k.Server, k.Project)
for _, subscription := range k.Packages {
id, _, _ := strings.Cut(subscription, "@")
installed := "nicht installiert"
if p, ok := l.Packages[id]; ok {
installed = p.Version
}
fmt.Printf(" %-40s %s\n", subscription, installed)
}
var more []string
for _, id := range slices.Sorted(maps.Keys(l.Packages)) {
if !slices.ContainsFunc(k.Packages, func(a string) bool { return strings.Split(a, "@")[0] == id }) {
more = append(more, fmt.Sprintf(" %-40s %s", id, l.Packages[id].Version))
}
}
if len(more) > 0 {
fmt.Println("\nInstalliert ohne Abo (z. B. Pflichtpakete):")
fmt.Println(strings.Join(more, "\n"))
}
fmt.Println("\nWerkzeuge:")
names := k.Tools.List
if len(names) == 0 {
names = tools.InRepo(repoFolder)
}
for _, name := range names {
version := tools.Version(name)
state := "ok"
switch {
case version == "":
version, state = "–", "nicht gefunden"
case tools.Less(version, tools.Floors[name]):
state = "älter als " + tools.Floors[name]
}
fmt.Printf(" %-10s %-10s %s\n", name, version, state)
}
return nil
},
}
}
func loginCommand() *cobra.Command {
var logout bool
cmd := &cobra.Command{
Use: "login",
Short: "speichert den Key im Schlüsselbund des Betriebssystems",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
k, err := config.Load(repoFolder)
if err != nil {
return err
}
if logout {
return keystore.Delete(k.Server)
}
fmt.Fprintf(os.Stderr, "Key für %s: ", k.Server)
row, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil && row == "" {
return err
}
key := strings.TrimSpace(row)
if key == "" {
return errors.New("kein Key eingegeben")
}
if err := keystore.Save(k.Server, key); err != nil {
return fmt.Errorf("Schlüsselbund: %w; alternativ %s setzen", err, keystore.Env)
}
fmt.Fprintln(os.Stderr, "gespeichert.")
return nil
},
}
cmd.Flags().BoolVar(&logout, "logout", false, "Key aus dem Schlüsselbund löschen")
return cmd
}

338
cli/cmd/hugin/sync.go Normal file
View file

@ -0,0 +1,338 @@
package main
import (
"errors"
"fmt"
"maps"
"os"
"regexp"
"slices"
"strings"
"github.com/spf13/cobra"
"hugin/cli/internal/api"
"hugin/cli/internal/bundle"
"hugin/cli/internal/config"
"hugin/cli/internal/keystore"
"hugin/cli/internal/localmcp"
"hugin/cli/internal/lock"
"hugin/cli/internal/reconcile"
"hugin/cli/internal/repo"
"hugin/cli/internal/tools"
)
type syncOptions struct {
bundle string
major bool
overwrite bool
strict bool
dryRun bool
}
func syncCommand() *cobra.Command {
var opt syncOptions
cmd := &cobra.Command{
Use: "sync",
Short: "holt die abonnierten und die Pflichtpakete und gleicht das Repo ab",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runSync(opt, false)
},
}
cmd.Flags().StringVar(&opt.bundle, "bundle", "", "Bundle aus Datei oder Ordner statt vom Server")
cmd.Flags().BoolVar(&opt.major, "major", false, "Major-Sprünge erlauben")
cmd.Flags().BoolVar(&opt.overwrite, "overwrite", false, "Handänderungen und Konflikte überschreiben")
cmd.Flags().BoolVar(&opt.strict, "strict", false, "bei zu alten Werkzeugen abbrechen")
cmd.Flags().BoolVar(&opt.dryRun, "dry-run", false, "nur anzeigen, nichts schreiben")
return cmd
}
func checkCommand() *cobra.Command {
var opt syncOptions
cmd := &cobra.Command{
Use: "check",
Short: "ändert nichts; meldet Neues, Handänderungen und zu alte Werkzeuge (Rückgabewert 1 bei Befund)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
opt.dryRun = true
return runSync(opt, true)
},
}
cmd.Flags().StringVar(&opt.bundle, "bundle", "", "Bundle aus Datei oder Ordner statt vom Server")
return cmd
}
type tool struct{ name, version string }
// checkCliVersion vergleicht die eigene Version mit den Angaben des Servers (→ KONZEPT.md 18).
// Eine Entwicklungsfassung (Version "dev") prüft nichts.
func checkCliVersion(conf *api.Configuration) []string {
if Version == "dev" || !semverPattern.MatchString(Version) {
return nil
}
if m := conf.CLI.MinVersion; m != "" && tools.Less(Version, m) {
return []string{fmt.Sprintf("hugin %s ist älter als die nötige Version %s; neues Programm unter %s",
Version, m, "„Programm hugin“ in der Weboberfläche")}
}
if n := conf.CLI.Newest; n != "" && tools.Less(Version, n) {
fmt.Fprintf(os.Stderr, "Hinweis: hugin %s ist verfügbar (installiert: %s)\n", n, Version)
}
return nil
}
var semverPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
// detectTools liefert die Werkzeuge laut .hugin.yaml (oder erkannt im
// Repo) mit installierter Version und Warnungen zu alten Versionen.
func detectTools(k *config.Config) ([]tool, []string) {
var names []string
if k != nil {
names = k.Tools.List
}
if len(names) == 0 {
names = tools.InRepo(repoFolder)
}
var detected []tool
var warnings []string
for _, name := range names {
w := tool{name, tools.Version(name)}
detected = append(detected, w)
if floor := tools.Floors[name]; w.version != "" && tools.Less(w.version, floor) {
warnings = append(warnings, fmt.Sprintf("%s %s ist älter als die Untergrenze %s", name, w.version, floor))
}
}
return detected, warnings
}
func runSync(opt syncOptions, check bool) error {
k, err := config.Load(repoFolder)
if err != nil {
return err
}
var cliWarnings []string
old, err := lock.Load(repoFolder)
if err != nil {
return err
}
// 1. Untergrenzen vom Server (sonst die eingebauten), Werkzeuge erkennen und prüfen
var client *api.Client
if opt.bundle == "" {
key, err := keystore.Get(k.Server)
if err != nil {
return err
}
if client, err = api.New(k.Server, key); err != nil {
return err
}
conf, err := client.Configuration()
if err != nil {
return err
}
for name, version := range conf.Floors {
tools.Floors[name] = version
}
cliWarnings = checkCliVersion(conf)
}
detected, warnings := detectTools(k)
warnings = append(cliWarnings, warnings...)
for _, w := range warnings {
fmt.Fprintln(os.Stderr, "Warnung:", w)
}
if len(warnings) > 0 && !check && (opt.strict || k.Strict()) {
return errors.New("Werkzeuge zu alt (tool_check: strict)")
}
// 2. Bundle holen
var b *bundle.Bundle
if opt.bundle != "" {
b, err = bundle.Read(opt.bundle)
} else {
b, err = get(client, k, old, detected, opt.major)
}
if err != nil {
return err
}
for _, w := range detected {
if !slices.Contains(b.Instructions.Tools, tools.Target(w.name, w.version)) {
fmt.Fprintf(os.Stderr, "Warnung: Bundle enthält nichts für %s\n", tools.Target(w.name, w.version))
}
}
// 3. Planen
plan, err := reconcile.MakePlan(repoFolder, b, old, reconcile.Options{Overwrite: opt.overwrite})
if err != nil {
return err
}
report(plan, b, old)
// Fehlende Variablen nur melden: hugin check läuft oft in CI, wo die Keys der MCP-Server fehlen dürfen
for _, w := range missingEnv(b.Instructions.Hints, os.LookupEnv) {
fmt.Fprintln(os.Stderr, "Warnung:", w)
}
// Lokale Server liegen außerhalb des Repos: auch ein frisch geklontes, sonst aktuelles Repo
// braucht sie noch auf diesem Rechner
home, err := localmcp.Home()
if err != nil {
return err
}
missing := missingInstalls(b, home)
for _, op := range missing {
if check {
fmt.Fprintf(os.Stderr, "Befund: lokaler Server %s %s ist auf diesem Rechner nicht installiert\n", op.Package, op.Version)
} else if opt.dryRun {
fmt.Printf(" würde installieren %s %s (lokaler Server)\n", op.Package, op.Version)
}
}
// check prüft den Ist-Stand, sync den Stand nach dem Schreiben (unten)
var opencodeError string
if check {
if opencodeError = checkOpenCode(detected); opencodeError != "" {
fmt.Fprintln(os.Stderr, "Befund: OpenCode lehnt die Konfiguration ab:\n"+opencodeError)
}
}
switch {
case check && opencodeError != "" && !plan.Changed() && len(plan.Findings) == 0 && len(missing) == 0:
return findingError{"OpenCode-Konfiguration reparieren; das kann hugin sync nicht"}
case check:
if plan.Changed() || len(plan.Findings) > 0 || len(warnings) > 0 || opencodeError != "" || len(missing) > 0 {
return findingError{"Abweichungen gefunden; hugin sync gleicht ab"}
}
fmt.Println("Alles aktuell.")
return nil
case plan.Open():
return findingError{"nichts geändert; Handänderungen erst klären oder mit --overwrite zurücksetzen"}
case opt.dryRun:
fmt.Println("Trockenlauf: nichts geschrieben.")
return nil
}
// 4. Schreiben
if err := plan.Run(repoFolder); err != nil {
return err
}
if err := install(b, home); err != nil {
return fmt.Errorf("Dateien geschrieben, aber lokaler Server nicht installiert: %w", err)
}
if !plan.Changed() && len(missing) == 0 {
fmt.Println("Alles aktuell.")
} else if msg := checkOpenCode(detected); msg != "" {
return fmt.Errorf("geschrieben, aber OpenCode startet so nicht; erst die Konfiguration reparieren:\n%s", msg)
}
// 5. Nutzung melden; ein Fehler hier macht den sync nicht ungültig
if client != nil {
if err := client.Usage(usage(plan.Lock, detected)); err != nil {
fmt.Fprintln(os.Stderr, "Warnung: Nutzung nicht gemeldet:", err)
}
}
return nil
}
func get(client *api.Client, k *config.Config, old *lock.Lock, detected []tool, major bool) (*bundle.Bundle, error) {
request := api.BundleRequest{
Packages: k.Packages,
Unsubscribed: k.Unsubscribed,
Tools: map[string]string{},
Mandatory: true,
Major: major,
Options: map[string]bool{"opencode_reads_claude": k.ReadsClaude()},
Installed: map[string]string{},
}
for _, w := range detected {
request.Tools[w.name] = w.version
}
for id, p := range old.Packages {
request.Installed[id] = p.Version
}
raw, err := client.Bundle(request)
if err != nil {
return nil, err
}
return bundle.FromZip(raw)
}
func usage(l *lock.Lock, detected []tool) api.Usage {
n := api.Usage{Repo: repo.Id(repoFolder), Tools: map[string]string{}, Installed: map[string]string{}}
for _, w := range detected {
n.Tools[w.name] = w.version
}
for id, p := range l.Packages {
n.Installed[id] = p.Version
}
return n
}
// checkOpenCode lässt OpenCode die Konfiguration laden, wenn das Repo OpenCode nutzt und es
// installiert ist; liefert OpenCodes Fehlermeldung oder nichts.
func checkOpenCode(detected []tool) string {
for _, w := range detected {
if w.name == "opencode" && w.version != "" {
return tools.CheckOpenCode(repoFolder)
}
}
return ""
}
func report(plan *reconcile.Plan, b *bundle.Bundle, old *lock.Lock) {
for _, p := range b.Instructions.Packages {
before, ok := old.Packages[p.ID]
switch {
case !ok:
fmt.Printf(" neu %s %s\n", p.ID, p.Version)
case before.Version != p.Version:
fmt.Printf(" aktualisiert %s %s → %s\n", p.ID, before.Version, p.Version)
}
}
for _, id := range slices.Sorted(maps.Keys(old.Packages)) {
if _, stays := plan.Lock.Packages[id]; !stays {
fmt.Printf(" entfernt %s %s\n", id, old.Packages[id].Version)
}
}
if plan.Changed() {
fmt.Printf("Dateien: %d schreiben (%d neu), %d löschen\n", len(plan.Write), len(plan.New), len(plan.Delete))
}
for _, f := range plan.Findings {
fmt.Fprintln(os.Stderr, "Befund:", f)
}
for _, h := range b.Instructions.Hints {
if h.Kind != "env_variable" { // eigene Prüfung in missingEnv
fmt.Println("Hinweis:", hintText(h))
}
}
}
// missingEnv nennt die Umgebungsvariablen, die Pakete brauchen (MCP-Kopfzeilen) und die
// nicht oder leer gesetzt sind. Gesetzte Variablen bleiben still.
func missingEnv(hints []bundle.Hint, lookup func(string) (string, bool)) []string {
var missing []string
for _, h := range hints {
if h.Kind != "env_variable" {
continue
}
if value, ok := lookup(h.Name); !ok || value == "" {
missing = append(missing, fmt.Sprintf("%s braucht die Umgebungsvariable %s; sie ist nicht gesetzt, "+
"der MCP-Server bekommt sonst keinen Wert (z. B. keinen Key)", h.Package, h.Name))
}
}
return missing
}
func hintText(h bundle.Hint) string {
switch h.Kind {
case "min_version":
return fmt.Sprintf("%s braucht %s ≥ %s", h.Package, h.Tool, h.Version)
case "deprecated":
text := h.Package + " ist veraltet"
if h.Successor != "" {
text += "; Nachfolger: " + h.Successor
}
return text
case "major_available":
return fmt.Sprintf("%s %s ist verfügbar; übernehmen mit hugin sync --major", h.Package, h.Version)
}
return strings.TrimSpace(h.Kind + " " + h.Package)
}

View file

@ -0,0 +1,31 @@
package main
import (
"strings"
"testing"
"hugin/cli/internal/bundle"
)
func TestMissingEnv(t *testing.T) {
hints := []bundle.Hint{
{Kind: "env_variable", Package: "pub-munin-anbindung", Name: "MUNIN_KEY"},
{Kind: "env_variable", Package: "pub-jira", Name: "JIRA_KEY"},
{Kind: "env_variable", Package: "pub-wiki", Name: "WIKI_KEY"},
{Kind: "min_version", Package: "pub-x", Tool: "claude", Version: "2.1.84"},
}
env := map[string]string{"MUNIN_KEY": "geheim", "WIKI_KEY": ""}
lookup := func(name string) (string, bool) { v, ok := env[name]; return v, ok }
got := missingEnv(hints, lookup)
if len(got) != 2 {
t.Fatalf("erwartet JIRA_KEY (fehlt) und WIKI_KEY (leer), bekommen: %q", got)
}
if !strings.Contains(got[0], "pub-jira") || !strings.Contains(got[0], "JIRA_KEY") ||
!strings.Contains(got[1], "WIKI_KEY") {
t.Errorf("falsche Meldungen: %q", got)
}
if missingEnv(hints[:1], lookup) != nil {
t.Error("gesetzte Variable darf nichts melden")
}
}

18
cli/go.mod Normal file
View file

@ -0,0 +1,18 @@
module hugin/cli
go 1.26.4
require (
github.com/spf13/cobra v1.10.2
github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f
github.com/zalando/go-keyring v0.2.8
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.27.0 // indirect
)

33
cli/go.sum Normal file
View file

@ -0,0 +1,33 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f h1:9hiVElpCmKzsBKQHkBqZ8LGzt82iLfM8egxr4sew+Ys=
github.com/tailscale/hujson v0.0.0-20260727124030-b80ff77dac4f/go.mod h1:8/zr1Tv0+cKpVtGCEB/7YfRXr2TszsMxMXLaT8YuBgU=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

279
cli/internal/api/api.go Normal file
View file

@ -0,0 +1,279 @@
// Package api spricht mit dem Hugin-Server unter /api/v1 (→ KONZEPT.md 14.6).
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"
)
type Client struct {
Base string // z. B. https://hugin.intern
Key string
HTTP *http.Client
}
// AllowHttp: Umgebungsvariable, die Keys auch unverschlüsselt an andere Rechner schickt (nur zum Testen).
const AllowHttp = "HUGIN_ALLOW_HTTP"
// New baut einen Client. Keys gehen nur über HTTPS oder an den eigenen Rechner;
// das gilt auch für Weiterleitungen.
func New(base, key string) (*Client, error) {
if err := safe(base); err != nil {
return nil, err
}
c := &Client{Base: strings.TrimSuffix(base, "/"), Key: key, HTTP: &http.Client{Timeout: 60 * time.Second}}
c.HTTP.CheckRedirect = func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("zu viele Weiterleitungen")
}
return safe(req.URL.String())
}
return c, nil
}
func safe(address string) error {
u, err := url.Parse(address)
if err != nil || u.Host == "" {
return fmt.Errorf("Server %q: keine gültige Adresse (z. B. https://hugin.intern)", address)
}
switch {
case u.Scheme == "https":
return nil
case u.Scheme != "http":
return fmt.Errorf("Server %q: nur https:// (oder http:// zum eigenen Rechner)", address)
}
host := u.Hostname()
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return nil
}
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
return nil
}
if os.Getenv(AllowHttp) == "1" {
fmt.Fprintf(os.Stderr, "hugin: Warnung: Key geht unverschlüsselt an %s (%s=1)\n", u.Host, AllowHttp)
return nil
}
return fmt.Errorf("Server %s: Keys gehen nicht unverschlüsselt über http:// an andere Rechner; https:// nutzen (zum Testen: %s=1)", u.Host, AllowHttp)
}
// BundleRequest entspricht contract/bundle-request.schema.json.
type BundleRequest struct {
Packages []string `json:"packages"`
Unsubscribed []string `json:"unsubscribed,omitempty"`
Tools map[string]string `json:"tools"`
Mandatory bool `json:"mandatory"`
Major bool `json:"major,omitempty"`
Options map[string]bool `json:"options,omitempty"`
Installed map[string]string `json:"installed,omitempty"`
}
// Bundle holt das ZIP für die Anfrage.
func (c *Client) Bundle(request BundleRequest) ([]byte, error) {
return c.post("/api/v1/bundle", request, "application/zip")
}
// Configuration entspricht GET /api/v1/config.
type Configuration struct {
Floors map[string]string `json:"floors"`
Mandatory []string `json:"mandatory"`
CLI struct {
MinVersion string `json:"min_version"`
Newest string `json:"newest"`
} `json:"cli"`
}
func (c *Client) Configuration() (*Configuration, error) {
var k Configuration
return &k, c.get("/api/v1/config", &k)
}
// Matches ist ein Eintrag im Katalog (GET /api/v1/packages).
type Matches struct {
ID string `json:"id"`
Kind string `json:"kind"`
Version string `json:"version"`
Description string `json:"description"`
Risk string `json:"risk"`
Compatibility string `json:"compatibility"`
}
func (c *Client) Search(text, kind string) ([]Matches, error) {
path := "/api/v1/packages?q=" + url.QueryEscape(text)
if kind != "" {
path += "&kind=" + url.QueryEscape(kind)
}
var t []Matches
return t, c.get(path, &t)
}
func (c *Client) get(path string, target any) error {
req, err := http.NewRequest(http.MethodGet, c.Base+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Key)
resp, err := c.HTTP.Do(req)
if err != nil {
return fmt.Errorf("Hugin nicht erreichbar: %w", err)
}
defer resp.Body.Close()
response, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if err != nil {
return err
}
if resp.StatusCode >= 300 {
text, _ := fehlertext(response)
return &APIError{Status: resp.StatusCode, Text: "GET " + strings.SplitN(path, "?", 2)[0] + ": " + resp.Status + " " + text}
}
return json.Unmarshal(response, target)
}
// Finding ist eine Meldung des Linters (→ KONZEPT.md 8).
type Finding struct {
Id string `json:"id"`
Severity string `json:"severity"`
Text string `json:"text"`
File string `json:"file"`
}
// ValidationResult ist die Antwort von POST /api/v1/validate.
type ValidationResult struct {
Findings []Finding `json:"findings"`
Risk string `json:"risk"`
Compatibility *string `json:"compatibility"`
Preview map[string][]map[string]any `json:"preview"`
}
func (c *Client) Validate(packageZip []byte) (*ValidationResult, error) {
raw, err := c.send("/api/v1/validate", packageZip, "application/zip", "application/json")
if err != nil {
return nil, err
}
var p ValidationResult
return &p, json.Unmarshal(raw, &p)
}
// Trial liefert ein Bundle für einen ungespeicherten Paketordner.
func (c *Client) Trial(packageZip []byte, tools map[string]string) ([]byte, error) {
var parts []string
for name, version := range tools {
parts = append(parts, name+":"+version)
}
path := "/api/v1/trial?tools=" + url.QueryEscape(strings.Join(parts, ","))
return c.send(path, packageZip, "application/zip", "application/zip")
}
// Version ist der Stand einer Version nach Hochladen oder Einreichen.
type Version struct {
Package string `json:"package"`
Version string `json:"version"`
Status string `json:"status"`
Risk string `json:"risk"`
Linter []Finding `json:"linter"`
}
func (c *Client) Upload(packageZip []byte) (*Version, error) {
raw, err := c.send("/api/v1/drafts", packageZip, "application/zip", "application/json")
if err != nil {
return nil, err
}
var v Version
return &v, json.Unmarshal(raw, &v)
}
func (c *Client) Submit(pkg, version string) (*Version, error) {
raw, err := c.send("/api/v1/packages/"+url.PathEscape(pkg)+"/versions/"+url.PathEscape(version)+"/submit",
nil, "application/json", "application/json")
if err != nil {
return nil, err
}
var v Version
return &v, json.Unmarshal(raw, &v)
}
// APIError ist eine Fehlerantwort des Servers; bei 422 vom Linter mit Befunden.
type APIError struct {
Status int
Text string
Findings []Finding
}
func (f *APIError) Error() string { return f.Text }
// Usage entspricht der Meldung nach sync (→ KONZEPT.md 11.5, Schritt 7).
type Usage struct {
Repo string `json:"repo"`
Tools map[string]string `json:"tools"`
Installed map[string]string `json:"installed"`
}
func (c *Client) Usage(n Usage) error {
_, err := c.post("/api/v1/usage", n, "application/json")
return err
}
func (c *Client) post(path string, body any, accept string) ([]byte, error) {
raw, err := json.Marshal(body)
if err != nil {
return nil, err
}
return c.send(path, raw, "application/json", accept)
}
func (c *Client) send(path string, body []byte, inhaltstyp, accept string) ([]byte, error) {
req, err := http.NewRequest(http.MethodPost, c.Base+path, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", inhaltstyp)
req.Header.Set("Accept", accept)
req.Header.Set("Authorization", "Bearer "+c.Key)
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("Hugin nicht erreichbar: %w", err)
}
defer resp.Body.Close()
response, err := io.ReadAll(io.LimitReader(resp.Body, 256<<20))
if err != nil {
return nil, err
}
if resp.StatusCode >= 300 {
f := &APIError{Status: resp.StatusCode}
f.Text, f.Findings = fehlertext(response)
f.Text = fmt.Sprintf("%s %s: %s %s", http.MethodPost, strings.SplitN(path, "?", 2)[0], resp.Status, f.Text)
return nil, f
}
return response, nil
}
func fehlertext(response []byte) (string, []Finding) {
var f struct {
Detail json.RawMessage `json:"detail"`
}
if json.Unmarshal(response, &f) == nil && len(f.Detail) > 0 {
var withFindings struct {
Findings []Finding `json:"findings"`
}
if json.Unmarshal(f.Detail, &withFindings) == nil && len(withFindings.Findings) > 0 {
return "vom Linter abgelehnt", withFindings.Findings
}
var text string
if json.Unmarshal(f.Detail, &text) == nil {
return text, nil
}
return string(f.Detail), nil
}
if len(response) > 200 {
response = response[:200]
}
return strings.TrimSpace(string(response)), nil
}

View file

@ -0,0 +1,46 @@
package api
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestKeysOnlyEncryptedOrLocal(t *testing.T) {
t.Setenv(AllowHttp, "")
for _, good := range []string{"https://hugin.intern", "http://127.0.0.1:8080", "http://localhost:8080", "http://[::1]:8080"} {
if _, err := New(good, "k"); err != nil {
t.Errorf("%s: %v", good, err)
}
}
for _, bad := range []string{"http://hugin.intern", "http://10.0.0.5:8080", "ftp://x", "hugin.intern", ""} {
if _, err := New(bad, "k"); err == nil {
t.Errorf("%s sollte abgelehnt werden", bad)
}
}
t.Setenv(AllowHttp, "1")
if _, err := New("http://hugin.intern", "k"); err != nil {
t.Errorf("mit %s=1: %v", AllowHttp, err)
}
}
func TestNoRedirectToHTTP(t *testing.T) {
t.Setenv(AllowHttp, "")
seen := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = r.Header.Get("Authorization")
http.Redirect(w, r, "http://boese.example/api/v1/config", http.StatusFound)
}))
defer server.Close()
c, err := New(server.URL, "hk_geheim")
if err != nil {
t.Fatal(err)
}
if _, err := c.Configuration(); err == nil || !strings.Contains(err.Error(), "nicht unverschlüsselt") {
t.Errorf("Weiterleitung auf http:// an fremden Rechner muss an der Prüfung scheitern: %v", err)
}
if seen != "Bearer hk_geheim" {
t.Errorf("erste Anfrage: %q", seen)
}
}

View file

@ -0,0 +1,313 @@
// Package bundle liest Hugin-Bundles: ZIP (oder entpackter Ordner) mit
// hugin-bundle.json, den Dateien fürs Repo unter files/ und denen lokaler Server
// unter local/ (→ contract/hugin-bundle.schema.json).
package bundle
import (
"archive/zip"
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"hugin/cli/internal/paths"
)
const (
InstructionsFile = "hugin-bundle.json"
Format = 1
// Obergrenze je Datei im Bundle, gegen ZIP-Bomben
maxFile = 16 << 20
)
type Instructions struct {
Format int `json:"format"`
Generated string `json:"generated"`
Tools []string `json:"tools"`
Packages []Package `json:"packages"`
Operations []Operation `json:"operations"`
Hints []Hint `json:"hints"`
}
type Package struct {
ID string `json:"id"`
Version string `json:"version"`
Checksum string `json:"checksum"`
Kind string `json:"kind"`
Risk string `json:"risk"`
Compatibility string `json:"compatibility"`
Mandatory bool `json:"mandatory"`
}
// Operation fasst alle Arten zusammen; welche Felder gelten, hängt von Op ab.
type Operation struct {
Op string `json:"op"`
Package string `json:"package,omitempty"`
Path string `json:"path,omitempty"`
Source string `json:"source,omitempty"`
Sha256 string `json:"sha256,omitempty"`
File string `json:"file,omitempty"`
Block string `json:"block,omitempty"`
Content *string `json:"content,omitempty"`
Key []string `json:"key,omitempty"`
Mode string `json:"mode,omitempty"`
Value json.RawMessage `json:"value,omitempty"`
Create string `json:"create,omitempty"`
// install: lokaler Server außerhalb des Repos
Version string `json:"version,omitempty"`
Checksum string `json:"checksum,omitempty"`
Runtime string `json:"runtime,omitempty"`
Entry string `json:"entry,omitempty"`
Python string `json:"python,omitempty"`
Files []InstallFile `json:"files,omitempty"`
}
// InstallFile ist eine Datei eines lokalen Servers; Path gilt relativ zu seinem Ordner.
type InstallFile struct {
Path string `json:"path"`
Source string `json:"source"`
Sha256 string `json:"sha256"`
}
// Installs liefert die install-Operationen, also die lokalen Server des Bundles.
func (b *Bundle) Installs() []Operation {
var ops []Operation
for _, op := range b.Instructions.Operations {
if op.Op == "install" {
ops = append(ops, op)
}
}
return ops
}
// ListItem sagt, ob Value in einer Liste stehen soll statt den Schlüssel zu ersetzen.
func (o Operation) ListItem() bool { return o.Mode == "list_item" }
type Hint struct {
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
Package string `json:"package,omitempty"`
Tool string `json:"tool,omitempty"`
Version string `json:"version,omitempty"`
Successor string `json:"successor,omitempty"`
}
type Bundle struct {
Instructions Instructions
files map[string][]byte
}
// Empty ist ein Bundle ohne Pakete; angewendet entfernt es alles, was die Lock-Datei nennt.
func Empty() *Bundle {
return &Bundle{Instructions: Instructions{Format: Format}, files: map[string][]byte{}}
}
// File liefert den Inhalt einer Datei im Bundle (Pfad wie in source/create).
func (b *Bundle) File(source string) ([]byte, error) {
data, ok := b.files[source]
if !ok {
return nil, fmt.Errorf("%s fehlt im Bundle", source)
}
return data, nil
}
// Read öffnet ein Bundle als ZIP-Datei oder entpackten Ordner und prüft es.
func Read(path string) (*Bundle, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
var files map[string][]byte
if info.IsDir() {
files, err = fromFolder(path)
} else {
var raw []byte
if raw, err = os.ReadFile(path); err == nil {
files, err = fromZip(raw)
}
}
if err != nil {
return nil, err
}
return fromFiles(files)
}
// FromZip liest ein Bundle aus den Bytes einer ZIP-Datei (Antwort von POST /bundle).
func FromZip(raw []byte) (*Bundle, error) {
files, err := fromZip(raw)
if err != nil {
return nil, err
}
return fromFiles(files)
}
func fromZip(raw []byte) (map[string][]byte, error) {
z, err := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
if err != nil {
return nil, fmt.Errorf("Bundle ist kein ZIP: %w", err)
}
files := map[string][]byte{}
for _, f := range z.File {
if strings.HasSuffix(f.Name, "/") {
continue
}
if f.UncompressedSize64 > maxFile {
return nil, fmt.Errorf("%s ist zu groß", f.Name)
}
r, err := f.Open()
if err != nil {
return nil, err
}
data, err := io.ReadAll(io.LimitReader(r, maxFile+1))
r.Close()
if err != nil {
return nil, err
}
files[f.Name] = data
}
return files, nil
}
func fromFolder(folder string) (map[string][]byte, error) {
files := map[string][]byte{}
err := filepath.WalkDir(folder, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
rel, err := filepath.Rel(folder, p)
if err != nil {
return err
}
data, err := os.ReadFile(p)
files[filepath.ToSlash(rel)] = data
return err
})
return files, err
}
func fromFiles(files map[string][]byte) (*Bundle, error) {
raw, ok := files[InstructionsFile]
if !ok {
return nil, fmt.Errorf("%s fehlt im Bundle", InstructionsFile)
}
var a Instructions
if err := json.Unmarshal(raw, &a); err != nil {
return nil, fmt.Errorf("%s: %w", InstructionsFile, err)
}
b := &Bundle{Instructions: a, files: files}
return b, b.validate()
}
func (b *Bundle) validate() error {
if b.Instructions.Format != Format {
return fmt.Errorf("Bundle-Format %d wird nicht unterstützt (erwartet %d); hugin aktualisieren",
b.Instructions.Format, Format)
}
var errs []error
for i, op := range b.Instructions.Operations {
if err := b.validateOp(op); err != nil {
errs = append(errs, fmt.Errorf("Operation %d (%s): %w", i+1, op.Op, err))
}
}
return errors.Join(errs...)
}
func (b *Bundle) validateOp(op Operation) error {
switch op.Op {
case "file":
if err := paths.Validate(op.Path); err != nil {
return err
}
return b.validateSource(op.Source, op.Sha256)
case "remove":
return paths.Validate(op.Path)
case "block":
if op.Content == nil || op.Block == "" {
return errors.New("block und inhalt sind Pflicht")
}
if op.Create != "" {
if err := b.validateSource(op.Create, ""); err != nil {
return err
}
}
return paths.Validate(op.File)
case "json", "json_remove":
if len(op.Key) == 0 {
return errors.New("key fehlt")
}
if op.Op == "json" && len(op.Value) == 0 {
return errors.New("value fehlt")
}
if op.Create != "" {
if err := b.validateSource(op.Create, ""); err != nil {
return err
}
}
return paths.Validate(op.File)
case "install":
return b.validateInstall(op)
}
return fmt.Errorf("unbekannte Operation %q; hugin aktualisieren", op.Op)
}
var (
packageId = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
semver = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
)
func (b *Bundle) validateInstall(op Operation) error {
// Paket und Version werden Ordnernamen außerhalb des Repos: streng prüfen
if !packageId.MatchString(op.Package) || !semver.MatchString(op.Version) {
return fmt.Errorf("ungültiges Paket %q oder Version %q", op.Package, op.Version)
}
if op.Entry == "" || op.Checksum == "" {
return errors.New("entry und checksum sind Pflicht")
}
prefix := "local/" + op.Package + "/" + op.Version + "/"
for _, f := range op.Files {
if err := paths.Validate(f.Path); err != nil {
return err
}
if f.Source != prefix+f.Path {
return fmt.Errorf("source %q passt nicht zu %s%s", f.Source, prefix, f.Path)
}
data, err := b.File(f.Source)
if err != nil {
return err
}
if Sha256(data) != f.Sha256 {
return fmt.Errorf("%s: Prüfsumme stimmt nicht", f.Source)
}
}
return nil
}
func (b *Bundle) validateSource(source, sha string) error {
if !strings.HasPrefix(source, "files/") {
return fmt.Errorf("source %q liegt nicht unter files/", source)
}
if err := paths.Validate(source); err != nil {
return err
}
data, err := b.File(source)
if err != nil {
return err
}
if sha != "" && Sha256(data) != sha {
return fmt.Errorf("%s: Prüfsumme stimmt nicht", source)
}
return nil
}
func Sha256(data []byte) string {
total := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(total[:])
}

View file

@ -0,0 +1,92 @@
package bundle
import (
"archive/zip"
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
const examples = "../../../contract/examples/bundles"
func TestReadExamples(t *testing.T) {
entries, err := os.ReadDir(examples)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
if _, err := Read(filepath.Join(examples, e.Name())); err != nil {
t.Errorf("%s: %v", e.Name(), err)
}
}
}
func zipWith(t *testing.T, instructions map[string]any, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
z := zip.NewWriter(&buffer)
raw, _ := json.Marshal(instructions)
files[InstructionsFile] = string(raw)
for name, content := range files {
w, _ := z.Create(name)
w.Write([]byte(content))
}
z.Close()
return buffer.Bytes()
}
func instructions(ops ...map[string]any) map[string]any {
return map[string]any{"format": 1, "generated": "2026-10-18T12:00:00Z", "tools": []string{"claude"},
"packages": []any{}, "operations": ops, "hints": []any{}}
}
func install(pkg, version, path, source, sha string) map[string]any {
return map[string]any{"op": "install", "package": pkg, "version": version, "checksum": sha, "runtime": "python",
"entry": "x", "files": []any{map[string]any{"path": path, "source": source, "sha256": sha}}}
}
func TestInvalidBundles(t *testing.T) {
content := "x"
sha := Sha256([]byte(content))
tests := []struct {
name string
zip []byte
message string
}{
{"Pfad nach draußen", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x",
"path": "../x", "source": "files/x", "sha256": sha}), map[string]string{"files/x": content}), "Segment"},
{"absoluter Pfad", zipWith(t, instructions(map[string]any{"op": "remove", "package": "pub-x",
"path": "/etc/passwd"}), map[string]string{}), "relative"},
{"Windows-Pfad", zipWith(t, instructions(map[string]any{"op": "remove", "package": "pub-x",
"path": `C:\x`}), map[string]string{}), "relative"},
{"falsche Prüfsumme", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x",
"path": "x", "source": "files/x", "sha256": Sha256([]byte("y"))}), map[string]string{"files/x": content}), "Prüfsumme"},
{"Quelle fehlt", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x",
"path": "x", "source": "files/x", "sha256": sha}), map[string]string{}), "fehlt"},
{"Quelle außerhalb files/", zipWith(t, instructions(map[string]any{"op": "file", "package": "pub-x",
"path": "x", "source": "x", "sha256": sha}), map[string]string{"x": content}), "files/"},
{"unbekannte Operation", zipWith(t, instructions(map[string]any{"op": "ausfuehren"}), map[string]string{}), "unbekannte"},
{"lokaler Server: Paket als Pfad", zipWith(t, instructions(install("../x", "1.0.0", "x", "local/../x/1.0.0/x", sha)),
map[string]string{}), "ungültiges Paket"},
{"lokaler Server: Version als Pfad", zipWith(t, instructions(install("pub-x", "..", "x", "local/pub-x/../x", sha)),
map[string]string{}), "ungültiges Paket"},
{"lokaler Server: Datei nach draußen", zipWith(t, instructions(install("pub-x", "1.0.0", "../x", "local/pub-x/1.0.0/../x", sha)),
map[string]string{}), "Segment"},
{"lokaler Server: Quelle passt nicht", zipWith(t, instructions(install("pub-x", "1.0.0", "x", "local/pub-y/1.0.0/x", sha)),
map[string]string{"local/pub-y/1.0.0/x": content}), "passt nicht"},
{"lokaler Server: falsche Prüfsumme", zipWith(t, instructions(install("pub-x", "1.0.0", "x", "local/pub-x/1.0.0/x", Sha256([]byte("y")))),
map[string]string{"local/pub-x/1.0.0/x": content}), "Prüfsumme"},
{"neues Format", zipWith(t, map[string]any{"format": 2}, map[string]string{}), "Format"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := FromZip(tt.zip)
if err == nil || !strings.Contains(err.Error(), tt.message) {
t.Errorf("Fehler %v, erwartet %q", err, tt.message)
}
})
}
}

View file

@ -0,0 +1,92 @@
// Package config liest und schreibt .hugin.yaml (→ KONZEPT.md 11.2).
package config
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
const Filename = ".hugin.yaml"
type Config struct {
Server string `yaml:"server"`
Project string `yaml:"project"`
// "auto" oder Liste aus claude, opencode
Tools Tools `yaml:"tools"`
OpencodeReadsClaude *bool `yaml:"opencode_reads_claude,omitempty"`
// warn | strict
ToolCheck string `yaml:"tool_check,omitempty"`
Packages []string `yaml:"packages"`
Unsubscribed []string `yaml:"unsubscribed"`
}
// Tools ist entweder automatisch (leer) oder eine feste Liste.
type Tools struct {
Auto bool
List []string
}
func (w Tools) MarshalYAML() (any, error) {
if w.Auto || len(w.List) == 0 {
return "auto", nil
}
return w.List, nil
}
func (w *Tools) UnmarshalYAML(n *yaml.Node) error {
if n.Kind == yaml.ScalarNode {
if n.Value != "auto" {
return fmt.Errorf("tools: %q, erwartet auto oder eine Liste", n.Value)
}
*w = Tools{Auto: true}
return nil
}
var items []string
if err := n.Decode(&items); err != nil {
return err
}
for _, name := range items {
if name != "claude" && name != "opencode" {
return fmt.Errorf("tools: unbekanntes Werkzeug %q", name)
}
}
*w = Tools{List: items}
return nil
}
func (k *Config) ReadsClaude() bool { return k.OpencodeReadsClaude == nil || *k.OpencodeReadsClaude }
func (k *Config) Strict() bool { return k.ToolCheck == "strict" }
func Load(repo string) (*Config, error) {
raw, err := os.ReadFile(filepath.Join(repo, Filename))
if errors.Is(err, fs.ErrNotExist) {
return nil, fmt.Errorf("%s fehlt; zuerst hugin init aufrufen", Filename)
}
if err != nil {
return nil, err
}
k := &Config{}
if err := yaml.Unmarshal(raw, k); err != nil {
return nil, fmt.Errorf("%s: %w", Filename, err)
}
switch k.ToolCheck {
case "", "warn", "strict":
default:
return nil, fmt.Errorf("%s: tool_check %q, erwartet warn oder strict", Filename, k.ToolCheck)
}
return k, nil
}
func (k *Config) Save(repo string) error {
raw, err := yaml.Marshal(k)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(repo, Filename), raw, 0o644)
}

View file

@ -0,0 +1,81 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
func write(t *testing.T, text string) string {
t.Helper()
repo := t.TempDir()
if err := os.WriteFile(filepath.Join(repo, Filename), []byte(text), 0o644); err != nil {
t.Fatal(err)
}
return repo
}
func TestLoadToolsAutoAndList(t *testing.T) {
k, err := Load(write(t, "server: https://hugin.intern\nproject: shop\ntools: auto\npackages: [pub-a]\n"))
if err != nil {
t.Fatal(err)
}
if !k.Tools.Auto || len(k.Packages) != 1 || !k.ReadsClaude() || k.Strict() {
t.Errorf("auto: %+v", k)
}
k, err = Load(write(t, "server: x\nproject: y\ntools: [claude, opencode]\ntool_check: strict\nopencode_reads_claude: false\n"))
if err != nil {
t.Fatal(err)
}
if k.Tools.Auto || strings.Join(k.Tools.List, ",") != "claude,opencode" || k.ReadsClaude() || !k.Strict() {
t.Errorf("Liste: %+v", k)
}
}
func TestLoadRejects(t *testing.T) {
for _, text := range []string{
"tools: immer\n",
"tools: [claude, cursor]\n",
"tool_check: hart\n",
} {
if _, err := Load(write(t, text)); err == nil {
t.Errorf("%q: Fehler erwartet", text)
}
}
if _, err := Load(t.TempDir()); err == nil || !strings.Contains(err.Error(), "hugin init") {
t.Errorf("fehlende Datei: %v", err)
}
}
func TestSaveRoundTrip(t *testing.T) {
repo := t.TempDir()
in := &Config{Server: "https://hugin.intern", Project: "shop", Tools: Tools{List: []string{"opencode"}},
Packages: []string{"pub-a", "pub-b"}, Unsubscribed: []string{"pub-c"}}
if err := in.Save(repo); err != nil {
t.Fatal(err)
}
out, err := Load(repo)
if err != nil {
t.Fatal(err)
}
if out.Server != in.Server || strings.Join(out.Tools.List, ",") != "opencode" ||
strings.Join(out.Packages, ",") != "pub-a,pub-b" || out.Unsubscribed[0] != "pub-c" {
t.Errorf("zurückgelesen: %+v", out)
}
raw, _ := os.ReadFile(filepath.Join(repo, Filename))
if !strings.Contains(string(raw), "tools:\n - opencode") {
t.Errorf("tools fehlt: %s", raw)
}
}
func TestEmptyToolsIsSavedAsAuto(t *testing.T) {
repo := t.TempDir()
if err := (&Config{Server: "x", Project: "y"}).Save(repo); err != nil {
t.Fatal(err)
}
raw, _ := os.ReadFile(filepath.Join(repo, Filename))
if !strings.Contains(string(raw), "tools: auto") {
t.Errorf("erwartet tools: auto, war:\n%s", raw)
}
}

View file

@ -0,0 +1,943 @@
// Package importieren wandelt vorhandene Agenten-Dateien eines Repos in
// Hugin-Pakete im Quellformat um (hugin import).
//
// OpenCode zuerst: Gibt es denselben Namen an mehreren Orten, gewinnt die
// OpenCode-Fassung. Werkzeugspezifische Felder bleiben in extra.opencode bzw.
// extra.claude erhalten, damit nach dem Rückweg durch Hugin dasselbe ankommt.
package importer
import (
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/tailscale/hujson"
"gopkg.in/yaml.v3"
)
type Options struct {
Repo string
Target string
Prefix string // z. B. "pub-" oder "shop-"
Owner string // Projekt-Slug
Overwrite bool
}
type Package struct {
ID string
Kind string
Sources []string
Hints []string
}
type Report struct {
Packages []Package
Skipped []Reason
}
type Reason struct {
Source string
Text string
}
// manifest entspricht hugin.yaml; Reihenfolge der Felder = Reihenfolge in der Datei.
type manifest struct {
ID string `yaml:"id"`
Kind string `yaml:"kind"`
Version string `yaml:"version"`
Owner string `yaml:"owner"`
Scope string `yaml:"scope"`
Description string `yaml:"description"`
License string `yaml:"license,omitempty"`
Arguments []string `yaml:"arguments,omitempty,flow"`
Capability any `yaml:"capability,omitempty"`
Model string `yaml:"model,omitempty"`
Server *mcpServer `yaml:"server,omitempty"`
Extra map[string]map[string]*yaml.Node `yaml:"extra,omitempty"`
}
type mcpServer struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Headers map[string]string `yaml:"headers,omitempty"`
}
type draft struct {
manifest
contentFile string
content string
files map[string][]byte // Hilfsdateien
sources []string
hints []string
}
type importer struct {
opt Options
packages map[string]*draft // nach ID
order []string
report *Report
opencode map[string]any
ocNode *yaml.Node
}
// Import liest das Repo und schreibt die Pakete nach opt.Target.
func Import(opt Options) (*Report, error) {
if !idPattern.MatchString(strings.TrimSuffix(opt.Prefix, "-")) || !strings.HasSuffix(opt.Prefix, "-") {
return nil, fmt.Errorf("--prefix %q: z. B. pub- (öffentlich) oder <tenant>- / <projekt>-", opt.Prefix)
}
in := &importer{opt: opt, packages: map[string]*draft{}, report: &Report{}}
var err error
if in.opencode, in.ocNode, err = readJson(opt.Repo, "opencode.json", "opencode.jsonc"); err != nil {
return nil, err
}
for _, step := range []func() error{in.skills, in.agents, in.commands, in.rules, in.mcp, in.plugins} {
if err := step(); err != nil {
return nil, err
}
}
return in.report, in.write()
}
// ---- Hilfen ----------------------------------------------------------------
var (
idPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
notAlnum = regexp.MustCompile(`[^a-z0-9]+`)
managed = "Verwaltet von Hugin ("
)
func (in *importer) id(name string) string {
slug := strings.Trim(notAlnum.ReplaceAllString(strings.ToLower(name), "-"), "-")
if !strings.HasPrefix(slug+"-", in.opt.Prefix) {
slug = in.opt.Prefix + slug
}
if len(slug) > 64 {
slug = strings.TrimRight(slug[:64], "-")
}
return slug
}
func (in *importer) scope() string {
if in.opt.Prefix == "pub-" {
return "public"
}
return "tenant"
}
func (in *importer) next(kind, name, description, source string) (*draft, bool) {
id := in.id(name)
if existing, ok := in.packages[id]; ok {
return existing, false
}
e := &draft{manifest: manifest{ID: id, Kind: kind, Version: "1.0.0", Owner: in.opt.Owner,
Scope: in.scope(), Description: singleLine(description)}, files: map[string][]byte{}}
if e.Description == "" {
e.Description = "TODO: Wann und wofür der Agent das nutzt."
e.hints = append(e.hints, "keine Beschreibung gefunden: in hugin.yaml ergänzen")
}
in.packages[id] = e
in.order = append(in.order, id)
e.sources = append(e.sources, source)
return e, true
}
func (in *importer) skip(source, text string) {
in.report.Skipped = append(in.report.Skipped, Reason{source, text})
}
// extra übernimmt einen Wert unverändert, mit Reihenfolge (z. B. Muster unter
// permission.bash: in OpenCode gewinnt die letzte passende Regel).
func (e *draft) extra(tool, field string, value *yaml.Node) {
if value == nil {
return
}
clearStyle(value)
if e.Extra == nil {
e.Extra = map[string]map[string]*yaml.Node{}
}
if e.Extra[tool] == nil {
e.Extra[tool] = map[string]*yaml.Node{}
}
e.Extra[tool][field] = value
}
func singleLine(s string) string { return strings.Join(strings.Fields(s), " ") }
// child liefert den Wert eines Schlüssels in einem YAML-Mapping (oder nil).
func child(n *yaml.Node, key string) *yaml.Node {
if n != nil && n.Kind == yaml.DocumentNode && len(n.Content) == 1 {
n = n.Content[0]
}
if n == nil || n.Kind != yaml.MappingNode {
return nil
}
for i := 0; i+1 < len(n.Content); i += 2 {
if n.Content[i].Value == key {
return n.Content[i+1]
}
}
return nil
}
// clearStyle schreibt Werte aus JSON als normales YAML (Block statt {…}).
func clearStyle(n *yaml.Node) {
n.Style &^= yaml.FlowStyle
if n.Kind == yaml.ScalarNode && n.Tag == "!!str" {
n.Style = 0
}
n.HeadComment, n.LineComment, n.FootComment = "", "", ""
for _, c := range n.Content {
clearStyle(c)
}
}
// frontmatter trennt YAML-Frontmatter vom Rumpf einer Markdown-Datei. Die Felder
// gibt es als Map (zum Auswerten) und als Knoten (zum Übernehmen).
func frontmatter(raw []byte) (map[string]any, *yaml.Node, string, error) {
text := strings.ReplaceAll(string(raw), "\r\n", "\n")
text = strings.TrimPrefix(text, "\ufeff")
if !strings.HasPrefix(text, "---\n") {
return map[string]any{}, nil, text, nil
}
end := strings.Index(text[4:], "\n---")
if end < 0 {
return nil, nil, "", errors.New("Frontmatter ohne Ende (---)")
}
header, body := text[4:4+end], text[4+end+4:]
body = strings.TrimPrefix(body, "\n")
fields, node := map[string]any{}, &yaml.Node{}
if err := yaml.Unmarshal([]byte(header), &fields); err != nil {
return nil, nil, "", fmt.Errorf("Frontmatter: %w", err)
}
if err := yaml.Unmarshal([]byte(header), node); err != nil {
return nil, nil, "", fmt.Errorf("Frontmatter: %w", err)
}
return fields, node, strings.TrimLeft(body, "\n"), nil
}
func stringField(m map[string]any, field string) string {
if v, ok := m[field].(string); ok {
return v
}
return ""
}
func isManaged(raw []byte) bool {
header := raw
if len(header) > 400 {
header = header[:400]
}
return bytes.Contains(header, []byte(managed))
}
func readJson(repo string, names ...string) (map[string]any, *yaml.Node, error) {
for _, name := range names {
p := filepath.Join(repo, name)
if info, err := os.Lstat(p); err == nil && info.Mode()&fs.ModeSymlink != 0 {
return nil, nil, fmt.Errorf("%s ist ein Symlink; Import liest nur Dateien im Repo", name)
}
raw, err := os.ReadFile(p)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, nil, err
}
value, err := hujson.Standardize(raw)
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", name, err)
}
m, node := map[string]any{}, &yaml.Node{}
if err := yaml.Unmarshal(value, &m); err != nil { // JSON ist gültiges YAML
return nil, nil, fmt.Errorf("%s: %w", name, err)
}
if err := yaml.Unmarshal(value, node); err != nil {
return nil, nil, fmt.Errorf("%s: %w", name, err)
}
return m, node, nil
}
return map[string]any{}, nil, nil
}
func object(m map[string]any, field string) map[string]any {
if v, ok := m[field].(map[string]any); ok {
return v
}
return map[string]any{}
}
// markdownFiles liefert *.md unter den Ordnern (rekursiv), relativ zum Repo.
func (in *importer) markdownFiles(folder ...string) []string {
var result []string
for _, o := range folder {
base := filepath.Join(in.opt.Repo, o)
filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".md") || d.Type()&fs.ModeSymlink != 0 {
return nil
}
rel, _ := filepath.Rel(in.opt.Repo, p)
result = append(result, filepath.ToSlash(rel))
return nil
})
}
return result
}
// read folgt keinen Symlinks: ein Import soll nichts von außerhalb des Repos mitnehmen.
func (in *importer) read(rel string) ([]byte, error) {
p := filepath.Join(in.opt.Repo, filepath.FromSlash(rel))
info, err := os.Lstat(p)
if err != nil {
return nil, err
}
if info.Mode()&fs.ModeSymlink != 0 || !info.Mode().IsRegular() {
return nil, fmt.Errorf("%s: keine normale Datei (Symlink?)", rel)
}
return os.ReadFile(p)
}
// ---- Skills ----------------------------------------------------------------
// Bekannte Felder einer SKILL.md; der Rest sind Zusätze für Claude Code.
var skillCoreFields = map[string]bool{"name": true, "description": true, "license": true, "metadata": true, "compatibility": true}
func (in *importer) skills() error {
for _, folder := range []string{".opencode/skills", ".opencode/skill", ".agents/skills", ".claude/skills"} {
entries, err := os.ReadDir(filepath.Join(in.opt.Repo, folder))
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return err
}
for _, d := range entries {
if !d.IsDir() {
continue
}
if err := in.skill(folder + "/" + d.Name()); err != nil {
return err
}
}
}
return nil
}
func (in *importer) skill(folder string) error {
source := folder + "/SKILL.md"
raw, err := in.read(source)
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
in.skip(source, err.Error())
return nil
}
if isManaged(raw) {
in.skip(source, "wird schon von Hugin verwaltet")
return nil
}
fields, header, body, err := frontmatter(raw)
if err != nil {
in.skip(source, err.Error())
return nil
}
name := stringField(fields, "name")
if name == "" {
name = filepath.Base(folder)
}
e, next := in.next("skill", name, stringField(fields, "description"), source)
if !next {
in.skip(source, fmt.Sprintf("gleicher Skill schon aus %s übernommen (OpenCode zuerst)", e.sources[0]))
return nil
}
e.contentFile, e.content = "SKILL.md", body
if e.ID != name {
e.hints = append(e.hints, fmt.Sprintf("Skill heißt künftig %s statt %s; Verweise in Prompts anpassen", e.ID, name))
}
e.License = stringField(fields, "license")
if meta, ok := fields["metadata"].(map[string]any); ok && len(meta) > 0 {
e.extra("opencode", "metadata", child(header, "metadata"))
}
if _, ok := fields["compatibility"]; ok {
e.hints = append(e.hints, "Feld compatibility nicht übernommen (Hugin setzt die Werkzeuge über targets)")
}
for field := range fields {
if !skillCoreFields[field] {
e.extra("claude", field, child(header, field))
}
}
// Hilfsdateien des Skill-Ordners
base := filepath.Join(in.opt.Repo, filepath.FromSlash(folder))
return filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(base, p)
rel = filepath.ToSlash(rel)
if rel == "." || rel == "SKILL.md" {
return nil
}
if strings.HasPrefix(d.Name(), ".") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.Type()&fs.ModeSymlink != 0 {
e.hints = append(e.hints, "Symlink "+rel+" nicht übernommen")
return nil
}
if d.IsDir() {
return nil
}
data, err := os.ReadFile(p)
e.files[rel] = data
return err
})
}
// ---- Subagenten --------------------------------------------------------------
// Fähigkeitsklassen aus OpenCode-Rechten bzw. Claude-Werkzeugen (→ KONZEPT.md 7.4)
func capabilityFromPermissions(permissions map[string]any) []string {
allowed := func(field string, includeAsk bool) bool {
switch w := permissions[field].(type) {
case string:
return w == "allow" || (includeAsk && w == "ask")
case map[string]any: // Muster: erlaubt, wenn irgendein Muster nicht deny ist
for _, v := range w {
if s, _ := v.(string); s == "allow" || (includeAsk && s == "ask") {
return true
}
}
}
return false
}
classes := []string{"read"}
if allowed("edit", false) {
classes = append(classes, "write")
}
if allowed("bash", true) {
classes = append(classes, "shell")
}
if allowed("webfetch", false) || allowed("websearch", false) {
classes = append(classes, "web")
}
return classes
}
func capabilityFromTools(tools string) []string {
classes := []string{"read"}
has := func(names ...string) bool {
for _, n := range names {
if regexp.MustCompile(`\b` + n + `\b`).MatchString(tools) {
return true
}
}
return false
}
if has("Edit", "Write", "MultiEdit") {
classes = append(classes, "write")
}
if has("Bash") {
classes = append(classes, "shell")
}
if has("WebFetch", "WebSearch") {
classes = append(classes, "web")
}
return classes
}
func capabilityValue(classes []string) any {
if len(classes) == 1 {
return classes[0]
}
return classes
}
// Felder eines OpenCode-Agenten, die Hugin selbst setzt
var agentCoreFields = map[string]bool{"description": true, "prompt": true, "name": true}
func (in *importer) agents() error {
for _, source := range in.markdownFiles(".opencode/agents", ".opencode/agent") {
raw, err := in.read(source)
if err != nil {
in.skip(source, err.Error())
continue
}
if isManaged(raw) {
in.skip(source, "wird schon von Hugin verwaltet")
continue
}
fields, header, body, err := frontmatter(raw)
if err != nil {
in.skip(source, err.Error())
continue
}
name := strings.TrimSuffix(filepath.Base(source), ".md")
in.opencodeAgent(name, fields, header, body, source)
}
// Agenten direkt in opencode.json
agents := object(in.opencode, "agent")
for _, name := range sorted(agents) {
fields, _ := agents[name].(map[string]any)
prompt := stringField(fields, "prompt")
if prompt == "" || strings.HasPrefix(prompt, "{file:") {
in.skip("opencode.json agent."+name, "ohne eigenen Prompt (Einstellungen eines eingebauten Agenten oder {file:…})")
continue
}
in.opencodeAgent(name, fields, child(child(in.ocNode, "agent"), name), prompt, "opencode.json agent."+name)
}
// Claude Code: neue Agenten oder Zusätze zu schon übernommenen
for _, source := range in.markdownFiles(".claude/agents") {
raw, err := in.read(source)
if err != nil {
in.skip(source, err.Error())
continue
}
if isManaged(raw) {
in.skip(source, "wird schon von Hugin verwaltet")
continue
}
fields, header, body, err := frontmatter(raw)
if err != nil {
in.skip(source, err.Error())
continue
}
name := stringField(fields, "name")
if name == "" {
name = strings.TrimSuffix(filepath.Base(source), ".md")
}
e, next := in.next("subagent", name, stringField(fields, "description"), source)
if !next {
if e.Kind == "subagent" {
e.sources = append(e.sources, source)
e.hints = append(e.hints, "Prompt aus der OpenCode-Fassung; "+source+" nur für Claude-Felder genutzt")
in.claudeAgentFields(e, fields, header)
}
continue
}
e.contentFile, e.content, e.Model = "AGENT.md", body, "inherit"
e.Capability = capabilityValue(capabilityFromTools(fmt.Sprint(fields["tools"])))
in.claudeAgentFields(e, fields, header)
}
return nil
}
func (in *importer) opencodeAgent(name string, fields map[string]any, header *yaml.Node, prompt, source string) {
e, next := in.next("subagent", name, stringField(fields, "description"), source)
if !next {
in.skip(source, "gleicher Name schon übernommen aus "+e.sources[0])
return
}
e.contentFile, e.content, e.Model = "AGENT.md", strings.TrimLeft(prompt, "\n"), "inherit"
permissions, _ := fields["permission"].(map[string]any)
e.Capability = capabilityValue(capabilityFromPermissions(permissions))
for field := range fields {
if !agentCoreFields[field] {
e.extra("opencode", field, child(header, field))
}
}
if _, ok := fields["mode"]; !ok {
e.hints = append(e.hints, "ohne mode: OpenCode nutzte den Agenten auch als Hauptagent, Hugin liefert ihn als Subagent (mode in extra.opencode setzen, falls nötig)")
}
if _, ok := fields["tools"]; ok {
e.hints = append(e.hints, "OpenCode-Feld tools ist veraltet; besser permission nutzen")
}
}
func (in *importer) claudeAgentFields(e *draft, fields map[string]any, header *yaml.Node) {
for field := range fields {
switch field {
case "name", "description", "tools":
default:
e.extra("claude", field, child(header, field))
}
}
}
func sorted(m map[string]any) []string {
names := make([]string, 0, len(m))
for k := range m {
names = append(names, k)
}
sort.Strings(names)
return names
}
// ---- Befehle -------------------------------------------------------------------
var (
numberedOpenCode = regexp.MustCompile(`\$([1-9][0-9]*)\b`)
numberedClaude = regexp.MustCompile(`\$ARGUMENTS\[([0-9]+)\]|\$([0-9]+)\b`)
)
// Platzhalter in {{argN}} umwandeln; ab bestimmt, ob $1 (OpenCode) oder $0 (Claude) das erste ist.
func placeholder(text string, pattern *regexp.Regexp, from int) (string, []string) {
highest := 0
next := pattern.ReplaceAllStringFunc(text, func(t string) string {
parts := pattern.FindStringSubmatch(t)
number := parts[1]
if number == "" && len(parts) > 2 {
number = parts[2]
}
var n int
fmt.Sscanf(number, "%d", &n)
n = n - from + 1
if n > highest {
highest = n
}
return fmt.Sprintf("{{arg%d}}", n)
})
arguments := make([]string, highest)
for i := range arguments {
arguments[i] = fmt.Sprintf("arg%d", i+1)
}
return next, arguments
}
var commandCoreFields = map[string]bool{"description": true, "template": true}
func (in *importer) commands() error {
for _, source := range in.markdownFiles(".opencode/commands", ".opencode/command") {
raw, err := in.read(source)
if err != nil {
in.skip(source, err.Error())
continue
}
if isManaged(raw) {
in.skip(source, "wird schon von Hugin verwaltet")
continue
}
fields, header, body, err := frontmatter(raw)
if err != nil {
in.skip(source, err.Error())
continue
}
in.opencodeCommand(strings.TrimSuffix(filepath.Base(source), ".md"), fields, header, body, source)
}
commands := object(in.opencode, "command")
for _, name := range sorted(commands) {
fields, _ := commands[name].(map[string]any)
template := stringField(fields, "template")
if template == "" || strings.HasPrefix(template, "{file:") {
in.skip("opencode.json command."+name, "ohne Vorlage oder mit {file:…}")
continue
}
in.opencodeCommand(name, fields, child(child(in.ocNode, "command"), name), template, "opencode.json command."+name)
}
for _, source := range in.markdownFiles(".claude/commands") {
raw, err := in.read(source)
if err != nil {
in.skip(source, err.Error())
continue
}
if isManaged(raw) {
in.skip(source, "wird schon von Hugin verwaltet")
continue
}
fields, header, body, err := frontmatter(raw)
if err != nil {
in.skip(source, err.Error())
continue
}
name := strings.TrimSuffix(filepath.ToSlash(strings.TrimPrefix(source, ".claude/commands/")), ".md")
e, next := in.next("command", name, stringField(fields, "description"), source)
if !next {
in.skip(source, "gleicher Befehl schon übernommen aus "+e.sources[0]+" (OpenCode zuerst)")
continue
}
// Benannte Argumente von Claude Code ($name aus arguments) und nummerierte ($0, $ARGUMENTS[0])
content := body
var arguments []string
if names, ok := fields["arguments"].([]any); ok {
for _, n := range names {
s := fmt.Sprint(n)
arguments = append(arguments, s)
content = regexp.MustCompile(`\$`+regexp.QuoteMeta(s)+`\b`).ReplaceAllString(content, "{{"+s+"}}")
}
}
if len(arguments) == 0 {
content, arguments = placeholder(content, numberedClaude, 0)
}
e.contentFile, e.content, e.Arguments = "COMMAND.md", content, arguments
for field := range fields {
switch field {
case "description", "arguments", "argument-hint":
default:
e.extra("claude", field, child(header, field))
}
}
}
return nil
}
func (in *importer) opencodeCommand(name string, fields map[string]any, header *yaml.Node, template, source string) {
e, next := in.next("command", name, stringField(fields, "description"), source)
if !next {
in.skip(source, "gleicher Name schon übernommen aus "+e.sources[0])
return
}
content, arguments := placeholder(template, numberedOpenCode, 1)
e.contentFile, e.content, e.Arguments = "COMMAND.md", content, arguments
if len(arguments) > 0 {
e.hints = append(e.hints, fmt.Sprintf("Platzhalter $1 … in {{arg1}} … umbenannt (%d); sprechende Namen in COMMAND.md und arguments setzen", len(arguments)))
}
for field := range fields {
if !commandCoreFields[field] {
e.extra("opencode", field, child(header, field))
}
}
}
// ---- Regeln ----------------------------------------------------------------------
var hugBlock = regexp.MustCompile(`(?s)\n?<!-- hugin:begin.*?<!-- hugin:end -->\n?`)
func (in *importer) rules() error {
repoName := filepath.Base(mustAbs(in.opt.Repo))
agentsMd, err := in.read("AGENTS.md")
if err == nil {
in.rule(repoName+"-rules", "AGENTS.md", agentsMd)
}
if raw, err := in.read("CLAUDE.md"); err == nil {
if agentsMd != nil {
in.skip("CLAUDE.md", "es gibt eine AGENTS.md; die gilt für OpenCode (OpenCode 2 liest nur AGENTS.md)")
} else {
in.rule(repoName+"-rules", "CLAUDE.md", raw)
}
}
// Dateien aus instructions in opencode.json (lokale Pfade und Globs; URLs nicht)
entries, _ := in.opencode["instructions"].([]any)
for _, raw := range entries {
pattern := fmt.Sprint(raw)
if strings.Contains(pattern, "://") {
in.skip("opencode.json instructions "+pattern, "URL; Hugin übernimmt nur Dateien aus dem Repo")
continue
}
if strings.HasPrefix(pattern, ".hugin/") {
continue // verwaltet Hugin selbst
}
matches, _ := filepath.Glob(filepath.Join(in.opt.Repo, filepath.FromSlash(pattern)))
sort.Strings(matches)
for _, p := range matches {
rel, _ := filepath.Rel(in.opt.Repo, p)
rel = filepath.ToSlash(rel)
if strings.HasPrefix(rel, "../") {
in.skip("opencode.json instructions "+pattern, "liegt außerhalb des Repos")
continue
}
data, err := in.read(rel)
if err != nil {
in.skip(rel, err.Error())
continue
}
in.rule(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)), rel, data)
}
}
return nil
}
func (in *importer) rule(name, source string, raw []byte) {
text := strings.ReplaceAll(string(raw), "\r\n", "\n")
text = strings.TrimSpace(hugBlock.ReplaceAllString(text, "\n"))
if text == "" {
in.skip(source, "leer (oder nur der Block von Hugin)")
return
}
if isManaged(raw) {
in.skip(source, "wird schon von Hugin verwaltet")
return
}
title := ""
for _, row := range strings.Split(text, "\n") {
if strings.HasPrefix(row, "# ") {
title = strings.TrimPrefix(row, "# ")
break
}
}
description := "Regeln aus " + source
if title != "" {
description = title + " (aus " + source + ")"
}
e, next := in.next("rules", name, description, source)
if !next {
in.skip(source, "Name schon vergeben durch "+e.sources[0])
return
}
e.contentFile, e.content = "RULES.md", text+"\n"
e.hints = append(e.hints, "Regeln aus einem Repo sind oft projektspezifisch: Geltung prüfen")
}
func mustAbs(p string) string {
abs, err := filepath.Abs(p)
if err != nil {
return p
}
return abs
}
// ---- MCP ----------------------------------------------------------------------------
var (
envOpenCode = regexp.MustCompile(`\{env:([A-Za-z_][A-Za-z0-9_]*)\}`)
envClaude = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-[^}]*)?\}`)
schemaWort = regexp.MustCompile(`^([A-Za-z]+)\s+`)
)
// convertHeader wandelt Umgebungsvariablen um; feste Werte ersetzt sie durch eine Variable.
func convertHeader(server, name, value string, env *regexp.Regexp) (string, string) {
next := env.ReplaceAllString(value, "{{env:$1}}")
if strings.Contains(next, "{{env:") {
return next, ""
}
variable := strings.ToUpper(notAlnum.ReplaceAllString(strings.ToLower(server+"_"+name), "_"))
replacement := "{{env:" + variable + "}}"
if t := schemaWort.FindStringSubmatch(value); t != nil {
replacement = t[1] + " " + replacement
}
return replacement, fmt.Sprintf("Kopfzeile %s hatte einen festen Wert; ersetzt durch {{env:%s}} (Wert nicht übernommen)", name, variable)
}
func (in *importer) mcp() error {
server := object(in.opencode, "mcp")
if s, ok := server["servers"].(map[string]any); ok { // OpenCode 2: mcp.servers
server = s
}
for _, name := range sorted(server) {
fields, _ := server[name].(map[string]any)
source := "opencode.json mcp." + name
if stringField(fields, "type") != "remote" || stringField(fields, "url") == "" {
in.skip(source, "lokaler MCP-Server (command); Hugin bindet nur entfernte Server an")
continue
}
in.mcpPackage(name, stringField(fields, "url"), object(fields, "headers"), source, envOpenCode)
}
claude, _, err := readJson(in.opt.Repo, ".mcp.json")
if err != nil {
return err
}
server = object(claude, "mcpServers")
for _, name := range sorted(server) {
fields, _ := server[name].(map[string]any)
source := ".mcp.json mcpServers." + name
typ := stringField(fields, "type")
if (typ != "http" && typ != "sse") || stringField(fields, "url") == "" {
in.skip(source, "lokaler MCP-Server (command); Hugin bindet nur entfernte Server an")
continue
}
if _, already := in.packages[in.id(name+"-mcp")]; already {
in.skip(source, "gleicher Server schon aus opencode.json übernommen")
continue
}
in.mcpPackage(name, stringField(fields, "url"), object(fields, "headers"), source, envClaude)
}
return nil
}
func (in *importer) mcpPackage(name, url string, header map[string]any, source string, env *regexp.Regexp) {
e, next := in.next("mcp", name+"-mcp", "Bindet "+name+" als MCP-Server an.", source)
if !next {
in.skip(source, "Name schon vergeben durch "+e.sources[0])
return
}
e.Server = &mcpServer{Name: name, URL: url}
for _, k := range sorted(header) {
value, hint := convertHeader(name, k, fmt.Sprint(header[k]), env)
if e.Server.Headers == nil {
e.Server.Headers = map[string]string{}
}
e.Server.Headers[k] = value
if hint != "" {
e.hints = append(e.hints, hint)
}
}
}
// ---- Plugins -------------------------------------------------------------------------
func (in *importer) plugins() error {
for _, folder := range []string{".opencode/plugins", ".opencode/plugin"} {
entries, _ := os.ReadDir(filepath.Join(in.opt.Repo, folder))
for _, d := range entries {
in.skip(folder+"/"+d.Name(), "OpenCode-Plugin: unterstützt Hugin noch nicht")
}
}
if items, ok := in.opencode["plugin"].([]any); ok {
for _, p := range items {
in.skip("opencode.json plugin "+fmt.Sprint(p), "OpenCode-Plugin: unterstützt Hugin noch nicht")
}
}
return nil
}
// ---- Schreiben -----------------------------------------------------------------------
func (in *importer) write() error {
if err := os.MkdirAll(in.opt.Target, 0o755); err != nil {
return err
}
for _, id := range in.order {
e := in.packages[id]
folder := filepath.Join(in.opt.Target, id)
if _, err := os.Stat(folder); err == nil && !in.opt.Overwrite {
in.skip(strings.Join(e.sources, ", "), "Zielordner "+folder+" gibt es schon (--overwrite)")
continue
}
if err := os.RemoveAll(folder); err != nil {
return err
}
if err := os.MkdirAll(folder, 0o755); err != nil {
return err
}
var buffer bytes.Buffer
enc := yaml.NewEncoder(&buffer)
enc.SetIndent(2)
if err := enc.Encode(e.manifest); err != nil {
return err
}
header := fmt.Sprintf("# Importiert von hugin import aus: %s\n", strings.Join(e.sources, ", "))
if err := os.WriteFile(filepath.Join(folder, "hugin.yaml"), append([]byte(header), buffer.Bytes()...), 0o644); err != nil {
return err
}
if e.contentFile != "" {
if err := os.WriteFile(filepath.Join(folder, e.contentFile), []byte(e.content), 0o644); err != nil {
return err
}
}
for rel, data := range e.files {
target := filepath.Join(folder, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := os.WriteFile(target, data, 0o644); err != nil {
return err
}
}
in.report.Packages = append(in.report.Packages, Package{e.ID, e.Kind, e.sources, e.hints})
}
return os.WriteFile(filepath.Join(in.opt.Target, "IMPORT.md"), []byte(in.report.markdown()), 0o644)
}
func (b *Report) markdown() string {
var s strings.Builder
s.WriteString("# Import nach Hugin\n\nVon `hugin import` erzeugt. Jedes Paket prüfen, dann:\n\n")
s.WriteString("```sh\nhugin validate <folder>\nhugin upload --submit <folder>\n```\n\n")
s.WriteString("## Pakete\n\n| Paket | Art | Quelle | Hinweise |\n|---|---|---|---|\n")
for _, p := range b.Packages {
fmt.Fprintf(&s, "| %s | %s | %s | %s |\n", p.ID, p.Kind, strings.Join(p.Sources, ", "), strings.Join(p.Hints, "; "))
}
if len(b.Skipped) > 0 {
s.WriteString("\n## Nicht übernommen\n\n| Quelle | Grund |\n|---|---|\n")
for _, g := range b.Skipped {
fmt.Fprintf(&s, "| %s | %s |\n", g.Source, g.Text)
}
}
return s.String()
}

View file

@ -0,0 +1,332 @@
package importer
import (
"os"
"path/filepath"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
// Beispiel-Repo mit OpenCode- und Claude-Fassungen, teils mit gleichen Namen.
var example = map[string]string{
".opencode/skills/munin/SKILL.md": `---
name: munin
description: Wie du Munin nutzt.
license: intern
metadata:
opencode/slash: "true"
---
Nutze Munin.
`,
".opencode/skills/munin/referenz/beispiele.md": "Beispiel\n",
".claude/skills/munin/SKILL.md": `---
name: munin
description: Claude-Fassung, soll verlieren.
---
Alt.
`,
".claude/skills/nur-claude/SKILL.md": `---
name: nur-claude
description: Gibt es nur bei Claude.
allowed-tools: [Read]
---
Inhalt.
`,
".claude/skills/hugin-alt/SKILL.md": "---\nname: x\ndescription: y\n---\n<!-- Verwaltet von Hugin (pub-x 1.0.0) -->\n",
".opencode/agents/review.md": `---
description: Prüft Änderungen.
mode: subagent
temperature: 0.1
permission:
edit: deny
bash:
"git diff*": allow
"*": deny
webfetch: deny
---
Du prüfst Code.
`,
".claude/agents/review.md": `---
name: review
description: Claude-Fassung.
tools: Read, Grep, Edit
model: sonnet
---
Anderer Prompt.
`,
".claude/agents/schreiber.md": `---
name: schreiber
description: Schreibt Doku.
tools: Read, Write, WebFetch
---
Du schreibst.
`,
".opencode/commands/release.md": `---
description: Release-Notiz
agent: build
---
Notiz für Version $1, gedacht für $2. Weitere: $ARGUMENTS
`,
".claude/commands/deploy.md": `---
description: Deployt
arguments: [umgebung]
allowed-tools: Bash(make:*)
---
Deploye nach $umgebung.
`,
".claude/commands/release.md": "---\ndescription: verliert\n---\nx\n",
"AGENTS.md": "# Regeln des Shops\n\nImmer testen.\n\n<!-- hugin:begin (nicht von Hand ändern) -->\nalt\n<!-- hugin:end -->\n",
"CLAUDE.md": "# Claude\n\nwird ignoriert, weil AGENTS.md da ist\n",
"doku/stil.md": "# Stil\n\nKurz schreiben.\n",
"opencode.json": `{
// Kommentar
"instructions": ["doku/*.md", ".hugin/rules/*.md", "https://x.example/regeln.md"],
"mcp": {
"munin": {"type": "remote", "url": "https://munin.intern/mcp", "headers": {"Authorization": "Bearer {env:MUNIN_KEY}"}},
"lokal": {"type": "local", "command": ["npx", "x"]}
},
"agent": {
"build": {"model": "anthropic/claude-sonnet-5-5"},
"doku": {"description": "Doku-Agent", "prompt": "Du dokumentierst.", "permission": {"edit": "allow"}}
},
"command": {"kurz": {"template": "Fasse $1 zusammen.", "description": "Kurzfassung"}},
"plugin": ["opencode-x"]
}`,
".mcp.json": `{"mcpServers": {
"munin": {"type": "http", "url": "https://andere.url/mcp"},
"jira": {"type": "http", "url": "https://jira.intern/mcp", "headers": {"Authorization": "Bearer geheim123"}}
}}`,
".opencode/plugins/x.ts": "export default {}\n",
}
func createRepo(t *testing.T) string {
t.Helper()
repo := filepath.Join(t.TempDir(), "shop-api")
for rel, content := range example {
p := filepath.Join(repo, filepath.FromSlash(rel))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return repo
}
func readManifest(t *testing.T, target, id string) map[string]any {
t.Helper()
raw, err := os.ReadFile(filepath.Join(target, id, "hugin.yaml"))
if err != nil {
t.Fatalf("%s: %v", id, err)
}
m := map[string]any{}
if err := yaml.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
func file(t *testing.T, target, rel string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(target, filepath.FromSlash(rel)))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestImport(t *testing.T) {
repo := createRepo(t)
target := filepath.Join(t.TempDir(), "import")
b, err := Import(Options{Repo: repo, Target: target, Prefix: "shop-", Owner: "api"})
if err != nil {
t.Fatal(err)
}
ids := map[string]string{}
for _, p := range b.Packages {
ids[p.ID] = p.Kind
}
expected := map[string]string{
"shop-munin": "skill", "shop-nur-claude": "skill",
"shop-review": "subagent", "shop-doku": "subagent", "shop-schreiber": "subagent",
"shop-release": "command", "shop-kurz": "command", "shop-deploy": "command",
"shop-api-rules": "rules", "shop-stil": "rules",
"shop-munin-mcp": "mcp", "shop-jira-mcp": "mcp",
}
for id, kind := range expected {
if ids[id] != kind {
t.Errorf("%s: Art %q, erwartet %q", id, ids[id], kind)
}
}
if len(ids) != len(expected) {
t.Errorf("Pakete: %v", ids)
}
// Skill: OpenCode-Fassung gewinnt, Hilfsdateien und metadata bleiben
m := readManifest(t, target, "shop-munin")
if m["description"] != "Wie du Munin nutzt." || m["scope"] != "tenant" || m["owner"] != "api" || m["license"] != "intern" {
t.Errorf("Manifest: %v", m)
}
if extra := m["extra"].(map[string]any)["opencode"].(map[string]any)["metadata"].(map[string]any); extra["opencode/slash"] != "true" {
t.Errorf("metadata: %v", extra)
}
if file(t, target, "shop-munin/SKILL.md") != "Nutze Munin.\n" || file(t, target, "shop-munin/referenz/beispiele.md") != "Beispiel\n" {
t.Error("Skill-Inhalt")
}
if strings.HasPrefix(file(t, target, "shop-munin/SKILL.md"), "---") {
t.Error("Inhaltsdatei darf keine Frontmatter haben")
}
// Claude-only Skill: unbekannte Felder als extra.claude
if z := readManifest(t, target, "shop-nur-claude")["extra"].(map[string]any)["claude"].(map[string]any); z["allowed-tools"] == nil {
t.Errorf("extra.claude: %v", z)
}
// Subagent: Fähigkeit aus OpenCode-Rechten (bash mit Muster = shell), Rechte bleiben als Zusatz
m = readManifest(t, target, "shop-review")
if f, _ := m["capability"].([]any); len(f) != 2 || f[0] != "read" || f[1] != "shell" {
t.Errorf("capability: %v", m["capability"])
}
oc := m["extra"].(map[string]any)["opencode"].(map[string]any)
if oc["mode"] != "subagent" || oc["temperature"] != 0.1 || oc["permission"] == nil {
t.Errorf("extra.opencode: %v", oc)
}
if cl := m["extra"].(map[string]any)["claude"].(map[string]any); cl["model"] != "sonnet" {
t.Errorf("Claude-Felder der Zweitfassung: %v", cl)
}
// Reihenfolge der Muster bleibt (in OpenCode gewinnt die letzte passende Regel)
if y := file(t, target, "shop-review/hugin.yaml"); strings.Index(y, "git diff*") > strings.Index(y, "'*': deny") {
t.Errorf("Reihenfolge unter permission.bash verloren:\n%s", y)
}
if y := file(t, target, "shop-doku/hugin.yaml"); !strings.Contains(y, "permission:\n edit: allow") {
t.Errorf("Rechte aus opencode.json als Block-YAML:\n%s", y)
}
if file(t, target, "shop-review/AGENT.md") != "Du prüfst Code.\n" {
t.Error("Prompt muss aus der OpenCode-Fassung kommen")
}
if f, _ := readManifest(t, target, "shop-schreiber")["capability"].([]any); len(f) != 3 || f[1] != "write" || f[2] != "web" {
t.Errorf("Fähigkeit aus Claude-Werkzeugen: %v", f)
}
if file(t, target, "shop-doku/AGENT.md") != "Du dokumentierst." {
t.Error("Agent aus opencode.json")
}
// Befehle: $1/$2 → {{arg1}}/{{arg2}}, $ARGUMENTS bleibt; Claude-Namen bleiben benannt
m = readManifest(t, target, "shop-release")
if a, _ := m["arguments"].([]any); len(a) != 2 || a[0] != "arg1" {
t.Errorf("arguments: %v", m["arguments"])
}
if s := file(t, target, "shop-release/COMMAND.md"); s != "Notiz für Version {{arg1}}, gedacht für {{arg2}}. Weitere: $ARGUMENTS\n" {
t.Errorf("COMMAND.md: %q", s)
}
if m["extra"].(map[string]any)["opencode"].(map[string]any)["agent"] != "build" {
t.Error("agent als extra.opencode")
}
if s := file(t, target, "shop-deploy/COMMAND.md"); s != "Deploye nach {{umgebung}}.\n" {
t.Errorf("Claude-Befehl: %q", s)
}
// Regeln: AGENTS.md ohne Hugin-Block, CLAUDE.md übersprungen, instructions-Datei übernommen
if s := file(t, target, "shop-api-rules/RULES.md"); s != "# Regeln des Shops\n\nImmer testen.\n" {
t.Errorf("RULES.md: %q", s)
}
if readManifest(t, target, "shop-api-rules")["description"] != "Regeln des Shops (aus AGENTS.md)" {
t.Error("Beschreibung aus Überschrift")
}
// MCP: OpenCode-Fassung gewinnt; feste Kopfzeile wird zur Variable, Wert nie übernommen
m = readManifest(t, target, "shop-munin-mcp")
s := m["server"].(map[string]any)
if s["url"] != "https://munin.intern/mcp" || s["headers"].(map[string]any)["Authorization"] != "Bearer {{env:MUNIN_KEY}}" {
t.Errorf("MCP munin: %v", s)
}
jira := file(t, target, "shop-jira-mcp/hugin.yaml")
if strings.Contains(jira, "geheim123") || !strings.Contains(jira, "Bearer {{env:JIRA_AUTHORIZATION}}") {
t.Errorf("Geheimnis darf nicht ins Paket:\n%s", jira)
}
// Übersprungen mit Grund
reasons := map[string]string{}
for _, g := range b.Skipped {
reasons[g.Source] = g.Text
}
for source, part := range map[string]string{
".claude/skills/munin/SKILL.md": "OpenCode zuerst",
".claude/skills/hugin-alt/SKILL.md": "von Hugin verwaltet",
".claude/commands/release.md": "OpenCode zuerst",
"CLAUDE.md": "AGENTS.md",
"opencode.json mcp.lokal": "lokaler MCP-Server",
".mcp.json mcpServers.munin": "schon aus opencode.json",
"opencode.json agent.build": "ohne eigenen Prompt",
"opencode.json instructions https://x.example/regeln.md": "URL",
".opencode/plugins/x.ts": "noch nicht",
"opencode.json plugin opencode-x": "noch nicht",
} {
if !strings.Contains(reasons[source], part) {
t.Errorf("%s: Grund %q, erwartet %q", source, reasons[source], part)
}
}
if !strings.Contains(file(t, target, "IMPORT.md"), "| shop-munin | skill | .opencode/skills/munin/SKILL.md |") {
t.Error("IMPORT.md")
}
// Im Repo ändert sich nichts
if file(t, repo, "AGENTS.md") != example["AGENTS.md"] {
t.Error("Repo verändert")
}
}
func TestImportPublicAndExistingTarget(t *testing.T) {
repo := createRepo(t)
target := t.TempDir()
if _, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern"}); err != nil {
t.Fatal(err)
}
if readManifest(t, target, "pub-munin")["scope"] != "public" {
t.Error("pub- heißt öffentlich")
}
os.WriteFile(filepath.Join(target, "pub-munin", "SKILL.md"), []byte("bearbeitet\n"), 0o644)
b, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern"})
if err != nil {
t.Fatal(err)
}
if len(b.Packages) != 0 || file(t, target, "pub-munin/SKILL.md") != "bearbeitet\n" {
t.Error("vorhandene Ordner nur mit Ueberschreiben ersetzen")
}
if _, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern", Overwrite: true}); err != nil {
t.Fatal(err)
}
if file(t, target, "pub-munin/SKILL.md") != "Nutze Munin.\n" {
t.Error("Ueberschreiben")
}
if _, err := Import(Options{Repo: repo, Target: target, Prefix: "Shop"}); err == nil {
t.Error("ungültiges Präfix")
}
}
func TestImportFollowsNoSymlinks(t *testing.T) {
repo := createRepo(t)
outside := filepath.Join(t.TempDir(), "geheim.md")
os.WriteFile(outside, []byte("# Geheim\n"), 0o644)
os.MkdirAll(filepath.Join(repo, ".opencode/skills/link"), 0o755)
os.Symlink(outside, filepath.Join(repo, ".opencode/skills/link/SKILL.md"))
os.Symlink(outside, filepath.Join(repo, ".opencode/skills/munin/referenz/geheim.md"))
os.Symlink(outside, filepath.Join(repo, "doku/link.md"))
target := t.TempDir()
b, err := Import(Options{Repo: repo, Target: target, Prefix: "shop-", Owner: "api"})
if err != nil {
t.Fatal(err)
}
for _, p := range b.Packages {
if p.ID == "shop-link" {
t.Error("Symlink-Skill übernommen")
}
}
if _, err := os.Stat(filepath.Join(target, "shop-munin/referenz/geheim.md")); err == nil {
t.Error("Symlink-Hilfsdatei übernommen")
}
if _, err := os.Stat(filepath.Join(target, "shop-link")); err == nil {
t.Error("Symlink in instructions übernommen")
}
}

View file

@ -0,0 +1,54 @@
// Package keystore verwaltet den Key für den Hugin-Server: zuerst die
// Umgebungsvariable HUGIN_KEY, sonst der Schlüsselbund des Betriebssystems.
package keystore
import (
"errors"
"fmt"
"os"
"strings"
"github.com/zalando/go-keyring"
)
const (
Env = "HUGIN_KEY"
ServerEnv = "HUGIN_SERVER"
service = "hugin"
)
func normalize(server string) string {
return strings.ToLower(strings.TrimRight(strings.TrimSpace(server), "/"))
}
var ErrNoKey = errors.New("kein Key: hugin login aufrufen oder HUGIN_KEY setzen")
// Get liefert den Key für server.
//
// Ein Key aus HUGIN_KEY gilt für jeden Server; in CI kommt server aber aus der
// eingecheckten .hugin.yaml, die ein Pull Request ändern kann. Ist HUGIN_SERVER
// gesetzt, geht der Key deshalb nur an diesen Server. Im Schlüsselbund ist der
// Key ohnehin an die Adresse gebunden.
func Get(server string) (string, error) {
if key := os.Getenv(Env); key != "" {
if fixed := os.Getenv(ServerEnv); fixed != "" && normalize(fixed) != normalize(server) {
return "", fmt.Errorf("Server %s weicht von %s=%s ab; %s wird nicht gesendet", server, ServerEnv, fixed, Env)
}
return key, nil
}
key, err := keyring.Get(service, server)
if errors.Is(err, keyring.ErrNotFound) {
return "", ErrNoKey
}
return key, err
}
func Save(server, key string) error { return keyring.Set(service, server, key) }
func Delete(server string) error {
err := keyring.Delete(service, server)
if errors.Is(err, keyring.ErrNotFound) {
return nil
}
return err
}

View file

@ -0,0 +1,18 @@
package keystore
import "testing"
func TestKeyFromEnvOnlyToFixedServer(t *testing.T) {
t.Setenv(Env, "hk_test")
t.Setenv(ServerEnv, "")
if k, err := Get("https://irgendwo"); err != nil || k != "hk_test" {
t.Fatalf("ohne HUGIN_SERVER: %q, %v", k, err)
}
t.Setenv(ServerEnv, "https://hugin.intern/")
if k, err := Get("https://HUGIN.intern"); err != nil || k != "hk_test" {
t.Errorf("gleicher Server: %q, %v", k, err)
}
if k, err := Get("https://boese.example"); err == nil || k != "" {
t.Errorf("fremder Server bekommt den Key: %q", k)
}
}

View file

@ -0,0 +1,11 @@
//go:build !windows
package localmcp
import "syscall"
// Exec ersetzt hugin durch den Server: Ein- und Ausgabe, Signale und Rückgabewert
// gehören dann direkt dem Werkzeug.
func Exec(program string, args, env []string) error {
return syscall.Exec(program, append([]string{program}, args...), env)
}

View file

@ -0,0 +1,26 @@
//go:build windows
package localmcp
import (
"errors"
"os"
"os/exec"
)
// Exec startet den Server als Kindprozess (Windows kennt kein exec), reicht Ein- und
// Ausgabe durch und endet mit seinem Rückgabewert.
func Exec(program string, args, env []string) error {
c := exec.Command(program, args...)
c.Env, c.Stdin, c.Stdout, c.Stderr = env, os.Stdin, os.Stdout, os.Stderr
err := c.Run()
var exit *exec.ExitError
if errors.As(err, &exit) {
os.Exit(exit.ExitCode())
}
if err != nil {
return err
}
os.Exit(0)
return nil
}

View file

@ -0,0 +1,340 @@
// Package localmcp installiert und startet lokale MCP-Server (→ KONZEPT.md 20, Stufe 5b).
//
// Ablage außerhalb des Repos: <HUGIN_MCP_HOME oder ~/.local/share/mcp>/<paket>/<version>/,
// unter Windows also %USERPROFILE%\.local\share\mcp\…. Jede Version in einem eigenen
// Ordner, mehrere Repos teilen sie. Fertig ist eine Installation erst, wenn die
// Markierungsdatei geschrieben ist; nur Ordner mit dieser Datei gehören Hugin.
package localmcp
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"hugin/cli/internal/bundle"
"hugin/cli/internal/lock"
"hugin/cli/internal/paths"
)
const (
markerFile = ".hugin-install.json"
registryFile = ".hugin-repos"
requirements = "requirements.txt"
// TrialLock ist die Lock-Datei von hugin try; ihre Versionen gelten im Repo vor hugin.lock
TrialLock = ".hugin/trial.lock"
)
// Marker steht in jedem fertig installierten Ordner.
type Marker struct {
Package string `json:"package"`
Version string `json:"version"`
Checksum string `json:"checksum"`
Runtime string `json:"runtime"`
Entry string `json:"entry"`
}
// Home ist der Ordner für alle lokalen Server.
func Home() (string, error) {
if h := os.Getenv("HUGIN_MCP_HOME"); h != "" {
return filepath.Abs(h)
}
user, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(user, ".local", "share", "mcp"), nil
}
// Folder ist der Ordner einer Version.
func Folder(home, pkg, version string) string { return filepath.Join(home, pkg, version) }
// Read liefert die Markierung einer Installation; fehlt sie, ist die Version nicht installiert.
func Read(folder string) (*Marker, error) {
raw, err := os.ReadFile(filepath.Join(folder, markerFile))
if err != nil {
return nil, err
}
var m Marker
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", markerFile, err)
}
return &m, nil
}
// Installed sagt, ob genau diese Fassung (Prüfsumme) fertig installiert ist.
func Installed(home string, op bundle.Operation) bool {
m, err := Read(Folder(home, op.Package, op.Version))
return err == nil && m.Checksum == op.Checksum
}
// Install legt Dateien und venv an und installiert die Abhängigkeiten mit pip. pip liest
// den Index (Artifactory) aus der pip.conf bzw. pip.ini des Rechners. Ausgaben von venv
// und pip gehen nach log.
func Install(home string, b *bundle.Bundle, op bundle.Operation, log io.Writer) error {
if op.Runtime != "python" {
return fmt.Errorf("%s: Laufzeit %q kennt dieses hugin nicht; hugin aktualisieren", op.Package, op.Runtime)
}
folder := Folder(home, op.Package, op.Version)
// Halb fertige oder andere Fassung derselben Version: neu anlegen
if err := os.RemoveAll(folder); err != nil {
return err
}
for _, f := range op.Files {
data, err := b.File(f.Source)
if err != nil {
return err
}
target, err := paths.InRepo(folder, f.Path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
if err := os.WriteFile(target, data, 0o644); err != nil {
return err
}
}
python, err := FindPython(op.Python)
if err != nil {
return fmt.Errorf("%s: %w", op.Package, err)
}
venv := filepath.Join(folder, ".venv")
if err := run(log, folder, python[0], append(python[1:], "-m", "venv", venv)...); err != nil {
return fmt.Errorf("%s: venv anlegen: %w", op.Package, err)
}
if _, err := os.Stat(filepath.Join(folder, requirements)); err == nil {
err := run(log, folder, VenvPython(folder), "-m", "pip", "install", "--quiet", "--no-input",
"--disable-pip-version-check", "--require-hashes", "-r", requirements)
if err != nil {
return fmt.Errorf("%s: pip install: %w (Index aus pip.conf bzw. pip.ini erreichbar?)", op.Package, err)
}
}
marker, _ := json.MarshalIndent(Marker{op.Package, op.Version, op.Checksum, op.Runtime, op.Entry}, "", " ")
return os.WriteFile(filepath.Join(folder, markerFile), append(marker, '\n'), 0o644)
}
func run(log io.Writer, dir, name string, args ...string) error {
c := exec.Command(name, args...)
c.Dir, c.Stdout, c.Stderr = dir, log, log
return c.Run()
}
// FindPython sucht ein Python für die venv, das mindestens Version minimum hat (z. B. "3.10";
// leer: jedes Python 3): HUGIN_PYTHON, unter Windows der Starter py -3, sonst python3.14 bis
// python3.9, python3 und python. Auf vielen Rechnern ist python3 älter als ein daneben
// installiertes python3.12.
func FindPython(minimum string) ([]string, error) {
var candidates [][]string
if p := os.Getenv("HUGIN_PYTHON"); p != "" {
candidates = [][]string{{p}}
} else if runtime.GOOS == "windows" {
candidates = [][]string{{"py", "-3"}, {"python"}}
} else {
for minor := 14; minor >= 9; minor-- {
candidates = append(candidates, []string{fmt.Sprintf("python3.%d", minor)})
}
candidates = append(candidates, []string{"python3"}, []string{"python"})
}
var found []string
for _, c := range candidates {
if _, err := exec.LookPath(c[0]); err != nil {
continue
}
out, err := exec.Command(c[0], append(c[1:], "-c",
"import sys; print('%d.%d' % sys.version_info[:2])")...).Output()
if err != nil {
continue // z. B. der Platzhalter python.exe aus dem Microsoft Store
}
version := strings.TrimSpace(string(out))
if strings.HasPrefix(version, "3.") && !olderThan(version, minimum) {
return c, nil
}
found = append(found, strings.Join(c, " ")+" "+version)
}
need := "Python 3"
if minimum != "" {
need = "Python ≥ " + minimum
}
if len(found) > 0 {
return nil, fmt.Errorf("%s nötig, gefunden: %s; neueres installieren oder HUGIN_PYTHON setzen",
need, strings.Join(found, ", "))
}
return nil, fmt.Errorf("%s nötig, aber keins gefunden (python3, python oder unter Windows py); "+
"installieren oder HUGIN_PYTHON setzen", need)
}
// olderThan vergleicht Versionen wie 3.9 und 3.10 zahlenweise.
func olderThan(version, minimum string) bool {
if minimum == "" {
return false
}
var a, b, c, d int
fmt.Sscanf(version, "%d.%d", &a, &b)
fmt.Sscanf(minimum, "%d.%d", &c, &d)
return a < c || (a == c && b < d)
}
// VenvPython ist das Python der venv einer Installation.
func VenvPython(folder string) string {
if runtime.GOOS == "windows" {
return filepath.Join(folder, ".venv", "Scripts", "python.exe")
}
return filepath.Join(folder, ".venv", "bin", "python")
}
// Command liefert Programm, Argumente und Umgebung zum Start eines installierten Servers.
func Command(folder string, m *Marker) (string, []string, []string) {
args := []string{"-m", m.Entry}
if strings.HasSuffix(m.Entry, ".py") {
args = []string{filepath.Join(folder, filepath.FromSlash(m.Entry))}
}
pythonPath := folder
if old := os.Getenv("PYTHONPATH"); old != "" {
pythonPath += string(os.PathListSeparator) + old
}
// UTF-8 auch unter Windows, wo stdin sonst in der Codepage gelesen wird
env := append(os.Environ(), "PYTHONPATH="+pythonPath, "PYTHONUTF8=1")
return VenvPython(folder), args, env
}
// FindRepo sucht vom Ordner start aufwärts das Repo (hugin.lock oder .hugin.yaml);
// Werkzeuge starten lokale Server auch aus Unterordnern.
func FindRepo(start string) (string, error) {
folder, err := filepath.Abs(start)
if err != nil {
return "", err
}
for {
for _, marker := range []string{lock.Filename, ".hugin.yaml"} {
if _, err := os.Stat(filepath.Join(folder, marker)); err == nil {
return folder, nil
}
}
parent := filepath.Dir(folder)
if parent == folder {
return "", fmt.Errorf("kein %s in %s oder darüber; im Repo hugin sync ausführen", lock.Filename, start)
}
folder = parent
}
}
// Register merkt sich das Repo, damit Prune weiß, welche Versionen noch gebraucht werden.
func Register(home, repo string) error {
abs, err := filepath.Abs(repo)
if err != nil {
return err
}
repos, err := registered(home)
if err != nil || slices.Contains(repos, abs) {
return err
}
if err := os.MkdirAll(home, 0o755); err != nil {
return err
}
return writeRegistry(home, append(repos, abs))
}
func registered(home string) ([]string, error) {
f, err := os.Open(filepath.Join(home, registryFile))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
defer f.Close()
var repos []string
s := bufio.NewScanner(f)
for s.Scan() {
if line := strings.TrimSpace(s.Text()); line != "" {
repos = append(repos, line)
}
}
return repos, s.Err()
}
func writeRegistry(home string, repos []string) error {
var b strings.Builder
for _, r := range repos {
b.WriteString(r + "\n")
}
return os.WriteFile(filepath.Join(home, registryFile), []byte(b.String()), 0o644)
}
// Prune löscht installierte Versionen, die kein bekanntes Repo laut hugin.lock mehr nutzt.
// Ordner ohne Markierung fasst es nicht an; sie gehören nicht Hugin.
func Prune(home string, dryRun bool) ([]string, error) {
repos, err := registered(home)
if err != nil {
return nil, err
}
used := map[string]bool{}
var still []string
for _, r := range repos {
known := false
for _, name := range []string{lock.Filename, TrialLock} {
path := filepath.Join(r, filepath.FromSlash(name))
if _, err := os.Stat(path); err != nil {
continue
}
l, err := lock.LoadFrom(path)
if err != nil {
return nil, err
}
known = true
for id, p := range l.Packages {
used[id+"/"+p.Version] = true
}
}
if known {
still = append(still, r) // sonst: Repo weg oder ohne Hugin, vergessen
}
}
packages, err := os.ReadDir(home)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var removed []string
for _, p := range packages {
if !p.IsDir() {
continue
}
versions, err := os.ReadDir(filepath.Join(home, p.Name()))
if err != nil {
return removed, err
}
before := len(removed)
for _, v := range versions {
folder := filepath.Join(home, p.Name(), v.Name())
m, err := Read(folder)
if err != nil || m.Package != p.Name() || m.Version != v.Name() || used[p.Name()+"/"+v.Name()] {
continue
}
removed = append(removed, p.Name()+" "+v.Name())
if !dryRun {
if err := os.RemoveAll(folder); err != nil {
return removed, err
}
}
}
if !dryRun && len(removed) > before {
os.Remove(filepath.Join(home, p.Name())) // nur, wenn jetzt leer
}
}
if !dryRun {
return removed, writeRegistry(home, still)
}
return removed, nil
}

View file

@ -0,0 +1,186 @@
package localmcp
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"hugin/cli/internal/bundle"
)
const example = "../../../contract/examples/bundles/local-mcp"
func TestHome(t *testing.T) {
t.Setenv("HUGIN_MCP_HOME", "")
t.Setenv("HOME", "/home/anna")
t.Setenv("USERPROFILE", `C:\Users\anna`)
home, err := Home()
if err != nil {
t.Fatal(err)
}
user, _ := os.UserHomeDir()
if home != filepath.Join(user, ".local", "share", "mcp") {
t.Errorf("Home = %s", home)
}
t.Setenv("HUGIN_MCP_HOME", t.TempDir())
if home, _ := Home(); home != os.Getenv("HUGIN_MCP_HOME") {
t.Errorf("HUGIN_MCP_HOME nicht beachtet: %s", home)
}
}
func TestFindRepoFromSubfolder(t *testing.T) {
repo := t.TempDir()
os.WriteFile(filepath.Join(repo, "hugin.lock"), []byte("packages: {}\n"), 0o644)
sub := filepath.Join(repo, "a", "b")
os.MkdirAll(sub, 0o755)
found, err := FindRepo(sub)
if err != nil || found != repo {
t.Errorf("FindRepo = %q, %v", found, err)
}
other := t.TempDir()
os.WriteFile(filepath.Join(other, ".hugin.yaml"), []byte("server: x\n"), 0o644)
if found, _ := FindRepo(other); found != other {
t.Errorf("Repo ohne hugin.lock nicht gefunden: %q", found)
}
if _, err := FindRepo(t.TempDir()); err == nil {
t.Error("ohne hugin.lock kein Fehler")
}
}
func TestCommand(t *testing.T) {
folder := filepath.Join("srv", "1.0.0")
_, args, env := Command(folder, &Marker{Entry: "jira_mcp"})
if !slices.Equal(args, []string{"-m", "jira_mcp"}) {
t.Errorf("Modul: %v", args)
}
_, args, _ = Command(folder, &Marker{Entry: "tools/server.py"})
if !slices.Equal(args, []string{filepath.Join(folder, "tools", "server.py")}) {
t.Errorf("Skript: %v", args)
}
if !slices.Contains(env, "PYTHONUTF8=1") {
t.Error("PYTHONUTF8 fehlt")
}
}
// Echte venv mit dem Python des Rechners; das Beispiel braucht keine Abhängigkeiten.
func TestInstallAndRun(t *testing.T) {
if _, err := FindPython(""); err != nil {
t.Skip(err)
}
b, err := bundle.Read(example)
if err != nil {
t.Fatal(err)
}
op := b.Installs()[0]
home := t.TempDir()
if Installed(home, op) {
t.Fatal("vor der Installation schon installiert")
}
if err := Install(home, b, op, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
if !Installed(home, op) {
t.Fatal("nach der Installation nicht installiert")
}
folder := Folder(home, op.Package, op.Version)
m, _ := Read(folder)
program, args, env := Command(folder, m)
c := exec.Command(program, args...)
c.Env = env
c.Stdin = bytes.NewBufferString(`{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"zeit"}}` + "\n")
out, err := c.Output()
if err != nil || !bytes.Contains(out, []byte("UHR-4711")) {
t.Errorf("Server antwortet nicht: %s %v", out, err)
}
// Andere Fassung derselben Version gilt als nicht installiert
op.Checksum = "sha256:anders"
if Installed(home, op) {
t.Error("andere Prüfsumme gilt als installiert")
}
}
func TestOlderThan(t *testing.T) {
for _, tt := range []struct {
version, minimum string
older bool
}{{"3.9", "3.10", true}, {"3.12", "3.10", false}, {"3.10", "3.10", false}, {"3.13", "", false}} {
if olderThan(tt.version, tt.minimum) != tt.older {
t.Errorf("olderThan(%s, %s) != %v", tt.version, tt.minimum, tt.older)
}
}
}
func TestFindPythonRespectsMinimum(t *testing.T) {
if _, err := exec.LookPath("sh"); err != nil {
t.Skip("braucht sh")
}
fake := filepath.Join(t.TempDir(), "python")
os.WriteFile(fake, []byte("#!/bin/sh\necho 3.9\n"), 0o755)
t.Setenv("HUGIN_PYTHON", fake)
if _, err := FindPython("3.10"); err == nil || !strings.Contains(err.Error(), "3.9") {
t.Errorf("zu altes Python nicht gemeldet: %v", err)
}
if got, err := FindPython("3.9"); err != nil || got[0] != fake {
t.Errorf("passendes Python nicht genommen: %v %v", got, err)
}
}
func TestPipGetsHashesAndNoIndex(t *testing.T) {
if _, err := exec.LookPath("sh"); err != nil {
t.Skip("braucht sh")
}
home, fake := t.TempDir(), t.TempDir()
log := filepath.Join(fake, "aufrufe")
// Stellvertreter für python: legt die venv-Struktur an und schreibt die Aufrufe mit
script := "#!/bin/sh\nif [ \"$1\" = -c ]; then echo 3.12; exit; fi\necho \"$@\" >> " + log +
"\nif [ \"$1\" = -m ] && [ \"$2\" = venv ]; then mkdir -p \"$3/bin\"; cp \"$0\" \"$3/bin/python\"; fi\n"
python := filepath.Join(fake, "python")
os.WriteFile(python, []byte(script), 0o755)
t.Setenv("HUGIN_PYTHON", python)
b, _ := bundle.Read(example)
op := b.Installs()[0]
op.Files = append(op.Files, bundle.InstallFile{Path: "requirements.txt", Source: op.Files[0].Source})
if err := Install(home, b, op, &bytes.Buffer{}); err != nil {
t.Fatal(err)
}
calls, _ := os.ReadFile(log)
want := "-m pip install --quiet --no-input --disable-pip-version-check --require-hashes -r requirements.txt"
if !bytes.Contains(calls, []byte(want)) {
t.Errorf("pip-Aufruf fehlt:\n%s", calls)
}
}
func TestPruneKeepsUsedAndForeign(t *testing.T) {
home, repo := t.TempDir(), t.TempDir()
marker := func(pkg, version string) {
folder := Folder(home, pkg, version)
os.MkdirAll(folder, 0o755)
os.WriteFile(filepath.Join(folder, markerFile), []byte(`{"package":"`+pkg+`","version":"`+version+`"}`), 0o644)
}
marker("pub-a", "1.0.0")
marker("pub-a", "1.1.0")
marker("pub-a", "1.2.0") // gerade mit hugin try installiert
os.MkdirAll(filepath.Join(repo, ".hugin"), 0o755)
os.WriteFile(filepath.Join(repo, ".hugin", "trial.lock"), []byte("packages:\n pub-a:\n version: 1.2.0\n checksum: y\n"), 0o644)
// Ordner ohne Markierung gehört nicht Hugin, etwa ein eigener Server
os.MkdirAll(filepath.Join(home, "eigener-server", "bin"), 0o755)
os.MkdirAll(filepath.Join(home, "noch-leer"), 0o755)
os.WriteFile(filepath.Join(repo, "hugin.lock"), []byte("packages:\n pub-a:\n version: 1.1.0\n checksum: x\n"), 0o644)
if err := Register(home, repo); err != nil {
t.Fatal(err)
}
removed, err := Prune(home, false)
if err != nil || !slices.Equal(removed, []string{"pub-a 1.0.0"}) {
t.Fatalf("Prune = %v, %v", removed, err)
}
for _, keep := range []string{Folder(home, "pub-a", "1.1.0"), Folder(home, "pub-a", "1.2.0"), filepath.Join(home, "eigener-server", "bin"),
filepath.Join(home, "noch-leer")} {
if _, err := os.Stat(keep); err != nil {
t.Errorf("%s gelöscht", keep)
}
}
}

134
cli/internal/lock/lock.go Normal file
View file

@ -0,0 +1,134 @@
// Package lock liest und schreibt hugin.lock (→ KONZEPT.md 11.3).
//
// Die Lock-Datei sagt, welche Dateien, JSON-Schlüssel und Blöcke Hugin gehören
// und welchen Stand sie hatten. Daran erkennt sync Handänderungen und weiß, was
// beim Abbestellen zu entfernen ist.
package lock
import (
"bytes"
"errors"
"io/fs"
"os"
"path/filepath"
"slices"
"gopkg.in/yaml.v3"
)
const (
Filename = "hugin.lock"
header = "# Von hugin sync erzeugt. Nicht von Hand ändern.\n"
)
type Lock struct {
Packages map[string]*Package `yaml:"packages"`
Common Common `yaml:"common,omitempty"`
}
type Package struct {
Version string `yaml:"version"`
Checksum string `yaml:"checksum"`
Files map[string]string `yaml:"files,omitempty"`
Keys []Key `yaml:"keys,omitempty"`
}
// Key ist ein JSON-Schlüssel, den Hugin gesetzt hat. Bei Mode list_item
// gehört Hugin nur der Eintrag Value in der Liste.
type Key struct {
File string `yaml:"file"`
Path []string `yaml:"path,flow"`
Mode string `yaml:"mode,omitempty"`
Value any `yaml:"value,omitempty"`
Sha256 string `yaml:"sha256,omitempty"`
}
// Common hält, was keinem einzelnen Paket gehört.
type Common struct {
Keys []Key `yaml:"keys,omitempty"`
// "<datei>#<block>" → Prüfsumme des Blockinhalts
Blocks map[string]string `yaml:"blocks,omitempty"`
// Dateien, die Hugin angelegt hat, z. B. CLAUDE.md oder opencode.json
Created []string `yaml:"created,omitempty"`
}
func New() *Lock { return &Lock{Packages: map[string]*Package{}} }
// Load liest hugin.lock aus repo; fehlt die Datei, ist das Ergebnis leer.
func Load(repo string) (*Lock, error) {
return LoadFrom(filepath.Join(repo, Filename))
}
// LoadFrom liest eine Lock-Datei von path, z. B. .hugin/trial.lock.
func LoadFrom(path string) (*Lock, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, fs.ErrNotExist) {
return New(), nil
}
if err != nil {
return nil, err
}
l := New()
if err := yaml.Unmarshal(raw, l); err != nil {
return nil, err
}
if l.Packages == nil {
l.Packages = map[string]*Package{}
}
return l, nil
}
func (l *Lock) Bytes() ([]byte, error) {
var buffer bytes.Buffer
buffer.WriteString(header)
enc := yaml.NewEncoder(&buffer)
enc.SetIndent(2)
if err := enc.Encode(l); err != nil {
return nil, err
}
return buffer.Bytes(), enc.Close()
}
// Files liefert alle Dateien aller Pakete: Pfad → Prüfsumme.
func (l *Lock) Files() map[string]string {
all := map[string]string{}
for _, p := range l.Packages {
for path, sha := range p.Files {
all[path] = sha
}
}
return all
}
// AllKeys liefert die Schlüssel aller Pakete und die gemeinsamen.
func (l *Lock) AllKeys() []Key {
var all []Key
for _, p := range l.Packages {
all = append(all, p.Keys...)
}
return append(all, l.Common.Keys...)
}
// Find sucht einen Schlüssel; bei list_item muss auch der Wert passen.
func Find(items []Key, file string, path []string, mode string, value any) *Key {
for i, s := range items {
if s.File != file || !slices.Equal(s.Path, path) || s.Mode != mode {
continue
}
if mode == "list_item" && !equal(s.Value, value) {
continue
}
return &items[i]
}
return nil
}
func equal(a, b any) bool {
ya, _ := yaml.Marshal(a)
yb, _ := yaml.Marshal(b)
return bytes.Equal(ya, yb)
}
func (l *Lock) Created(file string) bool {
return slices.Contains(l.Common.Created, file)
}

View file

@ -0,0 +1,83 @@
package lock
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadMissingIsEmpty(t *testing.T) {
l, err := Load(t.TempDir())
if err != nil || l.Packages == nil || len(l.Packages) != 0 {
t.Fatalf("leer erwartet: %+v, %v", l, err)
}
}
func TestRoundTrip(t *testing.T) {
l := New()
l.Packages["pub-a"] = &Package{Version: "1.0.0", Checksum: "sha256:aa",
Files: map[string]string{".claude/skills/pub-a/SKILL.md": "sha256:01"},
Keys: []Key{{File: ".mcp.json", Path: []string{"mcpServers", "munin"}, Sha256: "sha256:02"}}}
l.Packages["pub-b"] = &Package{Version: "2.0.0", Checksum: "sha256:bb",
Files: map[string]string{".hugin/rules/pub-b.md": "sha256:03"}}
l.Common = Common{
Keys: []Key{{File: "opencode.json", Path: []string{"instructions"}, Mode: "list_item", Value: ".hugin/rules/*.md"}},
Blocks: map[string]string{"CLAUDE.md#hugin": "sha256:04"},
Created: []string{"opencode.json"},
}
raw, err := l.Bytes()
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(string(raw), header) {
t.Errorf("Kopfzeile fehlt:\n%s", raw)
}
repo := t.TempDir()
if err := os.WriteFile(filepath.Join(repo, Filename), raw, 0o644); err != nil {
t.Fatal(err)
}
back, err := Load(repo)
if err != nil {
t.Fatal(err)
}
if len(back.Files()) != 2 || back.Files()[".hugin/rules/pub-b.md"] != "sha256:03" {
t.Errorf("Files: %v", back.Files())
}
if len(back.AllKeys()) != 2 || !back.Created("opencode.json") || back.Created("CLAUDE.md") {
t.Errorf("Keys/Created: %+v", back)
}
if back.Common.Blocks["CLAUDE.md#hugin"] != "sha256:04" {
t.Errorf("Blocks: %v", back.Common.Blocks)
}
}
func TestFind(t *testing.T) {
keys := []Key{
{File: "opencode.json", Path: []string{"instructions"}, Mode: "list_item", Value: ".hugin/rules/*.md"},
{File: ".mcp.json", Path: []string{"mcpServers", "munin"}},
}
if Find(keys, ".mcp.json", []string{"mcpServers", "munin"}, "", nil) == nil {
t.Error("Schlüssel nicht gefunden")
}
if Find(keys, "opencode.json", []string{"instructions"}, "list_item", ".hugin/rules/*.md") == nil {
t.Error("Listeneintrag nicht gefunden")
}
// Bei list_item muss auch der Wert passen
if Find(keys, "opencode.json", []string{"instructions"}, "list_item", "andere.md") != nil {
t.Error("anderer Wert darf nicht passen")
}
if Find(keys, ".mcp.json", []string{"mcpServers"}, "", nil) != nil {
t.Error("kürzerer Pfad darf nicht passen")
}
}
func TestBrokenFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "trial.lock")
if err := os.WriteFile(path, []byte("packages: [kaputt"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := LoadFrom(path); err == nil {
t.Error("Fehler erwartet")
}
}

View file

@ -0,0 +1,70 @@
// Package pfade prüft Pfade aus Bundles, bevor das CLI sie anfasst
// (→ KONZEPT.md 16.2: nicht aus dem Repo ausbrechen).
package paths
import (
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
// windowsForbidden: Zeichen, die Windows in Namen nicht erlaubt oder umdeutet.
const windowsForbidden = `<>:"|?*`
// Validate meldet einen Fehler, wenn rel kein sauberer, relativer Pfad mit /
// ist: kein führender /, kein \, kein Laufwerk, keine Segmente "." oder "..".
// Dazu, was Windows umdeutet (Punkt oder Leerzeichen am Ende, Steuerzeichen),
// und nichts unter .git/ (Hooks dort führt git selbst aus).
func Validate(rel string) error {
switch {
case rel == "":
return errors.New("leerer Pfad")
case strings.HasPrefix(rel, "/"), strings.Contains(rel, `\`), strings.Contains(rel, ":"):
return fmt.Errorf("%q: nur relative Pfade mit / erlaubt", rel)
}
for _, part := range strings.Split(strings.TrimSuffix(rel, "/"), "/") {
if part == "" || part == "." || part == ".." {
return fmt.Errorf("%q: ungültiges Segment %q", rel, part)
}
if strings.HasSuffix(part, ".") || strings.HasSuffix(part, " ") || strings.ContainsAny(part, windowsForbidden) ||
strings.IndexFunc(part, func(r rune) bool { return r < 32 }) >= 0 {
return fmt.Errorf("%q: Segment %q geht nicht auf allen Systemen", rel, part)
}
}
if erstes, _, _ := strings.Cut(rel, "/"); strings.EqualFold(erstes, ".git") {
return fmt.Errorf("%q: Hugin schreibt nicht unter .git/", rel)
}
if path.Clean(rel) != strings.TrimSuffix(rel, "/") {
return fmt.Errorf("%q: Pfad ist nicht normalisiert", rel)
}
return nil
}
// InRepo prüft rel und liefert den absoluten Pfad unter repo. Symlinks auf dem
// Weg dorthin werden abgelehnt, damit kein Pfad über einen Link nach draußen führt.
func InRepo(repo, rel string) (string, error) {
if err := Validate(rel); err != nil {
return "", err
}
target := filepath.Join(repo, filepath.FromSlash(rel))
parts := strings.Split(strings.TrimSuffix(rel, "/"), "/")
current := repo
for _, part := range parts {
current = filepath.Join(current, part)
info, err := os.Lstat(current)
if errors.Is(err, fs.ErrNotExist) {
break
}
if err != nil {
return "", err
}
if info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("%q: Symlink im Pfad (%s)", rel, current)
}
}
return target, nil
}

View file

@ -0,0 +1,34 @@
package paths
import (
"os"
"path/filepath"
"testing"
)
func TestValidate(t *testing.T) {
for _, good := range []string{".claude/skills/a/SKILL.md", "opencode.json", ".opencode/agents/x.md", ".github/x", "a/.gitkeep"} {
if err := Validate(good); err != nil {
t.Errorf("%s: %v", good, err)
}
}
for _, bad := range []string{"", "/etc/passwd", "../x", "a/../b", "a//b", "./a", `a\b`, "c:/x", "a/b.",
"a /b", "a/b ", "...", "a/<b>", "a|b", "a?b", "a\x01b", ".git/hooks/pre-commit", ".GIT/config", ".git"} {
if err := Validate(bad); err == nil {
t.Errorf("%q sollte abgelehnt werden", bad)
}
}
}
func TestInRepoWithoutSymlinks(t *testing.T) {
repo, outside := t.TempDir(), t.TempDir()
if err := os.Symlink(outside, filepath.Join(repo, ".claude")); err != nil {
t.Skip(err)
}
if _, err := InRepo(repo, ".claude/skills/a/SKILL.md"); err == nil {
t.Error("Symlink im Pfad muss abgelehnt werden")
}
if p, err := InRepo(repo, ".opencode/agents/a.md"); err != nil || p != filepath.Join(repo, ".opencode", "agents", "a.md") {
t.Errorf("%s, %v", p, err)
}
}

View file

@ -0,0 +1,168 @@
// Package paketordner packt Paketordner für den Server und legt neue an
// (→ KONZEPT.md 6.1, 11.1: hugin new, validate, try, upload).
package pkgdir
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
)
const Manifest = "hugin.yaml"
// Zip packt den Ordner wie der Server ihn erwartet: hugin.yaml in der Wurzel,
// versteckte Dateien weggelassen, Symlinks abgelehnt.
func Zip(folder string) ([]byte, error) {
if _, err := os.Stat(filepath.Join(folder, Manifest)); err != nil {
return nil, fmt.Errorf("%s fehlt in %s", Manifest, folder)
}
var buffer bytes.Buffer
z := zip.NewWriter(&buffer)
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(folder, path)
if err != nil || rel == "." {
return err
}
if strings.HasPrefix(d.Name(), ".") {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.Type()&fs.ModeSymlink != 0 {
return fmt.Errorf("Symlinks sind nicht erlaubt: %s", rel)
}
if d.IsDir() {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
w, err := z.Create(filepath.ToSlash(rel))
if err != nil {
return err
}
_, err = w.Write(data)
return err
})
if err != nil {
return nil, err
}
if err := z.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
var idPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
var contentFile = map[string]string{
"skill": "SKILL.md",
"rules": "RULES.md",
"command": "COMMAND.md",
"subagent": "AGENT.md",
"mcp": "",
"plugin": "index.ts",
}
var scaffolds = map[string]string{
"skill": `# Titel
Was der Agent tun soll, Schritt für Schritt. Werkzeuge neutral beschreiben
(„lies die Datei“, nicht „nutze Read“), damit es in Claude Code und OpenCode passt.
`,
"rules": `# Regeln
- Eine Regel pro Zeile, kurz und prüfbar.
`,
"command": `Beschreibe die Aufgabe für {{input}}.
Platzhalter: {{name}} für benannte Argumente (in hugin.yaml unter arguments),
$ARGUMENTS für alles zusammen. Keine $1, $2 …: Claude Code und OpenCode zählen verschieden.
`,
"subagent": `Du bist … Beschreibe Rolle, Vorgehen und Form der Antwort.
`,
"plugin": `import { tool } from "@opencode-ai/plugin"
// OpenCode ruft jede exportierte Funktion beim Start auf. Erlaubte Importe: relative
// Dateien im Paket, node:…, bun und @opencode-ai/plugin; npm-Pakete noch nicht.
export const MeinPlugin = async ({ worktree }) => {
return {
tool: {
mein_werkzeug: tool({
description: "Was das Werkzeug tut",
args: {},
async execute() {
return "Ergebnis"
},
}),
},
}
}
`,
}
// New legt folder/<id> mit Manifest-Gerüst und Inhaltsdatei an.
func New(folder, kind, id string) (string, error) {
file, ok := contentFile[kind]
if !ok {
return "", fmt.Errorf("kind %q: erwartet skill, rules, command, subagent, mcp oder plugin", kind)
}
if !idPattern.MatchString(id) || len(id) > 64 {
return "", fmt.Errorf("id %q: nur a–z, 0–9 und einzelne Bindestriche, 1–64 Zeichen", id)
}
target := filepath.Join(folder, id)
if _, err := os.Stat(target); err == nil {
return "", fmt.Errorf("%s gibt es schon", target)
} else if !errors.Is(err, fs.ErrNotExist) {
return "", err
}
if err := os.MkdirAll(target, 0o755); err != nil {
return "", err
}
if err := os.WriteFile(filepath.Join(target, Manifest), []byte(manifest(kind, id)), 0o644); err != nil {
return "", err
}
if file != "" {
if err := os.WriteFile(filepath.Join(target, file), []byte(scaffolds[kind]), 0o644); err != nil {
return "", err
}
}
return target, nil
}
func manifest(kind, id string) string {
scope := "tenant # public | tenant | project; das Präfix der id muss passen"
if strings.HasPrefix(id, "pub-") {
scope = "public # public | tenant | project"
}
var b strings.Builder
fmt.Fprintf(&b, "id: %s\nkind: %s\nversion: 0.1.0\nowner: \"\" # Projekt-Slug\nscope: %s\n", id, kind, scope)
b.WriteString("description: >\n Wann und wofür der Agent das nutzt. Die Werkzeuge wählen danach aus,\n also konkret schreiben (1–1.024 Zeichen).\n")
switch kind {
case "command":
b.WriteString("arguments: [input] # Reihenfolge = Position beim Aufruf\n")
case "subagent":
b.WriteString("capability: read # read | write | shell | web, auch als Liste\n")
b.WriteString("model: standard # fast | standard | thorough | inherit\n")
case "plugin":
b.WriteString("targets: [opencode] # Plugins gibt es nur für OpenCode\n")
case "mcp":
b.WriteString("server:\n name: name\n url: https://…/mcp\n headers:\n Authorization: \"Bearer {{env:NAME_KEY}}\"\n")
b.WriteString(" # Lokaler Server statt url und headers: Code (und requirements.txt mit Hashes) ins Paket\n" +
" # local:\n # runtime: python\n # entry: mein_server # Modul oder Skript.py\n" +
" # python: \"3.10\"\n # env: [NAME_KEY]\n")
}
b.WriteString("# extra: # nur, was ein Werkzeug allein kann\n# claude: {}\n# opencode: {}\n")
return b.String()
}

View file

@ -0,0 +1,71 @@
package pkgdir
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestNewAndZip(t *testing.T) {
folder := t.TempDir()
target, err := New(folder, "command", "pub-test")
if err != nil {
t.Fatal(err)
}
manifest, _ := os.ReadFile(filepath.Join(target, Manifest))
for _, part := range []string{"id: pub-test", "kind: command", "scope: public", "arguments: [input]"} {
if !strings.Contains(string(manifest), part) {
t.Errorf("Manifest ohne %q:\n%s", part, manifest)
}
}
os.WriteFile(filepath.Join(target, ".DS_Store"), []byte("x"), 0o644)
os.MkdirAll(filepath.Join(target, ".git"), 0o755)
os.WriteFile(filepath.Join(target, ".git", "HEAD"), []byte("x"), 0o644)
raw, err := Zip(target)
if err != nil {
t.Fatal(err)
}
z, _ := zip.NewReader(bytes.NewReader(raw), int64(len(raw)))
var names []string
for _, f := range z.File {
names = append(names, f.Name)
}
slices.Sort(names)
if !slices.Equal(names, []string{"COMMAND.md", "hugin.yaml"}) {
t.Errorf("ZIP enthält %v", names)
}
}
func TestNewValidatesInputs(t *testing.T) {
folder := t.TempDir()
if _, err := New(folder, "hook", "pub-x"); err == nil {
t.Error("Art hook angenommen")
}
if _, err := New(folder, "skill", "Pub_X"); err == nil {
t.Error("ungültige id angenommen")
}
New(folder, "skill", "pub-x")
if _, err := New(folder, "skill", "pub-x"); err == nil {
t.Error("vorhandener Ordner überschrieben")
}
}
func TestZipWithoutManifest(t *testing.T) {
if _, err := Zip(t.TempDir()); err == nil {
t.Error("Ordner ohne hugin.yaml angenommen")
}
}
func TestZipRejectsSymlink(t *testing.T) {
folder := t.TempDir()
os.WriteFile(filepath.Join(folder, Manifest), []byte("id: x\n"), 0o644)
os.Symlink("/etc/hosts", filepath.Join(folder, "SKILL.md"))
if _, err := Zip(folder); err == nil {
t.Error("Symlink angenommen")
}
}

View file

@ -0,0 +1,93 @@
package reconcile
import (
"strings"
)
// Verwaltete Blöcke in Textdateien wie CLAUDE.md (→ KONZEPT.md 7.2, 11.4):
//
// <!-- hugin:begin — von hugin sync verwaltet, nicht von Hand ändern -->
// @.hugin/rules/pub-hausregeln.md
// <!-- hugin:end -->
func beginMarker(block string) string {
return "<!-- " + block + ":begin — von hugin sync verwaltet, nicht von Hand ändern -->"
}
func endMarker(block string) string { return "<!-- " + block + ":end -->" }
// findBlock liefert die Zeilennummern von Anfangs- und Endmarke, -1 wenn es
// den Block nicht gibt.
func findBlock(lines []string, block string) (begin, end int) {
begin, end = -1, -1
for i, z := range lines {
z = strings.TrimSpace(z)
if begin < 0 && strings.HasPrefix(z, "<!-- "+block+":begin") {
begin = i
} else if begin >= 0 && z == endMarker(block) {
return begin, i
}
}
return -1, -1
}
// readBlock liefert den Inhalt des Blocks mit \n als Zeilenende.
func readBlock(text, block string) (string, bool) {
lines, _ := split(text)
begin, end := findBlock(lines, block)
if begin < 0 {
return "", false
}
var b strings.Builder
for _, z := range lines[begin+1 : end] {
b.WriteString(z + "\n")
}
return b.String(), true
}
// setBlock ersetzt den Block, hängt ihn ans Ende an oder entfernt ihn
// (leerer inhalt). Das Zeilenende der Datei (LF oder CRLF) bleibt erhalten.
func setBlock(text, block, content string) string {
lines, nl := split(text)
begin, end := findBlock(lines, block)
var next []string
if content != "" {
next = append(next, beginMarker(block))
next = append(next, strings.Split(strings.TrimSuffix(content, "\n"), "\n")...)
next = append(next, endMarker(block))
}
switch {
case begin >= 0:
rest := lines[end+1:]
if len(next) == 0 && len(rest) > 0 && strings.TrimSpace(rest[0]) == "" {
rest = rest[1:]
}
lines = append(append(append([]string{}, lines[:begin]...), next...), rest...)
case len(next) > 0:
if len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) != "" {
lines = append(lines, "")
}
lines = append(lines, next...)
}
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, nl) + nl
}
func split(text string) (lines []string, nl string) {
nl = "\n"
if strings.Contains(text, "\r\n") {
nl = "\r\n"
}
text = strings.TrimSuffix(text, nl)
if text == "" {
return nil, nl
}
return strings.Split(text, nl), nl
}

View file

@ -0,0 +1,326 @@
package reconcile
import (
"bytes"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/tailscale/hujson"
)
// jsonFile ist eine JSON- oder JSONC-Datei, die über JSON-Patch geändert
// wird. hujson erhält dabei Kommentare, Reihenfolge und die Form der übrigen
// Inhalte. Eingefügte Werte rückt formatieren im Stil der Datei ein.
type jsonFile struct {
value hujson.Value
// Einrückung der Datei, z. B. zwei Leerzeichen oder ein Tab
indent string
}
func readJson(raw []byte, next bool) (*jsonFile, error) {
if len(bytes.TrimSpace(raw)) == 0 {
raw = []byte("{}")
}
v, err := hujson.Parse(raw)
if err != nil {
return nil, err
}
return &jsonFile{value: v, indent: detectIndent(raw)}, nil
}
func detectIndent(raw []byte) string {
for _, row := range strings.Split(string(raw), "\n") {
rest := strings.TrimLeft(row, " \t")
if rest != "" && len(rest) < len(row) {
return row[:len(row)-len(rest)]
}
}
return " "
}
func (d *jsonFile) bytes() []byte {
b := d.value.Pack()
if !bytes.HasSuffix(b, []byte("\n")) {
b = append(b, '\n')
}
return b
}
// read liefert den Wert unter path als Go-Wert, ok=false wenn er fehlt.
func (d *jsonFile) read(path []string) (any, bool) {
clone := d.value.Clone()
clone.Standardize()
var value any
if err := json.Unmarshal(clone.Pack(), &value); err != nil {
return nil, false
}
for _, s := range path {
obj, ok := value.(map[string]any)
if !ok {
return nil, false
}
if value, ok = obj[s]; !ok {
return nil, false
}
}
return value, true
}
// empty sagt, ob die Datei nur noch ein leeres Objekt enthält.
// OpenCodeSchema legt hugin in eine neue opencode.json.
const OpenCodeSchema = "https://opencode.ai/config.json"
// empty: ein leeres Objekt; ein $schema allein zählt nicht als Inhalt.
func (d *jsonFile) empty() bool {
value, ok := d.read(nil)
obj, isObject := value.(map[string]any)
delete(obj, "$schema")
return ok && isObject && len(obj) == 0
}
func (d *jsonFile) set(path []string, value json.RawMessage) error {
if existing, ok := d.read(path); ok && canonical(existing) == canonicalRaw(value) {
return nil
}
// Fehlende Zwischenobjekte nicht einzeln anlegen, sondern den Wert ab der
// ersten fehlenden Ebene verschachtelt einfügen: ergibt saubere Einrückung.
for i := 1; i < len(path); i++ {
existing, ok := d.read(path[:i])
if !ok {
for j := len(path) - 1; j >= i; j-- {
key, _ := json.Marshal(path[j])
value = json.RawMessage("{" + string(key) + ":" + string(value) + "}")
}
path = path[:i]
break
}
if _, isObject := existing.(map[string]any); !isObject {
return fmt.Errorf("%s ist kein Objekt", pointer(path[:i]))
}
}
if err := d.patch("add", path, value); err != nil {
return err
}
return d.format(path[:len(path)-1], path[len(path)-1])
}
func (d *jsonFile) remove(path []string) error {
if _, ok := d.read(path); !ok {
return nil
}
if err := d.patch("remove", path, nil); err != nil {
return err
}
return d.removeEmptyParents(path)
}
// removeEmptyParents entfernt Objekte über pfad, die durch das Entfernen leer
// geworden sind, z. B. "mcpServers": {} nach dem letzten Server.
func (d *jsonFile) removeEmptyParents(path []string) error {
for i := len(path) - 1; i > 0; i-- {
value, ok := d.read(path[:i])
obj, isObject := value.(map[string]any)
if !ok || !isObject || len(obj) > 0 {
return nil
}
if err := d.patch("remove", path[:i], nil); err != nil {
return err
}
}
return nil
}
// addToList stellt sicher, dass wert in der Liste unter pfad steht.
func (d *jsonFile) addToList(path []string, value json.RawMessage) error {
existing, ok := d.read(path)
if !ok {
return d.set(path, json.RawMessage("["+string(value)+"]"))
}
items, isList := existing.([]any)
if !isList {
return fmt.Errorf("%s ist keine Liste", pointer(path))
}
if index(items, value) >= 0 {
return nil
}
if err := d.patch("add", append(append([]string{}, path...), "-"), value); err != nil {
return err
}
return d.format(path, "")
}
func (d *jsonFile) removeFromList(path []string, value json.RawMessage) error {
existing, _ := d.read(path)
items, _ := existing.([]any)
i := index(items, value)
if i < 0 {
return nil
}
if len(items) == 1 {
// Eine leere Liste nützt niemandem: den Schlüssel ganz entfernen
return d.remove(path)
}
return d.patch("remove", append(append([]string{}, path...), fmt.Sprint(i)), nil)
}
func index(items []any, value json.RawMessage) int {
for i, entry := range items {
if canonical(entry) == canonicalRaw(value) {
return i
}
}
return -1
}
func (d *jsonFile) patch(op string, path []string, value json.RawMessage) error {
step := map[string]any{"op": op, "path": pointer(path)}
if value != nil {
step["value"] = value
}
raw, err := json.Marshal([]any{step})
if err != nil {
return err
}
return d.value.Patch(raw)
}
// pointer baut einen JSON-Pointer (RFC 6901) aus den Schlüsseln.
func pointer(path []string) string {
var b strings.Builder
for _, s := range path {
b.WriteString("/")
b.WriteString(strings.NewReplacer("~", "~0", "/", "~1").Replace(s))
}
return b.String()
}
func canonical(value any) string {
b, _ := json.Marshal(value)
return string(b)
}
func canonicalRaw(raw json.RawMessage) string {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return string(raw)
}
return canonical(value)
}
// format rückt das eben gesetzte Element ein: den Member name eines
// Objekts bzw. (name leer) das letzte Element einer Liste unter eltern.
// Steht das Elternelement in einer Zeile, bleibt es einzeilig.
func (d *jsonFile) format(parents []string, name string) error {
node := d.value.Find(pointer(parents))
if node == nil {
return nil
}
depth := len(parents) + 1
inside := "\n" + strings.Repeat(d.indent, depth)
outside := "\n" + strings.Repeat(d.indent, depth-1)
switch c := node.Value.(type) {
case *hujson.Object:
member := withName(c, name)
if member == nil {
return nil
}
var before []hujson.Extra // Abstand vor den übrigen Membern
for i := range c.Members {
if &c.Members[i] != member {
before = append(before, c.Members[i].Name.BeforeExtra)
}
}
multiline := multiline(before)
switch {
case multiline:
member.Name.BeforeExtra = []byte(inside)
case member == &c.Members[0]:
member.Name.BeforeExtra = nil
default:
member.Name.BeforeExtra = []byte(" ")
}
if err := d.reindent(&member.Value, multiline, depth); err != nil {
return err
}
member.Value.BeforeExtra = []byte(" ")
member.Value.AfterExtra = nil
if multiline && !comment(c.AfterExtra) {
c.AfterExtra = []byte(outside)
}
case *hujson.Array:
if len(c.Elements) == 0 {
return nil
}
var before []hujson.Extra
for _, e := range c.Elements[:len(c.Elements)-1] {
before = append(before, e.BeforeExtra)
}
target := &c.Elements[len(c.Elements)-1]
multiline := multiline(before)
if err := d.reindent(target, multiline, depth); err != nil {
return err
}
switch {
case multiline:
target.BeforeExtra = []byte(inside)
if !comment(c.AfterExtra) {
c.AfterExtra = []byte(outside)
}
case len(c.Elements) > 1:
target.BeforeExtra = []byte(" ")
default:
target.BeforeExtra = nil
}
target.AfterExtra = nil
}
d.value.UpdateOffsets()
return nil
}
// reindent ersetzt den Wert durch eine eingerückte (oder kompakte) Fassung.
func (d *jsonFile) reindent(v *hujson.Value, multiline bool, depth int) error {
clone := v.Clone()
clone.Standardize()
var buffer bytes.Buffer
if multiline {
if err := json.Indent(&buffer, bytes.TrimSpace(clone.Pack()), strings.Repeat(d.indent, depth), d.indent); err != nil {
return err
}
} else if err := json.Compact(&buffer, clone.Pack()); err != nil {
return err
}
next, err := hujson.Parse(buffer.Bytes())
if err != nil {
return err
}
v.Value = next.Value
return nil
}
func withName(obj *hujson.Object, name string) *hujson.ObjectMember {
for i := range obj.Members {
if n, _ := strconv.Unquote(string(obj.Members[i].Name.Value.(hujson.Literal))); n == name {
return &obj.Members[i]
}
}
return nil
}
// mehrzeilig: Die übrigen Elemente stehen auf eigenen Zeilen, oder es gibt
// keine übrigen (dann wird ein neues Objekt bzw. eine neue Liste mehrzeilig).
func multiline(before []hujson.Extra) bool {
if len(before) == 0 {
return true
}
for _, e := range before {
if bytes.Contains(e, []byte("\n")) {
return true
}
}
return false
}
func comment(e hujson.Extra) bool { return bytes.Contains(e, []byte("/")) }

View file

@ -0,0 +1,83 @@
package reconcile
import (
"encoding/json"
"testing"
)
func TestJsonSet(t *testing.T) {
munin := json.RawMessage(`{"type":"http","url":"https://m"}`)
tests := []struct {
name, before string
path []string
value json.RawMessage
expected string
}{
{"neue Datei", "", []string{"mcpServers", "munin"}, munin,
"{\n \"mcpServers\": {\n \"munin\": {\n \"type\": \"http\",\n \"url\": \"https://m\"\n }\n }\n}\n"},
{"Einrückung der Datei", "{\n \"a\": 1\n}\n", []string{"mcpServers", "munin"}, munin,
"{\n \"a\": 1,\n \"mcpServers\": {\n \"munin\": {\n \"type\": \"http\",\n \"url\": \"https://m\"\n }\n }\n}\n"},
{"Kommentare bleiben", "{\n // Modell\n \"model\": \"x\"\n}\n", []string{"mcp", "munin"}, json.RawMessage(`{"enabled":true}`),
"{\n // Modell\n \"model\": \"x\",\n \"mcp\": {\n \"munin\": {\n \"enabled\": true\n }\n }\n}\n"},
{"einzeilige Datei bleibt einzeilig", `{"a": 1}`, []string{"b"}, json.RawMessage(`{"c": 2}`),
`{"a": 1, "b": {"c":2}}` + "\n"},
{"vorhandenen Wert ersetzen", "{\n \"a\": 1,\n \"b\": 2\n}\n", []string{"a"}, json.RawMessage(`[1,2]`),
"{\n \"a\": [\n 1,\n 2\n ],\n \"b\": 2\n}\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, err := readJson([]byte(tt.before), tt.before == "")
if err != nil {
t.Fatal(err)
}
if err := d.set(tt.path, tt.value); err != nil {
t.Fatal(err)
}
if got := string(d.bytes()); got != tt.expected {
t.Errorf("\n got:\n%s\nwant:\n%s", got, tt.expected)
}
})
}
}
func TestJsonList(t *testing.T) {
entry := json.RawMessage(`".hugin/rules/*.md"`)
tests := []struct{ name, before, expected string }{
{"Liste fehlt", "{\n \"model\": \"x\"\n}\n",
"{\n \"model\": \"x\",\n \"instructions\": [\n \".hugin/rules/*.md\"\n ]\n}\n"},
{"einzeilige Liste", "{\n \"instructions\": [\"docs/a.md\"]\n}\n",
"{\n \"instructions\": [\"docs/a.md\", \".hugin/rules/*.md\"]\n}\n"},
{"mehrzeilige Liste", "{\n \"instructions\": [\n \"docs/a.md\"\n ]\n}\n",
"{\n \"instructions\": [\n \"docs/a.md\",\n \".hugin/rules/*.md\"\n ]\n}\n"},
{"schon vorhanden", "{\"instructions\": [\".hugin/rules/*.md\"]}\n",
"{\"instructions\": [\".hugin/rules/*.md\"]}\n"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
d, _ := readJson([]byte(tt.before), false)
if err := d.addToList([]string{"instructions"}, entry); err != nil {
t.Fatal(err)
}
if got := string(d.bytes()); got != tt.expected {
t.Errorf("\n got:\n%s\nwant:\n%s", got, tt.expected)
}
if err := d.removeFromList([]string{"instructions"}, entry); err != nil {
t.Fatal(err)
}
value, ok := d.read([]string{"instructions"})
if items, _ := value.([]any); ok && (len(items) == 0 || index(items, entry) >= 0) {
t.Errorf("Eintrag nicht entfernt oder leere Liste übrig: %v", value)
}
})
}
}
func TestJsonSpecialCharsInKey(t *testing.T) {
d, _ := readJson(nil, true)
if err := d.set([]string{"a/b", "c~d", "e.f"}, json.RawMessage(`1`)); err != nil {
t.Fatal(err)
}
if value, ok := d.read([]string{"a/b", "c~d", "e.f"}); !ok || value != float64(1) {
t.Errorf("lesen: %v %v\n%s", value, ok, d.bytes())
}
}

View file

@ -0,0 +1,523 @@
// Package abgleich wendet ein Bundle auf ein Repo an (→ KONZEPT.md 11.4, 11.5).
//
// Ablauf in zwei Schritten: Planen liest das Repo und berechnet alle neuen
// Inhalte im Speicher, ohne etwas zu schreiben. Erst Run schreibt, und
// nur, wenn der Plan keine offenen Befunde hat. So entsteht kein halber Zustand,
// wenn eine Operation scheitert.
package reconcile
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"sort"
"strings"
"hugin/cli/internal/bundle"
"hugin/cli/internal/lock"
"hugin/cli/internal/paths"
)
type FindingKind string
const (
// ManualChange: eine verwaltete Datei, ein Schlüssel oder Block wurde
// seit dem letzten sync geändert. Der Wert erscheint in der Ausgabe.
ManualChange FindingKind = "Handänderung"
// Conflict: Datei oder Schlüssel gibt es schon, aber nicht von Hugin.
Conflict FindingKind = "Konflikt"
)
type Finding struct {
Kind FindingKind
Location string
Text string
}
func (b Finding) String() string { return fmt.Sprintf("%s: %s (%s)", b.Location, b.Text, b.Kind) }
type Options struct {
// Overwrite: Hugin gewinnt bei Handänderungen und Konflikten.
Overwrite bool
}
// Plan ist das Ergebnis von Planen: was geschrieben und gelöscht würde.
type Plan struct {
Findings []Finding
Write map[string][]byte // Pfad relativ zum Repo → neuer Inhalt
Delete []string
New []string // Pfade, die es vorher nicht gab
Lock *lock.Lock
options Options
unchanged int
}
// Open sagt, ob Befunde ein Ausführen verhindern.
func (p *Plan) Open() bool { return len(p.Findings) > 0 && !p.options.Overwrite }
// Changed sagt, ob der Plan überhaupt etwas im Repo ändert.
func (p *Plan) Changed() bool { return len(p.Write) > 0 || len(p.Delete) > 0 }
type file struct {
before []byte // nil: gab es nicht
content []byte // nil: soll gelöscht werden bzw. bleibt fehlen
loaded bool
json *jsonFile
}
type planner struct {
repo string
bundle *bundle.Bundle
old *lock.Lock
next *lock.Lock
files map[string]*file
findings []Finding
}
// MakePlan berechnet, was das Bundle im Repo ändern würde.
func MakePlan(repo string, b *bundle.Bundle, old *lock.Lock, opt Options) (*Plan, error) {
p := &planner{repo: repo, bundle: b, old: old, next: lock.New(), files: map[string]*file{}}
p.next.Common.Created = slices.Clone(old.Common.Created)
for _, pk := range b.Instructions.Packages {
p.next.Packages[pk.ID] = &lock.Package{Version: pk.Version, Checksum: pk.Checksum}
}
for _, op := range b.Instructions.Operations {
var err error
switch op.Op {
case "file":
err = p.file(op)
case "remove":
err = p.remove(op.Path)
case "json":
err = p.json(op)
case "json_remove":
err = p.removeJson(op)
case "block":
err = p.block(op)
}
if err != nil {
return nil, fmt.Errorf("%s %s: %w", op.Op, op.Path+op.File, err)
}
}
if err := p.removeOrphaned(); err != nil {
return nil, err
}
if err := p.removeOrphanedKeys(); err != nil {
return nil, err
}
if err := p.removeOrphanedBlocks(); err != nil {
return nil, err
}
return p.result(opt)
}
func (p *planner) load(rel string) (*file, error) {
if d, ok := p.files[rel]; ok {
return d, nil
}
abs, err := paths.InRepo(p.repo, rel)
if err != nil {
return nil, err
}
d := &file{loaded: true}
raw, err := os.ReadFile(abs)
switch {
case errors.Is(err, fs.ErrNotExist):
case err != nil:
return nil, err
default:
d.before, d.content = raw, raw
}
p.files[rel] = d
return d, nil
}
func (p *planner) finding(kind FindingKind, location, text string) {
p.findings = append(p.findings, Finding{kind, location, text})
}
func (p *planner) pkg(id string) *lock.Package {
pk, ok := p.next.Packages[id]
if !ok {
// Operation für ein Paket ohne Eintrag in pakete (z. B. entfernen)
pk = &lock.Package{}
p.next.Packages[id] = pk
}
return pk
}
func (p *planner) file(op bundle.Operation) error {
content, err := p.bundle.File(op.Source)
if err != nil {
return err
}
d, err := p.load(op.Path)
if err != nil {
return err
}
if d.before != nil && !bytes.Equal(d.before, content) {
oldSha, managed := p.old.Files()[op.Path]
switch {
case !managed:
p.finding(Conflict, op.Path, "Datei gibt es schon, sie stammt nicht von Hugin")
case bundle.Sha256(d.before) != oldSha:
p.finding(ManualChange, op.Path, "seit dem letzten sync von Hand geändert")
}
}
d.content = content
pk := p.pkg(op.Package)
if pk.Files == nil {
pk.Files = map[string]string{}
}
pk.Files[op.Path] = op.Sha256
return nil
}
func (p *planner) remove(rel string) error {
d, err := p.load(rel)
if err != nil || d.before == nil {
return err
}
oldSha, managed := p.old.Files()[rel]
if !managed {
// Nicht von Hugin: stehen lassen
return nil
}
if bundle.Sha256(d.before) != oldSha {
p.finding(ManualChange, rel, "soll entfernt werden, wurde aber von Hand geändert")
}
d.content = nil
return nil
}
// removeOrphaned räumt Dateien ab, die laut alter Lock Hugin gehören, im
// neuen Stand aber fehlen, auch wenn das Bundle kein entfernen dafür enthält.
func (p *planner) removeOrphaned() error {
next := p.next.Files()
old := p.old.Files()
pathList := make([]string, 0, len(old))
for relPath := range old {
pathList = append(pathList, relPath)
}
sort.Strings(pathList)
for _, relPath := range pathList {
if _, stays := next[relPath]; !stays {
if err := p.remove(relPath); err != nil {
return err
}
}
}
return nil
}
// removeOrphanedKeys entfernt JSON-Schlüssel und Listeneinträge, die
// laut alter Lock Hugin gehören, im neuen Stand aber fehlen.
func (p *planner) removeOrphanedKeys() error {
next := p.next.AllKeys()
for _, s := range p.old.AllKeys() {
var value any
if s.Mode == "list_item" {
value = s.Value
}
if lock.Find(next, s.File, s.Path, s.Mode, value) != nil {
continue
}
op := bundle.Operation{Op: "json_remove", File: s.File, Key: s.Path, Mode: s.Mode}
if s.Mode == "list_item" {
raw, err := json.Marshal(s.Value)
if err != nil {
return err
}
op.Value = raw
}
if err := p.removeJson(op); err != nil {
return err
}
}
return nil
}
// removeOrphanedBlocks entfernt verwaltete Blöcke, die im neuen Stand fehlen.
func (p *planner) removeOrphanedBlocks() error {
for key := range p.old.Common.Blocks {
if _, stays := p.next.Common.Blocks[key]; stays {
continue
}
file, block, _ := strings.Cut(key, "#")
d, err := p.load(file)
if err != nil {
return err
}
if _, exists := readBlock(string(d.content), block); !exists {
continue
}
empty := ""
if err := p.block(bundle.Operation{Op: "block", File: file, Block: block, Content: &empty}); err != nil {
return err
}
}
return nil
}
func (p *planner) jsonFile(rel, create string) (*jsonFile, error) {
d, err := p.load(rel)
if err != nil {
return nil, err
}
if d.json != nil {
return d.json, nil
}
raw, next := d.before, d.before == nil
if next {
if create != "" {
if raw, err = p.bundle.File(create); err != nil {
return nil, err
}
} else if path.Base(rel) == "opencode.json" {
// Von OpenCode empfohlen: Editoren prüfen die Datei dann beim Schreiben
raw = []byte("{\n \"$schema\": \"" + OpenCodeSchema + "\"\n}\n")
}
p.created(rel)
}
if d.json, err = readJson(raw, next); err != nil {
return nil, fmt.Errorf("%s ist kein gültiges JSON: %w", rel, err)
}
return d.json, nil
}
func (p *planner) created(rel string) {
if !slices.Contains(p.next.Common.Created, rel) {
p.next.Common.Created = append(p.next.Common.Created, rel)
}
}
func (p *planner) json(op bundle.Operation) error {
j, err := p.jsonFile(op.File, op.Create)
if err != nil {
return err
}
var value any
if err := json.Unmarshal(op.Value, &value); err != nil {
return err
}
location := op.File + " " + pointer(op.Key)
entry := lock.Key{File: op.File, Path: op.Key, Mode: op.Mode}
if op.ListItem() {
entry.Value = value
if err := j.addToList(op.Key, op.Value); err != nil {
p.finding(Conflict, location, err.Error())
return nil
}
} else {
entry.Sha256 = bundle.Sha256([]byte(canonical(value)))
if existing, ok := j.read(op.Key); ok && canonical(existing) != canonical(value) {
old := lock.Find(p.old.AllKeys(), op.File, op.Key, "", nil)
switch {
case old == nil:
p.finding(Conflict, location, "Schlüssel gibt es schon, er stammt nicht von Hugin")
case bundle.Sha256([]byte(canonical(existing))) != old.Sha256:
p.finding(ManualChange, location, "seit dem letzten sync von Hand geändert")
}
}
if err := j.set(op.Key, op.Value); err != nil {
p.finding(Conflict, location, err.Error())
return nil
}
}
if op.Package == "" {
p.next.Common.Keys = append(p.next.Common.Keys, entry)
} else {
pk := p.pkg(op.Package)
pk.Keys = append(pk.Keys, entry)
}
return nil
}
func (p *planner) removeJson(op bundle.Operation) error {
var value any
if len(op.Value) > 0 {
if err := json.Unmarshal(op.Value, &value); err != nil {
return err
}
}
old := lock.Find(p.old.AllKeys(), op.File, op.Key, op.Mode, value)
if old == nil {
return nil // nicht von Hugin
}
d, err := p.load(op.File)
if err != nil || d.before == nil {
return err
}
j, err := p.jsonFile(op.File, "")
if err != nil {
return err
}
if op.ListItem() {
return j.removeFromList(op.Key, op.Value)
}
if existing, ok := j.read(op.Key); ok && bundle.Sha256([]byte(canonical(existing))) != old.Sha256 {
p.finding(ManualChange, op.File+" "+pointer(op.Key), "soll entfernt werden, wurde aber von Hand geändert")
}
return j.remove(op.Key)
}
func (p *planner) block(op bundle.Operation) error {
d, err := p.load(op.File)
if err != nil {
return err
}
text := string(d.content)
if d.before == nil && *op.Content != "" {
if op.Create != "" {
raw, err := p.bundle.File(op.Create)
if err != nil {
return err
}
text = string(raw)
}
p.created(op.File)
}
key := op.File + "#" + op.Block
if existing, ok := readBlock(text, op.Block); ok {
if oldSha := p.old.Common.Blocks[key]; oldSha != "" &&
bundle.Sha256([]byte(existing)) != oldSha && existing != *op.Content {
p.finding(ManualChange, key, "Block seit dem letzten sync von Hand geändert")
}
}
// Leer heißt nicht löschen: ergebnis löscht nur Dateien, die Hugin angelegt hat.
d.content = append([]byte{}, setBlock(text, op.Block, *op.Content)...)
if *op.Content != "" {
if p.next.Common.Blocks == nil {
p.next.Common.Blocks = map[string]string{}
}
p.next.Common.Blocks[key] = bundle.Sha256([]byte(*op.Content))
}
return nil
}
func (p *planner) result(opt Options) (*Plan, error) {
plan := &Plan{Findings: p.findings, Write: map[string][]byte{}, Lock: p.next, options: opt}
names := make([]string, 0, len(p.files))
for rel := range p.files {
names = append(names, rel)
}
sort.Strings(names)
for _, rel := range names {
d := p.files[rel]
if d.json != nil {
d.content = d.json.bytes()
if d.json.empty() && p.old.Created(rel) {
d.content = nil
}
}
if len(d.content) == 0 && p.old.Created(rel) {
d.content = nil
}
switch {
case d.content == nil && d.before != nil:
plan.Delete = append(plan.Delete, rel)
p.forgotten(rel)
case d.content != nil && !bytes.Equal(d.content, d.before):
plan.Write[rel] = d.content
if d.before == nil {
plan.New = append(plan.New, rel)
}
default:
plan.unchanged++
}
}
// Pakete ohne eigene Dateien und Schlüssel (nur entfernen) gehören nicht in die Lock
for id, pk := range p.next.Packages {
if pk.Version == "" {
delete(p.next.Packages, id)
}
}
return plan, nil
}
func (p *planner) forgotten(rel string) {
p.next.Common.Created = slices.DeleteFunc(p.next.Common.Created, func(s string) bool { return s == rel })
}
// Run schreibt den Plan: erst alle neuen Inhalte in Hilfsdateien, dann
// umbenennen, dann löschen, zuletzt hugin.lock.
func (p *Plan) Run(repo string) error {
return p.RunWithLock(repo, filepath.Join(repo, lock.Filename))
}
// RunWithLock schreibt wie Run, die Lock-Datei aber nach lockPath
// (hugin try führt eine eigene, damit hugin.lock unberührt bleibt).
func (p *Plan) RunWithLock(repo, lockPath string) error {
if p.Open() {
return errors.New("offene Befunde; mit --overwrite setzt Hugin die Dateien zurück")
}
var helperFiles [][2]string
cleanUp := func() {
for _, h := range helperFiles {
os.Remove(h[0])
}
}
names := make([]string, 0, len(p.Write))
for rel := range p.Write {
names = append(names, rel)
}
sort.Strings(names)
for _, rel := range names {
abs, err := paths.InRepo(repo, rel)
if err != nil {
cleanUp()
return err
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
cleanUp()
return err
}
tmp := abs + ".hugin-new"
if err := os.WriteFile(tmp, p.Write[rel], 0o644); err != nil {
cleanUp()
return err
}
helperFiles = append(helperFiles, [2]string{tmp, abs})
}
for _, h := range helperFiles {
if err := os.Rename(h[0], h[1]); err != nil {
cleanUp()
return err
}
}
for _, rel := range p.Delete {
abs, err := paths.InRepo(repo, rel)
if err != nil {
return err
}
if err := os.Remove(abs); err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
removeEmptyFolders(repo, path.Dir(rel))
}
raw, err := p.Lock.Bytes()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(lockPath), 0o755); err != nil {
return err
}
return os.WriteFile(lockPath, raw, 0o644)
}
// removeEmptyFolders löscht rel und seine Eltern, solange sie leer sind.
func removeEmptyFolders(repo, rel string) {
for rel != "." && rel != "" && !strings.HasPrefix(rel, "..") {
if err := os.Remove(filepath.Join(repo, filepath.FromSlash(rel))); err != nil {
return
}
rel = path.Dir(rel)
}
}

View file

@ -0,0 +1,328 @@
package reconcile
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"hugin/cli/internal/bundle"
"hugin/cli/internal/lock"
)
// Goldene Bundles, die die Server-Tests erzeugen (contract/examples/bundles).
const examples = "../../../contract/examples/bundles"
func load(t *testing.T, name string) *bundle.Bundle {
t.Helper()
b, err := bundle.Read(filepath.Join(examples, name))
if err != nil {
t.Fatal(err)
}
return b
}
func sync(t *testing.T, repo, name string, opt Options) *Plan {
t.Helper()
old, err := lock.Load(repo)
if err != nil {
t.Fatal(err)
}
plan, err := MakePlan(repo, load(t, name), old, opt)
if err != nil {
t.Fatal(err)
}
if !plan.Open() {
if err := plan.Run(repo); err != nil {
t.Fatal(err)
}
}
return plan
}
func read(t *testing.T, repo, rel string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(repo, rel))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func write(t *testing.T, repo, rel, content string) {
t.Helper()
abs := filepath.Join(repo, rel)
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func exists(repo, rel string) bool {
_, err := os.Stat(filepath.Join(repo, rel))
return err == nil
}
func jsonValue(t *testing.T, repo, rel string) map[string]any {
t.Helper()
var value map[string]any
if err := json.Unmarshal([]byte(read(t, repo, rel)), &value); err != nil {
t.Fatalf("%s: %v", rel, err)
}
return value
}
func TestFirstInstallInEmptyRepo(t *testing.T) {
repo := t.TempDir()
plan := sync(t, repo, "first-install", Options{})
if len(plan.Findings) > 0 {
t.Fatalf("Befunde: %v", plan.Findings)
}
for _, rel := range []string{
".claude/skills/pub-munin-nutzen/SKILL.md",
".claude/skills/pub-munin-nutzen/referenz/beispiele.md",
".hugin/rules/pub-hausregeln.md",
"CLAUDE.md", ".mcp.json", "opencode.json", "hugin.lock",
} {
if !exists(repo, rel) {
t.Errorf("%s fehlt", rel)
}
}
claudeMd := read(t, repo, "CLAUDE.md")
if !strings.Contains(claudeMd, "<!-- hugin:begin") || !strings.Contains(claudeMd, "@.hugin/rules/pub-hausregeln.md\n<!-- hugin:end -->") {
t.Errorf("CLAUDE.md ohne Block:\n%s", claudeMd)
}
mcp := jsonValue(t, repo, ".mcp.json")["mcpServers"].(map[string]any)["munin"].(map[string]any)
if mcp["type"] != "http" {
t.Errorf(".mcp.json: %v", mcp)
}
oc := jsonValue(t, repo, "opencode.json")
if oc["instructions"].([]any)[0] != ".hugin/rules/*.md" {
t.Errorf("opencode.json: %v", oc)
}
// Ein zweiter sync mit demselben Bundle ändert nichts
if plan := sync(t, repo, "first-install", Options{}); plan.Changed() {
t.Errorf("zweiter sync ändert etwas: schreiben %v, löschen %v", plan.Write, plan.Delete)
}
}
func TestUpdateCleansUp(t *testing.T) {
repo := t.TempDir()
sync(t, repo, "first-install", Options{})
plan := sync(t, repo, "update", Options{})
if len(plan.Findings) > 0 {
t.Fatalf("Befunde: %v", plan.Findings)
}
for _, remove := range []string{
".claude/skills/pub-munin-nutzen/referenz",
".hugin/rules/pub-hausregeln.md",
".hugin",
"CLAUDE.md", // von Hugin angelegt und jetzt leer
} {
if exists(repo, remove) {
t.Errorf("%s sollte entfernt sein", remove)
}
}
if !strings.Contains(read(t, repo, ".claude/skills/pub-munin-nutzen/SKILL.md"), "pub-munin-nutzen 1.5.0") {
t.Error("SKILL.md nicht aktualisiert")
}
oc := jsonValue(t, repo, "opencode.json")
if _, ok := oc["mcp"]; !ok {
t.Error("MCP-Eintrag fehlt")
}
if _, ok := oc["instructions"]; ok {
t.Errorf("leere instructions nicht entfernt: %v", oc["instructions"])
}
l, _ := lock.Load(repo)
if _, ok := l.Packages["pub-hausregeln"]; ok {
t.Error("pub-hausregeln noch in hugin.lock")
}
if l.Packages["pub-munin-nutzen"].Version != "1.5.0" {
t.Errorf("Version in hugin.lock: %v", l.Packages["pub-munin-nutzen"])
}
}
func TestTeamFilesArePreserved(t *testing.T) {
repo := t.TempDir()
write(t, repo, "CLAUDE.md", "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n")
write(t, repo, "opencode.json", "{\n // unser Modell\n \"model\": \"anthropic/claude-sonnet-5-5\",\n \"instructions\": [\"docs/stil.md\"]\n}\n")
sync(t, repo, "first-install", Options{})
claudeMd := read(t, repo, "CLAUDE.md")
if !strings.HasPrefix(claudeMd, "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n\r\n<!-- hugin:begin") {
t.Errorf("CLAUDE.md:\n%q", claudeMd)
}
if strings.Contains(strings.ReplaceAll(claudeMd, "\r\n", ""), "\n") {
t.Error("CLAUDE.md: Zeilenenden gemischt")
}
oc := read(t, repo, "opencode.json")
if !strings.Contains(oc, "// unser Modell") || !strings.Contains(oc, `"docs/stil.md"`) {
t.Errorf("opencode.json verändert:\n%s", oc)
}
sync(t, repo, "update", Options{})
if got := read(t, repo, "CLAUDE.md"); got != "# Unser Projekt\r\n\r\nWir nutzen Go.\r\n" {
t.Errorf("CLAUDE.md nach Abbestellen:\n%q", got)
}
oc = read(t, repo, "opencode.json")
if !strings.Contains(oc, `"docs/stil.md"`) || strings.Contains(oc, ".hugin/rules") {
t.Errorf("opencode.json nach Abbestellen:\n%s", oc)
}
}
func TestManualChangeIsDetected(t *testing.T) {
repo := t.TempDir()
sync(t, repo, "first-install", Options{})
skill := ".claude/skills/pub-munin-nutzen/SKILL.md"
write(t, repo, skill, "von Hand\n")
plan := sync(t, repo, "update", Options{})
if !plan.Open() || len(plan.Findings) != 1 || plan.Findings[0].Kind != ManualChange || plan.Findings[0].Location != skill {
t.Fatalf("Befunde: %v", plan.Findings)
}
if read(t, repo, skill) != "von Hand\n" {
t.Error("Datei trotz Befund geändert")
}
if !exists(repo, ".hugin/rules/pub-hausregeln.md") {
t.Error("andere Dateien trotz Befund geändert")
}
sync(t, repo, "update", Options{Overwrite: true})
if !strings.Contains(read(t, repo, skill), "1.5.0") {
t.Error("--overwrite setzt nicht zurück")
}
}
func TestManualChangeInBlock(t *testing.T) {
repo := t.TempDir()
sync(t, repo, "first-install", Options{})
text := strings.Replace(read(t, repo, "CLAUDE.md"), "@.hugin", "@extra.md\n@.hugin", 1)
write(t, repo, "CLAUDE.md", text)
if plan := sync(t, repo, "first-install", Options{}); len(plan.Findings) != 1 || plan.Findings[0].Location != "CLAUDE.md#hugin" {
t.Fatalf("Befunde: %v", plan.Findings)
}
}
func TestForeignMcpServerIsConflict(t *testing.T) {
repo := t.TempDir()
write(t, repo, ".mcp.json", `{"mcpServers": {"munin": {"type": "http", "url": "https://anders"}}}`)
plan := sync(t, repo, "first-install", Options{})
if len(plan.Findings) != 1 || plan.Findings[0].Kind != Conflict {
t.Fatalf("Befunde: %v", plan.Findings)
}
if exists(repo, "hugin.lock") {
t.Error("trotz Konflikt geschrieben")
}
}
func TestForeignFileIsConflict(t *testing.T) {
repo := t.TempDir()
write(t, repo, ".claude/skills/pub-munin-nutzen/SKILL.md", "eigener Skill\n")
plan := sync(t, repo, "first-install", Options{})
if len(plan.Findings) != 1 || plan.Findings[0].Kind != Conflict {
t.Fatalf("Befunde: %v", plan.Findings)
}
}
func TestSymlinkInRepoIsRejected(t *testing.T) {
repo := t.TempDir()
outside := t.TempDir()
if err := os.Symlink(outside, filepath.Join(repo, ".claude")); err != nil {
t.Fatal(err)
}
if _, err := MakePlan(repo, load(t, "first-install"), lock.New(), Options{}); err == nil {
t.Fatal("Symlink nicht erkannt")
}
}
func TestBlockSet(t *testing.T) {
tests := []struct{ name, text, content, expected string }{
{"leere Datei", "", "a\n", beginMarker("hugin") + "\na\n" + endMarker("hugin") + "\n"},
{"anhängen", "x\n", "a\n", "x\n\n" + beginMarker("hugin") + "\na\n" + endMarker("hugin") + "\n"},
{"replace", "x\n\n" + beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\ny\n", "a\nb\n",
"x\n\n" + beginMarker("hugin") + "\na\nb\n" + endMarker("hugin") + "\ny\n"},
{"remove", "x\n\n" + beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\n", "", "x\n"},
{"entfernen ohne Block", "x\n", "", "x\n"},
{"nur Block entfernen", beginMarker("hugin") + "\nalt\n" + endMarker("hugin") + "\n", "", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := setBlock(tt.text, "hugin", tt.content); got != tt.expected {
t.Errorf("\n got %q\nwant %q", got, tt.expected)
}
})
}
}
func TestCommandsAndAgents(t *testing.T) {
repo := t.TempDir()
if plan := sync(t, repo, "commands-and-agents", Options{}); len(plan.Findings) > 0 {
t.Fatalf("Befunde: %v", plan.Findings)
}
for _, rel := range []string{
".claude/commands/pub-release-notiz.md", ".opencode/commands/pub-release-notiz.md",
".claude/agents/pub-code-review.md", ".opencode/agents/pub-code-review.md",
} {
if !exists(repo, rel) {
t.Errorf("%s fehlt", rel)
}
}
if !strings.Contains(read(t, repo, ".opencode/commands/pub-release-notiz.md"), "Version $1, gedacht für $2") {
t.Error("Platzhalter für OpenCode nicht übersetzt")
}
}
func TestEmptyBundleCleansUpEverything(t *testing.T) {
repo := t.TempDir()
write(t, repo, "opencode.json", "{\n \"model\": \"x\"\n}\n")
sync(t, repo, "first-install", Options{})
old, _ := lock.Load(repo)
plan, err := MakePlan(repo, bundle.Empty(), old, Options{})
if err != nil || len(plan.Findings) > 0 {
t.Fatalf("%v %v", err, plan.Findings)
}
if err := plan.Run(repo); err != nil {
t.Fatal(err)
}
for _, remove := range []string{".claude", ".hugin", "CLAUDE.md", ".mcp.json"} {
if exists(repo, remove) {
t.Errorf("%s ist noch da", remove)
}
}
if got := jsonValue(t, repo, "opencode.json"); len(got) != 1 || got["model"] != "x" {
t.Errorf("opencode.json: %v", got)
}
}
func TestNewOpencodeJsonHasSchemaAndIsCleanedUp(t *testing.T) {
repo := t.TempDir()
sync(t, repo, "first-install", Options{})
if got := jsonValue(t, repo, "opencode.json")["$schema"]; got != OpenCodeSchema {
t.Errorf("$schema in neuer opencode.json: %v", got)
}
// Von hugin angelegt und nur noch $schema übrig: wird wieder entfernt
old, _ := lock.Load(repo)
plan, err := MakePlan(repo, bundle.Empty(), old, Options{})
if err != nil || len(plan.Findings) > 0 {
t.Fatalf("%v %v", err, plan.Findings)
}
if err := plan.Run(repo); err != nil {
t.Fatal(err)
}
if exists(repo, "opencode.json") {
t.Errorf("opencode.json ist noch da:\n%s", read(t, repo, "opencode.json"))
}
}
func TestExistingOpencodeJsonGetsNoSchema(t *testing.T) {
repo := t.TempDir()
write(t, repo, "opencode.json", "{\n \"model\": \"x\"\n}\n")
sync(t, repo, "first-install", Options{})
if _, ok := jsonValue(t, repo, "opencode.json")["$schema"]; ok {
t.Error("hugin schreibt $schema nur in Dateien, die es selbst anlegt")
}
}

58
cli/internal/repo/repo.go Normal file
View file

@ -0,0 +1,58 @@
// Package repo liefert die Repo-Kennung für die Nutzungsmeldung (→ KONZEPT.md 11.5).
package repo
import (
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// Id ist die vereinheitlichte Git-Remote-URL von origin, z. B.
// git.intern/shop/api. Ohne Remote: der Pfad relativ zum Benutzerordner.
func Id(repo string) string {
ctx, abort := context.WithTimeout(context.Background(), 5*time.Second)
defer abort()
from, err := exec.CommandContext(ctx, "git", "-C", repo, "remote", "get-url", "origin").Output()
if err == nil {
if k := Unify(strings.TrimSpace(string(from))); k != "" {
return k
}
}
abs, _ := filepath.Abs(repo)
if home, err := os.UserHomeDir(); err == nil {
if rel, err := filepath.Rel(home, abs); err == nil && !strings.HasPrefix(rel, "..") {
return "~/" + filepath.ToSlash(rel)
}
}
return filepath.ToSlash(abs)
}
var scpForm = regexp.MustCompile(`^[^@/:]+@([^:/]+):(.+)$`) // git@host:pfad
// Unify entfernt Schema, Zugangsdaten, Port bei SSH und .git:
// https://user:pw@git.intern/shop/api.git und git@git.intern:shop/api.git
// werden beide zu git.intern/shop/api.
func Unify(url string) string {
if t := scpForm.FindStringSubmatch(url); t != nil {
url = t[1] + "/" + t[2]
} else {
if i := strings.Index(url, "://"); i >= 0 {
schema := url[:i]
url = url[i+3:]
if j := strings.Index(url, "@"); j >= 0 && j < strings.Index(url+"/", "/") {
url = url[j+1:]
}
if schema == "ssh" {
if host, rest, ok := strings.Cut(url, "/"); ok {
url = strings.Split(host, ":")[0] + "/" + rest
}
}
}
}
url = strings.TrimSuffix(strings.TrimSuffix(url, "/"), ".git")
return strings.ToLower(url[:strings.Index(url+"/", "/")]) + url[strings.Index(url+"/", "/"):]
}

View file

@ -0,0 +1,17 @@
package repo
import "testing"
func TestUnify(t *testing.T) {
for url, expected := range map[string]string{
"https://git.intern/shop/api.git": "git.intern/shop/api",
"https://user:geheim@Git.Intern/shop/api": "git.intern/shop/api",
"git@git.intern:shop/api.git": "git.intern/shop/api",
"ssh://git@git.intern:2222/shop/api.git": "git.intern/shop/api",
"https://git.intern:8443/shop/api/": "git.intern:8443/shop/api",
} {
if got := Unify(url); got != expected {
t.Errorf("Vereinheitlichen(%q) = %q, erwartet %q", url, got, expected)
}
}
}

View file

@ -0,0 +1,38 @@
package tools
import (
"os"
"path/filepath"
"testing"
)
// fakeOpenCode legt ein Programm „opencode“ in einen eigenen PATH.
func fakeOpenCode(t *testing.T, script string) {
t.Helper()
bin := t.TempDir()
if err := os.WriteFile(filepath.Join(bin, "opencode"), []byte("#!/bin/sh\n"+script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin)
}
func TestCheckOpenCode(t *testing.T) {
repo := t.TempDir()
fakeOpenCode(t, "exit 0\n")
if msg := CheckOpenCode(repo); msg != "" {
t.Errorf("gültige Konfiguration: %q", msg)
}
fakeOpenCode(t, `printf '\033[91m\033[1mError: \033[0mConfiguration is invalid at %s/opencode.json\n↳ Missing key mcp.x.enabled\n' "$PWD" >&2; exit 1`+"\n")
msg := CheckOpenCode(repo)
want := "Configuration is invalid at " + repo + "/opencode.json\n↳ Missing key mcp.x.enabled"
if resolved, _ := filepath.EvalSymlinks(repo); msg != want && msg != "Configuration is invalid at "+resolved+"/opencode.json\n↳ Missing key mcp.x.enabled" {
t.Errorf("Meldung ohne Farbcodes erwartet:\n%q\nbekommen:\n%q", want, msg)
}
t.Setenv("PATH", t.TempDir())
if msg := CheckOpenCode(repo); msg != "" {
t.Errorf("ohne OpenCode wird nichts geprüft: %q", msg)
}
}

115
cli/internal/tools/tools.go Normal file
View file

@ -0,0 +1,115 @@
// Package werkzeuge erkennt Claude Code und OpenCode im Repo und auf dem
// Rechner und prüft ihre Versionen gegen die Untergrenzen (→ KONZEPT.md 5.4, 5.5).
package tools
import (
"context"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// Untergrenzen, solange der Server keine anderen liefert (→ KONZEPT.md 5.5).
var Floors = map[string]string{
"claude": "2.1.105",
"opencode": "1.2.11",
}
var hints = map[string][]string{
"claude": {".claude", "CLAUDE.md", ".mcp.json"},
"opencode": {".opencode", "opencode.json", "opencode.jsonc", "AGENTS.md"},
}
// InRepo erkennt die Werkzeuge an vorhandenen Dateien. Ohne Hinweise: beide.
func InRepo(repo string) []string {
var found []string
for _, name := range []string{"claude", "opencode"} {
for _, file := range hints[name] {
if _, err := os.Stat(filepath.Join(repo, file)); err == nil {
found = append(found, name)
break
}
}
}
if len(found) == 0 {
return []string{"claude", "opencode"}
}
return found
}
var semver = regexp.MustCompile(`\d+\.\d+\.\d+`)
// Version fragt das installierte Werkzeug nach seiner Version; leer, wenn es
// nicht installiert ist oder nicht antwortet.
func Version(name string) string {
ctx, abort := context.WithTimeout(context.Background(), 5*time.Second)
defer abort()
from, err := exec.CommandContext(ctx, name, "--version").Output()
if err != nil {
return ""
}
return semver.FindString(string(from))
}
// Less vergleicht zwei Versionen a.b.c.
func Less(a, b string) bool {
pa, pb := parts(a), parts(b)
for i := range 3 {
if pa[i] != pb[i] {
return pa[i] < pb[i]
}
}
return false
}
func parts(v string) [3]int {
var t [3]int
for i, s := range strings.SplitN(v, ".", 3) {
t[i], _ = strconv.Atoi(s)
}
return t
}
// Target ist der Name im Bundle: claude, opencode@1 oder opencode@2.
func Target(name, version string) string {
if name != "opencode" {
return name
}
if version != "" && !Less(version, "2.0.0") {
return "opencode@2"
}
return "opencode@1"
}
var ansi = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// CheckOpenCode lässt OpenCode die zusammengeführte Konfiguration im Repo laden. OpenCode
// startet nicht, wenn opencode.json ein falsches Feld hat; das soll hugin melden, bevor es
// jemand beim Start merkt. Leer bei Erfolg oder wenn OpenCode nicht installiert ist.
// --pure: globale Plugins nicht mitladen (sie können eigene Fehler haben).
func CheckOpenCode(repo string) string {
if _, err := exec.LookPath("opencode"); err != nil {
return ""
}
ctx, abort := context.WithTimeout(context.Background(), 30*time.Second)
defer abort()
cmd := exec.CommandContext(ctx, "opencode", "debug", "config", "--pure")
cmd.Dir = repo
var stderr strings.Builder
cmd.Stderr = &stderr
if err := cmd.Run(); err == nil {
return ""
} else if ctx.Err() != nil {
return "opencode debug config antwortet nicht (Zeitlimit 30 s)"
}
text := strings.TrimSpace(ansi.ReplaceAllString(stderr.String(), ""))
text = strings.TrimPrefix(text, "Error: ")
if text == "" {
return "opencode debug config ist fehlgeschlagen"
}
return text
}

View file

@ -0,0 +1,44 @@
package tools
import (
"os"
"path/filepath"
"slices"
"testing"
)
func TestLess(t *testing.T) {
tests := []struct {
a, b string
ok bool
}{
{"2.1.104", "2.1.105", true},
{"2.1.105", "2.1.105", false},
{"2.10.0", "2.9.9", false},
{"1.2.9", "1.2.11", true},
}
for _, tt := range tests {
if Less(tt.a, tt.b) != tt.ok {
t.Errorf("Kleiner(%s, %s) != %v", tt.a, tt.b, tt.ok)
}
}
}
func TestTarget(t *testing.T) {
for version, target := range map[string]string{"1.19.3": "opencode@1", "2.0.1": "opencode@2", "": "opencode@1"} {
if got := Target("opencode", version); got != target {
t.Errorf("Ziel(opencode, %q) = %s", version, got)
}
}
}
func TestInRepo(t *testing.T) {
repo := t.TempDir()
if got := InRepo(repo); !slices.Equal(got, []string{"claude", "opencode"}) {
t.Errorf("leeres Repo: %v", got)
}
os.WriteFile(filepath.Join(repo, "CLAUDE.md"), nil, 0o644)
if got := InRepo(repo); !slices.Equal(got, []string{"claude"}) {
t.Errorf("nur CLAUDE.md: %v", got)
}
}

View file

@ -0,0 +1,73 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hugin.intern/schema/bundle-request-1.json",
"title": "Anfrage an POST /api/v1/bundle bzw. /api/v1/delivery/bundle",
"type": "object",
"required": [
"packages",
"tools"
],
"additionalProperties": false,
"properties": {
"packages": {
"description": "Abos: <id>@<stabil|test|^major|exakte Version>. Ohne @ gilt @stabil.",
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*(@(stabil|test|\\^\\d+|\\d+\\.\\d+\\.\\d+))?$"
},
"uniqueItems": true
},
"unsubscribed": {
"description": "Pakete, die nicht geliefert werden sollen. Für Pflichtpakete wirkungslos.",
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$"
},
"uniqueItems": true
},
"tools": {
"description": "Erkannte Werkzeuge mit installierter Version; leerer String, wenn die Version unbekannt ist.",
"type": "object",
"minProperties": 1,
"additionalProperties": false,
"properties": {
"claude": {
"type": "string"
},
"opencode": {
"type": "string"
}
}
},
"mandatory": {
"description": "Pflichtpakete mitliefern.",
"type": "boolean",
"default": true
},
"major": {
"description": "Major-Sprünge bei @stabil erlauben (hugin sync --major).",
"type": "boolean",
"default": false
},
"options": {
"type": "object",
"additionalProperties": false,
"properties": {
"opencode_reads_claude": {
"type": "boolean",
"default": true
}
}
},
"installed": {
"description": "Aktuell installierte Versionen (aus hugin.lock). Daraus berechnet der Server die entfernen-Operationen.",
"type": "object",
"additionalProperties": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
}
}
}
}

View file

@ -0,0 +1,17 @@
---
# Verwaltet von Hugin (pub-munin-nutzen 1.4.0). Änderungen über hugin.intern.
name: pub-munin-nutzen
description: 'Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.'
license: intern
---
# Munin nutzen
Munin ist das gemeinsame Gedächtnis der Teams.
1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach
bekannten Besonderheiten des Projekts.
2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest.
3. Mit `related` findest du verwandte Einträge.
Beispiele stehen in [referenz/beispiele.md](referenz/beispiele.md).

View file

@ -0,0 +1,4 @@
# Beispiele
- Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“
- Schlecht: „Ich habe heute die Tests laufen lassen.“

View file

@ -0,0 +1,6 @@
<!-- Verwaltet von Hugin (pub-hausregeln 1.0.0). Änderungen über hugin.intern. -->
# Hausregeln
- Schreibe keine Geheimnisse (Keys, Passwörter) in Dateien oder Commits.
- Frage nach, bevor du etwas löschst, das du nicht selbst angelegt hast.

View file

@ -0,0 +1,57 @@
{
"format": 1,
"generated": "2026-10-18T12:00:00Z",
"tools": [
"claude"
],
"packages": [
{
"id": "pub-hausregeln",
"version": "1.0.0",
"checksum": "sha256:0e2983623cec5dfe509ffb5f62332a4ff54a79b2affe5c981165148cb09ce127",
"kind": "rules",
"risk": "text",
"compatibility": "translated",
"mandatory": false
},
{
"id": "pub-munin-nutzen",
"version": "1.4.0",
"checksum": "sha256:9e50afeed46db30a6e651488caf175a82b763ce266943525e49a3c85cd05ca37",
"kind": "skill",
"risk": "text",
"compatibility": "portable",
"mandatory": false
}
],
"operations": [
{
"op": "file",
"package": "pub-munin-nutzen",
"path": ".claude/skills/pub-munin-nutzen/SKILL.md",
"source": "files/.claude/skills/pub-munin-nutzen/SKILL.md",
"sha256": "sha256:d3ad99d5e6f43d532917f1f57589259cf4812d292e1104f087d56820d5fbbe26"
},
{
"op": "file",
"package": "pub-munin-nutzen",
"path": ".claude/skills/pub-munin-nutzen/referenz/beispiele.md",
"source": "files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md",
"sha256": "sha256:2746734c5c36045173fadbed75377a65817c8ca6fccb4e487773fc0ae75ec58a"
},
{
"op": "file",
"package": "pub-hausregeln",
"path": ".hugin/rules/pub-hausregeln.md",
"source": "files/.hugin/rules/pub-hausregeln.md",
"sha256": "sha256:670a7621fa4b561ed580452fde7881d551d3c9cb83ae6adc581cb2e1b61c1bfd"
},
{
"op": "block",
"file": "CLAUDE.md",
"block": "hugin",
"content": "@.hugin/rules/pub-hausregeln.md\n"
}
],
"hints": []
}

View file

@ -0,0 +1,12 @@
---
# Verwaltet von Hugin (pub-code-review 1.0.0). Änderungen über hugin.intern.
name: pub-code-review
description: Prüft Änderungen auf Fehler und Verständlichkeit. Nach größeren Änderungen einsetzen, bevor ein Pull Request entsteht.
tools: Read, Grep, Glob
model: opus
effort: high
---
Du prüfst Änderungen an Code. Du änderst nichts, du schreibst nur Befunde.
Für jeden Befund: Datei und Zeile, was falsch ist, ein konkreter Vorschlag.

View file

@ -0,0 +1,13 @@
---
# Verwaltet von Hugin (pub-release-notiz 1.0.0). Änderungen über hugin.intern.
description: Schreibt eine Release-Notiz für eine Version.
argument-hint: '[version] [zielgruppe]'
arguments:
- version
- zielgruppe
---
Schreibe eine Release-Notiz für Version $version, gedacht für $zielgruppe.
Lies dazu die Commits seit dem letzten Tag.
Weitere Wünsche: $ARGUMENTS

View file

@ -0,0 +1,16 @@
---
# Verwaltet von Hugin (pub-code-review 1.0.0). Änderungen über hugin.intern.
description: Prüft Änderungen auf Fehler und Verständlichkeit. Nach größeren Änderungen einsetzen, bevor ein Pull Request entsteht.
mode: subagent
permission:
edit: deny
bash: deny
webfetch: deny
websearch: deny
model: anthropic/claude-opus-5-5
temperature: 0.1
---
Du prüfst Änderungen an Code. Du änderst nichts, du schreibst nur Befunde.
Für jeden Befund: Datei und Zeile, was falsch ist, ein konkreter Vorschlag.

View file

@ -0,0 +1,9 @@
---
# Verwaltet von Hugin (pub-release-notiz 1.0.0). Änderungen über hugin.intern.
description: Schreibt eine Release-Notiz für eine Version.
---
Schreibe eine Release-Notiz für Version $1, gedacht für $2.
Lies dazu die Commits seit dem letzten Tag.
Weitere Wünsche: $ARGUMENTS

View file

@ -0,0 +1,59 @@
{
"format": 1,
"generated": "2026-10-18T12:00:00Z",
"tools": [
"claude",
"opencode@1"
],
"packages": [
{
"id": "pub-code-review",
"version": "1.0.0",
"checksum": "sha256:0b8f1233bb379c53fa4b6edf63993c78b3f8bfd9a83f7a7ccc604b10ff3e5a29",
"kind": "subagent",
"risk": "text",
"compatibility": "translated",
"mandatory": false
},
{
"id": "pub-release-notiz",
"version": "1.0.0",
"checksum": "sha256:22643f328cc562b525974a0eb815aec3c0ef8fa5c79e3997e0d3e356ab327b16",
"kind": "command",
"risk": "text",
"compatibility": "translated",
"mandatory": false
}
],
"operations": [
{
"op": "file",
"package": "pub-code-review",
"path": ".claude/agents/pub-code-review.md",
"source": "files/.claude/agents/pub-code-review.md",
"sha256": "sha256:706a29ca50d481c7985ad5eb40e124d832b13124d56d42f2ed1d4f03111ae230"
},
{
"op": "file",
"package": "pub-release-notiz",
"path": ".claude/commands/pub-release-notiz.md",
"source": "files/.claude/commands/pub-release-notiz.md",
"sha256": "sha256:11fea449fecf436eb7753f2769e7f1fed45ebcd95c77bb5b8cd15d755bec5d08"
},
{
"op": "file",
"package": "pub-code-review",
"path": ".opencode/agents/pub-code-review.md",
"source": "files/.opencode/agents/pub-code-review.md",
"sha256": "sha256:96cab45a44f56db9fb3584323b14f5ddef4d2eb3f811806f95444d650b43ef3b"
},
{
"op": "file",
"package": "pub-release-notiz",
"path": ".opencode/commands/pub-release-notiz.md",
"source": "files/.opencode/commands/pub-release-notiz.md",
"sha256": "sha256:01579f65b6407f1f89fa7728cc7f44b8157b147378717e3031c0898ea673e3bd"
}
],
"hints": []
}

View file

@ -0,0 +1,17 @@
---
# Verwaltet von Hugin (pub-munin-nutzen 1.4.0). Änderungen über hugin.intern.
name: pub-munin-nutzen
description: 'Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.'
license: intern
---
# Munin nutzen
Munin ist das gemeinsame Gedächtnis der Teams.
1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach
bekannten Besonderheiten des Projekts.
2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest.
3. Mit `related` findest du verwandte Einträge.
Beispiele stehen in [referenz/beispiele.md](referenz/beispiele.md).

View file

@ -0,0 +1,4 @@
# Beispiele
- Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“
- Schlecht: „Ich habe heute die Tests laufen lassen.“

View file

@ -0,0 +1,6 @@
<!-- Verwaltet von Hugin (pub-hausregeln 1.0.0). Änderungen über hugin.intern. -->
# Hausregeln
- Schreibe keine Geheimnisse (Keys, Passwörter) in Dateien oder Commits.
- Frage nach, bevor du etwas löschst, das du nicht selbst angelegt hast.

View file

@ -0,0 +1,115 @@
{
"format": 1,
"generated": "2026-10-18T12:00:00Z",
"tools": [
"claude",
"opencode@1"
],
"packages": [
{
"id": "pub-hausregeln",
"version": "1.0.0",
"checksum": "sha256:0e2983623cec5dfe509ffb5f62332a4ff54a79b2affe5c981165148cb09ce127",
"kind": "rules",
"risk": "text",
"compatibility": "translated",
"mandatory": false
},
{
"id": "pub-munin-anbindung",
"version": "1.0.0",
"checksum": "sha256:9c3bb85f6f4bc4596303f8dacf00a7ad2db9e2ef813e889ea7fca42db23f34a2",
"kind": "mcp",
"risk": "tools",
"compatibility": "translated",
"mandatory": true
},
{
"id": "pub-munin-nutzen",
"version": "1.4.0",
"checksum": "sha256:9e50afeed46db30a6e651488caf175a82b763ce266943525e49a3c85cd05ca37",
"kind": "skill",
"risk": "text",
"compatibility": "portable",
"mandatory": false
}
],
"operations": [
{
"op": "file",
"package": "pub-munin-nutzen",
"path": ".claude/skills/pub-munin-nutzen/SKILL.md",
"source": "files/.claude/skills/pub-munin-nutzen/SKILL.md",
"sha256": "sha256:d3ad99d5e6f43d532917f1f57589259cf4812d292e1104f087d56820d5fbbe26"
},
{
"op": "file",
"package": "pub-munin-nutzen",
"path": ".claude/skills/pub-munin-nutzen/referenz/beispiele.md",
"source": "files/.claude/skills/pub-munin-nutzen/referenz/beispiele.md",
"sha256": "sha256:2746734c5c36045173fadbed75377a65817c8ca6fccb4e487773fc0ae75ec58a"
},
{
"op": "file",
"package": "pub-hausregeln",
"path": ".hugin/rules/pub-hausregeln.md",
"source": "files/.hugin/rules/pub-hausregeln.md",
"sha256": "sha256:670a7621fa4b561ed580452fde7881d551d3c9cb83ae6adc581cb2e1b61c1bfd"
},
{
"op": "json",
"package": "pub-munin-anbindung",
"file": ".mcp.json",
"key": [
"mcpServers",
"munin"
],
"value": {
"type": "http",
"url": "https://munin.intern/mcp",
"headers": {
"Authorization": "Bearer ${MUNIN_KEY}"
}
}
},
{
"op": "json",
"file": "opencode.json",
"key": [
"instructions"
],
"mode": "list_item",
"value": ".hugin/rules/*.md"
},
{
"op": "json",
"package": "pub-munin-anbindung",
"file": "opencode.json",
"key": [
"mcp",
"munin"
],
"value": {
"type": "remote",
"url": "https://munin.intern/mcp",
"enabled": true,
"headers": {
"Authorization": "Bearer {env:MUNIN_KEY}"
}
}
},
{
"op": "block",
"file": "CLAUDE.md",
"block": "hugin",
"content": "@.hugin/rules/pub-hausregeln.md\n"
}
],
"hints": [
{
"kind": "env_variable",
"name": "MUNIN_KEY",
"package": "pub-munin-anbindung"
}
]
}

View file

@ -0,0 +1,86 @@
{
"format": 1,
"generated": "2026-10-18T12:00:00Z",
"tools": [
"claude",
"opencode@1"
],
"packages": [
{
"id": "pub-zeit-lokal",
"version": "1.0.0",
"checksum": "sha256:29b47ccd830b4a4815746972bdcca964e23adc4e935e82ff7877ccb50ceb4584",
"kind": "mcp",
"risk": "scripts",
"compatibility": "translated",
"mandatory": false
}
],
"operations": [
{
"op": "json",
"package": "pub-zeit-lokal",
"file": ".mcp.json",
"key": [
"mcpServers",
"zeit"
],
"value": {
"type": "stdio",
"command": "hugin",
"args": [
"mcp",
"run",
"pub-zeit-lokal"
],
"env": {
"ZEIT_ZONE_HINWEIS": "${ZEIT_ZONE_HINWEIS}"
}
}
},
{
"op": "json",
"package": "pub-zeit-lokal",
"file": "opencode.json",
"key": [
"mcp",
"zeit"
],
"value": {
"type": "local",
"command": [
"hugin",
"mcp",
"run",
"pub-zeit-lokal"
],
"enabled": true,
"environment": {
"ZEIT_ZONE_HINWEIS": "{env:ZEIT_ZONE_HINWEIS}"
}
}
},
{
"op": "install",
"package": "pub-zeit-lokal",
"version": "1.0.0",
"checksum": "sha256:29b47ccd830b4a4815746972bdcca964e23adc4e935e82ff7877ccb50ceb4584",
"runtime": "python",
"entry": "zeit_server",
"files": [
{
"path": "zeit_server.py",
"source": "local/pub-zeit-lokal/1.0.0/zeit_server.py",
"sha256": "sha256:45e3d0952541a5f887a3734492b150c68c75fbf27afcd173fe8153ae845323cc"
}
]
}
],
"hints": [
{
"kind": "env_variable",
"name": "ZEIT_ZONE_HINWEIS",
"package": "pub-zeit-lokal"
}
]
}

View file

@ -0,0 +1,52 @@
"""Minimaler MCP-Server über stdio, nur mit der Standardbibliothek.
Ein JSON-Objekt je Zeile. Ausgabe als Bytes, damit Windows kein \r einfügt.
"""
import json
import os
import sys
from datetime import datetime
TOOL = {
"name": "zeit",
"description": "Liefert Datum und Uhrzeit dieses Rechners.",
"inputSchema": {"type": "object", "properties": {}},
}
def answer(message):
method = message.get("method")
if method == "initialize":
return {"protocolVersion": message["params"]["protocolVersion"],
"capabilities": {"tools": {}}, "serverInfo": {"name": "zeit", "version": "1.0.0"}}
if method == "tools/list":
return {"tools": [TOOL]}
if method == "tools/call":
hint = os.environ.get("ZEIT_ZONE_HINWEIS", "")
text = f"{datetime.now().astimezone():%d.%m.%Y %H:%M:%S %Z} UHR-4711" + (f" ({hint})" if hint else "")
return {"content": [{"type": "text", "text": text}]}
if method == "ping":
return {}
return None
def main():
for line in sys.stdin:
if not line.strip():
continue
message = json.loads(line)
if "id" not in message:
continue # Benachrichtigungen brauchen keine Antwort
result = answer(message)
if result is None:
reply = {"jsonrpc": "2.0", "id": message["id"],
"error": {"code": -32601, "message": f"unbekannt: {message.get('method')}"}}
else:
reply = {"jsonrpc": "2.0", "id": message["id"], "result": result}
sys.stdout.buffer.write(json.dumps(reply).encode("utf-8") + b"\n")
sys.stdout.buffer.flush()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,2 @@
// Verwaltet von Hugin (pub-specht-plugin 1.0.0). Änderungen über hugin.intern.
export * from "./pub-specht-plugin/index.ts"

View file

@ -0,0 +1,17 @@
import { tool } from "@opencode-ai/plugin"
import { marke } from "./lib/marke.ts"
// OpenCode ruft jede exportierte Funktion beim Start auf
export const SpechtPlugin = async () => {
return {
tool: {
specht_marke: tool({
description: "Liefert die Erkennungsmarke des Specht-Plugins",
args: {},
async execute() {
return marke
},
}),
},
}
}

View file

@ -0,0 +1 @@
export const marke = "SPECHT-5127"

View file

@ -0,0 +1,43 @@
{
"format": 1,
"generated": "2026-10-18T12:00:00Z",
"tools": [
"claude",
"opencode@1"
],
"packages": [
{
"id": "pub-specht-plugin",
"version": "1.0.0",
"checksum": "sha256:fafb3e308847f4b491685ba223350e75c79aa2b6a7d7fab21189a695b0d47dcb",
"kind": "plugin",
"risk": "permissions",
"compatibility": "opencode-only",
"mandatory": false
}
],
"operations": [
{
"op": "file",
"package": "pub-specht-plugin",
"path": ".opencode/plugins/pub-specht-plugin.ts",
"source": "files/.opencode/plugins/pub-specht-plugin.ts",
"sha256": "sha256:b053f6c9332c7a3777ae4b6aae3303216853da473225b72d3edc66c1045e1c1e"
},
{
"op": "file",
"package": "pub-specht-plugin",
"path": ".opencode/plugins/pub-specht-plugin/index.ts",
"source": "files/.opencode/plugins/pub-specht-plugin/index.ts",
"sha256": "sha256:c33979fe526c3b22b625bdc37f9e51bce083b9045ef6d6ab852e294295ecb8f8"
},
{
"op": "file",
"package": "pub-specht-plugin",
"path": ".opencode/plugins/pub-specht-plugin/lib/marke.ts",
"source": "files/.opencode/plugins/pub-specht-plugin/lib/marke.ts",
"sha256": "sha256:53e52e0666b133f9ec0597554abc8da3a80899e0f3abae405f47c4fa7da7a06d"
}
],
"hints": []
}

View file

@ -0,0 +1,18 @@
---
# Verwaltet von Hugin (pub-munin-nutzen 1.5.0). Änderungen über hugin.intern.
name: pub-munin-nutzen
description: 'Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.'
license: intern
---
# Munin nutzen
Munin ist das gemeinsame Gedächtnis der Teams.
1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach
bekannten Besonderheiten des Projekts.
2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest.
3. Mit `related` findest du verwandte Einträge.
Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“
Schlecht: „Ich habe heute die Tests laufen lassen.“

View file

@ -0,0 +1,102 @@
{
"format": 1,
"generated": "2026-10-18T12:00:00Z",
"tools": [
"claude",
"opencode@1"
],
"packages": [
{
"id": "pub-munin-anbindung",
"version": "1.0.0",
"checksum": "sha256:9c3bb85f6f4bc4596303f8dacf00a7ad2db9e2ef813e889ea7fca42db23f34a2",
"kind": "mcp",
"risk": "tools",
"compatibility": "translated",
"mandatory": true
},
{
"id": "pub-munin-nutzen",
"version": "1.5.0",
"checksum": "sha256:8a573c430c18265cb033dfdfba7daeb1d6366c7703ef0c00cb323554563a3f93",
"kind": "skill",
"risk": "text",
"compatibility": "portable",
"mandatory": false
}
],
"operations": [
{
"op": "remove",
"package": "pub-munin-nutzen",
"path": ".claude/skills/pub-munin-nutzen/referenz/beispiele.md"
},
{
"op": "remove",
"package": "pub-hausregeln",
"path": ".hugin/rules/pub-hausregeln.md"
},
{
"op": "json_remove",
"file": "opencode.json",
"key": [
"instructions"
],
"mode": "list_item",
"value": ".hugin/rules/*.md"
},
{
"op": "file",
"package": "pub-munin-nutzen",
"path": ".claude/skills/pub-munin-nutzen/SKILL.md",
"source": "files/.claude/skills/pub-munin-nutzen/SKILL.md",
"sha256": "sha256:50430ecef44ca4944aed97dd02b086e987a8f52b588cfafa95347fef154b78f1"
},
{
"op": "json",
"package": "pub-munin-anbindung",
"file": ".mcp.json",
"key": [
"mcpServers",
"munin"
],
"value": {
"type": "http",
"url": "https://munin.intern/mcp",
"headers": {
"Authorization": "Bearer ${MUNIN_KEY}"
}
}
},
{
"op": "json",
"package": "pub-munin-anbindung",
"file": "opencode.json",
"key": [
"mcp",
"munin"
],
"value": {
"type": "remote",
"url": "https://munin.intern/mcp",
"enabled": true,
"headers": {
"Authorization": "Bearer {env:MUNIN_KEY}"
}
}
},
{
"op": "block",
"file": "CLAUDE.md",
"block": "hugin",
"content": ""
}
],
"hints": [
{
"kind": "env_variable",
"name": "MUNIN_KEY",
"package": "pub-munin-anbindung"
}
]
}

View file

@ -0,0 +1,3 @@
Du prüfst Änderungen an Code. Du änderst nichts, du schreibst nur Befunde.
Für jeden Befund: Datei und Zeile, was falsch ist, ein konkreter Vorschlag.

View file

@ -0,0 +1,15 @@
id: pub-code-review
kind: subagent
version: 1.0.0
owner: plattform
scope: public
description: >
Prüft Änderungen auf Fehler und Verständlichkeit. Nach größeren Änderungen
einsetzen, bevor ein Pull Request entsteht.
capability: read
model: thorough
extra:
claude:
effort: high
opencode:
temperature: 0.1

View file

@ -0,0 +1,4 @@
# Hausregeln
- Schreibe keine Geheimnisse (Keys, Passwörter) in Dateien oder Commits.
- Frage nach, bevor du etwas löschst, das du nicht selbst angelegt hast.

View file

@ -0,0 +1,6 @@
id: pub-hausregeln
kind: rules
version: 1.0.0
owner: plattform
scope: public
description: Grundregeln für alle Agenten im Haus.

View file

@ -0,0 +1,11 @@
id: pub-munin-anbindung
kind: mcp
version: 1.0.0
owner: plattform
scope: public
description: Bindet Munin als MCP-Server an.
server:
name: munin
url: https://munin.intern/mcp
headers:
Authorization: "Bearer {{env:MUNIN_KEY}}"

View file

@ -0,0 +1,10 @@
# Munin nutzen
Munin ist das gemeinsame Gedächtnis der Teams.
1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach
bekannten Besonderheiten des Projekts.
2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest.
3. Mit `related` findest du verwandte Einträge.
Beispiele stehen in [referenz/beispiele.md](referenz/beispiele.md).

View file

@ -0,0 +1,11 @@
id: pub-munin-nutzen
kind: skill
version: 1.4.0
owner: plattform
scope: public
description: >
Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen
remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.
license: intern
changes: >
Hinweis auf das neue Werkzeug „related“ ergänzt.

View file

@ -0,0 +1,4 @@
# Beispiele
- Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“
- Schlecht: „Ich habe heute die Tests laufen lassen.“

View file

@ -0,0 +1,11 @@
# Munin nutzen
Munin ist das gemeinsame Gedächtnis der Teams.
1. Bevor du mit einer neuen Aufgabe beginnst, frage Munin mit `recall` nach
bekannten Besonderheiten des Projekts.
2. Wenn du etwas Neues und Dauerhaftes herausfindest, halte es mit `remember` fest.
3. Mit `related` findest du verwandte Einträge.
Gut: „Die Staging-DB wird jeden Sonntag zurückgesetzt.“
Schlecht: „Ich habe heute die Tests laufen lassen.“

View file

@ -0,0 +1,11 @@
id: pub-munin-nutzen
kind: skill
version: 1.5.0
owner: plattform
scope: public
description: >
Wann und wie du Munin nutzt: vor neuer Arbeit recall, nach Erkenntnissen
remember. Nutze diesen Skill immer, wenn du in einem Projekt mit Munin arbeitest.
license: intern
changes: >
Beispiele direkt in den Skill übernommen; referenz/ entfällt.

View file

@ -0,0 +1,4 @@
Schreibe eine Release-Notiz für Version {{version}}, gedacht für {{zielgruppe}}.
Lies dazu die Commits seit dem letzten Tag.
Weitere Wünsche: $ARGUMENTS

View file

@ -0,0 +1,7 @@
id: pub-release-notiz
kind: command
version: 1.0.0
owner: plattform
scope: public
description: Schreibt eine Release-Notiz für eine Version.
arguments: [version, zielgruppe]

View file

@ -0,0 +1,9 @@
id: pub-specht-plugin
kind: plugin
version: 1.0.0
owner: kern
scope: public
targets: [opencode]
description: >
OpenCode-Plugin mit dem Werkzeug specht_marke, das eine feste Erkennungsmarke
liefert. Beispiel für ein Plugin, das Hugin verteilt.

View file

@ -0,0 +1,17 @@
import { tool } from "@opencode-ai/plugin"
import { marke } from "./lib/marke.ts"
// OpenCode ruft jede exportierte Funktion beim Start auf
export const SpechtPlugin = async () => {
return {
tool: {
specht_marke: tool({
description: "Liefert die Erkennungsmarke des Specht-Plugins",
args: {},
async execute() {
return marke
},
}),
},
}
}

View file

@ -0,0 +1 @@
export const marke = "SPECHT-5127"

View file

@ -0,0 +1,14 @@
id: pub-zeit-lokal
kind: mcp
version: 1.0.0
owner: kern
scope: public
description: >
Lokaler MCP-Server mit dem Werkzeug zeit, das Datum und Uhrzeit des Rechners
liefert. Beispiel für einen Server, den Hugin samt Code verteilt.
server:
name: zeit
local:
runtime: python
entry: zeit_server
env: [ZEIT_ZONE_HINWEIS]

View file

@ -0,0 +1,52 @@
"""Minimaler MCP-Server über stdio, nur mit der Standardbibliothek.
Ein JSON-Objekt je Zeile. Ausgabe als Bytes, damit Windows kein \r einfügt.
"""
import json
import os
import sys
from datetime import datetime
TOOL = {
"name": "zeit",
"description": "Liefert Datum und Uhrzeit dieses Rechners.",
"inputSchema": {"type": "object", "properties": {}},
}
def answer(message):
method = message.get("method")
if method == "initialize":
return {"protocolVersion": message["params"]["protocolVersion"],
"capabilities": {"tools": {}}, "serverInfo": {"name": "zeit", "version": "1.0.0"}}
if method == "tools/list":
return {"tools": [TOOL]}
if method == "tools/call":
hint = os.environ.get("ZEIT_ZONE_HINWEIS", "")
text = f"{datetime.now().astimezone():%d.%m.%Y %H:%M:%S %Z} UHR-4711" + (f" ({hint})" if hint else "")
return {"content": [{"type": "text", "text": text}]}
if method == "ping":
return {}
return None
def main():
for line in sys.stdin:
if not line.strip():
continue
message = json.loads(line)
if "id" not in message:
continue # Benachrichtigungen brauchen keine Antwort
result = answer(message)
if result is None:
reply = {"jsonrpc": "2.0", "id": message["id"],
"error": {"code": -32601, "message": f"unbekannt: {message.get('method')}"}}
else:
reply = {"jsonrpc": "2.0", "id": message["id"], "result": result}
sys.stdout.buffer.write(json.dumps(reply).encode("utf-8") + b"\n")
sys.stdout.buffer.flush()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,29 @@
# Szenarien für goldene Bundles. Server erzeugt sie (pytest --update),
# CLI wendet sie in seinen Tests an. Pfade relativ zu packages/.
- name: first-install
tools: [claude, opencode@1]
packages: [pub-munin-nutzen/1.4.0, pub-hausregeln/1.0.0, pub-munin-anbindung/1.0.0]
mandatory: [pub-munin-anbindung]
- name: update
tools: [claude, opencode@1]
packages: [pub-munin-nutzen/1.5.0, pub-munin-anbindung/1.0.0]
mandatory: [pub-munin-anbindung]
installed: [pub-munin-nutzen/1.4.0, pub-hausregeln/1.0.0, pub-munin-anbindung/1.0.0]
- name: claude-only
tools: [claude]
packages: [pub-munin-nutzen/1.4.0, pub-hausregeln/1.0.0]
- name: commands-and-agents
tools: [claude, opencode@1]
packages: [pub-release-notiz/1.0.0, pub-code-review/1.0.0]
- name: local-mcp
tools: [claude, opencode@1]
packages: [pub-zeit-lokal/1.0.0]
# Plugin nur für OpenCode: Claude bekommt nichts
- name: opencode-plugin
tools: [claude, opencode@1]
packages: [pub-specht-plugin/1.0.0]

View file

@ -0,0 +1,473 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://hugin.intern/schema/hugin-bundle-1.json",
"title": "hugin-bundle.json",
"description": "Installationsanleitung in einem Hugin-Bundle (ZIP). Beschreibt den vollständigen Soll-Zustand der angefragten Pakete im Repo. Clients wenden die Operationen der Reihe nach an.",
"type": "object",
"required": [
"format",
"generated",
"tools",
"packages",
"operations",
"hints"
],
"additionalProperties": false,
"properties": {
"format": {
"const": 1
},
"generated": {
"type": "string",
"format": "date-time"
},
"tools": {
"description": "Für welche Werkzeuge übersetzt wurde, mit Hauptversion.",
"type": "array",
"minItems": 1,
"items": {
"enum": [
"claude",
"opencode@1",
"opencode@2"
]
},
"uniqueItems": true
},
"packages": {
"type": "array",
"items": {
"$ref": "#/$defs/package"
}
},
"operations": {
"type": "array",
"items": {
"oneOf": [
{
"$ref": "#/$defs/op_file"
},
{
"$ref": "#/$defs/op_block"
},
{
"$ref": "#/$defs/op_json"
},
{
"$ref": "#/$defs/op_remove"
},
{
"$ref": "#/$defs/op_json_remove"
},
{
"$ref": "#/$defs/op_install"
}
]
}
},
"hints": {
"type": "array",
"items": {
"$ref": "#/$defs/hint"
}
}
},
"$defs": {
"package_id": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"minLength": 1,
"maxLength": 64
},
"checksum": {
"type": "string",
"pattern": "^sha256:[0-9a-f]{64}$"
},
"repo_path": {
"description": "Pfad relativ zur Repo-Wurzel, mit /, ohne führenden /. Segmente '.' und '..' sind verboten; Clients prüfen das zusätzlich selbst (das Muster kommt ohne Lookahead aus, damit es auch mit RE2 funktioniert).",
"type": "string",
"minLength": 1,
"pattern": "^[^/\\\\:*?\"<>|][^\\\\:*?\"<>|]*$"
},
"zip_path": {
"description": "Pfad einer Datei im ZIP, immer unter files/. Segmente '..' sind verboten.",
"type": "string",
"pattern": "^files/[^\\\\]+$"
},
"json_path": {
"description": "Schlüsselpfad in einer JSON-Datei, als Liste von Schlüsseln (Punkte in Namen sind erlaubt).",
"type": "array",
"minItems": 1,
"items": {
"type": "string",
"minLength": 1
}
},
"package": {
"type": "object",
"required": [
"id",
"version",
"checksum",
"kind",
"risk",
"compatibility",
"mandatory"
],
"additionalProperties": false,
"properties": {
"id": {
"$ref": "#/$defs/package_id"
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"checksum": {
"$ref": "#/$defs/checksum"
},
"kind": {
"enum": [
"skill",
"rules",
"command",
"subagent",
"mcp",
"plugin",
"hook"
]
},
"risk": {
"enum": [
"text",
"tools",
"scripts",
"permissions"
]
},
"compatibility": {
"enum": [
"portable",
"translated",
"claude-only",
"opencode-only"
]
},
"mandatory": {
"type": "boolean"
}
}
},
"op_file": {
"description": "Datei anlegen oder ersetzen. Inhalt liegt im ZIP unter quelle.",
"type": "object",
"required": [
"op",
"package",
"path",
"source",
"sha256"
],
"additionalProperties": false,
"properties": {
"op": {
"const": "file"
},
"package": {
"$ref": "#/$defs/package_id"
},
"path": {
"$ref": "#/$defs/repo_path"
},
"source": {
"$ref": "#/$defs/zip_path"
},
"sha256": {
"$ref": "#/$defs/checksum"
}
}
},
"op_block": {
"description": "Verwalteten Block in einer Textdatei setzen. Der Block steht zwischen den Zeilen '<!-- {block}:anfang' … und '<!-- {block}:ende -->'. Leerer inhalt entfernt den Block. Fehlt die Datei, wird sie aus anlegen erzeugt (falls gesetzt), sonst nur mit dem Block.",
"type": "object",
"required": [
"op",
"file",
"block",
"content"
],
"additionalProperties": false,
"properties": {
"op": {
"const": "block"
},
"file": {
"$ref": "#/$defs/repo_path"
},
"block": {
"type": "string",
"pattern": "^[a-z][a-z0-9-]*$"
},
"content": {
"type": "string"
},
"create": {
"$ref": "#/$defs/zip_path"
}
}
},
"op_json": {
"description": "Schlüssel in einer JSON- oder JSONC-Datei setzen. modus 'setzen' ersetzt den Wert; 'listeneintrag' stellt sicher, dass wert in der Liste unter schluessel vorkommt. Andere Inhalte, Reihenfolge und Kommentare bleiben erhalten. Fehlt die Datei, wird sie aus anlegen erzeugt (falls gesetzt), sonst als leeres Objekt.",
"type": "object",
"required": [
"op",
"file",
"key",
"value"
],
"additionalProperties": false,
"properties": {
"op": {
"const": "json"
},
"package": {
"$ref": "#/$defs/package_id",
"description": "Fehlt bei gemeinsamen Einträgen, die Hugin für mehrere Pakete pflegt (z. B. instructions in opencode.json)."
},
"file": {
"$ref": "#/$defs/repo_path"
},
"key": {
"$ref": "#/$defs/json_path"
},
"mode": {
"enum": [
"set",
"list_item"
],
"default": "setzen"
},
"value": {},
"create": {
"$ref": "#/$defs/zip_path"
}
}
},
"op_remove": {
"description": "Datei oder Ordner entfernen, den eine frühere Version angelegt hat. Fehlt das Ziel, ist nichts zu tun.",
"type": "object",
"required": [
"op",
"package",
"path"
],
"additionalProperties": false,
"properties": {
"op": {
"const": "remove"
},
"package": {
"$ref": "#/$defs/package_id"
},
"path": {
"$ref": "#/$defs/repo_path"
}
}
},
"op_json_remove": {
"description": "Schlüssel entfernen ('setzen') bzw. Eintrag aus der Liste entfernen ('listeneintrag').",
"type": "object",
"required": [
"op",
"file",
"key"
],
"additionalProperties": false,
"properties": {
"op": {
"const": "json_remove"
},
"package": {
"$ref": "#/$defs/package_id",
"description": "Fehlt bei gemeinsamen Einträgen, die Hugin für mehrere Pakete pflegt (z. B. instructions in opencode.json)."
},
"file": {
"$ref": "#/$defs/repo_path"
},
"key": {
"$ref": "#/$defs/json_path"
},
"mode": {
"enum": [
"set",
"list_item"
],
"default": "setzen"
},
"value": {}
}
},
"hint": {
"type": "object",
"required": [
"kind"
],
"oneOf": [
{
"properties": {
"kind": {
"const": "env_variable"
},
"name": {
"type": "string",
"pattern": "^[A-Z_][A-Z0-9_]*$"
},
"package": {
"$ref": "#/$defs/package_id"
}
},
"required": [
"kind",
"name",
"package"
],
"additionalProperties": false
},
{
"properties": {
"kind": {
"const": "min_version"
},
"tool": {
"enum": [
"claude",
"opencode"
]
},
"version": {
"type": "string"
},
"package": {
"$ref": "#/$defs/package_id"
}
},
"required": [
"kind",
"tool",
"version"
],
"additionalProperties": false
},
{
"properties": {
"kind": {
"const": "deprecated"
},
"package": {
"$ref": "#/$defs/package_id"
},
"successor": {
"$ref": "#/$defs/package_id"
}
},
"required": [
"kind",
"package"
],
"additionalProperties": false
},
{
"properties": {
"kind": {
"const": "major_available"
},
"package": {
"$ref": "#/$defs/package_id"
},
"version": {
"type": "string"
}
},
"required": [
"kind",
"package",
"version"
],
"additionalProperties": false
}
]
},
"local_path": {
"description": "Pfad einer Datei im ZIP für lokale Server, immer unter local/<package>/<version>/. Segmente '..' sind verboten.",
"type": "string",
"pattern": "^local/[^\\\\]+$"
},
"op_install": {
"description": "Lokalen MCP-Server außerhalb des Repos installieren (→ KONZEPT.md 20, Stufe 5b). Ablage: <HUGIN_MCP_HOME oder ~/.local/share/mcp>/<package>/<version>/. Gestartet wird mit hugin mcp run <package>; Clients ohne diesen Befehl überspringen die Operation mit einer Warnung.",
"type": "object",
"required": [
"op",
"package",
"version",
"checksum",
"runtime",
"entry",
"files"
],
"additionalProperties": false,
"properties": {
"op": {
"const": "install"
},
"package": {
"$ref": "#/$defs/package_id"
},
"version": {
"type": "string",
"pattern": "^\\d+\\.\\d+\\.\\d+$"
},
"checksum": {
"$ref": "#/$defs/checksum"
},
"runtime": {
"enum": [
"python"
]
},
"entry": {
"description": "python: Modul (python -m) oder Skript im Paket (.py)",
"type": "string",
"minLength": 1
},
"files": {
"type": "array",
"items": {
"type": "object",
"required": [
"path",
"source",
"sha256"
],
"additionalProperties": false,
"properties": {
"path": {
"$ref": "#/$defs/repo_path"
},
"source": {
"$ref": "#/$defs/local_path"
},
"sha256": {
"$ref": "#/$defs/checksum"
}
}
}
},
"python": {
"description": "kleinste nötige Python-Version, z. B. 3.10",
"type": "string",
"pattern": "^3\\.\\d{1,2}$"
}
}
}
}
}

35
docker-compose.yml Normal file
View file

@ -0,0 +1,35 @@
# PostgreSQL zum Entwickeln und Testen, dauerhaft (Daten im Volume hugin-postgres).
#
# docker compose up -d # starten
# docker compose down # anhalten, Daten bleiben
# docker compose down -v # anhalten und Daten löschen
#
# Datenbanken: hugin (für den Server, z. B. scripts/demo.sh) und die Verwaltungs-
# datenbank postgres (für die Tests; jeder Test legt dort eine eigene an und
# räumt sie wieder ab). Zugang nur von diesem Rechner (127.0.0.1:55432).
#
# HUGIN_DATABASE_URL=postgresql+psycopg://hugin:hugin@127.0.0.1:55432/hugin scripts/demo.sh
# scripts/postgres-test.sh --compose
name: hugin
services:
postgres:
image: postgres:17.6
environment:
POSTGRES_USER: hugin
POSTGRES_PASSWORD: hugin
POSTGRES_DB: hugin
ports:
- "127.0.0.1:55432:5432"
volumes:
- hugin-postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U hugin -d hugin"]
interval: 2s
timeout: 3s
retries: 30
restart: unless-stopped
volumes:
hugin-postgres:

31
scripts/build-cli.sh Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Baut das CLI für alle unterstützten Systeme (→ KONZEPT.md 11, 18).
#
# scripts/build-cli.sh 0.2.0 # nach cli/dist/0.2.0/
# scripts/build-cli.sh 0.2.0 /srv/hugin-downloads # zusätzlich in den Download-Ordner (HUGIN_DOWNLOAD_DIR)
#
# Ergebnis: hugin-<os>-<arch>[.exe] und SHA256SUMS. Ohne CGO, also ohne
# Abhängigkeiten auf dem Zielrechner.
set -euo pipefail
VERSION="${1:?Version angeben, z. B. scripts/build-cli.sh 0.2.0}"
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "Version im Format x.y.z"; exit 2; }
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TARGET="$ROOT/cli/dist/$VERSION"
rm -rf "$TARGET" && mkdir -p "$TARGET"
for platform in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64 windows/amd64; do
os="${platform%/*}"; arch="${platform#*/}"
file="hugin-$os-$arch"; [[ "$os" == windows ]] && file+=".exe"
(cd "$ROOT/cli" && CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath -ldflags "-s -w -X main.Version=$VERSION" -o "$TARGET/$file" ./cmd/hugin)
echo " $file"
done
(cd "$TARGET" && shasum -a 256 hugin-* > SHA256SUMS)
echo "Gebaut: $TARGET"
if [[ -n "${2:-}" ]]; then
mkdir -p "$2/$VERSION"
cp "$TARGET"/* "$2/$VERSION/"
echo "Kopiert nach $2/$VERSION (für die Seite „Programm hugin“)"
fi

27
scripts/csp-test.sh Executable file
View file

@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Prüft das CSS-Framework unter der strengen CSP von Hugin (→ KONZEPT.md 16.4):
# zählt CSP-Verstöße der Demo und klickt alle data-action-Aktionen durch.
# Braucht Google Chrome (headless).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CH="${CHROME:-/Applications/Google Chrome.app/Contents/MacOS/Google Chrome}"
PORT="${PORT:-8813}"
PROFILE="$(mktemp -d)"
python3 -I "$ROOT/scripts/csp/server.py" "$ROOT/static/css-framework/${FOLDER:-.}" "$PORT" 1 "$ROOT/scripts/csp/actions.js" &
SERVER=$!
# Rückgabewert des Tests bewahren; wait auf den beendeten Server würde ihn überschreiben
trap 'rc=$?; kill $SERVER 2>/dev/null; wait $SERVER 2>/dev/null || true; rm -rf "$PROFILE"; exit $rc' EXIT
sleep 1
# Chrome endet nach dem Zeitbudget mit 143; bewertet wird nur die ausgegebene Seite
"$CH" --headless=new --disable-gpu --user-data-dir="$PROFILE" --virtual-time-budget=6000 \
--dump-dom "http://127.0.0.1:$PORT/index.html" >"$PROFILE/page.html" 2>/dev/null || true
python3 -I -c '
import html, re, sys
t = sys.stdin.read()
failed = 0
for part in ("actions", "csp-result"):
m = re.search(r"<pre id=\"" + part + r"\">(.*?)</pre>", t, re.S)
text = html.unescape(m.group(1)) if m else part + ": kein Ergebnis"
print(text)
failed += (not m) or ("FEHL" in text) or (part == "csp-result" and not text.startswith("VIOLATIONS 0"))
sys.exit(1 if failed else 0)' <"$PROFILE/page.html"

22
scripts/csp/actions.js Normal file
View file

@ -0,0 +1,22 @@
window.addEventListener('load', function () { setTimeout(function () {
var r = [];
function check(name, f) { try { r.push((f() ? 'OK ' : 'FEHLT') + ' ' + name); } catch (e) { r.push('FEHLER ' + name + ': ' + e); } }
function click(el) { el.dispatchEvent(new MouseEvent('click', { bubbles: true })); }
var q = function (s) { return document.querySelector(s); };
check('modal-open', function () { var b = q('[data-action="modal-open"]'); click(b); return q('#' + b.dataset.target).classList.contains('open') && document.body.classList.contains('modal-open'); });
check('modal-close', function () { var m = q('.modal-backdrop.open'); click(m.querySelector('[data-action="modal-close"]')); return !m.classList.contains('open') && !document.body.classList.contains('modal-open'); });
check('modal-backdrop', function () { var b = q('[data-action="modal-open"]'); click(b); var m = q('#' + b.dataset.target); click(m); return !m.classList.contains('open'); });
check('tab', function () { var t = document.querySelectorAll('[data-action="tab"]')[1]; click(t); return t.classList.contains('active') && q('#' + t.dataset.target).classList.contains('active'); });
check('dropdown', function () { var d = q('[data-action="dropdown"]'); click(d); return q('#' + d.dataset.target).classList.contains('open'); });
check('dropdown schließt bei Klick daneben', function () { click(document.body); return !q('.dropdown.open'); });
check('nav-dropdown', function () { var d = q('[data-action="nav-dropdown"]'); click(d); return d.closest('li').classList.contains('open'); });
check('toggle-class', function () { var e = q('[data-action="toggle-class"]'); var before = e.classList.contains(e.dataset.class); click(e); return e.classList.contains(e.dataset.class) !== before; });
check('sort', function () { var th = q('[data-action="sort"]'); click(th); return th.dataset.sort === 'asc'; });
check('toast + XSS-fest', function () { showToast('<img src=x>', 'success'); var t = q('.toast:last-child'); return t && t.querySelector('.toast-title').textContent === '<img src=x>' && !t.querySelector('img') && t.querySelector('.toast-icon-success'); });
check('demo-toast', function () { var n = document.querySelectorAll('.toast').length; click(q('[data-action="demo-toast"]')); return document.querySelectorAll('.toast').length === n + 1; });
check('toast-close', function () { var t = q('.toast'); click(t.querySelector('[data-action="toast-close"]')); return t.classList.contains('is-leaving'); });
check('theme-toggle', function () { var before = document.documentElement.dataset.theme; click(q('[data-action="theme-toggle"]')); return document.documentElement.dataset.theme !== before && getComputedStyle(q('#icon-sun')).display !== getComputedStyle(q('#icon-moon')).display; });
check('alert-dismiss', function () { var a = q('[data-action="alert-dismiss"]'); click(a); return a.closest('.alert').classList.contains('is-dismissing'); });
check('data-progress', function () { var b = q('[data-progress="80"]'); return b.style.width === '80%'; });
var m = document.createElement('pre'); m.id = 'actions'; m.textContent = r.join('\n'); document.body.appendChild(m);
}, 300); });

28
scripts/csp/server.py Normal file
View file

@ -0,0 +1,28 @@
# Testserver für scripts/csp-test.sh: liefert Dateien mit strenger CSP aus und
# schiebt Prüfskripte ein (Zählen von CSP-Verstößen, Klicktest der Aktionen).
import http.server, sys, functools, os
CSP = ("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; "
"connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'")
TRIAL = b"""window.__csp=[];document.addEventListener('securitypolicyviolation',function(e){
window.__csp.push(e.violatedDirective+' '+(e.sample||'').slice(0,40)+' @'+e.lineNumber);});
window.addEventListener('load',function(){setTimeout(function(){var m=document.createElement('pre');
m.id='csp-result';m.textContent='VIOLATIONS '+window.__csp.length+'\\n'+window.__csp.join('\\n');
document.body.appendChild(m);},500);});"""
class H(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
if self.server.csp: self.send_header("Content-Security-Policy", CSP)
super().end_headers()
def do_GET(self):
if self.path == "/__actions.js":
self.send_response(200); self.send_header("Content-Type","text/javascript"); self.end_headers(); self.wfile.write(open(sys.argv[4],"rb").read()); return
if self.path == "/__probe.js":
self.send_response(200); self.send_header("Content-Type","text/javascript"); self.end_headers(); self.wfile.write(TRIAL); return
if self.path.endswith(".html"):
raw = open(os.path.join(self.server.folder, self.path.lstrip("/")), "rb").read()
raw = raw.replace(b"<head>", b'<head><script src="/__probe.js"></script>', 1).replace(b"</body>", b'<script src="/__actions.js"></script></body>', 1)
self.send_response(200); self.send_header("Content-Type","text/html; charset=utf-8"); self.end_headers(); self.wfile.write(raw); return
super().do_GET()
def log_message(self, *a): pass
folder, port, csp = sys.argv[1], int(sys.argv[2]), sys.argv[3] == "1"
s = http.server.ThreadingHTTPServer(("127.0.0.1", port), functools.partial(H, directory=folder)); s.csp = csp; s.folder = folder
s.serve_forever()

203
scripts/csp/web_actions.py Normal file
View file

@ -0,0 +1,203 @@
"""Weboberfläche im echten Chrome bedienen: Live-Suche, Tabs, Kopieren, Rückfragen, Formulare.
Ergänzt web_browser.py (CSP und Bildschirmfotos) um das, was nur mit JavaScript im Browser
prüfbar ist: htmx-Trigger, hugin.js und die Aktionen des Frameworks auf den echten Seiten.
uv run --project server --with websockets --with httpx python scripts/csp/web_actions.py
"""
import asyncio
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
import httpx
import websockets
sys.path.insert(0, str(Path(__file__).parent))
from web_browser import ADMIN, CHROME, ROOT, cdp, cookie_for, create_data, free_port # noqa: E402
async def js(ws, expression):
result = await cdp(ws, "Runtime.evaluate", expression=expression, returnByValue=True, awaitPromise=True)
return result["result"].get("value")
async def press(ws, key, code, key_code):
for kind in ("keyDown", "keyUp"):
await cdp(ws, "Input.dispatchKeyEvent", type=kind, key=key, code=code, windowsVirtualKeyCode=key_code)
async def run(port_chrome: int, url: str, cookies: dict[str, str]) -> bool:
target = httpx.put(f"http://127.0.0.1:{port_chrome}/json/new?about:blank").json()
results = []
def check(name, ok):
results.append(bool(ok))
print(("OK " if ok else "FEHLT ") + name)
async with websockets.connect(target["webSocketDebuggerUrl"], max_size=50_000_000) as ws:
await cdp(ws, "Page.enable")
await cdp(ws, "Network.enable")
async def go(path, who):
await cdp(ws, "Network.clearBrowserCookies")
await cdp(ws, "Network.setCookie", name="hugin_session", value=cookies[who], url=url)
await cdp(ws, "Page.navigate", url=url + path)
await asyncio.sleep(1.2)
# Topbar: Abmelden bleibt sichtbar, auch unten auf langen Seiten und auf dem Handy
logout_visible = ("(function () { var b = [...document.querySelectorAll('button')]"
".find(b => b.textContent.trim() == 'Abmelden'); var r = b.getBoundingClientRect();"
"return r.width > 0 && r.top >= 0 && r.bottom <= innerHeight && r.left >= 0"
" && r.right <= document.documentElement.clientWidth; })()")
await cdp(ws, "Emulation.setDeviceMetricsOverride", width=1280, height=800, deviceScaleFactor=1, mobile=False)
await go("/manage?tenant=shop", "Admin")
await js(ws, "scrollTo(0, document.body.scrollHeight)")
await asyncio.sleep(0.3)
check("Topbar: Abmelden unten auf langer Seite sichtbar", await js(ws, logout_visible))
await cdp(ws, "Emulation.setDeviceMetricsOverride", width=390, height=844, deviceScaleFactor=1, mobile=True)
await go("/packages/pub-munin-nutzen/versions/1.4.0", "Shop CI")
check("Topbar: auf dem Handy nichts abgeschnitten", await js(ws, logout_visible))
await js(ws, "document.querySelector('[data-action=sidebar-open]').click()")
await asyncio.sleep(0.5)
check("Topbar: Menü öffnet die Sidebar",
await js(ws, "document.getElementById('sidebar').getBoundingClientRect().left >= 0"))
await cdp(ws, "Emulation.clearDeviceMetricsOverride")
# Katalog: Live-Suche über htmx
await go("/catalog", "Shop CI")
await js(ws, "var i = document.getElementById('search'); i.value = 'haus';"
"i.dispatchEvent(new Event('input', {bubbles: true}))")
await asyncio.sleep(1.2)
text = await js(ws, "document.getElementById('catalog-list').textContent")
check("Katalog: Live-Suche", "pub-hausregeln" in text and "pub-munin-nutzen" not in text)
check("Katalog: Suche steht in der URL", "q=haus" in await js(ws, "location.href"))
# Versionsseite: zurück zum ersten Tab
await go("/packages/pub-munin-nutzen/versions/1.4.0", "Shop CI")
await js(ws, "var t = document.querySelectorAll('[data-action=tab]'); t[1].click(); t[0].click()")
check("Version: Tab zurück zu Inhalt",
await js(ws, "document.getElementById('tab-content').classList.contains('active')"))
await js(ws, "document.getElementById('tabbtn-content').focus()")
await press(ws, "ArrowRight", "ArrowRight", 39)
check("Version: Pfeiltaste wechselt Tab samt aria-selected",
await js(ws, "document.activeElement.id == 'tabbtn-diff' && "
"document.getElementById('tabbtn-diff').getAttribute('aria-selected') == 'true' && "
"document.getElementById('tabbtn-content').getAttribute('aria-selected') == 'false' && "
"document.getElementById('tab-diff').classList.contains('active')"))
await press(ws, "Home", "Home", 36)
check("Version: Pos1 springt zum ersten Tab",
await js(ws, "document.getElementById('tab-content').classList.contains('active')"))
# Paketseite: Kopieren, erst ohne Fokus (kein Zugriff auf die Zwischenablage), dann mit
await go("/packages/pub-munin-nutzen", "Shop CI")
await js(ws, "document.querySelector('[data-copy=subscribe]').click()")
await asyncio.sleep(0.6)
check("Kopieren ohne Zugriff: Text markiert, Hinweis auf Tastenkürzel",
"Markiert" in await js(ws, "document.getElementById('toast-container').textContent")
and "pub-munin-nutzen" in await js(ws, "getSelection().toString()"))
await cdp(ws, "Emulation.setFocusEmulationEnabled", enabled=True)
await cdp(ws, "Browser.grantPermissions", origin=url,
permissions=["clipboardReadWrite", "clipboardSanitizedWrite"])
await js(ws, "document.querySelector('[data-copy=subscribe]').click()")
await asyncio.sleep(0.6)
check("Kopieren: Toast", "Kopiert" in await js(ws, "document.getElementById('toast-container').textContent"))
check("Kopieren: Abo in der Zwischenablage",
await js(ws, "navigator.clipboard.readText()") == "packages:\n - pub-munin-nutzen")
# Verwaltung: Rückfrage und Felder je Key-Art
await go("/manage?tenant=shop", "Admin")
await js(ws, "window.__asked = []; window.confirm = function (q) { __asked.push(q); return false; }")
await js(ws, "[...document.querySelectorAll('button')].find(b => b.textContent.trim() == 'Widerrufen').click()")
await asyncio.sleep(0.8)
asked = await js(ws, "JSON.stringify(window.__asked || null)")
check("Verwaltung: Rückfrage vor Widerrufen, Abbrechen bleibt auf der Seite",
asked and "widerrufen?" in asked and await js(ws, "Array.isArray(window.__asked)"))
def fields():
return js(ws, "JSON.stringify(['key-project', 'key-projects'].map(id => {"
"var f = document.getElementById(id); return [f.disabled, f.closest('[data-when]').hidden]; }))")
check("Key-Art Projekt: nur Feld Projekt", await fields() == "[[false,false],[true,true]]")
await js(ws, "var s = document.getElementById('key-kind'); s.value = 'integration';"
"s.dispatchEvent(new Event('change', {bubbles: true}))")
check("Key-Art Integration: nur Projektauswahl", await fields() == "[[true,true],[false,false]]")
await js(ws, "var s = document.getElementById('key-kind'); s.value = 'tenant';"
"s.dispatchEvent(new Event('change', {bubbles: true}))")
check("Key-Art Tenant: keins von beiden", await fields() == "[[true,true],[true,true]]")
check("Ausgeblendetes Feld ist wirklich unsichtbar",
await js(ws, "document.getElementById('key-project').offsetParent === null"))
# Audit: Live-Filter über htmx
await go("/audit", "Admin")
await js(ws, "var s = document.getElementById('action'); s.value = 'key.';"
"s.dispatchEvent(new Event('change', {bubbles: true}))")
await asyncio.sleep(1.2)
actions = await js(ws, "[...document.querySelectorAll('#audit-list tbody tr td:nth-child(4) code')]"
".map(c => c.textContent)")
check("Audit: Live-Filter", actions and all(x.startswith("key.") for x in actions))
# Prüfung: Zurückgeben verlangt einen Kommentar, Freigeben nicht
await go("/packages/pub-munin-nutzen/versions/1.5.0", "Prüfer")
await js(ws, "document.querySelector('[data-target=decide]').click()")
await js(ws, "document.querySelector('button[value=return]').click()")
await asyncio.sleep(0.8)
check("Prüfung: Zurückgeben ohne Kommentar bleibt im Dialog",
await js(ws, "location.pathname.endsWith('/versions/1.5.0') && "
"document.getElementById('comment').validity.valueMissing"))
await js(ws, "document.querySelector('button[value=approve]').click()")
await asyncio.sleep(1.2)
check("Prüfung: Freigeben ohne Kommentar geht", await js(ws, "location.pathname") == "/reviews")
# Editor zuletzt: Nach der Änderung hält die Warnung beim Verlassen jede weitere Navigation auf
# Editor: Tab rückt ein, Esc + Tab verlässt das Feld, Warnung bei ungespeicherten Änderungen
await go("/packages/pub-munin-nutzen/versions/1.4.0/edit", "Autorin")
check("Editor: ohne Änderung keine Warnung beim Verlassen",
not await js(ws, "var e = new Event('beforeunload', {cancelable: true}); dispatchEvent(e); e.defaultPrevented"))
await js(ws, "var t = document.querySelector('textarea[name=manifest]'); t.focus(); t.setSelectionRange(0, 0)")
await press(ws, "Tab", "Tab", 9)
check("Editor: Tab rückt ein",
await js(ws, "document.querySelector('textarea[name=manifest]').value.startsWith(' id:')"))
check("Editor: nach Änderung Warnung beim Verlassen",
await js(ws, "var e = new Event('beforeunload', {cancelable: true}); dispatchEvent(e); e.defaultPrevented"))
await press(ws, "Escape", "Escape", 27)
await press(ws, "Tab", "Tab", 9)
check("Editor: Esc und Tab springt weiter",
await js(ws, "document.activeElement.name") != "manifest")
return all(results)
def main() -> None:
work = Path(tempfile.mkdtemp())
port, port_chrome = free_port(), free_port()
url = f"http://127.0.0.1:{port}"
env = os.environ | {"HUGIN_DATABASE_URL": f"sqlite:///{work}/hugin.db", "HUGIN_ADMIN_TOKEN": ADMIN}
server_folder = ROOT / "server"
subprocess.run(["uv", "run", "-q", "hugin-server", "migrate"], cwd=server_folder, env=env, check=True)
server = subprocess.Popen(["uv", "run", "-q", "hugin-server", "run", "--host", "127.0.0.1", "--port", str(port)],
cwd=server_folder, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
chrome = subprocess.Popen([CHROME, "--headless=new", "--disable-gpu", f"--remote-debugging-port={port_chrome}",
f"--user-data-dir={work}/chrome", "about:blank"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(100):
try:
httpx.get(f"{url}/readyz").raise_for_status()
httpx.get(f"http://127.0.0.1:{port_chrome}/json/version").raise_for_status()
break
except httpx.HTTPError:
time.sleep(0.2)
keys = create_data(url)
cookies = {name: cookie_for(url, key) for name, key in keys.items()}
sys.exit(0 if asyncio.run(run(port_chrome, url, cookies)) else 1)
finally:
server.terminate()
chrome.terminate()
if __name__ == "__main__":
main()

200
scripts/csp/web_browser.py Normal file
View file

@ -0,0 +1,200 @@
"""Weboberfläche im echten Chrome prüfen: CSP-Verstöße zählen, Bildschirmfotos machen.
Startet einen Hugin-Server mit Testdaten, meldet sich mit verschiedenen Keys an
und ruft jede Seite über das DevTools-Protokoll auf. Ein vor dem Laden
eingefügtes Skript (CDP, nicht von der CSP betroffen) zählt Verstöße.
uv run --with websockets --with httpx python scripts/csp/web_browser.py <ausgabeordner>
"""
import asyncio
import base64
import io
import json
import os
import socket
import subprocess
import sys
import tempfile
import time
import zipfile
from pathlib import Path
import httpx
import websockets
ROOT = Path(__file__).resolve().parents[2]
CHROME = os.environ.get("CHROME", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
ADMIN = "web-test-admin"
COUNTER = """window.__csp = []; document.addEventListener('securitypolicyviolation',
e => window.__csp.push(e.violatedDirective + ' ' + (e.sample || '').slice(0, 40)));"""
def free_port() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def zip_folder(folder: Path) -> bytes:
buffer = io.BytesIO()
with zipfile.ZipFile(buffer, "w") as z:
for d in sorted(folder.rglob("*")):
if d.is_file():
z.write(d, d.relative_to(folder).as_posix())
return buffer.getvalue()
def create_data(url: str) -> dict[str, str]:
a = {"Authorization": f"Bearer {ADMIN}"}
c = httpx.Client(base_url=url)
keys = {}
for tenant, project in [("plattform", "kern"), ("shop", "api")]:
c.post("/api/v1/admin/tenants", headers=a, json={"slug": tenant, "name": tenant.title()}).raise_for_status()
c.post(f"/api/v1/admin/tenants/{tenant}/projects", headers=a, json={"slug": project, "name": project})
for name, tenant, project, roles in [("Autorin", "plattform", "kern", ["pub_author"]),
("Prüfer", "plattform", "kern", ["pub_reviewer"]),
("Shop CI", "shop", "api", [])]:
response = c.post(f"/api/v1/admin/tenants/{tenant}/keys", headers=a,
json={"kind": "project", "name": name, "project": project, "roles": roles})
keys[name] = response.json()["key"]
packages = ROOT / "contract/examples/packages"
for name in ["pub-munin-nutzen/1.4.0", "pub-hausregeln/1.0.0", "pub-munin-anbindung/1.0.0",
"pub-munin-nutzen/1.5.0"]:
c.post("/api/v1/drafts", headers={"Authorization": f"Bearer {keys['Autorin']}",
"Content-Type": "application/zip"},
content=zip_folder(packages / name)).raise_for_status()
package, number = name.split("/")
base = f"/api/v1/packages/{package}/versions/{number}"
c.post(f"{base}/submit", headers={"Authorization": f"Bearer {keys['Autorin']}"}).raise_for_status()
if name != "pub-munin-nutzen/1.5.0": # 1.5.0 bleibt in Prüfung
c.post(f"{base}/approve", headers={"Authorization": f"Bearer {keys['Prüfer']}"},
json={"comment": "passt"}).raise_for_status()
c.post("/api/v1/usage", headers={"Authorization": f"Bearer {keys['Shop CI']}"},
json={"repo": "git.intern/shop/api", "tools": {"claude": "2.1.302"},
"installed": {"pub-munin-nutzen": "1.4.0", "pub-hausregeln": "1.0.0"}})
keys["Admin"] = ADMIN
return keys
def cookie_for(url: str, key: str) -> str:
response = httpx.post(f"{url}/login", data={"key": key, "next": ""})
return response.cookies["hugin_session"]
async def cdp(ws, method, **params):
cdp.n += 1
await ws.send(json.dumps({"id": cdp.n, "method": method, "params": params}))
while True:
response = json.loads(await ws.recv())
if response.get("id") == cdp.n:
return response.get("result", {})
cdp.n = 0
async def validate_pages(port_chrome: int, url: str, pages, output: Path, dunkel: bool) -> int:
target = httpx.put(f"http://127.0.0.1:{port_chrome}/json/new?about:blank").json()
error = 0
async with websockets.connect(target["webSocketDebuggerUrl"], max_size=50_000_000) as ws:
await cdp(ws, "Page.enable")
await cdp(ws, "Network.enable")
await cdp(ws, "Page.addScriptToEvaluateOnNewDocument", source=COUNTER)
await cdp(ws, "Emulation.setDeviceMetricsOverride", width=1280, height=900, deviceScaleFactor=1, mobile=False)
if dunkel:
await cdp(ws, "Emulation.setEmulatedMedia", features=[{"name": "prefers-color-scheme", "value": "dark"}])
for name, path, cookie in pages:
await cdp(ws, "Network.clearBrowserCookies")
if cookie:
await cdp(ws, "Network.setCookie", name="hugin_session", value=cookie, url=url)
await cdp(ws, "Page.navigate", url=url + path)
await asyncio.sleep(1.2)
result = await cdp(ws, "Runtime.evaluate", expression="JSON.stringify(window.__csp)", returnByValue=True)
verstoesse = json.loads(result["result"]["value"])
error += len(verstoesse)
hoehe = (await cdp(ws, "Runtime.evaluate", expression="document.documentElement.scrollHeight",
returnByValue=True))["result"]["value"]
image = await cdp(ws, "Page.captureScreenshot", format="png", captureBeyondViewport=True,
clip={"x": 0, "y": 0, "width": 1280, "height": min(hoehe, 4000), "scale": 1})
file = output / f"{'dunkel-' if dunkel else ''}{name}.png"
file.write_bytes(base64.b64decode(image["data"]))
print(f"{'✓' if not verstoesse else '✗'} {name:<12} {path:<45} Verstöße: {len(verstoesse)} {verstoesse[:3]}")
return error
async def gegenprobe(port_chrome: int, url: str, cookie: str) -> bool:
"""Fügt ein style-Attribut ein; die Messung muss den Verstoß sehen."""
target = httpx.put(f"http://127.0.0.1:{port_chrome}/json/new?about:blank").json()
async with websockets.connect(target["webSocketDebuggerUrl"]) as ws:
await cdp(ws, "Page.enable")
await cdp(ws, "Network.enable")
await cdp(ws, "Page.addScriptToEvaluateOnNewDocument", source=COUNTER)
await cdp(ws, "Network.setCookie", name="hugin_session", value=cookie, url=url)
await cdp(ws, "Page.navigate", url=url + "/help")
await asyncio.sleep(1)
await cdp(ws, "Runtime.evaluate", expression="document.body.insertAdjacentHTML('beforeend', "
"'<div style=\\'color:red\\'>x</div>')")
await asyncio.sleep(0.5)
anzahl = (await cdp(ws, "Runtime.evaluate", expression="window.__csp.length", returnByValue=True))
return anzahl["result"]["value"] > 0
def main() -> None:
output = Path(sys.argv[1])
output.mkdir(parents=True, exist_ok=True)
arbeit = Path(tempfile.mkdtemp())
port, port_chrome = free_port(), free_port()
url = f"http://127.0.0.1:{port}"
env = os.environ | {"HUGIN_DATABASE_URL": f"sqlite:///{arbeit}/hugin.db", "HUGIN_ADMIN_TOKEN": ADMIN}
server_folder = ROOT / "server"
subprocess.run(["uv", "run", "-q", "hugin-server", "migrate"], cwd=server_folder, env=env, check=True)
server = subprocess.Popen(["uv", "run", "-q", "hugin-server", "run", "--host", "127.0.0.1", "--port", str(port)],
cwd=server_folder, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
chrome = subprocess.Popen([CHROME, "--headless=new", "--disable-gpu", f"--remote-debugging-port={port_chrome}",
f"--user-data-dir={arbeit}/chrome", "about:blank"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
for _ in range(100):
try:
httpx.get(f"{url}/readyz").raise_for_status()
httpx.get(f"http://127.0.0.1:{port_chrome}/json/version").raise_for_status()
break
except httpx.HTTPError:
time.sleep(0.2)
keys = create_data(url)
c = {name: cookie_for(url, key) for name, key in keys.items()}
pages = [
("login", "/login", None),
("catalog", "/catalog", c["Shop CI"]),
("package", "/packages/pub-munin-nutzen", c["Shop CI"]),
("version", "/packages/pub-munin-nutzen/versions/1.4.0", c["Shop CI"]),
("pruefung", "/packages/pub-munin-nutzen/versions/1.5.0", c["Prüfer"]),
("reviews", "/reviews", c["Prüfer"]),
("upload", "/upload", c["Autorin"]),
("project", "/project", c["Shop CI"]),
("manage", "/manage?tenant=shop", c["Admin"]),
("overview", "/overview", c["Shop CI"]),
("system", "/system", c["Admin"]),
("audit", "/audit", c["Admin"]),
("new", "/new?kind=skill&id=pub-neu", c["Autorin"]),
("editor", "/packages/pub-munin-nutzen/versions/1.4.0/edit", c["Autorin"]),
("download", "/download", c["Shop CI"]),
("help", "/help/package-format", c["Shop CI"]),
("hilfe-ohne", "/help/teams", None),
]
if not asyncio.run(gegenprobe(port_chrome, url, c["Shop CI"])):
print("Gegenprobe gescheitert: ein absichtlicher Verstoß wurde nicht gezählt")
sys.exit(2)
print("Gegenprobe ok: absichtlicher Verstoß wird gezählt")
error = asyncio.run(validate_pages(port_chrome, url, pages, output, dunkel=False))
error += asyncio.run(validate_pages(port_chrome, url, pages[1:4], output, dunkel=True))
print("Verstöße gesamt:", error)
sys.exit(1 if error else 0)
finally:
server.terminate()
chrome.terminate()
if __name__ == "__main__":
main()

107
scripts/demo.sh Executable file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Startet Hugin lokal mit Beispieldaten zum Ausprobieren.
#
# scripts/demo.sh # Server auf http://127.0.0.1:8080, Daten in .demo/
# scripts/demo.sh --neu # Beispieldaten verwerfen und neu anlegen
# HOST=0.0.0.0 scripts/demo.sh # auch von anderen Rechnern erreichbar (z. B. Windows-Test)
# HUGIN_DATABASE_URL=postgresql+psycopg://hugin:hugin@127.0.0.1:55432/hugin scripts/demo.sh
# # mit der PostgreSQL aus docker-compose.yml
#
# Beim ersten Start legt das Skript Tenants, Projekte, Personen und Keys an,
# veröffentlicht die Beispielpakete und schreibt alle Keys nach .demo/keys.txt.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
DEMO="$ROOT/.demo"
PORT="${PORT:-8080}"
URL="http://127.0.0.1:$PORT"
mkdir -p "$DEMO"
if [[ -n "${HUGIN_DATABASE_URL:-}" ]]; then
# Eigene Datenbank (z. B. PostgreSQL): die Keys gehören zu dieser Datenbank
KEYS="$DEMO/keys-$(printf '%s' "$HUGIN_DATABASE_URL" | shasum | cut -c1-8).txt"
if [[ "${1:-}" == "--neu" ]]; then
echo "--neu mit eigener Datenbank: Daten dort selbst löschen, z. B. docker compose down -v"; exit 2
fi
else
# Nur die SQLite-Demo verwerfen; Keys anderer Datenbanken (keys-*.txt) bleiben
[[ "${1:-}" == "--neu" ]] && rm -f "$DEMO"/hugin.db* "$DEMO/keys.txt" "$DEMO/server.log"
export HUGIN_DATABASE_URL="sqlite:///$DEMO/hugin.db"
KEYS="$DEMO/keys.txt"
fi
export HUGIN_ADMIN_TOKEN="${HUGIN_ADMIN_TOKEN:-demo-admin}"
ADMIN=(-H "Authorization: Bearer $HUGIN_ADMIN_TOKEN")
if ! (cd "$ROOT/server" && uv run -q hugin-server migrate 2>"$DEMO/migrate.log"); then
tail -1 "$DEMO/migrate.log"
echo "Migration gescheitert; die Datenbank stammt vermutlich von einem älteren Stand (ganzer Fehler: $DEMO/migrate.log)."
echo "Neu anlegen: bei SQLite 'scripts/demo.sh --neu',"
echo "bei PostgreSQL aus docker-compose.yml 'docker compose down -v && docker compose up -d'."
exit 1
fi
if [[ -f "$KEYS" ]]; then
echo "Beispieldaten vorhanden (.demo/). Keys: $KEYS"
else
(cd "$ROOT/server" && exec uv run -q hugin-server run --host "${HOST:-127.0.0.1}" --port "$PORT") >"$DEMO/server.log" 2>&1 &
SERVER=$!
trap 'kill $SERVER 2>/dev/null; wait $SERVER 2>/dev/null || true' EXIT
for _ in $(seq 60); do curl -fsS "$URL/readyz" >/dev/null 2>&1 && break; sleep 0.25; done
if curl -fsS "${ADMIN[@]}" "$URL/api/v1/admin/tenants" 2>/dev/null | grep -q '"plattform"'; then
echo "Die Datenbank enthält schon Demo-Daten, aber die Keys dazu fehlen ($KEYS)."
echo "Neu anlegen: bei PostgreSQL aus docker-compose.yml 'docker compose down -v && docker compose up -d',"
echo "bei SQLite 'scripts/demo.sh --neu'."
exit 1
fi
api() { curl -fsS -H "Content-Type: application/json" "$@"; }
field() { python3 -c "import json,sys; print(json.load(sys.stdin)['$1'])"; }
for t in plattform:kern shop:api shop:web; do
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d "{\"slug\":\"${t%%:*}\",\"name\":\"${t%%:*}\"}" >/dev/null 2>&1 || true
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/${t%%:*}/projects" -d "{\"slug\":\"${t##*:}\",\"name\":\"${t##*:}\"}" >/dev/null
done
person() { api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/$1/people" -d "$2" | field key; }
key() { api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/$1/keys" -d "$2" | field key; }
ANNA=$(person plattform '{"name":"Anna Autorin","mail":"anna@firma.de","project":"kern","roles":["pub_author"]}')
BERT=$(person plattform '{"name":"Bert Prüfer","mail":"bert@firma.de","roles":["pub_reviewer"]}')
CARLA=$(person plattform '{"name":"Carla Admin","mail":"carla@firma.de","roles":["pub_reviewer","tenant_admin"]}')
SHOP_ADMIN=$(key shop '{"kind":"tenant","name":"Shop-Admin"}')
SHOP_CI=$(key shop '{"kind":"project","name":"Shop CI","project":"api"}')
SHOP_INT=$(key shop '{"kind":"integration","name":"Softwareverteilung"}')
publish() {
local zip="$DEMO/package.zip"
rm -f "$zip"; (cd "$ROOT/contract/examples/packages/$1" && zip -qr "$zip" .)
curl -fsS -H "Authorization: Bearer $ANNA" -H "Content-Type: application/zip" --data-binary "@$zip" "$URL/api/v1/drafts" >/dev/null
local base="$URL/api/v1/packages/${1%/*}/versions/${1#*/}"
api -H "Authorization: Bearer $ANNA" -X POST "$base/submit" >/dev/null
[[ "${2:-}" == "open" ]] || api -H "Authorization: Bearer $BERT" "$base/approve" -d '{"comment":"Demo"}' >/dev/null
}
for p in pub-munin-nutzen/1.4.0 pub-hausregeln/1.0.0 pub-munin-anbindung/1.0.0 pub-release-notiz/1.0.0 pub-code-review/1.0.0; do
publish "$p"
done
publish pub-munin-nutzen/1.5.0 open # liegt bei den Prüfungen
curl -fsS "${ADMIN[@]}" -X PUT -H "Content-Type: application/json" "$URL/api/v1/admin/mandatory/pub-munin-anbindung" -d '{"tenant":null}'
api -H "Authorization: Bearer $SHOP_CI" "$URL/api/v1/usage" \
-d '{"repo":"git.intern/shop/api","tools":{"claude":"2.1.302","opencode":"1.15.4"},"installed":{"pub-munin-nutzen":"1.4.0","pub-hausregeln":"1.0.0"}}' >/dev/null
cat > "$KEYS" <<EOF
Hugin-Demo — Keys zum Anmelden unter $URL
(nur für diese lokale Demo; .demo/ löschen oder scripts/demo.sh --neu)
Plattform-Admin (alles, System) $HUGIN_ADMIN_TOKEN
Anna Autorin (pub_author, Person) $ANNA
Bert Prüfer (pub_reviewer, Person) $BERT
Carla Admin (Prüferin + Admin, Person) $CARLA
Shop-Admin (Tenant shop) $SHOP_ADMIN
Shop CI (Projekt shop/api, lesen) $SHOP_CI
Softwareverteilung (Integration shop) $SHOP_INT
EOF
kill $SERVER 2>/dev/null; wait $SERVER 2>/dev/null || true
trap - EXIT
fi
cat "$KEYS"
echo
echo "Weboberfläche: $URL API-Doku: $URL/api/docs MCP: $URL/mcp"
echo "Beenden mit Strg+C."
cd "$ROOT/server" && exec uv run -q hugin-server run --host "${HOST:-127.0.0.1}" --port "$PORT"

View file

@ -0,0 +1,125 @@
<#
.SYNOPSIS
Prüft hugin-install.ps1 unter Windows PowerShell 5.1 gegen einen laufenden Hugin.
.DESCRIPTION
Gegenstück zu scripts/integration-test.sh für Windows. Braucht einen Hugin mit
den Beispielpaketen, z. B. scripts/demo.sh auf einem anderen Rechner:
HOST=0.0.0.0 scripts/demo.sh # auf dem Mac/Linux-Rechner
Dann unter Windows (PowerShell 5.1, nicht pwsh):
powershell -ExecutionPolicy Bypass -File integration-test.ps1 `
-Server http://<rechner>:8080 -Key <Key "Softwareverteilung" aus .demo/keys.txt>
Ändert nichts außerhalb eines Temp-Ordners. Rückgabewert 0, wenn alles passt.
#>
param(
[Parameter(Mandatory = $true)][string]$Server,
[Parameter(Mandatory = $true)][string]$Key,
# Nur zum Vorabtest auf anderen Systemen, z. B. -PowerShell pwsh; unter Windows weglassen
[string]$PowerShell = 'powershell.exe'
)
Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Utf8 = New-Object System.Text.UTF8Encoding($false)
$failures = 0
function Check([string]$Name, [bool]$Ok, [string]$Info = '') {
if ($Ok) { Write-Host "OK $Name" } else { Write-Host "FEHLER $Name $Info" -ForegroundColor Red; $script:failures++ }
}
Write-Host "PowerShell $($PSVersionTable.PSVersion) ($($PSVersionTable.PSEdition))"
if ($PSVersionTable.PSVersion.Major -ne 5) { Write-Warning 'Dieser Test ist für Windows PowerShell 5.1 gedacht.' }
$work = Join-Path ([IO.Path]::GetTempPath()) ("hugin-test-" + [Guid]::NewGuid().ToString('N').Substring(0, 8))
$repo = Join-Path $work 'repo'
New-Item -ItemType Directory -Path $repo -Force | Out-Null
try {
# Skript vom Server laden, wie eine Softwareverteilung es tun würde
$script = Join-Path $work 'hugin-install.ps1'
Invoke-WebRequest -UseBasicParsing -Uri ($Server.TrimEnd('/') + '/api/v1/delivery/client.ps1') `
-Headers @{ Authorization = "Bearer $Key" } -OutFile $script
$raw = [IO.File]::ReadAllBytes($script)
Check 'client.ps1 mit BOM (für PowerShell 5.1)' ($raw[0] -eq 0xEF -and $raw[1] -eq 0xBB -and $raw[2] -eq 0xBF)
[IO.File]::WriteAllText((Join-Path $repo 'CLAUDE.md'), "# Werk`r`n`r`nUnser Projekt.`r`n", $Utf8)
[IO.File]::WriteAllText((Join-Path $repo 'opencode.json'), "{`n `"model`": `"anthropic/claude-sonnet-5-5`"`n}`n", $Utf8)
function Run([string[]]$More) {
$output = & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $script -Server $Server -Key $Key `
-Repo $repo -Tools 'claude=2.1.302,opencode=1.19.3' -Client 'windows-test' @More 2>&1 | Out-String
return @{ Text = $output; Rc = $LASTEXITCODE }
}
$first = Run @('-Packages', 'pub-munin-nutzen,pub-hausregeln,pub-code-review')
Check 'Erstinstallation' ($first.Rc -eq 0) $first.Text
foreach ($rel in '.claude\skills\pub-munin-nutzen\SKILL.md', '.claude\agents\pub-code-review.md',
'.opencode\agents\pub-code-review.md', '.hugin\rules\pub-hausregeln.md', '.mcp.json', 'hugin.lock.json') {
Check "Datei $rel" (Test-Path -LiteralPath (Join-Path $repo $rel))
}
$skill = [IO.File]::ReadAllBytes((Join-Path $repo '.claude\skills\pub-munin-nutzen\SKILL.md'))
Check 'SKILL.md ohne BOM' (-not ($skill[0] -eq 0xEF -and $skill[1] -eq 0xBB))
Check 'SKILL.md mit LF, ohne CRLF' (-not $Utf8.GetString($skill).Contains("`r`n"))
$claude = [IO.File]::ReadAllText((Join-Path $repo 'CLAUDE.md'), $Utf8)
Check 'CLAUDE.md behält Inhalt und CRLF' ($claude.StartsWith("# Werk`r`n`r`nUnser Projekt.`r`n`r`n<!-- hugin:begin") -and -not ($claude -replace "`r`n", '').Contains("`n"))
foreach ($json in 'opencode.json', '.mcp.json', 'hugin.lock.json') {
$text = [IO.File]::ReadAllText((Join-Path $repo $json), $Utf8)
$valid = $true; try { $null = $text | ConvertFrom-Json } catch { $valid = $false }
Check "$json gültiges JSON ohne BOM" ($valid -and -not $text.StartsWith([char]0xFEFF))
}
$oc = [IO.File]::ReadAllText((Join-Path $repo 'opencode.json'), $Utf8) | ConvertFrom-Json
Check 'opencode.json: eigenes Modell bleibt, MCP und instructions ergänzt' (
$oc.model -eq 'anthropic/claude-sonnet-5-5' -and $oc.mcp.munin.type -eq 'remote' -and @($oc.instructions) -contains '.hugin/rules/*.md')
# Eine Liste mit einem Eintrag muss eine Liste bleiben (Eigenheit von ConvertTo-Json in 5.1)
Check 'instructions bleibt eine Liste' ([IO.File]::ReadAllText((Join-Path $repo 'opencode.json'), $Utf8) -match '"instructions":\s*\[')
$second = Run @('-Packages', 'pub-munin-nutzen,pub-hausregeln,pub-code-review')
Check 'Zweiter Lauf ändert nichts' ($second.Rc -eq 0 -and $second.Text -match 'Dateien: 0 schreiben, 0 löschen') $second.Text
$dryRun = Run @('-Packages', 'pub-munin-nutzen', '-DryRun')
Check 'Trockenlauf schreibt nichts' ($dryRun.Text -match 'Trockenlauf' -and (Test-Path (Join-Path $repo '.claude\agents\pub-code-review.md')))
$unsubscribed = Run @('-Packages', 'pub-munin-nutzen')
Check 'Abbestellen räumt auf' ($unsubscribed.Rc -eq 0 -and -not (Test-Path (Join-Path $repo '.claude\agents\pub-code-review.md')) -and
-not ([IO.File]::ReadAllText((Join-Path $repo 'CLAUDE.md'), $Utf8).Contains('hugin:begin'))) $unsubscribed.Text
Check 'CLAUDE.md wieder wie vorher' ([IO.File]::ReadAllText((Join-Path $repo 'CLAUDE.md'), $Utf8) -eq "# Werk`r`n`r`nUnser Projekt.`r`n")
[IO.File]::AppendAllText((Join-Path $repo '.claude\skills\pub-munin-nutzen\SKILL.md'), "von Hand`n", $Utf8)
$manual = Run @('-Packages', 'pub-munin-nutzen,pub-hausregeln')
Check 'Handänderung wird erkannt und nichts geschrieben' ($manual.Rc -ne 0 -and $manual.Text -match 'von Hand' -and
-not (Test-Path (Join-Path $repo '.hugin\rules\pub-hausregeln.md'))) $manual.Text
$overwritten = Run @('-Packages', 'pub-munin-nutzen,pub-hausregeln', '-Overwrite')
Check '-Overwrite setzt zurück' ($overwritten.Rc -eq 0 -and (Test-Path (Join-Path $repo '.hugin\rules\pub-hausregeln.md')))
$repo2 = Join-Path $work 'repo2'; New-Item -ItemType Directory -Path $repo2 | Out-Null
[IO.File]::WriteAllText((Join-Path $repo2 'opencode.json'), "{`n // eigenes`n `"model`": `"x`"`n}`n", $Utf8)
$jsonc = & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $script -Server $Server -Key $Key -Repo $repo2 `
-Tools 'opencode=1.19.3' -Packages pub-hausregeln 2>&1 | Out-String
Check 'JSON mit Kommentaren bleibt unangetastet' ($LASTEXITCODE -ne 0 -and $jsonc -match 'Kommentare' -and
[IO.File]::ReadAllText((Join-Path $repo2 'opencode.json'), $Utf8).Contains('// eigenes'))
# Eine Junction im Repo darf nicht nach draußen führen (z. B. bei Ausführung als Admin)
$repo3 = Join-Path $work 'repo3'; $outside = Join-Path $work 'outside'
New-Item -ItemType Directory -Path $repo3, $outside | Out-Null
if ($PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows) {
New-Item -ItemType Junction -Path (Join-Path $repo3 '.opencode') -Target $outside | Out-Null
} else {
New-Item -ItemType SymbolicLink -Path (Join-Path $repo3 '.opencode') -Target $outside | Out-Null
}
$link = & $PowerShell -NoProfile -ExecutionPolicy Bypass -File $script -Server $Server -Key $Key -Repo $repo3 `
-Tools 'opencode=1.19.3' -Packages pub-code-review 2>&1 | Out-String
Check 'Junction nach draußen wird abgelehnt' ($LASTEXITCODE -ne 0 -and $link -match 'Junction' -and
@(Get-ChildItem -LiteralPath $outside -Force).Count -eq 0) $link
# Junction vor dem Aufräumen lösen, damit Remove-Item nicht dem Link folgt
(Get-Item -LiteralPath (Join-Path $repo3 '.opencode') -Force).Delete()
} finally {
Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
}
if ($failures -eq 0) { Write-Host "`nAlles ok." -ForegroundColor Green; exit 0 }
Write-Host "`n$failures Prüfung(en) fehlgeschlagen." -ForegroundColor Red
exit 1

137
scripts/integration-test.sh Executable file
View file

@ -0,0 +1,137 @@
#!/usr/bin/env bash
# Auslieferung für Integrationen (→ KONZEPT.md 14.7) Ende zu Ende:
# Server starten, Integrations-Key anlegen, Pakete veröffentlichen und
# hugin-install.ps1 gegen ein Test-Repo laufen lassen.
#
# PWSH=/pfad/zu/pwsh scripts/integration-test.sh
#
# Hinweis: Läuft hier mit PowerShell 7. Ziel ist Windows PowerShell 5.1; dort
# einmal von Hand prüfen (siehe README, „Integration testen“).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PWSH="${PWSH:-pwsh}"
WORK="$(mktemp -d)"
PORT="${PORT:-8766}"
URL="http://127.0.0.1:$PORT"
export HUGIN_DATABASE_URL="sqlite:///$WORK/hugin.db"
export HUGIN_ADMIN_TOKEN="integration-admin"
ADMIN=(-H "Authorization: Bearer $HUGIN_ADMIN_TOKEN")
cleanup() {
local rc=$?
[[ -n "${SERVER:-}" ]] && kill "$SERVER" 2>/dev/null && wait "$SERVER" 2>/dev/null || true
if [[ -z "${KEEP:-}" ]]; then rm -rf "$WORK"; else echo "Arbeitsordner: $WORK"; fi
exit $rc
}
trap cleanup EXIT
api() { curl -fsS -H "Content-Type: application/json" "$@"; }
key() { python3 -c 'import json,sys; print(json.load(sys.stdin)["key"])'; }
(cd "$ROOT/server" && uv run -q hugin-server migrate)
(cd "$ROOT/server" && exec uv run -q hugin-server run --host 127.0.0.1 --port "$PORT") >"$WORK/server.log" 2>&1 &
SERVER=$!
for _ in $(seq 50); do curl -fsS "$URL/readyz" >/dev/null 2>&1 && break; sleep 0.2; done
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d '{"slug":"plattform","name":"Plattform"}' >/dev/null
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/projects" -d '{"slug":"kern","name":"Kern"}' >/dev/null
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d '{"slug":"werk","name":"Werk"}' >/dev/null
AUTHOR=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/keys" -d '{"kind":"project","name":"autor","project":"kern","roles":["pub_author"]}' | key)
REVIEWER=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/keys" -d '{"kind":"project","name":"pruefer","project":"kern","roles":["pub_reviewer"]}' | key)
INTEGRATION=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/werk/keys" -d '{"kind":"integration","name":"Softwareverteilung"}' | key)
publish() {
local zip="$WORK/package.zip"
rm -f "$zip"; (cd "$ROOT/contract/examples/packages/$1" && zip -qr "$zip" .)
curl -fsS -H "Authorization: Bearer $AUTHOR" -H "Content-Type: application/zip" --data-binary "@$zip" "$URL/api/v1/drafts" >/dev/null
local base="$URL/api/v1/packages/${1%/*}/versions/${1#*/}"
api -H "Authorization: Bearer $AUTHOR" -X POST "$base/submit" >/dev/null
api -H "Authorization: Bearer $REVIEWER" "$base/approve" -d '{}' >/dev/null
}
for p in pub-munin-nutzen/1.4.0 pub-hausregeln/1.0.0 pub-munin-anbindung/1.0.0 pub-code-review/1.0.0; do publish "$p"; done
curl -fsS "${ADMIN[@]}" -X PUT -H "Content-Type: application/json" "$URL/api/v1/admin/mandatory/pub-munin-anbindung" -d '{"tenant":null}'
echo "== Status und Übersicht"
curl -fsS -H "Authorization: Bearer $INTEGRATION" "$URL/api/v1/delivery/status"; echo
curl -fsS -H "Authorization: Bearer $INTEGRATION" "$URL/api/v1/delivery/packages" | python3 -c 'import json,sys; print([(p["id"], p["channels"]) for p in json.load(sys.stdin)])'
code=$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $AUTHOR" "$URL/api/v1/delivery/status")
[[ $code == 403 ]] || { echo "Projekt-Key hätte abgelehnt werden müssen ($code)"; exit 1; }
echo "== Skript laden und ausführen"
curl -fsS -H "Authorization: Bearer $INTEGRATION" "$URL/api/v1/delivery/client.ps1" -o "$WORK/hugin-install.ps1"
REPO="$WORK/repo"; mkdir -p "$REPO"
printf '# Werk\r\n\r\nUnser Projekt.\r\n' > "$REPO/CLAUDE.md"
printf '{\n "model": "anthropic/claude-sonnet-5-5"\n}\n' > "$REPO/opencode.json"
run() {
HUGIN_KEY="$INTEGRATION" "$PWSH" -NoProfile -NonInteractive -File "$WORK/hugin-install.ps1" \
-Server "$URL" -Repo "$REPO" -Tools 'claude=2.1.302,opencode=1.19.3' -Client werk-pc-01 "$@"
}
run -Packages pub-munin-nutzen,pub-hausregeln,pub-code-review
check() { grep -q -- "$2" "$REPO/$1" || { echo "$1 enthält nicht: $2"; cat "$REPO/$1"; exit 1; }; }
check .claude/skills/pub-munin-nutzen/SKILL.md "pub-munin-nutzen 1.4.0"
check .claude/agents/pub-code-review.md "tools: Read, Grep, Glob"
check .opencode/agents/pub-code-review.md "webfetch: deny"
check CLAUDE.md "@.hugin/rules/pub-hausregeln.md"
check .mcp.json '"type": *"http"'
check opencode.json '.hugin/rules/\*.md'
python3 - "$REPO" <<'PY'
import json, sys, pathlib
repo = pathlib.Path(sys.argv[1])
claude = (repo / "CLAUDE.md").read_bytes()
assert claude.startswith(b"# Werk\r\n\r\nUnser Projekt.\r\n\r\n<!-- hugin:begin"), claude[:80]
assert b"\n" not in claude.replace(b"\r\n", b""), "Zeilenenden gemischt"
for f in ["opencode.json", ".mcp.json", "hugin.lock.json"]:
roh = (repo / f).read_bytes()
assert not roh.startswith(b"\xef\xbb\xbf"), f + " mit BOM"
json.loads(roh)
oc = json.loads((repo / "opencode.json").read_text())
assert oc["model"] == "anthropic/claude-sonnet-5-5" and oc["mcp"]["munin"]["type"] == "remote", oc
skill = (repo / ".claude/skills/pub-munin-nutzen/SKILL.md").read_bytes()
assert b"\r\n" not in skill and not skill.startswith(b"\xef\xbb\xbf")
print("Inhalte ok")
PY
echo "== Zweiter Lauf ändert nichts"
run -Packages pub-munin-nutzen,pub-hausregeln,pub-code-review | tee "$WORK/lauf2.txt"
grep -q "Dateien: 0 schreiben, 0 löschen" "$WORK/lauf2.txt" || { echo "zweiter Lauf hätte nichts ändern dürfen"; exit 1; }
echo "== Aktualisierung und Abbestellen"
publish pub-munin-nutzen/1.5.0
run -Packages pub-munin-nutzen
check .claude/skills/pub-munin-nutzen/SKILL.md "pub-munin-nutzen 1.5.0"
[[ ! -e "$REPO/.claude/skills/pub-munin-nutzen/referenz" ]] || { echo "referenz/ nicht entfernt"; exit 1; }
[[ ! -e "$REPO/.claude/agents/pub-code-review.md" ]] || { echo "Subagent nicht entfernt"; exit 1; }
grep -q "hugin:begin" "$REPO/CLAUDE.md" && { echo "Block nicht entfernt"; exit 1; }
cmp -s <(printf '# Werk\r\n\r\nUnser Projekt.\r\n') "$REPO/CLAUDE.md" || { echo "CLAUDE.md nicht wie vorher"; exit 1; }
python3 -c "import json,sys; oc=json.load(open('$REPO/opencode.json')); assert 'instructions' not in oc and 'munin' in oc['mcp'], oc"
echo "== Handänderung wird erkannt"
echo "von Hand" >> "$REPO/.claude/skills/pub-munin-nutzen/SKILL.md"
publish pub-hausregeln/1.0.0 2>/dev/null || true
set +e; run -Packages pub-munin-nutzen,pub-hausregeln > "$WORK/lauf4.txt" 2>&1; RC=$?; set -e
[[ $RC -ne 0 ]] && grep -q "von Hand geändert" "$WORK/lauf4.txt" || { cat "$WORK/lauf4.txt"; echo "Handänderung nicht erkannt"; exit 1; }
[[ ! -e "$REPO/.hugin/rules/pub-hausregeln.md" ]] || { echo "trotz Befund geschrieben"; exit 1; }
run -Packages pub-munin-nutzen,pub-hausregeln -Overwrite >/dev/null
check .hugin/rules/pub-hausregeln.md "Hausregeln"
echo "== JSON mit Kommentaren wird nicht angefasst"
REPO2="$WORK/repo2"; mkdir -p "$REPO2"
printf '{\n // eigenes\n "model": "x"\n}\n' > "$REPO2/opencode.json"
set +e; HUGIN_KEY="$INTEGRATION" "$PWSH" -NoProfile -NonInteractive -File "$WORK/hugin-install.ps1" \
-Server "$URL" -Repo "$REPO2" -Tools 'opencode=1.19.3' -Packages pub-hausregeln > "$WORK/lauf5.txt" 2>&1; RC=$?; set -e
[[ $RC -ne 0 ]] && grep -q "Kommentare" "$WORK/lauf5.txt" || { cat "$WORK/lauf5.txt"; echo "JSONC nicht erkannt"; exit 1; }
grep -q "// eigenes" "$REPO2/opencode.json" || { echo "opencode.json verändert"; exit 1; }
echo "== Kein Schreiben über Links nach draußen"
REPO3="$WORK/repo3"; OUTSIDE="$WORK/draussen"; mkdir -p "$REPO3" "$OUTSIDE"
ln -s "$OUTSIDE" "$REPO3/.opencode"
set +e; HUGIN_KEY="$INTEGRATION" "$PWSH" -NoProfile -NonInteractive -File "$WORK/hugin-install.ps1" \
-Server "$URL" -Repo "$REPO3" -Tools 'opencode=1.19.3' -Packages pub-code-review > "$WORK/lauf6.txt" 2>&1; RC=$?; set -e
[[ $RC -ne 0 ]] && grep -q "Link oder eine Junction" "$WORK/lauf6.txt" || { cat "$WORK/lauf6.txt"; echo "Link nicht erkannt"; exit 1; }
[[ -z "$(ls -A "$OUTSIDE")" ]] || { echo "außerhalb des Repos geschrieben"; ls -R "$OUTSIDE"; exit 1; }
echo "== Nutzung"
sqlite3 "$WORK/hugin.db" "select repo, package_id, version, tools from hugin_usage order by package_id" 2>/dev/null || true
echo "Integrationstest ok."

42
scripts/postgres-test.sh Executable file
View file

@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Server-Tests gegen PostgreSQL in Docker (→ KONZEPT.md 14.2: SQLite oder PostgreSQL).
#
# scripts/postgres-test.sh # ganze Testsuite
# scripts/postgres-test.sh -k bundle # weitere Argumente gehen an pytest
# POSTGRES_IMAGE=postgres:16.10 scripts/postgres-test.sh
# scripts/postgres-test.sh --compose # die dauerhafte PostgreSQL aus docker-compose.yml nutzen
#
# Startet einen Wegwerf-Container (Daten im Speicher), führt Migrationen und
# Tests aus, je Test eine eigene Datenbank, und entfernt den Container danach.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
if [[ "${1:-}" == "--compose" ]]; then
shift
(cd "$ROOT" && docker compose up -d --wait postgres >/dev/null)
echo "PostgreSQL aus docker-compose.yml (127.0.0.1:55432)"
cd "$ROOT/server"
exec env HUGIN_TEST_POSTGRES="postgresql+psycopg://hugin:hugin@127.0.0.1:55432/postgres" \
uv run pytest -q -p no:warnings "$@"
fi
IMAGE="${POSTGRES_IMAGE:-postgres:17.6}"
NAME="hugin-postgres-test-$$"
PASSWORD="hugin-test"
docker run -d --rm --name "$NAME" -e POSTGRES_PASSWORD="$PASSWORD" -e POSTGRES_USER=hugin \
--tmpfs /var/lib/postgresql/data -p 127.0.0.1::5432 "$IMAGE" >/dev/null
trap 'rc=$?; docker rm -f "$NAME" >/dev/null 2>&1 || true; exit $rc' EXIT
PORT="$(docker port "$NAME" 5432/tcp | head -1 | sed 's/.*://')"
for _ in $(seq 60); do
docker exec "$NAME" pg_isready -U hugin -q 2>/dev/null && break
sleep 0.5
done
docker exec "$NAME" pg_isready -U hugin -q || { echo "PostgreSQL startet nicht"; exit 1; }
echo "PostgreSQL $(docker exec "$NAME" postgres --version | awk '{print $3}') auf Port $PORT"
cd "$ROOT/server"
HUGIN_TEST_POSTGRES="postgresql+psycopg://hugin:$PASSWORD@127.0.0.1:$PORT/postgres" \
uv run pytest -q -p no:warnings "$@"

124
scripts/smoke-test-local.sh Executable file
View file

@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Rauchtest ohne echte Werkzeuge: Server starten, Beispielpakete veröffentlichen,
# mit dem CLI in ein Test-Repo abgleichen (→ KONZEPT.md 17).
#
# scripts/smoke-test-local.sh # Arbeitsordner wird danach gelöscht
# KEEP=1 scripts/smoke-test-local.sh # Arbeitsordner bleibt zum Ansehen
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
WORK="$(mktemp -d)"
PORT="${PORT:-8765}"
URL="http://127.0.0.1:$PORT"
export HUGIN_DATABASE_URL="sqlite:///$WORK/hugin.db"
export HUGIN_ADMIN_TOKEN="rauchtest-admin"
export HUGIN_CLI_MIN_VERSION="0.1.0"
export HUGIN_DOWNLOAD_DIR="$WORK/downloads"
mkdir -p "$HUGIN_DOWNLOAD_DIR/0.2.0" && touch "$HUGIN_DOWNLOAD_DIR/0.2.0/hugin-linux-amd64"
ADMIN=(-H "Authorization: Bearer $HUGIN_ADMIN_TOKEN")
cleanup() {
[[ -n "${SERVER:-}" ]] && kill "$SERVER" 2>/dev/null || true
if [[ -z "${KEEP:-}" ]]; then rm -rf "$WORK"; else echo "Arbeitsordner: $WORK"; fi
}
trap cleanup EXIT
api() { curl -fsS -H "Content-Type: application/json" "$@"; }
key() { python3 -c 'import json,sys; print(json.load(sys.stdin)["key"])'; }
echo "== Server"
(cd "$ROOT/server" && uv run -q hugin-server migrate)
(cd "$ROOT/server" && exec uv run -q hugin-server run --host 127.0.0.1 --port "$PORT") >"$WORK/server.log" 2>&1 &
SERVER=$!
for _ in $(seq 50); do curl -fsS "$URL/readyz" >/dev/null 2>&1 && break; sleep 0.2; done
curl -fsS "$URL/readyz"; echo
echo "== Tenants, Projekte, Keys"
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d '{"slug":"plattform","name":"Plattform"}' >/dev/null
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/projects" -d '{"slug":"kern","name":"Kern"}' >/dev/null
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants" -d '{"slug":"shop","name":"Shop"}' >/dev/null
api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/shop/projects" -d '{"slug":"web","name":"Web"}' >/dev/null
AUTHOR=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/keys" -d '{"kind":"project","name":"autor","project":"kern","roles":["pub_author"]}' | key)
REVIEWER=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/plattform/keys" -d '{"kind":"project","name":"pruefer","project":"kern","roles":["pub_reviewer"]}' | key)
READER=$(api "${ADMIN[@]}" "$URL/api/v1/admin/tenants/shop/keys" -d '{"kind":"project","name":"web","project":"web"}' | key)
publish() {
local folder="$ROOT/contract/examples/packages/$1" zip="$WORK/package.zip"
rm -f "$zip"; (cd "$folder" && zip -qr "$zip" .)
curl -fsS -H "Authorization: Bearer $AUTHOR" -H "Content-Type: application/zip" --data-binary "@$zip" "$URL/api/v1/drafts" >/dev/null
local base="$URL/api/v1/packages/${1%/*}/versions/${1#*/}"
api -H "Authorization: Bearer $AUTHOR" -X POST "$base/submit" >/dev/null
api -H "Authorization: Bearer $REVIEWER" "$base/approve" -d '{"comment":"Rauchtest"}' >/dev/null
echo " veröffentlicht: $1"
}
echo "== Pakete"
publish pub-munin-nutzen/1.4.0
publish pub-hausregeln/1.0.0
publish pub-munin-anbindung/1.0.0
curl -fsS "${ADMIN[@]}" -X PUT -H "Content-Type: application/json" "$URL/api/v1/admin/mandatory/pub-munin-anbindung" -d '{"tenant":null}'
echo "== CLI"
(cd "$ROOT/cli" && go build -o "$WORK/hugin" ./cmd/hugin)
REPO="$WORK/repo"; mkdir -p "$REPO"; cd "$REPO"; git init -q
printf '# Web\n\nUnser Projekt.\n' > CLAUDE.md
export HUGIN_KEY="$READER"
"$WORK/hugin" init --server "$URL" --project web >/dev/null
sed -i.alt 's/^packages: \[\]/packages:\n - pub-munin-nutzen\n - pub-hausregeln/' .hugin.yaml && rm -f .hugin.yaml.alt
"$WORK/hugin" sync
"$WORK/hugin" check
echo "== Aktualisierung"
publish pub-munin-nutzen/1.5.0
set +e; "$WORK/hugin" check; RC=$?; set -e
[[ $RC -eq 1 ]] || { echo "check hätte die neue Version melden müssen (rc=$RC)"; exit 1; }
"$WORK/hugin" sync
"$WORK/hugin" status
echo "== Autoren: new, validate, try, upload"
export HUGIN_SERVER="$URL"
cd "$WORK"
HUGIN_KEY="$AUTHOR" "$WORK/hugin" new command pub-rauch-befehl
cat > pub-rauch-befehl/hugin.yaml <<'YAML'
id: pub-rauch-befehl
kind: command
version: 1.0.0
owner: kern
scope: public
description: Rauchtest für Befehle.
arguments: [ziel]
YAML
printf 'Prüfe {{ziel}} und fasse zusammen. $ARGUMENTS\n' > pub-rauch-befehl/COMMAND.md
HUGIN_KEY="$AUTHOR" "$WORK/hugin" validate pub-rauch-befehl
cd "$REPO"
cp hugin.lock "$WORK/lock-vorher"
HUGIN_KEY="$AUTHOR" "$WORK/hugin" try "$WORK/pub-rauch-befehl"
grep -q 'Prüfe $ziel' .claude/commands/pub-rauch-befehl.md
cmp -s hugin.lock "$WORK/lock-vorher" || { echo "try hat hugin.lock verändert"; exit 1; }
"$WORK/hugin" try --remove
[[ ! -e .claude/commands/pub-rauch-befehl.md ]] || { echo "try --remove hat nicht aufgeräumt"; exit 1; }
cd "$WORK"
set +e; printf 'Nimm $1\n' > pub-rauch-befehl/COMMAND.md
HUGIN_KEY="$AUTHOR" "$WORK/hugin" upload pub-rauch-befehl; RC=$?; set -e
[[ $RC -eq 1 ]] || { echo "Linter hätte \$1 ablehnen müssen (rc=$RC)"; exit 1; }
printf 'Prüfe {{ziel}} und fasse zusammen.\n' > pub-rauch-befehl/COMMAND.md
HUGIN_KEY="$AUTHOR" "$WORK/hugin" upload --submit pub-rauch-befehl
api -H "Authorization: Bearer $REVIEWER" "$URL/api/v1/packages/pub-rauch-befehl/versions/1.0.0/approve" -d '{}' >/dev/null
cd "$REPO"
sed -i.alt 's/^ - pub-hausregeln$/ - pub-hausregeln\n - pub-rauch-befehl/' .hugin.yaml && rm -f .hugin.yaml.alt
"$WORK/hugin" sync
grep -q 'Prüfe $ziel' .claude/commands/pub-rauch-befehl.md
echo "== suche und Versionsprüfung des CLI"
"$WORK/hugin" search munin | tee "$WORK/search.txt"
grep -q "pub-munin-nutzen" "$WORK/search.txt" || { echo "search findet pub-munin-nutzen nicht"; exit 1; }
(cd "$ROOT/cli" && go build -ldflags "-X main.Version=0.0.9" -o "$WORK/hugin-alt" ./cmd/hugin)
set +e; "$WORK/hugin-alt" check > "$WORK/check-alt.txt" 2>&1; RC=$?; set -e
[[ $RC -eq 1 ]] && grep -q "älter als die nötige Version 0.1.0" "$WORK/check-alt.txt" || { cat "$WORK/check-alt.txt"; echo "altes CLI nicht gemeldet"; exit 1; }
(cd "$ROOT/cli" && go build -ldflags "-X main.Version=0.1.0" -o "$WORK/hugin-010" ./cmd/hugin)
"$WORK/hugin-010" check > "$WORK/check-010.txt" 2>&1 || true
grep -q "hugin 0.2.0 ist verfügbar" "$WORK/check-010.txt" || { cat "$WORK/check-010.txt"; echo "Hinweis auf neue Version fehlt"; exit 1; }
echo "== Nutzung"
sqlite3 "$WORK/hugin.db" "select repo, package_id, version, tools from hugin_usage order by package_id" 2>/dev/null || true
echo "Rauchtest ok."

Some files were not shown because too many files have changed in this diff Show more