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

943 lines
29 KiB
Go

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