diff --git a/cmd/talk/cli.go b/cmd/talk/cli.go index a3306d2..8b7650e 100644 --- a/cmd/talk/cli.go +++ b/cmd/talk/cli.go @@ -42,10 +42,12 @@ var commands = []command{ handler: runSay, }, { - 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: "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. Das erste „--\" schirmt den Befehl komplett von den talk-Flags ab: `talk wrap -- foo --bar` reicht --bar an foo weiter. Der Exit-Code des Befehls wird durchgereicht; scheitert die Ansage selbst, bleibt er trotzdem. Label der Ansage: config `wrap.label` oder der Basisname des Befehls. wrap erbt die say-Flags (--wait, --now, --priority, --voice, --lang, --provider).", + register: addSayFlags, + handler: runWrap, }, { name: "mute", diff --git a/cmd/talk/cli_test.go b/cmd/talk/cli_test.go index 34b4d60..ef400e6 100644 --- a/cmd/talk/cli_test.go +++ b/cmd/talk/cli_test.go @@ -15,10 +15,11 @@ var wantSubcommands = []string{ } // wantStubCommands ist wantSubcommands ohne die implementierten Befehle — -// say (Task 02/04) sowie mute/unmute/status/cancel (Task 04, Daemon- -// Aufrufe) sind real; die restlichen bleiben Stubs bis zu ihren Tasks. +// say (Task 02/04), mute/unmute/status/cancel (Task 04, Daemon-Aufrufe) +// und wrap (Task 07, Befehlsummichtung mit Ansage) sind real; die +// restlichen bleiben Stubs bis zu ihren Tasks. var wantStubCommands = []string{ - "wrap", "voices", "test", "doctor", "setup", + "voices", "test", "doctor", "setup", } // runCLI ist der Test-Einstieg in run mit leerem stdin (kein Text über @@ -106,6 +107,24 @@ func TestSayHelpZeigtFlags(t *testing.T) { } } +// TestWrapHelpZeigtSayFlags: wrap erbt die say-Flags (mindestens --wait) +// und ist kein Stub mehr. +func TestWrapHelpZeigtSayFlags(t *testing.T) { + var out, errBuf bytes.Buffer + code := runCLI([]string{"wrap", "--help"}, &out, &errBuf) + if code != exitOK { + t.Fatalf("talk wrap --help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String()) + } + for _, f := range []string{"-wait", "-provider"} { + if !strings.Contains(out.String(), f) { + t.Errorf("talk wrap --help zeigt Flag %s nicht (Output: %q)", f, out.String()) + } + } + if strings.Contains(out.String(), "noch nicht implementiert") { + t.Errorf("talk wrap --help soll wrap nicht als Stub markieren: %q", out.String()) + } +} + func TestHelpSubcommand(t *testing.T) { t.Run("ohne Argument", func(t *testing.T) { var out, errBuf bytes.Buffer diff --git a/cmd/talk/daemon.go b/cmd/talk/daemon.go index 78406dc..d83b511 100644 --- a/cmd/talk/daemon.go +++ b/cmd/talk/daemon.go @@ -24,16 +24,18 @@ const ( // sayViaDaemon ist der Daemon-Pfad von say: Erkennung über GET /status, // dann enqueue (POST /speak). Default: sofort zurückkehren (non-blocking), -// stdout erhält die Item-ID; --wait pollt /status, bis die Ansage weder +// info erhält die Item-ID; --wait pollt /status, bis die Ansage weder // läuft noch wartet. Bei aktivem Mute verwirft der Daemon die Ansage — -// stdout sagt „muted". +// info sagt „muted". info ist der Writer für Informations-Token (Item-ID, +// muted, discarded): say reicht stdout, wrap stderr durch — stdout eines +// gewrappten Befehls darf nicht um talk-Zeilen verunreinigt werden. // // handled=false heißt: kein Daemon erreichbar → der Aufrufer geht in den // Degraded Mode. Stirbt der Daemon zwischen Erkennung und enqueue // (Netz-Fehler), gilt dasselbe; eine Daemon-Antwort mit Fehlerstatus // (z. B. 400) wird dagegen angezeigt — die Ansage war beim Daemon // angekommen und wurde bewusst abgelehnt. -func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stderr io.Writer) (code int, handled bool) { +func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) (code int, handled bool) { client := api.NewClient(cfg.Server.Port) probeCtx, cancelProbe := context.WithTimeout(context.Background(), daemonProbeTimeout) @@ -61,7 +63,7 @@ func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stde return 0, false } if resp.Muted { - fmt.Fprintln(stdout, "muted") + fmt.Fprintln(info, "muted") fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen") return exitOK, true } @@ -73,11 +75,11 @@ func sayViaDaemon(cfg config.Config, text string, opts *sayOptions, stdout, stde if resp.Toast { line += " (toast)" } - fmt.Fprintln(stdout, line) + fmt.Fprintln(info, line) fmt.Fprintln(stderr, "talk: "+discardHint(resp.Discarded, resp.Toast)) return exitOK, true } - fmt.Fprintln(stdout, resp.ID) + fmt.Fprintln(info, resp.ID) if !opts.wait { return exitOK, true } diff --git a/cmd/talk/degraded.go b/cmd/talk/degraded.go index 74c7f1b..5fad80c 100644 --- a/cmd/talk/degraded.go +++ b/cmd/talk/degraded.go @@ -31,12 +31,13 @@ const ( // dann Spracherkennung → Provider-Kette → Sink — identische Kette wie im // Daemon, nur im CLI-Prozess (blockierend; ohne Daemon ist --wait das // Default-Verhalten). Best-effort: Ohne Config-Dir läuft es ohne Lock und -// Mute weiter, statt zu scheitern. -func sayDegraded(cfg config.Config, text string, opts *sayOptions, stdout, stderr io.Writer) int { +// Mute weiter, statt zu scheitern. info ist der Writer für Informations- +// Token („muted"): say reicht stdout, wrap stderr durch. +func sayDegraded(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int { if dir, err := config.Dir(getenv); err == nil { mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)} if mute.Muted() { - fmt.Fprintln(stdout, "muted") + fmt.Fprintln(info, "muted") fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen") return exitOK } diff --git a/cmd/talk/main.go b/cmd/talk/main.go index 29fd28e..b12a5fe 100644 --- a/cmd/talk/main.go +++ b/cmd/talk/main.go @@ -1,11 +1,14 @@ // talk ist die CLI für Sprach-Ausgaben (talk 2.0). // -// Dieser Stand (Task 04): say (und der Default-Aufruf `talk "Text"`) reicht +// Dieser Stand (Task 07): say (und der Default-Aufruf `talk "Text"`) reicht // Ansagen in den Daemon talkd ein — non-blocking, --wait pollt bis zur // Fertigstellung, --now springt vor. Ohne Daemon fällt say in den Degraded // Mode (Lock-Datei, sequenziell lokal, mute respektiert). mute/unmute/ -// status/cancel sind Daemon-Aufrufe mit plain Output. Die übrigen Befehle -// sind Stubs; TUI und weitere Aufgaben folgen in den Tasks 05–09. +// status/cancel sind Daemon-Aufrufe mit plain Output. `talk wrap -- ` +// führt Befehle aus (stdin/stdout/stderr durchgereicht, Exit-Code +// durchgereicht) und sagt danach Erfolg/Misserfolg und Dauer an. Die +// übrigen Befehle sind Stubs; TUI und weitere Aufgaben folgen in den +// Tasks 08–09. package main import "os" diff --git a/cmd/talk/say.go b/cmd/talk/say.go index 2001505..f4dd7ae 100644 --- a/cmd/talk/say.go +++ b/cmd/talk/say.go @@ -53,15 +53,10 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io. } } - // Validierung vor jedem Daemon-Kontakt: Benutzungsfehler sollen - // schnell und ohne Netzwerk-Umwege auffallen. - if opts.lang != "" && opts.lang != "de" && opts.lang != "en" { - fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang) - return exitFailure - } - if opts.priority != "" && !queue.Priority(opts.priority).Valid() { - fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority) - return exitFailure + // Validierung vor jedem Daemon-Kontakt (auch wrap erbt die say-Flags): + // Benutzungsfehler sollen schnell und ohne Netzwerk-Umwege auffallen. + if code, bad := validateSayOptions(opts, stderr); bad { + return code } cfg, err := config.Load(config.Options{Getenv: getenv}) @@ -78,6 +73,21 @@ func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io. return sayDegraded(cfg, text, opts, stdout, stderr) } +// validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de | +// en, --priority auf low | normal | high. Rückgabe (code, true) heißt: +// Benutzungsfehler, bereits auf stderr gemeldet. +func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) { + if opts.lang != "" && opts.lang != "de" && opts.lang != "en" { + fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang) + return exitFailure, true + } + if opts.priority != "" && !queue.Priority(opts.priority).Valid() { + fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority) + return exitFailure, true + } + return 0, false +} + // pickProvider löst den Provider-Pin auf: CLI-Flag --provider schlägt // `provider = "…"` aus der config.toml; leer = komplette Default-Kette. func pickProvider(flagValue, cfgValue string) string { diff --git a/cmd/talk/wrap.go b/cmd/talk/wrap.go new file mode 100644 index 0000000..db128d9 --- /dev/null +++ b/cmd/talk/wrap.go @@ -0,0 +1,195 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os/exec" + "path/filepath" + "strings" + "time" + + "code.m3ta.dev/m3tam3re/talk/internal/config" + "code.m3ta.dev/m3tam3re/talk/internal/langdetect" +) + +// Kind nicht gefunden — Shell-Konvention 127. +const exitNotFound = 127 + +// runWrap ist der wrap-Eingangspfad (Task 07): `talk wrap -- +// [Argumente …]` führt den Befehl aus — stdin/stdout/stderr werden +// durchgereicht, stdout gehört allein dem Befehl —, misst die Wandzeit und +// sagt danach das Ergebnis an: „