diff --git a/cmd/talk/cli.go b/cmd/talk/cli.go new file mode 100644 index 0000000..cfa91f9 --- /dev/null +++ b/cmd/talk/cli.go @@ -0,0 +1,259 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "io" +) + +// Exit-Codes. +const ( + exitOK = 0 // Erfolg (auch --help, --version) + exitFailure = 1 // Befehl fehlgeschlagen oder noch nicht implementiert + exitUsage = 2 // Falsche Benutzung (unbekannte Flags, kein Befehl) +) + +// command ist ein Eintrag der Befehlstabelle. Die Tabelle ist bewusst +// datengetrieben: Spätere Tasks (z. B. die TUI-Befehle aus Task 09) ergänzen +// hier Einträge bzw. Register-Funktionen, ohne die Dispatch-Logik in run() +// anzufassen. +type command struct { + name string // Subcommand-Name (bindende CLI-Surface aus der Spec) + short string // Kurzbeschreibung für `talk --help` + args string // Positionsargumente-Hinweis für die Usage-Zeile + detail string // Beschreibung für `talk --help` + register func(fs *flag.FlagSet) // hängt die Flags des Befehls an den FlagSet +} + +// commands ist die vollständige Befehlstabelle. Die Namen sind Teil der +// bindenden CLI-Surface (Spec: „CLI-Surface (exakt)“) — nicht umbenennen. +var commands = []command{ + { + name: "say", + short: "Text ansagen (Default-Befehl)", + args: `"Text"`, + detail: "Spricht den Text über die Provider-Kette aus. Ohne Text-Argument wird stdin gelesen. `talk \"Text\"` ist die Abkürzung für `talk say \"Text\"`.", + register: addSayFlags, + }, + { + name: "wrap", + short: "Befehl ausführen und Ergebnis ansagen", + args: "-- [Argumente …]", + detail: "Führt den Befehl aus (stdin/stdout/stderr werden durchgereicht) und spricht danach Erfolg bzw. Misserfolg und Dauer.", + }, + { + name: "mute", + short: "Ansagen dauerhaft stummschalten", + detail: "Aktiviert den persistenten Mute-State des Daemons (Datei im Config-Verzeichnis).", + }, + { + name: "unmute", + short: "Stummschaltung aufheben", + detail: "Hebt den persistenten Mute-State des Daemons wieder auf.", + }, + { + name: "status", + short: "Daemon-Status anzeigen", + detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version.", + }, + { + name: "cancel", + short: "Laufende und geplante Ansagen abbrechen", + detail: "Bricht die laufende Ansage ab und leert die Queue des Daemons.", + }, + { + name: "voices", + short: "Verfügbare Stimmen anzeigen", + detail: "Listet die Stimmen aller Provider (Defaults + Config-Overrides) als Tabelle.", + }, + { + name: "test", + short: "Test-Ansage ausgeben", + detail: "Spricht eine feste Testphrase und zeigt den verwendeten Provider samt Latenz.", + }, + { + name: "doctor", + short: "Setup-Diagnose anzeigen", + detail: "Prüft Daemon, Audio-Sink, Provider-Keys und Config und berichtet zeilenweise mit ✔/✘/⚠.", + }, + { + name: "setup", + short: "Geführte Ersteinrichtung (Wizard)", + detail: "Interaktiver Wizard: Provider-Keys, Stimmen probehören, config.toml schreiben. Ohne TTY: plain.", + }, +} + +// addSayFlags registriert die Flags des say-Befehls. Sie gelten auch für den +// Default-Pfad `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der +// Spec). Das Verhalten entsteht in Task 02 (direkter Pfad) bzw. Task 04 +// (Daemon-Pfad). +func addSayFlags(fs *flag.FlagSet) { + fs.Bool("wait", false, "warten, bis die Ansage fertig abgespielt ist") + fs.Bool("now", false, "Ansage sofort ausgeben (springt die Warteschlange)") + fs.String("priority", "normal", "Priorität: low | normal | high") + fs.String("voice", "", "Stimme als provider-spezifische ID") + fs.String("lang", "", "Sprache: de | en (Default: automatische Erkennung)") + fs.String("provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin") +} + +// run ist der Einstiegspunkt der CLI (Argumente ohne Programmnamen). Alle +// Ausgaben gehen auf die übergebenen Writer, damit run direkt und ohne +// Prozessstart testbar ist. Rückgabe ist der Exit-Code. +func run(argv []string, stdout, stderr io.Writer) int { + if len(argv) == 0 { + // Task 02 macht aus dem nackten Aufruf ggf. einen stdin-Leser + // (Text kommt dann über die Pipe); bis dahin: Benutzungsfehler. + writeUsage(stderr) + return exitUsage + } + + if argv[0] == "help" { + if len(argv) > 1 { + if cmd, ok := lookupCommand(argv[1]); ok { + writeCommandHelp(cmd, stdout) + return exitOK + } + fmt.Fprintf(stderr, "talk: unbekannter Befehl %q\n", argv[1]) + return exitUsage + } + writeUsage(stdout) + return exitOK + } + + if cmd, ok := lookupCommand(argv[0]); ok { + return runCommand(cmd, argv[1:], stdout, stderr) + } + + // Kein bekannter Befehl: Default-Pfad `talk "Text"` (mappt auf say) + // bzw. Root-Flags (--version, --help). Führt aktuell auf den say-Stub. + return runDefault(argv, stdout, stderr) +} + +func lookupCommand(name string) (command, bool) { + for _, cmd := range commands { + if cmd.name == name { + return cmd, true + } + } + return command{}, false +} + +// newCommandFlagSet baut den FlagSet eines Befehls: eigene Flags plus ein +// explizites --help. Die Default-Usage wird stummgeschaltet, damit Hilfetext +// ausschließlich über writeCommandHelp (eine Quelle) ausgegeben wird. +func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool) { + fs := flag.NewFlagSet("talk "+cmd.name, flag.ContinueOnError) + fs.SetOutput(out) + fs.Usage = func() {} + if cmd.register != nil { + cmd.register(fs) + } + help := fs.Bool("help", false, "Hilfe zu diesem Befehl anzeigen") + return fs, help +} + +// runCommand führt einen benannten Subcommand aus — in diesem Stand als Stub. +func runCommand(cmd command, args []string, stdout, stderr io.Writer) int { + fs, help := newCommandFlagSet(cmd, stderr) + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h + writeCommandHelp(cmd, stdout) + return exitOK + } + // Die Fehlermeldung selbst hat der FlagSet bereits ausgegeben. + fmt.Fprintf(stderr, "Hinweis: `talk %s --help` zeigt die Verwendung.\n", cmd.name) + return exitUsage + } + if *help { + writeCommandHelp(cmd, stdout) + return exitOK + } + return notImplemented(cmd.name, stderr) +} + +// runDefault behandelt Aufrufe ohne bekannten Subcommand: `talk "Text"`, +// `talk --version`, `talk --help` sowie say-Flags vor dem Text. +func runDefault(argv []string, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("talk", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() {} + showVersion := fs.Bool("version", false, "Version anzeigen") + help := fs.Bool("help", false, "diese Hilfe anzeigen") + addSayFlags(fs) + + if err := fs.Parse(argv); err != nil { + if errors.Is(err, flag.ErrHelp) { + writeUsage(stdout) + return exitOK + } + fmt.Fprintln(stderr, "Hinweis: `talk --help` zeigt die Verwendung.") + return exitUsage + } + if *showVersion { + fmt.Fprintln(stdout, versionString()) + return exitOK + } + if *help { + writeUsage(stdout) + return exitOK + } + return notImplemented("say", stderr) +} + +// versionString rendert die Versionsausgabe: `talk version ` plus +// Commit-Hash in Klammern, falls per ldflags injiziert. +func versionString() string { + if commit == "" { + return fmt.Sprintf("talk version %s", version) + } + return fmt.Sprintf("talk version %s (%s)", version, commit) +} + +// notImplemented meldet einen noch nicht implementierten Befehl auf stderr +// und liefert den dafür vorgesehenen Exit-Code (non-zero). +func notImplemented(name string, stderr io.Writer) int { + fmt.Fprintf(stderr, "talk %s is not implemented yet\n", name) + return exitFailure +} + +// writeUsage schreibt die Gesamt-Hilfe; die Befehlsliste wird direkt aus der +// Befehlstabelle generiert, damit Hilfe und Dispatch nicht auseinanderlaufen. +func writeUsage(w io.Writer) { + fmt.Fprint(w, `talk — Sprach-Ausgaben für Skripte und KI-Agenten + +Usage: + talk [Flags] "Text" Text ansagen (Abkürzung für: talk say) + talk [Flags] Befehl ausführen + talk help [Befehl] Hilfe anzeigen + +Befehle: +`) + for _, cmd := range commands { + fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.short) + } + fmt.Fprint(w, ` +Flags: + --version Version anzeigen + --help diese Hilfe anzeigen + +Die Flags von say gelten auch für talk "Text"; Details: talk say --help +`) +} + +// writeCommandHelp schreibt die Hilfe eines einzelnen Befehls inkl. Flags. +func writeCommandHelp(cmd command, w io.Writer) { + fmt.Fprintf(w, "Usage:\n talk %s", cmd.name) + if cmd.args != "" { + fmt.Fprintf(w, " %s", cmd.args) + } + fmt.Fprintln(w, " [Flags]") + fmt.Fprintln(w) + if cmd.detail != "" { + fmt.Fprintf(w, "%s\n\n", cmd.detail) + } + fmt.Fprintln(w, "Flags:") + fs, _ := newCommandFlagSet(cmd, w) + fs.PrintDefaults() + fmt.Fprintln(w, "\nStatus: noch nicht implementiert (WIP).") +} diff --git a/cmd/talk/cli_test.go b/cmd/talk/cli_test.go new file mode 100644 index 0000000..6b83aa2 --- /dev/null +++ b/cmd/talk/cli_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +// wantSubcommands ist die bindende CLI-Surface aus der Spec — bewusst als +// hartkodierte Liste getestet (nicht aus der Befehlstabelle abgeleitet), +// damit die Tests anschlagen, wenn Tabelle und Spec auseinanderlaufen. +var wantSubcommands = []string{ + "say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup", +} + +func TestHelpListetAlleSubcommands(t *testing.T) { + var out, errBuf bytes.Buffer + code := run([]string{"--help"}, &out, &errBuf) + if code != exitOK { + t.Fatalf("--help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String()) + } + lines := strings.Split(out.String(), "\n") + for _, name := range wantSubcommands { + found := false + for _, ln := range lines { + if strings.HasPrefix(strings.TrimLeft(ln, " "), name+" ") { + found = true + break + } + } + if !found { + t.Errorf("--help listet Befehl %q nicht (Output: %q)", name, out.String()) + } + } +} + +func TestVersionDefault(t *testing.T) { + var out, errBuf bytes.Buffer + code := run([]string{"--version"}, &out, &errBuf) + if code != exitOK { + t.Fatalf("--version: Exit-Code = %d, want %d", code, exitOK) + } + if got, want := out.String(), "talk version dev\n"; got != want { + t.Errorf("--version Output = %q, want %q", got, want) + } +} + +func TestVersionStringMitCommit(t *testing.T) { + oldCommit := commit + commit = "c0ffee1" + defer func() { commit = oldCommit }() + + if got, want := versionString(), "talk version dev (c0ffee1)"; got != want { + t.Errorf("versionString() = %q, want %q", got, want) + } +} + +func TestSubcommandStubs(t *testing.T) { + for _, name := range wantSubcommands { + t.Run(name, func(t *testing.T) { + var out, errBuf bytes.Buffer + code := run([]string{name}, &out, &errBuf) + if code == exitOK { + t.Errorf("talk %s: Exit-Code 0, want non-zero für Stub", name) + } + if !strings.Contains(errBuf.String(), "not implemented yet") { + t.Errorf("talk %s: stderr = %q, will Hinweis auf nicht implementiert", name, errBuf.String()) + } + if !strings.Contains(errBuf.String(), name) { + t.Errorf("talk %s: stderr nennt den Befehlsnamen nicht: %q", name, errBuf.String()) + } + if out.Len() != 0 { + t.Errorf("talk %s: unerwartete stdout-Ausgabe %q", name, out.String()) + } + }) + } +} + +func TestDefaultAufrufMapptAufSay(t *testing.T) { + var out, errBuf bytes.Buffer + code := run([]string{"Hallo Welt"}, &out, &errBuf) + if code != exitFailure { + t.Fatalf(`talk "Hallo Welt": Exit-Code = %d, want %d`, code, exitFailure) + } + if !strings.Contains(errBuf.String(), "talk say is not implemented yet") { + t.Errorf("Default-Aufruf mappt offenbar nicht auf say, stderr = %q", errBuf.String()) + } +} + +func TestSayHelpZeigtFlags(t *testing.T) { + var out, errBuf bytes.Buffer + code := run([]string{"say", "--help"}, &out, &errBuf) + if code != exitOK { + t.Fatalf("talk say --help: Exit-Code = %d, want %d", code, exitOK) + } + for _, f := range []string{"-wait", "-now", "-priority", "-voice", "-lang", "-provider"} { + if !strings.Contains(out.String(), f) { + t.Errorf("talk say --help zeigt Flag %s nicht (Output: %q)", f, out.String()) + } + } +} + +func TestHelpSubcommand(t *testing.T) { + t.Run("ohne Argument", func(t *testing.T) { + var out, errBuf bytes.Buffer + if code := run([]string{"help"}, &out, &errBuf); code != exitOK { + t.Fatalf("talk help: Exit-Code = %d, want %d", code, exitOK) + } + for _, name := range wantSubcommands { + if !strings.Contains(out.String(), name) { + t.Errorf("talk help listet %q nicht", name) + } + } + }) + t.Run("mit Befehl", func(t *testing.T) { + var out, errBuf bytes.Buffer + if code := run([]string{"help", "mute"}, &out, &errBuf); code != exitOK { + t.Fatalf("talk help mute: Exit-Code = %d, want %d", code, exitOK) + } + if !strings.Contains(out.String(), "talk mute") { + t.Errorf("talk help mute zeigt Usage von mute nicht: %q", out.String()) + } + }) +} + +func TestOhneArgumenteZeigtUsage(t *testing.T) { + var out, errBuf bytes.Buffer + code := run(nil, &out, &errBuf) + if code != exitUsage { + t.Fatalf("talk ohne Argumente: Exit-Code = %d, want %d", code, exitUsage) + } + if !strings.Contains(errBuf.String(), "Usage") { + t.Errorf("talk ohne Argumente: Usage fehlt auf stderr: %q", errBuf.String()) + } +} + +func TestUnbekanntesFlag(t *testing.T) { + var out, errBuf bytes.Buffer + code := run([]string{"--bogus"}, &out, &errBuf) + if code != exitUsage { + t.Fatalf("talk --bogus: Exit-Code = %d, want %d", code, exitUsage) + } + if !strings.Contains(errBuf.String(), "bogus") { + t.Errorf("talk --bogus: Fehlermeldung fehlt auf stderr: %q", errBuf.String()) + } +} diff --git a/cmd/talk/main.go b/cmd/talk/main.go new file mode 100644 index 0000000..41cd357 --- /dev/null +++ b/cmd/talk/main.go @@ -0,0 +1,21 @@ +// talk ist die CLI für Sprach-Ausgaben (talk 2.0). +// +// Dieser Stand (Task 01) liefert das Gerüst: Subcommand-Dispatch, --version +// und alle Befehle als Stubs. Die Funktionalität entsteht in den Tasks 02–09. +package main + +import "os" + +// Build-Informationen. Werden beim Release per -ldflags injiziert, z. B. +// +// go build -ldflags "-X main.version=1.0.0 -X main.commit=abc1234" ./cmd/talk +// +// Defaults: version "dev", commit leer (siehe auch .goreleaser.yaml, Task 11). +var ( + version = "dev" + commit = "" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/cmd/talkd/main.go b/cmd/talkd/main.go new file mode 100644 index 0000000..a4b709b --- /dev/null +++ b/cmd/talkd/main.go @@ -0,0 +1,13 @@ +// talkd ist der Daemon für talk 2.0 (Queue, Mute-State, HTTP-API, MCP). +// +// Dieser Stand (Task 01) ist ein Platzhalter: talkd startet, loggt eine Zeile +// und beendet sich mit Exit-Code 0. Die eigentliche Daemon-Implementierung +// (HTTP-Server auf 127.0.0.1, serialisierte Ansage-Queue, persistentes Mute) +// entsteht in Task 04. +package main + +import "log" + +func main() { + log.Println("talkd: Platzhalter — Daemon-Implementierung folgt in Task 04") +}