From 352920613e9a2ef2b3112e66be9648e03ad30057 Mon Sep 17 00:00:00 2001 From: luke-speechify <289678208+luke-speechify@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:58:49 +0100 Subject: [PATCH] docs(cli): generated, agent-discoverable command & flag reference (DRG-483) Full docs covering every command and flag, generated from the CLI's own commander definitions so they can't drift from what the binary accepts. - `llms.txt` (llms.txt-standard index) + `llms-full.txt` (everything inline) at the repo root, and `docs/` with one page per command plus `global-options.md`. - `src/docs/reference.ts` walks the command tree and renders the docs deterministically (no dates/version); `pnpm docs:generate` writes them and a vitest drift guard fails `pnpm test` when the checked-in copies are stale (CI runs typecheck/build/test, so the guard is enforced there). - Extract a side-effect-free `buildProgram()` into `src/program.ts` so both the entry point and the generator build the same tree; `bin.ts` now imports it. - Give the positional args (`say [text]`, `api `, `voices get [voice-id]`) descriptions so they surface in both `--help` and the docs. - README: link the reference and document `pnpm docs:generate`. --- README.md | 31 +++-- docs/api.md | 26 +++++ docs/global-options.md | 16 +++ docs/login.md | 13 +++ docs/logout.md | 13 +++ docs/mcp-install.md | 20 ++++ docs/mcp.md | 21 ++++ docs/say.md | 32 ++++++ docs/voices-get.md | 17 +++ docs/voices-list.md | 19 ++++ docs/voices.md | 16 +++ docs/whoami.md | 17 +++ llms-full.txt | 226 +++++++++++++++++++++++++++++++++++++ llms.txt | 22 ++++ package.json | 1 + src/bin.ts | 65 +---------- src/commands/api.ts | 3 +- src/commands/say.ts | 3 +- src/commands/voices.ts | 3 +- src/docs/reference.test.ts | 57 ++++++++++ src/docs/reference.ts | 226 +++++++++++++++++++++++++++++++++++++ src/program.ts | 70 ++++++++++++ 22 files changed, 844 insertions(+), 73 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/global-options.md create mode 100644 docs/login.md create mode 100644 docs/logout.md create mode 100644 docs/mcp-install.md create mode 100644 docs/mcp.md create mode 100644 docs/say.md create mode 100644 docs/voices-get.md create mode 100644 docs/voices-list.md create mode 100644 docs/voices.md create mode 100644 docs/whoami.md create mode 100644 llms-full.txt create mode 100644 llms.txt create mode 100644 src/docs/reference.test.ts create mode 100644 src/docs/reference.ts create mode 100644 src/program.ts diff --git a/README.md b/README.md index 5c1f4df..c1876f3 100644 --- a/README.md +++ b/README.md @@ -245,22 +245,37 @@ Run `speechify mcp install --print` to see the exact command for your setup — the CLI is published, it spawns the running binary by absolute path rather than a bare `speechify` on your `PATH`. +## Reference + +Full, agent-discoverable docs cover every command and flag. They're **generated +from the CLI itself**, so they can't drift from what the binary accepts: + +- [`llms.txt`](llms.txt) — the [llms.txt](https://llmstxt.org)-standard index. +- [`llms-full.txt`](llms-full.txt) — every command and flag in one file. +- [`docs/`](docs/) — one page per command, plus [global options](docs/global-options.md). + +Regenerate after changing any command or flag with `pnpm docs:generate`; a test in +the suite fails if the checked-in copies are out of date. + ## Development ```bash pnpm install -pnpm build # tsup → dist/bin.js (executable, shebang'd) +pnpm build # tsup → dist/bin.js (executable, shebang'd) pnpm typecheck -pnpm test -pnpm lint # biome +pnpm test # includes the docs drift guard +pnpm lint # biome +pnpm docs:generate # rewrite llms.txt, llms-full.txt and docs/ from the CLI node dist/bin.js whoami ``` ## Architecture -`src/auth/session.ts` resolves an API key (flag / env / stored) into a single -`AuthContext` (the Bearer). `src/core/client.ts` wraps the `@speechify/api` SDK -for TTS. Commands in `src/commands/` are thin adapters over `src/core/`; -`src/mcp/` relays a local stdio MCP client to the hosted Speechify MCP server, -forwarding the resolved Bearer upstream. +`src/program.ts` assembles the commander command tree (side-effect-free) and +`src/bin.ts` runs it. `src/auth/session.ts` resolves an API key (flag / env / +stored) into a single `AuthContext` (the Bearer). `src/core/client.ts` wraps the +`@speechify/api` SDK for TTS. Commands in `src/commands/` are thin adapters over +`src/core/`; `src/mcp/` relays a local stdio MCP client to the hosted Speechify MCP +server, forwarding the resolved Bearer upstream. `src/docs/reference.ts` walks the +command tree to generate the reference docs. diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..c9d05d2 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,26 @@ + + +# speechify api + +Authenticated raw request to any API endpoint (gh-api style). + +## Usage + +``` +speechify api [options] +``` + +## Arguments + +- `` — API path (e.g. v1/voices, leading slash optional) or a full https:// URL + +## Options + +- `-X, --method ` — HTTP method (default GET, or POST when a body is present) +- `-f, --field ` — body field key=value; repeatable, builds a JSON body (true/false/null and numbers become typed; use --data for literal strings) +- `-d, --data ` — raw request body; @file reads a file, - reads stdin +- `-q, --query ` — query parameter key=value; repeatable +- `-H, --header ` — extra header 'Key: Value'; repeatable +- `-i, --include` — include the response status line and headers in the output + +Global options apply here too — see [global options](./global-options.md). diff --git a/docs/global-options.md b/docs/global-options.md new file mode 100644 index 0000000..55225b2 --- /dev/null +++ b/docs/global-options.md @@ -0,0 +1,16 @@ + + +# Global options + +These flags are accepted by every `speechify` command (and its subcommands). + +- `--api-key ` — Speechify API key (overrides login / $SPEECHIFY_API_KEY) +- `--api-version ` — pin the Speechify-Version header (ISO date, e.g. 2026-06-27) +- `--base-url ` — override the API origin (defaults to $SPEECHIFY_BASE_URL or production) +- `--json` — emit machine-readable JSON on stdout +- `--agent-friendly` — JSON output plus explanatory context for AI agents +- `--no-input` — never prompt; return a needs-input spec instead + +Environment equivalents: `$SPEECHIFY_API_KEY` (`--api-key`), `$SPEECHIFY_BASE_URL` +(`--base-url`), `$SPEECHIFY_API_VERSION` (`--api-version`). An explicit flag always +wins over the environment. diff --git a/docs/login.md b/docs/login.md new file mode 100644 index 0000000..ea8a7ad --- /dev/null +++ b/docs/login.md @@ -0,0 +1,13 @@ + + +# speechify login + +Validate a Speechify API key and store it for later commands. + +## Usage + +``` +speechify login [options] +``` + +Global options apply here too — see [global options](./global-options.md). diff --git a/docs/logout.md b/docs/logout.md new file mode 100644 index 0000000..728301f --- /dev/null +++ b/docs/logout.md @@ -0,0 +1,13 @@ + + +# speechify logout + +Forget the stored API key. + +## Usage + +``` +speechify logout [options] +``` + +Global options apply here too — see [global options](./global-options.md). diff --git a/docs/mcp-install.md b/docs/mcp-install.md new file mode 100644 index 0000000..2625a4a --- /dev/null +++ b/docs/mcp-install.md @@ -0,0 +1,20 @@ + + +# speechify mcp install + +Install the MCP relay into local AI clients (Claude Code, Cursor, Claude Desktop, …). + +## Usage + +``` +speechify mcp install [options] +``` + +## Options + +- `--client ` — client id(s): claude-code, cursor, claude-desktop, windsurf, vscode +- `--all` — install into every detected client +- `--print` — print the config block instead of writing it +- `--embed-key` — embed $SPEECHIFY_API_KEY in the client env (default: rely on the stored session) + +Global options apply here too — see [global options](./global-options.md). diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..e8ab619 --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,21 @@ + + +# speechify mcp + +Relay the local MCP client to Speechify's hosted MCP server over stdio, for AI agents. + +## Usage + +``` +speechify mcp [options] +``` + +## Options + +- `--url ` — upstream MCP endpoint to relay to (default: `https://mcp.speechify.ai/mcp`) + +## Subcommands + +- [speechify mcp install](./mcp-install.md) — Install the MCP relay into local AI clients (Claude Code, Cursor, Claude Desktop, …). + +Global options apply here too — see [global options](./global-options.md). diff --git a/docs/say.md b/docs/say.md new file mode 100644 index 0000000..694d3e0 --- /dev/null +++ b/docs/say.md @@ -0,0 +1,32 @@ + + +# speechify say + +Synthesize speech from text and save (or play) the audio. + +## Usage + +``` +speechify say [text] [options] +``` + +## Arguments + +- `[text]` — text (or SSML) to synthesize; omit to read from --input-file or stdin + +## Options + +- `-v, --voice ` — voice id (see `speechify voices list`) (default: `george`) +- `--model ` — synthesis model (choices: `simba-english`, `simba-multilingual`, `simba-3.0`, `simba-3.2`) +- `-f, --format ` — output audio format (choices: `wav`, `mp3`, `ogg`, `aac`, `pcm`; default: `mp3`) +- `--language ` — input language, e.g. en-US +- `-o, --out ` — output file (default ./speech.); "-" writes raw audio to stdout +- `--play` — play the audio after synthesis +- `--loudness-normalization` — normalize loudness to -14 LUFS +- `--no-text-normalization` — keep numbers/dates as written instead of spelled out +- `--input-file ` — read input text from a file +- `--stream` — stream the audio as it is generated (up to 20000 characters, lower time to first byte); wav is unavailable +- `--output-format ` — exact codec/sample rate/bitrate, e.g. pcm_16000 or mp3_24000_64 (--stream only; replaces --format) (choices: `pcm_8000`, `pcm_16000`, `pcm_22050`, `pcm_24000`, `pcm_44100`, `pcm_48000`, `mp3_22050_32`, `mp3_22050_64`, `mp3_22050_96`, `mp3_22050_128`, `mp3_22050_192`, `mp3_24000_32`, `mp3_24000_64`, `mp3_24000_96`, `mp3_24000_128`, `mp3_24000_192`, `ulaw_8000`, `ogg_24000`, `aac_24000`) +- `--force` — overwrite the default output file (speech.) if it already exists + +Global options apply here too — see [global options](./global-options.md). diff --git a/docs/voices-get.md b/docs/voices-get.md new file mode 100644 index 0000000..c94ea67 --- /dev/null +++ b/docs/voices-get.md @@ -0,0 +1,17 @@ + + +# speechify voices get + +Show one voice: its models, locales, tags, and preview URLs. + +## Usage + +``` +speechify voices get [voice-id] [options] +``` + +## Arguments + +- `[voice-id]` — id of the voice to show (see `speechify voices list`); omit to be prompted + +Global options apply here too — see [global options](./global-options.md). diff --git a/docs/voices-list.md b/docs/voices-list.md new file mode 100644 index 0000000..97049fb --- /dev/null +++ b/docs/voices-list.md @@ -0,0 +1,19 @@ + + +# speechify voices list + +List available voices (built-in and cloned). + +## Usage + +``` +speechify voices list [options] +``` + +## Options + +- `--locale ` — filter by locale prefix, e.g. "en" (all English) or "en-US" (exact) +- `--gender ` — filter by gender (choices: `male`, `female`, `not_specified`) +- `--search ` — case-insensitive match against voice id, name, and tags + +Global options apply here too — see [global options](./global-options.md). diff --git a/docs/voices.md b/docs/voices.md new file mode 100644 index 0000000..3f20b43 --- /dev/null +++ b/docs/voices.md @@ -0,0 +1,16 @@ + + +# speechify voices + +Voice catalog. + +## Usage + +``` +speechify voices [options] +``` + +## Subcommands + +- [speechify voices list](./voices-list.md) — List available voices (built-in and cloned). +- [speechify voices get](./voices-get.md) — Show one voice: its models, locales, tags, and preview URLs. diff --git a/docs/whoami.md b/docs/whoami.md new file mode 100644 index 0000000..942f417 --- /dev/null +++ b/docs/whoami.md @@ -0,0 +1,17 @@ + + +# speechify whoami + +Show how you're authenticated (flag / env / stored API key). + +## Usage + +``` +speechify whoami [options] +``` + +## Options + +- `--check` — verify the credential against the API (exits non-zero when invalid) + +Global options apply here too — see [global options](./global-options.md). diff --git a/llms-full.txt b/llms-full.txt new file mode 100644 index 0000000..1b7a24a --- /dev/null +++ b/llms-full.txt @@ -0,0 +1,226 @@ +# Speechify CLI — full reference + +> SpeechifyAI command-line companion for the Speechify API. + +Generated from the CLI definitions with `pnpm docs:generate`. Do not edit by hand. + +--- + +# Global options + +These flags are accepted by every `speechify` command (and its subcommands). + +- `--api-key ` — Speechify API key (overrides login / $SPEECHIFY_API_KEY) +- `--api-version ` — pin the Speechify-Version header (ISO date, e.g. 2026-06-27) +- `--base-url ` — override the API origin (defaults to $SPEECHIFY_BASE_URL or production) +- `--json` — emit machine-readable JSON on stdout +- `--agent-friendly` — JSON output plus explanatory context for AI agents +- `--no-input` — never prompt; return a needs-input spec instead + +Environment equivalents: `$SPEECHIFY_API_KEY` (`--api-key`), `$SPEECHIFY_BASE_URL` +(`--base-url`), `$SPEECHIFY_API_VERSION` (`--api-version`). An explicit flag always +wins over the environment. + +--- + +# speechify login + +Validate a Speechify API key and store it for later commands. + +## Usage + +``` +speechify login [options] +``` + +Global options apply here too — see [global options](./global-options.md). + +--- + +# speechify logout + +Forget the stored API key. + +## Usage + +``` +speechify logout [options] +``` + +Global options apply here too — see [global options](./global-options.md). + +--- + +# speechify whoami + +Show how you're authenticated (flag / env / stored API key). + +## Usage + +``` +speechify whoami [options] +``` + +## Options + +- `--check` — verify the credential against the API (exits non-zero when invalid) + +Global options apply here too — see [global options](./global-options.md). + +--- + +# speechify say + +Synthesize speech from text and save (or play) the audio. + +## Usage + +``` +speechify say [text] [options] +``` + +## Arguments + +- `[text]` — text (or SSML) to synthesize; omit to read from --input-file or stdin + +## Options + +- `-v, --voice ` — voice id (see `speechify voices list`) (default: `george`) +- `--model ` — synthesis model (choices: `simba-english`, `simba-multilingual`, `simba-3.0`, `simba-3.2`) +- `-f, --format ` — output audio format (choices: `wav`, `mp3`, `ogg`, `aac`, `pcm`; default: `mp3`) +- `--language ` — input language, e.g. en-US +- `-o, --out ` — output file (default ./speech.); "-" writes raw audio to stdout +- `--play` — play the audio after synthesis +- `--loudness-normalization` — normalize loudness to -14 LUFS +- `--no-text-normalization` — keep numbers/dates as written instead of spelled out +- `--input-file ` — read input text from a file +- `--stream` — stream the audio as it is generated (up to 20000 characters, lower time to first byte); wav is unavailable +- `--output-format ` — exact codec/sample rate/bitrate, e.g. pcm_16000 or mp3_24000_64 (--stream only; replaces --format) (choices: `pcm_8000`, `pcm_16000`, `pcm_22050`, `pcm_24000`, `pcm_44100`, `pcm_48000`, `mp3_22050_32`, `mp3_22050_64`, `mp3_22050_96`, `mp3_22050_128`, `mp3_22050_192`, `mp3_24000_32`, `mp3_24000_64`, `mp3_24000_96`, `mp3_24000_128`, `mp3_24000_192`, `ulaw_8000`, `ogg_24000`, `aac_24000`) +- `--force` — overwrite the default output file (speech.) if it already exists + +Global options apply here too — see [global options](./global-options.md). + +--- + +# speechify voices + +Voice catalog. + +## Usage + +``` +speechify voices [options] +``` + +## Subcommands + +- [speechify voices list](./voices-list.md) — List available voices (built-in and cloned). +- [speechify voices get](./voices-get.md) — Show one voice: its models, locales, tags, and preview URLs. + +--- + +# speechify voices list + +List available voices (built-in and cloned). + +## Usage + +``` +speechify voices list [options] +``` + +## Options + +- `--locale ` — filter by locale prefix, e.g. "en" (all English) or "en-US" (exact) +- `--gender ` — filter by gender (choices: `male`, `female`, `not_specified`) +- `--search ` — case-insensitive match against voice id, name, and tags + +Global options apply here too — see [global options](./global-options.md). + +--- + +# speechify voices get + +Show one voice: its models, locales, tags, and preview URLs. + +## Usage + +``` +speechify voices get [voice-id] [options] +``` + +## Arguments + +- `[voice-id]` — id of the voice to show (see `speechify voices list`); omit to be prompted + +Global options apply here too — see [global options](./global-options.md). + +--- + +# speechify api + +Authenticated raw request to any API endpoint (gh-api style). + +## Usage + +``` +speechify api [options] +``` + +## Arguments + +- `` — API path (e.g. v1/voices, leading slash optional) or a full https:// URL + +## Options + +- `-X, --method ` — HTTP method (default GET, or POST when a body is present) +- `-f, --field ` — body field key=value; repeatable, builds a JSON body (true/false/null and numbers become typed; use --data for literal strings) +- `-d, --data ` — raw request body; @file reads a file, - reads stdin +- `-q, --query ` — query parameter key=value; repeatable +- `-H, --header ` — extra header 'Key: Value'; repeatable +- `-i, --include` — include the response status line and headers in the output + +Global options apply here too — see [global options](./global-options.md). + +--- + +# speechify mcp + +Relay the local MCP client to Speechify's hosted MCP server over stdio, for AI agents. + +## Usage + +``` +speechify mcp [options] +``` + +## Options + +- `--url ` — upstream MCP endpoint to relay to (default: `https://mcp.speechify.ai/mcp`) + +## Subcommands + +- [speechify mcp install](./mcp-install.md) — Install the MCP relay into local AI clients (Claude Code, Cursor, Claude Desktop, …). + +Global options apply here too — see [global options](./global-options.md). + +--- + +# speechify mcp install + +Install the MCP relay into local AI clients (Claude Code, Cursor, Claude Desktop, …). + +## Usage + +``` +speechify mcp install [options] +``` + +## Options + +- `--client ` — client id(s): claude-code, cursor, claude-desktop, windsurf, vscode +- `--all` — install into every detected client +- `--print` — print the config block instead of writing it +- `--embed-key` — embed $SPEECHIFY_API_KEY in the client env (default: rely on the stored session) + +Global options apply here too — see [global options](./global-options.md). diff --git a/llms.txt b/llms.txt new file mode 100644 index 0000000..0720729 --- /dev/null +++ b/llms.txt @@ -0,0 +1,22 @@ +# Speechify CLI + +> SpeechifyAI command-line companion for the Speechify API. + +`speechify` is the command-line companion to the Speechify API — authenticate with +an API key, synthesize speech, manage voices, run raw API calls, and relay a local +MCP client to Speechify's hosted MCP server. + +## Commands + +- [speechify login](docs/login.md): Validate a Speechify API key and store it for later commands. +- [speechify logout](docs/logout.md): Forget the stored API key. +- [speechify whoami](docs/whoami.md): Show how you're authenticated (flag / env / stored API key). +- [speechify say](docs/say.md): Synthesize speech from text and save (or play) the audio. +- [speechify voices](docs/voices.md): Voice catalog. +- [speechify api](docs/api.md): Authenticated raw request to any API endpoint (gh-api style). +- [speechify mcp](docs/mcp.md): Relay the local MCP client to Speechify's hosted MCP server over stdio, for AI agents. + +## Reference + +- [Global options](docs/global-options.md): flags available on every command. +- [Full reference](llms-full.txt): every command and flag in one file. diff --git a/package.json b/package.json index bc6a9df..da3dfff 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "docs:generate": "WRITE_DOCS=1 vitest run src/docs/reference.test.ts", "format": "biome format --write .", "lint": "biome check .", "prepublishOnly": "pnpm build" diff --git a/src/bin.ts b/src/bin.ts index c1ef774..af812b9 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -1,69 +1,10 @@ -// Entry point. Assembles the commander program (auth, say, voices) and routes all +// Entry point. Builds the commander program (see program.ts) and routes all // failures through one normalizer so exit codes and the error shape are uniform. -import { Command } from "commander"; -import { registerApiCommand } from "./commands/api.js"; -import { registerAuthCommands } from "./commands/auth.js"; -import { registerMcpCommand } from "./commands/mcp.js"; -import { registerSayCommand } from "./commands/say.js"; -import { registerVoicesCommand } from "./commands/voices.js"; -import { CliError, ExitCode, NeedsInputError, normalizeError } from "./core/errors.js"; +import { NeedsInputError, normalizeError } from "./core/errors.js"; import { emitNeedsInput } from "./output.js"; +import { buildProgram } from "./program.js"; import { type OutputMode, outputMode } from "./runtime.js"; -// Global options available on every command. They're attached to the root *and* -// each (nested) subcommand so they show up in that subcommand's --help and parse -// before or after the subcommand name. `-- ` strings double as the -// long-flag lookup key (the substring before the first space). -const GLOBAL_OPTIONS: ReadonlyArray = [ - ["--api-key ", "Speechify API key (overrides login / $SPEECHIFY_API_KEY)"], - ["--api-version ", "pin the Speechify-Version header (ISO date, e.g. 2026-06-27)"], - ["--base-url ", "override the API origin (defaults to $SPEECHIFY_BASE_URL or production)"], - ["--json", "emit machine-readable JSON on stdout"], - ["--agent-friendly", "JSON output plus explanatory context for AI agents"], - ["--no-input", "never prompt; return a needs-input spec instead"], -]; - -/** Attach the global options to a command and all its subcommands (skips any a command already defines). */ -function applyGlobalOptions(cmd: Command): void { - for (const [flags, description] of GLOBAL_OPTIONS) { - const long = flags.split(" ", 1)[0]; - if (!cmd.options.some((option) => option.long === long)) cmd.option(flags, description); - } - for (const sub of cmd.commands) applyGlobalOptions(sub); -} - -function buildProgram(): Command { - const program = new Command(); - program - .name("speechify") - .description("SpeechifyAI command-line companion for the Speechify API.") - .version(__CLI_VERSION__, "-V, --version", "print the CLI version"); - - registerAuthCommands(program); - registerSayCommand(program); - registerVoicesCommand(program); - registerApiCommand(program); - registerMcpCommand(program); - - // After all commands exist, hang the globals off the whole tree. - applyGlobalOptions(program); - - // --json and --agent-friendly are contradictory output contracts (bare payload - // vs. wrapped envelope). Passing both is a mistake, not a silent precedence - // decision — reject it before any command runs. - program.hook("preAction", (_thisCommand, actionCommand) => { - const opts = actionCommand.optsWithGlobals() as { json?: boolean; agentFriendly?: boolean }; - if (opts.json && opts.agentFriendly) { - throw new CliError("Use either --json or --agent-friendly, not both.", { - exitCode: ExitCode.DATA_ERR, - code: "conflicting_output", - }); - } - }); - - return program; -} - function handleFatal(err: unknown, mode: OutputMode): never { // A missing required input isn't an error envelope — it's a structured spec the // caller (or agent) can act on. Render it and exit 2. diff --git a/src/commands/api.ts b/src/commands/api.ts index 5cfd8cc..a48c18b 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -139,7 +139,8 @@ export async function buildApiRequest(auth: AuthContext, endpoint: string, opts: export function registerApiCommand(program: Command): void { program - .command("api ") + .command("api") + .argument("", "API path (e.g. v1/voices, leading slash optional) or a full https:// URL") .description("Authenticated raw request to any API endpoint (gh-api style).") .option("-X, --method ", "HTTP method (default GET, or POST when a body is present)") .option( diff --git a/src/commands/say.ts b/src/commands/say.ts index d88580a..9a26d54 100644 --- a/src/commands/say.ts +++ b/src/commands/say.ts @@ -125,7 +125,8 @@ function assertSayFlags(opts: SayOptions, formatCameFromCli: boolean): void { export function registerSayCommand(program: Command): void { program - .command("say [text]") + .command("say") + .argument("[text]", "text (or SSML) to synthesize; omit to read from --input-file or stdin") .description("Synthesize speech from text and save (or play) the audio.") .option("-v, --voice ", "voice id (see `speechify voices list`)", DEFAULT_VOICE) .addOption(new Option("--model ", "synthesis model").choices([...SPEECH_MODELS])) diff --git a/src/commands/voices.ts b/src/commands/voices.ts index 3087969..7fd7454 100644 --- a/src/commands/voices.ts +++ b/src/commands/voices.ts @@ -103,7 +103,8 @@ export function registerVoicesCommand(program: Command): void { }); voices - .command("get [voice-id]") + .command("get") + .argument("[voice-id]", "id of the voice to show (see `speechify voices list`); omit to be prompted") .description("Show one voice: its models, locales, tags, and preview URLs.") .addHelpText("after", "\nExample:\n $ speechify voices get george\n $ speechify voices get george --json") .action(async (voiceIdArg: string | undefined, _options: unknown, command: Command) => { diff --git a/src/docs/reference.test.ts b/src/docs/reference.test.ts new file mode 100644 index 0000000..6f4944b --- /dev/null +++ b/src/docs/reference.test.ts @@ -0,0 +1,57 @@ +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { buildProgram } from "../program.js"; +import { renderDocs } from "./reference.js"; + +// src/docs/ → repo root is two levels up. +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const DOCS_DIR = join(ROOT, "docs"); +const WRITE = Boolean(process.env.WRITE_DOCS); + +const rendered = renderDocs(buildProgram()); + +async function readIfExists(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch { + return null; + } +} + +describe("agent docs reference", () => { + if (WRITE) { + // `pnpm docs:generate` runs this branch to (re)write the checked-in docs. + it("writes llms.txt, llms-full.txt and docs/*", async () => { + await mkdir(DOCS_DIR, { recursive: true }); + await writeFile(join(ROOT, "llms.txt"), rendered.index); + await writeFile(join(ROOT, "llms-full.txt"), rendered.full); + for (const [name, content] of rendered.files) { + await writeFile(join(DOCS_DIR, name), content); + } + expect(rendered.files.size).toBeGreaterThan(0); + }); + return; + } + + // Default: fail if the checked-in docs drift from what the CLI would generate. + it("llms.txt is up to date (run `pnpm docs:generate`)", async () => { + expect(await readIfExists(join(ROOT, "llms.txt"))).toBe(rendered.index); + }); + + it("llms-full.txt is up to date (run `pnpm docs:generate`)", async () => { + expect(await readIfExists(join(ROOT, "llms-full.txt"))).toBe(rendered.full); + }); + + for (const [name, content] of rendered.files) { + it(`docs/${name} is up to date (run \`pnpm docs:generate\`)`, async () => { + expect(await readIfExists(join(DOCS_DIR, name))).toBe(content); + }); + } + + it("docs/ has no orphaned files (run `pnpm docs:generate`)", async () => { + const present = (await readdir(DOCS_DIR)).filter((f) => f.endsWith(".md")).sort(); + expect(present).toEqual([...rendered.files.keys()].sort()); + }); +}); diff --git a/src/docs/reference.ts b/src/docs/reference.ts new file mode 100644 index 0000000..7f0bc41 --- /dev/null +++ b/src/docs/reference.ts @@ -0,0 +1,226 @@ +// Renders agent-discoverable documentation from the CLI's own commander tree, so +// the docs can never drift from the flags the binary actually accepts. Produces: +// - one markdown file per command under docs/ (plus docs/global-options.md) +// - llms.txt — the llms.txt-standard index of links +// - llms-full.txt — every command's reference concatenated into one file +// Output is deterministic (no dates, no version) so a checked-in copy can be +// diffed in CI. Regenerate with `pnpm docs:generate`. +import type { Argument, Command, Option } from "commander"; +import { GLOBAL_OPTION_LONGS, GLOBAL_OPTIONS } from "../program.js"; + +/** Banner stamped on every generated file so nobody hand-edits it. */ +const BANNER = ""; + +export interface RenderedDocs { + /** Contents of `llms.txt` (the index). */ + index: string; + /** Contents of `llms-full.txt` (everything inline). */ + full: string; + /** Per-file contents, keyed by path relative to `docs/` (e.g. `login.md`). */ + files: Map; +} + +interface DocCommand { + /** Path segments below the root, e.g. ["voices", "list"]. */ + path: string[]; + /** `docs/` filename, e.g. `voices-list.md`. */ + slug: string; + command: Command; + children: DocCommand[]; +} + +/** A commander-added option we never document (help/version) — filtered by long flag. */ +const INTRINSIC_LONGS = new Set(["--help", "--version"]); + +function isDocumentedOption(option: Option): boolean { + if (option.hidden) return false; + const long = option.long ?? option.short ?? ""; + if (INTRINSIC_LONGS.has(long)) return false; + return !GLOBAL_OPTION_LONGS.has(long); +} + +/** Command-specific (non-global, non-hidden) options, in declaration order. */ +function ownOptions(command: Command): Option[] { + return command.options.filter(isDocumentedOption); +} + +/** Walk the commander tree into a nested list of documentable commands (root excluded). */ +function collect(command: Command, parents: string[] = []): DocCommand[] { + return ( + command.commands + // Skip commander's implicit `help` command; everything else is ours. + .filter((sub) => sub.name() !== "help") + .map((sub) => { + const path = [...parents, sub.name()]; + return { + path, + slug: `${path.join("-")}.md`, + command: sub, + children: collect(sub, path), + }; + }) + ); +} + +/** Flatten the nested command list depth-first, preserving tree order. */ +function flatten(nodes: DocCommand[]): DocCommand[] { + return nodes.flatMap((node) => [node, ...flatten(node.children)]); +} + +function fullName(path: string[]): string { + return ["speechify", ...path].join(" "); +} + +function argToken(arg: Argument): string { + const name = `${arg.name()}${arg.variadic ? "..." : ""}`; + return arg.required ? `<${name}>` : `[${name}]`; +} + +function formatDefault(value: unknown): string { + if (Array.isArray(value)) return value.map((v) => `\`${String(v)}\``).join(", "); + return `\`${String(value)}\``; +} + +/** A default worth printing: skip `undefined` and the implicit `false` of a boolean flag. */ +function hasMeaningfulDefault(option: Option): boolean { + if (option.defaultValue === undefined) return Boolean(option.defaultValueDescription); + if (option.defaultValue === false) return false; + if (Array.isArray(option.defaultValue) && option.defaultValue.length === 0) return false; + return true; +} + +function optionLine(option: Option): string { + let line = `- \`${option.flags}\``; + if (option.description) line += ` — ${option.description}`; + + const notes: string[] = []; + if (option.mandatory) notes.push("required"); + const choices = (option as { argChoices?: string[] }).argChoices; + if (choices?.length) notes.push(`choices: ${choices.map((c) => `\`${c}\``).join(", ")}`); + if (hasMeaningfulDefault(option)) { + notes.push( + option.defaultValue === undefined && option.defaultValueDescription + ? `default: ${option.defaultValueDescription}` + : `default: ${formatDefault(option.defaultValue)}`, + ); + } + if (notes.length) line += ` (${notes.join("; ")})`; + return line; +} + +function argLine(arg: Argument): string { + let line = `- \`${argToken(arg)}\``; + if (arg.description) line += ` — ${arg.description}`; + if (arg.defaultValue !== undefined) line += ` (default: ${formatDefault(arg.defaultValue)})`; + return line; +} + +/** Render a single command's markdown page (without the leading banner). */ +function renderCommand(node: DocCommand): string { + const { command, path, children } = node; + const usageArgs = command.registeredArguments.map(argToken).join(" "); + const opts = ownOptions(command); + const runnable = command.registeredArguments.length > 0 || opts.length > 0 || children.length === 0; + + const lines: string[] = [`# ${fullName(path)}`, ""]; + if (command.description()) lines.push(command.description(), ""); + + lines.push("## Usage", "", "```", `${fullName(path)}${usageArgs ? ` ${usageArgs}` : ""} [options]`, "```", ""); + + if (command.registeredArguments.length) { + lines.push("## Arguments", "", ...command.registeredArguments.map(argLine), ""); + } + + if (opts.length) { + lines.push("## Options", "", ...opts.map(optionLine), ""); + } + + if (children.length) { + lines.push("## Subcommands", ""); + for (const child of children) { + const desc = child.command.description(); + lines.push(`- [${fullName(child.path)}](./${child.slug})${desc ? ` — ${desc}` : ""}`); + } + lines.push(""); + } + + if (runnable) { + lines.push("Global options apply here too — see [global options](./global-options.md).", ""); + } + + return lines.join("\n").trimEnd(); +} + +function renderGlobalOptions(): string { + const lines: string[] = [ + "# Global options", + "", + "These flags are accepted by every `speechify` command (and its subcommands).", + "", + ...GLOBAL_OPTIONS.map(([flags, description]) => `- \`${flags}\` — ${description}`), + "", + "Environment equivalents: `$SPEECHIFY_API_KEY` (`--api-key`), `$SPEECHIFY_BASE_URL`", + "(`--base-url`), `$SPEECHIFY_API_VERSION` (`--api-version`). An explicit flag always", + "wins over the environment.", + ]; + return lines.join("\n").trimEnd(); +} + +function withBanner(body: string): string { + return `${BANNER}\n\n${body}\n`; +} + +/** Build every documentation artifact from a commander program. Pure + deterministic. */ +export function renderDocs(program: Command): RenderedDocs { + const tree = collect(program); + const all = flatten(tree); + const files = new Map(); + + files.set("global-options.md", withBanner(renderGlobalOptions())); + for (const node of all) files.set(node.slug, withBanner(renderCommand(node))); + + const rootDescription = program.description() || "SpeechifyAI command-line companion for the Speechify API."; + + // llms.txt — the index of links (top-level commands, then reference). + const indexLines: string[] = [ + "# Speechify CLI", + "", + `> ${rootDescription}`, + "", + "`speechify` is the command-line companion to the Speechify API — authenticate with", + "an API key, synthesize speech, manage voices, run raw API calls, and relay a local", + "MCP client to Speechify's hosted MCP server.", + "", + "## Commands", + "", + ...tree.map((node) => { + const desc = node.command.description(); + return `- [${fullName(node.path)}](docs/${node.slug})${desc ? `: ${desc}` : ""}`; + }), + "", + "## Reference", + "", + "- [Global options](docs/global-options.md): flags available on every command.", + "- [Full reference](llms-full.txt): every command and flag in one file.", + ]; + const index = `${indexLines.join("\n").trimEnd()}\n`; + + // llms-full.txt — everything inline, in tree order. + const fullSections: string[] = [ + "# Speechify CLI — full reference", + "", + `> ${rootDescription}`, + "", + "Generated from the CLI definitions with `pnpm docs:generate`. Do not edit by hand.", + "", + "---", + "", + renderGlobalOptions(), + ]; + for (const node of all) { + fullSections.push("", "---", "", renderCommand(node)); + } + const full = `${fullSections.join("\n").trimEnd()}\n`; + + return { index, full, files }; +} diff --git a/src/program.ts b/src/program.ts new file mode 100644 index 0000000..8b2a2f1 --- /dev/null +++ b/src/program.ts @@ -0,0 +1,70 @@ +// Assembles the commander program (auth, say, voices, api, mcp) and the global +// options shared by every command. Kept free of side effects (no argv parsing, no +// process exit) so both the entry point (bin.ts) and the docs generator can build +// the same command tree and introspect it. +import { Command } from "commander"; +import { registerApiCommand } from "./commands/api.js"; +import { registerAuthCommands } from "./commands/auth.js"; +import { registerMcpCommand } from "./commands/mcp.js"; +import { registerSayCommand } from "./commands/say.js"; +import { registerVoicesCommand } from "./commands/voices.js"; +import { CliError, ExitCode } from "./core/errors.js"; + +// Global options available on every command. They're attached to the root *and* +// each (nested) subcommand so they show up in that subcommand's --help and parse +// before or after the subcommand name. `-- ` strings double as the +// long-flag lookup key (the substring before the first space). +export const GLOBAL_OPTIONS: ReadonlyArray = [ + ["--api-key ", "Speechify API key (overrides login / $SPEECHIFY_API_KEY)"], + ["--api-version ", "pin the Speechify-Version header (ISO date, e.g. 2026-06-27)"], + ["--base-url ", "override the API origin (defaults to $SPEECHIFY_BASE_URL or production)"], + ["--json", "emit machine-readable JSON on stdout"], + ["--agent-friendly", "JSON output plus explanatory context for AI agents"], + ["--no-input", "never prompt; return a needs-input spec instead"], +]; + +/** Long flags (the token before any ` `) of the global options, for lookups. */ +export const GLOBAL_OPTION_LONGS: ReadonlySet = new Set( + GLOBAL_OPTIONS.map(([flags]) => flags.split(" ")[0] ?? flags), +); + +/** Attach the global options to a command and all its subcommands (skips any a command already defines). */ +function applyGlobalOptions(cmd: Command): void { + for (const [flags, description] of GLOBAL_OPTIONS) { + const long = flags.split(" ", 1)[0]; + if (!cmd.options.some((option) => option.long === long)) cmd.option(flags, description); + } + for (const sub of cmd.commands) applyGlobalOptions(sub); +} + +export function buildProgram(): Command { + const program = new Command(); + program + .name("speechify") + .description("SpeechifyAI command-line companion for the Speechify API.") + .version(__CLI_VERSION__, "-V, --version", "print the CLI version"); + + registerAuthCommands(program); + registerSayCommand(program); + registerVoicesCommand(program); + registerApiCommand(program); + registerMcpCommand(program); + + // After all commands exist, hang the globals off the whole tree. + applyGlobalOptions(program); + + // --json and --agent-friendly are contradictory output contracts (bare payload + // vs. wrapped envelope). Passing both is a mistake, not a silent precedence + // decision — reject it before any command runs. + program.hook("preAction", (_thisCommand, actionCommand) => { + const opts = actionCommand.optsWithGlobals() as { json?: boolean; agentFriendly?: boolean }; + if (opts.json && opts.agentFriendly) { + throw new CliError("Use either --json or --agent-friendly, not both.", { + exitCode: ExitCode.DATA_ERR, + code: "conflicting_output", + }); + } + }); + + return program; +}