hugin/cli/internal/localmcp/localmcp.go
2026-10-27 08:33:21 +01:00

340 lines
10 KiB
Go

// 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
}