663 lines
25 KiB
Diff
663 lines
25 KiB
Diff
# Review package: c8f3902..HEAD
|
||||
|
|
|
|||
|
|
## Commits
|
|||
|
|
fa4f193 ci: add gitea actions test workflow (vet, gofmt, test, build)
|
|||
|
|
33b97a5 feat(cli): add subcommand dispatch, --version and stub commands
|
|||
|
|
24dfe4d chore: bootstrap go module, license, readme and package skeleton
|
|||
|
|
|
|||
|
|
## Files changed
|
|||
|
|
.gitea/workflows/test.yml | 30 ++++++
|
|||
|
|
.gitignore | 10 ++
|
|||
|
|
LICENSE | 21 ++++
|
|||
|
|
README.md | 49 +++++++++
|
|||
|
|
cmd/talk/cli.go | 259 +++++++++++++++++++++++++++++++++++++++++++++
|
|||
|
|
cmd/talk/cli_test.go | 146 +++++++++++++++++++++++++
|
|||
|
|
cmd/talk/main.go | 21 ++++
|
|||
|
|
cmd/talkd/main.go | 13 +++
|
|||
|
|
go.mod | 3 +
|
|||
|
|
internal/api/doc.go | 4 +
|
|||
|
|
internal/audio/doc.go | 5 +
|
|||
|
|
internal/config/doc.go | 7 ++
|
|||
|
|
internal/langdetect/doc.go | 4 +
|
|||
|
|
internal/notify/doc.go | 4 +
|
|||
|
|
internal/provider/doc.go | 6 ++
|
|||
|
|
internal/queue/doc.go | 6 ++
|
|||
|
|
16 files changed, 588 insertions(+)
|
|||
|
|
|
|||
|
|
## Diff
|
|||
|
|
.gitea/workflows/test.yml --- YAML
|
|||
|
|
1 name: test
|
|||
|
|
2
|
|||
|
|
3 on:
|
|||
|
|
4 push:
|
|||
|
|
5 pull_request:
|
|||
|
|
6
|
|||
|
|
7 jobs:
|
|||
|
|
8 test:
|
|||
|
|
9 runs-on: ubuntu-latest
|
|||
|
|
10 steps:
|
|||
|
|
11 - name: Checkout
|
|||
|
|
12 uses: actions/checkout@v4
|
|||
|
|
13 - name: Go einrichten
|
|||
|
|
14 uses: actions/setup-go@v5
|
|||
|
|
15 with:
|
|||
|
|
16 go-version-file: go.mod
|
|||
|
|
17 - name: go vet
|
|||
|
|
18 run: go vet ./...
|
|||
|
|
19 - name: gofmt-Check
|
|||
|
|
20 run: |
|
|||
|
|
21 unformatted=$(gofmt -l .)
|
|||
|
|
22 if [ -n "$unformatted" ]; then
|
|||
|
|
23 echo "Folgende Dateien sind nicht gofmt-formatiert:"
|
|||
|
|
24 echo "$unformatted"
|
|||
|
|
25 exit 1
|
|||
|
|
26 fi
|
|||
|
|
27 - name: go test
|
|||
|
|
28 run: go test ./...
|
|||
|
|
29 - name: go build
|
|||
|
|
30 run: go build ./...
|
|||
|
|
31
|
|||
|
|
|
|||
|
|
.gitignore --- Text
|
|||
|
|
1 # Go-Binaries
|
|||
|
|
2 /talk
|
|||
|
|
3 /talkd
|
|||
|
|
4 /talk.exe
|
|||
|
|
5 /talkd.exe
|
|||
|
|
6 *.test
|
|||
|
|
7 *.out
|
|||
|
|
8
|
|||
|
|
9 # Release-Artefakte (GoReleaser, Task 11)
|
|||
|
|
10 /dist/
|
|||
|
|
11
|
|||
|
|
|
|||
|
|
LICENSE --- Text
|
|||
|
|
1 MIT License
|
|||
|
|
2
|
|||
|
|
3 Copyright (c) 2026 m3tam3re
|
|||
|
|
4
|
|||
|
|
5 Permission is hereby granted, free of charge, to any person obtaining a copy
|
|||
|
|
6 of this software and associated documentation files (the "Software"), to deal
|
|||
|
|
7 in the Software without restriction, including without limitation the rights
|
|||
|
|
8 to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|||
|
|
9 copies of the Software, and to permit persons to whom the Software is
|
|||
|
|
10 furnished to do so, subject to the following conditions:
|
|||
|
|
11
|
|||
|
|
12 The above copyright notice and this permission notice shall be included in all
|
|||
|
|
13 copies or substantial portions of the Software.
|
|||
|
|
14
|
|||
|
|
15 THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|||
|
|
16 IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|||
|
|
17 FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||
|
|
18 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|||
|
|
19 LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||
|
|
20 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||
|
|
21 SOFTWARE.
|
|||
|
|
22
|
|||
|
|
|
|||
|
|
README.md --- Text
|
|||
|
|
1 # talk
|
|||
|
|
2
|
|||
|
|
3 > **Status: WIP** — aktive Entwicklung (talk 2.0). Binde Spezifikation:
|
|||
|
|
4 > [`.scratch/talk-2-0/spec.md`](.scratch/talk-2-0/spec.md) ·
|
|||
|
|
5 > Implementierungsplan: [`.scratch/talk-2-0/plan.md`](.scratch/talk-2-0/plan.md)
|
|||
|
|
6
|
|||
|
|
7 **talk** macht Skripte und KI-Agenten hörbar: Ein Aufruf — `talk "Build fertig"` —
|
|||
|
|
8 spricht eine kurze Nachricht über eine TTS-Provider-Kette
|
|||
|
|
9 (ElevenLabs → OpenAI → Edge-TTS → OS-Stimme), serialisiert durch den Daemon
|
|||
|
|
10 `talkd`. Cross-platform (Windows/macOS/Linux, amd64/arm64), statische Binaries,
|
|||
|
|
11 keine Runtime-Abhängigkeiten.
|
|||
|
|
12
|
|||
|
|
13 ## Befehle
|
|||
|
|
14
|
|||
|
|
15 | Befehl | Zweck |
|
|||
|
|
16 | --- | --- |
|
|||
|
|
17 | `talk "Text"` | Ansage — Abkürzung für `talk say`, non-blocking |
|
|||
|
|
18 | `talk say "Text"` | Ansage (Text als Argument oder von stdin) |
|
|||
|
|
19 | `talk wrap -- <Befehl>` | Befehl ausführen, Erfolg/Misserfolg + Dauer ansagen |
|
|||
|
|
20 | `talk mute` / `talk unmute` | Ansagen dauerhaft stummschalten / Stummschaltung aufheben |
|
|||
|
|
21 | `talk status` | Daemon-Status (Queue, Mute, Provider, Version) |
|
|||
|
|
22 | `talk cancel` | laufende und geplante Ansagen abbrechen |
|
|||
|
|
23 | `talk voices` | verfügbare Stimmen anzeigen |
|
|||
|
|
24 | `talk test` | Test-Ansage ausgeben |
|
|||
|
|
25 | `talk doctor` | Selbstdiagnose des Setups |
|
|||
|
|
26 | `talk setup` | geführte Ersteinrichtung (Wizard) |
|
|||
|
|
27
|
|||
|
|
28 Say-Flags (gelten auch für `talk "Text"`): `--wait`, `--now`, `--priority`,
|
|||
|
|
29 `--voice`, `--lang`, `--provider`.
|
|||
|
|
30
|
|||
|
|
31 **Hinweis:** Alle Befehle sind in diesem Stand Stubs und melden
|
|||
|
|
32 `not implemented yet` (Exit-Code ≠ 0). Die Implementierung entsteht
|
|||
|
|
33 Aufgabenweise gemäß Plan; `--version` und `--help` funktionieren.
|
|||
|
|
34
|
|||
|
|
35 ## Entwicklung
|
|||
|
|
36
|
|||
|
|
37 ```console
|
|||
|
|
38 go build ./... # bauen
|
|||
|
|
39 go test ./... # Tests
|
|||
|
|
40 go vet ./... # statische Analyse
|
|||
|
|
41 gofmt -l . # Formatierung prüfen (leerer Output = ok)
|
|||
|
|
42 ```
|
|||
|
|
43
|
|||
|
|
44 CI: `.gitea/workflows/test.yml` (Gitea Actions) läuft vet, gofmt-Check, Tests
|
|||
|
|
45 und Build bei jedem Push und Pull Request.
|
|||
|
|
46
|
|||
|
|
47 ## Lizenz
|
|||
|
|
48
|
|||
|
|
49 MIT — siehe [LICENSE](LICENSE).
|
|||
|
|
50
|
|||
|
|
|
|||
|
|
cmd/talk/cli.go --- Go
|
|||
|
|
1 package main
|
|||
|
|
2
|
|||
|
|
3 import (
|
|||
|
|
4 "errors"
|
|||
|
|
5 "flag"
|
|||
|
|
6 "fmt"
|
|||
|
|
7 "io"
|
|||
|
|
8 )
|
|||
|
|
9
|
|||
|
|
10 // Exit-Codes.
|
|||
|
|
11 const (
|
|||
|
|
12 exitOK = 0 // Erfolg (auch --help, --version)
|
|||
|
|
13 exitFailure = 1 // Befehl fehlgeschlagen oder noch nicht implementiert
|
|||
|
|
14 exitUsage = 2 // Falsche Benutzung (unbekannte Flags, kein Befehl)
|
|||
|
|
15 )
|
|||
|
|
16
|
|||
|
|
17 // command ist ein Eintrag der Befehlstabelle. Die Tabelle ist bewusst
|
|||
|
|
18 // datengetrieben: Spätere Tasks (z. B. die TUI-Befehle aus Task 09) ergänzen
|
|||
|
|
19 // hier Einträge bzw. Register-Funktionen, ohne die Dispatch-Logik in run()
|
|||
|
|
20 // anzufassen.
|
|||
|
|
21 type command struct {
|
|||
|
|
22 name string // Subcommand-Name (bindende CLI-Surface aus der Spec)
|
|||
|
|
23 short string // Kurzbeschreibung für `talk --help`
|
|||
|
|
24 args string // Positionsargumente-Hinweis für die Usage-Zeile
|
|||
|
|
25 detail string // Beschreibung für `talk <Befehl> --help`
|
|||
|
|
26 register func(fs *flag.FlagSet) // hängt die Flags des Befehls an den FlagSet
|
|||
|
|
27 }
|
|||
|
|
28
|
|||
|
|
29 // commands ist die vollständige Befehlstabelle. Die Namen sind Teil der
|
|||
|
|
30 // bindenden CLI-Surface (Spec: „CLI-Surface (exakt)“) — nicht umbenennen.
|
|||
|
|
31 var commands = []command{
|
|||
|
|
32 {
|
|||
|
|
33 name: "say",
|
|||
|
|
34 short: "Text ansagen (Default-Befehl)",
|
|||
|
|
35 args: `"Text"`,
|
|||
|
|
36 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\"`.",
|
|||
|
|
37 register: addSayFlags,
|
|||
|
|
38 },
|
|||
|
|
39 {
|
|||
|
|
40 name: "wrap",
|
|||
|
|
41 short: "Befehl ausführen und Ergebnis ansagen",
|
|||
|
|
42 args: "-- <Befehl> [Argumente …]",
|
|||
|
|
43 detail: "Führt den Befehl aus (stdin/stdout/stderr werden durchgereicht) und spricht danach Erfolg bzw. Misserfolg und Dauer.",
|
|||
|
|
44 },
|
|||
|
|
45 {
|
|||
|
|
46 name: "mute",
|
|||
|
|
47 short: "Ansagen dauerhaft stummschalten",
|
|||
|
|
48 detail: "Aktiviert den persistenten Mute-State des Daemons (Datei im Config-Verzeichnis).",
|
|||
|
|
49 },
|
|||
|
|
50 {
|
|||
|
|
51 name: "unmute",
|
|||
|
|
52 short: "Stummschaltung aufheben",
|
|||
|
|
53 detail: "Hebt den persistenten Mute-State des Daemons wieder auf.",
|
|||
|
|
54 },
|
|||
|
|
55 {
|
|||
|
|
56 name: "status",
|
|||
|
|
57 short: "Daemon-Status anzeigen",
|
|||
|
|
58 detail: "Zeigt Queue, aktuelles Item, Mute-State, aktiven Provider und Daemon-Version.",
|
|||
|
|
59 },
|
|||
|
|
60 {
|
|||
|
|
61 name: "cancel",
|
|||
|
|
62 short: "Laufende und geplante Ansagen abbrechen",
|
|||
|
|
63 detail: "Bricht die laufende Ansage ab und leert die Queue des Daemons.",
|
|||
|
|
64 },
|
|||
|
|
65 {
|
|||
|
|
66 name: "voices",
|
|||
|
|
67 short: "Verfügbare Stimmen anzeigen",
|
|||
|
|
68 detail: "Listet die Stimmen aller Provider (Defaults + Config-Overrides) als Tabelle.",
|
|||
|
|
69 },
|
|||
|
|
70 {
|
|||
|
|
71 name: "test",
|
|||
|
|
72 short: "Test-Ansage ausgeben",
|
|||
|
|
73 detail: "Spricht eine feste Testphrase und zeigt den verwendeten Provider samt Latenz.",
|
|||
|
|
74 },
|
|||
|
|
75 {
|
|||
|
|
76 name: "doctor",
|
|||
|
|
77 short: "Setup-Diagnose anzeigen",
|
|||
|
|
78 detail: "Prüft Daemon, Audio-Sink, Provider-Keys und Config und berichtet zeilenweise mit ✔/✘/⚠.",
|
|||
|
|
79 },
|
|||
|
|
80 {
|
|||
|
|
81 name: "setup",
|
|||
|
|
82 short: "Geführte Ersteinrichtung (Wizard)",
|
|||
|
|
83 detail: "Interaktiver Wizard: Provider-Keys, Stimmen probehören, config.toml schreiben. Ohne TTY: plain.",
|
|||
|
|
84 },
|
|||
|
|
85 }
|
|||
|
|
86
|
|||
|
|
87 // addSayFlags registriert die Flags des say-Befehls. Sie gelten auch für den
|
|||
|
|
88 // Default-Pfad `talk "Text"` (gleiche Flag-Oberfläche, festgelegt in der
|
|||
|
|
89 // Spec). Das Verhalten entsteht in Task 02 (direkter Pfad) bzw. Task 04
|
|||
|
|
90 // (Daemon-Pfad).
|
|||
|
|
91 func addSayFlags(fs *flag.FlagSet) {
|
|||
|
|
92 fs.Bool("wait", false, "warten, bis die Ansage fertig abgespielt ist")
|
|||
|
|
93 fs.Bool("now", false, "Ansage sofort ausgeben (springt die Warteschlange)")
|
|||
|
|
94 fs.String("priority", "normal", "Priorität: low | normal | high")
|
|||
|
|
95 fs.String("voice", "", "Stimme als provider-spezifische ID")
|
|||
|
|
96 fs.String("lang", "", "Sprache: de | en (Default: automatische Erkennung)")
|
|||
|
|
97 fs.String("provider", "", "Provider pinnen: elevenlabs | openai | edge | osbuiltin")
|
|||
|
|
98 }
|
|||
|
|
99
|
|||
|
|
100 // run ist der Einstiegspunkt der CLI (Argumente ohne Programmnamen). Alle
|
|||
|
|
101 // Ausgaben gehen auf die übergebenen Writer, damit run direkt und ohne
|
|||
|
|
102 // Prozessstart testbar ist. Rückgabe ist der Exit-Code.
|
|||
|
|
103 func run(argv []string, stdout, stderr io.Writer) int {
|
|||
|
|
104 if len(argv) == 0 {
|
|||
|
|
105 // Task 02 macht aus dem nackten Aufruf ggf. einen stdin-Leser
|
|||
|
|
106 // (Text kommt dann über die Pipe); bis dahin: Benutzungsfehler.
|
|||
|
|
107 writeUsage(stderr)
|
|||
|
|
108 return exitUsage
|
|||
|
|
109 }
|
|||
|
|
110
|
|||
|
|
111 if argv[0] == "help" {
|
|||
|
|
112 if len(argv) > 1 {
|
|||
|
|
113 if cmd, ok := lookupCommand(argv[1]); ok {
|
|||
|
|
114 writeCommandHelp(cmd, stdout)
|
|||
|
|
115 return exitOK
|
|||
|
|
116 }
|
|||
|
|
117 fmt.Fprintf(stderr, "talk: unbekannter Befehl %q\n", argv[1])
|
|||
|
|
118 return exitUsage
|
|||
|
|
119 }
|
|||
|
|
120 writeUsage(stdout)
|
|||
|
|
121 return exitOK
|
|||
|
|
122 }
|
|||
|
|
123
|
|||
|
|
124 if cmd, ok := lookupCommand(argv[0]); ok {
|
|||
|
|
125 return runCommand(cmd, argv[1:], stdout, stderr)
|
|||
|
|
126 }
|
|||
|
|
127
|
|||
|
|
128 // Kein bekannter Befehl: Default-Pfad `talk "Text"` (mappt auf say)
|
|||
|
|
129 // bzw. Root-Flags (--version, --help). Führt aktuell auf den say-Stub.
|
|||
|
|
130 return runDefault(argv, stdout, stderr)
|
|||
|
|
131 }
|
|||
|
|
132
|
|||
|
|
133 func lookupCommand(name string) (command, bool) {
|
|||
|
|
134 for _, cmd := range commands {
|
|||
|
|
135 if cmd.name == name {
|
|||
|
|
136 return cmd, true
|
|||
|
|
137 }
|
|||
|
|
138 }
|
|||
|
|
139 return command{}, false
|
|||
|
|
140 }
|
|||
|
|
141
|
|||
|
|
142 // newCommandFlagSet baut den FlagSet eines Befehls: eigene Flags plus ein
|
|||
|
|
143 // explizites --help. Die Default-Usage wird stummgeschaltet, damit Hilfetext
|
|||
|
|
144 // ausschließlich über writeCommandHelp (eine Quelle) ausgegeben wird.
|
|||
|
|
145 func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool) {
|
|||
|
|
146 fs := flag.NewFlagSet("talk "+cmd.name, flag.ContinueOnError)
|
|||
|
|
147 fs.SetOutput(out)
|
|||
|
|
148 fs.Usage = func() {}
|
|||
|
|
149 if cmd.register != nil {
|
|||
|
|
150 cmd.register(fs)
|
|||
|
|
151 }
|
|||
|
|
152 help := fs.Bool("help", false, "Hilfe zu diesem Befehl anzeigen")
|
|||
|
|
153 return fs, help
|
|||
|
|
154 }
|
|||
|
|
155
|
|||
|
|
156 // runCommand führt einen benannten Subcommand aus — in diesem Stand als Stub.
|
|||
|
|
157 func runCommand(cmd command, args []string, stdout, stderr io.Writer) int {
|
|||
|
|
158 fs, help := newCommandFlagSet(cmd, stderr)
|
|||
|
|
159 if err := fs.Parse(args); err != nil {
|
|||
|
|
160 if errors.Is(err, flag.ErrHelp) { // z. B. nacktes -h
|
|||
|
|
161 writeCommandHelp(cmd, stdout)
|
|||
|
|
162 return exitOK
|
|||
|
|
163 }
|
|||
|
|
164 // Die Fehlermeldung selbst hat der FlagSet bereits ausgegeben.
|
|||
|
|
165 fmt.Fprintf(stderr, "Hinweis: `talk %s --help` zeigt die Verwendung.\n", cmd.name)
|
|||
|
|
166 return exitUsage
|
|||
|
|
167 }
|
|||
|
|
168 if *help {
|
|||
|
|
169 writeCommandHelp(cmd, stdout)
|
|||
|
|
170 return exitOK
|
|||
|
|
171 }
|
|||
|
|
172 return notImplemented(cmd.name, stderr)
|
|||
|
|
173 }
|
|||
|
|
174
|
|||
|
|
175 // runDefault behandelt Aufrufe ohne bekannten Subcommand: `talk "Text"`,
|
|||
|
|
176 // `talk --version`, `talk --help` sowie say-Flags vor dem Text.
|
|||
|
|
177 func runDefault(argv []string, stdout, stderr io.Writer) int {
|
|||
|
|
178 fs := flag.NewFlagSet("talk", flag.ContinueOnError)
|
|||
|
|
179 fs.SetOutput(stderr)
|
|||
|
|
180 fs.Usage = func() {}
|
|||
|
|
181 showVersion := fs.Bool("version", false, "Version anzeigen")
|
|||
|
|
182 help := fs.Bool("help", false, "diese Hilfe anzeigen")
|
|||
|
|
183 addSayFlags(fs)
|
|||
|
|
184
|
|||
|
|
185 if err := fs.Parse(argv); err != nil {
|
|||
|
|
186 if errors.Is(err, flag.ErrHelp) {
|
|||
|
|
187 writeUsage(stdout)
|
|||
|
|
188 return exitOK
|
|||
|
|
189 }
|
|||
|
|
190 fmt.Fprintln(stderr, "Hinweis: `talk --help` zeigt die Verwendung.")
|
|||
|
|
191 return exitUsage
|
|||
|
|
192 }
|
|||
|
|
193 if *showVersion {
|
|||
|
|
194 fmt.Fprintln(stdout, versionString())
|
|||
|
|
195 return exitOK
|
|||
|
|
196 }
|
|||
|
|
197 if *help {
|
|||
|
|
198 writeUsage(stdout)
|
|||
|
|
199 return exitOK
|
|||
|
|
200 }
|
|||
|
|
201 return notImplemented("say", stderr)
|
|||
|
|
202 }
|
|||
|
|
203
|
|||
|
|
204 // versionString rendert die Versionsausgabe: `talk version <version>` plus
|
|||
|
|
205 // Commit-Hash in Klammern, falls per ldflags injiziert.
|
|||
|
|
206 func versionString() string {
|
|||
|
|
207 if commit == "" {
|
|||
|
|
208 return fmt.Sprintf("talk version %s", version)
|
|||
|
|
209 }
|
|||
|
|
210 return fmt.Sprintf("talk version %s (%s)", version, commit)
|
|||
|
|
211 }
|
|||
|
|
212
|
|||
|
|
213 // notImplemented meldet einen noch nicht implementierten Befehl auf stderr
|
|||
|
|
214 // und liefert den dafür vorgesehenen Exit-Code (non-zero).
|
|||
|
|
215 func notImplemented(name string, stderr io.Writer) int {
|
|||
|
|
216 fmt.Fprintf(stderr, "talk %s is not implemented yet\n", name)
|
|||
|
|
217 return exitFailure
|
|||
|
|
218 }
|
|||
|
|
219
|
|||
|
|
220 // writeUsage schreibt die Gesamt-Hilfe; die Befehlsliste wird direkt aus der
|
|||
|
|
221 // Befehlstabelle generiert, damit Hilfe und Dispatch nicht auseinanderlaufen.
|
|||
|
|
222 func writeUsage(w io.Writer) {
|
|||
|
|
223 fmt.Fprint(w, `talk — Sprach-Ausgaben für Skripte und KI-Agenten
|
|||
|
|
224
|
|||
|
|
225 Usage:
|
|||
|
|
226 talk [Flags] "Text" Text ansagen (Abkürzung für: talk say)
|
|||
|
|
227 talk <Befehl> [Flags] Befehl ausführen
|
|||
|
|
228 talk help [Befehl] Hilfe anzeigen
|
|||
|
|
229
|
|||
|
|
230 Befehle:
|
|||
|
|
231 `)
|
|||
|
|
232 for _, cmd := range commands {
|
|||
|
|
233 fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.short)
|
|||
|
|
234 }
|
|||
|
|
235 fmt.Fprint(w, `
|
|||
|
|
236 Flags:
|
|||
|
|
237 --version Version anzeigen
|
|||
|
|
238 --help diese Hilfe anzeigen
|
|||
|
|
239
|
|||
|
|
240 Die Flags von say gelten auch für talk "Text"; Details: talk say --help
|
|||
|
|
241 `)
|
|||
|
|
242 }
|
|||
|
|
243
|
|||
|
|
244 // writeCommandHelp schreibt die Hilfe eines einzelnen Befehls inkl. Flags.
|
|||
|
|
245 func writeCommandHelp(cmd command, w io.Writer) {
|
|||
|
|
246 fmt.Fprintf(w, "Usage:\n talk %s", cmd.name)
|
|||
|
|
247 if cmd.args != "" {
|
|||
|
|
248 fmt.Fprintf(w, " %s", cmd.args)
|
|||
|
|
249 }
|
|||
|
|
250 fmt.Fprintln(w, " [Flags]")
|
|||
|
|
251 fmt.Fprintln(w)
|
|||
|
|
252 if cmd.detail != "" {
|
|||
|
|
253 fmt.Fprintf(w, "%s\n\n", cmd.detail)
|
|||
|
|
254 }
|
|||
|
|
255 fmt.Fprintln(w, "Flags:")
|
|||
|
|
256 fs, _ := newCommandFlagSet(cmd, w)
|
|||
|
|
257 fs.PrintDefaults()
|
|||
|
|
258 fmt.Fprintln(w, "\nStatus: noch nicht implementiert (WIP).")
|
|||
|
|
259 }
|
|||
|
|
260
|
|||
|
|
|
|||
|
|
cmd/talk/cli_test.go --- Go
|
|||
|
|
1 package main
|
|||
|
|
2
|
|||
|
|
3 import (
|
|||
|
|
4 "bytes"
|
|||
|
|
5 "strings"
|
|||
|
|
6 "testing"
|
|||
|
|
7 )
|
|||
|
|
8
|
|||
|
|
9 // wantSubcommands ist die bindende CLI-Surface aus der Spec — bewusst als
|
|||
|
|
10 // hartkodierte Liste getestet (nicht aus der Befehlstabelle abgeleitet),
|
|||
|
|
11 // damit die Tests anschlagen, wenn Tabelle und Spec auseinanderlaufen.
|
|||
|
|
12 var wantSubcommands = []string{
|
|||
|
|
13 "say", "wrap", "mute", "unmute", "status", "cancel", "voices", "test", "doctor", "setup",
|
|||
|
|
14 }
|
|||
|
|
15
|
|||
|
|
16 func TestHelpListetAlleSubcommands(t *testing.T) {
|
|||
|
|
17 var out, errBuf bytes.Buffer
|
|||
|
|
18 code := run([]string{"--help"}, &out, &errBuf)
|
|||
|
|
19 if code != exitOK {
|
|||
|
|
20 t.Fatalf("--help: Exit-Code = %d, want %d (stderr: %q)", code, exitOK, errBuf.String())
|
|||
|
|
21 }
|
|||
|
|
22 lines := strings.Split(out.String(), "\n")
|
|||
|
|
23 for _, name := range wantSubcommands {
|
|||
|
|
24 found := false
|
|||
|
|
25 for _, ln := range lines {
|
|||
|
|
26 if strings.HasPrefix(strings.TrimLeft(ln, " "), name+" ") {
|
|||
|
|
27 found = true
|
|||
|
|
28 break
|
|||
|
|
29 }
|
|||
|
|
30 }
|
|||
|
|
31 if !found {
|
|||
|
|
32 t.Errorf("--help listet Befehl %q nicht (Output: %q)", name, out.String())
|
|||
|
|
33 }
|
|||
|
|
34 }
|
|||
|
|
35 }
|
|||
|
|
36
|
|||
|
|
37 func TestVersionDefault(t *testing.T) {
|
|||
|
|
38 var out, errBuf bytes.Buffer
|
|||
|
|
39 code := run([]string{"--version"}, &out, &errBuf)
|
|||
|
|
40 if code != exitOK {
|
|||
|
|
41 t.Fatalf("--version: Exit-Code = %d, want %d", code, exitOK)
|
|||
|
|
42 }
|
|||
|
|
43 if got, want := out.String(), "talk version dev\n"; got != want {
|
|||
|
|
44 t.Errorf("--version Output = %q, want %q", got, want)
|
|||
|
|
45 }
|
|||
|
|
46 }
|
|||
|
|
47
|
|||
|
|
48 func TestVersionStringMitCommit(t *testing.T) {
|
|||
|
|
49 oldCommit := commit
|
|||
|
|
50 commit = "c0ffee1"
|
|||
|
|
51 defer func() { commit = oldCommit }()
|
|||
|
|
52
|
|||
|
|
53 if got, want := versionString(), "talk version dev (c0ffee1)"; got != want {
|
|||
|
|
54 t.Errorf("versionString() = %q, want %q", got, want)
|
|||
|
|
55 }
|
|||
|
|
56 }
|
|||
|
|
57
|
|||
|
|
58 func TestSubcommandStubs(t *testing.T) {
|
|||
|
|
59 for _, name := range wantSubcommands {
|
|||
|
|
60 t.Run(name, func(t *testing.T) {
|
|||
|
|
61 var out, errBuf bytes.Buffer
|
|||
|
|
62 code := run([]string{name}, &out, &errBuf)
|
|||
|
|
63 if code == exitOK {
|
|||
|
|
64 t.Errorf("talk %s: Exit-Code 0, want non-zero für Stub", name)
|
|||
|
|
65 }
|
|||
|
|
66 if !strings.Contains(errBuf.String(), "not implemented yet") {
|
|||
|
|
67 t.Errorf("talk %s: stderr = %q, will Hinweis auf nicht implementiert", name, errBuf.String())
|
|||
|
|
68 }
|
|||
|
|
69 if !strings.Contains(errBuf.String(), name) {
|
|||
|
|
70 t.Errorf("talk %s: stderr nennt den Befehlsnamen nicht: %q", name, errBuf.String())
|
|||
|
|
71 }
|
|||
|
|
72 if out.Len() != 0 {
|
|||
|
|
73 t.Errorf("talk %s: unerwartete stdout-Ausgabe %q", name, out.String())
|
|||
|
|
74 }
|
|||
|
|
75 })
|
|||
|
|
76 }
|
|||
|
|
77 }
|
|||
|
|
78
|
|||
|
|
79 func TestDefaultAufrufMapptAufSay(t *testing.T) {
|
|||
|
|
80 var out, errBuf bytes.Buffer
|
|||
|
|
81 code := run([]string{"Hallo Welt"}, &out, &errBuf)
|
|||
|
|
82 if code != exitFailure {
|
|||
|
|
83 t.Fatalf(`talk "Hallo Welt": Exit-Code = %d, want %d`, code, exitFailure)
|
|||
|
|
84 }
|
|||
|
|
85 if !strings.Contains(errBuf.String(), "talk say is not implemented yet") {
|
|||
|
|
86 t.Errorf("Default-Aufruf mappt offenbar nicht auf say, stderr = %q", errBuf.String())
|
|||
|
|
87 }
|
|||
|
|
88 }
|
|||
|
|
89
|
|||
|
|
90 func TestSayHelpZeigtFlags(t *testing.T) {
|
|||
|
|
91 var out, errBuf bytes.Buffer
|
|||
|
|
92 code := run([]string{"say", "--help"}, &out, &errBuf)
|
|||
|
|
93 if code != exitOK {
|
|||
|
|
94 t.Fatalf("talk say --help: Exit-Code = %d, want %d", code, exitOK)
|
|||
|
|
95 }
|
|||
|
|
96 for _, f := range []string{"-wait", "-now", "-priority", "-voice", "-lang", "-provider"} {
|
|||
|
|
97 if !strings.Contains(out.String(), f) {
|
|||
|
|
98 t.Errorf("talk say --help zeigt Flag %s nicht (Output: %q)", f, out.String())
|
|||
|
|
99 }
|
|||
|
|
100 }
|
|||
|
|
101 }
|
|||
|
|
102
|
|||
|
|
103 func TestHelpSubcommand(t *testing.T) {
|
|||
|
|
104 t.Run("ohne Argument", func(t *testing.T) {
|
|||
|
|
105 var out, errBuf bytes.Buffer
|
|||
|
|
106 if code := run([]string{"help"}, &out, &errBuf); code != exitOK {
|
|||
|
|
107 t.Fatalf("talk help: Exit-Code = %d, want %d", code, exitOK)
|
|||
|
|
108 }
|
|||
|
|
109 for _, name := range wantSubcommands {
|
|||
|
|
110 if !strings.Contains(out.String(), name) {
|
|||
|
|
111 t.Errorf("talk help listet %q nicht", name)
|
|||
|
|
112 }
|
|||
|
|
113 }
|
|||
|
|
114 })
|
|||
|
|
115 t.Run("mit Befehl", func(t *testing.T) {
|
|||
|
|
116 var out, errBuf bytes.Buffer
|
|||
|
|
117 if code := run([]string{"help", "mute"}, &out, &errBuf); code != exitOK {
|
|||
|
|
118 t.Fatalf("talk help mute: Exit-Code = %d, want %d", code, exitOK)
|
|||
|
|
119 }
|
|||
|
|
120 if !strings.Contains(out.String(), "talk mute") {
|
|||
|
|
121 t.Errorf("talk help mute zeigt Usage von mute nicht: %q", out.String())
|
|||
|
|
122 }
|
|||
|
|
123 })
|
|||
|
|
124 }
|
|||
|
|
125
|
|||
|
|
126 func TestOhneArgumenteZeigtUsage(t *testing.T) {
|
|||
|
|
127 var out, errBuf bytes.Buffer
|
|||
|
|
128 code := run(nil, &out, &errBuf)
|
|||
|
|
129 if code != exitUsage {
|
|||
|
|
130 t.Fatalf("talk ohne Argumente: Exit-Code = %d, want %d", code, exitUsage)
|
|||
|
|
131 }
|
|||
|
|
132 if !strings.Contains(errBuf.String(), "Usage") {
|
|||
|
|
133 t.Errorf("talk ohne Argumente: Usage fehlt auf stderr: %q", errBuf.String())
|
|||
|
|
134 }
|
|||
|
|
135 }
|
|||
|
|
136
|
|||
|
|
137 func TestUnbekanntesFlag(t *testing.T) {
|
|||
|
|
138 var out, errBuf bytes.Buffer
|
|||
|
|
139 code := run([]string{"--bogus"}, &out, &errBuf)
|
|||
|
|
140 if code != exitUsage {
|
|||
|
|
141 t.Fatalf("talk --bogus: Exit-Code = %d, want %d", code, exitUsage)
|
|||
|
|
142 }
|
|||
|
|
143 if !strings.Contains(errBuf.String(), "bogus") {
|
|||
|
|
144 t.Errorf("talk --bogus: Fehlermeldung fehlt auf stderr: %q", errBuf.String())
|
|||
|
|
145 }
|
|||
|
|
146 }
|
|||
|
|
147
|
|||
|
|
|
|||
|
|
cmd/talk/main.go --- Go
|
|||
|
|
1 // talk ist die CLI für Sprach-Ausgaben (talk 2.0).
|
|||
|
|
2 //
|
|||
|
|
3 // Dieser Stand (Task 01) liefert das Gerüst: Subcommand-Dispatch, --version
|
|||
|
|
4 // und alle Befehle als Stubs. Die Funktionalität entsteht in den Tasks 02–09.
|
|||
|
|
5 package main
|
|||
|
|
6
|
|||
|
|
7 import "os"
|
|||
|
|
8
|
|||
|
|
9 // Build-Informationen. Werden beim Release per -ldflags injiziert, z. B.
|
|||
|
|
10 //
|
|||
|
|
11 // go build -ldflags "-X main.version=1.0.0 -X main.commit=abc1234" ./cmd/talk
|
|||
|
|
12 //
|
|||
|
|
13 // Defaults: version "dev", commit leer (siehe auch .goreleaser.yaml, Task 11).
|
|||
|
|
14 var (
|
|||
|
|
15 version = "dev"
|
|||
|
|
16 commit = ""
|
|||
|
|
17 )
|
|||
|
|
18
|
|||
|
|
19 func main() {
|
|||
|
|
20 os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
|||
|
|
21 }
|
|||
|
|
22
|
|||
|
|
|
|||
|
|
cmd/talkd/main.go --- Go
|
|||
|
|
1 // talkd ist der Daemon für talk 2.0 (Queue, Mute-State, HTTP-API, MCP).
|
|||
|
|
2 //
|
|||
|
|
3 // Dieser Stand (Task 01) ist ein Platzhalter: talkd startet, loggt eine Zeile
|
|||
|
|
4 // und beendet sich mit Exit-Code 0. Die eigentliche Daemon-Implementierung
|
|||
|
|
5 // (HTTP-Server auf 127.0.0.1, serialisierte Ansage-Queue, persistentes Mute)
|
|||
|
|
6 // entsteht in Task 04.
|
|||
|
|
7 package main
|
|||
|
|
8
|
|||
|
|
9 import "log"
|
|||
|
|
10
|
|||
|
|
11 func main() {
|
|||
|
|
12 log.Println("talkd: Platzhalter — Daemon-Implementierung folgt in Task 04")
|
|||
|
|
13 }
|
|||
|
|
14
|
|||
|
|
|
|||
|
|
go.mod --- Text
|
|||
|
|
1 module code.m3ta.dev/m3tam3re/talk
|
|||
|
|
2
|
|||
|
|
3 go 1.26.8
|
|||
|
|
4
|
|||
|
|
|
|||
|
|
internal/api/doc.go --- Go
|
|||
|
|
1 // Package api implementiert die HTTP-Oberfläche des Daemons auf 127.0.0.1:
|
|||
|
|
2 // POST /speak, GET /status, POST /cancel, POST /mute, POST /unmute (Task 04),
|
|||
|
|
3 // CORS für /speak und den MCP-Endpoint (Streamable HTTP, Task 06).
|
|||
|
|
4 package api
|
|||
|
|
5
|
|||
|
|
|
|||
|
|
internal/audio/doc.go --- Go
|
|||
|
|
1 // Package audio implementiert die Audio-Ausgabe von talk über das
|
|||
|
|
2 // Sink-Interface: Playback embedded via oto (PCM/WAV) und ein null-Sink,
|
|||
|
|
3 // das verwirft (CI, Server, Tests). Auswahl per Config audio.sink.
|
|||
|
|
4 // Implementierung folgt in Task 02.
|
|||
|
|
5 package audio
|
|||
|
|
6
|
|||
|
|
|
|||
|
|
internal/config/doc.go --- Go
|
|||
|
|
1 // Package config implementiert die Config-Auflösung von talk: Defaults +
|
|||
|
|
2 // optionale config.toml im platform-nativen Verzeichnis (~/.config/talk,
|
|||
|
|
3 // ~/Library/Application Support/talk, %APPDATA%\talk) + Env-Overrides
|
|||
|
|
4 // (TALK_*); Env schlägt Datei. Enthält Voice-Mapping voice.<provider>.<lang>
|
|||
|
|
5 // und Provider-Endpunkte (Fake-bar für Tests). Implementierung folgt in
|
|||
|
|
6 // Task 02.
|
|||
|
|
7 package config
|
|||
|
|
8
|
|||
|
|
|
|||
|
|
internal/langdetect/doc.go --- Go
|
|||
|
|
1 // Package langdetect erkennt die Sprache eines Ansage-Texts als reine
|
|||
|
|
2 // Funktion Detect(text) → "de" | "en" (Default "de"; Heuristik über
|
|||
|
|
3 // Common-Word-Listen und Umlaute/ß). Implementierung folgt in Task 02.
|
|||
|
|
4 package langdetect
|
|||
|
|
5
|
|||
|
|
|
|||
|
|
internal/notify/doc.go --- Go
|
|||
|
|
1 // Package notify implementiert Best-effort-Desktop-Notifications parallel
|
|||
|
|
2 // zum Audio (beeep: notify-send / macOS Notification Center / Windows Toast),
|
|||
|
|
3 // mit Abbrechen-Button, wo das OS ihn hergibt. Implementierung folgt.
|
|||
|
|
4 package notify
|
|||
|
|
5
|
|||
|
|
|
|||
|
|
internal/provider/doc.go --- Go
|
|||
|
|
1 // Package provider implementiert die TTS-Provider-Kette von talk:
|
|||
|
|
2 // ElevenLabs → OpenAI TTS → Edge-TTS → OS built-in (macOS say, Windows SAPI,
|
|||
|
|
3 // Linux espeak/speech-dispatcher), überschreibbar per Config/Flag, mit
|
|||
|
|
4 // Fallback bei Fehlern. Implementierung folgt in Task 02 (osbuiltin) und
|
|||
|
|
5 // Task 03 (Cloud-Provider, Kette).
|
|||
|
|
6 package provider
|
|||
|
|
7
|
|||
|
|
|
|||
|
|
internal/queue/doc.go --- Go
|
|||
|
|
1 // Package queue implementiert die Ansage-Queue des Daemons: serialisierte
|
|||
|
|
2 // Abarbeitung (eine Ansage gleichzeitig), Prioritäten (high > normal > low,
|
|||
|
|
3 // FIFO je Level), now-Items springen vor, Rate-Limit mit prioritätsbewusstem
|
|||
|
|
4 // Verwerfen. Implementierung folgt in Task 04 (Queue) und Task 05
|
|||
|
|
5 // (Rate-Limit, Ruhezeiten).
|
|||
|
|
6 package queue
|
|||
|
|
7
|
|||
|
|
|