hugin/cli/internal/pkgdir/pkgdir.go
2026-10-27 08:33:21 +01:00

168 lines
5.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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()
}