@drakulavich/kesha-voice-kit 2.0.0 → 2.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,9 +11,9 @@
11
11
  <a href="https://bun.sh"><img src="https://img.shields.io/badge/runtime-Bun-f9f1e1?logo=bun" alt="Bun"></a>
12
12
  </p>
13
13
 
14
- <p align="center"><b>Give your local tools and LLM agents a voice.</b><br>Fast speech-to-text, text-to-speech, voice-activity detection, and language detection in one local-first CLI — CoreML on Apple Silicon, ONNX on Linux and Windows.</p>
14
+ <p align="center"><b>Kesha listens and talks back.</b><br>Your agent gets a voice message, Kesha turns it into text on your machine, and speaks the reply once you add <code>kesha install --tts</code> (<a href="docs/use-cases.md#sending-voice-replies">voice replies</a>). Fastest on Apple Silicon with CoreML, and it runs on Linux and Windows through ONNX.</p>
15
15
 
16
- - **Transcribe locally** — [25 languages](docs/languages.md#speech-to-text-25), up to ~19x faster than Whisper on Apple Silicon, ~2.5x on CPU
16
+ - **Transcribe locally** — [25 languages](docs/languages.md#speech-to-text-25), up to ~19x faster than Whisper on Apple Silicon, ~10x on an x86 CPU
17
17
  - **Speak back** — text-to-speech in [9 languages](docs/languages.md#text-to-speech)
18
18
  - **Plug into agents** — ship voice workflows as CLI commands, an MCP server, an <a href="docs/openclaw.md">OpenClaw</a> skill, or a <a href="docs/hermes.md">Hermes</a> agent
19
19
  - **Small Rust engine** — single ~65MB binary, no ffmpeg, no Python, no native Node addons
@@ -24,7 +24,7 @@
24
24
 
25
25
  ## Quick Start
26
26
 
27
- Runtime: **[Bun](https://bun.sh)** >= 1.3.0.
27
+ Runtime: **[Bun](https://bun.sh)** >= 1.4.0.
28
28
 
29
29
  ```bash
30
30
  # 1. Install Bun (skip if you have it)
@@ -64,6 +64,14 @@ All three targets transcribe, detect the spoken language, run VAD, and speak. Th
64
64
 
65
65
  Intel Macs get no published engine binary. Full matrix with maturity labels: [docs/product-positioning.md](docs/product-positioning.md#platform-matrix).
66
66
 
67
+ ## Plug into your agent
68
+
69
+ - **MCP server** — `kesha mcp` exposes transcribe/synthesize/list tools to any MCP client (Claude, Cursor, Codex, Gemini). Setup: [docs/mcp.md](docs/mcp.md).
70
+ - **OpenClaw** — give your LLM agent ears. Install & config: [docs/openclaw.md](docs/openclaw.md).
71
+ - **Hermes Agent** — local STT/TTS through Hermes command providers. Setup: [docs/hermes.md](docs/hermes.md).
72
+ - **Raycast** (macOS) — offline microphone dictation from the launcher: *Dictate to Clipboard* records with a live signal meter, auto-stops on silence, transcribes locally, and copies the text. [Install from the Raycast Store](https://www.raycast.com/drakulavich/kesha-voice-kit) · source: [`raycast/`](raycast/).
73
+ - **Programmatic API** — `@drakulavich/kesha-voice-kit/core` for use inside a Bun program. See [docs/api.md](docs/api.md).
74
+
67
75
  ## Speech-to-text
68
76
 
69
77
  ```bash
@@ -126,13 +134,13 @@ kesha say "Hello" --format flac --out hi.flac # FLAC — lossless, plays in
126
134
 
127
135
  ## Performance
128
136
 
129
- > **Up to ~19x faster than Whisper** on Apple Silicon (M2), **~2.5x faster** on CPU
137
+ > **Up to ~19x faster than Whisper** on Apple Silicon (M2), **~10x faster** on an x86 Linux CPU (~2.5x faster than faster-whisper)
130
138
 
131
139
  Compared against Whisper `large-v3-turbo`, all engines auto-detecting language:
132
140
 
133
141
  ![Benchmark: openai-whisper vs faster-whisper vs Kesha Voice Kit](https://github.com/drakulavich/kesha-voice-kit/raw/main/docs/assets/benchmark.svg)
134
142
 
135
- Full per-file breakdown (Russian + English): [BENCHMARK.md](BENCHMARK.md). The CPU figure is the ONNX engine on an M2's CPU cores; no x86 numbers are published yet.
143
+ Full per-file breakdown (Russian + English): [BENCHMARK.md](BENCHMARK.md). The chart is from an M2. The x86 figure comes from a GitHub-hosted `ubuntu-latest` runner (AMD EPYC, ONNX on CPU), where openai-whisper runs in FP32.
136
144
 
137
145
  ## Other install methods
138
146
 
@@ -144,14 +152,6 @@ All of these install the Bun CLI wrapper; engine + models still download explici
144
152
  - **Nix** (`aarch64-darwin` / `x86_64-linux`) — builds the engine from source (`nix build github:drakulavich/kesha-voice-kit#kesha-engine`). The full `kesha` CLI via `nix run` / `nix profile install` is **not yet available** — it needs a maintainer with Nix to populate a build hash ([#946](https://github.com/drakulavich/kesha-voice-kit/issues/946)). · [docs/distribution.md](docs/distribution.md#nix)
145
153
  - **Shell completions + manpage** — `kesha completions bash|zsh|fish` and `kesha manpage` print the packaged files to install wherever your shell expects them.
146
154
 
147
- ## Integrations
148
-
149
- - **MCP server** — `kesha mcp` exposes transcribe/synthesize/list tools to any MCP client (Claude, Cursor, Codex, Gemini). Setup: [docs/mcp.md](docs/mcp.md).
150
- - **OpenClaw** — give your LLM agent ears. Install & config: [docs/openclaw.md](docs/openclaw.md).
151
- - **Hermes Agent** — local STT/TTS through Hermes command providers. Setup: [docs/hermes.md](docs/hermes.md).
152
- - **Raycast** (macOS) — offline microphone dictation from the launcher: *Dictate to Clipboard* records with a live signal meter, auto-stops on silence, transcribes locally, and copies the text. [Install from the Raycast Store](https://www.raycast.com/drakulavich/kesha-voice-kit) · source: [`raycast/`](raycast/).
153
- - **Programmatic API** — `@drakulavich/kesha-voice-kit/core` for use inside a Bun program. See [docs/api.md](docs/api.md).
154
-
155
155
  ## More
156
156
 
157
157
  - [Architecture](docs/architecture.md) — runtime data flow, the models that ship, the CLI ↔ Rust engine boundary, model pinning, and where tests live.
@@ -1,6 +1,6 @@
1
1
  # Product positioning
2
2
 
3
- Kesha Voice Kit is a local-first voice toolkit for developers and agent workflows. It is built for small, scriptable jobs where a CLI can turn audio into text, text into audio, or voice messages into structured data without sending content to a hosted API.
3
+ Kesha listens and talks back: an agent gets a voice message, Kesha turns it into text locally, and speaks the reply. It is a local-first voice toolkit for developers and agent workflows, and Apple Silicon is where it is fastest and most complete. It is built for small, scriptable jobs where a CLI can turn audio into text, text into audio, or voice messages into structured data without sending content to a hosted API.
4
4
 
5
5
  The product promise is not "one model for every audio problem." The promise is a boring, automatable local voice stack with clear platform limits, predictable install paths, and machine-readable output.
6
6
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@drakulavich/kesha-voice-kit",
3
- "version": "2.0.0",
4
- "description": "Give your tools a voice — speech to text and back, 25 languages, up to ~19× faster than Whisper. On your machine.",
3
+ "version": "2.2.0-beta.1",
4
+ "description": "Kesha listens and talks back: local speech to text and text to speech for agents and scripts, fastest on Apple Silicon.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "kesha": "bin/kesha.js"
@@ -54,7 +54,7 @@
54
54
  "check": "bun run lint && bun run check:versions && bun run check:recipes && bun run test:cli-fast",
55
55
  "check:workflows": "bun .github/scripts/check-workflows.ts",
56
56
  "check:recipes": "bun .github/scripts/check-recipes.ts",
57
- "check:specs": "bunx @fission-ai/openspec@1.4.1 validate --specs --strict",
57
+ "check:specs": "openspec validate --specs --strict",
58
58
  "check:release-manifest": "bun .github/scripts/release-manifest.mjs --check",
59
59
  "check:versions": "bun .github/scripts/check-versions.ts",
60
60
  "check:engine-targets": "bun .github/scripts/check-engine-targets.ts",
@@ -83,9 +83,10 @@
83
83
  "url": "https://github.com/drakulavich/kesha-voice-kit/issues"
84
84
  },
85
85
  "engines": {
86
- "bun": ">=1.3.0"
86
+ "bun": ">=1.4.0"
87
87
  },
88
88
  "devDependencies": {
89
+ "@fission-ai/openspec": "1.14.1",
89
90
  "@types/bun": "latest",
90
91
  "fast-xml-parser": "^5.8.0",
91
92
  "typescript": "^7.0.2",
@@ -93,7 +94,7 @@
93
94
  },
94
95
  "dependencies": {
95
96
  "@clack/prompts": "^1.5.0",
96
- "@modelcontextprotocol/sdk": "^1.29.0",
97
+ "@modelcontextprotocol/sdk": "^1.32.1",
97
98
  "@toon-format/toon": "^4.1.0",
98
99
  "citty": "^0.2.2",
99
100
  "fastest-levenshtein": "^1.0.16",
@@ -109,19 +110,19 @@
109
110
  },
110
111
  "kesha": {
111
112
  "engine": {
112
- "version": "2.0.0",
113
+ "version": "2.2.0-beta.1",
113
114
  "sha256": {
114
- "kesha-engine-darwin-arm64": "2d75bc773fb81917a0dd996124c0e18eea3b15ecc8f04fe274d41c68bda9276c",
115
- "kesha-engine-linux-x64": "09082a1f3eb07d8f8051806a057669f098856d84541f087a1e614ca80d8235b2",
116
- "kesha-engine-windows-x64.exe": "de86f350bfd3d076cf339d99d892605b1a37a99085e8ff4c8ef87057618193f3",
117
- "say-avspeech-darwin-arm64": "a3aa5b75ddff68a48f1158e838310b37cac72ef86841d9cfcdf1ee233cb7451a",
115
+ "kesha-engine-darwin-arm64": "ccdd35b2433950175a6e3791ac3264969a732ea005fae4642b32842b2eb3443b",
116
+ "kesha-engine-linux-x64": "626fd32be46645e312ae3ad4cdf15838d0c4f54d56bdc67ec6d8f2a017813db8",
117
+ "kesha-engine-windows-x64.exe": "b38bc35bc2d0304f324da02b1d2857e86baf3c69e5351e5df2619c1c3a38889a",
118
+ "say-avspeech-darwin-arm64": "3a7e8b8fac92ce4ce0c34f736098a685dcb745f741db8303e1721e320ac73a98",
118
119
  "kesha-textlang-darwin-arm64": "e45e2822248b14dd72aa79e2def8fa6b9c07c97423f11c841508769225d72da4"
119
120
  },
120
121
  "size": {
121
- "kesha-engine-darwin-arm64": 73623152,
122
- "kesha-engine-linux-x64": 67392296,
123
- "kesha-engine-windows-x64.exe": 66550272,
124
- "say-avspeech-darwin-arm64": 64208,
122
+ "kesha-engine-darwin-arm64": 73639712,
123
+ "kesha-engine-linux-x64": 67404584,
124
+ "kesha-engine-windows-x64.exe": 66583040,
125
+ "say-avspeech-darwin-arm64": 82992,
125
126
  "kesha-textlang-darwin-arm64": 57648
126
127
  }
127
128
  }
@@ -1,10 +1,12 @@
1
- import { runMain, type ArgsDef, type CommandDef } from "citty";
1
+ import { renderUsage, runMain, type ArgsDef, type CommandDef } from "citty";
2
2
  import { existsSync } from "fs";
3
- import { log } from "../log";
3
+ import { stripVTControlCharacters } from "util";
4
+ import { isColorEnabled, log } from "../log";
5
+ import { packageVersion } from "../package-info";
4
6
  import { guardStdoutWrites } from "../stdout-pipe";
5
7
  import { suggestCommand } from "../suggest-command";
6
8
  import { applyCliContext, resolveCliContext } from "./context";
7
- import { rejectUnknownOptions, renderInvalidArg } from "./options";
9
+ import { rejectExtraPositionals, rejectUnknownOptions, renderInvalidArg } from "./options";
8
10
 
9
11
  // Lazy loaders so a cold CLI spawn transpiles only the invoked command, not the whole graph (#568).
10
12
  // `CommandDef<any>`: citty's generic is invariant in the arg shape and each command has its own schema.
@@ -87,7 +89,12 @@ export function unknownCommandMessages(token: string, subcommandKeys: string[]):
87
89
  if (suggestCommand(token, ["transcribe"]) === "transcribe") {
88
90
  warnLines.push("To transcribe, pass the audio path directly: kesha ./recording.ogg");
89
91
  }
90
- return { errorLine: `unknown command '${token}'`, warnLines };
92
+ return { errorLine: renderInvalidArg(`unknown command '${token}'`), warnLines };
93
+ }
94
+
95
+ async function showUsage<T extends ArgsDef>(cmd: CommandDef<T>, parent?: CommandDef<T>): Promise<void> {
96
+ const usage = await renderUsage(cmd, parent);
97
+ console.log((isColorEnabled() ? usage : stripVTControlCharacters(usage)) + "\n");
91
98
  }
92
99
 
93
100
  async function resolveArgsDef(command: CommandDef<any>): Promise<ArgsDef> {
@@ -121,9 +128,15 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
121
128
 
122
129
  switch (classifyFirstArg(firstArg, subcommandKeys)) {
123
130
  case "subcommand": {
131
+ if (restArgs.length === 1 && (restArgs[0] === "--version" || restArgs[0] === "-v")) {
132
+ process.stdout.write(`${packageVersion}\n`);
133
+ return;
134
+ }
124
135
  const command = await SUBCOMMANDS[firstArg!]!();
125
- rejectUnknownOptions(restArgs, await resolveArgsDef(command));
126
- await runMain(command, { rawArgs: restArgs });
136
+ const argsDef = await resolveArgsDef(command);
137
+ rejectUnknownOptions(restArgs, argsDef);
138
+ if (firstArg !== "install" && firstArg !== "say") rejectExtraPositionals(restArgs, argsDef);
139
+ await runMain(command, { rawArgs: restArgs, showUsage });
127
140
  return;
128
141
  }
129
142
 
@@ -132,7 +145,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
132
145
  const { errorLine, warnLines } = unknownCommandMessages(firstArg!, subcommandKeys);
133
146
  log.error(errorLine);
134
147
  for (const line of warnLines) log.warn(line);
135
- process.exit(1);
148
+ process.exit(2);
136
149
  break;
137
150
  }
138
151
 
@@ -142,10 +155,15 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
142
155
  log.error(renderInvalidArg(`put global flags after the subcommand: kesha ${misplaced} ...`));
143
156
  process.exit(2);
144
157
  }
145
- const { createMainCommand } = await import("./main");
158
+ const { briefMainUsage, createMainCommand } = await import("./main");
146
159
  const command = createMainCommand(context);
147
- rejectUnknownOptions(rawArgs, await resolveArgsDef(command));
148
- await runMain(command, { rawArgs });
160
+ const help = rawArgs.includes("--help") || rawArgs.includes("-h");
161
+ rejectUnknownOptions(help ? rawArgs.filter((arg) => arg !== "--all") : rawArgs, await resolveArgsDef(command));
162
+ if (help) {
163
+ await showUsage(rawArgs.includes("--all") ? command : briefMainUsage(command));
164
+ return;
165
+ }
166
+ await runMain(command, { rawArgs, showUsage });
149
167
  }
150
168
  }
151
169
  }
package/src/cli/init.ts CHANGED
@@ -3,7 +3,7 @@ import { confirm, multiselect, isCancel, cancel } from "@clack/prompts";
3
3
  import { installCommandTokens, renderInstallPlan } from "../install-plan";
4
4
  import { log } from "../log";
5
5
  import { errorMessage } from "../error-utils";
6
- import { exitCodeFor } from "../engine/events";
6
+ import { exitCodeFor, KeshaError } from "../engine/events";
7
7
  import { getEngineCapabilities } from "../engine";
8
8
  import {
9
9
  installableTtsLangs,
@@ -98,30 +98,6 @@ export function initInstallArgs(selection: InitSelection): string[] {
98
98
  return installCommandTokens(selection, selection.ttsLangs);
99
99
  }
100
100
 
101
- export function initSuggestionCommands(
102
- selection: InitSelection,
103
- canDiarize = canInstallDiarizeOnPlatform(),
104
- ): string[][] {
105
- const variants: InitSelection[] = [
106
- selection,
107
- { ...selection, ttsLangs: [], vad: true, diarize: false },
108
- { ...selection, ttsLangs: ["en"], vad: true, diarize: false },
109
- ];
110
-
111
- // #768: --diarize installs VAD on its own, so no explicit --vad here.
112
- if (canDiarize) {
113
- variants.push({ ...selection, ttsLangs: [], vad: false, diarize: true });
114
- }
115
-
116
- const seen = new Set<string>();
117
- return variants.map(initInstallArgs).filter((command) => {
118
- const key = command.join("\0");
119
- if (seen.has(key)) return false;
120
- seen.add(key);
121
- return true;
122
- });
123
- }
124
-
125
101
  export function omitUnsupportedDiarize(
126
102
  selection: InitSelection,
127
103
  canDiarize = canInstallDiarizeOnPlatform(),
@@ -176,14 +152,7 @@ export async function promptInitSelection(
176
152
  };
177
153
  }
178
154
 
179
- /** Mirrors the guard in `performInstall`: a preview must not describe an install this platform rejects (#684). */
180
- async function printPlan(selection: InitSelection): Promise<boolean> {
181
- const backendError = unavailableBackendRefusal(selection.backend);
182
- if (backendError) {
183
- log.error(errorMessage(backendError));
184
- process.exitCode = exitCodeFor(backendError);
185
- return false;
186
- }
155
+ async function printPlan(selection: InitSelection): Promise<void> {
187
156
  log.info(
188
157
  await renderInstallPlan({
189
158
  noCache: selection.noCache,
@@ -193,21 +162,6 @@ async function printPlan(selection: InitSelection): Promise<boolean> {
193
162
  diarize: selection.diarize,
194
163
  }),
195
164
  );
196
- return true;
197
- }
198
-
199
- async function runNonInteractive(selection: InitSelection): Promise<void> {
200
- const canDiarize = canInstallDiarizeOnPlatform();
201
- const printableSelection = omitUnsupportedDiarize(selection, canDiarize);
202
- if (selection.diarize && !canDiarize) {
203
- log.warn("--diarize is currently darwin-arm64 only; omitting it from non-interactive examples.");
204
- }
205
- log.info(renderInitOverview(canDiarize));
206
- if (!(await printPlan(printableSelection))) return;
207
- log.info("Run one of these commands from an interactive terminal:");
208
- for (const command of initSuggestionCommands(printableSelection, canDiarize)) {
209
- log.info(` ${command.join(" ")}`);
210
- }
211
165
  }
212
166
 
213
167
  export const initCommand = defineCommand({
@@ -262,6 +216,13 @@ export const initCommand = defineCommand({
262
216
  const noCache = resolveNoCacheFlag(args, rawArgs);
263
217
  const selection = resolveInitSelection(args, backend, noCache);
264
218
 
219
+ const backendError = args.yes && !args.plan ? null : unavailableBackendRefusal(backend);
220
+ if (backendError) {
221
+ log.error(errorMessage(backendError));
222
+ process.exitCode = exitCodeFor(backendError);
223
+ return;
224
+ }
225
+
265
226
  if (args.plan) {
266
227
  log.info(renderInitOverview());
267
228
  await printPlan(selection);
@@ -280,7 +241,11 @@ export const initCommand = defineCommand({
280
241
  const stdinIsTty = process.stdin.isTTY === true;
281
242
  const stdoutIsTty = process.stdout.isTTY === true;
282
243
  if (!stdinIsTty || !stdoutIsTty) {
283
- await runNonInteractive(selection);
244
+ const noTerminal = new KeshaError("E_INVALID_ARG", "kesha init is interactive and needs a terminal", {
245
+ hint: "run `kesha init --yes` to install the defaults, or `kesha init --plan` to show the plan",
246
+ });
247
+ log.error(errorMessage(noTerminal));
248
+ process.exitCode = exitCodeFor(noTerminal);
284
249
  return;
285
250
  }
286
251
 
@@ -293,7 +258,7 @@ export const initCommand = defineCommand({
293
258
  noCache,
294
259
  );
295
260
  log.info("");
296
- if (!(await printPlan(prompted))) return;
261
+ await printPlan(prompted);
297
262
  const confirmed = await promptConfirm(
298
263
  `Run \`${initInstallArgs(prompted).join(" ")}\` now?`,
299
264
  true,
@@ -246,7 +246,7 @@ export async function performInstall(options: PerformInstallOptions) {
246
246
  throw backendError;
247
247
  }
248
248
  await installEngine({ noCache, backend, ttsLangs, vad, diarize, version: engineVersion });
249
- await maybeAskForStar(getEngineBinPath(), packageVersion, log);
249
+ if (!log.quietEnabled) await maybeAskForStar(getEngineBinPath(), packageVersion, { info: log.progress, warn: log.warn });
250
250
  finishInstallDiagnostic(diagnosticLog, startedAt, "success");
251
251
  } catch (err: unknown) {
252
252
  const signalExitCode = getPendingSignalExitCode();
package/src/cli/logs.ts CHANGED
@@ -23,6 +23,9 @@ export function runLogsAction(args: LogsCommandArgs): ActionResult {
23
23
  if (args.json && action !== "status") {
24
24
  return { ok: false, error: "usage: kesha logs status --json" };
25
25
  }
26
+ if (args.value !== undefined && action !== "mode" && SUPPORTED_ACTIONS.split(", ").includes(action)) {
27
+ return { ok: false, error: `unexpected argument '${args.value}' (kesha logs ${action} takes no value)` };
28
+ }
26
29
  switch (action) {
27
30
  case "enable": {
28
31
  const status = setDiagnosticLogMode("on");
package/src/cli/main.ts CHANGED
@@ -1,10 +1,13 @@
1
- import { defineCommand } from "citty";
1
+ import { defineCommand, type ArgsDef, type CommandDef } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
3
  import { existsSync } from "fs";
4
4
  import { isDirectoryPath, transcribeWithSegments, validateTranscribeRequest } from "../transcribe";
5
5
 
6
6
  export { isDirectoryPath };
7
- import { detectAudioLanguageEngine, detectTextLanguageEngine } from "../engine";
7
+ import { detectAudioLanguageEngine, detectTextLanguageEngine, isEngineInstalled } from "../engine";
8
+ import { renderInstallPlan } from "../install-plan";
9
+ import { canInstallDiarizeOnPlatform, promptConfirm } from "./init";
10
+ import { performInstall } from "./install";
8
11
  import type { LangDetectResult } from "../engine";
9
12
  import { log } from "../log";
10
13
  import type { TextLangDetectResult, TranscribeErrorRecord, TranscribeResult } from "../types";
@@ -409,7 +412,8 @@ async function processFile(
409
412
  error_code: code,
410
413
  });
411
414
  log.error(`${file}: ${stderrText}`);
412
- return { ok: false, error: { file, code, message: stderrText }, exitCode: failureExitCode(err) };
415
+ const message = err instanceof Error ? err.message : String(err);
416
+ return { ok: false, error: { file, code, message }, exitCode: failureExitCode(err) };
413
417
  }
414
418
  }
415
419
 
@@ -452,13 +456,41 @@ export const MAIN_VAD_ARGS = {
452
456
  },
453
457
  } as const;
454
458
 
459
+ const BRIEF_HELP_DESCRIPTION =
460
+ "Kesha Voice Kit listens and talks back: local speech for agents and scripts.\n" +
461
+ "Try: kesha audio.ogg, or kesha say \"hello\"\n" +
462
+ "\n" +
463
+ "Commands:\n" +
464
+ " init Interactive setup guide.\n" +
465
+ " install Download engine and models.\n" +
466
+ " say Synthesize speech from text.\n" +
467
+ " record Record microphone audio.\n" +
468
+ " status Inspect installed backend.\n" +
469
+ " doctor Collect support diagnostics.\n" +
470
+ " mcp Run an MCP server over stdio.\n" +
471
+ "\n" +
472
+ "All commands and flags: kesha --help --all";
473
+
474
+ const BRIEF_HELP_ARGS: ArgsDef = {
475
+ json: { type: "boolean", description: "Output results as JSON" },
476
+ format: { type: "string", description: "transcript | json | toon" },
477
+ lang: { type: "string", description: "Expected language code, e.g. en" },
478
+ timestamps: { type: "boolean", description: "Timestamped segments in JSON/TOON" },
479
+ quiet: { type: "boolean", alias: "q", description: "Print only results and errors" },
480
+ };
481
+
482
+ /** What plain `kesha --help` renders; parsing still uses every flag of createMainCommand. */
483
+ export function briefMainUsage(command: CommandDef<any>): CommandDef<any> {
484
+ return { ...command, meta: { ...command.meta, description: BRIEF_HELP_DESCRIPTION }, args: BRIEF_HELP_ARGS };
485
+ }
486
+
455
487
  export function createMainCommand(context: CliContext = { quiet: false, disableColor: false }) {
456
488
  return defineCommand({
457
489
  meta: {
458
490
  name: "kesha",
459
491
  version: packageVersion,
460
492
  description:
461
- "Kesha Voice Kit — open-source voice toolkit for Apple Silicon.\n" +
493
+ "Kesha Voice Kit listens and talks back: local speech for agents and scripts.\n" +
462
494
  "\n" +
463
495
  "Examples:\n" +
464
496
  " kesha audio.ogg Transcribe an audio file.\n" +
@@ -572,6 +604,19 @@ export function createMainCommand(context: CliContext = { quiet: false, disableC
572
604
  process.exit(2);
573
605
  }
574
606
 
607
+ if (
608
+ !isEngineInstalled() &&
609
+ process.stdin.isTTY && process.stdout.isTTY && process.stderr.isTTY &&
610
+ files.every((f) => existsSync(f) && !isDirectoryPath(f)) &&
611
+ (!args.speakers || canInstallDiarizeOnPlatform())
612
+ ) {
613
+ const modules = { vad: vadMode === "on", diarize: Boolean(args.speakers) };
614
+ log.info(await renderInstallPlan(modules));
615
+ if (await promptConfirm("Kesha needs these downloads before it can transcribe. Download them now?", true)) {
616
+ await performInstall({ noCache: false, ttsLangs: [], ...modules });
617
+ }
618
+ }
619
+
575
620
  const wantsLangId = !!(args.lang || args.verbose || outputFormat !== "text");
576
621
  const reportProgress = shouldReportTranscribeProgress({
577
622
  stderrIsTty: process.stderr.isTTY === true,
@@ -17,6 +17,10 @@ function camelCase(name: string): string {
17
17
 
18
18
  /** The first flag `argsDef` does not declare, or null; citty parses non-strict, so the flag would otherwise vanish and its value become a positional (S9-F3). */
19
19
  export function findUnknownOption(rawArgs: string[], argsDef: ArgsDef): UnknownOption | null {
20
+ return scanArgs(rawArgs, argsDef).unknown;
21
+ }
22
+
23
+ function scanArgs(rawArgs: string[], argsDef: ArgsDef): { unknown: UnknownOption | null; positionals: string[] } {
20
24
  const long = new Set(RUNNER_LONG);
21
25
  const short = new Set(RUNNER_SHORT);
22
26
  const valued = new Set<string>();
@@ -41,23 +45,30 @@ export function findUnknownOption(rawArgs: string[], argsDef: ArgsDef): UnknownO
41
45
  const match = suggestCommand(name, suggestable);
42
46
  return match === null ? null : `--${match}`;
43
47
  };
48
+ const positionals: string[] = [];
44
49
  for (let i = 0; i < rawArgs.length; i++) {
45
50
  const arg = rawArgs[i]!;
46
- if (arg === "--") break;
47
- if (!arg.startsWith("-") || arg === "-" || /^-\d/.test(arg)) continue;
51
+ if (arg === "--") {
52
+ positionals.push(...rawArgs.slice(i + 1));
53
+ break;
54
+ }
55
+ if (!arg.startsWith("-") || arg === "-" || /^-\d/.test(arg)) {
56
+ positionals.push(arg);
57
+ continue;
58
+ }
48
59
  if (arg.startsWith("--")) {
49
60
  const eq = arg.indexOf("=");
50
61
  const name = eq === -1 ? arg.slice(2) : arg.slice(2, eq);
51
- if (!long.has(name)) return { name: `--${name}`, suggestion: suggest(name) };
62
+ if (!long.has(name)) return { unknown: { name: `--${name}`, suggestion: suggest(name) }, positionals };
52
63
  if (eq === -1 && valued.has(name)) i++;
53
64
  continue;
54
65
  }
55
66
  for (const letter of arg.slice(1)) {
56
- if (!short.has(letter)) return { name: `-${letter}`, suggestion: null };
67
+ if (!short.has(letter)) return { unknown: { name: `-${letter}`, suggestion: null }, positionals };
57
68
  }
58
69
  if (valued.has(arg[arg.length - 1]!)) i++;
59
70
  }
60
- return null;
71
+ return { unknown: null, positionals };
61
72
  }
62
73
 
63
74
  /** The coded line for a usage error the CLI refuses before doing anything (S9-F2). */
@@ -77,3 +88,13 @@ export function rejectUnknownOptions(rawArgs: string[], argsDef: ArgsDef): void
77
88
  log.error(renderUnknownOption(unknown));
78
89
  process.exit(2);
79
90
  }
91
+
92
+ /** Exits 2 with one coded stderr line when `rawArgs` carries more positionals than the command declares. */
93
+ export function rejectExtraPositionals(rawArgs: string[], argsDef: ArgsDef): void {
94
+ const declared = Object.values(argsDef).filter((def) => def.type === "positional").length;
95
+ const extra = scanArgs(rawArgs, argsDef).positionals[declared];
96
+ if (extra === undefined) return;
97
+ const tail = "output" in argsDef ? ` (did you mean --output ${extra}?)` : "";
98
+ log.error(renderInvalidArg(`unexpected argument '${extra}'${tail}`));
99
+ process.exit(2);
100
+ }
package/src/cli/say.ts CHANGED
@@ -358,8 +358,8 @@ export const sayCommand = defineCommand({
358
358
  log.error(errorMessage(err));
359
359
  process.exit(exitCodeFor(err));
360
360
  }
361
- await Bun.write(Bun.stdout, ids.map((id) => `${id}\n`).join(""));
362
- process.exit(0);
361
+ process.stdout.write(ids.map((id) => `${id}\n`).join(""));
362
+ return;
363
363
  }
364
364
 
365
365
  const flags = resolveSayFlags(args);
@@ -374,23 +374,26 @@ export const sayCommand = defineCommand({
374
374
  process.exit(2);
375
375
  }
376
376
 
377
- const inlineText = typeof args.text === "string" ? args.text : undefined;
377
+ const positionals = (args._ ?? []).map(String);
378
+ const inlineText =
379
+ positionals.length > 0 ? positionals.join(" ") : typeof args.text === "string" ? args.text : undefined;
378
380
  const stdinIsTty = (process.stdin as { isTTY?: boolean }).isTTY;
379
381
  if (shouldRejectMissingSayText(inlineText, stdinIsTty)) {
380
382
  log.error(renderInvalidArg("kesha say requires text or piped stdin. Usage: kesha say <text>"));
381
383
  process.exit(2);
382
384
  }
385
+ const explicitVoice = flags.voice;
386
+ const langHint = flags.lang;
383
387
  let text: string;
388
+ let voice: string | undefined;
384
389
  try {
385
390
  text = await resolveText(inlineText);
386
391
  validateSayText(text);
392
+ voice = await resolveSayVoice(explicitVoice, langHint, text);
387
393
  } catch (err) {
388
394
  log.error(errorMessage(err));
389
395
  process.exit(exitCodeFor(err));
390
396
  }
391
- const explicitVoice = flags.voice;
392
- const langHint = flags.lang;
393
- const voice = await resolveSayVoice(explicitVoice, langHint, text);
394
397
 
395
398
  const opts: SayOpts = {
396
399
  text,
package/src/cli/stats.ts CHANGED
@@ -26,6 +26,14 @@ const SUPPORTED_ACTIONS = "enable, disable, status, week, errors, export, reset,
26
26
  /** Runs one `kesha stats` action and reports what to print; all output happens in the caller. */
27
27
  export function runStatsAction(args: StatsCommandArgs): ActionResult {
28
28
  const action = args.action ?? "status";
29
+ if (
30
+ args.value !== undefined &&
31
+ action !== "export" &&
32
+ action !== "retention" &&
33
+ SUPPORTED_ACTIONS.split(", ").includes(action)
34
+ ) {
35
+ return { ok: false, error: `unexpected argument '${args.value}' (kesha stats ${action} takes no value)` };
36
+ }
29
37
  switch (action) {
30
38
  case "enable": {
31
39
  enableStats();
@@ -1,5 +1,6 @@
1
1
  import { defineCommand } from "citty";
2
2
  import { errorMessage } from "../error-utils";
3
+ import { exitCodeFor, KeshaError } from "../engine/events";
3
4
  import { createSupportBundle } from "../support-bundle";
4
5
  import { log } from "../log";
5
6
 
@@ -26,16 +27,25 @@ export const supportBundleCommand = defineCommand({
26
27
  },
27
28
  async run({ args }: { args: SupportBundleCommandArgs }) {
28
29
  try {
30
+ if (args.output === "") {
31
+ throw new KeshaError("E_INVALID_ARG", "--output needs a file path", { hint: "pass --output path.tar.gz." });
32
+ }
33
+ if (args.output === "-") {
34
+ throw new KeshaError("E_INVALID_ARG", "--output - is not a file path", {
35
+ hint: "to write the archive to stdout, pass --output /dev/stdout.",
36
+ });
37
+ }
29
38
  const bundle = await createSupportBundle({
30
39
  output: args.output,
31
40
  includeLogs: Boolean(args["include-logs"]),
32
41
  });
33
- log.status(`Created support bundle: ${bundle.path}`);
42
+ log.notice(`Created support bundle: ${bundle.path}`);
34
43
  log.status(`Entries: ${bundle.entries.length}`);
35
44
  log.status(`Size: ${bundle.sizeBytes} bytes`);
36
45
  } catch (err) {
46
+ if ((err as NodeJS.ErrnoException).code === "EPIPE") return;
37
47
  log.error(errorMessage(err));
38
- process.exit(1);
48
+ process.exit(err instanceof KeshaError ? exitCodeFor(err) : 1);
39
49
  }
40
50
  },
41
51
  });
package/src/cli.ts CHANGED
@@ -9,7 +9,6 @@ export {
9
9
  canInstallDiarizeOnPlatform,
10
10
  initCommand,
11
11
  initInstallArgs,
12
- initSuggestionCommands,
13
12
  omitUnsupportedDiarize,
14
13
  promptInitSelection,
15
14
  renderInitOverview,
package/src/doctor.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  NOT_FUNCTIONAL_STATE,
11
11
  probeExecutable,
12
12
  } from "./engine-health";
13
- import { readInstalledEngineVersion } from "./engine-version-marker";
13
+ import { MIN_ENGINE_VERSION, predatesMinimumEngine, readInstalledEngineVersion } from "./engine-version-marker";
14
14
  import { keshaCacheDir } from "./paths";
15
15
  import { installedVoiceIds } from "./voice-inventory";
16
16
  import { engineVersion, packageName, packageVersion } from "./package-info";
@@ -85,13 +85,14 @@ type DoctorDiagnosticLogStatus = DiagnosticLogStatus & { error?: string };
85
85
  * is a supported state (`kesha install --engine-version`), not a fault — `unrecorded` is
86
86
  * separate so a missing marker is never reported as a difference.
87
87
  */
88
- export type EngineVersionState = "matches-pin" | "differs-from-pin" | "unrecorded";
88
+ export type EngineVersionState = "matches-pin" | "differs-from-pin" | "unsupported" | "unrecorded";
89
89
 
90
90
  export function engineVersionState(
91
91
  versionMarker: string | null,
92
92
  pinnedVersion: string,
93
93
  ): EngineVersionState {
94
94
  if (versionMarker === null) return "unrecorded";
95
+ if (predatesMinimumEngine(versionMarker)) return "unsupported";
95
96
  return versionMarker === pinnedVersion ? "matches-pin" : "differs-from-pin";
96
97
  }
97
98
 
@@ -477,6 +478,11 @@ function formatVersionState(engine: DoctorReport["engine"]): string {
477
478
  `installed v${engine.versionMarker} differs from the pinned v${engine.pinnedVersion} — ` +
478
479
  "supported; `kesha install` without --engine-version restores the pin"
479
480
  );
481
+ case "unsupported":
482
+ return (
483
+ `installed v${engine.versionMarker} is older than v${MIN_ENGINE_VERSION}, ` +
484
+ "the oldest engine this CLI supports; run `kesha install`"
485
+ );
480
486
  case "unrecorded":
481
487
  return engine.installed
482
488
  ? "no recorded version beside the binary; `kesha install` records one"