feat(cli): fetch one voice with voices get - #7
Conversation
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
left a comment
There was a problem hiding this comment.
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>
|
Conflicts resolved. Merged
Verified: |
feat(cli): fetch one voice with
voices getAdds
voices get <voice-id>forGET /v1/voices/{voice_id}, plus a matchingget_voiceMCP 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 listis unchanged.voices getanswers a different question: not "what can I use", but "is this exact id usable, and what does it support".voices listvoices getmodels["simba-3.0", ...][{ name, languages }]The MCP
get_voicetool 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.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_foundbecomes 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
voices get georgevoices get george --jsonvoices get george --agent-friendlyvoices get " george\n"voices get ""(or a space)voices listvoices get(no id, in a script)voices get(no id, in a terminal)voices get nopevoices get "a/b"(or"../models")voices get george --json | head -3voices get george --workspace ws_othervoices get george extra4. Failure Handling
workspace use, as with every workspace commandfetch failed, with no code. Pre-existing on every SDK path, PLAN defect 125. Verification
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
pnpmscripts need the esbuild install approval that #6 adds, so the same tools were run directly here.6. Main Changes
core/voices.tscommands/voices.tsgetsubcommand, the printed block, and a shared client helpermcp/server.tsget_voicetool, over the same core functionmcp/run.tscore/voices.test.tscommands/voices.test.tsmcp/server.test.tsThree commits: the core service, the command, then the MCP tool.
7. Notes
biome.jsonandpnpm-workspace.yamlare left out on purpose since feat(cli): stream long-form speech with say --stream #6 already carries them.feat(cli): stream long-form speech with say --stream #6 also adds a tool in
mcp/server.ts, so whichever of the two lands second needs a small rebase of the tool-list assertion inmcp/server.test.ts.