// 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 - / -", 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?\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 \nhugin upload --submit \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() }