package importer import ( "os" "path/filepath" "strings" "testing" "gopkg.in/yaml.v3" ) // Beispiel-Repo mit OpenCode- und Claude-Fassungen, teils mit gleichen Namen. var example = map[string]string{ ".opencode/skills/munin/SKILL.md": `--- name: munin description: Wie du Munin nutzt. license: intern metadata: opencode/slash: "true" --- Nutze Munin. `, ".opencode/skills/munin/referenz/beispiele.md": "Beispiel\n", ".claude/skills/munin/SKILL.md": `--- name: munin description: Claude-Fassung, soll verlieren. --- Alt. `, ".claude/skills/nur-claude/SKILL.md": `--- name: nur-claude description: Gibt es nur bei Claude. allowed-tools: [Read] --- Inhalt. `, ".claude/skills/hugin-alt/SKILL.md": "---\nname: x\ndescription: y\n---\n\n", ".opencode/agents/review.md": `--- description: Prüft Änderungen. mode: subagent temperature: 0.1 permission: edit: deny bash: "git diff*": allow "*": deny webfetch: deny --- Du prüfst Code. `, ".claude/agents/review.md": `--- name: review description: Claude-Fassung. tools: Read, Grep, Edit model: sonnet --- Anderer Prompt. `, ".claude/agents/schreiber.md": `--- name: schreiber description: Schreibt Doku. tools: Read, Write, WebFetch --- Du schreibst. `, ".opencode/commands/release.md": `--- description: Release-Notiz agent: build --- Notiz für Version $1, gedacht für $2. Weitere: $ARGUMENTS `, ".claude/commands/deploy.md": `--- description: Deployt arguments: [umgebung] allowed-tools: Bash(make:*) --- Deploye nach $umgebung. `, ".claude/commands/release.md": "---\ndescription: verliert\n---\nx\n", "AGENTS.md": "# Regeln des Shops\n\nImmer testen.\n\n\nalt\n\n", "CLAUDE.md": "# Claude\n\nwird ignoriert, weil AGENTS.md da ist\n", "doku/stil.md": "# Stil\n\nKurz schreiben.\n", "opencode.json": `{ // Kommentar "instructions": ["doku/*.md", ".hugin/rules/*.md", "https://x.example/regeln.md"], "mcp": { "munin": {"type": "remote", "url": "https://munin.intern/mcp", "headers": {"Authorization": "Bearer {env:MUNIN_KEY}"}}, "lokal": {"type": "local", "command": ["npx", "x"]} }, "agent": { "build": {"model": "anthropic/claude-sonnet-5-5"}, "doku": {"description": "Doku-Agent", "prompt": "Du dokumentierst.", "permission": {"edit": "allow"}} }, "command": {"kurz": {"template": "Fasse $1 zusammen.", "description": "Kurzfassung"}}, "plugin": ["opencode-x"] }`, ".mcp.json": `{"mcpServers": { "munin": {"type": "http", "url": "https://andere.url/mcp"}, "jira": {"type": "http", "url": "https://jira.intern/mcp", "headers": {"Authorization": "Bearer geheim123"}} }}`, ".opencode/plugins/x.ts": "export default {}\n", } func createRepo(t *testing.T) string { t.Helper() repo := filepath.Join(t.TempDir(), "shop-api") for rel, content := range example { p := filepath.Join(repo, filepath.FromSlash(rel)) if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(p, []byte(content), 0o644); err != nil { t.Fatal(err) } } return repo } func readManifest(t *testing.T, target, id string) map[string]any { t.Helper() raw, err := os.ReadFile(filepath.Join(target, id, "hugin.yaml")) if err != nil { t.Fatalf("%s: %v", id, err) } m := map[string]any{} if err := yaml.Unmarshal(raw, &m); err != nil { t.Fatal(err) } return m } func file(t *testing.T, target, rel string) string { t.Helper() raw, err := os.ReadFile(filepath.Join(target, filepath.FromSlash(rel))) if err != nil { t.Fatal(err) } return string(raw) } func TestImport(t *testing.T) { repo := createRepo(t) target := filepath.Join(t.TempDir(), "import") b, err := Import(Options{Repo: repo, Target: target, Prefix: "shop-", Owner: "api"}) if err != nil { t.Fatal(err) } ids := map[string]string{} for _, p := range b.Packages { ids[p.ID] = p.Kind } expected := map[string]string{ "shop-munin": "skill", "shop-nur-claude": "skill", "shop-review": "subagent", "shop-doku": "subagent", "shop-schreiber": "subagent", "shop-release": "command", "shop-kurz": "command", "shop-deploy": "command", "shop-api-rules": "rules", "shop-stil": "rules", "shop-munin-mcp": "mcp", "shop-jira-mcp": "mcp", } for id, kind := range expected { if ids[id] != kind { t.Errorf("%s: Art %q, erwartet %q", id, ids[id], kind) } } if len(ids) != len(expected) { t.Errorf("Pakete: %v", ids) } // Skill: OpenCode-Fassung gewinnt, Hilfsdateien und metadata bleiben m := readManifest(t, target, "shop-munin") if m["description"] != "Wie du Munin nutzt." || m["scope"] != "tenant" || m["owner"] != "api" || m["license"] != "intern" { t.Errorf("Manifest: %v", m) } if extra := m["extra"].(map[string]any)["opencode"].(map[string]any)["metadata"].(map[string]any); extra["opencode/slash"] != "true" { t.Errorf("metadata: %v", extra) } if file(t, target, "shop-munin/SKILL.md") != "Nutze Munin.\n" || file(t, target, "shop-munin/referenz/beispiele.md") != "Beispiel\n" { t.Error("Skill-Inhalt") } if strings.HasPrefix(file(t, target, "shop-munin/SKILL.md"), "---") { t.Error("Inhaltsdatei darf keine Frontmatter haben") } // Claude-only Skill: unbekannte Felder als extra.claude if z := readManifest(t, target, "shop-nur-claude")["extra"].(map[string]any)["claude"].(map[string]any); z["allowed-tools"] == nil { t.Errorf("extra.claude: %v", z) } // Subagent: Fähigkeit aus OpenCode-Rechten (bash mit Muster = shell), Rechte bleiben als Zusatz m = readManifest(t, target, "shop-review") if f, _ := m["capability"].([]any); len(f) != 2 || f[0] != "read" || f[1] != "shell" { t.Errorf("capability: %v", m["capability"]) } oc := m["extra"].(map[string]any)["opencode"].(map[string]any) if oc["mode"] != "subagent" || oc["temperature"] != 0.1 || oc["permission"] == nil { t.Errorf("extra.opencode: %v", oc) } if cl := m["extra"].(map[string]any)["claude"].(map[string]any); cl["model"] != "sonnet" { t.Errorf("Claude-Felder der Zweitfassung: %v", cl) } // Reihenfolge der Muster bleibt (in OpenCode gewinnt die letzte passende Regel) if y := file(t, target, "shop-review/hugin.yaml"); strings.Index(y, "git diff*") > strings.Index(y, "'*': deny") { t.Errorf("Reihenfolge unter permission.bash verloren:\n%s", y) } if y := file(t, target, "shop-doku/hugin.yaml"); !strings.Contains(y, "permission:\n edit: allow") { t.Errorf("Rechte aus opencode.json als Block-YAML:\n%s", y) } if file(t, target, "shop-review/AGENT.md") != "Du prüfst Code.\n" { t.Error("Prompt muss aus der OpenCode-Fassung kommen") } if f, _ := readManifest(t, target, "shop-schreiber")["capability"].([]any); len(f) != 3 || f[1] != "write" || f[2] != "web" { t.Errorf("Fähigkeit aus Claude-Werkzeugen: %v", f) } if file(t, target, "shop-doku/AGENT.md") != "Du dokumentierst." { t.Error("Agent aus opencode.json") } // Befehle: $1/$2 → {{arg1}}/{{arg2}}, $ARGUMENTS bleibt; Claude-Namen bleiben benannt m = readManifest(t, target, "shop-release") if a, _ := m["arguments"].([]any); len(a) != 2 || a[0] != "arg1" { t.Errorf("arguments: %v", m["arguments"]) } if s := file(t, target, "shop-release/COMMAND.md"); s != "Notiz für Version {{arg1}}, gedacht für {{arg2}}. Weitere: $ARGUMENTS\n" { t.Errorf("COMMAND.md: %q", s) } if m["extra"].(map[string]any)["opencode"].(map[string]any)["agent"] != "build" { t.Error("agent als extra.opencode") } if s := file(t, target, "shop-deploy/COMMAND.md"); s != "Deploye nach {{umgebung}}.\n" { t.Errorf("Claude-Befehl: %q", s) } // Regeln: AGENTS.md ohne Hugin-Block, CLAUDE.md übersprungen, instructions-Datei übernommen if s := file(t, target, "shop-api-rules/RULES.md"); s != "# Regeln des Shops\n\nImmer testen.\n" { t.Errorf("RULES.md: %q", s) } if readManifest(t, target, "shop-api-rules")["description"] != "Regeln des Shops (aus AGENTS.md)" { t.Error("Beschreibung aus Überschrift") } // MCP: OpenCode-Fassung gewinnt; feste Kopfzeile wird zur Variable, Wert nie übernommen m = readManifest(t, target, "shop-munin-mcp") s := m["server"].(map[string]any) if s["url"] != "https://munin.intern/mcp" || s["headers"].(map[string]any)["Authorization"] != "Bearer {{env:MUNIN_KEY}}" { t.Errorf("MCP munin: %v", s) } jira := file(t, target, "shop-jira-mcp/hugin.yaml") if strings.Contains(jira, "geheim123") || !strings.Contains(jira, "Bearer {{env:JIRA_AUTHORIZATION}}") { t.Errorf("Geheimnis darf nicht ins Paket:\n%s", jira) } // Übersprungen mit Grund reasons := map[string]string{} for _, g := range b.Skipped { reasons[g.Source] = g.Text } for source, part := range map[string]string{ ".claude/skills/munin/SKILL.md": "OpenCode zuerst", ".claude/skills/hugin-alt/SKILL.md": "von Hugin verwaltet", ".claude/commands/release.md": "OpenCode zuerst", "CLAUDE.md": "AGENTS.md", "opencode.json mcp.lokal": "lokaler MCP-Server", ".mcp.json mcpServers.munin": "schon aus opencode.json", "opencode.json agent.build": "ohne eigenen Prompt", "opencode.json instructions https://x.example/regeln.md": "URL", ".opencode/plugins/x.ts": "noch nicht", "opencode.json plugin opencode-x": "noch nicht", } { if !strings.Contains(reasons[source], part) { t.Errorf("%s: Grund %q, erwartet %q", source, reasons[source], part) } } if !strings.Contains(file(t, target, "IMPORT.md"), "| shop-munin | skill | .opencode/skills/munin/SKILL.md |") { t.Error("IMPORT.md") } // Im Repo ändert sich nichts if file(t, repo, "AGENTS.md") != example["AGENTS.md"] { t.Error("Repo verändert") } } func TestImportPublicAndExistingTarget(t *testing.T) { repo := createRepo(t) target := t.TempDir() if _, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern"}); err != nil { t.Fatal(err) } if readManifest(t, target, "pub-munin")["scope"] != "public" { t.Error("pub- heißt öffentlich") } os.WriteFile(filepath.Join(target, "pub-munin", "SKILL.md"), []byte("bearbeitet\n"), 0o644) b, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern"}) if err != nil { t.Fatal(err) } if len(b.Packages) != 0 || file(t, target, "pub-munin/SKILL.md") != "bearbeitet\n" { t.Error("vorhandene Ordner nur mit Ueberschreiben ersetzen") } if _, err := Import(Options{Repo: repo, Target: target, Prefix: "pub-", Owner: "kern", Overwrite: true}); err != nil { t.Fatal(err) } if file(t, target, "pub-munin/SKILL.md") != "Nutze Munin.\n" { t.Error("Ueberschreiben") } if _, err := Import(Options{Repo: repo, Target: target, Prefix: "Shop"}); err == nil { t.Error("ungültiges Präfix") } } func TestImportFollowsNoSymlinks(t *testing.T) { repo := createRepo(t) outside := filepath.Join(t.TempDir(), "geheim.md") os.WriteFile(outside, []byte("# Geheim\n"), 0o644) os.MkdirAll(filepath.Join(repo, ".opencode/skills/link"), 0o755) os.Symlink(outside, filepath.Join(repo, ".opencode/skills/link/SKILL.md")) os.Symlink(outside, filepath.Join(repo, ".opencode/skills/munin/referenz/geheim.md")) os.Symlink(outside, filepath.Join(repo, "doku/link.md")) target := t.TempDir() b, err := Import(Options{Repo: repo, Target: target, Prefix: "shop-", Owner: "api"}) if err != nil { t.Fatal(err) } for _, p := range b.Packages { if p.ID == "shop-link" { t.Error("Symlink-Skill übernommen") } } if _, err := os.Stat(filepath.Join(target, "shop-munin/referenz/geheim.md")); err == nil { t.Error("Symlink-Hilfsdatei übernommen") } if _, err := os.Stat(filepath.Join(target, "shop-link")); err == nil { t.Error("Symlink in instructions übernommen") } }