feat(cli): add subcommand dispatch, --version and stub commands

This commit is contained in:
m3ta-chiron committed 2026-10-18 15:14:18 +02:00
1 parent 24dfe4d46c
commit 33b97a50ea
4 files changed
+439

No files matched your search

+259
View File
@@ -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 <Befehl> --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: "-- <Befehl> [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 <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 <Befehl> [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).")
}
+146
View File
@@ -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())
}
}
+21
View File
@@ -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))
}
+13
View File
@@ -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")
}