943 lines
29 KiB
Go
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()
|
|
}
|