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

313 lines
8.6 KiB
Go

package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"github.com/spf13/cobra"
"hugin/cli/internal/api"
"hugin/cli/internal/bundle"
"hugin/cli/internal/config"
"hugin/cli/internal/keystore"
"hugin/cli/internal/localmcp"
"hugin/cli/internal/lock"
"hugin/cli/internal/pkgdir"
"hugin/cli/internal/reconcile"
)
// Lock-Datei von hugin try; hugin.lock bleibt dabei unberührt (→ KONZEPT.md 11.1).
const trialLock = localmcp.TrialLock
func newCommand() *cobra.Command {
var folder string
cmd := &cobra.Command{
Use: "new <kind> <id>",
Short: "legt einen Paketordner mit Manifest-Gerüst an (Art: skill, rules, command, subagent, mcp, plugin)",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
target, err := pkgdir.New(folder, args[0], args[1])
if err != nil {
return err
}
fmt.Printf("%s angelegt. hugin.yaml ausfüllen, dann: hugin validate %s\n", target, target)
return nil
},
}
cmd.Flags().StringVar(&folder, "in", ".", "Ordner, in dem der Paketordner entsteht")
return cmd
}
// authorClient: Server aus --server, sonst aus .hugin.yaml, sonst HUGIN_SERVER.
func authorClient(server string) (*api.Client, error) {
if server == "" {
if k, err := config.Load(repoFolder); err == nil {
server = k.Server
}
}
if server == "" {
server = os.Getenv("HUGIN_SERVER")
}
if server == "" {
return nil, errors.New("kein Server: --server angeben, HUGIN_SERVER setzen oder in einem Repo mit .hugin.yaml arbeiten")
}
key, err := keystore.Get(server)
if err != nil {
return nil, err
}
return api.New(server, key)
}
func folderArg(args []string) string {
if len(args) == 1 {
return args[0]
}
return "."
}
// printFindings druckt Linter-Befunde; true, wenn ein Fehler dabei ist.
func printFindings(findings []api.Finding) bool {
errs := false
for _, b := range findings {
location := ""
if b.File != "" {
location = " (" + b.File + ")"
}
fmt.Printf(" %-7s %s %s%s\n", b.Severity, b.Id, b.Text, location)
errs = errs || b.Severity == "error"
}
return errs
}
// linterError macht aus einer 422-Antwort mit Befunden eine lesbare Ausgabe.
func linterError(err error) error {
var f *api.APIError
if errors.As(err, &f) && len(f.Findings) > 0 {
fmt.Println("Der Linter hat das Paket abgelehnt:")
printFindings(f.Findings)
return findingError{"Paket nicht angenommen"}
}
return err
}
func validateCommand() *cobra.Command {
var server string
cmd := &cobra.Command{
Use: "validate [folder]",
Short: "prüft einen Paketordner über den Server und zeigt, was in jedem Werkzeug ankommt; speichert nichts",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := authorClient(server)
if err != nil {
return err
}
raw, err := pkgdir.Zip(folderArg(args))
if err != nil {
return err
}
p, err := client.Validate(raw)
if err != nil {
return linterError(err)
}
if len(p.Findings) > 0 {
fmt.Println("Befunde:")
}
errs := printFindings(p.Findings)
fmt.Printf("Risiko: %s\n", p.Risk)
if p.Compatibility != nil {
fmt.Printf("Kompatibilität: %s\n", *p.Compatibility)
}
toolNames := make([]string, 0, len(p.Preview))
for w := range p.Preview {
toolNames = append(toolNames, w)
}
sort.Strings(toolNames)
for _, w := range toolNames {
fmt.Printf("\n%s:\n", w)
for _, op := range p.Preview[w] {
fmt.Printf(" %-10v %v\n", op["kind"], target(op))
}
}
if errs {
return findingError{"Paket hat Fehler"}
}
return nil
},
}
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
return cmd
}
func target(op map[string]any) string {
if path, ok := op["path"]; ok {
return fmt.Sprint(path)
}
text := fmt.Sprint(op["file"])
if s, ok := op["key"]; ok {
text += fmt.Sprintf(" %v", s)
}
if b, ok := op["block"]; ok {
text += fmt.Sprintf(" Block %v", b)
}
return text
}
func tryCommand() *cobra.Command {
var server string
var remove, overwrite bool
cmd := &cobra.Command{
Use: "try [folder]",
Short: "installiert einen Entwurf zum Ausprobieren ins Repo, ohne hugin.lock zu ändern (--remove räumt auf)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
lockPath := filepath.Join(repoFolder, trialLock)
old, err := lock.LoadFrom(lockPath)
if err != nil {
return err
}
b := bundle.Empty()
if !remove {
client, err := authorClient(server)
if err != nil {
return err
}
raw, err := pkgdir.Zip(folderArg(args))
if err != nil {
return err
}
k, _ := config.Load(repoFolder)
detected, _ := detectTools(k)
details := map[string]string{}
for _, w := range detected {
details[w.name] = w.version
}
zip, err := client.Trial(raw, details)
if err != nil {
return linterError(err)
}
if b, err = bundle.FromZip(zip); err != nil {
return err
}
}
plan, err := reconcile.MakePlan(repoFolder, b, old, reconcile.Options{Overwrite: overwrite})
if err != nil {
return err
}
for _, f := range plan.Findings {
fmt.Fprintln(os.Stderr, "Befund:", f)
}
if plan.Open() {
return findingError{"nichts geändert; mit --overwrite setzt try die Dateien trotzdem"}
}
if err := plan.RunWithLock(repoFolder, lockPath); err != nil {
return err
}
if !remove {
home, err := localmcp.Home()
if err != nil {
return err
}
if err := install(b, home); err != nil {
return fmt.Errorf("Probe geschrieben, aber lokaler Server nicht installiert: %w", err)
}
}
if remove {
os.Remove(lockPath)
fmt.Printf("Probe entfernt: %d Dateien gelöscht.\n", len(plan.Delete))
return nil
}
fmt.Printf("Probe installiert: %d Dateien geschrieben (%d neu).\n", len(plan.Write), len(plan.New))
for _, rel := range plan.New {
fmt.Println(" ", rel)
}
fmt.Println("Jetzt in Claude Code oder OpenCode ausprobieren. Aufräumen mit: hugin try --remove")
return nil
},
}
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
cmd.Flags().BoolVar(&remove, "remove", false, "Dateien der letzten Probe wieder entfernen")
cmd.Flags().BoolVar(&overwrite, "overwrite", false, "vorhandene Dateien überschreiben")
return cmd
}
func uploadCommand() *cobra.Command {
var server string
var submit bool
cmd := &cobra.Command{
Use: "upload [folder]",
Short: "lädt einen Paketordner als Entwurf hoch (mit --submit gleich zur Prüfung)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := authorClient(server)
if err != nil {
return err
}
raw, err := pkgdir.Zip(folderArg(args))
if err != nil {
return err
}
v, err := client.Upload(raw)
if err != nil {
return linterError(err)
}
printFindings(v.Linter)
fmt.Printf("Entwurf %s %s gespeichert (Risiko %s).\n", v.Package, v.Version, v.Risk)
if submit {
if v, err = client.Submit(v.Package, v.Version); err != nil {
return err
}
fmt.Printf("Eingereicht: %s %s ist jetzt %s. Eine zweite Person prüft.\n", v.Package, v.Version, v.Status)
}
return nil
},
}
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
cmd.Flags().BoolVar(&submit, "submit", false, "nach dem Hochladen zur Prüfung einreichen")
return cmd
}
func searchCommand() *cobra.Command {
var server, kind string
cmd := &cobra.Command{
Use: "search [text]",
Short: "sucht im Katalog in ID und Beschreibung (für Teams und Autoren)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
client, err := authorClient(server)
if err != nil {
return err
}
text := ""
if len(args) == 1 {
text = args[0]
}
matches, err := client.Search(text, kind)
if err != nil {
return err
}
if len(matches) == 0 {
fmt.Println("Keine Pakete gefunden.")
return nil
}
for _, t := range matches {
fmt.Printf("%-32s %-9s %-8s %s\n", t.ID, t.Kind, t.Version, shorten(t.Description, 80))
}
fmt.Printf("\n%d Paket(e). Abonnieren: in .hugin.yaml unter packages eintragen, dann hugin sync.\n", len(matches))
return nil
},
}
cmd.Flags().StringVar(&server, "server", "", "Adresse von Hugin (sonst aus .hugin.yaml oder HUGIN_SERVER)")
cmd.Flags().StringVar(&kind, "kind", "", "nur diese Art: skill, rules, command, subagent, mcp, plugin")
return cmd
}
func shorten(text string, n int) string {
r := []rune(text)
if len(r) <= n {
return text
}
return string(r[:n-1]) + "…"
}