332 lines
12 KiB
Go
332 lines
12 KiB
Go
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<!-- Verwaltet von Hugin (pub-x 1.0.0) -->\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<!-- hugin:begin (nicht von Hand ändern) -->\nalt\n<!-- hugin:end -->\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")
|
|
}
|
|
}
|