Skip to content

feat(cli): fetch one voice with voices get - #7

Merged
luke-speechify merged 4 commits into
mainfrom
feat/voices-get
Aug 28, 2026
Merged

feat(cli): fetch one voice with voices get#7
luke-speechify merged 4 commits into
mainfrom
feat/voices-get

Conversation

@ayushkcs

@ayushkcs ayushkcs commented Aug 18, 2026

Copy link
Copy Markdown
Member

feat(cli): fetch one voice with voices get

Adds voices get <voice-id> for GET /v1/voices/{voice_id}, plus a matching get_voice MCP tool. One request, no catalog download.

API Reference

Get Voice - GET https://api.speechify.ai/v1/voices/{voice_id}

1. One Command, One Tool

voices list is unchanged. voices get answers a different question: not "what can I use", but "is this exact id usable, and what does it support".

voices list voices get
Endpoint /v1/voices /v1/voices/{voice_id}
Returns The whole catalog One voice
Requests Every page, to the end Exactly one
models ["simba-3.0", ...] [{ name, languages }]
Preview URLs Not shown Voice and per locale
Human output A table A labelled block

The MCP get_voice tool calls the same core function the command does, so there is one copy of the logic. One voice is about 1 KB of payload against 983 voices for the full catalog.

2. Mechanism - which errors we rewrite

The server answers a missing voice with "Voice not found." and nothing else, which tells you neither what went wrong nor what to do. That one message is replaced. Everything else reaches you exactly as the API sent it.

image
  • The id is checked for emptiness before the request, so a bad value costs no request and no quota. Ids stay opaque, nothing else about them is inspected.

  • A 404 tagged voice_not_found becomes a message that names the id and explains that a cloned voice is only visible to the workspace that owns it. The error code, the status, the request id and the original error are all kept.

  • A 404 carrying any other code is passed through, because it would mean something we have not modelled and blaming the voice id would be a guess.

  • 401, 429, 5xx and transport failures are thrown onward untouched, so the shared error handler picks the exit code as it does for every other command.

Two details come from what the live API actually returns rather than from the spec. Most shared voices send "avatar_image": "" instead of omitting the field, so an empty string reads as absent and the line is dropped rather than printed blank. And an unknown gender, type or model name is passed through as plain text, so a new value in the catalog cannot break the command.

3. Behaviour

YOU TYPE RESULT EXIT WHY
voices get george THE VOICE 0 One request. Nothing else is fetched
voices get george --json BARE PAYLOAD 0 Payload on stdout, nothing on stderr
voices get george --agent-friendly PAYLOAD PLUS HINTS 0 Same payload, wrapped with what to do next
voices get " george\n" THE VOICE 0 Trimmed first, so a value from a shell variable still works
voices get "" (or a space) REFUSED 65 Caught before the request. The message points at voices list
voices get (no id, in a script) NEEDS INPUT 2 Returns the structured input spec so a caller can supply it and retry
voices get (no id, in a terminal) REFUSED 65 Names the exact command to run. It never waits on a prompt
voices get nope NOT FOUND 69 Names the id, says a cloned voice is private to its workspace
voices get "a/b" (or "../models") NOT FOUND 69 The id is URL encoded, so it stays an id and cannot reach another route
voices get george --json | head -3 QUIET EXIT 0 A closed pipe is normal, not a crash
voices get george --workspace ws_other THAT WORKSPACE 0 For this one command only. Nothing is saved
voices get george extra REFUSED 1 Commander rejects the extra argument by name

4. Failure Handling

SITUATION EXIT WHAT YOU ARE TOLD
Unknown voice id 69 Our message naming the id, plus the request id
Cloned voice in another workspace 69 The same message, since the API cannot distinguish the two
A 404 with any other error code 69 The server's own message and code, untouched
Bad or missing credential 77 / 78 The shared auth message and the usual codes
Rate limited 75 The server's own message, plus its request id
No workspace selected 78 Run workspace use, as with every workspace command
Host unreachable 1 fetch failed, with no code. Pre-existing on every SDK path, PLAN defect 12

5. Verification

image
npx tsc --noEmit && npx biome check src && npx vitest run
image

175 unit tests are passing, 23 of them new: 14 for the service, 7 for the command in all three output modes plus the needs-input path, and 2 for the MCP tool. Fixtures are trimmed from real recorded responses. The pnpm scripts need the esbuild install approval that #6 adds, so the same tools were run directly here.

image

6. Main Changes

File State What it does
core/voices.ts EDITED The domain type, the mapper, the id check, and the fetch with its 404 handling
commands/voices.ts EDITED The get subcommand, the printed block, and a shared client helper
mcp/server.ts EDITED The get_voice tool, over the same core function
mcp/run.ts EDITED The startup line that lists the tools by hand
core/voices.test.ts EDITED 14 tests: the mapper, the id check, and every failure route
commands/voices.test.ts NEW 7 tests: all three output modes, plus the missing input path
mcp/server.test.ts EDITED Registration, a mapped result, and a rejected empty id

Three commits: the core service, the command, then the MCP tool.

7. Notes

getVoice() fetches GET /v1/voices/{voice_id} and maps it to a camelCase
VoiceDetail, keeping the per-model locales and preview URLs that the list
view flattens to bare names.

An empty id is rejected before the request is sent. The live catalog sends
"avatar_image": "" for most shared voices, so an empty string reads as
absent, same as null. A 404 becomes a message that names the id and says a
cloned voice is only visible to the workspace that owns it, keeping the
error code, request id and original cause. Any other 404, and every other
failure, is passed through untouched.

Tests: 152 -> 166.
Prints one voice: its models with the locales each one covers, its tags,
and its preview URLs. Human mode is a labelled block, --json stays a bare
payload, agent mode adds context and hints.

A missing id returns the needs-input spec at exit 2 in a script, and names
the command to run when there is a terminal. It never waits on a prompt.

The resolve, guard and build preamble is now one helper shared by both
voice subcommands instead of a second copy. voices list is unchanged.

Tests: 166 -> 173.
Calls the same core function the command does. It lets an assistant check
a single voice id, about 1 KB of payload, instead of pulling the whole
catalog. The startup line lists the tools by hand, so it is updated too.

Tests: 173 -> 175.

@luke-speechify luke-speechify left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is clean, and it mirrors keys create exactly, which is the problem. that pattern is the one I want to change, so I'd rather not add a second command to it quietly.

voices get with no id, at a terminal, is the case I'd reserved for interactive: one required argument, nothing else supplied, a human sat in front of it. commands/voices.ts:116 sends it to a 65 instead, and the description writes "It never waits on a prompt" down as intended behaviour. not a regression, keys create already does it, but it turns it into precedent and I'd like to stop that at one.

the rule I never wrote down: global flags (--api-key, --workspace, --json) never change whether a command can prompt, any command flag or argument does, and a bare command on a TTY that isn't CI and isn't an agent is the interactive case. prompting isn't built yet so nothing in this PR behaves wrongly today, it's the intent I don't want baked into the description and the tests. #8 has the write-up, and the heavier version of this note is on #6.

one bit is fixable now. the 65 branch prints a single sentence while json and agent get the whole of GET_INPUTS, so the human gets less than the agent does. emitNeedsInput already renders that list readably, so throwing NeedsInputError on both sides and letting bin.ts render it gets you there without a second message to keep in sync.

asks:

  • drop "It never waits on a prompt" from the description, that's how it works today rather than how we want it
  • give the human branch the same input list the agent branch gets

separately, the --json | head -3 line: there's no EPIPE handling on the JSON path, only in #6's sink, so that exits 0 while the payload fits the pipe buffer and not by design. I'd not claim it.

--workspace ws_other staying per-command with nothing persisted is right, and the avatar_image: "" handling is a good catch from the live API.


Generated by Claude Code

Merge branch 'main' into feat/voices-get, resolving conflicts in the MCP
server and its tests so get_voice and the stream_text_to_speech tool
coexist.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@luke-speechify

Copy link
Copy Markdown
Contributor

Conflicts resolved. Merged main into feat/voices-get and fixed the overlapping changes in src/mcp/server.ts and src/mcp/server.test.ts so the new get_voice tool and the stream_text_to_speech tool coexist.

  • server.ts: combined the imports (kept main's streaming imports + added getVoice).
  • server.test.ts: merged the SDK mocks (speech/stream/list/get), the per-test resets, and the expected tool-registration list now includes all five tools.

Verified: pnpm typecheck clean, all 238 tests pass, pnpm lint clean.

@luke-speechify
luke-speechify merged commit 7d607e5 into main Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants