talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.
Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.
Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
149 lines
4.7 KiB
Go
149 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
|
)
|
|
|
|
// getenv is the CLI's environment seam (tests inject TALK_AUDIO_SINK,
|
|
// TALK_PORT etc. without touching the process environment). Default:
|
|
// os.Getenv.
|
|
var getenv = os.Getenv
|
|
|
|
// sayOptions bundles the say flags (identical for the default form
|
|
// `talk "text"`).
|
|
type sayOptions struct {
|
|
wait bool
|
|
now bool
|
|
priority string
|
|
voice string
|
|
lang string
|
|
provider string
|
|
|
|
// Diagnostic flags (doctor, voices): machine-readable output and real
|
|
// network probes. They live in the same struct so the command table
|
|
// keeps one flag signature; say ignores them.
|
|
jsonOut bool
|
|
probe bool
|
|
}
|
|
|
|
// runSay is the say entry point: collect the text from arguments or stdin,
|
|
// validate, then speak through the daemon (non-blocking by default, --wait
|
|
// polls until spoken, --now jumps the queue). When no daemon runs, talk
|
|
// starts one itself (daemon.autostart). If that fails too, talk speaks
|
|
// directly in this process (sayDirect): no queue, but never silent.
|
|
func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
text := strings.Join(args, " ")
|
|
if text == "" {
|
|
if isCharDevice(stdin) {
|
|
fmt.Fprintln(stderr, "talk: no text given; pass it as an argument or pipe it via stdin")
|
|
return exitUsage
|
|
}
|
|
b, err := io.ReadAll(stdin)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: reading stdin: %v\n", err)
|
|
return exitFailure
|
|
}
|
|
text = strings.TrimSpace(string(b))
|
|
if text == "" {
|
|
fmt.Fprintln(stderr, "talk: no text given (argument and stdin are empty)")
|
|
return exitFailure
|
|
}
|
|
}
|
|
|
|
// Validate before any daemon contact (wrap inherits the say flags):
|
|
// usage errors should fail fast and without network round trips.
|
|
if code, bad := validateSayOptions(opts, stderr); bad {
|
|
return code
|
|
}
|
|
|
|
cfg, err := config.Load(config.Options{Getenv: getenv})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
|
return exitFailure
|
|
}
|
|
return announce(cfg, text, opts, stdout, stderr)
|
|
}
|
|
|
|
// announce is the shared announcement path of say and wrap: ensure a daemon
|
|
// (start one if needed) and enqueue; without a daemon speak directly. info
|
|
// is the writer for the information tokens (item ID, muted, discarded).
|
|
func announce(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
|
|
if client, ok := ensureDaemon(cfg, stderr); ok {
|
|
if code, handled := sayViaDaemon(client, text, opts, info, stderr); handled {
|
|
return code
|
|
}
|
|
}
|
|
fmt.Fprintln(stderr, "talk: no daemon reachable; speaking directly (no queue)")
|
|
return sayDirect(cfg, text, opts, info, stderr)
|
|
}
|
|
|
|
// sayDirect is the emergency path without a daemon: honour mute (the same
|
|
// file the daemon reads), then speak via the engine, blocking in the CLI
|
|
// process and unserialized (two simultaneous calls may overlap; the daemon
|
|
// is the normal case). Without a config directory it runs without mute
|
|
// rather than failing.
|
|
func sayDirect(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(info, "muted")
|
|
fmt.Fprintln(stderr, "talk: muted; announcement discarded")
|
|
return exitOK
|
|
}
|
|
}
|
|
engine, err := speak.New(cfg, getenv, stderr)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
|
return exitFailure
|
|
}
|
|
_, err = engine.Speak(context.Background(), speak.Request{
|
|
Text: text,
|
|
Lang: opts.lang,
|
|
Provider: opts.provider,
|
|
Voice: opts.voice,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
|
return exitFailure
|
|
}
|
|
return exitOK
|
|
}
|
|
|
|
// validateSayOptions checks the say flags (wrap inherits them): --lang must
|
|
// be de | en, --priority low | normal | high. (code, true) means: usage
|
|
// error, already reported on stderr.
|
|
func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) {
|
|
if opts.lang != "" && opts.lang != "de" && opts.lang != "en" {
|
|
fmt.Fprintf(stderr, "talk: --lang: invalid value %q (allowed: de, en)\n", opts.lang)
|
|
return exitFailure, true
|
|
}
|
|
if opts.priority != "" && !queue.Priority(opts.priority).Valid() {
|
|
fmt.Fprintf(stderr, "talk: --priority: invalid value %q (allowed: low, normal, high)\n", opts.priority)
|
|
return exitFailure, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// isCharDevice reports whether r is a terminal (then talk must not wait for
|
|
// stdin input but report a usage error).
|
|
func isCharDevice(r io.Reader) bool {
|
|
f, ok := r.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return st.Mode()&os.ModeCharDevice != 0
|
|
}
|