168 lines
5.1 KiB
Go
168 lines
5.1 KiB
Go
// Package paketordner packt Paketordner für den Server und legt neue an
|
||
// (→ KONZEPT.md 6.1, 11.1: hugin new, validate, try, upload).
|
||
package pkgdir
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"io/fs"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
const Manifest = "hugin.yaml"
|
||
|
||
// Zip packt den Ordner wie der Server ihn erwartet: hugin.yaml in der Wurzel,
|
||
// versteckte Dateien weggelassen, Symlinks abgelehnt.
|
||
func Zip(folder string) ([]byte, error) {
|
||
if _, err := os.Stat(filepath.Join(folder, Manifest)); err != nil {
|
||
return nil, fmt.Errorf("%s fehlt in %s", Manifest, folder)
|
||
}
|
||
var buffer bytes.Buffer
|
||
z := zip.NewWriter(&buffer)
|
||
err := filepath.WalkDir(folder, func(path string, d fs.DirEntry, err error) error {
|
||
if err != nil {
|
||
return err
|
||
}
|
||
rel, err := filepath.Rel(folder, path)
|
||
if err != nil || rel == "." {
|
||
return err
|
||
}
|
||
if strings.HasPrefix(d.Name(), ".") {
|
||
if d.IsDir() {
|
||
return filepath.SkipDir
|
||
}
|
||
return nil
|
||
}
|
||
if d.Type()&fs.ModeSymlink != 0 {
|
||
return fmt.Errorf("Symlinks sind nicht erlaubt: %s", rel)
|
||
}
|
||
if d.IsDir() {
|
||
return nil
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
w, err := z.Create(filepath.ToSlash(rel))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, err = w.Write(data)
|
||
return err
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := z.Close(); err != nil {
|
||
return nil, err
|
||
}
|
||
return buffer.Bytes(), nil
|
||
}
|
||
|
||
var idPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
|
||
|
||
var contentFile = map[string]string{
|
||
"skill": "SKILL.md",
|
||
"rules": "RULES.md",
|
||
"command": "COMMAND.md",
|
||
"subagent": "AGENT.md",
|
||
"mcp": "",
|
||
"plugin": "index.ts",
|
||
}
|
||
|
||
var scaffolds = map[string]string{
|
||
"skill": `# Titel
|
||
|
||
Was der Agent tun soll, Schritt für Schritt. Werkzeuge neutral beschreiben
|
||
(„lies die Datei“, nicht „nutze Read“), damit es in Claude Code und OpenCode passt.
|
||
`,
|
||
"rules": `# Regeln
|
||
|
||
- Eine Regel pro Zeile, kurz und prüfbar.
|
||
`,
|
||
"command": `Beschreibe die Aufgabe für {{input}}.
|
||
|
||
Platzhalter: {{name}} für benannte Argumente (in hugin.yaml unter arguments),
|
||
$ARGUMENTS für alles zusammen. Keine $1, $2 …: Claude Code und OpenCode zählen verschieden.
|
||
`,
|
||
"subagent": `Du bist … Beschreibe Rolle, Vorgehen und Form der Antwort.
|
||
`,
|
||
"plugin": `import { tool } from "@opencode-ai/plugin"
|
||
|
||
// OpenCode ruft jede exportierte Funktion beim Start auf. Erlaubte Importe: relative
|
||
// Dateien im Paket, node:…, bun und @opencode-ai/plugin; npm-Pakete noch nicht.
|
||
export const MeinPlugin = async ({ worktree }) => {
|
||
return {
|
||
tool: {
|
||
mein_werkzeug: tool({
|
||
description: "Was das Werkzeug tut",
|
||
args: {},
|
||
async execute() {
|
||
return "Ergebnis"
|
||
},
|
||
}),
|
||
},
|
||
}
|
||
}
|
||
`,
|
||
}
|
||
|
||
// New legt folder/<id> mit Manifest-Gerüst und Inhaltsdatei an.
|
||
func New(folder, kind, id string) (string, error) {
|
||
file, ok := contentFile[kind]
|
||
if !ok {
|
||
return "", fmt.Errorf("kind %q: erwartet skill, rules, command, subagent, mcp oder plugin", kind)
|
||
}
|
||
if !idPattern.MatchString(id) || len(id) > 64 {
|
||
return "", fmt.Errorf("id %q: nur a–z, 0–9 und einzelne Bindestriche, 1–64 Zeichen", id)
|
||
}
|
||
target := filepath.Join(folder, id)
|
||
if _, err := os.Stat(target); err == nil {
|
||
return "", fmt.Errorf("%s gibt es schon", target)
|
||
} else if !errors.Is(err, fs.ErrNotExist) {
|
||
return "", err
|
||
}
|
||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
if err := os.WriteFile(filepath.Join(target, Manifest), []byte(manifest(kind, id)), 0o644); err != nil {
|
||
return "", err
|
||
}
|
||
if file != "" {
|
||
if err := os.WriteFile(filepath.Join(target, file), []byte(scaffolds[kind]), 0o644); err != nil {
|
||
return "", err
|
||
}
|
||
}
|
||
return target, nil
|
||
}
|
||
|
||
func manifest(kind, id string) string {
|
||
scope := "tenant # public | tenant | project; das Präfix der id muss passen"
|
||
if strings.HasPrefix(id, "pub-") {
|
||
scope = "public # public | tenant | project"
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "id: %s\nkind: %s\nversion: 0.1.0\nowner: \"\" # Projekt-Slug\nscope: %s\n", id, kind, scope)
|
||
b.WriteString("description: >\n Wann und wofür der Agent das nutzt. Die Werkzeuge wählen danach aus,\n also konkret schreiben (1–1.024 Zeichen).\n")
|
||
switch kind {
|
||
case "command":
|
||
b.WriteString("arguments: [input] # Reihenfolge = Position beim Aufruf\n")
|
||
case "subagent":
|
||
b.WriteString("capability: read # read | write | shell | web, auch als Liste\n")
|
||
b.WriteString("model: standard # fast | standard | thorough | inherit\n")
|
||
case "plugin":
|
||
b.WriteString("targets: [opencode] # Plugins gibt es nur für OpenCode\n")
|
||
case "mcp":
|
||
b.WriteString("server:\n name: name\n url: https://…/mcp\n headers:\n Authorization: \"Bearer {{env:NAME_KEY}}\"\n")
|
||
b.WriteString(" # Lokaler Server statt url und headers: Code (und requirements.txt mit Hashes) ins Paket\n" +
|
||
" # local:\n # runtime: python\n # entry: mein_server # Modul oder Skript.py\n" +
|
||
" # python: \"3.10\"\n # env: [NAME_KEY]\n")
|
||
}
|
||
b.WriteString("# extra: # nur, was ein Werkzeug allein kann\n# claude: {}\n# opencode: {}\n")
|
||
return b.String()
|
||
}
|