test / test (push) Canceled after 0s
A bare `talk version` was spoken as an announcement because only the --version flag existed. The subcommand prints the same line. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
373 lines
15 KiB
Go
373 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
)
|
|
|
|
// Exit codes.
|
|
const (
|
|
exitOK = 0 // success (also --help, --version)
|
|
exitFailure = 1 // command failed
|
|
exitUsage = 2 // wrong usage (unknown flags, missing command)
|
|
)
|
|
|
|
// command is an entry of the command table. The table is data-driven on
|
|
// purpose: new commands add an entry instead of touching the dispatch in
|
|
// run(). register attaches the command's flags to the FlagSet and returns the
|
|
// bound options; handler executes the command.
|
|
type command struct {
|
|
name string // subcommand name (part of the CLI surface)
|
|
short string // one-liner for `talk --help`
|
|
args string // positional arguments hint for the usage line
|
|
detail string // description for `talk <command> --help`
|
|
register func(fs *flag.FlagSet) *sayOptions // attaches the command's flags to the FlagSet
|
|
handler func(args []string, o *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int // execution
|
|
}
|
|
|
|
// commands is the complete command table. The names are part of the CLI
|
|
// surface; do not rename.
|
|
var commands = []command{
|
|
{
|
|
name: "say",
|
|
short: "speak a text (default command)",
|
|
args: `"text"`,
|
|
detail: "Speaks the text through the provider chain. Without a text argument, stdin is read. `talk \"text\"` is shorthand for `talk say \"text\"`.",
|
|
register: addSayFlags,
|
|
handler: runSay,
|
|
},
|
|
{
|
|
name: "wrap",
|
|
short: "run a command and announce the result",
|
|
args: "-- <command> [args …]",
|
|
detail: "Runs the command (stdin/stdout/stderr are passed through) and then announces success or failure and the duration. The first \"--\" shields the command completely from talk's flags: `talk wrap -- foo --bar` passes --bar to foo. The command's exit code is passed through; if the announcement itself fails, the code still stands. Announcement label: config `wrap.label` or the command's base name. wrap inherits the say flags (--wait, --now, --priority, --voice, --lang, --provider).",
|
|
register: addSayFlags,
|
|
handler: runWrap,
|
|
},
|
|
{
|
|
name: "mute",
|
|
short: "mute all announcements persistently",
|
|
detail: "Turns on the persistent mute state (file `mute` in the config directory). A running daemon is told; otherwise talk writes the file directly. The state survives daemon restarts.",
|
|
handler: runMute,
|
|
},
|
|
{
|
|
name: "unmute",
|
|
short: "turn announcements back on",
|
|
detail: "Clears the persistent mute state (daemon call; without a daemon directly via the file).",
|
|
handler: runUnmute,
|
|
},
|
|
{
|
|
name: "status",
|
|
short: "show the daemon status",
|
|
detail: "Shows queue, current item, mute state, active provider, laws and daemon version (plain, one line per field). Does not start the daemon.",
|
|
handler: runStatus,
|
|
},
|
|
{
|
|
name: "daemon",
|
|
short: "control the background process (run | start | stop)",
|
|
args: "[run|start|stop]",
|
|
detail: "run (default) runs the daemon in the foreground, for systemd, launchd or a terminal. start launches it in the background (log: daemon.log in the config directory), stop shuts a running daemon down. Normally not needed: talk starts the daemon itself on the first speak command (daemon.autostart, default on).",
|
|
handler: runDaemon,
|
|
},
|
|
{
|
|
name: "cancel",
|
|
short: "cancel the playing and all queued announcements",
|
|
detail: "Aborts the playing announcement and empties the daemon's queue; prints the number of cancelled announcements.",
|
|
handler: runCancel,
|
|
},
|
|
{
|
|
name: "uri",
|
|
short: "handle a talk: link (notification buttons)",
|
|
args: "<talk:…>",
|
|
detail: "The handler behind the talk: URL protocol: a toast's Cancel button opens talk:cancel?id=<id>&port=<port>, Windows starts `talk uri` with it, and talk tells the daemon to cancel that announcement. Registered for the current user by the daemon on Windows; not needed elsewhere.",
|
|
handler: runURI,
|
|
},
|
|
{
|
|
name: "voices",
|
|
short: "list known voices",
|
|
detail: "Lists known voices per provider and language as a table; \"*\" marks the voice talk uses without --voice (config voice.<provider>.<lang> or the built-in default). Filters: --provider, --lang. --json for machines. Any voice ID the provider knows works without a catalog entry.",
|
|
register: addVoicesFlags,
|
|
handler: runVoices,
|
|
},
|
|
{
|
|
name: "test",
|
|
short: "speak a test sentence",
|
|
detail: "Speaks a fixed test sentence directly in this process (not via the daemon, even when muted) and shows which provider spoke and how long synthesis and playback took. --lang de|en picks the sentence, --provider/--voice pin like say. --toast sends a test desktop notification instead (the path quiet_mode = toast uses).",
|
|
register: addTestFlags,
|
|
handler: runTest,
|
|
},
|
|
{
|
|
name: "doctor",
|
|
short: "diagnose the setup",
|
|
detail: "Checks config, daemon, audio output, every provider tier, notifications, mute and laws and reports one line each with ✔ (ok), ⚠ (note), ✘ (error). Exit 0 without ✘, else 1; a daemon that is not running is ⚠, not an error. --probe runs real synthesis probes against every available provider (network). --json for machines.",
|
|
register: addDoctorFlags,
|
|
handler: runDoctor,
|
|
},
|
|
{
|
|
name: "setup",
|
|
short: "guided first-time setup",
|
|
detail: "Asks a few questions for what talk needs to get going: keys (optional; without a key the free Edge voice speaks), a voice per language with preview, quiet hours, and writes config.toml (an existing file is merged, backup .bak). Without a terminal: only shows the effective configuration.",
|
|
handler: runSetup,
|
|
},
|
|
{
|
|
name: "version",
|
|
short: "show the version",
|
|
detail: "Prints the version and, when built from git, the commit. Same as `talk --version`; exists so that a bare `talk version` is not spoken as an announcement.",
|
|
handler: func(_ []string, _ *sayOptions, _ io.Reader, stdout, _ io.Writer) int {
|
|
fmt.Fprintln(stdout, versionString())
|
|
return exitOK
|
|
},
|
|
},
|
|
}
|
|
|
|
// addSayFlags registers the say flags and binds them to the returned options
|
|
// struct. They also apply to the default form `talk "text"`. A pointer is
|
|
// returned on purpose: flag.Var binds to the fields of exactly this struct.
|
|
func addSayFlags(fs *flag.FlagSet) *sayOptions {
|
|
o := &sayOptions{}
|
|
fs.BoolVar(&o.wait, "wait", false, "wait until the announcement has been spoken")
|
|
fs.BoolVar(&o.now, "now", false, "speak immediately (jumps the queue; breaks out of rate limit and quiet hours)")
|
|
fs.StringVar(&o.priority, "priority", "normal", "priority: low | normal | high")
|
|
fs.StringVar(&o.voice, "voice", "", "voice as a provider-specific ID")
|
|
fs.StringVar(&o.lang, "lang", "", "language: de | en (default: detected)")
|
|
fs.StringVar(&o.provider, "provider", "", "pin a provider: elevenlabs | openai | edge | osbuiltin")
|
|
return o
|
|
}
|
|
|
|
// addVoicesFlags registers the voices flags (filters + JSON).
|
|
func addVoicesFlags(fs *flag.FlagSet) *sayOptions {
|
|
o := &sayOptions{}
|
|
fs.StringVar(&o.provider, "provider", "", "only this provider: elevenlabs | openai | edge | osbuiltin")
|
|
fs.StringVar(&o.lang, "lang", "", "only this language: de | en")
|
|
fs.BoolVar(&o.jsonOut, "json", false, "machine-readable JSON output")
|
|
return o
|
|
}
|
|
|
|
// addTestFlags registers the test flags (sentence + pins).
|
|
func addTestFlags(fs *flag.FlagSet) *sayOptions {
|
|
o := &sayOptions{}
|
|
fs.StringVar(&o.lang, "lang", "", "language of the test sentence: de | en (default: configured lang)")
|
|
fs.StringVar(&o.provider, "provider", "", "pin a provider: elevenlabs | openai | edge | osbuiltin")
|
|
fs.StringVar(&o.voice, "voice", "", "voice as a provider-specific ID")
|
|
fs.BoolVar(&o.toast, "toast", false, "send a test desktop notification instead of speaking")
|
|
return o
|
|
}
|
|
|
|
// addDoctorFlags registers the doctor flags.
|
|
func addDoctorFlags(fs *flag.FlagSet) *sayOptions {
|
|
o := &sayOptions{}
|
|
fs.BoolVar(&o.jsonOut, "json", false, "machine-readable JSON output")
|
|
fs.BoolVar(&o.probe, "probe", false, "real synthesis probe for every available provider (network calls)")
|
|
return o
|
|
}
|
|
|
|
// run is the CLI entry point (arguments without the program name). All
|
|
// output goes to the given writers, stdin is injected, so run is testable
|
|
// without starting a process. The return value is the exit code.
|
|
func run(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
if len(argv) == 0 {
|
|
// No command and no argument: the text may come via stdin (pipe).
|
|
// At a terminal there is no text, so this is a usage error; talk
|
|
// must never sit waiting for input.
|
|
if isCharDevice(stdin) {
|
|
writeUsage(stderr)
|
|
return exitUsage
|
|
}
|
|
return runSay(nil, &sayOptions{}, stdin, stdout, stderr)
|
|
}
|
|
|
|
if argv[0] == "help" {
|
|
if len(argv) > 1 {
|
|
if cmd, ok := lookupCommand(argv[1]); ok {
|
|
writeCommandHelp(cmd, stdout)
|
|
return exitOK
|
|
}
|
|
fmt.Fprintf(stderr, "talk: unknown command %q\n", argv[1])
|
|
return exitUsage
|
|
}
|
|
writeUsage(stdout)
|
|
return exitOK
|
|
}
|
|
|
|
if cmd, ok := lookupCommand(argv[0]); ok {
|
|
return runCommand(cmd, argv[1:], stdin, stdout, stderr)
|
|
}
|
|
|
|
// No known command: default form `talk "text"` (maps to say) or root
|
|
// flags (--version, --help).
|
|
return runDefault(argv, stdin, stdout, stderr)
|
|
}
|
|
|
|
// parseInterspersed parses flags that may appear anywhere in argv. The
|
|
// standard flag package stops at the first non-flag argument, but the CLI
|
|
// accepts `talk "text" --provider=x` as well as `talk --provider=x "text"`.
|
|
// Trick: repeat the parse over the rest, taking one positional argument
|
|
// before each round. Errors (unknown flags, -h) come from the FlagSet.
|
|
//
|
|
// The first standalone "--" shields everything after it completely (the
|
|
// standard terminator semantics the interspersed loop used to defeat: it
|
|
// resumed parsing after every positional and ate flags meant for the
|
|
// wrapped command; `talk wrap -- foo --bar` failed on --bar). argv is split
|
|
// at the first "--": only the part before it is parsed interspersed, the
|
|
// rest is positional unconditionally, for say (`talk -- "text --flag"`) as
|
|
// well as wrap (`talk wrap -- foo --bar`). A "--" as a flag VALUE (right
|
|
// after --voice) does not split, because only a standalone "--" does.
|
|
func parseInterspersed(fs *flag.FlagSet, argv []string) ([]string, error) {
|
|
if i := slices.Index(argv, "--"); i >= 0 {
|
|
positional, err := parseInterspersedLoop(fs, argv[:i])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(positional, argv[i+1:]...), nil
|
|
}
|
|
return parseInterspersedLoop(fs, argv)
|
|
}
|
|
|
|
// parseInterspersedLoop is the interspersed pass; argv holds no standalone
|
|
// "--" any more (parseInterspersed split before).
|
|
func parseInterspersedLoop(fs *flag.FlagSet, argv []string) ([]string, error) {
|
|
var positional []string
|
|
rest := argv
|
|
for {
|
|
if err := fs.Parse(rest); err != nil {
|
|
return nil, err
|
|
}
|
|
args := fs.Args()
|
|
if len(args) == 0 {
|
|
return positional, nil
|
|
}
|
|
// args[0] is the argument Parse stopped at.
|
|
positional = append(positional, args[0])
|
|
rest = args[1:]
|
|
}
|
|
}
|
|
|
|
func lookupCommand(name string) (command, bool) {
|
|
for _, cmd := range commands {
|
|
if cmd.name == name {
|
|
return cmd, true
|
|
}
|
|
}
|
|
return command{}, false
|
|
}
|
|
|
|
// newCommandFlagSet builds a command's FlagSet: its own flags plus an
|
|
// explicit --help and the bound options. The default usage is silenced so
|
|
// help text comes from writeCommandHelp only (one source).
|
|
func newCommandFlagSet(cmd command, out io.Writer) (*flag.FlagSet, *bool, *sayOptions) {
|
|
fs := flag.NewFlagSet("talk "+cmd.name, flag.ContinueOnError)
|
|
fs.SetOutput(out)
|
|
fs.Usage = func() {}
|
|
var sayOpts *sayOptions
|
|
if cmd.register != nil {
|
|
sayOpts = cmd.register(fs)
|
|
}
|
|
help := fs.Bool("help", false, "show help for this command")
|
|
return fs, help, sayOpts
|
|
}
|
|
|
|
// runCommand executes a named subcommand. Flags may follow the text
|
|
// (parseInterspersed).
|
|
func runCommand(cmd command, args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
fs, help, sayOpts := newCommandFlagSet(cmd, stderr)
|
|
positional, err := parseInterspersed(fs, args)
|
|
if err != nil {
|
|
if errors.Is(err, flag.ErrHelp) { // for example a bare -h
|
|
writeCommandHelp(cmd, stdout)
|
|
return exitOK
|
|
}
|
|
// The FlagSet has already printed the error itself.
|
|
fmt.Fprintf(stderr, "hint: `talk %s --help` shows the usage.\n", cmd.name)
|
|
return exitUsage
|
|
}
|
|
if *help {
|
|
writeCommandHelp(cmd, stdout)
|
|
return exitOK
|
|
}
|
|
if cmd.handler != nil {
|
|
return cmd.handler(positional, sayOpts, stdin, stdout, stderr)
|
|
}
|
|
fmt.Fprintf(stderr, "talk %s is not implemented yet\n", cmd.name)
|
|
return exitFailure
|
|
}
|
|
|
|
// runDefault handles calls without a known subcommand: `talk "text"`,
|
|
// `talk --version`, `talk --help`, and say flags before or after the text.
|
|
func runDefault(argv []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
fs := flag.NewFlagSet("talk", flag.ContinueOnError)
|
|
fs.SetOutput(stderr)
|
|
fs.Usage = func() {}
|
|
showVersion := fs.Bool("version", false, "show the version")
|
|
help := fs.Bool("help", false, "show this help")
|
|
sayOpts := addSayFlags(fs)
|
|
|
|
positional, err := parseInterspersed(fs, argv)
|
|
if err != nil {
|
|
if errors.Is(err, flag.ErrHelp) {
|
|
writeUsage(stdout)
|
|
return exitOK
|
|
}
|
|
fmt.Fprintln(stderr, "hint: `talk --help` shows the usage.")
|
|
return exitUsage
|
|
}
|
|
if *showVersion {
|
|
fmt.Fprintln(stdout, versionString())
|
|
return exitOK
|
|
}
|
|
if *help {
|
|
writeUsage(stdout)
|
|
return exitOK
|
|
}
|
|
return runSay(positional, sayOpts, stdin, stdout, stderr)
|
|
}
|
|
|
|
// versionString renders the version output: `talk version <version>` plus
|
|
// the commit hash in parentheses when injected.
|
|
func versionString() string {
|
|
return "talk version " + buildVersion()
|
|
}
|
|
|
|
// writeUsage writes the overall help; the command list is generated from the
|
|
// command table so help and dispatch never drift apart.
|
|
func writeUsage(w io.Writer) {
|
|
fmt.Fprint(w, `talk: voice notifications for scripts and AI agents
|
|
|
|
Usage:
|
|
talk [flags] "text" speak a text (shorthand for: talk say)
|
|
talk <command> [flags] run a command
|
|
talk help [command] show help
|
|
|
|
Commands:
|
|
`)
|
|
for _, cmd := range commands {
|
|
fmt.Fprintf(w, " %-8s %s\n", cmd.name, cmd.short)
|
|
}
|
|
fmt.Fprint(w, `
|
|
Flags:
|
|
--version show the version
|
|
--help show this help
|
|
|
|
The say flags also apply to talk "text"; details: talk say --help
|
|
Getting started: talk doctor, then talk test, then talk setup.
|
|
`)
|
|
}
|
|
|
|
// writeCommandHelp writes the help of a single command including its 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()
|
|
}
|