// Package localmcp installiert und startet lokale MCP-Server (→ KONZEPT.md 20, Stufe 5b). // // Ablage außerhalb des Repos: ///, // 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 }