Files
talk/cmd/talk/cli.go
T
m3ta-chironandClaude Fable 5.1 07c89f4939
test / test (push) Canceled after 0s
feat(cli): talk version as a subcommand
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>
2026-10-25 12:53:30 +01:00

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()
}