The daemon (and talk test --toast) now register the AppUserModelID m3ta.talk under HKCU with display name and the installed icon, so toasts appear as "talk" with the icon instead of Windows PowerShell; the PowerShell identity stays as fallback until the registration has run once. The redundant "talk" title line is dropped under the own identity. notify.Setup bundles icon install, identity and the talk: protocol handler for both callers. Linux notifications carry the installed icon as app_icon. doctor names the active identity. Verified on the Windows 11 VM: registry key, icon file, toast with working Cancel button, exe icon; Linux via quickshell. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
469 lines
23 KiB
Markdown
469 lines
23 KiB
Markdown
<img src="assets/icon/talk.svg" alt="" width="96" align="right">
|
|
|
|
# talk
|
|
|
|
> **Status: WIP**, under active development. Specification with the decision
|
|
> addendum: [`docs/spec.md`](docs/spec.md)
|
|
|
|
**talk** gives scripts and AI agents a voice: one call, `talk "Build done"`,
|
|
speaks a short announcement through a chain of TTS providers
|
|
(ElevenLabs → OpenAI → Edge TTS → OS voice), serialized by a background
|
|
daemon that talk starts itself on first use. One static binary,
|
|
cross-platform (Windows, macOS, Linux; amd64 and arm64), no runtime
|
|
dependencies.
|
|
|
|
## Commands
|
|
|
|
| Command | Purpose |
|
|
| --- | --- |
|
|
| `talk "text"` | announce; shorthand for `talk say`, non-blocking |
|
|
| `talk say "text"` | announce (text as argument or from stdin) |
|
|
| `talk wrap -- <command>` | run a command, announce success/failure and duration |
|
|
| `talk mute` / `talk unmute` | mute all announcements persistently / turn them back on |
|
|
| `talk status` | daemon status (queue, mute, provider, laws, version) |
|
|
| `talk cancel` | cancel the playing and all queued announcements |
|
|
| `talk uri <talk:…>` | handler behind the `talk:` links of notification buttons (Windows); not called by hand |
|
|
| `talk daemon run\|start\|stop` | run the daemon in the foreground / start it in the background / stop it (normally not needed, see below) |
|
|
| `talk voices` | known voices per provider and language, default marked (`--provider`, `--lang`, `--json`) |
|
|
| `talk test` | speak a test sentence; shows provider and timings (`--lang`, `--provider`, `--voice`); `--toast` sends a test notification instead |
|
|
| `talk doctor` | self-diagnosis: config, daemon, audio, providers, notifications, mute, laws (`--probe`, `--json`) |
|
|
| `talk setup` | first-time setup in a few questions: language, voices with preview, quiet hours, config.toml |
|
|
|
|
Say flags (also for `talk "text"`): `--wait`, `--now`, `--priority`,
|
|
`--voice`, `--lang`, `--provider`.
|
|
|
|
## Installation
|
|
|
|
**Release binary** (Linux, macOS, Windows; amd64/arm64): download the
|
|
matching `talk_<os>_<arch>` from the
|
|
[releases](https://code.m3ta.dev/m3tam3re/talk/releases), verify the
|
|
checksum against `checksums.txt`, put it in your PATH as `talk`. No other
|
|
dependencies.
|
|
|
|
```console
|
|
curl -LO https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/talk_linux_amd64
|
|
sha256sum -c --ignore-missing <(curl -sL https://code.m3ta.dev/m3tam3re/talk/releases/latest/download/checksums.txt)
|
|
install -m 755 talk_linux_amd64 ~/.local/bin/talk
|
|
```
|
|
|
|
**Nix** (flake): `nix run git+https://code.m3ta.dev/m3tam3re/talk -- doctor`,
|
|
or in your configuration `inputs.talk.url = "git+https://code.m3ta.dev/m3tam3re/talk";`
|
|
and `inputs.talk.packages.${system}.talk` or `inputs.talk.overlays.default`.
|
|
The package brings espeak-ng (OS voice) and libnotify (toast) along.
|
|
|
|
**From source:** `go install code.m3ta.dev/m3tam3re/talk/cmd/talk@latest`.
|
|
|
|
## Getting started
|
|
|
|
```console
|
|
talk doctor # What is there, what is missing? One line per item.
|
|
talk test # Do you hear something? Which voice was it?
|
|
talk setup # Pick a language and voices, set quiet hours, done.
|
|
talk "Hello world" # From now on scripts and agents call exactly this.
|
|
```
|
|
|
|
talk works without any key: it uses Microsoft's free Edge voice (needs
|
|
internet) and otherwise your operating system's voice. For a premium voice,
|
|
set `TALK_ELEVENLABS_KEY` or `TALK_OPENAI_KEY` as an environment variable.
|
|
Keys never go into config.toml.
|
|
|
|
### Diagnosis and setup
|
|
|
|
- **`talk doctor`** reports one line per item with ✔ (ok), ⚠ (note), ✘
|
|
(error): config file, daemon (running / starts on the next speak command
|
|
/ foreign service on the port), audio output (can oto initialize? `null`
|
|
= no sound), every provider tier (key present, endpoint, default voice;
|
|
OS voice: which mechanism was found), notifications, mute, laws
|
|
including an active quiet window. Exit 0 without ✘, else 1; a daemon that
|
|
is not running is ⚠. `--probe` runs real synthesis probes (network),
|
|
`--json` returns the list for machines. Windows consoles get
|
|
`[OK]`/`[WARN]`/`[FAIL]` instead of the symbols.
|
|
- **`talk test`** speaks a fixed test sentence directly in this process (not
|
|
via the daemon; mute and quiet hours do not apply here, an active mute is
|
|
mentioned) and shows which tier spoke and how long synthesis and playback
|
|
took. `talk test --toast` shows a test notification with the Cancel button
|
|
and waits for a click, so you can check the notification setup without
|
|
speaking anything.
|
|
- **`talk voices`** lists the curated voice catalog (ElevenLabs premade,
|
|
OpenAI, Edge neural voices per locale) as a table; `*` marks the voice
|
|
talk uses without `--voice` (config `voice.<provider>.<lang>` or the
|
|
built-in default). Config entries outside the catalog appear as their own
|
|
row. Any voice ID the provider knows works without a catalog entry.
|
|
- **`talk setup`** asks in the terminal: keys (only a hint; without a key
|
|
the Edge voice speaks), the fallback language, a voice for each language
|
|
of the active tier (number from the catalog or a voice ID, preview,
|
|
accept), quiet hours. Then it writes config.toml, merging an existing
|
|
file (other settings stay) and backing it up as `.bak`, and speaks a
|
|
test sentence. `q` aborts at any point; the file is written only at the
|
|
very end. Without a terminal (pipe, agent) setup asks nothing and only
|
|
shows the effective configuration.
|
|
|
|
### Notifications with a Cancel button
|
|
|
|
Every announcement that plays also shows a desktop notification with its
|
|
text and a **Cancel** button (German: Abbrechen) that stops that
|
|
announcement without a terminal. The notification disappears when the
|
|
announcement ends.
|
|
|
|
- **Linux:** talk talks to the freedesktop notification service on the
|
|
session bus directly (any daemon with the "actions" capability: mako,
|
|
dunst, GNOME Shell, KDE, quickshell) and receives the click over D-Bus.
|
|
The notification carries the talk icon. Without a session bus it falls
|
|
back to notify-send without a button.
|
|
- **Windows:** a toast from "talk" with its icon and a Cancel button. The
|
|
button opens a `talk:` link; the daemon registers that URL protocol for
|
|
the current user (HKCU, no admin rights) so Windows starts
|
|
`talk uri talk:cancel?id=…`, which tells the daemon to stop the
|
|
announcement. The toast identity (`AppUserModelId m3ta.talk`, also HKCU)
|
|
is registered the same way on the first daemon start or `talk test
|
|
--toast`; before that the toast appears under Windows PowerShell's
|
|
identity.
|
|
- **macOS:** Notification Center, no button and no icon yet.
|
|
|
|
The icon file lives next to the config (`talk.png` in the config
|
|
directory); talk writes it there itself.
|
|
|
|
Turn it off with:
|
|
|
|
```toml
|
|
[notify]
|
|
on_speak = false # env TALK_NOTIFY=false
|
|
```
|
|
|
|
`talk test --toast` shows one such notification, waits a few seconds for a
|
|
click on Cancel and reports what happened; `talk doctor` names the service
|
|
and whether it supports the button.
|
|
|
|
### Language
|
|
|
|
talk detects German and English per announcement (word lists plus umlauts;
|
|
no ML) and picks the voice accordingly. When it cannot tell (numbers,
|
|
single words, ties) it uses the configured fallback language, which is also
|
|
the language of talk's own announcements such as the `wrap` result:
|
|
|
|
```toml
|
|
lang = "en" # de | en; default en (env TALK_LANG)
|
|
```
|
|
|
|
## Daemon
|
|
|
|
There is exactly one binary. The daemon is `talk daemon run`: the same code,
|
|
in the background. It owns the announcement queue and plays serially, exactly
|
|
one announcement at a time, in the order `now` > `high` > `normal` > `low`
|
|
(FIFO per level). The HTTP server listens on `127.0.0.1` only; port: default
|
|
`7800`, config `[server] port`, env `TALK_PORT`.
|
|
|
|
**Autostart:** when no daemon runs, `talk say` (and `talk wrap`) start it on
|
|
the first speak command, detached from the terminal (on Windows also broken
|
|
away from the caller's job object, so a WinRM, SSH or CI session does not
|
|
take it down), logging to `daemon.log` with its PID in `daemon.pid` in the
|
|
config directory; stderr says "daemon started" once. The daemon then keeps running (idle costs next to nothing)
|
|
until `talk daemon stop`, logout or reboot. Parallel first calls are
|
|
harmless: whoever loses the port race uses the other one's daemon. If you
|
|
manage the daemon yourself (systemd, launchd, Task Scheduler with `talk
|
|
daemon run`), turn autostart off:
|
|
|
|
```toml
|
|
[daemon]
|
|
autostart = false # env TALK_AUTOSTART=false
|
|
```
|
|
|
|
Control commands (`mute`, `unmute`, `status`, `cancel`) deliberately do not
|
|
start the daemon: `talk status` then says "not running" (exit 1), `talk
|
|
mute` writes the mute file directly.
|
|
|
|
| Endpoint | Effect |
|
|
| --- | --- |
|
|
| `POST /speak` | `{text, priority?, provider?, voice?, lang?, now?}` → `202 {"id":"…"}` (+ `"truncated":true` when cut to `max_text`); muted: `202 {"id":"","muted":true}`; law discard: `202 {"id":"","discarded":"rate_limit"\|"quiet_hours"}` (+ `"toast":true` in toast mode); documented behaviour, not an error |
|
|
| `OPTIONS /speak` | CORS preflight → `204` with allow headers when the `Origin` is listed in `server.allowed_origins` (see below); otherwise `204` without CORS headers |
|
|
| `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "limits": {"max_text", "max_age"}, "mcp": true, "cors": [origins]}` |
|
|
| `POST /cancel` | aborts the playing announcement and empties the queue → `200 {"cancelled":n}` |
|
|
| `POST /cancel-item` | `{"id":"…"}` cancels only that announcement if it is playing (notification Cancel button) → `200 {"cancelled":bool}` |
|
|
| `POST /mute` / `POST /unmute` | persistent mute state → `200 {"muted":bool}` |
|
|
| `POST /shutdown` | shuts the daemon down (`talk daemon stop`) → `200 {"ok":true}` |
|
|
| `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`; one tool `speak` (see below) |
|
|
| `GET /mcp` | `405`; the daemon offers no server-initiated streams |
|
|
|
|
- **CLI behaviour:** `talk say` (and `talk "text"`) detects the daemon via
|
|
`GET /status`. If it runs: enqueue and return at once (non-blocking,
|
|
stdout gets the item ID); `--wait` polls until spoken; `--now` jumps the
|
|
queue; when muted the CLI prints `muted`, on a law discard
|
|
`discarded: <reason>`. `talk mute`/`unmute`/`status`/`cancel` are daemon
|
|
calls with plain output.
|
|
- **Mute is persistent** (file `mute` in the config directory, content
|
|
`true`/`false`) and survives daemon restarts. `talk mute` without a
|
|
running daemon writes the file directly; the daemon re-reads it on every
|
|
announcement, so the state applies offline too.
|
|
- **Direct path:** if no daemon is reachable and none can be started
|
|
(autostart off, port taken by something else, start failed), `talk`
|
|
speaks directly in its own process: blocking, without a queue (parallel
|
|
calls may overlap), with a note and the reason on stderr. Mute is
|
|
honoured here too. Nothing fails silently.
|
|
|
|
### Rate limit, quiet hours, length and age
|
|
|
|
The daemon enforces the anti-spam laws as a backstop under whatever a
|
|
prompt does. **Rate limit:** every *started* announcement books a time
|
|
window, default **1 announcement / 30 s**. Until the window elapses:
|
|
|
|
- `priority=low` is **discarded** (already on admission; the 202 response
|
|
says `discarded: "rate_limit"`); if the window was open on admission, the
|
|
worker decides later.
|
|
- `normal` **parks** in the queue and plays at the next open slot (visible
|
|
in `/status` as `rate_limit.next_slot`).
|
|
- `priority=high` may **deliberately break** the limit with
|
|
`queue.allow_high_burst = true` (default); without burst rights high
|
|
waits like normal.
|
|
- `now:true` **bypasses the rate limit too**; the documented escape for
|
|
urgent matters (it still books a new window).
|
|
|
|
**Quiet hours:** `quiet_hours = "18:00-08:00"` (local time, may span
|
|
midnight; empty/`off` = disabled; deliberately no default window, quiet
|
|
hours are opt-in like mute). During quiet hours audio is dropped, also for
|
|
high (burst is a rate right, not a quiet right):
|
|
|
|
- `quiet_mode = "discard"` (default): drop silently.
|
|
- `quiet_mode = "toast"`: a desktop notification instead of audio (the
|
|
notification service or the system's own tool, see "Notifications"), best
|
|
effort: if nothing is available, only the log line remains and
|
|
`talk doctor` shows it under "Toast".
|
|
- `now:true` **breaks out of quiet hours too** (a deliberate interruption).
|
|
|
|
**Length and age:** two more laws against what prompts like to do, reading
|
|
out the long answer and piling up announcements:
|
|
|
|
- `max_text` (default **400 characters**, two to three sentences): longer
|
|
texts are **cut** at a word boundary on admission and spoken like that;
|
|
the response reports `"truncated": true`, the CLI warns on stderr, the
|
|
MCP result says so in its text. The user hears the beginning instead of a
|
|
ten-minute lecture, and the API budget stays bounded.
|
|
- `max_age` (default **2 minutes**): whatever parks longer in the queue
|
|
(rate limit, backlog) is no longer news and is **discarded** when it
|
|
comes due (`discarded: "expired"` in the log; the 202 on admission stays a
|
|
promise, because nothing is stale on admission). `now:true` never waits
|
|
long and is exempt.
|
|
|
|
**Precedence:** manual mute always wins > `now` > stale (`max_age`) > quiet
|
|
hours > rate limit; `max_text` applies on admission to everything.
|
|
Configuration (config.toml; env beats the file):
|
|
|
|
```toml
|
|
[queue]
|
|
rate_limit = "30s" # duration; "0s" = unlimited (TALK_RATE_LIMIT)
|
|
allow_high_burst = true # high may break the limit (TALK_ALLOW_HIGH_BURST)
|
|
max_text = 400 # characters; 0 = unlimited (TALK_MAX_TEXT)
|
|
max_age = "2m" # longest wait; "0s" = unlimited (TALK_MAX_AGE)
|
|
|
|
quiet_hours = "18:00-08:00" # empty = off; env TALK_QUIET_HOURS ("off" = off)
|
|
quiet_mode = "discard" # discard | toast (TALK_QUIET_MODE)
|
|
```
|
|
|
|
`talk status` shows the law state: `rate-limit: 30s (high burst allowed),
|
|
next slot …`, `quiet-hours: 18:00-08:00 (mode discard, active until 08:00)`
|
|
and `limits: max 400 characters, max wait 2m0s`.
|
|
|
|
### CORS door and MCP endpoint
|
|
|
|
**CORS** applies to `POST /speak` only, the one door a later browser
|
|
integration (for example a web chat UI) needs; control endpoints stay out of
|
|
reach of browsers. The allow-list holds exact origins; `"*"` is allowed but
|
|
not the default; **an empty list (default) means no CORS headers at all**:
|
|
|
|
```toml
|
|
[server]
|
|
allowed_origins = ["https://chat.example.com"] # env TALK_ALLOWED_ORIGINS
|
|
```
|
|
|
|
- `OPTIONS /speak` (preflight) from a listed origin → `204` with
|
|
`Access-Control-Allow-Origin` (the origin or `*`), `-Methods: POST,
|
|
OPTIONS`, `-Headers: Content-Type`, `Max-Age: 600`.
|
|
- A real `POST /speak` with a listed `Origin` carries the same header.
|
|
- **The door is shut server-side, not only browser-side.** CORS alone only
|
|
prevents *reading* the response; any web page can send a POST with a
|
|
`text/plain` body or a form POST without a preflight. Therefore the
|
|
daemon rejects every request with a foreign `Origin` header with `403`
|
|
unless it is POST /speak from a listed origin: unlisted origins on
|
|
/speak, and any browser access to `/status`, `/mute`, `/unmute`,
|
|
`/cancel` and `/mcp`, even from listed origins. Only the preflight
|
|
`OPTIONS /speak` stays a `204` without CORS headers for unlisted origins
|
|
(it has no effect; the browser drops the planned POST). CLI, curl and MCP
|
|
clients send no `Origin` header and are unaffected.
|
|
|
|
**MCP** (`POST /mcp`, Streamable HTTP): a lean own implementation of the
|
|
JSON-RPC subset, no SDK: `initialize` (the requested `protocolVersion` is
|
|
echoed; result `protocolVersion`, `capabilities: {tools:{}}`,
|
|
`serverInfo`), `notifications/initialized` (notification → `202` without
|
|
body), `ping`, `tools/list`, `tools/call`. The server is stateless (no
|
|
session IDs) and pushes nothing (hence `GET /mcp` → `405`). One tool:
|
|
|
|
```json
|
|
{"name": "speak",
|
|
"inputSchema": {"type": "object",
|
|
"properties": {"text": {"type": "string"},
|
|
"priority": {"enum": ["low","normal","high"]},
|
|
"provider": {"type": "string"},
|
|
"lang": {"enum": ["de","en"]},
|
|
"now": {"type": "boolean"}},
|
|
"required": ["text"]}}
|
|
```
|
|
|
|
`tools/call` takes exactly the same admission path as `POST /speak`
|
|
(including mute, rate limit, quiet hours, truncation). The result is always
|
|
text content: `"queued: <id>"` on admission; `muted`/`discarded: <reason>`
|
|
with an explanation, deliberately `isError: false`, because a discard is
|
|
the correct documented result, not a protocol error. Validation errors
|
|
(missing text, invalid priority, unknown tool) are JSON-RPC errors
|
|
(`-32602`). Handshake as a curl sequence:
|
|
|
|
```sh
|
|
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
|
|
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
|
|
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
|
|
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
|
|
curl -s http://127.0.0.1:7800/mcp -H 'Content-Type: application/json' \
|
|
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"speak","arguments":{"text":"Build done"}}}'
|
|
```
|
|
|
|
### Provider chain
|
|
|
|
Without a pin, the four-tier fallback chain runs
|
|
**ElevenLabs → OpenAI TTS → Edge TTS → OS built-in**: on a missing key, an
|
|
HTTP error, a timeout or a network problem the chain falls through to the
|
|
next tier automatically; every fallback is logged to stderr (never silent).
|
|
Pin with `--provider=<name>` or `provider = "<name>"` in config.toml.
|
|
|
|
| Tier | Key (env) | Default endpoint | Audio |
|
|
| --- | --- | --- | --- |
|
|
| `elevenlabs` | `TALK_ELEVENLABS_KEY` | `https://api.elevenlabs.io` | mp3 |
|
|
| `openai` | `TALK_OPENAI_KEY` | `https://api.openai.com` | mp3 |
|
|
| `edge` | none (free) | `wss://speech.platform.bing.com/...` | mp3 |
|
|
| `osbuiltin` | none | local OS TTS | WAV/PCM |
|
|
|
|
Keys come exclusively from environment variables (never config.toml).
|
|
Endpoints are configurable: file `[providers.<name>] endpoint = "…"` or env
|
|
`TALK_ELEVENLABS_ENDPOINT` / `TALK_OPENAI_ENDPOINT` / `TALK_EDGE_ENDPOINT`
|
|
(env beats the file; this is how tests and CI point the chain at fake
|
|
servers). The OpenAI model is configurable (`[providers.openai] model`,
|
|
default `tts-1`).
|
|
|
|
Voices: the neutral built-in defaults per provider and language (ElevenLabs
|
|
"Rachel", OpenAI "alloy", Edge `de-DE-KatjaNeural`/`en-US-AriaNeural`) can
|
|
be overridden per language in config.toml:
|
|
|
|
```toml
|
|
[voice.elevenlabs]
|
|
de = "21m00Tcm4TlvDq8ikWAM" # your own voice per language
|
|
en = "21m00Tcm4TlvDq8ikWAM"
|
|
|
|
[voice.edge]
|
|
de = "de-DE-KatjaNeural"
|
|
```
|
|
|
|
Precedence: `--voice` flag > `voice.<provider>.<lang>` > built-in default.
|
|
MP3 is decoded by the binary itself (pure Go decoder, no ffmpeg, CGO-free).
|
|
|
|
Edge TTS is an undocumented Microsoft endpoint with a DRM token scheme that
|
|
Microsoft changes now and then. When that happens, the edge tier fails with
|
|
a clear "protocol changed?" error and the chain falls through to the OS
|
|
voice; `talk doctor --probe` makes it visible.
|
|
|
|
### OS TTS on Linux
|
|
|
|
The `osbuiltin` provider uses two routes on Linux:
|
|
|
|
- **Audio bytes** (playback through the built-in oto sink): needs
|
|
`espeak-ng` (or `espeak`). On NixOS as a dev dependency:
|
|
`nix shell nixpkgs#espeak-ng`.
|
|
- **Direct speech** without espeak: `spd-say` (speech-dispatcher) plays by
|
|
itself through its daemon but cannot deliver bytes to a file or pipe.
|
|
`talk` takes this route automatically when espeak is missing.
|
|
|
|
`TALK_AUDIO_SINK=null` (or `audio.sink = "null"` in config.toml) turns all
|
|
sound off, for CI and servers.
|
|
|
|
## `talk wrap`: announce commands
|
|
|
|
`talk wrap -- <command> [args …]` runs the command and makes the result
|
|
audible: stdin/stdout/stderr are passed through; stdout belongs to the
|
|
command alone, talk's information lines (for example the announcement's
|
|
item ID) go to stderr so pipes and redirects stay clean. Wall time is
|
|
measured and announced after the command ends:
|
|
|
|
- success: "sleep succeeded. Duration: 2 seconds."
|
|
- failure: "sh failed, code 2." (without duration)
|
|
|
|
```console
|
|
talk wrap -- sleep 2
|
|
talk wrap --wait -- make test
|
|
```
|
|
|
|
- **`--` shields the command completely:** the first standalone `--` ends
|
|
talk's flag parsing; everything after it goes to the command unchanged.
|
|
`talk wrap -- foo --bar` passes `--bar` to foo instead of misreading it
|
|
as a talk flag. talk's flags for the announcement therefore go before it
|
|
(`talk wrap --wait -- …`); without `--` the usual rules apply (flags
|
|
anywhere, also after positional arguments).
|
|
- **Label** of the announcement: `wrap.label` in config.toml (env
|
|
`TALK_WRAP_LABEL` beats the file) or the command's base name
|
|
(`/usr/bin/make` → "make"). Language detection runs on the label (de/en,
|
|
falling back to the configured `lang`); `--lang` overrides.
|
|
- **Exit code policy:** wrap always exits with the command's code; if the
|
|
announcement itself fails, the command's code still stands (only a
|
|
warning on stderr; talk errors are not command errors). Commands that
|
|
cannot start (binary not found) count as 127 (shell convention), signal
|
|
deaths portably as 1. Usage errors of talk itself (no command, invalid
|
|
flags) fail before the command starts.
|
|
- **Announcement path:** the same as say: daemon (enqueue, non-blocking;
|
|
`--wait` honoured; autostart), without a daemon the direct path. wrap
|
|
inherits the say flags (`--wait`, `--now`, `--priority`, `--voice`,
|
|
`--lang`, `--provider`) and passes them on.
|
|
|
|
```toml
|
|
[wrap]
|
|
label = "Deployment" # announcement label instead of the command's base name (TALK_WRAP_LABEL)
|
|
```
|
|
|
|
## For agents: skill and standing order
|
|
|
|
[`skills/talk/SKILL.md`](skills/talk/SKILL.md) is the etiquette as a skill
|
|
artifact: **strict opt-in** (speak only when asked in the session or by a
|
|
standing order in the project's AGENTS.md, session-sticky until revoked),
|
|
content = status · next step · what the user is needed for, never the long
|
|
answer, honour `muted`/`discarded`. The three-line block for project
|
|
AGENTS.md files is in
|
|
[`skills/talk/AGENTS-snippet.md`](skills/talk/AGENTS-snippet.md). The daemon
|
|
enforces rate limit, length and age limits, mute and quiet hours regardless
|
|
of what the prompt says.
|
|
|
|
## Development
|
|
|
|
```console
|
|
go build ./... # build
|
|
go test ./... # tests
|
|
go vet ./... # static analysis
|
|
gofmt -l . # formatting check (empty output = ok)
|
|
nix develop # devShell with go, gopls, espeak-ng, libnotify
|
|
```
|
|
|
|
CI: `.gitea/workflows/test.yml` (Gitea Actions) runs vet, the gofmt check,
|
|
tests and build on every push and pull request.
|
|
|
|
**Icon:** the source is `assets/icon/talk.svg`. `go run assets/icon/gen.go`
|
|
(needs inkscape) renders the embedded PNG (`internal/icon/talk.png`), the
|
|
Windows `talk.ico` and the resource objects `cmd/talk/rsrc_windows_*.syso`
|
|
that give `talk.exe` its icon; all of them are committed, so a normal build
|
|
needs no resource compiler.
|
|
|
|
**Release:** push a `v*` tag → `.gitea/workflows/release.yml` tests, builds
|
|
the six targets with `scripts/release.sh` (static, `CGO_ENABLED=0`, version
|
|
via ldflags) and creates a Gitea release with `talk_<os>_<arch>[.exe]` and
|
|
`checksums.txt`. Locally: `scripts/release.sh 1.2.3` → `dist/`. Deliberately
|
|
without GoReleaser: a shell script around `go build` yields the same
|
|
artifact locally and in CI without another tool. `nix build .#talk` builds
|
|
the Nix package (after changes to go.mod, update the `vendorHash` in
|
|
flake.nix; `nix build` prints the expected value).
|
|
|
|
## License
|
|
|
|
MIT, see [LICENSE](LICENSE).
|